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); } }