From a928704e1943d8734b19dabddbcf6d759289a94e Mon Sep 17 00:00:00 2001 From: Michael <5672750+mibac138@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:51:13 +0200 Subject: [PATCH 01/51] Add debug action to show mod compat when playing (#896) * Add debug action to show mod compat when playing Requested by a mod tester * Allow clicking a mod name in mod compat dialog to open the mod's workshop page --- Source/Client/Debug/DebugActions.cs | 7 +++++++ Source/Client/Windows/ModCompatWindow.cs | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Source/Client/Debug/DebugActions.cs b/Source/Client/Debug/DebugActions.cs index 9bc4f5121..46d05147b 100644 --- a/Source/Client/Debug/DebugActions.cs +++ b/Source/Client/Debug/DebugActions.cs @@ -373,6 +373,13 @@ public static void DumpAddrInfoTable() } } + [DebugAction(MultiplayerLocalCategory, "Show mod compat", allowedGameStates = AllowedGameStates.Playing)] + public static void ShowModCompatDialog() + { + var window = new ModCompatWindow(null, true, false, null); + Find.WindowStack.Add(window); + } + #if DEBUG [DebugOutput] diff --git a/Source/Client/Windows/ModCompatWindow.cs b/Source/Client/Windows/ModCompatWindow.cs index 656aff6a2..af155406e 100644 --- a/Source/Client/Windows/ModCompatWindow.cs +++ b/Source/Client/Windows/ModCompatWindow.cs @@ -266,8 +266,13 @@ private void DoModRow(ModMetaData mod, bool alt, Rect row) // Name { + var labelRect = row.Width(NameWidth - Spacing - ListInset); using (MpStyle.Set(nameContainsSearch ? Color.white : Color.grey)) - MpUI.LabelTruncatedWithTip(row.Width(NameWidth - Spacing - ListInset), modName, modNameCache); + MpUI.LabelTruncatedWithTip(labelRect, modName, modNameCache); + + if (Widgets.ButtonInvisible(labelRect) && mod.Source == ContentSource.SteamWorkshop) + SteamUtility.OpenWorkshopPage(mod.GetPublishedFileId()); + row.xMin += NameWidth - ListInset; } From e743d1f629e0fb7dfa3c64a65deb88a0912920f3 Mon Sep 17 00:00:00 2001 From: Michael <5672750+mibac138@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:33:56 +0200 Subject: [PATCH 02/51] Send selected info only to other players (#902) To avoid a player having their own selection highlighted as if it was another player's selection --- Source/Common/Networking/State/ServerPlayingState.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index c6d22e298..cba3f2380 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -116,7 +116,7 @@ public void HandleCursor(ClientCursorPacket clientPacket) [TypedPacketHandler] public void HandleSelected(ClientSelectedPacket packet) => - Server.SendToPlaying(new ServerSelectedPacket(Player.id, packet)); + Server.SendToPlaying(new ServerSelectedPacket(Player.id, packet), excluding: Player); [TypedPacketHandler] public void HandlePing(ClientPingLocPacket packet) => From 9924e043d640bea4523f88ec4650576941a56c5b Mon Sep 17 00:00:00 2001 From: Michael <5672750+mibac138@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:34:27 +0200 Subject: [PATCH 03/51] Show mod compat scores in the main mod list dialog (#901) Shows XML mods as a 4 to save on the limited space and avoid needlessly complicating the logic --- Source/Client/ModCompatibilityManager.cs | 22 +++++ Source/Client/Windows/ModCompatWindow.cs | 102 ++++++++++++++++++----- 2 files changed, 103 insertions(+), 21 deletions(-) diff --git a/Source/Client/ModCompatibilityManager.cs b/Source/Client/ModCompatibilityManager.cs index e7c3d409b..fd6072463 100644 --- a/Source/Client/ModCompatibilityManager.cs +++ b/Source/Client/ModCompatibilityManager.cs @@ -10,6 +10,7 @@ using Multiplayer.Common; using RestSharp; using Steamworks; +using UnityEngine; using Verse; namespace Multiplayer.Client @@ -165,6 +166,9 @@ private static void SetupFrom(List mods) .ToDictionary(grouping => grouping.Key, grouping => grouping.First()); } + public static ModCompatibility? LookupByMod(ModMetaData meta) => + LookupByWorkshopId(meta.GetPublishedFileId()) ?? LookupByName(meta.Name); + public static ModCompatibility? LookupByWorkshopId(PublishedFileId_t workshopId) => LookupByWorkshopId(workshopId.m_PublishedFileId); @@ -188,5 +192,23 @@ public class ModCompatibility public string name { get; set; } public long workshopId { get; set; } public string notes { get; set; } = ""; + + public static Color ScoreColor(int score) => score switch + { + 1 => ColorLibrary.Red, + 2 => ColorLibrary.Orange, + 3 => ColorLibrary.Yellow, + 4 => ColorLibrary.Green, + _ => ColorLibrary.Grey + }; + + public static string ScoreDescription(int score) => score switch + { + 1 => "MpModCompatScore1", + 2 => "MpModCompatScore2", + 3 => "MpModCompatScore3", + 4 => "MpModCompatScore4", + _ => "MpModCompatScoreUnk" + }; } } diff --git a/Source/Client/Windows/ModCompatWindow.cs b/Source/Client/Windows/ModCompatWindow.cs index af155406e..f1f589158 100644 --- a/Source/Client/Windows/ModCompatWindow.cs +++ b/Source/Client/Windows/ModCompatWindow.cs @@ -40,7 +40,7 @@ public ModCompatWindow(Window parent, bool popup, bool forceNameSort, Func modNameCache = new(); private Dictionary notesCache = new(); @@ -75,6 +76,12 @@ public override void SetInitialSizeAndPosition() const float ScrollbarWidth = 20f; const float ListInset = 15f; + public void ScrollToModName(string modName) + { + nameFieldStr = modName; + scrollToModName = true; + } + public override void DoWindowContents(Rect inRect) { if (modsHash != ModLister.InstalledModsListHash(true)) @@ -128,6 +135,11 @@ ref Multiplayer.settings.hideTranslationMods GUI.SetNextControlName("mod_search"); nameFieldStr = Widgets.TextField(nameField, nameFieldStr); nameFieldChanged = nameFieldStr != prevNameField; + if (scrollToModName) + { + nameFieldChanged = true; + scrollToModName = false; + } inRect.yMin += CheckboxesHeight + 10f; GUI.BeginGroup(inRect); @@ -249,7 +261,7 @@ private void DoHeaders(float width) // Notes header var notesHeader = headerRow.MaxX(headerRow.width - ScrollbarWidth); - Widgets.Label(notesHeader, $"MpModCompatHeaderNotes".Translate()); + Widgets.Label(notesHeader, "MpModCompatHeaderNotes".Translate()); Widgets.DrawHighlightIfMouseover(notesHeader); } @@ -280,23 +292,10 @@ private void DoModRow(ModMetaData mod, bool alt, Rect row) { bool xml = MultiplayerData.IsXmlMod(mod); - var scoreColor = xml ? ColorLibrary.Green : info?.status switch - { - 1 => ColorLibrary.Red, - 2 => ColorLibrary.Orange, - 3 => ColorLibrary.Yellow, - 4 => ColorLibrary.Green, - _ => ColorLibrary.Grey - }; - - var scoreDescKey = xml ? "MpModCompatXmlOnlyDesc" : info?.status switch - { - 1 => "MpModCompatScore1", - 2 => "MpModCompatScore2", - 3 => "MpModCompatScore3", - 4 => "MpModCompatScore4", - _ => "MpModCompatScoreUnk" - }; + var scoreColor = xml ? ColorLibrary.Green : ModCompatibility.ScoreColor(info?.status ?? 0); + + var scoreDescKey = + xml ? "MpModCompatXmlOnlyDesc" : ModCompatibility.ScoreDescription(info?.status ?? 0); var scoreText = xml ? "XML" @@ -373,8 +372,7 @@ private static ModCompatibility TryGetCompatInfo(ModMetaData mod) if (!Multiplayer.settings.showModCompatibility) return null; - return ModCompatibilityManager.LookupByWorkshopId(mod.publishedFileIdInt) ?? - ModCompatibilityManager.LookupByName(mod.Name); + return ModCompatibilityManager.LookupByMod(mod); } private static string SortChar(SortDirection dir) => dir switch @@ -407,6 +405,68 @@ static IEnumerable Transpiler(IEnumerable inst } } + [HarmonyPatch(typeof(Page_ModsConfig), nameof(Page_ModsConfig.DoModRow))] + static class PageModsConfigShowModCompat + { + static IEnumerable Transpiler(IEnumerable insts) + { + var labelMethod = + AccessTools.Method(typeof(Widgets), nameof(Widgets.Label), [typeof(Rect), typeof(string)]); + foreach (var inst in insts) + { + if (inst.Calls(labelMethod)) + { + yield return CodeInstruction.LoadArgument(2); // ModMetaData + yield return CodeInstruction.LoadArgument(0); // Page_ModsConfig + inst.operand = AccessTools.Method(typeof(PageModsConfigShowModCompat), nameof(DrawModLabel)); + } + + yield return inst; + } + } + + public static void DrawModLabel(Rect r, string label, ModMetaData mod, Page_ModsConfig parent) + { + if (!Multiplayer.settings.showModCompatibility) + { + Widgets.Label(r, label); + return; + } + + var compat = ModCompatibilityManager.LookupByMod(mod); + var rect = new Rect(r.x, (float) (r.y + r.height / 2.0 - 12.0), 20f, 24f); + Text.Anchor = TextAnchor.MiddleCenter; + + bool xml = MultiplayerData.IsXmlMod(mod); + var score = xml ? 4 : compat?.status ?? 0; + var scoreColor = ModCompatibility.ScoreColor(score); + Widgets.Label(rect, "[" + score.ToString().Colorize(scoreColor) + "]"); + + if (Mouse.IsOver(rect)) + { + var scoreText = (xml ? "MpModCompatXmlOnlyDesc" : ModCompatibility.ScoreDescription(score)).Translate(); + if (compat?.notes is { Length: > 0 }) + { + scoreText += $"\n{compat.notes}"; + } + + TooltipHandler.TipRegion(rect, () => scoreText, + (int)(r.x + r.y * 56167.0)); + Widgets.DrawHighlight(rect); + } + + if (Widgets.ButtonInvisible(rect)) + { + var modCompatWindow = new ModCompatWindow(parent, true, false, null); + modCompatWindow.ScrollToModName(mod.Name); + Find.WindowStack.Add(modCompatWindow); + } + + Text.Anchor = TextAnchor.MiddleLeft; + Widgets.Label(r.Right(rect.width + 4f), label); + } + } + [HarmonyPatch(typeof(Page_ModsConfig), nameof(Page_ModsConfig.DoBottomButtons))] static class PageModsConfigAddButton From c58202ad9b633b8ab0b1ca7fae6078ab8af1f827 Mon Sep 17 00:00:00 2001 From: Sakura-TA <52643135+Sakura-TA@users.noreply.github.com> Date: Sat, 9 May 2026 03:44:01 +0800 Subject: [PATCH 04/51] Fix/rescue pawn (#907) * Use TargetMethods on the check to prevent missing patch --------- Co-authored-by: Sakura-TA --- Source/Client/Factions/MultifactionPatches.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index 3e70605c0..8c25c175e 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -616,10 +616,14 @@ static void Postfix(IAttackTarget target, ref bool __result) } } -[HarmonyPatch(typeof(JobDriver_TakeToBed), nameof(JobDriver_TakeToBed.CheckMakeTakeeGuest))] -[HarmonyPatch(typeof(JobDriver_CarryDownedPawn), nameof(JobDriver_CarryDownedPawn.CheckMakeTakeeGuest))] +[HarmonyPatch] static class TakeToBedGuestFactionPatch { + static IEnumerable TargetMethods() + { + yield return AccessTools.DeclaredMethod(typeof(JobDriver_TakeToBed), nameof(JobDriver_TakeToBed.CheckMakeTakeeGuest)); + yield return AccessTools.DeclaredMethod(typeof(JobDriver_CarryDownedPawn), nameof(JobDriver_CarryDownedPawn.CheckMakeTakeeGuest)); + } static bool Prefix(JobDriver __instance) { var takee = __instance.job.GetTarget(TargetIndex.A).Pawn; @@ -627,6 +631,7 @@ static bool Prefix(JobDriver __instance) } } + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] static class LetterStackReceiveOnlyMyFaction { From 25eea46b1f225736e1ceb49169d7f0c37a5ef567 Mon Sep 17 00:00:00 2001 From: Sakura-TA <52643135+Sakura-TA@users.noreply.github.com> Date: Sat, 9 May 2026 14:19:02 +0800 Subject: [PATCH 05/51] Replace stacked harmony patch atttribute with TargetMethods (#909) Co-authored-by: Sakura-TA --- Source/Client/Patches/AreaSource.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Source/Client/Patches/AreaSource.cs b/Source/Client/Patches/AreaSource.cs index 3644e5986..0c278f6b8 100644 --- a/Source/Client/Patches/AreaSource.cs +++ b/Source/Client/Patches/AreaSource.cs @@ -2,26 +2,27 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Text; using System.Threading.Tasks; using Verse; namespace Multiplayer.Client.Patches { - [HarmonyPatch(typeof(AreaSource))] + [HarmonyPatch] static class AreaSource_Patch { - [HarmonyPatch(nameof(AreaSource.ComputeAll))] - [HarmonyPatch(nameof(AreaSource.UpdateIncrementally))] + static IEnumerable TargetMethods() + { + yield return AccessTools.DeclaredMethod(typeof(AreaSource), nameof(AreaSource.ComputeAll)); + yield return AccessTools.DeclaredMethod(typeof(AreaSource), nameof(AreaSource.UpdateIncrementally)); + } static void Prefix(AreaSource __instance, ref AreaManager __state) { if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) return; __state = __instance.map.areaManager; __instance.map.areaManager = __instance.map.MpComp().AllAreaManager(); } - - [HarmonyPatch(nameof(AreaSource.ComputeAll))] - [HarmonyPatch(nameof(AreaSource.UpdateIncrementally))] static void Finalizer(AreaSource __instance, AreaManager __state) { if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) return; From b8bf25cc283a611c6ad16b99a669590daee23067 Mon Sep 17 00:00:00 2001 From: Michael <5672750+mibac138@users.noreply.github.com> Date: Sun, 10 May 2026 06:57:45 +0200 Subject: [PATCH 06/51] Add custom disconnection dialog when version mismatch is a likely cause (#908) --- Source/Client/OnMainThread.cs | 10 +++++----- Source/Client/Session/SessionDisconnectInfo.cs | 15 +++++++++++++++ Source/Common/Networking/ConnectionBase.cs | 2 +- Source/Common/Networking/PacketReadException.cs | 2 ++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Source/Client/OnMainThread.cs b/Source/Client/OnMainThread.cs index 3230718d4..866c50248 100644 --- a/Source/Client/OnMainThread.cs +++ b/Source/Client/OnMainThread.cs @@ -1,10 +1,10 @@ -using Multiplayer.Client.DebugUi; -using Multiplayer.Client.Networking; -using Multiplayer.Common; using System; using System.Collections.Generic; +using Multiplayer.Client.DebugUi; using Multiplayer.Client.Desyncs; +using Multiplayer.Client.Networking; using Multiplayer.Client.Util; +using Multiplayer.Common; using UnityEngine; using Verse; using Verse.Steam; @@ -53,8 +53,8 @@ public void Update() { Log.Error($"Exception handling packet by {conn}: {e}"); - ConnectionStatusListeners.TryNotifyAll_Disconnected(new SessionDisconnectInfo - { titleTranslated = "MpPacketErrorLocal".Translate() }); + ConnectionStatusListeners.TryNotifyAll_Disconnected( + SessionDisconnectInfo.FromLocalPacketReadException(e)); Multiplayer.StopMultiplayer(); } } diff --git a/Source/Client/Session/SessionDisconnectInfo.cs b/Source/Client/Session/SessionDisconnectInfo.cs index ce20290cb..f31eff056 100644 --- a/Source/Client/Session/SessionDisconnectInfo.cs +++ b/Source/Client/Session/SessionDisconnectInfo.cs @@ -115,4 +115,19 @@ public static SessionDisconnectInfo From(MpDisconnectReason reason, ByteReader r return disconnectInfo; } + + public static SessionDisconnectInfo FromLocalPacketReadException(Exception e) + { + var disconnectInfo = new SessionDisconnectInfo + { titleTranslated = "MpPacketErrorLocal".Translate() }; + + if (e is PacketBadIdException) + { + disconnectInfo.descTranslated = "MpPacketErrorLocalBadId".Translate(); + disconnectInfo.descTranslated += '\n' + "MpWrongVersionUpdateInfo".Translate(); + disconnectInfo.wideWindow = true; + } + + return disconnectInfo; + } } diff --git a/Source/Common/Networking/ConnectionBase.cs b/Source/Common/Networking/ConnectionBase.cs index 9499534c0..14ab1b872 100644 --- a/Source/Common/Networking/ConnectionBase.cs +++ b/Source/Common/Networking/ConnectionBase.cs @@ -166,7 +166,7 @@ public virtual void HandleReceiveRaw(ByteReader data, bool reliable) protected virtual void HandleReceiveMsg(int msgId, int fragState, ByteReader reader, bool reliable) { if (msgId is < 0 or >= (int)Packets.Count) - throw new PacketReadException($"Bad packet id {msgId}"); + throw new PacketBadIdException(msgId); Packets packetType = (Packets)msgId; if (reader.Left > MaxSinglePacketSize) diff --git a/Source/Common/Networking/PacketReadException.cs b/Source/Common/Networking/PacketReadException.cs index 94ff4b7da..4f644ce3a 100644 --- a/Source/Common/Networking/PacketReadException.cs +++ b/Source/Common/Networking/PacketReadException.cs @@ -12,4 +12,6 @@ public PacketReadException(string message, Exception innerException) : base(mess { } } + + public class PacketBadIdException(int id) : PacketReadException($"Bad packet id: {id}"); } From 10552a06591250f98d6d6daf22c616150e7e88b9 Mon Sep 17 00:00:00 2001 From: Michael <5672750+mibac138@users.noreply.github.com> Date: Sun, 10 May 2026 06:59:47 +0200 Subject: [PATCH 07/51] Detect registering the same SyncMethod multiple times (#911) * Detect registering the same SyncMethod multiple times * Fix broken Hediff_Pregnant sync methods * Remove duplicate SyncMethods - RenamableLabel is handled at the end of SyncMethods for all IRenamables - GameComponent_PsychicRitualManager.ClearAllCooldowns is handled in SyncDelegates near other Anomaly stuff --- Source/Client/Syncing/Game/SyncDelegates.cs | 4 ++-- Source/Client/Syncing/Game/SyncMethods.cs | 5 +---- Source/Client/Syncing/Sync.cs | 10 ++++++++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index 1ee71d206..202d5bfff 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -257,8 +257,8 @@ public static void Init() SyncMethod.Lambda(typeof(Hediff_LaborPushing), nameof(Hediff_LaborPushing.GetGizmos), 1).SetDebugOnly(); // Force infant illness SyncMethod.Lambda(typeof(Hediff_LaborPushing), nameof(Hediff_LaborPushing.GetGizmos), 2).SetDebugOnly(); // Force healthy SyncMethod.Lambda(typeof(Hediff_LaborPushing), nameof(Hediff_LaborPushing.GetGizmos), 3).SetDebugOnly(); // Force end - SyncMethod.Lambda(typeof(Hediff_LaborPushing), nameof(Hediff_Pregnant.GetGizmos), 0).SetDebugOnly(); // Next trimester - SyncMethod.Lambda(typeof(Hediff_LaborPushing), nameof(Hediff_Pregnant.GetGizmos), 1).SetDebugOnly(); // Start labor + SyncMethod.Lambda(typeof(Hediff_Pregnant), nameof(Hediff_Pregnant.GetGizmos), 0).SetDebugOnly(); // Next trimester + SyncMethod.Lambda(typeof(Hediff_Pregnant), nameof(Hediff_Pregnant.GetGizmos), 1).SetDebugOnly(); // Start labor SyncDelegate.Lambda(typeof(Hediff_MetalhorrorImplant), nameof(Hediff_MetalhorrorImplant.GetGizmos), 0).SetDebugOnly(); // Emerge SyncDelegate.Lambda(typeof(Hediff_MetalhorrorImplant), nameof(Hediff_MetalhorrorImplant.GetGizmos), 1).SetDebugOnly(); // Mark for flesh drop SyncDelegate.Lambda(typeof(Hediff_MetalhorrorImplant), nameof(Hediff_MetalhorrorImplant.GetGizmos), 2).SetDebugOnly(); // Discover next interaction diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index a3d5fbd9a..1b04e7881 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -28,7 +28,6 @@ public static void Init() SyncMethod.Register(typeof(Pawn_OutfitTracker), nameof(Pawn_OutfitTracker.CurrentApparelPolicy)).CancelIfAnyArgNull(); SyncMethod.Register(typeof(Pawn_FoodRestrictionTracker), nameof(Pawn_FoodRestrictionTracker.CurrentFoodPolicy)).CancelIfAnyArgNull(); SyncMethod.Register(typeof(Pawn_ReadingTracker), nameof(Pawn_ReadingTracker.CurrentPolicy)).CancelIfAnyArgNull(); - SyncMethod.Register(typeof(Policy), nameof(Policy.RenamableLabel)); SyncMethod.Register(typeof(Pawn_PlayerSettings), nameof(Pawn_PlayerSettings.AreaRestrictionInPawnCurrentMap)); SyncMethod.Register(typeof(Pawn_PlayerSettings), nameof(Pawn_PlayerSettings.Master)); SyncMethod.Register(typeof(Pawn), nameof(Pawn.Name)).ExposeParameter(0) @@ -54,7 +53,6 @@ public static void Init() SyncMethod.Register(typeof(Building_TurretGun), nameof(Building_TurretGun.ExtractShell)); SyncMethod.Register(typeof(Area), nameof(Area.Invert)); SyncMethod.Register(typeof(Area), nameof(Area.Delete)); - SyncMethod.Register(typeof(Area_Allowed), nameof(Area_Allowed.RenamableLabel)); SyncMethod.Register(typeof(AreaManager), nameof(AreaManager.TryMakeNewAllowed)); SyncMethod.Register(typeof(MainTabWindow_Research), nameof(MainTabWindow_Research.DoBeginResearch)) .TransformTarget(Serializer.SimpleReader(() => new MainTabWindow_Research())); @@ -295,7 +293,6 @@ public static void Init() SyncMethod.Register(typeof(CompPowerBattery), nameof(CompPowerBattery.SetStoredEnergyPct)).SetDebugOnly(); // Set battery to 0/100% SyncMethod.Lambda(typeof(CompPowerTrader), nameof(CompPowerTrader.CompGetGizmosExtra), 0).SetDebugOnly(); // Toggle power on/off SyncMethod.Lambda(typeof(CompProximityFuse), nameof(CompProximityFuse.CompGetGizmosExtra), 0).SetDebugOnly(); // Trigger - SyncMethod.Register(typeof(GameComponent_PsychicRitualManager), nameof(GameComponent_PsychicRitualManager.ClearAllCooldowns)).SetDebugOnly(); SyncMethod.Lambda(typeof(CompRevenant), nameof(CompRevenant.CompGetGizmosExtra), 0).SetDebugOnly(); // Reset hypnosis cooldown SyncMethod.Lambda(typeof(CompRevenant), nameof(CompRevenant.CompGetGizmosExtra), 1).SetDebugOnly(); // Change to wander mode SyncMethod.Lambda(typeof(CompRevenant), nameof(CompRevenant.CompGetGizmosExtra), 2).SetDebugOnly(); // Change to sleep mode @@ -926,7 +923,7 @@ static IEnumerable Transpiler(IEnumerable inst // Seems can't sync Action & Predicate so have to deduct params // This is enough for bookcase to use but needs update for new situation if needed. - + static bool SyncBookcaseTryDrop(Building_Bookcase bookcase, Thing thing, IntVec3 dropLoc, Map map, ThingPlaceMode mode, int count, out Thing resultingThing, Action placedAction = null, Predicate nearPlaceValidator = null) { return DoSyncBookcaseTryDrop(bookcase, thing, dropLoc, map, mode, count, out resultingThing); diff --git a/Source/Client/Syncing/Sync.cs b/Source/Client/Syncing/Sync.cs index 3ec9558ca..678830aa1 100644 --- a/Source/Client/Syncing/Sync.cs +++ b/Source/Client/Syncing/Sync.cs @@ -256,10 +256,16 @@ static void RegisterSyncField(FieldInfo field, SyncFieldAttribute attribute) public static SyncMethod RegisterSyncMethod(MethodInfo method, SyncType[] argTypes = null) { + if (methodBaseToInternalId.TryGetValue(method, out var id)) + { + Log.Error($"Error in {method.DeclaringType?.FullName}::{method}: Method is already registered as a SyncMethod."); + return (SyncMethod)internalIdToSyncMethod[id]; + } + MpUtil.MarkNoInlining(method); - SyncMethod handler = new SyncMethod(method.IsStatic ? null : method.DeclaringType, null, method, argTypes); - methodBaseToInternalId[handler.method] = internalIdToSyncMethod.Count; + var handler = new SyncMethod(method.IsStatic ? null : method.DeclaringType, null, method, argTypes); + methodBaseToInternalId[method] = internalIdToSyncMethod.Count; internalIdToSyncMethod.Add(handler); handlers.Add(handler); From c9157980d153543065decbb245f5548ae52a25e1 Mon Sep 17 00:00:00 2001 From: Michael <5672750+mibac138@users.noreply.github.com> Date: Sun, 10 May 2026 07:00:41 +0200 Subject: [PATCH 08/51] Fix: Bootstrap mode should be enabled when data is missing (#912) ..not when it's present --- Source/Server/Server.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index 155c57ca1..9e391d27c 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -36,7 +36,7 @@ { running = true, IsStandaloneServer = true, - BootstrapMode = settingsPresent && savePresent, + BootstrapMode = !settingsPresent || !savePresent, }; if (!server.BootstrapMode) From 8e5bbf576d2d1dc48161ee89bd02d6df8dca3055 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 05:35:30 -0500 Subject: [PATCH 09/51] Bump Languages from `8bfffb7` to `a8513b0` (#916) Bumps [Languages](https://github.com/rwmt/Multiplayer-Locale) from `8bfffb7` to `a8513b0`. - [Commits](https://github.com/rwmt/Multiplayer-Locale/compare/8bfffb77a14dbe77c4240fad7291ed2d32dc3572...a8513b0bc1ff212751c498e643241beeca23b953) --- updated-dependencies: - dependency-name: Languages dependency-version: a8513b0bc1ff212751c498e643241beeca23b953 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Languages | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Languages b/Languages index 8bfffb77a..a8513b0bc 160000 --- a/Languages +++ b/Languages @@ -1 +1 @@ -Subproject commit 8bfffb77a14dbe77c4240fad7291ed2d32dc3572 +Subproject commit a8513b0bc1ff212751c498e643241beeca23b953 From fc92a16197c133e3d984ad9929a104fc8deb7780 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Mon, 11 May 2026 22:48:59 +0200 Subject: [PATCH 10/51] Add standalone server zip to continuous release (#915) * ci: add standalone server zip to continuous release * Potential fix for pull request finding Avoid double build Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * ci: add standalone server download and bootstrap instructions to release notes --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/alpha-notes.md | 19 ++++++++++++++++++- .github/workflows/build-beta.yml | 6 +++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/alpha-notes.md b/.github/workflows/alpha-notes.md index a75332c6e..14edb45e4 100644 --- a/.github/workflows/alpha-notes.md +++ b/.github/workflows/alpha-notes.md @@ -12,4 +12,21 @@ - Linux: `~/.steam/steam/steamapps/common/RimWorld/Mods` 3. Extract the zip file into the `Mods` folder. * You should have a `Multiplayer` folder in the `Mods` folder (`Mods/Multiplayer`) - * Make sure you do not have this directory structure: `Mods/Multiplayer-beta/Multiplayer`. If you do, move the `Multiplayer` folder to the parent directory. \ No newline at end of file + * Make sure you do not have this directory structure: `Mods/Multiplayer-beta/Multiplayer`. If you do, move the `Multiplayer` folder to the parent directory. + +--- + +#### Standalone server + +Download `Server-beta.zip` if you want to host a dedicated standalone server for testing. + +**Setup** +1. Download and extract `Server-beta.zip`. +2. Run `Server.exe` (Windows) or `dotnet Server.dll` (Linux/Mac) from the extracted folder. +3. The server will start and wait for the first connection. + +**First-time configuration (bootstrap)** +No manual configuration files are required. +The **first player to connect** via the Multiplayer mod client will be prompted to perform the initial setup: +they can configure the game world, scenario, and other options directly from within RimWorld. +Subsequent players connect to the already-running session. \ No newline at end of file diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 1f940ae1a..548bd8411 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -34,6 +34,9 @@ jobs: - name: Build Mod run: dotnet build ${{ env.SLN_PATH }} --configuration Release --no-restore + - name: Publish Server + run: dotnet publish ${{ env.SLN_PATH }}Server/Server.csproj --configuration Release --no-restore --no-build -o output/Server + - name: Package files run: | sed -i "s/\(.*\)<\/name>\$/\1 [Continuous]<\/name>/" About/About.xml @@ -41,6 +44,7 @@ jobs: mv About/ Assemblies/ AssembliesCustom/ Defs/ Languages/ Textures/ output/Multiplayer cd output/ zip -r ../Multiplayer-beta.zip Multiplayer/ + zip -r ../Server-beta.zip Server/ cd ../ - name: Upload Mod Artifacts @@ -57,5 +61,5 @@ jobs: - name: Upload new release run: | gh release create --target "${{ github.sha }}" --title "Continuous" --notes-file ".github/workflows/alpha-notes.md" --draft "continuous" - gh release upload "continuous" Multiplayer-beta.zip + gh release upload "continuous" Multiplayer-beta.zip Server-beta.zip gh release edit "continuous" --draft=false From 0dea7551d1b55f049c852aa8456be2e2a685f7c8 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Tue, 12 May 2026 18:21:50 +0200 Subject: [PATCH 11/51] Standalone save & persistence improvements (#874) * Split standalone save trigger foundation * Unify standalone save path and add Days autosave support * Add standalone snapshot persistence * Add safe defaults to snapshot state structs * Use File.Replace in SaveGameToFile_Overwrite and eliminate double snapshot * Enforce standalone async time and control fixes * Reset MultiplayerServer.instance in TearDown * Use int.TryParse in SeedFromSaveZip * Clean up standalone prepublish maintenance * Correct misleading blocked log text in designator patches * Pass sourcePlayer to standalone join point creation for IssuedBySelf gate * Fix: restore hosted SendGameData guard, remove redundant ofPlayer assignment - CreateJoinPointAndSendIfHost: restore LocalServer/arbiter guard for hosted mode so only host/arbiter uploads world data (was accidentally ungated) - Standalone path kept separate with ConnectedToStandaloneServer gate - Remove redundant ofPlayer assignment in ChangeRealPlayerFaction (FactionContext.Set already does the same thing on the next line) * Fix test failures: handle KeepAlive in test states and disable auto join point - Add no-op HandleKeepAlive to TestJoiningState and TestLoadingKeepAliveState to prevent crash when server sends KeepAlive during async handshake - Disable autoJoinPoint in test server settings since the test server has no game simulation to process CreateJoinPoint commands * Update Source/Common/WorldData.cs Co-authored-by: Michael <5672750+mibac138@users.noreply.github.com> * Restrict world-travel join point trigger to streaming mode * Remove unrelated client-side changes from PR scope * Fix standalone join point source and trim snapshot metadata * Remove unused JoinPointRequestReason.Unknown; remove unnecessary isStandaloneServer reset in Stop() * Fix post-rebase build errors: add missing using, toml preview fields, Tab.Preview * Align bootstrap configurator with upstream state handling * Apply bootstrap settings upload review suggestion --------- Co-authored-by: Michael <5672750+mibac138@users.noreply.github.com> --- .gitignore | 1 + Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 21 +- Source/Client/ConstantTicker.cs | 34 ++ Source/Client/MultiplayerGame.cs | 2 - .../Networking/State/ClientJoiningState.cs | 4 + Source/Client/Patches/TickPatch.cs | 1 + Source/Client/Patches/VTRSyncPatch.cs | 6 + Source/Client/Saving/SaveLoad.cs | 56 +++ Source/Client/Session/Autosaving.cs | 43 ++- Source/Client/Session/MultiplayerSession.cs | 5 + ...otstrapConfiguratorWindow.BootstrapFlow.cs | 11 +- .../BootstrapConfiguratorWindow.SettingsUi.cs | 12 +- .../Windows/BootstrapConfiguratorWindow.cs | 6 +- Source/Client/Windows/SaveGameWindow.cs | 10 +- Source/Common/ChatCommands.cs | 2 +- Source/Common/JoinPointRequestReason.cs | 7 + Source/Common/MultiplayerServer.cs | 1 + .../Networking/Packet/AutosavingPacket.cs | 12 + .../Networking/Packet/ProtocolPacket.cs | 8 +- .../Packet/StandaloneSnapshotPackets.cs | 35 ++ Source/Common/Networking/Packets.cs | 2 + .../Networking/State/ServerJoiningState.cs | 14 +- .../Networking/State/ServerPlayingState.cs | 71 +++- Source/Common/PlayerManager.cs | 5 +- Source/Common/ServerSettings.cs | 6 + Source/Common/StandalonePersistence.cs | 320 ++++++++++++++++++ Source/Common/WorldData.cs | 140 +++++++- Source/Server/Server.cs | 131 +++---- Source/Tests/Helper/TestJoiningState.cs | 3 + .../Tests/Helper/TestLoadingKeepAliveState.cs | 3 + Source/Tests/PacketTest.cs | 4 +- Source/Tests/ServerTest.cs | 3 +- Source/Tests/StandalonePersistenceTest.cs | 104 ++++++ .../ServerProtocolOkPacket.verified.txt | 4 +- 34 files changed, 955 insertions(+), 132 deletions(-) create mode 100644 Source/Common/JoinPointRequestReason.cs create mode 100644 Source/Common/Networking/Packet/AutosavingPacket.cs create mode 100644 Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs create mode 100644 Source/Common/StandalonePersistence.cs create mode 100644 Source/Tests/StandalonePersistenceTest.cs diff --git a/.gitignore b/.gitignore index 42c5123e7..066bc4f10 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ ipch/ *.opensdf *.sdf *.cachefile +*.lscache *.VC.db *.VC.VC.opendb diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 7db59bb68..b749e2c6e 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -216,6 +216,9 @@ public void ExecuteCmd(ScheduledCommand cmd) if (cmdType == CommandType.CreateJoinPoint) { + if (Multiplayer.session?.ConnectedToStandaloneServer == true && !TickPatch.currentExecutingCmdIssuedBySelf) + return; + LongEventHandler.QueueLongEvent(CreateJoinPointAndSendIfHost, "MpCreatingJoinPoint", false, null); } @@ -275,9 +278,21 @@ private static void CreateJoinPointAndSendIfHost() { Multiplayer.session.dataSnapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveAndReload(), Multiplayer.GameComp.multifaction); - if (!TickPatch.Simulating && !Multiplayer.IsReplay && - (Multiplayer.LocalServer != null || Multiplayer.arbiterInstance)) - SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true); + if (!TickPatch.Simulating && !Multiplayer.IsReplay) + { + if (Multiplayer.session?.ConnectedToStandaloneServer == true) + { + // Standalone: every client uploads world data + individual snapshots + SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true); + SaveLoad.SendStandaloneMapSnapshots(Multiplayer.session.dataSnapshot); + SaveLoad.SendStandaloneWorldSnapshot(Multiplayer.session.dataSnapshot); + } + else if (Multiplayer.LocalServer != null || Multiplayer.arbiterInstance) + { + // Hosted: only host/arbiter uploads world data + SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true); + } + } } public void SetTimeEverywhere(TimeSpeed speed) diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs index aa3bbb499..00b6f249b 100644 --- a/Source/Client/ConstantTicker.cs +++ b/Source/Client/ConstantTicker.cs @@ -47,6 +47,40 @@ private static void TickNonSimulation() private static void TickAutosave() { + // When connected to a remote standalone server, the client drives + // the autosave timer using the interval received at connection time + // (from the server's TOML settings via ServerProtocolOkPacket). + if (Multiplayer.session?.ConnectedToStandaloneServer == true) + { + var session = Multiplayer.session; + if (session.autosaveInterval <= 0) + return; + + if (session.autosaveUnit == AutosaveUnit.Minutes) + { + session.autosaveCounter++; + + if (session.autosaveCounter > session.autosaveInterval * TicksPerMinute) + { + session.autosaveCounter = 0; + Autosaving.DoAutosave(); + } + } + else if (session.autosaveUnit == AutosaveUnit.Days) + { + var anyMapCounterUp = + Multiplayer.game.mapComps + .Any(m => m.autosaveCounter > session.autosaveInterval * TicksPerIngameDay); + + if (anyMapCounterUp) + { + Multiplayer.game.mapComps.Do(m => m.autosaveCounter = 0); + Autosaving.DoAutosave(); + } + } + return; + } + if (Multiplayer.LocalServer is not { } server) return; if (server.settings.autosaveUnit == AutosaveUnit.Minutes) diff --git a/Source/Client/MultiplayerGame.cs b/Source/Client/MultiplayerGame.cs index c75d499a4..ae9f3431d 100644 --- a/Source/Client/MultiplayerGame.cs +++ b/Source/Client/MultiplayerGame.cs @@ -123,8 +123,6 @@ public void ChangeRealPlayerFaction(int newFaction) public void ChangeRealPlayerFaction(Faction newFaction, bool regenMapDrawers = true) { - Log.Message($"Changing real player faction to {newFaction} from {myFaction}"); - myFaction = newFaction; FactionContext.Set(newFaction); worldComp.SetFaction(newFaction); diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index d3297ae4d..12db1c2a3 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -30,6 +30,10 @@ public override void StartState() [TypedPacketHandler] public void HandleProtocolOk(ServerProtocolOkPacket packet) { + Multiplayer.session.isStandaloneServer = packet.isStandaloneServer; + Multiplayer.session.autosaveInterval = packet.autosaveInterval; + Multiplayer.session.autosaveUnit = packet.autosaveUnit; + if (packet.hasPassword) { // Delay showing the window for better UX diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index 9c41c43d7..a8068091b 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -174,6 +174,7 @@ private static bool RunCmds() while (tickable.Cmds.Count > 0 && tickable.Cmds.Peek().ticks == curTimer) { ScheduledCommand cmd = tickable.Cmds.Dequeue(); + // Minimal code impact fix for #733. Having all the commands be added to a single queue gets rid of // the out-of-order execution problem. With a proper fix, this can be reverted to tickable.ExecuteCmd var target = TickableById(cmd.mapId); diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index 10ab17105..9e9ac2ca6 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -2,6 +2,7 @@ using HarmonyLib; using Multiplayer.Client.Util; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld.Planet; using Verse; @@ -142,6 +143,11 @@ static void Postfix(WorldRenderMode __result) { VTRSync.SendViewedMapUpdate(VTRSync.lastMovedToMapId, VTRSync.WorldMapId); } + + // On standalone with streaming, trigger a join point when leaving a map + // so each player can save independently without disturbing others + if (Multiplayer.session?.ConnectedToStandaloneServer == true && Multiplayer.GameComp.multifaction && Multiplayer.GameComp.asyncTime) + Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.WorldTravel)); } // Detect transition back to tile map else if (__result != WorldRenderMode.Planet && lastRenderMode == WorldRenderMode.Planet) diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 37f5ab661..933ecd609 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -1,9 +1,11 @@ using Ionic.Zlib; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld; using RimWorld.Planet; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; using System.Threading; using System.Xml; using Multiplayer.Client.Saving; @@ -240,6 +242,60 @@ void Send() else Send(); } + + /// + /// Send per-map standalone snapshots to the server for all maps in the given snapshot. + /// Called after autosave when connected to a standalone server. + /// + public static void SendStandaloneMapSnapshots(GameDataSnapshot snapshot) + { + var tick = snapshot.CachedAtTime; + + foreach (var (mapId, mapBytes) in snapshot.MapData) + { + var compressed = GZipStream.CompressBuffer(mapBytes); + + byte[] hash; + using (var sha = SHA256.Create()) + hash = sha.ComputeHash(compressed); + + var packet = new ClientStandaloneMapSnapshotPacket + { + mapId = mapId, + tick = tick, + mapData = compressed, + sha256Hash = hash, + }; + + OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize())); + } + } + + /// + /// Send the world + session standalone snapshot to the server. + /// Called after autosave when connected to a standalone server. + /// + public static void SendStandaloneWorldSnapshot(GameDataSnapshot snapshot) + { + var tick = snapshot.CachedAtTime; + var worldCompressed = GZipStream.CompressBuffer(snapshot.GameData); + var sessionCompressed = GZipStream.CompressBuffer(snapshot.SessionData); + + using var hasher = SHA256.Create(); + hasher.TransformBlock(worldCompressed, 0, worldCompressed.Length, null, 0); + hasher.TransformFinalBlock(sessionCompressed, 0, sessionCompressed.Length); + var hash = hasher.Hash ?? System.Array.Empty(); + + var packet = new ClientStandaloneWorldSnapshotPacket + { + tick = tick, + worldData = worldCompressed, + sessionData = sessionCompressed, + sha256Hash = hash, + }; + + OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize())); + } } } diff --git a/Source/Client/Session/Autosaving.cs b/Source/Client/Session/Autosaving.cs index 35f8dc22e..b0429942e 100644 --- a/Source/Client/Session/Autosaving.cs +++ b/Source/Client/Session/Autosaving.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld; using UnityEngine; using Verse; @@ -14,8 +15,19 @@ public static void DoAutosave() { LongEventHandler.QueueLongEvent(() => { - SaveGameToFile_Overwrite(GetNextAutosaveFileName(), false); - Multiplayer.Client.Send(Packets.Client_Autosaving); + var snapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false); + + if (!SaveGameToFile_Overwrite(GetNextAutosaveFileName(), snapshot)) + return; + + Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save)); + + // When connected to a standalone server, also upload fresh snapshots + if (Multiplayer.session?.ConnectedToStandaloneServer == true) + { + SaveLoad.SendStandaloneMapSnapshots(snapshot); + SaveLoad.SendStandaloneWorldSnapshot(snapshot); + } }, "MpSaving", false, null); } @@ -33,30 +45,39 @@ private static string GetNextAutosaveFileName() .First(); } - public static void SaveGameToFile_Overwrite(string fileNameNoExtension, bool currentReplay) + public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, bool currentReplay) + => SaveGameToFile_Overwrite(fileNameNoExtension, + currentReplay ? Multiplayer.session.dataSnapshot : null); + + public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameDataSnapshot snapshot) { Log.Message($"Multiplayer: saving to file {fileNameNoExtension}"); try { - var tmp = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.tmp.zip")); - Replay.ForSaving(tmp).WriteData( - currentReplay ? - Multiplayer.session.dataSnapshot : - SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false) + var tmpPath = Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.tmp.zip"); + if (File.Exists(tmpPath)) + File.Delete(tmpPath); + + Replay.ForSaving(new FileInfo(tmpPath)).WriteData( + snapshot ?? SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false) ); - var dst = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip")); - if (!dst.Exists) dst.Open(FileMode.Create).Close(); - tmp.Replace(dst.FullName, null); + var dstPath = Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip"); + if (File.Exists(dstPath)) + File.Replace(tmpPath, dstPath, destinationBackupFileName: null); + else + File.Move(tmpPath, dstPath); Messages.Message("MpGameSaved".Translate(fileNameNoExtension), MessageTypeDefOf.SilentInput, false); Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup; + return true; } catch (Exception e) { Log.Error($"Exception saving multiplayer game as {fileNameNoExtension}: {e}"); Messages.Message("MpGameSaveFailed".Translate(), MessageTypeDefOf.SilentInput, false); + return false; } } } diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 262b2bf6b..9431730a9 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -4,6 +4,7 @@ using Multiplayer.Client.Networking; using Multiplayer.Client.Util; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld; using Steamworks; using UnityEngine; @@ -51,6 +52,10 @@ public class MultiplayerSession : IConnectionStatusListener public bool ArbiterPlaying => players.Any(p => p.type == PlayerType.Arbiter && p.status == PlayerStatus.Playing); public IConnector connector; + public bool isStandaloneServer; + public float autosaveInterval; + public AutosaveUnit autosaveUnit; + public bool ConnectedToStandaloneServer => client != null && isStandaloneServer; public void Stop() { diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs index 26c99acb2..971234f6e 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs @@ -283,7 +283,16 @@ private void CreateBootstrapReplaySave() { try { - Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false); + if (!Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false)) + { + OnMainThread.Enqueue(() => + { + saveUploadStatus = "Save failed, see log for details."; + bootstrapSaveQueued = false; + }); + return; + } + var path = Path.Combine(Multiplayer.ReplaysDir, $"{BootstrapSaveName}.zip"); OnMainThread.Enqueue(() => FinalizeBootstrapSave(path)); } diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs index 98221d606..c077858e1 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs @@ -1,4 +1,3 @@ -using System; using Multiplayer.Client.Util; using Multiplayer.Common.Networking.Packet; using Multiplayer.Common.Util; @@ -45,6 +44,8 @@ private void DrawSettings(Rect entry, Rect inRect) else if (tab == Tab.Gameplay) ServerSettingsUI.DrawGameplaySettingsOnly(contentRect, settings, buffers); + settings.EnforceStandaloneRequirements(); + settingsUiBuffers.MaxPlayersBuffer = buffers.MaxPlayersBuffer; settingsUiBuffers.AutosaveBuffer = buffers.AutosaveBuffer; @@ -92,9 +93,7 @@ private void DrawSettingsButtons(Rect inRect) { var previewRect = new Rect(inRect.x, inRect.y, 150f, inRect.height); if (Widgets.ButtonText(previewRect, "Preview TOML")) - { Find.WindowStack.Add(new DebugTextWindow(GenerateToml())); - } nextRect = new Rect(inRect.xMax - 150f, inRect.y, 150f, inRect.height); } @@ -124,9 +123,10 @@ private void StartUploadSettingsToml() try { + settings.EnforceStandaloneRequirements(); connection.Send(new ClientBootstrapSettingsPacket(settings)); } - catch (Exception e) + catch (System.Exception e) { Log.Error($"Bootstrap settings upload failed: {e}"); isUploadingToml = false; @@ -140,10 +140,8 @@ private void StartUploadSettingsToml() statusText = "Server settings uploaded. Waiting for the server to request save.zip generation."; step = Step.GenerateMap; saveUploadRequestedByServer = false; - bootstrapState = bootstrapState with { SettingsMissing = false, SaveMissing = false }; } - private string GenerateToml() => - "# Generated by Multiplayer bootstrap configurator\n\n" + TomlSettings.Serialize(settings); + private string GenerateToml() => "# Generated by Multiplayer bootstrap configurator\n\n" + TomlSettings.Serialize(settings); } diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 6fd324d83..4e665f47d 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -71,6 +71,7 @@ public BootstrapConfiguratorWindow(ConnectionBase connection, BootstrapServerSta settings.steam = false; settings.arbiter = false; + settings.EnforceStandaloneRequirements(); settingsUiBuffers.MaxPlayersBuffer = settings.maxPlayers.ToString(); settingsUiBuffers.AutosaveBuffer = settings.autosaveInterval.ToString(); @@ -217,10 +218,7 @@ private float GetActiveTabContentHeight() if (tab == Tab.Connecting) return 5 * 30f; - if (tab == Tab.Gameplay) - return (MpVersion.IsDebug ? 9 : 8) * 30f; - - return 260f; + return (MpVersion.IsDebug ? 9 : 8) * 30f; } private sealed class PendingUploadState diff --git a/Source/Client/Windows/SaveGameWindow.cs b/Source/Client/Windows/SaveGameWindow.cs index 4e6973804..ef4dabf13 100644 --- a/Source/Client/Windows/SaveGameWindow.cs +++ b/Source/Client/Windows/SaveGameWindow.cs @@ -1,7 +1,9 @@ using Multiplayer.Client.Util; +using Multiplayer.Common; using RimWorld; using System.Collections.Generic; using System.IO; +using Multiplayer.Common.Networking.Packet; using UnityEngine; using Verse; @@ -199,7 +201,13 @@ private void Accept(bool currentReplay) { if (curText.Length != 0) { - LongEventHandler.QueueLongEvent(() => Autosaving.SaveGameToFile_Overwrite(curText, currentReplay), "MpSaving", false, null); + LongEventHandler.QueueLongEvent(() => + { + if (!Autosaving.SaveGameToFile_Overwrite(curText, currentReplay)) + return; + + Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save)); + }, "MpSaving", false, null); Close(); } } diff --git a/Source/Common/ChatCommands.cs b/Source/Common/ChatCommands.cs index 0a474d484..d39ae5ca3 100644 --- a/Source/Common/ChatCommands.cs +++ b/Source/Common/ChatCommands.cs @@ -53,7 +53,7 @@ public ChatCmdJoinPoint() public override void Handle(IChatSource source, string[] args) { - if (!Server.worldData.TryStartJoinPointCreation(true)) + if (!Server.worldData.TryStartJoinPointCreation(true, sourcePlayer: source as ServerPlayer)) source.SendMsg("Join point creation already in progress."); } } diff --git a/Source/Common/JoinPointRequestReason.cs b/Source/Common/JoinPointRequestReason.cs new file mode 100644 index 000000000..129f2fe65 --- /dev/null +++ b/Source/Common/JoinPointRequestReason.cs @@ -0,0 +1,7 @@ +namespace Multiplayer.Common; + +public enum JoinPointRequestReason : byte +{ + Save = 1, + WorldTravel = 2, +} diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 88ddbc00b..27dc3553e 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -74,6 +74,7 @@ static MultiplayerServer() public int NetTimer { get; private set; } public bool IsStandaloneServer { get; set; } + public StandalonePersistence? persistence; public MultiplayerServer(ServerSettings settings) { diff --git a/Source/Common/Networking/Packet/AutosavingPacket.cs b/Source/Common/Networking/Packet/AutosavingPacket.cs new file mode 100644 index 000000000..1b0b55b1a --- /dev/null +++ b/Source/Common/Networking/Packet/AutosavingPacket.cs @@ -0,0 +1,12 @@ +namespace Multiplayer.Common.Networking.Packet; + +[PacketDefinition(Packets.Client_Autosaving)] +public record struct ClientAutosavingPacket(JoinPointRequestReason reason) : IPacket +{ + public JoinPointRequestReason reason = reason; + + public void Bind(PacketBuffer buf) + { + buf.BindEnum(ref reason); + } +} \ No newline at end of file diff --git a/Source/Common/Networking/Packet/ProtocolPacket.cs b/Source/Common/Networking/Packet/ProtocolPacket.cs index 98b70963e..6b031968a 100644 --- a/Source/Common/Networking/Packet/ProtocolPacket.cs +++ b/Source/Common/Networking/Packet/ProtocolPacket.cs @@ -1,13 +1,19 @@ namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Server_ProtocolOk)] -public record struct ServerProtocolOkPacket(bool hasPassword) : IPacket +public record struct ServerProtocolOkPacket(bool hasPassword, bool isStandaloneServer = false) : IPacket { public bool hasPassword = hasPassword; + public bool isStandaloneServer = isStandaloneServer; + public float autosaveInterval; + public AutosaveUnit autosaveUnit; public void Bind(PacketBuffer buf) { buf.Bind(ref hasPassword); + buf.Bind(ref isStandaloneServer); + buf.Bind(ref autosaveInterval); + buf.BindEnum(ref autosaveUnit); } } diff --git a/Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs b/Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs new file mode 100644 index 000000000..3151ec78d --- /dev/null +++ b/Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs @@ -0,0 +1,35 @@ +namespace Multiplayer.Common.Networking.Packet; + +[PacketDefinition(Packets.Client_StandaloneWorldSnapshotUpload, allowFragmented: true)] +public record struct ClientStandaloneWorldSnapshotPacket : IPacket +{ + public int tick; + public byte[] worldData; + public byte[] sessionData; + public byte[] sha256Hash; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref tick); + buf.BindBytes(ref worldData, maxLength: -1); + buf.BindBytes(ref sessionData, maxLength: -1); + buf.BindBytes(ref sha256Hash, maxLength: 32); + } +} + +[PacketDefinition(Packets.Client_StandaloneMapSnapshotUpload, allowFragmented: true)] +public record struct ClientStandaloneMapSnapshotPacket : IPacket +{ + public int mapId; + public int tick; + public byte[] mapData; + public byte[] sha256Hash; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref mapId); + buf.Bind(ref tick); + buf.BindBytes(ref mapData, maxLength: -1); + buf.BindBytes(ref sha256Hash, maxLength: 32); + } +} \ No newline at end of file diff --git a/Source/Common/Networking/Packets.cs b/Source/Common/Networking/Packets.cs index 18306038c..13186cd32 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -34,6 +34,8 @@ public enum Packets : byte Client_RequestRejoin, Client_SetFaction, Client_FrameTime, + Client_StandaloneWorldSnapshotUpload, + Client_StandaloneMapSnapshotUpload, // Joining Server_ProtocolOk, diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index ebf089537..44d383f71 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Threading.Tasks; using Multiplayer.Common.Networking.Packet; @@ -28,8 +29,11 @@ protected override async Task RunState() if (Server.settings.pauseOnJoin) Server.commands.PauseAll(); - if (Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Join)) - Server.worldData.TryStartJoinPointCreation(); + // On standalone, only request a fresh join point when another player is already active. + // For the normal first join, serve the persisted state immediately instead of blocking on WaitJoinPoint. + if ((Server.IsStandaloneServer && Server.PlayingPlayers.Any()) || + (!Server.IsStandaloneServer && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Join))) + Server.worldData.TryStartJoinPointCreation(sourcePlayer: Player); Server.playerManager.OnJoin(Player); Server.playerManager.SendInitDataCommand(Player); @@ -45,7 +49,11 @@ private void HandleProtocol(ClientProtocolPacket packet) Player.Disconnect(MpDisconnectReason.Protocol, ByteWriter.GetBytes(MpVersion.Version, MpVersion.Protocol)); else { - Player.SendPacket(new ServerProtocolOkPacket(Server.settings.hasPassword)); + Player.SendPacket(new ServerProtocolOkPacket(Server.settings.hasPassword, Server.IsStandaloneServer) + { + autosaveInterval = Server.settings.autosaveInterval, + autosaveUnit = Server.settings.autosaveUnit + }); if (Server.BootstrapMode) { diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index cba3f2380..93a9fab82 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -83,10 +83,11 @@ public void HandleChat(ClientChatPacket packet) [PacketHandler(Packets.Client_WorldDataUpload, allowFragmented: true)] public void HandleWorldDataUpload(ByteReader data) { - if (Server.ArbiterPlaying ? !Player.IsArbiter : !Player.IsHost) // policy + // On standalone, accept from any playing client; otherwise only host/arbiter + if (!Server.IsStandaloneServer && (Server.ArbiterPlaying ? !Player.IsArbiter : !Player.IsHost)) return; - ServerLog.Log($"Got world upload {data.Left}"); + ServerLog.Detail($"Got world upload {data.Left}"); Server.worldData.mapData = new Dictionary(); @@ -104,6 +105,54 @@ public void HandleWorldDataUpload(ByteReader data) Server.worldData.EndJoinPointCreation(); } + [TypedPacketHandler] + public void HandleStandaloneWorldSnapshot(ClientStandaloneWorldSnapshotPacket packet) + { + if (!Server.IsStandaloneServer) + return; + + if (!Player.IsPlaying) + return; + + var accepted = Server.worldData.TryAcceptStandaloneWorldSnapshot(Player, packet.tick, + packet.worldData, packet.sessionData, packet.sha256Hash); + + if (accepted) + { + ServerLog.Detail( + $"Accepted standalone world snapshot tick={packet.tick} from {Player.Username}"); + } + else + { + ServerLog.Detail( + $"Rejected standalone world snapshot tick={packet.tick} from {Player.Username}"); + } + } + + [TypedPacketHandler] + public void HandleStandaloneMapSnapshot(ClientStandaloneMapSnapshotPacket packet) + { + if (!Server.IsStandaloneServer) + return; + + if (!Player.IsPlaying) + return; + + var accepted = Server.worldData.TryAcceptStandaloneMapSnapshot(Player, packet.mapId, packet.tick, + packet.mapData, packet.sha256Hash); + + if (accepted) + { + ServerLog.Detail( + $"Accepted standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); + } + else + { + ServerLog.Detail( + $"Rejected standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); + } + } + [TypedPacketHandler] public void HandleCursor(ClientCursorPacket clientPacket) { @@ -162,12 +211,20 @@ public void HandleFreeze(ClientFreezePacket packet) Player.unfrozenAt = Server.NetTimer; } - [PacketHandler(Packets.Client_Autosaving)] - public void HandleAutosaving(ByteReader data) + [TypedPacketHandler] + public void HandleAutosaving(ClientAutosavingPacket packet) { - // Host policy - if (Player.IsHost && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Autosave)) - Server.worldData.TryStartJoinPointCreation(); + var forceJoinPoint = packet.reason == JoinPointRequestReason.Save; + + ServerLog.Detail( + $"Received Client_Autosaving from {Player.Username}, standalone={Server.IsStandaloneServer}, " + + $"isHost={Player.IsHost}, reason={packet.reason}, force={forceJoinPoint}"); + + // On standalone, any playing client can trigger a join point (always, regardless of settings) + // On hosted, only the host can trigger and only if the Autosave flag is set + if (Server.IsStandaloneServer || + (Player.IsHost && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Autosave))) + Server.worldData.TryStartJoinPointCreation(forceJoinPoint, sourcePlayer: Player); } [TypedPacketHandler] diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index cc3da9284..a56eb2d8c 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -136,7 +136,10 @@ public void OnDesync(ServerPlayer player, int tick, int diffAt) public void OnJoin(ServerPlayer player) { player.hasJoined = true; - player.FactionId = player.id == 0 || !server.settings.multifaction ? + var standalonePrimaryPlayer = server.IsStandaloneServer && + !server.JoinedPlayers.Any(p => p != player && !p.IsArbiter); + + player.FactionId = standalonePrimaryPlayer || player.id == 0 || !server.settings.multifaction ? server.worldData.hostFactionId : server.worldData.spectatorFactionId; diff --git a/Source/Common/ServerSettings.cs b/Source/Common/ServerSettings.cs index 66b27aa3d..8c9237415 100644 --- a/Source/Common/ServerSettings.cs +++ b/Source/Common/ServerSettings.cs @@ -32,6 +32,12 @@ public class ServerSettings public bool pauseOnDesync = true; public TimeControl timeControl; + public void EnforceStandaloneRequirements() + { + if (multifaction) + asyncTime = true; + } + public string? TryParseEndpoints(out IPEndPoint[] endpoints) { var split = directAddress.Split(MultiplayerServer.EndpointSeparator); diff --git a/Source/Common/StandalonePersistence.cs b/Source/Common/StandalonePersistence.cs new file mode 100644 index 000000000..f7bcbd1e6 --- /dev/null +++ b/Source/Common/StandalonePersistence.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; + +namespace Multiplayer.Common; + +/// +/// Manages the Saved/ directory for standalone server durable persistence. +/// Uses atomic temp-file + rename pattern for crash safety. +/// +public class StandalonePersistence +{ + public string SavedDir { get; } + + private string MapsDir => Path.Combine(SavedDir, "maps"); + private string WorldPath => Path.Combine(SavedDir, "world.dat"); + private string WorldCmdsPath => Path.Combine(SavedDir, "world_cmds.dat"); + private string SessionPath => Path.Combine(SavedDir, "session.dat"); + private string InfoPath => Path.Combine(SavedDir, "info.xml"); + private string StatePath => Path.Combine(SavedDir, "state.bin"); + + public StandalonePersistence(string baseDir) + { + SavedDir = Path.Combine(baseDir, "Saved"); + } + + public void EnsureDirectories() + { + Directory.CreateDirectory(SavedDir); + Directory.CreateDirectory(MapsDir); + } + + public bool HasValidState() + { + return File.Exists(WorldPath); + } + + /// + /// Seed the Saved/ directory from a save.zip (replay format). + /// + public void SeedFromSaveZip(string zipPath) + { + EnsureDirectories(); + + using var zip = ZipFile.OpenRead(zipPath); + + // World save + var worldEntry = zip.GetEntry("world/000_save"); + if (worldEntry != null) + { + var worldBytes = ReadEntry(worldEntry); + AtomicWrite(WorldPath, Compress(worldBytes)); + } + + // World commands + var worldCmdsEntry = zip.GetEntry("world/000_cmds"); + if (worldCmdsEntry != null) + AtomicWrite(WorldCmdsPath, ReadEntry(worldCmdsEntry)); + + // Map saves and commands + foreach (var entry in zip.Entries) + { + if (!entry.FullName.StartsWith("maps/")) continue; + + var parts = entry.FullName.Replace("maps/", "").Split('_'); + if (parts.Length < 3) continue; + + if (!int.TryParse(parts[1], out int mapId)) continue; + + if (entry.FullName.EndsWith("_save")) + AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), Compress(ReadEntry(entry))); + else if (entry.FullName.EndsWith("_cmds")) + AtomicWrite(Path.Combine(MapsDir, $"{mapId}_cmds.dat"), ReadEntry(entry)); + } + + // Info/metadata + var infoEntry = zip.GetEntry("info"); + if (infoEntry != null) + { + var infoBytes = ReadEntry(infoEntry); + AtomicWrite(InfoPath, infoBytes); + + try + { + WritePersistedTick(GetLatestTick(ReplayInfo.Read(infoBytes))); + } + catch (Exception e) + { + ServerLog.Error($"Failed to seed persisted tick from info.xml: {e.Message}"); + } + } + + // Session data (empty for replay format, but create the file) + AtomicWrite(SessionPath, Array.Empty()); + + ServerLog.Log($"Seeded Saved/ directory from {zipPath}"); + } + + /// + /// Load persisted state into WorldData and return ReplayInfo if available. + /// + public ReplayInfo? LoadInto(MultiplayerServer server) + { + if (!File.Exists(WorldPath)) + return null; + + server.worldData.savedGame = File.ReadAllBytes(WorldPath); + + server.worldData.sessionData = File.Exists(SessionPath) ? File.ReadAllBytes(SessionPath) : Array.Empty(); + + // Load maps + if (Directory.Exists(MapsDir)) + { + foreach (var mapFile in Directory.GetFiles(MapsDir, "*.dat")) + { + var fileName = Path.GetFileNameWithoutExtension(mapFile); + if (fileName.EndsWith("_cmds")) continue; + + if (int.TryParse(fileName, out int mapId)) + { + server.worldData.mapData[mapId] = File.ReadAllBytes(mapFile); + + var cmdsPath = Path.Combine(MapsDir, $"{mapId}_cmds.dat"); + if (File.Exists(cmdsPath)) + { + server.worldData.mapCmds[mapId] = ScheduledCommand.DeserializeCmds(File.ReadAllBytes(cmdsPath)) + .Select(ScheduledCommand.Serialize).ToList(); + } + else + { + server.worldData.mapCmds[mapId] = new List(); + } + } + } + } + + // Load world commands + if (File.Exists(WorldCmdsPath)) + { + server.worldData.mapCmds[-1] = ScheduledCommand.DeserializeCmds(File.ReadAllBytes(WorldCmdsPath)) + .Select(ScheduledCommand.Serialize).ToList(); + } + else + { + server.worldData.mapCmds[-1] = new List(); + } + + // Load replay info for metadata + ReplayInfo? info = null; + if (File.Exists(InfoPath)) + { + try { info = ReplayInfo.Read(File.ReadAllBytes(InfoPath)); } + catch (Exception e) { ServerLog.Error($"Failed to read info.xml: {e.Message}"); } + } + + var loadedTick = ReadPersistedTick() ?? GetLatestTick(info); + server.gameTimer = loadedTick; + server.startingTimer = loadedTick; + server.worldData.standaloneWorldSnapshot.tick = loadedTick; + + foreach (var mapId in server.worldData.mapData.Keys) + { + server.worldData.standaloneMapSnapshots[mapId] = new StandaloneMapSnapshotState + { + tick = loadedTick, + }; + } + + ServerLog.Log($"Loaded state from Saved/ directory ({server.worldData.mapData.Count} maps) at tick {loadedTick}"); + return info; + } + + public void WriteJoinPoint(WorldData worldData, int tick) + { + EnsureDirectories(); + + if (worldData.savedGame != null) + AtomicWrite(WorldPath, worldData.savedGame); + + AtomicWrite(SessionPath, worldData.sessionData ?? Array.Empty()); + AtomicWrite(WorldCmdsPath, SerializeStoredCmds(worldData.mapCmds.GetValueOrDefault(ScheduledCommand.Global) ?? [])); + + var currentMapIds = worldData.mapData.Keys.ToHashSet(); + DeleteStaleMapFiles(currentMapIds); + + foreach (var (mapId, mapData) in worldData.mapData) + { + AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), mapData); + AtomicWrite( + Path.Combine(MapsDir, $"{mapId}_cmds.dat"), + SerializeStoredCmds(worldData.mapCmds.GetValueOrDefault(mapId) ?? [])); + } + + WritePersistedTick(tick); + } + + /// + /// Write an accepted map snapshot to disk atomically. + /// + public void WriteMapSnapshot(int mapId, byte[] compressedMapData) + { + EnsureDirectories(); + AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), compressedMapData); + } + + /// + /// Write an accepted world snapshot to disk atomically. + /// + public void WriteWorldSnapshot(byte[] compressedWorldData, byte[] sessionData, int tick) + { + EnsureDirectories(); + AtomicWrite(WorldPath, compressedWorldData); + AtomicWrite(SessionPath, sessionData); + WritePersistedTick(tick); + } + + /// + /// Cleanup any leftover .tmp files from interrupted writes. + /// + public void CleanupTempFiles() + { + if (!Directory.Exists(SavedDir)) return; + + foreach (var tmp in Directory.GetFiles(SavedDir, "*.tmp", SearchOption.AllDirectories)) + { + try + { + File.Delete(tmp); + ServerLog.Detail($"Cleaned up leftover temp file: {tmp}"); + } + catch (Exception e) + { + ServerLog.Error($"Failed to clean temp file {tmp}: {e.Message}"); + } + } + } + + /// + /// Atomic write: write to .tmp, then replace/rename over the target. + /// + private static void AtomicWrite(string targetPath, byte[] data) + { + var tmpPath = targetPath + ".tmp"; + File.WriteAllBytes(tmpPath, data); + + if (File.Exists(targetPath)) + File.Replace(tmpPath, targetPath, destinationBackupFileName: null); + else + File.Move(tmpPath, targetPath); + } + + private static byte[] ReadEntry(ZipArchiveEntry entry) + { + using var stream = entry.Open(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } + + private static byte[] Compress(byte[] input) + { + using var result = new MemoryStream(); + using (var gz = new GZipStream(result, CompressionMode.Compress)) + { + gz.Write(input, 0, input.Length); + gz.Flush(); + } + return result.ToArray(); + } + + private void DeleteStaleMapFiles(HashSet currentMapIds) + { + if (!Directory.Exists(MapsDir)) + return; + + foreach (var path in Directory.GetFiles(MapsDir, "*.dat")) + { + var fileName = Path.GetFileNameWithoutExtension(path); + var mapIdText = fileName.EndsWith("_cmds") ? fileName[..^5] : fileName; + if (int.TryParse(mapIdText, out var mapId) && !currentMapIds.Contains(mapId)) + File.Delete(path); + } + } + + private void WritePersistedTick(int tick) + { + AtomicWrite(StatePath, BitConverter.GetBytes(tick)); + } + + private int? ReadPersistedTick() + { + if (!File.Exists(StatePath)) + return null; + + var data = File.ReadAllBytes(StatePath); + return data.Length >= sizeof(int) ? BitConverter.ToInt32(data, 0) : null; + } + + private static int GetLatestTick(ReplayInfo? info) + { + if (info?.sections.Count > 0) + { + var section = info.sections.Last(); + return Math.Max(section.start, section.end); + } + + return 0; + } + + private static byte[] SerializeStoredCmds(List cmds) + { + var writer = new ByteWriter(); + writer.WriteInt32(cmds.Count); + foreach (var cmd in cmds) + writer.WritePrefixedBytes(cmd); + return writer.ToArray(); + } +} diff --git a/Source/Common/WorldData.cs b/Source/Common/WorldData.cs index a76e18dfb..26e7b596f 100644 --- a/Source/Common/WorldData.cs +++ b/Source/Common/WorldData.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; using System.Threading.Tasks; namespace Multiplayer.Common; @@ -14,10 +16,13 @@ public class WorldData public Dictionary> mapCmds = new(); // Map id to serialized cmds list public Dictionary>? tmpMapCmds; - public int lastJoinPointAtWorkTicks = -1; + public int lastJoinPointAtTick = -1; public List syncInfos = new(); + public StandaloneWorldSnapshotState standaloneWorldSnapshot = new(); + public Dictionary standaloneMapSnapshots = new(); + private TaskCompletionSource? dataSource; public bool CreatingJoinPoint => tmpMapCmds != null; @@ -29,17 +34,42 @@ public WorldData(MultiplayerServer server) Server = server; } - public bool TryStartJoinPointCreation(bool force = false) + private int CurrentJoinPointTick => Server.IsStandaloneServer ? Server.gameTimer : Server.workTicks; + + public bool TryStartJoinPointCreation(bool force = false, ServerPlayer? sourcePlayer = null) { - if (!force && Server.workTicks - lastJoinPointAtWorkTicks < 30) + int currentTick = CurrentJoinPointTick; + + if (!force && lastJoinPointAtTick >= 0 && currentTick - lastJoinPointAtTick < 30) + { + ServerLog.Detail($"Join point skipped: cooldown active at tick={currentTick}, last={lastJoinPointAtTick}, standalone={Server.IsStandaloneServer}"); return false; + } if (CreatingJoinPoint) + { + ServerLog.Detail("Join point skipped: already creating one"); return false; - + } + + var issuingPlayer = sourcePlayer; + if (Server.IsStandaloneServer && issuingPlayer == null) + { + issuingPlayer = Server.PlayingPlayers.FirstOrDefault(player => player.IsHost) + ?? Server.PlayingPlayers.FirstOrDefault(); + + if (issuingPlayer == null) + { + ServerLog.Detail("Join point skipped: no playing player available for standalone creation"); + return false; + } + } + + ServerLog.Detail($"Join point started at tick={currentTick}, force={force}, standalone={Server.IsStandaloneServer}"); Server.SendChat("Creating a join point..."); - Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, Array.Empty()); + Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, Array.Empty(), + sourcePlayer: Server.IsStandaloneServer ? issuingPlayer : null); tmpMapCmds = new Dictionary>(); dataSource = new TaskCompletionSource(); @@ -48,9 +78,24 @@ public bool TryStartJoinPointCreation(bool force = false) public void EndJoinPointCreation() { + int currentTick = CurrentJoinPointTick; + ServerLog.Detail($"Join point completed at tick={currentTick}, standalone={Server.IsStandaloneServer}"); mapCmds = tmpMapCmds!; tmpMapCmds = null; - lastJoinPointAtWorkTicks = Server.workTicks; + lastJoinPointAtTick = currentTick; + + if (Server.IsStandaloneServer && Server.persistence != null) + { + try + { + Server.persistence.WriteJoinPoint(this, currentTick); + } + catch (Exception e) + { + ServerLog.Error($"Failed to persist standalone join point at tick={currentTick}: {e}"); + } + } + dataSource!.SetResult(this); dataSource = null; } @@ -69,4 +114,87 @@ public Task WaitJoinPoint() { return dataSource?.Task ?? Task.FromResult(this); } + + public bool TryAcceptStandaloneWorldSnapshot(ServerPlayer player, int tick, byte[] worldSnapshot, + byte[] sessionSnapshot, byte[] expectedHash) + { + if (tick < standaloneWorldSnapshot.tick) + return false; + + var actualHash = ComputeHash(worldSnapshot, sessionSnapshot); + if (expectedHash.Length > 0 && !actualHash.AsSpan().SequenceEqual(expectedHash)) + return false; + + savedGame = worldSnapshot; + sessionData = sessionSnapshot; + standaloneWorldSnapshot = new StandaloneWorldSnapshotState + { + tick = tick, + producerPlayerId = player.id, + producerUsername = player.Username, + sha256Hash = actualHash + }; + + // Persist to disk + Server.persistence?.WriteWorldSnapshot(worldSnapshot, sessionSnapshot, tick); + + return true; + } + + public bool TryAcceptStandaloneMapSnapshot(ServerPlayer player, int mapId, int tick, + byte[] mapSnapshot, byte[] expectedHash) + { + if (mapId < 0) + return false; + + var snapshotState = standaloneMapSnapshots.GetOrAddNew(mapId); + if (tick < snapshotState.tick) + return false; + + var actualHash = ComputeHash(mapSnapshot); + if (expectedHash.Length > 0 && !actualHash.AsSpan().SequenceEqual(expectedHash)) + return false; + + mapData[mapId] = mapSnapshot; + snapshotState.tick = tick; + snapshotState.producerPlayerId = player.id; + snapshotState.producerUsername = player.Username; + snapshotState.sha256Hash = actualHash; + standaloneMapSnapshots[mapId] = snapshotState; + + // Persist to disk + Server.persistence?.WriteMapSnapshot(mapId, mapSnapshot); + + return true; + } + + private static byte[] ComputeHash(params byte[][] payloads) + { + using var hasher = SHA256.Create(); + foreach (var payload in payloads) + { + hasher.TransformBlock(payload, 0, payload.Length, null, 0); + } + + hasher.TransformFinalBlock([], 0, 0); + return hasher.Hash; + } +} + +public struct StandaloneWorldSnapshotState +{ + public StandaloneWorldSnapshotState() { } + public int tick; + public int producerPlayerId; + public string producerUsername = ""; + public byte[] sha256Hash = Array.Empty(); +} + +public struct StandaloneMapSnapshotState +{ + public StandaloneMapSnapshotState() { } + public int tick; + public int producerPlayerId; + public string producerUsername = ""; + public byte[] sha256Hash = Array.Empty(); } diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index 9e391d27c..c9edeb66b 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -1,9 +1,7 @@ -using System.IO.Compression; -using System.Net; +using System.Net; using Multiplayer.Common; using Multiplayer.Common.Util; -ServerLog.detailEnabled = true; Directory.SetCurrentDirectory(AppContext.BaseDirectory); const string settingsFile = "settings.toml"; @@ -22,27 +20,68 @@ else ServerLog.Log($"Bootstrap mode: '{settingsFile}' not found. Waiting for a client to upload it."); +settings.EnforceStandaloneRequirements(); +ServerLog.detailEnabled = settings.debugMode; +ServerLog.verboseEnabled = settings.debugMode; + if (settings.steam) ServerLog.Error("Steam is not supported in standalone server."); if (settings.arbiter) ServerLog.Error("Arbiter is not supported in standalone server."); -var savePresent = File.Exists(saveFile); -if (!savePresent) -{ - ServerLog.Log($"Bootstrap mode: '{saveFile}' not found. Server will start without a loaded save."); - ServerLog.Log("Waiting for a client to upload world data."); -} - var server = MultiplayerServer.instance = new MultiplayerServer(settings) { running = true, IsStandaloneServer = true, - BootstrapMode = !settingsPresent || !savePresent, }; -if (!server.BootstrapMode) +var persistence = new StandalonePersistence(AppContext.BaseDirectory); +server.persistence = persistence; + +// Cleanup leftover temp files from any previous interrupted writes +persistence.CleanupTempFiles(); + +var consoleSource = new ConsoleSource(); + +var bootstrap = !settingsPresent; + +if (!bootstrap && persistence.HasValidState()) { - LoadSave(server, saveFile); + // Prefer loading from the Saved/ directory (structured persistence) + var info = persistence.LoadInto(server); + if (info != null) + { + server.settings.gameName = info.name; + server.worldData.hostFactionId = info.playerFaction; + var spectatorFaction = info.spectatorFaction; + if (server.settings.multifaction && spectatorFaction == 0) + ServerLog.Error("Multifaction is enabled but the save doesn't contain spectator faction id."); + server.worldData.spectatorFactionId = spectatorFaction; + } + ServerLog.Log("Loaded state from Saved/ directory."); } +else if (!bootstrap && File.Exists(saveFile)) +{ + // Seed the Saved/ directory from save.zip, then load from it + ServerLog.Log($"Seeding Saved/ directory from {saveFile}..."); + persistence.SeedFromSaveZip(saveFile); + var info = persistence.LoadInto(server); + if (info != null) + { + server.settings.gameName = info.name; + server.worldData.hostFactionId = info.playerFaction; + var spectatorFaction = info.spectatorFaction; + if (server.settings.multifaction && spectatorFaction == 0) + ServerLog.Error("Multifaction is enabled but the save doesn't contain spectator faction id."); + server.worldData.spectatorFactionId = spectatorFaction; + } +} +else +{ + bootstrap = true; + ServerLog.Log($"Bootstrap mode: neither Saved/ directory nor '{saveFile}' found."); + ServerLog.Log("Waiting for a client to upload world data."); +} + +server.BootstrapMode = bootstrap; if (settings.direct) { var badEndpoint = settings.TryParseEndpoints(out var endpoints); @@ -79,7 +118,6 @@ new Thread(server.Run) { Name = "Server thread" }.Start(); -var consoleSource = new ConsoleSource(); while (server.running) { var cmd = Console.ReadLine(); @@ -90,71 +128,6 @@ break; } -static void LoadSave(MultiplayerServer server, string path) -{ - using var zip = ZipFile.OpenRead(path); - - var replayInfo = ReplayInfo.Read(zip.GetBytes("info")); - ServerLog.Detail($"Loading {path} saved in RW {replayInfo.rwVersion} with {replayInfo.modNames.Count} mods"); - - server.settings.gameName = replayInfo.name; - server.worldData.hostFactionId = replayInfo.playerFaction; - var spectatorFaction = replayInfo.spectatorFaction; - if (server.settings.multifaction && spectatorFaction == 0) - ServerLog.Error("Multifaction is enabled but the save doesn't contain spectator faction id."); - server.worldData.spectatorFactionId = spectatorFaction; - - //This parses multiple saves as long as they are named correctly - server.gameTimer = replayInfo.sections[0].start; - server.startingTimer = replayInfo.sections[0].start; - - - server.worldData.savedGame = Compress(zip.GetBytes("world/000_save")); - - // Parse cmds entry for each map - foreach (var entry in zip.GetEntries("maps/*_cmds")) - { - var parts = entry.FullName.Split('_'); - - if (parts.Length == 3) - { - int mapNumber = int.Parse(parts[1]); - server.worldData.mapCmds[mapNumber] = ScheduledCommand.DeserializeCmds(zip.GetBytes(entry.FullName)) - .Select(ScheduledCommand.Serialize).ToList(); - } - } - - // Parse save entry for each map - foreach (var entry in zip.GetEntries("maps/*_save")) - { - var parts = entry.FullName.Split('_'); - - if (parts.Length == 3) - { - int mapNumber = int.Parse(parts[1]); - server.worldData.mapData[mapNumber] = Compress(zip.GetBytes(entry.FullName)); - } - } - - - server.worldData.mapCmds[-1] = ScheduledCommand.DeserializeCmds(zip.GetBytes("world/000_cmds")) - .Select(ScheduledCommand.Serialize).ToList(); - server.worldData.sessionData = []; -} - -static byte[] Compress(byte[] input) -{ - using var result = new MemoryStream(); - - using (var compressionStream = new GZipStream(result, CompressionMode.Compress)) - { - compressionStream.Write(input, 0, input.Length); - compressionStream.Flush(); - - } - return result.ToArray(); -} - class ConsoleSource : IChatSource { public void SendMsg(string msg) diff --git a/Source/Tests/Helper/TestJoiningState.cs b/Source/Tests/Helper/TestJoiningState.cs index e1f0bb6f9..f0b1d4bb7 100644 --- a/Source/Tests/Helper/TestJoiningState.cs +++ b/Source/Tests/Helper/TestJoiningState.cs @@ -9,6 +9,9 @@ public TestJoiningState(ConnectionBase connection) : base(connection) { } + [TypedPacketHandler] + public void HandleKeepAlive(ServerKeepAlivePacket packet) { } + private const string RwVersion = "1.0.0"; protected override async Task RunState() diff --git a/Source/Tests/Helper/TestLoadingKeepAliveState.cs b/Source/Tests/Helper/TestLoadingKeepAliveState.cs index f8c5db33d..e7298763c 100644 --- a/Source/Tests/Helper/TestLoadingKeepAliveState.cs +++ b/Source/Tests/Helper/TestLoadingKeepAliveState.cs @@ -9,6 +9,9 @@ public TestLoadingKeepAliveState(ConnectionBase connection) : base(connection) { } + [TypedPacketHandler] + public void HandleKeepAlive(ServerKeepAlivePacket packet) { } + private const string RwVersion = "1.0.0"; protected override async Task RunState() diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index b1bd0e726..9ad9dd66e 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -170,8 +170,8 @@ private static IEnumerable RoundtripPackets() yield return new ClientProtocolPacket(50); - yield return new ServerProtocolOkPacket(true); - yield return new ServerProtocolOkPacket(false); + yield return new ServerProtocolOkPacket(true, true) { autosaveInterval = 5f, autosaveUnit = AutosaveUnit.Minutes }; + yield return new ServerProtocolOkPacket(false, false); yield return new ClientUsernamePacket("username"); yield return new ClientUsernamePacket("username", "password"); diff --git a/Source/Tests/ServerTest.cs b/Source/Tests/ServerTest.cs index 611ee3dfb..5727e2c11 100644 --- a/Source/Tests/ServerTest.cs +++ b/Source/Tests/ServerTest.cs @@ -124,7 +124,8 @@ private MultiplayerServer MakeServer(out int port) gameName = "Test", direct = true, directAddress = "127.0.0.1:0", // 0 makes the OS choose any free port - lan = false + lan = false, + autoJoinPoint = 0 // Disable: test server has no game simulation to complete join points }) { running = true diff --git a/Source/Tests/StandalonePersistenceTest.cs b/Source/Tests/StandalonePersistenceTest.cs new file mode 100644 index 000000000..dd421a361 --- /dev/null +++ b/Source/Tests/StandalonePersistenceTest.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Multiplayer.Common; + +namespace Tests; + +[TestFixture] +public class StandalonePersistenceTest +{ + private string tempDir = null!; + + [SetUp] + public void SetUp() + { + tempDir = Path.Combine(Path.GetTempPath(), $"mp-standalone-persistence-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + + [Test] + public void WriteJoinPoint_PersistsCommandsAndTickForReload() + { + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(tempDir), + }; + + server.worldData.savedGame = [1, 2, 3]; + server.worldData.sessionData = [4, 5, 6]; + server.worldData.mapData[7] = [7, 8, 9]; + + var worldCmd = ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Sync, 1234, 1, ScheduledCommand.Global, 5, [10])); + var mapCmd = ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 1234, 1, 7, 5, [11])); + server.worldData.mapCmds[ScheduledCommand.Global] = [worldCmd]; + server.worldData.mapCmds[7] = [mapCmd]; + + server.persistence.WriteJoinPoint(server.worldData, 1234); + + var reloadedServer = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(tempDir), + }; + + var info = reloadedServer.persistence.LoadInto(reloadedServer); + + Assert.That(info, Is.Null); + Assert.That(reloadedServer.gameTimer, Is.EqualTo(1234)); + Assert.That(reloadedServer.startingTimer, Is.EqualTo(1234)); + Assert.That(reloadedServer.worldData.savedGame, Is.EqualTo(new byte[] { 1, 2, 3 })); + Assert.That(reloadedServer.worldData.sessionData, Is.EqualTo(new byte[] { 4, 5, 6 })); + Assert.That(reloadedServer.worldData.mapData[7], Is.EqualTo(new byte[] { 7, 8, 9 })); + Assert.That(reloadedServer.worldData.mapCmds[ScheduledCommand.Global], Has.Count.EqualTo(1)); + Assert.That(reloadedServer.worldData.mapCmds[7], Has.Count.EqualTo(1)); + + var reloadedWorldCmd = ScheduledCommand.Deserialize(new ByteReader(reloadedServer.worldData.mapCmds[ScheduledCommand.Global][0])); + var reloadedMapCmd = ScheduledCommand.Deserialize(new ByteReader(reloadedServer.worldData.mapCmds[7][0])); + + Assert.That(reloadedWorldCmd.ticks, Is.EqualTo(1234)); + Assert.That(reloadedMapCmd.ticks, Is.EqualTo(1234)); + Assert.That(reloadedServer.worldData.standaloneWorldSnapshot.tick, Is.EqualTo(1234)); + Assert.That(reloadedServer.worldData.standaloneMapSnapshots[7].tick, Is.EqualTo(1234)); + } + + [Test] + public void LoadInto_FallsBackToLatestReplaySectionTick() + { + var persistence = new StandalonePersistence(tempDir); + persistence.EnsureDirectories(); + + File.WriteAllBytes(Path.Combine(tempDir, "Saved", "world.dat"), [1]); + File.WriteAllBytes(Path.Combine(tempDir, "Saved", "session.dat"), []); + File.WriteAllBytes(Path.Combine(tempDir, "Saved", "world_cmds.dat"), ScheduledCommand.SerializeCmds(new List())); + File.WriteAllBytes(Path.Combine(tempDir, "Saved", "info.xml"), ReplayInfo.Write(new ReplayInfo + { + sections = new List + { + new(100, 100), + new(999, 999), + } + })); + + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = persistence, + }; + + persistence.LoadInto(server); + + Assert.That(server.gameTimer, Is.EqualTo(999)); + Assert.That(server.startingTimer, Is.EqualTo(999)); + } +} \ No newline at end of file diff --git a/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt b/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt index 4f32769c1..db302e132 100644 --- a/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerProtocolOkPacket.verified.txt @@ -1,2 +1,2 @@ -01 -00 +01-01-00-00-A0-40-01-00-00-00 +00-00-00-00-00-00-00-00-00-00 From c6853fafcbb30b55aca660a927f4c61963cd3370 Mon Sep 17 00:00:00 2001 From: Sakura-TA <52643135+Sakura-TA@users.noreply.github.com> Date: Wed, 13 May 2026 02:52:47 +0800 Subject: [PATCH 12/51] Multifaction trade (#905) * fix(Determinism): force single-batch FastTileFinder.Query in MP to prevent quest site tile divergence * fix(FactionContext): handle transporters and gravship map gen faction context * fix(FactionContext): push gravship faction context during ArriveNewMap * Count building's ownership when trading --------- Co-authored-by: Sakura-TA --- Source/Client/Factions/MultifactionPatches.cs | 89 +++++++++++++++++-- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index 8c25c175e..b433562d0 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -1,4 +1,5 @@ using HarmonyLib; +using KTrie; using Multiplayer.API; using Multiplayer.Client.Factions; using RimWorld; @@ -8,6 +9,7 @@ using System.Linq; using System.Reflection; using System.Reflection.Emit; +using System.Runtime.Remoting.Messaging; using UnityEngine; using Verse; using Verse.AI; @@ -29,7 +31,7 @@ public static void Prefix(ref Rect rect, Quest quest) Rect iconRect = new Rect(rect.x + 2f, rect.y + 2f, 4f, rect.height - 4f); Widgets.DrawBoxSolid(iconRect, playerFaction.Color); rect.xMin += 8f; - TooltipHandler.TipRegion(rect,"MpQuestDesc".Translate(playerFaction.Name, playerFaction == Faction.OfPlayer ? ". (you)" : ".")); + TooltipHandler.TipRegion(rect, "MpQuestDesc".Translate(playerFaction.Name, playerFaction == Faction.OfPlayer ? ". (you)" : ".")); } else { @@ -299,7 +301,7 @@ static IEnumerable Postfix(IEnumerable gizmos, Pawn __instance) if (Multiplayer.Client == null || Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) yield break; - if (__instance.Faction is { IsPlayer: true } &&__instance.Faction != Faction.OfPlayer) + if (__instance.Faction is { IsPlayer: true } && __instance.Faction != Faction.OfPlayer) { var otherFaction = __instance.Faction; @@ -758,7 +760,7 @@ private static bool DontDrawIdeoPlate(bool generating) } } -[HarmonyPatch(typeof(CompShuttle), "ContainedColonistCount", MethodType.Getter)] +[HarmonyPatch(typeof(CompShuttle), nameof(CompShuttle.ContainedColonistCount), MethodType.Getter)] static class CompShuttle_ContainedColonistCount_Patch { static IEnumerable Transpiler(IEnumerable insts) @@ -787,7 +789,7 @@ public static bool IsFreeColonistAnyPlayerFaction(Pawn pawn) pawn.RaceProps.Humanlike && (!pawn.IsSlave || pawn.guest.SlaveIsSecure) && !pawn.IsSubhuman && - pawn.HostFaction == null; + pawn.HostFaction == null; } } @@ -828,14 +830,14 @@ static void Prefix(Gravship gravship, Map map, out bool __state) __state = true; } - static void Finalizer(Gravship gravship, Map map,bool __state) + static void Finalizer(Gravship gravship, Map map, bool __state) { if (!__state) return; map.PopFaction(); } } -[HarmonyPatch(typeof(QuestPart_LendColonistsToFaction), "Enable")] +[HarmonyPatch(typeof(QuestPart_LendColonistsToFaction), nameof(QuestPart_LendColonistsToFaction.Enable))] static class QuestPart_LendColonistsToFaction_Enable_Patch { static IEnumerable Transpiler(IEnumerable insts) @@ -854,3 +856,78 @@ static IEnumerable Transpiler(IEnumerable inst } } } + +[HarmonyPatch] +static class Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch +{ + static MethodInfo TargetMethod() + { + return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(Pawn_TraderTracker), nameof(Pawn_TraderTracker.ColonyThingsWillingToBuy))); + } + public static List AllBuildingsColonistOfDefOfPlayer(this ListerBuildings lister, ThingDef def) + { + if (def.CanHaveFaction) + return lister.AllBuildingsColonistOfDef(def).FindAll(building => building.Faction == Faction.OfPlayer); + else + return lister.AllBuildingsColonistOfDef(def); + } + public static IEnumerable AllColonistBuildingsOfTypeOfPlayer(this ListerBuildings lister) + { + return lister.AllColonistBuildingsOfType().Where(t => + { + if (t is Building building && (!building.def.CanHaveFaction || building.Faction == Faction.OfPlayer)) + return true; + return false; + }); + } + static IEnumerable Transpiler(IEnumerable insts) + { + var allBuildingsColonistOfDef = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllBuildingsColonistOfDef)); + var allBuildingsColonistOfDefOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllBuildingsColonistOfDefOfPlayer)); + var allColonistBuildingsOfType = AccessTools.Method(typeof(ListerBuildings), nameof(ListerBuildings.AllColonistBuildingsOfType)).MakeGenericMethod([typeof(IHaulSource)]); + var allColonistBuildingsOfTypeOfPlayer = AccessTools.Method(typeof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch), nameof(Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch.AllColonistBuildingsOfTypeOfPlayer)).MakeGenericMethod([typeof(IHaulSource)]); + foreach (var ci in insts) + { + if (ci.Calls(allBuildingsColonistOfDef)) + { + ci.opcode = OpCodes.Call; + ci.operand = allBuildingsColonistOfDefOfPlayer; + } + else if (ci.Calls(allColonistBuildingsOfType)) + { + ci.opcode = OpCodes.Call; + ci.operand = allColonistBuildingsOfTypeOfPlayer; + } + yield return ci; + } + } +} +[HarmonyPatch] +static class TradeUtility_AllLaunchableThingsForTrade_Patch +{ + static MethodInfo TargetMethod() + { + return AccessTools.EnumeratorMoveNext(AccessTools.Method(typeof(TradeUtility), nameof(TradeUtility.AllLaunchableThingsForTrade))); + } + + public static IEnumerable AllPoweredOfPlayer(Map map) + { + return Building_OrbitalTradeBeacon.AllPowered(map).Where(beacon => beacon.Faction == Faction.OfPlayer); + } + static IEnumerable Transpiler(IEnumerable insts) + { + var allPowered = AccessTools.Method(typeof(Building_OrbitalTradeBeacon), nameof(Building_OrbitalTradeBeacon.AllPowered)); + var allPoweredOfPlayer = AccessTools.Method(typeof(TradeUtility_AllLaunchableThingsForTrade_Patch), nameof(TradeUtility_AllLaunchableThingsForTrade_Patch.AllPoweredOfPlayer)); + foreach (var ci in insts) + { + if (ci.Calls(allPowered)) + { + ci.opcode = OpCodes.Call; + ci.operand = allPoweredOfPlayer; + } + + yield return ci; + } + + } +} From ccca3fbb7a81083db3fd43ab2941e9cd182992bb Mon Sep 17 00:00:00 2001 From: Sakura-TA <52643135+Sakura-TA@users.noreply.github.com> Date: Wed, 13 May 2026 02:59:14 +0800 Subject: [PATCH 13/51] Multifaction spectator home check (#903) * fix(Determinism): force single-batch FastTileFinder.Query in MP to prevent quest site tile divergence * fix(FactionContext): handle transporters and gravship map gen faction context * fix(FactionContext): push gravship faction context during ArriveNewMap * use IsPlayer check when context is Spectator * Removed unused TargetMethod from Map_IsPlayerHome_Spectator_Patch. --------- Co-authored-by: Sakura-TA Co-authored-by: Meru --- Source/Client/Factions/MultifactionPatches.cs | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index b433562d0..e3b5447ad 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -857,6 +857,32 @@ static IEnumerable Transpiler(IEnumerable inst } } +[HarmonyPatch(typeof(Map), nameof(Map.IsPlayerHome), MethodType.Getter)] +static class Map_IsPlayerHome_Spectator_Patch +{ + static bool Prefix(Map __instance, bool __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || + Faction.OfPlayer != Multiplayer.WorldComp.spectatorFaction) + return true; + + { + // Is repeat through all player faction a better idea? + if (!__instance.wasSpawnedViaGravShipLanding) + { + MapInfo mapInfo = __instance.info; + if (((mapInfo != null) ? mapInfo.parent : null) == null || __instance.info.parent.Faction.IsPlayer == false || !__instance.info.parent.def.canBePlayerHome) + { + __result = GravshipUtility.PlayerHasGravEngine(__instance); + return false; + } + } + __result = true; + return false; + } + } +} + [HarmonyPatch] static class Pawn_TraderTracker_ColonyThingsWillingToBuy_Patch { @@ -902,6 +928,7 @@ static IEnumerable Transpiler(IEnumerable inst } } } + [HarmonyPatch] static class TradeUtility_AllLaunchableThingsForTrade_Patch { @@ -928,6 +955,5 @@ static IEnumerable Transpiler(IEnumerable inst yield return ci; } - } } From d01bfda82fad43c7b422287f4ab528bb0b44c6cd Mon Sep 17 00:00:00 2001 From: Michael <5672750+mibac138@users.noreply.github.com> Date: Tue, 12 May 2026 20:59:49 +0200 Subject: [PATCH 14/51] Typed ClientInitDataPacket.Mods (#913) * Typed ClientInitDataPacket.Mods * Refactor RemoteData Remove IConnector from it in favor of passing it around separately, and add a factory method to create it from a packet --- Source/Client/Networking/HostUtil.cs | 3 +- Source/Client/Networking/JoinData.cs | 212 ++++++++---------- .../Networking/State/ClientJoiningState.cs | 35 ++- Source/Client/UI/MainMenuPatches.cs | 15 -- Source/Client/Windows/JoinDataWindow.cs | 12 +- Source/Common/Networking/Packet/IPacket.cs | 36 +++ .../Networking/Packet/InitDataPacket.cs | 98 +++++++- .../Networking/Packet/JoinDataPacket.cs | 10 + .../Networking/State/ServerJoiningState.cs | 3 +- Source/Common/ServerInitData.cs | 15 +- Source/Common/Version.cs | 2 +- Source/Tests/Helper/TestJoiningState.cs | 2 +- .../Tests/Helper/TestLoadingKeepAliveState.cs | 2 +- Source/Tests/PacketTest.cs | 28 ++- .../ClientInitDataPacket.verified.txt | 4 +- .../ServerJoinDataPacket.verified.txt | 4 +- 16 files changed, 301 insertions(+), 180 deletions(-) diff --git a/Source/Client/Networking/HostUtil.cs b/Source/Client/Networking/HostUtil.cs index b7db1b98c..0b736217e 100644 --- a/Source/Client/Networking/HostUtil.cs +++ b/Source/Client/Networking/HostUtil.cs @@ -71,7 +71,8 @@ private static void PrepareLocalServer(ServerSettings settings, bool fromReplay) localServer.startingTimer = TickPatch.Timer; } - localServer.StartInitData().SetResult(ClientJoiningState.PackInitData(settings.syncConfigs)); + var initData = ClientJoiningState.CreateInitDataPacket(settings.syncConfigs); + localServer.StartInitData().SetResult(ServerInitData.FromNet(initData)); } private static void PrepareGame() diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index 789fee517..c123d9c2a 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -1,11 +1,12 @@ +using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using HarmonyLib; -using Ionic.Zlib; using Multiplayer.Client.Util; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; using RimWorld; using Steamworks; using Verse; @@ -18,102 +19,49 @@ public static class JoinData public static List activeModsSnapshot; public static ModFileDict modFilesSnapshot; - public static byte[] WriteServerData(bool writeConfigs) + public static List WriteServerData(bool writeConfigs) { - var data = new ByteWriter(); - - data.WriteInt32(activeModsSnapshot.Count); - foreach (var m in activeModsSnapshot) - { - data.WriteString(m.PackageIdNonUnique); - data.WriteString(m.Name); - data.WriteULong((ulong)m.GetPublishedFileId()); - data.WriteEnum(m.Source); - } - - data.WriteInt32(modFilesSnapshot.Count()); - foreach (var files in modFilesSnapshot) - { - data.WriteString(files.Key); - data.WriteInt32(files.Value.Count); - - foreach (var file in files.Value.Values) - { - data.WriteString(file.relPath); - data.WriteInt32(file.hash); - } - } - - data.WriteBool(writeConfigs); - if (writeConfigs) - { - var configs = SyncConfigs.GetSyncableConfigContents( - activeModsSnapshot.Select(m => m.PackageIdNonUnique).ToList() - ); - - data.WriteInt32(configs.Count); - foreach (var config in configs) - { - data.WriteString(config.ModId); - data.WriteString(config.FileName); - data.WriteString(config.Contents); - } - } - - return GZipStream.CompressBuffer(data.ToArray()); - } - - public static void ReadServerData(byte[] compressedData, RemoteData remoteInfo) - { - var data = new ByteReader(GZipStream.UncompressBuffer(compressedData)); - - var modCount = data.ReadInt32(); - for (int i = 0; i < modCount; i++) + var configs = writeConfigs ? SyncConfigs.GetSyncableConfigContents( + activeModsSnapshot.Select(m => m.PackageIdNonUnique).ToList() + ) : []; + return activeModsSnapshot.Select(meta => { - var packageId = data.ReadString(); - var name = data.ReadString(); - var steamId = data.ReadULong(); - var source = data.ReadEnum(); - - remoteInfo.remoteMods.Add(new ModInfo() { packageId = packageId, name = name, steamId = steamId, source = source }); - } - - var rootCount = data.ReadInt32(); - for (int i = 0; i < rootCount; i++) - { - var modId = data.ReadString(); - var mod = GetInstalledMod(modId); - var fileCount = data.ReadInt32(); - - for (int j = 0; j < fileCount; j++) + var files = modFilesSnapshot + .Where(kv => kv.Key == meta.PackageIdNonUnique) + .SelectMany(modIdToFilesPair => modIdToFilesPair.Value) + .Select(pathToFilePair => new ClientInitDataPacket.ModFile + { + path = pathToFilePair.Value.relPath, + hash = pathToFilePair.Value.hash, + }) + .ToList(); + + var localConfig = configs.FirstOrDefault(localConfig => localConfig.ModId == meta.PackageIdNonUnique); + var source = meta.Source switch { - var relPath = data.ReadString(); - var hash = data.ReadInt32(); - string absPath = null; - - if (mod != null) - absPath = Path.Combine(mod.RootDir.FullName, relPath); - - remoteInfo.remoteFiles.Add(modId, new ModFile(absPath, relPath, hash)); - } - } - - remoteInfo.hasConfigs = data.ReadBool(); - if (remoteInfo.hasConfigs) - { - var configCount = data.ReadInt32(); - for (int i = 0; i < configCount; i++) + ContentSource.Undefined => ClientInitDataPacket.ModSource.Undefined, + ContentSource.OfficialModsFolder => ClientInitDataPacket.ModSource.OfficialModsFolder, + ContentSource.ModsFolder => ClientInitDataPacket.ModSource.ModsFolder, + ContentSource.SteamWorkshop => ClientInitDataPacket.ModSource.SteamWorkshop, + _ => throw new ArgumentOutOfRangeException() + }; + + return new ClientInitDataPacket.ModData { - const int MaxConfigContentLen = 8388608; // 8 megabytes - - var modId = data.ReadString(); - var fileName = data.ReadString(); - var contents = data.ReadString(MaxConfigContentLen); - - remoteInfo.remoteModConfigs.Add(new ModConfig(modId, fileName, contents)); - //remoteInfo.remoteModConfigs[trimmedPath] = remoteInfo.remoteModConfigs[trimmedPath].Insert(0, "a"); // for testing - } - } + packageIdNonUnique = meta.PackageIdNonUnique, + name = meta.Name, + publishedFileId = (ulong)meta.GetPublishedFileId(), + source = source, + files = files, + config = localConfig == null + ? null + : new ClientInitDataPacket.ModConfig + { + fileName = localConfig.FileName, + contents = localConfig.Contents, + } + }; + }).ToList(); } public static ModMetaData GetInstalledMod(string id) @@ -208,8 +156,6 @@ public class RemoteData public IEnumerable RemoteModIds => remoteMods.Select(m => m.packageId); - public IConnector connector; - public ModListDiff CompareMods(List localMods) { var mods1 = remoteMods.Select(m => (m.packageId, m.source)); @@ -223,6 +169,49 @@ public ModListDiff CompareMods(List localMods) return ModListDiff.None; } + + public static RemoteData FromNet(ServerJoinDataPacket packet) + { + var remoteInfo = new RemoteData + { + remoteRwVersion = packet.rwVersion, + remoteMpVersion = packet.mpVersion, + hasConfigs = packet.configsIncluded, + }; + + foreach (var mod in packet.ServerInitData) + { + var modInfo = new ModInfo + { + packageId = mod.packageIdNonUnique, name = mod.name, steamId = mod.publishedFileId, + source = mod.source switch + { + ClientInitDataPacket.ModSource.Undefined => ContentSource.Undefined, + ClientInitDataPacket.ModSource.OfficialModsFolder => ContentSource.OfficialModsFolder, + ClientInitDataPacket.ModSource.ModsFolder => ContentSource.ModsFolder, + ClientInitDataPacket.ModSource.SteamWorkshop => ContentSource.SteamWorkshop, + _ => throw new ArgumentOutOfRangeException() + } + }; + remoteInfo.remoteMods.Add(modInfo); + + var modMeta = JoinData.GetInstalledMod(modInfo.packageId); + 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)); + } + + if (mod.config.HasValue) + { + var modConfig = mod.config.Value; + remoteInfo.remoteModConfigs.Add( + new ModConfig(modInfo.packageId, modConfig.fileName, modConfig.contents)); + } + } + + return remoteInfo; + } } public enum ModListDiff @@ -287,33 +276,20 @@ public struct ModInfo public bool CanSubscribe => steamId != 0; } - public struct ModFile + public struct ModFile(string absPath, string relPath, int hash) { - public string absPath; // Can be null on the remote side - public string relPath; - public int hash; + public string absPath = absPath?.NormalizePath(); // Can be null on the remote side + public string relPath = relPath.NormalizePath(); + public int hash = hash; - public ModFile(string absPath, string relPath, int hash) - { - this.absPath = absPath?.NormalizePath(); - this.relPath = relPath.NormalizePath(); - this.hash = hash; - } + public bool Equals(ModFile other) => + relPath == other.relPath && hash == other.hash; - public bool Equals(ModFile other) - { - return relPath == other.relPath && hash == other.hash; - } + public override bool Equals(object obj) => + obj is ModFile other && Equals(other); - public override bool Equals(object obj) - { - return obj is ModFile other && Equals(other); - } - - public override int GetHashCode() - { - return Gen.HashCombineInt(relPath.GetHashCode(), hash); - } + public override int GetHashCode() => + Gen.HashCombineInt(relPath.GetHashCode(), hash); } [HarmonyPatch(typeof(ModLister), nameof(ModLister.RebuildModList))] diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index 12db1c2a3..1e8a98d3a 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -50,16 +50,21 @@ public void HandleProtocolOk(ServerProtocolOkPacket packet) [TypedPacketHandler] public void HandleInitDataRequest(ServerInitDataRequestPacket packet) => - connection.SendFragmented(PackInitData(packet.includeConfigs).ToNet().Serialize()); + connection.SendFragmented(CreateInitDataPacket(packet.includeConfigs).Serialize()); - public static ServerInitData PackInitData(bool includeConfigs) => new( - JoinData.WriteServerData(includeConfigs), - VersionControl.CurrentVersionString, - Sync.handlers.Where(h => h.debugOnly).Select(h => h.syncId).ToHashSet(), - Sync.handlers.Where(h => h.hostOnly).Select(h => h.syncId).ToHashSet(), - (MultiplayerData.modCtorRoundMode, MultiplayerData.staticCtorRoundMode), - new Dictionary(MultiplayerData.localDefInfos) - ); + public static ClientInitDataPacket CreateInitDataPacket(bool includeConfigs) => new() + { + rwVersion = VersionControl.CurrentVersionString, + debugOnlySyncCmds = Sync.handlers.Where(h => h.debugOnly).Select(h => h.syncId).ToHashSet().ToArray(), + hostOnlySyncCmds = Sync.handlers.Where(h => h.hostOnly).Select(h => h.syncId).ToHashSet().ToArray(), + modCtorRoundMode = MultiplayerData.modCtorRoundMode, + staticCtorRoundMode = MultiplayerData.staticCtorRoundMode, + defInfos = MultiplayerData.localDefInfos + .Select(kv => new KeyedDefInfo { name = kv.Key, count = kv.Value.count, hash = kv.Value.hash }) + .ToArray(), + includeConfigs = includeConfigs, + Mods = JoinData.WriteServerData(includeConfigs) + }; [PacketHandler(Packets.Server_UsernameOk)] public void HandleUsernameOk(ByteReader data) => @@ -78,13 +83,6 @@ public void HandleJoinData(ServerJoinDataPacket packet) Multiplayer.session.gameName = packet.gameName; Multiplayer.session.playerId = packet.playerId; - var remoteInfo = new RemoteData - { - remoteRwVersion = packet.rwVersion, - remoteMpVersion = packet.mpVersion, - connector = Multiplayer.session.connector - }; - var defDiff = false; var defStatusMap = new Dictionary(); var i = 0; @@ -97,7 +95,7 @@ public void HandleJoinData(ServerJoinDataPacket packet) defDiff = true; } - JoinData.ReadServerData(packet.rawServerInitData, remoteInfo); + var remoteInfo = RemoteData.FromNet(packet); // Delay showing the window for better UX OnMainThread.Schedule(Complete, 0.3f); @@ -119,7 +117,8 @@ void Complete() .Take(10) .Join(kv => $"{kv.name}: {kv.status}", "\n"); - Find.WindowStack.Add(new JoinDataWindow(remoteInfo){ + Find.WindowStack.Add(new JoinDataWindow(remoteInfo, Multiplayer.session.connector) + { connectAnywayDisabled = defDiff ? "MpMismatchDefsDiff".Translate() + defDiffStr : null, connectAnywayCallback = StartDownloading }); diff --git a/Source/Client/UI/MainMenuPatches.cs b/Source/Client/UI/MainMenuPatches.cs index 321939342..246b66e80 100644 --- a/Source/Client/UI/MainMenuPatches.cs +++ b/Source/Client/UI/MainMenuPatches.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Reflection; using HarmonyLib; using Multiplayer.Client.Saving; @@ -126,20 +125,6 @@ static void Prefix(Rect rect, List optList) } } - static void ShowModDebugInfo() - { - return; - - var info = new RemoteData(); - JoinData.ReadServerData(JoinData.WriteServerData(true), info); - for (int i = 0; i < 200; i++) - info.remoteMods.Add(info.remoteMods.Last()); - info.remoteFiles.Add("rwmt.multiplayer", new ModFile() { relPath = "/Test/Test.xml" }); - //info.remoteFiles.Add("ludeon.rimworld", new ModFile() { relPath = "/Test/Test.xml" }); - - Find.WindowStack.Add(new JoinDataWindow(info)); - } - public static void AskQuitToMainMenu() { if (Multiplayer.LocalServer == null) diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index 2e6f47599..fb891cc22 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -47,6 +47,7 @@ enum NodeStatus } private RemoteData remote; + private IConnector connector; private Node filesRoot; private Node configsRoot; public string connectAnywayDisabled; @@ -54,9 +55,10 @@ enum NodeStatus private ModFileDict filesForUI; private ModListDiff modListDiff; - public JoinDataWindow(RemoteData remote) + public JoinDataWindow(RemoteData remote, IConnector connector) { this.remote = remote; + this.connector = connector; closeOnAccept = false; closeOnCancel = false; @@ -253,7 +255,7 @@ void RefreshFiles() } if (MpUI.ButtonTextWithTip(btnCenter, "MpFixAndRestart".Translate(), "MpRestartNeeded".Translate())) - Find.WindowStack.Add(new FixAndRestartWindow(remote)); + Find.WindowStack.Add(new FixAndRestartWindow(remote, connector)); if (Widgets.ButtonText(btnCenter.Right(150f), "MpMismatchQuit".Translate())) { @@ -678,14 +680,16 @@ void DrawModListItem(Vector2 topLeft, string name, string tip, ContentSource sou public class FixAndRestartWindow : Window { private RemoteData data; + private IConnector connector; private bool applyModList = true; private bool applyConfigs; public override Vector2 InitialSize => new(400, 200); - public FixAndRestartWindow(RemoteData data) + public FixAndRestartWindow(RemoteData data, IConnector connector) { this.data = data; + this.connector = connector; applyConfigs = data.hasConfigs; closeOnAccept = false; @@ -753,7 +757,7 @@ private void DoRestart() SyncConfigs.MarkApplicableForChildProcess(); } - AutoJoinHandler.SetForChildProcess(data.connector); + AutoJoinHandler.SetForChildProcess(connector); GenCommandLine.Restart(); } } diff --git a/Source/Common/Networking/Packet/IPacket.cs b/Source/Common/Networking/Packet/IPacket.cs index 79f943763..1276ef9df 100644 --- a/Source/Common/Networking/Packet/IPacket.cs +++ b/Source/Common/Networking/Packet/IPacket.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.Compression; namespace Multiplayer.Common.Networking.Packet; @@ -67,6 +69,9 @@ public static Binder Enum() where T: Enum => public static Binder String() => (PacketBuffer buf, ref string obj) => buf.Bind(ref obj); + + public static Binder> List(Binder itemBinder) => + (PacketBuffer buf, ref List obj) => buf.Bind(ref obj, itemBinder); } public static class BinderExtensions @@ -84,6 +89,37 @@ public static T Deserialize(this Binder binder, byte[] src) binder(new PacketReader(new ByteReader(src)), ref obj); return obj; } + + public static Binder Gzipped(this Binder inner, int maxLength = PacketBuffer.DefaultMaxLength) => + (PacketBuffer buf, ref T obj) => + { + if (buf.isWriting) + { + var writer = new ByteWriter(); + inner(new PacketWriter(writer), ref obj); + + using var outputStream = new MemoryStream(); + using (var compress = new DeflateStream(outputStream, CompressionLevel.Optimal)) + { + compress.Write(writer.ToArray(), 0, writer.Position); + } + var compressed = outputStream.ToArray(); + buf.BindBytes(ref compressed, maxLength); + } + else + { + byte[] compressed = []; + buf.BindBytes(ref compressed, maxLength); + using var inputStream = new MemoryStream(compressed); + using var outputStream = new MemoryStream(); + using (var decompress = new DeflateStream(inputStream, CompressionMode.Decompress)) + { + decompress.CopyTo(outputStream); + } + var reader = new ByteReader(outputStream.ToArray()); + inner(new PacketReader(reader), ref obj); + } + }; } public abstract class PacketBuffer(bool isWriting) diff --git a/Source/Common/Networking/Packet/InitDataPacket.cs b/Source/Common/Networking/Packet/InitDataPacket.cs index 3be13b793..ecec5699c 100644 --- a/Source/Common/Networking/Packet/InitDataPacket.cs +++ b/Source/Common/Networking/Packet/InitDataPacket.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Server_InitDataRequest)] @@ -23,7 +25,14 @@ public record struct ClientInitDataPacket : IPacket public RoundModeEnum modCtorRoundMode; public RoundModeEnum staticCtorRoundMode; public KeyedDefInfo[] defInfos; - public byte[] rawData; + public bool includeConfigs; + public byte[] rawMods; + + public List Mods + { + get => ModData.ListBinder.Deserialize(rawMods); + set => rawMods = ModData.ListBinder.Serialize(value); + } public void Bind(PacketBuffer buf) { @@ -33,6 +42,91 @@ public void Bind(PacketBuffer buf) buf.BindEnum(ref modCtorRoundMode); buf.BindEnum(ref staticCtorRoundMode); buf.Bind(ref defInfos, BinderOf.Identity()); - buf.BindRemaining(ref rawData, maxLength: MaxRawDataLength); + buf.Bind(ref includeConfigs); + buf.BindRemaining(ref rawMods); + } + + // Based on ContentSource but a byte, so smaller on the network and also doesn't use Verse + // (which is unavailable in the standalone server) + public enum ModSource : byte + { + Undefined, + OfficialModsFolder, + ModsFolder, + SteamWorkshop, + } + + public record struct ModData : IPacketBufferable + { + public string packageIdNonUnique; + public string name; + public ulong publishedFileId; + public ModSource source; + public List files; + public ModConfig? config; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref packageIdNonUnique); + buf.Bind(ref name); + buf.Bind(ref publishedFileId); + buf.BindEnum(ref source); + buf.Bind(ref files, BinderOf.Identity()); + buf.BindWith(ref config, ConfigBinder); + } + + private static readonly Binder ConfigBinder = (PacketBuffer buf, ref ModConfig? modConfig) => + { + if (buf.isWriting) + { + var present = modConfig.HasValue; + buf.Bind(ref present); + if (present) + { + ModConfig config = modConfig.Value; + buf.Bind(ref config); + } + } + else + { + var present = false; + buf.Bind(ref present); + if (present) + { + ModConfig config = new(); + buf.Bind(ref config); + modConfig = config; + } + } + }; + + public static Binder> ListBinder => + BinderOf.List(BinderOf.Identity()).Gzipped(MaxRawDataLength); + } + + public record struct ModFile : IPacketBufferable + { + public string path; + public int hash; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref path); + buf.Bind(ref hash); + } + } + + public record struct ModConfig : IPacketBufferable + { + public string fileName; + public string contents; + + private const int MaxConfigContentLen = 8388608; // 8 MiB + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref fileName); + buf.Bind(ref contents, maxLength:MaxConfigContentLen); + } } } diff --git a/Source/Common/Networking/Packet/JoinDataPacket.cs b/Source/Common/Networking/Packet/JoinDataPacket.cs index 641d493f1..507b59f37 100644 --- a/Source/Common/Networking/Packet/JoinDataPacket.cs +++ b/Source/Common/Networking/Packet/JoinDataPacket.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Server_JoinData, allowFragmented: true)] @@ -8,8 +10,15 @@ public record struct ServerJoinDataPacket : IPacket public string rwVersion; public string mpVersion; public DefCheckStatus[] defStatus; + public bool configsIncluded; public byte[] rawServerInitData; + public List ServerInitData + { + get => ClientInitDataPacket.ModData.ListBinder.Deserialize(rawServerInitData); + set => rawServerInitData = ClientInitDataPacket.ModData.ListBinder.Serialize(value); + } + public void Bind(PacketBuffer buf) { buf.Bind(ref gameName); @@ -17,6 +26,7 @@ public void Bind(PacketBuffer buf) buf.Bind(ref rwVersion); buf.Bind(ref mpVersion); buf.Bind(ref defStatus, BinderOf.Enum()); + buf.Bind(ref configsIncluded); // Max 512KiB. Should be way more than enough. As an example, one game with ~100 mods used ~35KiB. buf.BindRemaining(ref rawServerInitData, maxLength: 1<<19); } diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index 44d383f71..c97410d11 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -159,7 +159,8 @@ private bool HandleClientJoinData(ClientJoinDataPacket packet) rwVersion = serverInitData.RwVersion, mpVersion = MpVersion.Version, defStatus = defStatus, - rawServerInitData = serverInitData.RawData + configsIncluded = serverInitData.IncludeConfigs, + rawServerInitData = serverInitData.RawData, }.Serialize()); if (Server.BootstrapMode) diff --git a/Source/Common/ServerInitData.cs b/Source/Common/ServerInitData.cs index aeb59f514..730a1afbb 100644 --- a/Source/Common/ServerInitData.cs +++ b/Source/Common/ServerInitData.cs @@ -6,6 +6,7 @@ namespace Multiplayer.Common; public record ServerInitData( byte[] RawData, + bool IncludeConfigs, string RwVersion, HashSet DebugOnlySyncCmds, HashSet HostOnlySyncCmds, @@ -13,20 +14,8 @@ public record ServerInitData( Dictionary DefInfos ) { - public ClientInitDataPacket ToNet() => new() - { - rwVersion = RwVersion, - debugOnlySyncCmds = DebugOnlySyncCmds.ToArray(), - hostOnlySyncCmds = HostOnlySyncCmds.ToArray(), - modCtorRoundMode = RoundModes.Item1, - staticCtorRoundMode = RoundModes.Item2, - defInfos = DefInfos.Select(kv => new KeyedDefInfo - { name = kv.Key, count = kv.Value.count, hash = kv.Value.hash }).ToArray(), - rawData = RawData - }; - public static ServerInitData FromNet(ClientInitDataPacket packet) => new( - packet.rawData, packet.rwVersion, + packet.rawMods, packet.includeConfigs, packet.rwVersion, packet.debugOnlySyncCmds.ToHashSet(), packet.hostOnlySyncCmds.ToHashSet(), (packet.modCtorRoundMode, packet.staticCtorRoundMode), diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index be9f18550..68009be35 100644 --- a/Source/Common/Version.cs +++ b/Source/Common/Version.cs @@ -6,7 +6,7 @@ namespace Multiplayer.Common public static class MpVersion { public const string SimpleVersion = "0.11.5"; - public const int Protocol = 54; + public const int Protocol = 55; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/Tests/Helper/TestJoiningState.cs b/Source/Tests/Helper/TestJoiningState.cs index f0b1d4bb7..5121b0cc5 100644 --- a/Source/Tests/Helper/TestJoiningState.cs +++ b/Source/Tests/Helper/TestJoiningState.cs @@ -30,7 +30,7 @@ protected override async Task RunState() modCtorRoundMode = RoundModeEnum.ToNearest, staticCtorRoundMode = RoundModeEnum.ToNearest, defInfos = [], - rawData = [] + rawMods = [] }); var p = await Packet(Packets.Server_UsernameOk); diff --git a/Source/Tests/Helper/TestLoadingKeepAliveState.cs b/Source/Tests/Helper/TestLoadingKeepAliveState.cs index e7298763c..c0bb37ef4 100644 --- a/Source/Tests/Helper/TestLoadingKeepAliveState.cs +++ b/Source/Tests/Helper/TestLoadingKeepAliveState.cs @@ -30,7 +30,7 @@ protected override async Task RunState() modCtorRoundMode = RoundModeEnum.ToNearest, staticCtorRoundMode = RoundModeEnum.ToNearest, defInfos = [], - rawData = [] + rawMods = [] }); var packet = await Packet(Packets.Server_UsernameOk); diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index 9ad9dd66e..c98994699 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -187,6 +187,15 @@ private static IEnumerable RoundtripPackets() ] }; + var mpModData = new ClientInitDataPacket.ModData + { + name = "Multiplayer", + packageIdNonUnique = "rwmt.multiplayer", + source = ClientInitDataPacket.ModSource.SteamWorkshop, + config = null, + files = [], + publishedFileId = 0, + }; yield return new ServerJoinDataPacket { gameName = "GameName", @@ -198,7 +207,8 @@ private static IEnumerable RoundtripPackets() DefCheckStatus.Ok, DefCheckStatus.Ok, DefCheckStatus.Count_Diff, DefCheckStatus.Hash_Diff, DefCheckStatus.Not_Found ], - rawServerInitData = [1, 2, 3, 4, 5] + configsIncluded = false, + ServerInitData = [mpModData], }; yield return new ClientFrameTimePacket(0f); @@ -220,7 +230,7 @@ private static IEnumerable RoundtripPackets() new KeyedDefInfo { name = "key", count = 1, hash = 123 }, new KeyedDefInfo { name = "key2", count = 0, hash = 0 } ], - rawData = [1, 2, 3, 4, 5] + Mods = [mpModData], }; // real code is using GZip compressed content for the traces, but we are only testing on the wire representation @@ -244,6 +254,13 @@ private static IEnumerable RoundtripPackets() yield return new ClientDesyncedPacket(100, 0); } + private static readonly List UnstablePackets = [ + // Uses deflate compression which *is* lossless, but it is not + // guaranteed to always be represented by the same bytes + typeof(ClientInitDataPacket), + typeof(ServerJoinDataPacket) + ]; + [TestCaseSource(nameof(RoundtripPackets))] public void TestRoundtrip(IPacket original) { @@ -262,6 +279,11 @@ public async Task SnapshotBinaryRepresentation() { var binder = RuntimeBinderOf(packetsOfType.Key); var text = new StringBuilder(); + var stable = !UnstablePackets.Contains(packetsOfType.Key); + if (!stable) + text.Append("This packet is not byte-stable while serialized, meaning it can be serialized" + + " differently due to various factors, but it does deserialize into the same object\n\n"); + foreach (var packet in packetsOfType) { var serialized = binder.Serialize(packet); @@ -272,7 +294,7 @@ public async Task SnapshotBinaryRepresentation() } await Verify(text).UseDirectory("packet-serializations").UseFileName(packetsOfType.Key.Name).DisableDiff() - .AutoVerify(includeBuildServer: false); + .AutoVerify(includeBuildServer: !stable); } } diff --git a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt index 04a42934b..b87199a17 100644 --- a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt @@ -1 +1,3 @@ -05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-01-02-03-04-05 (81 bytes) +This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object + +05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-00-24-00-00-00-63-64-60-60-10-00-E2-A2-F2-DC-12-BD-DC-D2-9C-92-CC-82-9C-C4-CA-D4-22-6E-A0-98-2F-82-0B-E4-81-01-33-98-04-00 (117 bytes) diff --git a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt index e7915cf1e..98e950920 100644 --- a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt @@ -1 +1,3 @@ -08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-01-02-03-04-05 (59 bytes) +This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object + +08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-00-24-00-00-00-63-64-60-60-10-00-E2-A2-F2-DC-12-BD-DC-D2-9C-92-CC-82-9C-C4-CA-D4-22-6E-A0-98-2F-82-0B-E4-81-01-33-98-04-00 (95 bytes) From 5d273b320540f5fab366d4e8c719ca7a5b7b1b54 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Thu, 14 May 2026 23:43:35 +0200 Subject: [PATCH 15/51] package dedicated server beta artifacts for Windows and Linux (#918) * ci: add standalone server zip to continuous release * Potential fix for pull request finding Avoid double build Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * ci: package server beta artifacts for Windows and Linux --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/alpha-notes.md | 11 +++++++---- .github/workflows/build-beta.yml | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/alpha-notes.md b/.github/workflows/alpha-notes.md index 14edb45e4..f8ec5c59f 100644 --- a/.github/workflows/alpha-notes.md +++ b/.github/workflows/alpha-notes.md @@ -14,16 +14,19 @@ * You should have a `Multiplayer` folder in the `Mods` folder (`Mods/Multiplayer`) * Make sure you do not have this directory structure: `Mods/Multiplayer-beta/Multiplayer`. If you do, move the `Multiplayer` folder to the parent directory. ---- - #### Standalone server Download `Server-beta.zip` if you want to host a dedicated standalone server for testing. **Setup** 1. Download and extract `Server-beta.zip`. -2. Run `Server.exe` (Windows) or `dotnet Server.dll` (Linux/Mac) from the extracted folder. -3. The server will start and wait for the first connection. +2. Open the folder for your platform: + - `Server/Windows` + - `Server/Linux` +3. Start the server using: + - Windows: `Server.exe` + - Linux: `./Server.sh` +4. The server will start and wait for the first connection. **First-time configuration (bootstrap)** No manual configuration files are required. diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 548bd8411..d4e2db162 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -34,8 +34,22 @@ jobs: - name: Build Mod run: dotnet build ${{ env.SLN_PATH }} --configuration Release --no-restore - - name: Publish Server - run: dotnet publish ${{ env.SLN_PATH }}Server/Server.csproj --configuration Release --no-restore --no-build -o output/Server + - name: Publish Server (Windows) + run: dotnet publish ${{ env.SLN_PATH }}Server/Server.csproj --configuration Release --runtime win-x64 --self-contained false -p:UseAppHost=true -o output/Server/Windows + + - name: Publish Server (Linux) + run: dotnet publish ${{ env.SLN_PATH }}Server/Server.csproj --configuration Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -o output/Server/Linux + + - name: Add Server Launchers + run: | + cat > output/Server/Linux/Server.sh <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + exec dotnet "$SCRIPT_DIR/Server.dll" "$@" + EOF + + chmod +x output/Server/Linux/Server.sh - name: Package files run: | From b38a8828488b1fe7c6201e429c728455fd55a06f Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 15 May 2026 00:46:16 +0200 Subject: [PATCH 16/51] Clean up standalone save persistence review noise (#920) --- Source/Client/ConstantTicker.cs | 3 +- Source/Client/Patches/VTRSyncPatch.cs | 4 +-- Source/Client/Saving/SaveLoad.cs | 9 +++-- .../Networking/State/ServerPlayingState.cs | 7 ++-- Source/Tests/StandaloneMapStreamingTest.cs | 34 +++++++++++++++++++ 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs index 00b6f249b..214ce1e33 100644 --- a/Source/Client/ConstantTicker.cs +++ b/Source/Client/ConstantTicker.cs @@ -94,7 +94,8 @@ private static void TickAutosave() session.autosaveCounter = 0; Autosaving.DoAutosave(); } - } else if (server.settings.autosaveUnit == AutosaveUnit.Days && server.settings.autosaveInterval > 0) + } + else if (server.settings.autosaveUnit == AutosaveUnit.Days && server.settings.autosaveInterval > 0) { var anyMapCounterUp = Multiplayer.game.mapComps diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index 9e9ac2ca6..582f1c257 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -16,8 +16,8 @@ static bool Prefix(Thing thing, ref int __result) if (Multiplayer.Client == null) return true; - // TODO: Put this back to the original value - // Probably need to sync up all the animations before doing this + // Keep the synchronized update rate until animation timing can be + // brought back in line with the vanilla value. __result = VTRSync.GetSynchronizedUpdateRate(thing); return false; } diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 933ecd609..e33a5d151 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -25,7 +25,6 @@ public static TempGameData SaveAndReload() Multiplayer.reloading = true; var worldGridSaved = Find.WorldGrid; - var worldRendererSaved = Find.World.renderer; var tweenedPos = new Dictionary(); var drawers = new Dictionary(); var localFactionId = Multiplayer.RealPlayerFaction.loadID; @@ -59,10 +58,10 @@ public static TempGameData SaveAndReload() gameData = SaveGameData(); } - // TODO - //MapDrawerRegenPatch.copyFrom = drawers; - //WorldGridCachePatch.copyFrom = worldGridSaved; - //WorldGridExposeDataPatch.copyFrom = worldGridSaved; + MapDrawerRegenPatch.copyFrom = drawers; + WorldGridCachePatch.copyFrom = worldGridSaved; + WorldGridExposeDataPatch.copyFrom = worldGridSaved; + WorldRendererCachePatch.copyFrom = worldGridSaved; MusicManagerPlay musicManager = null; if (Find.MusicManagerPlay.gameObjectCreated) diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index 93a9fab82..39b752158 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -66,9 +66,11 @@ public void HandleChat(ClientChatPacket packet) string msg = packet.msg; msg = msg.Trim(); - // todo handle max length if (msg.Length == 0) return; + if (msg.Length > MaxChatMsgLength) + msg = msg[..MaxChatMsgLength]; + if (msg[0] == '/') { var cmd = msg[1..]; @@ -230,7 +232,8 @@ public void HandleAutosaving(ClientAutosavingPacket packet) [TypedPacketHandler] public void HandleDebug(ClientDebugPacket _) { - // todo restrict handling + if (!Server.commands.CanUseDevMode(Player)) + return; Server.worldData.mapCmds.Clear(); Server.gameTimer = Server.startingTimer; diff --git a/Source/Tests/StandaloneMapStreamingTest.cs b/Source/Tests/StandaloneMapStreamingTest.cs index 37cf4393d..6676875b1 100644 --- a/Source/Tests/StandaloneMapStreamingTest.cs +++ b/Source/Tests/StandaloneMapStreamingTest.cs @@ -140,4 +140,38 @@ public void MapToMapTransition_DoesNotSendMapResponseWhenStreamingDisabled() Assert.That(player.currentMapId, Is.EqualTo(5)); Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_MapResponse)); } + + [Test] + public void HandleDebug_IgnoredWhenDevModeDisabled() + { + server.gameTimer = 123; + server.startingTimer = 5; + server.worldData.mapCmds[1] = [[1]]; + var (player, conn) = AddPlayer("player", 1); + + var state = player.conn.GetState()!; + state.HandleDebug(new ClientDebugPacket()); + + Assert.That(server.gameTimer, Is.EqualTo(123)); + Assert.That(server.worldData.mapCmds[1], Has.Count.EqualTo(1)); + Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_Debug)); + } + + [Test] + public void HandleDebug_AllowsPlayersWhenDevModeEnabled() + { + server.settings.debugMode = true; + server.settings.devModeScope = DevModeScope.Everyone; + server.gameTimer = 123; + server.startingTimer = 5; + server.worldData.mapCmds[1] = [[1]]; + var (player, conn) = AddPlayer("player", 1); + + var state = player.conn.GetState()!; + state.HandleDebug(new ClientDebugPacket()); + + Assert.That(server.gameTimer, Is.EqualTo(5)); + Assert.That(server.worldData.mapCmds, Is.Empty); + Assert.That(conn.SentPackets, Does.Contain(Packets.Server_Debug)); + } } From 3bfbe129f3569595b92c64e491f5744f99a00546 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 15 May 2026 10:48:09 +0200 Subject: [PATCH 17/51] Remove standalone bootstrap enforcement (#921) --- .../Windows/BootstrapConfiguratorWindow.SettingsUi.cs | 3 --- Source/Client/Windows/BootstrapConfiguratorWindow.cs | 1 - Source/Common/ServerSettings.cs | 6 ------ Source/Server/Server.cs | 2 -- 4 files changed, 12 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs index c077858e1..77ecb3da1 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.SettingsUi.cs @@ -44,8 +44,6 @@ private void DrawSettings(Rect entry, Rect inRect) else if (tab == Tab.Gameplay) ServerSettingsUI.DrawGameplaySettingsOnly(contentRect, settings, buffers); - settings.EnforceStandaloneRequirements(); - settingsUiBuffers.MaxPlayersBuffer = buffers.MaxPlayersBuffer; settingsUiBuffers.AutosaveBuffer = buffers.AutosaveBuffer; @@ -123,7 +121,6 @@ private void StartUploadSettingsToml() try { - settings.EnforceStandaloneRequirements(); connection.Send(new ClientBootstrapSettingsPacket(settings)); } catch (System.Exception e) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 4e665f47d..f82e60aae 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -71,7 +71,6 @@ public BootstrapConfiguratorWindow(ConnectionBase connection, BootstrapServerSta settings.steam = false; settings.arbiter = false; - settings.EnforceStandaloneRequirements(); settingsUiBuffers.MaxPlayersBuffer = settings.maxPlayers.ToString(); settingsUiBuffers.AutosaveBuffer = settings.autosaveInterval.ToString(); diff --git a/Source/Common/ServerSettings.cs b/Source/Common/ServerSettings.cs index 8c9237415..66b27aa3d 100644 --- a/Source/Common/ServerSettings.cs +++ b/Source/Common/ServerSettings.cs @@ -32,12 +32,6 @@ public class ServerSettings public bool pauseOnDesync = true; public TimeControl timeControl; - public void EnforceStandaloneRequirements() - { - if (multifaction) - asyncTime = true; - } - public string? TryParseEndpoints(out IPEndPoint[] endpoints) { var split = directAddress.Split(MultiplayerServer.EndpointSeparator); diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index c9edeb66b..fa83c5716 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -19,8 +19,6 @@ settings = TomlSettings.Load(settingsFile); else ServerLog.Log($"Bootstrap mode: '{settingsFile}' not found. Waiting for a client to upload it."); - -settings.EnforceStandaloneRequirements(); ServerLog.detailEnabled = settings.debugMode; ServerLog.verboseEnabled = settings.debugMode; From 76eed6e9d68a9d4f60391101fb26ddf6caef8466 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 15 May 2026 10:48:56 +0200 Subject: [PATCH 18/51] Fix bootstrap window reopening over scenario (#922) --- .../Patches/BootstrapStartedNewGamePatch.cs | 18 ------------------ ...ootstrapConfiguratorWindow.BootstrapFlow.cs | 2 +- 2 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 Source/Client/Patches/BootstrapStartedNewGamePatch.cs diff --git a/Source/Client/Patches/BootstrapStartedNewGamePatch.cs b/Source/Client/Patches/BootstrapStartedNewGamePatch.cs deleted file mode 100644 index 1434a71db..000000000 --- a/Source/Client/Patches/BootstrapStartedNewGamePatch.cs +++ /dev/null @@ -1,18 +0,0 @@ -using HarmonyLib; -using Verse; - -namespace Multiplayer.Client; - -[HarmonyPatch(typeof(GameComponentUtility), nameof(GameComponentUtility.StartedNewGame))] -static class BootstrapStartedNewGamePatch -{ - static void Postfix() - { - var window = BootstrapConfiguratorWindow.Instance; - if (window == null) - return; - - BootstrapConfiguratorWindow.AwaitingBootstrapMapInit = true; - OnMainThread.Enqueue(window.OnBootstrapMapInitialized); - } -} \ No newline at end of file diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs index 971234f6e..e2ff80c15 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs @@ -175,7 +175,7 @@ public void OnBootstrapMapInitialized() postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds; awaitingControllablePawns = true; bootstrapSaveQueued = false; - saveUploadStatus = "Map initialized. Waiting before saving..."; + saveUploadStatus = "Map initialized. Waiting for controllable colonists to spawn..."; if (Find.WindowStack.WindowOfType() == null) Find.WindowStack.Add(this); From a331d7162c888ed8c6cacd92394163953955a037 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 15 May 2026 19:05:14 +0200 Subject: [PATCH 19/51] Fix bootstrap world map rotation (#924) From dd7259ad5e30d8973fc9db5e6e3db7b170f89b8b Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 15 May 2026 19:05:59 +0200 Subject: [PATCH 20/51] Reset bootstrap client mode after save upload (#923) --- ...otstrapConfiguratorWindow.BootstrapFlow.cs | 3 ++ .../Windows/BootstrapConfiguratorWindow.cs | 32 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs index e2ff80c15..6a4e244f2 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs @@ -24,6 +24,7 @@ public partial class BootstrapConfiguratorWindow private bool saveReady; private bool isUploadingSave; private bool saveUploadAutoStarted; + private bool closeBootstrapModeOnDisconnect; private string savedReplayPath; private string saveUploadStatus; private float saveUploadProgress; @@ -380,6 +381,7 @@ private void StartUploadSaveZip() { try { + closeBootstrapModeOnDisconnect = true; connection.SendFragmented(new ClientBootstrapSaveDataPacket(saveData, hash).Serialize()); OnMainThread.Enqueue(() => @@ -392,6 +394,7 @@ private void StartUploadSaveZip() { OnMainThread.Enqueue(() => { + closeBootstrapModeOnDisconnect = false; isUploadingSave = false; saveUploadStatus = $"Failed to upload save.zip: {exception.GetType().Name}: {exception.Message}"; }); diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index f82e60aae..2aaf63c8a 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -106,6 +106,35 @@ public override void PostClose() Instance = null; } + private void ExitBootstrapMode(bool clearPendingUploadState) + { + closeBootstrapModeOnDisconnect = false; + retainInstanceOnClose = false; + + ResetTransientUiState(resetServerDrivenState: true); + + isUploadingToml = false; + uploadProgress = 0f; + statusText = null; + settingsUploaded = false; + saveReady = false; + savedReplayPath = null; + saveUploadStatus = null; + saveUploadProgress = 0f; + bootstrapState = BootstrapServerState.None; + + if (clearPendingUploadState) + pendingUploadState = null; + + if (Current.Game?.components != null) + Current.Game.components.RemoveAll(component => component is Comp.BootstrapCoordinator); + + if (Find.WindowStack?.Windows.Contains(this) == true) + Find.WindowStack.TryRemove(this); + else if (ReferenceEquals(Instance, this)) + Instance = null; + } + internal void ResetTransientUiState(bool resetServerDrivenState = false) { AwaitingBootstrapMapInit = false; @@ -233,7 +262,6 @@ public void Connected() public void Disconnected(SessionDisconnectInfo info) { - ResetTransientUiState(resetServerDrivenState: true); - Find.WindowStack.TryRemove(this); + ExitBootstrapMode(clearPendingUploadState: closeBootstrapModeOnDisconnect); } } From 9d938d07b22ac166f19721c0ebc09be85afdd6ee Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 15 May 2026 19:07:23 +0200 Subject: [PATCH 21/51] Delay bootstrap reconnect until main menu (#925) --- ...otstrapConfiguratorWindow.BootstrapFlow.cs | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs index 6a4e244f2..06ce06083 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs @@ -335,19 +335,27 @@ private void FinalizeBootstrapSave(string path) private void ReturnToMenuAndReconnect() { GenScene.GoToMainMenu(); - OnMainThread.Enqueue(() => + LongEventHandler.ExecuteWhenFinished(ReconnectAfterReturningToMenu); + } + + private void ReconnectAfterReturningToMenu() + { + if (Current.ProgramState != ProgramState.Entry || Current.Game != null) { - saveUploadStatus = "Reconnecting to upload save..."; - Multiplayer.StopMultiplayer(); + saveUploadStatus = "Waiting to finish returning to menu..."; + LongEventHandler.ExecuteWhenFinished(ReconnectAfterReturningToMenu); + return; + } - if (reconnectConnector == null) - { - saveUploadStatus = "No connector available to reconnect to the bootstrap server."; - return; - } + saveUploadStatus = "Reconnecting to upload save..."; + + if (reconnectConnector == null) + { + saveUploadStatus = "No connector available to reconnect to the bootstrap server."; + return; + } - ClientUtil.TryConnectWithWindow(reconnectConnector, false); - }); + ClientUtil.TryConnectWithWindow(reconnectConnector, false); } private void StartUploadSaveZip() From cbf907f85cf2c0b59e1970a4e32a4feac036e617 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Tue, 19 May 2026 07:43:24 +0200 Subject: [PATCH 22/51] Fix render burn (#926) * Fix render burn * Address SaveAndReload review --- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 2 +- Source/Client/Saving/SaveLoad.cs | 20 ++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index b749e2c6e..6be82d6af 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -276,7 +276,7 @@ public void ExecuteCmd(ScheduledCommand cmd) private static void CreateJoinPointAndSendIfHost() { - Multiplayer.session.dataSnapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveAndReload(), Multiplayer.GameComp.multifaction); + Multiplayer.session.dataSnapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveAndReload(true), Multiplayer.GameComp.multifaction); if (!TickPatch.Simulating && !Multiplayer.IsReplay) { diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index e33a5d151..a625b5669 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -20,7 +20,7 @@ public record TempGameData(XmlDocument SaveData, byte[] SessionData); public static class SaveLoad { - public static TempGameData SaveAndReload() + public static TempGameData SaveAndReload(bool cache = false) { Multiplayer.reloading = true; @@ -58,10 +58,20 @@ public static TempGameData SaveAndReload() gameData = SaveGameData(); } - MapDrawerRegenPatch.copyFrom = drawers; - WorldGridCachePatch.copyFrom = worldGridSaved; - WorldGridExposeDataPatch.copyFrom = worldGridSaved; - WorldRendererCachePatch.copyFrom = worldGridSaved; + if (cache) + { + MapDrawerRegenPatch.copyFrom = drawers; + WorldGridCachePatch.copyFrom = worldGridSaved; + WorldGridExposeDataPatch.copyFrom = worldGridSaved; + WorldRendererCachePatch.copyFrom = worldGridSaved; + } + else + { + MapDrawerRegenPatch.copyFrom.Clear(); + WorldGridCachePatch.copyFrom = null; + WorldGridExposeDataPatch.copyFrom = null; + WorldRendererCachePatch.copyFrom = null; + } MusicManagerPlay musicManager = null; if (Find.MusicManagerPlay.gameObjectCreated) From 1e00080d02d0f3f8aa00e53c46cb75b528f84dc1 Mon Sep 17 00:00:00 2001 From: Meru Date: Tue, 19 May 2026 00:43:46 -0500 Subject: [PATCH 23/51] Allow Properties in RegisterSyncField (#917) --- Source/Client/Syncing/Sync.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Source/Client/Syncing/Sync.cs b/Source/Client/Syncing/Sync.cs index 678830aa1..50b7cc7be 100644 --- a/Source/Client/Syncing/Sync.cs +++ b/Source/Client/Syncing/Sync.cs @@ -57,12 +57,13 @@ public static SyncField[] Fields(Type targetType, string instancePath, params st public static ISyncField RegisterSyncField(Type targetType, string fieldName) { - FieldInfo field = AccessTools.Field(targetType, fieldName) + MemberInfo field = AccessTools.Field(targetType, fieldName) as MemberInfo + ?? AccessTools.Property(targetType, fieldName) ?? throw new Exception($"Couldn't find field {targetType}::{fieldName}"); SyncField sf; string memberPath; - if (field.IsStatic) { + if (field.IsStatic()) { memberPath = field.ReflectedType + "/" + field.Name; sf = Field(null, null, memberPath); } else { From fa37fddca96ce62fefebf83b5d441b29880e80fd Mon Sep 17 00:00:00 2001 From: MhaWay Date: Wed, 20 May 2026 03:46:55 +0200 Subject: [PATCH 24/51] Use shared entry transition for bootstrap reconnect (#929) * Use shared entry transition for bootstrap reconnect * Add missing System import for Rejoiner helper --- Source/Client/Session/Rejoiner.cs | 31 +++++++++++-------- ...otstrapConfiguratorWindow.BootstrapFlow.cs | 27 +++++++++++----- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/Source/Client/Session/Rejoiner.cs b/Source/Client/Session/Rejoiner.cs index a69c0ff36..1d1f4c489 100644 --- a/Source/Client/Session/Rejoiner.cs +++ b/Source/Client/Session/Rejoiner.cs @@ -1,4 +1,5 @@ -using Multiplayer.Client.Util; +using System; +using Multiplayer.Client.Util; using Multiplayer.Common; using Verse; using Verse.Profile; @@ -7,18 +8,8 @@ namespace Multiplayer.Client; public static class Rejoiner { - public static void DoRejoin() + public static void ReturnToEntry(Action onFinished) { - Multiplayer.Client.Send(Packets.Client_RequestRejoin); - - Multiplayer.Client.ChangeState(ConnectionStateEnum.ClientLoading); - Multiplayer.Client.Lenient = true; - - Multiplayer.session.desynced = false; - - Log.Message("Multiplayer: rejoining"); - - // From GenScene.GoToMainMenu LongEventHandler.ClearQueuedEvents(); LongEventHandler.QueueLongEvent(() => { @@ -28,8 +19,22 @@ public static void DoRejoin() LongEventHandler.ExecuteWhenFinished(() => { MpUI.ClearWindowStack(); - Find.WindowStack.Add(new RejoiningWindow()); + onFinished?.Invoke(); }); }, "Entry", "LoadingLongEvent", true, null, false); } + + public static void DoRejoin() + { + Multiplayer.Client.Send(Packets.Client_RequestRejoin); + + Multiplayer.Client.ChangeState(ConnectionStateEnum.ClientLoading); + Multiplayer.Client.Lenient = true; + + Multiplayer.session.desynced = false; + + Log.Message("Multiplayer: rejoining"); + + ReturnToEntry(() => Find.WindowStack.Add(new RejoiningWindow())); + } } diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs index 06ce06083..e6cdc8126 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs @@ -328,25 +328,36 @@ private void FinalizeBootstrapSave(string path) StatusText = statusText ?? string.Empty }; - saveUploadStatus = "Save created. Returning to menu..."; - LongEventHandler.QueueLongEvent(ReturnToMenuAndReconnect, "Returning to menu", false, null); + saveUploadStatus = "Save created. Returning to entry..."; + LongEventHandler.ExecuteWhenFinished(ReturnToEntryAndReconnect); } - private void ReturnToMenuAndReconnect() + private void ReturnToEntryAndReconnect() { - GenScene.GoToMainMenu(); - LongEventHandler.ExecuteWhenFinished(ReconnectAfterReturningToMenu); + try + { + Log.Message("Bootstrap: returning to entry for save upload reconnect"); + Rejoiner.ReturnToEntry(ReconnectAfterReturningToEntry); + } + catch (Exception exception) + { + saveUploadStatus = $"Return to entry failed: {exception.GetType().Name}: {exception.Message}"; + bootstrapSaveQueued = false; + Log.Error($"Bootstrap return to entry failed: {exception}"); + } } - private void ReconnectAfterReturningToMenu() + private void ReconnectAfterReturningToEntry() { if (Current.ProgramState != ProgramState.Entry || Current.Game != null) { - saveUploadStatus = "Waiting to finish returning to menu..."; - LongEventHandler.ExecuteWhenFinished(ReconnectAfterReturningToMenu); + saveUploadStatus = "Waiting to finish returning to entry..."; + LongEventHandler.ExecuteWhenFinished(ReconnectAfterReturningToEntry); return; } + Multiplayer.StopMultiplayer(); + saveUploadStatus = "Reconnecting to upload save..."; if (reconnectConnector == null) From 2f856bb601958e5cad60641563dd8a613c8c037f Mon Sep 17 00:00:00 2001 From: MhaWay Date: Wed, 20 May 2026 03:47:36 +0200 Subject: [PATCH 25/51] Let bootstrap shutdown exit standalone server (#930) --- Source/Server/Server.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index fa83c5716..255cbcd23 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Threading; using Multiplayer.Common; using Multiplayer.Common.Util; @@ -118,12 +119,19 @@ while (server.running) { - var cmd = Console.ReadLine(); - if (cmd != null) - server.Enqueue(() => server.HandleChatCmd(consoleSource, cmd)); + if (Console.KeyAvailable) + { + var cmd = Console.ReadLine(); + if (cmd != null) + server.Enqueue(() => server.HandleChatCmd(consoleSource, cmd)); - if (cmd == stopCmd) - break; + if (cmd == stopCmd) + break; + } + else + { + Thread.Sleep(50); + } } class ConsoleSource : IChatSource From f03a2ce2e44d190cc167dae7b18090fad9eeacbf Mon Sep 17 00:00:00 2001 From: MhaWay Date: Wed, 20 May 2026 03:48:05 +0200 Subject: [PATCH 26/51] Add standalone server chat command help (#931) --- Source/Common/ChatCommands.cs | 88 +++++++++++++++++++++++++++++- Source/Common/MultiplayerServer.cs | 3 + 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/Source/Common/ChatCommands.cs b/Source/Common/ChatCommands.cs index d39ae5ca3..028642646 100644 --- a/Source/Common/ChatCommands.cs +++ b/Source/Common/ChatCommands.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; namespace Multiplayer.Common; @@ -18,11 +19,41 @@ public void Handle(IChatSource source, string cmd) } else { - source.SendMsg("Invalid command"); + source.SendMsg("Invalid command. Use help or ? to list available commands."); } } public void AddCommandHandler(string name, ChatCmdHandler handler) => handlers[name] = handler; + + public IEnumerable GetCommandNames() => handlers.Keys.OrderBy(name => name); + + public IEnumerable GetCommandInfos() + { + return handlers + .GroupBy(entry => entry.Value) + .Select(group => new ChatCmdInfo(group.Key, group.Select(entry => entry.Key).ToArray())) + .OrderBy(info => info.PrimaryName); + } + + public bool TryGetCommandInfo(string name, out ChatCmdInfo info) + { + if (handlers.TryGetValue(name, out var handler)) + { + info = new ChatCmdInfo(handler, handlers.Where(entry => entry.Value == handler).Select(entry => entry.Key).ToArray()); + return true; + } + + info = null!; + return false; + } +} + +public sealed class ChatCmdInfo(ChatCmdHandler handler, string[] names) +{ + public ChatCmdHandler Handler { get; } = handler; + public string[] Names { get; } = names; + public string PrimaryName => Names.First(); + public string DisplayNames => string.Join(", ", Names); } public abstract class ChatCmdHandler @@ -31,6 +62,9 @@ public abstract class ChatCmdHandler public MultiplayerServer Server => MultiplayerServer.instance!; + public virtual string Description => string.Empty; + public virtual string Usage => string.Empty; + public abstract void Handle(IChatSource source, string[] args); public void SendNoPermission(ServerPlayer player) @@ -46,6 +80,9 @@ public void SendNoPermission(ServerPlayer player) public class ChatCmdJoinPoint : ChatCmdHandler { + public override string Description => "Create a fresh join point immediately."; + public override string Usage => "joinpoint"; + public ChatCmdJoinPoint() { requiresHost = true; @@ -58,8 +95,54 @@ public override void Handle(IChatSource source, string[] args) } } +public class ChatCmdHelp : ChatCmdHandler +{ + public override string Description => "Show available commands or detailed help for one command."; + public override string Usage => "help [command]"; + + public override void Handle(IChatSource source, string[] args) + { + if (args.Length > 0) + { + if (Server.chatCmdManager.TryGetCommandInfo(args[0], out var command)) + { + source.SendMsg($"Command: {command.DisplayNames}"); + + if (!string.IsNullOrEmpty(command.Handler.Description)) + source.SendMsg($"Description: {command.Handler.Description}"); + + if (!string.IsNullOrEmpty(command.Handler.Usage)) + source.SendMsg($"Usage: {command.Handler.Usage}"); + + if (command.Handler.requiresHost) + source.SendMsg("Requires host permissions."); + + return; + } + + source.SendMsg($"Unknown command '{args[0]}'. Use help to list available commands."); + return; + } + + source.SendMsg("Available commands:"); + foreach (var command in Server.chatCmdManager.GetCommandInfos()) + { + var summary = command.Handler.Description; + if (command.Handler.requiresHost) + summary = string.IsNullOrEmpty(summary) ? "Requires host permissions." : $"{summary} Requires host permissions."; + + source.SendMsg($"- {command.DisplayNames}: {summary}"); + } + + source.SendMsg("Use help for detailed usage."); + } +} + public class ChatCmdKick : ChatCmdHandler { + public override string Description => "Disconnect a player by username."; + public override string Usage => "kick "; + public ChatCmdKick() { requiresHost = true; @@ -92,6 +175,9 @@ public override void Handle(IChatSource source, string[] args) public class ChatCmdStop : ChatCmdHandler { + public override string Description => "Stop the standalone server."; + public override string Usage => "stop"; + public ChatCmdStop() { requiresHost = true; diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 27dc3553e..1312a2b4c 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -86,6 +86,9 @@ public MultiplayerServer(ServerSettings settings) chatCmdManager = new ChatCmdManager(); playerManager = new PlayerManager(this); + var helpCmd = new ChatCmdHelp(); + RegisterChatCmd("help", helpCmd); + RegisterChatCmd("?", helpCmd); RegisterChatCmd("joinpoint", new ChatCmdJoinPoint()); RegisterChatCmd("kick", new ChatCmdKick()); RegisterChatCmd("stop", new ChatCmdStop()); From f4d06978fbccc49c2eee1f69901c8bb96ea55b77 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Wed, 20 May 2026 19:11:30 +0200 Subject: [PATCH 27/51] Delay bootstrap window until landing dialog clears (#928) --- ...otstrapConfiguratorWindow.BootstrapFlow.cs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs index e6cdc8126..e17927d2b 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs @@ -178,8 +178,21 @@ public void OnBootstrapMapInitialized() bootstrapSaveQueued = false; saveUploadStatus = "Map initialized. Waiting for controllable colonists to spawn..."; - if (Find.WindowStack.WindowOfType() == null) - Find.WindowStack.Add(this); + TryShowBootstrapWindow(); + } + + private void TryShowBootstrapWindow() + { + if (Find.WindowStack == null) + return; + + if (Find.WindowStack.WindowOfType() != null) + return; + + if (Find.WindowStack.Windows.OfType().Any()) + return; + + Find.WindowStack.Add(this); } private void TickPostMapEnterSaveDelayAndMaybeSave() @@ -195,7 +208,10 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() return; if (!WaitForControllableColonists()) + { + TryShowBootstrapWindow(); return; + } postMapEnterSaveDelayRemaining = 0f; bootstrapSaveQueued = true; From 823146d19a2087d12dae8324cd4318eff1800196 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Wed, 20 May 2026 23:43:47 +0200 Subject: [PATCH 28/51] Close connecting window when entering bootstrap flow (#933) --- Source/Client/Networking/State/ClientJoiningState.cs | 10 ++++++++++ Source/Client/Windows/ConnectingWindow.cs | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index 1e8a98d3a..560a3b6da 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -127,6 +127,16 @@ void StartDownloading() { if (bootstrapState is { Enabled: true } state) { + var connectingWindows = Find.WindowStack.Windows + .OfType() + .ToList(); + + foreach (var connectingWindow in connectingWindows) + { + connectingWindow.suppressPostCloseActions = true; + Find.WindowStack.TryRemove(connectingWindow); + } + connection.ChangeState(ConnectionStateEnum.ClientBootstrap); Find.WindowStack.Add(new BootstrapConfiguratorWindow(connection, state)); return; diff --git a/Source/Client/Windows/ConnectingWindow.cs b/Source/Client/Windows/ConnectingWindow.cs index 11aef7617..1e05e0e31 100644 --- a/Source/Client/Windows/ConnectingWindow.cs +++ b/Source/Client/Windows/ConnectingWindow.cs @@ -14,6 +14,7 @@ public abstract class BaseConnectingWindow : Window, IConnectionStatusListener protected abstract string ConnectingString { get; } public bool returnToServerBrowser; + public bool suppressPostCloseActions; protected string result; // Only show this window if there aren't any others during connecting @@ -117,6 +118,9 @@ public override void DoWindowContents(Rect inRect) public override void PostClose() { + if (suppressPostCloseActions) + return; + Multiplayer.StopMultiplayer(); if (returnToServerBrowser) From 9982be112024d8a6f0e933f3fae76c1a688cc929 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 04:10:28 -0500 Subject: [PATCH 29/51] Bump Languages from `a8513b0` to `90a86c7` (#940) Bumps [Languages](https://github.com/rwmt/Multiplayer-Locale) from `a8513b0` to `90a86c7`. - [Commits](https://github.com/rwmt/Multiplayer-Locale/compare/a8513b0bc1ff212751c498e643241beeca23b953...90a86c76f8f15479069ff92cbaa29cf51f861f1d) --- updated-dependencies: - dependency-name: Languages dependency-version: 90a86c76f8f15479069ff92cbaa29cf51f861f1d dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Languages | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Languages b/Languages index a8513b0bc..90a86c76f 160000 --- a/Languages +++ b/Languages @@ -1 +1 @@ -Subproject commit a8513b0bc1ff212751c498e643241beeca23b953 +Subproject commit 90a86c76f8f15479069ff92cbaa29cf51f861f1d From f8702f286cc51fe27846c9d3b68abe6f7202eeb2 Mon Sep 17 00:00:00 2001 From: Kuinox Date: Sat, 30 May 2026 03:48:25 +0200 Subject: [PATCH 30/51] Fix join point stuck state on dedicated server (#942) * Fix join point stuck state on dedicated server On a dedicated server no player is ever IsHost (hostUsername is never set), so AbortJoinPointCreation was never called on disconnect. When a client drops mid-join-point creation the server was left stuck in CreatingJoinPoint forever, blocking all subsequent connections at WaitJoinPoint(). Fix: also abort when no joined players remain after a disconnect. Co-Authored-By: Claude Sonnet 4.6 * Update log message --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Meru --- Source/Common/PlayerManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index a56eb2d8c..efa871704 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -71,10 +71,10 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) ServerPlayer player = conn.serverPlayer; Players.Remove(player); - if (player.IsHost && server.worldData.CreatingJoinPoint) + if (server.worldData.CreatingJoinPoint && (player.IsHost || !Players.Any(p => p.hasJoined))) { server.worldData.AbortJoinPointCreation(); - ServerLog.Log("Aborted join point creation because the host disconnected."); + ServerLog.Log("Aborted join point creation because no players remain."); } if (player.hasJoined) From 6aef95e2e2b73740c2e875da6cdb4e311c955af7 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 1 Jun 2026 18:28:23 +0200 Subject: [PATCH 31/51] Fix join-point reload cache crash (#938) * Prototype for no error on create join-point * Optimize join-point reload redraw * Clean up join-point reload optimization * Expand reload optimization tests * Remove net48 tests --- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 5 +- Source/Client/Saving/CacheForReloading.cs | 164 ------------------ Source/Client/Saving/ReloadOptimization.cs | 53 ++++++ Source/Client/Saving/SaveLoad.cs | 47 ++--- 4 files changed, 83 insertions(+), 186 deletions(-) delete mode 100644 Source/Client/Saving/CacheForReloading.cs create mode 100644 Source/Client/Saving/ReloadOptimization.cs diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 6be82d6af..8184163ab 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -276,7 +276,10 @@ public void ExecuteCmd(ScheduledCommand cmd) private static void CreateJoinPointAndSendIfHost() { - Multiplayer.session.dataSnapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveAndReload(true), Multiplayer.GameComp.multifaction); + Multiplayer.session.dataSnapshot = SaveLoad.SaveReloadAndCreateSnapshot( + Multiplayer.GameComp.multifaction, + ReloadOptimizationMode.ForJoinPointSnapshot + ); if (!TickPatch.Simulating && !Multiplayer.IsReplay) { diff --git a/Source/Client/Saving/CacheForReloading.cs b/Source/Client/Saving/CacheForReloading.cs deleted file mode 100644 index 0eee71f2e..000000000 --- a/Source/Client/Saving/CacheForReloading.cs +++ /dev/null @@ -1,164 +0,0 @@ -using HarmonyLib; -using Multiplayer.Client.Util; -using RimWorld.Planet; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Verse; - -// TODO: TEST: Test that this works with the new world generation - -namespace Multiplayer.Client -{ - [HarmonyPatch(typeof(MapDrawer), nameof(MapDrawer.RegenerateEverythingNow))] - public static class MapDrawerRegenPatch - { - public static Dictionary copyFrom = new(); - - // These are readonly so they need to be set using reflection - private static FieldInfo mapDrawerMap = AccessTools.Field(typeof(MapDrawer), nameof(MapDrawer.map)); - private static FieldInfo sectionMap = AccessTools.Field(typeof(Section), nameof(Section.map)); - - static bool Prefix(MapDrawer __instance) - { - Map map = __instance.map; - if (!copyFrom.TryGetValue(map.uniqueID, out MapDrawer keepDrawer)) return true; - - map.mapDrawer = keepDrawer; - mapDrawerMap.SetValue(keepDrawer, map); - - foreach (Section section in keepDrawer.sections) - { - sectionMap.SetValue(section, map); - - for (int i = 0; i < section.layers.Count; i++) - { - SectionLayer layer = section.layers[i]; - - if (!ShouldKeep(layer)) - section.layers[i] = (SectionLayer)Activator.CreateInstance(layer.GetType(), section); - else if (layer is SectionLayer_TerrainScatter scatter) - scatter.scats.Do(s => s.map = map); - } - } - - foreach (Section s in keepDrawer.sections) - foreach (SectionLayer layer in s.layers) - if (!ShouldKeep(layer)) - layer.Regenerate(); - - copyFrom.Remove(map.uniqueID); - - return false; - } - - static bool ShouldKeep(SectionLayer layer) - { - return layer.GetType().Assembly == typeof(Game).Assembly; - } - } - - [HarmonyPatch(typeof(WorldGrid), MethodType.Constructor)] - public static class WorldGridCachePatch - { - public static AccessTools.FieldRef> globalLayers = AccessTools.FieldRefAccess>(nameof(WorldGrid.globalLayers)); - public static WorldGrid copyFrom; - - static bool Prefix(WorldGrid __instance, ref int ___cachedTraversalDistance, ref int ___cachedTraversalDistanceForStart, ref int ___cachedTraversalDistanceForEnd) - { - if (copyFrom == null) return true; - - WorldGrid grid = __instance; - - grid.surfaceViewAngle = copyFrom.SurfaceViewAngle; - grid.surfaceViewCenter = copyFrom.SurfaceViewCenter; - grid.surface.verts = copyFrom.UnsafeVerts; - grid.surface.tileIDToNeighbors_offsets = copyFrom.UnsafeTileIDToNeighbors_offsets; - grid.surface.tileIDToNeighbors_values = copyFrom.UnsafeTileIDToNeighbors_values; - grid.surface.tileIDToVerts_offsets = copyFrom.UnsafeTileIDToVerts_offsets; - grid.surface.averageTileSize = copyFrom.AverageTileSize; - grid.surface.tiles.Clear(); - globalLayers(grid) = copyFrom.globalLayers; - - ___cachedTraversalDistance = -1; - ___cachedTraversalDistanceForStart = -1; - ___cachedTraversalDistanceForEnd = -1; - - copyFrom = null; - - return false; - } - } - - [HarmonyPatch(typeof(WorldGrid), nameof(WorldGrid.ExposeData))] - public static class WorldGridExposeDataPatch - { - public static WorldGrid copyFrom; - - static bool Prefix(WorldGrid __instance) - { - if (copyFrom == null) return true; - - WorldGrid grid = __instance; - - List copyTiles = copyFrom.Tiles.ToList(); - List gridTiles = grid.Tiles.ToList(); - - for(int i = 0; i < copyTiles.Count; i++) - { - SurfaceTile sourceTile = copyTiles[i]; - SurfaceTile targetTile = gridTiles[i]; - - // Tile - targetTile.biome = sourceTile.biome; - targetTile.elevation = sourceTile.elevation; - targetTile.hilliness = sourceTile.hilliness; - targetTile.temperature = sourceTile.temperature; - targetTile.rainfall = sourceTile.rainfall; - targetTile.swampiness = sourceTile.swampiness; - targetTile.feature = sourceTile.feature; - targetTile.pollution = sourceTile.pollution; - targetTile.tile = sourceTile.tile; - targetTile.mutatorsNullable = sourceTile.mutatorsNullable; - - // Surface Tile - Roads/Rivers are getters for potentialRoads/potentialRivers - targetTile.potentialRoads = sourceTile.potentialRoads; - targetTile.riverDist = sourceTile.riverDist; - targetTile.potentialRivers = sourceTile.potentialRivers; - } - - // This is plain old data apart from the WorldFeature feature field which is a reference - // It later gets reset in WorldFeatures.ExposeData though so it can be safely copied - - // Use Clear/AddRange instead of reflection to preserve collection observers - // and handle readonly field correctly - grid.surface.tiles.Clear(); - grid.surface.tiles.AddRange(copyFrom.surface.tiles); - - // ExposeData runs multiple times but WorldGrid only needs LoadSaveMode.LoadingVars - copyFrom = null; - - return false; - } - } - - //TODO: TEST: Test that this works with the new world generation - [HarmonyPatch(typeof(WorldGrid), (nameof(WorldGrid.InitializeGlobalLayers)))] - public static class WorldRendererCachePatch - { - - public static AccessTools.FieldRef> globalLayers = AccessTools.FieldRefAccess>(nameof(WorldGrid.globalLayers)); - public static WorldGrid copyFrom; - - static bool Prefix(WorldGrid __instance) - { - if (copyFrom == null) return true; - - globalLayers(__instance) = copyFrom.globalLayers; - copyFrom = null; - - return false; - } - } -} diff --git a/Source/Client/Saving/ReloadOptimization.cs b/Source/Client/Saving/ReloadOptimization.cs new file mode 100644 index 000000000..5b907e6ae --- /dev/null +++ b/Source/Client/Saving/ReloadOptimization.cs @@ -0,0 +1,53 @@ +using System; +using Verse; + +namespace Multiplayer.Client +{ + public enum ReloadOptimizationMode + { + None, + ForJoinPointSnapshot, + } + + internal static class ReloadOptimization + { + public static ReloadOptimizationPlan PlanFor(ReloadOptimizationMode mode) + { + return mode switch + { + ReloadOptimizationMode.ForJoinPointSnapshot => new( + RegenerateMapDrawersWhenRestoringFaction: false, + RegenerateMapDrawersAfterSnapshot: true + ), + _ => new( + RegenerateMapDrawersWhenRestoringFaction: true, + RegenerateMapDrawersAfterSnapshot: false + ), + }; + } + + public static void Complete(ReloadOptimizationMode mode) + { + Complete(mode, RegenerateMapDrawers); + } + + internal static void Complete(ReloadOptimizationMode mode, Action regenerateMapDrawers) + { + if (!PlanFor(mode).RegenerateMapDrawersAfterSnapshot) + return; + + regenerateMapDrawers(); + } + + private static void RegenerateMapDrawers() + { + foreach (var map in Find.Maps) + map.mapDrawer.RegenerateEverythingNow(); + } + } + + internal readonly record struct ReloadOptimizationPlan( + bool RegenerateMapDrawersWhenRestoringFaction, + bool RegenerateMapDrawersAfterSnapshot + ); +} diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index a625b5669..857ff0b18 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -20,13 +20,23 @@ public record TempGameData(XmlDocument SaveData, byte[] SessionData); public static class SaveLoad { - public static TempGameData SaveAndReload(bool cache = false) + public static TempGameData SaveAndReload() + { + return SaveAndReload(ReloadOptimizationMode.None); + } + + public static TempGameData SaveAndReload(ReloadOptimizationMode optimizationMode) + { + var data = SaveAndReloadCore(optimizationMode); + ReloadOptimization.Complete(optimizationMode); + return data; + } + + private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizationMode) { Multiplayer.reloading = true; - var worldGridSaved = Find.WorldGrid; var tweenedPos = new Dictionary(); - var drawers = new Dictionary(); var localFactionId = Multiplayer.RealPlayerFaction.loadID; var mapCmds = new Dictionary>(); var planetRenderMode = Find.World.renderer.wantedMode; @@ -37,8 +47,6 @@ public static TempGameData SaveAndReload(bool cache = false) foreach (Map map in Find.Maps) { - drawers[map.uniqueID] = map.mapDrawer; - foreach (Pawn p in map.mapPawns.AllPawnsSpawned) tweenedPos[p.thingIDNumber] = p.drawer.tweener.tweenedPos; @@ -58,21 +66,6 @@ public static TempGameData SaveAndReload(bool cache = false) gameData = SaveGameData(); } - if (cache) - { - MapDrawerRegenPatch.copyFrom = drawers; - WorldGridCachePatch.copyFrom = worldGridSaved; - WorldGridExposeDataPatch.copyFrom = worldGridSaved; - WorldRendererCachePatch.copyFrom = worldGridSaved; - } - else - { - MapDrawerRegenPatch.copyFrom.Clear(); - WorldGridCachePatch.copyFrom = null; - WorldGridExposeDataPatch.copyFrom = null; - WorldRendererCachePatch.copyFrom = null; - } - MusicManagerPlay musicManager = null; if (Find.MusicManagerPlay.gameObjectCreated) { @@ -88,7 +81,11 @@ public static TempGameData SaveAndReload(bool cache = false) if (musicManager != null) Current.Root_Play.musicManagerPlay = musicManager; - Multiplayer.game.ChangeRealPlayerFaction(Find.FactionManager.GetById(localFactionId)); + var reloadPlan = ReloadOptimization.PlanFor(optimizationMode); + Multiplayer.game.ChangeRealPlayerFaction( + Find.FactionManager.GetById(localFactionId), + reloadPlan.RegenerateMapDrawersWhenRestoringFaction + ); foreach (Map m in Find.Maps) { @@ -118,6 +115,14 @@ public static TempGameData SaveAndReload(bool cache = false) return gameData; } + public static GameDataSnapshot SaveReloadAndCreateSnapshot(bool removeCurrentMapId, ReloadOptimizationMode optimizationMode) + { + var data = SaveAndReloadCore(optimizationMode); + var snapshot = CreateGameDataSnapshot(data, removeCurrentMapId); + ReloadOptimization.Complete(optimizationMode); + return snapshot; + } + public static void LoadInMainThread(TempGameData gameData) { DeepProfiler.Start("Multiplayer LoadInMainThread"); From 821804cbcf81141f5e830f07a3b82ed78637fe9b Mon Sep 17 00:00:00 2001 From: MhaWay Date: Tue, 2 Jun 2026 02:06:50 +0200 Subject: [PATCH 32/51] Fix bootstrap config window race after map entry (#934) --- ...BootstrapConfiguratorWindow.BootstrapFlow.cs | 17 +++++++++++++---- .../Windows/BootstrapConfiguratorWindow.cs | 1 + 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs index e17927d2b..ea2fd5aab 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.BootstrapFlow.cs @@ -16,6 +16,7 @@ public partial class BootstrapConfiguratorWindow { private const string BootstrapSaveName = "MpBootstrapSave"; private const float PostMapEnterSaveDelaySeconds = 1.5f; + private const float PostMapEnterWindowDelaySeconds = 0.1f; private bool hideWindowDuringMapGen; private bool autoAdvanceArmed; @@ -29,6 +30,7 @@ public partial class BootstrapConfiguratorWindow private string saveUploadStatus; private float saveUploadProgress; private float postMapEnterSaveDelayRemaining; + private float postMapEnterWindowDelayRemaining; private float GetGenerateMapStepHeight() { @@ -132,6 +134,7 @@ private void StartVanillaNewColonyFlow() savedReplayPath = null; autoAdvanceArmed = true; AwaitingBootstrapMapInit = true; + postMapEnterWindowDelayRemaining = 0f; saveUploadStatus = "Generating map..."; Find.WindowStack.TryRemove(this); @@ -144,7 +147,7 @@ private void TryArmAwaitingBootstrapMapInit() if (AwaitingBootstrapMapInit) return; - if (Multiplayer.Client != null || bootstrapSaveQueued || saveReady || isUploadingSave || saveUploadAutoStarted) + if (Multiplayer.Client != null || bootstrapSaveQueued || saveReady || isUploadingSave || saveUploadAutoStarted || awaitingControllablePawns || postMapEnterSaveDelayRemaining > 0f) return; if (Current.ProgramState != ProgramState.Playing || Find.Maps == null || Find.Maps.Count == 0) @@ -174,11 +177,10 @@ public void OnBootstrapMapInitialized() retainInstanceOnClose = false; AwaitingBootstrapMapInit = false; postMapEnterSaveDelayRemaining = PostMapEnterSaveDelaySeconds; + postMapEnterWindowDelayRemaining = PostMapEnterWindowDelaySeconds; awaitingControllablePawns = true; bootstrapSaveQueued = false; saveUploadStatus = "Map initialized. Waiting for controllable colonists to spawn..."; - - TryShowBootstrapWindow(); } private void TryShowBootstrapWindow() @@ -189,7 +191,7 @@ private void TryShowBootstrapWindow() if (Find.WindowStack.WindowOfType() != null) return; - if (Find.WindowStack.Windows.OfType().Any()) + if (Find.WindowStack.Windows.Any(window => window is Dialog_MessageBox or Dialog_NodeTree)) return; Find.WindowStack.Add(this); @@ -200,6 +202,13 @@ private void TickPostMapEnterSaveDelayAndMaybeSave() if (hideWindowDuringMapGen || bootstrapSaveQueued || saveReady || isUploadingSave) return; + if (postMapEnterWindowDelayRemaining > 0f || postMapEnterSaveDelayRemaining > 0f || awaitingControllablePawns) + { + postMapEnterWindowDelayRemaining -= Time.deltaTime; + if (postMapEnterWindowDelayRemaining <= 0f) + TryShowBootstrapWindow(); + } + if (postMapEnterSaveDelayRemaining <= 0f && !awaitingControllablePawns) return; diff --git a/Source/Client/Windows/BootstrapConfiguratorWindow.cs b/Source/Client/Windows/BootstrapConfiguratorWindow.cs index 2aaf63c8a..f95268dfa 100644 --- a/Source/Client/Windows/BootstrapConfiguratorWindow.cs +++ b/Source/Client/Windows/BootstrapConfiguratorWindow.cs @@ -145,6 +145,7 @@ internal void ResetTransientUiState(bool resetServerDrivenState = false) isUploadingSave = false; saveUploadAutoStarted = false; postMapEnterSaveDelayRemaining = 0f; + postMapEnterWindowDelayRemaining = 0f; if (resetServerDrivenState) { From 7dcabee49107c25217a84154f85fb3bf2108e055 Mon Sep 17 00:00:00 2001 From: notfood Date: Fri, 5 Jun 2026 04:02:33 -0500 Subject: [PATCH 33/51] Bump Languages from `90a86c7` to `407942a` --- Languages | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Languages b/Languages index 90a86c76f..407942ad0 160000 --- a/Languages +++ b/Languages @@ -1 +1 @@ -Subproject commit 90a86c76f8f15479069ff92cbaa29cf51f861f1d +Subproject commit 407942ad083979fac0e8a7eff79ac43e11db585f From 075ab81bf33397ef19ea85265c5c110a12bd35bb Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 5 Jun 2026 11:04:42 +0200 Subject: [PATCH 34/51] Command Framework with source generation for the chat commands (#939) * Convert chat commands into source generated registry command * Fix generated enum defaults for chat command args * Validate chat rest command arguments * Validate chat command parser accessibility * Validate chat command construction * Validate generated chat command names * Make chat command dispatch case-insensitive * Extra commands to test the system * Move chat command permissions onto commands * Add usable-command help preference * Parse chat command player arguments * Request client rejoin for resync command * Preserve raw chat command usage text * Render raw chat messages without rich text * Update protocol version * Set default value for helpOnlyUsableCommands to true * Tokenize quoted chat command arguments * Infer rest parsing for single chat command arguments * Clean up generated code * Small code cleanup * Added small pagination to mods command * Temporarily switch to my fork of languages * Fix chat window breaking with wrapped text --- .../ChatCommandAttributes.cs | 23 + .../ChatCommandContracts.csproj | 11 + .../Networking/State/ClientPlayingState.cs | 5 +- Source/Client/Session/MultiplayerSession.cs | 4 +- Source/Client/Settings/MpSettings.cs | 2 + Source/Client/Settings/MpSettingsUI.cs | 2 + Source/Client/Windows/ChatWindow.cs | 40 +- Source/Common/ChatCommands.cs | 190 ----- .../ChatCommands/BuiltIn/AnnounceCommand.cs | 12 + .../ChatCommands/BuiltIn/HelpCommand.cs | 54 ++ .../ChatCommands/BuiltIn/JoinPointCommand.cs | 13 + .../ChatCommands/BuiltIn/KickCommand.cs | 22 + .../ChatCommands/BuiltIn/ModsCommand.cs | 60 ++ .../BuiltIn/PlayerCommandUtility.cs | 12 + .../ChatCommands/BuiltIn/PlayersCommand.cs | 25 + .../ChatCommands/BuiltIn/ResyncCommand.cs | 29 + .../ChatCommands/BuiltIn/StatusCommand.cs | 24 + .../ChatCommands/BuiltIn/StopCommand.cs | 12 + .../BuiltIn/TimeControlCommands.cs | 88 +++ .../ChatCommands/BuiltIn/WhoisCommand.cs | 23 + .../ChatCommands/ChatCommand.Generic.cs | 29 + Source/Common/ChatCommands/ChatCommand.cs | 29 + .../ChatCommands/ChatCommandArgumentReader.cs | 110 +++ .../Common/ChatCommands/ChatCommandContext.cs | 9 + Source/Common/ChatCommands/ChatCommandInfo.cs | 18 + .../Common/ChatCommands/ChatCommandManager.cs | 104 +++ .../Common/ChatCommands/ChatCommandParser.cs | 3 + .../ChatCommands/ChatCommandRegistration.cs | 3 + .../ChatCommands/ChatCommandRegistry.cs | 19 + .../Common/ChatCommands/CommandTokenizer.cs | 132 ++++ Source/Common/ChatCommands/IChatCommand.cs | 10 + .../Common/ChatCommands/LegacyChatCommands.cs | 56 ++ Source/Common/Common.csproj | 5 + Source/Common/IChatSource.cs | 1 + Source/Common/MultiplayerServer.cs | 26 +- Source/Common/Networking/Packet/ChatPacket.cs | 13 +- .../Common/Networking/Packet/RejoinPacket.cs | 9 + Source/Common/Networking/Packets.cs | 1 + .../Networking/State/ServerPlayingState.cs | 4 +- Source/Common/Properties/AssemblyInfo.cs | 3 + Source/Common/ServerPlayer.cs | 2 + Source/Common/Version.cs | 2 +- Source/Multiplayer.sln | 12 + Source/Server/Server.cs | 7 +- .../SourceGen/ChatCommandRegistryGenerator.cs | 709 ++++++++++++++++++ Source/SourceGen/SourceGen.csproj | 21 + Source/Tests/ChatCommandGeneratorTest.cs | 626 ++++++++++++++++ Source/Tests/ChatCommandManagerTest.cs | 656 ++++++++++++++++ Source/Tests/CommandTokenizerTest.cs | 46 ++ Source/Tests/PacketTest.cs | 1 + Source/Tests/Tests.csproj | 3 + .../ClientChatPacket.verified.txt | 4 +- .../ServerChatPacket.verified.txt | 5 +- 53 files changed, 3113 insertions(+), 216 deletions(-) create mode 100644 Source/ChatCommandContracts/ChatCommandAttributes.cs create mode 100644 Source/ChatCommandContracts/ChatCommandContracts.csproj delete mode 100644 Source/Common/ChatCommands.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/AnnounceCommand.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/HelpCommand.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/JoinPointCommand.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/KickCommand.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/ModsCommand.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/PlayerCommandUtility.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/PlayersCommand.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/ResyncCommand.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/StatusCommand.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/StopCommand.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/TimeControlCommands.cs create mode 100644 Source/Common/ChatCommands/BuiltIn/WhoisCommand.cs create mode 100644 Source/Common/ChatCommands/ChatCommand.Generic.cs create mode 100644 Source/Common/ChatCommands/ChatCommand.cs create mode 100644 Source/Common/ChatCommands/ChatCommandArgumentReader.cs create mode 100644 Source/Common/ChatCommands/ChatCommandContext.cs create mode 100644 Source/Common/ChatCommands/ChatCommandInfo.cs create mode 100644 Source/Common/ChatCommands/ChatCommandManager.cs create mode 100644 Source/Common/ChatCommands/ChatCommandParser.cs create mode 100644 Source/Common/ChatCommands/ChatCommandRegistration.cs create mode 100644 Source/Common/ChatCommands/ChatCommandRegistry.cs create mode 100644 Source/Common/ChatCommands/CommandTokenizer.cs create mode 100644 Source/Common/ChatCommands/IChatCommand.cs create mode 100644 Source/Common/ChatCommands/LegacyChatCommands.cs create mode 100644 Source/Common/Networking/Packet/RejoinPacket.cs create mode 100644 Source/Common/Properties/AssemblyInfo.cs create mode 100644 Source/SourceGen/ChatCommandRegistryGenerator.cs create mode 100644 Source/SourceGen/SourceGen.csproj create mode 100644 Source/Tests/ChatCommandGeneratorTest.cs create mode 100644 Source/Tests/ChatCommandManagerTest.cs create mode 100644 Source/Tests/CommandTokenizerTest.cs diff --git a/Source/ChatCommandContracts/ChatCommandAttributes.cs b/Source/ChatCommandContracts/ChatCommandAttributes.cs new file mode 100644 index 000000000..f8bb4072f --- /dev/null +++ b/Source/ChatCommandContracts/ChatCommandAttributes.cs @@ -0,0 +1,23 @@ +using System; + +namespace Multiplayer.Common; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ChatCommandAttribute(string name, params string[] aliases) : Attribute +{ + public string Name { get; } = name; + public string[] Aliases { get; } = aliases; + public string Usage { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public bool RequiresHost { get; set; } +} + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field)] +public sealed class ChatArgumentAttribute(string name) : Attribute +{ + public string Name { get; } = name; + public string Description { get; set; } = string.Empty; +} + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field)] +public sealed class ChatRestAttribute : Attribute; diff --git a/Source/ChatCommandContracts/ChatCommandContracts.csproj b/Source/ChatCommandContracts/ChatCommandContracts.csproj new file mode 100644 index 000000000..51f41f762 --- /dev/null +++ b/Source/ChatCommandContracts/ChatCommandContracts.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.0 + enable + 12 + Multiplayer.Common + MultiplayerChatCommandContracts + + + diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs index da91769d8..6a96635a5 100644 --- a/Source/Client/Networking/State/ClientPlayingState.cs +++ b/Source/Client/Networking/State/ClientPlayingState.cs @@ -92,7 +92,7 @@ public void HandlePlayerList(ServerPlayerListPacket packet) } [TypedPacketHandler] - public void HandleChat(ServerChatPacket packet) => Multiplayer.session.AddMsg(packet.msg); + public void HandleChat(ServerChatPacket packet) => Multiplayer.session.AddMsg(packet.msg, rawMessage: packet.rawMessage); [TypedPacketHandler] public void HandleCursor(ServerCursorPacket packet) @@ -204,6 +204,9 @@ public void HandleTraces(ServerTracesPacket packet) [TypedPacketHandler] public void HandleDebug(ServerDebugPacket _) => Rejoiner.DoRejoin(); + [TypedPacketHandler] + public void HandleRequestRejoin(ServerRequestRejoinPacket _) => Rejoiner.DoRejoin(); + [TypedPacketHandler] public void HandleSetFaction(ServerSetFactionPacket packet) { diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 9431730a9..9bf2464c1 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -74,9 +74,9 @@ public void Stop() public PlayerInfo GetPlayerInfo(int id) => players.FirstOrDefault(p => p.id == id); - public void AddMsg(string msg, bool notify = true) + public void AddMsg(string msg, bool notify = true, bool rawMessage = false) { - AddMsg(new ChatMsg_Text(msg), notify); + AddMsg(new ChatMsg_Text(msg, rawMessage), notify); } public void AddMsg(ChatMsg msg, bool notify = true) diff --git a/Source/Client/Settings/MpSettings.cs b/Source/Client/Settings/MpSettings.cs index 2faded769..03845c5dd 100644 --- a/Source/Client/Settings/MpSettings.cs +++ b/Source/Client/Settings/MpSettings.cs @@ -14,6 +14,7 @@ public class MpSettings : ModSettings public bool showCursors = true; public bool autoAcceptSteam; public bool transparentChat = true; + public bool helpOnlyUsableCommands = true; public int autosaveSlots = 5; public bool showDevInfo; public bool includeReplayInDesync = VersionChecker.IsContinuousRelease; @@ -60,6 +61,7 @@ public override void ExposeData() Scribe_Values.Look(ref showCursors, "showCursors", true); Scribe_Values.Look(ref autoAcceptSteam, "autoAcceptSteam"); Scribe_Values.Look(ref transparentChat, "transparentChat", true); + Scribe_Values.Look(ref helpOnlyUsableCommands, "helpOnlyUsableCommands", true); Scribe_Values.Look(ref autosaveSlots, "autosaveSlots", 5); Scribe_Values.Look(ref showDevInfo, "showDevInfo"); Scribe_Values.Look(ref includeReplayInDesync, "includeReplayInDesync", VersionChecker.IsContinuousRelease); diff --git a/Source/Client/Settings/MpSettingsUI.cs b/Source/Client/Settings/MpSettingsUI.cs index 1d1e30d89..4c857ca10 100644 --- a/Source/Client/Settings/MpSettingsUI.cs +++ b/Source/Client/Settings/MpSettingsUI.cs @@ -70,6 +70,8 @@ public static void DoGeneralSettings(MpSettings settings, Rect inRect, Rect page listing.CheckboxLabeled("MpAutoAcceptSteam".Translate(), ref settings.autoAcceptSteam, "MpAutoAcceptSteamDesc".Translate()); listing.CheckboxLabeled("MpTransparentChat".Translate(), ref settings.transparentChat); + listing.CheckboxLabeled("MpHelpOnlyUsableCommands".Translate(), ref settings.helpOnlyUsableCommands, + "MpHelpOnlyUsableCommandsDesc".Translate()); listing.CheckboxLabeled("MpAppendNameToAutosave".Translate(), ref settings.appendNameToAutosave); listing.CheckboxLabeled("MpShowModCompat".Translate(), ref settings.showModCompatibility, "MpShowModCompatDesc".Translate()); diff --git a/Source/Client/Windows/ChatWindow.cs b/Source/Client/Windows/ChatWindow.cs index 5ef46f7e4..5f2362afb 100644 --- a/Source/Client/Windows/ChatWindow.cs +++ b/Source/Client/Windows/ChatWindow.cs @@ -288,8 +288,8 @@ private void DrawChat(Rect inRect) foreach (ChatMsg msg in Multiplayer.session.messages) { - float height = Text.CalcHeight(msg.Msg, width - 20f); - float textWidth = Text.CalcSize(msg.Msg).x + 15; + CalculateMessageSize(msg, width, out var height, out var textWidth); + Rect msgRect = new Rect(20f, yPos, width - 20f, height); if (Mouse.IsOver(msgRect)) @@ -310,7 +310,7 @@ private void DrawChat(Rect inRect) GUI.color = new Color(0.8f, 0.8f, 1); GUI.SetNextControlName("chat_msg_" + i++); - Widgets.TextArea(msgRect, msg.Msg, true); + DrawMessageTextArea(msgRect, msg); if (mouseOver && msg.Clickable) { @@ -362,7 +362,7 @@ public void SendMsg() if (Multiplayer.Client == null) Multiplayer.session.AddMsg(Multiplayer.username + ": " + currentMsg); else - Multiplayer.Client.Send(ClientChatPacket.Create(currentMsg)); + Multiplayer.Client.Send(ClientChatPacket.Create(currentMsg, Multiplayer.settings.helpOnlyUsableCommands)); currentMsg = ""; } @@ -429,6 +429,33 @@ public void OnChatReceived() chatScroll.y = messagesHeight; } + private static void CalculateMessageSize(ChatMsg msg, float width, out float height, out float textWidth) + { + var style = msg.RawMessage ? RawTextAreaStyle() : Text.CurTextAreaReadOnlyStyle; + var content = new GUIContent(msg.Msg); + height = style.CalcHeight(content, width - 20f); + textWidth = style.CalcSize(content).x + 15; + } + + private static void DrawMessageTextArea(Rect rect, ChatMsg msg) + { + if (!msg.RawMessage) + { + Widgets.TextArea(rect, msg.Msg, true); + return; + } + + GUI.Label(rect, msg.Msg, RawTextAreaStyle()); + } + + private static GUIStyle RawTextAreaStyle() + { + return new GUIStyle(Text.CurTextAreaReadOnlyStyle) + { + richText = false + }; + } + public override void PostClose() { if (Multiplayer.session != null && saveSize) @@ -474,6 +501,7 @@ public static void OpenChat() public abstract class ChatMsg { public virtual bool Clickable => false; + public virtual bool RawMessage => false; public abstract string Msg { get; } public virtual DateTime TimeStamp { get; } @@ -488,10 +516,12 @@ public virtual void Click() { } public class ChatMsg_Text : ChatMsg { public override string Msg { get; } + public override bool RawMessage { get; } - public ChatMsg_Text(string msg) + public ChatMsg_Text(string msg, bool rawMessage = false) { this.Msg = msg; + this.RawMessage = rawMessage; } } diff --git a/Source/Common/ChatCommands.cs b/Source/Common/ChatCommands.cs deleted file mode 100644 index 028642646..000000000 --- a/Source/Common/ChatCommands.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace Multiplayer.Common; - -public class ChatCmdManager -{ - private readonly IDictionary handlers = new Dictionary(); - - public void Handle(IChatSource source, string cmd) - { - var parts = cmd.Split(' '); - if (handlers.TryGetValue(parts[0], out var handler)) - { - if (handler.requiresHost && source is ServerPlayer { IsHost: false }) - source.SendMsg("No permission"); - else - handler.Handle(source, parts.SubArray(1)); - } - else - { - source.SendMsg("Invalid command. Use help or ? to list available commands."); - } - } - - public void AddCommandHandler(string name, ChatCmdHandler handler) => handlers[name] = handler; - - public IEnumerable GetCommandNames() => handlers.Keys.OrderBy(name => name); - - public IEnumerable GetCommandInfos() - { - return handlers - .GroupBy(entry => entry.Value) - .Select(group => new ChatCmdInfo(group.Key, group.Select(entry => entry.Key).ToArray())) - .OrderBy(info => info.PrimaryName); - } - - public bool TryGetCommandInfo(string name, out ChatCmdInfo info) - { - if (handlers.TryGetValue(name, out var handler)) - { - info = new ChatCmdInfo(handler, handlers.Where(entry => entry.Value == handler).Select(entry => entry.Key).ToArray()); - return true; - } - - info = null!; - return false; - } -} - -public sealed class ChatCmdInfo(ChatCmdHandler handler, string[] names) -{ - public ChatCmdHandler Handler { get; } = handler; - public string[] Names { get; } = names; - public string PrimaryName => Names.First(); - public string DisplayNames => string.Join(", ", Names); -} - -public abstract class ChatCmdHandler -{ - public bool requiresHost; - - public MultiplayerServer Server => MultiplayerServer.instance!; - - public virtual string Description => string.Empty; - public virtual string Usage => string.Empty; - - public abstract void Handle(IChatSource source, string[] args); - - public void SendNoPermission(ServerPlayer player) - { - player.SendMsg("You don't have permission."); - } - - public ServerPlayer? FindPlayer(string username) - { - return Server.GetPlayer(username); - } -} - -public class ChatCmdJoinPoint : ChatCmdHandler -{ - public override string Description => "Create a fresh join point immediately."; - public override string Usage => "joinpoint"; - - public ChatCmdJoinPoint() - { - requiresHost = true; - } - - public override void Handle(IChatSource source, string[] args) - { - if (!Server.worldData.TryStartJoinPointCreation(true, sourcePlayer: source as ServerPlayer)) - source.SendMsg("Join point creation already in progress."); - } -} - -public class ChatCmdHelp : ChatCmdHandler -{ - public override string Description => "Show available commands or detailed help for one command."; - public override string Usage => "help [command]"; - - public override void Handle(IChatSource source, string[] args) - { - if (args.Length > 0) - { - if (Server.chatCmdManager.TryGetCommandInfo(args[0], out var command)) - { - source.SendMsg($"Command: {command.DisplayNames}"); - - if (!string.IsNullOrEmpty(command.Handler.Description)) - source.SendMsg($"Description: {command.Handler.Description}"); - - if (!string.IsNullOrEmpty(command.Handler.Usage)) - source.SendMsg($"Usage: {command.Handler.Usage}"); - - if (command.Handler.requiresHost) - source.SendMsg("Requires host permissions."); - - return; - } - - source.SendMsg($"Unknown command '{args[0]}'. Use help to list available commands."); - return; - } - - source.SendMsg("Available commands:"); - foreach (var command in Server.chatCmdManager.GetCommandInfos()) - { - var summary = command.Handler.Description; - if (command.Handler.requiresHost) - summary = string.IsNullOrEmpty(summary) ? "Requires host permissions." : $"{summary} Requires host permissions."; - - source.SendMsg($"- {command.DisplayNames}: {summary}"); - } - - source.SendMsg("Use help for detailed usage."); - } -} - -public class ChatCmdKick : ChatCmdHandler -{ - public override string Description => "Disconnect a player by username."; - public override string Usage => "kick "; - - public ChatCmdKick() - { - requiresHost = true; - } - - public override void Handle(IChatSource source, string[] args) - { - if (args.Length < 1) - { - source.SendMsg("No username provided."); - return; - } - - var toKick = FindPlayer(args[0]); - if (toKick == null) - { - source.SendMsg("Couldn't find the player."); - return; - } - - if (toKick.IsHost) - { - source.SendMsg("You can't kick the host."); - return; - } - - toKick.Disconnect(MpDisconnectReason.Kick); - } -} - -public class ChatCmdStop : ChatCmdHandler -{ - public override string Description => "Stop the standalone server."; - public override string Usage => "stop"; - - public ChatCmdStop() - { - requiresHost = true; - } - - public override void Handle(IChatSource source, string[] args) - { - Server.running = false; - } -} diff --git a/Source/Common/ChatCommands/BuiltIn/AnnounceCommand.cs b/Source/Common/ChatCommands/BuiltIn/AnnounceCommand.cs new file mode 100644 index 000000000..cbd37d284 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/AnnounceCommand.cs @@ -0,0 +1,12 @@ +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("announce", Description = "Broadcast a server announcement.", Usage = "announce ", RequiresHost = true)] +public class AnnounceCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, AnnounceCommandArgs args) + { + Server.SendChat($"[Announcement] {args.Message}"); + } +} + +public readonly record struct AnnounceCommandArgs(string Message); diff --git a/Source/Common/ChatCommands/BuiltIn/HelpCommand.cs b/Source/Common/ChatCommands/BuiltIn/HelpCommand.cs new file mode 100644 index 000000000..b03c3f2a5 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/HelpCommand.cs @@ -0,0 +1,54 @@ +using System.Linq; +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +public readonly record struct HelpCommandArgs(string? Command = null); + +[ChatCommand("help", "?", Description = "Show available commands or detailed help for one command.", Usage = "help [command]")] +public class HelpCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, HelpCommandArgs args) + { + var source = context.Source; + if (args.Command != null) + { + if (Server.chatCmdManager.TryGetCommandInfo(args.Command, out var command)) + { + source.SendMsg($"Command: {command.DisplayNames}"); + + if (!string.IsNullOrEmpty(command.Description)) + source.SendMsg($"Description: {command.Description}"); + + if (!string.IsNullOrEmpty(command.Usage)) + source.SendRawMsg($"Usage: {command.Usage}"); + + if (command.RequiresHost) + source.SendMsg("Requires host permissions."); + + return; + } + + source.SendMsg($"Unknown command '{args.Command}'. Use help to list available commands."); + return; + } + + var onlyUsable = source is ServerPlayer { helpOnlyUsableCommands: true }; + source.SendMsg(onlyUsable ? "Available commands you can use:" : "Available commands:"); + + var commands = Server.chatCmdManager.GetCommandInfos(); + if (onlyUsable) + commands = commands.Where(command => command.CanUse(source)); + + foreach (var command in commands) + { + var summary = command.Description; + if (command.RequiresHost) + summary = string.IsNullOrEmpty(summary) ? "Requires host permissions." : $"{summary} Requires host permissions."; + + source.SendMsg($"- {command.DisplayNames}: {summary}"); + } + + source.SendRawMsg("Use help for detailed usage."); + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/JoinPointCommand.cs b/Source/Common/ChatCommands/BuiltIn/JoinPointCommand.cs new file mode 100644 index 000000000..1ae39df7e --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/JoinPointCommand.cs @@ -0,0 +1,13 @@ +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("joinpoint", Description = "Create a fresh join point immediately.", Usage = "joinpoint", RequiresHost = true)] +public class JoinPointCommand : ChatCommand +{ + public override void Execute(ChatCommandContext context) + { + if (!Server.worldData.TryStartJoinPointCreation(true, sourcePlayer: context.Source as ServerPlayer)) + context.Source.SendMsg("Join point creation already in progress."); + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/KickCommand.cs b/Source/Common/ChatCommands/BuiltIn/KickCommand.cs new file mode 100644 index 000000000..903ad13c4 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/KickCommand.cs @@ -0,0 +1,22 @@ +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +public readonly record struct KickCommandArgs([ChatArgument("username")] ServerPlayer Player); + +[ChatCommand("kick", Description = "Disconnect a player by username.", Usage = "kick ", RequiresHost = true)] +public class KickCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, KickCommandArgs args) + { + ServerPlayer toKick = args.Player; + + if (toKick.IsHost) + { + context.Source.SendMsg("You can't kick the host."); + return; + } + + toKick.Disconnect(MpDisconnectReason.Kick); + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/ModsCommand.cs b/Source/Common/ChatCommands/BuiltIn/ModsCommand.cs new file mode 100644 index 000000000..cc9a98515 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/ModsCommand.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using Multiplayer.Common.Networking.Packet; + +namespace Multiplayer.Common.ChatCommands; + +public readonly record struct ModsCommandArgs( + [ChatArgument("page")] int Page = 1, + [ChatArgument("amount")] int Amount = 20 +); + +[ChatCommand("mods", Description = "Show the server mod list summary.", Usage = "mods [page] [amount]")] +public class ModsCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, ModsCommandArgs args) + { + if (args.Page < 1 || args.Amount < 1) + { + context.Source.SendMsg("Usage: mods [page] [amount]"); + return; + } + + var initData = Server.InitData; + if (initData == null) + { + context.Source.SendMsg("Mod data is not available yet."); + return; + } + + try + { + var mods = ClientInitDataPacket.ModData.ListBinder.Deserialize(initData.RawData); + var totalPages = Math.Max(1, (int)Math.Ceiling(mods.Count / (double)args.Amount)); + if (args.Page > totalPages) + { + context.Source.SendMsg($"Page {args.Page} is out of range. Last page is {totalPages}."); + return; + } + + var pageMods = mods + .Skip((args.Page - 1) * args.Amount) + .Take(args.Amount) + .ToList(); + + context.Source.SendMsg($"RimWorld: {initData.RwVersion}"); + context.Source.SendMsg(totalPages == 1 + ? $"Mods ({mods.Count}):" + : $"Mods ({mods.Count}), page {args.Page}/{totalPages}:" + ); + + foreach (var mod in pageMods) + context.Source.SendMsg($"- {mod.name} ({mod.packageIdNonUnique})"); + } + catch (Exception e) + { + ServerLog.Error($"Failed to read server mod data: {e}"); + context.Source.SendMsg("Could not read mod data."); + } + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/PlayerCommandUtility.cs b/Source/Common/ChatCommands/BuiltIn/PlayerCommandUtility.cs new file mode 100644 index 000000000..7a6fc8921 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/PlayerCommandUtility.cs @@ -0,0 +1,12 @@ +namespace Multiplayer.Common.ChatCommands; + +internal static class PlayerCommandUtility +{ + public static string GetRole(ServerPlayer player) + { + if (player.IsHost) + return "host"; + + return player.IsArbiter ? "arbiter" : "player"; + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/PlayersCommand.cs b/Source/Common/ChatCommands/BuiltIn/PlayersCommand.cs new file mode 100644 index 000000000..b85ee75dd --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/PlayersCommand.cs @@ -0,0 +1,25 @@ +using System.Linq; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("players", "list", Description = "List connected players.", Usage = "players")] +public class PlayersCommand : ChatCommand +{ + public override void Execute(ChatCommandContext context) + { + var players = Server.playerManager.Players.OrderBy(player => player.id).ToList(); + if (players.Count == 0) + { + context.Source.SendMsg("No players connected."); + return; + } + + context.Source.SendMsg($"Players ({players.Count}):"); + foreach (var player in players) + { + context.Source.SendMsg( + $"- #{player.id} {player.Username} [{player.status}] {PlayerCommandUtility.GetRole(player)} faction={player.FactionId} map={player.currentMapId} ping={player.Latency}ms behind={player.ExtrapolatedTicksBehind}" + ); + } + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/ResyncCommand.cs b/Source/Common/ChatCommands/BuiltIn/ResyncCommand.cs new file mode 100644 index 000000000..6a954e30d --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/ResyncCommand.cs @@ -0,0 +1,29 @@ +using Multiplayer.Common.Networking.Packet; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("resync", Description = "Force a player to reload world data.", Usage = "resync ", RequiresHost = true)] +public class ResyncCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, ResyncCommandArgs args) + { + ServerPlayer player = args.Player; + + if (player.IsHost) + { + context.Source.SendMsg("You can't force-resync the host."); + return; + } + + if (!player.IsPlaying) + { + context.Source.SendMsg("Player is not in the playing state."); + return; + } + + player.SendPacket(new ServerRequestRejoinPacket()); + context.Source.SendMsg($"Resync requested for {player.Username}."); + } +} + +public readonly record struct ResyncCommandArgs([ChatArgument("username")] ServerPlayer Player); diff --git a/Source/Common/ChatCommands/BuiltIn/StatusCommand.cs b/Source/Common/ChatCommands/BuiltIn/StatusCommand.cs new file mode 100644 index 000000000..243d66562 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/StatusCommand.cs @@ -0,0 +1,24 @@ +using System.Linq; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("status", Description = "Show multiplayer server status.", Usage = "status")] +public class StatusCommand : ChatCommand +{ + public override void Execute(ChatCommandContext context) + { + var worldState = Server.worldData.savedGame != null ? "loaded" : "not loaded"; + var joinPointState = Server.worldData.CreatingJoinPoint + ? "creating" + : Server.worldData.lastJoinPointAtTick >= 0 + ? $"last at tick {Server.worldData.lastJoinPointAtTick}" + : "never"; + + context.Source.SendMsg($"Server: {(Server.running ? "running" : "stopped")}"); + context.Source.SendMsg($"World: {worldState}, maps={Server.worldData.mapData.Count}, join point={joinPointState}"); + context.Source.SendMsg($"Ticks: game={Server.gameTimer}, net={Server.NetTimer}, work={Server.workTicks}"); + context.Source.SendMsg( + $"Players: connected={Server.playerManager.Players.Count}, joined={Server.JoinedPlayers.Count()}, playing={Server.PlayingPlayers.Count()}" + ); + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/StopCommand.cs b/Source/Common/ChatCommands/BuiltIn/StopCommand.cs new file mode 100644 index 000000000..4ce8abc00 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/StopCommand.cs @@ -0,0 +1,12 @@ +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("stop", Description = "Stop the standalone server.", Usage = "stop", RequiresHost = true)] +public class StopCommand : ChatCommand +{ + public override void Execute(ChatCommandContext context) + { + Server.running = false; + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/TimeControlCommands.cs b/Source/Common/ChatCommands/BuiltIn/TimeControlCommands.cs new file mode 100644 index 000000000..1f29a1075 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/TimeControlCommands.cs @@ -0,0 +1,88 @@ +using System.Linq; + +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("pause", Description = "Pause the multiplayer session.", Usage = "pause")] +public class PauseCommand : TimeControlCommand +{ + public override void Execute(ChatCommandContext context) + { + TimeControlCommandUtil.SetSpeed(Server, context.Source, TimeVote.Paused); + context.Source.SendMsg("Speed set to Paused."); + } +} + +[ChatCommand("unpause", Description = "Resume the multiplayer session at normal speed.", Usage = "unpause")] +public class UnpauseCommand : TimeControlCommand +{ + public override void Execute(ChatCommandContext context) + { + TimeControlCommandUtil.SetSpeed(Server, context.Source, TimeVote.Normal); + context.Source.SendMsg("Speed set to Normal."); + } +} + +[ChatCommand("speed", Description = "Set global multiplayer speed.", Usage = "speed <1-4>")] +public class SpeedCommand : TimeControlCommand +{ + protected override void Execute(ChatCommandContext context, SpeedCommandArgs args) + { + if (args.Speed is < 1 or > 4) + { + context.Source.SendRawMsg("Usage: speed <1-4>"); + return; + } + + var speed = (TimeVote)args.Speed; + TimeControlCommandUtil.SetSpeed(Server, context.Source, speed); + context.Source.SendMsg($"Speed set to {speed}."); + } +} + +public readonly record struct SpeedCommandArgs([ChatArgument("speed")] int Speed); + +public abstract class TimeControlCommand : ChatCommand +{ + public override bool CanUse(IChatSource source) => TimeControlCommandUtil.CanUse(Server, source); +} + +public abstract class TimeControlCommand : ChatCommand +{ + public override bool CanUse(IChatSource source) => TimeControlCommandUtil.CanUse(Server, source); +} + +internal static class TimeControlCommandUtil +{ + public static bool CanUse(MultiplayerServer server, IChatSource source) + { + return server.settings.timeControl != TimeControl.HostOnly || + source is not ServerPlayer { IsHost: false } || + !server.PlayingPlayers.Any(player => player.IsHost); + } + + public static void SetSpeed(MultiplayerServer server, IChatSource source, TimeVote speed) + { + var sourcePlayer = source as ServerPlayer; + var factionId = sourcePlayer?.FactionId ?? ScheduledCommand.NoFaction; + + if (server.settings.timeControl == TimeControl.LowestWins) + { + server.commands.Send( + CommandType.TimeSpeedVote, + factionId, + ScheduledCommand.Global, + ByteWriter.GetBytes(speed, ScheduledCommand.Global), + sourcePlayer + ); + return; + } + + server.commands.Send( + CommandType.GlobalTimeSpeed, + factionId, + ScheduledCommand.Global, + [(byte)speed], + sourcePlayer + ); + } +} diff --git a/Source/Common/ChatCommands/BuiltIn/WhoisCommand.cs b/Source/Common/ChatCommands/BuiltIn/WhoisCommand.cs new file mode 100644 index 000000000..0cdb75160 --- /dev/null +++ b/Source/Common/ChatCommands/BuiltIn/WhoisCommand.cs @@ -0,0 +1,23 @@ +namespace Multiplayer.Common.ChatCommands; + +[ChatCommand("whois", Description = "Show details for a connected player.", Usage = "whois ")] +public class WhoisCommand : ChatCommand +{ + protected override void Execute(ChatCommandContext context, WhoisCommandArgs args) + { + ServerPlayer player = args.Player; + + context.Source.SendMsg($"Player: {player.Username} (#{player.id})"); + context.Source.SendMsg($"Status: {player.status}"); + context.Source.SendMsg($"Role: {PlayerCommandUtility.GetRole(player)}"); + context.Source.SendMsg($"Faction: {player.FactionId}"); + context.Source.SendMsg($"Map: {player.currentMapId}"); + context.Source.SendMsg($"Latency: {player.Latency}ms"); + context.Source.SendMsg($"Ticks behind: {player.ExtrapolatedTicksBehind}"); + + if (player.steamId != 0 || !string.IsNullOrWhiteSpace(player.steamPersonaName)) + context.Source.SendMsg($"Steam: {player.steamPersonaName} ({player.steamId})"); + } +} + +public readonly record struct WhoisCommandArgs([ChatArgument("username")] ServerPlayer Player); diff --git a/Source/Common/ChatCommands/ChatCommand.Generic.cs b/Source/Common/ChatCommands/ChatCommand.Generic.cs new file mode 100644 index 000000000..594185398 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommand.Generic.cs @@ -0,0 +1,29 @@ +using System; + +namespace Multiplayer.Common.ChatCommands; + +public abstract class ChatCommand : ChatCommand +{ + private ChatCommandParser? parser; + + public void SetParser(ChatCommandParser parser) + { + this.parser = parser; + } + + public sealed override void Execute(ChatCommandContext context) + { + if (parser == null) + throw new InvalidOperationException($"No generated parser was registered for {GetType().FullName}."); + + if (!parser(context, out var args, out var error)) + { + context.Source.SendRawMsg(error ?? "Invalid command arguments."); + return; + } + + Execute(context, args); + } + + protected abstract void Execute(ChatCommandContext context, TArgs args); +} diff --git a/Source/Common/ChatCommands/ChatCommand.cs b/Source/Common/ChatCommands/ChatCommand.cs new file mode 100644 index 000000000..d00be6d4c --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommand.cs @@ -0,0 +1,29 @@ +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +public abstract class ChatCommand : IChatCommand +{ + private bool requiresHost; + + protected MultiplayerServer Server => MultiplayerServer.instance!; + + internal void ConfigurePermissions(bool requiresHost) + { + this.requiresHost = requiresHost; + } + + public virtual bool CanUse(IChatSource source) + { + return !requiresHost || source is not ServerPlayer { IsHost: false }; + } + + public virtual string PermissionDeniedMessage => "No permission"; + + public abstract void Execute(ChatCommandContext context); + + public ServerPlayer? FindPlayer(string username) + { + return Server.GetPlayer(username); + } +} diff --git a/Source/Common/ChatCommands/ChatCommandArgumentReader.cs b/Source/Common/ChatCommands/ChatCommandArgumentReader.cs new file mode 100644 index 000000000..f09eeeee0 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandArgumentReader.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Multiplayer.Common.ChatCommands; + +public static class ChatCommandArgumentReader +{ + private delegate bool Parser(string raw, out T value); + + public static bool HasArgument(ChatCommandContext context, int index, string missingMessage, out string? error) + { + if (context.RawArgs.Count > index) + { + error = null; + return true; + } + + error = missingMessage; + return false; + } + + public static string JoinRest(IReadOnlyList args, int startIndex) + { + if (startIndex >= args.Count) + return string.Empty; + + var values = new string[args.Count - startIndex]; + for (var i = 0; i < values.Length; i++) + values[i] = args[startIndex + i]; + + return string.Join(" ", values); + } + + public static bool TryParseInt(string raw, string name, out int value, out string? error) + { + return TryParse(raw, name, int.TryParse, out value, out error); + } + + public static bool TryParseBool(string raw, string name, out bool value, out string? error) + { + return TryParse(raw, name, bool.TryParse, out value, out error); + } + + public static bool TryParseFloat(string raw, string name, out float value, out string? error) + { + return TryParse(raw, name, TryParseFloatInvariant, out value, out error); + } + + public static bool TryParseEnum(string raw, string name, out T value, out string? error) where T : struct + { + return TryParse(raw, name, TryParseEnumIgnoreCase, out value, out error); + } + + public static bool TryParsePlayer(ChatCommandContext context, string raw, string name, out ServerPlayer value, out string? error) + { + var server = MultiplayerServer.instance!; + var exact = server.playerManager.Players.FirstOrDefault(player => + string.Equals(player.Username, raw, System.StringComparison.OrdinalIgnoreCase) + ); + + if (exact != null) + { + value = exact; + error = null; + return true; + } + + var matches = server.playerManager.Players + .Where(player => player.Username.Contains(raw, System.StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matches.Count == 1) + { + value = matches[0]; + error = null; + return true; + } + + value = null!; + error = matches.Count == 0 + ? "Couldn't find the player." + : $"Player name '{raw}' is ambiguous: {string.Join(", ", matches.Select(player => player.Username))}."; + return false; + } + + private static bool TryParse(string raw, string name, Parser parseMethod, out T value, out string? error) + { + if (parseMethod(raw, out value)) + { + error = null; + return true; + } + + error = InvalidValueMessage(name); + return false; + } + + private static bool TryParseFloatInvariant(string raw, out float value) + { + return float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } + + private static bool TryParseEnumIgnoreCase(string raw, out T value) where T : struct + { + return System.Enum.TryParse(raw, true, out value); + } + + private static string InvalidValueMessage(string name) => $"Invalid value for '{name}'."; +} diff --git a/Source/Common/ChatCommands/ChatCommandContext.cs b/Source/Common/ChatCommands/ChatCommandContext.cs new file mode 100644 index 000000000..b8ded95dc --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandContext.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Multiplayer.Common.ChatCommands; + +public sealed record ChatCommandContext( + IChatSource Source, + string CommandName, + IReadOnlyList RawArgs +); diff --git a/Source/Common/ChatCommands/ChatCommandInfo.cs b/Source/Common/ChatCommands/ChatCommandInfo.cs new file mode 100644 index 000000000..f0d41b664 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandInfo.cs @@ -0,0 +1,18 @@ +using System.Linq; + +namespace Multiplayer.Common.ChatCommands; + +public sealed class ChatCommandInfo(IChatCommand command, string[] names, string description, string usage, bool requiresHost) +{ + public IChatCommand Command { get; } = command; + public string[] Names { get; } = [.. names]; + public string PrimaryName => Names.First(); + public string DisplayNames => string.Join(", ", Names); + public string Description { get; } = description; + public string Usage { get; } = usage; + public bool RequiresHost { get; } = requiresHost; + + public bool CanUse(IChatSource source) => Command.CanUse(source); + + public string PermissionDeniedMessage => Command.PermissionDeniedMessage; +} diff --git a/Source/Common/ChatCommands/ChatCommandManager.cs b/Source/Common/ChatCommands/ChatCommandManager.cs new file mode 100644 index 000000000..1b59f4950 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandManager.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Multiplayer.Common; + +namespace Multiplayer.Common.ChatCommands; + +public class ChatCommandManager +{ + private readonly MultiplayerServer server; + private readonly IDictionary handlers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public ChatCommandManager(MultiplayerServer server) + { + this.server = server; + } + + public void Handle(IChatSource source, string cmd) + { + if (!CommandTokenizer.TryTokenize(cmd, out var parts, out var error)) + { + source.SendMsg(error ?? "Invalid command arguments."); + return; + } + + if (parts.Length == 0) + return; + + if (handlers.TryGetValue(parts[0], out var registration)) + { + if (!registration.Info.CanUse(source)) + source.SendMsg(registration.Info.PermissionDeniedMessage); + else + registration.Command.Execute(new ChatCommandContext(source, parts[0], parts.Skip(1).ToArray())); + } + else + { + source.SendMsg("Invalid command. Use help or ? to list available commands."); + } + } + + public void AddCommand(string name, IChatCommand command, ChatCommandInfo info) => + AddCommands([name], command, info); + + public void AddCommand(string name, IChatCommand command) + { + var names = handlers + .Where(entry => ReferenceEquals(entry.Value.Command, command)) + .Select(entry => entry.Key) + .Append(name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var info = new ChatCommandInfo(command, names, GetDescription(command), GetUsage(command), RequiresHost(command)); + AddCommands(names, command, info); + } + + public void AddCommands(string[] names, IChatCommand command, string description, string usage, bool requiresHost) + { + var registeredNames = names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var info = new ChatCommandInfo(command, registeredNames, description, usage, requiresHost); + AddCommands(registeredNames, command, info); + } + + private void AddCommands(string[] names, IChatCommand command, ChatCommandInfo info) + { + if (command is ChatCommand chatCommand) + chatCommand.ConfigurePermissions(info.RequiresHost); + + foreach (var name in names) + handlers[name] = new ChatCommandRegistration(command, info); + } + + private static string GetDescription(IChatCommand command) => + command is IChatCommandMetadata metadata ? metadata.Description : string.Empty; + + private static string GetUsage(IChatCommand command) => + command is IChatCommandMetadata metadata ? metadata.Usage : string.Empty; + + private static bool RequiresHost(IChatCommand command) => + command is IChatCommandMetadata { RequiresHost: true }; + + public IEnumerable GetCommandNames() => handlers.Keys.OrderBy(name => name); + + public IEnumerable GetCommandInfos() + { + return handlers + .Select(entry => entry.Value.Info) + .Distinct() + .OrderBy(info => info.PrimaryName); + } + + public bool TryGetCommandInfo(string name, out ChatCommandInfo info) + { + if (handlers.TryGetValue(name, out var registration)) + { + info = registration.Info; + return true; + } + + info = null!; + return false; + } +} diff --git a/Source/Common/ChatCommands/ChatCommandParser.cs b/Source/Common/ChatCommands/ChatCommandParser.cs new file mode 100644 index 000000000..0acdd65c2 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandParser.cs @@ -0,0 +1,3 @@ +namespace Multiplayer.Common.ChatCommands; + +public delegate bool ChatCommandParser(ChatCommandContext context, out TArgs args, out string? error); diff --git a/Source/Common/ChatCommands/ChatCommandRegistration.cs b/Source/Common/ChatCommands/ChatCommandRegistration.cs new file mode 100644 index 000000000..bc4cb99d4 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandRegistration.cs @@ -0,0 +1,3 @@ +namespace Multiplayer.Common.ChatCommands; + +public sealed record ChatCommandRegistration(IChatCommand Command, ChatCommandInfo Info); diff --git a/Source/Common/ChatCommands/ChatCommandRegistry.cs b/Source/Common/ChatCommands/ChatCommandRegistry.cs new file mode 100644 index 000000000..993c21d73 --- /dev/null +++ b/Source/Common/ChatCommands/ChatCommandRegistry.cs @@ -0,0 +1,19 @@ +namespace Multiplayer.Common.ChatCommands; + +/// +/// The concrete command registry is generated by ChatCommandRegistryGenerator +/// from classes annotated with [ChatCommand]. +/// +/// +/// This partial declaration exists so the generated registry has a stable source +/// location in the IDE. The generated half provides Register, creates each +/// built-in command, attaches generated argument parsers, and registers command +/// names and aliases with . +/// +internal static partial class ChatCommandRegistry +{ + /// + /// Registers all source-generated chat commands and aliases. + /// + public static partial void Register(ChatCommandManager manager, MultiplayerServer server); +} diff --git a/Source/Common/ChatCommands/CommandTokenizer.cs b/Source/Common/ChatCommands/CommandTokenizer.cs new file mode 100644 index 000000000..80131d430 --- /dev/null +++ b/Source/Common/ChatCommands/CommandTokenizer.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Multiplayer.Common.ChatCommands; + +internal struct CommandTokenizer +{ + private const string MissingClosingQuoteError = "Invalid command arguments: missing closing quote."; + + private readonly string input; + private int index; + + private CommandTokenizer(string input) + { + this.input = input; + index = 0; + } + + public static bool TryTokenize(string input, out string[] tokens, out string? error) + { + var tokenizer = new CommandTokenizer(input); + return tokenizer.TryReadAll(out tokens, out error); + } + + private bool TryReadAll(out string[] tokens, out string? error) + { + var result = new List(); + + while (true) + { + SkipWhitespace(); + if (index >= input.Length) + { + tokens = [.. result]; + error = null; + return true; + } + + if (!TryReadToken(out var token, out error)) + { + tokens = []; + return false; + } + + result.Add(token); + } + } + + private void SkipWhitespace() + { + while (index < input.Length && char.IsWhiteSpace(input[index])) + index++; + } + + private bool TryReadToken(out string token, out string? error) + { + var tokenPartStart = index; + StringBuilder? builder = null; + + while (index < input.Length && !char.IsWhiteSpace(input[index])) + { + if (input[index] == '"') + { + builder ??= new StringBuilder(); + builder.Append(input, tokenPartStart, index - tokenPartStart); + + index++; + if (!TryReadQuotedToken(builder, out error)) + { + token = string.Empty; + return false; + } + + tokenPartStart = index; + continue; + } + + ReadUnquotedToken(); + } + + if (builder == null) + { + token = input[tokenPartStart..index]; + } + else + { + builder.Append(input, tokenPartStart, index - tokenPartStart); + token = builder.ToString(); + } + + error = null; + return true; + } + + private bool TryReadQuotedToken(StringBuilder builder, out string? error) + { + while (index < input.Length) + { + var c = input[index++]; + + if (c == '"') + { + error = null; + return true; + } + + if (c == '\\') + { + if (index >= input.Length) + { + builder.Append('\\'); + continue; + } + + builder.Append(input[index++]); + continue; + } + + builder.Append(c); + } + + error = MissingClosingQuoteError; + return false; + } + + private void ReadUnquotedToken() + { + while (index < input.Length && !char.IsWhiteSpace(input[index]) && input[index] != '"') + index++; + } +} diff --git a/Source/Common/ChatCommands/IChatCommand.cs b/Source/Common/ChatCommands/IChatCommand.cs new file mode 100644 index 000000000..520e022d5 --- /dev/null +++ b/Source/Common/ChatCommands/IChatCommand.cs @@ -0,0 +1,10 @@ +namespace Multiplayer.Common.ChatCommands; + +public interface IChatCommand +{ + bool CanUse(IChatSource source); + + string PermissionDeniedMessage { get; } + + void Execute(ChatCommandContext context); +} diff --git a/Source/Common/ChatCommands/LegacyChatCommands.cs b/Source/Common/ChatCommands/LegacyChatCommands.cs new file mode 100644 index 000000000..ea94f6ee3 --- /dev/null +++ b/Source/Common/ChatCommands/LegacyChatCommands.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using Multiplayer.Common.ChatCommands; + +namespace Multiplayer.Common; + +[Obsolete("Use Multiplayer.Common.ChatCommands.ChatCommandInfo instead.")] +public sealed class ChatCmdInfo(ChatCmdHandler handler, string[] names) +{ + public ChatCmdHandler Handler { get; } = handler; + public string[] Names { get; } = names; + public string PrimaryName => Names.First(); + public string DisplayNames => string.Join(", ", Names); +} + +[Obsolete("Use Multiplayer.Common.ChatCommands.ChatCommand or ChatCommand instead.")] +public abstract class ChatCmdHandler : IChatCommand, IChatCommandMetadata +{ + public bool requiresHost; + + public MultiplayerServer Server => MultiplayerServer.instance!; + + public virtual string Description => string.Empty; + public virtual string Usage => string.Empty; + bool IChatCommandMetadata.RequiresHost => requiresHost; + public virtual string PermissionDeniedMessage => "No permission"; + + public virtual bool CanUse(IChatSource source) + { + return !requiresHost || source is not ServerPlayer { IsHost: false }; + } + + public void Execute(ChatCommandContext context) + { + Handle(context.Source, context.RawArgs.ToArray()); + } + + public abstract void Handle(IChatSource source, string[] args); + + public void SendNoPermission(ServerPlayer player) + { + player.SendMsg("You don't have permission."); + } + + public ServerPlayer? FindPlayer(string username) + { + return Server.GetPlayer(username); + } +} + +internal interface IChatCommandMetadata +{ + string Description { get; } + string Usage { get; } + bool RequiresHost { get; } +} diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index 5e5c84ae1..6693eee0f 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -21,6 +21,11 @@ + + + + + diff --git a/Source/Common/IChatSource.cs b/Source/Common/IChatSource.cs index 83fb0b465..a272a6e63 100644 --- a/Source/Common/IChatSource.cs +++ b/Source/Common/IChatSource.cs @@ -3,4 +3,5 @@ public interface IChatSource { void SendMsg(string msg); + void SendRawMsg(string msg); } diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 1312a2b4c..7d3940f8b 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using HarmonyLib; +using Multiplayer.Common.ChatCommands; using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common @@ -36,7 +37,7 @@ static MultiplayerServer() public WorldData worldData; public FreezeManager freezeManager; public CommandHandler commands; - public ChatCmdManager chatCmdManager; + public ChatCommandManager chatCmdManager; public PlayerManager playerManager; public List netManagers = []; public IEnumerable JoinedPlayers => playerManager.JoinedPlayers; @@ -83,15 +84,10 @@ public MultiplayerServer(ServerSettings settings) worldData = new WorldData(this); freezeManager = new FreezeManager(this); commands = new CommandHandler(this); - chatCmdManager = new ChatCmdManager(); + chatCmdManager = new ChatCommandManager(this); playerManager = new PlayerManager(this); - var helpCmd = new ChatCmdHelp(); - RegisterChatCmd("help", helpCmd); - RegisterChatCmd("?", helpCmd); - RegisterChatCmd("joinpoint", new ChatCmdJoinPoint()); - RegisterChatCmd("kick", new ChatCmdKick()); - RegisterChatCmd("stop", new ChatCmdStop()); + ChatCommandRegistry.Register(chatCmdManager, this); initDataSource.SetResult(null); } @@ -273,10 +269,20 @@ public void SendChat(string msg) public void SendNotification(string key, params string[] args) => SendToPlaying(new ServerNotificationPacket(key) { args = args }); + public void RegisterChatCommand(string commandName, IChatCommand command) => + chatCmdManager.AddCommand(commandName, command); + + public void RegisterChatCommand(string[] commandNames, IChatCommand command, string description = "", string usage = "", bool requiresHost = false) => + chatCmdManager.AddCommands(commandNames, command, description, usage, requiresHost); + + [Obsolete("Use RegisterChatCommand instead.")] public void RegisterChatCmd(string cmdName, ChatCmdHandler handler) => - chatCmdManager.AddCommandHandler(cmdName, handler); + RegisterChatCommand(cmdName, handler); + + public void HandleChatCommand(IChatSource source, string command) => chatCmdManager.Handle(source, command); - public void HandleChatCmd(IChatSource source, string cmd) => chatCmdManager.Handle(source, cmd); + [Obsolete("Use HandleChatCommand instead.")] + public void HandleChatCmd(IChatSource source, string cmd) => HandleChatCommand(source, cmd); public Task InitDataTask() => initDataSource.Task; diff --git a/Source/Common/Networking/Packet/ChatPacket.cs b/Source/Common/Networking/Packet/ChatPacket.cs index 52d273d51..c2c57c812 100644 --- a/Source/Common/Networking/Packet/ChatPacket.cs +++ b/Source/Common/Networking/Packet/ChatPacket.cs @@ -4,12 +4,16 @@ namespace Multiplayer.Common.Networking.Packet; public record struct ServerChatPacket : IPacket { public string msg; + public bool rawMessage; public static ServerChatPacket Create(string msg) => new() { msg = msg.Trim() }; + public static ServerChatPacket CreateRaw(string msg) => new() { msg = msg.Trim(), rawMessage = true }; public void Bind(PacketBuffer buf) { buf.Bind(ref msg); + if (buf.isWriting || buf.DataRemaining) + buf.Bind(ref rawMessage); } } @@ -17,11 +21,18 @@ public void Bind(PacketBuffer buf) public record struct ClientChatPacket : IPacket { public string msg; + public bool helpOnlyUsableCommands; - public static ClientChatPacket Create(string msg) => new() { msg = msg.Trim() }; + public static ClientChatPacket Create(string msg, bool helpOnlyUsableCommands = false) => new() + { + msg = msg.Trim(), + helpOnlyUsableCommands = helpOnlyUsableCommands + }; public void Bind(PacketBuffer buf) { buf.Bind(ref msg); + if (buf.isWriting || buf.DataRemaining) + buf.Bind(ref helpOnlyUsableCommands); } } diff --git a/Source/Common/Networking/Packet/RejoinPacket.cs b/Source/Common/Networking/Packet/RejoinPacket.cs new file mode 100644 index 000000000..ef27722f0 --- /dev/null +++ b/Source/Common/Networking/Packet/RejoinPacket.cs @@ -0,0 +1,9 @@ +namespace Multiplayer.Common.Networking.Packet; + +[PacketDefinition(Packets.Server_RequestRejoin)] +public record struct ServerRequestRejoinPacket : IPacket +{ + public void Bind(PacketBuffer buf) + { + } +} diff --git a/Source/Common/Networking/Packets.cs b/Source/Common/Networking/Packets.cs index 13186cd32..0d3cd3a69 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -63,6 +63,7 @@ public enum Packets : byte Server_PingLocation, Server_Traces, Server_SetFaction, + Server_RequestRejoin, // All states (Joining, Loading, Playing) Server_Disconnect, diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index 39b752158..f7b21e7a7 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -63,6 +63,8 @@ public void HandleClientCommand(ClientCommandPacket packet) [TypedPacketHandler] public void HandleChat(ClientChatPacket packet) { + Player.helpOnlyUsableCommands = packet.helpOnlyUsableCommands; + string msg = packet.msg; msg = msg.Trim(); @@ -74,7 +76,7 @@ public void HandleChat(ClientChatPacket packet) if (msg[0] == '/') { var cmd = msg[1..]; - Server.HandleChatCmd(Player, cmd); + Server.HandleChatCommand(Player, cmd); } else { diff --git a/Source/Common/Properties/AssemblyInfo.cs b/Source/Common/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..9ead8d2ec --- /dev/null +++ b/Source/Common/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Tests")] diff --git a/Source/Common/ServerPlayer.cs b/Source/Common/ServerPlayer.cs index 51471d217..f1a231c97 100644 --- a/Source/Common/ServerPlayer.cs +++ b/Source/Common/ServerPlayer.cs @@ -33,6 +33,7 @@ public class ServerPlayer : IChatSource // Track which map the player is currently on public int currentMapId = -1; public bool hasReportedCurrentMap; + public bool helpOnlyUsableCommands; public string Username => conn.username; public int Latency => conn.Latency; @@ -129,6 +130,7 @@ public void ResetTimeVotes() } public void SendMsg(string msg) => SendPacket(ServerChatPacket.Create(msg)); + public void SendRawMsg(string msg) => SendPacket(ServerChatPacket.CreateRaw(msg)); } public enum PlayerStatus : byte diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index 68009be35..9e8b83277 100644 --- a/Source/Common/Version.cs +++ b/Source/Common/Version.cs @@ -6,7 +6,7 @@ namespace Multiplayer.Common public static class MpVersion { public const string SimpleVersion = "0.11.5"; - public const int Protocol = 55; + public const int Protocol = 56; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/Multiplayer.sln b/Source/Multiplayer.sln index ed00c539f..8c8f90207 100644 --- a/Source/Multiplayer.sln +++ b/Source/Multiplayer.sln @@ -15,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiplayerLoader", "Multip EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestsOnMono", "TestsOnMono\TestsOnMono.csproj", "{B9258593-604D-4862-96EF-192B2BC6250E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGen", "SourceGen\SourceGen.csproj", "{61D773A9-7B5D-443D-893E-F5C7A56272CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatCommandContracts", "ChatCommandContracts\ChatCommandContracts.csproj", "{64FFAC22-1A74-492B-965E-7AA953F263C3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -49,6 +53,14 @@ Global {B9258593-604D-4862-96EF-192B2BC6250E}.Debug|Any CPU.Build.0 = Debug|Any CPU {B9258593-604D-4862-96EF-192B2BC6250E}.Release|Any CPU.ActiveCfg = Release|Any CPU {B9258593-604D-4862-96EF-192B2BC6250E}.Release|Any CPU.Build.0 = Release|Any CPU + {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Release|Any CPU.Build.0 = Release|Any CPU + {64FFAC22-1A74-492B-965E-7AA953F263C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64FFAC22-1A74-492B-965E-7AA953F263C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64FFAC22-1A74-492B-965E-7AA953F263C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64FFAC22-1A74-492B-965E-7AA953F263C3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index 255cbcd23..30890e715 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -123,7 +123,7 @@ { var cmd = Console.ReadLine(); if (cmd != null) - server.Enqueue(() => server.HandleChatCmd(consoleSource, cmd)); + server.Enqueue(() => server.HandleChatCommand(consoleSource, cmd)); if (cmd == stopCmd) break; @@ -140,4 +140,9 @@ public void SendMsg(string msg) { ServerLog.Log(msg); } + + public void SendRawMsg(string msg) + { + SendMsg(msg); + } } diff --git a/Source/SourceGen/ChatCommandRegistryGenerator.cs b/Source/SourceGen/ChatCommandRegistryGenerator.cs new file mode 100644 index 000000000..a0a71bf60 --- /dev/null +++ b/Source/SourceGen/ChatCommandRegistryGenerator.cs @@ -0,0 +1,709 @@ +using System; +using System.Collections.Immutable; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Multiplayer.Common; + +namespace Multiplayer.SourceGen; + +[Generator] +public sealed class ChatCommandRegistryGenerator : IIncrementalGenerator +{ + private static readonly string ChatCommandAttributeName = AttributeMetadataName(nameof(ChatCommandAttribute)); + private const string ChatCommandGenericName = "Multiplayer.Common.ChatCommands.ChatCommand"; + private const string ChatCommandContextName = "Multiplayer.Common.ChatCommands.ChatCommandContext"; + private const string ServerPlayerName = "Multiplayer.Common.ServerPlayer"; + private static readonly string ChatArgumentAttributeName = AttributeMetadataName(nameof(ChatArgumentAttribute)); + private static readonly string ChatRestAttributeName = AttributeMetadataName(nameof(ChatRestAttribute)); + + private static readonly DiagnosticDescriptor DuplicateNameDescriptor = new( + "MPCHAT001", + "Duplicate chat command name", + "Chat command name or alias '{0}' is already registered by '{1}'", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidCommandDescriptor = new( + "MPCHAT002", + "Invalid chat command type", + "Chat command '{0}' must implement Multiplayer.Common.ChatCommands.IChatCommand", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor UnsupportedArgumentDescriptor = new( + "MPCHAT003", + "Unsupported chat command argument", + "Chat command argument '{0}' has unsupported type '{1}'", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidRestArgumentDescriptor = new( + "MPCHAT004", + "Invalid chat rest argument", + "Chat rest argument '{0}' {1}", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidArgumentParserDescriptor = new( + "MPCHAT005", + "Invalid chat command argument parser", + "Chat command arguments '{0}' {1}", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidCommandConstructorDescriptor = new( + "MPCHAT006", + "Invalid chat command constructor", + "Chat command '{0}' must have an accessible parameterless constructor", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly DiagnosticDescriptor InvalidNameDescriptor = new( + "MPCHAT007", + "Invalid chat command name", + "Chat command name or alias cannot be blank", + "ChatCommands", + DiagnosticSeverity.Error, + true + ); + + private static readonly SymbolDisplayFormat FullyQualifiedNullableFormat = + SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + ); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var commands = context.SyntaxProvider + .ForAttributeWithMetadataName( + ChatCommandAttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (ctx, _) => CreateCommand(ctx) + ) + .Where(static command => command is not null) + .Select(static (command, _) => command!) + .Collect(); + + context.RegisterSourceOutput(commands, Generate); + } + + private static ChatCommandModel? CreateCommand(GeneratorAttributeSyntaxContext context) + { + var type = (INamedTypeSymbol)context.TargetSymbol; + var attribute = context.Attributes.First(attribute => + attribute.AttributeClass?.ToDisplayString() == ChatCommandAttributeName + ); + + var name = attribute.ConstructorArguments.Length > 0 + ? attribute.ConstructorArguments[0].Value?.ToString() ?? type.Name + : type.Name; + + var aliases = attribute.ConstructorArguments.Length > 1 + ? attribute.ConstructorArguments[1].Values + .Select(value => value.Value?.ToString() ?? string.Empty) + .ToArray() + : []; + + var namedArguments = attribute.NamedArguments.ToDictionary(argument => argument.Key, argument => argument.Value); + var description = GetString(namedArguments, "Description"); + var usage = GetString(namedArguments, "Usage"); + var requiresHost = namedArguments.TryGetValue("RequiresHost", out var hostValue) && (bool)(hostValue.Value ?? false); + + return new ChatCommandModel(type, name, aliases, description, usage, requiresHost); + } + + private static string GetString(IReadOnlyDictionary arguments, string key) => + arguments.TryGetValue(key, out var value) ? value.Value?.ToString() ?? string.Empty : string.Empty; + + private static void Generate(SourceProductionContext context, ImmutableArray commands) + { + if (commands.Length == 0) + return; + + var validCommands = new List(); + var seenNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var command in commands.OrderBy(command => command.Type.ToDisplayString(), StringComparer.Ordinal)) + { + if (!ImplementsInterface(command.Type, "Multiplayer.Common.ChatCommands.IChatCommand")) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidCommandDescriptor, + command.Type.Locations.FirstOrDefault(), + command.Type.ToDisplayString() + )); + continue; + } + + if (!HasAccessibleParameterlessConstructor(command.Type)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidCommandConstructorDescriptor, + command.Type.Locations.FirstOrDefault(), + command.Type.ToDisplayString() + )); + continue; + } + + var commandType = command.Type.ToDisplayString(); + var hasInvalidName = false; + var hasDuplicateName = false; + foreach (var name in command.AllNames) + { + if (string.IsNullOrWhiteSpace(name)) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidNameDescriptor, + command.Type.Locations.FirstOrDefault() + )); + hasInvalidName = true; + continue; + } + + if (!seenNames.TryAdd(name, commandType)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateNameDescriptor, + command.Type.Locations.FirstOrDefault(), + name, + seenNames[name] + )); + hasDuplicateName = true; + } + } + + if (!hasInvalidName && !hasDuplicateName) + validCommands.Add(command); + } + + if (validCommands.Count == 0) + return; + + var source = CreateRegistrySource(context, validCommands); + context.AddSource("ChatCommandRegistry.g.cs", SourceText.From(source, Encoding.UTF8)); + } + + private static string CreateRegistrySource(SourceProductionContext context, IReadOnlyList commands) + { + var registrations = new StringBuilder(); + var parsers = new StringBuilder(); + + for (var i = 0; i < commands.Count; i++) + { + var command = commands[i]; + var variable = $"command{i}"; + var metadata = $"metadata{i}"; + var commandType = command.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var names = command.AllNames.ToArray(); + var namesExpression = "new string[] { " + string.Join(", ", names.Select(StringLiteral)) + " }"; + + registrations.AppendLine($"var {variable} = new {commandType}();"); + + var argsType = GetChatCommandArgumentType(command.Type); + if (argsType != null) + { + var parserName = $"TryParseCommand{i}Args"; + registrations.AppendLine($"{variable}.SetParser({parserName});"); + parsers.AppendLine(CreateParser(context, command, argsType, parserName)); + } + + registrations.AppendLine( + $"var {metadata} = new global::Multiplayer.Common.ChatCommands.ChatCommandInfo({variable}, {namesExpression}, {StringLiteral(command.Description)}, {StringLiteral(command.Usage)}, {command.RequiresHost.ToString().ToLowerInvariant()});" + ); + + foreach (var name in names) + registrations.AppendLine($"manager.AddCommand({StringLiteral(name)}, {variable}, {metadata});"); + + registrations.AppendLine(); + } + + return $$""" + // + #nullable enable + + namespace Multiplayer.Common.ChatCommands; + + internal static partial class ChatCommandRegistry + { + public static partial void Register(global::Multiplayer.Common.ChatCommands.ChatCommandManager manager, global::Multiplayer.Common.MultiplayerServer server) + { + {{Indent(registrations.ToString().TrimEnd(), 8)}} + } + + {{Indent(parsers.ToString().TrimEnd(), 4)}} + } + """; + } + + private static string CreateParser(SourceProductionContext context, ChatCommandModel command, ITypeSymbol argsType, string parserName) + { + var customParser = FindCustomParser(command.Type, argsType); + if (customParser != null) + return CreateCustomParser(command, argsType, parserName); + + var constructor = FindConstructor(context, argsType, out var hasValidConstructorSelection); + + if (!hasValidConstructorSelection) + return CreateInvalidParser(argsType, parserName); + + if (constructor == null) + { + if (!HasAccessibleParameterlessConstructor(argsType)) + { + ReportInvalidArgumentParser(context, argsType, "must have an accessible constructor"); + return CreateInvalidParser(argsType, parserName); + } + + return CreateParameterlessParser(argsType, parserName); + } + + if (!ValidateConstructorParameters(context, constructor)) + return CreateInvalidParser(argsType, parserName); + + var body = new StringBuilder(); + var values = new List(); + var index = 0; + + foreach (var parameter in constructor.Parameters) + { + var variable = $"arg{index}"; + var displayName = GetArgumentName(parameter); + var isRest = IsRestArgument(constructor, parameter); + var isOptional = IsOptional(parameter); + + if (!isOptional) + AppendRequiredArgumentCheck(body, command, displayName, index); + + var rawExpression = isRest + ? $"global::Multiplayer.Common.ChatCommands.ChatCommandArgumentReader.JoinRest(context.RawArgs, {index})" + : $"context.RawArgs[{index}]"; + var defaultExpression = DefaultExpression(parameter); + var parseExpression = ParseExpression(context, parameter, rawExpression, variable, out var parseStatements); + var valueExpression = variable; + if (isOptional && parseStatements.Length > 0) + { + body.AppendLine($$""" + {{parameter.Type.ToDisplayString(FullyQualifiedNullableFormat)}} {{variable}}; + if (context.RawArgs.Count > {{index}}) + { + {{Indent(parseStatements.TrimEnd(), 8)}} + {{variable}} = {{parseExpression}}; + } + else + { + {{variable}} = {{defaultExpression}}; + } + """); + } + else if (isOptional) + { + body.AppendLine($" var {variable} = context.RawArgs.Count > {index} ? {parseExpression} : {defaultExpression};"); + } + else + { + if (parseStatements.Length > 0) + { + body.Append(Indent(parseStatements.TrimEnd(), 4)); + body.AppendLine(); + } + + valueExpression = parseExpression; + } + + values.Add(valueExpression); + index++; + } + + var argsTypeName = argsType.ToDisplayString(FullyQualifiedNullableFormat); + return $$""" + private static bool {{parserName}}(global::Multiplayer.Common.ChatCommands.ChatCommandContext context, out {{argsTypeName}} args, out string? error) + { + {{body.ToString().TrimEnd()}} + args = new {{argsTypeName}}({{string.Join(", ", values)}}); + error = null; + return true; + } + """; + } + + private static string CreateCustomParser(ChatCommandModel command, ITypeSymbol argsType, string parserName) + { + return $$""" + private static bool {{parserName}}(global::Multiplayer.Common.ChatCommands.ChatCommandContext context, out {{argsType.ToDisplayString(FullyQualifiedNullableFormat)}} args, out string? error) + { + if ({{command.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}}.TryParse(context, out args)) + { + error = null; + return true; + } + + error = "Invalid command arguments."; + return false; + } + """; + } + + private static string CreateParameterlessParser(ITypeSymbol argsType, string parserName) + { + return $$""" + private static bool {{parserName}}(global::Multiplayer.Common.ChatCommands.ChatCommandContext context, out {{argsType.ToDisplayString(FullyQualifiedNullableFormat)}} args, out string? error) + { + args = new {{argsType.ToDisplayString(FullyQualifiedNullableFormat)}}(); + error = null; + return true; + } + """; + } + + private static string CreateInvalidParser(ITypeSymbol argsType, string parserName) + { + return $$""" + private static bool {{parserName}}(global::Multiplayer.Common.ChatCommands.ChatCommandContext context, out {{argsType.ToDisplayString(FullyQualifiedNullableFormat)}} args, out string? error) + { + args = default; + error = "Invalid command arguments."; + return false; + } + """; + } + + private static IMethodSymbol? FindConstructor(SourceProductionContext context, ITypeSymbol argsType, out bool isValid) + { + isValid = true; + var constructors = argsType + .GetMembers(".ctor") + .OfType() + .Where(ctor => !ctor.IsStatic && ctor.Parameters.Length > 0 && IsAccessible(ctor)) + .ToArray(); + + if (constructors.Length == 0) + return null; + + var largestParameterCount = constructors.Max(ctor => ctor.Parameters.Length); + var candidates = constructors + .Where(ctor => ctor.Parameters.Length == largestParameterCount) + .ToArray(); + + if (candidates.Length == 1) + return candidates[0]; + + ReportInvalidArgumentParser(context, argsType, "has ambiguous constructors"); + isValid = false; + return null; + } + + private static bool HasAccessibleParameterlessConstructor(ITypeSymbol argsType) => + argsType.IsValueType + || argsType.GetMembers(".ctor") + .OfType() + .Any(ctor => !ctor.IsStatic && ctor.Parameters.Length == 0 && IsAccessible(ctor)); + + private static void ReportInvalidArgumentParser(SourceProductionContext context, ITypeSymbol argsType, string message) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidArgumentParserDescriptor, + argsType.Locations.FirstOrDefault(), + argsType.ToDisplayString(), + message + )); + } + + private static bool ValidateConstructorParameters(SourceProductionContext context, IMethodSymbol constructor) + { + var valid = true; + var hasRestArgument = false; + + for (var i = 0; i < constructor.Parameters.Length; i++) + { + var parameter = constructor.Parameters[i]; + if (!IsRestArgument(constructor, parameter)) + continue; + + if (hasRestArgument) + { + ReportInvalidRestArgument(context, parameter, "must be the only rest argument"); + valid = false; + } + + hasRestArgument = true; + + if (!IsValidRestType(parameter.Type)) + { + ReportInvalidRestArgument(context, parameter, "must be a string or ServerPlayer"); + valid = false; + } + + if (i != constructor.Parameters.Length - 1) + { + ReportInvalidRestArgument(context, parameter, "must be the final argument"); + valid = false; + } + } + + return valid; + } + + private static bool IsRestArgument(IMethodSymbol constructor, IParameterSymbol parameter) + { + return HasAttribute(parameter, ChatRestAttributeName) + || (constructor.Parameters.Length == 1 && IsValidRestType(parameter.Type)); + } + + private static bool IsValidRestType(ITypeSymbol type) + { + var nonNullable = NonNullableType(type); + return nonNullable.SpecialType == SpecialType.System_String + || nonNullable.ToDisplayString() == ServerPlayerName; + } + + private static void ReportInvalidRestArgument(SourceProductionContext context, IParameterSymbol parameter, string message) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidRestArgumentDescriptor, + parameter.Locations.FirstOrDefault(), + parameter.Name, + message + )); + } + + private static void AppendRequiredArgumentCheck(StringBuilder body, ChatCommandModel command, string displayName, int index) + { + body.AppendLine($$""" + if (!global::Multiplayer.Common.ChatCommands.ChatCommandArgumentReader.HasArgument(context, {{index}}, {{StringLiteral(MissingArgumentMessage(command, displayName))}}, out error)) + { + args = default; + return false; + } + """); + } + + private static string ParseExpression(SourceProductionContext context, IParameterSymbol parameter, string rawExpression, string variable, out string statements) + { + statements = string.Empty; + var type = parameter.Type; + var nonNullable = type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } named + ? named.TypeArguments[0] + : type; + + if (nonNullable.SpecialType == SpecialType.System_String) + return rawExpression; + + if (nonNullable.SpecialType == SpecialType.System_Int32) + { + statements = CreateTryParseStatements("TryParseInt", rawExpression, variable, parameter); + return $"parsed{variable}"; + } + + if (nonNullable.SpecialType == SpecialType.System_Boolean) + { + statements = CreateTryParseStatements("TryParseBool", rawExpression, variable, parameter); + return $"parsed{variable}"; + } + + if (nonNullable.SpecialType == SpecialType.System_Single) + { + statements = CreateTryParseStatements("TryParseFloat", rawExpression, variable, parameter); + return $"parsed{variable}"; + } + + if (nonNullable.TypeKind == TypeKind.Enum) + { + var typeName = nonNullable.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + statements = CreateTryParseStatements($"TryParseEnum<{typeName}>", rawExpression, variable, parameter); + return $"parsed{variable}"; + } + + if (nonNullable.ToDisplayString() == ServerPlayerName) + { + statements = CreateTryParseStatements("TryParsePlayer", $"context, {rawExpression}", variable, parameter); + return $"parsed{variable}"; + } + + context.ReportDiagnostic(Diagnostic.Create( + UnsupportedArgumentDescriptor, + parameter.Locations.FirstOrDefault(), + parameter.Name, + type.ToDisplayString() + )); + return rawExpression; + } + + private static string CreateTryParseStatements(string method, string input, string valueName, IParameterSymbol symbol) + { + return $$""" + if (!global::Multiplayer.Common.ChatCommands.ChatCommandArgumentReader.{{method}}({{input}}, {{StringLiteral(GetArgumentName(symbol))}}, out var parsed{{valueName}}, out error)) + { + args = default; + return false; + } + """; + } + + private static string MissingArgumentMessage(ChatCommandModel command, string name) => + string.IsNullOrWhiteSpace(command.Usage) + ? $"Missing argument: {name}." + : $"Usage: {command.Usage}"; + + private static string DefaultExpression(IParameterSymbol parameter) + { + if (parameter.HasExplicitDefaultValue) + { + var nonNullable = NonNullableType(parameter.Type); + if (parameter.ExplicitDefaultValue != null && nonNullable.TypeKind == TypeKind.Enum) + return $"({nonNullable.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}){Literal(parameter.ExplicitDefaultValue)}"; + + return Literal(parameter.ExplicitDefaultValue); + } + + return parameter.Type.NullableAnnotation == NullableAnnotation.Annotated + || parameter.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + ? "null" + : "default"; + } + + private static ITypeSymbol NonNullableType(ITypeSymbol type) => + type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } named + ? named.TypeArguments[0] + : type; + + private static string Literal(object? value) + { + return value switch + { + null => "null", + string s => StringLiteral(s), + char c => "'" + c.ToString().Replace("\\", "\\\\").Replace("'", "\\'") + "'", + bool b => b ? "true" : "false", + float f => f.ToString(CultureInfo.InvariantCulture) + "f", + double d => d.ToString(CultureInfo.InvariantCulture) + "d", + decimal d => d.ToString(CultureInfo.InvariantCulture) + "m", + _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? "default" + }; + } + + private static bool IsOptional(IParameterSymbol parameter) => + parameter.HasExplicitDefaultValue + || parameter.NullableAnnotation == NullableAnnotation.Annotated + || parameter.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }; + + private static string GetArgumentName(IParameterSymbol parameter) + { + var attribute = parameter.GetAttributes().FirstOrDefault(attribute => + attribute.AttributeClass?.ToDisplayString() == ChatArgumentAttributeName + ); + + return attribute?.ConstructorArguments.FirstOrDefault().Value?.ToString() + ?? parameter.Name; + } + + private static ITypeSymbol? GetChatCommandArgumentType(INamedTypeSymbol type) + { + for (var current = type.BaseType; current != null; current = current.BaseType) + { + if (current.OriginalDefinition.ToDisplayString() == ChatCommandGenericName) + return current.TypeArguments[0]; + } + + return null; + } + + private static IMethodSymbol? FindCustomParser(INamedTypeSymbol commandType, ITypeSymbol argsType) + { + return commandType.GetMembers("TryParse") + .OfType() + .FirstOrDefault(method => + method.IsStatic + && IsAccessible(method) + && method.ReturnType.SpecialType == SpecialType.System_Boolean + && method.Parameters.Length == 2 + && method.Parameters[0].Type.ToDisplayString() == ChatCommandContextName + && method.Parameters[1].RefKind == RefKind.Out + && SymbolEqualityComparer.Default.Equals(method.Parameters[1].Type, argsType) + ); + } + + private static bool IsAccessible(ISymbol symbol) => + symbol.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal; + + private static bool ImplementsInterface(INamedTypeSymbol type, string interfaceName) => + type.AllInterfaces.Any(@interface => @interface.ToDisplayString() == interfaceName); + + private static bool HasAttribute(ISymbol symbol, string attributeName) => + symbol.GetAttributes().Any(attribute => attribute.AttributeClass?.ToDisplayString() == attributeName); + + private static string AttributeMetadataName(string attributeTypeName) => + $"Multiplayer.Common.{attributeTypeName}"; + + private static string StringLiteral(string value) => + "@\"" + value.Replace("\"", "\"\"") + "\""; + + private static string Indent(string value, int spaces) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var prefix = new string(' ', spaces); + return string.Join( + "\n", + value.Split(["\r\n", "\n"], StringSplitOptions.None) + .Select(line => line.Length == 0 ? string.Empty : prefix + line) + ); + } + + private sealed class ChatCommandModel + { + public ChatCommandModel( + INamedTypeSymbol type, + string name, + string[] aliases, + string description, + string usage, + bool requiresHost + ) + { + Type = type; + Name = name; + Aliases = aliases; + Description = description; + Usage = usage; + RequiresHost = requiresHost; + } + + public INamedTypeSymbol Type { get; } + public string Name { get; } + public string[] Aliases { get; } + public string Description { get; } + public string Usage { get; } + public bool RequiresHost { get; } + public IEnumerable AllNames => new[] { Name }.Concat(Aliases); + } +} + +internal static class DictionaryExtensions +{ + public static bool TryAdd(this Dictionary dictionary, TKey key, TValue value) + { + if (dictionary.ContainsKey(key)) + return false; + + dictionary.Add(key, value); + return true; + } +} diff --git a/Source/SourceGen/SourceGen.csproj b/Source/SourceGen/SourceGen.csproj new file mode 100644 index 000000000..e2387a071 --- /dev/null +++ b/Source/SourceGen/SourceGen.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0 + enable + 12 + true + true + $(NoWarn);RS1041 + + + + + + + + + + + + diff --git a/Source/Tests/ChatCommandGeneratorTest.cs b/Source/Tests/ChatCommandGeneratorTest.cs new file mode 100644 index 000000000..11d7257ea --- /dev/null +++ b/Source/Tests/ChatCommandGeneratorTest.cs @@ -0,0 +1,626 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Multiplayer.Common; +using Multiplayer.Common.ChatCommands; +using Multiplayer.SourceGen; + +namespace Tests; + +public class ChatCommandGeneratorTest +{ + [Test] + public void ChatCommand_RegistersCommand() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("ping", Description = "Ping the server.", Usage = "ping")] + public sealed class PingCommand : ChatCommand + { + public override void Execute(ChatCommandContext context) + { + context.Source.SendMsg("pong"); + } + } + """ + ); + + var registry = result.GeneratedTrees.Single(tree => tree.FilePath.EndsWith("ChatCommandRegistry.g.cs")); + var source = registry.GetText().ToString(); + + Assert.That(source, Does.Contain("internal static partial class ChatCommandRegistry")); + Assert.That(source, Does.Contain("""new global::Multiplayer.Common.PingCommand()""")); + Assert.That(source, Does.Contain("""manager.AddCommand(@"ping",""")); + } + + [Test] + public void ChatCommand_GeneratesTypedArgumentParser() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs([ChatArgument("text")] string Text); + + [ChatCommand("echo", Usage = "echo ")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + context.Source.SendMsg(args.Text); + } + } + """ + ); + + var source = GeneratedRegistrySource(result); + + Assert.That(source, Does.Contain("command0.SetParser(TryParseCommand0Args);")); + Assert.That( + source, + Does.Contain("args = new global::Multiplayer.Common.EchoArgs(global::Multiplayer.Common.ChatCommands.ChatCommandArgumentReader.JoinRest(context.RawArgs, 0));") + ); + } + + [Test] + public void ChatCommand_GeneratesPlayerArgumentParser() + { + var result = RunGeneratorWithCompilation( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct InspectArgs([ChatArgument("username")] ServerPlayer Player); + + [ChatCommand("inspect", Usage = "inspect ")] + public sealed class InspectCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, InspectArgs args) + { + } + } + """ + ); + + var source = GeneratedRegistrySource(result.Result); + + Assert.That(source, Does.Contain("ChatCommandArgumentReader.TryParsePlayer(context, global::Multiplayer.Common.ChatCommands.ChatCommandArgumentReader.JoinRest(context.RawArgs, 0), @\"username\"")); + Assert.That( + result.Compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty + ); + } + + [Test] + public void ChatCommand_SameClassNameInDifferentNamespacesGeneratesCompilableRegistry() + { + var result = RunGeneratorWithCompilation( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace First + { + + public readonly record struct EchoArgs(string Text); + + [ChatCommand("first")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + } + + namespace Second + { + + public readonly record struct EchoArgs(string Text); + + [ChatCommand("second")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + } + """ + ); + + Assert.That( + result.Compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty + ); + } + + [Test] + public void ChatCommand_MultipleArgumentsKeepStringArgumentPositional() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs(string Text, int Count); + + [ChatCommand("echo", Usage = "echo ")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + var source = GeneratedRegistrySource(result); + + Assert.That(source, Does.Contain("ChatCommandArgumentReader.TryParseInt(context.RawArgs[1], @\"Count\"")); + Assert.That(source, Does.Contain("args = new global::Multiplayer.Common.EchoArgs(context.RawArgs[0], parsedarg1);")); + } + + [Test] + public void ChatCommand_UsesCustomParserWhenPresent() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct CustomArgs(string Value); + + [ChatCommand("custom")] + public sealed class CustomCommand : ChatCommand + { + public static bool TryParse(ChatCommandContext context, out CustomArgs args) + { + args = new CustomArgs("custom"); + return true; + } + + protected override void Execute(ChatCommandContext context, CustomArgs args) + { + context.Source.SendMsg(args.Value); + } + } + """ + ); + + var source = GeneratedRegistrySource(result); + + Assert.That(source, Does.Contain("global::Multiplayer.Common.CustomCommand.TryParse(context, out args)")); + } + + [Test] + public void ChatCommand_GeneratesParsersForOptionalPrimitiveEnumAndRestArguments() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public enum EchoMode + { + Normal, + Loud + } + + public readonly record struct EchoArgs( + [ChatArgument("count")] int Count = 1, + [ChatArgument("enabled")] bool Enabled = true, + [ChatArgument("mode")] EchoMode Mode = EchoMode.Normal, + [ChatRest] string? Text = null + ); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + var source = GeneratedRegistrySource(result); + + Assert.That(source, Does.Contain("ChatCommandArgumentReader.TryParseInt(context.RawArgs[0], @\"count\"")); + Assert.That(source, Does.Contain("ChatCommandArgumentReader.TryParseBool(context.RawArgs[1], @\"enabled\"")); + Assert.That(source, Does.Contain("ChatCommandArgumentReader.TryParseEnum(context.RawArgs[2], @\"mode\"")); + Assert.That(source, Does.Contain("ChatCommandArgumentReader.JoinRest(context.RawArgs, 3)")); + Assert.That(source, Does.Contain("if (context.RawArgs.Count > 0)")); + Assert.That(source, Does.Contain("arg0 = 1;")); + } + + [Test] + public void ChatCommand_OptionalEnumDefaultGeneratesCompilableRegistry() + { + var result = RunGeneratorWithCompilation( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public enum EchoMode + { + Normal, + Loud + } + + public readonly record struct EchoArgs([ChatArgument("mode")] EchoMode Mode = EchoMode.Loud); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That( + result.Compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty + ); + } + + [Test] + public void ChatCommand_RestArgumentMustBeString() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs([ChatRest] int Text); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT004")); + } + + [Test] + public void ChatCommand_RestArgumentMustBeFinalArgument() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs([ChatRest] string Text, int Count); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT004")); + } + + [Test] + public void ChatCommand_RestArgumentMustBeUnique() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs([ChatRest] string First, [ChatRest] string Second); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT004")); + } + + [Test] + public void ChatCommand_AmbiguousArgumentConstructorsReportDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public sealed class EchoArgs + { + public EchoArgs(int count) + { + } + + public EchoArgs(string text) + { + } + } + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT005")); + } + + [Test] + public void ChatCommand_PrivateCustomParserDoesNotGenerateInaccessibleCall() + { + var result = RunGeneratorWithCompilation( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public readonly record struct EchoArgs(string Text); + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + private static bool TryParse(ChatCommandContext context, out EchoArgs args) + { + args = new EchoArgs("private"); + return true; + } + + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That( + result.Compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty + ); + } + + [Test] + public void ChatCommand_PrivateArgumentConstructorReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + public sealed class EchoArgs + { + private EchoArgs(string text) + { + } + } + + [ChatCommand("echo")] + public sealed class EchoCommand : ChatCommand + { + protected override void Execute(ChatCommandContext context, EchoArgs args) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT005")); + } + + [Test] + public void ChatCommand_PrivateCommandConstructorReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("secret")] + public sealed class SecretCommand : ChatCommand + { + private SecretCommand() + { + } + + public override void Execute(ChatCommandContext context) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT006")); + } + + [Test] + public void ChatCommand_AbstractCommandReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("abstract")] + public abstract class AbstractCommand : ChatCommand + { + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT006")); + } + + [Test] + public void ChatCommand_BlankPrimaryNameReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("")] + public sealed class BlankCommand : ChatCommand + { + public override void Execute(ChatCommandContext context) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT007")); + } + + [Test] + public void ChatCommand_BlankAliasReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("named", "")] + public sealed class BlankAliasCommand : ChatCommand + { + public override void Execute(ChatCommandContext context) + { + } + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT007")); + } + + [Test] + public void ChatCommand_DuplicateNameReportsDiagnostic() + { + var result = RunGenerator( + """ + using Multiplayer.Common; + using Multiplayer.Common.ChatCommands; + + namespace Multiplayer.Common; + + [ChatCommand("same")] + public sealed class FirstCommand : ChatCommand + { + public override void Execute(ChatCommandContext context) {} + } + + [ChatCommand("same")] + public sealed class SecondCommand : ChatCommand + { + public override void Execute(ChatCommandContext context) {} + } + """ + ); + + Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Id), Does.Contain("MPCHAT001")); + } + + private static GeneratorDriverRunResult RunGenerator(string source) + { + return RunGeneratorWithCompilation(source).Result; + } + + private static GeneratorRun RunGeneratorWithCompilation(string source) + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrEmpty(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Append(MetadataReference.CreateFromFile(typeof(ChatCommandAttribute).Assembly.Location)) + .Cast(); + + var parseOptions = new CSharpParseOptions(LanguageVersion.CSharp12); + var registryDeclaration = CSharpSyntaxTree.ParseText( + """ + namespace Multiplayer.Common.ChatCommands; + + internal static partial class ChatCommandRegistry + { + public static partial void Register(ChatCommandManager manager, MultiplayerServer server); + } + """, + parseOptions + ); + + var compilation = CSharpCompilation.Create( + "ChatCommandGeneratorTests", + [CSharpSyntaxTree.ParseText(source, parseOptions), registryDeclaration], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new ChatCommandRegistryGenerator().AsSourceGenerator()], + parseOptions: parseOptions + ); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); + return new GeneratorRun(driver.GetRunResult(), outputCompilation); + } + + private static string GeneratedRegistrySource(GeneratorDriverRunResult result) + { + return result.GeneratedTrees.Single(tree => tree.FilePath.EndsWith("ChatCommandRegistry.g.cs")).GetText().ToString(); + } + + private sealed record GeneratorRun(GeneratorDriverRunResult Result, Compilation Compilation); +} diff --git a/Source/Tests/ChatCommandManagerTest.cs b/Source/Tests/ChatCommandManagerTest.cs new file mode 100644 index 000000000..6dc33c554 --- /dev/null +++ b/Source/Tests/ChatCommandManagerTest.cs @@ -0,0 +1,656 @@ +using Multiplayer.Common; +using Multiplayer.Common.ChatCommands; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[TestFixture] +public class ChatCommandManagerTest +{ + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + [Test] + public void GeneratedRegistry_RegistersAliasesAsOneCommandInfo() + { + var server = MakeServer(); + + Assert.That(server.chatCmdManager.TryGetCommandInfo("help", out var help), Is.True); + Assert.That(server.chatCmdManager.TryGetCommandInfo("?", out var alias), Is.True); + + Assert.That(alias, Is.SameAs(help)); + Assert.That(help.Names, Is.EqualTo(["help", "?"])); + Assert.That(help.Command, Is.SameAs(alias.Command)); + } + + [Test] + public void GeneratedRegistry_StoresCommandMetadata() + { + var server = MakeServer(); + + Assert.That(server.chatCmdManager.TryGetCommandInfo("kick", out var info), Is.True); + Assert.That(info.Description, Is.EqualTo("Disconnect a player by username.")); + Assert.That(info.Usage, Is.EqualTo("kick ")); + Assert.That(info.RequiresHost, Is.True); + } + + [Test] + public void ManualRegistration_GroupsAliasesForTheSameCommand() + { + var server = MakeServer(); + var command = new RecordingCommand(); + + server.RegisterChatCommand("alias-one", command); + server.RegisterChatCommand("alias-two", command); + + Assert.That(server.chatCmdManager.TryGetCommandInfo("alias-one", out var first), Is.True); + Assert.That(server.chatCmdManager.TryGetCommandInfo("alias-two", out var second), Is.True); + Assert.That(second, Is.SameAs(first)); + Assert.That(first.Names, Is.EqualTo(["alias-one", "alias-two"])); + Assert.That(first.DisplayNames, Is.EqualTo("alias-one, alias-two")); + Assert.That(server.chatCmdManager.GetCommandInfos().Count(info => info.Command == command), Is.EqualTo(1)); + } + + [Test] + public void ManualRegistration_CanProvideMetadataForAliases() + { + var server = MakeServer(); + var command = new RecordingCommand(); + + server.RegisterChatCommand( + ["metadata", "meta"], + command, + "Manual description.", + "metadata ", + true + ); + + Assert.That(server.chatCmdManager.TryGetCommandInfo("metadata", out var primary), Is.True); + Assert.That(server.chatCmdManager.TryGetCommandInfo("meta", out var alias), Is.True); + Assert.That(alias, Is.SameAs(primary)); + Assert.That(primary.Names, Is.EqualTo(["metadata", "meta"])); + Assert.That(primary.DisplayNames, Is.EqualTo("metadata, meta")); + Assert.That(primary.Description, Is.EqualTo("Manual description.")); + Assert.That(primary.Usage, Is.EqualTo("metadata ")); + Assert.That(primary.RequiresHost, Is.True); + } + + [Test] + public void Commands_DispatchCaseInsensitively() + { + var server = MakeServer(); + var command = new RecordingCommand(); + var source = new RecordingChatSource(); + + server.RegisterChatCommand("mixed", command); + server.HandleChatCommand(source, "MIXED"); + + Assert.That(command.ExecutionCount, Is.EqualTo(1)); + Assert.That(source.Messages, Is.Empty); + } + + [Test] +#pragma warning disable CS0618 + public void LegacyRegistration_UsesOldHandlerMetadataAndDispatch() + { + var server = MakeServer(); + var command = new LegacyRecordingCommand(); + var source = new RecordingChatSource(); + + server.RegisterChatCmd("legacy", command); + server.HandleChatCmd(source, "legacy hello"); + + Assert.That(command.Args, Is.EqualTo(["hello"])); + Assert.That(server.chatCmdManager.TryGetCommandInfo("legacy", out var info), Is.True); + Assert.That(info.Description, Is.EqualTo("Legacy description.")); + Assert.That(info.Usage, Is.EqualTo("legacy ")); + Assert.That(info.RequiresHost, Is.True); + } +#pragma warning restore CS0618 + + [Test] + public void HelpCommand_PrintsMetadataForRequestedCommand() + { + var server = MakeServer(); + server.RegisterChatCommand( + ["documented", "doc"], + new RecordingCommand(), + "Manual description.", + "documented ", + true + ); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "help documented"); + + Assert.That( + source.Messages, + Is.EqualTo([ + "Command: documented, doc", + "Description: Manual description.", + "Usage: documented ", + "Requires host permissions." + ]) + ); + } + + [Test] + public void HostOnlyCommand_DoesNotExecuteForNonHostPlayer() + { + var server = MakeServer(); + var command = new RecordingCommand(); + server.RegisterChatCommand(["host-only"], command, requiresHost: true); + + var conn = new DummyConnection("guest"); + var player = new ServerPlayer(1, conn); + conn.serverPlayer = player; + server.hostUsername = "host"; + + server.HandleChatCommand(player, "host-only"); + + Assert.That(command.ExecutionCount, Is.Zero); + } + + [Test] + public void GeneratedTypedCommand_MissingRequiredArgumentRepliesWithUsage() + { + var server = MakeServer(); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "kick"); + + Assert.That(source.Messages, Is.EqualTo(["Usage: kick "])); + Assert.That(source.RawMessages, Is.EqualTo(["Usage: kick "])); + } + + [Test] + public void GeneratedTypedCommand_MissingWhoisArgumentRepliesWithUsernameUsage() + { + var server = MakeServer(); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois"); + + Assert.That(source.Messages, Is.EqualTo(["Usage: whois "])); + Assert.That(source.RawMessages, Is.EqualTo(["Usage: whois "])); + } + + [Test] + public void GeneratedTypedCommand_MissingResyncArgumentRepliesWithUsernameUsage() + { + var server = MakeServer(); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "resync"); + + Assert.That(source.Messages, Is.EqualTo(["Usage: resync "])); + Assert.That(source.RawMessages, Is.EqualTo(["Usage: resync "])); + } + + [Test] + public void GeneratedTypedCommand_MissingArgumentSentToPlayerKeepsRawUsageText() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "host", isHost: true); + + server.HandleChatCommand(player, "resync"); + + var conn = (RecordingConnection)player.conn; + Assert.That(conn.ChatMessages, Is.EqualTo(["Usage: resync "])); + Assert.That(conn.RawChatMessages, Is.EqualTo(["Usage: resync "])); + } + + [Test] + public void PlayersCommand_ListsConnectedPlayers() + { + var server = MakeServer(); + AddPlayingPlayer(server, "host", isHost: true, factionId: 4, currentMapId: 0); + AddPlayingPlayer(server, "guest", factionId: 7, currentMapId: 2); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "players"); + + Assert.That(source.Messages[0], Is.EqualTo("Players (2):")); + Assert.That(source.Messages[1], Does.Contain("host")); + Assert.That(source.Messages[1], Does.Contain("[Playing]")); + Assert.That(source.Messages[1], Does.Contain("faction=4")); + Assert.That(source.Messages[1], Does.Contain("map=0")); + Assert.That(source.Messages[2], Does.Contain("guest")); + Assert.That(source.Messages[2], Does.Contain("player")); + Assert.That(source.Messages[2], Does.Contain("faction=7")); + Assert.That(source.Messages[2], Does.Contain("map=2")); + } + + [Test] + public void WhoisCommand_ShowsPlayerDetails() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "guest", factionId: 7, currentMapId: 2); + player.ticksBehind = 12; + player.steamId = 12345; + player.steamPersonaName = "Steam Guest"; + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois guest"); + + Assert.That(source.Messages, Does.Contain($"Player: guest (#{player.id})")); + Assert.That(source.Messages, Does.Contain("Status: Playing")); + Assert.That(source.Messages, Does.Contain("Role: player")); + Assert.That(source.Messages, Does.Contain("Faction: 7")); + Assert.That(source.Messages, Does.Contain("Map: 2")); + Assert.That(source.Messages, Does.Contain("Ticks behind: 12")); + Assert.That(source.Messages, Does.Contain("Steam: Steam Guest (12345)")); + } + + [Test] + public void PlayerArgument_ResolvesUniquePartialName() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "NemuruYama", factionId: 7, currentMapId: 2); + AddPlayingPlayer(server, "OtherPlayer"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois Nemu"); + + Assert.That(source.Messages, Does.Contain($"Player: NemuruYama (#{player.id})")); + } + + [Test] + public void PlayerArgument_RejectsAmbiguousPartialNameBeforeExecute() + { + var server = MakeServer(); + AddPlayingPlayer(server, "NemuruYama1"); + AddPlayingPlayer(server, "NemuruYama2"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois Nemu"); + + Assert.That( + source.Messages, + Is.EqualTo(["Player name 'Nemu' is ambiguous: NemuruYama1, NemuruYama2."]) + ); + } + + [Test] + public void PlayerArgument_ExactNameWinsOverAmbiguousPartialName() + { + var server = MakeServer(); + var exact = AddPlayingPlayer(server, "Nemu"); + AddPlayingPlayer(server, "NemuruYama1"); + AddPlayingPlayer(server, "NemuruYama2"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois Nemu"); + + Assert.That(source.Messages, Does.Contain($"Player: Nemu (#{exact.id})")); + } + + [Test] + public void PlayerArgument_ResolvesQuotedPlayerName() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "Player Name", factionId: 7, currentMapId: 2); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois \"Player Name\""); + + Assert.That(source.Messages, Does.Contain($"Player: Player Name (#{player.id})")); + } + + [Test] + public void SinglePlayerArgument_UsesRemainingTextAsPlayerName() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "Player Name", factionId: 7, currentMapId: 2); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "whois Player Name"); + + Assert.That(source.Messages, Does.Contain($"Player: Player Name (#{player.id})")); + } + + [Test] + public void QuotedArgument_MissingClosingQuoteStopsBeforeDispatch() + { + var server = MakeServer(); + var command = new RecordingCommand(); + var source = new RecordingChatSource(); + + server.RegisterChatCommand("record", command); + server.HandleChatCommand(source, "record \"unfinished"); + + Assert.That(command.ExecutionCount, Is.Zero); + Assert.That(source.Messages, Is.EqualTo(["Invalid command arguments: missing closing quote."])); + } + + [Test] + public void StatusCommand_ShowsServerSummary() + { + var server = MakeServer(); + server.running = true; + server.gameTimer = 120; + server.workTicks = 45; + server.worldData.savedGame = [1, 2, 3]; + server.worldData.sessionData = [4, 5, 6]; + server.worldData.mapData[10] = [7, 8, 9]; + server.worldData.lastJoinPointAtTick = 90; + AddPlayingPlayer(server, "host", isHost: true); + AddPlayingPlayer(server, "guest"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "status"); + + Assert.That(source.Messages, Does.Contain("Server: running")); + Assert.That(source.Messages, Does.Contain("World: loaded, maps=1, join point=last at tick 90")); + Assert.That(source.Messages, Does.Contain("Ticks: game=120, net=0, work=45")); + Assert.That(source.Messages, Does.Contain("Players: connected=2, joined=2, playing=2")); + } + + [Test] + public void ModsCommand_ShowsServerModList() + { + var server = MakeServer(); + server.StartInitData().SetResult(new ServerInitData( + ClientInitDataPacket.ModData.ListBinder.Serialize([ + ModData("Core", "ludeon.rimworld"), + ModData("Multiplayer", "rwmt.multiplayer") + ]), + false, + "1.6.4633", + [], + [], + default, + [] + )); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "mods"); + + Assert.That(source.Messages, Does.Contain("RimWorld: 1.6.4633")); + Assert.That(source.Messages, Does.Contain("Mods (2):")); + Assert.That(source.Messages, Does.Contain("- Core (ludeon.rimworld)")); + Assert.That(source.Messages, Does.Contain("- Multiplayer (rwmt.multiplayer)")); + } + + [Test] + public void ModsCommand_CanPageThroughServerModList() + { + var server = MakeServer(); + server.StartInitData().SetResult(new ServerInitData( + ClientInitDataPacket.ModData.ListBinder.Serialize([ + ModData("Core", "ludeon.rimworld"), + ModData("Royalty", "ludeon.rimworld.royalty"), + ModData("Ideology", "ludeon.rimworld.ideology"), + ModData("Biotech", "ludeon.rimworld.biotech"), + ModData("Multiplayer", "rwmt.multiplayer") + ]), + false, + "1.6.4633", + [], + [], + default, + [] + )); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "mods 2 2"); + + Assert.That(source.Messages, Does.Contain("Mods (5), page 2/3:")); + Assert.That(source.Messages, Does.Contain("- Ideology (ludeon.rimworld.ideology)")); + Assert.That(source.Messages, Does.Contain("- Biotech (ludeon.rimworld.biotech)")); + Assert.That(source.Messages, Does.Not.Contain("- Core (ludeon.rimworld)")); + Assert.That(source.Messages, Does.Not.Contain("- Multiplayer (rwmt.multiplayer)")); + } + + [Test] + public void ResyncCommand_SendsWorldDataReloadRequest() + { + var server = MakeServer(); + server.worldData.savedGame = []; + server.worldData.sessionData = []; + var player = AddPlayingPlayer(server, "guest"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "resync guest"); + + var connection = (RecordingConnection)player.conn; + Assert.That(connection.PacketIds, Does.Contain(Packets.Server_RequestRejoin)); + Assert.That(connection.PacketIds, Does.Not.Contain(Packets.Server_WorldDataStart)); + Assert.That(connection.PacketIds, Does.Not.Contain(Packets.Server_WorldData)); + Assert.That(source.Messages, Does.Contain("Resync requested for guest.")); + } + + [Test] + public void TimeControlCommands_SchedulePlayerScopedCommands() + { + var server = MakeServer(); + var source = AddPlayingPlayer(server, "guest", factionId: 7); + + server.HandleChatCommand(source, "pause"); + var pauseCommand = LastGlobalCommand(server); + + server.HandleChatCommand(source, "unpause"); + var unpauseCommand = LastGlobalCommand(server); + + server.HandleChatCommand(source, "speed 3"); + var speedCommand = LastGlobalCommand(server); + + Assert.That(pauseCommand.type, Is.EqualTo(CommandType.GlobalTimeSpeed)); + Assert.That(pauseCommand.factionId, Is.EqualTo(7)); + Assert.That(pauseCommand.playerId, Is.EqualTo(source.id)); + Assert.That(pauseCommand.data, Is.EqualTo([(byte)TimeVote.Paused])); + Assert.That(unpauseCommand.type, Is.EqualTo(CommandType.GlobalTimeSpeed)); + Assert.That(unpauseCommand.factionId, Is.EqualTo(7)); + Assert.That(unpauseCommand.playerId, Is.EqualTo(source.id)); + Assert.That(unpauseCommand.data, Is.EqualTo([(byte)TimeVote.Normal])); + Assert.That(speedCommand.type, Is.EqualTo(CommandType.GlobalTimeSpeed)); + Assert.That(speedCommand.factionId, Is.EqualTo(7)); + Assert.That(speedCommand.playerId, Is.EqualTo(source.id)); + Assert.That(speedCommand.data, Is.EqualTo([(byte)TimeVote.Superfast])); + Assert.That(((RecordingConnection)source.conn).ChatMessages, Does.Contain("Speed set to Paused.")); + Assert.That(((RecordingConnection)source.conn).ChatMessages, Does.Contain("Speed set to Normal.")); + Assert.That(((RecordingConnection)source.conn).ChatMessages, Does.Contain("Speed set to Superfast.")); + } + + [Test] + public void TimeControlCommands_UseLowestWinsVoteWhenEnabled() + { + var server = MakeServer(); + server.settings.timeControl = TimeControl.LowestWins; + var source = AddPlayingPlayer(server, "guest", factionId: 7); + + server.HandleChatCommand(source, "pause"); + + var command = LastGlobalCommand(server); + var data = new ByteReader(command.data); + Assert.That(command.type, Is.EqualTo(CommandType.TimeSpeedVote)); + Assert.That(command.factionId, Is.EqualTo(7)); + Assert.That(command.playerId, Is.EqualTo(source.id)); + Assert.That((TimeVote)data.ReadByte(), Is.EqualTo(TimeVote.Paused)); + Assert.That(data.ReadInt32(), Is.EqualTo(ScheduledCommand.Global)); + } + + [Test] + public void TimeControlCommands_RespectHostOnlyTimeControlSetting() + { + var server = MakeServer(); + server.settings.timeControl = TimeControl.HostOnly; + AddPlayingPlayer(server, "host", isHost: true); + var guest = AddPlayingPlayer(server, "guest"); + + server.HandleChatCommand(guest, "pause"); + + Assert.That(server.worldData.mapCmds.ContainsKey(ScheduledCommand.Global), Is.False); + Assert.That(((RecordingConnection)guest.conn).ChatMessages, Does.Contain("No permission")); + } + + [Test] + public void HelpCommand_CanShowOnlyCommandsThePlayerCanUse() + { + var server = MakeServer(); + server.settings.timeControl = TimeControl.HostOnly; + AddPlayingPlayer(server, "host", isHost: true); + var guest = AddPlayingPlayer(server, "guest"); + guest.helpOnlyUsableCommands = true; + + server.HandleChatCommand(guest, "help"); + + var messages = ((RecordingConnection)guest.conn).ChatMessages; + Assert.That(messages, Does.Contain("Available commands you can use:")); + Assert.That(messages, Does.Contain("- help, ?: Show available commands or detailed help for one command.")); + Assert.That(messages, Does.Contain("- whois: Show details for a connected player.")); + Assert.That(messages.Any(message => message.StartsWith("- kick:")), Is.False); + Assert.That(messages.Any(message => message.StartsWith("- pause:")), Is.False); + } + + [Test] + public void AnnounceCommand_BroadcastsServerAnnouncement() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "guest"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "announce raid soon"); + + Assert.That(player.conn, Is.TypeOf()); + Assert.That(((RecordingConnection)player.conn).ChatMessages, Does.Contain("[Announcement] raid soon")); + } + + [Test] + public void AnnounceCommand_PreservesQuotedMessageAsOneArgument() + { + var server = MakeServer(); + var player = AddPlayingPlayer(server, "guest"); + var source = new RecordingChatSource(); + + server.HandleChatCommand(source, "announce \"raid soon\""); + + Assert.That(player.conn, Is.TypeOf()); + Assert.That(((RecordingConnection)player.conn).ChatMessages, Does.Contain("[Announcement] raid soon")); + } + + private static MultiplayerServer MakeServer() + { + return MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + } + + private static ServerPlayer AddPlayingPlayer( + MultiplayerServer server, + string username, + bool isHost = false, + int factionId = 0, + int currentMapId = -1 + ) + { + var player = server.playerManager.OnConnected(new RecordingConnection(username)); + player.FactionId = factionId; + player.currentMapId = currentMapId; + player.hasJoined = true; + player.status = PlayerStatus.Playing; + player.conn.ChangeState(ConnectionStateEnum.ServerPlaying); + + if (isHost) + server.hostUsername = username; + + return player; + } + + private static ScheduledCommand LastGlobalCommand(MultiplayerServer server) + { + return ScheduledCommand.Deserialize(new ByteReader(server.worldData.mapCmds[ScheduledCommand.Global].Last())); + } + + private static ClientInitDataPacket.ModData ModData(string name, string packageId) + { + return new ClientInitDataPacket.ModData + { + name = name, + packageIdNonUnique = packageId, + files = [] + }; + } + + private sealed class RecordingChatSource : IChatSource + { + public List Messages { get; } = []; + public List RawMessages { get; } = []; + + public void SendMsg(string msg) + { + Messages.Add(msg); + } + + public void SendRawMsg(string msg) + { + Messages.Add(msg); + RawMessages.Add(msg); + } + } + + private sealed class RecordingCommand : ChatCommand + { + public int ExecutionCount { get; private set; } + + public override void Execute(ChatCommandContext context) + { + ExecutionCount++; + } + } + + private sealed class RecordingConnection : ConnectionBase + { + public List ChatMessages { get; } = []; + public List RawChatMessages { get; } = []; + public List PacketIds { get; } = []; + + public RecordingConnection(string username) + { + this.username = username; + } + + public override int Latency { get => 0; set { } } + + protected override void SendRaw(byte[] raw, bool reliable) + { + var packetId = (Packets)(raw[0] & 0x3F); + PacketIds.Add(packetId); + if (packetId != Packets.Server_Chat) + return; + + var packet = new ServerChatPacket(); + packet.Bind(new PacketReader(new ByteReader(raw[1..]))); + ChatMessages.Add(packet.msg ?? ""); + if (packet.rawMessage) + RawChatMessages.Add(packet.msg ?? ""); + } + + protected override void OnClose(ServerDisconnectPacket? goodbye) { } + } + +#pragma warning disable CS0618 + private sealed class LegacyRecordingCommand : ChatCmdHandler + { + public string[] Args { get; private set; } = []; + public override string Description => "Legacy description."; + public override string Usage => "legacy "; + + public LegacyRecordingCommand() + { + requiresHost = true; + } + + public override void Handle(IChatSource source, string[] args) + { + Args = args; + } + } +#pragma warning restore CS0618 +} diff --git a/Source/Tests/CommandTokenizerTest.cs b/Source/Tests/CommandTokenizerTest.cs new file mode 100644 index 000000000..3060e5937 --- /dev/null +++ b/Source/Tests/CommandTokenizerTest.cs @@ -0,0 +1,46 @@ +using Multiplayer.Common.ChatCommands; + +namespace Tests; + +[TestFixture] +public class CommandTokenizerTest +{ + [TestCase("", new string[0])] + [TestCase(" ", new string[0])] + [TestCase("speed 3", new[] { "speed", "3" })] + [TestCase(" speed 3 ", new[] { "speed", "3" })] + [TestCase("whois PlayerName", new[] { "whois", "PlayerName" })] + [TestCase("announce raid soon", new[] { "announce", "raid", "soon" })] + [TestCase("help ?", new[] { "help", "?" })] + public void Tokenize_PlainCommandText_ReturnsWhitespaceSeparatedArguments(string input, string[] expected) + { + Assert.That(CommandTokenizer.TryTokenize(input, out var tokens, out var error), Is.True); + Assert.That(error, Is.Null); + Assert.That(tokens, Is.EqualTo(expected)); + } + + [TestCase("whois \"Player Name\"", new[] { "whois", "Player Name" })] + [TestCase("announce \"raid soon\"", new[] { "announce", "raid soon" })] + [TestCase("kick \"\"", new[] { "kick", "" })] + [TestCase("announce \"hello \\\"world\\\"\"", new[] { "announce", "hello \"world\"" })] + [TestCase("announce \"C:\\\\Games\\\\RimWorld\"", new[] { "announce", "C:\\Games\\RimWorld" })] + [TestCase("command pre\"quoted middle\"post", new[] { "command", "prequoted middlepost" })] + [TestCase("command \"quoted\"plain", new[] { "command", "quotedplain" })] + [TestCase("command plain\"quoted\"", new[] { "command", "plainquoted" })] + public void Tokenize_QuotedCommandText_ReturnsQuotedContentAsArgument(string input, string[] expected) + { + Assert.That(CommandTokenizer.TryTokenize(input, out var tokens, out var error), Is.True); + Assert.That(error, Is.Null); + Assert.That(tokens, Is.EqualTo(expected)); + } + + [TestCase("whois \"Player Name")] + [TestCase("announce raid \"soon")] + [TestCase("\"")] + public void Tokenize_MissingClosingQuote_ReturnsHelpfulError(string input) + { + Assert.That(CommandTokenizer.TryTokenize(input, out var tokens, out var error), Is.False); + Assert.That(tokens, Is.Empty); + Assert.That(error, Is.EqualTo("Invalid command arguments: missing closing quote.")); + } +} diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index c98994699..fbe1eac84 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -164,6 +164,7 @@ private static IEnumerable RoundtripPackets() yield return ServerChatPacket.Create(""); yield return ServerChatPacket.Create("ABC123!@#"); + yield return ServerChatPacket.CreateRaw("Usage: whois "); yield return ClientChatPacket.Create(""); yield return ClientChatPacket.Create("ABC123!@#"); diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index 202f24066..30ff60ec1 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -12,6 +12,7 @@ + @@ -23,7 +24,9 @@ + + diff --git a/Source/Tests/packet-serializations/ClientChatPacket.verified.txt b/Source/Tests/packet-serializations/ClientChatPacket.verified.txt index a263e7895..167f64a0c 100644 --- a/Source/Tests/packet-serializations/ClientChatPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientChatPacket.verified.txt @@ -1,2 +1,2 @@ -00-00-00-00 -09-00-00-00-41-42-43-31-32-33-21-40-23 +00-00-00-00-00 +09-00-00-00-41-42-43-31-32-33-21-40-23-00 diff --git a/Source/Tests/packet-serializations/ServerChatPacket.verified.txt b/Source/Tests/packet-serializations/ServerChatPacket.verified.txt index a263e7895..472e88178 100644 --- a/Source/Tests/packet-serializations/ServerChatPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerChatPacket.verified.txt @@ -1,2 +1,3 @@ -00-00-00-00 -09-00-00-00-41-42-43-31-32-33-21-40-23 +00-00-00-00-00 +09-00-00-00-41-42-43-31-32-33-21-40-23-00 +17-00-00-00-55-73-61-67-65-3A-20-77-68-6F-69-73-20-3C-75-73-65-72-6E-61-6D-65-3E-01 From 39e4f87c20a77edd14f1f8190c7aeb0d9b3abfd9 Mon Sep 17 00:00:00 2001 From: MhaWay Date: Fri, 5 Jun 2026 21:43:15 +0200 Subject: [PATCH 35/51] Fix standalone join-point creation on join (#945) * Fix standalone join-point creation on join * Address review feedback on standalone join fix --- .../Networking/State/ServerJoiningState.cs | 5 +--- Source/Tests/ServerTest.cs | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index c97410d11..b2d2a392f 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -29,10 +29,7 @@ protected override async Task RunState() if (Server.settings.pauseOnJoin) Server.commands.PauseAll(); - // On standalone, only request a fresh join point when another player is already active. - // For the normal first join, serve the persisted state immediately instead of blocking on WaitJoinPoint. - if ((Server.IsStandaloneServer && Server.PlayingPlayers.Any()) || - (!Server.IsStandaloneServer && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Join))) + if (!Server.IsStandaloneServer && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Join)) Server.worldData.TryStartJoinPointCreation(sourcePlayer: Player); Server.playerManager.OnJoin(Player); diff --git a/Source/Tests/ServerTest.cs b/Source/Tests/ServerTest.cs index 5727e2c11..4cfff8bd5 100644 --- a/Source/Tests/ServerTest.cs +++ b/Source/Tests/ServerTest.cs @@ -96,6 +96,35 @@ public void LoadingStateHandlesKeepAliveWhileWaitingForJoinPoint() } } + [Test] + public void StandaloneJoinWithExistingPlayer_DoesNotStartJoinPoint() + { + var server = MakeServer(out var port); + server.IsStandaloneServer = true; + + var existingConn = new RecordingConnection("existing"); + existingConn.ChangeState(ConnectionStateEnum.ServerPlaying); + var existingPlayer = new ServerPlayer(100, existingConn); + existingConn.serverPlayer = existingPlayer; + server.playerManager.Players.Add(existingPlayer); + + ConnectClient(port, typeof(TestJoiningState)); + + var timeoutWatch = Stopwatch.StartNew(); + while (true) + { + if (server.playerManager.Players.Count == 1) + break; + + if (timeoutWatch.ElapsedMilliseconds > 2000) + Assert.Fail("Timeout"); + + Thread.Sleep(50); + } + + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + } + private void ConnectClient(int port, Type joiningStateType) { var clientListener = new TestNetListener(joiningStateType); From 7899c436097d03ce25210e2847d7d4ed9836f557 Mon Sep 17 00:00:00 2001 From: Kuinox Date: Sat, 6 Jun 2026 19:43:41 +0200 Subject: [PATCH 36/51] Skip autosave requests while simulating (#946) --- Source/Client/Patches/VTRSyncPatch.cs | 3 +-- Source/Client/Session/Autosaving.cs | 10 +++++++++- Source/Client/Windows/SaveGameWindow.cs | 3 +-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index 582f1c257..eb5812a86 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -2,7 +2,6 @@ using HarmonyLib; using Multiplayer.Client.Util; using Multiplayer.Common; -using Multiplayer.Common.Networking.Packet; using RimWorld.Planet; using Verse; @@ -147,7 +146,7 @@ static void Postfix(WorldRenderMode __result) // On standalone with streaming, trigger a join point when leaving a map // so each player can save independently without disturbing others if (Multiplayer.session?.ConnectedToStandaloneServer == true && Multiplayer.GameComp.multifaction && Multiplayer.GameComp.asyncTime) - Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.WorldTravel)); + Autosaving.SendAutosavingRequest(JoinPointRequestReason.WorldTravel); } // Detect transition back to tile map else if (__result != WorldRenderMode.Planet && lastRenderMode == WorldRenderMode.Planet) diff --git a/Source/Client/Session/Autosaving.cs b/Source/Client/Session/Autosaving.cs index b0429942e..7f3786066 100644 --- a/Source/Client/Session/Autosaving.cs +++ b/Source/Client/Session/Autosaving.cs @@ -20,7 +20,7 @@ public static void DoAutosave() if (!SaveGameToFile_Overwrite(GetNextAutosaveFileName(), snapshot)) return; - Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save)); + SendAutosavingRequest(JoinPointRequestReason.Save); // When connected to a standalone server, also upload fresh snapshots if (Multiplayer.session?.ConnectedToStandaloneServer == true) @@ -31,6 +31,14 @@ public static void DoAutosave() }, "MpSaving", false, null); } + public static void SendAutosavingRequest(JoinPointRequestReason reason) + { + if (TickPatch.Simulating) + return; + + Multiplayer.Client.Send(new ClientAutosavingPacket(reason)); + } + private static string GetNextAutosaveFileName() { var autosavePrefix = "Autosave-"; diff --git a/Source/Client/Windows/SaveGameWindow.cs b/Source/Client/Windows/SaveGameWindow.cs index ef4dabf13..b2eae89a4 100644 --- a/Source/Client/Windows/SaveGameWindow.cs +++ b/Source/Client/Windows/SaveGameWindow.cs @@ -3,7 +3,6 @@ using RimWorld; using System.Collections.Generic; using System.IO; -using Multiplayer.Common.Networking.Packet; using UnityEngine; using Verse; @@ -206,7 +205,7 @@ private void Accept(bool currentReplay) if (!Autosaving.SaveGameToFile_Overwrite(curText, currentReplay)) return; - Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save)); + Autosaving.SendAutosavingRequest(JoinPointRequestReason.Save); }, "MpSaving", false, null); Close(); } From f7647ca3362b3d7eee98f0e879df4420eed27e80 Mon Sep 17 00:00:00 2001 From: Kuinox Date: Sun, 7 Jun 2026 03:06:59 +0200 Subject: [PATCH 37/51] Fix Map.IsPlayerHome spectator result patch (#947) --- Source/Client/Factions/MultifactionPatches.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index e3b5447ad..abc21316c 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -860,7 +860,7 @@ static IEnumerable Transpiler(IEnumerable inst [HarmonyPatch(typeof(Map), nameof(Map.IsPlayerHome), MethodType.Getter)] static class Map_IsPlayerHome_Spectator_Patch { - static bool Prefix(Map __instance, bool __result) + static bool Prefix(Map __instance, ref bool __result) { if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || Faction.OfPlayer != Multiplayer.WorldComp.spectatorFaction) From 56dac09d591680288c9d520122589b1464c19a33 Mon Sep 17 00:00:00 2001 From: still222 <118853487+still222@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:56:21 +0500 Subject: [PATCH 38/51] Update for Rimworld 1.6.4850 (#899) * Update for Rimworld 1.6.4850 "GenConstruct.CanPlaceBlueprintAt" from stable version now just calls the new "GenConstruct.CanPlaceBlueprintAt_NewTemp", which bugs existing MP transplier on load. "GenConstruct.CanPlaceBlueprintAt_NewTemp" Mostly repeats already existing method, so I just changed targets of a patch. Also updated packages for the latest Harmony and (obviously) latest unstable patch. --------- Co-authored-by: Meru --- Source/Client/Factions/Blueprints.cs | 4 ++-- Source/Client/Multiplayer.csproj | 4 ++-- Source/Common/Common.csproj | 4 ++-- Source/MultiplayerLoader/MultiplayerLoader.csproj | 4 ++-- Source/Server/Server.csproj | 2 +- Source/Tests/Tests.csproj | 2 +- Source/TestsOnMono/TestsOnMono.csproj | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Source/Client/Factions/Blueprints.cs b/Source/Client/Factions/Blueprints.cs index ca5c32714..9a5920cfe 100644 --- a/Source/Client/Factions/Blueprints.cs +++ b/Source/Client/Factions/Blueprints.cs @@ -15,7 +15,7 @@ namespace Multiplayer.Client // Don't draw other factions' blueprints // Don't link graphics of different factions' blueprints - [HarmonyPatch(typeof(GenConstruct), nameof(GenConstruct.CanPlaceBlueprintAt))] + [HarmonyPatch(typeof(GenConstruct), nameof(GenConstruct.CanPlaceBlueprintAt_NewTemp))] static class CanPlaceBlueprintAtPatch { static MethodInfo CanPlaceBlueprintOver = AccessTools.Method(typeof(GenConstruct), nameof(GenConstruct.CanPlaceBlueprintOver)); @@ -48,7 +48,7 @@ static IEnumerable Transpiler(IEnumerable e, M static bool ShouldIgnore1(Thing oldThing) => oldThing.def.IsBlueprint && oldThing.Faction != Faction.OfPlayer; } - [HarmonyPatch(typeof(GenConstruct), nameof(GenConstruct.CanPlaceBlueprintAt))] + [HarmonyPatch(typeof(GenConstruct), nameof(GenConstruct.CanPlaceBlueprintAt_NewTemp))] static class CanPlaceBlueprintAtPatch2 { static IEnumerable Transpiler(IEnumerable e, MethodBase original) diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index 5a59eb989..26cdeb7b1 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index 6693eee0f..9e94cb170 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -12,9 +12,9 @@ - + - + diff --git a/Source/MultiplayerLoader/MultiplayerLoader.csproj b/Source/MultiplayerLoader/MultiplayerLoader.csproj index ba7f7a84d..018083c71 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.csproj +++ b/Source/MultiplayerLoader/MultiplayerLoader.csproj @@ -10,13 +10,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Source/Server/Server.csproj b/Source/Server/Server.csproj index 83083e6e7..322a8f5fe 100644 --- a/Source/Server/Server.csproj +++ b/Source/Server/Server.csproj @@ -17,7 +17,7 @@ - + diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index 30ff60ec1..17b0b2fce 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -18,7 +18,7 @@ - + diff --git a/Source/TestsOnMono/TestsOnMono.csproj b/Source/TestsOnMono/TestsOnMono.csproj index 5c89b2d87..187ca8c74 100644 --- a/Source/TestsOnMono/TestsOnMono.csproj +++ b/Source/TestsOnMono/TestsOnMono.csproj @@ -10,7 +10,7 @@ - + From 1792fc4b61449312b0be87cb525900036c8dc21d Mon Sep 17 00:00:00 2001 From: Kuinox Date: Sun, 14 Jun 2026 10:26:10 +0200 Subject: [PATCH 39/51] Fix spectator home check with null faction (#953) --- Source/Client/Factions/MultifactionPatches.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index abc21316c..9eea55518 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -871,7 +871,7 @@ static bool Prefix(Map __instance, ref bool __result) if (!__instance.wasSpawnedViaGravShipLanding) { MapInfo mapInfo = __instance.info; - if (((mapInfo != null) ? mapInfo.parent : null) == null || __instance.info.parent.Faction.IsPlayer == false || !__instance.info.parent.def.canBePlayerHome) + if (((mapInfo != null) ? mapInfo.parent : null) == null || __instance.info.parent.Faction?.IsPlayer != true || !__instance.info.parent.def.canBePlayerHome) { __result = GravshipUtility.PlayerHasGravEngine(__instance); return false; From 862eacceec3ffa153fbc957e36cecb4b41024ec8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:10:50 -0500 Subject: [PATCH 40/51] Bump Languages from `407942a` to `0a6b746` (#954) Bumps [Languages](https://github.com/rwmt/Multiplayer-Locale) from `407942a` to `0a6b746`. - [Commits](https://github.com/rwmt/Multiplayer-Locale/compare/407942ad083979fac0e8a7eff79ac43e11db585f...0a6b746ca9b2d05a0cc5770f5348ec077f980deb) --- updated-dependencies: - dependency-name: Languages dependency-version: 0a6b746ca9b2d05a0cc5770f5348ec077f980deb dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Languages | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Languages b/Languages index 407942ad0..0a6b746ca 160000 --- a/Languages +++ b/Languages @@ -1 +1 @@ -Subproject commit 407942ad083979fac0e8a7eff79ac43e11db585f +Subproject commit 0a6b746ca9b2d05a0cc5770f5348ec077f980deb From ca157716b3af1c98945e6cbd3bf211007e790d89 Mon Sep 17 00:00:00 2001 From: Kuinox Date: Mon, 15 Jun 2026 17:45:54 +0200 Subject: [PATCH 41/51] Allow changing faction colors (#951) --- Source/Client/Factions/FactionCreator.cs | 10 +++++++++- Source/Client/Factions/FactionsWindow.cs | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index c0ddb767a..c26ea721e 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -30,6 +30,15 @@ public static void SendPawn(int playerId, Pawn p) pawnStore.GetOrAddNew(playerId).Add(p); } + [SyncMethod] + public static void ChangeFactionColor(Faction faction, Color color) + { + if (faction is not { IsPlayer: true }) + return; + + faction.color = color; + } + [SyncMethod] public static void CreateFaction(int playerId, FactionCreationData creationData) { @@ -286,4 +295,3 @@ public record FactionCreationData : ISyncSimple public List startingPossessions; public bool setupNextMapFromTickZero; } - diff --git a/Source/Client/Factions/FactionsWindow.cs b/Source/Client/Factions/FactionsWindow.cs index 6145e32ee..436cae4b1 100644 --- a/Source/Client/Factions/FactionsWindow.cs +++ b/Source/Client/Factions/FactionsWindow.cs @@ -51,6 +51,21 @@ void DrawFactionInLastRect(Faction faction) using (MpStyle.Set(GameFont.Medium)) Layouter.Label(faction.Name); + + var colorRect = Layouter.Rect(20f, 20f); + Widgets.DrawBoxSolid(colorRect.ContractedBy(2f), faction.Color); + Widgets.DrawBox(colorRect); + Widgets.DrawHighlightIfMouseover(colorRect); + + if (Widgets.ButtonInvisible(colorRect)) + { + Find.WindowStack.Add(new Dialog_ChooseFactionColor(color => + { + FactionCreator.ChangeFactionColor(faction, color); + }, faction.Color)); + } + + TooltipHandler.TipRegion(colorRect, "MpChangeFactionColor".Translate()); } Layouter.EndHorizontal(); From 67d8f38cdb9f6d84231c65910ff6f956eba502da Mon Sep 17 00:00:00 2001 From: Michael <5672750+mibac138@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:22:31 +0200 Subject: [PATCH 42/51] Synchronize colonist bar reordering (#528) Co-authored-by: Meru --- Source/Client/Syncing/Game/SyncDelegates.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index 202d5bfff..cff9df04b 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -280,6 +280,9 @@ public static void Init() SyncDelegate.Lambda(typeof(Pawn_TrainingTracker), nameof(Pawn_TrainingTracker.GetGizmos), 0, fields: [SyncDelegate.DelegateThis, "master"]).SetContext(SyncContext.MapSelected).CancelIfNoSelectedMapObjects(); // Force attack target SyncDelegate.Lambda(typeof(Pawn_TrainingTracker), nameof(Pawn_TrainingTracker.GetGizmos), 3).SetContext(SyncContext.MapSelected).CancelIfNoSelectedMapObjects(); // Cancel attacking target + // Colonist bar reordering + SyncDelegate.Lambda(typeof(ColonistBar.Entry), null, lambdaOrdinal: 0, parentMethodType: MethodType.Constructor, parentArgs: [typeof(Pawn), typeof(Map), typeof(int)]); + InitRituals(); InitChoiceLetters(); InitDevTools(); From a1780f7a9c5f572249e5d14aef999e1b2c099135 Mon Sep 17 00:00:00 2001 From: Kuinox Date: Thu, 18 Jun 2026 00:30:03 +0200 Subject: [PATCH 43/51] Fix delayed world-to-map pawn timestamp conversion (#949) --- Source/Client/Patches/TimestampFixer.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Source/Client/Patches/TimestampFixer.cs b/Source/Client/Patches/TimestampFixer.cs index dedc99b02..5526021d1 100644 --- a/Source/Client/Patches/TimestampFixer.cs +++ b/Source/Client/Patches/TimestampFixer.cs @@ -96,8 +96,12 @@ static void Postfix(Pawn __instance) if (Multiplayer.Client == null) return; if (__instance.Map == null) return; - if (__instance.GetComp().worldPawnRemoveTick == Multiplayer.AsyncWorldTime.worldTicks) + var comp = __instance.GetComp(); + if (comp.worldPawnRemoveTick != -1) + { TimestampFixer.FixPawn(__instance, null, __instance.Map); + comp.worldPawnRemoveTick = -1; + } } } @@ -110,6 +114,9 @@ static void Prefix(Pawn p) var lastMap = p.GetComp().lastMap; if (lastMap != -1) + { TimestampFixer.FixPawn(p, Find.Maps.FirstOrDefault(m => m.uniqueID == lastMap), null); + p.GetComp().lastMap = -1; + } } } From c9fb55546d08650d5274beaa284e8ad64c3ddc96 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 18 Jun 2026 01:30:23 +0300 Subject: [PATCH 44/51] Disable desync log stack trace unwinding for non-X86/X64 arches (#950) This is implemented only for x86 and using it on other arches breaks the game tick. --- Source/Client/Desyncs/DeferredStackTracing.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index 1f4b15030..db53da2df 100644 --- a/Source/Client/Desyncs/DeferredStackTracing.cs +++ b/Source/Client/Desyncs/DeferredStackTracing.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Reflection; +using System.Runtime.InteropServices; using HarmonyLib; using Multiplayer.Client.Patches; using Multiplayer.Common; @@ -25,6 +26,9 @@ static IEnumerable TargetMethods() public static int acc; + private static bool SupportsDeferredStackTracing => + RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86; + public static void Postfix() { if (Native.LmfPtr == 0) return; @@ -43,6 +47,7 @@ public static void Postfix() public static bool ShouldAddStackTraceForDesyncLog() { + if (!SupportsDeferredStackTracing) return false; if (Multiplayer.Client == null) return false; if (Multiplayer.settings.desyncTracingMode == DesyncTracingMode.None) return false; if (Multiplayer.game == null) return false; From 891117e2d1d957c7545d5925cff23fd32764832f Mon Sep 17 00:00:00 2001 From: romangr Date: Wed, 22 Jul 2026 12:19:09 +0200 Subject: [PATCH 45/51] JoinDataWindow: Fix NRE in case of a def mismatch (#962) StopMultiplayerAndClearAllWindows nulls Multiplayer.session, so reading session.connector afterwards crashed before JoinDataWindow could open. Capture the connector while the session is still alive. Co-authored-by: Claude Fable 5 --- Source/Client/Networking/State/ClientJoiningState.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index 560a3b6da..0b74bab3b 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -96,6 +96,8 @@ public void HandleJoinData(ServerJoinDataPacket packet) } var remoteInfo = RemoteData.FromNet(packet); + // Captured before Complete runs: StopMultiplayerAndClearAllWindows nulls the session + var connector = Multiplayer.session.connector; // Delay showing the window for better UX OnMainThread.Schedule(Complete, 0.3f); @@ -117,7 +119,7 @@ void Complete() .Take(10) .Join(kv => $"{kv.name}: {kv.status}", "\n"); - Find.WindowStack.Add(new JoinDataWindow(remoteInfo, Multiplayer.session.connector) + Find.WindowStack.Add(new JoinDataWindow(remoteInfo, connector) { connectAnywayDisabled = defDiff ? "MpMismatchDefsDiff".Translate() + defDiffStr : null, connectAnywayCallback = StartDownloading From a481546405b03a7a087046d246ff3e2c72781f1c Mon Sep 17 00:00:00 2001 From: cmlee119 <60453679+cmlee119@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:21:51 +0900 Subject: [PATCH 46/51] Sync EndCurrentJob in FloatMenuOptionProvider_DraftedMove.PawnGotoAction (#957) When a group of drafted pawns is given a move order, PawnGotoAction runs once per pawn on the client that issued the order. We already sync its TryTakeOrderedJob call, but it has another path: when a pawn is already standing on gotoLoc and its current job is Goto, it calls Pawn_JobTracker.EndCurrentJob directly. That call isn't synced, so the pawn stops only for the player who issued the order while it keeps walking for everyone else, causing a desync. Redirect that EndCurrentJob call to a synced wrapper through the existing DraftedMove_GotoFeedbackPatch transpiler, the same way the TryTakeOrderedJob call in the same method is already handled. Co-authored-by: chimook --- Source/Client/Patches/Feedback.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Source/Client/Patches/Feedback.cs b/Source/Client/Patches/Feedback.cs index e2e18e1e3..bf6183b18 100644 --- a/Source/Client/Patches/Feedback.cs +++ b/Source/Client/Patches/Feedback.cs @@ -237,11 +237,15 @@ static class DraftedMove_GotoFeedbackPatch private static MethodInfo tryTakeOrderedJob = AccessTools.Method(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.TryTakeOrderedJob)); + private static MethodInfo endCurrentJob = + AccessTools.Method(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.EndCurrentJob)); + static IEnumerable Transpiler(IEnumerable instructions) { foreach (var inst in instructions) { if (inst.Calls(tryTakeOrderedJob)) inst.operand = ((Delegate)CustomTryTakeOrderedJob).Method; + else if (inst.Calls(endCurrentJob)) inst.operand = ((Delegate)CustomEndCurrentJob).Method; yield return inst; } } @@ -254,6 +258,17 @@ static bool CustomTryTakeOrderedJob(Pawn_JobTracker self, Job job, JobTag? tag = FleckMaker.Static(job.targetA.Cell, self.pawn.Map, FleckDefOf.FeedbackGoto); return false; } + + // PawnGotoAction can also stop a pawn without going through TryTakeOrderedJob: when the pawn is + // already standing on gotoLoc and its current job is Goto, it calls EndCurrentJob directly. That + // call isn't synced, so the pawn stops only for the player who issued the order while it keeps + // walking for everyone else, causing a desync. Sync it the same way as the TryTakeOrderedJob call. + [SyncMethod] + static void CustomEndCurrentJob(Pawn_JobTracker self, JobCondition condition, + bool startNewJob = true, bool canReturnToPool = true) + { + self.EndCurrentJob(condition, startNewJob, canReturnToPool); + } } } From cbe74ae5dc98fa32808ac8d76fa2b58c23f82214 Mon Sep 17 00:00:00 2001 From: notfood Date: Mon, 27 Jul 2026 09:56:09 -0500 Subject: [PATCH 47/51] Prevent "Collection was modified" Fixes #969 --- Source/Client/AsyncTime/MultiplayerAsyncQuest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs index 883980f39..e9332b7fb 100644 --- a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs +++ b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs @@ -250,7 +250,7 @@ public static void TickMapQuests(AsyncTimeComp mapAsyncTimeComp) /// Quests to run QuestTick() on private static void TickQuests(IEnumerable quests) { - foreach (var quest in quests) + foreach (var quest in quests.ToList()) { quest.QuestTick(); } From 899e359cf27b27e1fda11afac490df9d5a57cd2a Mon Sep 17 00:00:00 2001 From: Kuinox Date: Mon, 27 Jul 2026 17:00:08 +0200 Subject: [PATCH 48/51] Filter map player home incident tags (#955) --- Source/Client/AsyncTime/StorytellerPatches.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index 7d21a4af5..f8068341e 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -131,6 +131,15 @@ static IEnumerable Postfix(IEnumerable Date: Sat, 1 Aug 2026 21:50:48 +0900 Subject: [PATCH 49/51] Isolate RNG in the gravship launch ritual's reachability check (#968) Building the gravship launch confirmation dialog decides which pawns can board in Dialog_BeginRitual's constructor: CreateRitualRoleAssignments -> RitualRoleAssignments.PawnNotAssignableReason -> RitualBehaviorWorker_GravshipLaunch.PawnCanFillRole -> CanReachGravship -> GravshipUtility.TryFindSpotOnGravship -> Region.RandomCell, which consumes RNG. Building the dialog is UI work opened only by the issuing peer (the currentExecutingCmdIssuedBySelf gate in CancelDialogBeginRitual), so that RNG runs on just one peer and advances the shared stream on that peer only. Spamming the launch button repeats it and the divergence accumulates into a desync ("Random state from commands doesn't match"). Wrap CanReachGravship in Rand.PushState/PopState, matching the existing SeedPreceptComp_UnwillingToDo_Chance isolation for RNG that "can be called in interface". CanReachGravship returns a bool that does not depend on the RNG (it only searches for any allowed cell), and the real boarding calls TryFindSpotOnGravship outside this check, so isolating here leaves both the check result and determinism intact. Co-authored-by: chimook Co-authored-by: Claude Opus 4.8 (1M context) --- Source/Client/Patches/Seeds.cs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Source/Client/Patches/Seeds.cs b/Source/Client/Patches/Seeds.cs index 373af28f8..31e4368a7 100644 --- a/Source/Client/Patches/Seeds.cs +++ b/Source/Client/Patches/Seeds.cs @@ -235,4 +235,34 @@ public static void Finalizer(bool __state) } } + [HarmonyPatch(typeof(RitualBehaviorWorker_GravshipLaunch), nameof(RitualBehaviorWorker_GravshipLaunch.CanReachGravship))] + static class SeedGravshipCanReachGravship + { + static void Prefix(ref bool __state) + { + if (Multiplayer.Client == null) return; + + // The gravship launch confirmation dialog works out which pawns can board while it is being + // built: Dialog_BeginRitual's constructor calls CreateRitualRoleAssignments -> + // RitualRoleAssignments.PawnNotAssignableReason -> RitualBehaviorWorker_GravshipLaunch + // .PawnCanFillRole -> CanReachGravship -> GravshipUtility.TryFindSpotOnGravship -> + // Region.RandomCell, which consumes RNG. Building that dialog is UI work and, because the + // dialog is opened only by the issuing peer (see CancelDialogBeginRitual's + // currentExecutingCmdIssuedBySelf gate), it runs on just one peer - so this RNG must not + // advance the shared stream. Spamming the launch button repeats it and the divergence + // accumulates into a desync ("Random state from commands doesn't match"). + // CanReachGravship returns a bool that does not depend on the RNG (it only searches for any + // allowed cell), and the real boarding calls TryFindSpotOnGravship outside this check, so + // isolating the RNG here leaves both the check result and determinism intact. + Rand.PushState(); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + Rand.PopState(); + } + } + } From e760d3018495589ded3ecb21901705c47deb4a2c Mon Sep 17 00:00:00 2001 From: cmlee119 <60453679+cmlee119@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:01:54 +0900 Subject: [PATCH 50/51] Add missing SetContext Map for Gravship launch (#966) --- Source/Client/Syncing/Game/SyncDelegates.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index cff9df04b..fbacb638e 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -105,7 +105,7 @@ public static void Init() SyncDelegate.Lambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), 4); // Cancel gravship tile picker SyncDelegate.Lambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), 5); // Confirm gravship landing tile SyncDelegate.Lambda(typeof(RitualOutcomeEffectWorker_GravshipLaunch), nameof(RitualOutcomeEffectWorker_GravshipLaunch.Apply), 0); // Confirm gravship prelaunch dialog - SyncDelegate.Lambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), 4); // Cancel gravship prelaunch dialog + SyncDelegate.Lambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), 4).SetContext(SyncContext.MapSelected); // Cancel gravship prelaunch dialog // Biosculpter pod SyncMethod.Lambda(typeof(CompBiosculpterPod), nameof(CompBiosculpterPod.CompGetGizmosExtra), 1); // Interrupt cycle (eject contents) From 30e5ad409bfe4fa3cea61b6794f293f4d3cda691 Mon Sep 17 00:00:00 2001 From: GetParanoid Date: Wed, 9 Sep 2026 01:46:58 -0500 Subject: [PATCH 51/51] Isolate RNG in PowerNet battery energy distribution --- Source/Client/Patches/Seeds.cs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Source/Client/Patches/Seeds.cs b/Source/Client/Patches/Seeds.cs index 31e4368a7..c93ca513a 100644 --- a/Source/Client/Patches/Seeds.cs +++ b/Source/Client/Patches/Seeds.cs @@ -265,4 +265,34 @@ static void Finalizer(bool __state) } } + [HarmonyPatch(typeof(PowerNet), nameof(PowerNet.DistributeEnergyAmongBatteries))] + static class SeedPowerNetBatteryDistribution + { + static void Prefix(ref bool __state) + { + if (Multiplayer.Client == null) return; + + // DistributeEnergyAmongBatteries shuffles the net's battery list, consuming RNG. Whether it + // runs at all on a given tick is decided by a float comparison in PowerNet.ChangeStoredEnergy + // (extra > 0f), fed by CurrentEnergyGainRate/CurrentStoredEnergy summing floats across every + // power component on the net. Those sums drift by tiny amounts between machines, so the + // comparison can flip a tick earlier on one peer than another: one peer runs the shuffle on a + // tick where the other does not, the shared stream advances by a different number of calls, + // and the map desyncs ("Wrong random state on map 0"). A colony with a large enough grid hits + // this repeatedly - the desync recurs every few thousand ticks after each rejoin. + // Isolating the RNG is safe because the shuffled order cannot affect the outcome: each pass + // adds the same amount to every battery still in the list (the smallest AmountCanAccept, or + // an even share of what remains), and a battery's AmountCanAccept depends only on itself, so + // every permutation leaves the batteries holding the same energy. + Rand.PushState(); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + Rand.PopState(); + } + } + }