Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions Source/Client/Patches/Determinism.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pawn, IntVec3> snapshot = new();

public static void Rebuild(IReadOnlyList<Pawn> 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<Pawn>)CachedPawns.GetValue(__instance));
}
}

[HarmonyPatch]
static class PathGridDoorsBlockedJobPositionPatch
{
static readonly MethodInfo PositionGetter =
AccessTools.PropertyGetter(typeof(Thing), nameof(Thing.Position));

static IEnumerable<MethodBase> TargetMethods()
{
yield return AccessTools.Method(typeof(PathGridDoorsBlockedJob), "Execute");
yield return AccessTools.Method(typeof(PathGridDoorsBlockedJob), "CanBlockEver");
}

static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> 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
{
Expand Down
Loading