From f32293eac08e1e26fcdcd22f2c0a13f313fe7786 Mon Sep 17 00:00:00 2001 From: chimook Date: Fri, 24 Jul 2026 03:14:55 +0900 Subject: [PATCH 1/3] Fix desync when cancelling a gravship launch The prelaunch-cancel handler closed the GravshipTravelSession with CloseSessionAt(Find.CurrentMap.Tile). Cancelling runs as a synced command on every peer, but Find.CurrentMap is the map each peer's camera is on - local UI state, not synchronized simulation state. A peer whose camera is on a different map closes the wrong tile and never closes the gravship session, so that map stays paused only for them. The per-map tick counts then diverge and the game desyncs ("Map instances don't match" / "Wrong random state on map N"). Close the session by looking it up instead of by the local camera. Of the four CloseSessionAt call sites, this was the only one using Find.CurrentMap; the others already pass deterministic tiles (curTile/landingTile/takeoffTile). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Patches/GravshipTravelSessionPatches.cs | 8 +++++++- .../Client/Persistent/GravshipTravelSession.cs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 4939f821c..5543893c1 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -38,7 +38,13 @@ static void Postfix(Dialog_MessageBox __instance) if (Multiplayer.Client == null) return; if (!Multiplayer.ExecutingCmds) return; - GravshipTravelUtils.CloseSessionAt(Find.CurrentMap.Tile); + // Cancelling is a synced command, so this runs on every peer. Using Find.CurrentMap here is + // non-deterministic: it's the map each peer's camera happens to be on, not synchronized state. + // A peer whose camera is on another map would call CloseSessionAt with the wrong tile and never + // close the gravship session, leaving that map paused only for them -> the per-map tick counts + // diverge and the game desyncs. There is exactly one open GravshipTravelSession at prelaunch time, + // so close it by looking it up rather than by the local camera. + GravshipTravelUtils.CloseAllSessions(); GravshipTravelUtils.CloseGravshipPrelaunchDialog(); } } diff --git a/Source/Client/Persistent/GravshipTravelSession.cs b/Source/Client/Persistent/GravshipTravelSession.cs index 92461b7b8..8e8de83e4 100644 --- a/Source/Client/Persistent/GravshipTravelSession.cs +++ b/Source/Client/Persistent/GravshipTravelSession.cs @@ -57,6 +57,22 @@ public static void CloseSessionAt(PlanetTile tile) } } + // Closes every open GravshipTravelSession, independent of the local camera (Find.CurrentMap). + // Used by the prelaunch-cancel path, which runs as a synced command on all peers: closing by the + // local camera's tile is non-deterministic and leaves the session (and thus the map's pause) alive + // on peers looking at another map. There is only ever one such session while a launch is pending. + public static void CloseAllSessions() + { + foreach (var sessionManager in Multiplayer.game.mapComps.Select(mp => mp.sessionManager)) + { + foreach (var session in sessionManager.AllSessions.OfType().ToList()) + { + session.Map.MpComp()?.sessionManager?.RemoveSession(session); + session.StopPausing(); + } + } + } + public static bool TryGetSessionAt(PlanetTile takeoffTile, out GravshipTravelSession session) { session = null; From b3aa7c63fabcb7a868fe6b649f63fa3d466bc51d Mon Sep 17 00:00:00 2001 From: chimook Date: Tue, 4 Aug 2026 00:06:15 +0900 Subject: [PATCH 2/3] Bind the gravship prelaunch cancel command to a map The cancel callback is a capture-less lambda, so its SyncDelegate has no map-bound object to serialize and DoSync falls back to mapId = ScheduledCommand.Global. World commands are executed by AsyncWorldTimeComp.ExecuteCmd which, unlike AsyncTimeComp.ExecuteCmd, never sets Current.Game.currentMapIndex. Find.CurrentMap therefore stayed whatever map each peer's camera happened to be on, and the handler's CloseSessionAt(Find.CurrentMap.Tile) closed the GravshipTravelSession on some peers but not others. The peers that missed it kept that map paused, the per-map tick counts diverged, and the game desynced ("Map instances don't match" / "Wrong random state on map N"). SetContext(SyncContext.MapSelected) makes the command carry the selection, which binds it to that map, so every peer executes the cancel in the same map context and closes the same session. This replaces the CloseAllSessions() approach from the previous commit, which closed every open GravshipTravelSession and would have closed another faction's session in multifaction games. Thanks @notfood for catching it. Co-Authored-By: Claude Opus 5 (1M context) --- .../Patches/GravshipTravelSessionPatches.cs | 8 +------- .../Client/Persistent/GravshipTravelSession.cs | 16 ---------------- Source/Client/Syncing/Game/SyncDelegates.cs | 4 +++- 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 5543893c1..4939f821c 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -38,13 +38,7 @@ static void Postfix(Dialog_MessageBox __instance) if (Multiplayer.Client == null) return; if (!Multiplayer.ExecutingCmds) return; - // Cancelling is a synced command, so this runs on every peer. Using Find.CurrentMap here is - // non-deterministic: it's the map each peer's camera happens to be on, not synchronized state. - // A peer whose camera is on another map would call CloseSessionAt with the wrong tile and never - // close the gravship session, leaving that map paused only for them -> the per-map tick counts - // diverge and the game desyncs. There is exactly one open GravshipTravelSession at prelaunch time, - // so close it by looking it up rather than by the local camera. - GravshipTravelUtils.CloseAllSessions(); + GravshipTravelUtils.CloseSessionAt(Find.CurrentMap.Tile); GravshipTravelUtils.CloseGravshipPrelaunchDialog(); } } diff --git a/Source/Client/Persistent/GravshipTravelSession.cs b/Source/Client/Persistent/GravshipTravelSession.cs index 8e8de83e4..92461b7b8 100644 --- a/Source/Client/Persistent/GravshipTravelSession.cs +++ b/Source/Client/Persistent/GravshipTravelSession.cs @@ -57,22 +57,6 @@ public static void CloseSessionAt(PlanetTile tile) } } - // Closes every open GravshipTravelSession, independent of the local camera (Find.CurrentMap). - // Used by the prelaunch-cancel path, which runs as a synced command on all peers: closing by the - // local camera's tile is non-deterministic and leaves the session (and thus the map's pause) alive - // on peers looking at another map. There is only ever one such session while a launch is pending. - public static void CloseAllSessions() - { - foreach (var sessionManager in Multiplayer.game.mapComps.Select(mp => mp.sessionManager)) - { - foreach (var session in sessionManager.AllSessions.OfType().ToList()) - { - session.Map.MpComp()?.sessionManager?.RemoveSession(session); - session.StopPausing(); - } - } - } - public static bool TryGetSessionAt(PlanetTile takeoffTile, out GravshipTravelSession session) { session = null; diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index 1ee71d206..3932b2a86 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -105,7 +105,9 @@ public static void Init() SyncDelegate.Lambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), 4); // Cancel gravship tile picker SyncDelegate.Lambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), 5); // Confirm gravship landing tile SyncDelegate.Lambda(typeof(RitualOutcomeEffectWorker_GravshipLaunch), nameof(RitualOutcomeEffectWorker_GravshipLaunch.Apply), 0); // Confirm gravship prelaunch dialog - SyncDelegate.Lambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), 4); // Cancel gravship prelaunch dialog + // The cancel callback is a capture-less lambda, so without a context the command has no map to bind to and + // goes out as a world command, leaving Find.CurrentMap (used to close the session) up to each peer's camera. + SyncDelegate.Lambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), 4).SetContext(SyncContext.MapSelected); // Cancel gravship prelaunch dialog // Biosculpter pod SyncMethod.Lambda(typeof(CompBiosculpterPod), nameof(CompBiosculpterPod.CompGetGizmosExtra), 1); // Interrupt cycle (eject contents) From 275042d42378f8cc86a59233a49a05e9f9374e93 Mon Sep 17 00:00:00 2001 From: Meru Date: Mon, 3 Aug 2026 11:00:27 -0500 Subject: [PATCH 3/3] Clean up comments in SyncDelegates.cs Removed comments explaining the cancel callback behavior. It's self explanatory. --- Source/Client/Syncing/Game/SyncDelegates.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index 3932b2a86..b93a89a62 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -105,8 +105,6 @@ public static void Init() SyncDelegate.Lambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), 4); // Cancel gravship tile picker SyncDelegate.Lambda(typeof(CompPilotConsole), nameof(CompPilotConsole.StartChoosingDestination_NewTemp), 5); // Confirm gravship landing tile SyncDelegate.Lambda(typeof(RitualOutcomeEffectWorker_GravshipLaunch), nameof(RitualOutcomeEffectWorker_GravshipLaunch.Apply), 0); // Confirm gravship prelaunch dialog - // The cancel callback is a capture-less lambda, so without a context the command has no map to bind to and - // goes out as a world command, leaving Find.CurrentMap (used to close the session) up to each peer's camera. SyncDelegate.Lambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), 4).SetContext(SyncContext.MapSelected); // Cancel gravship prelaunch dialog // Biosculpter pod