From 261c5984c4fc96a72666f7102c6db69e4e347598 Mon Sep 17 00:00:00 2001 From: Manaether Date: Sat, 12 Sep 2026 17:43:10 +0200 Subject: [PATCH 1/4] Fix UI ID drift, shuttle departure loop, and stack tracing overflow - UniqueIDsManagerGuard: Snapshot and restore UniqueIDsManager counters across UIRootOnGUI via compiled expression delegates to prevent off-tick UI passes from desyncing simulation IDs - ShuttlePatches: Null-coalesce CompShuttle required collections and guard SendLaunchedSignals to prevent infinite takeoff NRE loop - DeferredStackTracingImpl: Add bounds check in TraceImpl to prevent IndexOutOfRangeException on deep or inlined stack traces - DeferredStackTracing: Guard Postfix with try-catch to prevent tracing exceptions from breaking Rand getters - SourceGen: Align Microsoft.CodeAnalysis version with SDK compiler --- Source/Client/Desyncs/DeferredStackTracing.cs | 26 ++- Source/Client/Patches/ShuttlePatches.cs | 42 +++++ .../Client/Patches/UniqueIDsManagerGuard.cs | 155 ++++++++++++++++++ Source/Common/DeferredStackTracingImpl.cs | 3 + Source/SourceGen/SourceGen.csproj | 4 +- 5 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 Source/Client/Patches/ShuttlePatches.cs create mode 100644 Source/Client/Patches/UniqueIDsManagerGuard.cs diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index db53da2df..dc7befe1f 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; @@ -34,15 +35,22 @@ 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); - - acc++; + try + { + 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); + + acc++; + } + catch (Exception ex) + { + Log.WarningOnce($"[Multiplayer] Exception during deferred stack tracing: {ex}", 71948201); + } } public static bool ShouldAddStackTraceForDesyncLog() diff --git a/Source/Client/Patches/ShuttlePatches.cs b/Source/Client/Patches/ShuttlePatches.cs new file mode 100644 index 000000000..cdc2557e5 --- /dev/null +++ b/Source/Client/Patches/ShuttlePatches.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using HarmonyLib; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Patches +{ + /// + /// Prevents null reference exceptions during shuttle departure and post-loading in Multiplayer. + /// If requiredPawns or requiredItems became null during deserialization or despawn, + /// SendLaunchedSignals throws an NRE in ShipJob_FlyAway.TryStart, causing the transport ship + /// to get stuck in an endless exception loop every tick. + /// + [HarmonyPatch] + static class ShuttlePatches + { + [HarmonyPrepare] + static bool Prepare() => AccessTools.TypeByName("RimWorld.CompShuttle") != null; + + [HarmonyPostfix] + [HarmonyPatch(typeof(CompShuttle), nameof(CompShuttle.PostExposeData))] + static void PostExposeData_Postfix(CompShuttle __instance) + { + __instance.requiredPawns ??= new List(); + __instance.requiredItems ??= new List(); + __instance.pawnsToIgnoreIfDownedOfNotOnTheMap ??= new List(); + } + + [HarmonyFinalizer] + [HarmonyPatch(typeof(CompShuttle), nameof(CompShuttle.SendLaunchedSignals))] + static Exception SendLaunchedSignals_Finalizer(Exception __exception) + { + if (__exception != null) + { + Log.WarningOnce($"[Multiplayer] Suppressed exception in CompShuttle.SendLaunchedSignals: {__exception}", 84920194); + return null; + } + return null; + } + } +} diff --git a/Source/Client/Patches/UniqueIDsManagerGuard.cs b/Source/Client/Patches/UniqueIDsManagerGuard.cs new file mode 100644 index 000000000..2176bde2e --- /dev/null +++ b/Source/Client/Patches/UniqueIDsManagerGuard.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using HarmonyLib; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Patches +{ + /// + /// Guards counters against ID drift caused by UI rendering passes. + /// + /// Problem: + /// Various UI mods (e.g., colonist bars, apparel renderers, inspection panels) and vanilla interface panels + /// (quest previews, trade dialogs) instantiate temporary s, jobs, or previews during UI passes + /// (). Instantiating these objects off-tick advances + /// counters on the local client only. When a synchronized simulation tick or player command later executes, + /// the host and peers have divergent ID counters, causing instant multiplayer desyncs. + /// + /// + /// Solution: + /// Snapshot all next* integer fields in at the beginning of , + /// allow UI elements to receive normal valid positive IDs during rendering, and roll back the counters + /// via a finalizer when the GUI pass completes. + /// + /// + /// Performance: + /// All field accessors are precompiled into JIT expression delegates at static initialization, guaranteeing + /// zero heap allocations (0 boxing) during every frame's GUI passes. + /// + /// + [HarmonyPatch(typeof(UIRoot_Play), nameof(UIRoot_Play.UIRootOnGUI))] + public static class UniqueIDsManagerGuard + { + private delegate int IdGetter(UniqueIDsManager manager); + private delegate void IdSetter(UniqueIDsManager manager, int value); + + private static readonly IdGetter[] getters; + private static readonly IdSetter[] setters; + private static readonly int[] snapshot; + private static bool hasSnapshot; + private static int guiDepth; + + static UniqueIDsManagerGuard() + { + try + { + var getterList = new List(); + var setterList = new List(); + + var paramManager = Expression.Parameter(typeof(UniqueIDsManager), "manager"); + var paramValue = Expression.Parameter(typeof(int), "value"); + + foreach (var field in typeof(UniqueIDsManager).GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)) + { + if (field.FieldType == typeof(int) && field.Name.StartsWith("next", StringComparison.OrdinalIgnoreCase)) + { + var fieldExpr = Expression.Field(paramManager, field); + var getter = Expression.Lambda(fieldExpr, paramManager).Compile(); + + var assignExpr = Expression.Assign(fieldExpr, paramValue); + var setter = Expression.Lambda(assignExpr, paramManager, paramValue).Compile(); + + getterList.Add(getter); + setterList.Add(setter); + } + } + + getters = getterList.ToArray(); + setters = setterList.ToArray(); + snapshot = new int[getters.Length]; + } + catch (Exception ex) + { + Log.Warning($"[Multiplayer] UniqueIDsManagerGuard: Failed to compile ID accessor delegates: {ex}"); + getters = new IdGetter[0]; + setters = new IdSetter[0]; + snapshot = new int[0]; + } + } + + private static bool ShouldGuard() + { + return Multiplayer.Client != null + && Current.ProgramState == ProgramState.Playing + && !Multiplayer.Ticking + && !Multiplayer.ExecutingCmds; + } + + private static void TakeSnapshot(UniqueIDsManager manager) + { + for (int i = 0; i < getters.Length; i++) + { + snapshot[i] = getters[i](manager); + } + hasSnapshot = true; + } + + private static void RestoreSnapshot(UniqueIDsManager manager) + { + if (!hasSnapshot) + return; + + for (int i = 0; i < setters.Length; i++) + { + setters[i](manager, snapshot[i]); + } + hasSnapshot = false; + } + + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + private static void Prefix() + { + if (!ShouldGuard()) + return; + + var manager = Find.UniqueIDsManager; + if (manager == null) + return; + + if (guiDepth++ == 0) + { + TakeSnapshot(manager); + } + } + + [HarmonyFinalizer] + [HarmonyPriority(Priority.Last)] + private static Exception Finalizer(Exception __exception) + { + if (guiDepth <= 0) + return __exception; + + if (--guiDepth == 0) + { + if (ShouldGuard()) + { + var manager = Find.UniqueIDsManager; + if (manager != null) + { + RestoreSnapshot(manager); + } + } + else + { + hasSnapshot = false; + } + } + + return __exception; + } + } +} diff --git a/Source/Common/DeferredStackTracingImpl.cs b/Source/Common/DeferredStackTracingImpl.cs index 92c3a71a0..95e170d7d 100644 --- a/Source/Common/DeferredStackTracingImpl.cs +++ b/Source/Common/DeferredStackTracingImpl.cs @@ -153,6 +153,9 @@ public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames if (depth >= skipFrames) { + if (index >= traceIn.Length) + break; + traceIn[index] = ret; // info.nameHash == 0 marks methods to skip diff --git a/Source/SourceGen/SourceGen.csproj b/Source/SourceGen/SourceGen.csproj index e2387a071..ba0889326 100644 --- a/Source/SourceGen/SourceGen.csproj +++ b/Source/SourceGen/SourceGen.csproj @@ -10,8 +10,8 @@ - - + + From f4d821c4237a2ab2671d8b62bfc71b9ecb280477 Mon Sep 17 00:00:00 2001 From: Manaether Date: Sat, 12 Sep 2026 21:24:50 +0200 Subject: [PATCH 2/4] Remove UniqueIDsManagerGuard: redundant with core UniqueIdsPatch and interferes with map generation --- .../Client/Patches/UniqueIDsManagerGuard.cs | 155 ------------------ 1 file changed, 155 deletions(-) delete mode 100644 Source/Client/Patches/UniqueIDsManagerGuard.cs diff --git a/Source/Client/Patches/UniqueIDsManagerGuard.cs b/Source/Client/Patches/UniqueIDsManagerGuard.cs deleted file mode 100644 index 2176bde2e..000000000 --- a/Source/Client/Patches/UniqueIDsManagerGuard.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq.Expressions; -using System.Reflection; -using HarmonyLib; -using RimWorld; -using Verse; - -namespace Multiplayer.Client.Patches -{ - /// - /// Guards counters against ID drift caused by UI rendering passes. - /// - /// Problem: - /// Various UI mods (e.g., colonist bars, apparel renderers, inspection panels) and vanilla interface panels - /// (quest previews, trade dialogs) instantiate temporary s, jobs, or previews during UI passes - /// (). Instantiating these objects off-tick advances - /// counters on the local client only. When a synchronized simulation tick or player command later executes, - /// the host and peers have divergent ID counters, causing instant multiplayer desyncs. - /// - /// - /// Solution: - /// Snapshot all next* integer fields in at the beginning of , - /// allow UI elements to receive normal valid positive IDs during rendering, and roll back the counters - /// via a finalizer when the GUI pass completes. - /// - /// - /// Performance: - /// All field accessors are precompiled into JIT expression delegates at static initialization, guaranteeing - /// zero heap allocations (0 boxing) during every frame's GUI passes. - /// - /// - [HarmonyPatch(typeof(UIRoot_Play), nameof(UIRoot_Play.UIRootOnGUI))] - public static class UniqueIDsManagerGuard - { - private delegate int IdGetter(UniqueIDsManager manager); - private delegate void IdSetter(UniqueIDsManager manager, int value); - - private static readonly IdGetter[] getters; - private static readonly IdSetter[] setters; - private static readonly int[] snapshot; - private static bool hasSnapshot; - private static int guiDepth; - - static UniqueIDsManagerGuard() - { - try - { - var getterList = new List(); - var setterList = new List(); - - var paramManager = Expression.Parameter(typeof(UniqueIDsManager), "manager"); - var paramValue = Expression.Parameter(typeof(int), "value"); - - foreach (var field in typeof(UniqueIDsManager).GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)) - { - if (field.FieldType == typeof(int) && field.Name.StartsWith("next", StringComparison.OrdinalIgnoreCase)) - { - var fieldExpr = Expression.Field(paramManager, field); - var getter = Expression.Lambda(fieldExpr, paramManager).Compile(); - - var assignExpr = Expression.Assign(fieldExpr, paramValue); - var setter = Expression.Lambda(assignExpr, paramManager, paramValue).Compile(); - - getterList.Add(getter); - setterList.Add(setter); - } - } - - getters = getterList.ToArray(); - setters = setterList.ToArray(); - snapshot = new int[getters.Length]; - } - catch (Exception ex) - { - Log.Warning($"[Multiplayer] UniqueIDsManagerGuard: Failed to compile ID accessor delegates: {ex}"); - getters = new IdGetter[0]; - setters = new IdSetter[0]; - snapshot = new int[0]; - } - } - - private static bool ShouldGuard() - { - return Multiplayer.Client != null - && Current.ProgramState == ProgramState.Playing - && !Multiplayer.Ticking - && !Multiplayer.ExecutingCmds; - } - - private static void TakeSnapshot(UniqueIDsManager manager) - { - for (int i = 0; i < getters.Length; i++) - { - snapshot[i] = getters[i](manager); - } - hasSnapshot = true; - } - - private static void RestoreSnapshot(UniqueIDsManager manager) - { - if (!hasSnapshot) - return; - - for (int i = 0; i < setters.Length; i++) - { - setters[i](manager, snapshot[i]); - } - hasSnapshot = false; - } - - [HarmonyPrefix] - [HarmonyPriority(Priority.First)] - private static void Prefix() - { - if (!ShouldGuard()) - return; - - var manager = Find.UniqueIDsManager; - if (manager == null) - return; - - if (guiDepth++ == 0) - { - TakeSnapshot(manager); - } - } - - [HarmonyFinalizer] - [HarmonyPriority(Priority.Last)] - private static Exception Finalizer(Exception __exception) - { - if (guiDepth <= 0) - return __exception; - - if (--guiDepth == 0) - { - if (ShouldGuard()) - { - var manager = Find.UniqueIDsManager; - if (manager != null) - { - RestoreSnapshot(manager); - } - } - else - { - hasSnapshot = false; - } - } - - return __exception; - } - } -} From 8e7cf6ddc6b0b76c29adae619db797b5fd7aa0f5 Mon Sep 17 00:00:00 2001 From: Manaether Date: Sat, 12 Sep 2026 21:32:01 +0200 Subject: [PATCH 3/4] Fix wanderer quest desync: snapshot UniqueIDsManager across UIRootOnGUI with LongEventHandler & Scribe guards --- .../Client/Patches/UniqueIDsManagerGuard.cs | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 Source/Client/Patches/UniqueIDsManagerGuard.cs diff --git a/Source/Client/Patches/UniqueIDsManagerGuard.cs b/Source/Client/Patches/UniqueIDsManagerGuard.cs new file mode 100644 index 000000000..b15828843 --- /dev/null +++ b/Source/Client/Patches/UniqueIDsManagerGuard.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using HarmonyLib; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Patches +{ + /// + /// Guards counters against ID drift caused by UI rendering passes. + /// + /// Problem: + /// Various UI mods (colonist bars, apparel/equipment renderers, inspection panels) and vanilla interface + /// dialogs (quest previews, trade dialogs) instantiate temporary s, pawns, or jobs during UI + /// rendering (). Off-tick instantiation advances + /// counters on the local client only (e.g. wanderer join quest pawn ID divergence). + /// Furthermore, assigning negative IDs during UI passes breaks mods and vanilla code that rely on + /// non-negative IDs for indexing graphic variant arrays (thingIDNumber % array.Length). + /// + /// + /// Solution: + /// Snapshot all next* integer fields in at the start of , + /// allow UI elements to receive normal valid positive IDs during rendering, and roll back the counters + /// via a finalizer when the GUI pass completes. + /// + /// + /// Safety: + /// The guard is strictly bypassed during map generation, save/load passes, and long events + /// (LongEventHandler.currentEvent != null, LongEventHandler.AnyEventNowOrWaiting, + /// Scribe.mode != LoadSaveMode.Inactive) to prevent interfering with actual world/map generation. + /// Precompiled JIT expression delegates guarantee zero heap allocations per frame. + /// + /// + [HarmonyPatch(typeof(UIRoot_Play), nameof(UIRoot_Play.UIRootOnGUI))] + public static class UniqueIDsManagerGuard + { + private delegate int IdGetter(UniqueIDsManager manager); + private delegate void IdSetter(UniqueIDsManager manager, int value); + + private static readonly IdGetter[] getters; + private static readonly IdSetter[] setters; + private static readonly int[] snapshot; + private static bool hasSnapshot; + private static int guiDepth; + + static UniqueIDsManagerGuard() + { + try + { + var getterList = new List(); + var setterList = new List(); + + var paramManager = Expression.Parameter(typeof(UniqueIDsManager), "manager"); + var paramValue = Expression.Parameter(typeof(int), "value"); + + foreach (var field in typeof(UniqueIDsManager).GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)) + { + if (field.FieldType == typeof(int) && field.Name.StartsWith("next", StringComparison.OrdinalIgnoreCase)) + { + var fieldExpr = Expression.Field(paramManager, field); + var getter = Expression.Lambda(fieldExpr, paramManager).Compile(); + + var assignExpr = Expression.Assign(fieldExpr, paramValue); + var setter = Expression.Lambda(assignExpr, paramManager, paramValue).Compile(); + + getterList.Add(getter); + setterList.Add(setter); + } + } + + getters = getterList.ToArray(); + setters = setterList.ToArray(); + snapshot = new int[getters.Length]; + } + catch (Exception ex) + { + Log.Warning($"[Multiplayer] UniqueIDsManagerGuard: Failed to compile ID accessor delegates: {ex}"); + getters = new IdGetter[0]; + setters = new IdSetter[0]; + snapshot = new int[0]; + } + } + + private static bool ShouldGuard() + { + return Multiplayer.Client != null + && Current.ProgramState == ProgramState.Playing + && !Multiplayer.Ticking + && !Multiplayer.ExecutingCmds + && !Multiplayer.reloading + && LongEventHandler.currentEvent == null + && !LongEventHandler.AnyEventNowOrWaiting + && Scribe.mode == LoadSaveMode.Inactive; + } + + private static void TakeSnapshot(UniqueIDsManager manager) + { + for (int i = 0; i < getters.Length; i++) + { + snapshot[i] = getters[i](manager); + } + hasSnapshot = true; + } + + private static void RestoreSnapshot(UniqueIDsManager manager) + { + if (!hasSnapshot) + return; + + for (int i = 0; i < setters.Length; i++) + { + setters[i](manager, snapshot[i]); + } + hasSnapshot = false; + } + + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + private static void Prefix() + { + if (!ShouldGuard()) + return; + + var manager = Find.UniqueIDsManager; + if (manager == null) + return; + + if (guiDepth++ == 0) + { + TakeSnapshot(manager); + } + } + + [HarmonyFinalizer] + [HarmonyPriority(Priority.Last)] + private static Exception Finalizer(Exception __exception) + { + if (guiDepth <= 0) + return __exception; + + if (--guiDepth == 0) + { + if (ShouldGuard()) + { + var manager = Find.UniqueIDsManager; + if (manager != null) + { + RestoreSnapshot(manager); + } + } + else + { + hasSnapshot = false; + } + } + + return __exception; + } + } +} From 363ca3136a6b46f62ac7005df42100350ef0e59c Mon Sep 17 00:00:00 2001 From: Manaether Date: Sun, 13 Sep 2026 20:50:56 +0200 Subject: [PATCH 4/4] Revert SourceGen.csproj package versions to match upstream origin/dev --- Source/SourceGen/SourceGen.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/SourceGen/SourceGen.csproj b/Source/SourceGen/SourceGen.csproj index ba0889326..e2387a071 100644 --- a/Source/SourceGen/SourceGen.csproj +++ b/Source/SourceGen/SourceGen.csproj @@ -10,8 +10,8 @@ - - + +