From 34f3db359a9ebf1565fa83477e95647cdb3675b9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 00:15:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20M110=20r2=20=E2=80=94=E2=80=94=20d?= =?UTF-8?q?uplicate=5Foverride=5Fpaths=20=E4=B8=A4=E7=A7=8D=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E5=90=8C=E6=97=B6=E6=8A=A5=EF=BC=8C=E8=A1=A5=20mrpack?= =?UTF-8?q?=20=E5=90=8C=E4=BE=A7=E5=88=86=E6=94=AF=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - p2-2:`duplicate_override_paths` 改成「先按 path 分组,再在组内报 (侧集合, 条目份数)」, 判据 `侧数 > 1 || 份数 > 侧数`。r1 修好了跨侧(Both×Client)但按侧去重把「同侧两个组 撞同一路径」这条旧能力换掉了(实测返回 []);现在两种冲突同时报得出来,返回值升为 `Vec<(String, Vec, usize)>`(M111 注意:这是最后一次改形状的窗口)。 - p3-a:mrpack `check_overrides` 的「每侧只能有一个覆盖目录」分支补用例(此前零测试, 删掉它 54 条全绿;现在删掉即红)。 - p3-b:把 ReadBudget 的「每次调用一份额度,不是整包一份」写进 `override_files` / `ReadBudget` 文档(mrpack 连调三次 ⇒ 理论上限 3×;要整包共用额度就走 `override_files_with`;`open` 阶段的声明总量仍是整包一次)。 - 顺带(复审点名、各一行):mrpack 非表达目录的 hint 里 21 个连续空格; curseforge 文档里 `.minecraft/.minecraft/…` 的说法按 CF 语义订正(overrides 是 zip 内前缀, 内容铺开不丢;要不要多拷一层取决于导入器)。 --- crates/core/src/addon/pack/archive.rs | 9 +++- crates/core/src/addon/pack/archive/limits.rs | 4 ++ crates/core/src/addon/pack/curseforge.rs | 6 ++- crates/core/src/addon/pack/manifest.rs | 44 +++++++++++++------- crates/core/src/addon/pack/modrinth.rs | 3 +- crates/core/src/addon/pack/tests/export.rs | 27 ++++++++++++ crates/core/src/addon/pack/tests/model.rs | 40 +++++++++++++++++- 7 files changed, 111 insertions(+), 22 deletions(-) diff --git a/crates/core/src/addon/pack/archive.rs b/crates/core/src/addon/pack/archive.rs index 141d6d7..3a231a3 100644 --- a/crates/core/src/addon/pack/archive.rs +++ b/crates/core/src/addon/pack/archive.rs @@ -254,8 +254,13 @@ impl PackArchive { /// 目录不存在时返回空表——"包没带 overrides"不是错误。 /// /// 这是**唯一会批量解压的 API**,所以它自己持有一个累计预算 - /// (`PackLimits::max_uncompressed_bytes`),逐条按实际读出的字节扣减: - /// 声明大小撒谎的包不会在这里把 CPU 时间耗光(r1 p3-4)。 + /// (`PackLimits::max_uncompressed_bytes`),本次调用内逐条按实际读出的字节扣减: + /// 声明大小撒谎的包不会靠"很多条各自合规"把 CPU 时间耗光(r1 p3-4)。 + /// + /// **额度是"每次调用一份",不是"整包一份"**(r2 p3-b):格式读者若对多个覆盖目录各调一次 + /// (mrpack 会连调三次:`overrides` / `client-overrides` / `server-overrides`),理论上限 + /// 是 `max_uncompressed_bytes × 调用次数`。要整包共用一份额度,就自己建 [`ReadBudget`] + /// 并走 [`Self::override_files_with`];`open` 阶段的"声明总量"仍然是整包只判一次。 pub fn override_files(&self, dir: &str) -> PackResult> { let budget = ReadBudget::new(self.limits.max_uncompressed_bytes); self.override_files_with(dir, &budget) diff --git a/crates/core/src/addon/pack/archive/limits.rs b/crates/core/src/addon/pack/archive/limits.rs index 524306c..03dbc11 100644 --- a/crates/core/src/addon/pack/archive/limits.rs +++ b/crates/core/src/addon/pack/archive/limits.rs @@ -59,6 +59,10 @@ impl Default for PackLimits { /// 一次批量读取的**累计**预算(读取阶段,按实际读出的字节扣减)。 /// +/// 作用域是"拿到它的那次扫描":`PackArchive::override_files` 每次调用新建一份, +/// 所以要跨多次调用共享额度(例如整包只给一份 `max_uncompressed_bytes`), +/// 必须由调用方自建一个并交给 `override_files_with`(r2 p3-b)。 +/// /// 为什么需要它:中央目录声明的大小可以撒谎,单个条目也被限制在 /// [`PackLimits::max_entry_bytes`],但"很多条各自合规"累起来仍可能远超 /// `max_uncompressed_bytes`。批量读取(`override_files`)持有一个预算,边读边扣。 diff --git a/crates/core/src/addon/pack/curseforge.rs b/crates/core/src/addon/pack/curseforge.rs index 466e5ed..8ee4364 100644 --- a/crates/core/src/addon/pack/curseforge.rs +++ b/crates/core/src/addon/pack/curseforge.rs @@ -24,8 +24,10 @@ //! `disabled` 即 `required: false`。`client`/`server` 分侧语义 CF 表达不了(mrpack 专属)。 //! - **跨格式导出不完整**(M112 的活):`modLoaders[].id` 只对 CF 来源是合法形状; //! MultiMC 的 `net.fabricmc.fabric-loader` / 非加载器组件会被逐字写进去(CF 不认); -//! `overrides` 字段会照抄模型里的目录名(从 MultiMC 转过来是 `.minecraft`,落到 ULL 实例里 -//! 会变成 `.minecraft/.minecraft/…`)。同一份模型只有 `read` 出来的那个格式才保证往返。 +//! `overrides` 字段会照抄模型里的目录名(从 MultiMC 转过来是 `.minecraft`)。 +//! 按 CF 语义 `overrides` 只是 zip 内**前缀**,导入器把该前缀下的内容铺到游戏目录, +//! 所以内容不会丢;但"前缀目录本身要不要跟着拷一层"取决于导入器实现(Prism/ULL 的实例根 +//! 本身就叫 `.minecraft` 时可能多出一层)。同一份模型只有 `read` 出来的那个格式才保证往返。 use serde::Deserialize; use serde_json::{json, Map, Value}; diff --git a/crates/core/src/addon/pack/manifest.rs b/crates/core/src/addon/pack/manifest.rs index 475b9ae..12fa353 100644 --- a/crates/core/src/addon/pack/manifest.rs +++ b/crates/core/src/addon/pack/manifest.rs @@ -333,28 +333,42 @@ impl PackManifest { Ok(()) } - /// 跨覆盖组撞同一实例路径的清单:`(路径, 撞车的侧集合)`,按路径排序。 + /// 撞同一实例路径的覆盖条目:`(路径, 涉及的侧, 条目份数)`,按路径排序。 /// - /// **按 `path` 跨侧计数**,不是按 `(side, path)`:mrpack 里 `overrides/`(Both 侧)与 - /// `client-overrides/`(Client 侧)写到同一个实例路径才是真实冲突,而两侧的键天然不同 - /// (r1 p2-2 之前这里恒返回空表,正好漏掉它要支撑的那个决策)。 + /// **两种冲突都要报**(r2 p2-2:上一版按侧去重,把"同侧撞车"这条能力换掉了): /// - /// 返回的多侧冲突由导入器决定优先级(本模块只报告,不选边)。 + /// - **跨侧**:`overrides/`(Both)与 `client-overrides/`(Client)写同一个实例路径—— + /// mrpack 唯一的真实冲突。按 `(side, path)` 计数会恒返回空表(两侧键天然不同)。 + /// - **同侧**:两个同侧组撞同一路径,或一个组里同一路径列了两次(模型被合并时才出现)。 + /// 按侧去重会把这份重复折叠掉,于是同样"查不出来"。 + /// + /// 判据:`侧数 > 1 || 条份数 > 侧数`(后者即同侧重复)。冲突由导入器决定优先级, + /// 本模块只报告、不选边。 #[must_use] - pub fn duplicate_override_paths(&self) -> Vec<(String, Vec)> { - let mut sides: HashMap<&str, Vec> = HashMap::new(); + pub fn duplicate_override_paths(&self) -> Vec<(String, Vec, usize)> { + struct Slot { + sides: Vec, + occurrences: usize, + } + + let mut slots: HashMap<&str, Slot> = HashMap::new(); for (side, file) in self.override_files() { - let entry = sides.entry(file.path.as_str()).or_default(); - if !entry.contains(&side) { - entry.push(side); + let slot = slots.entry(file.path.as_str()).or_insert(Slot { + sides: Vec::new(), + occurrences: 0, + }); + slot.occurrences += 1; + if !slot.sides.contains(&side) { + slot.sides.push(side); } } - let mut duplicated: Vec<(String, Vec)> = sides + + let mut duplicated: Vec<(String, Vec, usize)> = slots .into_iter() - .filter(|(_, sides)| sides.len() > 1) - .map(|(path, mut sides)| { - sides.sort_by_key(|side| side.as_str()); - (path.to_string(), sides) + .filter(|(_, slot)| slot.sides.len() > 1 || slot.occurrences > slot.sides.len()) + .map(|(path, mut slot)| { + slot.sides.sort_by_key(|side| side.as_str()); + (path.to_string(), slot.sides, slot.occurrences) }) .collect(); duplicated.sort_by(|left, right| left.0.cmp(&right.0)); diff --git a/crates/core/src/addon/pack/modrinth.rs b/crates/core/src/addon/pack/modrinth.rs index bde0a6e..15be43b 100644 --- a/crates/core/src/addon/pack/modrinth.rs +++ b/crates/core/src/addon/pack/modrinth.rs @@ -154,7 +154,8 @@ impl PackFormat for Modrinth { group.dir )) .with_hint( - "按 side 分派到 overrides / client-overrides / server-overrides 属 M112; 不同格式的实例目录(如 MultiMC 的 .minecraft)不能直接搬过来", + "按 side 分派到 overrides / client-overrides / server-overrides 属 M112;\ + 不同格式的实例目录(如 MultiMC 的 .minecraft)不能直接搬过来", )); } if seen.contains(&group.side) { diff --git a/crates/core/src/addon/pack/tests/export.rs b/crates/core/src/addon/pack/tests/export.rs index a31d6af..14ab2aa 100644 --- a/crates/core/src/addon/pack/tests/export.rs +++ b/crates/core/src/addon/pack/tests/export.rs @@ -127,3 +127,30 @@ fn same_format_export_is_still_allowed() { .check_overrides(&manifest) .expect("mrpack 自己读出来的模型必须能被 mrpack 表达"); } + +#[test] +fn mrpack_refuses_two_override_groups_for_the_same_side() { + // r2 p3-a:mrpack 的「每侧只能有一个覆盖目录」分支此前零测试(删掉它 59 条全绿)。 + // 目录名检查管不到这里:两个组的 `dir` 都合法,区别只在于同一侧出现了两次。 + let mut manifest = PackManifest::new("同侧两个覆盖目录"); + manifest.version = Some("1.0.0".to_string()); + manifest.minecraft = Some("1.20.1".to_string()); + manifest + .overrides + .push(PackOverrides::new("overrides", PackSide::Both)); + manifest + .overrides + .push(PackOverrides::new("overrides", PackSide::Both)); + + let target = temp_root("export-mr-same-side").join("out.mrpack"); + let error = MODRINTH + .write(&manifest, PackPayload::metadata_only(), &target) + .expect_err("同一侧两个覆盖目录必须拒"); + assert_eq!(error.kind(), PackErrorKind::Unsupported, "{error}"); + assert!( + error.message().contains("每个侧只能有一个覆盖目录"), + "错误要指出是「同侧重复」而不是「目录名不对」:{}", + error.message() + ); + assert!(!target.exists(), "被拒时不许留下目标文件"); +} diff --git a/crates/core/src/addon/pack/tests/model.rs b/crates/core/src/addon/pack/tests/model.rs index 3184b25..ad229a2 100644 --- a/crates/core/src/addon/pack/tests/model.rs +++ b/crates/core/src/addon/pack/tests/model.rs @@ -33,9 +33,45 @@ fn duplicate_override_paths_counts_across_sides_not_per_side() { manifest.duplicate_override_paths(), vec![( "config/x.toml".to_string(), - vec![PackSide::Both, PackSide::Client] + vec![PackSide::Both, PackSide::Client], + 2 )], - "只报真正撞车的路径,并给出涉及的侧" + "只报真正撞车的路径,并给出涉及的侧与条目份数" + ); +} + +#[test] +fn duplicate_override_paths_still_reports_two_groups_on_the_same_side() { + // r2 p2-2:上一版按侧去重,把这半能力换掉了 —— 两个 Both 组撞同一路径实测返回 `[]`。 + // 现在判据是「侧数 > 1 || 条份数 > 侧数」,两种冲突同时报得出来。 + let mut first = PackOverrides::new("overrides", PackSide::Both); + first.files.push(file("config/x.toml", "a", 1)); + let mut second = PackOverrides::new("extra-overrides", PackSide::Both); + second.files.push(file("config/x.toml", "b", 2)); + let manifest = PackManifest { + overrides: vec![first, second], + ..PackManifest::default() + }; + assert_eq!( + manifest.duplicate_override_paths(), + vec![("config/x.toml".to_string(), vec![PackSide::Both], 2)], + "同侧两个组撞同一路径必须报(侧集合只有一个,份数是 2)" + ); +} + +#[test] +fn duplicate_override_paths_reports_the_same_path_twice_inside_one_group() { + // 同侧撞车的另一种形态:一个组里同一路径列了两次(模型被合并出来时会出现)。 + let mut group = PackOverrides::new("overrides", PackSide::Both); + group.files.push(file("config/x.toml", "a", 1)); + group.files.push(file("config/x.toml", "b", 2)); + let manifest = PackManifest { + overrides: vec![group], + ..PackManifest::default() + }; + assert_eq!( + manifest.duplicate_override_paths(), + vec![("config/x.toml".to_string(), vec![PackSide::Both], 2)] ); }