From 3553be9d5759b6027d320af2716cba2b3f080401 Mon Sep 17 00:00:00 2001 From: roman_gr Date: Mon, 27 Jul 2026 20:39:54 +0200 Subject: [PATCH] Show mod diff status and file versions in the join data window Color both mod lists by diff status: mods the client is missing are red on the server list, while extra mods (orange) and mods loaded out of relative order (yellow) are flagged on the client list, since reordering is resolved locally. Out-of-order mods are found with a longest-common-order pass so a single displaced mod doesn't mark everything after it. A legend between the two lists explains the colors. Leaf file nodes in the Files tab gain a tooltip showing each side's last write time, plus the assembly, file and product versions for .dll files. The host reads these when snapshotting mod files and sends them along in ClientInitDataPacket. They are kept out of ModFile equality so they never affect the diff itself, which stays keyed on relative path and hash. New keys: MpMismatchModListWrongOrder, MpMismatchFileAssemblyVersion, MpMismatchFileVersion, MpMismatchFileProductVersion, MpMismatchFileModified, MpMismatchFileUnknown (added in the locale repo, pointer bump pending). Co-Authored-By: Claude Opus 5 --- Source/Client/Networking/JoinData.cs | 56 +++++- Source/Client/Windows/JoinDataWindow.cs | 178 +++++++++++++++++- .../Networking/Packet/InitDataPacket.cs | 8 + 3 files changed, 228 insertions(+), 14 deletions(-) diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index c123d9c2a..2af3c70b7 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -1,8 +1,10 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; +using System.Reflection; using HarmonyLib; using Multiplayer.Client.Util; using Multiplayer.Common; @@ -33,6 +35,10 @@ public static class JoinData { path = pathToFilePair.Value.relPath, hash = pathToFilePair.Value.hash, + assemblyVersion = pathToFilePair.Value.assemblyVersion ?? "", + fileVersion = pathToFilePair.Value.fileVersion ?? "", + productVersion = pathToFilePair.Value.productVersion ?? "", + writeTime = pathToFilePair.Value.writeTime, }) .ToList(); @@ -101,19 +107,51 @@ internal static ModFileDict GetModFiles(IEnumerable modIds) foreach (var asm in MultiplayerData.GetModAssemblies(contentPack)) { var relPath = asm.FullName.RemovePrefix(contentPack.RootDir).NormalizePath(); - fileDict.Add(modId, new ModFile(asm.FullName, relPath, asm.CRC32())); + var (assemblyVersion, fileVersion, productVersion) = ReadDllVersions(asm.FullName); + fileDict.Add(modId, new ModFile(asm.FullName, relPath, asm.CRC32(), + assemblyVersion, fileVersion, productVersion, asm.LastWriteTimeUtc.Ticks)); } foreach (var xmlFile in GetModDefsAndPatches(contentPack)) { var relPath = xmlFile.FullName.RemovePrefix(contentPack.RootDir).NormalizePath(); - fileDict.Add(modId, new ModFile(xmlFile.FullName, relPath, xmlFile.CRC32())); + fileDict.Add(modId, new ModFile(xmlFile.FullName, relPath, xmlFile.CRC32(), + writeTime: xmlFile.LastWriteTimeUtc.Ticks)); } } return fileDict; } + // Reads the assembly, file and product versions of a .dll. Most mods bump the file + // version per release while leaving the assembly version at 1.0.0.0. Each is null when + // absent (unset 0.0.0.0 or unreadable/native DLL). + private static (string assembly, string file, string product) ReadDllVersions(string path) + { + string assembly = null, file = null, product = null; + + try + { + var v = AssemblyName.GetAssemblyName(path).Version; + if (v != null && v != new System.Version(0, 0, 0, 0)) + assembly = v.ToString(); + } + catch { } + + try + { + var fvi = FileVersionInfo.GetVersionInfo(path); + var fv = new System.Version(fvi.FileMajorPart, fvi.FileMinorPart, fvi.FileBuildPart, fvi.FilePrivatePart); + if (fv != new System.Version(0, 0, 0, 0)) + file = fv.ToString(); + if (!string.IsNullOrWhiteSpace(fvi.ProductVersion)) + product = fvi.ProductVersion.Trim(); + } + catch { } + + return (assembly, file, product); + } + public static IEnumerable GetModDefsAndPatches(ModContentPack mod) { foreach (var f in ModContentPack.GetAllFilesForModPreserveOrder(mod, "Defs/", f => f.ToLower() == ".xml")) @@ -196,10 +234,13 @@ public static RemoteData FromNet(ServerJoinDataPacket packet) remoteInfo.remoteMods.Add(modInfo); var modMeta = JoinData.GetInstalledMod(modInfo.packageId); + static string NullIfEmpty(string s) => string.IsNullOrEmpty(s) ? null : s; foreach (var modFile in mod.files) { var absPath = modMeta == null ? null : Path.Combine(modMeta.RootDir.FullName, modFile.path); - remoteInfo.remoteFiles.Add(modInfo.packageId, new ModFile(absPath, modFile.path, modFile.hash)); + remoteInfo.remoteFiles.Add(modInfo.packageId, new ModFile(absPath, modFile.path, modFile.hash, + NullIfEmpty(modFile.assemblyVersion), NullIfEmpty(modFile.fileVersion), + NullIfEmpty(modFile.productVersion), modFile.writeTime)); } if (mod.config.HasValue) @@ -276,12 +317,19 @@ public struct ModInfo public bool CanSubscribe => steamId != 0; } - public struct ModFile(string absPath, string relPath, int hash) + public struct ModFile(string absPath, string relPath, int hash, + string assemblyVersion = null, string fileVersion = null, string productVersion = null, long writeTime = 0) { public string absPath = absPath?.NormalizePath(); // Can be null on the remote side public string relPath = relPath.NormalizePath(); public int hash = hash; + public string assemblyVersion = assemblyVersion; // .dll only, null otherwise + public string fileVersion = fileVersion; // .dll only, null otherwise + public string productVersion = productVersion; // .dll only, null otherwise + public long writeTime = writeTime; // Last write time, UTC ticks (0 if unknown) + // Equality stays on relPath + hash only: version/write time are display metadata and + // must not affect the file diff (content changes are already reflected by the hash). public bool Equals(ModFile other) => relPath == other.relPath && hash == other.hash; diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index fb891cc22..11ccbe0dc 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -31,6 +31,8 @@ class Node public string name; public string id; public string path; + public string modId; // Set on leaf file nodes: the owning mod's package id + public string relPath; // Set on leaf file nodes: the file's relative path public int depth; public Node parent; public List children = new(); @@ -80,6 +82,79 @@ public override void PostOpen() private void CheckModLists() { modListDiff = remote.CompareMods(JoinData.activeModsSnapshot); + ComputeModListStatuses(); + } + + // Per-mod diff status, keyed by (packageId, source) to match RemoteData.CompareMods. + private Dictionary<(string, ContentSource), NodeStatus> serverModStatus = new(); + private Dictionary<(string, ContentSource), NodeStatus> localModStatus = new(); + + private void ComputeModListStatuses() + { + serverModStatus.Clear(); + localModStatus.Clear(); + + var serverKeys = remote.remoteMods.Select(m => (m.packageId, m.source)).ToList(); + var localKeys = JoinData.activeModsSnapshot.Select(m => (m.PackageIdNonUnique, m.Source)).ToList(); + var serverSet = serverKeys.ToHashSet(); + var localSet = localKeys.ToHashSet(); + + // Mods present on both sides in the same relative load order. The rest of the + // common mods are considered out of order. + var inOrder = LongestCommonOrder(serverKeys, localKeys); + + // Server list only flags missing mods; wrong-order is highlighted on the client list + // only, since reordering is something the client resolves locally. + foreach (var k in serverKeys) + serverModStatus[k] = localSet.Contains(k) ? NodeStatus.None : NodeStatus.Missing; + + foreach (var k in localKeys) + localModStatus[k] = + !serverSet.Contains(k) ? NodeStatus.Added : + !inOrder.Contains(k) ? NodeStatus.Modified : + NodeStatus.None; + } + + // Longest subsequence of mods that keeps the same relative order in both lists. + // Mods are unique within a load order, so this reduces to a longest-increasing-subsequence + // over the local positions of the common mods (taken in server order). + private static HashSet LongestCommonOrder(List server, List local) + { + var localPos = new Dictionary(); + for (int i = 0; i < local.Count; i++) + localPos[local[i]] = i; + + var items = new List<(T key, int pos)>(); + foreach (var k in server) + if (localPos.TryGetValue(k, out var p)) + items.Add((k, p)); + + int n = items.Count; + var result = new HashSet(); + if (n == 0) return result; + + var dp = new int[n]; // length of the best subsequence ending at i + var prev = new int[n]; // predecessor index in that subsequence + int bestIdx = 0; + + for (int i = 0; i < n; i++) + { + dp[i] = 1; + prev[i] = -1; + for (int j = 0; j < i; j++) + if (items[j].pos < items[i].pos && dp[j] + 1 > dp[i]) + { + dp[i] = dp[j] + 1; + prev[i] = j; + } + if (dp[i] > dp[bestIdx]) + bestIdx = i; + } + + for (int i = bestIdx; i >= 0; i = prev[i]) + result.Add(items[i].key); + + return result; } private void AddNodesForPath(string path, Node root, NodeStatus status) @@ -100,6 +175,8 @@ private void AddNodesForPath(string path, Node root, NodeStatus status) } cur.status = status; + cur.relPath = path; + cur.modId = root.id; // null for the configs tree (root has no id) root.paths.Add(path); if (root.childrenPerStatus != null) @@ -368,6 +445,70 @@ private void DrawGeneralTab(Rect inRect) private const string OrangeStr = "ff8844"; private const string YellowStr = "ffff44"; + static Color StatusColor(NodeStatus status) => status switch + { + NodeStatus.Added => Orange, + NodeStatus.Missing => Red, + NodeStatus.Modified => Yellow, + _ => Color.white + }; + + // Builds the tooltip for a leaf file node: assembly/file/product versions (for .dll) and + // last-write time on each side that has the file. + private string BuildFileTip(Node n) + { + var isDll = n.relPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase); + + string Side(ModFile? file) + { + if (file == null) return null; + var f = file.Value; + + var lines = new List(); + + void VersionLine(string labelKey, string value) + { + var shown = string.IsNullOrEmpty(value) ? (string)"MpMismatchFileUnknown".Translate() : value; + lines.Add(" " + (string)labelKey.Translate() + ": " + shown); + } + + if (isDll) + { + VersionLine("MpMismatchFileAssemblyVersion", f.assemblyVersion); + VersionLine("MpMismatchFileVersion", f.fileVersion); + VersionLine("MpMismatchFileProductVersion", f.productVersion); + } + + if (f.writeTime != 0) + lines.Add(" " + (string)"MpMismatchFileModified".Translate() + ": " + FormatWriteTime(f.writeTime)); + + return lines.Any() ? string.Join("\n", lines) : null; + } + + var tip = ""; + var server = Side(remote.remoteFiles.GetOrDefault(n.modId, n.relPath)); + if (server != null) + tip += "MpMismatchServerSide".Translate() + "\n" + server; + + var yours = Side(filesForUI.GetOrDefault(n.modId, n.relPath)); + if (yours != null) + tip += (tip.Length > 0 ? "\n\n" : "") + "MpMismatchClientSide".Translate() + "\n" + yours; + + return tip; + } + + private static string FormatWriteTime(long utcTicks) + { + try + { + return new DateTime(utcTicks, DateTimeKind.Utc).ToLocalTime().ToString("yyyy-MM-dd HH:mm"); + } + catch (ArgumentOutOfRangeException) + { + return "?"; + } + } + private void DrawTreeTab(Rect inRect, string labelKey, Node root, Action refresh, bool showCounts, string desc, string treeDescKey) { var listRect = new Rect(0, 20f, 440f, 220f); @@ -433,6 +574,15 @@ private void DrawTreeTab(Rect inRect, string labelKey, Node root, Action refresh TooltipHandler.TipRegion(labelRect, () => nodeTip, 13624604); } + else if (n.modId != null && n.relPath != null) + { + var node = n; + TooltipHandler.TipRegion( + labelRect, + () => BuildFileTip(node), + Gen.HashCombineInt(node.relPath.GetHashCode(), node.modId.GetHashCode() ^ 748210) + ); + } if (Widgets.ButtonInvisible(labelRect)) { @@ -445,14 +595,7 @@ private void DrawTreeTab(Rect inRect, string labelKey, Node root, Action refresh MpUI.Label( labelRect.MinX(5 + 15f * n.depth), n.DisplayName, - color: - n.status switch - { - NodeStatus.Added => Orange, - NodeStatus.Missing => Red, - NodeStatus.Modified => Yellow, - _ => Color.white - } + color: StatusColor(n.status) ); if (showCounts && n.childrenPerStatus != null) @@ -537,6 +680,11 @@ void DrawModListItem(Vector2 topLeft, string name, string tip, ContentSource sou MpUI.Label(new Rect(topLeft + new Vector2(21, 0), new(selectorWidth - 21f - 16f, modLabelHeight + 2)), name, color: color); } + Color ServerColor(ModInfo m) => + StatusColor(serverModStatus.TryGetValue((m.packageId, m.source), out var s) ? s : NodeStatus.None); + Color LocalColor(ModMetaData m) => + StatusColor(localModStatus.TryGetValue((m.PackageIdNonUnique, m.Source), out var s) ? s : NodeStatus.None); + GUI.BeginGroup(mods1Rect); { MpUI.Label(new Rect(0, 0, selectorWidth, 20f), "MpMismatchServerMods".Translate(), GameFont.Tiny, TextAnchor.MiddleCenter); @@ -556,7 +704,7 @@ void DrawModListItem(Vector2 topLeft, string name, string tip, ContentSource sou m.name, m.packageId, m.source, - m.Installed ? Color.white : Red, + ServerColor(m), new Vector2(modScrollLeft.x, selectorHeight + modLabelHeight) ); i++; @@ -589,7 +737,7 @@ void DrawModListItem(Vector2 topLeft, string name, string tip, ContentSource sou m.Name, m.PackageIdNonUnique, m.Source, - Color.white, + LocalColor(m), new Vector2(modScrollRight.x, selectorHeight + modLabelHeight) ); i++; @@ -608,6 +756,16 @@ void DrawModListItem(Vector2 topLeft, string name, string tip, ContentSource sou GUI.BeginGroup(mods3Rect); { + Text.CurFontStyle.richText = true; + MpUI.Label( + new Rect(0, 0, btnsWidth, 66f), + $"({"MpMismatchTreeMissing".Translate()})\n" + + $"({"MpMismatchTreeAdded".Translate()})\n" + + $"({"MpMismatchModListWrongOrder".Translate()})", + GameFont.Tiny, + TextAnchor.MiddleCenter + ); + var notInstalled = remote.remoteMods.Where(m => !m.Installed); var notInstalledNotOnSteam = notInstalled.Where(m => !m.CanSubscribe); var btns = new Rect(0, 0, btnsWidth, 35f * 2 + 10f).CenterOn(new Rect(0, 0, btnsWidth, selectorHeight)); diff --git a/Source/Common/Networking/Packet/InitDataPacket.cs b/Source/Common/Networking/Packet/InitDataPacket.cs index ecec5699c..c0bb38574 100644 --- a/Source/Common/Networking/Packet/InitDataPacket.cs +++ b/Source/Common/Networking/Packet/InitDataPacket.cs @@ -108,11 +108,19 @@ public record struct ModFile : IPacketBufferable { public string path; public int hash; + public string assemblyVersion; // .dll files only, empty otherwise + public string fileVersion; // .dll files only, empty otherwise + public string productVersion; // .dll files only, empty otherwise + public long writeTime; // Last write time, UTC ticks public void Bind(PacketBuffer buf) { buf.Bind(ref path); buf.Bind(ref hash); + buf.Bind(ref assemblyVersion); + buf.Bind(ref fileVersion); + buf.Bind(ref productVersion); + buf.Bind(ref writeTime); } }