diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index ba1d398..fa20128 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -211,7 +211,8 @@ versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 父版本 + 主 可以是实例 id、实例名,或直接是版本 id。 --dry-run(D18):只组装命令行并打印,不 spawn 进程、不写会话文件、不刷新令牌, -也不写盘——不解压 natives、不建 natives 目录(连缺的目录也不补),对版本树是纯读的。 +也不碰版本树(不解压 natives、不建 natives 目录,连缺的目录也不补);启动器自身的 +运行日志(<数据根>/logs/launcher.log)照记,这一点与真实启动一致。 账户:--account 指定账户(玩家名或条目 id);不给时用唯一账户,一个都没有则回落 离线占位(--username 或 Player),有多个账户会报错并列出候选。 accessToken 一律脱敏(输出里出现的是 )。", diff --git a/crates/core/src/launch/launcher.rs b/crates/core/src/launch/launcher.rs index d35cc35..170f4c2 100644 --- a/crates/core/src/launch/launcher.rs +++ b/crates/core/src/launch/launcher.rs @@ -91,11 +91,13 @@ impl LauncherConfig { } } -/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件、不落盘**。 +/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件、不碰磁盘**。 /// -/// 环境准备(第 6 段)在 dry-run 下走 [`crate::launch::natives::preview`]:只算「会解出 -/// 几个原生库 / natives 目录是不是已经就绪」,**不建目录、不写文件、不删文件**—— -/// `--dry-run` 对版本树(尤其是别人铺的 `versions//`)是纯读的。 +/// 「不碰磁盘」是本层(core 启动管线)的承诺:环境准备(第 6 段)在 dry-run 下走 +/// [`crate::launch::natives::preview`],只算「会解出几个原生库 / natives 目录是不是 +/// 已经就绪」,**不建目录、不写文件、不删文件**——对版本树(尤其是别人铺的 +/// `versions//`)是纯读的。调用方自己的运行日志(CLI 的 `<数据根>/logs/launcher.log`) +/// 不在此列。 #[derive(Debug, Clone)] pub struct DryRun { /// 实际要执行的程序。 diff --git a/crates/core/src/launch/natives/mod.rs b/crates/core/src/launch/natives/mod.rs index c8282e6..df653f8 100644 --- a/crates/core/src/launch/natives/mod.rs +++ b/crates/core/src/launch/natives/mod.rs @@ -227,6 +227,34 @@ pub fn collect( natives } +/// 目录状态的三种结论:两个入口共用一条判定链,别各写一份。 +enum DirStatus { + /// 无需解压:1.19+ 没有 natives 分类器 jar,只要目录在。 + NoJars, + /// 戳记与当前 jar 集一致:目录已就绪,上次解出 `usize` 个原生库。 + Ready(usize), + /// 需要(重新)解压。 + Stale, +} + +/// 判定链 + 本次源 jar 指纹(真解压拿它写戳记,预览丢弃)。 +/// +/// `extract` 与 `preview` **只在这里**决定「要不要解压」:改判定只改这一处, +/// 两边不会漂。 +fn inspect(dir: &Path, plan: &NativesPlan) -> (DirStatus, Vec) { + 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 都没变时零写盘)。 /// /// `--dry-run` 不走这里,走 [`preview`]:那里一个字节都不写。 @@ -254,18 +282,18 @@ pub fn extract(dir: &Path, plan: &NativesPlan) -> Result { report.up_to_date = true; - report.skipped = stamp.files(); return Ok(report); } + DirStatus::Ready(files) => { + report.up_to_date = true; + report.skipped = files; + return Ok(report); + } + DirStatus::Stale => {} } // 源变了:先清旧原生库(避免两代 dll 混在同一个 java.library.path 里),再全量解。 @@ -279,10 +307,10 @@ pub fn extract(dir: &Path, plan: &NativesPlan) -> Result Result { report.up_to_date = true; - report.skipped = stamp.files(); return Ok(report); } + DirStatus::Ready(files) => { + report.up_to_date = true; + report.skipped = files; + return Ok(report); + } + DirStatus::Stale => {} } for native in &plan.jars { diff --git a/crates/core/tests/launch/natives_cases.rs b/crates/core/tests/launch/natives_cases.rs index e60afe7..4677c10 100644 --- a/crates/core/tests/launch/natives_cases.rs +++ b/crates/core/tests/launch/natives_cases.rs @@ -176,3 +176,46 @@ fn natives_extracted_event_carries_report_fields() { .expect("dir 是字符串") .ends_with("natives")); } + +/// 缺 natives jar:适配层当成组件缺失(组装前显式失败),而不是等到 UnsatisfiedLinkError。 +#[test] +fn missing_native_jar_becomes_missing_component() { + let (mut request, ctx) = legacy_forge_request(); + request.natives = NativesPlan::new(vec![NativeJar::new( + "C:/mc/libraries/missing-natives-windows.jar", + "org.lwjgl:lwjgl:3.2.2:natives-windows", + )]); + // 直接验证适配层:给一个不存在的 natives jar,严格模式应产出 MissingNativeJar 告警。 + let libraries: Vec = request + .classpath + .iter() + .map(|path| LibraryInput::new(path.clone(), "x")) + .collect(); + let natives = request.natives.jars.clone(); + let snapshot = SnapshotInput { + version_id: "1.16.5", + main_class: Some("net.minecraft.client.main.Main"), + libraries: &libraries, + natives: &natives, + ..SnapshotInput::minimal("1.16.5", "net.minecraft.client.main.Main") + }; + let options = AdapterOptions { + java: JavaSelection::new("C:/java/bin/java.exe", 17, 64), + ..AdapterOptions::strict() + }; + let output = build_request(&snapshot, options); + assert!( + output + .warnings + .iter() + .any(|warning| matches!(warning, AdapterWarning::MissingNativeJar { .. })), + "应有 MissingNativeJar 告警:{:?}", + output.warnings + ); + assert_eq!(output.request.natives.era, NativesEra::Legacy); + // 组装按既有语义直接失败(missing_components 非空)。 + assert!(matches!( + assemble(&output.request, &ctx).err(), + Some(LaunchError::Assembly(AssemblyError::MissingComponents(_))) + )); +} diff --git a/crates/core/tests/launch/natives_dry_run_cases.rs b/crates/core/tests/launch/natives_dry_run_cases.rs index dced100..beb32de 100644 --- a/crates/core/tests/launch/natives_dry_run_cases.rs +++ b/crates/core/tests/launch/natives_dry_run_cases.rs @@ -12,16 +12,16 @@ use crate::prelude::*; /// 版本树的「节点类型 + mtime(纳秒)+ 相对路径」快照,用来逐字比较 dry-run 前后。 /// -/// 抓得住:新增/删除任何节点(含 `natives/` 目录本身 —— 根节点的 mtime 就是父目录 mtime)、 -/// 任何已有节点被重写或 mtime 被更新。目录节点的 mtime 会因「建子目录」而变,所以 -/// 「建了又删」这种瞬时写入也会被记到根节点上(只要快照跨过了那次操作)。 -/// 抓不住:内容变了但 mtime 被回填成原值(本仓没有这种写法);时间戳分辨率粗于操作间隔的 -/// 文件系统(FAT/exFAT 的 2s 粒度)上「建了又删」可能落进同一格 —— 那种情况只剩 -/// 「`natives` 目录不存在」这条判据兜底,瞬时的建-删会漏。 +/// 抓得住:新增/删除任何节点(含 `natives/` 目录本身)、任何已有节点被重写或 mtime 被 +/// 更新;根节点(= 父目录 `versions//`)也在快照里,所以「建目录」会同时体现在 +/// 节点集合与父目录 mtime 上。 +/// 抓不住:①内容变了但 mtime 被回填成原值(本仓没有这种写法);②**建了立刻删**这类 +/// 瞬时写入只留父目录 mtime 一条线索,而 NTFS 会把该更新合并掉 —— 本仓实测:全量并行跑 +/// 测试时这条偶发看不见(单跑又看得见),所以瞬时建删只剩「`natives` 目录不存在」兜底。 /// -/// 时间戳口径:NTFS / ext4 的 mtime 精度是 100ns 级,快照取纳秒整数比较,所以同一秒内的 -/// 多次操作(建目录 → 删目录)照样分辨得出来,不需要 `sleep` 把两次操作推到不同秒。 -/// 下面 `tree_snapshot_notices_a_transient_directory` 就是这条判据的自检(防假绿)。 +/// 时间戳口径:NTFS / ext4 的 mtime 精度是 100ns 级,快照取纳秒整数比较,同一秒内的 +/// 两次操作(写文件 → 再写一次)分辨得出来,不需要 `sleep` 把操作推到不同秒。 +/// 判据自检见 `tree_snapshot_sees_new_nodes_and_mtime_changes`(防假绿)。 fn tree_snapshot(root: &Path) -> Vec { fn line(meta: &std::fs::Metadata, relative: &str) -> String { let nanos = meta @@ -206,30 +206,40 @@ fn dry_run_after_real_extract_reports_ready_without_writing() { assert_eq!(report.skipped, 1, "{report:?}"); } -/// 判据自检(防假绿):快照必须看得见「建一个子目录又立刻删掉」留下的父目录 mtime 变化, +/// 判据自检(防假绿):快照必须看得见「新增节点」与「已有节点的 mtime 变化」, /// 否则上面几条「快照逐字相等」等于没断言。 +/// +/// 这里**不**用「建了立刻删」来探父目录 mtime:NTFS 会把瞬时建删对父目录 mtime 的影响 +/// 合并掉(本仓实测:全量并行跑时偶发看不见、单跑看得见),拿它当门禁就是引入 flaky。 +/// 改成两种确定性变化:新节点(结构维度)+ 同内容重写(时间维度,只有文件 mtime 变, +/// 不依赖父目录时间戳的合并行为)。 #[test] -fn tree_snapshot_notices_a_transient_directory() { +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 before = tree_snapshot(&version_dir); + let baseline = tree_snapshot(&version_dir); - // 模拟 `create_dir_all(natives)` 之后目录又被删掉:节点集合复原,只有父目录 mtime 变了。 - let transient = version_dir.join("natives"); - std::fs::create_dir(&transient).expect("建目录"); - std::fs::remove_dir(&transient).expect("删目录"); - assert!(!transient.exists(), "目录应已删掉"); + // 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); - let after = tree_snapshot(&version_dir); + // 2) 时间维度:同一份内容重写一遍,节点集合不变、文件 mtime 必须变。 + std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("重写清单占位"); + let rewritten = tree_snapshot(&version_dir); assert_eq!( - before.len(), - after.len(), - "节点集合应恢复原样,差异只该在 mtime 上:{before:?} vs {after:?}" + with_dir.len(), + rewritten.len(), + "节点集合没变,差异只该在 mtime 上" ); assert_ne!( - before, after, - "快照必须看得见瞬时建删留下的父目录 mtime 变化,否则零落盘断言是假绿" + with_dir, rewritten, + "快照必须看得见已有文件的 mtime 变化,否则零落盘断言是假绿" ); }