Merge branch 'feat/fix-launch-dry-run-disk-side-effect-dryr'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
3789cb7e61
11 files changed
+633
-205
No files matched your search
@@ -210,7 +210,9 @@ versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 父版本 + 主
|
|||||||
[--max-memory <MB>]
|
[--max-memory <MB>]
|
||||||
|
|
||||||
<instance> 可以是实例 id、实例名,或直接是版本 id。
|
<instance> 可以是实例 id、实例名,或直接是版本 id。
|
||||||
--dry-run(D18):只组装命令行并打印,不 spawn 进程、不写会话文件、不刷新令牌。
|
--dry-run(D18):只组装命令行并打印,不 spawn 进程、不写会话文件、不刷新令牌,
|
||||||
|
也不碰版本树(不解压 natives、不建 natives 目录,连缺的目录也不补);启动器自身的
|
||||||
|
运行日志(<数据根>/logs/launcher.log)照记,这一点与真实启动一致。
|
||||||
账户:--account <name> 指定账户(玩家名或条目 id);不给时用唯一账户,一个都没有则回落
|
账户:--account <name> 指定账户(玩家名或条目 id);不给时用唯一账户,一个都没有则回落
|
||||||
离线占位(--username 或 Player),有多个账户会报错并列出候选。
|
离线占位(--username 或 Player),有多个账户会报错并列出候选。
|
||||||
accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
|
accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 阶段 B 说明:diagnose(M15)尚未合并,诊断走 [`DiagnosisHook`] 接缝,默认 [`NoopDiagnosis`]。
|
//! 阶段 B 说明:diagnose(M15)尚未合并,诊断走 [`DiagnosisHook`] 接缝,默认 [`NoopDiagnosis`]。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ use crate::launch::args_table::{assemble, AssembledCommand};
|
|||||||
use crate::launch::diagnosis_hook::{DiagnosisHook, NoopDiagnosis};
|
use crate::launch::diagnosis_hook::{DiagnosisHook, NoopDiagnosis};
|
||||||
use crate::launch::error::{AssemblyError, Result};
|
use crate::launch::error::{AssemblyError, Result};
|
||||||
use crate::launch::launch_session::{LaunchSession, SessionHandle};
|
use crate::launch::launch_session::{LaunchSession, SessionHandle};
|
||||||
use crate::launch::natives::{self, NativesReport};
|
use crate::launch::natives::{self, NativesError, NativesPlan, NativesReport};
|
||||||
use crate::launch::platform::PlatformCtx;
|
use crate::launch::platform::PlatformCtx;
|
||||||
use crate::launch::process::{ProcessConfig, RunningProcess};
|
use crate::launch::process::{ProcessConfig, RunningProcess};
|
||||||
use crate::launch::redact::Redactor;
|
use crate::launch::redact::Redactor;
|
||||||
@@ -91,10 +91,13 @@ impl LauncherConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件**。
|
/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件、不碰磁盘**。
|
||||||
///
|
///
|
||||||
/// 会做的事与真实启动**一致到「环境准备」为止**:natives 解压照做(幂等,`natives` 字段
|
/// 「不碰磁盘」是本层(core 启动管线)的承诺:环境准备(第 6 段)在 dry-run 下走
|
||||||
/// 回报结果),因为「装完能不能起」的一半答案就在「native 库有没有落地」上。
|
/// [`crate::launch::natives::preview`],只算「会解出几个原生库 / natives 目录是不是
|
||||||
|
/// 已经就绪」,**不建目录、不写文件、不删文件**——对版本树(尤其是别人铺的
|
||||||
|
/// `versions/<id>/`)是纯读的。调用方自己的运行日志(CLI 的 `<数据根>/logs/launcher.log`)
|
||||||
|
/// 不在此列。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DryRun {
|
pub struct DryRun {
|
||||||
/// 实际要执行的程序。
|
/// 实际要执行的程序。
|
||||||
@@ -109,7 +112,9 @@ pub struct DryRun {
|
|||||||
pub classpath_entries: usize,
|
pub classpath_entries: usize,
|
||||||
/// 日志解码编码。
|
/// 日志解码编码。
|
||||||
pub encoding: String,
|
pub encoding: String,
|
||||||
/// natives 解压结果(没有 natives 目录、即请求未配置时为 `None`)。
|
/// natives 预览结果(没有 natives 目录、即请求未配置时为 `None`)。
|
||||||
|
///
|
||||||
|
/// `planned == true`:`files` 是「真启动会写下」的数量,盘上零改动。
|
||||||
pub natives: Option<NativesReport>,
|
pub natives: Option<NativesReport>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,15 +166,32 @@ impl Launcher {
|
|||||||
assemble(request, &self.config.platform)
|
assemble(request, &self.config.platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 环境准备(管线第 6 段):natives 解压。
|
/// 环境准备(管线第 6 段):natives 解压(**落盘**,真启动专用)。
|
||||||
///
|
///
|
||||||
/// 目标目录取 `request.dirs.natives_dir`(与 `${natives_directory}` 同一真相源);
|
/// 目标目录取 `request.dirs.natives_dir`(与 `${natives_directory}` 同一真相源);
|
||||||
/// 目录为空视为调用方不需要,返回 `Ok(None)`。
|
/// 目录为空视为调用方不需要,返回 `Ok(None)`。
|
||||||
fn prepare_environment(&self, request: &LaunchRequest) -> Result<Option<NativesReport>> {
|
fn prepare_environment(&self, request: &LaunchRequest) -> Result<Option<NativesReport>> {
|
||||||
|
self.environment(request, natives::extract)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 环境准备的第 6 段预览版:只算「会解出什么」,**零落盘**(`--dry-run` 专用)。
|
||||||
|
fn preview_environment(&self, request: &LaunchRequest) -> Result<Option<NativesReport>> {
|
||||||
|
self.environment(request, natives::preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两个入口共用的外壳:空 `natives_dir` = 调用方不需要,失败统一折成组装错误。
|
||||||
|
///
|
||||||
|
/// `run` 是 [`natives::extract`](落盘)或 [`natives::preview`](零落盘);
|
||||||
|
/// 返回值类型要写全 `std::result::Result`——本模块的 `Result` 别名固定带 `LaunchError`。
|
||||||
|
fn environment(
|
||||||
|
&self,
|
||||||
|
request: &LaunchRequest,
|
||||||
|
run: fn(&Path, &NativesPlan) -> std::result::Result<NativesReport, NativesError>,
|
||||||
|
) -> Result<Option<NativesReport>> {
|
||||||
if request.dirs.natives_dir.as_os_str().is_empty() {
|
if request.dirs.natives_dir.as_os_str().is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
natives::extract(&request.dirs.natives_dir, &request.natives)
|
run(&request.dirs.natives_dir, &request.natives)
|
||||||
.map(Some)
|
.map(Some)
|
||||||
.map_err(|err| AssemblyError::NativesExtraction {
|
.map_err(|err| AssemblyError::NativesExtraction {
|
||||||
message: err.message(),
|
message: err.message(),
|
||||||
@@ -177,12 +199,13 @@ impl Launcher {
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// D18:组装完整命令行并返回,**不 spawn 进程、不写会话文件**。
|
/// D18:组装完整命令行并返回,**不 spawn 进程、不写会话文件、不落盘**。
|
||||||
///
|
///
|
||||||
/// 供 `unlucky-launcher --dry-run <instance>` 打印一行命令行后退出;
|
/// 供 `unlucky-launcher --dry-run <instance>` 打印一行命令行后退出;
|
||||||
|
/// natives 走 [`natives::preview`](不建目录、不写文件),所以对版本树是纯读的;
|
||||||
/// accessToken 已脱敏(绝不把凭据打到终端/日志里)。
|
/// accessToken 已脱敏(绝不把凭据打到终端/日志里)。
|
||||||
pub fn dry_run(&self, request: &LaunchRequest) -> Result<DryRun> {
|
pub fn dry_run(&self, request: &LaunchRequest) -> Result<DryRun> {
|
||||||
let natives = self.prepare_environment(request)?;
|
let natives = self.preview_environment(request)?;
|
||||||
let command = self.plan(request)?;
|
let command = self.plan(request)?;
|
||||||
let redactor = self.redactor(request);
|
let redactor = self.redactor(request);
|
||||||
let warnings = command
|
let warnings = command
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//!
|
//!
|
||||||
//! # 公共面
|
//! # 公共面
|
||||||
//!
|
//!
|
||||||
//! - [`Launcher`]:`dry_run`(D18,只组装命令行)、`spawn`/`launch`(完整管线);
|
//! - [`Launcher`]:`dry_run`(D18,只组装命令行、零落盘)、`spawn`/`launch`(完整管线);
|
||||||
//! - [`LaunchRequest`] 是命令行组装的**纯数据输入**,由 [`adapter`] 从 game 快照映射而来;
|
//! - [`LaunchRequest`] 是命令行组装的**纯数据输入**,由 [`adapter`] 从 game 快照映射而来;
|
||||||
//! - [`AssembledCommand`] 是组装结果(program + argv + classpath + 环境变量 + 告警);
|
//! - [`AssembledCommand`] 是组装结果(program + argv + classpath + 环境变量 + 告警);
|
||||||
//! - 事件流([`session`])与索引([`session_index`])是 M15 诊断采集器与
|
//! - 事件流([`session`])与索引([`session_index`])是 M15 诊断采集器与
|
||||||
|
|||||||
@@ -68,26 +68,33 @@ pub(crate) fn io_error(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解压单个 jar:排除前缀 → 原生库白名单 → zip-slip 防护 → 落盘。
|
/// 打开一个 natives jar(打不开 / 不是合法 zip 都以 [`NativesError`] 带路径报出)。
|
||||||
pub(crate) fn extract_jar(
|
fn open_archive(native: &NativeJar) -> Result<ZipArchive<File>, NativesError> {
|
||||||
native: &NativeJar,
|
|
||||||
dir: &Path,
|
|
||||||
report: &mut NativesReport,
|
|
||||||
) -> Result<(), NativesError> {
|
|
||||||
let file = File::open(&native.path)
|
let file = File::open(&native.path)
|
||||||
.map_err(|source| io_error("open_native_jar", &native.path, source))?;
|
.map_err(|source| io_error("open_native_jar", &native.path, source))?;
|
||||||
let mut archive = ZipArchive::new(file).map_err(|err| NativesError::Zip {
|
ZipArchive::new(file).map_err(|err| NativesError::Zip {
|
||||||
path: native.path.clone(),
|
path: native.path.clone(),
|
||||||
reason: err.to_string(),
|
reason: err.to_string(),
|
||||||
})?;
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 该落盘的条目(zip 索引 + 相对路径):排除前缀 → 原生库白名单 → zip-slip 防护。
|
||||||
|
///
|
||||||
|
/// 只读遍历,不解压:`extract_jar` 拿它算落点,`count_jar` 拿它清点数量,
|
||||||
|
/// 两边共用同一套筛选口径(预览与真解压不会给出不同的数字)。
|
||||||
|
fn extractable_entries(
|
||||||
|
archive: &mut ZipArchive<File>,
|
||||||
|
native: &NativeJar,
|
||||||
|
report: &mut NativesReport,
|
||||||
|
) -> Result<Vec<(usize, PathBuf)>, NativesError> {
|
||||||
let excludes: Vec<&str> = DEFAULT_EXCLUDES
|
let excludes: Vec<&str> = DEFAULT_EXCLUDES
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
.chain(native.exclude.iter().map(String::as_str))
|
.chain(native.exclude.iter().map(String::as_str))
|
||||||
.collect();
|
.collect();
|
||||||
|
let mut entries = Vec::new();
|
||||||
for index in 0..archive.len() {
|
for index in 0..archive.len() {
|
||||||
let mut entry = archive.by_index(index).map_err(|err| NativesError::Zip {
|
let entry = archive.by_index(index).map_err(|err| NativesError::Zip {
|
||||||
path: native.path.clone(),
|
path: native.path.clone(),
|
||||||
reason: err.to_string(),
|
reason: err.to_string(),
|
||||||
})?;
|
})?;
|
||||||
@@ -104,6 +111,23 @@ pub(crate) fn extract_jar(
|
|||||||
report.ignored += 1;
|
report.ignored += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
entries.push((index, relative));
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解压单个 jar:`extractable_entries` 选条目,再逐个落到 `dir` 下。
|
||||||
|
pub(crate) fn extract_jar(
|
||||||
|
native: &NativeJar,
|
||||||
|
dir: &Path,
|
||||||
|
report: &mut NativesReport,
|
||||||
|
) -> Result<(), NativesError> {
|
||||||
|
let mut archive = open_archive(native)?;
|
||||||
|
for (index, relative) in extractable_entries(&mut archive, native, report)? {
|
||||||
|
let mut entry = archive.by_index(index).map_err(|err| NativesError::Zip {
|
||||||
|
path: native.path.clone(),
|
||||||
|
reason: err.to_string(),
|
||||||
|
})?;
|
||||||
let dest = dir.join(&relative);
|
let dest = dir.join(&relative);
|
||||||
if let Some(parent) = dest.parent() {
|
if let Some(parent) = dest.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
@@ -118,6 +142,20 @@ pub(crate) fn extract_jar(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 只读清点单个 jar:只算「真解压会写下几个原生库」,一个字节都不落盘。
|
||||||
|
///
|
||||||
|
/// 与 [`extract_jar`] 的差别只有「写」这一步;jar 打不开照样报错,所以预览不会把
|
||||||
|
/// 真启动会遇到的坏包藏起来。
|
||||||
|
pub(crate) fn count_jar(
|
||||||
|
native: &NativeJar,
|
||||||
|
report: &mut NativesReport,
|
||||||
|
) -> Result<(), NativesError> {
|
||||||
|
let mut archive = open_archive(native)?;
|
||||||
|
let entries = extractable_entries(&mut archive, native, report)?;
|
||||||
|
report.files += entries.len();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 该条目要不要解压:排除前缀命中、签名文件、非原生库扩展名 → 跳过。
|
/// 该条目要不要解压:排除前缀命中、签名文件、非原生库扩展名 → 跳过。
|
||||||
pub(crate) fn should_extract(name: &str, excludes: &[&str]) -> bool {
|
pub(crate) fn should_extract(name: &str, excludes: &[&str]) -> bool {
|
||||||
if excludes.iter().any(|prefix| name.starts_with(prefix)) {
|
if excludes.iter().any(|prefix| name.starts_with(prefix)) {
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
//!
|
//!
|
||||||
//! 这是 10 阶段管线里**第 6 段「环境准备」**的落盘部分(`PlanDoc/06` §1),发生顺序在
|
//! 这是 10 阶段管线里**第 6 段「环境准备」**的落盘部分(`PlanDoc/06` §1),发生顺序在
|
||||||
//! 命令行组装之前(HMCL 也在组装期做:`DefaultLauncher.decompressNatives`,
|
//! 命令行组装之前(HMCL 也在组装期做:`DefaultLauncher.decompressNatives`,
|
||||||
//! `analyse/03` §6.4-(7)),因此 `--dry-run` 也能验证「装完到底能不能起」。
|
//! `analyse/03` §6.4-(7))。
|
||||||
|
//!
|
||||||
|
//! # 两条路径
|
||||||
|
//!
|
||||||
|
//! - [`extract`]:真启动走这条,把原生库落到 `java.library.path`;
|
||||||
|
//! - [`preview`]:`--dry-run` 走这条,**零落盘**(不建目录、不写文件、不删文件),
|
||||||
|
//! 只回答「真启动会解出几个文件 / 目录是不是已经就绪」。断言口径与 [`extract`]
|
||||||
|
//! 共用(同一套条目筛选、同一份戳记),所以预览能当「装完能不能起」的依据,
|
||||||
|
//! 又不会在别人的版本树里留下 `natives/` 目录或改掉父目录 mtime。
|
||||||
//!
|
//!
|
||||||
//! # 三个时代(`SubAgentsTalk/launch-natives-解压与时代分界.md`,piston-meta 实测)
|
//! # 三个时代(`SubAgentsTalk/launch-natives-解压与时代分界.md`,piston-meta 实测)
|
||||||
//!
|
//!
|
||||||
@@ -24,6 +32,8 @@
|
|||||||
//! 混在同一个 `java.library.path` 里(HMCL 也是每次清目录,这里只把「没变就不动」补上)。
|
//! 混在同一个 `java.library.path` 里(HMCL 也是每次清目录,这里只把「没变就不动」补上)。
|
||||||
|
|
||||||
pub(crate) mod jar;
|
pub(crate) mod jar;
|
||||||
|
/// 报告与错误类型(`mod.rs` 只留计划与两个入口)。
|
||||||
|
pub(crate) mod report;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -164,115 +174,8 @@ impl NativesPlan {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解压结果(进 `natives_extracted` 事件与 `--dry-run` 输出)。
|
/// 解压结果 / 预览结果与失败原因单独成文件(`mod.rs` 只留计划与两个入口)。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
pub use report::{NativesError, NativesReport};
|
||||||
pub struct NativesReport {
|
|
||||||
/// 目标目录。
|
|
||||||
pub dir: PathBuf,
|
|
||||||
/// 时代。
|
|
||||||
pub era: NativesEra,
|
|
||||||
/// 参与的 jar 数。
|
|
||||||
pub jars: usize,
|
|
||||||
/// 本次写盘的原生库文件数。
|
|
||||||
pub files: usize,
|
|
||||||
/// 按戳记跳过、未重新解压的文件数(= 上次解压的文件数)。
|
|
||||||
pub skipped: usize,
|
|
||||||
/// 被排除 / 非原生库而忽略的条目数(`META-INF/`、签名文件)。
|
|
||||||
pub ignored: usize,
|
|
||||||
/// 目录是否用户自定义。
|
|
||||||
pub custom: bool,
|
|
||||||
/// 是否按戳记判定「目录已就绪」而整体跳过。
|
|
||||||
pub up_to_date: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NativesReport {
|
|
||||||
/// 人读一行(CLI / 日志用)。
|
|
||||||
pub fn summary(&self) -> String {
|
|
||||||
if self.jars == 0 {
|
|
||||||
return format!(
|
|
||||||
"natives 目录已就绪(该版本无需解压,LWJGL 运行期自取)→ {}",
|
|
||||||
self.dir.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if self.up_to_date {
|
|
||||||
return format!(
|
|
||||||
"natives 已就绪({} 个原生库,{} 个 jar 未变)",
|
|
||||||
self.skipped, self.jars
|
|
||||||
);
|
|
||||||
}
|
|
||||||
format!(
|
|
||||||
"natives 解压 {} 个原生库({} 个 jar,忽略 {} 个条目)→ {}",
|
|
||||||
self.files,
|
|
||||||
self.jars,
|
|
||||||
self.ignored,
|
|
||||||
self.dir.display()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--json` 的 `natives` 块(`ull launch --dry-run` 用;字段名是接口的一部分)。
|
|
||||||
#[must_use]
|
|
||||||
pub fn to_json(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"dir": self.dir.display().to_string(),
|
|
||||||
"files": self.files,
|
|
||||||
"skipped": self.skipped,
|
|
||||||
"ignored": self.ignored,
|
|
||||||
"jars": self.jars,
|
|
||||||
"custom": self.custom,
|
|
||||||
"up_to_date": self.up_to_date,
|
|
||||||
"era": self.era.as_str(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解压失败原因。
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum NativesError {
|
|
||||||
/// 目标目录为空(调用方漏填 `natives_dir`)。
|
|
||||||
NoDir,
|
|
||||||
/// 文件系统操作失败。
|
|
||||||
Io {
|
|
||||||
/// 操作名。
|
|
||||||
op: &'static str,
|
|
||||||
/// 相关路径。
|
|
||||||
path: PathBuf,
|
|
||||||
/// 底层错误。
|
|
||||||
source: std::io::Error,
|
|
||||||
},
|
|
||||||
/// natives jar 打不开 / 不是合法 zip(下载损坏的最常见形态)。
|
|
||||||
Zip {
|
|
||||||
/// jar 路径。
|
|
||||||
path: PathBuf,
|
|
||||||
/// 原因。
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NativesError {
|
|
||||||
/// 一行人读描述。
|
|
||||||
pub fn message(&self) -> String {
|
|
||||||
match self {
|
|
||||||
Self::NoDir => "natives 目录未配置(natives_dir 为空)".to_string(),
|
|
||||||
Self::Io { op, path, source } => {
|
|
||||||
format!("{op} 失败({}):{source}", path.display())
|
|
||||||
}
|
|
||||||
Self::Zip { path, reason } => {
|
|
||||||
format!(
|
|
||||||
"natives jar 无法作为 zip 打开({}):{reason}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for NativesError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str(&self.message())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for NativesError {}
|
|
||||||
|
|
||||||
/// 从版本链库表收集 natives 分类器构件(adapter / app / CLI 共用这一段,别各写一份)。
|
/// 从版本链库表收集 natives 分类器构件(adapter / app / CLI 共用这一段,别各写一份)。
|
||||||
///
|
///
|
||||||
@@ -324,8 +227,38 @@ pub fn collect(
|
|||||||
natives
|
natives
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 目录状态的三种结论:两个入口共用一条判定链,别各写一份。
|
||||||
|
enum DirStatus {
|
||||||
|
/// 无需解压:1.19+ 没有 natives 分类器 jar,只要目录在。
|
||||||
|
NoJars,
|
||||||
|
/// 戳记与当前 jar 集一致:目录已就绪,上次解出 `usize` 个原生库。
|
||||||
|
Ready(usize),
|
||||||
|
/// 需要(重新)解压。
|
||||||
|
Stale,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判定链 + 本次源 jar 指纹(真解压拿它写戳记,预览丢弃)。
|
||||||
|
///
|
||||||
|
/// `extract` 与 `preview` **只在这里**决定「要不要解压」:改判定只改这一处,
|
||||||
|
/// 两边不会漂。
|
||||||
|
fn inspect(dir: &Path, plan: &NativesPlan) -> (DirStatus, Vec<jar::JarStamp>) {
|
||||||
|
let stamps = jar_stamps(&plan.jars);
|
||||||
|
if plan.jars.is_empty() {
|
||||||
|
return (DirStatus::NoJars, stamps);
|
||||||
|
}
|
||||||
|
let ready = read_stamp(dir)
|
||||||
|
.filter(|stamp| stamp.matches(&stamps))
|
||||||
|
.map(|stamp| stamp.files());
|
||||||
|
match ready {
|
||||||
|
Some(files) => (DirStatus::Ready(files), stamps),
|
||||||
|
None => (DirStatus::Stale, stamps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 解压一个计划(幂等:目录与源 jar 都没变时零写盘)。
|
/// 解压一个计划(幂等:目录与源 jar 都没变时零写盘)。
|
||||||
///
|
///
|
||||||
|
/// `--dry-run` 不走这里,走 [`preview`]:那里一个字节都不写。
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// 目录为空、建目录 / 写文件失败、jar 不是合法 zip 时返回 [`NativesError`]。
|
/// 目录为空、建目录 / 写文件失败、jar 不是合法 zip 时返回 [`NativesError`]。
|
||||||
@@ -342,22 +275,25 @@ pub fn extract(dir: &Path, plan: &NativesPlan) -> Result<NativesReport, NativesE
|
|||||||
ignored: 0,
|
ignored: 0,
|
||||||
custom: plan.custom,
|
custom: plan.custom,
|
||||||
up_to_date: false,
|
up_to_date: false,
|
||||||
|
planned: false,
|
||||||
|
dir_exists: false,
|
||||||
};
|
};
|
||||||
// 1.19+ 没有 jar 也要建目录:LWJGL 3.3 往 SharedLibraryExtractPath 里解压。
|
// 1.19+ 没有 jar 也要建目录:LWJGL 3.3 往 SharedLibraryExtractPath 里解压。
|
||||||
std::fs::create_dir_all(dir)
|
std::fs::create_dir_all(dir)
|
||||||
.map_err(|source| jar::io_error("create_natives_dir", dir, source))?;
|
.map_err(|source| jar::io_error("create_natives_dir", dir, source))?;
|
||||||
if plan.jars.is_empty() {
|
report.dir_exists = true;
|
||||||
report.up_to_date = true;
|
let (status, stamps) = inspect(dir, plan);
|
||||||
return Ok(report);
|
match status {
|
||||||
}
|
DirStatus::NoJars => {
|
||||||
|
|
||||||
let stamps = jar_stamps(&plan.jars);
|
|
||||||
if let Some(stamp) = read_stamp(dir) {
|
|
||||||
if stamp.matches(&stamps) {
|
|
||||||
report.up_to_date = true;
|
report.up_to_date = true;
|
||||||
report.skipped = stamp.files();
|
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
|
DirStatus::Ready(files) => {
|
||||||
|
report.up_to_date = true;
|
||||||
|
report.skipped = files;
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
DirStatus::Stale => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 源变了:先清旧原生库(避免两代 dll 混在同一个 java.library.path 里),再全量解。
|
// 源变了:先清旧原生库(避免两代 dll 混在同一个 java.library.path 里),再全量解。
|
||||||
@@ -368,3 +304,51 @@ pub fn extract(dir: &Path, plan: &NativesPlan) -> Result<NativesReport, NativesE
|
|||||||
write_stamp(dir, report.files, &stamps)?;
|
write_stamp(dir, report.files, &stamps)?;
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 预览一个计划:算出「真启动会怎么解压」,**不建目录、不写文件、不删文件**。
|
||||||
|
///
|
||||||
|
/// `--dry-run` 的唯一环境准备入口。与 [`extract`] 共用判定链([`inspect`])与条目
|
||||||
|
/// 筛选口径:无 jar(1.19+)= 无需解压;戳记与当前 jar 集一致 = 已就绪;否则读 jar
|
||||||
|
/// 清点「将写下几个原生库」。区别只有「不落盘」:没有 `create_dir_all`、没有
|
||||||
|
/// `clear_natives`、没有 `write_stamp`,因此版本树的目录结构与父目录 mtime 都不动。
|
||||||
|
///
|
||||||
|
/// jar 打不开 / 不是合法 zip 照样报错(与真启动同一条失败路径),预览不会藏坏包。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 目录为空、jar 打不开或不是合法 zip 时返回 [`NativesError`]。
|
||||||
|
pub fn preview(dir: &Path, plan: &NativesPlan) -> Result<NativesReport, NativesError> {
|
||||||
|
if dir.as_os_str().is_empty() {
|
||||||
|
return Err(NativesError::NoDir);
|
||||||
|
}
|
||||||
|
let mut report = NativesReport {
|
||||||
|
dir: dir.to_path_buf(),
|
||||||
|
era: plan.era,
|
||||||
|
jars: plan.jars.len(),
|
||||||
|
files: 0,
|
||||||
|
skipped: 0,
|
||||||
|
ignored: 0,
|
||||||
|
custom: plan.custom,
|
||||||
|
up_to_date: false,
|
||||||
|
planned: true,
|
||||||
|
dir_exists: dir.is_dir(),
|
||||||
|
};
|
||||||
|
let (status, _stamps) = inspect(dir, plan);
|
||||||
|
match status {
|
||||||
|
DirStatus::NoJars => {
|
||||||
|
report.up_to_date = true;
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
DirStatus::Ready(files) => {
|
||||||
|
report.up_to_date = true;
|
||||||
|
report.skipped = files;
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
DirStatus::Stale => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for native in &plan.jars {
|
||||||
|
jar::count_jar(native, &mut report)?;
|
||||||
|
}
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! natives 的报告与错误:真解压与预览共用的 [`NativesReport`] / [`NativesError`]。
|
||||||
|
//!
|
||||||
|
//! 单独成文件是为了让 `mod.rs` 只留「计划 + 两个入口(`extract` / `preview`)」,
|
||||||
|
//! 本仓的红线是单文件 ≤400 行。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::NativesEra;
|
||||||
|
|
||||||
|
/// 解压结果 / 预览结果(进 `natives_extracted` 事件与 `--dry-run` 输出)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NativesReport {
|
||||||
|
/// 目标目录。
|
||||||
|
pub dir: PathBuf,
|
||||||
|
/// 时代。
|
||||||
|
pub era: NativesEra,
|
||||||
|
/// 参与的 jar 数。
|
||||||
|
pub jars: usize,
|
||||||
|
/// 原生库文件数:真解压时是**本次写下**的数量,预览时是**将会写下**的数量。
|
||||||
|
pub files: usize,
|
||||||
|
/// 按戳记跳过、未重新解压的文件数(= 上次解压的文件数)。
|
||||||
|
pub skipped: usize,
|
||||||
|
/// 被排除 / 非原生库而忽略的条目数(`META-INF/`、签名文件)。
|
||||||
|
pub ignored: usize,
|
||||||
|
/// 目录是否用户自定义。
|
||||||
|
pub custom: bool,
|
||||||
|
/// 是否按戳记判定「目录已就绪」而整体跳过。
|
||||||
|
pub up_to_date: bool,
|
||||||
|
/// 报告是否来自只读预览([`super::preview`])。true = 盘上零改动,`files` 只是预计值。
|
||||||
|
pub planned: bool,
|
||||||
|
/// 报告生成时目标目录是否已在盘上。
|
||||||
|
///
|
||||||
|
/// 预览时这是**盘上真值**(用于区分「目录已就绪」与「真启动才会建出来」);
|
||||||
|
/// 真解压路径在 `create_dir_all` 之后恒为 true。
|
||||||
|
pub dir_exists: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativesReport {
|
||||||
|
/// 人读一行(CLI / 日志用)。
|
||||||
|
pub fn summary(&self) -> String {
|
||||||
|
if self.planned {
|
||||||
|
return self.planned_summary();
|
||||||
|
}
|
||||||
|
if self.jars == 0 {
|
||||||
|
return format!(
|
||||||
|
"natives 目录已就绪(该版本无需解压,LWJGL 运行期自取)→ {}",
|
||||||
|
self.dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.up_to_date {
|
||||||
|
return format!(
|
||||||
|
"natives 已就绪({} 个原生库,{} 个 jar 未变)",
|
||||||
|
self.skipped, self.jars
|
||||||
|
);
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"natives 解压 {} 个原生库({} 个 jar,忽略 {} 个条目)→ {}",
|
||||||
|
self.files,
|
||||||
|
self.jars,
|
||||||
|
self.ignored,
|
||||||
|
self.dir.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览版人读一行:句句带「将 / 未落盘」,别让用户把预览当成已经解好了。
|
||||||
|
fn planned_summary(&self) -> String {
|
||||||
|
if self.jars == 0 {
|
||||||
|
return if self.dir_exists {
|
||||||
|
format!(
|
||||||
|
"natives 目录已就绪(该版本无需解压,LWJGL 运行期自取)→ {}",
|
||||||
|
self.dir.display()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"natives 目录尚不存在,真启动会建出来(--dry-run 未落盘)→ {}",
|
||||||
|
self.dir.display()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if self.up_to_date {
|
||||||
|
return format!(
|
||||||
|
"natives 已就绪({} 个原生库,{} 个 jar 未变;--dry-run 未落盘)",
|
||||||
|
self.skipped, self.jars
|
||||||
|
);
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"将解压 {} 个原生库({} 个 jar,忽略 {} 个条目)→ {}(--dry-run 未落盘)",
|
||||||
|
self.files,
|
||||||
|
self.jars,
|
||||||
|
self.ignored,
|
||||||
|
self.dir.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--json` 的 `natives` 块(`ull launch --dry-run` 用;字段名是接口的一部分)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn to_json(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"dir": self.dir.display().to_string(),
|
||||||
|
"files": self.files,
|
||||||
|
"skipped": self.skipped,
|
||||||
|
"ignored": self.ignored,
|
||||||
|
"jars": self.jars,
|
||||||
|
"custom": self.custom,
|
||||||
|
"up_to_date": self.up_to_date,
|
||||||
|
"era": self.era.as_str(),
|
||||||
|
"planned": self.planned,
|
||||||
|
"dir_exists": self.dir_exists,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解压失败原因。
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum NativesError {
|
||||||
|
/// 目标目录为空(调用方漏填 `natives_dir`)。
|
||||||
|
NoDir,
|
||||||
|
/// 文件系统操作失败。
|
||||||
|
Io {
|
||||||
|
/// 操作名。
|
||||||
|
op: &'static str,
|
||||||
|
/// 相关路径。
|
||||||
|
path: PathBuf,
|
||||||
|
/// 底层错误。
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
/// natives jar 打不开 / 不是合法 zip(下载损坏的最常见形态)。
|
||||||
|
Zip {
|
||||||
|
/// jar 路径。
|
||||||
|
path: PathBuf,
|
||||||
|
/// 原因。
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativesError {
|
||||||
|
/// 一行人读描述。
|
||||||
|
pub fn message(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::NoDir => "natives 目录未配置(natives_dir 为空)".to_string(),
|
||||||
|
Self::Io { op, path, source } => {
|
||||||
|
format!("{op} 失败({}):{source}", path.display())
|
||||||
|
}
|
||||||
|
Self::Zip { path, reason } => {
|
||||||
|
format!(
|
||||||
|
"natives jar 无法作为 zip 打开({}):{reason}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for NativesError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.message())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for NativesError {}
|
||||||
@@ -305,8 +305,9 @@ pub struct LaunchRequest {
|
|||||||
pub dirs: LaunchDirs,
|
pub dirs: LaunchDirs,
|
||||||
/// natives 解压计划(`dirs.natives_dir` 为目标目录,`jars` 为分类器构件清单)。
|
/// natives 解压计划(`dirs.natives_dir` 为目标目录,`jars` 为分类器构件清单)。
|
||||||
///
|
///
|
||||||
/// 解压发生在**组装期**(`Launcher::dry_run`/`spawn` 的「环境准备」段),
|
/// 解压发生在**组装期**(`spawn`/`launch` 的「环境准备」段);`dry_run` 只**预览**同一段
|
||||||
/// 见 [`crate::launch::natives`] 与 `SubAgentsTalk/launch-natives-解压与时代分界.md`。
|
/// 的结果(不建目录、不写文件),见 [`crate::launch::natives`] 与
|
||||||
|
/// `SubAgentsTalk/launch-natives-解压与时代分界.md`。
|
||||||
pub natives: NativesPlan,
|
pub natives: NativesPlan,
|
||||||
/// 账户。
|
/// 账户。
|
||||||
pub auth: AuthPlaceholders,
|
pub auth: AuthPlaceholders,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod exit_cases;
|
|||||||
mod graphics_backend_cases;
|
mod graphics_backend_cases;
|
||||||
mod natives_args_cases;
|
mod natives_args_cases;
|
||||||
mod natives_cases;
|
mod natives_cases;
|
||||||
|
mod natives_dry_run_cases;
|
||||||
mod pipeline_cases;
|
mod pipeline_cases;
|
||||||
mod prelude;
|
mod prelude;
|
||||||
mod process_smoke;
|
mod process_smoke;
|
||||||
|
|||||||
@@ -2,44 +2,12 @@
|
|||||||
//!
|
//!
|
||||||
//! 假 jar 用 `zip` 的 Stored 模式现造(不依赖真下载),真机验收另有人工步骤。
|
//! 假 jar 用 `zip` 的 Stored 模式现造(不依赖真下载),真机验收另有人工步骤。
|
||||||
|
|
||||||
use std::fs::File;
|
use std::path::Path;
|
||||||
use std::io::Write;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use zip::write::SimpleFileOptions;
|
|
||||||
use zip::{CompressionMethod, ZipWriter};
|
|
||||||
|
|
||||||
use unlucky_core::launch::natives::{self, NativeJar, NativesEra, NativesPlan, STAMP_FILE};
|
use unlucky_core::launch::natives::{self, NativeJar, NativesEra, NativesPlan, STAMP_FILE};
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
/// 造一个 natives jar:`entries` 是(条目名, 内容)。
|
|
||||||
fn make_jar(path: &Path, entries: &[(&str, &[u8])]) {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).expect("建 jar 目录");
|
|
||||||
}
|
|
||||||
let file = File::create(path).expect("建 jar");
|
|
||||||
let mut writer = ZipWriter::new(file);
|
|
||||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
|
||||||
for (name, bytes) in entries {
|
|
||||||
writer.start_file(*name, options).expect("写条目");
|
|
||||||
writer.write_all(bytes).expect("写条目内容");
|
|
||||||
}
|
|
||||||
writer.finish().expect("收尾 jar");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 一份 1.16.5 风格的 natives jar 的条目集(原生库 + 该被排除的噪音)。
|
|
||||||
fn lwjgl_jar_entries() -> Vec<(&'static str, &'static [u8])> {
|
|
||||||
vec![
|
|
||||||
("lwjgl.dll", b"DLL-BYTES".as_slice()),
|
|
||||||
("OpenAL.dll", b"OPENAL-BYTES".as_slice()),
|
|
||||||
("META-INF/MANIFEST.MF", b"manifest".as_slice()),
|
|
||||||
("META-INF/LWJGL.SF", b"signature".as_slice()),
|
|
||||||
("lwjgl.dll.sha1", b"deadbeef".as_slice()),
|
|
||||||
("README.txt", b"noise".as_slice()),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn plan(jars: Vec<NativeJar>) -> NativesPlan {
|
fn plan(jars: Vec<NativeJar>) -> NativesPlan {
|
||||||
NativesPlan::new(jars)
|
NativesPlan::new(jars)
|
||||||
}
|
}
|
||||||
@@ -209,34 +177,6 @@ fn natives_extracted_event_carries_report_fields() {
|
|||||||
.ends_with("natives"));
|
.ends_with("natives"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `--dry-run` 也做环境准备:第二次跑要看到 up_to_date(真机验收的判定依据)。
|
|
||||||
#[test]
|
|
||||||
fn dry_run_prepares_natives_and_is_idempotent() {
|
|
||||||
let root = temp_dir("natives-dry-run");
|
|
||||||
let jar = root.join("natives-windows.jar");
|
|
||||||
make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]);
|
|
||||||
let natives_dir: PathBuf = root.join("versions/1.16.5/natives");
|
|
||||||
|
|
||||||
let (mut request, _) = legacy_forge_request();
|
|
||||||
request.dirs.natives_dir = natives_dir.clone();
|
|
||||||
request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]);
|
|
||||||
request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string());
|
|
||||||
|
|
||||||
let sessions = temp_sessions_root("natives-dry-run-sessions");
|
|
||||||
let launcher = Launcher::new(LauncherConfig::new(&sessions));
|
|
||||||
|
|
||||||
let first = launcher.dry_run(&request).expect("首次 dry-run");
|
|
||||||
let report = first.natives.expect("应有 natives 报告");
|
|
||||||
assert_eq!(report.files, 1, "{report:?}");
|
|
||||||
assert!(!report.up_to_date);
|
|
||||||
assert!(natives_dir.join("lwjgl.dll").is_file());
|
|
||||||
|
|
||||||
let second = launcher.dry_run(&request).expect("二次 dry-run");
|
|
||||||
let report = second.natives.expect("应有 natives 报告");
|
|
||||||
assert!(report.up_to_date, "第二次应命中戳记:{report:?}");
|
|
||||||
assert_eq!(report.skipped, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 缺 natives jar:适配层当成组件缺失(组装前显式失败),而不是等到 UnsatisfiedLinkError。
|
/// 缺 natives jar:适配层当成组件缺失(组装前显式失败),而不是等到 UnsatisfiedLinkError。
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_native_jar_becomes_missing_component() {
|
fn missing_native_jar_becomes_missing_component() {
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//! `--dry-run` 的零落盘契约(M231 / DRYRUNPURE):预览只读版本树,不建目录、不改 mtime。
|
||||||
|
//!
|
||||||
|
//! 判据是「版本树的节点类型 + mtime 快照逐字相等」而不是「natives 目录不存在」:
|
||||||
|
//! 后者抓不住 `create_dir_all` 之后立刻删掉的情形(父目录 mtime 已经变了),
|
||||||
|
//! 也抓不住「目录本来就在、但被重写/清空」的情形。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use unlucky_core::launch::natives::{self, NativeJar, NativesPlan};
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
/// 版本树的「节点类型 + mtime(纳秒)+ 相对路径」快照,用来逐字比较 dry-run 前后。
|
||||||
|
///
|
||||||
|
/// 抓得住:新增/删除任何节点(含 `natives/` 目录本身)、任何已有节点被重写或 mtime 被
|
||||||
|
/// 更新;根节点(= 父目录 `versions/<id>/`)也在快照里,所以「建目录」会同时体现在
|
||||||
|
/// 节点集合与父目录 mtime 上。
|
||||||
|
/// 抓不住:①内容变了但 mtime 被回填成原值(本仓没有这种写法);②**建了立刻删**这类
|
||||||
|
/// 瞬时写入只留父目录 mtime 一条线索,而 NTFS 会把该更新合并掉 —— 本仓实测:全量并行跑
|
||||||
|
/// 测试时这条偶发看不见(单跑又看得见),所以瞬时建删只剩「`natives` 目录不存在」兜底。
|
||||||
|
///
|
||||||
|
/// 时间戳口径:NTFS / ext4 的 mtime 精度是 100ns 级,快照取纳秒整数比较,同一秒内的
|
||||||
|
/// 两次操作(写文件 → 再写一次)分辨得出来,不需要 `sleep` 把操作推到不同秒。
|
||||||
|
/// 判据自检见 `tree_snapshot_sees_new_nodes_and_mtime_changes`(防假绿)。
|
||||||
|
fn tree_snapshot(root: &Path) -> Vec<String> {
|
||||||
|
fn line(meta: &std::fs::Metadata, relative: &str) -> String {
|
||||||
|
let nanos = meta
|
||||||
|
.modified()
|
||||||
|
.expect("mtime")
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.expect("epoch 之后")
|
||||||
|
.as_nanos();
|
||||||
|
let kind = if meta.is_dir() { "d" } else { "f" };
|
||||||
|
format!("{kind} {nanos} {relative}")
|
||||||
|
}
|
||||||
|
fn walk(dir: &Path, prefix: &str, out: &mut Vec<String>) {
|
||||||
|
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||||
|
.expect("读目录")
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.collect();
|
||||||
|
entries.sort();
|
||||||
|
for path in entries {
|
||||||
|
let meta = std::fs::symlink_metadata(&path).expect("元数据");
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.expect("文件名")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let relative = format!("{prefix}{name}");
|
||||||
|
out.push(line(&meta, &relative));
|
||||||
|
if meta.is_dir() {
|
||||||
|
walk(&path, &format!("{relative}/"), out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut out = vec![line(
|
||||||
|
&std::fs::symlink_metadata(root).expect("根元数据"),
|
||||||
|
"<root>",
|
||||||
|
)];
|
||||||
|
walk(root, "", &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--dry-run` 零落盘(有 jar):只报「将解压几个」,版本树里一个节点都不新增、mtime 不动。
|
||||||
|
#[test]
|
||||||
|
fn dry_run_previews_natives_without_touching_disk() {
|
||||||
|
let root = temp_dir("natives-dry-run-pure");
|
||||||
|
let jar = root.join("libraries/lwjgl-natives-windows.jar");
|
||||||
|
make_jar(&jar, &lwjgl_jar_entries());
|
||||||
|
let version_dir = root.join("versions/1.16.5");
|
||||||
|
std::fs::create_dir_all(&version_dir).expect("建版本目录");
|
||||||
|
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("放清单占位");
|
||||||
|
let natives_dir = version_dir.join("natives");
|
||||||
|
let before = tree_snapshot(&version_dir);
|
||||||
|
|
||||||
|
let (mut request, _) = legacy_forge_request();
|
||||||
|
request.dirs.natives_dir = natives_dir.clone();
|
||||||
|
request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]);
|
||||||
|
request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string());
|
||||||
|
let sessions = temp_sessions_root("natives-dry-run-pure-sessions");
|
||||||
|
let launcher = Launcher::new(LauncherConfig::new(&sessions));
|
||||||
|
|
||||||
|
let dry = launcher.dry_run(&request).expect("dry-run 应成功");
|
||||||
|
assert_eq!(
|
||||||
|
before,
|
||||||
|
tree_snapshot(&version_dir),
|
||||||
|
"dry-run 不许在版本树里加节点 / 改 mtime"
|
||||||
|
);
|
||||||
|
let report = dry.natives.expect("应有 natives 预览");
|
||||||
|
assert!(report.planned, "预览报告要自报家门:{report:?}");
|
||||||
|
assert!(!report.dir_exists, "natives 目录原本不在盘上:{report:?}");
|
||||||
|
assert_eq!(report.files, 2, "要算出「将写下」的数量:{report:?}");
|
||||||
|
assert_eq!(report.ignored, 4, "忽略口径要与真解压一致:{report:?}");
|
||||||
|
assert!(!report.up_to_date, "{report:?}");
|
||||||
|
assert!(
|
||||||
|
report.summary().contains("未落盘"),
|
||||||
|
"人读行得说清没写盘:{}",
|
||||||
|
report.summary()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!natives_dir.exists(), "natives 目录不该被建出来");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--dry-run` 零落盘(无 jar,1.19+ 常态):不建 natives 目录,人读行说清「尚不存在」。
|
||||||
|
#[test]
|
||||||
|
fn dry_run_with_no_natives_jar_creates_no_directory() {
|
||||||
|
let root = temp_dir("natives-dry-run-nojar");
|
||||||
|
let version_dir = root.join("versions/1.20.1");
|
||||||
|
std::fs::create_dir_all(&version_dir).expect("建版本目录");
|
||||||
|
let natives_dir = version_dir.join("natives");
|
||||||
|
let before = tree_snapshot(&version_dir);
|
||||||
|
|
||||||
|
let (mut request, _) = legacy_forge_request();
|
||||||
|
request.dirs.natives_dir = natives_dir.clone();
|
||||||
|
request.natives = NativesPlan::default();
|
||||||
|
request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string());
|
||||||
|
let sessions = temp_sessions_root("natives-dry-run-nojar-sessions");
|
||||||
|
let launcher = Launcher::new(LauncherConfig::new(&sessions));
|
||||||
|
|
||||||
|
let dry = launcher.dry_run(&request).expect("dry-run 应成功");
|
||||||
|
assert_eq!(before, tree_snapshot(&version_dir), "无 jar 也不许建目录");
|
||||||
|
let report = dry.natives.expect("应有 natives 预览");
|
||||||
|
assert_eq!(report.jars, 0, "{report:?}");
|
||||||
|
assert_eq!(report.files, 0, "{report:?}");
|
||||||
|
assert!(!report.dir_exists, "盘上没有这个目录:{report:?}");
|
||||||
|
assert!(report.up_to_date, "无 jar = 无需解压:{report:?}");
|
||||||
|
assert!(
|
||||||
|
report.summary().contains("尚不存在"),
|
||||||
|
"{}",
|
||||||
|
report.summary()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!natives_dir.exists(), "natives 目录不该被建出来");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--dry-run` 零落盘(空 jar:只有 `META-INF/` 与噪音):数得出 0 个原生库,仍不建目录。
|
||||||
|
#[test]
|
||||||
|
fn dry_run_with_empty_native_jar_creates_no_directory() {
|
||||||
|
let root = temp_dir("natives-dry-run-emptyjar");
|
||||||
|
let jar = root.join("natives-windows.jar");
|
||||||
|
make_jar(
|
||||||
|
&jar,
|
||||||
|
&[
|
||||||
|
("META-INF/MANIFEST.MF", b"manifest".as_slice()),
|
||||||
|
("README.txt", b"noise".as_slice()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let version_dir = root.join("versions/1.16.5");
|
||||||
|
std::fs::create_dir_all(&version_dir).expect("建版本目录");
|
||||||
|
let natives_dir = version_dir.join("natives");
|
||||||
|
let before = tree_snapshot(&version_dir);
|
||||||
|
|
||||||
|
let (mut request, _) = legacy_forge_request();
|
||||||
|
request.dirs.natives_dir = natives_dir.clone();
|
||||||
|
request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]);
|
||||||
|
request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string());
|
||||||
|
let sessions = temp_sessions_root("natives-dry-run-emptyjar-sessions");
|
||||||
|
let launcher = Launcher::new(LauncherConfig::new(&sessions));
|
||||||
|
|
||||||
|
let dry = launcher.dry_run(&request).expect("dry-run 应成功");
|
||||||
|
assert_eq!(before, tree_snapshot(&version_dir), "空 jar 也不许建目录");
|
||||||
|
let report = dry.natives.expect("应有 natives 预览");
|
||||||
|
assert_eq!(report.files, 0, "空 jar 解不出原生库:{report:?}");
|
||||||
|
assert_eq!(report.ignored, 2, "两条噪音都要数进忽略:{report:?}");
|
||||||
|
assert!(!report.up_to_date, "{report:?}");
|
||||||
|
assert!(!report.dir_exists, "{report:?}");
|
||||||
|
|
||||||
|
assert!(!natives_dir.exists(), "natives 目录不该被建出来");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目录已经真解压过:预览读戳记报「已就绪」,同样零写盘(幂等路径也不许碰盘)。
|
||||||
|
#[test]
|
||||||
|
fn dry_run_after_real_extract_reports_ready_without_writing() {
|
||||||
|
let root = temp_dir("natives-dry-run-ready");
|
||||||
|
let jar = root.join("natives-windows.jar");
|
||||||
|
make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]);
|
||||||
|
let version_dir = root.join("versions/1.16.5");
|
||||||
|
let natives_dir = version_dir.join("natives");
|
||||||
|
std::fs::create_dir_all(&version_dir).expect("建版本目录");
|
||||||
|
natives::extract(
|
||||||
|
&natives_dir,
|
||||||
|
&NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]),
|
||||||
|
)
|
||||||
|
.expect("先真解压一次");
|
||||||
|
let before = tree_snapshot(&version_dir);
|
||||||
|
|
||||||
|
let (mut request, _) = legacy_forge_request();
|
||||||
|
request.dirs.natives_dir = natives_dir.clone();
|
||||||
|
request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]);
|
||||||
|
request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string());
|
||||||
|
let sessions = temp_sessions_root("natives-dry-run-ready-sessions");
|
||||||
|
let launcher = Launcher::new(LauncherConfig::new(&sessions));
|
||||||
|
|
||||||
|
let dry = launcher.dry_run(&request).expect("dry-run 应成功");
|
||||||
|
assert_eq!(
|
||||||
|
before,
|
||||||
|
tree_snapshot(&version_dir),
|
||||||
|
"预览不该重写已有的原生库或戳记"
|
||||||
|
);
|
||||||
|
let report = dry.natives.expect("应有 natives 预览");
|
||||||
|
assert!(report.planned, "{report:?}");
|
||||||
|
assert!(report.dir_exists, "{report:?}");
|
||||||
|
assert!(report.up_to_date, "戳记命中应报已就绪:{report:?}");
|
||||||
|
assert_eq!(report.files, 0, "预览不该再数一遍:{report:?}");
|
||||||
|
assert_eq!(report.skipped, 1, "{report:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判据自检(防假绿):快照必须看得见「新增节点」与「已有节点的 mtime 变化」,
|
||||||
|
/// 否则上面几条「快照逐字相等」等于没断言。
|
||||||
|
///
|
||||||
|
/// 这里**不**用「建了立刻删」来探父目录 mtime:NTFS 会把瞬时建删对父目录 mtime 的影响
|
||||||
|
/// 合并掉(本仓实测:全量并行跑时偶发看不见、单跑看得见),拿它当门禁就是引入 flaky。
|
||||||
|
/// 改成两种确定性变化:新节点(结构维度)+ 同内容重写(时间维度,只有文件 mtime 变,
|
||||||
|
/// 不依赖父目录时间戳的合并行为)。
|
||||||
|
#[test]
|
||||||
|
fn tree_snapshot_sees_new_nodes_and_mtime_changes() {
|
||||||
|
let root = temp_dir("natives-snapshot-probe");
|
||||||
|
let version_dir = root.join("versions/1.16.5");
|
||||||
|
std::fs::create_dir_all(&version_dir).expect("建版本目录");
|
||||||
|
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("放清单占位");
|
||||||
|
let baseline = tree_snapshot(&version_dir);
|
||||||
|
|
||||||
|
// 1) 结构维度:多一个目录节点(正是 `create_dir_all(natives)` 留下的痕迹)。
|
||||||
|
std::fs::create_dir(version_dir.join("natives")).expect("建目录");
|
||||||
|
let with_dir = tree_snapshot(&version_dir);
|
||||||
|
assert!(
|
||||||
|
with_dir.len() > baseline.len(),
|
||||||
|
"快照必须看得见新目录:{baseline:?} vs {with_dir:?}"
|
||||||
|
);
|
||||||
|
assert_ne!(baseline, with_dir);
|
||||||
|
|
||||||
|
// 2) 时间维度:同一份内容重写一遍,节点集合不变、文件 mtime 必须变。
|
||||||
|
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("重写清单占位");
|
||||||
|
let rewritten = tree_snapshot(&version_dir);
|
||||||
|
assert_eq!(
|
||||||
|
with_dir.len(),
|
||||||
|
rewritten.len(),
|
||||||
|
"节点集合没变,差异只该在 mtime 上"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
with_dir, rewritten,
|
||||||
|
"快照必须看得见已有文件的 mtime 变化,否则零落盘断言是假绿"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
//!
|
//!
|
||||||
//! **crate 名只在本文件出现**(tower 定案 `unlucky_core`;若 M11 改包名,只改这里)。
|
//! **crate 名只在本文件出现**(tower 定案 `unlucky_core`;若 M11 改包名,只改这里)。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use zip::write::SimpleFileOptions;
|
||||||
|
use zip::{CompressionMethod, ZipWriter};
|
||||||
|
|
||||||
pub use unlucky_core::launch::args_table::{assemble, AssembledCommand, AssemblyWarning};
|
pub use unlucky_core::launch::args_table::{assemble, AssembledCommand, AssemblyWarning};
|
||||||
pub use unlucky_core::launch::argument::{ArgumentEntry, Rule};
|
pub use unlucky_core::launch::argument::{ArgumentEntry, Rule};
|
||||||
@@ -147,3 +152,32 @@ pub fn read_events(path: &std::path::Path) -> Vec<serde_json::Value> {
|
|||||||
.map(|line| serde_json::from_str(line).expect("解析事件行"))
|
.map(|line| serde_json::from_str(line).expect("解析事件行"))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 造一个假 jar(`zip` Stored 模式,不依赖真下载):`entries` 是(条目名, 内容)。
|
||||||
|
///
|
||||||
|
/// natives 用例共用(真解压与 `--dry-run` 预览两边都要现造 jar)。
|
||||||
|
pub fn make_jar(path: &Path, entries: &[(&str, &[u8])]) {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).expect("建 jar 目录");
|
||||||
|
}
|
||||||
|
let file = File::create(path).expect("建 jar");
|
||||||
|
let mut writer = ZipWriter::new(file);
|
||||||
|
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
||||||
|
for (name, bytes) in entries {
|
||||||
|
writer.start_file(*name, options).expect("写条目");
|
||||||
|
writer.write_all(bytes).expect("写条目内容");
|
||||||
|
}
|
||||||
|
writer.finish().expect("收尾 jar");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一份 1.16.5 风格的 natives jar 的条目集(2 个原生库 + 4 条该被排除的噪音)。
|
||||||
|
pub fn lwjgl_jar_entries() -> Vec<(&'static str, &'static [u8])> {
|
||||||
|
vec![
|
||||||
|
("lwjgl.dll", b"DLL-BYTES".as_slice()),
|
||||||
|
("OpenAL.dll", b"OPENAL-BYTES".as_slice()),
|
||||||
|
("META-INF/MANIFEST.MF", b"manifest".as_slice()),
|
||||||
|
("META-INF/LWJGL.SF", b"signature".as_slice()),
|
||||||
|
("lwjgl.dll.sha1", b"deadbeef".as_slice()),
|
||||||
|
("README.txt", b"noise".as_slice()),
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user