fix(core): M110 r2 —— duplicate_override_paths 两种冲突同时报,补 mrpack 同侧分支用例

- p2-2:`duplicate_override_paths` 改成「先按 path 分组,再在组内报 (侧集合, 条目份数)」,
  判据 `侧数 > 1 || 份数 > 侧数`。r1 修好了跨侧(Both×Client)但按侧去重把「同侧两个组
  撞同一路径」这条旧能力换掉了(实测返回 []);现在两种冲突同时报得出来,返回值升为
  `Vec<(String, Vec<PackSide>, 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 内前缀,
  内容铺开不丢;要不要多拷一层取决于导入器)。
This commit is contained in:
AskaEth committed 2026-11-04 00:20:25 +08:00
1 parent 6dd652aced
commit 34f3db359a
7 files changed
+111 -22

No files matched your search

+7 -2
View File
@@ -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<Vec<OverrideFile>> {
let budget = ReadBudget::new(self.limits.max_uncompressed_bytes);
self.override_files_with(dir, &budget)
@@ -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`)持有一个预算,边读边扣。
+4 -2
View File
@@ -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};
+29 -15
View File
@@ -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<PackSide>)> {
let mut sides: HashMap<&str, Vec<PackSide>> = HashMap::new();
pub fn duplicate_override_paths(&self) -> Vec<(String, Vec<PackSide>, usize)> {
struct Slot {
sides: Vec<PackSide>,
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<PackSide>)> = sides
let mut duplicated: Vec<(String, Vec<PackSide>, 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));
+2 -1
View File
@@ -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) {
@@ -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(), "被拒时不许留下目标文件");
}
+38 -2
View File
@@ -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)]
);
}