From 63c10f465e6244cd0cbfb7523e048c4ab9e57d8a Mon Sep 17 00:00:00 2001 From: lokins Date: Sun, 19 Jul 2026 19:03:07 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(mod):=20=E6=96=B0=E5=A2=9E=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E5=85=B3=E7=B3=BB=E9=A1=B5=EF=BC=8C=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E5=86=85=E5=B5=8C=E6=A3=80=E6=B5=8B=E4=B8=8E=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Localization/Languages/en-US.xaml | 20 ++ .../App/Localization/Languages/zh-CN.xaml | 20 ++ Plain Craft Launcher 2/FormMain.xaml.cs | 1 + .../Export/CrashReportExporter.cs | 16 +- .../Modules/Minecraft/ModJarInJar.cs | 137 ++++++++++- .../Modules/Minecraft/ModJarInJarCache.cs | 142 +++++++++++ .../Minecraft/ModJarInJarDependency.cs | 228 ++++++++++++++++++ .../Modules/Minecraft/ModLocalComp.cs | 81 +++++-- Plain Craft Launcher 2/Modules/ModMain.cs | 1 + .../PageInstanceCompJarInJar.xaml | 73 ++++++ .../PageInstanceCompJarInJar.xaml.cs | 171 +++++++++++++ .../PageInstanceCompResource.xaml | 2 + .../PageInstanceCompResource.xaml.cs | 84 ++++++- .../PageInstance/PageInstanceLeft.xaml.cs | 6 + 14 files changed, 954 insertions(+), 28 deletions(-) create mode 100644 Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs create mode 100644 Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs create mode 100644 Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml create mode 100644 Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml index 6623de3ca..c68126f9e 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -1076,6 +1076,26 @@ 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} + Installed + Disabled + Bundled + Missing + 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: {1} Choose how to proceed + Deleting this mod affects {0} mod(s) that depend on it: {1} Choose how to proceed + …and {0} more 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..2a94cd28e 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -1076,6 +1076,26 @@ 你需要先安装 Forge、Fabric 等模组加载器才能使用模组,请在下载页面安装这些实例。 如果你已经安装过了模组加载器,那么你很可能选择了错误的实例,请点击实例选择按钮切换实例。 实例选择 该实例不可使用模组 + 模组关系 + 查看模组的依赖关系、缺失前置与内嵌(Jar-in-Jar)结构 + 这里概览模组间的依赖关系与内嵌结构。禁用或删除模组时会提示受影响的依赖项。 + 没有可分析的模组关系 + 当前实例的模组均无依赖声明或内嵌模组,或模组列表尚未加载完成。 + 警告 + 模组关系 + 内嵌模组 + 依赖 + {0} 缺少前置:{1} + 已安装 + 已禁用 + 内嵌提供 + 缺失 + 仅操作此模组 + 连带禁用依赖它的模组 + 连带删除依赖它的模组 + 禁用此模组会影响 {0} 个依赖它的模组: {1} 请选择处理方式 + 删除此模组会影响 {0} 个依赖它的模组: {1} 请选择处理方式 + …以及其它 {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..38ccae709 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,6 +372,7 @@ 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) @@ -395,11 +398,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/ModJarInJar.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs index 2b91f8456..d2fbe484a 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs @@ -13,36 +13,103 @@ 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 }); + + /// + /// 带持久化缓存的解析:按文件指纹命中缓存则直接重建,否则解析并写入缓存(批量结束后需调用 + /// 落盘)。 + /// + 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 tree = Resolve(modFilePath, jar); + 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, + Children = _ToNodes(m.EmbeddedMods) + }).ToList(); + + private static List _FromNodes(List nodes, string parentPath) + { + var result = new List(); + 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.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) 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); + 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); child.LookupMetadata(nestedJar); - child.MarkLoaded(); - child.EmbeddedMods = Resolve(childPath, nestedJar, depth + 1); - result.Add(child); + child.JijLoader = _DetectLoader(nestedJar); + child.JijTargetMcVersion = child.Dependencies.TryGetValue("minecraft", out var mc) ? mc : null; + child.EmbeddedMods = _Resolve(childPath, nestedJar, depth + 1, budget); } catch (Exception ex) { @@ -88,4 +155,58 @@ 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); + } + } + + private 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..2aec5b236 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +namespace PCL; + +/// +/// 内嵌模组(Jar-in-Jar)解析结果的持久化缓存。每实例一个文件,位于该实例的 +/// PCL\JarInJar.json(与 config.v1.yml 同级)。按 Mod 文件路径 + (最后修改时间, 大小) 指纹判断 +/// 有效性,避免每次加载都重新递归解析嵌套 jar;也为后续依赖/级联分析提供可查询的内嵌索引。 +/// 使用前须先 切到目标实例,用毕 落盘。 +/// +public static class ModJarInJarCache +{ + /// 缓存数据结构变化时递增此值以令旧缓存失效(改动 JIJ 解析/节点字段后务必升此值)。 + private const int FormatVersion = 2; + + private static readonly object _lock = new(); + private static string _cachePath; // 当前实例的缓存文件;null 表示未启用缓存 + private static Dictionary _entries; + private static 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,与 config.v1.yml 同级); + /// 会先落盘上一个实例的变更。传空表示停用缓存(此时解析不走缓存)。 + /// + public static void UseInstance(string instancePath) + { + lock (_lock) + { + var newPath = string.IsNullOrEmpty(instancePath) + ? null + : Path.Combine(instancePath, "PCL", "JarInJar.json"); + if (string.Equals(_cachePath, newPath, StringComparison.OrdinalIgnoreCase)) return; + _FlushLocked(); + _cachePath = newPath; + _entries = null; // 换实例后惰性重载 + } + } + + private static void _EnsureLoaded() + { + if (_entries is not null) return; + _entries = new Dictionary(); + if (_cachePath is null) return; + try + { + if (File.Exists(_cachePath)) + { + var file = JsonSerializer.Deserialize(File.ReadAllText(_cachePath)); + if (file is not null && file.Version == FormatVersion) + _entries = file.Entries ?? new Dictionary(); + } + } + catch (Exception ex) + { + ModBase.Log(ex, "读取 Jar-in-Jar 缓存失败,已重置", ModBase.LogLevel.Developer); + } + } + + /// 指纹匹配时返回缓存的内嵌树,否则返回 null。 + public static List TryGet(string path, long lastModified, long size) + { + lock (_lock) + { + if (_cachePath is null) return null; + _EnsureLoaded(); + if (_entries.TryGetValue(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) + { + lock (_lock) + { + if (_cachePath is null) return; + _EnsureLoaded(); + _entries[path] = new CacheEntry { LastModified = lastModified, Size = size, Tree = tree }; + _dirty = true; + } + } + + /// 将变更原子写入磁盘(临时文件 + 移动)。批量加载结束后调用一次即可。 + public static void Flush() + { + lock (_lock) + { + _FlushLocked(); + } + } + + private static void _FlushLocked() + { + if (!_dirty || _entries is null || _cachePath is null) return; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_cachePath)!); + var tmp = _cachePath + ".tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize(new CacheFile { Version = FormatVersion, Entries = _entries })); + if (File.Exists(_cachePath)) File.Delete(_cachePath); + File.Move(tmp, _cachePath); + _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; } + + 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..02a607228 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs @@ -0,0 +1,228 @@ +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 内嵌了它 + Missing // 无任何提供者 +} + +/// +/// 内嵌模组依赖分析。构建时按当前实例 MC 版本过滤出"真正会加载"的内嵌副本, +/// 供依赖四态判定与禁用/删除的级联反查复用(模组管理页与内嵌模组二级页共用)。 +/// +public class ModJarInJarIndex +{ + // 加载器/平台伪依赖,不参与"缺失"判定 + private static readonly HashSet _platformIds = new(StringComparer.OrdinalIgnoreCase) + { "minecraft", "forge", "neoforge", "fabric", "fabricloader", "quilt", "quilt_loader", "java", "mcp" }; + + private readonly List _allMods; + private readonly Dictionary> _providers = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _selfBundled = new(); + + public ModJarInJarIndex(IEnumerable allMods, string mc) + { + _allMods = allMods.Where(m => !m.IsFolder).ToList(); + foreach (var m in _allMods) + { + // 仅收录当前实例 MC 版本真正会加载的内嵌副本(多版本 wrapper 只留匹配的那份) + var bundled = new HashSet(StringComparer.OrdinalIgnoreCase); + _CollectLoadableIds(m.EmbeddedMods, mc, bundled); + _selfBundled[m] = bundled; + + if (!string.IsNullOrEmpty(m.ModId)) _AddProvider(m.ModId, m); + foreach (var id in bundled) _AddProvider(id, m); + } + } + + public static bool IsPlatform(string id) => _platformIds.Contains(id); + + /// 某 Mod 的某条依赖当前处于四态中的哪一态。 + public JijDepStatus Analyze(CompFile mod, string depId) + { + _providers.TryGetValue(depId, out var provs); + var external = provs?.Where(p => p != mod).ToList() ?? new List(); + if (external.Any(p => p.State == CompFile.LocalFileStatus.Fine)) return JijDepStatus.Installed; + if (external.Count > 0) return JijDepStatus.Disabled; + if (_selfBundled.TryGetValue(mod, out var ids) && ids.Contains(depId)) return JijDepStatus.Bundled; + 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 c.Dependencies.Keys) + { + if (_platformIds.Contains(dep)) continue; + if (_selfBundled.TryGetValue(c, out var ids) && ids.Contains(dep)) continue; + if (!_providers.TryGetValue(dep, out var provs)) continue; + var active = provs.Where(p => p.State == CompFile.LocalFileStatus.Fine).ToList(); + if (active.Count == 0) continue; // 本就未满足,忽略 + if (active.All(removal.Contains)) + { + removal.Add(c); + changed = true; + } + } + } + } while (changed); + + return removal.Where(m => !targetSet.Contains(m)).ToList(); + } + + private void _AddProvider(string id, CompFile top) + { + if (!_providers.TryGetValue(id, out var list)) + { + list = new List(); + _providers[id] = list; + } + + if (!list.Contains(top)) list.Add(top); + } + + #region MC 版本匹配 + + // 递归收集"会加载"的内嵌 ModId:某副本 MC 约束不匹配当前实例则整支剪掉 + private static void _CollectLoadableIds(List embedded, string mc, HashSet into) + { + if (embedded is null) return; + foreach (var e in embedded) + { + if (!_NodeLoads(e, mc)) continue; + if (!string.IsNullOrEmpty(e.ModId)) into.Add(e.ModId); + _CollectLoadableIds(e.EmbeddedMods, mc, into); + } + } + + // 该内嵌副本是否会在当前实例 MC 版本下加载 + 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 (_McSatisfiesRange(constraint, mc)) return true; + // 兜底:文件名/版本号里恰好整词出现该实例版本,视作精确命中 + return _ContainsVersionToken(node.FileName, mc) || _ContainsVersionToken(node.Version, mc); + } + + // Maven 风格区间:[a,b] [a,b) (a,b) [a,) (,b] [a](精确)或裸 a(软下限 >=a),逗号分隔的多区间取或 + private static bool _McSatisfiesRange(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 (McVersionComparer.CompareVersion(mc, s) >= 0) return true; // 裸版本 = 软下限 + 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 (only.Length > 0 && McVersionComparer.CompareVersion(mc, only) == 0) return true; + continue; + } + + var loStr = body.Substring(0, comma).Trim(); + var hiStr = body.Substring(comma + 1).Trim(); + var ok = true; + if (loStr.Length > 0) + { + var c = McVersionComparer.CompareVersion(mc, loStr); + ok = incLo ? c >= 0 : c > 0; + } + + if (ok && hiStr.Length > 0) + { + var c = McVersionComparer.CompareVersion(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; + } + + // version 是否作为完整版本词出现在 haystack 中("1.21" 不命中 "1.21.2" 或 "9.1.20") + private 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/ModLocalComp.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs index 321ae25a0..b553c9a87 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs @@ -832,12 +832,18 @@ public Dictionary Dependencies private Dictionary _Dependencies = new(); + // 加载器/平台伪依赖,不作为真实 Mod 依赖收录(保留 minecraft:其版本要求供内嵌目标版本判定使用) + private static readonly HashSet _IgnoredDepIds = new(StringComparer.OrdinalIgnoreCase) + { "forge", "neoforge", "fabric", "fabricloader", "quilt", "quilt_loader", "java", "mcp" }; + private void AddDependency(string modID, string versionRequirement = null) { // 确保信息正确 if (modID is null || modID.Length < 2) return; modID = modID.ToLower(); + if (_IgnoredDepIds.Contains(modID)) + return; if (modID == "name" || (ModBase.Val(modID).ToString() ?? "") == (modID ?? "")) return; // 跳过 name 与纯数字 id if (versionRequirement is null || @@ -877,6 +883,17 @@ 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; + } + /// /// Mod 文件是否可被正常读取。 /// @@ -1096,7 +1113,7 @@ public void Load(bool forceReload = false) jar = new ZipArchive(new FileStream(path, FileMode.Open)); // 信息获取 LookupMetadata(jar); - EmbeddedMods = ModJarInJar.Resolve(path, jar); + EmbeddedMods = ModJarInJar.ResolveCached(path, jar); } catch (UnauthorizedAccessException ex) { @@ -1123,6 +1140,12 @@ public void Load(bool forceReload = false) /// public List EmbeddedMods { get; internal set; } = new(); + /// 内嵌(Jar-in-Jar)子项声明的加载器(Fabric/Quilt/Forge/NeoForge);仅内嵌项有值。 + public string JijLoader { get; internal set; } + + /// 内嵌(Jar-in-Jar)子项声明的目标 Minecraft 版本范围;仅内嵌项有值。 + public string JijTargetMcVersion { get; internal set; } + /// /// 从 Jar 文件中获取 Mod 信息。 /// @@ -1280,7 +1303,8 @@ internal void LookupMetadata(ZipArchive jar) // 依赖处理 (省略了 VB 中的注释部分,按逻辑实现) if (fabricObject.ContainsKey("depends")) foreach (var dep in (JsonObject)fabricObject["depends"]) - AddDependency(dep.Key, dep.Value.ToString()); + // 版本要求允许为字符串或字符串数组;数组时不取版本,避免 ToString() 污染成 JSON 文本 + AddDependency(dep.Key, dep.Value is JsonArray ? null : dep.Value?.ToString()); } catch (Exception ex) { @@ -1484,22 +1508,48 @@ internal void LookupMetadata(ZipArchive jar) if (tomlData[0].Value.ContainsKey("authors")) Authors = tomlData[0].Value["authors"].ToString(); - // 读取依赖 + // 读取依赖:优先 dependencies.<本modid>,并回退裸 [[dependencies]] 与命名不一致的 dependencies.* 段 foreach (var subData in tomlData) - if (subData.Key.ToLower() == $"dependencies.{ModId.ToLower()}") + { + var headerL = subData.Key.ToLower(); + if (headerL != "dependencies" && !headerL.StartsWithF("dependencies.")) + continue; { 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")) + { + // 可选依赖:mandatory=false(Forge)或 type 为可选类(NeoForge);未显式声明则视为必需 + var optional = + (depEntry.ContainsKey("mandatory") && depEntry["mandatory"] is bool mb && + !mb) || + (depEntry.ContainsKey("type") && new[] { "optional", "incompatible", "discouraged" } + .Contains(depEntry["type"].ToString().ToLower())); + // 仅服务端依赖与客户端启动无关,排除;side 缺省为 BOTH,不得因缺失而丢弃 + var serverOnly = depEntry.ContainsKey("side") && + depEntry["side"].ToString().ToLower() == "server"; + if (!optional && !serverOnly) + AddDependency( + depEntry["modId"].ToString(), + depEntry.ContainsKey("versionRange") + ? depEntry["versionRange"].ToString() + : null + ); + } } + } + + // 内联表写法兜底:dependencies[.] = [{ modId="x", ... }] + // 逐行解析器把整行值存为字符串(进不了段落路径),字段名可能是 dependencies 或 dependencies.<自身id> + foreach (var subData in tomlData) + foreach (var kv in subData.Value) + if (kv.Key.ToLower().StartsWithF("dependencies") && + kv.Value is string depsStr && + depsStr.IndexOf("modid", StringComparison.OrdinalIgnoreCase) >= 0) + foreach (System.Text.RegularExpressions.Match dm in + System.Text.RegularExpressions.Regex.Matches(depsStr, + "modId\\s*=\\s*\"([^\"]+)\"", + System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + AddDependency(dm.Groups[1].Value); // 加载成功,跳转到完成标签 goto Finished; @@ -1996,6 +2046,7 @@ private static void CompResourceListLoad(LoaderTask(); + ModJarInJarCache.UseInstance(loader.input.gameVersion.PathInstance); foreach (var ModEntry in modList) { loader.Progress += 0.94d / modList.Count; @@ -2029,6 +2080,8 @@ private static void CompResourceListLoad(LoaderTask m.Comp is null).Count()} 个需要联网获取信息,{modUpdateList.Where(m => m.Comp is not null).Count()} 个需要更新信息"); diff --git a/Plain Craft Launcher 2/Modules/ModMain.cs b/Plain Craft Launcher 2/Modules/ModMain.cs index c1ecd882a..990eea1f1 100644 --- a/Plain Craft Launcher 2/Modules/ModMain.cs +++ b/Plain Craft Launcher 2/Modules/ModMain.cs @@ -75,6 +75,7 @@ public static class ModMain public static PageInstanceOverall? frmInstanceOverall; public static PageInstanceCompResource? frmInstanceMod; public static PageInstanceModDisabled? frmInstanceModDisabled; + public static PageInstanceCompJarInJar? frmInstanceModJarInJar; public static PageInstanceScreenshot? frmInstanceScreenshot; public static PageInstanceSaves? frmInstanceSaves; public static PageInstanceCompResource? frmInstanceShader; diff --git a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml new file mode 100644 index 000000000..f5b27ac12 --- /dev/null +++ b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..76def31a6 --- /dev/null +++ b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs @@ -0,0 +1,171 @@ +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; + + public PageInstanceCompJarInJar() + { + InitializeComponent(); + BtnBack.Click += (_, _) => GoBack(); + BtnEmptyBack.Click += (_, _) => GoBack(); + BtnRefresh.Click += (_, _) => RefreshList(); + Loaded += (_, _) => RefreshList(); + } + + private static void GoBack() + => ModMain.frmInstanceLeft?.PageChange(FormMain.PageSubType.VersionMod); + + #region 列表构建 + + private void RefreshList() + { + var allMods = ModLocalComp.compResourceListLoader.output.Where(m => !m.IsFolder).ToList(); + _index = new ModJarInJarIndex(allMods, PageInstanceLeft.McInstance?.Info?.VanillaName); + PanLoad.Visibility = Visibility.Collapsed; + + PanWarnList.Children.Clear(); + foreach (var mod in allMods) + { + var missing = mod.Dependencies.Keys + .Where(k => !ModJarInJarIndex.IsPlatform(k) && _index.Analyze(mod, k) == JijDepStatus.Missing) + .ToList(); + if (missing.Count > 0) + PanWarnList.Children.Add(_Text( + Lang.Text("Instance.Resource.Mod.JarInJar.Warning.Missing", _DisplayName(mod), + string.Join(", ", missing)), _BrushError)); + } + + var hasWarning = PanWarnList.Children.Count > 0; + CardWarnings.Visibility = hasWarning ? Visibility.Visible : Visibility.Collapsed; + + PanRelationList.Children.Clear(); + var relationMods = allMods + .Where(m => m.Dependencies.Keys.Any(k => !ModJarInJarIndex.IsPlatform(k))).ToList(); + foreach (var mod in relationMods) + PanRelationList.Children.Add(_MakeCard(mod, _BuildRelationContent)); + SectionRelations.Visibility = relationMods.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + + PanBundledList.Children.Clear(); + var bundledMods = allMods.Where(m => m.EmbeddedMods is { Count: > 0 }).ToList(); + foreach (var mod in bundledMods) + PanBundledList.Children.Add(_MakeCard(mod, _BuildBundledContent)); + SectionBundled.Visibility = bundledMods.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + + PanEmpty.Visibility = hasWarning || relationMods.Count > 0 || bundledMods.Count > 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 _BuildRelationContent(StackPanel stack, CompFile mod) + { + var deps = mod.Dependencies.Keys.Where(k => !ModJarInJarIndex.IsPlatform(k)).ToList(); + foreach (var dep in deps) + stack.Children.Add(BuildDependencyRow(mod, dep, mod.Dependencies[dep])); + } + + // 内嵌模组:仅展示内嵌树(禁用/删除请在模组列表进行,级联在那里统一处理) + 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; + foreach (var e in embedded) + { + stack.Children.Add(BuildTreeRow(e, depth)); + AppendTreeRows(stack, e.EmbeddedMods, depth + 1); + } + } + + private Panel BuildTreeRow(CompFile e, int depth) + { + var row = new StackPanel + { + Orientation = Orientation.Horizontal, Margin = new Thickness(8 + depth * 20, 2, 0, 2) + }; + row.Children.Add(_Text("• " + _DisplayName(e), _BrushMain, true)); + if (!string.IsNullOrEmpty(e.Version)) + row.Children.Add(_Text(" " + e.Version, _BrushGray)); + if (!string.IsNullOrEmpty(e.JijLoader)) + row.Children.Add(_Text(" [" + e.JijLoader + "]", _BrushGray)); + if (!string.IsNullOrEmpty(e.JijTargetMcVersion)) + row.Children.Add(_Text(" MC " + e.JijTargetMcVersion, _BrushGray)); + return row; + } + + private Panel BuildDependencyRow(CompFile mod, string depId, string versionReq) + { + var (label, brush) = _StatusText(_index.Analyze(mod, depId)); + var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 2, 0, 2) }; + var text = versionReq is null ? depId : depId + " " + versionReq; + row.Children.Add(_Text("• " + text, _BrushMain)); + row.Children.Add(_Text(" (" + label + ")", brush)); + return row; + } + + private static (string, Brush) _StatusText(JijDepStatus status) => status switch + { + JijDepStatus.Installed => (Lang.Text("Instance.Resource.Mod.JarInJar.Dep.Installed"), _BrushOk), + JijDepStatus.Disabled => (Lang.Text("Instance.Resource.Mod.JarInJar.Dep.Disabled"), _BrushWarn), + JijDepStatus.Bundled => (Lang.Text("Instance.Resource.Mod.JarInJar.Dep.Bundled"), _BrushGray), + _ => (Lang.Text("Instance.Resource.Mod.JarInJar.Dep.Missing"), _BrushError) + }; + + #endregion + + #region 辅助 + + // 与模组列表卡片一致的名称显示:有在线工程信息时用 译名 | 原名,否则回退本地名/文件名 + 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 _BrushMain = (Brush)Application.Current.Resources["ColorBrush1"]; + private static readonly Brush _BrushGray = (Brush)Application.Current.Resources["ColorBrush2"]; + 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 + }; + + #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,81 @@ 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) + { + 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); + } + + // 反查受影响的依赖者(仅 Mod 类型);非 Mod 或无内嵌依赖返回空 + private List _JijFindAffected(List targets) + { + if (currentCompType != ModComp.CompType.Mod) return new List(); + var index = new ModJarInJarIndex(ModLocalComp.compResourceListLoader.output, + PageInstanceLeft.McInstance?.Info?.VanillaName); + return index.FindAffected(targets); + } + + // 级联选择弹窗;返回 0=仅此项 / 1=连带禁用 /(删除时)2=连带删除;null=取消。名单超 10 条折叠防撑爆 + private int? _AskJijCascade(List affected, bool isDelete) + { + const int maxShow = 10; + var names = string.Join("\n", + affected.Take(maxShow).Select(m => " - " + (string.IsNullOrWhiteSpace(m.Name) ? m.FileName : m.Name))); + if (affected.Count > maxShow) + names += "\n" + Lang.Text("Instance.Resource.Mod.JarInJar.Cascade.More", affected.Count - maxShow); + + 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, names), + 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) @@ -2235,7 +2315,7 @@ private void BtnSelectDelete_Click(object sender, ModBase.RouteEventArgs e) ChangeAllSelected(false); } - private void DeleteMods(IEnumerable modList) + private void DeleteModsCore(IEnumerable modList) { try { 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) From 05ed984f7c77c0f226181c85d6b012f5d90d92fd Mon Sep 17 00:00:00 2001 From: lokins Date: Sun, 26 Jul 2026 00:26:31 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(mod):=20=E5=A2=9E=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E4=BE=9D=E8=B5=96=E6=A0=87=E6=B3=A8=E4=B8=8E=E7=BA=A7?= =?UTF-8?q?=E8=81=94=E8=B7=B3=E8=BF=87=EF=BC=8C=E4=BF=AE=E5=A4=8D=E8=A7=A3?= =?UTF-8?q?=E6=9E=90/=E5=B9=B6=E5=8F=91/UI=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Localization/Languages/en-US.xaml | 6 +- .../App/Localization/Languages/zh-CN.xaml | 6 +- .../Modules/Minecraft/ModJarInJar.cs | 34 +++- .../Modules/Minecraft/ModJarInJarCache.cs | 161 +++++++++++++----- .../Minecraft/ModJarInJarDependency.cs | 46 +++-- .../Modules/Minecraft/ModLocalComp.cs | 106 ++++++++---- .../PageInstanceCompJarInJar.xaml.cs | 86 +++++++--- .../PageInstanceCompResource.xaml.cs | 23 ++- 8 files changed, 341 insertions(+), 127 deletions(-) diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml index c68126f9e..92c162a3c 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -1090,12 +1090,12 @@ Disabled Bundled Missing + [optional] 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: {1} Choose how to proceed - Deleting this mod affects {0} mod(s) that depend on it: {1} Choose how to proceed - …and {0} more + 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 2a94cd28e..b7420681e 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -1090,12 +1090,12 @@ 已禁用 内嵌提供 缺失 + [可选] 仅操作此模组 连带禁用依赖它的模组 连带删除依赖它的模组 - 禁用此模组会影响 {0} 个依赖它的模组: {1} 请选择处理方式 - 删除此模组会影响 {0} 个依赖它的模组: {1} 请选择处理方式 - …以及其它 {0} 个 + 禁用此模组会影响 {0} 个依赖它的模组,请选择处理方式 + 删除此模组会影响 {0} 个依赖它的模组,请选择处理方式 下载投影模组 你可能需要先安装投影模组,如果已经安装过了投影模组请先启动一次游戏。 也可能是你选择错了实例,请点击实例选择按钮切换实例。 该实例不可用投影原理图 diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs index d2fbe484a..bec769083 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs @@ -19,7 +19,7 @@ public static class ModJarInJar /// 解析 内嵌套的其它 Mod jar,返回内嵌 Mod 列表。 /// public static List Resolve(string parentPath, ZipArchive jar, int depth = 0) - => _Resolve(parentPath, jar, depth, new[] { MaxNodes }); + => _Resolve(parentPath, jar, depth, new[] { MaxNodes, 0 }); /// /// 带持久化缓存的解析:按文件指纹命中缓存则直接重建,否则解析并写入缓存(批量结束后需调用 @@ -42,8 +42,11 @@ public static class ModJarInJar var cached = ModJarInJarCache.TryGet(modFilePath, lastModified, size); if (cached is not null) return _FromNodes(cached, modFilePath); - var tree = Resolve(modFilePath, jar); - ModJarInJarCache.Set(modFilePath, lastModified, size, _ToNodes(tree)); + 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; } @@ -62,6 +65,7 @@ private static List _ToNodes(List m 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; @@ -89,7 +93,12 @@ private static List _ToNodes(List m foreach (var nestedPath in nestedPaths.Distinct()) { - if (budget[0] <= 0) break; // 总节点预算,避免病态嵌套爆树 + if (budget[0] <= 0) + { + budget[1] = 1; // 节点预算耗尽,标记截断(供上层决定不入盘),避免病态嵌套爆树 + break; + } + var entry = jar.GetEntry(nestedPath); if (entry is null) continue; budget[0]--; @@ -100,12 +109,15 @@ private static List _ToNodes(List m // 始终列出该内嵌项:即使下方元数据解析/递归失败,也能按文件名保留(不丢节点) result.Add(child); + string tmp = null; try { - using var ms = new MemoryStream(); - using (var es = entry.Open()) es.CopyTo(ms); - ms.Position = 0; - using var nestedJar = new ZipArchive(ms, ZipArchiveMode.Read); + // 嵌套流不可 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.JijLoader = _DetectLoader(nestedJar); child.JijTargetMcVersion = child.Dependencies.TryGetValue("minecraft", out var mc) ? mc : null; @@ -115,6 +127,12 @@ private static List _ToNodes(List m { ModBase.Log(ex, "解析内嵌 Mod 失败(" + parentPath + " -> " + nestedPath + ")", ModBase.LogLevel.Developer); } + finally + { + if (tmp is not null) + try { File.Delete(tmp); } + catch { /* 临时文件清理失败无妨 */ } + } } return result; diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs index 2aec5b236..039a055ee 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.Json; namespace PCL; @@ -9,7 +10,9 @@ namespace PCL; /// 内嵌模组(Jar-in-Jar)解析结果的持久化缓存。每实例一个文件,位于该实例的 /// PCL\JarInJar.json(与 config.v1.yml 同级)。按 Mod 文件路径 + (最后修改时间, 大小) 指纹判断 /// 有效性,避免每次加载都重新递归解析嵌套 jar;也为后续依赖/级联分析提供可查询的内嵌索引。 -/// 使用前须先 切到目标实例,用毕 落盘。 +/// 使用前须先 注册目标实例,用毕 落盘。 +/// 多实例流程(模组列表加载与崩溃导出)可能并发:读写按 Mod 路径前缀路由到所属实例的存储, +/// 路由不到时才回退到最近一次 UseInstance 的实例,避免条目写进错误实例的缓存文件。 /// public static class ModJarInJarCache { @@ -17,9 +20,19 @@ public static class ModJarInJarCache private const int FormatVersion = 2; private static readonly object _lock = new(); - private static string _cachePath; // 当前实例的缓存文件;null 表示未启用缓存 - private static Dictionary _entries; - private static bool _dirty; + 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 { @@ -35,36 +48,78 @@ private class CacheFile } /// - /// 切换到某实例的缓存文件(\PCL\JarInJar.json,与 config.v1.yml 同级); - /// 会先落盘上一个实例的变更。传空表示停用缓存(此时解析不走缓存)。 + /// 注册并切换到某实例的缓存(\PCL\JarInJar.json)。 + /// 传空表示后续路由不到的读写不走缓存。 /// public static void UseInstance(string instancePath) { lock (_lock) { - var newPath = string.IsNullOrEmpty(instancePath) - ? null - : Path.Combine(instancePath, "PCL", "JarInJar.json"); - if (string.Equals(_cachePath, newPath, StringComparison.OrdinalIgnoreCase)) return; - _FlushLocked(); - _cachePath = newPath; - _entries = null; // 换实例后惰性重载 + 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; } } - private static void _EnsureLoaded() + // 按 Mod 文件路径前缀找到所属实例的存储;找不到(如非版本隔离时 mods 在 .minecraft 根下)回退当前实例 + private static _Store _Route(string modPath) + { + if (!string.IsNullOrEmpty(modPath)) + foreach (var s in _stores.Values) + if (modPath.StartsWith(s.InstancePath + "\\", StringComparison.OrdinalIgnoreCase) || + modPath.StartsWith(s.InstancePath + "/", StringComparison.OrdinalIgnoreCase)) + return s; + return _current; + } + + // 启/禁用是纯改名、mtime 不变;剥 .disabled 后复用同键避免白白重扫。 + // 保留 .old(.old 可与新文件并存,剥了会键冲突)。 + private static string _NormalizeKey(string path) { - if (_entries is not null) return; - _entries = new Dictionary(); - if (_cachePath is null) return; + 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(_cachePath)) - { - var file = JsonSerializer.Deserialize(File.ReadAllText(_cachePath)); - if (file is not null && file.Version == FormatVersion) - _entries = file.Entries ?? new Dictionary(); - } + 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) { @@ -77,9 +132,11 @@ public static List TryGet(string path, long lastModified, long { lock (_lock) { - if (_cachePath is null) return null; - _EnsureLoaded(); - if (_entries.TryGetValue(path, out var e) && e.LastModified == lastModified && e.Size == size) + var store = _Route(path); + if (store is null) return null; + _EnsureLoaded(store); + if (store.Entries.TryGetValue(_NormalizeKey(path), out var e) && e.LastModified == lastModified && + e.Size == size) return e.Tree; return null; } @@ -89,33 +146,57 @@ public static void Set(string path, long lastModified, long size, List + /// 清理当前实例存储中已不存在的文件条目(删除/改名后残留), 为本次 + /// 扫描到的全部 Mod 文件路径。应由模组列表加载器在扫描完成后调用。 + /// + public static void Prune(IEnumerable keepPaths) + { + lock (_lock) + { + var list = keepPaths as ICollection ?? keepPaths.ToList(); + // 按 mod 路径路由到所属实例,避免并发切换 _current 时用本实例清单误剪别的实例 + var store = list.Count > 0 ? _Route(list.First()) : _current; + if (store is null) return; + _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) { - _FlushLocked(); + foreach (var store in _stores.Values) + _FlushStore(store); } } - private static void _FlushLocked() + private static void _FlushStore(_Store store) { - if (!_dirty || _entries is null || _cachePath is null) return; + if (!store.Dirty || store.Entries is null) return; try { - Directory.CreateDirectory(Path.GetDirectoryName(_cachePath)!); - var tmp = _cachePath + ".tmp"; - File.WriteAllText(tmp, JsonSerializer.Serialize(new CacheFile { Version = FormatVersion, Entries = _entries })); - if (File.Exists(_cachePath)) File.Delete(_cachePath); - File.Move(tmp, _cachePath); - _dirty = false; + Directory.CreateDirectory(Path.GetDirectoryName(store.CachePath)!); + var tmp = store.CachePath + ".tmp"; + File.WriteAllText(tmp, + JsonSerializer.Serialize(new CacheFile { Version = FormatVersion, Entries = store.Entries })); + if (File.Exists(store.CachePath)) File.Delete(store.CachePath); + File.Move(tmp, store.CachePath); + store.Dirty = false; } catch (Exception ex) { diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs index 02a607228..ae95634c1 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs @@ -18,12 +18,26 @@ public enum JijDepStatus /// 内嵌模组依赖分析。构建时按当前实例 MC 版本过滤出"真正会加载"的内嵌副本, /// 供依赖四态判定与禁用/删除的级联反查复用(模组管理页与内嵌模组二级页共用)。 /// -public class ModJarInJarIndex +/// +/// 加载器/平台伪依赖 id 的统一判定,供依赖解析(.AddDependency)与 +/// 依赖四态/级联分析共用,避免两处 id 集漂移。 +/// +public static class ModDependencyIds { - // 加载器/平台伪依赖,不参与"缺失"判定 - private static readonly HashSet _platformIds = new(StringComparer.OrdinalIgnoreCase) - { "minecraft", "forge", "neoforge", "fabric", "fabricloader", "quilt", "quilt_loader", "java", "mcp" }; + // 加载器/运行时平台伪 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); +} +public class ModJarInJarIndex +{ private readonly List _allMods; private readonly Dictionary> _providers = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _selfBundled = new(); @@ -43,7 +57,7 @@ public ModJarInJarIndex(IEnumerable allMods, string mc) } } - public static bool IsPlatform(string id) => _platformIds.Contains(id); + public static bool IsPlatform(string id) => ModDependencyIds.IsPlatform(id); /// 某 Mod 的某条依赖当前处于四态中的哪一态。 public JijDepStatus Analyze(CompFile mod, string depId) @@ -73,7 +87,8 @@ public List FindAffected(IEnumerable targets) if (c.State != CompFile.LocalFileStatus.Fine || removal.Contains(c)) continue; foreach (var dep in c.Dependencies.Keys) { - if (_platformIds.Contains(dep)) continue; + if (ModDependencyIds.IsPlatform(dep)) continue; + if (c.OptionalDependencies.Contains(dep)) continue; // 可选依赖不参与级联 if (_selfBundled.TryGetValue(c, out var ids) && ids.Contains(dep)) continue; if (!_providers.TryGetValue(dep, out var provs)) continue; var active = provs.Where(p => p.State == CompFile.LocalFileStatus.Fine).ToList(); @@ -122,10 +137,18 @@ private static bool _NodeLoads(CompFile node, string mc) var constraint = node.JijTargetMcVersion; if (string.IsNullOrWhiteSpace(constraint)) return true; // 无 MC 约束:任意版本均加载 if (_McSatisfiesRange(constraint, mc)) return true; - // 兜底:文件名/版本号里恰好整词出现该实例版本,视作精确命中 - return _ContainsVersionToken(node.FileName, mc) || _ContainsVersionToken(node.Version, mc); + // 文件名/版本号里恰好整词出现该实例版本,视作精确命中 + if (_ContainsVersionToken(node.FileName, mc) || _ContainsVersionToken(node.Version, mc)) return true; + // provider 收集宁可多收(fail-open):约束含 Maven 解析器看不懂的 semver 运算符/通配 + // (如 Fabric 的 >=1.20 被 AddDependency 规整成 [>=1.20,))时,拿不准就当作会加载, + // 避免漏 provider 导致依赖方误报"缺失"(假红) + return constraint.IndexOfAny(new[] { '>', '<', '~', '^', '*' }) >= 0; } + // 边界是否形如可比较的 MC 版本(1.20.1 / 26.1 / 23w13a 均以数字开头); + // 不可解析的边界整段判不匹配(fail-closed),避免垃圾串经字符串比较误判"匹配一切" + private static bool _IsKnownVersion(string s) => s.Length > 0 && char.IsDigit(s[0]); + // Maven 风格区间:[a,b] [a,b) (a,b) [a,) (,b] [a](精确)或裸 a(软下限 >=a),逗号分隔的多区间取或 private static bool _McSatisfiesRange(string constraint, string mc) { @@ -136,7 +159,7 @@ private static bool _McSatisfiesRange(string constraint, string mc) if (s[0] != '[' && s[0] != '(') { - if (McVersionComparer.CompareVersion(mc, s) >= 0) return true; // 裸版本 = 软下限 + if (_IsKnownVersion(s) && McVersionComparer.CompareVersion(mc, s) >= 0) return true; // 裸版本 = 软下限 continue; } @@ -147,12 +170,15 @@ private static bool _McSatisfiesRange(string constraint, string mc) if (comma < 0) { var only = body.Trim(); // [a] 精确 - if (only.Length > 0 && McVersionComparer.CompareVersion(mc, only) == 0) return true; + if (_IsKnownVersion(only) && McVersionComparer.CompareVersion(mc, only) == 0) return true; continue; } var loStr = body.Substring(0, comma).Trim(); var hiStr = body.Substring(comma + 1).Trim(); + if ((loStr.Length > 0 && !_IsKnownVersion(loStr)) || + (hiStr.Length > 0 && !_IsKnownVersion(hiStr))) + continue; var ok = true; if (loStr.Length > 0) { diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs index b553c9a87..0ffa9b883 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs @@ -832,17 +832,28 @@ public Dictionary Dependencies private Dictionary _Dependencies = new(); - // 加载器/平台伪依赖,不作为真实 Mod 依赖收录(保留 minecraft:其版本要求供内嵌目标版本判定使用) - private static readonly HashSet _IgnoredDepIds = new(StringComparer.OrdinalIgnoreCase) - { "forge", "neoforge", "fabric", "fabricloader", "quilt", "quilt_loader", "java", "mcp" }; + /// + /// 其中被声明为可选(optional / mandatory=false)的依赖 ModID 子集。这些依赖仍列入 + /// 供展示(标注"可选"),但不参与缺失前置警告与禁用/删除级联。 + /// + public HashSet OptionalDependencies + { + get + { + Load(); + return _OptionalDependencies; + } + } + + private readonly HashSet _OptionalDependencies = new(StringComparer.OrdinalIgnoreCase); - private void AddDependency(string modID, string versionRequirement = null) + private void AddDependency(string modID, string versionRequirement = null, bool optional = false) { // 确保信息正确 if (modID is null || modID.Length < 2) return; modID = modID.ToLower(); - if (_IgnoredDepIds.Contains(modID)) + if (ModDependencyIds.IsLoaderId(modID)) // 加载器/平台伪依赖不收录(minecraft 除外,其版本另有用途) return; if (modID == "name" || (ModBase.Val(modID).ToString() ?? "") == (modID ?? "")) return; // 跳过 name 与纯数字 id @@ -863,6 +874,9 @@ private void AddDependency(string modID, string versionRequirement = null) { _Dependencies.Add(modID, versionRequirement); } + + if (optional) _OptionalDependencies.Add(modID); + else _OptionalDependencies.Remove(modID); // 任一处声明为必需即覆盖可选标记 } #endregion @@ -1030,13 +1044,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(); @@ -1137,8 +1164,19 @@ 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; } @@ -1518,38 +1556,45 @@ internal void LookupMetadata(ZipArchive jar) var depEntry = subData.Value; if (depEntry.ContainsKey("modId")) { - // 可选依赖:mandatory=false(Forge)或 type 为可选类(NeoForge);未显式声明则视为必需 - var optional = - (depEntry.ContainsKey("mandatory") && depEntry["mandatory"] is bool mb && - !mb) || - (depEntry.ContainsKey("type") && new[] { "optional", "incompatible", "discouraged" } - .Contains(depEntry["type"].ToString().ToLower())); - // 仅服务端依赖与客户端启动无关,排除;side 缺省为 BOTH,不得因缺失而丢弃 - var serverOnly = depEntry.ContainsKey("side") && - depEntry["side"].ToString().ToLower() == "server"; - if (!optional && !serverOnly) + 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"] is bool mb && !mb); + if (!skip) AddDependency( depEntry["modId"].ToString(), depEntry.ContainsKey("versionRange") ? depEntry["versionRange"].ToString() - : null - ); + : null, + optional); } } } - // 内联表写法兜底:dependencies[.] = [{ modId="x", ... }] - // 逐行解析器把整行值存为字符串(进不了段落路径),字段名可能是 dependencies 或 dependencies.<自身id> - foreach (var subData in tomlData) - foreach (var kv in subData.Value) - if (kv.Key.ToLower().StartsWithF("dependencies") && - kv.Value is string depsStr && - depsStr.IndexOf("modid", StringComparison.OrdinalIgnoreCase) >= 0) - foreach (System.Text.RegularExpressions.Match dm in - System.Text.RegularExpressions.Regex.Matches(depsStr, - "modId\\s*=\\s*\"([^\"]+)\"", - System.Text.RegularExpressions.RegexOptions.IgnoreCase)) - AddDependency(dm.Groups[1].Value); + foreach (Match arr in Regex.Matches(tomlText, @"dependencies[.\w]*\s*=\s*\[(.*?)\]", + RegexOptions.IgnoreCase | RegexOptions.Singleline)) + foreach (Match obj in Regex.Matches(arr.Groups[1].Value, @"\{([^}]*)\}")) + { + var body = obj.Groups[1].Value; + var idMatch = Regex.Match(body, "modId\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase); + if (!idMatch.Success) continue; + var lower = body.ToLower(); + // 排斥/劝退/仅服务端:不是依赖,跳过 + if (lower.Contains("\"incompatible\"") || lower.Contains("\"discouraged\"") || + Regex.IsMatch(body, "side\\s*=\\s*\"server\"", RegexOptions.IgnoreCase)) + continue; + var optional = lower.Contains("mandatory=false") || + lower.Contains("mandatory = false") || lower.Contains("\"optional\""); + AddDependency(idMatch.Groups[1].Value, null, optional); + } // 加载成功,跳转到完成标签 goto Finished; @@ -2080,6 +2125,7 @@ private static void CompResourceListLoad(LoaderTask !m.IsFolder).Select(m => m.path)); ModJarInJarCache.Flush(); loader.Progress = 0.99d; diff --git a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs index 76def31a6..50ef821c9 100644 --- a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs @@ -29,7 +29,14 @@ private static void GoBack() private void RefreshList() { - var allMods = ModLocalComp.compResourceListLoader.output.Where(m => !m.IsFolder).ToList(); + var output = ModLocalComp.compResourceListLoader.output; + if (!_OutputBelongsToCurrentInstance(output)) + { + GoBack(); + return; + } + + var allMods = output.Where(m => !m.IsFolder).ToList(); _index = new ModJarInJarIndex(allMods, PageInstanceLeft.McInstance?.Info?.VanillaName); PanLoad.Visibility = Visibility.Collapsed; @@ -37,7 +44,8 @@ private void RefreshList() foreach (var mod in allMods) { var missing = mod.Dependencies.Keys - .Where(k => !ModJarInJarIndex.IsPlatform(k) && _index.Analyze(mod, k) == JijDepStatus.Missing) + .Where(k => !ModJarInJarIndex.IsPlatform(k) && !mod.OptionalDependencies.Contains(k) && + _index.Analyze(mod, k) == JijDepStatus.Missing) .ToList(); if (missing.Count > 0) PanWarnList.Children.Add(_Text( @@ -86,7 +94,8 @@ private void _BuildRelationContent(StackPanel stack, CompFile mod) { var deps = mod.Dependencies.Keys.Where(k => !ModJarInJarIndex.IsPlatform(k)).ToList(); foreach (var dep in deps) - stack.Children.Add(BuildDependencyRow(mod, dep, mod.Dependencies[dep])); + stack.Children.Add(BuildDependencyRow(mod, dep, mod.Dependencies[dep], + mod.OptionalDependencies.Contains(dep))); } // 内嵌模组:仅展示内嵌树(禁用/删除请在模组列表进行,级联在那里统一处理) @@ -111,39 +120,67 @@ private Panel BuildTreeRow(CompFile e, int depth) { Orientation = Orientation.Horizontal, Margin = new Thickness(8 + depth * 20, 2, 0, 2) }; - row.Children.Add(_Text("• " + _DisplayName(e), _BrushMain, true)); - if (!string.IsNullOrEmpty(e.Version)) - row.Children.Add(_Text(" " + e.Version, _BrushGray)); + 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(_Text(" [" + e.JijLoader + "]", _BrushGray)); - if (!string.IsNullOrEmpty(e.JijTargetMcVersion)) - row.Children.Add(_Text(" MC " + e.JijTargetMcVersion, _BrushGray)); + row.Children.Add(_TextRes(" [" + e.JijLoader + "]", "ColorBrush2")); + var mc = _CleanPlaceholder(e.JijTargetMcVersion); + if (!string.IsNullOrEmpty(mc)) + row.Children.Add(_TextRes(" MC " + mc, "ColorBrush2")); return row; } - private Panel BuildDependencyRow(CompFile mod, string depId, string versionReq) + private Panel BuildDependencyRow(CompFile mod, string depId, string versionReq, bool optional) { - var (label, brush) = _StatusText(_index.Analyze(mod, depId)); + var status = _index.Analyze(mod, depId); var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 2, 0, 2) }; var text = versionReq is null ? depId : depId + " " + versionReq; - row.Children.Add(_Text("• " + text, _BrushMain)); - row.Children.Add(_Text(" (" + label + ")", brush)); + 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, Brush) _StatusText(JijDepStatus status) => status switch + private static string _StatusLabel(JijDepStatus status) => Lang.Text(status switch { - JijDepStatus.Installed => (Lang.Text("Instance.Resource.Mod.JarInJar.Dep.Installed"), _BrushOk), - JijDepStatus.Disabled => (Lang.Text("Instance.Resource.Mod.JarInJar.Dep.Disabled"), _BrushWarn), - JijDepStatus.Bundled => (Lang.Text("Instance.Resource.Mod.JarInJar.Dep.Bundled"), _BrushGray), - _ => (Lang.Text("Instance.Resource.Mod.JarInJar.Dep.Missing"), _BrushError) + JijDepStatus.Installed => "Instance.Resource.Mod.JarInJar.Dep.Installed", + JijDepStatus.Disabled => "Instance.Resource.Mod.JarInJar.Dep.Disabled", + JijDepStatus.Bundled => "Instance.Resource.Mod.JarInJar.Dep.Bundled", + _ => "Instance.Resource.Mod.JarInJar.Dep.Missing" + }); + + private static Brush _StatusBrush(JijDepStatus status) => status switch + { + JijDepStatus.Installed => _BrushOk, + JijDepStatus.Disabled => _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) @@ -155,8 +192,6 @@ private static string _DisplayName(CompFile m) return string.IsNullOrWhiteSpace(m.Name) ? m.FileName : m.Name; } - private static readonly Brush _BrushMain = (Brush)Application.Current.Resources["ColorBrush1"]; - private static readonly Brush _BrushGray = (Brush)Application.Current.Resources["ColorBrush2"]; 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)); @@ -167,5 +202,16 @@ private static string _DisplayName(CompFile m) 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.cs b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompResource.xaml.cs index 0881f3da1..b90af217e 100644 --- a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompResource.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompResource.xaml.cs @@ -1931,6 +1931,8 @@ public void EDMods(IEnumerable modList, bool isEnabl /// 删除 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) @@ -1948,27 +1950,23 @@ public void DeleteMods(IEnumerable modList) } } - DeleteModsCore(list); + DeleteModsCore(list, isShiftPressed); } // 反查受影响的依赖者(仅 Mod 类型);非 Mod 或无内嵌依赖返回空 private List _JijFindAffected(List targets) { if (currentCompType != ModComp.CompType.Mod) return new List(); - var index = new ModJarInJarIndex(ModLocalComp.compResourceListLoader.output, - PageInstanceLeft.McInstance?.Info?.VanillaName); + // 列表尚未就绪则不做级联(不阻塞操作,也避免对 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=取消。名单超 10 条折叠防撑爆 + // 级联选择弹窗;返回 0=仅此项 / 1=连带禁用 /(删除时)2=连带删除;null=取消 private int? _AskJijCascade(List affected, bool isDelete) { - const int maxShow = 10; - var names = string.Join("\n", - affected.Take(maxShow).Select(m => " - " + (string.IsNullOrWhiteSpace(m.Name) ? m.FileName : m.Name))); - if (affected.Count > maxShow) - names += "\n" + Lang.Text("Instance.Resource.Mod.JarInJar.Cascade.More", affected.Count - maxShow); - var sels = new List { new MyRadioBox { Text = Lang.Text("Instance.Resource.Mod.JarInJar.Cascade.OnlySelf") }, @@ -1980,7 +1978,7 @@ public void DeleteMods(IEnumerable modList) return ModMain.MyMsgBoxSelect(sels, Lang.Text(isDelete ? "Instance.Resource.Mod.JarInJar.Cascade.DeleteTitle" - : "Instance.Resource.Mod.JarInJar.Cascade.DisableTitle", affected.Count, names), + : "Instance.Resource.Mod.JarInJar.Cascade.DisableTitle", affected.Count), Lang.Text("Common.Action.Confirm"), Lang.Text("Common.Action.Cancel"), true); } @@ -2315,12 +2313,11 @@ private void BtnSelectDelete_Click(object sender, ModBase.RouteEventArgs e) ChangeAllSelected(false); } - private void DeleteModsCore(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 => From 473f259986e02b2e796099759e4e30d361fa9906 Mon Sep 17 00:00:00 2001 From: lokins Date: Sun, 26 Jul 2026 12:09:27 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(mod):=20=E4=BF=AE=E5=A4=8D=20JIJ=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E7=BA=A6=E6=9D=9F=E6=B1=82=E5=80=BC/provider?= =?UTF-8?q?=20=E7=89=88=E6=9C=AC=E6=A0=A1=E9=AA=8C/optional=20=E4=BF=9D?= =?UTF-8?q?=E7=9C=9F,=E5=8A=A0=E5=9B=BA=E7=BC=93=E5=AD=98=E5=B9=B6?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Localization/Languages/en-US.xaml | 3 + .../App/Localization/Languages/zh-CN.xaml | 3 + .../Modules/Minecraft/McConstraintMatcher.cs | 270 ++++++++++++++++++ .../Modules/Minecraft/McVersionComparer.cs | 9 +- .../Modules/Minecraft/ModJarInJar.cs | 3 +- .../Modules/Minecraft/ModJarInJarCache.cs | 40 ++- .../Minecraft/ModJarInJarDependency.cs | 199 ++++--------- .../Modules/Minecraft/ModLocalComp.cs | 193 +++++++++++-- .../PageInstanceCompJarInJar.xaml.cs | 57 +++- .../PageInstanceCompResource.xaml.cs | 1 + 10 files changed, 574 insertions(+), 204 deletions(-) create mode 100644 Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml index 92c162a3c..7c0ba95f3 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -1091,6 +1091,9 @@ Bundled Missing [optional] + ✓ used by this instance + ⚠ incompatible with this version + {0} versions Only this mod Also disable mods that depend on it Also delete mods that depend on it diff --git a/PCL.Core/App/Localization/Languages/zh-CN.xaml b/PCL.Core/App/Localization/Languages/zh-CN.xaml index b7420681e..a19bb32e9 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -1091,6 +1091,9 @@ 内嵌提供 缺失 [可选] + ✓ 当前实例使用 + ⚠ 不适配当前版本 + {0} 个版本 仅操作此模组 连带禁用依赖它的模组 连带删除依赖它的模组 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..b0a714e31 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs @@ -0,0 +1,270 @@ +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; + } + } + + // 版本串是否可比较(1.20.1 / 26.1 / 23w13a 均数字开头;剥掉 Fabric 尾 - 后判断) + private static bool IsKnown(string s) + { + if (string.IsNullOrEmpty(s)) return false; + if (s.EndsWith("-")) s = s.Substring(0, s.Length - 1); + 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) && McVersionComparer.CompareVersion(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) && McVersionComparer.CompareVersion(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 = McVersionComparer.CompareVersion(mc, loStr); + ok = incLo ? c >= 0 : c > 0; + } + + if (ok && hiStr.Length > 0) + { + var c = McVersionComparer.CompareVersion(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) + + // 空格 = 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(); + if (ver.EndsWith("-")) ver = ver.Substring(0, ver.Length - 1); // Fabric 预发布标记,如 ">=26.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 (McVersionComparer.CompareVersion(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; + var upper = (tilde || nums[0] == 0) && nums.Count >= 2 + ? nums[0] + "." + (nums[1] + 1) + : (nums[0] + 1).ToString(); + return McVersionComparer.CompareVersion(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 + var c = McVersionComparer.CompareVersion(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 bec769083..e45316e66 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs @@ -120,7 +120,8 @@ private static List _ToNodes(List m using var nestedJar = ZipFile.OpenRead(tmp); child.LookupMetadata(nestedJar); child.JijLoader = _DetectLoader(nestedJar); - child.JijTargetMcVersion = child.Dependencies.TryGetValue("minecraft", out var mc) ? mc : null; + // 用原始约束(未 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) diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs index 039a055ee..d481ba345 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs @@ -11,13 +11,18 @@ namespace PCL; /// PCL\JarInJar.json(与 config.v1.yml 同级)。按 Mod 文件路径 + (最后修改时间, 大小) 指纹判断 /// 有效性,避免每次加载都重新递归解析嵌套 jar;也为后续依赖/级联分析提供可查询的内嵌索引。 /// 使用前须先 注册目标实例,用毕 落盘。 -/// 多实例流程(模组列表加载与崩溃导出)可能并发:读写按 Mod 路径前缀路由到所属实例的存储, -/// 路由不到时才回退到最近一次 UseInstance 的实例,避免条目写进错误实例的缓存文件。 +/// "当前实例"按线程记录([ThreadStatic]),模组列表加载与崩溃导出各自的线程互不干扰。 /// public static class ModJarInJarCache { /// 缓存数据结构变化时递增此值以令旧缓存失效(改动 JIJ 解析/节点字段后务必升此值)。 - private const int FormatVersion = 2; + private const int FormatVersion = 3; + + 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); @@ -76,17 +81,6 @@ public static void UseInstance(string instancePath) } } - // 按 Mod 文件路径前缀找到所属实例的存储;找不到(如非版本隔离时 mods 在 .minecraft 根下)回退当前实例 - private static _Store _Route(string modPath) - { - if (!string.IsNullOrEmpty(modPath)) - foreach (var s in _stores.Values) - if (modPath.StartsWith(s.InstancePath + "\\", StringComparison.OrdinalIgnoreCase) || - modPath.StartsWith(s.InstancePath + "/", StringComparison.OrdinalIgnoreCase)) - return s; - return _current; - } - // 启/禁用是纯改名、mtime 不变;剥 .disabled 后复用同键避免白白重扫。 // 保留 .old(.old 可与新文件并存,剥了会键冲突)。 private static string _NormalizeKey(string path) @@ -130,10 +124,12 @@ private static void _EnsureLoaded(_Store store) /// 指纹匹配时返回缓存的内嵌树,否则返回 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) { - var store = _Route(path); - if (store is null) return null; _EnsureLoaded(store); if (store.Entries.TryGetValue(_NormalizeKey(path), out var e) && e.LastModified == lastModified && e.Size == size) @@ -144,10 +140,10 @@ public static List TryGet(string path, long lastModified, long public static void Set(string path, long lastModified, long size, List tree) { + var store = _current; + if (store is null) return; lock (_lock) { - var store = _Route(path); - if (store is null) return; _EnsureLoaded(store); store.Entries[_NormalizeKey(path)] = new CacheEntry { LastModified = lastModified, Size = size, Tree = tree }; store.Dirty = true; @@ -160,12 +156,11 @@ public static void Set(string path, long lastModified, long size, List public static void Prune(IEnumerable keepPaths) { + var store = _current; + if (store is null) return; lock (_lock) { var list = keepPaths as ICollection ?? keepPaths.ToList(); - // 按 mod 路径路由到所属实例,避免并发切换 _current 时用本实例清单误剪别的实例 - var store = list.Count > 0 ? _Route(list.First()) : _current; - if (store is null) return; _EnsureLoaded(store); var keep = new HashSet(list.Select(_NormalizeKey), StringComparer.OrdinalIgnoreCase); var stale = store.Entries.Keys.Where(k => !keep.Contains(k)).ToList(); @@ -193,7 +188,8 @@ private static void _FlushStore(_Store store) Directory.CreateDirectory(Path.GetDirectoryName(store.CachePath)!); var tmp = store.CachePath + ".tmp"; File.WriteAllText(tmp, - JsonSerializer.Serialize(new CacheFile { Version = FormatVersion, Entries = store.Entries })); + 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; diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs index ae95634c1..b7073c608 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs @@ -14,10 +14,6 @@ public enum JijDepStatus Missing // 无任何提供者 } -/// -/// 内嵌模组依赖分析。构建时按当前实例 MC 版本过滤出"真正会加载"的内嵌副本, -/// 供依赖四态判定与禁用/删除的级联反查复用(模组管理页与内嵌模组二级页共用)。 -/// /// /// 加载器/平台伪依赖 id 的统一判定,供依赖解析(.AddDependency)与 /// 依赖四态/级联分析共用,避免两处 id 集漂移。 @@ -36,37 +32,62 @@ public static bool IsPlatform(string id) => string.Equals(id, "minecraft", StringComparison.OrdinalIgnoreCase) || _loaderIds.Contains(id); } +/// +/// 内嵌模组依赖分析。构建时按当前实例 MC 版本过滤出"真正会加载"的内嵌副本, +/// 供依赖四态判定与禁用/删除的级联反查复用(模组管理页与内嵌模组二级页共用)。 +/// public class ModJarInJarIndex { private readonly List _allMods; - private readonly Dictionary> _providers = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary> _selfBundled = new(); + private readonly Dictionary> _providers = + new(StringComparer.OrdinalIgnoreCase); + // 每个 Mod 内嵌提供的 (id, 版本) 列表(同一 id 的多版本 wrapper 保留全部副本版本) + private readonly Dictionary> _selfBundled = new(); public ModJarInJarIndex(IEnumerable allMods, string mc) { _allMods = allMods.Where(m => !m.IsFolder).ToList(); foreach (var m in _allMods) { - // 仅收录当前实例 MC 版本真正会加载的内嵌副本(多版本 wrapper 只留匹配的那份) - var bundled = new HashSet(StringComparer.OrdinalIgnoreCase); - _CollectLoadableIds(m.EmbeddedMods, mc, bundled); - _selfBundled[m] = bundled; + var loadable = _CollectLoadable(m.EmbeddedMods, mc); + _selfBundled[m] = loadable; - if (!string.IsNullOrEmpty(m.ModId)) _AddProvider(m.ModId, m); - foreach (var id in bundled) _AddProvider(id, m); + if (!string.IsNullOrEmpty(m.ModId)) _AddProvider(m.ModId, m, m.Version); + foreach (var (id, ver) in loadable) _AddProvider(id, m, ver); } } + private static bool _VersionSatisfies(CompFile dependent, string depId, string providerVersion) + { + var req = dependent.DependencyRaw.TryGetValue(depId, out var r) ? r : null; + if (req is null) return true; + // provider 版本未知或不可比较(占位符未解析、纯库无版本、"MC1.21-xx" 等字母开头): + // 无法可靠判断时视为满足——错标"缺失"比漏一次版本警告更糟 + if (string.IsNullOrWhiteSpace(providerVersion)) return true; + var ver = providerVersion.Split('+')[0].Trim(); // 剥 semver 构建元数据(1.0.82+mc1.21.1) + if (ver.Length == 0 || !char.IsDigit(ver[0])) return true; + return McConstraintMatcher.Satisfies(req, null, ver); + } + + // 本 Mod 自己内嵌的副本是否满足其对该依赖的版本要求(内嵌了但版本不够时不算满足) + private bool _SelfBundleSatisfies(CompFile mod, string depId) + { + return _selfBundled.TryGetValue(mod, out var self) && + self.Any(x => string.Equals(x.Id, depId, StringComparison.OrdinalIgnoreCase) && + _VersionSatisfies(mod, depId, x.Version)); + } + public static bool IsPlatform(string id) => ModDependencyIds.IsPlatform(id); /// 某 Mod 的某条依赖当前处于四态中的哪一态。 public JijDepStatus Analyze(CompFile mod, string depId) { + if (_SelfBundleSatisfies(mod, depId)) return JijDepStatus.Bundled; _providers.TryGetValue(depId, out var provs); - var external = provs?.Where(p => p != mod).ToList() ?? new List(); - if (external.Any(p => p.State == CompFile.LocalFileStatus.Fine)) return JijDepStatus.Installed; - if (external.Count > 0) return JijDepStatus.Disabled; - if (_selfBundled.TryGetValue(mod, out var ids) && ids.Contains(depId)) return JijDepStatus.Bundled; + var satisfying = provs?.Where(p => p.Mod != mod && _VersionSatisfies(mod, depId, p.Version)).ToList() + ?? new List<(CompFile Mod, string Version)>(); + if (satisfying.Any(p => p.Mod.State == CompFile.LocalFileStatus.Fine)) return JijDepStatus.Installed; + if (satisfying.Count > 0) return JijDepStatus.Disabled; return JijDepStatus.Missing; } @@ -89,11 +110,13 @@ public List FindAffected(IEnumerable targets) { if (ModDependencyIds.IsPlatform(dep)) continue; if (c.OptionalDependencies.Contains(dep)) continue; // 可选依赖不参与级联 - if (_selfBundled.TryGetValue(c, out var ids) && ids.Contains(dep)) continue; + if (_SelfBundleSatisfies(c, dep)) continue; if (!_providers.TryGetValue(dep, out var provs)) continue; - var active = provs.Where(p => p.State == CompFile.LocalFileStatus.Fine).ToList(); + var active = provs + .Where(p => p.Mod.State == CompFile.LocalFileStatus.Fine && _VersionSatisfies(c, dep, p.Version)) + .ToList(); if (active.Count == 0) continue; // 本就未满足,忽略 - if (active.All(removal.Contains)) + if (active.All(p => removal.Contains(p.Mod))) { removal.Add(c); changed = true; @@ -105,149 +128,47 @@ public List FindAffected(IEnumerable targets) return removal.Where(m => !targetSet.Contains(m)).ToList(); } - private void _AddProvider(string id, CompFile top) + private void _AddProvider(string id, CompFile top, string version) { if (!_providers.TryGetValue(id, out var list)) { - list = new List(); + list = new List<(CompFile, string)>(); _providers[id] = list; } - if (!list.Contains(top)) list.Add(top); + // 去重键含版本:多版本 wrapper 的每份副本版本都保留,供依赖版本区间校验逐一尝试 + if (!list.Any(p => p.Mod == top && p.Version == version)) list.Add((top, version)); } #region MC 版本匹配 - // 递归收集"会加载"的内嵌 ModId:某副本 MC 约束不匹配当前实例则整支剪掉 - private static void _CollectLoadableIds(List embedded, string mc, HashSet into) + // 递归收集"会加载"的内嵌 (ModId, 版本):某副本 MC 约束不匹配当前实例则整支剪掉 + private static List<(string Id, string Version)> _CollectLoadable(List embedded, string mc) + { + var into = new List<(string, string)>(); + _Collect(embedded, mc, into); + return into; + } + + private static void _Collect(List embedded, string mc, List<(string, string)> into) { if (embedded is null) return; foreach (var e in embedded) { if (!_NodeLoads(e, mc)) continue; - if (!string.IsNullOrEmpty(e.ModId)) into.Add(e.ModId); - _CollectLoadableIds(e.EmbeddedMods, mc, into); + if (!string.IsNullOrEmpty(e.ModId)) into.Add((e.ModId, e.Version)); + _Collect(e.EmbeddedMods, mc, into); } } - // 该内嵌副本是否会在当前实例 MC 版本下加载 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 (_McSatisfiesRange(constraint, mc)) return true; - // 文件名/版本号里恰好整词出现该实例版本,视作精确命中 - if (_ContainsVersionToken(node.FileName, mc) || _ContainsVersionToken(node.Version, mc)) return true; - // provider 收集宁可多收(fail-open):约束含 Maven 解析器看不懂的 semver 运算符/通配 - // (如 Fabric 的 >=1.20 被 AddDependency 规整成 [>=1.20,))时,拿不准就当作会加载, - // 避免漏 provider 导致依赖方误报"缺失"(假红) - return constraint.IndexOfAny(new[] { '>', '<', '~', '^', '*' }) >= 0; - } - - // 边界是否形如可比较的 MC 版本(1.20.1 / 26.1 / 23w13a 均以数字开头); - // 不可解析的边界整段判不匹配(fail-closed),避免垃圾串经字符串比较误判"匹配一切" - private static bool _IsKnownVersion(string s) => s.Length > 0 && char.IsDigit(s[0]); - - // Maven 风格区间:[a,b] [a,b) (a,b) [a,) (,b] [a](精确)或裸 a(软下限 >=a),逗号分隔的多区间取或 - private static bool _McSatisfiesRange(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 (_IsKnownVersion(s) && McVersionComparer.CompareVersion(mc, s) >= 0) return true; // 裸版本 = 软下限 - 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 (_IsKnownVersion(only) && McVersionComparer.CompareVersion(mc, only) == 0) return true; - continue; - } - - var loStr = body.Substring(0, comma).Trim(); - var hiStr = body.Substring(comma + 1).Trim(); - if ((loStr.Length > 0 && !_IsKnownVersion(loStr)) || - (hiStr.Length > 0 && !_IsKnownVersion(hiStr))) - continue; - var ok = true; - if (loStr.Length > 0) - { - var c = McVersionComparer.CompareVersion(mc, loStr); - ok = incLo ? c >= 0 : c > 0; - } - - if (ok && hiStr.Length > 0) - { - var c = McVersionComparer.CompareVersion(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; - } - - // version 是否作为完整版本词出现在 haystack 中("1.21" 不命中 "1.21.2" 或 "9.1.20") - private 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; + 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 0ffa9b883..3b0fee9e6 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs @@ -847,6 +847,24 @@ public HashSet 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); + private void AddDependency(string modID, string versionRequirement = null, bool optional = false) { // 确保信息正确 @@ -857,6 +875,13 @@ private void AddDependency(string modID, string versionRequirement = null, bool return; if (modID == "name" || (ModBase.Val(modID).ToString() ?? "") == (modID ?? "")) return; // 跳过 name 与纯数字 id + var raw = versionRequirement is null || + (!versionRequirement.Contains(".") && !versionRequirement.Contains("-")) || + 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("$")) @@ -875,8 +900,52 @@ private void AddDependency(string modID, string versionRequirement = null, bool _Dependencies.Add(modID, versionRequirement); } - if (optional) _OptionalDependencies.Add(modID); - else _OptionalDependencies.Remove(modID); // 任一处声明为必需即覆盖可选标记 + // 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 @@ -889,7 +958,8 @@ private void AddDependency(string modID, string versionRequirement = null, bool /// /// 是否已进行 Mod 文件的基础加载。(这包括第一步和第二步) /// - private bool isLoaded; + // volatile:加载器线程写、UI 线程双检锁快路径无锁读,保证元数据发布顺序(弱内存模型下的可见性) + private volatile bool isLoaded; /// /// 标记为已加载。用于内嵌(Jar-in-Jar)子项——其元数据已由 通过 @@ -908,6 +978,38 @@ internal void SetJijMetadata(string name, string modId, string version) isLoaded = true; } + /// + /// 从同一物理文件的已解析实体复制解析结果。启/禁用仅重命名、文件内容未变, + /// 替换实体后无需在 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); + _EmbeddedMods = other._EmbeddedMods; + JijLoader = other.JijLoader; + JijTargetMcVersion = other.JijTargetMcVersion; + Logo = other.Logo; + Url = other.Url; + Authors = other.Authors; + _FileUnavailableReason = other._FileUnavailableReason; + isLoaded = true; + } + } + /// /// Mod 文件是否可被正常读取。 /// @@ -970,6 +1072,12 @@ private void Init() _ModId = null; possibleModId = new List(); _Dependencies = new Dictionary(); + _DependencyRaw.Clear(); + _OptionalDependencies.Clear(); + _RequiredDeclared.Clear(); + _EmbeddedMods = new List(); + JijLoader = null; + JijTargetMcVersion = null; isLoaded = false; _FileUnavailableReason = null; isInfoWithClassLoaded = false; @@ -1137,7 +1245,10 @@ private void LoadCore() 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)); // 信息获取 LookupMetadata(jar); EmbeddedMods = ModJarInJar.ResolveCached(path, jar); @@ -1338,11 +1449,17 @@ internal void LookupMetadata(ZipArchive jar) } } - // 依赖处理 (省略了 VB 中的注释部分,按逻辑实现) + // 依赖处理:版本要求允许为字符串或字符串数组。Fabric 的数组语义是任一命中即满足(OR), + // 故用 || 合并;元素自身含 || 时 OR 结合律无损 if (fabricObject.ContainsKey("depends")) foreach (var dep in (JsonObject)fabricObject["depends"]) - // 版本要求允许为字符串或字符串数组;数组时不取版本,避免 ToString() 污染成 JSON 文本 - AddDependency(dep.Key, dep.Value is JsonArray ? null : 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); + } } catch (Exception ex) { @@ -1547,6 +1664,7 @@ internal void LookupMetadata(ZipArchive jar) Authors = tomlData[0].Value["authors"].ToString(); // 读取依赖:优先 dependencies.<本modid>,并回退裸 [[dependencies]] 与命名不一致的 dependencies.* 段 + var sectionHadDeps = false; foreach (var subData in tomlData) { var headerL = subData.Key.ToLower(); @@ -1556,6 +1674,7 @@ internal void LookupMetadata(ZipArchive jar) var depEntry = subData.Value; if (depEntry.ContainsKey("modId")) { + sectionHadDeps = true; var type = depEntry.ContainsKey("type") ? depEntry["type"].ToString().ToLower() : null; @@ -1564,10 +1683,10 @@ internal void LookupMetadata(ZipArchive jar) var skip = type is "incompatible" or "discouraged" || (depEntry.ContainsKey("side") && depEntry["side"].ToString().ToLower() == "server"); - // optional:type=optional 或 mandatory=false;未显式声明即必需 + // optional:type=optional 或 mandatory=false(布尔或带引号字符串);未显式声明即必需 var optional = type == "optional" || (depEntry.ContainsKey("mandatory") && - depEntry["mandatory"] is bool mb && !mb); + depEntry["mandatory"].ToString().ToLower() == "false"); if (!skip) AddDependency( depEntry["modId"].ToString(), @@ -1579,22 +1698,29 @@ internal void LookupMetadata(ZipArchive jar) } } - foreach (Match arr in Regex.Matches(tomlText, @"dependencies[.\w]*\s*=\s*\[(.*?)\]", - RegexOptions.IgnoreCase | RegexOptions.Singleline)) - foreach (Match obj in Regex.Matches(arr.Groups[1].Value, @"\{([^}]*)\}")) - { - var body = obj.Groups[1].Value; - var idMatch = Regex.Match(body, "modId\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase); - if (!idMatch.Success) continue; - var lower = body.ToLower(); - // 排斥/劝退/仅服务端:不是依赖,跳过 - if (lower.Contains("\"incompatible\"") || lower.Contains("\"discouraged\"") || - Regex.IsMatch(body, "side\\s*=\\s*\"server\"", RegexOptions.IgnoreCase)) - continue; - var optional = lower.Contains("mandatory=false") || - lower.Contains("mandatory = false") || lower.Contains("\"optional\""); - AddDependency(idMatch.Groups[1].Value, 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; @@ -2091,7 +2217,9 @@ private static void CompResourceListLoad(LoaderTask(); - ModJarInJarCache.UseInstance(loader.input.gameVersion.PathInstance); + // 仅 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; @@ -2125,8 +2253,11 @@ private static void CompResourceListLoad(LoaderTask !m.IsFolder).Select(m => m.path)); - ModJarInJarCache.Flush(); + if (jijEnabled) + { + ModJarInJarCache.Prune(modList.Where(m => !m.IsFolder).Select(m => m.path)); + ModJarInJarCache.Flush(); + } loader.Progress = 0.99d; ModBase.Log( @@ -2161,6 +2292,12 @@ private static void CompResourceListLoad(LoaderTask !m.IsFolder).ToList(); - _index = new ModJarInJarIndex(allMods, PageInstanceLeft.McInstance?.Info?.VanillaName); + _instanceMc = PageInstanceLeft.McInstance?.Info?.VanillaName; + _index = new ModJarInJarIndex(allMods, _instanceMc); PanLoad.Visibility = Visibility.Collapsed; PanWarnList.Children.Clear(); @@ -94,7 +96,8 @@ private void _BuildRelationContent(StackPanel stack, CompFile mod) { var deps = mod.Dependencies.Keys.Where(k => !ModJarInJarIndex.IsPlatform(k)).ToList(); foreach (var dep in deps) - stack.Children.Add(BuildDependencyRow(mod, dep, mod.Dependencies[dep], + stack.Children.Add(BuildDependencyRow(mod, dep, + mod.DependencyRaw.TryGetValue(dep, out var rawReq) ? rawReq : mod.Dependencies[dep], mod.OptionalDependencies.Contains(dep))); } @@ -107,14 +110,39 @@ private void _BuildBundledContent(StackPanel stack, CompFile mod) private void AppendTreeRows(StackPanel stack, List embedded, int depth) { if (embedded is null) return; - foreach (var e in embedded) + + 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)); + stack.Children.Add(BuildTreeRow(e, depth, 0, McConstraintMatcher.MatchKind.None)); AppendTreeRows(stack, e.EmbeddedMods, depth + 1); } } - private Panel BuildTreeRow(CompFile e, int depth) + private Panel BuildTreeRow(CompFile e, int depth, int versionCount, McConstraintMatcher.MatchKind kind, + string versionsTooltip = null) { var row = new StackPanel { @@ -125,10 +153,25 @@ private Panel BuildTreeRow(CompFile e, int depth) if (!string.IsNullOrEmpty(ver)) row.Children.Add(_TextRes(" (" + ver + ")", "ColorBrush2")); if (!string.IsNullOrEmpty(e.JijLoader)) - row.Children.Add(_TextRes(" [" + e.JijLoader + "]", "ColorBrush2")); + row.Children.Add(_TextRes(" [" + e.JijLoader + "]", "ColorBrush3")); var mc = _CleanPlaceholder(e.JijTargetMcVersion); if (!string.IsNullOrEmpty(mc)) - row.Children.Add(_TextRes(" MC " + mc, "ColorBrush2")); + 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; } diff --git a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompResource.xaml.cs b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompResource.xaml.cs index b90af217e..a8f2e7588 100644 --- a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompResource.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompResource.xaml.cs @@ -2043,6 +2043,7 @@ private void EDModsCore(IEnumerable modList, bool is // 更改 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); From 8d68a6393a2d58e90d859f7912f059e505a020f5 Mon Sep 17 00:00:00 2001 From: lokins Date: Sun, 26 Jul 2026 12:30:33 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(mod):=20JIJ=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F=E6=8C=89=E5=8A=A0=E8=BD=BD=E5=99=A8=E6=96=B9?= =?UTF-8?q?=E8=A8=80=E6=B1=82=E5=80=BC,=E4=BF=9D=E7=95=99=20Fabric=20?= =?UTF-8?q?=E9=A2=84=E5=8F=91=E5=B8=83=E4=B8=8B=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Minecraft/McConstraintMatcher.cs | 9 +++++++-- Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs | 5 +++-- .../Modules/Minecraft/ModJarInJarDependency.cs | 2 +- Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs | 6 ++++++ 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs b/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs index b0a714e31..0100ef082 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs @@ -183,7 +183,9 @@ private static bool TermMatches(string term, string mc) while (i < term.Length && "><=".IndexOf(term[i]) >= 0) i++; var op = term.Substring(0, i); var ver = term.Substring(i).Trim(); - if (ver.EndsWith("-")) ver = ver.Substring(0, ver.Length - 1); // Fabric 预发布标记,如 ">=26.1-" + // 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) @@ -219,6 +221,9 @@ private static bool TermMatches(string term, string mc) } 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 = McVersionComparer.CompareVersion(mc, ver); return op switch { @@ -227,7 +232,7 @@ private static bool TermMatches(string term, string mc) ">" => c > 0, "<=" => c <= 0, "<" => c < 0, - _ => false // ~ ^ 及异常运算符:不猜 + _ => false // 异常运算符:不猜 }; } diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs index e45316e66..2b868469a 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs @@ -119,7 +119,7 @@ private static List _ToNodes(List m es.CopyTo(fs); using var nestedJar = ZipFile.OpenRead(tmp); child.LookupMetadata(nestedJar); - child.JijLoader = _DetectLoader(nestedJar); + 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); @@ -219,7 +219,8 @@ private static void _CollectManifestEmbeddedJars(ZipArchive jar, List pa } } - private static string _DetectLoader(ZipArchive jar) + /// 按存在的清单文件判断 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"; diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs index b7073c608..97722da31 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs @@ -66,7 +66,7 @@ private static bool _VersionSatisfies(CompFile dependent, string depId, string p if (string.IsNullOrWhiteSpace(providerVersion)) return true; var ver = providerVersion.Split('+')[0].Trim(); // 剥 semver 构建元数据(1.0.82+mc1.21.1) if (ver.Length == 0 || !char.IsDigit(ver[0])) return true; - return McConstraintMatcher.Satisfies(req, null, ver); + return McConstraintMatcher.Satisfies(req, dependent.DetectedLoader, ver); } // 本 Mod 自己内嵌的副本是否满足其对该依赖的版本要求(内嵌了但版本不够时不算满足) diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs index 3b0fee9e6..4605f1bbb 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs @@ -1002,6 +1002,7 @@ internal void CopyLoadedStateFrom(LocalCompFile other) _EmbeddedMods = other._EmbeddedMods; JijLoader = other.JijLoader; JijTargetMcVersion = other.JijTargetMcVersion; + DetectedLoader = other.DetectedLoader; Logo = other.Logo; Url = other.Url; Authors = other.Authors; @@ -1078,6 +1079,7 @@ private void Init() _EmbeddedMods = new List(); JijLoader = null; JijTargetMcVersion = null; + DetectedLoader = null; isLoaded = false; _FileUnavailableReason = null; isInfoWithClassLoaded = false; @@ -1249,6 +1251,7 @@ private void LoadCore() // (如崩溃导出线程与列表加载并发)抛 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.ResolveCached(path, jar); @@ -1295,6 +1298,9 @@ public List EmbeddedMods /// 内嵌(Jar-in-Jar)子项声明的目标 Minecraft 版本范围;仅内嵌项有值。 public string JijTargetMcVersion { get; internal set; } + /// 本 Mod 检出的加载器(Fabric/Quilt/Forge/NeoForge),供依赖版本约束按方言求值;null=未知。 + public string DetectedLoader { get; internal set; } + /// /// 从 Jar 文件中获取 Mod 信息。 /// From fb229015d720f2e8dbbc9996f8b9e52c0f04e191 Mon Sep 17 00:00:00 2001 From: lokins Date: Sun, 26 Jul 2026 12:42:30 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(mod):=20=E4=BF=AE=E5=A4=8D=E5=B7=B2?= =?UTF-8?q?=E7=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Export/CrashReportExporter.cs | 6 ++++++ .../Modules/Minecraft/McConstraintMatcher.cs | 18 +++++++++++++++--- .../Modules/Minecraft/ModLocalComp.cs | 6 +++--- 3 files changed, 24 insertions(+), 6 deletions(-) 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 38ccae709..52062e53c 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Export/CrashReportExporter.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Export/CrashReportExporter.cs @@ -379,6 +379,12 @@ private static void _WriteModInfo(string reportFolder, McInstance? instance) { LogWrapper.Warn(ex, "Crash", "导出模组信息失败"); } + finally + { + // 复位本线程的"当前实例":含 mods 目录不存在的 early return 与异常路径, + // 否则本线程后续懒加载别实例的 Mod 会被路由进本实例缓存 + ModJarInJarCache.UseInstance(null); + } } private static IEnumerable _FlattenEmbedded(List mods) diff --git a/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs b/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs index 0100ef082..41a7393fa 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs @@ -207,9 +207,21 @@ private static bool TermMatches(string term, string mc) if (cur >= 0) nums.Add(cur); if (nums.Count == 0) return false; - var upper = (tilde || nums[0] == 0) && nums.Count >= 2 - ? nums[0] + "." + (nums[1] + 1) - : (nums[0] + 1).ToString(); + 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 McVersionComparer.CompareVersion(mc, upper) < 0; } diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs index 4605f1bbb..b765defb7 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs @@ -875,9 +875,9 @@ private void AddDependency(string modID, string versionRequirement = null, bool return; if (modID == "name" || (ModBase.Val(modID).ToString() ?? "") == (modID ?? "")) return; // 跳过 name 与纯数字 id - var raw = versionRequirement is null || - (!versionRequirement.Contains(".") && !versionRequirement.Contains("-")) || - versionRequirement.Contains("$") + // 原始约束:仅剔除空值与占位符,语法有效性交给 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; From 15c15f033a1d0a32ccd7e08a988cea77c4714061 Mon Sep 17 00:00:00 2001 From: lokins Date: Sun, 26 Jul 2026 18:43:27 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(mod):=20=E4=BF=AE=E5=A4=8D=20JIJ=20semv?= =?UTF-8?q?er=20=E5=BF=BD=E7=95=A5=20build=20metadata=EF=BC=8C=E5=8C=BA?= =?UTF-8?q?=E5=88=86=E4=BE=9D=E8=B5=96=E7=89=88=E6=9C=AC=E4=B8=8D=E7=AC=A6?= =?UTF-8?q?=E4=B8=8E=E7=BC=BA=E5=A4=B1=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=B7=B2?= =?UTF-8?q?=E7=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Localization/Languages/en-US.xaml | 4 + .../App/Localization/Languages/zh-CN.xaml | 4 + .../Modules/Minecraft/McConstraintMatcher.cs | 55 +++++-- .../Modules/Minecraft/ModJarInJar.cs | 3 + .../Modules/Minecraft/ModJarInJarCache.cs | 8 +- .../Minecraft/ModJarInJarDependency.cs | 122 ++++++++++++---- .../Modules/Minecraft/ModLocalComp.cs | 22 ++- .../PageInstanceCompJarInJar.xaml | 3 + .../PageInstanceCompJarInJar.xaml.cs | 135 ++++++++++++++---- 9 files changed, 291 insertions(+), 65 deletions(-) diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml index 7c0ba95f3..d42b9925d 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -1086,14 +1086,18 @@ 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 diff --git a/PCL.Core/App/Localization/Languages/zh-CN.xaml b/PCL.Core/App/Localization/Languages/zh-CN.xaml index a19bb32e9..d5214fd4a 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -1086,14 +1086,18 @@ 内嵌模组 依赖 {0} 缺少前置:{1} + {0} 前置版本不符:{1} 已安装 已禁用 内嵌提供 + 版本不符 缺失 [可选] ✓ 当前实例使用 ⚠ 不适配当前版本 {0} 个版本 + [内置 · 来自 {0}] + 搜索模组:名称 / ModId / 依赖 仅操作此模组 连带禁用依赖它的模组 连带删除依赖它的模组 diff --git a/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs b/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs index 41a7393fa..13913545e 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs @@ -71,11 +71,34 @@ public static bool Satisfies(string constraint, string loaderType, string versio } } - // 版本串是否可比较(1.20.1 / 26.1 / 23w13a 均数字开头;剥掉 Fabric 尾 - 后判断) + // 剥 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);仅在其后紧跟数字时剥 + private 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]); } @@ -90,7 +113,7 @@ private static bool SatisfiesMaven(string constraint, string mc) if (s[0] != '[' && s[0] != '(') { - if (IsKnown(s) && McVersionComparer.CompareVersion(mc, s) >= 0) return true; // 裸版本=软下限 + if (IsKnown(s) && Cmp(mc,s) >= 0) return true; // 裸版本=软下限 continue; } @@ -102,7 +125,7 @@ private static bool SatisfiesMaven(string constraint, string mc) if (comma < 0) { var only = body.Trim(); // [a] 精确 - if (IsKnown(only) && McVersionComparer.CompareVersion(mc, only) == 0) return true; + if (IsKnown(only) && Cmp(mc,only) == 0) return true; continue; } @@ -113,13 +136,13 @@ private static bool SatisfiesMaven(string constraint, string mc) var ok = true; if (loStr.Length > 0) { - var c = McVersionComparer.CompareVersion(mc, loStr); + var c = Cmp(mc,loStr); ok = incLo ? c >= 0 : c > 0; } if (ok && hiStr.Length > 0) { - var c = McVersionComparer.CompareVersion(mc, hiStr); + var c = Cmp(mc,hiStr); ok = incHi ? c <= 0 : c < 0; } @@ -154,6 +177,22 @@ private static List SplitTopLevel(string s) #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 McVersionComparer.CompareVersion(a.Substring(aPre + 1), b.Substring(bPre + 1)); + } + // 空格 = AND,|| = OR,运算符 >= > <= < =,x/* 通配,尾 - 预发布标记 private static bool SatisfiesSemVer(string constraint, string mc) { @@ -194,7 +233,7 @@ private static bool TermMatches(string term, string mc) var tilde = ver[0] == '~'; var baseVer = ver.Substring(1).Trim(); if (!IsKnown(baseVer)) return false; - if (McVersionComparer.CompareVersion(mc, baseVer) < 0) return false; + if (CompareSemVer(mc, baseVer) < 0) return false; var nums = new List(); var cur = -1; foreach (var ch in baseVer) @@ -222,7 +261,7 @@ private static bool TermMatches(string term, string mc) upper = string.Join(".", nums.Take(idx).Concat(new[] { nums[idx] + 1 })); } - return McVersionComparer.CompareVersion(mc, upper) < 0; + return CompareSemVer(mc, upper) < 0; } // 通配 1.20.x / 1.20.* 仅在无运算符时有意义 @@ -236,7 +275,7 @@ private static bool TermMatches(string term, string mc) if (prereleaseFloor && (op is "" or ">=" or ">") && string.Equals(mc.Split('-')[0], ver, StringComparison.OrdinalIgnoreCase)) return true; - var c = McVersionComparer.CompareVersion(mc, ver); + var c = CompareSemVer(mc, ver); return op switch { "" or "=" or "==" => c == 0, // Fabric 裸版本是精确匹配 diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs index 2b868469a..77f224ed9 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJar.cs @@ -59,6 +59,8 @@ private static List _ToNodes(List m Version = m.Version, Loader = m.JijLoader, TargetMcVersion = m.JijTargetMcVersion, + Dependencies = new Dictionary(m.DependencyRaw), + OptionalDeps = m.OptionalDependencies.ToList(), Children = _ToNodes(m.EmbeddedMods) }).ToList(); @@ -73,6 +75,7 @@ private static List _ToNodes(List m 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); } diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs index d481ba345..43baf7a6d 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarCache.cs @@ -16,7 +16,7 @@ namespace PCL; public static class ModJarInJarCache { /// 缓存数据结构变化时递增此值以令旧缓存失效(改动 JIJ 解析/节点字段后务必升此值)。 - private const int FormatVersion = 3; + private const int FormatVersion = 5; private static readonly JsonSerializerOptions _jsonOpts = new() { @@ -215,5 +215,11 @@ public class EmbeddedModNode /// 声明的目标 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 index 97722da31..0cb885bef 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs @@ -9,9 +9,10 @@ namespace PCL; public enum JijDepStatus { Installed, // 有启用的独立/其它内嵌提供者 - Disabled, // 有提供者但都被禁用 + Disabled, // 有满足版本的提供者但都被禁用 Bundled, // 无独立提供,但本 Mod 内嵌了它 - Missing // 无任何提供者 + VersionMismatch, // 有提供者但版本都不满足约束(装了,但装错版本) + Missing // 无任何提供者(根本没装) } /// @@ -38,56 +39,116 @@ public static bool IsPlatform(string id) => /// 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 loadable = _CollectLoadable(m.EmbeddedMods, mc); - _selfBundled[m] = loadable; + 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 (id, ver) in loadable) _AddProvider(id, m, ver); + 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, StringComparer.OrdinalIgnoreCase) + .Select(g => new DepRow + { + DepId = g.Key, + Raw = g.Select(r => r.Raw).FirstOrDefault(v => v is not null), + Optional = g.All(r => r.Optional), + Loader = g.Select(r => r.Loader).FirstOrDefault(l => l is not null) + }).ToList(); } } - private static bool _VersionSatisfies(CompFile dependent, string depId, string providerVersion) + /// 某 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) { - var req = dependent.DependencyRaw.TryGetValue(depId, out var r) ? r : null; - if (req is null) return true; + if (dep.Raw is null) return true; // provider 版本未知或不可比较(占位符未解析、纯库无版本、"MC1.21-xx" 等字母开头): // 无法可靠判断时视为满足——错标"缺失"比漏一次版本警告更糟 if (string.IsNullOrWhiteSpace(providerVersion)) return true; - var ver = providerVersion.Split('+')[0].Trim(); // 剥 semver 构建元数据(1.0.82+mc1.21.1) + var ver = providerVersion.Trim(); if (ver.Length == 0 || !char.IsDigit(ver[0])) return true; - return McConstraintMatcher.Satisfies(req, dependent.DetectedLoader, ver); + if (McConstraintMatcher.Satisfies(dep.Raw, dep.Loader, ver)) return true; + return !McConstraintMatcher.HasComparableLowerBound(dep.Raw); } - // 本 Mod 自己内嵌的副本是否满足其对该依赖的版本要求(内嵌了但版本不够时不算满足) - private bool _SelfBundleSatisfies(CompFile mod, string depId) + // 本 Mod 自己内嵌的副本是否满足该依赖的版本要求(内嵌了但版本不够时不算满足) + private bool _SelfBundleSatisfies(CompFile mod, DepRow dep) { return _selfBundled.TryGetValue(mod, out var self) && - self.Any(x => string.Equals(x.Id, depId, StringComparison.OrdinalIgnoreCase) && - _VersionSatisfies(mod, depId, x.Version)); + 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, string depId) + /// 某 Mod 的某条有效依赖当前处于四态中的哪一态。 + public JijDepStatus Analyze(CompFile mod, DepRow dep) { - if (_SelfBundleSatisfies(mod, depId)) return JijDepStatus.Bundled; - _providers.TryGetValue(depId, out var provs); - var satisfying = provs?.Where(p => p.Mod != mod && _VersionSatisfies(mod, depId, p.Version)).ToList() - ?? new List<(CompFile Mod, string Version)>(); + 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; } @@ -106,14 +167,14 @@ public List FindAffected(IEnumerable targets) foreach (var c in _allMods) { if (c.State != CompFile.LocalFileStatus.Fine || removal.Contains(c)) continue; - foreach (var dep in c.Dependencies.Keys) + foreach (var dep in GetDependencies(c)) { - if (ModDependencyIds.IsPlatform(dep)) continue; - if (c.OptionalDependencies.Contains(dep)) continue; // 可选依赖不参与级联 + if (ModDependencyIds.IsPlatform(dep.DepId)) continue; + if (dep.Optional) continue; // 可选依赖不参与级联 if (_SelfBundleSatisfies(c, dep)) continue; - if (!_providers.TryGetValue(dep, out var provs)) continue; + if (!_providers.TryGetValue(_Norm(dep.DepId), out var provs)) continue; var active = provs - .Where(p => p.Mod.State == CompFile.LocalFileStatus.Fine && _VersionSatisfies(c, dep, p.Version)) + .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))) @@ -130,6 +191,7 @@ public List FindAffected(IEnumerable targets) 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)>(); @@ -142,21 +204,21 @@ private void _AddProvider(string id, CompFile top, string version) #region MC 版本匹配 - // 递归收集"会加载"的内嵌 (ModId, 版本):某副本 MC 约束不匹配当前实例则整支剪掉 - private static List<(string Id, string Version)> _CollectLoadable(List embedded, string mc) + // 递归收集"会加载"的内嵌节点:某副本 MC 约束不匹配当前实例则整支剪掉 + private static List _CollectLoadableNodes(List embedded, string mc) { - var into = new List<(string, string)>(); + var into = new List(); _Collect(embedded, mc, into); return into; } - private static void _Collect(List embedded, string mc, List<(string, string)> 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; - if (!string.IsNullOrEmpty(e.ModId)) into.Add((e.ModId, e.Version)); + into.Add(e); _Collect(e.EmbeddedMods, mc, into); } } diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs index b765defb7..5157cb704 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs @@ -978,6 +978,16 @@ internal void SetJijMetadata(string name, string modId, string 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 线程重新解压解析。来源未加载时不做任何事。 @@ -1669,13 +1679,23 @@ internal void LookupMetadata(ZipArchive jar) if (tomlData[0].Value.ContainsKey("authors")) Authors = tomlData[0].Value["authors"].ToString(); - // 读取依赖:优先 dependencies.<本modid>,并回退裸 [[dependencies]] 与命名不一致的 dependencies.* 段 + var ownModIdL = ModId.ToLower(); + var jarModIds = tomlData + .Where(s => s.Key == "mods" && s.Value.ContainsKey("modId")) + .Select(s => s.Value["modId"].ToString().ToLower()) + .ToHashSet(); var sectionHadDeps = false; foreach (var subData in tomlData) { 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")) diff --git a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml index f5b27ac12..4b6db495f 100644 --- a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml +++ b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml @@ -23,6 +23,9 @@ + + diff --git a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs index 1a591e612..70682b36d 100644 --- a/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageInstance/PageInstanceCompJarInJar.xaml.cs @@ -13,6 +13,7 @@ public partial class PageInstanceCompJarInJar { private ModJarInJarIndex _index; private string _instanceMc; + private string _search = ""; public PageInstanceCompJarInJar() { @@ -20,9 +21,34 @@ public PageInstanceCompJarInJar() 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); @@ -43,35 +69,53 @@ private void RefreshList() PanLoad.Visibility = Visibility.Collapsed; PanWarnList.Children.Clear(); - foreach (var mod in allMods) + foreach (var host in allMods) { - var missing = mod.Dependencies.Keys - .Where(k => !ModJarInJarIndex.IsPlatform(k) && !mod.OptionalDependencies.Contains(k) && - _index.Analyze(mod, k) == JijDepStatus.Missing) - .ToList(); - if (missing.Count > 0) - PanWarnList.Children.Add(_Text( - Lang.Text("Instance.Resource.Mod.JarInJar.Warning.Missing", _DisplayName(mod), - string.Join(", ", missing)), _BrushError)); + 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 relationMods = allMods - .Where(m => m.Dependencies.Keys.Any(k => !ModJarInJarIndex.IsPlatform(k))).ToList(); - foreach (var mod in relationMods) - PanRelationList.Children.Add(_MakeCard(mod, _BuildRelationContent)); - SectionRelations.Visibility = relationMods.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + 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 }).ToList(); + 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 || relationMods.Count > 0 || bundledMods.Count > 0 + // 搜索无结果时不显示"没有模组"空态(避免误导为实例本身没有),仅留空白 + PanEmpty.Visibility = hasWarning || relationCount > 0 || bundledMods.Count > 0 || _search.Length > 0 ? Visibility.Collapsed : Visibility.Visible; } @@ -92,13 +136,51 @@ private MyCard _MakeCard(CompFile mod, Action build) return card; } - private void _BuildRelationContent(StackPanel stack, CompFile mod) + 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 deps = mod.Dependencies.Keys.Where(k => !ModJarInJarIndex.IsPlatform(k)).ToList(); - foreach (var dep in deps) - stack.Children.Add(BuildDependencyRow(mod, dep, - mod.DependencyRaw.TryGetValue(dep, out var rawReq) ? rawReq : mod.Dependencies[dep], - mod.OptionalDependencies.Contains(dep))); + 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; } // 内嵌模组:仅展示内嵌树(禁用/删除请在模组列表进行,级联在那里统一处理) @@ -175,11 +257,12 @@ private Panel BuildTreeRow(CompFile e, int depth, int versionCount, McConstraint return row; } - private Panel BuildDependencyRow(CompFile mod, string depId, string versionReq, bool optional) + private Panel BuildDependencyRow(CompFile mod, ModJarInJarIndex.DepRow dep) { - var status = _index.Analyze(mod, depId); + 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 = versionReq is null ? depId : depId + " " + versionReq; + 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")); @@ -197,6 +280,7 @@ private Panel BuildDependencyRow(CompFile mod, string depId, string versionReq, 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" }); @@ -204,6 +288,7 @@ private Panel BuildDependencyRow(CompFile mod, string depId, string versionReq, { JijDepStatus.Installed => _BrushOk, JijDepStatus.Disabled => _BrushWarn, + JijDepStatus.VersionMismatch => _BrushWarn, _ => _BrushError }; From 10f669e338c1000f459059a7f35cae2fe629b003 Mon Sep 17 00:00:00 2001 From: lokins Date: Sun, 26 Jul 2026 19:40:59 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(mod):=20=E4=BF=AE=E5=A4=8D=E5=B7=B2?= =?UTF-8?q?=E7=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Minecraft/McConstraintMatcher.cs | 40 ++++++++++++++++++- .../Minecraft/ModJarInJarDependency.cs | 4 +- .../Modules/Minecraft/ModLocalComp.cs | 28 +++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs b/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs index 13913545e..34509851b 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/McConstraintMatcher.cs @@ -79,7 +79,7 @@ private static string StripBuild(string s) } // 剥版本号常见的前导 v/V(如 v0.5.1c → 0.5.1c);仅在其后紧跟数字时剥 - private static string StripV(string s) => + 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 @@ -190,7 +190,43 @@ private static int CompareSemVer(string a, string b) if (aPre < 0 && bPre < 0) return 0; if (aPre < 0) return 1; // a 无预发布 > b(有预发布) if (bPre < 0) return -1; // a 有预发布 < b(正式版) - return McVersionComparer.CompareVersion(a.Substring(aPre + 1), b.Substring(bPre + 1)); + 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/* 通配,尾 - 预发布标记 diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs index 0cb885bef..0642b2e49 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs @@ -69,6 +69,8 @@ public ModJarInJarIndex(IEnumerable allMods, string mc) .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); @@ -122,7 +124,7 @@ private static bool _VersionSatisfies(DepRow dep, string providerVersion) // provider 版本未知或不可比较(占位符未解析、纯库无版本、"MC1.21-xx" 等字母开头): // 无法可靠判断时视为满足——错标"缺失"比漏一次版本警告更糟 if (string.IsNullOrWhiteSpace(providerVersion)) return true; - var ver = providerVersion.Trim(); + 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); diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs index 5157cb704..a28ad5675 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLocalComp.cs @@ -865,6 +865,21 @@ public Dictionary DependencyRaw 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) { // 确保信息正确 @@ -1009,6 +1024,8 @@ internal void CopyLoadedStateFrom(LocalCompFile other) _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; @@ -1086,6 +1103,7 @@ private void Init() _DependencyRaw.Clear(); _OptionalDependencies.Clear(); _RequiredDeclared.Clear(); + _ProvidedIds.Clear(); _EmbeddedMods = new List(); JijLoader = null; JijTargetMcVersion = null; @@ -1476,6 +1494,13 @@ internal void LookupMetadata(ZipArchive jar) : 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) { @@ -1684,6 +1709,9 @@ internal void LookupMetadata(ZipArchive jar) .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) { From c13a6af2797b8cd8ca104158da0ee980b657719f Mon Sep 17 00:00:00 2001 From: lokins Date: Sun, 26 Jul 2026 19:52:43 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(mod):=20=E4=BF=AE=E5=A4=8D=E5=90=8C=20i?= =?UTF-8?q?d=20=E7=BA=A6=E6=9D=9F=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Minecraft/ModJarInJarDependency.cs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs index 0642b2e49..084f76f5b 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModJarInJarDependency.cs @@ -89,14 +89,9 @@ public ModJarInJarIndex(IEnumerable allMods, string mc) Optional = n.OptionalDependencies.Contains(kv.Key), Loader = n.JijLoader }); _deps[m] = rows - .GroupBy(r => r.DepId, StringComparer.OrdinalIgnoreCase) - .Select(g => new DepRow - { - DepId = g.Key, - Raw = g.Select(r => r.Raw).FirstOrDefault(v => v is not null), - Optional = g.All(r => r.Optional), - Loader = g.Select(r => r.Loader).FirstOrDefault(l => l is not null) - }).ToList(); + .GroupBy(r => (r.DepId, r.Raw, r.Loader, r.Optional)) + .Select(g => g.First()) + .ToList(); } }