From 7df46f3d5289bf44f3f887329b6d2ac9e781ebf1 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Sun, 19 Jul 2026 02:37:09 +0200 Subject: [PATCH 01/27] Desync fixes, multifaction routing, and failure surfacing --- Source/Client/AsyncTime/AsyncTimeComp.cs | 3 +- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 2 +- Source/Client/AsyncTime/StorytellerPatches.cs | 19 ++- Source/Client/AsyncTime/TimeControlUI.cs | 7 +- .../Client/Comp/World/FactionBossgroupData.cs | 62 ++++++++ Source/Client/Comp/World/FactionWorldData.cs | 19 ++- .../Client/Comp/World/MultiplayerWorldComp.cs | 22 +++ Source/Client/Desyncs/SaveableDesyncInfo.cs | 3 + Source/Client/Desyncs/SimulationFailures.cs | 35 +++++ .../Client/Desyncs/UserReadableDesyncInfo.cs | 18 ++- Source/Client/EarlyInit.cs | 3 +- Source/Client/Factions/FactionCreator.cs | 28 +++- Source/Client/Factions/FactionSidebar.cs | 38 +++++ Source/Client/Factions/IncidentRouting.cs | 54 +++++++ .../Factions/Page_ChooseIdeo_Multifaction.cs | 54 ++++++- .../Client/Factions/QuestFactionOwnership.cs | 142 ++++++++++++++++++ Source/Client/Multiplayer.cs | 10 ++ Source/Client/Multiplayer.csproj | 2 +- Source/Client/MultiplayerStatic.cs | 3 +- Source/Client/Networking/JoinData.cs | 13 +- Source/Client/Patches/Anomaly.cs | 53 +++++++ Source/Client/Patches/Feedback.cs | 27 ++++ Source/Client/Patches/Patches.cs | 30 +++- Source/Client/Patches/TickPatch.cs | 3 +- Source/Client/Patches/TimestampFixer.cs | 5 + Source/Client/Persistent/RitualSession.cs | 24 +++ Source/Client/Saving/ConvertToSp.cs | 12 +- Source/Client/Saving/SavingPatches.cs | 8 +- Source/Client/Session/MultiplayerSession.cs | 5 + Source/Client/Syncing/Game/SyncActions.cs | 3 +- Source/Client/Syncing/Game/SyncDelegates.cs | 8 +- Source/Client/Syncing/Game/SyncGame.cs | 3 +- Source/Client/Syncing/Game/SyncMarkers.cs | 4 +- Source/Client/Syncing/Game/SyncMethods.cs | 42 +++++- .../Client/Syncing/Game/ThingFilterMarkers.cs | 40 ++--- Source/Client/Syncing/Sync.cs | 9 +- Source/Client/Syncing/SyncFieldUtil.cs | 3 +- Source/Client/UI/IngameDebug.cs | 2 + Source/Client/UI/MainMenuPatches.cs | 33 +++- Source/Client/Util/Extensions.cs | 2 +- Source/Client/Util/MpPatch.cs | 30 +++- Source/Common/Common.csproj | 2 +- Source/Common/MultiplayerServer.cs | 23 ++- Source/Common/ServerPlayer.cs | 3 + .../MultiplayerLoader.csproj | 2 +- .../ClientInitDataPacket.verified.txt | 2 +- .../ServerJoinDataPacket.verified.txt | 2 +- 47 files changed, 835 insertions(+), 82 deletions(-) create mode 100644 Source/Client/Comp/World/FactionBossgroupData.cs create mode 100644 Source/Client/Desyncs/SimulationFailures.cs create mode 100644 Source/Client/Factions/IncidentRouting.cs create mode 100644 Source/Client/Factions/QuestFactionOwnership.cs diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 32233940b..a2200500d 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using HarmonyLib; using Multiplayer.Client.Comp; +using Multiplayer.Client.Desyncs; using Multiplayer.Client.Factions; using Multiplayer.Client.Patches; using Multiplayer.Client.Saving; @@ -290,7 +291,7 @@ public void ExecuteCmd(ScheduledCommand cmd) } catch (Exception e) { - MpLog.Error($"Map cmd exception ({cmdType}): {e}"); + SimulationFailures.Handle($"Map cmd exception ({cmdType})", e); } finally { diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 8184163ab..6897eec3e 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -252,7 +252,7 @@ public void ExecuteCmd(ScheduledCommand cmd) } catch (Exception e) { - Log.Error($"World cmd exception ({cmdType}): {e}"); + SimulationFailures.Handle($"World cmd exception ({cmdType})", e); } finally { diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index 7d21a4af5..751ded46c 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -39,7 +39,8 @@ static void Postfix(List __result) if (Multiplayer.MapContext != null) { __result.Clear(); - __result.Add(Multiplayer.MapContext); + if (FactionCanReceiveIncidentsOn(Multiplayer.MapContext, Faction.OfPlayer)) + __result.Add(Multiplayer.MapContext); } else if (AsyncWorldTimeComp.tickingWorld) { @@ -52,6 +53,22 @@ static void Postfix(List __result) __result.Add(Find.World); } } + + // #659: FactionRepeater runs this once per player faction; without an ownership + // check every faction's storyteller fires on every map (wrong-faction raids and + // letters, storyteller pressure multiplied by player count) + static bool FactionCanReceiveIncidentsOn(Map map, Faction faction) + { + if (!Multiplayer.GameComp.multifaction) + return true; + + if (map.ParentFaction == faction) + return true; + + // Unowned maps (sites, temp maps): target factions with humanlike pawns present + return map.ParentFaction is not { IsPlayer: true } && + map.mapPawns.SpawnedPawnsInFaction(faction).Any(p => p.RaceProps.Humanlike); + } } // The MP Mod's ticker calls Storyteller.StorytellerTick() on both the World and each Map, each tick diff --git a/Source/Client/AsyncTime/TimeControlUI.cs b/Source/Client/AsyncTime/TimeControlUI.cs index 79444d14f..9f3bba19c 100644 --- a/Source/Client/AsyncTime/TimeControlUI.cs +++ b/Source/Client/AsyncTime/TimeControlUI.cs @@ -19,6 +19,11 @@ public static class TimeControlPatch { private static TimeVote[] GameSpeeds = { TimeVote.Paused, TimeVote.Normal, TimeVote.Fast, TimeVote.Superfast }; + // Vanilla parity: show the Ultrafast button in dev mode (the vote enum, + // tick rates and hotkey already support it) + private static TimeVote[] GameSpeedsDev = + { TimeVote.Paused, TimeVote.Normal, TimeVote.Fast, TimeVote.Superfast, TimeVote.Ultrafast }; + private static bool ShouldReset => Event.current.shift && Multiplayer.GameComp.IsLowestWins; private static ITickable Tickable => @@ -84,7 +89,7 @@ private static void DoTimeControlsGUI(Rect timerRect) SendTimeVote(TimeVote.ResetTickable); } - foreach (var speed in GameSpeeds) + foreach (var speed in Prefs.DevMode ? GameSpeedsDev : GameSpeeds) { if (Widgets.ButtonImage(rect, TexButton.SpeedButtonTextures[(uint)speed])) { diff --git a/Source/Client/Comp/World/FactionBossgroupData.cs b/Source/Client/Comp/World/FactionBossgroupData.cs new file mode 100644 index 000000000..81dc727f6 --- /dev/null +++ b/Source/Client/Comp/World/FactionBossgroupData.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using HarmonyLib; +using RimWorld; +using Verse; + +namespace Multiplayer.Client; + +// Multifaction: GameComponent_Bossgroup state is a game singleton - one player's +// bossgroup call escalated threat difficulty and cooldown for every faction. +// Dict/list swap by reference on faction switch; the cooldown int is copied in +// and written back by the postfix on its single writer. +public class FactionBossgroupData : IExposable +{ + public int lastBossgroupCalled = -9999999; + public Dictionary timesCalledBossgroups = new(); + public List killedBosses = new(); + + public void ExposeData() + { + Scribe_Values.Look(ref lastBossgroupCalled, "lastBossgroupCalled", -9999999); + Scribe_Collections.Look(ref timesCalledBossgroups, "timesCalledBossgroups", LookMode.Def, LookMode.Value); + Scribe_Collections.Look(ref killedBosses, "killedBosses", LookMode.Def); + + if (Scribe.mode == LoadSaveMode.LoadingVars) + { + timesCalledBossgroups ??= new Dictionary(); + killedBosses ??= new List(); + } + } + + public static FactionBossgroupData New() + { + return new FactionBossgroupData(); + } + + public static FactionBossgroupData FromCurrent() + { + var comp = Current.Game?.GetComponent(); + if (comp == null) + return new FactionBossgroupData(); + + return new FactionBossgroupData + { + lastBossgroupCalled = comp.lastBossgroupCalled, + timesCalledBossgroups = comp.timesCalledBossgroups, + killedBosses = comp.killedBosses, + }; + } +} + +[HarmonyPatch(typeof(GameComponent_Bossgroup), nameof(GameComponent_Bossgroup.Notify_BossgroupCalled))] +static class BossgroupLastCalledWriteBack +{ + static void Postfix(GameComponent_Bossgroup __instance) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (Multiplayer.WorldComp.factionData.TryGetValue(Faction.OfPlayer.loadID, out var data)) + data.bossgroup.lastBossgroupCalled = __instance.lastBossgroupCalled; + } +} diff --git a/Source/Client/Comp/World/FactionWorldData.cs b/Source/Client/Comp/World/FactionWorldData.cs index 20b43fe7b..c3fc4547a 100644 --- a/Source/Client/Comp/World/FactionWorldData.cs +++ b/Source/Client/Comp/World/FactionWorldData.cs @@ -17,6 +17,9 @@ public class FactionWorldData : IExposable public Storyteller storyteller; public StoryWatcher storyWatcher; + public AnalysisManager analysisManager; + public FactionBossgroupData bossgroup; + public FactionWorldData() { } public void ExposeData() @@ -33,6 +36,9 @@ public void ExposeData() Scribe_Deep.Look(ref storyteller, "storyteller"); Scribe_Deep.Look(ref storyWatcher, "storyWatcher"); + Scribe_Deep.Look(ref analysisManager, "analysisManager"); + Scribe_Deep.Look(ref bossgroup, "bossgroup"); + if (Scribe.mode == LoadSaveMode.LoadingVars) { researchManager ??= new ResearchManager(); @@ -45,6 +51,9 @@ public void ExposeData() storyteller ??= new Storyteller(Find.Storyteller.def, Find.Storyteller.difficultyDef, Find.Storyteller.difficulty); storyWatcher ??= new StoryWatcher(); + + analysisManager ??= new AnalysisManager(); + bossgroup ??= FactionBossgroupData.New(); } } @@ -74,7 +83,10 @@ public static FactionWorldData New(int factionId) history = new History(), storyteller = new Storyteller(Find.Storyteller.def, Find.Storyteller.difficultyDef, Find.Storyteller.difficulty), - storyWatcher = new StoryWatcher() + storyWatcher = new StoryWatcher(), + + analysisManager = new AnalysisManager(), + bossgroup = FactionBossgroupData.New() }; } @@ -92,7 +104,10 @@ public static FactionWorldData FromCurrent(int factionId) history = Find.History, storyteller = Find.Storyteller, - storyWatcher = Find.StoryWatcher + storyWatcher = Find.StoryWatcher, + + analysisManager = Current.Game.analysisManager, + bossgroup = FactionBossgroupData.FromCurrent() }; } } diff --git a/Source/Client/Comp/World/MultiplayerWorldComp.cs b/Source/Client/Comp/World/MultiplayerWorldComp.cs index a505856af..6f9d84be6 100644 --- a/Source/Client/Comp/World/MultiplayerWorldComp.cs +++ b/Source/Client/Comp/World/MultiplayerWorldComp.cs @@ -22,6 +22,9 @@ public class MultiplayerWorldComp : IHasSessionData public Faction spectatorFaction; + // Multifaction: quest.id -> faction.loadID owning the quest (see QuestFactionOwnership) + public Dictionary questOwnership = new(); + private int currentFactionId; public MultiplayerWorldComp(World world) @@ -35,6 +38,10 @@ public void ExposeData() { ExposeFactionData(); + Scribe_Collections.Look(ref questOwnership, "questOwnership", LookMode.Value, LookMode.Value); + if (Scribe.mode != LoadSaveMode.Saving) + questOwnership ??= new Dictionary(); + sessionManager.ExposeSessions(); // Ensure a pause lock session exists if there's any pause locks registered if (!PauseLockSession.pauseLocks.NullOrEmpty() && Scribe.mode == LoadSaveMode.PostLoadInit) @@ -79,6 +86,9 @@ void RemoveOpponentFaction() RemoveOpponentFaction(); } + if (Multiplayer.GameComp.multifaction) + Factions.QuestFactionOwnership.BackfillOwnership(); + // Fix old save files by ensuring all factions have access to Anomaly research if // it was enabled. This needs to be done since Anomaly state is shared by all players. var anomaly = Find.Anomaly; @@ -164,6 +174,18 @@ public void SetFaction(Faction faction) game.history = data.history; game.storyteller = data.storyteller; game.storyWatcher = data.storyWatcher; + + if (data.analysisManager != null) + game.analysisManager = data.analysisManager; + + // Bossgroup component state: dict/list swap by reference, the + // cooldown int is copied in (written back by BossgroupLastCalledWriteBack) + if (data.bossgroup != null && game.GetComponent() is { } bossgroups) + { + bossgroups.timesCalledBossgroups = data.bossgroup.timesCalledBossgroups; + bossgroups.killedBosses = data.bossgroup.killedBosses; + bossgroups.lastBossgroupCalled = data.bossgroup.lastBossgroupCalled; + } } public void DirtyColonyTradeForMap(Map map) diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 200c3dfee..9ac184387 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -118,6 +118,9 @@ private string GetDesyncDetails() .AppendLine($"Multiplayer Debug Build - Client|||{MpVersion.IsDebug}") .AppendLine($"Multiplayer Debug Mode - Host|||{Multiplayer.GameComp.debugMode}") .AppendLine($"Rimworld Developer Mode - Client|||{Prefs.DevMode}") + .AppendLine("\n###Simulation Failures###") + .AppendLine($"Skipped Commands/Ticks This Session|||{Multiplayer.session.simulationFailures}") + .AppendLine($"First Failure|||{Multiplayer.session.firstSimulationFailure ?? "none"}") .AppendLine("\n###Server Info###") .AppendLine($"Player Count|||{Multiplayer.session.players.Count}") .AppendLine($"Async time active|||{Multiplayer.GameComp.asyncTime}") diff --git a/Source/Client/Desyncs/SimulationFailures.cs b/Source/Client/Desyncs/SimulationFailures.cs new file mode 100644 index 000000000..329a1ee62 --- /dev/null +++ b/Source/Client/Desyncs/SimulationFailures.cs @@ -0,0 +1,35 @@ +using System; +using Multiplayer.Client.Util; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Desyncs +{ + // A command/tick exception is caught and skipped so the simulation keeps + // running, but the skip means this client may no longer match the others - + // record it and warn the player once instead of only burying it in the log + public static class SimulationFailures + { + public static void Handle(string context, Exception e) + { + MpLog.Error($"{context}: {e}"); + + var session = Multiplayer.session; + if (session == null) + return; + + session.simulationFailures++; + if (session.simulationFailures > 1) + return; + + session.firstSimulationFailure = $"[{TickPatch.Timer}] {context}: {e.GetType().Name}: {e.Message}"; + + // historical: false - archiving mutates game state, and the failure + // may have happened on this client only + Messages.Message( + "MP: a synced command or tick failed on this client and was skipped. " + + "This can lead to a desync - check the log for the error.", + MessageTypeDefOf.NegativeEvent, historical: false); + } + } +} diff --git a/Source/Client/Desyncs/UserReadableDesyncInfo.cs b/Source/Client/Desyncs/UserReadableDesyncInfo.cs index 7d269984e..27c8c45f1 100644 --- a/Source/Client/Desyncs/UserReadableDesyncInfo.cs +++ b/Source/Client/Desyncs/UserReadableDesyncInfo.cs @@ -51,8 +51,9 @@ public static string GenerateFromReplay(Replay replay) text.AppendLine("[info]"); text.AppendLine(zip.GetString("info")); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -63,8 +64,9 @@ public static string GenerateFromReplay(Replay replay) //Local Client Opinion data local = DeserializeAndPrintSyncInfo(text, "sync_local", zip.GetBytes("sync_local")); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -75,8 +77,9 @@ public static string GenerateFromReplay(Replay replay) //Remote Client Opinion data remote = DeserializeAndPrintSyncInfo(text, "sync_remote", zip.GetBytes("sync_remote")); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -104,8 +107,9 @@ public static string GenerateFromReplay(Replay replay) text.AppendLine($"Game debug mode: {desyncInfo.ReadBool()}"); } } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -140,8 +144,9 @@ public static string GenerateFromReplay(Replay replay) foreach (var cmd in ScheduledCommand.DeserializeCmds(zip.GetBytes("maps/000_0_cmds"))) PrintCmdInfo(text, cmd); } - catch + catch (Exception e) { + text.AppendLine($""); } text.AppendLine(); @@ -153,8 +158,9 @@ public static string GenerateFromReplay(Replay replay) foreach (var cmd in ScheduledCommand.DeserializeCmds(zip.GetBytes("world/000_cmds"))) PrintCmdInfo(text, cmd); } - catch + catch (Exception e) { + text.AppendLine($""); } } diff --git a/Source/Client/EarlyInit.cs b/Source/Client/EarlyInit.cs index 35d89bb7e..cc8e23182 100644 --- a/Source/Client/EarlyInit.cs +++ b/Source/Client/EarlyInit.cs @@ -30,8 +30,7 @@ internal static void EarlyPatches(Harmony harmony) if (type.IsDefined(typeof(EarlyPatchAttribute))) harmony.CreateClassProcessor(type).Patch(); } catch (Exception e) { - Log.Error($"FAIL: {type} with {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"FAIL: {type} with {e}"); } }); diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index c26ea721e..a7917c8c0 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -223,13 +223,16 @@ private static Faction NewFactionWithIdeo(string name, Color color, FactionDef d faction.ideos = new FactionIdeosTracker(faction); - if (!ModsConfig.IdeologyActive || Find.IdeoManager.classicMode || chooseIdeoInfo.SelectedIdeo == null) + if (!ModsConfig.IdeologyActive || Find.IdeoManager.classicMode || + (chooseIdeoInfo.SelectedIdeo == null && chooseIdeoInfo.CustomIdeoData == null)) { faction.ideos.SetPrimary(Faction.OfPlayer.ideos.PrimaryIdeo); } else { - var newIdeo = GenerateIdeo(chooseIdeoInfo); + var newIdeo = chooseIdeoInfo.CustomIdeoData != null + ? ReconstructCustomIdeo(chooseIdeoInfo.CustomIdeoData) + : GenerateIdeo(chooseIdeoInfo); faction.ideos.SetPrimary(newIdeo); Find.IdeoManager.Add(newIdeo); } @@ -254,6 +257,27 @@ private static Faction NewFactionWithIdeo(string name, Color color, FactionDef d return faction; } + // Deserializes a player-provided custom ideo (.rid file) inside the synced + // creation command; ids from the issuer's local game are reassigned here, + // where allocation is deterministic across clients + private static Ideo ReconstructCustomIdeo(byte[] data) + { + var ideo = ScribeUtil.ReadExposable(data); + + ideo.id = Find.UniqueIDsManager.GetNextIdeoID(); + foreach (var precept in ideo.PreceptsListForReading) + { + precept.ID = Find.UniqueIDsManager.GetNextPreceptID(); + precept.ideo = ideo; + } + + // Same back-reference fixups as FixIdeoAfterCopy + ideo.development.ideo = ideo; + ideo.style.ideo = ideo; + + return ideo; + } + private static Ideo GenerateIdeo(IdeologyData chooseIdeoInfo) { List list = chooseIdeoInfo.SelectedIdeo.memes.ToList(); diff --git a/Source/Client/Factions/FactionSidebar.cs b/Source/Client/Factions/FactionSidebar.cs index c530eadb1..b6c072b04 100644 --- a/Source/Client/Factions/FactionSidebar.cs +++ b/Source/Client/Factions/FactionSidebar.cs @@ -360,6 +360,44 @@ private static void DrawFactionCreator(Rect inRect) { Find.WindowStack.Add(new Dialog_FactionDuringLanding()); } + + // Multifaction creation skips the vanilla naming prompts - offer them + // here (the dialogs' accept paths are synced in SyncMethods) + if (Multiplayer.RealPlayerFaction is { } myFaction && + myFaction != Multiplayer.WorldComp.spectatorFaction) + { + Rect renameFactionRect = new Rect( + viewRect.x, + worldFactionsRect.yMax + spacing, + viewRect.width, + buttonHeight + ); + if (Widgets.ButtonText(renameFactionRect, "Rename faction")) + Find.WindowStack.Add(new Dialog_NamePlayerFaction()); + + var mySettlements = Find.WorldObjects.Settlements + .Where(s => s.Faction == myFaction).ToList(); + + if (mySettlements.Any()) + { + Rect nameColonyRect = new Rect( + viewRect.x, + renameFactionRect.yMax + spacing, + viewRect.width, + buttonHeight + ); + if (Widgets.ButtonText(nameColonyRect, "Name colony")) + { + if (mySettlements.Count == 1) + Find.WindowStack.Add(new Dialog_NamePlayerSettlement(mySettlements[0])); + else + Find.WindowStack.Add(new FloatMenu(mySettlements + .Select(s => new FloatMenuOption(s.Name, + () => Find.WindowStack.Add(new Dialog_NamePlayerSettlement(s)))) + .ToList())); + } + } + } // END Buttons } diff --git a/Source/Client/Factions/IncidentRouting.cs b/Source/Client/Factions/IncidentRouting.cs new file mode 100644 index 000000000..66c039556 --- /dev/null +++ b/Source/Client/Factions/IncidentRouting.cs @@ -0,0 +1,54 @@ +using HarmonyLib; +using Multiplayer.Client.Util; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Safety nets for multifaction incident routing (the root fix is in +// StorytellerTargetsPatch): log anything that still slips through. + +[HarmonyPatch(typeof(IncidentWorker), nameof(IncidentWorker.TryExecute))] +static class SuppressCrossFactionIncidents +{ + static bool Prefix(IncidentWorker __instance, IncidentParms parms, ref bool __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return true; + + if (parms.target is Map { ParentFaction: { IsPlayer: true } owner } && owner != Faction.OfPlayer) + { + // Spectator context is how quest-fired incidents execute in async-off + // games - log but let through to avoid eating quest raids + if (Faction.OfPlayer == Multiplayer.WorldComp.spectatorFaction) + { + MpLog.Log( + $"Spectator-context incident {__instance.def?.defName} targeting map of " + + $"{owner.Name} - candidate for quest faction routing (61-quests-review.md)"); + return true; + } + + MpLog.Error( + $"Suppressed cross-faction incident {__instance.def?.defName} targeting map of " + + $"{owner.Name} while executing as {Faction.OfPlayer?.Name} - report this, the " + + "root-cause filter should have prevented it"); + // Report "fired" so the storyteller/queue doesn't retry it elsewhere + __result = true; + return false; + } + + return true; + } +} + +[HarmonyPatch(typeof(Storyteller), nameof(Storyteller.TryFire))] +static class WarnSpectatorStorytellerFire +{ + static void Prefix(FiringIncident fi) + { + if (Multiplayer.Client != null && Multiplayer.GameComp.multifaction && + Faction.OfPlayer == Multiplayer.WorldComp.spectatorFaction) + MpLog.Error($"Storyteller fired {fi?.def?.defName} in spectator faction context - " + + "an unrouted generation source (see 62-incidents-review.md)"); + } +} diff --git a/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs b/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs index 5a5b940bb..d0fdfeb0d 100644 --- a/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs +++ b/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs @@ -13,6 +13,10 @@ public class Page_ChooseIdeo_Multifaction : Page public Page_ChooseIdeoPreset pageChooseIdeo = new(); + // Custom ideology from a saved .rid file, loaded as a detached object + // (TryLoadIdeo registers nothing) and serialized into the creation command + private Ideo customIdeo; + public override void DoWindowContents(Rect inRect) { DrawPageTitle(inRect); @@ -27,6 +31,13 @@ public override void DoWindowContents(Rect inRect) pageChooseIdeo.DrawStructureAndStyleSelection(inRect); + var loadRect = new Rect(inRect.xMax - 260f, inRect.y, 250f, 32f); + var loadLabel = customIdeo == null + ? "Load custom ideoligion..." + : $"Custom: {customIdeo.name} (click to change)"; + if (Widgets.ButtonText(loadRect, loadLabel)) + OpenCustomIdeoMenu(); + Rect outRect = mainRect; outRect.width = 954f; outRect.yMin += totalHeight; @@ -49,25 +60,58 @@ public override void DoWindowContents(Rect inRect) DoBottomButtons(inRect); } + private void OpenCustomIdeoMenu() + { + var ideosDir = GenFilePaths.FolderUnderSaveData("Ideos"); + var files = new System.IO.DirectoryInfo(ideosDir).GetFiles("*.rid"); + + if (files.Length == 0) + { + Messages.Message( + "No saved ideoligions found. Create one in singleplayer's ideoligion editor and save it, then load it here.", + MessageTypeDefOf.RejectInput, historical: false); + return; + } + + var options = files + .Select(f => new FloatMenuOption(System.IO.Path.GetFileNameWithoutExtension(f.Name), () => + { + if (GameDataSaveLoader.TryLoadIdeo(f.FullName, out var loaded)) + customIdeo = loaded; + else + Messages.Message($"Failed to load {f.Name} - see log.", + MessageTypeDefOf.RejectInput, historical: false); + })) + .Append(new FloatMenuOption("Clear custom ideoligion", () => customIdeo = null)) + .ToList(); + + Find.WindowStack.Add(new FloatMenu(options)); + } + public override bool CanDoNext() { - if (pageChooseIdeo.selectedIdeo == null) + if (customIdeo == null && pageChooseIdeo.selectedIdeo == null) { - Messages.Message("Please select a preset.", MessageTypeDefOf.RejectInput, historical: false); + Messages.Message("Please select a preset or load a custom ideoligion.", MessageTypeDefOf.RejectInput, historical: false); return false; } return base.CanDoNext(); - } + } public IdeologyData GetIdeologyData() { - return new IdeologyData(pageChooseIdeo.selectedIdeo, pageChooseIdeo.selectedStructure, pageChooseIdeo.selectedStyles); + return new IdeologyData( + pageChooseIdeo.selectedIdeo, + pageChooseIdeo.selectedStructure, + pageChooseIdeo.selectedStyles, + customIdeo != null ? ScribeUtil.WriteExposable(customIdeo) : null); } } public record IdeologyData( IdeoPresetDef SelectedIdeo = null, MemeDef SelectedStructure = null, - List SelectedStyles = null + List SelectedStyles = null, + byte[] CustomIdeoData = null ) : ISyncSimple; diff --git a/Source/Client/Factions/QuestFactionOwnership.cs b/Source/Client/Factions/QuestFactionOwnership.cs new file mode 100644 index 000000000..3789ad3d1 --- /dev/null +++ b/Source/Client/Factions/QuestFactionOwnership.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Multiplayer.Client.Util; +using RimWorld; +using RimWorld.QuestGen; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Multifaction: vanilla quests have no owning faction, so quest parts executed +// in whatever context the tick held (usually spectator) - reward pawns joined +// the Spectator faction (#546), letters and ideo checks hit the wrong faction. +// Quests are now stamped with an owner whose context wraps their execution. +public static class QuestFactionOwnership +{ + public static Faction GetOwner(Quest quest) + { + if (quest == null || + !Multiplayer.WorldComp.questOwnership.TryGetValue(quest.id, out var factionId)) + return null; + return Find.FactionManager.GetById(factionId); + } + + public static void Stamp(Quest quest, Faction faction) + { + if (quest != null && faction != null) + Multiplayer.WorldComp.questOwnership[quest.id] = faction.loadID; + } + + public static bool IsOwnablePlayerFaction(Faction f) => + f is { IsPlayer: true } && f != Multiplayer.WorldComp.spectatorFaction; + + public static Faction ResolveOwner(Quest quest, Faction contextFaction) + { + // 1. Generation context (per-faction storyteller loop, synced command) + if (IsOwnablePlayerFaction(contextFaction)) + return contextFaction; + + // 2. Infer from quest look targets (owning settlement) + if (quest.TryGetPlayerFaction(out var inferred) && IsOwnablePlayerFaction(inferred)) + return inferred; + + // 3. Deterministic fallback: lowest-loadID player faction (the host's) + return Find.FactionManager.AllFactionsListForReading + .Where(IsOwnablePlayerFaction) + .OrderBy(f => f.loadID) + .FirstOrDefault(); + } + + // Old saves have no ownership data + public static void BackfillOwnership() + { + var backfilled = 0; + foreach (var quest in Find.QuestManager.QuestsListForReading) + { + if (Multiplayer.WorldComp.questOwnership.ContainsKey(quest.id)) + continue; + Stamp(quest, ResolveOwner(quest, null)); + backfilled++; + } + + if (backfilled > 0) + MpLog.Log($"Backfilled faction ownership for {backfilled} quests"); + } +} + +[HarmonyPatch(typeof(QuestGen), nameof(QuestGen.Generate))] +static class StampQuestFactionOnGeneration +{ + static void Postfix(Quest __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || __result == null) + return; + + var contextFaction = Faction.OfPlayer; + var owner = QuestFactionOwnership.ResolveOwner(__result, contextFaction); + + // Logs identify generation sources that still run unrouted + if (!QuestFactionOwnership.IsOwnablePlayerFaction(contextFaction)) + MpLog.Log($"Quest {__result.root?.defName} generated outside a player faction " + + $"context ({contextFaction?.Name}); assigned to {owner?.Name}"); + + QuestFactionOwnership.Stamp(__result, owner); + } +} + +[HarmonyPatch(typeof(Quest), nameof(Quest.Accept))] +static class StampQuestFactionOnAccept +{ + // Ownership follows whoever accepts (the synced command's context faction) + static void Prefix(Quest __instance) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer)) + QuestFactionOwnership.Stamp(__instance, Faction.OfPlayer); + } +} + +[HarmonyPatch] +static class PushQuestOwnerContext +{ + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(Quest), nameof(Quest.QuestTick)); + yield return AccessTools.Method(typeof(Quest), nameof(Quest.Notify_SignalReceived)); + yield return AccessTools.Method(typeof(Quest), nameof(Quest.CleanupQuestParts)); + } + + static void Prefix(Quest __instance, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + var owner = QuestFactionOwnership.GetOwner(__instance); + if (owner == null) + return; + + // Push/pop are null-balanced, safe when the owner already holds context + ((Map)null).PushFaction(owner); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +[HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Remove))] +static class RemoveQuestOwnership +{ + static void Postfix(Quest quest) + { + if (Multiplayer.Client != null && quest != null) + Multiplayer.WorldComp?.questOwnership.Remove(quest.id); + } +} diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 5ea4b1760..ce26b791c 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -81,8 +81,18 @@ public static class Multiplayer public static bool arbiterInstance; public static bool loadingErrors; + public static readonly List loadingErrorsList = new(); public static Stopwatch harmonyWatch = new(); + // Central sink for init-time failures; keeps the message so the UI can + // show what broke instead of just a warning marker + public static void LoadingError(string message) + { + Log.Error(message); + loadingErrors = true; + loadingErrorsList.Add(message); + } + public static ModContentPack modContentPack; public static void InitMultiplayer(ModContentPack content) diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index 26cdeb7b1..9935e5553 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -25,7 +25,7 @@ - + diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 49519f9b9..df8730880 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -256,8 +256,7 @@ void LogError(string str) if (categoryNeedsAnnouncement) { Log.Message($"Multiplayer :: {category}"); } - Log.Error(str); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError(str); } var harmony = Multiplayer.harmony; diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index c123d9c2a..ad0585135 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -78,7 +78,18 @@ public static bool CompareToLocal(RemoteData remote) remote.remoteRwVersion == VersionControl.CurrentVersionString && remote.CompareMods(activeModsSnapshot) == ModListDiff.None && remote.remoteFiles.DictsEqual(modFilesSnapshot) && - (!remote.hasConfigs || remote.remoteModConfigs.EqualAsSets(SyncConfigs.GetSyncableConfigContents(remote.RemoteModIds.ToList()))); + (!remote.hasConfigs || ConfigsEquivalent(remote.remoteModConfigs, + SyncConfigs.GetSyncableConfigContents(remote.RemoteModIds.ToList()))); + } + + // Don't fail the check over CRLF vs LF: mods writing settings with + // platform-default newlines produce different bytes for identical configs + private static bool ConfigsEquivalent(IEnumerable a, IEnumerable b) + { + static ModConfig Normalize(ModConfig c) => + c with { Contents = c.Contents?.Replace("\r\n", "\n") }; + + return a.Select(Normalize).EqualAsSets(b.Select(Normalize)); } internal static void TakeModDataSnapshot() diff --git a/Source/Client/Patches/Anomaly.cs b/Source/Client/Patches/Anomaly.cs index aa6a4d553..112323b54 100644 --- a/Source/Client/Patches/Anomaly.cs +++ b/Source/Client/Patches/Anomaly.cs @@ -1,9 +1,13 @@ using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Reflection.Emit; using HarmonyLib; using RimWorld; +using UnityEngine; using Verse; +using Verse.AI; +using Verse.AI.Group; namespace Multiplayer.Client.Patches { @@ -36,4 +40,53 @@ static CellRect EmptyCellRect() static void Prefix() => Rand.PushState(Find.TickManager.TicksAbs); static void Postfix() => Rand.PopState(); } + + // #959: UnnaturalCorpse gates simulation decisions on the local camera, + // forking corpse state between clients. Treat it as always unobserved in MP. + [HarmonyPatch(typeof(AnomalyUtility), nameof(AnomalyUtility.IsValidUnseenCell))] + static class AnomalyUtility_IsValidUnseenCell + { + // An empty rect makes the internal camera/CurrentMap check short-circuit + // deterministically; covers the container-breakout call in UnnaturalCorpseTracker + static void Prefix(ref CellRect view) + { + if (Multiplayer.Client != null) + view = CellRect.Empty; + } + } + + // #855: vanilla mutates the inventory container it's enumerating, aborting the + // toil so psychic ritual outcomes never fire. Replicated with a snapshot. + [HarmonyPatch(typeof(PsychicRitualToil_InvokeHorax), nameof(PsychicRitualToil_InvokeHorax.HoldRequiredOfferings))] + static class FixHoldRequiredOfferingsCollectionModified + { + static bool Prefix(PsychicRitualToil_InvokeHorax __instance, PsychicRitual psychicRitual) + { + if (__instance.requiredOffering == null) + return false; + + foreach (var pawn in psychicRitual.assignments.AssignedPawns(__instance.invokerRole)) + foreach (var thing in pawn.inventory.GetDirectlyHeldThings().ToList()) + if (__instance.requiredOffering.filter.Allows(thing)) + pawn.inventory.innerContainer.TryTransferToContainer(thing, pawn.carryTracker.innerContainer, + Mathf.CeilToInt(__instance.requiredOffering.GetBaseCount())); + + return false; + } + } + + [HarmonyPatch(typeof(UnnaturalCorpse), nameof(UnnaturalCorpse.IsOutsideView))] + static class UnnaturalCorpse_IsOutsideView + { + // Keep the (deterministic) reservation check, skip the camera checks + static bool Prefix(UnnaturalCorpse __instance, ref bool __result) + { + if (Multiplayer.Client == null) + return true; + + __result = !(__instance.SpawnedOrAnyParentSpawned && + __instance.MapHeld.reservationManager.IsReservedByAnyoneOf(__instance, Faction.OfPlayer)); + return false; + } + } } \ No newline at end of file diff --git a/Source/Client/Patches/Feedback.cs b/Source/Client/Patches/Feedback.cs index e2e18e1e3..c50d49d1d 100644 --- a/Source/Client/Patches/Feedback.cs +++ b/Source/Client/Patches/Feedback.cs @@ -237,13 +237,29 @@ static class DraftedMove_GotoFeedbackPatch private static MethodInfo tryTakeOrderedJob = AccessTools.Method(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.TryTakeOrderedJob)); + private static MethodInfo endCurrentJob = + AccessTools.Method(typeof(Pawn_JobTracker), nameof(Pawn_JobTracker.EndCurrentJob)); + static IEnumerable Transpiler(IEnumerable instructions) { + var endCurrentJobCalls = 0; + foreach (var inst in instructions) { if (inst.Calls(tryTakeOrderedJob)) inst.operand = ((Delegate)CustomTryTakeOrderedJob).Method; + if (inst.Calls(endCurrentJob)) + { + inst.operand = ((Delegate)SyncedEndGotoJob).Method; + endCurrentJobCalls++; + } yield return inst; } + + // #849: the EndCurrentJob call (goto onto the pawn's own cell) stopped + // the pawn on the clicking client only; fail loud if the shape changes + if (endCurrentJobCalls != 1) + Multiplayer.LoadingError( + $"DraftedMove_GotoFeedbackPatch: expected 1 EndCurrentJob call in PawnGotoAction, found {endCurrentJobCalls}"); } [SyncMethod(exposeParameters = [1], context = SyncContext.QueueOrder_Down)] @@ -254,6 +270,17 @@ static bool CustomTryTakeOrderedJob(Pawn_JobTracker self, Job job, JobTag? tag = FleckMaker.Static(job.targetA.Cell, self.pawn.Map, FleckDefOf.FeedbackGoto); return false; } + + [SyncMethod] + static void SyncedEndGotoJob(Pawn_JobTracker self, JobCondition condition, bool startNewJob, bool canReturnToPool) + { + // The command lands ticks after the click - only end the job if it's + // still the goto this order was aimed at + if (Multiplayer.Client != null && self.curJob?.def != JobDefOf.Goto) + return; + + self.EndCurrentJob(condition, startNewJob, canReturnToPool); + } } } diff --git a/Source/Client/Patches/Patches.cs b/Source/Client/Patches/Patches.cs index 344833379..49c35b067 100644 --- a/Source/Client/Patches/Patches.cs +++ b/Source/Client/Patches/Patches.cs @@ -9,6 +9,7 @@ using System.Reflection.Emit; using System.Text.RegularExpressions; using System.Xml.Linq; +using Multiplayer.Client.Factions; using Multiplayer.Client.Patches; using UnityEngine; using Verse; @@ -410,6 +411,16 @@ static bool Prefix(QuestPart_Choice.Choice ___localChoice) int index = part.choices.IndexOf(___localChoice); if (index >= 0) { + // Multifaction: only the owning faction claims its rewards + if (Multiplayer.GameComp.multifaction && + QuestFactionOwnership.GetOwner(part.quest) is { } owner && + owner != Multiplayer.RealPlayerFaction) + { + Messages.Message($"Only {owner.Name} can choose this quest's reward.", + MessageTypeDefOf.RejectInput, historical: false); + return false; + } + Choose(part, index); return false; } @@ -423,7 +434,24 @@ static bool Prefix(QuestPart_Choice.Choice ___localChoice) // Registered in SyncMethods.cs internal static void Choose(QuestPart_Choice part, int index) { - part.Choose(part.choices[index]); + // The synced command executes in the clicking player's faction context; + // reward actions must resolve against the quest owner's instead + var owner = Multiplayer.Client != null && Multiplayer.GameComp.multifaction + ? QuestFactionOwnership.GetOwner(part.quest) + : null; + + if (owner != null) + ((Map)null).PushFaction(owner); + + try + { + part.Choose(part.choices[index]); + } + finally + { + if (owner != null) + FactionExtensions.PopFaction(); + } } } diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index a8068091b..f95572c3e 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -5,6 +5,7 @@ using HarmonyLib; using LudeonTK; using Multiplayer.Client.AsyncTime; +using Multiplayer.Client.Desyncs; using Multiplayer.Common; using Multiplayer.Common.Networking.Packet; using RimWorld.Planet; @@ -258,7 +259,7 @@ private static void TickTickable(ITickable tickable) } catch (Exception e) { - Log.Error($"Exception during ticking {tickable}: {e}"); + SimulationFailures.Handle($"Exception during ticking {tickable}", e); } } } diff --git a/Source/Client/Patches/TimestampFixer.cs b/Source/Client/Patches/TimestampFixer.cs index 5526021d1..9ab9a34b2 100644 --- a/Source/Client/Patches/TimestampFixer.cs +++ b/Source/Client/Patches/TimestampFixer.cs @@ -33,6 +33,11 @@ static TimestampFixer() public static void FixPawn(Pawn p, Map oldMap, Map newMap) { + // Destroyed pawns don't need fixing, and deep-scribing them warns + // "Deep-saving destroyed thing" on every predator kill + if (p.Destroyed) + return; + var oldTime = oldMap?.AsyncTime().mapTicks ?? Multiplayer.AsyncWorldTime.worldTicks; var newTime = newMap?.AsyncTime().mapTicks ?? Multiplayer.AsyncWorldTime.worldTicks; currentOffset = newTime - oldTime; diff --git a/Source/Client/Persistent/RitualSession.cs b/Source/Client/Persistent/RitualSession.cs index b278b83c0..f27175ae9 100644 --- a/Source/Client/Persistent/RitualSession.cs +++ b/Source/Client/Persistent/RitualSession.cs @@ -1,4 +1,5 @@ using Multiplayer.API; +using Multiplayer.Client.Factions; using RimWorld; using Verse; @@ -30,6 +31,29 @@ public void Remove() [SyncMethod] public void Start() + { + // Multifaction: ritual side-effects (ideo development, letters) must + // resolve against the ritual's faction, not whoever invoked Start + var ownerFaction = Multiplayer.GameComp.multifaction + ? data.organizer?.Faction is { IsPlayer: true } f ? f + : map.ParentFaction is { IsPlayer: true } pf ? pf : null + : null; + + if (ownerFaction != null) + map.PushFaction(ownerFaction); + + try + { + StartInt(); + } + finally + { + if (ownerFaction != null) + map.PopFaction(); + } + } + + private void StartInt() { // Handle the same stuff as Dialog_BeginGravshipLaunch if (data.isGravshipRitual) diff --git a/Source/Client/Saving/ConvertToSp.cs b/Source/Client/Saving/ConvertToSp.cs index 1dc65d74a..299d6cec2 100644 --- a/Source/Client/Saving/ConvertToSp.cs +++ b/Source/Client/Saving/ConvertToSp.cs @@ -1,4 +1,5 @@ -using Verse; +using RimWorld; +using Verse; using Verse.Profile; namespace Multiplayer.Client.Saving; @@ -30,6 +31,15 @@ private static void PrepareSingleplayer() var spectator = Multiplayer.WorldComp.spectatorFaction; if (spectator != null) { + // Purge faction-keyed state first or the save writes dangling Faction_N refs + foreach (var map in Find.Maps) + map.pawnDestinationReservationManager.reservedDestinations.Remove(spectator); + + // Pawns stranded in the spectator faction would keep a dangling reference + foreach (var pawn in PawnsFinder.All_AliveOrDead) + if (pawn.Faction == spectator) + pawn.SetFaction(Faction.OfPlayer); + spectator.RemoveAllRelations(); Find.FactionManager.allFactions.Remove(spectator); } diff --git a/Source/Client/Saving/SavingPatches.cs b/Source/Client/Saving/SavingPatches.cs index 95832ff18..a242ff4cf 100644 --- a/Source/Client/Saving/SavingPatches.cs +++ b/Source/Client/Saving/SavingPatches.cs @@ -170,8 +170,14 @@ static bool Prefix(LoadedObjectDirectory __instance, ref ILoadReferenceable reff { text = reffable.GetUniqueLoadID(); } - catch + catch (Exception e) { + // Tolerated so one broken object doesn't abort the load, but every + // object after the first shares "[excepted]" and drops from + // registration - that shouldn't stay silent + Log.WarningOnce( + $"MP: GetUniqueLoadID threw for {reffable?.GetType().FullName ?? "null"}; object may not resolve cross-references: {e.Message}", + reffable?.GetType().GetHashCode() ?? 0); } return !__instance.allObjectsByLoadID.ContainsKey(text); diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 9bf2464c1..df05bc703 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -40,6 +40,11 @@ public class MultiplayerSession : IConnectionStatusListener public bool desynced; + // Synced commands/ticks that threw and were skipped this session - + // each one is a potential divergence. See SimulationFailures. + public int simulationFailures; + public string firstSimulationFailure; + public List pendingSteam = new(); public List knownUsers = new(); diff --git a/Source/Client/Syncing/Game/SyncActions.cs b/Source/Client/Syncing/Game/SyncActions.cs index b5b20a220..3ed6d9fc8 100644 --- a/Source/Client/Syncing/Game/SyncActions.cs +++ b/Source/Client/Syncing/Game/SyncActions.cs @@ -16,8 +16,7 @@ public static void Init() { void Error(string error) { - Multiplayer.loadingErrors = true; - Log.Error(error); + Multiplayer.LoadingError(error); } // TODO: Use MpMethodUtil instead if we decide to make it work with generic types/methods (already in MP Compat, so use it). Or remove this TODO if we decide not to. diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index cff9df04b..648823e27 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -24,13 +24,13 @@ public static void Init() SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_CaptureEntity), "GetOptionsFor", 2).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Capture entity // Other possible float menu options that could be added later: - // TODO maybe? TransporterUtility.InitiateLoading is not synced, not sure if significant + // Carry to shuttle: TransporterUtility.InitiateLoading is synced via a prefix in SyncMethods // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_CarryToShuttle), "GetSingleOptionFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Carry to shuttle // TODO: missing some flecks // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_DraftedAttack), "GetSingleOptionFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Drafted attack // TODO: missing fleck // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_Equip), "GetSingleOptionFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Equip - // TODO: uncertain if everything is synced, some things are possibly synced elsewhere, possibly not fully synced + // Start ritual: covered - both branches call the synced Precept_Ritual.ShowRitualBeginWindow // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_StartRitual), "GetOptionsFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Start ritual // TODO: missing fleck/mote // SyncDelegate.Lambda(typeof(FloatMenuOptionProvider_WorkGivers), "GetOptionsFor", 0).CancelIfAnyFieldNull().SetContext(mouseKeyContext); // Generic work givers @@ -296,7 +296,9 @@ private static void InitRituals() SyncDelegate.Lambda(typeof(LordJob_BestowingCeremony), nameof(LordJob_BestowingCeremony.GetPawnGizmos), 2); // Cancel ceremony SyncDelegate.Lambda(typeof(LordJob_BestowingCeremony), nameof(LordJob_BestowingCeremony.GetPawnGizmos), 0); // Make pawn leave ceremony - // TODO: May need to change this back to SynchMetho, but changes to 1.6 broke it. + // Verified against 1.6 assembly: ordinal 0 resolves to the dialog-opening + // lambda in <>c__DisplayClass7_0 and its closure fields (LordJob, LordToil) + // have implicit sync workers. The dialog becomes a RitualSession on open. SyncDelegate.Lambda(typeof(LordToil_BestowingCeremony_Wait), nameof(LordToil_BestowingCeremony_Wait.ExtraFloatMenuOptions), 0); // Begin bestowing float menu SyncMethod.Register(typeof(Command_BestowerCeremony), nameof(Command_BestowerCeremony.ProcessInput)); // Begin bestowing gizmo diff --git a/Source/Client/Syncing/Game/SyncGame.cs b/Source/Client/Syncing/Game/SyncGame.cs index f9c1bc366..136b4b38b 100644 --- a/Source/Client/Syncing/Game/SyncGame.cs +++ b/Source/Client/Syncing/Game/SyncGame.cs @@ -15,8 +15,7 @@ static void TryInit(string name, Action action) } catch (Exception e) { - Log.Error($"Exception during {name} initialization: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"Exception during {name} initialization: {e}"); } } diff --git a/Source/Client/Syncing/Game/SyncMarkers.cs b/Source/Client/Syncing/Game/SyncMarkers.cs index 4ce5e5140..65bb29154 100644 --- a/Source/Client/Syncing/Game/SyncMarkers.cs +++ b/Source/Client/Syncing/Game/SyncMarkers.cs @@ -9,6 +9,6 @@ public static class SyncMarkers [MpPrefix(typeof(MainTabWindow_Work), nameof(MainTabWindow_Work.DoManualPrioritiesCheckbox))] static void ManualPriorities_Prefix() => manualPriorities = true; - [MpPostfix(typeof(MainTabWindow_Work), nameof(MainTabWindow_Work.DoManualPrioritiesCheckbox))] - static void ManualPriorities_Postfix() => manualPriorities = false; + [MpFinalizer(typeof(MainTabWindow_Work), nameof(MainTabWindow_Work.DoManualPrioritiesCheckbox))] + static void ManualPriorities_Finalizer() => manualPriorities = false; } diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index 1b04e7881..f13ef34bf 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -515,6 +515,11 @@ public static void Init() crate.GetMultiSelectFloatMenuOptions(SyncUtil.prevSelected.OfType()).ExecuteEnumerable(); }); + // Faction/colony naming dialogs (opened from the multifaction sidebar); + // Named targets Faction.OfPlayer = the issuing player's faction in command context + SyncMethod.Register(typeof(NamePlayerFactionDialogUtility), nameof(NamePlayerFactionDialogUtility.Named)); + SyncMethod.Register(typeof(NamePlayerSettlementDialogUtility), nameof(NamePlayerSettlementDialogUtility.Named)); + // Double ExecuteWhenFinished ensures it'll load after MP Compat late patches, // so it will have registered all its sync workers already. LongEventHandler.ExecuteWhenFinished(() => LongEventHandler.ExecuteWhenFinished(() => @@ -545,10 +550,38 @@ static bool PastePawnTimetable(Pawn p) } [MpPrefix(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] - static void StorageSettingsClipboardCopy_Prefix() => Multiplayer.dontSync = true; + static void StorageSettingsClipboardCopy_Prefix(ref bool __state) + { + if (!Multiplayer.dontSync) + { + Multiplayer.dontSync = true; + __state = true; + } + } - [MpPostfix(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] - static void StorageSettingsClipboardCopy_Postfix() => Multiplayer.dontSync = false; + // Finalizer, not postfix: a throw in Copy would leave dontSync stuck on, + // silently disabling all syncing for the session + [MpFinalizer(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] + static void StorageSettingsClipboardCopy_Finalizer(bool __state) + { + if (__state) + Multiplayer.dontSync = false; + } + + // "Carry to shuttle" calls InitiateLoading from interface code, mutating + // loading state on the clicking client only (the job order itself already + // syncs via TryTakeOrderedJob) + [MpPrefix(typeof(TransporterUtility), nameof(TransporterUtility.InitiateLoading))] + static bool InitiateLoading_Prefix(IEnumerable transporters) + { + if (!Multiplayer.ShouldSync) return true; + SyncedInitiateLoading(transporters.ToList()); + return false; + } + + [SyncMethod] + static void SyncedInitiateLoading(List transporters) => + TransporterUtility.InitiateLoading(transporters); [MpTranspiler(typeof(CompPlantable), nameof(CompPlantable.BeginTargeting), lambdaOrdinal: 0)] static IEnumerable CompPlantableTranspiler(IEnumerable insts) @@ -705,7 +738,8 @@ static void AddBill_Prefix(Bill bill) [MpPostfix(typeof(Ideo), nameof(Ideo.CopyTo))] static void FixIdeoAfterCopy(Ideo __instance, Ideo ideo) { - if (Multiplayer.ExecutingCmds) + // Also during ticking: ideo reform can complete from ritual outcome toils + if (Multiplayer.ExecutingCmds || Multiplayer.Ticking) { // Fix ids for precepts generated by the fluid ideo reforming UI foreach (var precept in ideo.PreceptsListForReading) diff --git a/Source/Client/Syncing/Game/ThingFilterMarkers.cs b/Source/Client/Syncing/Game/ThingFilterMarkers.cs index 98651e066..8f9599a0c 100644 --- a/Source/Client/Syncing/Game/ThingFilterMarkers.cs +++ b/Source/Client/Syncing/Game/ThingFilterMarkers.cs @@ -37,56 +37,56 @@ static void TabStorageFillTab_Prefix(ITab_Storage __instance) DrawnThingFilter = new TabStorageWrapper(selParent); } - [MpPostfix(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] - static void TabStorageFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] + static void TabStorageFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] static void BillConfig_Prefix(Dialog_BillConfig __instance) => DrawnThingFilter = new BillConfigWrapper(__instance.bill); - [MpPostfix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] - static void BillConfig_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] + static void BillConfig_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] static void ManageOutfit_Prefix(Dialog_ManageApparelPolicies __instance) => DrawnThingFilter = new OutfitWrapper(__instance.SelectedPolicy); - [MpPostfix(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] - static void ManageOutfit_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] + static void ManageOutfit_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] static void ManageFoodRestriction_Prefix(Dialog_ManageFoodPolicies __instance) => DrawnThingFilter = new FoodRestrictionWrapper(__instance.SelectedPolicy); - [MpPostfix(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] - static void ManageFoodRestriction_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] + static void ManageFoodRestriction_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] static void TabPenAutocutFillTab_Prefix(ITab_PenAutoCut __instance) => DrawnThingFilter = new PenAutocutWrapper(__instance.SelectedCompAnimalPenMarker); - [MpPostfix(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] - static void TabPenAutocutFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] + static void TabPenAutocutFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] static void TabPenAnimalsFillTab_Prefix(ITab_PenAnimals __instance) => DrawnThingFilter = new PenAnimalsWrapper(__instance.SelectedCompAnimalPenMarker); - [MpPostfix(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] - static void TabPenAnimalsFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] + static void TabPenAnimalsFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] static void TabWindTurbineAutocutFillTab_Prefix(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = new DefaultAutocutWrapper(__instance.AutoCut); - [MpPostfix(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] - static void TabWindTurbineAutocutFillTab_Postfix(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] + static void TabWindTurbineAutocutFillTab_Finalizer(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = null; [MpPrefix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] static void ThingFilterUI_Prefix() => drawingThingFilter = true; - [MpPostfix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] - static void ThingFilterUI_Postfix() => drawingThingFilter = false; + [MpFinalizer(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] + static void ThingFilterUI_Finalizer() => drawingThingFilter = false; // Reading policies need special handling as they draw two ThingFilters private static ReadingPolicy drawnReadingPolicy; @@ -94,8 +94,8 @@ static void TabWindTurbineAutocutFillTab_Prefix(ITab_WindTurbineAutoCut __instan [MpPrefix(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] static void Dialog_ManageReadingPolicies_Prefix(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = __instance.SelectedPolicy; - [MpPostfix(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] - static void Dialog_ManageReadingPolicies_Postfix(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = null; + [MpFinalizer(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] + static void Dialog_ManageReadingPolicies_Finalizer(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = null; [MpPrefix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] static void ThingFilterUI_ReadingPolicy_Prefix(ThingFilter filter) @@ -107,8 +107,8 @@ static void ThingFilterUI_ReadingPolicy_Prefix(ThingFilter filter) DrawnThingFilter = new ReadingPolicyEffectFilterWrapper(drawnReadingPolicy); } - [MpPostfix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] - static void ThingFilterUI_ReadingPolicy_Postfix(ThingFilter filter) + [MpFinalizer(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] + static void ThingFilterUI_ReadingPolicy_Finalizer(ThingFilter filter) { if (drawnReadingPolicy != null && drawnReadingPolicy.defFilter == filter) DrawnThingFilter = null; diff --git a/Source/Client/Syncing/Sync.cs b/Source/Client/Syncing/Sync.cs index 50b7cc7be..a4912bc2b 100644 --- a/Source/Client/Syncing/Sync.cs +++ b/Source/Client/Syncing/Sync.cs @@ -147,8 +147,7 @@ internal static void RegisterAllAttributes(Assembly asm) } catch (Exception e) { - Log.Error($"Exception registering SyncMethod {type}::{method} by attribute: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"Exception registering SyncMethod {type}::{method} by attribute: {e}"); } } @@ -161,8 +160,7 @@ internal static void RegisterAllAttributes(Assembly asm) } catch (Exception e) { - Log.Error($"Exception registering SyncField by attribute: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"Exception registering SyncField by attribute: {e}"); } } } @@ -397,8 +395,7 @@ public static void ValidateAll() handler.Validate(); } catch (Exception e) { - Log.Error($"{handler} validation failed: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"{handler} validation failed: {e}"); } } } diff --git a/Source/Client/Syncing/SyncFieldUtil.cs b/Source/Client/Syncing/SyncFieldUtil.cs index 5a42405dd..4dc4bbc86 100644 --- a/Source/Client/Syncing/SyncFieldUtil.cs +++ b/Source/Client/Syncing/SyncFieldUtil.cs @@ -163,8 +163,7 @@ internal static void ApplyWatchFieldPatches(Type type) { Multiplayer.harmony.PatchMeasure(attr.Method, prefix, postfix); } catch (Exception e) { - Log.Error($"FAIL: {attr.Method.DeclaringType.FullName}:{attr.Method.Name} with {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"FAIL: {attr.Method.DeclaringType.FullName}:{attr.Method.Name} with {e}"); } } } diff --git a/Source/Client/UI/IngameDebug.cs b/Source/Client/UI/IngameDebug.cs index 9f76da958..b929769d4 100644 --- a/Source/Client/UI/IngameDebug.cs +++ b/Source/Client/UI/IngameDebug.cs @@ -37,6 +37,8 @@ internal static void DoDebugPrintout() text.AppendLine($"Tick Until: {TickPatch.tickUntil}{separator}Remote: {Multiplayer.session?.remoteTickUntil ?? 0}"); text.AppendLine($"Received Commands: {Multiplayer.session?.receivedCmds ?? 0}"); text.AppendLine($"Sent Commands: {Multiplayer.session?.remoteSentCmds ?? 0}"); + text.AppendLine($"Sim Failures: {Multiplayer.session?.simulationFailures ?? 0}" + + (Multiplayer.session?.firstSimulationFailure is { } first ? $"{separator}First: {first}" : "")); text.AppendLine($"\nFaction ID: {Faction.OfPlayer.loadID} ({FactionContext.stack.Count}){separator}Real Faction ID: {Multiplayer.RealPlayerFaction?.loadID ?? -1}"); text.AppendLine($"Next Thing ID: {Find.UniqueIDsManager.nextThingID}{separator}Next Job ID: {Find.UniqueIDsManager.nextJobID}"); diff --git a/Source/Client/UI/MainMenuPatches.cs b/Source/Client/UI/MainMenuPatches.cs index 246b66e80..ce577b7be 100644 --- a/Source/Client/UI/MainMenuPatches.cs +++ b/Source/Client/UI/MainMenuPatches.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using HarmonyLib; using Multiplayer.Client.Saving; @@ -29,6 +30,28 @@ public static class MainMenu_AddHeight [HarmonyPatch(typeof(OptionListingUtility), nameof(OptionListingUtility.DrawOptionListing))] public static class MainMenuPatch { + // Playing with failed patches/registrations near-guarantees desyncs or + // broken features - confirm before hosting/browsing + internal static void OpenWithInitErrorCheck(Action open) + { + if (!Multiplayer.loadingErrors) + { + open(); + return; + } + + const int maxShown = 8; + var errors = Multiplayer.loadingErrorsList; + var shown = errors.Take(maxShown).Select(e => " - " + e.Split('\n')[0].Trim()); + var text = $"Multiplayer failed to initialize {errors.Count} of its patches or sync registrations. " + + "Playing multiplayer in this state is likely to desync or break features.\n\n" + + string.Join("\n", shown) + + (errors.Count > maxShown ? $"\n ...and {errors.Count - maxShown} more" : "") + + "\n\nFull stack traces are in the log. Continue anyway?"; + + Find.WindowStack.Add(Dialog_MessageBox.CreateConfirmation(text, open, true, layer: WindowLayer.Super)); + } + static void Prefix(Rect rect, List optList) { if (!MainMenuMarker.drawing) return; @@ -55,8 +78,11 @@ static void Prefix(Rect rect, List optList) GUIUtility.systemCopyBuffer = version; else { - Find.WindowStack.Add(new ServerBrowser()); - VersionChecker.OpenNewVersionDialogIfApplicable(); + OpenWithInitErrorCheck(() => + { + Find.WindowStack.Add(new ServerBrowser()); + VersionChecker.OpenNewVersionDialogIfApplicable(); + }); } })); } @@ -67,7 +93,8 @@ static void Prefix(Rect rect, List optList) if (Multiplayer.session == null) optList.Insert(0, new ListableOption( "MpHostServer".Translate(), - () => Find.WindowStack.Add(new HostWindow() { layer = WindowLayer.Super }) + () => OpenWithInitErrorCheck(() => + Find.WindowStack.Add(new HostWindow() { layer = WindowLayer.Super })) )); if (MpVersion.IsDebug && Multiplayer.IsReplay) diff --git a/Source/Client/Util/Extensions.cs b/Source/Client/Util/Extensions.cs index 1be34d424..faf6e3129 100644 --- a/Source/Client/Util/Extensions.cs +++ b/Source/Client/Util/Extensions.cs @@ -182,7 +182,7 @@ public static void TryKill(this Process process) { process.Kill(); } - catch { } + catch { } // best-effort shutdown; the process may already have exited } /// diff --git a/Source/Client/Util/MpPatch.cs b/Source/Client/Util/MpPatch.cs index cce8bdea6..aa2fd0eee 100644 --- a/Source/Client/Util/MpPatch.cs +++ b/Source/Client/Util/MpPatch.cs @@ -120,7 +120,8 @@ public static List DoMpPatches(this Harmony harmony, Type type) toPatch, (attr is MpPrefix) ? patch : null, (attr is MpPostfix) ? patch : null, - (attr is MpTranspiler) ? patch : null + (attr is MpTranspiler) ? patch : null, + (attr is MpFinalizer) ? patch : null ); } @@ -129,8 +130,7 @@ public static List DoMpPatches(this Harmony harmony, Type type) } catch (Exception e) { - Log.Error($"MpPatch {m.DeclaringType}.{m.Name} failed with exception: {e}"); - Multiplayer.loadingErrors = true; + Multiplayer.LoadingError($"MpPatch {m.DeclaringType}.{m.Name} failed with exception: {e}"); } } } @@ -189,6 +189,30 @@ public MpPostfix(Type parentType, string parentMethod, int lambdaOrdinal) : base } } + /// + /// Finalizer method attribute. Runs even when the patched method throws, + /// unlike a postfix - use for cleanup of global state set in a prefix. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class MpFinalizer : MpPatch + { + public MpFinalizer(string typeName, string method) : base(typeName, method) + { + } + + public MpFinalizer(Type type, string method, Type[] argTypes = null) : base(type, method, argTypes) + { + } + + public MpFinalizer(Type type, string innerType, string method) : base(type, innerType, method) + { + } + + public MpFinalizer(Type parentType, string parentMethod, int lambdaOrdinal) : base(parentType, parentMethod, lambdaOrdinal) + { + } + } + /// /// Transpiler method attribute /// diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index 9e94cb170..1b4a31459 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -12,7 +12,7 @@ - + diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 7d3940f8b..7a2834ad3 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -32,6 +32,10 @@ static MultiplayerServer() public const char EndpointSeparator = '&'; public const int NetTicksPerSecond = 30; // Not an exact amount. The net loop isn't particularly precise. + // How long a client may stay behind the pause gate before the whole + // simulation pauses for it (net ticks, ~45s real time) + public const int PauseGraceNetTicks = 45 * NetTicksPerSecond; + public static readonly Regex UsernamePattern = new(@"^[a-zA-Z0-9_]+$"); public WorldData worldData; @@ -120,7 +124,24 @@ public void Run() while (realTime > 0 && ticked < 2) { playersBehind.Clear(); - playersBehind.AddRange(PlayingIngamePlayers.Where(p => p.ExtrapolatedTicksBehind > 90)); + foreach (var p in PlayingIngamePlayers) + { + if (p.ExtrapolatedTicksBehind > 90) + { + if (p.behindSinceNetTimer < 0) + p.behindSinceNetTimer = NetTimer; + + // Tolerate a lagging/tabbed-out client for a grace period + // before pausing everyone; they catch up on return + if (NetTimer - p.behindSinceNetTimer > PauseGraceNetTicks) + playersBehind.Add(p); + } + else + { + p.behindSinceNetTimer = -1; + } + } + if (!freezeManager.Frozen && PlayingPlayers.Any(p => p.ExtrapolatedTicksBehind < 40) && !playersBehind.Any()) diff --git a/Source/Common/ServerPlayer.cs b/Source/Common/ServerPlayer.cs index f1a231c97..d28c14e0e 100644 --- a/Source/Common/ServerPlayer.cs +++ b/Source/Common/ServerPlayer.cs @@ -19,6 +19,9 @@ public class ServerPlayer : IChatSource public int ticksBehindReceivedAt; public int ExtrapolatedTicksBehind => ticksBehind + (Server.gameTimer - ticksBehindReceivedAt); + // NetTimer value when this player first exceeded the pause gate, -1 while keeping up + public int behindSinceNetTimer = -1; + public ulong steamId; public string steamPersonaName = ""; diff --git a/Source/MultiplayerLoader/MultiplayerLoader.csproj b/Source/MultiplayerLoader/MultiplayerLoader.csproj index 018083c71..1ffe0a20b 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.csproj +++ b/Source/MultiplayerLoader/MultiplayerLoader.csproj @@ -10,7 +10,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt index b87199a17..08c775816 100644 --- a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt @@ -1,3 +1,3 @@ This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object -05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-00-24-00-00-00-63-64-60-60-10-00-E2-A2-F2-DC-12-BD-DC-D2-9C-92-CC-82-9C-C4-CA-D4-22-6E-A0-98-2F-82-0B-E4-81-01-33-98-04-00 (117 bytes) +05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (119 bytes) diff --git a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt index 98e950920..0e11269af 100644 --- a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt @@ -1,3 +1,3 @@ This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object -08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-00-24-00-00-00-63-64-60-60-10-00-E2-A2-F2-DC-12-BD-DC-D2-9C-92-CC-82-9C-C4-CA-D4-22-6E-A0-98-2F-82-0B-E4-81-01-33-98-04-00 (95 bytes) +08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (97 bytes) From b85c35d97d3ceeb1ffeec097bc4beb0b09b48ed6 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Sun, 19 Jul 2026 13:25:41 +0200 Subject: [PATCH 02/27] Fix ideo reconstruction, bossgroup cooldown, and pause-grace resets - Guard null ideo development when reconstructing custom ideos (non-fluid .rid ideos have no development tracker) - Gate the bossgroup copy-in on multifaction so plain MP keeps the vanilla component live instead of stamping a stale cooldown over it each map tick - Reset the behind-timer when a player leaves Playing status so a rejoin or desync gets a fresh pause grace, with unit tests covering the timer lifecycle (PauseGraceTest) - Exclude the spectator faction from ritual session owner resolution - Normalize lone carriage returns in the synced config comparison --- .../Client/Comp/World/FactionBossgroupData.cs | 3 +- .../Client/Comp/World/MultiplayerWorldComp.cs | 9 +- Source/Client/Factions/FactionCreator.cs | 6 +- Source/Client/Factions/IncidentRouting.cs | 7 +- Source/Client/Networking/JoinData.cs | 2 +- Source/Client/Persistent/RitualSession.cs | 7 +- Source/Client/Saving/SavingPatches.cs | 7 +- Source/Client/Syncing/Game/SyncMethods.cs | 8 +- Source/Common/MultiplayerServer.cs | 52 +++-- Source/Tests/PauseGraceTest.cs | 185 ++++++++++++++++++ 10 files changed, 249 insertions(+), 37 deletions(-) create mode 100644 Source/Tests/PauseGraceTest.cs diff --git a/Source/Client/Comp/World/FactionBossgroupData.cs b/Source/Client/Comp/World/FactionBossgroupData.cs index 81dc727f6..8607a6496 100644 --- a/Source/Client/Comp/World/FactionBossgroupData.cs +++ b/Source/Client/Comp/World/FactionBossgroupData.cs @@ -8,7 +8,8 @@ namespace Multiplayer.Client; // Multifaction: GameComponent_Bossgroup state is a game singleton - one player's // bossgroup call escalated threat difficulty and cooldown for every faction. // Dict/list swap by reference on faction switch; the cooldown int is copied in -// and written back by the postfix on its single writer. +// and written back by the postfix on its single writer. Copy-in and write-back +// are both multifaction-gated: plain MP leaves the vanilla component untouched. public class FactionBossgroupData : IExposable { public int lastBossgroupCalled = -9999999; diff --git a/Source/Client/Comp/World/MultiplayerWorldComp.cs b/Source/Client/Comp/World/MultiplayerWorldComp.cs index 6f9d84be6..1c05c4df1 100644 --- a/Source/Client/Comp/World/MultiplayerWorldComp.cs +++ b/Source/Client/Comp/World/MultiplayerWorldComp.cs @@ -178,9 +178,12 @@ public void SetFaction(Faction faction) if (data.analysisManager != null) game.analysisManager = data.analysisManager; - // Bossgroup component state: dict/list swap by reference, the - // cooldown int is copied in (written back by BossgroupLastCalledWriteBack) - if (data.bossgroup != null && game.GetComponent() is { } bossgroups) + // Bossgroup component state: dict/list swap by reference, the cooldown + // int is copied in (written back by BossgroupLastCalledWriteBack). + // Multifaction only - in plain MP the vanilla component stays live and + // this would stamp a stale cooldown over it every forced context push + if (Multiplayer.GameComp.multifaction && + data.bossgroup != null && game.GetComponent() is { } bossgroups) { bossgroups.timesCalledBossgroups = data.bossgroup.timesCalledBossgroups; bossgroups.killedBosses = data.bossgroup.killedBosses; diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index a7917c8c0..49562e1f1 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -271,8 +271,10 @@ private static Ideo ReconstructCustomIdeo(byte[] data) precept.ideo = ideo; } - // Same back-reference fixups as FixIdeoAfterCopy - ideo.development.ideo = ideo; + // Same back-reference fixups as FixIdeoAfterCopy. development is null + // for non-fluid ideos; style always exists + if (ideo.development != null) + ideo.development.ideo = ideo; ideo.style.ideo = ideo; return ideo; diff --git a/Source/Client/Factions/IncidentRouting.cs b/Source/Client/Factions/IncidentRouting.cs index 66c039556..d0c93b504 100644 --- a/Source/Client/Factions/IncidentRouting.cs +++ b/Source/Client/Factions/IncidentRouting.cs @@ -18,13 +18,14 @@ static bool Prefix(IncidentWorker __instance, IncidentParms parms, ref bool __re if (parms.target is Map { ParentFaction: { IsPlayer: true } owner } && owner != Faction.OfPlayer) { - // Spectator context is how quest-fired incidents execute in async-off - // games - log but let through to avoid eating quest raids + // QuestFactionOwnership now routes quest-fired incidents through + // the owner's context - spectator context here is a residual gap. + // Log it but let it through rather than eat a quest raid. if (Faction.OfPlayer == Multiplayer.WorldComp.spectatorFaction) { MpLog.Log( $"Spectator-context incident {__instance.def?.defName} targeting map of " + - $"{owner.Name} - candidate for quest faction routing (61-quests-review.md)"); + $"{owner.Name} - quest-ownership routing gap (61-quests-review.md)"); return true; } diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index ad0585135..976bc96bf 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -87,7 +87,7 @@ public static bool CompareToLocal(RemoteData remote) private static bool ConfigsEquivalent(IEnumerable a, IEnumerable b) { static ModConfig Normalize(ModConfig c) => - c with { Contents = c.Contents?.Replace("\r\n", "\n") }; + c with { Contents = c.Contents?.Replace("\r\n", "\n").Replace("\r", "\n") }; return a.Select(Normalize).EqualAsSets(b.Select(Normalize)); } diff --git a/Source/Client/Persistent/RitualSession.cs b/Source/Client/Persistent/RitualSession.cs index f27175ae9..99b5abf03 100644 --- a/Source/Client/Persistent/RitualSession.cs +++ b/Source/Client/Persistent/RitualSession.cs @@ -33,10 +33,11 @@ public void Remove() public void Start() { // Multifaction: ritual side-effects (ideo development, letters) must - // resolve against the ritual's faction, not whoever invoked Start + // resolve against the ritual's faction, not whoever invoked Start. + // Ownable excludes the spectator faction (IsPlayer matches it) var ownerFaction = Multiplayer.GameComp.multifaction - ? data.organizer?.Faction is { IsPlayer: true } f ? f - : map.ParentFaction is { IsPlayer: true } pf ? pf : null + ? data.organizer?.Faction is { } f && QuestFactionOwnership.IsOwnablePlayerFaction(f) ? f + : map.ParentFaction is { } pf && QuestFactionOwnership.IsOwnablePlayerFaction(pf) ? pf : null : null; if (ownerFaction != null) diff --git a/Source/Client/Saving/SavingPatches.cs b/Source/Client/Saving/SavingPatches.cs index a242ff4cf..9c5b6ff7f 100644 --- a/Source/Client/Saving/SavingPatches.cs +++ b/Source/Client/Saving/SavingPatches.cs @@ -172,9 +172,10 @@ static bool Prefix(LoadedObjectDirectory __instance, ref ILoadReferenceable reff } catch (Exception e) { - // Tolerated so one broken object doesn't abort the load, but every - // object after the first shares "[excepted]" and drops from - // registration - that shouldn't stay silent + // Tolerated so one broken object doesn't abort the load, but a + // throwing object silently never registers (vanilla's per-object + // try/catch skips it; "[excepted]" is only a message placeholder) + // - that shouldn't stay silent Log.WarningOnce( $"MP: GetUniqueLoadID threw for {reffable?.GetType().FullName ?? "null"}; object may not resolve cross-references: {e.Message}", reffable?.GetType().GetHashCode() ?? 0); diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index f13ef34bf..a0da09d18 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -570,7 +570,9 @@ static void StorageSettingsClipboardCopy_Finalizer(bool __state) // "Carry to shuttle" calls InitiateLoading from interface code, mutating // loading state on the clicking client only (the job order itself already - // syncs via TryTakeOrderedJob) + // syncs via TryTakeOrderedJob). The skipped original's int return (new + // group id) is default on the sync path - the real id is allocated later + // inside the synced command; all vanilla call sites discard it [MpPrefix(typeof(TransporterUtility), nameof(TransporterUtility.InitiateLoading))] static bool InitiateLoading_Prefix(IEnumerable transporters) { @@ -746,7 +748,9 @@ static void FixIdeoAfterCopy(Ideo __instance, Ideo ideo) if (precept.ID < 0) precept.ID = Find.UniqueIDsManager.GetNextPreceptID(); - ideo.development.ideo = ideo; + // CopyTo only creates development for fluid ideos - guard non-fluid copies + if (ideo.development != null) + ideo.development.ideo = ideo; ideo.style.ideo = ideo; } } diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 7a2834ad3..0f7c1e3f8 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -123,24 +123,7 @@ public void Run() int ticked = 0; while (realTime > 0 && ticked < 2) { - playersBehind.Clear(); - foreach (var p in PlayingIngamePlayers) - { - if (p.ExtrapolatedTicksBehind > 90) - { - if (p.behindSinceNetTimer < 0) - p.behindSinceNetTimer = NetTimer; - - // Tolerate a lagging/tabbed-out client for a grace period - // before pausing everyone; they catch up on return - if (NetTimer - p.behindSinceNetTimer > PauseGraceNetTicks) - playersBehind.Add(p); - } - else - { - p.behindSinceNetTimer = -1; - } - } + EvaluatePlayersBehind(playersBehind); if (!freezeManager.Frozen && PlayingPlayers.Any(p => p.ExtrapolatedTicksBehind < 40) && @@ -193,7 +176,38 @@ public void Run() } } - private void TickNet() + internal void EvaluatePlayersBehind(List playersBehind) + { + playersBehind.Clear(); + foreach (var p in JoinedPlayers) + { + // Only actively playing players hold the pause gate; anyone + // loading, rejoining or desynced gets the timer reset so the + // grace restarts fresh if they return still behind + if (p.status != PlayerStatus.Playing || !p.IsPlaying) + { + p.behindSinceNetTimer = -1; + continue; + } + + if (p.ExtrapolatedTicksBehind > 90) + { + if (p.behindSinceNetTimer < 0) + p.behindSinceNetTimer = NetTimer; + + // Tolerate a lagging/tabbed-out client for a grace period + // before pausing everyone; they catch up on return + if (NetTimer - p.behindSinceNetTimer > PauseGraceNetTicks) + playersBehind.Add(p); + } + else + { + p.behindSinceNetTimer = -1; + } + } + } + + internal void TickNet() { NetTimer++; diff --git a/Source/Tests/PauseGraceTest.cs b/Source/Tests/PauseGraceTest.cs new file mode 100644 index 000000000..cef773b65 --- /dev/null +++ b/Source/Tests/PauseGraceTest.cs @@ -0,0 +1,185 @@ +using Multiplayer.Common; + +namespace Tests; + +[TestFixture] +public class PauseGraceTest +{ + private MultiplayerServer server = null!; + private int nextPlayerId; + private readonly List playersBehind = new(); + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + nextPlayerId = 1; + playersBehind.Clear(); + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private ServerPlayer AddPlayingPlayer(string username) + { + var conn = new DummyConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn); + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + player.UpdateStatus(PlayerStatus.Playing); + return player; + } + + private void AdvanceNetTicks(int ticks) + { + for (int i = 0; i < ticks; i++) + server.TickNet(); + } + + private void Evaluate() => server.EvaluatePlayersBehind(playersBehind); + + // ExtrapolatedTicksBehind = ticksBehind + (gameTimer - ticksBehindReceivedAt); + // gameTimer never advances in these tests, so ticksBehind is the whole value + private static void SetBehind(ServerPlayer player, int ticks) + { + player.ticksBehind = ticks; + player.ticksBehindReceivedAt = 0; + } + + [Test] + public void Behind_WithinGrace_NoPause() + { + var player = AddPlayingPlayer("player"); + SetBehind(player, 100); + + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.GreaterThanOrEqualTo(0), "timer should arm"); + Assert.That(playersBehind, Is.Empty); + + // Grace uses a strict > comparison: exactly PauseGraceNetTicks elapsed is still within grace + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks); + Evaluate(); + Assert.That(playersBehind, Is.Empty); + } + + [Test] + public void Behind_PastGrace_PauseFires() + { + var player = AddPlayingPlayer("player"); + SetBehind(player, 100); + + Evaluate(); + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 1); + Evaluate(); + + Assert.That(playersBehind, Is.EqualTo(new[] { player })); + } + + [Test] + public void Recovery_ResetsTimer_GraceRestartsOnRelapse() + { + var player = AddPlayingPlayer("player"); + SetBehind(player, 100); + Evaluate(); + AdvanceNetTicks(600); + + SetBehind(player, 0); + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.EqualTo(-1), "recovery should reset the timer"); + + // Falling behind again must arm a fresh grace, not resume the old one + SetBehind(player, 100); + Evaluate(); + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks); + Evaluate(); + Assert.That(playersBehind, Is.Empty); + + AdvanceNetTicks(1); + Evaluate(); + Assert.That(playersBehind, Is.EqualTo(new[] { player })); + } + + [Test] + public void RejoinWhileArmed_GraceRestarts() + { + var player = AddPlayingPlayer("player"); + SetBehind(player, 100); + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.GreaterThanOrEqualTo(0)); + + // Rejoin changes only the connection state - status stays Playing. + // Start a join point first (as PlayerManager does on rejoin) so the + // loading state genuinely pends instead of completing synchronously. + server.worldData.TryStartJoinPointCreation(force: true); + player.conn.ChangeState(ConnectionStateEnum.ServerLoading); + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 100); + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.EqualTo(-1), "timer should reset while rejoining"); + Assert.That(playersBehind, Is.Empty); + + // Back in play, still behind: fresh grace, no instant pause + player.conn.ChangeState(ConnectionStateEnum.ServerPlaying); + Evaluate(); + Assert.That(playersBehind, Is.Empty, "returning player must get a fresh grace"); + + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 1); + Evaluate(); + Assert.That(playersBehind, Is.EqualTo(new[] { player })); + } + + [Test] + public void DesyncWhileArmed_GraceRestarts() + { + var player = AddPlayingPlayer("player"); + SetBehind(player, 100); + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.GreaterThanOrEqualTo(0)); + + // Desync changes the status but not the connection state + player.UpdateStatus(PlayerStatus.Desynced); + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 100); + Evaluate(); + Assert.That(player.behindSinceNetTimer, Is.EqualTo(-1), "timer should reset while desynced"); + + player.UpdateStatus(PlayerStatus.Playing); + Evaluate(); + Assert.That(playersBehind, Is.Empty, "recovered player must get a fresh grace"); + + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 1); + Evaluate(); + Assert.That(playersBehind, Is.EqualTo(new[] { player })); + } + + [Test] + public void TimersArePerPlayer() + { + var laggard = AddPlayingPlayer("laggard"); + var healthy = AddPlayingPlayer("healthy"); + SetBehind(laggard, 100); + SetBehind(healthy, 0); + + Evaluate(); + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 1); + Evaluate(); + Assert.That(playersBehind, Is.EqualTo(new[] { laggard })); + Assert.That(healthy.behindSinceNetTimer, Is.EqualTo(-1)); + + // The second player arms its own timer at the current NetTimer, not the first player's + SetBehind(healthy, 100); + Evaluate(); + Assert.That(playersBehind, Is.EqualTo(new[] { laggard }), "healthy player is within its own grace"); + + AdvanceNetTicks(MultiplayerServer.PauseGraceNetTicks + 1); + Evaluate(); + Assert.That(playersBehind, Is.EquivalentTo(new[] { laggard, healthy })); + } +} From 4c424d33832eb0ccfa2e4adef37ac0c3154c9d28 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Sun, 19 Jul 2026 18:27:20 +0200 Subject: [PATCH 03/27] Fix live-session desync causes and quest routing Fixes from the first multiplayer group session on this branch: - Blood Animations compat: isolate its effect rand rolls from the synced stream and suppress its filth spawns in MP (settings-gated and render-path driven; the cause of every desync in the session's reports) - Declare Ideology TreeSighting references before save compression so sighted wild trees stop being compressed away (vanilla 1.6 gap) - Quest cache modification during quest ticking: a quest ending during its own tick removes itself from the async cache mid-enumeration - Quest map selection: multifaction quests no longer bind another player faction's map (refugees arrived on the wrong colony) - Register runtime-created lords in the shared cross-ref directory so synced ceremony jobs resolve their lord --- .../Client/AsyncTime/MultiplayerAsyncQuest.cs | 10 ++- .../Client/Factions/QuestFactionOwnership.cs | 17 ++++ .../Client/Patches/BloodAnimationsCompat.cs | 77 +++++++++++++++++++ Source/Client/Saving/LordCrossRefs.cs | 28 +++++++ Source/Client/Saving/SaveCompression.cs | 28 +++++++ 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 Source/Client/Patches/BloodAnimationsCompat.cs create mode 100644 Source/Client/Saving/LordCrossRefs.cs diff --git a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs index 883980f39..8cc75e6c0 100644 --- a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs +++ b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs @@ -248,11 +248,15 @@ public static void TickMapQuests(AsyncTimeComp mapAsyncTimeComp) /// Runs QuestTick() on all quests passed /// /// Quests to run QuestTick() on - private static void TickQuests(IEnumerable quests) + private static void TickQuests(List quests) { - foreach (var quest in quests) + // Index loop, not foreach: a quest ending during its own tick + // removes itself from this list via RemoveQuestFromCacheOnQuestEnd + // (live find: InvalidOperationException in AsyncTimeComp ticking). + // Removals are deterministic, so any skip is identical on all clients. + for (int i = 0; i < quests.Count; i++) { - quest.QuestTick(); + quests[i].QuestTick(); } } diff --git a/Source/Client/Factions/QuestFactionOwnership.cs b/Source/Client/Factions/QuestFactionOwnership.cs index 3789ad3d1..21f5a55f2 100644 --- a/Source/Client/Factions/QuestFactionOwnership.cs +++ b/Source/Client/Factions/QuestFactionOwnership.cs @@ -140,3 +140,20 @@ static void Postfix(Quest quest) Multiplayer.WorldComp?.questOwnership.Remove(quest.id); } } + +// Vanilla QuestNode_GetMap picks any IsPlayerHome map, so one faction's +// quest could bind another player's colony. Generation context = owner; +// reject other player factions' maps (neutral/site maps stay acceptable). +[HarmonyPatch(typeof(RimWorld.QuestGen.QuestNode_GetMap), "IsAcceptableMap")] +static class QuestMapMatchesGeneratingFaction +{ + static void Postfix(Map map, ref bool __result) + { + if (!__result || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer) && + map.ParentFaction is { IsPlayer: true } && map.ParentFaction != Faction.OfPlayer) + __result = false; + } +} diff --git a/Source/Client/Patches/BloodAnimationsCompat.cs b/Source/Client/Patches/BloodAnimationsCompat.cs new file mode 100644 index 000000000..f3c15dd75 --- /dev/null +++ b/Source/Client/Patches/BloodAnimationsCompat.cs @@ -0,0 +1,77 @@ +using HarmonyLib; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Patches; + +// Blood Animations rolls the synced Rand stream inside its effect patches +// behind per-client gates (mod settings, frame-driven fleck updates) and +// spawns filth from the render path - a confirmed desync source. In MP, +// isolate its rand usage and suppress its filth; visuals still play locally. +[StaticConstructorOnStartup] +static class BloodAnimationsCompat +{ + private static int effectDepth; + + static BloodAnimationsCompat() + { + if (AccessTools.TypeByName("BloodAnimations.FleckThrownBlood") == null) + return; + + var targets = new (string typeName, string methodName)[] + { + ("BloodAnimations.DamageWorker_AddInjury_ApplyToPawn", "ApplyToPawn"), + ("BloodAnimations.Pawn_HealthTracker_HealthTick", "HealthTick"), + ("BloodAnimations.Pawn_Kill", "Kill"), + ("BloodAnimations.Verb_TryCastNextBurstShot", "TryCastNextBurstShot"), + ("BloodAnimations.FleckThrownBlood", "TimeInterval"), + }; + + var prefix = new HarmonyMethod(typeof(BloodAnimationsCompat), nameof(EffectPrefix)); + var finalizer = new HarmonyMethod(typeof(BloodAnimationsCompat), nameof(EffectFinalizer)); + + foreach (var (typeName, methodName) in targets) + { + var method = AccessTools.Method(AccessTools.TypeByName(typeName), methodName); + if (method == null) + { + Log.Warning($"MP: Blood Animations compat found no {typeName}.{methodName} - mod version mismatch? That wrap skipped."); + continue; + } + + Multiplayer.harmony.Patch(method, prefix: prefix, finalizer: finalizer); + } + + var tryMakeFilth = AccessTools.Method(typeof(FilthMaker), nameof(FilthMaker.TryMakeFilth), + new[] { typeof(IntVec3), typeof(Map), typeof(ThingDef), typeof(int), typeof(FilthSourceFlags), typeof(bool) }); + Multiplayer.harmony.Patch(tryMakeFilth, + prefix: new HarmonyMethod(typeof(BloodAnimationsCompat), nameof(SuppressFilthPrefix))); + + Log.Message("MP: Blood Animations compat patches applied"); + } + + static void EffectPrefix() + { + if (Multiplayer.Client == null) return; + effectDepth++; + Rand.PushState(); + } + + // Finalizer, not postfix: an exception mid-effect must not leave the + // pushed state (or the depth counter) behind + static void EffectFinalizer() + { + if (Multiplayer.Client == null || effectDepth == 0) return; + Rand.PopState(); + effectDepth--; + } + + // Filth is simulation state; Blood Animations creates it from per-client + // gates, so in MP its effect scopes must not spawn any + static bool SuppressFilthPrefix(ref bool __result) + { + if (effectDepth == 0 || Multiplayer.Client == null) return true; + __result = false; + return false; + } +} diff --git a/Source/Client/Saving/LordCrossRefs.cs b/Source/Client/Saving/LordCrossRefs.cs new file mode 100644 index 000000000..2505a0d9c --- /dev/null +++ b/Source/Client/Saving/LordCrossRefs.cs @@ -0,0 +1,28 @@ +using HarmonyLib; +using Verse.AI.Group; + +namespace Multiplayer.Client; + +// Lords loaded with the game register into sharedCrossRefs automatically (the +// loader directory is swapped to it), but lords created at runtime never did - +// so synced blobs referencing them (e.g. ceremony delivery jobs) failed to +// resolve and ran lordless (live find: GiveToPawn job losing Lord_7). +[HarmonyPatch(typeof(LordManager), nameof(LordManager.AddLord))] +static class RegisterLordCrossRef +{ + static void Postfix(Lord newLord) + { + if (Multiplayer.game != null) + ScribeUtil.sharedCrossRefs.RegisterLoaded(newLord); + } +} + +[HarmonyPatch(typeof(LordManager), nameof(LordManager.RemoveLord))] +static class UnregisterLordCrossRef +{ + static void Postfix(Lord oldLord) + { + if (Multiplayer.game != null) + ScribeUtil.sharedCrossRefs.Unregister(oldLord); + } +} diff --git a/Source/Client/Saving/SaveCompression.cs b/Source/Client/Saving/SaveCompression.cs index 9ea45d393..b6a151d44 100644 --- a/Source/Client/Saving/SaveCompression.cs +++ b/Source/Client/Saving/SaveCompression.cs @@ -383,4 +383,32 @@ static bool Referenced(Thing t) return t.Map?.compressor?.compressibilityDecider.IsReferenced(t) ?? false; } } + + // Vanilla 1.6 bug: Ideology TreeSighting refs aren't declared to the + // compressor, so sighted wild trees compress away and every save warns. + // Declare them like job/designation targets; also fixes SP saves. + [HarmonyPatch(typeof(CompressibilityDecider), nameof(CompressibilityDecider.DetermineReferences))] + static class DeclareTreeSightingRefs + { + static void Postfix(CompressibilityDecider __instance) + { + foreach (var pawn in __instance.map.mapPawns.AllPawnsSpawned) + { + var surroundings = pawn.surroundings; + if (surroundings == null) continue; + + Declare(__instance, surroundings.miniTreeSightings); + Declare(__instance, surroundings.fullTreeSightings); + } + } + + static void Declare(CompressibilityDecider decider, List sightings) + { + if (sightings == null) return; + + foreach (var sighting in sightings) + if (sighting.tree != null) + decider.referencedThings.Add(sighting.tree); + } + } } From 496bfccaf48322293cbdf179c33b91b7bc1e293c Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Sun, 19 Jul 2026 19:12:11 +0200 Subject: [PATCH 04/27] Sync VNPE gizmo actions, add unsynced-spawn diagnostic, fix download ETA race - VNPE's Extract and Drain gizmos mutate simulation state in interface context on the clicking client only (confirmed desync source via desync report traces); register their entry points as synced methods, resolved by reflection and skipped when VNPE or Multiplayer Compatibility make that appropriate. - Warn once per def, with the offending stack, when a Thing spawns outside the synced simulation or with an interface-block id - turns this class of mod bug from a forensics session into a one-line diagnosis. - Fix a check-then-use race on the connecting window's recomputed download speed (observed DivideByZeroException; -1 sentinel passed the old check). --- .../Client/Patches/UnsyncedSpawnTripwire.cs | 35 +++++++++++ Source/Client/Patches/VNPECompat.cs | 63 +++++++++++++++++++ Source/Client/Windows/ConnectingWindow.cs | 11 +++- 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 Source/Client/Patches/UnsyncedSpawnTripwire.cs create mode 100644 Source/Client/Patches/VNPECompat.cs diff --git a/Source/Client/Patches/UnsyncedSpawnTripwire.cs b/Source/Client/Patches/UnsyncedSpawnTripwire.cs new file mode 100644 index 000000000..dd591778e --- /dev/null +++ b/Source/Client/Patches/UnsyncedSpawnTripwire.cs @@ -0,0 +1,35 @@ +using System; +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client.Patches; + +// A Thing spawned outside the synced simulation - neither during ticking nor +// command execution - exists on this client only and desyncs the session much +// later, when pawn decisions around the divergent state first differ (the +// classic unsynced-mod-gizmo bug). Warn the moment it happens, once per def, +// with the stack that names the offender. A negative id means the thing was +// created in interface context (UniqueIdsPatch local id block) - caught +// regardless of where the spawn itself runs. +[HarmonyPatch(typeof(Thing))] +[HarmonyPatch(nameof(Thing.SpawnSetup))] +static class UnsyncedSpawnTripwire +{ + static void Postfix(Thing __instance, bool respawningAfterLoad) + { + // Motes/flecks and other no-id things are not simulation state; loading + // respawns everything outside ticking legitimately + if (Multiplayer.Client == null || respawningAfterLoad || !__instance.def.HasThingIDNumber) + return; + + // InInterface (not raw flag checks) so map gen, reloading and long + // events don't false-positive + if (__instance.thingIDNumber >= 0 && !Multiplayer.InInterface) + return; + + Log.WarningOnce( + $"MP: {__instance.def.defName} (id {__instance.thingIDNumber}) spawned outside the synced simulation - " + + $"an unsynced mod action is diverging this client. Stack: {Environment.StackTrace}", + __instance.def.shortHash ^ 0x51DE); + } +} diff --git a/Source/Client/Patches/VNPECompat.cs b/Source/Client/Patches/VNPECompat.cs new file mode 100644 index 000000000..88d23ff5d --- /dev/null +++ b/Source/Client/Patches/VNPECompat.cs @@ -0,0 +1,63 @@ +using System; +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client.Patches; + +// Vanilla Nutrient Paste Expanded's Extract and Drain gizmo actions mutate +// simulation state (spawn meals, consume feedstock, empty tanks) directly in +// interface context - on the clicking client only - a confirmed desync source. +// Register the gizmo entry points as synced methods; the bodies are +// deterministic and safe no-ops when preconditions fail, so all-client +// re-execution is exactly vanilla behavior. +[StaticConstructorOnStartup] +static class VNPECompat +{ + static VNPECompat() + { + var dispenserGizmos = AccessTools.TypeByName("VNPE.Building_NutrientPasteDispenser_GetGizmos"); + if (dispenserGizmos == null) + return; + + // Multiplayer Compatibility ships its own VNPE gizmo sync; registering + // twice would double-patch and skew syncIds vs its expectations + if (ModsConfig.IsActive("rwmt.multiplayercompatibility")) + { + Log.Message("MP: VNPE compat skipped - Multiplayer Compatibility is active and covers VNPE itself"); + return; + } + + // Static TryDropFood(dispenser, amount) backs all three dispenser + // Extract gizmos and the pipe-network fallback branch + RegisterMethod(dispenserGizmos, "TryDropFood")?.CancelIfAnyArgNull(); + + // The tap overrides gizmos with its own private instance TryDropFood(amount); + // the instance rides along as the sync target + RegisterMethod(AccessTools.TypeByName("VNPE.Building_NutrientPasteTap"), "TryDropFood"); + + // The Drain gizmo action is a lambda capturing only 'this', hoisted onto + // the comp class itself - resolvable by parent method + ordinal + try + { + SyncMethod.Lambda(AccessTools.TypeByName("VNPE.CompRegisterIngredients"), "CompGetGizmosExtra", 0); + } + catch (Exception e) + { + Log.Warning($"MP: VNPE compat found no Drain gizmo lambda - mod version mismatch? That sync skipped. ({e.Message})"); + } + + Log.Message("MP: VNPE compat sync methods registered"); + } + + private static SyncMethod RegisterMethod(Type type, string methodName) + { + var method = type != null ? AccessTools.Method(type, methodName) : null; + if (method == null) + { + Log.Warning($"MP: VNPE compat found no {type?.ToString() ?? methodName} sync target - mod version mismatch? That sync skipped."); + return null; + } + + return Sync.RegisterSyncMethod(method); + } +} diff --git a/Source/Client/Windows/ConnectingWindow.cs b/Source/Client/Windows/ConnectingWindow.cs index 1e05e0e31..d8203a402 100644 --- a/Source/Client/Windows/ConnectingWindow.cs +++ b/Source/Client/Windows/ConnectingWindow.cs @@ -54,9 +54,14 @@ public override void DoWindowContents(Rect inRect) case ClientLoadingState { subState: LoadingState.Downloading } state: label = "MpDownloading".Translate() + $" ({state.DownloadProgressPercent}%)"; var leftToDownloadKBps = (state.WorldExpectedSize - state.WorldReceivedSize) / 1000; - if (state.DownloadSpeedKBps != 0) + // The speed getter recomputes from live checkpoints on every + // access - read it once, or the value checked isn't the value + // divided by (observed as a DivideByZeroException here; it is + // also -1 before the first checkpoint) + var downloadSpeedKBps = state.DownloadSpeedKBps; + if (downloadSpeedKBps > 0) { - var timeLeftSecs = leftToDownloadKBps / state.DownloadSpeedKBps; + var timeLeftSecs = leftToDownloadKBps / downloadSpeedKBps; label += $"\n{timeLeftSecs}s – "; } @@ -64,7 +69,7 @@ public override void DoWindowContents(Rect inRect) label += "\n"; label += - $"{state.WorldReceivedSize / 1000}/{state.WorldExpectedSize / 1000} KB ({state.DownloadSpeedKBps} KB/s)"; + $"{state.WorldReceivedSize / 1000}/{state.WorldExpectedSize / 1000} KB ({downloadSpeedKBps} KB/s)"; break; default: label = result ?? (ConnectingString + MpUI.FixedEllipsis()); From 432d2be27d107a4f2ce7808c8960e74da0c41387 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Sun, 19 Jul 2026 19:27:11 +0200 Subject: [PATCH 05/27] Improve desync forensics: log tail capture, join-diff recording - Desync reports kept the first 10,000 log lines and discarded the tail - the desync itself and everything near it. Keep a startup head plus the newest lines within the same budget. - Record the join-data diff string on the session when a player connects anyway through the mismatch window, include it in desync reports, and post a chat warning on file/modlist mismatches - whether the builds matched is the first question of any desync investigation. - Correct the diff string's 'RW version match' label: it compares Multiplayer mod versions. --- Source/Client/Desyncs/LogGenerator.cs | 21 ++++++++++++++++----- Source/Client/Desyncs/SaveableDesyncInfo.cs | 3 +++ Source/Client/Session/MultiplayerSession.cs | 5 +++++ Source/Client/Windows/JoinDataWindow.cs | 17 ++++++++++++++++- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/Source/Client/Desyncs/LogGenerator.cs b/Source/Client/Desyncs/LogGenerator.cs index 192bb3110..58094403a 100644 --- a/Source/Client/Desyncs/LogGenerator.cs +++ b/Source/Client/Desyncs/LogGenerator.cs @@ -13,6 +13,11 @@ namespace Multiplayer.Client public static class LogGenerator { private const int MaxLogLineCount = 10000; + // The incident is always at the end of the log, so when over budget + // keep the newest lines; a small head preserves startup/env context + // (mod list details live in local_metadata anyway) + private const int HeadLineCount = 2000; + private const int TailLineCount = MaxLogLineCount - HeadLineCount; internal static string PrepareLogData() { @@ -49,11 +54,17 @@ private static string NormalizeLineEndings(string log) private static string TrimExcessLines(string log) { var indexOfLastNewline = log.IndexOfOccurrence('\n', MaxLogLineCount); - if (indexOfLastNewline >= 0) - { - log = $"{log.Substring(0, indexOfLastNewline + 1)}(log trimmed to {MaxLogLineCount:N0} lines.)"; - } - return log; + if (indexOfLastNewline < 0) + return log; + + var lines = log.Split('\n'); + var omitted = lines.Length - HeadLineCount - TailLineCount; + if (omitted <= 0) + return log; + + return string.Join("\n", lines.Take(HeadLineCount)) + + $"\n(... {omitted:N0} lines omitted ...)\n" + + string.Join("\n", lines.Skip(lines.Length - TailLineCount)); } private static string RedactUselessLines(string log) diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 9ac184387..2f582c688 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -114,6 +114,9 @@ private string GetDesyncDetails() .AppendLine("\n###Version Data###") .AppendLine($"Multiplayer Mod Version|||{MpVersion.Version}") .AppendLine($"Rimworld Version and Rev|||{VersionControl.CurrentVersionStringWithRev}") + // Whether the builds matched is the first forensic question; null + // means the join-data mismatch window never opened + .AppendLine($"Join Data Diff|||{Multiplayer.session.joinDataDiff ?? "clean (no mismatch window shown)"}") .AppendLine("\n###Debug Options###") .AppendLine($"Multiplayer Debug Build - Client|||{MpVersion.IsDebug}") .AppendLine($"Multiplayer Debug Mode - Host|||{Multiplayer.GameComp.debugMode}") diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index df05bc703..9ac028a2b 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -45,6 +45,11 @@ public class MultiplayerSession : IConnectionStatusListener public int simulationFailures; public string firstSimulationFailure; + // Set when the join-data mismatch window let the player connect anyway; + // null means no mismatch window was shown. Included in desync reports - + // whether the builds matched is the first forensic question. + public string joinDataDiff; + public List pendingSteam = new(); public List knownUsers = new(); diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index fb891cc22..de96a649d 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -250,6 +250,20 @@ void RefreshFiles() if (connectAnyway) { Log.Message($"Multiplayer: Connecting anyway ({DiffString()})"); + + // Remember what was mismatched for the desync report, and make + // the risk visible in chat - a mid-rejoin click-through is easy + // to wave past and mismatched files mean divergent simulations + if (Multiplayer.session != null) + { + Multiplayer.session.joinDataDiff = DiffString(); + + if (filesRoot.children.Any() || modListDiff != ModListDiff.None) + Multiplayer.session.AddMsg( + $"Connected with mismatched mod files ({DiffString()}) - desyncs are likely until everyone runs the same build.", + rawMessage: true); + } + connectAnywayCallback(); Close(false); } @@ -269,7 +283,8 @@ void RefreshFiles() private string DiffString() { var str = ""; - str += $"RW version match: {remote.remoteMpVersion == MpVersion.Version}, "; + // remoteMpVersion is the MP mod version, not the RimWorld version + str += $"MP version match: {remote.remoteMpVersion == MpVersion.Version}, "; str += $"Mod list diff: {modListDiff}, "; str += $"Files match: {!filesRoot.children.Any()}, "; str += $"Config sync enabled: {remote.hasConfigs}, "; From 95317aa0110f097bf3b3b63af2fa38a25ee47ac0 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Sun, 19 Jul 2026 22:35:41 +0200 Subject: [PATCH 06/27] Multifaction: repeat objective zone/designation cleanup, owner-filter C# quest map picks - Zone-overlap clears and destroy/recruit-time designation removal now repeat across every faction's manager copy. Hooked at the objective call sites, not RemoveAllDesignationsOn itself, which also backs player-intent designators where repeating would delete other factions' designations. - QuestGen_Get.GetMap - the C# quest-root map picker used by refugee, beggar, wanderer and DLC arrival quests - now applies the same generating-faction owner rule as QuestNode_GetMap. --- Source/Client/Factions/FactionRepeater.cs | 65 +++++++++++++++++++ .../Client/Factions/QuestFactionOwnership.cs | 31 +++++++++ 2 files changed, 96 insertions(+) diff --git a/Source/Client/Factions/FactionRepeater.cs b/Source/Client/Factions/FactionRepeater.cs index 7fd1a8110..3bb4bf185 100644 --- a/Source/Client/Factions/FactionRepeater.cs +++ b/Source/Client/Factions/FactionRepeater.cs @@ -196,6 +196,71 @@ ref ignore ); } + // An impassable building spawned while another faction's (or the + // spectator's) zoneManager is installed - e.g. from a quest signal during + // the world tick - clears zone cells only from that copy, leaving stale + // zone cells under the building in the owner's zones. Objective event with + // a single caller (Thing.SpawnSetup), so repeat it across every copy. + [HarmonyPatch(typeof(ZoneManager), nameof(ZoneManager.Notify_NoZoneOverlapThingSpawned))] + static class ZoneOverlapSpawnedPatch + { + static bool ignore; + + static bool Prefix(ZoneManager __instance, Thing thing) => + FactionRepeater.Template( + __instance.map.MpComp()?.factionData, + d => d.zoneManager.Notify_NoZoneOverlapThingSpawned(thing), + __instance.map, + ref ignore + ); + } + + // Destroying a designated thing cleans designations only from each map's + // installed designationManager - other factions' copies keep a stale + // designation on a dead thing. RemoveAllDesignationsOn itself also backs + // player-intent designators (cancel/tame), where repeating would delete + // other factions' designations, so hook the objective call sites instead: + // Thing's destroy-time cleanup and faction changes (recruit/capture). + [HarmonyPatch(typeof(Thing), nameof(Thing.RemoveAllReservationsAndDesignationsOnThis))] + static class ThingDestroyedDesignationsPatch + { + static void Postfix(Thing __instance) + { + if (Multiplayer.Client == null || __instance.def.category == ThingCategory.Mote) + return; + + // Mirrors the patched method: designations can live on any map's copies + foreach (var map in Find.Maps) + { + var dummy = false; + FactionRepeater.Template( + map.MpComp()?.factionData, + d => d.designationManager.RemoveAllDesignationsOn(__instance), + map, + ref dummy + ); + } + } + } + + [HarmonyPatch(typeof(Pawn), nameof(Pawn.SetFaction))] + static class PawnSetFactionDesignationsPatch + { + static void Postfix(Pawn __instance) + { + if (Multiplayer.Client == null || !__instance.Spawned) + return; + + var dummy = false; + FactionRepeater.Template( + __instance.Map.MpComp()?.factionData, + d => d.designationManager.RemoveAllDesignationsOn(__instance), + __instance.Map, + ref dummy + ); + } + } + // Fix for RimWorld 1.6 bug where HistoryAutoRecorder.Tick() calls .Last() on empty collection [HarmonyPatch(typeof(HistoryAutoRecorder), nameof(HistoryAutoRecorder.Tick))] static class HistoryAutoRecorderTickPatch diff --git a/Source/Client/Factions/QuestFactionOwnership.cs b/Source/Client/Factions/QuestFactionOwnership.cs index 21f5a55f2..0b3bb9c78 100644 --- a/Source/Client/Factions/QuestFactionOwnership.cs +++ b/Source/Client/Factions/QuestFactionOwnership.cs @@ -157,3 +157,34 @@ static void Postfix(Map map, ref bool __result) __result = false; } } + +// The same gap's second entry point: many C# quest roots (hospitality +// refugees, beggars, wanderer joins, DLC arrivals) pick their map via +// QuestGen_Get.GetMap, which never consults QuestNode_GetMap. Same owner +// rule: re-pick among the generating faction's own maps with vanilla's +// preference order; null makes the quest's map test fail, vanilla's own +// no-map behavior. +[HarmonyPatch(typeof(QuestGen_Get), nameof(QuestGen_Get.GetMap))] +static class QuestGenGetMapMatchesGeneratingFaction +{ + static void Postfix(ref Map __result, bool mustBeInfestable, int? preferMapWithMinFreeColonists, bool canBeSpace) + { + if (__result == null || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (!QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer) || + __result.ParentFaction is not { IsPlayer: true } || __result.ParentFaction == Faction.OfPlayer) + return; + + int minCount = preferMapWithMinFreeColonists ?? 1; + var ownMaps = Find.Maps.Where(m => + m.IsPlayerHome && m.ParentFaction == Faction.OfPlayer && + (canBeSpace || !m.Tile.LayerDef.isSpace) && + (!mustBeInfestable || InfestationCellFinder.TryFindCell(out _, m))).ToList(); + + if (!ownMaps.Where(m => m.mapPawns.FreeColonists.Count >= minCount).TryRandomElement(out var rePicked)) + ownMaps.Where(m => m.mapPawns.FreeColonists.Any()).TryRandomElement(out rePicked); + + __result = rePicked; + } +} From f7ea49a4978182e73df371bdffa0ff4a10cf44b8 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Sun, 19 Jul 2026 23:30:06 +0200 Subject: [PATCH 07/27] Multifaction: faction-context and routing fixes across DLC systems - Faction creation cleared of client-local xenotype-database writes - the game-global database (and later factionless-pawn generation Rand rolls) stayed identical across clients only for the issuer before. - Ideo ticking, member-death notifications and birthdays now run under the relevant player faction's context instead of the spectator's: date obligations (festivals), funeral obligations for caravan/cross-map deaths, role letters and caravan children's growth-moment choices all fire again, with letters routed to the right faction. - Growth-moment sessions are scoped to the child's faction (dialog, choices, timeout) and tolerate unspawned pawns instead of throwing. - Quest routing: owner filters for two private map pickers (work sites, reliquary pilgrims), retargeting on map loss prefers the quest owner's maps, bestowing-ceremony generation runs under the royal's faction, and owner inference also reads pawn look targets. - Incidents fired inside a synced player command bypass the cross-faction suppression net - explicit orders always execute. --- Source/Client/Factions/FactionCreator.cs | 14 ++ Source/Client/Factions/FactionExtensions.cs | 8 +- Source/Client/Factions/IncidentRouting.cs | 12 +- .../Client/Factions/QuestFactionOwnership.cs | 128 ++++++++++++++++++ Source/Client/Patches/IdeoContext.cs | 106 +++++++++++++++ .../Client/Persistent/GrowthMomentSession.cs | 59 +++++++- 6 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 Source/Client/Patches/IdeoContext.cs diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index 49562e1f1..03488d615 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -110,6 +110,20 @@ private static Map GenerateNewMap(PlanetTile tile, Scenario scenario, bool setup { // This has to be null, otherwise, during map generation, Faction.OfPlayer returns it which breaks FactionContext Find.GameInitData.playerFaction = null; + + // PrepForMapGen's Biotech tail reads the CLIENT-LOCAL static + // StartingPawnUtility generation requests to add custom xenotypes to + // the game-global customXenotypeDatabase - only the issuer's client + // has them populated, so the database (and later factionless-pawn + // xenotype Rand rolls) would diverge across clients. Clear the + // requests on every client so the loop no-ops identically everywhere. + // Accepted cost: custom xenotypes of faction-creation starting pawns + // aren't registered in the database (the pawns keep their xenotype; + // the entry only feeds the UI list and factionless spawn weighting). + if (StartingPawnUtility.StartingAndOptionalPawnGenerationRequests.Any(r => r.ForcedCustomXenotype != null)) + Log.Message("MP: faction creation dropped custom-xenotype database registration (kept deterministic)"); + StartingPawnUtility.StartingAndOptionalPawnGenerationRequests.Clear(); + Find.GameInitData.PrepForMapGen(); // ScenPart_PlayerFaction --> PreMapGenerate diff --git a/Source/Client/Factions/FactionExtensions.cs b/Source/Client/Factions/FactionExtensions.cs index a520c8cf0..2c4a9a36f 100644 --- a/Source/Client/Factions/FactionExtensions.cs +++ b/Source/Client/Factions/FactionExtensions.cs @@ -43,7 +43,13 @@ public static bool TryGetPlayerFaction(this Quest quest, out Faction faction) faction = quest.QuestLookTargets .Where(t => t.HasWorldObject && t.WorldObject is Settlement) .Select(t => ((Settlement)t.WorldObject).Faction) - .FirstOrDefault(f => f != null); + .FirstOrDefault(f => f != null) + // Pawn-targeted quests (bestowing ceremonies, rescues) have no + // settlement target - the targeted pawn's faction is the owner + ?? quest.QuestLookTargets + .Where(t => t.HasThing && t.Thing is Pawn) + .Select(t => ((Pawn)t.Thing).Faction) + .FirstOrDefault(f => f is { IsPlayer: true }); return faction != null; } diff --git a/Source/Client/Factions/IncidentRouting.cs b/Source/Client/Factions/IncidentRouting.cs index d0c93b504..c4fb208d6 100644 --- a/Source/Client/Factions/IncidentRouting.cs +++ b/Source/Client/Factions/IncidentRouting.cs @@ -1,5 +1,6 @@ using HarmonyLib; using Multiplayer.Client.Util; +using Multiplayer.Common; using RimWorld; using Verse; @@ -16,6 +17,13 @@ static bool Prefix(IncidentWorker __instance, IncidentParms parms, ref bool __re if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) return true; + // Explicit player orders always execute: an incident fired inside a + // synced command (e.g. a Call-Aid permit used while standing on + // another faction's map) is deliberate, not a routing bug - only + // storyteller/quest/tick-driven incidents keep the safety net + if (TickPatch.currentExecutingCmdType == CommandType.Sync) + return true; + if (parms.target is Map { ParentFaction: { IsPlayer: true } owner } && owner != Faction.OfPlayer) { // QuestFactionOwnership now routes quest-fired incidents through @@ -25,7 +33,7 @@ static bool Prefix(IncidentWorker __instance, IncidentParms parms, ref bool __re { MpLog.Log( $"Spectator-context incident {__instance.def?.defName} targeting map of " + - $"{owner.Name} - quest-ownership routing gap (61-quests-review.md)"); + $"{owner.Name} - unrouted generation source, letting it through"); return true; } @@ -50,6 +58,6 @@ static void Prefix(FiringIncident fi) if (Multiplayer.Client != null && Multiplayer.GameComp.multifaction && Faction.OfPlayer == Multiplayer.WorldComp.spectatorFaction) MpLog.Error($"Storyteller fired {fi?.def?.defName} in spectator faction context - " + - "an unrouted generation source (see 62-incidents-review.md)"); + "an unrouted generation source"); } } diff --git a/Source/Client/Factions/QuestFactionOwnership.cs b/Source/Client/Factions/QuestFactionOwnership.cs index 0b3bb9c78..1994cabfa 100644 --- a/Source/Client/Factions/QuestFactionOwnership.cs +++ b/Source/Client/Factions/QuestFactionOwnership.cs @@ -1,9 +1,11 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using HarmonyLib; using Multiplayer.Client.Util; using RimWorld; +using RimWorld.Planet; using RimWorld.QuestGen; using Verse; @@ -158,6 +160,132 @@ static void Postfix(Map map, ref bool __result) } } +// Multifaction: QuestNode_Root_ReliquaryPilgrims has its own private map +// picker accepting any player map with a reliquary - neither GetMap owner +// filter applies. Re-pick among the generating faction's own maps. +[HarmonyPatch(typeof(QuestNode_Root_ReliquaryPilgrims), "GetMap")] +static class ReliquaryPilgrimsMapOwnerFilter +{ + static void Postfix(ref Map __result) + { + if (__result == null || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (!QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer) || + __result.ParentFaction is not { IsPlayer: true } || __result.ParentFaction == Faction.OfPlayer) + return; + + Find.Maps + .Where(m => m.IsPlayerHome && m.ParentFaction == Faction.OfPlayer && + QuestNode_Root_ReliquaryPilgrims.TryFindReliquaryWithRelic(m, out _, out _, out _)) + .TryRandomElement(out var rePicked); + __result = rePicked; + } +} + +// Same gap: QuestNode_Root_WorkSite picks its map with an inline predicate +// over IsPlayerHome maps. The predicate is a compiler-generated lambda - +// resolved by ordinal (like VNPE's Drain gizmo) and shape-checked so a game +// update degrades to a warning, not a crash. +[StaticConstructorOnStartup] +static class WorkSiteMapPickerFilter +{ + static WorkSiteMapPickerFilter() + { + try + { + var lambda = MpMethodUtil.GetLambda(typeof(QuestNode_Root_WorkSite), "RunInt", MethodType.Normal, null, 0); + if (lambda.ReturnType != typeof(bool) || + lambda.GetParameters() is not { Length: 1 } ps || ps[0].ParameterType != typeof(Map)) + { + Log.Warning("MP: WorkSite map-picker lambda shape unexpected - owner filter skipped"); + return; + } + + Multiplayer.harmony.Patch(lambda, + postfix: new HarmonyMethod(typeof(WorkSiteMapPickerFilter), nameof(PredicatePostfix))); + } + catch (Exception e) + { + Log.Warning($"MP: WorkSite map-picker lambda not found - owner filter skipped ({e.Message})"); + } + } + + static void PredicatePostfix(Map m, ref bool __result) + { + if (!__result || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + if (QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer) && + m.ParentFaction is { IsPlayer: true } && m.ParentFaction != Faction.OfPlayer) + __result = false; + } +} + +// The lambda filter narrows RunInt's candidates, so TestRunInt must agree or +// generation can proceed with no acceptable map left +[HarmonyPatch(typeof(QuestNode_Root_WorkSite), "TestRunInt")] +static class WorkSiteTestRunMatchesFilter +{ + static void Postfix(ref bool __result) + { + if (!__result || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || + !QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer)) + return; + + __result = Find.Maps.Any(m => + m.IsPlayerHome && m.ParentFaction == Faction.OfPlayer && QuestNode_Root_WorkSite.GetCandidates(m.Tile).Any()); + } +} + +// Delivery-side: when a quest's bound map is lost mid-quest, vanilla +// retargets to the FIRST player home map, faction-blind - a hostile mech +// cluster or monument copy lands on an uninvolved colony. Quest parts call +// this under the pushed owner context, so prefer the owner's maps. +[HarmonyPatch(typeof(Quest), nameof(Quest.TryFindNewSuitableMapParentForRetarget))] +static class RetargetPrefersOwnerMaps +{ + static void Postfix(ref MapParent __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || + !QuestFactionOwnership.IsOwnablePlayerFaction(Faction.OfPlayer)) + return; + + if (__result?.Map is { } m && m.ParentFaction == Faction.OfPlayer) + return; + + var owned = Find.Maps.FirstOrDefault(map => + map.IsPlayerHome && map.ParentFaction == Faction.OfPlayer)?.Parent; + if (owned != null) + __result = owned; + } +} + +// Caravan royals' bestowing-ceremony checks tick in the world tick +// (spectator), so the quest was stamped to the fallback owner and its +// quest-available letter - received under spectator - was dropped for every +// client. Push the royal's own faction around generation. +[HarmonyPatch(typeof(RoyalTitleUtility), nameof(RoyalTitleUtility.GenerateBestowingCeremonyQuest))] +static class BestowingCeremonyOwnerContext +{ + static void Prefix(Pawn pawn, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || + !QuestFactionOwnership.IsOwnablePlayerFaction(pawn?.Faction) || + pawn.Faction == Faction.OfPlayer) + return; + + ((Map)null).PushFaction(pawn.Faction); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + // The same gap's second entry point: many C# quest roots (hospitality // refugees, beggars, wanderer joins, DLC arrivals) pick their map via // QuestGen_Get.GetMap, which never consults QuestNode_GetMap. Same owner diff --git a/Source/Client/Patches/IdeoContext.cs b/Source/Client/Patches/IdeoContext.cs new file mode 100644 index 000000000..17ed6c380 --- /dev/null +++ b/Source/Client/Patches/IdeoContext.cs @@ -0,0 +1,106 @@ +using System.Linq; +using HarmonyLib; +using Multiplayer.Client.Factions; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Patches; + +// Multifaction: ideo and age ticking run in the world tick, whose context is +// the spectator faction - so every vanilla `Faction.OfPlayer`-gated feature +// in those paths silently never fires for anyone: date obligations +// (festivals/celebrations), the small-ideo ObligationsActive fallback, role +// activity letters, funeral obligations for caravan/cross-map deaths, and +// growth-moment letters for caravan children (auto-rolled instead). Push the +// right player faction's context around each entry point; letters then also +// route to that faction instead of being dropped for everyone. +static class IdeoContextUtil +{ + // Deterministic "whose ideo is this": the lowest-loadID ownable player + // faction following it (identical on all clients) + public static Faction PrimaryPlayerFollower(Ideo ideo) + { + if (ideo == null) + return null; + + return Find.FactionManager.AllFactionsListForReading + .Where(f => QuestFactionOwnership.IsOwnablePlayerFaction(f) && f.ideos != null && f.ideos.Has(ideo)) + .OrderBy(f => f.loadID) + .FirstOrDefault(); + } + + public static bool PushIfOwnable(Faction faction) + { + if (!QuestFactionOwnership.IsOwnablePlayerFaction(faction) || faction == Faction.OfPlayer) + return false; + + ((Map)null).PushFaction(faction); + return true; + } +} + +[HarmonyPatch(typeof(Ideo), nameof(Ideo.IdeoTick))] +static class IdeoTickContextPatch +{ + static void Prefix(Ideo __instance, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + __state = IdeoContextUtil.PushIfOwnable(IdeoContextUtil.PrimaryPlayerFollower(__instance)); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +// Death/corpse-destroyed obligation triggers gate on the dead pawn's home +// faction being Faction.OfPlayer - push it so caravan and cross-map deaths +// still produce funerals +[HarmonyPatch] +static class IdeoMemberNotifyContextPatch +{ + static System.Collections.Generic.IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(Ideo), nameof(Ideo.Notify_MemberDied)); + yield return AccessTools.Method(typeof(Ideo), nameof(Ideo.Notify_MemberCorpseDestroyed)); + } + + static void Prefix(Pawn member, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || member == null) + return; + + __state = IdeoContextUtil.PushIfOwnable(member.HomeFaction); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +// Growth moments: a caravan child's birthday ticks under the spectator, so +// the letter branch's `pawn.Faction != Faction.OfPlayer` check fails and the +// trait/passions are rolled silently with no player choice +[HarmonyPatch(typeof(Pawn_AgeTracker), "BirthdayBiological")] +static class BirthdayBiologicalContextPatch +{ + static void Prefix(Pawn_AgeTracker __instance, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + __state = IdeoContextUtil.PushIfOwnable(__instance.pawn.Faction); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} diff --git a/Source/Client/Persistent/GrowthMomentSession.cs b/Source/Client/Persistent/GrowthMomentSession.cs index 3c348ccdc..dea968868 100644 --- a/Source/Client/Persistent/GrowthMomentSession.cs +++ b/Source/Client/Persistent/GrowthMomentSession.cs @@ -10,7 +10,10 @@ namespace Multiplayer.Client.Persistent; public class GrowthMomentSession(Map _) : ExposableSession(null), ITickingSession { public static GrowthMomentSession GetSessionFor(Pawn pawn) => - pawn.Map.MpComp().sessionManager.GetFirstOfType(sess => sess.Pawn == pawn); + // Null map: unspawned pawn (caravan child) - sessions are map-bound, + // so there is none; callers fall back to the vanilla dialog whose + // choices are still synced + pawn.Map?.MpComp().sessionManager.GetFirstOfType(sess => sess.Pawn == pawn); // There isn't a trait selected. public const int NullTraitIdx = -1; // intentionally the same as the "not found" return value for IndexOf @@ -24,9 +27,26 @@ public static GrowthMomentSession GetSessionFor(Pawn pawn) => public List passionIndexes = []; public bool uiDirty; // if true, received or sent an update + // The child's faction (stamped from the creation context) - only that + // faction's players get the dialog and may make the choice. -1 = legacy + // sessions from older saves: everyone, the old behavior. + public int ownerFactionId = -1; + + public bool IsForLocalPlayer => + ownerFactionId < 0 || Multiplayer.RealPlayerFaction?.loadID == ownerFactionId; + + // Valid during synced execution, where Faction.OfPlayer is the issuer's + private bool ContextFactionMayChoose => + ownerFactionId < 0 || Faction.OfPlayer?.loadID == ownerFactionId; + public static GrowthMomentSession Create(ChoiceLetter_GrowthMoment letter) => new(null) { - letter = letter + letter = letter, + ownerFactionId = Multiplayer.GameComp.multifaction && + Faction.OfPlayer is { IsPlayer: true } f && + f != Multiplayer.WorldComp.spectatorFaction + ? f.loadID + : -1 }; // LetterWithTimeout.LastTickBeforeTimeout adjusted to use the map's time instead of Find.TickManager.TicksGame @@ -38,7 +58,7 @@ public override bool IsCurrentlyPausing(Map map) => Map == map && letter.Timeout public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { - if (Map != entry.map) return null; + if (Map != entry.map || !IsForLocalPlayer) return null; return new FloatMenuOption("MpGrowthMomentSession".Translate(Pawn.Name.ToStringShort), OpenWindow); } @@ -47,6 +67,9 @@ public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry [SyncMethod] public void UpdateChoices(int traitIdx, List passionIndexes) { + // Another faction's player can't steer this child's choices + if (!ContextFactionMayChoose) return; + this.traitIdx = traitIdx; this.passionIndexes = passionIndexes; this.uiDirty = true; @@ -62,8 +85,13 @@ public void OpenWindow() public void Tick() { + // Pawn off-map mid-session (formed a caravan): session lingers in its + // original map's manager but Map follows the pawn - don't NRE + if (Map == null) return; if (!IsSessionValid) Map.MpComp().sessionManager.RemoveSession(this); - else if (letter.TimeoutActive && letter.disappearAtTick <= Map.AsyncTime().mapTicks + 1) OpenWindow(); + // The forced-open at timeout is UI-only - owner's players only + else if (letter.TimeoutActive && letter.disappearAtTick <= Map.AsyncTime().mapTicks + 1 && IsForLocalPlayer) + OpenWindow(); } public override void ExposeData() @@ -73,6 +101,7 @@ public override void ExposeData() Scribe_References.Look(ref letter, "pawn"); Scribe_Values.Look(ref traitIdx, "traitIdx"); Scribe_Collections.Look(ref passionIndexes, "passionIndexes", LookMode.Value); + Scribe_Values.Look(ref ownerFactionId, "ownerFactionId", -1); } [SyncMethod] @@ -80,6 +109,9 @@ public static GrowthMomentSession TryAddSession(ChoiceLetter_GrowthMoment letter { if (letter.ArchiveView) return null; var pawn = letter.pawn; + // Unspawned pawn (caravan child): sessions are map-bound, so no + // session - the vanilla dialog handles it and MakeChoices is synced + if (pawn.Map == null) return null; var sessionManager = pawn.Map.MpComp().sessionManager; var sess = sessionManager.GetFirstOfType(sess => sess.Pawn == pawn); if (sess == null) @@ -168,6 +200,11 @@ static bool WindowStackAddPatch(ref Window window) dialog.letter.ArchiveView || window is GrowthMomentWindow) return true; + // Unspawned pawn (caravan child): no map-bound session possible - let + // the vanilla dialog open locally; MakeChoices itself is synced + if (dialog.letter.pawn.Map == null) + return true; + if (GrowthMomentSession.GetSessionFor(dialog.letter.pawn) is { } session) session.OpenWindow(); else @@ -176,6 +213,20 @@ static bool WindowStackAddPatch(ref Window window) return false; } + // The choice itself is faction authority: only the child's faction may + // apply it (UpdateChoices already guards the live-sync path) + [MpPrefix(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.MakeChoices))] + static bool MakeChoicesGuard(ChoiceLetter_GrowthMoment __instance) + { + if (Multiplayer.Client == null || !Multiplayer.ExecutingCmds) return true; + var session = GrowthMomentSession.GetSessionFor(__instance.pawn); + if (session == null || session.ownerFactionId < 0 || Faction.OfPlayer?.loadID == session.ownerFactionId) + return true; + + Log.Message($"MP: ignored growth-moment choice for {__instance.pawn} from non-owner faction {Faction.OfPlayer?.Name}"); + return false; + } + [MpPostfix(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.MakeChoices))] static void MakeChoicesPatch(ChoiceLetter_GrowthMoment __instance) { From 815cedb532af45f737dd77d836c23568ea3ee3b2 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Tue, 21 Jul 2026 01:13:28 +0200 Subject: [PATCH 08/27] Attribute wastepack events, fix async season desync, determinism and forensics improvements - Wastepack goodwill and retaliation now attribute to the dumping faction (holder chain, call-site context, scribed per-thing stamps); retaliation quests target the dumper's map and never fall back to an arbitrary player's. Unknown dumpers drop retaliation with a warning. - Timed site exits and defeat-all-enemies site quests tick under the map owner's faction context instead of the spectator (pawn-loss and reward misfire fixes). - Season notifications are computed per player faction from that faction's own min-timezone home map clock against per-faction saved state, fixing a desync where clients' season transitions diverged under async time (also affected spectating clients in single-faction games). - PlayerItemAccessibilityUtility no longer serves frame-cached results in multiplayer: the RealTime.frameCount cache key diverged per client and fed stale data into quest generation and the AI-core offer roll. - The host's game language now travels with the join data (protocol 57) and shows in the mismatch window: translated text is scribed, so mismatched languages diverge saves. --- Source/Client/AsyncTime/AsyncTimePatches.cs | 84 ++++- Source/Client/Comp/World/FactionWorldData.cs | 7 + .../Client/Comp/World/MultiplayerWorldComp.cs | 9 + .../Client/Factions/WastepackAttribution.cs | 327 ++++++++++++++++++ .../Client/Factions/WorldTickOwnerContext.cs | 84 +++++ Source/Client/Networking/JoinData.cs | 5 + .../Networking/State/ClientJoiningState.cs | 1 + .../Client/Patches/FrameCacheDeterminism.cs | 29 ++ Source/Client/Windows/JoinDataWindow.cs | 14 +- .../Networking/Packet/InitDataPacket.cs | 2 + .../Networking/Packet/JoinDataPacket.cs | 2 + .../Networking/State/ServerJoiningState.cs | 1 + Source/Common/ServerInitData.cs | 3 +- Source/Common/Version.cs | 2 +- Source/Tests/ChatCommandManagerTest.cs | 2 + Source/Tests/Helper/TestJoiningState.cs | 1 + .../Tests/Helper/TestLoadingKeepAliveState.cs | 1 + Source/Tests/PacketTest.cs | 2 + .../ClientInitDataPacket.verified.txt | 2 +- .../ServerJoinDataPacket.verified.txt | 2 +- 20 files changed, 564 insertions(+), 16 deletions(-) create mode 100644 Source/Client/Factions/WastepackAttribution.cs create mode 100644 Source/Client/Factions/WorldTickOwnerContext.cs create mode 100644 Source/Client/Patches/FrameCacheDeterminism.cs diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index 10a8bb051..db01dd4af 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -3,6 +3,7 @@ using System.Reflection; using HarmonyLib; using Multiplayer.Client.Factions; +using Multiplayer.Client.Util; using RimWorld; using RimWorld.Planet; using Verse; @@ -50,26 +51,87 @@ static IEnumerable TargetMethods() static bool Prefix() => Multiplayer.Client == null || !MapUpdateMarker.updating; } + // Season notifications. The old patch pushed Multiplayer.RealPlayerFaction + // and that client's own faction's map clock into a method that runs in the + // synced world tick and writes scribed state (lastSeason) - per-client + // inputs in the deterministic sim. Under async, clients' chosen maps cross + // season boundaries at different world ticks and lastSeason diverges (the + // "summer arrived twice" desync); a spectating client diverged even in + // single-faction games. Replaced with a per-faction pass over synced state + // only: each ownable player faction, in deterministic order, gets its + // season computed from ITS min-timezone home map's async clock, with its + // own scribed lastSeason (FactionWorldData) and its letters routed by the + // pushed context. Identical inputs on every client by construction. [HarmonyPatch(typeof(DateNotifier), nameof(DateNotifier.DateNotifierTick))] static class DateNotifierPatch { - static void Prefix(DateNotifier __instance, ref int? __state) + static bool Prefix(DateNotifier __instance) { - if (Multiplayer.Client == null && Multiplayer.RealPlayerFaction != null) return; + if (Multiplayer.Client == null) + return true; - Map map = __instance.FindPlayerHomeWithMinTimezone(); - if (map == null) return; + foreach (var kv in Multiplayer.WorldComp.factionData) + { + var faction = Find.FactionManager.GetById(kv.Key); + if (!QuestFactionOwnership.IsOwnablePlayerFaction(faction)) + continue; + + ((Map)null).PushFaction(faction); + try + { + TickForFaction(__instance, kv.Value); + } + finally + { + FactionExtensions.PopFaction(); + } + } - __state = Find.TickManager.TicksGame; - FactionContext.Push(Multiplayer.RealPlayerFaction); - Find.TickManager.DebugSetTicksGame(map.AsyncTime().mapTicks); + return false; } - static void Finalizer(int? __state) + // Vanilla DateNotifierTick body (1.6.4871) against per-faction state, + // under the faction's context and its map's clock + static void TickForFaction(DateNotifier notifier, FactionWorldData data) { - if (!__state.HasValue) return; - Find.TickManager.DebugSetTicksGame(__state.Value); - FactionContext.Pop(); + // First run on an existing save: adopt the global pre-fix state so + // the transition already seen isn't re-announced + if (data.lastSeason == Season.Undefined && notifier.lastSeason != Season.Undefined) + data.lastSeason = notifier.lastSeason; + + Map map = notifier.FindPlayerHomeWithMinTimezone(); + + int prevTicks = Find.TickManager.TicksGame; + if (map != null) + Find.TickManager.DebugSetTicksGame(map.AsyncTime().mapTicks); + + try + { + float latitude = map != null ? Find.WorldGrid.LongLatOf(map.Tile).y : 0f; + float longitude = map != null ? Find.WorldGrid.LongLatOf(map.Tile).x : 0f; + Season season = GenDate.Season(Find.TickManager.TicksAbs, latitude, longitude); + + if (season == data.lastSeason || + (data.lastSeason != Season.Undefined && season == data.lastSeason.GetPreviousSeason())) + return; + + if (data.lastSeason != Season.Undefined && notifier.AnyPlayerHomeSeasonsAreMeaningful()) + { + if (GenDate.YearsPassed == 0 && season == Season.Summer && + notifier.AnyPlayerHomeAvgTempIsLowInWinter()) + Find.LetterStack.ReceiveLetter("LetterLabelFirstSummerWarning".Translate(), + "FirstSummerWarning".Translate(), LetterDefOf.NeutralEvent); + else if (GenDate.DaysPassed > 5) + Messages.Message("MessageSeasonBegun".Translate(season.Label()).CapitalizeFirst(), + MessageTypeDefOf.NeutralEvent); + } + + data.lastSeason = season; + } + finally + { + Find.TickManager.DebugSetTicksGame(prevTicks); + } } } diff --git a/Source/Client/Comp/World/FactionWorldData.cs b/Source/Client/Comp/World/FactionWorldData.cs index c3fc4547a..09f6869f1 100644 --- a/Source/Client/Comp/World/FactionWorldData.cs +++ b/Source/Client/Comp/World/FactionWorldData.cs @@ -20,6 +20,11 @@ public class FactionWorldData : IExposable public AnalysisManager analysisManager; public FactionBossgroupData bossgroup; + // Per-faction season notification state: each faction's transitions are + // computed from its own min-timezone home map's async clock. See the + // DateNotifier patch in AsyncTime/AsyncTimePatches.cs. + public Season lastSeason; + public FactionWorldData() { } public void ExposeData() @@ -39,6 +44,8 @@ public void ExposeData() Scribe_Deep.Look(ref analysisManager, "analysisManager"); Scribe_Deep.Look(ref bossgroup, "bossgroup"); + Scribe_Values.Look(ref lastSeason, "lastSeason", Season.Undefined); + if (Scribe.mode == LoadSaveMode.LoadingVars) { researchManager ??= new ResearchManager(); diff --git a/Source/Client/Comp/World/MultiplayerWorldComp.cs b/Source/Client/Comp/World/MultiplayerWorldComp.cs index 1c05c4df1..e5ed703c8 100644 --- a/Source/Client/Comp/World/MultiplayerWorldComp.cs +++ b/Source/Client/Comp/World/MultiplayerWorldComp.cs @@ -25,6 +25,11 @@ public class MultiplayerWorldComp : IHasSessionData // Multifaction: quest.id -> faction.loadID owning the quest (see QuestFactionOwnership) public Dictionary questOwnership = new(); + // Multifaction: wastepack thingIDNumber -> faction.loadID of the dumper. + // Consulted when a spawned pack rots on a non-player-home map; pruned on + // destroy. See Factions/WastepackAttribution.cs. + public Dictionary wastepackDumpers = new(); + private int currentFactionId; public MultiplayerWorldComp(World world) @@ -42,6 +47,10 @@ public void ExposeData() if (Scribe.mode != LoadSaveMode.Saving) questOwnership ??= new Dictionary(); + Scribe_Collections.Look(ref wastepackDumpers, "wastepackDumpers", LookMode.Value, LookMode.Value); + if (Scribe.mode != LoadSaveMode.Saving) + wastepackDumpers ??= new Dictionary(); + sessionManager.ExposeSessions(); // Ensure a pause lock session exists if there's any pause locks registered if (!PauseLockSession.pauseLocks.NullOrEmpty() && Scribe.mode == LoadSaveMode.PostLoadInit) diff --git a/Source/Client/Factions/WastepackAttribution.cs b/Source/Client/Factions/WastepackAttribution.cs new file mode 100644 index 000000000..693471fe0 --- /dev/null +++ b/Source/Client/Factions/WastepackAttribution.cs @@ -0,0 +1,327 @@ +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Multiplayer.Client.Util; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Multifaction: wastepack goodwill/retaliation events carry no dumper. +// Vanilla queues { tile, amount } and applies via Faction.OfPlayer in the +// world tick - under the spectator context the goodwill hit lands on nobody +// and the retaliation quest's map pick has no owner in context (falls back to +// the lowest-loadID player). Mirror the pending queue with a dumper faction +// per event, resolved at enqueue time (ambient push, holder chain, per-thing +// stamp, or the synced-command faction), and re-run the apply grouped by +// (tile, dumper) under that faction's context. Unknown dumper: goodwill +// behaves as before (spectator no-op) and retaliation is dropped - a raid +// must never fall back to an arbitrary player. +public static class WastepackAttribution +{ + // Mirrors CompDissolutionEffect_Goodwill.pendingGoodwillEvents 1:1. + // Entries are faction loadIDs, -1 = unknown. + public static readonly List pendingDumpers = new(); + + // Set around enqueue paths whose call site knows the dumper but whose + // thing (if any) can't tell us (abandoned-map events have no thing). + public static int ambientDumper = -1; + + public static bool MpActive => Multiplayer.Client != null && Multiplayer.GameComp.multifaction; + + static bool Ownable(Faction f) => QuestFactionOwnership.IsOwnablePlayerFaction(f); + + public static int ResolveDumper(Thing pack) + { + if (ambientDumper != -1) + return ambientDumper; + + if (pack != null) + { + // Held packs: the holder chain names the dumper (transporter world + // object, caravan, carrying pawn - all still parented at the + // Notify_AbandonedAtTile/CompTick call sites). + for (var holder = pack.ParentHolder; holder != null; holder = holder.ParentHolder) + { + var f = holder switch + { + WorldObject wo => wo.Faction, + Pawn p => p.Faction, + Thing t => t.Faction, + _ => null + }; + if (Ownable(f)) + return f.loadID; + } + + if (Multiplayer.WorldComp.wastepackDumpers.TryGetValue(pack.thingIDNumber, out var stamped) && + Ownable(Find.FactionManager.GetById(stamped))) + return stamped; + } + + // Synced-command context (player-ordered dumps, jobs under a pushed + // faction) - the spectator is not ownable, so world-tick noise stays -1 + if (Ownable(Faction.OfPlayer)) + return Faction.OfPlayer.loadID; + + return -1; + } + + // Keeps the mirror aligned with the vanilla list even when an enqueue was + // gated off (pollution-ignored quests, unspawned packs) or a third party + // enqueued while our patches were bypassed: pad, then append per growth. + public static void MirrorAppend(int prevCount, Thing source) + { + var events = CompDissolutionEffect_Goodwill.pendingGoodwillEvents; + + while (pendingDumpers.Count < prevCount) + pendingDumpers.Add(-1); + + for (int i = prevCount; i < events.Count; i++) + pendingDumpers.Add(ResolveDumper(source)); + } + + // Vanilla WorldUpdate re-run grouped by (tile, dumper); apply wrapped in + // the dumper's faction context so every Faction.OfPlayer read - goodwill, + // IsPlayerTile, AnyPlayerHomeMap gates, GetMap's owner filter - resolves + // to the polluter. Body mirrors CompDissolutionEffect_Goodwill.WorldUpdate + // (1.6.4871); keep in lockstep with the decompile on game updates. + public static void RunAttributed() + { + var events = CompDissolutionEffect_Goodwill.pendingGoodwillEvents; + if (events.Count == 0) + { + pendingDumpers.Clear(); + return; + } + + while (pendingDumpers.Count < events.Count) + pendingDumpers.Add(-1); + + foreach (var group in events + .Select((e, i) => (e, dumperId: pendingDumpers[i])) + .GroupBy(x => (x.e.tile, x.dumperId))) + { + PlanetTile key = group.Key.tile; + var dumper = Find.FactionManager.GetById(group.Key.dumperId); + if (!Ownable(dumper)) + dumper = null; + + if (CompDissolutionEffect_Goodwill.TryGetAffectedSettlement(key, out var result, out var distance)) + { + if (dumper != null) + ((Map)null).PushFaction(dumper); + + try + { + int num = group.Sum(p => p.e.amount); + int num2 = UnityEngine.Mathf.Min( + -UnityEngine.Mathf.RoundToInt( + CompDissolutionEffect_Goodwill.GoodwillFactorOverDistanceCurvePerWastepack.Evaluate(distance) * num), -1); + HistoryEventDef historyEventDef = + ModsConfig.OdysseyActive && key.LayerDef.isSpace ? HistoryEventDefOf.OrbitalPollution + : result.Tile == key ? HistoryEventDefOf.PollutedBase + : distance > 8 ? HistoryEventDefOf.ToxicWasteDumping + : HistoryEventDefOf.PollutedNearbySite; + + if (result.Faction.IsPlayerGoodwillMinimum()) + Messages.Message("MessageAngeredPollutedCell".Translate(result.Faction.Name, historyEventDef.label), + result, MessageTypeDefOf.NegativeEvent); + + Faction.OfPlayer.TryAffectGoodwillWith(result.Faction, num2, canSendMessage: true, + canSendHostilityLetter: true, historyEventDef, result); + + if (!Current.Game.IsPlayerTile(key) && result.Faction.HostileTo(Faction.OfPlayer) && + Find.AnyPlayerHomeMap != null && + Rand.Chance(UnityEngine.Mathf.Clamp01((float)-num2 / 100f))) + { + if (dumper != null) + CompDissolutionEffect_Goodwill.TriggerRetaliationEvent(result.Faction); + else + Log.WarningOnce( + $"MP: dropped a wastepack retaliation with no attributable dumper (tile {key})", + Gen.HashCombineInt(key.GetHashCode(), 0x5EA7)); + } + } + finally + { + if (dumper != null) + FactionExtensions.PopFaction(); + } + } + + CompDissolutionEffect_Goodwill.tmpAvailableSettlements.Clear(); + } + + events.Clear(); + pendingDumpers.Clear(); + } +} + +[HarmonyPatch(typeof(CompDissolutionEffect_Goodwill), nameof(CompDissolutionEffect_Goodwill.WorldUpdate))] +static class WastepackWorldUpdateAttributed +{ + static bool Prefix() + { + if (!WastepackAttribution.MpActive) + { + // Vanilla will clear the event list; keep the mirror in step + WastepackAttribution.pendingDumpers.Clear(); + return true; + } + + WastepackAttribution.RunAttributed(); + return false; + } +} + +[HarmonyPatch(typeof(CompDissolutionEffect_Goodwill), nameof(CompDissolutionEffect_Goodwill.DoDissolutionEffectMap))] +static class WastepackMirrorMapEvent +{ + static void Prefix(ref int __state) => + __state = CompDissolutionEffect_Goodwill.pendingGoodwillEvents.Count; + + static void Postfix(CompDissolutionEffect_Goodwill __instance, int __state) + { + if (WastepackAttribution.MpActive) + WastepackAttribution.MirrorAppend(__state, __instance.parent); + } +} + +[HarmonyPatch(typeof(CompDissolutionEffect_Goodwill), nameof(CompDissolutionEffect_Goodwill.AddWorldDissolutionEvent))] +static class WastepackMirrorWorldEvent +{ + static void Prefix(ref int __state) => + __state = CompDissolutionEffect_Goodwill.pendingGoodwillEvents.Count; + + static void Postfix(int __state) + { + if (WastepackAttribution.MpActive) + WastepackAttribution.MirrorAppend(__state, null); + } +} + +// World dissolution of a held pack (caravan inventory tick, transporter +// "arrived and lost", abandon utilities): resolve from the holder chain while +// the pack is still parented +[HarmonyPatch(typeof(CompDissolution), nameof(CompDissolution.DissolveWorld))] +static class WastepackAmbientFromHolder +{ + static void Prefix(CompDissolution __instance, ref bool __state) + { + if (!WastepackAttribution.MpActive || WastepackAttribution.ambientDumper != -1) + return; + + WastepackAttribution.ambientDumper = WastepackAttribution.ResolveDumper(__instance.parent); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + WastepackAttribution.ambientDumper = -1; + } +} + +// Abandoned map: the synthetic world event has no thing - the dumper is the +// map's owner +[HarmonyPatch(typeof(PollutionInfo), nameof(PollutionInfo.MapRemoved))] +static class WastepackAmbientFromAbandonedMap +{ + static void Prefix(PollutionInfo __instance, ref bool __state) + { + if (!WastepackAttribution.MpActive) + return; + + if (__instance.map.ParentFaction is { IsPlayer: true } owner && + owner != Multiplayer.WorldComp.spectatorFaction) + { + WastepackAttribution.ambientDumper = owner.loadID; + __state = true; + } + } + + static void Finalizer(bool __state) + { + if (__state) + WastepackAttribution.ambientDumper = -1; + } +} + +// Family-4 stamps: a spawned pack rotting on a non-home map has no holder and +// no context, so the dumper is recorded ahead of time (scribed - rot takes +// days) + +// Pods/shuttles landing on an existing map: stamp cargo with the launcher +// before the arrival action scatters it +[HarmonyPatch(typeof(TravellingTransporters), nameof(TravellingTransporters.Arrived))] +static class WastepackStampOnArrival +{ + static void Prefix(TravellingTransporters __instance) + { + if (!WastepackAttribution.MpActive) + return; + if (__instance.Faction is not { IsPlayer: true } faction || + faction == Multiplayer.WorldComp.spectatorFaction) + return; + + foreach (var info in __instance.transporters) + foreach (var thing in info.innerContainer) + if (thing.def == ThingDefOf.Wastepack) + Multiplayer.WorldComp.wastepackDumpers[thing.thingIDNumber] = faction.loadID; + } +} + +// Mech charger/gestator waste: stamp at production with the building's owner +[HarmonyPatch(typeof(CompWasteProducer), nameof(CompWasteProducer.ProduceWaste))] +static class WastepackStampOnProduction +{ + static void Postfix(CompWasteProducer __instance) + { + if (!WastepackAttribution.MpActive) + return; + if (__instance.parent.Faction is not { IsPlayer: true } faction || + faction == Multiplayer.WorldComp.spectatorFaction) + return; + + var owner = __instance.parent.TryGetInnerInteractableThingOwner(); + if (owner == null) + return; + + foreach (var thing in owner) + if (thing.def == ThingDefOf.Wastepack && + !Multiplayer.WorldComp.wastepackDumpers.ContainsKey(thing.thingIDNumber)) + Multiplayer.WorldComp.wastepackDumpers[thing.thingIDNumber] = faction.loadID; + } +} + +// Catch-all: a pack spawning onto a non-home map under a synced command +// (pawn drops, trades) stamps from the acting faction. Existing stamps win. +[HarmonyPatch(typeof(Thing), nameof(Thing.SpawnSetup))] +static class WastepackStampOnSpawn +{ + static void Postfix(Thing __instance, Map map, bool respawningAfterLoad) + { + if (respawningAfterLoad || !WastepackAttribution.MpActive) + return; + if (__instance.def != ThingDefOf.Wastepack || map == null || map.IsPlayerHome) + return; + if (Multiplayer.WorldComp.wastepackDumpers.ContainsKey(__instance.thingIDNumber)) + return; + + var resolved = WastepackAttribution.ResolveDumper(__instance); + if (resolved != -1) + Multiplayer.WorldComp.wastepackDumpers[__instance.thingIDNumber] = resolved; + } +} + +[HarmonyPatch(typeof(Thing), nameof(Thing.Destroy))] +static class WastepackStampPrune +{ + static void Prefix(Thing __instance) + { + if (WastepackAttribution.MpActive && __instance.def == ThingDefOf.Wastepack) + Multiplayer.WorldComp.wastepackDumpers.Remove(__instance.thingIDNumber); + } +} diff --git a/Source/Client/Factions/WorldTickOwnerContext.cs b/Source/Client/Factions/WorldTickOwnerContext.cs new file mode 100644 index 000000000..a84ca84f8 --- /dev/null +++ b/Source/Client/Factions/WorldTickOwnerContext.cs @@ -0,0 +1,84 @@ +using System.Linq; +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Multifaction: WorldObjectComp ticking runs under the spectator world tick, +// so core-side Faction.OfPlayer gates in those paths match nobody. Two sites +// with the same root cause: +// - TimedForcedExit.ForceReform gathers `x.Faction == Faction.OfPlayer` pawns +// and reforms via ExitMapAndCreateCaravan(.., Faction.OfPlayer): under the +// spectator both match nothing and an expiring site map can close over the +// owner's pawns. +// - DefeatAllEnemiesQuestComp.CompTickInterval checks +// AnyHostileActiveThreatToPlayer and delivers rewards to AnyPlayerHomeMap: +// completion misdetects and the letter/rewards misfire for everyone. +// Both get the deterministic owner of the site map pushed around the body. +static class WorldTickOwnerContext +{ + // Deterministic "whose map is this" for maps without a player parent + // faction: the lowest-loadID ownable player faction with a humanlike pawn + // spawned there (same site rule as StorytellerTargetsPatch, same selector + // shape as IdeoContextUtil.PrimaryPlayerFollower - identical on all + // clients). + public static Faction DeterministicMapOwner(Map map) + { + if (map == null) + return null; + + if (map.ParentFaction is { IsPlayer: true } parent && + QuestFactionOwnership.IsOwnablePlayerFaction(parent)) + return parent; + + return Find.FactionManager.AllFactionsListForReading + .Where(f => QuestFactionOwnership.IsOwnablePlayerFaction(f) && + map.mapPawns.SpawnedPawnsInFaction(f).Any(p => p.RaceProps.Humanlike)) + .OrderBy(f => f.loadID) + .FirstOrDefault(); + } + + public static bool PushOwnerOf(Map map) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return false; + + var owner = DeterministicMapOwner(map); + if (owner == null || owner == Faction.OfPlayer) + return false; + + ((Map)null).PushFaction(owner); + return true; + } +} + +[HarmonyPatch(typeof(TimedForcedExit), nameof(TimedForcedExit.ForceReform))] +static class TimedForcedExitOwnerContext +{ + static void Prefix(MapParent mapParent, ref bool __state) => + __state = WorldTickOwnerContext.PushOwnerOf(mapParent?.Map); + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +[HarmonyPatch(typeof(DefeatAllEnemiesQuestComp), nameof(DefeatAllEnemiesQuestComp.CompTickInterval))] +static class DefeatAllEnemiesQuestOwnerContext +{ + static void Prefix(DefeatAllEnemiesQuestComp __instance, ref bool __state) + { + if (__instance.Active && __instance.parent is MapParent { HasMap: true } mapParent) + __state = WorldTickOwnerContext.PushOwnerOf(mapParent.Map); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index 976bc96bf..b8de17190 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -76,6 +76,9 @@ public static bool CompareToLocal(RemoteData remote) { return remote.remoteRwVersion == VersionControl.CurrentVersionString && + // Letter/message text is Translate()d at fire time and scribed - + // mismatched game languages scribe divergent text + remote.remoteLanguage == LanguageDatabase.activeLanguage.folderName && remote.CompareMods(activeModsSnapshot) == ModListDiff.None && remote.remoteFiles.DictsEqual(modFilesSnapshot) && (!remote.hasConfigs || ConfigsEquivalent(remote.remoteModConfigs, @@ -159,6 +162,7 @@ public class RemoteData { public string remoteRwVersion; public string remoteMpVersion; + public string remoteLanguage; public List remoteMods = new(); public ModFileDict remoteFiles = new(); @@ -187,6 +191,7 @@ public static RemoteData FromNet(ServerJoinDataPacket packet) { remoteRwVersion = packet.rwVersion, remoteMpVersion = packet.mpVersion, + remoteLanguage = packet.language, hasConfigs = packet.configsIncluded, }; diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index 560a3b6da..a8f01945d 100644 --- a/Source/Client/Networking/State/ClientJoiningState.cs +++ b/Source/Client/Networking/State/ClientJoiningState.cs @@ -55,6 +55,7 @@ public void HandleInitDataRequest(ServerInitDataRequestPacket packet) => public static ClientInitDataPacket CreateInitDataPacket(bool includeConfigs) => new() { rwVersion = VersionControl.CurrentVersionString, + language = LanguageDatabase.activeLanguage.folderName, debugOnlySyncCmds = Sync.handlers.Where(h => h.debugOnly).Select(h => h.syncId).ToHashSet().ToArray(), hostOnlySyncCmds = Sync.handlers.Where(h => h.hostOnly).Select(h => h.syncId).ToHashSet().ToArray(), modCtorRoundMode = MultiplayerData.modCtorRoundMode, diff --git a/Source/Client/Patches/FrameCacheDeterminism.cs b/Source/Client/Patches/FrameCacheDeterminism.cs new file mode 100644 index 000000000..3d473d3e8 --- /dev/null +++ b/Source/Client/Patches/FrameCacheDeterminism.cs @@ -0,0 +1,29 @@ +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Verse; + +namespace Multiplayer.Client.Patches; + +// PlayerItemAccessibilityUtility caches its accessible-things scan keyed on +// (tile, RealTime.frameCount). Frame counts are per-client machine state: one +// client serves a list computed from older sim state while another recomputes +// fresh. Reachable from the synced sim via quest generation +// (QuestNode_Root_Beggars, QuestNode_TradeRequest_GetRequestedThing) and +// GameComponent_OnetimeNotification's world tick, where a flipped result +// consumes extra synced RNG (RandomNonHostileFaction) - a rand-state desync +// on stable MP, no multifaction needed. In MP the cache is invalidated per +// call: recompute from current synced state, identical on every client. Call +// sites are rare (quest gen; a 2000-tick 5%-gated component), so the rescan +// cost is negligible. +[HarmonyPatch(typeof(PlayerItemAccessibilityUtility), nameof(PlayerItemAccessibilityUtility.CacheAccessibleThings))] +static class ItemAccessibilityCacheInvalidation +{ + static void Prefix() + { + // Frame key, not the tile: PlanetTile.Invalid is a legitimate nearTile + // for map-less callers and would re-match; frameCount is never -1 + if (Multiplayer.Client != null) + PlayerItemAccessibilityUtility.cachedAccessibleThingsForFrame = -1; + } +} diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index de96a649d..25828ee1a 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -285,6 +285,7 @@ private string DiffString() var str = ""; // remoteMpVersion is the MP mod version, not the RimWorld version str += $"MP version match: {remote.remoteMpVersion == MpVersion.Version}, "; + str += $"Language match: {remote.remoteLanguage == LanguageDatabase.activeLanguage.folderName}, "; str += $"Mod list diff: {modListDiff}, "; str += $"Files match: {!filesRoot.children.Any()}, "; str += $"Config sync enabled: {remote.hasConfigs}, "; @@ -367,7 +368,18 @@ private void DrawGeneralTab(Rect inRect) bool mpVersionCheck = remote.remoteMpVersion == MpVersion.Version; Widgets.Checkbox(new Rect(0, 0, 24, 24).CenterOn(checkboxColumn.Down(2 * rowHeight)).min, ref mpVersionCheck); - inRect.yMin += rowHeight * 3 + 30f; + var languageRect = mpVersionRect.Down(rowHeight).Width(rowLabelWidth); + Widgets.DrawHighlightIfMouseover(languageRect); + Widgets.DrawAltRect(headerRect.Down(3 * rowHeight)); + Widgets.Label(languageRect, + "MpMismatchLanguage".TryTranslate(out var languageLabel) ? languageLabel.ToString() : "Game language"); + Widgets.Label(serverColumn.Down(3 * rowHeight), remote.remoteLanguage); + Widgets.Label(clientColumn.Down(3 * rowHeight), LanguageDatabase.activeLanguage.folderName); + + bool languageCheck = remote.remoteLanguage == LanguageDatabase.activeLanguage.folderName; + Widgets.Checkbox(new Rect(0, 0, 24, 24).CenterOn(checkboxColumn.Down(3 * rowHeight)).min, ref languageCheck); + + inRect.yMin += rowHeight * 4 + 30f; } } diff --git a/Source/Common/Networking/Packet/InitDataPacket.cs b/Source/Common/Networking/Packet/InitDataPacket.cs index ecec5699c..4409f9172 100644 --- a/Source/Common/Networking/Packet/InitDataPacket.cs +++ b/Source/Common/Networking/Packet/InitDataPacket.cs @@ -20,6 +20,7 @@ public record struct ClientInitDataPacket : IPacket private const int MaxRawDataLength = 1 << 20; public string rwVersion; + public string language; public int[] debugOnlySyncCmds; public int[] hostOnlySyncCmds; public RoundModeEnum modCtorRoundMode; @@ -37,6 +38,7 @@ public List Mods public void Bind(PacketBuffer buf) { buf.Bind(ref rwVersion); + buf.Bind(ref language); buf.Bind(ref debugOnlySyncCmds, BinderOf.Int()); buf.Bind(ref hostOnlySyncCmds, BinderOf.Int()); buf.BindEnum(ref modCtorRoundMode); diff --git a/Source/Common/Networking/Packet/JoinDataPacket.cs b/Source/Common/Networking/Packet/JoinDataPacket.cs index 507b59f37..2a83126fd 100644 --- a/Source/Common/Networking/Packet/JoinDataPacket.cs +++ b/Source/Common/Networking/Packet/JoinDataPacket.cs @@ -9,6 +9,7 @@ public record struct ServerJoinDataPacket : IPacket public int playerId; public string rwVersion; public string mpVersion; + public string language; public DefCheckStatus[] defStatus; public bool configsIncluded; public byte[] rawServerInitData; @@ -25,6 +26,7 @@ public void Bind(PacketBuffer buf) buf.Bind(ref playerId); buf.Bind(ref rwVersion); buf.Bind(ref mpVersion); + buf.Bind(ref language); buf.Bind(ref defStatus, BinderOf.Enum()); buf.Bind(ref configsIncluded); // Max 512KiB. Should be way more than enough. As an example, one game with ~100 mods used ~35KiB. diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index b2d2a392f..8a1ac2c6c 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -155,6 +155,7 @@ private bool HandleClientJoinData(ClientJoinDataPacket packet) playerId = Player.id, rwVersion = serverInitData.RwVersion, mpVersion = MpVersion.Version, + language = serverInitData.Language, defStatus = defStatus, configsIncluded = serverInitData.IncludeConfigs, rawServerInitData = serverInitData.RawData, diff --git a/Source/Common/ServerInitData.cs b/Source/Common/ServerInitData.cs index 730a1afbb..968f8fdf2 100644 --- a/Source/Common/ServerInitData.cs +++ b/Source/Common/ServerInitData.cs @@ -8,6 +8,7 @@ public record ServerInitData( byte[] RawData, bool IncludeConfigs, string RwVersion, + string Language, HashSet DebugOnlySyncCmds, HashSet HostOnlySyncCmds, (RoundModeEnum, RoundModeEnum) RoundModes, @@ -15,7 +16,7 @@ Dictionary DefInfos ) { public static ServerInitData FromNet(ClientInitDataPacket packet) => new( - packet.rawMods, packet.includeConfigs, packet.rwVersion, + packet.rawMods, packet.includeConfigs, packet.rwVersion, packet.language, packet.debugOnlySyncCmds.ToHashSet(), packet.hostOnlySyncCmds.ToHashSet(), (packet.modCtorRoundMode, packet.staticCtorRoundMode), diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index 9e8b83277..e6ac62012 100644 --- a/Source/Common/Version.cs +++ b/Source/Common/Version.cs @@ -6,7 +6,7 @@ namespace Multiplayer.Common public static class MpVersion { public const string SimpleVersion = "0.11.5"; - public const int Protocol = 56; + public const int Protocol = 57; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/Tests/ChatCommandManagerTest.cs b/Source/Tests/ChatCommandManagerTest.cs index 6dc33c554..befe4fbd3 100644 --- a/Source/Tests/ChatCommandManagerTest.cs +++ b/Source/Tests/ChatCommandManagerTest.cs @@ -360,6 +360,7 @@ public void ModsCommand_ShowsServerModList() ]), false, "1.6.4633", + "English", [], [], default, @@ -389,6 +390,7 @@ public void ModsCommand_CanPageThroughServerModList() ]), false, "1.6.4633", + "English", [], [], default, diff --git a/Source/Tests/Helper/TestJoiningState.cs b/Source/Tests/Helper/TestJoiningState.cs index 5121b0cc5..3198bef9f 100644 --- a/Source/Tests/Helper/TestJoiningState.cs +++ b/Source/Tests/Helper/TestJoiningState.cs @@ -25,6 +25,7 @@ protected override async Task RunState() connection.Send(new ClientInitDataPacket { rwVersion = RwVersion, + language = "English", debugOnlySyncCmds = [], hostOnlySyncCmds = [], modCtorRoundMode = RoundModeEnum.ToNearest, diff --git a/Source/Tests/Helper/TestLoadingKeepAliveState.cs b/Source/Tests/Helper/TestLoadingKeepAliveState.cs index c0bb37ef4..e0cb4935a 100644 --- a/Source/Tests/Helper/TestLoadingKeepAliveState.cs +++ b/Source/Tests/Helper/TestLoadingKeepAliveState.cs @@ -25,6 +25,7 @@ protected override async Task RunState() connection.Send(new ClientInitDataPacket { rwVersion = RwVersion, + language = "English", debugOnlySyncCmds = [], hostOnlySyncCmds = [], modCtorRoundMode = RoundModeEnum.ToNearest, diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index fbe1eac84..b6174d828 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -202,6 +202,7 @@ private static IEnumerable RoundtripPackets() gameName = "GameName", playerId = 1, rwVersion = "1.6.4566", + language = "English", mpVersion = "0.11.0+123456", defStatus = [ @@ -222,6 +223,7 @@ private static IEnumerable RoundtripPackets() yield return new ClientInitDataPacket { rwVersion = "1.0.0", + language = "English", debugOnlySyncCmds = [1, 2, 3, 4], hostOnlySyncCmds = [1], modCtorRoundMode = RoundModeEnum.ToNearest, diff --git a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt index 08c775816..e40528e62 100644 --- a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt @@ -1,3 +1,3 @@ This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object -05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (119 bytes) +05-00-00-00-31-2E-30-2E-30-07-00-00-00-45-6E-67-6C-69-73-68-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (130 bytes) diff --git a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt index 0e11269af..d3cbfc1ac 100644 --- a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt @@ -1,3 +1,3 @@ This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object -08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (97 bytes) +08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-07-00-00-00-45-6E-67-6C-69-73-68-05-00-00-00-00-00-02-03-01-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (108 bytes) From 6a7123e7359ff150694c7c52b4221cf4b016ddf2 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Tue, 21 Jul 2026 20:50:26 +0200 Subject: [PATCH 09/27] Add Turn It On and Off compat, gate spectator home check from sim, fix version nag - TurnItOnandOff keeps unsaved per-client state and per-client settings; host/joiner power drift forks rand in PowerNet battery shuffle (repeatable tester desyncs). Compat clears its state on every client at every MP (re)load and carries host settings via the game comp. - Map.IsPlayerHome spectator override now UI-only: in ticking/command contexts it returned per-client results (spectator world tick). - Version update warning only on exact continuous builds; git describe on branches containing the tag made downstream builds nag testers to "update" onto upstream. --- .../Client/Comp/Game/MultiplayerGameComp.cs | 4 + Source/Client/Factions/MultifactionPatches.cs | 6 + Source/Client/Patches/TIOAOCompat.cs | 134 ++++++++++++++++++ Source/Client/Saving/SaveLoad.cs | 5 + Source/Client/Util/VersionChecker.cs | 5 +- 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 Source/Client/Patches/TIOAOCompat.cs diff --git a/Source/Client/Comp/Game/MultiplayerGameComp.cs b/Source/Client/Comp/Game/MultiplayerGameComp.cs index 2f3909cc7..228126da4 100644 --- a/Source/Client/Comp/Game/MultiplayerGameComp.cs +++ b/Source/Client/Comp/Game/MultiplayerGameComp.cs @@ -21,6 +21,9 @@ public class MultiplayerGameComp : IExposable, IHasSessionData public string idBlockBase64; + // Host's Turn It On and Off settings (TIOAOCompat); null when absent + public string tioaoHostSettings; + public bool IsLowestWins => timeControl == TimeControl.LowestWins; public PlayerData LocalPlayerDataOrNull => playerData.GetValueOrDefault(Multiplayer.session.playerId); @@ -34,6 +37,7 @@ public void ExposeData() Scribe_Values.Look(ref pauseOnLetter, "pauseOnLetter"); Scribe_Values.Look(ref timeControl, "timeControl"); Scribe_Values.Look(ref nextSessionId, "nextSessionId"); + Scribe_Values.Look(ref tioaoHostSettings, "tioaoHostSettings"); // Store for back-compat conversion in GameExposeComponentsPatch if (Scribe.mode == LoadSaveMode.LoadingVars) diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index 9eea55518..9e5b12be9 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -862,6 +862,12 @@ static class Map_IsPlayerHome_Spectator_Patch { static bool Prefix(Map __instance, ref bool __result) { + // Spectator override shapes UI only: in sim contexts OfPlayer can be + // the spectator faction on one client but not others, and IsPlayerHome + // feeds sim-side consumers - all clients must take vanilla there + if (Multiplayer.Ticking || Multiplayer.ExecutingCmds) + return true; + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || Faction.OfPlayer != Multiplayer.WorldComp.spectatorFaction) return true; diff --git a/Source/Client/Patches/TIOAOCompat.cs b/Source/Client/Patches/TIOAOCompat.cs new file mode 100644 index 000000000..c3ddfa275 --- /dev/null +++ b/Source/Client/Patches/TIOAOCompat.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Verse; + +namespace Multiplayer.Client.Patches; + +// Turn It On and Off keeps all of its state per client and unsaved (building +// sets, rescan countdowns, power values from local settings), so host and +// joiner drift apart and fork rand in PowerNet's battery shuffle. Under MP: +// suppress the mod's own long-event-timed MapLoaded clear, clear its state on +// every client after every MP (re)load instead (its count-mismatch check then +// full-rescans next tick, identically everywhere), and carry the host's +// settings via the game comp so every client runs the same values. +// Reflection-gated: mod absent = no-op; stands down if MpCompat patches it. +[StaticConstructorOnStartup] +static class TIOAOCompat +{ + private static Type modType; + private static Type settingsType; + + public static bool Active => modType != null; + + static TIOAOCompat() + { + modType = AccessTools.TypeByName("TurnItOnandOff.TurnItOnandOff"); + if (modType == null) + return; + + settingsType = AccessTools.TypeByName("TurnItOnandOff.ModSettings"); + if (settingsType == null) + { + Log.Warning("MP: TIOAO compat found the mod but not its ModSettings - mod version mismatch? Compat disabled."); + modType = null; + return; + } + + if (ModsConfig.IsActive("rwmt.multiplayercompatibility") && + AccessTools.TypeByName("Multiplayer.Compat.TurnItOnandOff") != null) + { + Log.Message("MP: TIOAO compat skipped - Multiplayer Compatibility covers the mod itself"); + modType = null; + return; + } + + Multiplayer.harmony.Patch( + AccessTools.Method(modType, "MapLoaded"), + prefix: new HarmonyMethod(typeof(TIOAOCompat), nameof(SkipModOwnClear)) + ); + + Log.Message("MP: TIOAO compat active - deterministic resets + host settings"); + } + + // In MP the compat owns every reset point; the mod's own long-event-timed + // clear is the asymmetric one. + private static bool SkipModOwnClear() => Multiplayer.Client == null; + + // Called from SaveAndReloadCore before the game is written: the first + // join-point snapshot must already carry the host's settings. + public static void BeforeMpSave() + { + if (!Active || Multiplayer.LocalServer == null || Multiplayer.GameComp == null) + return; + if (Multiplayer.GameComp.tioaoHostSettings == null) + Multiplayer.GameComp.tioaoHostSettings = CaptureSettings(); + } + + // Called from LoadInMainThread after every MP (re)load, host reloads and + // joiner loads alike: apply host settings, then reset the mod's state so + // every client rebuilds from the same blank slate on the next tick. + public static void AfterMpLoad() + { + if (!Active || Multiplayer.Client == null) + return; + + try + { + if (Multiplayer.GameComp?.tioaoHostSettings is { } blob) + ApplySettings(blob); + + var singleton = AccessTools.Field(modType, "singleton").GetValue(null); + if (singleton != null) + AccessTools.Method(modType, "Clear").Invoke(singleton, null); + } + catch (Exception e) + { + Log.Warning($"MP: TIOAO compat reset failed - mod version mismatch? ({e.Message})"); + } + } + + private static string CaptureSettings() + { + string Defs(string field) => + string.Join(",", (List)AccessTools.Field(settingsType, field).GetValue(null) ?? new List()); + + return string.Join(";", + ((float)AccessTools.Field(settingsType, "IdlePowerUsage").GetValue(null)).ToString(CultureInfo.InvariantCulture), + ((float)AccessTools.Field(settingsType, "ActivePowerMultiplier").GetValue(null)).ToString(CultureInfo.InvariantCulture), + ((int)AccessTools.Field(settingsType, "RescanPeriod").GetValue(null)).ToString(CultureInfo.InvariantCulture), + Defs("WhitelistedDefs"), + Defs("BlacklistedDefs"), + Defs("ReservableDefs") + ); + } + + private static void ApplySettings(string blob) + { + var parts = blob.Split(';'); + if (parts.Length != 6) + { + Log.Warning($"MP: TIOAO compat host settings malformed ({parts.Length} fields) - local settings stay"); + return; + } + + List Defs(string csv) => + csv.Length == 0 ? new List() : csv.Split(',').ToList(); + + AccessTools.Field(settingsType, "IdlePowerUsage").SetValue(null, float.Parse(parts[0], CultureInfo.InvariantCulture)); + AccessTools.Field(settingsType, "ActivePowerMultiplier").SetValue(null, float.Parse(parts[1], CultureInfo.InvariantCulture)); + AccessTools.Field(settingsType, "RescanPeriod").SetValue(null, int.Parse(parts[2], CultureInfo.InvariantCulture)); + AccessTools.Field(settingsType, "WhitelistedDefs").SetValue(null, Defs(parts[3])); + AccessTools.Field(settingsType, "BlacklistedDefs").SetValue(null, Defs(parts[4])); + AccessTools.Field(settingsType, "ReservableDefs").SetValue(null, Defs(parts[5])); + + // Rebuild the derived per-def power table from the applied values + var singleton = AccessTools.Field(modType, "singleton").GetValue(null); + if (singleton != null) + AccessTools.Method(modType, "initPowerValues", Type.EmptyTypes) + ?.Invoke(singleton, null); + } +} diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 857ff0b18..196c68bed 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -36,6 +36,9 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio { Multiplayer.reloading = true; + // Host TIOAO settings must be in the game comp before it is saved + Patches.TIOAOCompat.BeforeMpSave(); + var tweenedPos = new Dictionary(); var localFactionId = Multiplayer.RealPlayerFaction.loadID; var mapCmds = new Dictionary>(); @@ -139,6 +142,8 @@ public static void LoadInMainThread(TempGameData gameData) // SaveCompression enabled in the patch SavedGameLoaderNow.LoadGameFromSaveFileNow(null); + Patches.TIOAOCompat.AfterMpLoad(); + DeepProfiler.End(); } diff --git a/Source/Client/Util/VersionChecker.cs b/Source/Client/Util/VersionChecker.cs index 22cf2625f..2881fcaad 100644 --- a/Source/Client/Util/VersionChecker.cs +++ b/Source/Client/Util/VersionChecker.cs @@ -34,9 +34,12 @@ public static void Init() }); } + // Exact match: git describe on any branch CONTAINING the continuous tag + // yields "continuous-N-g" - those are downstream builds, not the + // continuous release, and must not be told to "update" to upstream public static bool IsContinuousRelease => Multiplayer.modContentPack.ModMetaData.Source == ContentSource.ModsFolder && - MpVersion.GitDescription?.StartsWith("continuous") == true && + MpVersion.GitDescription == "continuous" && MpVersion.GitHash?.EndsWith("dirty") == false; public static bool IsLocalBuild => From fd3fa3477e5fa6c8483f5f9de284586e2f1b626f Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Tue, 21 Jul 2026 21:22:01 +0200 Subject: [PATCH 10/27] Multifaction: per-pair NPC goodwill and shared-ideo pitfall fixes - Natural goodwill drift and ideo-derived opinion caps ran against the spectator faction only (Faction.OfPlayer during the world tick): player factions kept permanent grudges and caps were computed against an ideo nobody plays. Each ownable faction now has its own GoodwillSituationManager and per-NPC drift timers; drift and the 1000-tick caps recalc fan out per faction under its context. Vanilla's pairwise Faction.relations supplies the storage. - Goodwill cache fills from UI context no longer persist (call-time worker values differed per client until the next recalc while sim reads trusted them, and persisting fills fired relation-kind flips from UI context). - Shared ideos: reformation applies only for the primary follower faction; ritual repeat-penalty windows are per faction. --- Source/Client/Comp/World/FactionWorldData.cs | 19 +- .../Client/Comp/World/MultiplayerWorldComp.cs | 13 ++ Source/Client/Factions/GoodwillPairPatches.cs | 162 ++++++++++++++++++ .../Client/Factions/IdeoSharedStatePatches.cs | 98 +++++++++++ 4 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 Source/Client/Factions/GoodwillPairPatches.cs create mode 100644 Source/Client/Factions/IdeoSharedStatePatches.cs diff --git a/Source/Client/Comp/World/FactionWorldData.cs b/Source/Client/Comp/World/FactionWorldData.cs index 09f6869f1..09dc81dbf 100644 --- a/Source/Client/Comp/World/FactionWorldData.cs +++ b/Source/Client/Comp/World/FactionWorldData.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using RimWorld; using Verse; @@ -25,6 +26,11 @@ public class FactionWorldData : IExposable // DateNotifier patch in AsyncTime/AsyncTimePatches.cs. public Season lastSeason; + // Per-faction goodwill caps cache (runtime-only, rebuilds deterministically) + // and per-NPC drift timers replacing vanilla's single OfPlayer-bound timer + public GoodwillSituationManager goodwillSituationManager; + public Dictionary naturalGoodwillTimers; + public FactionWorldData() { } public void ExposeData() @@ -45,9 +51,12 @@ public void ExposeData() Scribe_Deep.Look(ref bossgroup, "bossgroup"); Scribe_Values.Look(ref lastSeason, "lastSeason", Season.Undefined); + Scribe_Collections.Look(ref naturalGoodwillTimers, "naturalGoodwillTimers", LookMode.Value, LookMode.Value); if (Scribe.mode == LoadSaveMode.LoadingVars) { + goodwillSituationManager ??= new GoodwillSituationManager(); + naturalGoodwillTimers ??= new Dictionary(); researchManager ??= new ResearchManager(); drugPolicyDatabase ??= new DrugPolicyDatabase(); outfitDatabase ??= new OutfitDatabase(); @@ -93,7 +102,10 @@ public static FactionWorldData New(int factionId) storyWatcher = new StoryWatcher(), analysisManager = new AnalysisManager(), - bossgroup = FactionBossgroupData.New() + bossgroup = FactionBossgroupData.New(), + + goodwillSituationManager = new GoodwillSituationManager(), + naturalGoodwillTimers = new Dictionary() }; } @@ -114,7 +126,10 @@ public static FactionWorldData FromCurrent(int factionId) storyWatcher = Find.StoryWatcher, analysisManager = Current.Game.analysisManager, - bossgroup = FactionBossgroupData.FromCurrent() + bossgroup = FactionBossgroupData.FromCurrent(), + + goodwillSituationManager = Find.FactionManager.goodwillSituationManager, + naturalGoodwillTimers = new Dictionary() }; } } diff --git a/Source/Client/Comp/World/MultiplayerWorldComp.cs b/Source/Client/Comp/World/MultiplayerWorldComp.cs index e5ed703c8..cbe5273b9 100644 --- a/Source/Client/Comp/World/MultiplayerWorldComp.cs +++ b/Source/Client/Comp/World/MultiplayerWorldComp.cs @@ -30,6 +30,10 @@ public class MultiplayerWorldComp : IHasSessionData // destroy. See Factions/WastepackAttribution.cs. public Dictionary wastepackDumpers = new(); + // Multifaction: per-faction ritual repeat-penalty windows. + // Key = (precept.Id << 32) | faction.loadID -> finish tick (see IdeoSharedStatePatches) + public Dictionary ritualLastFinished = new(); + private int currentFactionId; public MultiplayerWorldComp(World world) @@ -44,12 +48,16 @@ public void ExposeData() ExposeFactionData(); Scribe_Collections.Look(ref questOwnership, "questOwnership", LookMode.Value, LookMode.Value); + Scribe_Collections.Look(ref ritualLastFinished, "ritualLastFinished", LookMode.Value, LookMode.Value); if (Scribe.mode != LoadSaveMode.Saving) questOwnership ??= new Dictionary(); Scribe_Collections.Look(ref wastepackDumpers, "wastepackDumpers", LookMode.Value, LookMode.Value); if (Scribe.mode != LoadSaveMode.Saving) + { wastepackDumpers ??= new Dictionary(); + ritualLastFinished ??= new Dictionary(); + } sessionManager.ExposeSessions(); // Ensure a pause lock session exists if there's any pause locks registered @@ -187,6 +195,11 @@ public void SetFaction(Faction faction) if (data.analysisManager != null) game.analysisManager = data.analysisManager; + // Goodwill caps/natural-goodwill cache per faction: workers read + // Faction.OfPlayer's ideo, so each faction's queries hit its own instance + if (Multiplayer.GameComp.multifaction && data.goodwillSituationManager != null) + Find.FactionManager.goodwillSituationManager = data.goodwillSituationManager; + // Bossgroup component state: dict/list swap by reference, the cooldown // int is copied in (written back by BossgroupLastCalledWriteBack). // Multifaction only - in plain MP the vanilla component stays live and diff --git a/Source/Client/Factions/GoodwillPairPatches.cs b/Source/Client/Factions/GoodwillPairPatches.cs new file mode 100644 index 000000000..dfe76be56 --- /dev/null +++ b/Source/Client/Factions/GoodwillPairPatches.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using HarmonyLib; +using Multiplayer.Client.Factions; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.Patches; + +// Vanilla funnels all goodwill through Faction.OfPlayer - the spectator during +// the multifaction world tick - so natural drift and ideo-derived caps only +// ever touched the spectator pair. Faction.relations is already pairwise; +// these patches fix the access paths: per-faction GoodwillSituationManager +// instances (swapped in SetFaction) and per-NPC drift timers, with the drift +// check and 1000-tick caps recalc fanned per ownable faction under context. + +[HarmonyPatch(typeof(Faction), "CheckReachNaturalGoodwill")] +static class CheckReachNaturalGoodwillPatch +{ + static bool Prefix(Faction __instance) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return true; + + // Vanilla's early-outs, replicated so the fan below only runs for + // factions the original would have processed + if (__instance.IsPlayer || !__instance.HasGoodwill || __instance.def.permanentEnemy) + return false; + + foreach (var kv in Multiplayer.WorldComp.factionData) + { + var faction = Find.FactionManager.GetById(kv.Key); + if (!QuestFactionOwnership.IsOwnablePlayerFaction(faction)) + continue; + + ((Map)null).PushFaction(faction); + try + { + DriftForPair(__instance, kv.Value); + } + finally + { + FactionExtensions.PopFaction(); + } + } + + return false; + } + + // Vanilla CheckReachNaturalGoodwill body (1.6.4871) against the pushed + // faction's manager instance and this pair's own timer. OfPlayer is the + // pushed faction here, so BaseGoodwillWith/TryAffectGoodwillWith land on + // the right FactionRelation pair. + private static void DriftForPair(Faction npc, FactionWorldData data) + { + data.naturalGoodwillTimers.TryGetValue(npc.loadID, out int timer); + + int current = npc.BaseGoodwillWith(Faction.OfPlayer); + int natural = data.goodwillSituationManager.GetNaturalGoodwill(npc); + var window = new IntRange(natural - 50, natural + 50); + + if (window.Includes(current)) + { + data.naturalGoodwillTimers[npc.loadID] = 0; + return; + } + + timer++; + + if (current < window.min) + { + if (timer >= 3000000) + { + npc.TryAffectGoodwillWith(Faction.OfPlayer, Mathf.Min(10, window.min - current), + canSendMessage: true, canSendHostilityLetter: !npc.temporary, + HistoryEventDefOf.ReachNaturalGoodwill); + timer = 0; + } + } + else if (current > window.max && timer >= 3000000) + { + npc.TryAffectGoodwillWith(Faction.OfPlayer, -Mathf.Min(10, current - window.max), + canSendMessage: true, canSendHostilityLetter: !npc.temporary, + HistoryEventDefOf.ReachNaturalGoodwill); + timer = 0; + } + + data.naturalGoodwillTimers[npc.loadID] = timer; + } +} + +[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GoodwillManagerTick))] +static class GoodwillManagerTickPatch +{ + static bool Prefix() + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return true; + + if (Find.TickManager.TicksGame % 1000 != 0) + return false; + + foreach (var kv in Multiplayer.WorldComp.factionData) + { + var faction = Find.FactionManager.GetById(kv.Key); + if (!QuestFactionOwnership.IsOwnablePlayerFaction(faction)) + continue; + + ((Map)null).PushFaction(faction); + try + { + kv.Value.goodwillSituationManager.RecalculateAll(canSendHostilityChangedLetter: true); + } + finally + { + FactionExtensions.PopFaction(); + } + } + + return false; + } +} + +// Cache fills from UI context must not persist: workers read live sim state, +// so a client whose UI filled an entry at tick T holds different cached +// values than one whose entry filled at the 1000-tick recalc - and the drift +// check trusts the cache. Pre-existing upstream hazard on stable MP too +// (faction dialog explanations fill lazily), worse with per-faction caches. +// UI reads get a fresh uncached computation; sim-context fills persist as +// vanilla. Persisting fills also fire CheckHostilityChanged (relation-kind +// flips + letters), so this gate keeps that sim-only as a side effect. +[HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetSituations))] +static class GetSituationsUIGuard +{ + private static readonly AccessTools.FieldRef>> + cachedDataRef = AccessTools.FieldRefAccess>>("cachedData"); + + private static readonly FastInvokeHandler recalculateInto = MethodInvoker.GetHandler( + AccessTools.Method(typeof(GoodwillSituationManager), "Recalculate", + new[] { typeof(Faction), typeof(List) })); + + static bool Prefix(GoodwillSituationManager __instance, Faction other, + ref List __result) + { + if (Multiplayer.Client == null || Multiplayer.Ticking || Multiplayer.ExecutingCmds) + return true; + + if (other == null || other.IsPlayer) + return true; + + if (cachedDataRef(__instance).TryGetValue(other, out var cached)) + { + __result = cached; + return false; + } + + var fresh = new List(); + recalculateInto(__instance, other, fresh); + __result = fresh; + return false; + } +} diff --git a/Source/Client/Factions/IdeoSharedStatePatches.cs b/Source/Client/Factions/IdeoSharedStatePatches.cs new file mode 100644 index 000000000..1074f9430 --- /dev/null +++ b/Source/Client/Factions/IdeoSharedStatePatches.cs @@ -0,0 +1,98 @@ +using HarmonyLib; +using RimWorld; +using Verse; +using Verse.AI.Group; + +namespace Multiplayer.Client.Patches; + +// Shared ideos keep "one church shares its state" semantics; these patches +// fix only the cross-faction pitfalls: reformation applies only for the +// ideo's primary follower faction, and ritual repeat-penalty windows are +// per (precept, faction) so one faction's ritual doesn't burn another's +// 20-day quality window. The shared field keeps vanilla non-MP behavior. + +static class RitualPairs +{ + public static long Key(Precept precept, Faction faction) => + ((long)precept.Id << 32) | (uint)faction.loadID; + + public static bool Active => + Multiplayer.Client != null && Multiplayer.GameComp.multifaction; + + // Finish tick for the context faction's pair, or -1 (never performed) + public static int PairFinishTick(Precept_Ritual ritual) + { + var faction = Faction.OfPlayer; + if (faction == null) + return -1; + return Multiplayer.WorldComp.ritualLastFinished + .TryGetValue(Key(ritual, faction), out var tick) ? tick : -1; + } +} + +[HarmonyPatch(typeof(IdeoDevelopmentUtility), nameof(IdeoDevelopmentUtility.ApplyChangesToIdeo))] +static class SharedIdeoReformationGate +{ + static bool Prefix(Ideo ideo) + { + if (!RitualPairs.Active) + return true; + + var owner = IdeoContextUtil.PrimaryPlayerFollower(ideo); + if (owner == null || Faction.OfPlayer == owner) + return true; + + Log.Message($"MP: reform of shared ideo '{ideo?.name}' skipped - only its primary follower faction may reform it"); + return false; + } +} + +[HarmonyPatch(typeof(LordJob_Ritual), nameof(LordJob_Ritual.ApplyOutcome))] +static class RitualFinishRecordsPair +{ + static void Postfix(LordJob_Ritual __instance) + { + if (!RitualPairs.Active) + return; + + var ritual = __instance.Ritual; + // The shared field is stamped with the current tick only on the + // success path - that stamp is the signal this run finished + if (ritual == null || ritual.lastFinishedTick != GenTicks.TicksGame) + return; + + if (__instance.lord?.faction is not { IsPlayer: true } faction) + return; + + Multiplayer.WorldComp.ritualLastFinished[RitualPairs.Key(ritual, faction)] = GenTicks.TicksGame; + } +} + +[HarmonyPatch(typeof(Precept_Ritual), nameof(Precept_Ritual.TicksSinceLastPerformed), MethodType.Getter)] +static class TicksSinceLastPerformedPerFaction +{ + static bool Prefix(Precept_Ritual __instance, ref int __result) + { + if (!RitualPairs.Active) + return true; + + __result = GenTicks.TicksGame - RitualPairs.PairFinishTick(__instance); + return false; + } +} + +[HarmonyPatch(typeof(Precept_Ritual), nameof(Precept_Ritual.RepeatPenaltyActive), MethodType.Getter)] +static class RepeatPenaltyActivePerFaction +{ + static bool Prefix(Precept_Ritual __instance, ref bool __result) + { + if (!RitualPairs.Active) + return true; + + // Vanilla body against the pair's finish tick + __result = __instance.isAnytime && __instance.def.useRepeatPenalty && + RitualPairs.PairFinishTick(__instance) != -1 && + __instance.TicksSinceLastPerformed < 1200000; + return false; + } +} From 13bd05345baed9916b761c918efc6512b002c19b Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Tue, 21 Jul 2026 22:14:17 +0200 Subject: [PATCH 11/27] Move mod compat patches to the mod-compat branch Blood Animations, Vanilla Nutrient Paste Expanded, and Turn It On and Off compat move out of this PR at maintainer request; they stay on the mod-compat branch for re-merge once a home is decided. Also removes the TIOAO host-settings plumbing from the game comp and save/reload path. Saves written with compat builds keep an inert tioaoHostSettings node. --- .../Client/Comp/Game/MultiplayerGameComp.cs | 4 - .../Client/Patches/BloodAnimationsCompat.cs | 77 ---------- Source/Client/Patches/TIOAOCompat.cs | 134 ------------------ Source/Client/Patches/VNPECompat.cs | 63 -------- Source/Client/Saving/SaveLoad.cs | 5 - 5 files changed, 283 deletions(-) delete mode 100644 Source/Client/Patches/BloodAnimationsCompat.cs delete mode 100644 Source/Client/Patches/TIOAOCompat.cs delete mode 100644 Source/Client/Patches/VNPECompat.cs diff --git a/Source/Client/Comp/Game/MultiplayerGameComp.cs b/Source/Client/Comp/Game/MultiplayerGameComp.cs index 228126da4..2f3909cc7 100644 --- a/Source/Client/Comp/Game/MultiplayerGameComp.cs +++ b/Source/Client/Comp/Game/MultiplayerGameComp.cs @@ -21,9 +21,6 @@ public class MultiplayerGameComp : IExposable, IHasSessionData public string idBlockBase64; - // Host's Turn It On and Off settings (TIOAOCompat); null when absent - public string tioaoHostSettings; - public bool IsLowestWins => timeControl == TimeControl.LowestWins; public PlayerData LocalPlayerDataOrNull => playerData.GetValueOrDefault(Multiplayer.session.playerId); @@ -37,7 +34,6 @@ public void ExposeData() Scribe_Values.Look(ref pauseOnLetter, "pauseOnLetter"); Scribe_Values.Look(ref timeControl, "timeControl"); Scribe_Values.Look(ref nextSessionId, "nextSessionId"); - Scribe_Values.Look(ref tioaoHostSettings, "tioaoHostSettings"); // Store for back-compat conversion in GameExposeComponentsPatch if (Scribe.mode == LoadSaveMode.LoadingVars) diff --git a/Source/Client/Patches/BloodAnimationsCompat.cs b/Source/Client/Patches/BloodAnimationsCompat.cs deleted file mode 100644 index f3c15dd75..000000000 --- a/Source/Client/Patches/BloodAnimationsCompat.cs +++ /dev/null @@ -1,77 +0,0 @@ -using HarmonyLib; -using RimWorld; -using Verse; - -namespace Multiplayer.Client.Patches; - -// Blood Animations rolls the synced Rand stream inside its effect patches -// behind per-client gates (mod settings, frame-driven fleck updates) and -// spawns filth from the render path - a confirmed desync source. In MP, -// isolate its rand usage and suppress its filth; visuals still play locally. -[StaticConstructorOnStartup] -static class BloodAnimationsCompat -{ - private static int effectDepth; - - static BloodAnimationsCompat() - { - if (AccessTools.TypeByName("BloodAnimations.FleckThrownBlood") == null) - return; - - var targets = new (string typeName, string methodName)[] - { - ("BloodAnimations.DamageWorker_AddInjury_ApplyToPawn", "ApplyToPawn"), - ("BloodAnimations.Pawn_HealthTracker_HealthTick", "HealthTick"), - ("BloodAnimations.Pawn_Kill", "Kill"), - ("BloodAnimations.Verb_TryCastNextBurstShot", "TryCastNextBurstShot"), - ("BloodAnimations.FleckThrownBlood", "TimeInterval"), - }; - - var prefix = new HarmonyMethod(typeof(BloodAnimationsCompat), nameof(EffectPrefix)); - var finalizer = new HarmonyMethod(typeof(BloodAnimationsCompat), nameof(EffectFinalizer)); - - foreach (var (typeName, methodName) in targets) - { - var method = AccessTools.Method(AccessTools.TypeByName(typeName), methodName); - if (method == null) - { - Log.Warning($"MP: Blood Animations compat found no {typeName}.{methodName} - mod version mismatch? That wrap skipped."); - continue; - } - - Multiplayer.harmony.Patch(method, prefix: prefix, finalizer: finalizer); - } - - var tryMakeFilth = AccessTools.Method(typeof(FilthMaker), nameof(FilthMaker.TryMakeFilth), - new[] { typeof(IntVec3), typeof(Map), typeof(ThingDef), typeof(int), typeof(FilthSourceFlags), typeof(bool) }); - Multiplayer.harmony.Patch(tryMakeFilth, - prefix: new HarmonyMethod(typeof(BloodAnimationsCompat), nameof(SuppressFilthPrefix))); - - Log.Message("MP: Blood Animations compat patches applied"); - } - - static void EffectPrefix() - { - if (Multiplayer.Client == null) return; - effectDepth++; - Rand.PushState(); - } - - // Finalizer, not postfix: an exception mid-effect must not leave the - // pushed state (or the depth counter) behind - static void EffectFinalizer() - { - if (Multiplayer.Client == null || effectDepth == 0) return; - Rand.PopState(); - effectDepth--; - } - - // Filth is simulation state; Blood Animations creates it from per-client - // gates, so in MP its effect scopes must not spawn any - static bool SuppressFilthPrefix(ref bool __result) - { - if (effectDepth == 0 || Multiplayer.Client == null) return true; - __result = false; - return false; - } -} diff --git a/Source/Client/Patches/TIOAOCompat.cs b/Source/Client/Patches/TIOAOCompat.cs deleted file mode 100644 index c3ddfa275..000000000 --- a/Source/Client/Patches/TIOAOCompat.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Reflection; -using HarmonyLib; -using Verse; - -namespace Multiplayer.Client.Patches; - -// Turn It On and Off keeps all of its state per client and unsaved (building -// sets, rescan countdowns, power values from local settings), so host and -// joiner drift apart and fork rand in PowerNet's battery shuffle. Under MP: -// suppress the mod's own long-event-timed MapLoaded clear, clear its state on -// every client after every MP (re)load instead (its count-mismatch check then -// full-rescans next tick, identically everywhere), and carry the host's -// settings via the game comp so every client runs the same values. -// Reflection-gated: mod absent = no-op; stands down if MpCompat patches it. -[StaticConstructorOnStartup] -static class TIOAOCompat -{ - private static Type modType; - private static Type settingsType; - - public static bool Active => modType != null; - - static TIOAOCompat() - { - modType = AccessTools.TypeByName("TurnItOnandOff.TurnItOnandOff"); - if (modType == null) - return; - - settingsType = AccessTools.TypeByName("TurnItOnandOff.ModSettings"); - if (settingsType == null) - { - Log.Warning("MP: TIOAO compat found the mod but not its ModSettings - mod version mismatch? Compat disabled."); - modType = null; - return; - } - - if (ModsConfig.IsActive("rwmt.multiplayercompatibility") && - AccessTools.TypeByName("Multiplayer.Compat.TurnItOnandOff") != null) - { - Log.Message("MP: TIOAO compat skipped - Multiplayer Compatibility covers the mod itself"); - modType = null; - return; - } - - Multiplayer.harmony.Patch( - AccessTools.Method(modType, "MapLoaded"), - prefix: new HarmonyMethod(typeof(TIOAOCompat), nameof(SkipModOwnClear)) - ); - - Log.Message("MP: TIOAO compat active - deterministic resets + host settings"); - } - - // In MP the compat owns every reset point; the mod's own long-event-timed - // clear is the asymmetric one. - private static bool SkipModOwnClear() => Multiplayer.Client == null; - - // Called from SaveAndReloadCore before the game is written: the first - // join-point snapshot must already carry the host's settings. - public static void BeforeMpSave() - { - if (!Active || Multiplayer.LocalServer == null || Multiplayer.GameComp == null) - return; - if (Multiplayer.GameComp.tioaoHostSettings == null) - Multiplayer.GameComp.tioaoHostSettings = CaptureSettings(); - } - - // Called from LoadInMainThread after every MP (re)load, host reloads and - // joiner loads alike: apply host settings, then reset the mod's state so - // every client rebuilds from the same blank slate on the next tick. - public static void AfterMpLoad() - { - if (!Active || Multiplayer.Client == null) - return; - - try - { - if (Multiplayer.GameComp?.tioaoHostSettings is { } blob) - ApplySettings(blob); - - var singleton = AccessTools.Field(modType, "singleton").GetValue(null); - if (singleton != null) - AccessTools.Method(modType, "Clear").Invoke(singleton, null); - } - catch (Exception e) - { - Log.Warning($"MP: TIOAO compat reset failed - mod version mismatch? ({e.Message})"); - } - } - - private static string CaptureSettings() - { - string Defs(string field) => - string.Join(",", (List)AccessTools.Field(settingsType, field).GetValue(null) ?? new List()); - - return string.Join(";", - ((float)AccessTools.Field(settingsType, "IdlePowerUsage").GetValue(null)).ToString(CultureInfo.InvariantCulture), - ((float)AccessTools.Field(settingsType, "ActivePowerMultiplier").GetValue(null)).ToString(CultureInfo.InvariantCulture), - ((int)AccessTools.Field(settingsType, "RescanPeriod").GetValue(null)).ToString(CultureInfo.InvariantCulture), - Defs("WhitelistedDefs"), - Defs("BlacklistedDefs"), - Defs("ReservableDefs") - ); - } - - private static void ApplySettings(string blob) - { - var parts = blob.Split(';'); - if (parts.Length != 6) - { - Log.Warning($"MP: TIOAO compat host settings malformed ({parts.Length} fields) - local settings stay"); - return; - } - - List Defs(string csv) => - csv.Length == 0 ? new List() : csv.Split(',').ToList(); - - AccessTools.Field(settingsType, "IdlePowerUsage").SetValue(null, float.Parse(parts[0], CultureInfo.InvariantCulture)); - AccessTools.Field(settingsType, "ActivePowerMultiplier").SetValue(null, float.Parse(parts[1], CultureInfo.InvariantCulture)); - AccessTools.Field(settingsType, "RescanPeriod").SetValue(null, int.Parse(parts[2], CultureInfo.InvariantCulture)); - AccessTools.Field(settingsType, "WhitelistedDefs").SetValue(null, Defs(parts[3])); - AccessTools.Field(settingsType, "BlacklistedDefs").SetValue(null, Defs(parts[4])); - AccessTools.Field(settingsType, "ReservableDefs").SetValue(null, Defs(parts[5])); - - // Rebuild the derived per-def power table from the applied values - var singleton = AccessTools.Field(modType, "singleton").GetValue(null); - if (singleton != null) - AccessTools.Method(modType, "initPowerValues", Type.EmptyTypes) - ?.Invoke(singleton, null); - } -} diff --git a/Source/Client/Patches/VNPECompat.cs b/Source/Client/Patches/VNPECompat.cs deleted file mode 100644 index 88d23ff5d..000000000 --- a/Source/Client/Patches/VNPECompat.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using HarmonyLib; -using Verse; - -namespace Multiplayer.Client.Patches; - -// Vanilla Nutrient Paste Expanded's Extract and Drain gizmo actions mutate -// simulation state (spawn meals, consume feedstock, empty tanks) directly in -// interface context - on the clicking client only - a confirmed desync source. -// Register the gizmo entry points as synced methods; the bodies are -// deterministic and safe no-ops when preconditions fail, so all-client -// re-execution is exactly vanilla behavior. -[StaticConstructorOnStartup] -static class VNPECompat -{ - static VNPECompat() - { - var dispenserGizmos = AccessTools.TypeByName("VNPE.Building_NutrientPasteDispenser_GetGizmos"); - if (dispenserGizmos == null) - return; - - // Multiplayer Compatibility ships its own VNPE gizmo sync; registering - // twice would double-patch and skew syncIds vs its expectations - if (ModsConfig.IsActive("rwmt.multiplayercompatibility")) - { - Log.Message("MP: VNPE compat skipped - Multiplayer Compatibility is active and covers VNPE itself"); - return; - } - - // Static TryDropFood(dispenser, amount) backs all three dispenser - // Extract gizmos and the pipe-network fallback branch - RegisterMethod(dispenserGizmos, "TryDropFood")?.CancelIfAnyArgNull(); - - // The tap overrides gizmos with its own private instance TryDropFood(amount); - // the instance rides along as the sync target - RegisterMethod(AccessTools.TypeByName("VNPE.Building_NutrientPasteTap"), "TryDropFood"); - - // The Drain gizmo action is a lambda capturing only 'this', hoisted onto - // the comp class itself - resolvable by parent method + ordinal - try - { - SyncMethod.Lambda(AccessTools.TypeByName("VNPE.CompRegisterIngredients"), "CompGetGizmosExtra", 0); - } - catch (Exception e) - { - Log.Warning($"MP: VNPE compat found no Drain gizmo lambda - mod version mismatch? That sync skipped. ({e.Message})"); - } - - Log.Message("MP: VNPE compat sync methods registered"); - } - - private static SyncMethod RegisterMethod(Type type, string methodName) - { - var method = type != null ? AccessTools.Method(type, methodName) : null; - if (method == null) - { - Log.Warning($"MP: VNPE compat found no {type?.ToString() ?? methodName} sync target - mod version mismatch? That sync skipped."); - return null; - } - - return Sync.RegisterSyncMethod(method); - } -} diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 196c68bed..857ff0b18 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -36,9 +36,6 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio { Multiplayer.reloading = true; - // Host TIOAO settings must be in the game comp before it is saved - Patches.TIOAOCompat.BeforeMpSave(); - var tweenedPos = new Dictionary(); var localFactionId = Multiplayer.RealPlayerFaction.loadID; var mapCmds = new Dictionary>(); @@ -142,8 +139,6 @@ public static void LoadInMainThread(TempGameData gameData) // SaveCompression enabled in the patch SavedGameLoaderNow.LoadGameFromSaveFileNow(null); - Patches.TIOAOCompat.AfterMpLoad(); - DeepProfiler.End(); } From f844dd33232ac5c29aa146a0cc58446c2f84d43f Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Wed, 22 Jul 2026 18:41:59 +0200 Subject: [PATCH 12/27] Fix Steam persona names over 32 UTF-8 bytes killing the session on player list receive --- .../Networking/Packet/PlayerListPacket.cs | 3 ++- Source/Tests/PacketTest.cs | 17 +++++++++++++++++ .../ServerPlayerListPacket.verified.txt | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Source/Common/Networking/Packet/PlayerListPacket.cs b/Source/Common/Networking/Packet/PlayerListPacket.cs index b42fdc18a..2b4e41c88 100644 --- a/Source/Common/Networking/Packet/PlayerListPacket.cs +++ b/Source/Common/Networking/Packet/PlayerListPacket.cs @@ -98,7 +98,8 @@ public void Bind(PacketBuffer buf) buf.BindEnum(ref type); buf.BindEnum(ref status); buf.Bind(ref steamId); - buf.Bind(ref steamPersonaName, maxLength: Constants.k_cwchPersonaNameMax); + // k_cwchPersonaNameMax counts chars but ReadString limits UTF-8 bytes (up to 4 per char) + buf.Bind(ref steamPersonaName, maxLength: Constants.k_cwchPersonaNameMax * 4); buf.Bind(ref ticksBehind); buf.Bind(ref simulating); buf.Bind(ref r); diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index b6174d828..8adca7099 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -108,6 +108,23 @@ private static IEnumerable RoundtripPackets() factionId = 30 }); + // Steam allows 32 *characters*; this 30-char Cyrillic name is 56 UTF-8 bytes + // and must survive the byte-limited reader + yield return ServerPlayerListPacket.Add(new ServerPlayerListPacket.PlayerInfo + { + id = 4, + username = "Dana", + latency = 55, + type = PlayerType.Steam, + status = PlayerStatus.Playing, + steamId = 444555666, + steamPersonaName = "Тестовое имя игрока в Римворлд", + ticksBehind = 0, + simulating = false, + r = 128, g = 64, b = 32, + factionId = 40 + }); + yield return ServerPlayerListPacket.Remove(99); yield return ServerPlayerListPacket.Latencies([ diff --git a/Source/Tests/packet-serializations/ServerPlayerListPacket.verified.txt b/Source/Tests/packet-serializations/ServerPlayerListPacket.verified.txt index 8a8a61fa8..d3bf89510 100644 --- a/Source/Tests/packet-serializations/ServerPlayerListPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerPlayerListPacket.verified.txt @@ -1,5 +1,6 @@ 00-02-00-00-00-01-00-00-00-05-00-00-00-41-6C-69-63-65-2A-00-00-00-00-01-15-CD-5B-07-00-00-00-00-0A-00-00-00-41-6C-69-63-65-53-74-65-61-6D-00-00-00-00-00-FF-00-00-0A-00-00-00-02-00-00-00-03-00-00-00-42-6F-62-63-00-00-00-02-02-B1-68-DE-3A-00-00-00-00-06-00-00-00-42-6F-62-42-6F-74-05-00-00-00-01-00-FF-00-14-00-00-00 (105 bytes) 01-01-00-00-00-03-00-00-00-07-00-00-00-43-68-61-72-6C-69-65-0A-00-00-00-01-00-3D-1E-A1-06-00-00-00-00-08-00-00-00-43-68-61-72-6C-69-65-43-01-00-00-00-00-00-00-FF-1E-00-00-00 (58 bytes) +01-01-00-00-00-04-00-00-00-04-00-00-00-44-61-6E-61-37-00-00-00-01-01-92-61-7F-1A-00-00-00-00-38-00-00-00-D0-A2-D0-B5-D1-81-D1-82-D0-BE-D0-B2-D0-BE-D0-B5-20-D0-B8-D0-BC-D1-8F-20-D0-B8-D0-B3-D1-80-D0-BE-D0-BA-D0-B0-20-D0-B2-20-D0-A0-D0-B8-D0-BC-D0-B2-D0-BE-D1-80-D0-BB-D0-B4-00-00-00-00-00-80-40-20-28-00-00-00 (103 bytes) 02-63-00-00-00 03-02-00-00-00-01-00-00-00-2A-00-00-00-00-00-00-00-00-29-5C-85-41-02-00-00-00-4D-00-00-00-03-00-00-00-01-EC-51-05-42 (39 bytes) 04-01-00-00-00-01 From 37a4bf8f8d34832c5cddac858480c41fa00fe368 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Wed, 22 Jul 2026 19:47:41 +0200 Subject: [PATCH 13/27] Fix async-time cooldown skew: normalize shuttle, ability and daily-skill-reset tick stamps to the world clock (#964) --- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 6 +- .../Client/AsyncTime/CooldownClockPatches.cs | 178 ++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 Source/Client/AsyncTime/CooldownClockPatches.cs diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 6897eec3e..a47300f9e 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -96,7 +96,11 @@ public void ExposeData() Multiplayer.game.worldComp.ExposeData(); - if (Scribe.mode == LoadSaveMode.LoadingVars) + // World-basis tick stamps (CooldownClockPatches) survive reload only if + // the world clock itself does; absent node (older saves) falls back to + // the old rebuild-from-TicksGame behavior + Scribe_Values.Look(ref worldTicks, "worldTicks", -1); + if (Scribe.mode == LoadSaveMode.LoadingVars && worldTicks < 0) worldTicks = Find.TickManager.TicksGame; } diff --git a/Source/Client/AsyncTime/CooldownClockPatches.cs b/Source/Client/AsyncTime/CooldownClockPatches.cs new file mode 100644 index 000000000..038634962 --- /dev/null +++ b/Source/Client/AsyncTime/CooldownClockPatches.cs @@ -0,0 +1,178 @@ +using HarmonyLib; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.AsyncTime +{ + // #964: async time stamps cooldowns under one map's clock and compares them + // under another's after map/world transfer (world context never swaps the + // clock at all). Fix: stamp and read against the world clock — it runs at + // the fastest map's speed and pauses only when everything pauses, so it's a + // monotone session clock defined in every context. Lockstep (non-async MP) + // makes this an identity transform. + static class CooldownClock + { + public static int Now => Multiplayer.AsyncWorldTime.worldTicks; + + // game/comp can lag Client during the join+load window + public static bool Active => Multiplayer.Client != null && Multiplayer.game?.asyncWorldTimeComp != null; + } + + // Vanilla Ability: cooldownEndTick's only readers all funnel through the + // CooldownTicksRemaining getter (gizmo disable, tick-side end check, + // per-charge recharge), so rebasing the stamp and the getter covers every + // path, including Psycast subclasses and cooldownPerCharge. + [HarmonyPatch(typeof(Ability), nameof(Ability.StartCooldown))] + static class AbilityCooldownWorldClockStamp + { + static void Postfix(Ability __instance, int ticks) + { + if (CooldownClock.Active) + __instance.cooldownEndTick = CooldownClock.Now + ticks; + } + } + + [HarmonyPatch(typeof(Ability), nameof(Ability.CooldownTicksRemaining), MethodType.Getter)] + static class AbilityCooldownWorldClockRead + { + static bool Prefix(Ability __instance, ref int __result) + { + if (!CooldownClock.Active) return true; + + __result = __instance.inCooldown + ? Mathf.Max(__instance.cooldownEndTick - CooldownClock.Now, 0) + : 0; + return false; + } + } + + // Daily skill growth: the midnight reset is gated by a 30k-tick window + // against TicksGame. Across maps (or in caravan context, where the clock is + // whatever map ticked last) the window goes incoherent and the reset is + // skipped or fires early — saturation sticks. Body replicated from + // 1.6.4871 with the guard and stamp moved to the world clock; the pawn's + // local-midnight gate and per-skill Interval loop are untouched. + [HarmonyPatch(typeof(Pawn_SkillTracker), nameof(Pawn_SkillTracker.SkillsTickInterval))] + static class SkillDailyResetWorldClock + { + static bool Prefix(Pawn_SkillTracker __instance, int delta) + { + if (!CooldownClock.Active) return true; + + var pawn = __instance.pawn; + if (!pawn.IsHashIntervalTick(200, delta) || !CanGainXP(pawn)) + return false; + + if (GenLocalDate.HourInteger(pawn) == 0 && + (__instance.lastXpSinceMidnightResetTimestamp < 0 || + CooldownClock.Now - __instance.lastXpSinceMidnightResetTimestamp >= 30000)) + { + for (int i = 0; i < __instance.skills.Count; i++) + __instance.skills[i].xpSinceMidnight = 0f; + + __instance.lastXpSinceMidnightResetTimestamp = CooldownClock.Now; + } + + for (int j = 0; j < __instance.skills.Count; j++) + __instance.skills[j].Interval(); + + return false; + } + + // Pawn_SkillTracker.CanGainXP is private; replicated (1.6.4871) + static bool CanGainXP(Pawn pawn) + { + if (ModsConfig.AnomalyActive && pawn.IsMutant && !pawn.mutant.Def.canGainXP) + return false; + return true; + } + } + + // Shuttle cooldown: the shuttle physically leaves the stamping map, so the + // stamp is rebased at the write sites and the compare sites read under the + // world clock. TryLaunch is NOT wrapped in a clock swap — it spawns + // skyfallers and motes that must keep stamping their own map ticks. + [HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.TryLaunch))] + static class LaunchCooldownWorldClockStamp + { + static void Prefix(CompLaunchable __instance, ref int __state) + => __state = __instance.lastLaunchTick; + + static void Postfix(CompLaunchable __instance, int __state) + { + // Rebase only if this call actually stamped (TryLaunch has + // early-out paths that leave the field untouched) + if (CooldownClock.Active && __instance.lastLaunchTick != __state) + __instance.lastLaunchTick = CooldownClock.Now; + } + } + + [HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.Notify_Arrived))] + static class LaunchCooldownArrivalWorldClockStamp + { + static void Postfix(CompLaunchable __instance) + { + if (CooldownClock.Active) + __instance.lastLaunchTick = CooldownClock.Now; + } + } + + // Read swaps: both methods are read-only w.r.t. game state, so swapping + // ticksGameInt for their duration only affects the cooldown compare. The + // finalizer restores unconditionally, exception or not. + [HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.CanLaunch))] + static class LaunchCooldownCanLaunchWorldClock + { + static void Prefix(ref int? __state) + { + if (!CooldownClock.Active) return; + __state = Find.TickManager.TicksGame; + Find.TickManager.DebugSetTicksGame(CooldownClock.Now); + } + + static void Finalizer(int? __state) + { + if (__state is { } prev) + Find.TickManager.DebugSetTicksGame(prev); + } + } + + [HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.CompInspectStringExtra))] + static class LaunchCooldownInspectWorldClock + { + static void Prefix(ref int? __state) + { + if (!CooldownClock.Active) return; + __state = Find.TickManager.TicksGame; + Find.TickManager.DebugSetTicksGame(CooldownClock.Now); + } + + static void Finalizer(int? __state) + { + if (__state is { } prev) + Find.TickManager.DebugSetTicksGame(prev); + } + } + + // CompTick's only job here is the cooldown-ended message; its == compare + // must run against the same clock the stamp uses. A slow map can tick past + // the exact world tick and miss the message — cosmetic, accepted. + [HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.CompTick))] + static class LaunchCooldownEndedMessageWorldClock + { + static bool Prefix(CompLaunchable __instance) + { + if (!CooldownClock.Active) return true; + + if (!__instance.Props.cooldownEndedMessage.NullOrEmpty() && + __instance.lastLaunchTick > 0 && + __instance.lastLaunchTick + __instance.Props.cooldownTicks == CooldownClock.Now) + { + Messages.Message(__instance.Props.cooldownEndedMessage.Formatted(__instance.parent.LabelCap), + __instance.parent, MessageTypeDefOf.NeutralEvent, historical: false); + } + return false; + } + } +} From d6ab4cb3af21163c179e09ce77870ae142d0e8a2 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Sat, 25 Jul 2026 23:59:48 +0200 Subject: [PATCH 14/27] Fix paused maps reading a foreign time speed under async time With async time, a player whose map is paused while a peer's map runs fast saw rapid UI flicker, pawns teleporting instead of moving, and a degrading world render, worsening over time and clearing on unpause. No desync - it is entirely client-side. Two maps at different non-zero speeds do not trigger it; only a paused one does. AsyncWorldTimeComp.PreContext set Find.TickManager.CurTimeSpeed and PostContext never restored it, unlike AsyncTimeComp which is snapshot-balanced. Since the world's speed is the maximum over all maps with a non-zero rate, the fastest map's speed stayed installed globally for the rest of the frame. TickRatePatch replaces the TickRateMultiplier getter and computes from that global, while the Paused getter is patched from Find.CurrentMap, so the same frame reported Paused = true and TickRateMultiplier = 3 - a pair vanilla cannot produce. Per-frame code believes both: tweeners extrapolate against a root position that never advances and snap back, and UI animates at the remote map's rate against a stopped clock. Most per-frame paths (WindowStackOnGUI, MainButtonsOnGUI, World.WorldUpdate, WorldRenderer) install no time context at all, which is why the world render is affected too. PreContext now saves the speed and PostContext restores it, and TickPatch's Postfix installs the viewed context's speed for the remainder of the frame so those unwrapped paths read something defined rather than a leftover. ExposeData reads the world's own DesiredTimeSpeed instead of the global, which only held the world's speed because of the unrestored write. Speed only: ticksGameInt stays the world clock and gameStartAbsTick is untouched because AsyncTimeComp.GameStartAbsTick latches it into scribed per-map state, where a viewer-dependent latch would create a desync. The install runs after the whole tick and command loop and every sim path installs its own context on entry, so it is never a simulation input - tick counts, ordering and rand draws are unchanged. Also adds a Find.CurrentMap null guard to the Paused postfix and prints TickRateMultiplier beside Paused in the dev overlay, where the contradiction is directly visible. --- Source/Client/AsyncTime/AsyncTimePatches.cs | 1 + Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 21 ++++++++++- Source/Client/Patches/TickPatch.cs | 36 ++++++++++++++++++- Source/Client/UI/IngameDebug.cs | 5 +++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index db01dd4af..0c908b9be 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -211,6 +211,7 @@ static void Postfix(ref bool __result) if (Multiplayer.Client == null) return; if (WorldRendererUtility.WorldSelected) return; if (FactionCreator.generatingMap) return; + if (Find.CurrentMap == null) return; var asyncTime = Find.CurrentMap.AsyncTime(); var timeSpeed = Multiplayer.IsReplay ? TickPatch.replayTimeSpeed : asyncTime.DesiredTimeSpeed; diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index a47300f9e..0ac49ed2a 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -86,7 +86,11 @@ public void ExposeData() Scribe_Values.Look(ref timeSpeedInt, "timeSpeed"); Scribe_Custom.LookULong(ref randState, "randState", 2); - TimeSpeed timeSpeed = Find.TickManager.CurTimeSpeed; + // Read the world's own speed, not the global TickManager - the global only + // held it because PreContext used to leave it installed, so a save taken from + // a UI context would persist the viewed map's speed instead. Guarded on + // Saving: DesiredTimeSpeed walks Find.Maps, empty during LoadingVars. + TimeSpeed timeSpeed = Scribe.mode == LoadSaveMode.Saving ? DesiredTimeSpeed : TimeSpeed.Paused; Scribe_Values.Look(ref timeSpeed, "timeSpeed"); if (Scribe.mode == LoadSaveMode.LoadingVars) Find.TickManager.CurTimeSpeed = timeSpeed; @@ -145,8 +149,17 @@ public void Tick() } } + // The time speed is global on TickManager but per-tickable here, so the world + // tick has to put back what was installed before it. It didn't: the world's speed + // (the fastest running map) stayed installed for the rest of the frame, and every + // reader without its own context - TickRateMultiplier above all - believed time + // was running at that rate even while the viewed map was paused. + // Speed only; DoSingleTick's ticksGameInt increment IS the world clock. + private TimeSpeed? prevSpeed; + public void PreContext() { + prevSpeed = Find.TickManager.CurTimeSpeed; Find.TickManager.CurTimeSpeed = DesiredTimeSpeed; Rand.PushState(); Rand.StateCompressed = randState; @@ -170,6 +183,12 @@ public void PostContext() randState = Rand.StateCompressed; Rand.PopState(); + + if (prevSpeed.HasValue) + { + Find.TickManager.CurTimeSpeed = prevSpeed.Value; + prevSpeed = null; + } } public void ExecuteCmd(ScheduledCommand cmd) diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index f95572c3e..b62d6d998 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -162,10 +162,44 @@ static ITickable CurrentTickable() static void Postfix() { - if (Multiplayer.Client == null || Find.CurrentMap == null) return; + if (Multiplayer.Client == null) return; + + InstallViewerTimeContext(); + + if (Find.CurrentMap == null) return; Shader.SetGlobalFloat(ShaderPropertyIDs.GameSeconds, Find.CurrentMap.AsyncTime().mapTicks.TicksToSeconds()); } + // Nothing owns the global time speed between ticks, so rendering and the UI + // read whatever the last tickable left installed. Give the rest of the frame + // the speed of what the player is actually looking at instead. + // + // Without this a paused map reports Paused = true (patched from CurrentMap) + // while TickRateMultiplier reports the fastest running map's rate (computed + // from the global) - a pair vanilla can't produce. Per-frame code believes + // both: tweened things extrapolate against a root that never advances and + // snap back, UI animates at the remote map's rate against a stopped clock. + // + // Speed only. ticksGameInt stays the world clock, the normalization basis for + // cooldown stamps. gameStartAbsTick is left alone because + // AsyncTimeComp.GameStartAbsTick latches it into scribed per-map state, so a + // viewer-dependent latch would turn a render bug into a desync. slower keeps a + // single owner; TickRatePatch matches Paused before ForcedNormalSpeed, so the + // speed alone resolves this. + // + // Runs after the whole tick and command loop, and every sim path installs its + // own context on entry, so it is never a simulation input - tick counts, + // ordering and rand draws are unchanged. + private static void InstallViewerTimeContext() + { + if (Find.TickManager == null) return; + + if (WorldRendererUtility.WorldSelected) + Find.TickManager.CurTimeSpeed = Multiplayer.AsyncWorldTime.DesiredTimeSpeed; + else if (Find.CurrentMap != null) + Find.TickManager.CurTimeSpeed = Find.CurrentMap.AsyncTime().DesiredTimeSpeed; + } + private static bool RunCmds() { int curTimer = Timer; diff --git a/Source/Client/UI/IngameDebug.cs b/Source/Client/UI/IngameDebug.cs index b929769d4..d74159def 100644 --- a/Source/Client/UI/IngameDebug.cs +++ b/Source/Client/UI/IngameDebug.cs @@ -34,6 +34,10 @@ internal static void DoDebugPrintout() text.AppendLine($"Avg Delta: {avgDelta = (avgDelta * 59.0 + Time.deltaTime * 60.0) / 60.0:0.0000}"); text.AppendLine($"Game Ticks: {Find.TickManager.TicksGame}"); text.AppendLine($"Time Speed: {Find.TickManager.CurTimeSpeed}"); + // Paused comes from the current map, TickRateMultiplier from the global + // time speed. In vanilla they cannot disagree; if they do here, something + // left a foreign speed installed in the global TickManager. + text.AppendLine($"Tick Rate Mult: {Find.TickManager.TickRateMultiplier:0.##}{separator}Paused: {Find.TickManager.Paused}"); text.AppendLine($"Tick Until: {TickPatch.tickUntil}{separator}Remote: {Multiplayer.session?.remoteTickUntil ?? 0}"); text.AppendLine($"Received Commands: {Multiplayer.session?.receivedCmds ?? 0}"); text.AppendLine($"Sent Commands: {Multiplayer.session?.remoteSentCmds ?? 0}"); @@ -54,6 +58,7 @@ internal static void DoDebugPrintout() text.AppendLine($"Server TPT: {TickPatch.serverTimePerTick:0.0}ms"); text.AppendLine($"Calculated TPT: {(TickPatch.tickUntil - TickPatch.Timer <= 3 ? TickPatch.serverTimePerTick * 1.2f : TickPatch.tickUntil - TickPatch.Timer >= 7 ? TickPatch.serverTimePerTick * 0.8f : TickPatch.serverTimePerTick):0.0}ms"); text.AppendLine($"Map Ticks: {async.mapTicks}{separator}Frozen: {TickPatch.serverFrozen} @ {TickPatch.frozenAt}"); + text.AppendLine($"Map Speed: {async.DesiredTimeSpeed}{separator}World Speed: {Multiplayer.AsyncWorldTime?.DesiredTimeSpeed}"); text.AppendLine($"Client Opinions: {Multiplayer.game.sync.knownClientOpinions.Count}{separator}Opinion Start Tick: {Multiplayer.game.sync.knownClientOpinions.FirstOrDefault()?.startTick ?? 0}"); text.AppendLine($"Opinion Start Tick: {Multiplayer.game.sync.knownClientOpinions.FirstOrDefault()?.startTick ?? 0}"); text.AppendLine($"Force Normal Speed Until: {async.slower.forceNormalSpeedUntil}"); From 28cd967b54110a9f1d5200c88acd4720a85de307 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Sun, 26 Jul 2026 00:26:44 +0200 Subject: [PATCH 15/27] Don't read the viewer-dependent time speed when creating a map's time comp Follow-up to the previous commit. That change made the global Find.TickManager.CurTimeSpeed describe whatever the local player is looking at, so that rendering and the UI have a defined context. CreateAsyncTimeCompForMap read the same global into a newly generated map's DesiredTimeSpeed, which is scribed synced state, so clients viewing different things - the planet view, a colony map, or no map at all - would compute different starting speeds for the same generated map and scribe the difference. A session-level pause zeroing one map's rate while the world's maximum stays non-zero makes those branches disagree even when every speed looks identical. Only the non-async branch is affected. It now takes the world's DesiredTimeSpeed, which is what the global carried there before and is identical on every client. The usingMapTimeFromSingleplayer read at the same site runs during host-time save conversion on a single machine, before any client exists, and is left alone. --- Source/Client/Patches/MapSetup.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Source/Client/Patches/MapSetup.cs b/Source/Client/Patches/MapSetup.cs index f83e0f249..ef1c60fa9 100644 --- a/Source/Client/Patches/MapSetup.cs +++ b/Source/Client/Patches/MapSetup.cs @@ -68,8 +68,14 @@ private static AsyncTimeComp CreateAsyncTimeCompForMap(Map map, bool usingMapTim startingTimeSpeed = TimeSpeed.Paused; } + // Ask the world, not the global TickManager: the global is viewer-dependent + // (TickPatch installs the speed of whatever the local player is looking at so + // rendering has a context) and this value is scribed onto the new map, so + // reading it here would let clients disagree on a generated map's starting + // speed. The world's DesiredTimeSpeed is what the global used to carry here + // and is identical on every client. if (!Multiplayer.GameComp.asyncTime) - startingTimeSpeed = Find.TickManager.CurTimeSpeed; + startingTimeSpeed = Multiplayer.AsyncWorldTime?.DesiredTimeSpeed ?? Find.TickManager.CurTimeSpeed; var asyncTimeCompForMap = new AsyncTimeComp(map, gameStartAbsTick) { From 95ba62157e9bc65319e7665b6682d5191dada891 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Sun, 26 Jul 2026 01:37:59 +0200 Subject: [PATCH 16/27] Fix the world's saved time speed scribing to a colliding label The DesiredTimeSpeed scribe added alongside the paused-map time context fix used the label "timeSpeed", which the timeSpeedInt node three lines above already used. Two Scribe_Values.Look calls sharing a label write two nodes on save, and on load both resolve through curXmlParent[label] to the first child, so both reads returned timeSpeedInt and the world's desired speed was never persisted or restored. Renamed to "globalTimeSpeed" with timeSpeedInt as the default. Look skips writing a value equal to its default, so the node appears only when the two differ, and saves without it (older saves, and the duplicate-node saves the collision produced) fall back to the speed loaded on the line above rather than to Paused. Both values are deterministic synced world state, so the wrong value was wrong consistently on every client - a correctness and save-hygiene defect, not a divergence. --- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 0ac49ed2a..ad6db34b9 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -90,10 +90,13 @@ public void ExposeData() // held it because PreContext used to leave it installed, so a save taken from // a UI context would persist the viewed map's speed instead. Guarded on // Saving: DesiredTimeSpeed walks Find.Maps, empty during LoadingVars. - TimeSpeed timeSpeed = Scribe.mode == LoadSaveMode.Saving ? DesiredTimeSpeed : TimeSpeed.Paused; - Scribe_Values.Look(ref timeSpeed, "timeSpeed"); + // Own node: sharing "timeSpeed" with the field above made this a no-op, since + // Look resolves a label to the first matching child. timeSpeedInt is the + // default, so saves without the node fall back to the speed loaded above. + TimeSpeed globalTimeSpeed = Scribe.mode == LoadSaveMode.Saving ? DesiredTimeSpeed : timeSpeedInt; + Scribe_Values.Look(ref globalTimeSpeed, "globalTimeSpeed", timeSpeedInt); if (Scribe.mode == LoadSaveMode.LoadingVars) - Find.TickManager.CurTimeSpeed = timeSpeed; + Find.TickManager.CurTimeSpeed = globalTimeSpeed; if (Scribe.mode == LoadSaveMode.LoadingVars) Multiplayer.game.worldComp = new MultiplayerWorldComp(world); From 1d7ca1ab78ad5b5ba2605cc0d45f6fe6cc7dc58a Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Mon, 27 Jul 2026 19:10:59 +0200 Subject: [PATCH 17/27] Fix hosting from a singleplayer save and non-async map generation NREs The deterministic starting-speed read added with the paused-map time context fix called AsyncWorldTimeComp.DesiredTimeSpeed, whose Where clause dereferences each map's AsyncTime() comp unguarded - and the call runs exactly while the map being set up is in Find.Maps without a registered comp (MapGenerator adds the map before extraInitBeforeContentGen; singleplayer conversion sets up comps for already-listed maps one by one, starting from an empty list). Threw on hosting a server from a singleplayer save and on any map generated mid-session in a non-async game. The override also stomped the singleplayer speed on the conversion path: with every map comp-less the getter returns Paused. That path runs on one machine before any client exists, so the viewer-dependence concern doesn't apply - it now keeps the speed it read from the TickManager, matching dev. The getter's Where gains the null guard its Max already had: a map with no time comp isn't a running map. Mid-session generation still gets the deterministic session speed - the comp-less new map filters out and the remaining maps are in lockstep in non-async games. --- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 7 +++++-- Source/Client/Patches/MapSetup.cs | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index ad6db34b9..a24dcb4a9 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -44,13 +44,16 @@ public float TickRateMultiplier(TimeSpeed speed) }; } - // Run at the speed of the fastest map or at chosen speed if there are no maps + // Run at the speed of the fastest map or at chosen speed if there are no maps. + // A map can be in Find.Maps before its AsyncTimeComp is registered (map + // generation, singleplayer conversion) - a comp-less map isn't a running map, + // so skip it rather than throw. public TimeSpeed DesiredTimeSpeed { get => !Find.Maps.Any() ? timeSpeedInt : Find.Maps.Select(m => m.AsyncTime()) - .Where(a => a.ActualRateMultiplier(a.DesiredTimeSpeed) != 0f) + .Where(a => a != null && a.ActualRateMultiplier(a.DesiredTimeSpeed) != 0f) .Max(a => a?.DesiredTimeSpeed) ?? TimeSpeed.Paused; set => timeSpeedInt = value; } diff --git a/Source/Client/Patches/MapSetup.cs b/Source/Client/Patches/MapSetup.cs index ef1c60fa9..7aaa8bd83 100644 --- a/Source/Client/Patches/MapSetup.cs +++ b/Source/Client/Patches/MapSetup.cs @@ -74,7 +74,11 @@ private static AsyncTimeComp CreateAsyncTimeCompForMap(Map map, bool usingMapTim // reading it here would let clients disagree on a generated map's starting // speed. The world's DesiredTimeSpeed is what the global used to carry here // and is identical on every client. - if (!Multiplayer.GameComp.asyncTime) + // + // Not on the singleplayer-conversion path: it runs on one machine before any + // client exists, and at that point no map has its AsyncTimeComp yet, so the + // world's speed would read Paused and stomp the speed read above. + if (!usingMapTimeFromSingleplayer && !Multiplayer.GameComp.asyncTime) startingTimeSpeed = Multiplayer.AsyncWorldTime?.DesiredTimeSpeed ?? Find.TickManager.CurTimeSpeed; var asyncTimeCompForMap = new AsyncTimeComp(map, gameStartAbsTick) From c9b4d573db84a95f2cef86ac808414d5d66cb619 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Mon, 27 Jul 2026 19:54:42 +0200 Subject: [PATCH 18/27] Pin SourceGen's Roslyn reference to 4.13.0 so SDK 9 builds work (#970) The 5.3.0 reference from 075ab81 is refused by SDK 9's bundled Roslyn 4.14 (CS9057), silently skipping the source generator and failing the build on its missing partials (CS8795). Matches the pin in #971. --- Source/SourceGen/SourceGen.csproj | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Source/SourceGen/SourceGen.csproj b/Source/SourceGen/SourceGen.csproj index e2387a071..f2ea383ff 100644 --- a/Source/SourceGen/SourceGen.csproj +++ b/Source/SourceGen/SourceGen.csproj @@ -10,8 +10,12 @@ - - + + + From 84de5154fcb05e9a399fe830d7b7e6e0dee3d724 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Tue, 28 Jul 2026 00:39:00 +0200 Subject: [PATCH 19/27] Fix multifaction cross-faction bleed in global counters and pickers IdeoManager's gauranlen pod counter and resettled/psychic-ritual thought stamps are single globals: every faction drained the one pod counter (the reset value's divisibility parks the zero-crossing on the same faction every cycle) and one faction's resettle or ritual moved every faction's precept moods. Faction.lastMilitaryAidRequestTick lives on the NPC faction, so one player faction calling aid locked the others out of that ally for a day - and it is a cross-clock stamp under async time, so it is now stamped and read against the world clock. ShipJob_WaitSendable.SendAway's private map picker ran under the spectator world tick and could unload one faction's mission pawns and loot at another faction's colony when the destination map was lost mid-flight; the mission owner is now pushed around it. --- Source/Client/Comp/World/FactionWorldData.cs | 28 ++- Source/Client/Factions/IdeoCounterPatches.cs | 183 ++++++++++++++++++ Source/Client/Factions/MilitaryAidPatches.cs | 90 +++++++++ .../Client/Factions/WorldTickOwnerContext.cs | 58 ++++++ 4 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 Source/Client/Factions/IdeoCounterPatches.cs create mode 100644 Source/Client/Factions/MilitaryAidPatches.cs diff --git a/Source/Client/Comp/World/FactionWorldData.cs b/Source/Client/Comp/World/FactionWorldData.cs index 09dc81dbf..19c672706 100644 --- a/Source/Client/Comp/World/FactionWorldData.cs +++ b/Source/Client/Comp/World/FactionWorldData.cs @@ -31,6 +31,22 @@ public class FactionWorldData : IExposable public GoodwillSituationManager goodwillSituationManager; public Dictionary naturalGoodwillTimers; + // Vanilla IdeoManager keeps these as single globals, so + // the per-faction storyteller/thought fan turns them into cross-faction + // bleed (one faction drains everyone's pod counter, one faction's settle + // or psychic ritual moves every faction's precept moods). -1 = never + // stamped for this faction; readers treat that as vanilla's default 0, + // except the gauranlen counter, which seeds from the shared global on + // first sim use so an existing save keeps its cycle progress. + public int ticksToNextGauranlenSpawn = -1; + public int lastResettledTick = -1; + public int lastPsychicRitualPerformedTick = -1; + + // Military-aid lockout per NPC faction loadID, + // stamped on the async world clock (CooldownClock.Now). Vanilla's single + // Faction.lastMilitaryAidRequestTick stays written for the SP path. + public Dictionary militaryAidStamps; + public FactionWorldData() { } public void ExposeData() @@ -53,10 +69,16 @@ public void ExposeData() Scribe_Values.Look(ref lastSeason, "lastSeason", Season.Undefined); Scribe_Collections.Look(ref naturalGoodwillTimers, "naturalGoodwillTimers", LookMode.Value, LookMode.Value); + Scribe_Values.Look(ref ticksToNextGauranlenSpawn, "ticksToNextGauranlenSpawn", -1); + Scribe_Values.Look(ref lastResettledTick, "lastResettledTick", -1); + Scribe_Values.Look(ref lastPsychicRitualPerformedTick, "lastPsychicRitualPerformedTick", -1); + Scribe_Collections.Look(ref militaryAidStamps, "militaryAidStamps", LookMode.Value, LookMode.Value); + if (Scribe.mode == LoadSaveMode.LoadingVars) { goodwillSituationManager ??= new GoodwillSituationManager(); naturalGoodwillTimers ??= new Dictionary(); + militaryAidStamps ??= new Dictionary(); researchManager ??= new ResearchManager(); drugPolicyDatabase ??= new DrugPolicyDatabase(); outfitDatabase ??= new OutfitDatabase(); @@ -105,7 +127,8 @@ public static FactionWorldData New(int factionId) bossgroup = FactionBossgroupData.New(), goodwillSituationManager = new GoodwillSituationManager(), - naturalGoodwillTimers = new Dictionary() + naturalGoodwillTimers = new Dictionary(), + militaryAidStamps = new Dictionary() }; } @@ -129,7 +152,8 @@ public static FactionWorldData FromCurrent(int factionId) bossgroup = FactionBossgroupData.FromCurrent(), goodwillSituationManager = Find.FactionManager.goodwillSituationManager, - naturalGoodwillTimers = new Dictionary() + naturalGoodwillTimers = new Dictionary(), + militaryAidStamps = new Dictionary() }; } } diff --git a/Source/Client/Factions/IdeoCounterPatches.cs b/Source/Client/Factions/IdeoCounterPatches.cs new file mode 100644 index 000000000..88f81ec76 --- /dev/null +++ b/Source/Client/Factions/IdeoCounterPatches.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Verse; +using Verse.AI.Group; + +namespace Multiplayer.Client.Factions; + +// IdeoManager keeps three scalars as single globals. Under +// the per-faction storyteller/thought fan they become cross-faction bleed: +// every faction drains the one gauranlen pod counter (and the reset value's +// divisibility parks the zero-crossing on the same faction every cycle), and +// one faction's resettle or psychic ritual moves every faction's precept +// moods. Per-faction state lives on FactionWorldData; the vanilla globals +// stay written so SP and single-faction MP are untouched. +static class IdeoCounterPatches +{ + public static FactionWorldData ContextFactionData() + { + var f = Faction.OfPlayer; + if (f == null || !QuestFactionOwnership.IsOwnablePlayerFaction(f)) + return null; + return Multiplayer.WorldComp.factionData.TryGetValue(f.loadID, out var data) ? data : null; + } +} + +[HarmonyPatch(typeof(StorytellerComp_GauranlenPodSpawn), nameof(StorytellerComp_GauranlenPodSpawn.MakeIntervalIncidents))] +static class PerFactionGauranlenPodCounter +{ + static bool Prefix(StorytellerComp_GauranlenPodSpawn __instance, IIncidentTarget target, + ref IEnumerable __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return true; + + __result = PerFaction(__instance, target); + return false; + } + + static IEnumerable PerFaction(StorytellerComp_GauranlenPodSpawn comp, IIncidentTarget target) + { + // Spectator context (or a faction with no data) must not drain anyone + var data = IdeoCounterPatches.ContextFactionData(); + if (data == null) + yield break; + + // Replicated from StorytellerComp_GauranlenPodSpawn.MakeIntervalIncidents + // (1.6.4871) with the counter swapped to the context faction's. + // Re-verify against the decompile on game update. + var props = (StorytellerCompProperties_GauranlenPodSpawn)comp.props; + if (!ModsConfig.IdeologyActive || props.daysBetweenPodSpawns == 0f || + Faction.OfPlayer.ideos == null || (float)GenDate.DaysPassed < props.minDaysPassed) + yield break; + + int num = 1; + if (props.countdownFactorAnyConnectors > 1) + { + foreach (Ideo ideo in Faction.OfPlayer.ideos.AllIdeos) + { + if (ideo.HasMeme(MemeDefOf.TreeConnection)) + { + num = props.countdownFactorAnyConnectors; + break; + } + } + } + + // First sim use for this faction: take over the shared global's + // progress instead of restarting a full cycle. Sim-only path, so the + // seed write is deterministic on every client. + if (data.ticksToNextGauranlenSpawn < 0) + data.ticksToNextGauranlenSpawn = Find.IdeoManager.ticksToNextGauranlenSpawn; + + data.ticksToNextGauranlenSpawn -= 1000 * num; + if (data.ticksToNextGauranlenSpawn <= 0) + { + data.ticksToNextGauranlenSpawn = (int)(props.daysBetweenPodSpawns * 60000f); + yield return new FiringIncident(props.incident, comp, comp.GenerateParms(props.incident.category, target)); + } + } +} + +// Writers: stamp the acting faction's copy whenever vanilla stamped the +// global this tick. Comparing against the global inherits vanilla's own +// gating (e.g. AddNewHome only stamps for faction == Faction.OfPlayer) +// without replicating it. +[HarmonyPatch(typeof(SettleUtility), nameof(SettleUtility.AddNewHome))] +static class PerFactionResettledStampSettle +{ + static void Postfix() + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + if (Find.IdeoManager != null && Find.IdeoManager.lastResettledTick == GenTicks.TicksGame && + IdeoCounterPatches.ContextFactionData() is { } data) + data.lastResettledTick = GenTicks.TicksGame; + } +} + +[HarmonyPatch(typeof(GravshipUtility), nameof(GravshipUtility.ArriveNewMap))] +static class PerFactionResettledStampGravship +{ + static void Postfix() + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + if (Find.IdeoManager != null && Find.IdeoManager.lastResettledTick == GenTicks.TicksGame && + IdeoCounterPatches.ContextFactionData() is { } data) + data.lastResettledTick = GenTicks.TicksGame; + } +} + +[HarmonyPatch(typeof(LordToil_PsychicRitual), "RitualCompleted")] +static class PerFactionPsychicRitualStamp +{ + static void Postfix() + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + if (Find.IdeoManager != null && Find.IdeoManager.lastPsychicRitualPerformedTick == Find.TickManager.TicksGame && + IdeoCounterPatches.ContextFactionData() is { } data) + data.lastPsychicRitualPerformedTick = Find.TickManager.TicksGame; + } +} + +// Readers: swap the context faction's values into the globals around the +// thought-worker bodies, restore unconditionally. -1 (never stamped for this +// faction) reads as vanilla's default 0 WITHOUT writing anything - reads can +// run from UI context (mood tooltips), where a lazily-seeded write would be a +// per-client mutation of scribed state. +[HarmonyPatch] +static class PerFactionIdeoStampReads +{ + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(ThoughtWorker_Precept_ResettledRecently), "ShouldHaveThought"); + yield return AccessTools.Method(typeof(ThoughtWorker_Precept_ResettledRecently), "CurrentStateInternal"); + yield return AccessTools.Method(typeof(ThoughtWorker_Precept_NoPsychicRituals), "ShouldHaveThought"); + } + + static void Prefix(ref (int resettled, int psychic)? __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction || Find.IdeoManager == null) + return; + if (IdeoCounterPatches.ContextFactionData() is not { } data) + return; + + __state = (Find.IdeoManager.lastResettledTick, Find.IdeoManager.lastPsychicRitualPerformedTick); + Find.IdeoManager.lastResettledTick = Math.Max(0, data.lastResettledTick); + Find.IdeoManager.lastPsychicRitualPerformedTick = Math.Max(0, data.lastPsychicRitualPerformedTick); + } + + static void Finalizer((int resettled, int psychic)? __state) + { + if (__state is not { } saved) + return; + Find.IdeoManager.lastResettledTick = saved.resettled; + Find.IdeoManager.lastPsychicRitualPerformedTick = saved.psychic; + } +} + +// GetDescriptionArgs is an iterator, so a field swap around the call would be +// restored before the body ever runs - replace the one-line body instead. +// Replicated from ThoughtWorker_Precept_ResettledRecently.GetDescriptionArgs +// (1.6.4871). +[HarmonyPatch(typeof(ThoughtWorker_Precept_ResettledRecently), nameof(ThoughtWorker_Precept_ResettledRecently.GetDescriptionArgs))] +static class PerFactionResettledDescription +{ + static bool Prefix(ref IEnumerable __result) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return true; + if (IdeoCounterPatches.ContextFactionData() is not { } data) + return true; + + int ticksSince = GenTicks.TicksGame - Math.Max(0, data.lastResettledTick); + __result = new List { ticksSince.ToStringTicksToPeriod().Named("DURATION") }; + return false; + } +} diff --git a/Source/Client/Factions/MilitaryAidPatches.cs b/Source/Client/Factions/MilitaryAidPatches.cs new file mode 100644 index 000000000..f867bc0d3 --- /dev/null +++ b/Source/Client/Factions/MilitaryAidPatches.cs @@ -0,0 +1,90 @@ +using HarmonyLib; +using Multiplayer.Client.AsyncTime; +using RimWorld; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Faction.lastMilitaryAidRequestTick lives on the NPC +// faction, so faction A calling military aid locks faction B out of that ally +// for a day. It is also a cross-clock stamp (absolute tick written +// under the comms console map's clock, read under whatever clock is installed +// at dialog build). Per-(player faction, NPC faction) stamps live on +// FactionWorldData, on the async world clock; the vanilla field stays written +// so SP is untouched. +// +// Sync-safety: the dialog is built inside the synced comms job on every +// client and NodeTreeDialogSync replays the chosen DiaOption BY INDEX, so +// this patch may change an option's disabled state but must never change +// whether an option appears. RequestMilitaryAidOption always returns exactly +// one option (enabled or disabled with "WaitTime"), and the field swap below +// cannot change that. The swap brackets only that builder and the finalizer +// restores unconditionally. +static class MilitaryAidPatches +{ + // Gated on CooldownClock.Active (all MP, not just multifaction): the + // clock-skew half applies to single-faction async games too; there the + // dict simply has one paying faction. + public static System.Collections.Generic.Dictionary ContextStamps() + { + var f = Faction.OfPlayer; + if (f == null) + return null; + return Multiplayer.WorldComp.factionData.TryGetValue(f.loadID, out var data) ? data.militaryAidStamps : null; + } +} + +[HarmonyPatch(typeof(FactionDialogMaker), "CallForAid")] +static class MilitaryAidStampComms +{ + static void Postfix(Faction faction) + { + if (!CooldownClock.Active) + return; + var stamps = MilitaryAidPatches.ContextStamps(); + if (stamps != null) + stamps[faction.loadID] = CooldownClock.Now; + } +} + +[HarmonyPatch(typeof(RoyalTitlePermitWorker_CallAid), "CallAid")] +static class MilitaryAidStampPermit +{ + static void Postfix(Faction faction) + { + if (!CooldownClock.Active) + return; + var stamps = MilitaryAidPatches.ContextStamps(); + if (stamps != null) + stamps[faction.loadID] = CooldownClock.Now; + } +} + +[HarmonyPatch(typeof(FactionDialogMaker), "RequestMilitaryAidOption")] +static class MilitaryAidReadPerPair +{ + static void Prefix(Faction faction, ref int? __state) + { + if (!CooldownClock.Active) + return; + var stamps = MilitaryAidPatches.ContextStamps(); + if (stamps == null) + return; + + __state = faction.lastMilitaryAidRequestTick; + + // Vanilla compares lastMilitaryAidRequestTick + 60000 against the + // ambient TicksGame, so install a value that reproduces this pair's + // world-clock remaining time under that comparison. No stamp for this + // pair = available (any value older than a day works). + faction.lastMilitaryAidRequestTick = stamps.TryGetValue(faction.loadID, out var stamp) + ? Find.TickManager.TicksGame - (CooldownClock.Now - stamp) + : -60000; + } + + static void Finalizer(Faction faction, int? __state) + { + if (__state is { } saved) + faction.lastMilitaryAidRequestTick = saved; + } +} diff --git a/Source/Client/Factions/WorldTickOwnerContext.cs b/Source/Client/Factions/WorldTickOwnerContext.cs index a84ca84f8..523cc727a 100644 --- a/Source/Client/Factions/WorldTickOwnerContext.cs +++ b/Source/Client/Factions/WorldTickOwnerContext.cs @@ -82,3 +82,61 @@ static void Finalizer(bool __state) FactionExtensions.PopFaction(); } } + +// When a mission's stamped destination map +// is lost mid-flight, SendAway's private picker scans settlements for +// Faction.OfPlayer - under the spectator world tick that matches nobody and +// the faction-blind AnyPlayerHomeMap fallback unloads the mission's pawns and +// loot at an arbitrary player colony. Push the mission owner so vanilla's own +// scan starts matching; no body replication, no new scribed state. +[HarmonyPatch(typeof(ShipJob_WaitSendable), "SendAway")] +static class ShipJobWaitSendableOwnerContext +{ + static void Prefix(ShipJob_WaitSendable __instance, ref bool __state) + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + var owner = ResolveOwner(__instance); + if (owner == null || owner == Faction.OfPlayer) + return; + + ((Map)null).PushFaction(owner); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } + + // Deterministic on every client: the quest table, the transporter's + // contents and DeterministicMapOwner are all synced state. The quest scan + // skips only Historical - never quest.hidden/dismissed like vanilla's + // gizmo scan, because dismissal is a per-client UI action. + static Faction ResolveOwner(ShipJob_WaitSendable job) + { + var shipThing = job.transportShip?.shipThing; + if (shipThing == null) + return null; + + var quests = Find.QuestManager.QuestsListForReading; + for (int i = 0; i < quests.Count; i++) + { + if (quests[i].Historical) + continue; + if (quests[i].QuestLookTargets.Contains(shipThing) && + QuestFactionOwnership.GetOwner(quests[i]) is { } questOwner) + return questOwner; + } + + if (job.transportShip.TransporterComp?.innerContainer is { } held) + foreach (var thing in held) + if (thing is Pawn { RaceProps.Humanlike: true } pawn && + QuestFactionOwnership.IsOwnablePlayerFaction(pawn.Faction)) + return pawn.Faction; + + return WorldTickOwnerContext.DeterministicMapOwner(shipThing.Map); + } +} From 341b31eaaa402f5d8f90996aa1b954d2704a3a07 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Tue, 28 Jul 2026 00:40:24 +0200 Subject: [PATCH 20/27] Fix the async-time clock split that flickers the UI on a paused client After each frame's tick pass the ambient tick count was left as the world clock while the per-frame viewer install covered only the speed - so on a client paused beside a running map, every frame alternated between the frozen viewer clock (inside the map-time UI brackets) and the advancing world clock (everywhere else), and anything comparing a remembered tick against 'now' across that boundary flickered: colonist-bar portraits (the portrait time context had been dead code since the 1.3 renderer rewrite), alerts (updated under one clock, drawn under the other), window countdowns, the satellites world layer (regenerated every frame), per-thing tick caches. The context snapshots become re-entrant stacks, the world tick installs its own tick count (with the slower/gameStartAbsTick fields the world lacked), GameStartAbsTick no longer latches the ambient global into scribed map state from a getter, and the frame ends by installing the viewer's full snapshot. Readers that need world time between ticks get explicit wraps: letters, alerts, world render, and saving - the last one required for determinism, since join-point saves run on every client and scribe the ambient TickManager state. Context installs write the curTimeSpeed field rather than the PlayerCanControl-gated property, which silently dropped writes during gravship landing. --- Source/Client/AsyncTime/AsyncTimeComp.cs | 50 ++++--- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 50 +++++-- Source/Client/AsyncTime/SetMapTime.cs | 128 +++++++++++++++++- Source/Client/AsyncTime/StorytellerPatches.cs | 5 + .../Client/Patches/FrameCacheDeterminism.cs | 62 +++++++-- Source/Client/Patches/TickPatch.cs | 52 +++---- Source/Client/Saving/SaveLoad.cs | 20 ++- Source/Client/Saving/SavingPatches.cs | 1 + 8 files changed, 290 insertions(+), 78 deletions(-) diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index a2200500d..a4e96587b 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -65,18 +65,17 @@ public TimeSpeed DesiredTimeSpeed public int TickableId => map.uniqueID; - public int GameStartAbsTick - { - get - { - if (gameStartAbsTickMap == 0) - { - gameStartAbsTickMap = Find.TickManager?.gameStartAbsTick ?? 0; - } - - return gameStartAbsTickMap; - } - } + // Pure: this is read from UI-reachable context installs + // (TimeSnapshot.GetAndSetFromMap), and a read that wrote the ambient + // global into the scribed field made render order and viewer identity + // inputs to TicksAbs - an RNG seed in pawn think trees + // (RandSeedForHour), wind and sky glow - so a render bug could become + // a desync and a save diff. The scribed field is resolved once in + // FinalizeInit instead; the fallback covers reads before that point + // without writing anything. + public int GameStartAbsTick => gameStartAbsTickMap != 0 + ? gameStartAbsTickMap + : Find.TickManager?.gameStartAbsTick ?? 0; public Map map; public int mapTicks; @@ -175,7 +174,12 @@ public void UpdateManagers() map.glowGrid.GlowGridUpdate_First(); } - private TimeSnapshot? prevTime; + // Contexts nest on the same comp by design (the quest brackets in + // MultiplayerAsyncQuest run inside Tick(), SetContextForAccept inside + // ExecuteCmd), so the restore state is a stack like the Rand and + // faction state around it: a single field let an inner bracket clobber + // the outer snapshot, leaking the inner clock for the rest of the frame. + private readonly Stack prevTimes = new(); public void PreContext() { @@ -185,7 +189,7 @@ public void PreContext() : Multiplayer.WorldComp.spectatorFaction, force: true); - prevTime = TimeSnapshot.GetAndSetFromMap(map); + prevTimes.Push(TimeSnapshot.GetAndSetFromMap(map)); Rand.PushState(); Rand.StateCompressed = randState; @@ -196,7 +200,10 @@ public void PreContext() public void PostContext() { - prevTime?.Set(); + if (prevTimes.Count == 0) + Log.Error($"MP: unbalanced PostContext on {this}"); + else + prevTimes.Pop()?.Set(); randState = Rand.StateCompressed; Rand.PopState(); @@ -209,7 +216,7 @@ public void ExposeData() Scribe_Values.Look(ref mapTicks, "mapTicks"); Scribe_Values.Look(ref timeSpeedInt, "timeSpeed"); - Scribe_Values.Look(ref gameStartAbsTickMap, "gameStartAbsTickMap"); + Scribe_Values.Look(ref gameStartAbsTickMap, "gameStartAbsTickMap", 0); Scribe_Custom.LookULong(ref randState, "randState", 1); } @@ -221,6 +228,17 @@ public void ExposeData() public void FinalizeInit() { + // Only saves predating the per-map field (3bfa487) and the + // comp-missing error path in SavingPatches can still hold 0 here. + // Both are load-time, and this runs at a fixed point of the load + // path on every client (Map.FinalizeLoading -> Map.FinalizeInit -> + // MapComponentUtility.FinalizeInit), where the ambient + // gameStartAbsTick is the scribed global - the correct value for + // those saves. Normal play never constructs a comp with 0 + // (MapSetup always passes a non-zero source). + if (gameStartAbsTickMap == 0) + gameStartAbsTickMap = Find.TickManager?.gameStartAbsTick ?? 0; + cmds = new Queue( Multiplayer.session.dataSnapshot?.MapCmds.GetValueSafe(map.uniqueID) ?? new List() ); diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index a24dcb4a9..4d48aeda1 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -71,6 +71,15 @@ public TimeSpeed DesiredTimeSpeed public int worldTicks; + // The global slot fields the world previously had no owner for. TimeSlower + // is transient in vanilla (never scribed), so a fresh instance is correct; + // gameStartAbsTick is captured at construction, which runs after + // ExposeSmallComponents has loaded the TickManager on every path + // (deserialization via SaveWorldComp, its comp-missing fallback, and the + // singleplayer conversion in HostUtil). + public TimeSlower slower = new(); + public int worldGameStartAbsTick; + public AsyncWorldTimeComp(World world) { this.world = world; @@ -78,6 +87,8 @@ public AsyncWorldTimeComp(World world) // Use the world's constant rand seed as our initial randState. // Only fill the seed part, leave the iterations out. randState = (uint)world.ConstantRandSeed; + + worldGameStartAbsTick = Find.TickManager?.gameStartAbsTick ?? 0; } public void ExposeData() @@ -112,6 +123,11 @@ public void ExposeData() Scribe_Values.Look(ref worldTicks, "worldTicks", -1); if (Scribe.mode == LoadSaveMode.LoadingVars && worldTicks < 0) worldTicks = Find.TickManager.TicksGame; + + // Not scribed - always the global TickManager's value, re-derived on + // load in case the ctor ran before the meta components were current + if (Scribe.mode == LoadSaveMode.LoadingVars) + worldGameStartAbsTick = Find.TickManager.gameStartAbsTick; } public void Tick() @@ -123,6 +139,13 @@ public void Tick() { Find.TickManager.DoSingleTick(); worldTicks++; + + // PreContext installed worldTicks and DoSingleTick incremented the + // ambient, so the two can only disagree if something else moved one + // of them - which would silently shift every world-clock read + if (MpVersion.IsDebug && Find.TickManager.ticksGameInt != worldTicks) + Log.Error($"MP: world clock mismatch: ambient {Find.TickManager.ticksGameInt} != worldTicks {worldTicks}"); + Multiplayer.WorldComp.TickWorldSessions(); if (ModsConfig.BiotechActive) @@ -155,18 +178,18 @@ public void Tick() } } - // The time speed is global on TickManager but per-tickable here, so the world - // tick has to put back what was installed before it. It didn't: the world's speed - // (the fastest running map) stayed installed for the rest of the frame, and every - // reader without its own context - TickRateMultiplier above all - believed time - // was running at that rate even while the viewed map was paused. - // Speed only; DoSingleTick's ticksGameInt increment IS the world clock. - private TimeSpeed? prevSpeed; +// The world's clock lives on the global TickManager only while installed + // here; the world tick is self-contained. PreContext installs the full + // world snapshot - ticksGameInt = worldTicks, the scribed mirror that + // DoSingleTick's increment tracks - and PostContext restores whatever the + // frame had. Readers that want world time between ticks (letters, alerts, + // world render, saving) install it explicitly. A stack because world + // commands can nest a world context inside the world tick. + private readonly Stack prevTimes = new(); public void PreContext() { - prevSpeed = Find.TickManager.CurTimeSpeed; - Find.TickManager.CurTimeSpeed = DesiredTimeSpeed; + prevTimes.Push(TimeSnapshot.GetAndSetFromWorld()); Rand.PushState(); Rand.StateCompressed = randState; @@ -190,11 +213,10 @@ public void PostContext() randState = Rand.StateCompressed; Rand.PopState(); - if (prevSpeed.HasValue) - { - Find.TickManager.CurTimeSpeed = prevSpeed.Value; - prevSpeed = null; - } + if (prevTimes.Count == 0) + Log.Error("MP: unbalanced PostContext on the world clock"); + else + prevTimes.Pop()?.Set(); } public void ExecuteCmd(ScheduledCommand cmd) diff --git a/Source/Client/AsyncTime/SetMapTime.cs b/Source/Client/AsyncTime/SetMapTime.cs index 538f482c7..d73c44759 100644 --- a/Source/Client/AsyncTime/SetMapTime.cs +++ b/Source/Client/AsyncTime/SetMapTime.cs @@ -21,10 +21,8 @@ static IEnumerable TargetMethods() yield return AccessTools.Method(typeof(MapInterface), nameof(MapInterface.HandleMapClicks)); yield return AccessTools.Method(typeof(MapInterface), nameof(MapInterface.HandleLowPriorityInput)); yield return AccessTools.Method(typeof(MapInterface), nameof(MapInterface.MapInterfaceUpdate)); - yield return AccessTools.Method(typeof(AlertsReadout), nameof(AlertsReadout.AlertsReadoutUpdate)); yield return AccessTools.Method(typeof(SoundRoot), nameof(SoundRoot.Update)); yield return AccessTools.Method(typeof(FloatMenuMakerMap), nameof(FloatMenuMakerMap.GetOptions)); - yield return AccessTools.Method(typeof(Hediff), nameof(Hediff.GetTooltip)); } [HarmonyPriority(MpPriority.MpFirst)] @@ -38,6 +36,47 @@ internal static void Prefix(ref TimeSnapshot? __state) internal static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } + // The hediff's pawn may live on another map or in a caravan, so its + // elapsed-time strings must come from the pawn's own clock, not the + // viewed map's (it used to sit in the SetMapTimeForUI list above). + [HarmonyPatch(typeof(Hediff), nameof(Hediff.GetTooltip))] + static class HediffTooltipMapTime + { + static void Prefix(Hediff __instance, ref TimeSnapshot? __state) + { + if (Multiplayer.Client == null) return; + __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn?.MapHeld); + } + + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); + } + + // Alerts iterate all maps, so no single map's clock is right for them; + // the session-wide world clock keeps countdowns advancing regardless of + // which map is viewed and keeps the update and draw halves consistent + // with each other (previously AlertsReadoutUpdate ran under the viewed + // map's clock and AlertsReadoutOnGUI under the frame ambient - the same + // alert evaluated under two clocks in one frame). + [HarmonyPatch] + static class AlertsReadoutWorldTime + { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(AlertsReadout), nameof(AlertsReadout.AlertsReadoutUpdate)); + yield return AccessTools.Method(typeof(AlertsReadout), nameof(AlertsReadout.AlertsReadoutOnGUI)); + } + + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(ref TimeSnapshot? __state) + { + if (Multiplayer.Client == null) return; + __state = TimeSnapshot.GetAndSetFromWorld(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); + } + [HarmonyPatch] static class MapUpdateTimePatch { @@ -75,14 +114,20 @@ static void Prefix(Pawn pawn, ref TimeSnapshot? __state) static void Postfix(TimeSnapshot? __state) => __state?.Set(); } - // TODO 1.3: set time on the new renderer - //[HarmonyPatch(typeof(PawnRenderer), nameof(PawnRenderer.RenderPortrait))] + // Portraits render through PawnCacheRenderer.RenderPawn since the 1.3 + // renderer rewrite (PawnRenderer.RenderPortrait no longer exists); + // camera.Render() is synchronous, so this pair brackets OnPostRender -> + // PawnRenderer.RenderCache, which reads the clock for the damage flasher + // and render-tree animation frames. Without it a portrait renders under + // whatever clock the frame has ambient while PortraitsCache.IsAnimated + // (patched above) decides re-renders under the pawn's own map clock. + [HarmonyPatch(typeof(PawnCacheRenderer), nameof(PawnCacheRenderer.RenderPawn))] static class PawnRenderPortraitMapTime { - static void Prefix(PawnRenderer __instance, ref TimeSnapshot? __state) + static void Prefix(Pawn pawn, ref TimeSnapshot? __state) { if (Multiplayer.Client == null || Current.ProgramState != ProgramState.Playing) return; - __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn.MapHeld); + __state = TimeSnapshot.GetAndSetFromMap(pawn.MapHeld); } static void Postfix(TimeSnapshot? __state) => __state?.Set(); @@ -235,13 +280,82 @@ public static TimeSnapshot Current() var tickManager = Find.TickManager; var mapComp = map.AsyncTime(); + // Field, not the property: the vanilla setter silently drops + // writes (and can emit a RejectInput message) when + // !PlayerCanControl, e.g. during gravship landing confirmation, + // and a context install must never be lossy - Set() already + // writes the field on restore tickManager.ticksGameInt = mapComp.mapTicks; tickManager.slower = mapComp.slower; - tickManager.CurTimeSpeed = mapComp.DesiredTimeSpeed; + tickManager.curTimeSpeed = mapComp.DesiredTimeSpeed; tickManager.gameStartAbsTick = mapComp.GameStartAbsTick; return prev; } + + // The world half of GetAndSetFromMap. Writes the curTimeSpeed field, + // not the property: the vanilla setter silently drops writes (and can + // emit a RejectInput message) when !PlayerCanControl, e.g. during + // gravship landing confirmation, and a context install must never be + // lossy. + public static TimeSnapshot? GetAndSetFromWorld() + { + var worldComp = Multiplayer.AsyncWorldTime; + if (worldComp == null) return null; + + TimeSnapshot prev = Current(); + var tickManager = Find.TickManager; + + tickManager.ticksGameInt = worldComp.worldTicks; + tickManager.slower = worldComp.slower; + tickManager.curTimeSpeed = worldComp.DesiredTimeSpeed; + tickManager.gameStartAbsTick = worldComp.worldGameStartAbsTick; + + return prev; + } + } + + // World rendering and world-object updates key caches and motion deltas + // on the ambient clock: WorldDrawLayer_Satellites regenerates whenever + // lastUpdate != TicksGame and integrates orbit rotation by the delta, + // WorldObject.DrawPos caches per tick. The world clock is their correct + // basis - caravans and orbits move on world ticks - while an ambient that + // alternates with the viewer's clock regenerates the layer every frame + // (the observed world-render degradation) or jumps deltas + // backwards. Plain snapshot bracket, deliberately not PreContext: no Rand + // or faction state may change on a render path. Runs even while a surface + // map is viewed (background world render). + [HarmonyPatch(typeof(World), nameof(World.WorldUpdate))] + static class WorldUpdateWorldTime + { + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(ref TimeSnapshot? __state) + { + if (Multiplayer.Client == null) return; + __state = TimeSnapshot.GetAndSetFromWorld(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); + } + + // Letters are world-scoped state read between ticks: arrival and timeout + // stamps must be compared under one session-wide clock rather than + // whatever the frame left ambient. This preserves the clock the + // method effectively ran under before the viewer context owned the frame. + // Letters.cs patches the same method for unrelated reasons. + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.LetterStackUpdate))] + static class LetterStackUpdateWorldTime + { + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(ref TimeSnapshot? __state) + { + if (Multiplayer.Client == null) return; + __state = TimeSnapshot.GetAndSetFromWorld(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } } diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index 9bafa8bbd..ae15aa875 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -123,6 +123,11 @@ static class MapContextIncidentExecute { static void Prefix(IncidentParms parms, ref Map __state) { + // This may be running inside a context already: TryExecute nests on + // quest-spawn incidents, and tickingWorld stays true throughout + if (AsyncTimeComp.tickingMap != null) + return; + if (AsyncWorldTimeComp.tickingWorld && parms.target is Map map) { AsyncTimeComp.tickingMap = map; diff --git a/Source/Client/Patches/FrameCacheDeterminism.cs b/Source/Client/Patches/FrameCacheDeterminism.cs index 3d473d3e8..43f98be9f 100644 --- a/Source/Client/Patches/FrameCacheDeterminism.cs +++ b/Source/Client/Patches/FrameCacheDeterminism.cs @@ -6,24 +6,58 @@ namespace Multiplayer.Client.Patches; // PlayerItemAccessibilityUtility caches its accessible-things scan keyed on -// (tile, RealTime.frameCount). Frame counts are per-client machine state: one -// client serves a list computed from older sim state while another recomputes -// fresh. Reachable from the synced sim via quest generation -// (QuestNode_Root_Beggars, QuestNode_TradeRequest_GetRequestedThing) and -// GameComponent_OnetimeNotification's world tick, where a flipped result -// consumes extra synced RNG (RandomNonHostileFaction) - a rand-state desync -// on stable MP, no multifaction needed. In MP the cache is invalidated per -// call: recompute from current synced state, identical on every client. Call -// sites are rare (quest gen; a 2000-tick 5%-gated component), so the rescan -// cost is negligible. +// (tile, RealTime.frameCount). Frame counts are per-client machine state: +// whether a call rescans or reuses depends on the local framerate, and the +// scan consumes synced RNG (GenMath.RoundRandom on pawn leather amounts), so +// per-client cache collapse diverges the rand stream. The sim callers are +// quest generation: QuestNode_TradeRequest_GetRequestedThing's validator +// calls PossiblyAccessible + PlayerCanMake for EVERY def in +// ThingSetMakerUtility.allGeneratableItems (hundreds, 1-3 scans each), and +// QuestNode_Root_Beggars (5 defs) - and TradeRequest's TestRun runs during +// every random-quest selection (NaturalRandomQuestChooser evaluates all +// candidate roots), not just when it wins. +// +// The old fix forced a full rescan on every call. That made the draw count +// deterministic but turned each quest-fire tick into 300-700 full +// every-item-on-every-map scans: a multi-second freeze on every client at +// every random quest arrival (doc 89). The requirement was never "always +// rescan", only "every client rescans the same number of times" - so in sim +// context, repeat calls collapse per (tile, TickPatch.Timer), identical on +// every client in every sim context: the first call each session-tick +// rescans (also flushing any content a UI call computed between ticks; sim +// ticks are atomic on the main thread, so UI calls cannot interleave +// mid-tick), repeats within the tick reuse it. Outside the sim, vanilla +// behavior is untouched. [HarmonyPatch(typeof(PlayerItemAccessibilityUtility), nameof(PlayerItemAccessibilityUtility.CacheAccessibleThings))] static class ItemAccessibilityCacheInvalidation { - static void Prefix() + private static (PlanetTile tile, int timer)? lastSimScan; + + static void Prefix(PlanetTile nearTile) { - // Frame key, not the tile: PlanetTile.Invalid is a legitimate nearTile - // for map-less callers and would re-match; frameCount is never -1 - if (Multiplayer.Client != null) + if (Multiplayer.Client == null) + return; + if (!Multiplayer.Ticking && !Multiplayer.ExecutingCmds) + return; + + if (lastSimScan is { } last && last.tile == nearTile && last.timer == TickPatch.Timer) + { + // Same tile, same session tick: force vanilla's guard to pass so + // the content computed earlier this tick is reused + PlayerItemAccessibilityUtility.cachedAccessibleThingsForTile = nearTile; + PlayerItemAccessibilityUtility.cachedAccessibleThingsForFrame = RealTime.frameCount; + } + else + { + // Frame key, not the tile: PlanetTile.Invalid is a legitimate + // nearTile for map-less callers and would re-match; frameCount is + // never -1 PlayerItemAccessibilityUtility.cachedAccessibleThingsForFrame = -1; + lastSimScan = (nearTile, TickPatch.Timer); + } } + + // A forced reuse must never serve content from before a reload - the + // cached Thing refs die at SaveAndReload. Called from ClearAllPatch. + public static void Reset() => lastSimScan = null; } diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index b62d6d998..3528d7a40 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -166,38 +166,42 @@ static void Postfix() InstallViewerTimeContext(); - if (Find.CurrentMap == null) return; - Shader.SetGlobalFloat(ShaderPropertyIDs.GameSeconds, Find.CurrentMap.AsyncTime().mapTicks.TicksToSeconds()); + // AsyncTime() can be null while a join or load is mid-flight + // (Multiplayer.game lags Client in that window) + if (Find.CurrentMap?.AsyncTime() is not { } viewerTime) return; + Shader.SetGlobalFloat(ShaderPropertyIDs.GameSeconds, viewerTime.mapTicks.TicksToSeconds()); } - // Nothing owns the global time speed between ticks, so rendering and the UI - // read whatever the last tickable left installed. Give the rest of the frame - // the speed of what the player is actually looking at instead. +// Nothing owns the global clock between ticks, so rendering and the + // UI read whatever the last tickable left installed. Give the rest of + // the frame one defined context instead: the full snapshot (tick + // count, speed, slower, gameStartAbsTick) of what the player is + // actually looking at, so every unwrapped per-frame reader sees a + // single consistent clock instead of alternating between the viewer's + // clock inside SetMapTime brackets and the world clock outside them. // - // Without this a paused map reports Paused = true (patched from CurrentMap) - // while TickRateMultiplier reports the fastest running map's rate (computed - // from the global) - a pair vanilla can't produce. Per-frame code believes - // both: tweened things extrapolate against a root that never advances and - // snap back, UI animates at the remote map's rate against a stopped clock. + // Between-tick readers that genuinely want world time have explicit + // world wraps: LetterStackUpdate, AlertsReadout, World.WorldUpdate + // and - load-bearing for determinism - SaveLoad.SaveGameData, which + // would otherwise scribe each client's viewer clock into join-point + // saves. // - // Speed only. ticksGameInt stays the world clock, the normalization basis for - // cooldown stamps. gameStartAbsTick is left alone because - // AsyncTimeComp.GameStartAbsTick latches it into scribed per-map state, so a - // viewer-dependent latch would turn a render bug into a desync. slower keeps a - // single owner; TickRatePatch matches Paused before ForcedNormalSpeed, so the - // speed alone resolves this. - // - // Runs after the whole tick and command loop, and every sim path installs its - // own context on entry, so it is never a simulation input - tick counts, - // ordering and rand draws are unchanged. + // Safe for the sim: this runs after the entire tick and command loop, + // and every sim path installs its own context on entry (the world + // tick installs its own count), so this value is never a simulation + // input. private static void InstallViewerTimeContext() { - if (Find.TickManager == null) return; + // Null checks cover the join/load window where Multiplayer.game + // (and with it the async comps) lags Multiplayer.Client + if (Multiplayer.game == null || Find.TickManager == null) return; + // The previous snapshots are deliberately discarded: this installs + // the frame's owner, it doesn't bracket a scope if (WorldRendererUtility.WorldSelected) - Find.TickManager.CurTimeSpeed = Multiplayer.AsyncWorldTime.DesiredTimeSpeed; - else if (Find.CurrentMap != null) - Find.TickManager.CurTimeSpeed = Find.CurrentMap.AsyncTime().DesiredTimeSpeed; + TimeSnapshot.GetAndSetFromWorld(); + else if (Find.CurrentMap is { } map && map.AsyncTime() != null) + TimeSnapshot.GetAndSetFromMap(map); } private static bool RunCmds() diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 857ff0b18..f581dda43 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -159,9 +159,23 @@ private static void ClearState() public static TempGameData SaveGameData() { - var gameDoc = SaveGameToDoc(); - var sessionData = SessionData.WriteSessionData(); - return new TempGameData(gameDoc, sessionData); + // ExposeSmallComponents scribes the ambient TickManager state + // (ticksGameInt, gameStartAbsTick, curTimeSpeed), and join-point + // saves run on every client locally - so the save must be pinned + // to the world clock, or each client would scribe its own + // viewer's clock and their post-reload states would diverge + //. Covers SaveAndReload and both autosave paths. + var prev = TimeSnapshot.GetAndSetFromWorld(); + try + { + var gameDoc = SaveGameToDoc(); + var sessionData = SessionData.WriteSessionData(); + return new TempGameData(gameDoc, sessionData); + } + finally + { + prev?.Set(); + } } public static XmlDocument SaveGameToDoc() diff --git a/Source/Client/Saving/SavingPatches.cs b/Source/Client/Saving/SavingPatches.cs index 9c5b6ff7f..46717856e 100644 --- a/Source/Client/Saving/SavingPatches.cs +++ b/Source/Client/Saving/SavingPatches.cs @@ -66,6 +66,7 @@ static class ClearAllPatch static void Postfix() { CacheAverageTileTemperature.Clear(); + Patches.ItemAccessibilityCacheInvalidation.Reset(); Multiplayer.game?.OnDestroy(); Multiplayer.game = null; } From d521e9931b97825e34906ae4b964a886805a470c Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Tue, 28 Jul 2026 00:40:35 +0200 Subject: [PATCH 21/27] Collapse item-accessibility rescans deterministically instead of always rescanning Forcing a full rescan on every CacheAccessibleThings call kept the RNG draw count deterministic but made every random-quest selection sweep 300-700 full every-item-on-every-map scans: TradeRequest's TestRun probes PossiblyAccessible/PlayerCanMake for each def in allGeneratableItems during every ChooseNaturalRandomQuest evaluation, whichever quest wins. That is a multi-second simultaneous freeze on all clients at quest arrival. The determinism requirement is only that every client rescans the same number of times, so in sim context repeat calls now collapse per (tile, session tick); outside the sim vanilla behavior is untouched, and the recorded key resets on world unload so a reuse can never serve pre-reload Thing refs. --- Source/Client/Patches/FrameCacheDeterminism.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Client/Patches/FrameCacheDeterminism.cs b/Source/Client/Patches/FrameCacheDeterminism.cs index 43f98be9f..18e23a93f 100644 --- a/Source/Client/Patches/FrameCacheDeterminism.cs +++ b/Source/Client/Patches/FrameCacheDeterminism.cs @@ -20,7 +20,7 @@ namespace Multiplayer.Client.Patches; // The old fix forced a full rescan on every call. That made the draw count // deterministic but turned each quest-fire tick into 300-700 full // every-item-on-every-map scans: a multi-second freeze on every client at -// every random quest arrival (doc 89). The requirement was never "always +// every random quest arrival. The requirement was never "always // rescan", only "every client rescans the same number of times" - so in sim // context, repeat calls collapse per (tile, TickPatch.Timer), identical on // every client in every sim context: the first call each session-tick From 213d222c356be6a362e3691306842dc4c0912967 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Tue, 28 Jul 2026 17:21:47 +0200 Subject: [PATCH 22/27] Fix the update-rate stutter after joins and rehosts Adaptive update rates are driven by a per-map viewer count that is rebuilt from synced view-switch commands, but the count lives only in the runtime comps: every join point or rehost reloads the game on every client, zeroing all counts, while the reload suppresses view sends and the switch dedup blocks any re-send for a player who stays on the same map. Every watched map then decays to the idle rate - all things ticking and drawing in 15-tick lumps, identically on every client - which reads as UI flicker, clumped notification sounds and world render stutter after any join or rehost. After a reload completes each client now re-announces its viewed map (or the world view) as a fresh synced command, so the counts rebuild consistently everywhere. The ship-launch countdown in the constant tick is also pinned to the world clock, as its completion does simulation work but ran under the client-local viewer clock. --- Source/Client/ConstantTicker.cs | 17 ++++++++++++++--- Source/Client/Patches/VTRSyncPatch.cs | 26 ++++++++++++++++++++++++++ Source/Client/Saving/SaveLoad.cs | 3 +++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs index 214ce1e33..30abf8c94 100644 --- a/Source/Client/ConstantTicker.cs +++ b/Source/Client/ConstantTicker.cs @@ -137,10 +137,21 @@ private static void TickShipCountdown() { if (ShipCountdown.timeLeft > 0f) { - ShipCountdown.timeLeft -= 1f / GenTicks.TicksPerRealSecond; + // CountdownEnded does sim work but the constant tick runs + // under whatever clock the frame installed (the viewer's + // since the viewer-clock install) - pin it to the world clock + var prev = TimeSnapshot.GetAndSetFromWorld(); + try + { + ShipCountdown.timeLeft -= 1f / GenTicks.TicksPerRealSecond; - if (ShipCountdown.timeLeft <= 0f) - ShipCountdown.CountdownEnded(); + if (ShipCountdown.timeLeft <= 0f) + ShipCountdown.CountdownEnded(); + } + finally + { + prev?.Set(); + } } } } diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index eb5812a86..fffd35204 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -82,6 +82,32 @@ public static void Reset() lastMovedToMapId = InvalidMapId; lastSentAtTick = -1; } + + // Every SaveAndReload recreates the time comps, zeroing every + // CurrentPlayerCount, while sends are suppressed by the reloading + // flag and MapSwitchPatch never re-fires for a player who stays on + // the same map. Without a re-announcement every viewed map is left + // at count 0 -> VTR 15 after each join point/rehost. Called by + // SaveAndReloadCore after the reloading flag clears; previous is + // InvalidMapId so the command increments the new map without + // decrementing anything (the old counts died with the old comps). + public static void ResendCurrentView() + { + if (Multiplayer.Client == null) return; + + lastMovedToMapId = InvalidMapId; + lastSentAtTick = -1; + + // wantedMode, not the CurrentWorldRenderMode getter: the getter + // is patched (WorldRenderModePatch) and must not run mid-install + int viewedId = Find.World?.renderer?.wantedMode == WorldRenderMode.Planet + ? WorldMapId + : Find.CurrentMap?.uniqueID ?? InvalidMapId; + + if (viewedId == InvalidMapId) return; + + SendViewedMapUpdate(InvalidMapId, viewedId); + } } [HarmonyPatch(typeof(Game), nameof(Game.CurrentMap), MethodType.Setter)] diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index f581dda43..e1e68a134 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -8,6 +8,7 @@ using System.Security.Cryptography; using System.Threading; using System.Xml; +using Multiplayer.Client.Patches; using Multiplayer.Client.Saving; using Multiplayer.Client.Util; using UnityEngine; @@ -112,6 +113,8 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio Multiplayer.reloading = false; + VTRSync.ResendCurrentView(); + return gameData; } From ae16b717833c47799d298109398d2c45b37652aa Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Tue, 28 Jul 2026 17:22:21 +0200 Subject: [PATCH 23/27] Handle playing-state broadcasts on clients that are still loading The server flips a joiner to the playing state as soon as the world data is queued, without waiting for the client to finish downloading and loading it, so player-list, chat and notification broadcasts can legally arrive while the client is still in the loading state - and Steam P2P delivers reliable packets out of order besides (a player-list packet was observed arriving ahead of the world-data header). An unhandled reliable packet is treated as protocol corruption and kills the whole session, dumping the joiner back to the menu mid-join. The three handlers move to the shared client state base, where keep-alive and time-control handling already live, so loading clients process them instead of dying. All three touch only session UI state, and the full player list sent after the world data reconciles anything received during the window. Sim-relevant packets (commands, freezes, desync checks) intentionally stay playing-only. --- .../Networking/State/ClientBaseState.cs | 93 +++++++++++++++++++ .../Networking/State/ClientPlayingState.cs | 80 ---------------- 2 files changed, 93 insertions(+), 80 deletions(-) diff --git a/Source/Client/Networking/State/ClientBaseState.cs b/Source/Client/Networking/State/ClientBaseState.cs index 0eb6bc1ba..176827ab3 100644 --- a/Source/Client/Networking/State/ClientBaseState.cs +++ b/Source/Client/Networking/State/ClientBaseState.cs @@ -1,6 +1,10 @@ +using System; +using System.Linq; using Multiplayer.Client.Networking; using Multiplayer.Common; using Multiplayer.Common.Networking.Packet; +using RimWorld; +using Verse; namespace Multiplayer.Client; @@ -8,6 +12,95 @@ public abstract class ClientBaseState(ConnectionBase connection) : MpConnectionS { protected MultiplayerSession Session => Multiplayer.session; + // PlayerList, chat and notifications live here rather than in + // ClientPlayingState: the server flips a joiner to ServerPlaying as soon + // as the world data is queued (ServerLoadingState.RunState), so these + // broadcasts can legally reach a client still in ClientLoading - and + // Steam P2P delivers reliable packets out of order besides (observed + // Server_PlayerList ahead of Server_WorldDataStart, 2026-07-28). An + // unhandled reliable packet kills the session, so the loading state must + // handle them. All three touch session/UI state only; the full List sent + // after the world data reconciles anything applied during the window. + [TypedPacketHandler] + public void HandlePlayerList(ServerPlayerListPacket packet) + { + if (packet.action == PlayerListAction.Add) + { + foreach (var info in packet.players) + { + if (!Multiplayer.session.players.Any(p => p.id == info.id || p.username == info.username)) + { + ServerLog.Log($"PlayerList: Adding player {info.id}:{info.username}"); + Multiplayer.session.players.Add(PlayerInfo.FromNet(info)); + } + else + { + ServerLog.Error($"PlayerList: Adding player {info.id}:{info.username} - player already exists"); + } + } + } + else if (packet.action == PlayerListAction.Remove) + { + ServerLog.Log($"PlayerList: Removing player with id {packet.playerId}"); + var matches = Multiplayer.session.players.RemoveAll(p => p.id == packet.playerId); + if (matches > 1) + { + ServerLog.Error($"PlayerList: Removing player with id {packet.playerId} -- occurred {matches} times. This should not happen"); + } + } + else if (packet.action == PlayerListAction.List) + { + ServerLog.Log($"PlayerList: Received player list with {packet.players.Length} entries"); + + Multiplayer.session.players.Clear(); + foreach (var info in packet.players) + { + ServerLog.Log($"PlayerList: Adding player from list {info.id}:{info.username}"); + Multiplayer.session.players.Add(PlayerInfo.FromNet(info)); + } + } + else if (packet.action == PlayerListAction.Latencies) + { + foreach (var latency in packet.latencies) + { + var player = Multiplayer.session.GetPlayerInfo(latency.playerId); + if (player == null) + { + ServerLog.Log($"PlayerList: Received latency info for unknown player with id {latency.playerId}"); + continue; + } + player.latency = latency.latency; + player.ticksBehind = latency.ticksBehind; + player.simulating = latency.simulating; + player.frameTime = latency.frameTime; + } + } + else if (packet.action == PlayerListAction.Status) + { + var player = Multiplayer.session.GetPlayerInfo(packet.playerId); + if (player == null) + { + ServerLog.Log($"PlayerList: Received player status ({packet.status}) for unknown player with id {packet.playerId}"); + } + else + { + player.status = packet.status; + } + } + } + + [TypedPacketHandler] + public void HandleChat(ServerChatPacket packet) => Multiplayer.session.AddMsg(packet.msg, rawMessage: packet.rawMessage); + + [TypedPacketHandler] + public void HandleNotification(ServerNotificationPacket packet) + { + var namedArgs = Array.ConvertAll(packet.args, s => (NamedArgument)s); + var msg = packet.key.Translate(namedArgs); + Messages.Message(msg, MessageTypeDefOf.SilentInput, false); + ServerLog.Log($"Notification: {msg} ({packet.key}, {packet.args.Join(", ")})"); + } + [TypedPacketHandler] public void HandleKeepAlive(ServerKeepAlivePacket packet) { diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs index 6a96635a5..fdfd4dd1d 100644 --- a/Source/Client/Networking/State/ClientPlayingState.cs +++ b/Source/Client/Networking/State/ClientPlayingState.cs @@ -23,77 +23,6 @@ public void HandleCommand(ServerCommandPacket packet) Multiplayer.session.ProcessTimeControl(); } - [TypedPacketHandler] - public void HandlePlayerList(ServerPlayerListPacket packet) - { - if (packet.action == PlayerListAction.Add) - { - foreach (var info in packet.players) - { - if (!Multiplayer.session.players.Any(p => p.id == info.id || p.username == info.username)) - { - ServerLog.Log($"PlayerList: Adding player {info.id}:{info.username}"); - Multiplayer.session.players.Add(PlayerInfo.FromNet(info)); - } - else - { - ServerLog.Error($"PlayerList: Adding player {info.id}:{info.username} - player already exists"); - } - } - } - else if (packet.action == PlayerListAction.Remove) - { - ServerLog.Log($"PlayerList: Removing player with id {packet.playerId}"); - var matches = Multiplayer.session.players.RemoveAll(p => p.id == packet.playerId); - if (matches > 1) - { - ServerLog.Error($"PlayerList: Removing player with id {packet.playerId} -- occurred {matches} times. This should not happen"); - } - } - else if (packet.action == PlayerListAction.List) - { - ServerLog.Log($"PlayerList: Received player list with {packet.players.Length} entries"); - - Multiplayer.session.players.Clear(); - foreach (var info in packet.players) - { - ServerLog.Log($"PlayerList: Adding player from list {info.id}:{info.username}"); - Multiplayer.session.players.Add(PlayerInfo.FromNet(info)); - } - } - else if (packet.action == PlayerListAction.Latencies) - { - foreach (var latency in packet.latencies) - { - var player = Multiplayer.session.GetPlayerInfo(latency.playerId); - if (player == null) - { - ServerLog.Log($"PlayerList: Received latency info for unknown player with id {latency.playerId}"); - continue; - } - player.latency = latency.latency; - player.ticksBehind = latency.ticksBehind; - player.simulating = latency.simulating; - player.frameTime = latency.frameTime; - } - } - else if (packet.action == PlayerListAction.Status) - { - var player = Multiplayer.session.GetPlayerInfo(packet.playerId); - if (player == null) - { - ServerLog.Log($"PlayerList: Received player status ({packet.status}) for unknown player with id {packet.playerId}"); - } - else - { - player.status = packet.status; - } - } - } - - [TypedPacketHandler] - public void HandleChat(ServerChatPacket packet) => Multiplayer.session.AddMsg(packet.msg, rawMessage: packet.rawMessage); - [TypedPacketHandler] public void HandleCursor(ServerCursorPacket packet) { @@ -157,15 +86,6 @@ public void HandleMapResponse(ByteReader data) }); } - [TypedPacketHandler] - public void HandleNotification(ServerNotificationPacket packet) - { - var namedArgs = Array.ConvertAll(packet.args, s => (NamedArgument)s); - var msg = packet.key.Translate(namedArgs); - Messages.Message(msg, MessageTypeDefOf.SilentInput, false); - ServerLog.Log($"Notification: {msg} ({packet.key}, {packet.args.Join(", ")})"); - } - [TypedPacketHandler] public void HandleDesyncCheck(ServerSyncInfoPacket packet) => Multiplayer.game?.sync.AddClientOpinionAndCheckDesync(ClientSyncOpinion.FromNet(packet.SyncOpinion)); From b1fc85ec8186f699b44543f988445950af1a3002 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Fri, 31 Jul 2026 17:30:13 +0200 Subject: [PATCH 24/27] Fix comp-less map NRE storm; derive VTR counts from a synced view table A map generated mid-session sits in Find.Maps before SetupMap registers its AsyncTimeComp. In non-async games ActualRateMultiplier dereferenced every map's comp unguarded, so any speed read inside that window threw - including SetupMap's own world-speed read, which aborted setup and left the map comp-less permanently: per-frame root OnGUI exceptions (the reported UI flicker), a dead alerts readout, and RunCmds NREs that skip scheduled commands (no desync only because every client fails symmetrically). ActualRateMultiplier, AllTickables and TimeSnapshot.GetAndSetFromMap now skip comp-less maps, and the world- speed override runs after comp registration so a future exception can't poison the session. The per-comp VTR view counts were incremental and drift-prone: reload wipes, cross-join-point double counting, and an unfloored planet-view decrement that could pin world objects at the no-viewer rate. Replaced with a synced player->viewed-map table on the game comp, scribed with the save. The PlayerCount payload becomes an absolute announce (playerId, viewedMapId): idempotent, order-tolerant, removed server-side on disconnect, sender id validated. Comps derive counts lazily behind a version stamp and self-heal after reloads. World objects now run at full rate while anyone is connected - in multiplayer the session is always watched and world motion is visible from map view. Protocol 57 -> 58. Also adds a log-only diagnostic that reports an alert re-activating repeatedly (field reports of alert sounds re-firing), naming the alert type and the clock/speed state it flipped under. --- Source/Client/AsyncTime/AsyncTimeComp.cs | 31 +++++-- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 62 +++++++++----- Source/Client/AsyncTime/SetMapTime.cs | 7 +- .../Client/Comp/Game/MultiplayerGameComp.cs | 29 +++++++ Source/Client/Patches/AlertThrashDiag.cs | 85 +++++++++++++++++++ Source/Client/Patches/MapSetup.cs | 26 +++--- Source/Client/Patches/TickPatch.cs | 19 ++++- Source/Client/Patches/VTRSyncPatch.cs | 23 +++-- .../Networking/State/ServerPlayingState.cs | 16 +++- Source/Common/PlayerManager.cs | 6 +- Source/Common/Version.cs | 2 +- Source/Tests/StandaloneMapStreamingTest.cs | 36 +++++++- 12 files changed, 282 insertions(+), 60 deletions(-) create mode 100644 Source/Client/Patches/AlertThrashDiag.cs diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index a4e96587b..b141d41e9 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -95,7 +95,31 @@ public TimeSpeed DesiredTimeSpeed public Queue cmds = new(); - public int CurrentPlayerCount { get; private set; } + private int cachedPlayerCount; + private int cachedPlayerCountVersion = -1; + + // Derived from the synced view table on demand, never incremented. + // The version gate makes the count self-healing: comps recreated by a + // reload start stale and re-derive on first read, so there is no + // window where a viewed map sits at the no-viewer rate. + public int CurrentPlayerCount + { + get + { + var gameComp = Multiplayer.GameComp; + if (cachedPlayerCountVersion != gameComp.playerViewsVersion) + { + cachedPlayerCount = 0; + foreach (var viewedMapId in gameComp.playerViewedMaps.Values) + if (viewedMapId == map.uniqueID) + cachedPlayerCount++; + cachedPlayerCountVersion = gameComp.playerViewsVersion; + } + + return cachedPlayerCount; + } + } + public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public AsyncTimeComp(Map map, int gameStartAbsTick = 0) @@ -221,11 +245,6 @@ public void ExposeData() Scribe_Custom.LookULong(ref randState, "randState", 1); } - public int IncreasePlayerCount() => CurrentPlayerCount += 1; - // This should never go below 0, this is just defensive programming. Hopefully not needed anymore, but - // nevertheless still left. - public int DecreasePlayerCount() => CurrentPlayerCount = Math.Max(0, CurrentPlayerCount - 1); - public void FinalizeInit() { // Only saves predating the per-map field (3bfa487) and the diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 4d48aeda1..336bb8292 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -61,8 +61,40 @@ public TimeSpeed DesiredTimeSpeed public Queue Cmds => cmds; public Queue cmds = new(); - public int CurrentPlayerCount { get; private set; } - public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; + private int cachedPlanetViewerCount; + private int cachedPlayerCountVersion = -1; + + // Players currently in the planet view, derived from the synced view + // table (see MultiplayerGameComp.playerViewedMaps). The old incremental + // count had no floor on this comp and a post-reload disconnect could + // drive it negative, pinning world objects at the no-viewer rate even + // with the planet view open. + public int CurrentPlayerCount + { + get + { + var gameComp = Multiplayer.GameComp; + if (cachedPlayerCountVersion != gameComp.playerViewsVersion) + { + cachedPlanetViewerCount = 0; + foreach (var viewedMapId in gameComp.playerViewedMaps.Values) + if (viewedMapId == VTRSync.WorldMapId) + cachedPlanetViewerCount++; + cachedPlayerCountVersion = gameComp.playerViewsVersion; + } + + return cachedPlanetViewerCount; + } + } + + // World objects run at full rate while anyone is connected, not only + // while someone has the planet view open. Vanilla's rate-15 default for + // an unwatched world assumes nobody can see it; in multiplayer caravans + // and world motion are visible from map view edges and the session is + // always watched by someone, and a permanently lumping world was field- + // reported as "world rendering strangeness". Derived from synced state, + // identical on every client. + public int VTR => Multiplayer.GameComp.playerViewedMaps.Count > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public int TickableId => -1; @@ -282,23 +314,15 @@ public void ExecuteCmd(ScheduledCommand cmd) if (cmdType == CommandType.PlayerCount) { - int previousMapId = data.ReadInt32(); - int newMapId = data.ReadInt32(); - int mapCount = Find.Maps.Count; - - var prev = -1; - if (previousMapId >= 0) - prev = Find.Maps.FirstOrDefault(x => x.uniqueID == previousMapId)?.AsyncTime()?.DecreasePlayerCount() ?? -1; - else if (previousMapId == VTRSync.WorldMapId) - prev = Multiplayer.AsyncWorldTime.CurrentPlayerCount -= 1; - - var curr = -1; - if (newMapId >= 0) - curr = Find.Maps.FirstOrDefault(x => x.uniqueID == newMapId)?.AsyncTime()?.IncreasePlayerCount() ?? -1; - else if (newMapId == VTRSync.WorldMapId) - curr = Multiplayer.AsyncWorldTime.CurrentPlayerCount += 1; - - MpLog.Debug($"[{worldTicks}|{Multiplayer.session.remoteTickUntil}] Player count change: previousMapId={previousMapId} ({prev}), newMapId={newMapId} ({curr}), mapCount={mapCount}"); + // Payload: (playerId, viewedMapId). InvalidMapId removes the + // entry (server-sent when the player disconnects). The VTR + // counts derive from the table - see + // MultiplayerGameComp.playerViewedMaps. + int playerId = data.ReadInt32(); + int viewedMapId = data.ReadInt32(); + Multiplayer.GameComp.SetPlayerViewedMap(playerId, viewedMapId); + + MpLog.Debug($"[{worldTicks}|{Multiplayer.session.remoteTickUntil}] Player view: player={playerId}, map={viewedMapId}, views={Multiplayer.GameComp.playerViewedMaps.Count}"); } } catch (Exception e) diff --git a/Source/Client/AsyncTime/SetMapTime.cs b/Source/Client/AsyncTime/SetMapTime.cs index d73c44759..620b63aee 100644 --- a/Source/Client/AsyncTime/SetMapTime.cs +++ b/Source/Client/AsyncTime/SetMapTime.cs @@ -275,10 +275,15 @@ public static TimeSnapshot Current() { if (map == null) return null; + // A map mid-generation is in Find.Maps before its AsyncTimeComp is + // registered - no comp means no map clock to install yet, same + // contract as the null-world guard in GetAndSetFromWorld + var mapComp = map.AsyncTime(); + if (mapComp == null) return null; + TimeSnapshot prev = Current(); var tickManager = Find.TickManager; - var mapComp = map.AsyncTime(); // Field, not the property: the vanilla setter silently drops // writes (and can emit a RejectInput message) when diff --git a/Source/Client/Comp/Game/MultiplayerGameComp.cs b/Source/Client/Comp/Game/MultiplayerGameComp.cs index 2f3909cc7..8939873e6 100644 --- a/Source/Client/Comp/Game/MultiplayerGameComp.cs +++ b/Source/Client/Comp/Game/MultiplayerGameComp.cs @@ -19,10 +19,30 @@ public class MultiplayerGameComp : IExposable, IHasSessionData public Dictionary playerData = new(); // player id to player data public int nextSessionId; + // Synced view table: player id -> viewed map uniqueID, with + // VTRSync.WorldMapId for the planet view. Source of truth for the VTR + // player counts - counts are derived from this table, never + // incremented, so no send/wipe/disconnect ordering can drift them. + // Written only from CommandType.PlayerCount commands (client view + // announces and the server's disconnect removal) and scribed so join + // points and rehosts keep the views instead of resetting every map to + // the no-viewer rate. + public Dictionary playerViewedMaps = new(); + public int playerViewsVersion; + public string idBlockBase64; public bool IsLowestWins => timeControl == TimeControl.LowestWins; + public void SetPlayerViewedMap(int playerId, int mapId) + { + if (mapId == Patches.VTRSync.InvalidMapId) + playerViewedMaps.Remove(playerId); + else + playerViewedMaps[playerId] = mapId; + playerViewsVersion++; + } + public PlayerData LocalPlayerDataOrNull => playerData.GetValueOrDefault(Multiplayer.session.playerId); public void ExposeData() @@ -35,6 +55,15 @@ public void ExposeData() Scribe_Values.Look(ref timeControl, "timeControl"); Scribe_Values.Look(ref nextSessionId, "nextSessionId"); + Scribe_Collections.Look(ref playerViewedMaps, "playerViewedMaps", LookMode.Value, LookMode.Value); + if (Scribe.mode == LoadSaveMode.PostLoadInit) + { + // Absent on saves predating the table; version bump invalidates + // every comp's cached count after a (re)load + playerViewedMaps ??= new Dictionary(); + playerViewsVersion++; + } + // Store for back-compat conversion in GameExposeComponentsPatch if (Scribe.mode == LoadSaveMode.LoadingVars) Scribe_Values.Look(ref idBlockBase64, "globalIdBlock"); diff --git a/Source/Client/Patches/AlertThrashDiag.cs b/Source/Client/Patches/AlertThrashDiag.cs new file mode 100644 index 000000000..3615efce7 --- /dev/null +++ b/Source/Client/Patches/AlertThrashDiag.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; +using Multiplayer.Client.Util; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.Patches +{ + // Diagnostic only. Field reports describe alert sounds re-firing ("low + // food keeps pinging") in multiplayer sessions, which requires an alert + // to leave and re-enter the active list - Notify_Started rings the bell + // only on re-entry. No mechanism for the flip has been confirmed from + // the code, so instead of a speculative fix this logs a rate-limited + // line naming the alert and the state it flipped under, turning the next + // session's Player.log into the evidence. Log-only, UI-side, no sim + // state touched. + [HarmonyPatch(typeof(AlertsReadout), nameof(AlertsReadout.CheckAddOrRemoveAlert))] + static class AlertThrashDiag + { + // Real-time seconds of recent activations, per alert type + private static readonly Dictionary> activations = new(); + private static readonly Dictionary lastReport = new(); + + private const int ThrashActivationCount = 3; + private const float ThrashWindowSeconds = 60f; + private const float ReportIntervalSeconds = 300f; + + static void Prefix(AlertsReadout __instance, Alert alert, ref bool __state) + => __state = alert != null && __instance.activeAlerts.Contains(alert); + + static void Postfix(AlertsReadout __instance, Alert alert, bool __state) + { + if (Multiplayer.Client == null || alert == null) return; + + try + { + // Only newly-(re)activated alerts ring the bell + if (__state || !__instance.activeAlerts.Contains(alert)) return; + + var key = alert.GetType().Name; + var now = Time.realtimeSinceStartup; + + if (!activations.TryGetValue(key, out var times)) + activations[key] = times = new Queue(); + + times.Enqueue(now); + while (times.Count > 0 && times.Peek() < now - ThrashWindowSeconds) + times.Dequeue(); + + if (times.Count < ThrashActivationCount) return; + if (lastReport.TryGetValue(key, out var last) && now - last < ReportIntervalSeconds) return; + lastReport[key] = now; + + MpLog.Log( + $"Alert thrash: {key} activated {times.Count}x in {ThrashWindowSeconds}s. " + + $"ambientTicks={Find.TickManager.TicksGame}, worldTicks={Multiplayer.AsyncWorldTime?.worldTicks}, " + + $"viewedMap={Find.CurrentMap?.uniqueID}, " + + $"mapSpeeds=[{MapStates()}]{LowFoodInputs(alert)}"); + } + catch (Exception e) + { + Log.WarningOnce($"Alert thrash diagnostic failed: {e.Message}", 758231); + } + } + + private static string MapStates() => + Find.Maps.Select(m => m.AsyncTime()) + .Where(c => c != null) + .Join(c => $"{c.map.uniqueID}:{c.DesiredTimeSpeed}@{c.mapTicks}", ", "); + + private static string LowFoodInputs(Alert alert) + { + if (alert is not Alert_LowFood) return ""; + + var perMap = Find.Maps + .Where(m => m.IsPlayerHome && m.mapPawns.AnyColonistSpawned) + .Join(m => $"{m.uniqueID}:food={m.resourceCounter.TotalHumanEdibleNutrition:F1}/colonists={m.mapPawns.FreeColonistsSpawnedCount}", ", "); + + return $", lowFood=[{perMap}]"; + } + } +} diff --git a/Source/Client/Patches/MapSetup.cs b/Source/Client/Patches/MapSetup.cs index 7aaa8bd83..78bc0e2bd 100644 --- a/Source/Client/Patches/MapSetup.cs +++ b/Source/Client/Patches/MapSetup.cs @@ -29,6 +29,17 @@ public static void SetupMap(Map map, bool usingMapTimeFromSingleplayer = false) var async = CreateAsyncTimeCompForMap(map, usingMapTimeFromSingleplayer); Multiplayer.game.asyncTimeComps.Add(async); + // Ask the world, not the global TickManager: the global is viewer-dependent + // and this value is scribed onto the new map. Not on the singleplayer- + // conversion path (no client exists there; the world would read Paused and + // stomp the singleplayer speed). Runs after the comp is registered: the world + // getter walks every map's comp, and reading it while this map is comp-less + // is what let an exception here abort SetupMap and leave the map comp-less + // forever. The fresh comp sits at Paused, which the running-map filter + // ignores, so the value is unchanged. + if (!usingMapTimeFromSingleplayer && !Multiplayer.GameComp.asyncTime) + async.DesiredTimeSpeed = Multiplayer.AsyncWorldTime?.DesiredTimeSpeed ?? Find.TickManager.CurTimeSpeed; + // Store all current managers for Faction.OfPlayer InitFactionDataFromMap(map, Faction.OfPlayer); @@ -68,19 +79,8 @@ private static AsyncTimeComp CreateAsyncTimeCompForMap(Map map, bool usingMapTim startingTimeSpeed = TimeSpeed.Paused; } - // Ask the world, not the global TickManager: the global is viewer-dependent - // (TickPatch installs the speed of whatever the local player is looking at so - // rendering has a context) and this value is scribed onto the new map, so - // reading it here would let clients disagree on a generated map's starting - // speed. The world's DesiredTimeSpeed is what the global used to carry here - // and is identical on every client. - // - // Not on the singleplayer-conversion path: it runs on one machine before any - // client exists, and at that point no map has its AsyncTimeComp yet, so the - // world's speed would read Paused and stomp the speed read above. - if (!usingMapTimeFromSingleplayer && !Multiplayer.GameComp.asyncTime) - startingTimeSpeed = Multiplayer.AsyncWorldTime?.DesiredTimeSpeed ?? Find.TickManager.CurTimeSpeed; - + // The non-async world-speed override happens in SetupMap AFTER this comp is + // registered - see the comment there. var asyncTimeCompForMap = new AsyncTimeComp(map, gameStartAbsTick) { mapTicks = startingMapTicks, diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index 3528d7a40..5cf6aea60 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -52,7 +52,15 @@ public static IEnumerable AllTickables var maps = Find.Maps; for (int i = maps.Count - 1; i >= 0; i--) - yield return maps[i].AsyncTime(); + { + // Skip maps whose AsyncTimeComp isn't registered yet + // (mid-session map generation) - every consumer derefs the + // tickable, and a command aimed at such a map already + // fails loud through RunCmds' TickableById null path. + var comp = maps[i].AsyncTime(); + if (comp != null) + yield return comp; + } } } @@ -330,7 +338,14 @@ public static float ActualRateMultiplier(this ITickable tickable, TimeSpeed spee var rate = Multiplayer.AsyncWorldTime.TickRateMultiplier(speed); foreach (var map in Find.Maps) - rate = Math.Min(rate, map.AsyncTime().TickRateMultiplier(speed)); + { + // A map can sit in Find.Maps before its AsyncTimeComp is + // registered (mid-session map generation). A comp-less map + // isn't a running map, so it can't bound the rate. + var comp = map.AsyncTime(); + if (comp != null) + rate = Math.Min(rate, comp.TickRateMultiplier(speed)); + } return rate; } diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index fffd35204..d3c350067 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -73,7 +73,12 @@ public static void SendViewedMapUpdate(int previous, int current) else if (previous == current) return; int currentTick = Find.TickManager?.TicksGame ?? 0; MpLog.Debug($"VTR MapSwitchPatch: {lastMovedToMapId}->{current} @ tick {currentTick}{warn}"); - Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(previous, current)); + // Payload is an absolute announce (playerId, viewedMapId) into the + // synced view table, not a delta - re-announcing the same view is + // idempotent, so ResendCurrentView after a reload can never + // double-count and command ordering around join points can't + // drift the counts + Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(Multiplayer.session.playerId, current)); lastMovedToMapId = current; } @@ -83,14 +88,14 @@ public static void Reset() lastSentAtTick = -1; } - // Every SaveAndReload recreates the time comps, zeroing every - // CurrentPlayerCount, while sends are suppressed by the reloading - // flag and MapSwitchPatch never re-fires for a player who stays on - // the same map. Without a re-announcement every viewed map is left - // at count 0 -> VTR 15 after each join point/rehost. Called by - // SaveAndReloadCore after the reloading flag clears; previous is - // InvalidMapId so the command increments the new map without - // decrementing anything (the old counts died with the old comps). + // Re-announce the local view after every SaveAndReload. The view + // table is scribed and the counts derive from it, so unlike the old + // incremental counts nothing is lost across a reload - but sends are + // suppressed while reloading and a view change during that window + // (or a fresh host with an empty table) would otherwise go + // unannounced. The announce is an absolute (playerId, mapId) write, + // so repeating an unchanged view is a no-op. Called by + // SaveAndReloadCore after the reloading flag clears. public static void ResendCurrentView() { if (Multiplayer.Client == null) return; diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index f7b21e7a7..5f9beabb4 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -37,12 +37,20 @@ public void HandleClientCommand(ClientCommandPacket packet) if (packet.type == CommandType.PlayerCount) { + // Payload: (playerId, viewedMapId) - an absolute announce into + // the clients' synced view table. The player id must be the + // sender's own; the server also sends removals on its behalf + // at disconnect (PlayerManager.SetDisconnected). ByteReader reader = new ByteReader(packet.data); - var prevMapId = reader.ReadInt32(); + var playerId = reader.ReadInt32(); var newMapId = reader.ReadInt32(); - if (Player.currentMapId != prevMapId) - ServerLog.Error($"Inconsistent player {Player.Username} map. Last known map: {Player.currentMapId}, " + - $"however received command with transition: {prevMapId} -> {newMapId}"); + + if (playerId != Player.id) + { + ServerLog.Error($"Player {Player.Username} announced a view for player id {playerId} - dropped"); + return; + } + Player.currentMapId = newMapId; Player.hasReportedCurrentMap = true; diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index efa871704..1a1e4943f 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -79,10 +79,12 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) if (player.hasJoined) { - // Send PlayerCount command to remove the player from their last known map + // Remove the leaver from the clients' synced view table + // (viewedMapId -1 removes the entry) so their map doesn't keep + // a phantom viewer until the next reload if (player.currentMapId != -1) { - byte[] playerCountData = ByteWriter.GetBytes(player.currentMapId, -1); // previousMap: player's map, newMap: -1 (disconnected) + byte[] playerCountData = ByteWriter.GetBytes(player.id, -1); server.commands.Send(CommandType.PlayerCount, ScheduledCommand.NoFaction, ScheduledCommand.Global, playerCountData); } // todo check player.IsPlaying? diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index e6ac62012..81ab54b52 100644 --- a/Source/Common/Version.cs +++ b/Source/Common/Version.cs @@ -6,7 +6,7 @@ namespace Multiplayer.Common public static class MpVersion { public const string SimpleVersion = "0.11.5"; - public const int Protocol = 57; + public const int Protocol = 58; public static readonly string? GitHash = Assembly.GetExecutingAssembly() .GetCustomAttributes() diff --git a/Source/Tests/StandaloneMapStreamingTest.cs b/Source/Tests/StandaloneMapStreamingTest.cs index 6676875b1..cc7f146c1 100644 --- a/Source/Tests/StandaloneMapStreamingTest.cs +++ b/Source/Tests/StandaloneMapStreamingTest.cs @@ -97,7 +97,7 @@ public void InitialPlayerCountMapReport_DoesNotSendMapResponse() state.HandleClientCommand(new ClientCommandPacket( CommandType.PlayerCount, ScheduledCommand.Global, - ByteWriter.GetBytes(-1, 5) + ByteWriter.GetBytes(player.id, 5) )); Assert.That(player.currentMapId, Is.EqualTo(5)); @@ -116,7 +116,7 @@ public void WorldToMapTransition_DoesNotSendMapResponse() state.HandleClientCommand(new ClientCommandPacket( CommandType.PlayerCount, ScheduledCommand.Global, - ByteWriter.GetBytes(-2, 5) + ByteWriter.GetBytes(player.id, 5) )); Assert.That(player.currentMapId, Is.EqualTo(5)); @@ -134,13 +134,43 @@ public void MapToMapTransition_DoesNotSendMapResponseWhenStreamingDisabled() state.HandleClientCommand(new ClientCommandPacket( CommandType.PlayerCount, ScheduledCommand.Global, - ByteWriter.GetBytes(3, 5) + ByteWriter.GetBytes(player.id, 5) )); Assert.That(player.currentMapId, Is.EqualTo(5)); Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_MapResponse)); } + [Test] + public void PlayerCountForOtherPlayer_IsDropped() + { + var (player, conn) = AddPlayer("player", 3); + + // The drop logs a server error by design - capture it instead of + // letting the global Assert.Fail hook (ServerTest.SetUp) eat it + var prevErrorHook = ServerLog.error; + string? loggedError = null; + ServerLog.error = s => loggedError = s; + + try + { + var state = player.conn.GetState()!; + state.HandleClientCommand(new ClientCommandPacket( + CommandType.PlayerCount, + ScheduledCommand.Global, + ByteWriter.GetBytes(player.id + 1, 5) + )); + } + finally + { + ServerLog.error = prevErrorHook; + } + + Assert.That(loggedError, Does.Contain("announced a view")); + Assert.That(player.currentMapId, Is.EqualTo(3)); + Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_Command)); + } + [Test] public void HandleDebug_IgnoredWhenDevModeDisabled() { From b6725566a2bb2fbd17f71f0de65fe249248977cd Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Fri, 31 Jul 2026 18:42:31 +0200 Subject: [PATCH 25/27] Fix cross-clock UI/sound artifacts at mixed speeds Three composable mechanisms behind the remaining mixed-speed flicker and re-firing notification sounds: World-tick faction restore hardened: PreContext swaps every map onto the spectator faction's data (resource counter, zone/area/designation managers) and the restore was conditional on the popped context, with SetFaction silently no-oping when a map lacks the faction's data. Any leak left maps on spectator data, alternating with frames that ran a world tick - which only happens at mixed speeds. Restore now falls back to the local player faction (identical to a balanced pop), the skip warns once per map/faction, and a log-only diagnostic reports residue at UI time. Sustainer/sample cross-clock lifetime: most sustainers have no map (the constructor downgrades defs without world sub-sounds to OnCamera), so their tick math ran under the viewed map's clock while Maintain() stamped other clocks - a viewer clock ahead of the stamp ends and respawns the sustainer every frame. Sound brackets now fall back to the world clock for map-less sounds; a tolerant end-check re-stamps tick-stale sustainers that were realtime-maintained within 1s (truly abandoned ones still die, at most 1s late); the one-shot reaper runs under the world clock so a paused viewer keeps reaping. Frame-order and tween: Root_Play.Update runs RealTime/portraits/ UIRootUpdate before the frame's viewer clock was installed - install it at frame start too; PawnTweener brackets carried pawns with MapHeld instead of the null Map. All render/audio/UI-side; no simulation-visible changes. --- Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 13 ++- Source/Client/AsyncTime/SetMapTime.cs | 43 ++++++- Source/Client/Comp/Map/MultiplayerMapComp.cs | 13 +++ Source/Client/Patches/FactionResidueDiag.cs | 53 +++++++++ .../Client/Patches/SustainerClockPatches.cs | 105 ++++++++++++++++++ Source/Client/Patches/TickPatch.cs | 19 +++- 6 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 Source/Client/Patches/FactionResidueDiag.cs create mode 100644 Source/Client/Patches/SustainerClockPatches.cs diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 336bb8292..08e777145 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -237,7 +237,18 @@ public void PostContext() { if (Multiplayer.GameComp.multifaction) { - var f = FactionExtensions.PopFaction(); + // Restore must be unconditional: PreContext swapped EVERY map onto + // the spectator faction's data (resourceCounter, zone/area/ + // designation managers), and leaving any map on it corrupts every + // UI read until something else swaps it back (alternating only on + // frames that ran a world tick - a per-frame flicker/re-ping + // shape). A balanced pop returns the pre-world-tick OfPlayer, + // which is the client-local faction, so falling back to + // RealPlayerFaction on an unbalanced pop restores the same thing + // the balanced path would have. This is the faction half of the + // same PreContext/PostContext imbalance whose speed half caused + // the paused-map time-context bug. + var f = FactionExtensions.PopFaction() ?? Multiplayer.RealPlayerFaction; foreach (var map in Find.Maps) map.MpComp().SetFaction(f); } diff --git a/Source/Client/AsyncTime/SetMapTime.cs b/Source/Client/AsyncTime/SetMapTime.cs index 620b63aee..5bd2567f5 100644 --- a/Source/Client/AsyncTime/SetMapTime.cs +++ b/Source/Client/AsyncTime/SetMapTime.cs @@ -139,7 +139,11 @@ static class PreDrawPosCalculationMapTime static void Prefix(PawnTweener __instance, ref TimeSnapshot? __state) { if (Multiplayer.Client == null || Current.ProgramState != ProgramState.Playing) return; - __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn.Map); + // MapHeld, not Map: a carried/transported pawn has no map of its + // own, and with no install its tween ran under the viewer's clock + // (stamping lastDrawTick with it), then hard-snapped when a real + // clock returned. The holder's map clock is the motion's basis. + __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn.MapHeld); } static void Postfix(TimeSnapshot? __state) => __state?.Set(); @@ -169,7 +173,16 @@ static IEnumerable TargetMethods() static void Prefix(Sustainer __instance, ref TimeSnapshot? __state) { if (Multiplayer.game == null) return; - __state = TimeSnapshot.GetAndSetFromMap(__instance.info.Maker.Map); + // Most sustainers have no map: the Sustainer constructor + // downgrades any def without world sub-sounds to OnCamera, making + // Maker invalid. Falling through with no install left those + // running under the viewed map's clock while Maintain() stamps + // the maintainer's ambient tick - a cross-clock pair that ends + // (and respawns) the sustainer every frame when the viewer's + // clock is ahead. The world clock is the only session-wide + // monotone basis, so install it for map-less sound code. + __state = TimeSnapshot.GetAndSetFromMap(__instance.info.Maker.Map) + ?? TimeSnapshot.GetAndSetFromWorld(); } static void Postfix(TimeSnapshot? __state) => __state?.Set(); @@ -181,12 +194,36 @@ static class SampleUpdateMapTime static void Prefix(Sample __instance, ref TimeSnapshot? __state) { if (Multiplayer.game == null) return; - __state = TimeSnapshot.GetAndSetFromMap(__instance.Map); + // World-clock fallback for map-less samples - see + // SustainerUpdateMapTime above + __state = TimeSnapshot.GetAndSetFromMap(__instance.Map) + ?? TimeSnapshot.GetAndSetFromWorld(); } static void Postfix(TimeSnapshot? __state) => __state?.Set(); } + // The one-shot reaper decides "finished" partly from Find.TickManager.Paused, + // which under the viewer install is the VIEWED map's pause state: a paused + // viewer never reaps finished tempo-affected one-shots, they accumulate, and + // the voice limiter then cuts/restarts sounds on every new play. The world + // clock pauses only when the whole session does, so reaping tracks actual + // sim activity. Per-sample Update calls nest their own map/world snapshots + // inside this bracket (SampleUpdateMapTime). + [HarmonyPatch(typeof(SampleOneShotManager), nameof(SampleOneShotManager.SampleOneShotManagerUpdate))] + static class OneShotReaperWorldTime + { + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(ref TimeSnapshot? __state) + { + if (Multiplayer.game == null) return; + __state = TimeSnapshot.GetAndSetFromWorld(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); + } + [HarmonyPatch(typeof(TipSignal), MethodType.Constructor, new[] { typeof(Func), typeof(int) })] static class TipSignalCtor { diff --git a/Source/Client/Comp/Map/MultiplayerMapComp.cs b/Source/Client/Comp/Map/MultiplayerMapComp.cs index 541a9233e..cba4bc4ff 100644 --- a/Source/Client/Comp/Map/MultiplayerMapComp.cs +++ b/Source/Client/Comp/Map/MultiplayerMapComp.cs @@ -5,6 +5,7 @@ using Multiplayer.Client.Factions; using Multiplayer.Client.Persistent; using Multiplayer.Client.Saving; +using Multiplayer.Client.Util; using Multiplayer.Common; using RimWorld; using RimWorld.Planet; @@ -121,10 +122,22 @@ public void DoTick() } } + private static readonly HashSet warnedMissingFactionData = new(); + public void SetFaction(Faction faction) { if (!factionData.TryGetValue(faction.loadID, out FactionMapData data)) + { + // Skipping the swap leaves the map on whatever faction's data + // is currently installed - if that was a transient context + // (e.g. the world tick's spectator swap), the map stays wrong + // until the next successful SetFaction. Never fail this + // silently: name the map and faction once so a leak is + // attributable. + if (warnedMissingFactionData.Add(((long)map.uniqueID << 32) | (uint)faction.loadID)) + MpLog.Warn($"SetFaction skipped: map {map.uniqueID} has no FactionMapData for faction {faction.loadID} ({faction.Name}) - map keeps the previously installed faction data"); return; + } map.designationManager = data.designationManager; map.areaManager = data.areaManager; diff --git a/Source/Client/Patches/FactionResidueDiag.cs b/Source/Client/Patches/FactionResidueDiag.cs new file mode 100644 index 000000000..6054f3dc2 --- /dev/null +++ b/Source/Client/Patches/FactionResidueDiag.cs @@ -0,0 +1,53 @@ +using HarmonyLib; +using Multiplayer.Client.Util; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.Patches +{ + // Diagnostic only. The world tick swaps every map onto the spectator + // faction's data and restores afterwards; if any path leaves a map on the + // wrong faction's managers, UI reads (resource readout, zones, areas, + // low-food alert nutrition) alternate between factions' data on frames + // that ran a world tick - a flicker/re-ping shape that only exists at + // mixed speeds. The restore is hardened now, but this check catches ANY + // residue source red-handed at UI time, named and counted. Log-only. + [HarmonyPatch(typeof(UIRoot_Play), nameof(UIRoot_Play.UIRootUpdate))] + static class FactionResidueDiag + { + private static int residueFrames; + private static float lastReport; + private const float ReportIntervalSeconds = 60f; + + static void Postfix() + { + if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) return; + + var map = Find.CurrentMap; + var realFaction = Multiplayer.RealPlayerFaction; + if (map == null || realFaction == null) return; + + var comp = map.MpComp(); + if (comp == null || !comp.factionData.TryGetValue(realFaction.loadID, out var ownData)) return; + + if (ReferenceEquals(map.resourceCounter, ownData.resourceCounter)) return; + + residueFrames++; + var now = Time.realtimeSinceStartup; + if (now - lastReport < ReportIntervalSeconds) return; + lastReport = now; + + var installedFactionId = -1; + foreach (var kv in comp.factionData) + if (ReferenceEquals(map.resourceCounter, kv.Value.resourceCounter)) + installedFactionId = kv.Key; + + MpLog.Warn( + $"Faction residue: map {map.uniqueID} has faction {installedFactionId}'s data installed at UI time " + + $"(local faction {realFaction.loadID}, OfPlayer {Faction.OfPlayer?.loadID}); " + + $"{residueFrames} affected frames since last report"); + residueFrames = 0; + } + } +} diff --git a/Source/Client/Patches/SustainerClockPatches.cs b/Source/Client/Patches/SustainerClockPatches.cs new file mode 100644 index 000000000..a90dc68f1 --- /dev/null +++ b/Source/Client/Patches/SustainerClockPatches.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using HarmonyLib; +using Multiplayer.Client.Util; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Multiplayer.Client.Patches +{ + // Sustainers with PerTick/PerTickRare maintenance compare an ambient tick + // against lastMaintainTick, but under per-map clocks the stamp and the + // check can come from different clocks: Maintain() runs under whatever + // context the maintainer had (often a map's tick loop), while + // SustainerUpdate runs under the sound bracket's clock (owner map, or the + // world clock for map-less sustainers). A stamp clock behind the check + // clock reads as "unmaintained" and ends the sustainer even though its + // owner maintained it microseconds ago - and owners respawn ended + // sustainers, so the sound restarts every frame. + // + // Fix: before the vanilla staleness check runs, if the sustainer LOOKS + // tick-stale but was really maintained within the grace window (measured + // in realtime, which no clock swap can touch), re-stamp it under the + // clock the check will use. Truly abandoned sustainers still die, at most + // GraceSeconds late - audio-only, imperceptible. PerFrame maintenance is + // untouched (frame counts are global). Vanilla paused behavior is + // untouched (a frozen clock never reads as stale). + public static class SustainerRealtimeMaintenance + { + public static readonly Dictionary lastMaintainRealTime = new(); + + public const float GraceSeconds = 1f; + + private static float lastRescueReport; + private static int rescueCount; + + public static void NoteRescue(Sustainer sustainer, int ambientTicks, int staleStamp) + { + rescueCount++; + var now = Time.realtimeSinceStartup; + if (now - lastRescueReport < 60f) return; + lastRescueReport = now; + MpLog.Debug($"Sustainer cross-clock rescue: {sustainer.def} ambient={ambientTicks} stamp={staleStamp} ({rescueCount} rescues since last report)"); + rescueCount = 0; + } + } + + [HarmonyPatch(typeof(Sustainer), MethodType.Constructor, typeof(SoundDef), typeof(SoundInfo))] + static class SustainerCtorRealtimeStamp + { + static void Postfix(Sustainer __instance) + { + if (Multiplayer.Client == null) return; + SustainerRealtimeMaintenance.lastMaintainRealTime[__instance] = Time.realtimeSinceStartup; + } + } + + [HarmonyPatch(typeof(Sustainer), nameof(Sustainer.Maintain))] + static class SustainerMaintainRealtimeStamp + { + static void Postfix(Sustainer __instance) + { + if (Multiplayer.Client == null) return; + SustainerRealtimeMaintenance.lastMaintainRealTime[__instance] = Time.realtimeSinceStartup; + } + } + + [HarmonyPatch(typeof(Sustainer), nameof(Sustainer.End))] + static class SustainerEndCleanup + { + static void Postfix(Sustainer __instance) + => SustainerRealtimeMaintenance.lastMaintainRealTime.Remove(__instance); + } + + [HarmonyPatch(typeof(Sustainer), nameof(Sustainer.SustainerUpdate))] + static class SustainerTolerantEndCheck + { + // Runs after SustainerUpdateMapTime's prefix so the re-stamp uses the + // same clock the vanilla staleness check is about to read + [HarmonyPriority(Priority.Low)] + static void Prefix(Sustainer __instance) + { + if (Multiplayer.Client == null || __instance.Ended) return; + + var maintenance = __instance.info.Maintenance; + int staleAfter; + if (maintenance == MaintenanceType.PerTick) + staleAfter = 1; + else if (maintenance == MaintenanceType.PerTickRare) + staleAfter = 250; + else + return; + + var ambientTicks = Find.TickManager.TicksGame; + if (ambientTicks <= __instance.lastMaintainTick + staleAfter) + return; // not stale, vanilla keeps it + + if (!SustainerRealtimeMaintenance.lastMaintainRealTime.TryGetValue(__instance, out var maintainedAt) || + Time.realtimeSinceStartup - maintainedAt > SustainerRealtimeMaintenance.GraceSeconds) + return; // truly abandoned, let vanilla end it + + SustainerRealtimeMaintenance.NoteRescue(__instance, ambientTicks, __instance.lastMaintainTick); + __instance.lastMaintainTick = ambientTicks; + } + } +} diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index 5cf6aea60..fb5d1c312 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -198,7 +198,7 @@ static void Postfix() // and every sim path installs its own context on entry (the world // tick installs its own count), so this value is never a simulation // input. - private static void InstallViewerTimeContext() + internal static void InstallViewerTimeContext() { // Null checks cover the join/load window where Multiplayer.game // (and with it the async comps) lags Multiplayer.Client @@ -371,6 +371,23 @@ public static void Reset() public static ITickable TickableById(int tickableId) => AllTickables.FirstOrDefault(t => t.TickableId == tickableId); } + // Root_Play.Update runs RealTime.Update, PortraitsCache and UIRootUpdate + // BEFORE TickManagerUpdate, where the frame's viewer context is normally + // installed - so those consumers read the PREVIOUS frame's residual + // ambient (e.g. unpausedTime advances by a stale TickRateMultiplier, + // making pausable-animated materials move in bursts). Install the viewer + // context at the top of the frame too; the sim still installs its own + // contexts on entry, so this is render/UI-only like the post-tick install. + [HarmonyPatch(typeof(Root_Play), nameof(Root_Play.Update))] + static class FrameStartViewerContext + { + static void Prefix() + { + if (Multiplayer.Client == null) return; + TickPatch.InstallViewerTimeContext(); + } + } + public class SimulatingData { public int? target; From f8faaef257b5a7c663ad572da3b840c44789dd96 Mon Sep 17 00:00:00 2001 From: Cormac O Connor Date: Sat, 1 Aug 2026 12:03:23 +0200 Subject: [PATCH 26/27] Fix faction data residue corrupting UI reads after in-bracket exceptions FactionContext is strict always-push/always-pop, so a push whose pop is skipped by an exception permanently shifts every later pop onto the wrong entry: the restored OfPlayer and installed faction data then depend on tick history instead of scope pairing, and UI reads alternate between factions' data (resource readout and low-food/medicine alerts reading a foreign resourceCounter, zones/areas/designations rendering from the wrong copies; a paused map keeps the wrong data until its next tick, so the artifacts clear on unpause). - MultiplayerMapComp.DoTick pops in a per-iteration finally instead of stranding a faction's managers when a lister/resource tick throws - The async time brackets record the faction stack depth on entry and unwind stranded entries back to it before their own pop, with a rate-limited warning naming the site (covers the command paths too: both ExecuteCmds push after PreContext and pop in finally) - FactionResidueGuard (was FactionResidueDiag) heals instead of only logging: after the tick/command loop, before render and UI read anything, re-point OfPlayer, world data and every map's data at the local player's faction and name what was wrong. Render/UI-only - every sim path installs its own context on entry - The sustainer rescue diagnostic uses MpLog.Log: MpLog.Debug is compiled out of Release builds, so it could never fire in the field --- Source/Client/AsyncTime/AsyncTimeComp.cs | 12 +++ Source/Client/AsyncTime/AsyncWorldTimeComp.cs | 13 +++ Source/Client/Comp/Map/MultiplayerMapComp.cs | 17 +++- Source/Client/Factions/FactionExtensions.cs | 37 ++++++++ Source/Client/Patches/FactionResidueDiag.cs | 53 ------------ Source/Client/Patches/FactionResidueGuard.cs | 86 +++++++++++++++++++ .../Client/Patches/SustainerClockPatches.cs | 5 +- Source/Client/Patches/TickPatch.cs | 1 + 8 files changed, 167 insertions(+), 57 deletions(-) delete mode 100644 Source/Client/Patches/FactionResidueDiag.cs create mode 100644 Source/Client/Patches/FactionResidueGuard.cs diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index b141d41e9..0346502e3 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -205,6 +205,11 @@ public void UpdateManagers() // the outer snapshot, leaking the inner clock for the rest of the frame. private readonly Stack prevTimes = new(); + // Expected faction stack depth per nested bracket, recorded at + // PreContext exit - PostContext unwinds any entries stranded above it + // by an exception before doing its own pop (see UnwindFactionStack) + private readonly Stack prevFactionDepths = new(); + public void PreContext() { map.PushFaction( @@ -213,6 +218,8 @@ public void PreContext() : Multiplayer.WorldComp.spectatorFaction, force: true); + prevFactionDepths.Push(FactionContext.stack.Count); + prevTimes.Push(TimeSnapshot.GetAndSetFromMap(map)); Rand.PushState(); @@ -232,6 +239,11 @@ public void PostContext() randState = Rand.StateCompressed; Rand.PopState(); + if (prevFactionDepths.Count == 0) + Log.Error($"MP: unbalanced faction depth on {this}"); + else + FactionExtensions.UnwindFactionStack(map, prevFactionDepths.Pop(), $"map {map.uniqueID} bracket"); + map.PopFaction(); } diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 08e777145..23c97ca7c 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -219,6 +219,9 @@ public void Tick() // commands can nest a world context inside the world tick. private readonly Stack prevTimes = new(); + // Expected faction stack depth per nested bracket - see AsyncTimeComp + private readonly Stack prevFactionDepths = new(); + public void PreContext() { prevTimes.Push(TimeSnapshot.GetAndSetFromWorld()); @@ -231,10 +234,20 @@ public void PreContext() foreach (var map in Find.Maps) map.MpComp().SetFaction(Multiplayer.WorldComp.spectatorFaction); } + + // Recorded unconditionally: the command path pushes the command's + // faction after PreContext even outside multifaction, and a throw in + // the handler must not strand it + prevFactionDepths.Push(FactionContext.stack.Count); } public void PostContext() { + if (prevFactionDepths.Count == 0) + Log.Error("MP: unbalanced faction depth on the world clock"); + else + FactionExtensions.UnwindFactionStack(null, prevFactionDepths.Pop(), "world bracket"); + if (Multiplayer.GameComp.multifaction) { // Restore must be unconditional: PreContext swapped EVERY map onto diff --git a/Source/Client/Comp/Map/MultiplayerMapComp.cs b/Source/Client/Comp/Map/MultiplayerMapComp.cs index cba4bc4ff..c04d34199 100644 --- a/Source/Client/Comp/Map/MultiplayerMapComp.cs +++ b/Source/Client/Comp/Map/MultiplayerMapComp.cs @@ -111,9 +111,20 @@ public void DoTick() foreach (var data in factionData) { map.PushFaction(data.Key); - data.Value.listerHaulables.ListerHaulablesTick(); - data.Value.resourceCounter.ResourceCounterTick(); - map.PopFaction(); + try + { + data.Value.listerHaulables.ListerHaulablesTick(); + data.Value.resourceCounter.ResourceCounterTick(); + } + finally + { + // A throw here still aborts the remaining factions + // (caught and surfaced at the tickable level), but it + // must not strand this faction's data as the map's + // installed managers - that shifts the faction context + // stack and corrupts UI reads for the whole session + map.PopFaction(); + } } } finally diff --git a/Source/Client/Factions/FactionExtensions.cs b/Source/Client/Factions/FactionExtensions.cs index 2c4a9a36f..2fd09afae 100644 --- a/Source/Client/Factions/FactionExtensions.cs +++ b/Source/Client/Factions/FactionExtensions.cs @@ -1,4 +1,5 @@ using System.Linq; +using Multiplayer.Client.Util; using RimWorld; using RimWorld.Planet; using Verse; @@ -7,6 +8,42 @@ namespace Multiplayer.Client.Factions; public static class FactionExtensions { + private static float lastUnwindReport; + private static int unwoundEntries; + + // FactionContext is strict always-push/always-pop, so a push whose pop was + // skipped (an exception inside a faction bracket) permanently shifts every + // later pop onto the wrong entry: the restored OfPlayer and installed + // faction data then depend on tick history instead of scope pairing, and + // UI/render reads alternate between factions' data. Brackets record the + // stack depth on entry and unwind to it here before their own pop - + // popping stranded entries in LIFO order lands on the oldest one's saved + // value, which is what the balanced path would have restored. + public static void UnwindFactionStack(Map map, int expectedDepth, string site) + { + if (FactionContext.stack.Count < expectedDepth) + { + // Someone popped past our bracket - can't repair from here, but + // never silent: the bracket's own pop below is now misaligned + MpLog.Error($"Faction stack underflow at {site}: depth {FactionContext.stack.Count} < expected {expectedDepth}"); + return; + } + + if (FactionContext.stack.Count == expectedDepth) + return; + + unwoundEntries += FactionContext.stack.Count - expectedDepth; + while (FactionContext.stack.Count > expectedDepth) + map.PopFaction(); + + var now = UnityEngine.Time.realtimeSinceStartup; + if (now - lastUnwindReport < 30f) return; + lastUnwindReport = now; + MpLog.Warn($"Faction stack unwound: {unwoundEntries} stranded entr(y/ies), last at {site} - " + + "an exception inside a faction bracket skipped its pop (see errors above for the thrower)"); + unwoundEntries = 0; + } + // Sets the current Faction.OfPlayer // Applies faction's world components // Applies faction's map components if map not null diff --git a/Source/Client/Patches/FactionResidueDiag.cs b/Source/Client/Patches/FactionResidueDiag.cs deleted file mode 100644 index 6054f3dc2..000000000 --- a/Source/Client/Patches/FactionResidueDiag.cs +++ /dev/null @@ -1,53 +0,0 @@ -using HarmonyLib; -using Multiplayer.Client.Util; -using RimWorld; -using UnityEngine; -using Verse; - -namespace Multiplayer.Client.Patches -{ - // Diagnostic only. The world tick swaps every map onto the spectator - // faction's data and restores afterwards; if any path leaves a map on the - // wrong faction's managers, UI reads (resource readout, zones, areas, - // low-food alert nutrition) alternate between factions' data on frames - // that ran a world tick - a flicker/re-ping shape that only exists at - // mixed speeds. The restore is hardened now, but this check catches ANY - // residue source red-handed at UI time, named and counted. Log-only. - [HarmonyPatch(typeof(UIRoot_Play), nameof(UIRoot_Play.UIRootUpdate))] - static class FactionResidueDiag - { - private static int residueFrames; - private static float lastReport; - private const float ReportIntervalSeconds = 60f; - - static void Postfix() - { - if (Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) return; - - var map = Find.CurrentMap; - var realFaction = Multiplayer.RealPlayerFaction; - if (map == null || realFaction == null) return; - - var comp = map.MpComp(); - if (comp == null || !comp.factionData.TryGetValue(realFaction.loadID, out var ownData)) return; - - if (ReferenceEquals(map.resourceCounter, ownData.resourceCounter)) return; - - residueFrames++; - var now = Time.realtimeSinceStartup; - if (now - lastReport < ReportIntervalSeconds) return; - lastReport = now; - - var installedFactionId = -1; - foreach (var kv in comp.factionData) - if (ReferenceEquals(map.resourceCounter, kv.Value.resourceCounter)) - installedFactionId = kv.Key; - - MpLog.Warn( - $"Faction residue: map {map.uniqueID} has faction {installedFactionId}'s data installed at UI time " + - $"(local faction {realFaction.loadID}, OfPlayer {Faction.OfPlayer?.loadID}); " + - $"{residueFrames} affected frames since last report"); - residueFrames = 0; - } - } -} diff --git a/Source/Client/Patches/FactionResidueGuard.cs b/Source/Client/Patches/FactionResidueGuard.cs new file mode 100644 index 000000000..4c96e246d --- /dev/null +++ b/Source/Client/Patches/FactionResidueGuard.cs @@ -0,0 +1,86 @@ +using Multiplayer.Client.Util; +using RimWorld; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.Patches +{ + // Heal-and-tell for faction data residue. Sim brackets (map tick, world + // tick, command execution) swap the live world/map managers between + // factions' copies; any strand (an exception that skipped a pop, a + // restore that no-op'd) leaves foreign faction data installed, and + // between frames NOTHING re-installs the local player's - alerts read a + // foreign resourceCounter (low-food/medicine flips and re-rings), zones/ + // areas/designations render from the wrong copies, and a paused map + // stays wrong until its next tick ("clears on unpause"). The bracket + // depth guards close the strand sources we know; this closes the class: + // after the tick/command loop, before render and UI read anything, + // re-point every map and the world at the local faction's data and name + // what was wrong. Render/UI-only by construction - every sim path + // installs its own context on entry, so healed state is never a + // simulation input. + public static class FactionResidueGuard + { + private static int healedFrames; + private static float lastReport; + private const float ReportIntervalSeconds = 60f; + + public static void HealAfterTicks() + { + if (Multiplayer.Client == null || Multiplayer.game == null || Multiplayer.reloading) return; + if (!Multiplayer.GameComp.multifaction) return; + if (Current.ProgramState != ProgramState.Playing) return; + + var real = Multiplayer.RealPlayerFaction; + if (real == null) return; + + string healed = null; + + // Outside any open bracket the ambient player faction must be the + // local player's; a non-empty stack after the tick loop means + // something stranded - the depth guards report that separately + if (FactionContext.stack.Count == 0 && Faction.OfPlayer != real) + { + healed = $"OfPlayer was {Faction.OfPlayer?.loadID.ToString() ?? "null"}"; + FactionContext.Set(real); + } + + var worldComp = Multiplayer.WorldComp; + if (worldComp.factionData.TryGetValue(real.loadID, out var worldData) && + !ReferenceEquals(Find.ResearchManager, worldData.researchManager)) + { + healed = "world data was foreign"; + worldComp.SetFaction(real); + } + + // Every map, not just the viewed one: alerts and the colonist bar + // read all maps' managers each frame + foreach (var map in Find.Maps) + { + var comp = map.MpComp(); + if (comp == null || !comp.factionData.TryGetValue(real.loadID, out var ownData)) continue; + if (ReferenceEquals(map.resourceCounter, ownData.resourceCounter)) continue; + + var installedFactionId = -1; + foreach (var kv in comp.factionData) + if (ReferenceEquals(map.resourceCounter, kv.Value.resourceCounter)) + installedFactionId = kv.Key; + + comp.SetFaction(real); + healed = $"map {map.uniqueID} had faction {installedFactionId}'s data"; + } + + if (healed == null) return; + + healedFrames++; + var now = Time.realtimeSinceStartup; + if (now - lastReport < ReportIntervalSeconds) return; + lastReport = now; + + MpLog.Warn( + $"Faction residue healed: {healed} -> local {real.loadID} (OfPlayer {Faction.OfPlayer?.loadID}); " + + $"{healedFrames} healed frames since last report"); + healedFrames = 0; + } + } +} diff --git a/Source/Client/Patches/SustainerClockPatches.cs b/Source/Client/Patches/SustainerClockPatches.cs index a90dc68f1..b24a4c1c2 100644 --- a/Source/Client/Patches/SustainerClockPatches.cs +++ b/Source/Client/Patches/SustainerClockPatches.cs @@ -39,7 +39,10 @@ public static void NoteRescue(Sustainer sustainer, int ambientTicks, int staleSt var now = Time.realtimeSinceStartup; if (now - lastRescueReport < 60f) return; lastRescueReport = now; - MpLog.Debug($"Sustainer cross-clock rescue: {sustainer.def} ambient={ambientTicks} stamp={staleStamp} ({rescueCount} rescues since last report)"); + // MpLog.Log, not Debug: Debug is [Conditional("DEBUG")] and + // compiled out of the Release zips the field actually runs - + // this line is field evidence, it must exist there + MpLog.Log($"Sustainer cross-clock rescue: {sustainer.def} ambient={ambientTicks} stamp={staleStamp} ({rescueCount} rescues since last report)"); rescueCount = 0; } } diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index fb5d1c312..6832a4c0c 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -173,6 +173,7 @@ static void Postfix() if (Multiplayer.Client == null) return; InstallViewerTimeContext(); + Patches.FactionResidueGuard.HealAfterTicks(); // AsyncTime() can be null while a join or load is mid-flight // (Multiplayer.game lags Client in that window) From e2909ea4a1164f5877dac1e288ed911278d8ccce Mon Sep 17 00:00:00 2001 From: Alexey Nikitin Date: Sun, 9 Aug 2026 02:10:11 -0500 Subject: [PATCH 27/27] Fix missing victory letter after caravan battles under multifaction. When a caravan won an ambush, or won a fight the players started by attacking, no player got the victory letter. The win check ran with no player set as battle owner, so it ignored that caravan's faction. Run the win check while set as that caravan's owning player. --- .../Client/Factions/WorldTickOwnerContext.cs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/Source/Client/Factions/WorldTickOwnerContext.cs b/Source/Client/Factions/WorldTickOwnerContext.cs index 523cc727a..a5d631fef 100644 --- a/Source/Client/Factions/WorldTickOwnerContext.cs +++ b/Source/Client/Factions/WorldTickOwnerContext.cs @@ -7,7 +7,7 @@ namespace Multiplayer.Client.Factions; // Multifaction: WorldObjectComp ticking runs under the spectator world tick, -// so core-side Faction.OfPlayer gates in those paths match nobody. Two sites +// so core-side Faction.OfPlayer gates in those paths match nobody. Sites // with the same root cause: // - TimedForcedExit.ForceReform gathers `x.Faction == Faction.OfPlayer` pawns // and reforms via ExitMapAndCreateCaravan(.., Faction.OfPlayer): under the @@ -16,7 +16,11 @@ namespace Multiplayer.Client.Factions; // - DefeatAllEnemiesQuestComp.CompTickInterval checks // AnyHostileActiveThreatToPlayer and delivers rewards to AnyPlayerHomeMap: // completion misdetects and the letter/rewards misfire for everyone. -// Both get the deterministic owner of the site map pushed around the body. +// - CaravansBattlefield.CheckWonBattle uses AnyHostileActiveThreatToPlayer and +// FreeColonists.RandomElement for the victory tale/letter: under Spectator +// FreeColonists is empty on every client (ParentFaction is enemy/null, so the +// existing WorldObjectMethodPatches push cannot install a player owner). +// Each gets the deterministic owner of the site map pushed around the body. static class WorldTickOwnerContext { // Deterministic "whose map is this" for maps without a player parent @@ -83,6 +87,26 @@ static void Finalizer(bool __state) } } +// CaravansBattlefield.CheckWonBattle is private; ParentFaction is typically +// the ambushers (or null), so WorldObjectMethodPatches cannot push a player +// owner. Push the deterministic map owner so FreeColonists / victory letter +// see the caravan faction instead of Spectator. +[HarmonyPatch(typeof(CaravansBattlefield), "CheckWonBattle")] +static class CaravansBattlefieldWonBattleOwnerContext +{ + static void Prefix(CaravansBattlefield __instance, ref bool __state) + { + if (__instance.HasMap) + __state = WorldTickOwnerContext.PushOwnerOf(__instance.Map); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + // When a mission's stamped destination map // is lost mid-flight, SendAway's private picker scans settlements for // Faction.OfPlayer - under the spectator world tick that matches nobody and