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..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;
+ }
+ }
+}
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