Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions Source/Client/Networking/JoinData.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -101,19 +107,51 @@ internal static ModFileDict GetModFiles(IEnumerable<string> 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<FileInfo> GetModDefsAndPatches(ModContentPack mod)
{
foreach (var f in ModContentPack.GetAllFilesForModPreserveOrder(mod, "Defs/", f => f.ToLower() == ".xml"))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;

Expand Down
178 changes: 168 additions & 10 deletions Source/Client/Windows/JoinDataWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node> children = new();
Expand Down Expand Up @@ -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<T> LongestCommonOrder<T>(List<T> server, List<T> local)
{
var localPos = new Dictionary<T, int>();
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<T>();
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)
Expand All @@ -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)
Expand Down Expand Up @@ -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<string>();

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);
Expand Down Expand Up @@ -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))
{
Expand All @@ -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)
Expand Down Expand Up @@ -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);
Expand All @@ -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++;
Expand Down Expand Up @@ -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++;
Expand All @@ -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),
$"<color=#{RedStr}>({"MpMismatchTreeMissing".Translate()})</color>\n" +
$"<color=#{OrangeStr}>({"MpMismatchTreeAdded".Translate()})</color>\n" +
$"<color=#{YellowStr}>({"MpMismatchModListWrongOrder".Translate()})</color>",
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));
Expand Down
8 changes: 8 additions & 0 deletions Source/Common/Networking/Packet/InitDataPacket.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Loading