From 675ec35ac0d62617db0133c30a7ad9045ed7f102 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:34:04 -0600 Subject: [PATCH 001/183] Isolate RNG in sound and music entry points Fire.TickInterval calls SustainerAggregatorUtility.AggregateOrSpawnSustainerFor, which constructs a SoundSizeAggregator (Rand.Value) only when no aggregated sustainer exists locally. Sustainer lifetime depends on the local sound manager (camera, frame timing), so the map RNG advanced on some clients only (#618). Push/pop the RNG around the sound entry points reachable from ticks (PlayOneShot, TrySpawnSustainer, Sustainer ctor, the aggregator and its ctor) and the public music manager triggers. The push is skipped off the main thread so async long events don't race on Rand's state stack. Co-Authored-By: Claude Opus 5.5 --- Source/Client/MultiplayerStatic.cs | 19 +++++++++++++++++++ Source/Client/Patches/Seeds.cs | 10 ++++++++++ 2 files changed, 29 insertions(+) diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 49519f9b9..82d655fdc 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -325,10 +325,26 @@ void TryPatch(MethodBase original, HarmonyMethod prefix = null, HarmonyMethod po { var randPatchPrefix = new HarmonyMethod(typeof(RandPatches), nameof(RandPatches.Prefix)); var randPatchFinalizer = new HarmonyMethod(typeof(RandPatches), nameof(RandPatches.Finalizer)); + var randPatchPrefixMainThread = new HarmonyMethod(typeof(RandPatches), nameof(RandPatches.PrefixMainThread)); var subSustainerStart = MpMethodUtil.GetLambda(typeof(SubSustainer), parentMethodType: MethodType.Constructor, parentArgs: new[] { typeof(Sustainer), typeof(SubSoundDef) }); var sampleCtor = typeof(Sample).GetConstructor(new[] { typeof(SubSoundDef) }); var subSoundPlay = typeof(SubSoundDef).GetMethod(nameof(SubSoundDef.TryPlay)); + // Whether these consume RNG depends on the local sound manager state (camera, frame timing) + var soundMethods = new MethodBase[] + { + typeof(SoundStarter).GetMethod(nameof(SoundStarter.PlayOneShot)), + typeof(SoundStarter).GetMethod(nameof(SoundStarter.TrySpawnSustainer)), + typeof(Sustainer).GetConstructor(new[] { typeof(SoundDef), typeof(SoundInfo) }), + typeof(SustainerAggregatorUtility).GetMethod(nameof(SustainerAggregatorUtility.AggregateOrSpawnSustainerFor)), + typeof(SoundSizeAggregator).GetConstructor(Type.EmptyTypes), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.ForceTriggerTransition)), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.ForceTriggerNextSongOrSequence)), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.ForcePlaySong)), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.ForceFadeoutAndSilenceFor)), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.StartNewSong)), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.ScheduleNewSong)), + }; var effecterTick = typeof(Effecter).GetMethod(nameof(Effecter.EffectTick)); var effecterTrigger = typeof(Effecter).GetMethod(nameof(Effecter.Trigger)); var effecterCleanup = typeof(Effecter).GetMethod(nameof(Effecter.Cleanup)); @@ -353,6 +369,9 @@ void TryPatch(MethodBase original, HarmonyMethod prefix = null, HarmonyMethod po foreach (MethodBase m in effectMethods.Concat(moteMethods).Concat(fleckMethods).Concat(ritualMethods)) TryPatch(m, randPatchPrefix, finalizer: randPatchFinalizer); + + foreach (MethodBase m in soundMethods) + TryPatch(m, randPatchPrefixMainThread, finalizer: randPatchFinalizer); } SetCategory("Non-deterministic patches 2"); diff --git a/Source/Client/Patches/Seeds.cs b/Source/Client/Patches/Seeds.cs index 31e4368a7..1dc85186b 100644 --- a/Source/Client/Patches/Seeds.cs +++ b/Source/Client/Patches/Seeds.cs @@ -152,6 +152,16 @@ public static void Prefix(ref bool __state) __state = true; } + // For methods that may also run on worker threads (e.g. async long events), where touching the shared + // Rand state stack would race with the main thread + [HarmonyPriority(MpPriority.MpFirst)] + public static void PrefixMainThread(ref bool __state) + { + if (!UnityData.IsInMainThread) return; + Rand.PushState(); + __state = true; + } + [HarmonyPriority(MpPriority.MpLast)] public static void Finalizer(bool __state) { From f8d9f49f9a54af3aa41fef99274f491e13570cc1 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:39:54 -0600 Subject: [PATCH 002/183] Run gravship takeoff/landing end in a synced command TakeoffEnded and LandingEnded are called by vanilla from WorldComponentUpdate, i.e. per frame in the interface, at a different moment on every client. The landing outcome, session closing and map speed change ran there with interface semantics: synced methods called inside were sent as commands by every client (#640), unique IDs were local, Faction.OfPlayer was the local faction and RNG was popped. When the local cutscene ends, each client now unfreezes and sends a synced command (landing as a command of the landing map, takeoff as a world command). The first one to execute runs the vanilla method on all clients; later ones are ignored using a pending flag that is only changed from command execution. The cutscene is held on its last frame until then. In multifaction the gravship's faction is pushed. Co-Authored-By: Claude Opus 5.5 --- .../Patches/GravshipTravelSessionPatches.cs | 212 ++++++++++++++++-- 1 file changed, 198 insertions(+), 14 deletions(-) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 4939f821c..35c9c527a 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -3,6 +3,7 @@ using System.Reflection; using HarmonyLib; using Multiplayer.API; +using Multiplayer.Client.Factions; using Multiplayer.Client.Persistent; using Multiplayer.Client.Util; using RimWorld; @@ -181,49 +182,232 @@ static IEnumerable TargetMethods() yield return AccessTools.Method(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.InitiateLanding)); } - static void Postfix() + static void Postfix(MethodBase __originalMethod) { if (Multiplayer.Client == null) return; + GravshipCutsceneSync.CutsceneStarted(__originalMethod.Name == nameof(WorldComponent_GravshipController.InitiateTakeoff)); + } + } + + /// + /// The takeoff/landing cutscenes end in WorldComponent_GravshipController.WorldComponentUpdate, i.e. in a frame + /// update, at a different moment on every client and outside of any tick or command. TakeoffEnded/LandingEnded + /// change the game state (landing outcome, world objects, maps, sessions), so in MP they are not run from there. + /// Instead, every client sends a synced command when its own cutscene ends and the first one to execute runs + /// the method on all clients at the same point of the simulation. The others are ignored. + /// + public static class GravshipCutsceneSync + { + private enum Cutscene { None, Takeoff, Landing } + + // Only changed from InitiateTakeoff/InitiateLanding and TakeoffEnded/LandingEnded which run in commands, + // so it's the same on all clients. + private static Cutscene pending; + private static MultiplayerGame pendingGame; + + // Local state of this client + private static bool endRequested; + private static bool frozen; + + public static bool EndRequested => endRequested && pendingGame == Multiplayer.game; + + public static void CutsceneStarted(bool takeoff) + { + pending = takeoff ? Cutscene.Takeoff : Cutscene.Landing; + pendingGame = Multiplayer.game; + endRequested = false; + + frozen = true; GravshipTravelUtils.StartFreeze(); } + + private static void Unfreeze() + { + if (!frozen) return; + + frozen = false; + GravshipTravelUtils.StopFreeze(); + } + + private static bool IsPending(bool takeoff) => + pendingGame == Multiplayer.game && pending == (takeoff ? Cutscene.Takeoff : Cutscene.Landing); + + // Called by vanilla in the interface when the local cutscene ends + public static void LocalCutsceneEnded(WorldComponent_GravshipController controller, bool takeoff) + { + if (EndRequested || !Multiplayer.ShouldSync) + return; + + if (!IsPending(takeoff)) + { + // The synced end already ran (e.g. this client's cutscene was very late), only clean up the visuals + Log.Warning($"MP: Gravship {(takeoff ? "takeoff" : "landing")} cutscene ended locally without a pending synced end"); + Unfreeze(); + WorldComponent_GravshipController.cutsceneInProgress = false; + + try + { + controller.ResetCutscene(); + } + catch (Exception e) + { + Log.Error($"MP: Exception resetting gravship cutscene: {e}"); + } + + return; + } + + endRequested = true; + + // Commands don't execute while frozen + Unfreeze(); + + if (takeoff) + SyncTakeoffEnded(); + else + SyncLandingEnded(controller.map); + } + + // Called when TakeoffEnded/LandingEnded actually runs + public static void SyncedEndExecuting() + { + pending = Cutscene.None; + endRequested = false; + Unfreeze(); + } + + [SyncMethod] + public static void SyncTakeoffEnded() + { + if (!IsPending(true)) return; + RunWithFeedback(Find.GravshipController.TakeoffEnded); + } + + // The map argument makes it a command of the landing map (its RNG, time and faction) + [SyncMethod] + public static void SyncLandingEnded(Map map) + { + if (!IsPending(false)) return; + RunWithFeedback(Find.GravshipController.LandingEnded); + } + + // Everyone watched the cutscene, so show its feedback (sounds, messages, camera) to all players like + // vanilla does, not only to the player whose command happened to arrive first. + private static void RunWithFeedback(Action action) + { + var prevIssuedBySelf = TickPatch.currentExecutingCmdIssuedBySelf; + TickPatch.currentExecutingCmdIssuedBySelf = !TickPatch.Simulating; + + try + { + action(); + } + finally + { + TickPatch.currentExecutingCmdIssuedBySelf = prevIssuedBySelf; + } + } } - [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.TakeoffEnded))] - public static class PatchGravshipTakeoffEnded + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.WorldComponentUpdate))] + public static class PatchGravshipCutsceneHoldLastFrame { static void Prefix(WorldComponent_GravshipController __instance) { if (Multiplayer.Client == null) return; - GravshipTravelUtils.StopFreeze(); + // Keep the cutscene on its last frame while waiting for the synced end + if (GravshipCutsceneSync.EndRequested && __instance.timeLeft < 0f) + __instance.timeLeft = 0f; + } + } + + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.TakeoffEnded))] + public static class PatchGravshipTakeoffEnded + { + static bool Prefix(WorldComponent_GravshipController __instance, out (bool factionPushed, Map map) __state) + { + __state = default; + if (Multiplayer.Client == null) return true; + + if (Multiplayer.InInterface) + { + GravshipCutsceneSync.LocalCutsceneEnded(__instance, true); + return false; + } + + GravshipCutsceneSync.SyncedEndExecuting(); GravshipTravelUtils.CloseSessionAt(__instance.takeoffTile); + + if (Multiplayer.GameComp.multifaction && __instance.gravship?.Faction is { } faction) + { + __instance.map.PushFaction(faction); + __state = (true, __instance.map); + } + + return true; + } + + static void Finalizer((bool factionPushed, Map map) __state) + { + if (!__state.factionPushed) return; + + // TakeoffEnded can remove the map that was left behind + if (__state.map != null && Find.Maps.Contains(__state.map)) + __state.map.PopFaction(); + else + FactionExtensions.PopFaction(); } } - // TODO: Is the random pushing here necessary? This might be related to issue #638. [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.LandingEnded))] public static class PatchGravshipLandingEnded { - static void Prefix(WorldComponent_GravshipController __instance) + static bool Prefix(WorldComponent_GravshipController __instance, out (Map factionMap, bool randPushed) __state) { - if (Multiplayer.Client == null) return; + __state = default; + if (Multiplayer.Client == null) return true; + + if (Multiplayer.InInterface) + { + GravshipCutsceneSync.LocalCutsceneEnded(__instance, false); + return false; + } - Rand.PushState(); - Rand.StateCompressed = __instance.map.AsyncTime().randState; + var map = __instance.map; - GravshipTravelUtils.StopFreeze(); + // Normally runs in a command of the landing map which already provides its RNG state (and saves it after). + // Otherwise (e.g. called while loading) keep it from consuming an unrelated RNG state. + if (!Multiplayer.ExecutingCmds && !Multiplayer.Ticking) + { + Rand.PushState(); + Rand.StateCompressed = map.AsyncTime().randState; + __state.randPushed = true; + } + + GravshipCutsceneSync.SyncedEndExecuting(); GravshipTravelUtils.CloseSessionAt(__instance.gravship.destinationTile); // LandingEnded calls Find.TickManager.CurTimeSpeed = TimeSpeed.Normal, but that doesn't work in MP as we // have our own way of changing the map speed. - __instance.map.AsyncTime().DesiredTimeSpeed = TimeSpeed.Normal; + map.AsyncTime().DesiredTimeSpeed = TimeSpeed.Normal; + + if (Multiplayer.GameComp.multifaction && __instance.gravship.Faction is { } faction) + { + map.PushFaction(faction); + __state.factionMap = map; + } + + return true; } - static void Finalizer() + static void Finalizer((Map factionMap, bool randPushed) __state) { - if (Multiplayer.Client == null) return; - Rand.PopState(); + __state.factionMap?.PopFaction(); + + if (__state.randPushed) + Rand.PopState(); } } #endregion From 83d0cc8dfebd05ac6642ca17dcaee7775ab6ab37 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:40:56 -0600 Subject: [PATCH 003/183] Fix inverted check in the zone/plan cell shuffle patch The transpiler emitted 'call ShouldShuffle; not; or'. OpCodes.Not is a bitwise not, so the condition was always non-zero and Zone/Plan cells were never shuffled, even in singleplayer. Restore the vanilla shuffle in singleplayer and keep MP on the long-standing (deterministic) no-shuffle behaviour: the shuffle uses Rand and the shuffled flag isn't saved, so after an asymmetric reload or a UI access it could differ between clients. Log an error if the patch site isn't found. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Determinism.cs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index 9bdf674e2..eda6d5811 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -149,25 +149,31 @@ static IEnumerable Transpiler(IEnumerable inst public static class CellsShufflePatchShared { - public static IEnumerable Transpiler(IEnumerable insts, FieldInfo cellsShuffledField) + // Turns "if (!cellsShuffled) shuffle" into "if (!(cellsShuffled || SkipShuffle())) shuffle" + public static IEnumerable Transpiler(IEnumerable insts, FieldInfo cellsShuffledField, MethodBase original) { bool found = false; foreach (CodeInstruction inst in insts) { yield return inst; - if (!found && inst.operand as FieldInfo == cellsShuffledField) + if (!found && inst.opcode == OpCodes.Ldfld && inst.operand as FieldInfo == cellsShuffledField) { - yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(CellsShufflePatchShared), nameof(ShouldShuffle))); - yield return new CodeInstruction(OpCodes.Not); + yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(CellsShufflePatchShared), nameof(SkipShuffle))); yield return new CodeInstruction(OpCodes.Or); found = true; } } + + if (!found) + Log.Error($"MP: Couldn't patch the cell shuffle in {original.DeclaringType}:{original.Name}. Was the original method changed?"); } - public static bool ShouldShuffle() + // The shuffle uses Rand and its "shuffled" flag isn't saved, so whether a given access shuffles could differ + // between clients (e.g. only one of them reloaded or first accessed the cells from the UI). Before this was + // made explicit, a bitwise "not" here made the shuffle never happen at all, so keep that in MP. + public static bool SkipShuffle() { - return Multiplayer.Client == null || Multiplayer.Ticking; + return Multiplayer.Client != null; } } @@ -175,16 +181,16 @@ public static bool ShouldShuffle() static class ZoneCellsShufflePatch { static readonly FieldInfo CellsShuffled = AccessTools.Field(typeof(Zone), "cellsShuffled"); - static IEnumerable Transpiler(IEnumerable insts) - => CellsShufflePatchShared.Transpiler(insts, CellsShuffled); + static IEnumerable Transpiler(IEnumerable insts, MethodBase __originalMethod) + => CellsShufflePatchShared.Transpiler(insts, CellsShuffled, __originalMethod); } [HarmonyPatch(typeof(Plan), nameof(Plan.Cells), MethodType.Getter)] static class PlanCellsShufflePatch { static readonly FieldInfo CellsShuffled = AccessTools.Field(typeof(Plan), "cellsShuffled"); - static IEnumerable Transpiler(IEnumerable insts) - => CellsShufflePatchShared.Transpiler(insts, CellsShuffled); + static IEnumerable Transpiler(IEnumerable insts, MethodBase __originalMethod) + => CellsShufflePatchShared.Transpiler(insts, CellsShuffled, __originalMethod); } [HarmonyPatch] From d2cf0425bc7274767792cdaac896aab309b95cae Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:41:02 -0600 Subject: [PATCH 004/183] Keep the server join flow on the server thread TypedPacket() wrapped the packet task in ContinueWith without ExecuteSynchronously. The server thread has no SynchronizationContext, so everything after the first awaited typed packet in ServerJoiningState (PauseAll, join point creation, OnJoin, the InitPlayerData command, disconnects) ran on a thread pool thread, concurrently with the server loop. That raced CommandHandler.Send (mapCmds, SentCmds, per-peer send order) and player list mutations. Typed packet waits now use PacketAwaitable directly: the packet is bound and the awaiting state resumed inline by the packet handler, so the whole async state runs on the thread that handles the packet. PacketAwaitable tracks completion explicitly and supports exceptions. Add MultiplayerServer.CheckServerThread() and call it from CommandHandler.Send and PlayerManager.OnJoin/SetDisconnected. It logs (rate limited) when they run off the server thread once it started. The loopback join test now fails on a regression; the join point test ends the join point on the server thread like the real code does. Co-Authored-By: Claude Opus 5.5 --- Source/Common/CommandHandler.cs | 2 + Source/Common/MultiplayerServer.cs | 19 +++ .../Common/Networking/AsyncConnectionState.cs | 130 ++++++++++++------ Source/Common/PlayerManager.cs | 2 + Source/Tests/ServerTest.cs | 6 +- 5 files changed, 114 insertions(+), 45 deletions(-) diff --git a/Source/Common/CommandHandler.cs b/Source/Common/CommandHandler.cs index ef7b43b77..2ae4184d4 100644 --- a/Source/Common/CommandHandler.cs +++ b/Source/Common/CommandHandler.cs @@ -17,6 +17,8 @@ public CommandHandler(MultiplayerServer server) public void Send(CommandType cmdType, int factionId, int mapId, byte[] data, ServerPlayer? sourcePlayer = null, ServerPlayer? fauxSource = null) { + server.CheckServerThread(); + // policy if (sourcePlayer != null) { diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 7d3940f8b..4f508de1c 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -77,6 +78,10 @@ static MultiplayerServer() public bool IsStandaloneServer { get; set; } public StandalonePersistence? persistence; + private Thread? serverThread; + private int offThreadCalls; + public int OffThreadCalls => offThreadCalls; + public MultiplayerServer(ServerSettings settings) { this.settings = settings; @@ -94,6 +99,7 @@ public MultiplayerServer(ServerSettings settings) public void Run() { + serverThread = Thread.CurrentThread; ServerLog.Detail("Server started"); Stopwatch time = Stopwatch.StartNew(); @@ -215,6 +221,19 @@ public void Enqueue(Action action) queue.Enqueue(action); } + /// Server state isn't thread-safe and the server thread defines the order of commands, so mutations from + /// other threads can race the tick loop. Use Enqueue to get onto the server thread. + public void CheckServerThread([CallerMemberName] string caller = "") + { + var thread = serverThread; + if (thread == null || thread == Thread.CurrentThread || !running) return; + + // Limit the log spam, one stack trace is enough to find the culprit + if (Interlocked.Increment(ref offThreadCalls) <= 10) + ServerLog.Error($"{caller} called outside the server thread " + + $"(thread {Thread.CurrentThread.ManagedThreadId}): {Environment.StackTrace}"); + } + public void SendToPlaying(T packet, bool reliable = true, ServerPlayer? excluding = null) where T : IPacket { var serialized = packet.Serialize(); diff --git a/Source/Common/Networking/AsyncConnectionState.cs b/Source/Common/Networking/AsyncConnectionState.cs index a1ca6a719..27ae847c3 100644 --- a/Source/Common/Networking/AsyncConnectionState.cs +++ b/Source/Common/Networking/AsyncConnectionState.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; using System.Threading.Tasks; using Multiplayer.Common.Networking.Packet; using Multiplayer.Common.Util; @@ -64,16 +65,18 @@ protected PacketAwaitable Packet(Packets packet) /// An exception is thrown if this is called again before the packet arrives. The player is disconnected if a /// different packet type arrives. /// - protected Task TypedPacket() where T: struct, IPacket + protected PacketAwaitable TypedPacket() where T: struct, IPacket { - if (packetAwaitable != null) - throw new Exception($"Already waiting for another packet: {packetAwaitable}"); - - ServerLog.Verbose($"{connection} waiting for {PacketTypeInfo.Id}"); - - packetAwaitable = TypedPacketAwaitable(out var task, announcePacketFailure: false); - return task - .ContinueWith(finishedTask => (T)finishedTask.Result!, TaskContinuationOptions.OnlyOnRanToCompletion); + var typed = new PacketAwaitable(PacketTypeInfo.Id, announcePacketFailure: false); + WaitForTypedPacket(announcePacketFailure: false, packet => + { + if (packet.HasValue) + typed.SetResult(packet.Value); + else + // Packet failure isn't announced for this awaitable, so this shouldn't happen + typed.SetException(new InvalidOperationException($"Didn't receive {PacketTypeInfo.Id}")); + }, typed.SetException); + return typed; } /// @@ -99,15 +102,51 @@ protected Task TypedPacket() where T: struct, IPacket /// An exception is thrown if this is called again before the packet arrives. The player is disconnected if a /// different packet type arrives. /// - protected Task TypedPacketOrNull() where T: struct, IPacket + protected PacketAwaitable TypedPacketOrNull() where T: struct, IPacket + { + var typed = new PacketAwaitable(PacketTypeInfo.Id, announcePacketFailure: true); + WaitForTypedPacket(announcePacketFailure: true, typed.SetResult, typed.SetException); + return typed; + } + + private void WaitForTypedPacket(bool announcePacketFailure, Action onResult, Action onError) + where T : struct, IPacket { if (packetAwaitable != null) throw new Exception($"Already waiting for another packet: {packetAwaitable}"); ServerLog.Verbose($"{connection} waiting for {PacketTypeInfo.Id}"); - packetAwaitable = TypedPacketAwaitable(out var task, announcePacketFailure: true); - return task; + var raw = new PacketAwaitable(PacketTypeInfo.Id, announcePacketFailure); + if (PacketTypeInfo.AllowFragmented) raw.Fragmented(); + + // Bind and resume the awaiting state inline, on the thread that handles the packet (the server thread). + // Task continuations used to move the rest of the state onto the thread pool, racing the server loop. + raw.OnCompleted(() => + { + var reader = raw.GetResult(); + if (reader == null) + { + onResult(null); + return; + } + + var packet = default(T); + try + { + packet.Bind(new PacketReader(reader)); + } + catch (Exception e) + { + ServerLog.Error($"Failed to bind packet {PacketTypeInfo.Id}: {e}"); + onError(e); + return; + } + + onResult(packet); + }); + + packetAwaitable = raw; } public override PacketHandlerInfo? GetPacketHandler(Packets packet) @@ -129,52 +168,55 @@ protected async Task EndIfDead() await new Blackhole(); return true; } - - private static PacketAwaitable TypedPacketAwaitable(out Task task, bool announcePacketFailure) where TPacket : struct, IPacket - { - var awaitable = new PacketAwaitable(PacketTypeInfo.Id, announcePacketFailure); - if (PacketTypeInfo.AllowFragmented) awaitable.Fragmented(); - - task = CreateTask(); - return awaitable; - - async Task CreateTask() - { - // Announce packet failure is false, so this won't be null. - var reader = await awaitable; - if (reader == null) return null; - var packet = default(TPacket); - try - { - packet.Bind(new PacketReader(reader)); - } - catch (Exception e) - { - ServerLog.Error($"Failed to bind packet {PacketTypeInfo.Id}: {e}"); - throw; - } - return packet; - } - } } public class PacketAwaitable(Packets packetType, bool announcePacketFailure) : INotifyCompletion { private List continuations = new(); + private bool completed; + private T result = default!; + private ExceptionDispatchInfo? exception; + public Packets PacketType { get; } = packetType; public bool AnnouncePacketFailure { get; } = announcePacketFailure; public bool Fragment { get; private set; } - private T? result; - public void OnCompleted(Action continuation) => continuations.Add(continuation); - public bool IsCompleted => result != null; - public T GetResult() => result!; + public bool IsCompleted => completed; public PacketAwaitable GetAwaiter() => this; + public void OnCompleted(Action continuation) + { + if (completed) continuation(); + else continuations.Add(continuation); + } + + public T GetResult() + { + exception?.Throw(); + return result; + } + public void SetResult(T r) { result = r; - foreach (var continuation in continuations) + Complete(); + } + + public void SetException(Exception e) + { + exception = ExceptionDispatchInfo.Capture(e); + Complete(); + } + + // Continuations run synchronously on the completing thread + private void Complete() + { + if (completed) throw new InvalidOperationException($"Awaitable for {PacketType} completed twice"); + completed = true; + + var toRun = continuations; + continuations = []; + foreach (var continuation in toRun) continuation(); } diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index efa871704..ce6d67361 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -65,6 +65,7 @@ public ServerPlayer OnConnected(ConnectionBase conn) public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) { if (conn.State == ConnectionStateEnum.Disconnected) return; + server.CheckServerThread(); conn.StateObj?.OnDisconnect(); @@ -135,6 +136,7 @@ public void OnDesync(ServerPlayer player, int tick, int diffAt) public void OnJoin(ServerPlayer player) { + server.CheckServerThread(); player.hasJoined = true; var standalonePrimaryPlayer = server.IsStandaloneServer && !server.JoinedPlayers.Any(p => p != player && !p.IsArbiter); diff --git a/Source/Tests/ServerTest.cs b/Source/Tests/ServerTest.cs index 4cfff8bd5..6f541749d 100644 --- a/Source/Tests/ServerTest.cs +++ b/Source/Tests/ServerTest.cs @@ -42,6 +42,9 @@ public void Test() Thread.Sleep(50); } + + // The join flow sends commands and mutates players, which must happen on the server thread + Assert.That(server.OffThreadCalls, Is.Zero); } [Test] @@ -78,7 +81,8 @@ public void LoadingStateHandlesKeepAliveWhileWaitingForJoinPoint() _ = Task.Run(async () => { await Task.Delay(200); - server.worldData.EndJoinPointCreation(); + // Join points end on the server thread (world upload handler) + server.Enqueue(() => server.worldData.EndJoinPointCreation()); }); ConnectClient(port, typeof(TestLoadingKeepAliveState)); From 393ac902d3a4ebe58b8b927518e644c99e388438 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:41:41 -0600 Subject: [PATCH 005/183] Allow PriorityWork.Clear during ticks The patch meant to stop the interface (IsPrioritized timeout check from the gizmos) from clearing prioritized work, but its guard only allowed command execution. Vanilla's tick-time calls (JobGiver_Work after the prioritized work is exhausted, the timeout check while thinking) were dropped too, leaving the pawn prioritized until the timeout. Block only interface calls. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Determinism.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index eda6d5811..8cd328af3 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -258,10 +258,11 @@ static void Postfix(Pawn_AbilityTracker __instance, ref List __result) [HarmonyPatch(typeof(PriorityWork), nameof(PriorityWork.Clear))] static class PriorityWorkClearNoInterface { - // This can get called in the UI but has side effects + // This can get called in the UI (IsPrioritized timeout check from the gizmos) but has side effects. + // Calls during ticking (e.g. JobGiver_Work after exhausting the prioritized work) are deterministic. static bool Prefix(PriorityWork __instance) { - return Multiplayer.Client == null || Multiplayer.ExecutingCmds; + return Multiplayer.Client == null || !Multiplayer.InInterface; } } From 93d6f864ebdaf271d78403828141fc3752e4301a Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:43:04 -0600 Subject: [PATCH 006/183] Make the deferred stack tracer fail safely The tracer runs as a postfix of Rand.Value/Rand.Int. Any exception in it aborted the game code after the RNG had advanced, on that client only: - GetStackUsage threw "Unknown function header" (and left a half-filled cache entry that later walks followed with a bogus frame size) - frames skipped for AggressiveInlining were stored without counting toward the 32-frame cap, so traceIn could overflow - the LMF walk dereferenced previous_lmf before the zero check and without masking Mono's flag bits (the NRE from #944) Now unknown frames are cached as such and end the walk, LMF pointers are masked and checked (ext/trampoline LMFs end the walk), rbp must move up the stack, the walk has a step cap and the postfix never throws. A failed walk still records a placeholder trace so trace counts stay aligned, and marks the opinion so its trace hashes aren't compared (sent as "simulating") instead of producing a false "Trace hashes don't match". The failure is logged once. Frames skipped for AggressiveInlining no longer count toward the recorded depth or the hashed frames either, since whether they appear can differ between platforms. Traces are only taken on the main thread. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Desyncs/ClientSyncOpinion.cs | 6 +- Source/Client/Desyncs/DeferredStackTracing.cs | 73 +++++++++++++-- Source/Client/Desyncs/SyncCoordinator.cs | 9 ++ Source/Common/DeferredStackTracingImpl.cs | 89 ++++++++++++++----- 4 files changed, 143 insertions(+), 34 deletions(-) diff --git a/Source/Client/Desyncs/ClientSyncOpinion.cs b/Source/Client/Desyncs/ClientSyncOpinion.cs index 0ab3482f3..daf032339 100644 --- a/Source/Client/Desyncs/ClientSyncOpinion.cs +++ b/Source/Client/Desyncs/ClientSyncOpinion.cs @@ -28,6 +28,8 @@ public class ClientSyncOpinion(int startTick) public List desyncStackTraces = new(); public List desyncStackTraceHashes = new(); public bool simulating; + // Local only, sent as simulating: a stack walk failed so the trace hashes can't be compared + public bool tracesUnreliable; public RoundModeEnum roundMode; public string CheckForDesync(ClientSyncOpinion other) @@ -53,7 +55,7 @@ join map2 in other.mapStates on map1.mapId equals map2.mapId if (!commandRandomStates.SequenceEqual(other.commandRandomStates)) return "Random state from commands doesn't match"; - if (!simulating && !other.simulating && desyncStackTraceHashes.Any() && other.desyncStackTraceHashes.Any() && !desyncStackTraceHashes.SequenceEqual(other.desyncStackTraceHashes)) + if (!simulating && !other.simulating && !tracesUnreliable && !other.tracesUnreliable && desyncStackTraceHashes.Any() && other.desyncStackTraceHashes.Any() && !desyncStackTraceHashes.SequenceEqual(other.desyncStackTraceHashes)) return "Trace hashes don't match"; return null; @@ -108,7 +110,7 @@ public byte[] Serialize() mapRandomStates = mapStates.Select(state => new MapRandomState { mapId = state.mapId, randomStates = state.randomStates }).ToList(), traceHashes = desyncStackTraceHashes, - simulating = simulating, + simulating = simulating || tracesUnreliable, roundMode = roundMode }; diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index db53da2df..8efe7afcb 100644 --- a/Source/Client/Desyncs/DeferredStackTracing.cs +++ b/Source/Client/Desyncs/DeferredStackTracing.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Reflection; using System.Runtime.InteropServices; @@ -29,20 +30,71 @@ static IEnumerable TargetMethods() private static bool SupportsDeferredStackTracing => RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86; + private static bool loggedTraceFailure; + public static void Postfix() { if (Native.LmfPtr == 0) return; - if (!ShouldAddStackTraceForDesyncLog()) return; - - var logItem = StackTraceLogItemRaw.GetFromPool(); - var trace = logItem.raw; - int hash = 0; - // Skip this (DeferredStackTracing.Postfix) frame. Keeping it in the trace doesn't provide any value. - int depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash, skipFrames: 1); - Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); + // This runs inside Rand and other game code: an exception here would abort the game code on this client + // only, so never let one escape + try + { + if (!ShouldAddStackTraceForDesyncLog()) return; + + var logItem = StackTraceLogItemRaw.GetFromPool(); + var trace = logItem.raw; + int hash = 0; + int depth; + + try + { + // Skip this (DeferredStackTracing.Postfix) frame. Keeping it in the trace doesn't provide any value. + depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash, skipFrames: 1); + } + catch (Exception e) + { + LogTraceFailureOnce(e.ToString()); + depth = -1; + } + + if (depth < 0) + { + if (DeferredStackTracingImpl.lastFailureReason != null) + LogTraceFailureOnce(DeferredStackTracingImpl.lastFailureReason); + + // Keep the trace count in line with other clients but don't compare this window's trace hashes + depth = 0; + hash = 0; + Multiplayer.game.sync.MarkTracesUnreliable(); + } + + Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); + + acc++; + } + catch (Exception e) + { + LogTraceFailureOnce(e.ToString()); + } + } - acc++; + private static void LogTraceFailureOnce(string info) + { + if (loggedTraceFailure) return; + loggedTraceFailure = true; + + string at = null; + try + { + at = Native.MethodNameFromAddr(DeferredStackTracingImpl.lastFailureAddr, false); + } + catch + { + // ignored + } + + Log.Error($"Multiplayer: deferred stack tracing failed (further failures aren't logged). At: {at ?? "unknown"}. {info}"); } public static bool ShouldAddStackTraceForDesyncLog() @@ -59,6 +111,9 @@ public static bool ShouldAddStackTraceForDesyncLog() if (!Multiplayer.Ticking && !Multiplayer.ExecutingCmds) return false; + // Worker thread timing isn't deterministic and LmfPtr belongs to the main thread + if (!UnityData.IsInMainThread) return false; + return ignoreTraces == 0; } } diff --git a/Source/Client/Desyncs/SyncCoordinator.cs b/Source/Client/Desyncs/SyncCoordinator.cs index 0714065d5..1efd6f535 100644 --- a/Source/Client/Desyncs/SyncCoordinator.cs +++ b/Source/Client/Desyncs/SyncCoordinator.cs @@ -212,6 +212,15 @@ public void TryAddInfoForDesyncLog(string info1, string info2) OpinionInBuilding.desyncStackTraceHashes.Add(hash); } + /// + /// Excludes the current opinion's trace hashes from comparison, used when the local stack walk failed. + /// + public void MarkTracesUnreliable() + { + if (!ShouldCollect) return; + OpinionInBuilding.tracesUnreliable = true; + } + public void TryAddStackTraceForDesyncLogRaw(StackTraceLogItemRaw item, int depth, int hashIn, string moreInfo = null) { if (!ShouldCollect) return; diff --git a/Source/Common/DeferredStackTracingImpl.cs b/Source/Common/DeferredStackTracingImpl.cs index 92c3a71a0..0df72205d 100644 --- a/Source/Common/DeferredStackTracingImpl.cs +++ b/Source/Common/DeferredStackTracingImpl.cs @@ -102,6 +102,10 @@ public static class DeferredStackTracingImpl { const long NotJit = long.MaxValue; const long RbpBased = long.MaxValue - 1; + const long UnknownFrame = long.MaxValue - 2; + + // Upper bound on loop iterations in case the walk gets stuck (e.g. on a cyclic LMF chain) + const int MaxWalkSteps = 256; const long UsesRbpAsGpr = 1L << 50; const long UsesRbx = 1L << 51; @@ -111,6 +115,11 @@ public static class DeferredStackTracingImpl public static AddrTable hashTable = new(); + // Info about the last failed stack walk, for logging + public static string? lastFailureReason; + public static long lastFailureAddr; + + // Returns the number of frames written to traceIn or -1 if the stack couldn't be walked public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames = 0) { if (Native.LmfPtr == 0) @@ -123,9 +132,9 @@ public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames long lmfPtr = *(long*)Native.LmfPtr; - int depth = 0; // frames seen + int depth = 0; // frames seen, only counting frames which contribute to the hash int index = 0; // frames returned through long[] traceIn - while (true) + for (int step = 0; step < MaxWalkSteps; step++) { var ret = *(long*)(stck + 8); ref var info = ref hashTable.GetOrCreateAddrInfo(ret); @@ -135,38 +144,55 @@ public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames if (stackUsage == NotJit) { // LMF (Last Managed Frame) layout on x64: - // previous + // previous (the lowest 3 bits are flags) // rbp // rsp - lmfPtr = *(long*)lmfPtr; - var lmfRbp = *(long*)(lmfPtr + 8); + // The first step reads the innermost LMF from lmf_addr, later ones follow previous_lmf + lmfPtr = *(long*)lmfPtr & ~7L; + if (lmfPtr == 0) + break; - if (lmfPtr == 0 || lmfRbp == 0) + // MonoLMFExt (bit 2) and MonoLMFTramp (bit 4) don't store a usable rbp/rsp + if ((*(long*)lmfPtr & 6) != 0) + break; + + var lmfRbp = *(long*)(lmfPtr + 8); + var lmfRsp = *(long*)(lmfPtr + 16); + if (lmfRbp == 0 || lmfRsp == 0) break; rbp = lmfRbp; - stck = *(long*)(lmfPtr + 16) - 16; + stck = lmfRsp - 16; continue; } - if (depth >= skipFrames) - { - traceIn[index] = ret; + if (stackUsage == UnknownFrame) + return Fail(ret, null); - // info.nameHash == 0 marks methods to skip - if (index < HashInfluence && info.nameHash != 0) hash = HashCombineInt(hash, (int)info.nameHash); + // info.nameHash == 0 marks methods to skip. They might get inlined depending on the platform so they + // don't count towards the hash, the depth or the recorded frames at all. + if (info.nameHash != 0) + { + if (depth >= skipFrames) + { + traceIn[index] = ret; + if (index < HashInfluence) hash = HashCombineInt(hash, (int)info.nameHash); + index++; + } - index++; + // traceIn length limits above all how many frames are visited, not how many are populated. + if (++depth == traceIn.Length) + break; } - // traceIn length limits above all how many frames are visited, not how many are populated. - if (info.nameHash != 0 && ++depth == traceIn.Length) - break; - if (stackUsage == RbpBased) { + // The stack grows down so the caller's frame has to be above the current one + if (rbp <= stck) + return Fail(ret, "Bad rbp"); + stck = rbp; rbp = *(long*)rbp; continue; @@ -190,18 +216,35 @@ public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames return index; } + private static int Fail(long addr, string? reason) + { + lastFailureAddr = addr; + if (reason != null) lastFailureReason = reason; + return -1; + } private static void UpdateNewElement(ref AddrInfo info, long ret) { info.addr = ret; - info.stackUsage = GetStackUsage(ret); - var normalizedMethodNameBetweenOS = Native.MethodNameNormalizedFromAddr(ret, true); + try + { + info.stackUsage = GetStackUsage(ret); + + var normalizedMethodNameBetweenOS = Native.MethodNameNormalizedFromAddr(ret, true); - info.nameHash = - normalizedMethodNameBetweenOS == null ? 1 : - Native.GetMethodAggressiveInlining(ret) ? 0 : - StableStringHash(normalizedMethodNameBetweenOS); + info.nameHash = + normalizedMethodNameBetweenOS == null ? 1 : + Native.GetMethodAggressiveInlining(ret) ? 0 : + StableStringHash(normalizedMethodNameBetweenOS); + } + catch (Exception e) + { + // Cache the failure: following a frame of unknown size would read garbage + info.stackUsage = UnknownFrame; + info.nameHash = 1; + lastFailureReason = e.Message; + } } private static unsafe long GetStackUsage(long addr) From 059510ebfced0c32355ca9fd64314e9a73e7f8c6 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:43:35 -0600 Subject: [PATCH 007/183] Keep trace hash depth independent of the tick loop call site The desync trace hash includes the stack depth. Commands run either from RunCmds in TickPatch.Prefix or one frame deeper from the DoUpdate loop, depending on local frame timing, so identical clients could report "Trace hashes don't match" (#994). Route the Prefix call through a NoInlining wrapper so both paths have the same depth. The dev tick-once hotkey called DoUpdate from OnGUI on a single client; it is meant for replays only, so restrict it to them. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/TimeControlUI.cs | 5 +++-- Source/Client/Patches/TickPatch.cs | 9 ++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Source/Client/AsyncTime/TimeControlUI.cs b/Source/Client/AsyncTime/TimeControlUI.cs index 79444d14f..ca0a1a8b3 100644 --- a/Source/Client/AsyncTime/TimeControlUI.cs +++ b/Source/Client/AsyncTime/TimeControlUI.cs @@ -161,8 +161,9 @@ private static void DoTimeControlsHotkeys() if (KeyBindingDefOf.TimeSpeed_Ultrafast.KeyDownEvent) SendTimeVote(TimeVote.Ultrafast); - // Only for replaying - if (KeyBindingDefOf.Dev_TickOnce.KeyDownEvent && CurTimeSpeedGame == TimeVote.Paused) + // Only for replaying: in a live game this would run ticks and commands from OnGUI with a different call + // stack than other clients + if (Multiplayer.IsReplay && KeyBindingDefOf.Dev_TickOnce.KeyDownEvent && CurTimeSpeedGame == TimeVote.Paused) { DoSingleTick(); SoundDefOf.Clock_Stop.PlayOneShotOnCamera(); diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index a8068091b..4ac5bed2c 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.CompilerServices; using HarmonyLib; using LudeonTK; using Multiplayer.Client.AsyncTime; @@ -110,7 +111,7 @@ static bool Prefix() if (MpVersion.IsDebug) SimpleProfiler.Start(); - RunCmds(); + RunCmdsBeforeTicks(); if (LongEventHandler.eventQueue.Count == 0) { DoUpdate(out var worked); @@ -165,6 +166,12 @@ static void Postfix() Shader.SetGlobalFloat(ShaderPropertyIDs.GameSeconds, Find.CurrentMap.AsyncTime().mapTicks.TicksToSeconds()); } + // Commands run either here at the start of the frame or in the DoUpdate loop. The desync trace hash includes + // the stack depth, so both paths have to be equally deep: Prefix -> RunCmdsBeforeTicks -> RunCmds like + // Prefix -> DoUpdate -> RunCmds (#994). + [MethodImpl(MethodImplOptions.NoInlining)] + private static void RunCmdsBeforeTicks() => RunCmds(); + private static bool RunCmds() { int curTimer = Timer; From fa7f328aca855f95286c5acbbec449eedb10957c Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:43:43 -0600 Subject: [PATCH 008/183] Use deterministic job-loop counter for pawns without a map Pawn_JobTracker.StartJob compares RealTime.frameCount to detect job loops. The patch replaced it with the map's event counter, but for pawns that aren't spawned (carried, in containers, in caravans) Map is null and it fell back to the frame count, which differs between clients. Use MapHeld and fall back to the tick timer. Also log an error if the transpiler finds nothing to patch. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Patches.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Source/Client/Patches/Patches.cs b/Source/Client/Patches/Patches.cs index 344833379..4917ba197 100644 --- a/Source/Client/Patches/Patches.cs +++ b/Source/Client/Patches/Patches.cs @@ -33,21 +33,30 @@ static class JobTrackerStartFixFrames // and handling this case properly might prevent some crashes and help with debugging static IEnumerable Transpiler(IEnumerable insts) { + int patched = 0; + foreach (var inst in insts) { yield return inst; - if (inst.operand as FieldInfo == FrameCountField) + if (inst.opcode == OpCodes.Ldsfld && inst.operand as FieldInfo == FrameCountField) { yield return new CodeInstruction(OpCodes.Ldarg_0); yield return new CodeInstruction(OpCodes.Call, FrameCountReplacementMethod); + patched++; } } + + if (patched == 0) + Log.Error("MP: Couldn't find RealTime.frameCount in Pawn_JobTracker.StartJob. Was the original method changed?"); } static int FrameCountReplacement(int frameCount, Pawn_JobTracker tracker) { - return tracker.pawn.Map.AsyncTime()?.eventCount ?? frameCount; + if (Multiplayer.Client == null) return frameCount; + + // The frame count differs between clients. Pawns without a map (caravans, world pawns) use the timer. + return tracker.pawn.MapHeld?.AsyncTime()?.eventCount ?? TickPatch.Timer; } } From 873f837d1e112741422b5870744a2a70db2fffc0 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:44:26 -0600 Subject: [PATCH 009/183] Tick the UI tile temperature cache from the interface The tick patch only ticked uiTemperatures when the real comp's WorldComponentTick ran in the interface, which never happens (it's only called from the world tick). UI temperature entries were therefore never refreshed or evicted. Tick the UI cache at most once per frame when the interface reads from it. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/TileTemperatures.cs | 25 +++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/Source/Client/Patches/TileTemperatures.cs b/Source/Client/Patches/TileTemperatures.cs index 02b8e3e30..8214d52bc 100644 --- a/Source/Client/Patches/TileTemperatures.cs +++ b/Source/Client/Patches/TileTemperatures.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using UnityEngine; using Verse; namespace Multiplayer.Client @@ -28,11 +29,23 @@ static void Prefix(PlanetTile ___tile, ref TimeSnapshot? __state) [HarmonyPatch(typeof(TileTemperaturesComp), nameof(TileTemperaturesComp.RetrieveCachedData))] static class RetrieveCachedData_Patch { + private static int uiTickedAtFrame = -1; + static bool Prefix(TileTemperaturesComp __instance, PlanetTile tile, ref TileTemperaturesComp.CachedTileTemperatureData __result) { if (Multiplayer.InInterface && __instance != Multiplayer.WorldComp.uiTemperatures) { - __result = Multiplayer.WorldComp.uiTemperatures.RetrieveCachedData(tile); + var uiTemperatures = Multiplayer.WorldComp.uiTemperatures; + + // The UI cache isn't a real world component, so vanilla never ticks it and its entries would never + // refresh or get evicted. Tick it (at most once per frame) while the interface uses it. + if (uiTickedAtFrame != Time.frameCount) + { + uiTickedAtFrame = Time.frameCount; + uiTemperatures.WorldComponentTick(); + } + + __result = uiTemperatures.RetrieveCachedData(tile); return false; } @@ -40,16 +53,6 @@ static bool Prefix(TileTemperaturesComp __instance, PlanetTile tile, ref TileTem } } - [HarmonyPatch(typeof(TileTemperaturesComp), nameof(TileTemperaturesComp.WorldComponentTick))] - static class TileTemperaturesTick_Patch - { - static void Prefix(TileTemperaturesComp __instance) - { - if (Multiplayer.InInterface && __instance != Multiplayer.WorldComp.uiTemperatures) - Multiplayer.WorldComp.uiTemperatures.WorldComponentTick(); - } - } - [HarmonyPatch(typeof(GenTemperature), nameof(GenTemperature.AverageTemperatureAtTileForTwelfth))] static class CacheAverageTileTemperature { From 7c88a31594cafc790236abb6b8bf966c77dc2c7c Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:44:32 -0600 Subject: [PATCH 010/183] Fix init data requests that hang every later join - SetDisconnected marks the state dead before OnDisconnect. OnDisconnect synchronously resumes a pending init data request; the joining loop then saw InitDataState.Waiting on a still "alive" state and requested the init data again from the dying connection. That orphaned request kept the server in InitDataState.Requested, so every later joiner waited forever (standalone first joiner leaving mid-handshake). - RequestInitData always completes the source, also when ServerInitData.FromNet throws (e.g. duplicate def names). The player is then disconnected and the next joiner gets asked. - Waits in AsyncConnectionState take an optional timeout; the server loop disconnects players whose awaited packet doesn't arrive in time (PlayerManager.CheckTimeouts, once per second). ServerJoiningState uses 2 minutes for automatic replies (protocol, init data, join data) and 15 minutes for packets behind user input (password, mismatch window). Idle handshakes no longer hold a player slot forever. - Restore a real limit for ClientInitDataPacket.rawMods: #913 moved it back to the default 32 KiB of BindRemaining. Raise the compressed mod list limit to 4 MiB and use the same limit for the forwarded copy in ServerJoinDataPacket (was 512 KiB, too small for big synced configs). - Stop the joining flow right after a protocol/username disconnect. Co-Authored-By: Claude Opus 5.5 --- Source/Common/MultiplayerServer.cs | 3 + .../Common/Networking/AsyncConnectionState.cs | 45 +++-- .../Networking/Packet/InitDataPacket.cs | 8 +- .../Networking/Packet/JoinDataPacket.cs | 5 +- .../Networking/State/ServerJoiningState.cs | 31 ++-- Source/Common/PlayerManager.cs | 23 ++- Source/Tests/JoiningStateTest.cs | 157 ++++++++++++++++++ 7 files changed, 241 insertions(+), 31 deletions(-) create mode 100644 Source/Tests/JoiningStateTest.cs diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 4f508de1c..f83c04cbe 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -183,7 +183,10 @@ private void TickNet() NetTimer++; if (NetTimer % NetTicksPerSecond == 0) + { playerManager.SendLatencies(); + playerManager.CheckTimeouts(); + } if (NetTimer % (NetTicksPerSecond / 5) == 0) diff --git a/Source/Common/Networking/AsyncConnectionState.cs b/Source/Common/Networking/AsyncConnectionState.cs index 27ae847c3..6ece793e9 100644 --- a/Source/Common/Networking/AsyncConnectionState.cs +++ b/Source/Common/Networking/AsyncConnectionState.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using System.Threading.Tasks; @@ -10,10 +11,20 @@ namespace Multiplayer.Common; public abstract class AsyncConnectionState(ConnectionBase connection) : MpConnectionState(connection) { + private static readonly Stopwatch Clock = Stopwatch.StartNew(); + private PacketAwaitable? packetAwaitable; + private long waitDeadlineMs = long.MaxValue; public Task? CurrentTask { get; private set; } + /// The packet this state is waiting for, if any. + public Packets? AwaitedPacket => packetAwaitable?.PacketType; + + /// True when the awaited packet didn't arrive within the timeout given when starting to wait. Checked by the + /// server loop, which disconnects the player. + public bool WaitTimedOut => packetAwaitable != null && Clock.ElapsedMilliseconds > waitDeadlineMs; + public override void StartState() { async Task RunStateCatch() @@ -49,15 +60,16 @@ public override void OnDisconnect() /// An exception is thrown if this is called again before the packet arrives. The player is disconnected if a /// different packet type arrives. /// - protected PacketAwaitable Packet(Packets packet) + protected PacketAwaitable Packet(Packets packet, TimeSpan? timeout = null) { if (packetAwaitable != null) throw new Exception($"Already waiting for another packet: {packetAwaitable}"); ServerLog.Verbose($"{connection} waiting for {packet}"); - packetAwaitable = new PacketAwaitable(packet, false); - return packetAwaitable!; + var awaitable = new PacketAwaitable(packet, false); + StartWaiting(awaitable, timeout); + return awaitable!; } /// @@ -65,10 +77,10 @@ protected PacketAwaitable Packet(Packets packet) /// An exception is thrown if this is called again before the packet arrives. The player is disconnected if a /// different packet type arrives. /// - protected PacketAwaitable TypedPacket() where T: struct, IPacket + protected PacketAwaitable TypedPacket(TimeSpan? timeout = null) where T: struct, IPacket { var typed = new PacketAwaitable(PacketTypeInfo.Id, announcePacketFailure: false); - WaitForTypedPacket(announcePacketFailure: false, packet => + WaitForTypedPacket(announcePacketFailure: false, timeout, packet => { if (packet.HasValue) typed.SetResult(packet.Value); @@ -85,15 +97,16 @@ protected PacketAwaitable TypedPacket() where T: struct, IPacket /// different packet type arrives. /// The result is null if the player is disconnected while waiting. /// - protected PacketAwaitable PacketOrNull(Packets packet) + protected PacketAwaitable PacketOrNull(Packets packet, TimeSpan? timeout = null) { if (packetAwaitable != null) throw new Exception($"Already waiting for another packet: {packetAwaitable}"); ServerLog.Verbose($"{connection} waiting for {packet}"); - packetAwaitable = new PacketAwaitable(packet, true); - return packetAwaitable; + var awaitable = new PacketAwaitable(packet, true); + StartWaiting(awaitable, timeout); + return awaitable; } @@ -102,15 +115,15 @@ protected PacketAwaitable TypedPacket() where T: struct, IPacket /// An exception is thrown if this is called again before the packet arrives. The player is disconnected if a /// different packet type arrives. /// - protected PacketAwaitable TypedPacketOrNull() where T: struct, IPacket + protected PacketAwaitable TypedPacketOrNull(TimeSpan? timeout = null) where T: struct, IPacket { var typed = new PacketAwaitable(PacketTypeInfo.Id, announcePacketFailure: true); - WaitForTypedPacket(announcePacketFailure: true, typed.SetResult, typed.SetException); + WaitForTypedPacket(announcePacketFailure: true, timeout, typed.SetResult, typed.SetException); return typed; } - private void WaitForTypedPacket(bool announcePacketFailure, Action onResult, Action onError) - where T : struct, IPacket + private void WaitForTypedPacket(bool announcePacketFailure, TimeSpan? timeout, Action onResult, + Action onError) where T : struct, IPacket { if (packetAwaitable != null) throw new Exception($"Already waiting for another packet: {packetAwaitable}"); @@ -146,7 +159,13 @@ private void WaitForTypedPacket(bool announcePacketFailure, Action onResu onResult(packet); }); - packetAwaitable = raw; + StartWaiting(raw, timeout); + } + + private void StartWaiting(PacketAwaitable awaitable, TimeSpan? timeout) + { + packetAwaitable = awaitable; + waitDeadlineMs = timeout is { } t ? Clock.ElapsedMilliseconds + (long)t.TotalMilliseconds : long.MaxValue; } public override PacketHandlerInfo? GetPacketHandler(Packets packet) diff --git a/Source/Common/Networking/Packet/InitDataPacket.cs b/Source/Common/Networking/Packet/InitDataPacket.cs index ecec5699c..cf655b094 100644 --- a/Source/Common/Networking/Packet/InitDataPacket.cs +++ b/Source/Common/Networking/Packet/InitDataPacket.cs @@ -16,8 +16,10 @@ public void Bind(PacketBuffer buf) [PacketDefinition(Packets.Client_InitData, allowFragmented: true)] public record struct ClientInitDataPacket : IPacket { - // 1 MiB limit — large mod lists can exceed the default 32 KiB BindRemaining limit - private const int MaxRawDataLength = 1 << 20; + // Max size of the compressed mod list. Large mod lists with synced configs can exceed 1 MiB. + public const int MaxRawDataLength = 1 << 22; + // rawMods additionally holds the length prefix of the compressed data + public const int MaxRawModsLength = MaxRawDataLength + sizeof(int); public string rwVersion; public int[] debugOnlySyncCmds; @@ -43,7 +45,7 @@ public void Bind(PacketBuffer buf) buf.BindEnum(ref staticCtorRoundMode); buf.Bind(ref defInfos, BinderOf.Identity()); buf.Bind(ref includeConfigs); - buf.BindRemaining(ref rawMods); + buf.BindRemaining(ref rawMods, maxLength: MaxRawModsLength); } // Based on ContentSource but a byte, so smaller on the network and also doesn't use Verse diff --git a/Source/Common/Networking/Packet/JoinDataPacket.cs b/Source/Common/Networking/Packet/JoinDataPacket.cs index 507b59f37..7d1d9782b 100644 --- a/Source/Common/Networking/Packet/JoinDataPacket.cs +++ b/Source/Common/Networking/Packet/JoinDataPacket.cs @@ -27,8 +27,9 @@ public void Bind(PacketBuffer buf) 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); + // The server forwards the first joiner's (or host's) ClientInitDataPacket.rawMods, so the limits must match. + // As an example, one game with ~100 mods used ~35KiB, synced configs can add a lot more. + buf.BindRemaining(ref rawServerInitData, maxLength: ClientInitDataPacket.MaxRawModsLength); } } diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index b2d2a392f..cf35a8965 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -8,14 +8,21 @@ namespace Multiplayer.Common; public class ServerJoiningState : AsyncConnectionState { + // Packets the client sends on its own, without user interaction + public static TimeSpan ReplyTimeout { get; set; } = TimeSpan.FromMinutes(2); + // Packets sent after user input: the password prompt and the mod mismatch window + public static TimeSpan UserInputTimeout { get; set; } = TimeSpan.FromMinutes(15); + public ServerJoiningState(ConnectionBase connection) : base(connection) { } protected override async Task RunState() { - HandleProtocol(await TypedPacket()); - HandleUsername(await TypedPacket()); + HandleProtocol(await TypedPacket(ReplyTimeout)); + await EndIfDead(); + HandleUsername(await TypedPacket(UserInputTimeout)); + await EndIfDead(); while (await Server.InitDataTask() is null && await EndIfDead()) if (Server.InitDataState == InitDataState.Waiting) @@ -23,7 +30,7 @@ protected override async Task RunState() connection.Send(Packets.Server_UsernameOk); - if (!HandleClientJoinData(await TypedPacket())) + if (!HandleClientJoinData(await TypedPacket(ReplyTimeout))) return; if (Server.settings.pauseOnJoin) @@ -35,7 +42,7 @@ protected override async Task RunState() Server.playerManager.OnJoin(Player); Server.playerManager.SendInitDataCommand(Player); - await Packet(Packets.Client_WorldRequest); + await Packet(Packets.Client_WorldRequest, UserInputTimeout); connection.ChangeState(ConnectionStateEnum.ServerLoading); } @@ -98,23 +105,23 @@ private void HandleUsername(ClientUsernamePacket packet) private async Task RequestInitData() { - ClientInitDataPacket? initData = null; + ServerInitData? result = null; var completionSource = Server.StartInitData(); try { Player.SendPacket(new ServerInitDataRequestPacket(Server.settings.syncConfigs)); ServerLog.Verbose("Sent initial data request"); - initData = await TypedPacketOrNull(); + // Null when the player disconnects (or times out) while we wait + if (await TypedPacketOrNull(ReplyTimeout) is { } initData) + result = ServerInitData.FromNet(initData); } finally { - // Invoking StartInitData and abandoning the completion source in case of an exception would mean a server - // restart is necessary to try and set init data again. Make sure the server is more graceful in that case. - completionSource.SetResult(initData != null - // cast to non-nullable, for whatever reason C# isn't able to infer that from the != null check - ? ServerInitData.FromNet((ClientInitDataPacket)initData) - : null); + // Always complete the source, also when FromNet throws: an abandoned source would block every later + // joiner until a server restart. Null puts the server back into InitDataState.Waiting so that the next + // joiner gets asked. + completionSource.SetResult(result); } } diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index ce6d67361..5f08f7780 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -67,7 +67,13 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) if (conn.State == ConnectionStateEnum.Disconnected) return; server.CheckServerThread(); - conn.StateObj?.OnDisconnect(); + if (conn.StateObj is { } stateObj) + { + // OnDisconnect can synchronously resume the state's async flow (e.g. a pending init data request). + // Mark it dead first so that it stops instead of continuing on the dying connection. + stateObj.alive = false; + stateObj.OnDisconnect(); + } ServerPlayer player = conn.serverPlayer; Players.Remove(player); @@ -107,6 +113,21 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) ServerLog.Log($"Disconnected ({reason}): {conn}"); } + public void CheckTimeouts() + { + // Disconnecting modifies Players + foreach (var player in Players.ToArray()) + { + if (player.conn.State == ConnectionStateEnum.Disconnected) continue; + + if (player.conn.StateObj is AsyncConnectionState { WaitTimedOut: true } state) + { + ServerLog.Log($"{player.conn} timed out waiting for {state.AwaitedPacket} in {player.conn.State}"); + player.Disconnect("Timed out while joining the server"); + } + } + } + public void OnDesync(ServerPlayer player, int tick, int diffAt) { player.UpdateStatus(PlayerStatus.Desynced); diff --git a/Source/Tests/JoiningStateTest.cs b/Source/Tests/JoiningStateTest.cs new file mode 100644 index 000000000..efbeae3c2 --- /dev/null +++ b/Source/Tests/JoiningStateTest.cs @@ -0,0 +1,157 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[NonParallelizable] +public class JoiningStateTest +{ + private MultiplayerServer server = null!; + private Action? prevErrorLog; + private readonly List errors = new(); + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false, + autoJoinPoint = 0 + }); + server.worldData.savedGame = []; + + prevErrorLog = ServerLog.error; + errors.Clear(); + ServerLog.error = errors.Add; + } + + [TearDown] + public void TearDown() + { + ServerLog.error = prevErrorLog; + ServerJoiningState.ReplyTimeout = TimeSpan.FromMinutes(2); + MultiplayerServer.instance = null; + } + + private (ServerPlayer player, RecordingConnection conn) Connect() + { + // Username gets set by the joining state + var conn = new RecordingConnection(null!); + conn.ChangeState(ConnectionStateEnum.ServerJoining); + var player = server.playerManager.OnConnected(conn); + return (player, conn); + } + + private static void Receive(ConnectionBase conn, T packet) where T : struct, IPacket + { + var serialized = packet.Serialize(); + var raw = new byte[serialized.data.Length + 1]; + raw[0] = (byte)serialized.id; + serialized.data.CopyTo(raw, 1); + conn.HandleReceiveRaw(new ByteReader(raw), true); + } + + private static void SendHandshake(ConnectionBase conn, string username) + { + Receive(conn, ClientProtocolPacket.Current()); + Receive(conn, new ClientUsernamePacket(username)); + } + + private static ClientInitDataPacket InitData(params KeyedDefInfo[] defInfos) => new() + { + rwVersion = "1.0.0", + debugOnlySyncCmds = [], + hostOnlySyncCmds = [], + modCtorRoundMode = RoundModeEnum.ToNearest, + staticCtorRoundMode = RoundModeEnum.ToNearest, + defInfos = defInfos, + Mods = [] + }; + + [Test] + public void InitDataRequesterDisconnecting_NextJoinerIsAsked() + { + var (_, first) = Connect(); + SendHandshake(first, "first"); + + Assert.That(first.SentPackets, Does.Contain(Packets.Server_InitDataRequest)); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Requested)); + + server.playerManager.SetDisconnected(first, MpDisconnectReason.ClientLeft); + + // The dying connection must not request the init data again + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Waiting)); + Assert.That(first.SentPackets.Count(p => p == Packets.Server_InitDataRequest), Is.EqualTo(1)); + + var (_, second) = Connect(); + SendHandshake(second, "second"); + Assert.That(second.SentPackets, Does.Contain(Packets.Server_InitDataRequest)); + + Receive(second, InitData()); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Complete)); + Assert.That(second.SentPackets, Does.Contain(Packets.Server_UsernameOk)); + } + + [Test] + public void WaitingJoinerTakesOverInitDataRequest() + { + var (_, first) = Connect(); + SendHandshake(first, "first"); + var (_, second) = Connect(); + SendHandshake(second, "second"); + + Assert.That(second.SentPackets, Does.Not.Contain(Packets.Server_InitDataRequest)); + + server.playerManager.SetDisconnected(first, MpDisconnectReason.ClientLeft); + + Assert.That(second.SentPackets, Does.Contain(Packets.Server_InitDataRequest)); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Requested)); + } + + [Test] + public void InvalidInitData_DisconnectsAndResetsInitDataState() + { + var (_, first) = Connect(); + SendHandshake(first, "first"); + + // Duplicate def type names make ServerInitData.FromNet throw + Receive(first, InitData( + new KeyedDefInfo { name = "ThingDef", count = 1, hash = 1 }, + new KeyedDefInfo { name = "ThingDef", count = 2, hash = 2 })); + + Assert.That(first.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Waiting)); + Assert.That(errors, Is.Not.Empty); + } + + [Test] + public void InitDataRequestTimesOut() + { + var (_, first) = Connect(); + ServerJoiningState.ReplyTimeout = TimeSpan.Zero; + SendHandshake(first, "first"); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Requested)); + + Thread.Sleep(10); + server.playerManager.CheckTimeouts(); + + Assert.That(first.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(server.playerManager.Players, Is.Empty); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Waiting)); + } + + [Test] + public void IdleHandshakeTimesOut() + { + ServerJoiningState.ReplyTimeout = TimeSpan.Zero; + var (_, conn) = Connect(); + + Thread.Sleep(10); + server.playerManager.CheckTimeouts(); + + Assert.That(conn.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(server.playerManager.Players, Is.Empty); + } +} From a59e1c806c7619fa39d153f593932d7e4d21eee0 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:45:22 -0600 Subject: [PATCH 011/183] Restore watched sync fields even if the watched code throws The field watch postfix didn't run when the patched UI method threw, leaving the user's change (or the pending buffered value) in the simulation of that client only, and the watch stack unbalanced. - Patch watched methods with a finalizer instead of a postfix. - Float menu/dropdown watch wrappers use try/finally instead of a multicast delegate. - Always pop the whole watch scope, even if handling one entry throws. - Flush leaked scopes (e.g. API WatchBegin without WatchEnd) once per frame. - Skip nested watches of a field already watched by an enclosing scope (Bill_Production.paused is watched in both Bill.DoInterface and ShouldDoNow, which reverted the buffered value mid-scope). - Restore a null actual value instead of falling back to the UI value. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/Game/SyncFields.cs | 4 +- Source/Client/Syncing/Handler/SyncField.cs | 4 + Source/Client/Syncing/SyncFieldUtil.cs | 161 +++++++++++++++------ 3 files changed, 121 insertions(+), 48 deletions(-) diff --git a/Source/Client/Syncing/Game/SyncFields.cs b/Source/Client/Syncing/Game/SyncFields.cs index d39185851..788f936e5 100644 --- a/Source/Client/Syncing/Game/SyncFields.cs +++ b/Source/Client/Syncing/Game/SyncFields.cs @@ -536,7 +536,7 @@ static IEnumerable> WatchDropdowns(Action watchAction, foreach (var entry in dropdowns) { if (entry.option.action != null) - entry.option.action = SyncFieldUtil.FieldWatchPrefix + watchAction + entry.option.action + SyncFieldUtil.FieldWatchPostfix; + entry.option.action = SyncFieldUtil.WatchScope(watchAction, entry.option.action); yield return entry; } } @@ -547,7 +547,7 @@ static void WatchMenuOptions(Action watchAction, IEnumerable op { if (entry.action != null) { - entry.action = SyncFieldUtil.FieldWatchPrefix + watchAction + entry.action + SyncFieldUtil.FieldWatchPostfix; + entry.action = SyncFieldUtil.WatchScope(watchAction, entry.action); } } } diff --git a/Source/Client/Syncing/Handler/SyncField.cs b/Source/Client/Syncing/Handler/SyncField.cs index 0c58a8ae4..6f5306345 100644 --- a/Source/Client/Syncing/Handler/SyncField.cs +++ b/Source/Client/Syncing/Handler/SyncField.cs @@ -108,6 +108,10 @@ public void Watch(object target = null, object index = null) if (!(inGameLoop || Multiplayer.ShouldSync)) return; + // The enclosing watch scope takes care of it (e.g. a watched method called from another watched method) + if (SyncFieldUtil.IsWatched(this, target, index)) + return; + object value; if (bufferChanges && SyncFieldUtil.bufferedChanges[this].TryGetValue(new(target, index), out BufferData cached)) diff --git a/Source/Client/Syncing/SyncFieldUtil.cs b/Source/Client/Syncing/SyncFieldUtil.cs index 5a42405dd..d5541d101 100644 --- a/Source/Client/Syncing/SyncFieldUtil.cs +++ b/Source/Client/Syncing/SyncFieldUtil.cs @@ -17,71 +17,140 @@ public static void StackPush(SyncField field, object target, object value, objec watchedStack.Push(new FieldData(field, target, value, index)); } + // True if an enclosing watch scope already watches this field of this target. That scope restores and syncs it, + // watching it again in a nested scope would revert the pending (buffered) value in the middle of the outer scope. + internal static bool IsWatched(SyncField field, object target, object index) + { + foreach (var entry in watchedStack) + if (entry is { } data && data.handler == field && Equals(data.target, target) && Equals(data.index, index)) + return true; + + return false; + } + public static void FieldWatchPrefix() { if (Multiplayer.Client == null) return; watchedStack.Push(null); // Marker } - // todo what happens on exceptions? public static void FieldWatchPostfix() { if (Multiplayer.Client == null) return; + PopWatchScope(); + } + + // Harmony patches use a finalizer, so the simulation values get restored even if the watched method throws + static void FieldWatchPatchPrefix(out bool __state) + { + __state = Multiplayer.Client != null; + if (__state) + watchedStack.Push(null); // Marker + } + + static void FieldWatchPatchFinalizer(bool __state) + { + if (__state) + PopWatchScope(); + } + + /// Wraps the action in a watch scope which is closed even if the action throws. + internal static Action WatchScope(Action watchAction, Action action) => () => + { + bool pushed = Multiplayer.Client != null; + if (pushed) + watchedStack.Push(null); // Marker + + try + { + watchAction(); + action(); + } + finally + { + if (pushed) + PopWatchScope(); + } + }; - // Iterate until we hit the marker (`null`). + private static void PopWatchScope() + { + // Iterate until we hit the marker (`null`). Always pop the whole scope, even if handling an entry fails. while (watchedStack.TryPop(out var dataOrNull) && dataOrNull is { } data) { - SyncField handler = data.handler; - object newValue = MpReflection.GetValue(data.target, handler.memberPath, data.index); - bool changed = !ValuesEqual(handler, newValue, data.oldValue); - var cache = - handler.bufferChanges && !Multiplayer.IsReplay && !Multiplayer.GhostMode - ? bufferedChanges.GetValueSafe(handler) - : null; - var bufferTarget = new BufferTarget(data.target, data.index); - var cachedData = cache?.GetValueSafe(bufferTarget); - - // Revert the local field's value for simulation purposes. - // For unbuffered fields, this rollback is only necessary if the value actually changed. - // For buffered fields, however, we always perform the restore, since the value is overwritten - // whenever watching it begins — assuming the value was previously changed and thus the buffer was - // initialized. - // If any change has happened, we record it and either send it immediately (unbuffered field) or queue - // it (buffered field). The server will eventually acknowledge the change and send it back, at which - // point the field is updated. - if (changed || cachedData != null) + try { - var simulationValue = cachedData?.actualValue ?? data.oldValue; - MpReflection.SetValue(data.target, handler.memberPath, simulationValue, data.index); + HandleWatchedField(data); } - - // No changes happened means no syncing needed either - we are done. - if (!changed) - continue; - - // For unbuffered fields, just immediately sync any changes. - if (cache == null) + catch (Exception e) { - handler.DoSyncCatch(data.target, newValue, data.index); - continue; + Log.Error($"Exception handling watched {data.handler}: {e}"); } + } + } - // For buffered fields, update the value to be sent. - if (cachedData != null) - { - cachedData.sent = false; - cachedData.lastChangedAtMillis = Utils.MillisNow; - cachedData.toSend = SnapshotValueIfNeeded(handler, newValue); - continue; - } + private static void HandleWatchedField(FieldData data) + { + SyncField handler = data.handler; + object newValue = MpReflection.GetValue(data.target, handler.memberPath, data.index); + bool changed = !ValuesEqual(handler, newValue, data.oldValue); + var cache = + handler.bufferChanges && !Multiplayer.IsReplay && !Multiplayer.GhostMode + ? bufferedChanges.GetValueSafe(handler) + : null; + var bufferTarget = new BufferTarget(data.target, data.index); + var cachedData = cache?.GetValueSafe(bufferTarget); + + // Revert the local field's value for simulation purposes. + // For unbuffered fields, this rollback is only necessary if the value actually changed. + // For buffered fields, however, we always perform the restore, since the value is overwritten + // whenever watching it begins — assuming the value was previously changed and thus the buffer was + // initialized. + // If any change has happened, we record it and either send it immediately (unbuffered field) or queue + // it (buffered field). The server will eventually acknowledge the change and send it back, at which + // point the field is updated. + if (changed || cachedData != null) + { + // The actual value can be null, so don't fall back to oldValue (the pending UI value) based on it + var simulationValue = cachedData != null ? cachedData.actualValue : data.oldValue; + MpReflection.SetValue(data.target, handler.memberPath, simulationValue, data.index); + } - // The field is buffered but had no prior changes; initialize the cache entry now that a change has occurred. - cache[bufferTarget] = new BufferData(handler, data.oldValue, SnapshotValueIfNeeded(handler, newValue)); + // No changes happened means no syncing needed either - we are done. + if (!changed) + return; + + // For unbuffered fields, just immediately sync any changes. + if (cache == null) + { + handler.DoSyncCatch(data.target, newValue, data.index); + return; + } + + // For buffered fields, update the value to be sent. + if (cachedData != null) + { + cachedData.sent = false; + cachedData.lastChangedAtMillis = Utils.MillisNow; + cachedData.toSend = SnapshotValueIfNeeded(handler, newValue); + return; } + + // The field is buffered but had no prior changes; initialize the cache entry now that a change has occurred. + cache[bufferTarget] = new BufferData(handler, data.oldValue, SnapshotValueIfNeeded(handler, newValue)); } public static void UpdateSync() { + // Watch scopes never span frames. Leftovers mean a scope wasn't closed (e.g. an exception between the API's + // WatchBegin and WatchEnd), restore and sync those fields now. + if (watchedStack.Count > 0) + { + Log.Warning($"Multiplayer: {watchedStack.Count} field watch stack entries weren't closed, restoring them"); + while (watchedStack.Count > 0) + PopWatchScope(); + } + foreach (var (field, fieldBufferedChanges) in bufferedChanges) { if (field.inGameLoop) continue; @@ -150,10 +219,10 @@ private static bool ValuesEqual(SyncField field, object newValue, object oldValu internal static void ApplyWatchFieldPatches(Type type) { - HarmonyMethod prefix = new HarmonyMethod(AccessTools.Method(typeof(SyncFieldUtil), nameof(FieldWatchPrefix))); + HarmonyMethod prefix = new HarmonyMethod(AccessTools.Method(typeof(SyncFieldUtil), nameof(FieldWatchPatchPrefix))); prefix.priority = MpPriority.MpFirst; - HarmonyMethod postfix = new HarmonyMethod(AccessTools.Method(typeof(SyncFieldUtil), nameof(FieldWatchPostfix))); - postfix.priority = MpPriority.MpLast; + HarmonyMethod finalizer = new HarmonyMethod(AccessTools.Method(typeof(SyncFieldUtil), nameof(FieldWatchPatchFinalizer))); + finalizer.priority = MpPriority.MpLast; foreach (var toPatch in type.GetDeclaredMethods()) { @@ -161,7 +230,7 @@ internal static void ApplyWatchFieldPatches(Type type) { try { - Multiplayer.harmony.PatchMeasure(attr.Method, prefix, postfix); + Multiplayer.harmony.PatchMeasure(attr.Method, prefix, finalizer: finalizer); } catch (Exception e) { Log.Error($"FAIL: {attr.Method.DeclaringType.FullName}:{attr.Method.Name} with {e}"); Multiplayer.loadingErrors = true; From 9a7c2788ac50af989f487952256b66e65750a3bf Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:45:55 -0600 Subject: [PATCH 012/183] Complete path jobs right after PathFinderTick The only protection against path jobs racing main thread changes (#900) is ForceCompleteScheduledJobs after MapPreTick; the snapshot patch in PathFinderPatch.cs is compiled out. Also complete the jobs in a PathFinderTick postfix so they can't overlap the rest of MapPreTick or calls from elsewhere, and mark the dead code as reference only. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/PathFinderPatch.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Source/Client/Patches/PathFinderPatch.cs b/Source/Client/Patches/PathFinderPatch.cs index 13639324d..11c95a7c0 100644 --- a/Source/Client/Patches/PathFinderPatch.cs +++ b/Source/Client/Patches/PathFinderPatch.cs @@ -6,6 +6,24 @@ using System.Reflection.Emit; using Verse; +namespace Multiplayer.Client.Patches +{ + // Path jobs scheduled by PathFinderTick run on worker threads and read live map state (e.g. pawn positions). + // AsyncTimeComp.Tick already completes them right after MapPreTick; also do it right after PathFinderTick so + // that they can never overlap main thread changes, whoever calls it (rest of MapPreTick, mods). + [HarmonyPatch(typeof(PathFinder), nameof(PathFinder.PathFinderTick))] + static class PathFinderTickCompleteJobs + { + static void Postfix(PathFinder __instance) + { + if (Multiplayer.Client != null) + __instance.ForceCompleteScheduledJobs(); + } + } +} + +// Disabled alternative to ForceCompleteScheduledJobs (see #900): snapshot the data worker threads read instead of +// waiting for them. Kept for reference only, it isn't compiled. #if false namespace Multiplayer.Client.Patches { From dd969f9f4b88ad287ae0ec04205a1e82d4042fae Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:46:22 -0600 Subject: [PATCH 013/183] Reject server-only commands and foreign faction changes from clients HandleClientCommand forwarded any CommandType with any map id. Clients only send Sync, Designator, DebugTools, GlobalTimeSpeed, MapTimeSpeed, TimeSpeedVote and PlayerCount; PauseAll, CreateJoinPoint and InitPlayerData are issued by the server alone. A modified client could e.g. send InitPlayerData to grant dev mode to any player, or spam pauses and join points. Unknown types and map ids outside the scope the client uses (Global for global commands, >= 0 for map commands, either for Sync/DebugTools) now fail as a bad packet, which disconnects the sender. Validating against the set of existing maps is not done: the server doesn't know maps created since the last join point. HandleSetFaction let anyone move anyone to any faction. Players may now only change their own faction; moving others (factions window drag and drop, which the UI only offers on the hosting client) is host-only. Co-Authored-By: Claude Opus 5.5 --- .../Networking/State/ServerPlayingState.cs | 28 +++- Source/Tests/ServerPlayingStateTest.cs | 140 ++++++++++++++++++ 2 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 Source/Tests/ServerPlayingStateTest.cs diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index f7b21e7a7..549d11dc0 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -33,6 +33,9 @@ public void HandleTraces(ClientTracesPacket packet) [TypedPacketHandler] public void HandleClientCommand(ClientCommandPacket packet) { + if (!IsValidClientCommand(packet.type, packet.mapId)) + throw new PacketReadException($"Invalid client command {packet.type} for map {packet.mapId}"); + int? mapToResync = null; if (packet.type == CommandType.PlayerCount) @@ -50,7 +53,8 @@ public void HandleClientCommand(ClientCommandPacket packet) mapToResync = newMapId; } - // todo check if map id is valid for the player + // todo check if map id is valid for the player (the server doesn't know maps created since the last + // join point) Server.commands.Send(packet.type, Player.FactionId, packet.mapId, packet.data, Player); @@ -58,6 +62,19 @@ public void HandleClientCommand(ClientCommandPacket packet) Server.SendMapResponse(Player, currentMapId); } + /// Only the command types and scopes the client sends. PauseAll, CreateJoinPoint and InitPlayerData are + /// issued by the server alone; accepting them from clients would e.g. let anyone grant dev mode to anyone. + public static bool IsValidClientCommand(CommandType type, int mapId) => type switch + { + CommandType.GlobalTimeSpeed or CommandType.TimeSpeedVote or CommandType.PlayerCount => + mapId == ScheduledCommand.Global, + CommandType.MapTimeSpeed or CommandType.Designator => + mapId >= 0, + CommandType.Sync or CommandType.DebugTools => + mapId == ScheduledCommand.Global || mapId >= 0, + _ => false + }; + public const int MaxChatMsgLength = 128; [TypedPacketHandler] @@ -246,11 +263,16 @@ public void HandleDebug(ClientDebugPacket _) [TypedPacketHandler] public void HandleSetFaction(ClientSetFactionPacket packet) { - // todo restrict handling - int playerId = packet.playerId; int factionId = packet.factionId; + // Players switch their own faction; moving other players (factions window drag and drop) is host-only + if (playerId != Player.id && !Player.IsHost) + { + ServerLog.Log($"Ignoring faction change of player {playerId} requested by {Player.Username}"); + return; + } + var player = Server.GetPlayer(playerId); if (player == null) return; if (player.FactionId == factionId) return; diff --git a/Source/Tests/ServerPlayingStateTest.cs b/Source/Tests/ServerPlayingStateTest.cs new file mode 100644 index 000000000..75b361a42 --- /dev/null +++ b/Source/Tests/ServerPlayingStateTest.cs @@ -0,0 +1,140 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[NonParallelizable] +public class ServerPlayingStateTest +{ + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + nextPlayerId = 0; + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private (ServerPlayer player, RecordingConnection conn) AddPlayer(string username) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn); + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return (player, conn); + } + + [TestCase(CommandType.PauseAll, ScheduledCommand.Global)] + [TestCase(CommandType.CreateJoinPoint, ScheduledCommand.Global)] + [TestCase(CommandType.InitPlayerData, ScheduledCommand.Global)] + [TestCase(CommandType.GlobalTimeSpeed, 0)] + [TestCase(CommandType.TimeSpeedVote, 3)] + [TestCase(CommandType.PlayerCount, 1)] + [TestCase(CommandType.Designator, ScheduledCommand.Global)] + [TestCase(CommandType.MapTimeSpeed, ScheduledCommand.Global)] + [TestCase(CommandType.Sync, -2)] + [TestCase(CommandType.DebugTools, int.MinValue)] + [TestCase((CommandType)200, ScheduledCommand.Global)] + public void InvalidClientCommandIsRejected(CommandType type, int mapId) + { + var (player, _) = AddPlayer("player"); + var state = player.conn.GetState()!; + + Assert.Throws(() => + state.HandleClientCommand(new ClientCommandPacket(type, mapId, ByteWriter.GetBytes(-1, 0)))); + Assert.That(server.commands.SentCmds, Is.Zero); + Assert.That(server.worldData.mapCmds, Is.Empty); + } + + [TestCase(CommandType.GlobalTimeSpeed, ScheduledCommand.Global)] + [TestCase(CommandType.TimeSpeedVote, ScheduledCommand.Global)] + [TestCase(CommandType.PlayerCount, ScheduledCommand.Global)] + [TestCase(CommandType.Designator, 0)] + [TestCase(CommandType.MapTimeSpeed, 5)] + [TestCase(CommandType.DebugTools, ScheduledCommand.Global)] + [TestCase(CommandType.DebugTools, 3)] + public void ValidClientCommandIsScheduled(CommandType type, int mapId) + { + var (player, conn) = AddPlayer("player"); + var state = player.conn.GetState()!; + server.settings.debugMode = true; + server.settings.devModeScope = DevModeScope.Everyone; + + state.HandleClientCommand(new ClientCommandPacket(type, mapId, ByteWriter.GetBytes(-1, 0))); + + Assert.That(server.commands.SentCmds, Is.EqualTo(1)); + Assert.That(conn.SentPackets, Does.Contain(Packets.Server_Command)); + } + + [Test] + public void ServerOnlyCommandDisconnectsSender() + { + var (player, conn) = AddPlayer("player"); + + var cmd = new ClientCommandPacket(CommandType.InitPlayerData, ScheduledCommand.Global, + ByteWriter.GetBytes(player.id, true)).Serialize(); + var raw = new byte[cmd.data.Length + 1]; + raw[0] = (byte)cmd.id; + cmd.data.CopyTo(raw, 1); + + var prevError = ServerLog.error; + ServerLog.error = _ => { }; + try + { + player.HandleReceive(new ByteReader(raw), true); + } + finally + { + ServerLog.error = prevError; + } + + Assert.That(conn.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(server.commands.SentCmds, Is.Zero); + } + + [Test] + public void PlayerCanOnlySetOwnFaction() + { + var (alice, aliceConn) = AddPlayer("alice"); + var (bob, _) = AddPlayer("bob"); + alice.FactionId = 1; + bob.FactionId = 1; + + var state = alice.conn.GetState()!; + state.HandleSetFaction(new ClientSetFactionPacket(bob.id, 2)); + + Assert.That(bob.FactionId, Is.EqualTo(1)); + Assert.That(aliceConn.SentPackets, Does.Not.Contain(Packets.Server_SetFaction)); + + state.HandleSetFaction(new ClientSetFactionPacket(alice.id, 2)); + + Assert.That(alice.FactionId, Is.EqualTo(2)); + Assert.That(aliceConn.SentPackets, Does.Contain(Packets.Server_SetFaction)); + } + + [Test] + public void HostCanSetOtherPlayersFaction() + { + var (host, _) = AddPlayer("host"); + var (bob, _) = AddPlayer("bob"); + server.hostUsername = "host"; + bob.FactionId = 1; + + host.conn.GetState()!.HandleSetFaction(new ClientSetFactionPacket(bob.id, 2)); + + Assert.That(bob.FactionId, Is.EqualTo(2)); + } +} From b6f1126893bcdcfdeae1d42ad3ae24ffa7be3815 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:47:03 -0600 Subject: [PATCH 014/183] Balance faction context pushes in trading UI and map faction ticks FactionContext.Push always pushes an entry (null when the push is a no-op), but TradingWindow only popped when Push returned non-null, so every frame of an own-faction trade leaked a stack entry. Always pop. MultiplayerMapComp.DoTick popped the faction without try/finally, so an exception in a faction's lister/resource tick left the faction context and map managers of that faction applied for the rest of the tick. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Comp/Map/MultiplayerMapComp.cs | 12 +++++++++--- Source/Client/Persistent/TradingUI.cs | 8 +++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Source/Client/Comp/Map/MultiplayerMapComp.cs b/Source/Client/Comp/Map/MultiplayerMapComp.cs index 541a9233e..dc3b91039 100644 --- a/Source/Client/Comp/Map/MultiplayerMapComp.cs +++ b/Source/Client/Comp/Map/MultiplayerMapComp.cs @@ -110,9 +110,15 @@ public void DoTick() foreach (var data in factionData) { map.PushFaction(data.Key); - data.Value.listerHaulables.ListerHaulablesTick(); - data.Value.resourceCounter.ResourceCounterTick(); - map.PopFaction(); + try + { + data.Value.listerHaulables.ListerHaulablesTick(); + data.Value.resourceCounter.ResourceCounterTick(); + } + finally + { + map.PopFaction(); + } } } finally diff --git a/Source/Client/Persistent/TradingUI.cs b/Source/Client/Persistent/TradingUI.cs index e4c21a1d9..97078b697 100644 --- a/Source/Client/Persistent/TradingUI.cs +++ b/Source/Client/Persistent/TradingUI.cs @@ -67,10 +67,10 @@ public override void DoWindowContents(Rect inRect) selectedSession = session.SessionId; } - Faction factionContext = session.NegotiatorFaction; + // FactionContext.Push always pushes (null when it's a no-op), so always pop + FactionContext.Push(session.NegotiatorFaction); try { - factionContext = FactionContext.Push(factionContext); MpTradeSession.SetTradeSession(session); drawingTrade = this; @@ -125,9 +125,7 @@ public override void DoWindowContents(Rect inRect) { drawingTrade = null; MpTradeSession.SetTradeSession(null); - if (factionContext != null) { - FactionContext.Pop(); - } + FactionContext.Pop(); } } From 22f958b187e28bd2875504065a75def6fce738f7 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:47:51 -0600 Subject: [PATCH 015/183] Don't mutate live sessions while saving; fix inverted checks SessionManager.WriteSessionData removed invalid semi-persistent sessions from the live lists (without PostRemoveSession). Saving can happen on a single client (host autosave, manual save), so the session lists, and with them map pausing, could differ between clients. Invalid sessions are now only left out of the written data. The 'log once per type' conditions were inverted (logged only from the second occurrence on). CaravanFormingSession scribed its onClosed Action with Scribe_Values, which can't round-trip (logs a parse error on load). Stop saving it and document that it's intentionally never invoked. HandleMissingDialogsGameStart returned when there were trade sessions, making the rest of the method dead code (inverted in #401). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Comp/Map/MultiplayerMapComp.cs | 2 +- .../Persistent/CaravanFormingSession.cs | 3 ++- Source/Client/Persistent/SessionManager.cs | 27 ++++++++++--------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/Source/Client/Comp/Map/MultiplayerMapComp.cs b/Source/Client/Comp/Map/MultiplayerMapComp.cs index dc3b91039..5ba02cd63 100644 --- a/Source/Client/Comp/Map/MultiplayerMapComp.cs +++ b/Source/Client/Comp/Map/MultiplayerMapComp.cs @@ -277,7 +277,7 @@ static void Prefix() if (Multiplayer.Client == null) return; // Trading window on resume save - if (!Multiplayer.WorldComp.trading.NullOrEmpty()) return; + if (Multiplayer.WorldComp.trading.NullOrEmpty()) return; // playerNegotiator == null can only happen during loading? Is this a resuming check? Multiplayer.WorldComp.trading.FirstOrDefault(t => t.playerNegotiator == null)?.OpenWindow(); } diff --git a/Source/Client/Persistent/CaravanFormingSession.cs b/Source/Client/Persistent/CaravanFormingSession.cs index 949ace19a..64d61b5f7 100644 --- a/Source/Client/Persistent/CaravanFormingSession.cs +++ b/Source/Client/Persistent/CaravanFormingSession.cs @@ -14,6 +14,8 @@ public class CaravanFormingSession : ExposableSession, ISessionWithTransferables public Faction faction; public bool reform; + // Never invoked (and not saved): an Action can't be persisted, so running it would differ between + // clients that loaded the session from a save and clients that created it. public Action onClosed; public bool mapAboutToBeRemoved; public PlanetTile startingTile = -1; @@ -175,7 +177,6 @@ public override void ExposeData() Scribe_References.Look(ref faction, "faction"); Scribe_Values.Look(ref reform, "reform"); - Scribe_Values.Look(ref onClosed, "onClosed"); Scribe_Values.Look(ref mapAboutToBeRemoved, "mapAboutToBeRemoved"); Scribe_Values.Look(ref startingTile, "startingTile"); Scribe_Values.Look(ref destinationTile, "destinationTile"); diff --git a/Source/Client/Persistent/SessionManager.cs b/Source/Client/Persistent/SessionManager.cs index eee16edaf..5f6dad6ee 100644 --- a/Source/Client/Persistent/SessionManager.cs +++ b/Source/Client/Persistent/SessionManager.cs @@ -137,27 +137,30 @@ public void TickSessions() public void WriteSessionData(ByteWriter data) { + // Saving can happen on a single client (e.g. host autosaves), so it mustn't modify the live session lists. + // Invalid sessions are just left out of the written data. + var sessionsToWrite = new List(semiPersistentSessions.Count); + // Clear the set to make sure it's empty tempCleanupLoggingTypes.Clear(); - for (int i = semiPersistentSessions.Count - 1; i >= 0; i--) + foreach (var session in semiPersistentSessions) { - var session = semiPersistentSessions[i]; - if (!session.IsSessionValid) + if (session.IsSessionValid) { - semiPersistentSessions.RemoveAt(i); - allSessions.Remove(session); - if (session is ITickingSession tickingSession) tickingSessions.Remove(tickingSession); - var sessionType = session.GetType(); - if (!tempCleanupLoggingTypes.Add(sessionType)) - Log.Message($"Multiplayer session not valid after writing semi persistent data: {sessionType}"); + sessionsToWrite.Add(session); + continue; } + + var sessionType = session.GetType(); + if (tempCleanupLoggingTypes.Add(sessionType)) + Log.Message($"Multiplayer session not valid when writing semi persistent data: {sessionType}"); } // Clear the set again to not leave behind any unneeded stuff tempCleanupLoggingTypes.Clear(); - data.WriteInt32(semiPersistentSessions.Count); + data.WriteInt32(sessionsToWrite.Count); - foreach (var session in semiPersistentSessions) + foreach (var session in sessionsToWrite) { data.WriteUShort((ushort)ApiSerialization.sessions.FindIndex(session.GetType())); data.WriteInt32(session.SessionId); @@ -240,7 +243,7 @@ public void ExposeSessions() { session.PostRemoveSession(); var sessionType = session.GetType(); - if (!tempCleanupLoggingTypes.Add(sessionType)) + if (tempCleanupLoggingTypes.Add(sessionType)) Log.Message($"Multiplayer session not valid after exposing data: {sessionType}"); } } From 2aec6acbcbb1f781d645a104738860b47fb06c1f Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:48:20 -0600 Subject: [PATCH 016/183] Reset sync state flags in finalizers instead of postfixes Add an MpFinalizer patch attribute and use it where a skipped postfix leaves global state broken for the rest of the session: - StorageSettingsClipboard.Copy set Multiplayer.dontSync in a prefix and cleared it in a postfix. If Copy threw, every later sync handler ran local-only. Restore the previous value (so an outer dontSync scope isn't clobbered) in a finalizer. - ThingFilterMarkers cleared the drawn filter context in postfixes. One exception while drawing made the setter throw "context already set" on every following frame (or left a stale context for unrelated filters). - The growth moment letter's seeded Rand state is popped in a finalizer, only if it was pushed. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/Game/SyncDelegates.cs | 18 ++++++--- Source/Client/Syncing/Game/SyncMethods.cs | 11 +++-- .../Client/Syncing/Game/ThingFilterMarkers.cs | 40 +++++++++---------- Source/Client/Util/MpPatch.cs | 30 +++++++++++++- 4 files changed, 70 insertions(+), 29 deletions(-) diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index fbacb638e..63fc86aae 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -463,12 +463,20 @@ static void PickRandomTraitAndPassions(ChoiceLetter_GrowthMoment letter) // The letter tries to generate the options when opened. Make the picks seeded, so all players will get the same ones. [MpPrefix(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.TrySetChoices))] - static void PreLetterChoices(ChoiceLetter_GrowthMoment __instance) - => Rand.PushState(Gen.HashCombineInt(__instance.pawn.thingIDNumber, __instance.arrivalTick)); + static void PreLetterChoices(ChoiceLetter_GrowthMoment __instance, out bool __state) + { + __state = false; + Rand.PushState(Gen.HashCombineInt(__instance.pawn.thingIDNumber, __instance.arrivalTick)); + __state = true; + } - [MpPostfix(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.TrySetChoices))] - static void PostLetterChoices() - => Rand.PopState(); + // A finalizer to keep the Rand state stack balanced if TrySetChoices throws + [MpFinalizer(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.TrySetChoices))] + static void PostLetterChoices(bool __state) + { + if (__state) + Rand.PopState(); + } static void InitDevTools() { diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index 1b04e7881..922209300 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -545,10 +545,15 @@ static bool PastePawnTimetable(Pawn p) } [MpPrefix(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] - static void StorageSettingsClipboardCopy_Prefix() => Multiplayer.dontSync = true; + static void StorageSettingsClipboardCopy_Prefix(out bool __state) + { + __state = Multiplayer.dontSync; + Multiplayer.dontSync = true; + } - [MpPostfix(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] - static void StorageSettingsClipboardCopy_Postfix() => Multiplayer.dontSync = false; + // A finalizer, as leaving dontSync set would turn every later sync handler local-only + [MpFinalizer(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] + static void StorageSettingsClipboardCopy_Finalizer(bool __state) => Multiplayer.dontSync = __state; [MpTranspiler(typeof(CompPlantable), nameof(CompPlantable.BeginTargeting), lambdaOrdinal: 0)] static IEnumerable CompPlantableTranspiler(IEnumerable insts) diff --git a/Source/Client/Syncing/Game/ThingFilterMarkers.cs b/Source/Client/Syncing/Game/ThingFilterMarkers.cs index 98651e066..8f9599a0c 100644 --- a/Source/Client/Syncing/Game/ThingFilterMarkers.cs +++ b/Source/Client/Syncing/Game/ThingFilterMarkers.cs @@ -37,56 +37,56 @@ static void TabStorageFillTab_Prefix(ITab_Storage __instance) DrawnThingFilter = new TabStorageWrapper(selParent); } - [MpPostfix(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] - static void TabStorageFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] + static void TabStorageFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] static void BillConfig_Prefix(Dialog_BillConfig __instance) => DrawnThingFilter = new BillConfigWrapper(__instance.bill); - [MpPostfix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] - static void BillConfig_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] + static void BillConfig_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] static void ManageOutfit_Prefix(Dialog_ManageApparelPolicies __instance) => DrawnThingFilter = new OutfitWrapper(__instance.SelectedPolicy); - [MpPostfix(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] - static void ManageOutfit_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] + static void ManageOutfit_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] static void ManageFoodRestriction_Prefix(Dialog_ManageFoodPolicies __instance) => DrawnThingFilter = new FoodRestrictionWrapper(__instance.SelectedPolicy); - [MpPostfix(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] - static void ManageFoodRestriction_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] + static void ManageFoodRestriction_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] static void TabPenAutocutFillTab_Prefix(ITab_PenAutoCut __instance) => DrawnThingFilter = new PenAutocutWrapper(__instance.SelectedCompAnimalPenMarker); - [MpPostfix(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] - static void TabPenAutocutFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] + static void TabPenAutocutFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] static void TabPenAnimalsFillTab_Prefix(ITab_PenAnimals __instance) => DrawnThingFilter = new PenAnimalsWrapper(__instance.SelectedCompAnimalPenMarker); - [MpPostfix(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] - static void TabPenAnimalsFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] + static void TabPenAnimalsFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] static void TabWindTurbineAutocutFillTab_Prefix(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = new DefaultAutocutWrapper(__instance.AutoCut); - [MpPostfix(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] - static void TabWindTurbineAutocutFillTab_Postfix(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] + static void TabWindTurbineAutocutFillTab_Finalizer(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = null; [MpPrefix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] static void ThingFilterUI_Prefix() => drawingThingFilter = true; - [MpPostfix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] - static void ThingFilterUI_Postfix() => drawingThingFilter = false; + [MpFinalizer(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] + static void ThingFilterUI_Finalizer() => drawingThingFilter = false; // Reading policies need special handling as they draw two ThingFilters private static ReadingPolicy drawnReadingPolicy; @@ -94,8 +94,8 @@ static void TabWindTurbineAutocutFillTab_Prefix(ITab_WindTurbineAutoCut __instan [MpPrefix(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] static void Dialog_ManageReadingPolicies_Prefix(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = __instance.SelectedPolicy; - [MpPostfix(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] - static void Dialog_ManageReadingPolicies_Postfix(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = null; + [MpFinalizer(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] + static void Dialog_ManageReadingPolicies_Finalizer(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = null; [MpPrefix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] static void ThingFilterUI_ReadingPolicy_Prefix(ThingFilter filter) @@ -107,8 +107,8 @@ static void ThingFilterUI_ReadingPolicy_Prefix(ThingFilter filter) DrawnThingFilter = new ReadingPolicyEffectFilterWrapper(drawnReadingPolicy); } - [MpPostfix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] - static void ThingFilterUI_ReadingPolicy_Postfix(ThingFilter filter) + [MpFinalizer(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] + static void ThingFilterUI_ReadingPolicy_Finalizer(ThingFilter filter) { if (drawnReadingPolicy != null && drawnReadingPolicy.defFilter == filter) DrawnThingFilter = null; diff --git a/Source/Client/Util/MpPatch.cs b/Source/Client/Util/MpPatch.cs index cce8bdea6..f56ea0fe3 100644 --- a/Source/Client/Util/MpPatch.cs +++ b/Source/Client/Util/MpPatch.cs @@ -120,7 +120,8 @@ public static List DoMpPatches(this Harmony harmony, Type type) toPatch, (attr is MpPrefix) ? patch : null, (attr is MpPostfix) ? patch : null, - (attr is MpTranspiler) ? patch : null + (attr is MpTranspiler) ? patch : null, + (attr is MpFinalizer) ? patch : null ); } @@ -189,6 +190,33 @@ public MpPostfix(Type parentType, string parentMethod, int lambdaOrdinal) : base } } + /// + /// Finalizer method attribute + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class MpFinalizer : MpPatch + { + public MpFinalizer(string typeName, string method) : base(typeName, method) + { + } + + public MpFinalizer(Type type, string method, Type[] argTypes = null) : base(type, method, argTypes) + { + } + + public MpFinalizer(Type type, string innerType, string method) : base(type, innerType, method) + { + } + + public MpFinalizer(Type type, MethodType methodType, Type[] argTypes = null) : base(type, methodType, argTypes) + { + } + + public MpFinalizer(Type parentType, string parentMethod, int lambdaOrdinal) : base(parentType, parentMethod, lambdaOrdinal) + { + } + } + /// /// Transpiler method attribute /// From e3edd35ff68af5acc758ee814a870b6e669260e1 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:48:22 -0600 Subject: [PATCH 017/183] Drop partially received fragmented packets on state change The fragment buffer allows one fragmented packet in flight and survived connection state changes. When a client changed state mid-transfer (e.g. rejoining while a Server_SyncInfo was arriving), the remaining fragments were dropped in lenient mode but the partial entry stayed; the first Server_WorldData fragment then failed with "High number of fragmented packets at once!" and the rejoin broke (likely #691). ChangeState now discards partial packets and the remaining fragments of a discarded packet are skipped by fragment id, whatever the new state handles. A fragment without a handler (lenient mode) drops the rest of its packet the same way instead of logging each fragment, so a later state can't mistake a continuation fragment for a new packet. Co-Authored-By: Claude Opus 5.5 --- Source/Common/Networking/ConnectionBase.cs | 41 ++++++++ Source/Tests/FragmentedPacketTest.cs | 110 +++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 Source/Tests/FragmentedPacketTest.cs diff --git a/Source/Common/Networking/ConnectionBase.cs b/Source/Common/Networking/ConnectionBase.cs index 14ab1b872..fc84df44f 100644 --- a/Source/Common/Networking/ConnectionBase.cs +++ b/Source/Common/Networking/ConnectionBase.cs @@ -23,6 +23,7 @@ public void ChangeState(MpConnectionState state) if (StateObj != null) StateObj.alive = false; + DiscardPartialFragments(); State = MpConnectionState.GetStateEnumOf(state); StateObj = state; StateObj?.StartState(); @@ -33,6 +34,7 @@ public void ChangeState(ConnectionStateEnum state) if (StateObj != null) StateObj.alive = false; + DiscardPartialFragments(); State = state; StateObj = MpConnectionState.CreateState(state, this); StateObj?.StartState(); @@ -162,6 +164,9 @@ public virtual void HandleReceiveRaw(ByteReader data, bool reliable) private const int MaxFragmentedPackets = 1; private readonly List<(/* fragId */ byte, FragmentedPacket)> fragments = []; + // Fragmented packet whose remaining fragments are dropped, or -1. Fragments of one packet are sent back to + // back on the reliable ordered channel, so a fragment with another id means this one is over. + private int discardedFragId = -1; protected virtual void HandleReceiveMsg(int msgId, int fragState, ByteReader reader, bool reliable) { @@ -172,12 +177,34 @@ protected virtual void HandleReceiveMsg(int msgId, int fragState, ByteReader rea if (reader.Left > MaxSinglePacketSize) throw new PacketReadException($"Packet {packetType} too big {reader.Left}>{MaxSinglePacketSize}"); + if (fragState != FragNone && discardedFragId != -1) + { + if (reader.Left > 0 && reader.PeekByte() == discardedFragId) + { + if (fragState == FragEnd) discardedFragId = -1; + reader.Seek(reader.Length); + return; + } + + discardedFragId = -1; + } + var handler = StateObj?.GetPacketHandler(packetType); if (handler == null) { if (reliable && !Lenient) throw new PacketReadException($"No handler for packet {packetType} in state {State}"); ServerLog.Error($"No handler for packet {packetType} in state {State}"); + + // Also drop the rest of a fragmented packet; a later state could otherwise mistake its next + // fragment for the start of a new packet. + if (fragState != FragNone && reader.Left > 0) + { + var fragId = reader.PeekByte(); + fragments.RemoveAll(f => f.Item1 == fragId); + if (fragState != FragEnd) discardedFragId = fragId; + } + reader.Seek(reader.Length); return; } @@ -237,6 +264,20 @@ private void HandleReceiveFragment(ByteReader reader, Packets packetType, Packet ExecuteMessageHandler(handler, packetType, new ByteReader(fragPacket.Data.GetBuffer())); } + // A partially received fragmented packet was meant for the old state. Without this, it would stay in + // fragments forever and the next fragmented packet would fail with "High number of fragmented packets". + private void DiscardPartialFragments() + { + foreach (var (fragId, fragPacket) in fragments) + { + ServerLog.Verbose($"{this} dropping partially received {fragPacket.Id} " + + $"({fragPacket.ReceivedPartsCount}/{fragPacket.ExpectedPartsCount} parts) on state change"); + discardedFragId = fragId; + } + + fragments.Clear(); + } + private void ExecuteMessageHandler(PacketHandlerInfo handler, Packets packet, ByteReader data) { var pos = data.Position; diff --git a/Source/Tests/FragmentedPacketTest.cs b/Source/Tests/FragmentedPacketTest.cs new file mode 100644 index 000000000..64c10abe5 --- /dev/null +++ b/Source/Tests/FragmentedPacketTest.cs @@ -0,0 +1,110 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[NonParallelizable] +public class FragmentedPacketTest +{ + private MultiplayerServer server = null!; + private Action? prevErrorLog; + private readonly List errors = new(); + + private class CapturingConnection : ConnectionBase + { + public readonly List sent = new(); + protected override void SendRaw(byte[] raw, bool reliable) => sent.Add(raw); + protected override void OnClose(ServerDisconnectPacket? goodbye) { } + } + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + server.hostUsername = "host"; + + prevErrorLog = ServerLog.error; + errors.Clear(); + ServerLog.error = errors.Add; + } + + [TearDown] + public void TearDown() + { + ServerLog.error = prevErrorLog; + MultiplayerServer.instance = null; + } + + private RecordingConnection AddHost() + { + var conn = new RecordingConnection("host"); + var player = new ServerPlayer(0, conn); + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return conn; + } + + private static List Fragments(byte[] syncOpinion) + { + var sender = new CapturingConnection(); + sender.SendFragmented(new ClientSyncInfoPacket { rawSyncOpinion = syncOpinion }.Serialize()); + return sender.sent; + } + + private static byte[] Payload(byte fill) => Enumerable.Repeat(fill, 5000).ToArray(); + + private static void Receive(ConnectionBase conn, IEnumerable raws) + { + foreach (var raw in raws) + conn.HandleReceiveRaw(new ByteReader(raw), true); + } + + [Test] + public void StateChangeDropsPartialPacket() + { + var conn = AddHost(); + var dropped = Fragments(Payload(1)); + Assert.That(dropped, Has.Count.GreaterThan(3)); + + Receive(conn, dropped.Take(2)); + conn.ChangeState(ConnectionStateEnum.ServerLoading); // Goes straight back to playing + Assert.That(conn.State, Is.EqualTo(ConnectionStateEnum.ServerPlaying)); + + // The rest of the dropped packet is ignored, the next fragmented packet goes through + Receive(conn, dropped.Skip(2)); + Receive(conn, Fragments(Payload(2))); + + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(1)); + Assert.That(server.worldData.syncInfos[0], Is.EqualTo(Payload(2))); + Assert.That(errors, Is.Empty); + } + + [Test] + public void FragmentWithoutHandlerDropsWholePacket() + { + var conn = AddHost(); + conn.ChangeState(ConnectionStateEnum.ServerJoining); // No handler for sync info + conn.Lenient = true; + + var dropped = Fragments(Payload(1)); + Receive(conn, dropped.Take(1)); + Assert.That(errors, Has.Count.EqualTo(1)); // "No handler" is logged once + + conn.Lenient = false; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + + // The remaining fragments must not be mistaken for the start of a new packet + Receive(conn, dropped.Skip(1)); + Receive(conn, Fragments(Payload(2))); + + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(1)); + Assert.That(server.worldData.syncInfos[0], Is.EqualTo(Payload(2))); + Assert.That(errors, Has.Count.EqualTo(1)); + } +} From 7d09265bdf0f9d6260f507122b6f91ed22f2bca2 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:48:24 -0600 Subject: [PATCH 018/183] Keep one globally ordered command log for joiners Live clients execute every command from a single queue in arrival order (#734), but the server stored commands per map and joiners rebuilt per-map queues, running "world first, then each map". Any tick with commands for two tickables then ran in a different order on joiners, and AsyncTimeComp .FinalizeInit replaced a map's queue with the snapshot list, which could drop map commands received during the asynchronous load or execute them twice for maps generated during play. Server: WorldData keeps a single list of serialized commands in send order and Server_WorldData sends it as one list. Standalone persistence stores it in Saved/cmds.dat; legacy world_cmds.dat + maps/N_cmds.dat are still read (merged by tick) and removed on the next join point. Client: GameDataSnapshot holds one ordered command list. Every command is queued in the world queue; while a (re)load is in progress commands are only appended to the snapshot list and Loader.PostLoad (main thread) builds the queue from it, so nothing is lost or duplicated. FinalizeInit no longer touches queues. Replays store all commands in world/N_cmds and still read old per-map entries. The consistentCommandOrder tweak value is removed. RunCmds no longer blocks forever on a command for a past tick; it logs an error and drops it (the loaded state already contains it). Protocol bumped to 57 (Server_WorldData format). Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 9 +- .../Networking/State/ClientLoadingState.cs | 20 +-- .../Networking/State/ClientPlayingState.cs | 7 +- Source/Client/Patches/TickPatch.cs | 31 ++-- Source/Client/Saving/Loader.cs | 11 +- Source/Client/Saving/Replay.cs | 24 ++-- Source/Client/Saving/SaveLoad.cs | 16 +-- Source/Client/Session/GameDataSnapshot.cs | 3 +- Source/Client/Session/MultiplayerSession.cs | 19 ++- Source/Common/CommandHandler.cs | 6 +- Source/Common/MultiplayerServer.cs | 4 +- .../Networking/State/ServerLoadingState.cs | 21 +-- .../Networking/State/ServerPlayingState.cs | 2 +- Source/Common/ScheduledCommand.cs | 17 +++ Source/Common/StandalonePersistence.cs | 68 +++++---- Source/Common/Version.cs | 2 +- Source/Common/WorldData.cs | 16 ++- Source/Tests/ChatCommandManagerTest.cs | 4 +- Source/Tests/CommandLogTest.cs | 136 ++++++++++++++++++ Source/Tests/Helper/RecordingConnection.cs | 4 + Source/Tests/StandaloneMapStreamingTest.cs | 14 +- Source/Tests/StandalonePersistenceTest.cs | 85 +++++++++-- 22 files changed, 368 insertions(+), 151 deletions(-) create mode 100644 Source/Tests/CommandLogTest.cs diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 32233940b..dc31b1e7b 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -93,6 +93,7 @@ public int GameStartAbsTick // Shared random state for ticking and commands public ulong randState; + // Always empty: map commands are queued in AsyncWorldTimeComp.cmds to keep a single global order public Queue cmds = new(); public int CurrentPlayerCount { get; private set; } @@ -220,11 +221,9 @@ public void ExposeData() public void FinalizeInit() { - cmds = new Queue( - Multiplayer.session.dataSnapshot?.MapCmds.GetValueSafe(map.uniqueID) ?? new List() - ); - - Log.Message($"Init map with cmds {cmds.Count}"); + // Map commands live in the world's queue (see MultiplayerSession.ScheduleCommand) which is built only in + // Loader.PostLoad. Rebuilding a queue here (possibly on the loading thread, or for a map generated during + // play) used to drop or duplicate commands. } public static bool keepTheMap; diff --git a/Source/Client/Networking/State/ClientLoadingState.cs b/Source/Client/Networking/State/ClientLoadingState.cs index 2622c9611..79a18f65d 100644 --- a/Source/Client/Networking/State/ClientLoadingState.cs +++ b/Source/Client/Networking/State/ClientLoadingState.cs @@ -77,22 +77,14 @@ public void HandleWorldData(ByteReader data) byte[] worldData = GZipStream.UncompressBuffer(data.ReadPrefixedBytes()); byte[] sessionData = GZipStream.UncompressBuffer(data.ReadPrefixedBytes()); - var mapCmdsDict = new Dictionary>(); var mapDataDict = new Dictionary(); List mapsToLoad = new List(); - int mapCmdsCount = data.ReadInt32(); - for (int i = 0; i < mapCmdsCount; i++) - { - int mapId = data.ReadInt32(); - - int mapCmdsLen = data.ReadInt32(); - List mapCmds = new List(mapCmdsLen); - for (int j = 0; j < mapCmdsLen; j++) - mapCmds.Add(ScheduledCommand.Deserialize(new ByteReader(data.ReadPrefixedBytes()))); - - mapCmdsDict[mapId] = mapCmds; - } + // World and map cmds in the order live clients executed them + int cmdsCount = data.ReadInt32(); + var cmds = new List(cmdsCount); + for (int i = 0; i < cmdsCount; i++) + cmds.Add(ScheduledCommand.Deserialize(new ByteReader(data.ReadPrefixedBytes()))); int mapDataCount = data.ReadInt32(); for (int i = 0; i < mapDataCount; i++) @@ -110,7 +102,7 @@ public void HandleWorldData(ByteReader data) worldData, sessionData, mapDataDict, - mapCmdsDict + cmds ); TickPatch.tickUntil = tickUntil; diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs index 6a96635a5..c32c9ae2d 100644 --- a/Source/Client/Networking/State/ClientPlayingState.cs +++ b/Source/Client/Networking/State/ClientPlayingState.cs @@ -140,12 +140,11 @@ public void HandleMapResponse(ByteReader data) { int mapId = data.ReadInt32(); + // Map streaming is disabled (MultiplayerServer.CanUseStandaloneMapStreaming). Streamed map cmds can't be + // placed in the global command order, so they are skipped here. int mapCmdsLen = data.ReadInt32(); - List mapCmds = new List(mapCmdsLen); for (int j = 0; j < mapCmdsLen; j++) - mapCmds.Add(ScheduledCommand.Deserialize(new ByteReader(data.ReadPrefixedBytes()))); - - Session.dataSnapshot.MapCmds[mapId] = mapCmds; + data.ReadPrefixedBytes(); byte[] mapData = GZipStream.UncompressBuffer(data.ReadPrefixedBytes()); Session.dataSnapshot.MapData[mapId] = mapData; diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index a8068091b..7783906f9 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -169,22 +169,29 @@ private static bool RunCmds() { int curTimer = Timer; - foreach (ITickable tickable in AllTickables) + // All commands (world and map) are in the world's queue, in the order the server sent them (#733) + var cmds = Multiplayer.AsyncWorldTime.Cmds; + + while (cmds.Count > 0 && cmds.Peek().ticks <= curTimer) { - while (tickable.Cmds.Count > 0 && tickable.Cmds.Peek().ticks == curTimer) + ScheduledCommand cmd = cmds.Dequeue(); + + if (cmd.ticks < curTimer) { - ScheduledCommand cmd = tickable.Cmds.Dequeue(); + // Can only happen with a broken log (e.g. a world state newer than its commands). The loaded state + // already contains the command's effects and executing it late can't match other clients, so + // drop it instead of blocking the queue forever. + Log.Error($"Multiplayer: dropping command scheduled for a past tick (timer {curTimer}): {cmd}"); + continue; + } - // 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); - if (target == null) - { - Log.Error($"!!! Tickable of {cmd.mapId} not found! {cmd}"); - } else target.ExecuteCmd(cmd); + var target = TickableById(cmd.mapId); + if (target == null) + { + Log.Error($"!!! Tickable of {cmd.mapId} not found! {cmd}"); + } else target.ExecuteCmd(cmd); - if (LongEventHandler.eventQueue.Count > 0) return true; // Yield to e.g. join-point creation - } + if (LongEventHandler.eventQueue.Count > 0) return true; // Yield to e.g. join-point creation } return false; diff --git a/Source/Client/Saving/Loader.cs b/Source/Client/Saving/Loader.cs index 74e5c0085..4d9160f7d 100644 --- a/Source/Client/Saving/Loader.cs +++ b/Source/Client/Saving/Loader.cs @@ -24,6 +24,10 @@ public static void ReloadGame(List mapsToLoad, bool changeScene, Action cus LoadPatch.gameToLoad = new TempGameData(gameDoc, Multiplayer.session.dataSnapshot.SessionData); TickPatch.replayTimeSpeed = TimeSpeed.Paused; + // Commands arriving until PostLoad are only recorded in dataSnapshot.Cmds (packets are processed on the + // main thread while the game loads asynchronously) + Multiplayer.session.cmdQueueReady = false; + if (changeScene) { LongEventHandler.QueueLongEvent(() => @@ -75,9 +79,10 @@ private static void PostLoad() ); Multiplayer.game.myFactionLoading = null; - Multiplayer.AsyncWorldTime.cmds = new Queue( - Multiplayer.session.dataSnapshot.MapCmds.GetValueSafe(ScheduledCommand.Global) ?? []); - // Map cmds are added in MapAsyncTimeComp.FinalizeInit + // The only place the command queue is rebuilt: all cmds since the snapshot, in order, including the ones + // received while loading. Runs on the main thread, like MultiplayerSession.ScheduleCommand. + Multiplayer.AsyncWorldTime.cmds = new Queue(Multiplayer.session.dataSnapshot.Cmds); + Multiplayer.session.cmdQueueReady = true; } private static XmlDocument DataSnapshotToXml(GameDataSnapshot dataSnapshot, List mapsToLoad) diff --git a/Source/Client/Saving/Replay.cs b/Source/Client/Saving/Replay.cs index 19587f82f..f3f5b6b65 100644 --- a/Source/Client/Saving/Replay.cs +++ b/Source/Client/Saving/Replay.cs @@ -33,12 +33,8 @@ public void WriteData(GameDataSnapshot gameData) foreach (var (mapId, mapData) in gameData.MapData) zip.AddEntry($"maps/{sectionId}_{mapId}_save", mapData); - foreach (var (mapId, mapCmdData) in gameData.MapCmds) - if (mapId >= 0) - zip.AddEntry($"maps/{sectionId}_{mapId}_cmds", ScheduledCommand.SerializeCmds(mapCmdData)); - - if (gameData.MapCmds.TryGetValue(ScheduledCommand.Global, out var worldCmds)) - zip.AddEntry($"world/{sectionId}_cmds", ScheduledCommand.SerializeCmds(worldCmds)); + // All cmds in execution order. Map cmd entries (maps/N_M_cmds) are only read from older replays. + zip.AddEntry($"world/{sectionId}_cmds", ScheduledCommand.SerializeCmds(gameData.Cmds)); zip.AddEntry($"world/{sectionId}_save", gameData.GameData); info.sections.Add(new ReplaySection(gameData.CachedAtTime, TickPatch.Timer)); @@ -59,7 +55,7 @@ public bool LoadInfo() public GameDataSnapshot LoadGameData(int sectionId) { - var mapCmdsDict = new Dictionary>(); + var legacyMapCmds = new List>(); var mapDataDict = new Dictionary(); string sectionIdStr = sectionId.ToString("D3"); @@ -67,14 +63,12 @@ public GameDataSnapshot LoadGameData(int sectionId) using var zip = OpenZipRead(); foreach (var mapCmds in zip.GetEntries($"maps/{sectionIdStr}_*_cmds")) - { - int mapId = int.Parse(mapCmds.Name.Split('_')[1]); - mapCmdsDict[mapId] = ScheduledCommand.DeserializeCmds(mapCmds.GetBytes()); - } + legacyMapCmds.Add(ScheduledCommand.DeserializeCmds(mapCmds.GetBytes())); - var worldCmds = zip.GetEntry($"world/{sectionIdStr}_cmds"); - if (worldCmds != null) - mapCmdsDict[ScheduledCommand.Global] = ScheduledCommand.DeserializeCmds(worldCmds.GetBytes()); + var worldCmdsEntry = zip.GetEntry($"world/{sectionIdStr}_cmds"); + var worldCmds = worldCmdsEntry != null + ? ScheduledCommand.DeserializeCmds(worldCmdsEntry.GetBytes()) + : new List(); foreach (var mapSave in zip.GetEntries($"maps/{sectionIdStr}_*_save")) { @@ -87,7 +81,7 @@ public GameDataSnapshot LoadGameData(int sectionId) zip.GetBytes($"world/{sectionIdStr}_save"), Array.Empty(), mapDataDict, - mapCmdsDict + ScheduledCommand.MergeLegacyCmds(worldCmds, legacyMapCmds) ); } diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 857ff0b18..6706b9986 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -38,7 +38,7 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio var tweenedPos = new Dictionary(); var localFactionId = Multiplayer.RealPlayerFaction.loadID; - var mapCmds = new Dictionary>(); + var cmds = Multiplayer.AsyncWorldTime.cmds; var planetRenderMode = Find.World.renderer.wantedMode; var chatWindow = ChatWindow.Opened; @@ -49,12 +49,8 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio { foreach (Pawn p in map.mapPawns.AllPawnsSpawned) tweenedPos[p.thingIDNumber] = p.drawer.tweener.tweenedPos; - - mapCmds[map.uniqueID] = map.AsyncTime().cmds; } - mapCmds[ScheduledCommand.Global] = Multiplayer.AsyncWorldTime.cmds; - TempGameData gameData; using (DeepProfilerWrapper.Section("Multiplayer SaveAndReload: Save")) { @@ -97,8 +93,6 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio p.drawer.tweener.lastDrawFrame = Time.frameCount; } } - - m.AsyncTime().cmds = mapCmds[m.uniqueID]; } if (chatWindow != null) @@ -108,7 +102,7 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio Find.Selector.selected = SyncSerialization.ReadSync>(selectedReader).AllNotNull().Cast().ToList(); Find.World.renderer.wantedMode = planetRenderMode; - Multiplayer.AsyncWorldTime.cmds = mapCmds[ScheduledCommand.Global]; + Multiplayer.AsyncWorldTime.cmds = cmds; Multiplayer.reloading = false; @@ -198,7 +192,6 @@ public static GameDataSnapshot CreateGameDataSnapshot(TempGameData data, bool re XmlNode gameNode = data.SaveData.DocumentElement["game"]; XmlNode mapsNode = gameNode["maps"]; - var mapCmdsDict = new Dictionary>(); var mapDataDict = new Dictionary(); foreach (XmlNode mapNode in mapsNode) @@ -206,7 +199,6 @@ public static GameDataSnapshot CreateGameDataSnapshot(TempGameData data, bool re int id = int.Parse(mapNode["uniqueID"].InnerText); byte[] mapData = ScribeUtil.XmlToByteArray(mapNode); mapDataDict[id] = mapData; - mapCmdsDict[id] = new List(Find.Maps.First(m => m.uniqueID == id).AsyncTime().cmds); } if (removeCurrentMapId) @@ -215,14 +207,14 @@ public static GameDataSnapshot CreateGameDataSnapshot(TempGameData data, bool re mapsNode.RemoveAll(); byte[] gameData = ScribeUtil.XmlToByteArray(data.SaveData); - mapCmdsDict[ScheduledCommand.Global] = new List(Multiplayer.AsyncWorldTime.cmds); return new GameDataSnapshot( TickPatch.Timer, gameData, data.SessionData, mapDataDict, - mapCmdsDict + // The commands not executed yet, in order (all of them are in the world's queue) + new List(Multiplayer.AsyncWorldTime.cmds) ); } diff --git a/Source/Client/Session/GameDataSnapshot.cs b/Source/Client/Session/GameDataSnapshot.cs index fd3abbcd1..8e3f23b30 100644 --- a/Source/Client/Session/GameDataSnapshot.cs +++ b/Source/Client/Session/GameDataSnapshot.cs @@ -8,5 +8,6 @@ public record GameDataSnapshot( byte[] GameData, byte[] SessionData, Dictionary MapData, - Dictionary> MapCmds // Global cmds are -1, this is mutated by MultiplayerSession.ScheduleCommand + // All cmds (world and map) since CachedAtTime in execution order, appended to by MultiplayerSession.ScheduleCommand + List Cmds ); diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 9bf2464c1..7e1dad5c4 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Diagnostics; -using LudeonTK; using Multiplayer.Client.Networking; using Multiplayer.Client.Util; using Multiplayer.Common; @@ -131,20 +130,20 @@ public void ProcessTimeControl() TickPatch.tickUntil = remoteTickUntil; } - [TweakValue("Multiplayer")] public static bool consistentCommandOrder = true; + // False while a (re)load is in progress: Loader.PostLoad then builds the command queue from dataSnapshot.Cmds, + // which also contains every command received in the meantime. + public bool cmdQueueReady = true; + public void ScheduleCommand(ScheduledCommand cmd) { MpLog.Debug(cmd.ToString()); - dataSnapshot.MapCmds.GetOrAddNew(cmd.mapId).Add(cmd); + dataSnapshot.Cmds.Add(cmd); - if (Current.ProgramState != ProgramState.Playing) return; + if (!cmdQueueReady) return; - // 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. - if (cmd.mapId == ScheduledCommand.Global || consistentCommandOrder) - Multiplayer.AsyncWorldTime.Cmds.Enqueue(cmd); - else - cmd.GetMap()?.AsyncTime().Cmds.Enqueue(cmd); + // All commands (world and map) go to a single queue and run in arrival order, the same order the server + // stores them in for joiners (#733) + Multiplayer.AsyncWorldTime.Cmds.Enqueue(cmd); } public void Update() diff --git a/Source/Common/CommandHandler.cs b/Source/Common/CommandHandler.cs index ef7b43b77..c51cbe2e8 100644 --- a/Source/Common/CommandHandler.cs +++ b/Source/Common/CommandHandler.cs @@ -46,9 +46,9 @@ public void Send(CommandType cmdType, int factionId, int mapId, byte[] data, Ser data); byte[] toSave = ScheduledCommand.Serialize(cmd); - // todo cull target players if not global - server.worldData.mapCmds.GetOrAddNew(mapId).Add(toSave); - server.worldData.tmpMapCmds?.GetOrAddNew(mapId).Add(toSave); + // The log and the live broadcast must stay identical: joiners replay the log in this exact order + server.worldData.cmds.Add(toSave); + server.worldData.tmpCmds?.Add(toSave); if (server.CanUseStandaloneMapStreaming(mapId)) { diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 7d3940f8b..453a84738 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -241,7 +241,9 @@ public void SendMapResponse(ServerPlayer player, int mapId) ByteWriter writer = new ByteWriter(); writer.WriteInt32(mapId); - var mapCmds = worldData.mapCmds.GetValueSafe(mapId) ?? []; + var mapCmds = worldData.cmds + .Where(cmd => ScheduledCommand.Deserialize(new ByteReader(cmd)).mapId == mapId) + .ToList(); writer.WriteInt32(mapCmds.Count); foreach (var cmd in mapCmds) writer.WritePrefixedBytes(cmd); diff --git a/Source/Common/Networking/State/ServerLoadingState.cs b/Source/Common/Networking/State/ServerLoadingState.cs index 76638809b..d7cf7c75d 100644 --- a/Source/Common/Networking/State/ServerLoadingState.cs +++ b/Source/Common/Networking/State/ServerLoadingState.cs @@ -51,22 +51,11 @@ public void SendWorldData() writer.WritePrefixedBytes(Server.worldData.savedGame); writer.WritePrefixedBytes(Server.worldData.sessionData); - writer.WriteInt32(Server.worldData.mapCmds.Count); - - foreach (var kv in Server.worldData.mapCmds) - { - int mapId = kv.Key; - - //MultiplayerServer.instance.SendCommand(CommandType.CreateMapFactionData, ScheduledCommand.NoFaction, mapId, ByteWriter.GetBytes(factionId)); - - List mapCmds = kv.Value; - - writer.WriteInt32(mapId); - - writer.WriteInt32(mapCmds.Count); - foreach (var arr in mapCmds) - writer.WritePrefixedBytes(arr); - } + // All cmds in one list, in send order, so the joiner executes them exactly like live clients did + var cmds = Server.worldData.cmds; + writer.WriteInt32(cmds.Count); + foreach (var cmd in cmds) + writer.WritePrefixedBytes(cmd); writer.WriteInt32(Server.worldData.mapData.Count); diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index f7b21e7a7..c61fef1e6 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -237,7 +237,7 @@ public void HandleDebug(ClientDebugPacket _) if (!Server.commands.CanUseDevMode(Player)) return; - Server.worldData.mapCmds.Clear(); + Server.worldData.cmds.Clear(); Server.gameTimer = Server.startingTimer; Server.SendToPlaying(new ServerDebugPacket()); diff --git a/Source/Common/ScheduledCommand.cs b/Source/Common/ScheduledCommand.cs index 928334517..c0a5228bf 100644 --- a/Source/Common/ScheduledCommand.cs +++ b/Source/Common/ScheduledCommand.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; namespace Multiplayer.Common { @@ -65,5 +66,21 @@ public static byte[] SerializeCmds(List cmds) return writer.ToArray(); } + + /// + /// Old saves stored world and map commands in separate lists, so their relative order is lost. Merge them by + /// tick, keeping world commands first within a tick (best effort, the original order is unknown). + /// Logs in the current format keep all commands in the world list and are returned unchanged. + /// + public static List MergeLegacyCmds(List worldCmds, + IEnumerable> mapCmds) + { + var nonEmptyMapCmds = mapCmds.Where(cmds => cmds.Count > 0).ToList(); + if (nonEmptyMapCmds.Count == 0) + return worldCmds; + + // OrderBy is stable + return worldCmds.Concat(nonEmptyMapCmds.SelectMany(cmds => cmds)).OrderBy(cmd => cmd.ticks).ToList(); + } } } diff --git a/Source/Common/StandalonePersistence.cs b/Source/Common/StandalonePersistence.cs index f7bcbd1e6..1d5b35f50 100644 --- a/Source/Common/StandalonePersistence.cs +++ b/Source/Common/StandalonePersistence.cs @@ -16,7 +16,9 @@ public class StandalonePersistence private string MapsDir => Path.Combine(SavedDir, "maps"); private string WorldPath => Path.Combine(SavedDir, "world.dat"); - private string WorldCmdsPath => Path.Combine(SavedDir, "world_cmds.dat"); + // All cmds since the join point in send order. Replaces the legacy per-tickable world_cmds.dat + maps/N_cmds.dat. + private string CmdsPath => Path.Combine(SavedDir, "cmds.dat"); + private string LegacyWorldCmdsPath => 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"); @@ -54,27 +56,32 @@ public void SeedFromSaveZip(string zipPath) AtomicWrite(WorldPath, Compress(worldBytes)); } - // World commands + // World commands (all commands for replays written by current versions) var worldCmdsEntry = zip.GetEntry("world/000_cmds"); - if (worldCmdsEntry != null) - AtomicWrite(WorldCmdsPath, ReadEntry(worldCmdsEntry)); + var worldCmds = worldCmdsEntry != null + ? ScheduledCommand.DeserializeCmds(ReadEntry(worldCmdsEntry)) + : new List(); + var mapCmds = new List>(); - // Map saves and commands + // Map saves and commands of the first section 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 (parts.Length < 3 || parts[0] != "000") 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)); + mapCmds.Add(ScheduledCommand.DeserializeCmds(ReadEntry(entry))); } + AtomicWrite(CmdsPath, ScheduledCommand.SerializeCmds(ScheduledCommand.MergeLegacyCmds(worldCmds, mapCmds))); + DeleteLegacyCmdFiles(); + // Info/metadata var infoEntry = zip.GetEntry("info"); if (infoEntry != null) @@ -111,6 +118,7 @@ public void SeedFromSaveZip(string zipPath) server.worldData.sessionData = File.Exists(SessionPath) ? File.ReadAllBytes(SessionPath) : Array.Empty(); // Load maps + var legacyMapCmds = new List>(); if (Directory.Exists(MapsDir)) { foreach (var mapFile in Directory.GetFiles(MapsDir, "*.dat")) @@ -124,29 +132,27 @@ public void SeedFromSaveZip(string zipPath) 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(); - } + legacyMapCmds.Add(ScheduledCommand.DeserializeCmds(File.ReadAllBytes(cmdsPath))); } } } - // Load world commands - if (File.Exists(WorldCmdsPath)) + // Load commands + List cmds; + if (File.Exists(CmdsPath)) { - server.worldData.mapCmds[-1] = ScheduledCommand.DeserializeCmds(File.ReadAllBytes(WorldCmdsPath)) - .Select(ScheduledCommand.Serialize).ToList(); + cmds = ScheduledCommand.DeserializeCmds(File.ReadAllBytes(CmdsPath)); } else { - server.worldData.mapCmds[-1] = new List(); + var legacyWorldCmds = File.Exists(LegacyWorldCmdsPath) + ? ScheduledCommand.DeserializeCmds(File.ReadAllBytes(LegacyWorldCmdsPath)) + : new List(); + cmds = ScheduledCommand.MergeLegacyCmds(legacyWorldCmds, legacyMapCmds); } + server.worldData.cmds = cmds.Select(ScheduledCommand.Serialize).ToList(); + // Load replay info for metadata ReplayInfo? info = null; if (File.Exists(InfoPath)) @@ -180,18 +186,16 @@ public void WriteJoinPoint(WorldData worldData, int tick) 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) ?? [])); - } + + // cmds.dat takes precedence over the legacy files, so they are only deleted once it exists + AtomicWrite(CmdsPath, SerializeStoredCmds(worldData.cmds)); + DeleteLegacyCmdFiles(); WritePersistedTick(tick); } @@ -284,6 +288,18 @@ private void DeleteStaleMapFiles(HashSet currentMapIds) } } + private void DeleteLegacyCmdFiles() + { + if (File.Exists(LegacyWorldCmdsPath)) + File.Delete(LegacyWorldCmdsPath); + + if (!Directory.Exists(MapsDir)) + return; + + foreach (var path in Directory.GetFiles(MapsDir, "*_cmds.dat")) + File.Delete(path); + } + private void WritePersistedTick(int tick) { AtomicWrite(StatePath, BitConverter.GetBytes(tick)); diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index 9e8b83277..e6ac62012 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 = 56; + public const int Protocol = 57; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/Common/WorldData.cs b/Source/Common/WorldData.cs index 26e7b596f..052819319 100644 --- a/Source/Common/WorldData.cs +++ b/Source/Common/WorldData.cs @@ -14,8 +14,10 @@ public class WorldData public byte[]? sessionData; // Compressed semi persistent data public Dictionary mapData = new(); // Map id to compressed map data - public Dictionary> mapCmds = new(); // Map id to serialized cmds list - public Dictionary>? tmpMapCmds; + // Serialized cmds since the last join point, in the order they were sent to clients. Live clients execute + // commands in arrival order, so joiners must get exactly this order (not grouped by map). + public List cmds = new(); + public List? tmpCmds; public int lastJoinPointAtTick = -1; public List syncInfos = new(); @@ -25,7 +27,7 @@ public class WorldData private TaskCompletionSource? dataSource; - public bool CreatingJoinPoint => tmpMapCmds != null; + public bool CreatingJoinPoint => tmpCmds != null; public MultiplayerServer Server { get; } @@ -70,7 +72,7 @@ public bool TryStartJoinPointCreation(bool force = false, ServerPlayer? sourcePl Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, Array.Empty(), sourcePlayer: Server.IsStandaloneServer ? issuingPlayer : null); - tmpMapCmds = new Dictionary>(); + tmpCmds = new List(); dataSource = new TaskCompletionSource(); return true; @@ -80,8 +82,8 @@ public void EndJoinPointCreation() { int currentTick = CurrentJoinPointTick; ServerLog.Detail($"Join point completed at tick={currentTick}, standalone={Server.IsStandaloneServer}"); - mapCmds = tmpMapCmds!; - tmpMapCmds = null; + cmds = tmpCmds!; + tmpCmds = null; lastJoinPointAtTick = currentTick; if (Server.IsStandaloneServer && Server.persistence != null) @@ -105,7 +107,7 @@ public void AbortJoinPointCreation() if (!CreatingJoinPoint) return; - tmpMapCmds = null; + tmpCmds = null; dataSource?.SetResult(this); dataSource = null; } diff --git a/Source/Tests/ChatCommandManagerTest.cs b/Source/Tests/ChatCommandManagerTest.cs index 6dc33c554..1b20b8b20 100644 --- a/Source/Tests/ChatCommandManagerTest.cs +++ b/Source/Tests/ChatCommandManagerTest.cs @@ -483,7 +483,7 @@ public void TimeControlCommands_RespectHostOnlyTimeControlSetting() server.HandleChatCommand(guest, "pause"); - Assert.That(server.worldData.mapCmds.ContainsKey(ScheduledCommand.Global), Is.False); + Assert.That(server.worldData.cmds, Is.Empty); Assert.That(((RecordingConnection)guest.conn).ChatMessages, Does.Contain("No permission")); } @@ -565,7 +565,7 @@ private static ServerPlayer AddPlayingPlayer( private static ScheduledCommand LastGlobalCommand(MultiplayerServer server) { - return ScheduledCommand.Deserialize(new ByteReader(server.worldData.mapCmds[ScheduledCommand.Global].Last())); + return ScheduledCommand.Deserialize(new ByteReader(server.worldData.cmds.Last(cmd => ScheduledCommand.Deserialize(new ByteReader(cmd)).mapId == ScheduledCommand.Global))); } private static ClientInitDataPacket.ModData ModData(string name, string packageId) diff --git a/Source/Tests/CommandLogTest.cs b/Source/Tests/CommandLogTest.cs new file mode 100644 index 000000000..e874fc159 --- /dev/null +++ b/Source/Tests/CommandLogTest.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using System.Linq; +using Multiplayer.Common; + +namespace Tests; + +/// Joiners must execute exactly the command sequence live clients executed (same order across tickables) +[TestFixture] +public class CommandLogTest +{ + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + nextPlayerId = 0; + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private (ServerPlayer player, RecordingConnection conn) AddPlayer(string username, ConnectionStateEnum state) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn) { hasJoined = true }; + conn.serverPlayer = player; + conn.ChangeState(state); + server.playerManager.Players.Add(player); + return (player, conn); + } + + private void SendCmd(int mapId, byte marker) => + server.commands.Send(CommandType.Sync, 0, mapId, [marker]); + + private static List<(int mapId, byte marker)> Decode(IEnumerable cmds) => + cmds.Select(raw => ScheduledCommand.Deserialize(new ByteReader(raw))) + .Where(cmd => cmd.type == CommandType.Sync) + .Select(cmd => (cmd.mapId, cmd.data[0])) + .ToList(); + + [Test] + public void CommandsAreLoggedInSendOrderAcrossTickables() + { + var (_, conn) = AddPlayer("live", ConnectionStateEnum.ServerPlaying); + + SendCmd(5, 1); + SendCmd(ScheduledCommand.Global, 2); + SendCmd(3, 3); + SendCmd(5, 4); + + var expected = new List<(int, byte)> { (5, 1), (ScheduledCommand.Global, 2), (3, 3), (5, 4) }; + Assert.That(Decode(server.worldData.cmds), Is.EqualTo(expected)); + + // Live clients receive the same order + var live = conn.SentMessages + .Where(m => m.id == Packets.Server_Command) + .Select(m => ScheduledCommand.Serialize(ToCmd(m.data))); + Assert.That(Decode(live), Is.EqualTo(expected)); + } + + [Test] + public void WorldDataContainsAllCommandsInLogOrder() + { + server.worldData.savedGame = [9]; + server.worldData.sessionData = [8]; + AddPlayer("live", ConnectionStateEnum.ServerPlaying); + + SendCmd(5, 1); + SendCmd(ScheduledCommand.Global, 2); + SendCmd(3, 3); + + var (joiner, joinerConn) = AddPlayer("joiner", ConnectionStateEnum.ServerPlaying); + new ServerLoadingState(joinerConn).SendWorldData(); + + var data = joinerConn.SentMessages.Single(m => m.id == Packets.Server_WorldData).data; + var reader = new ByteReader(data); + reader.ReadInt32(); // faction + reader.ReadInt32(); // timer + Assert.That(reader.ReadInt32(), Is.EqualTo(server.commands.SentCmds)); + reader.ReadBool(); // frozen + Assert.That(reader.ReadPrefixedBytes(), Is.EqualTo(new byte[] { 9 })); + Assert.That(reader.ReadPrefixedBytes(), Is.EqualTo(new byte[] { 8 })); + + int count = reader.ReadInt32(); + var cmds = Enumerable.Range(0, count).Select(_ => reader.ReadPrefixedBytes()!).ToList(); + Assert.That(Decode(cmds), Is.EqualTo(new List<(int, byte)> { (5, 1), (ScheduledCommand.Global, 2), (3, 3) })); + } + + [Test] + public void CompletedJoinPointKeepsOnlyCommandsAfterIt() + { + AddPlayer("live", ConnectionStateEnum.ServerPlaying); + + SendCmd(5, 1); + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + SendCmd(ScheduledCommand.Global, 2); + SendCmd(5, 3); + server.worldData.EndJoinPointCreation(); + + var cmds = server.worldData.cmds.Select(raw => ScheduledCommand.Deserialize(new ByteReader(raw))).ToList(); + Assert.That(cmds.Any(c => c.type == CommandType.CreateJoinPoint), Is.False); + Assert.That(Decode(server.worldData.cmds), Is.EqualTo(new List<(int, byte)> { (ScheduledCommand.Global, 2), (5, 3) })); + } + + [Test] + public void AbortedJoinPointKeepsTheFullLog() + { + AddPlayer("live", ConnectionStateEnum.ServerPlaying); + + SendCmd(5, 1); + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + SendCmd(ScheduledCommand.Global, 2); + server.worldData.AbortJoinPointCreation(); + + var types = server.worldData.cmds.Select(raw => ScheduledCommand.Deserialize(new ByteReader(raw)).type); + // The joiner replays the aborted CreateJoinPoint too, so it reloads at the same tick as live clients did + Assert.That(types, Is.EqualTo(new[] { CommandType.Sync, CommandType.CreateJoinPoint, CommandType.Sync })); + } + + private static ScheduledCommand ToCmd(byte[] packetData) + { + var packet = default(Multiplayer.Common.Networking.Packet.ServerCommandPacket); + packet.Bind(new Multiplayer.Common.Networking.Packet.PacketReader(new ByteReader(packetData))); + return packet.ToCommand(); + } +} diff --git a/Source/Tests/Helper/RecordingConnection.cs b/Source/Tests/Helper/RecordingConnection.cs index e219992a1..df8e0f8ab 100644 --- a/Source/Tests/Helper/RecordingConnection.cs +++ b/Source/Tests/Helper/RecordingConnection.cs @@ -7,6 +7,8 @@ namespace Tests; public class RecordingConnection : ConnectionBase { public List SentPackets { get; } = new(); + // Payloads of non-fragmented packets + public List<(Packets id, byte[] data)> SentMessages { get; } = new(); public RecordingConnection(string username) { @@ -21,6 +23,8 @@ protected override void SendRaw(byte[] raw, bool reliable) return; SentPackets.Add((Packets)(raw[0] & 0x3F)); + if ((raw[0] & 0xC0) == 0) + SentMessages.Add(((Packets)(raw[0] & 0x3F), raw[1..])); } protected override void OnClose(ServerDisconnectPacket? goodbye) { } diff --git a/Source/Tests/StandaloneMapStreamingTest.cs b/Source/Tests/StandaloneMapStreamingTest.cs index 6676875b1..95dbc7026 100644 --- a/Source/Tests/StandaloneMapStreamingTest.cs +++ b/Source/Tests/StandaloneMapStreamingTest.cs @@ -90,7 +90,7 @@ public void StandaloneStreamingDisabled_BroadcastsEvenWithWorldViewer() public void InitialPlayerCountMapReport_DoesNotSendMapResponse() { server.worldData.mapData[5] = [9, 9, 9]; - server.worldData.mapCmds[5] = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; + server.worldData.cmds = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; var (player, conn) = AddPlayer("player", -1, hasReportedCurrentMap: false); var state = player.conn.GetState()!; @@ -109,7 +109,7 @@ public void InitialPlayerCountMapReport_DoesNotSendMapResponse() public void WorldToMapTransition_DoesNotSendMapResponse() { server.worldData.mapData[5] = [9, 9, 9]; - server.worldData.mapCmds[5] = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; + server.worldData.cmds = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; var (player, conn) = AddPlayer("player", -2); var state = player.conn.GetState()!; @@ -127,7 +127,7 @@ public void WorldToMapTransition_DoesNotSendMapResponse() public void MapToMapTransition_DoesNotSendMapResponseWhenStreamingDisabled() { server.worldData.mapData[5] = [9, 9, 9]; - server.worldData.mapCmds[5] = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; + server.worldData.cmds = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; var (player, conn) = AddPlayer("player", 3); var state = player.conn.GetState()!; @@ -146,14 +146,14 @@ public void HandleDebug_IgnoredWhenDevModeDisabled() { server.gameTimer = 123; server.startingTimer = 5; - server.worldData.mapCmds[1] = [[1]]; + server.worldData.cmds = [[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(server.worldData.cmds, Has.Count.EqualTo(1)); Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_Debug)); } @@ -164,14 +164,14 @@ public void HandleDebug_AllowsPlayersWhenDevModeEnabled() server.settings.devModeScope = DevModeScope.Everyone; server.gameTimer = 123; server.startingTimer = 5; - server.worldData.mapCmds[1] = [[1]]; + server.worldData.cmds = [[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(server.worldData.cmds, Is.Empty); Assert.That(conn.SentPackets, Does.Contain(Packets.Server_Debug)); } } diff --git a/Source/Tests/StandalonePersistenceTest.cs b/Source/Tests/StandalonePersistenceTest.cs index dd421a361..fa2b39918 100644 --- a/Source/Tests/StandalonePersistenceTest.cs +++ b/Source/Tests/StandalonePersistenceTest.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Multiplayer.Common; namespace Tests; @@ -39,10 +40,10 @@ public void WriteJoinPoint_PersistsCommandsAndTickForReload() 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])); + // Map command first: the persisted log must keep the send order across tickables var mapCmd = ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 1234, 1, 7, 5, [11])); - server.worldData.mapCmds[ScheduledCommand.Global] = [worldCmd]; - server.worldData.mapCmds[7] = [mapCmd]; + var worldCmd = ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Sync, 1234, 1, ScheduledCommand.Global, 5, [10])); + server.worldData.cmds = [mapCmd, worldCmd]; server.persistence.WriteJoinPoint(server.worldData, 1234); @@ -60,18 +61,80 @@ public void WriteJoinPoint_PersistsCommandsAndTickForReload() 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.cmds, Is.EqualTo(new[] { mapCmd, worldCmd })); Assert.That(reloadedServer.worldData.standaloneWorldSnapshot.tick, Is.EqualTo(1234)); Assert.That(reloadedServer.worldData.standaloneMapSnapshots[7].tick, Is.EqualTo(1234)); } + [Test] + public void LoadInto_MergesLegacyPerMapCommandFilesByTick() + { + var persistence = new StandalonePersistence(tempDir); + persistence.EnsureDirectories(); + + var savedDir = Path.Combine(tempDir, "Saved"); + File.WriteAllBytes(Path.Combine(savedDir, "world.dat"), [1]); + File.WriteAllBytes(Path.Combine(savedDir, "maps", "7.dat"), [2]); + File.WriteAllBytes(Path.Combine(savedDir, "world_cmds.dat"), ScheduledCommand.SerializeCmds([ + Cmd(10, ScheduledCommand.Global, 1), + Cmd(20, ScheduledCommand.Global, 2), + ])); + File.WriteAllBytes(Path.Combine(savedDir, "maps", "7_cmds.dat"), ScheduledCommand.SerializeCmds([ + Cmd(5, 7, 3), + Cmd(10, 7, 4), + ])); + + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = persistence, + }; + + persistence.LoadInto(server); + + Assert.That(DataBytes(server.worldData.cmds), Is.EqualTo(new byte[] { 3, 1, 4, 2 })); + } + + [Test] + public void WriteJoinPoint_ReplacesLegacyCommandFiles() + { + var persistence = new StandalonePersistence(tempDir); + persistence.EnsureDirectories(); + + var savedDir = Path.Combine(tempDir, "Saved"); + File.WriteAllBytes(Path.Combine(savedDir, "world_cmds.dat"), ScheduledCommand.SerializeCmds([Cmd(1, ScheduledCommand.Global, 9)])); + File.WriteAllBytes(Path.Combine(savedDir, "maps", "7_cmds.dat"), ScheduledCommand.SerializeCmds([Cmd(1, 7, 9)])); + + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = persistence, + }; + server.worldData.savedGame = [1]; + server.worldData.mapData[7] = [2]; + server.worldData.cmds = [ScheduledCommand.Serialize(Cmd(50, 7, 1)), ScheduledCommand.Serialize(Cmd(50, ScheduledCommand.Global, 2))]; + + persistence.WriteJoinPoint(server.worldData, 50); + + Assert.That(File.Exists(Path.Combine(savedDir, "world_cmds.dat")), Is.False); + Assert.That(File.Exists(Path.Combine(savedDir, "maps", "7_cmds.dat")), Is.False); + + var reloaded = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(tempDir), + }; + reloaded.persistence.LoadInto(reloaded); + + Assert.That(DataBytes(reloaded.worldData.cmds), Is.EqualTo(new byte[] { 1, 2 })); + } + + private static ScheduledCommand Cmd(int ticks, int mapId, byte marker) => + new(CommandType.Sync, ticks, 0, mapId, 0, [marker]); + + private static byte[] DataBytes(List cmds) => + cmds.Select(cmd => ScheduledCommand.Deserialize(new ByteReader(cmd)).data[0]).ToArray(); + [Test] public void LoadInto_FallsBackToLatestReplaySectionTick() { From 85066f338c46fc2df40b73be19203a4a5dfef458 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:48:39 -0600 Subject: [PATCH 019/183] Keep thing filter edits out of the sender-only simulation - The hit points / quality / mental break range setters invoke the filter's settingsChangedCallback, which for storage rechecks the map's haulables (StorageSettings.TryNotifyChanged). The buffered field watch wrote the pending value and restored the real one through these setters every frame, and the slider itself calls them in the UI, so the haulables list changed on the sender only. In the interface, write the backing field only for the drawn (synced) filter or a storage filter; the callback runs everywhere when the synced field is applied. - When the storage tab's selection has no map no context is created and SetAllow/range edits ran locally. Block them instead. - TabStorageWrapper now supplies ITab_Storage.HiddenSpecialThingFilters(), so "Clear all" and category toggles leave hidden special filters (e.g. AllowFresh) alone like in vanilla. Co-Authored-By: Claude Opus 5.5 --- .../Client/Syncing/Game/SyncThingFilters.cs | 82 ++++++++++++++++--- .../Syncing/Game/ThingFilterContexts.cs | 4 + .../Client/Syncing/Game/ThingFilterMarkers.cs | 21 ++++- 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/Source/Client/Syncing/Game/SyncThingFilters.cs b/Source/Client/Syncing/Game/SyncThingFilters.cs index 33078da2d..7b98dab8a 100644 --- a/Source/Client/Syncing/Game/SyncThingFilters.cs +++ b/Source/Client/Syncing/Game/SyncThingFilters.cs @@ -8,53 +8,111 @@ namespace Multiplayer.Client; public static class SyncThingFilters { [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetAllow), [typeof(StuffCategoryDef), typeof(bool)])] - static bool ThingFilter_SetAllow(StuffCategoryDef cat, bool allow) + static bool ThingFilter_SetAllow(ThingFilter __instance, StuffCategoryDef cat, bool allow) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; AllowStuffCat_Helper(ThingFilterMarkers.DrawnThingFilter, cat, allow); return false; } [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetAllow), [typeof(SpecialThingFilterDef), typeof(bool)])] - static bool ThingFilter_SetAllow(SpecialThingFilterDef sfDef, bool allow) + static bool ThingFilter_SetAllow(ThingFilter __instance, SpecialThingFilterDef sfDef, bool allow) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; AllowSpecial_Helper(ThingFilterMarkers.DrawnThingFilter, sfDef, allow); return false; } [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetAllow), [typeof(ThingDef), typeof(bool)])] - static bool ThingFilter_SetAllow(ThingDef thingDef, bool allow) + static bool ThingFilter_SetAllow(ThingFilter __instance, ThingDef thingDef, bool allow) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; AllowThing_Helper(ThingFilterMarkers.DrawnThingFilter, thingDef, allow); return false; } [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetAllow), [typeof(ThingCategoryDef), typeof(bool), typeof(IEnumerable), typeof(IEnumerable)])] - static bool ThingFilter_SetAllow(ThingCategoryDef categoryDef, bool allow) + static bool ThingFilter_SetAllow(ThingFilter __instance, ThingCategoryDef categoryDef, bool allow) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; AllowCategory_Helper(ThingFilterMarkers.DrawnThingFilter, categoryDef, allow); return false; } [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetAllowAll))] - static bool ThingFilter_SetAllowAll() + static bool ThingFilter_SetAllowAll(ThingFilter __instance) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; AllowAll_Helper(ThingFilterMarkers.DrawnThingFilter); return false; } [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetDisallowAll))] - static bool ThingFilter_SetDisallowAll() + static bool ThingFilter_SetDisallowAll(ThingFilter __instance) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; DisallowAll_Helper(ThingFilterMarkers.DrawnThingFilter); return false; } + // The range setters invoke settingsChangedCallback, which for storage rechecks the map's haulables. In the interface + // the setters are called by the watched sliders and the field watch's restore, so only write the backing field + // there to keep those side effects out of the sender's simulation. The callback runs when the synced field applies. + static bool RunRangeSetter(ThingFilter filter, out bool writeField) + { + writeField = false; + + if (!Multiplayer.InInterface) + return true; + + if (ThingFilterMarkers.IsUnsyncableFilter(filter)) + return false; + + if (ThingFilterMarkers.DrawnThingFilter?.Filter == filter || filter.settingsChangedCallback?.Target is StorageSettings) + { + writeField = true; + return false; + } + + return true; + } + + [MpPrefix(typeof(ThingFilter), "set_" + nameof(ThingFilter.AllowedHitPointsPercents))] + static bool ThingFilter_SetHitPoints(ThingFilter __instance, FloatRange value) + { + if (RunRangeSetter(__instance, out var writeField)) return true; + if (writeField) __instance.allowedHitPointsPercents = value; + return false; + } + + [MpPrefix(typeof(ThingFilter), "set_" + nameof(ThingFilter.AllowedQualityLevels))] + static bool ThingFilter_SetQuality(ThingFilter __instance, QualityRange value) + { + if (RunRangeSetter(__instance, out var writeField)) return true; + if (writeField) __instance.allowedQualities = value; + return false; + } + + [MpPrefix(typeof(ThingFilter), "set_" + nameof(ThingFilter.AllowedMentalBreakChance))] + static bool ThingFilter_SetMentalBreak(ThingFilter __instance, FloatRange value) + { + if (RunRangeSetter(__instance, out var writeField)) return true; + if (writeField) __instance.allowedMentalBreakChance = value; + return false; + } + [SyncMethod] static void AllowStuffCat_Helper(ThingFilterContext context, StuffCategoryDef cat, bool allow) { diff --git a/Source/Client/Syncing/Game/ThingFilterContexts.cs b/Source/Client/Syncing/Game/ThingFilterContexts.cs index 66d58b59f..61cb6e622 100644 --- a/Source/Client/Syncing/Game/ThingFilterContexts.cs +++ b/Source/Client/Syncing/Game/ThingFilterContexts.cs @@ -10,6 +10,10 @@ public record TabStorageWrapper(IStoreSettingsParent Storage) : ThingFilterConte { public override ThingFilter Filter => Storage.GetStoreSettings().filter; public override ThingFilter ParentFilter => Storage.GetParentStoreSettings()?.filter; + + // Same as the ones passed by ITab_Storage.FillTab (the method doesn't depend on the tab's state) + public override IEnumerable HiddenFilters => + ((ITab_Storage)InspectTabManager.GetSharedInstance(typeof(ITab_Storage))).HiddenSpecialThingFilters(); } public record BillConfigWrapper(Bill Bill) : ThingFilterContext diff --git a/Source/Client/Syncing/Game/ThingFilterMarkers.cs b/Source/Client/Syncing/Game/ThingFilterMarkers.cs index 8f9599a0c..2317cd81a 100644 --- a/Source/Client/Syncing/Game/ThingFilterMarkers.cs +++ b/Source/Client/Syncing/Game/ThingFilterMarkers.cs @@ -23,22 +23,39 @@ public static ThingFilterContext DrawnThingFilter } } + // The filter of a storage which can't be synced, edits to it are blocked instead of being applied locally + private static ThingFilter unsyncableFilter; + + public static bool IsUnsyncableFilter(ThingFilter filter) => + drawingThingFilter && filter != null && filter == unsyncableFilter; + [MpPrefix(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] static void TabStorageFillTab_Prefix(ITab_Storage __instance) { var selThing = __instance.SelObject; var selParent = __instance.SelStoreSettingsParent; // If SelStoreSettingsParent is null, just return early. There'll be nothing to sync. + if (selParent == null) + return; + // The map could potentially be null - for example, if we're syncing mortar. The mortar gun itself // holds the store settings, and turret guns don't have a map/location assigned - so we sync their parent. // Because of that, we check if the parent is not null. - if (selParent == null || selThing is Thing { Map: null } or ThingComp { parent: { Map: null } }) + if (selThing is Thing { Map: null } or ThingComp { parent: { Map: null } }) + { + unsyncableFilter = selParent.GetStoreSettings()?.filter; return; + } + DrawnThingFilter = new TabStorageWrapper(selParent); } [MpFinalizer(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] - static void TabStorageFillTab_Finalizer() => DrawnThingFilter = null; + static void TabStorageFillTab_Finalizer() + { + DrawnThingFilter = null; + unsyncableFilter = null; + } [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] static void BillConfig_Prefix(Dialog_BillConfig __instance) => From ec1900b77cfd61c4292dfce61d6eb4524f99d53d Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:49:33 -0600 Subject: [PATCH 020/183] Validate command size before adding it to the log Client command data was allowed up to 65535 bytes, but the server's rebroadcast adds a 17 byte header and Send throws above MaxSinglePacketSize. The throw happened after the command was appended to the log, so joiners executed a command live clients never received. Command data is now capped at what fits the rebroadcast (ServerCommandPacket.MaxDataLength) for both packet directions, and CommandHandler.Send rejects oversized data before logging anything. Co-Authored-By: Claude Opus 5.5 --- Source/Common/CommandHandler.cs | 7 +++ .../Common/Networking/Packet/CommandPacket.cs | 10 +++- Source/Tests/CommandLogTest.cs | 49 ++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/Source/Common/CommandHandler.cs b/Source/Common/CommandHandler.cs index c51cbe2e8..021a34745 100644 --- a/Source/Common/CommandHandler.cs +++ b/Source/Common/CommandHandler.cs @@ -37,6 +37,13 @@ public void Send(CommandType cmdType, int factionId, int mapId, byte[] data, Ser return; } + // Checked before logging: a command in the log that live clients never received desyncs every joiner + if (data.Length > ServerCommandPacket.MaxDataLength) + { + ServerLog.Error($"Dropped {cmdType} command with too much data ({data.Length} > {ServerCommandPacket.MaxDataLength})"); + return; + } + var cmd = new ScheduledCommand( cmdType, server.gameTimer, diff --git a/Source/Common/Networking/Packet/CommandPacket.cs b/Source/Common/Networking/Packet/CommandPacket.cs index ddbe1a5f2..c977a4550 100644 --- a/Source/Common/Networking/Packet/CommandPacket.cs +++ b/Source/Common/Networking/Packet/CommandPacket.cs @@ -3,6 +3,11 @@ [PacketDefinition(Packets.Server_Command)] public record struct ServerCommandPacket : IPacket { + // type + ticks + factionId + mapId + playerId + private const int HeaderSize = 1 + 4 * 4; + // Largest data that still fits the rebroadcast in a single packet + public const int MaxDataLength = ConnectionBase.MaxSinglePacketSize - HeaderSize; + public CommandType type; public int ticks; public int factionId; @@ -30,7 +35,7 @@ public void Bind(PacketBuffer buf) buf.Bind(ref factionId); buf.Bind(ref mapId); buf.Bind(ref playerId); - buf.BindRemaining(ref data, maxLength: 65535); + buf.BindRemaining(ref data, maxLength: MaxDataLength); } } @@ -45,6 +50,7 @@ public void Bind(PacketBuffer buf) { buf.BindEnum(ref type); buf.Bind(ref mapId); - buf.BindRemaining(ref data, maxLength: 65535); + // Capped by what the server can rebroadcast, see ServerCommandPacket + buf.BindRemaining(ref data, maxLength: ServerCommandPacket.MaxDataLength); } } diff --git a/Source/Tests/CommandLogTest.cs b/Source/Tests/CommandLogTest.cs index e874fc159..3ee33ff0d 100644 --- a/Source/Tests/CommandLogTest.cs +++ b/Source/Tests/CommandLogTest.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; namespace Tests; @@ -127,10 +128,54 @@ public void AbortedJoinPointKeepsTheFullLog() Assert.That(types, Is.EqualTo(new[] { CommandType.Sync, CommandType.CreateJoinPoint, CommandType.Sync })); } + [Test] + public void LargestAllowedCommandIsLoggedAndBroadcast() + { + var (_, conn) = AddPlayer("live", ConnectionStateEnum.ServerPlaying); + + server.commands.Send(CommandType.Sync, 0, 1, new byte[ServerCommandPacket.MaxDataLength]); + + Assert.That(server.worldData.cmds, Has.Count.EqualTo(1)); + Assert.That(conn.SentPackets, Does.Contain(Packets.Server_Command)); + } + + [Test] + public void TooLargeCommandIsNeitherLoggedNorBroadcast() + { + var (_, conn) = AddPlayer("live", ConnectionStateEnum.ServerPlaying); + var errors = new List(); + var prevError = ServerLog.error; + ServerLog.error = errors.Add; + + try + { + server.commands.Send(CommandType.Sync, 0, 1, new byte[ServerCommandPacket.MaxDataLength + 1]); + } + finally + { + ServerLog.error = prevError; + } + + Assert.That(server.worldData.cmds, Is.Empty); + Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_Command)); + Assert.That(server.commands.SentCmds, Is.EqualTo(0)); + Assert.That(errors, Has.Count.EqualTo(1)); + } + + [Test] + public void ClientCommandDataIsCappedAtRebroadcastLimit() + { + var ok = new ClientCommandPacket(CommandType.Sync, 1, new byte[ServerCommandPacket.MaxDataLength]); + Assert.DoesNotThrow(() => ok.Serialize()); + + var tooBig = new ClientCommandPacket(CommandType.Sync, 1, new byte[ServerCommandPacket.MaxDataLength + 1]); + Assert.Catch(() => tooBig.Serialize()); + } + private static ScheduledCommand ToCmd(byte[] packetData) { - var packet = default(Multiplayer.Common.Networking.Packet.ServerCommandPacket); - packet.Bind(new Multiplayer.Common.Networking.Packet.PacketReader(new ByteReader(packetData))); + var packet = default(ServerCommandPacket); + packet.Bind(new PacketReader(new ByteReader(packetData))); return packet.ToCommand(); } } From de1999a25e84c0330948a88fcfe831f2bdc1a5b5 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:49:34 -0600 Subject: [PATCH 021/183] Make growth moment sessions survive the child leaving the map The session's map was letter.pawn.Map, so once the child left the map (caravan, transport pod, death, kidnapping) Tick() threw inside TickMapSessions every tick, GetSessionFor threw and the session could never be removed. The window's PostClose also ticked the session from UI code, which could remove it on one client only. - Store the session's map (saved sessions get it from the loader) and treat a child that's dead or not on that map as an invalid session, removed deterministically by the session's tick. - Look sessions up on every map, never through pawn.Map. - PostClose only reopens the window (UI), it no longer ticks the session. - The timeout window opened by the tick is only shown to players of the child's faction. - Generate the letter's options when the session is created on every client, instead of only on clients that open the window. - SessionManager.TickSessions catches and logs exceptions per session so one broken session doesn't skip the rest of the map/world tick. Co-Authored-By: Claude Opus 5.5 --- .../Client/Persistent/GrowthMomentSession.cs | 61 ++++++++++++++----- Source/Client/Persistent/SessionManager.cs | 17 +++++- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/Source/Client/Persistent/GrowthMomentSession.cs b/Source/Client/Persistent/GrowthMomentSession.cs index 3c348ccdc..a0caa10f5 100644 --- a/Source/Client/Persistent/GrowthMomentSession.cs +++ b/Source/Client/Persistent/GrowthMomentSession.cs @@ -7,10 +7,19 @@ namespace Multiplayer.Client.Persistent; -public class GrowthMomentSession(Map _) : ExposableSession(null), ITickingSession +public class GrowthMomentSession(Map map) : ExposableSession(null), ITickingSession { - public static GrowthMomentSession GetSessionFor(Pawn pawn) => - pawn.Map.MpComp().sessionManager.GetFirstOfType(sess => sess.Pawn == pawn); + // The child may have left the session's map (caravan, transport pod, death), so look on every map + public static GrowthMomentSession GetSessionFor(Pawn pawn) + { + if (pawn == null) return null; + + foreach (var m in Find.Maps) + if (m.MpComp()?.sessionManager.GetFirstOfType(sess => sess.Pawn == pawn) is { } session) + return session; + + return null; + } // There isn't a trait selected. public const int NullTraitIdx = -1; // intentionally the same as the "not found" return value for IndexOf @@ -18,31 +27,37 @@ public static GrowthMomentSession GetSessionFor(Pawn pawn) => // There's a chance to have a trait choice of "No Trait". It's represented by this public const int NoTraitTraitIdx = -2; + // Stored explicitly: the session is ticked by and pauses this map, even if the child leaves it + private Map map = map; private ChoiceLetter_GrowthMoment letter; - public Pawn Pawn => letter.pawn; + public Pawn Pawn => letter?.pawn; public int traitIdx = NullTraitIdx; public List passionIndexes = []; public bool uiDirty; // if true, received or sent an update - public static GrowthMomentSession Create(ChoiceLetter_GrowthMoment letter) => new(null) + public static GrowthMomentSession Create(ChoiceLetter_GrowthMoment letter) => new(letter.pawn.Map) { letter = letter }; // LetterWithTimeout.LastTickBeforeTimeout adjusted to use the map's time instead of Find.TickManager.TicksGame - public override bool IsCurrentlyPausing(Map map) => Map == map && letter.TimeoutActive && - letter.disappearAtTick <= map.AsyncTime().mapTicks + 1 && - !letter.ArchiveView; + private bool AtTimeout => letter.TimeoutActive && letter.disappearAtTick <= map.AsyncTime().mapTicks + 1; + + public override bool IsCurrentlyPausing(Map map) => this.map == map && IsSessionValid && AtTimeout; - public override bool IsSessionValid => !letter.ArchiveView; + // The child leaving the map (or dying) ends the session, the letter's timeout handles the rest + public override bool IsSessionValid => map != null && Pawn is { Dead: false } pawn && pawn.Map == map && !letter.ArchiveView; public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { - if (Map != entry.map) return null; + if (Map != entry.map || !IsSessionValid) return null; return new FloatMenuOption("MpGrowthMomentSession".Translate(Pawn.Name.ToStringShort), OpenWindow); } - public override Map Map => letter.pawn.Map; + public override Map Map => map; + + // Windows are only for the players of the child's faction + private bool IsLocalPlayersChild => Pawn?.Faction == Multiplayer.RealPlayerFaction; [SyncMethod] public void UpdateChoices(int traitIdx, List passionIndexes) @@ -62,8 +77,15 @@ public void OpenWindow() public void Tick() { - if (!IsSessionValid) Map.MpComp().sessionManager.RemoveSession(this); - else if (letter.TimeoutActive && letter.disappearAtTick <= Map.AsyncTime().mapTicks + 1) OpenWindow(); + if (!IsSessionValid) map.MpComp().sessionManager.RemoveSession(this); + else if (AtTimeout && IsLocalPlayersChild) OpenWindow(); + } + + // UI only: reopen the window if it was closed while the map waits for the choice + public void ReopenIfWaiting() + { + if (IsSessionValid && AtTimeout && IsLocalPlayersChild) + OpenWindow(); } public override void ExposeData() @@ -80,6 +102,8 @@ public static GrowthMomentSession TryAddSession(ChoiceLetter_GrowthMoment letter { if (letter.ArchiveView) return null; var pawn = letter.pawn; + // Only children on a map can have a session (it pauses and is ticked by that map) + if (pawn?.Map == null) return null; var sessionManager = pawn.Map.MpComp().sessionManager; var sess = sessionManager.GetFirstOfType(sess => sess.Pawn == pawn); if (sess == null) @@ -92,6 +116,10 @@ public static GrowthMomentSession TryAddSession(ChoiceLetter_GrowthMoment letter $"Failed trying to created a session of type {nameof(GrowthMomentSession)} - prior session did not exist and creating session failed."); return null; } + + // Generate the options now on every client. Otherwise they'd only be generated by clients + // opening the window, at different times and so possibly from a different pawn state. + letter.TrySetChoices(); } return sess; @@ -151,7 +179,12 @@ private void UpdateChoicesFromSession() session.uiDirty = false; } - public override void PostClose() => Session?.Tick(); + public override void PostClose() + { + base.PostClose(); + // Don't tick the session from here, UI code mustn't change the simulation + Session?.ReopenIfWaiting(); + } [MpPrefix(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), [typeof(Letter), typeof(string), typeof(int), typeof(bool)])] diff --git a/Source/Client/Persistent/SessionManager.cs b/Source/Client/Persistent/SessionManager.cs index 5f6dad6ee..c72a1a0af 100644 --- a/Source/Client/Persistent/SessionManager.cs +++ b/Source/Client/Persistent/SessionManager.cs @@ -120,11 +120,26 @@ private Session GetFirstConflictingSession(Session session) /// /// Ticks over . It iterates backwards using a loop to make it safe for the sessions to remove themselves when ticking. + /// A failing session is logged and doesn't stop the other sessions (or the rest of the map/world tick). /// public void TickSessions() { for (int i = tickingSessions.Count - 1; i >= 0; i--) - tickingSessions[i].Tick(); + { + // A session's tick may remove more than one session + if (i >= tickingSessions.Count) + continue; + + var session = tickingSessions[i]; + try + { + session.Tick(); + } + catch (Exception e) + { + Log.ErrorOnce($"Exception ticking session {session}: {e}", session.GetHashCode() ^ 0x3a5f19); + } + } } public T GetFirstOfType() where T : Session => allSessions.OfType().FirstOrDefault(); From dce32db0169a20f928a19987154c026ca31e9d57 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:50:11 -0600 Subject: [PATCH 022/183] Sync colonist bar reordering with pawns resolved on the sender The reorder was synced as the ColonistBar.Entry closure, which only captures the entry group and then calls Find.ColonistBar.Reorder(from, to, group) on each client. The indices and group were resolved against every client's own cached bar entries, which differ in multifaction, with hidden maps, or when the receiver hasn't recached its bar yet, so the saved displayOrder diverged. Intercept ColonistBar.Reorder in the interface instead, resolve the dragged pawn, the target pawn and the group's pawns from the local bar and sync those. The synced method applies the vanilla reorder rule to the group and all pawns of the faction (a deterministic superset of the bar's pawns). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/Game/SyncDelegates.cs | 61 ++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index 63fc86aae..82e74ed7c 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -280,8 +280,7 @@ 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)]); + // Colonist bar reordering is synced through ColonistBarReorder below InitRituals(); InitChoiceLetters(); @@ -461,6 +460,64 @@ static void PickRandomTraitAndPassions(ChoiceLetter_GrowthMoment letter) Find.LetterStack.RemoveLetter(letter); } + // The reorder indices and the group are relative to the local colonist bar's entries, which differ between + // clients (multifaction, hidden maps, entries not yet recached), so resolve them to pawns on the sender. + [MpPrefix(typeof(ColonistBar), nameof(ColonistBar.Reorder))] + static bool ColonistBarReorder(ColonistBar __instance, int from, int to, int entryGroup) + { + if (!Multiplayer.ShouldSync) + return true; + + var group = new List(); + foreach (var entry in __instance.cachedEntries) + if (entry.group == entryGroup && entry.pawn != null) + group.Add(entry.pawn); + + if (from >= 0 && from < group.Count) + { + bool toEnd = to < 0 || to >= group.Count; + SyncedColonistBarReorder(group[from], toEnd ? null : group[to], toEnd, group); + } + + return false; + } + + [SyncMethod] + static void SyncedColonistBarReorder(Pawn dragged, Pawn target, bool toEnd, List group) + { + group.RemoveAll(p => p?.playerSettings == null); + if (dragged?.playerSettings == null || !toEnd && target?.playerSettings == null || group.Count == 0) + return; + + int newOrder = toEnd ? group[group.Count - 1].playerSettings.displayOrder + 1 : target.playerSettings.displayOrder; + + // Same as ColonistBar.Reorder, but instead of the local bar's entries it shifts the group and all the faction's + // pawns. Shifting pawns which aren't in the bar keeps the relative order, so the bar's order is the same. + var pawns = new List(group); + var seen = new HashSet(group); + foreach (var p in PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive) + if (p.playerSettings != null && p.Faction == Faction.OfPlayer && seen.Add(p)) + pawns.Add(p); + + foreach (var p in pawns) + { + var settings = p.playerSettings; + if (settings.displayOrder == newOrder) + { + if (!toEnd && group.Contains(p)) + settings.displayOrder += p.thingIDNumber < target.thingIDNumber ? -1 : 1; + } + else if (settings.displayOrder > newOrder) + settings.displayOrder++; + else + settings.displayOrder--; + } + + dragged.playerSettings.displayOrder = newOrder; + Find.ColonistBar.MarkColonistsDirty(); + MainTabWindowUtility.NotifyAllPawnTables_PawnsChanged(); + } + // The letter tries to generate the options when opened. Make the picks seeded, so all players will get the same ones. [MpPrefix(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.TrySetChoices))] static void PreLetterChoices(ChoiceLetter_GrowthMoment __instance, out bool __state) From 6e8249c0f7c38fb4b5464a1d1dfedcc6861eca55 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:50:29 -0600 Subject: [PATCH 023/183] Apply deterministic HashCode.Combine patches early and always The int-only System.HashCode.Combine patches switched between the random-seeded and the deterministic hash on Multiplayer.Client != null, so the hash function changed under hash containers that were alive across connecting/disconnecting, and containers filled during def loading kept a per-process order. Apply them as early patches (before def loading) and unconditionally. Also document why Tradeable's identity hash is acceptable. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/HashCodes.cs | 37 ++++++++++++------------------ 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/Source/Client/Patches/HashCodes.cs b/Source/Client/Patches/HashCodes.cs index 497fd75f8..b10ab5ab9 100644 --- a/Source/Client/Patches/HashCodes.cs +++ b/Source/Client/Patches/HashCodes.cs @@ -82,7 +82,9 @@ static IEnumerable Transpiler(IEnumerable inst static int CombineHashes(int seed, Map map) => Gen.HashCombineInt(seed, map?.uniqueID ?? -1); } - // todo does this cause issues? + // Vanilla hashes the first thing, which changes while trading. The identity hash differs between clients, which + // is fine as long as Tradeables are only kept in hash containers locally (TradingUI, buffered sync fields) and + // the simulation keeps them in lists (TradeDeal). Don't iterate a hash container of Tradeables in synced code. [HarmonyPatch(typeof(Tradeable), nameof(Tradeable.GetHashCode))] static class TradeableHashCode { @@ -95,6 +97,11 @@ static void Postfix(Tradeable __instance, ref int __result) } // Have created a patch that will handle 2-8 System.HashCode.Combine functions. + // System.HashCode uses a random per-process seed. These patches make the all-int overloads deterministic. + // They're applied early (before def loading) and unconditionally so that the hash function doesn't change while + // hash containers are alive: switching it only while connected broke lookups in containers filled before + // connecting, and left containers filled during def loading in a per-process order. + // Combine with other type arguments and HashCode.Add/ToHashCode still use the random seed. // TODO: Check the following: // TileQueryParams.GetHashCode // UnmanagedGridTraverseParams.GetHashCode @@ -126,6 +133,7 @@ internal static MethodBase GetCombineIntMethod(int numOfInts) } // ─── PATCH 2 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine2Patch { @@ -133,15 +141,13 @@ static class Combine2Patch static bool Prefix(int value1, int value2, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = Gen.HashCombineInt(value1, value2); return false; } } // ─── PATCH 3 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine3Patch { @@ -149,15 +155,13 @@ static class Combine3Patch static bool Prefix(int value1, int value2, int value3, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = DeterministicHash.HashCombineInt(value1, value2, value3); return false; } } // ─── PATCH 4 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine4Patch { @@ -165,15 +169,13 @@ static class Combine4Patch static bool Prefix(int value1, int value2, int value3, int value4, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = Gen.HashCombineInt(value1, value2, value3, value4); return false; } } // ─── PATCH 5 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine5Patch { @@ -181,15 +183,13 @@ static class Combine5Patch static bool Prefix(int value1, int value2, int value3, int value4, int value5, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, value5); return false; } } // ─── PATCH 6 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine6Patch { @@ -197,9 +197,6 @@ static class Combine6Patch static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, value5, value6); return false; @@ -207,6 +204,7 @@ static bool Prefix(int value1, int value2, int value3, int value4, int value5, i } // ─── PATCH 7 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine7Patch { @@ -214,9 +212,6 @@ static class Combine7Patch static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, int value7, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, value5, value6, value7); return false; @@ -224,6 +219,7 @@ static bool Prefix(int value1, int value2, int value3, int value4, int value5, i } // ─── PATCH 8 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine8Patch { @@ -231,9 +227,6 @@ static class Combine8Patch static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, int value7, int value8, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, value5, value6, value7, value8); return false; From dba4ff22271cebda0c845819fe02f1acb8e76412 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:50:54 -0600 Subject: [PATCH 024/183] Validate remote lengths before allocating A length prefix claiming int.MaxValue made the host try to allocate 2 GB before noticing the data isn't there: - ByteReader.ReadRaw checks 0 <= len <= Left before allocating (all prefixed byte reads go through it); SubArray validates its range. - ReadPrefixedInts/UInts/ULongs/Strings check the count against the remaining bytes. - PacketReader.BindBytes honours maxLength (it ignored it, so the Gzipped mod list limit from #878 never applied). The writer checks it too. Desync traces used the 32 KiB default unknowingly and now explicitly allow up to the fragmented packet size. - List/Dictionary binds don't preallocate more than the remaining data. - Gzipped caps the decompressed size (64 MiB default, also for the mod list with synced configs) against decompression bombs. Large legit payloads (world data, snapshots, saves, traces) are bounded by the packet size limits only, as before. No wire format change. Co-Authored-By: Claude Opus 5.5 --- Source/Common/ByteReader.cs | 14 ++ Source/Common/Networking/Packet/IPacket.cs | 33 +++- .../Networking/Packet/InitDataPacket.cs | 4 +- .../Common/Networking/Packet/TracesPacket.cs | 11 +- Source/Common/Util/Extensions.cs | 3 + Source/Tests/ReaderBoundsTest.cs | 141 ++++++++++++++++++ 6 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 Source/Tests/ReaderBoundsTest.cs diff --git a/Source/Common/ByteReader.cs b/Source/Common/ByteReader.cs index 4e87dbe43..b400ef86c 100644 --- a/Source/Common/ByteReader.cs +++ b/Source/Common/ByteReader.cs @@ -76,6 +76,9 @@ public virtual string ReadString(int maxLen = DefaultMaxStringLen) public virtual byte[] ReadRaw(int len) { + // Validate before allocating: the length usually comes from the remote side + if (len < 0 || len > Left) + throw new ReaderException($"Byte array length ({len}) out of range (0-{Left})"); return array.SubArray(IncrementIndex(len), len); } @@ -100,6 +103,7 @@ public virtual int[] ReadPrefixedInts(int maxLen = int.MaxValue) throw new ReaderException($"Int array length ({len}<0)"); if (len > maxLen) throw new ReaderException($"Int array too long ({len}>{maxLen})"); + CheckArrayLength(len, sizeof(int)); int[] result = new int[len]; for (int i = 0; i < len; i++) @@ -111,6 +115,7 @@ public virtual int[] ReadPrefixedInts(int maxLen = int.MaxValue) public virtual uint[] ReadPrefixedUInts() { int len = ReadInt32(); + CheckArrayLength(len, sizeof(uint)); uint[] result = new uint[len]; for (int i = 0; i < len; i++) result[i] = ReadUInt32(); @@ -120,6 +125,7 @@ public virtual uint[] ReadPrefixedUInts() public virtual ulong[] ReadPrefixedULongs() { int len = ReadInt32(); + CheckArrayLength(len, sizeof(ulong)); ulong[] result = new ulong[len]; for (int i = 0; i < len; i++) { result[i] = ReadULong(); @@ -130,12 +136,20 @@ public virtual ulong[] ReadPrefixedULongs() public virtual string?[] ReadPrefixedStrings() { int len = ReadInt32(); + CheckArrayLength(len, sizeof(int)); // Every string has at least its length prefix string?[] result = new string[len]; for (int i = 0; i < len; i++) result[i] = ReadString(); return result; } + // Validate a remote array length before allocating the array + private void CheckArrayLength(int len, int minElementSize) + { + if (len < 0 || len > Left / minElementSize) + throw new ReaderException($"Array length ({len}) out of range for {Left} remaining bytes"); + } + public virtual T ReadEnum() where T : Enum { Type type = Enum.GetUnderlyingType(typeof(T)); diff --git a/Source/Common/Networking/Packet/IPacket.cs b/Source/Common/Networking/Packet/IPacket.cs index 1276ef9df..7a5dc0dea 100644 --- a/Source/Common/Networking/Packet/IPacket.cs +++ b/Source/Common/Networking/Packet/IPacket.cs @@ -90,7 +90,11 @@ public static T Deserialize(this Binder binder, byte[] src) return obj; } - public static Binder Gzipped(this Binder inner, int maxLength = PacketBuffer.DefaultMaxLength) => + public const int DefaultMaxDecompressedLength = 1 << 26; + + /// maxLength limits the compressed bytes, maxDecompressedLength the inflated data (decompression bombs). + public static Binder Gzipped(this Binder inner, int maxLength = PacketBuffer.DefaultMaxLength, + int maxDecompressedLength = DefaultMaxDecompressedLength) => (PacketBuffer buf, ref T obj) => { if (buf.isWriting) @@ -114,12 +118,26 @@ public static Binder Gzipped(this Binder inner, int maxLength = PacketB using var outputStream = new MemoryStream(); using (var decompress = new DeflateStream(inputStream, CompressionMode.Decompress)) { - decompress.CopyTo(outputStream); + CopyLimited(decompress, outputStream, maxDecompressedLength); } var reader = new ByteReader(outputStream.ToArray()); inner(new PacketReader(reader), ref obj); } }; + + private static void CopyLimited(Stream source, Stream destination, int maxLength) + { + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + total += read; + if (total > maxLength) + throw new ReaderException($"Decompressed data too big (>{maxLength})"); + destination.Write(buffer, 0, read); + } + } } public abstract class PacketBuffer(bool isWriting) @@ -215,6 +233,7 @@ public sealed class PacketReader(ByteReader reader) : PacketBuffer(false) public override void BindBytes(ref byte[] obj, int maxLength = DefaultMaxLength) { int len = reader.ReadInt32(); + if (len > maxLength && maxLength != -1) throw new ReaderException($"Byte array too big ({len}>{maxLength})"); obj = reader.ReadRaw(len); } @@ -243,7 +262,10 @@ public override void Bind(ref List obj, Binder bind, int maxLength = De int len = reader.ReadInt32(); if (len > maxLength) throw new ReaderException($"List too big ({len}>{maxLength})"); - obj = new List(len); + if (len < 0) throw new ReaderException($"List length ({len}<0)"); + + // The length is remote data, don't preallocate more than the packet could hold + obj = new List(Math.Min(len, reader.Left)); for (var i = 0; i < len; i++) { var item = default(T); @@ -258,7 +280,9 @@ public override void Bind(ref Dictionary obj, Binder bindKey, Bin int len = reader.ReadInt32(); if (len > maxLength && maxLength != -1) throw new ReaderException($"Dictionary too big ({len}>{maxLength})"); - obj = new Dictionary(len); + if (len < 0) throw new ReaderException($"Dictionary length ({len}<0)"); + + obj = new Dictionary(Math.Min(len, reader.Left)); for (int i = 0; i < len; i++) { var key = default(K); @@ -310,6 +334,7 @@ public override void Bind(ref string obj, int maxLength = DefaultMaxLength) public override void BindBytes(ref byte[] obj, int maxLength = DefaultMaxLength) { + if (obj.Length > maxLength && maxLength != -1) throw new WriterException($"Byte array too big ({obj.Length}>{maxLength})"); writer.WriteInt32(obj.Length); writer.WriteRaw(obj); } diff --git a/Source/Common/Networking/Packet/InitDataPacket.cs b/Source/Common/Networking/Packet/InitDataPacket.cs index cf655b094..145d16517 100644 --- a/Source/Common/Networking/Packet/InitDataPacket.cs +++ b/Source/Common/Networking/Packet/InitDataPacket.cs @@ -20,6 +20,8 @@ public record struct ClientInitDataPacket : IPacket public const int MaxRawDataLength = 1 << 22; // rawMods additionally holds the length prefix of the compressed data public const int MaxRawModsLength = MaxRawDataLength + sizeof(int); + // Synced configs are plain text and compress well + public const int MaxDecompressedLength = 1 << 26; public string rwVersion; public int[] debugOnlySyncCmds; @@ -103,7 +105,7 @@ public void Bind(PacketBuffer buf) }; public static Binder> ListBinder => - BinderOf.List(BinderOf.Identity()).Gzipped(MaxRawDataLength); + BinderOf.List(BinderOf.Identity()).Gzipped(MaxRawDataLength, MaxDecompressedLength); } public record struct ModFile : IPacketBufferable diff --git a/Source/Common/Networking/Packet/TracesPacket.cs b/Source/Common/Networking/Packet/TracesPacket.cs index 7e48ad09d..97f41bb37 100644 --- a/Source/Common/Networking/Packet/TracesPacket.cs +++ b/Source/Common/Networking/Packet/TracesPacket.cs @@ -3,6 +3,9 @@ namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Client_Traces, allowFragmented: true)] public record struct ClientTracesPacket : IPacket { + // Compressed desync traces and jitted methods easily exceed the default limit, so only the packet size limits them + public const int MaxLength = ConnectionBase.MaxFragmentPacketTotalSize; + public int playerId; public byte[] rawTraces; public byte[] rawJittedMethods; @@ -10,8 +13,8 @@ public record struct ClientTracesPacket : IPacket public void Bind(PacketBuffer buf) { buf.Bind(ref playerId); - buf.BindBytes(ref rawTraces); - buf.BindBytes(ref rawJittedMethods); + buf.BindBytes(ref rawTraces, MaxLength); + buf.BindBytes(ref rawJittedMethods, MaxLength); } } @@ -55,8 +58,8 @@ public void Bind(PacketBuffer buf) } else { - buf.BindBytes(ref rawTraces); - buf.BindBytes(ref rawJittedMethods); + buf.BindBytes(ref rawTraces, ClientTracesPacket.MaxLength); + buf.BindBytes(ref rawJittedMethods, ClientTracesPacket.MaxLength); } } } diff --git a/Source/Common/Util/Extensions.cs b/Source/Common/Util/Extensions.cs index 332a96778..e3af7a987 100644 --- a/Source/Common/Util/Extensions.cs +++ b/Source/Common/Util/Extensions.cs @@ -59,6 +59,9 @@ public static T[] SubArray(this T[] data, int index) public static T[] SubArray(this T[] data, int index, int length) { + if (index < 0 || length < 0 || index > data.Length - length) + throw new ArgumentOutOfRangeException(nameof(length), $"Sub array {index}+{length} out of range ({data.Length})"); + T[] result = new T[length]; Array.Copy(data, index, result, 0, length); return result; diff --git a/Source/Tests/ReaderBoundsTest.cs b/Source/Tests/ReaderBoundsTest.cs new file mode 100644 index 000000000..e3752e8d0 --- /dev/null +++ b/Source/Tests/ReaderBoundsTest.cs @@ -0,0 +1,141 @@ +using System.IO.Compression; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +public class ReaderBoundsTest +{ + private static ByteReader LengthPrefixed(int len, int payload = 8) => + new(ByteWriter.GetBytes(len).Concat(new byte[payload]).ToArray()); + + [TestCase(int.MaxValue)] + [TestCase(9)] + [TestCase(-1)] + public void ReadRawRejectsLengthOutsideData(int len) + { + var reader = new ByteReader(new byte[8]); + Assert.Throws(() => reader.ReadRaw(len)); + Assert.That(reader.Position, Is.Zero); + } + + [Test] + public void ReadRawReadsExactLength() + { + var reader = new ByteReader([1, 2, 3, 4]); + Assert.That(reader.ReadRaw(4), Is.EqualTo(new byte[] { 1, 2, 3, 4 })); + Assert.That(reader.Left, Is.Zero); + } + + [Test] + public void PrefixedReadsRejectHugeLengths() + { + Assert.Throws(() => LengthPrefixed(int.MaxValue).ReadPrefixedBytes()); + Assert.Throws(() => LengthPrefixed(int.MaxValue).ReadPrefixedInts()); + Assert.Throws(() => LengthPrefixed(int.MaxValue).ReadPrefixedUInts()); + Assert.Throws(() => LengthPrefixed(int.MaxValue).ReadPrefixedULongs()); + Assert.Throws(() => LengthPrefixed(int.MaxValue).ReadPrefixedStrings()); + Assert.Throws(() => LengthPrefixed(-5).ReadPrefixedUInts()); + // 8 bytes hold 2 uints, not 3 + Assert.Throws(() => LengthPrefixed(3).ReadPrefixedUInts()); + Assert.That(LengthPrefixed(2).ReadPrefixedUInts(), Is.EqualTo(new uint[] { 0, 0 })); + } + + [Test] + public void BindBytesEnforcesMaxLength() + { + var bytes = new byte[100]; + var writer = new ByteWriter(); + new PacketWriter(writer).BindBytes(ref bytes, maxLength: -1); + + byte[] read = []; + Assert.Throws(() => new PacketReader(new ByteReader(writer.ToArray())).BindBytes(ref read, 99)); + + new PacketReader(new ByteReader(writer.ToArray())).BindBytes(ref read, 100); + Assert.That(read, Has.Length.EqualTo(100)); + + Assert.Throws(() => new PacketWriter(new ByteWriter()).BindBytes(ref bytes, 99)); + } + + [Test] + public void BindBytesRejectsLengthBeyondData() + { + byte[] read = []; + Assert.Throws(() => + new PacketReader(LengthPrefixed(int.MaxValue)).BindBytes(ref read, maxLength: -1)); + } + + [Test] + public void ListBindDoesNotPreallocateFromLength() + { + List list = []; + // A huge claimed count fails on the missing data instead of preallocating + Assert.Catch(() => + new PacketReader(LengthPrefixed(int.MaxValue - 1)).Bind(ref list, BinderOf.Int(), maxLength: int.MaxValue)); + } + + [Test] + public void GzippedRejectsDecompressionBomb() + { + var binder = BinderOf.Identity().Gzipped(maxLength: 1 << 20, maxDecompressedLength: 1 << 16); + + var small = new Blob { data = new byte[1000] }; + Assert.That(binder.Deserialize(binder.Serialize(small)).data, Has.Length.EqualTo(1000)); + + // Zeros compress to almost nothing + var bomb = new Blob { data = new byte[1 << 20] }; + var serialized = binder.Serialize(bomb); + Assert.That(serialized, Has.Length.LessThan(1 << 16)); + Assert.Throws(() => binder.Deserialize(serialized)); + } + + [Test] + public void ModListLimitsMatchBetweenInitAndJoinData() + { + // Random config contents, so that they don't compress too well + var random = new Random(0); + string RandomText(int bytes) + { + var data = new byte[bytes]; + random.NextBytes(data); + return Convert.ToBase64String(data); + } + + var mods = Enumerable.Range(0, 1000).Select(i => new ClientInitDataPacket.ModData + { + packageIdNonUnique = $"mod.{i}", + name = $"Mod {i}", + files = [new ClientInitDataPacket.ModFile { path = "Assemblies/Mod.dll", hash = i }], + config = new ClientInitDataPacket.ModConfig { fileName = "Mod_Mod.xml", contents = RandomText(1000) } + }).ToList(); + + var initData = new ClientInitDataPacket + { + rwVersion = "1.6", debugOnlySyncCmds = [], hostOnlySyncCmds = [], defInfos = [], Mods = mods + }; + // Bigger than the old 32 KiB and 512 KiB limits + Assert.That(initData.rawMods, Has.Length.GreaterThan(1 << 19)); + + var roundtrip = Roundtrip(initData); + var joinData = Roundtrip(new ServerJoinDataPacket + { + gameName = "", rwVersion = "", mpVersion = "", defStatus = [], rawServerInitData = roundtrip.rawMods + }); + Assert.That(joinData.ServerInitData, Has.Count.EqualTo(mods.Count)); + } + + private static T Roundtrip(T packet) where T : struct, IPacket + { + var writer = new ByteWriter(); + packet.Bind(new PacketWriter(writer)); + var result = default(T); + result.Bind(new PacketReader(new ByteReader(writer.ToArray()))); + return result; + } + + private struct Blob : IPacketBufferable + { + public byte[] data; + public void Bind(PacketBuffer buf) => buf.BindBytes(ref data, maxLength: -1); + } +} From ee6e9af9c6458bc6ee331ec2473b0723111a2fce Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:50:55 -0600 Subject: [PATCH 025/183] Look up portal/transporter sessions by their target, fix forming faction - MapPortalProxy found its session with GetFirstOfType although a map can have one portal session per portal, so a second portal's window edited the first portal's transferables. Look the session up by portal. - MapPortalSession.Map was portal.Map, null once the portal despawned (Remove() threw). Store the map; a despawned portal no longer pauses the map or offers the blocking-window option, and its window closes. - CreateTransporterLoadingSession reused any loading session on the map for a different group of transporters, and the proxy looked up the first one as well. Sessions are now matched by transporters, and the owner faction is taken from the transporters. - Caravan forming sessions created during ticks used Faction.OfPlayer, which is the spectator on maps without a player owner: every player's controls were disabled while the session paused the map. Use a player faction that has pawns on the map instead (multifaction only). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Comp/Map/MultiplayerMapComp.cs | 7 +++++- .../Persistent/CaravanFormingPatches.cs | 23 +++++++++++++++++- Source/Client/Persistent/MapPortalProxy.cs | 8 +++++-- Source/Client/Persistent/MapPortalSession.cs | 24 +++++++++++-------- .../Persistent/TransporterLoadingProxy.cs | 3 ++- .../Persistent/TransporterLoadingSession.cs | 8 +++++++ 6 files changed, 58 insertions(+), 15 deletions(-) diff --git a/Source/Client/Comp/Map/MultiplayerMapComp.cs b/Source/Client/Comp/Map/MultiplayerMapComp.cs index 5ba02cd63..8635b5fa2 100644 --- a/Source/Client/Comp/Map/MultiplayerMapComp.cs +++ b/Source/Client/Comp/Map/MultiplayerMapComp.cs @@ -53,9 +53,14 @@ public CaravanFormingSession CreateCaravanFormingSession(Faction faction, bool r public TransporterLoading CreateTransporterLoadingSession(Faction faction, List transporters) { - var transporterLoading = sessionManager.GetFirstOfType(); + // Several groups of transporters can be loaded at once, only reuse the session of this group + var transporterLoading = TransporterLoading.GetSessionFor(map, transporters); if (transporterLoading == null) { + // The transporters' owner, Faction.OfPlayer can be e.g. the spectator faction when called during a tick + if (transporters.FirstOrDefault()?.parent.Faction is { IsPlayer: true } owner) + faction = owner; + transporterLoading = new TransporterLoading(faction, map, transporters); if (!sessionManager.AddSession(transporterLoading)) { diff --git a/Source/Client/Persistent/CaravanFormingPatches.cs b/Source/Client/Persistent/CaravanFormingPatches.cs index 8a35de1f5..aa7709067 100644 --- a/Source/Client/Persistent/CaravanFormingPatches.cs +++ b/Source/Client/Persistent/CaravanFormingPatches.cs @@ -7,6 +7,7 @@ using UnityEngine; using Verse; using static Verse.Widgets; +using System.Linq; using System.Reflection; namespace Multiplayer.Client.Persistent @@ -176,7 +177,7 @@ static void Prefix(Dialog_FormCaravan __instance, Map map, bool reform, Action o { var comp = map.MpComp(); if (comp.sessionManager.GetFirstOfType() == null) - comp.CreateCaravanFormingSession(faction, reform, onClosed, mapAboutToBeRemoved, designatedMeetingPoint); + comp.CreateCaravanFormingSession(GetFormingFaction(map, faction), reform, onClosed, mapAboutToBeRemoved, designatedMeetingPoint); } else // Handles opening from the interface: forming gizmos, reforming gizmos and caravan hitching spots { @@ -184,6 +185,26 @@ static void Prefix(Dialog_FormCaravan __instance, Map map, bool reform, Action o } } + // Outside of commands Faction.OfPlayer is the map's owner or the spectator faction (e.g. on maps without + // a player owner), which would make the session unusable for everyone. Use a faction with pawns on the map. + private static Faction GetFormingFaction(Map map, Faction ofPlayer) + { + if (Multiplayer.ExecutingCmds || !Multiplayer.GameComp.multifaction) + return ofPlayer; + + bool HasPawnsOnMap(Faction f) => map.mapPawns.SpawnedPawnsInFaction(f).Any(p => p.RaceProps.Humanlike); + + var spectator = Multiplayer.WorldComp.spectatorFaction; + if (ofPlayer != spectator && HasPawnsOnMap(ofPlayer)) + return ofPlayer; + + foreach (var f in Find.FactionManager.AllFactionsListForReading) + if (f.IsPlayer && f != spectator && HasPawnsOnMap(f)) + return f; + + return ofPlayer; + } + [SyncMethod] internal static void StartFormingCaravan(Faction faction, Map map, bool reform = false, IntVec3? designatedMeetingPoint = null, PlanetTile? routePlannerWaypoint = null) { diff --git a/Source/Client/Persistent/MapPortalProxy.cs b/Source/Client/Persistent/MapPortalProxy.cs index 0dfb7fc7b..ade08d424 100644 --- a/Source/Client/Persistent/MapPortalProxy.cs +++ b/Source/Client/Persistent/MapPortalProxy.cs @@ -8,7 +8,8 @@ public class MapPortalProxy(MapPortal portal) : Dialog_EnterPortal(portal), ISwi public static MapPortalProxy drawing; public bool itemsReady = false; - public MapPortalSession Session => portal.Map.MpComp().sessionManager.GetFirstOfType(); + // Several portal sessions can exist on a map, find the one for this portal + public MapPortalSession Session => MapPortalSession.GetSessionFor(portal); public override void DoWindowContents(Rect inRect) { @@ -18,8 +19,11 @@ public override void DoWindowContents(Rect inRect) try { - if (session == null) + if (session == null || !portal.Spawned) + { Close(); + return; + } base.DoWindowContents(inRect); } diff --git a/Source/Client/Persistent/MapPortalSession.cs b/Source/Client/Persistent/MapPortalSession.cs index 8b41f2336..1f14693bf 100644 --- a/Source/Client/Persistent/MapPortalSession.cs +++ b/Source/Client/Persistent/MapPortalSession.cs @@ -10,24 +10,30 @@ public class MapPortalSession : ExposableSession, ISessionWithTransferables, ISe { public MapPortal portal; public List transferables; + // Stored explicitly as the portal's map becomes null if it despawns + private Map map; - public override Map Map => portal.Map; + public override Map Map => map; - public override bool IsSessionValid => portal != null; + public override bool IsSessionValid => portal != null && map != null; - public MapPortalSession(Map _) : base(null) + public MapPortalSession(Map map) : base(null) { // Mandatory constructor + this.map = map; } public static MapPortalSession Of(MapPortal portal) { - var session = new MapPortalSession(null); + var session = new MapPortalSession(portal.Map); session.portal = portal; session.AddItems(); return session; } + public static MapPortalSession GetSessionFor(MapPortal portal) => + portal?.MapHeld?.MpComp().sessionManager.GetFirstOfType(s => s.portal == portal); + private void AddItems() { var dialog = new MapPortalProxy(portal); @@ -38,11 +44,11 @@ private void AddItems() transferables = dialog.transferables; } - public override bool IsCurrentlyPausing(Map map) => Map == map; + public override bool IsCurrentlyPausing(Map map) => this.map == map && portal is { Spawned: true }; public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { - if (entry.map != Map) + if (entry.map != Map || portal is not { Spawned: true }) return null; return new FloatMenuOption("MpMapPortalSession".Translate(portal?.Label), () => @@ -54,9 +60,7 @@ public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry public static void OpenOrCreateSession(MapPortal portal) { - var session = portal.Map.MpComp().sessionManager.AllSessions - .OfType() - .FirstOrDefault(s => s.portal == portal); + var session = GetSessionFor(portal); if (session == null) CreateSession(portal); else @@ -90,7 +94,7 @@ public void TryAccept() public void Reset() => transferables.ForEach(t => t.CountToTransfer = 0); [SyncMethod] - public void Remove() => Map.MpComp().sessionManager.RemoveSession(this); + public void Remove() => map?.MpComp().sessionManager.RemoveSession(this); public void OpenWindow(bool sound = true) { diff --git a/Source/Client/Persistent/TransporterLoadingProxy.cs b/Source/Client/Persistent/TransporterLoadingProxy.cs index a5a5e0f39..549da8478 100644 --- a/Source/Client/Persistent/TransporterLoadingProxy.cs +++ b/Source/Client/Persistent/TransporterLoadingProxy.cs @@ -11,7 +11,8 @@ public class TransporterLoadingProxy : Dialog_LoadTransporters, ISwitchToMap public static TransporterLoadingProxy drawing; - public TransporterLoading Session => map.MpComp().sessionManager.GetFirstOfType(); + // Several transporter loading sessions can exist on a map, find the one for these transporters + public TransporterLoading Session => TransporterLoading.GetSessionFor(map, transporters); public TransporterLoadingProxy(Map map, List transporters) : base(map, transporters) { diff --git a/Source/Client/Persistent/TransporterLoadingSession.cs b/Source/Client/Persistent/TransporterLoadingSession.cs index 6c6ffabe0..f7715c934 100644 --- a/Source/Client/Persistent/TransporterLoadingSession.cs +++ b/Source/Client/Persistent/TransporterLoadingSession.cs @@ -111,6 +111,14 @@ public override void ExposeData() transporters = pods.Select(t => t.GetComp()).ToList(); } + // Sessions sharing a transporter are treated as the same session (as two sessions loading the same + // transporter would conflict), disjoint groups of transporters get their own sessions + public static TransporterLoading GetSessionFor(Map map, List transporters) => + map?.MpComp().sessionManager.GetFirstOfType(s => s.SharesTransporterWith(transporters)); + + public bool SharesTransporterWith(List others) => + transporters != null && others != null && transporters.Any(others.Contains); + public Transferable GetTransferableByThingId(int thingId) { return transferables.Find(tr => tr.things.Any(t => t.thingIDNumber == thingId)); From 523839234d46f4b6ce77d9b77a9de0f90405f363 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:51:54 -0600 Subject: [PATCH 026/183] Keep sync serializer reader and writer aligned - Writers of implementation-indexed types (SyncWithImpl, BindType, the ThingComp/HediffComp/AbilityComp/MapComponent/WorldObjectComp/ WorldComponent/GameComponent workers and ISyncSimple) wrote 0xFFFF for an unknown type and then the object's data anyway. The reader treats 0xFFFF as null and doesn't consume that data, misaligning the rest of the command. Throw on the writer instead, so the command isn't sent at all. - When an implicit sync worker returned false, the writer was truncated to the start of the worker, dropping the parent type's data already written, while the reader re-read the parent's data. Roll both back only to the position after the parent's data. Co-Authored-By: Claude Opus 5.5 --- .../Client/Syncing/Dict/SyncDictRimWorld.cs | 42 ++++++++---- Source/Client/Syncing/RwSerialization.cs | 5 +- Source/Common/Syncing/SyncTypeHelper.cs | 7 +- .../Syncing/Worker/ReadingSyncWorker.cs | 8 +-- .../Common/Syncing/Worker/SyncWorkerEntry.cs | 13 +++- .../Syncing/Worker/WritingSyncWorker.cs | 8 +-- Source/Tests/SerializationTest.cs | 67 ++++++++++++++++++- 7 files changed, 123 insertions(+), 27 deletions(-) diff --git a/Source/Client/Syncing/Dict/SyncDictRimWorld.cs b/Source/Client/Syncing/Dict/SyncDictRimWorld.cs index 865528ac2..21801e478 100644 --- a/Source/Client/Syncing/Dict/SyncDictRimWorld.cs +++ b/Source/Client/Syncing/Dict/SyncDictRimWorld.cs @@ -174,8 +174,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref HediffComp hediffComp) => { if (data.isWriting) { if (hediffComp != null) { - ushort index = (ushort)Array.IndexOf(hediffCompTypes, hediffComp.GetType()); - data.Write(index); + int index = Array.IndexOf(hediffCompTypes, hediffComp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {hediffComp.GetType()}, not in hediffCompTypes"); + data.Write((ushort)index); data.Write(hediffComp.parent); var tempComp = hediffComp; var compIndex = hediffComp.parent.comps.Where(x => x.props.compClass == tempComp.props.compClass).FirstIndexOf(x => x == tempComp); @@ -329,8 +331,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref AbilityComp comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(abilityCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(abilityCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in abilityCompTypes"); + data.Write((ushort)index); data.Write(comp.parent); } else { data.Write(ushort.MaxValue); @@ -868,8 +872,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref ThingComp comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(thingCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(thingCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in thingCompTypes"); + data.Write((ushort)index); data.Write(comp.parent); var tempComp = comp; var compIndex = comp.parent.AllComps.Where(x => x.props.compClass == tempComp.props.compClass).FirstIndexOf(x => x == tempComp); @@ -944,8 +950,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref MapComponent comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(mapCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(mapCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in mapCompTypes"); + data.Write((ushort)index); data.Write(comp.map); } else { data.Write(ushort.MaxValue); @@ -988,8 +996,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref WorldObjectComp comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(worldObjectCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(worldObjectCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in worldObjectCompTypes"); + data.Write((ushort)index); data.Write(comp.parent); } else { data.Write(ushort.MaxValue); @@ -1012,8 +1022,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref WorldComponent comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(worldCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(worldCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in worldCompTypes"); + data.Write((ushort)index); data.Write(comp.world); } else { data.Write(ushort.MaxValue); @@ -1095,8 +1107,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref GameComponent comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(gameCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(gameCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in gameCompTypes"); + data.Write((ushort)index); } else { data.Write(ushort.MaxValue); } diff --git a/Source/Client/Syncing/RwSerialization.cs b/Source/Client/Syncing/RwSerialization.cs index 840f78eaa..3bdbd63d8 100644 --- a/Source/Client/Syncing/RwSerialization.cs +++ b/Source/Client/Syncing/RwSerialization.cs @@ -106,7 +106,10 @@ public static void Init() }, (data, obj, _) => { - data.WriteUShort((ushort)ApiSerialization.syncSimples.FindIndex(obj!.GetType())); + int typeIndex = ApiSerialization.syncSimples.FindIndex(obj!.GetType()); + if (typeIndex == -1) + throw new SerializationException($"Unknown ISyncSimple type {obj.GetType()}"); + data.WriteUShort((ushort)typeIndex); foreach (var field in AccessTools.GetDeclaredFields(obj.GetType())) SyncSerialization.WriteSyncObject(data, field.GetValue(obj), field.FieldType); } diff --git a/Source/Common/Syncing/SyncTypeHelper.cs b/Source/Common/Syncing/SyncTypeHelper.cs index 21c141470..ad94069fc 100644 --- a/Source/Common/Syncing/SyncTypeHelper.cs +++ b/Source/Common/Syncing/SyncTypeHelper.cs @@ -12,7 +12,12 @@ public Type GetImplementationByIndex(Type baseType, ushort index) public ushort GetIndexFromImplementation(Type baseType, Type type) { - return (ushort)GetImplementations(baseType).IndexOf(type); + // Writing ushort.MaxValue (the null marker) followed by the object's data would misalign the reader + int index = GetImplementations(baseType).IndexOf(type); + if (index < 0 || index >= ushort.MaxValue) + throw new Multiplayer.Client.SerializationException($"Type {type} isn't a known implementation of {baseType}"); + + return (ushort)index; } public abstract List GetImplementations(Type baseType); diff --git a/Source/Common/Syncing/Worker/ReadingSyncWorker.cs b/Source/Common/Syncing/Worker/ReadingSyncWorker.cs index 204bf3797..6de87d616 100644 --- a/Source/Common/Syncing/Worker/ReadingSyncWorker.cs +++ b/Source/Common/Syncing/Worker/ReadingSyncWorker.cs @@ -8,8 +8,6 @@ namespace Multiplayer.Client public class ReadingSyncWorker(ByteReader reader, SyncSerialization serialization) : SyncWorker(false) { internal readonly ByteReader reader = reader; - readonly int initialPos = reader.Position; - public ByteReader Reader => reader; public override void Bind(ref T obj, SyncType type) @@ -96,9 +94,11 @@ public override void BindType(ref Type type) type = serialization.TypeHelper.GetImplementationByIndex(typeof(T), reader.ReadUShort()); } - internal void Reset() + internal int Position => reader.Position; + + internal void Reset(int position) { - reader.Seek(initialPos); + reader.Seek(position); } } } diff --git a/Source/Common/Syncing/Worker/SyncWorkerEntry.cs b/Source/Common/Syncing/Worker/SyncWorkerEntry.cs index 739eaefe1..6b82a75d3 100644 --- a/Source/Common/Syncing/Worker/SyncWorkerEntry.cs +++ b/Source/Common/Syncing/Worker/SyncWorkerEntry.cs @@ -96,14 +96,23 @@ public bool Invoke(SyncWorker worker, ref object? obj) { parent?.Invoke(worker, ref obj); + // A worker which returns false gets rolled back to this point, i.e. after the parent's data. Rolling back + // further would drop the parent's data on the writer but not on the reader. + int start = worker switch + { + ReadingSyncWorker reader => reader.Position, + WritingSyncWorker writer => writer.Position, + _ => 0 + }; + for (int i = 0; i < syncWorkers.Count; i++) { if (syncWorkers[i](worker, ref obj)) return true; if (worker is ReadingSyncWorker reader) { - reader.Reset(); + reader.Reset(start); } else if (worker is WritingSyncWorker writer) { - writer.Reset(); + writer.Reset(start); } } diff --git a/Source/Common/Syncing/Worker/WritingSyncWorker.cs b/Source/Common/Syncing/Worker/WritingSyncWorker.cs index 4acd24cae..2509e0bb4 100644 --- a/Source/Common/Syncing/Worker/WritingSyncWorker.cs +++ b/Source/Common/Syncing/Worker/WritingSyncWorker.cs @@ -8,8 +8,6 @@ namespace Multiplayer.Client public class WritingSyncWorker(ByteWriter writer, SyncSerialization serialization) : SyncWorker(true) { internal readonly ByteWriter writer = writer; - readonly int initialPos = writer.Position; - public ByteWriter Writer => writer; public override void Bind(ref T obj, SyncType type) @@ -95,9 +93,11 @@ public override void BindType(ref Type type) writer.WriteUShort(serialization.TypeHelper.GetIndexFromImplementation(typeof(T), type)); } - internal void Reset() + internal int Position => writer.Position; + + internal void Reset(int position) { - writer.SetLength(initialPos); + writer.SetLength(position); } } } diff --git a/Source/Tests/SerializationTest.cs b/Source/Tests/SerializationTest.cs index fa1ef1b63..b15c3d842 100644 --- a/Source/Tests/SerializationTest.cs +++ b/Source/Tests/SerializationTest.cs @@ -1,4 +1,5 @@ -using Multiplayer.API; +using System.Reflection; +using Multiplayer.API; using Multiplayer.Client; using Multiplayer.Common; @@ -165,6 +166,64 @@ public void SyncWithImpl() } } + [Test] + public void SyncWithImplUnknownTypeThrows() + { + var ser = new SyncSerialization(new TestTypeHelper()); + ser.syncTree = new SyncWorkerDictionaryTree + { + { + (SyncWorker worker, ref C4 c) => + { + worker.Bind(ref c.a); + }, false, true + }, + }; + ser.RegisterForSyncWithImpl(typeof(C1)); + + // Writing the "null" implementation marker followed by the object's data would misalign the reader + Assert.Throws(() => ser.WriteSync(new ByteWriter(), new C4 { a = 1 })); + } + + [Test] + public void RejectingImplicitWorkerKeepsParentData() + { + var ser = new SyncSerialization(new TestTypeHelper()); + var tree = new SyncWorkerDictionaryTree + { + { + (SyncWorker worker, ref C2 c) => + { + worker.Bind(ref c.a); + }, true, true + }, + { + (SyncWorker worker, ref C3 c) => + { + worker.Bind(ref c.b); + }, true, true + }, + }; + + // Bool returning workers are inserted first; this one writes/reads some data and then gives up + tree.GetOrAddEntry(typeof(C3), isImplicit: true, shouldConstruct: true) + .Add(typeof(SerializationTest).GetMethod(nameof(RejectingWorker), BindingFlags.NonPublic | BindingFlags.Static)!); + ser.syncTree = tree; + + var input = new C3 { a = 1, b = 2 }; + Assert.That( + RoundtripSerialization(ser, input), + Is.EqualTo(input) + ); + } + + private static bool RejectingWorker(SyncWorker worker, ref object? obj) + { + int junk = 12345; + worker.Bind(ref junk); + return false; + } + class TestTypeHelper : SyncTypeHelper { public override List GetImplementations(Type baseType) @@ -198,6 +257,12 @@ public override bool Equals(object? obj) } } + // Not returned by TestTypeHelper + public class C4 : C1 + { + public int a; + } + public interface IA; // Using structs so that equality is by value which simplifies the test code From e0161aa8b2a245705ac66c62f78e5fa410d25d10 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:51:57 -0600 Subject: [PATCH 027/183] Don't run caravan splitting commands through the local dialog CaravanSplittingSession's synced methods used its 'dialog' field, a UI window that only exists on clients that opened it (and is null after a reload on every client): - CancelSplittingSession threw on other clients before RemoveSession, leaving the session (which pauses everything) alive there. - AcceptSplitSession split the caravan only where the dialog existed, using its possibly stale mass caches. Accept now splits through a fresh proxy built from the session's transferables, UI closes are null-safe and open proxies close themselves once the session is removed. The window is no longer opened from session creation on every client: only the caravan's players get it (unchanged for single faction games), and only if the session was actually added. Co-Authored-By: Claude Opus 5.5 --- .../Persistent/CaravanSplittingPatches.cs | 6 +++- .../Persistent/CaravanSplittingProxy.cs | 6 ++-- .../Persistent/CaravanSplittingSession.cs | 34 ++++++++++++------- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/Source/Client/Persistent/CaravanSplittingPatches.cs b/Source/Client/Persistent/CaravanSplittingPatches.cs index 7e429125b..bd0660df4 100644 --- a/Source/Client/Persistent/CaravanSplittingPatches.cs +++ b/Source/Client/Persistent/CaravanSplittingPatches.cs @@ -64,7 +64,11 @@ static bool Prefix(Caravan caravan) [SyncMethod] public static void CreateSplittingSession(Caravan caravan) { - Multiplayer.WorldComp.sessionManager.AddSession(CaravanSplittingSession.Of(caravan)); + var session = CaravanSplittingSession.Of(caravan); + + // Only show the window to the caravan's players (everyone in a single faction game) + if (Multiplayer.WorldComp.sessionManager.AddSession(session) && caravan.Faction == Multiplayer.RealPlayerFaction) + session.OpenWindow(); } } } diff --git a/Source/Client/Persistent/CaravanSplittingProxy.cs b/Source/Client/Persistent/CaravanSplittingProxy.cs index 50b655966..741bb279f 100644 --- a/Source/Client/Persistent/CaravanSplittingProxy.cs +++ b/Source/Client/Persistent/CaravanSplittingProxy.cs @@ -49,11 +49,13 @@ public override void DoWindowContents(Rect inRect) try { - if (session == null) + if (session == null || session.removed) { Close(); + return; } - else if (session.uiDirty) + + if (session.uiDirty) { CountToTransferChanged(); diff --git a/Source/Client/Persistent/CaravanSplittingSession.cs b/Source/Client/Persistent/CaravanSplittingSession.cs index c51b667eb..7431e8fa7 100644 --- a/Source/Client/Persistent/CaravanSplittingSession.cs +++ b/Source/Client/Persistent/CaravanSplittingSession.cs @@ -37,9 +37,15 @@ public Caravan Caravan /// /// Reference to the dialog that is being displayed. + /// UI only: it's local to each client (and not saved), synced methods must never depend on it. /// public CaravanSplittingProxy dialog; + /// + /// Set once the session has been removed, open dialogs close themselves. + /// + public bool removed; + public override bool IsSessionValid => Caravan != null; // Used when saving and loading @@ -61,15 +67,9 @@ public static CaravanSplittingSession Of(Caravan caravan) private void AddItems() { - CaravanSplittingProxy.CreatingProxy = true; - dialog = new CaravanSplittingProxy(Caravan) { - session = this - }; - CaravanSplittingProxy.CreatingProxy = false; - dialog.CalculateAndRecacheTransferables(); - transferables = dialog.transferables; - - Find.WindowStack.Add(dialog); + var proxy = PrepareDialogProxy(); + proxy.CalculateAndRecacheTransferables(); + transferables = proxy.transferables; } /// @@ -142,7 +142,7 @@ public void Notify_CountChanged(Transferable tr) /// [SyncMethod] public void CancelSplittingSession() { - dialog.Close(); + dialog?.Close(); Multiplayer.WorldComp.sessionManager.RemoveSession(this); } @@ -163,14 +163,24 @@ public void ResetSplittingSession() [SyncMethod] public void AcceptSplitSession() { - if (dialog.TrySplitCaravan()) + // Split using a fresh proxy (fresh mass caches) built from the session's state, so every client does the same + var proxy = PrepareDialogProxy(); + proxy.CountToTransferChanged(); + + if (proxy.TrySplitCaravan()) { SoundDefOf.Tick_High.PlayOneShotOnCamera(); - dialog.Close(false); + dialog?.Close(false); Multiplayer.WorldComp.sessionManager.RemoveSession(this); } } + public override void PostRemoveSession() + { + base.PostRemoveSession(); + removed = true; + } + public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { if (!Caravan.pawns.Contains(entry.pawn)) From 3f41f923f9722cf359e46baa3ef7925aa6937b10 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:52:57 -0600 Subject: [PATCH 028/183] Recache trade deals in ticks and commands instead of the UI MpTradeDeal.Recache() adds/removes tradeables and reassigns stacks, but it was only called by TradingWindow, i.e. on clients that had the window open, using the local client's faction data. Count changes are resolved by thing id against deal.tradeables, so once one client recached the others resolved them differently, and TryExecute traded different items. The deal is now recached (when dirty) by the session's world tick and before resolving a synced transferable reference, both of which run on every client, with the negotiator's faction data forced. The UI only follows the deal through uiShouldReset. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Persistent/Trading.cs | 39 ++++++++++++++++++++++++--- Source/Client/Persistent/TradingUI.cs | 5 ++-- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/Source/Client/Persistent/Trading.cs b/Source/Client/Persistent/Trading.cs index ff25e2a70..283ce797a 100644 --- a/Source/Client/Persistent/Trading.cs +++ b/Source/Client/Persistent/Trading.cs @@ -1,5 +1,6 @@ using HarmonyLib; using Multiplayer.API; +using Multiplayer.Client.Factions; using RimWorld; using RimWorld.Planet; using System.Collections.Generic; @@ -200,10 +201,39 @@ public void CloseWindow(bool sound = true) public void Tick() { - if (playerNegotiator.Spawned) return; - - if (ShouldCancel()) + if (!playerNegotiator.Spawned && ShouldCancel()) + { Multiplayer.WorldComp.sessionManager.RemoveSession(this); + return; + } + + RecacheDealIfNeeded(); + } + + /// + /// Applies the changes marked by the dirty flags to the deal's tradeables. This changes simulation state + /// (which tradeables exist and which things they hold), so it must only run in deterministic contexts + /// (ticks and commands), never from the UI. + /// + public void RecacheDealIfNeeded() + { + if (deal is not { ShouldRecache: true }) return; + + var prevSession = current; + var map = playerNegotiator?.Map; + + // Use the negotiator's data, not whatever the current context or the local client has applied + map.PushFaction(NegotiatorFaction, force: true); + try + { + SetTradeSession(this); + deal.Recache(); + } + finally + { + SetTradeSession(prevSession); + map.PopFaction(); + } } public override void ExposeData() @@ -226,6 +256,9 @@ public override void ExposeData() public Transferable GetTransferableByThingId(int thingId) { + // Called when reading synced data, i.e. on every client in the same command + RecacheDealIfNeeded(); + for (int i = 0; i < deal.tradeables.Count; i++) { Tradeable tr = deal.tradeables[i]; diff --git a/Source/Client/Persistent/TradingUI.cs b/Source/Client/Persistent/TradingUI.cs index 97078b697..f7e9acb59 100644 --- a/Source/Client/Persistent/TradingUI.cs +++ b/Source/Client/Persistent/TradingUI.cs @@ -74,9 +74,8 @@ public override void DoWindowContents(Rect inRect) MpTradeSession.SetTradeSession(session); drawingTrade = this; - if (session.deal.ShouldRecache) - session.deal.Recache(); - + // The deal itself is only recached in ticks and commands (MpTradeSession.RecacheDealIfNeeded), + // the UI just follows it if (session.deal.uiShouldReset != UIShouldReset.None) { if (session.deal.uiShouldReset != UIShouldReset.Silent) From 7b666d7c4bf3a832ace277eb94059416847bed8b Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:53:05 -0600 Subject: [PATCH 029/183] Save VTR player counts and clamp the world count The per-map and world player counts decide VTR, which affects the simulation, but they weren't saved, so every reload reset them to 0. On standalone servers a join point reloads only the requesting client, leaving its VTR different from everyone else's; elsewhere viewed maps ran at the slow rate until someone switched maps. Save them with the async time comps. Also clamp the world count at 0 like the map count. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 6 ++++++ Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 32233940b..59683023a 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -211,6 +211,12 @@ public void ExposeData() Scribe_Values.Look(ref gameStartAbsTickMap, "gameStartAbsTickMap"); Scribe_Custom.LookULong(ref randState, "randState", 1); + + // Affects the simulation through VTR. Saved so that it survives reloads, which can happen + // on one client only (standalone join points). + var playerCount = CurrentPlayerCount; + Scribe_Values.Look(ref playerCount, "playerCount"); + CurrentPlayerCount = playerCount; } public int IncreasePlayerCount() => CurrentPlayerCount += 1; diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 8184163ab..82160abeb 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -86,6 +86,11 @@ public void ExposeData() Scribe_Values.Look(ref timeSpeedInt, "timeSpeed"); Scribe_Custom.LookULong(ref randState, "randState", 2); + // Affects the simulation through VTR, see AsyncTimeComp.ExposeData + var playerCount = CurrentPlayerCount; + Scribe_Values.Look(ref playerCount, "playerCount"); + CurrentPlayerCount = playerCount; + TimeSpeed timeSpeed = Find.TickManager.CurTimeSpeed; Scribe_Values.Look(ref timeSpeed, "timeSpeed"); if (Scribe.mode == LoadSaveMode.LoadingVars) @@ -239,7 +244,7 @@ public void ExecuteCmd(ScheduledCommand cmd) if (previousMapId >= 0) prev = Find.Maps.FirstOrDefault(x => x.uniqueID == previousMapId)?.AsyncTime()?.DecreasePlayerCount() ?? -1; else if (previousMapId == VTRSync.WorldMapId) - prev = Multiplayer.AsyncWorldTime.CurrentPlayerCount -= 1; + prev = Multiplayer.AsyncWorldTime.CurrentPlayerCount = Math.Max(0, Multiplayer.AsyncWorldTime.CurrentPlayerCount - 1); var curr = -1; if (newMapId >= 0) From b2fb3f15d5444bf1977ae8681fa7a6387dc6abd3 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:53:13 -0600 Subject: [PATCH 030/183] Time out unresponsive players and clamp client timing reports keepAliveAt was written but never read. LiteNet pings from its own thread, so a hung game stays connected while its ticksBehind pauses the simulation for everyone, indefinitely. CheckTimeouts now drops a player in game (ServerPlaying, status Playing) that hasn't answered a keep alive for 10 minutes. The in-memory host connection is exempt and the timer restarts when the playing state starts and on world ready, because loading a big world blocks the client's main thread. Client-reported values are sanitized; the pause-when-behind logic is unchanged: - ticksBehind is clamped to [0, 2^24]. Negative values sped the server up through the slowdown factor, huge ones overflowed ExtrapolatedTicksBehind. - frameTime: NaN becomes 0, the rest is clamped to [0, 10s]. A NaN went into serverTimePerTick and made realTime NaN, which stopped the server's game timer for good. The serverTimePerTick clamp is also NaN-safe now. Co-Authored-By: Claude Opus 5.5 --- Source/Common/MultiplayerServer.cs | 3 +- .../Networking/State/ServerPlayingState.cs | 19 +++++++- Source/Common/PlayerManager.cs | 15 +++++++ Source/Common/ServerPlayer.cs | 11 ++++- Source/Tests/ServerPlayingStateTest.cs | 45 +++++++++++++++++++ 5 files changed, 90 insertions(+), 3 deletions(-) diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index f83c04cbe..8e1f5e33a 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -202,7 +202,8 @@ private void TickNet() serverTimePerTick = PlayingIngamePlayers.MaxOrZero(p => p.frameTime); - if (serverTimePerTick < StandardTimePerTick) + // Written to also catch NaN + if (!(serverTimePerTick >= StandardTimePerTick)) serverTimePerTick = StandardTimePerTick; if (serverTimePerTick > StandardTimePerTick * 4f) diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index 549d11dc0..214c38188 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Multiplayer.Common.Networking.Packet; @@ -6,9 +7,18 @@ namespace Multiplayer.Common { public class ServerPlayingState(ConnectionBase conn) : MpConnectionState(conn) { + public override void StartState() + { + // The client is about to load the world and may not answer keep alives for a while (also on rejoin, + // where the status stays Playing), so start the stale keep alive timeout from here. + if (connection.serverPlayer is { } player && MultiplayerServer.instance is { } server) + player.keepAliveAt = server.NetTimer; + } + [PacketHandler(Packets.Client_WorldReady)] public void HandleWorldReady(ByteReader data) { + Player.keepAliveAt = Server.NetTimer; Player.UpdateStatus(PlayerStatus.Playing); } @@ -281,7 +291,14 @@ public void HandleSetFaction(ClientSetFactionPacket packet) Server.SendToPlaying(new ServerSetFactionPacket(playerId, factionId)); } + public const float MaxFrameTime = 10_000f; + [TypedPacketHandler] - public void HandleFrameTime(ClientFrameTimePacket packet) => Player.frameTime = packet.frameTime; + public void HandleFrameTime(ClientFrameTimePacket packet) + { + // A NaN would end up in the server's time per tick and stop the simulation for good + var frameTime = packet.frameTime; + Player.frameTime = float.IsNaN(frameTime) ? 0f : Math.Max(0f, Math.Min(frameTime, MaxFrameTime)); + } } } diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index 5f08f7780..faec64c55 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -113,6 +113,11 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) ServerLog.Log($"Disconnected ({reason}): {conn}"); } + // The transport keeps a hung game connected. A playing client that doesn't answer keep alives anymore stalls + // everyone through its ticks behind, so drop it after a generous delay (loading and saving big games blocks + // the client's main thread for a while). + public const int StaleKeepAliveNetTicks = 10 * 60 * MultiplayerServer.NetTicksPerSecond; + public void CheckTimeouts() { // Disconnecting modifies Players @@ -124,6 +129,16 @@ public void CheckTimeouts() { ServerLog.Log($"{player.conn} timed out waiting for {state.AwaitedPacket} in {player.conn.State}"); player.Disconnect("Timed out while joining the server"); + continue; + } + + // The hosting player is connected in memory + var localHost = player.IsHost && !server.IsStandaloneServer; + if (player.IsPlaying && player.status == PlayerStatus.Playing && !localHost && + server.NetTimer - player.keepAliveAt > StaleKeepAliveNetTicks) + { + ServerLog.Log($"{player.conn} stopped answering keep alives"); + player.Disconnect("Timed out: the game stopped responding"); } } } diff --git a/Source/Common/ServerPlayer.cs b/Source/Common/ServerPlayer.cs index f1a231c97..9856fc672 100644 --- a/Source/Common/ServerPlayer.cs +++ b/Source/Common/ServerPlayer.cs @@ -15,7 +15,16 @@ public class ServerPlayer : IChatSource public bool simulating; public float frameTime; - public int ticksBehind; + // Reported by the client. It pauses and slows down the server for everyone, so keep it in a sane range + // (negative values sped the server up, huge ones overflowed ExtrapolatedTicksBehind). + public int ticksBehind + { + get => clampedTicksBehind; + set => clampedTicksBehind = Math.Max(0, Math.Min(value, MaxTicksBehind)); + } + private int clampedTicksBehind; + public const int MaxTicksBehind = 1 << 24; + public int ticksBehindReceivedAt; public int ExtrapolatedTicksBehind => ticksBehind + (Server.gameTimer - ticksBehindReceivedAt); diff --git a/Source/Tests/ServerPlayingStateTest.cs b/Source/Tests/ServerPlayingStateTest.cs index 75b361a42..11449893d 100644 --- a/Source/Tests/ServerPlayingStateTest.cs +++ b/Source/Tests/ServerPlayingStateTest.cs @@ -137,4 +137,49 @@ public void HostCanSetOtherPlayersFaction() Assert.That(bob.FactionId, Is.EqualTo(2)); } + + [TestCase(-1000, 0)] + [TestCase(int.MinValue, 0)] + [TestCase(int.MaxValue, ServerPlayer.MaxTicksBehind)] + [TestCase(50, 50)] + public void KeepAliveTicksBehindIsClamped(int reported, int expected) + { + var (player, _) = AddPlayer("player"); + player.conn.GetState()!.HandleClientKeepAlive(new ClientKeepAlivePacket(0, reported, false, 0)); + Assert.That(player.ticksBehind, Is.EqualTo(expected)); + } + + [TestCase(float.NaN, 0f)] + [TestCase(float.PositiveInfinity, ServerPlayingState.MaxFrameTime)] + [TestCase(-5f, 0f)] + [TestCase(20f, 20f)] + public void FrameTimeIsSanitized(float reported, float expected) + { + var (player, _) = AddPlayer("player"); + player.conn.GetState()!.HandleFrameTime(new ClientFrameTimePacket(reported)); + Assert.That(player.frameTime, Is.EqualTo(expected)); + } + + [Test] + public void StaleKeepAliveDisconnectsPlayingPlayer() + { + var (host, hostConn) = AddPlayer("host"); + server.hostUsername = "host"; + var (loading, loadingConn) = AddPlayer("loading"); + var (stale, staleConn) = AddPlayer("stale"); + var (fresh, freshConn) = AddPlayer("fresh"); + + foreach (var p in new[] { host, stale, fresh }) + p.status = PlayerStatus.Playing; + + host.keepAliveAt = loading.keepAliveAt = stale.keepAliveAt = -PlayerManager.StaleKeepAliveNetTicks - 1; + fresh.keepAliveAt = -PlayerManager.StaleKeepAliveNetTicks + 10; + + server.playerManager.CheckTimeouts(); + + Assert.That(staleConn.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(hostConn.State, Is.EqualTo(ConnectionStateEnum.ServerPlaying)); // In-memory host connection + Assert.That(loadingConn.State, Is.EqualTo(ConnectionStateEnum.ServerPlaying)); // Not in game yet + Assert.That(freshConn.State, Is.EqualTo(ConnectionStateEnum.ServerPlaying)); + } } From 1d5e411b6ef9287090ce0f65c264118f57a4b1ae Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:53:14 -0600 Subject: [PATCH 031/183] Emit valid default values in the sync method transpiler For synced methods returning a struct (e.g. *Database.TryDelete returning AcceptanceReport) the early-return path emitted `ldloca; initobj` without loading the local, so `ret` ran with an empty stack. Load the zeroed local, and use the same path for enums and other value types (an int64 enum got ldc.i4). Out parameters of value types are now zeroed with `initobj` too: stind.i4 wrote 4 bytes into 1/2 byte locations such as `out bool`. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/SyncTemplates.cs | 42 +++++++++----------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/Source/Client/Syncing/SyncTemplates.cs b/Source/Client/Syncing/SyncTemplates.cs index 88a379f93..3cf2d9771 100644 --- a/Source/Client/Syncing/SyncTemplates.cs +++ b/Source/Client/Syncing/SyncTemplates.cs @@ -39,18 +39,12 @@ static IEnumerable Transpiler(MethodBase original, IEnumerable< if (pInfo.IsOut) { pType = pType.GetElementType(); yield return new CodeInstruction(OpCodes.Ldarg, argIndex); - yield return CreateDefaultCodes(gen, pType).Last(); - if (AccessTools.IsClass(pType)) + if (AccessTools.IsClass(pType)) { + yield return new CodeInstruction(OpCodes.Ldnull); yield return new CodeInstruction(OpCodes.Stind_Ref); - if (AccessTools.IsValue(pType)) { - if (pType == typeof(float)) - yield return new CodeInstruction(OpCodes.Stind_R4, (float) 0); - else if (pType == typeof(double)) - yield return new CodeInstruction(OpCodes.Stind_R8, (double) 0); - else if (pType == typeof(long)) - yield return new CodeInstruction(OpCodes.Stind_I8, (long) 0); - else - yield return new CodeInstruction(OpCodes.Stind_I4, 0); + } else { + // Zeroes the value type at the address with its real size (unlike stind.i4 for e.g. bool) + yield return new CodeInstruction(OpCodes.Initobj, pType); } } } @@ -92,31 +86,25 @@ static IEnumerable Transpiler(MethodBase original, IEnumerable< yield return instruction; } + // Pushes the default value of the type (nothing for void) static IEnumerable CreateDefaultCodes(ILGenerator generator, Type type) { if (type.IsByRef) type = type.GetElementType(); + if (type == typeof(void)) + yield break; + if (AccessTools.IsClass(type)) { yield return new CodeInstruction(OpCodes.Ldnull); yield break; } - if (AccessTools.IsStruct(type)) { - var v = generator.DeclareLocal(type); - yield return new CodeInstruction(OpCodes.Ldloca, v); - yield return new CodeInstruction(OpCodes.Initobj, type); - yield break; - } - if (AccessTools.IsValue(type)) { - if (type == typeof(float)) - yield return new CodeInstruction(OpCodes.Ldc_R4, (float) 0); - else if (type == typeof(double)) - yield return new CodeInstruction(OpCodes.Ldc_R8, (double) 0); - else if (type == typeof(long)) - yield return new CodeInstruction(OpCodes.Ldc_I8, (long) 0); - else - yield return new CodeInstruction(OpCodes.Ldc_I4, 0); - } + + // Any value type (struct, enum, primitive): zero a local and load it + var v = generator.DeclareLocal(type); + yield return new CodeInstruction(OpCodes.Ldloca, v); + yield return new CodeInstruction(OpCodes.Initobj, type); + yield return new CodeInstruction(OpCodes.Ldloc, v); } } From 3b62baa2698a00997ea52999c7fc2f8b0e2f37b7 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:54:19 -0600 Subject: [PATCH 032/183] Find the map gen faction from any player caravan MapGenFactionPatch used PlayerControlledCaravanAt, which compares the caravan faction with Faction.OfPlayer. Outside a simulation context that is each client's own faction, so only the owner found the caravan and the map was set up for different factions on different clients (#518). Match any caravan of a player faction instead. The TileFactionContext fallback was dead (SetFactionForTile is no longer called since #603), remove it. Co-Authored-By: Claude Opus 5.5 --- .../Client/Factions/FactionContextSetters.cs | 19 +++------- Source/Client/Factions/TileFactionContext.cs | 36 ------------------- 2 files changed, 5 insertions(+), 50 deletions(-) delete mode 100644 Source/Client/Factions/TileFactionContext.cs diff --git a/Source/Client/Factions/FactionContextSetters.cs b/Source/Client/Factions/FactionContextSetters.cs index 7ba933c72..9351e2062 100644 --- a/Source/Client/Factions/FactionContextSetters.cs +++ b/Source/Client/Factions/FactionContextSetters.cs @@ -29,7 +29,7 @@ static void Prefix(PlanetTile tile) Faction factionToSet = GetFactionAt(tile); if (Multiplayer.Client != null && factionToSet == null) - Log.Warning($"Couldn't set the faction context for map gen at {tile.tileId}: no world object and no stored faction."); + Log.Warning($"Couldn't set the faction context for map gen at {tile.tileId}: no player world object at the tile."); FactionContext.Push(factionToSet); } @@ -42,7 +42,9 @@ private static Faction GetFactionAt(PlanetTile tile) if (mapParent != null && mapParent.Faction is { IsPlayer: true }) return mapParent.Faction; - var caravan = worldObjectsHolder.PlayerControlledCaravanAt(tile); + // Not PlayerControlledCaravanAt: it compares with Faction.OfPlayer, which differs between clients outside + // of a simulation context + var caravan = worldObjectsHolder.Caravans.Find(c => c.Tile == tile && c.Faction is { IsPlayer: true }); if (caravan != null) return caravan.Faction; @@ -54,7 +56,7 @@ private static Faction GetFactionAt(PlanetTile tile) if (gravship != null) return gravship.Faction; - return TileFactionContext.GetFactionForTile(tile); + return null; } static void Finalizer() @@ -154,14 +156,3 @@ static void Finalizer() FactionContext.Pop(); } } - -// Clean up after map generation is complete -[HarmonyPatch(typeof(MapGenerator), nameof(MapGenerator.GenerateMap))] -static class CleanupTileFactionContext -{ - static void Finalizer(MapParent parent) - { - if (parent != null) - TileFactionContext.ClearTile(parent.Tile); - } -} diff --git a/Source/Client/Factions/TileFactionContext.cs b/Source/Client/Factions/TileFactionContext.cs deleted file mode 100644 index 5c35965ea..000000000 --- a/Source/Client/Factions/TileFactionContext.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using RimWorld; -using RimWorld.Planet; - -namespace Multiplayer.Client.Factions -{ - public static class TileFactionContext - { - private static readonly Dictionary tileFactions = []; - private static readonly object lockObject = new(); - - public static void SetFactionForTile(PlanetTile tile, Faction faction) - { - lock (lockObject) - { - tileFactions[tile] = faction; - } - } - - public static Faction GetFactionForTile(PlanetTile tile) - { - lock (lockObject) - { - return tileFactions.TryGetValue(tile, out Faction faction) ? faction : null; - } - } - - public static void ClearTile(PlanetTile tile) - { - lock (lockObject) - { - tileFactions.Remove(tile); - } - } - } -} From 619bc19389b768ae2968065b8aab1f19eea7b90d Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:54:19 -0600 Subject: [PATCH 033/183] Run sim long events in a deterministic context Long events queued during a tick or command run later, outside of it. Only the action was seeded (as push + action + pop, so a throwing action left the RNG stack unbalanced) and nothing else was restored: - the faction context was lost, so under multifaction each client ran the event (e.g. map generation and MapSetup's faction data init) for its own faction (#518, #963) - actions deferred with ExecuteWhenFinished run after currentEvent is cleared, so they counted as interface code: unseeded UI RNG, local unique ids, sync methods sending commands instead of executing - events queued while ticking (not executing commands) could still run asynchronously on a worker thread sharing the global Rand with the UI Capture a SimLongEventContext (RNG seed and, under multifaction, the current faction) when such an event is queued and apply it around the whole synchronous update (action and deferred actions) and the async cleanup, with try/finally semantics. While it is active InInterface is false. After a game reload inside the event (join points) the old game's context is no longer applied, matching how a fresh joiner loads. On exit Faction.OfPlayer is set to the real player faction since the event may have changed it (FactionCreator). Events queued from such a context get one too, and all of them are forced synchronous. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Multiplayer.cs | 3 +- Source/Client/Patches/LongEvents.cs | 127 +++++++++++++++++++++++++++- Source/Client/Patches/Seeds.cs | 29 ++++--- 3 files changed, 143 insertions(+), 16 deletions(-) diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 5ea4b1760..00a625911 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -70,7 +70,8 @@ public static class Multiplayer && !ExecutingCmds && !reloading && Current.ProgramState == ProgramState.Playing - && LongEventHandler.currentEvent == null; + && LongEventHandler.currentEvent == null + && !SimLongEventContext.InSimulation; public static string ReplaysDir => GenFilePaths.FolderUnderSaveData("MpReplays"); public static string DesyncsDir => GenFilePaths.FolderUnderSaveData("MpDesyncs"); diff --git a/Source/Client/Patches/LongEvents.cs b/Source/Client/Patches/LongEvents.cs index 473faea57..109d85684 100644 --- a/Source/Client/Patches/LongEvents.cs +++ b/Source/Client/Patches/LongEvents.cs @@ -3,9 +3,11 @@ using System.Linq; using System.Reflection; using System.Reflection.Emit; +using System.Runtime.CompilerServices; using HarmonyLib; using Multiplayer.Common; using Multiplayer.Common.Networking.Packet; +using RimWorld; using Verse; namespace Multiplayer.Client.Patches @@ -71,7 +73,8 @@ static class LongEventAlwaysSync { static void Prefix(ref bool doAsynchronously, ref Action action, Action callback) { - if (Multiplayer.ExecutingCmds) + // Asynchronous events would run on a worker thread sharing the global Rand with the UI + if (SimLongEventContext.ShouldCapture) { doAsynchronously = false; @@ -106,15 +109,131 @@ static IEnumerable Transpiler(IEnumerable inst public static void ReorderCleanupCode() { Action callback = LongEventHandler.currentEvent.callback; + var context = SimLongEventContext.For(LongEventHandler.currentEvent); LongEventHandler.currentEvent = null; LongEventHandler.eventThread = null; LongEventHandler.levelLoadOp = null; - LongEventHandler.ExecuteToExecuteWhenFinished(); + context?.Enter(); + try + { + LongEventHandler.ExecuteToExecuteWhenFinished(); + + if (callback != null) + callback(); + } + finally + { + context?.Exit(); + } + } + } + + // Synchronous events run the action, then (with currentEvent already cleared) the actions deferred with + // LongEventHandler.ExecuteWhenFinished. Both get the simulation context of an event queued from the simulation. + [HarmonyPatch(typeof(LongEventHandler), nameof(LongEventHandler.UpdateCurrentSynchronousEvent))] + static class SimLongEventSynchronousContext + { + static void Prefix(ref SimLongEventContext __state) + { + __state = SimLongEventContext.For(LongEventHandler.currentEvent); + __state?.Enter(); + } + + static void Finalizer(SimLongEventContext __state) + { + __state?.Exit(); + } + } + + /// + /// The simulation context of a long event queued during a tick or command. Long events run later, outside any + /// tick or command, so this re-applies what has to be the same on all clients: a seeded RNG, the faction the + /// event was queued for (multifaction) and a non-interface context. The latter matters for the actions the event + /// defers with ExecuteWhenFinished: they run after currentEvent is cleared and would otherwise count as UI + /// (local unique ids, sending sync commands). + /// + public class SimLongEventContext + { + private static readonly ConditionalWeakTable contexts = new(); + private static SimLongEventContext current; + + // A reload (e.g. a join point) replaces the game: what runs after it isn't simulation of the old game anymore + public static bool InSimulation => current != null && current.game != null && current.game == Multiplayer.game; + + public static bool ShouldCapture => + Multiplayer.Client != null && (Multiplayer.Ticking || Multiplayer.ExecutingCmds || InSimulation); + + private readonly MultiplayerGame game; + private readonly int seed; + private readonly Faction faction; + + private SimLongEventContext prev; + private bool pushedFaction; + + private SimLongEventContext(int seed, Faction faction) + { + game = Multiplayer.game; + this.seed = seed; + this.faction = faction; + } + + public static SimLongEventContext Capture() => + new(Rand.Int, Multiplayer.GameComp.multifaction ? Faction.OfPlayer : null); + + public static void Attach(LongEventHandler.QueuedLongEvent ev, SimLongEventContext context) + { + contexts.Remove(ev); + contexts.Add(ev, context); + } + + public static SimLongEventContext For(LongEventHandler.QueuedLongEvent ev) => + ev != null && contexts.TryGetValue(ev, out var context) ? context : null; + + public void Enter() + { + prev = current; + current = this; + + Rand.PushState(seed); + + if (faction != null && game == Multiplayer.game) + { + pushedFaction = true; + if (FactionContext.Push(faction, force: true) is { } f) + SetFactionData(f); + } + } + + public void Exit() + { + // FactionContext is cleared when the game is reloaded + if (pushedFaction && game == Multiplayer.game && FactionContext.stack.Count > 0) + { + FactionContext.Pop(); + + // Long events run in the interface context, where Faction.OfPlayer is the real faction. Restore that + // instead of the popped faction as the event may have changed it (FactionCreator). + if (Multiplayer.RealPlayerFaction is { } real) + { + FactionContext.Set(real); + SetFactionData(real); + } + } + + pushedFaction = false; + Rand.PopState(); - if (callback != null) - callback(); + current = prev; + prev = null; + } + + private static void SetFactionData(Faction f) + { + Multiplayer.WorldComp?.SetFaction(f); + foreach (var map in Find.Maps) + map.MpComp().SetFaction(f); } } } diff --git a/Source/Client/Patches/Seeds.cs b/Source/Client/Patches/Seeds.cs index 1dc85186b..3219b76a1 100644 --- a/Source/Client/Patches/Seeds.cs +++ b/Source/Client/Patches/Seeds.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Reflection; using System.Reflection.Emit; +using Multiplayer.Client.Patches; using Multiplayer.Client.Util; using Verse; using Verse.Grammar; @@ -92,22 +93,28 @@ static void Postfix(Map map, bool __state) } } + // The seed and other simulation context are applied when the event runs, see SimLongEventContext [HarmonyPatch(typeof(LongEventHandler), nameof(LongEventHandler.QueueLongEvent), typeof(Action), typeof(string), typeof(bool), typeof(Action), typeof(bool), typeof(bool), typeof(Action))] static class SeedLongEvents { - static void Prefix(ref Action action, ref Action callback) + static void Prefix(ref SimLongEventContext __state) { - if (Multiplayer.Client != null && (Multiplayer.Ticking || Multiplayer.ExecutingCmds)) - { - var seed = Rand.Int; - action = (() => Rand.PushState(seed)) + action + Rand.PopState; + if (SimLongEventContext.ShouldCapture) + __state = SimLongEventContext.Capture(); + } - if (callback != null) - { - var callbackSeed = Rand.Int; - callback = (() => Rand.PushState(callbackSeed)) + callback + Rand.PopState; - } - } + static void Postfix(Action action, SimLongEventContext __state) + { + if (__state == null) return; + + // Find the event that was just queued (it isn't if another prefix cancelled it) + LongEventHandler.QueuedLongEvent queued = null; + foreach (var ev in LongEventHandler.eventQueue) + if (ReferenceEquals(ev.eventAction, action)) + queued = ev; + + if (queued != null) + SimLongEventContext.Attach(queued, __state); } } From c3889fa179fab0289f9a44381c44b96e9e799490 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:54:58 -0600 Subject: [PATCH 034/183] Apply the context faction to every map in forced faction pushes Outside of simulation contexts every map holds the faction data (areas, zones, designations, haul destinations, listers, ...) of the local client's own faction. Map ticks and commands only applied their faction to their own map, so simulation code reaching into another map (map portals, pocket maps, gravship landing, FactionRepeater on spawns into another map) read client-specific data and desynced. Forced pushes (used exactly to set up map/world ticks and commands) now apply the faction to the world data and to every map, and their pops re-apply the previous faction everywhere. FactionContext tracks which entries were such pushes so plain FactionContext.Push/Pop users stay balanced. Invariant: inside any tick or command, every map holds the data of a faction determined by the simulation (the context faction, or one set by a nested push), never a client's resting faction. Single faction games are unaffected (all maps already hold the player faction's data). The gravship patches now force their push: in a world command Faction.OfPlayer already is the gravship's faction while the maps hold the spectator's data, so the regular push was a no-op. The RemoveGravshipFromMap finalizer remembers the map instead of reading engine.Map, which is null after the engine despawned. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/FactionContext.cs | 16 ++++++++-- Source/Client/Factions/FactionExtensions.cs | 30 ++++++++++++++++--- Source/Client/Factions/MultifactionPatches.cs | 21 ++++++------- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/Source/Client/Factions/FactionContext.cs b/Source/Client/Factions/FactionContext.cs index 121cd8aa9..d5df81f31 100644 --- a/Source/Client/Factions/FactionContext.cs +++ b/Source/Client/Factions/FactionContext.cs @@ -8,22 +8,32 @@ public static class FactionContext { public static Stack stack = new(); - public static Faction Push(Faction newFaction, bool force = false) + // Parallel to stack: whether the entry's push applied its faction to every map (see FactionExtensions.PushFaction) + private static Stack allMapsStack = new(); + + public static Faction Push(Faction newFaction, bool force = false) => Push(newFaction, force, false); + + public static Faction Push(Faction newFaction, bool force, bool allMaps) { if (newFaction == null || !force && Find.FactionManager.ofPlayer == newFaction || !newFaction.def.isPlayer) { stack.Push(null); + allMapsStack.Push(false); return null; } stack.Push(Find.FactionManager.OfPlayer); + allMapsStack.Push(allMaps); Set(newFaction); return newFaction; } - public static Faction Pop() + public static Faction Pop() => Pop(out _); + + public static Faction Pop(out bool allMaps) { + allMaps = allMapsStack.Count > 0 && allMapsStack.Pop(); Faction f = stack.Pop(); if (f != null) Set(f); @@ -38,8 +48,8 @@ public static void Set(Faction newFaction) public static void Clear() { stack.Clear(); + allMapsStack.Clear(); } } } - diff --git a/Source/Client/Factions/FactionExtensions.cs b/Source/Client/Factions/FactionExtensions.cs index a520c8cf0..a3fad5284 100644 --- a/Source/Client/Factions/FactionExtensions.cs +++ b/Source/Client/Factions/FactionExtensions.cs @@ -10,13 +10,22 @@ public static class FactionExtensions // Sets the current Faction.OfPlayer // Applies faction's world components // Applies faction's map components if map not null + // + // force: the faction is applied even if it already is Faction.OfPlayer, and to every map, not only the given one. + // Forced pushes set up simulation contexts (map/world ticks and commands). Outside of them every map holds the + // data of the local client's own faction, so applying the context's faction to all maps makes sure simulation + // code touching another map (portals, pocket maps, gravships, ...) never sees client-specific data. + // The matching pop re-applies the previous faction to the world and every map. public static void PushFaction(this Map map, Faction f, bool force = false) { - var faction = FactionContext.Push(f, force); + var faction = FactionContext.Push(f, force, allMaps: force); if (faction == null) return; Multiplayer.WorldComp?.SetFaction(faction); - map?.MpComp().SetFaction(faction); + if (force) + SetFactionOnAllMaps(faction); + else + map?.MpComp().SetFaction(faction); } public static void PushFaction(this Map map, int factionId) @@ -29,15 +38,28 @@ public static void PushFaction(this Map map, int factionId) public static Faction PopFaction(this Map map) { - Faction faction = FactionContext.Pop(); + Faction faction = FactionContext.Pop(out bool allMaps); if (faction == null) return null; Multiplayer.WorldComp?.SetFaction(faction); - map?.MpComp().SetFaction(faction); + if (allMaps) + SetFactionOnAllMaps(faction); + else + map?.MpComp().SetFaction(faction); return faction; } + private static void SetFactionOnAllMaps(Faction faction) + { + // Includes comps of removed maps (they're never removed from the list), which is harmless + var comps = Multiplayer.game?.mapComps; + if (comps == null) return; + + for (int i = 0; i < comps.Count; i++) + comps[i].SetFaction(faction); + } + public static bool TryGetPlayerFaction(this Quest quest, out Faction faction) { faction = quest.QuestLookTargets diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index 9eea55518..a1ad2a177 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -794,25 +794,26 @@ public static bool IsFreeColonistAnyPlayerFaction(Pawn pawn) } +// The pushes are forced: in a world command Faction.OfPlayer already is the gravship's faction while the maps hold +// the spectator's data, so a regular push would be a no-op and the gravship would use the spectator's managers. [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.RemoveGravshipFromMap))] static class RemoveGravshipFromMap_Patch { - static void Prefix(Building_GravEngine engine, out bool __state) + static void Prefix(Building_GravEngine engine, out Map __state) { - __state = false; + __state = null; if (Multiplayer.Client == null) return; if (!Multiplayer.GameComp.multifaction) return; - if (engine == null) return; - - engine.Map.PushFaction(engine.Faction); + if (engine?.Map == null) return; - __state = true; + // Remember the map, the engine is despawned by the time the finalizer runs + __state = engine.Map; + __state.PushFaction(engine.Faction, force: true); } - static void Finalizer(Building_GravEngine engine, bool __state) + static void Finalizer(Map __state) { - if (!__state) return; - engine.Map.PopFaction(); + __state?.PopFaction(); } } @@ -825,7 +826,7 @@ static void Prefix(Gravship gravship, Map map, out bool __state) if (Multiplayer.Client == null) return; if (!Multiplayer.GameComp.multifaction) return; - map.PushFaction(gravship.Faction); + map.PushFaction(gravship.Faction, force: true); __state = true; } From 82053950cec80d3c894726e2d53a374d1a419a7e Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:55:11 -0600 Subject: [PATCH 035/183] Catch Multiplayer build and def type differences when joining The join compatibility check passed while clients ran different code: - Mod file hashes only covered Assemblies/, but Multiplayer's own code (Multiplayer.dll, MultiplayerCommon.dll) lives in AssembliesCustom/. Those files are hashed now, so a different Multiplayer build than the host's (or the standalone server's first joiner) shows up as a file mismatch in the mismatch window. This also works on standalone servers, whose own MP version string says nothing about the clients. "Connect anyway" stays available as for other file differences. - The def check only looked at the def types the client sent, so types that only the server's reference data had went undetected. The server now also sends the def types missing on the client (ServerJoinDataPacket.missingDefs) and treats them as a def mismatch, which the client lists as ": Missing" like other def diffs. Packet format change: bump MpVersion.Protocol to 57 and update the ServerJoinDataPacket snapshot. Co-Authored-By: Claude Opus 5.5 --- Source/Client/MultiplayerData.cs | 7 ++++ Source/Client/Networking/JoinData.cs | 4 ++- .../Networking/State/ClientJoiningState.cs | 9 +++-- .../Networking/Packet/JoinDataPacket.cs | 13 +++++++- .../Networking/State/ServerJoiningState.cs | 11 +++++++ Source/Common/Version.cs | 2 +- Source/Tests/Helper/RecordingConnection.cs | 2 ++ Source/Tests/JoiningStateTest.cs | 33 +++++++++++++++++++ Source/Tests/PacketTest.cs | 1 + Source/Tests/ReaderBoundsTest.cs | 3 +- .../ServerJoinDataPacket.verified.txt | 2 +- 11 files changed, 80 insertions(+), 7 deletions(-) diff --git a/Source/Client/MultiplayerData.cs b/Source/Client/MultiplayerData.cs index 0212296c9..f5cda0b10 100644 --- a/Source/Client/MultiplayerData.cs +++ b/Source/Client/MultiplayerData.cs @@ -70,6 +70,13 @@ public static IEnumerable GetModAssemblies(ModContentPack mod) .Select(t => t.Item2); } + // Multiplayer's own code (Multiplayer.dll, MultiplayerCommon.dll) is loaded by MultiplayerLoader from here + public static IEnumerable GetModCustomAssemblies(ModContentPack mod) + { + return ModContentPack.GetAllFilesForModPreserveOrder(mod, "AssembliesCustom/", f => f.ToLower() == ".dll") + .Select(t => t.Item2); + } + public static ModContentPack DummyContentPack(ModMetaData mod) { // The constructor calls InitLoadFolders diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index c123d9c2a..eb912e961 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -98,7 +98,9 @@ internal static ModFileDict GetModFiles(IEnumerable modIds) var contentPack = MultiplayerData.DummyContentPack(mod); - foreach (var asm in MultiplayerData.GetModAssemblies(contentPack)) + // Include AssembliesCustom so that different Multiplayer builds show up as a mismatch + foreach (var asm in MultiplayerData.GetModAssemblies(contentPack) + .Concat(MultiplayerData.GetModCustomAssemblies(contentPack))) { var relPath = asm.FullName.RemovePrefix(contentPack.RootDir).NormalizePath(); fileDict.Add(modId, new ModFile(asm.FullName, relPath, asm.CRC32())); diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index 0b74bab3b..cf932cd98 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -83,7 +83,7 @@ public void HandleJoinData(ServerJoinDataPacket packet) Multiplayer.session.gameName = packet.gameName; Multiplayer.session.playerId = packet.playerId; - var defDiff = false; + var defDiff = packet.missingDefs.Length > 0; var defStatusMap = new Dictionary(); var i = 0; foreach (var local in MultiplayerData.localDefInfos) @@ -95,6 +95,8 @@ public void HandleJoinData(ServerJoinDataPacket packet) defDiff = true; } + var missingDefs = packet.missingDefs; + var remoteInfo = RemoteData.FromNet(packet); // Captured before Complete runs: StopMultiplayerAndClearAllWindows nulls the session var connector = Multiplayer.session.connector; @@ -116,8 +118,11 @@ void Complete() var defDiffStr = "\n\n" + MultiplayerData.localDefInfos .Select(kv => (name: kv.Key, def: kv.Value, status: defStatusMap[kv.Value])) .Where(kv => kv.status != DefCheckStatus.Ok) + .Select(kv => $"{kv.name}: {kv.status}") + // Def types only the server has + .Concat(missingDefs.Select(name => $"{name}: Missing")) .Take(10) - .Join(kv => $"{kv.name}: {kv.status}", "\n"); + .Join(s => s, "\n"); Find.WindowStack.Add(new JoinDataWindow(remoteInfo, connector) { diff --git a/Source/Common/Networking/Packet/JoinDataPacket.cs b/Source/Common/Networking/Packet/JoinDataPacket.cs index 7d1d9782b..cd5c35262 100644 --- a/Source/Common/Networking/Packet/JoinDataPacket.cs +++ b/Source/Common/Networking/Packet/JoinDataPacket.cs @@ -5,11 +5,16 @@ namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Server_JoinData, allowFragmented: true)] public record struct ServerJoinDataPacket : IPacket { + public const int MaxMissingDefs = 512; + public string gameName; public int playerId; public string rwVersion; public string mpVersion; + // Aligned with the client's def infos from ClientJoinDataPacket public DefCheckStatus[] defStatus; + // Def types the server knows but the client didn't send + public string[] missingDefs; public bool configsIncluded; public byte[] rawServerInitData; @@ -26,11 +31,15 @@ public void Bind(PacketBuffer buf) buf.Bind(ref rwVersion); buf.Bind(ref mpVersion); buf.Bind(ref defStatus, BinderOf.Enum()); + buf.Bind(ref missingDefs, MissingDefBinder, maxLength: MaxMissingDefs); buf.Bind(ref configsIncluded); // The server forwards the first joiner's (or host's) ClientInitDataPacket.rawMods, so the limits must match. // As an example, one game with ~100 mods used ~35KiB, synced configs can add a lot more. buf.BindRemaining(ref rawServerInitData, maxLength: ClientInitDataPacket.MaxRawModsLength); } + + private static readonly Binder MissingDefBinder = (PacketBuffer buf, ref string name) => + buf.Bind(ref name, maxLength: KeyedDefInfo.MaxNameLength); } [PacketDefinition(Packets.Client_JoinData, allowFragmented: true)] @@ -50,13 +59,15 @@ public void Bind(PacketBuffer buf) public record struct KeyedDefInfo : IPacketBufferable { + public const int MaxNameLength = 128; + public string name; public int count; public int hash; public void Bind(PacketBuffer buf) { - buf.Bind(ref name, maxLength: 128); + buf.Bind(ref name, maxLength: MaxNameLength); buf.Bind(ref count); buf.Bind(ref hash); } diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index cf35a8965..68ef593f0 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -156,6 +157,15 @@ private bool HandleClientJoinData(ClientJoinDataPacket packet) defStatus[i] = status; } + // The statuses above only cover the def types the client knows about + var clientDefNames = new HashSet(packet.defInfos.Select(info => info.name)); + var missingDefs = serverInitData.DefInfos.Keys + .Where(name => !clientDefNames.Contains(name)) + .Take(ServerJoinDataPacket.MaxMissingDefs) + .ToArray(); + if (missingDefs.Length > 0) + defsMatch = false; + connection.SendFragmented(new ServerJoinDataPacket { gameName = Server.settings.gameName, @@ -163,6 +173,7 @@ private bool HandleClientJoinData(ClientJoinDataPacket packet) rwVersion = serverInitData.RwVersion, mpVersion = MpVersion.Version, defStatus = defStatus, + missingDefs = missingDefs, configsIncluded = serverInitData.IncludeConfigs, rawServerInitData = serverInitData.RawData, }.Serialize()); diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index 9e8b83277..e6ac62012 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 = 56; + public const int Protocol = 57; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/Tests/Helper/RecordingConnection.cs b/Source/Tests/Helper/RecordingConnection.cs index e219992a1..3c5c0da83 100644 --- a/Source/Tests/Helper/RecordingConnection.cs +++ b/Source/Tests/Helper/RecordingConnection.cs @@ -7,6 +7,7 @@ namespace Tests; public class RecordingConnection : ConnectionBase { public List SentPackets { get; } = new(); + public List SentRaw { get; } = new(); public RecordingConnection(string username) { @@ -21,6 +22,7 @@ protected override void SendRaw(byte[] raw, bool reliable) return; SentPackets.Add((Packets)(raw[0] & 0x3F)); + SentRaw.Add(raw); } protected override void OnClose(ServerDisconnectPacket? goodbye) { } diff --git a/Source/Tests/JoiningStateTest.cs b/Source/Tests/JoiningStateTest.cs index efbeae3c2..bfc9abfb1 100644 --- a/Source/Tests/JoiningStateTest.cs +++ b/Source/Tests/JoiningStateTest.cs @@ -154,4 +154,37 @@ public void IdleHandshakeTimesOut() Assert.That(conn.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); Assert.That(server.playerManager.Players, Is.Empty); } + + [Test] + public void DefTypesMissingOnClientFailTheDefCheck() + { + var a = new KeyedDefInfo { name = "A", count = 1, hash = 1 }; + var b = new KeyedDefInfo { name = "B", count = 2, hash = 2 }; + + var (first, firstConn) = Connect(); + SendHandshake(firstConn, "first"); + Receive(firstConn, InitData(a, b)); + + var (second, secondConn) = Connect(); + SendHandshake(secondConn, "second"); + Receive(secondConn, JoinData(a)); + + var raw = secondConn.SentRaw.Single(r => (Packets)(r[0] & 0x3F) == Packets.Server_JoinData); + var joinData = default(ServerJoinDataPacket); + joinData.Bind(new PacketReader(new ByteReader(raw.Skip(1).ToArray()))); + + Assert.That(joinData.defStatus, Is.EqualTo(new[] { DefCheckStatus.Ok })); + Assert.That(joinData.missingDefs, Is.EqualTo(new[] { "B" })); + Assert.That(second.hasJoined, Is.False); + + Receive(firstConn, JoinData(a, b)); + Assert.That(first.hasJoined, Is.True); + } + + private static ClientJoinDataPacket JoinData(params KeyedDefInfo[] defInfos) => new() + { + modCtorRoundMode = RoundModeEnum.ToNearest, + staticCtorRoundMode = RoundModeEnum.ToNearest, + defInfos = defInfos + }; } diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index fbe1eac84..72c7161b0 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -208,6 +208,7 @@ private static IEnumerable RoundtripPackets() DefCheckStatus.Ok, DefCheckStatus.Ok, DefCheckStatus.Count_Diff, DefCheckStatus.Hash_Diff, DefCheckStatus.Not_Found ], + missingDefs = ["ServerOnlyDef"], configsIncluded = false, ServerInitData = [mpModData], }; diff --git a/Source/Tests/ReaderBoundsTest.cs b/Source/Tests/ReaderBoundsTest.cs index e3752e8d0..20978e390 100644 --- a/Source/Tests/ReaderBoundsTest.cs +++ b/Source/Tests/ReaderBoundsTest.cs @@ -119,7 +119,8 @@ string RandomText(int bytes) var roundtrip = Roundtrip(initData); var joinData = Roundtrip(new ServerJoinDataPacket { - gameName = "", rwVersion = "", mpVersion = "", defStatus = [], rawServerInitData = roundtrip.rawMods + gameName = "", rwVersion = "", mpVersion = "", defStatus = [], missingDefs = [], + rawServerInitData = roundtrip.rawMods }); Assert.That(joinData.ServerInitData, Has.Count.EqualTo(mods.Count)); } diff --git a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt index 98e950920..4f16979ee 100644 --- a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt @@ -1,3 +1,3 @@ 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) +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-00-00-00-0D-00-00-00-53-65-72-76-65-72-4F-6E-6C-79-44-65-66-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-02-00 (118 bytes) From 2e921f57fb784bcf901838635e66d10713043fd5 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:55:20 -0600 Subject: [PATCH 036/183] Make fragile determinism transpilers fail loudly - TryGetRandomUnusedSolidName: load the list the same way the Shuffle call does instead of hardcoding ldloc.1 (falls back to it with an error), and log if FirstOrDefault isn't found. - TargetInfo/GlobalTargetInfo hash codes and ProcessPostTickVisuals: log an error when nothing was patched. - Plan color gizmo: find the lambda with MpMethodUtil.GetLambda instead of the hardcoded display class name and check its captured fields. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Determinism.cs | 34 +++++++++++++++++++++++++++- Source/Client/Patches/HashCodes.cs | 10 +++++++- Source/Client/Patches/Plans.cs | 19 ++++++++++++---- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index 8cd328af3..6b0da3917 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -70,20 +70,46 @@ static class GenerateNewPawnInternalPatch e => e.FirstOrDefault() ); + static MethodBase Shuffle = AccessTools.Method(typeof(GenList), nameof(GenList.Shuffle)).MakeGenericMethod(typeof(NameTriple)); + static IEnumerable Transpiler(IEnumerable e) { List insts = new List(e); + // Load the list the same way it's loaded for the shuffle call + CodeInstruction loadList = null; + for (int i = 1; i < insts.Count; i++) + { + if (insts[i].Calls((MethodInfo)Shuffle) && insts[i - 1].IsLdloc()) + { + loadList = new CodeInstruction(insts[i - 1].opcode, insts[i - 1].operand); + break; + } + } + + if (loadList == null) + { + Log.Error("MP: Couldn't find the shuffled name list in PawnBioAndNameGenerator.TryGetRandomUnusedSolidName, assuming local 1. Was the original method changed?"); + loadList = new CodeInstruction(OpCodes.Ldloc_1); + } + + int patched = 0; for (int i = insts.Count - 1; i >= 0; i--) { if (insts[i].operand as MethodBase == FirstOrDefault) + { insts.Insert( i + 1, - new CodeInstruction(OpCodes.Ldloc_1), + loadList.Clone(), new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(GenerateNewPawnInternalPatch), nameof(Unshuffle)).MakeGenericMethod(typeof(NameTriple))) ); + patched++; + } } + if (patched == 0) + Log.Error("MP: Couldn't find FirstOrDefault in PawnBioAndNameGenerator.TryGetRandomUnusedSolidName. Was the original method changed?"); + return insts; } @@ -134,6 +160,8 @@ static class DrawTrackerTickPatch static IEnumerable Transpiler(IEnumerable insts) { + int patched = 0; + foreach (var inst in insts) { yield return inst; @@ -142,8 +170,12 @@ static IEnumerable Transpiler(IEnumerable inst { yield return new CodeInstruction(OpCodes.Ldc_I4_1); yield return new CodeInstruction(OpCodes.Or); + patched++; } } + + if (patched == 0) + Log.Error("MP: Couldn't find CellRect.Contains in Pawn.ProcessPostTickVisuals. Was the original method changed?"); } } diff --git a/Source/Client/Patches/HashCodes.cs b/Source/Client/Patches/HashCodes.cs index b10ab5ab9..bfe0ba254 100644 --- a/Source/Client/Patches/HashCodes.cs +++ b/Source/Client/Patches/HashCodes.cs @@ -68,15 +68,23 @@ static IEnumerable TargetMethods() yield return AccessTools.Method(typeof(TargetInfo), nameof(TargetInfo.GetHashCode)); } - static IEnumerable Transpiler(IEnumerable insts) + static IEnumerable Transpiler(IEnumerable insts, MethodBase original) { + int patched = 0; + foreach (var inst in insts) { if (inst.operand as MethodInfo == Combine) + { inst.operand = AccessTools.Method(typeof(PatchTargetInfoHashCodes), nameof(CombineHashes)); + patched++; + } yield return inst; } + + if (patched == 0) + Log.Error($"MP: Couldn't find Gen.HashCombine in {original.DeclaringType}:{original.Name}. Was the original method changed?"); } static int CombineHashes(int seed, Map map) => Gen.HashCombineInt(seed, map?.uniqueID ?? -1); diff --git a/Source/Client/Patches/Plans.cs b/Source/Client/Patches/Plans.cs index 42f2f89fa..3a6439bb5 100644 --- a/Source/Client/Patches/Plans.cs +++ b/Source/Client/Patches/Plans.cs @@ -1,5 +1,6 @@ using HarmonyLib; using Multiplayer.API; +using Multiplayer.Client.Util; using RimWorld; using System; using System.Collections.Generic; @@ -11,18 +12,28 @@ namespace Multiplayer.Client.Patches [HarmonyPatch] public static class PlanGetGizmosPatch { + private static FieldInfo planField; + private static FieldInfo colorField; + static IEnumerable TargetMethods() { - Type planType = AccessTools.TypeByName("Verse.Plan+<>c__DisplayClass45_0"); - yield return AccessTools.Method(planType, "b__1"); + // The color option's action, <>c__DisplayClass45_0.b__1 in 1.6 + var lambda = MpMethodUtil.GetLambda(typeof(Plan), nameof(Plan.GetGizmos), lambdaOrdinal: 1); + + planField = AccessTools.Field(lambda.DeclaringType, "<>4__this"); + colorField = AccessTools.Field(lambda.DeclaringType, "newCol"); + if (planField == null || colorField == null) + throw new Exception($"Plan color lambda {lambda.DeclaringType}::{lambda.Name} doesn't capture the expected fields"); + + yield return lambda; } static bool Prefix(object __instance) { if (Multiplayer.Client == null) return true; - Plan plan = (Plan)AccessTools.Field(__instance.GetType(), "<>4__this").GetValue(__instance); - ColorDef color = (ColorDef)AccessTools.Field(__instance.GetType(), "newCol").GetValue(__instance); + Plan plan = (Plan)planField.GetValue(__instance); + ColorDef color = (ColorDef)colorField.GetValue(__instance); SyncSetPlanColor(plan, color); return false; From 578c0a60fd506e4533b539d8dce5a0374a9bed0c Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:56:20 -0600 Subject: [PATCH 037/183] Create factions with deterministic faction data on every client FactionCreator's long event runs after the CreateFaction command, i.e. with each client's own faction applied, and MpScope.PushFaction only set Faction.OfPlayer. So per client: - the new faction's fallback ideo was Faction.OfPlayer's (the local player's faction), - ApplyPlayerStartingResearch / ScenPart_StartingResearch finished projects in the local player's research manager, - map generation used the local player's play settings, - FactionWorldData.New copied the local player's storyteller. FactionManager.Add also reset Faction.OfPlayer to the first player faction, which the following pops then restored as 'previous' faction. The long event now runs under a forced spectator push (world data and all maps) and finally re-applies the local player's faction (the issuer switches to the new faction inside). MpScope.PushFaction does a forced push, so the new faction's world data is really applied. Faction.OfPlayer is restored after FactionManager.Add. New storytellers get a copy of the difficulty instead of sharing the instance (sharing only exists in memory, so changing one faction's difficulty changed both only on clients that hadn't reloaded). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Comp/World/FactionWorldData.cs | 14 +++++++++++--- Source/Client/Factions/FactionCreator.cs | 6 ++++++ Source/Client/Factions/FactionExtensions.cs | 14 ++++++++++++++ Source/Client/Util/MpScope.cs | 11 ++++++++++- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Source/Client/Comp/World/FactionWorldData.cs b/Source/Client/Comp/World/FactionWorldData.cs index 20b43fe7b..0e87ffc2e 100644 --- a/Source/Client/Comp/World/FactionWorldData.cs +++ b/Source/Client/Comp/World/FactionWorldData.cs @@ -42,8 +42,7 @@ public void ExposeData() playSettings ??= new PlaySettings(); history ??= new History(); - storyteller ??= new Storyteller(Find.Storyteller.def, Find.Storyteller.difficultyDef, - Find.Storyteller.difficulty); + storyteller ??= NewStoryteller(); storyWatcher ??= new StoryWatcher(); } } @@ -73,11 +72,20 @@ public static FactionWorldData New(int factionId) playSettings = new PlaySettings(), history = new History(), - storyteller = new Storyteller(Find.Storyteller.def, Find.Storyteller.difficultyDef, Find.Storyteller.difficulty), + storyteller = NewStoryteller(), storyWatcher = new StoryWatcher() }; } + // Copies the current storyteller's settings. The difficulty is copied, not shared: a shared instance would only + // exist in memory (saving separates it), so changing one faction's difficulty would change both only on some clients + private static Storyteller NewStoryteller() + { + var current = Find.Storyteller; + var difficulty = current.difficulty != null ? Gen.MemberwiseClone(current.difficulty) : null; + return new Storyteller(current.def, current.difficultyDef, difficulty); + } + public static FactionWorldData FromCurrent(int factionId) { return new FactionWorldData() diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index c26ea721e..46b0fbb36 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -46,6 +46,9 @@ public static void CreateFaction(int playerId, FactionCreationData creationData) LongEventHandler.QueueLongEvent(() => { + // The long event runs after the command, with each client's own faction data applied + using var _ = MpScope.PushFactionOutsideContext(Multiplayer.WorldComp.spectatorFaction); + var scenario = creationData.scenarioDef?.scenario ?? Current.Game.Scenario; Map newMap = null; @@ -237,7 +240,10 @@ private static Faction NewFactionWithIdeo(string name, Color color, FactionDef d foreach (Faction other in Find.FactionManager.AllFactionsListForReading) faction.TryMakeInitialRelationsWith(other); + // Add recaches Faction.OfPlayer to the first player faction, keep the current context + var ofPlayer = Find.FactionManager.ofPlayer; Find.FactionManager.Add(faction); + Find.FactionManager.ofPlayer = ofPlayer; var newWorldFactionData = FactionWorldData.New(faction.loadID); Multiplayer.WorldComp.factionData[faction.loadID] = newWorldFactionData; diff --git a/Source/Client/Factions/FactionExtensions.cs b/Source/Client/Factions/FactionExtensions.cs index a3fad5284..692085adf 100644 --- a/Source/Client/Factions/FactionExtensions.cs +++ b/Source/Client/Factions/FactionExtensions.cs @@ -50,6 +50,20 @@ public static Faction PopFaction(this Map map) return faction; } + // Pops a push made outside of ticks and commands and re-applies the local player's faction everywhere (the resting + // state there) instead of the faction saved by the push, as the code in between may have changed the local faction + public static void PopToLocalFaction() + { + FactionContext.Pop(); + + var faction = Multiplayer.RealPlayerFaction; + if (faction == null) return; + + FactionContext.Set(faction); + Multiplayer.WorldComp?.SetFaction(faction); + SetFactionOnAllMaps(faction); + } + private static void SetFactionOnAllMaps(Faction faction) { // Includes comps of removed maps (they're never removed from the list), which is harmless diff --git a/Source/Client/Util/MpScope.cs b/Source/Client/Util/MpScope.cs index 624fe1798..a38b8d77b 100644 --- a/Source/Client/Util/MpScope.cs +++ b/Source/Client/Util/MpScope.cs @@ -1,4 +1,5 @@ using System; +using Multiplayer.Client.Factions; using RimWorld; namespace Multiplayer.Client.Util; @@ -18,8 +19,16 @@ public static MpScope Create(Action begin, Action end) return new MpScope { end = end }; } + // Applies the faction to Faction.OfPlayer, the world data and every map (like a tick does), not only Faction.OfPlayer public static MpScope PushFaction(Faction f) { - return Create(() => FactionContext.Push(f), () => FactionContext.Pop()); + return Create(() => FactionExtensions.PushFaction(null, f, force: true), () => FactionExtensions.PopFaction()); + } + + // For simulation code running outside of ticks and commands (e.g. long events queued by commands), where the + // current faction data is the local player's. Ends by re-applying the local player's faction everywhere. + public static MpScope PushFactionOutsideContext(Faction f) + { + return Create(() => FactionExtensions.PushFaction(null, f, force: true), FactionExtensions.PopToLocalFaction); } } From 8bc60df2bb2097cb9631f6a602326599851e2a1e Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:56:22 -0600 Subject: [PATCH 038/183] Sync world pawns and genes reliably, fix sync worker edge cases Wire format changes, protocol bumped to 57: - Things: pawns without a supported holder (world pawns, pawns only kept by quests/letters) were written as inaccessible and read as null. Add a holder kind that looks them up by id in WorldPawns / PawnsFinder. ChoiceLetter_ChoosePawn's option captures such an unspawned reward candidate; also cancel it if any captured field reads as null instead of running with a null pawn. - Gene: also send the gene's loadID. Resolving by def alone picked the first gene when a pawn has the same def as both an endogene and a xenogene. Other: - Hediff reader used First, throwing instead of reaching the null check. - PlanetLayer writer threw on null instead of writing the -1 marker. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/Dict/SyncDictDlc.cs | 9 ++++++++- Source/Client/Syncing/Dict/SyncDictRimWorld.cs | 14 +++++++++----- Source/Client/Syncing/Game/SyncDelegates.cs | 2 +- Source/Client/Syncing/RwSerialization.cs | 12 +++++++++++- Source/Common/Version.cs | 2 +- 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/Source/Client/Syncing/Dict/SyncDictDlc.cs b/Source/Client/Syncing/Dict/SyncDictDlc.cs index 7e31767fd..ed74cee1e 100644 --- a/Source/Client/Syncing/Dict/SyncDictDlc.cs +++ b/Source/Client/Syncing/Dict/SyncDictDlc.cs @@ -229,13 +229,20 @@ public static class SyncDictDlc { WriteSync(data, gene.def); WriteSync(data, gene.pawn); + data.WriteInt32(gene.loadID); }, (ByteReader data) => { var geneDef = ReadSync(data); var pawn = ReadSync(data); + var loadId = data.ReadInt32(); - return pawn.genes.GetGene(geneDef); + var genes = pawn?.genes; + if (genes == null) + return null; + + // A pawn can have the same gene def both as an endogene and a xenogene + return genes.GenesListForReading.Find(g => g.loadID == loadId) ?? genes.GetGene(geneDef); }, true // implicit }, { diff --git a/Source/Client/Syncing/Dict/SyncDictRimWorld.cs b/Source/Client/Syncing/Dict/SyncDictRimWorld.cs index 21801e478..1749abb80 100644 --- a/Source/Client/Syncing/Dict/SyncDictRimWorld.cs +++ b/Source/Client/Syncing/Dict/SyncDictRimWorld.cs @@ -161,7 +161,7 @@ public static class SyncDictRimWorld return; } - hediff = pawn.health.hediffSet.hediffs.First(x => x.loadID == id); + hediff = pawn.health.hediffSet.hediffs.FirstOrDefault(x => x.loadID == id); if (hediff == null) { @@ -819,6 +819,8 @@ public static class SyncDictRimWorld holder = worldObj; else if (RwSerialization.GetAnyParent(thing) is { } worldObjComp) holder = worldObjComp; + else if (thing is Pawn pawn && RwSerialization.CanFindUnheldPawn(pawn)) + holder = Find.WorldPawns; // World pawns, pawns kept by quests/letters, etc. RwSerialization.GetImpl(holder, RwSerialization.supportedThingHolders, out Type implType, out int index); if (index == -1) @@ -830,7 +832,7 @@ public static class SyncDictRimWorld data.WriteByte((byte)index); - if (implType != typeof(Map)) + if (implType != typeof(Map) && implType != typeof(WorldPawns)) { context.syncingThingParent = true; WriteSyncObject(data, holder, implType); @@ -853,6 +855,9 @@ public static class SyncDictRimWorld Type implType = RwSerialization.supportedThingHolders[implIndex]; + if (implType == typeof(WorldPawns)) + return RwSerialization.FindUnheldPawn(thingId); + if (implType != typeof(Map)) { context.syncingThingParent = true; @@ -1059,10 +1064,9 @@ public static class SyncDictRimWorld (ByteReader data) => new PlanetTile(data.ReadInt32(), data.ReadInt32()), true // Implicit }, { - (ByteWriter data, PlanetLayer workGiver) => + (ByteWriter data, PlanetLayer layer) => { - int layerId = workGiver?.LayerID ?? -1; - WriteSync(data, workGiver.LayerID); + WriteSync(data, layer?.LayerID ?? -1); }, (ByteReader data) => { diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index 82e74ed7c..40d3091a5 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -337,7 +337,7 @@ The UI's main interaction area is split into three types of groups of pawns. private static void InitChoiceLetters() { - SyncDelegate.Lambda(typeof(ChoiceLetter_ChoosePawn), nameof(ChoiceLetter_ChoosePawn.Option_ChoosePawn), 0); // Choose pawn (currently used for quest rewards) + SyncDelegate.Lambda(typeof(ChoiceLetter_ChoosePawn), nameof(ChoiceLetter_ChoosePawn.Option_ChoosePawn), 0).CancelIfAnyFieldNull(); // Choose pawn (currently used for quest rewards) SyncMethod.LambdaInGetter(typeof(ChoiceLetter_AcceptJoiner), nameof(ChoiceLetter_AcceptJoiner.Choices), 0); // Accept joiner CloseDialogsForExpiredLetters.RegisterDefaultLetterChoice( diff --git a/Source/Client/Syncing/RwSerialization.cs b/Source/Client/Syncing/RwSerialization.cs index 3bdbd63d8..eba9068a1 100644 --- a/Source/Client/Syncing/RwSerialization.cs +++ b/Source/Client/Syncing/RwSerialization.cs @@ -7,6 +7,7 @@ using HarmonyLib; using Multiplayer.API; using Multiplayer.Common; +using RimWorld; using RimWorld.Planet; using Verse; @@ -139,9 +140,18 @@ internal static T GetAnyParent(Thing thing) where T : class typeof(Thing), typeof(ThingComp), typeof(WorldObject), - typeof(WorldObjectComp) + typeof(WorldObjectComp), + typeof(WorldPawns) // Not an IThingHolder, marks a pawn looked up by id (see FindUnheldPawn) }; + // Pawns without a supported holder: world pawns and pawns only referenced by quests, letters etc. + internal static bool CanFindUnheldPawn(Pawn pawn) => + pawn.IsWorldPawn() || PawnsFinder.AllMapsWorldAndTemporary_AliveOrDead.Contains(pawn); + + internal static Pawn FindUnheldPawn(int thingId) => + Find.WorldPawns.AllPawnsAliveOrDead.FirstOrDefault(p => p.thingIDNumber == thingId) ?? + PawnsFinder.AllMapsWorldAndTemporary_AliveOrDead.FirstOrDefault(p => p.thingIDNumber == thingId); + internal static string ThingHolderString(Thing thing) { StringBuilder builder = new StringBuilder(thing.ToString()); diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index 9e8b83277..e6ac62012 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 = 56; + public const int Protocol = 57; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() From 318bac4887f42501bf7ed96d5bc919045ff717d7 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:56:38 -0600 Subject: [PATCH 039/183] Include the selected world tile in the WorldSelected sync context Wire format change (protocol already bumped to 57 in this series). - SyncContext.WorldSelected only synced WorldSelector.selected. Also sync selectedTile and selectedLayer, and use this context for the dev "Generate settlement" tile gizmo, a non-capturing lambda which reads Find.WorldSelector.SelectedTile and so ran on every client's own selected tile. - HandleCmd reads the command context inside its try/finally, so a failing read can't leave a foreign selection applied. - SyncUtil.prevSelected was the empty placeholder list for map commands; it's now the local player's selection from before the command. - Archonexus core / crate: the post-invoke restore of the local float menu state now runs outside of the command (it's local UI state for the local selection and must not differ between clients during the simulation). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/Game/SyncDelegates.cs | 2 +- Source/Client/Syncing/Game/SyncMethods.cs | 18 ++++- Source/Client/Syncing/SyncUtil.cs | 82 +++++++++++++-------- 3 files changed, 68 insertions(+), 34 deletions(-) diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index 40d3091a5..010ac1f46 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -244,7 +244,7 @@ public static void Init() SyncDelegate.Lambda(typeof(GeneResourceDrainUtility), nameof(GeneResourceDrainUtility.GetResourceDrainGizmos), 1).SetDebugOnly(); // +10% resource SyncMethod.Register(typeof(CompBreakdownable), nameof(CompBreakdownable.Notify_Repaired)).SetDebugOnly(); // Dev repair breakdownable SyncDelegate.Lambda(typeof(CompCanBeDormant), nameof(CompCanBeDormant.CompGetGizmosExtra), 1).SetDebugOnly(); // Wake up after delay - SyncDelegate.Lambda(typeof(Tile), nameof(Tile.GetGizmos), 0).SetDebugOnly(); // Generate settlement + SyncDelegate.Lambda(typeof(Tile), nameof(Tile.GetGizmos), 0).SetContext(SyncContext.WorldSelected).SetDebugOnly(); // Generate settlement (reads Find.WorldSelector.SelectedTile) SyncDelegate.Lambda(typeof(Tile), nameof(Tile.GetGizmos), 4).SetDebugOnly(); // Generate map // Hediffs diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index 922209300..fb21164a9 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -488,11 +488,16 @@ public static void Init() core.GetMultiSelectFloatMenuOptions(Find.Selector.SelectedPawns).ExecuteEnumerable(); }) // After syncing and invoking the method, we want to restore the previous state of tmpPawnsCanReach. - // Just run the GetMultiSelectFloatMenuOptions method again so the list is populated. + // Just run the GetMultiSelectFloatMenuOptions method again so the list is populated. It's UI state for + // the local selection, so do it outside of the command to keep the clients' simulation identical. .SetPostInvoke((x, _) => { if (x is not Building_ArchonexusCore core) return; - core.GetMultiSelectFloatMenuOptions(SyncUtil.prevSelected.OfType()).ExecuteEnumerable(); + OnMainThread.Enqueue(() => + { + if (core.Spawned) + core.GetMultiSelectFloatMenuOptions(Find.Selector.SelectedPawns).ExecuteEnumerable(); + }); }); SyncMethod.Register(typeof(Building_ArchonexusCore), nameof(Building_ArchonexusCore.Activate)).SetDebugOnly(); // Activate archonexus core @@ -508,11 +513,16 @@ public static void Init() crate.GetMultiSelectFloatMenuOptions(Find.Selector.SelectedPawns).ExecuteEnumerable(); }) // After syncing and invoking the method, we want to restore the previous state of tmpPawnsCanReach. - // Just run the GetMultiSelectFloatMenuOptions method again so the list is populated. + // Just run the GetMultiSelectFloatMenuOptions method again so the list is populated. It's UI state for + // the local selection, so do it outside of the command to keep the clients' simulation identical. .SetPostInvoke((x, _) => { if (x is not Building_Crate crate) return; - crate.GetMultiSelectFloatMenuOptions(SyncUtil.prevSelected.OfType()).ExecuteEnumerable(); + OnMainThread.Enqueue(() => + { + if (crate.Spawned) + crate.GetMultiSelectFloatMenuOptions(Find.Selector.SelectedPawns).ExecuteEnumerable(); + }); }); // Double ExecuteWhenFinished ensures it'll load after MP Compat late patches, diff --git a/Source/Client/Syncing/SyncUtil.cs b/Source/Client/Syncing/SyncUtil.cs index 3740b0156..91e7908b0 100644 --- a/Source/Client/Syncing/SyncUtil.cs +++ b/Source/Client/Syncing/SyncUtil.cs @@ -49,47 +49,67 @@ public static SyncHandler HandleCmd(ByteReader data) throw; } - prevSelected = Find.Selector.selected; - prevWorldSelected = Find.WorldSelector.selected; - - bool shouldQueue = false; - - if (handler.context != SyncContext.None) + var worldSelector = Find.WorldSelector; + var restoreSelected = Find.Selector.selected; + var restoreWorldSelected = worldSelector.selected; + var restoreSelectedTile = worldSelector.selectedTile; + var restoreSelectedLayer = worldSelector.selectedLayer; + bool restoreWorldTile = false; + + // The local player's selection from before the command (map commands already replaced Find.Selector's) + prevSelected = AsyncTimeComp.prevSelected ?? restoreSelected; + prevWorldSelected = restoreWorldSelected; + + // Reading the context is inside the try, so a failure there can't leave the context applied either + try { - if (handler.context.HasFlag(SyncContext.MapMouseCell)) - { - IntVec3 mouseCell = SyncSerialization.ReadSync(data); - MouseCellPatch.result = mouseCell; - } + bool shouldQueue = false; - if (handler.context.HasFlag(SyncContext.MapSelected)) + if (handler.context != SyncContext.None) { - List selected = SyncSerialization.ReadSync>(data); - Find.Selector.selected = selected.Cast().AllNotNull().ToList(); + if (handler.context.HasFlag(SyncContext.MapMouseCell)) + { + IntVec3 mouseCell = SyncSerialization.ReadSync(data); + MouseCellPatch.result = mouseCell; + } + + if (handler.context.HasFlag(SyncContext.MapSelected)) + { + List selected = SyncSerialization.ReadSync>(data); + Find.Selector.selected = selected.Cast().AllNotNull().ToList(); + } + + if (handler.context.HasFlag(SyncContext.WorldSelected)) + { + List selected = SyncSerialization.ReadSync>(data); + FieldRefs.worldSelected(worldSelector) = selected.Cast().AllNotNull().ToList(); + + // Some world gizmos only read the selected tile (e.g. the dev "Generate settlement") + restoreWorldTile = true; + worldSelector.selectedTile = SyncSerialization.ReadSync(data); + worldSelector.selectedLayer = SyncSerialization.ReadSync(data); + } + + if (handler.context.HasFlag(SyncContext.QueueOrder_Down)) + shouldQueue = data.ReadBool(); } - if (handler.context.HasFlag(SyncContext.WorldSelected)) - { - List selected = SyncSerialization.ReadSync>(data); - FieldRefs.worldSelected(Find.WorldSelector) = selected.Cast().AllNotNull().ToList(); - } - - if (handler.context.HasFlag(SyncContext.QueueOrder_Down)) - shouldQueue = data.ReadBool(); - } + KeyIsDownPatch.shouldQueue = shouldQueue; - KeyIsDownPatch.shouldQueue = shouldQueue; - - try - { handler.Handle(data); } finally { MouseCellPatch.result = null; KeyIsDownPatch.shouldQueue = null; - Find.Selector.selected = prevSelected; - FieldRefs.worldSelected(Find.WorldSelector) = prevWorldSelected; + Find.Selector.selected = restoreSelected; + FieldRefs.worldSelected(worldSelector) = restoreWorldSelected; + + if (restoreWorldTile) + { + worldSelector.selectedTile = restoreSelectedTile; + worldSelector.selectedLayer = restoreSelectedLayer; + } } return handler; @@ -112,7 +132,11 @@ public static void WriteContext(SyncHandler handler, ByteWriter data) SyncSerialization.WriteSync(data, Find.Selector.selected.Cast().ToList()); if (handler.context.HasFlag(SyncContext.WorldSelected)) + { SyncSerialization.WriteSync(data, Find.WorldSelector.selected.Cast().ToList()); + SyncSerialization.WriteSync(data, Find.WorldSelector.selectedTile); + SyncSerialization.WriteSync(data, Find.WorldSelector.selectedLayer); + } if (handler.context.HasFlag(SyncContext.QueueOrder_Down)) data.WriteBool(KeyBindingDefOf.QueueOrder.IsDownEvent); From f5a41e86c90c40adfeefced3ad0ed821244b3f13 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:56:42 -0600 Subject: [PATCH 040/183] Make Rand push/pop exception safe Several patches pushed the RNG state in a prefix and popped it in a postfix, which doesn't run when the method throws: SeedGameLoad, SeedMapLoad, SeedMapFinalizeLoading, SeedCaravanEnter, SeedGrammar, SeedPawnGraphics, AnomalyUtility.TryGetNearbyUnseenCell and DeterministicUndercaveRockCollapse (whose inner push/pop was unguarded too). The enclosing tick or command context then saved the inner state as its own and left its entry orphaned, and Rand.stateStack.Count > 1 disabled desync traces for the rest of the session on that client. Pop in finalizers / try-finally instead. As a safety net, map and world Tick/ExecuteCmd remember the stack depth and unwind leftover pushes (logged once) before saving their state. TryGetNearbyUnseenCell is now seeded with the pawn id as well, so several calls in a tick don't all get the same random sequence, and only in MP. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 17 +++++++ Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 4 ++ Source/Client/Patches/Anomaly.cs | 15 ++++++- Source/Client/Patches/Determinism.cs | 45 +++++++++++++------ Source/Client/Patches/Seeds.cs | 12 ++--- 5 files changed, 71 insertions(+), 22 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 32233940b..6b99b3c0d 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -111,6 +111,7 @@ public AsyncTimeComp(Map map, int gameStartAbsTick = 0) public void Tick() { tickingMap = map; + int randDepth = Rand.stateStack.Count; PreContext(); //SimpleProfiler.Start(); @@ -148,6 +149,7 @@ public void Tick() } finally { + RestoreRandStack(randDepth + 1, this); PostContext(); Multiplayer.game.sync.TryAddMapRandomState(map.uniqueID, randState); eventCount++; @@ -203,6 +205,19 @@ public void PostContext() map.PopFaction(); } + // A Rand push left unpopped by an exception would otherwise be saved as the context's state, orphan the + // context's own entry and keep the stack too deep (which disables desync traces) for the rest of the session + public static void RestoreRandStack(int depth, object context) + { + int count = Rand.stateStack.Count; + if (count == depth) return; + + Log.ErrorOnce($"Multiplayer: unbalanced Rand push/pop in {context}: stack depth {count}, expected {depth}", 0x5A3D17 ^ depth); + + while (Rand.stateStack.Count > depth) + Rand.PopState(); + } + public void ExposeData() { Scribe_Values.Look(ref mapTicks, "mapTicks"); @@ -246,6 +261,7 @@ public void ExecuteCmd(ScheduledCommand cmd) TickPatch.currentExecutingCmdIssuedBySelf = cmd.IsIssuedBySelf() && !TickPatch.Simulating; TickPatch.currentExecutingCmdType = cmdType; + int randDepth = Rand.stateStack.Count; PreContext(); map.PushFaction(cmd.GetFaction(), force: true); @@ -306,6 +322,7 @@ public void ExecuteCmd(ScheduledCommand cmd) Find.MainButtonsRoot.tabs.Notify_SelectedObjectDespawned(); + RestoreRandStack(randDepth + 1, this); map.PopFaction(); PostContext(); diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 8184163ab..fa1fef3e3 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -103,6 +103,7 @@ public void ExposeData() public void Tick() { tickingWorld = true; + int randDepth = Rand.stateStack.Count; PreContext(); try @@ -134,6 +135,7 @@ public void Tick() } finally { + AsyncTimeComp.RestoreRandStack(randDepth + 1, this); PostContext(); tickingWorld = false; @@ -177,6 +179,7 @@ public void ExecuteCmd(ScheduledCommand cmd) TickPatch.currentExecutingCmdIssuedBySelf = cmd.IsIssuedBySelf() && !TickPatch.Simulating; TickPatch.currentExecutingCmdType = cmdType; + int randDepth = Rand.stateStack.Count; PreContext(); FactionExtensions.PushFaction(null, cmd.GetFaction()); @@ -262,6 +265,7 @@ public void ExecuteCmd(ScheduledCommand cmd) MpLog.Debug($"rand calls {DeferredStackTracing.randCalls - randCalls1}"); MpLog.Debug("rand state " + Rand.StateCompressed); + AsyncTimeComp.RestoreRandStack(randDepth + 1, this); FactionExtensions.PopFaction(); PostContext(); TickPatch.currentExecutingCmdIssuedBySelf = false; diff --git a/Source/Client/Patches/Anomaly.cs b/Source/Client/Patches/Anomaly.cs index aa6a4d553..70691d962 100644 --- a/Source/Client/Patches/Anomaly.cs +++ b/Source/Client/Patches/Anomaly.cs @@ -33,7 +33,18 @@ static CellRect EmptyCellRect() return Multiplayer.Client != null ? CellRect.Empty : Find.CameraDriver.CurrentViewRect; } - static void Prefix() => Rand.PushState(Find.TickManager.TicksAbs); - static void Postfix() => Rand.PopState(); + // Seed per pawn so that multiple calls in one tick don't all pick the same cell + static void Prefix(Pawn pawn, ref bool __state) + { + if (Multiplayer.Client == null) return; + Rand.PushState(Gen.HashCombineInt(pawn?.thingIDNumber ?? 0, Find.TickManager.TicksAbs)); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + Rand.PopState(); + } } } \ No newline at end of file diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index 9bdf674e2..421d95af4 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -559,12 +559,17 @@ static IEnumerable Transpiler(IEnumerable inst } } - static void Prefix() => Rand.PushState(); + static void Prefix(ref bool __state) + { + Rand.PushState(); + __state = true; + } - static void Postfix(UndercaveMapComponent __instance) + static void Postfix(UndercaveMapComponent __instance, ref bool __state) { // Pop the RNG state from the prefix Rand.PopState(); + __state = false; // Make sure the pit gate is collapsing if (__instance.pitGate is not { IsCollapsing: true }) @@ -580,21 +585,33 @@ static void Postfix(UndercaveMapComponent __instance) // effecters - it would not be MP safe, but since it happens last it will be fine once we pop the state. Rand.PushState(); - // If not looking at the map, trigger the collapse without shake/effecters (since it's not needed for current player). - // The call to play a sound is handled by RW itself, since it targets a specific map already. - if (Find.CurrentMap != __instance.map) + try { - // Progress the RNG state, matching the RandomInRange call in other two cases - Rand.RangeInclusive(0, 100); - __instance.TriggerCollapseFX(0, 0); + // If not looking at the map, trigger the collapse without shake/effecters (since it's not needed for current player). + // The call to play a sound is handled by RW itself, since it targets a specific map already. + if (Find.CurrentMap != __instance.map) + { + // Progress the RNG state, matching the RandomInRange call in other two cases + Rand.RangeInclusive(0, 100); + __instance.TriggerCollapseFX(0, 0); + } + // Else, follow vanilla shake/effecter rules + else if (__instance.pitGate.CollapseStage == 1) + __instance.TriggerCollapseFX(UndercaveMapComponent.StageOneShakeAmount, UndercaveMapComponent.StageOneNumCollapseEffects.RandomInRange); + else + __instance.TriggerCollapseFX(UndercaveMapComponent.StageTwoShakeAmount, UndercaveMapComponent.StageTwoNumCollapseEffects.RandomInRange); } - // Else, follow vanilla shake/effecter rules - else if (__instance.pitGate.CollapseStage == 1) - __instance.TriggerCollapseFX(UndercaveMapComponent.StageOneShakeAmount, UndercaveMapComponent.StageOneNumCollapseEffects.RandomInRange); - else - __instance.TriggerCollapseFX(UndercaveMapComponent.StageTwoShakeAmount, UndercaveMapComponent.StageTwoNumCollapseEffects.RandomInRange); + finally + { + Rand.PopState(); + } + } - Rand.PopState(); + // Pops the prefix state if MapComponentTick threw before the postfix could + static void Finalizer(bool __state) + { + if (__state) + Rand.PopState(); } } diff --git a/Source/Client/Patches/Seeds.cs b/Source/Client/Patches/Seeds.cs index 3219b76a1..0077e9498 100644 --- a/Source/Client/Patches/Seeds.cs +++ b/Source/Client/Patches/Seeds.cs @@ -24,7 +24,7 @@ static void Prefix() Rand.Seed = 1; } - static void Postfix() + static void Finalizer() { Rand.PopState(); } @@ -45,7 +45,7 @@ static void Prefix(Map __instance, ref bool __state) __state = true; } - static void Postfix(Map __instance, bool __state) + static void Finalizer(bool __state) { if (__state) Rand.PopState(); @@ -66,7 +66,7 @@ static void Prefix(Map __instance, ref bool __state) __state = true; } - static void Postfix(Map __instance, bool __state) + static void Finalizer(bool __state) { if (__state) Rand.PopState(); @@ -86,7 +86,7 @@ static void Prefix(Map map, ref bool __state) __state = true; } - static void Postfix(Map map, bool __state) + static void Finalizer(bool __state) { if (__state) Rand.PopState(); @@ -196,7 +196,7 @@ static void Prefix(ref bool __state) } [HarmonyPriority(MpPriority.MpLast)] - static void Postfix(bool __state) + static void Finalizer(bool __state) { if (__state) Rand.PopState(); @@ -219,7 +219,7 @@ static void Prefix(PawnRenderer __instance, ref bool __state) } [HarmonyPriority(MpPriority.MpLast)] - static void Postfix(bool __state) + static void Finalizer(bool __state) { if (__state) Rand.PopState(); From c189a0c2c09f6ac37ac48ee1b7c4717dc922710c Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:56:48 -0600 Subject: [PATCH 041/183] Stop the join flow of players who left while waiting for init data A joiner waiting for someone else's init data resumed when it arrived even if it had disconnected meanwhile, and went on to wait for join data on the dead connection. Check the state is alive after the loop. Co-Authored-By: Claude Opus 5.5 --- .../Networking/State/ServerJoiningState.cs | 2 ++ Source/Tests/JoiningStateTest.cs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index 68ef593f0..38edd34ff 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -28,6 +28,8 @@ protected override async Task RunState() while (await Server.InitDataTask() is null && await EndIfDead()) if (Server.InitDataState == InitDataState.Waiting) await RequestInitData(); + // The player may have left while waiting for someone else's init data + await EndIfDead(); connection.Send(Packets.Server_UsernameOk); diff --git a/Source/Tests/JoiningStateTest.cs b/Source/Tests/JoiningStateTest.cs index bfc9abfb1..b82e88aef 100644 --- a/Source/Tests/JoiningStateTest.cs +++ b/Source/Tests/JoiningStateTest.cs @@ -187,4 +187,21 @@ public void DefTypesMissingOnClientFailTheDefCheck() staticCtorRoundMode = RoundModeEnum.ToNearest, defInfos = defInfos }; + + [Test] + public void JoinerLeavingWhileWaitingForInitDataStops() + { + var (_, first) = Connect(); + SendHandshake(first, "first"); + var (_, second) = Connect(); + SendHandshake(second, "second"); + + var secondState = second.GetState()!; + server.playerManager.SetDisconnected(second, MpDisconnectReason.ClientLeft); + Receive(first, InitData()); + + Assert.That(first.SentPackets, Does.Contain(Packets.Server_UsernameOk)); + // The dead state's flow stopped instead of going on to wait for join data + Assert.That(secondState.AwaitedPacket, Is.Null); + } } From 109b5f280b5284bfe2f589610947fd1e198686a9 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:57:36 -0600 Subject: [PATCH 042/183] Log leaked field watch scopes only once An API user leaking a watch scope every frame would spam the log. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/SyncFieldUtil.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Client/Syncing/SyncFieldUtil.cs b/Source/Client/Syncing/SyncFieldUtil.cs index d5541d101..6554b0a59 100644 --- a/Source/Client/Syncing/SyncFieldUtil.cs +++ b/Source/Client/Syncing/SyncFieldUtil.cs @@ -146,7 +146,7 @@ public static void UpdateSync() // WatchBegin and WatchEnd), restore and sync those fields now. if (watchedStack.Count > 0) { - Log.Warning($"Multiplayer: {watchedStack.Count} field watch stack entries weren't closed, restoring them"); + Log.WarningOnce($"Multiplayer: {watchedStack.Count} field watch stack entries weren't closed, restoring them", 0x5e1d3a7); while (watchedStack.Count > 0) PopWatchScope(); } From 1b011ec45cfb84df1daa0e25949f01667d0cd1f4 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:57:45 -0600 Subject: [PATCH 043/183] Give the init data upload a longer timeout The init data includes the mod list and synced configs and can be a few MB, so allow 5 minutes instead of the 2 minutes used for the small automatic replies. Co-Authored-By: Claude Opus 5.5 --- Source/Common/Networking/State/ServerJoiningState.cs | 4 +++- Source/Tests/JoiningStateTest.cs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index 38edd34ff..ab607ba31 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -11,6 +11,8 @@ public class ServerJoiningState : AsyncConnectionState { // Packets the client sends on its own, without user interaction public static TimeSpan ReplyTimeout { get; set; } = TimeSpan.FromMinutes(2); + // The init data can be a few MB with synced configs, other joiners wait for it + public static TimeSpan InitDataTimeout { get; set; } = TimeSpan.FromMinutes(5); // Packets sent after user input: the password prompt and the mod mismatch window public static TimeSpan UserInputTimeout { get; set; } = TimeSpan.FromMinutes(15); @@ -116,7 +118,7 @@ private async Task RequestInitData() ServerLog.Verbose("Sent initial data request"); // Null when the player disconnects (or times out) while we wait - if (await TypedPacketOrNull(ReplyTimeout) is { } initData) + if (await TypedPacketOrNull(InitDataTimeout) is { } initData) result = ServerInitData.FromNet(initData); } finally diff --git a/Source/Tests/JoiningStateTest.cs b/Source/Tests/JoiningStateTest.cs index b82e88aef..2ef2fc7c7 100644 --- a/Source/Tests/JoiningStateTest.cs +++ b/Source/Tests/JoiningStateTest.cs @@ -32,6 +32,7 @@ public void TearDown() { ServerLog.error = prevErrorLog; ServerJoiningState.ReplyTimeout = TimeSpan.FromMinutes(2); + ServerJoiningState.InitDataTimeout = TimeSpan.FromMinutes(5); MultiplayerServer.instance = null; } @@ -130,7 +131,7 @@ public void InvalidInitData_DisconnectsAndResetsInitDataState() public void InitDataRequestTimesOut() { var (_, first) = Connect(); - ServerJoiningState.ReplyTimeout = TimeSpan.Zero; + ServerJoiningState.InitDataTimeout = TimeSpan.Zero; SendHandshake(first, "first"); Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Requested)); From bcf938400c43c3f85403729681aa8c3ef1e183d7 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:57:46 -0600 Subject: [PATCH 044/183] Don't return null motes only on non-issuing clients During commands MakeStaticMote is cancelled for clients that didn't issue the command, which made it return null there while FixNullMotes makes it return a mote for the issuer. A caller using the result would throw (or branch differently) only on the other clients. Return an unspawned mote instead when the issuer would get one (in bounds). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Feedback.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Source/Client/Patches/Feedback.cs b/Source/Client/Patches/Feedback.cs index bf6183b18..19502417c 100644 --- a/Source/Client/Patches/Feedback.cs +++ b/Source/Client/Patches/Feedback.cs @@ -63,13 +63,27 @@ static IEnumerable TargetMethods() yield return AccessTools.Method(typeof(MoteMaker), nameof(MoteMaker.MakeStaticMote), new[] { typeof(Vector3), typeof(Map), typeof(ThingDef), typeof(float), typeof(bool), typeof(float) }); } - static bool Prefix(ThingDef moteDef) + static bool Prefix(object[] __args, ThingDef moteDef, ref Mote __result) { // Catches ritual effect motes if (moteDef.mote.solidTime >= 99999) return true; - return !CancelFeedbackNotTargetedAtMe.Cancel; + if (!CancelFeedbackNotTargetedAtMe.Cancel) + return true; + + // Callers may use the result and FixNullMotes makes it non-null for the issuer (unless out of bounds), + // so return the same kind of result on the other clients. The mote is made (motes get no thing ID) + // but not spawned. + var cell = __args[0] switch + { + IntVec3 c => c, + Vector3 v => v.ToIntVec3(), + _ => IntVec3.Invalid + }; + + __result = __args[1] is Map map && cell.InBounds(map) ? ThingMaker.MakeThing(moteDef) as Mote : null; + return false; } } From bdf1995d96c4f5088a402a38e2c66552b05dbf4c Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:58:01 -0600 Subject: [PATCH 045/183] Make join points atomic with a designated uploader Standalone join points reloaded only the issuing client (#874), but a reload is not a no-op (LoadPatch's extra tick, the seed long event, statics reset by MultiplayerGame, save compression, unsaved state), so the issuer drifted from everyone else and joiners loading its save drifted from the live clients. Every client now reloads at a join point, like in hosted mode. The server picks the uploader (the sync reference: arbiter or host, or on standalone the longest connected player that is caught up and in sync) and names it in the CreateJoinPoint payload; only it uploads. The upload carries the join point tick. The server accepts it only while a join point is being created, from the expected uploader and for that tick, and then swaps world, maps and the command log together (plus the standalone persistence). Other uploads are rejected, except the host's initial upload when hosting. Standalone snapshot packets no longer replace the world (that left the old command log in place); clients stop sending them. A join point no longer hangs: it is aborted when the uploader disconnects, desyncs or rejoins, or after 60 s without upload progress. Waiting joiners then get the previous world with the full log, which still contains the CreateJoinPoint command, so they reload at the same tick as live clients. Forced join points are rate limited (10 s) and repeated desync reports of an already desynced player are ignored. A rejoining player is marked as simulating until it is caught up. Standalone autosaves with the Minutes unit are issued by the server at a tick instead of per-client timers (clients still write local autosaves without requesting join points). The Days unit stays client driven since the map counters are synced; duplicates are rejected. World travel join point requests are ignored: with every client reloading they would stall everyone each time someone opens the world map. OnDesync no longer throws on standalone (no host): traces are requested from the host or the sync reference. MpConnectionState.SetImplementation now clears the previous handlers of the state, so tests can connect with different client state types. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 27 +- Source/Client/ConstantTicker.cs | 11 +- Source/Client/Saving/SaveLoad.cs | 58 +-- Source/Client/Session/Autosaving.cs | 14 +- Source/Common/MultiplayerServer.cs | 1 + Source/Common/Networking/MpConnectionState.cs | 4 + .../Networking/State/ServerPlayingState.cs | 98 ++--- Source/Common/PlayerManager.cs | 39 +- Source/Common/StandalonePersistence.cs | 20 - Source/Common/WorldData.cs | 187 ++++++--- Source/Tests/CommandLogTest.cs | 2 + Source/Tests/JoinPointTest.cs | 355 ++++++++++++++++++ Source/Tests/ServerTest.cs | 28 +- 13 files changed, 615 insertions(+), 229 deletions(-) create mode 100644 Source/Tests/JoinPointTest.cs diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 8184163ab..e7cf230a9 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -216,10 +216,12 @@ public void ExecuteCmd(ScheduledCommand cmd) if (cmdType == CommandType.CreateJoinPoint) { - if (Multiplayer.session?.ConnectedToStandaloneServer == true && !TickPatch.currentExecutingCmdIssuedBySelf) - return; + // Every client reloads (hosted and standalone): a reload isn't a no-op, and joiners start from the + // reloaded state. Only the uploader chosen by the server sends the save. Old logs have no payload. + var uploaderId = data.Left >= sizeof(int) ? data.ReadInt32() : cmd.playerId; + var upload = uploaderId == Multiplayer.session.playerId && !TickPatch.Simulating && !Multiplayer.IsReplay; - LongEventHandler.QueueLongEvent(CreateJoinPointAndSendIfHost, "MpCreatingJoinPoint", false, null); + LongEventHandler.QueueLongEvent(() => CreateJoinPoint(upload), "MpCreatingJoinPoint", false, null); } if (cmdType == CommandType.InitPlayerData) @@ -274,28 +276,15 @@ public void ExecuteCmd(ScheduledCommand cmd) } } - private static void CreateJoinPointAndSendIfHost() + private static void CreateJoinPoint(bool upload) { Multiplayer.session.dataSnapshot = SaveLoad.SaveReloadAndCreateSnapshot( Multiplayer.GameComp.multifaction, ReloadOptimizationMode.ForJoinPointSnapshot ); - 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); - } - } + if (upload) + SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true); } public void SetTimeEverywhere(TimeSpeed speed) diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs index 214ce1e33..88ae0578a 100644 --- a/Source/Client/ConstantTicker.cs +++ b/Source/Client/ConstantTicker.cs @@ -47,9 +47,8 @@ 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). + // When connected to a remote standalone server, the client autosaves locally using the interval + // received at connection time (from the server's TOML settings via ServerProtocolOkPacket). if (Multiplayer.session?.ConnectedToStandaloneServer == true) { var session = Multiplayer.session; @@ -60,14 +59,18 @@ private static void TickAutosave() { session.autosaveCounter++; + // This counter is per client (it starts when joining), so it must not request join points: + // the server issues the autosave join points itself (WorldData.TickStandaloneAutosave) if (session.autosaveCounter > session.autosaveInterval * TicksPerMinute) { session.autosaveCounter = 0; - Autosaving.DoAutosave(); + Autosaving.DoAutosave(requestJoinPoint: false); } } else if (session.autosaveUnit == AutosaveUnit.Days) { + // The map counters are synced game state, so every client requests at the same tick and the + // server starts a single join point (duplicates are rejected while one is created/rate limited) var anyMapCounterUp = Multiplayer.game.mapComps .Any(m => m.autosaveCounter > session.autosaveInterval * TicksPerIngameDay); diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 6706b9986..39b4b92af 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -5,7 +5,6 @@ using RimWorld.Planet; using System.Collections.Generic; using System.Linq; -using System.Security.Cryptography; using System.Threading; using System.Xml; using Multiplayer.Client.Saving; @@ -223,11 +222,14 @@ public static void SendGameData(GameDataSnapshot snapshot, bool async) var mapsData = new Dictionary(snapshot.MapData); var gameData = snapshot.GameData; var sessionData = snapshot.SessionData; + var tick = snapshot.CachedAtTime; void Send() { var writer = new ByteWriter(); + // The server only accepts the upload for the join point created at this tick + writer.WriteInt32(tick); writer.WriteInt32(mapsData.Count); foreach (var mapData in mapsData) { @@ -248,60 +250,6 @@ 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 7f3786066..0506f552d 100644 --- a/Source/Client/Session/Autosaving.cs +++ b/Source/Client/Session/Autosaving.cs @@ -11,7 +11,9 @@ namespace Multiplayer.Client; public static class Autosaving { - public static void DoAutosave() + // The local save isn't sent to the server: it's taken without the reload every client does at a join point + // (and in this client's faction context), so a joiner loading it wouldn't match the live clients. + public static void DoAutosave(bool requestJoinPoint = true) { LongEventHandler.QueueLongEvent(() => { @@ -20,14 +22,8 @@ public static void DoAutosave() if (!SaveGameToFile_Overwrite(GetNextAutosaveFileName(), snapshot)) return; - SendAutosavingRequest(JoinPointRequestReason.Save); - - // When connected to a standalone server, also upload fresh snapshots - if (Multiplayer.session?.ConnectedToStandaloneServer == true) - { - SaveLoad.SendStandaloneMapSnapshots(snapshot); - SaveLoad.SendStandaloneWorldSnapshot(snapshot); - } + if (requestJoinPoint) + SendAutosavingRequest(JoinPointRequestReason.Save); }, "MpSaving", false, null); } diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 453a84738..72b2d99d9 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -115,6 +115,7 @@ public void Run() queue.RunQueue(ServerLog.Error); netManagers.ForEach(manager => manager.Tick()); TickNet(); + worldData.Tick(); int ticked = 0; while (realTime > 0 && ticked < 2) diff --git a/Source/Common/Networking/MpConnectionState.cs b/Source/Common/Networking/MpConnectionState.cs index 8551f48eb..2875f57b9 100644 --- a/Source/Common/Networking/MpConnectionState.cs +++ b/Source/Common/Networking/MpConnectionState.cs @@ -49,6 +49,10 @@ public static void SetImplementation(ConnectionStateEnum state, Type type) StateImpls[(int)state] = type; + // Replacing an implementation (tests do) must not keep the previous one's handlers + for (var packetId = 0; packetId < packetHandlers.GetLength(1); packetId++) + packetHandlers[(int)state, packetId] = null; + // The point of this attribute is to explicitly mark how to handle packet listeners from the base type. // If the base type is the lowest possible (MpConnectionState, which doesn't have any listeners), there is // no reason to warn when the annotation is missing. diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index c61fef1e6..48a4a4e76 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -15,6 +15,10 @@ public void HandleWorldReady(ByteReader data) [PacketHandler(Packets.Client_RequestRejoin)] public void HandleRejoin(ByteReader data) { + // Not caught up until Client_WorldReady again: can't be the sync reference or a join point uploader + Player.UpdateStatus(PlayerStatus.Simulating); + Server.worldData.OnPlayerUnavailable(Player, "is rejoining"); + connection.ChangeState(ConnectionStateEnum.ServerLoading); Player.ResetTimeVotes(); } @@ -26,7 +30,8 @@ public void HandleDesynced(ClientDesyncedPacket packet) => [TypedPacketHandler] public void HandleTraces(ClientTracesPacket packet) { - if (!Player.IsHost) return; + // Requested in PlayerManager.OnDesync + if (!Player.IsHost && Player != Server.playerManager.SyncReference) return; Server.GetPlayer(packet.playerId)?.SendPacket(ServerTracesPacket.Transfer(packet.rawTraces, packet.rawJittedMethods)); } @@ -87,75 +92,47 @@ public void HandleChat(ClientChatPacket packet) [PacketHandler(Packets.Client_WorldDataUpload, allowFragmented: true)] public void HandleWorldDataUpload(ByteReader data) { - // On standalone, accept from any playing client; otherwise only host/arbiter - if (!Server.IsStandaloneServer && (Server.ArbiterPlaying ? !Player.IsArbiter : !Player.IsHost)) - return; + ServerLog.Detail($"Got world upload {data.Left} from {Player.Username}"); - ServerLog.Detail($"Got world upload {data.Left}"); + int tick = data.ReadInt32(); - Server.worldData.mapData = new Dictionary(); - - int maps = data.ReadInt32(); - for (int i = 0; i < maps; i++) + var maps = new Dictionary(); + int mapCount = data.ReadInt32(); + for (int i = 0; i < mapCount; i++) { int mapId = data.ReadInt32(); - Server.worldData.mapData[mapId] = data.ReadPrefixedBytes(); + maps[mapId] = data.ReadPrefixedBytes()!; } - Server.worldData.savedGame = data.ReadPrefixedBytes(); - Server.worldData.sessionData = data.ReadPrefixedBytes(); - - if (Server.worldData.CreatingJoinPoint) - 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); + var game = data.ReadPrefixedBytes()!; + var session = data.ReadPrefixedBytes()!; - if (accepted) + // The host's initial upload when hosting, there is no command log yet + if (Server.worldData.savedGame == null && !Server.IsStandaloneServer && Player.IsHost) { - 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}"); + Server.worldData.mapData = maps; + Server.worldData.savedGame = game; + Server.worldData.sessionData = session; + return; } - } - [TypedPacketHandler] - public void HandleStandaloneMapSnapshot(ClientStandaloneMapSnapshotPacket packet) - { - if (!Server.IsStandaloneServer) - return; + // Anything else replacing the world without its command log would break every later joiner + Server.worldData.TryCompleteJoinPoint(Player, tick, maps, game, session); + } - if (!Player.IsPlaying) - return; + [FragmentedPacketHandler(Packets.Client_WorldDataUpload)] + public void HandleWorldDataUploadFragment(FragmentedPacket packet) => + Server.worldData.OnUploadProgress(Player); - var accepted = Server.worldData.TryAcceptStandaloneMapSnapshot(Player, packet.mapId, packet.tick, - packet.mapData, packet.sha256Hash); + // Standalone snapshots are taken from join point uploads (Client_WorldDataUpload). A snapshot on its own would + // replace the world without trimming the command log. + [TypedPacketHandler] + public void HandleStandaloneWorldSnapshot(ClientStandaloneWorldSnapshotPacket packet) => + ServerLog.Detail($"Ignored standalone world snapshot tick={packet.tick} from {Player.Username}"); - 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 HandleStandaloneMapSnapshot(ClientStandaloneMapSnapshotPacket packet) => + ServerLog.Detail($"Ignored standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); [TypedPacketHandler] public void HandleCursor(ClientCursorPacket clientPacket) @@ -224,7 +201,12 @@ public void HandleAutosaving(ClientAutosavingPacket packet) $"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) + // Every client reloads at a join point (and the game pauses), so per-player world travel join points + // would interrupt everyone each time someone opens the world map. Standalone autosaves are server driven. + if (packet.reason == JoinPointRequestReason.WorldTravel) + return; + + // On standalone, any playing client can request one (saves; forced ones are rate limited) // 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))) diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index efa871704..eb13cf2de 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -71,11 +71,9 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) ServerPlayer player = conn.serverPlayer; Players.Remove(player); + server.worldData.OnPlayerUnavailable(player, "disconnected"); if (server.worldData.CreatingJoinPoint && (player.IsHost || !Players.Any(p => p.hasJoined))) - { - server.worldData.AbortJoinPointCreation(); - ServerLog.Log("Aborted join point creation because no players remain."); - } + server.worldData.AbortJoinPointCreation("The host left or no players remain."); if (player.hasJoined) { @@ -108,8 +106,16 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) public void OnDesync(ServerPlayer player, int tick, int diffAt) { + // A desynced client reports once and then rejoins; ignore repeats (each one pauses and forces a join point) + if (player.status == PlayerStatus.Desynced) return; + player.UpdateStatus(PlayerStatus.Desynced); - server.HostPlayer.SendPacket(ServerTracesPacket.Request(tick, diffAt, player.id)); + server.worldData.OnPlayerUnavailable(player, "desynced"); + + // Traces come from the client whose opinions the desynced player was compared against (no host on standalone) + var traceSource = PlayingPlayers.FirstOrDefault(p => p.IsHost && p != player) ?? SyncReference; + if (traceSource != null && traceSource != player) + traceSource.SendPacket(ServerTracesPacket.Request(tick, diffAt, player.id)); player.ResetTimeVotes(); @@ -120,6 +126,29 @@ public void OnDesync(ServerPlayer player, int tick, int diffAt) server.worldData.TryStartJoinPointCreation(true); } + /// + /// The client treated as the truth: its sync opinions are forwarded to everyone else and it uploads join + /// points. The arbiter if playing, else the host; standalone has no host, so the longest connected player + /// which is caught up and in sync. + /// + public ServerPlayer? SyncReference + { + get + { + var arbiter = PlayingPlayers.FirstOrDefault(p => p.IsArbiter && p.status == PlayerStatus.Playing); + if (arbiter != null) + return arbiter; + + if (!server.IsStandaloneServer) + return PlayingPlayers.FirstOrDefault(p => p.IsHost); + + return PlayingPlayers + .Where(p => !p.IsArbiter && p.status == PlayerStatus.Playing) + .OrderBy(p => p.id) + .FirstOrDefault(); + } + } + public static ColorRGB[] PlayerColors = { new(0,125,255), diff --git a/Source/Common/StandalonePersistence.cs b/Source/Common/StandalonePersistence.cs index 1d5b35f50..8f561b1a2 100644 --- a/Source/Common/StandalonePersistence.cs +++ b/Source/Common/StandalonePersistence.cs @@ -200,26 +200,6 @@ public void WriteJoinPoint(WorldData worldData, int tick) 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. /// diff --git a/Source/Common/WorldData.cs b/Source/Common/WorldData.cs index 052819319..a35c6c448 100644 --- a/Source/Common/WorldData.cs +++ b/Source/Common/WorldData.cs @@ -27,6 +27,20 @@ public class WorldData private TaskCompletionSource? dataSource; + // The join point being created: the tick of its CreateJoinPoint command and the only player whose upload + // completes it + public int JoinPointTick { get; private set; } = -1; + public int JoinPointUploaderId { get; private set; } = ScheduledCommand.NoPlayer; + private int joinPointProgressAt; // NetTimer + private int? lastForcedJoinPointAt; // NetTimer + private int standaloneAutosaveBaseTick = -1; + + // Every client reloads at a join point and the game pauses meanwhile, so forced requests (saves, desyncs) are + // rate limited. Abort if the uploader shows no progress, joiners would wait forever otherwise. + public const int ForcedJoinPointCooldown = MultiplayerServer.NetTicksPerSecond * 10; + public const int JoinPointTimeout = MultiplayerServer.NetTicksPerSecond * 60; + private const int TicksPerMinute = 60 * 60; + public bool CreatingJoinPoint => tmpCmds != null; public MultiplayerServer Server { get; } @@ -38,6 +52,8 @@ public WorldData(MultiplayerServer server) private int CurrentJoinPointTick => Server.IsStandaloneServer ? Server.gameTimer : Server.workTicks; + /// sourcePlayer is the requester, only used for logging: the upload always comes from the sync reference + /// (host/arbiter, or the longest connected player on standalone), the client every other one is compared against. public bool TryStartJoinPointCreation(bool force = false, ServerPlayer? sourcePlayer = null) { int currentTick = CurrentJoinPointTick; @@ -54,30 +70,67 @@ public bool TryStartJoinPointCreation(bool force = false, ServerPlayer? sourcePl return false; } - var issuingPlayer = sourcePlayer; - if (Server.IsStandaloneServer && issuingPlayer == null) + if (force && Server.NetTimer - lastForcedJoinPointAt < ForcedJoinPointCooldown) { - issuingPlayer = Server.PlayingPlayers.FirstOrDefault(player => player.IsHost) - ?? Server.PlayingPlayers.FirstOrDefault(); + ServerLog.Detail($"Join point skipped: forced join point rate limited (requested by {sourcePlayer?.Username})"); + return false; + } - if (issuingPlayer == null) - { - ServerLog.Detail("Join point skipped: no playing player available for standalone creation"); - return false; - } + var uploader = Server.playerManager.SyncReference; + if (uploader == null) + { + ServerLog.Detail("Join point skipped: no playing player available to upload it"); + return false; } - ServerLog.Detail($"Join point started at tick={currentTick}, force={force}, standalone={Server.IsStandaloneServer}"); + ServerLog.Detail($"Join point started at tick={Server.gameTimer}, force={force}, uploader={uploader.Username}, " + + $"requested by {sourcePlayer?.Username}, standalone={Server.IsStandaloneServer}"); Server.SendChat("Creating a join point..."); - Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, Array.Empty(), - sourcePlayer: Server.IsStandaloneServer ? issuingPlayer : null); + JoinPointTick = Server.gameTimer; + JoinPointUploaderId = uploader.id; + joinPointProgressAt = Server.NetTimer; + if (force) + lastForcedJoinPointAt = Server.NetTimer; + + // Every client reloads when executing it, only the uploader sends the save back + Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, + ByteWriter.GetBytes(uploader.id)); tmpCmds = new List(); dataSource = new TaskCompletionSource(); return true; } + /// Accepts the uploaded world only if it belongs to the join point being created. World, maps and cmds are + /// replaced together so the stored save and its command log always match. + public bool TryCompleteJoinPoint(ServerPlayer player, int tick, Dictionary uploadedMaps, + byte[] uploadedGame, byte[] uploadedSession) + { + if (!CreatingJoinPoint) + { + ServerLog.Detail($"Rejected world upload from {player.Username}: no join point in progress"); + return false; + } + + if (player.id != JoinPointUploaderId || tick != JoinPointTick) + { + ServerLog.Detail($"Rejected world upload from {player.Username} at tick={tick}: expected " + + $"player {JoinPointUploaderId} at tick={JoinPointTick}"); + return false; + } + + mapData = uploadedMaps; + savedGame = uploadedGame; + sessionData = uploadedSession; + + if (Server.IsStandaloneServer) + RecordStandaloneSnapshot(player, tick); + + EndJoinPointCreation(); + return true; + } + public void EndJoinPointCreation() { int currentTick = CurrentJoinPointTick; @@ -85,6 +138,7 @@ public void EndJoinPointCreation() cmds = tmpCmds!; tmpCmds = null; lastJoinPointAtTick = currentTick; + ClearPendingJoinPoint(); if (Server.IsStandaloneServer && Server.persistence != null) { @@ -102,72 +156,99 @@ public void EndJoinPointCreation() dataSource = null; } - public void AbortJoinPointCreation() + /// The previous world and the full command log (including the CreateJoinPoint command, which joiners replay + /// as a reload like live clients did) stay valid, so waiting joiners continue with those. + public void AbortJoinPointCreation(string reason = "") { if (!CreatingJoinPoint) return; + ServerLog.Log($"Aborted join point creation at tick={JoinPointTick}. {reason}"); + tmpCmds = null; + ClearPendingJoinPoint(); dataSource?.SetResult(this); dataSource = null; } - public Task WaitJoinPoint() + /// Called when the expected uploader disconnects, desyncs or starts rejoining + public void OnPlayerUnavailable(ServerPlayer player, string reason) { - return dataSource?.Task ?? Task.FromResult(this); + if (CreatingJoinPoint && player.id == JoinPointUploaderId) + AbortJoinPointCreation($"The uploader {player.Username} {reason}."); } - public bool TryAcceptStandaloneWorldSnapshot(ServerPlayer player, int tick, byte[] worldSnapshot, - byte[] sessionSnapshot, byte[] expectedHash) + public void OnUploadProgress(ServerPlayer player) { - if (tick < standaloneWorldSnapshot.tick) - return false; + if (CreatingJoinPoint && player.id == JoinPointUploaderId) + joinPointProgressAt = Server.NetTimer; + } - var actualHash = ComputeHash(worldSnapshot, sessionSnapshot); - if (expectedHash.Length > 0 && !actualHash.AsSpan().SequenceEqual(expectedHash)) - return false; + private void ClearPendingJoinPoint() + { + JoinPointTick = -1; + JoinPointUploaderId = ScheduledCommand.NoPlayer; + } - savedGame = worldSnapshot; - sessionData = sessionSnapshot; - standaloneWorldSnapshot = new StandaloneWorldSnapshotState - { - tick = tick, - producerPlayerId = player.id, - producerUsername = player.Username, - sha256Hash = actualHash - }; + public Task WaitJoinPoint() + { + return dataSource?.Task ?? Task.FromResult(this); + } - // Persist to disk - Server.persistence?.WriteWorldSnapshot(worldSnapshot, sessionSnapshot, tick); + public void Tick() + { + CheckJoinPointTimeout(Server.NetTimer); + TickStandaloneAutosave(); + } - return true; + public void CheckJoinPointTimeout(int netTimer) + { + if (CreatingJoinPoint && netTimer - joinPointProgressAt > JoinPointTimeout) + AbortJoinPointCreation("Timed out waiting for the world upload."); } - public bool TryAcceptStandaloneMapSnapshot(ServerPlayer player, int mapId, int tick, - byte[] mapSnapshot, byte[] expectedHash) + /// Standalone autosaves are join points issued by the server at a tick, so every client reloads at the same + /// moment and the persisted save matches its command log. Per-client timers would create a join point per client. + /// In-game day intervals are only known to clients (deterministically), see ConstantTicker.TickAutosave. + private void TickStandaloneAutosave() { - if (mapId < 0) - return false; + var settings = Server.settings; + if (!Server.IsStandaloneServer || settings.autosaveUnit != AutosaveUnit.Minutes || settings.autosaveInterval <= 0) + return; - var snapshotState = standaloneMapSnapshots.GetOrAddNew(mapId); - if (tick < snapshotState.tick) - return false; + if (standaloneAutosaveBaseTick < 0) + standaloneAutosaveBaseTick = Server.gameTimer; - var actualHash = ComputeHash(mapSnapshot); - if (expectedHash.Length > 0 && !actualHash.AsSpan().SequenceEqual(expectedHash)) - return false; + var since = Math.Max(standaloneAutosaveBaseTick, lastJoinPointAtTick); + if (CreatingJoinPoint || Server.gameTimer - since < settings.autosaveInterval * TicksPerMinute) + return; - mapData[mapId] = mapSnapshot; - snapshotState.tick = tick; - snapshotState.producerPlayerId = player.id; - snapshotState.producerUsername = player.Username; - snapshotState.sha256Hash = actualHash; - standaloneMapSnapshots[mapId] = snapshotState; + // Wait another interval if it can't start now + standaloneAutosaveBaseTick = Server.gameTimer; + TryStartJoinPointCreation(); + } - // Persist to disk - Server.persistence?.WriteMapSnapshot(mapId, mapSnapshot); + private void RecordStandaloneSnapshot(ServerPlayer player, int tick) + { + standaloneWorldSnapshot = new StandaloneWorldSnapshotState + { + tick = tick, + producerPlayerId = player.id, + producerUsername = player.Username, + sha256Hash = ComputeHash(savedGame!, sessionData!) + }; - return true; + standaloneMapSnapshots.Clear(); + foreach (var (mapId, data) in mapData) + { + standaloneMapSnapshots[mapId] = new StandaloneMapSnapshotState + { + tick = tick, + producerPlayerId = player.id, + producerUsername = player.Username, + sha256Hash = ComputeHash(data) + }; + } } private static byte[] ComputeHash(params byte[][] payloads) diff --git a/Source/Tests/CommandLogTest.cs b/Source/Tests/CommandLogTest.cs index 3ee33ff0d..476907112 100644 --- a/Source/Tests/CommandLogTest.cs +++ b/Source/Tests/CommandLogTest.cs @@ -101,6 +101,7 @@ public void WorldDataContainsAllCommandsInLogOrder() public void CompletedJoinPointKeepsOnlyCommandsAfterIt() { AddPlayer("live", ConnectionStateEnum.ServerPlaying); + server.hostUsername = "live"; SendCmd(5, 1); Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); @@ -117,6 +118,7 @@ public void CompletedJoinPointKeepsOnlyCommandsAfterIt() public void AbortedJoinPointKeepsTheFullLog() { AddPlayer("live", ConnectionStateEnum.ServerPlaying); + server.hostUsername = "live"; SendCmd(5, 1); Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); diff --git a/Source/Tests/JoinPointTest.cs b/Source/Tests/JoinPointTest.cs new file mode 100644 index 000000000..d25ffbd9f --- /dev/null +++ b/Source/Tests/JoinPointTest.cs @@ -0,0 +1,355 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[TestFixture] +public class JoinPointTest +{ + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false, + autosaveInterval = 0 + }); + server.worldData.savedGame = [1]; + nextPlayerId = 0; + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private ServerPlayer AddPlayer(string username, PlayerStatus status = PlayerStatus.Playing, + PlayerType type = PlayerType.Normal) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn) { hasJoined = true, status = status, type = type }; + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return player; + } + + private static void Upload(ServerPlayer player, int tick, byte[] game, int mapId = 0, byte[]? map = null) + { + var writer = new ByteWriter(); + writer.WriteInt32(tick); + writer.WriteInt32(1); + writer.WriteInt32(mapId); + writer.WritePrefixedBytes(map ?? [7]); + writer.WritePrefixedBytes(game); + writer.WritePrefixedBytes([5]); + player.conn.GetState()!.HandleWorldDataUpload(new ByteReader(writer.ToArray())); + } + + private void SendCmd(byte marker) => server.commands.Send(CommandType.Sync, 0, 1, [marker]); + + private List Cmds => server.worldData.cmds + .Select(raw => ScheduledCommand.Deserialize(new ByteReader(raw))).ToList(); + + [Test] + public void StandaloneUploaderIsLongestConnectedPlayerInSync() + { + server.IsStandaloneServer = true; + AddPlayer("desynced", PlayerStatus.Desynced); + AddPlayer("simulating", PlayerStatus.Simulating); + var reference = AddPlayer("reference"); + AddPlayer("other"); + + Assert.That(server.playerManager.SyncReference, Is.SameAs(reference)); + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + Assert.That(server.worldData.JoinPointUploaderId, Is.EqualTo(reference.id)); + + // The uploader is announced in the command itself, every client reloads + var cjp = Cmds.Single(c => c.type == CommandType.CreateJoinPoint); + Assert.That(new ByteReader(cjp.data).ReadInt32(), Is.EqualTo(reference.id)); + } + + [Test] + public void HostedUploaderIsArbiterWhenPlaying() + { + var host = AddPlayer("host"); + server.hostUsername = "host"; + var arbiter = AddPlayer("arbiter", type: PlayerType.Arbiter); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + Assert.That(server.worldData.JoinPointUploaderId, Is.EqualTo(arbiter.id)); + + Upload(host, 0, [2]); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + + Upload(arbiter, 0, [3]); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 3 })); + } + + [Test] + public void NoJoinPointWithoutAnUploader() + { + server.IsStandaloneServer = true; + AddPlayer("simulating", PlayerStatus.Simulating); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.False); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(server.worldData.cmds, Is.Empty); + } + + [Test] + public void OnlyTheExpectedUploadAtTheJoinPointTickCompletesIt() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + var other = AddPlayer("b"); + server.gameTimer = 100; + + SendCmd(1); + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + server.gameTimer = 105; + SendCmd(2); + + Upload(other, 100, [2]); + Upload(uploader, 99, [2]); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + + Upload(uploader, 100, [2], mapId: 3, map: [4]); + + // World, maps and log are swapped together + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 2 })); + Assert.That(server.worldData.mapData.Keys, Is.EqualTo(new[] { 3 })); + Assert.That(Cmds.Select(c => c.data[0]), Is.EqualTo(new byte[] { 2 })); + Assert.That(Cmds.All(c => c.ticks >= 100), Is.True); + Assert.That(server.worldData.standaloneWorldSnapshot.tick, Is.EqualTo(100)); + Assert.That(server.worldData.standaloneWorldSnapshot.producerPlayerId, Is.EqualTo(uploader.id)); + } + + [Test] + public void UploadOutsideOfJoinPointIsRejected([Values] bool standalone) + { + server.IsStandaloneServer = standalone; + var host = AddPlayer("host"); + server.hostUsername = standalone ? null : "host"; + + Upload(host, 0, [2]); + + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + Assert.That(server.worldData.mapData, Is.Empty); + } + + [Test] + public void InitialHostUploadIsAccepted() + { + server.worldData.savedGame = null; + var host = AddPlayer("host"); + server.hostUsername = "host"; + + Upload(host, 0, [2]); + + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 2 })); + } + + [Test] + public void JoinPointAbortsWhenUploaderDisconnects() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + AddPlayer("b"); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + var waiter = server.worldData.WaitJoinPoint(); + + uploader.Disconnect(MpDisconnectReason.Generic); + + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(waiter.IsCompleted, Is.True); + // Joiners get the previous world with the full log, including the aborted CreateJoinPoint + Assert.That(Cmds.Any(c => c.type == CommandType.CreateJoinPoint), Is.True); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + } + + [Test] + public void JoinPointAbortsWhenUploaderDesyncs() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + AddPlayer("b"); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + server.playerManager.OnDesync(uploader, 0, 0); + + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(uploader.status, Is.EqualTo(PlayerStatus.Desynced)); + } + + [Test] + public void JoinPointAbortsWhenUploaderRejoins() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + AddPlayer("b"); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + uploader.conn.GetState()!.HandleRejoin(new ByteReader([])); + + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(uploader.status, Is.EqualTo(PlayerStatus.Simulating)); + } + + [Test] + public void JoinPointTimesOutWithoutUpload() + { + AddPlayer("host"); + server.hostUsername = "host"; + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + + server.worldData.CheckJoinPointTimeout(WorldData.JoinPointTimeout); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + + server.worldData.CheckJoinPointTimeout(WorldData.JoinPointTimeout + 1); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + } + + [Test] + public void ForcedJoinPointsAreRateLimited() + { + AddPlayer("host"); + server.hostUsername = "host"; + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + server.worldData.EndJoinPointCreation(); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.False); + } + + [Test] + public void RepeatedDesyncReportsAreIgnored() + { + server.settings.autoJoinPoint = AutoJoinPointFlags.Desync; + var host = AddPlayer("host"); + server.hostUsername = "host"; + var player = AddPlayer("player"); + + server.playerManager.OnDesync(player, 0, 0); + var cmdCount = server.worldData.cmds.Count; + server.playerManager.OnDesync(player, 0, 0); + + Assert.That(server.worldData.cmds, Has.Count.EqualTo(cmdCount)); + Assert.That(((RecordingConnection)host.conn).SentPackets.Count(p => p == Packets.Server_Traces), Is.EqualTo(1)); + } + + [Test] + public void StandaloneDesyncRequestsTracesFromSyncReference() + { + server.IsStandaloneServer = true; + var reference = AddPlayer("reference"); + var player = AddPlayer("player"); + + Assert.DoesNotThrow(() => server.playerManager.OnDesync(player, 0, 0)); + Assert.That(((RecordingConnection)reference.conn).SentPackets, Does.Contain(Packets.Server_Traces)); + } + + [Test] + public void StandaloneAutosaveIsIssuedByServer() + { + server.IsStandaloneServer = true; + server.settings.autosaveUnit = AutosaveUnit.Minutes; + server.settings.autosaveInterval = 1; + var uploader = AddPlayer("a"); + + server.worldData.Tick(); + server.gameTimer = 60 * 60 - 1; + server.worldData.Tick(); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + + server.gameTimer = 60 * 60; + server.worldData.Tick(); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + Assert.That(server.worldData.JoinPointUploaderId, Is.EqualTo(uploader.id)); + Assert.That(server.worldData.JoinPointTick, Is.EqualTo(60 * 60)); + } + + [Test] + public void WorldTravelRequestsDoNotCreateJoinPoints() + { + server.IsStandaloneServer = true; + var player = AddPlayer("a"); + + player.conn.GetState()!.HandleAutosaving(new ClientAutosavingPacket(JoinPointRequestReason.WorldTravel)); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + + player.conn.GetState()!.HandleAutosaving(new ClientAutosavingPacket(JoinPointRequestReason.Save)); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + } + + [Test] + public void StandaloneSnapshotsDoNotReplaceTheWorld() + { + server.IsStandaloneServer = true; + var player = AddPlayer("a"); + var state = player.conn.GetState()!; + + state.HandleStandaloneWorldSnapshot(new ClientStandaloneWorldSnapshotPacket + { + tick = 10, worldData = [2], sessionData = [3], sha256Hash = [] + }); + state.HandleStandaloneMapSnapshot(new ClientStandaloneMapSnapshotPacket + { + mapId = 0, tick = 10, mapData = [4], sha256Hash = [] + }); + + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + Assert.That(server.worldData.mapData, Is.Empty); + } + + [Test] + public void StandaloneJoinPointIsPersistedWithItsLog() + { + var dir = Path.Combine(Path.GetTempPath(), $"mp-joinpoint-{Guid.NewGuid():N}"); + try + { + server.IsStandaloneServer = true; + server.persistence = new StandalonePersistence(dir); + var uploader = AddPlayer("a"); + server.gameTimer = 50; + + SendCmd(1); + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + server.gameTimer = 60; + SendCmd(2); + Upload(uploader, 50, [2]); + + var reloaded = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(dir), + }; + reloaded.persistence.LoadInto(reloaded); + + Assert.That(reloaded.worldData.savedGame, Is.EqualTo(new byte[] { 2 })); + Assert.That(reloaded.worldData.cmds.Select(c => ScheduledCommand.Deserialize(new ByteReader(c)).data[0]), + Is.EqualTo(new byte[] { 2 })); + // Never behind the last logged command + Assert.That(reloaded.gameTimer, Is.EqualTo(60)); + } + finally + { + if (Directory.Exists(dir)) + Directory.Delete(dir, true); + } + } +} diff --git a/Source/Tests/ServerTest.cs b/Source/Tests/ServerTest.cs index 4cfff8bd5..b589b7f99 100644 --- a/Source/Tests/ServerTest.cs +++ b/Source/Tests/ServerTest.cs @@ -47,12 +47,13 @@ public void Test() [Test] public async Task JoinPointAbortUnblocksWaiters() { - var server = new MultiplayerServer(new ServerSettings + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings { gameName = "Test", direct = false, lan = false }); + AddHost(server); Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); Assert.That(server.worldData.CreatingJoinPoint, Is.True); @@ -73,6 +74,7 @@ public async Task JoinPointAbortUnblocksWaiters() public void LoadingStateHandlesKeepAliveWhileWaitingForJoinPoint() { var server = MakeServer(out var port); + AddHost(server); Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); _ = Task.Run(async () => @@ -83,16 +85,30 @@ public void LoadingStateHandlesKeepAliveWhileWaitingForJoinPoint() ConnectClient(port, typeof(TestLoadingKeepAliveState)); + // The client joins, waits for the join point, receives the world and leaves; only the host remains + WaitUntil(() => server.playerManager.Players.Count == 2); + WaitUntil(() => server.playerManager.Players.Count == 1); + } + + private static void AddHost(MultiplayerServer server) + { + var conn = new RecordingConnection("host"); + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + var host = new ServerPlayer(100, conn) { hasJoined = true }; + conn.serverPlayer = host; + server.playerManager.Players.Add(host); + server.hostUsername = "host"; + } + + private static void WaitUntil(Func condition) + { var timeoutWatch = Stopwatch.StartNew(); - while (true) + while (!condition()) { - if (server.playerManager.Players.Count == 0) - break; - if (timeoutWatch.ElapsedMilliseconds > 2000) Assert.Fail("Timeout"); - Thread.Sleep(50); + Thread.Sleep(20); } } From 8c2731e6d14cabcfe09da40175bcca2255e6d2ff Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:58:04 -0600 Subject: [PATCH 046/183] Keep faction creation pages from changing shared game state Opening the multifaction 'settle' pages changed live game state on the creating client only: - Current.Game.Scenario was replaced by the chosen scenario and never restored (GenerateNewMap 'restored' the already replaced value), so Scenario.TickScenario, ScenPart_StatFactor, disabled incidents etc. differed from the other clients. - The temporary faction used for pawn generation was added to the live FactionManager (and every faction got a relation to it) while the page was open: ticked, iterated by the simulation and serialized into join points created meanwhile. FactionManager.Add/Remove also reset Faction.OfPlayer. The pages now get the chosen scenario and only apply it while their own code runs (PreOpen/PostOpen/DoWindowContents/DoNext/accept key), always restoring the game's scenario. The temporary faction is no longer registered: pawn generation only needs it as GameInitData.playerFaction (Faction.OfPlayer outside ProgramState.Playing), and the reverse relations NewGeneratedFaction adds to the other factions are removed. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/FactionSidebar.cs | 26 +++++++-- .../Factions/Page_ChooseIdeo_Multifaction.cs | 9 +++ ...age_ConfigureStartingPawns_Multifaction.cs | 55 +++++++++++++++---- 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/Source/Client/Factions/FactionSidebar.cs b/Source/Client/Factions/FactionSidebar.cs index c530eadb1..d7724a585 100644 --- a/Source/Client/Factions/FactionSidebar.cs +++ b/Source/Client/Factions/FactionSidebar.cs @@ -421,12 +421,13 @@ private static void OpenConfigurationPages() var gameConfigurationPages = new List(); Page_ChooseIdeo_Multifaction ideologyConfigPage = null; Page_ConfigureStartingPawns pawnConfigPage = null; + var scenario = chosenScenario?.scenario ?? Find.Scenario; InitializeDataForGameConfigurationPages(); - ideologyConfigPage = TryGetIdeologyConfigurationPage(); + ideologyConfigPage = TryGetIdeologyConfigurationPage(scenario); - pawnConfigPage = new Page_ConfigureStartingPawns_Multifaction() + pawnConfigPage = new Page_ConfigureStartingPawns_Multifaction(scenario) { nextAct = () => { @@ -442,10 +443,23 @@ private static void OpenConfigurationPages() Find.WindowStack.Add(PageUtility.StitchedPages(gameConfigurationPages)); } - private static void InitializeDataForGameConfigurationPages() + // Find.Scenario is shared game state the simulation reads (Scenario.TickScenario, stat factors, disabled + // incidents, ...), so the configuration pages only apply their scenario while their own code runs + internal static MpScope UseScenario(Scenario scenario) { - Current.Game.Scenario = chosenScenario.scenario; + Scenario prevScenario = null; + return MpScope.Create( + () => + { + prevScenario = Current.Game.Scenario; + if (scenario != null) + Current.Game.Scenario = scenario; + }, + () => Current.Game.Scenario = prevScenario); + } + private static void InitializeDataForGameConfigurationPages() + { Current.Game.InitData = new GameInitData { startedFromEntry = true, @@ -465,12 +479,12 @@ private static List GetStartingPossessions(List startingPaw return startingPossessions; } - private static Page_ChooseIdeo_Multifaction TryGetIdeologyConfigurationPage() + private static Page_ChooseIdeo_Multifaction TryGetIdeologyConfigurationPage(Scenario scenario) { Page_ChooseIdeo_Multifaction chooseIdeoPage = null; if (ModsConfig.IdeologyActive && !Find.IdeoManager.classicMode) - chooseIdeoPage = new Page_ChooseIdeo_Multifaction(); + chooseIdeoPage = new Page_ChooseIdeo_Multifaction { scenario = scenario }; return chooseIdeoPage; } diff --git a/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs b/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs index 5a5b940bb..ca45ab55c 100644 --- a/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs +++ b/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs @@ -13,7 +13,16 @@ public class Page_ChooseIdeo_Multifaction : Page public Page_ChooseIdeoPreset pageChooseIdeo = new(); + // The scenario chosen for the new faction, only applied while the page draws (see FactionSidebar.UseScenario) + public Scenario scenario; + public override void DoWindowContents(Rect inRect) + { + using (FactionSidebar.UseScenario(scenario)) + DoContents(inRect); + } + + private void DoContents(Rect inRect) { DrawPageTitle(inRect); float totalHeight = 0f; diff --git a/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs b/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs index 38b0d9685..ed21e815d 100644 --- a/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs +++ b/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs @@ -1,4 +1,6 @@ +using Multiplayer.Client.Util; using RimWorld; +using UnityEngine; using Verse; namespace Multiplayer.Client.Factions @@ -8,14 +10,42 @@ internal class Page_ConfigureStartingPawns_Multifaction : Page_ConfigureStarting Faction _temporaryFactionForPawnCreation; Scenario _scenario; + public Page_ConfigureStartingPawns_Multifaction(Scenario scenario) + { + _scenario = scenario; + } + + // The page's code reads Find.Scenario, which is shared game state used by the simulation. The chosen scenario + // is only applied while the page's own code runs (in the UI, between ticks). + private MpScope UseScenario() => FactionSidebar.UseScenario(_scenario); + public override void PreOpen() { - base.PreOpen(); + using (UseScenario()) + { + base.PreOpen(); + + GenerateTemporaryFaction(); + GeneratePawnsForConfigurePage(); + } + } + + public override void PostOpen() + { + using (UseScenario()) + base.PostOpen(); + } - _scenario = Find.Scenario; + public override void DoWindowContents(Rect rect) + { + using (UseScenario()) + base.DoWindowContents(rect); + } - GenerateTemporaryFaction(); - GeneratePawnsForConfigurePage(); + public override void OnAcceptKeyPressed() + { + using (UseScenario()) + base.OnAcceptKeyPressed(); } public override void PostClose() @@ -27,18 +57,18 @@ public override void PostClose() public override void DoNext() { - CleanUpTemporaryFaction(); + using (UseScenario()) + { + CleanUpTemporaryFaction(); - base.DoNext(); + base.DoNext(); + } } private void CleanUpTemporaryFaction() { if (_temporaryFactionForPawnCreation == null) return; - Find.FactionManager.Remove(_temporaryFactionForPawnCreation); - Find.FactionManager.toRemove.Remove(_temporaryFactionForPawnCreation); - Current.Game.InitData.playerFaction = null; _temporaryFactionForPawnCreation = null; } @@ -48,7 +78,12 @@ private void GenerateTemporaryFaction() _temporaryFactionForPawnCreation = FactionGenerator.NewGeneratedFaction(new FactionGeneratorParms(_scenario.playerFaction.factionDef)); _temporaryFactionForPawnCreation.temporary = true; - Find.FactionManager.Add(_temporaryFactionForPawnCreation); + // The faction isn't added to the FactionManager: it would be live game state on this client only (ticked, + // iterated by the simulation and saved into join points). Pawn generation only needs it as Faction.OfPlayer, + // which comes from GameInitData.playerFaction. NewGeneratedFaction also added relations with the temporary + // faction to every other faction, remove those. + foreach (var other in Find.FactionManager.AllFactionsListForReading) + other.relations.RemoveAll(r => r.other == _temporaryFactionForPawnCreation); } private void GeneratePawnsForConfigurePage() From 5a92b1d6c25333b671ba0af689aa4e945cfbd4a5 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:58:19 -0600 Subject: [PATCH 047/183] Isolate issuer-only code in commands from shared state Commands run with the map/world RNG and the shared unique id counters active. Code that runs on the issuing client only (session windows opened for the issuer, FactionCreator's local setup) or depends on local UI state (MainTabsRoot.Notify_SelectedObjectDespawned with the local selection) could advance them on one client only, as in #968. Add LocalOnlyScope (Rand push/pop, local unique ids, desync traces ignored) and use it at those call sites: ritual, psychic ritual, growth moment, transporter loading and caravan forming windows, FactionCreator's issuer block and the selection notify in AsyncTimeComp.ExecuteCmd. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 4 ++- Source/Client/Factions/FactionCreator.cs | 3 ++ Source/Client/Patches/Seeds.cs | 29 +++++++++++++++++++ .../Persistent/CaravanFormingPatches.cs | 4 ++- .../Client/Persistent/GrowthMomentSession.cs | 4 ++- .../Client/Persistent/PsychicRitualSession.cs | 3 +- Source/Client/Persistent/RitualPatches.cs | 3 +- .../Persistent/TransporterLoadingPatches.cs | 3 +- 8 files changed, 47 insertions(+), 6 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 6b99b3c0d..560048233 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -320,7 +320,9 @@ public void ExecuteCmd(ScheduledCommand cmd) Find.Selector.selected = prevSelected; prevSelected = null; - Find.MainButtonsRoot.tabs.Notify_SelectedObjectDespawned(); + // Local UI, depends on the local selection + using (LocalOnlyScope.Enter()) + Find.MainButtonsRoot.tabs.Notify_SelectedObjectDespawned(); RestoreRandStack(randDepth + 1, this); map.PopFaction(); diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index c26ea721e..de9607e14 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -76,6 +76,9 @@ public static void CreateFaction(int playerId, FactionCreationData creationData) if (executingOnlyOnIssuer) { + // Runs on the issuer only: e.g. archived dialogs take unique ids + using var localOnly = LocalOnlyScope.Enter(); + ClearPortraitCacheFromPawnConfigPage(); Current.Game.CurrentMap = newMap; diff --git a/Source/Client/Patches/Seeds.cs b/Source/Client/Patches/Seeds.cs index 0077e9498..264a80261 100644 --- a/Source/Client/Patches/Seeds.cs +++ b/Source/Client/Patches/Seeds.cs @@ -177,6 +177,35 @@ public static void Finalizer(bool __state) } } + /// + /// For code inside a tick or command which runs only on some clients, e.g. a window opened only for the issuer of + /// a command or local UI updates. Keeps it off the shared RNG stream, unique id counters and desync traces. + /// + public readonly struct LocalOnlyScope : IDisposable + { + private readonly bool prevLocalIds; + + private LocalOnlyScope(bool prevLocalIds) => this.prevLocalIds = prevLocalIds; + + public static LocalOnlyScope Enter() + { + var scope = new LocalOnlyScope(Patches.UniqueIdsPatch.useLocalIdsOverride); + + Rand.PushState(); + Desyncs.DeferredStackTracing.ignoreTraces++; + Patches.UniqueIdsPatch.useLocalIdsOverride = true; + + return scope; + } + + public void Dispose() + { + Patches.UniqueIdsPatch.useLocalIdsOverride = prevLocalIds; + Desyncs.DeferredStackTracing.ignoreTraces--; + Rand.PopState(); + } + } + [HarmonyPatch] static class SeedGrammar { diff --git a/Source/Client/Persistent/CaravanFormingPatches.cs b/Source/Client/Persistent/CaravanFormingPatches.cs index 8a35de1f5..8db4ebccd 100644 --- a/Source/Client/Persistent/CaravanFormingPatches.cs +++ b/Source/Client/Persistent/CaravanFormingPatches.cs @@ -192,7 +192,9 @@ internal static void StartFormingCaravan(Faction faction, Map map, bool reform = if (TickPatch.currentExecutingCmdIssuedBySelf) { - CaravanFormingProxy dialog = session.OpenWindow(); + CaravanFormingProxy dialog; + using (LocalOnlyScope.Enter()) + dialog = session.OpenWindow(); if (!routePlannerWaypoint.HasValue) return; diff --git a/Source/Client/Persistent/GrowthMomentSession.cs b/Source/Client/Persistent/GrowthMomentSession.cs index 3c348ccdc..19b11f989 100644 --- a/Source/Client/Persistent/GrowthMomentSession.cs +++ b/Source/Client/Persistent/GrowthMomentSession.cs @@ -190,6 +190,8 @@ static void MakeChoicesPatch(ChoiceLetter_GrowthMoment __instance) static void OpenSessionWindow(ChoiceLetter_GrowthMoment letter) { var sess = GrowthMomentSession.TryAddSession(letter); - if (Multiplayer.ExecutingCmds && TickPatch.currentExecutingCmdIssuedBySelf) sess?.OpenWindow(); + if (Multiplayer.ExecutingCmds && TickPatch.currentExecutingCmdIssuedBySelf) + using (LocalOnlyScope.Enter()) + sess?.OpenWindow(); } } diff --git a/Source/Client/Persistent/PsychicRitualSession.cs b/Source/Client/Persistent/PsychicRitualSession.cs index edb5257cb..b3c7db153 100644 --- a/Source/Client/Persistent/PsychicRitualSession.cs +++ b/Source/Client/Persistent/PsychicRitualSession.cs @@ -58,7 +58,8 @@ public static void CreateSession(PsychicRitualDef_InvocationCircle ritual, Thing var session = manager.GetOrAddSession(new PsychicRitualSession(map, ritual, candidatePool, assignments)); if (TickPatch.currentExecutingCmdIssuedBySelf) - session.OpenWindow(); + using (LocalOnlyScope.Enter()) + session.OpenWindow(); } [SyncMethod] diff --git a/Source/Client/Persistent/RitualPatches.cs b/Source/Client/Persistent/RitualPatches.cs index 6a5270bd4..6e5238a1e 100644 --- a/Source/Client/Persistent/RitualPatches.cs +++ b/Source/Client/Persistent/RitualPatches.cs @@ -105,7 +105,8 @@ static bool Prefix(Window window) } if (TickPatch.currentExecutingCmdIssuedBySelf) - session?.OpenWindow(); + using (LocalOnlyScope.Enter()) + session?.OpenWindow(); return false; } diff --git a/Source/Client/Persistent/TransporterLoadingPatches.cs b/Source/Client/Persistent/TransporterLoadingPatches.cs index 1b165711a..2b47df6f0 100644 --- a/Source/Client/Persistent/TransporterLoadingPatches.cs +++ b/Source/Client/Persistent/TransporterLoadingPatches.cs @@ -135,7 +135,8 @@ static void Prefix(Dialog_LoadTransporters __instance, Map map, List Date: Sat, 17 Oct 2026 16:00:11 -0600 Subject: [PATCH 048/183] Judge UI stat cache staleness with the thing's own time StatWorker.GetValue's temporary cache is shared between the simulation and the interface. The interface overwrites a simulation entry when it finds it stale, forcing the next simulation access to recompute, which only matches clients that didn't overwrite if the interface's time is the time the simulation uses for the thing. With async time the interface uses the current map's or the world's time, so looking at a thing on another map could make one client recompute a stat that the others still take from the cache. In the interface, use the time of the thing's map (or the world's for things without one). No change outside of async time or outside of the interface. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Determinism.cs | 37 +++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index 6b0da3917..cae3ade55 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -523,7 +523,42 @@ static IEnumerable Transpiler(IEnumerable inst AccessTools.Method(typeof(StatWorkerGetValuePatch), nameof(NewCacheTicks))) ); - return matcher.Instructions(); + // Make the interface judge staleness (and so decide whether to overwrite a simulation entry) + // with the time the simulation uses for the thing + var tickManagerTicksGame = AccessTools.PropertyGetter(typeof(TickManager), nameof(TickManager.TicksGame)); + var genTicksTicksGame = AccessTools.PropertyGetter(typeof(GenTicks), nameof(GenTicks.TicksGame)); + var result = matcher.Instructions(); + int patched = 0; + + for (int i = 0; i < result.Count; i++) + { + if (!result[i].Calls(tickManagerTicksGame) && !result[i].Calls(genTicksTicksGame)) + continue; + + result.InsertRange(i + 1, [ + new CodeInstruction(OpCodes.Ldarg_1), + new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(StatWorkerGetValuePatch), nameof(TicksGameFor))) + ]); + i += 2; + patched++; + } + + if (patched == 0) + Log.Error("MP: Couldn't find TicksGame in StatWorker.GetValue. Was the original method changed?"); + + return result; + } + + private static int TicksGameFor(int ticksGame, Thing thing) + { + if (Multiplayer.Client == null || !Multiplayer.InInterface) + return ticksGame; + + // In the interface the current time can be another map's or the world's (async time) + if (thing?.MapHeld?.AsyncTime() is { } mapTime) + return mapTime.mapTicks; + + return Multiplayer.AsyncWorldTime?.worldTicks ?? ticksGame; } private static bool HasValueInCache(bool hasValueInCache, StatWorker worker, Thing t) From 6c48789b1b1f417876ce6bd2f3b7e24f53adc0ba Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:00:18 -0600 Subject: [PATCH 049/183] Detect desyncs on standalone servers Clients only sent sync opinions when they were the host or arbiter and the server only forwarded those, so a standalone server (no host) never compared anything and desyncs went unnoticed until something visibly broke. The server now forwards the opinions of the sync reference: the arbiter or host as before, or on standalone the longest connected player that is caught up and in sync. The reference is re-evaluated when a player becomes ready, desyncs, rejoins or leaves; on standalone the server tells the old and new reference with the new Server_SyncReference packet (appended so existing packet ids stay stable), and only that client uploads its opinions, so the bandwidth stays as in hosted mode. Stored opinions for joiners are cleared when the reference changes. Co-Authored-By: Claude Opus 5.5 --- Source/Client/ConstantTicker.cs | 5 +- .../Networking/State/ClientBaseState.cs | 5 + .../Networking/State/ClientLoadingState.cs | 3 + Source/Client/Session/MultiplayerSession.cs | 2 + .../Networking/Packet/SyncInfoPacket.cs | 13 ++ Source/Common/Networking/Packets.cs | 3 + .../Networking/State/ServerPlayingState.cs | 9 +- Source/Common/PlayerManager.cs | 26 +++ Source/Tests/PacketTest.cs | 3 + Source/Tests/SyncReferenceTest.cs | 152 ++++++++++++++++++ .../ServerSyncReferencePacket.verified.txt | 2 + 11 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 Source/Tests/SyncReferenceTest.cs create mode 100644 Source/Tests/packet-serializations/ServerSyncReferencePacket.verified.txt diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs index 88ae0578a..4030be6fb 100644 --- a/Source/Client/ConstantTicker.cs +++ b/Source/Client/ConstantTicker.cs @@ -122,7 +122,10 @@ private static void TickSyncCoordinator() try { - if (!TickPatch.Simulating && (Multiplayer.LocalServer != null || Multiplayer.arbiterInstance)) + // Only the sync reference's opinions are used by the server: host/arbiter, or the client the + // standalone server picked. Opinions can be megabytes, others don't send them. + if (!TickPatch.Simulating && (Multiplayer.LocalServer != null || Multiplayer.arbiterInstance || + Multiplayer.session.isSyncReference)) Multiplayer.Client.SendFragmented( new ClientSyncInfoPacket { SyncOpinion = opinion.ToNet() }.Serialize()); } diff --git a/Source/Client/Networking/State/ClientBaseState.cs b/Source/Client/Networking/State/ClientBaseState.cs index 0eb6bc1ba..b45e8516b 100644 --- a/Source/Client/Networking/State/ClientBaseState.cs +++ b/Source/Client/Networking/State/ClientBaseState.cs @@ -28,6 +28,11 @@ public void HandleTimeControl(ServerTimeControlPacket packet) Multiplayer.session.ProcessTimeControl(); } + // Handled in every state: it can arrive while rejoining + [TypedPacketHandler] + public void HandleSyncReference(ServerSyncReferencePacket packet) => + Multiplayer.session.isSyncReference = packet.isReference; + // Currently handles disconnection only for Steam connections. See comment in ConnectionBase.Close for more info. [TypedPacketHandler] public virtual void HandleDisconnected(ServerDisconnectPacket packet) diff --git a/Source/Client/Networking/State/ClientLoadingState.cs b/Source/Client/Networking/State/ClientLoadingState.cs index 79a18f65d..9c9df72dc 100644 --- a/Source/Client/Networking/State/ClientLoadingState.cs +++ b/Source/Client/Networking/State/ClientLoadingState.cs @@ -70,6 +70,9 @@ public void HandleWorldData(ByteReader data) int factionId = data.ReadInt32(); Multiplayer.session.myFactionId = factionId; + // The server only picks caught up players as the sync reference + Multiplayer.session.isSyncReference = false; + int tickUntil = data.ReadInt32(); int remoteSentCmds = data.ReadInt32(); bool serverFrozen = data.ReadBool(); diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 7e1dad5c4..918304b46 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -38,6 +38,8 @@ public class MultiplayerSession : IConnectionStatusListener public bool showTimeline; public bool desynced; + // Standalone: set by the server when this client's sync opinions are the ones compared against + public bool isSyncReference; public List pendingSteam = new(); public List knownUsers = new(); diff --git a/Source/Common/Networking/Packet/SyncInfoPacket.cs b/Source/Common/Networking/Packet/SyncInfoPacket.cs index 4c6f1619b..8fe8d7484 100644 --- a/Source/Common/Networking/Packet/SyncInfoPacket.cs +++ b/Source/Common/Networking/Packet/SyncInfoPacket.cs @@ -41,6 +41,19 @@ public void Bind(PacketBuffer buf) private static readonly Binder SyncOpinionBinder = BinderOf.Identity(); } +/// Standalone only: whether the receiving client is the sync reference, the one whose opinions the server forwards to +/// every other client (the host and arbiter know that they are). +[PacketDefinition(Packets.Server_SyncReference)] +public record struct ServerSyncReferencePacket(bool isReference) : IPacket +{ + public bool isReference = isReference; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref isReference); + } +} + public record struct SyncOpinion : IPacketBufferable { public int startTick; diff --git a/Source/Common/Networking/Packets.cs b/Source/Common/Networking/Packets.cs index 0d3cd3a69..c6755424f 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -73,6 +73,9 @@ public enum Packets : byte Client_BootstrapSave, Server_Bootstrap, + // Playing (appended to keep the ids above stable) + Server_SyncReference, + Count, Max = 63 // max packet id } diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index 48a4a4e76..8e810d349 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -10,6 +10,7 @@ public class ServerPlayingState(ConnectionBase conn) : MpConnectionState(conn) public void HandleWorldReady(ByteReader data) { Player.UpdateStatus(PlayerStatus.Playing); + Server.playerManager.UpdateSyncReference(); } [PacketHandler(Packets.Client_RequestRejoin)] @@ -18,6 +19,7 @@ public void HandleRejoin(ByteReader data) // Not caught up until Client_WorldReady again: can't be the sync reference or a join point uploader Player.UpdateStatus(PlayerStatus.Simulating); Server.worldData.OnPlayerUnavailable(Player, "is rejoining"); + Server.playerManager.UpdateSyncReference(); connection.ChangeState(ConnectionStateEnum.ServerLoading); Player.ResetTimeVotes(); @@ -171,15 +173,16 @@ public void HandleClientKeepAlive(ClientKeepAlivePacket packet) [TypedPacketHandler] public void HandleDesyncCheck(ClientSyncInfoPacket packet) { - var arbiter = Server.ArbiterPlaying; - if (arbiter ? !Player.IsArbiter : !Player.IsHost) return; // policy + // Only the reference's opinions are kept and forwarded (arbiter, host, or the standalone reference) + var reference = Server.playerManager.SyncReference; + if (Player != reference) return; // policy // Keep at most 10 sync infos Server.worldData.syncInfos.Add(packet.rawSyncOpinion); if (Server.worldData.syncInfos.Count > 10) Server.worldData.syncInfos.RemoveAt(0); - foreach (var p in Server.PlayingPlayers.Where(p => !p.IsArbiter && (arbiter || !p.IsHost))) + foreach (var p in Server.PlayingPlayers.Where(p => p != reference && !p.IsArbiter)) p.conn.SendFragmented(new ServerSyncInfoPacket { rawSyncOpinion = packet.rawSyncOpinion }.Serialize()); } diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index eb13cf2de..47df642ff 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -72,6 +72,7 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) Players.Remove(player); server.worldData.OnPlayerUnavailable(player, "disconnected"); + UpdateSyncReference(); if (server.worldData.CreatingJoinPoint && (player.IsHost || !Players.Any(p => p.hasJoined))) server.worldData.AbortJoinPointCreation("The host left or no players remain."); @@ -111,6 +112,7 @@ public void OnDesync(ServerPlayer player, int tick, int diffAt) player.UpdateStatus(PlayerStatus.Desynced); server.worldData.OnPlayerUnavailable(player, "desynced"); + UpdateSyncReference(); // Traces come from the client whose opinions the desynced player was compared against (no host on standalone) var traceSource = PlayingPlayers.FirstOrDefault(p => p.IsHost && p != player) ?? SyncReference; @@ -149,6 +151,29 @@ public ServerPlayer? SyncReference } } + private int syncReferenceId = ScheduledCommand.NoPlayer; + + /// Call after player status changes and disconnects + public void UpdateSyncReference() + { + var reference = SyncReference; + var referenceId = reference?.id ?? ScheduledCommand.NoPlayer; + if (referenceId == syncReferenceId) return; + + var previous = GetPlayer(syncReferenceId); + syncReferenceId = referenceId; + ServerLog.Detail($"Sync reference changed to {reference?.Username ?? "none"}"); + + // Joiners compare their catch-up against these, keep only the current reference's opinions + server.worldData.syncInfos.Clear(); + + // Hosted clients know their role (host/arbiter) + if (!server.IsStandaloneServer) return; + + previous?.SendPacket(new ServerSyncReferencePacket(false)); + reference?.SendPacket(new ServerSyncReferencePacket(true)); + } + public static ColorRGB[] PlayerColors = { new(0,125,255), @@ -210,6 +235,7 @@ public void MakeHost(ServerPlayer host) host.SendPlayerList(); SendInitDataCommand(host); host.UpdateStatus(PlayerStatus.Playing); + UpdateSyncReference(); } public ServerPlayer? GetPlayer(string username) diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index fbe1eac84..8051673f2 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -250,6 +250,9 @@ private static IEnumerable RoundtripPackets() yield return new ServerSelectedPacket(1, new ClientSelectedPacket { newlySelectedIds = [1, 10, 100], reset = false, unselectedIds = [] }); + yield return new ServerSyncReferencePacket(true); + yield return new ServerSyncReferencePacket(false); + yield return new ClientDesyncedPacket(1234, 4567); yield return new ClientDesyncedPacket(0, 0); yield return new ClientDesyncedPacket(100, 0); diff --git a/Source/Tests/SyncReferenceTest.cs b/Source/Tests/SyncReferenceTest.cs new file mode 100644 index 000000000..7dc9f218c --- /dev/null +++ b/Source/Tests/SyncReferenceTest.cs @@ -0,0 +1,152 @@ +using System.Linq; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +/// Desync detection compares every client against one reference client's sync opinions +[TestFixture] +public class SyncReferenceTest +{ + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false, + pauseOnDesync = false, + autoJoinPoint = 0 + }); + nextPlayerId = 0; + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private ServerPlayer AddPlayer(string username, PlayerStatus status = PlayerStatus.Playing, + PlayerType type = PlayerType.Normal) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn) { hasJoined = true, status = status, type = type }; + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return player; + } + + private static RecordingConnection Conn(ServerPlayer p) => (RecordingConnection)p.conn; + + private static void SendOpinion(ServerPlayer from) => + from.conn.GetState()!.HandleDesyncCheck(new ClientSyncInfoPacket { rawSyncOpinion = [1, 2, 3] }); + + private static bool? LastReferenceFlag(ServerPlayer p) + { + var msg = Conn(p).SentMessages.LastOrDefault(m => m.id == Packets.Server_SyncReference); + return msg.data == null ? null : msg.data[0] == 1; + } + + [Test] + public void StandaloneForwardsOnlyReferenceOpinions() + { + server.IsStandaloneServer = true; + var reference = AddPlayer("reference"); + var other = AddPlayer("other"); + var joining = AddPlayer("joining", PlayerStatus.Simulating); + + SendOpinion(other); + Assert.That(server.worldData.syncInfos, Is.Empty); + Assert.That(Conn(reference).SentPackets, Does.Not.Contain(Packets.Server_SyncInfo)); + + SendOpinion(reference); + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(1)); + Assert.That(Conn(other).SentPackets, Does.Contain(Packets.Server_SyncInfo)); + Assert.That(Conn(joining).SentPackets, Does.Contain(Packets.Server_SyncInfo)); + Assert.That(Conn(reference).SentPackets, Does.Not.Contain(Packets.Server_SyncInfo)); + } + + [Test] + public void StandaloneReferenceChangeIsAnnounced() + { + server.IsStandaloneServer = true; + var first = AddPlayer("first"); + var second = AddPlayer("second"); + + server.playerManager.UpdateSyncReference(); + Assert.That(LastReferenceFlag(first), Is.True); + Assert.That(LastReferenceFlag(second), Is.Null); + + SendOpinion(first); + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(1)); + + server.playerManager.OnDesync(first, 0, 0); + + Assert.That(server.playerManager.SyncReference, Is.SameAs(second)); + Assert.That(LastReferenceFlag(first), Is.False); + Assert.That(LastReferenceFlag(second), Is.True); + // Opinions of the previous reference aren't used for joiners + Assert.That(server.worldData.syncInfos, Is.Empty); + } + + [Test] + public void StandaloneReferenceMovesWhenItRejoinsAndReturnsAfterWorldReady() + { + server.IsStandaloneServer = true; + var first = AddPlayer("first"); + var second = AddPlayer("second"); + server.playerManager.UpdateSyncReference(); + + var state = first.conn.GetState()!; + state.HandleRejoin(new ByteReader([])); + Assert.That(server.playerManager.SyncReference, Is.SameAs(second)); + Assert.That(LastReferenceFlag(first), Is.False); + + // Back in the playing state after loading, reference again once caught up + first.conn.ChangeState(ConnectionStateEnum.ServerPlaying); + first.conn.GetState()!.HandleWorldReady(new ByteReader([])); + Assert.That(server.playerManager.SyncReference, Is.SameAs(first)); + Assert.That(LastReferenceFlag(first), Is.True); + Assert.That(LastReferenceFlag(second), Is.False); + } + + [Test] + public void HostedForwardsHostOpinionsToOthers() + { + var host = AddPlayer("host"); + server.hostUsername = "host"; + var other = AddPlayer("other"); + + SendOpinion(other); + Assert.That(server.worldData.syncInfos, Is.Empty); + + SendOpinion(host); + Assert.That(Conn(other).SentPackets, Does.Contain(Packets.Server_SyncInfo)); + Assert.That(Conn(host).SentPackets, Does.Not.Contain(Packets.Server_SyncInfo)); + + // Hosted clients know their role, no announcements + server.playerManager.UpdateSyncReference(); + Assert.That(Conn(host).SentPackets, Does.Not.Contain(Packets.Server_SyncReference)); + } + + [Test] + public void HostedForwardsArbiterOpinionsToEveryoneButArbiter() + { + var host = AddPlayer("host"); + server.hostUsername = "host"; + var arbiter = AddPlayer("arbiter", type: PlayerType.Arbiter); + + SendOpinion(host); + Assert.That(server.worldData.syncInfos, Is.Empty); + + SendOpinion(arbiter); + Assert.That(Conn(host).SentPackets, Does.Contain(Packets.Server_SyncInfo)); + Assert.That(Conn(arbiter).SentPackets, Does.Not.Contain(Packets.Server_SyncInfo)); + } +} diff --git a/Source/Tests/packet-serializations/ServerSyncReferencePacket.verified.txt b/Source/Tests/packet-serializations/ServerSyncReferencePacket.verified.txt new file mode 100644 index 000000000..4f32769c1 --- /dev/null +++ b/Source/Tests/packet-serializations/ServerSyncReferencePacket.verified.txt @@ -0,0 +1,2 @@ +01 +00 From a95832b6b596e313b452c57f0972e6f76ae6fa8d Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:01:14 -0600 Subject: [PATCH 050/183] Keep the letter stack the same on every client in multifaction LetterStackReceiveOnlyMyFaction removed every received letter from the stack of clients whose player faction wasn't Faction.OfPlayer, but the simulation reads the stack: LetterStackTick force-opens (and MP applies default choices for) expiring letters only from the local stack, growth moment sessions and their pausing depend on ArchivedOnly/ArchiveView, archive culling skips letters still in the stack (and synced letters are looked up in the archive). Letters received in spectator contexts (world ticks, delayed letters) were dropped for all players. Now letters stay in every client's stack. The faction a letter was received for is recorded (letter id -> faction id, kept for delayed letters, saved in the world comp for the letters still in the stack; old saves just show their letters to everyone) and only the UI filters: LettersOnGUI draws the local player's letters plus faction-less ones (removals/mouseover during drawing are mapped to the real stack), the arrival sound follows the same rule, and expiring letters without a default choice only open their dialog for the letter's players. Expiry and default choices run on every client. Single faction games are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../Client/Comp/World/MultiplayerWorldComp.cs | 39 +++++ Source/Client/Factions/MultifactionPatches.cs | 15 +- Source/Client/Patches/Letters.cs | 153 ++++++++++++++++-- 3 files changed, 186 insertions(+), 21 deletions(-) diff --git a/Source/Client/Comp/World/MultiplayerWorldComp.cs b/Source/Client/Comp/World/MultiplayerWorldComp.cs index a505856af..f6a7190fb 100644 --- a/Source/Client/Comp/World/MultiplayerWorldComp.cs +++ b/Source/Client/Comp/World/MultiplayerWorldComp.cs @@ -22,6 +22,10 @@ public class MultiplayerWorldComp : IHasSessionData public Faction spectatorFaction; + // Multifaction: letter ID -> load ID of Faction.OfPlayer when the letter was received. Only used by the UI to show + // letters to their faction's players, the letter stack itself is the same on every client. + public Dictionary letterFactions = new(); + private int currentFactionId; public MultiplayerWorldComp(World world) @@ -34,6 +38,7 @@ public MultiplayerWorldComp(World world) public void ExposeData() { ExposeFactionData(); + ExposeLetterFactions(); sessionManager.ExposeSessions(); // Ensure a pause lock session exists if there's any pause locks registered @@ -128,6 +133,40 @@ private void ExposeFactionData() } } + private void ExposeLetterFactions() + { + List letterIds = null; + List factionIds = null; + + if (Scribe.mode == LoadSaveMode.Saving) + { + // Only letters still in the stack (or waiting to be received) matter + letterIds = new List(); + factionIds = new List(); + + var stack = Find.LetterStack; + foreach (var letter in stack.letters.Concat(stack.letterQueue)) + { + if (letter != null && letterFactions.TryGetValue(letter.ID, out var factionId)) + { + letterIds.Add(letter.ID); + factionIds.Add(factionId); + } + } + } + + Scribe_Collections.Look(ref letterIds, "letterIds", LookMode.Value); + Scribe_Collections.Look(ref factionIds, "letterFactionIds", LookMode.Value); + + if (Scribe.mode == LoadSaveMode.LoadingVars) + { + letterFactions = new Dictionary(); + if (letterIds != null && factionIds != null) + for (int i = 0; i < letterIds.Count && i < factionIds.Count; i++) + letterFactions[letterIds[i]] = factionIds[i]; + } + } + public void WriteSessionData(ByteWriter writer) { sessionManager.WriteSessionData(writer); diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index a1ad2a177..87909aa08 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -634,17 +634,6 @@ static bool Prefix(JobDriver __instance) } -[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] -static class LetterStackReceiveOnlyMyFaction -{ - // todo the letter might get culled from the archive if it isn't in the stack and Sync depends on the archive - static void Postfix(LetterStack __instance, Letter let) - { - if (Multiplayer.RealPlayerFaction != Faction.OfPlayer) - __instance.letters.Remove(let); - } -} - [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] static class LetterStackReceiveSoundOnlyMyFaction { @@ -666,7 +655,9 @@ static IEnumerable Transpiler(IEnumerable inst static void PlaySoundReplacement(SoundDef sound, Map map) { - if (Multiplayer.RealPlayerFaction == Faction.OfPlayer) + // Letters are kept in every client's stack and only shown to their faction's players (see MultifactionLetters) + var letter = RecordLetterFaction.receiving; + if (letter != null ? MultifactionLetters.IsVisibleLocally(letter) : Multiplayer.RealPlayerFaction == Faction.OfPlayer) sound.PlayOneShotOnCamera(map); } } diff --git a/Source/Client/Patches/Letters.cs b/Source/Client/Patches/Letters.cs index 93ba7396b..2aa76d2dd 100644 --- a/Source/Client/Patches/Letters.cs +++ b/Source/Client/Patches/Letters.cs @@ -11,6 +11,127 @@ namespace Multiplayer.Client.Patches { // todo letter timeouts and async time + /// + /// In multifaction every client keeps the same letter stack, as the simulation reads it (expiring letters and their + /// default choices, growth moment sessions, archive culling). Letters are only hidden in the UI from players of other + /// factions, based on the faction (Faction.OfPlayer) they were received for. + /// + public static class MultifactionLetters + { + public static bool Active => Multiplayer.Client != null && Multiplayer.GameComp.multifaction; + + public static void RecordReceivingFaction(Letter let) + { + // Negative ids are interface-only letters + if (!Active || let == null || let.ID < 0 || Faction.OfPlayer == null) return; + + // Delayed letters are received again later from the queue (in another context), keep the first faction + var letterFactions = Multiplayer.WorldComp.letterFactions; + if (!letterFactions.ContainsKey(let.ID)) + letterFactions[let.ID] = Faction.OfPlayer.loadID; + } + + public static bool IsVisibleLocally(Letter let) + { + if (!Active || let == null) return true; + + var worldComp = Multiplayer.WorldComp; + if (worldComp == null || !worldComp.letterFactions.TryGetValue(let.ID, out var factionId)) + return true; + + // Letters received in a spectator context (e.g. world ticks) aren't specific to a player faction + return factionId == Multiplayer.RealPlayerFaction?.loadID || + factionId == worldComp.spectatorFaction?.loadID || + Find.FactionManager.GetById(factionId) == null; + } + } + + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] + static class RecordLetterFaction + { + // The letter being received, for the arrival sound + public static Letter receiving; + + static void Prefix(Letter let, ref Letter __state) + { + if (Multiplayer.Client == null) return; + + MultifactionLetters.RecordReceivingFaction(let); + __state = receiving; + receiving = let; + } + + static void Finalizer(Letter __state) + { + if (Multiplayer.Client == null) return; + receiving = __state; + } + } + + // Draw only the letters of the local player's faction, the stack itself is kept the same on every client + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.LettersOnGUI))] + static class LettersOnGUIOnlyMyFaction + { + // The actual list while the filtered one is being drawn + public static List realLetters; + private static List visibleLetters = new(); + + static void Prefix(LetterStack __instance, ref bool __state) + { + if (!MultifactionLetters.Active || realLetters != null) return; + + var letters = __instance.letters; + if (letters.All(MultifactionLetters.IsVisibleLocally)) return; + + visibleLetters.Clear(); + foreach (var letter in letters) + if (MultifactionLetters.IsVisibleLocally(letter)) + visibleLetters.Add(letter); + + realLetters = letters; + __instance.letters = visibleLetters; + __state = true; + } + + static void Finalizer(LetterStack __instance, bool __state) + { + if (!__state) return; + + var drawn = __instance.letters; + __instance.letters = realLetters; + realLetters = null; + + // Removals are applied by LetterStackRemoveDuringFilteredGUI, add letters received while drawing + foreach (var letter in drawn) + if (!__instance.letters.Contains(letter)) + __instance.letters.Add(letter); + + visibleLetters.Clear(); + } + } + + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.RemoveLetter))] + static class LetterStackRemoveDuringFilteredGUI + { + static void Postfix(LetterStack __instance, Letter let) + { + // Removed from the drawn list (e.g. dismissed with right click), remove it from the actual one too + if (LettersOnGUIOnlyMyFaction.realLetters != null && !__instance.letters.Contains(let)) + LettersOnGUIOnlyMyFaction.realLetters.Remove(let); + } + } + + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.Notify_LetterMouseover))] + static class LetterMouseoverDuringFilteredGUI + { + static void Postfix(LetterStack __instance, Letter let) + { + // The index is used on the actual list in LetterStackUpdate + if (LettersOnGUIOnlyMyFaction.realLetters != null) + __instance.mouseoverLetterIndex = LettersOnGUIOnlyMyFaction.realLetters.IndexOf(let); + } + } + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.LetterStackUpdate))] static class CloseLettersDuringSimulating { @@ -55,27 +176,41 @@ static IEnumerable TargetMethods() yield return AccessTools.DeclaredMethod(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.OpenLetter)); // Not a subtype of ChoiceLetter, only LetterWithTimeout } - static bool Prefix(LetterWithTimeout __instance) + static bool Prefix(LetterWithTimeout __instance, ref bool __state) { // The letter is about to be force-shown by LetterStack.LetterStackTick because of expiry if (Multiplayer.Ticking && __instance.TimeoutActive - && __instance.disappearAtTick == Find.TickManager.TicksGame + 1 - && defaultChoices.TryGetValue(__instance.GetType(), out var tuple)) + && __instance.disappearAtTick == Find.TickManager.TicksGame + 1) { - if (tuple.method.IsStatic) - tuple.handler(null, __instance); - else - tuple.handler(__instance); + if (defaultChoices.TryGetValue(__instance.GetType(), out var tuple)) + { + if (tuple.method.IsStatic) + tuple.handler(null, __instance); + else + tuple.handler(__instance); - return false; + __state = true; + return false; + } + + // Every client force-opens it (the stack is the same everywhere), only show it to the letter's faction. + // Opening a ChoiceLetter only creates its dialog. + if (!MultifactionLetters.IsVisibleLocally(__instance)) + { + __state = true; + return false; + } } return true; } - static void Postfix(LetterWithTimeout __instance) + static void Postfix(LetterWithTimeout __instance, bool __state) { + // The letter wasn't opened + if (__state) return; + var wasArchived = __instance.ArchivedOnly; var window = Find.WindowStack.WindowOfType(); From d900efeb487b4596b642c3969169b34f42ffbba1 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:01:42 -0600 Subject: [PATCH 051/183] Save the pending gravship cutscene end A join point can be created during a cutscene (e.g. on a standalone server without a host nothing freezes, and there only the requesting client reloads). The pending flag was tied to the MultiplayerGame instance, which is recreated on every load, so a reloaded client would ignore the synced end the others execute. Save the flag with the gravship controller instead; it's reset on every load. Co-Authored-By: Claude Opus 5.5 --- .../Patches/GravshipTravelSessionPatches.cs | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 35c9c527a..040044ce3 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -202,20 +202,27 @@ public static class GravshipCutsceneSync private enum Cutscene { None, Takeoff, Landing } // Only changed from InitiateTakeoff/InitiateLanding and TakeoffEnded/LandingEnded which run in commands, - // so it's the same on all clients. + // so it's the same on all clients. Saved with the controller, a join point can happen during a cutscene. private static Cutscene pending; - private static MultiplayerGame pendingGame; // Local state of this client private static bool endRequested; private static bool frozen; - public static bool EndRequested => endRequested && pendingGame == Multiplayer.game; + public static bool EndRequested => endRequested && pending != Cutscene.None; + + public static void ExposeData() + { + // Don't add it to singleplayer saves, but reset it on every load + if (Scribe.mode == LoadSaveMode.Saving && Multiplayer.Client == null) + return; + + Scribe_Values.Look(ref pending, "mpPendingCutsceneEnd"); + } public static void CutsceneStarted(bool takeoff) { pending = takeoff ? Cutscene.Takeoff : Cutscene.Landing; - pendingGame = Multiplayer.game; endRequested = false; frozen = true; @@ -231,7 +238,7 @@ private static void Unfreeze() } private static bool IsPending(bool takeoff) => - pendingGame == Multiplayer.game && pending == (takeoff ? Cutscene.Takeoff : Cutscene.Landing); + pending == (takeoff ? Cutscene.Takeoff : Cutscene.Landing); // Called by vanilla in the interface when the local cutscene ends public static void LocalCutsceneEnded(WorldComponent_GravshipController controller, bool takeoff) @@ -310,6 +317,12 @@ private static void RunWithFeedback(Action action) } } + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.ExposeData))] + public static class PatchGravshipControllerExposePendingEnd + { + static void Postfix() => GravshipCutsceneSync.ExposeData(); + } + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.WorldComponentUpdate))] public static class PatchGravshipCutsceneHoldLastFrame { From 977e1bc7203a9d304632d9b0e169600638858e2d Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:02:46 -0600 Subject: [PATCH 052/183] Guard the UI tile temperature tick against exceptions It runs from whatever interface code reads a temperature; don't let an exception there break that code every frame. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/TileTemperatures.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Source/Client/Patches/TileTemperatures.cs b/Source/Client/Patches/TileTemperatures.cs index 8214d52bc..8c64d98de 100644 --- a/Source/Client/Patches/TileTemperatures.cs +++ b/Source/Client/Patches/TileTemperatures.cs @@ -1,6 +1,7 @@ using HarmonyLib; using RimWorld; using RimWorld.Planet; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -42,7 +43,15 @@ static bool Prefix(TileTemperaturesComp __instance, PlanetTile tile, ref TileTem if (uiTickedAtFrame != Time.frameCount) { uiTickedAtFrame = Time.frameCount; - uiTemperatures.WorldComponentTick(); + + try + { + uiTemperatures.WorldComponentTick(); + } + catch (Exception e) + { + Log.ErrorOnce($"MP: Exception ticking UI tile temperatures: {e}", 846582641); + } } __result = uiTemperatures.RetrieveCachedData(tile); From d67d559d5751e10ef69c6fed7a1e61ec9896d73e Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:02:48 -0600 Subject: [PATCH 053/183] Reload the game under ProgramState.MapInitializing Join point and resync reloads go through SaveLoad.LoadInMainThread, which loaded the game while ProgramState stayed Playing. A normal load, and a joining client which loads through a scene change, run under MapInitializing. Load-time code that checks for Playing then behaved differently on existing players than on the joiner, e.g. GenSpawn.Spawn's per-cell item limit moved or destroyed stacked items only on reloading clients (#854). Set MapInitializing for the load and restore the previous state afterwards unless the load changed it. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Saving/SaveLoad.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 857ff0b18..16bd71d9b 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -136,8 +136,21 @@ public static void LoadInMainThread(TempGameData gameData) Find.Root.Start(); CancelRootPlayStartLongEvents.cancel = false; - // SaveCompression enabled in the patch - SavedGameLoaderNow.LoadGameFromSaveFileNow(null); + // Load in the same program state as a normal load (and a joining client, which loads through a scene + // change) does. Load-time code checks for Playing, e.g. GenSpawn.Spawn's per-cell item limit (#854). + var prevProgramState = Current.ProgramState; + Current.programStateInt = ProgramState.MapInitializing; + + try + { + // SaveCompression enabled in the patch + SavedGameLoaderNow.LoadGameFromSaveFileNow(null); + } + finally + { + if (Current.ProgramState == ProgramState.MapInitializing) + Current.programStateInt = prevProgramState; + } DeepProfiler.End(); } From 8a091e5579bdc750568ee83479ab0e0fca7d9c8d Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:02:54 -0600 Subject: [PATCH 054/183] Don't let temporary faction generation register ideoligions Generating the temporary faction for the starting pawn page may add an ideoligion to the live IdeoManager on the creating client only (the faction used to be removed through FactionManager.Remove, which isn't the case anymore). Restore the IdeoManager's list if it changed; the temporary faction keeps using the ideoligion unregistered. Co-Authored-By: Claude Opus 5.5 --- .../Page_ConfigureStartingPawns_Multifaction.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs b/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs index ed21e815d..b6d7e9a8c 100644 --- a/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs +++ b/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs @@ -1,3 +1,4 @@ +using System.Linq; using Multiplayer.Client.Util; using RimWorld; using UnityEngine; @@ -75,9 +76,20 @@ private void CleanUpTemporaryFaction() private void GenerateTemporaryFaction() { + // Generating a faction can register a new ideoligion, which would be live game state on this client only. + // The temporary faction can keep using it unregistered. + var ideos = Find.IdeoManager.ideos; + var prevIdeos = ideos.ToList(); + _temporaryFactionForPawnCreation = FactionGenerator.NewGeneratedFaction(new FactionGeneratorParms(_scenario.playerFaction.factionDef)); _temporaryFactionForPawnCreation.temporary = true; + if (!ideos.SequenceEqual(prevIdeos)) + { + ideos.Clear(); + ideos.AddRange(prevIdeos); + } + // The faction isn't added to the FactionManager: it would be live game state on this client only (ticked, // iterated by the simulation and saved into join points). Pawn generation only needs it as Faction.OfPlayer, // which comes from GameInitData.playerFaction. NewGeneratedFaction also added relations with the temporary From 0bd4e06a32df13d2f872aabe44476c6a98132217 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:03:21 -0600 Subject: [PATCH 055/183] Test that current-format command logs are never reordered The legacy merge only applies to old per-map command lists; a log in the current format must come back unchanged even if its ticks are not sorted. Co-Authored-By: Claude Opus 5.5 --- Source/Tests/CommandLogTest.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Source/Tests/CommandLogTest.cs b/Source/Tests/CommandLogTest.cs index 476907112..2c231bc21 100644 --- a/Source/Tests/CommandLogTest.cs +++ b/Source/Tests/CommandLogTest.cs @@ -174,6 +174,19 @@ public void ClientCommandDataIsCappedAtRebroadcastLimit() Assert.Catch(() => tooBig.Serialize()); } + [Test] + public void CurrentFormatLogsAreNotReordered() + { + var log = new List + { + new(CommandType.Sync, 10, 0, 5, 0, [1]), + new(CommandType.Sync, 10, 0, ScheduledCommand.Global, 0, [2]), + new(CommandType.Sync, 3, 0, 5, 0, [3]), // e.g. after a debug timer reset + }; + + Assert.That(ScheduledCommand.MergeLegacyCmds(log, [[], []]), Is.SameAs(log)); + } + private static ScheduledCommand ToCmd(byte[] packetData) { var packet = default(ServerCommandPacket); From b7a389fcc602906cd32cf1cf7eac66b77aa128d3 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:03:29 -0600 Subject: [PATCH 056/183] Null-guard the multifaction letter helpers Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Letters.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/Client/Patches/Letters.cs b/Source/Client/Patches/Letters.cs index 2aa76d2dd..1631562c5 100644 --- a/Source/Client/Patches/Letters.cs +++ b/Source/Client/Patches/Letters.cs @@ -18,7 +18,7 @@ namespace Multiplayer.Client.Patches /// public static class MultifactionLetters { - public static bool Active => Multiplayer.Client != null && Multiplayer.GameComp.multifaction; + public static bool Active => Multiplayer.Client != null && Multiplayer.game?.gameComp?.multifaction == true; public static void RecordReceivingFaction(Letter let) { @@ -26,8 +26,8 @@ public static void RecordReceivingFaction(Letter let) if (!Active || let == null || let.ID < 0 || Faction.OfPlayer == null) return; // Delayed letters are received again later from the queue (in another context), keep the first faction - var letterFactions = Multiplayer.WorldComp.letterFactions; - if (!letterFactions.ContainsKey(let.ID)) + var letterFactions = Multiplayer.game.worldComp?.letterFactions; + if (letterFactions != null && !letterFactions.ContainsKey(let.ID)) letterFactions[let.ID] = Faction.OfPlayer.loadID; } @@ -35,7 +35,7 @@ public static bool IsVisibleLocally(Letter let) { if (!Active || let == null) return true; - var worldComp = Multiplayer.WorldComp; + var worldComp = Multiplayer.game.worldComp; if (worldComp == null || !worldComp.letterFactions.TryGetValue(let.ID, out var factionId)) return true; From 54525020e3ce86d5b7a96a3eebb2191a58c52ddc Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:04:14 -0600 Subject: [PATCH 057/183] Don't check the child's map in GrowthMomentSession.IsSessionValid IsSessionValid is also evaluated right after loading (sessions failing it are dropped), when pawns aren't spawned yet, so requiring the child to be on the session's map would have dropped every growth moment session for clients loading a save while the others kept it. Only the tick, pausing and window code require the child to be on the map. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Persistent/GrowthMomentSession.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Source/Client/Persistent/GrowthMomentSession.cs b/Source/Client/Persistent/GrowthMomentSession.cs index a0caa10f5..377456a58 100644 --- a/Source/Client/Persistent/GrowthMomentSession.cs +++ b/Source/Client/Persistent/GrowthMomentSession.cs @@ -43,14 +43,17 @@ public static GrowthMomentSession GetSessionFor(Pawn pawn) // LetterWithTimeout.LastTickBeforeTimeout adjusted to use the map's time instead of Find.TickManager.TicksGame private bool AtTimeout => letter.TimeoutActive && letter.disappearAtTick <= map.AsyncTime().mapTicks + 1; - public override bool IsCurrentlyPausing(Map map) => this.map == map && IsSessionValid && AtTimeout; + public override bool IsCurrentlyPausing(Map map) => this.map == map && IsActive && AtTimeout; + + // Also checked right after loading, when pawns aren't spawned yet, so it mustn't depend on the child's map + public override bool IsSessionValid => map != null && Pawn != null && !letter.ArchiveView; // The child leaving the map (or dying) ends the session, the letter's timeout handles the rest - public override bool IsSessionValid => map != null && Pawn is { Dead: false } pawn && pawn.Map == map && !letter.ArchiveView; + private bool IsActive => IsSessionValid && Pawn.Map == map; public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { - if (Map != entry.map || !IsSessionValid) return null; + if (Map != entry.map || !IsActive) return null; return new FloatMenuOption("MpGrowthMomentSession".Translate(Pawn.Name.ToStringShort), OpenWindow); } @@ -69,7 +72,7 @@ public void UpdateChoices(int traitIdx, List passionIndexes) public void OpenWindow() { - if (!IsSessionValid) return; + if (!IsActive) return; letter.TrySetChoices(); var window = new GrowthMomentWindow(letter.text, letter); Find.WindowStack.Add(window); @@ -77,14 +80,14 @@ public void OpenWindow() public void Tick() { - if (!IsSessionValid) map.MpComp().sessionManager.RemoveSession(this); + if (!IsActive) map.MpComp().sessionManager.RemoveSession(this); else if (AtTimeout && IsLocalPlayersChild) OpenWindow(); } // UI only: reopen the window if it was closed while the map waits for the choice public void ReopenIfWaiting() { - if (IsSessionValid && AtTimeout && IsLocalPlayersChild) + if (IsActive && AtTimeout && IsLocalPlayersChild) OpenWindow(); } From 38fbe9d18ac46ae60424df6d1d0b735950b4f188 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:05:25 -0600 Subject: [PATCH 058/183] Don't let one subsystem's exception skip the rest of a tick AsyncTimeComp.Tick ran everything in a single try/finally, so an exception in MapPreTick skipped the mapTicks increment and everything after it, and one in the sessions or storyteller skipped quests, MapPostTick and the manager updates. Vanilla's DoSingleTick catches per subsystem; do the same for MapPreTick, map sessions, the storyteller, the story watcher and async quests. For the world, an exception in DoSingleTick skipped worldTicks++ and the world sessions. worldTicks is set from TicksGame after loading, so a client that joined later would disagree with the others from then on. Catch it and derive worldTicks from TicksGame after the tick the same way. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 49 +++++++++++++++++-- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 24 +++++++-- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 560048233..bf73eea91 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -118,7 +118,17 @@ public void Tick() try { - map.MapPreTick(); + // Like vanilla's DoSingleTick, an exception in one subsystem shouldn't skip the rest of the tick + // (in particular the mapTicks increment) + try + { + map.MapPreTick(); + } + catch (Exception e) + { + Log.Error($"Exception in MapPreTick of {map}: {e}"); + } + mapTicks++; Find.TickManager.ticksGameInt = mapTicks; @@ -132,12 +142,41 @@ public void Tick() tickListRare.Tick(); tickListLong.Tick(); - TickMapSessions(); + try + { + TickMapSessions(); + } + catch (Exception e) + { + Log.Error($"Exception ticking sessions of {map}: {e}"); + } + + try + { + Find.Storyteller.StorytellerTick(); + } + catch (Exception e) + { + Log.Error(e.ToString()); + } - Find.Storyteller.StorytellerTick(); - Find.StoryWatcher.StoryWatcherTick(); + try + { + Find.StoryWatcher.StoryWatcherTick(); + } + catch (Exception e) + { + Log.Error(e.ToString()); + } - QuestManagerTickAsyncTime(); + try + { + QuestManagerTickAsyncTime(); + } + catch (Exception e) + { + Log.Error(e.ToString()); + } map.MapPostTick(); Find.TickManager.ticksThisFrame = 1; diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index fa1fef3e3..09b89d60f 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -108,9 +108,27 @@ public void Tick() try { - Find.TickManager.DoSingleTick(); - worldTicks++; - Multiplayer.WorldComp.TickWorldSessions(); + try + { + Find.TickManager.DoSingleTick(); + } + catch (Exception e) + { + Log.Error($"Exception in world DoSingleTick: {e}"); + } + + // Matches how worldTicks is initialized after loading (ExposeData), so clients which joined later agree + // even if a tick threw before or after advancing TicksGame + worldTicks = Find.TickManager.TicksGame; + + try + { + Multiplayer.WorldComp.TickWorldSessions(); + } + catch (Exception e) + { + Log.Error($"Exception ticking world sessions: {e}"); + } if (ModsConfig.BiotechActive) { From 3f575f8e6b60bff7a2627eb6cc529347a8094a78 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:06:58 -0600 Subject: [PATCH 059/183] Keep pending sync opinions across reloads Every reload (join points) creates a new MultiplayerGame and with it a new SyncCoordinator, dropping the opinions still waiting for their counterpart from the other side, so a desync right before a join point went unnoticed until it showed up again later. Take them over (with lastValidTick) when the new coordinator is created during a reload of the same session. Also log opinions that are skipped because their windows don't line up instead of discarding them silently. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Desyncs/SyncCoordinator.cs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Source/Client/Desyncs/SyncCoordinator.cs b/Source/Client/Desyncs/SyncCoordinator.cs index 1efd6f535..952e0f73d 100644 --- a/Source/Client/Desyncs/SyncCoordinator.cs +++ b/Source/Client/Desyncs/SyncCoordinator.cs @@ -31,6 +31,27 @@ public class SyncCoordinator private const int MaxBacklog = 30; + // The coordinator of the previous MultiplayerGame instance, see the constructor + private static SyncCoordinator previous; + private readonly MultiplayerSession session; + + public SyncCoordinator() + { + session = Multiplayer.session; + + // Every reload (e.g. a join point) creates a new MultiplayerGame. Keep the opinions still waiting for + // their counterpart so that a desync right before the reload is still detected. + if (Multiplayer.reloading && previous is { } prev && prev != this && prev.session != null && prev.session == session) + { + knownClientOpinions.AddRange(prev.knownClientOpinions); + prev.knownClientOpinions.Clear(); + lastValidTick = prev.lastValidTick; + arbiterWasPlayingOnLastValidTick = prev.arbiterWasPlayingOnLastValidTick; + } + + previous = this; + } + public ClientSyncOpinion FinishLocalOpinion() { if (!ShouldCollect || currentOpinion == null) return null; @@ -78,6 +99,8 @@ public void AddClientOpinionAndCheckDesync(ClientSyncOpinion newOpinion) if (knownClientOpinions.First().startTick != newOpinion.startTick) { // Ignore this opinion + MpLog.Log($"Sync opinion windows don't line up, skipping the check: {(newOpinion.isLocalClientsOpinion ? "local" : "remote")} " + + $"starts at {newOpinion.startTick}, the oldest known one at {knownClientOpinions.First().startTick}"); newOpinion.Clear(); return; } From 9427b7dc5918a4e82d8d50e1146beeddb9c234e4 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:07:54 -0600 Subject: [PATCH 060/183] Run the ship countdown end in the world context ConstantTicker ends the ship countdown during ticking but outside of any tickable's PreContext, so ShipCountdown.CountdownEnded (which destroys the ship and removes its pawns) consumed the unsynced global RNG and the traces taken there hashed a client-specific RNG state. Run it inside the world's PreContext/PostContext. Co-Authored-By: Claude Opus 5.5 --- Source/Client/ConstantTicker.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs index 214ce1e33..f8e19bf6e 100644 --- a/Source/Client/ConstantTicker.cs +++ b/Source/Client/ConstantTicker.cs @@ -18,7 +18,7 @@ public static void Tick() try { - TickShipCountdown(); // todo control the RNG seed here? + TickShipCountdown(); TickNonSimulation(); } finally @@ -140,7 +140,22 @@ private static void TickShipCountdown() ShipCountdown.timeLeft -= 1f / GenTicks.TicksPerRealSecond; if (ShipCountdown.timeLeft <= 0f) - ShipCountdown.CountdownEnded(); + { + // This is game logic (destroys the ship, removes pawns): run it on the world's RNG and faction + // context instead of the unsynced RNG outside of any tickable + var world = Multiplayer.AsyncWorldTime; + int randDepth = Rand.stateStack.Count; + world.PreContext(); + try + { + ShipCountdown.CountdownEnded(); + } + finally + { + AsyncTimeComp.RestoreRandStack(randDepth + 1, "ShipCountdown"); + world.PostContext(); + } + } } } } From b33e913f54230bab8847d542432e358071fce0ac Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:09:10 -0600 Subject: [PATCH 061/183] Switch the faction's world data for map generation MapGenFactionPatch only swapped Faction.OfPlayer, so a map generated for another player faction than the current context's used the context faction's world data (research, play settings, history, storyteller). Use the full PushFaction/PopFaction in multiplayer, as the tick and command contexts do. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/FactionContextSetters.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Source/Client/Factions/FactionContextSetters.cs b/Source/Client/Factions/FactionContextSetters.cs index 9351e2062..d39d26e25 100644 --- a/Source/Client/Factions/FactionContextSetters.cs +++ b/Source/Client/Factions/FactionContextSetters.cs @@ -31,7 +31,11 @@ static void Prefix(PlanetTile tile) if (Multiplayer.Client != null && factionToSet == null) Log.Warning($"Couldn't set the faction context for map gen at {tile.tileId}: no player world object at the tile."); - FactionContext.Push(factionToSet); + // Also switch the faction's world data (research, play settings, history...) used during generation + if (Multiplayer.Client != null) + FactionExtensions.PushFaction(null, factionToSet); + else + FactionContext.Push(factionToSet); } private static Faction GetFactionAt(PlanetTile tile) @@ -61,7 +65,10 @@ private static Faction GetFactionAt(PlanetTile tile) static void Finalizer() { - FactionContext.Pop(); + if (Multiplayer.Client != null) + FactionExtensions.PopFaction(); + else + FactionContext.Pop(); } } From c436e65767f0b6d1152c74c5fd52c435414e37b6 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:21:15 -0600 Subject: [PATCH 062/183] Address review findings in the sync fixes - Flush leaked field watch scopes at the end of UIRoot_Play.UIRootOnGUI and UIRootUpdate too: OnMainThread.Update has no fixed order relative to Root_Play.Update, so a leaked UI value could still be in the simulation during the next frame's ticks. - Clear the watch stack when multiplayer stops and don't push InGameLoop watch entries in singleplayer (nothing pops them), so the next session can't restore and sync targets from an old game. - Gene reader: require the exact loadID (and def) instead of falling back to GetGene(def), which reintroduced the endogene/xenogene ambiguity. - Colonist bar reorder also shifts colonist corpses' pawns, which the bar shows like vanilla. - The unheld Thing holder kind only covers world pawns. The PawnsFinder fallback rebuilt PawnsFinder's shared result lists while serializing, which can break a caller iterating one of them. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/Dict/SyncDictDlc.cs | 5 ++-- .../Client/Syncing/Dict/SyncDictRimWorld.cs | 6 ++--- Source/Client/Syncing/Game/SyncDelegates.cs | 17 +++++++++--- Source/Client/Syncing/Handler/SyncField.cs | 3 ++- Source/Client/Syncing/RwSerialization.cs | 11 +------- Source/Client/Syncing/SyncFieldUtil.cs | 27 +++++++++++++------ 6 files changed, 42 insertions(+), 27 deletions(-) diff --git a/Source/Client/Syncing/Dict/SyncDictDlc.cs b/Source/Client/Syncing/Dict/SyncDictDlc.cs index ed74cee1e..7c0ad6eb9 100644 --- a/Source/Client/Syncing/Dict/SyncDictDlc.cs +++ b/Source/Client/Syncing/Dict/SyncDictDlc.cs @@ -241,8 +241,9 @@ public static class SyncDictDlc if (genes == null) return null; - // A pawn can have the same gene def both as an endogene and a xenogene - return genes.GenesListForReading.Find(g => g.loadID == loadId) ?? genes.GetGene(geneDef); + // A pawn can have the same gene def both as an endogene and a xenogene, so don't fall back to + // GetGene(def) if the exact gene is gone + return genes.GenesListForReading.Find(g => g.loadID == loadId && g.def == geneDef); }, true // implicit }, { diff --git a/Source/Client/Syncing/Dict/SyncDictRimWorld.cs b/Source/Client/Syncing/Dict/SyncDictRimWorld.cs index 1749abb80..3ced8310f 100644 --- a/Source/Client/Syncing/Dict/SyncDictRimWorld.cs +++ b/Source/Client/Syncing/Dict/SyncDictRimWorld.cs @@ -819,8 +819,8 @@ public static class SyncDictRimWorld holder = worldObj; else if (RwSerialization.GetAnyParent(thing) is { } worldObjComp) holder = worldObjComp; - else if (thing is Pawn pawn && RwSerialization.CanFindUnheldPawn(pawn)) - holder = Find.WorldPawns; // World pawns, pawns kept by quests/letters, etc. + else if (thing is Pawn pawn && pawn.IsWorldPawn()) + holder = Find.WorldPawns; // E.g. pawns only referenced by quests or letters RwSerialization.GetImpl(holder, RwSerialization.supportedThingHolders, out Type implType, out int index); if (index == -1) @@ -856,7 +856,7 @@ public static class SyncDictRimWorld Type implType = RwSerialization.supportedThingHolders[implIndex]; if (implType == typeof(WorldPawns)) - return RwSerialization.FindUnheldPawn(thingId); + return Find.WorldPawns.AllPawnsAliveOrDead.FirstOrDefault(p => p.thingIDNumber == thingId); if (implType != typeof(Map)) { diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index 010ac1f46..00bc229c7 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -492,12 +492,23 @@ static void SyncedColonistBarReorder(Pawn dragged, Pawn target, bool toEnd, List int newOrder = toEnd ? group[group.Count - 1].playerSettings.displayOrder + 1 : target.playerSettings.displayOrder; // Same as ColonistBar.Reorder, but instead of the local bar's entries it shifts the group and all the faction's - // pawns. Shifting pawns which aren't in the bar keeps the relative order, so the bar's order is the same. + // pawns (and colonist corpses, which the bar shows too). Shifting pawns which aren't in the bar keeps the + // relative order, so the bar's order is the same. var pawns = new List(group); var seen = new HashSet(group); - foreach (var p in PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive) - if (p.playerSettings != null && p.Faction == Faction.OfPlayer && seen.Add(p)) + + void TryAdd(Pawn p) + { + if (p?.playerSettings != null && p.Faction == Faction.OfPlayer && seen.Add(p)) pawns.Add(p); + } + + foreach (var p in PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive) + TryAdd(p); + + foreach (var map in Find.Maps) + foreach (var thing in map.listerThings.ThingsInGroup(ThingRequestGroup.Corpse)) + TryAdd((thing as Corpse)?.InnerPawn); foreach (var p in pawns) { diff --git a/Source/Client/Syncing/Handler/SyncField.cs b/Source/Client/Syncing/Handler/SyncField.cs index 6f5306345..b8c99cff3 100644 --- a/Source/Client/Syncing/Handler/SyncField.cs +++ b/Source/Client/Syncing/Handler/SyncField.cs @@ -105,7 +105,8 @@ public override void Handle(ByteReader data) public void Watch(object target = null, object index = null) { - if (!(inGameLoop || Multiplayer.ShouldSync)) + // In singleplayer nothing ever pops the entry (InGameLoop fields) + if (!(inGameLoop || Multiplayer.ShouldSync) || Multiplayer.Client == null) return; // The enclosing watch scope takes care of it (e.g. a watched method called from another watched method) diff --git a/Source/Client/Syncing/RwSerialization.cs b/Source/Client/Syncing/RwSerialization.cs index eba9068a1..b155c6935 100644 --- a/Source/Client/Syncing/RwSerialization.cs +++ b/Source/Client/Syncing/RwSerialization.cs @@ -7,7 +7,6 @@ using HarmonyLib; using Multiplayer.API; using Multiplayer.Common; -using RimWorld; using RimWorld.Planet; using Verse; @@ -141,17 +140,9 @@ internal static T GetAnyParent(Thing thing) where T : class typeof(ThingComp), typeof(WorldObject), typeof(WorldObjectComp), - typeof(WorldPawns) // Not an IThingHolder, marks a pawn looked up by id (see FindUnheldPawn) + typeof(WorldPawns) // Not an IThingHolder, marks a world pawn looked up by id }; - // Pawns without a supported holder: world pawns and pawns only referenced by quests, letters etc. - internal static bool CanFindUnheldPawn(Pawn pawn) => - pawn.IsWorldPawn() || PawnsFinder.AllMapsWorldAndTemporary_AliveOrDead.Contains(pawn); - - internal static Pawn FindUnheldPawn(int thingId) => - Find.WorldPawns.AllPawnsAliveOrDead.FirstOrDefault(p => p.thingIDNumber == thingId) ?? - PawnsFinder.AllMapsWorldAndTemporary_AliveOrDead.FirstOrDefault(p => p.thingIDNumber == thingId); - internal static string ThingHolderString(Thing thing) { StringBuilder builder = new StringBuilder(thing.ToString()); diff --git a/Source/Client/Syncing/SyncFieldUtil.cs b/Source/Client/Syncing/SyncFieldUtil.cs index 6554b0a59..18f121d92 100644 --- a/Source/Client/Syncing/SyncFieldUtil.cs +++ b/Source/Client/Syncing/SyncFieldUtil.cs @@ -1,5 +1,6 @@ using HarmonyLib; using Multiplayer.Common; +using RimWorld; using System; using System.Collections.Generic; using System.Linq; @@ -140,16 +141,23 @@ private static void HandleWatchedField(FieldData data) cache[bufferTarget] = new BufferData(handler, data.oldValue, SnapshotValueIfNeeded(handler, newValue)); } + // Watch scopes never outlive the UI pass that opened them. Leftovers mean a scope wasn't closed (e.g. an + // exception between the API's WatchBegin and WatchEnd), restore and sync those fields before anything ticks. + [MpFinalizer(typeof(UIRoot_Play), nameof(UIRoot_Play.UIRootOnGUI))] + [MpFinalizer(typeof(UIRoot_Play), nameof(UIRoot_Play.UIRootUpdate))] + static void FlushLeakedWatchScopes() + { + if (watchedStack.Count == 0 || Multiplayer.Client == null) + return; + + Log.WarningOnce($"Multiplayer: {watchedStack.Count} field watch stack entries weren't closed, restoring them", 0x5e1d3a7); + while (watchedStack.Count > 0) + PopWatchScope(); + } + public static void UpdateSync() { - // Watch scopes never span frames. Leftovers mean a scope wasn't closed (e.g. an exception between the API's - // WatchBegin and WatchEnd), restore and sync those fields now. - if (watchedStack.Count > 0) - { - Log.WarningOnce($"Multiplayer: {watchedStack.Count} field watch stack entries weren't closed, restoring them", 0x5e1d3a7); - while (watchedStack.Count > 0) - PopWatchScope(); - } + FlushLeakedWatchScopes(); foreach (var (field, fieldBufferedChanges) in bufferedChanges) { @@ -243,6 +251,9 @@ public static void ClearAllBufferedChanges() { foreach (var entry in bufferedChanges) entry.Value.Clear(); + + // Called when multiplayer stops: entries left from the old session mustn't be restored in the next one + watchedStack.Clear(); } } From 389678cee9039b124b5822ed6f5372482c72b0b4 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:35:58 -0600 Subject: [PATCH 063/183] Harden SimLongEventContext capture and exit - Derive the seed from the current RNG state instead of taking Rand.Int. Some sim events are queued only on the issuer of a command (join points on a standalone server return early on other clients), and advancing the world RNG there moved the shared stream on one client only. The old SeedLongEvents had the same problem. - Don't capture MpSaving events: autosaves are queued from the non-simulation part of ConstantTicker on some clients only. - Restore the current context in a finally block: a context left active would keep InInterface false for the rest of the session. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/LongEvents.cs | 43 +++++++++++++++++++---------- Source/Client/Patches/Seeds.cs | 5 ++-- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/Source/Client/Patches/LongEvents.cs b/Source/Client/Patches/LongEvents.cs index 109d85684..4833cd22f 100644 --- a/Source/Client/Patches/LongEvents.cs +++ b/Source/Client/Patches/LongEvents.cs @@ -179,8 +179,14 @@ private SimLongEventContext(int seed, Faction faction) this.faction = faction; } - public static SimLongEventContext Capture() => - new(Rand.Int, Multiplayer.GameComp.multifaction ? Faction.OfPlayer : null); + // The seed is derived from the RNG state without advancing it: some events are queued only on the issuer of a + // command (e.g. join points on a standalone server), which mustn't move the shared stream + public static SimLongEventContext Capture() + { + ulong state = Rand.StateCompressed; + int seed = Gen.HashCombineInt((int)state, (int)(state >> 32)); + return new(seed, Multiplayer.GameComp.multifaction ? Faction.OfPlayer : null); + } public static void Attach(LongEventHandler.QueuedLongEvent ev, SimLongEventContext context) { @@ -208,25 +214,32 @@ public void Enter() public void Exit() { - // FactionContext is cleared when the game is reloaded - if (pushedFaction && game == Multiplayer.game && FactionContext.stack.Count > 0) + try { - FactionContext.Pop(); - - // Long events run in the interface context, where Faction.OfPlayer is the real faction. Restore that - // instead of the popped faction as the event may have changed it (FactionCreator). - if (Multiplayer.RealPlayerFaction is { } real) + // FactionContext is cleared when the game is reloaded + if (pushedFaction && game == Multiplayer.game && FactionContext.stack.Count > 0) { - FactionContext.Set(real); - SetFactionData(real); + FactionContext.Pop(); + + // Long events run in the interface context, where Faction.OfPlayer is the real faction. Restore + // that instead of the popped faction as the event may have changed it (FactionCreator). + if (Multiplayer.RealPlayerFaction is { } real) + { + FactionContext.Set(real); + SetFactionData(real); + } } } + finally + { + pushedFaction = false; - pushedFaction = false; - Rand.PopState(); + // A context left active would keep InInterface false for good + current = prev; + prev = null; - current = prev; - prev = null; + Rand.PopState(); + } } private static void SetFactionData(Faction f) diff --git a/Source/Client/Patches/Seeds.cs b/Source/Client/Patches/Seeds.cs index 264a80261..458b28281 100644 --- a/Source/Client/Patches/Seeds.cs +++ b/Source/Client/Patches/Seeds.cs @@ -97,9 +97,10 @@ static void Finalizer(bool __state) [HarmonyPatch(typeof(LongEventHandler), nameof(LongEventHandler.QueueLongEvent), typeof(Action), typeof(string), typeof(bool), typeof(Action), typeof(bool), typeof(bool), typeof(Action))] static class SeedLongEvents { - static void Prefix(ref SimLongEventContext __state) + static void Prefix(string textKey, ref SimLongEventContext __state) { - if (SimLongEventContext.ShouldCapture) + // MpSaving events (autosaves) are queued from the non-simulation part of ConstantTicker on some clients only + if (SimLongEventContext.ShouldCapture && textKey != "MpSaving") __state = SimLongEventContext.Capture(); } From 0030d365ccb2fc1eb74fd496767e522f6fef6075 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:35:58 -0600 Subject: [PATCH 064/183] Use the right faction for map gen world data and the ship launch MapGenFactionPatch's PushFaction wasn't forced, so when the context had only set Faction.OfPlayer (MpScope.PushFaction, e.g. FactionCreator) the world data stayed the context faction's. Force it. ShipCountdown.CountdownEnded now runs in the world context, which under multifaction is the spectator faction; push the ship's faction (and its map data) on top of it. Co-Authored-By: Claude Opus 5.5 --- Source/Client/ConstantTicker.cs | 4 ++++ Source/Client/Factions/FactionContextSetters.cs | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs index f8e19bf6e..fd403aace 100644 --- a/Source/Client/ConstantTicker.cs +++ b/Source/Client/ConstantTicker.cs @@ -1,6 +1,7 @@ using System; using HarmonyLib; using Multiplayer.Client.Desyncs; +using Multiplayer.Client.Factions; using Multiplayer.Common; using Multiplayer.Common.Networking.Packet; using RimWorld; @@ -144,8 +145,10 @@ private static void TickShipCountdown() // This is game logic (destroys the ship, removes pawns): run it on the world's RNG and faction // context instead of the unsynced RNG outside of any tickable var world = Multiplayer.AsyncWorldTime; + var shipMap = ShipCountdown.shipRoot?.Map; int randDepth = Rand.stateStack.Count; world.PreContext(); + shipMap.PushFaction(ShipCountdown.shipRoot?.Faction, force: true); try { ShipCountdown.CountdownEnded(); @@ -153,6 +156,7 @@ private static void TickShipCountdown() finally { AsyncTimeComp.RestoreRandStack(randDepth + 1, "ShipCountdown"); + shipMap.PopFaction(); world.PostContext(); } } diff --git a/Source/Client/Factions/FactionContextSetters.cs b/Source/Client/Factions/FactionContextSetters.cs index d39d26e25..cbf932d98 100644 --- a/Source/Client/Factions/FactionContextSetters.cs +++ b/Source/Client/Factions/FactionContextSetters.cs @@ -32,8 +32,9 @@ static void Prefix(PlanetTile tile) Log.Warning($"Couldn't set the faction context for map gen at {tile.tileId}: no player world object at the tile."); // Also switch the faction's world data (research, play settings, history...) used during generation + // Forced as the current context may have set only Faction.OfPlayer (MpScope.PushFaction) if (Multiplayer.Client != null) - FactionExtensions.PushFaction(null, factionToSet); + FactionExtensions.PushFaction(null, factionToSet, force: true); else FactionContext.Push(factionToSet); } From 9fb2dcd93885138b8a36df7488d3f76b142cd389 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:35:58 -0600 Subject: [PATCH 065/183] Add small safety checks to the tracer and command context - TraceImpl: the managed frame an LMF points to must be above the current frame, otherwise fail the walk instead of following it down the stack. - ExecuteCmd: an exception from the main tabs update no longer skips the rest of the finally block (RNG/faction restore, command state reset). Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 12 ++++++++++-- Source/Common/DeferredStackTracingImpl.cs | 4 ++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index bf73eea91..fa9fff3ad 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -251,6 +251,7 @@ public static void RestoreRandStack(int depth, object context) int count = Rand.stateStack.Count; if (count == depth) return; + // Too shallow (an extra pop) can't be repaired, only reported Log.ErrorOnce($"Multiplayer: unbalanced Rand push/pop in {context}: stack depth {count}, expected {depth}", 0x5A3D17 ^ depth); while (Rand.stateStack.Count > depth) @@ -360,8 +361,15 @@ public void ExecuteCmd(ScheduledCommand cmd) prevSelected = null; // Local UI, depends on the local selection - using (LocalOnlyScope.Enter()) - Find.MainButtonsRoot.tabs.Notify_SelectedObjectDespawned(); + try + { + using (LocalOnlyScope.Enter()) + Find.MainButtonsRoot.tabs.Notify_SelectedObjectDespawned(); + } + catch (Exception e) + { + Log.Error($"Exception updating main tabs after a command: {e}"); + } RestoreRandStack(randDepth + 1, this); map.PopFaction(); diff --git a/Source/Common/DeferredStackTracingImpl.cs b/Source/Common/DeferredStackTracingImpl.cs index 0df72205d..25949fc54 100644 --- a/Source/Common/DeferredStackTracingImpl.cs +++ b/Source/Common/DeferredStackTracingImpl.cs @@ -162,6 +162,10 @@ public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames if (lmfRbp == 0 || lmfRsp == 0) break; + // The managed frame which called into native code is above the native frames + if (lmfRsp - 16 <= stck) + return Fail(ret, "Bad LMF rsp"); + rbp = lmfRbp; stck = lmfRsp - 16; From 2da45bba0613f4a8bf59ba8ffd8d53546477e0fd Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:40:31 -0600 Subject: [PATCH 066/183] Restore the exact faction data on forced pops, skip removed maps A forced pop re-applied the popped Faction.OfPlayer to the world and every map, which isn't necessarily the data they held before the push: plain FactionContext.Push only changes Faction.OfPlayer and world commands don't apply their faction to maps. E.g. PlaceGravship inside GravshipArriveNewMapFactionPatch's plain push left the world data (storyteller, history, research) and all maps on the gravship faction for the rest of the world tick. Forced pushes now record which faction's data the world and each map held and their pops restore exactly that (maps without a known previous faction, e.g. generated in between, get the popped faction). The world and map comps report their applied faction from the live managers. Comps of removed (disposed) maps, which are never removed from mapComps, are skipped. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Comp/Map/MultiplayerMapComp.cs | 18 ++++- .../Client/Comp/World/MultiplayerWorldComp.cs | 18 ++++- Source/Client/Factions/FactionContext.cs | 25 ++++--- Source/Client/Factions/FactionExtensions.cs | 73 ++++++++++++++++--- 4 files changed, 109 insertions(+), 25 deletions(-) diff --git a/Source/Client/Comp/Map/MultiplayerMapComp.cs b/Source/Client/Comp/Map/MultiplayerMapComp.cs index 8635b5fa2..0c5c3e746 100644 --- a/Source/Client/Comp/Map/MultiplayerMapComp.cs +++ b/Source/Client/Comp/Map/MultiplayerMapComp.cs @@ -132,10 +132,12 @@ public void DoTick() } } - public void SetFaction(Faction faction) + public void SetFaction(Faction faction) => SetFaction(faction.loadID); + + public bool SetFaction(int factionId) { - if (!factionData.TryGetValue(faction.loadID, out FactionMapData data)) - return; + if (!factionData.TryGetValue(factionId, out FactionMapData data)) + return false; map.designationManager = data.designationManager; map.areaManager = data.areaManager; @@ -147,6 +149,16 @@ public void SetFaction(Faction faction) map.resourceCounter = data.resourceCounter; map.listerFilthInHomeArea = data.listerFilthInHomeArea; map.listerMergeables = data.listerMergeables; + return true; + } + + // The faction whose managers are currently applied to the map, -1 if unknown + public int GetAppliedFactionId() + { + foreach (var (id, data) in factionData) + if (data.zoneManager == map.zoneManager) + return id; + return -1; } public CustomFactionMapData GetCurrentCustomFactionData() diff --git a/Source/Client/Comp/World/MultiplayerWorldComp.cs b/Source/Client/Comp/World/MultiplayerWorldComp.cs index f6a7190fb..916f59929 100644 --- a/Source/Client/Comp/World/MultiplayerWorldComp.cs +++ b/Source/Client/Comp/World/MultiplayerWorldComp.cs @@ -188,10 +188,12 @@ public void RemoveTradeSession(MpTradeSession session) sessionManager.RemoveSession(session); } - public void SetFaction(Faction faction) + public void SetFaction(Faction faction) => SetFaction(faction.loadID); + + public bool SetFaction(int factionId) { - if (!factionData.TryGetValue(faction.loadID, out FactionWorldData data)) - return; + if (!factionData.TryGetValue(factionId, out FactionWorldData data)) + return false; Game game = Current.Game; game.researchManager = data.researchManager; @@ -203,6 +205,16 @@ public void SetFaction(Faction faction) game.history = data.history; game.storyteller = data.storyteller; game.storyWatcher = data.storyWatcher; + return true; + } + + // The faction whose data is currently applied to the game, -1 if unknown + public int GetAppliedFactionId() + { + foreach (var (id, data) in factionData) + if (data.researchManager == Find.ResearchManager) + return id; + return -1; } public void DirtyColonyTradeForMap(Map map) diff --git a/Source/Client/Factions/FactionContext.cs b/Source/Client/Factions/FactionContext.cs index d5df81f31..d87e7819d 100644 --- a/Source/Client/Factions/FactionContext.cs +++ b/Source/Client/Factions/FactionContext.cs @@ -8,32 +8,37 @@ public static class FactionContext { public static Stack stack = new(); - // Parallel to stack: whether the entry's push applied its faction to every map (see FactionExtensions.PushFaction) - private static Stack allMapsStack = new(); + // Parallel to stack: optional state of the entry's push, restored by its pop (see FactionExtensions.PushFaction) + private static Stack stateStack = new(); - public static Faction Push(Faction newFaction, bool force = false) => Push(newFaction, force, false); - - public static Faction Push(Faction newFaction, bool force, bool allMaps) + public static Faction Push(Faction newFaction, bool force = false) { if (newFaction == null || !force && Find.FactionManager.ofPlayer == newFaction || !newFaction.def.isPlayer) { stack.Push(null); - allMapsStack.Push(false); + stateStack.Push(null); return null; } stack.Push(Find.FactionManager.OfPlayer); - allMapsStack.Push(allMaps); + stateStack.Push(null); Set(newFaction); return newFaction; } + // Attaches state to the entry pushed last + public static void SetTopState(object state) + { + stateStack.Pop(); + stateStack.Push(state); + } + public static Faction Pop() => Pop(out _); - public static Faction Pop(out bool allMaps) + public static Faction Pop(out object state) { - allMaps = allMapsStack.Count > 0 && allMapsStack.Pop(); + state = stateStack.Count > 0 ? stateStack.Pop() : null; Faction f = stack.Pop(); if (f != null) Set(f); @@ -48,7 +53,7 @@ public static void Set(Faction newFaction) public static void Clear() { stack.Clear(); - allMapsStack.Clear(); + stateStack.Clear(); } } diff --git a/Source/Client/Factions/FactionExtensions.cs b/Source/Client/Factions/FactionExtensions.cs index 692085adf..31615c397 100644 --- a/Source/Client/Factions/FactionExtensions.cs +++ b/Source/Client/Factions/FactionExtensions.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using RimWorld; using RimWorld.Planet; @@ -15,12 +16,15 @@ public static class FactionExtensions // Forced pushes set up simulation contexts (map/world ticks and commands). Outside of them every map holds the // data of the local client's own faction, so applying the context's faction to all maps makes sure simulation // code touching another map (portals, pocket maps, gravships, ...) never sees client-specific data. - // The matching pop re-applies the previous faction to the world and every map. + // The matching pop restores exactly the factions the world and each map had before the push. public static void PushFaction(this Map map, Faction f, bool force = false) { - var faction = FactionContext.Push(f, force, allMaps: force); + var faction = FactionContext.Push(f, force); if (faction == null) return; + if (force) + FactionContext.SetTopState(AppliedFactions.Capture()); + Multiplayer.WorldComp?.SetFaction(faction); if (force) SetFactionOnAllMaps(faction); @@ -38,14 +42,17 @@ public static void PushFaction(this Map map, int factionId) public static Faction PopFaction(this Map map) { - Faction faction = FactionContext.Pop(out bool allMaps); + Faction faction = FactionContext.Pop(out var state); if (faction == null) return null; + if (state is AppliedFactions applied) + { + applied.Restore(faction); + return faction; + } + Multiplayer.WorldComp?.SetFaction(faction); - if (allMaps) - SetFactionOnAllMaps(faction); - else - map?.MpComp().SetFaction(faction); + map?.MpComp().SetFaction(faction); return faction; } @@ -66,12 +73,60 @@ public static void PopToLocalFaction() private static void SetFactionOnAllMaps(Faction faction) { - // Includes comps of removed maps (they're never removed from the list), which is harmless var comps = Multiplayer.game?.mapComps; if (comps == null) return; for (int i = 0; i < comps.Count; i++) - comps[i].SetFaction(faction); + if (IsLive(comps[i])) + comps[i].SetFaction(faction); + } + + // Comps of removed maps are never removed from the list + private static bool IsLive(MultiplayerMapComp comp) => comp.map is { Disposed: false }; + + // The factions whose data the world and the maps held before a forced push + private class AppliedFactions + { + private int worldFactionId; + private List comps; + private int[] mapFactionIds; + + public static AppliedFactions Capture() + { + var applied = new AppliedFactions + { + worldFactionId = Multiplayer.WorldComp?.GetAppliedFactionId() ?? -1, + comps = Multiplayer.game?.mapComps + }; + + var count = applied.comps?.Count ?? 0; + applied.mapFactionIds = new int[count]; + for (int i = 0; i < count; i++) + applied.mapFactionIds[i] = IsLive(applied.comps[i]) ? applied.comps[i].GetAppliedFactionId() : -1; + + return applied; + } + + // Maps without a known previous faction (e.g. generated in between) get the popped faction + public void Restore(Faction popped) + { + var worldComp = Multiplayer.WorldComp; + if (worldComp != null && (worldFactionId == -1 || !worldComp.SetFaction(worldFactionId))) + worldComp.SetFaction(popped); + + var currentComps = Multiplayer.game?.mapComps; + if (currentComps == null) return; + + for (int i = 0; i < currentComps.Count; i++) + { + var comp = currentComps[i]; + if (!IsLive(comp)) continue; + + var id = currentComps == comps && i < mapFactionIds.Length ? mapFactionIds[i] : -1; + if (id == -1 || !comp.SetFaction(id)) + comp.SetFaction(popped); + } + } } public static bool TryGetPlayerFaction(this Quest quest, out Faction faction) From 4649662d3d5217793d7fd569c19e23cf7a2e3caf Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:41:50 -0600 Subject: [PATCH 067/183] Save the trade deal dirty state and ignore dirtying while loading Since the deal is recached by the simulation, its dirty flags decide when every client recaches. They weren't saved and loading set them again (respawning things fires the ListerThings/Reachability/MapPawns dirtying patches), so a client that reloaded (standalone join points, rejoining) recached at a different time than clients continuing from memory. The flags and the dirty things are now saved, dirtying is ignored while loading/reloading, destroyed dirty things (which can't be saved) are ignored, and the recache sets the trade session statics directly instead of going through the UI's transferables marker, popping the faction push in a finally. Co-Authored-By: Claude Opus 5.5 --- .../Client/Comp/World/MultiplayerWorldComp.cs | 9 ++- Source/Client/Persistent/Trading.cs | 61 +++++++++++++++++-- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/Source/Client/Comp/World/MultiplayerWorldComp.cs b/Source/Client/Comp/World/MultiplayerWorldComp.cs index 916f59929..86ffaaf13 100644 --- a/Source/Client/Comp/World/MultiplayerWorldComp.cs +++ b/Source/Client/Comp/World/MultiplayerWorldComp.cs @@ -217,9 +217,12 @@ public int GetAppliedFactionId() return -1; } + // Loading respawns things (and so triggers these) only on the loading client, while the trade dirty state is saved + private static bool IgnoreTradeDirtying => Multiplayer.reloading || Current.ProgramState != ProgramState.Playing; + public void DirtyColonyTradeForMap(Map map) { - if (map == null) return; + if (map == null || IgnoreTradeDirtying) return; foreach (MpTradeSession session in trading) if (session.playerNegotiator.Map == map) session.deal.recacheColony = true; @@ -227,7 +230,7 @@ public void DirtyColonyTradeForMap(Map map) public void DirtyTraderTradeForTrader(ITrader trader) { - if (trader == null) return; + if (trader == null || IgnoreTradeDirtying) return; foreach (MpTradeSession session in trading) if (session.trader == trader) session.deal.recacheTrader = true; @@ -235,7 +238,7 @@ public void DirtyTraderTradeForTrader(ITrader trader) public void DirtyTradeForSpawnedThing(Thing t) { - if (t is not { Spawned: true }) return; + if (t is not { Spawned: true } || IgnoreTradeDirtying) return; foreach (MpTradeSession session in trading) if (session.playerNegotiator.Map == t.Map) session.deal.recacheThings.Add(t); diff --git a/Source/Client/Persistent/Trading.cs b/Source/Client/Persistent/Trading.cs index 283ce797a..e8bfb0892 100644 --- a/Source/Client/Persistent/Trading.cs +++ b/Source/Client/Persistent/Trading.cs @@ -1,6 +1,7 @@ using HarmonyLib; using Multiplayer.API; using Multiplayer.Client.Factions; +using Multiplayer.Client.Util; using RimWorld; using RimWorld.Planet; using System.Collections.Generic; @@ -219,23 +220,49 @@ public void RecacheDealIfNeeded() { if (deal is not { ShouldRecache: true }) return; - var prevSession = current; var map = playerNegotiator?.Map; // Use the negotiator's data, not whatever the current context or the local client has applied map.PushFaction(NegotiatorFaction, force: true); try { - SetTradeSession(this); - deal.Recache(); + using (UseTradeSession()) + deal.Recache(); } finally { - SetTradeSession(prevSession); map.PopFaction(); } } + // Like SetTradeSession but restores the previous state and leaves the UI's transferables marker alone + private MpScope UseTradeSession() + { + var prevCurrent = current; + var prevTrader = TradeSession.trader; + var prevNegotiator = TradeSession.playerNegotiator; + var prevGiftMode = TradeSession.giftMode; + var prevDeal = TradeSession.deal; + + return MpScope.Create( + () => + { + current = this; + TradeSession.trader = trader; + TradeSession.playerNegotiator = playerNegotiator; + TradeSession.giftMode = giftMode; + TradeSession.deal = deal; + }, + () => + { + current = prevCurrent; + TradeSession.trader = prevTrader; + TradeSession.playerNegotiator = prevNegotiator; + TradeSession.giftMode = prevGiftMode; + TradeSession.deal = prevDeal; + }); + } + public override void ExposeData() { base.ExposeData(); @@ -312,10 +339,20 @@ public class MpTradeDeal : TradeDeal, IExposable public UIShouldReset uiShouldReset; + // The dirty state decides when every client recaches the deal, so it's saved and set only by the simulation public HashSet recacheThings = new HashSet(); public bool recacheColony; public bool recacheTrader; - public bool ShouldRecache => recacheColony || recacheTrader || recacheThings.Count > 0; + public bool ShouldRecache => recacheColony || recacheTrader || AnyLiveRecacheThing(); + + // Destroyed things can't be saved, ignore them so reloaded clients agree with the others + private bool AnyLiveRecacheThing() + { + foreach (var t in recacheThings) + if (!t.Destroyed) + return true; + return false; + } public bool caravanDirty; public Thing permanentSilver; @@ -327,6 +364,8 @@ public MpTradeDeal(MpTradeSession session) public void Recache() { + recacheThings.RemoveWhere(t => t.Destroyed); + if (recacheColony) CheckAddRemoveColony(); @@ -452,6 +491,18 @@ public void ExposeData() { Scribe_Deep.Look(ref permanentSilver, "permanentSilver"); Scribe_Collections.Look(ref tradeables, "tradeables", LookMode.Deep); + + Scribe_Values.Look(ref recacheColony, "recacheColony"); + Scribe_Values.Look(ref recacheTrader, "recacheTrader"); + + List things = null; + if (Scribe.mode == LoadSaveMode.Saving) + things = recacheThings.Where(t => !t.Destroyed).ToList(); + + Scribe_Collections.Look(ref things, "recacheThings", LookMode.Reference); + + if (Scribe.mode == LoadSaveMode.ResolvingCrossRefs) + recacheThings = things != null ? new HashSet(things.Where(t => t != null)) : new HashSet(); } } From ad4bc2f9b90c478d5e22af92f75b84fd9f6902eb Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:42:44 -0600 Subject: [PATCH 068/183] Open other factions' expiring letters without their UI Now that every client keeps the whole letter stack, LetterStackTick force-opens every expiring letter on every client. Letters overriding OpenLetter without calling ChoiceLetter.OpenLetter (e.g. NewQuestLetter, which switches to the quests tab and removes itself) bypassed the visibility check. The tick's OpenLetter call now still opens the letter everywhere (opening can change the simulation, like removing the letter) but suppresses windows and main tab switches for letters of other factions. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Letters.cs | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/Source/Client/Patches/Letters.cs b/Source/Client/Patches/Letters.cs index 1631562c5..75d67f530 100644 --- a/Source/Client/Patches/Letters.cs +++ b/Source/Client/Patches/Letters.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Reflection.Emit; using HarmonyLib; using RimWorld; using UnityEngine; @@ -132,6 +133,63 @@ static void Postfix(LetterStack __instance, Letter let) } } + // LetterStackTick force-opens letters about to expire on every client (the stack is the same everywhere). Letters of + // other factions are still opened, as opening may change the simulation (e.g. NewQuestLetter removes itself from + // the stack), but without their UI (windows, main tab switch). + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.LetterStackTick))] + static class OpenExpiringLettersForTheirFaction + { + public static bool suppressUI; + + static IEnumerable Transpiler(IEnumerable insts) + { + var openLetter = AccessTools.Method(typeof(Letter), nameof(Letter.OpenLetter)); + var replacement = AccessTools.Method(typeof(OpenExpiringLettersForTheirFaction), nameof(OpenLetter)); + + foreach (var inst in insts) + { + if (inst.Calls(openLetter)) + { + inst.opcode = OpCodes.Call; + inst.operand = replacement; + } + + yield return inst; + } + } + + static void OpenLetter(Letter letter) + { + if (MultifactionLetters.IsVisibleLocally(letter)) + { + letter.OpenLetter(); + return; + } + + suppressUI = true; + try + { + letter.OpenLetter(); + } + finally + { + suppressUI = false; + } + } + } + + [HarmonyPatch(typeof(WindowStack), nameof(WindowStack.Add))] + static class SuppressExpiringLetterWindows + { + static bool Prefix() => !OpenExpiringLettersForTheirFaction.suppressUI; + } + + [HarmonyPatch(typeof(MainTabsRoot), nameof(MainTabsRoot.SetCurrentTab))] + static class SuppressExpiringLetterTabSwitch + { + static bool Prefix() => !OpenExpiringLettersForTheirFaction.suppressUI; + } + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.LetterStackUpdate))] static class CloseLettersDuringSimulating { From a1e00595c8a59dba4a77f0506f5d871a74548178 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:43:33 -0600 Subject: [PATCH 069/183] Use MapHeld for growth moment children, end portal sessions with the portal - Growth moment sessions treat a child held on the map (growth vat, carried, in a shuttle) as on the map, and GetSessionFor skips sessions the child already left, so a new one can be created on the child's current map before the old map ticks again. - Map portal sessions now tick and remove themselves once their portal is gone or on another map (e.g. moved with a gravship) instead of lingering forever; such a portal no longer pauses the session's map. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Persistent/GrowthMomentSession.cs | 16 +++++++++------- Source/Client/Persistent/MapPortalSession.cs | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/Source/Client/Persistent/GrowthMomentSession.cs b/Source/Client/Persistent/GrowthMomentSession.cs index 377456a58..ef6b2b0a9 100644 --- a/Source/Client/Persistent/GrowthMomentSession.cs +++ b/Source/Client/Persistent/GrowthMomentSession.cs @@ -9,13 +9,14 @@ namespace Multiplayer.Client.Persistent; public class GrowthMomentSession(Map map) : ExposableSession(null), ITickingSession { - // The child may have left the session's map (caravan, transport pod, death), so look on every map + // The child may have moved to another map, so look on every map. Sessions the child left (removed by their map's + // next tick) are skipped, so a new session can be created where the child is now. public static GrowthMomentSession GetSessionFor(Pawn pawn) { if (pawn == null) return null; foreach (var m in Find.Maps) - if (m.MpComp()?.sessionManager.GetFirstOfType(sess => sess.Pawn == pawn) is { } session) + if (m.MpComp()?.sessionManager.GetFirstOfType(sess => sess.Pawn == pawn && sess.IsActive) is { } session) return session; return null; @@ -35,7 +36,7 @@ public static GrowthMomentSession GetSessionFor(Pawn pawn) public List passionIndexes = []; public bool uiDirty; // if true, received or sent an update - public static GrowthMomentSession Create(ChoiceLetter_GrowthMoment letter) => new(letter.pawn.Map) + public static GrowthMomentSession Create(ChoiceLetter_GrowthMoment letter) => new(letter.pawn.MapHeld) { letter = letter }; @@ -48,8 +49,9 @@ public static GrowthMomentSession GetSessionFor(Pawn pawn) // Also checked right after loading, when pawns aren't spawned yet, so it mustn't depend on the child's map public override bool IsSessionValid => map != null && Pawn != null && !letter.ArchiveView; - // The child leaving the map (or dying) ends the session, the letter's timeout handles the rest - private bool IsActive => IsSessionValid && Pawn.Map == map; + // The child leaving the map (or dying) ends the session, the letter's timeout handles the rest. + // MapHeld: the child can be on the map without being spawned (growth vat, carried, in a shuttle). + private bool IsActive => IsSessionValid && !Pawn.Dead && Pawn.MapHeld == map; public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { @@ -106,8 +108,8 @@ public static GrowthMomentSession TryAddSession(ChoiceLetter_GrowthMoment letter if (letter.ArchiveView) return null; var pawn = letter.pawn; // Only children on a map can have a session (it pauses and is ticked by that map) - if (pawn?.Map == null) return null; - var sessionManager = pawn.Map.MpComp().sessionManager; + if (pawn?.MapHeld == null || pawn.Dead) return null; + var sessionManager = pawn.MapHeld.MpComp().sessionManager; var sess = sessionManager.GetFirstOfType(sess => sess.Pawn == pawn); if (sess == null) { diff --git a/Source/Client/Persistent/MapPortalSession.cs b/Source/Client/Persistent/MapPortalSession.cs index 1f14693bf..1adccaa6e 100644 --- a/Source/Client/Persistent/MapPortalSession.cs +++ b/Source/Client/Persistent/MapPortalSession.cs @@ -6,7 +6,7 @@ namespace Multiplayer.Client.Persistent; -public class MapPortalSession : ExposableSession, ISessionWithTransferables, ISessionWithCreationRestrictions +public class MapPortalSession : ExposableSession, ISessionWithTransferables, ISessionWithCreationRestrictions, ITickingSession { public MapPortal portal; public List transferables; @@ -44,11 +44,20 @@ private void AddItems() transferables = dialog.transferables; } - public override bool IsCurrentlyPausing(Map map) => this.map == map && portal is { Spawned: true }; + // Destroyed or moved (e.g. with a gravship) portals end the session, see Tick + private bool PortalOnMap => portal is { Spawned: true } && portal.Map == map; + + public override bool IsCurrentlyPausing(Map map) => this.map == map && PortalOnMap; + + public void Tick() + { + if (!PortalOnMap) + map.MpComp().sessionManager.RemoveSession(this); + } public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { - if (entry.map != Map || portal is not { Spawned: true }) + if (entry.map != Map || !PortalOnMap) return null; return new FloatMenuOption("MpMapPortalSession".Translate(portal?.Label), () => From 513af76af4fec2f7744478b7c4c34e2b918f5f8b Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:43:58 -0600 Subject: [PATCH 070/183] Generate a new faction's ideoligion for its own faction def GenerateIdeo used Faction.OfPlayer's def and the game scenario's player faction def instead of the new faction's (chosen scenario) def. Both are deterministic now, but wrong for e.g. tribal starts. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/FactionCreator.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index 46b0fbb36..43694af5b 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -232,7 +232,7 @@ private static Faction NewFactionWithIdeo(string name, Color color, FactionDef d } else { - var newIdeo = GenerateIdeo(chooseIdeoInfo); + var newIdeo = GenerateIdeo(chooseIdeoInfo, def); faction.ideos.SetPrimary(newIdeo); Find.IdeoManager.Add(newIdeo); } @@ -260,16 +260,17 @@ private static Faction NewFactionWithIdeo(string name, Color color, FactionDef d return faction; } - private static Ideo GenerateIdeo(IdeologyData chooseIdeoInfo) + // factionDef: the new faction's (from the chosen scenario), not Faction.OfPlayer's or the game scenario's + private static Ideo GenerateIdeo(IdeologyData chooseIdeoInfo, FactionDef factionDef) { List list = chooseIdeoInfo.SelectedIdeo.memes.ToList(); if (chooseIdeoInfo.SelectedStructure != null) list.Add(chooseIdeoInfo.SelectedStructure); - else if (DefDatabase.AllDefsListForReading.Where(m => m.category == MemeCategory.Structure && IdeoUtility.IsMemeAllowedFor(m, Find.Scenario.playerFaction.factionDef)).TryRandomElement(out var result)) + else if (DefDatabase.AllDefsListForReading.Where(m => m.category == MemeCategory.Structure && IdeoUtility.IsMemeAllowedFor(m, factionDef)).TryRandomElement(out var result)) list.Add(result); - Ideo ideo = IdeoGenerator.GenerateIdeo(new IdeoGenerationParms(Find.FactionManager.OfPlayer.def, forceNoExpansionIdeo: false, null, null, list, chooseIdeoInfo.SelectedIdeo.classicPlus, forceNoWeaponPreference: true)); + Ideo ideo = IdeoGenerator.GenerateIdeo(new IdeoGenerationParms(factionDef, forceNoExpansionIdeo: false, null, null, list, chooseIdeoInfo.SelectedIdeo.classicPlus, forceNoWeaponPreference: true)); new Page_ChooseIdeoPreset { selectedStyles = chooseIdeoInfo.SelectedStyles }.ApplySelectedStylesToIdeo(ideo); return ideo; From d6a13aa15cffefccec5e72ed58cd64cbd2dfbae8 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:50:37 -0600 Subject: [PATCH 071/183] Keep the faction creation event's faction data in its sim context FactionCreator's long event is queued by the CreateFaction command, so SimLongEventContext already runs it (and the actions map generation defers with ExecuteWhenFinished) with the command's faction applied to the world and every map, and re-applies the real player faction on exit. The event's own PushFactionOutsideContext scope ended with PopToLocalFaction, which applied each client's own faction everywhere while still inside that context: the deferred map generation actions then ran with client-specific faction data. Use the regular forced MpScope.PushFaction, whose pop restores the context's faction data, and leave restoring the real faction to the context's exit (the issuer's is the new faction, set inside the issuer-only block). PushFactionOutsideContext and PopToLocalFaction have no other users and are removed. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/FactionCreator.cs | 6 ++++-- Source/Client/Factions/FactionExtensions.cs | 14 -------------- Source/Client/Util/MpScope.cs | 7 ------- 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index 5fa3296f7..b4529aee5 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -46,8 +46,10 @@ public static void CreateFaction(int playerId, FactionCreationData creationData) LongEventHandler.QueueLongEvent(() => { - // The long event runs after the command, with each client's own faction data applied - using var _ = MpScope.PushFactionOutsideContext(Multiplayer.WorldComp.spectatorFaction); + // Runs in the command's simulation context (SimLongEventContext), which also covers the actions map + // generation defers with ExecuteWhenFinished. The pop restores that context's faction data, not the + // local player's (the context's exit re-applies the real player faction, the issuer's new one). + using var _ = MpScope.PushFaction(Multiplayer.WorldComp.spectatorFaction); var scenario = creationData.scenarioDef?.scenario ?? Current.Game.Scenario; Map newMap = null; diff --git a/Source/Client/Factions/FactionExtensions.cs b/Source/Client/Factions/FactionExtensions.cs index 31615c397..2206a1fe4 100644 --- a/Source/Client/Factions/FactionExtensions.cs +++ b/Source/Client/Factions/FactionExtensions.cs @@ -57,20 +57,6 @@ public static Faction PopFaction(this Map map) return faction; } - // Pops a push made outside of ticks and commands and re-applies the local player's faction everywhere (the resting - // state there) instead of the faction saved by the push, as the code in between may have changed the local faction - public static void PopToLocalFaction() - { - FactionContext.Pop(); - - var faction = Multiplayer.RealPlayerFaction; - if (faction == null) return; - - FactionContext.Set(faction); - Multiplayer.WorldComp?.SetFaction(faction); - SetFactionOnAllMaps(faction); - } - private static void SetFactionOnAllMaps(Faction faction) { var comps = Multiplayer.game?.mapComps; diff --git a/Source/Client/Util/MpScope.cs b/Source/Client/Util/MpScope.cs index a38b8d77b..70d39c85b 100644 --- a/Source/Client/Util/MpScope.cs +++ b/Source/Client/Util/MpScope.cs @@ -24,11 +24,4 @@ public static MpScope PushFaction(Faction f) { return Create(() => FactionExtensions.PushFaction(null, f, force: true), () => FactionExtensions.PopFaction()); } - - // For simulation code running outside of ticks and commands (e.g. long events queued by commands), where the - // current faction data is the local player's. Ends by re-applying the local player's faction everywhere. - public static MpScope PushFactionOutsideContext(Faction f) - { - return Create(() => FactionExtensions.PushFaction(null, f, force: true), FactionExtensions.PopToLocalFaction); - } } From 8b2268a3bc168fb55f20ec44b2583848215e85b8 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:55:01 -0600 Subject: [PATCH 072/183] Force the gravship faction push in the synced cutscene ends TakeoffEnded now runs in a world command whose faction is that of the player whose end command executed first. The world context applies the spectator's data to every map and the command only sets Faction.OfPlayer and the world data, so when that player owns the gravship the regular push was a no-op and TakeoffEnded ran with the spectator's map data; with another player's command it got the gravship faction's. All clients agree, but the faction data depended on who sent the command first. The same holds for LandingEnded in a command of the landing map. Force both pushes, like the other gravship patches do, so the world and every map always hold the gravship faction's data. The forced pop restores the world and all live maps, so TakeoffEnded's finalizer no longer needs to know whether the map left behind was removed. Co-Authored-By: Claude Opus 5.5 --- .../Patches/GravshipTravelSessionPatches.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 040044ce3..73b88a820 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -339,9 +339,9 @@ static void Prefix(WorldComponent_GravshipController __instance) [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.TakeoffEnded))] public static class PatchGravshipTakeoffEnded { - static bool Prefix(WorldComponent_GravshipController __instance, out (bool factionPushed, Map map) __state) + static bool Prefix(WorldComponent_GravshipController __instance, out bool __state) { - __state = default; + __state = false; if (Multiplayer.Client == null) return true; if (Multiplayer.InInterface) @@ -353,23 +353,22 @@ static bool Prefix(WorldComponent_GravshipController __instance, out (bool facti GravshipCutsceneSync.SyncedEndExecuting(); GravshipTravelUtils.CloseSessionAt(__instance.takeoffTile); + // Forced like the other gravship pushes (see RemoveGravshipFromMap_Patch): in this world command + // Faction.OfPlayer may already be the gravship's faction (its player's command came first) while the maps + // hold the spectator's data, which would make a regular push a no-op if (Multiplayer.GameComp.multifaction && __instance.gravship?.Faction is { } faction) { - __instance.map.PushFaction(faction); - __state = (true, __instance.map); + FactionExtensions.PushFaction(null, faction, force: true); + __state = true; } return true; } - static void Finalizer((bool factionPushed, Map map) __state) + static void Finalizer(bool __state) { - if (!__state.factionPushed) return; - - // TakeoffEnded can remove the map that was left behind - if (__state.map != null && Find.Maps.Contains(__state.map)) - __state.map.PopFaction(); - else + // The forced pop restores the world and every live map, TakeoffEnded can remove the map left behind + if (__state) FactionExtensions.PopFaction(); } } @@ -406,9 +405,10 @@ static bool Prefix(WorldComponent_GravshipController __instance, out (Map factio // have our own way of changing the map speed. map.AsyncTime().DesiredTimeSpeed = TimeSpeed.Normal; + // Forced: the command's faction (whichever player's end command came first) may already be the gravship's if (Multiplayer.GameComp.multifaction && __instance.gravship.Faction is { } faction) { - map.PushFaction(faction); + map.PushFaction(faction, force: true); __state.factionMap = map; } From 83f88539a907e5ff6e171ef31b244fa41f96c502 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:05:09 -0600 Subject: [PATCH 073/183] Isolate the remaining client-subset session windows LocalOnlyScope was added for session windows opened inside commands on the issuer only, but the session rework opens two windows inside the simulation on a subset of clients that it didn't cover: - the growth moment window, opened from the session's map tick only on the clients of the child's faction (it used to open on every client), - the caravan splitting window, opened in CreateSplittingSession only on the clients of the caravan's faction. The map portal window opened for the issuer of CreateSession had been missed as well. Opening these could advance the map/world RNG or take shared unique ids on those clients only; run them in LocalOnlyScope like the other session windows. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Persistent/CaravanSplittingPatches.cs | 6 ++++-- Source/Client/Persistent/GrowthMomentSession.cs | 5 ++++- Source/Client/Persistent/MapPortalSession.cs | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Source/Client/Persistent/CaravanSplittingPatches.cs b/Source/Client/Persistent/CaravanSplittingPatches.cs index bd0660df4..b1a26954d 100644 --- a/Source/Client/Persistent/CaravanSplittingPatches.cs +++ b/Source/Client/Persistent/CaravanSplittingPatches.cs @@ -66,9 +66,11 @@ public static void CreateSplittingSession(Caravan caravan) { var session = CaravanSplittingSession.Of(caravan); - // Only show the window to the caravan's players (everyone in a single faction game) + // Only show the window to the caravan's players (everyone in a single faction game). It's opened in the + // command on those clients only, so keep it off the shared state. if (Multiplayer.WorldComp.sessionManager.AddSession(session) && caravan.Faction == Multiplayer.RealPlayerFaction) - session.OpenWindow(); + using (LocalOnlyScope.Enter()) + session.OpenWindow(); } } } diff --git a/Source/Client/Persistent/GrowthMomentSession.cs b/Source/Client/Persistent/GrowthMomentSession.cs index a7fa19052..9d34eb115 100644 --- a/Source/Client/Persistent/GrowthMomentSession.cs +++ b/Source/Client/Persistent/GrowthMomentSession.cs @@ -83,7 +83,10 @@ public void OpenWindow() public void Tick() { if (!IsActive) map.MpComp().sessionManager.RemoveSession(this); - else if (AtTimeout && IsLocalPlayersChild) OpenWindow(); + else if (AtTimeout && IsLocalPlayersChild) + // Runs in the map tick on the child's players' clients only + using (LocalOnlyScope.Enter()) + OpenWindow(); } // UI only: reopen the window if it was closed while the map waits for the choice diff --git a/Source/Client/Persistent/MapPortalSession.cs b/Source/Client/Persistent/MapPortalSession.cs index 1adccaa6e..70ea9f485 100644 --- a/Source/Client/Persistent/MapPortalSession.cs +++ b/Source/Client/Persistent/MapPortalSession.cs @@ -87,7 +87,8 @@ private static void CreateSession(MapPortal portal) if (session == null) Log.Error($"Failed creating session of type {nameof(MapPortalSession)}."); else if (MP.IsExecutingSyncCommandIssuedBySelf) - session.OpenWindow(); + using (LocalOnlyScope.Enter()) + session.OpenWindow(); } [SyncMethod] From 66ee8bcc956e0382a2d4e43ee43ec3bff2520c1e Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:06:04 -0600 Subject: [PATCH 074/183] Send desync traces fragmented The trace request now goes to the sync reference, which on a standalone server is a remote client. Its reply (compressed traces plus jitted methods, allowed up to 32 MiB by the packet since the bounds checks) was still sent as a single packet, limited to 64 KiB. Above that, Send threw inside the reference's packet handler, which stops multiplayer on that client, and the server's forward threw inside the sender's handler, which disconnects the sender. One player's desync could so kick the reference. Both packets already allow fragmentation; send them fragmented. Co-Authored-By: Claude Opus 5.5 --- .../Networking/State/ClientPlayingState.cs | 6 +++-- .../Networking/State/ServerPlayingState.cs | 5 +++- Source/Tests/SyncReferenceTest.cs | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs index c32c9ae2d..76b19627f 100644 --- a/Source/Client/Networking/State/ClientPlayingState.cs +++ b/Source/Client/Networking/State/ClientPlayingState.cs @@ -184,12 +184,14 @@ public void HandleTraces(ServerTracesPacket packet) var info = Multiplayer.game.sync.knownClientOpinions.FirstOrDefault(b => b.startTick == packet.tick); var response = info?.GetFormattedStackTracesForRange(packet.diffAt) ?? "Traces not available"; - connection.Send(new ClientTracesPacket + // Fragmented: the traces and jitted methods can exceed the single packet limit, which would throw + // in this handler and disconnect this client (on standalone, the sync reference) + connection.SendFragmented(new ClientTracesPacket { playerId = packet.playerId, rawTraces = GZipStream.CompressString(response), rawJittedMethods = GZipStream.CompressString(JittedMethods.GetJittedMethodsString()) - }); + }.Serialize()); } else if (packet.mode == ServerTracesPacket.Mode.Transfer) { diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index 609c00763..a3459fb6e 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -44,7 +44,10 @@ public void HandleTraces(ClientTracesPacket packet) { // Requested in PlayerManager.OnDesync if (!Player.IsHost && Player != Server.playerManager.SyncReference) return; - Server.GetPlayer(packet.playerId)?.SendPacket(ServerTracesPacket.Transfer(packet.rawTraces, packet.rawJittedMethods)); + // The traces are up to ClientTracesPacket.MaxLength; a single packet over the limit would throw here and + // disconnect the sender + Server.GetPlayer(packet.playerId)?.conn.SendFragmented( + ServerTracesPacket.Transfer(packet.rawTraces, packet.rawJittedMethods).Serialize()); } [TypedPacketHandler] diff --git a/Source/Tests/SyncReferenceTest.cs b/Source/Tests/SyncReferenceTest.cs index 7dc9f218c..51a60c120 100644 --- a/Source/Tests/SyncReferenceTest.cs +++ b/Source/Tests/SyncReferenceTest.cs @@ -135,6 +135,29 @@ public void HostedForwardsHostOpinionsToOthers() Assert.That(Conn(host).SentPackets, Does.Not.Contain(Packets.Server_SyncReference)); } + [Test] + public void StandaloneForwardsLargeTracesFragmented() + { + server.IsStandaloneServer = true; + var reference = AddPlayer("reference"); + var desynced = AddPlayer("desynced"); + + server.playerManager.OnDesync(desynced, 0, 0); + Assert.That(Conn(reference).SentPackets, Does.Contain(Packets.Server_Traces)); + + // Compressed traces and jitted methods can exceed a single packet + var traces = new byte[ConnectionBase.MaxSinglePacketSize * 2]; + new Random(1).NextBytes(traces); + Assert.DoesNotThrow(() => reference.conn.GetState()!.HandleTraces(new ClientTracesPacket + { + playerId = desynced.id, rawTraces = traces, rawJittedMethods = [1] + })); + + var sent = Conn(desynced).SentRaw.Where(raw => (Packets)(raw[0] & 0x3F) == Packets.Server_Traces).ToList(); + Assert.That(sent, Has.Count.GreaterThan(1)); + Assert.That(sent.All(raw => (raw[0] & 0xC0) != 0), Is.True); + } + [Test] public void HostedForwardsArbiterOpinionsToEveryoneButArbiter() { From 940a691830fca79b89916c138c49df1c9afedf50 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:07:02 -0600 Subject: [PATCH 075/183] Don't time out join points while the uploader is saving The join point timeout counted only upload fragments as progress, so it also ran while the uploader saved and reloaded the game for the join point, which blocks its main thread and takes over a minute for big games. Such join points were always aborted after 60 s: every client reloaded for nothing, joiners kept replaying the old log and standalone servers never persisted a newer save. The uploader doesn't answer keep alives while it is blocked, so treat that as progress. A client that hangs for good is still dropped by the 10 minute stale keep alive timeout, which aborts the join point; one that answers keep alives but doesn't upload times out as before. Co-Authored-By: Claude Opus 5.5 --- Source/Common/WorldData.cs | 12 +++++++++++- Source/Tests/JoinPointTest.cs | 29 ++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Source/Common/WorldData.cs b/Source/Common/WorldData.cs index a35c6c448..811b2ec63 100644 --- a/Source/Common/WorldData.cs +++ b/Source/Common/WorldData.cs @@ -39,6 +39,8 @@ public class WorldData // rate limited. Abort if the uploader shows no progress, joiners would wait forever otherwise. public const int ForcedJoinPointCooldown = MultiplayerServer.NetTicksPerSecond * 10; public const int JoinPointTimeout = MultiplayerServer.NetTicksPerSecond * 60; + // Keep alives are answered several times a second unless the client's main thread is blocked + public const int UploaderBusyAfter = MultiplayerServer.NetTicksPerSecond * 5; private const int TicksPerMinute = 60 * 60; public bool CreatingJoinPoint => tmpCmds != null; @@ -203,7 +205,15 @@ public void Tick() public void CheckJoinPointTimeout(int netTimer) { - if (CreatingJoinPoint && netTimer - joinPointProgressAt > JoinPointTimeout) + if (!CreatingJoinPoint) return; + + // Saving and reloading for the join point blocks the uploader's main thread, for minutes with big games, so + // it doesn't answer keep alives meanwhile. That's progress too: a client that hangs for good is dropped by + // the stale keep alive timeout (PlayerManager.CheckTimeouts), which aborts the join point. + if (Server.GetPlayer(JoinPointUploaderId) is { } uploader && netTimer - uploader.keepAliveAt > UploaderBusyAfter) + joinPointProgressAt = netTimer; + + if (netTimer - joinPointProgressAt > JoinPointTimeout) AbortJoinPointCreation("Timed out waiting for the world upload."); } diff --git a/Source/Tests/JoinPointTest.cs b/Source/Tests/JoinPointTest.cs index d25ffbd9f..e0f536b26 100644 --- a/Source/Tests/JoinPointTest.cs +++ b/Source/Tests/JoinPointTest.cs @@ -212,18 +212,45 @@ public void JoinPointAbortsWhenUploaderRejoins() [Test] public void JoinPointTimesOutWithoutUpload() { - AddPlayer("host"); + var host = AddPlayer("host"); server.hostUsername = "host"; Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + // The uploader keeps answering keep alives (it isn't busy saving) but never uploads + host.keepAliveAt = WorldData.JoinPointTimeout; server.worldData.CheckJoinPointTimeout(WorldData.JoinPointTimeout); Assert.That(server.worldData.CreatingJoinPoint, Is.True); + host.keepAliveAt = WorldData.JoinPointTimeout + 1; server.worldData.CheckJoinPointTimeout(WorldData.JoinPointTimeout + 1); Assert.That(server.worldData.CreatingJoinPoint, Is.False); } + [Test] + public void JoinPointWaitsWhileTheUploaderIsBusySaving() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + AddPlayer("b"); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + + // Saving and reloading a big game blocks the uploader's main thread: no keep alives, no fragments + var reloaded = WorldData.JoinPointTimeout * 5; + server.worldData.CheckJoinPointTimeout(reloaded); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + + // Once it answers keep alives again, the upload has to start within the timeout + uploader.keepAliveAt = reloaded + WorldData.JoinPointTimeout; + server.worldData.CheckJoinPointTimeout(reloaded + WorldData.JoinPointTimeout); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + + uploader.keepAliveAt = reloaded + WorldData.JoinPointTimeout + 1; + server.worldData.CheckJoinPointTimeout(reloaded + WorldData.JoinPointTimeout + 1); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + } + [Test] public void ForcedJoinPointsAreRateLimited() { From 37302bddb6bf24e7f3d57d18a9d971faf43c178f Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:25:24 -0600 Subject: [PATCH 076/183] Keep the world data for joiners within the fragmented packet limit Server_WorldData carries the save, the command log since the join point and up to 10 stored sync opinions of the sync reference. With desync traces on, an opinion can be a few MiB on big saves (up to 8 MiB are accepted), so the opinions alone could exceed the 32 MiB limit of a fragmented packet. SendFragmented then threw, the joiner got a generic state exception disconnect and so did every later joiner. Joiners only use the stored opinions to check their catch-up simulation; their own opinions are marked as simulating then, so the trace hashes aren't compared, but they still locate the first differing trace for a desync report. Rather than stripping them, keep the newest opinions complete: store at most 8 MiB of them (dropping the oldest first, always keeping the newest one) and put only the newest ones that fit into what the packet has left after the save and the log. Fewer opinions only means fewer catch-up windows are checked, live opinions take over afterwards. The save itself (world, maps, session) reaches the server in one fragmented upload with the same limit, so on hosted servers only the log can push it over. Should the world data still be too big (a huge log without join points, a big seeded standalone save), the joiner is disconnected with a message giving the sizes, the server logs it and announces it in the chat instead of failing with a state exception. Co-Authored-By: Claude Opus 5.5 --- Source/Common/Networking/ConnectionBase.cs | 4 +- .../Networking/State/ServerLoadingState.cs | 46 ++++- .../Networking/State/ServerPlayingState.cs | 5 +- Source/Common/WorldData.cs | 29 +++ Source/Tests/Helper/RecordingConnection.cs | 4 +- Source/Tests/WorldDataSizeTest.cs | 182 ++++++++++++++++++ 6 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 Source/Tests/WorldDataSizeTest.cs diff --git a/Source/Common/Networking/ConnectionBase.cs b/Source/Common/Networking/ConnectionBase.cs index fc84df44f..8c9eab945 100644 --- a/Source/Common/Networking/ConnectionBase.cs +++ b/Source/Common/Networking/ConnectionBase.cs @@ -74,6 +74,8 @@ protected virtual void Send(Packets id, byte[] message, bool reliable = true) public const int MaxFragmentPacketSize = 1024; // Max size of a packet that can be sent fragmented. It is not possible to send any packets larger than this. public const int MaxFragmentPacketTotalSize = 33_554_432; + // The longest message SendFragmented accepts: the first fragment adds 6 bytes of metadata + public const int MaxFragmentedMessageLength = MaxFragmentPacketTotalSize - 6; private const int FragNone = 0x0; private const int FragMore = 0x40; @@ -98,7 +100,7 @@ public void SendFragmented(Packets id, byte[] message) const int maxFragmentSize = MaxFragmentPacketSize - 2; // the first packet has an additional 6 bytes of overhead var totalLength = message.Length + 6; - if (totalLength > MaxFragmentPacketTotalSize) + if (message.Length > MaxFragmentedMessageLength) { throw new PacketSendException( $"Tried to send too big packet {id}. Max size: {MaxFragmentPacketTotalSize}, requested size (incl. overhead): {totalLength}."); diff --git a/Source/Common/Networking/State/ServerLoadingState.cs b/Source/Common/Networking/State/ServerLoadingState.cs index d7cf7c75d..cd0a4e817 100644 --- a/Source/Common/Networking/State/ServerLoadingState.cs +++ b/Source/Common/Networking/State/ServerLoadingState.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Multiplayer.Common.Networking.Packet; @@ -32,16 +33,15 @@ protected override async Task RunState() await Server.worldData.WaitJoinPoint(); await EndIfDead(); - SendWorldData(); + if (!SendWorldData()) + return; Player.SendPlayerList(); connection.ChangeState(ConnectionStateEnum.ServerPlaying); } - public void SendWorldData() + public bool SendWorldData() { - connection.Send(Packets.Server_WorldDataStart); - ByteWriter writer = new ByteWriter(); writer.WriteInt32(Player.FactionId); @@ -68,13 +68,47 @@ public void SendWorldData() writer.WritePrefixedBytes(mapData); } - writer.WriteInt32(Server.worldData.syncInfos.Count); - foreach (var syncInfo in Server.worldData.syncInfos) + // The world and maps usually came in one upload with the same limit, but the command log since the join point + // and the sync opinions come on top. The opinions only let the joiner check its catch-up, they get what's left. + var syncInfoSpace = ConnectionBase.MaxFragmentedMessageLength - writer.Position - sizeof(int); + if (syncInfoSpace < 0) + { + RejectOversizeWorldData(writer.Position); + return false; + } + + var syncInfos = Server.worldData.NewestSyncInfosFitting(syncInfoSpace); + if (syncInfos.Count < Server.worldData.syncInfos.Count) + ServerLog.Detail($"World data for {Player.Username}: only {syncInfos.Count} of " + + $"{Server.worldData.syncInfos.Count} sync opinions fit"); + + writer.WriteInt32(syncInfos.Count); + foreach (var syncInfo in syncInfos) writer.WritePrefixedBytes(syncInfo); byte[] packetData = writer.ToArray(); + connection.Send(Packets.Server_WorldDataStart); connection.SendFragmented(Packets.Server_WorldData, packetData); ServerLog.Log("World response sent: " + packetData.Length); + return true; + } + + // SendFragmented would throw, which disconnects with a generic state exception and repeats for every joiner + private void RejectOversizeWorldData(int length) + { + const double MiB = 1024 * 1024; + var worldData = Server.worldData; + var saveLength = (worldData.savedGame?.Length ?? 0) + (worldData.sessionData?.Length ?? 0) + + worldData.mapData.Values.Sum(map => (long)map.Length); + var cmdsLength = worldData.cmds.Sum(cmd => (long)cmd.Length); + + var details = $"{length / MiB:0.0} MiB > {ConnectionBase.MaxFragmentedMessageLength / MiB:0.0} MiB; " + + $"save {saveLength / MiB:0.0} MiB, command log since the last join point " + + $"{cmdsLength / MiB:0.0} MiB ({worldData.cmds.Count} commands)"; + ServerLog.Error($"Can't send the world data to {Player.Username}, it's too big ({details}). " + + "A new join point (e.g. saving the game) clears the command log."); + Server.SendChat($"{Player.Username} can't join: the world data is too big ({details})"); + Player.Disconnect($"The server's world data is too big to send ({details})."); } } diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index a3459fb6e..c1a562b1c 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -207,10 +207,7 @@ public void HandleDesyncCheck(ClientSyncInfoPacket packet) var reference = Server.playerManager.SyncReference; if (Player != reference) return; // policy - // Keep at most 10 sync infos - Server.worldData.syncInfos.Add(packet.rawSyncOpinion); - if (Server.worldData.syncInfos.Count > 10) - Server.worldData.syncInfos.RemoveAt(0); + Server.worldData.AddSyncInfo(packet.rawSyncOpinion); foreach (var p in Server.PlayingPlayers.Where(p => p != reference && !p.IsArbiter)) p.conn.SendFragmented(new ServerSyncInfoPacket { rawSyncOpinion = packet.rawSyncOpinion }.Serialize()); diff --git a/Source/Common/WorldData.cs b/Source/Common/WorldData.cs index 811b2ec63..fdfce7f10 100644 --- a/Source/Common/WorldData.cs +++ b/Source/Common/WorldData.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Security.Cryptography; using System.Threading.Tasks; +using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common; @@ -20,7 +21,12 @@ public class WorldData public List? tmpCmds; public int lastJoinPointAtTick = -1; + // The sync reference's newest opinions (oldest first). Joiners only use them to check their catch-up + // simulation, after that they get the live ones, so losing the oldest ones only skips a few checks. public List syncInfos = new(); + public const int MaxSyncInfos = 10; + // With desync traces on, one opinion can be a few MiB on big saves and they all go into the world data packet + public const int MaxSyncInfosLength = ClientSyncInfoPacket.MaxLength; public StandaloneWorldSnapshotState standaloneWorldSnapshot = new(); public Dictionary standaloneMapSnapshots = new(); @@ -192,6 +198,29 @@ private void ClearPendingJoinPoint() JoinPointUploaderId = ScheduledCommand.NoPlayer; } + public void AddSyncInfo(byte[] rawSyncOpinion) + { + syncInfos.Add(rawSyncOpinion); + + long length = syncInfos.Sum(info => (long)info.Length); + while (syncInfos.Count > MaxSyncInfos || syncInfos.Count > 1 && length > MaxSyncInfosLength) + { + length -= syncInfos[0].Length; + syncInfos.RemoveAt(0); + } + } + + /// The newest opinions whose length prefixed size fits into maxLength, oldest first + public List NewestSyncInfosFitting(long maxLength) + { + int start = syncInfos.Count; + long length = 0; + while (start > 0 && length + sizeof(int) + syncInfos[start - 1].Length <= maxLength) + length += sizeof(int) + syncInfos[--start].Length; + + return syncInfos.GetRange(start, syncInfos.Count - start); + } + public Task WaitJoinPoint() { return dataSource?.Task ?? Task.FromResult(this); diff --git a/Source/Tests/Helper/RecordingConnection.cs b/Source/Tests/Helper/RecordingConnection.cs index 28cd1ac4b..eb4e407b5 100644 --- a/Source/Tests/Helper/RecordingConnection.cs +++ b/Source/Tests/Helper/RecordingConnection.cs @@ -29,5 +29,7 @@ protected override void SendRaw(byte[] raw, bool reliable) SentRaw.Add(raw); } - protected override void OnClose(ServerDisconnectPacket? goodbye) { } + public ServerDisconnectPacket? Goodbye { get; private set; } + + protected override void OnClose(ServerDisconnectPacket? goodbye) => Goodbye = goodbye; } \ No newline at end of file diff --git a/Source/Tests/WorldDataSizeTest.cs b/Source/Tests/WorldDataSizeTest.cs new file mode 100644 index 000000000..19ddcd3ab --- /dev/null +++ b/Source/Tests/WorldDataSizeTest.cs @@ -0,0 +1,182 @@ +using System.Collections.Generic; +using System.Linq; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +/// The world data sent to joiners is one fragmented packet: the stored sync opinions must never push it over the limit +[TestFixture] +public class WorldDataSizeTest +{ + private const int MiB = 1024 * 1024; + + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + server.worldData.savedGame = [1]; + server.worldData.sessionData = [2]; + nextPlayerId = 0; + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private (ServerPlayer player, RecordingConnection conn) AddPlayer(string username) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn) { hasJoined = true }; + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return (player, conn); + } + + private static byte[] Opinion(byte marker, int length) + { + var opinion = new byte[length]; + opinion[0] = marker; + return opinion; + } + + // Reassembles the fragments the way ConnectionBase.HandleReceiveFragment does + private static byte[] ReceivedFragmented(RecordingConnection conn, Packets id) + { + var data = new List(); + foreach (var raw in conn.SentRaw.Where(raw => (Packets)(raw[0] & 0x3F) == id && (raw[0] & 0xC0) != 0)) + data.AddRange(data.Count == 0 ? raw[8..] : raw[2..]); + return data.ToArray(); + } + + private static List SentOpinionMarkers(byte[] worldData) + { + var reader = new ByteReader(worldData); + reader.ReadInt32(); // faction + reader.ReadInt32(); // timer + reader.ReadInt32(); // sent cmds + reader.ReadBool(); // frozen + reader.ReadPrefixedBytes(); // game + reader.ReadPrefixedBytes(); // session + var cmds = reader.ReadInt32(); + for (int i = 0; i < cmds; i++) reader.ReadPrefixedBytes(); + var maps = reader.ReadInt32(); + for (int i = 0; i < maps; i++) + { + reader.ReadInt32(); + reader.ReadPrefixedBytes(); + } + + var opinions = reader.ReadInt32(); + var markers = Enumerable.Range(0, opinions).Select(_ => reader.ReadPrefixedBytes()![0]).ToList(); + Assert.That(reader.Left, Is.Zero); + return markers; + } + + [Test] + public void StoredOpinionsAreBoundedDroppingTheOldest() + { + for (byte i = 0; i < WorldData.MaxSyncInfos + 2; i++) + server.worldData.AddSyncInfo([i]); + + Assert.That(server.worldData.syncInfos.Select(o => o[0]), + Is.EqualTo(Enumerable.Range(2, WorldData.MaxSyncInfos).Select(i => (byte)i))); + + // Opinions with desync traces of a big save + for (byte i = 20; i < 24; i++) + server.worldData.AddSyncInfo(Opinion(i, 3 * MiB)); + + Assert.That(server.worldData.syncInfos.Select(o => o[0]), Is.EqualTo(new byte[] { 22, 23 })); + Assert.That(server.worldData.syncInfos.Sum(o => o.Length), Is.LessThanOrEqualTo(WorldData.MaxSyncInfosLength)); + } + + [Test] + public void TheNewestOpinionIsAlwaysKept() + { + server.worldData.AddSyncInfo(Opinion(1, 2 * MiB)); + server.worldData.AddSyncInfo(Opinion(2, WorldData.MaxSyncInfosLength)); + + Assert.That(server.worldData.syncInfos.Select(o => o[0]), Is.EqualTo(new byte[] { 2 })); + } + + [Test] + public void WorldDataSendsOnlyTheNewestOpinionsThatFit() + { + for (byte i = 0; i < 8; i++) + server.worldData.AddSyncInfo(Opinion(i, MiB)); + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(8)); + + // A big save leaves room for two and a half of them + server.worldData.savedGame = new byte[ConnectionBase.MaxFragmentedMessageLength - MiB * 5 / 2]; + + var (_, conn) = AddPlayer("joiner"); + Assert.That(new ServerLoadingState(conn).SendWorldData(), Is.True); + + var worldData = ReceivedFragmented(conn, Packets.Server_WorldData); + Assert.That(worldData.Length, Is.LessThanOrEqualTo(ConnectionBase.MaxFragmentedMessageLength)); + Assert.That(SentOpinionMarkers(worldData), Is.EqualTo(new byte[] { 6, 7 })); + Assert.That(conn.SentPackets.First(), Is.EqualTo(Packets.Server_WorldDataStart)); + // The stored ones stay for joiners with smaller world data + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(8)); + } + + [Test] + public void SmallWorldDataSendsAllOpinions() + { + for (byte i = 0; i < 3; i++) + server.worldData.AddSyncInfo([i]); + + var (_, conn) = AddPlayer("joiner"); + Assert.That(new ServerLoadingState(conn).SendWorldData(), Is.True); + + var worldData = conn.SentMessages.Single(m => m.id == Packets.Server_WorldData).data; + Assert.That(SentOpinionMarkers(worldData), Is.EqualTo(new byte[] { 0, 1, 2 })); + } + + [Test] + public void OversizeWorldDataDisconnectsWithAReason() + { + // The command log since the last join point comes on top of a world that fit into the upload + server.worldData.savedGame = new byte[ConnectionBase.MaxFragmentedMessageLength - 1000]; + var (_, liveConn) = AddPlayer("live"); + for (int i = 0; i < 10; i++) + server.commands.Send(CommandType.Sync, 0, 1, new byte[200]); + + var (joiner, conn) = AddPlayer("joiner"); + var errors = new List(); + var prevError = ServerLog.error; + ServerLog.error = errors.Add; + try + { + Assert.That(new ServerLoadingState(conn).SendWorldData(), Is.False); + } + finally + { + ServerLog.error = prevError; + } + + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0], Does.Contain("joiner").And.Contain("too big")); + + Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_WorldData)); + Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_WorldDataStart)); + Assert.That(conn.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(conn.Goodbye?.reason, Is.EqualTo(MpDisconnectReason.GenericKeyed)); + Assert.That(new ByteReader(conn.Goodbye!.Value.data).ReadString(), + Does.Contain("too big").And.Contain("10 commands")); + Assert.That(server.playerManager.Players, Does.Not.Contain(joiner)); + Assert.That(liveConn.SentPackets, Does.Contain(Packets.Server_Chat)); + Assert.That(liveConn.State, Is.EqualTo(ConnectionStateEnum.ServerPlaying)); + } +} From edd7d3c064f3b7e858d3fddd718f166cae6dd6af Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:27:51 -0600 Subject: [PATCH 077/183] Start one join point per saved tick for save requests With in-game day autosaves on standalone, every client autosaves at the same tick (the map counters are synced) and requests a join point once its save is written. Duplicates were only rejected while a join point was being created or within 10 s of the last forced one, so a client whose save took longer started a second join point, reloading every client again. Save requests now carry the game timer of the saved state (taken when the autosave starts, as more ticks can run in that frame before the long event saves) and the server ignores requests for a tick that the join point in progress or the last completed one already covers. Join points start at the server's timer, which is never behind a client's, so all requests of one autosave are covered by the first one. An aborted join point doesn't count, so a later request retries it. The packet changes within protocol 57, which this branch introduced. Co-Authored-By: Claude Opus 5.5 --- Source/Client/ConstantTicker.cs | 4 +- Source/Client/Patches/VTRSyncPatch.cs | 2 +- Source/Client/Session/Autosaving.cs | 10 +++- Source/Client/Windows/SaveGameWindow.cs | 3 +- .../Networking/Packet/AutosavingPacket.cs | 8 ++- .../Networking/State/ServerPlayingState.cs | 10 ++-- Source/Common/WorldData.cs | 19 ++++++ Source/Tests/JoinPointTest.cs | 60 ++++++++++++++++++- 8 files changed, 99 insertions(+), 17 deletions(-) diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs index f3e98d001..30fbad4f8 100644 --- a/Source/Client/ConstantTicker.cs +++ b/Source/Client/ConstantTicker.cs @@ -70,8 +70,8 @@ private static void TickAutosave() } else if (session.autosaveUnit == AutosaveUnit.Days) { - // The map counters are synced game state, so every client requests at the same tick and the - // server starts a single join point (duplicates are rejected while one is created/rate limited) + // The map counters are synced game state, so every client saves at the same tick and the server + // starts a single join point for it (WorldData.TryStartJoinPointForSave) var anyMapCounterUp = Multiplayer.game.mapComps .Any(m => m.autosaveCounter > session.autosaveInterval * TicksPerIngameDay); diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index eb5812a86..fde3c0a47 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -146,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) - Autosaving.SendAutosavingRequest(JoinPointRequestReason.WorldTravel); + Autosaving.SendAutosavingRequest(JoinPointRequestReason.WorldTravel, TickPatch.Timer); } // 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 0506f552d..0b8b6bbff 100644 --- a/Source/Client/Session/Autosaving.cs +++ b/Source/Client/Session/Autosaving.cs @@ -15,6 +15,10 @@ public static class Autosaving // (and in this client's faction context), so a joiner loading it wouldn't match the live clients. public static void DoAutosave(bool requestJoinPoint = true) { + // Taken now: in-game day autosaves start at the same tick on every client, but more ticks can run in this + // frame before the long event saves. The server starts one join point per tick. + var tick = TickPatch.Timer; + LongEventHandler.QueueLongEvent(() => { var snapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false); @@ -23,16 +27,16 @@ public static void DoAutosave(bool requestJoinPoint = true) return; if (requestJoinPoint) - SendAutosavingRequest(JoinPointRequestReason.Save); + SendAutosavingRequest(JoinPointRequestReason.Save, tick); }, "MpSaving", false, null); } - public static void SendAutosavingRequest(JoinPointRequestReason reason) + public static void SendAutosavingRequest(JoinPointRequestReason reason, int tick) { if (TickPatch.Simulating) return; - Multiplayer.Client.Send(new ClientAutosavingPacket(reason)); + Multiplayer.Client.Send(new ClientAutosavingPacket(reason, tick)); } private static string GetNextAutosaveFileName() diff --git a/Source/Client/Windows/SaveGameWindow.cs b/Source/Client/Windows/SaveGameWindow.cs index b2eae89a4..9399c71ac 100644 --- a/Source/Client/Windows/SaveGameWindow.cs +++ b/Source/Client/Windows/SaveGameWindow.cs @@ -200,12 +200,13 @@ private void Accept(bool currentReplay) { if (curText.Length != 0) { + var tick = TickPatch.Timer; LongEventHandler.QueueLongEvent(() => { if (!Autosaving.SaveGameToFile_Overwrite(curText, currentReplay)) return; - Autosaving.SendAutosavingRequest(JoinPointRequestReason.Save); + Autosaving.SendAutosavingRequest(JoinPointRequestReason.Save, tick); }, "MpSaving", false, null); Close(); } diff --git a/Source/Common/Networking/Packet/AutosavingPacket.cs b/Source/Common/Networking/Packet/AutosavingPacket.cs index 1b0b55b1a..cd7e5f4fd 100644 --- a/Source/Common/Networking/Packet/AutosavingPacket.cs +++ b/Source/Common/Networking/Packet/AutosavingPacket.cs @@ -1,12 +1,16 @@ namespace Multiplayer.Common.Networking.Packet; +/// tick: the game timer of the saved state. With in-game day autosaves every client saves at the same tick and asks +/// for a join point once its save is written, the server starts one per tick. [PacketDefinition(Packets.Client_Autosaving)] -public record struct ClientAutosavingPacket(JoinPointRequestReason reason) : IPacket +public record struct ClientAutosavingPacket(JoinPointRequestReason reason, int tick) : IPacket { public JoinPointRequestReason reason = reason; + public int tick = tick; public void Bind(PacketBuffer buf) { buf.BindEnum(ref reason); + buf.Bind(ref tick); } -} \ No newline at end of file +} diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index c1a562b1c..9fbda4907 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -225,22 +225,20 @@ public void HandleFreeze(ClientFreezePacket packet) [TypedPacketHandler] public void HandleAutosaving(ClientAutosavingPacket packet) { - 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}"); + $"isHost={Player.IsHost}, reason={packet.reason}, tick={packet.tick}"); // Every client reloads at a join point (and the game pauses), so per-player world travel join points // would interrupt everyone each time someone opens the world map. Standalone autosaves are server driven. - if (packet.reason == JoinPointRequestReason.WorldTravel) + if (packet.reason != JoinPointRequestReason.Save) return; - // On standalone, any playing client can request one (saves; forced ones are rate limited) + // On standalone, any playing client can request one (forced ones are rate limited) // 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); + Server.worldData.TryStartJoinPointForSave(packet.tick, sourcePlayer: Player); } [TypedPacketHandler] diff --git a/Source/Common/WorldData.cs b/Source/Common/WorldData.cs index fdfce7f10..fccda65f7 100644 --- a/Source/Common/WorldData.cs +++ b/Source/Common/WorldData.cs @@ -37,6 +37,8 @@ public class WorldData // completes it public int JoinPointTick { get; private set; } = -1; public int JoinPointUploaderId { get; private set; } = ScheduledCommand.NoPlayer; + // The game timer of the last completed join point's save + public int CompletedJoinPointTick { get; private set; } = -1; private int joinPointProgressAt; // NetTimer private int? lastForcedJoinPointAt; // NetTimer private int standaloneAutosaveBaseTick = -1; @@ -110,6 +112,22 @@ public bool TryStartJoinPointCreation(bool force = false, ServerPlayer? sourcePl return true; } + /// A client saved the game at savedAtTick and wants the server's world to be at least as new. With in-game day + /// autosaves every client saves at the same tick and asks once its save is written, slow clients only after the + /// join point is done, so requests for a tick a join point already covers are ignored. + public bool TryStartJoinPointForSave(int savedAtTick, ServerPlayer? sourcePlayer = null) + { + var coveredUntil = Math.Max(CompletedJoinPointTick, JoinPointTick); + if (savedAtTick <= coveredUntil) + { + ServerLog.Detail($"Join point skipped: the save of {sourcePlayer?.Username} at tick={savedAtTick} is " + + $"covered by the join point at tick={coveredUntil}"); + return false; + } + + return TryStartJoinPointCreation(true, sourcePlayer); + } + /// Accepts the uploaded world only if it belongs to the join point being created. World, maps and cmds are /// replaced together so the stored save and its command log always match. public bool TryCompleteJoinPoint(ServerPlayer player, int tick, Dictionary uploadedMaps, @@ -146,6 +164,7 @@ public void EndJoinPointCreation() cmds = tmpCmds!; tmpCmds = null; lastJoinPointAtTick = currentTick; + CompletedJoinPointTick = JoinPointTick; ClearPendingJoinPoint(); if (Server.IsStandaloneServer && Server.persistence != null) diff --git a/Source/Tests/JoinPointTest.cs b/Source/Tests/JoinPointTest.cs index e0f536b26..2cf1fe6bd 100644 --- a/Source/Tests/JoinPointTest.cs +++ b/Source/Tests/JoinPointTest.cs @@ -316,10 +316,66 @@ public void WorldTravelRequestsDoNotCreateJoinPoints() server.IsStandaloneServer = true; var player = AddPlayer("a"); - player.conn.GetState()!.HandleAutosaving(new ClientAutosavingPacket(JoinPointRequestReason.WorldTravel)); + player.conn.GetState()!.HandleAutosaving(new ClientAutosavingPacket(JoinPointRequestReason.WorldTravel, 0)); Assert.That(server.worldData.CreatingJoinPoint, Is.False); - player.conn.GetState()!.HandleAutosaving(new ClientAutosavingPacket(JoinPointRequestReason.Save)); + player.conn.GetState()!.HandleAutosaving(new ClientAutosavingPacket(JoinPointRequestReason.Save, 0)); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + } + + private void AdvanceNetTimer(int ticks) => + typeof(MultiplayerServer).GetProperty(nameof(MultiplayerServer.NetTimer))! + .SetValue(server, server.NetTimer + ticks); + + private static void RequestSave(ServerPlayer player, int tick) => + player.conn.GetState()!.HandleAutosaving( + new ClientAutosavingPacket(JoinPointRequestReason.Save, tick)); + + [Test] + public void DaysAutosaveRequestsForOneTickCreateOneJoinPoint() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + var other = AddPlayer("b"); + var slow = AddPlayer("slow"); + + // Every client autosaves at tick 90 and asks once its save is written + server.gameTimer = 100; + RequestSave(uploader, 90); + Assert.That(server.worldData.JoinPointTick, Is.EqualTo(100)); + RequestSave(other, 90); + Upload(uploader, 100, [2]); + Assert.That(server.worldData.CompletedJoinPointTick, Is.EqualTo(100)); + + // Saving took the slow client longer than the forced join point cooldown + AdvanceNetTimer(WorldData.ForcedJoinPointCooldown + 1); + server.gameTimer = 400; + var cmdCount = server.worldData.cmds.Count; + RequestSave(slow, 90); + + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(server.worldData.cmds, Has.Count.EqualTo(cmdCount)); + + // A save of a newer state still gets one + RequestSave(other, 300); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + Assert.That(server.worldData.JoinPointTick, Is.EqualTo(400)); + } + + [Test] + public void SaveRequestAfterAnAbortedJoinPointCreatesOne() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + var slow = AddPlayer("slow"); + + server.gameTimer = 100; + RequestSave(uploader, 90); + server.worldData.AbortJoinPointCreation(); + + // The server's world is still older than the save + AdvanceNetTimer(WorldData.ForcedJoinPointCooldown + 1); + RequestSave(slow, 90); Assert.That(server.worldData.CreatingJoinPoint, Is.True); } From 2947cb764b40e4abb52bbedbd823e667f0bd75c6 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:31:36 -0600 Subject: [PATCH 078/183] Discard saved standalone command logs of other protocols The command log in Saved/ (cmds.dat, or the legacy world_cmds.dat and maps/N_cmds.dat) may have been written by an older server whose commands encode their sync data differently (protocol 56), and nothing recorded which protocol wrote it. Replaying misread commands executes something else than what was played, can fail halfway or depend on client local state, and the clients desync. Persist the protocol in Saved/cmds_protocol.txt: the current one after a join point is written, the replay's one when seeding from save.zip. It is removed before the world and the log are rewritten and written last, so an interrupted write also leaves none. On load, a log without a matching protocol is discarded with an error that says why; the saved world, maps and session are still loaded and the timer is kept. Discarding is the safe choice: every client continues from the same saved world and only the progress since that save is lost. The legacy per-map logs only come from versions before cmds.dat, which used an older protocol, so LoadInto no longer merges them; they are still removed with the next join point. Co-Authored-By: Claude Opus 5.5 --- Source/Common/StandalonePersistence.cs | 92 +++++++++++----- Source/Tests/StandalonePersistenceTest.cs | 124 +++++++++++++++++++++- 2 files changed, 189 insertions(+), 27 deletions(-) diff --git a/Source/Common/StandalonePersistence.cs b/Source/Common/StandalonePersistence.cs index 8f561b1a2..2fab7e008 100644 --- a/Source/Common/StandalonePersistence.cs +++ b/Source/Common/StandalonePersistence.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.IO.Compression; using System.Linq; +using System.Text; namespace Multiplayer.Common; @@ -19,6 +21,10 @@ public class StandalonePersistence // All cmds since the join point in send order. Replaces the legacy per-tickable world_cmds.dat + maps/N_cmds.dat. private string CmdsPath => Path.Combine(SavedDir, "cmds.dat"); private string LegacyWorldCmdsPath => Path.Combine(SavedDir, "world_cmds.dat"); + // The protocol of the server that wrote the command log. Sync data in commands is encoded differently across + // protocols and the log is only replayed with the same one. Removed before and written after the log and the + // world are written, so a log of an older version or from an interrupted write has none. + private string CmdsProtocolPath => Path.Combine(SavedDir, "cmds_protocol.txt"); private string SessionPath => Path.Combine(SavedDir, "session.dat"); private string InfoPath => Path.Combine(SavedDir, "info.xml"); private string StatePath => Path.Combine(SavedDir, "state.bin"); @@ -45,6 +51,7 @@ public bool HasValidState() public void SeedFromSaveZip(string zipPath) { EnsureDirectories(); + DeleteCmdsProtocol(); using var zip = ZipFile.OpenRead(zipPath); @@ -91,11 +98,14 @@ public void SeedFromSaveZip(string zipPath) try { - WritePersistedTick(GetLatestTick(ReplayInfo.Read(infoBytes))); + var info = ReplayInfo.Read(infoBytes); + WritePersistedTick(GetLatestTick(info)); + // The replay's commands were written by its protocol + WriteCmdsProtocol(info.protocol); } catch (Exception e) { - ServerLog.Error($"Failed to seed persisted tick from info.xml: {e.Message}"); + ServerLog.Error($"Failed to seed persisted tick and protocol from info.xml: {e.Message}"); } } @@ -118,7 +128,6 @@ public void SeedFromSaveZip(string zipPath) server.worldData.sessionData = File.Exists(SessionPath) ? File.ReadAllBytes(SessionPath) : Array.Empty(); // Load maps - var legacyMapCmds = new List>(); if (Directory.Exists(MapsDir)) { foreach (var mapFile in Directory.GetFiles(MapsDir, "*.dat")) @@ -127,32 +136,10 @@ public void SeedFromSaveZip(string zipPath) 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)) - legacyMapCmds.Add(ScheduledCommand.DeserializeCmds(File.ReadAllBytes(cmdsPath))); - } } } - // Load commands - List cmds; - if (File.Exists(CmdsPath)) - { - cmds = ScheduledCommand.DeserializeCmds(File.ReadAllBytes(CmdsPath)); - } - else - { - var legacyWorldCmds = File.Exists(LegacyWorldCmdsPath) - ? ScheduledCommand.DeserializeCmds(File.ReadAllBytes(LegacyWorldCmdsPath)) - : new List(); - cmds = ScheduledCommand.MergeLegacyCmds(legacyWorldCmds, legacyMapCmds); - } - - server.worldData.cmds = cmds.Select(ScheduledCommand.Serialize).ToList(); - // Load replay info for metadata ReplayInfo? info = null; if (File.Exists(InfoPath)) @@ -162,6 +149,7 @@ public void SeedFromSaveZip(string zipPath) } var loadedTick = ReadPersistedTick() ?? GetLatestTick(info); + server.worldData.cmds = LoadCmds(); server.gameTimer = loadedTick; server.startingTimer = loadedTick; server.worldData.standaloneWorldSnapshot.tick = loadedTick; @@ -181,6 +169,7 @@ public void SeedFromSaveZip(string zipPath) public void WriteJoinPoint(WorldData worldData, int tick) { EnsureDirectories(); + DeleteCmdsProtocol(); if (worldData.savedGame != null) AtomicWrite(WorldPath, worldData.savedGame); @@ -198,6 +187,39 @@ public void WriteJoinPoint(WorldData worldData, int tick) DeleteLegacyCmdFiles(); WritePersistedTick(tick); + WriteCmdsProtocol(MpVersion.Protocol); + } + + /// The command log is only replayed when this protocol wrote it. Commands of another protocol can be misread: + /// they then do something else than what was played, fail halfway or depend on client local state, and the + /// clients desync. Without them every client continues from the same saved world, which only loses the progress + /// since that save. Logs from before cmds.dat (world_cmds.dat, maps/N_cmds.dat) always come from older protocols. + private List LoadCmds() + { + var protocol = ReadCmdsProtocol(); + if (protocol == MpVersion.Protocol) + return File.Exists(CmdsPath) + ? ScheduledCommand.DeserializeCmds(File.ReadAllBytes(CmdsPath)).Select(ScheduledCommand.Serialize).ToList() + : new List(); + + var cmdFiles = new[] { CmdsPath, LegacyWorldCmdsPath } + .Concat(Directory.Exists(MapsDir) ? Directory.GetFiles(MapsDir, "*_cmds.dat") : []); + // Every format starts with the command count + var discarded = cmdFiles.Where(File.Exists).Select(File.ReadAllBytes) + .Sum(data => data.Length >= sizeof(int) ? Math.Max(0L, BitConverter.ToInt32(data, 0)) : 0L); + + if (discarded > 0) + { + var writer = protocol == null + ? "an older version of the server (or its write was interrupted)" + : $"protocol {protocol}"; + ServerLog.Error($"Discarding the saved command log ({discarded} commands): it was written by {writer} " + + $"and this server (protocol {MpVersion.Protocol}) could misread it, replaying misread " + + "commands desyncs. The game continues from the last saved world, the progress since " + + "that save is lost."); + } + + return new List(); } /// @@ -285,6 +307,26 @@ private void WritePersistedTick(int tick) AtomicWrite(StatePath, BitConverter.GetBytes(tick)); } + private void WriteCmdsProtocol(int protocol) + { + AtomicWrite(CmdsProtocolPath, Encoding.UTF8.GetBytes(protocol.ToString(CultureInfo.InvariantCulture))); + } + + private void DeleteCmdsProtocol() + { + if (File.Exists(CmdsProtocolPath)) + File.Delete(CmdsProtocolPath); + } + + private int? ReadCmdsProtocol() + { + if (!File.Exists(CmdsProtocolPath)) + return null; + + return int.TryParse(File.ReadAllText(CmdsProtocolPath).Trim(), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var protocol) ? protocol : null; + } + private int? ReadPersistedTick() { if (!File.Exists(StatePath)) diff --git a/Source/Tests/StandalonePersistenceTest.cs b/Source/Tests/StandalonePersistenceTest.cs index fa2b39918..4fb31b5e0 100644 --- a/Source/Tests/StandalonePersistenceTest.cs +++ b/Source/Tests/StandalonePersistenceTest.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; using Multiplayer.Common; @@ -10,17 +11,26 @@ namespace Tests; public class StandalonePersistenceTest { private string tempDir = null!; + private readonly List errors = new(); + private Action? prevError; + + private string SavedDir => Path.Combine(tempDir, "Saved"); [SetUp] public void SetUp() { tempDir = Path.Combine(Path.GetTempPath(), $"mp-standalone-persistence-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); + + errors.Clear(); + prevError = ServerLog.error; + ServerLog.error = errors.Add; } [TearDown] public void TearDown() { + ServerLog.error = prevError; MultiplayerServer.instance = null; if (Directory.Exists(tempDir)) @@ -67,7 +77,7 @@ public void WriteJoinPoint_PersistsCommandsAndTickForReload() } [Test] - public void LoadInto_MergesLegacyPerMapCommandFilesByTick() + public void LoadInto_DiscardsLegacyCommandFiles() { var persistence = new StandalonePersistence(tempDir); persistence.EnsureDirectories(); @@ -92,7 +102,12 @@ public void LoadInto_MergesLegacyPerMapCommandFilesByTick() persistence.LoadInto(server); - Assert.That(DataBytes(server.worldData.cmds), Is.EqualTo(new byte[] { 3, 1, 4, 2 })); + // Only versions before cmds.dat (an older protocol) wrote them + Assert.That(server.worldData.cmds, Is.Empty); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + Assert.That(server.worldData.mapData[7], Is.EqualTo(new byte[] { 2 })); + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0], Does.Contain("Discarding the saved command log (4 commands)")); } [Test] @@ -129,6 +144,109 @@ public void WriteJoinPoint_ReplacesLegacyCommandFiles() Assert.That(DataBytes(reloaded.worldData.cmds), Is.EqualTo(new byte[] { 1, 2 })); } + private MultiplayerServer Reload() + { + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(tempDir), + }; + server.persistence.LoadInto(server); + return server; + } + + private void WriteJoinPoint(byte world, params byte[] cmdMarkers) + { + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(tempDir), + }; + server.worldData.savedGame = [world]; + server.worldData.mapData[7] = [world]; + server.worldData.cmds = cmdMarkers.Select(m => ScheduledCommand.Serialize(Cmd(50, ScheduledCommand.Global, m))).ToList(); + server.persistence.WriteJoinPoint(server.worldData, 60); + } + + [Test] + public void LoadInto_KeepsCommandsOfTheCurrentProtocol() + { + WriteJoinPoint(1, 5, 6); + + Assert.That(File.ReadAllText(Path.Combine(SavedDir, "cmds_protocol.txt")), Is.EqualTo(MpVersion.Protocol.ToString())); + Assert.That(DataBytes(Reload().worldData.cmds), Is.EqualTo(new byte[] { 5, 6 })); + Assert.That(errors, Is.Empty); + } + + [Test] + public void LoadInto_DiscardsCommandsOfAnotherProtocol([Values(true, false)] bool protocolKnown) + { + WriteJoinPoint(1, 5, 6); + + var protocolPath = Path.Combine(SavedDir, "cmds_protocol.txt"); + if (protocolKnown) + File.WriteAllText(protocolPath, "56"); + else + File.Delete(protocolPath); + + var server = Reload(); + + Assert.That(server.worldData.cmds, Is.Empty); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + Assert.That(server.worldData.mapData[7], Is.EqualTo(new byte[] { 1 })); + Assert.That(server.gameTimer, Is.EqualTo(60)); + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0], Does.Contain("Discarding the saved command log (2 commands)") + .And.Contain(protocolKnown ? "protocol 56" : "an older version")); + } + + [Test] + public void InterruptedJoinPointWriteDiscardsTheLog() + { + WriteJoinPoint(1, 5); + + // The next join point fails after writing the world + Directory.CreateDirectory(Path.Combine(SavedDir, "cmds.dat.tmp")); + Assert.That(() => WriteJoinPoint(2, 6), NUnit.Framework.Throws.Exception); + Directory.Delete(Path.Combine(SavedDir, "cmds.dat.tmp")); + + var server = Reload(); + + // The old log doesn't belong to the new world + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 2 })); + Assert.That(server.worldData.cmds, Is.Empty); + } + + [Test] + public void SeedFromSaveZip_KeepsCommandsOnlyFromTheCurrentProtocol([Values(true, false)] bool currentProtocol) + { + var zipPath = Path.Combine(tempDir, "save.zip"); + using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + void Entry(string name, byte[] data) + { + using var stream = zip.CreateEntry(name).Open(); + stream.Write(data); + } + + Entry("world/000_save", [1]); + Entry("world/000_cmds", ScheduledCommand.SerializeCmds([Cmd(10, ScheduledCommand.Global, 5)])); + Entry("info", ReplayInfo.Write(new ReplayInfo + { + protocol = currentProtocol ? MpVersion.Protocol : MpVersion.Protocol - 1, + sections = [new ReplaySection(10, 20)] + })); + } + + new StandalonePersistence(tempDir).SeedFromSaveZip(zipPath); + var server = Reload(); + + Assert.That(server.worldData.savedGame, Is.Not.Null); + Assert.That(server.gameTimer, Is.EqualTo(20)); + Assert.That(DataBytes(server.worldData.cmds), Is.EqualTo(currentProtocol ? new byte[] { 5 } : [])); + Assert.That(errors, Has.Count.EqualTo(currentProtocol ? 0 : 1)); + } + private static ScheduledCommand Cmd(int ticks, int mapId, byte marker) => new(CommandType.Sync, ticks, 0, mapId, 0, [marker]); @@ -163,5 +281,7 @@ public void LoadInto_FallsBackToLatestReplaySectionTick() Assert.That(server.gameTimer, Is.EqualTo(999)); Assert.That(server.startingTimer, Is.EqualTo(999)); + // An empty legacy log loses nothing + Assert.That(errors, Is.Empty); } } \ No newline at end of file From ac8feebf18f4c6b205fe808823dcfed9f6194bee Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:32:03 -0600 Subject: [PATCH 079/183] Open the trade window in a local only scope DialogTradeCtorPatch creates the trade session in the synced tick or command and then opens the trading window only for the player who started the trade (or, for settlement trades, on clients looking at the world). Window constructors and PreOpen can use Rand or unique ids, so on those clients only they advanced the shared RNG and id counters and added desync traces (#968). Open it inside LocalOnlyScope like the other session windows. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Persistent/Trading.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Source/Client/Persistent/Trading.cs b/Source/Client/Persistent/Trading.cs index e8bfb0892..229d8c917 100644 --- a/Source/Client/Persistent/Trading.cs +++ b/Source/Client/Persistent/Trading.cs @@ -548,11 +548,11 @@ static bool Prefix(Pawn playerNegotiator, ITrader trader, bool giftsOnly) if (playerNegotiator.Map == Find.CurrentMap && playerNegotiator.CurJob.loadID == SyncMethods.tradeJobStartedByMe) { SyncMethods.tradeJobStartedByMe = -1; - trade.OpenWindow(); + OpenWindowLocally(trade); } else if (trader is Settlement && Find.World.renderer.wantedMode == WorldRenderMode.Planet) { - trade.OpenWindow(); + OpenWindowLocally(trade); } } @@ -561,6 +561,14 @@ static bool Prefix(Pawn playerNegotiator, ITrader trader, bool giftsOnly) return true; } + + // Runs in a tick or command for the player who started the trade (or who looks at the world) only, the + // window's constructor and PreOpen must stay off the shared RNG and unique ids + static void OpenWindowLocally(MpTradeSession trade) + { + using (LocalOnlyScope.Enter()) + trade.OpenWindow(); + } } [HarmonyPatch(typeof(IncidentWorker_TraderCaravanArrival), nameof(IncidentWorker_TraderCaravanArrival.TryExecuteWorker))] From c877df8e10f6b8e7e34ea6ca173f4cd6d7ab6619 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:12:44 -0600 Subject: [PATCH 080/183] Let the arbiter run in-game synchronous long events A synchronous long event with a loading window only starts once the window was drawn. In batch mode windows on the window stack are never drawn, so the arbiter never ran in-game events like join point creation: it stopped simulating, the server stalled everyone waiting for it, and the join point (uploaded by the arbiter as the sync reference) timed out. Found by running a hosted game with an arbiter and a joining player. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/ArbiterPatches.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Source/Client/Patches/ArbiterPatches.cs b/Source/Client/Patches/ArbiterPatches.cs index 3314cb9e3..16455ad71 100644 --- a/Source/Client/Patches/ArbiterPatches.cs +++ b/Source/Client/Patches/ArbiterPatches.cs @@ -71,6 +71,19 @@ static IEnumerable TargetMethods() static bool Prefix() => !Multiplayer.arbiterInstance; } + // A synchronous long event with a loading window only starts after the window was drawn once. In batch mode + // windows on the window stack are never drawn, so in-game events like join point creation never ran: the + // arbiter stopped simulating and the server stalled everyone waiting for it. + [HarmonyPatch(typeof(LongEventHandler), nameof(LongEventHandler.LongEventsUpdate))] + static class ArbiterLongEventsDontWaitForWindow + { + static void Prefix() + { + if (Multiplayer.arbiterInstance && LongEventHandler.currentEvent != null) + LongEventHandler.currentEvent.alreadyDisplayed = true; + } + } + // TODO test if this works. [HarmonyPatch(typeof(WorldGrid), (nameof(WorldGrid.InitializeGlobalLayers)))] static class NoWorldRenderLayersForArbiter From 41c8fab12bd889be036cbe7f492c1abdd58171a2 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:18:58 -0600 Subject: [PATCH 081/183] Count UTF-8 bytes in packet string limits, bound array lengths The writer compared string limits with the char count while the reader compares UTF-8 bytes, so a Steam name of 17+ Cyrillic letters passed the host's check and made every receiver of the player list drop the connection. Steam names are now bounded by Steam's own byte limit (k_cchPersonaNameMax). Array binds reject negative lengths and don't allocate the remote length up front. Co-Authored-By: Claude Opus 5.5 --- Source/Common/Networking/Packet/IPacket.cs | 14 +++++-- .../Networking/Packet/PlayerListPacket.cs | 2 +- Source/Tests/ReaderBoundsTest.cs | 37 +++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/Source/Common/Networking/Packet/IPacket.cs b/Source/Common/Networking/Packet/IPacket.cs index 7a5dc0dea..01e8fe4b8 100644 --- a/Source/Common/Networking/Packet/IPacket.cs +++ b/Source/Common/Networking/Packet/IPacket.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; +using System.Text; namespace Multiplayer.Common.Networking.Packet; @@ -242,13 +243,17 @@ public override void Bind(ref T[] obj, Binder bind, int maxLength = Defaul int len = reader.ReadInt32(); if (len > maxLength && maxLength != -1) throw new ReaderException($"Array too big ({len}>{maxLength})"); - obj = new T[len]; + if (len < 0) throw new ReaderException($"Array length ({len}<0)"); + + // The length is remote data, don't allocate more than the packet could hold up front + var items = new List(Math.Min(len, reader.Left)); for (var i = 0; i < len; i++) { var item = default(T); bind(this, ref item); - obj[i] = item; + items.Add(item); } + obj = items.ToArray(); } public override void BindRemaining(ref byte[] obj, int maxLength = DefaultMaxLength) @@ -326,7 +331,10 @@ public sealed class PacketWriter(ByteWriter writer) : PacketBuffer(true) public override void Bind(ref string obj, int maxLength = DefaultMaxLength) { - if (obj != null && obj.Length > maxLength) throw new WriterException($"Too long string ({obj.Length}>{maxLength})"); + // The reader limits UTF-8 bytes, not chars: checking chars here let non-ASCII strings through which every + // receiver then rejected + if (obj != null && Encoding.UTF8.GetByteCount(obj) is var bytes && bytes > maxLength) + throw new WriterException($"Too long string ({bytes} bytes>{maxLength})"); writer.WriteString(obj); } diff --git a/Source/Common/Networking/Packet/PlayerListPacket.cs b/Source/Common/Networking/Packet/PlayerListPacket.cs index b42fdc18a..c28e54a46 100644 --- a/Source/Common/Networking/Packet/PlayerListPacket.cs +++ b/Source/Common/Networking/Packet/PlayerListPacket.cs @@ -98,7 +98,7 @@ public void Bind(PacketBuffer buf) buf.BindEnum(ref type); buf.BindEnum(ref status); buf.Bind(ref steamId); - buf.Bind(ref steamPersonaName, maxLength: Constants.k_cwchPersonaNameMax); + buf.Bind(ref steamPersonaName, maxLength: Constants.k_cchPersonaNameMax); buf.Bind(ref ticksBehind); buf.Bind(ref simulating); buf.Bind(ref r); diff --git a/Source/Tests/ReaderBoundsTest.cs b/Source/Tests/ReaderBoundsTest.cs index 20978e390..011e1820b 100644 --- a/Source/Tests/ReaderBoundsTest.cs +++ b/Source/Tests/ReaderBoundsTest.cs @@ -74,6 +74,43 @@ public void ListBindDoesNotPreallocateFromLength() new PacketReader(LengthPrefixed(int.MaxValue - 1)).Bind(ref list, BinderOf.Int(), maxLength: int.MaxValue)); } + [Test] + public void ArrayBindRejectsNegativeAndUnbackedLengths() + { + int[] array = []; + Assert.Throws(() => new PacketReader(LengthPrefixed(-1)).Bind(ref array, BinderOf.Int())); + // A huge claimed count fails on the missing data instead of allocating the array up front + Assert.Catch(() => + new PacketReader(LengthPrefixed(int.MaxValue - 1)).Bind(ref array, BinderOf.Int(), maxLength: int.MaxValue)); + } + + [Test] + public void StringLimitCountsUtf8Bytes() + { + // 20 Cyrillic letters are 40 UTF-8 bytes: the writer has to refuse them for a 32 byte limit, the reader would + string tooLong = new('ж', 20); + Assert.Throws(() => new PacketWriter(new ByteWriter()).Bind(ref tooLong, maxLength: 32)); + + string fits = new('ж', 16); + var writer = new ByteWriter(); + new PacketWriter(writer).Bind(ref fits, maxLength: 32); + string read = ""; + new PacketReader(new ByteReader(writer.ToArray())).Bind(ref read, maxLength: 32); + Assert.That(read, Is.EqualTo(fits)); + } + + [Test] + public void PlayerListAcceptsLongNonAsciiSteamNames() + { + // Steam allows 32 characters, up to 128 UTF-8 bytes + var info = new ServerPlayerListPacket.PlayerInfo { username = "Host", steamPersonaName = new string('ж', 32) }; + var writer = new ByteWriter(); + info.Bind(new PacketWriter(writer)); + var read = default(ServerPlayerListPacket.PlayerInfo); + read.Bind(new PacketReader(new ByteReader(writer.ToArray()))); + Assert.That(read.steamPersonaName, Is.EqualTo(info.steamPersonaName)); + } + [Test] public void GzippedRejectsDecompressionBomb() { From ab816c3405b38467d0714c32f1ad66dc24b098ac Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:19:46 -0600 Subject: [PATCH 082/183] Replay debug actions under the acting player's world view Vanilla debug actions (incident targets) read WorldRendererUtility.WorldSelected, which is local view state, so the same command picked different targets. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Debug/DebugSync.cs | 8 ++++++++ Source/Client/Patches/Patches.cs | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Source/Client/Debug/DebugSync.cs b/Source/Client/Debug/DebugSync.cs index 4b8609d8c..01aad6695 100644 --- a/Source/Client/Debug/DebugSync.cs +++ b/Source/Client/Debug/DebugSync.cs @@ -48,6 +48,10 @@ public static void HandleCmd(ByteReader data) int selectedId = data.ReadInt32(); + // Replay under the acting player's view: vanilla debug actions read it (e.g. the incident + // actions pick their target from DebugActionsIncidents.GetTarget) + WorldSelectedPatch.result = data.ReadBool(); + if (Multiplayer.MapContext != null) { var thing = Multiplayer.ThingsById.GetValueSafe(selectedId); @@ -115,6 +119,7 @@ public static void HandleCmd(ByteReader data) MouseCellPatch.result = null; MouseTilePatch.result = null; + WorldSelectedPatch.result = null; Find.Selector.selected = prevSelected; FieldRefs.worldSelected(Find.WorldSelector) = prevWorldSelected; @@ -156,6 +161,9 @@ public static void SendCmd(DebugSource source, int hash, string path, Map map, I else writer.WriteInt32(Find.WorldSelector.SingleSelectedObject?.ID ?? -1); + // Sent like the cursor and selection above, so the action resolves the same target everywhere + writer.WriteBool(WorldRendererUtility.WorldSelected); + Multiplayer.WriterLog.AddCurrentNode(writer); int mapId = map?.uniqueID ?? ScheduledCommand.Global; diff --git a/Source/Client/Patches/Patches.cs b/Source/Client/Patches/Patches.cs index 4917ba197..8210e5541 100644 --- a/Source/Client/Patches/Patches.cs +++ b/Source/Client/Patches/Patches.cs @@ -201,6 +201,26 @@ static void Postfix(ref PlanetTile __result) } } + // Set only while a debug command is executed (see DebugSync.HandleCmd) + [HarmonyPatch(typeof(WorldRendererUtility), nameof(WorldRendererUtility.WorldSelected), MethodType.Getter)] + public static class WorldSelectedPatch + { + public static bool? result; + + static void Prepare(MethodBase original) + { + // A one-line getter: keep the JIT from inlining it into callers, which would skip the patch + if (original != null) + MpUtil.MarkNoInlining(original); + } + + static void Postfix(ref bool __result) + { + if (result.HasValue) + __result = result.Value; + } + } + [HarmonyPatch(typeof(KeyBindingDef), nameof(KeyBindingDef.IsDownEvent), MethodType.Getter)] public static class KeyIsDownPatch { From 1095720e16108fe9e008ebfff87465b89d1d282a Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:20:17 -0600 Subject: [PATCH 083/183] Don't tick an accepted async world quest twice per tick Generation caches a world quest and accepting it cached it again, so until the next reload its parts ticked twice per tick (timers ran at double speed). Caching now removes every earlier entry first; removal also no longer skips the world list when the quest was on a map list (operator precedence). The quest contexts are restored in finalizers. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/MultiplayerAsyncQuest.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs index e9332b7fb..9dacb7736 100644 --- a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs +++ b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs @@ -95,7 +95,7 @@ static void Prefix(Quest __instance, ref AsyncTimeComp __state) __state?.PreContext(); } - static void Postfix(AsyncTimeComp __state) => __state?.PostContext(); + static void Finalizer(AsyncTimeComp __state) => __state?.PostContext(); } [HarmonyPatch(typeof(Quest), nameof(Quest.Accept))] @@ -112,7 +112,7 @@ static void Prefix(Quest __instance, ref AsyncTimeComp __state) __state?.PreContext(); } - static void Postfix(AsyncTimeComp __state) => __state?.PostContext(); + static void Finalizer(AsyncTimeComp __state) => __state?.PostContext(); } [HarmonyPatch(typeof(Quest), nameof(Quest.End))] @@ -172,7 +172,13 @@ public static bool TryRemoveCachedMap(AsyncTimeComp mapAsyncTimeComp) /// Quest to remove /// If quest is found in cache public static bool TryRemoveCachedQuest(Quest quest) - => mapQuestsCache.SingleOrDefault(x => x.Value.Contains(quest)).Value?.Remove(quest) ?? false | worldQuestsCache.Remove(quest); + { + // Removes every entry: a quest cached twice would be ticked twice per tick + bool removed = worldQuestsCache.RemoveAll(q => q == quest) > 0; + foreach (var quests in mapQuestsCache.Values) + removed |= quests.RemoveAll(q => q == quest) > 0; + return removed; + } /// /// Attempts to get the MapAsyncTimeComp cached for that quest @@ -207,6 +213,8 @@ public static AsyncTimeComp CacheQuest(Quest quest) //if it doesn't add it to the world quest list else { + // Accepting caches a quest again after its generation did + TryRemoveCachedQuest(quest); worldQuestsCache.Add(quest); if (MpVersion.IsDebug) Log.Message($"Info: Could not find AsyncTimeMap for Quest: '{quest.name}'"); From 6605f20fd1f995ae4da0f2be74382dfac294121d Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:21:19 -0600 Subject: [PATCH 084/183] Make unnatural corpse teleports independent of the camera UnnaturalCorpse.IsOutsideView and the container escape check read Find.CurrentMap and the local camera rect, so corpses teleported/escaped on some clients only (#959). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Anomaly.cs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Source/Client/Patches/Anomaly.cs b/Source/Client/Patches/Anomaly.cs index 70691d962..77293f896 100644 --- a/Source/Client/Patches/Anomaly.cs +++ b/Source/Client/Patches/Anomaly.cs @@ -47,4 +47,32 @@ static void Finalizer(bool __state) Rand.PopState(); } } + + // #959: vanilla checks whether the local camera sees the cell (map == Find.CurrentMap && view.Contains). + // An empty view makes the check camera independent; covers the container escape in UnnaturalCorpseTracker. + [HarmonyPatch(typeof(AnomalyUtility), nameof(AnomalyUtility.IsValidUnseenCell))] + static class AnomalyUtility_IsValidUnseenCell + { + static void Prefix(ref CellRect view) + { + if (Multiplayer.Client != null) + view = CellRect.Empty; + } + } + + // #959: the corpse teleports/disappears only when the local camera doesn't see it. + // Keep the reservation check (shared state), treat the corpse as never seen by a camera. + [HarmonyPatch(typeof(UnnaturalCorpse), nameof(UnnaturalCorpse.IsOutsideView))] + static class UnnaturalCorpse_IsOutsideView + { + static bool Prefix(UnnaturalCorpse __instance, ref bool __result) + { + if (Multiplayer.Client == null) + return true; + + __result = !(__instance.SpawnedOrAnyParentSpawned && + __instance.MapHeld.reservationManager.IsReservedByAnyoneOf(__instance, Faction.OfPlayer)); + return false; + } + } } \ No newline at end of file From 43a6493a74e2f729b9324bb6b8640d6e08f4d362 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:23:24 -0600 Subject: [PATCH 085/183] Notify seasons per player faction in multifaction DateNotifierTick runs in the synced world tick but pushed the local player's faction, so each client picked its own faction's home and clock and saved a different lastSeason; the first summer warning letter (with a unique id, on the shared letter stack) only came on some clients (#904). In multifaction run it once per player faction under a forced push of that faction, with a saved lastSeason per faction (old saves start undefined, which vanilla initializes without a message). The value is kept in the world comp because FactionWorldData isn't saved for the faction applied at save time. Single-faction games keep vanilla's state. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimePatches.cs | 70 ++++++++++++++++--- .../Client/Comp/World/MultiplayerWorldComp.cs | 9 +++ 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index 10a8bb051..8f19ee0fe 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -50,26 +51,73 @@ static IEnumerable TargetMethods() static bool Prefix() => Multiplayer.Client == null || !MapUpdateMarker.updating; } + // Season notifications run in the synced world tick and write saved state (lastSeason, letters), so they can't + // depend on the local player's faction. In multifaction every player faction gets its own pass under its context + // (which also routes its letters to its players) with its own saved lastSeason. Each pass uses the clock of the + // faction's home map with the minimum time zone, the map vanilla takes the season from. [HarmonyPatch(typeof(DateNotifier), nameof(DateNotifier.DateNotifierTick))] static class DateNotifierPatch { - static void Prefix(DateNotifier __instance, ref int? __state) + static bool running; + + static bool Prefix(DateNotifier __instance) { - if (Multiplayer.Client == null && Multiplayer.RealPlayerFaction != null) return; + if (Multiplayer.Client == null || running) return true; + + if (!Multiplayer.GameComp.multifaction) + { + // Every player plays the host's faction, the world tick runs under it + TickWithHomeTime(__instance); + return false; + } - Map map = __instance.FindPlayerHomeWithMinTimezone(); - if (map == null) return; + var worldComp = Multiplayer.WorldComp; + foreach (var (factionId, _) in worldComp.factionData) + { + var faction = Find.FactionManager.GetById(factionId); + if (faction is not { IsPlayer: true } || faction == worldComp.spectatorFaction) + continue; + + var prevLastSeason = __instance.lastSeason; + __instance.lastSeason = worldComp.factionLastSeasons.TryGetValue(factionId, out var s) ? s : Season.Undefined; + ((Map)null).PushFaction(faction, force: true); + + try + { + TickWithHomeTime(__instance); + } + catch (Exception e) + { + Log.Error($"Exception in DateNotifierTick for {faction}: {e}"); + } + finally + { + FactionExtensions.PopFaction(); + worldComp.factionLastSeasons[factionId] = __instance.lastSeason; + __instance.lastSeason = prevLastSeason; + } + } - __state = Find.TickManager.TicksGame; - FactionContext.Push(Multiplayer.RealPlayerFaction); - Find.TickManager.DebugSetTicksGame(map.AsyncTime().mapTicks); + return false; } - static void Finalizer(int? __state) + static void TickWithHomeTime(DateNotifier notifier) { - if (!__state.HasValue) return; - Find.TickManager.DebugSetTicksGame(__state.Value); - FactionContext.Pop(); + Map map = notifier.FindPlayerHomeWithMinTimezone(); + int prevTicks = Find.TickManager.TicksGame; + if (map != null) + Find.TickManager.DebugSetTicksGame(map.AsyncTime().mapTicks); + + running = true; + try + { + notifier.DateNotifierTick(); + } + finally + { + running = false; + Find.TickManager.DebugSetTicksGame(prevTicks); + } } } diff --git a/Source/Client/Comp/World/MultiplayerWorldComp.cs b/Source/Client/Comp/World/MultiplayerWorldComp.cs index 86ffaaf13..0dbf8cd3b 100644 --- a/Source/Client/Comp/World/MultiplayerWorldComp.cs +++ b/Source/Client/Comp/World/MultiplayerWorldComp.cs @@ -26,6 +26,10 @@ public class MultiplayerWorldComp : IHasSessionData // letters to their faction's players, the letter stack itself is the same on every client. public Dictionary letterFactions = new(); + // Multifaction: faction load ID -> DateNotifier.lastSeason of that faction (see DateNotifierPatch). Kept here and + // not in FactionWorldData, which isn't saved for the faction applied when saving. + public Dictionary factionLastSeasons = new(); + private int currentFactionId; public MultiplayerWorldComp(World world) @@ -40,6 +44,11 @@ public void ExposeData() ExposeFactionData(); ExposeLetterFactions(); + // Missing in old saves: the factions start Undefined, which vanilla initializes without notifying + Scribe_Collections.Look(ref factionLastSeasons, "factionLastSeasons", LookMode.Value, LookMode.Value); + if (Scribe.mode != LoadSaveMode.Saving) + factionLastSeasons ??= new Dictionary(); + sessionManager.ExposeSessions(); // Ensure a pause lock session exists if there's any pause locks registered if (!PauseLockSession.pauseLocks.NullOrEmpty() && Scribe.mode == LoadSaveMode.PostLoadInit) From 035d208303555d29f75e6ff34c319ed1e3a366de Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:24:22 -0600 Subject: [PATCH 086/183] Sync InitiateLoading called from float menu options Carry to shuttle, place in transporter and enter shuttle set the transporter group id in interface code, on the clicking client only, before the synced job. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/Game/SyncDelegates.cs | 2 +- Source/Client/Syncing/Game/SyncMethods.cs | 28 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index 00bc229c7..1528c0b57 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -24,7 +24,7 @@ public static void Init() SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_CaptureEntity), "GetOptionsFor", 2).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Capture entity // Other possible float menu options that could be added later: - // TODO maybe? TransporterUtility.InitiateLoading is not synced, not sure if significant + // Carry to shuttle: its TransporterUtility.InitiateLoading call is synced in SyncMethods // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_CarryToShuttle), "GetSingleOptionFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Carry to shuttle // TODO: missing some flecks // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_DraftedAttack), "GetSingleOptionFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Drafted attack diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index fb21164a9..c2f619ca4 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -599,6 +599,34 @@ static IEnumerable CompPlantableTranspiler(IEnumerable plantable.plantCells.Add(newValue); + // Float menu options (carry to shuttle, place carried pawn in a transporter, enter shuttle) call + // InitiateLoading from the interface right before the synced TryTakeOrderedJob, so the group id was + // set on the clicking client only. Both commands target the transporter's map and keep their order. + [MpPrefix(typeof(TransporterUtility), nameof(TransporterUtility.InitiateLoading))] + static bool InitiateLoading_Prefix(IEnumerable transporters, ref int __result) + { + if (!Multiplayer.ShouldSync) + return true; + + SyncedInitiateLoading(transporters.ToList()); + // Vanilla interface callers ignore the group id; -1 is the "no group" marker + __result = -1; + return false; + } + + [SyncMethod] + static void SyncedInitiateLoading(List transporters) + { + transporters.RemoveAll(t => t == null); + + // The interface callers only initiate loading when it isn't in progress; it may have started + // since the click (e.g. another player's order), don't replace the group then + if (transporters.Count == 0 || transporters.All(t => t.LoadingInProgressOrReadyToLaunch)) + return; + + TransporterUtility.InitiateLoading(transporters); + } + // ===== CALLBACKS ===== [MpPostfix(typeof(DrugPolicyDatabase), nameof(DrugPolicyDatabase.MakeNewDrugPolicy))] From 822b44c3e15b88fe8a380d3380045765252b49a9 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:24:26 -0600 Subject: [PATCH 087/183] Let map time contexts nest, guard maps without time comps A map's PreContext kept the previous time in one field. Quest time getters enter their map's context while that map ticks the quest, so the inner call overwrote it and the outer PostContext left the map's clock installed: the world clock then jumped to the map's. Each PreContext now keeps its own frame, and a context directly nested in its own map continues the map's random state instead of restarting from the saved one. The time restores of the UI patches run in finalizers. A map being generated is in Find.Maps before MapSetup creates its comps; the tickable list, the rate multiplier and the time snapshot now skip such maps instead of throwing. GameStartAbsTick no longer latches whichever clock happens to be set on its first read; saves without the field get the world's value on load. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 56 ++++++++++++++------- Source/Client/AsyncTime/AsyncTimePatches.cs | 6 ++- Source/Client/AsyncTime/SetMapTime.cs | 19 +++---- Source/Client/Patches/TickPatch.cs | 7 ++- 4 files changed, 58 insertions(+), 30 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index a8afbf573..af7cf62e3 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -64,18 +64,9 @@ public TimeSpeed DesiredTimeSpeed public int TickableId => map.uniqueID; - public int GameStartAbsTick - { - get - { - if (gameStartAbsTickMap == 0) - { - gameStartAbsTickMap = Find.TickManager?.gameStartAbsTick ?? 0; - } - - return gameStartAbsTickMap; - } - } + // No side effect: latching the global value on first read took whichever clock was set at that moment (another + // map's under async time). Saves without the field get the world's value when loading. + public int GameStartAbsTick => gameStartAbsTickMap != 0 ? gameStartAbsTickMap : Find.TickManager?.gameStartAbsTick ?? 0; public Map map; public int mapTicks; @@ -216,7 +207,18 @@ public void UpdateManagers() map.glowGrid.GlowGridUpdate_First(); } - private TimeSnapshot? prevTime; + private readonly struct ContextFrame(TimeSnapshot? prevTime, int outerRandDepth, bool continuesOuter) + { + public readonly TimeSnapshot? prevTime = prevTime; + public readonly int outerRandDepth = outerRandDepth; + public readonly bool continuesOuter = continuesOuter; + } + + // Contexts nest (e.g. a quest's time getter enters its map's context while that map ticks the quest), so each + // PreContext keeps its own previous time. A single field made the outer PostContext restore the map's time + // instead of the world's, and the world clock then jumped to the map's. + private readonly Stack contextFrames = new(); + private int contextRandDepth = -1; public void PreContext() { @@ -226,10 +228,17 @@ public void PreContext() : Multiplayer.WorldComp.spectatorFaction, force: true); - prevTime = TimeSnapshot.GetAndSetFromMap(map); + // Directly inside this map's own context: go on with its live random state instead of restarting from + // the state saved when the outer context began + bool continuesOuter = contextFrames.Count > 0 && Rand.stateStack.Count == contextRandDepth; + if (continuesOuter) + randState = Rand.StateCompressed; + + contextFrames.Push(new ContextFrame(TimeSnapshot.GetAndSetFromMap(map), contextRandDepth, continuesOuter)); Rand.PushState(); Rand.StateCompressed = randState; + contextRandDepth = Rand.stateStack.Count; // Reset the effects of SkyManager.SkyManagerUpdate map.skyManager.curSkyGlowInt = map.skyManager.CurrentSkyTarget().glow; @@ -237,10 +246,21 @@ public void PreContext() public void PostContext() { - prevTime?.Set(); + if (contextFrames.Count == 0) + { + Log.ErrorOnce($"Multiplayer: PostContext without PreContext on {map}", 0x2C7B90 ^ map.uniqueID); + return; + } + + var frame = contextFrames.Pop(); + + frame.prevTime?.Set(); randState = Rand.StateCompressed; Rand.PopState(); + if (frame.continuesOuter) + Rand.StateCompressed = randState; + contextRandDepth = frame.outerRandDepth; map.PopFaction(); } @@ -265,11 +285,13 @@ public void ExposeData() Scribe_Values.Look(ref timeSpeedInt, "timeSpeed"); Scribe_Values.Look(ref gameStartAbsTickMap, "gameStartAbsTickMap"); + if (Scribe.mode == LoadSaveMode.LoadingVars && gameStartAbsTickMap == 0) + gameStartAbsTickMap = Find.TickManager.gameStartAbsTick; Scribe_Custom.LookULong(ref randState, "randState", 1); - // Affects the simulation through VTR. Saved so that it survives reloads, which can happen - // on one client only (standalone join points). + // Affects the simulation through VTR. Saved so that it survives join point reloads and joiners start + // with the same counts as everyone else. var playerCount = CurrentPlayerCount; Scribe_Values.Look(ref playerCount, "playerCount"); CurrentPlayerCount = playerCount; diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index 10a8bb051..7257e0609 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -134,7 +134,8 @@ static void Postfix(ref float __result) if (WorldRendererUtility.WorldSelected) return; var map = PreDrawCalcMarker.calculating.Map ?? Find.CurrentMap; - var asyncTime = map.AsyncTime(); + var asyncTime = map?.AsyncTime(); + if (asyncTime == null) return; var timeSpeed = Multiplayer.IsReplay ? TickPatch.replayTimeSpeed : asyncTime.DesiredTimeSpeed; __result = TickPatch.Simulating ? 6 : asyncTime.ActualRateMultiplier(timeSpeed); @@ -150,7 +151,8 @@ static void Postfix(ref bool __result) if (WorldRendererUtility.WorldSelected) return; if (FactionCreator.generatingMap) return; - var asyncTime = Find.CurrentMap.AsyncTime(); + var asyncTime = Find.CurrentMap?.AsyncTime(); + if (asyncTime == null) return; var timeSpeed = Multiplayer.IsReplay ? TickPatch.replayTimeSpeed : asyncTime.DesiredTimeSpeed; __result = asyncTime.ActualRateMultiplier(timeSpeed) == 0; diff --git a/Source/Client/AsyncTime/SetMapTime.cs b/Source/Client/AsyncTime/SetMapTime.cs index 538f482c7..0b3dc14d8 100644 --- a/Source/Client/AsyncTime/SetMapTime.cs +++ b/Source/Client/AsyncTime/SetMapTime.cs @@ -55,7 +55,7 @@ static void Prefix(Map __instance, ref TimeSnapshot? __state) } [HarmonyPriority(MpPriority.MpLast)] - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch] @@ -72,7 +72,7 @@ static void Prefix(Pawn pawn, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(pawn.MapHeld); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } // TODO 1.3: set time on the new renderer @@ -85,7 +85,7 @@ static void Prefix(PawnRenderer __instance, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn.MapHeld); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch(typeof(PawnTweener), nameof(PawnTweener.PreDrawPosCalculation))] @@ -97,7 +97,7 @@ static void Prefix(PawnTweener __instance, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn.Map); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch(typeof(DangerWatcher), nameof(DangerWatcher.DangerRating), MethodType.Getter)] @@ -109,7 +109,7 @@ static void Prefix(DangerWatcher __instance, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(__instance.map); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch] @@ -127,7 +127,7 @@ static void Prefix(Sustainer __instance, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(__instance.info.Maker.Map); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch(typeof(Sample), nameof(Sample.Update))] @@ -139,7 +139,7 @@ static void Prefix(Sample __instance, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(__instance.Map); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch(typeof(TipSignal), MethodType.Constructor, new[] { typeof(Func), typeof(int) })] @@ -228,12 +228,13 @@ public static TimeSnapshot Current() public static TimeSnapshot? GetAndSetFromMap(Map map) { - if (map == null) return null; + // No comp yet while the map is being generated + var mapComp = map?.AsyncTime(); + if (mapComp == null) return null; TimeSnapshot prev = Current(); var tickManager = Find.TickManager; - var mapComp = map.AsyncTime(); tickManager.ticksGameInt = mapComp.mapTicks; tickManager.slower = mapComp.slower; diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index 0aa757d9e..b4914fb48 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -50,9 +50,11 @@ public static IEnumerable AllTickables { yield return Multiplayer.AsyncWorldTime; + // A map being generated is in Find.Maps before MapSetup gives it its comps var maps = Find.Maps; for (int i = maps.Count - 1; i >= 0; i--) - yield return maps[i].AsyncTime(); + if (maps[i].AsyncTime() is { } comp) + yield return comp; } } @@ -305,7 +307,8 @@ public static float ActualRateMultiplier(this ITickable tickable, TimeSpeed spee var rate = Multiplayer.AsyncWorldTime.TickRateMultiplier(speed); foreach (var map in Find.Maps) - rate = Math.Min(rate, map.AsyncTime().TickRateMultiplier(speed)); + if (map.AsyncTime() is { } comp) + rate = Math.Min(rate, comp.TickRateMultiplier(speed)); return rate; } From 8ce438612579052d59613a812ff4390dc8017035 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:26:43 -0600 Subject: [PATCH 088/183] Handle Steam reconnects and free closed P2P sessions A join packet from a remote we still had a connection for was dropped as an error, so a player who rejoined before their old connection timed out waited for the host forever. A client sends the join packet once per connection, so the old connection is stale: replace it without a goodbye or close, which would hit the new connection sharing the Steam session. P2P sessions were never closed, so a kicked or rejected player's next connection reused the session without a new accept prompt. Close them on disconnect; after a goodbye, wait 3 s so Steam can send it first. The delayed closes run on the server thread next to the join handling and fall back to the main thread once no Steam server runs, so a stopping server doesn't leave them open. A new connection or session request from the remote cancels its pending close. A rejected prompt left the remote in pendingSteam, which ignored all of their later requests. Connections turned away before joining (server full, starting, throttled) now get the reason instead of hanging. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Networking/NetworkingSteam.cs | 97 ++++++++++++++++--- .../Networking/State/ClientSteamState.cs | 9 ++ Source/Client/Networking/SteamIntegration.cs | 52 +++++++--- Source/Client/OnMainThread.cs | 6 ++ .../Common/Networking/PendingSessionCloses.cs | 68 +++++++++++++ Source/Common/PlayerManager.cs | 9 ++ Source/Tests/ReconnectTest.cs | 97 +++++++++++++++++++ 7 files changed, 306 insertions(+), 32 deletions(-) create mode 100644 Source/Common/Networking/PendingSessionCloses.cs create mode 100644 Source/Tests/ReconnectTest.cs diff --git a/Source/Client/Networking/NetworkingSteam.cs b/Source/Client/Networking/NetworkingSteam.cs index 3f9534a61..b91269ecb 100644 --- a/Source/Client/Networking/NetworkingSteam.cs +++ b/Source/Client/Networking/NetworkingSteam.cs @@ -42,14 +42,6 @@ public void SendRawSteam(byte[] raw, bool reliable) public abstract void OnError(EP2PSessionError error); - protected override void OnClose(ServerDisconnectPacket? goodbye) - { - if (goodbye.HasValue) Send(goodbye.Value); - // TODO this should probably include SteamNetworking.CloseP2PSessionWithUser to free up any leftover - // resources in the Steam API. The API docs are not clear whether the connection is closed instantly, or - // are the queued packets sent. - } - public override string ToString() => $"SteamP2P ({remoteId}:{username})"; } @@ -57,9 +49,14 @@ public class SteamClientConn : SteamBaseConn, ITickableConnection { public SteamClientConn(CSteamID remoteId, string username) : base(remoteId, RandomChannelId(), 0) { + // A server hosted here before may still be about to close its session with this remote + SteamP2PIntegration.KeepSession(remoteId); ChangeState(new ClientSteamState(this, username)); } + // The client never sends a goodbye + protected override void OnClose(ServerDisconnectPacket? goodbye) => SteamP2PIntegration.CloseSession(remoteId); + static ushort RandomChannelId() => (ushort)new Random().Next(); public void Tick() @@ -97,6 +94,19 @@ public override void OnError(EP2PSessionError error) OnDisconnect(); } + protected override void OnClose(ServerDisconnectPacket? goodbye) + { + if (goodbye.HasValue) + { + Send(goodbye.Value); + SteamP2PIntegration.CloseSessionAfterFlush(remoteId); + } + // A disconnected connection may have been replaced by a new one from the same remote, which uses the + // same session (see SteamP2PNetManager.Tick) + else if (State != ConnectionStateEnum.Disconnected) + SteamP2PIntegration.CloseSession(remoteId); + } + public override void OnKeepAliveArrived(bool idMatched) { if (!idMatched) return; @@ -108,6 +118,8 @@ public override void OnKeepAliveArrived(bool idMatched) private void OnDisconnect() { + // The session failed, free it so that a reconnect asks for a new one + SteamP2PIntegration.CloseSession(remoteId); serverPlayer.Server.playerManager.SetDisconnected(this, MpDisconnectReason.ClientLeft); } } @@ -126,21 +138,36 @@ public static SteamP2PNetManager Create(MultiplayerServer server) public void Tick() { + var playerManager = server.playerManager; + + // Closed here rather than on a timer so that it can't race a reconnect handled below + SteamP2PIntegration.CloseDueSessions(remote => FindPlayer(remote) != null); + foreach (var packet in SteamP2PIntegration.ReadPackets(0)) { - var playerManager = server.playerManager; - var player = playerManager.Players - .FirstOrDefault(p => p.conn is SteamBaseConn conn && conn.remoteId == packet.remote); + var player = FindPlayer(packet.remote); - if (packet.joinPacket && player == null) + if (packet.joinPacket) { ConnectionBase conn = new SteamServerConn(packet.remote, packet.channel); + // The join packet arrived on the session of the remote's earlier connection: a goodbye sent on + // it is out by now and closing would cut off this one + SteamP2PIntegration.KeepSession(packet.remote); + + // A client sends the join packet once per connection, so the remote's connection we still + // have is stale (e.g. they restarted the game before it timed out). Without replacing it the + // join would be dropped and the client would wait for the host forever (#843). + if (player != null) + playerManager.ReplaceStale(player, conn); + var preConnect = playerManager.OnPreConnect(packet.remote); if (preConnect != null) { ServerLog.Log($"Rejected incoming connection from {packet.remote}: {preConnect}"); - conn.Close(preConnect.Value); + // Close doesn't send a goodbye before the connection has a server state + conn.Send(new ServerDisconnectPacket { reason = preConnect.Value, data = [] }); + SteamP2PIntegration.CloseSessionAfterFlush(packet.remote); continue; } @@ -155,18 +182,21 @@ public void Tick() conn.Send(Packets.Server_SteamAccept); } - else if (!packet.joinPacket && player != null) + else if (player != null) { player.HandleReceive(packet.data, packet.reliable); } else { - ServerLog.Error( - $"Received a join packet: {packet.joinPacket} for player: {player} (player should only be null when joinPacket is true)"); + // Packets still in flight when the server dropped the connection + ServerLog.Verbose($"Received a data packet from {packet.remote}, who has no connection"); } } } + private ServerPlayer FindPlayer(CSteamID remote) => + server.playerManager.Players.FirstOrDefault(p => p.conn is SteamBaseConn conn && conn.remoteId == remote); + public void Stop() { // Managed externally by Steamworks @@ -181,6 +211,41 @@ public static class SteamP2PIntegration { private static Callback p2pFail; + // Closing a P2P session discards the packets Steam hasn't sent yet, a goodbye gets this long to go out + private const int GoodbyeFlushMs = 3000; + private static readonly PendingSessionCloses pendingCloses = new(); + + /// Frees the P2P session with the remote. Their next connection then needs a new session, which shows the + /// host's accept prompt again, instead of silently reusing the old one (#843). + public static void CloseSession(CSteamID remote) + { + pendingCloses.Cancel(remote); + ServerLog.Log($"Closing Steam P2P session with {remote}"); + SteamNetworking.CloseP2PSessionWithUser(remote); + } + + public static void CloseSessionAfterFlush(CSteamID remote) => pendingCloses.Schedule(remote, GoodbyeFlushMs); + + /// The remote's session is in use by a new connection + public static void KeepSession(CSteamID remote) + { + if (pendingCloses.Cancel(remote)) + ServerLog.Verbose($"Keeping Steam P2P session with {remote} for a new connection"); + } + + /// Runs on the thread of the server handling Steam joins, or on the main thread when no such server runs. + /// The remote reconnected in the meantime and their connection uses the session + public static void CloseDueSessions(Func inUse = null) + { + foreach (var remote in pendingCloses.TakeDue()) + { + if (inUse?.Invoke(remote) == true) continue; + + ServerLog.Log($"Closing Steam P2P session with {remote} after its goodbye"); + SteamNetworking.CloseP2PSessionWithUser(remote); + } + } + public static void InitCallbacks() { p2pFail = Callback.Create(fail => diff --git a/Source/Client/Networking/State/ClientSteamState.cs b/Source/Client/Networking/State/ClientSteamState.cs index ee8b5305d..25b9ce446 100644 --- a/Source/Client/Networking/State/ClientSteamState.cs +++ b/Source/Client/Networking/State/ClientSteamState.cs @@ -1,5 +1,6 @@ using Multiplayer.Client.Networking; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Client { @@ -19,6 +20,14 @@ public void HandleSteamAccept(ByteReader data) { connection.ChangeState(new ClientJoiningState(connection, username)); } + + // The server turned the connection away (server full, starting, throttled) + [TypedPacketHandler] + public void HandleDisconnected(ServerDisconnectPacket packet) + { + ConnectionStatusListeners.TryNotifyAll_Disconnected(SessionDisconnectInfo.From(packet)); + Multiplayer.StopMultiplayer(); + } } } diff --git a/Source/Client/Networking/SteamIntegration.cs b/Source/Client/Networking/SteamIntegration.cs index 8bfa3f7cc..316f06af3 100644 --- a/Source/Client/Networking/SteamIntegration.cs +++ b/Source/Client/Networking/SteamIntegration.cs @@ -32,25 +32,45 @@ public static void InitCallbacks() sessionReq = Callback.Create(req => { - ServerLog.Log($"Received P2P session request from {req.m_steamIDRemote}"); + var remoteId = req.m_steamIDRemote; + ServerLog.Log($"Received P2P session request from {remoteId}"); var session = Multiplayer.session; - if (Multiplayer.LocalServer?.settings.steam == true && !session.pendingSteam.Contains(req.m_steamIDRemote)) + if (Multiplayer.LocalServer?.settings.steam != true || session == null) return; + + // The remote opens a new session, a delayed close of the old one would tear it down + SteamP2PIntegration.KeepSession(remoteId); + + if (Multiplayer.settings.autoAcceptSteam) { - if (Multiplayer.settings.autoAcceptSteam) - SteamNetworking.AcceptP2PSessionWithUser(req.m_steamIDRemote); - else + SteamNetworking.AcceptP2PSessionWithUser(remoteId); + // A prompt from before auto accept was turned on + session.pendingSteam.Remove(remoteId); + } + // Steam repeats the request while the remote keeps sending, the prompt for it is still unanswered + else if (session.pendingSteam.Contains(remoteId)) + { + return; + } + else + { + session.pendingSteam.Add(remoteId); + PendingPlayerWindow.EnqueueJoinRequest(remoteId, (joinReq, accepted) => { - session.pendingSteam.Add(req.m_steamIDRemote); - PendingPlayerWindow.EnqueueJoinRequest(req.m_steamIDRemote, (joinReq, accepted) => - { - if(joinReq.steamId.HasValue && accepted) AcceptPlayerJoinRequest(joinReq.steamId.Value); - }); - } - session.knownUsers.Add(req.m_steamIDRemote); - session.NotifyChat(); - - SteamFriends.RequestUserInformation(req.m_steamIDRemote, true); + if (!joinReq.steamId.HasValue) return; + if (accepted) + AcceptPlayerJoinRequest(joinReq.steamId.Value); + else + // Rejecting leaves the request unanswered. Forget it, or the player's later requests + // would be ignored as duplicates of this one. + session.pendingSteam.Remove(joinReq.steamId.Value); + }); } + + if (!session.knownUsers.Contains(remoteId)) + session.knownUsers.Add(remoteId); + session.NotifyChat(); + + SteamFriends.RequestUserInformation(remoteId, true); }); friendRchpUpdate = Callback.Create(update => @@ -85,7 +105,7 @@ public static void InitCallbacks() public static void AcceptPlayerJoinRequest(CSteamID id) { SteamNetworking.AcceptP2PSessionWithUser(id); - Multiplayer.session.pendingSteam.Remove(id); + Multiplayer.session?.pendingSteam.Remove(id); Messages.Message("MpSteamAccepted".Translate(), MessageTypeDefOf.PositiveEvent, false); } diff --git a/Source/Client/OnMainThread.cs b/Source/Client/OnMainThread.cs index 866c50248..2cb17800e 100644 --- a/Source/Client/OnMainThread.cs +++ b/Source/Client/OnMainThread.cs @@ -27,8 +27,14 @@ public void Update() RunScheduled(); if (SteamManager.Initialized) + { SteamIntegration.UpdateRichPresence(); + // A running Steam server closes them on its thread, in step with the joins it handles + if (Multiplayer.LocalServer is not { running: true, settings.steam: true }) + SteamP2PIntegration.CloseDueSessions(); + } + if (Multiplayer.Client == null) return; Multiplayer.session.Update(); diff --git a/Source/Common/Networking/PendingSessionCloses.cs b/Source/Common/Networking/PendingSessionCloses.cs new file mode 100644 index 000000000..9f90c615a --- /dev/null +++ b/Source/Common/Networking/PendingSessionCloses.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Multiplayer.Common; + +/// +/// Transport sessions waiting to be closed, by remote. Closing a session can discard packets the transport hasn't +/// sent yet, so a close that follows a goodbye waits a little. Thread safe: the server thread schedules and takes +/// closes while it runs, another thread takes the rest once it stopped. +/// +public class PendingSessionCloses where TRemote : notnull +{ + private readonly object locker = new(); + private readonly Dictionary dueAt = new(); + private readonly Func clockMs; + + public PendingSessionCloses(Func? clockMs = null) + { + if (clockMs == null) + { + var watch = Stopwatch.StartNew(); + clockMs = () => watch.ElapsedMilliseconds; + } + + this.clockMs = clockMs; + } + + public int Count + { + get { lock (locker) return dueAt.Count; } + } + + /// Replaces an earlier schedule for the remote: the later goodbye needs the time too. + public void Schedule(TRemote remote, int delayMs) + { + lock (locker) + dueAt[remote] = clockMs() + delayMs; + } + + /// Returns whether a close was pending. + public bool Cancel(TRemote remote) + { + lock (locker) + return dueAt.Remove(remote); + } + + /// Removes and returns the remotes whose delay is over. + public IReadOnlyList TakeDue() + { + lock (locker) + { + if (dueAt.Count == 0) return Array.Empty(); + + var now = clockMs(); + List? due = null; + foreach (var entry in dueAt) + if (now >= entry.Value) + (due ??= []).Add(entry.Key); + + if (due == null) return Array.Empty(); + + foreach (var remote in due) + dueAt.Remove(remote); + return due; + } + } +} diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index 464cc078c..e4c05eb35 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -62,6 +62,15 @@ public ServerPlayer OnConnected(ConnectionBase conn) return conn.serverPlayer; } + /// Drops a connection superseded by a new one from the same remote: its client is gone. Unlike Disconnect, + /// nothing is sent or closed. Steam carries all connections with a remote over one session, so the goodbye + /// would only reach the new client and closing would cut it off. + public void ReplaceStale(ServerPlayer stale, ConnectionBase replacement) + { + ServerLog.Log($"Replacing stale connection {stale.conn} with {replacement}"); + SetDisconnected(stale.conn, MpDisconnectReason.ClientLeft); + } + public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) { if (conn.State == ConnectionStateEnum.Disconnected) return; diff --git a/Source/Tests/ReconnectTest.cs b/Source/Tests/ReconnectTest.cs new file mode 100644 index 000000000..37f2afb7b --- /dev/null +++ b/Source/Tests/ReconnectTest.cs @@ -0,0 +1,97 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[TestFixture] +public class ReconnectTest +{ + private long now; + + [SetUp] + public void SetUp() => now = 0; + + [Test] + public void PendingCloseIsDueAfterItsDelayOnce() + { + var closes = new PendingSessionCloses(() => now); + closes.Schedule(1, 3000); + + now = 2999; + Assert.That(closes.TakeDue(), Is.Empty); + + now = 3000; + Assert.That(closes.TakeDue(), Is.EqualTo(new[] { 1 })); + Assert.That(closes.TakeDue(), Is.Empty); + Assert.That(closes.Count, Is.Zero); + } + + [Test] + public void CancelledCloseNeverComesDue() + { + var closes = new PendingSessionCloses(() => now); + closes.Schedule(1, 3000); + closes.Schedule(2, 3000); + + Assert.That(closes.Cancel(1), Is.True); + Assert.That(closes.Cancel(1), Is.False); + + now = 5000; + Assert.That(closes.TakeDue(), Is.EqualTo(new[] { 2 })); + } + + [Test] + public void RescheduledCloseWaitsForTheLaterGoodbye() + { + var closes = new PendingSessionCloses(() => now); + closes.Schedule(1, 3000); + + now = 2000; + closes.Schedule(1, 3000); + + now = 4000; + Assert.That(closes.TakeDue(), Is.Empty); + + now = 5000; + Assert.That(closes.TakeDue(), Is.EqualTo(new[] { 1 })); + } + + [Test] + public void ReplaceStaleDropsTheOldConnectionWithoutClosingIt() + { + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", direct = false, lan = false + }); + + try + { + var other = AddPlayer(server, 0, "other"); + var stale = AddPlayer(server, 1, "stale"); + var replacement = new RecordingConnection("stale"); + + server.playerManager.ReplaceStale(stale, replacement); + + Assert.That(server.playerManager.Players, Is.EqualTo(new[] { other })); + Assert.That(stale.conn.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + // A goodbye would reach the reconnecting client on transports which share the session (Steam) + Assert.That(((RecordingConnection)stale.conn).Goodbye, Is.Null); + Assert.That(((RecordingConnection)stale.conn).SentPackets, Is.Empty); + Assert.That(((RecordingConnection)other.conn).SentPackets, Does.Contain(Packets.Server_PlayerList)); + } + finally + { + MultiplayerServer.instance = null; + } + } + + private static ServerPlayer AddPlayer(MultiplayerServer server, int id, string username) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(id, conn) { hasJoined = true, status = PlayerStatus.Playing }; + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return player; + } +} From 0b4fde82e296caa540d8507856d12ca0cdefb9b5 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:27:23 -0600 Subject: [PATCH 089/183] Keep multifaction storytellers off other player factions' maps Every faction's storyteller ticks on every map; Map_Misc (visiting colonists) still let faction A's incidents fire on faction B's home map. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/StorytellerPatches.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index f8068341e..cfcc1e7d8 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -39,7 +39,8 @@ static void Postfix(List __result) if (Multiplayer.MapContext != null) { __result.Clear(); - __result.Add(Multiplayer.MapContext); + if (FactionCanReceiveIncidentsOn(Multiplayer.MapContext, Faction.OfPlayer)) + __result.Add(Multiplayer.MapContext); } else if (AsyncWorldTimeComp.tickingWorld) { @@ -52,6 +53,21 @@ static void Postfix(List __result) __result.Add(Find.World); } } + + // In multifaction every player faction's storyteller ticks on every map (FactionRepeater). Keep each one + // to its own maps and to unowned maps (sites etc.) where it has people, so faction A's incidents + // (e.g. Map_Misc ones while A's colonists visit) don't land on faction B's home. + static bool FactionCanReceiveIncidentsOn(Map map, Faction faction) + { + if (!Multiplayer.GameComp.multifaction) + return true; + + if (map.ParentFaction == faction) + return true; + + return map.ParentFaction is not { IsPlayer: true } && + map.mapPawns.SpawnedPawnsInFaction(faction).Any(p => p.RaceProps.Humanlike); + } } // The MP Mod's ticker calls Storyteller.StorytellerTick() on both the World and each Map, each tick From fb00a6e163aad01c5bb7b9b3d049ec65739c5861 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:27:23 -0600 Subject: [PATCH 090/183] Don't re-enter the map context for nested incident execution A nested IncidentWorker.TryExecute during the world tick reset tickingMap and popped the outer incident's map context when it returned. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/StorytellerPatches.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index cfcc1e7d8..4b3b32442 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -122,6 +122,11 @@ static class MapContextIncidentExecute { static void Prefix(IncidentParms parms, ref Map __state) { + // Incidents can execute nested ones (tickingWorld stays set); entering the context again would + // clear tickingMap and pop the outer context's state when the inner call returns + if (AsyncTimeComp.tickingMap != null) + return; + if (AsyncWorldTimeComp.tickingWorld && parms.target is Map map) { AsyncTimeComp.tickingMap = map; From f3b6ca5f44a51406b2cb20908299ea706989a05e Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:28:28 -0600 Subject: [PATCH 091/183] Offer the growth moment window only to the child's faction A player of another faction with a pawn on the child's map got the colonist bar option and could choose the child's traits; the window is otherwise only opened for the child's own players. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Persistent/GrowthMomentSession.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/Client/Persistent/GrowthMomentSession.cs b/Source/Client/Persistent/GrowthMomentSession.cs index 9d34eb115..4b2ebab7f 100644 --- a/Source/Client/Persistent/GrowthMomentSession.cs +++ b/Source/Client/Persistent/GrowthMomentSession.cs @@ -55,7 +55,8 @@ public static GrowthMomentSession GetSessionFor(Pawn pawn) public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { - if (Map != entry.map || !IsActive) return null; + // Another faction's player with a pawn on the child's map mustn't choose the child's traits + if (Map != entry.map || !IsActive || !IsLocalPlayersChild) return null; return new FloatMenuOption("MpGrowthMomentSession".Translate(Pawn.Name.ToStringShort), OpenWindow); } From 244f8ef75bed84919014ff32b27c5df92bc40af5 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:29:03 -0600 Subject: [PATCH 092/183] Guard FixIdeoAfterCopy against non-fluid ideos and run it in ticks Ideo.CopyTo leaves development null for a non-fluid target, so the postfix threw after the copy; precept id fixing also applies to copies made from simulation code. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/Game/SyncMethods.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index c2f619ca4..221ee2f88 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -748,14 +748,17 @@ static void AddBill_Prefix(Bill bill) [MpPostfix(typeof(Ideo), nameof(Ideo.CopyTo))] static void FixIdeoAfterCopy(Ideo __instance, Ideo ideo) { - if (Multiplayer.ExecutingCmds) + // Ticking too: mods can apply a reform from simulation code (e.g. ritual outcomes) + if (Multiplayer.ExecutingCmds || Multiplayer.Ticking) { // Fix ids for precepts generated by the fluid ideo reforming UI foreach (var precept in ideo.PreceptsListForReading) if (precept.ID < 0) precept.ID = Find.UniqueIDsManager.GetNextPreceptID(); - ideo.development.ideo = ideo; + // CopyTo creates the development tracker only for fluid ideos + if (ideo.development != null) + ideo.development.ideo = ideo; ideo.style.ideo = ideo; } } From cd6d1f5273dab4ec32a2a10091185818be8b4e48 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:30:10 -0600 Subject: [PATCH 093/183] Stop the server once, on its own thread TryStop ran twice (Run's exit, then the main thread or the bootstrap handler) and nulled the static instance unconditionally. The bootstrap upload called it in the middle of a server tick, so the rest of the iteration ran without an instance. A slow test server thread could clear the next test's instance. TryStop is now latched, clears the instance only if it is still this server, and from another thread waits (5 s at most) for the server thread, which stops the server on its way out of Run. The bootstrap only clears running. Test servers get their state before the server thread starts instead of racing it. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Multiplayer.cs | 3 +- Source/Common/MultiplayerServer.cs | 21 ++++- .../Networking/State/ServerBootstrapState.cs | 2 +- Source/Tests/ServerStopTest.cs | 77 +++++++++++++++++++ Source/Tests/ServerTest.cs | 19 ++--- 5 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 Source/Tests/ServerStopTest.cs diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 00a625911..1de147d22 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -221,8 +221,7 @@ public static void StopMultiplayer() if (LocalServer != null) { - LocalServer.running = false; - localServerThread?.Join(); + // Waits for the server thread, which stops the server on its way out LocalServer.TryStop(); LocalServer = null; } diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index f464b2116..22cd0b2c2 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -100,6 +100,8 @@ public MultiplayerServer(ServerSettings settings) public void Run() { serverThread = Thread.CurrentThread; + // Pairs with TryStop: either it sees this thread and joins it, or this sees running cleared + Thread.MemoryBarrier(); ServerLog.Detail("Server started"); Stopwatch time = Stopwatch.StartNew(); @@ -211,14 +213,31 @@ private void TickNet() serverTimePerTick = StandardTimePerTick * 4f; } + public static readonly TimeSpan StopTimeout = TimeSpan.FromSeconds(5); + private int stopped; + + /// Stops the server once, later calls do nothing. Server state isn't thread safe, so called from another + /// thread, this waits for the server thread to finish its iteration and stop the server on its way out + /// (Run). Only if that takes too long is the server stopped from the calling thread. public void TryStop() { + running = false; + Thread.MemoryBarrier(); + + var thread = serverThread; + if (thread != null && thread != Thread.CurrentThread && !thread.Join(StopTimeout)) + ServerLog.Error($"The server thread didn't stop within {StopTimeout.TotalSeconds}s, " + + $"stopping the server from thread {Thread.CurrentThread.ManagedThreadId}"); + + if (Interlocked.Exchange(ref stopped, 1) != 0) return; + ServerLog.Detail("Server shutting down..."); playerManager.OnServerStop(); netManagers.ForEach(manager => manager.Stop()); - instance = null; + // A server stopping late mustn't clear the instance of the next one + Interlocked.CompareExchange(ref instance, null, this); } public void Enqueue(Action action) diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index ee1831f49..f423ba429 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -91,8 +91,8 @@ public void HandleSaveData(ClientBootstrapSaveDataPacket packet) player.conn.Send(new ServerDisconnectPacket { reason = MpDisconnectReason.BootstrapCompleted, data = Array.Empty() }); ResetUploadState(); + // Run stops the server once this iteration is over, the rest of it still needs the server Server.running = false; - Server.TryStop(); } [FragmentedPacketHandler(Packets.Client_BootstrapSave)] diff --git a/Source/Tests/ServerStopTest.cs b/Source/Tests/ServerStopTest.cs new file mode 100644 index 000000000..44558f44f --- /dev/null +++ b/Source/Tests/ServerStopTest.cs @@ -0,0 +1,77 @@ +using Multiplayer.Common; + +namespace Tests; + +[TestFixture] +[NonParallelizable] +public class ServerStopTest +{ + private class StopRecorder : INetManager + { + public readonly List stopThreads = new(); + public volatile bool ticked; + + public void Tick() => ticked = true; + public void Stop() { lock (stopThreads) stopThreads.Add(Environment.CurrentManagedThreadId); } + public string GetDiagnosticsName() => "test"; + public string? GetDiagnosticsInfo() => null; + } + + private static MultiplayerServer MakeServer(StopRecorder recorder) + { + var server = new MultiplayerServer(new ServerSettings { gameName = "Test", direct = false, lan = false }) + { + running = true + }; + server.netManagers.Add(recorder); + return server; + } + + [TearDown] + public void TearDown() => MultiplayerServer.instance = null; + + [Test] + public void StopFromAnotherThreadIsDoneByTheServerThreadOnce() + { + var recorder = new StopRecorder(); + var server = MultiplayerServer.instance = MakeServer(recorder); + var thread = new Thread(server.Run) { IsBackground = true }; + thread.Start(); + SpinWait.SpinUntil(() => recorder.ticked, 2000); + + server.TryStop(); + server.TryStop(); + + Assert.That(thread.IsAlive, Is.False); + Assert.That(recorder.stopThreads, Is.EqualTo(new[] { thread.ManagedThreadId })); + Assert.That(MultiplayerServer.instance, Is.Null); + } + + [Test] + public void ServerStoppingLateKeepsTheNewerInstance() + { + var old = MakeServer(new StopRecorder()); + var newer = MultiplayerServer.instance = MakeServer(new StopRecorder()); + + old.TryStop(); + + Assert.That(MultiplayerServer.instance, Is.SameAs(newer)); + } + + [Test] + public void ServerThreadExitStopsTheServer() + { + var recorder = new StopRecorder(); + var server = MultiplayerServer.instance = MakeServer(recorder); + var thread = new Thread(server.Run) { IsBackground = true }; + thread.Start(); + SpinWait.SpinUntil(() => recorder.ticked, 2000); + + // What the bootstrap and the stop command do from the server thread + server.running = false; + + Assert.That(thread.Join(2000), Is.True); + Assert.That(recorder.stopThreads, Is.EqualTo(new[] { thread.ManagedThreadId })); + Assert.That(MultiplayerServer.instance, Is.Null); + } +} diff --git a/Source/Tests/ServerTest.cs b/Source/Tests/ServerTest.cs index 4c47b0860..8bec8bac9 100644 --- a/Source/Tests/ServerTest.cs +++ b/Source/Tests/ServerTest.cs @@ -76,9 +76,11 @@ public async Task JoinPointAbortUnblocksWaiters() [Test] public void LoadingStateHandlesKeepAliveWhileWaitingForJoinPoint() { - var server = MakeServer(out var port); - AddHost(server); - Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + var server = MakeServer(out var port, s => + { + AddHost(s); + Assert.That(s.worldData.TryStartJoinPointCreation(true), Is.True); + }); _ = Task.Run(async () => { @@ -166,7 +168,8 @@ private void ConnectClient(int port, Type joiningStateType) }) { IsBackground = true }.Start(); } - private MultiplayerServer MakeServer(out int port) + /// beforeStart sets up server state before the server thread runs, which owns it from then on + private MultiplayerServer MakeServer(out int port, Action? beforeStart = null) { var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings { @@ -190,14 +193,12 @@ private MultiplayerServer MakeServer(out int port) port = liteNet.netManagers[0].manager.LocalPort; + beforeStart?.Invoke(server); + var serverThread = new Thread(server.Run) { IsBackground = true }; serverThread.Start(); - teardownActions.Add(() => - { - server.running = false; - serverThread.Join(timeout: TimeSpan.FromSeconds(2)); - }); + teardownActions.Add(server.TryStop); return server; } From 64e3aef594310e0b89ff9c9e53ed752b1f5c9388 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:30:33 -0600 Subject: [PATCH 094/183] Keep the grav engine cache out of the interface's reach GravshipUtility caches each map's grav engine (behind Map.IsPlayerHome) for the current TicksGame. The interface runs at the current map's time, which is also the time of the commands before that map's next tick and of its MapPreTick, so the simulation could read a value the interface cached before a command changed the map, on some clients only. Start every tick, command and simulated long event with an empty cache. In multifaction the lookup also took another player's engine, making a map with any player's gravship a home of every player faction. Under a player faction's context skip other player factions' engines (unowned ones still count, the spectator context keeps vanilla's result). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/LongEvents.cs | 1 + Source/Client/Patches/Odyssey.cs | 89 +++++++++++++++++++++++++++++ Source/Client/Patches/TickPatch.cs | 10 +++- 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/Source/Client/Patches/LongEvents.cs b/Source/Client/Patches/LongEvents.cs index 4833cd22f..a48262f86 100644 --- a/Source/Client/Patches/LongEvents.cs +++ b/Source/Client/Patches/LongEvents.cs @@ -203,6 +203,7 @@ public void Enter() current = this; Rand.PushState(seed); + GravEngineCache.Reset(); if (faction != null && game == Multiplayer.game) { diff --git a/Source/Client/Patches/Odyssey.cs b/Source/Client/Patches/Odyssey.cs index 460f3f20d..5f04978f0 100644 --- a/Source/Client/Patches/Odyssey.cs +++ b/Source/Client/Patches/Odyssey.cs @@ -47,6 +47,95 @@ static void Prefix(ref bool ___haveShownNameDialog) } } + // Vanilla caches each map's grav engine for the current TicksGame (GravshipUtility.GetPlayerGravEngine_NewTemp, + // behind Map.IsPlayerHome). The interface calls it too, at the current map's time: the same TicksGame as the + // commands executed before that map's next tick and its MapPreTick. A value the interface cached before a command + // changed the map would then be used by the simulation on some clients only. Every tick and command starts with an + // empty cache instead, so its content depends only on the simulation. + public static class GravEngineCache + { + private static readonly Dictionary<(Map, Faction), Building_GravEngine> factionEngines = new(); + private static int factionEnginesTick = -1; + private static readonly List tmpThings = new(); + + public static void Reset() + { + GravshipUtility.tmpMapToGravship.Clear(); + GravshipUtility.lastCachedEngineTick = -1; + + factionEngines.Clear(); + factionEnginesTick = -1; + } + + public static bool OfOtherPlayer(Thing engine, Faction faction) => + engine.Faction is { IsPlayer: true } f && f != faction; + + // Cached like vanilla's lookup + public static Building_GravEngine GetEngineOf(Map map, Faction faction) + { + if (Find.TickManager.TicksGame != factionEnginesTick) + { + factionEngines.Clear(); + factionEnginesTick = Find.TickManager.TicksGame; + } + + if (!factionEngines.TryGetValue((map, faction), out var engine)) + factionEngines[(map, faction)] = engine = FindEngine(map, faction); + + return engine; + } + + // Vanilla's search order: spawned engines, spawned minified ones, then minified ones held in containers + private static Building_GravEngine FindEngine(Map map, Faction faction) + { + var def = ThingDefOf.GravEngine; + + foreach (var t in map.listerThings.ThingsOfDef(def)) + if (t is Building_GravEngine e && !OfOtherPlayer(e, faction)) + return e; + + foreach (var t in map.listerThings.ThingsOfDef(def.minifiedDef)) + if (t.GetInnerIfMinified() is Building_GravEngine e && !OfOtherPlayer(e, faction)) + return e; + + try + { + ThingOwnerUtility.GetAllThingsRecursively(map, ThingRequest.ForDef(def.minifiedDef), tmpThings, + allowUnreal: true, null, alsoGetSpawnedThings: false); + + foreach (var t in tmpThings) + if (t.GetInnerIfMinified() is Building_GravEngine e && !OfOtherPlayer(e, faction)) + return e; + } + finally + { + tmpThings.Clear(); + } + + return null; + } + } + + // Multifaction: vanilla takes the first grav engine on the map, which can be another player's (making the map a + // home of every player faction, see Map.IsPlayerHome). Under a player faction's context look for one that isn't + // another player faction's (unowned engines count, as in vanilla). The spectator context (world ticks, maps of no + // player, see Map_IsPlayerHome_Spectator_Patch) keeps vanilla's result. + [HarmonyPatch(typeof(GravshipUtility), nameof(GravshipUtility.GetPlayerGravEngine_NewTemp))] + static class GetPlayerGravEngineFactionPatch + { + static void Postfix(Map map, ref Building_GravEngine __result) + { + if (__result == null || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) return; + + var faction = Faction.OfPlayer; + if (faction == null || faction == Multiplayer.WorldComp.spectatorFaction || + !GravEngineCache.OfOtherPlayer(__result, faction)) + return; + + __result = GravEngineCache.GetEngineOf(map, faction); + } + } + [HarmonyPatch(typeof(CompStatue), nameof(CompStatue.InitFakePawn))] public static class PatchInitFakePawnToNotSyncPawnName { diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index 0aa757d9e..1418e98a4 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -6,6 +6,7 @@ using HarmonyLib; using LudeonTK; using Multiplayer.Client.AsyncTime; +using Multiplayer.Client.Patches; using Multiplayer.Common; using Multiplayer.Common.Networking.Packet; using RimWorld.Planet; @@ -196,7 +197,12 @@ private static bool RunCmds() if (target == null) { Log.Error($"!!! Tickable of {cmd.mapId} not found! {cmd}"); - } else target.ExecuteCmd(cmd); + } + else + { + GravEngineCache.Reset(); + target.ExecuteCmd(cmd); + } if (LongEventHandler.eventQueue.Count > 0) return true; // Yield to e.g. join-point creation } @@ -241,6 +247,7 @@ public static bool DoTick(ref bool worked) TickTickable(tickable); } + GravEngineCache.Reset(); ConstantTicker.Tick(); ticksToRun -= 1; @@ -268,6 +275,7 @@ private static void TickTickable(ITickable tickable) try { + GravEngineCache.Reset(); tickable.Tick(); } catch (Exception e) From 6b109e5489b9bf5b2384a706c03e1010947035e4 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:31:59 -0600 Subject: [PATCH 095/183] Don't report sends to a disconnecting LiteNet peer as errors LiteNetLib changes the peer state on its own thread and the server only hears of a disconnect on its next poll, so the rest of that tick still sends to the peer (time control, keep alives). That logged an error and failed ServerTest now and then. Log those at verbose level, other bad states stay errors. StandaloneJoinWithExistingPlayer waited for one player, which the existing player alone satisfies: wait for the client to come and go. Co-Authored-By: Claude Opus 5.5 --- Source/Common/Networking/LiteNetConnection.cs | 8 +++-- Source/Tests/ServerTest.cs | 30 ++++++++----------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/Source/Common/Networking/LiteNetConnection.cs b/Source/Common/Networking/LiteNetConnection.cs index b3a8c5d05..7ddb5298c 100644 --- a/Source/Common/Networking/LiteNetConnection.cs +++ b/Source/Common/Networking/LiteNetConnection.cs @@ -9,10 +9,14 @@ public class LiteNetConnection(NetPeer peer) : ConnectionBase protected override void SendRaw(byte[] raw, bool reliable) { - if (peer.ConnectionState == ConnectionState.Connected) + var state = peer.ConnectionState; + if (state == ConnectionState.Connected) peer.Send(raw, reliable ? DeliveryMethod.ReliableOrdered : DeliveryMethod.Unreliable); + // LiteNetLib's thread changes the state, the server hears of the disconnect only on its next poll + else if ((state & (ConnectionState.ShutdownRequested | ConnectionState.Disconnected)) != 0) + ServerLog.Verbose($"Dropping a packet for {peer}, which is disconnecting ({state})"); else - ServerLog.Error($"SendRaw() called with invalid connection state ({peer}): {peer.ConnectionState}"); + ServerLog.Error($"SendRaw() called with invalid connection state ({peer}): {state}"); } protected override void OnClose(ServerDisconnectPacket? goodbye) diff --git a/Source/Tests/ServerTest.cs b/Source/Tests/ServerTest.cs index 8bec8bac9..dc49cc1fd 100644 --- a/Source/Tests/ServerTest.cs +++ b/Source/Tests/ServerTest.cs @@ -121,28 +121,22 @@ private static void WaitUntil(Func condition) [Test] public void StandaloneJoinWithExistingPlayer_DoesNotStartJoinPoint() { - var server = MakeServer(out var port); - server.IsStandaloneServer = true; + var server = MakeServer(out var port, s => + { + s.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); + var existingConn = new RecordingConnection("existing"); + existingConn.ChangeState(ConnectionStateEnum.ServerPlaying); + var existingPlayer = new ServerPlayer(100, existingConn); + existingConn.serverPlayer = existingPlayer; + s.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); - } + // The existing player alone makes the count 1: wait for the client to come and go + WaitUntil(() => server.playerManager.Players.Count == 2); + WaitUntil(() => server.playerManager.Players.Count == 1); Assert.That(server.worldData.CreatingJoinPoint, Is.False); } From 7399c62716f8a02ad12b5a39cee44a428f58416b Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:32:15 -0600 Subject: [PATCH 096/183] Keep other players out of trade requests and ritual goodwill Multifaction: Settlement.Visitable only excludes Faction.OfPlayer's settlements, so trade request quests (and caravan visits) could target another player's colony, taking a quest slot of a real faction. The NearbyFactionGoodwill ritual reward could likewise raise the goodwill with the nearest other player (#984). The goodwill fix is limited to the ritual reward's settlement search: CanChangeGoodwillFor in general is also used between players. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/MultifactionPatches.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index 87909aa08..6a7dfd09b 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -949,3 +949,53 @@ static IEnumerable Transpiler(IEnumerable inst } } } + +// Multifaction: trade request quests (QuestNode_GetNearbySettlement) and caravan/transporter visits could pick another +// player's settlement, which has nothing to trade. Vanilla only excludes the settlements of Faction.OfPlayer. +[HarmonyPatch(typeof(Settlement), nameof(Settlement.Visitable), MethodType.Getter)] +static class Settlement_Visitable_OtherPlayers_Patch +{ + static void Postfix(Settlement __instance, ref bool __result) + { + if (__result && Multiplayer.Client != null && Multiplayer.GameComp.multifaction && + __instance.Faction is { IsPlayer: true }) + __result = false; + } +} + +// The ritual reward "goodwill with the nearest faction" could pick another player's settlement and change the +// goodwill between the two players. Skip player factions only here: CanChangeGoodwillFor itself is also used for +// goodwill between players (e.g. friendly fire), which has to keep working. +[HarmonyPatch(typeof(RitualAttachableOutcomeEffectWorker_NearbyFactionGoodwill), + nameof(RitualAttachableOutcomeEffectWorker_NearbyFactionGoodwill.Apply))] +static class RitualNearbyFactionGoodwill_SkipPlayers_Patch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var canChangeGoodwillFor = AccessTools.Method(typeof(Faction), nameof(Faction.CanChangeGoodwillFor)); + var replacement = AccessTools.Method(typeof(RitualNearbyFactionGoodwill_SkipPlayers_Patch), nameof(CanChangeGoodwillFor)); + var replaced = false; + + foreach (var ci in insts) + { + if (ci.Calls(canChangeGoodwillFor)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + replaced = true; + } + + yield return ci; + } + + if (!replaced) + Log.Error("Multiplayer: CanChangeGoodwillFor not found in RitualAttachableOutcomeEffectWorker_NearbyFactionGoodwill.Apply"); + } + + static bool CanChangeGoodwillFor(Faction faction, Faction other, int goodwillChange) + { + if (Multiplayer.Client != null && faction is { IsPlayer: true }) + return false; + return faction.CanChangeGoodwillFor(other, goodwillChange); + } +} From e2a589f7e03c262f65674538f4dac0989edbf2c0 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:32:23 -0600 Subject: [PATCH 097/183] Clear local starting pawn requests before faction map generation PrepForMapGen registers custom xenotypes from the client-local static generation requests, so the shared custom xenotype database diverged after faction creation. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/FactionCreator.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index b4529aee5..1e819020e 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -118,6 +118,12 @@ private static Map GenerateNewMap(PlanetTile tile, Scenario scenario, bool setup { // This has to be null, otherwise, during map generation, Faction.OfPlayer returns it which breaks FactionContext Find.GameInitData.playerFaction = null; + + // PrepForMapGen adds the custom xenotypes of the static (client-local) starting pawn generation requests + // to the game's customXenotypeDatabase. Only the issuer has its requests there (others may have stale + // ones), and the database feeds factionless pawn generation, so clear them on every client. The pawns + // keep their genes; their custom xenotype just isn't added to the database. + StartingPawnUtility.StartingAndOptionalPawnGenerationRequests.Clear(); Find.GameInitData.PrepForMapGen(); // ScenPart_PlayerFaction --> PreMapGenerate From 1c20d175ccde5bf7563bb3fa4a55413b4a85fc58 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:32:37 -0600 Subject: [PATCH 098/183] Guard null maps and jobs in three patches The world time speed query threw in Where before reaching the existing null check on a map's async time. Despawning an unspawned thing (vanilla only logs an error) threw in our forbiddables prefix. The teaching work giver prefix threw for a student without a current job. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 2 +- Source/Client/Factions/Forbiddables.cs | 3 ++- Source/Client/Patches/TeachingPatches.cs | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 091c45009..0db80e41a 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -50,7 +50,7 @@ public TimeSpeed DesiredTimeSpeed get => !Find.Maps.Any() ? timeSpeedInt : Find.Maps.Select(m => m.AsyncTime()) - .Where(a => a.ActualRateMultiplier(a.DesiredTimeSpeed) != 0f) + .Where(a => a != null && a.ActualRateMultiplier(a.DesiredTimeSpeed) != 0f) .Max(a => a?.DesiredTimeSpeed) ?? TimeSpeed.Paused; set => timeSpeedInt = value; } diff --git a/Source/Client/Factions/Forbiddables.cs b/Source/Client/Factions/Forbiddables.cs index 590e9d58e..cce6ce6cd 100644 --- a/Source/Client/Factions/Forbiddables.cs +++ b/Source/Client/Factions/Forbiddables.cs @@ -116,7 +116,8 @@ static class ThingDespawnUnsetForbidden static void Prefix(Thing __instance) { if (Multiplayer.Client == null) return; - __instance.Map.MpComp().Notify_ThingDespawned(__instance); + // Despawning an unspawned thing only logs an error in vanilla + __instance.Map?.MpComp()?.Notify_ThingDespawned(__instance); } } } diff --git a/Source/Client/Patches/TeachingPatches.cs b/Source/Client/Patches/TeachingPatches.cs index dd229f8df..f03e9d59b 100644 --- a/Source/Client/Patches/TeachingPatches.cs +++ b/Source/Client/Patches/TeachingPatches.cs @@ -14,8 +14,8 @@ static bool Prefix(Thing t, ref Job __result) // FloatMenuMakerMap.AddJobGiverWorkOrders despite not assigning the job yet. if (!Multiplayer.InInterface) return true; - // If target is not pawn, no result - if (t is not Pawn pawn) + // If target is not pawn (or has no lesson to give), no result + if (t is not Pawn pawn || pawn.CurJob == null) return false; // Alternative approach would be to store the current target of the lesson taking pawn in the From 6ad2ef24fe64dd7745e5524612be5d17cea4948b Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:34:45 -0600 Subject: [PATCH 099/183] Count the host's view for VTR, reset viewer counts of earlier sessions The host's game is created by a save and reload, which doesn't report view changes, so the host's own map was never counted and ran at the no-viewer rate (projectiles moving in 15 tick steps) until the host switched maps. The host now announces its initial view after starting the server; the arbiter, which views nothing, no longer counts as a viewer. Since viewer counts are saved (7b666d7), a game hosted from a replay or a standalone server started from a save kept the counts of players of that earlier session, whose leaving the new server never sees. Hosting zeroes them before the first snapshot; a standalone server issues a server-only ResetPlayerCounts command before anyone can connect. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 1 + Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 4 ++++ Source/Client/Networking/HostUtil.cs | 5 ++++ Source/Client/Patches/VTRSyncPatch.cs | 23 ++++++++++++++++++- Source/Common/CommandType.cs | 3 +++ Source/Server/Server.cs | 6 +++++ Source/Tests/ServerPlayingStateTest.cs | 1 + 7 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index af7cf62e3..5c1883db4 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -88,6 +88,7 @@ public TimeSpeed DesiredTimeSpeed public Queue cmds = new(); public int CurrentPlayerCount { get; private set; } + public void ResetPlayerCount() => CurrentPlayerCount = 0; public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public AsyncTimeComp(Map map, int gameStartAbsTick = 0) diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 091c45009..b475c0dd5 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -59,6 +59,7 @@ public TimeSpeed DesiredTimeSpeed public Queue cmds = new(); public int CurrentPlayerCount { get; private set; } + public void ResetPlayerCount() => CurrentPlayerCount = 0; public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public int TickableId => -1; @@ -257,6 +258,9 @@ public void ExecuteCmd(ScheduledCommand cmd) Multiplayer.GameComp.playerData[playerId] = new PlayerData { canUseDevMode = canUseDevMode }; } + if (cmdType == CommandType.ResetPlayerCounts) + VTRSync.ResetAllPlayerCounts(); + if (cmdType == CommandType.PlayerCount) { int previousMapId = data.ReadInt32(); diff --git a/Source/Client/Networking/HostUtil.cs b/Source/Client/Networking/HostUtil.cs index 0b736217e..e2a8c57dc 100644 --- a/Source/Client/Networking/HostUtil.cs +++ b/Source/Client/Networking/HostUtil.cs @@ -7,6 +7,7 @@ using Multiplayer.Client.AsyncTime; using Multiplayer.Client.Comp; using Multiplayer.Client.Networking; +using Multiplayer.Client.Patches; using Multiplayer.Client.Util; using Multiplayer.Common; using RimWorld; @@ -38,6 +39,9 @@ public static async ClientTask HostServer(ServerSettings settings, bool fromRepl PrepareGame(); SetGameState(settings); + // Viewer counts saved in a replay belong to the players of that session + VTRSync.ResetAllPlayerCounts(); + Multiplayer.session.dataSnapshot = await CreateGameData(); MakeHostOnServer(serverConn); @@ -46,6 +50,7 @@ public static async ClientTask HostServer(ServerSettings settings, bool fromRepl SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, false); StartLocalServer(); + VTRSync.AnnounceCurrentView(); } private static void CreateSession(ServerSettings settings) => diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index fde3c0a47..98d2f01d5 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -62,11 +62,31 @@ static class VTRSync public static int GetSynchronizedUpdateRate(Thing thing) => thing?.MapHeld?.AsyncTime()?.VTR ?? MaximumVtr; + public static int CurrentViewId() => + WorldRendererUtility.WorldSelected ? WorldMapId : Find.CurrentMap?.uniqueID ?? InvalidMapId; + + // Hosting creates the game with a save and reload, which doesn't report the view (see below), so the host + // announces the view it starts with. Without it the host's map ran at the no-viewer rate until the host + // switched maps. Joiners report theirs when their load sets the current map. + public static void AnnounceCurrentView() => SendViewedMapUpdate(lastMovedToMapId, CurrentViewId()); + + public static void ResetAllPlayerCounts() + { + Multiplayer.AsyncWorldTime.ResetPlayerCount(); + foreach (var map in Find.Maps) + map.AsyncTime()?.ResetPlayerCount(); + } + public static void SendViewedMapUpdate(int previous, int current) { + // Join point reloads keep the saved counts, the view doesn't change if (Multiplayer.reloading) return; + // The arbiter doesn't view anything; counting it kept its map at the full rate for everyone + if (Multiplayer.arbiterInstance) + return; + string warn = string.Empty; if (previous != lastMovedToMapId) warn = $" mismatch between expected previous map {previous} and last moved to map {lastMovedToMapId}"; @@ -81,6 +101,7 @@ public static void Reset() { lastMovedToMapId = InvalidMapId; lastSentAtTick = -1; + WorldRenderModePatch.lastRenderMode = WorldRenderMode.None; } } @@ -127,7 +148,7 @@ private static int GetPreviousMapId() [HarmonyPatch(typeof(WorldRendererUtility), nameof(WorldRendererUtility.CurrentWorldRenderMode), MethodType.Getter)] static class WorldRenderModePatch { - private static WorldRenderMode lastRenderMode = WorldRenderMode.None; + internal static WorldRenderMode lastRenderMode = WorldRenderMode.None; static void Postfix(WorldRenderMode __result) { diff --git a/Source/Common/CommandType.cs b/Source/Common/CommandType.cs index 0239db070..4e3c29f72 100644 --- a/Source/Common/CommandType.cs +++ b/Source/Common/CommandType.cs @@ -17,4 +17,7 @@ public enum CommandType : byte MapTimeSpeed, Designator, PlayerCount, + + // Global scope, server only: viewer counts saved with the game belong to players of an earlier session + ResetPlayerCounts, } diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index 30890e715..754493b2a 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -82,6 +82,12 @@ server.BootstrapMode = bootstrap; +// The loaded game's viewer counts belong to players of an earlier session, who are not connected anymore. Runs on the +// server thread before any player can connect, so it's the first command everyone executes after the loaded state. +if (!bootstrap) + server.Enqueue(() => server.commands.Send(CommandType.ResetPlayerCounts, ScheduledCommand.NoFaction, + ScheduledCommand.Global, [])); + if (settings.direct) { var badEndpoint = settings.TryParseEndpoints(out var endpoints); if (badEndpoint != null) diff --git a/Source/Tests/ServerPlayingStateTest.cs b/Source/Tests/ServerPlayingStateTest.cs index 9ea30b615..303171233 100644 --- a/Source/Tests/ServerPlayingStateTest.cs +++ b/Source/Tests/ServerPlayingStateTest.cs @@ -40,6 +40,7 @@ public void TearDown() [TestCase(CommandType.PauseAll, ScheduledCommand.Global)] [TestCase(CommandType.CreateJoinPoint, ScheduledCommand.Global)] [TestCase(CommandType.InitPlayerData, ScheduledCommand.Global)] + [TestCase(CommandType.ResetPlayerCounts, ScheduledCommand.Global)] [TestCase(CommandType.GlobalTimeSpeed, 0)] [TestCase(CommandType.TimeSpeedVote, 3)] [TestCase(CommandType.PlayerCount, 1)] From 123ee0566581b6dcdc804d7a4adfed10f05de8b7 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:35:20 -0600 Subject: [PATCH 100/183] Judge animal hunger alerts with the animal's own time Need_Food.TicksStarving compares TicksGame with a timestamp taken on the pawn's clock, while the alerts run at the current map's time. With async time the starvation, penned and hitched animal alerts showed animals on other maps as starving (or missed them) depending on the viewed map (#983). In the interface, use the time of the pawn's map (or the world's). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Alerts.cs | 48 ++++++++++++++++++++++++++++ Source/Client/Patches/Determinism.cs | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Source/Client/Patches/Alerts.cs b/Source/Client/Patches/Alerts.cs index a0b9982e1..4ae4abad9 100644 --- a/Source/Client/Patches/Alerts.cs +++ b/Source/Client/Patches/Alerts.cs @@ -4,6 +4,10 @@ using System.Collections.Generic; using RimWorld; using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using Multiplayer.Client.Patches; +using UnityEngine; namespace Multiplayer.Client; @@ -47,3 +51,47 @@ public static void Postfix(ref List __result) __result = __result.Where(pawn => !pawn.Faction.IsPlayer || pawn.MapHeld.ParentFaction == Faction.OfPlayer).ToList(); } } + +// Need_Food.TicksStarving compares TicksGame with a timestamp of the pawn's own clock (its map's or, in caravans, the +// world's). The alerts run at the current map's time, which with async time made animals on other maps look starving +// (or not) depending on which map is viewed. Interface only: these alerts don't run in the simulation. +[HarmonyPatch] +public static class AnimalHungerAlerts_OwnTime_Patch +{ + static IEnumerable TargetMethods() + { + yield return AccessTools.PropertyGetter(typeof(Alert_StarvationAnimals), nameof(Alert_StarvationAnimals.StarvingAnimals)); + yield return AccessTools.Method(typeof(Alert_PennedAnimalHungry), nameof(Alert_PennedAnimalHungry.CalculateTargets)); + yield return AccessTools.Method(typeof(Alert_HitchedAnimalHungryNoFood), nameof(Alert_HitchedAnimalHungryNoFood.CalculateTargets)); + } + + static IEnumerable Transpiler(IEnumerable insts, MethodBase original) + { + var ticksStarving = AccessTools.PropertyGetter(typeof(Need_Food), nameof(Need_Food.TicksStarving)); + var replacement = AccessTools.Method(typeof(AnimalHungerAlerts_OwnTime_Patch), nameof(TicksStarving)); + var replaced = false; + + foreach (var ci in insts) + { + if (ci.Calls(ticksStarving)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + replaced = true; + } + + yield return ci; + } + + if (!replaced) + Log.Error($"Multiplayer: Need_Food.TicksStarving not found in {original.DeclaringType?.Name}.{original.Name}"); + } + + static int TicksStarving(Need_Food food) + { + if (Multiplayer.Client == null || !Multiplayer.InInterface) + return food.TicksStarving; + + return Mathf.Max(0, StatWorkerGetValuePatch.TicksGameFor(Find.TickManager.TicksGame, food.pawn) - food.lastNonStarvingTick); + } +} diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index f313b62cd..56ecdb566 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -549,7 +549,7 @@ static IEnumerable Transpiler(IEnumerable inst return result; } - private static int TicksGameFor(int ticksGame, Thing thing) + internal static int TicksGameFor(int ticksGame, Thing thing) { if (Multiplayer.Client == null || !Multiplayer.InInterface) return ticksGame; From d4349f264bf56ef97b2db9a04097c811dd9f55e4 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:36:20 -0600 Subject: [PATCH 101/183] Ask before hosting or joining with loading errors Failed patches and sync registrations only showed a marker on the main menu button, and the transpilers that check their target (2e921f5) only logged. Collect them through Multiplayer.LoadingError, which also marks the loading errors, and confirm once before opening the server browser, hosting from a game or accepting a Steam invite, listing the first ones. Co-Authored-By: Claude Opus 5.5 --- Source/Client/EarlyInit.cs | 3 +- Source/Client/Multiplayer.cs | 19 ++++++++ Source/Client/MultiplayerStatic.cs | 3 +- Source/Client/Networking/SteamIntegration.cs | 3 +- Source/Client/Patches/Determinism.cs | 12 ++--- Source/Client/Patches/HashCodes.cs | 2 +- Source/Client/Patches/Patches.cs | 2 +- Source/Client/Syncing/Game/SyncActions.cs | 6 +-- Source/Client/Syncing/Game/SyncGame.cs | 3 +- Source/Client/Syncing/Sync.cs | 9 ++-- Source/Client/Syncing/SyncFieldUtil.cs | 3 +- Source/Client/UI/MainMenuPatches.cs | 51 ++++++++++++++++++-- Source/Client/Util/MpPatch.cs | 3 +- 13 files changed, 85 insertions(+), 34 deletions(-) diff --git a/Source/Client/EarlyInit.cs b/Source/Client/EarlyInit.cs index 35d89bb7e..cc8e23182 100644 --- a/Source/Client/EarlyInit.cs +++ b/Source/Client/EarlyInit.cs @@ -30,8 +30,7 @@ internal static void EarlyPatches(Harmony harmony) if (type.IsDefined(typeof(EarlyPatchAttribute))) harmony.CreateClassProcessor(type).Patch(); } catch (Exception e) { - Log.Error($"FAIL: {type} with {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"FAIL: {type} with {e}"); } }); diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 1de147d22..212ad3d8f 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -82,8 +82,27 @@ public static class Multiplayer public static bool arbiterInstance; public static bool loadingErrors; + public static int loadingErrorCount; + // The first few, shown before hosting or joining + public static readonly List loadingErrorMessages = new(); + private const int MaxShownLoadingErrors = 20; public static Stopwatch harmonyWatch = new(); + /// A patch or sync registration failed to load. Playing like that likely desyncs or breaks features, so + /// hosting and joining ask for a confirmation first. + public static void LoadingError(string message) + { + Log.Error(message); + + lock (loadingErrorMessages) + { + loadingErrors = true; + loadingErrorCount++; + if (loadingErrorMessages.Count < MaxShownLoadingErrors) + loadingErrorMessages.Add(message); + } + } + public static ModContentPack modContentPack; public static void InitMultiplayer(ModContentPack content) diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 82d655fdc..e38bfcf88 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -256,8 +256,7 @@ void LogError(string str) if (categoryNeedsAnnouncement) { Log.Message($"Multiplayer :: {category}"); } - Log.Error(str); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError(str); } var harmony = Multiplayer.harmony; diff --git a/Source/Client/Networking/SteamIntegration.cs b/Source/Client/Networking/SteamIntegration.cs index 316f06af3..aa8112b81 100644 --- a/Source/Client/Networking/SteamIntegration.cs +++ b/Source/Client/Networking/SteamIntegration.cs @@ -81,7 +81,8 @@ public static void InitCallbacks() { if (Current.Game == null) { - ClientUtil.TryConnectWithWindow(ConnectorRegistry.Steam(req.m_steamIDFriend), false); + MainMenuPatch.ConfirmLoadingErrors(() => + ClientUtil.TryConnectWithWindow(ConnectorRegistry.Steam(req.m_steamIDFriend), false)); } else { diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index f313b62cd..7414e993c 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -89,7 +89,7 @@ static IEnumerable Transpiler(IEnumerable e) if (loadList == null) { - Log.Error("MP: Couldn't find the shuffled name list in PawnBioAndNameGenerator.TryGetRandomUnusedSolidName, assuming local 1. Was the original method changed?"); + Multiplayer.LoadingError("MP: Couldn't find the shuffled name list in PawnBioAndNameGenerator.TryGetRandomUnusedSolidName, assuming local 1. Was the original method changed?"); loadList = new CodeInstruction(OpCodes.Ldloc_1); } @@ -108,7 +108,7 @@ static IEnumerable Transpiler(IEnumerable e) } if (patched == 0) - Log.Error("MP: Couldn't find FirstOrDefault in PawnBioAndNameGenerator.TryGetRandomUnusedSolidName. Was the original method changed?"); + Multiplayer.LoadingError("MP: Couldn't find FirstOrDefault in PawnBioAndNameGenerator.TryGetRandomUnusedSolidName. Was the original method changed?"); return insts; } @@ -175,7 +175,7 @@ static IEnumerable Transpiler(IEnumerable inst } if (patched == 0) - Log.Error("MP: Couldn't find CellRect.Contains in Pawn.ProcessPostTickVisuals. Was the original method changed?"); + Multiplayer.LoadingError("MP: Couldn't find CellRect.Contains in Pawn.ProcessPostTickVisuals. Was the original method changed?"); } } @@ -197,7 +197,7 @@ public static IEnumerable Transpiler(IEnumerable Transpiler(IEnumerable inst } if (patched == 0) - Log.Error("MP: Couldn't find TicksGame in StatWorker.GetValue. Was the original method changed?"); + Multiplayer.LoadingError("MP: Couldn't find TicksGame in StatWorker.GetValue. Was the original method changed?"); return result; } @@ -608,7 +608,7 @@ static IEnumerable Transpiler(IEnumerable inst const int expectedPatches = 1; if (patchCount != expectedPatches) - Log.Error($"Replaced an incorrect amount of Time.deltaTime calls for GameConditionManager.MapBrightnessTracker:Tick (expected: {expectedPatches}, patched: {patchCount}). Was the original method changed?"); + Multiplayer.LoadingError($"Replaced an incorrect amount of Time.deltaTime calls for GameConditionManager.MapBrightnessTracker:Tick (expected: {expectedPatches}, patched: {patchCount}). Was the original method changed?"); } } diff --git a/Source/Client/Patches/HashCodes.cs b/Source/Client/Patches/HashCodes.cs index bfe0ba254..8cd64878f 100644 --- a/Source/Client/Patches/HashCodes.cs +++ b/Source/Client/Patches/HashCodes.cs @@ -84,7 +84,7 @@ static IEnumerable Transpiler(IEnumerable inst } if (patched == 0) - Log.Error($"MP: Couldn't find Gen.HashCombine in {original.DeclaringType}:{original.Name}. Was the original method changed?"); + Multiplayer.LoadingError($"MP: Couldn't find Gen.HashCombine in {original.DeclaringType}:{original.Name}. Was the original method changed?"); } static int CombineHashes(int seed, Map map) => Gen.HashCombineInt(seed, map?.uniqueID ?? -1); diff --git a/Source/Client/Patches/Patches.cs b/Source/Client/Patches/Patches.cs index 4917ba197..287705eac 100644 --- a/Source/Client/Patches/Patches.cs +++ b/Source/Client/Patches/Patches.cs @@ -48,7 +48,7 @@ static IEnumerable Transpiler(IEnumerable inst } if (patched == 0) - Log.Error("MP: Couldn't find RealTime.frameCount in Pawn_JobTracker.StartJob. Was the original method changed?"); + Multiplayer.LoadingError("MP: Couldn't find RealTime.frameCount in Pawn_JobTracker.StartJob. Was the original method changed?"); } static int FrameCountReplacement(int frameCount, Pawn_JobTracker tracker) diff --git a/Source/Client/Syncing/Game/SyncActions.cs b/Source/Client/Syncing/Game/SyncActions.cs index b5b20a220..dfd61dab7 100644 --- a/Source/Client/Syncing/Game/SyncActions.cs +++ b/Source/Client/Syncing/Game/SyncActions.cs @@ -14,11 +14,7 @@ static class SyncActions public static void Init() { - void Error(string error) - { - Multiplayer.loadingErrors = true; - Log.Error(error); - } + void Error(string error) => Multiplayer.LoadingError(error); // TODO: Use MpMethodUtil instead if we decide to make it work with generic types/methods (already in MP Compat, so use it). Or remove this TODO if we decide not to. CaravanActionConfirmationType = AccessTools.Inner(typeof(CaravanArrivalActionUtility), "<>c__DisplayClass0_1`1"); diff --git a/Source/Client/Syncing/Game/SyncGame.cs b/Source/Client/Syncing/Game/SyncGame.cs index f9c1bc366..136b4b38b 100644 --- a/Source/Client/Syncing/Game/SyncGame.cs +++ b/Source/Client/Syncing/Game/SyncGame.cs @@ -15,8 +15,7 @@ static void TryInit(string name, Action action) } catch (Exception e) { - Log.Error($"Exception during {name} initialization: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"Exception during {name} initialization: {e}"); } } diff --git a/Source/Client/Syncing/Sync.cs b/Source/Client/Syncing/Sync.cs index 50b7cc7be..a4912bc2b 100644 --- a/Source/Client/Syncing/Sync.cs +++ b/Source/Client/Syncing/Sync.cs @@ -147,8 +147,7 @@ internal static void RegisterAllAttributes(Assembly asm) } catch (Exception e) { - Log.Error($"Exception registering SyncMethod {type}::{method} by attribute: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"Exception registering SyncMethod {type}::{method} by attribute: {e}"); } } @@ -161,8 +160,7 @@ internal static void RegisterAllAttributes(Assembly asm) } catch (Exception e) { - Log.Error($"Exception registering SyncField by attribute: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"Exception registering SyncField by attribute: {e}"); } } } @@ -397,8 +395,7 @@ public static void ValidateAll() handler.Validate(); } catch (Exception e) { - Log.Error($"{handler} validation failed: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"{handler} validation failed: {e}"); } } } diff --git a/Source/Client/Syncing/SyncFieldUtil.cs b/Source/Client/Syncing/SyncFieldUtil.cs index 18f121d92..822ea1bf3 100644 --- a/Source/Client/Syncing/SyncFieldUtil.cs +++ b/Source/Client/Syncing/SyncFieldUtil.cs @@ -240,8 +240,7 @@ internal static void ApplyWatchFieldPatches(Type type) { Multiplayer.harmony.PatchMeasure(attr.Method, prefix, finalizer: finalizer); } catch (Exception e) { - Log.Error($"FAIL: {attr.Method.DeclaringType.FullName}:{attr.Method.Name} with {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"FAIL: {attr.Method.DeclaringType.FullName}:{attr.Method.Name} with {e}"); } } } diff --git a/Source/Client/UI/MainMenuPatches.cs b/Source/Client/UI/MainMenuPatches.cs index 246b66e80..18b3366cf 100644 --- a/Source/Client/UI/MainMenuPatches.cs +++ b/Source/Client/UI/MainMenuPatches.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using HarmonyLib; using Multiplayer.Client.Saving; @@ -29,6 +30,45 @@ public static class MainMenu_AddHeight [HarmonyPatch(typeof(OptionListingUtility), nameof(OptionListingUtility.DrawOptionListing))] public static class MainMenuPatch { + private static bool loadingErrorsConfirmed; + + /// Runs the action after the player confirms playing with loading errors, once per game start + public static void ConfirmLoadingErrors(Action action) + { + if (!Multiplayer.loadingErrors || loadingErrorsConfirmed) + { + action(); + return; + } + + static string FirstLine(string error) + { + var line = error.Split('\n')[0].Trim(); + return line.Length > 150 ? line[..150] + "..." : line; + } + + string errors; + int count; + lock (Multiplayer.loadingErrorMessages) + { + errors = Multiplayer.loadingErrorMessages.Select(e => "- " + FirstLine(e)).Join(delimiter: "\n"); + count = Multiplayer.loadingErrorCount; + if (count > Multiplayer.loadingErrorMessages.Count) + errors += $"\n... and {count - Multiplayer.loadingErrorMessages.Count} more"; + } + + // Not translated: the strings live in the separate locale repository + var text = $"Multiplayer had {count} error(s) while loading its patches and " + + "sync handlers. Playing like this is likely to desync or break features.\n\n" + + $"{errors}\n\nThe log has the full errors. Continue anyway?"; + + Find.WindowStack.Add(Dialog_MessageBox.CreateConfirmation(text, () => + { + loadingErrorsConfirmed = true; + action(); + }, true, layer: WindowLayer.Super)); + } + static void Prefix(Rect rect, List optList) { if (!MainMenuMarker.drawing) return; @@ -55,8 +95,11 @@ static void Prefix(Rect rect, List optList) GUIUtility.systemCopyBuffer = version; else { - Find.WindowStack.Add(new ServerBrowser()); - VersionChecker.OpenNewVersionDialogIfApplicable(); + ConfirmLoadingErrors(() => + { + Find.WindowStack.Add(new ServerBrowser()); + VersionChecker.OpenNewVersionDialogIfApplicable(); + }); } })); } @@ -67,13 +110,13 @@ static void Prefix(Rect rect, List optList) if (Multiplayer.session == null) optList.Insert(0, new ListableOption( "MpHostServer".Translate(), - () => Find.WindowStack.Add(new HostWindow() { layer = WindowLayer.Super }) + () => ConfirmLoadingErrors(() => Find.WindowStack.Add(new HostWindow() { layer = WindowLayer.Super })) )); if (MpVersion.IsDebug && Multiplayer.IsReplay) optList.Insert(0, new ListableOption( "MpHostServer".Translate(), - () => Find.WindowStack.Add(new HostWindow() { layer = WindowLayer.Super }) + () => ConfirmLoadingErrors(() => Find.WindowStack.Add(new HostWindow() { layer = WindowLayer.Super })) )); if (Multiplayer.Client != null) diff --git a/Source/Client/Util/MpPatch.cs b/Source/Client/Util/MpPatch.cs index f56ea0fe3..7316c5b12 100644 --- a/Source/Client/Util/MpPatch.cs +++ b/Source/Client/Util/MpPatch.cs @@ -130,8 +130,7 @@ public static List DoMpPatches(this Harmony harmony, Type type) } catch (Exception e) { - Log.Error($"MpPatch {m.DeclaringType}.{m.Name} failed with exception: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"MpPatch {m.DeclaringType}.{m.Name} failed with exception: {e}"); } } } From dceac5825d22d279cb647dbc84dd4dad7292c7ef Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:37:24 -0600 Subject: [PATCH 102/183] Clear zone cells under new buildings for every faction Notify_NoZoneOverlapThingSpawned only reached the installed (context faction's) zone manager, so other factions' zones kept cells under impassable buildings. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/FactionRepeater.cs | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Source/Client/Factions/FactionRepeater.cs b/Source/Client/Factions/FactionRepeater.cs index 7fd1a8110..038bbd116 100644 --- a/Source/Client/Factions/FactionRepeater.cs +++ b/Source/Client/Factions/FactionRepeater.cs @@ -196,6 +196,44 @@ ref ignore ); } + // A building that can't overlap zones clears zone cells only from the installed (context faction's) zone + // manager, other factions' zones kept cells under it. Repeat it for them when any of them has a zone there. + [HarmonyPatch(typeof(ZoneManager), nameof(ZoneManager.Notify_NoZoneOverlapThingSpawned))] + static class ZoneOverlapSpawnedPatch + { + static bool ignore; + + static void Prefix(ZoneManager __instance, Thing thing) + { + if (Multiplayer.Client == null || ignore) return; + + var factionData = __instance.map.MpComp()?.factionData; + if (factionData == null || !AnyOtherFactionZoneUnder(factionData, __instance, thing)) return; + + // The original then runs on the installed manager again, a no-op once its cells are cleared + FactionRepeater.Template( + factionData, + d => d.zoneManager.Notify_NoZoneOverlapThingSpawned(thing), + __instance.map, + ref ignore + ); + } + + static bool AnyOtherFactionZoneUnder(IDictionary factionData, ZoneManager installed, Thing thing) + { + var rect = thing.OccupiedRect(); + foreach (var data in factionData.Values) + { + if (data.zoneManager == installed) continue; + foreach (var c in rect) + if (c.InBounds(installed.map) && data.zoneManager.ZoneAt(c) != null) + return true; + } + + return false; + } + } + // Fix for RimWorld 1.6 bug where HistoryAutoRecorder.Tick() calls .Last() on empty collection [HarmonyPatch(typeof(HistoryAutoRecorder), nameof(HistoryAutoRecorder.Tick))] static class HistoryAutoRecorderTickPatch From 6dc1e5bf6b23ced8b380a61036440381e6812327 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:37:24 -0600 Subject: [PATCH 103/183] Remove other factions' designations on a destroyed thing Destroy only cleaned the installed designation managers; other factions kept designations on the dead thing until a reload dropped them, on the reloading client only. Limited to the thing's own map. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/FactionRepeater.cs | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Source/Client/Factions/FactionRepeater.cs b/Source/Client/Factions/FactionRepeater.cs index 038bbd116..9ff1fab4a 100644 --- a/Source/Client/Factions/FactionRepeater.cs +++ b/Source/Client/Factions/FactionRepeater.cs @@ -234,6 +234,38 @@ static bool AnyOtherFactionZoneUnder(IDictionary factionDat } } + // Destroying a thing removes designations on it only from each map's installed designation manager, so other + // factions' managers kept designations on a destroyed thing (they're dropped on load, so a reloading client + // differed from the others). Designations on a thing live on its own map. + [HarmonyPatch(typeof(Thing), nameof(Thing.Destroy))] + static class ThingDestroyDesignationsPatch + { + static void Prefix(Thing __instance, ref Map __state) + { + if (Multiplayer.Client != null && __instance.def.category != ThingCategory.Mote) + __state = __instance.MapHeld; + } + + static void Postfix(Thing __instance, Map __state) + { + if (__state == null || !__instance.Destroyed) return; + + var factionData = __state.MpComp()?.factionData; + if (factionData == null || !factionData.Values.Any(d => + d.designationManager != __state.designationManager && + d.designationManager.AllDesignationsOn(__instance).Count > 0)) + return; + + var ignore = false; + FactionRepeater.Template( + factionData, + d => d.designationManager.RemoveAllDesignationsOn(__instance), + __state, + ref ignore + ); + } + } + // Fix for RimWorld 1.6 bug where HistoryAutoRecorder.Tick() calls .Last() on empty collection [HarmonyPatch(typeof(HistoryAutoRecorder), nameof(HistoryAutoRecorder.Tick))] static class HistoryAutoRecorderTickPatch From a21fb663a6805d29b1a0562fd24c0f9515515573 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:38:30 -0600 Subject: [PATCH 104/183] Count skipped simulation errors and report them The catch blocks that keep one subsystem's exception from skipping the rest of a tick (38fbe9d), and the map and world command ones, only logged. Every such exception may have left this client different from the others. Count them per session, tell the player once (from the main thread, outside the tick) and add them and the loading error count to the desync report. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 6 +++ Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 3 ++ Source/Client/Desyncs/SaveableDesyncInfo.cs | 4 ++ Source/Client/Desyncs/SimulationFailures.cs | 38 +++++++++++++++++++ Source/Client/Patches/TickPatch.cs | 1 + Source/Client/Session/MultiplayerSession.cs | 3 ++ 6 files changed, 55 insertions(+) create mode 100644 Source/Client/Desyncs/SimulationFailures.cs diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index a8afbf573..b1b280845 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -128,6 +128,7 @@ public void Tick() catch (Exception e) { Log.Error($"Exception in MapPreTick of {map}: {e}"); + SimulationFailures.Record("MapPreTick", e); } mapTicks++; @@ -150,6 +151,7 @@ public void Tick() catch (Exception e) { Log.Error($"Exception ticking sessions of {map}: {e}"); + SimulationFailures.Record("map sessions", e); } try @@ -159,6 +161,7 @@ public void Tick() catch (Exception e) { Log.Error(e.ToString()); + SimulationFailures.Record("storyteller", e); } try @@ -168,6 +171,7 @@ public void Tick() catch (Exception e) { Log.Error(e.ToString()); + SimulationFailures.Record("story watcher", e); } try @@ -177,6 +181,7 @@ public void Tick() catch (Exception e) { Log.Error(e.ToString()); + SimulationFailures.Record("async quests", e); } map.MapPostTick(); @@ -352,6 +357,7 @@ public void ExecuteCmd(ScheduledCommand cmd) catch (Exception e) { MpLog.Error($"Map cmd exception ({cmdType}): {e}"); + SimulationFailures.Record($"map command {cmdType}", e); } finally { diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 0db80e41a..98ebb6ac2 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -120,6 +120,7 @@ public void Tick() catch (Exception e) { Log.Error($"Exception in world DoSingleTick: {e}"); + SimulationFailures.Record("world DoSingleTick", e); } // Matches how worldTicks is initialized after loading (ExposeData), so clients which joined later agree @@ -133,6 +134,7 @@ public void Tick() catch (Exception e) { Log.Error($"Exception ticking world sessions: {e}"); + SimulationFailures.Record("world sessions", e); } if (ModsConfig.BiotechActive) @@ -281,6 +283,7 @@ public void ExecuteCmd(ScheduledCommand cmd) catch (Exception e) { Log.Error($"World cmd exception ({cmdType}): {e}"); + SimulationFailures.Record($"world command {cmdType}", e); } finally { diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 200c3dfee..926b1df73 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -118,6 +118,10 @@ private string GetDesyncDetails() .AppendLine($"Multiplayer Debug Build - Client|||{MpVersion.IsDebug}") .AppendLine($"Multiplayer Debug Mode - Host|||{Multiplayer.GameComp.debugMode}") .AppendLine($"Rimworld Developer Mode - Client|||{Prefs.DevMode}") + .AppendLine("\n###Failures###") + .AppendLine($"Loading Errors - Client|||{Multiplayer.loadingErrorCount}") + .AppendLine($"Skipped Simulation Errors - Client|||{Multiplayer.session.simulationFailures}") + .AppendLine($"First Simulation Error - Client|||{Multiplayer.session.firstSimulationFailure ?? "none"}") .AppendLine("\n###Server Info###") .AppendLine($"Player Count|||{Multiplayer.session.players.Count}") .AppendLine($"Async time active|||{Multiplayer.GameComp.asyncTime}") diff --git a/Source/Client/Desyncs/SimulationFailures.cs b/Source/Client/Desyncs/SimulationFailures.cs new file mode 100644 index 000000000..bb28f04e9 --- /dev/null +++ b/Source/Client/Desyncs/SimulationFailures.cs @@ -0,0 +1,38 @@ +using System; +using RimWorld; +using Verse; + +namespace Multiplayer.Client; + +/// +/// Exceptions in synced commands and ticks which are caught so that the rest of the simulation goes on. Each one +/// may have left this client different from the others, so they are counted, the player is told once per session +/// and desync reports include them. +/// +public static class SimulationFailures +{ + /// Call from the catch block, next to logging the exception. Touches only local state. + public static void Record(string where, Exception e) + { + var session = Multiplayer.session; + if (session == null) return; + + session.simulationFailures++; + if (session.firstSimulationFailure != null) return; + + session.firstSimulationFailure = + $"tick {TickPatch.Timer}, {where}: {e.GetType().Name}: {e.Message}".Replace('\n', ' ').Replace('\r', ' '); + + // Not from inside the tick: the message is local UI and the failure may have happened on this client only. + // Not translated: the strings live in the separate locale repository. + OnMainThread.Enqueue(() => + { + if (Multiplayer.session != session) return; + + const string text = "Multiplayer: an error in the game simulation was skipped on this client. " + + "This can cause a desync, the log has the details."; + Messages.Message(text, MessageTypeDefOf.NegativeEvent, historical: false); + session.AddMsg($"{text} ({session.firstSimulationFailure})", rawMessage: true); + }); + } +} diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index 0aa757d9e..7a030ab07 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -273,6 +273,7 @@ private static void TickTickable(ITickable tickable) catch (Exception e) { Log.Error($"Exception during ticking {tickable}: {e}"); + SimulationFailures.Record($"ticking {tickable}", e); } } } diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 918304b46..722437141 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -38,6 +38,9 @@ public class MultiplayerSession : IConnectionStatusListener public bool showTimeline; public bool desynced; + // Caught exceptions in synced commands and ticks, see SimulationFailures + public int simulationFailures; + public string firstSimulationFailure; // Standalone: set by the server when this client's sync opinions are the ones compared against public bool isSyncReference; From c5aac4fffd732bfe30f3d18648ef107b67b71df4 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:38:42 -0600 Subject: [PATCH 105/183] Drop the spectator's destination reservations when converting to SP The spectator faction is removed from the faction list, so its saved reservation set referenced a faction missing from the converted save. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Saving/ConvertToSp.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Source/Client/Saving/ConvertToSp.cs b/Source/Client/Saving/ConvertToSp.cs index 1dc65d74a..dcdf15a5e 100644 --- a/Source/Client/Saving/ConvertToSp.cs +++ b/Source/Client/Saving/ConvertToSp.cs @@ -30,6 +30,11 @@ private static void PrepareSingleplayer() var spectator = Multiplayer.WorldComp.spectatorFaction; if (spectator != null) { + // Destination reservations are keyed by faction and saved as references, an entry for the + // spectator (created e.g. in its simulation context) would be a dangling reference in the save + foreach (var map in Find.Maps) + map.pawnDestinationReservationManager.Notify_FactionRemoved(spectator); + spectator.RemoveAllRelations(); Find.FactionManager.allFactions.Remove(spectator); } From 77fb479a03bb65427a3c2c0248ba7a3391d365ad Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:40:07 -0600 Subject: [PATCH 106/183] Keep the end of the log and the join data diff in desync reports Desync reports kept the first 10,000 log lines, so a long session lost the lines around the desync. Keep 2,000 from the start (setup, patching errors) and the newest 8,000. Record what didn't match when the player connected anyway (and label the MP version check correctly), and show which part of a replay's desync data couldn't be read instead of leaving it empty. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Desyncs/LogGenerator.cs | 14 +++----- Source/Client/Desyncs/SaveableDesyncInfo.cs | 1 + .../Client/Desyncs/UserReadableDesyncInfo.cs | 18 ++++++---- Source/Client/Session/MultiplayerSession.cs | 2 ++ Source/Client/Windows/JoinDataWindow.cs | 6 +++- Source/Common/Util/Extensions.cs | 12 +++++++ Source/Tests/LogTrimTest.cs | 33 +++++++++++++++++++ 7 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 Source/Tests/LogTrimTest.cs diff --git a/Source/Client/Desyncs/LogGenerator.cs b/Source/Client/Desyncs/LogGenerator.cs index 192bb3110..2ef23e2b9 100644 --- a/Source/Client/Desyncs/LogGenerator.cs +++ b/Source/Client/Desyncs/LogGenerator.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; +using Multiplayer.Common; using UnityEngine; using Verse; @@ -13,6 +14,8 @@ namespace Multiplayer.Client public static class LogGenerator { private const int MaxLogLineCount = 10000; + // The start has the setup (mods, patching errors), the end the desync itself + private const int HeadLogLineCount = 2000; internal static string PrepareLogData() { @@ -46,15 +49,8 @@ private static string NormalizeLineEndings(string log) return log.Replace("\r\n", "\n"); } - private static string TrimExcessLines(string log) - { - var indexOfLastNewline = log.IndexOfOccurrence('\n', MaxLogLineCount); - if (indexOfLastNewline >= 0) - { - log = $"{log.Substring(0, indexOfLastNewline + 1)}(log trimmed to {MaxLogLineCount:N0} lines.)"; - } - return log; - } + private static string TrimExcessLines(string log) => + log.KeepHeadAndTailLines(HeadLogLineCount, MaxLogLineCount - HeadLogLineCount); private static string RedactUselessLines(string log) { diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 926b1df73..2549a0ba9 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -114,6 +114,7 @@ private string GetDesyncDetails() .AppendLine("\n###Version Data###") .AppendLine($"Multiplayer Mod Version|||{MpVersion.Version}") .AppendLine($"Rimworld Version and Rev|||{VersionControl.CurrentVersionStringWithRev}") + .AppendLine($"Join Data Mismatch|||{Multiplayer.session.joinDataDiff ?? "none"}") .AppendLine("\n###Debug Options###") .AppendLine($"Multiplayer Debug Build - Client|||{MpVersion.IsDebug}") .AppendLine($"Multiplayer Debug Mode - Host|||{Multiplayer.GameComp.debugMode}") diff --git a/Source/Client/Desyncs/UserReadableDesyncInfo.cs b/Source/Client/Desyncs/UserReadableDesyncInfo.cs index 7d269984e..27c8c45f1 100644 --- a/Source/Client/Desyncs/UserReadableDesyncInfo.cs +++ b/Source/Client/Desyncs/UserReadableDesyncInfo.cs @@ -51,8 +51,9 @@ public static string GenerateFromReplay(Replay replay) text.AppendLine("[info]"); text.AppendLine(zip.GetString("info")); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -63,8 +64,9 @@ public static string GenerateFromReplay(Replay replay) //Local Client Opinion data local = DeserializeAndPrintSyncInfo(text, "sync_local", zip.GetBytes("sync_local")); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -75,8 +77,9 @@ public static string GenerateFromReplay(Replay replay) //Remote Client Opinion data remote = DeserializeAndPrintSyncInfo(text, "sync_remote", zip.GetBytes("sync_remote")); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -104,8 +107,9 @@ public static string GenerateFromReplay(Replay replay) text.AppendLine($"Game debug mode: {desyncInfo.ReadBool()}"); } } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -140,8 +144,9 @@ public static string GenerateFromReplay(Replay replay) foreach (var cmd in ScheduledCommand.DeserializeCmds(zip.GetBytes("maps/000_0_cmds"))) PrintCmdInfo(text, cmd); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -153,8 +158,9 @@ public static string GenerateFromReplay(Replay replay) foreach (var cmd in ScheduledCommand.DeserializeCmds(zip.GetBytes("world/000_cmds"))) PrintCmdInfo(text, cmd); } - catch + catch (Exception e) { + text.AppendLine($""); } } diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 722437141..57be25a0d 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -41,6 +41,8 @@ public class MultiplayerSession : IConnectionStatusListener // Caught exceptions in synced commands and ticks, see SimulationFailures public int simulationFailures; public string firstSimulationFailure; + // Set when the player connected despite mismatched game data + public string joinDataDiff; // Standalone: set by the server when this client's sync opinions are the ones compared against public bool isSyncReference; diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index fb891cc22..be14ba154 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -250,6 +250,9 @@ void RefreshFiles() if (connectAnyway) { Log.Message($"Multiplayer: Connecting anyway ({DiffString()})"); + // For desync reports: whether everyone ran the same build is the first question + if (Multiplayer.session != null) + Multiplayer.session.joinDataDiff = DiffString(); connectAnywayCallback(); Close(false); } @@ -269,7 +272,8 @@ void RefreshFiles() private string DiffString() { var str = ""; - str += $"RW version match: {remote.remoteMpVersion == MpVersion.Version}, "; + str += $"RW version match: {remote.remoteRwVersion == VersionControl.CurrentVersionString}, "; + str += $"MP version match: {remote.remoteMpVersion == MpVersion.Version}, "; str += $"Mod list diff: {modListDiff}, "; str += $"Files match: {!filesRoot.children.Any()}, "; str += $"Config sync enabled: {remote.hasConfigs}, "; diff --git a/Source/Common/Util/Extensions.cs b/Source/Common/Util/Extensions.cs index e3af7a987..0ba2814bf 100644 --- a/Source/Common/Util/Extensions.cs +++ b/Source/Common/Util/Extensions.cs @@ -136,6 +136,18 @@ public static float MaxOrZero(this IEnumerable items, Func map) public static T? ResultNowOrNull(this Task task) where T : class? => task.Status == TaskStatus.RanToCompletion ? task.Result : null; + /// Keeps the first and the last lines of a long text and notes how many lines were left out in between + public static string KeepHeadAndTailLines(this string text, int headLines, int tailLines) + { + var lines = text.Split('\n'); + var omitted = lines.Length - headLines - tailLines; + if (omitted <= 0) return text; + + return string.Join("\n", lines, 0, headLines) + + $"\n(... {omitted:N0} lines omitted ...)\n" + + string.Join("\n", lines, lines.Length - tailLines, tailLines); + } + public static string RemovePrefix(this string str, string? prefix) { if (prefix == null) return str; diff --git a/Source/Tests/LogTrimTest.cs b/Source/Tests/LogTrimTest.cs new file mode 100644 index 000000000..bcb5e166c --- /dev/null +++ b/Source/Tests/LogTrimTest.cs @@ -0,0 +1,33 @@ +using Multiplayer.Common; + +namespace Tests; + +[TestFixture] +public class LogTrimTest +{ + private static string Lines(int from, int to) => + string.Join("\n", Enumerable.Range(from, to - from + 1).Select(i => $"line {i}")); + + [Test] + public void ShortTextIsKept() + { + var text = Lines(1, 5); + Assert.That(text.KeepHeadAndTailLines(2, 3), Is.SameAs(text)); + } + + [Test] + public void LongTextKeepsHeadAndTail() + { + var trimmed = Lines(1, 10).KeepHeadAndTailLines(2, 3); + + Assert.That(trimmed, Is.EqualTo(Lines(1, 2) + "\n(... 5 lines omitted ...)\n" + Lines(8, 10))); + } + + [Test] + public void TrailingNewlineCountsAsAnEmptyLastLine() + { + var trimmed = (Lines(1, 10) + "\n").KeepHeadAndTailLines(2, 3); + + Assert.That(trimmed, Is.EqualTo(Lines(1, 2) + "\n(... 6 lines omitted ...)\n" + Lines(9, 10) + "\n")); + } +} From c6c9fa6fd29e524e3ab0d5f2e689449aab367b12 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:40:10 -0600 Subject: [PATCH 107/183] Snapshot the invoker's inventory when holding psychic ritual offerings Vanilla transfers the offering while enumerating the inventory; moving a whole stack throws and the ritual never completes (#855). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Anomaly.cs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Source/Client/Patches/Anomaly.cs b/Source/Client/Patches/Anomaly.cs index 77293f896..31c8f9d7f 100644 --- a/Source/Client/Patches/Anomaly.cs +++ b/Source/Client/Patches/Anomaly.cs @@ -1,9 +1,12 @@ using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Reflection.Emit; using HarmonyLib; using RimWorld; +using UnityEngine; using Verse; +using Verse.AI.Group; namespace Multiplayer.Client.Patches { @@ -75,4 +78,25 @@ static bool Prefix(UnnaturalCorpse __instance, ref bool __result) return false; } } + + // #855: vanilla moves the offering out of the invoker's inventory while enumerating it; moving a whole stack + // throws (collection modified) and the psychic ritual never completes. Same logic over a snapshot. + [HarmonyPatch(typeof(PsychicRitualToil_InvokeHorax), nameof(PsychicRitualToil_InvokeHorax.HoldRequiredOfferings))] + static class PsychicRitualToil_InvokeHorax_HoldRequiredOfferings + { + static bool Prefix(PsychicRitualToil_InvokeHorax __instance, PsychicRitual psychicRitual) + { + var offering = __instance.requiredOffering; + if (offering == null) + return false; + + foreach (Pawn pawn in psychicRitual.assignments.AssignedPawns(__instance.invokerRole)) + foreach (Thing thing in pawn.inventory.GetDirectlyHeldThings().ToList()) + if (offering.filter.Allows(thing)) + pawn.inventory.innerContainer.TryTransferToContainer(thing, pawn.carryTracker.innerContainer, + Mathf.CeilToInt(offering.GetBaseCount())); + + return false; + } + } } \ No newline at end of file From 89dfd7a69ee2322e1e844beb2dfc4521bafa3dee Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:40:24 -0600 Subject: [PATCH 108/183] Fix pawn timestamps without saving destroyed pawns TimestampFixer visits a pawn's data with a debug save, also for pawns killed on a map and passed to the world pawns, which logged a warning on every death (#979), even without async time where there's nothing to shift. Skip pawns whose clock doesn't change and shift the fields of destroyed pawns directly. Skipping destroyed pawns altogether (as #979 did) would leave a resurrected pawn's timestamps off by the difference between its map's and the world's time. Also shift the starvation start (TicksStarving jumped on map/world moves), ability and royal permit cooldowns, the daily skill xp reset and the decree interval (#964), keeping their "never" markers. Fields are now also found on subclasses (psycasts are abilities). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/TimestampFixer.cs | 73 ++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/Source/Client/Patches/TimestampFixer.cs b/Source/Client/Patches/TimestampFixer.cs index 5526021d1..dc2adcb71 100644 --- a/Source/Client/Patches/TimestampFixer.cs +++ b/Source/Client/Patches/TimestampFixer.cs @@ -14,11 +14,23 @@ public static class TimestampFixer { delegate ref int FieldGetter(T obj) where T : IExposable; - private static Dictionary>> timestampFields = new(); + // Shifts a timestamp field by the given offset + delegate void FieldFixer(IExposable obj, int offset); - private static void Add(FieldGetter t) where T : IExposable + private static Dictionary> timestampFields = new(); + + // timestampFields including the fields of base types, by the exact type + private static Dictionary> resolvedFields = new(); + + // Values below minValid are "never" markers: kept as they are, and shifted values are clamped to stay valid + private static void Add(FieldGetter t, int minValid = int.MinValue) where T : IExposable { - timestampFields.GetOrAddNew(typeof(T)).Add(obj => ref t((T)obj)); + timestampFields.GetOrAddNew(typeof(T)).Add((obj, offset) => + { + ref int field = ref t((T)obj); + if (field >= minValid) + field = Math.Max(minValid, field + offset); + }); } static TimestampFixer() @@ -27,6 +39,12 @@ static TimestampFixer() Add((Pawn_MindState mind) => ref mind.canLovinTick); Add((Pawn_GuestTracker guest) => ref guest.ticksWhenAllowedToEscapeAgain); Add((Pawn_GuestTracker guest) => ref guest.lastPrisonBreakTicks); + Add((Need_Food food) => ref food.lastNonStarvingTick); + Add((Ability ability) => ref ability.cooldownEndTick); + Add((Ability ability) => ref ability.lastCastTick, minValid: 0); + Add((Pawn_SkillTracker skills) => ref skills.lastXpSinceMidnightResetTimestamp, minValid: 0); + Add((FactionPermit permit) => ref permit.lastUsedTick, minValid: 1); + Add((Pawn_RoyaltyTracker royalty) => ref royalty.lastDecreeTicks); } public static int? currentOffset; @@ -35,14 +53,21 @@ public static void FixPawn(Pawn p, Map oldMap, Map newMap) { var oldTime = oldMap?.AsyncTime().mapTicks ?? Multiplayer.AsyncWorldTime.worldTicks; var newTime = newMap?.AsyncTime().mapTicks ?? Multiplayer.AsyncWorldTime.worldTicks; + if (newTime == oldTime) return; + currentOffset = newTime - oldTime; MpLog.Debug($"Fixing pawn timestamps for {p} moving from {oldMap?.ToString() ?? "World"}:{oldTime} to {newMap?.ToString() ?? "World"}:{newTime}"); try { - // Auxiliary save which is used to visit the pawn's data - Scribe.saver.DebugOutputFor(p); + // Saving a destroyed pawn logs a warning, and every pawn killed on a map is passed to the world pawns. Its + // timestamps still have to move to the world's time: they're shifted back if it's resurrected. + if (p.Destroyed) + FixDirectly(p); + else + // Auxiliary save which is used to visit the pawn's data + Scribe.saver.DebugOutputFor(p); } finally { @@ -50,11 +75,43 @@ public static void FixPawn(Pawn p, Map oldMap, Map newMap) } } + // The holders of the fields above, without the save pass's walk through the pawn's data + private static void FixDirectly(Pawn p) + { + ProcessExposable(p.mindState); + ProcessExposable(p.guest); + ProcessExposable(p.needs?.food); + ProcessExposable(p.skills); + ProcessExposable(p.royalty); + + if (p.abilities != null) + foreach (var ability in p.abilities.abilities) + ProcessExposable(ability); + + if (p.royalty != null) + foreach (var permit in p.royalty.AllFactionPermits) + ProcessExposable(permit); + } + public static void ProcessExposable(IExposable exposable) { - if (timestampFields.TryGetValue(exposable.GetType(), out var fieldGetters)) - foreach (var del in fieldGetters) - del(exposable) += currentOffset!.Value; + if (exposable == null) return; + + foreach (var fixer in FieldsFor(exposable.GetType())) + fixer(exposable, currentOffset!.Value); + } + + private static List FieldsFor(Type type) + { + if (resolvedFields.TryGetValue(type, out var fields)) + return fields; + + fields = new List(); + for (var t = type; t != null; t = t.BaseType) + if (timestampFields.TryGetValue(t, out var own)) + fields.AddRange(own); + + return resolvedFields[type] = fields; } } From c3c332317d9119b5ace0db91c606f094222a2da0 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:40:34 -0600 Subject: [PATCH 109/183] Read the download speed once in the connecting window The speed is recomputed from the clock on every read, so the value checked against 0 wasn't the one divided by (a DivideByZeroException), and the -1 before the first checkpoint passed the check. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Windows/ConnectingWindow.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Source/Client/Windows/ConnectingWindow.cs b/Source/Client/Windows/ConnectingWindow.cs index 1e05e0e31..8f892efbd 100644 --- a/Source/Client/Windows/ConnectingWindow.cs +++ b/Source/Client/Windows/ConnectingWindow.cs @@ -54,9 +54,12 @@ public override void DoWindowContents(Rect inRect) case ClientLoadingState { subState: LoadingState.Downloading } state: label = "MpDownloading".Translate() + $" ({state.DownloadProgressPercent}%)"; var leftToDownloadKBps = (state.WorldExpectedSize - state.WorldReceivedSize) / 1000; - if (state.DownloadSpeedKBps != 0) + // Computed from the clock on every read, so read once: the value checked could otherwise be 0 + // when divided by. It's -1 before the first checkpoint. + var downloadSpeedKBps = state.DownloadSpeedKBps; + if (downloadSpeedKBps > 0) { - var timeLeftSecs = leftToDownloadKBps / state.DownloadSpeedKBps; + var timeLeftSecs = leftToDownloadKBps / downloadSpeedKBps; label += $"\n{timeLeftSecs}s – "; } @@ -64,7 +67,7 @@ public override void DoWindowContents(Rect inRect) label += "\n"; label += - $"{state.WorldReceivedSize / 1000}/{state.WorldExpectedSize / 1000} KB ({state.DownloadSpeedKBps} KB/s)"; + $"{state.WorldReceivedSize / 1000}/{state.WorldExpectedSize / 1000} KB ({Mathf.Max(downloadSpeedKBps, 0)} KB/s)"; break; default: label = result ?? (ConnectingString + MpUI.FixedEllipsis()); From 01981804a69883a08f9d070037b79dab0d0d824a Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:41:05 -0600 Subject: [PATCH 110/183] Ignore line endings when comparing synced mod configs Mods write their settings with the platform's line endings, so a host and a client with the same settings on different systems got the data mismatch window over identical configs. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Networking/JoinData.cs | 11 +++++++++-- Source/Client/Windows/JoinDataWindow.cs | 6 +++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index eb912e961..fa1a59bdd 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -78,7 +78,8 @@ public static bool CompareToLocal(RemoteData remote) remote.remoteRwVersion == VersionControl.CurrentVersionString && remote.CompareMods(activeModsSnapshot) == ModListDiff.None && remote.remoteFiles.DictsEqual(modFilesSnapshot) && - (!remote.hasConfigs || remote.remoteModConfigs.EqualAsSets(SyncConfigs.GetSyncableConfigContents(remote.RemoteModIds.ToList()))); + (!remote.hasConfigs || remote.remoteModConfigs.Select(c => c.Comparable()).EqualAsSets( + SyncConfigs.GetSyncableConfigContents(remote.RemoteModIds.ToList()).Select(c => c.Comparable()))); } internal static void TakeModDataSnapshot() @@ -221,7 +222,13 @@ public enum ModListDiff None, NoMatchAsSets, WrongOrder } - public record ModConfig(string ModId, string FileName, string Contents); + public record ModConfig(string ModId, string FileName, string Contents) + { + // Mods write their settings with the platform's line endings, the same settings shouldn't be a mismatch + public string ComparableContents => Contents?.Replace("\r\n", "\n").Replace('\r', '\n'); + + public ModConfig Comparable() => this with { Contents = ComparableContents }; + } public class ModFileDict : IEnumerable>> { diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index be14ba154..4097a7443 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -157,14 +157,14 @@ private void RefreshConfigNodes() void AddConfigs(List one, List two, NodeStatus notInTwo) { - var oneDict = one.ToDictionaryPermissive(m => m.ModId + "/" + m.FileName, m => m.Contents); - var twoDict = two.ToDictionaryPermissive(m => m.ModId + "/" + m.FileName, m => m.Contents); + var oneDict = one.ToDictionaryPermissive(m => m.ModId + "/" + m.FileName, m => m.ComparableContents); + var twoDict = two.ToDictionaryPermissive(m => m.ModId + "/" + m.FileName, m => m.ComparableContents); foreach (var kv in oneDict){ AddNodesForPath( kv.Key, configsRoot, - twoDict.TryGetValue(kv.Key, out var t) ? (t.Equals(kv.Value) ? NodeStatus.None : NodeStatus.Modified) : notInTwo + twoDict.TryGetValue(kv.Key, out var t) ? (t == kv.Value ? NodeStatus.None : NodeStatus.Modified) : notInTwo ); } } From b815de51ef41fd8ea3828c8d965bb34ff1b0a652 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:42:16 -0600 Subject: [PATCH 111/183] Seed quest name generation retries The GrammarRequest overload of NameGenerator.GenerateName and the quest name collision loop retry depending on the generated text, so on the game language (#980). Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Seeds.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Source/Client/Patches/Seeds.cs b/Source/Client/Patches/Seeds.cs index 458b28281..e0b608987 100644 --- a/Source/Client/Patches/Seeds.cs +++ b/Source/Client/Patches/Seeds.cs @@ -7,6 +7,7 @@ using System.Reflection.Emit; using Multiplayer.Client.Patches; using Multiplayer.Client.Util; +using RimWorld.QuestGen; using Verse; using Verse.Grammar; @@ -215,6 +216,11 @@ static IEnumerable TargetMethods() yield return AccessTools.Method(typeof(GrammarResolver), nameof(GrammarResolver.Resolve)); yield return AccessTools.Method(typeof(PawnBioAndNameGenerator), nameof(PawnBioAndNameGenerator.GeneratePawnName)); yield return AccessTools.Method(typeof(NameGenerator), nameof(NameGenerator.GenerateName), [typeof(RulePackDef), typeof(Predicate), typeof(bool), typeof(string), typeof(string), typeof(List)]); + // Called directly by quest, ideo, deity and unique weapon naming; its validator retries depend on the + // generated text, so on the game language + yield return AccessTools.Method(typeof(NameGenerator), nameof(NameGenerator.GenerateName), [typeof(GrammarRequest), typeof(Predicate), typeof(bool), typeof(string), typeof(string)]); + // Retries whole GenerateName calls while the name collides with an existing quest's name + yield return AccessTools.Method(typeof(QuestNode_ResolveQuestName), nameof(QuestNode_ResolveQuestName.Resolve)); } [HarmonyPriority(MpPriority.MpFirst)] From 5005f8fc5b18e2f8dac0558728455ad4dc9af75b Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:44:39 -0600 Subject: [PATCH 112/183] Sync the product style of bills The style menu of the bill dialog (Ideology in classic mode) set Bill.style, globalStyle and graphicIndexOverride only locally, so the products got a different style on the other clients (#402). Watch the fields around the menu options' lambdas. The bill is found through the lambdas' closures by type instead of by compiler-generated field names. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/Game/SyncFields.cs | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/Source/Client/Syncing/Game/SyncFields.cs b/Source/Client/Syncing/Game/SyncFields.cs index 788f936e5..3dabaa8b9 100644 --- a/Source/Client/Syncing/Game/SyncFields.cs +++ b/Source/Client/Syncing/Game/SyncFields.cs @@ -54,6 +54,7 @@ public static partial class SyncFields public static SyncField[] SyncBillProduction; public static SyncField[] SyncBillIncludeCriteria; + public static SyncField[] SyncBillStyle; public static SyncField[] SyncDrugPolicyEntry; public static ISyncField SyncDrugPolicyEntryTakeToInventory; @@ -168,6 +169,14 @@ public static void Init() nameof(Bill_Production.unpauseWhenYouHave) ).SetBufferChanges(); + SyncBillStyle = Sync.Fields( + typeof(Bill), + null, + nameof(Bill.style), + nameof(Bill.globalStyle), + nameof(Bill.graphicIndexOverride) + ); + SyncBillIncludeCriteria = Sync.Fields( typeof(Bill_Production), null, @@ -411,6 +420,37 @@ static void DialogBillConfig(Dialog_BillConfig __instance) } } + // The product style menu of the bill dialog (Ideology in classic mode). Its options' lambdas: use the global + // style (10), basic (11), a style (12) and a style with a graphic index (13) + [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents), lambdaOrdinal: 10)] + [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents), lambdaOrdinal: 11)] + [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents), lambdaOrdinal: 12)] + [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents), lambdaOrdinal: 13)] + static void BillStyleOption(object __instance) + { + if (FindBillConfig(__instance, 0) is { } dialog) + SyncBillStyle.Watch(dialog.bill); + else + Log.ErrorOnce($"Multiplayer: couldn't find the bill of a style option ({__instance?.GetType()}), the style won't be synced", 0x4b1157); + } + + // The lambdas are the dialog's own methods or capture it, possibly through an outer closure + private static Dialog_BillConfig FindBillConfig(object obj, int depth) + { + if (obj is Dialog_BillConfig dialog) + return dialog; + if (obj == null || depth > 3) + return null; + + foreach (var field in obj.GetType().GetFields(AccessTools.all)) + if (typeof(Dialog_BillConfig).IsAssignableFrom(field.FieldType) || + field.FieldType.DeclaringType == typeof(Dialog_BillConfig)) + if (FindBillConfig(field.GetValue(obj), depth + 1) is { } found) + return found; + + return null; + } + [MpTranspiler(typeof(BillRepeatModeUtility), nameof(BillRepeatModeUtility.MakeConfigFloatMenu))] static IEnumerable BillConfigFloatMenuTranspiler(IEnumerable insts, MethodBase orig) { From 92af9a96851cdaa914e885fade88977af9ecb96a Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:46:30 -0600 Subject: [PATCH 113/183] Warn about a game language mismatch when joining Translated text is generated and saved during the simulation and name generation retries depend on it, so different languages can desync (#264). The init data provider's language travels in the join data and shows in the mismatch window. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Networking/JoinData.cs | 11 ++++++++++ .../Networking/State/ClientJoiningState.cs | 1 + Source/Client/Windows/JoinDataWindow.cs | 13 +++++++++++- .../Networking/Packet/InitDataPacket.cs | 4 ++++ .../Networking/Packet/JoinDataPacket.cs | 3 +++ .../Networking/State/ServerJoiningState.cs | 1 + Source/Common/ServerInitData.cs | 3 ++- Source/Tests/ChatCommandManagerTest.cs | 2 ++ Source/Tests/Helper/TestJoiningState.cs | 1 + .../Tests/Helper/TestLoadingKeepAliveState.cs | 1 + Source/Tests/JoiningStateTest.cs | 20 +++++++++++++++++++ Source/Tests/PacketTest.cs | 2 ++ Source/Tests/ReaderBoundsTest.cs | 4 ++-- .../ClientInitDataPacket.verified.txt | 2 +- .../ServerJoinDataPacket.verified.txt | 2 +- 15 files changed, 64 insertions(+), 6 deletions(-) diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index eb912e961..f1744d41d 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -72,10 +72,19 @@ public static ModMetaData GetInstalledMod(string id) return ModLister.GetModWithIdentifier(id); } + // Translated text is generated and saved during the simulation (names, letters) and name generation retries + // depend on it, so clients with different game languages can desync + public static string LocalLanguage => LanguageDatabase.activeLanguage?.folderName ?? ""; + + public static bool LanguageMatches(string remoteLanguage) => + remoteLanguage == LocalLanguage || + !remoteLanguage.NullOrEmpty() && remoteLanguage == LanguageDatabase.activeLanguage?.LegacyFolderName; + public static bool CompareToLocal(RemoteData remote) { return remote.remoteRwVersion == VersionControl.CurrentVersionString && + LanguageMatches(remote.remoteLanguage) && remote.CompareMods(activeModsSnapshot) == ModListDiff.None && remote.remoteFiles.DictsEqual(modFilesSnapshot) && (!remote.hasConfigs || remote.remoteModConfigs.EqualAsSets(SyncConfigs.GetSyncableConfigContents(remote.RemoteModIds.ToList()))); @@ -150,6 +159,7 @@ public class RemoteData { public string remoteRwVersion; public string remoteMpVersion; + public string remoteLanguage; public List remoteMods = new(); public ModFileDict remoteFiles = new(); @@ -178,6 +188,7 @@ public static RemoteData FromNet(ServerJoinDataPacket packet) { remoteRwVersion = packet.rwVersion, remoteMpVersion = packet.mpVersion, + remoteLanguage = packet.language, hasConfigs = packet.configsIncluded, }; diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index cf932cd98..227109cd2 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -55,6 +55,7 @@ public void HandleInitDataRequest(ServerInitDataRequestPacket packet) => public static ClientInitDataPacket CreateInitDataPacket(bool includeConfigs) => new() { rwVersion = VersionControl.CurrentVersionString, + language = JoinData.LocalLanguage, 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, diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index fb891cc22..9d46a54da 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -270,6 +270,7 @@ private string DiffString() { var str = ""; str += $"RW version match: {remote.remoteMpVersion == MpVersion.Version}, "; + str += $"Language match: {JoinData.LanguageMatches(remote.remoteLanguage)}, "; str += $"Mod list diff: {modListDiff}, "; str += $"Files match: {!filesRoot.children.Any()}, "; str += $"Config sync enabled: {remote.hasConfigs}, "; @@ -352,7 +353,17 @@ private void DrawGeneralTab(Rect inRect) bool mpVersionCheck = remote.remoteMpVersion == MpVersion.Version; Widgets.Checkbox(new Rect(0, 0, 24, 24).CenterOn(checkboxColumn.Down(2 * rowHeight)).min, ref mpVersionCheck); - inRect.yMin += rowHeight * 3 + 30f; + var languageRect = mpVersionRect.Down(rowHeight).Width(rowLabelWidth); + Widgets.DrawHighlightIfMouseover(languageRect); + Widgets.DrawAltRect(headerRect.Down(3 * rowHeight)); + Widgets.Label(languageRect, "MpMismatchLanguage".TryTranslate(out var languageLabel) ? languageLabel.ToString() : "Game language"); + Widgets.Label(serverColumn.Down(3 * rowHeight), remote.remoteLanguage); + Widgets.Label(clientColumn.Down(3 * rowHeight), JoinData.LocalLanguage); + + bool languageCheck = JoinData.LanguageMatches(remote.remoteLanguage); + Widgets.Checkbox(new Rect(0, 0, 24, 24).CenterOn(checkboxColumn.Down(3 * rowHeight)).min, ref languageCheck); + + inRect.yMin += rowHeight * 4 + 30f; } } diff --git a/Source/Common/Networking/Packet/InitDataPacket.cs b/Source/Common/Networking/Packet/InitDataPacket.cs index 145d16517..7788d3994 100644 --- a/Source/Common/Networking/Packet/InitDataPacket.cs +++ b/Source/Common/Networking/Packet/InitDataPacket.cs @@ -22,8 +22,11 @@ public record struct ClientInitDataPacket : IPacket public const int MaxRawModsLength = MaxRawDataLength + sizeof(int); // Synced configs are plain text and compress well public const int MaxDecompressedLength = 1 << 26; + // A language folder name, at most 255 UTF-16 units on common file systems + public const int MaxLanguageLength = 1024; public string rwVersion; + public string language; public int[] debugOnlySyncCmds; public int[] hostOnlySyncCmds; public RoundModeEnum modCtorRoundMode; @@ -41,6 +44,7 @@ public List Mods public void Bind(PacketBuffer buf) { buf.Bind(ref rwVersion); + buf.Bind(ref language, maxLength: MaxLanguageLength); buf.Bind(ref debugOnlySyncCmds, BinderOf.Int()); buf.Bind(ref hostOnlySyncCmds, BinderOf.Int()); buf.BindEnum(ref modCtorRoundMode); diff --git a/Source/Common/Networking/Packet/JoinDataPacket.cs b/Source/Common/Networking/Packet/JoinDataPacket.cs index cd5c35262..d11a3d0f4 100644 --- a/Source/Common/Networking/Packet/JoinDataPacket.cs +++ b/Source/Common/Networking/Packet/JoinDataPacket.cs @@ -11,6 +11,8 @@ public record struct ServerJoinDataPacket : IPacket public int playerId; public string rwVersion; public string mpVersion; + // Game language of the client which provided the server's init data + public string language; // Aligned with the client's def infos from ClientJoinDataPacket public DefCheckStatus[] defStatus; // Def types the server knows but the client didn't send @@ -30,6 +32,7 @@ public void Bind(PacketBuffer buf) buf.Bind(ref playerId); buf.Bind(ref rwVersion); buf.Bind(ref mpVersion); + buf.Bind(ref language, maxLength: ClientInitDataPacket.MaxLanguageLength); buf.Bind(ref defStatus, BinderOf.Enum()); buf.Bind(ref missingDefs, MissingDefBinder, maxLength: MaxMissingDefs); buf.Bind(ref configsIncluded); diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index ab607ba31..bc1dbdadb 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -176,6 +176,7 @@ private bool HandleClientJoinData(ClientJoinDataPacket packet) playerId = Player.id, rwVersion = serverInitData.RwVersion, mpVersion = MpVersion.Version, + language = serverInitData.Language, defStatus = defStatus, missingDefs = missingDefs, configsIncluded = serverInitData.IncludeConfigs, diff --git a/Source/Common/ServerInitData.cs b/Source/Common/ServerInitData.cs index 730a1afbb..968f8fdf2 100644 --- a/Source/Common/ServerInitData.cs +++ b/Source/Common/ServerInitData.cs @@ -8,6 +8,7 @@ public record ServerInitData( byte[] RawData, bool IncludeConfigs, string RwVersion, + string Language, HashSet DebugOnlySyncCmds, HashSet HostOnlySyncCmds, (RoundModeEnum, RoundModeEnum) RoundModes, @@ -15,7 +16,7 @@ Dictionary DefInfos ) { public static ServerInitData FromNet(ClientInitDataPacket packet) => new( - packet.rawMods, packet.includeConfigs, packet.rwVersion, + packet.rawMods, packet.includeConfigs, packet.rwVersion, packet.language, packet.debugOnlySyncCmds.ToHashSet(), packet.hostOnlySyncCmds.ToHashSet(), (packet.modCtorRoundMode, packet.staticCtorRoundMode), diff --git a/Source/Tests/ChatCommandManagerTest.cs b/Source/Tests/ChatCommandManagerTest.cs index 1b20b8b20..516b4417a 100644 --- a/Source/Tests/ChatCommandManagerTest.cs +++ b/Source/Tests/ChatCommandManagerTest.cs @@ -360,6 +360,7 @@ public void ModsCommand_ShowsServerModList() ]), false, "1.6.4633", + "English", [], [], default, @@ -389,6 +390,7 @@ public void ModsCommand_CanPageThroughServerModList() ]), false, "1.6.4633", + "English", [], [], default, diff --git a/Source/Tests/Helper/TestJoiningState.cs b/Source/Tests/Helper/TestJoiningState.cs index 5121b0cc5..3198bef9f 100644 --- a/Source/Tests/Helper/TestJoiningState.cs +++ b/Source/Tests/Helper/TestJoiningState.cs @@ -25,6 +25,7 @@ protected override async Task RunState() connection.Send(new ClientInitDataPacket { rwVersion = RwVersion, + language = "English", debugOnlySyncCmds = [], hostOnlySyncCmds = [], modCtorRoundMode = RoundModeEnum.ToNearest, diff --git a/Source/Tests/Helper/TestLoadingKeepAliveState.cs b/Source/Tests/Helper/TestLoadingKeepAliveState.cs index c0bb37ef4..e0cb4935a 100644 --- a/Source/Tests/Helper/TestLoadingKeepAliveState.cs +++ b/Source/Tests/Helper/TestLoadingKeepAliveState.cs @@ -25,6 +25,7 @@ protected override async Task RunState() connection.Send(new ClientInitDataPacket { rwVersion = RwVersion, + language = "English", debugOnlySyncCmds = [], hostOnlySyncCmds = [], modCtorRoundMode = RoundModeEnum.ToNearest, diff --git a/Source/Tests/JoiningStateTest.cs b/Source/Tests/JoiningStateTest.cs index 2ef2fc7c7..f4ee64776 100644 --- a/Source/Tests/JoiningStateTest.cs +++ b/Source/Tests/JoiningStateTest.cs @@ -63,6 +63,7 @@ private static void SendHandshake(ConnectionBase conn, string username) private static ClientInitDataPacket InitData(params KeyedDefInfo[] defInfos) => new() { rwVersion = "1.0.0", + language = "English", debugOnlySyncCmds = [], hostOnlySyncCmds = [], modCtorRoundMode = RoundModeEnum.ToNearest, @@ -182,6 +183,25 @@ public void DefTypesMissingOnClientFailTheDefCheck() Assert.That(first.hasJoined, Is.True); } + [Test] + public void JoinDataCarriesTheInitDataLanguage() + { + var (_, firstConn) = Connect(); + SendHandshake(firstConn, "first"); + Receive(firstConn, InitData() with { language = "Russian" }); + + var (_, secondConn) = Connect(); + SendHandshake(secondConn, "second"); + Receive(secondConn, JoinData()); + + var raw = secondConn.SentRaw.Single(r => (Packets)(r[0] & 0x3F) == Packets.Server_JoinData); + var joinData = default(ServerJoinDataPacket); + joinData.Bind(new PacketReader(new ByteReader(raw.Skip(1).ToArray()))); + + // Joiners compare it with their own language and warn on a mismatch + Assert.That(joinData.language, Is.EqualTo("Russian")); + } + private static ClientJoinDataPacket JoinData(params KeyedDefInfo[] defInfos) => new() { modCtorRoundMode = RoundModeEnum.ToNearest, diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index 5f220fdf2..904233e56 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -203,6 +203,7 @@ private static IEnumerable RoundtripPackets() playerId = 1, rwVersion = "1.6.4566", mpVersion = "0.11.0+123456", + language = "English", defStatus = [ DefCheckStatus.Ok, DefCheckStatus.Ok, DefCheckStatus.Count_Diff, DefCheckStatus.Hash_Diff, @@ -223,6 +224,7 @@ private static IEnumerable RoundtripPackets() yield return new ClientInitDataPacket { rwVersion = "1.0.0", + language = "English", debugOnlySyncCmds = [1, 2, 3, 4], hostOnlySyncCmds = [1], modCtorRoundMode = RoundModeEnum.ToNearest, diff --git a/Source/Tests/ReaderBoundsTest.cs b/Source/Tests/ReaderBoundsTest.cs index 20978e390..b147ad8e5 100644 --- a/Source/Tests/ReaderBoundsTest.cs +++ b/Source/Tests/ReaderBoundsTest.cs @@ -111,7 +111,7 @@ string RandomText(int bytes) var initData = new ClientInitDataPacket { - rwVersion = "1.6", debugOnlySyncCmds = [], hostOnlySyncCmds = [], defInfos = [], Mods = mods + rwVersion = "1.6", language = "English", debugOnlySyncCmds = [], hostOnlySyncCmds = [], defInfos = [], Mods = mods }; // Bigger than the old 32 KiB and 512 KiB limits Assert.That(initData.rawMods, Has.Length.GreaterThan(1 << 19)); @@ -119,7 +119,7 @@ string RandomText(int bytes) var roundtrip = Roundtrip(initData); var joinData = Roundtrip(new ServerJoinDataPacket { - gameName = "", rwVersion = "", mpVersion = "", defStatus = [], missingDefs = [], + gameName = "", rwVersion = "", mpVersion = "", language = "", defStatus = [], missingDefs = [], rawServerInitData = roundtrip.rawMods }); Assert.That(joinData.ServerInitData, Has.Count.EqualTo(mods.Count)); diff --git a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt index b87199a17..6315fe5d8 100644 --- a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt @@ -1,3 +1,3 @@ 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) +05-00-00-00-31-2E-30-2E-30-07-00-00-00-45-6E-67-6C-69-73-68-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 (128 bytes) diff --git a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt index 4f16979ee..cb70302a5 100644 --- a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt @@ -1,3 +1,3 @@ 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-01-00-00-00-0D-00-00-00-53-65-72-76-65-72-4F-6E-6C-79-44-65-66-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-02-00 (118 bytes) +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-07-00-00-00-45-6E-67-6C-69-73-68-05-00-00-00-00-00-02-03-01-01-00-00-00-0D-00-00-00-53-65-72-76-65-72-4F-6E-6C-79-44-65-66-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-02-00 (129 bytes) From f8dd31117b01cb3fa8cced1c7afc94019ecc13e5 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:46:37 -0600 Subject: [PATCH 114/183] Send big commands fragmented Commands were single packets of at most 64 KiB, so big vanilla actions (designating or pasting a large area, reforming an ideo) failed with a send exception in the middle of the action (#415). Client and server commands that don't fit a single packet are now sent fragmented, on the reliable ordered channel like the rest, so their order is kept. Command data is capped at 1 MiB: commands go into the log joiners get with the world data, which has to fit a fragmented packet together with the save. The server still drops bigger ones before logging them, and the client now tells the player instead of throwing. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Saving/ReplayConnection.cs | 3 + Source/Client/Util/Extensions.cs | 15 ++- Source/Common/CommandHandler.cs | 17 +-- Source/Common/Networking/ConnectionBase.cs | 12 +- .../Common/Networking/Packet/CommandPacket.cs | 12 +- Source/Tests/LargeCommandTest.cs | 116 ++++++++++++++++++ 6 files changed, 155 insertions(+), 20 deletions(-) create mode 100644 Source/Tests/LargeCommandTest.cs diff --git a/Source/Client/Saving/ReplayConnection.cs b/Source/Client/Saving/ReplayConnection.cs index a79e24ca3..793c620bf 100644 --- a/Source/Client/Saving/ReplayConnection.cs +++ b/Source/Client/Saving/ReplayConnection.cs @@ -18,6 +18,9 @@ protected override void Send(Packets id, byte[] message, bool reliable = true) } } + // Big commands are sent fragmented, catch them whole + public override void SendFragmented(Packets id, byte[] message) => Send(id, message); + protected override void SendRaw(byte[] raw, bool reliable) { } diff --git a/Source/Client/Util/Extensions.cs b/Source/Client/Util/Extensions.cs index 1be34d424..5e521eda3 100644 --- a/Source/Client/Util/Extensions.cs +++ b/Source/Client/Util/Extensions.cs @@ -75,8 +75,19 @@ public static Map GetById(this List maps, int mapId) return null; } - public static void SendCommand(this ConnectionBase conn, CommandType type, int mapId, byte[] data) => - conn.Send(new ClientCommandPacket(type, mapId, data)); + public static void SendCommand(this ConnectionBase conn, CommandType type, int mapId, byte[] data) + { + // The server would drop it: tell the player instead of throwing in the middle of their action + if (data.Length > ServerCommandPacket.MaxDataLength) + { + Log.Error($"Multiplayer: {type} command too big to send ({data.Length} > {ServerCommandPacket.MaxDataLength} bytes)"); + Messages.Message("Multiplayer: this action is too big to send to the server.", + MessageTypeDefOf.RejectInput, historical: false); + return; + } + + conn.SendFragmentedIfNeeded(new ClientCommandPacket(type, mapId, data).Serialize()); + } public static void SendCommand(this ConnectionBase conn, CommandType type, int mapId, params object[] data) => SendCommand(conn, type, mapId, ByteWriter.GetBytes(data)); diff --git a/Source/Common/CommandHandler.cs b/Source/Common/CommandHandler.cs index dcae6744a..78a5eff83 100644 --- a/Source/Common/CommandHandler.cs +++ b/Source/Common/CommandHandler.cs @@ -59,18 +59,13 @@ public void Send(CommandType cmdType, int factionId, int mapId, byte[] data, Ser server.worldData.cmds.Add(toSave); server.worldData.tmpCmds?.Add(toSave); - if (server.CanUseStandaloneMapStreaming(mapId)) + var serialized = ServerCommandPacket.From(cmd).Serialize(); + var mapStreaming = server.CanUseStandaloneMapStreaming(mapId); + foreach (var player in server.PlayingPlayers) { - var serialized = ServerCommandPacket.From(cmd).Serialize(); - foreach (var player in server.PlayingPlayers) - { - if (!player.hasReportedCurrentMap || player.currentMapId < 0 || player.currentMapId == mapId) - player.conn.Send(serialized, true); - } - } - else - { - server.SendToPlaying(ServerCommandPacket.From(cmd)); + if (!mapStreaming || !player.hasReportedCurrentMap || player.currentMapId < 0 || + player.currentMapId == mapId) + player.conn.SendFragmentedIfNeeded(serialized); } SentCmds++; diff --git a/Source/Common/Networking/ConnectionBase.cs b/Source/Common/Networking/ConnectionBase.cs index 8c9eab945..5f047569e 100644 --- a/Source/Common/Networking/ConnectionBase.cs +++ b/Source/Common/Networking/ConnectionBase.cs @@ -84,7 +84,7 @@ protected virtual void Send(Packets id, byte[] message, bool reliable = true) private byte sendFragId; // All fragmented packets need to be sent from the same thread - public void SendFragmented(Packets id, byte[] message) + public virtual void SendFragmented(Packets id, byte[] message) { if (State == ConnectionStateEnum.Disconnected) return; @@ -144,6 +144,16 @@ public void SendFragmented(Packets id, byte[] message) public void SendFragmented(SerializedPacket packet) => SendFragmented(packet.id, packet.data); + /// For packets which usually fit a single packet but can be bigger: fragments only those which don't fit. + /// The packet's handler must allow fragments, and the same thread rule as for SendFragmented applies. + public void SendFragmentedIfNeeded(SerializedPacket packet) + { + if (packet.data.Length > MaxSinglePacketSize) + SendFragmented(packet); + else + Send(packet); + } + protected abstract void SendRaw(byte[] raw, bool reliable = true); public virtual void HandleReceiveRaw(ByteReader data, bool reliable) diff --git a/Source/Common/Networking/Packet/CommandPacket.cs b/Source/Common/Networking/Packet/CommandPacket.cs index c977a4550..30e5f586a 100644 --- a/Source/Common/Networking/Packet/CommandPacket.cs +++ b/Source/Common/Networking/Packet/CommandPacket.cs @@ -1,12 +1,12 @@ namespace Multiplayer.Common.Networking.Packet; -[PacketDefinition(Packets.Server_Command)] +// Commands which don't fit a single packet (designating or pasting big areas, reforming an ideo) are sent fragmented +[PacketDefinition(Packets.Server_Command, allowFragmented: true)] public record struct ServerCommandPacket : IPacket { - // type + ticks + factionId + mapId + playerId - private const int HeaderSize = 1 + 4 * 4; - // Largest data that still fits the rebroadcast in a single packet - public const int MaxDataLength = ConnectionBase.MaxSinglePacketSize - HeaderSize; + // Commands go into the log joiners receive with the world data, which has to fit a fragmented packet + // (ConnectionBase.MaxFragmentedMessageLength) together with the save, so keep them well below that + public const int MaxDataLength = 1024 * 1024; public CommandType type; public int ticks; @@ -39,7 +39,7 @@ public void Bind(PacketBuffer buf) } } -[PacketDefinition(Packets.Client_Command)] +[PacketDefinition(Packets.Client_Command, allowFragmented: true)] public record struct ClientCommandPacket(CommandType type, int mapId, byte[] data) : IPacket { public CommandType type = type; diff --git a/Source/Tests/LargeCommandTest.cs b/Source/Tests/LargeCommandTest.cs new file mode 100644 index 000000000..f088780d7 --- /dev/null +++ b/Source/Tests/LargeCommandTest.cs @@ -0,0 +1,116 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +/// Commands bigger than a single packet (big designations, stockpile pastes) are sent fragmented both ways +[TestFixture] +[NonParallelizable] +public class LargeCommandTest +{ + // Stands in for the client's playing state + public class CommandReceiverState(ConnectionBase conn) : MpConnectionState(conn) + { + public static readonly List received = new(); + + [TypedPacketHandler] + public void HandleCommand(ServerCommandPacket packet) => received.Add(packet); + } + + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + nextPlayerId = 0; + MpConnectionState.SetImplementation(ConnectionStateEnum.ClientPlaying, typeof(CommandReceiverState)); + CommandReceiverState.received.Clear(); + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private RecordingConnection AddPlayer(string username) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn) { hasJoined = true }; + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return conn; + } + + private static void Receive(ConnectionBase conn, IEnumerable raws) + { + foreach (var raw in raws) + conn.HandleReceiveRaw(new ByteReader(raw), true); + } + + private static bool IsFragment(byte[] raw) => (raw[0] & 0xC0) != 0; + + [Test] + public void BigCommandReachesLiveClientsWhole() + { + var senderConn = AddPlayer("sender"); + var liveConn = AddPlayer("live"); + var data = Enumerable.Range(0, 300_000).Select(i => (byte)(i * 7)).ToArray(); + + var client = new RecordingConnection("client"); + client.SendFragmentedIfNeeded(new ClientCommandPacket(CommandType.Designator, 3, data).Serialize()); + Assert.That(client.SentRaw, Has.Count.GreaterThan(1).And.All.Matches(IsFragment)); + + Receive(senderConn, client.SentRaw); + + var logged = ScheduledCommand.Deserialize(new ByteReader(server.worldData.cmds.Single())); + Assert.That(logged.type, Is.EqualTo(CommandType.Designator)); + Assert.That(logged.mapId, Is.EqualTo(3)); + Assert.That(logged.data, Is.EqualTo(data)); + + var liveClient = new RecordingConnection("live client"); + liveClient.ChangeState(ConnectionStateEnum.ClientPlaying); + Receive(liveClient, liveConn.SentRaw.Where(raw => (Packets)(raw[0] & 0x3F) == Packets.Server_Command)); + + var received = CommandReceiverState.received.Single(); + Assert.That(received.ToCommand().playerId, Is.EqualTo(0)); + Assert.That(received.data, Is.EqualTo(data)); + } + + [Test] + public void SmallCommandIsASinglePacket() + { + var liveConn = AddPlayer("live"); + + server.commands.Send(CommandType.Sync, 0, 1, new byte[ConnectionBase.MaxSinglePacketSize - 100]); + + var sent = liveConn.SentRaw.Where(raw => (Packets)(raw[0] & 0x3F) == Packets.Server_Command).ToList(); + Assert.That(sent, Has.Count.EqualTo(1)); + Assert.That(IsFragment(sent[0]), Is.False); + } + + [Test] + public void CommandsKeepTheirOrderAroundABigOne() + { + var liveConn = AddPlayer("live"); + + server.commands.Send(CommandType.Sync, 0, 1, [1]); + server.commands.Send(CommandType.Sync, 0, 1, Enumerable.Repeat((byte)2, 200_000).ToArray()); + server.commands.Send(CommandType.Sync, 0, 1, [3]); + + var liveClient = new RecordingConnection("live client"); + liveClient.ChangeState(ConnectionStateEnum.ClientPlaying); + Receive(liveClient, liveConn.SentRaw.Where(raw => (Packets)(raw[0] & 0x3F) == Packets.Server_Command)); + + Assert.That(CommandReceiverState.received.Select(p => p.data[0]), Is.EqualTo(new byte[] { 1, 2, 3 })); + Assert.That(CommandReceiverState.received[1].data, Has.Length.EqualTo(200_000)); + } +} From 46638cc64942fa90e7bfe9dcf823ca35b3806d37 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:48:37 -0600 Subject: [PATCH 115/183] Keep quest map picks to the generating faction's maps Multifaction: quests generated for a player faction (e.g. by its storyteller) chose among the player homes, which can include another player's map: a gravship landing site counts as a home of every faction, and QuestNode_GetMap accepts a map given in the slate as it is. Refugees and other arrivals then came to a friend's colony (#659). Port the owner filter of #961 for QuestNode_GetMap, QuestGen_Get.GetMap and the private pickers of work sites and reliquary pilgrims, applied in the pickers' own predicates so vanilla's choice is otherwise unchanged. The spectator's context (no owner) keeps vanilla's choice. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/QuestMapPatches.cs | 110 ++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 Source/Client/Factions/QuestMapPatches.cs diff --git a/Source/Client/Factions/QuestMapPatches.cs b/Source/Client/Factions/QuestMapPatches.cs new file mode 100644 index 000000000..c100316a2 --- /dev/null +++ b/Source/Client/Factions/QuestMapPatches.cs @@ -0,0 +1,110 @@ +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Multiplayer.Client.Util; +using RimWorld; +using RimWorld.QuestGen; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Multifaction: quests pick their map among the player homes. Generated for a player faction (e.g. by its +// storyteller), they could still pick another player's map: a map it got in another way than Map.IsPlayerHome's +// settlement check (a gravship landing site), or a map already given in the slate. Exclude the maps of other player +// factions. Generation in the spectator's context (no owner) keeps vanilla's choice. +public static class QuestMapFilter +{ + public static bool IsOtherPlayersMap(Map map) + { + if (map == null || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return false; + + var faction = Faction.OfPlayer; + return faction != null && faction != Multiplayer.WorldComp.spectatorFaction && + map.ParentFaction is { IsPlayer: true } owner && owner != faction; + } +} + +[HarmonyPatch(typeof(QuestNode_GetMap), nameof(QuestNode_GetMap.IsAcceptableMap))] +static class QuestNodeGetMap_OwnMaps_Patch +{ + static void Postfix(Map map, ref bool __result) + { + if (__result && QuestMapFilter.IsOtherPlayersMap(map)) + __result = false; + } +} + +// The map picker of C# quest roots (refugees, beggars, wanderers, DLC arrivals) +[HarmonyPatch] +static class QuestGenGetMap_Validator_OwnMaps_Patch +{ + static MethodBase TargetMethod() => + MpMethodUtil.GetLocalFunc(typeof(QuestGen_Get), nameof(QuestGen_Get.GetMap), localFunc: "Validator"); + + static void Postfix(Map m, ref bool __result) + { + if (__result && QuestMapFilter.IsOtherPlayersMap(m)) + __result = false; + } +} + +[HarmonyPatch(typeof(QuestGen_Get), nameof(QuestGen_Get.GetMap))] +static class QuestGenGetMap_Fallback_OwnMaps_Patch +{ + // The validator excludes the other players' maps, such a result comes from the fallback to the first player home + static void Postfix(ref Map __result) + { + if (QuestMapFilter.IsOtherPlayersMap(__result)) + __result = Find.Maps.FirstOrDefault(m => m.IsPlayerHome && !QuestMapFilter.IsOtherPlayersMap(m)); + } +} + +[HarmonyPatch] +static class ReliquaryPilgrimsMap_OwnMaps_Patch +{ + static MethodBase TargetMethod() => + MpMethodUtil.GetLocalFunc(typeof(QuestNode_Root_ReliquaryPilgrims), nameof(QuestNode_Root_ReliquaryPilgrims.GetMap), localFunc: "Validator"); + + static void Postfix(Map m, ref bool __result) + { + if (__result && QuestMapFilter.IsOtherPlayersMap(m)) + __result = false; + } +} + +// RunInt picks the map with Find.Maps.Where(m => m.IsPlayerHome && GetCandidates(m.Tile).Any()) +[HarmonyPatch] +static class WorkSiteMap_OwnMaps_Patch +{ + static MethodBase TargetMethod() + { + var lambda = MpMethodUtil.GetLambda(typeof(QuestNode_Root_WorkSite), nameof(QuestNode_Root_WorkSite.RunInt), lambdaOrdinal: 0); + if (lambda.ReturnType != typeof(bool) || lambda.GetParameters() is not [{ ParameterType: var param }] || param != typeof(Map)) + throw new System.Exception($"Unexpected map picker lambda in QuestNode_Root_WorkSite.RunInt: {lambda}"); + return lambda; + } + + static void Postfix(Map m, ref bool __result) + { + if (__result && QuestMapFilter.IsOtherPlayersMap(m)) + __result = false; + } +} + +// RunInt has to find a map when TestRunInt passed +[HarmonyPatch(typeof(QuestNode_Root_WorkSite), nameof(QuestNode_Root_WorkSite.TestRunInt))] +static class WorkSiteTestRun_OwnMaps_Patch +{ + static void Postfix(ref bool __result) + { + if (!__result || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + // Vanilla's map check, the rest of the test passed + __result = Find.Maps.Any(map => + map.IsPlayerHome && !QuestMapFilter.IsOtherPlayersMap(map) && + QuestNode_Root_WorkSite.AppearanceFrequency(map) > 0f && + QuestNode_Root_WorkSite.GetCandidates(map.Tile).Any()); + } +} From e05a6793863321ccb60f5b607ecc3238807ed1e6 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:48:59 -0600 Subject: [PATCH 116/183] Refill the ritual role group cache before a synced TryAssignAnyRole TryAssignAnyRole iterates a static cache filled only when the ritual dialog is drawn, so clients without the dialog (or with another ritual's groups) assigned differently (#995). Co-Authored-By: Claude Opus 5.5 --- Source/MultiplayerLoader/SyncRituals.cs | 33 ++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/Source/MultiplayerLoader/SyncRituals.cs b/Source/MultiplayerLoader/SyncRituals.cs index 24a1c4af6..3c019fac5 100644 --- a/Source/MultiplayerLoader/SyncRituals.cs +++ b/Source/MultiplayerLoader/SyncRituals.cs @@ -13,13 +13,22 @@ namespace MultiplayerLoader; public static class SyncRituals { private static List syncMethodsWithPrepatches = new(); + // Indexed like syncMethodsWithPrepatches + private static List> beforeExecutingWithPrepatches = new(); public static bool PrepatcherPrefix(int index, object __instance, object[] __args) { // This prefix is applied to a generic method and called by all possible instantiations // This check makes sure the type is correct if (syncMethodsWithPrepatches[index].TargetType == __instance.GetType()) - return !syncMethodsWithPrepatches[index].DoSync(__instance, __args); + { + if (syncMethodsWithPrepatches[index].DoSync(__instance, __args)) + return false; + + // Also runs for calls nested in another synced method + if (MP.IsExecutingSyncCommand) + beforeExecutingWithPrepatches[index]?.Invoke(__instance); + } return true; } @@ -30,7 +39,8 @@ public static bool PrepatcherPrefix(int index, object __instance, object[] __arg // The second pass with module == null is called from Sync initialization and registers SyncMethods public static void ApplyPrepatches(ModuleDefinition module) { - void Register(Type baseType, string method, Type derivedType, Action postProcess = null) + void Register(Type baseType, string method, Type derivedType, Action postProcess = null, + Action beforeExecuting = null) { if (module != null) { @@ -51,6 +61,8 @@ void Register(Type baseType, string method, Type derivedType, Action m.TransformArgument(1, psychicRitualRolesSerializer)); Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnRitualRoleSelectionWidget)); + typeof(PawnRitualRoleSelectionWidget), + beforeExecuting: target => RefreshRoleGroups((PawnRitualRoleSelectionWidget)target)); Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnPsychicRitualRoleSelectionWidget)); + typeof(PawnPsychicRitualRoleSelectionWidget), + beforeExecuting: target => RefreshRoleGroups((PawnPsychicRitualRoleSelectionWidget)target)); Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), typeof(PawnRitualRoleSelectionWidget), @@ -118,6 +132,17 @@ void Register(Type baseType, string method, Type derivedType, Action(PawnRoleSelectionWidgetBase widget) + where RoleType : class, ILordJobRole + { + var groups = PawnRoleSelectionWidgetBase.rolesGroupedTmp; + groups.Clear(); + groups.AddRange(widget.assignments.RoleGroups()); + } + private static (WidgetType roleSelectionWidget, string Key) RitualRoleWriter(IEnumerable roles, object target, object[] _) where WidgetType : PawnRoleSelectionWidgetBase where RoleType : class, ILordJobRole From 9b94384f6736fe22f4f51cf449489cf2ec52e009 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:48:25 -0600 Subject: [PATCH 117/183] Split selections over 200 things into several packets The selection packet carries at most 200 ids per list, and a bigger change (mods can select more than vanilla's 200) threw in the client's writer every 0.2 s (#377). Split the change into packets within the limit; only the first one resets the selection, and they're reliable, so the receivers apply the whole change in order. Co-Authored-By: Claude Opus 5.5 --- Source/Client/UI/PlayerCursors.cs | 5 +- .../Networking/Packet/SelectedPacket.cs | 34 ++++++++- Source/Tests/SelectedPacketTest.cs | 69 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 Source/Tests/SelectedPacketTest.cs diff --git a/Source/Client/UI/PlayerCursors.cs b/Source/Client/UI/PlayerCursors.cs index d8c8bc990..a22314421 100644 --- a/Source/Client/UI/PlayerCursors.cs +++ b/Source/Client/UI/PlayerCursors.cs @@ -84,7 +84,8 @@ private void SendSelected() lastSelected = selected; - Multiplayer.Client.Send(new ClientSelectedPacket - { reset = reset, newlySelectedIds = add, unselectedIds = remove }); + // Selections over the packet's limit used to throw here (#377) + foreach (var packet in ClientSelectedPacket.Split(reset, add, remove)) + Multiplayer.Client.Send(packet); } } diff --git a/Source/Common/Networking/Packet/SelectedPacket.cs b/Source/Common/Networking/Packet/SelectedPacket.cs index b328dab7d..6c39981a3 100644 --- a/Source/Common/Networking/Packet/SelectedPacket.cs +++ b/Source/Common/Networking/Packet/SelectedPacket.cs @@ -1,5 +1,7 @@ -namespace Multiplayer.Common.Networking.Packet; +using System; +using System.Collections.Generic; +namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Server_Selected)] public record struct ServerSelectedPacket(int playerId, ClientSelectedPacket data) : IPacket @@ -14,9 +16,13 @@ public void Bind(PacketBuffer buf) } } +/// A change of the player's selection: reset clears it first, then the ids are added and removed [PacketDefinition(Packets.Client_Selected)] public record struct ClientSelectedPacket : IPacket { + // Per packet and list. Vanilla selects at most 200 things, but mods can select more. + public const int MaxIds = 200; + public bool reset; public int[] newlySelectedIds; public int[] unselectedIds; @@ -24,7 +30,29 @@ public record struct ClientSelectedPacket : IPacket public void Bind(PacketBuffer buf) { buf.Bind(ref reset); - buf.Bind(ref newlySelectedIds, BinderOf.Int(), maxLength: 200); - buf.Bind(ref unselectedIds, BinderOf.Int(), maxLength: 200); + buf.Bind(ref newlySelectedIds, BinderOf.Int(), maxLength: MaxIds); + buf.Bind(ref unselectedIds, BinderOf.Int(), maxLength: MaxIds); + } + + /// Splits a selection change into packets within the limits. Only the first one resets, so applying them in + /// order (they're sent reliably) gives the whole change. + public static IEnumerable Split(bool reset, int[] added, int[] removed) + { + var count = Math.Max(1, (Math.Max(added.Length, removed.Length) + MaxIds - 1) / MaxIds); + for (int i = 0; i < count; i++) + yield return new ClientSelectedPacket + { + reset = reset && i == 0, + newlySelectedIds = Slice(added, i * MaxIds), + unselectedIds = Slice(removed, i * MaxIds) + }; + } + + private static int[] Slice(int[] ids, int start) + { + var length = Math.Max(0, Math.Min(MaxIds, ids.Length - start)); + var slice = new int[length]; + Array.Copy(ids, Math.Min(start, ids.Length), slice, 0, length); + return slice; } } diff --git a/Source/Tests/SelectedPacketTest.cs b/Source/Tests/SelectedPacketTest.cs new file mode 100644 index 000000000..17cd6cbde --- /dev/null +++ b/Source/Tests/SelectedPacketTest.cs @@ -0,0 +1,69 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[TestFixture] +public class SelectedPacketTest +{ + // What the receiving client does with the packets + private static HashSet Apply(HashSet selection, IEnumerable packets) + { + foreach (var packet in packets) + { + var read = default(ClientSelectedPacket); + read.Bind(new PacketReader(new ByteReader(packet.Serialize().data))); + + if (read.reset) selection.Clear(); + selection.UnionWith(read.newlySelectedIds!); + selection.ExceptWith(read.unselectedIds!); + } + + return selection; + } + + [Test] + public void SmallChangeIsOnePacket() + { + var packets = ClientSelectedPacket.Split(true, [1, 2], [3]).ToList(); + + Assert.That(packets, Has.Count.EqualTo(1)); + Assert.That(packets[0].reset, Is.True); + Assert.That(packets[0].newlySelectedIds, Is.EqualTo(new[] { 1, 2 })); + Assert.That(packets[0].unselectedIds, Is.EqualTo(new[] { 3 })); + } + + [Test] + public void ResetWithoutChangesIsStillSent() + { + var packets = ClientSelectedPacket.Split(true, [], []).ToList(); + + Assert.That(packets, Has.Count.EqualTo(1)); + Assert.That(Apply([5], packets), Is.Empty); + } + + [Test] + public void BigSelectionIsSplitWithinTheLimit() + { + var added = Enumerable.Range(1000, 450).ToArray(); + var removed = Enumerable.Range(0, 10).ToArray(); + + var packets = ClientSelectedPacket.Split(false, added, removed).ToList(); + + Assert.That(packets, Has.Count.EqualTo(3)); + Assert.That(packets.All(p => p.newlySelectedIds.Length <= ClientSelectedPacket.MaxIds)); + Assert.That(packets.Any(p => p.reset), Is.False); + Assert.That(Apply([..removed, 9999], packets), Is.EquivalentTo(added.Append(9999))); + } + + [Test] + public void OnlyTheFirstPartResets() + { + var added = Enumerable.Range(0, 2 * ClientSelectedPacket.MaxIds + 1).ToArray(); + + var packets = ClientSelectedPacket.Split(true, added, []).ToList(); + + Assert.That(packets.Select(p => p.reset), Is.EqualTo(new[] { true, false, false })); + Assert.That(Apply([-1], packets), Is.EquivalentTo(added)); + } +} From d3c7bb1a8ac8b8b1c07e388af9df55f910c5cd66 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:50:38 -0600 Subject: [PATCH 118/183] Don't fill the goodwill situation cache from the interface A cache miss from UI (e.g. the factions tab) cached values on one client only and ran CheckHostilityChanged there; the simulation then read those values. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Determinism.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index f313b62cd..4cf2028e2 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -841,5 +841,25 @@ static int GetBatchCount(int length) => Multiplayer.Client != null ? length : UnityData.GetIdealBatchCount(length); } + // On a cache miss GetSituations fills the shared cache and runs CheckHostilityChanged (relation kind changes, + // letters). Called from the interface (e.g. the factions tab) that happened on one client only, and the values + // cached at that moment were what its simulation read until the next recalculation. Compute uncached values + // for the interface instead. + [HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetSituations))] + static class GoodwillSituationsInInterfacePatch + { + static bool Prefix(GoodwillSituationManager __instance, Faction other, + ref List __result) + { + if (!Multiplayer.InInterface || other == null || other.IsPlayer || __instance.cachedData.ContainsKey(other)) + return true; + + var situations = new List(); + __instance.Recalculate(other, situations); + __result = situations; + return false; + } + } + } From f409a3d5c89329be21c9daa7e7a116eb5221cff5 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:52:51 -0600 Subject: [PATCH 119/183] Run caravan battle wins and ideo ticks for their player faction Multifaction: the world tick runs in the spectator's context, where vanilla's Faction.OfPlayer checks match no player. The caravan battle win check compared the threats with the spectator and recorded its tale with a colonist of the spectator (an error, it has none), and ideo ticking never added date ritual obligations (festivals), which require Faction.OfPlayer to follow the ideo. Force-push the battlefield map's player faction (its parent's or the first one with a humanlike pawn there) around CheckWonBattle and the first player faction following the ideo around Ideo.IdeoTick, as in #961. Its DefeatAllEnemiesQuestComp and TimedForcedExit parts are left out: nothing starts that quest and no def uses the obsolete comp in 1.6. Co-Authored-By: Claude Opus 5.5 --- .../Client/Factions/WorldTickOwnerContext.cs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 Source/Client/Factions/WorldTickOwnerContext.cs diff --git a/Source/Client/Factions/WorldTickOwnerContext.cs b/Source/Client/Factions/WorldTickOwnerContext.cs new file mode 100644 index 000000000..fbb91e275 --- /dev/null +++ b/Source/Client/Factions/WorldTickOwnerContext.cs @@ -0,0 +1,91 @@ +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Multifaction: the world tick runs in the spectator's context, where vanilla's Faction.OfPlayer checks match no +// player. Some world tick code decides saved state or sends letters with them, push the player faction it's about. +// The pushes are forced: the world and the maps hold the spectator's data in the world tick. +static class WorldTickOwnerContext +{ + // A map's player faction: its parent's, or else the first player faction (in the faction list) with a humanlike + // pawn on it. Only synced state is read, so it's the same on every client. + public static Faction MapOwner(Map map) + { + if (map == null) + return null; + + if (IsPlayerFaction(map.ParentFaction)) + return map.ParentFaction; + + foreach (var faction in Find.FactionManager.AllFactionsListForReading) + if (IsPlayerFaction(faction) && map.mapPawns.SpawnedPawnsInFaction(faction).Any(p => p.RaceProps.Humanlike)) + return faction; + + return null; + } + + // The first player faction (in the faction list) following the ideo + public static Faction IdeoFollower(Ideo ideo) + { + foreach (var faction in Find.FactionManager.AllFactionsListForReading) + if (IsPlayerFaction(faction) && faction.ideos != null && faction.ideos.Has(ideo)) + return faction; + + return null; + } + + private static bool IsPlayerFaction(Faction faction) => + faction is { IsPlayer: true } && faction != Multiplayer.WorldComp.spectatorFaction; + + public static bool TryPush(Faction faction) + { + if (faction == null) + return false; + + ((Map)null).PushFaction(faction, force: true); + return true; + } + + public static bool Active => Multiplayer.Client != null && Multiplayer.GameComp.multifaction; +} + +// The win check of a caravan battle (ambush or attacked caravan) compares the threats with Faction.OfPlayer and records +// the tale with a free colonist (an error under the spectator, which has none). The battlefield's own faction is the +// enemy's or none, so the world object patches don't push a player. +[HarmonyPatch(typeof(CaravansBattlefield), nameof(CaravansBattlefield.CheckWonBattle))] +static class CaravansBattlefieldCheckWonBattle_OwnerContext +{ + static void Prefix(CaravansBattlefield __instance, ref bool __state) + { + if (WorldTickOwnerContext.Active && __instance.HasMap) + __state = WorldTickOwnerContext.TryPush(WorldTickOwnerContext.MapOwner(__instance.Map)); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +// Ideos tick in the world tick: date ritual obligations (festivals) need Faction.OfPlayer to follow the ideo (and use +// the time zone of its home), role activity letters go to it. Without a push no player ever got festivals. An ideo +// shared by several player factions gets them for the first one. +[HarmonyPatch(typeof(Ideo), nameof(Ideo.IdeoTick))] +static class IdeoTick_FollowerContext +{ + static void Prefix(Ideo __instance, ref bool __state) + { + if (WorldTickOwnerContext.Active && AsyncTime.AsyncWorldTimeComp.tickingWorld) + __state = WorldTickOwnerContext.TryPush(WorldTickOwnerContext.IdeoFollower(__instance)); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} From 0abd01f7464a6bbaa83fe43bf20a19b8282b8273 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:55:24 -0600 Subject: [PATCH 120/183] Skip season notifications of player factions without a home A faction created mid-game (or one without a map) would take the season at latitude/longitude 0 and could get a season message or the first summer letter once it settles. Keep its last season until it has a home; a new faction's first season is then set without a notification. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimePatches.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index 8f19ee0fe..2f19331c7 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -67,7 +67,7 @@ static bool Prefix(DateNotifier __instance) if (!Multiplayer.GameComp.multifaction) { // Every player plays the host's faction, the world tick runs under it - TickWithHomeTime(__instance); + TickWithHomeTime(__instance, requireHome: false); return false; } @@ -84,7 +84,9 @@ static bool Prefix(DateNotifier __instance) try { - TickWithHomeTime(__instance); + // A faction without a home yet (e.g. just created) would take the season at 0,0 and could get a + // notification when it settles + TickWithHomeTime(__instance, requireHome: true); } catch (Exception e) { @@ -101,9 +103,11 @@ static bool Prefix(DateNotifier __instance) return false; } - static void TickWithHomeTime(DateNotifier notifier) + static void TickWithHomeTime(DateNotifier notifier, bool requireHome) { Map map = notifier.FindPlayerHomeWithMinTimezone(); + if (map == null && requireHome) return; + int prevTicks = Find.TickManager.TicksGame; if (map != null) Find.TickManager.DebugSetTicksGame(map.AsyncTime().mapTicks); From 9b5661affba4f8e19ca62a14720e4c129cdcb20b Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:55:28 -0600 Subject: [PATCH 121/183] Key the item accessibility cache by simulation step in MP PlayerItemAccessibilityUtility reused its Rand-consuming scan within a frame, so quest generation rescanned a client-dependent number of times and could read a scan made by interface code. Reuse now only happens within one command or tick. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Determinism.cs | 48 ++++++++++++++++++++++++++++ Source/Client/Patches/TickPatch.cs | 12 ++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index 4cf2028e2..373bc53c5 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -841,6 +841,54 @@ static int GetBatchCount(int length) => Multiplayer.Client != null ? length : UnityData.GetIdealBatchCount(length); } + // PlayerItemAccessibilityUtility caches its scan of all accessible things per (tile, RealTime.frameCount). Quest + // generation (trade request and beggars quests, also during every random quest selection) calls it from the + // simulation, and the scan uses Rand (leather amounts): whether a call rescans depended on the client's frames, + // and a scan made by interface code could be reused by the simulation. In the simulation, reuse a scan only + // within the same simulation step and only if it was made there. + [HarmonyPatch(typeof(PlayerItemAccessibilityUtility), nameof(PlayerItemAccessibilityUtility.CacheAccessibleThings))] + static class ItemAccessibilityCachePerSimStep + { + private static Game scanGame; + private static PlanetTile scanTile = PlanetTile.Invalid; + private static int scanStep = -1; + + static void Prefix(PlanetTile nearTile, ref bool __state) + { + if (Multiplayer.Client == null) return; + + if (!Multiplayer.Ticking && !Multiplayer.ExecutingCmds) + { + // May rescan and replace the contents + scanGame = null; + return; + } + + if (scanGame == Current.Game && scanTile == nearTile && scanStep == TickPatch.simStep) + { + // Make vanilla reuse the scan + PlayerItemAccessibilityUtility.cachedAccessibleThingsForTile = nearTile; + PlayerItemAccessibilityUtility.cachedAccessibleThingsForFrame = RealTime.frameCount; + } + else + { + // Make vanilla rescan, a frame count is never -1 + PlayerItemAccessibilityUtility.cachedAccessibleThingsForFrame = -1; + scanGame = null; + __state = true; + } + } + + static void Postfix(PlanetTile nearTile, bool __state) + { + if (!__state) return; + + scanGame = Current.Game; + scanTile = nearTile; + scanStep = TickPatch.simStep; + } + } + // On a cache miss GetSituations fills the shared cache and runs CheckHostilityChanged (relation kind changes, // letters). Called from the interface (e.g. the factions tab) that happened on one client only, and the values // cached at that moment were what its simulation read until the next recalculation. Compute uncached values diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index 0aa757d9e..3012fcc02 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -24,6 +24,9 @@ public static class TickPatch public static int workTicks; public static bool currentExecutingCmdIssuedBySelf; public static CommandType? currentExecutingCmdType; + // Changes at the start of every command and every tick of a tickable. Interface code (and so a frame) can't run + // within one step, so caches keyed by it behave the same on every client, unlike ones keyed by frame. + public static int simStep; public static bool serverFrozen; public static int frozenAt; @@ -196,7 +199,12 @@ private static bool RunCmds() if (target == null) { Log.Error($"!!! Tickable of {cmd.mapId} not found! {cmd}"); - } else target.ExecuteCmd(cmd); + } + else + { + simStep++; + target.ExecuteCmd(cmd); + } if (LongEventHandler.eventQueue.Count > 0) return true; // Yield to e.g. join-point creation } @@ -241,6 +249,7 @@ public static bool DoTick(ref bool worked) TickTickable(tickable); } + simStep++; ConstantTicker.Tick(); ticksToRun -= 1; @@ -265,6 +274,7 @@ private static void TickTickable(ITickable tickable) if (timePerTick == 0) break; tickable.TimeToTickThrough -= timePerTick; + simStep++; try { From 417c0ac59ecb7ced296d62f9c0ebc2faaefe9271 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:55:52 -0600 Subject: [PATCH 122/183] Reset the manual priorities marker in a finalizer Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/Game/SyncMarkers.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Source/Client/Syncing/Game/SyncMarkers.cs b/Source/Client/Syncing/Game/SyncMarkers.cs index 4ce5e5140..64c770628 100644 --- a/Source/Client/Syncing/Game/SyncMarkers.cs +++ b/Source/Client/Syncing/Game/SyncMarkers.cs @@ -9,6 +9,7 @@ public static class SyncMarkers [MpPrefix(typeof(MainTabWindow_Work), nameof(MainTabWindow_Work.DoManualPrioritiesCheckbox))] static void ManualPriorities_Prefix() => manualPriorities = true; - [MpPostfix(typeof(MainTabWindow_Work), nameof(MainTabWindow_Work.DoManualPrioritiesCheckbox))] - static void ManualPriorities_Postfix() => manualPriorities = false; + // A finalizer, so a throw in the checkbox doesn't leave the marker set + [MpFinalizer(typeof(MainTabWindow_Work), nameof(MainTabWindow_Work.DoManualPrioritiesCheckbox))] + static void ManualPriorities_Finalizer() => manualPriorities = false; } From d412a1c249bd93d9480c8adc0646ea987bd85e09 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:57:28 -0600 Subject: [PATCH 123/183] Unwind leaked faction context pushes at tick and command boundaries Like the Rand stack: a push left by an exception kept the wrong Faction.OfPlayer and faction data applied after the context. Pop back to the context's own depth and report it once. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 4 ++++ Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 4 ++++ Source/Client/ConstantTicker.cs | 2 ++ Source/Client/Factions/FactionContext.cs | 15 +++++++++++++++ 4 files changed, 25 insertions(+) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index a8afbf573..796cdd8a0 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -114,6 +114,7 @@ public void Tick() tickingMap = map; int randDepth = Rand.stateStack.Count; PreContext(); + int factionDepth = FactionContext.stack.Count; //SimpleProfiler.Start(); @@ -190,6 +191,7 @@ public void Tick() finally { RestoreRandStack(randDepth + 1, this); + FactionContext.RestoreStack(factionDepth, this); PostContext(); Multiplayer.game.sync.TryAddMapRandomState(map.uniqueID, randState); eventCount++; @@ -309,6 +311,7 @@ public void ExecuteCmd(ScheduledCommand cmd) int randDepth = Rand.stateStack.Count; PreContext(); map.PushFaction(cmd.GetFaction(), force: true); + int factionDepth = FactionContext.stack.Count; context.map = map; @@ -377,6 +380,7 @@ public void ExecuteCmd(ScheduledCommand cmd) } RestoreRandStack(randDepth + 1, this); + FactionContext.RestoreStack(factionDepth, this); map.PopFaction(); PostContext(); diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 091c45009..ce88185a5 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -110,6 +110,7 @@ public void Tick() tickingWorld = true; int randDepth = Rand.stateStack.Count; PreContext(); + int factionDepth = FactionContext.stack.Count; try { @@ -159,6 +160,7 @@ public void Tick() finally { AsyncTimeComp.RestoreRandStack(randDepth + 1, this); + FactionContext.RestoreStack(factionDepth, this); PostContext(); tickingWorld = false; @@ -205,6 +207,7 @@ public void ExecuteCmd(ScheduledCommand cmd) int randDepth = Rand.stateStack.Count; PreContext(); FactionExtensions.PushFaction(null, cmd.GetFaction()); + int factionDepth = FactionContext.stack.Count; bool prevDevMode = Prefs.data.devMode; var prevGodMode = DebugSettings.godMode; @@ -291,6 +294,7 @@ public void ExecuteCmd(ScheduledCommand cmd) MpLog.Debug("rand state " + Rand.StateCompressed); AsyncTimeComp.RestoreRandStack(randDepth + 1, this); + FactionContext.RestoreStack(factionDepth, this); FactionExtensions.PopFaction(); PostContext(); TickPatch.currentExecutingCmdIssuedBySelf = false; diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs index 30fbad4f8..f7631befb 100644 --- a/Source/Client/ConstantTicker.cs +++ b/Source/Client/ConstantTicker.cs @@ -155,6 +155,7 @@ private static void TickShipCountdown() int randDepth = Rand.stateStack.Count; world.PreContext(); shipMap.PushFaction(ShipCountdown.shipRoot?.Faction, force: true); + int factionDepth = FactionContext.stack.Count; try { ShipCountdown.CountdownEnded(); @@ -162,6 +163,7 @@ private static void TickShipCountdown() finally { AsyncTimeComp.RestoreRandStack(randDepth + 1, "ShipCountdown"); + FactionContext.RestoreStack(factionDepth, "ShipCountdown"); shipMap.PopFaction(); world.PostContext(); } diff --git a/Source/Client/Factions/FactionContext.cs b/Source/Client/Factions/FactionContext.cs index d87e7819d..0e860595d 100644 --- a/Source/Client/Factions/FactionContext.cs +++ b/Source/Client/Factions/FactionContext.cs @@ -50,6 +50,21 @@ public static void Set(Faction newFaction) Find.FactionManager.ofPlayer = newFaction; } + // Like AsyncTimeComp.RestoreRandStack: a push left unpopped by an exception would leave the wrong + // Faction.OfPlayer and faction data applied after the context ends and keep the stack too deep. Pops (and + // restores what each entry applied) down to the depth the context had after its own pushes. + public static void RestoreStack(int depth, object context) + { + int count = stack.Count; + if (count == depth) return; + + // Too shallow (an extra pop) can't be repaired, only reported + Log.ErrorOnce($"Multiplayer: unbalanced faction push/pop in {context}: stack depth {count}, expected {depth}", 0x2B71C4 ^ depth); + + while (stack.Count > depth) + Factions.FactionExtensions.PopFaction(); + } + public static void Clear() { stack.Clear(); From af91582186a8ff6121e4dff2e322c5753fa218af Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:59:20 -0600 Subject: [PATCH 124/183] Register lords in the shared cross refs Synced data referencing a lord (e.g. jobs of ceremonies) resolved it to null. Registered at runtime and from the save on load, so every client has the same set. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Saving/CrossRefs.cs | 31 +++++++++++++++++++++++++ Source/Client/Saving/SharedCrossRefs.cs | 4 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Source/Client/Saving/CrossRefs.cs b/Source/Client/Saving/CrossRefs.cs index f533a8736..bcbe73c9d 100644 --- a/Source/Client/Saving/CrossRefs.cs +++ b/Source/Client/Saving/CrossRefs.cs @@ -2,6 +2,7 @@ using RimWorld; using RimWorld.Planet; using Verse; +using Verse.AI.Group; namespace Multiplayer.Client { @@ -164,6 +165,30 @@ static void Postfix(Map map) } } + // Lords are referenced by synced data (e.g. jobs exposed by TryTakeOrderedJob), which resolved them to null. + // Registered on every client the same way: at runtime here and from the save in FinalizeLoadingGame. + [HarmonyPatch(typeof(LordManager))] + [HarmonyPatch(nameof(LordManager.AddLord))] + public static class LordManagerAddPatch + { + static void Postfix(Lord newLord) + { + if (Multiplayer.game == null) return; + ScribeUtil.sharedCrossRefs.RegisterLoaded(newLord); + } + } + + [HarmonyPatch(typeof(LordManager))] + [HarmonyPatch(nameof(LordManager.RemoveLord))] + public static class LordManagerRemovePatch + { + static void Postfix(Lord oldLord) + { + if (Multiplayer.game == null) return; + ScribeUtil.sharedCrossRefs.Unregister(oldLord); + } + } + [HarmonyPatch(typeof(MapDeiniter))] [HarmonyPatch(nameof(MapDeiniter.Deinit))] public static class DeinitMapPatch @@ -206,7 +231,13 @@ static void RegisterCrossRefs() ScribeUtil.sharedCrossRefs.RegisterLoaded(precept); foreach (var map in Find.Maps) + { ScribeUtil.sharedCrossRefs.RegisterLoaded(map); + + // Loaded lords don't go through LordManager.AddLord + foreach (var lord in map.lordManager.lords) + ScribeUtil.sharedCrossRefs.RegisterLoaded(lord); + } } } diff --git a/Source/Client/Saving/SharedCrossRefs.cs b/Source/Client/Saving/SharedCrossRefs.cs index 70fc93fcd..5d019f620 100644 --- a/Source/Client/Saving/SharedCrossRefs.cs +++ b/Source/Client/Saving/SharedCrossRefs.cs @@ -2,6 +2,7 @@ using System.Linq; using RimWorld; using Verse; +using Verse.AI.Group; namespace Multiplayer.Client; @@ -31,7 +32,8 @@ public List UnregisterAllFrom(Map map) { if (val is Thing thing && thing.Map == map || val is PassingShip ship && ship.Map == map || - val is Bill bill && bill.Map == map + val is Bill bill && bill.Map == map || + val is Lord lord && lord.lordManager?.map == map ) { Unregister(val); From 017ed3c7ffd5b80e6d602232e3722637a44f1dd7 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:00:20 -0600 Subject: [PATCH 125/183] Recheck the goto job before ending it from a synced drafted move The synced EndCurrentJob executes ticks after the click; the pawn may have started another job by then. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Feedback.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Source/Client/Patches/Feedback.cs b/Source/Client/Patches/Feedback.cs index 19502417c..e3ec4a3ce 100644 --- a/Source/Client/Patches/Feedback.cs +++ b/Source/Client/Patches/Feedback.cs @@ -281,6 +281,11 @@ static bool CustomTryTakeOrderedJob(Pawn_JobTracker self, Job job, JobTag? tag = static void CustomEndCurrentJob(Pawn_JobTracker self, JobCondition condition, bool startNewJob = true, bool canReturnToPool = true) { + // The command executes ticks after the click: repeat vanilla's check so a job the pawn started + // in between isn't ended + if (self.curJob?.def != JobDefOf.Goto) + return; + self.EndCurrentJob(condition, startNewJob, canReturnToPool); } } From 324df634206caa921e05a05fcfb9e1080e0fa5eb Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:11:06 -0600 Subject: [PATCH 126/183] Make the standalone join point test able to fail It couldn't: auto join points were off, the existing player wasn't a valid uploader and the client leaving aborts a started join point anyway. Turn them on, make the existing player a playing one and look for the CreateJoinPoint command, which stays in the log after an abort. Co-Authored-By: Claude Opus 5.5 --- Source/Tests/ServerTest.cs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/Source/Tests/ServerTest.cs b/Source/Tests/ServerTest.cs index dc49cc1fd..23858678d 100644 --- a/Source/Tests/ServerTest.cs +++ b/Source/Tests/ServerTest.cs @@ -124,21 +124,36 @@ public void StandaloneJoinWithExistingPlayer_DoesNotStartJoinPoint() var server = MakeServer(out var port, s => { s.IsStandaloneServer = true; + // Everything a hosted server would need to start a join point on join + s.settings.autoJoinPoint = AutoJoinPointFlags.Join; var existingConn = new RecordingConnection("existing"); existingConn.ChangeState(ConnectionStateEnum.ServerPlaying); - var existingPlayer = new ServerPlayer(100, existingConn); + var existingPlayer = new ServerPlayer(100, existingConn) { hasJoined = true, status = PlayerStatus.Playing }; existingConn.serverPlayer = existingPlayer; s.playerManager.Players.Add(existingPlayer); }); ConnectClient(port, typeof(TestJoiningState)); - // The existing player alone makes the count 1: wait for the client to come and go + // The client leaving would abort a started join point, but its command stays in the log + bool JoinPointStarted() => OnServerThread(server, () => server.worldData.cmds + .Any(raw => ScheduledCommand.Deserialize(new ByteReader(raw)).type == CommandType.CreateJoinPoint)); + + // The existing player alone makes the count 1: wait for the client to come and go (with a join point, it + // would wait for it instead) WaitUntil(() => server.playerManager.Players.Count == 2); - WaitUntil(() => server.playerManager.Players.Count == 1); + WaitUntil(() => server.playerManager.Players.Count == 1 || JoinPointStarted()); - Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(JoinPointStarted(), Is.False); + } + + private static T OnServerThread(MultiplayerServer server, Func func) + { + var result = new TaskCompletionSource(); + server.Enqueue(() => result.SetResult(func())); + Assert.That(result.Task.Wait(2000), Is.True, "The server thread didn't run the action"); + return result.Task.Result; } private void ConnectClient(int port, Type joiningStateType) From 49cd635bc02a617d18adaf6eb934ca3f9ecf3a16 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:11:47 -0600 Subject: [PATCH 127/183] Don't prompt again right after rejecting a Steam join request The client's join packet is reliable, so Steam keeps resending it and posts a new session request: the prompt came back seconds after a reject. Ignore the rejected player's requests for 30 s and close the session to drop what they sent. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Networking/SteamIntegration.cs | 22 +++++++++++++++++--- Source/Client/Session/MultiplayerSession.cs | 2 ++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Source/Client/Networking/SteamIntegration.cs b/Source/Client/Networking/SteamIntegration.cs index aa8112b81..47f8ce6bb 100644 --- a/Source/Client/Networking/SteamIntegration.cs +++ b/Source/Client/Networking/SteamIntegration.cs @@ -37,6 +37,12 @@ public static void InitCallbacks() var session = Multiplayer.session; if (Multiplayer.LocalServer?.settings.steam != true || session == null) return; + // The client's join packet is reliable and Steam keeps asking while it's resent: don't prompt again + // right after a reject + if (session.rejectedSteam.TryGetValue(remoteId, out var rejectedAt) && + Time.realtimeSinceStartup - rejectedAt < RejectedRequestCooldownSecs) + return; + // The remote opens a new session, a delayed close of the old one would tear it down SteamP2PIntegration.KeepSession(remoteId); @@ -60,9 +66,7 @@ public static void InitCallbacks() if (accepted) AcceptPlayerJoinRequest(joinReq.steamId.Value); else - // Rejecting leaves the request unanswered. Forget it, or the player's later requests - // would be ignored as duplicates of this one. - session.pendingSteam.Remove(joinReq.steamId.Value); + RejectPlayerJoinRequest(session, joinReq.steamId.Value); }); } @@ -103,10 +107,22 @@ public static void InitCallbacks() Callback.Create(loaded => SteamImages.GetTexture(loaded.m_iImage, force: true)); } + private const float RejectedRequestCooldownSecs = 30f; + + private static void RejectPlayerJoinRequest(MultiplayerSession session, CSteamID id) + { + // Forget the request, or the player's later ones would be ignored as duplicates of it + session.pendingSteam.Remove(id); + session.rejectedSteam[id] = Time.realtimeSinceStartup; + // Drops what the player sent so far + SteamP2PIntegration.CloseSession(id); + } + public static void AcceptPlayerJoinRequest(CSteamID id) { SteamNetworking.AcceptP2PSessionWithUser(id); Multiplayer.session?.pendingSteam.Remove(id); + Multiplayer.session?.rejectedSteam.Remove(id); Messages.Message("MpSteamAccepted".Translate(), MessageTypeDefOf.PositiveEvent, false); } diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 57be25a0d..66e37d0bd 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -47,6 +47,8 @@ public class MultiplayerSession : IConnectionStatusListener public bool isSyncReference; public List pendingSteam = new(); + // Steam join requests the host rejected, by time (Time.realtimeSinceStartup) + public Dictionary rejectedSteam = new(); public List knownUsers = new(); public const int MaxMessages = 200; From 799efd491743845fae4cab85d4112603fc9547ff Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:12:08 -0600 Subject: [PATCH 128/183] Close due Steam sessions after reading the tick's packets Closing first could cut off a reconnect whose join packet was already waiting on the session when its delayed close came due; read first so the join packet cancels the close. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Networking/NetworkingSteam.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Source/Client/Networking/NetworkingSteam.cs b/Source/Client/Networking/NetworkingSteam.cs index b91269ecb..a64d3719a 100644 --- a/Source/Client/Networking/NetworkingSteam.cs +++ b/Source/Client/Networking/NetworkingSteam.cs @@ -140,9 +140,6 @@ public void Tick() { var playerManager = server.playerManager; - // Closed here rather than on a timer so that it can't race a reconnect handled below - SteamP2PIntegration.CloseDueSessions(remote => FindPlayer(remote) != null); - foreach (var packet in SteamP2PIntegration.ReadPackets(0)) { var player = FindPlayer(packet.remote); @@ -192,6 +189,10 @@ public void Tick() ServerLog.Verbose($"Received a data packet from {packet.remote}, who has no connection"); } } + + // Closed here rather than on a timer so that it can't race a reconnect, and after reading the packets so + // that a reconnect's join packet already waiting on the session cancels the close first + SteamP2PIntegration.CloseDueSessions(remote => FindPlayer(remote) != null); } private ServerPlayer FindPlayer(CSteamID remote) => From 60b457b9fc8965e6fbd4eb0bf8369f4b6112c861 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:12:47 -0600 Subject: [PATCH 129/183] Reset the simulation error count with the world from the server A rejoin keeps the session, so a later desync report counted errors from before the rejoin and the notice never showed again. The world data replaces whatever those errors did: reset when it arrives. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Desyncs/SimulationFailures.cs | 9 ++++++++- Source/Client/Networking/State/ClientLoadingState.cs | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Source/Client/Desyncs/SimulationFailures.cs b/Source/Client/Desyncs/SimulationFailures.cs index bb28f04e9..e20a65648 100644 --- a/Source/Client/Desyncs/SimulationFailures.cs +++ b/Source/Client/Desyncs/SimulationFailures.cs @@ -11,6 +11,13 @@ namespace Multiplayer.Client; /// public static class SimulationFailures { + /// The world from the server replaces whatever the failures did (joining, rejoining after a desync) + public static void Reset(MultiplayerSession session) + { + session.simulationFailures = 0; + session.firstSimulationFailure = null; + } + /// Call from the catch block, next to logging the exception. Touches only local state. public static void Record(string where, Exception e) { @@ -27,7 +34,7 @@ public static void Record(string where, Exception e) // Not translated: the strings live in the separate locale repository. OnMainThread.Enqueue(() => { - if (Multiplayer.session != session) return; + if (Multiplayer.session != session || session.firstSimulationFailure == null) return; const string text = "Multiplayer: an error in the game simulation was skipped on this client. " + "This can cause a desync, the log has the details."; diff --git a/Source/Client/Networking/State/ClientLoadingState.cs b/Source/Client/Networking/State/ClientLoadingState.cs index 9c9df72dc..4f51808eb 100644 --- a/Source/Client/Networking/State/ClientLoadingState.cs +++ b/Source/Client/Networking/State/ClientLoadingState.cs @@ -72,6 +72,8 @@ public void HandleWorldData(ByteReader data) // The server only picks caught up players as the sync reference Multiplayer.session.isSyncReference = false; + // Also on a rejoin, which keeps the session + SimulationFailures.Reset(Multiplayer.session); int tickUntil = data.ReadInt32(); int remoteSentCmds = data.ReadInt32(); From ce0136acf60eb2aa3128dfbaf3340413747a4547 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:17:13 -0600 Subject: [PATCH 130/183] Take the season with the home map's whole clock Setting only TicksGame to the map's ticks left the world's game start tick in TicksAbs. A faction created later in multifaction (with async time) has its own start tick, so its season, first summer warning and initial last season were computed for a wrong date. Use the map's time snapshot like the map contexts do. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimePatches.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index 2f19331c7..c37568047 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -108,9 +108,9 @@ static void TickWithHomeTime(DateNotifier notifier, bool requireHome) Map map = notifier.FindPlayerHomeWithMinTimezone(); if (map == null && requireHome) return; - int prevTicks = Find.TickManager.TicksGame; - if (map != null) - Find.TickManager.DebugSetTicksGame(map.AsyncTime().mapTicks); + // The map's whole clock: a faction created later in multifaction has its own game start tick, which + // TicksAbs (the season) depends on + var prevTime = TimeSnapshot.GetAndSetFromMap(map); running = true; try @@ -120,7 +120,7 @@ static void TickWithHomeTime(DateNotifier notifier, bool requireHome) finally { running = false; - Find.TickManager.DebugSetTicksGame(prevTicks); + prevTime?.Set(); } } } From 2da260310851d1bafd2f1ec437c8a80ddb42972b Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:18:02 -0600 Subject: [PATCH 131/183] Look only at the faction's own homes for its season Map.IsPlayerHome is true for every faction on a gravship landing site, so a faction's season pass could take another player's landing map as its home with the minimum time zone (or for the seasons/temperature checks). Leave the other player factions' maps out during the pass. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimePatches.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index c37568047..e4fee1deb 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -60,6 +60,9 @@ static class DateNotifierPatch { static bool running; + // The faction of the current multifaction pass + public static Faction factionPass; + static bool Prefix(DateNotifier __instance) { if (Multiplayer.Client == null || running) return true; @@ -81,6 +84,7 @@ static bool Prefix(DateNotifier __instance) var prevLastSeason = __instance.lastSeason; __instance.lastSeason = worldComp.factionLastSeasons.TryGetValue(factionId, out var s) ? s : Season.Undefined; ((Map)null).PushFaction(faction, force: true); + factionPass = faction; try { @@ -94,6 +98,7 @@ static bool Prefix(DateNotifier __instance) } finally { + factionPass = null; FactionExtensions.PopFaction(); worldComp.factionLastSeasons[factionId] = __instance.lastSeason; __instance.lastSeason = prevLastSeason; @@ -125,6 +130,19 @@ static void TickWithHomeTime(DateNotifier notifier, bool requireHome) } } + // A faction's season pass looks only at its own homes. Gravship landing sites are homes of every faction in vanilla + // (and a map with the faction's gravship counts too), the other players' maps among them are left out. + [HarmonyPatch(typeof(Map), nameof(Map.IsPlayerHome), MethodType.Getter)] + static class DateNotifierOwnHomes + { + static void Postfix(Map __instance, ref bool __result) + { + if (__result && DateNotifierPatch.factionPass is { } faction && + __instance.ParentFaction is { IsPlayer: true } owner && owner != faction) + __result = false; + } + } + [HarmonyPatch(typeof(TickManager), nameof(TickManager.RegisterAllTickabilityFor))] public static class TickListAdd { From 81ba18413e0f57ff8d42dbdae9110cc37a706b92 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:19:02 -0600 Subject: [PATCH 132/183] Show a faction's season message only to its players Every faction's season pass showed its message on every client and archived it. Show it only on the clients of the pass's faction, as a non-historical message: those take no synced message id and aren't archived, so they can differ between clients. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimePatches.cs | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index e4fee1deb..f184df328 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Reflection.Emit; using HarmonyLib; using Multiplayer.Client.Factions; using RimWorld; @@ -108,6 +109,40 @@ static bool Prefix(DateNotifier __instance) return false; } + static IEnumerable Transpiler(IEnumerable insts) + { + var message = AccessTools.Method(typeof(Messages), nameof(Messages.Message), + [typeof(string), typeof(MessageTypeDef), typeof(bool)]); + var replacement = AccessTools.Method(typeof(DateNotifierPatch), nameof(SeasonMessage)); + var replaced = false; + + foreach (var ci in insts) + { + if (ci.Calls(message)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + replaced = true; + } + + yield return ci; + } + + if (!replaced) + Log.Error("Multiplayer: Messages.Message not found in DateNotifier.DateNotifierTick"); + } + + // A multifaction pass shows its season message only to the faction's players. Not historical: such messages + // take no synced message id (see NextMessageIdPatch) and aren't archived, so showing it on some clients only is + // safe. + static void SeasonMessage(string text, MessageTypeDef def, bool historical) + { + if (factionPass == null) + Messages.Message(text, def, historical); + else if (factionPass == Multiplayer.RealPlayerFaction) + Messages.Message(text, def, historical: false); + } + static void TickWithHomeTime(DateNotifier notifier, bool requireHome) { Map map = notifier.FindPlayerHomeWithMinTimezone(); From bec7f23fb8c31d0c61c0c4900cfa0ef35a6a7139 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:19:23 -0600 Subject: [PATCH 133/183] Document the shared ideo limit of the ideo tick context Only the first player faction following an ideo gets its festival and role letters. The festival date stays the world's: the date trigger compares exact ticks each world tick, which a map clock can repeat or skip. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/WorldTickOwnerContext.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Source/Client/Factions/WorldTickOwnerContext.cs b/Source/Client/Factions/WorldTickOwnerContext.cs index fbb91e275..5c381ff1d 100644 --- a/Source/Client/Factions/WorldTickOwnerContext.cs +++ b/Source/Client/Factions/WorldTickOwnerContext.cs @@ -72,8 +72,11 @@ static void Finalizer(bool __state) } // Ideos tick in the world tick: date ritual obligations (festivals) need Faction.OfPlayer to follow the ideo (and use -// the time zone of its home), role activity letters go to it. Without a push no player ever got festivals. An ideo -// shared by several player factions gets them for the first one. +// the time zone of its home), role activity letters go to it. Without a push no player ever got festivals. +// Limitation: with an ideo shared by several player factions only the first one gets the festival and role letters and +// the festival timing of its home (the obligation itself is the ideo's). Before, none of them did. +// The date is still the world's: the trigger fires on an exact tick each world tick, a map clock can pause (firing it +// again) or run ahead (skipping it). [HarmonyPatch(typeof(Ideo), nameof(Ideo.IdeoTick))] static class IdeoTick_FollowerContext { From c181b797b7819aef0c2ee3b0e5c0590948e4e8c8 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:19:54 -0600 Subject: [PATCH 134/183] Shift the timestamps of all of a destroyed pawn's abilities FixDirectly only went through the pawn's own abilities list, while the save pass for live pawns also reaches the abilities of royal titles, equipment, apparel and hediffs. Use AllAbilitiesForReading without the ideo roles' abilities, which the ideo holds. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/TimestampFixer.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Source/Client/Patches/TimestampFixer.cs b/Source/Client/Patches/TimestampFixer.cs index dc2adcb71..2a0e106d1 100644 --- a/Source/Client/Patches/TimestampFixer.cs +++ b/Source/Client/Patches/TimestampFixer.cs @@ -84,9 +84,12 @@ private static void FixDirectly(Pawn p) ProcessExposable(p.skills); ProcessExposable(p.royalty); + // Also the abilities of titles, equipment, apparel and hediffs, like the save pass. Not the ideo roles' ones, + // the ideo holds those if (p.abilities != null) - foreach (var ability in p.abilities.abilities) - ProcessExposable(ability); + foreach (var ability in p.abilities.AllAbilitiesForReading) + if (ability.sourcePrecept is not Precept_Role) + ProcessExposable(ability); if (p.royalty != null) foreach (var permit in p.royalty.AllFactionPermits) From 546b68da94d47f36ed53f3d5a56790a596c5fe28 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:20:23 -0600 Subject: [PATCH 135/183] Skip the battlefield owner push after the battle is won CheckWonBattle runs on every tick interval of the battlefield but does nothing once won, the owner scan and forced push were wasted. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/WorldTickOwnerContext.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/Client/Factions/WorldTickOwnerContext.cs b/Source/Client/Factions/WorldTickOwnerContext.cs index 5c381ff1d..ab9239704 100644 --- a/Source/Client/Factions/WorldTickOwnerContext.cs +++ b/Source/Client/Factions/WorldTickOwnerContext.cs @@ -60,7 +60,8 @@ static class CaravansBattlefieldCheckWonBattle_OwnerContext { static void Prefix(CaravansBattlefield __instance, ref bool __state) { - if (WorldTickOwnerContext.Active && __instance.HasMap) + // The check does nothing once won, skip the owner scan + if (WorldTickOwnerContext.Active && !__instance.wonBattle && __instance.HasMap) __state = WorldTickOwnerContext.TryPush(WorldTickOwnerContext.MapOwner(__instance.Map)); } From 65bfd9d1081b96447bdb84d763550a16be331972 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:24:17 -0600 Subject: [PATCH 136/183] Follow the viewed map for VTR every frame instead of catching transitions Viewer counts only changed through the CurrentMap setter and world map toggles, and only from a view the server had been told about. A multifaction joiner starts on the world map without a current map, so it was never counted; its first switch to its new colony then took one viewer away from the host's map, leaving the host's map at the no-viewer rate. Command execution also changes the map index directly. The client now compares what it looks at with what it last reported once per frame (outside long events and reloads), and a new connection starts unreported, also after a rejoin. Co-Authored-By: Claude Opus 5.5 --- .../Networking/State/ClientLoadingState.cs | 4 + Source/Client/OnMainThread.cs | 2 + Source/Client/Patches/VTRSyncPatch.cs | 94 +++++-------------- 3 files changed, 32 insertions(+), 68 deletions(-) diff --git a/Source/Client/Networking/State/ClientLoadingState.cs b/Source/Client/Networking/State/ClientLoadingState.cs index 9c9df72dc..cc6b07fd5 100644 --- a/Source/Client/Networking/State/ClientLoadingState.cs +++ b/Source/Client/Networking/State/ClientLoadingState.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Linq; using Ionic.Zlib; +using Multiplayer.Client.Patches; using Multiplayer.Client.Saving; using Multiplayer.Common; using Verse; @@ -70,6 +71,9 @@ public void HandleWorldData(ByteReader data) int factionId = data.ReadInt32(); Multiplayer.session.myFactionId = factionId; + // The server knows this connection's view from scratch (also after a rejoin, which keeps the session) + VTRSync.Reset(); + // The server only picks caught up players as the sync reference Multiplayer.session.isSyncReference = false; diff --git a/Source/Client/OnMainThread.cs b/Source/Client/OnMainThread.cs index 866c50248..b991fa802 100644 --- a/Source/Client/OnMainThread.cs +++ b/Source/Client/OnMainThread.cs @@ -3,6 +3,7 @@ using Multiplayer.Client.DebugUi; using Multiplayer.Client.Desyncs; using Multiplayer.Client.Networking; +using Multiplayer.Client.Patches; using Multiplayer.Client.Util; using Multiplayer.Common; using UnityEngine; @@ -33,6 +34,7 @@ public void Update() Multiplayer.session.Update(); SyncFieldUtil.UpdateSync(); + VTRSync.UpdateView(); if (PerformanceRecorder.IsRecording) { diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index 98d2f01d5..925bd35fc 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -54,7 +54,6 @@ static class VTRSync public const int WorldMapId = -2; public const int InvalidMapId = -1; public static int lastMovedToMapId = InvalidMapId; - public static int lastSentAtTick = -1; // Vtr rates public const int MaximumVtr = 15; @@ -65,9 +64,24 @@ static class VTRSync public static int CurrentViewId() => WorldRendererUtility.WorldSelected ? WorldMapId : Find.CurrentMap?.uniqueID ?? InvalidMapId; - // Hosting creates the game with a save and reload, which doesn't report the view (see below), so the host - // announces the view it starts with. Without it the host's map ran at the no-viewer rate until the host - // switched maps. Joiners report theirs when their load sets the current map. + // Called every frame. The counts follow what the player looks at however the view changed: catching only + // the CurrentMap setter and world map toggles missed views that never went through them. A multifaction + // joiner starts on the world without a current map, command execution changes the map index directly, + // and hosting creates the game with a save and reload (during which nothing is reported). + public static void UpdateView() + { + if (Multiplayer.arbiterInstance || Multiplayer.reloading || LongEventHandler.AnyEventNowOrWaiting) + return; + + if (Multiplayer.session.client.State != ConnectionStateEnum.ClientPlaying) + return; + + int view = CurrentViewId(); + if (view != lastMovedToMapId) + SendViewedMapUpdate(lastMovedToMapId, view); + } + + // The host reports its view as soon as its server runs public static void AnnounceCurrentView() => SendViewedMapUpdate(lastMovedToMapId, CurrentViewId()); public static void ResetAllPlayerCounts() @@ -77,6 +91,8 @@ public static void ResetAllPlayerCounts() map.AsyncTime()?.ResetPlayerCount(); } + // previous must be what the server last got from this client: the server and every client apply the + // transition to the synced counts public static void SendViewedMapUpdate(int previous, int current) { // Join point reloads keep the saved counts, the view doesn't change @@ -87,68 +103,24 @@ public static void SendViewedMapUpdate(int previous, int current) if (Multiplayer.arbiterInstance) return; - string warn = string.Empty; - if (previous != lastMovedToMapId) - warn = $" mismatch between expected previous map {previous} and last moved to map {lastMovedToMapId}"; - else if (previous == current) return; - int currentTick = Find.TickManager?.TicksGame ?? 0; - MpLog.Debug($"VTR MapSwitchPatch: {lastMovedToMapId}->{current} @ tick {currentTick}{warn}"); + if (previous == current) return; + + MpLog.Debug($"VTR view: {previous}->{current} @ tick {Find.TickManager?.TicksGame ?? 0}"); Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(previous, current)); lastMovedToMapId = current; } + // A new connection starts unknown to the server (which counts its own record of the player's view) public static void Reset() { lastMovedToMapId = InvalidMapId; - lastSentAtTick = -1; - WorldRenderModePatch.lastRenderMode = WorldRenderMode.None; - } - } - - [HarmonyPatch(typeof(Game), nameof(Game.CurrentMap), MethodType.Setter)] - static class MapSwitchPatch - { - static void Prefix(Map value) - { - if (Multiplayer.Client == null) return; - - try - { - // WorldRenderModePatch will handle it - if (VTRSync.lastMovedToMapId == VTRSync.WorldMapId) return; - int previousMap = GetPreviousMapId(); - int newMap = value?.uniqueID ?? VTRSync.InvalidMapId; - int currentTick = Find.TickManager?.TicksGame ?? 0; - - if (previousMap == newMap) return; - if (VTRSync.lastMovedToMapId == newMap && currentTick == VTRSync.lastSentAtTick) return; - - VTRSync.SendViewedMapUpdate(previousMap, newMap); - VTRSync.lastSentAtTick = currentTick; - } - catch (Exception ex) - { - MpLog.Error($"VTR MapSwitchPatch error: {ex}"); - } - } - - private static int GetPreviousMapId() - { - bool currentMapIsRemovedAndWasLatestMap = Current.Game.currentMapIndex >= Find.Maps.Count; - - if (currentMapIsRemovedAndWasLatestMap) - { - return VTRSync.InvalidMapId; - } - - return Find.CurrentMap?.uniqueID ?? VTRSync.InvalidMapId; } } [HarmonyPatch(typeof(WorldRendererUtility), nameof(WorldRendererUtility.CurrentWorldRenderMode), MethodType.Getter)] static class WorldRenderModePatch { - internal static WorldRenderMode lastRenderMode = WorldRenderMode.None; + private static WorldRenderMode lastRenderMode = WorldRenderMode.None; static void Postfix(WorldRenderMode __result) { @@ -156,28 +128,14 @@ static void Postfix(WorldRenderMode __result) try { - // Detect transition to world map (Planet mode) + // Detect transition to world map (Planet mode). Viewer counts are updated by VTRSync.UpdateView. if (__result == WorldRenderMode.Planet && lastRenderMode != WorldRenderMode.Planet) { - if (VTRSync.lastMovedToMapId != VTRSync.InvalidMapId && VTRSync.lastMovedToMapId != VTRSync.WorldMapId) - { - 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) Autosaving.SendAutosavingRequest(JoinPointRequestReason.WorldTravel, TickPatch.Timer); } - // Detect transition back to tile map - else if (__result != WorldRenderMode.Planet && lastRenderMode == WorldRenderMode.Planet) - { - var newMap = Find.CurrentMap?.uniqueID ?? VTRSync.InvalidMapId; - if (newMap != VTRSync.InvalidMapId && VTRSync.lastMovedToMapId == VTRSync.WorldMapId) - { - VTRSync.SendViewedMapUpdate(VTRSync.WorldMapId, newMap); - } - } lastRenderMode = __result; } From 537333003553be37431c805da9020bee6cf5c845 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:33:09 -0600 Subject: [PATCH 137/183] Switch map contexts for nested world tick incidents on another map A nested incident for another map ran in the outer map's context (time, Rand, faction). Leave the outer context for it and re-enter it afterwards; a nested call for the same map stays in its context. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/StorytellerPatches.cs | 70 ++++++++----------- 1 file changed, 30 insertions(+), 40 deletions(-) diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index 4b3b32442..83fab8ce3 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -90,59 +90,49 @@ static void Postfix(ref List __result) } } -[HarmonyPatch(typeof(StorytellerUtility), nameof(StorytellerUtility.DefaultParmsNow))] -static class MapContextIncidentParms +// The world tick runs incident code for maps (incident parms, TryExecute); it runs in that map's context. Incidents can +// nest (tickingWorld stays set): a nested call for the same map stays in its context, one for another map leaves the +// outer map's context for the duration and re-enters it afterwards, so each map's time, Rand stream and faction apply. +static class WorldTickMapContext { - static void Prefix(IIncidentTarget target, ref Map __state) + public static void Enter(IIncidentTarget target, out (Map map, Map prev) state) { - // This may be running inside a context already - if (AsyncTimeComp.tickingMap != null) - return; + state = default; + if (!AsyncWorldTimeComp.tickingWorld || target is not Map map) return; - if (AsyncWorldTimeComp.tickingWorld && target is Map map) - { - AsyncTimeComp.tickingMap = map; - map.AsyncTime().PreContext(); - __state = map; - } + var prev = AsyncTimeComp.tickingMap; + if (prev == map) return; + + prev?.AsyncTime().PostContext(); + AsyncTimeComp.tickingMap = map; + map.AsyncTime().PreContext(); + state = (map, prev); } - static void Finalizer(Map __state) + public static void Exit((Map map, Map prev) state) { - if (__state != null) - { - __state.AsyncTime().PostContext(); - AsyncTimeComp.tickingMap = null; - } + if (state.map == null) return; + + state.map.AsyncTime().PostContext(); + AsyncTimeComp.tickingMap = state.prev; + state.prev?.AsyncTime().PreContext(); } } +[HarmonyPatch(typeof(StorytellerUtility), nameof(StorytellerUtility.DefaultParmsNow))] +static class MapContextIncidentParms +{ + static void Prefix(IIncidentTarget target, out (Map, Map) __state) => WorldTickMapContext.Enter(target, out __state); + + static void Finalizer((Map, Map) __state) => WorldTickMapContext.Exit(__state); +} + [HarmonyPatch(typeof(IncidentWorker), nameof(IncidentWorker.TryExecute))] static class MapContextIncidentExecute { - static void Prefix(IncidentParms parms, ref Map __state) - { - // Incidents can execute nested ones (tickingWorld stays set); entering the context again would - // clear tickingMap and pop the outer context's state when the inner call returns - if (AsyncTimeComp.tickingMap != null) - return; + static void Prefix(IncidentParms parms, out (Map, Map) __state) => WorldTickMapContext.Enter(parms.target, out __state); - if (AsyncWorldTimeComp.tickingWorld && parms.target is Map map) - { - AsyncTimeComp.tickingMap = map; - map.AsyncTime().PreContext(); - __state = map; - } - } - - static void Finalizer(Map __state) - { - if (__state != null) - { - __state.AsyncTime().PostContext(); - AsyncTimeComp.tickingMap = null; - } - } + static void Finalizer((Map, Map) __state) => WorldTickMapContext.Exit(__state); } [HarmonyPatch(typeof(Settlement), nameof(Settlement.IncidentTargetTags))] From aa636c573320d962777ef23d5a0d9b797df3c88f Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:34:49 -0600 Subject: [PATCH 138/183] End only the goto a drafted stop order was aimed at The synced EndCurrentJob executes ticks later: a goto ordered in between was ended too. Pass the current job's id and check it. Not the position, which changes while the pawn keeps walking until then. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Feedback.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Source/Client/Patches/Feedback.cs b/Source/Client/Patches/Feedback.cs index e3ec4a3ce..f98eab47d 100644 --- a/Source/Client/Patches/Feedback.cs +++ b/Source/Client/Patches/Feedback.cs @@ -277,13 +277,20 @@ static bool CustomTryTakeOrderedJob(Pawn_JobTracker self, Job job, JobTag? tag = // 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) { - // The command executes ticks after the click: repeat vanilla's check so a job the pawn started - // in between isn't ended - if (self.curJob?.def != JobDefOf.Goto) + // Vanilla only calls this while the current job is a Goto: remember which one + SyncedEndGotoJob(self, condition, startNewJob, canReturnToPool, self.curJob?.loadID ?? -1); + } + + [SyncMethod] + static void SyncedEndGotoJob(Pawn_JobTracker self, JobCondition condition, bool startNewJob, + bool canReturnToPool, int jobId) + { + // The command executes ticks after the click: end only the goto the order was aimed at, not a job or + // another goto the pawn started in between. Not its position: the pawn keeps walking until then. + if (self.curJob == null || self.curJob.loadID != jobId || self.curJob.def != JobDefOf.Goto) return; self.EndCurrentJob(condition, startNewJob, canReturnToPool); From 25558d456d9fbe79ddbc4bfc374d8581ed5ec367 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:35:28 -0600 Subject: [PATCH 139/183] Check unnatural corpse reservations of every player faction In multifaction Faction.OfPlayer is the spectator on maps no player owns, so a corpse reserved by a player's pawn counted as unreserved and teleported. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Anomaly.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Source/Client/Patches/Anomaly.cs b/Source/Client/Patches/Anomaly.cs index 31c8f9d7f..8a75d7e76 100644 --- a/Source/Client/Patches/Anomaly.cs +++ b/Source/Client/Patches/Anomaly.cs @@ -73,8 +73,18 @@ static bool Prefix(UnnaturalCorpse __instance, ref bool __result) if (Multiplayer.Client == null) return true; - __result = !(__instance.SpawnedOrAnyParentSpawned && - __instance.MapHeld.reservationManager.IsReservedByAnyoneOf(__instance, Faction.OfPlayer)); + __result = !(__instance.SpawnedOrAnyParentSpawned && ReservedByAnyPlayer(__instance)); + return false; + } + + // Faction.OfPlayer is the spectator when ticking a map no player faction owns (multifaction), so check the + // reservations of every player faction + static bool ReservedByAnyPlayer(UnnaturalCorpse corpse) + { + var reservations = corpse.MapHeld.reservationManager; + foreach (var faction in Find.FactionManager.AllFactionsListForReading) + if (faction.IsPlayer && reservations.IsReservedByAnyoneOf(corpse, faction)) + return true; return false; } } From 64c25c49096c290af15281b04c0ff40bce78d098 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:36:06 -0600 Subject: [PATCH 140/183] Limit the psychic ritual offering fix to MP and the required count Keep singleplayer vanilla and stop moving offerings into the carry tracker once it holds the required amount. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Anomaly.cs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/Source/Client/Patches/Anomaly.cs b/Source/Client/Patches/Anomaly.cs index 8a75d7e76..445bc05f1 100644 --- a/Source/Client/Patches/Anomaly.cs +++ b/Source/Client/Patches/Anomaly.cs @@ -90,23 +90,45 @@ static bool ReservedByAnyPlayer(UnnaturalCorpse corpse) } // #855: vanilla moves the offering out of the invoker's inventory while enumerating it; moving a whole stack - // throws (collection modified) and the psychic ritual never completes. Same logic over a snapshot. + // throws (collection modified) and the psychic ritual never completes. Same logic over a snapshot, holding no + // more than the required count. [HarmonyPatch(typeof(PsychicRitualToil_InvokeHorax), nameof(PsychicRitualToil_InvokeHorax.HoldRequiredOfferings))] static class PsychicRitualToil_InvokeHorax_HoldRequiredOfferings { static bool Prefix(PsychicRitualToil_InvokeHorax __instance, PsychicRitual psychicRitual) { + if (Multiplayer.Client == null) + return true; + var offering = __instance.requiredOffering; if (offering == null) return false; + int required = Mathf.CeilToInt(offering.GetBaseCount()); + foreach (Pawn pawn in psychicRitual.assignments.AssignedPawns(__instance.invokerRole)) + { foreach (Thing thing in pawn.inventory.GetDirectlyHeldThings().ToList()) + { + int missing = required - HeldOfferingCount(pawn, offering); + if (missing <= 0) + break; + if (offering.filter.Allows(thing)) - pawn.inventory.innerContainer.TryTransferToContainer(thing, pawn.carryTracker.innerContainer, - Mathf.CeilToInt(offering.GetBaseCount())); + pawn.inventory.innerContainer.TryTransferToContainer(thing, pawn.carryTracker.innerContainer, missing); + } + } return false; } + + static int HeldOfferingCount(Pawn pawn, IngredientCount offering) + { + int count = 0; + foreach (Thing held in pawn.carryTracker.innerContainer) + if (offering.filter.Allows(held)) + count += held.stackCount; + return count; + } } } \ No newline at end of file From a3e2414f1d75141919a020f0180b3bd11b173a7c Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:37:36 -0600 Subject: [PATCH 141/183] Derive the debug command's world view from its context A world command sent from a map view (lister or float menu opened in a world command, map targeter) replayed with WorldSelected false, so GetTarget fell back to the local Find.CurrentMap. World commands now replay under the planet view and map commands under their map, and the targeter sends its map. The command data is read before any override is installed. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Debug/DebugSync.cs | 63 ++++++++++++++++---------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/Source/Client/Debug/DebugSync.cs b/Source/Client/Debug/DebugSync.cs index 01aad6695..791bb46d5 100644 --- a/Source/Client/Debug/DebugSync.cs +++ b/Source/Client/Debug/DebugSync.cs @@ -22,18 +22,19 @@ static class DebugSync public static void HandleCmd(ByteReader data) { - currentPlayer = data.ReadInt32(); + // Read everything before changing any state, so a malformed command can't leave the overrides set + int player = data.ReadInt32(); var source = (DebugSource)data.ReadInt32(); int cursorX = data.ReadInt32(); int cursorZ = data.ReadInt32(); + int hash = data.ReadInt32(); + var path = data.ReadStringNullable(); + int selectedId = data.ReadInt32(); - if (Multiplayer.MapContext != null) - MouseCellPatch.result = new IntVec3(cursorX, 0, cursorZ); - else - MouseTilePatch.result = cursorX; + Log.Message($"Debug tool {source} ({cursorX}, {cursorZ}) {hash} {path}"); - currentHash = data.ReadInt32(); - var path = data.ReadStringNullable(); + currentPlayer = player; + currentHash = hash; var state = Multiplayer.game.playerDebugState.GetOrAddNew(currentPlayer); @@ -46,29 +47,31 @@ public static void HandleCmd(ByteReader data) Find.Selector.selected = new List(); FieldRefs.worldSelected(Find.WorldSelector) = new List(); - int selectedId = data.ReadInt32(); - - // Replay under the acting player's view: vanilla debug actions read it (e.g. the incident - // actions pick their target from DebugActionsIncidents.GetTarget) - WorldSelectedPatch.result = data.ReadBool(); - - if (Multiplayer.MapContext != null) - { - var thing = Multiplayer.ThingsById.GetValueSafe(selectedId); - if (thing != null) - Find.Selector.selected.Add(thing); - } - else + try { - var obj = Find.WorldObjects.AllWorldObjects.FirstOrDefault(w => w.ID == selectedId); - if (obj != null) - Find.WorldSelector.selected.Add(obj); - } + if (Multiplayer.MapContext != null) + MouseCellPatch.result = new IntVec3(cursorX, 0, cursorZ); + else + MouseTilePatch.result = cursorX; - Log.Message($"Debug tool {source} ({cursorX}, {cursorZ}) {currentHash} {path}"); + // Vanilla debug actions read the view (e.g. the incident actions pick their target in + // DebugActionsIncidents.GetTarget, which falls back to Find.CurrentMap). Replay world commands under + // the planet view and map commands under the command's map (it's Find.CurrentMap while executing). + WorldSelectedPatch.result = Multiplayer.MapContext == null; + + if (Multiplayer.MapContext != null) + { + var thing = Multiplayer.ThingsById.GetValueSafe(selectedId); + if (thing != null) + Find.Selector.selected.Add(thing); + } + else + { + var obj = Find.WorldObjects.AllWorldObjects.FirstOrDefault(w => w.ID == selectedId); + if (obj != null) + Find.WorldSelector.selected.Add(obj); + } - try - { if (source == DebugSource.Tree) { var node = RecreateGraphAndGetNode(path); @@ -161,9 +164,6 @@ public static void SendCmd(DebugSource source, int hash, string path, Map map, I else writer.WriteInt32(Find.WorldSelector.SingleSelectedObject?.ID ?? -1); - // Sent like the cursor and selection above, so the action resolves the same target everywhere - writer.WriteBool(WorldRendererUtility.WorldSelected); - Multiplayer.WriterLog.AddCurrentNode(writer); int mapId = map?.uniqueID ?? ScheduledCommand.Global; @@ -267,7 +267,8 @@ static bool Prefix(ref Action action) action = targetInfo => { - DebugSync.SendCmd(DebugSource.Targeter, 0, null, null, targetInfo.Cell); + // A map targeter: the action uses the targeted map (Find.CurrentMap), which a map command provides + DebugSync.SendCmd(DebugSource.Targeter, 0, null, Find.CurrentMap, targetInfo.Cell); }; return true; From c5d05669883255c1e6dc6718b0d9843040ab2330 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:38:13 -0600 Subject: [PATCH 142/183] Restore the map of a non-forced faction push when unwinding Unwinding leaked pushes popped without a map, so a leaked map.PushFaction left that map with the leaked faction's data. Non-forced pushes now keep their map with the stack entry and a pop without a map restores it. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/FactionExtensions.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Source/Client/Factions/FactionExtensions.cs b/Source/Client/Factions/FactionExtensions.cs index 2206a1fe4..14f87278f 100644 --- a/Source/Client/Factions/FactionExtensions.cs +++ b/Source/Client/Factions/FactionExtensions.cs @@ -24,6 +24,8 @@ public static void PushFaction(this Map map, Faction f, bool force = false) if (force) FactionContext.SetTopState(AppliedFactions.Capture()); + else if (map != null) + FactionContext.SetTopState(map); // Lets an unwind (FactionContext.RestoreStack) restore the map too Multiplayer.WorldComp?.SetFaction(faction); if (force) @@ -52,7 +54,7 @@ public static Faction PopFaction(this Map map) } Multiplayer.WorldComp?.SetFaction(faction); - map?.MpComp().SetFaction(faction); + (map ?? state as Map)?.MpComp()?.SetFaction(faction); return faction; } From a6a4ab7b46d40dc5db28d528536f82091aaa2c64 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:38:56 -0600 Subject: [PATCH 143/183] Remove the spectator through vanilla's faction removal when converting to SP Removing it from the faction list alone left references in pawns, listers, letters, the play log, quests, ideos, tales and map events of the converted save. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Saving/ConvertToSp.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Source/Client/Saving/ConvertToSp.cs b/Source/Client/Saving/ConvertToSp.cs index dcdf15a5e..2dafc7154 100644 --- a/Source/Client/Saving/ConvertToSp.cs +++ b/Source/Client/Saving/ConvertToSp.cs @@ -30,13 +30,11 @@ private static void PrepareSingleplayer() var spectator = Multiplayer.WorldComp.spectatorFaction; if (spectator != null) { - // Destination reservations are keyed by faction and saved as references, an entry for the - // spectator (created e.g. in its simulation context) would be a dangling reference in the save - foreach (var map in Find.Maps) - map.pawnDestinationReservationManager.Notify_FactionRemoved(spectator); - - spectator.RemoveAllRelations(); - Find.FactionManager.allFactions.Remove(spectator); + // Vanilla's removal clears what refers to the faction (relations, its pawns, destination reservations, + // listers, letters, play log, quests, ideos, tales, map events) so the save has no dangling references. + // It only accepts temporary factions. + spectator.temporary = true; + Find.FactionManager.Remove(spectator); } } From 524e05e0829196d270c4962a17fed1acf468fa51 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:39:38 -0600 Subject: [PATCH 144/183] Count pocket map owners and all held pawns for multifaction storytellers A pocket map belongs to its source map's owner, and any pawn of the faction on an unowned map (also mechs, animals, pawns in shuttles or pods) lets its storyteller target the map, like vanilla targets every map. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/StorytellerPatches.cs | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index 83fab8ce3..23e883fb9 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -55,18 +55,33 @@ static void Postfix(List __result) } // In multifaction every player faction's storyteller ticks on every map (FactionRepeater). Keep each one - // to its own maps and to unowned maps (sites etc.) where it has people, so faction A's incidents + // to its own maps and to unowned maps (sites etc.) where it has pawns, so faction A's incidents // (e.g. Map_Misc ones while A's colonists visit) don't land on faction B's home. static bool FactionCanReceiveIncidentsOn(Map map, Faction faction) { if (!Multiplayer.GameComp.multifaction) return true; - if (map.ParentFaction == faction) + var owner = OwnerFaction(map); + if (owner == faction) return true; - return map.ParentFaction is not { IsPlayer: true } && - map.mapPawns.SpawnedPawnsInFaction(faction).Any(p => p.RaceProps.Humanlike); + if (owner is { IsPlayer: true }) + return false; + + // Also pawns held on the map (carried, in a shuttle or pod...). Only evaluated when the storyteller makes + // incidents (every 1000 ticks), the unspawned ones need a scan of the map's holders. + return map.mapPawns.SpawnedPawnsInFaction(faction).Count > 0 || map.mapPawns.PawnsInFaction(faction).Count > 0; + } + + // A pocket map (e.g. an undercave) belongs to the owner of the map it was opened from + static Faction OwnerFaction(Map map) + { + var owningMap = map; + for (int i = 0; i < 8 && owningMap.IsPocketMap && owningMap.PocketMapParent?.sourceMap is { } source; i++) + owningMap = source; + + return owningMap.ParentFaction; } } From a3ccabd56b93346b8273b841bc8a2bd37b878802 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:40:23 -0600 Subject: [PATCH 145/183] Treat sim long events as simulation steps for the item accessibility cache Classify calls by InInterface, so sim long events use the per-step key instead of the vanilla per-frame cache, and start a new step when one is entered. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Determinism.cs | 3 ++- Source/Client/Patches/LongEvents.cs | 3 +++ Source/Client/Patches/TickPatch.cs | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index 373bc53c5..008fc6b91 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -857,7 +857,8 @@ static void Prefix(PlanetTile nearTile, ref bool __state) { if (Multiplayer.Client == null) return; - if (!Multiplayer.Ticking && !Multiplayer.ExecutingCmds) + // Also sim long events, loading etc.: every simulation step (TickPatch.simStep) starts with a new key + if (Multiplayer.InInterface) { // May rescan and replace the contents scanGame = null; diff --git a/Source/Client/Patches/LongEvents.cs b/Source/Client/Patches/LongEvents.cs index 4833cd22f..7d738904e 100644 --- a/Source/Client/Patches/LongEvents.cs +++ b/Source/Client/Patches/LongEvents.cs @@ -202,6 +202,9 @@ public void Enter() prev = current; current = this; + // Runs in a later frame than the tick or command which queued it, so it's a step of its own + TickPatch.simStep++; + Rand.PushState(seed); if (faction != null && game == Multiplayer.game) diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index 3012fcc02..34f527de7 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -24,7 +24,7 @@ public static class TickPatch public static int workTicks; public static bool currentExecutingCmdIssuedBySelf; public static CommandType? currentExecutingCmdType; - // Changes at the start of every command and every tick of a tickable. Interface code (and so a frame) can't run + // Changes at the start of every command, tick of a tickable and sim long event. Interface code (and so a frame) can't run // within one step, so caches keyed by it behave the same on every client, unlike ones keyed by frame. public static int simStep; public static bool serverFrozen; From 5fb7c98b66a86b31517947bf64b4cc30fd1ccba7 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:17:38 -0600 Subject: [PATCH 146/183] Tick story watcher adaptation once per game tick The world tick (vanilla DoSingleTick) ticks the story watcher and every map tick ticked it again, so with N maps adaptation (raid points factor) and pop adaptation grew (1+N) times as fast as in single player. Map ticks now only resolve the colonists downed during them, the periodic growth runs in the world tick only. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 9 ++++++ Source/Client/AsyncTime/StorytellerPatches.cs | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 19d7d6491..55acf4e6f 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using HarmonyLib; +using Multiplayer.Client.AsyncTime; using Multiplayer.Client.Comp; using Multiplayer.Client.Factions; using Multiplayer.Client.Patches; @@ -157,6 +158,10 @@ public void Tick() SimulationFailures.Record("storyteller", e); } + // The world tick (vanilla DoSingleTick) already ticks the story watcher once per game tick, ticking it + // here too made adaptation and pop adaptation grow once more per map. Only resolve the colonists downed + // during this map tick, which vanilla does later in the same tick. + StoryWatcherMapTick.downedOnly = true; try { Find.StoryWatcher.StoryWatcherTick(); @@ -166,6 +171,10 @@ public void Tick() Log.Error(e.ToString()); SimulationFailures.Record("story watcher", e); } + finally + { + StoryWatcherMapTick.downedOnly = false; + } try { diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index 23e883fb9..0dc913451 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -28,6 +28,36 @@ static bool Prefix() static void Finalizer() => updating = false; } +// Set while a map tick calls StoryWatcherTick (see AsyncTimeComp.Tick): the periodic adaptation growth runs only in the +// world tick, so once per game tick like vanilla +public static class StoryWatcherMapTick +{ + public static bool downedOnly; +} + +[HarmonyPatch(typeof(StoryWatcher_Adaptation), nameof(StoryWatcher_Adaptation.AdaptationWatcherTick))] +static class AdaptationWatcherMapTickPatch +{ + static bool Prefix(StoryWatcher_Adaptation __instance) + { + if (!StoryWatcherMapTick.downedOnly) return true; + + // The first half of the original + var downed = __instance.pawnsJustDownedThisTick; + for (int i = 0; i < downed.Count; i++) + __instance.ResolvePawnEvent(downed[i], AdaptationEvent.Downed); + downed.Clear(); + + return false; + } +} + +[HarmonyPatch(typeof(StoryWatcher_PopAdaptation), nameof(StoryWatcher_PopAdaptation.PopAdaptationWatcherTick))] +static class PopAdaptationWatcherMapTickPatch +{ + static bool Prefix() => !StoryWatcherMapTick.downedOnly; +} + [HarmonyPatch(typeof(Storyteller))] [HarmonyPatch(nameof(Storyteller.AllIncidentTargets), MethodType.Getter)] public class StorytellerTargetsPatch From f59d6b7de3625b0ab42c99f908bc91c15097b19c Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:18:14 -0600 Subject: [PATCH 147/183] Pick interface melee verbs without building verb entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the interface TryGetMeleeVerb is answered by the patch instead of the Rand-based vanilla choice. It used GetUpdatedAvailableVerbsList, whose VerbEntry constructor is O(n²) in the number of verbs, for every selected drafted pawn several times per frame. Reuse the current melee verb when it is still usable, otherwise find the first non-Worst usable verb in O(n), and cache the answer for the frame. Stays read-only: no Rand, no change to curMeleeVerb. Fixes #308 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Determinism.cs | 92 +++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index 0048fe4c3..93396fe7a 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -50,16 +50,102 @@ static class TryGetMeleeVerbPatch { static bool Cancel => Multiplayer.Client != null && Multiplayer.InInterface; + // The melee gizmo of every selected drafted pawn and pawn rendering ask for a melee verb several times per frame + // (#308), so interface answers are cached for the current frame + private static int cacheFrame = -1; + private static readonly Dictionary<(Pawn_MeleeVerbs, Thing), Verb> cache = new(); + private static readonly List usableVerbs = new(); + static bool Prefix() { - // Namely FloatMenuUtility.GetMeleeAttackAction + // The original uses Rand and changes curMeleeVerb (namely called by FloatMenuUtility.GetMeleeAttackAction) return !Cancel; } static void Postfix(Pawn_MeleeVerbs __instance, Thing target, ref Verb __result) { - if (Cancel) - __result = __instance.GetUpdatedAvailableVerbsList(false).FirstOrDefault(ve => ve.GetSelectionWeight(target) != 0).verb; + if (!Cancel) return; + + if (cacheFrame != Time.frameCount) + { + cacheFrame = Time.frameCount; + cache.Clear(); + } + + if (!cache.TryGetValue((__instance, target), out var verb)) + { + verb = InterfaceMeleeVerb(__instance, target); + cache[(__instance, target)] = verb; + } + + __result = verb; + } + + // Read-only: no Rand, doesn't touch curMeleeVerb or the vanilla verb lists (except in the fallback) + private static Verb InterfaceMeleeVerb(Pawn_MeleeVerbs meleeVerbs, Thing target) + { + var pawn = meleeVerbs.pawn; + var cur = meleeVerbs.curMeleeVerb; + + if (cur != null && cur.IsStillUsableBy(pawn) && UsableOn(cur, target)) + return cur; + + // Same result as the first VerbEntry with a non-zero selection weight, without building VerbEntries: + // their constructor calls VerbUtility.FinalSelectionWeight, which iterates all verbs (O(n²)) + try + { + CollectUsableMeleeVerbs(pawn); + + float highestWeight = 0f; + foreach (var v in usableVerbs) + highestWeight = Math.Max(highestWeight, VerbUtility.InitialVerbWeight(v, pawn)); + + foreach (var v in usableVerbs) + if (v.GetSelectionCategory(pawn, highestWeight) != VerbSelectionCategory.Worst && UsableOn(v, target)) + return v; + } + finally + { + usableVerbs.Clear(); + } + + // Fall back to the vanilla list in case a mod adds verbs to it + return meleeVerbs.GetUpdatedAvailableVerbsList(false).FirstOrDefault(ve => ve.GetSelectionWeight(target) != 0).verb; + } + + // Mirrors VerbEntry.GetSelectionWeight being non-zero for a verb that isn't in the Worst category + private static bool UsableOn(Verb v, Thing target) => + v.IsUsableOn(target) && (target?.def?.IsEdifice() != true || v.verbProps.commonalityVsEdificeFactor != 0f); + + // Mirrors the non-terrain branch of Pawn_MeleeVerbs.GetUpdatedAvailableVerbsList + private static void CollectUsableMeleeVerbs(Pawn pawn) + { + void Add(List verbs) + { + if (verbs == null) return; + foreach (var v in verbs) + if (v.IsMeleeAttack && v.IsStillUsableBy(pawn)) + usableVerbs.Add(v); + } + + Add(pawn.verbTracker.AllVerbs); + + if (pawn.equipment != null) + foreach (var eq in pawn.equipment.AllEquipmentListForReading) + Add(eq.GetComp()?.AllVerbs); + + if (pawn.apparel != null) + foreach (var apparel in pawn.apparel.WornApparel) + Add(apparel.GetComp()?.AllVerbs); + + foreach (var v in pawn.health.hediffSet.GetHediffsVerbs()) + if (v.IsMeleeAttack && v.IsStillUsableBy(pawn)) + usableVerbs.Add(v); + + if (ModsConfig.AnomalyActive && pawn.IsMutant) + foreach (var v in pawn.mutant.AllVerbs) + if (v.IsMeleeAttack && v.IsStillUsableBy(pawn)) + usableVerbs.Add(v); } } From 17c7354cae89472d321116207a9aeb65905ce301 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:19:28 -0600 Subject: [PATCH 148/183] Don't filter quest threat generators in single player The storyteller prefix sets the updating flag in single player too, where there is no map context, so the postfix dropped every QuestPart_ThreatsGenerator and quest threats never fired in single player with the mod loaded. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/StorytellerPatches.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index 0dc913451..dbe5bcd12 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -122,7 +122,8 @@ public class QuestPartsListForReadingPatch { static void Postfix(ref List __result) { - if (StorytellerTickPatch.updating) + // The storyteller prefix also sets the flag in single player, where there is no map context + if (Multiplayer.Client != null && StorytellerTickPatch.updating) { __result = __result.Where(questPart => { if (questPart is QuestPart_ThreatsGenerator questPartThreatsGenerator) From 46c06a72aeb61a3e6c9bbe0eb510e9a70a3ca4cd Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:20:16 -0600 Subject: [PATCH 149/183] Fire multifaction quest threats from one storyteller per map Every player faction's storyteller ticks on each map (FactionRepeater) and walks the shared quest list, and threat generator incident counts are seeded by the world, target and salt, so all of them fired the same quest raids at the same moment. Quest incident makers now run only for one faction per map: its owner, else the lowest id player faction with pawns there, else the lowest id player faction. Part of #659 and #884 Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/StorytellerPatches.cs | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index dbe5bcd12..73747e2ed 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -99,9 +99,38 @@ static bool FactionCanReceiveIncidentsOn(Map map, Faction faction) if (owner is { IsPlayer: true }) return false; - // Also pawns held on the map (carried, in a shuttle or pod...). Only evaluated when the storyteller makes - // incidents (every 1000 ticks), the unspawned ones need a scan of the map's holders. - return map.mapPawns.SpawnedPawnsInFaction(faction).Count > 0 || map.mapPawns.PawnsInFaction(faction).Count > 0; + return HasPawnsOn(map, faction); + } + + // Also pawns held on the map (carried, in a shuttle or pod...). Only evaluated when the storyteller makes + // incidents (every 1000 ticks), the unspawned ones need a scan of the map's holders. + static bool HasPawnsOn(Map map, Faction faction) => + map.mapPawns.SpawnedPawnsInFaction(faction).Count > 0 || map.mapPawns.PawnsInFaction(faction).Count > 0; + + // In multifaction the single player faction whose storyteller runs the quest incident makers (threat generators) + // of a map: its owner, else the lowest id player faction with pawns there, else the lowest id player faction. + // Every faction's storyteller walks the same quest list, so without this each one fired the same raids. + public static Faction QuestIncidentsFaction(Map map) + { + var worldComp = Multiplayer.WorldComp; + var spectator = worldComp.spectatorFaction; + + if (map != null && OwnerFaction(map) is { IsPlayer: true } owner && owner != spectator) + return owner; + + Faction first = null; + foreach (var (id, _) in worldComp.factionData) // Sorted by id + { + if (id == spectator?.loadID || Find.FactionManager.GetById(id) is not { } faction) + continue; + + if (map == null || HasPawnsOn(map, faction)) + return faction; + + first ??= faction; + } + + return first; } // A pocket map (e.g. an undercave) belongs to the owner of the map it was opened from @@ -123,14 +152,20 @@ public class QuestPartsListForReadingPatch static void Postfix(ref List __result) { // The storyteller prefix also sets the flag in single player, where there is no map context - if (Multiplayer.Client != null && StorytellerTickPatch.updating) + if (Multiplayer.Client != null && StorytellerTickPatch.updating && __result.Any(p => p is IIncidentMakerQuestPart)) { - __result = __result.Where(questPart => { - if (questPart is QuestPart_ThreatsGenerator questPartThreatsGenerator) - { - return questPartThreatsGenerator.mapParent?.Map == Multiplayer.MapContext; - } - return true; + var mapContext = Multiplayer.MapContext; + + // Multifaction: each player faction's storyteller ticks (FactionRepeater) and incident counts are seeded + // by the world, target and salt, so all of them would fire the same quest incidents at the same moment + bool incidentMakersAllowed = !Multiplayer.GameComp.multifaction || + Faction.OfPlayer == StorytellerTargetsPatch.QuestIncidentsFaction(mapContext); + + __result = __result.Where(questPart => questPart switch + { + QuestPart_ThreatsGenerator threats => incidentMakersAllowed && threats.mapParent?.Map == mapContext, + IIncidentMakerQuestPart => incidentMakersAllowed, + _ => true }).ToList(); } } From 03f3ae586703471e5ff9e7afbae52ddc86ccd138 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:20:28 -0600 Subject: [PATCH 150/183] Ignore line endings in mod XML file hashes Defs and Patches were compared by the CRC32 of their raw bytes, so the same mod checked out with CRLF on Windows and LF on Linux showed up as a file mismatch when joining. The XML parser normalizes line endings anyway, so hash the files with CRLF/CR rewritten as LF. Assemblies are still hashed raw. Both sides compute the hashes with the same code, and protocol 57 is not released yet. Fixes #386 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Networking/JoinData.cs | 3 +- Source/Client/Util/Extensions.cs | 9 +++++ Source/Common/Util/Extensions.cs | 21 +++++++++++ Source/Tests/LineEndingNormalizationTest.cs | 39 +++++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 Source/Tests/LineEndingNormalizationTest.cs diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index 286f9dfe5..e4a57b5b6 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -116,10 +116,11 @@ internal static ModFileDict GetModFiles(IEnumerable modIds) fileDict.Add(modId, new ModFile(asm.FullName, relPath, asm.CRC32())); } + // The XML parser normalizes line endings, so a mod copied between Windows and Linux isn't a mismatch foreach (var xmlFile in GetModDefsAndPatches(contentPack)) { var relPath = xmlFile.FullName.RemovePrefix(contentPack.RootDir).NormalizePath(); - fileDict.Add(modId, new ModFile(xmlFile.FullName, relPath, xmlFile.CRC32())); + fileDict.Add(modId, new ModFile(xmlFile.FullName, relPath, xmlFile.CRC32IgnoringLineEndings())); } } diff --git a/Source/Client/Util/Extensions.cs b/Source/Client/Util/Extensions.cs index 5e521eda3..4b3436355 100644 --- a/Source/Client/Util/Extensions.cs +++ b/Source/Client/Util/Extensions.cs @@ -236,6 +236,15 @@ public static int CRC32(this FileInfo file) return new CRC32().GetCrc32(stream); } + // For text files whose line endings depend on the OS they were saved or checked out on (#386) + public static int CRC32IgnoringLineEndings(this FileInfo file) + { + var data = File.ReadAllBytes(file.FullName); + var crc = new CRC32(); + crc.SlurpBlock(data, 0, Utils.NormalizeLineEndingsInPlace(data)); + return crc.Crc32Result; + } + public static int AggregateHash(this IEnumerable e) { return e.Aggregate(0, (a, b) => Gen.HashCombineInt(a, b)); diff --git a/Source/Common/Util/Extensions.cs b/Source/Common/Util/Extensions.cs index 0ba2814bf..ad45cfdef 100644 --- a/Source/Common/Util/Extensions.cs +++ b/Source/Common/Util/Extensions.cs @@ -174,6 +174,27 @@ public static byte[] GetMD5(IEnumerable strings) } public static long MillisNow => DateTime.Now.Ticks / TimeSpan.TicksPerMillisecond; + + // Rewrites CRLF and lone CR as LF in place and returns the new length. Byte-wise, so it's meant for hashing + // text files that may come from another OS, not for decoding them + public static int NormalizeLineEndingsInPlace(byte[] data) + { + int len = 0; + for (int i = 0; i < data.Length; i++) + { + byte b = data[i]; + if (b == '\r') + { + if (i + 1 < data.Length && data[i + 1] == '\n') + i++; + b = (byte)'\n'; + } + + data[len++] = b; + } + + return len; + } } } diff --git a/Source/Tests/LineEndingNormalizationTest.cs b/Source/Tests/LineEndingNormalizationTest.cs new file mode 100644 index 000000000..6c32f243b --- /dev/null +++ b/Source/Tests/LineEndingNormalizationTest.cs @@ -0,0 +1,39 @@ +using System.Text; + +namespace Tests; + +[TestFixture] +public class LineEndingNormalizationTest +{ + private static string Normalize(string text) + { + var data = Encoding.UTF8.GetBytes(text); + var len = Multiplayer.Common.Utils.NormalizeLineEndingsInPlace(data); + return Encoding.UTF8.GetString(data, 0, len); + } + + [Test] + public void WindowsAndOldMacEndingsBecomeLf() + { + const string lf = "\n \n\n"; + + Assert.That(Normalize("\r\n \r\n\r\n"), Is.EqualTo(lf)); + Assert.That(Normalize("\r \r\r"), Is.EqualTo(lf)); + Assert.That(Normalize(lf), Is.EqualTo(lf)); + } + + [Test] + public void MixedEndingsAndBlankLines() + { + Assert.That(Normalize("a\r\n\r\nb\r\rc\n\rd"), Is.EqualTo("a\n\nb\n\nc\n\nd")); + Assert.That(Normalize("\r"), Is.EqualTo("\n")); + Assert.That(Normalize("\r\n"), Is.EqualTo("\n")); + Assert.That(Normalize(""), Is.EqualTo("")); + } + + [Test] + public void OtherBytesAreKept() + { + Assert.That(Normalize("ünïcødé\tтекст"), Is.EqualTo("ünïcødé\tтекст")); + } +} From 201829a1d796e410773664c2f5896ecbeae47c9b Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:25:27 -0600 Subject: [PATCH 151/183] Offer saving the game after a disconnect The disconnected window only allowed quitting to the main menu, so a client lost everything since the host's last save when the server closed or the connection dropped. While disconnecting, before the session and the multiplayer game are destroyed, keep the last join point snapshot (which already gets every received command appended) and the replay info. The window's Save button writes them as a save that simulates to the tick this client had reached; it doesn't read or change any game state, and the window stays open so the player can quit afterwards. Fixes #333 Fixes #937 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Saving/Replay.cs | 42 +++++++-------- Source/Client/Session/Autosaving.cs | 54 +++++++++++++++++-- Source/Client/Session/MultiplayerSession.cs | 9 ++++ .../Client/Session/SessionDisconnectInfo.cs | 2 + Source/Client/Windows/DisconnectedWindow.cs | 4 +- 5 files changed, 85 insertions(+), 26 deletions(-) diff --git a/Source/Client/Saving/Replay.cs b/Source/Client/Saving/Replay.cs index f3f5b6b65..c9cca39a9 100644 --- a/Source/Client/Saving/Replay.cs +++ b/Source/Client/Saving/Replay.cs @@ -25,7 +25,10 @@ public class Replay public void WriteCurrentData() => WriteData(Multiplayer.session.dataSnapshot); - public void WriteData(GameDataSnapshot gameData) + public void WriteData(GameDataSnapshot gameData) => WriteData(gameData, TickPatch.Timer); + + // endTick: the tick loading the replay simulates to + public void WriteData(GameDataSnapshot gameData, int endTick) { string sectionId = info.sections.Count.ToString("D3"); using var zip = CreateZipWrite(); @@ -37,7 +40,7 @@ public void WriteData(GameDataSnapshot gameData) zip.AddEntry($"world/{sectionId}_cmds", ScheduledCommand.SerializeCmds(gameData.Cmds)); zip.AddEntry($"world/{sectionId}_save", gameData.GameData); - info.sections.Add(new ReplaySection(gameData.CachedAtTime, TickPatch.Timer)); + info.sections.Add(new ReplaySection(gameData.CachedAtTime, endTick)); zip.AddEntry("info", ReplayInfo.Write(info)); } @@ -92,26 +95,23 @@ public static FileInfo SavedReplayFile(string fileName, string folder = null) public static Replay ForLoading(FileInfo file) => new(file); public static Replay ForSaving(string fileName) => ForSaving(SavedReplayFile(fileName)); - public static Replay ForSaving(FileInfo file) - { - var replay = new Replay(file) - { - info = new ReplayInfo - { - name = Multiplayer.session.gameName, - playerFaction = Multiplayer.session.myFactionId, - spectatorFaction = Multiplayer.WorldComp.spectatorFaction.loadID, - protocol = MpVersion.Protocol, - rwVersion = VersionControl.CurrentVersionStringWithRev, - modIds = LoadedModManager.RunningModsListForReading.Select(m => m.PackageId).ToList(), - modNames = LoadedModManager.RunningModsListForReading.Select(m => m.Name).ToList(), - asyncTime = Multiplayer.GameComp.asyncTime, - multifaction = Multiplayer.GameComp.multifaction - } - }; + public static Replay ForSaving(FileInfo file) => ForSaving(file, CurrentInfo()); - return replay; - } + public static Replay ForSaving(FileInfo file, ReplayInfo info) => new(file) { info = info }; + + // Info of the current session, without sections + public static ReplayInfo CurrentInfo() => new() + { + name = Multiplayer.session.gameName, + playerFaction = Multiplayer.session.myFactionId, + spectatorFaction = Multiplayer.WorldComp.spectatorFaction.loadID, + protocol = MpVersion.Protocol, + rwVersion = VersionControl.CurrentVersionStringWithRev, + modIds = LoadedModManager.RunningModsListForReading.Select(m => m.PackageId).ToList(), + modNames = LoadedModManager.RunningModsListForReading.Select(m => m.Name).ToList(), + asyncTime = Multiplayer.GameComp.asyncTime, + multifaction = Multiplayer.GameComp.multifaction + }; public static void LoadReplay(FileInfo file, bool toEnd = false, Action after = null, Action cancel = null, string simTextKey = null, bool showTimeline = true) { diff --git a/Source/Client/Session/Autosaving.cs b/Source/Client/Session/Autosaving.cs index 0b8b6bbff..903daeece 100644 --- a/Source/Client/Session/Autosaving.cs +++ b/Source/Client/Session/Autosaving.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using Multiplayer.Common; @@ -58,6 +59,13 @@ public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, bool cur currentReplay ? Multiplayer.session.dataSnapshot : null); public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameDataSnapshot snapshot) + => SaveGameToFile_Overwrite(fileNameNoExtension, file => + Replay.ForSaving(file).WriteData( + snapshot ?? SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false) + )); + + // writeReplay writes the save to the given (temporary) file + private static bool SaveGameToFile_Overwrite(string fileNameNoExtension, Action writeReplay) { Log.Message($"Multiplayer: saving to file {fileNameNoExtension}"); @@ -67,9 +75,7 @@ public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameData if (File.Exists(tmpPath)) File.Delete(tmpPath); - Replay.ForSaving(new FileInfo(tmpPath)).WriteData( - snapshot ?? SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false) - ); + writeReplay(new FileInfo(tmpPath)); var dstPath = Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip"); if (File.Exists(dstPath)) @@ -78,7 +84,8 @@ public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameData File.Move(tmpPath, dstPath); Messages.Message("MpGameSaved".Translate(fileNameNoExtension), MessageTypeDefOf.SilentInput, false); - Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup; + if (Multiplayer.session != null) + Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup; return true; } catch (Exception e) @@ -88,4 +95,43 @@ public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameData return false; } } + + // Called while disconnecting, before the session and the multiplayer game are gone (#333, #937). Keeps what a + // save needs: the last join point snapshot, which every command received since is appended to, and the replay + // info. Loading the save simulates the commands up to the tick this client had reached. Returns null when + // there's no game to save. + public static Action CaptureDisconnectSave() + { + var session = Multiplayer.session; + if (session == null || session.replay || session.dataSnapshot == null || Multiplayer.arbiterInstance || + Multiplayer.Client?.State != ConnectionStateEnum.ClientPlaying || + Current.ProgramState != ProgramState.Playing) + return null; + + try + { + var snapshot = session.dataSnapshot with { Cmds = new List(session.dataSnapshot.Cmds) }; + var endTick = TickPatch.Timer; + var info = Replay.CurrentInfo(); + + var baseName = $"Disconnected-{GenFile.SanitizedFileName(session.gameName ?? "")}"; + var fileName = baseName; + for (int i = 2; File.Exists(Path.Combine(Multiplayer.ReplaysDir, $"{fileName}.zip")); i++) + fileName = $"{baseName}-{i}"; + + return () => LongEventHandler.QueueLongEvent(() => + { + SaveGameToFile_Overwrite(fileName, file => + { + info.sections.Clear(); + Replay.ForSaving(file, info).WriteData(snapshot, endTick); + }); + }, "MpSaving", false, null); + } + catch (Exception e) + { + Log.Error($"Multiplayer: couldn't keep the game for saving after disconnecting: {e}"); + return null; + } + } } diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 66e37d0bd..b773ee4ba 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -122,6 +122,15 @@ public void Disconnected(SessionDisconnectInfo info) { MpUI.ClearWindowStack(); + if (info.specialButtonTranslated == null && Autosaving.CaptureDisconnectSave() is { } save) + { + info.specialButtonTranslated = "MpSaveGameAfterDisconnect".TryTranslate(out var label) + ? label + : "SaveGameButton".Translate(); + info.specialButtonAction = save; + info.specialButtonKeepsWindow = true; + } + Find.WindowStack.Add(new DisconnectedWindow(info) { returnToServerBrowser = Multiplayer.Client?.State != ConnectionStateEnum.ClientPlaying diff --git a/Source/Client/Session/SessionDisconnectInfo.cs b/Source/Client/Session/SessionDisconnectInfo.cs index f31eff056..79483c8b0 100644 --- a/Source/Client/Session/SessionDisconnectInfo.cs +++ b/Source/Client/Session/SessionDisconnectInfo.cs @@ -12,6 +12,8 @@ public struct SessionDisconnectInfo public string descTranslated; public string specialButtonTranslated; public Action specialButtonAction; + // The window stays open after the special button, e.g. to save the game and then quit to the main menu + public bool specialButtonKeepsWindow; public bool wideWindow; public static SessionDisconnectInfo From(ServerDisconnectPacket goodbye) => diff --git a/Source/Client/Windows/DisconnectedWindow.cs b/Source/Client/Windows/DisconnectedWindow.cs index b7d1b4b5b..083299557 100644 --- a/Source/Client/Windows/DisconnectedWindow.cs +++ b/Source/Client/Windows/DisconnectedWindow.cs @@ -80,8 +80,10 @@ private void DrawButtons(Rect inRect) if (Widgets.ButtonText(connectAsBtn, info.specialButtonTranslated)) { - returnToServerBrowser = false; info.specialButtonAction(); + if (info.specialButtonKeepsWindow) return; + + returnToServerBrowser = false; Close(); } } From db58b6520f778ebfd60103c4e79625fdd40758a4 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:26:11 -0600 Subject: [PATCH 152/183] Explain disconnects caused by a server state exception When the server's joining or loading state threw, the client only showed "Disconnected" with no hint that the error is in the host's log. Fixes #797 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Session/SessionDisconnectInfo.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Source/Client/Session/SessionDisconnectInfo.cs b/Source/Client/Session/SessionDisconnectInfo.cs index 79483c8b0..218b36acf 100644 --- a/Source/Client/Session/SessionDisconnectInfo.cs +++ b/Source/Client/Session/SessionDisconnectInfo.cs @@ -102,6 +102,17 @@ public static SessionDisconnectInfo From(MpDisconnectReason reason, ByteReader r descKey = "MpChangeUsernameInfo"; } + // The server's joining or loading state threw (#797); the details are only in the server's log + if (reason == MpDisconnectReason.StateException) + { + disconnectInfo.titleTranslated = "MpServerStateException".TryTranslate(out var title) + ? title + : "Server error while joining"; + disconnectInfo.descTranslated = "MpServerStateExceptionDesc".TryTranslate(out var desc) + ? desc + : "See the host's log (or the standalone server's log) for the error."; + } + if (reason == MpDisconnectReason.ServerClosed) titleKey = "MpServerClosed"; if (reason == MpDisconnectReason.ServerFull) titleKey = "MpServerFull"; if (reason == MpDisconnectReason.ServerStarting) titleKey = "MpDisconnectServerStarting"; From 783d500fe77429ebd127415bda7b24085c75b931 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:27:03 -0600 Subject: [PATCH 153/183] Load synced starting pawns like vanilla loads pawns SendPawn's pawn was read by the sync layer in Playing: the needs tracker's constructor then evaluated Need_Food.MaxLevel as a stat over hediffs whose pawn isn't set yet (NRE, and a stat of a half-loaded pawn could stay cached). The pawn's ideoligion belongs to the unregistered temporary page faction, so the reference didn't resolve, a random fallback ideo was assigned and CreateFaction's SetIdeo then recorded an ideo change and kept the fallback as a previous ideo. The issuer now writes the pawn without an ideo and SendPawn reads it itself in MapInitializing with the fallback ideo disabled; CreateFaction sets the ideo from none. Same wire format as before. Fixes #536, fixes #537 Co-Authored-By: Claude Opus 5.5 --- .../Client/Factions/FactionCreationPatches.cs | 14 ++++ Source/Client/Factions/FactionCreator.cs | 66 ++++++++++++++++++- Source/Client/Factions/FactionSidebar.cs | 2 +- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/Source/Client/Factions/FactionCreationPatches.cs b/Source/Client/Factions/FactionCreationPatches.cs index c34880290..492cff6a9 100644 --- a/Source/Client/Factions/FactionCreationPatches.cs +++ b/Source/Client/Factions/FactionCreationPatches.cs @@ -45,6 +45,20 @@ static bool Prefix() } } +// Starting pawns are sent without an ideoligion (FactionCreator.WriteStartingPawn), CreateFaction gives them the new +// faction's. Pawn_IdeoTracker's PostLoadInit would otherwise assign a random fallback one. +[HarmonyPatch(typeof(Pawn), nameof(Pawn.ShouldHaveIdeo), MethodType.Getter)] +static class NoFallbackIdeoForStartingPawnPatch +{ + static bool Prefix(ref bool __result) + { + if (!FactionCreator.readingStartingPawn) return true; + + __result = false; + return false; + } +} + [HarmonyPatch(typeof(StartingPawnUtility), nameof(StartingPawnUtility.GetGenerationRequest))] static class StartingPawnUtility_GetGenerationRequest_Patch { diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index 1e819020e..caa10f165 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -24,10 +24,54 @@ public static void ClearData() pawnStore.Clear(); } - [SyncMethod(exposeParameters = new[] { 1 })] - public static void SendPawn(int playerId, Pawn p) + // Set while SendPawn reads a starting pawn, see NoFallbackIdeoForStartingPawnPatch + public static bool readingStartingPawn; + + [SyncMethod] + public static void SendPawn(int playerId, StartingPawnData data) + { + if (ReadStartingPawn(data.xml) is { } pawn) + pawnStore.GetOrAddNew(playerId).Add(pawn); + } + + // The page's pawns believe in the temporary page faction's ideoligion, which isn't registered: on load the + // reference didn't resolve, the pawn got a random fallback ideoligion and CreateFaction's SetIdeo then recorded + // an ideo change and kept the fallback as a previous ideo (#537). Send them without one, CreateFaction sets it. + public static StartingPawnData WriteStartingPawn(Pawn pawn) + { + var tracker = pawn.ideo; + var ideo = tracker?.ideo; + if (tracker != null) tracker.ideo = null; + + try + { + return new StartingPawnData { xml = ScribeUtil.WriteExposable(pawn) }; + } + finally + { + if (tracker != null) tracker.ideo = ideo; + } + } + + private static Pawn ReadStartingPawn(byte[] xml) { - pawnStore.GetOrAddNew(playerId).Add(p); + if (xml == null) return null; + + // Vanilla loads pawns only while a game initializes. In Playing the needs tracker's constructor evaluates + // Need_Food.MaxLevel as a stat over hediffs whose pawn is set only when cross refs resolve (#536). + var prevState = Current.programStateInt; + Current.programStateInt = ProgramState.MapInitializing; + readingStartingPawn = true; + + try + { + return ScribeUtil.ReadExposable(xml); + } + finally + { + readingStartingPawn = false; + Current.programStateInt = prevState; + } } [SyncMethod] @@ -302,6 +346,22 @@ public static int GetStartingPawnsCountForScenario(Scenario scenario) return 0; } } + +// A starting pawn's XML, read inside SendPawn (not by the sync layer) so that the read runs in a load-like state. +// Same wire format as the exposed Pawn parameter it replaces. +public class StartingPawnData : ISynchronizable +{ + public byte[] xml; + + public void Sync(SyncWorker sync) + { + if (sync is WritingSyncWorker writer) + writer.Writer.WritePrefixedBytes(xml); + else if (sync is ReadingSyncWorker reader) + xml = reader.Reader.ReadPrefixedBytes(); + } +} + public record FactionCreationData : ISyncSimple { public string factionName; diff --git a/Source/Client/Factions/FactionSidebar.cs b/Source/Client/Factions/FactionSidebar.cs index d7724a585..90baa6e5b 100644 --- a/Source/Client/Factions/FactionSidebar.cs +++ b/Source/Client/Factions/FactionSidebar.cs @@ -381,7 +381,7 @@ private static void DoCreateFaction(IdeologyData chooseIdeoInfo, bool generateMa if (Current.Game.InitData?.startingAndOptionalPawns is { } pawns) for (int i = 0; i < Find.GameInitData.startingPawnCount; i++) { - FactionCreator.SendPawn(playerId, pawns[i]); + FactionCreator.SendPawn(playerId, FactionCreator.WriteStartingPawn(pawns[i])); startingPawns.Add(pawns[i]); } From 0d00fcc7d36ab4baad71b1eed0c29adbe51452bb Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:29:19 -0600 Subject: [PATCH 154/183] Stop collecting desync traces after the stack walk throws On some setups (macOS through Rosetta) the deferred stack walk throws on every Rand call; the exceptions were caught but repeated so often that the game stalled. Turn trace collection off for the rest of the process after the first exception. The window being built is marked unreliable (now before anything else in the failure path can throw) and later windows of this client carry no trace hashes, which other clients don't compare, so this can't cause a false trace desync. Fixes #856 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Desyncs/DeferredStackTracing.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index 8efe7afcb..996598d78 100644 --- a/Source/Client/Desyncs/DeferredStackTracing.cs +++ b/Source/Client/Desyncs/DeferredStackTracing.cs @@ -32,6 +32,12 @@ static IEnumerable TargetMethods() private static bool loggedTraceFailure; + // Set by the first exception thrown by the stack walk, until the game restarts. On some setups (e.g. macOS + // through Rosetta) the walk throws on every Rand call, which stalls the game (#856). Traces are diagnostic + // only: the window being built is marked unreliable below and this client's later windows have no trace + // hashes, which aren't compared. + public static bool tracingFailed; + public static void Postfix() { if (Native.LmfPtr == 0) return; @@ -54,19 +60,21 @@ public static void Postfix() } catch (Exception e) { + tracingFailed = true; LogTraceFailureOnce(e.ToString()); + Log.Warning("Multiplayer: desync stack traces are turned off until the game restarts"); depth = -1; } if (depth < 0) { - if (DeferredStackTracingImpl.lastFailureReason != null) - LogTraceFailureOnce(DeferredStackTracingImpl.lastFailureReason); - // Keep the trace count in line with other clients but don't compare this window's trace hashes depth = 0; hash = 0; Multiplayer.game.sync.MarkTracesUnreliable(); + + if (DeferredStackTracingImpl.lastFailureReason != null) + LogTraceFailureOnce(DeferredStackTracingImpl.lastFailureReason); } Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); @@ -99,6 +107,7 @@ private static void LogTraceFailureOnce(string info) public static bool ShouldAddStackTraceForDesyncLog() { + if (tracingFailed) return false; if (!SupportsDeferredStackTracing) return false; if (Multiplayer.Client == null) return false; if (Multiplayer.settings.desyncTracingMode == DesyncTracingMode.None) return false; From b9ad3956229f492b7cd2426658d4b927586a1681 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:31:48 -0600 Subject: [PATCH 155/183] Stop the launch destination targeter when the launchable is gone While one player picks a destination for transport pods or a shuttle, another can launch the group or cancel its loading. The world targeter's label getter then threw every frame and the targeter couldn't be closed. Track which launchable started the targeting and stop it once the launchable is destroyed, despawned (if it was spawned; caravan shuttles aren't) or no longer loading. UI only. Fixes #365 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/LaunchTargeting.cs | 69 ++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 Source/Client/Patches/LaunchTargeting.cs diff --git a/Source/Client/Patches/LaunchTargeting.cs b/Source/Client/Patches/LaunchTargeting.cs new file mode 100644 index 000000000..8f5a2ba53 --- /dev/null +++ b/Source/Client/Patches/LaunchTargeting.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Verse; + +namespace Multiplayer.Client.Patches; + +// While a player picks a destination for transport pods or a shuttle, the game keeps running: another player can +// launch the group or cancel its loading meanwhile. The world targeter's callbacks then throw every frame and the +// targeter can't be closed (#365). Stop it once the launchable is gone. UI only: nothing here changes game state. +[HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.StartChoosingDestination))] +static class TrackLaunchTargeting +{ + public static CompLaunchable launchable; + public static Func targeterAction; + public static bool startedSpawned; + + static void Postfix(CompLaunchable __instance) + { + if (Multiplayer.Client == null || !Find.WorldTargeter.IsTargeting) return; + + launchable = __instance; + targeterAction = Find.WorldTargeter.action; + // A shuttle in a caravan isn't spawned + startedSpawned = __instance.parent.Spawned; + } + + public static void Clear() + { + launchable = null; + targeterAction = null; + } +} + +[HarmonyPatch] +static class StopLaunchTargetingWhenGone +{ + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(WorldTargeter), nameof(WorldTargeter.TargeterUpdate)); + yield return AccessTools.Method(typeof(WorldTargeter), nameof(WorldTargeter.TargeterOnGUI)); + yield return AccessTools.Method(typeof(WorldTargeter), nameof(WorldTargeter.ProcessInputEvents)); + } + + static void Prefix(WorldTargeter __instance) + { + var launchable = TrackLaunchTargeting.launchable; + if (launchable == null) return; + + // Targeting ended or something else started targeting + if (__instance.action == null || __instance.action != TrackLaunchTargeting.targeterAction) + { + TrackLaunchTargeting.Clear(); + return; + } + + var parent = launchable.parent; + if (parent.Destroyed || + TrackLaunchTargeting.startedSpawned && !parent.Spawned || + launchable.Transporter is not { LoadingInProgressOrReadyToLaunch: true }) + { + TrackLaunchTargeting.Clear(); + __instance.StopTargeting(); + } + } +} From 291c3c476f1d09901a9a37c3abd854955317d7e7 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:33:31 -0600 Subject: [PATCH 156/183] Tick the world and maps in lockstep without async time Each tickable ticked through its own accumulator: the world N times, then each map N times. A new map started with a fresh accumulator and gained a tick, a rate change in a map tick (pausing letter, TimeSlower, pausing session) came after the world's ticks and later maps never caught up, and removing a map during a map tick shifted the lazily read map list so another map ticked twice. World and map clocks drifted apart by a few ticks. Without async time every game tick now ticks the world and then a snapshot of the maps once each, with the world's accumulator and rate checked only between game ticks; new maps start at worldTicks. Async time is unchanged. Fixes #339 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/MapSetup.cs | 7 ++- Source/Client/Patches/TickPatch.cs | 72 +++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/Source/Client/Patches/MapSetup.cs b/Source/Client/Patches/MapSetup.cs index f83e0f249..ebac77a24 100644 --- a/Source/Client/Patches/MapSetup.cs +++ b/Source/Client/Patches/MapSetup.cs @@ -63,7 +63,12 @@ private static AsyncTimeComp CreateAsyncTimeCompForMap(Map map, bool usingMapTim } else { - startingMapTicks = Find.Maps.Where(m => m != map).Select(m => m.AsyncTime()?.mapTicks).Max() ?? Find.TickManager.TicksGame; + // Without async time the maps tick in lockstep with the world (TickPatch.TickLockstep). A map generated by + // the world tick, while worldTicks is still one behind, ticks in the same game tick, one generated later + // from the next one. The other maps' clocks differ from that in the middle of a game tick. + startingMapTicks = !Multiplayer.GameComp.asyncTime && Multiplayer.AsyncWorldTime is { } worldTime + ? worldTime.worldTicks + : Find.Maps.Where(m => m != map).Select(m => m.AsyncTime()?.mapTicks).Max() ?? Find.TickManager.TicksGame; gameStartAbsTick = Find.TickManager.gameStartAbsTick; startingTimeSpeed = TimeSpeed.Paused; } diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index 269c824c5..44fe3d513 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -244,13 +244,20 @@ public static bool DoTick(ref bool worked) { tickTimer.Restart(); - foreach (ITickable tickable in AllTickables) + if (Multiplayer.GameComp.asyncTime) { - if (tickable.TimePerTick(tickable.DesiredTimeSpeed) == 0) continue; - tickable.TimeToTickThrough += 1f; + foreach (ITickable tickable in AllTickables) + { + if (tickable.TimePerTick(tickable.DesiredTimeSpeed) == 0) continue; + tickable.TimeToTickThrough += 1f; - worked = true; - TickTickable(tickable); + worked = true; + TickTickable(tickable); + } + } + else + { + TickLockstep(ref worked); } GravEngineCache.Reset(); @@ -271,6 +278,61 @@ public static bool DoTick(ref bool worked) return false; } + // Without async time the world and the maps share one clock, but each tickable used to tick through its own + // accumulator, and they drifted apart (#339): a new map started with a fresh accumulator and got an extra tick, + // a rate change during a map tick (a pausing letter, TimeSlower, a pausing session) reached only the tickables + // after it, and removing a map during a map tick shifted the lazily read map list so another map ticked twice. + // Here every game tick ticks the world and then each map once, the rate (the world's, which runs at the fastest + // map's speed) is checked only between game ticks like in vanilla's tick loop, and only the world's accumulator + // is used. Maps generated during a game tick start at worldTicks (MapSetup): one generated by the world tick + // still ticks in this game tick, one generated by a map tick from the next one. + private static void TickLockstep(ref bool worked) + { + var world = Multiplayer.AsyncWorldTime; + if (world.TimePerTick(world.DesiredTimeSpeed) == 0) return; + world.TimeToTickThrough += 1f; + + worked = true; + List maps = null; + + while (world.TimeToTickThrough >= 0) + { + float timePerTick = world.TimePerTick(world.DesiredTimeSpeed); + if (timePerTick == 0) break; + + world.TimeToTickThrough -= timePerTick; + TickOnce(world); + + // Same order as AllTickables + maps ??= new List(); + maps.Clear(); + var gameMaps = Find.Maps; + for (int i = gameMaps.Count - 1; i >= 0; i--) + if (gameMaps[i].AsyncTime() is { } comp) + maps.Add(comp); + + foreach (var comp in maps) + if (!comp.map.Disposed && Find.Maps.Contains(comp.map)) // Removed during this game tick + TickOnce(comp); + } + } + + private static void TickOnce(ITickable tickable) + { + simStep++; + + try + { + GravEngineCache.Reset(); + tickable.Tick(); + } + catch (Exception e) + { + Log.Error($"Exception during ticking {tickable}: {e}"); + SimulationFailures.Record($"ticking {tickable}", e); + } + } + private static void TickTickable(ITickable tickable) { while (tickable.TimeToTickThrough >= 0) From 3d8a29aa159357d055b6c627ac4c9aa6ed9b9c5b Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:33:37 -0600 Subject: [PATCH 157/183] Restore the gravship landing pause after loading The GravshipTravelSession pausing the landing map isn't saved, so a join point (every client reloads) or a joiner during landing area confirmation dropped it and the map ran while the players chose where to land. Vanilla saves the landing marker, so recreate the session from it after loading, at the same point on every client. The takeoff dialogs/picker and landing cutscenes aren't restorable after a reload, so their sessions stay unsaved. Fixes #643 Co-Authored-By: Claude Opus 5.5 --- .../Patches/GravshipTravelSessionPatches.cs | 25 +++++++++++++++++++ .../Persistent/GravshipTravelSession.cs | 5 +++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 73b88a820..eba52e5ea 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -323,6 +323,31 @@ public static class PatchGravshipControllerExposePendingEnd static void Postfix() => GravshipCutsceneSync.ExposeData(); } + /// + /// GravshipTravelSession isn't saved, so every reload (all clients reload at a join point) and every joiner lost + /// the pause of the landing map while the players choose where to land, which can take a while. Vanilla saves the + /// landing confirmation state (the landing marker), so recreate the session from it after loading. This runs at + /// the same point of every load on every client and joiner, so they all get the same session (and session id). + /// The takeoff side (prelaunch dialog and destination picker) isn't restored: it's UI which a reload closes, so a + /// restored session would keep the map paused with no way to continue or cancel. Neither is a landing cutscene + /// in progress: vanilla can't resume it after loading (the gravship it references isn't saved). + /// + [HarmonyPatch(typeof(Game), nameof(Game.LoadGame))] + public static class RestoreGravshipLandingSessionOnLoad + { + static void Postfix() + { + if (Multiplayer.Client == null || !ModsConfig.OdysseyActive) return; + + var controller = Find.GravshipController; + if (controller is not { LandingAreaConfirmationInProgress: true }) return; + + // The same tile ArriveExistingMap/ArriveNewMap open the session at + if (controller.landingMarker.gravship is { } gravship) + GravshipTravelUtils.OpenSessionAt(gravship.destinationTile); + } + } + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.WorldComponentUpdate))] public static class PatchGravshipCutsceneHoldLastFrame { diff --git a/Source/Client/Persistent/GravshipTravelSession.cs b/Source/Client/Persistent/GravshipTravelSession.cs index 92461b7b8..6e9d4fc15 100644 --- a/Source/Client/Persistent/GravshipTravelSession.cs +++ b/Source/Client/Persistent/GravshipTravelSession.cs @@ -39,7 +39,10 @@ public static class GravshipTravelUtils { public static void OpenSessionAt(PlanetTile tile) { - Map map = Find.WorldObjects.MapParentAt(tile).Map; + // E.g. ArriveExistingMap without a map at the tile (vanilla logs an error and returns) + Map map = Find.WorldObjects.MapParentAt(tile)?.Map; + if (map == null) + return; if (HasSessionAt(map.Tile)) return; From 64726786b884822f964941d07ab66f6b2ff9a0cc Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:34:41 -0600 Subject: [PATCH 158/183] Start rituals with the starting player's role assignments Without Prepatcher the generic role selection widget methods aren't synced, so each client can hold its own assignments when a ritual or psychic ritual starts and the lords diverge. Send the starting player's assignments (exposed) with the start command and copy them into the session's object on every client before starting. With Prepatcher this is a no-op. Addresses #687 (desync when the ritual starts without Prepatcher) Co-Authored-By: Claude Opus 5.5 --- Source/Client/Persistent/PsychicRitualSession.cs | 11 +++++++++-- Source/Client/Persistent/RitualSession.cs | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Source/Client/Persistent/PsychicRitualSession.cs b/Source/Client/Persistent/PsychicRitualSession.cs index b3c7db153..6b1537f57 100644 --- a/Source/Client/Persistent/PsychicRitualSession.cs +++ b/Source/Client/Persistent/PsychicRitualSession.cs @@ -68,9 +68,16 @@ public void Remove() map.MpComp().sessionManager.RemoveSession(this); } - [SyncMethod] - public void Start() + // Called by the starting player's dialog + public void Start() => StartWithAssignments(assignments); + + // See RitualSession.StartWithAssignments: without Prepatcher the role assignments may differ between clients + [SyncMethod(exposeParameters = new[] { 0 })] + private void StartWithAssignments(MpPsychicRitualAssignments startingAssignments) { + if (startingAssignments != null) + MpUtil.ShallowCopy(startingAssignments, assignments); + Remove(); ritual.MakeNewLord(assignments); Find.PsychicRitualManager.RegisterCooldown(ritual); diff --git a/Source/Client/Persistent/RitualSession.cs b/Source/Client/Persistent/RitualSession.cs index b278b83c0..f75aaabe6 100644 --- a/Source/Client/Persistent/RitualSession.cs +++ b/Source/Client/Persistent/RitualSession.cs @@ -28,9 +28,19 @@ public void Remove() map.MpComp().sessionManager.RemoveSession(this); } - [SyncMethod] - public void Start() + // Called by the starting player's dialog + public void Start() => StartWithAssignments(data.assignments); + + // Without Prepatcher the role selection widget's methods aren't synced, so each client may have its own role + // assignments by now. The starting player's assignments are sent along and applied on every client (with + // Prepatcher they're the same everywhere already). + [SyncMethod(exposeParameters = new[] { 0 })] + private void StartWithAssignments(MpRitualAssignments startingAssignments) { + // Copied into the session's object, which the open dialogs and the assignments' sync worker refer to + if (startingAssignments != null) + MpUtil.ShallowCopy(startingAssignments, data.assignments); + // Handle the same stuff as Dialog_BeginGravshipLaunch if (data.isGravshipRitual) { From 52cd03daa7facff28c6c043e5aebc1adcb1bc68e Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:35:00 -0600 Subject: [PATCH 159/183] Make FastTileFinder.Closest deterministic Closest runs the same parallel job as Query and keeps a closest tile per worker thread, so equally close tiles went to whichever thread got them first. Run it in a single batch like Query. Query and Closest also share the flag that lets them reuse their last result: a call made only by one client's interface could make the simulation reuse a result from before a tile change, so the simulation now always recomputes. Fixes #648 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Determinism.cs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index 0048fe4c3..ac26af964 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -816,13 +816,28 @@ static IEnumerable Transpiler(IEnumerable inst } } - // FastTileFinder.ComputeQueryJob uses Interlocked.Increment to race-fill a 50-slot result array - // across parallel Unity Job batches. Thread scheduling differs between machines, so clients get - // different candidate tile sets. Force single-batch execution in MP so tiles are processed in - // tileId order, making the first 50 valid tiles consistent across all clients. - [HarmonyPatch(typeof(FastTileFinder), nameof(FastTileFinder.Query))] + // FastTileFinder.ComputeQueryJob runs in parallel Unity Job batches: Query race-fills a 50-slot result array with + // Interlocked.Increment and Closest keeps a closest tile per worker thread, ties going to the lower thread index + // (#648). Thread scheduling differs between machines, so clients got different tiles. Force single-batch execution + // in MP so tiles are processed in tileId order: the first 50 valid tiles and the first of equally close ones. + [HarmonyPatch] static class FastTileFinderQueryDeterminismPatch { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(FastTileFinder), nameof(FastTileFinder.Query)); + yield return AccessTools.Method(typeof(FastTileFinder), nameof(FastTileFinder.Closest)); + } + + // Query and Closest reuse their last result for the same parameters until a tile changes, but they share the + // flag: a call made only by one client's interface could make the simulation reuse a result from before a + // tile change. Always recompute in the simulation. + static void Prefix(FastTileFinder __instance) + { + if (Multiplayer.Client != null && !Multiplayer.InInterface) + __instance.tileChangedSinceQuery = true; + } + static IEnumerable Transpiler(IEnumerable instructions) { var getIdealBatchCount = AccessTools.Method(typeof(UnityData), nameof(UnityData.GetIdealBatchCount)); From 3682aead0eeb1c56d55084ebc39e9fd75c2eae52 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:35:55 -0600 Subject: [PATCH 160/183] Read the gravship move designator through its lazy accessor The Designator_MoveGravship sync worker used the controller's moveDesignator field, which only the landing UI's OnGUI creates (and refreshes after an abort). On clients viewing the world, in screenshot mode or just loaded it was null or stale, the command threw there and the landing marker, and so the landing cells, diverged. Use MoveDesignator() and return no designator when the landing already ended; HandleDesignator skips a null designator. Fixes #791 Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 4 ++++ .../Client/Syncing/Dict/SyncDictRimWorld.cs | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 19d7d6491..1da5bfe1d 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -515,6 +515,10 @@ void RestoreState() var mode = SyncSerialization.ReadSync(data); var designator = SyncSerialization.ReadSync(data); + // A sync worker returns null when its designator's target is gone (e.g. Designator_MoveGravship after the + // landing), which is the same on every client + if (designator == null) return; + try { if (!SetState(designator)) return; diff --git a/Source/Client/Syncing/Dict/SyncDictRimWorld.cs b/Source/Client/Syncing/Dict/SyncDictRimWorld.cs index 3ced8310f..16d2fbc43 100644 --- a/Source/Client/Syncing/Dict/SyncDictRimWorld.cs +++ b/Source/Client/Syncing/Dict/SyncDictRimWorld.cs @@ -673,7 +673,7 @@ public static class SyncDictRimWorld { (SyncWorker sync, ref Designator_MoveGravship moveGravship) => { if (sync.isWriting) - sync.Write(moveGravship.marker.GravshipRotation); + sync.Write(moveGravship.marker?.GravshipRotation ?? Rot4.North); else { var rot = sync.Read(); @@ -681,11 +681,20 @@ public static class SyncDictRimWorld var gravController = Find.GravshipController; var marker = gravController.landingMarker; - if (marker != null) - marker.GravshipRotation = rot; + // The landing was confirmed or aborted before this command, the same on every client + if (marker == null) + { + moveGravship = null; + return; + } + + marker.GravshipRotation = rot; - moveGravship = gravController.moveDesignator; - moveGravship.deselectedRotation = moveGravship.marker.GravshipRotation; + // Not the moveDesignator field: the landing UI creates it lazily when drawn, which it isn't on + // clients viewing the world or in screenshot mode, or right after loading. The command + // failed there and the landing marker (and so the landing spot) differed between clients. + moveGravship = gravController.MoveDesignator(); + moveGravship.deselectedRotation = rot; } }, true, false }, From 016815fb5c0520493d7df27cedba83475c462028 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:36:50 -0600 Subject: [PATCH 161/183] Pause when a caravan meeting or demand dialog opens Dialog_NodeTree force-pauses in vanilla, but MP forces WindowsForcePause to false since windows differ between clients, so time kept running while the caravan meeting or demand dialog waited for an answer. When either incident fires on a caravan, pause from the simulation the way a pausing letter does, unless the host set pause on letter to never. Fixes #512 Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimePatches.cs | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index 3b2c1de68..1f0d20e4a 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -309,6 +309,12 @@ private static void PauseOnLetter(TickManager manager) return; } + SimPause(); + } + + // Pauses the context's tickable (everything without async time) from the simulation and resets the time votes + public static void SimPause() + { if (Multiplayer.GameComp.asyncTime) { var tickable = (ITickable)Multiplayer.MapContext.AsyncTime() ?? Multiplayer.AsyncWorldTime; @@ -323,4 +329,29 @@ private static void PauseOnLetter(TickManager manager) } } } + + // Vanilla's Dialog_NodeTree force-pauses, but in MP WindowsForcePause is always false (windows differ between + // clients). The caravan meeting and demand incidents open one when they fire on a caravan, so pause the way a + // letter does, keyed on the incident's result, which every client shares (#512). + [HarmonyPatch] + static class PauseOnCaravanDialogIncident + { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(IncidentWorker_CaravanMeeting), nameof(IncidentWorker_CaravanMeeting.TryExecuteWorker)); + yield return AccessTools.Method(typeof(IncidentWorker_CaravanDemand), nameof(IncidentWorker_CaravanDemand.TryExecuteWorker)); + } + + static void Postfix(bool __result, IncidentParms parms) + { + if (Multiplayer.Client == null || Multiplayer.InInterface || !__result || parms.target is not Caravan) + return; + + // The host chose to never pause automatically + if (Multiplayer.GameComp.pauseOnLetter == Common.PauseOnLetter.Never) + return; + + ReceiveLetterPause.SimPause(); + } + } } From bdb25156f37e22dc1ef5f00fbdb4edafecd8f289 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:37:31 -0600 Subject: [PATCH 162/183] Place the landing gravship in its map's context PlaceGravship runs in a long event queued from a cutscene render callback, outside any tick or command, so spawning the ship and clearing its area used the interface's RNG and the world's clock. Run it inside the landing map's context (time, random state, faction) like a command of that map and update the managers a command updates afterwards, which replaces the glow grid workaround from #635. Drop the PlaceGravship sync method registration: it never synced inside the long event and couldn't be read as a command. Fixes #646 Co-Authored-By: Claude Opus 5.5 --- .../Patches/GravshipTravelSessionPatches.cs | 40 +++++++++++++++++-- Source/Client/Syncing/Game/SyncMethods.cs | 3 +- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index eba52e5ea..fff8c3649 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -158,14 +158,46 @@ static void Prefix(WorldComponent_GravshipController __instance, ref bool __stat } } + /// + /// PlaceGravship (spawning the ship, clearing what's under it, recounting) runs in a long event queued from a + /// render callback of the landing cutscene, so outside of any tick or command and with the interface's time and + /// RNG. Run it in the landing map's context, like a command of that map (its time, random state and faction; + /// PlaceGravship_FactionContext_Patch pushes the gravship's faction inside it), and afterwards update what a + /// command updates. In MP MapUpdate doesn't do that (see AsyncTimeComp.UpdateManagers), which left the ship's + /// lights off (#635). + /// All clients reach this point after the same command (SyncBeginLanding) and the landing map stays paused by its + /// GravshipTravelSession meanwhile. The camera pan before the long event takes a second, by which time the cutscene + /// freeze sent by the host normally has stopped every client at the same tick. That isn't guaranteed (no host on + /// a standalone server, a client lagging by over a second): making the placement a command would need the + /// cutscene's callback chain to wait for it. + /// [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship))] - public static class PatchPlaceGravshipToUpdateSystemsAfterTheySpawn + public static class PatchPlaceGravshipInMapContext { - static void Postfix(Map map) + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(Map map, out int __state) { - if (Multiplayer.Client == null) return; + __state = -1; + if (Multiplayer.Client == null || Multiplayer.Ticking || Multiplayer.ExecutingCmds) return; + if (map?.AsyncTime() is not { } comp) return; + + __state = Rand.stateStack.Count; + comp.PreContext(); + } + + static void Postfix(Map map, int __state) + { + if (__state >= 0) + map.AsyncTime().UpdateManagers(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer(Map map, int __state) + { + if (__state < 0) return; - map.glowGrid.GlowGridUpdate_First(); + AsyncTimeComp.RestoreRandStack(__state + 1, nameof(WorldComponent_GravshipController.PlaceGravship)); + map.AsyncTime().PostContext(); } } diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index 221ee2f88..49a922032 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -468,7 +468,8 @@ public static void Init() SyncMethod.Lambda(typeof(Building_BioferriteHarvester), nameof(Building_BioferriteHarvester.GetGizmos), 1); // Toggle unload SyncMethod.Lambda(typeof(Building_BioferriteHarvester), nameof(Building_BioferriteHarvester.GetGizmos), 3).SetDebugOnly(); // Dev add +1 - SyncMethod.Register(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship)); + // Not PlaceGravship: it runs in a long event on every client (see PatchPlaceGravshipInMapContext), where it + // never synced, and as a command it couldn't be read anyway (the gravship isn't a world object then) SyncMethod.Register(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.AbortLanding)).SetContext(SyncContext.CurrentMap); // Outfit stand From 44a946ac8b113d9ffb868efa00618bc553c758eb Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:38:35 -0600 Subject: [PATCH 163/183] Time extended camera shakes with the MP timer ExtendedShakeRequest stores TicksGame when created and CameraShaker.Update compares it with TicksGame in the interface. Under async time the request may come from a map's clock and the check uses the world's, so after a landing on a map ahead of the world the camera kept shaking until the world caught up. In MP use TickPatch.Timer for both and clamp loaded requests to it. UI only. Fixes #696 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Feedback.cs | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/Source/Client/Patches/Feedback.cs b/Source/Client/Patches/Feedback.cs index f98eab47d..50f7369ae 100644 --- a/Source/Client/Patches/Feedback.cs +++ b/Source/Client/Patches/Feedback.cs @@ -297,4 +297,57 @@ static void SyncedEndGotoJob(Pawn_JobTracker self, JobCondition condition, bool } } + // Extended camera shakes (CameraShaker.DoShake(mag, durationTicks)) store TicksGame when requested and + // CameraShaker.Update compares it with TicksGame again. Under async time these can be different clocks: a map's + // when requested in its tick or command, the world's in the interface. A shake requested on a map ahead of the + // world went on until the world caught up, possibly for good (#696). In MP both use TickPatch.Timer instead: it's + // the same for every map and the world, and stops while the gravship cutscenes freeze the game, like TicksGame does + // in a vanilla landing (paused). Camera shake is local UI, nothing in the simulation reads it. + [HarmonyPatch(typeof(ExtendedShakeRequest), MethodType.Constructor, typeof(float), typeof(int))] + static class ExtendedShakeRequestUsesTimer + { + static void Postfix(ExtendedShakeRequest __instance) + { + if (Multiplayer.Client != null) + __instance.startTick = TickPatch.Timer; + } + } + + [HarmonyPatch(typeof(CameraShaker), nameof(CameraShaker.Update))] + static class CameraShakerUpdateUsesTimer + { + private static readonly MethodInfo TicksGameGetter = + AccessTools.PropertyGetter(typeof(TickManager), nameof(TickManager.TicksGame)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + // Consumes the TickManager instance like the getter + if (inst.Calls(TicksGameGetter)) + yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(CameraShakerUpdateUsesTimer), nameof(ShakeClock))).MoveLabelsFrom(inst); + else + yield return inst; + } + } + + static int ShakeClock(TickManager tickManager) => + Multiplayer.Client != null ? TickPatch.Timer : tickManager.TicksGame; + } + + // The requests are saved with the camera. Ones from a save which used TicksGame (or with a later timer) would + // otherwise last until the timer reaches their start. + [HarmonyPatch(typeof(CameraShaker), nameof(CameraShaker.Expose))] + static class CameraShakerLoadClampsToTimer + { + static void Postfix(CameraShaker __instance) + { + if (Multiplayer.Client == null || Scribe.mode != LoadSaveMode.LoadingVars) return; + + foreach (var request in __instance.extendedShakeRequests) + if (request != null && request.startTick > TickPatch.Timer) + request.startTick = TickPatch.Timer; + } + } + } From 0d73a38ed1dc48b0136dee22c95166f14c4e46c1 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:39:17 -0600 Subject: [PATCH 164/183] Only click the ritual gizmo the player clicked Grouped Command_Ritual gizmos (shared rituals of several ideoligions) also process the click of another one in their group, before it. In MP each sync call opens a session and the clicked ritual's came last and was rejected, so the dialog opened for the wrong ideoligion. Don't inherit interactions from grouped ritual gizmos in MP. UI only. Fixes #506 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Persistent/RitualPatches.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Source/Client/Persistent/RitualPatches.cs b/Source/Client/Persistent/RitualPatches.cs index 6e5238a1e..388a70d6d 100644 --- a/Source/Client/Persistent/RitualPatches.cs +++ b/Source/Client/Persistent/RitualPatches.cs @@ -115,6 +115,21 @@ static bool Prefix(Window window) } } + // Ritual gizmos of different ideoligions can be grouped and clicking one also clicks the others in its group first + // (GizmoGridDrawer, Gizmo.InheritInteractionsFrom). In SP every one opens its dialog and the clicked one, opened + // last, stays. In MP each click syncs, the first command creates the session and the following ones, the clicked + // ritual's among them, are rejected, so the wrong ritual opened (#506). UI only, like vanilla's launch gizmos. + [HarmonyPatch(typeof(Command_Ritual), MethodType.Constructor, + typeof(Precept_Ritual), typeof(TargetInfo), typeof(RitualObligation), typeof(Dictionary))] + static class RitualGizmoOnlyClickedOne + { + static void Postfix(Command_Ritual __instance) + { + if (Multiplayer.Client != null) + __instance.alsoClickIfOtherInGroupClicked = false; + } + } + // Note that cachedRoles is a static field and is only used for UI drawing [HarmonyPatch] static class DontClearDialogBeginRitualCache From 1fa6df11dcf7aacfc0c4cd4ff9344dc49d485a0f Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:42:27 -0600 Subject: [PATCH 165/183] Sync the dev remove hediff button of the health tab HealthCardUtility.DrawHediffRow's dev mode button removed the hediff locally from its drawer lambda. The lambda runs every frame, so instead of syncing it, redirect its RemoveHediff call to a debug-only sync method. Fixes #358 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Syncing/Game/SyncMethods.cs | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index 49a922032..90a3f6c60 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -982,5 +982,48 @@ static bool DoSyncBookcaseTryDrop(Building_Bookcase bookcase, Thing thing, IntVe return owner.TryDrop(thing, dropLoc, map, mode, count, out resultingThing, null, null); } + + // The dev mode "remove hediff" button of the health tab calls RemoveHediff in its drawer lambda, which runs + // every frame and so can't be synced itself (as a SyncDelegate it would send a command each frame). Sync the + // call instead (#358). + [HarmonyPatch] + static class HealthCardDevRemoveHediff_Patch + { + private static readonly MethodInfo RemoveHediff = + AccessTools.Method(typeof(Pawn_HealthTracker), nameof(Pawn_HealthTracker.RemoveHediff)); + + static MethodBase TargetMethod() + { + // The only lambda of DrawHediffRow calling RemoveHediff + var lambdaPrefix = $"<{nameof(HealthCardUtility.DrawHediffRow)}>"; + return typeof(HealthCardUtility).GetNestedTypes(AccessTools.all) + .SelectMany(t => t.GetDeclaredMethods()) + .Where(m => m.Name.StartsWith(lambdaPrefix)) + .First(m => PatchProcessor.ReadMethodBody(m).Any(i => i.Value as MethodInfo == RemoveHediff)); + } + + static IEnumerable Transpiler(IEnumerable insts) + { + var replacement = AccessTools.Method(typeof(SyncMethods), nameof(DevRemoveHediff)); + + foreach (var ci in insts) + { + if (ci.Calls(RemoveHediff)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + } + + yield return ci; + } + } + } + + // Takes the arguments of the instance call it replaces + static void DevRemoveHediff(Pawn_HealthTracker health, Hediff hediff) => + SyncedDevRemoveHediff(health.pawn, hediff); + + [SyncMethod(debugOnly = true, cancelIfAnyArgNull = true)] + static void SyncedDevRemoveHediff(Pawn pawn, Hediff hediff) => pawn.health.RemoveHediff(hediff); } } From ab76273f66e5ac704310eb2785f1c62267a588c8 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:47:04 -0600 Subject: [PATCH 166/183] Warn when Prepatcher didn't patch the ritual role widget Rituals rely on Prepatcher to sync the generic role selection widget, but nothing told players when it was missing or failed to load (e.g. on macOS). Detect it from the patched method itself (a flag set by Prepatcher's pass is lost when it restarts the game with the patched assemblies), log a warning and show it in the host window, the server browser and the ritual and psychic ritual dialogs. English text with TryTranslate fallbacks. Fixes #501 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Persistent/PrepatcherWarning.cs | 42 +++++++++++++++++++ .../Persistent/PsychicRitualBeginProxy.cs | 6 +++ Source/Client/Persistent/RitualBeginProxy.cs | 4 ++ Source/Client/Syncing/Game/SyncDelegates.cs | 2 + Source/Client/Windows/HostWindow.cs | 5 +++ Source/Client/Windows/ServerBrowser.cs | 11 +++++ Source/MultiplayerLoader/SyncRituals.cs | 30 +++++++++++++ 7 files changed, 100 insertions(+) create mode 100644 Source/Client/Persistent/PrepatcherWarning.cs diff --git a/Source/Client/Persistent/PrepatcherWarning.cs b/Source/Client/Persistent/PrepatcherWarning.cs new file mode 100644 index 000000000..5a7f76fc5 --- /dev/null +++ b/Source/Client/Persistent/PrepatcherWarning.cs @@ -0,0 +1,42 @@ +using Multiplayer.Client.Util; +using MultiplayerLoader; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.Persistent; + +/// +/// Shown in the host/join windows and the ritual dialogs when Prepatcher didn't patch the ritual role selection +/// widget (see SyncRituals.PrepatchesApplied). The texts fall back to English as the keys may not be translated yet. +/// +public static class PrepatcherWarning +{ + public static bool Show => !SyncRituals.PrepatchesApplied; + + public static string Label => + "MpPrepatcherMissing".TryTranslate(out var text) ? text.ToString() : "Prepatcher isn't active"; + + public static string Desc => + "MpPrepatcherMissingDesc".TryTranslate(out var text) + ? text.ToString() + : "Multiplayer needs the Prepatcher mod to share the role assignments of ritual dialogs between players. " + + "Without it, each player only sees their own changes there and a ritual starts with the roles " + + "chosen by the player who starts it.\n\nMake sure Prepatcher is installed and enabled. If it is, " + + "check the log for why it failed."; + + public static float LabelWidth(GameFont font) + { + using (MpStyle.Set(font)) + return Text.CalcSize(Label).x; + } + + // The label in the warning color with the description as its tooltip + public static void DrawLabel(Rect rect, GameFont font = GameFont.Small) + { + using (MpStyle.Set(font).Set(ColorLibrary.RedReadable)) + Widgets.Label(rect, Label); + + Widgets.DrawHighlightIfMouseover(rect); + TooltipHandler.TipRegion(rect, Desc); + } +} diff --git a/Source/Client/Persistent/PsychicRitualBeginProxy.cs b/Source/Client/Persistent/PsychicRitualBeginProxy.cs index 43f12da4b..8143ebab8 100644 --- a/Source/Client/Persistent/PsychicRitualBeginProxy.cs +++ b/Source/Client/Persistent/PsychicRitualBeginProxy.cs @@ -61,6 +61,12 @@ public override void DoWindowContents(Rect inRect) Close(); } + if (PrepatcherWarning.Show) + { + PrepatcherWarning.DrawLabel(inRect.Height(20f).Width(PrepatcherWarning.LabelWidth(GameFont.Tiny) + 10f), GameFont.Tiny); + inRect.yMin += 20f; + } + base.DoWindowContents(inRect); } finally diff --git a/Source/Client/Persistent/RitualBeginProxy.cs b/Source/Client/Persistent/RitualBeginProxy.cs index b1a91490c..a81d54179 100644 --- a/Source/Client/Persistent/RitualBeginProxy.cs +++ b/Source/Client/Persistent/RitualBeginProxy.cs @@ -74,6 +74,10 @@ public override void DoWindowContents(Rect inRect) Close(); } + // In the space made for the "Switch to map" button (top right) + if (PrepatcherWarning.Show) + PrepatcherWarning.DrawLabel(inRect.Height(20f).Width(PrepatcherWarning.LabelWidth(GameFont.Tiny) + 10f), GameFont.Tiny); + // Make space for the "Switch to map" button inRect.yMin += 20f; diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index 1528c0b57..85de5f3d8 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -333,6 +333,8 @@ The UI's main interaction area is split into three types of groups of pawns. SyncMethod.Register(typeof(PsychicRitualRoleAssignments), nameof(PsychicRitualRoleAssignments.RemoveParticipant)); SyncRituals.ApplyPrepatches(null); + if (!SyncRituals.PrepatchesApplied) + Log.Warning("Multiplayer: Prepatcher isn't active, ritual role assignments won't be shared between players"); } private static void InitChoiceLetters() diff --git a/Source/Client/Windows/HostWindow.cs b/Source/Client/Windows/HostWindow.cs index 1abe4e6ff..ec99d6d20 100644 --- a/Source/Client/Windows/HostWindow.cs +++ b/Source/Client/Windows/HostWindow.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using Multiplayer.Client.Networking; +using Multiplayer.Client.Persistent; using Multiplayer.Client.Util; using Multiplayer.Common; using Multiplayer.Common.Util; @@ -109,6 +110,10 @@ public override void DoWindowContents(Rect inRect) DoTabButton(entry.Down(50f).Width(140).Height(40f), Tab.Gameplay); } + // Below the tab buttons, the column is empty there + if (PrepatcherWarning.Show) + PrepatcherWarning.DrawLabel(entry.Down(100f).Width(140).Height(50f), GameFont.Tiny); + if (tab == Tab.Connecting) DoConnecting(entry.MinX(entry.xMin + 150)); else diff --git a/Source/Client/Windows/ServerBrowser.cs b/Source/Client/Windows/ServerBrowser.cs index 81267de58..18a668a3f 100644 --- a/Source/Client/Windows/ServerBrowser.cs +++ b/Source/Client/Windows/ServerBrowser.cs @@ -9,6 +9,7 @@ using HarmonyLib; using LiteNetLib; using LudeonTK; +using Multiplayer.Client.Persistent; using Multiplayer.Client.Util; using Multiplayer.Common; using Multiplayer.Common.Util; @@ -135,6 +136,16 @@ bool Button(Texture2D icon, string labelKey, string tip, Color baseIconColor, fl Application.OpenURL(Multiplayer.DiscordLink); x += 10; + + // Before the note, which then may not fit anymore + if (PrepatcherWarning.Show) + { + var warningWidth = PrepatcherWarning.LabelWidth(GameFont.Small); + using (MpStyle.Set(TextAnchor.MiddleLeft)) + PrepatcherWarning.DrawLabel(new Rect(x, 0, warningWidth, 24f)); + x += warningWidth + 15; + } + Widgets.Label(new Rect(x, 0, 400, 24), "Note: Multiplayer for 1.6 is a work in progress."); const string v15Notice = diff --git a/Source/MultiplayerLoader/SyncRituals.cs b/Source/MultiplayerLoader/SyncRituals.cs index 3c019fac5..2dfd4520c 100644 --- a/Source/MultiplayerLoader/SyncRituals.cs +++ b/Source/MultiplayerLoader/SyncRituals.cs @@ -34,11 +34,41 @@ public static bool PrepatcherPrefix(int index, object __instance, object[] __arg private static MethodInfo syncMethod = AccessTools.Method("Multiplayer.Client.Sync:Method"); + /// + /// Whether the methods below run the sync prefix, i.e. Prepatcher ran the first pass. Prepatcher is a mod + /// dependency but can be missing or fail to load (e.g. on macOS). Then the role assignments of ritual dialogs + /// stay local. Set by the second pass from the patched code itself: a flag set by the first pass is lost because + /// Prepatcher restarts the game with the patched assemblies. + /// + public static bool PrepatchesApplied { get; private set; } + + private static bool HasPrepatcherPrefix(MethodBase method) + { + try + { + // By name: the patched assembly refers to this method through its own assembly reference + return PatchProcessor.ReadMethodBody(method).Any(inst => + inst.Value is MethodInfo m && + m.Name == nameof(PrepatcherPrefix) && + m.DeclaringType?.FullName == typeof(SyncRituals).FullName); + } + catch (Exception e) + { + Verse.Log.Warning($"Multiplayer: couldn't check whether Prepatcher patched {method}: {e}"); + return false; + } + } + // This method is called twice (two passes) // The first pass with module != null is called from Prepatcher and applies prepatches // The second pass with module == null is called from Sync initialization and registers SyncMethods public static void ApplyPrepatches(ModuleDefinition module) { + if (module == null) + PrepatchesApplied = HasPrepatcherPrefix(AccessTools.Method( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.TryAssignReplace))); + void Register(Type baseType, string method, Type derivedType, Action postProcess = null, Action beforeExecuting = null) { From 6c5bf264fc54b9e4cf3a82c2ffe8459c3650f0ce Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:50:47 -0600 Subject: [PATCH 167/183] Mark traces unreliable before logging a stack walk failure If logging the failure threw, the outer catch skipped marking the window, which then kept a truncated trace hash list and would report a false trace desync. Mark right after turning tracing off, and again in the outer catch when tracing was turned off. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Desyncs/DeferredStackTracing.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index 996598d78..118f8ffb7 100644 --- a/Source/Client/Desyncs/DeferredStackTracing.cs +++ b/Source/Client/Desyncs/DeferredStackTracing.cs @@ -60,7 +60,10 @@ public static void Postfix() } catch (Exception e) { + // Mark first: if logging throws, the outer catch skips the rest of this method tracingFailed = true; + Multiplayer.game.sync.MarkTracesUnreliable(); + LogTraceFailureOnce(e.ToString()); Log.Warning("Multiplayer: desync stack traces are turned off until the game restarts"); depth = -1; @@ -83,6 +86,17 @@ public static void Postfix() } catch (Exception e) { + try + { + // Traces stop for this client mid-window, so its trace hashes can't be compared + if (tracingFailed) + Multiplayer.game?.sync.MarkTracesUnreliable(); + } + catch + { + // ignored + } + LogTraceFailureOnce(e.ToString()); } } From 03d89bde6007595be73ada404f862a5452b53177 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:50:47 -0600 Subject: [PATCH 168/183] Match vanilla more closely when picking interface melee verbs Clear the per-frame cache when a tick ran within the frame, require the reused current verb not to be in the Worst category like any other pick, and ignore NaN weights for the highest weight like vanilla's comparison. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Determinism.cs | 29 +++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index 93396fe7a..102d82ef0 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -51,8 +51,10 @@ static class TryGetMeleeVerbPatch static bool Cancel => Multiplayer.Client != null && Multiplayer.InInterface; // The melee gizmo of every selected drafted pawn and pawn rendering ask for a melee verb several times per frame - // (#308), so interface answers are cached for the current frame + // (#308), so interface answers are cached for the current frame and tick (ticks run between UI updates of a + // frame) private static int cacheFrame = -1; + private static int cacheTick = -1; private static readonly Dictionary<(Pawn_MeleeVerbs, Thing), Verb> cache = new(); private static readonly List usableVerbs = new(); @@ -66,9 +68,10 @@ static void Postfix(Pawn_MeleeVerbs __instance, Thing target, ref Verb __result) { if (!Cancel) return; - if (cacheFrame != Time.frameCount) + if (cacheFrame != Time.frameCount || cacheTick != TickPatch.Timer) { cacheFrame = Time.frameCount; + cacheTick = TickPatch.Timer; cache.Clear(); } @@ -87,19 +90,27 @@ private static Verb InterfaceMeleeVerb(Pawn_MeleeVerbs meleeVerbs, Thing target) var pawn = meleeVerbs.pawn; var cur = meleeVerbs.curMeleeVerb; - if (cur != null && cur.IsStillUsableBy(pawn) && UsableOn(cur, target)) - return cur; - - // Same result as the first VerbEntry with a non-zero selection weight, without building VerbEntries: - // their constructor calls VerbUtility.FinalSelectionWeight, which iterates all verbs (O(n²)) + // Without building VerbEntries: their constructor calls VerbUtility.FinalSelectionWeight, which iterates + // all verbs (O(n²)). A verb has a non-zero VerbEntry selection weight when it isn't in the Worst category. try { CollectUsableMeleeVerbs(pawn); + // Like vanilla's comparison, ignores NaN weights float highestWeight = 0f; foreach (var v in usableVerbs) - highestWeight = Math.Max(highestWeight, VerbUtility.InitialVerbWeight(v, pawn)); + { + float weight = VerbUtility.InitialVerbWeight(v, pawn); + if (weight > highestWeight) + highestWeight = weight; + } + + // Prefer the verb the simulation chose last + if (cur != null && cur.IsStillUsableBy(pawn) && UsableOn(cur, target) && + cur.GetSelectionCategory(pawn, highestWeight) != VerbSelectionCategory.Worst) + return cur; + // Otherwise the same result as the first VerbEntry with a non-zero selection weight foreach (var v in usableVerbs) if (v.GetSelectionCategory(pawn, highestWeight) != VerbSelectionCategory.Worst && UsableOn(v, target)) return v; @@ -113,7 +124,7 @@ private static Verb InterfaceMeleeVerb(Pawn_MeleeVerbs meleeVerbs, Thing target) return meleeVerbs.GetUpdatedAvailableVerbsList(false).FirstOrDefault(ve => ve.GetSelectionWeight(target) != 0).verb; } - // Mirrors VerbEntry.GetSelectionWeight being non-zero for a verb that isn't in the Worst category + // The target-dependent part of VerbEntry.GetSelectionWeight being non-zero private static bool UsableOn(Verb v, Thing target) => v.IsUsableOn(target) && (target?.def?.IsEdifice() != true || v.verbProps.commonalityVsEdificeFactor != 0f); From 6fcaecbe940bff6770ed0daec64c6f46eb9aa586 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:57:56 -0600 Subject: [PATCH 169/183] Report not paused while a world or map tick runs TickManager.Paused is patched to the paused state of the map the local player is looking at, also while the simulation ticks, so code in ticks saw different values per client (e.g. Pawn_JobTracker.StartJob counts jobs by the local frame count while paused). This became reachable more often with lockstep ticking: the maps after a mid-tick pause (session, letter) still finish the game tick. Ticks now always see not paused, as in vanilla where only unpaused games tick. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimePatches.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index 1f0d20e4a..52b46eb90 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -253,6 +253,16 @@ static class TickManagerPausedPatch static void Postfix(ref bool __result) { if (Multiplayer.Client == null) return; + + // The rest of this reads the local view. A world or map tick is running, so report not paused (vanilla only + // ticks unpaused): ticks can continue after a rate change in the same game tick (TickPatch.TickLockstep), + // and while paused Pawn_JobTracker.StartJob counts jobs by the local frame count. + if (AsyncWorldTimeComp.tickingWorld || AsyncTimeComp.tickingMap != null) + { + __result = false; + return; + } + if (WorldRendererUtility.WorldSelected) return; if (FactionCreator.generatingMap) return; From 34e19f8f89c50acca041af5e99d29bcfab0e0e05 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:58:17 -0600 Subject: [PATCH 170/183] Start new maps at the game's speed without async time A new map took TickManager.CurTimeSpeed, the speed of the last context entered. After a pausing letter earlier in the same world tick that was still the old speed, and since the world runs at the fastest map's speed the new map unpaused the game (with lockstep ticking every map). Use the speed last set for the whole game instead. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 4 ++++ Source/Client/Patches/MapSetup.cs | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 12be3c7ab..6398d55b4 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -55,6 +55,10 @@ public TimeSpeed DesiredTimeSpeed set => timeSpeedInt = value; } + // Without async time: the speed last set for the whole game (SetTimeEverywhere), unlike DesiredTimeSpeed not + // derived from the maps + public TimeSpeed GlobalTimeSpeed => timeSpeedInt; + public Queue Cmds => cmds; public Queue cmds = new(); diff --git a/Source/Client/Patches/MapSetup.cs b/Source/Client/Patches/MapSetup.cs index ebac77a24..329dfed01 100644 --- a/Source/Client/Patches/MapSetup.cs +++ b/Source/Client/Patches/MapSetup.cs @@ -73,8 +73,12 @@ private static AsyncTimeComp CreateAsyncTimeCompForMap(Map map, bool usingMapTim startingTimeSpeed = TimeSpeed.Paused; } + // TickManager.CurTimeSpeed is the speed of the last context entered: a pausing letter earlier in the same world + // tick doesn't change it, and the new map would then unpause the game if (!Multiplayer.GameComp.asyncTime) - startingTimeSpeed = Find.TickManager.CurTimeSpeed; + startingTimeSpeed = !usingMapTimeFromSingleplayer && Multiplayer.AsyncWorldTime is { } world + ? world.GlobalTimeSpeed + : Find.TickManager.CurTimeSpeed; var asyncTimeCompForMap = new AsyncTimeComp(map, gameStartAbsTick) { From 9992f50addf7aa7500f440e57579fdfb603eb975 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:58:53 -0600 Subject: [PATCH 171/183] Hide the per-client GameInitData while reading a starting pawn Outside Playing, GenTicks.TicksAbs, Faction.OfPlayer and PawnsFinder read the GameInitData, which differs between clients when SendPawn runs (the issuer's configuration pages, an earlier faction creation, none). Nothing on the starting pawn load path reaches them today, but a mod's pawn data could. Co-Authored-By: Claude Opus 5.5 --- Source/Client/Factions/FactionCreator.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index caa10f165..9cc69714c 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -59,8 +59,12 @@ private static Pawn ReadStartingPawn(byte[] xml) // Vanilla loads pawns only while a game initializes. In Playing the needs tracker's constructor evaluates // Need_Food.MaxLevel as a stat over hediffs whose pawn is set only when cross refs resolve (#536). + // Outside Playing some getters read the GameInitData (TicksAbs, Faction.OfPlayer, PawnsFinder), which + // differs between clients here (the issuer's pages, an earlier faction creation, none), so hide it. var prevState = Current.programStateInt; + var prevInitData = Current.Game.InitData; Current.programStateInt = ProgramState.MapInitializing; + Current.Game.InitData = null; readingStartingPawn = true; try @@ -70,6 +74,7 @@ private static Pawn ReadStartingPawn(byte[] xml) finally { readingStartingPawn = false; + Current.Game.InitData = prevInitData; Current.programStateInt = prevState; } } From e47be4775c884bf99cd00a78c44532ea575d4a7d Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:16:57 -0600 Subject: [PATCH 172/183] Keep the landing marker on the confirming client until it lands Vanilla's confirm landing button clears the controller's landingMarker right after BeginLanding, which MP turns into a command, so the confirming client alone had no landing in progress until its command executed: a join point in between didn't restore its landing session, and a move designation or abort from another player failed there only. Restore the field after the OnGUI call and ignore a second confirmation of a destroyed marker. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimeComp.cs | 4 +-- .../Patches/GravshipTravelSessionPatches.cs | 32 ++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 1da5bfe1d..37af4d241 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -515,8 +515,8 @@ void RestoreState() var mode = SyncSerialization.ReadSync(data); var designator = SyncSerialization.ReadSync(data); - // A sync worker returns null when its designator's target is gone (e.g. Designator_MoveGravship after the - // landing), which is the same on every client + // A sync worker returns null when its designator's target is gone (e.g. Designator_MoveGravship once the + // landing started or was aborted, which happens in commands, so on every client at the same point) if (designator == null) return; try diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index fff8c3649..6a5bd40e2 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -116,11 +116,15 @@ public static class PatchGravshipDesignatorDeselectForAllClients [HarmonyPatch(typeof(GravshipLandingMarker), nameof(GravshipLandingMarker.BeginLanding))] public static class PatchBeginLandingToSyncWithClients { + // The marker this client just confirmed, see KeepLandingMarkerUntilSyncedLanding + internal static GravshipLandingMarker confirmedLocally; + static bool Prefix(GravshipLandingMarker __instance) { if (Multiplayer.Client == null) return true; if (Multiplayer.ExecutingCmds) return true; + confirmedLocally = __instance; SyncBeginLanding(__instance); return false; @@ -131,7 +135,12 @@ public static void SyncBeginLanding(GravshipLandingMarker landingMarker) { var gravshipController = Find.GravshipController; - if (landingMarker == null || landingMarker.Tile == null) + // Already landing: the confirm button stays usable until this command executes, so it can be sent twice + // (the destroyed marker then reads as null) + if (landingMarker == null || landingMarker.Destroyed) + return; + + if (landingMarker.Tile == null) { MpLog.Error($"[MP] SyncConfirmGravshipLanding: Marker [{landingMarker != null}] Tile [{landingMarker?.Tile != null}]."); return; @@ -146,6 +155,27 @@ public static void SyncBeginLanding(GravshipLandingMarker landingMarker) } } + /// + /// Vanilla's confirm landing button (WorldComponentOnGUI) clears the controller's landingMarker right after + /// BeginLanding, which MP turns into a command. Until that command executes, the confirming client would be the only + /// one without a landing confirmation in progress: a join point wouldn't restore its landing session, the move + /// designator's sync worker and AbortLanding would fail there only. Keep the field until the synced landing. + /// + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.WorldComponentOnGUI))] + public static class KeepLandingMarkerUntilSyncedLanding + { + static void Prefix() => PatchBeginLandingToSyncWithClients.confirmedLocally = null; + + static void Finalizer(WorldComponent_GravshipController __instance) + { + var marker = PatchBeginLandingToSyncWithClients.confirmedLocally; + PatchBeginLandingToSyncWithClients.confirmedLocally = null; + + if (Multiplayer.Client != null && marker is { Destroyed: false } && __instance.landingMarker == null) + __instance.landingMarker = marker; + } + } + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.AbortLanding))] public static class PatchAbortLandingToCloseSession { From 74a851a097701017c612418397033572359ed728 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:17:34 -0600 Subject: [PATCH 173/183] Set the speed of every tickable after landing without async time Landing set Normal speed only on the landing map. Without async time all tickables share one speed, as a time speed command sets it, so the other maps and the world kept the old one. Use SetTimeEverywhere there. Co-Authored-By: Claude Opus 5.5 --- .../Patches/GravshipTravelSessionPatches.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 6a5bd40e2..257a15528 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -488,9 +488,7 @@ static bool Prefix(WorldComponent_GravshipController __instance, out (Map factio GravshipCutsceneSync.SyncedEndExecuting(); GravshipTravelUtils.CloseSessionAt(__instance.gravship.destinationTile); - // LandingEnded calls Find.TickManager.CurTimeSpeed = TimeSpeed.Normal, but that doesn't work in MP as we - // have our own way of changing the map speed. - map.AsyncTime().DesiredTimeSpeed = TimeSpeed.Normal; + SetNormalSpeedAfterLanding(map); // Forced: the command's faction (whichever player's end command came first) may already be the gravship's if (Multiplayer.GameComp.multifaction && __instance.gravship.Faction is { } faction) @@ -509,6 +507,17 @@ static void Finalizer((Map factionMap, bool randPushed) __state) if (__state.randPushed) Rand.PopState(); } + + // LandingEnded sets Find.TickManager.CurTimeSpeed = TimeSpeed.Normal, which MP overrides with its own speeds. + // Without async time all tickables share one speed (like a time speed command sets it), otherwise only the + // landing map's is changed. + internal static void SetNormalSpeedAfterLanding(Map map) + { + if (Multiplayer.GameComp.asyncTime) + map.AsyncTime().DesiredTimeSpeed = TimeSpeed.Normal; + else + Multiplayer.AsyncWorldTime.SetTimeEverywhere(TimeSpeed.Normal); + } } #endregion From 2bca475a76be8fac8a2eedd35b6100699bd606b4 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:19:19 -0600 Subject: [PATCH 174/183] End gravship cutscenes that lost their gravship A join point during a gravship cutscene (e.g. executed right after the unfreeze, before the synced end) loses the gravship: nothing saves it while it's neither on a map nor a world object. LandingEnded then threw and TakeoffEnded threw after abandoning the map, leaving the game in the cutscene for good (no world map, no saving, the map paused). Without a gravship, end the cutscene instead: in the synced end command, and right after loading when a cutscene was pending (no client would end it then). This also closes the landing session of a catching-up joiner that executes the end before its own PlaceGravship ran. Co-Authored-By: Claude Opus 5.5 --- .../Patches/GravshipTravelSessionPatches.cs | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 257a15528..988132d1f 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -346,6 +346,47 @@ public static void SyncedEndExecuting() Unfreeze(); } + /// + /// Vanilla can't finish a cutscene without its gravship, which nothing saves while it's neither on a map nor a + /// world object: a join point during a cutscene (e.g. right after the unfreeze, before the synced end) loses + /// it, and a catching-up joiner can execute the landing's end before its own PlaceGravship ran. TakeoffEnded + /// (after abandoning the map) and LandingEnded would throw and leave the game in the cutscene for good (no + /// world map, no saving, the map paused). End the cutscene without the gravship instead, which is lost. + /// Runs in the synced end command or at the same point of every load, so the same way on all clients. + /// + public static void EndWithoutGravship(WorldComponent_GravshipController controller, bool takeoff) + { + Log.Warning($"MP: Gravship {(takeoff ? "takeoff" : "landing")} ended without its gravship (lost by a reload during the cutscene or not placed here yet)"); + + pending = Cutscene.None; + endRequested = false; + Unfreeze(); + + var map = controller.map; + if (takeoff) + { + GravshipTravelUtils.CloseSessionAt(controller.takeoffTile); + } + else + { + GravshipTravelUtils.CloseSessionAt(map?.Tile ?? controller.landingTile); + if (map != null) + PatchGravshipLandingEnded.SetNormalSpeedAfterLanding(map); + controller.moveDesignator = null; + Current.Game.Gravship = null; + } + + controller.map = null; + controller.ResetCutscene(); + } + + // After loading, a cutscene in progress can't continue (its gravship isn't saved) and nobody would end it + public static void EndCutsceneLostByLoading(WorldComponent_GravshipController controller) + { + if (pending != Cutscene.None && controller.gravship == null) + EndWithoutGravship(controller, pending == Cutscene.Takeoff); + } + [SyncMethod] public static void SyncTakeoffEnded() { @@ -391,8 +432,8 @@ public static class PatchGravshipControllerExposePendingEnd /// landing confirmation state (the landing marker), so recreate the session from it after loading. This runs at /// the same point of every load on every client and joiner, so they all get the same session (and session id). /// The takeoff side (prelaunch dialog and destination picker) isn't restored: it's UI which a reload closes, so a - /// restored session would keep the map paused with no way to continue or cancel. Neither is a landing cutscene - /// in progress: vanilla can't resume it after loading (the gravship it references isn't saved). + /// restored session would keep the map paused with no way to continue or cancel. A cutscene in progress can't + /// be resumed after loading (its gravship isn't saved), so it's ended instead (EndCutsceneLostByLoading). /// [HarmonyPatch(typeof(Game), nameof(Game.LoadGame))] public static class RestoreGravshipLandingSessionOnLoad @@ -402,7 +443,11 @@ static void Postfix() if (Multiplayer.Client == null || !ModsConfig.OdysseyActive) return; var controller = Find.GravshipController; - if (controller is not { LandingAreaConfirmationInProgress: true }) return; + if (controller == null) return; + + GravshipCutsceneSync.EndCutsceneLostByLoading(controller); + + if (!controller.LandingAreaConfirmationInProgress) return; // The same tile ArriveExistingMap/ArriveNewMap open the session at if (controller.landingMarker.gravship is { } gravship) @@ -437,6 +482,12 @@ static bool Prefix(WorldComponent_GravshipController __instance, out bool __stat return false; } + if (__instance.gravship == null) + { + GravshipCutsceneSync.EndWithoutGravship(__instance, takeoff: true); + return false; + } + GravshipCutsceneSync.SyncedEndExecuting(); GravshipTravelUtils.CloseSessionAt(__instance.takeoffTile); @@ -474,6 +525,12 @@ static bool Prefix(WorldComponent_GravshipController __instance, out (Map factio return false; } + if (__instance.gravship == null) + { + GravshipCutsceneSync.EndWithoutGravship(__instance, takeoff: false); + return false; + } + var map = __instance.map; // Normally runs in a command of the landing map which already provides its RNG state (and saves it after). From 425c18dd6448c60da23a1a6b9447fbd8029d2fa7 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:20:08 -0600 Subject: [PATCH 175/183] Apply only the widget-changed role assignments at ritual start Copying every field of the starting player's exposed assignments also replaced the ritual and the role change selection, which are saved by reference: precepts created during the game (a new faction's ideoligion, a reform) aren't in the shared cross refs and read back as null, which left the lord without roles or threw. Copy only what the role selection widgets change (assigned roles, spectators, a non-null role change selection; for psychic rituals the role and pawn assignments and spectators) and drop pawns which didn't resolve. Co-Authored-By: Claude Opus 5.5 --- .../Client/Persistent/PsychicRitualSession.cs | 19 +++++++++++++- Source/Client/Persistent/RitualSession.cs | 26 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/Source/Client/Persistent/PsychicRitualSession.cs b/Source/Client/Persistent/PsychicRitualSession.cs index 6b1537f57..9d676e5ca 100644 --- a/Source/Client/Persistent/PsychicRitualSession.cs +++ b/Source/Client/Persistent/PsychicRitualSession.cs @@ -76,13 +76,30 @@ public void Remove() private void StartWithAssignments(MpPsychicRitualAssignments startingAssignments) { if (startingAssignments != null) - MpUtil.ShallowCopy(startingAssignments, assignments); + ApplyStartingAssignments(startingAssignments, assignments); Remove(); ritual.MakeNewLord(assignments); Find.PsychicRitualManager.RegisterCooldown(ritual); } + // Only what the role selection widgets change, the rest (target, forced assignments) stays from the live object. + // Pawns which didn't resolve were already removed by ExposeData's PostLoadInit, except from the spectators. + private static void ApplyStartingAssignments(PsychicRitualRoleAssignments from, PsychicRitualRoleAssignments to) + { + if (from.roleAssignments != null) + to.roleAssignments = from.roleAssignments; + + if (from.pawnAssignments != null) + to.pawnAssignments = from.pawnAssignments; + + if (from.spectators != null) + { + from.spectators.RemoveAll(p => p == null); + to.spectators = from.spectators; + } + } + public void OpenWindow(bool sound = true) { var dialog = new PsychicRitualBeginProxy( diff --git a/Source/Client/Persistent/RitualSession.cs b/Source/Client/Persistent/RitualSession.cs index f75aaabe6..dc370a8b5 100644 --- a/Source/Client/Persistent/RitualSession.cs +++ b/Source/Client/Persistent/RitualSession.cs @@ -37,9 +37,9 @@ public void Remove() [SyncMethod(exposeParameters = new[] { 0 })] private void StartWithAssignments(MpRitualAssignments startingAssignments) { - // Copied into the session's object, which the open dialogs and the assignments' sync worker refer to + // Into the session's object, which the open dialogs and the assignments' sync worker refer to if (startingAssignments != null) - MpUtil.ShallowCopy(startingAssignments, data.assignments); + ApplyStartingAssignments(startingAssignments, data.assignments); // Handle the same stuff as Dialog_BeginGravshipLaunch if (data.isGravshipRitual) @@ -57,6 +57,28 @@ private void StartWithAssignments(MpRitualAssignments startingAssignments) Remove(); } + // Only what the role selection widgets change, the rest stays from the live object. E.g. the ritual (and so the + // roles) is saved by reference and precepts created during the game (a new ideoligion, a reform) aren't in the + // shared cross refs, so they'd read back as null. Pawns which didn't resolve (despawned) are left out. + private static void ApplyStartingAssignments(RitualRoleAssignments from, RitualRoleAssignments to) + { + if (from.assignedRoles != null) + { + foreach (var pawns in from.assignedRoles.Values) + pawns?.RemoveAll(p => p == null); + to.assignedRoles = from.assignedRoles; + } + + if (from.spectators != null) + { + from.spectators.RemoveAll(p => p == null); + to.spectators = from.spectators; + } + + if (from.roleChangeSelection != null) + to.roleChangeSelection = from.roleChangeSelection; + } + public void OpenWindow(bool sound = true) { var dialog = new RitualBeginProxy( From 218c7e522dcca9bf73cf433ee3acf4a8d9986ec8 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:21:21 -0600 Subject: [PATCH 176/183] Land the gravship with the confirming player's rotation Other players can still have the move designator selected, and rotating its preview changes the landing marker's (and the gravship's) rotation locally, which BeginLanding and later PlaceGravship use. Send the confirming player's rotation with SyncBeginLanding, deselect the move designator on every client and then apply the rotation before landing. Co-Authored-By: Claude Opus 5.5 --- .../Patches/GravshipTravelSessionPatches.cs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 988132d1f..476e7a38e 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -125,13 +125,16 @@ static bool Prefix(GravshipLandingMarker __instance) if (Multiplayer.ExecutingCmds) return true; confirmedLocally = __instance; - SyncBeginLanding(__instance); + SyncBeginLanding(__instance, __instance.GravshipRotation); return false; } + // The confirming player's rotation (vanilla only confirms without the move designator selected, so it's the + // last designated one): other players may still have the designator selected, and rotating its preview + // changes the marker's, and so the gravship's, rotation locally. BeginLanding lands with it. [SyncMethod] - public static void SyncBeginLanding(GravshipLandingMarker landingMarker) + public static void SyncBeginLanding(GravshipLandingMarker landingMarker, Rot4 rotation) { var gravshipController = Find.GravshipController; @@ -146,6 +149,23 @@ public static void SyncBeginLanding(GravshipLandingMarker landingMarker) return; } + // Deselect the designator everywhere: rotating its preview during the cutscene would change the rotation + // PlaceGravship places the ship with. Before setting the rotation, Deselected restores its own one. + if (Find.DesignatorManager.SelectedDesignator is Designator_MoveGravship) + { + CancelDesignatorDeselection.EnableCanceling(); + try + { + Find.DesignatorManager.Deselect(); + } + finally + { + CancelDesignatorDeselection.DisableCanceling(); + } + } + + landingMarker.GravshipRotation = rotation; + gravshipController.landingMarker = landingMarker; landingMarker.BeginLanding(gravshipController); gravshipController.landingMarker = null; From f145f9a5d8e2218e09938688a10f4084cd338692 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:21:45 -0600 Subject: [PATCH 177/183] Don't let the simulation use a tile cache the interface regenerated When FastTileFinder's cache is dirty (after a reload, WorldReachability clearing it) the first Query or Closest regenerates it, which can be interface code on one client only (world targeter, launch and pilot console targeting). Later DirtyTile calls refresh only changed tiles, so the cache kept e.g. the neighbours' validForSettlement from that client's interface time. The simulation now regenerates a cache the interface regenerated. Part of #648 Co-Authored-By: Claude Opus 5.5 --- Source/Client/Patches/Determinism.cs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index ac26af964..79360c20f 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Reflection; using System.Reflection.Emit; +using System.Runtime.CompilerServices; using Multiplayer.Client.AsyncTime; using Multiplayer.Client.Util; using RimWorld.QuestGen; @@ -829,13 +830,20 @@ static IEnumerable TargetMethods() yield return AccessTools.Method(typeof(FastTileFinder), nameof(FastTileFinder.Closest)); } + // Tile caches regenerated by interface code (only on that client, at its own time). Later DirtyTile calls + // refresh only the changed tiles, so the cache keeps e.g. neighbours' validForSettlement from that time. + internal static readonly ConditionalWeakTable regeneratedInInterface = new(); + // Query and Closest reuse their last result for the same parameters until a tile changes, but they share the // flag: a call made only by one client's interface could make the simulation reuse a result from before a - // tile change. Always recompute in the simulation. + // tile change. Always recompute in the simulation, and regenerate a tile cache the interface regenerated. static void Prefix(FastTileFinder __instance) { - if (Multiplayer.Client != null && !Multiplayer.InInterface) - __instance.tileChangedSinceQuery = true; + if (Multiplayer.Client == null || Multiplayer.InInterface) return; + + __instance.tileChangedSinceQuery = true; + if (regeneratedInInterface.TryGetValue(__instance, out _)) + __instance.cacheDirty = true; } static IEnumerable Transpiler(IEnumerable instructions) @@ -856,6 +864,20 @@ static int GetBatchCount(int length) => Multiplayer.Client != null ? length : UnityData.GetIdealBatchCount(length); } + [HarmonyPatch(typeof(FastTileFinder), nameof(FastTileFinder.RegenerateCache))] + static class FastTileFinderRegenerateCachePatch + { + static void Prefix(FastTileFinder __instance) + { + if (Multiplayer.Client == null) return; + + var table = FastTileFinderQueryDeterminismPatch.regeneratedInInterface; + table.Remove(__instance); + if (Multiplayer.InInterface) + table.Add(__instance, null); + } + } + // PlayerItemAccessibilityUtility caches its scan of all accessible things per (tile, RealTime.frameCount). Quest // generation (trade request and beggars quests, also during every random quest selection) calls it from the // simulation, and the scan uses Rand (leather amounts): whether a call rescans depended on the client's frames, From 2b952b94330b711351d11c9ab22ad2687edec6e5 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:22:23 -0600 Subject: [PATCH 178/183] Remove the departing gravship in its map's context The takeoff counterpart of PlaceGravship: the long event queued from the gravship capture callback removes the ship from the takeoff map (creating its world object) and rebuilds the vacuum data with the interface's RNG and clock. Run that lambda in the takeoff map's context too and update the managers a command updates afterwards. Co-Authored-By: Claude Opus 5.5 --- .../Patches/GravshipTravelSessionPatches.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 476e7a38e..539cd6a30 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using HarmonyLib; using Multiplayer.API; @@ -251,6 +252,57 @@ static void Finalizer(Map map, int __state) } } + /// + /// The takeoff counterpart of PatchPlaceGravshipInMapContext: the long event queued from the gravship capture's + /// callback (OnGravshipCaptureComplete) removes the gravship from the takeoff map, creating its world object, and + /// rebuilds the map's vacuum data. Run it in the takeoff map's context as well + /// (RemoveGravshipFromMap_Patch pushes the gravship's faction inside it). + /// + [HarmonyPatch] + public static class PatchGravshipRemovalInMapContext + { + static MethodBase TargetMethod() + { + // The only lambda of OnGravshipCaptureComplete calling RemoveGravshipFromMap + var removeGravship = AccessTools.Method(typeof(WorldComponent_GravshipController), + nameof(WorldComponent_GravshipController.RemoveGravshipFromMap)); + var lambdaPrefix = $"<{nameof(WorldComponent_GravshipController.OnGravshipCaptureComplete)}>"; + + return typeof(WorldComponent_GravshipController).GetNestedTypes(AccessTools.all) + .SelectMany(t => t.GetDeclaredMethods()) + .Where(m => m.Name.StartsWith(lambdaPrefix)) + .First(m => PatchProcessor.ReadMethodBody(m).Any(i => i.Value as MethodInfo == removeGravship)); + } + + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(out (Map map, int randDepth) __state) + { + __state = (null, -1); + if (Multiplayer.Client == null || Multiplayer.Ticking || Multiplayer.ExecutingCmds) return; + + // The takeoff map, set by InitiateTakeoff + var map = Find.GravshipController?.map; + if (map?.AsyncTime() is not { } comp) return; + + __state = (map, Rand.stateStack.Count); + comp.PreContext(); + } + + static void Postfix((Map map, int randDepth) __state) + { + __state.map?.AsyncTime().UpdateManagers(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer((Map map, int randDepth) __state) + { + if (__state.map == null) return; + + AsyncTimeComp.RestoreRandStack(__state.randDepth + 1, nameof(WorldComponent_GravshipController.RemoveGravshipFromMap)); + __state.map.AsyncTime().PostContext(); + } + } + #endregion #region Landing/Takeoff freeze From 2336353cd7b298d104c5dcfed3809409ddc87377 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:22:26 -0600 Subject: [PATCH 179/183] Report the simulated paused state to commands and sim long events Commands, the constant ticker and sim long events still got TickManager.Paused from the local view (viewed map, world view, vanilla ForcePaused incl. TilePicker.Active). They now get whether their context's tickable is paused. StartJob counts a job given while paused only for the tracker's second one in the same step (its frame count is already replaced by the map's event count), so the paused value itself made jobsGivenThisTick differ between clients. This also corrects the reason given in 6fcaecb, which wasn't the frame count. Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncTimePatches.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index 52b46eb90..18919928e 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -254,15 +254,25 @@ static void Postfix(ref bool __result) { if (Multiplayer.Client == null) return; - // The rest of this reads the local view. A world or map tick is running, so report not paused (vanilla only - // ticks unpaused): ticks can continue after a rate change in the same game tick (TickPatch.TickLockstep), - // and while paused Pawn_JobTracker.StartJob counts jobs by the local frame count. + // The simulation must not see the local view (the viewed map, the world view, vanilla's ForcePaused with + // e.g. TilePicker.Active): Pawn_JobTracker.StartJob counts a job given while paused only if it's the + // tracker's second in the same step, so the value changed jobsGivenThisTick per client. + // A world or map tick: not paused, as vanilla only ticks unpaused. Ticks can continue after a rate change + // in the same game tick (TickPatch.TickLockstep). if (AsyncWorldTimeComp.tickingWorld || AsyncTimeComp.tickingMap != null) { __result = false; return; } + // Commands, the constant ticker and sim long events: whether their context's tickable is paused + if (Multiplayer.ExecutingCmds || ConstantTicker.ticking || Patches.SimLongEventContext.InSimulation) + { + var tickable = (ITickable)Multiplayer.MapContext?.AsyncTime() ?? Multiplayer.AsyncWorldTime; + __result = tickable.ActualRateMultiplier(tickable.DesiredTimeSpeed) == 0; + return; + } + if (WorldRendererUtility.WorldSelected) return; if (FactionCreator.generatingMap) return; From 99d1da2e91e3653f5e7190db05c55c2d19a6280c Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:23:09 -0600 Subject: [PATCH 180/183] Fire other quest incident makers once and keep owners without data firing Incident makers other than threat generators (from mods) passed the filter in the world tick and in every map tick, so they fired 1+N times; they now run in the world tick only. A map owned by a player faction without world data (so without a storyteller pass) never got its threats; it now falls back to the pawn-based choice. Part of #659 and #884 Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/StorytellerPatches.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index 73747e2ed..7730d5b97 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -110,12 +110,14 @@ static bool HasPawnsOn(Map map, Faction faction) => // In multifaction the single player faction whose storyteller runs the quest incident makers (threat generators) // of a map: its owner, else the lowest id player faction with pawns there, else the lowest id player faction. // Every faction's storyteller walks the same quest list, so without this each one fired the same raids. + // Only factions with world data have a storyteller (FactionRepeater). public static Faction QuestIncidentsFaction(Map map) { var worldComp = Multiplayer.WorldComp; var spectator = worldComp.spectatorFaction; - if (map != null && OwnerFaction(map) is { IsPlayer: true } owner && owner != spectator) + if (map != null && OwnerFaction(map) is { IsPlayer: true } owner && owner != spectator && + worldComp.factionData.ContainsKey(owner.loadID)) return owner; Faction first = null; @@ -161,10 +163,12 @@ static void Postfix(ref List __result) bool incidentMakersAllowed = !Multiplayer.GameComp.multifaction || Faction.OfPlayer == StorytellerTargetsPatch.QuestIncidentsFaction(mapContext); + // A threats generator makes incidents for its own map, so in that map's tick. Other (mods') incident makers + // only in the world tick: the storyteller ticks in the world tick and in every map tick. __result = __result.Where(questPart => questPart switch { QuestPart_ThreatsGenerator threats => incidentMakersAllowed && threats.mapParent?.Map == mapContext, - IIncidentMakerQuestPart => incidentMakersAllowed, + IIncidentMakerQuestPart => incidentMakersAllowed && mapContext == null, _ => true }).ToList(); } From 925534cc326460617c062a3d440fa0b7f4d93d53 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:23:54 -0600 Subject: [PATCH 181/183] Start maps generated anywhere in the world tick at the maps' tick worldTicks is updated right after DoSingleTick, so a map generated later in the world tick (world sessions, dissolution updates) started at the new value and, being in the same game tick's map snapshot, ended a tick ahead. During the world tick new maps now start at worldTicks from before it. Part of #339 Co-Authored-By: Claude Opus 5.5 --- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 4 ++++ Source/Client/Patches/MapSetup.cs | 11 +++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 6398d55b4..35122f763 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -73,6 +73,9 @@ public TimeSpeed DesiredTimeSpeed public int worldTicks; + // worldTicks from before the world tick in progress: the game tick the maps are at until they tick in it + public int TicksBeforeTick { get; private set; } + public AsyncWorldTimeComp(World world) { this.world = world; @@ -112,6 +115,7 @@ public void ExposeData() public void Tick() { + TicksBeforeTick = worldTicks; tickingWorld = true; int randDepth = Rand.stateStack.Count; PreContext(); diff --git a/Source/Client/Patches/MapSetup.cs b/Source/Client/Patches/MapSetup.cs index 329dfed01..69493bf67 100644 --- a/Source/Client/Patches/MapSetup.cs +++ b/Source/Client/Patches/MapSetup.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using HarmonyLib; +using Multiplayer.Client.AsyncTime; using RimWorld; using Verse; @@ -63,11 +64,13 @@ private static AsyncTimeComp CreateAsyncTimeCompForMap(Map map, bool usingMapTim } else { - // Without async time the maps tick in lockstep with the world (TickPatch.TickLockstep). A map generated by - // the world tick, while worldTicks is still one behind, ticks in the same game tick, one generated later - // from the next one. The other maps' clocks differ from that in the middle of a game tick. + // Without async time the maps tick in lockstep with the world (TickPatch.TickLockstep). A map generated + // during the world tick still ticks in the same game tick, so it starts where the maps were before it (also + // after DoSingleTick updated worldTicks, and with TicksGame of a nested map context). One generated later + // starts at worldTicks and ticks from the next game tick. The other maps' clocks differ from that in the + // middle of a game tick. startingMapTicks = !Multiplayer.GameComp.asyncTime && Multiplayer.AsyncWorldTime is { } worldTime - ? worldTime.worldTicks + ? AsyncWorldTimeComp.tickingWorld ? worldTime.TicksBeforeTick : worldTime.worldTicks : Find.Maps.Where(m => m != map).Select(m => m.AsyncTime()?.mapTicks).Max() ?? Find.TickManager.TicksGame; gameStartAbsTick = Find.TickManager.gameStartAbsTick; startingTimeSpeed = TimeSpeed.Paused; From 273b61b380f0f73f3618e26237c1925b11bcda03 Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:33:58 -0600 Subject: [PATCH 182/183] Ignore late gravship aborts and stale cutscene freeze state An AbortLanding command executed after the landing began (another player confirmed meanwhile, or the abort dialog was left open) closed the landing session mid-landing and vanilla then threw: skip it when no landing area confirmation is in progress. Tie the cutscene freeze to the connection it was sent on and clear this client's leftover freeze state when a game without a pending cutscene loads (e.g. rejoining after one ended). Correct the comment on when EndWithoutGravship runs identically on every client. Co-Authored-By: Claude Opus 5.5 --- .../Patches/GravshipTravelSessionPatches.cs | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 539cd6a30..cd417722d 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -200,12 +200,19 @@ static void Finalizer(WorldComponent_GravshipController __instance) [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.AbortLanding))] public static class PatchAbortLandingToCloseSession { - static void Prefix(WorldComponent_GravshipController __instance, ref bool __state) + static bool Prefix(WorldComponent_GravshipController __instance) { - if (Multiplayer.Client == null) return; - if (!Multiplayer.ExecutingCmds) return; + if (Multiplayer.Client == null) return true; + if (!Multiplayer.ExecutingCmds) return true; + + // The landing began (or the ship was already redirected to a new map) before this command, e.g. another + // player confirmed meanwhile or the abort dialog was left open. Closing the session would unpause the + // landing map mid-landing and vanilla would throw (no landing marker or landing map). + if (!__instance.LandingAreaConfirmationInProgress || __instance.landingMap == null) + return false; GravshipTravelUtils.CloseSessionAt(__instance.landingTile); + return true; } } @@ -342,6 +349,7 @@ private enum Cutscene { None, Takeoff, Landing } // Local state of this client private static bool endRequested; private static bool frozen; + private static MultiplayerSession frozenSession; // The connection the freeze was sent on public static bool EndRequested => endRequested && pending != Cutscene.None; @@ -360,6 +368,7 @@ public static void CutsceneStarted(bool takeoff) endRequested = false; frozen = true; + frozenSession = Multiplayer.session; GravshipTravelUtils.StartFreeze(); } @@ -368,7 +377,11 @@ private static void Unfreeze() if (!frozen) return; frozen = false; - GravshipTravelUtils.StopFreeze(); + + // A freeze sent on an earlier connection (disconnected or rejoined since) ended with it + if (frozenSession == Multiplayer.session) + GravshipTravelUtils.StopFreeze(); + frozenSession = null; } private static bool IsPending(bool takeoff) => @@ -424,7 +437,10 @@ public static void SyncedEndExecuting() /// it, and a catching-up joiner can execute the landing's end before its own PlaceGravship ran. TakeoffEnded /// (after abandoning the map) and LandingEnded would throw and leave the game in the cutscene for good (no /// world map, no saving, the map paused). End the cutscene without the gravship instead, which is lost. - /// Runs in the synced end command or at the same point of every load, so the same way on all clients. + /// After loading this runs the same way on every client (the gravship is gone everywhere). From the synced end + /// command it only runs on clients whose own rendering hasn't removed or placed the gravship yet (the arbiter + /// never renders, a lagging or catching-up joiner): they already differ from the others, this just replaces + /// the exception and the never ending cutscene there. /// public static void EndWithoutGravship(WorldComponent_GravshipController controller, bool takeoff) { @@ -452,11 +468,20 @@ public static void EndWithoutGravship(WorldComponent_GravshipController controll controller.ResetCutscene(); } - // After loading, a cutscene in progress can't continue (its gravship isn't saved) and nobody would end it - public static void EndCutsceneLostByLoading(WorldComponent_GravshipController controller) + // Called at the same point of every load + public static void AfterLoading(WorldComponent_GravshipController controller) { - if (pending != Cutscene.None && controller.gravship == null) + if (pending == Cutscene.None) + { + // Left over from a cutscene of an earlier connection (e.g. rejoining after it ended) + endRequested = false; + Unfreeze(); + } + else if (controller.gravship == null) + { + // A cutscene in progress can't continue (its gravship isn't saved) and nobody would end it EndWithoutGravship(controller, pending == Cutscene.Takeoff); + } } [SyncMethod] @@ -505,7 +530,7 @@ public static class PatchGravshipControllerExposePendingEnd /// the same point of every load on every client and joiner, so they all get the same session (and session id). /// The takeoff side (prelaunch dialog and destination picker) isn't restored: it's UI which a reload closes, so a /// restored session would keep the map paused with no way to continue or cancel. A cutscene in progress can't - /// be resumed after loading (its gravship isn't saved), so it's ended instead (EndCutsceneLostByLoading). + /// be resumed after loading (its gravship isn't saved), so it's ended instead (GravshipCutsceneSync.AfterLoading). /// [HarmonyPatch(typeof(Game), nameof(Game.LoadGame))] public static class RestoreGravshipLandingSessionOnLoad @@ -517,7 +542,7 @@ static void Postfix() var controller = Find.GravshipController; if (controller == null) return; - GravshipCutsceneSync.EndCutsceneLostByLoading(controller); + GravshipCutsceneSync.AfterLoading(controller); if (!controller.LandingAreaConfirmationInProgress) return; From 48afdb4431ea766e5ad0473a3f65933dcb22dceb Mon Sep 17 00:00:00 2001 From: WoodyWizard <6019828+WoodyWizard@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:19:36 -0600 Subject: [PATCH 183/183] Describe the desync review changes for the pull request docs/desync-review.md lists every change on this branch, grouped by area, with the commits and issues, plus the compatibility and behavior changes, known limitations and how it was tested. It is meant for reviewing the pull request and can be removed before merging. The branch is a broad attempt at fixing desyncs and a number of smaller bugs: a full review of the desync-relevant code (command log and join points, server, RNG and simulation contexts, sync framework, sessions, multifaction, async time and the tick loop, storyteller, gravship), followed by a review of all open pull requests and desync issues, porting the useful parts and fixing the remaining issues. Co-Authored-By: Claude Opus 5.5 --- docs/desync-review.md | 788 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 788 insertions(+) create mode 100644 docs/desync-review.md diff --git a/docs/desync-review.md b/docs/desync-review.md new file mode 100644 index 000000000..2cc833ce4 --- /dev/null +++ b/docs/desync-review.md @@ -0,0 +1,788 @@ +# Desync review: changes on `fix/desync-review` + +This document lists every change on the `fix/desync-review` branch, grouped by area. It is written for reviewing the +pull request and can be removed before merging. + +## Scope + +The branch has 182 commits on top of `dev` at `e760d30` (192 files, +8957/−1468). The work was done in three stages: + +1. A full review of the code that decides whether clients stay in sync: the command log and join points, the server + and networking, RNG and simulation contexts, the sync framework, sessions, multifaction faction contexts, async + time and the tick loop, the storyteller and quests, and the Odyssey gravship. +2. A review of all 28 open pull requests and about 60 open issues against the branch. Useful parts of open PRs were + ported or reimplemented; the entries below credit them. +3. Fixes for the open issues that were still not addressed after stage 2. + +Most of the desyncs fixed here fall into a few groups: + +- Code that runs on only some clients (the interface, the client that issued a command, the camera or view, local + settings, the game language) changed shared state or used the synced RNG. +- Global state was set in a Harmony prefix and restored in a postfix, which does not run when the original method + throws. +- Joiners and reloading clients reached a state by a different path than live clients (command order, `ProgramState`, + state that is not saved). +- In multifaction, simulation code ran with the local player's faction data instead of a faction chosen by the + simulation. +- The interface and the simulation shared a cache, so a value cached by the interface on one client was later read by + the simulation. + +Conventions: hashes are commits on `fix/desync-review`; `#N` are issues and pull requests in rwmt/Multiplayer. +"Fixes #N" means the listed commits fully fix the issue; "part of #N" means they address part of it. + +Terms used below: + +- **Sync reference**: the client whose state counts as correct: the arbiter, else the host, else (on a standalone + server) the longest-connected player that is caught up and in sync. +- **Join point**: a saved world plus the command log after it, which joiners load and replay. +- **Sim long event**: a long event queued during a tick or command, which runs later in the frame, outside of it. +- **Forced faction push**: `map.PushFaction(f, force: true)`, which (after `c3889fa`) applies a faction's data to the + world and to every map. +- **`LocalOnlyScope`**: runs code with a pushed RNG state and local unique ids, with desync traces ignored + (`5a92b1d`). + +## 1. Joining, command log and join points + +### Command log + +- **One globally ordered command log for joiners.** Live clients run all commands from one queue in arrival order + (#734), but the server stored commands per map and joiners replayed them "world first, then each map", so any tick + with commands for two tickables ran in a different order on joiners (and `AsyncTimeComp.FinalizeInit` could drop or + duplicate map commands received during the load). The server now keeps one ordered list (`Saved/cmds.dat` on + standalone), clients build the queue from it in `Loader.PostLoad`, `RunCmds` drops a command for a past tick with an + error instead of blocking forever, and the `consistentCommandOrder` tweak value is removed; protocol bumped to 57. + `7d09265`, test `0bd4e06` +- **Command size is checked before the command is logged.** The server's rebroadcast adds a 17-byte header, so a + client command near the 65535-byte limit made `Send` throw after the command was already in the log, and joiners + executed a command that live clients never received. Command data is now capped at what fits the rebroadcast and + rejected before logging. `ec1900b` (the cap was later raised to 1 MiB by fragmented commands, `f8dd311`) +- **Saved standalone command logs from other protocols are discarded.** Nothing recorded which protocol wrote the + command log in `Saved/`, so a log from protocol 56 could be replayed with misread sync data. The protocol is now + written to `Saved/cmds_protocol.txt` and a log without a matching protocol is discarded with an error, while the + saved world, maps and session still load; legacy per-map logs are no longer merged. `2947cb7` + +### Join points + +- **Join points are atomic and have one designated uploader.** On standalone servers a join point reloaded only the + issuing client (#874), but a reload is not a no-op (LoadPatch's extra tick, the seed long event, statics reset by + `MultiplayerGame`, state that isn't saved), so that client drifted from the others and joiners loading its save + drifted from live clients. Now every client reloads, as in hosted mode; the server names the uploader (the sync + reference) in `CreateJoinPoint`, accepts only its upload for that tick and swaps world, maps and command log + together. A join point is aborted when the uploader leaves, desyncs or rejoins, or after 60 s without progress; + forced join points are rate-limited to one per 10 s; minute-based standalone autosaves are issued by the server at a + tick; world-travel join point requests are ignored. `bdf1995` +- **Join points don't time out while the uploader is saving.** Only upload fragments counted as progress, so a big + game whose save took over a minute always had its join point aborted. Missing keep-alive replies while the + uploader's main thread is blocked now count as progress; a client that hangs for good is still dropped by the + 10-minute keep-alive timeout. `940a691` +- **One join point per saved tick.** With day-based autosaves every client requests a join point after its save, and a + slower client started a second one, reloading everyone again. Save requests now carry the saved state's game timer + and the server ignores requests already covered by the current or last completed join point. `edd7d3c` +- **The arbiter runs in-game synchronous long events.** Such an event only starts after its loading window has been + drawn, and in batch mode windows are never drawn, so the arbiter never ran join point creation: it stopped + simulating and the server paused everyone waiting for it. On `dev` an arbiter game froze for everyone as soon as + someone joined. `c877df8` +- **World data for joiners stays within the fragmented packet limit.** `Server_WorldData` carries the save, the log + and up to 10 stored sync opinions, which with traces could exceed 32 MiB; `SendFragmented` then threw and every + later joiner got a state exception disconnect. Stored opinions are capped at 8 MiB (the newest are kept) and only + those that fit are sent; if the data is still too big the joiner gets a message with the sizes. `37302bd` +- **The standalone join point test can fail.** Auto join points were off and the existing player wasn't a valid + uploader, so the test passed either way. `324df63` + +### Joining handshake and compatibility check + +- **Init data requests no longer hang every later join.** A joiner disconnecting during the handshake left the server + in `InitDataState.Requested`, so every later joiner waited forever. `RequestInitData` now always completes, + handshake waits time out (2 min for automatic replies, 15 min for replies behind user input), the `rawMods` limit is + restored (4 MiB compressed, also for the forwarded copy), and the flow stops right after a protocol or username + disconnect. `7c88a31` +- **Players who left while waiting for init data stop their join flow** instead of waiting for join data on a dead + connection (`c189a0c`). The init data upload, which carries the mod list and synced configs, gets 5 minutes instead + of 2 (`1b011ec`). +- **Reloads run under `ProgramState.MapInitializing`.** Join point and resync reloads kept `ProgramState.Playing`, + unlike a normal load or a joiner, so load-time code behaved differently on reloading clients (e.g. + `GenSpawn.Spawn`'s per-cell item limit moved or destroyed stacked items only there). Fixes #854. `d67d559` +- **Different Multiplayer builds and missing def types are caught.** Mod file hashes covered only `Assemblies/`, but + Multiplayer's own DLLs are in `AssembliesCustom/`, and the def check only looked at def types the client sent. Those + DLLs are hashed now (shown as a file mismatch, "Connect anyway" still works), and the server also reports def types + missing on the client (`ServerJoinDataPacket.missingDefs`). `8205395` +- **Line endings are ignored when comparing mods.** Synced mod configs and Defs/Patches XML hashes were compared byte + by byte, so the same mod on Windows (CRLF) and Linux (LF) showed a mismatch; assemblies are still hashed raw. Config + comparison from #961. Fixes #386. `0198180`, `03f3ae5` +- **A different game language shows in the mismatch window.** Translated text is generated and saved during the + simulation and name generation retries depend on it, so different languages can desync. The init data provider's + language is sent in the join data and compared like the other entries. From #961; part of #264. `92af9a9` + +## 2. Server and networking + +### Threads and lifecycle + +- **The server join flow stays on the server thread.** `TypedPacket()` used `ContinueWith` without + `ExecuteSynchronously` and the server thread has no `SynchronizationContext`, so everything after the first awaited + typed packet in `ServerJoiningState` (pausing, join point creation, `OnJoin`, disconnects) ran on a thread-pool + thread at the same time as the server loop, racing `CommandHandler.Send` and the player list. Typed waits now resume + inline on the packet handler's thread, and `MultiplayerServer.CheckServerThread()` logs off-thread calls. `d2cf042` +- **The server stops once, on its own thread.** `TryStop` ran twice and cleared the static instance unconditionally, + so the rest of a server iteration could run without an instance (the `FreezeManager.Tick` NRE after which the game + timer never advanced). It is now latched, clears the instance only if it is still the same server, and from other + threads waits up to 5 s for the server thread. Reimplements #993 (@MhaWay). Likely fixes the NRE of #991 (its "players too far behind" symptom needs a retest). `cd6d1f5` + +### Client input validation + +- **Server-only commands and foreign faction changes are rejected.** The server forwarded any command type for any map + id, so a modified client could send `InitPlayerData` (granting dev mode) or spam `PauseAll`/`CreateJoinPoint`, and + anyone could move anyone to any faction. Unknown types and map ids outside the type's scope now disconnect the + sender; players may change only their own faction and moving others is host-only. `dd969f9` +- **Remote lengths are validated before allocating.** A length prefix of `int.MaxValue` made the host try to allocate + 2 GB, and `PacketReader.BindBytes` ignored `maxLength` (so the mod list limit from #878 never applied). Reads and + binds now check lengths against the remaining data, and `Gzipped` caps the decompressed size at 64 MiB; no wire + format change. `dba4ff2` +- **String limits count UTF-8 bytes.** The writer checked string limits by char count and the reader by UTF-8 bytes, + so a Steam name of 17+ Cyrillic letters passed the host's check and made every receiver of the player list drop the + connection. Steam names are bounded by Steam's own byte limit (from #961) and array binds reject negative lengths. + `41c8fab` +- **Unresponsive players time out; client timing reports are clamped.** `keepAliveAt` was never read, so a hung game + stayed connected and its `ticksBehind` paused everyone indefinitely; players in game that don't answer keep-alives + for 10 minutes are now dropped (the in-memory host connection is exempt). `ticksBehind` and `frameTime` are clamped + and NaN-safe: a NaN frame time used to stop the server's game timer for good. `b2fb3f1` + +### Packets + +- **Partially received fragmented packets are dropped on a state change.** The fragment buffer survived connection + state changes, so after a change in the middle of a transfer the next fragmented packet failed with "High number of + fragmented packets at once!" and the rejoin broke. Partial packets are discarded and their remaining fragments + skipped by fragment id. `e3edd35` +- **Big commands are sent fragmented.** Commands were single packets of at most 64 KiB, so big actions (designating or + pasting a large area, reforming an ideoligion) failed with a send exception halfway. Commands that don't fit are + sent fragmented on the same reliable ordered channel, capped at 1 MiB, and the client shows a message instead of + throwing. Fixes #415. `f8dd311` +- **Selections over 200 things are split.** The selection packet carries at most 200 ids per list, so bigger changes + (possible with mods) threw in the writer every 0.2 s; they are now split into several packets. Fixes #377. `9b94384` + +### Standalone servers + +- **Desyncs are detected on standalone servers.** Only the host or arbiter sent sync opinions, so a standalone server + never compared anything. The server now forwards the opinions of the sync reference, re-evaluates it when a player + becomes ready, desyncs, rejoins or leaves, and on standalone tells the old and new reference with a new + `Server_SyncReference` packet (appended, existing packet ids unchanged), so only that client uploads opinions. + `6c48789` + +### Steam and LiteNetLib + +- **Steam reconnects work and closed P2P sessions are freed.** A join packet from a remote that still had a connection + was dropped, so a player who rejoined before the old connection timed out waited forever; P2P sessions were never + closed, and a rejected prompt made the host ignore all later requests from that player. The stale connection is + replaced, sessions are closed on disconnect (3 s after a goodbye so it gets delivered), a rejected player's requests + are ignored for 30 s (`49cd635`), and due closes run after the tick's packets are read so a waiting reconnect + cancels them (`799efd4`). Steam part of #958 (@romangr); its same-IP replacement for direct connections was left out + because a player behind the same NAT could kick another. Fixes #843. `8ce4386`, `49cd635`, `799efd4` +- **Sends to a disconnecting LiteNetLib peer aren't reported as errors.** LiteNetLib changes the peer state on its own + thread, so the rest of the server tick still sent to the peer and logged an error (and failed `ServerTest` at + times). These are logged at verbose level now. Reimplements #977 (@M-r-A). `6b109e5` + +## 3. Determinism + +### RNG isolation + +- **Sound and music don't use the synced RNG.** `Fire.TickInterval` reaches a `SoundSizeAggregator` constructor + (`Rand.Value`) only when no aggregated sustainer exists locally, which depends on the local sound manager, so the + map RNG advanced on some clients only. The RNG is pushed and popped around the sound entry points reachable from + ticks and around the music manager triggers. Part of #618. `675ec35` +- **`Rand` push/pop is exception safe.** Several seeding patches (`SeedGameLoad`, `SeedMapLoad`, + `SeedMapFinalizeLoading`, `SeedCaravanEnter`, `SeedGrammar`, `SeedPawnGraphics`, `TryGetNearbyUnseenCell`, + `DeterministicUndercaveRockCollapse`) popped in a postfix, so an exception left an orphaned entry that the enclosing + tick saved as its own state, and the extra stack depth turned desync traces off for the session. They pop in + finalizers now, and ticks and commands unwind leftover pushes; `TryGetNearbyUnseenCell` is also seeded with the pawn + id. `f5a41e8` +- **Quest name generation retries are seeded.** The `GrammarRequest` overload of `NameGenerator.GenerateName` and + `QuestNode_ResolveQuestName` retry depending on the generated text, so on the game language. Port of #980 + (@romangr); part of #264. `b815de5` + +### Simulation contexts + +- **Sim long events run in a deterministic context.** Only the action of such an event was seeded: the faction context + was lost (under multifaction each client generated maps for its own faction), actions deferred with + `ExecuteWhenFinished` counted as interface code (unseeded RNG, local unique ids, sync methods sending commands), and + events could run on a worker thread sharing `Rand` with the UI. A `SimLongEventContext` (RNG seed and, in + multifaction, the faction) is now captured when the event is queued and applied around the whole update with + `InInterface` false, and such events run synchronously. `389678c` takes the seed without advancing the RNG (some + events are queued on the issuer only), skips autosave events and restores the context in `finally`. Part of #518 and + #621. `619bc19`, `389678c` +- **The ship countdown ends in the world context.** `ConstantTicker` ended it outside any tickable's context, so + `ShipCountdown.CountdownEnded` used the unsynced global RNG; it now runs inside the world's context, with the ship's + faction pushed (`0030d36`). `9427b7d` + +### Hashing and ordering + +- **Deterministic `HashCode.Combine` patches are applied early and always.** They switched on `Multiplayer.Client != + null`, so the hash function changed under containers alive across connecting and disconnecting, and containers + filled during def loading kept a per-process order. `6e8249c` +- **Inverted zone/plan cell shuffle check.** The transpiler emitted a bitwise `not`, so the condition was always true + and cells were never shuffled, even in singleplayer. Singleplayer gets vanilla's shuffle back; MP keeps the existing + no-shuffle behavior, since the shuffled flag isn't saved. `83d0cc8` +- **Job-loop counter for pawns without a map.** `Pawn_JobTracker.StartJob`'s frame count was replaced by the map's + event counter, but unspawned pawns (carried, in containers or caravans) fell back to the frame count, which differs + between clients. It now uses `MapHeld` with the tick timer as fallback. `fa7f328` + +### Threads + +- **Path jobs complete right after `PathFinderTick`.** The only guard against path jobs racing main-thread changes + (#900) ran after `MapPreTick`; jobs are now also completed in a `PathFinderTick` postfix so they can't overlap the + rest of `MapPreTick`. `9a7c278` +- **`FastTileFinder.Closest` is deterministic.** It kept a closest tile per worker thread, so equally close tiles went + to whichever thread found them first, and a cached result or cache regeneration triggered by one client's interface + could be reused by the simulation. `Closest` runs in a single batch like `Query`, and the simulation always + recomputes and regenerates a cache that the interface regenerated (`f145f9a`). Likely fixes #648 (the reported desync came from this path; not reproduced). `52cd03d`, `f145f9a` + +### Transpilers + +- **Fragile determinism transpilers fail loudly.** `TryGetRandomUnusedSolidName` loads its list the way the `Shuffle` + call does instead of hardcoding `ldloc.1`, the `TargetInfo`/`GlobalTargetInfo` hash code and + `ProcessPostTickVisuals` patches log when nothing was patched, and the plan color gizmo lambda is found with + `MpMethodUtil.GetLambda` instead of a hardcoded display class name. These errors are shown before hosting or joining + (`d4349f2`). `2e921f5` + +### Caches shared by the interface and the simulation + +- **Item accessibility cache per simulation step.** `PlayerItemAccessibilityUtility` reused its `Rand`-consuming scan + within a frame, so quest generation rescanned a client-dependent number of times and could read a scan made by the + interface. In MP it is reused only within one tick or command, and sim long events count as steps (`a3ccabd`). From + #961. `9b5661a`, `a3ccabd` +- **The goodwill situation cache isn't filled from the interface.** On a cache miss `GetSituations` filled the shared + cache and ran `CheckHostilityChanged`, which from the factions tab happened on one client only. The interface now + gets uncached values. From #961. `d3c7bb1` +- **Stat cache staleness is judged with the thing's own time.** With async time the interface checked + `StatWorker.GetValue`'s shared cache against the viewed map's or the world's time and could replace an entry that + the simulation would still have used. In the interface the time of the thing's map is used. `5923645` +- **The UI tile temperature cache is ticked.** It was only ticked when the real comp ticked in the interface, which + never happens, so its entries were never refreshed or evicted; it is now ticked at most once per frame when read, + and guarded against exceptions. `873f837`, `977e1bc` +- The grav engine cache is covered under the Odyssey section (`64e3aef`). + +## 4. Sync framework + +### Field watches and markers + +- **Watched fields are restored even if the watched code throws.** The watch postfix didn't run when the UI method + threw, leaving the user's change (or the buffered value) in that client's simulation and the watch stack unbalanced. + Watches now use finalizers and `try/finally`, always pop the whole scope, flush leaked scopes once per frame (also + at the end of `UIRootOnGUI`/`UIRootUpdate`, logged once), skip nested watches of an already watched field (as + `Bill_Production.paused` is), and clear the stack when MP stops. `a59e1c8`, `109b5f2`, `c436e65` +- **Sync state flags are reset in finalizers.** A new `MpFinalizer` attribute replaces postfixes where a skipped + postfix broke state for the session: `StorageSettingsClipboard.Copy` left `Multiplayer.dontSync` set (every later + sync handler ran locally), `ThingFilterMarkers` left a stale filter context, and the growth moment letter's RNG push + and the manual priorities marker could leak. `2aec6ac`, `417c0ac` + +### Serializers and sync workers + +- **Sync serializer reader and writer stay aligned.** Writers of implementation-indexed types wrote `0xFFFF` for an + unknown type followed by the object's data, which the reader didn't consume, and a failing implicit sync worker + truncated the writer to before the parent type's data while the reader re-read it. The writer now throws so the + command isn't sent, and both sides roll back to the same position. `5238392` +- **World pawns and genes sync reliably.** Pawns without a supported holder (world pawns, pawns kept by quests or + letters) were read as null, and genes were resolved by def alone, which picks the wrong one when a pawn has the same + def as endogene and xenogene. Unheld world pawns are looked up by id, genes are matched by exact `loadID`, and + `ChoiceLetter_ChoosePawn`'s option is cancelled if a captured field reads as null; protocol 57. `8bc60df`, `c436e65` +- **Lords are registered in the shared cross refs.** Synced data that references a lord (e.g. ceremony jobs) resolved + it to null; lords are registered at runtime and from the save, the same way on every client. From #961. `af91582` +- **The `WorldSelected` sync context includes the selected tile.** Only `WorldSelector.selected` was synced, so the + dev "Generate settlement" gizmo ran on every client's own selected tile. `selectedTile`/`selectedLayer` are synced, + the context is read inside `HandleCmd`'s `try/finally`, and `SyncUtil.prevSelected` holds the real previous + selection. `318bac4` + +### Sync methods and delegates + +- **The sync method transpiler emits valid default values.** For synced methods returning a struct (e.g. + `*Database.TryDelete` returning `AcceptanceReport`) the early return ran `ret` with an empty stack, and `out` + parameters of small value types were overwritten with 4 bytes. `1d5e411` +- **Colonist bar reordering is synced with pawns.** It was synced as the `ColonistBar.Entry` closure, whose indices + were resolved against each client's own cached bar, which differs in multifaction or with hidden maps. The sender + now resolves the pawns and the synced method applies vanilla's reorder rule to them. `dce32db`, `c436e65` +- **Thing filter edits stay out of the sender-only simulation.** Range setters run `settingsChangedCallback` (storage + rechecks the map's haulables), and the buffered watch and the slider called them every frame on the sender only. The + interface now writes only the backing field, edits without a map context are blocked, and "Clear all" leaves hidden + special filters alone as in vanilla. `85066f3` +- **`TransporterUtility.InitiateLoading` from float menu options is synced.** Carry to shuttle, place in transporter + and enter shuttle set the transporter group id on the clicking client only. From #961. `035d208` +- **A drafted stop order ends only the goto it was aimed at.** The synced `EndCurrentJob` runs ticks after the click, + so it could end a job (or another goto) the pawn had started meanwhile; it now checks the job id. From #961. + `017ed3c`, `aa636c5` +- **`PriorityWork.Clear` is allowed during ticks.** The guard meant to stop the interface from clearing prioritized + work also dropped vanilla's tick-time calls, so pawns stayed prioritized until the timeout. `393ac90` +- **The product style of bills is synced.** The bill dialog's style menu (Ideology, classic mode) set `Bill.style`, + `globalStyle` and `graphicIndexOverride` only locally. Reimplements #402 (@SokyranTheDragon) for 1.6. `5005f8f` +- **The health tab's dev "remove hediff" button is synced.** It removed the hediff locally; its `RemoveHediff` call is + redirected to a debug-only sync method. Fixes #358. `1fa6df1` +- **Debug actions replay under the acting player's view.** Vanilla debug actions (e.g. incident targets) read + `WorldRendererUtility.WorldSelected`, which is local view state. `ab816c3` (port of #976 by @M-r-A) overrides it + during the command, and `a3e2414` derives it from the command (world commands under the planet view, map commands + under their map) and reads all command data before installing overrides. `ab816c3`, `a3e2414` + +## 5. Sessions, issuer-only and interface-only code + +### Code that runs on a subset of clients inside the simulation + +- **`LocalOnlyScope` for issuer-only code in commands.** Commands run with the shared RNG and unique id counters, and + code that runs only on the issuing client (session windows, `FactionCreator`'s local setup) or uses local UI state + (`MainTabsRoot.Notify_SelectedObjectDespawned`) advanced them on one client only, the bug class #968 fixed at one + call site. Those call sites now run in `LocalOnlyScope`: ritual, psychic ritual, growth moment, transporter loading, + caravan forming, caravan splitting, map portal and trade windows. `5a92b1d`, `83f8853`, `ac8feeb` +- **Motes aren't null only on non-issuing clients.** During commands `MakeStaticMote` returned null on clients that + didn't issue the command but a mote on the issuer, so callers using the result threw or branched differently; + non-issuers now get an unspawned mote. `bcf9384` + +### Session lifecycle + +- **Saving doesn't change live sessions.** `SessionManager.WriteSessionData` removed invalid sessions from the live + lists, and saving can happen on one client only (e.g. the host's autosave), so session lists and map pausing could + differ. Invalid sessions are now only left out of the written data; inverted "log once" checks, the unsaveable + `CaravanFormingSession.onClosed` and an inverted early return in `HandleMissingDialogsGameStart` are fixed too. + `22f958b` +- **Growth moment sessions survive the child leaving the map (Biotech).** The session's map was `letter.pawn.Map`, so + once the child left, `Tick()` threw every tick and the session could never be removed, and the window's `PostClose` + ticked the session from UI code. The session stores its map, a dead or absent child invalidates it (checked by the + tick, not right after loading, when pawns aren't spawned yet), children held on the map (growth vat, carried, + shuttle) count as present, options are generated on every client, and one failing session no longer skips the + others. `de1999a`, `5452502`, `a1e0059` +- **The growth moment option is offered only to the child's faction.** A player of another faction with a pawn on the + map could choose the child's traits from the colonist bar. #961 makes the same check. `f3b6ca5` +- **Portal and transporter sessions are matched by their target.** A map can have one portal session per portal, but + the proxy took the first one, and transporter loading reused any session on the map; sessions despawning with their + portal threw. Sessions are looked up by portal or transporters, store their map, end with their portal, and caravan + forming sessions created in ticks use a player faction with pawns on the map instead of the spectator. `ee6e9af`, + `a1e0059` +- **Caravan splitting doesn't depend on the local dialog.** The synced cancel and accept used the session's dialog, + which exists only on clients that opened it, so cancelling threw elsewhere and accepting split the caravan only + there. Accept now splits through a proxy built from the session's transferables, and only the caravan's players get + the window. `e0161aa` + +### Trading + +- **Trade deals are recached by the simulation.** `MpTradeDeal.Recache()` changed the tradeables but was called only + from the trading window, i.e. on clients that had it open, so `TryExecute` traded different items elsewhere. The + session's world tick and the synced transferable lookup now recache with the negotiator's faction forced, and the + dirty state is saved and ignored while loading (`4649662`). `3f41f92`, `4649662` +- **Faction pushes are balanced in the trading UI and map faction ticks.** The trading window popped only when the + push returned non-null, leaking a stack entry every frame of an own-faction trade, and `MultiplayerMapComp.DoTick` + popped without `try/finally`. `b6f1126` + +## 6. Multifaction + +### Faction contexts + +- **Forced pushes apply the faction to every map.** Outside simulation contexts each map holds the local player's + faction data (areas, zones, designations, listers), and ticks and commands applied their faction only to their own + map, so code reaching into another map (portals, pocket maps, gravship landing) read client-specific data. Forced + pushes now apply the faction to the world and every map, and their pops restore exactly what each map held before. + Part of #621. `c3889fa`, `2da45bb` +- **Leaked faction pushes are unwound at tick and command boundaries.** A push left by an exception kept the wrong + `Faction.OfPlayer` and faction data after the context; the stack is popped back to the context's depth (reported + once), and leaked map pushes restore their map. `d412a1c`, `c5d0566` + +### Map generation + +- **The map generation faction is found from any player caravan.** `MapGenFactionPatch` used + `PlayerControlledCaravanAt`, which compares with `Faction.OfPlayer`, so only the caravan's owner found it and maps + were set up for different factions on different clients. The dead `TileFactionContext` fallback is removed. Fixes + #518 (with `619bc19`). `3b62baa` +- **Map generation uses the faction's world data.** The patch swapped only `Faction.OfPlayer`, so another faction's + map used the context faction's research, play settings, history and storyteller; it now does a forced `PushFaction`. + Part of #621. `b33e913`, `0030d36` + +### Creating a faction + +- **Factions are created with the same data on every client.** `FactionCreator`'s long event ran with each client's + own faction applied, so the fallback ideoligion, starting research, play settings and copied storyteller were the + local player's. It runs under a forced spectator push, `Faction.OfPlayer` is restored after `FactionManager.Add`, + new storytellers get their own difficulty copy, and the event keeps its faction data in its sim context (`d6a13aa`). + `578c0a6`, `d6a13aa` +- **Faction creation pages don't change shared state.** Opening the settle pages replaced `Current.Game.Scenario` for + good and registered the temporary pawn-generation faction (and possibly its ideoligion) in the live game on the + creating client only. The chosen scenario is applied only while the pages' own code runs, and the temporary faction + and its ideoligion stay unregistered. `8c2731e`, `8a091e5` +- **A new faction's ideoligion is generated for its own faction def**, not `Faction.OfPlayer`'s or the game scenario's + (wrong for e.g. tribal starts). `513af76` +- **Local starting pawn requests are cleared before faction map generation.** `PrepForMapGen` added custom xenotypes + from client-local static requests to the shared custom xenotype database. From #961. `e2a589f` +- **Synced starting pawns load like vanilla loads pawns.** The pawn was read in `Playing`, so the needs tracker hit an + NRE on hediffs without a pawn, and its ideoligion (from the unregistered page faction) didn't resolve, so a random + fallback was assigned and kept as a previous ideoligion. The pawn is sent without an ideoligion and read in + `MapInitializing` with the fallback disabled and the per-client `GameInitData` hidden; same wire format. Fixes #536 + and #537. `783d500`, `9992f50` + +### Letters + +- **The letter stack is the same on every client.** Each client removed other factions' letters from its stack, but + the simulation reads the stack (expiring letters and their default choices, growth moment sessions, archive + culling), and letters received in spectator contexts were dropped for everyone. Letters now stay in every stack with + their faction recorded and saved, only the UI filters them, and other factions' expiring letters open without their + windows or tab switches. `a95832b`, `ad4bc2f`, `b7a389f` + +### Seasons + +- **Season notifications run per player faction.** `DateNotifierTick` ran in the synced world tick with the local + player's faction, so clients saved different `lastSeason` values and the first summer warning letter came on some + clients only. It now runs once per player faction under a forced push with a saved `lastSeason` per faction, skips + factions without a home, uses the home map's whole clock and only the faction's own homes, and shows the message + only to that faction's players. Reimplements #904 (@Sakura-TA) per faction. `43a6493`, `0abd01f`, `ce0136a`, + `2da2603`, `81ba184` + +### World tick under the spectator faction + +- **Caravan battle wins and ideoligion ticks run for their player faction.** The world tick runs as the spectator, so + `CheckWonBattle` compared threats with the spectator and `Ideo.IdeoTick` never added date ritual obligations + (festivals). The battlefield's player faction and the first player faction following the ideoligion are force-pushed + around them, as in #961. `f409a3d`, `546b68d`, `bec7f23` + +### Other factions' data on shared maps + +- **Zone cells under new buildings are cleared for every faction, and other factions' designations on a destroyed + thing are removed.** Only the installed (context faction's) managers were updated, and the leftovers disappeared + only on a client that reloaded. From #961. `dceac58`, `6dc1e5b` +- **Other players are kept out of trade requests and ritual goodwill.** `Settlement.Visitable` excluded only + `Faction.OfPlayer`'s settlements, so trade request quests could target another player's colony, and the + `NearbyFactionGoodwill` ritual reward could raise goodwill with the nearest player. From #984 (@still222), with a + narrow transpiler instead of a global goodwill patch. `7399c62` + +### Converting to singleplayer + +- **The spectator faction is removed cleanly.** Removing it only from the faction list left references in + reservations, pawns, listers, letters, the play log, quests, ideoligions, tales and map events of the converted + save; it now goes through vanilla's faction removal. From #961. `c5aac4f`, `a6a4ab7` + +## 7. Async time, VTR and the tick loop + +### Tick loop + +- **The world and maps tick in lockstep without async time.** Each tickable ticked through its own accumulator (the + world N times, then each map N times), so new maps gained a tick, a rate change during a map tick came after the + world's ticks, and removing a map mid-tick made another one tick twice; world and map clocks drifted apart by a few + ticks. Every game tick now ticks the world and then a snapshot of the maps once each; new maps start at the world's + tick, also when generated later in the world tick (`925534c`), and at the game's speed (`34e19f8`). Async time is + unchanged. Fixes #339. `291c3c4`, `925534c`, `34e19f8` +- **Ticks and commands see the simulated paused state.** `TickManager.Paused` returned the paused state of the map the + local player was looking at, also inside ticks and commands, so for example `Pawn_JobTracker.StartJob`'s + `jobsGivenThisTick` differed between clients. Ticks now always see "not paused", and commands, the constant ticker + and sim long events see whether their own tickable is paused. `6fcaecb`, `2336353` +- **One subsystem's exception doesn't skip the rest of the tick.** `AsyncTimeComp.Tick` ran everything in one + `try/finally`, so an exception in `MapPreTick` skipped `mapTicks++` and everything after it, and the world could + skip `worldTicks++`. Each subsystem is caught separately, as in vanilla's `DoSingleTick`. `38fbe9d` + +### Time contexts + +- **Map time contexts nest.** A map's `PreContext` kept the previous time in one field, and quest time getters enter + their map's context while that map ticks the quest, so the outer `PostContext` left the map's clock installed and + the world clock jumped to the map's. Each context keeps its own frame, UI time restores run in finalizers, and maps + that are still being generated (no comps yet) are skipped instead of throwing. Fixes #789 (with `f8d9f49`). + `822b44c` + +### VTR (variable tick rate) + +- **VTR player counts are saved.** The per-map and world viewer counts affect the simulation but weren't saved, so a + reload reset them to 0 (on standalone, only on the reloading client); the world count is clamped at 0. `7b666d7` +- **The host's view is counted and old counts are reset.** The host's game starts through a save and reload, which + doesn't report a view, so the host's map ran at the no-viewer rate (projectiles moving in 15-tick steps) until the + host switched maps. The host now announces its view, the arbiter no longer counts as a viewer, and counts saved by + an earlier session are zeroed when hosting or starting a standalone server. `6ad2ef2` +- **The viewed map is followed every frame.** Counts changed only through the `CurrentMap` setter and world map + toggles, so a multifaction joiner starting on the world map was never counted and its first switch took a viewer + away from the host's map. The client now compares what it looks at with what it last reported once per frame. + Supersedes #956 (@MhaWay), whose out-of-band count packets would bring back #862. `65bfd9d` + +### Async time + +- **An accepted async world quest ticks once per tick.** Generation and acceptance both cached the quest, so its parts + ticked twice per tick until the next reload, and removal skipped the world list because of an operator precedence + bug. Supersedes the async quest part of #541 (@Tick-git); #539 itself is already fixed on `dev`. `1095720` +- **Animal hunger alerts use the animal's own time.** `Need_Food.TicksStarving` compares the current time with a + timestamp from the pawn's clock, but alerts run at the viewed map's time, so animals on other maps showed as + starving (or didn't) depending on the view. Reimplements #983 (@still222). `123ee05` +- **TimestampFixer handles destroyed pawns and more fields.** It ran a debug save also on pawns killed on a map and + passed to the world, logging a warning on each death even without async time; it now skips pawns whose clock doesn't + change and shifts destroyed pawns' fields directly, including all their abilities (`c181b79`). It also shifts the + starvation start, ability and royal permit cooldowns, the daily skill XP reset and the decree interval. Reimplements + #979 (@M-r-A) without its save pass; part of #964 and #422. `89dfd7a`, `c181b79` + +## 8. Storyteller and quests + +- **Story watcher adaptation grows once per game tick.** The world tick ticks the story watcher and every map tick + ticked it again, so with N maps adaptation (the raid points factor) grew 1+N times as fast as in singleplayer. Map + ticks now only resolve the colonists downed during them. Part of #659 and #884. `5fb7c98` +- **Quest threats fire in singleplayer.** The storyteller prefix set its flag in singleplayer too, so the postfix + dropped every `QuestPart_ThreatsGenerator` and quest threats never fired with the mod loaded. `17c7354` +- **Multifaction quest threats fire from one storyteller per map.** Every player faction's storyteller walks the + shared quest list on each map, and threat counts are seeded by world, target and salt, so all of them fired the same + quest raids at once; other quest incident makers fired 1+N times. Quest threats now run for one faction per map (the + owner, else the lowest-id player faction with pawns there), and other incident makers run in the world tick only; a + map owned by a faction without world data falls back to the pawn-based choice. Part of #659 and #884. `46c06a7`, + `99d1da2` +- **Multifaction storytellers stay off other players' maps.** Faction A's incidents (e.g. `Map_Misc` while its + colonists visit) could fire on faction B's home; each storyteller now targets its own maps (including pocket maps) + and unowned maps where it has pawns. From #961; part of #659 and #884. `0b4fde8`, `524e05e` +- **Quest map picks stay on the generating faction's maps.** Quests generated for a player faction could choose + another player's map (a gravship landing site counts as a home of every faction), so refugees arrived at a friend's + colony. Port of #961's owner filter for `QuestNode_GetMap`, `QuestGen_Get.GetMap`, work sites and reliquary + pilgrims. Part of #659. `46638cc` +- **Nested incidents keep the right map context.** A nested `IncidentWorker.TryExecute` during the world tick reset + `tickingMap` and popped the outer incident's context, and a nested incident for another map ran in the outer map's + context (time, RNG, faction). The context is now left and re-entered for another map and kept for the same map. From + #961. `fb00a6e`, `5373330` +- Quest name seeding is under RNG isolation (`b815de5`), the async world quest double tick under async time + (`1095720`). + +## 9. Odyssey: gravship + +- **Takeoff and landing ends run in a synced command.** Vanilla calls `TakeoffEnded`/`LandingEnded` from + `WorldComponentUpdate`, i.e. per frame in the interface at a different moment on each client, so synced methods + inside were sent as commands by every client, unique ids were local and `Faction.OfPlayer` was the local faction. + Each client now sends a command when its cutscene ends, the first one executed runs vanilla's method everywhere, and + the gravship faction is force-pushed (`8b2268a`). Fixes #640 and #638 (with #893 on `dev`); part of #789 and #687. + `f8d9f49`, `8b2268a` +- **Cutscene ends survive reloads.** The pending flag lived in `MultiplayerGame`, which is recreated on every load + (`d900efe`), and a join point during a cutscene lost the gravship, after which the ends threw and the game stayed in + the cutscene for good (`2bca475`). The flag is saved with the controller, cutscenes without a gravship end cleanly, + late aborts are ignored, and stale freeze state is cleared (`273b61b`). Part of the #643 fix. `d900efe`, `2bca475`, + `273b61b` +- **The landing pause is restored after loading.** The `GravshipTravelSession` pausing the landing map isn't saved, so + after a join point, or for a joiner, the map ran while players chose where to land; it is recreated from the saved + landing marker. Fixes #643 (with `2bca475`, `273b61b`). `3d8a29a` +- **The landing marker and rotation are the same on every client.** The move designator's sync worker read a field + that only the landing UI creates, so the command threw on other clients; the confirming client cleared its landing + marker before its command executed; and other players' designator previews could rotate the marker locally. The lazy + accessor is used, the marker stays until landing, and the confirming player's rotation is sent with the landing. + Fixes #791. `3682aea`, `e47be47`, `218c7e5` +- **The landing and departing gravship are placed in their map's context.** `PlaceGravship` and the takeoff's removal + run in long events queued from cutscene callbacks, outside any tick or command, so they used the interface's RNG and + the world's clock. Both now run in their map's context (time, RNG, faction), which also replaces the glow grid + workaround from #635. Fixes #646. `bdb2515` (landing), `2b952b9` (takeoff) +- **Every tickable gets the speed set after landing without async time.** Landing set Normal speed only on the landing + map, while without async time all tickables share one speed. `74a851a` +- **Camera shakes are timed with the MP timer.** Under async time the extended shake request came from a map's clock + and was checked against the world's, so the camera shook until the world caught up. UI only. Fixes #696. `44a946a` +- **The grav engine cache is out of the interface's reach.** `GravshipUtility` caches each map's grav engine for the + current tick, and the interface (at the viewed map's time) could fill it before a command changed the map; in + multifaction any player's engine made a map a home of every player faction. Ticks, commands and sim long events + start with an empty cache, and other players' engines are skipped. Different approach than #906 (@Sakura-TA), which + doesn't build on 1.6.4850. `64e3aef` + +## 10. Anomaly, Ideology and Biotech + +### Anomaly + +- **Unnatural corpse teleports don't depend on the camera.** `UnnaturalCorpse.IsOutsideView` and the container escape + check read `Find.CurrentMap` and the local camera rect, so corpses teleported or escaped on some clients only. In MP + the corpse counts as never seen by a camera, keeping the reservation check for every player faction. From #961. + Fixes #959. `6605f20`, `25558d4` +- **Psychic ritual offerings (Horax) work.** Vanilla moved the offering while enumerating the invoker's inventory, so + moving a whole stack threw and the ritual never completed; the inventory is snapshotted first, in MP only and up to + the required count. From #961. Fixes #855 (the rest of it was fixed on `dev` by #739). `c6c9fa6`, `64c25c4` + +### Ideology + +- **The ritual role group cache is refilled before a synced `TryAssignAnyRole`.** It iterates a static cache that is + filled only when the ritual dialog is drawn, so clients without the dialog assigned roles differently. Core of #995 + (@jangoergens), without its Roslyn test fixtures. `e05a679` +- **Rituals start with the starting player's role assignments.** Without Prepatcher the role selection widgets aren't + synced, so each client could hold its own assignments; the start command now carries the widget-changed assignments + of the starting player (no-op with Prepatcher). Part of #687. `6472678`, `425c18d` +- **Only the clicked ritual gizmo is processed.** Grouped `Command_Ritual` gizmos also processed another gizmo's + click, and in MP each opens a session, so the dialog opened for the wrong ideoligion. Fixes #506. `0d73a38` +- **A warning when Prepatcher didn't patch the ritual role widget.** Nothing told players that ritual role selection + isn't synced without Prepatcher; the patched method is checked, and a warning shows in the log, the host window, the + server browser and the ritual dialogs. Fixes #501. `ab76273` +- **`FixIdeoAfterCopy` handles non-fluid ideoligions and runs in ticks.** `Ideo.CopyTo` leaves `development` null for + a non-fluid target, so the postfix threw; precept id fixing also applies to copies made by simulation code. From + #961. `244f8ef` +- Bill product style: see sync methods (`5005f8f`). + +### Biotech + +- Growth moment sessions: see section 5 (`de1999a`, `5452502`, `a1e0059`, `f3b6ca5`). Gene sync by `loadID`: + `8bc60df`. Custom xenotypes on faction creation: `e2a589f`. + +## 11. Diagnostics + +### Desync tracer + +- **The deferred stack tracer fails safely.** It runs as a postfix of `Rand.Value`/`Rand.Int`, so any exception in it + aborted game code after the RNG had advanced, on that client only ("Unknown function header", a trace buffer + overflow from uncounted inlined frames, the LMF walk NRE from #944). The walk now ends on unknown frames, checks and + masks LMF pointers, has a step cap and never throws; a failed walk marks the window so its trace hashes aren't + compared. Covers the tracer overflow part of #988 (@Manaether). `93d6f86`, `9fb2dcd` +- **The trace hash depth doesn't depend on the tick loop call site.** Commands ran either from `TickPatch.Prefix` or + one frame deeper from `DoUpdate`, depending on frame timing, so identical clients could report "Trace hashes don't + match". Both paths now have the same depth, and the dev tick-once hotkey is limited to replays. Fixes #994. + `059510e` +- **Trace collection stops after the stack walk throws.** On some setups (macOS through Rosetta) the walk threw on + every `Rand` call and the caught exceptions stalled the game; tracing is turned off for the process after the first + one, and the affected window is marked unreliable first, so this can't cause a false trace desync. Likely fixes #856 (needs confirmation on macOS). + `0d00fcc`, `6c5bf26` +- `9fb2dcd` also keeps an exception from the main tabs update from skipping the rest of `ExecuteCmd`'s `finally` block + (RNG and faction restore). + +### Desync reports and sync opinions + +- **Pending sync opinions are kept across reloads.** Every reload creates a new `SyncCoordinator`, dropping opinions + still waiting for their counterpart, so a desync right before a join point went unnoticed; they are now carried + over, and skipped opinions are logged. `3f575f8` +- **Desync traces are sent fragmented.** On standalone the trace reply comes from a remote client and was sent as one + packet limited to 64 KiB; above that `Send` threw in the reference's handler and the server's forward disconnected + the sender. `66ee8bc` +- **Desync reports keep the end of the log and the join data diff.** Reports kept the first 10,000 log lines, losing + the lines around the desync in long sessions; they now keep 2,000 from the start and the newest 8,000, record what + didn't match when the player connected anyway, and say which part of a replay's desync data couldn't be read. From + #961. `77fb479` +- **Skipped simulation errors are counted and reported.** The catch blocks that keep one subsystem's exception from + skipping a tick only logged; the count and the first error are now added to desync reports and the player is told + once, and the count resets when world data arrives after a rejoin (`60b457b`). From #961. `a21fb66`, `60b457b` + +### Surfacing failures + +- **Loading errors are confirmed before hosting or joining.** Failed patches and sync registrations only showed a + marker on the main menu button; they are now collected through `Multiplayer.LoadingError` and listed in a + confirmation dialog before opening the server browser, hosting or accepting a Steam invite. From #961. `d4349f2` +- **Disconnects caused by a server state exception are explained.** The client only showed "Disconnected", with no + hint that the error is in the host's log. Likely fixes #797 together with the bigger init/join data limits of `7c88a31` (no host log in the report). `db58b65` + +## 12. Usability and robustness + +- **The game can be saved after a disconnect.** The disconnected window only allowed quitting, losing everything since + the host's last save. The last join point snapshot (which already gets every received command appended) is kept + while disconnecting, and a Save button writes it as a save that simulates to the tick this client reached, without + changing game state. Fixes #333 and #937. `201829a` +- **Selecting drafted pawns doesn't lag.** The interface melee verb patch built `VerbEntry` lists (O(n²) in the number + of verbs) several times per frame for every selected drafted pawn; it now reuses the current melee verb when usable, + otherwise picks in O(n) as vanilla would, and caches the answer per frame, still without `Rand`. Fixes #308. + `f59d6b7`, `03d89bd` +- **The launch destination targeter stops when the launchable is gone.** While one player picked a destination, + another could launch or cancel the group, after which the targeter threw every frame and couldn't be closed. UI + only. Fixes #365. `b9ad395` +- **Caravan meeting and demand dialogs pause the game.** MP disables `WindowsForcePause` because windows differ + between clients, so time kept running while these dialogs waited for an answer; the incidents now pause from the + simulation like a pausing letter, unless the host set pause on letter to never. Fixes #512. `016815f` +- **The connecting window reads the download speed once.** The speed was recomputed on every read, so the value + checked against 0 wasn't the one divided by (`DivideByZeroException`). From #961. `c3c3323` +- **Null guards in three patches**: the world time speed query, the forbiddables despawn prefix and the teaching work + giver prefix threw on null maps or jobs. From #974 (@Spagles). `1c20d17` + +## Compatibility and behavior changes + +### Protocol, replays and saved data + +- `MpVersion.Protocol` goes from 56 to 57. Within 57 the branch changes `Server_WorldData` (one command list), + `ServerJoinDataPacket` (missing defs, game language), `ClientInitDataPacket` (language), the `CreateJoinPoint` + payload (uploader), join point uploads and save requests (tick), adds `Server_SyncReference`, sends large commands + fragmented and changes the sync data of unheld pawns, genes and the `WorldSelected` context. Other open PRs also + bump the protocol with different formats (#956 to 57, #961 to 58), so the number needs coordinating. +- Replays recorded before this branch are not compatible (sync handler ids, command formats and tick order changed). + The old per-map command layout of replays is still read. +- Standalone servers keep their command log in `Saved/cmds.dat` with its protocol in `Saved/cmds_protocol.txt`. A log + written by an older server is discarded on start with an error; the saved world, maps and session still load, and + only the progress since that save is lost. +- The join compatibility check now also compares Multiplayer's own assemblies, def types missing on the client and the + game language, and ignores line endings in synced configs and XML files. + +### Server and join behavior + +- On standalone servers every client reloads at a join point (as in hosted mode). Only the sync reference uploads; + join points are aborted after 60 s without progress; forced join points are limited to one per 10 s; minute-based + autosaves are issued by the server; join point requests from world travel are ignored. +- Standalone servers now compare sync opinions (from the sync reference) and report desyncs. +- Clients can't send server-only commands; only the host can move other players between factions. +- Handshake timeouts: 2 minutes for automatic replies, 5 minutes for the init data upload, 15 minutes for replies that + wait on user input. Players in game who don't answer keep-alives for 10 minutes are dropped. +- Commands larger than a single packet are sent fragmented, up to 1 MiB; selections over 200 things are split. +- A joiner whose world data would exceed 32 MiB is disconnected with a message giving the sizes. +- Steam: P2P sessions are closed on disconnect; a rejected player's join requests are ignored for 30 s. + +### Gameplay-visible behavior (deterministic on all clients) + +- Storyteller adaptation grows once per game tick (it grew 1+N times with N maps). +- Multifaction storytellers target only their own maps and unowned maps where they have pawns; quest threats fire once + per map; other quest incident makers fire once. +- Quest threats fire again in singleplayer with the mod loaded. +- Without async time the world and maps tick in lockstep (no 1–3 tick drift). At speeds above 1x the order is W M M W + M M instead of W W M M M M. +- Code in ticks sees `TickManager.Paused` as false; commands see their own tickable's paused state. +- The arbiter doesn't count as a VTR viewer; the host's map is counted. +- Caravan meeting and demand dialogs pause the game (unless pause on letter is set to never). +- Rituals start with the starting player's role assignments (matters without Prepatcher). +- Zone and plan cells are shuffled again in singleplayer; MP keeps not shuffling them. +- Multifaction: every client keeps the full letter stack and the UI filters it; season messages are shown only to the + faction's players and are not archived. + +### UI and translations + +- New UI: a confirmation dialog before hosting or joining with loading errors, a Prepatcher-missing warning (host + window, server browser, ritual dialogs), a Save button in the disconnected window, a language row in the join data + window, an explanation for server state exception disconnects, and messages when an action is too big to send or a + simulation error was skipped. +- New translation keys, with English fallbacks in code, to add to Multiplayer-Locale: `MpMismatchLanguage`, + `MpPrepatcherMissing`, `MpPrepatcherMissingDesc`, `MpSaveGameAfterDisconnect`, `MpServerStateException`, + `MpServerStateExceptionDesc`. +- Some new texts are English only, without keys: the loading errors confirmation, "this action is too big to send to + the server", the skipped simulation error notice and the "world data too big" disconnect reason. + +### Code-level changes + +- Added: `MpFinalizer`, `LocalOnlyScope`, `SimLongEventContext`, `Multiplayer.LoadingError`, + `MultiplayerServer.CheckServerThread()`. +- Removed: the `consistentCommandOrder` tweak value, the `PlaceGravship` sync method registration (it never synced + inside the long event), `PushFactionOutsideContext`/`PopToLocalFaction`, the `TileFactionContext` fallback. Clients + no longer send standalone snapshot packets. + +## Known limitations / not addressed + +### Limitations of the fixes + +- Without Prepatcher the ritual role widgets themselves are still not synced; only the assignments at ritual start are + (#687 is only partly fixed). +- In multifaction only the first player faction that follows an ideoligion gets its festival and role letters + (`bec7f23`). +- Map ids in client commands are checked by scope only, not against existing maps, because the server doesn't know + maps created since the last join point. +- Gravship takeoff dialogs, the destination picker and landing cutscenes can't be restored after a reload; their + sessions stay unsaved. +- On setups where the stack walk throws (e.g. macOS through Rosetta) desync traces are off for the rest of the + process, so reports from that client carry no traces. +- Custom xenotypes of a new multifaction faction's starting pawns aren't added to the custom xenotype database + (`e2a589f`). +- A different game language is only reported (#264); translated text still differs between such clients. +- #618 is addressed only for the fire/sound path; #621, #659, #884, #964 and #422 are partly addressed. + +### Parts of open PRs deliberately not taken + +- #958: the same-IP replacement for direct connections (a player behind the same NAT could kick another). +- #961: its async clock model and VTR view table (they conflict with the sim long event context and the VTR fix here), + and its `DefeatAllEnemiesQuestComp`/`TimedForcedExit` parts (nothing starts that quest and no def uses the obsolete + comp in 1.6). +- #956: out-of-band player count packets (would bring back #862). +- #995: the Roslyn test fixtures. + +### Open issues not addressed + +- #546, #581: quests in multifaction need the quest owner's faction context (a large change). +- #669 (scenarios in multifaction), #639 (one global gravship controller in multifaction), #799 (permanent pawn + opinions), #898 (pens with same-tile settlements), #627 (ShuttleCrash_Rescue bodyguards leaving). +- #992 (Combat Extended bullet collision desync). +- #989, #990, #457 (MonoMod allocator in Prepatcher's Harmony). +- #754 (ARM float differences). + +## Testing + +- The solution builds and 284 unit tests pass. New test files cover the command log, join points, the joining state, + fragmented packets, large commands, reader bounds, reconnects, server stop, the sync reference, standalone + persistence, world data size, selection packets, line ending normalization and log trimming. +- Each stage was reviewed separately and the findings were fixed before it was merged into the branch. +- In-game tests used several real RimWorld 1.6.4850 instances with all DLCs on Linux (headless, Xvfb, software GL). A + harness mod drives the game through normal sync paths (draft/undraft, mining, stockpiles, work priorities, forbid, + plant cutting, raids, fires, incidents) and logs the sync state every 5 s. +- Scenarios: hosted with an arbiter and a joiner joining mid-game; hosted without an arbiter; multifaction with the + joiner creating its own faction and map; async time; standalone server (two players, server-driven autosave join + point, restart from the persisted state, start from an older save). +- Results on `dev`: a desync in 1 of 4 valid runs ("Random state from commands doesn't match" during the joiner's + catch-up), and the arbiter game froze for everyone as soon as someone joined. +- Results on the branch: no desyncs in about 30 sessions, the last ones a full run of every scenario on the complete branch, identical VTR viewer counts on all instances, and identical + world and map ticks without async time. +- Not covered in game, only by code review: Steam, Windows and macOS clients, other mods, gravship flights, rituals, + trading and caravans, large mod lists.