diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index a56bf353..94b09671 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -665,6 +665,82 @@ static void Postfix(Building_Electroharvester __instance) } } + // PathGridDoorsBlockedJob is a cross-tick Unity Job: scheduled at end of MapPreTick(N), + // completes at start of MapPreTick(N+1). During MapTick(N) it runs concurrently on a + // worker thread and reads live pawn.Position while the main thread writes + // pawn.Position = nextCell in Pawn_PathFollower.PatherTick(). Host and client see + // different enemy positions depending on thread scheduling → different blocked cells → + // different A* path → different nextCell for the pathing pawn → WillCollideNextCell + // differs → one client enters the attack branch → ChooseMeleeVerb → Rand.Chance → desync. + // + // Fix: snapshot all cached pawn positions on the main thread right after + // ScheduleBatchedPathJobs (still inside MapPreTick, before any pawn ticking). + // Worker threads reading pawn.Position during Execute() get the stable snapshot. + static class PawnPositionSnapshot + { + // Written once per tick on the main thread before jobs are dispatched; + // read concurrently by worker threads. Dictionary is safe for concurrent reads. + static readonly Dictionary snapshot = new(); + + public static void Rebuild(IReadOnlyList pawns) + { + snapshot.Clear(); + foreach (var pawn in pawns) + snapshot[pawn] = pawn.Position; + } + + public static bool TryGet(Pawn pawn, out IntVec3 pos) => + snapshot.TryGetValue(pawn, out pos); + } + + [HarmonyPatch(typeof(PathFinder), "ScheduleBatchedPathJobs")] + static class ScheduleBatchedPathJobsSnapshotPositions + { + static readonly FieldInfo CachedPawns = + AccessTools.Field(typeof(PathFinder), "cachedPawns"); + + static void Prefix(PathFinder __instance) + { + if (Multiplayer.Client == null) return; + // Snapshot pawn positions immediately before PathGridDoorsBlockedJob is + // dispatched so worker threads read stable positions instead of live ones. + PawnPositionSnapshot.Rebuild((IReadOnlyList)CachedPawns.GetValue(__instance)); + } + } + + [HarmonyPatch] + static class PathGridDoorsBlockedJobPositionPatch + { + static readonly MethodInfo PositionGetter = + AccessTools.PropertyGetter(typeof(Thing), nameof(Thing.Position)); + + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(PathGridDoorsBlockedJob), "Execute"); + yield return AccessTools.Method(typeof(PathGridDoorsBlockedJob), "CanBlockEver"); + } + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + if (inst.Calls(PositionGetter)) + inst.operand = AccessTools.Method( + typeof(PathGridDoorsBlockedJobPositionPatch), nameof(GetPosition)); + yield return inst; + } + } + + internal static IntVec3 GetPosition(Thing thing) + { + if (Multiplayer.Client != null + && thing is Pawn pawn + && PawnPositionSnapshot.TryGet(pawn, out IntVec3 pos)) + return pos; + return thing.Position; + } + } + [HarmonyPatch(typeof(MainTabWindow), nameof(MainTabWindow.SetInitialSizeAndPosition))] static class MainTabWindow_NoResizingInSimulation {