diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml index 6623de3ca..d42b9925d 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -1076,6 +1076,33 @@ You need to install a mod loader such as Forge, Fabric, etc. to use mods. Please go to the download page to install one. If you have already installed a mod loader, you may have selected the wrong instance. Please switch to the correct instance. Select an instance This instance cannot install mods + Mod Relations + View mod dependencies, missing prerequisites and bundled (Jar-in-Jar) structure + An overview of dependencies and bundled structure between mods. Disabling or deleting a mod will warn about affected dependencies. + No mod relations to analyze + None of this instance's mods declare dependencies or bundle other mods, or the mod list has not finished loading. + Warnings + Mod relations + Bundled mods + Dependencies + {0} is missing prerequisite(s): {1} + {0} has version-incompatible prerequisite(s): {1} + Installed + Disabled + Bundled + Version mismatch + Missing + [optional] + ✓ used by this instance + ⚠ incompatible with this version + {0} versions + [bundled · from {0}] + Search mods: name / ModId / dependency + Only this mod + Also disable mods that depend on it + Also delete mods that depend on it + Disabling this mod affects {0} mod(s) that depend on it. Choose how to proceed: + Deleting this mod affects {0} mod(s) that depend on it. Choose how to proceed: Download Litematica You may need to install the Litematica mod first. If you have already installed one, please launch the game once. Alternatively, you may have selected the wrong instance. Please use the instance selection button to switch instances. Schematics unavailable for this instance diff --git a/PCL.Core/App/Localization/Languages/zh-CN.xaml b/PCL.Core/App/Localization/Languages/zh-CN.xaml index eab971756..d5214fd4a 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -1076,6 +1076,33 @@ 你需要先安装 Forge、Fabric 等模组加载器才能使用模组,请在下载页面安装这些实例。 如果你已经安装过了模组加载器,那么你很可能选择了错误的实例,请点击实例选择按钮切换实例。 实例选择 该实例不可使用模组 + 模组关系 + 查看模组的依赖关系、缺失前置与内嵌(Jar-in-Jar)结构 + 这里概览模组间的依赖关系与内嵌结构。禁用或删除模组时会提示受影响的依赖项。 + 没有可分析的模组关系 + 当前实例的模组均无依赖声明或内嵌模组,或模组列表尚未加载完成。 + 警告 + 模组关系 + 内嵌模组 + 依赖 + {0} 缺少前置:{1} + {0} 前置版本不符:{1} + 已安装 + 已禁用 + 内嵌提供 + 版本不符 + 缺失 + [可选] + ✓ 当前实例使用 + ⚠ 不适配当前版本 + {0} 个版本 + [内置 · 来自 {0}] + 搜索模组:名称 / ModId / 依赖 + 仅操作此模组 + 连带禁用依赖它的模组 + 连带删除依赖它的模组 + 禁用此模组会影响 {0} 个依赖它的模组,请选择处理方式 + 删除此模组会影响 {0} 个依赖它的模组,请选择处理方式 下载投影模组 你可能需要先安装投影模组,如果已经安装过了投影模组请先启动一次游戏。 也可能是你选择错了实例,请点击实例选择按钮切换实例。 该实例不可用投影原理图 diff --git a/Plain Craft Launcher 2/FormMain.xaml.cs b/Plain Craft Launcher 2/FormMain.xaml.cs index 3826112f3..1a87d9bd0 100644 --- a/Plain Craft Launcher 2/FormMain.xaml.cs +++ b/Plain Craft Launcher 2/FormMain.xaml.cs @@ -1465,6 +1465,7 @@ public enum PageSubType VersionSchematic = 9, VersionInstall = 10, VersionServer = 11, + VersionModJarInJar = 12, VersionSavesInfo = 0, VersionSavesDatapack = 1 } diff --git a/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Export/CrashReportExporter.cs b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Export/CrashReportExporter.cs index 737f76d01..52062e53c 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Export/CrashReportExporter.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Export/CrashReportExporter.cs @@ -264,6 +264,7 @@ private static void _WriteModInfo(string reportFolder, McInstance? instance) try { + ModJarInJarCache.UseInstance(instance.PathInstance); var modsFolderName = ModLocalComp.GetPathNameByCompType(ModComp.CompType.Mod); var modsFolder = instance.Info.HasLabyMod ? Path.Combine(instance.PathIndie, "labymod-neo", "fabric", instance.Info.VanillaName, modsFolderName) @@ -345,8 +346,9 @@ private static void _WriteModInfo(string reportFolder, McInstance? instance) foreach (var mod in activeMods) { var line = "| |-> " + (mod.Name ?? mod.FileName); - if (!string.IsNullOrWhiteSpace(mod.Version)) - line += $" ({mod.Version})"; + var version = _CleanVersion(mod.Version); + if (!string.IsNullOrWhiteSpace(version)) + line += $" ({version})"; if (mod.Name != mod.FileName) line += $" [{mod.FileName}]"; sb.AppendLine(line); @@ -370,12 +372,19 @@ private static void _WriteModInfo(string reportFolder, McInstance? instance) sb.AppendLine(Lang.Text("Crash.Report.JarInJarMod.JarInJarNone")); CrashFileIo.WriteText(Path.Combine(reportFolder, ModInfoFileName), sb.ToString(), Encoding.UTF8); + ModJarInJarCache.Flush(); LogWrapper.Info("Crash", "已导出模组列表及 Jar-in-Jar 信息"); } catch (Exception ex) { LogWrapper.Warn(ex, "Crash", "导出模组信息失败"); } + finally + { + // 复位本线程的"当前实例":含 mods 目录不存在的 early return 与异常路径, + // 否则本线程后续懒加载别实例的 Mod 会被路由进本实例缓存 + ModJarInJarCache.UseInstance(null); + } } private static IEnumerable _FlattenEmbedded(List mods) @@ -395,11 +404,16 @@ private static void _AppendEmbeddedMods(StringBuilder builder, List " + (mod.Name ?? mod.ModId ?? "?"); - if (!string.IsNullOrWhiteSpace(mod.Version)) - line += $" ({mod.Version})"; + var version = _CleanVersion(mod.Version); + if (!string.IsNullOrWhiteSpace(version)) + line += $" ({version})"; builder.AppendLine(line); if (mod.EmbeddedMods.Any()) _AppendEmbeddedMods(builder, mod.EmbeddedMods, depth + 1); } } + + // 未解析的版本占位符(如 Fabric ${version}、Forge ${file.jarVersion})显示为空,避免裸 token + private static string _CleanVersion(string version) + => string.IsNullOrEmpty(version) || version.Contains("${") ? null : version; } \ No newline at end of file diff --git a/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs b/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs new file mode 100644 index 000000000..34509851b --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs @@ -0,0 +1,362 @@ +using System; +using System.Collections.Generic; + +namespace PCL; + +/// +/// 判断某版本是否满足一条依赖版本约束。按加载器方言分派: +/// Forge/NeoForge = Maven 区间([a,b] [a,b) (a,b) [a,) (,b] [a] 或裸 a=软下限); +/// Fabric/Quilt = SemVer 谓词(|| OR、空格 AND、>= > <= < =x/* 通配、尾 -)。 +/// 哲学:宁可不标不可错标——任何拿不准的边界一律判不满足(fail-closed),比较统一走 +/// 以正确处理快照/预发布/年份版本。 +/// +public static class McConstraintMatcher +{ + public static bool IsForgeLike(string loaderType) => + string.Equals(loaderType, "Forge", StringComparison.OrdinalIgnoreCase) || + string.Equals(loaderType, "NeoForge", StringComparison.OrdinalIgnoreCase); + + /// 某内嵌副本相对当前实例 MC 版本的匹配类型(用于多版本 wrapper 折叠时标记当前副本)。 + /// 数值顺序即代表副本选择的优先级。 + public enum MatchKind + { + None, // 无从判断(拿不到实例版本) + Incompatible, // 有实例版本,此副本声明的范围不含它 + NoConstraint, // 副本无 MC 约束:任意版本均会加载,但无从"精确匹配"(优先于不兼容、低于范围命中) + Range, // 声明的版本范围覆盖当前实例 + Exact // 文件名/版本号里精确出现当前实例版本 + } + + /// + /// 判断内嵌副本在 下的匹配类型:精确命中(文件名/版本含该版本词) > + /// 范围覆盖(约束满足) > 无约束(恒加载) > 不兼容。用于多版本 wrapper 选出当前实例会加载的那份并标记。 + /// + public static MatchKind Match(string fileName, string version, string constraint, string loaderType, + string instanceMc) + { + if (string.IsNullOrWhiteSpace(instanceMc)) return MatchKind.None; + if (ContainsVersionToken(fileName, instanceMc) || ContainsVersionToken(version, instanceMc)) + return MatchKind.Exact; + if (string.IsNullOrWhiteSpace(constraint)) return MatchKind.NoConstraint; + return Satisfies(constraint, loaderType, instanceMc) ? MatchKind.Range : MatchKind.Incompatible; + } + + /// + /// 是否满足 。约束为空视为无限制(满足)。 + /// 决定方言;为 null/未知时先按 semver 再按 Maven 兜底。 + /// + public static bool Satisfies(string constraint, string loaderType, string version) + { + if (string.IsNullOrWhiteSpace(constraint)) return true; + if (string.IsNullOrWhiteSpace(version)) return false; + try + { + // 语法特征优先于 loader 声明:混合元数据 jar(fabric.mod.json + mods.toml 并存)可能把 + // Maven 区间挂在 Fabric loader 名下,按语法自识别可避免方言错配恒判不满足 + var t = constraint.TrimStart(); + var looksMaven = t.Length > 0 && (t[0] == '[' || t[0] == '('); + var looksSemver = constraint.IndexOfAny(new[] { '>', '<', '~', '^', '*' }) >= 0 || + constraint.Contains("||"); + if (looksMaven && !looksSemver) return SatisfiesMaven(constraint, version); + if (looksSemver && !looksMaven) return SatisfiesSemVer(constraint, version); + + // 语法无法区分(如裸版本:Maven=软下限,semver=精确):按 loader 方言 + if (IsForgeLike(loaderType)) return SatisfiesMaven(constraint, version); + if (loaderType is null) return SatisfiesSemVer(constraint, version) || SatisfiesMaven(constraint, version); + return SatisfiesSemVer(constraint, version); + } + catch + { + return false; + } + } + + // 剥 SemVer build metadata(+ 及其后),供 semver 比较用 + private static string StripBuild(string s) + { + var i = s.IndexOf('+'); + return i < 0 ? s : s.Substring(0, i); + } + + // 剥版本号常见的前导 v/V(如 v0.5.1c → 0.5.1c);仅在其后紧跟数字时剥 + public static string StripV(string s) => + s is { Length: > 1 } && (s[0] == 'v' || s[0] == 'V') && char.IsDigit(s[1]) ? s.Substring(1) : s; + + // 比较两个版本,各自先剥前导 v + private static int Cmp(string a, string b) => McVersionComparer.CompareVersion(StripV(a), StripV(b)); + + /// 约束是否有可解析的下界版本(供 provider 检查:无可解析下界时应 fail-open 而非误判缺失)。 + public static bool HasComparableLowerBound(string constraint) + { + if (string.IsNullOrWhiteSpace(constraint)) return false; + var t = constraint.TrimStart('[', '(', '>', '<', '=', '~', '^', ' ', '"'); + return IsKnown(t); + } + + // 版本串是否可比较(1.20.1 / 26.1 / 23w13a 数字开头,或 v0.5.1c 剥 v 后数字开头;剥掉 Fabric 尾 - 后判断) + private static bool IsKnown(string s) + { + if (string.IsNullOrEmpty(s)) return false; + if (s.EndsWith("-")) s = s.Substring(0, s.Length - 1); + s = StripV(s); + return s.Length > 0 && char.IsDigit(s[0]); + } + + #region Maven 区间(Forge / NeoForge) + + private static bool SatisfiesMaven(string constraint, string mc) + { + foreach (var interval in SplitTopLevel(constraint)) + { + var s = interval.Trim(); + if (s.Length == 0) continue; + + if (s[0] != '[' && s[0] != '(') + { + if (IsKnown(s) && Cmp(mc,s) >= 0) return true; // 裸版本=软下限 + continue; + } + + if (s.Length < 2 || (s[^1] != ']' && s[^1] != ')')) continue; // 畸形区间(未闭合):不猜 + var incLo = s[0] == '['; + var incHi = s[^1] == ']'; + var body = s.Substring(1, s.Length - 2); + var comma = body.IndexOf(','); + if (comma < 0) + { + var only = body.Trim(); // [a] 精确 + if (IsKnown(only) && Cmp(mc,only) == 0) return true; + continue; + } + + var loStr = body.Substring(0, comma).Trim(); + var hiStr = body.Substring(comma + 1).Trim(); + if ((loStr.Length > 0 && !IsKnown(loStr)) || (hiStr.Length > 0 && !IsKnown(hiStr))) + continue; // 边界不可解析:整段判不满足(fail-closed) + var ok = true; + if (loStr.Length > 0) + { + var c = Cmp(mc,loStr); + ok = incLo ? c >= 0 : c > 0; + } + + if (ok && hiStr.Length > 0) + { + var c = Cmp(mc,hiStr); + ok = incHi ? c <= 0 : c < 0; + } + + if (ok) return true; + } + + return false; + } + + // 按括号深度为 0 的逗号切分(区间内部的逗号不切) + private static List SplitTopLevel(string s) + { + var outList = new List(); + int depth = 0, start = 0; + for (var i = 0; i < s.Length; i++) + { + var ch = s[i]; + if (ch == '[' || ch == '(') depth++; + else if (ch == ']' || ch == ')') depth--; + else if (ch == ',' && depth == 0) + { + outList.Add(s.Substring(start, i - start)); + start = i + 1; + } + } + + outList.Add(s.Substring(start)); + return outList; + } + + #endregion + + #region SemVer 谓词(Fabric / Quilt) + + private static int CompareSemVer(string a, string b) + { + a = StripBuild(a); + b = StripBuild(b); + var aPre = a.IndexOf('-'); + var bPre = b.IndexOf('-'); + var aBase = aPre < 0 ? a : a.Substring(0, aPre); + var bBase = bPre < 0 ? b : b.Substring(0, bPre); + var c = Cmp(aBase, bBase); + if (c != 0) return c; + if (aPre < 0 && bPre < 0) return 0; + if (aPre < 0) return 1; // a 无预发布 > b(有预发布) + if (bPre < 0) return -1; // a 有预发布 < b(正式版) + return ComparePrerelease(a.Substring(aPre + 1), b.Substring(bPre + 1)); + } + + private static int ComparePrerelease(string a, string b) + { + var pa = a.Split('.'); + var pb = b.Split('.'); + var n = Math.Min(pa.Length, pb.Length); + for (var i = 0; i < n; i++) + { + bool xn = IsAllDigits(pa[i]), yn = IsAllDigits(pb[i]); + int c; + if (xn && yn) c = CompareNumericId(pa[i], pb[i]); + else if (xn) c = -1; // 数字标识符 < 字母标识符 + else if (yn) c = 1; + else c = string.CompareOrdinal(pa[i], pb[i]); + if (c != 0) return c; + } + + return pa.Length.CompareTo(pb.Length); + } + + private static bool IsAllDigits(string s) + { + if (s.Length == 0) return false; + foreach (var ch in s) + if (!char.IsDigit(ch)) + return false; + return true; + } + + // 纯数字标识符按数值比:去前导零后先比长度再字典序,避免长串溢出 + private static int CompareNumericId(string x, string y) + { + x = x.TrimStart('0'); + y = y.TrimStart('0'); + return x.Length != y.Length ? x.Length - y.Length : string.CompareOrdinal(x, y); + } + + // 空格 = AND,|| = OR,运算符 >= > <= < =,x/* 通配,尾 - 预发布标记 + private static bool SatisfiesSemVer(string constraint, string mc) + { + foreach (var alt in constraint.Split(new[] { "||" }, StringSplitOptions.None)) // OR + { + var a = alt.Trim(); + if (a.Length == 0) continue; + var all = true; + foreach (var term in a.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries)) // AND + if (!TermMatches(term, mc)) + { + all = false; + break; + } + + if (all) return true; + } + + return false; + } + + private static bool TermMatches(string term, string mc) + { + if (term == "*" || string.Equals(term, "any", StringComparison.OrdinalIgnoreCase)) return true; + + var i = 0; + while (i < term.Length && "><=".IndexOf(term[i]) >= 0) i++; + var op = term.Substring(0, i); + var ver = term.Substring(i).Trim(); + // Fabric 预发布下限语法 ">=26.1-":下界纳入 26.1 的预发布(26.1-pre1 等),而非稳定版 26.1 + var prereleaseFloor = ver.EndsWith("-"); + if (prereleaseFloor) ver = ver.Substring(0, ver.Length - 1); + if (ver.Length == 0) return false; + + // ~(同 minor 内可升)与 ^(同 major 内可升;major=0 时按 semver 规则同 minor) + if (op.Length == 0 && (ver[0] == '~' || ver[0] == '^')) + { + var tilde = ver[0] == '~'; + var baseVer = ver.Substring(1).Trim(); + if (!IsKnown(baseVer)) return false; + if (CompareSemVer(mc, baseVer) < 0) return false; + var nums = new List(); + var cur = -1; + foreach (var ch in baseVer) + if (char.IsDigit(ch)) cur = (cur < 0 ? 0 : cur * 10) + (ch - '0'); + else if (cur >= 0) + { + nums.Add(cur); + cur = -1; + } + + if (cur >= 0) nums.Add(cur); + if (nums.Count == 0) return false; + string upper; + if (tilde) + { + // ~:允许 patch 级更新,上界为 minor+1(只有 major 时为 major+1) + upper = nums.Count >= 2 ? nums[0] + "." + (nums[1] + 1) : (nums[0] + 1).ToString(); + } + else + { + // ^:上界为第一个非零组件 +1(其后组件归零);^0.0.3 → 0.0.4,^0.2.3 → 0.3,^1.2.3 → 2 + var idx = 0; + while (idx < nums.Count && nums[idx] == 0) idx++; + if (idx >= nums.Count) idx = nums.Count - 1; // 全零 ^0.0.0:上界为末位 +1(仅匹配自身) + upper = string.Join(".", nums.Take(idx).Concat(new[] { nums[idx] + 1 })); + } + + return CompareSemVer(mc, upper) < 0; + } + + // 通配 1.20.x / 1.20.* 仅在无运算符时有意义 + if (op.Length == 0 && (ver.EndsWith(".x") || ver.EndsWith(".X") || ver.EndsWith(".*"))) + { + var prefix = ver.Substring(0, ver.Length - 2); + return mc == prefix || mc.StartsWith(prefix + ".", StringComparison.Ordinal); + } + + if (!IsKnown(ver)) return false; // 不可解析边界 fail-closed + if (prereleaseFloor && (op is "" or ">=" or ">") && + string.Equals(mc.Split('-')[0], ver, StringComparison.OrdinalIgnoreCase)) + return true; + var c = CompareSemVer(mc, ver); + return op switch + { + "" or "=" or "==" => c == 0, // Fabric 裸版本是精确匹配 + ">=" => c >= 0, + ">" => c > 0, + "<=" => c <= 0, + "<" => c < 0, + _ => false // 异常运算符:不猜 + }; + } + + #endregion + + #region 版本 token(文件名/版本号精确命中兜底) + + // version 是否作为完整版本词出现在 haystack 中("1.21" 不命中 "1.21.2" 或 "9.1.20") + public static bool ContainsVersionToken(string haystack, string version) + { + if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(version)) return false; + var i = haystack.IndexOf(version, StringComparison.Ordinal); + while (i >= 0) + { + var before = i > 0 ? haystack[i - 1] : ' '; + var end = i + version.Length; + var after = end < haystack.Length ? haystack[end] : ' '; + var leadingOk = before != '.' && !char.IsDigit(before); + bool trailingOk; + if (char.IsDigit(after)) + trailingOk = false; + else if (after == '.') + { + var next = end + 1 < haystack.Length ? haystack[end + 1] : ' '; + trailingOk = !char.IsDigit(next); + } + else + { + trailingOk = true; + } + + if (leadingOk && trailingOk) return true; + i = haystack.IndexOf(version, i + 1, StringComparison.Ordinal); + } + + return false; + } + + #endregion +} diff --git a/Plain Craft Launcher 2/Modules/Minecraft/McVersionComparer.cs b/Plain Craft Launcher 2/Modules/Minecraft/McVersionComparer.cs index ed709a99a..19e1046a3 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/McVersionComparer.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/McVersionComparer.cs @@ -41,15 +41,10 @@ public static int CompareVersion(string left, string right) var i = 0; while (true) { - // 两边均缺失,感觉是一个东西 + // 两边 token(含补零)全部相等:视为同一版本。 + // 不再回退原始串 ordinal 比较——那会把 "1.20" 判得小于 "1.20.0"(前缀关系≠更旧) if (lefts.Count - 1 < i && rights.Count - 1 < i) - { - if (string.CompareOrdinal(left, right) > 0) - return 1; - if (string.CompareOrdinal(left, right) < 0) - return -1; return 0; - } // 确定两边的数值 var leftValue = lefts.Count - 1 < i ? "0" : lefts[i]; diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs index 2b91f8456..77f224ed9 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs @@ -13,41 +13,130 @@ namespace PCL; public static class ModJarInJar { private const int MaxDepth = 5; + private const int MaxNodes = 512; /// /// 解析 内嵌套的其它 Mod jar,返回内嵌 Mod 列表。 /// public static List Resolve(string parentPath, ZipArchive jar, int depth = 0) + => _Resolve(parentPath, jar, depth, new[] { MaxNodes, 0 }); + + /// + /// 带持久化缓存的解析:按文件指纹命中缓存则直接重建,否则解析并写入缓存(批量结束后需调用 + /// 落盘)。 + /// + public static List ResolveCached(string modFilePath, ZipArchive jar) + { + long lastModified, size; + try + { + var fi = new FileInfo(modFilePath); + lastModified = fi.LastWriteTimeUtc.Ticks; + size = fi.Length; + } + catch + { + return Resolve(modFilePath, jar); + } + + var cached = ModJarInJarCache.TryGet(modFilePath, lastModified, size); + if (cached is not null) return _FromNodes(cached, modFilePath); + + var budget = new[] { MaxNodes, 0 }; + var tree = _Resolve(modFilePath, jar, 0, budget); + // 截断树(预算耗尽,budget[1]==1)不入盘:宿主指纹不变会被永久复用,缺失的 id 永不再现;下次启动重扫 + if (budget[1] == 0) + ModJarInJarCache.Set(modFilePath, lastModified, size, _ToNodes(tree)); + return tree; + } + + private static List _ToNodes(List mods) + => mods.Select(m => new EmbeddedModNode + { + FileName = m.FileName, + Name = m.Name, + ModId = m.ModId, + Version = m.Version, + Loader = m.JijLoader, + TargetMcVersion = m.JijTargetMcVersion, + Dependencies = new Dictionary(m.DependencyRaw), + OptionalDeps = m.OptionalDependencies.ToList(), + Children = _ToNodes(m.EmbeddedMods) + }).ToList(); + + private static List _FromNodes(List nodes, string parentPath) + { + var result = new List(); + if (nodes is null) return result; + foreach (var node in nodes) + { + var childPath = parentPath + "!/" + node.FileName; + var child = new ModLocalComp.LocalCompFile(childPath); + child.SetJijMetadata(node.Name, node.ModId, node.Version); + child.JijLoader = node.Loader; + child.JijTargetMcVersion = node.TargetMcVersion; + child.SetJijDependencies(node.Dependencies, node.OptionalDeps); + child.EmbeddedMods = _FromNodes(node.Children, childPath); + result.Add(child); + } + + return result; + } + + private static List _Resolve(string parentPath, ZipArchive jar, int depth, int[] budget) { var result = new List(); if (depth >= MaxDepth) return result; var nestedPaths = new List(); _CollectFabricNestedJars(jar, nestedPaths); + _CollectQuiltNestedJars(jar, nestedPaths); _CollectForgeNestedJars(jar, nestedPaths); + _CollectManifestEmbeddedJars(jar, nestedPaths); foreach (var nestedPath in nestedPaths.Distinct()) { + if (budget[0] <= 0) + { + budget[1] = 1; // 节点预算耗尽,标记截断(供上层决定不入盘),避免病态嵌套爆树 + break; + } + + var entry = jar.GetEntry(nestedPath); + if (entry is null) continue; + budget[0]--; + + var childPath = parentPath + "!/" + nestedPath; + var child = new ModLocalComp.LocalCompFile(childPath); + child.MarkLoaded(); + // 始终列出该内嵌项:即使下方元数据解析/递归失败,也能按文件名保留(不丢节点) + result.Add(child); + + string tmp = null; try { - var entry = jar.GetEntry(nestedPath); - if (entry is null) continue; - using var ms = new MemoryStream(); - using (var es = entry.Open()) es.CopyTo(ms); - ms.Position = 0; - using var nestedJar = new ZipArchive(ms, ZipArchiveMode.Read); - - var childPath = parentPath + "!/" + nestedPath; - var child = new ModLocalComp.LocalCompFile(childPath); + // 嵌套流不可 seek,落临时文件后再作为 zip 打开,避免大嵌套 jar 全量进内存 + tmp = Path.GetTempFileName(); + using (var es = entry.Open()) + using (var fs = File.Create(tmp)) + es.CopyTo(fs); + using var nestedJar = ZipFile.OpenRead(tmp); child.LookupMetadata(nestedJar); - child.MarkLoaded(); - child.EmbeddedMods = Resolve(childPath, nestedJar, depth + 1); - result.Add(child); + child.JijLoader = DetectLoader(nestedJar); + // 用原始约束(未 Maven 化),保留 Fabric semver 原貌供求值 + child.JijTargetMcVersion = child.DependencyRaw.TryGetValue("minecraft", out var mc) ? mc : null; + child.EmbeddedMods = _Resolve(childPath, nestedJar, depth + 1, budget); } catch (Exception ex) { ModBase.Log(ex, "解析内嵌 Mod 失败(" + parentPath + " -> " + nestedPath + ")", ModBase.LogLevel.Developer); } + finally + { + if (tmp is not null) + try { File.Delete(tmp); } + catch { /* 临时文件清理失败无妨 */ } + } } return result; @@ -88,4 +177,59 @@ private static void _CollectForgeNestedJars(ZipArchive jar, List paths) ModBase.Log(ex, "解析 META-INF/jarjar/metadata.json 内嵌清单失败", ModBase.LogLevel.Developer); } } + + // Quilt:quilt.mod.json 的 quilt_loader.jars(字符串数组,直接为内嵌 jar 路径) + private static void _CollectQuiltNestedJars(ZipArchive jar, List paths) + { + try + { + var entry = jar.GetEntry("quilt.mod.json"); + if (entry is null) return; + var obj = (JsonObject)ModBase.GetJson(ModBase.ReadFile(entry.Open())); + if (obj.TryGetPropertyValue("quilt_loader", out var ql) && ql is JsonObject qlo + && qlo.TryGetPropertyValue("jars", out var jars) && jars is JsonArray arr) + foreach (var j in arr) + { + var s = j?.ToString(); + if (!string.IsNullOrEmpty(s)) paths.Add(s); + } + } + catch (Exception ex) + { + ModBase.Log(ex, "解析 quilt.mod.json 内嵌清单失败", ModBase.LogLevel.Developer); + } + } + + // JAR manifest 的 Embedded-Dependencies-Mod:无 mods.toml 的“包装 jar”仅通过它声明内嵌 mod + private static void _CollectManifestEmbeddedJars(ZipArchive jar, List paths) + { + try + { + var entry = jar.GetEntry("META-INF/MANIFEST.MF"); + if (entry is null) return; + foreach (var raw in ModBase.ReadFile(entry.Open()).Split('\n')) + { + var line = raw.TrimEnd('\r'); + if (!line.StartsWith("Embedded-Dependencies-Mod:", StringComparison.OrdinalIgnoreCase)) continue; + var value = line.Substring("Embedded-Dependencies-Mod:".Length).Trim(); + if (!string.IsNullOrEmpty(value)) paths.Add(value); + return; + } + } + catch (Exception ex) + { + ModBase.Log(ex, "解析 MANIFEST.MF 内嵌声明失败", ModBase.LogLevel.Developer); + } + } + + /// 按存在的清单文件判断 jar 声明的加载器(Fabric/Quilt/Forge/NeoForge);无从判断返回 null。 + public static string DetectLoader(ZipArchive jar) + { + if (jar.GetEntry("fabric.mod.json") is not null) return "Fabric"; + if (jar.GetEntry("quilt.mod.json") is not null) return "Quilt"; + if (jar.GetEntry("META-INF/neoforge.mods.toml") is not null) return "NeoForge"; + if (jar.GetEntry("META-INF/mods.toml") is not null) return "Forge"; + if (jar.GetEntry("mcmod.info") is not null) return "Forge"; + return null; + } } diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs new file mode 100644 index 000000000..43baf7a6d --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace PCL; + +/// +/// 内嵌模组(Jar-in-Jar)解析结果的持久化缓存。每实例一个文件,位于该实例的 +/// PCL\JarInJar.json(与 config.v1.yml 同级)。按 Mod 文件路径 + (最后修改时间, 大小) 指纹判断 +/// 有效性,避免每次加载都重新递归解析嵌套 jar;也为后续依赖/级联分析提供可查询的内嵌索引。 +/// 使用前须先 注册目标实例,用毕 落盘。 +/// "当前实例"按线程记录([ThreadStatic]),模组列表加载与崩溃导出各自的线程互不干扰。 +/// +public static class ModJarInJarCache +{ + /// 缓存数据结构变化时递增此值以令旧缓存失效(改动 JIJ 解析/节点字段后务必升此值)。 + private const int FormatVersion = 5; + + private static readonly JsonSerializerOptions _jsonOpts = new() + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + private static readonly object _lock = new(); + private static readonly Dictionary _stores = new(StringComparer.OrdinalIgnoreCase); + + // 每线程各自的"当前实例":列表加载线程与崩溃导出线程并发时互不干扰, + // 避免一个线程 UseInstance 切走 _current 后,另一个线程路由回退写进错误实例的缓存 + [ThreadStatic] private static _Store _current; + + private class _Store + { + public string InstancePath; + public string CachePath; + public Dictionary Entries; // null = 未加载 + public bool Dirty; + } + + public class CacheEntry + { + public long LastModified { get; set; } + public long Size { get; set; } + public List Tree { get; set; } = new(); + } + + private class CacheFile + { + public int Version { get; set; } + public Dictionary Entries { get; set; } = new(); + } + + /// + /// 注册并切换到某实例的缓存(\PCL\JarInJar.json)。 + /// 传空表示后续路由不到的读写不走缓存。 + /// + public static void UseInstance(string instancePath) + { + lock (_lock) + { + if (string.IsNullOrEmpty(instancePath)) + { + _current = null; + return; + } + + var key = instancePath.TrimEnd('\\', '/'); + if (!_stores.TryGetValue(key, out var store)) + { + store = new _Store + { + InstancePath = key, + CachePath = Path.Combine(key, "PCL", "JarInJar.json") + }; + _stores[key] = store; + } + + _current = store; + } + } + + // 启/禁用是纯改名、mtime 不变;剥 .disabled 后复用同键避免白白重扫。 + // 保留 .old(.old 可与新文件并存,剥了会键冲突)。 + private static string _NormalizeKey(string path) + { + if (path is null) return null; + return path.EndsWith(".disabled", StringComparison.OrdinalIgnoreCase) + ? path.Substring(0, path.Length - ".disabled".Length) + : path; + } + + private static void _EnsureLoaded(_Store store) + { + if (store.Entries is not null) return; + store.Entries = new Dictionary(); + try + { + if (!File.Exists(store.CachePath)) return; + using var doc = JsonDocument.Parse(File.ReadAllText(store.CachePath)); + var root = doc.RootElement; + // 格式版本不符:丢整表(避免旧结构的错值按指纹命中) + if (!root.TryGetProperty("Version", out var ver) || ver.GetInt32() != FormatVersion) return; + if (!root.TryGetProperty("Entries", out var entries) || entries.ValueKind != JsonValueKind.Object) return; + // 逐条容错:单条损坏只丢那条,不丢整表 + foreach (var prop in entries.EnumerateObject()) + try + { + var entry = prop.Value.Deserialize(); + if (entry is not null) store.Entries[prop.Name] = entry; + } + catch (Exception ex) + { + ModBase.Log(ex, "跳过损坏的 Jar-in-Jar 缓存条目:" + prop.Name, ModBase.LogLevel.Developer); + } + } + catch (Exception ex) + { + ModBase.Log(ex, "读取 Jar-in-Jar 缓存失败,已重置", ModBase.LogLevel.Developer); + } + } + + /// 指纹匹配时返回缓存的内嵌树,否则返回 null。 + public static List TryGet(string path, long lastModified, long size) + { + // [ThreadStatic] 读取无需锁;未启用缓存的线程(如 UI 惰性加载)直接 bypass, + // 不必排队等别的线程在锁内做的读盘/落盘重 IO + var store = _current; + if (store is null) return null; + lock (_lock) + { + _EnsureLoaded(store); + if (store.Entries.TryGetValue(_NormalizeKey(path), out var e) && e.LastModified == lastModified && + e.Size == size) + return e.Tree; + return null; + } + } + + public static void Set(string path, long lastModified, long size, List tree) + { + var store = _current; + if (store is null) return; + lock (_lock) + { + _EnsureLoaded(store); + store.Entries[_NormalizeKey(path)] = new CacheEntry { LastModified = lastModified, Size = size, Tree = tree }; + store.Dirty = true; + } + } + + /// + /// 清理当前实例存储中已不存在的文件条目(删除/改名后残留), 为本次 + /// 扫描到的全部 Mod 文件路径。应由模组列表加载器在扫描完成后调用。 + /// + public static void Prune(IEnumerable keepPaths) + { + var store = _current; + if (store is null) return; + lock (_lock) + { + var list = keepPaths as ICollection ?? keepPaths.ToList(); + _EnsureLoaded(store); + var keep = new HashSet(list.Select(_NormalizeKey), StringComparer.OrdinalIgnoreCase); + var stale = store.Entries.Keys.Where(k => !keep.Contains(k)).ToList(); + if (stale.Count == 0) return; + foreach (var k in stale) store.Entries.Remove(k); + store.Dirty = true; + } + } + + /// 将全部实例的变更原子写入磁盘(临时文件 + 移动)。批量加载结束后调用一次即可。 + public static void Flush() + { + lock (_lock) + { + foreach (var store in _stores.Values) + _FlushStore(store); + } + } + + private static void _FlushStore(_Store store) + { + if (!store.Dirty || store.Entries is null) return; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(store.CachePath)!); + var tmp = store.CachePath + ".tmp"; + File.WriteAllText(tmp, + JsonSerializer.Serialize(new CacheFile { Version = FormatVersion, Entries = store.Entries }, + _jsonOpts)); + if (File.Exists(store.CachePath)) File.Delete(store.CachePath); + File.Move(tmp, store.CachePath); + store.Dirty = false; + } + catch (Exception ex) + { + ModBase.Log(ex, "写入 Jar-in-Jar 缓存失败", ModBase.LogLevel.Developer); + } + } +} + +/// 内嵌模组的轻量序列化节点(供缓存与后续依赖分析使用)。 +public class EmbeddedModNode +{ + public string FileName { get; set; } + public string ModId { get; set; } + public string Name { get; set; } + public string Version { get; set; } + + /// 声明的加载器(Fabric/Quilt/Forge/NeoForge)。 + public string Loader { get; set; } + + /// 声明的目标 Minecraft 版本范围。 + public string TargetMcVersion { get; set; } + + /// 本内嵌 mod 声明的依赖(ModId → 原始版本约束),供其作为依赖方参与四态/级联分析。 + public Dictionary Dependencies { get; set; } = new(); + + /// 其中被声明为可选的依赖 ModId 子集。 + public List OptionalDeps { get; set; } = new(); + + public List Children { get; set; } = new(); +} diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs new file mode 100644 index 000000000..084f76f5b --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CompFile = PCL.ModLocalComp.LocalCompFile; + +namespace PCL; + +/// 内嵌模组(Jar-in-Jar)依赖状态四态。 +public enum JijDepStatus +{ + Installed, // 有启用的独立/其它内嵌提供者 + Disabled, // 有满足版本的提供者但都被禁用 + Bundled, // 无独立提供,但本 Mod 内嵌了它 + VersionMismatch, // 有提供者但版本都不满足约束(装了,但装错版本) + Missing // 无任何提供者(根本没装) +} + +/// +/// 加载器/平台伪依赖 id 的统一判定,供依赖解析(.AddDependency)与 +/// 依赖四态/级联分析共用,避免两处 id 集漂移。 +/// +public static class ModDependencyIds +{ + // 加载器/运行时平台伪 id:不作为真实 Mod 依赖收录。不含 minecraft(其版本要求另有用途) + private static readonly HashSet _loaderIds = new(StringComparer.OrdinalIgnoreCase) + { "forge", "neoforge", "fabric", "fabricloader", "quilt", "quilt_loader", "java", "mcp" }; + + /// 是否加载器/平台伪 id(AddDependency 收录依赖时用;不含 minecraft)。 + public static bool IsLoaderId(string id) => _loaderIds.Contains(id); + + /// 是否平台伪依赖(依赖四态/级联判定时用;含 minecraft,不参与"缺失"判定)。 + public static bool IsPlatform(string id) => + string.Equals(id, "minecraft", StringComparison.OrdinalIgnoreCase) || _loaderIds.Contains(id); +} + +/// +/// 内嵌模组依赖分析。构建时按当前实例 MC 版本过滤出"真正会加载"的内嵌副本, +/// 供依赖四态判定与禁用/删除的级联反查复用(模组管理页与内嵌模组二级页共用)。 +/// +public class ModJarInJarIndex +{ + /// 一条依赖要求(可能来自宿主自身或其某个内嵌 mod),带来源加载器方言与可选标记。 + public sealed class DepRow + { + public string DepId; + public string Raw; // 原始版本约束,null=无版本要求 + public bool Optional; + public string Loader; // 声明方的加载器(决定版本方言),null=未知 + } + + private readonly List _allMods; + private readonly Dictionary> _providers = + new(StringComparer.OrdinalIgnoreCase); + // 每个 Mod 内嵌提供的 (id, 版本) 列表(同一 id 的多版本 wrapper 保留全部副本版本) + private readonly Dictionary> _selfBundled = new(); + // 用于缺失警告与级联反查(宿主是启用/禁用单位,故内嵌依赖归到宿主承担) + private readonly Dictionary> _deps = new(); + // 每个宿主的可加载内嵌节点(关系页把有依赖的内嵌 mod 单独成卡时遍历) + private readonly Dictionary> _loadableNodes = new(); + + public ModJarInJarIndex(IEnumerable allMods, string mc) + { + _allMods = allMods.Where(m => !m.IsFolder).ToList(); + foreach (var m in _allMods) + { + var nodes = _CollectLoadableNodes(m.EmbeddedMods, mc); + _loadableNodes[m] = nodes; + _selfBundled[m] = nodes.Where(n => !string.IsNullOrEmpty(n.ModId)) + .Select(n => (n.ModId, n.Version)).ToList(); + + if (!string.IsNullOrEmpty(m.ModId)) _AddProvider(m.ModId, m, m.Version); + foreach (var pid in m.ProvidedIds) + _AddProvider(pid, m, m.Version); + foreach (var n in nodes.Where(n => !string.IsNullOrEmpty(n.ModId))) + _AddProvider(n.ModId, m, n.Version); + + var rows = new List(); + foreach (var kv in m.DependencyRaw) + rows.Add(new DepRow + { + DepId = kv.Key, Raw = kv.Value, + Optional = m.OptionalDependencies.Contains(kv.Key), Loader = m.DetectedLoader + }); + foreach (var n in nodes) + foreach (var kv in n.DependencyRaw) + rows.Add(new DepRow + { + DepId = kv.Key, Raw = kv.Value, + Optional = n.OptionalDependencies.Contains(kv.Key), Loader = n.JijLoader + }); + _deps[m] = rows + .GroupBy(r => (r.DepId, r.Raw, r.Loader, r.Optional)) + .Select(g => g.First()) + .ToList(); + } + } + + /// 某 Mod 的有效依赖(含其内嵌 mod 上浮的依赖)。用于缺失警告与级联。 + public IReadOnlyList GetDependencies(CompFile mod) => + _deps.TryGetValue(mod, out var list) ? list : new List(); + + /// 宿主的可加载内嵌节点(关系页遍历,把有依赖的内嵌 mod 单独成卡)。 + public IReadOnlyList GetLoadableEmbedded(CompFile host) => + _loadableNodes.TryGetValue(host, out var list) ? list : new List(); + + /// 构造某 mod 自身声明的依赖行(不含内嵌上浮),供关系页按 mod 分卡展示。 + public static List BuildOwnDependencies(CompFile mod, string loader) => + mod.DependencyRaw.Select(kv => new DepRow + { + DepId = kv.Key, Raw = kv.Value, + Optional = mod.OptionalDependencies.Contains(kv.Key), Loader = loader + }).ToList(); + + private static string _Norm(string id) => id?.Replace('-', '_'); + + private static bool _VersionSatisfies(DepRow dep, string providerVersion) + { + if (dep.Raw is null) return true; + // provider 版本未知或不可比较(占位符未解析、纯库无版本、"MC1.21-xx" 等字母开头): + // 无法可靠判断时视为满足——错标"缺失"比漏一次版本警告更糟 + if (string.IsNullOrWhiteSpace(providerVersion)) return true; + var ver = McConstraintMatcher.StripV(providerVersion.Trim()); + if (ver.Length == 0 || !char.IsDigit(ver[0])) return true; + if (McConstraintMatcher.Satisfies(dep.Raw, dep.Loader, ver)) return true; + return !McConstraintMatcher.HasComparableLowerBound(dep.Raw); + } + + // 本 Mod 自己内嵌的副本是否满足该依赖的版本要求(内嵌了但版本不够时不算满足) + private bool _SelfBundleSatisfies(CompFile mod, DepRow dep) + { + return _selfBundled.TryGetValue(mod, out var self) && + self.Any(x => string.Equals(_Norm(x.Id), _Norm(dep.DepId), StringComparison.OrdinalIgnoreCase) && + _VersionSatisfies(dep, x.Version)); + } + + public static bool IsPlatform(string id) => ModDependencyIds.IsPlatform(id); + + /// 某 Mod 的某条有效依赖当前处于四态中的哪一态。 + public JijDepStatus Analyze(CompFile mod, DepRow dep) + { + if (_SelfBundleSatisfies(mod, dep)) return JijDepStatus.Bundled; + _providers.TryGetValue(_Norm(dep.DepId), out var provs); + var others = provs?.Where(p => p.Mod != mod).ToList() ?? new List<(CompFile Mod, string Version)>(); + var satisfying = others.Where(p => _VersionSatisfies(dep, p.Version)).ToList(); + if (satisfying.Any(p => p.Mod.State == CompFile.LocalFileStatus.Fine)) return JijDepStatus.Installed; + if (satisfying.Count > 0) return JijDepStatus.Disabled; + // 有提供者却无一满足版本:装了但版本不对,区别于根本没装 + if (others.Count > 0) return JijDepStatus.VersionMismatch; + return JijDepStatus.Missing; + } + + /// + /// 移除 后,哪些仍启用的 Mod 会因此丢失依赖(传递闭包, + /// "最后一个提供者"才算丢失)。返回不含 targets 自身。 + /// + public List FindAffected(IEnumerable targets) + { + var targetSet = new HashSet(targets); + var removal = new HashSet(targetSet); + bool changed; + do + { + changed = false; + foreach (var c in _allMods) + { + if (c.State != CompFile.LocalFileStatus.Fine || removal.Contains(c)) continue; + foreach (var dep in GetDependencies(c)) + { + if (ModDependencyIds.IsPlatform(dep.DepId)) continue; + if (dep.Optional) continue; // 可选依赖不参与级联 + if (_SelfBundleSatisfies(c, dep)) continue; + if (!_providers.TryGetValue(_Norm(dep.DepId), out var provs)) continue; + var active = provs + .Where(p => p.Mod.State == CompFile.LocalFileStatus.Fine && _VersionSatisfies(dep, p.Version)) + .ToList(); + if (active.Count == 0) continue; // 本就未满足,忽略 + if (active.All(p => removal.Contains(p.Mod))) + { + removal.Add(c); + changed = true; + } + } + } + } while (changed); + + return removal.Where(m => !targetSet.Contains(m)).ToList(); + } + + private void _AddProvider(string id, CompFile top, string version) + { + id = _Norm(id); // 归一化连字符/下划线,使依赖 yumi-commons-core 能命中 id=yumi_commons_core + if (!_providers.TryGetValue(id, out var list)) + { + list = new List<(CompFile, string)>(); + _providers[id] = list; + } + + // 去重键含版本:多版本 wrapper 的每份副本版本都保留,供依赖版本区间校验逐一尝试 + if (!list.Any(p => p.Mod == top && p.Version == version)) list.Add((top, version)); + } + + #region MC 版本匹配 + + // 递归收集"会加载"的内嵌节点:某副本 MC 约束不匹配当前实例则整支剪掉 + private static List _CollectLoadableNodes(List embedded, string mc) + { + var into = new List(); + _Collect(embedded, mc, into); + return into; + } + + private static void _Collect(List embedded, string mc, List into) + { + if (embedded is null) return; + foreach (var e in embedded) + { + if (!_NodeLoads(e, mc)) continue; + into.Add(e); + _Collect(e.EmbeddedMods, mc, into); + } + } + + private static bool _NodeLoads(CompFile node, string mc) + { + if (string.IsNullOrEmpty(mc)) return true; // 拿不到实例版本则不过滤 + var constraint = node.JijTargetMcVersion; + if (string.IsNullOrWhiteSpace(constraint)) return true; // 无 MC 约束:任意版本均加载 + if (McConstraintMatcher.Satisfies(constraint, node.JijLoader, mc)) return true; + return McConstraintMatcher.ContainsVersionToken(node.FileName, mc) || + McConstraintMatcher.ContainsVersionToken(node.Version, mc); + } + + #endregion +} diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs index 321ae25a0..a28ad5675 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs @@ -832,14 +832,71 @@ public Dictionary Dependencies private Dictionary _Dependencies = new(); - private void AddDependency(string modID, string versionRequirement = null) + /// + /// 其中被声明为可选(optional / mandatory=false)的依赖 ModID 子集。这些依赖仍列入 + /// 供展示(标注"可选"),但不参与缺失前置警告与禁用/删除级联。 + /// + public HashSet OptionalDependencies + { + get + { + Load(); + return _OptionalDependencies; + } + } + + private readonly HashSet _OptionalDependencies = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 依赖的原始版本约束(未经 Maven 括号规整),保留 Fabric/Quilt semver 谓词原貌,供 + /// 按加载器方言求值;key=ModId,无版本要求则为 null。 + /// 仍存规整值以兼容既有消费方。 + /// + public Dictionary DependencyRaw + { + get + { + Load(); + return _DependencyRaw; + } + } + + private readonly Dictionary _DependencyRaw = new(StringComparer.OrdinalIgnoreCase); + + private readonly HashSet _RequiredDeclared = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 本文件额外提供的 Mod id 别名:multi-mod jar 的兄弟 [[mods]] id 与 Fabric provides。 + /// 依赖索引把它们当作与主 ModId 同源、同版本的 provider,避免依赖别名时误报缺失。 + /// + public HashSet ProvidedIds + { + get + { + Load(); + return _ProvidedIds; + } + } + + private readonly HashSet _ProvidedIds = new(StringComparer.OrdinalIgnoreCase); + + private void AddDependency(string modID, string versionRequirement = null, bool optional = false) { // 确保信息正确 if (modID is null || modID.Length < 2) return; modID = modID.ToLower(); + if (ModDependencyIds.IsLoaderId(modID)) // 加载器/平台伪依赖不收录(minecraft 除外,其版本另有用途) + return; if (modID == "name" || (ModBase.Val(modID).ToString() ?? "") == (modID ?? "")) return; // 跳过 name 与纯数字 id + // 原始约束:仅剔除空值与占位符,语法有效性交给 McConstraintMatcher; + // 不能因"无 . 无 -"就丢掉 >=2 / [2,) 这类合法整数约束(否则任何 provider 版本都会被判满足) + var raw = string.IsNullOrWhiteSpace(versionRequirement) || versionRequirement.Contains("$") + ? null + : versionRequirement; + if (!_DependencyRaw.TryGetValue(modID, out var oldRaw) || oldRaw is null) _DependencyRaw[modID] = raw; + if (versionRequirement is null || (!versionRequirement.Contains(".") && !versionRequirement.Contains("-")) || versionRequirement.Contains("$")) @@ -857,6 +914,53 @@ private void AddDependency(string modID, string versionRequirement = null) { _Dependencies.Add(modID, versionRequirement); } + + // required 优先于 optional,与声明顺序无关 + if (!optional) + { + _RequiredDeclared.Add(modID); + _OptionalDependencies.Remove(modID); + } + else if (!_RequiredDeclared.Contains(modID)) + { + _OptionalDependencies.Add(modID); + } + } + + // 提取 dependencies[.] = [ ... ] 的内联数组体。用引号感知的方括号平衡扫描而非懒惰正则, + // 避免在 versionRange="[1.21.1]" 之类字符串内的 ] 处提前截断 + private static List _ExtractInlineDepArrays(string text) + { + var result = new List(); + foreach (Match m in Regex.Matches(text, @"dependencies[.\w""-]*\s*=\s*\[", RegexOptions.IgnoreCase)) + { + var start = m.Index + m.Length; + var depth = 1; + var inStr = false; + for (var i = start; i < text.Length; i++) + { + var ch = text[i]; + if (inStr) + { + if (ch == '"') inStr = false; + continue; + } + + if (ch == '"') inStr = true; + else if (ch == '[') depth++; + else if (ch == ']') + { + depth--; + if (depth == 0) + { + result.Add(text.Substring(start, i - start)); + break; + } + } + } + } + + return result; } #endregion @@ -869,7 +973,8 @@ private void AddDependency(string modID, string versionRequirement = null) /// /// 是否已进行 Mod 文件的基础加载。(这包括第一步和第二步) /// - private bool isLoaded; + // volatile:加载器线程写、UI 线程双检锁快路径无锁读,保证元数据发布顺序(弱内存模型下的可见性) + private volatile bool isLoaded; /// /// 标记为已加载。用于内嵌(Jar-in-Jar)子项——其元数据已由 通过 @@ -877,6 +982,62 @@ private void AddDependency(string modID, string versionRequirement = null) /// internal void MarkLoaded() => isLoaded = true; + /// + /// 由 Jar-in-Jar 缓存重建时直接写入已解析的元数据,并标记为已加载(避免属性 getter 触发 Load())。 + /// + internal void SetJijMetadata(string name, string modId, string version) + { + _Name = name; + _ModId = modId; + _Version = version; + isLoaded = true; + } + + /// 由 Jar-in-Jar 缓存重建内嵌项时恢复其依赖声明(供该内嵌项作为依赖方参与分析)。 + internal void SetJijDependencies(Dictionary rawDeps, List optional) + { + _DependencyRaw.Clear(); + if (rawDeps is not null) + foreach (var kv in rawDeps) _DependencyRaw[kv.Key] = kv.Value; + _OptionalDependencies.Clear(); + if (optional is not null) _OptionalDependencies.UnionWith(optional); + } + + /// + /// 从同一物理文件的已解析实体复制解析结果。启/禁用仅重命名、文件内容未变, + /// 替换实体后无需在 UI 线程重新解压解析。来源未加载时不做任何事。 + /// + internal void CopyLoadedStateFrom(LocalCompFile other) + { + if (other is null || !other.isLoaded) return; + lock (_loadLock) + { + _Name = other._Name; + _Description = other._Description; + _Version = other._Version; + _ModId = other._ModId; + possibleModId = new List(other.possibleModId); + _Dependencies = new Dictionary(other._Dependencies); + _DependencyRaw.Clear(); + foreach (var kv in other._DependencyRaw) _DependencyRaw[kv.Key] = kv.Value; + _OptionalDependencies.Clear(); + _OptionalDependencies.UnionWith(other._OptionalDependencies); + _RequiredDeclared.Clear(); + _RequiredDeclared.UnionWith(other._RequiredDeclared); + _ProvidedIds.Clear(); + _ProvidedIds.UnionWith(other._ProvidedIds); + _EmbeddedMods = other._EmbeddedMods; + JijLoader = other.JijLoader; + JijTargetMcVersion = other.JijTargetMcVersion; + DetectedLoader = other.DetectedLoader; + Logo = other.Logo; + Url = other.Url; + Authors = other.Authors; + _FileUnavailableReason = other._FileUnavailableReason; + isLoaded = true; + } + } + /// /// Mod 文件是否可被正常读取。 /// @@ -939,6 +1100,14 @@ private void Init() _ModId = null; possibleModId = new List(); _Dependencies = new Dictionary(); + _DependencyRaw.Clear(); + _OptionalDependencies.Clear(); + _RequiredDeclared.Clear(); + _ProvidedIds.Clear(); + _EmbeddedMods = new List(); + JijLoader = null; + JijTargetMcVersion = null; + DetectedLoader = null; isLoaded = false; _FileUnavailableReason = null; isInfoWithClassLoaded = false; @@ -1013,13 +1182,26 @@ public void LoadNbtDataIfNeeded() } } + private readonly object _loadLock = new(); + /// /// 进行文件可用性检查与 .class 以外的信息获取。 + /// 加载器线程与 UI 线程的属性 getter 可能并发触发,须加锁防止同一文件被并行解析。 /// public void Load(bool forceReload = false) { if (isLoaded && !forceReload) return; + lock (_loadLock) + { + if (isLoaded && !forceReload) + return; + LoadCore(); + } + } + + private void LoadCore() + { // 初始化 Init(); @@ -1093,10 +1275,14 @@ public void Load(bool forceReload = false) ZipArchive jar = null; try { - jar = new ZipArchive(new FileStream(path, FileMode.Open)); + // 只读打开 + 宽共享:默认的 ReadWrite/FileShare.Read 会让并发解析同一文件的第二个打开者 + // (如崩溃导出线程与列表加载并发)抛 IOException,把好 Mod 误判为 Unavailable + jar = new ZipArchive(new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.Read | FileShare.Write | FileShare.Delete)); + DetectedLoader = ModJarInJar.DetectLoader(jar); // 供依赖版本约束按加载器方言求值 // 信息获取 LookupMetadata(jar); - EmbeddedMods = ModJarInJar.Resolve(path, jar); + EmbeddedMods = ModJarInJar.ResolveCached(path, jar); } catch (UnauthorizedAccessException ex) { @@ -1120,8 +1306,28 @@ public void Load(bool forceReload = false) /// /// 内嵌模组列表,由 解析填充。 + /// 惰性触发 Load:启/禁用后实体会被替换为未加载的新实例,直读字段会拿到空列表。 /// - public List EmbeddedMods { get; internal set; } = new(); + public List EmbeddedMods + { + get + { + Load(); + return _EmbeddedMods; + } + internal set => _EmbeddedMods = value; + } + + private List _EmbeddedMods = new(); + + /// 内嵌(Jar-in-Jar)子项声明的加载器(Fabric/Quilt/Forge/NeoForge);仅内嵌项有值。 + public string JijLoader { get; internal set; } + + /// 内嵌(Jar-in-Jar)子项声明的目标 Minecraft 版本范围;仅内嵌项有值。 + public string JijTargetMcVersion { get; internal set; } + + /// 本 Mod 检出的加载器(Fabric/Quilt/Forge/NeoForge),供依赖版本约束按方言求值;null=未知。 + public string DetectedLoader { get; internal set; } /// /// 从 Jar 文件中获取 Mod 信息。 @@ -1277,10 +1483,24 @@ internal void LookupMetadata(ZipArchive jar) } } - // 依赖处理 (省略了 VB 中的注释部分,按逻辑实现) + // 依赖处理:版本要求允许为字符串或字符串数组。Fabric 的数组语义是任一命中即满足(OR), + // 故用 || 合并;元素自身含 || 时 OR 结合律无损 if (fabricObject.ContainsKey("depends")) foreach (var dep in (JsonObject)fabricObject["depends"]) - AddDependency(dep.Key, dep.Value.ToString()); + { + var ver = dep.Value is JsonArray arr + ? string.Join(" || ", + arr.Select(x => x?.ToString()).Where(x => !string.IsNullOrEmpty(x))) + : dep.Value?.ToString(); + AddDependency(dep.Key, string.IsNullOrEmpty(ver) ? null : ver); + } + + if (fabricObject["provides"] is JsonArray provides) + foreach (var pv in provides) + { + var id = pv?.ToString(); + if (!string.IsNullOrEmpty(id)) _ProvidedIds.Add(id.ToLower()); + } } catch (Exception ex) { @@ -1484,22 +1704,77 @@ internal void LookupMetadata(ZipArchive jar) if (tomlData[0].Value.ContainsKey("authors")) Authors = tomlData[0].Value["authors"].ToString(); - // 读取依赖 + var ownModIdL = ModId.ToLower(); + var jarModIds = tomlData + .Where(s => s.Key == "mods" && s.Value.ContainsKey("modId")) + .Select(s => s.Value["modId"].ToString().ToLower()) + .ToHashSet(); + foreach (var sib in jarModIds) + if (sib != ownModIdL) + _ProvidedIds.Add(sib); + var sectionHadDeps = false; foreach (var subData in tomlData) - if (subData.Key.ToLower() == $"dependencies.{ModId.ToLower()}") + { + var headerL = subData.Key.ToLower(); + if (headerL != "dependencies" && !headerL.StartsWithF("dependencies.")) + continue; + if (headerL.StartsWithF("dependencies.")) + { + var seg = headerL.Substring("dependencies.".Length).Trim('"', ' '); + if (seg != ownModIdL && jarModIds.Contains(seg)) continue; // 兄弟 mod 的依赖段 + } + { var depEntry = subData.Value; - if (depEntry.ContainsKey("modId") && - depEntry.ContainsKey("mandatory") && (bool)depEntry["mandatory"] && - depEntry.ContainsKey("side") && - depEntry["side"].ToString().ToLower() != "server") - AddDependency( - depEntry["modId"].ToString(), - depEntry.ContainsKey("versionRange") - ? depEntry["versionRange"].ToString() - : null - ); + if (depEntry.ContainsKey("modId")) + { + sectionHadDeps = true; + var type = depEntry.ContainsKey("type") + ? depEntry["type"].ToString().ToLower() + : null; + // incompatible/discouraged 是排斥/劝退,不是依赖,跳过; + // 仅服务端依赖与客户端无关,跳过(side 缺省为 BOTH,不得因缺失而丢弃) + var skip = type is "incompatible" or "discouraged" || + (depEntry.ContainsKey("side") && + depEntry["side"].ToString().ToLower() == "server"); + // optional:type=optional 或 mandatory=false(布尔或带引号字符串);未显式声明即必需 + var optional = type == "optional" || + (depEntry.ContainsKey("mandatory") && + depEntry["mandatory"].ToString().ToLower() == "false"); + if (!skip) + AddDependency( + depEntry["modId"].ToString(), + depEntry.ContainsKey("versionRange") + ? depEntry["versionRange"].ToString() + : null, + optional); + } } + } + + // 内联表写法兜底:dependencies[.] = [ { modId=".." , ... }, ... ]。 + // 仅在段落分支无产出时启用(两种写法互斥;无条件跑会让注释里的示例洗掉段落声明的 + // optional/required 标记),且在去注释后的文本上执行 + if (!sectionHadDeps) + foreach (var arrBody in _ExtractInlineDepArrays(string.Join("\n", lines))) + foreach (Match obj in Regex.Matches(arrBody, @"\{([^}]*)\}")) + { + var body = obj.Groups[1].Value; + var idMatch = Regex.Match(body, "modId\\s*=\\s*\"([^\"]+)\"", + RegexOptions.IgnoreCase); + if (!idMatch.Success) continue; + if (Regex.IsMatch(body, "\"(incompatible|discouraged)\"", + RegexOptions.IgnoreCase) || + Regex.IsMatch(body, "side\\s*=\\s*\"server\"", RegexOptions.IgnoreCase)) + continue; + var optional = + Regex.IsMatch(body, @"mandatory\s*=\s*""?false", RegexOptions.IgnoreCase) || + Regex.IsMatch(body, "type\\s*=\\s*\"optional\"", RegexOptions.IgnoreCase); + var vr = Regex.Match(body, "versionRange\\s*=\\s*\"([^\"]+)\"", + RegexOptions.IgnoreCase); + AddDependency(idMatch.Groups[1].Value, vr.Success ? vr.Groups[1].Value : null, + optional); + } // 加载成功,跳转到完成标签 goto Finished; @@ -1996,6 +2271,9 @@ private static void CompResourceListLoad(LoaderTask(); + // 仅 Mod 类型启用 Jar-in-Jar 缓存;资源包/光影/投影共用同一 loader,不能用其路径污染/剪裁 mod 缓存 + var jijEnabled = loader.input.compType == CompType.Mod; + ModJarInJarCache.UseInstance(jijEnabled ? loader.input.gameVersion.PathInstance : null); foreach (var ModEntry in modList) { loader.Progress += 0.94d / modList.Count; @@ -2029,6 +2307,12 @@ private static void CompResourceListLoad(LoaderTask !m.IsFolder).Select(m => m.path)); + ModJarInJarCache.Flush(); + } + loader.Progress = 0.99d; ModBase.Log( $"[Mod] 共有 {modList.Count} 个 Mod,其中 {modUpdateList.Where(m => m.Comp is null).Count()} 个需要联网获取信息,{modUpdateList.Where(m => m.Comp is not null).Count()} 个需要更新信息"); @@ -2062,6 +2346,12 @@ private static void CompResourceListLoad(LoaderTask + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs new file mode 100644 index 000000000..70682b36d --- /dev/null +++ b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using PCL.Core.App.Localization; +using CompFile = PCL.ModLocalComp.LocalCompFile; + +namespace PCL; + +public partial class PageInstanceCompJarInJar +{ + private ModJarInJarIndex _index; + private string _instanceMc; + private string _search = ""; + + public PageInstanceCompJarInJar() + { + InitializeComponent(); + BtnBack.Click += (_, _) => GoBack(); + BtnEmptyBack.Click += (_, _) => GoBack(); + BtnRefresh.Click += (_, _) => RefreshList(); + SearchBox.TextChanged += (_, _) => + { + _search = (SearchBox.Text ?? "").Trim(); + RefreshList(); + }; + Loaded += (_, _) => RefreshList(); + } + + private bool _Match(CompFile m) + { + if (_search.Length == 0) return true; + return _Contains(_DisplayName(m)) || _Contains(m.ModId) || _Contains(m.FileName); + } + + private bool _Contains(string s) => + !string.IsNullOrEmpty(s) && s.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0; + + private bool _AnyEmbeddedMatch(List embedded) => + embedded is not null && embedded.Any(e => _Match(e) || _AnyEmbeddedMatch(e.EmbeddedMods)); + + private IEnumerable _DistinctEmbedded(CompFile host) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var n in _index.GetLoadableEmbedded(host)) + if (string.IsNullOrEmpty(n.ModId) || seen.Add(n.ModId)) + yield return n; + } + + private static void GoBack() + => ModMain.frmInstanceLeft?.PageChange(FormMain.PageSubType.VersionMod); + + #region 列表构建 + + private void RefreshList() + { + var output = ModLocalComp.compResourceListLoader.output; + if (!_OutputBelongsToCurrentInstance(output)) + { + GoBack(); + return; + } + + var allMods = output.Where(m => !m.IsFolder).ToList(); + _instanceMc = PageInstanceLeft.McInstance?.Info?.VanillaName; + _index = new ModJarInJarIndex(allMods, _instanceMc); + PanLoad.Visibility = Visibility.Collapsed; + + PanWarnList.Children.Clear(); + foreach (var host in allMods) + { + if (host.State != CompFile.LocalFileStatus.Fine) continue; // 禁用的 mod 不加载,其缺失前置不算问题 + if (_Match(host)) _AppendMissingWarning(host, null, host.DetectedLoader); + foreach (var node in _DistinctEmbedded(host)) + if (_Match(node)) + _AppendMissingWarning(node, host, node.JijLoader); + } + + var hasWarning = PanWarnList.Children.Count > 0; + CardWarnings.Visibility = hasWarning ? Visibility.Visible : Visibility.Collapsed; + + PanRelationList.Children.Clear(); + var relationCount = 0; + foreach (var host in allMods) + { + var hostDeps = ModJarInJarIndex.BuildOwnDependencies(host, host.DetectedLoader) + .Where(d => !ModJarInJarIndex.IsPlatform(d.DepId)).ToList(); + if (hostDeps.Count > 0 && (_Match(host) || hostDeps.Any(d => _Contains(d.DepId)))) + { + PanRelationList.Children.Add(_MakeRelationCard(host, null, hostDeps)); + relationCount++; + } + + foreach (var node in _DistinctEmbedded(host)) + { + var nodeDeps = ModJarInJarIndex.BuildOwnDependencies(node, node.JijLoader) + .Where(d => !ModJarInJarIndex.IsPlatform(d.DepId)).ToList(); + if (nodeDeps.Count == 0) continue; + if (!_Match(node) && !nodeDeps.Any(d => _Contains(d.DepId))) continue; + PanRelationList.Children.Add(_MakeRelationCard(node, host, nodeDeps)); + relationCount++; + } + } + + SectionRelations.Visibility = relationCount > 0 ? Visibility.Visible : Visibility.Collapsed; + + PanBundledList.Children.Clear(); + var bundledMods = allMods + .Where(m => m.EmbeddedMods is { Count: > 0 } && (_Match(m) || _AnyEmbeddedMatch(m.EmbeddedMods))) + .ToList(); + foreach (var mod in bundledMods) + PanBundledList.Children.Add(_MakeCard(mod, _BuildBundledContent)); + SectionBundled.Visibility = bundledMods.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + + // 搜索无结果时不显示"没有模组"空态(避免误导为实例本身没有),仅留空白 + PanEmpty.Visibility = hasWarning || relationCount > 0 || bundledMods.Count > 0 || _search.Length > 0 + ? Visibility.Collapsed + : Visibility.Visible; + } + + private MyCard _MakeCard(CompFile mod, Action build) + { + var card = new MyCard { Title = _DisplayName(mod), CanSwap = true, Margin = new Thickness(0, 0, 0, 10) }; + var stack = new StackPanel + { + Margin = new Thickness(20, MyCard.SwapedHeight, 18, 12), + VerticalAlignment = VerticalAlignment.Top, RenderTransform = new TranslateTransform(0, 0), + Tag = mod // StackInstall 靠 Tag 非空触发一次性懒加载 + }; + card.Children.Add(stack); + card.SwapControl = stack; + card.InstallMethod = s => build(s, mod); + card.IsSwapped = true; + return card; + } + + private void _AppendMissingWarning(CompFile mod, CompFile parent, string loader) + { + var probed = ModJarInJarIndex.BuildOwnDependencies(mod, loader) + .Where(d => !ModJarInJarIndex.IsPlatform(d.DepId) && !d.Optional) + .Select(d => (d, status: _index.Analyze(mod, d))) + .ToList(); + var missing = probed.Where(x => x.status == JijDepStatus.Missing).Select(x => x.d.DepId).ToList(); + var mismatch = probed.Where(x => x.status == JijDepStatus.VersionMismatch) + .Select(x => x.d.Raw is null ? x.d.DepId : x.d.DepId + " " + x.d.Raw).ToList(); + if (missing.Count == 0 && mismatch.Count == 0) return; + var name = parent is null + ? _DisplayName(mod) + : _DisplayName(mod) + " " + + Lang.Text("Instance.Resource.Mod.JarInJar.Bundled.From", _DisplayName(parent)); + if (missing.Count > 0) + PanWarnList.Children.Add(_Text( + Lang.Text("Instance.Resource.Mod.JarInJar.Warning.Missing", name, string.Join(", ", missing)), + _BrushError)); + if (mismatch.Count > 0) + PanWarnList.Children.Add(_Text( + Lang.Text("Instance.Resource.Mod.JarInJar.Warning.VersionMismatch", name, string.Join(", ", mismatch)), + _BrushWarn)); + } + + private MyCard _MakeRelationCard(CompFile mod, CompFile parent, List deps) + { + var title = parent is null + ? _DisplayName(mod) + : _DisplayName(mod) + " " + + Lang.Text("Instance.Resource.Mod.JarInJar.Bundled.From", _DisplayName(parent)); + var card = new MyCard { Title = title, CanSwap = true, Margin = new Thickness(0, 0, 0, 10) }; + var stack = new StackPanel + { + Margin = new Thickness(20, MyCard.SwapedHeight, 18, 12), + VerticalAlignment = VerticalAlignment.Top, RenderTransform = new TranslateTransform(0, 0), + Tag = mod + }; + card.Children.Add(stack); + card.SwapControl = stack; + card.InstallMethod = s => + { + foreach (var dep in deps) s.Children.Add(BuildDependencyRow(mod, dep)); + }; + card.IsSwapped = true; + return card; + } + + // 内嵌模组:仅展示内嵌树(禁用/删除请在模组列表进行,级联在那里统一处理) + private void _BuildBundledContent(StackPanel stack, CompFile mod) + { + AppendTreeRows(stack, mod.EmbeddedMods, 0); + } + + private void AppendTreeRows(StackPanel stack, List embedded, int depth) + { + if (embedded is null) return; + + var ungrouped = embedded.Where(e => string.IsNullOrEmpty(e.ModId)).ToList(); + foreach (var g in embedded.Where(e => !string.IsNullOrEmpty(e.ModId)) + .GroupBy(e => e.ModId, StringComparer.OrdinalIgnoreCase)) + { + var versions = g.ToList(); + if (versions.Count == 1) + { + stack.Children.Add(BuildTreeRow(versions[0], depth, 0, McConstraintMatcher.MatchKind.None)); + AppendTreeRows(stack, versions[0].EmbeddedMods, depth + 1); + continue; + } + + var scored = versions + .Select(v => (v, kind: McConstraintMatcher.Match(v.FileName, v.Version, v.JijTargetMcVersion, + v.JijLoader, _instanceMc))) + .OrderByDescending(x => (int)x.kind).ToList(); + var rep = scored[0].v; + var tooltip = string.Join("\n", + versions.Select(v => string.IsNullOrWhiteSpace(v.Version) ? v.FileName : v.Version)); + stack.Children.Add(BuildTreeRow(rep, depth, versions.Count, scored[0].kind, tooltip)); + AppendTreeRows(stack, rep.EmbeddedMods, depth + 1); + } + + foreach (var e in ungrouped) + { + stack.Children.Add(BuildTreeRow(e, depth, 0, McConstraintMatcher.MatchKind.None)); + AppendTreeRows(stack, e.EmbeddedMods, depth + 1); + } + } + + private Panel BuildTreeRow(CompFile e, int depth, int versionCount, McConstraintMatcher.MatchKind kind, + string versionsTooltip = null) + { + var row = new StackPanel + { + Orientation = Orientation.Horizontal, Margin = new Thickness(8 + depth * 20, 2, 0, 2) + }; + row.Children.Add(_TextRes("• " + _DisplayName(e), "ColorBrush1", true)); + var ver = _CleanPlaceholder(e.Version); + if (!string.IsNullOrEmpty(ver)) + row.Children.Add(_TextRes(" (" + ver + ")", "ColorBrush2")); + if (!string.IsNullOrEmpty(e.JijLoader)) + row.Children.Add(_TextRes(" [" + e.JijLoader + "]", "ColorBrush3")); + var mc = _CleanPlaceholder(e.JijTargetMcVersion); + if (!string.IsNullOrEmpty(mc)) + row.Children.Add(_TextRes(" (MC " + mc + ")", "ColorBrushInfo")); + + if (versionCount > 1) + { + var badge = _TextRes(" " + Lang.Text("Instance.Resource.Mod.JarInJar.Bundled.Versions", versionCount), + "ColorBrush2"); + if (!string.IsNullOrEmpty(versionsTooltip)) badge.ToolTip = versionsTooltip; + row.Children.Add(badge); + } + + if (kind is McConstraintMatcher.MatchKind.Exact or McConstraintMatcher.MatchKind.Range) + row.Children.Add(_Text(" " + Lang.Text("Instance.Resource.Mod.JarInJar.Bundled.Current"), _BrushOk)); + else if (kind == McConstraintMatcher.MatchKind.Incompatible) + row.Children.Add(_Text(" " + Lang.Text("Instance.Resource.Mod.JarInJar.Bundled.Incompatible"), + _BrushWarn)); + + return row; + } + + private Panel BuildDependencyRow(CompFile mod, ModJarInJarIndex.DepRow dep) + { + var status = _index.Analyze(mod, dep); + var optional = dep.Optional; + var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 2, 0, 2) }; + var text = dep.Raw is null ? dep.DepId : dep.DepId + " " + dep.Raw; + row.Children.Add(_TextRes("• " + text, "ColorBrush1")); + if (optional) + row.Children.Add(_TextRes(" " + Lang.Text("Instance.Resource.Mod.JarInJar.Dep.Optional"), "ColorBrush2")); + var label = " (" + _StatusLabel(status) + ")"; + // 可选依赖缺失不算问题,用主题灰;其余:内嵌提供灰、已装/已禁/缺失为绿/橙/红语义色 + if (optional || status == JijDepStatus.Bundled) + row.Children.Add(_TextRes(label, "ColorBrush2")); + else + row.Children.Add(_Text(label, _StatusBrush(status))); + return row; + } + + private static string _StatusLabel(JijDepStatus status) => Lang.Text(status switch + { + JijDepStatus.Installed => "Instance.Resource.Mod.JarInJar.Dep.Installed", + JijDepStatus.Disabled => "Instance.Resource.Mod.JarInJar.Dep.Disabled", + JijDepStatus.Bundled => "Instance.Resource.Mod.JarInJar.Dep.Bundled", + JijDepStatus.VersionMismatch => "Instance.Resource.Mod.JarInJar.Dep.VersionMismatch", + _ => "Instance.Resource.Mod.JarInJar.Dep.Missing" + }); + + private static Brush _StatusBrush(JijDepStatus status) => status switch + { + JijDepStatus.Installed => _BrushOk, + JijDepStatus.Disabled => _BrushWarn, + JijDepStatus.VersionMismatch => _BrushWarn, + _ => _BrushError + }; + + #endregion + + #region 辅助 + + // output 是否属于当前实例(mod 路径必在实例游戏目录 PathIndie 下);空列表无从判断按匹配处理避免误弹 + private static bool _OutputBelongsToCurrentInstance(List output) + { + var inst = PageInstanceLeft.McInstance; + if (inst is null || output is null) return false; + var first = output.FirstOrDefault(m => !string.IsNullOrEmpty(m.path)); + return first is null || first.path.StartsWith(inst.PathIndie, StringComparison.OrdinalIgnoreCase); + } + + // 与模组列表卡片一致的名称显示:有在线工程信息时用 译名 | 原名,否则回退本地名/文件名 + // 未替换的版本占位符(Forge ${file.jarVersion}、Fabric ${version} 等)不显示原文 + private static string _CleanPlaceholder(string v) => string.IsNullOrEmpty(v) || v.Contains("${") ? null : v; + + private static string _DisplayName(CompFile m) + { + if (m.Comp is not null) + { + var t = m.Comp.GetControlTitle(false); + return t.Key + t.Value; + } + + return string.IsNullOrWhiteSpace(m.Name) ? m.FileName : m.Name; + } + + private static readonly Brush _BrushOk = new SolidColorBrush(Color.FromRgb(0x4C, 0xAF, 0x50)); + private static readonly Brush _BrushWarn = new SolidColorBrush(Color.FromRgb(0xFF, 0x98, 0x00)); + private static readonly Brush _BrushError = new SolidColorBrush(Color.FromRgb(0xE5, 0x39, 0x35)); + + private static TextBlock _Text(string text, Brush brush, bool bold = false) => new() + { + Text = text, Foreground = brush, VerticalAlignment = VerticalAlignment.Center, + FontWeight = bold ? FontWeights.Bold : FontWeights.Normal, TextWrapping = TextWrapping.Wrap + }; + + private static TextBlock _TextRes(string text, string resourceKey, bool bold = false) + { + var tb = new TextBlock + { + Text = text, VerticalAlignment = VerticalAlignment.Center, + FontWeight = bold ? FontWeights.Bold : FontWeights.Normal, TextWrapping = TextWrapping.Wrap + }; + tb.SetResourceReference(TextBlock.ForegroundProperty, resourceKey); + return tb; + } + + #endregion +} diff --git a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompResource.xaml b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompResource.xaml index 185ead92a..e71951a29 100644 --- a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompResource.xaml +++ b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompResource.xaml @@ -26,6 +26,8 @@ Margin="0,7,15,0" HorizontalAlignment="Left" /> + + ModMain.frmInstanceLeft?.PageChange(FormMain.PageSubType.VersionModJarInJar); + else + BtnManageJarInJar.Visibility = Visibility.Collapsed; + Unloaded += Page_Unloaded; Loaded += (_, _) => PageOther_Loaded(); LoaderInit(); @@ -1904,7 +1910,79 @@ private void BtnSelectED_Click(object sender, ModBase.RouteEventArgs e) ChangeAllSelected(false); } - private void EDMods(IEnumerable modList, bool isEnable) + /// 启用/禁用 Mod。禁用时会检测依赖它的其它 Mod 并弹窗提示是否连带禁用(内嵌模组级联)。 + public void EDMods(IEnumerable modList, bool isEnable) + { + var list = modList.ToList(); + if (!isEnable) + { + var affected = _JijFindAffected(list); + if (affected.Count > 0) + { + var choice = _AskJijCascade(affected, false); + if (choice is null) return; + if (choice == 1) list = list.Concat(affected).ToList(); + } + } + + EDModsCore(list, isEnable); + } + + /// 删除 Mod。会检测依赖它的其它 Mod 并弹窗提示:仅删此项 / 连带禁用依赖者 / 连带删除依赖者。 + public void DeleteMods(IEnumerable modList) + { + // 在弹出级联模态框之前捕获 Shift(永久删除意图),否则选完档位时 Shift 多半已松开 + var isShiftPressed = Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift); + var list = modList.ToList(); + var affected = _JijFindAffected(list); + if (affected.Count > 0) + { + var choice = _AskJijCascade(affected, true); + if (choice is null) return; + switch (choice) + { + case 1: + EDModsCore(affected, false); + break; + case 2: + list = list.Concat(affected).ToList(); + break; + } + } + + DeleteModsCore(list, isShiftPressed); + } + + // 反查受影响的依赖者(仅 Mod 类型);非 Mod 或无内嵌依赖返回空 + private List _JijFindAffected(List targets) + { + if (currentCompType != ModComp.CompType.Mod) return new List(); + // 列表尚未就绪则不做级联(不阻塞操作,也避免对 null 列表构建索引) + var output = ModLocalComp.compResourceListLoader.output; + if (output is null) return new List(); + var index = new ModJarInJarIndex(output, PageInstanceLeft.McInstance?.Info?.VanillaName); + return index.FindAffected(targets); + } + + // 级联选择弹窗;返回 0=仅此项 / 1=连带禁用 /(删除时)2=连带删除;null=取消 + private int? _AskJijCascade(List affected, bool isDelete) + { + var sels = new List + { + new MyRadioBox { Text = Lang.Text("Instance.Resource.Mod.JarInJar.Cascade.OnlySelf") }, + new MyRadioBox { Text = Lang.Text("Instance.Resource.Mod.JarInJar.Cascade.WithDisable") } + }; + if (isDelete) + sels.Add(new MyRadioBox { Text = Lang.Text("Instance.Resource.Mod.JarInJar.Cascade.WithDelete") }); + + return ModMain.MyMsgBoxSelect(sels, + Lang.Text(isDelete + ? "Instance.Resource.Mod.JarInJar.Cascade.DeleteTitle" + : "Instance.Resource.Mod.JarInJar.Cascade.DisableTitle", affected.Count), + Lang.Text("Common.Action.Confirm"), Lang.Text("Common.Action.Cancel"), true); + } + + private void EDModsCore(IEnumerable modList, bool isEnable) { var isSuccessful = true; foreach (var ModE in modList) @@ -1965,6 +2043,7 @@ private void EDMods(IEnumerable modList, bool isEnab // 更改 Loader 中的列表 var newModEntity = new ModLocalComp.LocalCompFile(newPath); newModEntity.FromJson(modEntity.ToJson()); + newModEntity.CopyLoadedStateFrom(modEntity); // 仅改名未改内容,复用已解析元数据,免得 UI 线程重新解压 if (ModLocalComp.compResourceListLoader.output.Contains(modEntity)) { var indexOfLoader = ModLocalComp.compResourceListLoader.output.IndexOf(modEntity); @@ -2235,12 +2314,11 @@ private void BtnSelectDelete_Click(object sender, ModBase.RouteEventArgs e) ChangeAllSelected(false); } - private void DeleteMods(IEnumerable modList) + private void DeleteModsCore(IEnumerable modList, bool isShiftPressed) { try { var isSuccessful = true; - var isShiftPressed = Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift); // 确认需要删除的文件 // 文件夹只需要删除自身 modList = modList.SelectMany(target => diff --git a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceLeft.xaml.cs b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceLeft.xaml.cs index 84f7b651d..4215d21c8 100644 --- a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceLeft.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceLeft.xaml.cs @@ -222,6 +222,12 @@ public object PageGet(FormMain.PageSubType id) ModMain.frmInstanceModDisabled = new PageInstanceModDisabled(); return ModMain.frmInstanceModDisabled; } + case FormMain.PageSubType.VersionModJarInJar: + { + if (ModMain.frmInstanceModJarInJar is null) + ModMain.frmInstanceModJarInJar = new PageInstanceCompJarInJar(); + return ModMain.frmInstanceModJarInJar; + } case FormMain.PageSubType.VersionSetup: { if (ModMain.frmInstanceSetup is null)