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