diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 32233940b..6d9d068a0 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using HarmonyLib; +using Multiplayer.Client.AsyncTime; using Multiplayer.Client.Comp; using Multiplayer.Client.Factions; using Multiplayer.Client.Patches; @@ -64,18 +65,9 @@ public TimeSpeed DesiredTimeSpeed public int TickableId => map.uniqueID; - public int GameStartAbsTick - { - get - { - if (gameStartAbsTickMap == 0) - { - gameStartAbsTickMap = Find.TickManager?.gameStartAbsTick ?? 0; - } - - return gameStartAbsTickMap; - } - } + // No side effect: latching the global value on first read took whichever clock was set at that moment (another + // map's under async time). Saves without the field get the world's value when loading. + public int GameStartAbsTick => gameStartAbsTickMap != 0 ? gameStartAbsTickMap : Find.TickManager?.gameStartAbsTick ?? 0; public Map map; public int mapTicks; @@ -93,9 +85,11 @@ public int GameStartAbsTick // Shared random state for ticking and commands public ulong randState; + // Always empty: map commands are queued in AsyncWorldTimeComp.cmds to keep a single global order public Queue cmds = new(); public int CurrentPlayerCount { get; private set; } + public void ResetPlayerCount() => CurrentPlayerCount = 0; public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public AsyncTimeComp(Map map, int gameStartAbsTick = 0) @@ -111,13 +105,26 @@ public AsyncTimeComp(Map map, int gameStartAbsTick = 0) public void Tick() { tickingMap = map; + int randDepth = Rand.stateStack.Count; PreContext(); + int factionDepth = FactionContext.stack.Count; //SimpleProfiler.Start(); try { - map.MapPreTick(); + // Like vanilla's DoSingleTick, an exception in one subsystem shouldn't skip the rest of the tick + // (in particular the mapTicks increment) + try + { + map.MapPreTick(); + } + catch (Exception e) + { + Log.Error($"Exception in MapPreTick of {map}: {e}"); + SimulationFailures.Record("MapPreTick", e); + } + mapTicks++; Find.TickManager.ticksGameInt = mapTicks; @@ -131,12 +138,53 @@ public void Tick() tickListRare.Tick(); tickListLong.Tick(); - TickMapSessions(); + try + { + TickMapSessions(); + } + catch (Exception e) + { + Log.Error($"Exception ticking sessions of {map}: {e}"); + SimulationFailures.Record("map sessions", e); + } - Find.Storyteller.StorytellerTick(); - Find.StoryWatcher.StoryWatcherTick(); + try + { + Find.Storyteller.StorytellerTick(); + } + catch (Exception e) + { + Log.Error(e.ToString()); + SimulationFailures.Record("storyteller", e); + } - QuestManagerTickAsyncTime(); + // The world tick (vanilla DoSingleTick) already ticks the story watcher once per game tick, ticking it + // here too made adaptation and pop adaptation grow once more per map. Only resolve the colonists downed + // during this map tick, which vanilla does later in the same tick. + StoryWatcherMapTick.downedOnly = true; + try + { + Find.StoryWatcher.StoryWatcherTick(); + } + catch (Exception e) + { + Log.Error(e.ToString()); + SimulationFailures.Record("story watcher", e); + } + finally + { + StoryWatcherMapTick.downedOnly = false; + } + + try + { + QuestManagerTickAsyncTime(); + } + catch (Exception e) + { + Log.Error(e.ToString()); + SimulationFailures.Record("async quests", e); + } map.MapPostTick(); Find.TickManager.ticksThisFrame = 1; @@ -148,6 +196,8 @@ public void Tick() } finally { + RestoreRandStack(randDepth + 1, this); + FactionContext.RestoreStack(factionDepth, this); PostContext(); Multiplayer.game.sync.TryAddMapRandomState(map.uniqueID, randState); eventCount++; @@ -174,7 +224,18 @@ public void UpdateManagers() map.glowGrid.GlowGridUpdate_First(); } - private TimeSnapshot? prevTime; + private readonly struct ContextFrame(TimeSnapshot? prevTime, int outerRandDepth, bool continuesOuter) + { + public readonly TimeSnapshot? prevTime = prevTime; + public readonly int outerRandDepth = outerRandDepth; + public readonly bool continuesOuter = continuesOuter; + } + + // Contexts nest (e.g. a quest's time getter enters its map's context while that map ticks the quest), so each + // PreContext keeps its own previous time. A single field made the outer PostContext restore the map's time + // instead of the world's, and the world clock then jumped to the map's. + private readonly Stack contextFrames = new(); + private int contextRandDepth = -1; public void PreContext() { @@ -184,10 +245,17 @@ public void PreContext() : Multiplayer.WorldComp.spectatorFaction, force: true); - prevTime = TimeSnapshot.GetAndSetFromMap(map); + // Directly inside this map's own context: go on with its live random state instead of restarting from + // the state saved when the outer context began + bool continuesOuter = contextFrames.Count > 0 && Rand.stateStack.Count == contextRandDepth; + if (continuesOuter) + randState = Rand.StateCompressed; + + contextFrames.Push(new ContextFrame(TimeSnapshot.GetAndSetFromMap(map), contextRandDepth, continuesOuter)); Rand.PushState(); Rand.StateCompressed = randState; + contextRandDepth = Rand.stateStack.Count; // Reset the effects of SkyManager.SkyManagerUpdate map.skyManager.curSkyGlowInt = map.skyManager.CurrentSkyTarget().glow; @@ -195,22 +263,55 @@ public void PreContext() public void PostContext() { - prevTime?.Set(); + if (contextFrames.Count == 0) + { + Log.ErrorOnce($"Multiplayer: PostContext without PreContext on {map}", 0x2C7B90 ^ map.uniqueID); + return; + } + + var frame = contextFrames.Pop(); + + frame.prevTime?.Set(); randState = Rand.StateCompressed; Rand.PopState(); + if (frame.continuesOuter) + Rand.StateCompressed = randState; + contextRandDepth = frame.outerRandDepth; map.PopFaction(); } + // A Rand push left unpopped by an exception would otherwise be saved as the context's state, orphan the + // context's own entry and keep the stack too deep (which disables desync traces) for the rest of the session + public static void RestoreRandStack(int depth, object context) + { + int count = Rand.stateStack.Count; + if (count == depth) return; + + // Too shallow (an extra pop) can't be repaired, only reported + Log.ErrorOnce($"Multiplayer: unbalanced Rand push/pop in {context}: stack depth {count}, expected {depth}", 0x5A3D17 ^ depth); + + while (Rand.stateStack.Count > depth) + Rand.PopState(); + } + public void ExposeData() { Scribe_Values.Look(ref mapTicks, "mapTicks"); Scribe_Values.Look(ref timeSpeedInt, "timeSpeed"); Scribe_Values.Look(ref gameStartAbsTickMap, "gameStartAbsTickMap"); + if (Scribe.mode == LoadSaveMode.LoadingVars && gameStartAbsTickMap == 0) + gameStartAbsTickMap = Find.TickManager.gameStartAbsTick; Scribe_Custom.LookULong(ref randState, "randState", 1); + + // Affects the simulation through VTR. Saved so that it survives join point reloads and joiners start + // with the same counts as everyone else. + var playerCount = CurrentPlayerCount; + Scribe_Values.Look(ref playerCount, "playerCount"); + CurrentPlayerCount = playerCount; } public int IncreasePlayerCount() => CurrentPlayerCount += 1; @@ -220,11 +321,9 @@ public void ExposeData() public void FinalizeInit() { - cmds = new Queue( - Multiplayer.session.dataSnapshot?.MapCmds.GetValueSafe(map.uniqueID) ?? new List() - ); - - Log.Message($"Init map with cmds {cmds.Count}"); + // Map commands live in the world's queue (see MultiplayerSession.ScheduleCommand) which is built only in + // Loader.PostLoad. Rebuilding a queue here (possibly on the loading thread, or for a map generated during + // play) used to drop or duplicate commands. } public static bool keepTheMap; @@ -246,8 +345,10 @@ public void ExecuteCmd(ScheduledCommand cmd) TickPatch.currentExecutingCmdIssuedBySelf = cmd.IsIssuedBySelf() && !TickPatch.Simulating; TickPatch.currentExecutingCmdType = cmdType; + int randDepth = Rand.stateStack.Count; PreContext(); map.PushFaction(cmd.GetFaction(), force: true); + int factionDepth = FactionContext.stack.Count; context.map = map; @@ -291,6 +392,7 @@ public void ExecuteCmd(ScheduledCommand cmd) catch (Exception e) { MpLog.Error($"Map cmd exception ({cmdType}): {e}"); + SimulationFailures.Record($"map command {cmdType}", e); } finally { @@ -304,8 +406,19 @@ public void ExecuteCmd(ScheduledCommand cmd) Find.Selector.selected = prevSelected; prevSelected = null; - Find.MainButtonsRoot.tabs.Notify_SelectedObjectDespawned(); + // Local UI, depends on the local selection + try + { + using (LocalOnlyScope.Enter()) + Find.MainButtonsRoot.tabs.Notify_SelectedObjectDespawned(); + } + catch (Exception e) + { + Log.Error($"Exception updating main tabs after a command: {e}"); + } + RestoreRandStack(randDepth + 1, this); + FactionContext.RestoreStack(factionDepth, this); map.PopFaction(); PostContext(); @@ -411,6 +524,10 @@ void RestoreState() var mode = SyncSerialization.ReadSync(data); var designator = SyncSerialization.ReadSync(data); + // A sync worker returns null when its designator's target is gone (e.g. Designator_MoveGravship once the + // landing started or was aborted, which happens in commands, so on every client at the same point) + if (designator == null) return; + try { if (!SetState(designator)) return; diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index 10a8bb051..18919928e 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Reflection.Emit; using HarmonyLib; using Multiplayer.Client.Factions; using RimWorld; @@ -50,26 +52,129 @@ static IEnumerable TargetMethods() static bool Prefix() => Multiplayer.Client == null || !MapUpdateMarker.updating; } + // Season notifications run in the synced world tick and write saved state (lastSeason, letters), so they can't + // depend on the local player's faction. In multifaction every player faction gets its own pass under its context + // (which also routes its letters to its players) with its own saved lastSeason. Each pass uses the clock of the + // faction's home map with the minimum time zone, the map vanilla takes the season from. [HarmonyPatch(typeof(DateNotifier), nameof(DateNotifier.DateNotifierTick))] static class DateNotifierPatch { - static void Prefix(DateNotifier __instance, ref int? __state) + static bool running; + + // The faction of the current multifaction pass + public static Faction factionPass; + + static bool Prefix(DateNotifier __instance) + { + if (Multiplayer.Client == null || running) return true; + + if (!Multiplayer.GameComp.multifaction) + { + // Every player plays the host's faction, the world tick runs under it + TickWithHomeTime(__instance, requireHome: false); + return false; + } + + var worldComp = Multiplayer.WorldComp; + foreach (var (factionId, _) in worldComp.factionData) + { + var faction = Find.FactionManager.GetById(factionId); + if (faction is not { IsPlayer: true } || faction == worldComp.spectatorFaction) + continue; + + var prevLastSeason = __instance.lastSeason; + __instance.lastSeason = worldComp.factionLastSeasons.TryGetValue(factionId, out var s) ? s : Season.Undefined; + ((Map)null).PushFaction(faction, force: true); + factionPass = faction; + + try + { + // A faction without a home yet (e.g. just created) would take the season at 0,0 and could get a + // notification when it settles + TickWithHomeTime(__instance, requireHome: true); + } + catch (Exception e) + { + Log.Error($"Exception in DateNotifierTick for {faction}: {e}"); + } + finally + { + factionPass = null; + FactionExtensions.PopFaction(); + worldComp.factionLastSeasons[factionId] = __instance.lastSeason; + __instance.lastSeason = prevLastSeason; + } + } + + return false; + } + + static IEnumerable Transpiler(IEnumerable insts) { - if (Multiplayer.Client == null && Multiplayer.RealPlayerFaction != null) return; + var message = AccessTools.Method(typeof(Messages), nameof(Messages.Message), + [typeof(string), typeof(MessageTypeDef), typeof(bool)]); + var replacement = AccessTools.Method(typeof(DateNotifierPatch), nameof(SeasonMessage)); + var replaced = false; - Map map = __instance.FindPlayerHomeWithMinTimezone(); - if (map == null) return; + foreach (var ci in insts) + { + if (ci.Calls(message)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + replaced = true; + } + + yield return ci; + } + + if (!replaced) + Log.Error("Multiplayer: Messages.Message not found in DateNotifier.DateNotifierTick"); + } + + // A multifaction pass shows its season message only to the faction's players. Not historical: such messages + // take no synced message id (see NextMessageIdPatch) and aren't archived, so showing it on some clients only is + // safe. + static void SeasonMessage(string text, MessageTypeDef def, bool historical) + { + if (factionPass == null) + Messages.Message(text, def, historical); + else if (factionPass == Multiplayer.RealPlayerFaction) + Messages.Message(text, def, historical: false); + } - __state = Find.TickManager.TicksGame; - FactionContext.Push(Multiplayer.RealPlayerFaction); - Find.TickManager.DebugSetTicksGame(map.AsyncTime().mapTicks); + static void TickWithHomeTime(DateNotifier notifier, bool requireHome) + { + Map map = notifier.FindPlayerHomeWithMinTimezone(); + if (map == null && requireHome) return; + + // The map's whole clock: a faction created later in multifaction has its own game start tick, which + // TicksAbs (the season) depends on + var prevTime = TimeSnapshot.GetAndSetFromMap(map); + + running = true; + try + { + notifier.DateNotifierTick(); + } + finally + { + running = false; + prevTime?.Set(); + } } + } - static void Finalizer(int? __state) + // A faction's season pass looks only at its own homes. Gravship landing sites are homes of every faction in vanilla + // (and a map with the faction's gravship counts too), the other players' maps among them are left out. + [HarmonyPatch(typeof(Map), nameof(Map.IsPlayerHome), MethodType.Getter)] + static class DateNotifierOwnHomes + { + static void Postfix(Map __instance, ref bool __result) { - if (!__state.HasValue) return; - Find.TickManager.DebugSetTicksGame(__state.Value); - FactionContext.Pop(); + if (__result && DateNotifierPatch.factionPass is { } faction && + __instance.ParentFaction is { IsPlayer: true } owner && owner != faction) + __result = false; } } @@ -134,7 +239,8 @@ static void Postfix(ref float __result) if (WorldRendererUtility.WorldSelected) return; var map = PreDrawCalcMarker.calculating.Map ?? Find.CurrentMap; - var asyncTime = map.AsyncTime(); + var asyncTime = map?.AsyncTime(); + if (asyncTime == null) return; var timeSpeed = Multiplayer.IsReplay ? TickPatch.replayTimeSpeed : asyncTime.DesiredTimeSpeed; __result = TickPatch.Simulating ? 6 : asyncTime.ActualRateMultiplier(timeSpeed); @@ -147,10 +253,31 @@ static class TickManagerPausedPatch static void Postfix(ref bool __result) { if (Multiplayer.Client == null) return; + + // The simulation must not see the local view (the viewed map, the world view, vanilla's ForcePaused with + // e.g. TilePicker.Active): Pawn_JobTracker.StartJob counts a job given while paused only if it's the + // tracker's second in the same step, so the value changed jobsGivenThisTick per client. + // A world or map tick: not paused, as vanilla only ticks unpaused. Ticks can continue after a rate change + // in the same game tick (TickPatch.TickLockstep). + if (AsyncWorldTimeComp.tickingWorld || AsyncTimeComp.tickingMap != null) + { + __result = false; + return; + } + + // Commands, the constant ticker and sim long events: whether their context's tickable is paused + if (Multiplayer.ExecutingCmds || ConstantTicker.ticking || Patches.SimLongEventContext.InSimulation) + { + var tickable = (ITickable)Multiplayer.MapContext?.AsyncTime() ?? Multiplayer.AsyncWorldTime; + __result = tickable.ActualRateMultiplier(tickable.DesiredTimeSpeed) == 0; + return; + } + if (WorldRendererUtility.WorldSelected) return; if (FactionCreator.generatingMap) return; - var asyncTime = Find.CurrentMap.AsyncTime(); + var asyncTime = Find.CurrentMap?.AsyncTime(); + if (asyncTime == null) return; var timeSpeed = Multiplayer.IsReplay ? TickPatch.replayTimeSpeed : asyncTime.DesiredTimeSpeed; __result = asyncTime.ActualRateMultiplier(timeSpeed) == 0; @@ -202,6 +329,12 @@ private static void PauseOnLetter(TickManager manager) return; } + SimPause(); + } + + // Pauses the context's tickable (everything without async time) from the simulation and resets the time votes + public static void SimPause() + { if (Multiplayer.GameComp.asyncTime) { var tickable = (ITickable)Multiplayer.MapContext.AsyncTime() ?? Multiplayer.AsyncWorldTime; @@ -216,4 +349,29 @@ private static void PauseOnLetter(TickManager manager) } } } + + // Vanilla's Dialog_NodeTree force-pauses, but in MP WindowsForcePause is always false (windows differ between + // clients). The caravan meeting and demand incidents open one when they fire on a caravan, so pause the way a + // letter does, keyed on the incident's result, which every client shares (#512). + [HarmonyPatch] + static class PauseOnCaravanDialogIncident + { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(IncidentWorker_CaravanMeeting), nameof(IncidentWorker_CaravanMeeting.TryExecuteWorker)); + yield return AccessTools.Method(typeof(IncidentWorker_CaravanDemand), nameof(IncidentWorker_CaravanDemand.TryExecuteWorker)); + } + + static void Postfix(bool __result, IncidentParms parms) + { + if (Multiplayer.Client == null || Multiplayer.InInterface || !__result || parms.target is not Caravan) + return; + + // The host chose to never pause automatically + if (Multiplayer.GameComp.pauseOnLetter == Common.PauseOnLetter.Never) + return; + + ReceiveLetterPause.SimPause(); + } + } } diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 8184163ab..35122f763 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -50,15 +50,20 @@ 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; } + // Without async time: the speed last set for the whole game (SetTimeEverywhere), unlike DesiredTimeSpeed not + // derived from the maps + public TimeSpeed GlobalTimeSpeed => timeSpeedInt; + public Queue Cmds => cmds; public Queue cmds = new(); public int CurrentPlayerCount { get; private set; } + public void ResetPlayerCount() => CurrentPlayerCount = 0; public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public int TickableId => -1; @@ -68,6 +73,9 @@ public TimeSpeed DesiredTimeSpeed public int worldTicks; + // worldTicks from before the world tick in progress: the game tick the maps are at until they tick in it + public int TicksBeforeTick { get; private set; } + public AsyncWorldTimeComp(World world) { this.world = world; @@ -86,6 +94,11 @@ public void ExposeData() Scribe_Values.Look(ref timeSpeedInt, "timeSpeed"); Scribe_Custom.LookULong(ref randState, "randState", 2); + // Affects the simulation through VTR, see AsyncTimeComp.ExposeData + var playerCount = CurrentPlayerCount; + Scribe_Values.Look(ref playerCount, "playerCount"); + CurrentPlayerCount = playerCount; + TimeSpeed timeSpeed = Find.TickManager.CurTimeSpeed; Scribe_Values.Look(ref timeSpeed, "timeSpeed"); if (Scribe.mode == LoadSaveMode.LoadingVars) @@ -102,14 +115,37 @@ public void ExposeData() public void Tick() { + TicksBeforeTick = worldTicks; tickingWorld = true; + int randDepth = Rand.stateStack.Count; PreContext(); + int factionDepth = FactionContext.stack.Count; try { - Find.TickManager.DoSingleTick(); - worldTicks++; - Multiplayer.WorldComp.TickWorldSessions(); + try + { + Find.TickManager.DoSingleTick(); + } + catch (Exception e) + { + Log.Error($"Exception in world DoSingleTick: {e}"); + SimulationFailures.Record("world DoSingleTick", e); + } + + // Matches how worldTicks is initialized after loading (ExposeData), so clients which joined later agree + // even if a tick threw before or after advancing TicksGame + worldTicks = Find.TickManager.TicksGame; + + try + { + Multiplayer.WorldComp.TickWorldSessions(); + } + catch (Exception e) + { + Log.Error($"Exception ticking world sessions: {e}"); + SimulationFailures.Record("world sessions", e); + } if (ModsConfig.BiotechActive) { @@ -134,6 +170,8 @@ public void Tick() } finally { + AsyncTimeComp.RestoreRandStack(randDepth + 1, this); + FactionContext.RestoreStack(factionDepth, this); PostContext(); tickingWorld = false; @@ -177,8 +215,10 @@ public void ExecuteCmd(ScheduledCommand cmd) TickPatch.currentExecutingCmdIssuedBySelf = cmd.IsIssuedBySelf() && !TickPatch.Simulating; TickPatch.currentExecutingCmdType = cmdType; + int randDepth = Rand.stateStack.Count; PreContext(); FactionExtensions.PushFaction(null, cmd.GetFaction()); + int factionDepth = FactionContext.stack.Count; bool prevDevMode = Prefs.data.devMode; var prevGodMode = DebugSettings.godMode; @@ -216,10 +256,12 @@ public void ExecuteCmd(ScheduledCommand cmd) if (cmdType == CommandType.CreateJoinPoint) { - if (Multiplayer.session?.ConnectedToStandaloneServer == true && !TickPatch.currentExecutingCmdIssuedBySelf) - return; + // Every client reloads (hosted and standalone): a reload isn't a no-op, and joiners start from the + // reloaded state. Only the uploader chosen by the server sends the save. Old logs have no payload. + var uploaderId = data.Left >= sizeof(int) ? data.ReadInt32() : cmd.playerId; + var upload = uploaderId == Multiplayer.session.playerId && !TickPatch.Simulating && !Multiplayer.IsReplay; - LongEventHandler.QueueLongEvent(CreateJoinPointAndSendIfHost, "MpCreatingJoinPoint", false, null); + LongEventHandler.QueueLongEvent(() => CreateJoinPoint(upload), "MpCreatingJoinPoint", false, null); } if (cmdType == CommandType.InitPlayerData) @@ -229,6 +271,9 @@ public void ExecuteCmd(ScheduledCommand cmd) Multiplayer.GameComp.playerData[playerId] = new PlayerData { canUseDevMode = canUseDevMode }; } + if (cmdType == CommandType.ResetPlayerCounts) + VTRSync.ResetAllPlayerCounts(); + if (cmdType == CommandType.PlayerCount) { int previousMapId = data.ReadInt32(); @@ -239,7 +284,7 @@ public void ExecuteCmd(ScheduledCommand cmd) if (previousMapId >= 0) prev = Find.Maps.FirstOrDefault(x => x.uniqueID == previousMapId)?.AsyncTime()?.DecreasePlayerCount() ?? -1; else if (previousMapId == VTRSync.WorldMapId) - prev = Multiplayer.AsyncWorldTime.CurrentPlayerCount -= 1; + prev = Multiplayer.AsyncWorldTime.CurrentPlayerCount = Math.Max(0, Multiplayer.AsyncWorldTime.CurrentPlayerCount - 1); var curr = -1; if (newMapId >= 0) @@ -253,6 +298,7 @@ public void ExecuteCmd(ScheduledCommand cmd) catch (Exception e) { Log.Error($"World cmd exception ({cmdType}): {e}"); + SimulationFailures.Record($"world command {cmdType}", e); } finally { @@ -262,6 +308,8 @@ public void ExecuteCmd(ScheduledCommand cmd) MpLog.Debug($"rand calls {DeferredStackTracing.randCalls - randCalls1}"); MpLog.Debug("rand state " + Rand.StateCompressed); + AsyncTimeComp.RestoreRandStack(randDepth + 1, this); + FactionContext.RestoreStack(factionDepth, this); FactionExtensions.PopFaction(); PostContext(); TickPatch.currentExecutingCmdIssuedBySelf = false; @@ -274,28 +322,15 @@ public void ExecuteCmd(ScheduledCommand cmd) } } - private static void CreateJoinPointAndSendIfHost() + private static void CreateJoinPoint(bool upload) { Multiplayer.session.dataSnapshot = SaveLoad.SaveReloadAndCreateSnapshot( Multiplayer.GameComp.multifaction, ReloadOptimizationMode.ForJoinPointSnapshot ); - if (!TickPatch.Simulating && !Multiplayer.IsReplay) - { - if (Multiplayer.session?.ConnectedToStandaloneServer == true) - { - // Standalone: every client uploads world data + individual snapshots - SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true); - SaveLoad.SendStandaloneMapSnapshots(Multiplayer.session.dataSnapshot); - SaveLoad.SendStandaloneWorldSnapshot(Multiplayer.session.dataSnapshot); - } - else if (Multiplayer.LocalServer != null || Multiplayer.arbiterInstance) - { - // Hosted: only host/arbiter uploads world data - SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true); - } - } + if (upload) + SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true); } public void SetTimeEverywhere(TimeSpeed speed) diff --git a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs index e9332b7fb..9dacb7736 100644 --- a/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs +++ b/Source/Client/AsyncTime/MultiplayerAsyncQuest.cs @@ -95,7 +95,7 @@ static void Prefix(Quest __instance, ref AsyncTimeComp __state) __state?.PreContext(); } - static void Postfix(AsyncTimeComp __state) => __state?.PostContext(); + static void Finalizer(AsyncTimeComp __state) => __state?.PostContext(); } [HarmonyPatch(typeof(Quest), nameof(Quest.Accept))] @@ -112,7 +112,7 @@ static void Prefix(Quest __instance, ref AsyncTimeComp __state) __state?.PreContext(); } - static void Postfix(AsyncTimeComp __state) => __state?.PostContext(); + static void Finalizer(AsyncTimeComp __state) => __state?.PostContext(); } [HarmonyPatch(typeof(Quest), nameof(Quest.End))] @@ -172,7 +172,13 @@ public static bool TryRemoveCachedMap(AsyncTimeComp mapAsyncTimeComp) /// Quest to remove /// If quest is found in cache public static bool TryRemoveCachedQuest(Quest quest) - => mapQuestsCache.SingleOrDefault(x => x.Value.Contains(quest)).Value?.Remove(quest) ?? false | worldQuestsCache.Remove(quest); + { + // Removes every entry: a quest cached twice would be ticked twice per tick + bool removed = worldQuestsCache.RemoveAll(q => q == quest) > 0; + foreach (var quests in mapQuestsCache.Values) + removed |= quests.RemoveAll(q => q == quest) > 0; + return removed; + } /// /// Attempts to get the MapAsyncTimeComp cached for that quest @@ -207,6 +213,8 @@ public static AsyncTimeComp CacheQuest(Quest quest) //if it doesn't add it to the world quest list else { + // Accepting caches a quest again after its generation did + TryRemoveCachedQuest(quest); worldQuestsCache.Add(quest); if (MpVersion.IsDebug) Log.Message($"Info: Could not find AsyncTimeMap for Quest: '{quest.name}'"); diff --git a/Source/Client/AsyncTime/SetMapTime.cs b/Source/Client/AsyncTime/SetMapTime.cs index 538f482c7..0b3dc14d8 100644 --- a/Source/Client/AsyncTime/SetMapTime.cs +++ b/Source/Client/AsyncTime/SetMapTime.cs @@ -55,7 +55,7 @@ static void Prefix(Map __instance, ref TimeSnapshot? __state) } [HarmonyPriority(MpPriority.MpLast)] - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch] @@ -72,7 +72,7 @@ static void Prefix(Pawn pawn, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(pawn.MapHeld); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } // TODO 1.3: set time on the new renderer @@ -85,7 +85,7 @@ static void Prefix(PawnRenderer __instance, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn.MapHeld); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch(typeof(PawnTweener), nameof(PawnTweener.PreDrawPosCalculation))] @@ -97,7 +97,7 @@ static void Prefix(PawnTweener __instance, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(__instance.pawn.Map); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch(typeof(DangerWatcher), nameof(DangerWatcher.DangerRating), MethodType.Getter)] @@ -109,7 +109,7 @@ static void Prefix(DangerWatcher __instance, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(__instance.map); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch] @@ -127,7 +127,7 @@ static void Prefix(Sustainer __instance, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(__instance.info.Maker.Map); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch(typeof(Sample), nameof(Sample.Update))] @@ -139,7 +139,7 @@ static void Prefix(Sample __instance, ref TimeSnapshot? __state) __state = TimeSnapshot.GetAndSetFromMap(__instance.Map); } - static void Postfix(TimeSnapshot? __state) => __state?.Set(); + static void Finalizer(TimeSnapshot? __state) => __state?.Set(); } [HarmonyPatch(typeof(TipSignal), MethodType.Constructor, new[] { typeof(Func), typeof(int) })] @@ -228,12 +228,13 @@ public static TimeSnapshot Current() public static TimeSnapshot? GetAndSetFromMap(Map map) { - if (map == null) return null; + // No comp yet while the map is being generated + var mapComp = map?.AsyncTime(); + if (mapComp == null) return null; TimeSnapshot prev = Current(); var tickManager = Find.TickManager; - var mapComp = map.AsyncTime(); tickManager.ticksGameInt = mapComp.mapTicks; tickManager.slower = mapComp.slower; diff --git a/Source/Client/AsyncTime/StorytellerPatches.cs b/Source/Client/AsyncTime/StorytellerPatches.cs index f8068341e..7730d5b97 100644 --- a/Source/Client/AsyncTime/StorytellerPatches.cs +++ b/Source/Client/AsyncTime/StorytellerPatches.cs @@ -28,6 +28,36 @@ static bool Prefix() static void Finalizer() => updating = false; } +// Set while a map tick calls StoryWatcherTick (see AsyncTimeComp.Tick): the periodic adaptation growth runs only in the +// world tick, so once per game tick like vanilla +public static class StoryWatcherMapTick +{ + public static bool downedOnly; +} + +[HarmonyPatch(typeof(StoryWatcher_Adaptation), nameof(StoryWatcher_Adaptation.AdaptationWatcherTick))] +static class AdaptationWatcherMapTickPatch +{ + static bool Prefix(StoryWatcher_Adaptation __instance) + { + if (!StoryWatcherMapTick.downedOnly) return true; + + // The first half of the original + var downed = __instance.pawnsJustDownedThisTick; + for (int i = 0; i < downed.Count; i++) + __instance.ResolvePawnEvent(downed[i], AdaptationEvent.Downed); + downed.Clear(); + + return false; + } +} + +[HarmonyPatch(typeof(StoryWatcher_PopAdaptation), nameof(StoryWatcher_PopAdaptation.PopAdaptationWatcherTick))] +static class PopAdaptationWatcherMapTickPatch +{ + static bool Prefix() => !StoryWatcherMapTick.downedOnly; +} + [HarmonyPatch(typeof(Storyteller))] [HarmonyPatch(nameof(Storyteller.AllIncidentTargets), MethodType.Getter)] public class StorytellerTargetsPatch @@ -39,7 +69,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 +83,67 @@ static void Postfix(List __result) __result.Add(Find.World); } } + + // In multifaction every player faction's storyteller ticks on every map (FactionRepeater). Keep each one + // to its own maps and to unowned maps (sites etc.) where it has pawns, so faction A's incidents + // (e.g. Map_Misc ones while A's colonists visit) don't land on faction B's home. + static bool FactionCanReceiveIncidentsOn(Map map, Faction faction) + { + if (!Multiplayer.GameComp.multifaction) + return true; + + var owner = OwnerFaction(map); + if (owner == faction) + return true; + + if (owner is { IsPlayer: true }) + return false; + + return HasPawnsOn(map, faction); + } + + // Also pawns held on the map (carried, in a shuttle or pod...). Only evaluated when the storyteller makes + // incidents (every 1000 ticks), the unspawned ones need a scan of the map's holders. + static bool HasPawnsOn(Map map, Faction faction) => + map.mapPawns.SpawnedPawnsInFaction(faction).Count > 0 || map.mapPawns.PawnsInFaction(faction).Count > 0; + + // In multifaction the single player faction whose storyteller runs the quest incident makers (threat generators) + // of a map: its owner, else the lowest id player faction with pawns there, else the lowest id player faction. + // Every faction's storyteller walks the same quest list, so without this each one fired the same raids. + // Only factions with world data have a storyteller (FactionRepeater). + public static Faction QuestIncidentsFaction(Map map) + { + var worldComp = Multiplayer.WorldComp; + var spectator = worldComp.spectatorFaction; + + if (map != null && OwnerFaction(map) is { IsPlayer: true } owner && owner != spectator && + worldComp.factionData.ContainsKey(owner.loadID)) + return owner; + + Faction first = null; + foreach (var (id, _) in worldComp.factionData) // Sorted by id + { + if (id == spectator?.loadID || Find.FactionManager.GetById(id) is not { } faction) + continue; + + if (map == null || HasPawnsOn(map, faction)) + return faction; + + first ??= faction; + } + + return first; + } + + // A pocket map (e.g. an undercave) belongs to the owner of the map it was opened from + static Faction OwnerFaction(Map map) + { + var owningMap = map; + for (int i = 0; i < 8 && owningMap.IsPocketMap && owningMap.PocketMapParent?.sourceMap is { } source; i++) + owningMap = source; + + return owningMap.ParentFaction; + } } // The MP Mod's ticker calls Storyteller.StorytellerTick() on both the World and each Map, each tick @@ -61,67 +153,71 @@ public class QuestPartsListForReadingPatch { static void Postfix(ref List __result) { - if (StorytellerTickPatch.updating) + // The storyteller prefix also sets the flag in single player, where there is no map context + if (Multiplayer.Client != null && StorytellerTickPatch.updating && __result.Any(p => p is IIncidentMakerQuestPart)) { - __result = __result.Where(questPart => { - if (questPart is QuestPart_ThreatsGenerator questPartThreatsGenerator) - { - return questPartThreatsGenerator.mapParent?.Map == Multiplayer.MapContext; - } - return true; + var mapContext = Multiplayer.MapContext; + + // Multifaction: each player faction's storyteller ticks (FactionRepeater) and incident counts are seeded + // by the world, target and salt, so all of them would fire the same quest incidents at the same moment + bool incidentMakersAllowed = !Multiplayer.GameComp.multifaction || + Faction.OfPlayer == StorytellerTargetsPatch.QuestIncidentsFaction(mapContext); + + // A threats generator makes incidents for its own map, so in that map's tick. Other (mods') incident makers + // only in the world tick: the storyteller ticks in the world tick and in every map tick. + __result = __result.Where(questPart => questPart switch + { + QuestPart_ThreatsGenerator threats => incidentMakersAllowed && threats.mapParent?.Map == mapContext, + IIncidentMakerQuestPart => incidentMakersAllowed && mapContext == null, + _ => true }).ToList(); } } } -[HarmonyPatch(typeof(StorytellerUtility), nameof(StorytellerUtility.DefaultParmsNow))] -static class MapContextIncidentParms +// The world tick runs incident code for maps (incident parms, TryExecute); it runs in that map's context. Incidents can +// nest (tickingWorld stays set): a nested call for the same map stays in its context, one for another map leaves the +// outer map's context for the duration and re-enters it afterwards, so each map's time, Rand stream and faction apply. +static class WorldTickMapContext { - static void Prefix(IIncidentTarget target, ref Map __state) + public static void Enter(IIncidentTarget target, out (Map map, Map prev) state) { - // This may be running inside a context already - if (AsyncTimeComp.tickingMap != null) - return; + state = default; + if (!AsyncWorldTimeComp.tickingWorld || target is not Map map) return; - if (AsyncWorldTimeComp.tickingWorld && target is Map map) - { - AsyncTimeComp.tickingMap = map; - map.AsyncTime().PreContext(); - __state = map; - } + var prev = AsyncTimeComp.tickingMap; + if (prev == map) return; + + prev?.AsyncTime().PostContext(); + AsyncTimeComp.tickingMap = map; + map.AsyncTime().PreContext(); + state = (map, prev); } - static void Finalizer(Map __state) + public static void Exit((Map map, Map prev) state) { - if (__state != null) - { - __state.AsyncTime().PostContext(); - AsyncTimeComp.tickingMap = null; - } + if (state.map == null) return; + + state.map.AsyncTime().PostContext(); + AsyncTimeComp.tickingMap = state.prev; + state.prev?.AsyncTime().PreContext(); } } +[HarmonyPatch(typeof(StorytellerUtility), nameof(StorytellerUtility.DefaultParmsNow))] +static class MapContextIncidentParms +{ + static void Prefix(IIncidentTarget target, out (Map, Map) __state) => WorldTickMapContext.Enter(target, out __state); + + static void Finalizer((Map, Map) __state) => WorldTickMapContext.Exit(__state); +} + [HarmonyPatch(typeof(IncidentWorker), nameof(IncidentWorker.TryExecute))] static class MapContextIncidentExecute { - static void Prefix(IncidentParms parms, ref Map __state) - { - if (AsyncWorldTimeComp.tickingWorld && parms.target is Map map) - { - AsyncTimeComp.tickingMap = map; - map.AsyncTime().PreContext(); - __state = map; - } - } + static void Prefix(IncidentParms parms, out (Map, Map) __state) => WorldTickMapContext.Enter(parms.target, out __state); - static void Finalizer(Map __state) - { - if (__state != null) - { - __state.AsyncTime().PostContext(); - AsyncTimeComp.tickingMap = null; - } - } + static void Finalizer((Map, Map) __state) => WorldTickMapContext.Exit(__state); } [HarmonyPatch(typeof(Settlement), nameof(Settlement.IncidentTargetTags))] diff --git a/Source/Client/AsyncTime/TimeControlUI.cs b/Source/Client/AsyncTime/TimeControlUI.cs index 79444d14f..ca0a1a8b3 100644 --- a/Source/Client/AsyncTime/TimeControlUI.cs +++ b/Source/Client/AsyncTime/TimeControlUI.cs @@ -161,8 +161,9 @@ private static void DoTimeControlsHotkeys() if (KeyBindingDefOf.TimeSpeed_Ultrafast.KeyDownEvent) SendTimeVote(TimeVote.Ultrafast); - // Only for replaying - if (KeyBindingDefOf.Dev_TickOnce.KeyDownEvent && CurTimeSpeedGame == TimeVote.Paused) + // Only for replaying: in a live game this would run ticks and commands from OnGUI with a different call + // stack than other clients + if (Multiplayer.IsReplay && KeyBindingDefOf.Dev_TickOnce.KeyDownEvent && CurTimeSpeedGame == TimeVote.Paused) { DoSingleTick(); SoundDefOf.Clock_Stop.PlayOneShotOnCamera(); diff --git a/Source/Client/Comp/Map/MultiplayerMapComp.cs b/Source/Client/Comp/Map/MultiplayerMapComp.cs index 541a9233e..0c5c3e746 100644 --- a/Source/Client/Comp/Map/MultiplayerMapComp.cs +++ b/Source/Client/Comp/Map/MultiplayerMapComp.cs @@ -53,9 +53,14 @@ public CaravanFormingSession CreateCaravanFormingSession(Faction faction, bool r public TransporterLoading CreateTransporterLoadingSession(Faction faction, List transporters) { - var transporterLoading = sessionManager.GetFirstOfType(); + // Several groups of transporters can be loaded at once, only reuse the session of this group + var transporterLoading = TransporterLoading.GetSessionFor(map, transporters); if (transporterLoading == null) { + // The transporters' owner, Faction.OfPlayer can be e.g. the spectator faction when called during a tick + if (transporters.FirstOrDefault()?.parent.Faction is { IsPlayer: true } owner) + faction = owner; + transporterLoading = new TransporterLoading(faction, map, transporters); if (!sessionManager.AddSession(transporterLoading)) { @@ -110,9 +115,15 @@ 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 + { + map.PopFaction(); + } } } finally @@ -121,10 +132,12 @@ public void DoTick() } } - public void SetFaction(Faction faction) + public void SetFaction(Faction faction) => SetFaction(faction.loadID); + + public bool SetFaction(int factionId) { - if (!factionData.TryGetValue(faction.loadID, out FactionMapData data)) - return; + if (!factionData.TryGetValue(factionId, out FactionMapData data)) + return false; map.designationManager = data.designationManager; map.areaManager = data.areaManager; @@ -136,6 +149,16 @@ public void SetFaction(Faction faction) map.resourceCounter = data.resourceCounter; map.listerFilthInHomeArea = data.listerFilthInHomeArea; map.listerMergeables = data.listerMergeables; + return true; + } + + // The faction whose managers are currently applied to the map, -1 if unknown + public int GetAppliedFactionId() + { + foreach (var (id, data) in factionData) + if (data.zoneManager == map.zoneManager) + return id; + return -1; } public CustomFactionMapData GetCurrentCustomFactionData() @@ -271,7 +294,7 @@ static void Prefix() if (Multiplayer.Client == null) return; // Trading window on resume save - if (!Multiplayer.WorldComp.trading.NullOrEmpty()) return; + if (Multiplayer.WorldComp.trading.NullOrEmpty()) return; // playerNegotiator == null can only happen during loading? Is this a resuming check? Multiplayer.WorldComp.trading.FirstOrDefault(t => t.playerNegotiator == null)?.OpenWindow(); } diff --git a/Source/Client/Comp/World/FactionWorldData.cs b/Source/Client/Comp/World/FactionWorldData.cs index 20b43fe7b..0e87ffc2e 100644 --- a/Source/Client/Comp/World/FactionWorldData.cs +++ b/Source/Client/Comp/World/FactionWorldData.cs @@ -42,8 +42,7 @@ public void ExposeData() playSettings ??= new PlaySettings(); history ??= new History(); - storyteller ??= new Storyteller(Find.Storyteller.def, Find.Storyteller.difficultyDef, - Find.Storyteller.difficulty); + storyteller ??= NewStoryteller(); storyWatcher ??= new StoryWatcher(); } } @@ -73,11 +72,20 @@ public static FactionWorldData New(int factionId) playSettings = new PlaySettings(), history = new History(), - storyteller = new Storyteller(Find.Storyteller.def, Find.Storyteller.difficultyDef, Find.Storyteller.difficulty), + storyteller = NewStoryteller(), storyWatcher = new StoryWatcher() }; } + // Copies the current storyteller's settings. The difficulty is copied, not shared: a shared instance would only + // exist in memory (saving separates it), so changing one faction's difficulty would change both only on some clients + private static Storyteller NewStoryteller() + { + var current = Find.Storyteller; + var difficulty = current.difficulty != null ? Gen.MemberwiseClone(current.difficulty) : null; + return new Storyteller(current.def, current.difficultyDef, difficulty); + } + public static FactionWorldData FromCurrent(int factionId) { return new FactionWorldData() diff --git a/Source/Client/Comp/World/MultiplayerWorldComp.cs b/Source/Client/Comp/World/MultiplayerWorldComp.cs index a505856af..0dbf8cd3b 100644 --- a/Source/Client/Comp/World/MultiplayerWorldComp.cs +++ b/Source/Client/Comp/World/MultiplayerWorldComp.cs @@ -22,6 +22,14 @@ public class MultiplayerWorldComp : IHasSessionData public Faction spectatorFaction; + // Multifaction: letter ID -> load ID of Faction.OfPlayer when the letter was received. Only used by the UI to show + // letters to their faction's players, the letter stack itself is the same on every client. + public Dictionary letterFactions = new(); + + // Multifaction: faction load ID -> DateNotifier.lastSeason of that faction (see DateNotifierPatch). Kept here and + // not in FactionWorldData, which isn't saved for the faction applied when saving. + public Dictionary factionLastSeasons = new(); + private int currentFactionId; public MultiplayerWorldComp(World world) @@ -34,6 +42,12 @@ public MultiplayerWorldComp(World world) public void ExposeData() { ExposeFactionData(); + ExposeLetterFactions(); + + // Missing in old saves: the factions start Undefined, which vanilla initializes without notifying + Scribe_Collections.Look(ref factionLastSeasons, "factionLastSeasons", LookMode.Value, LookMode.Value); + if (Scribe.mode != LoadSaveMode.Saving) + factionLastSeasons ??= new Dictionary(); sessionManager.ExposeSessions(); // Ensure a pause lock session exists if there's any pause locks registered @@ -128,6 +142,40 @@ private void ExposeFactionData() } } + private void ExposeLetterFactions() + { + List letterIds = null; + List factionIds = null; + + if (Scribe.mode == LoadSaveMode.Saving) + { + // Only letters still in the stack (or waiting to be received) matter + letterIds = new List(); + factionIds = new List(); + + var stack = Find.LetterStack; + foreach (var letter in stack.letters.Concat(stack.letterQueue)) + { + if (letter != null && letterFactions.TryGetValue(letter.ID, out var factionId)) + { + letterIds.Add(letter.ID); + factionIds.Add(factionId); + } + } + } + + Scribe_Collections.Look(ref letterIds, "letterIds", LookMode.Value); + Scribe_Collections.Look(ref factionIds, "letterFactionIds", LookMode.Value); + + if (Scribe.mode == LoadSaveMode.LoadingVars) + { + letterFactions = new Dictionary(); + if (letterIds != null && factionIds != null) + for (int i = 0; i < letterIds.Count && i < factionIds.Count; i++) + letterFactions[letterIds[i]] = factionIds[i]; + } + } + public void WriteSessionData(ByteWriter writer) { sessionManager.WriteSessionData(writer); @@ -149,10 +197,12 @@ public void RemoveTradeSession(MpTradeSession session) sessionManager.RemoveSession(session); } - public void SetFaction(Faction faction) + public void SetFaction(Faction faction) => SetFaction(faction.loadID); + + public bool SetFaction(int factionId) { - if (!factionData.TryGetValue(faction.loadID, out FactionWorldData data)) - return; + if (!factionData.TryGetValue(factionId, out FactionWorldData data)) + return false; Game game = Current.Game; game.researchManager = data.researchManager; @@ -164,11 +214,24 @@ public void SetFaction(Faction faction) game.history = data.history; game.storyteller = data.storyteller; game.storyWatcher = data.storyWatcher; + return true; + } + + // The faction whose data is currently applied to the game, -1 if unknown + public int GetAppliedFactionId() + { + foreach (var (id, data) in factionData) + if (data.researchManager == Find.ResearchManager) + return id; + return -1; } + // Loading respawns things (and so triggers these) only on the loading client, while the trade dirty state is saved + private static bool IgnoreTradeDirtying => Multiplayer.reloading || Current.ProgramState != ProgramState.Playing; + public void DirtyColonyTradeForMap(Map map) { - if (map == null) return; + if (map == null || IgnoreTradeDirtying) return; foreach (MpTradeSession session in trading) if (session.playerNegotiator.Map == map) session.deal.recacheColony = true; @@ -176,7 +239,7 @@ public void DirtyColonyTradeForMap(Map map) public void DirtyTraderTradeForTrader(ITrader trader) { - if (trader == null) return; + if (trader == null || IgnoreTradeDirtying) return; foreach (MpTradeSession session in trading) if (session.trader == trader) session.deal.recacheTrader = true; @@ -184,7 +247,7 @@ public void DirtyTraderTradeForTrader(ITrader trader) public void DirtyTradeForSpawnedThing(Thing t) { - if (t is not { Spawned: true }) return; + if (t is not { Spawned: true } || IgnoreTradeDirtying) return; foreach (MpTradeSession session in trading) if (session.playerNegotiator.Map == t.Map) session.deal.recacheThings.Add(t); diff --git a/Source/Client/ConstantTicker.cs b/Source/Client/ConstantTicker.cs index 214ce1e33..f7631befb 100644 --- a/Source/Client/ConstantTicker.cs +++ b/Source/Client/ConstantTicker.cs @@ -1,6 +1,7 @@ using System; using HarmonyLib; using Multiplayer.Client.Desyncs; +using Multiplayer.Client.Factions; using Multiplayer.Common; using Multiplayer.Common.Networking.Packet; using RimWorld; @@ -18,7 +19,7 @@ public static void Tick() try { - TickShipCountdown(); // todo control the RNG seed here? + TickShipCountdown(); TickNonSimulation(); } finally @@ -47,9 +48,8 @@ private static void TickNonSimulation() private static void TickAutosave() { - // When connected to a remote standalone server, the client drives - // the autosave timer using the interval received at connection time - // (from the server's TOML settings via ServerProtocolOkPacket). + // When connected to a remote standalone server, the client autosaves locally using the interval + // received at connection time (from the server's TOML settings via ServerProtocolOkPacket). if (Multiplayer.session?.ConnectedToStandaloneServer == true) { var session = Multiplayer.session; @@ -60,14 +60,18 @@ private static void TickAutosave() { session.autosaveCounter++; + // This counter is per client (it starts when joining), so it must not request join points: + // the server issues the autosave join points itself (WorldData.TickStandaloneAutosave) if (session.autosaveCounter > session.autosaveInterval * TicksPerMinute) { session.autosaveCounter = 0; - Autosaving.DoAutosave(); + Autosaving.DoAutosave(requestJoinPoint: false); } } else if (session.autosaveUnit == AutosaveUnit.Days) { + // The map counters are synced game state, so every client saves at the same tick and the server + // starts a single join point for it (WorldData.TryStartJoinPointForSave) var anyMapCounterUp = Multiplayer.game.mapComps .Any(m => m.autosaveCounter > session.autosaveInterval * TicksPerIngameDay); @@ -119,7 +123,10 @@ private static void TickSyncCoordinator() try { - if (!TickPatch.Simulating && (Multiplayer.LocalServer != null || Multiplayer.arbiterInstance)) + // Only the sync reference's opinions are used by the server: host/arbiter, or the client the + // standalone server picked. Opinions can be megabytes, others don't send them. + if (!TickPatch.Simulating && (Multiplayer.LocalServer != null || Multiplayer.arbiterInstance || + Multiplayer.session.isSyncReference)) Multiplayer.Client.SendFragmented( new ClientSyncInfoPacket { SyncOpinion = opinion.ToNet() }.Serialize()); } @@ -140,7 +147,27 @@ private static void TickShipCountdown() ShipCountdown.timeLeft -= 1f / GenTicks.TicksPerRealSecond; if (ShipCountdown.timeLeft <= 0f) - ShipCountdown.CountdownEnded(); + { + // This is game logic (destroys the ship, removes pawns): run it on the world's RNG and faction + // context instead of the unsynced RNG outside of any tickable + var world = Multiplayer.AsyncWorldTime; + var shipMap = ShipCountdown.shipRoot?.Map; + int randDepth = Rand.stateStack.Count; + world.PreContext(); + shipMap.PushFaction(ShipCountdown.shipRoot?.Faction, force: true); + int factionDepth = FactionContext.stack.Count; + try + { + ShipCountdown.CountdownEnded(); + } + finally + { + AsyncTimeComp.RestoreRandStack(randDepth + 1, "ShipCountdown"); + FactionContext.RestoreStack(factionDepth, "ShipCountdown"); + shipMap.PopFaction(); + world.PostContext(); + } + } } } } diff --git a/Source/Client/Debug/DebugSync.cs b/Source/Client/Debug/DebugSync.cs index 4b8609d8c..791bb46d5 100644 --- a/Source/Client/Debug/DebugSync.cs +++ b/Source/Client/Debug/DebugSync.cs @@ -22,18 +22,19 @@ static class DebugSync public static void HandleCmd(ByteReader data) { - currentPlayer = data.ReadInt32(); + // Read everything before changing any state, so a malformed command can't leave the overrides set + int player = data.ReadInt32(); var source = (DebugSource)data.ReadInt32(); int cursorX = data.ReadInt32(); int cursorZ = data.ReadInt32(); + int hash = data.ReadInt32(); + var path = data.ReadStringNullable(); + int selectedId = data.ReadInt32(); - if (Multiplayer.MapContext != null) - MouseCellPatch.result = new IntVec3(cursorX, 0, cursorZ); - else - MouseTilePatch.result = cursorX; + Log.Message($"Debug tool {source} ({cursorX}, {cursorZ}) {hash} {path}"); - currentHash = data.ReadInt32(); - var path = data.ReadStringNullable(); + currentPlayer = player; + currentHash = hash; var state = Multiplayer.game.playerDebugState.GetOrAddNew(currentPlayer); @@ -46,25 +47,31 @@ public static void HandleCmd(ByteReader data) Find.Selector.selected = new List(); FieldRefs.worldSelected(Find.WorldSelector) = new List(); - int selectedId = data.ReadInt32(); - - if (Multiplayer.MapContext != null) - { - var thing = Multiplayer.ThingsById.GetValueSafe(selectedId); - if (thing != null) - Find.Selector.selected.Add(thing); - } - else + try { - var obj = Find.WorldObjects.AllWorldObjects.FirstOrDefault(w => w.ID == selectedId); - if (obj != null) - Find.WorldSelector.selected.Add(obj); - } + if (Multiplayer.MapContext != null) + MouseCellPatch.result = new IntVec3(cursorX, 0, cursorZ); + else + MouseTilePatch.result = cursorX; - Log.Message($"Debug tool {source} ({cursorX}, {cursorZ}) {currentHash} {path}"); + // Vanilla debug actions read the view (e.g. the incident actions pick their target in + // DebugActionsIncidents.GetTarget, which falls back to Find.CurrentMap). Replay world commands under + // the planet view and map commands under the command's map (it's Find.CurrentMap while executing). + WorldSelectedPatch.result = Multiplayer.MapContext == null; + + if (Multiplayer.MapContext != null) + { + var thing = Multiplayer.ThingsById.GetValueSafe(selectedId); + if (thing != null) + Find.Selector.selected.Add(thing); + } + else + { + var obj = Find.WorldObjects.AllWorldObjects.FirstOrDefault(w => w.ID == selectedId); + if (obj != null) + Find.WorldSelector.selected.Add(obj); + } - try - { if (source == DebugSource.Tree) { var node = RecreateGraphAndGetNode(path); @@ -115,6 +122,7 @@ public static void HandleCmd(ByteReader data) MouseCellPatch.result = null; MouseTilePatch.result = null; + WorldSelectedPatch.result = null; Find.Selector.selected = prevSelected; FieldRefs.worldSelected(Find.WorldSelector) = prevWorldSelected; @@ -259,7 +267,8 @@ static bool Prefix(ref Action action) action = targetInfo => { - DebugSync.SendCmd(DebugSource.Targeter, 0, null, null, targetInfo.Cell); + // A map targeter: the action uses the targeted map (Find.CurrentMap), which a map command provides + DebugSync.SendCmd(DebugSource.Targeter, 0, null, Find.CurrentMap, targetInfo.Cell); }; return true; diff --git a/Source/Client/Desyncs/ClientSyncOpinion.cs b/Source/Client/Desyncs/ClientSyncOpinion.cs index 0ab3482f3..daf032339 100644 --- a/Source/Client/Desyncs/ClientSyncOpinion.cs +++ b/Source/Client/Desyncs/ClientSyncOpinion.cs @@ -28,6 +28,8 @@ public class ClientSyncOpinion(int startTick) public List desyncStackTraces = new(); public List desyncStackTraceHashes = new(); public bool simulating; + // Local only, sent as simulating: a stack walk failed so the trace hashes can't be compared + public bool tracesUnreliable; public RoundModeEnum roundMode; public string CheckForDesync(ClientSyncOpinion other) @@ -53,7 +55,7 @@ join map2 in other.mapStates on map1.mapId equals map2.mapId if (!commandRandomStates.SequenceEqual(other.commandRandomStates)) return "Random state from commands doesn't match"; - if (!simulating && !other.simulating && desyncStackTraceHashes.Any() && other.desyncStackTraceHashes.Any() && !desyncStackTraceHashes.SequenceEqual(other.desyncStackTraceHashes)) + if (!simulating && !other.simulating && !tracesUnreliable && !other.tracesUnreliable && desyncStackTraceHashes.Any() && other.desyncStackTraceHashes.Any() && !desyncStackTraceHashes.SequenceEqual(other.desyncStackTraceHashes)) return "Trace hashes don't match"; return null; @@ -108,7 +110,7 @@ public byte[] Serialize() mapRandomStates = mapStates.Select(state => new MapRandomState { mapId = state.mapId, randomStates = state.randomStates }).ToList(), traceHashes = desyncStackTraceHashes, - simulating = simulating, + simulating = simulating || tracesUnreliable, roundMode = roundMode }; diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index db53da2df..118f8ffb7 100644 --- a/Source/Client/Desyncs/DeferredStackTracing.cs +++ b/Source/Client/Desyncs/DeferredStackTracing.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Reflection; using System.Runtime.InteropServices; @@ -29,24 +30,98 @@ static IEnumerable TargetMethods() private static bool SupportsDeferredStackTracing => RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86; + private static bool loggedTraceFailure; + + // Set by the first exception thrown by the stack walk, until the game restarts. On some setups (e.g. macOS + // through Rosetta) the walk throws on every Rand call, which stalls the game (#856). Traces are diagnostic + // only: the window being built is marked unreliable below and this client's later windows have no trace + // hashes, which aren't compared. + public static bool tracingFailed; + public static void Postfix() { if (Native.LmfPtr == 0) return; - if (!ShouldAddStackTraceForDesyncLog()) return; - - var logItem = StackTraceLogItemRaw.GetFromPool(); - var trace = logItem.raw; - int hash = 0; - // Skip this (DeferredStackTracing.Postfix) frame. Keeping it in the trace doesn't provide any value. - int depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash, skipFrames: 1); - Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); + // This runs inside Rand and other game code: an exception here would abort the game code on this client + // only, so never let one escape + try + { + if (!ShouldAddStackTraceForDesyncLog()) return; + + var logItem = StackTraceLogItemRaw.GetFromPool(); + var trace = logItem.raw; + int hash = 0; + int depth; + + try + { + // Skip this (DeferredStackTracing.Postfix) frame. Keeping it in the trace doesn't provide any value. + depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash, skipFrames: 1); + } + catch (Exception e) + { + // Mark first: if logging throws, the outer catch skips the rest of this method + tracingFailed = true; + Multiplayer.game.sync.MarkTracesUnreliable(); + + LogTraceFailureOnce(e.ToString()); + Log.Warning("Multiplayer: desync stack traces are turned off until the game restarts"); + depth = -1; + } + + if (depth < 0) + { + // Keep the trace count in line with other clients but don't compare this window's trace hashes + depth = 0; + hash = 0; + Multiplayer.game.sync.MarkTracesUnreliable(); + + if (DeferredStackTracingImpl.lastFailureReason != null) + LogTraceFailureOnce(DeferredStackTracingImpl.lastFailureReason); + } + + Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); + + acc++; + } + catch (Exception e) + { + try + { + // Traces stop for this client mid-window, so its trace hashes can't be compared + if (tracingFailed) + Multiplayer.game?.sync.MarkTracesUnreliable(); + } + catch + { + // ignored + } + + LogTraceFailureOnce(e.ToString()); + } + } - acc++; + private static void LogTraceFailureOnce(string info) + { + if (loggedTraceFailure) return; + loggedTraceFailure = true; + + string at = null; + try + { + at = Native.MethodNameFromAddr(DeferredStackTracingImpl.lastFailureAddr, false); + } + catch + { + // ignored + } + + Log.Error($"Multiplayer: deferred stack tracing failed (further failures aren't logged). At: {at ?? "unknown"}. {info}"); } public static bool ShouldAddStackTraceForDesyncLog() { + if (tracingFailed) return false; if (!SupportsDeferredStackTracing) return false; if (Multiplayer.Client == null) return false; if (Multiplayer.settings.desyncTracingMode == DesyncTracingMode.None) return false; @@ -59,6 +134,9 @@ public static bool ShouldAddStackTraceForDesyncLog() if (!Multiplayer.Ticking && !Multiplayer.ExecutingCmds) return false; + // Worker thread timing isn't deterministic and LmfPtr belongs to the main thread + if (!UnityData.IsInMainThread) return false; + return ignoreTraces == 0; } } diff --git a/Source/Client/Desyncs/LogGenerator.cs b/Source/Client/Desyncs/LogGenerator.cs index 192bb3110..2ef23e2b9 100644 --- a/Source/Client/Desyncs/LogGenerator.cs +++ b/Source/Client/Desyncs/LogGenerator.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; +using Multiplayer.Common; using UnityEngine; using Verse; @@ -13,6 +14,8 @@ namespace Multiplayer.Client public static class LogGenerator { private const int MaxLogLineCount = 10000; + // The start has the setup (mods, patching errors), the end the desync itself + private const int HeadLogLineCount = 2000; internal static string PrepareLogData() { @@ -46,15 +49,8 @@ private static string NormalizeLineEndings(string log) return log.Replace("\r\n", "\n"); } - 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; - } + private static string TrimExcessLines(string log) => + log.KeepHeadAndTailLines(HeadLogLineCount, MaxLogLineCount - HeadLogLineCount); private static string RedactUselessLines(string log) { diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 200c3dfee..2549a0ba9 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -114,10 +114,15 @@ private string GetDesyncDetails() .AppendLine("\n###Version Data###") .AppendLine($"Multiplayer Mod Version|||{MpVersion.Version}") .AppendLine($"Rimworld Version and Rev|||{VersionControl.CurrentVersionStringWithRev}") + .AppendLine($"Join Data Mismatch|||{Multiplayer.session.joinDataDiff ?? "none"}") .AppendLine("\n###Debug Options###") .AppendLine($"Multiplayer Debug Build - Client|||{MpVersion.IsDebug}") .AppendLine($"Multiplayer Debug Mode - Host|||{Multiplayer.GameComp.debugMode}") .AppendLine($"Rimworld Developer Mode - Client|||{Prefs.DevMode}") + .AppendLine("\n###Failures###") + .AppendLine($"Loading Errors - Client|||{Multiplayer.loadingErrorCount}") + .AppendLine($"Skipped Simulation Errors - Client|||{Multiplayer.session.simulationFailures}") + .AppendLine($"First Simulation Error - Client|||{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..e20a65648 --- /dev/null +++ b/Source/Client/Desyncs/SimulationFailures.cs @@ -0,0 +1,45 @@ +using System; +using RimWorld; +using Verse; + +namespace Multiplayer.Client; + +/// +/// Exceptions in synced commands and ticks which are caught so that the rest of the simulation goes on. Each one +/// may have left this client different from the others, so they are counted, the player is told once per session +/// and desync reports include them. +/// +public static class SimulationFailures +{ + /// The world from the server replaces whatever the failures did (joining, rejoining after a desync) + public static void Reset(MultiplayerSession session) + { + session.simulationFailures = 0; + session.firstSimulationFailure = null; + } + + /// Call from the catch block, next to logging the exception. Touches only local state. + public static void Record(string where, Exception e) + { + var session = Multiplayer.session; + if (session == null) return; + + session.simulationFailures++; + if (session.firstSimulationFailure != null) return; + + session.firstSimulationFailure = + $"tick {TickPatch.Timer}, {where}: {e.GetType().Name}: {e.Message}".Replace('\n', ' ').Replace('\r', ' '); + + // Not from inside the tick: the message is local UI and the failure may have happened on this client only. + // Not translated: the strings live in the separate locale repository. + OnMainThread.Enqueue(() => + { + if (Multiplayer.session != session || session.firstSimulationFailure == null) return; + + const string text = "Multiplayer: an error in the game simulation was skipped on this client. " + + "This can cause a desync, the log has the details."; + Messages.Message(text, MessageTypeDefOf.NegativeEvent, historical: false); + session.AddMsg($"{text} ({session.firstSimulationFailure})", rawMessage: true); + }); + } +} diff --git a/Source/Client/Desyncs/SyncCoordinator.cs b/Source/Client/Desyncs/SyncCoordinator.cs index 0714065d5..952e0f73d 100644 --- a/Source/Client/Desyncs/SyncCoordinator.cs +++ b/Source/Client/Desyncs/SyncCoordinator.cs @@ -31,6 +31,27 @@ public class SyncCoordinator private const int MaxBacklog = 30; + // The coordinator of the previous MultiplayerGame instance, see the constructor + private static SyncCoordinator previous; + private readonly MultiplayerSession session; + + public SyncCoordinator() + { + session = Multiplayer.session; + + // Every reload (e.g. a join point) creates a new MultiplayerGame. Keep the opinions still waiting for + // their counterpart so that a desync right before the reload is still detected. + if (Multiplayer.reloading && previous is { } prev && prev != this && prev.session != null && prev.session == session) + { + knownClientOpinions.AddRange(prev.knownClientOpinions); + prev.knownClientOpinions.Clear(); + lastValidTick = prev.lastValidTick; + arbiterWasPlayingOnLastValidTick = prev.arbiterWasPlayingOnLastValidTick; + } + + previous = this; + } + public ClientSyncOpinion FinishLocalOpinion() { if (!ShouldCollect || currentOpinion == null) return null; @@ -78,6 +99,8 @@ public void AddClientOpinionAndCheckDesync(ClientSyncOpinion newOpinion) if (knownClientOpinions.First().startTick != newOpinion.startTick) { // Ignore this opinion + MpLog.Log($"Sync opinion windows don't line up, skipping the check: {(newOpinion.isLocalClientsOpinion ? "local" : "remote")} " + + $"starts at {newOpinion.startTick}, the oldest known one at {knownClientOpinions.First().startTick}"); newOpinion.Clear(); return; } @@ -212,6 +235,15 @@ public void TryAddInfoForDesyncLog(string info1, string info2) OpinionInBuilding.desyncStackTraceHashes.Add(hash); } + /// + /// Excludes the current opinion's trace hashes from comparison, used when the local stack walk failed. + /// + public void MarkTracesUnreliable() + { + if (!ShouldCollect) return; + OpinionInBuilding.tracesUnreliable = true; + } + public void TryAddStackTraceForDesyncLogRaw(StackTraceLogItemRaw item, int depth, int hashIn, string moreInfo = null) { if (!ShouldCollect) return; 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/FactionContext.cs b/Source/Client/Factions/FactionContext.cs index 121cd8aa9..0e860595d 100644 --- a/Source/Client/Factions/FactionContext.cs +++ b/Source/Client/Factions/FactionContext.cs @@ -8,22 +8,37 @@ public static class FactionContext { public static Stack stack = new(); + // Parallel to stack: optional state of the entry's push, restored by its pop (see FactionExtensions.PushFaction) + private static Stack stateStack = new(); + public static Faction Push(Faction newFaction, bool force = false) { if (newFaction == null || !force && Find.FactionManager.ofPlayer == newFaction || !newFaction.def.isPlayer) { stack.Push(null); + stateStack.Push(null); return null; } stack.Push(Find.FactionManager.OfPlayer); + stateStack.Push(null); Set(newFaction); return newFaction; } - public static Faction Pop() + // Attaches state to the entry pushed last + public static void SetTopState(object state) + { + stateStack.Pop(); + stateStack.Push(state); + } + + public static Faction Pop() => Pop(out _); + + public static Faction Pop(out object state) { + state = stateStack.Count > 0 ? stateStack.Pop() : null; Faction f = stack.Pop(); if (f != null) Set(f); @@ -35,11 +50,26 @@ public static void Set(Faction newFaction) Find.FactionManager.ofPlayer = newFaction; } + // Like AsyncTimeComp.RestoreRandStack: a push left unpopped by an exception would leave the wrong + // Faction.OfPlayer and faction data applied after the context ends and keep the stack too deep. Pops (and + // restores what each entry applied) down to the depth the context had after its own pushes. + public static void RestoreStack(int depth, object context) + { + int count = stack.Count; + if (count == depth) return; + + // Too shallow (an extra pop) can't be repaired, only reported + Log.ErrorOnce($"Multiplayer: unbalanced faction push/pop in {context}: stack depth {count}, expected {depth}", 0x2B71C4 ^ depth); + + while (stack.Count > depth) + Factions.FactionExtensions.PopFaction(); + } + public static void Clear() { stack.Clear(); + stateStack.Clear(); } } } - diff --git a/Source/Client/Factions/FactionContextSetters.cs b/Source/Client/Factions/FactionContextSetters.cs index 7ba933c72..cbf932d98 100644 --- a/Source/Client/Factions/FactionContextSetters.cs +++ b/Source/Client/Factions/FactionContextSetters.cs @@ -29,9 +29,14 @@ static void Prefix(PlanetTile tile) Faction factionToSet = GetFactionAt(tile); if (Multiplayer.Client != null && factionToSet == null) - Log.Warning($"Couldn't set the faction context for map gen at {tile.tileId}: no world object and no stored faction."); + Log.Warning($"Couldn't set the faction context for map gen at {tile.tileId}: no player world object at the tile."); - FactionContext.Push(factionToSet); + // Also switch the faction's world data (research, play settings, history...) used during generation + // Forced as the current context may have set only Faction.OfPlayer (MpScope.PushFaction) + if (Multiplayer.Client != null) + FactionExtensions.PushFaction(null, factionToSet, force: true); + else + FactionContext.Push(factionToSet); } private static Faction GetFactionAt(PlanetTile tile) @@ -42,7 +47,9 @@ private static Faction GetFactionAt(PlanetTile tile) if (mapParent != null && mapParent.Faction is { IsPlayer: true }) return mapParent.Faction; - var caravan = worldObjectsHolder.PlayerControlledCaravanAt(tile); + // Not PlayerControlledCaravanAt: it compares with Faction.OfPlayer, which differs between clients outside + // of a simulation context + var caravan = worldObjectsHolder.Caravans.Find(c => c.Tile == tile && c.Faction is { IsPlayer: true }); if (caravan != null) return caravan.Faction; @@ -54,12 +61,15 @@ private static Faction GetFactionAt(PlanetTile tile) if (gravship != null) return gravship.Faction; - return TileFactionContext.GetFactionForTile(tile); + return null; } static void Finalizer() { - FactionContext.Pop(); + if (Multiplayer.Client != null) + FactionExtensions.PopFaction(); + else + FactionContext.Pop(); } } @@ -154,14 +164,3 @@ static void Finalizer() FactionContext.Pop(); } } - -// Clean up after map generation is complete -[HarmonyPatch(typeof(MapGenerator), nameof(MapGenerator.GenerateMap))] -static class CleanupTileFactionContext -{ - static void Finalizer(MapParent parent) - { - if (parent != null) - TileFactionContext.ClearTile(parent.Tile); - } -} diff --git a/Source/Client/Factions/FactionCreationPatches.cs b/Source/Client/Factions/FactionCreationPatches.cs index c34880290..492cff6a9 100644 --- a/Source/Client/Factions/FactionCreationPatches.cs +++ b/Source/Client/Factions/FactionCreationPatches.cs @@ -45,6 +45,20 @@ static bool Prefix() } } +// Starting pawns are sent without an ideoligion (FactionCreator.WriteStartingPawn), CreateFaction gives them the new +// faction's. Pawn_IdeoTracker's PostLoadInit would otherwise assign a random fallback one. +[HarmonyPatch(typeof(Pawn), nameof(Pawn.ShouldHaveIdeo), MethodType.Getter)] +static class NoFallbackIdeoForStartingPawnPatch +{ + static bool Prefix(ref bool __result) + { + if (!FactionCreator.readingStartingPawn) return true; + + __result = false; + return false; + } +} + [HarmonyPatch(typeof(StartingPawnUtility), nameof(StartingPawnUtility.GetGenerationRequest))] static class StartingPawnUtility_GetGenerationRequest_Patch { diff --git a/Source/Client/Factions/FactionCreator.cs b/Source/Client/Factions/FactionCreator.cs index c26ea721e..9cc69714c 100644 --- a/Source/Client/Factions/FactionCreator.cs +++ b/Source/Client/Factions/FactionCreator.cs @@ -24,10 +24,59 @@ public static void ClearData() pawnStore.Clear(); } - [SyncMethod(exposeParameters = new[] { 1 })] - public static void SendPawn(int playerId, Pawn p) + // Set while SendPawn reads a starting pawn, see NoFallbackIdeoForStartingPawnPatch + public static bool readingStartingPawn; + + [SyncMethod] + public static void SendPawn(int playerId, StartingPawnData data) + { + if (ReadStartingPawn(data.xml) is { } pawn) + pawnStore.GetOrAddNew(playerId).Add(pawn); + } + + // The page's pawns believe in the temporary page faction's ideoligion, which isn't registered: on load the + // reference didn't resolve, the pawn got a random fallback ideoligion and CreateFaction's SetIdeo then recorded + // an ideo change and kept the fallback as a previous ideo (#537). Send them without one, CreateFaction sets it. + public static StartingPawnData WriteStartingPawn(Pawn pawn) + { + var tracker = pawn.ideo; + var ideo = tracker?.ideo; + if (tracker != null) tracker.ideo = null; + + try + { + return new StartingPawnData { xml = ScribeUtil.WriteExposable(pawn) }; + } + finally + { + if (tracker != null) tracker.ideo = ideo; + } + } + + private static Pawn ReadStartingPawn(byte[] xml) { - pawnStore.GetOrAddNew(playerId).Add(p); + if (xml == null) return null; + + // Vanilla loads pawns only while a game initializes. In Playing the needs tracker's constructor evaluates + // Need_Food.MaxLevel as a stat over hediffs whose pawn is set only when cross refs resolve (#536). + // Outside Playing some getters read the GameInitData (TicksAbs, Faction.OfPlayer, PawnsFinder), which + // differs between clients here (the issuer's pages, an earlier faction creation, none), so hide it. + var prevState = Current.programStateInt; + var prevInitData = Current.Game.InitData; + Current.programStateInt = ProgramState.MapInitializing; + Current.Game.InitData = null; + readingStartingPawn = true; + + try + { + return ScribeUtil.ReadExposable(xml); + } + finally + { + readingStartingPawn = false; + Current.Game.InitData = prevInitData; + Current.programStateInt = prevState; + } } [SyncMethod] @@ -46,6 +95,11 @@ public static void CreateFaction(int playerId, FactionCreationData creationData) LongEventHandler.QueueLongEvent(() => { + // Runs in the command's simulation context (SimLongEventContext), which also covers the actions map + // generation defers with ExecuteWhenFinished. The pop restores that context's faction data, not the + // local player's (the context's exit re-applies the real player faction, the issuer's new one). + using var _ = MpScope.PushFaction(Multiplayer.WorldComp.spectatorFaction); + var scenario = creationData.scenarioDef?.scenario ?? Current.Game.Scenario; Map newMap = null; @@ -76,6 +130,9 @@ public static void CreateFaction(int playerId, FactionCreationData creationData) if (executingOnlyOnIssuer) { + // Runs on the issuer only: e.g. archived dialogs take unique ids + using var localOnly = LocalOnlyScope.Enter(); + ClearPortraitCacheFromPawnConfigPage(); Current.Game.CurrentMap = newMap; @@ -110,6 +167,12 @@ 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 adds the custom xenotypes of the static (client-local) starting pawn generation requests + // to the game's customXenotypeDatabase. Only the issuer has its requests there (others may have stale + // ones), and the database feeds factionless pawn generation, so clear them on every client. The pawns + // keep their genes; their custom xenotype just isn't added to the database. + StartingPawnUtility.StartingAndOptionalPawnGenerationRequests.Clear(); Find.GameInitData.PrepForMapGen(); // ScenPart_PlayerFaction --> PreMapGenerate @@ -229,7 +292,7 @@ private static Faction NewFactionWithIdeo(string name, Color color, FactionDef d } else { - var newIdeo = GenerateIdeo(chooseIdeoInfo); + var newIdeo = GenerateIdeo(chooseIdeoInfo, def); faction.ideos.SetPrimary(newIdeo); Find.IdeoManager.Add(newIdeo); } @@ -237,7 +300,10 @@ private static Faction NewFactionWithIdeo(string name, Color color, FactionDef d foreach (Faction other in Find.FactionManager.AllFactionsListForReading) faction.TryMakeInitialRelationsWith(other); + // Add recaches Faction.OfPlayer to the first player faction, keep the current context + var ofPlayer = Find.FactionManager.ofPlayer; Find.FactionManager.Add(faction); + Find.FactionManager.ofPlayer = ofPlayer; var newWorldFactionData = FactionWorldData.New(faction.loadID); Multiplayer.WorldComp.factionData[faction.loadID] = newWorldFactionData; @@ -254,16 +320,17 @@ private static Faction NewFactionWithIdeo(string name, Color color, FactionDef d return faction; } - private static Ideo GenerateIdeo(IdeologyData chooseIdeoInfo) + // factionDef: the new faction's (from the chosen scenario), not Faction.OfPlayer's or the game scenario's + private static Ideo GenerateIdeo(IdeologyData chooseIdeoInfo, FactionDef factionDef) { List list = chooseIdeoInfo.SelectedIdeo.memes.ToList(); if (chooseIdeoInfo.SelectedStructure != null) list.Add(chooseIdeoInfo.SelectedStructure); - else if (DefDatabase.AllDefsListForReading.Where(m => m.category == MemeCategory.Structure && IdeoUtility.IsMemeAllowedFor(m, Find.Scenario.playerFaction.factionDef)).TryRandomElement(out var result)) + else if (DefDatabase.AllDefsListForReading.Where(m => m.category == MemeCategory.Structure && IdeoUtility.IsMemeAllowedFor(m, factionDef)).TryRandomElement(out var result)) list.Add(result); - Ideo ideo = IdeoGenerator.GenerateIdeo(new IdeoGenerationParms(Find.FactionManager.OfPlayer.def, forceNoExpansionIdeo: false, null, null, list, chooseIdeoInfo.SelectedIdeo.classicPlus, forceNoWeaponPreference: true)); + Ideo ideo = IdeoGenerator.GenerateIdeo(new IdeoGenerationParms(factionDef, forceNoExpansionIdeo: false, null, null, list, chooseIdeoInfo.SelectedIdeo.classicPlus, forceNoWeaponPreference: true)); new Page_ChooseIdeoPreset { selectedStyles = chooseIdeoInfo.SelectedStyles }.ApplySelectedStylesToIdeo(ideo); return ideo; @@ -284,6 +351,22 @@ public static int GetStartingPawnsCountForScenario(Scenario scenario) return 0; } } + +// A starting pawn's XML, read inside SendPawn (not by the sync layer) so that the read runs in a load-like state. +// Same wire format as the exposed Pawn parameter it replaces. +public class StartingPawnData : ISynchronizable +{ + public byte[] xml; + + public void Sync(SyncWorker sync) + { + if (sync is WritingSyncWorker writer) + writer.Writer.WritePrefixedBytes(xml); + else if (sync is ReadingSyncWorker reader) + xml = reader.Reader.ReadPrefixedBytes(); + } +} + public record FactionCreationData : ISyncSimple { public string factionName; diff --git a/Source/Client/Factions/FactionExtensions.cs b/Source/Client/Factions/FactionExtensions.cs index a520c8cf0..14f87278f 100644 --- a/Source/Client/Factions/FactionExtensions.cs +++ b/Source/Client/Factions/FactionExtensions.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; using RimWorld; using RimWorld.Planet; @@ -10,13 +11,27 @@ public static class FactionExtensions // Sets the current Faction.OfPlayer // Applies faction's world components // Applies faction's map components if map not null + // + // force: the faction is applied even if it already is Faction.OfPlayer, and to every map, not only the given one. + // Forced pushes set up simulation contexts (map/world ticks and commands). Outside of them every map holds the + // data of the local client's own faction, so applying the context's faction to all maps makes sure simulation + // code touching another map (portals, pocket maps, gravships, ...) never sees client-specific data. + // The matching pop restores exactly the factions the world and each map had before the push. public static void PushFaction(this Map map, Faction f, bool force = false) { var faction = FactionContext.Push(f, force); if (faction == null) return; + if (force) + FactionContext.SetTopState(AppliedFactions.Capture()); + else if (map != null) + FactionContext.SetTopState(map); // Lets an unwind (FactionContext.RestoreStack) restore the map too + Multiplayer.WorldComp?.SetFaction(faction); - map?.MpComp().SetFaction(faction); + if (force) + SetFactionOnAllMaps(faction); + else + map?.MpComp().SetFaction(faction); } public static void PushFaction(this Map map, int factionId) @@ -29,15 +44,79 @@ public static void PushFaction(this Map map, int factionId) public static Faction PopFaction(this Map map) { - Faction faction = FactionContext.Pop(); + Faction faction = FactionContext.Pop(out var state); if (faction == null) return null; + if (state is AppliedFactions applied) + { + applied.Restore(faction); + return faction; + } + Multiplayer.WorldComp?.SetFaction(faction); - map?.MpComp().SetFaction(faction); + (map ?? state as Map)?.MpComp()?.SetFaction(faction); return faction; } + private static void SetFactionOnAllMaps(Faction faction) + { + var comps = Multiplayer.game?.mapComps; + if (comps == null) return; + + for (int i = 0; i < comps.Count; i++) + if (IsLive(comps[i])) + comps[i].SetFaction(faction); + } + + // Comps of removed maps are never removed from the list + private static bool IsLive(MultiplayerMapComp comp) => comp.map is { Disposed: false }; + + // The factions whose data the world and the maps held before a forced push + private class AppliedFactions + { + private int worldFactionId; + private List comps; + private int[] mapFactionIds; + + public static AppliedFactions Capture() + { + var applied = new AppliedFactions + { + worldFactionId = Multiplayer.WorldComp?.GetAppliedFactionId() ?? -1, + comps = Multiplayer.game?.mapComps + }; + + var count = applied.comps?.Count ?? 0; + applied.mapFactionIds = new int[count]; + for (int i = 0; i < count; i++) + applied.mapFactionIds[i] = IsLive(applied.comps[i]) ? applied.comps[i].GetAppliedFactionId() : -1; + + return applied; + } + + // Maps without a known previous faction (e.g. generated in between) get the popped faction + public void Restore(Faction popped) + { + var worldComp = Multiplayer.WorldComp; + if (worldComp != null && (worldFactionId == -1 || !worldComp.SetFaction(worldFactionId))) + worldComp.SetFaction(popped); + + var currentComps = Multiplayer.game?.mapComps; + if (currentComps == null) return; + + for (int i = 0; i < currentComps.Count; i++) + { + var comp = currentComps[i]; + if (!IsLive(comp)) continue; + + var id = currentComps == comps && i < mapFactionIds.Length ? mapFactionIds[i] : -1; + if (id == -1 || !comp.SetFaction(id)) + comp.SetFaction(popped); + } + } + } + public static bool TryGetPlayerFaction(this Quest quest, out Faction faction) { faction = quest.QuestLookTargets diff --git a/Source/Client/Factions/FactionRepeater.cs b/Source/Client/Factions/FactionRepeater.cs index 7fd1a8110..9ff1fab4a 100644 --- a/Source/Client/Factions/FactionRepeater.cs +++ b/Source/Client/Factions/FactionRepeater.cs @@ -196,6 +196,76 @@ ref ignore ); } + // A building that can't overlap zones clears zone cells only from the installed (context faction's) zone + // manager, other factions' zones kept cells under it. Repeat it for them when any of them has a zone there. + [HarmonyPatch(typeof(ZoneManager), nameof(ZoneManager.Notify_NoZoneOverlapThingSpawned))] + static class ZoneOverlapSpawnedPatch + { + static bool ignore; + + static void Prefix(ZoneManager __instance, Thing thing) + { + if (Multiplayer.Client == null || ignore) return; + + var factionData = __instance.map.MpComp()?.factionData; + if (factionData == null || !AnyOtherFactionZoneUnder(factionData, __instance, thing)) return; + + // The original then runs on the installed manager again, a no-op once its cells are cleared + FactionRepeater.Template( + factionData, + d => d.zoneManager.Notify_NoZoneOverlapThingSpawned(thing), + __instance.map, + ref ignore + ); + } + + static bool AnyOtherFactionZoneUnder(IDictionary factionData, ZoneManager installed, Thing thing) + { + var rect = thing.OccupiedRect(); + foreach (var data in factionData.Values) + { + if (data.zoneManager == installed) continue; + foreach (var c in rect) + if (c.InBounds(installed.map) && data.zoneManager.ZoneAt(c) != null) + return true; + } + + return false; + } + } + + // Destroying a thing removes designations on it only from each map's installed designation manager, so other + // factions' managers kept designations on a destroyed thing (they're dropped on load, so a reloading client + // differed from the others). Designations on a thing live on its own map. + [HarmonyPatch(typeof(Thing), nameof(Thing.Destroy))] + static class ThingDestroyDesignationsPatch + { + static void Prefix(Thing __instance, ref Map __state) + { + if (Multiplayer.Client != null && __instance.def.category != ThingCategory.Mote) + __state = __instance.MapHeld; + } + + static void Postfix(Thing __instance, Map __state) + { + if (__state == null || !__instance.Destroyed) return; + + var factionData = __state.MpComp()?.factionData; + if (factionData == null || !factionData.Values.Any(d => + d.designationManager != __state.designationManager && + d.designationManager.AllDesignationsOn(__instance).Count > 0)) + return; + + var ignore = false; + FactionRepeater.Template( + factionData, + d => d.designationManager.RemoveAllDesignationsOn(__instance), + __state, + ref ignore + ); + } + } + // 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/FactionSidebar.cs b/Source/Client/Factions/FactionSidebar.cs index c530eadb1..90baa6e5b 100644 --- a/Source/Client/Factions/FactionSidebar.cs +++ b/Source/Client/Factions/FactionSidebar.cs @@ -381,7 +381,7 @@ private static void DoCreateFaction(IdeologyData chooseIdeoInfo, bool generateMa if (Current.Game.InitData?.startingAndOptionalPawns is { } pawns) for (int i = 0; i < Find.GameInitData.startingPawnCount; i++) { - FactionCreator.SendPawn(playerId, pawns[i]); + FactionCreator.SendPawn(playerId, FactionCreator.WriteStartingPawn(pawns[i])); startingPawns.Add(pawns[i]); } @@ -421,12 +421,13 @@ private static void OpenConfigurationPages() var gameConfigurationPages = new List(); Page_ChooseIdeo_Multifaction ideologyConfigPage = null; Page_ConfigureStartingPawns pawnConfigPage = null; + var scenario = chosenScenario?.scenario ?? Find.Scenario; InitializeDataForGameConfigurationPages(); - ideologyConfigPage = TryGetIdeologyConfigurationPage(); + ideologyConfigPage = TryGetIdeologyConfigurationPage(scenario); - pawnConfigPage = new Page_ConfigureStartingPawns_Multifaction() + pawnConfigPage = new Page_ConfigureStartingPawns_Multifaction(scenario) { nextAct = () => { @@ -442,10 +443,23 @@ private static void OpenConfigurationPages() Find.WindowStack.Add(PageUtility.StitchedPages(gameConfigurationPages)); } - private static void InitializeDataForGameConfigurationPages() + // Find.Scenario is shared game state the simulation reads (Scenario.TickScenario, stat factors, disabled + // incidents, ...), so the configuration pages only apply their scenario while their own code runs + internal static MpScope UseScenario(Scenario scenario) { - Current.Game.Scenario = chosenScenario.scenario; + Scenario prevScenario = null; + return MpScope.Create( + () => + { + prevScenario = Current.Game.Scenario; + if (scenario != null) + Current.Game.Scenario = scenario; + }, + () => Current.Game.Scenario = prevScenario); + } + private static void InitializeDataForGameConfigurationPages() + { Current.Game.InitData = new GameInitData { startedFromEntry = true, @@ -465,12 +479,12 @@ private static List GetStartingPossessions(List startingPaw return startingPossessions; } - private static Page_ChooseIdeo_Multifaction TryGetIdeologyConfigurationPage() + private static Page_ChooseIdeo_Multifaction TryGetIdeologyConfigurationPage(Scenario scenario) { Page_ChooseIdeo_Multifaction chooseIdeoPage = null; if (ModsConfig.IdeologyActive && !Find.IdeoManager.classicMode) - chooseIdeoPage = new Page_ChooseIdeo_Multifaction(); + chooseIdeoPage = new Page_ChooseIdeo_Multifaction { scenario = scenario }; return chooseIdeoPage; } diff --git a/Source/Client/Factions/Forbiddables.cs b/Source/Client/Factions/Forbiddables.cs index 590e9d58e..cce6ce6cd 100644 --- a/Source/Client/Factions/Forbiddables.cs +++ b/Source/Client/Factions/Forbiddables.cs @@ -116,7 +116,8 @@ static class ThingDespawnUnsetForbidden static void Prefix(Thing __instance) { if (Multiplayer.Client == null) return; - __instance.Map.MpComp().Notify_ThingDespawned(__instance); + // Despawning an unspawned thing only logs an error in vanilla + __instance.Map?.MpComp()?.Notify_ThingDespawned(__instance); } } } diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index 9eea55518..6a7dfd09b 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -634,17 +634,6 @@ static bool Prefix(JobDriver __instance) } -[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] -static class LetterStackReceiveOnlyMyFaction -{ - // todo the letter might get culled from the archive if it isn't in the stack and Sync depends on the archive - static void Postfix(LetterStack __instance, Letter let) - { - if (Multiplayer.RealPlayerFaction != Faction.OfPlayer) - __instance.letters.Remove(let); - } -} - [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] static class LetterStackReceiveSoundOnlyMyFaction { @@ -666,7 +655,9 @@ static IEnumerable Transpiler(IEnumerable inst static void PlaySoundReplacement(SoundDef sound, Map map) { - if (Multiplayer.RealPlayerFaction == Faction.OfPlayer) + // Letters are kept in every client's stack and only shown to their faction's players (see MultifactionLetters) + var letter = RecordLetterFaction.receiving; + if (letter != null ? MultifactionLetters.IsVisibleLocally(letter) : Multiplayer.RealPlayerFaction == Faction.OfPlayer) sound.PlayOneShotOnCamera(map); } } @@ -794,25 +785,26 @@ public static bool IsFreeColonistAnyPlayerFaction(Pawn pawn) } +// The pushes are forced: in a world command Faction.OfPlayer already is the gravship's faction while the maps hold +// the spectator's data, so a regular push would be a no-op and the gravship would use the spectator's managers. [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.RemoveGravshipFromMap))] static class RemoveGravshipFromMap_Patch { - static void Prefix(Building_GravEngine engine, out bool __state) + static void Prefix(Building_GravEngine engine, out Map __state) { - __state = false; + __state = null; if (Multiplayer.Client == null) return; if (!Multiplayer.GameComp.multifaction) return; - if (engine == null) return; - - engine.Map.PushFaction(engine.Faction); + if (engine?.Map == null) return; - __state = true; + // Remember the map, the engine is despawned by the time the finalizer runs + __state = engine.Map; + __state.PushFaction(engine.Faction, force: true); } - static void Finalizer(Building_GravEngine engine, bool __state) + static void Finalizer(Map __state) { - if (!__state) return; - engine.Map.PopFaction(); + __state?.PopFaction(); } } @@ -825,7 +817,7 @@ static void Prefix(Gravship gravship, Map map, out bool __state) if (Multiplayer.Client == null) return; if (!Multiplayer.GameComp.multifaction) return; - map.PushFaction(gravship.Faction); + map.PushFaction(gravship.Faction, force: true); __state = true; } @@ -957,3 +949,53 @@ static IEnumerable Transpiler(IEnumerable inst } } } + +// Multifaction: trade request quests (QuestNode_GetNearbySettlement) and caravan/transporter visits could pick another +// player's settlement, which has nothing to trade. Vanilla only excludes the settlements of Faction.OfPlayer. +[HarmonyPatch(typeof(Settlement), nameof(Settlement.Visitable), MethodType.Getter)] +static class Settlement_Visitable_OtherPlayers_Patch +{ + static void Postfix(Settlement __instance, ref bool __result) + { + if (__result && Multiplayer.Client != null && Multiplayer.GameComp.multifaction && + __instance.Faction is { IsPlayer: true }) + __result = false; + } +} + +// The ritual reward "goodwill with the nearest faction" could pick another player's settlement and change the +// goodwill between the two players. Skip player factions only here: CanChangeGoodwillFor itself is also used for +// goodwill between players (e.g. friendly fire), which has to keep working. +[HarmonyPatch(typeof(RitualAttachableOutcomeEffectWorker_NearbyFactionGoodwill), + nameof(RitualAttachableOutcomeEffectWorker_NearbyFactionGoodwill.Apply))] +static class RitualNearbyFactionGoodwill_SkipPlayers_Patch +{ + static IEnumerable Transpiler(IEnumerable insts) + { + var canChangeGoodwillFor = AccessTools.Method(typeof(Faction), nameof(Faction.CanChangeGoodwillFor)); + var replacement = AccessTools.Method(typeof(RitualNearbyFactionGoodwill_SkipPlayers_Patch), nameof(CanChangeGoodwillFor)); + var replaced = false; + + foreach (var ci in insts) + { + if (ci.Calls(canChangeGoodwillFor)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + replaced = true; + } + + yield return ci; + } + + if (!replaced) + Log.Error("Multiplayer: CanChangeGoodwillFor not found in RitualAttachableOutcomeEffectWorker_NearbyFactionGoodwill.Apply"); + } + + static bool CanChangeGoodwillFor(Faction faction, Faction other, int goodwillChange) + { + if (Multiplayer.Client != null && faction is { IsPlayer: true }) + return false; + return faction.CanChangeGoodwillFor(other, goodwillChange); + } +} diff --git a/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs b/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs index 5a5b940bb..ca45ab55c 100644 --- a/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs +++ b/Source/Client/Factions/Page_ChooseIdeo_Multifaction.cs @@ -13,7 +13,16 @@ public class Page_ChooseIdeo_Multifaction : Page public Page_ChooseIdeoPreset pageChooseIdeo = new(); + // The scenario chosen for the new faction, only applied while the page draws (see FactionSidebar.UseScenario) + public Scenario scenario; + public override void DoWindowContents(Rect inRect) + { + using (FactionSidebar.UseScenario(scenario)) + DoContents(inRect); + } + + private void DoContents(Rect inRect) { DrawPageTitle(inRect); float totalHeight = 0f; diff --git a/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs b/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs index 38b0d9685..b6d7e9a8c 100644 --- a/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs +++ b/Source/Client/Factions/Page_ConfigureStartingPawns_Multifaction.cs @@ -1,4 +1,7 @@ +using System.Linq; +using Multiplayer.Client.Util; using RimWorld; +using UnityEngine; using Verse; namespace Multiplayer.Client.Factions @@ -8,14 +11,42 @@ internal class Page_ConfigureStartingPawns_Multifaction : Page_ConfigureStarting Faction _temporaryFactionForPawnCreation; Scenario _scenario; + public Page_ConfigureStartingPawns_Multifaction(Scenario scenario) + { + _scenario = scenario; + } + + // The page's code reads Find.Scenario, which is shared game state used by the simulation. The chosen scenario + // is only applied while the page's own code runs (in the UI, between ticks). + private MpScope UseScenario() => FactionSidebar.UseScenario(_scenario); + public override void PreOpen() { - base.PreOpen(); + using (UseScenario()) + { + base.PreOpen(); + + GenerateTemporaryFaction(); + GeneratePawnsForConfigurePage(); + } + } + + public override void PostOpen() + { + using (UseScenario()) + base.PostOpen(); + } - _scenario = Find.Scenario; + public override void DoWindowContents(Rect rect) + { + using (UseScenario()) + base.DoWindowContents(rect); + } - GenerateTemporaryFaction(); - GeneratePawnsForConfigurePage(); + public override void OnAcceptKeyPressed() + { + using (UseScenario()) + base.OnAcceptKeyPressed(); } public override void PostClose() @@ -27,28 +58,44 @@ public override void PostClose() public override void DoNext() { - CleanUpTemporaryFaction(); + using (UseScenario()) + { + CleanUpTemporaryFaction(); - base.DoNext(); + base.DoNext(); + } } private void CleanUpTemporaryFaction() { if (_temporaryFactionForPawnCreation == null) return; - Find.FactionManager.Remove(_temporaryFactionForPawnCreation); - Find.FactionManager.toRemove.Remove(_temporaryFactionForPawnCreation); - Current.Game.InitData.playerFaction = null; _temporaryFactionForPawnCreation = null; } private void GenerateTemporaryFaction() { + // Generating a faction can register a new ideoligion, which would be live game state on this client only. + // The temporary faction can keep using it unregistered. + var ideos = Find.IdeoManager.ideos; + var prevIdeos = ideos.ToList(); + _temporaryFactionForPawnCreation = FactionGenerator.NewGeneratedFaction(new FactionGeneratorParms(_scenario.playerFaction.factionDef)); _temporaryFactionForPawnCreation.temporary = true; - Find.FactionManager.Add(_temporaryFactionForPawnCreation); + if (!ideos.SequenceEqual(prevIdeos)) + { + ideos.Clear(); + ideos.AddRange(prevIdeos); + } + + // The faction isn't added to the FactionManager: it would be live game state on this client only (ticked, + // iterated by the simulation and saved into join points). Pawn generation only needs it as Faction.OfPlayer, + // which comes from GameInitData.playerFaction. NewGeneratedFaction also added relations with the temporary + // faction to every other faction, remove those. + foreach (var other in Find.FactionManager.AllFactionsListForReading) + other.relations.RemoveAll(r => r.other == _temporaryFactionForPawnCreation); } private void GeneratePawnsForConfigurePage() diff --git a/Source/Client/Factions/QuestMapPatches.cs b/Source/Client/Factions/QuestMapPatches.cs new file mode 100644 index 000000000..c100316a2 --- /dev/null +++ b/Source/Client/Factions/QuestMapPatches.cs @@ -0,0 +1,110 @@ +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Multiplayer.Client.Util; +using RimWorld; +using RimWorld.QuestGen; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Multifaction: quests pick their map among the player homes. Generated for a player faction (e.g. by its +// storyteller), they could still pick another player's map: a map it got in another way than Map.IsPlayerHome's +// settlement check (a gravship landing site), or a map already given in the slate. Exclude the maps of other player +// factions. Generation in the spectator's context (no owner) keeps vanilla's choice. +public static class QuestMapFilter +{ + public static bool IsOtherPlayersMap(Map map) + { + if (map == null || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return false; + + var faction = Faction.OfPlayer; + return faction != null && faction != Multiplayer.WorldComp.spectatorFaction && + map.ParentFaction is { IsPlayer: true } owner && owner != faction; + } +} + +[HarmonyPatch(typeof(QuestNode_GetMap), nameof(QuestNode_GetMap.IsAcceptableMap))] +static class QuestNodeGetMap_OwnMaps_Patch +{ + static void Postfix(Map map, ref bool __result) + { + if (__result && QuestMapFilter.IsOtherPlayersMap(map)) + __result = false; + } +} + +// The map picker of C# quest roots (refugees, beggars, wanderers, DLC arrivals) +[HarmonyPatch] +static class QuestGenGetMap_Validator_OwnMaps_Patch +{ + static MethodBase TargetMethod() => + MpMethodUtil.GetLocalFunc(typeof(QuestGen_Get), nameof(QuestGen_Get.GetMap), localFunc: "Validator"); + + static void Postfix(Map m, ref bool __result) + { + if (__result && QuestMapFilter.IsOtherPlayersMap(m)) + __result = false; + } +} + +[HarmonyPatch(typeof(QuestGen_Get), nameof(QuestGen_Get.GetMap))] +static class QuestGenGetMap_Fallback_OwnMaps_Patch +{ + // The validator excludes the other players' maps, such a result comes from the fallback to the first player home + static void Postfix(ref Map __result) + { + if (QuestMapFilter.IsOtherPlayersMap(__result)) + __result = Find.Maps.FirstOrDefault(m => m.IsPlayerHome && !QuestMapFilter.IsOtherPlayersMap(m)); + } +} + +[HarmonyPatch] +static class ReliquaryPilgrimsMap_OwnMaps_Patch +{ + static MethodBase TargetMethod() => + MpMethodUtil.GetLocalFunc(typeof(QuestNode_Root_ReliquaryPilgrims), nameof(QuestNode_Root_ReliquaryPilgrims.GetMap), localFunc: "Validator"); + + static void Postfix(Map m, ref bool __result) + { + if (__result && QuestMapFilter.IsOtherPlayersMap(m)) + __result = false; + } +} + +// RunInt picks the map with Find.Maps.Where(m => m.IsPlayerHome && GetCandidates(m.Tile).Any()) +[HarmonyPatch] +static class WorkSiteMap_OwnMaps_Patch +{ + static MethodBase TargetMethod() + { + var lambda = MpMethodUtil.GetLambda(typeof(QuestNode_Root_WorkSite), nameof(QuestNode_Root_WorkSite.RunInt), lambdaOrdinal: 0); + if (lambda.ReturnType != typeof(bool) || lambda.GetParameters() is not [{ ParameterType: var param }] || param != typeof(Map)) + throw new System.Exception($"Unexpected map picker lambda in QuestNode_Root_WorkSite.RunInt: {lambda}"); + return lambda; + } + + static void Postfix(Map m, ref bool __result) + { + if (__result && QuestMapFilter.IsOtherPlayersMap(m)) + __result = false; + } +} + +// RunInt has to find a map when TestRunInt passed +[HarmonyPatch(typeof(QuestNode_Root_WorkSite), nameof(QuestNode_Root_WorkSite.TestRunInt))] +static class WorkSiteTestRun_OwnMaps_Patch +{ + static void Postfix(ref bool __result) + { + if (!__result || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) + return; + + // Vanilla's map check, the rest of the test passed + __result = Find.Maps.Any(map => + map.IsPlayerHome && !QuestMapFilter.IsOtherPlayersMap(map) && + QuestNode_Root_WorkSite.AppearanceFrequency(map) > 0f && + QuestNode_Root_WorkSite.GetCandidates(map.Tile).Any()); + } +} diff --git a/Source/Client/Factions/TileFactionContext.cs b/Source/Client/Factions/TileFactionContext.cs deleted file mode 100644 index 5c35965ea..000000000 --- a/Source/Client/Factions/TileFactionContext.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using RimWorld; -using RimWorld.Planet; - -namespace Multiplayer.Client.Factions -{ - public static class TileFactionContext - { - private static readonly Dictionary tileFactions = []; - private static readonly object lockObject = new(); - - public static void SetFactionForTile(PlanetTile tile, Faction faction) - { - lock (lockObject) - { - tileFactions[tile] = faction; - } - } - - public static Faction GetFactionForTile(PlanetTile tile) - { - lock (lockObject) - { - return tileFactions.TryGetValue(tile, out Faction faction) ? faction : null; - } - } - - public static void ClearTile(PlanetTile tile) - { - lock (lockObject) - { - tileFactions.Remove(tile); - } - } - } -} diff --git a/Source/Client/Factions/WorldTickOwnerContext.cs b/Source/Client/Factions/WorldTickOwnerContext.cs new file mode 100644 index 000000000..ab9239704 --- /dev/null +++ b/Source/Client/Factions/WorldTickOwnerContext.cs @@ -0,0 +1,95 @@ +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Verse; + +namespace Multiplayer.Client.Factions; + +// Multifaction: the world tick runs in the spectator's context, where vanilla's Faction.OfPlayer checks match no +// player. Some world tick code decides saved state or sends letters with them, push the player faction it's about. +// The pushes are forced: the world and the maps hold the spectator's data in the world tick. +static class WorldTickOwnerContext +{ + // A map's player faction: its parent's, or else the first player faction (in the faction list) with a humanlike + // pawn on it. Only synced state is read, so it's the same on every client. + public static Faction MapOwner(Map map) + { + if (map == null) + return null; + + if (IsPlayerFaction(map.ParentFaction)) + return map.ParentFaction; + + foreach (var faction in Find.FactionManager.AllFactionsListForReading) + if (IsPlayerFaction(faction) && map.mapPawns.SpawnedPawnsInFaction(faction).Any(p => p.RaceProps.Humanlike)) + return faction; + + return null; + } + + // The first player faction (in the faction list) following the ideo + public static Faction IdeoFollower(Ideo ideo) + { + foreach (var faction in Find.FactionManager.AllFactionsListForReading) + if (IsPlayerFaction(faction) && faction.ideos != null && faction.ideos.Has(ideo)) + return faction; + + return null; + } + + private static bool IsPlayerFaction(Faction faction) => + faction is { IsPlayer: true } && faction != Multiplayer.WorldComp.spectatorFaction; + + public static bool TryPush(Faction faction) + { + if (faction == null) + return false; + + ((Map)null).PushFaction(faction, force: true); + return true; + } + + public static bool Active => Multiplayer.Client != null && Multiplayer.GameComp.multifaction; +} + +// The win check of a caravan battle (ambush or attacked caravan) compares the threats with Faction.OfPlayer and records +// the tale with a free colonist (an error under the spectator, which has none). The battlefield's own faction is the +// enemy's or none, so the world object patches don't push a player. +[HarmonyPatch(typeof(CaravansBattlefield), nameof(CaravansBattlefield.CheckWonBattle))] +static class CaravansBattlefieldCheckWonBattle_OwnerContext +{ + static void Prefix(CaravansBattlefield __instance, ref bool __state) + { + // The check does nothing once won, skip the owner scan + if (WorldTickOwnerContext.Active && !__instance.wonBattle && __instance.HasMap) + __state = WorldTickOwnerContext.TryPush(WorldTickOwnerContext.MapOwner(__instance.Map)); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} + +// Ideos tick in the world tick: date ritual obligations (festivals) need Faction.OfPlayer to follow the ideo (and use +// the time zone of its home), role activity letters go to it. Without a push no player ever got festivals. +// Limitation: with an ideo shared by several player factions only the first one gets the festival and role letters and +// the festival timing of its home (the obligation itself is the ideo's). Before, none of them did. +// The date is still the world's: the trigger fires on an exact tick each world tick, a map clock can pause (firing it +// again) or run ahead (skipping it). +[HarmonyPatch(typeof(Ideo), nameof(Ideo.IdeoTick))] +static class IdeoTick_FollowerContext +{ + static void Prefix(Ideo __instance, ref bool __state) + { + if (WorldTickOwnerContext.Active && AsyncTime.AsyncWorldTimeComp.tickingWorld) + __state = WorldTickOwnerContext.TryPush(WorldTickOwnerContext.IdeoFollower(__instance)); + } + + static void Finalizer(bool __state) + { + if (__state) + FactionExtensions.PopFaction(); + } +} diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 5ea4b1760..212ad3d8f 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -70,7 +70,8 @@ public static class Multiplayer && !ExecutingCmds && !reloading && Current.ProgramState == ProgramState.Playing - && LongEventHandler.currentEvent == null; + && LongEventHandler.currentEvent == null + && !SimLongEventContext.InSimulation; public static string ReplaysDir => GenFilePaths.FolderUnderSaveData("MpReplays"); public static string DesyncsDir => GenFilePaths.FolderUnderSaveData("MpDesyncs"); @@ -81,8 +82,27 @@ public static class Multiplayer public static bool arbiterInstance; public static bool loadingErrors; + public static int loadingErrorCount; + // The first few, shown before hosting or joining + public static readonly List loadingErrorMessages = new(); + private const int MaxShownLoadingErrors = 20; public static Stopwatch harmonyWatch = new(); + /// A patch or sync registration failed to load. Playing like that likely desyncs or breaks features, so + /// hosting and joining ask for a confirmation first. + public static void LoadingError(string message) + { + Log.Error(message); + + lock (loadingErrorMessages) + { + loadingErrors = true; + loadingErrorCount++; + if (loadingErrorMessages.Count < MaxShownLoadingErrors) + loadingErrorMessages.Add(message); + } + } + public static ModContentPack modContentPack; public static void InitMultiplayer(ModContentPack content) @@ -220,8 +240,7 @@ public static void StopMultiplayer() if (LocalServer != null) { - LocalServer.running = false; - localServerThread?.Join(); + // Waits for the server thread, which stops the server on its way out LocalServer.TryStop(); LocalServer = null; } diff --git a/Source/Client/MultiplayerData.cs b/Source/Client/MultiplayerData.cs index 0212296c9..f5cda0b10 100644 --- a/Source/Client/MultiplayerData.cs +++ b/Source/Client/MultiplayerData.cs @@ -70,6 +70,13 @@ public static IEnumerable GetModAssemblies(ModContentPack mod) .Select(t => t.Item2); } + // Multiplayer's own code (Multiplayer.dll, MultiplayerCommon.dll) is loaded by MultiplayerLoader from here + public static IEnumerable GetModCustomAssemblies(ModContentPack mod) + { + return ModContentPack.GetAllFilesForModPreserveOrder(mod, "AssembliesCustom/", f => f.ToLower() == ".dll") + .Select(t => t.Item2); + } + public static ModContentPack DummyContentPack(ModMetaData mod) { // The constructor calls InitLoadFolders diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 49519f9b9..e38bfcf88 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; @@ -325,10 +324,26 @@ void TryPatch(MethodBase original, HarmonyMethod prefix = null, HarmonyMethod po { var randPatchPrefix = new HarmonyMethod(typeof(RandPatches), nameof(RandPatches.Prefix)); var randPatchFinalizer = new HarmonyMethod(typeof(RandPatches), nameof(RandPatches.Finalizer)); + var randPatchPrefixMainThread = new HarmonyMethod(typeof(RandPatches), nameof(RandPatches.PrefixMainThread)); var subSustainerStart = MpMethodUtil.GetLambda(typeof(SubSustainer), parentMethodType: MethodType.Constructor, parentArgs: new[] { typeof(Sustainer), typeof(SubSoundDef) }); var sampleCtor = typeof(Sample).GetConstructor(new[] { typeof(SubSoundDef) }); var subSoundPlay = typeof(SubSoundDef).GetMethod(nameof(SubSoundDef.TryPlay)); + // Whether these consume RNG depends on the local sound manager state (camera, frame timing) + var soundMethods = new MethodBase[] + { + typeof(SoundStarter).GetMethod(nameof(SoundStarter.PlayOneShot)), + typeof(SoundStarter).GetMethod(nameof(SoundStarter.TrySpawnSustainer)), + typeof(Sustainer).GetConstructor(new[] { typeof(SoundDef), typeof(SoundInfo) }), + typeof(SustainerAggregatorUtility).GetMethod(nameof(SustainerAggregatorUtility.AggregateOrSpawnSustainerFor)), + typeof(SoundSizeAggregator).GetConstructor(Type.EmptyTypes), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.ForceTriggerTransition)), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.ForceTriggerNextSongOrSequence)), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.ForcePlaySong)), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.ForceFadeoutAndSilenceFor)), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.StartNewSong)), + typeof(MusicManagerPlay).GetMethod(nameof(MusicManagerPlay.ScheduleNewSong)), + }; var effecterTick = typeof(Effecter).GetMethod(nameof(Effecter.EffectTick)); var effecterTrigger = typeof(Effecter).GetMethod(nameof(Effecter.Trigger)); var effecterCleanup = typeof(Effecter).GetMethod(nameof(Effecter.Cleanup)); @@ -353,6 +368,9 @@ void TryPatch(MethodBase original, HarmonyMethod prefix = null, HarmonyMethod po foreach (MethodBase m in effectMethods.Concat(moteMethods).Concat(fleckMethods).Concat(ritualMethods)) TryPatch(m, randPatchPrefix, finalizer: randPatchFinalizer); + + foreach (MethodBase m in soundMethods) + TryPatch(m, randPatchPrefixMainThread, finalizer: randPatchFinalizer); } SetCategory("Non-deterministic patches 2"); diff --git a/Source/Client/Networking/HostUtil.cs b/Source/Client/Networking/HostUtil.cs index 0b736217e..e2a8c57dc 100644 --- a/Source/Client/Networking/HostUtil.cs +++ b/Source/Client/Networking/HostUtil.cs @@ -7,6 +7,7 @@ using Multiplayer.Client.AsyncTime; using Multiplayer.Client.Comp; using Multiplayer.Client.Networking; +using Multiplayer.Client.Patches; using Multiplayer.Client.Util; using Multiplayer.Common; using RimWorld; @@ -38,6 +39,9 @@ public static async ClientTask HostServer(ServerSettings settings, bool fromRepl PrepareGame(); SetGameState(settings); + // Viewer counts saved in a replay belong to the players of that session + VTRSync.ResetAllPlayerCounts(); + Multiplayer.session.dataSnapshot = await CreateGameData(); MakeHostOnServer(serverConn); @@ -46,6 +50,7 @@ public static async ClientTask HostServer(ServerSettings settings, bool fromRepl SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, false); StartLocalServer(); + VTRSync.AnnounceCurrentView(); } private static void CreateSession(ServerSettings settings) => diff --git a/Source/Client/Networking/JoinData.cs b/Source/Client/Networking/JoinData.cs index c123d9c2a..e4a57b5b6 100644 --- a/Source/Client/Networking/JoinData.cs +++ b/Source/Client/Networking/JoinData.cs @@ -72,13 +72,23 @@ public static ModMetaData GetInstalledMod(string id) return ModLister.GetModWithIdentifier(id); } + // Translated text is generated and saved during the simulation (names, letters) and name generation retries + // depend on it, so clients with different game languages can desync + public static string LocalLanguage => LanguageDatabase.activeLanguage?.folderName ?? ""; + + public static bool LanguageMatches(string remoteLanguage) => + remoteLanguage == LocalLanguage || + !remoteLanguage.NullOrEmpty() && remoteLanguage == LanguageDatabase.activeLanguage?.LegacyFolderName; + public static bool CompareToLocal(RemoteData remote) { return remote.remoteRwVersion == VersionControl.CurrentVersionString && + LanguageMatches(remote.remoteLanguage) && remote.CompareMods(activeModsSnapshot) == ModListDiff.None && remote.remoteFiles.DictsEqual(modFilesSnapshot) && - (!remote.hasConfigs || remote.remoteModConfigs.EqualAsSets(SyncConfigs.GetSyncableConfigContents(remote.RemoteModIds.ToList()))); + (!remote.hasConfigs || remote.remoteModConfigs.Select(c => c.Comparable()).EqualAsSets( + SyncConfigs.GetSyncableConfigContents(remote.RemoteModIds.ToList()).Select(c => c.Comparable()))); } internal static void TakeModDataSnapshot() @@ -98,16 +108,19 @@ internal static ModFileDict GetModFiles(IEnumerable modIds) var contentPack = MultiplayerData.DummyContentPack(mod); - foreach (var asm in MultiplayerData.GetModAssemblies(contentPack)) + // Include AssembliesCustom so that different Multiplayer builds show up as a mismatch + foreach (var asm in MultiplayerData.GetModAssemblies(contentPack) + .Concat(MultiplayerData.GetModCustomAssemblies(contentPack))) { var relPath = asm.FullName.RemovePrefix(contentPack.RootDir).NormalizePath(); fileDict.Add(modId, new ModFile(asm.FullName, relPath, asm.CRC32())); } + // The XML parser normalizes line endings, so a mod copied between Windows and Linux isn't a mismatch foreach (var xmlFile in GetModDefsAndPatches(contentPack)) { var relPath = xmlFile.FullName.RemovePrefix(contentPack.RootDir).NormalizePath(); - fileDict.Add(modId, new ModFile(xmlFile.FullName, relPath, xmlFile.CRC32())); + fileDict.Add(modId, new ModFile(xmlFile.FullName, relPath, xmlFile.CRC32IgnoringLineEndings())); } } @@ -148,6 +161,7 @@ public class RemoteData { public string remoteRwVersion; public string remoteMpVersion; + public string remoteLanguage; public List remoteMods = new(); public ModFileDict remoteFiles = new(); @@ -176,6 +190,7 @@ public static RemoteData FromNet(ServerJoinDataPacket packet) { remoteRwVersion = packet.rwVersion, remoteMpVersion = packet.mpVersion, + remoteLanguage = packet.language, hasConfigs = packet.configsIncluded, }; @@ -219,7 +234,13 @@ public enum ModListDiff None, NoMatchAsSets, WrongOrder } - public record ModConfig(string ModId, string FileName, string Contents); + public record ModConfig(string ModId, string FileName, string Contents) + { + // Mods write their settings with the platform's line endings, the same settings shouldn't be a mismatch + public string ComparableContents => Contents?.Replace("\r\n", "\n").Replace('\r', '\n'); + + public ModConfig Comparable() => this with { Contents = ComparableContents }; + } public class ModFileDict : IEnumerable>> { diff --git a/Source/Client/Networking/NetworkingSteam.cs b/Source/Client/Networking/NetworkingSteam.cs index 3f9534a61..a64d3719a 100644 --- a/Source/Client/Networking/NetworkingSteam.cs +++ b/Source/Client/Networking/NetworkingSteam.cs @@ -42,14 +42,6 @@ public void SendRawSteam(byte[] raw, bool reliable) public abstract void OnError(EP2PSessionError error); - protected override void OnClose(ServerDisconnectPacket? goodbye) - { - if (goodbye.HasValue) Send(goodbye.Value); - // TODO this should probably include SteamNetworking.CloseP2PSessionWithUser to free up any leftover - // resources in the Steam API. The API docs are not clear whether the connection is closed instantly, or - // are the queued packets sent. - } - public override string ToString() => $"SteamP2P ({remoteId}:{username})"; } @@ -57,9 +49,14 @@ public class SteamClientConn : SteamBaseConn, ITickableConnection { public SteamClientConn(CSteamID remoteId, string username) : base(remoteId, RandomChannelId(), 0) { + // A server hosted here before may still be about to close its session with this remote + SteamP2PIntegration.KeepSession(remoteId); ChangeState(new ClientSteamState(this, username)); } + // The client never sends a goodbye + protected override void OnClose(ServerDisconnectPacket? goodbye) => SteamP2PIntegration.CloseSession(remoteId); + static ushort RandomChannelId() => (ushort)new Random().Next(); public void Tick() @@ -97,6 +94,19 @@ public override void OnError(EP2PSessionError error) OnDisconnect(); } + protected override void OnClose(ServerDisconnectPacket? goodbye) + { + if (goodbye.HasValue) + { + Send(goodbye.Value); + SteamP2PIntegration.CloseSessionAfterFlush(remoteId); + } + // A disconnected connection may have been replaced by a new one from the same remote, which uses the + // same session (see SteamP2PNetManager.Tick) + else if (State != ConnectionStateEnum.Disconnected) + SteamP2PIntegration.CloseSession(remoteId); + } + public override void OnKeepAliveArrived(bool idMatched) { if (!idMatched) return; @@ -108,6 +118,8 @@ public override void OnKeepAliveArrived(bool idMatched) private void OnDisconnect() { + // The session failed, free it so that a reconnect asks for a new one + SteamP2PIntegration.CloseSession(remoteId); serverPlayer.Server.playerManager.SetDisconnected(this, MpDisconnectReason.ClientLeft); } } @@ -126,21 +138,33 @@ public static SteamP2PNetManager Create(MultiplayerServer server) public void Tick() { + var playerManager = server.playerManager; + foreach (var packet in SteamP2PIntegration.ReadPackets(0)) { - var playerManager = server.playerManager; - var player = playerManager.Players - .FirstOrDefault(p => p.conn is SteamBaseConn conn && conn.remoteId == packet.remote); + var player = FindPlayer(packet.remote); - if (packet.joinPacket && player == null) + if (packet.joinPacket) { ConnectionBase conn = new SteamServerConn(packet.remote, packet.channel); + // The join packet arrived on the session of the remote's earlier connection: a goodbye sent on + // it is out by now and closing would cut off this one + SteamP2PIntegration.KeepSession(packet.remote); + + // A client sends the join packet once per connection, so the remote's connection we still + // have is stale (e.g. they restarted the game before it timed out). Without replacing it the + // join would be dropped and the client would wait for the host forever (#843). + if (player != null) + playerManager.ReplaceStale(player, conn); + var preConnect = playerManager.OnPreConnect(packet.remote); if (preConnect != null) { ServerLog.Log($"Rejected incoming connection from {packet.remote}: {preConnect}"); - conn.Close(preConnect.Value); + // Close doesn't send a goodbye before the connection has a server state + conn.Send(new ServerDisconnectPacket { reason = preConnect.Value, data = [] }); + SteamP2PIntegration.CloseSessionAfterFlush(packet.remote); continue; } @@ -155,18 +179,25 @@ public void Tick() conn.Send(Packets.Server_SteamAccept); } - else if (!packet.joinPacket && player != null) + else if (player != null) { player.HandleReceive(packet.data, packet.reliable); } else { - ServerLog.Error( - $"Received a join packet: {packet.joinPacket} for player: {player} (player should only be null when joinPacket is true)"); + // Packets still in flight when the server dropped the connection + ServerLog.Verbose($"Received a data packet from {packet.remote}, who has no connection"); } } + + // Closed here rather than on a timer so that it can't race a reconnect, and after reading the packets so + // that a reconnect's join packet already waiting on the session cancels the close first + SteamP2PIntegration.CloseDueSessions(remote => FindPlayer(remote) != null); } + private ServerPlayer FindPlayer(CSteamID remote) => + server.playerManager.Players.FirstOrDefault(p => p.conn is SteamBaseConn conn && conn.remoteId == remote); + public void Stop() { // Managed externally by Steamworks @@ -181,6 +212,41 @@ public static class SteamP2PIntegration { private static Callback p2pFail; + // Closing a P2P session discards the packets Steam hasn't sent yet, a goodbye gets this long to go out + private const int GoodbyeFlushMs = 3000; + private static readonly PendingSessionCloses pendingCloses = new(); + + /// Frees the P2P session with the remote. Their next connection then needs a new session, which shows the + /// host's accept prompt again, instead of silently reusing the old one (#843). + public static void CloseSession(CSteamID remote) + { + pendingCloses.Cancel(remote); + ServerLog.Log($"Closing Steam P2P session with {remote}"); + SteamNetworking.CloseP2PSessionWithUser(remote); + } + + public static void CloseSessionAfterFlush(CSteamID remote) => pendingCloses.Schedule(remote, GoodbyeFlushMs); + + /// The remote's session is in use by a new connection + public static void KeepSession(CSteamID remote) + { + if (pendingCloses.Cancel(remote)) + ServerLog.Verbose($"Keeping Steam P2P session with {remote} for a new connection"); + } + + /// Runs on the thread of the server handling Steam joins, or on the main thread when no such server runs. + /// The remote reconnected in the meantime and their connection uses the session + public static void CloseDueSessions(Func inUse = null) + { + foreach (var remote in pendingCloses.TakeDue()) + { + if (inUse?.Invoke(remote) == true) continue; + + ServerLog.Log($"Closing Steam P2P session with {remote} after its goodbye"); + SteamNetworking.CloseP2PSessionWithUser(remote); + } + } + public static void InitCallbacks() { p2pFail = Callback.Create(fail => diff --git a/Source/Client/Networking/State/ClientBaseState.cs b/Source/Client/Networking/State/ClientBaseState.cs index 0eb6bc1ba..b45e8516b 100644 --- a/Source/Client/Networking/State/ClientBaseState.cs +++ b/Source/Client/Networking/State/ClientBaseState.cs @@ -28,6 +28,11 @@ public void HandleTimeControl(ServerTimeControlPacket packet) Multiplayer.session.ProcessTimeControl(); } + // Handled in every state: it can arrive while rejoining + [TypedPacketHandler] + public void HandleSyncReference(ServerSyncReferencePacket packet) => + Multiplayer.session.isSyncReference = packet.isReference; + // Currently handles disconnection only for Steam connections. See comment in ConnectionBase.Close for more info. [TypedPacketHandler] public virtual void HandleDisconnected(ServerDisconnectPacket packet) diff --git a/Source/Client/Networking/State/ClientJoiningState.cs b/Source/Client/Networking/State/ClientJoiningState.cs index 0b74bab3b..227109cd2 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 = JoinData.LocalLanguage, 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, @@ -83,7 +84,7 @@ public void HandleJoinData(ServerJoinDataPacket packet) Multiplayer.session.gameName = packet.gameName; Multiplayer.session.playerId = packet.playerId; - var defDiff = false; + var defDiff = packet.missingDefs.Length > 0; var defStatusMap = new Dictionary(); var i = 0; foreach (var local in MultiplayerData.localDefInfos) @@ -95,6 +96,8 @@ public void HandleJoinData(ServerJoinDataPacket packet) defDiff = true; } + var missingDefs = packet.missingDefs; + var remoteInfo = RemoteData.FromNet(packet); // Captured before Complete runs: StopMultiplayerAndClearAllWindows nulls the session var connector = Multiplayer.session.connector; @@ -116,8 +119,11 @@ void Complete() var defDiffStr = "\n\n" + MultiplayerData.localDefInfos .Select(kv => (name: kv.Key, def: kv.Value, status: defStatusMap[kv.Value])) .Where(kv => kv.status != DefCheckStatus.Ok) + .Select(kv => $"{kv.name}: {kv.status}") + // Def types only the server has + .Concat(missingDefs.Select(name => $"{name}: Missing")) .Take(10) - .Join(kv => $"{kv.name}: {kv.status}", "\n"); + .Join(s => s, "\n"); Find.WindowStack.Add(new JoinDataWindow(remoteInfo, connector) { diff --git a/Source/Client/Networking/State/ClientLoadingState.cs b/Source/Client/Networking/State/ClientLoadingState.cs index 2622c9611..c2148fed0 100644 --- a/Source/Client/Networking/State/ClientLoadingState.cs +++ b/Source/Client/Networking/State/ClientLoadingState.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Linq; using Ionic.Zlib; +using Multiplayer.Client.Patches; using Multiplayer.Client.Saving; using Multiplayer.Common; using Verse; @@ -70,6 +71,14 @@ public void HandleWorldData(ByteReader data) int factionId = data.ReadInt32(); Multiplayer.session.myFactionId = factionId; + // The server knows this connection's view from scratch (also after a rejoin, which keeps the session) + VTRSync.Reset(); + + // The server only picks caught up players as the sync reference + Multiplayer.session.isSyncReference = false; + // Also on a rejoin, which keeps the session + SimulationFailures.Reset(Multiplayer.session); + int tickUntil = data.ReadInt32(); int remoteSentCmds = data.ReadInt32(); bool serverFrozen = data.ReadBool(); @@ -77,22 +86,14 @@ public void HandleWorldData(ByteReader data) byte[] worldData = GZipStream.UncompressBuffer(data.ReadPrefixedBytes()); byte[] sessionData = GZipStream.UncompressBuffer(data.ReadPrefixedBytes()); - var mapCmdsDict = new Dictionary>(); var mapDataDict = new Dictionary(); List mapsToLoad = new List(); - int mapCmdsCount = data.ReadInt32(); - for (int i = 0; i < mapCmdsCount; i++) - { - int mapId = data.ReadInt32(); - - int mapCmdsLen = data.ReadInt32(); - List mapCmds = new List(mapCmdsLen); - for (int j = 0; j < mapCmdsLen; j++) - mapCmds.Add(ScheduledCommand.Deserialize(new ByteReader(data.ReadPrefixedBytes()))); - - mapCmdsDict[mapId] = mapCmds; - } + // World and map cmds in the order live clients executed them + int cmdsCount = data.ReadInt32(); + var cmds = new List(cmdsCount); + for (int i = 0; i < cmdsCount; i++) + cmds.Add(ScheduledCommand.Deserialize(new ByteReader(data.ReadPrefixedBytes()))); int mapDataCount = data.ReadInt32(); for (int i = 0; i < mapDataCount; i++) @@ -110,7 +111,7 @@ public void HandleWorldData(ByteReader data) worldData, sessionData, mapDataDict, - mapCmdsDict + cmds ); TickPatch.tickUntil = tickUntil; diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs index 6a96635a5..76b19627f 100644 --- a/Source/Client/Networking/State/ClientPlayingState.cs +++ b/Source/Client/Networking/State/ClientPlayingState.cs @@ -140,12 +140,11 @@ public void HandleMapResponse(ByteReader data) { int mapId = data.ReadInt32(); + // Map streaming is disabled (MultiplayerServer.CanUseStandaloneMapStreaming). Streamed map cmds can't be + // placed in the global command order, so they are skipped here. int mapCmdsLen = data.ReadInt32(); - List mapCmds = new List(mapCmdsLen); for (int j = 0; j < mapCmdsLen; j++) - mapCmds.Add(ScheduledCommand.Deserialize(new ByteReader(data.ReadPrefixedBytes()))); - - Session.dataSnapshot.MapCmds[mapId] = mapCmds; + data.ReadPrefixedBytes(); byte[] mapData = GZipStream.UncompressBuffer(data.ReadPrefixedBytes()); Session.dataSnapshot.MapData[mapId] = mapData; @@ -185,12 +184,14 @@ public void HandleTraces(ServerTracesPacket packet) var info = Multiplayer.game.sync.knownClientOpinions.FirstOrDefault(b => b.startTick == packet.tick); var response = info?.GetFormattedStackTracesForRange(packet.diffAt) ?? "Traces not available"; - connection.Send(new ClientTracesPacket + // Fragmented: the traces and jitted methods can exceed the single packet limit, which would throw + // in this handler and disconnect this client (on standalone, the sync reference) + connection.SendFragmented(new ClientTracesPacket { playerId = packet.playerId, rawTraces = GZipStream.CompressString(response), rawJittedMethods = GZipStream.CompressString(JittedMethods.GetJittedMethodsString()) - }); + }.Serialize()); } else if (packet.mode == ServerTracesPacket.Mode.Transfer) { diff --git a/Source/Client/Networking/State/ClientSteamState.cs b/Source/Client/Networking/State/ClientSteamState.cs index ee8b5305d..25b9ce446 100644 --- a/Source/Client/Networking/State/ClientSteamState.cs +++ b/Source/Client/Networking/State/ClientSteamState.cs @@ -1,5 +1,6 @@ using Multiplayer.Client.Networking; using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Client { @@ -19,6 +20,14 @@ public void HandleSteamAccept(ByteReader data) { connection.ChangeState(new ClientJoiningState(connection, username)); } + + // The server turned the connection away (server full, starting, throttled) + [TypedPacketHandler] + public void HandleDisconnected(ServerDisconnectPacket packet) + { + ConnectionStatusListeners.TryNotifyAll_Disconnected(SessionDisconnectInfo.From(packet)); + Multiplayer.StopMultiplayer(); + } } } diff --git a/Source/Client/Networking/SteamIntegration.cs b/Source/Client/Networking/SteamIntegration.cs index 8bfa3f7cc..47f8ce6bb 100644 --- a/Source/Client/Networking/SteamIntegration.cs +++ b/Source/Client/Networking/SteamIntegration.cs @@ -32,25 +32,49 @@ public static void InitCallbacks() sessionReq = Callback.Create(req => { - ServerLog.Log($"Received P2P session request from {req.m_steamIDRemote}"); + var remoteId = req.m_steamIDRemote; + ServerLog.Log($"Received P2P session request from {remoteId}"); var session = Multiplayer.session; - if (Multiplayer.LocalServer?.settings.steam == true && !session.pendingSteam.Contains(req.m_steamIDRemote)) + if (Multiplayer.LocalServer?.settings.steam != true || session == null) return; + + // The client's join packet is reliable and Steam keeps asking while it's resent: don't prompt again + // right after a reject + if (session.rejectedSteam.TryGetValue(remoteId, out var rejectedAt) && + Time.realtimeSinceStartup - rejectedAt < RejectedRequestCooldownSecs) + return; + + // The remote opens a new session, a delayed close of the old one would tear it down + SteamP2PIntegration.KeepSession(remoteId); + + if (Multiplayer.settings.autoAcceptSteam) + { + SteamNetworking.AcceptP2PSessionWithUser(remoteId); + // A prompt from before auto accept was turned on + session.pendingSteam.Remove(remoteId); + } + // Steam repeats the request while the remote keeps sending, the prompt for it is still unanswered + else if (session.pendingSteam.Contains(remoteId)) + { + return; + } + else { - if (Multiplayer.settings.autoAcceptSteam) - SteamNetworking.AcceptP2PSessionWithUser(req.m_steamIDRemote); - else + session.pendingSteam.Add(remoteId); + PendingPlayerWindow.EnqueueJoinRequest(remoteId, (joinReq, accepted) => { - session.pendingSteam.Add(req.m_steamIDRemote); - PendingPlayerWindow.EnqueueJoinRequest(req.m_steamIDRemote, (joinReq, accepted) => - { - if(joinReq.steamId.HasValue && accepted) AcceptPlayerJoinRequest(joinReq.steamId.Value); - }); - } - session.knownUsers.Add(req.m_steamIDRemote); - session.NotifyChat(); - - SteamFriends.RequestUserInformation(req.m_steamIDRemote, true); + if (!joinReq.steamId.HasValue) return; + if (accepted) + AcceptPlayerJoinRequest(joinReq.steamId.Value); + else + RejectPlayerJoinRequest(session, joinReq.steamId.Value); + }); } + + if (!session.knownUsers.Contains(remoteId)) + session.knownUsers.Add(remoteId); + session.NotifyChat(); + + SteamFriends.RequestUserInformation(remoteId, true); }); friendRchpUpdate = Callback.Create(update => @@ -61,7 +85,8 @@ public static void InitCallbacks() { if (Current.Game == null) { - ClientUtil.TryConnectWithWindow(ConnectorRegistry.Steam(req.m_steamIDFriend), false); + MainMenuPatch.ConfirmLoadingErrors(() => + ClientUtil.TryConnectWithWindow(ConnectorRegistry.Steam(req.m_steamIDFriend), false)); } else { @@ -82,10 +107,22 @@ public static void InitCallbacks() Callback.Create(loaded => SteamImages.GetTexture(loaded.m_iImage, force: true)); } + private const float RejectedRequestCooldownSecs = 30f; + + private static void RejectPlayerJoinRequest(MultiplayerSession session, CSteamID id) + { + // Forget the request, or the player's later ones would be ignored as duplicates of it + session.pendingSteam.Remove(id); + session.rejectedSteam[id] = Time.realtimeSinceStartup; + // Drops what the player sent so far + SteamP2PIntegration.CloseSession(id); + } + public static void AcceptPlayerJoinRequest(CSteamID id) { SteamNetworking.AcceptP2PSessionWithUser(id); - Multiplayer.session.pendingSteam.Remove(id); + Multiplayer.session?.pendingSteam.Remove(id); + Multiplayer.session?.rejectedSteam.Remove(id); Messages.Message("MpSteamAccepted".Translate(), MessageTypeDefOf.PositiveEvent, false); } diff --git a/Source/Client/OnMainThread.cs b/Source/Client/OnMainThread.cs index 866c50248..cef40efc3 100644 --- a/Source/Client/OnMainThread.cs +++ b/Source/Client/OnMainThread.cs @@ -3,6 +3,7 @@ using Multiplayer.Client.DebugUi; using Multiplayer.Client.Desyncs; using Multiplayer.Client.Networking; +using Multiplayer.Client.Patches; using Multiplayer.Client.Util; using Multiplayer.Common; using UnityEngine; @@ -27,12 +28,19 @@ public void Update() RunScheduled(); if (SteamManager.Initialized) + { SteamIntegration.UpdateRichPresence(); + // A running Steam server closes them on its thread, in step with the joins it handles + if (Multiplayer.LocalServer is not { running: true, settings.steam: true }) + SteamP2PIntegration.CloseDueSessions(); + } + if (Multiplayer.Client == null) return; Multiplayer.session.Update(); SyncFieldUtil.UpdateSync(); + VTRSync.UpdateView(); if (PerformanceRecorder.IsRecording) { diff --git a/Source/Client/Patches/Alerts.cs b/Source/Client/Patches/Alerts.cs index a0b9982e1..4ae4abad9 100644 --- a/Source/Client/Patches/Alerts.cs +++ b/Source/Client/Patches/Alerts.cs @@ -4,6 +4,10 @@ using System.Collections.Generic; using RimWorld; using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using Multiplayer.Client.Patches; +using UnityEngine; namespace Multiplayer.Client; @@ -47,3 +51,47 @@ public static void Postfix(ref List __result) __result = __result.Where(pawn => !pawn.Faction.IsPlayer || pawn.MapHeld.ParentFaction == Faction.OfPlayer).ToList(); } } + +// Need_Food.TicksStarving compares TicksGame with a timestamp of the pawn's own clock (its map's or, in caravans, the +// world's). The alerts run at the current map's time, which with async time made animals on other maps look starving +// (or not) depending on which map is viewed. Interface only: these alerts don't run in the simulation. +[HarmonyPatch] +public static class AnimalHungerAlerts_OwnTime_Patch +{ + static IEnumerable TargetMethods() + { + yield return AccessTools.PropertyGetter(typeof(Alert_StarvationAnimals), nameof(Alert_StarvationAnimals.StarvingAnimals)); + yield return AccessTools.Method(typeof(Alert_PennedAnimalHungry), nameof(Alert_PennedAnimalHungry.CalculateTargets)); + yield return AccessTools.Method(typeof(Alert_HitchedAnimalHungryNoFood), nameof(Alert_HitchedAnimalHungryNoFood.CalculateTargets)); + } + + static IEnumerable Transpiler(IEnumerable insts, MethodBase original) + { + var ticksStarving = AccessTools.PropertyGetter(typeof(Need_Food), nameof(Need_Food.TicksStarving)); + var replacement = AccessTools.Method(typeof(AnimalHungerAlerts_OwnTime_Patch), nameof(TicksStarving)); + var replaced = false; + + foreach (var ci in insts) + { + if (ci.Calls(ticksStarving)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + replaced = true; + } + + yield return ci; + } + + if (!replaced) + Log.Error($"Multiplayer: Need_Food.TicksStarving not found in {original.DeclaringType?.Name}.{original.Name}"); + } + + static int TicksStarving(Need_Food food) + { + if (Multiplayer.Client == null || !Multiplayer.InInterface) + return food.TicksStarving; + + return Mathf.Max(0, StatWorkerGetValuePatch.TicksGameFor(Find.TickManager.TicksGame, food.pawn) - food.lastNonStarvingTick); + } +} diff --git a/Source/Client/Patches/Anomaly.cs b/Source/Client/Patches/Anomaly.cs index aa6a4d553..445bc05f1 100644 --- a/Source/Client/Patches/Anomaly.cs +++ b/Source/Client/Patches/Anomaly.cs @@ -1,9 +1,12 @@ 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.Group; namespace Multiplayer.Client.Patches { @@ -33,7 +36,99 @@ static CellRect EmptyCellRect() return Multiplayer.Client != null ? CellRect.Empty : Find.CameraDriver.CurrentViewRect; } - static void Prefix() => Rand.PushState(Find.TickManager.TicksAbs); - static void Postfix() => Rand.PopState(); + // Seed per pawn so that multiple calls in one tick don't all pick the same cell + static void Prefix(Pawn pawn, ref bool __state) + { + if (Multiplayer.Client == null) return; + Rand.PushState(Gen.HashCombineInt(pawn?.thingIDNumber ?? 0, Find.TickManager.TicksAbs)); + __state = true; + } + + static void Finalizer(bool __state) + { + if (__state) + Rand.PopState(); + } + } + + // #959: vanilla checks whether the local camera sees the cell (map == Find.CurrentMap && view.Contains). + // An empty view makes the check camera independent; covers the container escape in UnnaturalCorpseTracker. + [HarmonyPatch(typeof(AnomalyUtility), nameof(AnomalyUtility.IsValidUnseenCell))] + static class AnomalyUtility_IsValidUnseenCell + { + static void Prefix(ref CellRect view) + { + if (Multiplayer.Client != null) + view = CellRect.Empty; + } + } + + // #959: the corpse teleports/disappears only when the local camera doesn't see it. + // Keep the reservation check (shared state), treat the corpse as never seen by a camera. + [HarmonyPatch(typeof(UnnaturalCorpse), nameof(UnnaturalCorpse.IsOutsideView))] + static class UnnaturalCorpse_IsOutsideView + { + static bool Prefix(UnnaturalCorpse __instance, ref bool __result) + { + if (Multiplayer.Client == null) + return true; + + __result = !(__instance.SpawnedOrAnyParentSpawned && ReservedByAnyPlayer(__instance)); + return false; + } + + // Faction.OfPlayer is the spectator when ticking a map no player faction owns (multifaction), so check the + // reservations of every player faction + static bool ReservedByAnyPlayer(UnnaturalCorpse corpse) + { + var reservations = corpse.MapHeld.reservationManager; + foreach (var faction in Find.FactionManager.AllFactionsListForReading) + if (faction.IsPlayer && reservations.IsReservedByAnyoneOf(corpse, faction)) + return true; + return false; + } + } + + // #855: vanilla moves the offering out of the invoker's inventory while enumerating it; moving a whole stack + // throws (collection modified) and the psychic ritual never completes. Same logic over a snapshot, holding no + // more than the required count. + [HarmonyPatch(typeof(PsychicRitualToil_InvokeHorax), nameof(PsychicRitualToil_InvokeHorax.HoldRequiredOfferings))] + static class PsychicRitualToil_InvokeHorax_HoldRequiredOfferings + { + static bool Prefix(PsychicRitualToil_InvokeHorax __instance, PsychicRitual psychicRitual) + { + if (Multiplayer.Client == null) + return true; + + var offering = __instance.requiredOffering; + if (offering == null) + return false; + + int required = Mathf.CeilToInt(offering.GetBaseCount()); + + foreach (Pawn pawn in psychicRitual.assignments.AssignedPawns(__instance.invokerRole)) + { + foreach (Thing thing in pawn.inventory.GetDirectlyHeldThings().ToList()) + { + int missing = required - HeldOfferingCount(pawn, offering); + if (missing <= 0) + break; + + if (offering.filter.Allows(thing)) + pawn.inventory.innerContainer.TryTransferToContainer(thing, pawn.carryTracker.innerContainer, missing); + } + } + + return false; + } + + static int HeldOfferingCount(Pawn pawn, IngredientCount offering) + { + int count = 0; + foreach (Thing held in pawn.carryTracker.innerContainer) + if (offering.filter.Allows(held)) + count += held.stackCount; + return count; + } } } \ No newline at end of file diff --git a/Source/Client/Patches/ArbiterPatches.cs b/Source/Client/Patches/ArbiterPatches.cs index 3314cb9e3..16455ad71 100644 --- a/Source/Client/Patches/ArbiterPatches.cs +++ b/Source/Client/Patches/ArbiterPatches.cs @@ -71,6 +71,19 @@ static IEnumerable TargetMethods() static bool Prefix() => !Multiplayer.arbiterInstance; } + // A synchronous long event with a loading window only starts after the window was drawn once. In batch mode + // windows on the window stack are never drawn, so in-game events like join point creation never ran: the + // arbiter stopped simulating and the server stalled everyone waiting for it. + [HarmonyPatch(typeof(LongEventHandler), nameof(LongEventHandler.LongEventsUpdate))] + static class ArbiterLongEventsDontWaitForWindow + { + static void Prefix() + { + if (Multiplayer.arbiterInstance && LongEventHandler.currentEvent != null) + LongEventHandler.currentEvent.alreadyDisplayed = true; + } + } + // TODO test if this works. [HarmonyPatch(typeof(WorldGrid), (nameof(WorldGrid.InitializeGlobalLayers)))] static class NoWorldRenderLayersForArbiter diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index 9bdf674e2..64ac7d429 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Reflection; using System.Reflection.Emit; +using System.Runtime.CompilerServices; using Multiplayer.Client.AsyncTime; using Multiplayer.Client.Util; using RimWorld.QuestGen; @@ -50,16 +51,113 @@ static class TryGetMeleeVerbPatch { static bool Cancel => Multiplayer.Client != null && Multiplayer.InInterface; + // The melee gizmo of every selected drafted pawn and pawn rendering ask for a melee verb several times per frame + // (#308), so interface answers are cached for the current frame and tick (ticks run between UI updates of a + // frame) + private static int cacheFrame = -1; + private static int cacheTick = -1; + private static readonly Dictionary<(Pawn_MeleeVerbs, Thing), Verb> cache = new(); + private static readonly List usableVerbs = new(); + static bool Prefix() { - // Namely FloatMenuUtility.GetMeleeAttackAction + // The original uses Rand and changes curMeleeVerb (namely called by FloatMenuUtility.GetMeleeAttackAction) return !Cancel; } static void Postfix(Pawn_MeleeVerbs __instance, Thing target, ref Verb __result) { - if (Cancel) - __result = __instance.GetUpdatedAvailableVerbsList(false).FirstOrDefault(ve => ve.GetSelectionWeight(target) != 0).verb; + if (!Cancel) return; + + if (cacheFrame != Time.frameCount || cacheTick != TickPatch.Timer) + { + cacheFrame = Time.frameCount; + cacheTick = TickPatch.Timer; + cache.Clear(); + } + + if (!cache.TryGetValue((__instance, target), out var verb)) + { + verb = InterfaceMeleeVerb(__instance, target); + cache[(__instance, target)] = verb; + } + + __result = verb; + } + + // Read-only: no Rand, doesn't touch curMeleeVerb or the vanilla verb lists (except in the fallback) + private static Verb InterfaceMeleeVerb(Pawn_MeleeVerbs meleeVerbs, Thing target) + { + var pawn = meleeVerbs.pawn; + var cur = meleeVerbs.curMeleeVerb; + + // Without building VerbEntries: their constructor calls VerbUtility.FinalSelectionWeight, which iterates + // all verbs (O(n²)). A verb has a non-zero VerbEntry selection weight when it isn't in the Worst category. + try + { + CollectUsableMeleeVerbs(pawn); + + // Like vanilla's comparison, ignores NaN weights + float highestWeight = 0f; + foreach (var v in usableVerbs) + { + float weight = VerbUtility.InitialVerbWeight(v, pawn); + if (weight > highestWeight) + highestWeight = weight; + } + + // Prefer the verb the simulation chose last + if (cur != null && cur.IsStillUsableBy(pawn) && UsableOn(cur, target) && + cur.GetSelectionCategory(pawn, highestWeight) != VerbSelectionCategory.Worst) + return cur; + + // Otherwise the same result as the first VerbEntry with a non-zero selection weight + foreach (var v in usableVerbs) + if (v.GetSelectionCategory(pawn, highestWeight) != VerbSelectionCategory.Worst && UsableOn(v, target)) + return v; + } + finally + { + usableVerbs.Clear(); + } + + // Fall back to the vanilla list in case a mod adds verbs to it + return meleeVerbs.GetUpdatedAvailableVerbsList(false).FirstOrDefault(ve => ve.GetSelectionWeight(target) != 0).verb; + } + + // The target-dependent part of VerbEntry.GetSelectionWeight being non-zero + private static bool UsableOn(Verb v, Thing target) => + v.IsUsableOn(target) && (target?.def?.IsEdifice() != true || v.verbProps.commonalityVsEdificeFactor != 0f); + + // Mirrors the non-terrain branch of Pawn_MeleeVerbs.GetUpdatedAvailableVerbsList + private static void CollectUsableMeleeVerbs(Pawn pawn) + { + void Add(List verbs) + { + if (verbs == null) return; + foreach (var v in verbs) + if (v.IsMeleeAttack && v.IsStillUsableBy(pawn)) + usableVerbs.Add(v); + } + + Add(pawn.verbTracker.AllVerbs); + + if (pawn.equipment != null) + foreach (var eq in pawn.equipment.AllEquipmentListForReading) + Add(eq.GetComp()?.AllVerbs); + + if (pawn.apparel != null) + foreach (var apparel in pawn.apparel.WornApparel) + Add(apparel.GetComp()?.AllVerbs); + + foreach (var v in pawn.health.hediffSet.GetHediffsVerbs()) + if (v.IsMeleeAttack && v.IsStillUsableBy(pawn)) + usableVerbs.Add(v); + + if (ModsConfig.AnomalyActive && pawn.IsMutant) + foreach (var v in pawn.mutant.AllVerbs) + if (v.IsMeleeAttack && v.IsStillUsableBy(pawn)) + usableVerbs.Add(v); } } @@ -70,20 +168,46 @@ static class GenerateNewPawnInternalPatch e => e.FirstOrDefault() ); + static MethodBase Shuffle = AccessTools.Method(typeof(GenList), nameof(GenList.Shuffle)).MakeGenericMethod(typeof(NameTriple)); + static IEnumerable Transpiler(IEnumerable e) { List insts = new List(e); + // Load the list the same way it's loaded for the shuffle call + CodeInstruction loadList = null; + for (int i = 1; i < insts.Count; i++) + { + if (insts[i].Calls((MethodInfo)Shuffle) && insts[i - 1].IsLdloc()) + { + loadList = new CodeInstruction(insts[i - 1].opcode, insts[i - 1].operand); + break; + } + } + + if (loadList == null) + { + Multiplayer.LoadingError("MP: Couldn't find the shuffled name list in PawnBioAndNameGenerator.TryGetRandomUnusedSolidName, assuming local 1. Was the original method changed?"); + loadList = new CodeInstruction(OpCodes.Ldloc_1); + } + + int patched = 0; for (int i = insts.Count - 1; i >= 0; i--) { if (insts[i].operand as MethodBase == FirstOrDefault) + { insts.Insert( i + 1, - new CodeInstruction(OpCodes.Ldloc_1), + loadList.Clone(), new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(GenerateNewPawnInternalPatch), nameof(Unshuffle)).MakeGenericMethod(typeof(NameTriple))) ); + patched++; + } } + if (patched == 0) + Multiplayer.LoadingError("MP: Couldn't find FirstOrDefault in PawnBioAndNameGenerator.TryGetRandomUnusedSolidName. Was the original method changed?"); + return insts; } @@ -134,6 +258,8 @@ static class DrawTrackerTickPatch static IEnumerable Transpiler(IEnumerable insts) { + int patched = 0; + foreach (var inst in insts) { yield return inst; @@ -142,32 +268,42 @@ static IEnumerable Transpiler(IEnumerable inst { yield return new CodeInstruction(OpCodes.Ldc_I4_1); yield return new CodeInstruction(OpCodes.Or); + patched++; } } + + if (patched == 0) + Multiplayer.LoadingError("MP: Couldn't find CellRect.Contains in Pawn.ProcessPostTickVisuals. Was the original method changed?"); } } public static class CellsShufflePatchShared { - public static IEnumerable Transpiler(IEnumerable insts, FieldInfo cellsShuffledField) + // Turns "if (!cellsShuffled) shuffle" into "if (!(cellsShuffled || SkipShuffle())) shuffle" + public static IEnumerable Transpiler(IEnumerable insts, FieldInfo cellsShuffledField, MethodBase original) { bool found = false; foreach (CodeInstruction inst in insts) { yield return inst; - if (!found && inst.operand as FieldInfo == cellsShuffledField) + if (!found && inst.opcode == OpCodes.Ldfld && inst.operand as FieldInfo == cellsShuffledField) { - yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(CellsShufflePatchShared), nameof(ShouldShuffle))); - yield return new CodeInstruction(OpCodes.Not); + yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(CellsShufflePatchShared), nameof(SkipShuffle))); yield return new CodeInstruction(OpCodes.Or); found = true; } } + + if (!found) + Multiplayer.LoadingError($"MP: Couldn't patch the cell shuffle in {original.DeclaringType}:{original.Name}. Was the original method changed?"); } - public static bool ShouldShuffle() + // The shuffle uses Rand and its "shuffled" flag isn't saved, so whether a given access shuffles could differ + // between clients (e.g. only one of them reloaded or first accessed the cells from the UI). Before this was + // made explicit, a bitwise "not" here made the shuffle never happen at all, so keep that in MP. + public static bool SkipShuffle() { - return Multiplayer.Client == null || Multiplayer.Ticking; + return Multiplayer.Client != null; } } @@ -175,16 +311,16 @@ public static bool ShouldShuffle() static class ZoneCellsShufflePatch { static readonly FieldInfo CellsShuffled = AccessTools.Field(typeof(Zone), "cellsShuffled"); - static IEnumerable Transpiler(IEnumerable insts) - => CellsShufflePatchShared.Transpiler(insts, CellsShuffled); + static IEnumerable Transpiler(IEnumerable insts, MethodBase __originalMethod) + => CellsShufflePatchShared.Transpiler(insts, CellsShuffled, __originalMethod); } [HarmonyPatch(typeof(Plan), nameof(Plan.Cells), MethodType.Getter)] static class PlanCellsShufflePatch { static readonly FieldInfo CellsShuffled = AccessTools.Field(typeof(Plan), "cellsShuffled"); - static IEnumerable Transpiler(IEnumerable insts) - => CellsShufflePatchShared.Transpiler(insts, CellsShuffled); + static IEnumerable Transpiler(IEnumerable insts, MethodBase __originalMethod) + => CellsShufflePatchShared.Transpiler(insts, CellsShuffled, __originalMethod); } [HarmonyPatch] @@ -252,10 +388,11 @@ static void Postfix(Pawn_AbilityTracker __instance, ref List __result) [HarmonyPatch(typeof(PriorityWork), nameof(PriorityWork.Clear))] static class PriorityWorkClearNoInterface { - // This can get called in the UI but has side effects + // This can get called in the UI (IsPrioritized timeout check from the gizmos) but has side effects. + // Calls during ticking (e.g. JobGiver_Work after exhausting the prioritized work) are deterministic. static bool Prefix(PriorityWork __instance) { - return Multiplayer.Client == null || Multiplayer.ExecutingCmds; + return Multiplayer.Client == null || !Multiplayer.InInterface; } } @@ -484,7 +621,42 @@ static IEnumerable Transpiler(IEnumerable inst AccessTools.Method(typeof(StatWorkerGetValuePatch), nameof(NewCacheTicks))) ); - return matcher.Instructions(); + // Make the interface judge staleness (and so decide whether to overwrite a simulation entry) + // with the time the simulation uses for the thing + var tickManagerTicksGame = AccessTools.PropertyGetter(typeof(TickManager), nameof(TickManager.TicksGame)); + var genTicksTicksGame = AccessTools.PropertyGetter(typeof(GenTicks), nameof(GenTicks.TicksGame)); + var result = matcher.Instructions(); + int patched = 0; + + for (int i = 0; i < result.Count; i++) + { + if (!result[i].Calls(tickManagerTicksGame) && !result[i].Calls(genTicksTicksGame)) + continue; + + result.InsertRange(i + 1, [ + new CodeInstruction(OpCodes.Ldarg_1), + new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(StatWorkerGetValuePatch), nameof(TicksGameFor))) + ]); + i += 2; + patched++; + } + + if (patched == 0) + Multiplayer.LoadingError("MP: Couldn't find TicksGame in StatWorker.GetValue. Was the original method changed?"); + + return result; + } + + internal static int TicksGameFor(int ticksGame, Thing thing) + { + if (Multiplayer.Client == null || !Multiplayer.InInterface) + return ticksGame; + + // In the interface the current time can be another map's or the world's (async time) + if (thing?.MapHeld?.AsyncTime() is { } mapTime) + return mapTime.mapTicks; + + return Multiplayer.AsyncWorldTime?.worldTicks ?? ticksGame; } private static bool HasValueInCache(bool hasValueInCache, StatWorker worker, Thing t) @@ -534,7 +706,7 @@ static IEnumerable Transpiler(IEnumerable inst const int expectedPatches = 1; if (patchCount != expectedPatches) - Log.Error($"Replaced an incorrect amount of Time.deltaTime calls for GameConditionManager.MapBrightnessTracker:Tick (expected: {expectedPatches}, patched: {patchCount}). Was the original method changed?"); + Multiplayer.LoadingError($"Replaced an incorrect amount of Time.deltaTime calls for GameConditionManager.MapBrightnessTracker:Tick (expected: {expectedPatches}, patched: {patchCount}). Was the original method changed?"); } } @@ -559,12 +731,17 @@ static IEnumerable Transpiler(IEnumerable inst } } - static void Prefix() => Rand.PushState(); + static void Prefix(ref bool __state) + { + Rand.PushState(); + __state = true; + } - static void Postfix(UndercaveMapComponent __instance) + static void Postfix(UndercaveMapComponent __instance, ref bool __state) { // Pop the RNG state from the prefix Rand.PopState(); + __state = false; // Make sure the pit gate is collapsing if (__instance.pitGate is not { IsCollapsing: true }) @@ -580,21 +757,33 @@ static void Postfix(UndercaveMapComponent __instance) // effecters - it would not be MP safe, but since it happens last it will be fine once we pop the state. Rand.PushState(); - // If not looking at the map, trigger the collapse without shake/effecters (since it's not needed for current player). - // The call to play a sound is handled by RW itself, since it targets a specific map already. - if (Find.CurrentMap != __instance.map) + try { - // Progress the RNG state, matching the RandomInRange call in other two cases - Rand.RangeInclusive(0, 100); - __instance.TriggerCollapseFX(0, 0); + // If not looking at the map, trigger the collapse without shake/effecters (since it's not needed for current player). + // The call to play a sound is handled by RW itself, since it targets a specific map already. + if (Find.CurrentMap != __instance.map) + { + // Progress the RNG state, matching the RandomInRange call in other two cases + Rand.RangeInclusive(0, 100); + __instance.TriggerCollapseFX(0, 0); + } + // Else, follow vanilla shake/effecter rules + else if (__instance.pitGate.CollapseStage == 1) + __instance.TriggerCollapseFX(UndercaveMapComponent.StageOneShakeAmount, UndercaveMapComponent.StageOneNumCollapseEffects.RandomInRange); + else + __instance.TriggerCollapseFX(UndercaveMapComponent.StageTwoShakeAmount, UndercaveMapComponent.StageTwoNumCollapseEffects.RandomInRange); } - // Else, follow vanilla shake/effecter rules - else if (__instance.pitGate.CollapseStage == 1) - __instance.TriggerCollapseFX(UndercaveMapComponent.StageOneShakeAmount, UndercaveMapComponent.StageOneNumCollapseEffects.RandomInRange); - else - __instance.TriggerCollapseFX(UndercaveMapComponent.StageTwoShakeAmount, UndercaveMapComponent.StageTwoNumCollapseEffects.RandomInRange); + finally + { + Rand.PopState(); + } + } - Rand.PopState(); + // Pops the prefix state if MapComponentTick threw before the postfix could + static void Finalizer(bool __state) + { + if (__state) + Rand.PopState(); } } @@ -725,13 +914,35 @@ static IEnumerable Transpiler(IEnumerable inst } } - // FastTileFinder.ComputeQueryJob uses Interlocked.Increment to race-fill a 50-slot result array - // across parallel Unity Job batches. Thread scheduling differs between machines, so clients get - // different candidate tile sets. Force single-batch execution in MP so tiles are processed in - // tileId order, making the first 50 valid tiles consistent across all clients. - [HarmonyPatch(typeof(FastTileFinder), nameof(FastTileFinder.Query))] + // FastTileFinder.ComputeQueryJob runs in parallel Unity Job batches: Query race-fills a 50-slot result array with + // Interlocked.Increment and Closest keeps a closest tile per worker thread, ties going to the lower thread index + // (#648). Thread scheduling differs between machines, so clients got different tiles. Force single-batch execution + // in MP so tiles are processed in tileId order: the first 50 valid tiles and the first of equally close ones. + [HarmonyPatch] static class FastTileFinderQueryDeterminismPatch { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(FastTileFinder), nameof(FastTileFinder.Query)); + yield return AccessTools.Method(typeof(FastTileFinder), nameof(FastTileFinder.Closest)); + } + + // Tile caches regenerated by interface code (only on that client, at its own time). Later DirtyTile calls + // refresh only the changed tiles, so the cache keeps e.g. neighbours' validForSettlement from that time. + internal static readonly ConditionalWeakTable regeneratedInInterface = new(); + + // Query and Closest reuse their last result for the same parameters until a tile changes, but they share the + // flag: a call made only by one client's interface could make the simulation reuse a result from before a + // tile change. Always recompute in the simulation, and regenerate a tile cache the interface regenerated. + static void Prefix(FastTileFinder __instance) + { + if (Multiplayer.Client == null || Multiplayer.InInterface) return; + + __instance.tileChangedSinceQuery = true; + if (regeneratedInInterface.TryGetValue(__instance, out _)) + __instance.cacheDirty = true; + } + static IEnumerable Transpiler(IEnumerable instructions) { var getIdealBatchCount = AccessTools.Method(typeof(UnityData), nameof(UnityData.GetIdealBatchCount)); @@ -750,5 +961,88 @@ static int GetBatchCount(int length) => Multiplayer.Client != null ? length : UnityData.GetIdealBatchCount(length); } + [HarmonyPatch(typeof(FastTileFinder), nameof(FastTileFinder.RegenerateCache))] + static class FastTileFinderRegenerateCachePatch + { + static void Prefix(FastTileFinder __instance) + { + if (Multiplayer.Client == null) return; + + var table = FastTileFinderQueryDeterminismPatch.regeneratedInInterface; + table.Remove(__instance); + if (Multiplayer.InInterface) + table.Add(__instance, null); + } + } + + // PlayerItemAccessibilityUtility caches its scan of all accessible things per (tile, RealTime.frameCount). Quest + // generation (trade request and beggars quests, also during every random quest selection) calls it from the + // simulation, and the scan uses Rand (leather amounts): whether a call rescans depended on the client's frames, + // and a scan made by interface code could be reused by the simulation. In the simulation, reuse a scan only + // within the same simulation step and only if it was made there. + [HarmonyPatch(typeof(PlayerItemAccessibilityUtility), nameof(PlayerItemAccessibilityUtility.CacheAccessibleThings))] + static class ItemAccessibilityCachePerSimStep + { + private static Game scanGame; + private static PlanetTile scanTile = PlanetTile.Invalid; + private static int scanStep = -1; + + static void Prefix(PlanetTile nearTile, ref bool __state) + { + if (Multiplayer.Client == null) return; + + // Also sim long events, loading etc.: every simulation step (TickPatch.simStep) starts with a new key + if (Multiplayer.InInterface) + { + // May rescan and replace the contents + scanGame = null; + return; + } + + if (scanGame == Current.Game && scanTile == nearTile && scanStep == TickPatch.simStep) + { + // Make vanilla reuse the scan + PlayerItemAccessibilityUtility.cachedAccessibleThingsForTile = nearTile; + PlayerItemAccessibilityUtility.cachedAccessibleThingsForFrame = RealTime.frameCount; + } + else + { + // Make vanilla rescan, a frame count is never -1 + PlayerItemAccessibilityUtility.cachedAccessibleThingsForFrame = -1; + scanGame = null; + __state = true; + } + } + + static void Postfix(PlanetTile nearTile, bool __state) + { + if (!__state) return; + + scanGame = Current.Game; + scanTile = nearTile; + scanStep = TickPatch.simStep; + } + } + + // On a cache miss GetSituations fills the shared cache and runs CheckHostilityChanged (relation kind changes, + // letters). Called from the interface (e.g. the factions tab) that happened on one client only, and the values + // cached at that moment were what its simulation read until the next recalculation. Compute uncached values + // for the interface instead. + [HarmonyPatch(typeof(GoodwillSituationManager), nameof(GoodwillSituationManager.GetSituations))] + static class GoodwillSituationsInInterfacePatch + { + static bool Prefix(GoodwillSituationManager __instance, Faction other, + ref List __result) + { + if (!Multiplayer.InInterface || other == null || other.IsPlayer || __instance.cachedData.ContainsKey(other)) + return true; + + var situations = new List(); + __instance.Recalculate(other, situations); + __result = situations; + return false; + } + } + } diff --git a/Source/Client/Patches/Feedback.cs b/Source/Client/Patches/Feedback.cs index bf6183b18..50f7369ae 100644 --- a/Source/Client/Patches/Feedback.cs +++ b/Source/Client/Patches/Feedback.cs @@ -63,13 +63,27 @@ static IEnumerable TargetMethods() yield return AccessTools.Method(typeof(MoteMaker), nameof(MoteMaker.MakeStaticMote), new[] { typeof(Vector3), typeof(Map), typeof(ThingDef), typeof(float), typeof(bool), typeof(float) }); } - static bool Prefix(ThingDef moteDef) + static bool Prefix(object[] __args, ThingDef moteDef, ref Mote __result) { // Catches ritual effect motes if (moteDef.mote.solidTime >= 99999) return true; - return !CancelFeedbackNotTargetedAtMe.Cancel; + if (!CancelFeedbackNotTargetedAtMe.Cancel) + return true; + + // Callers may use the result and FixNullMotes makes it non-null for the issuer (unless out of bounds), + // so return the same kind of result on the other clients. The mote is made (motes get no thing ID) + // but not spawned. + var cell = __args[0] switch + { + IntVec3 c => c, + Vector3 v => v.ToIntVec3(), + _ => IntVec3.Invalid + }; + + __result = __args[1] is Map map && cell.InBounds(map) ? ThingMaker.MakeThing(moteDef) as Mote : null; + return false; } } @@ -263,12 +277,77 @@ static bool CustomTryTakeOrderedJob(Pawn_JobTracker self, Job job, JobTag? tag = // already standing on gotoLoc and its current job is Goto, it calls EndCurrentJob directly. That // call isn't synced, so the pawn stops only for the player who issued the order while it keeps // walking for everyone else, causing a desync. Sync it the same way as the TryTakeOrderedJob call. - [SyncMethod] static void CustomEndCurrentJob(Pawn_JobTracker self, JobCondition condition, bool startNewJob = true, bool canReturnToPool = true) { + // Vanilla only calls this while the current job is a Goto: remember which one + SyncedEndGotoJob(self, condition, startNewJob, canReturnToPool, self.curJob?.loadID ?? -1); + } + + [SyncMethod] + static void SyncedEndGotoJob(Pawn_JobTracker self, JobCondition condition, bool startNewJob, + bool canReturnToPool, int jobId) + { + // The command executes ticks after the click: end only the goto the order was aimed at, not a job or + // another goto the pawn started in between. Not its position: the pawn keeps walking until then. + if (self.curJob == null || self.curJob.loadID != jobId || self.curJob.def != JobDefOf.Goto) + return; + self.EndCurrentJob(condition, startNewJob, canReturnToPool); } } + // Extended camera shakes (CameraShaker.DoShake(mag, durationTicks)) store TicksGame when requested and + // CameraShaker.Update compares it with TicksGame again. Under async time these can be different clocks: a map's + // when requested in its tick or command, the world's in the interface. A shake requested on a map ahead of the + // world went on until the world caught up, possibly for good (#696). In MP both use TickPatch.Timer instead: it's + // the same for every map and the world, and stops while the gravship cutscenes freeze the game, like TicksGame does + // in a vanilla landing (paused). Camera shake is local UI, nothing in the simulation reads it. + [HarmonyPatch(typeof(ExtendedShakeRequest), MethodType.Constructor, typeof(float), typeof(int))] + static class ExtendedShakeRequestUsesTimer + { + static void Postfix(ExtendedShakeRequest __instance) + { + if (Multiplayer.Client != null) + __instance.startTick = TickPatch.Timer; + } + } + + [HarmonyPatch(typeof(CameraShaker), nameof(CameraShaker.Update))] + static class CameraShakerUpdateUsesTimer + { + private static readonly MethodInfo TicksGameGetter = + AccessTools.PropertyGetter(typeof(TickManager), nameof(TickManager.TicksGame)); + + static IEnumerable Transpiler(IEnumerable insts) + { + foreach (var inst in insts) + { + // Consumes the TickManager instance like the getter + if (inst.Calls(TicksGameGetter)) + yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(CameraShakerUpdateUsesTimer), nameof(ShakeClock))).MoveLabelsFrom(inst); + else + yield return inst; + } + } + + static int ShakeClock(TickManager tickManager) => + Multiplayer.Client != null ? TickPatch.Timer : tickManager.TicksGame; + } + + // The requests are saved with the camera. Ones from a save which used TicksGame (or with a later timer) would + // otherwise last until the timer reaches their start. + [HarmonyPatch(typeof(CameraShaker), nameof(CameraShaker.Expose))] + static class CameraShakerLoadClampsToTimer + { + static void Postfix(CameraShaker __instance) + { + if (Multiplayer.Client == null || Scribe.mode != LoadSaveMode.LoadingVars) return; + + foreach (var request in __instance.extendedShakeRequests) + if (request != null && request.startTick > TickPatch.Timer) + request.startTick = TickPatch.Timer; + } + } + } diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 4939f821c..cd417722d 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using HarmonyLib; using Multiplayer.API; +using Multiplayer.Client.Factions; using Multiplayer.Client.Persistent; using Multiplayer.Client.Util; using RimWorld; @@ -115,27 +117,56 @@ public static class PatchGravshipDesignatorDeselectForAllClients [HarmonyPatch(typeof(GravshipLandingMarker), nameof(GravshipLandingMarker.BeginLanding))] public static class PatchBeginLandingToSyncWithClients { + // The marker this client just confirmed, see KeepLandingMarkerUntilSyncedLanding + internal static GravshipLandingMarker confirmedLocally; + static bool Prefix(GravshipLandingMarker __instance) { if (Multiplayer.Client == null) return true; if (Multiplayer.ExecutingCmds) return true; - SyncBeginLanding(__instance); + confirmedLocally = __instance; + SyncBeginLanding(__instance, __instance.GravshipRotation); return false; } + // The confirming player's rotation (vanilla only confirms without the move designator selected, so it's the + // last designated one): other players may still have the designator selected, and rotating its preview + // changes the marker's, and so the gravship's, rotation locally. BeginLanding lands with it. [SyncMethod] - public static void SyncBeginLanding(GravshipLandingMarker landingMarker) + public static void SyncBeginLanding(GravshipLandingMarker landingMarker, Rot4 rotation) { var gravshipController = Find.GravshipController; - if (landingMarker == null || landingMarker.Tile == null) + // Already landing: the confirm button stays usable until this command executes, so it can be sent twice + // (the destroyed marker then reads as null) + if (landingMarker == null || landingMarker.Destroyed) + return; + + if (landingMarker.Tile == null) { MpLog.Error($"[MP] SyncConfirmGravshipLanding: Marker [{landingMarker != null}] Tile [{landingMarker?.Tile != null}]."); return; } + // Deselect the designator everywhere: rotating its preview during the cutscene would change the rotation + // PlaceGravship places the ship with. Before setting the rotation, Deselected restores its own one. + if (Find.DesignatorManager.SelectedDesignator is Designator_MoveGravship) + { + CancelDesignatorDeselection.EnableCanceling(); + try + { + Find.DesignatorManager.Deselect(); + } + finally + { + CancelDesignatorDeselection.DisableCanceling(); + } + } + + landingMarker.GravshipRotation = rotation; + gravshipController.landingMarker = landingMarker; landingMarker.BeginLanding(gravshipController); gravshipController.landingMarker = null; @@ -145,26 +176,137 @@ public static void SyncBeginLanding(GravshipLandingMarker landingMarker) } } + /// + /// Vanilla's confirm landing button (WorldComponentOnGUI) clears the controller's landingMarker right after + /// BeginLanding, which MP turns into a command. Until that command executes, the confirming client would be the only + /// one without a landing confirmation in progress: a join point wouldn't restore its landing session, the move + /// designator's sync worker and AbortLanding would fail there only. Keep the field until the synced landing. + /// + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.WorldComponentOnGUI))] + public static class KeepLandingMarkerUntilSyncedLanding + { + static void Prefix() => PatchBeginLandingToSyncWithClients.confirmedLocally = null; + + static void Finalizer(WorldComponent_GravshipController __instance) + { + var marker = PatchBeginLandingToSyncWithClients.confirmedLocally; + PatchBeginLandingToSyncWithClients.confirmedLocally = null; + + if (Multiplayer.Client != null && marker is { Destroyed: false } && __instance.landingMarker == null) + __instance.landingMarker = marker; + } + } + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.AbortLanding))] public static class PatchAbortLandingToCloseSession { - static void Prefix(WorldComponent_GravshipController __instance, ref bool __state) + static bool Prefix(WorldComponent_GravshipController __instance) { - if (Multiplayer.Client == null) return; - if (!Multiplayer.ExecutingCmds) return; + if (Multiplayer.Client == null) return true; + if (!Multiplayer.ExecutingCmds) return true; + + // The landing began (or the ship was already redirected to a new map) before this command, e.g. another + // player confirmed meanwhile or the abort dialog was left open. Closing the session would unpause the + // landing map mid-landing and vanilla would throw (no landing marker or landing map). + if (!__instance.LandingAreaConfirmationInProgress || __instance.landingMap == null) + return false; GravshipTravelUtils.CloseSessionAt(__instance.landingTile); + return true; } } + /// + /// PlaceGravship (spawning the ship, clearing what's under it, recounting) runs in a long event queued from a + /// render callback of the landing cutscene, so outside of any tick or command and with the interface's time and + /// RNG. Run it in the landing map's context, like a command of that map (its time, random state and faction; + /// PlaceGravship_FactionContext_Patch pushes the gravship's faction inside it), and afterwards update what a + /// command updates. In MP MapUpdate doesn't do that (see AsyncTimeComp.UpdateManagers), which left the ship's + /// lights off (#635). + /// All clients reach this point after the same command (SyncBeginLanding) and the landing map stays paused by its + /// GravshipTravelSession meanwhile. The camera pan before the long event takes a second, by which time the cutscene + /// freeze sent by the host normally has stopped every client at the same tick. That isn't guaranteed (no host on + /// a standalone server, a client lagging by over a second): making the placement a command would need the + /// cutscene's callback chain to wait for it. + /// [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship))] - public static class PatchPlaceGravshipToUpdateSystemsAfterTheySpawn + public static class PatchPlaceGravshipInMapContext { - static void Postfix(Map map) + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(Map map, out int __state) { - if (Multiplayer.Client == null) return; + __state = -1; + if (Multiplayer.Client == null || Multiplayer.Ticking || Multiplayer.ExecutingCmds) return; + if (map?.AsyncTime() is not { } comp) return; - map.glowGrid.GlowGridUpdate_First(); + __state = Rand.stateStack.Count; + comp.PreContext(); + } + + static void Postfix(Map map, int __state) + { + if (__state >= 0) + map.AsyncTime().UpdateManagers(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer(Map map, int __state) + { + if (__state < 0) return; + + AsyncTimeComp.RestoreRandStack(__state + 1, nameof(WorldComponent_GravshipController.PlaceGravship)); + map.AsyncTime().PostContext(); + } + } + + /// + /// The takeoff counterpart of PatchPlaceGravshipInMapContext: the long event queued from the gravship capture's + /// callback (OnGravshipCaptureComplete) removes the gravship from the takeoff map, creating its world object, and + /// rebuilds the map's vacuum data. Run it in the takeoff map's context as well + /// (RemoveGravshipFromMap_Patch pushes the gravship's faction inside it). + /// + [HarmonyPatch] + public static class PatchGravshipRemovalInMapContext + { + static MethodBase TargetMethod() + { + // The only lambda of OnGravshipCaptureComplete calling RemoveGravshipFromMap + var removeGravship = AccessTools.Method(typeof(WorldComponent_GravshipController), + nameof(WorldComponent_GravshipController.RemoveGravshipFromMap)); + var lambdaPrefix = $"<{nameof(WorldComponent_GravshipController.OnGravshipCaptureComplete)}>"; + + return typeof(WorldComponent_GravshipController).GetNestedTypes(AccessTools.all) + .SelectMany(t => t.GetDeclaredMethods()) + .Where(m => m.Name.StartsWith(lambdaPrefix)) + .First(m => PatchProcessor.ReadMethodBody(m).Any(i => i.Value as MethodInfo == removeGravship)); + } + + [HarmonyPriority(MpPriority.MpFirst)] + static void Prefix(out (Map map, int randDepth) __state) + { + __state = (null, -1); + if (Multiplayer.Client == null || Multiplayer.Ticking || Multiplayer.ExecutingCmds) return; + + // The takeoff map, set by InitiateTakeoff + var map = Find.GravshipController?.map; + if (map?.AsyncTime() is not { } comp) return; + + __state = (map, Rand.stateStack.Count); + comp.PreContext(); + } + + static void Postfix((Map map, int randDepth) __state) + { + __state.map?.AsyncTime().UpdateManagers(); + } + + [HarmonyPriority(MpPriority.MpLast)] + static void Finalizer((Map map, int randDepth) __state) + { + if (__state.map == null) return; + + AsyncTimeComp.RestoreRandStack(__state.randDepth + 1, nameof(WorldComponent_GravshipController.RemoveGravshipFromMap)); + __state.map.AsyncTime().PostContext(); } } @@ -181,49 +323,354 @@ static IEnumerable TargetMethods() yield return AccessTools.Method(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.InitiateLanding)); } - static void Postfix() + static void Postfix(MethodBase __originalMethod) { if (Multiplayer.Client == null) return; + GravshipCutsceneSync.CutsceneStarted(__originalMethod.Name == nameof(WorldComponent_GravshipController.InitiateTakeoff)); + } + } + + /// + /// The takeoff/landing cutscenes end in WorldComponent_GravshipController.WorldComponentUpdate, i.e. in a frame + /// update, at a different moment on every client and outside of any tick or command. TakeoffEnded/LandingEnded + /// change the game state (landing outcome, world objects, maps, sessions), so in MP they are not run from there. + /// Instead, every client sends a synced command when its own cutscene ends and the first one to execute runs + /// the method on all clients at the same point of the simulation. The others are ignored. + /// + public static class GravshipCutsceneSync + { + private enum Cutscene { None, Takeoff, Landing } + + // Only changed from InitiateTakeoff/InitiateLanding and TakeoffEnded/LandingEnded which run in commands, + // so it's the same on all clients. Saved with the controller, a join point can happen during a cutscene. + private static Cutscene pending; + + // Local state of this client + private static bool endRequested; + private static bool frozen; + private static MultiplayerSession frozenSession; // The connection the freeze was sent on + + public static bool EndRequested => endRequested && pending != Cutscene.None; + + public static void ExposeData() + { + // Don't add it to singleplayer saves, but reset it on every load + if (Scribe.mode == LoadSaveMode.Saving && Multiplayer.Client == null) + return; + + Scribe_Values.Look(ref pending, "mpPendingCutsceneEnd"); + } + + public static void CutsceneStarted(bool takeoff) + { + pending = takeoff ? Cutscene.Takeoff : Cutscene.Landing; + endRequested = false; + + frozen = true; + frozenSession = Multiplayer.session; GravshipTravelUtils.StartFreeze(); } + + private static void Unfreeze() + { + if (!frozen) return; + + frozen = false; + + // A freeze sent on an earlier connection (disconnected or rejoined since) ended with it + if (frozenSession == Multiplayer.session) + GravshipTravelUtils.StopFreeze(); + frozenSession = null; + } + + private static bool IsPending(bool takeoff) => + pending == (takeoff ? Cutscene.Takeoff : Cutscene.Landing); + + // Called by vanilla in the interface when the local cutscene ends + public static void LocalCutsceneEnded(WorldComponent_GravshipController controller, bool takeoff) + { + if (EndRequested || !Multiplayer.ShouldSync) + return; + + if (!IsPending(takeoff)) + { + // The synced end already ran (e.g. this client's cutscene was very late), only clean up the visuals + Log.Warning($"MP: Gravship {(takeoff ? "takeoff" : "landing")} cutscene ended locally without a pending synced end"); + Unfreeze(); + WorldComponent_GravshipController.cutsceneInProgress = false; + + try + { + controller.ResetCutscene(); + } + catch (Exception e) + { + Log.Error($"MP: Exception resetting gravship cutscene: {e}"); + } + + return; + } + + endRequested = true; + + // Commands don't execute while frozen + Unfreeze(); + + if (takeoff) + SyncTakeoffEnded(); + else + SyncLandingEnded(controller.map); + } + + // Called when TakeoffEnded/LandingEnded actually runs + public static void SyncedEndExecuting() + { + pending = Cutscene.None; + endRequested = false; + Unfreeze(); + } + + /// + /// Vanilla can't finish a cutscene without its gravship, which nothing saves while it's neither on a map nor a + /// world object: a join point during a cutscene (e.g. right after the unfreeze, before the synced end) loses + /// it, and a catching-up joiner can execute the landing's end before its own PlaceGravship ran. TakeoffEnded + /// (after abandoning the map) and LandingEnded would throw and leave the game in the cutscene for good (no + /// world map, no saving, the map paused). End the cutscene without the gravship instead, which is lost. + /// After loading this runs the same way on every client (the gravship is gone everywhere). From the synced end + /// command it only runs on clients whose own rendering hasn't removed or placed the gravship yet (the arbiter + /// never renders, a lagging or catching-up joiner): they already differ from the others, this just replaces + /// the exception and the never ending cutscene there. + /// + public static void EndWithoutGravship(WorldComponent_GravshipController controller, bool takeoff) + { + Log.Warning($"MP: Gravship {(takeoff ? "takeoff" : "landing")} ended without its gravship (lost by a reload during the cutscene or not placed here yet)"); + + pending = Cutscene.None; + endRequested = false; + Unfreeze(); + + var map = controller.map; + if (takeoff) + { + GravshipTravelUtils.CloseSessionAt(controller.takeoffTile); + } + else + { + GravshipTravelUtils.CloseSessionAt(map?.Tile ?? controller.landingTile); + if (map != null) + PatchGravshipLandingEnded.SetNormalSpeedAfterLanding(map); + controller.moveDesignator = null; + Current.Game.Gravship = null; + } + + controller.map = null; + controller.ResetCutscene(); + } + + // Called at the same point of every load + public static void AfterLoading(WorldComponent_GravshipController controller) + { + if (pending == Cutscene.None) + { + // Left over from a cutscene of an earlier connection (e.g. rejoining after it ended) + endRequested = false; + Unfreeze(); + } + else if (controller.gravship == null) + { + // A cutscene in progress can't continue (its gravship isn't saved) and nobody would end it + EndWithoutGravship(controller, pending == Cutscene.Takeoff); + } + } + + [SyncMethod] + public static void SyncTakeoffEnded() + { + if (!IsPending(true)) return; + RunWithFeedback(Find.GravshipController.TakeoffEnded); + } + + // The map argument makes it a command of the landing map (its RNG, time and faction) + [SyncMethod] + public static void SyncLandingEnded(Map map) + { + if (!IsPending(false)) return; + RunWithFeedback(Find.GravshipController.LandingEnded); + } + + // Everyone watched the cutscene, so show its feedback (sounds, messages, camera) to all players like + // vanilla does, not only to the player whose command happened to arrive first. + private static void RunWithFeedback(Action action) + { + var prevIssuedBySelf = TickPatch.currentExecutingCmdIssuedBySelf; + TickPatch.currentExecutingCmdIssuedBySelf = !TickPatch.Simulating; + + try + { + action(); + } + finally + { + TickPatch.currentExecutingCmdIssuedBySelf = prevIssuedBySelf; + } + } } - [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.TakeoffEnded))] - public static class PatchGravshipTakeoffEnded + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.ExposeData))] + public static class PatchGravshipControllerExposePendingEnd + { + static void Postfix() => GravshipCutsceneSync.ExposeData(); + } + + /// + /// GravshipTravelSession isn't saved, so every reload (all clients reload at a join point) and every joiner lost + /// the pause of the landing map while the players choose where to land, which can take a while. Vanilla saves the + /// landing confirmation state (the landing marker), so recreate the session from it after loading. This runs at + /// the same point of every load on every client and joiner, so they all get the same session (and session id). + /// The takeoff side (prelaunch dialog and destination picker) isn't restored: it's UI which a reload closes, so a + /// restored session would keep the map paused with no way to continue or cancel. A cutscene in progress can't + /// be resumed after loading (its gravship isn't saved), so it's ended instead (GravshipCutsceneSync.AfterLoading). + /// + [HarmonyPatch(typeof(Game), nameof(Game.LoadGame))] + public static class RestoreGravshipLandingSessionOnLoad + { + static void Postfix() + { + if (Multiplayer.Client == null || !ModsConfig.OdysseyActive) return; + + var controller = Find.GravshipController; + if (controller == null) return; + + GravshipCutsceneSync.AfterLoading(controller); + + if (!controller.LandingAreaConfirmationInProgress) return; + + // The same tile ArriveExistingMap/ArriveNewMap open the session at + if (controller.landingMarker.gravship is { } gravship) + GravshipTravelUtils.OpenSessionAt(gravship.destinationTile); + } + } + + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.WorldComponentUpdate))] + public static class PatchGravshipCutsceneHoldLastFrame { static void Prefix(WorldComponent_GravshipController __instance) { if (Multiplayer.Client == null) return; - GravshipTravelUtils.StopFreeze(); + // Keep the cutscene on its last frame while waiting for the synced end + if (GravshipCutsceneSync.EndRequested && __instance.timeLeft < 0f) + __instance.timeLeft = 0f; + } + } + + [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.TakeoffEnded))] + public static class PatchGravshipTakeoffEnded + { + static bool Prefix(WorldComponent_GravshipController __instance, out bool __state) + { + __state = false; + if (Multiplayer.Client == null) return true; + + if (Multiplayer.InInterface) + { + GravshipCutsceneSync.LocalCutsceneEnded(__instance, true); + return false; + } + + if (__instance.gravship == null) + { + GravshipCutsceneSync.EndWithoutGravship(__instance, takeoff: true); + return false; + } + + GravshipCutsceneSync.SyncedEndExecuting(); GravshipTravelUtils.CloseSessionAt(__instance.takeoffTile); + + // Forced like the other gravship pushes (see RemoveGravshipFromMap_Patch): in this world command + // Faction.OfPlayer may already be the gravship's faction (its player's command came first) while the maps + // hold the spectator's data, which would make a regular push a no-op + if (Multiplayer.GameComp.multifaction && __instance.gravship?.Faction is { } faction) + { + FactionExtensions.PushFaction(null, faction, force: true); + __state = true; + } + + return true; + } + + static void Finalizer(bool __state) + { + // The forced pop restores the world and every live map, TakeoffEnded can remove the map left behind + if (__state) + FactionExtensions.PopFaction(); } } - // TODO: Is the random pushing here necessary? This might be related to issue #638. [HarmonyPatch(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.LandingEnded))] public static class PatchGravshipLandingEnded { - static void Prefix(WorldComponent_GravshipController __instance) + static bool Prefix(WorldComponent_GravshipController __instance, out (Map factionMap, bool randPushed) __state) { - if (Multiplayer.Client == null) return; + __state = default; + if (Multiplayer.Client == null) return true; + + if (Multiplayer.InInterface) + { + GravshipCutsceneSync.LocalCutsceneEnded(__instance, false); + return false; + } + + if (__instance.gravship == null) + { + GravshipCutsceneSync.EndWithoutGravship(__instance, takeoff: false); + return false; + } + + var map = __instance.map; - Rand.PushState(); - Rand.StateCompressed = __instance.map.AsyncTime().randState; + // Normally runs in a command of the landing map which already provides its RNG state (and saves it after). + // Otherwise (e.g. called while loading) keep it from consuming an unrelated RNG state. + if (!Multiplayer.ExecutingCmds && !Multiplayer.Ticking) + { + Rand.PushState(); + Rand.StateCompressed = map.AsyncTime().randState; + __state.randPushed = true; + } - GravshipTravelUtils.StopFreeze(); + GravshipCutsceneSync.SyncedEndExecuting(); GravshipTravelUtils.CloseSessionAt(__instance.gravship.destinationTile); - // LandingEnded calls Find.TickManager.CurTimeSpeed = TimeSpeed.Normal, but that doesn't work in MP as we - // have our own way of changing the map speed. - __instance.map.AsyncTime().DesiredTimeSpeed = TimeSpeed.Normal; + SetNormalSpeedAfterLanding(map); + + // Forced: the command's faction (whichever player's end command came first) may already be the gravship's + if (Multiplayer.GameComp.multifaction && __instance.gravship.Faction is { } faction) + { + map.PushFaction(faction, force: true); + __state.factionMap = map; + } + + return true; } - static void Finalizer() + static void Finalizer((Map factionMap, bool randPushed) __state) { - if (Multiplayer.Client == null) return; - Rand.PopState(); + __state.factionMap?.PopFaction(); + + if (__state.randPushed) + Rand.PopState(); + } + + // LandingEnded sets Find.TickManager.CurTimeSpeed = TimeSpeed.Normal, which MP overrides with its own speeds. + // Without async time all tickables share one speed (like a time speed command sets it), otherwise only the + // landing map's is changed. + internal static void SetNormalSpeedAfterLanding(Map map) + { + if (Multiplayer.GameComp.asyncTime) + map.AsyncTime().DesiredTimeSpeed = TimeSpeed.Normal; + else + Multiplayer.AsyncWorldTime.SetTimeEverywhere(TimeSpeed.Normal); } } #endregion diff --git a/Source/Client/Patches/HashCodes.cs b/Source/Client/Patches/HashCodes.cs index 497fd75f8..8cd64878f 100644 --- a/Source/Client/Patches/HashCodes.cs +++ b/Source/Client/Patches/HashCodes.cs @@ -68,21 +68,31 @@ static IEnumerable TargetMethods() yield return AccessTools.Method(typeof(TargetInfo), nameof(TargetInfo.GetHashCode)); } - static IEnumerable Transpiler(IEnumerable insts) + static IEnumerable Transpiler(IEnumerable insts, MethodBase original) { + int patched = 0; + foreach (var inst in insts) { if (inst.operand as MethodInfo == Combine) + { inst.operand = AccessTools.Method(typeof(PatchTargetInfoHashCodes), nameof(CombineHashes)); + patched++; + } yield return inst; } + + if (patched == 0) + Multiplayer.LoadingError($"MP: Couldn't find Gen.HashCombine in {original.DeclaringType}:{original.Name}. Was the original method changed?"); } static int CombineHashes(int seed, Map map) => Gen.HashCombineInt(seed, map?.uniqueID ?? -1); } - // todo does this cause issues? + // Vanilla hashes the first thing, which changes while trading. The identity hash differs between clients, which + // is fine as long as Tradeables are only kept in hash containers locally (TradingUI, buffered sync fields) and + // the simulation keeps them in lists (TradeDeal). Don't iterate a hash container of Tradeables in synced code. [HarmonyPatch(typeof(Tradeable), nameof(Tradeable.GetHashCode))] static class TradeableHashCode { @@ -95,6 +105,11 @@ static void Postfix(Tradeable __instance, ref int __result) } // Have created a patch that will handle 2-8 System.HashCode.Combine functions. + // System.HashCode uses a random per-process seed. These patches make the all-int overloads deterministic. + // They're applied early (before def loading) and unconditionally so that the hash function doesn't change while + // hash containers are alive: switching it only while connected broke lookups in containers filled before + // connecting, and left containers filled during def loading in a per-process order. + // Combine with other type arguments and HashCode.Add/ToHashCode still use the random seed. // TODO: Check the following: // TileQueryParams.GetHashCode // UnmanagedGridTraverseParams.GetHashCode @@ -126,6 +141,7 @@ internal static MethodBase GetCombineIntMethod(int numOfInts) } // ─── PATCH 2 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine2Patch { @@ -133,15 +149,13 @@ static class Combine2Patch static bool Prefix(int value1, int value2, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = Gen.HashCombineInt(value1, value2); return false; } } // ─── PATCH 3 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine3Patch { @@ -149,15 +163,13 @@ static class Combine3Patch static bool Prefix(int value1, int value2, int value3, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = DeterministicHash.HashCombineInt(value1, value2, value3); return false; } } // ─── PATCH 4 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine4Patch { @@ -165,15 +177,13 @@ static class Combine4Patch static bool Prefix(int value1, int value2, int value3, int value4, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = Gen.HashCombineInt(value1, value2, value3, value4); return false; } } // ─── PATCH 5 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine5Patch { @@ -181,15 +191,13 @@ static class Combine5Patch static bool Prefix(int value1, int value2, int value3, int value4, int value5, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, value5); return false; } } // ─── PATCH 6 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine6Patch { @@ -197,9 +205,6 @@ static class Combine6Patch static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, value5, value6); return false; @@ -207,6 +212,7 @@ static bool Prefix(int value1, int value2, int value3, int value4, int value5, i } // ─── PATCH 7 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine7Patch { @@ -214,9 +220,6 @@ static class Combine7Patch static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, int value7, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, value5, value6, value7); return false; @@ -224,6 +227,7 @@ static bool Prefix(int value1, int value2, int value3, int value4, int value5, i } // ─── PATCH 8 ints ───────────────────────────────────────── + [EarlyPatch] [HarmonyPatch] static class Combine8Patch { @@ -231,9 +235,6 @@ static class Combine8Patch static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, int value7, int value8, ref int __result) { - if (Multiplayer.Client == null) - return true; - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, value5, value6, value7, value8); return false; diff --git a/Source/Client/Patches/LaunchTargeting.cs b/Source/Client/Patches/LaunchTargeting.cs new file mode 100644 index 000000000..8f5a2ba53 --- /dev/null +++ b/Source/Client/Patches/LaunchTargeting.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using RimWorld; +using RimWorld.Planet; +using Verse; + +namespace Multiplayer.Client.Patches; + +// While a player picks a destination for transport pods or a shuttle, the game keeps running: another player can +// launch the group or cancel its loading meanwhile. The world targeter's callbacks then throw every frame and the +// targeter can't be closed (#365). Stop it once the launchable is gone. UI only: nothing here changes game state. +[HarmonyPatch(typeof(CompLaunchable), nameof(CompLaunchable.StartChoosingDestination))] +static class TrackLaunchTargeting +{ + public static CompLaunchable launchable; + public static Func targeterAction; + public static bool startedSpawned; + + static void Postfix(CompLaunchable __instance) + { + if (Multiplayer.Client == null || !Find.WorldTargeter.IsTargeting) return; + + launchable = __instance; + targeterAction = Find.WorldTargeter.action; + // A shuttle in a caravan isn't spawned + startedSpawned = __instance.parent.Spawned; + } + + public static void Clear() + { + launchable = null; + targeterAction = null; + } +} + +[HarmonyPatch] +static class StopLaunchTargetingWhenGone +{ + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(WorldTargeter), nameof(WorldTargeter.TargeterUpdate)); + yield return AccessTools.Method(typeof(WorldTargeter), nameof(WorldTargeter.TargeterOnGUI)); + yield return AccessTools.Method(typeof(WorldTargeter), nameof(WorldTargeter.ProcessInputEvents)); + } + + static void Prefix(WorldTargeter __instance) + { + var launchable = TrackLaunchTargeting.launchable; + if (launchable == null) return; + + // Targeting ended or something else started targeting + if (__instance.action == null || __instance.action != TrackLaunchTargeting.targeterAction) + { + TrackLaunchTargeting.Clear(); + return; + } + + var parent = launchable.parent; + if (parent.Destroyed || + TrackLaunchTargeting.startedSpawned && !parent.Spawned || + launchable.Transporter is not { LoadingInProgressOrReadyToLaunch: true }) + { + TrackLaunchTargeting.Clear(); + __instance.StopTargeting(); + } + } +} diff --git a/Source/Client/Patches/Letters.cs b/Source/Client/Patches/Letters.cs index 93ba7396b..75d67f530 100644 --- a/Source/Client/Patches/Letters.cs +++ b/Source/Client/Patches/Letters.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Reflection.Emit; using HarmonyLib; using RimWorld; using UnityEngine; @@ -11,6 +12,184 @@ namespace Multiplayer.Client.Patches { // todo letter timeouts and async time + /// + /// In multifaction every client keeps the same letter stack, as the simulation reads it (expiring letters and their + /// default choices, growth moment sessions, archive culling). Letters are only hidden in the UI from players of other + /// factions, based on the faction (Faction.OfPlayer) they were received for. + /// + public static class MultifactionLetters + { + public static bool Active => Multiplayer.Client != null && Multiplayer.game?.gameComp?.multifaction == true; + + public static void RecordReceivingFaction(Letter let) + { + // Negative ids are interface-only letters + if (!Active || let == null || let.ID < 0 || Faction.OfPlayer == null) return; + + // Delayed letters are received again later from the queue (in another context), keep the first faction + var letterFactions = Multiplayer.game.worldComp?.letterFactions; + if (letterFactions != null && !letterFactions.ContainsKey(let.ID)) + letterFactions[let.ID] = Faction.OfPlayer.loadID; + } + + public static bool IsVisibleLocally(Letter let) + { + if (!Active || let == null) return true; + + var worldComp = Multiplayer.game.worldComp; + if (worldComp == null || !worldComp.letterFactions.TryGetValue(let.ID, out var factionId)) + return true; + + // Letters received in a spectator context (e.g. world ticks) aren't specific to a player faction + return factionId == Multiplayer.RealPlayerFaction?.loadID || + factionId == worldComp.spectatorFaction?.loadID || + Find.FactionManager.GetById(factionId) == null; + } + } + + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), typeof(Letter), typeof(string), typeof(int), typeof(bool))] + static class RecordLetterFaction + { + // The letter being received, for the arrival sound + public static Letter receiving; + + static void Prefix(Letter let, ref Letter __state) + { + if (Multiplayer.Client == null) return; + + MultifactionLetters.RecordReceivingFaction(let); + __state = receiving; + receiving = let; + } + + static void Finalizer(Letter __state) + { + if (Multiplayer.Client == null) return; + receiving = __state; + } + } + + // Draw only the letters of the local player's faction, the stack itself is kept the same on every client + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.LettersOnGUI))] + static class LettersOnGUIOnlyMyFaction + { + // The actual list while the filtered one is being drawn + public static List realLetters; + private static List visibleLetters = new(); + + static void Prefix(LetterStack __instance, ref bool __state) + { + if (!MultifactionLetters.Active || realLetters != null) return; + + var letters = __instance.letters; + if (letters.All(MultifactionLetters.IsVisibleLocally)) return; + + visibleLetters.Clear(); + foreach (var letter in letters) + if (MultifactionLetters.IsVisibleLocally(letter)) + visibleLetters.Add(letter); + + realLetters = letters; + __instance.letters = visibleLetters; + __state = true; + } + + static void Finalizer(LetterStack __instance, bool __state) + { + if (!__state) return; + + var drawn = __instance.letters; + __instance.letters = realLetters; + realLetters = null; + + // Removals are applied by LetterStackRemoveDuringFilteredGUI, add letters received while drawing + foreach (var letter in drawn) + if (!__instance.letters.Contains(letter)) + __instance.letters.Add(letter); + + visibleLetters.Clear(); + } + } + + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.RemoveLetter))] + static class LetterStackRemoveDuringFilteredGUI + { + static void Postfix(LetterStack __instance, Letter let) + { + // Removed from the drawn list (e.g. dismissed with right click), remove it from the actual one too + if (LettersOnGUIOnlyMyFaction.realLetters != null && !__instance.letters.Contains(let)) + LettersOnGUIOnlyMyFaction.realLetters.Remove(let); + } + } + + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.Notify_LetterMouseover))] + static class LetterMouseoverDuringFilteredGUI + { + static void Postfix(LetterStack __instance, Letter let) + { + // The index is used on the actual list in LetterStackUpdate + if (LettersOnGUIOnlyMyFaction.realLetters != null) + __instance.mouseoverLetterIndex = LettersOnGUIOnlyMyFaction.realLetters.IndexOf(let); + } + } + + // LetterStackTick force-opens letters about to expire on every client (the stack is the same everywhere). Letters of + // other factions are still opened, as opening may change the simulation (e.g. NewQuestLetter removes itself from + // the stack), but without their UI (windows, main tab switch). + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.LetterStackTick))] + static class OpenExpiringLettersForTheirFaction + { + public static bool suppressUI; + + static IEnumerable Transpiler(IEnumerable insts) + { + var openLetter = AccessTools.Method(typeof(Letter), nameof(Letter.OpenLetter)); + var replacement = AccessTools.Method(typeof(OpenExpiringLettersForTheirFaction), nameof(OpenLetter)); + + foreach (var inst in insts) + { + if (inst.Calls(openLetter)) + { + inst.opcode = OpCodes.Call; + inst.operand = replacement; + } + + yield return inst; + } + } + + static void OpenLetter(Letter letter) + { + if (MultifactionLetters.IsVisibleLocally(letter)) + { + letter.OpenLetter(); + return; + } + + suppressUI = true; + try + { + letter.OpenLetter(); + } + finally + { + suppressUI = false; + } + } + } + + [HarmonyPatch(typeof(WindowStack), nameof(WindowStack.Add))] + static class SuppressExpiringLetterWindows + { + static bool Prefix() => !OpenExpiringLettersForTheirFaction.suppressUI; + } + + [HarmonyPatch(typeof(MainTabsRoot), nameof(MainTabsRoot.SetCurrentTab))] + static class SuppressExpiringLetterTabSwitch + { + static bool Prefix() => !OpenExpiringLettersForTheirFaction.suppressUI; + } + [HarmonyPatch(typeof(LetterStack), nameof(LetterStack.LetterStackUpdate))] static class CloseLettersDuringSimulating { @@ -55,27 +234,41 @@ static IEnumerable TargetMethods() yield return AccessTools.DeclaredMethod(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.OpenLetter)); // Not a subtype of ChoiceLetter, only LetterWithTimeout } - static bool Prefix(LetterWithTimeout __instance) + static bool Prefix(LetterWithTimeout __instance, ref bool __state) { // The letter is about to be force-shown by LetterStack.LetterStackTick because of expiry if (Multiplayer.Ticking && __instance.TimeoutActive - && __instance.disappearAtTick == Find.TickManager.TicksGame + 1 - && defaultChoices.TryGetValue(__instance.GetType(), out var tuple)) + && __instance.disappearAtTick == Find.TickManager.TicksGame + 1) { - if (tuple.method.IsStatic) - tuple.handler(null, __instance); - else - tuple.handler(__instance); + if (defaultChoices.TryGetValue(__instance.GetType(), out var tuple)) + { + if (tuple.method.IsStatic) + tuple.handler(null, __instance); + else + tuple.handler(__instance); - return false; + __state = true; + return false; + } + + // Every client force-opens it (the stack is the same everywhere), only show it to the letter's faction. + // Opening a ChoiceLetter only creates its dialog. + if (!MultifactionLetters.IsVisibleLocally(__instance)) + { + __state = true; + return false; + } } return true; } - static void Postfix(LetterWithTimeout __instance) + static void Postfix(LetterWithTimeout __instance, bool __state) { + // The letter wasn't opened + if (__state) return; + var wasArchived = __instance.ArchivedOnly; var window = Find.WindowStack.WindowOfType(); diff --git a/Source/Client/Patches/LongEvents.cs b/Source/Client/Patches/LongEvents.cs index 473faea57..1f5b6b2a2 100644 --- a/Source/Client/Patches/LongEvents.cs +++ b/Source/Client/Patches/LongEvents.cs @@ -3,9 +3,11 @@ using System.Linq; using System.Reflection; using System.Reflection.Emit; +using System.Runtime.CompilerServices; using HarmonyLib; using Multiplayer.Common; using Multiplayer.Common.Networking.Packet; +using RimWorld; using Verse; namespace Multiplayer.Client.Patches @@ -71,7 +73,8 @@ static class LongEventAlwaysSync { static void Prefix(ref bool doAsynchronously, ref Action action, Action callback) { - if (Multiplayer.ExecutingCmds) + // Asynchronous events would run on a worker thread sharing the global Rand with the UI + if (SimLongEventContext.ShouldCapture) { doAsynchronously = false; @@ -106,15 +109,148 @@ static IEnumerable Transpiler(IEnumerable inst public static void ReorderCleanupCode() { Action callback = LongEventHandler.currentEvent.callback; + var context = SimLongEventContext.For(LongEventHandler.currentEvent); LongEventHandler.currentEvent = null; LongEventHandler.eventThread = null; LongEventHandler.levelLoadOp = null; - LongEventHandler.ExecuteToExecuteWhenFinished(); + context?.Enter(); + try + { + LongEventHandler.ExecuteToExecuteWhenFinished(); + + if (callback != null) + callback(); + } + finally + { + context?.Exit(); + } + } + } - if (callback != null) - callback(); + // Synchronous events run the action, then (with currentEvent already cleared) the actions deferred with + // LongEventHandler.ExecuteWhenFinished. Both get the simulation context of an event queued from the simulation. + [HarmonyPatch(typeof(LongEventHandler), nameof(LongEventHandler.UpdateCurrentSynchronousEvent))] + static class SimLongEventSynchronousContext + { + static void Prefix(ref SimLongEventContext __state) + { + __state = SimLongEventContext.For(LongEventHandler.currentEvent); + __state?.Enter(); + } + + static void Finalizer(SimLongEventContext __state) + { + __state?.Exit(); + } + } + + /// + /// The simulation context of a long event queued during a tick or command. Long events run later, outside any + /// tick or command, so this re-applies what has to be the same on all clients: a seeded RNG, the faction the + /// event was queued for (multifaction) and a non-interface context. The latter matters for the actions the event + /// defers with ExecuteWhenFinished: they run after currentEvent is cleared and would otherwise count as UI + /// (local unique ids, sending sync commands). + /// + public class SimLongEventContext + { + private static readonly ConditionalWeakTable contexts = new(); + private static SimLongEventContext current; + + // A reload (e.g. a join point) replaces the game: what runs after it isn't simulation of the old game anymore + public static bool InSimulation => current != null && current.game != null && current.game == Multiplayer.game; + + public static bool ShouldCapture => + Multiplayer.Client != null && (Multiplayer.Ticking || Multiplayer.ExecutingCmds || InSimulation); + + private readonly MultiplayerGame game; + private readonly int seed; + private readonly Faction faction; + + private SimLongEventContext prev; + private bool pushedFaction; + + private SimLongEventContext(int seed, Faction faction) + { + game = Multiplayer.game; + this.seed = seed; + this.faction = faction; + } + + // The seed is derived from the RNG state without advancing it: some events are queued only on the issuer of a + // command (e.g. join points on a standalone server), which mustn't move the shared stream + public static SimLongEventContext Capture() + { + ulong state = Rand.StateCompressed; + int seed = Gen.HashCombineInt((int)state, (int)(state >> 32)); + return new(seed, Multiplayer.GameComp.multifaction ? Faction.OfPlayer : null); + } + + public static void Attach(LongEventHandler.QueuedLongEvent ev, SimLongEventContext context) + { + contexts.Remove(ev); + contexts.Add(ev, context); + } + + public static SimLongEventContext For(LongEventHandler.QueuedLongEvent ev) => + ev != null && contexts.TryGetValue(ev, out var context) ? context : null; + + public void Enter() + { + prev = current; + current = this; + + // Runs in a later frame than the tick or command which queued it, so it's a step of its own + TickPatch.simStep++; + + Rand.PushState(seed); + GravEngineCache.Reset(); + + if (faction != null && game == Multiplayer.game) + { + pushedFaction = true; + if (FactionContext.Push(faction, force: true) is { } f) + SetFactionData(f); + } + } + + public void Exit() + { + try + { + // FactionContext is cleared when the game is reloaded + if (pushedFaction && game == Multiplayer.game && FactionContext.stack.Count > 0) + { + FactionContext.Pop(); + + // Long events run in the interface context, where Faction.OfPlayer is the real faction. Restore + // that instead of the popped faction as the event may have changed it (FactionCreator). + if (Multiplayer.RealPlayerFaction is { } real) + { + FactionContext.Set(real); + SetFactionData(real); + } + } + } + finally + { + pushedFaction = false; + + // A context left active would keep InInterface false for good + current = prev; + prev = null; + + Rand.PopState(); + } + } + + private static void SetFactionData(Faction f) + { + Multiplayer.WorldComp?.SetFaction(f); + foreach (var map in Find.Maps) + map.MpComp().SetFaction(f); } } } diff --git a/Source/Client/Patches/MapSetup.cs b/Source/Client/Patches/MapSetup.cs index f83e0f249..69493bf67 100644 --- a/Source/Client/Patches/MapSetup.cs +++ b/Source/Client/Patches/MapSetup.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using HarmonyLib; +using Multiplayer.Client.AsyncTime; using RimWorld; using Verse; @@ -63,13 +64,24 @@ private static AsyncTimeComp CreateAsyncTimeCompForMap(Map map, bool usingMapTim } else { - startingMapTicks = Find.Maps.Where(m => m != map).Select(m => m.AsyncTime()?.mapTicks).Max() ?? Find.TickManager.TicksGame; + // Without async time the maps tick in lockstep with the world (TickPatch.TickLockstep). A map generated + // during the world tick still ticks in the same game tick, so it starts where the maps were before it (also + // after DoSingleTick updated worldTicks, and with TicksGame of a nested map context). One generated later + // starts at worldTicks and ticks from the next game tick. The other maps' clocks differ from that in the + // middle of a game tick. + startingMapTicks = !Multiplayer.GameComp.asyncTime && Multiplayer.AsyncWorldTime is { } worldTime + ? AsyncWorldTimeComp.tickingWorld ? worldTime.TicksBeforeTick : worldTime.worldTicks + : Find.Maps.Where(m => m != map).Select(m => m.AsyncTime()?.mapTicks).Max() ?? Find.TickManager.TicksGame; gameStartAbsTick = Find.TickManager.gameStartAbsTick; startingTimeSpeed = TimeSpeed.Paused; } + // TickManager.CurTimeSpeed is the speed of the last context entered: a pausing letter earlier in the same world + // tick doesn't change it, and the new map would then unpause the game if (!Multiplayer.GameComp.asyncTime) - startingTimeSpeed = Find.TickManager.CurTimeSpeed; + startingTimeSpeed = !usingMapTimeFromSingleplayer && Multiplayer.AsyncWorldTime is { } world + ? world.GlobalTimeSpeed + : Find.TickManager.CurTimeSpeed; var asyncTimeCompForMap = new AsyncTimeComp(map, gameStartAbsTick) { diff --git a/Source/Client/Patches/Odyssey.cs b/Source/Client/Patches/Odyssey.cs index 460f3f20d..5f04978f0 100644 --- a/Source/Client/Patches/Odyssey.cs +++ b/Source/Client/Patches/Odyssey.cs @@ -47,6 +47,95 @@ static void Prefix(ref bool ___haveShownNameDialog) } } + // Vanilla caches each map's grav engine for the current TicksGame (GravshipUtility.GetPlayerGravEngine_NewTemp, + // behind Map.IsPlayerHome). The interface calls it too, at the current map's time: the same TicksGame as the + // commands executed before that map's next tick and its MapPreTick. A value the interface cached before a command + // changed the map would then be used by the simulation on some clients only. Every tick and command starts with an + // empty cache instead, so its content depends only on the simulation. + public static class GravEngineCache + { + private static readonly Dictionary<(Map, Faction), Building_GravEngine> factionEngines = new(); + private static int factionEnginesTick = -1; + private static readonly List tmpThings = new(); + + public static void Reset() + { + GravshipUtility.tmpMapToGravship.Clear(); + GravshipUtility.lastCachedEngineTick = -1; + + factionEngines.Clear(); + factionEnginesTick = -1; + } + + public static bool OfOtherPlayer(Thing engine, Faction faction) => + engine.Faction is { IsPlayer: true } f && f != faction; + + // Cached like vanilla's lookup + public static Building_GravEngine GetEngineOf(Map map, Faction faction) + { + if (Find.TickManager.TicksGame != factionEnginesTick) + { + factionEngines.Clear(); + factionEnginesTick = Find.TickManager.TicksGame; + } + + if (!factionEngines.TryGetValue((map, faction), out var engine)) + factionEngines[(map, faction)] = engine = FindEngine(map, faction); + + return engine; + } + + // Vanilla's search order: spawned engines, spawned minified ones, then minified ones held in containers + private static Building_GravEngine FindEngine(Map map, Faction faction) + { + var def = ThingDefOf.GravEngine; + + foreach (var t in map.listerThings.ThingsOfDef(def)) + if (t is Building_GravEngine e && !OfOtherPlayer(e, faction)) + return e; + + foreach (var t in map.listerThings.ThingsOfDef(def.minifiedDef)) + if (t.GetInnerIfMinified() is Building_GravEngine e && !OfOtherPlayer(e, faction)) + return e; + + try + { + ThingOwnerUtility.GetAllThingsRecursively(map, ThingRequest.ForDef(def.minifiedDef), tmpThings, + allowUnreal: true, null, alsoGetSpawnedThings: false); + + foreach (var t in tmpThings) + if (t.GetInnerIfMinified() is Building_GravEngine e && !OfOtherPlayer(e, faction)) + return e; + } + finally + { + tmpThings.Clear(); + } + + return null; + } + } + + // Multifaction: vanilla takes the first grav engine on the map, which can be another player's (making the map a + // home of every player faction, see Map.IsPlayerHome). Under a player faction's context look for one that isn't + // another player faction's (unowned engines count, as in vanilla). The spectator context (world ticks, maps of no + // player, see Map_IsPlayerHome_Spectator_Patch) keeps vanilla's result. + [HarmonyPatch(typeof(GravshipUtility), nameof(GravshipUtility.GetPlayerGravEngine_NewTemp))] + static class GetPlayerGravEngineFactionPatch + { + static void Postfix(Map map, ref Building_GravEngine __result) + { + if (__result == null || Multiplayer.Client == null || !Multiplayer.GameComp.multifaction) return; + + var faction = Faction.OfPlayer; + if (faction == null || faction == Multiplayer.WorldComp.spectatorFaction || + !GravEngineCache.OfOtherPlayer(__result, faction)) + return; + + __result = GravEngineCache.GetEngineOf(map, faction); + } + } + [HarmonyPatch(typeof(CompStatue), nameof(CompStatue.InitFakePawn))] public static class PatchInitFakePawnToNotSyncPawnName { diff --git a/Source/Client/Patches/Patches.cs b/Source/Client/Patches/Patches.cs index 344833379..013f7beb5 100644 --- a/Source/Client/Patches/Patches.cs +++ b/Source/Client/Patches/Patches.cs @@ -33,21 +33,30 @@ static class JobTrackerStartFixFrames // and handling this case properly might prevent some crashes and help with debugging static IEnumerable Transpiler(IEnumerable insts) { + int patched = 0; + foreach (var inst in insts) { yield return inst; - if (inst.operand as FieldInfo == FrameCountField) + if (inst.opcode == OpCodes.Ldsfld && inst.operand as FieldInfo == FrameCountField) { yield return new CodeInstruction(OpCodes.Ldarg_0); yield return new CodeInstruction(OpCodes.Call, FrameCountReplacementMethod); + patched++; } } + + if (patched == 0) + Multiplayer.LoadingError("MP: Couldn't find RealTime.frameCount in Pawn_JobTracker.StartJob. Was the original method changed?"); } static int FrameCountReplacement(int frameCount, Pawn_JobTracker tracker) { - return tracker.pawn.Map.AsyncTime()?.eventCount ?? frameCount; + if (Multiplayer.Client == null) return frameCount; + + // The frame count differs between clients. Pawns without a map (caravans, world pawns) use the timer. + return tracker.pawn.MapHeld?.AsyncTime()?.eventCount ?? TickPatch.Timer; } } @@ -192,6 +201,26 @@ static void Postfix(ref PlanetTile __result) } } + // Set only while a debug command is executed (see DebugSync.HandleCmd) + [HarmonyPatch(typeof(WorldRendererUtility), nameof(WorldRendererUtility.WorldSelected), MethodType.Getter)] + public static class WorldSelectedPatch + { + public static bool? result; + + static void Prepare(MethodBase original) + { + // A one-line getter: keep the JIT from inlining it into callers, which would skip the patch + if (original != null) + MpUtil.MarkNoInlining(original); + } + + static void Postfix(ref bool __result) + { + if (result.HasValue) + __result = result.Value; + } + } + [HarmonyPatch(typeof(KeyBindingDef), nameof(KeyBindingDef.IsDownEvent), MethodType.Getter)] public static class KeyIsDownPatch { diff --git a/Source/Client/Patches/PathFinderPatch.cs b/Source/Client/Patches/PathFinderPatch.cs index 13639324d..11c95a7c0 100644 --- a/Source/Client/Patches/PathFinderPatch.cs +++ b/Source/Client/Patches/PathFinderPatch.cs @@ -6,6 +6,24 @@ using System.Reflection.Emit; using Verse; +namespace Multiplayer.Client.Patches +{ + // Path jobs scheduled by PathFinderTick run on worker threads and read live map state (e.g. pawn positions). + // AsyncTimeComp.Tick already completes them right after MapPreTick; also do it right after PathFinderTick so + // that they can never overlap main thread changes, whoever calls it (rest of MapPreTick, mods). + [HarmonyPatch(typeof(PathFinder), nameof(PathFinder.PathFinderTick))] + static class PathFinderTickCompleteJobs + { + static void Postfix(PathFinder __instance) + { + if (Multiplayer.Client != null) + __instance.ForceCompleteScheduledJobs(); + } + } +} + +// Disabled alternative to ForceCompleteScheduledJobs (see #900): snapshot the data worker threads read instead of +// waiting for them. Kept for reference only, it isn't compiled. #if false namespace Multiplayer.Client.Patches { diff --git a/Source/Client/Patches/Plans.cs b/Source/Client/Patches/Plans.cs index 42f2f89fa..3a6439bb5 100644 --- a/Source/Client/Patches/Plans.cs +++ b/Source/Client/Patches/Plans.cs @@ -1,5 +1,6 @@ using HarmonyLib; using Multiplayer.API; +using Multiplayer.Client.Util; using RimWorld; using System; using System.Collections.Generic; @@ -11,18 +12,28 @@ namespace Multiplayer.Client.Patches [HarmonyPatch] public static class PlanGetGizmosPatch { + private static FieldInfo planField; + private static FieldInfo colorField; + static IEnumerable TargetMethods() { - Type planType = AccessTools.TypeByName("Verse.Plan+<>c__DisplayClass45_0"); - yield return AccessTools.Method(planType, "b__1"); + // The color option's action, <>c__DisplayClass45_0.b__1 in 1.6 + var lambda = MpMethodUtil.GetLambda(typeof(Plan), nameof(Plan.GetGizmos), lambdaOrdinal: 1); + + planField = AccessTools.Field(lambda.DeclaringType, "<>4__this"); + colorField = AccessTools.Field(lambda.DeclaringType, "newCol"); + if (planField == null || colorField == null) + throw new Exception($"Plan color lambda {lambda.DeclaringType}::{lambda.Name} doesn't capture the expected fields"); + + yield return lambda; } static bool Prefix(object __instance) { if (Multiplayer.Client == null) return true; - Plan plan = (Plan)AccessTools.Field(__instance.GetType(), "<>4__this").GetValue(__instance); - ColorDef color = (ColorDef)AccessTools.Field(__instance.GetType(), "newCol").GetValue(__instance); + Plan plan = (Plan)planField.GetValue(__instance); + ColorDef color = (ColorDef)colorField.GetValue(__instance); SyncSetPlanColor(plan, color); return false; diff --git a/Source/Client/Patches/Seeds.cs b/Source/Client/Patches/Seeds.cs index 31e4368a7..e0b608987 100644 --- a/Source/Client/Patches/Seeds.cs +++ b/Source/Client/Patches/Seeds.cs @@ -5,7 +5,9 @@ using System.Collections.Generic; using System.Reflection; using System.Reflection.Emit; +using Multiplayer.Client.Patches; using Multiplayer.Client.Util; +using RimWorld.QuestGen; using Verse; using Verse.Grammar; @@ -23,7 +25,7 @@ static void Prefix() Rand.Seed = 1; } - static void Postfix() + static void Finalizer() { Rand.PopState(); } @@ -44,7 +46,7 @@ static void Prefix(Map __instance, ref bool __state) __state = true; } - static void Postfix(Map __instance, bool __state) + static void Finalizer(bool __state) { if (__state) Rand.PopState(); @@ -65,7 +67,7 @@ static void Prefix(Map __instance, ref bool __state) __state = true; } - static void Postfix(Map __instance, bool __state) + static void Finalizer(bool __state) { if (__state) Rand.PopState(); @@ -85,29 +87,36 @@ static void Prefix(Map map, ref bool __state) __state = true; } - static void Postfix(Map map, bool __state) + static void Finalizer(bool __state) { if (__state) Rand.PopState(); } } + // The seed and other simulation context are applied when the event runs, see SimLongEventContext [HarmonyPatch(typeof(LongEventHandler), nameof(LongEventHandler.QueueLongEvent), typeof(Action), typeof(string), typeof(bool), typeof(Action), typeof(bool), typeof(bool), typeof(Action))] static class SeedLongEvents { - static void Prefix(ref Action action, ref Action callback) + static void Prefix(string textKey, ref SimLongEventContext __state) { - if (Multiplayer.Client != null && (Multiplayer.Ticking || Multiplayer.ExecutingCmds)) - { - var seed = Rand.Int; - action = (() => Rand.PushState(seed)) + action + Rand.PopState; + // MpSaving events (autosaves) are queued from the non-simulation part of ConstantTicker on some clients only + if (SimLongEventContext.ShouldCapture && textKey != "MpSaving") + __state = SimLongEventContext.Capture(); + } - if (callback != null) - { - var callbackSeed = Rand.Int; - callback = (() => Rand.PushState(callbackSeed)) + callback + Rand.PopState; - } - } + static void Postfix(Action action, SimLongEventContext __state) + { + if (__state == null) return; + + // Find the event that was just queued (it isn't if another prefix cancelled it) + LongEventHandler.QueuedLongEvent queued = null; + foreach (var ev in LongEventHandler.eventQueue) + if (ReferenceEquals(ev.eventAction, action)) + queued = ev; + + if (queued != null) + SimLongEventContext.Attach(queued, __state); } } @@ -152,6 +161,16 @@ public static void Prefix(ref bool __state) __state = true; } + // For methods that may also run on worker threads (e.g. async long events), where touching the shared + // Rand state stack would race with the main thread + [HarmonyPriority(MpPriority.MpFirst)] + public static void PrefixMainThread(ref bool __state) + { + if (!UnityData.IsInMainThread) return; + Rand.PushState(); + __state = true; + } + [HarmonyPriority(MpPriority.MpLast)] public static void Finalizer(bool __state) { @@ -160,6 +179,35 @@ public static void Finalizer(bool __state) } } + /// + /// For code inside a tick or command which runs only on some clients, e.g. a window opened only for the issuer of + /// a command or local UI updates. Keeps it off the shared RNG stream, unique id counters and desync traces. + /// + public readonly struct LocalOnlyScope : IDisposable + { + private readonly bool prevLocalIds; + + private LocalOnlyScope(bool prevLocalIds) => this.prevLocalIds = prevLocalIds; + + public static LocalOnlyScope Enter() + { + var scope = new LocalOnlyScope(Patches.UniqueIdsPatch.useLocalIdsOverride); + + Rand.PushState(); + Desyncs.DeferredStackTracing.ignoreTraces++; + Patches.UniqueIdsPatch.useLocalIdsOverride = true; + + return scope; + } + + public void Dispose() + { + Patches.UniqueIdsPatch.useLocalIdsOverride = prevLocalIds; + Desyncs.DeferredStackTracing.ignoreTraces--; + Rand.PopState(); + } + } + [HarmonyPatch] static class SeedGrammar { @@ -168,6 +216,11 @@ static IEnumerable TargetMethods() yield return AccessTools.Method(typeof(GrammarResolver), nameof(GrammarResolver.Resolve)); yield return AccessTools.Method(typeof(PawnBioAndNameGenerator), nameof(PawnBioAndNameGenerator.GeneratePawnName)); yield return AccessTools.Method(typeof(NameGenerator), nameof(NameGenerator.GenerateName), [typeof(RulePackDef), typeof(Predicate), typeof(bool), typeof(string), typeof(string), typeof(List)]); + // Called directly by quest, ideo, deity and unique weapon naming; its validator retries depend on the + // generated text, so on the game language + yield return AccessTools.Method(typeof(NameGenerator), nameof(NameGenerator.GenerateName), [typeof(GrammarRequest), typeof(Predicate), typeof(bool), typeof(string), typeof(string)]); + // Retries whole GenerateName calls while the name collides with an existing quest's name + yield return AccessTools.Method(typeof(QuestNode_ResolveQuestName), nameof(QuestNode_ResolveQuestName.Resolve)); } [HarmonyPriority(MpPriority.MpFirst)] @@ -179,7 +232,7 @@ static void Prefix(ref bool __state) } [HarmonyPriority(MpPriority.MpLast)] - static void Postfix(bool __state) + static void Finalizer(bool __state) { if (__state) Rand.PopState(); @@ -202,7 +255,7 @@ static void Prefix(PawnRenderer __instance, ref bool __state) } [HarmonyPriority(MpPriority.MpLast)] - static void Postfix(bool __state) + static void Finalizer(bool __state) { if (__state) Rand.PopState(); diff --git a/Source/Client/Patches/TeachingPatches.cs b/Source/Client/Patches/TeachingPatches.cs index dd229f8df..f03e9d59b 100644 --- a/Source/Client/Patches/TeachingPatches.cs +++ b/Source/Client/Patches/TeachingPatches.cs @@ -14,8 +14,8 @@ static bool Prefix(Thing t, ref Job __result) // FloatMenuMakerMap.AddJobGiverWorkOrders despite not assigning the job yet. if (!Multiplayer.InInterface) return true; - // If target is not pawn, no result - if (t is not Pawn pawn) + // If target is not pawn (or has no lesson to give), no result + if (t is not Pawn pawn || pawn.CurJob == null) return false; // Alternative approach would be to store the current target of the lesson taking pawn in the diff --git a/Source/Client/Patches/TickPatch.cs b/Source/Client/Patches/TickPatch.cs index a8068091b..44fe3d513 100644 --- a/Source/Client/Patches/TickPatch.cs +++ b/Source/Client/Patches/TickPatch.cs @@ -2,9 +2,11 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.CompilerServices; using HarmonyLib; using LudeonTK; using Multiplayer.Client.AsyncTime; +using Multiplayer.Client.Patches; using Multiplayer.Common; using Multiplayer.Common.Networking.Packet; using RimWorld.Planet; @@ -23,6 +25,9 @@ public static class TickPatch public static int workTicks; public static bool currentExecutingCmdIssuedBySelf; public static CommandType? currentExecutingCmdType; + // Changes at the start of every command, tick of a tickable and sim long event. Interface code (and so a frame) can't run + // within one step, so caches keyed by it behave the same on every client, unlike ones keyed by frame. + public static int simStep; public static bool serverFrozen; public static int frozenAt; @@ -49,9 +54,11 @@ public static IEnumerable AllTickables { yield return Multiplayer.AsyncWorldTime; + // A map being generated is in Find.Maps before MapSetup gives it its comps var maps = Find.Maps; for (int i = maps.Count - 1; i >= 0; i--) - yield return maps[i].AsyncTime(); + if (maps[i].AsyncTime() is { } comp) + yield return comp; } } @@ -110,7 +117,7 @@ static bool Prefix() if (MpVersion.IsDebug) SimpleProfiler.Start(); - RunCmds(); + RunCmdsBeforeTicks(); if (LongEventHandler.eventQueue.Count == 0) { DoUpdate(out var worked); @@ -165,26 +172,45 @@ static void Postfix() Shader.SetGlobalFloat(ShaderPropertyIDs.GameSeconds, Find.CurrentMap.AsyncTime().mapTicks.TicksToSeconds()); } + // Commands run either here at the start of the frame or in the DoUpdate loop. The desync trace hash includes + // the stack depth, so both paths have to be equally deep: Prefix -> RunCmdsBeforeTicks -> RunCmds like + // Prefix -> DoUpdate -> RunCmds (#994). + [MethodImpl(MethodImplOptions.NoInlining)] + private static void RunCmdsBeforeTicks() => RunCmds(); + private static bool RunCmds() { int curTimer = Timer; - foreach (ITickable tickable in AllTickables) + // All commands (world and map) are in the world's queue, in the order the server sent them (#733) + var cmds = Multiplayer.AsyncWorldTime.Cmds; + + while (cmds.Count > 0 && cmds.Peek().ticks <= curTimer) { - while (tickable.Cmds.Count > 0 && tickable.Cmds.Peek().ticks == curTimer) - { - ScheduledCommand cmd = tickable.Cmds.Dequeue(); + ScheduledCommand cmd = cmds.Dequeue(); - // Minimal code impact fix for #733. Having all the commands be added to a single queue gets rid of - // the out-of-order execution problem. With a proper fix, this can be reverted to tickable.ExecuteCmd - var target = TickableById(cmd.mapId); - if (target == null) - { - Log.Error($"!!! Tickable of {cmd.mapId} not found! {cmd}"); - } else target.ExecuteCmd(cmd); + if (cmd.ticks < curTimer) + { + // Can only happen with a broken log (e.g. a world state newer than its commands). The loaded state + // already contains the command's effects and executing it late can't match other clients, so + // drop it instead of blocking the queue forever. + Log.Error($"Multiplayer: dropping command scheduled for a past tick (timer {curTimer}): {cmd}"); + continue; + } - if (LongEventHandler.eventQueue.Count > 0) return true; // Yield to e.g. join-point creation + var target = TickableById(cmd.mapId); + if (target == null) + { + Log.Error($"!!! Tickable of {cmd.mapId} not found! {cmd}"); + } + else + { + GravEngineCache.Reset(); + simStep++; + target.ExecuteCmd(cmd); } + + if (LongEventHandler.eventQueue.Count > 0) return true; // Yield to e.g. join-point creation } return false; @@ -218,15 +244,24 @@ public static bool DoTick(ref bool worked) { tickTimer.Restart(); - foreach (ITickable tickable in AllTickables) + if (Multiplayer.GameComp.asyncTime) { - if (tickable.TimePerTick(tickable.DesiredTimeSpeed) == 0) continue; - tickable.TimeToTickThrough += 1f; + foreach (ITickable tickable in AllTickables) + { + if (tickable.TimePerTick(tickable.DesiredTimeSpeed) == 0) continue; + tickable.TimeToTickThrough += 1f; - worked = true; - TickTickable(tickable); + worked = true; + TickTickable(tickable); + } + } + else + { + TickLockstep(ref worked); } + GravEngineCache.Reset(); + simStep++; ConstantTicker.Tick(); ticksToRun -= 1; @@ -243,6 +278,61 @@ public static bool DoTick(ref bool worked) return false; } + // Without async time the world and the maps share one clock, but each tickable used to tick through its own + // accumulator, and they drifted apart (#339): a new map started with a fresh accumulator and got an extra tick, + // a rate change during a map tick (a pausing letter, TimeSlower, a pausing session) reached only the tickables + // after it, and removing a map during a map tick shifted the lazily read map list so another map ticked twice. + // Here every game tick ticks the world and then each map once, the rate (the world's, which runs at the fastest + // map's speed) is checked only between game ticks like in vanilla's tick loop, and only the world's accumulator + // is used. Maps generated during a game tick start at worldTicks (MapSetup): one generated by the world tick + // still ticks in this game tick, one generated by a map tick from the next one. + private static void TickLockstep(ref bool worked) + { + var world = Multiplayer.AsyncWorldTime; + if (world.TimePerTick(world.DesiredTimeSpeed) == 0) return; + world.TimeToTickThrough += 1f; + + worked = true; + List maps = null; + + while (world.TimeToTickThrough >= 0) + { + float timePerTick = world.TimePerTick(world.DesiredTimeSpeed); + if (timePerTick == 0) break; + + world.TimeToTickThrough -= timePerTick; + TickOnce(world); + + // Same order as AllTickables + maps ??= new List(); + maps.Clear(); + var gameMaps = Find.Maps; + for (int i = gameMaps.Count - 1; i >= 0; i--) + if (gameMaps[i].AsyncTime() is { } comp) + maps.Add(comp); + + foreach (var comp in maps) + if (!comp.map.Disposed && Find.Maps.Contains(comp.map)) // Removed during this game tick + TickOnce(comp); + } + } + + private static void TickOnce(ITickable tickable) + { + simStep++; + + try + { + GravEngineCache.Reset(); + tickable.Tick(); + } + catch (Exception e) + { + Log.Error($"Exception during ticking {tickable}: {e}"); + SimulationFailures.Record($"ticking {tickable}", e); + } + } + private static void TickTickable(ITickable tickable) { while (tickable.TimeToTickThrough >= 0) @@ -251,14 +341,17 @@ private static void TickTickable(ITickable tickable) if (timePerTick == 0) break; tickable.TimeToTickThrough -= timePerTick; + simStep++; try { + GravEngineCache.Reset(); tickable.Tick(); } catch (Exception e) { Log.Error($"Exception during ticking {tickable}: {e}"); + SimulationFailures.Record($"ticking {tickable}", e); } } } @@ -291,7 +384,8 @@ 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)); + if (map.AsyncTime() is { } comp) + rate = Math.Min(rate, comp.TickRateMultiplier(speed)); return rate; } diff --git a/Source/Client/Patches/TileTemperatures.cs b/Source/Client/Patches/TileTemperatures.cs index 02b8e3e30..8c64d98de 100644 --- a/Source/Client/Patches/TileTemperatures.cs +++ b/Source/Client/Patches/TileTemperatures.cs @@ -1,9 +1,11 @@ using HarmonyLib; using RimWorld; using RimWorld.Planet; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using UnityEngine; using Verse; namespace Multiplayer.Client @@ -28,11 +30,31 @@ static void Prefix(PlanetTile ___tile, ref TimeSnapshot? __state) [HarmonyPatch(typeof(TileTemperaturesComp), nameof(TileTemperaturesComp.RetrieveCachedData))] static class RetrieveCachedData_Patch { + private static int uiTickedAtFrame = -1; + static bool Prefix(TileTemperaturesComp __instance, PlanetTile tile, ref TileTemperaturesComp.CachedTileTemperatureData __result) { if (Multiplayer.InInterface && __instance != Multiplayer.WorldComp.uiTemperatures) { - __result = Multiplayer.WorldComp.uiTemperatures.RetrieveCachedData(tile); + var uiTemperatures = Multiplayer.WorldComp.uiTemperatures; + + // The UI cache isn't a real world component, so vanilla never ticks it and its entries would never + // refresh or get evicted. Tick it (at most once per frame) while the interface uses it. + if (uiTickedAtFrame != Time.frameCount) + { + uiTickedAtFrame = Time.frameCount; + + try + { + uiTemperatures.WorldComponentTick(); + } + catch (Exception e) + { + Log.ErrorOnce($"MP: Exception ticking UI tile temperatures: {e}", 846582641); + } + } + + __result = uiTemperatures.RetrieveCachedData(tile); return false; } @@ -40,16 +62,6 @@ static bool Prefix(TileTemperaturesComp __instance, PlanetTile tile, ref TileTem } } - [HarmonyPatch(typeof(TileTemperaturesComp), nameof(TileTemperaturesComp.WorldComponentTick))] - static class TileTemperaturesTick_Patch - { - static void Prefix(TileTemperaturesComp __instance) - { - if (Multiplayer.InInterface && __instance != Multiplayer.WorldComp.uiTemperatures) - Multiplayer.WorldComp.uiTemperatures.WorldComponentTick(); - } - } - [HarmonyPatch(typeof(GenTemperature), nameof(GenTemperature.AverageTemperatureAtTileForTwelfth))] static class CacheAverageTileTemperature { diff --git a/Source/Client/Patches/TimestampFixer.cs b/Source/Client/Patches/TimestampFixer.cs index 5526021d1..2a0e106d1 100644 --- a/Source/Client/Patches/TimestampFixer.cs +++ b/Source/Client/Patches/TimestampFixer.cs @@ -14,11 +14,23 @@ public static class TimestampFixer { delegate ref int FieldGetter(T obj) where T : IExposable; - private static Dictionary>> timestampFields = new(); + // Shifts a timestamp field by the given offset + delegate void FieldFixer(IExposable obj, int offset); - private static void Add(FieldGetter t) where T : IExposable + private static Dictionary> timestampFields = new(); + + // timestampFields including the fields of base types, by the exact type + private static Dictionary> resolvedFields = new(); + + // Values below minValid are "never" markers: kept as they are, and shifted values are clamped to stay valid + private static void Add(FieldGetter t, int minValid = int.MinValue) where T : IExposable { - timestampFields.GetOrAddNew(typeof(T)).Add(obj => ref t((T)obj)); + timestampFields.GetOrAddNew(typeof(T)).Add((obj, offset) => + { + ref int field = ref t((T)obj); + if (field >= minValid) + field = Math.Max(minValid, field + offset); + }); } static TimestampFixer() @@ -27,6 +39,12 @@ static TimestampFixer() Add((Pawn_MindState mind) => ref mind.canLovinTick); Add((Pawn_GuestTracker guest) => ref guest.ticksWhenAllowedToEscapeAgain); Add((Pawn_GuestTracker guest) => ref guest.lastPrisonBreakTicks); + Add((Need_Food food) => ref food.lastNonStarvingTick); + Add((Ability ability) => ref ability.cooldownEndTick); + Add((Ability ability) => ref ability.lastCastTick, minValid: 0); + Add((Pawn_SkillTracker skills) => ref skills.lastXpSinceMidnightResetTimestamp, minValid: 0); + Add((FactionPermit permit) => ref permit.lastUsedTick, minValid: 1); + Add((Pawn_RoyaltyTracker royalty) => ref royalty.lastDecreeTicks); } public static int? currentOffset; @@ -35,14 +53,21 @@ public static void FixPawn(Pawn p, Map oldMap, Map newMap) { var oldTime = oldMap?.AsyncTime().mapTicks ?? Multiplayer.AsyncWorldTime.worldTicks; var newTime = newMap?.AsyncTime().mapTicks ?? Multiplayer.AsyncWorldTime.worldTicks; + if (newTime == oldTime) return; + currentOffset = newTime - oldTime; MpLog.Debug($"Fixing pawn timestamps for {p} moving from {oldMap?.ToString() ?? "World"}:{oldTime} to {newMap?.ToString() ?? "World"}:{newTime}"); try { - // Auxiliary save which is used to visit the pawn's data - Scribe.saver.DebugOutputFor(p); + // Saving a destroyed pawn logs a warning, and every pawn killed on a map is passed to the world pawns. Its + // timestamps still have to move to the world's time: they're shifted back if it's resurrected. + if (p.Destroyed) + FixDirectly(p); + else + // Auxiliary save which is used to visit the pawn's data + Scribe.saver.DebugOutputFor(p); } finally { @@ -50,11 +75,46 @@ public static void FixPawn(Pawn p, Map oldMap, Map newMap) } } + // The holders of the fields above, without the save pass's walk through the pawn's data + private static void FixDirectly(Pawn p) + { + ProcessExposable(p.mindState); + ProcessExposable(p.guest); + ProcessExposable(p.needs?.food); + ProcessExposable(p.skills); + ProcessExposable(p.royalty); + + // Also the abilities of titles, equipment, apparel and hediffs, like the save pass. Not the ideo roles' ones, + // the ideo holds those + if (p.abilities != null) + foreach (var ability in p.abilities.AllAbilitiesForReading) + if (ability.sourcePrecept is not Precept_Role) + ProcessExposable(ability); + + if (p.royalty != null) + foreach (var permit in p.royalty.AllFactionPermits) + ProcessExposable(permit); + } + public static void ProcessExposable(IExposable exposable) { - if (timestampFields.TryGetValue(exposable.GetType(), out var fieldGetters)) - foreach (var del in fieldGetters) - del(exposable) += currentOffset!.Value; + if (exposable == null) return; + + foreach (var fixer in FieldsFor(exposable.GetType())) + fixer(exposable, currentOffset!.Value); + } + + private static List FieldsFor(Type type) + { + if (resolvedFields.TryGetValue(type, out var fields)) + return fields; + + fields = new List(); + for (var t = type; t != null; t = t.BaseType) + if (timestampFields.TryGetValue(t, out var own)) + fields.AddRange(own); + + return resolvedFields[type] = fields; } } diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index eb5812a86..925bd35fc 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -54,7 +54,6 @@ static class VTRSync public const int WorldMapId = -2; public const int InvalidMapId = -1; public static int lastMovedToMapId = InvalidMapId; - public static int lastSentAtTick = -1; // Vtr rates public const int MaximumVtr = 15; @@ -62,65 +61,59 @@ static class VTRSync public static int GetSynchronizedUpdateRate(Thing thing) => thing?.MapHeld?.AsyncTime()?.VTR ?? MaximumVtr; - public static void SendViewedMapUpdate(int previous, int current) + public static int CurrentViewId() => + WorldRendererUtility.WorldSelected ? WorldMapId : Find.CurrentMap?.uniqueID ?? InvalidMapId; + + // Called every frame. The counts follow what the player looks at however the view changed: catching only + // the CurrentMap setter and world map toggles missed views that never went through them. A multifaction + // joiner starts on the world without a current map, command execution changes the map index directly, + // and hosting creates the game with a save and reload (during which nothing is reported). + public static void UpdateView() { - if (Multiplayer.reloading) + if (Multiplayer.arbiterInstance || Multiplayer.reloading || LongEventHandler.AnyEventNowOrWaiting) return; - string warn = string.Empty; - if (previous != lastMovedToMapId) - warn = $" mismatch between expected previous map {previous} and last moved to map {lastMovedToMapId}"; - 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)); - lastMovedToMapId = current; + if (Multiplayer.session.client.State != ConnectionStateEnum.ClientPlaying) + return; + + int view = CurrentViewId(); + if (view != lastMovedToMapId) + SendViewedMapUpdate(lastMovedToMapId, view); } - public static void Reset() + // The host reports its view as soon as its server runs + public static void AnnounceCurrentView() => SendViewedMapUpdate(lastMovedToMapId, CurrentViewId()); + + public static void ResetAllPlayerCounts() { - lastMovedToMapId = InvalidMapId; - lastSentAtTick = -1; + Multiplayer.AsyncWorldTime.ResetPlayerCount(); + foreach (var map in Find.Maps) + map.AsyncTime()?.ResetPlayerCount(); } - } - [HarmonyPatch(typeof(Game), nameof(Game.CurrentMap), MethodType.Setter)] - static class MapSwitchPatch - { - static void Prefix(Map value) + // previous must be what the server last got from this client: the server and every client apply the + // transition to the synced counts + public static void SendViewedMapUpdate(int previous, int current) { - if (Multiplayer.Client == null) return; + // Join point reloads keep the saved counts, the view doesn't change + if (Multiplayer.reloading) + return; - try - { - // WorldRenderModePatch will handle it - if (VTRSync.lastMovedToMapId == VTRSync.WorldMapId) return; - int previousMap = GetPreviousMapId(); - int newMap = value?.uniqueID ?? VTRSync.InvalidMapId; - int currentTick = Find.TickManager?.TicksGame ?? 0; + // The arbiter doesn't view anything; counting it kept its map at the full rate for everyone + if (Multiplayer.arbiterInstance) + return; - if (previousMap == newMap) return; - if (VTRSync.lastMovedToMapId == newMap && currentTick == VTRSync.lastSentAtTick) return; + if (previous == current) return; - VTRSync.SendViewedMapUpdate(previousMap, newMap); - VTRSync.lastSentAtTick = currentTick; - } - catch (Exception ex) - { - MpLog.Error($"VTR MapSwitchPatch error: {ex}"); - } + MpLog.Debug($"VTR view: {previous}->{current} @ tick {Find.TickManager?.TicksGame ?? 0}"); + Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(previous, current)); + lastMovedToMapId = current; } - private static int GetPreviousMapId() + // A new connection starts unknown to the server (which counts its own record of the player's view) + public static void Reset() { - bool currentMapIsRemovedAndWasLatestMap = Current.Game.currentMapIndex >= Find.Maps.Count; - - if (currentMapIsRemovedAndWasLatestMap) - { - return VTRSync.InvalidMapId; - } - - return Find.CurrentMap?.uniqueID ?? VTRSync.InvalidMapId; + lastMovedToMapId = InvalidMapId; } } @@ -135,27 +128,13 @@ static void Postfix(WorldRenderMode __result) try { - // Detect transition to world map (Planet mode) + // Detect transition to world map (Planet mode). Viewer counts are updated by VTRSync.UpdateView. if (__result == WorldRenderMode.Planet && lastRenderMode != WorldRenderMode.Planet) { - if (VTRSync.lastMovedToMapId != VTRSync.InvalidMapId && VTRSync.lastMovedToMapId != VTRSync.WorldMapId) - { - VTRSync.SendViewedMapUpdate(VTRSync.lastMovedToMapId, VTRSync.WorldMapId); - } - // On standalone with streaming, trigger a join point when leaving a map // so each player can save independently without disturbing others if (Multiplayer.session?.ConnectedToStandaloneServer == true && Multiplayer.GameComp.multifaction && Multiplayer.GameComp.asyncTime) - Autosaving.SendAutosavingRequest(JoinPointRequestReason.WorldTravel); - } - // Detect transition back to tile map - else if (__result != WorldRenderMode.Planet && lastRenderMode == WorldRenderMode.Planet) - { - var newMap = Find.CurrentMap?.uniqueID ?? VTRSync.InvalidMapId; - if (newMap != VTRSync.InvalidMapId && VTRSync.lastMovedToMapId == VTRSync.WorldMapId) - { - VTRSync.SendViewedMapUpdate(VTRSync.WorldMapId, newMap); - } + Autosaving.SendAutosavingRequest(JoinPointRequestReason.WorldTravel, TickPatch.Timer); } lastRenderMode = __result; diff --git a/Source/Client/Persistent/CaravanFormingPatches.cs b/Source/Client/Persistent/CaravanFormingPatches.cs index 8a35de1f5..09ce95593 100644 --- a/Source/Client/Persistent/CaravanFormingPatches.cs +++ b/Source/Client/Persistent/CaravanFormingPatches.cs @@ -7,6 +7,7 @@ using UnityEngine; using Verse; using static Verse.Widgets; +using System.Linq; using System.Reflection; namespace Multiplayer.Client.Persistent @@ -176,7 +177,7 @@ static void Prefix(Dialog_FormCaravan __instance, Map map, bool reform, Action o { var comp = map.MpComp(); if (comp.sessionManager.GetFirstOfType() == null) - comp.CreateCaravanFormingSession(faction, reform, onClosed, mapAboutToBeRemoved, designatedMeetingPoint); + comp.CreateCaravanFormingSession(GetFormingFaction(map, faction), reform, onClosed, mapAboutToBeRemoved, designatedMeetingPoint); } else // Handles opening from the interface: forming gizmos, reforming gizmos and caravan hitching spots { @@ -184,6 +185,26 @@ static void Prefix(Dialog_FormCaravan __instance, Map map, bool reform, Action o } } + // Outside of commands Faction.OfPlayer is the map's owner or the spectator faction (e.g. on maps without + // a player owner), which would make the session unusable for everyone. Use a faction with pawns on the map. + private static Faction GetFormingFaction(Map map, Faction ofPlayer) + { + if (Multiplayer.ExecutingCmds || !Multiplayer.GameComp.multifaction) + return ofPlayer; + + bool HasPawnsOnMap(Faction f) => map.mapPawns.SpawnedPawnsInFaction(f).Any(p => p.RaceProps.Humanlike); + + var spectator = Multiplayer.WorldComp.spectatorFaction; + if (ofPlayer != spectator && HasPawnsOnMap(ofPlayer)) + return ofPlayer; + + foreach (var f in Find.FactionManager.AllFactionsListForReading) + if (f.IsPlayer && f != spectator && HasPawnsOnMap(f)) + return f; + + return ofPlayer; + } + [SyncMethod] internal static void StartFormingCaravan(Faction faction, Map map, bool reform = false, IntVec3? designatedMeetingPoint = null, PlanetTile? routePlannerWaypoint = null) { @@ -192,7 +213,9 @@ internal static void StartFormingCaravan(Faction faction, Map map, bool reform = if (TickPatch.currentExecutingCmdIssuedBySelf) { - CaravanFormingProxy dialog = session.OpenWindow(); + CaravanFormingProxy dialog; + using (LocalOnlyScope.Enter()) + dialog = session.OpenWindow(); if (!routePlannerWaypoint.HasValue) return; diff --git a/Source/Client/Persistent/CaravanFormingSession.cs b/Source/Client/Persistent/CaravanFormingSession.cs index 949ace19a..64d61b5f7 100644 --- a/Source/Client/Persistent/CaravanFormingSession.cs +++ b/Source/Client/Persistent/CaravanFormingSession.cs @@ -14,6 +14,8 @@ public class CaravanFormingSession : ExposableSession, ISessionWithTransferables public Faction faction; public bool reform; + // Never invoked (and not saved): an Action can't be persisted, so running it would differ between + // clients that loaded the session from a save and clients that created it. public Action onClosed; public bool mapAboutToBeRemoved; public PlanetTile startingTile = -1; @@ -175,7 +177,6 @@ public override void ExposeData() Scribe_References.Look(ref faction, "faction"); Scribe_Values.Look(ref reform, "reform"); - Scribe_Values.Look(ref onClosed, "onClosed"); Scribe_Values.Look(ref mapAboutToBeRemoved, "mapAboutToBeRemoved"); Scribe_Values.Look(ref startingTile, "startingTile"); Scribe_Values.Look(ref destinationTile, "destinationTile"); diff --git a/Source/Client/Persistent/CaravanSplittingPatches.cs b/Source/Client/Persistent/CaravanSplittingPatches.cs index 7e429125b..b1a26954d 100644 --- a/Source/Client/Persistent/CaravanSplittingPatches.cs +++ b/Source/Client/Persistent/CaravanSplittingPatches.cs @@ -64,7 +64,13 @@ static bool Prefix(Caravan caravan) [SyncMethod] public static void CreateSplittingSession(Caravan caravan) { - Multiplayer.WorldComp.sessionManager.AddSession(CaravanSplittingSession.Of(caravan)); + var session = CaravanSplittingSession.Of(caravan); + + // Only show the window to the caravan's players (everyone in a single faction game). It's opened in the + // command on those clients only, so keep it off the shared state. + if (Multiplayer.WorldComp.sessionManager.AddSession(session) && caravan.Faction == Multiplayer.RealPlayerFaction) + using (LocalOnlyScope.Enter()) + session.OpenWindow(); } } } diff --git a/Source/Client/Persistent/CaravanSplittingProxy.cs b/Source/Client/Persistent/CaravanSplittingProxy.cs index 50b655966..741bb279f 100644 --- a/Source/Client/Persistent/CaravanSplittingProxy.cs +++ b/Source/Client/Persistent/CaravanSplittingProxy.cs @@ -49,11 +49,13 @@ public override void DoWindowContents(Rect inRect) try { - if (session == null) + if (session == null || session.removed) { Close(); + return; } - else if (session.uiDirty) + + if (session.uiDirty) { CountToTransferChanged(); diff --git a/Source/Client/Persistent/CaravanSplittingSession.cs b/Source/Client/Persistent/CaravanSplittingSession.cs index c51b667eb..7431e8fa7 100644 --- a/Source/Client/Persistent/CaravanSplittingSession.cs +++ b/Source/Client/Persistent/CaravanSplittingSession.cs @@ -37,9 +37,15 @@ public Caravan Caravan /// /// Reference to the dialog that is being displayed. + /// UI only: it's local to each client (and not saved), synced methods must never depend on it. /// public CaravanSplittingProxy dialog; + /// + /// Set once the session has been removed, open dialogs close themselves. + /// + public bool removed; + public override bool IsSessionValid => Caravan != null; // Used when saving and loading @@ -61,15 +67,9 @@ public static CaravanSplittingSession Of(Caravan caravan) private void AddItems() { - CaravanSplittingProxy.CreatingProxy = true; - dialog = new CaravanSplittingProxy(Caravan) { - session = this - }; - CaravanSplittingProxy.CreatingProxy = false; - dialog.CalculateAndRecacheTransferables(); - transferables = dialog.transferables; - - Find.WindowStack.Add(dialog); + var proxy = PrepareDialogProxy(); + proxy.CalculateAndRecacheTransferables(); + transferables = proxy.transferables; } /// @@ -142,7 +142,7 @@ public void Notify_CountChanged(Transferable tr) /// [SyncMethod] public void CancelSplittingSession() { - dialog.Close(); + dialog?.Close(); Multiplayer.WorldComp.sessionManager.RemoveSession(this); } @@ -163,14 +163,24 @@ public void ResetSplittingSession() [SyncMethod] public void AcceptSplitSession() { - if (dialog.TrySplitCaravan()) + // Split using a fresh proxy (fresh mass caches) built from the session's state, so every client does the same + var proxy = PrepareDialogProxy(); + proxy.CountToTransferChanged(); + + if (proxy.TrySplitCaravan()) { SoundDefOf.Tick_High.PlayOneShotOnCamera(); - dialog.Close(false); + dialog?.Close(false); Multiplayer.WorldComp.sessionManager.RemoveSession(this); } } + public override void PostRemoveSession() + { + base.PostRemoveSession(); + removed = true; + } + public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { if (!Caravan.pawns.Contains(entry.pawn)) diff --git a/Source/Client/Persistent/GravshipTravelSession.cs b/Source/Client/Persistent/GravshipTravelSession.cs index 92461b7b8..6e9d4fc15 100644 --- a/Source/Client/Persistent/GravshipTravelSession.cs +++ b/Source/Client/Persistent/GravshipTravelSession.cs @@ -39,7 +39,10 @@ public static class GravshipTravelUtils { public static void OpenSessionAt(PlanetTile tile) { - Map map = Find.WorldObjects.MapParentAt(tile).Map; + // E.g. ArriveExistingMap without a map at the tile (vanilla logs an error and returns) + Map map = Find.WorldObjects.MapParentAt(tile)?.Map; + if (map == null) + return; if (HasSessionAt(map.Tile)) return; diff --git a/Source/Client/Persistent/GrowthMomentSession.cs b/Source/Client/Persistent/GrowthMomentSession.cs index 3c348ccdc..4b2ebab7f 100644 --- a/Source/Client/Persistent/GrowthMomentSession.cs +++ b/Source/Client/Persistent/GrowthMomentSession.cs @@ -7,10 +7,20 @@ namespace Multiplayer.Client.Persistent; -public class GrowthMomentSession(Map _) : ExposableSession(null), ITickingSession +public class GrowthMomentSession(Map map) : ExposableSession(null), ITickingSession { - public static GrowthMomentSession GetSessionFor(Pawn pawn) => - pawn.Map.MpComp().sessionManager.GetFirstOfType(sess => sess.Pawn == pawn); + // The child may have moved to another map, so look on every map. Sessions the child left (removed by their map's + // next tick) are skipped, so a new session can be created where the child is now. + public static GrowthMomentSession GetSessionFor(Pawn pawn) + { + if (pawn == null) return null; + + foreach (var m in Find.Maps) + if (m.MpComp()?.sessionManager.GetFirstOfType(sess => sess.Pawn == pawn && sess.IsActive) is { } session) + return session; + + return null; + } // There isn't a trait selected. public const int NullTraitIdx = -1; // intentionally the same as the "not found" return value for IndexOf @@ -18,31 +28,42 @@ public static GrowthMomentSession GetSessionFor(Pawn pawn) => // There's a chance to have a trait choice of "No Trait". It's represented by this public const int NoTraitTraitIdx = -2; + // Stored explicitly: the session is ticked by and pauses this map, even if the child leaves it + private Map map = map; private ChoiceLetter_GrowthMoment letter; - public Pawn Pawn => letter.pawn; + public Pawn Pawn => letter?.pawn; public int traitIdx = NullTraitIdx; public List passionIndexes = []; public bool uiDirty; // if true, received or sent an update - public static GrowthMomentSession Create(ChoiceLetter_GrowthMoment letter) => new(null) + public static GrowthMomentSession Create(ChoiceLetter_GrowthMoment letter) => new(letter.pawn.MapHeld) { letter = letter }; // LetterWithTimeout.LastTickBeforeTimeout adjusted to use the map's time instead of Find.TickManager.TicksGame - public override bool IsCurrentlyPausing(Map map) => Map == map && letter.TimeoutActive && - letter.disappearAtTick <= map.AsyncTime().mapTicks + 1 && - !letter.ArchiveView; + private bool AtTimeout => letter.TimeoutActive && letter.disappearAtTick <= map.AsyncTime().mapTicks + 1; - public override bool IsSessionValid => !letter.ArchiveView; + public override bool IsCurrentlyPausing(Map map) => this.map == map && IsActive && AtTimeout; + + // Also checked right after loading, when pawns aren't spawned yet, so it mustn't depend on the child's map + public override bool IsSessionValid => map != null && Pawn != null && !letter.ArchiveView; + + // The child leaving the map (or dying) ends the session, the letter's timeout handles the rest. + // MapHeld: the child can be on the map without being spawned (growth vat, carried, in a shuttle). + private bool IsActive => IsSessionValid && !Pawn.Dead && Pawn.MapHeld == map; public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { - if (Map != entry.map) return null; + // Another faction's player with a pawn on the child's map mustn't choose the child's traits + if (Map != entry.map || !IsActive || !IsLocalPlayersChild) return null; return new FloatMenuOption("MpGrowthMomentSession".Translate(Pawn.Name.ToStringShort), OpenWindow); } - public override Map Map => letter.pawn.Map; + public override Map Map => map; + + // Windows are only for the players of the child's faction + private bool IsLocalPlayersChild => Pawn?.Faction == Multiplayer.RealPlayerFaction; [SyncMethod] public void UpdateChoices(int traitIdx, List passionIndexes) @@ -54,7 +75,7 @@ public void UpdateChoices(int traitIdx, List passionIndexes) public void OpenWindow() { - if (!IsSessionValid) return; + if (!IsActive) return; letter.TrySetChoices(); var window = new GrowthMomentWindow(letter.text, letter); Find.WindowStack.Add(window); @@ -62,8 +83,18 @@ public void OpenWindow() public void Tick() { - if (!IsSessionValid) Map.MpComp().sessionManager.RemoveSession(this); - else if (letter.TimeoutActive && letter.disappearAtTick <= Map.AsyncTime().mapTicks + 1) OpenWindow(); + if (!IsActive) map.MpComp().sessionManager.RemoveSession(this); + else if (AtTimeout && IsLocalPlayersChild) + // Runs in the map tick on the child's players' clients only + using (LocalOnlyScope.Enter()) + OpenWindow(); + } + + // UI only: reopen the window if it was closed while the map waits for the choice + public void ReopenIfWaiting() + { + if (IsActive && AtTimeout && IsLocalPlayersChild) + OpenWindow(); } public override void ExposeData() @@ -80,7 +111,9 @@ public static GrowthMomentSession TryAddSession(ChoiceLetter_GrowthMoment letter { if (letter.ArchiveView) return null; var pawn = letter.pawn; - var sessionManager = pawn.Map.MpComp().sessionManager; + // Only children on a map can have a session (it pauses and is ticked by that map) + if (pawn?.MapHeld == null || pawn.Dead) return null; + var sessionManager = pawn.MapHeld.MpComp().sessionManager; var sess = sessionManager.GetFirstOfType(sess => sess.Pawn == pawn); if (sess == null) { @@ -92,6 +125,10 @@ public static GrowthMomentSession TryAddSession(ChoiceLetter_GrowthMoment letter $"Failed trying to created a session of type {nameof(GrowthMomentSession)} - prior session did not exist and creating session failed."); return null; } + + // Generate the options now on every client. Otherwise they'd only be generated by clients + // opening the window, at different times and so possibly from a different pawn state. + letter.TrySetChoices(); } return sess; @@ -151,7 +188,12 @@ private void UpdateChoicesFromSession() session.uiDirty = false; } - public override void PostClose() => Session?.Tick(); + public override void PostClose() + { + base.PostClose(); + // Don't tick the session from here, UI code mustn't change the simulation + Session?.ReopenIfWaiting(); + } [MpPrefix(typeof(LetterStack), nameof(LetterStack.ReceiveLetter), [typeof(Letter), typeof(string), typeof(int), typeof(bool)])] @@ -190,6 +232,8 @@ static void MakeChoicesPatch(ChoiceLetter_GrowthMoment __instance) static void OpenSessionWindow(ChoiceLetter_GrowthMoment letter) { var sess = GrowthMomentSession.TryAddSession(letter); - if (Multiplayer.ExecutingCmds && TickPatch.currentExecutingCmdIssuedBySelf) sess?.OpenWindow(); + if (Multiplayer.ExecutingCmds && TickPatch.currentExecutingCmdIssuedBySelf) + using (LocalOnlyScope.Enter()) + sess?.OpenWindow(); } } diff --git a/Source/Client/Persistent/MapPortalProxy.cs b/Source/Client/Persistent/MapPortalProxy.cs index 0dfb7fc7b..ade08d424 100644 --- a/Source/Client/Persistent/MapPortalProxy.cs +++ b/Source/Client/Persistent/MapPortalProxy.cs @@ -8,7 +8,8 @@ public class MapPortalProxy(MapPortal portal) : Dialog_EnterPortal(portal), ISwi public static MapPortalProxy drawing; public bool itemsReady = false; - public MapPortalSession Session => portal.Map.MpComp().sessionManager.GetFirstOfType(); + // Several portal sessions can exist on a map, find the one for this portal + public MapPortalSession Session => MapPortalSession.GetSessionFor(portal); public override void DoWindowContents(Rect inRect) { @@ -18,8 +19,11 @@ public override void DoWindowContents(Rect inRect) try { - if (session == null) + if (session == null || !portal.Spawned) + { Close(); + return; + } base.DoWindowContents(inRect); } diff --git a/Source/Client/Persistent/MapPortalSession.cs b/Source/Client/Persistent/MapPortalSession.cs index 8b41f2336..70ea9f485 100644 --- a/Source/Client/Persistent/MapPortalSession.cs +++ b/Source/Client/Persistent/MapPortalSession.cs @@ -6,28 +6,34 @@ namespace Multiplayer.Client.Persistent; -public class MapPortalSession : ExposableSession, ISessionWithTransferables, ISessionWithCreationRestrictions +public class MapPortalSession : ExposableSession, ISessionWithTransferables, ISessionWithCreationRestrictions, ITickingSession { public MapPortal portal; public List transferables; + // Stored explicitly as the portal's map becomes null if it despawns + private Map map; - public override Map Map => portal.Map; + public override Map Map => map; - public override bool IsSessionValid => portal != null; + public override bool IsSessionValid => portal != null && map != null; - public MapPortalSession(Map _) : base(null) + public MapPortalSession(Map map) : base(null) { // Mandatory constructor + this.map = map; } public static MapPortalSession Of(MapPortal portal) { - var session = new MapPortalSession(null); + var session = new MapPortalSession(portal.Map); session.portal = portal; session.AddItems(); return session; } + public static MapPortalSession GetSessionFor(MapPortal portal) => + portal?.MapHeld?.MpComp().sessionManager.GetFirstOfType(s => s.portal == portal); + private void AddItems() { var dialog = new MapPortalProxy(portal); @@ -38,11 +44,20 @@ private void AddItems() transferables = dialog.transferables; } - public override bool IsCurrentlyPausing(Map map) => Map == map; + // Destroyed or moved (e.g. with a gravship) portals end the session, see Tick + private bool PortalOnMap => portal is { Spawned: true } && portal.Map == map; + + public override bool IsCurrentlyPausing(Map map) => this.map == map && PortalOnMap; + + public void Tick() + { + if (!PortalOnMap) + map.MpComp().sessionManager.RemoveSession(this); + } public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry) { - if (entry.map != Map) + if (entry.map != Map || !PortalOnMap) return null; return new FloatMenuOption("MpMapPortalSession".Translate(portal?.Label), () => @@ -54,9 +69,7 @@ public override FloatMenuOption GetBlockingWindowOptions(ColonistBar.Entry entry public static void OpenOrCreateSession(MapPortal portal) { - var session = portal.Map.MpComp().sessionManager.AllSessions - .OfType() - .FirstOrDefault(s => s.portal == portal); + var session = GetSessionFor(portal); if (session == null) CreateSession(portal); else @@ -74,7 +87,8 @@ private static void CreateSession(MapPortal portal) if (session == null) Log.Error($"Failed creating session of type {nameof(MapPortalSession)}."); else if (MP.IsExecutingSyncCommandIssuedBySelf) - session.OpenWindow(); + using (LocalOnlyScope.Enter()) + session.OpenWindow(); } [SyncMethod] @@ -90,7 +104,7 @@ public void TryAccept() public void Reset() => transferables.ForEach(t => t.CountToTransfer = 0); [SyncMethod] - public void Remove() => Map.MpComp().sessionManager.RemoveSession(this); + public void Remove() => map?.MpComp().sessionManager.RemoveSession(this); public void OpenWindow(bool sound = true) { diff --git a/Source/Client/Persistent/PrepatcherWarning.cs b/Source/Client/Persistent/PrepatcherWarning.cs new file mode 100644 index 000000000..5a7f76fc5 --- /dev/null +++ b/Source/Client/Persistent/PrepatcherWarning.cs @@ -0,0 +1,42 @@ +using Multiplayer.Client.Util; +using MultiplayerLoader; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.Persistent; + +/// +/// Shown in the host/join windows and the ritual dialogs when Prepatcher didn't patch the ritual role selection +/// widget (see SyncRituals.PrepatchesApplied). The texts fall back to English as the keys may not be translated yet. +/// +public static class PrepatcherWarning +{ + public static bool Show => !SyncRituals.PrepatchesApplied; + + public static string Label => + "MpPrepatcherMissing".TryTranslate(out var text) ? text.ToString() : "Prepatcher isn't active"; + + public static string Desc => + "MpPrepatcherMissingDesc".TryTranslate(out var text) + ? text.ToString() + : "Multiplayer needs the Prepatcher mod to share the role assignments of ritual dialogs between players. " + + "Without it, each player only sees their own changes there and a ritual starts with the roles " + + "chosen by the player who starts it.\n\nMake sure Prepatcher is installed and enabled. If it is, " + + "check the log for why it failed."; + + public static float LabelWidth(GameFont font) + { + using (MpStyle.Set(font)) + return Text.CalcSize(Label).x; + } + + // The label in the warning color with the description as its tooltip + public static void DrawLabel(Rect rect, GameFont font = GameFont.Small) + { + using (MpStyle.Set(font).Set(ColorLibrary.RedReadable)) + Widgets.Label(rect, Label); + + Widgets.DrawHighlightIfMouseover(rect); + TooltipHandler.TipRegion(rect, Desc); + } +} diff --git a/Source/Client/Persistent/PsychicRitualBeginProxy.cs b/Source/Client/Persistent/PsychicRitualBeginProxy.cs index 43f12da4b..8143ebab8 100644 --- a/Source/Client/Persistent/PsychicRitualBeginProxy.cs +++ b/Source/Client/Persistent/PsychicRitualBeginProxy.cs @@ -61,6 +61,12 @@ public override void DoWindowContents(Rect inRect) Close(); } + if (PrepatcherWarning.Show) + { + PrepatcherWarning.DrawLabel(inRect.Height(20f).Width(PrepatcherWarning.LabelWidth(GameFont.Tiny) + 10f), GameFont.Tiny); + inRect.yMin += 20f; + } + base.DoWindowContents(inRect); } finally diff --git a/Source/Client/Persistent/PsychicRitualSession.cs b/Source/Client/Persistent/PsychicRitualSession.cs index edb5257cb..9d676e5ca 100644 --- a/Source/Client/Persistent/PsychicRitualSession.cs +++ b/Source/Client/Persistent/PsychicRitualSession.cs @@ -58,7 +58,8 @@ public static void CreateSession(PsychicRitualDef_InvocationCircle ritual, Thing var session = manager.GetOrAddSession(new PsychicRitualSession(map, ritual, candidatePool, assignments)); if (TickPatch.currentExecutingCmdIssuedBySelf) - session.OpenWindow(); + using (LocalOnlyScope.Enter()) + session.OpenWindow(); } [SyncMethod] @@ -67,14 +68,38 @@ public void Remove() map.MpComp().sessionManager.RemoveSession(this); } - [SyncMethod] - public void Start() + // Called by the starting player's dialog + public void Start() => StartWithAssignments(assignments); + + // See RitualSession.StartWithAssignments: without Prepatcher the role assignments may differ between clients + [SyncMethod(exposeParameters = new[] { 0 })] + private void StartWithAssignments(MpPsychicRitualAssignments startingAssignments) { + if (startingAssignments != null) + ApplyStartingAssignments(startingAssignments, assignments); + Remove(); ritual.MakeNewLord(assignments); Find.PsychicRitualManager.RegisterCooldown(ritual); } + // Only what the role selection widgets change, the rest (target, forced assignments) stays from the live object. + // Pawns which didn't resolve were already removed by ExposeData's PostLoadInit, except from the spectators. + private static void ApplyStartingAssignments(PsychicRitualRoleAssignments from, PsychicRitualRoleAssignments to) + { + if (from.roleAssignments != null) + to.roleAssignments = from.roleAssignments; + + if (from.pawnAssignments != null) + to.pawnAssignments = from.pawnAssignments; + + if (from.spectators != null) + { + from.spectators.RemoveAll(p => p == null); + to.spectators = from.spectators; + } + } + public void OpenWindow(bool sound = true) { var dialog = new PsychicRitualBeginProxy( diff --git a/Source/Client/Persistent/RitualBeginProxy.cs b/Source/Client/Persistent/RitualBeginProxy.cs index b1a91490c..a81d54179 100644 --- a/Source/Client/Persistent/RitualBeginProxy.cs +++ b/Source/Client/Persistent/RitualBeginProxy.cs @@ -74,6 +74,10 @@ public override void DoWindowContents(Rect inRect) Close(); } + // In the space made for the "Switch to map" button (top right) + if (PrepatcherWarning.Show) + PrepatcherWarning.DrawLabel(inRect.Height(20f).Width(PrepatcherWarning.LabelWidth(GameFont.Tiny) + 10f), GameFont.Tiny); + // Make space for the "Switch to map" button inRect.yMin += 20f; diff --git a/Source/Client/Persistent/RitualPatches.cs b/Source/Client/Persistent/RitualPatches.cs index 6a5270bd4..388a70d6d 100644 --- a/Source/Client/Persistent/RitualPatches.cs +++ b/Source/Client/Persistent/RitualPatches.cs @@ -105,7 +105,8 @@ static bool Prefix(Window window) } if (TickPatch.currentExecutingCmdIssuedBySelf) - session?.OpenWindow(); + using (LocalOnlyScope.Enter()) + session?.OpenWindow(); return false; } @@ -114,6 +115,21 @@ static bool Prefix(Window window) } } + // Ritual gizmos of different ideoligions can be grouped and clicking one also clicks the others in its group first + // (GizmoGridDrawer, Gizmo.InheritInteractionsFrom). In SP every one opens its dialog and the clicked one, opened + // last, stays. In MP each click syncs, the first command creates the session and the following ones, the clicked + // ritual's among them, are rejected, so the wrong ritual opened (#506). UI only, like vanilla's launch gizmos. + [HarmonyPatch(typeof(Command_Ritual), MethodType.Constructor, + typeof(Precept_Ritual), typeof(TargetInfo), typeof(RitualObligation), typeof(Dictionary))] + static class RitualGizmoOnlyClickedOne + { + static void Postfix(Command_Ritual __instance) + { + if (Multiplayer.Client != null) + __instance.alsoClickIfOtherInGroupClicked = false; + } + } + // Note that cachedRoles is a static field and is only used for UI drawing [HarmonyPatch] static class DontClearDialogBeginRitualCache diff --git a/Source/Client/Persistent/RitualSession.cs b/Source/Client/Persistent/RitualSession.cs index b278b83c0..dc370a8b5 100644 --- a/Source/Client/Persistent/RitualSession.cs +++ b/Source/Client/Persistent/RitualSession.cs @@ -28,9 +28,19 @@ public void Remove() map.MpComp().sessionManager.RemoveSession(this); } - [SyncMethod] - public void Start() + // Called by the starting player's dialog + public void Start() => StartWithAssignments(data.assignments); + + // Without Prepatcher the role selection widget's methods aren't synced, so each client may have its own role + // assignments by now. The starting player's assignments are sent along and applied on every client (with + // Prepatcher they're the same everywhere already). + [SyncMethod(exposeParameters = new[] { 0 })] + private void StartWithAssignments(MpRitualAssignments startingAssignments) { + // Into the session's object, which the open dialogs and the assignments' sync worker refer to + if (startingAssignments != null) + ApplyStartingAssignments(startingAssignments, data.assignments); + // Handle the same stuff as Dialog_BeginGravshipLaunch if (data.isGravshipRitual) { @@ -47,6 +57,28 @@ public void Start() Remove(); } + // Only what the role selection widgets change, the rest stays from the live object. E.g. the ritual (and so the + // roles) is saved by reference and precepts created during the game (a new ideoligion, a reform) aren't in the + // shared cross refs, so they'd read back as null. Pawns which didn't resolve (despawned) are left out. + private static void ApplyStartingAssignments(RitualRoleAssignments from, RitualRoleAssignments to) + { + if (from.assignedRoles != null) + { + foreach (var pawns in from.assignedRoles.Values) + pawns?.RemoveAll(p => p == null); + to.assignedRoles = from.assignedRoles; + } + + if (from.spectators != null) + { + from.spectators.RemoveAll(p => p == null); + to.spectators = from.spectators; + } + + if (from.roleChangeSelection != null) + to.roleChangeSelection = from.roleChangeSelection; + } + public void OpenWindow(bool sound = true) { var dialog = new RitualBeginProxy( diff --git a/Source/Client/Persistent/SessionManager.cs b/Source/Client/Persistent/SessionManager.cs index eee16edaf..c72a1a0af 100644 --- a/Source/Client/Persistent/SessionManager.cs +++ b/Source/Client/Persistent/SessionManager.cs @@ -120,11 +120,26 @@ private Session GetFirstConflictingSession(Session session) /// /// Ticks over . It iterates backwards using a loop to make it safe for the sessions to remove themselves when ticking. + /// A failing session is logged and doesn't stop the other sessions (or the rest of the map/world tick). /// public void TickSessions() { for (int i = tickingSessions.Count - 1; i >= 0; i--) - tickingSessions[i].Tick(); + { + // A session's tick may remove more than one session + if (i >= tickingSessions.Count) + continue; + + var session = tickingSessions[i]; + try + { + session.Tick(); + } + catch (Exception e) + { + Log.ErrorOnce($"Exception ticking session {session}: {e}", session.GetHashCode() ^ 0x3a5f19); + } + } } public T GetFirstOfType() where T : Session => allSessions.OfType().FirstOrDefault(); @@ -137,27 +152,30 @@ public void TickSessions() public void WriteSessionData(ByteWriter data) { + // Saving can happen on a single client (e.g. host autosaves), so it mustn't modify the live session lists. + // Invalid sessions are just left out of the written data. + var sessionsToWrite = new List(semiPersistentSessions.Count); + // Clear the set to make sure it's empty tempCleanupLoggingTypes.Clear(); - for (int i = semiPersistentSessions.Count - 1; i >= 0; i--) + foreach (var session in semiPersistentSessions) { - var session = semiPersistentSessions[i]; - if (!session.IsSessionValid) + if (session.IsSessionValid) { - semiPersistentSessions.RemoveAt(i); - allSessions.Remove(session); - if (session is ITickingSession tickingSession) tickingSessions.Remove(tickingSession); - var sessionType = session.GetType(); - if (!tempCleanupLoggingTypes.Add(sessionType)) - Log.Message($"Multiplayer session not valid after writing semi persistent data: {sessionType}"); + sessionsToWrite.Add(session); + continue; } + + var sessionType = session.GetType(); + if (tempCleanupLoggingTypes.Add(sessionType)) + Log.Message($"Multiplayer session not valid when writing semi persistent data: {sessionType}"); } // Clear the set again to not leave behind any unneeded stuff tempCleanupLoggingTypes.Clear(); - data.WriteInt32(semiPersistentSessions.Count); + data.WriteInt32(sessionsToWrite.Count); - foreach (var session in semiPersistentSessions) + foreach (var session in sessionsToWrite) { data.WriteUShort((ushort)ApiSerialization.sessions.FindIndex(session.GetType())); data.WriteInt32(session.SessionId); @@ -240,7 +258,7 @@ public void ExposeSessions() { session.PostRemoveSession(); var sessionType = session.GetType(); - if (!tempCleanupLoggingTypes.Add(sessionType)) + if (tempCleanupLoggingTypes.Add(sessionType)) Log.Message($"Multiplayer session not valid after exposing data: {sessionType}"); } } diff --git a/Source/Client/Persistent/Trading.cs b/Source/Client/Persistent/Trading.cs index ff25e2a70..229d8c917 100644 --- a/Source/Client/Persistent/Trading.cs +++ b/Source/Client/Persistent/Trading.cs @@ -1,5 +1,7 @@ using HarmonyLib; using Multiplayer.API; +using Multiplayer.Client.Factions; +using Multiplayer.Client.Util; using RimWorld; using RimWorld.Planet; using System.Collections.Generic; @@ -200,10 +202,65 @@ public void CloseWindow(bool sound = true) public void Tick() { - if (playerNegotiator.Spawned) return; - - if (ShouldCancel()) + if (!playerNegotiator.Spawned && ShouldCancel()) + { Multiplayer.WorldComp.sessionManager.RemoveSession(this); + return; + } + + RecacheDealIfNeeded(); + } + + /// + /// Applies the changes marked by the dirty flags to the deal's tradeables. This changes simulation state + /// (which tradeables exist and which things they hold), so it must only run in deterministic contexts + /// (ticks and commands), never from the UI. + /// + public void RecacheDealIfNeeded() + { + if (deal is not { ShouldRecache: true }) return; + + var map = playerNegotiator?.Map; + + // Use the negotiator's data, not whatever the current context or the local client has applied + map.PushFaction(NegotiatorFaction, force: true); + try + { + using (UseTradeSession()) + deal.Recache(); + } + finally + { + map.PopFaction(); + } + } + + // Like SetTradeSession but restores the previous state and leaves the UI's transferables marker alone + private MpScope UseTradeSession() + { + var prevCurrent = current; + var prevTrader = TradeSession.trader; + var prevNegotiator = TradeSession.playerNegotiator; + var prevGiftMode = TradeSession.giftMode; + var prevDeal = TradeSession.deal; + + return MpScope.Create( + () => + { + current = this; + TradeSession.trader = trader; + TradeSession.playerNegotiator = playerNegotiator; + TradeSession.giftMode = giftMode; + TradeSession.deal = deal; + }, + () => + { + current = prevCurrent; + TradeSession.trader = prevTrader; + TradeSession.playerNegotiator = prevNegotiator; + TradeSession.giftMode = prevGiftMode; + TradeSession.deal = prevDeal; + }); } public override void ExposeData() @@ -226,6 +283,9 @@ public override void ExposeData() public Transferable GetTransferableByThingId(int thingId) { + // Called when reading synced data, i.e. on every client in the same command + RecacheDealIfNeeded(); + for (int i = 0; i < deal.tradeables.Count; i++) { Tradeable tr = deal.tradeables[i]; @@ -279,10 +339,20 @@ public class MpTradeDeal : TradeDeal, IExposable public UIShouldReset uiShouldReset; + // The dirty state decides when every client recaches the deal, so it's saved and set only by the simulation public HashSet recacheThings = new HashSet(); public bool recacheColony; public bool recacheTrader; - public bool ShouldRecache => recacheColony || recacheTrader || recacheThings.Count > 0; + public bool ShouldRecache => recacheColony || recacheTrader || AnyLiveRecacheThing(); + + // Destroyed things can't be saved, ignore them so reloaded clients agree with the others + private bool AnyLiveRecacheThing() + { + foreach (var t in recacheThings) + if (!t.Destroyed) + return true; + return false; + } public bool caravanDirty; public Thing permanentSilver; @@ -294,6 +364,8 @@ public MpTradeDeal(MpTradeSession session) public void Recache() { + recacheThings.RemoveWhere(t => t.Destroyed); + if (recacheColony) CheckAddRemoveColony(); @@ -419,6 +491,18 @@ public void ExposeData() { Scribe_Deep.Look(ref permanentSilver, "permanentSilver"); Scribe_Collections.Look(ref tradeables, "tradeables", LookMode.Deep); + + Scribe_Values.Look(ref recacheColony, "recacheColony"); + Scribe_Values.Look(ref recacheTrader, "recacheTrader"); + + List things = null; + if (Scribe.mode == LoadSaveMode.Saving) + things = recacheThings.Where(t => !t.Destroyed).ToList(); + + Scribe_Collections.Look(ref things, "recacheThings", LookMode.Reference); + + if (Scribe.mode == LoadSaveMode.ResolvingCrossRefs) + recacheThings = things != null ? new HashSet(things.Where(t => t != null)) : new HashSet(); } } @@ -464,11 +548,11 @@ static bool Prefix(Pawn playerNegotiator, ITrader trader, bool giftsOnly) if (playerNegotiator.Map == Find.CurrentMap && playerNegotiator.CurJob.loadID == SyncMethods.tradeJobStartedByMe) { SyncMethods.tradeJobStartedByMe = -1; - trade.OpenWindow(); + OpenWindowLocally(trade); } else if (trader is Settlement && Find.World.renderer.wantedMode == WorldRenderMode.Planet) { - trade.OpenWindow(); + OpenWindowLocally(trade); } } @@ -477,6 +561,14 @@ static bool Prefix(Pawn playerNegotiator, ITrader trader, bool giftsOnly) return true; } + + // Runs in a tick or command for the player who started the trade (or who looks at the world) only, the + // window's constructor and PreOpen must stay off the shared RNG and unique ids + static void OpenWindowLocally(MpTradeSession trade) + { + using (LocalOnlyScope.Enter()) + trade.OpenWindow(); + } } [HarmonyPatch(typeof(IncidentWorker_TraderCaravanArrival), nameof(IncidentWorker_TraderCaravanArrival.TryExecuteWorker))] diff --git a/Source/Client/Persistent/TradingUI.cs b/Source/Client/Persistent/TradingUI.cs index e4c21a1d9..f7e9acb59 100644 --- a/Source/Client/Persistent/TradingUI.cs +++ b/Source/Client/Persistent/TradingUI.cs @@ -67,16 +67,15 @@ public override void DoWindowContents(Rect inRect) selectedSession = session.SessionId; } - Faction factionContext = session.NegotiatorFaction; + // FactionContext.Push always pushes (null when it's a no-op), so always pop + FactionContext.Push(session.NegotiatorFaction); try { - factionContext = FactionContext.Push(factionContext); MpTradeSession.SetTradeSession(session); drawingTrade = this; - if (session.deal.ShouldRecache) - session.deal.Recache(); - + // The deal itself is only recached in ticks and commands (MpTradeSession.RecacheDealIfNeeded), + // the UI just follows it if (session.deal.uiShouldReset != UIShouldReset.None) { if (session.deal.uiShouldReset != UIShouldReset.Silent) @@ -125,9 +124,7 @@ public override void DoWindowContents(Rect inRect) { drawingTrade = null; MpTradeSession.SetTradeSession(null); - if (factionContext != null) { - FactionContext.Pop(); - } + FactionContext.Pop(); } } diff --git a/Source/Client/Persistent/TransporterLoadingPatches.cs b/Source/Client/Persistent/TransporterLoadingPatches.cs index 1b165711a..2b47df6f0 100644 --- a/Source/Client/Persistent/TransporterLoadingPatches.cs +++ b/Source/Client/Persistent/TransporterLoadingPatches.cs @@ -135,7 +135,8 @@ static void Prefix(Dialog_LoadTransporters __instance, Map map, List map.MpComp().sessionManager.GetFirstOfType(); + // Several transporter loading sessions can exist on a map, find the one for these transporters + public TransporterLoading Session => TransporterLoading.GetSessionFor(map, transporters); public TransporterLoadingProxy(Map map, List transporters) : base(map, transporters) { diff --git a/Source/Client/Persistent/TransporterLoadingSession.cs b/Source/Client/Persistent/TransporterLoadingSession.cs index 6c6ffabe0..f7715c934 100644 --- a/Source/Client/Persistent/TransporterLoadingSession.cs +++ b/Source/Client/Persistent/TransporterLoadingSession.cs @@ -111,6 +111,14 @@ public override void ExposeData() transporters = pods.Select(t => t.GetComp()).ToList(); } + // Sessions sharing a transporter are treated as the same session (as two sessions loading the same + // transporter would conflict), disjoint groups of transporters get their own sessions + public static TransporterLoading GetSessionFor(Map map, List transporters) => + map?.MpComp().sessionManager.GetFirstOfType(s => s.SharesTransporterWith(transporters)); + + public bool SharesTransporterWith(List others) => + transporters != null && others != null && transporters.Any(others.Contains); + public Transferable GetTransferableByThingId(int thingId) { return transferables.Find(tr => tr.things.Any(t => t.thingIDNumber == thingId)); diff --git a/Source/Client/Saving/ConvertToSp.cs b/Source/Client/Saving/ConvertToSp.cs index 1dc65d74a..2dafc7154 100644 --- a/Source/Client/Saving/ConvertToSp.cs +++ b/Source/Client/Saving/ConvertToSp.cs @@ -30,8 +30,11 @@ private static void PrepareSingleplayer() var spectator = Multiplayer.WorldComp.spectatorFaction; if (spectator != null) { - spectator.RemoveAllRelations(); - Find.FactionManager.allFactions.Remove(spectator); + // Vanilla's removal clears what refers to the faction (relations, its pawns, destination reservations, + // listers, letters, play log, quests, ideos, tales, map events) so the save has no dangling references. + // It only accepts temporary factions. + spectator.temporary = true; + Find.FactionManager.Remove(spectator); } } diff --git a/Source/Client/Saving/CrossRefs.cs b/Source/Client/Saving/CrossRefs.cs index f533a8736..bcbe73c9d 100644 --- a/Source/Client/Saving/CrossRefs.cs +++ b/Source/Client/Saving/CrossRefs.cs @@ -2,6 +2,7 @@ using RimWorld; using RimWorld.Planet; using Verse; +using Verse.AI.Group; namespace Multiplayer.Client { @@ -164,6 +165,30 @@ static void Postfix(Map map) } } + // Lords are referenced by synced data (e.g. jobs exposed by TryTakeOrderedJob), which resolved them to null. + // Registered on every client the same way: at runtime here and from the save in FinalizeLoadingGame. + [HarmonyPatch(typeof(LordManager))] + [HarmonyPatch(nameof(LordManager.AddLord))] + public static class LordManagerAddPatch + { + static void Postfix(Lord newLord) + { + if (Multiplayer.game == null) return; + ScribeUtil.sharedCrossRefs.RegisterLoaded(newLord); + } + } + + [HarmonyPatch(typeof(LordManager))] + [HarmonyPatch(nameof(LordManager.RemoveLord))] + public static class LordManagerRemovePatch + { + static void Postfix(Lord oldLord) + { + if (Multiplayer.game == null) return; + ScribeUtil.sharedCrossRefs.Unregister(oldLord); + } + } + [HarmonyPatch(typeof(MapDeiniter))] [HarmonyPatch(nameof(MapDeiniter.Deinit))] public static class DeinitMapPatch @@ -206,7 +231,13 @@ static void RegisterCrossRefs() ScribeUtil.sharedCrossRefs.RegisterLoaded(precept); foreach (var map in Find.Maps) + { ScribeUtil.sharedCrossRefs.RegisterLoaded(map); + + // Loaded lords don't go through LordManager.AddLord + foreach (var lord in map.lordManager.lords) + ScribeUtil.sharedCrossRefs.RegisterLoaded(lord); + } } } diff --git a/Source/Client/Saving/Loader.cs b/Source/Client/Saving/Loader.cs index 74e5c0085..4d9160f7d 100644 --- a/Source/Client/Saving/Loader.cs +++ b/Source/Client/Saving/Loader.cs @@ -24,6 +24,10 @@ public static void ReloadGame(List mapsToLoad, bool changeScene, Action cus LoadPatch.gameToLoad = new TempGameData(gameDoc, Multiplayer.session.dataSnapshot.SessionData); TickPatch.replayTimeSpeed = TimeSpeed.Paused; + // Commands arriving until PostLoad are only recorded in dataSnapshot.Cmds (packets are processed on the + // main thread while the game loads asynchronously) + Multiplayer.session.cmdQueueReady = false; + if (changeScene) { LongEventHandler.QueueLongEvent(() => @@ -75,9 +79,10 @@ private static void PostLoad() ); Multiplayer.game.myFactionLoading = null; - Multiplayer.AsyncWorldTime.cmds = new Queue( - Multiplayer.session.dataSnapshot.MapCmds.GetValueSafe(ScheduledCommand.Global) ?? []); - // Map cmds are added in MapAsyncTimeComp.FinalizeInit + // The only place the command queue is rebuilt: all cmds since the snapshot, in order, including the ones + // received while loading. Runs on the main thread, like MultiplayerSession.ScheduleCommand. + Multiplayer.AsyncWorldTime.cmds = new Queue(Multiplayer.session.dataSnapshot.Cmds); + Multiplayer.session.cmdQueueReady = true; } private static XmlDocument DataSnapshotToXml(GameDataSnapshot dataSnapshot, List mapsToLoad) diff --git a/Source/Client/Saving/Replay.cs b/Source/Client/Saving/Replay.cs index 19587f82f..c9cca39a9 100644 --- a/Source/Client/Saving/Replay.cs +++ b/Source/Client/Saving/Replay.cs @@ -25,7 +25,10 @@ public class Replay public void WriteCurrentData() => WriteData(Multiplayer.session.dataSnapshot); - public void WriteData(GameDataSnapshot gameData) + public void WriteData(GameDataSnapshot gameData) => WriteData(gameData, TickPatch.Timer); + + // endTick: the tick loading the replay simulates to + public void WriteData(GameDataSnapshot gameData, int endTick) { string sectionId = info.sections.Count.ToString("D3"); using var zip = CreateZipWrite(); @@ -33,15 +36,11 @@ public void WriteData(GameDataSnapshot gameData) foreach (var (mapId, mapData) in gameData.MapData) zip.AddEntry($"maps/{sectionId}_{mapId}_save", mapData); - foreach (var (mapId, mapCmdData) in gameData.MapCmds) - if (mapId >= 0) - zip.AddEntry($"maps/{sectionId}_{mapId}_cmds", ScheduledCommand.SerializeCmds(mapCmdData)); - - if (gameData.MapCmds.TryGetValue(ScheduledCommand.Global, out var worldCmds)) - zip.AddEntry($"world/{sectionId}_cmds", ScheduledCommand.SerializeCmds(worldCmds)); + // All cmds in execution order. Map cmd entries (maps/N_M_cmds) are only read from older replays. + zip.AddEntry($"world/{sectionId}_cmds", ScheduledCommand.SerializeCmds(gameData.Cmds)); zip.AddEntry($"world/{sectionId}_save", gameData.GameData); - info.sections.Add(new ReplaySection(gameData.CachedAtTime, TickPatch.Timer)); + info.sections.Add(new ReplaySection(gameData.CachedAtTime, endTick)); zip.AddEntry("info", ReplayInfo.Write(info)); } @@ -59,7 +58,7 @@ public bool LoadInfo() public GameDataSnapshot LoadGameData(int sectionId) { - var mapCmdsDict = new Dictionary>(); + var legacyMapCmds = new List>(); var mapDataDict = new Dictionary(); string sectionIdStr = sectionId.ToString("D3"); @@ -67,14 +66,12 @@ public GameDataSnapshot LoadGameData(int sectionId) using var zip = OpenZipRead(); foreach (var mapCmds in zip.GetEntries($"maps/{sectionIdStr}_*_cmds")) - { - int mapId = int.Parse(mapCmds.Name.Split('_')[1]); - mapCmdsDict[mapId] = ScheduledCommand.DeserializeCmds(mapCmds.GetBytes()); - } + legacyMapCmds.Add(ScheduledCommand.DeserializeCmds(mapCmds.GetBytes())); - var worldCmds = zip.GetEntry($"world/{sectionIdStr}_cmds"); - if (worldCmds != null) - mapCmdsDict[ScheduledCommand.Global] = ScheduledCommand.DeserializeCmds(worldCmds.GetBytes()); + var worldCmdsEntry = zip.GetEntry($"world/{sectionIdStr}_cmds"); + var worldCmds = worldCmdsEntry != null + ? ScheduledCommand.DeserializeCmds(worldCmdsEntry.GetBytes()) + : new List(); foreach (var mapSave in zip.GetEntries($"maps/{sectionIdStr}_*_save")) { @@ -87,7 +84,7 @@ public GameDataSnapshot LoadGameData(int sectionId) zip.GetBytes($"world/{sectionIdStr}_save"), Array.Empty(), mapDataDict, - mapCmdsDict + ScheduledCommand.MergeLegacyCmds(worldCmds, legacyMapCmds) ); } @@ -98,26 +95,23 @@ public static FileInfo SavedReplayFile(string fileName, string folder = null) public static Replay ForLoading(FileInfo file) => new(file); public static Replay ForSaving(string fileName) => ForSaving(SavedReplayFile(fileName)); - public static Replay ForSaving(FileInfo file) - { - var replay = new Replay(file) - { - info = new ReplayInfo - { - name = Multiplayer.session.gameName, - playerFaction = Multiplayer.session.myFactionId, - spectatorFaction = Multiplayer.WorldComp.spectatorFaction.loadID, - protocol = MpVersion.Protocol, - rwVersion = VersionControl.CurrentVersionStringWithRev, - modIds = LoadedModManager.RunningModsListForReading.Select(m => m.PackageId).ToList(), - modNames = LoadedModManager.RunningModsListForReading.Select(m => m.Name).ToList(), - asyncTime = Multiplayer.GameComp.asyncTime, - multifaction = Multiplayer.GameComp.multifaction - } - }; + public static Replay ForSaving(FileInfo file) => ForSaving(file, CurrentInfo()); - return replay; - } + public static Replay ForSaving(FileInfo file, ReplayInfo info) => new(file) { info = info }; + + // Info of the current session, without sections + public static ReplayInfo CurrentInfo() => new() + { + name = Multiplayer.session.gameName, + playerFaction = Multiplayer.session.myFactionId, + spectatorFaction = Multiplayer.WorldComp.spectatorFaction.loadID, + protocol = MpVersion.Protocol, + rwVersion = VersionControl.CurrentVersionStringWithRev, + modIds = LoadedModManager.RunningModsListForReading.Select(m => m.PackageId).ToList(), + modNames = LoadedModManager.RunningModsListForReading.Select(m => m.Name).ToList(), + asyncTime = Multiplayer.GameComp.asyncTime, + multifaction = Multiplayer.GameComp.multifaction + }; public static void LoadReplay(FileInfo file, bool toEnd = false, Action after = null, Action cancel = null, string simTextKey = null, bool showTimeline = true) { diff --git a/Source/Client/Saving/ReplayConnection.cs b/Source/Client/Saving/ReplayConnection.cs index a79e24ca3..793c620bf 100644 --- a/Source/Client/Saving/ReplayConnection.cs +++ b/Source/Client/Saving/ReplayConnection.cs @@ -18,6 +18,9 @@ protected override void Send(Packets id, byte[] message, bool reliable = true) } } + // Big commands are sent fragmented, catch them whole + public override void SendFragmented(Packets id, byte[] message) => Send(id, message); + protected override void SendRaw(byte[] raw, bool reliable) { } diff --git a/Source/Client/Saving/SaveLoad.cs b/Source/Client/Saving/SaveLoad.cs index 857ff0b18..3c991456e 100644 --- a/Source/Client/Saving/SaveLoad.cs +++ b/Source/Client/Saving/SaveLoad.cs @@ -5,7 +5,6 @@ using RimWorld.Planet; using System.Collections.Generic; using System.Linq; -using System.Security.Cryptography; using System.Threading; using System.Xml; using Multiplayer.Client.Saving; @@ -38,7 +37,7 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio var tweenedPos = new Dictionary(); var localFactionId = Multiplayer.RealPlayerFaction.loadID; - var mapCmds = new Dictionary>(); + var cmds = Multiplayer.AsyncWorldTime.cmds; var planetRenderMode = Find.World.renderer.wantedMode; var chatWindow = ChatWindow.Opened; @@ -49,12 +48,8 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio { foreach (Pawn p in map.mapPawns.AllPawnsSpawned) tweenedPos[p.thingIDNumber] = p.drawer.tweener.tweenedPos; - - mapCmds[map.uniqueID] = map.AsyncTime().cmds; } - mapCmds[ScheduledCommand.Global] = Multiplayer.AsyncWorldTime.cmds; - TempGameData gameData; using (DeepProfilerWrapper.Section("Multiplayer SaveAndReload: Save")) { @@ -97,8 +92,6 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio p.drawer.tweener.lastDrawFrame = Time.frameCount; } } - - m.AsyncTime().cmds = mapCmds[m.uniqueID]; } if (chatWindow != null) @@ -108,7 +101,7 @@ private static TempGameData SaveAndReloadCore(ReloadOptimizationMode optimizatio Find.Selector.selected = SyncSerialization.ReadSync>(selectedReader).AllNotNull().Cast().ToList(); Find.World.renderer.wantedMode = planetRenderMode; - Multiplayer.AsyncWorldTime.cmds = mapCmds[ScheduledCommand.Global]; + Multiplayer.AsyncWorldTime.cmds = cmds; Multiplayer.reloading = false; @@ -136,8 +129,21 @@ public static void LoadInMainThread(TempGameData gameData) Find.Root.Start(); CancelRootPlayStartLongEvents.cancel = false; - // SaveCompression enabled in the patch - SavedGameLoaderNow.LoadGameFromSaveFileNow(null); + // Load in the same program state as a normal load (and a joining client, which loads through a scene + // change) does. Load-time code checks for Playing, e.g. GenSpawn.Spawn's per-cell item limit (#854). + var prevProgramState = Current.ProgramState; + Current.programStateInt = ProgramState.MapInitializing; + + try + { + // SaveCompression enabled in the patch + SavedGameLoaderNow.LoadGameFromSaveFileNow(null); + } + finally + { + if (Current.ProgramState == ProgramState.MapInitializing) + Current.programStateInt = prevProgramState; + } DeepProfiler.End(); } @@ -198,7 +204,6 @@ public static GameDataSnapshot CreateGameDataSnapshot(TempGameData data, bool re XmlNode gameNode = data.SaveData.DocumentElement["game"]; XmlNode mapsNode = gameNode["maps"]; - var mapCmdsDict = new Dictionary>(); var mapDataDict = new Dictionary(); foreach (XmlNode mapNode in mapsNode) @@ -206,7 +211,6 @@ public static GameDataSnapshot CreateGameDataSnapshot(TempGameData data, bool re int id = int.Parse(mapNode["uniqueID"].InnerText); byte[] mapData = ScribeUtil.XmlToByteArray(mapNode); mapDataDict[id] = mapData; - mapCmdsDict[id] = new List(Find.Maps.First(m => m.uniqueID == id).AsyncTime().cmds); } if (removeCurrentMapId) @@ -215,14 +219,14 @@ public static GameDataSnapshot CreateGameDataSnapshot(TempGameData data, bool re mapsNode.RemoveAll(); byte[] gameData = ScribeUtil.XmlToByteArray(data.SaveData); - mapCmdsDict[ScheduledCommand.Global] = new List(Multiplayer.AsyncWorldTime.cmds); return new GameDataSnapshot( TickPatch.Timer, gameData, data.SessionData, mapDataDict, - mapCmdsDict + // The commands not executed yet, in order (all of them are in the world's queue) + new List(Multiplayer.AsyncWorldTime.cmds) ); } @@ -231,11 +235,14 @@ public static void SendGameData(GameDataSnapshot snapshot, bool async) var mapsData = new Dictionary(snapshot.MapData); var gameData = snapshot.GameData; var sessionData = snapshot.SessionData; + var tick = snapshot.CachedAtTime; void Send() { var writer = new ByteWriter(); + // The server only accepts the upload for the join point created at this tick + writer.WriteInt32(tick); writer.WriteInt32(mapsData.Count); foreach (var mapData in mapsData) { @@ -256,60 +263,6 @@ void Send() else Send(); } - - /// - /// Send per-map standalone snapshots to the server for all maps in the given snapshot. - /// Called after autosave when connected to a standalone server. - /// - public static void SendStandaloneMapSnapshots(GameDataSnapshot snapshot) - { - var tick = snapshot.CachedAtTime; - - foreach (var (mapId, mapBytes) in snapshot.MapData) - { - var compressed = GZipStream.CompressBuffer(mapBytes); - - byte[] hash; - using (var sha = SHA256.Create()) - hash = sha.ComputeHash(compressed); - - var packet = new ClientStandaloneMapSnapshotPacket - { - mapId = mapId, - tick = tick, - mapData = compressed, - sha256Hash = hash, - }; - - OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize())); - } - } - - /// - /// Send the world + session standalone snapshot to the server. - /// Called after autosave when connected to a standalone server. - /// - public static void SendStandaloneWorldSnapshot(GameDataSnapshot snapshot) - { - var tick = snapshot.CachedAtTime; - var worldCompressed = GZipStream.CompressBuffer(snapshot.GameData); - var sessionCompressed = GZipStream.CompressBuffer(snapshot.SessionData); - - using var hasher = SHA256.Create(); - hasher.TransformBlock(worldCompressed, 0, worldCompressed.Length, null, 0); - hasher.TransformFinalBlock(sessionCompressed, 0, sessionCompressed.Length); - var hash = hasher.Hash ?? System.Array.Empty(); - - var packet = new ClientStandaloneWorldSnapshotPacket - { - tick = tick, - worldData = worldCompressed, - sessionData = sessionCompressed, - sha256Hash = hash, - }; - - OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize())); - } } } diff --git a/Source/Client/Saving/SharedCrossRefs.cs b/Source/Client/Saving/SharedCrossRefs.cs index 70fc93fcd..5d019f620 100644 --- a/Source/Client/Saving/SharedCrossRefs.cs +++ b/Source/Client/Saving/SharedCrossRefs.cs @@ -2,6 +2,7 @@ using System.Linq; using RimWorld; using Verse; +using Verse.AI.Group; namespace Multiplayer.Client; @@ -31,7 +32,8 @@ public List UnregisterAllFrom(Map map) { if (val is Thing thing && thing.Map == map || val is PassingShip ship && ship.Map == map || - val is Bill bill && bill.Map == map + val is Bill bill && bill.Map == map || + val is Lord lord && lord.lordManager?.map == map ) { Unregister(val); diff --git a/Source/Client/Session/Autosaving.cs b/Source/Client/Session/Autosaving.cs index 7f3786066..903daeece 100644 --- a/Source/Client/Session/Autosaving.cs +++ b/Source/Client/Session/Autosaving.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using Multiplayer.Common; @@ -11,8 +12,14 @@ namespace Multiplayer.Client; public static class Autosaving { - public static void DoAutosave() + // The local save isn't sent to the server: it's taken without the reload every client does at a join point + // (and in this client's faction context), so a joiner loading it wouldn't match the live clients. + public static void DoAutosave(bool requestJoinPoint = true) { + // Taken now: in-game day autosaves start at the same tick on every client, but more ticks can run in this + // frame before the long event saves. The server starts one join point per tick. + var tick = TickPatch.Timer; + LongEventHandler.QueueLongEvent(() => { var snapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false); @@ -20,23 +27,17 @@ public static void DoAutosave() if (!SaveGameToFile_Overwrite(GetNextAutosaveFileName(), snapshot)) return; - SendAutosavingRequest(JoinPointRequestReason.Save); - - // When connected to a standalone server, also upload fresh snapshots - if (Multiplayer.session?.ConnectedToStandaloneServer == true) - { - SaveLoad.SendStandaloneMapSnapshots(snapshot); - SaveLoad.SendStandaloneWorldSnapshot(snapshot); - } + if (requestJoinPoint) + SendAutosavingRequest(JoinPointRequestReason.Save, tick); }, "MpSaving", false, null); } - public static void SendAutosavingRequest(JoinPointRequestReason reason) + public static void SendAutosavingRequest(JoinPointRequestReason reason, int tick) { if (TickPatch.Simulating) return; - Multiplayer.Client.Send(new ClientAutosavingPacket(reason)); + Multiplayer.Client.Send(new ClientAutosavingPacket(reason, tick)); } private static string GetNextAutosaveFileName() @@ -58,6 +59,13 @@ public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, bool cur currentReplay ? Multiplayer.session.dataSnapshot : null); public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameDataSnapshot snapshot) + => SaveGameToFile_Overwrite(fileNameNoExtension, file => + Replay.ForSaving(file).WriteData( + snapshot ?? SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false) + )); + + // writeReplay writes the save to the given (temporary) file + private static bool SaveGameToFile_Overwrite(string fileNameNoExtension, Action writeReplay) { Log.Message($"Multiplayer: saving to file {fileNameNoExtension}"); @@ -67,9 +75,7 @@ public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameData if (File.Exists(tmpPath)) File.Delete(tmpPath); - Replay.ForSaving(new FileInfo(tmpPath)).WriteData( - snapshot ?? SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false) - ); + writeReplay(new FileInfo(tmpPath)); var dstPath = Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip"); if (File.Exists(dstPath)) @@ -78,7 +84,8 @@ public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameData File.Move(tmpPath, dstPath); Messages.Message("MpGameSaved".Translate(fileNameNoExtension), MessageTypeDefOf.SilentInput, false); - Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup; + if (Multiplayer.session != null) + Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup; return true; } catch (Exception e) @@ -88,4 +95,43 @@ public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, GameData return false; } } + + // Called while disconnecting, before the session and the multiplayer game are gone (#333, #937). Keeps what a + // save needs: the last join point snapshot, which every command received since is appended to, and the replay + // info. Loading the save simulates the commands up to the tick this client had reached. Returns null when + // there's no game to save. + public static Action CaptureDisconnectSave() + { + var session = Multiplayer.session; + if (session == null || session.replay || session.dataSnapshot == null || Multiplayer.arbiterInstance || + Multiplayer.Client?.State != ConnectionStateEnum.ClientPlaying || + Current.ProgramState != ProgramState.Playing) + return null; + + try + { + var snapshot = session.dataSnapshot with { Cmds = new List(session.dataSnapshot.Cmds) }; + var endTick = TickPatch.Timer; + var info = Replay.CurrentInfo(); + + var baseName = $"Disconnected-{GenFile.SanitizedFileName(session.gameName ?? "")}"; + var fileName = baseName; + for (int i = 2; File.Exists(Path.Combine(Multiplayer.ReplaysDir, $"{fileName}.zip")); i++) + fileName = $"{baseName}-{i}"; + + return () => LongEventHandler.QueueLongEvent(() => + { + SaveGameToFile_Overwrite(fileName, file => + { + info.sections.Clear(); + Replay.ForSaving(file, info).WriteData(snapshot, endTick); + }); + }, "MpSaving", false, null); + } + catch (Exception e) + { + Log.Error($"Multiplayer: couldn't keep the game for saving after disconnecting: {e}"); + return null; + } + } } diff --git a/Source/Client/Session/GameDataSnapshot.cs b/Source/Client/Session/GameDataSnapshot.cs index fd3abbcd1..8e3f23b30 100644 --- a/Source/Client/Session/GameDataSnapshot.cs +++ b/Source/Client/Session/GameDataSnapshot.cs @@ -8,5 +8,6 @@ public record GameDataSnapshot( byte[] GameData, byte[] SessionData, Dictionary MapData, - Dictionary> MapCmds // Global cmds are -1, this is mutated by MultiplayerSession.ScheduleCommand + // All cmds (world and map) since CachedAtTime in execution order, appended to by MultiplayerSession.ScheduleCommand + List Cmds ); diff --git a/Source/Client/Session/MultiplayerSession.cs b/Source/Client/Session/MultiplayerSession.cs index 9bf2464c1..b773ee4ba 100644 --- a/Source/Client/Session/MultiplayerSession.cs +++ b/Source/Client/Session/MultiplayerSession.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Diagnostics; -using LudeonTK; using Multiplayer.Client.Networking; using Multiplayer.Client.Util; using Multiplayer.Common; @@ -39,8 +38,17 @@ public class MultiplayerSession : IConnectionStatusListener public bool showTimeline; public bool desynced; + // Caught exceptions in synced commands and ticks, see SimulationFailures + public int simulationFailures; + public string firstSimulationFailure; + // Set when the player connected despite mismatched game data + public string joinDataDiff; + // Standalone: set by the server when this client's sync opinions are the ones compared against + public bool isSyncReference; public List pendingSteam = new(); + // Steam join requests the host rejected, by time (Time.realtimeSinceStartup) + public Dictionary rejectedSteam = new(); public List knownUsers = new(); public const int MaxMessages = 200; @@ -114,6 +122,15 @@ public void Disconnected(SessionDisconnectInfo info) { MpUI.ClearWindowStack(); + if (info.specialButtonTranslated == null && Autosaving.CaptureDisconnectSave() is { } save) + { + info.specialButtonTranslated = "MpSaveGameAfterDisconnect".TryTranslate(out var label) + ? label + : "SaveGameButton".Translate(); + info.specialButtonAction = save; + info.specialButtonKeepsWindow = true; + } + Find.WindowStack.Add(new DisconnectedWindow(info) { returnToServerBrowser = Multiplayer.Client?.State != ConnectionStateEnum.ClientPlaying @@ -131,20 +148,20 @@ public void ProcessTimeControl() TickPatch.tickUntil = remoteTickUntil; } - [TweakValue("Multiplayer")] public static bool consistentCommandOrder = true; + // False while a (re)load is in progress: Loader.PostLoad then builds the command queue from dataSnapshot.Cmds, + // which also contains every command received in the meantime. + public bool cmdQueueReady = true; + public void ScheduleCommand(ScheduledCommand cmd) { MpLog.Debug(cmd.ToString()); - dataSnapshot.MapCmds.GetOrAddNew(cmd.mapId).Add(cmd); + dataSnapshot.Cmds.Add(cmd); - if (Current.ProgramState != ProgramState.Playing) return; + if (!cmdQueueReady) return; - // Minimal code impact fix for #733. Having all the commands be added to a single queue gets rid of the - // out-of-order execution problem. - if (cmd.mapId == ScheduledCommand.Global || consistentCommandOrder) - Multiplayer.AsyncWorldTime.Cmds.Enqueue(cmd); - else - cmd.GetMap()?.AsyncTime().Cmds.Enqueue(cmd); + // All commands (world and map) go to a single queue and run in arrival order, the same order the server + // stores them in for joiners (#733) + Multiplayer.AsyncWorldTime.Cmds.Enqueue(cmd); } public void Update() diff --git a/Source/Client/Session/SessionDisconnectInfo.cs b/Source/Client/Session/SessionDisconnectInfo.cs index f31eff056..218b36acf 100644 --- a/Source/Client/Session/SessionDisconnectInfo.cs +++ b/Source/Client/Session/SessionDisconnectInfo.cs @@ -12,6 +12,8 @@ public struct SessionDisconnectInfo public string descTranslated; public string specialButtonTranslated; public Action specialButtonAction; + // The window stays open after the special button, e.g. to save the game and then quit to the main menu + public bool specialButtonKeepsWindow; public bool wideWindow; public static SessionDisconnectInfo From(ServerDisconnectPacket goodbye) => @@ -100,6 +102,17 @@ public static SessionDisconnectInfo From(MpDisconnectReason reason, ByteReader r descKey = "MpChangeUsernameInfo"; } + // The server's joining or loading state threw (#797); the details are only in the server's log + if (reason == MpDisconnectReason.StateException) + { + disconnectInfo.titleTranslated = "MpServerStateException".TryTranslate(out var title) + ? title + : "Server error while joining"; + disconnectInfo.descTranslated = "MpServerStateExceptionDesc".TryTranslate(out var desc) + ? desc + : "See the host's log (or the standalone server's log) for the error."; + } + if (reason == MpDisconnectReason.ServerClosed) titleKey = "MpServerClosed"; if (reason == MpDisconnectReason.ServerFull) titleKey = "MpServerFull"; if (reason == MpDisconnectReason.ServerStarting) titleKey = "MpDisconnectServerStarting"; diff --git a/Source/Client/Syncing/Dict/SyncDictDlc.cs b/Source/Client/Syncing/Dict/SyncDictDlc.cs index 7e31767fd..7c0ad6eb9 100644 --- a/Source/Client/Syncing/Dict/SyncDictDlc.cs +++ b/Source/Client/Syncing/Dict/SyncDictDlc.cs @@ -229,13 +229,21 @@ public static class SyncDictDlc { WriteSync(data, gene.def); WriteSync(data, gene.pawn); + data.WriteInt32(gene.loadID); }, (ByteReader data) => { var geneDef = ReadSync(data); var pawn = ReadSync(data); + var loadId = data.ReadInt32(); - return pawn.genes.GetGene(geneDef); + var genes = pawn?.genes; + if (genes == null) + return null; + + // A pawn can have the same gene def both as an endogene and a xenogene, so don't fall back to + // GetGene(def) if the exact gene is gone + return genes.GenesListForReading.Find(g => g.loadID == loadId && g.def == geneDef); }, true // implicit }, { diff --git a/Source/Client/Syncing/Dict/SyncDictRimWorld.cs b/Source/Client/Syncing/Dict/SyncDictRimWorld.cs index 865528ac2..16d2fbc43 100644 --- a/Source/Client/Syncing/Dict/SyncDictRimWorld.cs +++ b/Source/Client/Syncing/Dict/SyncDictRimWorld.cs @@ -161,7 +161,7 @@ public static class SyncDictRimWorld return; } - hediff = pawn.health.hediffSet.hediffs.First(x => x.loadID == id); + hediff = pawn.health.hediffSet.hediffs.FirstOrDefault(x => x.loadID == id); if (hediff == null) { @@ -174,8 +174,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref HediffComp hediffComp) => { if (data.isWriting) { if (hediffComp != null) { - ushort index = (ushort)Array.IndexOf(hediffCompTypes, hediffComp.GetType()); - data.Write(index); + int index = Array.IndexOf(hediffCompTypes, hediffComp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {hediffComp.GetType()}, not in hediffCompTypes"); + data.Write((ushort)index); data.Write(hediffComp.parent); var tempComp = hediffComp; var compIndex = hediffComp.parent.comps.Where(x => x.props.compClass == tempComp.props.compClass).FirstIndexOf(x => x == tempComp); @@ -329,8 +331,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref AbilityComp comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(abilityCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(abilityCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in abilityCompTypes"); + data.Write((ushort)index); data.Write(comp.parent); } else { data.Write(ushort.MaxValue); @@ -669,7 +673,7 @@ public static class SyncDictRimWorld { (SyncWorker sync, ref Designator_MoveGravship moveGravship) => { if (sync.isWriting) - sync.Write(moveGravship.marker.GravshipRotation); + sync.Write(moveGravship.marker?.GravshipRotation ?? Rot4.North); else { var rot = sync.Read(); @@ -677,11 +681,20 @@ public static class SyncDictRimWorld var gravController = Find.GravshipController; var marker = gravController.landingMarker; - if (marker != null) - marker.GravshipRotation = rot; + // The landing was confirmed or aborted before this command, the same on every client + if (marker == null) + { + moveGravship = null; + return; + } - moveGravship = gravController.moveDesignator; - moveGravship.deselectedRotation = moveGravship.marker.GravshipRotation; + marker.GravshipRotation = rot; + + // Not the moveDesignator field: the landing UI creates it lazily when drawn, which it isn't on + // clients viewing the world or in screenshot mode, or right after loading. The command + // failed there and the landing marker (and so the landing spot) differed between clients. + moveGravship = gravController.MoveDesignator(); + moveGravship.deselectedRotation = rot; } }, true, false }, @@ -815,6 +828,8 @@ public static class SyncDictRimWorld holder = worldObj; else if (RwSerialization.GetAnyParent(thing) is { } worldObjComp) holder = worldObjComp; + else if (thing is Pawn pawn && pawn.IsWorldPawn()) + holder = Find.WorldPawns; // E.g. pawns only referenced by quests or letters RwSerialization.GetImpl(holder, RwSerialization.supportedThingHolders, out Type implType, out int index); if (index == -1) @@ -826,7 +841,7 @@ public static class SyncDictRimWorld data.WriteByte((byte)index); - if (implType != typeof(Map)) + if (implType != typeof(Map) && implType != typeof(WorldPawns)) { context.syncingThingParent = true; WriteSyncObject(data, holder, implType); @@ -849,6 +864,9 @@ public static class SyncDictRimWorld Type implType = RwSerialization.supportedThingHolders[implIndex]; + if (implType == typeof(WorldPawns)) + return Find.WorldPawns.AllPawnsAliveOrDead.FirstOrDefault(p => p.thingIDNumber == thingId); + if (implType != typeof(Map)) { context.syncingThingParent = true; @@ -868,8 +886,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref ThingComp comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(thingCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(thingCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in thingCompTypes"); + data.Write((ushort)index); data.Write(comp.parent); var tempComp = comp; var compIndex = comp.parent.AllComps.Where(x => x.props.compClass == tempComp.props.compClass).FirstIndexOf(x => x == tempComp); @@ -944,8 +964,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref MapComponent comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(mapCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(mapCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in mapCompTypes"); + data.Write((ushort)index); data.Write(comp.map); } else { data.Write(ushort.MaxValue); @@ -988,8 +1010,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref WorldObjectComp comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(worldObjectCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(worldObjectCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in worldObjectCompTypes"); + data.Write((ushort)index); data.Write(comp.parent); } else { data.Write(ushort.MaxValue); @@ -1012,8 +1036,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref WorldComponent comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(worldCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(worldCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in worldCompTypes"); + data.Write((ushort)index); data.Write(comp.world); } else { data.Write(ushort.MaxValue); @@ -1047,10 +1073,9 @@ public static class SyncDictRimWorld (ByteReader data) => new PlanetTile(data.ReadInt32(), data.ReadInt32()), true // Implicit }, { - (ByteWriter data, PlanetLayer workGiver) => + (ByteWriter data, PlanetLayer layer) => { - int layerId = workGiver?.LayerID ?? -1; - WriteSync(data, workGiver.LayerID); + WriteSync(data, layer?.LayerID ?? -1); }, (ByteReader data) => { @@ -1095,8 +1120,10 @@ public static class SyncDictRimWorld (SyncWorker data, ref GameComponent comp) => { if (data.isWriting) { if (comp != null) { - ushort index = (ushort)Array.IndexOf(gameCompTypes, comp.GetType()); - data.Write(index); + int index = Array.IndexOf(gameCompTypes, comp.GetType()); + if (index == -1) + throw new SerializationException($"Unknown {comp.GetType()}, not in gameCompTypes"); + data.Write((ushort)index); } else { data.Write(ushort.MaxValue); } diff --git a/Source/Client/Syncing/Game/SyncActions.cs b/Source/Client/Syncing/Game/SyncActions.cs index b5b20a220..dfd61dab7 100644 --- a/Source/Client/Syncing/Game/SyncActions.cs +++ b/Source/Client/Syncing/Game/SyncActions.cs @@ -14,11 +14,7 @@ static class SyncActions public static void Init() { - void Error(string error) - { - Multiplayer.loadingErrors = true; - Log.Error(error); - } + void Error(string 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. CaravanActionConfirmationType = AccessTools.Inner(typeof(CaravanArrivalActionUtility), "<>c__DisplayClass0_1`1"); diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index fbacb638e..85de5f3d8 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -24,7 +24,7 @@ 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: its TransporterUtility.InitiateLoading call is synced 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 @@ -244,7 +244,7 @@ public static void Init() SyncDelegate.Lambda(typeof(GeneResourceDrainUtility), nameof(GeneResourceDrainUtility.GetResourceDrainGizmos), 1).SetDebugOnly(); // +10% resource SyncMethod.Register(typeof(CompBreakdownable), nameof(CompBreakdownable.Notify_Repaired)).SetDebugOnly(); // Dev repair breakdownable SyncDelegate.Lambda(typeof(CompCanBeDormant), nameof(CompCanBeDormant.CompGetGizmosExtra), 1).SetDebugOnly(); // Wake up after delay - SyncDelegate.Lambda(typeof(Tile), nameof(Tile.GetGizmos), 0).SetDebugOnly(); // Generate settlement + SyncDelegate.Lambda(typeof(Tile), nameof(Tile.GetGizmos), 0).SetContext(SyncContext.WorldSelected).SetDebugOnly(); // Generate settlement (reads Find.WorldSelector.SelectedTile) SyncDelegate.Lambda(typeof(Tile), nameof(Tile.GetGizmos), 4).SetDebugOnly(); // Generate map // Hediffs @@ -280,8 +280,7 @@ public static void Init() SyncDelegate.Lambda(typeof(Pawn_TrainingTracker), nameof(Pawn_TrainingTracker.GetGizmos), 0, fields: [SyncDelegate.DelegateThis, "master"]).SetContext(SyncContext.MapSelected).CancelIfNoSelectedMapObjects(); // Force attack target SyncDelegate.Lambda(typeof(Pawn_TrainingTracker), nameof(Pawn_TrainingTracker.GetGizmos), 3).SetContext(SyncContext.MapSelected).CancelIfNoSelectedMapObjects(); // Cancel attacking target - // Colonist bar reordering - SyncDelegate.Lambda(typeof(ColonistBar.Entry), null, lambdaOrdinal: 0, parentMethodType: MethodType.Constructor, parentArgs: [typeof(Pawn), typeof(Map), typeof(int)]); + // Colonist bar reordering is synced through ColonistBarReorder below InitRituals(); InitChoiceLetters(); @@ -334,11 +333,13 @@ The UI's main interaction area is split into three types of groups of pawns. SyncMethod.Register(typeof(PsychicRitualRoleAssignments), nameof(PsychicRitualRoleAssignments.RemoveParticipant)); SyncRituals.ApplyPrepatches(null); + if (!SyncRituals.PrepatchesApplied) + Log.Warning("Multiplayer: Prepatcher isn't active, ritual role assignments won't be shared between players"); } private static void InitChoiceLetters() { - SyncDelegate.Lambda(typeof(ChoiceLetter_ChoosePawn), nameof(ChoiceLetter_ChoosePawn.Option_ChoosePawn), 0); // Choose pawn (currently used for quest rewards) + SyncDelegate.Lambda(typeof(ChoiceLetter_ChoosePawn), nameof(ChoiceLetter_ChoosePawn.Option_ChoosePawn), 0).CancelIfAnyFieldNull(); // Choose pawn (currently used for quest rewards) SyncMethod.LambdaInGetter(typeof(ChoiceLetter_AcceptJoiner), nameof(ChoiceLetter_AcceptJoiner.Choices), 0); // Accept joiner CloseDialogsForExpiredLetters.RegisterDefaultLetterChoice( @@ -461,14 +462,91 @@ static void PickRandomTraitAndPassions(ChoiceLetter_GrowthMoment letter) Find.LetterStack.RemoveLetter(letter); } + // The reorder indices and the group are relative to the local colonist bar's entries, which differ between + // clients (multifaction, hidden maps, entries not yet recached), so resolve them to pawns on the sender. + [MpPrefix(typeof(ColonistBar), nameof(ColonistBar.Reorder))] + static bool ColonistBarReorder(ColonistBar __instance, int from, int to, int entryGroup) + { + if (!Multiplayer.ShouldSync) + return true; + + var group = new List(); + foreach (var entry in __instance.cachedEntries) + if (entry.group == entryGroup && entry.pawn != null) + group.Add(entry.pawn); + + if (from >= 0 && from < group.Count) + { + bool toEnd = to < 0 || to >= group.Count; + SyncedColonistBarReorder(group[from], toEnd ? null : group[to], toEnd, group); + } + + return false; + } + + [SyncMethod] + static void SyncedColonistBarReorder(Pawn dragged, Pawn target, bool toEnd, List group) + { + group.RemoveAll(p => p?.playerSettings == null); + if (dragged?.playerSettings == null || !toEnd && target?.playerSettings == null || group.Count == 0) + return; + + int newOrder = toEnd ? group[group.Count - 1].playerSettings.displayOrder + 1 : target.playerSettings.displayOrder; + + // Same as ColonistBar.Reorder, but instead of the local bar's entries it shifts the group and all the faction's + // pawns (and colonist corpses, which the bar shows too). Shifting pawns which aren't in the bar keeps the + // relative order, so the bar's order is the same. + var pawns = new List(group); + var seen = new HashSet(group); + + void TryAdd(Pawn p) + { + if (p?.playerSettings != null && p.Faction == Faction.OfPlayer && seen.Add(p)) + pawns.Add(p); + } + + foreach (var p in PawnsFinder.AllMapsCaravansAndTravellingTransporters_Alive) + TryAdd(p); + + foreach (var map in Find.Maps) + foreach (var thing in map.listerThings.ThingsInGroup(ThingRequestGroup.Corpse)) + TryAdd((thing as Corpse)?.InnerPawn); + + foreach (var p in pawns) + { + var settings = p.playerSettings; + if (settings.displayOrder == newOrder) + { + if (!toEnd && group.Contains(p)) + settings.displayOrder += p.thingIDNumber < target.thingIDNumber ? -1 : 1; + } + else if (settings.displayOrder > newOrder) + settings.displayOrder++; + else + settings.displayOrder--; + } + + dragged.playerSettings.displayOrder = newOrder; + Find.ColonistBar.MarkColonistsDirty(); + MainTabWindowUtility.NotifyAllPawnTables_PawnsChanged(); + } + // The letter tries to generate the options when opened. Make the picks seeded, so all players will get the same ones. [MpPrefix(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.TrySetChoices))] - static void PreLetterChoices(ChoiceLetter_GrowthMoment __instance) - => Rand.PushState(Gen.HashCombineInt(__instance.pawn.thingIDNumber, __instance.arrivalTick)); + static void PreLetterChoices(ChoiceLetter_GrowthMoment __instance, out bool __state) + { + __state = false; + Rand.PushState(Gen.HashCombineInt(__instance.pawn.thingIDNumber, __instance.arrivalTick)); + __state = true; + } - [MpPostfix(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.TrySetChoices))] - static void PostLetterChoices() - => Rand.PopState(); + // A finalizer to keep the Rand state stack balanced if TrySetChoices throws + [MpFinalizer(typeof(ChoiceLetter_GrowthMoment), nameof(ChoiceLetter_GrowthMoment.TrySetChoices))] + static void PostLetterChoices(bool __state) + { + if (__state) + Rand.PopState(); + } static void InitDevTools() { diff --git a/Source/Client/Syncing/Game/SyncFields.cs b/Source/Client/Syncing/Game/SyncFields.cs index d39185851..3dabaa8b9 100644 --- a/Source/Client/Syncing/Game/SyncFields.cs +++ b/Source/Client/Syncing/Game/SyncFields.cs @@ -54,6 +54,7 @@ public static partial class SyncFields public static SyncField[] SyncBillProduction; public static SyncField[] SyncBillIncludeCriteria; + public static SyncField[] SyncBillStyle; public static SyncField[] SyncDrugPolicyEntry; public static ISyncField SyncDrugPolicyEntryTakeToInventory; @@ -168,6 +169,14 @@ public static void Init() nameof(Bill_Production.unpauseWhenYouHave) ).SetBufferChanges(); + SyncBillStyle = Sync.Fields( + typeof(Bill), + null, + nameof(Bill.style), + nameof(Bill.globalStyle), + nameof(Bill.graphicIndexOverride) + ); + SyncBillIncludeCriteria = Sync.Fields( typeof(Bill_Production), null, @@ -411,6 +420,37 @@ static void DialogBillConfig(Dialog_BillConfig __instance) } } + // The product style menu of the bill dialog (Ideology in classic mode). Its options' lambdas: use the global + // style (10), basic (11), a style (12) and a style with a graphic index (13) + [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents), lambdaOrdinal: 10)] + [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents), lambdaOrdinal: 11)] + [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents), lambdaOrdinal: 12)] + [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents), lambdaOrdinal: 13)] + static void BillStyleOption(object __instance) + { + if (FindBillConfig(__instance, 0) is { } dialog) + SyncBillStyle.Watch(dialog.bill); + else + Log.ErrorOnce($"Multiplayer: couldn't find the bill of a style option ({__instance?.GetType()}), the style won't be synced", 0x4b1157); + } + + // The lambdas are the dialog's own methods or capture it, possibly through an outer closure + private static Dialog_BillConfig FindBillConfig(object obj, int depth) + { + if (obj is Dialog_BillConfig dialog) + return dialog; + if (obj == null || depth > 3) + return null; + + foreach (var field in obj.GetType().GetFields(AccessTools.all)) + if (typeof(Dialog_BillConfig).IsAssignableFrom(field.FieldType) || + field.FieldType.DeclaringType == typeof(Dialog_BillConfig)) + if (FindBillConfig(field.GetValue(obj), depth + 1) is { } found) + return found; + + return null; + } + [MpTranspiler(typeof(BillRepeatModeUtility), nameof(BillRepeatModeUtility.MakeConfigFloatMenu))] static IEnumerable BillConfigFloatMenuTranspiler(IEnumerable insts, MethodBase orig) { @@ -536,7 +576,7 @@ static IEnumerable> WatchDropdowns(Action watchAction, foreach (var entry in dropdowns) { if (entry.option.action != null) - entry.option.action = SyncFieldUtil.FieldWatchPrefix + watchAction + entry.option.action + SyncFieldUtil.FieldWatchPostfix; + entry.option.action = SyncFieldUtil.WatchScope(watchAction, entry.option.action); yield return entry; } } @@ -547,7 +587,7 @@ static void WatchMenuOptions(Action watchAction, IEnumerable op { if (entry.action != null) { - entry.action = SyncFieldUtil.FieldWatchPrefix + watchAction + entry.action + SyncFieldUtil.FieldWatchPostfix; + entry.action = SyncFieldUtil.WatchScope(watchAction, entry.action); } } } 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..64c770628 100644 --- a/Source/Client/Syncing/Game/SyncMarkers.cs +++ b/Source/Client/Syncing/Game/SyncMarkers.cs @@ -9,6 +9,7 @@ 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; + // A finalizer, so a throw in the checkbox doesn't leave the marker set + [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..90a3f6c60 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -468,7 +468,8 @@ public static void Init() SyncMethod.Lambda(typeof(Building_BioferriteHarvester), nameof(Building_BioferriteHarvester.GetGizmos), 1); // Toggle unload SyncMethod.Lambda(typeof(Building_BioferriteHarvester), nameof(Building_BioferriteHarvester.GetGizmos), 3).SetDebugOnly(); // Dev add +1 - SyncMethod.Register(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.PlaceGravship)); + // Not PlaceGravship: it runs in a long event on every client (see PatchPlaceGravshipInMapContext), where it + // never synced, and as a command it couldn't be read anyway (the gravship isn't a world object then) SyncMethod.Register(typeof(WorldComponent_GravshipController), nameof(WorldComponent_GravshipController.AbortLanding)).SetContext(SyncContext.CurrentMap); // Outfit stand @@ -488,11 +489,16 @@ public static void Init() core.GetMultiSelectFloatMenuOptions(Find.Selector.SelectedPawns).ExecuteEnumerable(); }) // After syncing and invoking the method, we want to restore the previous state of tmpPawnsCanReach. - // Just run the GetMultiSelectFloatMenuOptions method again so the list is populated. + // Just run the GetMultiSelectFloatMenuOptions method again so the list is populated. It's UI state for + // the local selection, so do it outside of the command to keep the clients' simulation identical. .SetPostInvoke((x, _) => { if (x is not Building_ArchonexusCore core) return; - core.GetMultiSelectFloatMenuOptions(SyncUtil.prevSelected.OfType()).ExecuteEnumerable(); + OnMainThread.Enqueue(() => + { + if (core.Spawned) + core.GetMultiSelectFloatMenuOptions(Find.Selector.SelectedPawns).ExecuteEnumerable(); + }); }); SyncMethod.Register(typeof(Building_ArchonexusCore), nameof(Building_ArchonexusCore.Activate)).SetDebugOnly(); // Activate archonexus core @@ -508,11 +514,16 @@ public static void Init() crate.GetMultiSelectFloatMenuOptions(Find.Selector.SelectedPawns).ExecuteEnumerable(); }) // After syncing and invoking the method, we want to restore the previous state of tmpPawnsCanReach. - // Just run the GetMultiSelectFloatMenuOptions method again so the list is populated. + // Just run the GetMultiSelectFloatMenuOptions method again so the list is populated. It's UI state for + // the local selection, so do it outside of the command to keep the clients' simulation identical. .SetPostInvoke((x, _) => { if (x is not Building_Crate crate) return; - crate.GetMultiSelectFloatMenuOptions(SyncUtil.prevSelected.OfType()).ExecuteEnumerable(); + OnMainThread.Enqueue(() => + { + if (crate.Spawned) + crate.GetMultiSelectFloatMenuOptions(Find.Selector.SelectedPawns).ExecuteEnumerable(); + }); }); // Double ExecuteWhenFinished ensures it'll load after MP Compat late patches, @@ -545,10 +556,15 @@ static bool PastePawnTimetable(Pawn p) } [MpPrefix(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] - static void StorageSettingsClipboardCopy_Prefix() => Multiplayer.dontSync = true; + static void StorageSettingsClipboardCopy_Prefix(out bool __state) + { + __state = Multiplayer.dontSync; + Multiplayer.dontSync = true; + } - [MpPostfix(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] - static void StorageSettingsClipboardCopy_Postfix() => Multiplayer.dontSync = false; + // A finalizer, as leaving dontSync set would turn every later sync handler local-only + [MpFinalizer(typeof(StorageSettingsClipboard), nameof(StorageSettingsClipboard.Copy))] + static void StorageSettingsClipboardCopy_Finalizer(bool __state) => Multiplayer.dontSync = __state; [MpTranspiler(typeof(CompPlantable), nameof(CompPlantable.BeginTargeting), lambdaOrdinal: 0)] static IEnumerable CompPlantableTranspiler(IEnumerable insts) @@ -584,6 +600,34 @@ static IEnumerable CompPlantableTranspiler(IEnumerable plantable.plantCells.Add(newValue); + // Float menu options (carry to shuttle, place carried pawn in a transporter, enter shuttle) call + // InitiateLoading from the interface right before the synced TryTakeOrderedJob, so the group id was + // set on the clicking client only. Both commands target the transporter's map and keep their order. + [MpPrefix(typeof(TransporterUtility), nameof(TransporterUtility.InitiateLoading))] + static bool InitiateLoading_Prefix(IEnumerable transporters, ref int __result) + { + if (!Multiplayer.ShouldSync) + return true; + + SyncedInitiateLoading(transporters.ToList()); + // Vanilla interface callers ignore the group id; -1 is the "no group" marker + __result = -1; + return false; + } + + [SyncMethod] + static void SyncedInitiateLoading(List transporters) + { + transporters.RemoveAll(t => t == null); + + // The interface callers only initiate loading when it isn't in progress; it may have started + // since the click (e.g. another player's order), don't replace the group then + if (transporters.Count == 0 || transporters.All(t => t.LoadingInProgressOrReadyToLaunch)) + return; + + TransporterUtility.InitiateLoading(transporters); + } + // ===== CALLBACKS ===== [MpPostfix(typeof(DrugPolicyDatabase), nameof(DrugPolicyDatabase.MakeNewDrugPolicy))] @@ -705,14 +749,17 @@ static void AddBill_Prefix(Bill bill) [MpPostfix(typeof(Ideo), nameof(Ideo.CopyTo))] static void FixIdeoAfterCopy(Ideo __instance, Ideo ideo) { - if (Multiplayer.ExecutingCmds) + // Ticking too: mods can apply a reform from simulation code (e.g. ritual outcomes) + if (Multiplayer.ExecutingCmds || Multiplayer.Ticking) { // Fix ids for precepts generated by the fluid ideo reforming UI foreach (var precept in ideo.PreceptsListForReading) if (precept.ID < 0) precept.ID = Find.UniqueIDsManager.GetNextPreceptID(); - ideo.development.ideo = ideo; + // CopyTo creates the development tracker only for fluid ideos + if (ideo.development != null) + ideo.development.ideo = ideo; ideo.style.ideo = ideo; } } @@ -935,5 +982,48 @@ static bool DoSyncBookcaseTryDrop(Building_Bookcase bookcase, Thing thing, IntVe return owner.TryDrop(thing, dropLoc, map, mode, count, out resultingThing, null, null); } + + // The dev mode "remove hediff" button of the health tab calls RemoveHediff in its drawer lambda, which runs + // every frame and so can't be synced itself (as a SyncDelegate it would send a command each frame). Sync the + // call instead (#358). + [HarmonyPatch] + static class HealthCardDevRemoveHediff_Patch + { + private static readonly MethodInfo RemoveHediff = + AccessTools.Method(typeof(Pawn_HealthTracker), nameof(Pawn_HealthTracker.RemoveHediff)); + + static MethodBase TargetMethod() + { + // The only lambda of DrawHediffRow calling RemoveHediff + var lambdaPrefix = $"<{nameof(HealthCardUtility.DrawHediffRow)}>"; + return typeof(HealthCardUtility).GetNestedTypes(AccessTools.all) + .SelectMany(t => t.GetDeclaredMethods()) + .Where(m => m.Name.StartsWith(lambdaPrefix)) + .First(m => PatchProcessor.ReadMethodBody(m).Any(i => i.Value as MethodInfo == RemoveHediff)); + } + + static IEnumerable Transpiler(IEnumerable insts) + { + var replacement = AccessTools.Method(typeof(SyncMethods), nameof(DevRemoveHediff)); + + foreach (var ci in insts) + { + if (ci.Calls(RemoveHediff)) + { + ci.opcode = OpCodes.Call; + ci.operand = replacement; + } + + yield return ci; + } + } + } + + // Takes the arguments of the instance call it replaces + static void DevRemoveHediff(Pawn_HealthTracker health, Hediff hediff) => + SyncedDevRemoveHediff(health.pawn, hediff); + + [SyncMethod(debugOnly = true, cancelIfAnyArgNull = true)] + static void SyncedDevRemoveHediff(Pawn pawn, Hediff hediff) => pawn.health.RemoveHediff(hediff); } } diff --git a/Source/Client/Syncing/Game/SyncThingFilters.cs b/Source/Client/Syncing/Game/SyncThingFilters.cs index 33078da2d..7b98dab8a 100644 --- a/Source/Client/Syncing/Game/SyncThingFilters.cs +++ b/Source/Client/Syncing/Game/SyncThingFilters.cs @@ -8,53 +8,111 @@ namespace Multiplayer.Client; public static class SyncThingFilters { [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetAllow), [typeof(StuffCategoryDef), typeof(bool)])] - static bool ThingFilter_SetAllow(StuffCategoryDef cat, bool allow) + static bool ThingFilter_SetAllow(ThingFilter __instance, StuffCategoryDef cat, bool allow) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; AllowStuffCat_Helper(ThingFilterMarkers.DrawnThingFilter, cat, allow); return false; } [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetAllow), [typeof(SpecialThingFilterDef), typeof(bool)])] - static bool ThingFilter_SetAllow(SpecialThingFilterDef sfDef, bool allow) + static bool ThingFilter_SetAllow(ThingFilter __instance, SpecialThingFilterDef sfDef, bool allow) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; AllowSpecial_Helper(ThingFilterMarkers.DrawnThingFilter, sfDef, allow); return false; } [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetAllow), [typeof(ThingDef), typeof(bool)])] - static bool ThingFilter_SetAllow(ThingDef thingDef, bool allow) + static bool ThingFilter_SetAllow(ThingFilter __instance, ThingDef thingDef, bool allow) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; AllowThing_Helper(ThingFilterMarkers.DrawnThingFilter, thingDef, allow); return false; } [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetAllow), [typeof(ThingCategoryDef), typeof(bool), typeof(IEnumerable), typeof(IEnumerable)])] - static bool ThingFilter_SetAllow(ThingCategoryDef categoryDef, bool allow) + static bool ThingFilter_SetAllow(ThingFilter __instance, ThingCategoryDef categoryDef, bool allow) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; AllowCategory_Helper(ThingFilterMarkers.DrawnThingFilter, categoryDef, allow); return false; } [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetAllowAll))] - static bool ThingFilter_SetAllowAll() + static bool ThingFilter_SetAllowAll(ThingFilter __instance) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; AllowAll_Helper(ThingFilterMarkers.DrawnThingFilter); return false; } [MpPrefix(typeof(ThingFilter), nameof(ThingFilter.SetDisallowAll))] - static bool ThingFilter_SetDisallowAll() + static bool ThingFilter_SetDisallowAll(ThingFilter __instance) { - if (!Multiplayer.ShouldSync || ThingFilterMarkers.DrawnThingFilter == null) return true; + if (!Multiplayer.ShouldSync) return true; + if (ThingFilterMarkers.IsUnsyncableFilter(__instance)) return false; + if (ThingFilterMarkers.DrawnThingFilter == null) return true; DisallowAll_Helper(ThingFilterMarkers.DrawnThingFilter); return false; } + // The range setters invoke settingsChangedCallback, which for storage rechecks the map's haulables. In the interface + // the setters are called by the watched sliders and the field watch's restore, so only write the backing field + // there to keep those side effects out of the sender's simulation. The callback runs when the synced field applies. + static bool RunRangeSetter(ThingFilter filter, out bool writeField) + { + writeField = false; + + if (!Multiplayer.InInterface) + return true; + + if (ThingFilterMarkers.IsUnsyncableFilter(filter)) + return false; + + if (ThingFilterMarkers.DrawnThingFilter?.Filter == filter || filter.settingsChangedCallback?.Target is StorageSettings) + { + writeField = true; + return false; + } + + return true; + } + + [MpPrefix(typeof(ThingFilter), "set_" + nameof(ThingFilter.AllowedHitPointsPercents))] + static bool ThingFilter_SetHitPoints(ThingFilter __instance, FloatRange value) + { + if (RunRangeSetter(__instance, out var writeField)) return true; + if (writeField) __instance.allowedHitPointsPercents = value; + return false; + } + + [MpPrefix(typeof(ThingFilter), "set_" + nameof(ThingFilter.AllowedQualityLevels))] + static bool ThingFilter_SetQuality(ThingFilter __instance, QualityRange value) + { + if (RunRangeSetter(__instance, out var writeField)) return true; + if (writeField) __instance.allowedQualities = value; + return false; + } + + [MpPrefix(typeof(ThingFilter), "set_" + nameof(ThingFilter.AllowedMentalBreakChance))] + static bool ThingFilter_SetMentalBreak(ThingFilter __instance, FloatRange value) + { + if (RunRangeSetter(__instance, out var writeField)) return true; + if (writeField) __instance.allowedMentalBreakChance = value; + return false; + } + [SyncMethod] static void AllowStuffCat_Helper(ThingFilterContext context, StuffCategoryDef cat, bool allow) { diff --git a/Source/Client/Syncing/Game/ThingFilterContexts.cs b/Source/Client/Syncing/Game/ThingFilterContexts.cs index 66d58b59f..61cb6e622 100644 --- a/Source/Client/Syncing/Game/ThingFilterContexts.cs +++ b/Source/Client/Syncing/Game/ThingFilterContexts.cs @@ -10,6 +10,10 @@ public record TabStorageWrapper(IStoreSettingsParent Storage) : ThingFilterConte { public override ThingFilter Filter => Storage.GetStoreSettings().filter; public override ThingFilter ParentFilter => Storage.GetParentStoreSettings()?.filter; + + // Same as the ones passed by ITab_Storage.FillTab (the method doesn't depend on the tab's state) + public override IEnumerable HiddenFilters => + ((ITab_Storage)InspectTabManager.GetSharedInstance(typeof(ITab_Storage))).HiddenSpecialThingFilters(); } public record BillConfigWrapper(Bill Bill) : ThingFilterContext diff --git a/Source/Client/Syncing/Game/ThingFilterMarkers.cs b/Source/Client/Syncing/Game/ThingFilterMarkers.cs index 98651e066..2317cd81a 100644 --- a/Source/Client/Syncing/Game/ThingFilterMarkers.cs +++ b/Source/Client/Syncing/Game/ThingFilterMarkers.cs @@ -23,70 +23,87 @@ public static ThingFilterContext DrawnThingFilter } } + // The filter of a storage which can't be synced, edits to it are blocked instead of being applied locally + private static ThingFilter unsyncableFilter; + + public static bool IsUnsyncableFilter(ThingFilter filter) => + drawingThingFilter && filter != null && filter == unsyncableFilter; + [MpPrefix(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] static void TabStorageFillTab_Prefix(ITab_Storage __instance) { var selThing = __instance.SelObject; var selParent = __instance.SelStoreSettingsParent; // If SelStoreSettingsParent is null, just return early. There'll be nothing to sync. + if (selParent == null) + return; + // The map could potentially be null - for example, if we're syncing mortar. The mortar gun itself // holds the store settings, and turret guns don't have a map/location assigned - so we sync their parent. // Because of that, we check if the parent is not null. - if (selParent == null || selThing is Thing { Map: null } or ThingComp { parent: { Map: null } }) + if (selThing is Thing { Map: null } or ThingComp { parent: { Map: null } }) + { + unsyncableFilter = selParent.GetStoreSettings()?.filter; return; + } + 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; + unsyncableFilter = 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 +111,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 +124,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/Handler/SyncField.cs b/Source/Client/Syncing/Handler/SyncField.cs index 0c58a8ae4..b8c99cff3 100644 --- a/Source/Client/Syncing/Handler/SyncField.cs +++ b/Source/Client/Syncing/Handler/SyncField.cs @@ -105,7 +105,12 @@ public override void Handle(ByteReader data) public void Watch(object target = null, object index = null) { - if (!(inGameLoop || Multiplayer.ShouldSync)) + // In singleplayer nothing ever pops the entry (InGameLoop fields) + if (!(inGameLoop || Multiplayer.ShouldSync) || Multiplayer.Client == null) + return; + + // The enclosing watch scope takes care of it (e.g. a watched method called from another watched method) + if (SyncFieldUtil.IsWatched(this, target, index)) return; object value; diff --git a/Source/Client/Syncing/RwSerialization.cs b/Source/Client/Syncing/RwSerialization.cs index 840f78eaa..b155c6935 100644 --- a/Source/Client/Syncing/RwSerialization.cs +++ b/Source/Client/Syncing/RwSerialization.cs @@ -106,7 +106,10 @@ public static void Init() }, (data, obj, _) => { - data.WriteUShort((ushort)ApiSerialization.syncSimples.FindIndex(obj!.GetType())); + int typeIndex = ApiSerialization.syncSimples.FindIndex(obj!.GetType()); + if (typeIndex == -1) + throw new SerializationException($"Unknown ISyncSimple type {obj.GetType()}"); + data.WriteUShort((ushort)typeIndex); foreach (var field in AccessTools.GetDeclaredFields(obj.GetType())) SyncSerialization.WriteSyncObject(data, field.GetValue(obj), field.FieldType); } @@ -136,7 +139,8 @@ internal static T GetAnyParent(Thing thing) where T : class typeof(Thing), typeof(ThingComp), typeof(WorldObject), - typeof(WorldObjectComp) + typeof(WorldObjectComp), + typeof(WorldPawns) // Not an IThingHolder, marks a world pawn looked up by id }; internal static string ThingHolderString(Thing thing) 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..822ea1bf3 100644 --- a/Source/Client/Syncing/SyncFieldUtil.cs +++ b/Source/Client/Syncing/SyncFieldUtil.cs @@ -1,5 +1,6 @@ using HarmonyLib; using Multiplayer.Common; +using RimWorld; using System; using System.Collections.Generic; using System.Linq; @@ -17,71 +18,147 @@ public static void StackPush(SyncField field, object target, object value, objec watchedStack.Push(new FieldData(field, target, value, index)); } + // True if an enclosing watch scope already watches this field of this target. That scope restores and syncs it, + // watching it again in a nested scope would revert the pending (buffered) value in the middle of the outer scope. + internal static bool IsWatched(SyncField field, object target, object index) + { + foreach (var entry in watchedStack) + if (entry is { } data && data.handler == field && Equals(data.target, target) && Equals(data.index, index)) + return true; + + return false; + } + public static void FieldWatchPrefix() { if (Multiplayer.Client == null) return; watchedStack.Push(null); // Marker } - // todo what happens on exceptions? public static void FieldWatchPostfix() { if (Multiplayer.Client == null) return; + PopWatchScope(); + } - // Iterate until we hit the marker (`null`). + // Harmony patches use a finalizer, so the simulation values get restored even if the watched method throws + static void FieldWatchPatchPrefix(out bool __state) + { + __state = Multiplayer.Client != null; + if (__state) + watchedStack.Push(null); // Marker + } + + static void FieldWatchPatchFinalizer(bool __state) + { + if (__state) + PopWatchScope(); + } + + /// Wraps the action in a watch scope which is closed even if the action throws. + internal static Action WatchScope(Action watchAction, Action action) => () => + { + bool pushed = Multiplayer.Client != null; + if (pushed) + watchedStack.Push(null); // Marker + + try + { + watchAction(); + action(); + } + finally + { + if (pushed) + PopWatchScope(); + } + }; + + private static void PopWatchScope() + { + // Iterate until we hit the marker (`null`). Always pop the whole scope, even if handling an entry fails. while (watchedStack.TryPop(out var dataOrNull) && dataOrNull is { } data) { - SyncField handler = data.handler; - object newValue = MpReflection.GetValue(data.target, handler.memberPath, data.index); - bool changed = !ValuesEqual(handler, newValue, data.oldValue); - var cache = - handler.bufferChanges && !Multiplayer.IsReplay && !Multiplayer.GhostMode - ? bufferedChanges.GetValueSafe(handler) - : null; - var bufferTarget = new BufferTarget(data.target, data.index); - var cachedData = cache?.GetValueSafe(bufferTarget); - - // Revert the local field's value for simulation purposes. - // For unbuffered fields, this rollback is only necessary if the value actually changed. - // For buffered fields, however, we always perform the restore, since the value is overwritten - // whenever watching it begins — assuming the value was previously changed and thus the buffer was - // initialized. - // If any change has happened, we record it and either send it immediately (unbuffered field) or queue - // it (buffered field). The server will eventually acknowledge the change and send it back, at which - // point the field is updated. - if (changed || cachedData != null) + try { - var simulationValue = cachedData?.actualValue ?? data.oldValue; - MpReflection.SetValue(data.target, handler.memberPath, simulationValue, data.index); + HandleWatchedField(data); } - - // No changes happened means no syncing needed either - we are done. - if (!changed) - continue; - - // For unbuffered fields, just immediately sync any changes. - if (cache == null) + catch (Exception e) { - handler.DoSyncCatch(data.target, newValue, data.index); - continue; + Log.Error($"Exception handling watched {data.handler}: {e}"); } + } + } - // For buffered fields, update the value to be sent. - if (cachedData != null) - { - cachedData.sent = false; - cachedData.lastChangedAtMillis = Utils.MillisNow; - cachedData.toSend = SnapshotValueIfNeeded(handler, newValue); - continue; - } + private static void HandleWatchedField(FieldData data) + { + SyncField handler = data.handler; + object newValue = MpReflection.GetValue(data.target, handler.memberPath, data.index); + bool changed = !ValuesEqual(handler, newValue, data.oldValue); + var cache = + handler.bufferChanges && !Multiplayer.IsReplay && !Multiplayer.GhostMode + ? bufferedChanges.GetValueSafe(handler) + : null; + var bufferTarget = new BufferTarget(data.target, data.index); + var cachedData = cache?.GetValueSafe(bufferTarget); + + // Revert the local field's value for simulation purposes. + // For unbuffered fields, this rollback is only necessary if the value actually changed. + // For buffered fields, however, we always perform the restore, since the value is overwritten + // whenever watching it begins — assuming the value was previously changed and thus the buffer was + // initialized. + // If any change has happened, we record it and either send it immediately (unbuffered field) or queue + // it (buffered field). The server will eventually acknowledge the change and send it back, at which + // point the field is updated. + if (changed || cachedData != null) + { + // The actual value can be null, so don't fall back to oldValue (the pending UI value) based on it + var simulationValue = cachedData != null ? cachedData.actualValue : data.oldValue; + MpReflection.SetValue(data.target, handler.memberPath, simulationValue, data.index); + } - // The field is buffered but had no prior changes; initialize the cache entry now that a change has occurred. - cache[bufferTarget] = new BufferData(handler, data.oldValue, SnapshotValueIfNeeded(handler, newValue)); + // No changes happened means no syncing needed either - we are done. + if (!changed) + return; + + // For unbuffered fields, just immediately sync any changes. + if (cache == null) + { + handler.DoSyncCatch(data.target, newValue, data.index); + return; } + + // For buffered fields, update the value to be sent. + if (cachedData != null) + { + cachedData.sent = false; + cachedData.lastChangedAtMillis = Utils.MillisNow; + cachedData.toSend = SnapshotValueIfNeeded(handler, newValue); + return; + } + + // The field is buffered but had no prior changes; initialize the cache entry now that a change has occurred. + cache[bufferTarget] = new BufferData(handler, data.oldValue, SnapshotValueIfNeeded(handler, newValue)); + } + + // Watch scopes never outlive the UI pass that opened them. Leftovers mean a scope wasn't closed (e.g. an + // exception between the API's WatchBegin and WatchEnd), restore and sync those fields before anything ticks. + [MpFinalizer(typeof(UIRoot_Play), nameof(UIRoot_Play.UIRootOnGUI))] + [MpFinalizer(typeof(UIRoot_Play), nameof(UIRoot_Play.UIRootUpdate))] + static void FlushLeakedWatchScopes() + { + if (watchedStack.Count == 0 || Multiplayer.Client == null) + return; + + Log.WarningOnce($"Multiplayer: {watchedStack.Count} field watch stack entries weren't closed, restoring them", 0x5e1d3a7); + while (watchedStack.Count > 0) + PopWatchScope(); } public static void UpdateSync() { + FlushLeakedWatchScopes(); + foreach (var (field, fieldBufferedChanges) in bufferedChanges) { if (field.inGameLoop) continue; @@ -150,10 +227,10 @@ private static bool ValuesEqual(SyncField field, object newValue, object oldValu internal static void ApplyWatchFieldPatches(Type type) { - HarmonyMethod prefix = new HarmonyMethod(AccessTools.Method(typeof(SyncFieldUtil), nameof(FieldWatchPrefix))); + HarmonyMethod prefix = new HarmonyMethod(AccessTools.Method(typeof(SyncFieldUtil), nameof(FieldWatchPatchPrefix))); prefix.priority = MpPriority.MpFirst; - HarmonyMethod postfix = new HarmonyMethod(AccessTools.Method(typeof(SyncFieldUtil), nameof(FieldWatchPostfix))); - postfix.priority = MpPriority.MpLast; + HarmonyMethod finalizer = new HarmonyMethod(AccessTools.Method(typeof(SyncFieldUtil), nameof(FieldWatchPatchFinalizer))); + finalizer.priority = MpPriority.MpLast; foreach (var toPatch in type.GetDeclaredMethods()) { @@ -161,10 +238,9 @@ internal static void ApplyWatchFieldPatches(Type type) { try { - Multiplayer.harmony.PatchMeasure(attr.Method, prefix, postfix); + Multiplayer.harmony.PatchMeasure(attr.Method, prefix, finalizer: finalizer); } 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}"); } } } @@ -174,6 +250,9 @@ public static void ClearAllBufferedChanges() { foreach (var entry in bufferedChanges) entry.Value.Clear(); + + // Called when multiplayer stops: entries left from the old session mustn't be restored in the next one + watchedStack.Clear(); } } diff --git a/Source/Client/Syncing/SyncTemplates.cs b/Source/Client/Syncing/SyncTemplates.cs index 88a379f93..3cf2d9771 100644 --- a/Source/Client/Syncing/SyncTemplates.cs +++ b/Source/Client/Syncing/SyncTemplates.cs @@ -39,18 +39,12 @@ static IEnumerable Transpiler(MethodBase original, IEnumerable< if (pInfo.IsOut) { pType = pType.GetElementType(); yield return new CodeInstruction(OpCodes.Ldarg, argIndex); - yield return CreateDefaultCodes(gen, pType).Last(); - if (AccessTools.IsClass(pType)) + if (AccessTools.IsClass(pType)) { + yield return new CodeInstruction(OpCodes.Ldnull); yield return new CodeInstruction(OpCodes.Stind_Ref); - if (AccessTools.IsValue(pType)) { - if (pType == typeof(float)) - yield return new CodeInstruction(OpCodes.Stind_R4, (float) 0); - else if (pType == typeof(double)) - yield return new CodeInstruction(OpCodes.Stind_R8, (double) 0); - else if (pType == typeof(long)) - yield return new CodeInstruction(OpCodes.Stind_I8, (long) 0); - else - yield return new CodeInstruction(OpCodes.Stind_I4, 0); + } else { + // Zeroes the value type at the address with its real size (unlike stind.i4 for e.g. bool) + yield return new CodeInstruction(OpCodes.Initobj, pType); } } } @@ -92,31 +86,25 @@ static IEnumerable Transpiler(MethodBase original, IEnumerable< yield return instruction; } + // Pushes the default value of the type (nothing for void) static IEnumerable CreateDefaultCodes(ILGenerator generator, Type type) { if (type.IsByRef) type = type.GetElementType(); + if (type == typeof(void)) + yield break; + if (AccessTools.IsClass(type)) { yield return new CodeInstruction(OpCodes.Ldnull); yield break; } - if (AccessTools.IsStruct(type)) { - var v = generator.DeclareLocal(type); - yield return new CodeInstruction(OpCodes.Ldloca, v); - yield return new CodeInstruction(OpCodes.Initobj, type); - yield break; - } - if (AccessTools.IsValue(type)) { - if (type == typeof(float)) - yield return new CodeInstruction(OpCodes.Ldc_R4, (float) 0); - else if (type == typeof(double)) - yield return new CodeInstruction(OpCodes.Ldc_R8, (double) 0); - else if (type == typeof(long)) - yield return new CodeInstruction(OpCodes.Ldc_I8, (long) 0); - else - yield return new CodeInstruction(OpCodes.Ldc_I4, 0); - } + + // Any value type (struct, enum, primitive): zero a local and load it + var v = generator.DeclareLocal(type); + yield return new CodeInstruction(OpCodes.Ldloca, v); + yield return new CodeInstruction(OpCodes.Initobj, type); + yield return new CodeInstruction(OpCodes.Ldloc, v); } } diff --git a/Source/Client/Syncing/SyncUtil.cs b/Source/Client/Syncing/SyncUtil.cs index 3740b0156..91e7908b0 100644 --- a/Source/Client/Syncing/SyncUtil.cs +++ b/Source/Client/Syncing/SyncUtil.cs @@ -49,47 +49,67 @@ public static SyncHandler HandleCmd(ByteReader data) throw; } - prevSelected = Find.Selector.selected; - prevWorldSelected = Find.WorldSelector.selected; - - bool shouldQueue = false; - - if (handler.context != SyncContext.None) + var worldSelector = Find.WorldSelector; + var restoreSelected = Find.Selector.selected; + var restoreWorldSelected = worldSelector.selected; + var restoreSelectedTile = worldSelector.selectedTile; + var restoreSelectedLayer = worldSelector.selectedLayer; + bool restoreWorldTile = false; + + // The local player's selection from before the command (map commands already replaced Find.Selector's) + prevSelected = AsyncTimeComp.prevSelected ?? restoreSelected; + prevWorldSelected = restoreWorldSelected; + + // Reading the context is inside the try, so a failure there can't leave the context applied either + try { - if (handler.context.HasFlag(SyncContext.MapMouseCell)) - { - IntVec3 mouseCell = SyncSerialization.ReadSync(data); - MouseCellPatch.result = mouseCell; - } + bool shouldQueue = false; - if (handler.context.HasFlag(SyncContext.MapSelected)) + if (handler.context != SyncContext.None) { - List selected = SyncSerialization.ReadSync>(data); - Find.Selector.selected = selected.Cast().AllNotNull().ToList(); + if (handler.context.HasFlag(SyncContext.MapMouseCell)) + { + IntVec3 mouseCell = SyncSerialization.ReadSync(data); + MouseCellPatch.result = mouseCell; + } + + if (handler.context.HasFlag(SyncContext.MapSelected)) + { + List selected = SyncSerialization.ReadSync>(data); + Find.Selector.selected = selected.Cast().AllNotNull().ToList(); + } + + if (handler.context.HasFlag(SyncContext.WorldSelected)) + { + List selected = SyncSerialization.ReadSync>(data); + FieldRefs.worldSelected(worldSelector) = selected.Cast().AllNotNull().ToList(); + + // Some world gizmos only read the selected tile (e.g. the dev "Generate settlement") + restoreWorldTile = true; + worldSelector.selectedTile = SyncSerialization.ReadSync(data); + worldSelector.selectedLayer = SyncSerialization.ReadSync(data); + } + + if (handler.context.HasFlag(SyncContext.QueueOrder_Down)) + shouldQueue = data.ReadBool(); } - if (handler.context.HasFlag(SyncContext.WorldSelected)) - { - List selected = SyncSerialization.ReadSync>(data); - FieldRefs.worldSelected(Find.WorldSelector) = selected.Cast().AllNotNull().ToList(); - } - - if (handler.context.HasFlag(SyncContext.QueueOrder_Down)) - shouldQueue = data.ReadBool(); - } + KeyIsDownPatch.shouldQueue = shouldQueue; - KeyIsDownPatch.shouldQueue = shouldQueue; - - try - { handler.Handle(data); } finally { MouseCellPatch.result = null; KeyIsDownPatch.shouldQueue = null; - Find.Selector.selected = prevSelected; - FieldRefs.worldSelected(Find.WorldSelector) = prevWorldSelected; + Find.Selector.selected = restoreSelected; + FieldRefs.worldSelected(worldSelector) = restoreWorldSelected; + + if (restoreWorldTile) + { + worldSelector.selectedTile = restoreSelectedTile; + worldSelector.selectedLayer = restoreSelectedLayer; + } } return handler; @@ -112,7 +132,11 @@ public static void WriteContext(SyncHandler handler, ByteWriter data) SyncSerialization.WriteSync(data, Find.Selector.selected.Cast().ToList()); if (handler.context.HasFlag(SyncContext.WorldSelected)) + { SyncSerialization.WriteSync(data, Find.WorldSelector.selected.Cast().ToList()); + SyncSerialization.WriteSync(data, Find.WorldSelector.selectedTile); + SyncSerialization.WriteSync(data, Find.WorldSelector.selectedLayer); + } if (handler.context.HasFlag(SyncContext.QueueOrder_Down)) data.WriteBool(KeyBindingDefOf.QueueOrder.IsDownEvent); diff --git a/Source/Client/UI/MainMenuPatches.cs b/Source/Client/UI/MainMenuPatches.cs index 246b66e80..18b3366cf 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,45 @@ public static class MainMenu_AddHeight [HarmonyPatch(typeof(OptionListingUtility), nameof(OptionListingUtility.DrawOptionListing))] public static class MainMenuPatch { + private static bool loadingErrorsConfirmed; + + /// Runs the action after the player confirms playing with loading errors, once per game start + public static void ConfirmLoadingErrors(Action action) + { + if (!Multiplayer.loadingErrors || loadingErrorsConfirmed) + { + action(); + return; + } + + static string FirstLine(string error) + { + var line = error.Split('\n')[0].Trim(); + return line.Length > 150 ? line[..150] + "..." : line; + } + + string errors; + int count; + lock (Multiplayer.loadingErrorMessages) + { + errors = Multiplayer.loadingErrorMessages.Select(e => "- " + FirstLine(e)).Join(delimiter: "\n"); + count = Multiplayer.loadingErrorCount; + if (count > Multiplayer.loadingErrorMessages.Count) + errors += $"\n... and {count - Multiplayer.loadingErrorMessages.Count} more"; + } + + // Not translated: the strings live in the separate locale repository + var text = $"Multiplayer had {count} error(s) while loading its patches and " + + "sync handlers. Playing like this is likely to desync or break features.\n\n" + + $"{errors}\n\nThe log has the full errors. Continue anyway?"; + + Find.WindowStack.Add(Dialog_MessageBox.CreateConfirmation(text, () => + { + loadingErrorsConfirmed = true; + action(); + }, true, layer: WindowLayer.Super)); + } + static void Prefix(Rect rect, List optList) { if (!MainMenuMarker.drawing) return; @@ -55,8 +95,11 @@ static void Prefix(Rect rect, List optList) GUIUtility.systemCopyBuffer = version; else { - Find.WindowStack.Add(new ServerBrowser()); - VersionChecker.OpenNewVersionDialogIfApplicable(); + ConfirmLoadingErrors(() => + { + Find.WindowStack.Add(new ServerBrowser()); + VersionChecker.OpenNewVersionDialogIfApplicable(); + }); } })); } @@ -67,13 +110,13 @@ 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 }) + () => ConfirmLoadingErrors(() => Find.WindowStack.Add(new HostWindow() { layer = WindowLayer.Super })) )); if (MpVersion.IsDebug && Multiplayer.IsReplay) optList.Insert(0, new ListableOption( "MpHostServer".Translate(), - () => Find.WindowStack.Add(new HostWindow() { layer = WindowLayer.Super }) + () => ConfirmLoadingErrors(() => Find.WindowStack.Add(new HostWindow() { layer = WindowLayer.Super })) )); if (Multiplayer.Client != null) diff --git a/Source/Client/UI/PlayerCursors.cs b/Source/Client/UI/PlayerCursors.cs index d8c8bc990..a22314421 100644 --- a/Source/Client/UI/PlayerCursors.cs +++ b/Source/Client/UI/PlayerCursors.cs @@ -84,7 +84,8 @@ private void SendSelected() lastSelected = selected; - Multiplayer.Client.Send(new ClientSelectedPacket - { reset = reset, newlySelectedIds = add, unselectedIds = remove }); + // Selections over the packet's limit used to throw here (#377) + foreach (var packet in ClientSelectedPacket.Split(reset, add, remove)) + Multiplayer.Client.Send(packet); } } diff --git a/Source/Client/Util/Extensions.cs b/Source/Client/Util/Extensions.cs index 1be34d424..4b3436355 100644 --- a/Source/Client/Util/Extensions.cs +++ b/Source/Client/Util/Extensions.cs @@ -75,8 +75,19 @@ public static Map GetById(this List maps, int mapId) return null; } - public static void SendCommand(this ConnectionBase conn, CommandType type, int mapId, byte[] data) => - conn.Send(new ClientCommandPacket(type, mapId, data)); + public static void SendCommand(this ConnectionBase conn, CommandType type, int mapId, byte[] data) + { + // The server would drop it: tell the player instead of throwing in the middle of their action + if (data.Length > ServerCommandPacket.MaxDataLength) + { + Log.Error($"Multiplayer: {type} command too big to send ({data.Length} > {ServerCommandPacket.MaxDataLength} bytes)"); + Messages.Message("Multiplayer: this action is too big to send to the server.", + MessageTypeDefOf.RejectInput, historical: false); + return; + } + + conn.SendFragmentedIfNeeded(new ClientCommandPacket(type, mapId, data).Serialize()); + } public static void SendCommand(this ConnectionBase conn, CommandType type, int mapId, params object[] data) => SendCommand(conn, type, mapId, ByteWriter.GetBytes(data)); @@ -225,6 +236,15 @@ public static int CRC32(this FileInfo file) return new CRC32().GetCrc32(stream); } + // For text files whose line endings depend on the OS they were saved or checked out on (#386) + public static int CRC32IgnoringLineEndings(this FileInfo file) + { + var data = File.ReadAllBytes(file.FullName); + var crc = new CRC32(); + crc.SlurpBlock(data, 0, Utils.NormalizeLineEndingsInPlace(data)); + return crc.Crc32Result; + } + public static int AggregateHash(this IEnumerable e) { return e.Aggregate(0, (a, b) => Gen.HashCombineInt(a, b)); diff --git a/Source/Client/Util/MpPatch.cs b/Source/Client/Util/MpPatch.cs index cce8bdea6..7316c5b12 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,33 @@ public MpPostfix(Type parentType, string parentMethod, int lambdaOrdinal) : base } } + /// + /// Finalizer method attribute + /// + [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 type, MethodType methodType, Type[] argTypes = null) : base(type, methodType, argTypes) + { + } + + public MpFinalizer(Type parentType, string parentMethod, int lambdaOrdinal) : base(parentType, parentMethod, lambdaOrdinal) + { + } + } + /// /// Transpiler method attribute /// diff --git a/Source/Client/Util/MpScope.cs b/Source/Client/Util/MpScope.cs index 624fe1798..70d39c85b 100644 --- a/Source/Client/Util/MpScope.cs +++ b/Source/Client/Util/MpScope.cs @@ -1,4 +1,5 @@ using System; +using Multiplayer.Client.Factions; using RimWorld; namespace Multiplayer.Client.Util; @@ -18,8 +19,9 @@ public static MpScope Create(Action begin, Action end) return new MpScope { end = end }; } + // Applies the faction to Faction.OfPlayer, the world data and every map (like a tick does), not only Faction.OfPlayer public static MpScope PushFaction(Faction f) { - return Create(() => FactionContext.Push(f), () => FactionContext.Pop()); + return Create(() => FactionExtensions.PushFaction(null, f, force: true), () => FactionExtensions.PopFaction()); } } diff --git a/Source/Client/Windows/ConnectingWindow.cs b/Source/Client/Windows/ConnectingWindow.cs index 1e05e0e31..8f892efbd 100644 --- a/Source/Client/Windows/ConnectingWindow.cs +++ b/Source/Client/Windows/ConnectingWindow.cs @@ -54,9 +54,12 @@ 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) + // Computed from the clock on every read, so read once: the value checked could otherwise be 0 + // when divided by. It's -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 +67,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 ({Mathf.Max(downloadSpeedKBps, 0)} KB/s)"; break; default: label = result ?? (ConnectingString + MpUI.FixedEllipsis()); diff --git a/Source/Client/Windows/DisconnectedWindow.cs b/Source/Client/Windows/DisconnectedWindow.cs index b7d1b4b5b..083299557 100644 --- a/Source/Client/Windows/DisconnectedWindow.cs +++ b/Source/Client/Windows/DisconnectedWindow.cs @@ -80,8 +80,10 @@ private void DrawButtons(Rect inRect) if (Widgets.ButtonText(connectAsBtn, info.specialButtonTranslated)) { - returnToServerBrowser = false; info.specialButtonAction(); + if (info.specialButtonKeepsWindow) return; + + returnToServerBrowser = false; Close(); } } diff --git a/Source/Client/Windows/HostWindow.cs b/Source/Client/Windows/HostWindow.cs index 1abe4e6ff..ec99d6d20 100644 --- a/Source/Client/Windows/HostWindow.cs +++ b/Source/Client/Windows/HostWindow.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using Multiplayer.Client.Networking; +using Multiplayer.Client.Persistent; using Multiplayer.Client.Util; using Multiplayer.Common; using Multiplayer.Common.Util; @@ -109,6 +110,10 @@ public override void DoWindowContents(Rect inRect) DoTabButton(entry.Down(50f).Width(140).Height(40f), Tab.Gameplay); } + // Below the tab buttons, the column is empty there + if (PrepatcherWarning.Show) + PrepatcherWarning.DrawLabel(entry.Down(100f).Width(140).Height(50f), GameFont.Tiny); + if (tab == Tab.Connecting) DoConnecting(entry.MinX(entry.xMin + 150)); else diff --git a/Source/Client/Windows/JoinDataWindow.cs b/Source/Client/Windows/JoinDataWindow.cs index fb891cc22..9ebdc1de3 100644 --- a/Source/Client/Windows/JoinDataWindow.cs +++ b/Source/Client/Windows/JoinDataWindow.cs @@ -157,14 +157,14 @@ private void RefreshConfigNodes() void AddConfigs(List one, List two, NodeStatus notInTwo) { - var oneDict = one.ToDictionaryPermissive(m => m.ModId + "/" + m.FileName, m => m.Contents); - var twoDict = two.ToDictionaryPermissive(m => m.ModId + "/" + m.FileName, m => m.Contents); + var oneDict = one.ToDictionaryPermissive(m => m.ModId + "/" + m.FileName, m => m.ComparableContents); + var twoDict = two.ToDictionaryPermissive(m => m.ModId + "/" + m.FileName, m => m.ComparableContents); foreach (var kv in oneDict){ AddNodesForPath( kv.Key, configsRoot, - twoDict.TryGetValue(kv.Key, out var t) ? (t.Equals(kv.Value) ? NodeStatus.None : NodeStatus.Modified) : notInTwo + twoDict.TryGetValue(kv.Key, out var t) ? (t == kv.Value ? NodeStatus.None : NodeStatus.Modified) : notInTwo ); } } @@ -250,6 +250,9 @@ void RefreshFiles() if (connectAnyway) { Log.Message($"Multiplayer: Connecting anyway ({DiffString()})"); + // For desync reports: whether everyone ran the same build is the first question + if (Multiplayer.session != null) + Multiplayer.session.joinDataDiff = DiffString(); connectAnywayCallback(); Close(false); } @@ -269,7 +272,9 @@ void RefreshFiles() private string DiffString() { var str = ""; - str += $"RW version match: {remote.remoteMpVersion == MpVersion.Version}, "; + str += $"RW version match: {remote.remoteRwVersion == VersionControl.CurrentVersionString}, "; + str += $"MP version match: {remote.remoteMpVersion == MpVersion.Version}, "; + str += $"Language match: {JoinData.LanguageMatches(remote.remoteLanguage)}, "; str += $"Mod list diff: {modListDiff}, "; str += $"Files match: {!filesRoot.children.Any()}, "; str += $"Config sync enabled: {remote.hasConfigs}, "; @@ -352,7 +357,17 @@ 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), JoinData.LocalLanguage); + + bool languageCheck = JoinData.LanguageMatches(remote.remoteLanguage); + 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/Client/Windows/SaveGameWindow.cs b/Source/Client/Windows/SaveGameWindow.cs index b2eae89a4..9399c71ac 100644 --- a/Source/Client/Windows/SaveGameWindow.cs +++ b/Source/Client/Windows/SaveGameWindow.cs @@ -200,12 +200,13 @@ private void Accept(bool currentReplay) { if (curText.Length != 0) { + var tick = TickPatch.Timer; LongEventHandler.QueueLongEvent(() => { if (!Autosaving.SaveGameToFile_Overwrite(curText, currentReplay)) return; - Autosaving.SendAutosavingRequest(JoinPointRequestReason.Save); + Autosaving.SendAutosavingRequest(JoinPointRequestReason.Save, tick); }, "MpSaving", false, null); Close(); } diff --git a/Source/Client/Windows/ServerBrowser.cs b/Source/Client/Windows/ServerBrowser.cs index 81267de58..18a668a3f 100644 --- a/Source/Client/Windows/ServerBrowser.cs +++ b/Source/Client/Windows/ServerBrowser.cs @@ -9,6 +9,7 @@ using HarmonyLib; using LiteNetLib; using LudeonTK; +using Multiplayer.Client.Persistent; using Multiplayer.Client.Util; using Multiplayer.Common; using Multiplayer.Common.Util; @@ -135,6 +136,16 @@ bool Button(Texture2D icon, string labelKey, string tip, Color baseIconColor, fl Application.OpenURL(Multiplayer.DiscordLink); x += 10; + + // Before the note, which then may not fit anymore + if (PrepatcherWarning.Show) + { + var warningWidth = PrepatcherWarning.LabelWidth(GameFont.Small); + using (MpStyle.Set(TextAnchor.MiddleLeft)) + PrepatcherWarning.DrawLabel(new Rect(x, 0, warningWidth, 24f)); + x += warningWidth + 15; + } + Widgets.Label(new Rect(x, 0, 400, 24), "Note: Multiplayer for 1.6 is a work in progress."); const string v15Notice = diff --git a/Source/Common/ByteReader.cs b/Source/Common/ByteReader.cs index 4e87dbe43..b400ef86c 100644 --- a/Source/Common/ByteReader.cs +++ b/Source/Common/ByteReader.cs @@ -76,6 +76,9 @@ public virtual string ReadString(int maxLen = DefaultMaxStringLen) public virtual byte[] ReadRaw(int len) { + // Validate before allocating: the length usually comes from the remote side + if (len < 0 || len > Left) + throw new ReaderException($"Byte array length ({len}) out of range (0-{Left})"); return array.SubArray(IncrementIndex(len), len); } @@ -100,6 +103,7 @@ public virtual int[] ReadPrefixedInts(int maxLen = int.MaxValue) throw new ReaderException($"Int array length ({len}<0)"); if (len > maxLen) throw new ReaderException($"Int array too long ({len}>{maxLen})"); + CheckArrayLength(len, sizeof(int)); int[] result = new int[len]; for (int i = 0; i < len; i++) @@ -111,6 +115,7 @@ public virtual int[] ReadPrefixedInts(int maxLen = int.MaxValue) public virtual uint[] ReadPrefixedUInts() { int len = ReadInt32(); + CheckArrayLength(len, sizeof(uint)); uint[] result = new uint[len]; for (int i = 0; i < len; i++) result[i] = ReadUInt32(); @@ -120,6 +125,7 @@ public virtual uint[] ReadPrefixedUInts() public virtual ulong[] ReadPrefixedULongs() { int len = ReadInt32(); + CheckArrayLength(len, sizeof(ulong)); ulong[] result = new ulong[len]; for (int i = 0; i < len; i++) { result[i] = ReadULong(); @@ -130,12 +136,20 @@ public virtual ulong[] ReadPrefixedULongs() public virtual string?[] ReadPrefixedStrings() { int len = ReadInt32(); + CheckArrayLength(len, sizeof(int)); // Every string has at least its length prefix string?[] result = new string[len]; for (int i = 0; i < len; i++) result[i] = ReadString(); return result; } + // Validate a remote array length before allocating the array + private void CheckArrayLength(int len, int minElementSize) + { + if (len < 0 || len > Left / minElementSize) + throw new ReaderException($"Array length ({len}) out of range for {Left} remaining bytes"); + } + public virtual T ReadEnum() where T : Enum { Type type = Enum.GetUnderlyingType(typeof(T)); diff --git a/Source/Common/CommandHandler.cs b/Source/Common/CommandHandler.cs index ef7b43b77..78a5eff83 100644 --- a/Source/Common/CommandHandler.cs +++ b/Source/Common/CommandHandler.cs @@ -17,6 +17,8 @@ public CommandHandler(MultiplayerServer server) public void Send(CommandType cmdType, int factionId, int mapId, byte[] data, ServerPlayer? sourcePlayer = null, ServerPlayer? fauxSource = null) { + server.CheckServerThread(); + // policy if (sourcePlayer != null) { @@ -37,6 +39,13 @@ public void Send(CommandType cmdType, int factionId, int mapId, byte[] data, Ser return; } + // Checked before logging: a command in the log that live clients never received desyncs every joiner + if (data.Length > ServerCommandPacket.MaxDataLength) + { + ServerLog.Error($"Dropped {cmdType} command with too much data ({data.Length} > {ServerCommandPacket.MaxDataLength})"); + return; + } + var cmd = new ScheduledCommand( cmdType, server.gameTimer, @@ -46,22 +55,17 @@ public void Send(CommandType cmdType, int factionId, int mapId, byte[] data, Ser data); byte[] toSave = ScheduledCommand.Serialize(cmd); - // todo cull target players if not global - server.worldData.mapCmds.GetOrAddNew(mapId).Add(toSave); - server.worldData.tmpMapCmds?.GetOrAddNew(mapId).Add(toSave); + // The log and the live broadcast must stay identical: joiners replay the log in this exact order + server.worldData.cmds.Add(toSave); + server.worldData.tmpCmds?.Add(toSave); - if (server.CanUseStandaloneMapStreaming(mapId)) - { - var serialized = ServerCommandPacket.From(cmd).Serialize(); - foreach (var player in server.PlayingPlayers) - { - if (!player.hasReportedCurrentMap || player.currentMapId < 0 || player.currentMapId == mapId) - player.conn.Send(serialized, true); - } - } - else + var serialized = ServerCommandPacket.From(cmd).Serialize(); + var mapStreaming = server.CanUseStandaloneMapStreaming(mapId); + foreach (var player in server.PlayingPlayers) { - server.SendToPlaying(ServerCommandPacket.From(cmd)); + if (!mapStreaming || !player.hasReportedCurrentMap || player.currentMapId < 0 || + player.currentMapId == mapId) + player.conn.SendFragmentedIfNeeded(serialized); } SentCmds++; diff --git a/Source/Common/CommandType.cs b/Source/Common/CommandType.cs index 0239db070..4e3c29f72 100644 --- a/Source/Common/CommandType.cs +++ b/Source/Common/CommandType.cs @@ -17,4 +17,7 @@ public enum CommandType : byte MapTimeSpeed, Designator, PlayerCount, + + // Global scope, server only: viewer counts saved with the game belong to players of an earlier session + ResetPlayerCounts, } diff --git a/Source/Common/DeferredStackTracingImpl.cs b/Source/Common/DeferredStackTracingImpl.cs index 92c3a71a0..25949fc54 100644 --- a/Source/Common/DeferredStackTracingImpl.cs +++ b/Source/Common/DeferredStackTracingImpl.cs @@ -102,6 +102,10 @@ public static class DeferredStackTracingImpl { const long NotJit = long.MaxValue; const long RbpBased = long.MaxValue - 1; + const long UnknownFrame = long.MaxValue - 2; + + // Upper bound on loop iterations in case the walk gets stuck (e.g. on a cyclic LMF chain) + const int MaxWalkSteps = 256; const long UsesRbpAsGpr = 1L << 50; const long UsesRbx = 1L << 51; @@ -111,6 +115,11 @@ public static class DeferredStackTracingImpl public static AddrTable hashTable = new(); + // Info about the last failed stack walk, for logging + public static string? lastFailureReason; + public static long lastFailureAddr; + + // Returns the number of frames written to traceIn or -1 if the stack couldn't be walked public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames = 0) { if (Native.LmfPtr == 0) @@ -123,9 +132,9 @@ public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames long lmfPtr = *(long*)Native.LmfPtr; - int depth = 0; // frames seen + int depth = 0; // frames seen, only counting frames which contribute to the hash int index = 0; // frames returned through long[] traceIn - while (true) + for (int step = 0; step < MaxWalkSteps; step++) { var ret = *(long*)(stck + 8); ref var info = ref hashTable.GetOrCreateAddrInfo(ret); @@ -135,38 +144,59 @@ public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames if (stackUsage == NotJit) { // LMF (Last Managed Frame) layout on x64: - // previous + // previous (the lowest 3 bits are flags) // rbp // rsp - lmfPtr = *(long*)lmfPtr; - var lmfRbp = *(long*)(lmfPtr + 8); + // The first step reads the innermost LMF from lmf_addr, later ones follow previous_lmf + lmfPtr = *(long*)lmfPtr & ~7L; + if (lmfPtr == 0) + break; - if (lmfPtr == 0 || lmfRbp == 0) + // MonoLMFExt (bit 2) and MonoLMFTramp (bit 4) don't store a usable rbp/rsp + if ((*(long*)lmfPtr & 6) != 0) break; + var lmfRbp = *(long*)(lmfPtr + 8); + var lmfRsp = *(long*)(lmfPtr + 16); + if (lmfRbp == 0 || lmfRsp == 0) + break; + + // The managed frame which called into native code is above the native frames + if (lmfRsp - 16 <= stck) + return Fail(ret, "Bad LMF rsp"); + rbp = lmfRbp; - stck = *(long*)(lmfPtr + 16) - 16; + stck = lmfRsp - 16; continue; } - if (depth >= skipFrames) - { - traceIn[index] = ret; + if (stackUsage == UnknownFrame) + return Fail(ret, null); - // info.nameHash == 0 marks methods to skip - if (index < HashInfluence && info.nameHash != 0) hash = HashCombineInt(hash, (int)info.nameHash); + // info.nameHash == 0 marks methods to skip. They might get inlined depending on the platform so they + // don't count towards the hash, the depth or the recorded frames at all. + if (info.nameHash != 0) + { + if (depth >= skipFrames) + { + traceIn[index] = ret; + if (index < HashInfluence) hash = HashCombineInt(hash, (int)info.nameHash); + index++; + } - index++; + // traceIn length limits above all how many frames are visited, not how many are populated. + if (++depth == traceIn.Length) + break; } - // traceIn length limits above all how many frames are visited, not how many are populated. - if (info.nameHash != 0 && ++depth == traceIn.Length) - break; - if (stackUsage == RbpBased) { + // The stack grows down so the caller's frame has to be above the current one + if (rbp <= stck) + return Fail(ret, "Bad rbp"); + stck = rbp; rbp = *(long*)rbp; continue; @@ -190,18 +220,35 @@ public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames return index; } + private static int Fail(long addr, string? reason) + { + lastFailureAddr = addr; + if (reason != null) lastFailureReason = reason; + return -1; + } private static void UpdateNewElement(ref AddrInfo info, long ret) { info.addr = ret; - info.stackUsage = GetStackUsage(ret); - var normalizedMethodNameBetweenOS = Native.MethodNameNormalizedFromAddr(ret, true); + try + { + info.stackUsage = GetStackUsage(ret); + + var normalizedMethodNameBetweenOS = Native.MethodNameNormalizedFromAddr(ret, true); - info.nameHash = - normalizedMethodNameBetweenOS == null ? 1 : - Native.GetMethodAggressiveInlining(ret) ? 0 : - StableStringHash(normalizedMethodNameBetweenOS); + info.nameHash = + normalizedMethodNameBetweenOS == null ? 1 : + Native.GetMethodAggressiveInlining(ret) ? 0 : + StableStringHash(normalizedMethodNameBetweenOS); + } + catch (Exception e) + { + // Cache the failure: following a frame of unknown size would read garbage + info.stackUsage = UnknownFrame; + info.nameHash = 1; + lastFailureReason = e.Message; + } } private static unsafe long GetStackUsage(long addr) diff --git a/Source/Common/MultiplayerServer.cs b/Source/Common/MultiplayerServer.cs index 7d3940f8b..22cd0b2c2 100644 --- a/Source/Common/MultiplayerServer.cs +++ b/Source/Common/MultiplayerServer.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -77,6 +78,10 @@ static MultiplayerServer() public bool IsStandaloneServer { get; set; } public StandalonePersistence? persistence; + private Thread? serverThread; + private int offThreadCalls; + public int OffThreadCalls => offThreadCalls; + public MultiplayerServer(ServerSettings settings) { this.settings = settings; @@ -94,6 +99,9 @@ public MultiplayerServer(ServerSettings settings) public void Run() { + serverThread = Thread.CurrentThread; + // Pairs with TryStop: either it sees this thread and joins it, or this sees running cleared + Thread.MemoryBarrier(); ServerLog.Detail("Server started"); Stopwatch time = Stopwatch.StartNew(); @@ -115,6 +123,7 @@ public void Run() queue.RunQueue(ServerLog.Error); netManagers.ForEach(manager => manager.Tick()); TickNet(); + worldData.Tick(); int ticked = 0; while (realTime > 0 && ticked < 2) @@ -177,7 +186,10 @@ private void TickNet() NetTimer++; if (NetTimer % NetTicksPerSecond == 0) + { playerManager.SendLatencies(); + playerManager.CheckTimeouts(); + } if (NetTimer % (NetTicksPerSecond / 5) == 0) @@ -193,21 +205,39 @@ private void TickNet() serverTimePerTick = PlayingIngamePlayers.MaxOrZero(p => p.frameTime); - if (serverTimePerTick < StandardTimePerTick) + // Written to also catch NaN + if (!(serverTimePerTick >= StandardTimePerTick)) serverTimePerTick = StandardTimePerTick; if (serverTimePerTick > StandardTimePerTick * 4f) serverTimePerTick = StandardTimePerTick * 4f; } + public static readonly TimeSpan StopTimeout = TimeSpan.FromSeconds(5); + private int stopped; + + /// Stops the server once, later calls do nothing. Server state isn't thread safe, so called from another + /// thread, this waits for the server thread to finish its iteration and stop the server on its way out + /// (Run). Only if that takes too long is the server stopped from the calling thread. public void TryStop() { + running = false; + Thread.MemoryBarrier(); + + var thread = serverThread; + if (thread != null && thread != Thread.CurrentThread && !thread.Join(StopTimeout)) + ServerLog.Error($"The server thread didn't stop within {StopTimeout.TotalSeconds}s, " + + $"stopping the server from thread {Thread.CurrentThread.ManagedThreadId}"); + + if (Interlocked.Exchange(ref stopped, 1) != 0) return; + ServerLog.Detail("Server shutting down..."); playerManager.OnServerStop(); netManagers.ForEach(manager => manager.Stop()); - instance = null; + // A server stopping late mustn't clear the instance of the next one + Interlocked.CompareExchange(ref instance, null, this); } public void Enqueue(Action action) @@ -215,6 +245,19 @@ public void Enqueue(Action action) queue.Enqueue(action); } + /// Server state isn't thread-safe and the server thread defines the order of commands, so mutations from + /// other threads can race the tick loop. Use Enqueue to get onto the server thread. + public void CheckServerThread([CallerMemberName] string caller = "") + { + var thread = serverThread; + if (thread == null || thread == Thread.CurrentThread || !running) return; + + // Limit the log spam, one stack trace is enough to find the culprit + if (Interlocked.Increment(ref offThreadCalls) <= 10) + ServerLog.Error($"{caller} called outside the server thread " + + $"(thread {Thread.CurrentThread.ManagedThreadId}): {Environment.StackTrace}"); + } + public void SendToPlaying(T packet, bool reliable = true, ServerPlayer? excluding = null) where T : IPacket { var serialized = packet.Serialize(); @@ -241,7 +284,9 @@ public void SendMapResponse(ServerPlayer player, int mapId) ByteWriter writer = new ByteWriter(); writer.WriteInt32(mapId); - var mapCmds = worldData.mapCmds.GetValueSafe(mapId) ?? []; + var mapCmds = worldData.cmds + .Where(cmd => ScheduledCommand.Deserialize(new ByteReader(cmd)).mapId == mapId) + .ToList(); writer.WriteInt32(mapCmds.Count); foreach (var cmd in mapCmds) writer.WritePrefixedBytes(cmd); diff --git a/Source/Common/Networking/AsyncConnectionState.cs b/Source/Common/Networking/AsyncConnectionState.cs index a1ca6a719..6ece793e9 100644 --- a/Source/Common/Networking/AsyncConnectionState.cs +++ b/Source/Common/Networking/AsyncConnectionState.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; using System.Threading.Tasks; using Multiplayer.Common.Networking.Packet; using Multiplayer.Common.Util; @@ -9,10 +11,20 @@ namespace Multiplayer.Common; public abstract class AsyncConnectionState(ConnectionBase connection) : MpConnectionState(connection) { + private static readonly Stopwatch Clock = Stopwatch.StartNew(); + private PacketAwaitable? packetAwaitable; + private long waitDeadlineMs = long.MaxValue; public Task? CurrentTask { get; private set; } + /// The packet this state is waiting for, if any. + public Packets? AwaitedPacket => packetAwaitable?.PacketType; + + /// True when the awaited packet didn't arrive within the timeout given when starting to wait. Checked by the + /// server loop, which disconnects the player. + public bool WaitTimedOut => packetAwaitable != null && Clock.ElapsedMilliseconds > waitDeadlineMs; + public override void StartState() { async Task RunStateCatch() @@ -48,15 +60,16 @@ public override void OnDisconnect() /// An exception is thrown if this is called again before the packet arrives. The player is disconnected if a /// different packet type arrives. /// - protected PacketAwaitable Packet(Packets packet) + protected PacketAwaitable Packet(Packets packet, TimeSpan? timeout = null) { if (packetAwaitable != null) throw new Exception($"Already waiting for another packet: {packetAwaitable}"); ServerLog.Verbose($"{connection} waiting for {packet}"); - packetAwaitable = new PacketAwaitable(packet, false); - return packetAwaitable!; + var awaitable = new PacketAwaitable(packet, false); + StartWaiting(awaitable, timeout); + return awaitable!; } /// @@ -64,16 +77,18 @@ protected PacketAwaitable Packet(Packets packet) /// An exception is thrown if this is called again before the packet arrives. The player is disconnected if a /// different packet type arrives. /// - protected Task TypedPacket() where T: struct, IPacket + protected PacketAwaitable TypedPacket(TimeSpan? timeout = null) where T: struct, IPacket { - if (packetAwaitable != null) - throw new Exception($"Already waiting for another packet: {packetAwaitable}"); - - ServerLog.Verbose($"{connection} waiting for {PacketTypeInfo.Id}"); - - packetAwaitable = TypedPacketAwaitable(out var task, announcePacketFailure: false); - return task - .ContinueWith(finishedTask => (T)finishedTask.Result!, TaskContinuationOptions.OnlyOnRanToCompletion); + var typed = new PacketAwaitable(PacketTypeInfo.Id, announcePacketFailure: false); + WaitForTypedPacket(announcePacketFailure: false, timeout, packet => + { + if (packet.HasValue) + typed.SetResult(packet.Value); + else + // Packet failure isn't announced for this awaitable, so this shouldn't happen + typed.SetException(new InvalidOperationException($"Didn't receive {PacketTypeInfo.Id}")); + }, typed.SetException); + return typed; } /// @@ -82,15 +97,16 @@ protected Task TypedPacket() where T: struct, IPacket /// different packet type arrives. /// The result is null if the player is disconnected while waiting. /// - protected PacketAwaitable PacketOrNull(Packets packet) + protected PacketAwaitable PacketOrNull(Packets packet, TimeSpan? timeout = null) { if (packetAwaitable != null) throw new Exception($"Already waiting for another packet: {packetAwaitable}"); ServerLog.Verbose($"{connection} waiting for {packet}"); - packetAwaitable = new PacketAwaitable(packet, true); - return packetAwaitable; + var awaitable = new PacketAwaitable(packet, true); + StartWaiting(awaitable, timeout); + return awaitable; } @@ -99,15 +115,57 @@ protected Task TypedPacket() where T: struct, IPacket /// An exception is thrown if this is called again before the packet arrives. The player is disconnected if a /// different packet type arrives. /// - protected Task TypedPacketOrNull() where T: struct, IPacket + protected PacketAwaitable TypedPacketOrNull(TimeSpan? timeout = null) where T: struct, IPacket + { + var typed = new PacketAwaitable(PacketTypeInfo.Id, announcePacketFailure: true); + WaitForTypedPacket(announcePacketFailure: true, timeout, typed.SetResult, typed.SetException); + return typed; + } + + private void WaitForTypedPacket(bool announcePacketFailure, TimeSpan? timeout, Action onResult, + Action onError) where T : struct, IPacket { if (packetAwaitable != null) throw new Exception($"Already waiting for another packet: {packetAwaitable}"); ServerLog.Verbose($"{connection} waiting for {PacketTypeInfo.Id}"); - packetAwaitable = TypedPacketAwaitable(out var task, announcePacketFailure: true); - return task; + var raw = new PacketAwaitable(PacketTypeInfo.Id, announcePacketFailure); + if (PacketTypeInfo.AllowFragmented) raw.Fragmented(); + + // Bind and resume the awaiting state inline, on the thread that handles the packet (the server thread). + // Task continuations used to move the rest of the state onto the thread pool, racing the server loop. + raw.OnCompleted(() => + { + var reader = raw.GetResult(); + if (reader == null) + { + onResult(null); + return; + } + + var packet = default(T); + try + { + packet.Bind(new PacketReader(reader)); + } + catch (Exception e) + { + ServerLog.Error($"Failed to bind packet {PacketTypeInfo.Id}: {e}"); + onError(e); + return; + } + + onResult(packet); + }); + + StartWaiting(raw, timeout); + } + + private void StartWaiting(PacketAwaitable awaitable, TimeSpan? timeout) + { + packetAwaitable = awaitable; + waitDeadlineMs = timeout is { } t ? Clock.ElapsedMilliseconds + (long)t.TotalMilliseconds : long.MaxValue; } public override PacketHandlerInfo? GetPacketHandler(Packets packet) @@ -129,52 +187,55 @@ protected async Task EndIfDead() await new Blackhole(); return true; } - - private static PacketAwaitable TypedPacketAwaitable(out Task task, bool announcePacketFailure) where TPacket : struct, IPacket - { - var awaitable = new PacketAwaitable(PacketTypeInfo.Id, announcePacketFailure); - if (PacketTypeInfo.AllowFragmented) awaitable.Fragmented(); - - task = CreateTask(); - return awaitable; - - async Task CreateTask() - { - // Announce packet failure is false, so this won't be null. - var reader = await awaitable; - if (reader == null) return null; - var packet = default(TPacket); - try - { - packet.Bind(new PacketReader(reader)); - } - catch (Exception e) - { - ServerLog.Error($"Failed to bind packet {PacketTypeInfo.Id}: {e}"); - throw; - } - return packet; - } - } } public class PacketAwaitable(Packets packetType, bool announcePacketFailure) : INotifyCompletion { private List continuations = new(); + private bool completed; + private T result = default!; + private ExceptionDispatchInfo? exception; + public Packets PacketType { get; } = packetType; public bool AnnouncePacketFailure { get; } = announcePacketFailure; public bool Fragment { get; private set; } - private T? result; - public void OnCompleted(Action continuation) => continuations.Add(continuation); - public bool IsCompleted => result != null; - public T GetResult() => result!; + public bool IsCompleted => completed; public PacketAwaitable GetAwaiter() => this; + public void OnCompleted(Action continuation) + { + if (completed) continuation(); + else continuations.Add(continuation); + } + + public T GetResult() + { + exception?.Throw(); + return result; + } + public void SetResult(T r) { result = r; - foreach (var continuation in continuations) + Complete(); + } + + public void SetException(Exception e) + { + exception = ExceptionDispatchInfo.Capture(e); + Complete(); + } + + // Continuations run synchronously on the completing thread + private void Complete() + { + if (completed) throw new InvalidOperationException($"Awaitable for {PacketType} completed twice"); + completed = true; + + var toRun = continuations; + continuations = []; + foreach (var continuation in toRun) continuation(); } diff --git a/Source/Common/Networking/ConnectionBase.cs b/Source/Common/Networking/ConnectionBase.cs index 14ab1b872..5f047569e 100644 --- a/Source/Common/Networking/ConnectionBase.cs +++ b/Source/Common/Networking/ConnectionBase.cs @@ -23,6 +23,7 @@ public void ChangeState(MpConnectionState state) if (StateObj != null) StateObj.alive = false; + DiscardPartialFragments(); State = MpConnectionState.GetStateEnumOf(state); StateObj = state; StateObj?.StartState(); @@ -33,6 +34,7 @@ public void ChangeState(ConnectionStateEnum state) if (StateObj != null) StateObj.alive = false; + DiscardPartialFragments(); State = state; StateObj = MpConnectionState.CreateState(state, this); StateObj?.StartState(); @@ -72,6 +74,8 @@ protected virtual void Send(Packets id, byte[] message, bool reliable = true) public const int MaxFragmentPacketSize = 1024; // Max size of a packet that can be sent fragmented. It is not possible to send any packets larger than this. public const int MaxFragmentPacketTotalSize = 33_554_432; + // The longest message SendFragmented accepts: the first fragment adds 6 bytes of metadata + public const int MaxFragmentedMessageLength = MaxFragmentPacketTotalSize - 6; private const int FragNone = 0x0; private const int FragMore = 0x40; @@ -80,7 +84,7 @@ protected virtual void Send(Packets id, byte[] message, bool reliable = true) private byte sendFragId; // All fragmented packets need to be sent from the same thread - public void SendFragmented(Packets id, byte[] message) + public virtual void SendFragmented(Packets id, byte[] message) { if (State == ConnectionStateEnum.Disconnected) return; @@ -96,7 +100,7 @@ public void SendFragmented(Packets id, byte[] message) const int maxFragmentSize = MaxFragmentPacketSize - 2; // the first packet has an additional 6 bytes of overhead var totalLength = message.Length + 6; - if (totalLength > MaxFragmentPacketTotalSize) + if (message.Length > MaxFragmentedMessageLength) { throw new PacketSendException( $"Tried to send too big packet {id}. Max size: {MaxFragmentPacketTotalSize}, requested size (incl. overhead): {totalLength}."); @@ -140,6 +144,16 @@ public void SendFragmented(Packets id, byte[] message) public void SendFragmented(SerializedPacket packet) => SendFragmented(packet.id, packet.data); + /// For packets which usually fit a single packet but can be bigger: fragments only those which don't fit. + /// The packet's handler must allow fragments, and the same thread rule as for SendFragmented applies. + public void SendFragmentedIfNeeded(SerializedPacket packet) + { + if (packet.data.Length > MaxSinglePacketSize) + SendFragmented(packet); + else + Send(packet); + } + protected abstract void SendRaw(byte[] raw, bool reliable = true); public virtual void HandleReceiveRaw(ByteReader data, bool reliable) @@ -162,6 +176,9 @@ public virtual void HandleReceiveRaw(ByteReader data, bool reliable) private const int MaxFragmentedPackets = 1; private readonly List<(/* fragId */ byte, FragmentedPacket)> fragments = []; + // Fragmented packet whose remaining fragments are dropped, or -1. Fragments of one packet are sent back to + // back on the reliable ordered channel, so a fragment with another id means this one is over. + private int discardedFragId = -1; protected virtual void HandleReceiveMsg(int msgId, int fragState, ByteReader reader, bool reliable) { @@ -172,12 +189,34 @@ protected virtual void HandleReceiveMsg(int msgId, int fragState, ByteReader rea if (reader.Left > MaxSinglePacketSize) throw new PacketReadException($"Packet {packetType} too big {reader.Left}>{MaxSinglePacketSize}"); + if (fragState != FragNone && discardedFragId != -1) + { + if (reader.Left > 0 && reader.PeekByte() == discardedFragId) + { + if (fragState == FragEnd) discardedFragId = -1; + reader.Seek(reader.Length); + return; + } + + discardedFragId = -1; + } + var handler = StateObj?.GetPacketHandler(packetType); if (handler == null) { if (reliable && !Lenient) throw new PacketReadException($"No handler for packet {packetType} in state {State}"); ServerLog.Error($"No handler for packet {packetType} in state {State}"); + + // Also drop the rest of a fragmented packet; a later state could otherwise mistake its next + // fragment for the start of a new packet. + if (fragState != FragNone && reader.Left > 0) + { + var fragId = reader.PeekByte(); + fragments.RemoveAll(f => f.Item1 == fragId); + if (fragState != FragEnd) discardedFragId = fragId; + } + reader.Seek(reader.Length); return; } @@ -237,6 +276,20 @@ private void HandleReceiveFragment(ByteReader reader, Packets packetType, Packet ExecuteMessageHandler(handler, packetType, new ByteReader(fragPacket.Data.GetBuffer())); } + // A partially received fragmented packet was meant for the old state. Without this, it would stay in + // fragments forever and the next fragmented packet would fail with "High number of fragmented packets". + private void DiscardPartialFragments() + { + foreach (var (fragId, fragPacket) in fragments) + { + ServerLog.Verbose($"{this} dropping partially received {fragPacket.Id} " + + $"({fragPacket.ReceivedPartsCount}/{fragPacket.ExpectedPartsCount} parts) on state change"); + discardedFragId = fragId; + } + + fragments.Clear(); + } + private void ExecuteMessageHandler(PacketHandlerInfo handler, Packets packet, ByteReader data) { var pos = data.Position; diff --git a/Source/Common/Networking/LiteNetConnection.cs b/Source/Common/Networking/LiteNetConnection.cs index b3a8c5d05..7ddb5298c 100644 --- a/Source/Common/Networking/LiteNetConnection.cs +++ b/Source/Common/Networking/LiteNetConnection.cs @@ -9,10 +9,14 @@ public class LiteNetConnection(NetPeer peer) : ConnectionBase protected override void SendRaw(byte[] raw, bool reliable) { - if (peer.ConnectionState == ConnectionState.Connected) + var state = peer.ConnectionState; + if (state == ConnectionState.Connected) peer.Send(raw, reliable ? DeliveryMethod.ReliableOrdered : DeliveryMethod.Unreliable); + // LiteNetLib's thread changes the state, the server hears of the disconnect only on its next poll + else if ((state & (ConnectionState.ShutdownRequested | ConnectionState.Disconnected)) != 0) + ServerLog.Verbose($"Dropping a packet for {peer}, which is disconnecting ({state})"); else - ServerLog.Error($"SendRaw() called with invalid connection state ({peer}): {peer.ConnectionState}"); + ServerLog.Error($"SendRaw() called with invalid connection state ({peer}): {state}"); } protected override void OnClose(ServerDisconnectPacket? goodbye) diff --git a/Source/Common/Networking/MpConnectionState.cs b/Source/Common/Networking/MpConnectionState.cs index 8551f48eb..2875f57b9 100644 --- a/Source/Common/Networking/MpConnectionState.cs +++ b/Source/Common/Networking/MpConnectionState.cs @@ -49,6 +49,10 @@ public static void SetImplementation(ConnectionStateEnum state, Type type) StateImpls[(int)state] = type; + // Replacing an implementation (tests do) must not keep the previous one's handlers + for (var packetId = 0; packetId < packetHandlers.GetLength(1); packetId++) + packetHandlers[(int)state, packetId] = null; + // The point of this attribute is to explicitly mark how to handle packet listeners from the base type. // If the base type is the lowest possible (MpConnectionState, which doesn't have any listeners), there is // no reason to warn when the annotation is missing. diff --git a/Source/Common/Networking/Packet/AutosavingPacket.cs b/Source/Common/Networking/Packet/AutosavingPacket.cs index 1b0b55b1a..cd7e5f4fd 100644 --- a/Source/Common/Networking/Packet/AutosavingPacket.cs +++ b/Source/Common/Networking/Packet/AutosavingPacket.cs @@ -1,12 +1,16 @@ namespace Multiplayer.Common.Networking.Packet; +/// tick: the game timer of the saved state. With in-game day autosaves every client saves at the same tick and asks +/// for a join point once its save is written, the server starts one per tick. [PacketDefinition(Packets.Client_Autosaving)] -public record struct ClientAutosavingPacket(JoinPointRequestReason reason) : IPacket +public record struct ClientAutosavingPacket(JoinPointRequestReason reason, int tick) : IPacket { public JoinPointRequestReason reason = reason; + public int tick = tick; public void Bind(PacketBuffer buf) { buf.BindEnum(ref reason); + buf.Bind(ref tick); } -} \ No newline at end of file +} diff --git a/Source/Common/Networking/Packet/CommandPacket.cs b/Source/Common/Networking/Packet/CommandPacket.cs index ddbe1a5f2..30e5f586a 100644 --- a/Source/Common/Networking/Packet/CommandPacket.cs +++ b/Source/Common/Networking/Packet/CommandPacket.cs @@ -1,8 +1,13 @@ namespace Multiplayer.Common.Networking.Packet; -[PacketDefinition(Packets.Server_Command)] +// Commands which don't fit a single packet (designating or pasting big areas, reforming an ideo) are sent fragmented +[PacketDefinition(Packets.Server_Command, allowFragmented: true)] public record struct ServerCommandPacket : IPacket { + // Commands go into the log joiners receive with the world data, which has to fit a fragmented packet + // (ConnectionBase.MaxFragmentedMessageLength) together with the save, so keep them well below that + public const int MaxDataLength = 1024 * 1024; + public CommandType type; public int ticks; public int factionId; @@ -30,11 +35,11 @@ public void Bind(PacketBuffer buf) buf.Bind(ref factionId); buf.Bind(ref mapId); buf.Bind(ref playerId); - buf.BindRemaining(ref data, maxLength: 65535); + buf.BindRemaining(ref data, maxLength: MaxDataLength); } } -[PacketDefinition(Packets.Client_Command)] +[PacketDefinition(Packets.Client_Command, allowFragmented: true)] public record struct ClientCommandPacket(CommandType type, int mapId, byte[] data) : IPacket { public CommandType type = type; @@ -45,6 +50,7 @@ public void Bind(PacketBuffer buf) { buf.BindEnum(ref type); buf.Bind(ref mapId); - buf.BindRemaining(ref data, maxLength: 65535); + // Capped by what the server can rebroadcast, see ServerCommandPacket + buf.BindRemaining(ref data, maxLength: ServerCommandPacket.MaxDataLength); } } diff --git a/Source/Common/Networking/Packet/IPacket.cs b/Source/Common/Networking/Packet/IPacket.cs index 1276ef9df..01e8fe4b8 100644 --- a/Source/Common/Networking/Packet/IPacket.cs +++ b/Source/Common/Networking/Packet/IPacket.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; +using System.Text; namespace Multiplayer.Common.Networking.Packet; @@ -90,7 +91,11 @@ public static T Deserialize(this Binder binder, byte[] src) return obj; } - public static Binder Gzipped(this Binder inner, int maxLength = PacketBuffer.DefaultMaxLength) => + public const int DefaultMaxDecompressedLength = 1 << 26; + + /// maxLength limits the compressed bytes, maxDecompressedLength the inflated data (decompression bombs). + public static Binder Gzipped(this Binder inner, int maxLength = PacketBuffer.DefaultMaxLength, + int maxDecompressedLength = DefaultMaxDecompressedLength) => (PacketBuffer buf, ref T obj) => { if (buf.isWriting) @@ -114,12 +119,26 @@ public static Binder Gzipped(this Binder inner, int maxLength = PacketB using var outputStream = new MemoryStream(); using (var decompress = new DeflateStream(inputStream, CompressionMode.Decompress)) { - decompress.CopyTo(outputStream); + CopyLimited(decompress, outputStream, maxDecompressedLength); } var reader = new ByteReader(outputStream.ToArray()); inner(new PacketReader(reader), ref obj); } }; + + private static void CopyLimited(Stream source, Stream destination, int maxLength) + { + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + total += read; + if (total > maxLength) + throw new ReaderException($"Decompressed data too big (>{maxLength})"); + destination.Write(buffer, 0, read); + } + } } public abstract class PacketBuffer(bool isWriting) @@ -215,6 +234,7 @@ public sealed class PacketReader(ByteReader reader) : PacketBuffer(false) public override void BindBytes(ref byte[] obj, int maxLength = DefaultMaxLength) { int len = reader.ReadInt32(); + if (len > maxLength && maxLength != -1) throw new ReaderException($"Byte array too big ({len}>{maxLength})"); obj = reader.ReadRaw(len); } @@ -223,13 +243,17 @@ public override void Bind(ref T[] obj, Binder bind, int maxLength = Defaul int len = reader.ReadInt32(); if (len > maxLength && maxLength != -1) throw new ReaderException($"Array too big ({len}>{maxLength})"); - obj = new T[len]; + if (len < 0) throw new ReaderException($"Array length ({len}<0)"); + + // The length is remote data, don't allocate more than the packet could hold up front + var items = new List(Math.Min(len, reader.Left)); for (var i = 0; i < len; i++) { var item = default(T); bind(this, ref item); - obj[i] = item; + items.Add(item); } + obj = items.ToArray(); } public override void BindRemaining(ref byte[] obj, int maxLength = DefaultMaxLength) @@ -243,7 +267,10 @@ public override void Bind(ref List obj, Binder bind, int maxLength = De int len = reader.ReadInt32(); if (len > maxLength) throw new ReaderException($"List too big ({len}>{maxLength})"); - obj = new List(len); + if (len < 0) throw new ReaderException($"List length ({len}<0)"); + + // The length is remote data, don't preallocate more than the packet could hold + obj = new List(Math.Min(len, reader.Left)); for (var i = 0; i < len; i++) { var item = default(T); @@ -258,7 +285,9 @@ public override void Bind(ref Dictionary obj, Binder bindKey, Bin int len = reader.ReadInt32(); if (len > maxLength && maxLength != -1) throw new ReaderException($"Dictionary too big ({len}>{maxLength})"); - obj = new Dictionary(len); + if (len < 0) throw new ReaderException($"Dictionary length ({len}<0)"); + + obj = new Dictionary(Math.Min(len, reader.Left)); for (int i = 0; i < len; i++) { var key = default(K); @@ -302,7 +331,10 @@ public sealed class PacketWriter(ByteWriter writer) : PacketBuffer(true) public override void Bind(ref string obj, int maxLength = DefaultMaxLength) { - if (obj != null && obj.Length > maxLength) throw new WriterException($"Too long string ({obj.Length}>{maxLength})"); + // The reader limits UTF-8 bytes, not chars: checking chars here let non-ASCII strings through which every + // receiver then rejected + if (obj != null && Encoding.UTF8.GetByteCount(obj) is var bytes && bytes > maxLength) + throw new WriterException($"Too long string ({bytes} bytes>{maxLength})"); writer.WriteString(obj); } @@ -310,6 +342,7 @@ public override void Bind(ref string obj, int maxLength = DefaultMaxLength) public override void BindBytes(ref byte[] obj, int maxLength = DefaultMaxLength) { + if (obj.Length > maxLength && maxLength != -1) throw new WriterException($"Byte array too big ({obj.Length}>{maxLength})"); writer.WriteInt32(obj.Length); writer.WriteRaw(obj); } diff --git a/Source/Common/Networking/Packet/InitDataPacket.cs b/Source/Common/Networking/Packet/InitDataPacket.cs index ecec5699c..7788d3994 100644 --- a/Source/Common/Networking/Packet/InitDataPacket.cs +++ b/Source/Common/Networking/Packet/InitDataPacket.cs @@ -16,10 +16,17 @@ public void Bind(PacketBuffer buf) [PacketDefinition(Packets.Client_InitData, allowFragmented: true)] public record struct ClientInitDataPacket : IPacket { - // 1 MiB limit — large mod lists can exceed the default 32 KiB BindRemaining limit - private const int MaxRawDataLength = 1 << 20; + // Max size of the compressed mod list. Large mod lists with synced configs can exceed 1 MiB. + public const int MaxRawDataLength = 1 << 22; + // rawMods additionally holds the length prefix of the compressed data + public const int MaxRawModsLength = MaxRawDataLength + sizeof(int); + // Synced configs are plain text and compress well + public const int MaxDecompressedLength = 1 << 26; + // A language folder name, at most 255 UTF-16 units on common file systems + public const int MaxLanguageLength = 1024; public string rwVersion; + public string language; public int[] debugOnlySyncCmds; public int[] hostOnlySyncCmds; public RoundModeEnum modCtorRoundMode; @@ -37,13 +44,14 @@ public List Mods public void Bind(PacketBuffer buf) { buf.Bind(ref rwVersion); + buf.Bind(ref language, maxLength: MaxLanguageLength); buf.Bind(ref debugOnlySyncCmds, BinderOf.Int()); buf.Bind(ref hostOnlySyncCmds, BinderOf.Int()); buf.BindEnum(ref modCtorRoundMode); buf.BindEnum(ref staticCtorRoundMode); buf.Bind(ref defInfos, BinderOf.Identity()); buf.Bind(ref includeConfigs); - buf.BindRemaining(ref rawMods); + buf.BindRemaining(ref rawMods, maxLength: MaxRawModsLength); } // Based on ContentSource but a byte, so smaller on the network and also doesn't use Verse @@ -101,7 +109,7 @@ public void Bind(PacketBuffer buf) }; public static Binder> ListBinder => - BinderOf.List(BinderOf.Identity()).Gzipped(MaxRawDataLength); + BinderOf.List(BinderOf.Identity()).Gzipped(MaxRawDataLength, MaxDecompressedLength); } public record struct ModFile : IPacketBufferable diff --git a/Source/Common/Networking/Packet/JoinDataPacket.cs b/Source/Common/Networking/Packet/JoinDataPacket.cs index 507b59f37..d11a3d0f4 100644 --- a/Source/Common/Networking/Packet/JoinDataPacket.cs +++ b/Source/Common/Networking/Packet/JoinDataPacket.cs @@ -5,11 +5,18 @@ namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Server_JoinData, allowFragmented: true)] public record struct ServerJoinDataPacket : IPacket { + public const int MaxMissingDefs = 512; + public string gameName; public int playerId; public string rwVersion; public string mpVersion; + // Game language of the client which provided the server's init data + public string language; + // Aligned with the client's def infos from ClientJoinDataPacket public DefCheckStatus[] defStatus; + // Def types the server knows but the client didn't send + public string[] missingDefs; public bool configsIncluded; public byte[] rawServerInitData; @@ -25,11 +32,17 @@ public void Bind(PacketBuffer buf) buf.Bind(ref playerId); buf.Bind(ref rwVersion); buf.Bind(ref mpVersion); + buf.Bind(ref language, maxLength: ClientInitDataPacket.MaxLanguageLength); buf.Bind(ref defStatus, BinderOf.Enum()); + buf.Bind(ref missingDefs, MissingDefBinder, maxLength: MaxMissingDefs); buf.Bind(ref configsIncluded); - // Max 512KiB. Should be way more than enough. As an example, one game with ~100 mods used ~35KiB. - buf.BindRemaining(ref rawServerInitData, maxLength: 1<<19); + // The server forwards the first joiner's (or host's) ClientInitDataPacket.rawMods, so the limits must match. + // As an example, one game with ~100 mods used ~35KiB, synced configs can add a lot more. + buf.BindRemaining(ref rawServerInitData, maxLength: ClientInitDataPacket.MaxRawModsLength); } + + private static readonly Binder MissingDefBinder = (PacketBuffer buf, ref string name) => + buf.Bind(ref name, maxLength: KeyedDefInfo.MaxNameLength); } [PacketDefinition(Packets.Client_JoinData, allowFragmented: true)] @@ -49,13 +62,15 @@ public void Bind(PacketBuffer buf) public record struct KeyedDefInfo : IPacketBufferable { + public const int MaxNameLength = 128; + public string name; public int count; public int hash; public void Bind(PacketBuffer buf) { - buf.Bind(ref name, maxLength: 128); + buf.Bind(ref name, maxLength: MaxNameLength); buf.Bind(ref count); buf.Bind(ref hash); } diff --git a/Source/Common/Networking/Packet/PlayerListPacket.cs b/Source/Common/Networking/Packet/PlayerListPacket.cs index b42fdc18a..c28e54a46 100644 --- a/Source/Common/Networking/Packet/PlayerListPacket.cs +++ b/Source/Common/Networking/Packet/PlayerListPacket.cs @@ -98,7 +98,7 @@ 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); + buf.Bind(ref steamPersonaName, maxLength: Constants.k_cchPersonaNameMax); buf.Bind(ref ticksBehind); buf.Bind(ref simulating); buf.Bind(ref r); diff --git a/Source/Common/Networking/Packet/SelectedPacket.cs b/Source/Common/Networking/Packet/SelectedPacket.cs index b328dab7d..6c39981a3 100644 --- a/Source/Common/Networking/Packet/SelectedPacket.cs +++ b/Source/Common/Networking/Packet/SelectedPacket.cs @@ -1,5 +1,7 @@ -namespace Multiplayer.Common.Networking.Packet; +using System; +using System.Collections.Generic; +namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Server_Selected)] public record struct ServerSelectedPacket(int playerId, ClientSelectedPacket data) : IPacket @@ -14,9 +16,13 @@ public void Bind(PacketBuffer buf) } } +/// A change of the player's selection: reset clears it first, then the ids are added and removed [PacketDefinition(Packets.Client_Selected)] public record struct ClientSelectedPacket : IPacket { + // Per packet and list. Vanilla selects at most 200 things, but mods can select more. + public const int MaxIds = 200; + public bool reset; public int[] newlySelectedIds; public int[] unselectedIds; @@ -24,7 +30,29 @@ public record struct ClientSelectedPacket : IPacket public void Bind(PacketBuffer buf) { buf.Bind(ref reset); - buf.Bind(ref newlySelectedIds, BinderOf.Int(), maxLength: 200); - buf.Bind(ref unselectedIds, BinderOf.Int(), maxLength: 200); + buf.Bind(ref newlySelectedIds, BinderOf.Int(), maxLength: MaxIds); + buf.Bind(ref unselectedIds, BinderOf.Int(), maxLength: MaxIds); + } + + /// Splits a selection change into packets within the limits. Only the first one resets, so applying them in + /// order (they're sent reliably) gives the whole change. + public static IEnumerable Split(bool reset, int[] added, int[] removed) + { + var count = Math.Max(1, (Math.Max(added.Length, removed.Length) + MaxIds - 1) / MaxIds); + for (int i = 0; i < count; i++) + yield return new ClientSelectedPacket + { + reset = reset && i == 0, + newlySelectedIds = Slice(added, i * MaxIds), + unselectedIds = Slice(removed, i * MaxIds) + }; + } + + private static int[] Slice(int[] ids, int start) + { + var length = Math.Max(0, Math.Min(MaxIds, ids.Length - start)); + var slice = new int[length]; + Array.Copy(ids, Math.Min(start, ids.Length), slice, 0, length); + return slice; } } diff --git a/Source/Common/Networking/Packet/SyncInfoPacket.cs b/Source/Common/Networking/Packet/SyncInfoPacket.cs index 4c6f1619b..8fe8d7484 100644 --- a/Source/Common/Networking/Packet/SyncInfoPacket.cs +++ b/Source/Common/Networking/Packet/SyncInfoPacket.cs @@ -41,6 +41,19 @@ public void Bind(PacketBuffer buf) private static readonly Binder SyncOpinionBinder = BinderOf.Identity(); } +/// Standalone only: whether the receiving client is the sync reference, the one whose opinions the server forwards to +/// every other client (the host and arbiter know that they are). +[PacketDefinition(Packets.Server_SyncReference)] +public record struct ServerSyncReferencePacket(bool isReference) : IPacket +{ + public bool isReference = isReference; + + public void Bind(PacketBuffer buf) + { + buf.Bind(ref isReference); + } +} + public record struct SyncOpinion : IPacketBufferable { public int startTick; diff --git a/Source/Common/Networking/Packet/TracesPacket.cs b/Source/Common/Networking/Packet/TracesPacket.cs index 7e48ad09d..97f41bb37 100644 --- a/Source/Common/Networking/Packet/TracesPacket.cs +++ b/Source/Common/Networking/Packet/TracesPacket.cs @@ -3,6 +3,9 @@ namespace Multiplayer.Common.Networking.Packet; [PacketDefinition(Packets.Client_Traces, allowFragmented: true)] public record struct ClientTracesPacket : IPacket { + // Compressed desync traces and jitted methods easily exceed the default limit, so only the packet size limits them + public const int MaxLength = ConnectionBase.MaxFragmentPacketTotalSize; + public int playerId; public byte[] rawTraces; public byte[] rawJittedMethods; @@ -10,8 +13,8 @@ public record struct ClientTracesPacket : IPacket public void Bind(PacketBuffer buf) { buf.Bind(ref playerId); - buf.BindBytes(ref rawTraces); - buf.BindBytes(ref rawJittedMethods); + buf.BindBytes(ref rawTraces, MaxLength); + buf.BindBytes(ref rawJittedMethods, MaxLength); } } @@ -55,8 +58,8 @@ public void Bind(PacketBuffer buf) } else { - buf.BindBytes(ref rawTraces); - buf.BindBytes(ref rawJittedMethods); + buf.BindBytes(ref rawTraces, ClientTracesPacket.MaxLength); + buf.BindBytes(ref rawJittedMethods, ClientTracesPacket.MaxLength); } } } diff --git a/Source/Common/Networking/Packets.cs b/Source/Common/Networking/Packets.cs index 0d3cd3a69..c6755424f 100644 --- a/Source/Common/Networking/Packets.cs +++ b/Source/Common/Networking/Packets.cs @@ -73,6 +73,9 @@ public enum Packets : byte Client_BootstrapSave, Server_Bootstrap, + // Playing (appended to keep the ids above stable) + Server_SyncReference, + Count, Max = 63 // max packet id } diff --git a/Source/Common/Networking/PendingSessionCloses.cs b/Source/Common/Networking/PendingSessionCloses.cs new file mode 100644 index 000000000..9f90c615a --- /dev/null +++ b/Source/Common/Networking/PendingSessionCloses.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Multiplayer.Common; + +/// +/// Transport sessions waiting to be closed, by remote. Closing a session can discard packets the transport hasn't +/// sent yet, so a close that follows a goodbye waits a little. Thread safe: the server thread schedules and takes +/// closes while it runs, another thread takes the rest once it stopped. +/// +public class PendingSessionCloses where TRemote : notnull +{ + private readonly object locker = new(); + private readonly Dictionary dueAt = new(); + private readonly Func clockMs; + + public PendingSessionCloses(Func? clockMs = null) + { + if (clockMs == null) + { + var watch = Stopwatch.StartNew(); + clockMs = () => watch.ElapsedMilliseconds; + } + + this.clockMs = clockMs; + } + + public int Count + { + get { lock (locker) return dueAt.Count; } + } + + /// Replaces an earlier schedule for the remote: the later goodbye needs the time too. + public void Schedule(TRemote remote, int delayMs) + { + lock (locker) + dueAt[remote] = clockMs() + delayMs; + } + + /// Returns whether a close was pending. + public bool Cancel(TRemote remote) + { + lock (locker) + return dueAt.Remove(remote); + } + + /// Removes and returns the remotes whose delay is over. + public IReadOnlyList TakeDue() + { + lock (locker) + { + if (dueAt.Count == 0) return Array.Empty(); + + var now = clockMs(); + List? due = null; + foreach (var entry in dueAt) + if (now >= entry.Value) + (due ??= []).Add(entry.Key); + + if (due == null) return Array.Empty(); + + foreach (var remote in due) + dueAt.Remove(remote); + return due; + } + } +} diff --git a/Source/Common/Networking/State/ServerBootstrapState.cs b/Source/Common/Networking/State/ServerBootstrapState.cs index ee1831f49..f423ba429 100644 --- a/Source/Common/Networking/State/ServerBootstrapState.cs +++ b/Source/Common/Networking/State/ServerBootstrapState.cs @@ -91,8 +91,8 @@ public void HandleSaveData(ClientBootstrapSaveDataPacket packet) player.conn.Send(new ServerDisconnectPacket { reason = MpDisconnectReason.BootstrapCompleted, data = Array.Empty() }); ResetUploadState(); + // Run stops the server once this iteration is over, the rest of it still needs the server Server.running = false; - Server.TryStop(); } [FragmentedPacketHandler(Packets.Client_BootstrapSave)] diff --git a/Source/Common/Networking/State/ServerJoiningState.cs b/Source/Common/Networking/State/ServerJoiningState.cs index b2d2a392f..bc1dbdadb 100644 --- a/Source/Common/Networking/State/ServerJoiningState.cs +++ b/Source/Common/Networking/State/ServerJoiningState.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -8,22 +9,33 @@ namespace Multiplayer.Common; public class ServerJoiningState : AsyncConnectionState { + // Packets the client sends on its own, without user interaction + public static TimeSpan ReplyTimeout { get; set; } = TimeSpan.FromMinutes(2); + // The init data can be a few MB with synced configs, other joiners wait for it + public static TimeSpan InitDataTimeout { get; set; } = TimeSpan.FromMinutes(5); + // Packets sent after user input: the password prompt and the mod mismatch window + public static TimeSpan UserInputTimeout { get; set; } = TimeSpan.FromMinutes(15); + public ServerJoiningState(ConnectionBase connection) : base(connection) { } protected override async Task RunState() { - HandleProtocol(await TypedPacket()); - HandleUsername(await TypedPacket()); + HandleProtocol(await TypedPacket(ReplyTimeout)); + await EndIfDead(); + HandleUsername(await TypedPacket(UserInputTimeout)); + await EndIfDead(); while (await Server.InitDataTask() is null && await EndIfDead()) if (Server.InitDataState == InitDataState.Waiting) await RequestInitData(); + // The player may have left while waiting for someone else's init data + await EndIfDead(); connection.Send(Packets.Server_UsernameOk); - if (!HandleClientJoinData(await TypedPacket())) + if (!HandleClientJoinData(await TypedPacket(ReplyTimeout))) return; if (Server.settings.pauseOnJoin) @@ -35,7 +47,7 @@ protected override async Task RunState() Server.playerManager.OnJoin(Player); Server.playerManager.SendInitDataCommand(Player); - await Packet(Packets.Client_WorldRequest); + await Packet(Packets.Client_WorldRequest, UserInputTimeout); connection.ChangeState(ConnectionStateEnum.ServerLoading); } @@ -98,23 +110,23 @@ private void HandleUsername(ClientUsernamePacket packet) private async Task RequestInitData() { - ClientInitDataPacket? initData = null; + ServerInitData? result = null; var completionSource = Server.StartInitData(); try { Player.SendPacket(new ServerInitDataRequestPacket(Server.settings.syncConfigs)); ServerLog.Verbose("Sent initial data request"); - initData = await TypedPacketOrNull(); + // Null when the player disconnects (or times out) while we wait + if (await TypedPacketOrNull(InitDataTimeout) is { } initData) + result = ServerInitData.FromNet(initData); } finally { - // Invoking StartInitData and abandoning the completion source in case of an exception would mean a server - // restart is necessary to try and set init data again. Make sure the server is more graceful in that case. - completionSource.SetResult(initData != null - // cast to non-nullable, for whatever reason C# isn't able to infer that from the != null check - ? ServerInitData.FromNet((ClientInitDataPacket)initData) - : null); + // Always complete the source, also when FromNet throws: an abandoned source would block every later + // joiner until a server restart. Null puts the server back into InitDataState.Waiting so that the next + // joiner gets asked. + completionSource.SetResult(result); } } @@ -149,13 +161,24 @@ private bool HandleClientJoinData(ClientJoinDataPacket packet) defStatus[i] = status; } + // The statuses above only cover the def types the client knows about + var clientDefNames = new HashSet(packet.defInfos.Select(info => info.name)); + var missingDefs = serverInitData.DefInfos.Keys + .Where(name => !clientDefNames.Contains(name)) + .Take(ServerJoinDataPacket.MaxMissingDefs) + .ToArray(); + if (missingDefs.Length > 0) + defsMatch = false; + connection.SendFragmented(new ServerJoinDataPacket { gameName = Server.settings.gameName, playerId = Player.id, rwVersion = serverInitData.RwVersion, mpVersion = MpVersion.Version, + language = serverInitData.Language, defStatus = defStatus, + missingDefs = missingDefs, configsIncluded = serverInitData.IncludeConfigs, rawServerInitData = serverInitData.RawData, }.Serialize()); diff --git a/Source/Common/Networking/State/ServerLoadingState.cs b/Source/Common/Networking/State/ServerLoadingState.cs index 76638809b..cd0a4e817 100644 --- a/Source/Common/Networking/State/ServerLoadingState.cs +++ b/Source/Common/Networking/State/ServerLoadingState.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Multiplayer.Common.Networking.Packet; @@ -32,16 +33,15 @@ protected override async Task RunState() await Server.worldData.WaitJoinPoint(); await EndIfDead(); - SendWorldData(); + if (!SendWorldData()) + return; Player.SendPlayerList(); connection.ChangeState(ConnectionStateEnum.ServerPlaying); } - public void SendWorldData() + public bool SendWorldData() { - connection.Send(Packets.Server_WorldDataStart); - ByteWriter writer = new ByteWriter(); writer.WriteInt32(Player.FactionId); @@ -51,22 +51,11 @@ public void SendWorldData() writer.WritePrefixedBytes(Server.worldData.savedGame); writer.WritePrefixedBytes(Server.worldData.sessionData); - writer.WriteInt32(Server.worldData.mapCmds.Count); - - foreach (var kv in Server.worldData.mapCmds) - { - int mapId = kv.Key; - - //MultiplayerServer.instance.SendCommand(CommandType.CreateMapFactionData, ScheduledCommand.NoFaction, mapId, ByteWriter.GetBytes(factionId)); - - List mapCmds = kv.Value; - - writer.WriteInt32(mapId); - - writer.WriteInt32(mapCmds.Count); - foreach (var arr in mapCmds) - writer.WritePrefixedBytes(arr); - } + // All cmds in one list, in send order, so the joiner executes them exactly like live clients did + var cmds = Server.worldData.cmds; + writer.WriteInt32(cmds.Count); + foreach (var cmd in cmds) + writer.WritePrefixedBytes(cmd); writer.WriteInt32(Server.worldData.mapData.Count); @@ -79,13 +68,47 @@ public void SendWorldData() writer.WritePrefixedBytes(mapData); } - writer.WriteInt32(Server.worldData.syncInfos.Count); - foreach (var syncInfo in Server.worldData.syncInfos) + // The world and maps usually came in one upload with the same limit, but the command log since the join point + // and the sync opinions come on top. The opinions only let the joiner check its catch-up, they get what's left. + var syncInfoSpace = ConnectionBase.MaxFragmentedMessageLength - writer.Position - sizeof(int); + if (syncInfoSpace < 0) + { + RejectOversizeWorldData(writer.Position); + return false; + } + + var syncInfos = Server.worldData.NewestSyncInfosFitting(syncInfoSpace); + if (syncInfos.Count < Server.worldData.syncInfos.Count) + ServerLog.Detail($"World data for {Player.Username}: only {syncInfos.Count} of " + + $"{Server.worldData.syncInfos.Count} sync opinions fit"); + + writer.WriteInt32(syncInfos.Count); + foreach (var syncInfo in syncInfos) writer.WritePrefixedBytes(syncInfo); byte[] packetData = writer.ToArray(); + connection.Send(Packets.Server_WorldDataStart); connection.SendFragmented(Packets.Server_WorldData, packetData); ServerLog.Log("World response sent: " + packetData.Length); + return true; + } + + // SendFragmented would throw, which disconnects with a generic state exception and repeats for every joiner + private void RejectOversizeWorldData(int length) + { + const double MiB = 1024 * 1024; + var worldData = Server.worldData; + var saveLength = (worldData.savedGame?.Length ?? 0) + (worldData.sessionData?.Length ?? 0) + + worldData.mapData.Values.Sum(map => (long)map.Length); + var cmdsLength = worldData.cmds.Sum(cmd => (long)cmd.Length); + + var details = $"{length / MiB:0.0} MiB > {ConnectionBase.MaxFragmentedMessageLength / MiB:0.0} MiB; " + + $"save {saveLength / MiB:0.0} MiB, command log since the last join point " + + $"{cmdsLength / MiB:0.0} MiB ({worldData.cmds.Count} commands)"; + ServerLog.Error($"Can't send the world data to {Player.Username}, it's too big ({details}). " + + "A new join point (e.g. saving the game) clears the command log."); + Server.SendChat($"{Player.Username} can't join: the world data is too big ({details})"); + Player.Disconnect($"The server's world data is too big to send ({details})."); } } diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index f7b21e7a7..9fbda4907 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Multiplayer.Common.Networking.Packet; @@ -6,15 +7,30 @@ namespace Multiplayer.Common { public class ServerPlayingState(ConnectionBase conn) : MpConnectionState(conn) { + public override void StartState() + { + // The client is about to load the world and may not answer keep alives for a while (also on rejoin, + // where the status stays Playing), so start the stale keep alive timeout from here. + if (connection.serverPlayer is { } player && MultiplayerServer.instance is { } server) + player.keepAliveAt = server.NetTimer; + } + [PacketHandler(Packets.Client_WorldReady)] public void HandleWorldReady(ByteReader data) { + Player.keepAliveAt = Server.NetTimer; Player.UpdateStatus(PlayerStatus.Playing); + Server.playerManager.UpdateSyncReference(); } [PacketHandler(Packets.Client_RequestRejoin)] public void HandleRejoin(ByteReader data) { + // Not caught up until Client_WorldReady again: can't be the sync reference or a join point uploader + Player.UpdateStatus(PlayerStatus.Simulating); + Server.worldData.OnPlayerUnavailable(Player, "is rejoining"); + Server.playerManager.UpdateSyncReference(); + connection.ChangeState(ConnectionStateEnum.ServerLoading); Player.ResetTimeVotes(); } @@ -26,13 +42,20 @@ public void HandleDesynced(ClientDesyncedPacket packet) => [TypedPacketHandler] public void HandleTraces(ClientTracesPacket packet) { - if (!Player.IsHost) return; - Server.GetPlayer(packet.playerId)?.SendPacket(ServerTracesPacket.Transfer(packet.rawTraces, packet.rawJittedMethods)); + // Requested in PlayerManager.OnDesync + if (!Player.IsHost && Player != Server.playerManager.SyncReference) return; + // The traces are up to ClientTracesPacket.MaxLength; a single packet over the limit would throw here and + // disconnect the sender + Server.GetPlayer(packet.playerId)?.conn.SendFragmented( + ServerTracesPacket.Transfer(packet.rawTraces, packet.rawJittedMethods).Serialize()); } [TypedPacketHandler] public void HandleClientCommand(ClientCommandPacket packet) { + if (!IsValidClientCommand(packet.type, packet.mapId)) + throw new PacketReadException($"Invalid client command {packet.type} for map {packet.mapId}"); + int? mapToResync = null; if (packet.type == CommandType.PlayerCount) @@ -50,7 +73,8 @@ public void HandleClientCommand(ClientCommandPacket packet) mapToResync = newMapId; } - // todo check if map id is valid for the player + // todo check if map id is valid for the player (the server doesn't know maps created since the last + // join point) Server.commands.Send(packet.type, Player.FactionId, packet.mapId, packet.data, Player); @@ -58,6 +82,19 @@ public void HandleClientCommand(ClientCommandPacket packet) Server.SendMapResponse(Player, currentMapId); } + /// Only the command types and scopes the client sends. PauseAll, CreateJoinPoint and InitPlayerData are + /// issued by the server alone; accepting them from clients would e.g. let anyone grant dev mode to anyone. + public static bool IsValidClientCommand(CommandType type, int mapId) => type switch + { + CommandType.GlobalTimeSpeed or CommandType.TimeSpeedVote or CommandType.PlayerCount => + mapId == ScheduledCommand.Global, + CommandType.MapTimeSpeed or CommandType.Designator => + mapId >= 0, + CommandType.Sync or CommandType.DebugTools => + mapId == ScheduledCommand.Global || mapId >= 0, + _ => false + }; + public const int MaxChatMsgLength = 128; [TypedPacketHandler] @@ -87,75 +124,47 @@ public void HandleChat(ClientChatPacket packet) [PacketHandler(Packets.Client_WorldDataUpload, allowFragmented: true)] public void HandleWorldDataUpload(ByteReader data) { - // On standalone, accept from any playing client; otherwise only host/arbiter - if (!Server.IsStandaloneServer && (Server.ArbiterPlaying ? !Player.IsArbiter : !Player.IsHost)) - return; + ServerLog.Detail($"Got world upload {data.Left} from {Player.Username}"); - ServerLog.Detail($"Got world upload {data.Left}"); + int tick = data.ReadInt32(); - Server.worldData.mapData = new Dictionary(); - - int maps = data.ReadInt32(); - for (int i = 0; i < maps; i++) + var maps = new Dictionary(); + int mapCount = data.ReadInt32(); + for (int i = 0; i < mapCount; i++) { int mapId = data.ReadInt32(); - Server.worldData.mapData[mapId] = data.ReadPrefixedBytes(); + maps[mapId] = data.ReadPrefixedBytes()!; } - Server.worldData.savedGame = data.ReadPrefixedBytes(); - Server.worldData.sessionData = data.ReadPrefixedBytes(); - - if (Server.worldData.CreatingJoinPoint) - Server.worldData.EndJoinPointCreation(); - } - - [TypedPacketHandler] - public void HandleStandaloneWorldSnapshot(ClientStandaloneWorldSnapshotPacket packet) - { - if (!Server.IsStandaloneServer) - return; - - if (!Player.IsPlaying) - return; - - var accepted = Server.worldData.TryAcceptStandaloneWorldSnapshot(Player, packet.tick, - packet.worldData, packet.sessionData, packet.sha256Hash); + var game = data.ReadPrefixedBytes()!; + var session = data.ReadPrefixedBytes()!; - if (accepted) - { - ServerLog.Detail( - $"Accepted standalone world snapshot tick={packet.tick} from {Player.Username}"); - } - else + // The host's initial upload when hosting, there is no command log yet + if (Server.worldData.savedGame == null && !Server.IsStandaloneServer && Player.IsHost) { - ServerLog.Detail( - $"Rejected standalone world snapshot tick={packet.tick} from {Player.Username}"); + Server.worldData.mapData = maps; + Server.worldData.savedGame = game; + Server.worldData.sessionData = session; + return; } - } - [TypedPacketHandler] - public void HandleStandaloneMapSnapshot(ClientStandaloneMapSnapshotPacket packet) - { - if (!Server.IsStandaloneServer) - return; + // Anything else replacing the world without its command log would break every later joiner + Server.worldData.TryCompleteJoinPoint(Player, tick, maps, game, session); + } - if (!Player.IsPlaying) - return; + [FragmentedPacketHandler(Packets.Client_WorldDataUpload)] + public void HandleWorldDataUploadFragment(FragmentedPacket packet) => + Server.worldData.OnUploadProgress(Player); - var accepted = Server.worldData.TryAcceptStandaloneMapSnapshot(Player, packet.mapId, packet.tick, - packet.mapData, packet.sha256Hash); + // Standalone snapshots are taken from join point uploads (Client_WorldDataUpload). A snapshot on its own would + // replace the world without trimming the command log. + [TypedPacketHandler] + public void HandleStandaloneWorldSnapshot(ClientStandaloneWorldSnapshotPacket packet) => + ServerLog.Detail($"Ignored standalone world snapshot tick={packet.tick} from {Player.Username}"); - if (accepted) - { - ServerLog.Detail( - $"Accepted standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); - } - else - { - ServerLog.Detail( - $"Rejected standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); - } - } + [TypedPacketHandler] + public void HandleStandaloneMapSnapshot(ClientStandaloneMapSnapshotPacket packet) => + ServerLog.Detail($"Ignored standalone map snapshot map={packet.mapId} tick={packet.tick} from {Player.Username}"); [TypedPacketHandler] public void HandleCursor(ClientCursorPacket clientPacket) @@ -194,15 +203,13 @@ public void HandleClientKeepAlive(ClientKeepAlivePacket packet) [TypedPacketHandler] public void HandleDesyncCheck(ClientSyncInfoPacket packet) { - var arbiter = Server.ArbiterPlaying; - if (arbiter ? !Player.IsArbiter : !Player.IsHost) return; // policy + // Only the reference's opinions are kept and forwarded (arbiter, host, or the standalone reference) + var reference = Server.playerManager.SyncReference; + if (Player != reference) return; // policy - // Keep at most 10 sync infos - Server.worldData.syncInfos.Add(packet.rawSyncOpinion); - if (Server.worldData.syncInfos.Count > 10) - Server.worldData.syncInfos.RemoveAt(0); + Server.worldData.AddSyncInfo(packet.rawSyncOpinion); - foreach (var p in Server.PlayingPlayers.Where(p => !p.IsArbiter && (arbiter || !p.IsHost))) + foreach (var p in Server.PlayingPlayers.Where(p => p != reference && !p.IsArbiter)) p.conn.SendFragmented(new ServerSyncInfoPacket { rawSyncOpinion = packet.rawSyncOpinion }.Serialize()); } @@ -218,17 +225,20 @@ public void HandleFreeze(ClientFreezePacket packet) [TypedPacketHandler] public void HandleAutosaving(ClientAutosavingPacket packet) { - var forceJoinPoint = packet.reason == JoinPointRequestReason.Save; - ServerLog.Detail( $"Received Client_Autosaving from {Player.Username}, standalone={Server.IsStandaloneServer}, " + - $"isHost={Player.IsHost}, reason={packet.reason}, force={forceJoinPoint}"); + $"isHost={Player.IsHost}, reason={packet.reason}, tick={packet.tick}"); + + // Every client reloads at a join point (and the game pauses), so per-player world travel join points + // would interrupt everyone each time someone opens the world map. Standalone autosaves are server driven. + if (packet.reason != JoinPointRequestReason.Save) + return; - // On standalone, any playing client can trigger a join point (always, regardless of settings) + // On standalone, any playing client can request one (forced ones are rate limited) // On hosted, only the host can trigger and only if the Autosave flag is set if (Server.IsStandaloneServer || (Player.IsHost && Server.settings.autoJoinPoint.HasFlag(AutoJoinPointFlags.Autosave))) - Server.worldData.TryStartJoinPointCreation(forceJoinPoint, sourcePlayer: Player); + Server.worldData.TryStartJoinPointForSave(packet.tick, sourcePlayer: Player); } [TypedPacketHandler] @@ -237,7 +247,7 @@ public void HandleDebug(ClientDebugPacket _) if (!Server.commands.CanUseDevMode(Player)) return; - Server.worldData.mapCmds.Clear(); + Server.worldData.cmds.Clear(); Server.gameTimer = Server.startingTimer; Server.SendToPlaying(new ServerDebugPacket()); @@ -246,11 +256,16 @@ public void HandleDebug(ClientDebugPacket _) [TypedPacketHandler] public void HandleSetFaction(ClientSetFactionPacket packet) { - // todo restrict handling - int playerId = packet.playerId; int factionId = packet.factionId; + // Players switch their own faction; moving other players (factions window drag and drop) is host-only + if (playerId != Player.id && !Player.IsHost) + { + ServerLog.Log($"Ignoring faction change of player {playerId} requested by {Player.Username}"); + return; + } + var player = Server.GetPlayer(playerId); if (player == null) return; if (player.FactionId == factionId) return; @@ -259,7 +274,14 @@ public void HandleSetFaction(ClientSetFactionPacket packet) Server.SendToPlaying(new ServerSetFactionPacket(playerId, factionId)); } + public const float MaxFrameTime = 10_000f; + [TypedPacketHandler] - public void HandleFrameTime(ClientFrameTimePacket packet) => Player.frameTime = packet.frameTime; + public void HandleFrameTime(ClientFrameTimePacket packet) + { + // A NaN would end up in the server's time per tick and stop the simulation for good + var frameTime = packet.frameTime; + Player.frameTime = float.IsNaN(frameTime) ? 0f : Math.Max(0f, Math.Min(frameTime, MaxFrameTime)); + } } } diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index efa871704..e4c05eb35 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -62,20 +62,35 @@ public ServerPlayer OnConnected(ConnectionBase conn) return conn.serverPlayer; } + /// Drops a connection superseded by a new one from the same remote: its client is gone. Unlike Disconnect, + /// nothing is sent or closed. Steam carries all connections with a remote over one session, so the goodbye + /// would only reach the new client and closing would cut it off. + public void ReplaceStale(ServerPlayer stale, ConnectionBase replacement) + { + ServerLog.Log($"Replacing stale connection {stale.conn} with {replacement}"); + SetDisconnected(stale.conn, MpDisconnectReason.ClientLeft); + } + public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) { if (conn.State == ConnectionStateEnum.Disconnected) return; + server.CheckServerThread(); - conn.StateObj?.OnDisconnect(); + if (conn.StateObj is { } stateObj) + { + // OnDisconnect can synchronously resume the state's async flow (e.g. a pending init data request). + // Mark it dead first so that it stops instead of continuing on the dying connection. + stateObj.alive = false; + stateObj.OnDisconnect(); + } ServerPlayer player = conn.serverPlayer; Players.Remove(player); + server.worldData.OnPlayerUnavailable(player, "disconnected"); + UpdateSyncReference(); if (server.worldData.CreatingJoinPoint && (player.IsHost || !Players.Any(p => p.hasJoined))) - { - server.worldData.AbortJoinPointCreation(); - ServerLog.Log("Aborted join point creation because no players remain."); - } + server.worldData.AbortJoinPointCreation("The host left or no players remain."); if (player.hasJoined) { @@ -106,10 +121,49 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) ServerLog.Log($"Disconnected ({reason}): {conn}"); } + // The transport keeps a hung game connected. A playing client that doesn't answer keep alives anymore stalls + // everyone through its ticks behind, so drop it after a generous delay (loading and saving big games blocks + // the client's main thread for a while). + public const int StaleKeepAliveNetTicks = 10 * 60 * MultiplayerServer.NetTicksPerSecond; + + public void CheckTimeouts() + { + // Disconnecting modifies Players + foreach (var player in Players.ToArray()) + { + if (player.conn.State == ConnectionStateEnum.Disconnected) continue; + + if (player.conn.StateObj is AsyncConnectionState { WaitTimedOut: true } state) + { + ServerLog.Log($"{player.conn} timed out waiting for {state.AwaitedPacket} in {player.conn.State}"); + player.Disconnect("Timed out while joining the server"); + continue; + } + + // The hosting player is connected in memory + var localHost = player.IsHost && !server.IsStandaloneServer; + if (player.IsPlaying && player.status == PlayerStatus.Playing && !localHost && + server.NetTimer - player.keepAliveAt > StaleKeepAliveNetTicks) + { + ServerLog.Log($"{player.conn} stopped answering keep alives"); + player.Disconnect("Timed out: the game stopped responding"); + } + } + } + public void OnDesync(ServerPlayer player, int tick, int diffAt) { + // A desynced client reports once and then rejoins; ignore repeats (each one pauses and forces a join point) + if (player.status == PlayerStatus.Desynced) return; + player.UpdateStatus(PlayerStatus.Desynced); - server.HostPlayer.SendPacket(ServerTracesPacket.Request(tick, diffAt, player.id)); + server.worldData.OnPlayerUnavailable(player, "desynced"); + UpdateSyncReference(); + + // Traces come from the client whose opinions the desynced player was compared against (no host on standalone) + var traceSource = PlayingPlayers.FirstOrDefault(p => p.IsHost && p != player) ?? SyncReference; + if (traceSource != null && traceSource != player) + traceSource.SendPacket(ServerTracesPacket.Request(tick, diffAt, player.id)); player.ResetTimeVotes(); @@ -120,6 +174,52 @@ public void OnDesync(ServerPlayer player, int tick, int diffAt) server.worldData.TryStartJoinPointCreation(true); } + /// + /// The client treated as the truth: its sync opinions are forwarded to everyone else and it uploads join + /// points. The arbiter if playing, else the host; standalone has no host, so the longest connected player + /// which is caught up and in sync. + /// + public ServerPlayer? SyncReference + { + get + { + var arbiter = PlayingPlayers.FirstOrDefault(p => p.IsArbiter && p.status == PlayerStatus.Playing); + if (arbiter != null) + return arbiter; + + if (!server.IsStandaloneServer) + return PlayingPlayers.FirstOrDefault(p => p.IsHost); + + return PlayingPlayers + .Where(p => !p.IsArbiter && p.status == PlayerStatus.Playing) + .OrderBy(p => p.id) + .FirstOrDefault(); + } + } + + private int syncReferenceId = ScheduledCommand.NoPlayer; + + /// Call after player status changes and disconnects + public void UpdateSyncReference() + { + var reference = SyncReference; + var referenceId = reference?.id ?? ScheduledCommand.NoPlayer; + if (referenceId == syncReferenceId) return; + + var previous = GetPlayer(syncReferenceId); + syncReferenceId = referenceId; + ServerLog.Detail($"Sync reference changed to {reference?.Username ?? "none"}"); + + // Joiners compare their catch-up against these, keep only the current reference's opinions + server.worldData.syncInfos.Clear(); + + // Hosted clients know their role (host/arbiter) + if (!server.IsStandaloneServer) return; + + previous?.SendPacket(new ServerSyncReferencePacket(false)); + reference?.SendPacket(new ServerSyncReferencePacket(true)); + } + public static ColorRGB[] PlayerColors = { new(0,125,255), @@ -135,6 +235,7 @@ public void OnDesync(ServerPlayer player, int tick, int diffAt) public void OnJoin(ServerPlayer player) { + server.CheckServerThread(); player.hasJoined = true; var standalonePrimaryPlayer = server.IsStandaloneServer && !server.JoinedPlayers.Any(p => p != player && !p.IsArbiter); @@ -181,6 +282,7 @@ public void MakeHost(ServerPlayer host) host.SendPlayerList(); SendInitDataCommand(host); host.UpdateStatus(PlayerStatus.Playing); + UpdateSyncReference(); } public ServerPlayer? GetPlayer(string username) diff --git a/Source/Common/ScheduledCommand.cs b/Source/Common/ScheduledCommand.cs index 928334517..c0a5228bf 100644 --- a/Source/Common/ScheduledCommand.cs +++ b/Source/Common/ScheduledCommand.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; namespace Multiplayer.Common { @@ -65,5 +66,21 @@ public static byte[] SerializeCmds(List cmds) return writer.ToArray(); } + + /// + /// Old saves stored world and map commands in separate lists, so their relative order is lost. Merge them by + /// tick, keeping world commands first within a tick (best effort, the original order is unknown). + /// Logs in the current format keep all commands in the world list and are returned unchanged. + /// + public static List MergeLegacyCmds(List worldCmds, + IEnumerable> mapCmds) + { + var nonEmptyMapCmds = mapCmds.Where(cmds => cmds.Count > 0).ToList(); + if (nonEmptyMapCmds.Count == 0) + return worldCmds; + + // OrderBy is stable + return worldCmds.Concat(nonEmptyMapCmds.SelectMany(cmds => cmds)).OrderBy(cmd => cmd.ticks).ToList(); + } } } 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/ServerPlayer.cs b/Source/Common/ServerPlayer.cs index f1a231c97..9856fc672 100644 --- a/Source/Common/ServerPlayer.cs +++ b/Source/Common/ServerPlayer.cs @@ -15,7 +15,16 @@ public class ServerPlayer : IChatSource public bool simulating; public float frameTime; - public int ticksBehind; + // Reported by the client. It pauses and slows down the server for everyone, so keep it in a sane range + // (negative values sped the server up, huge ones overflowed ExtrapolatedTicksBehind). + public int ticksBehind + { + get => clampedTicksBehind; + set => clampedTicksBehind = Math.Max(0, Math.Min(value, MaxTicksBehind)); + } + private int clampedTicksBehind; + public const int MaxTicksBehind = 1 << 24; + public int ticksBehindReceivedAt; public int ExtrapolatedTicksBehind => ticksBehind + (Server.gameTimer - ticksBehindReceivedAt); diff --git a/Source/Common/StandalonePersistence.cs b/Source/Common/StandalonePersistence.cs index f7bcbd1e6..2fab7e008 100644 --- a/Source/Common/StandalonePersistence.cs +++ b/Source/Common/StandalonePersistence.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.IO.Compression; using System.Linq; +using System.Text; namespace Multiplayer.Common; @@ -16,7 +18,13 @@ public class StandalonePersistence private string MapsDir => Path.Combine(SavedDir, "maps"); private string WorldPath => Path.Combine(SavedDir, "world.dat"); - private string WorldCmdsPath => Path.Combine(SavedDir, "world_cmds.dat"); + // All cmds since the join point in send order. Replaces the legacy per-tickable world_cmds.dat + maps/N_cmds.dat. + private string CmdsPath => Path.Combine(SavedDir, "cmds.dat"); + private string LegacyWorldCmdsPath => Path.Combine(SavedDir, "world_cmds.dat"); + // The protocol of the server that wrote the command log. Sync data in commands is encoded differently across + // protocols and the log is only replayed with the same one. Removed before and written after the log and the + // world are written, so a log of an older version or from an interrupted write has none. + private string CmdsProtocolPath => Path.Combine(SavedDir, "cmds_protocol.txt"); private string SessionPath => Path.Combine(SavedDir, "session.dat"); private string InfoPath => Path.Combine(SavedDir, "info.xml"); private string StatePath => Path.Combine(SavedDir, "state.bin"); @@ -43,6 +51,7 @@ public bool HasValidState() public void SeedFromSaveZip(string zipPath) { EnsureDirectories(); + DeleteCmdsProtocol(); using var zip = ZipFile.OpenRead(zipPath); @@ -54,27 +63,32 @@ public void SeedFromSaveZip(string zipPath) AtomicWrite(WorldPath, Compress(worldBytes)); } - // World commands + // World commands (all commands for replays written by current versions) var worldCmdsEntry = zip.GetEntry("world/000_cmds"); - if (worldCmdsEntry != null) - AtomicWrite(WorldCmdsPath, ReadEntry(worldCmdsEntry)); + var worldCmds = worldCmdsEntry != null + ? ScheduledCommand.DeserializeCmds(ReadEntry(worldCmdsEntry)) + : new List(); + var mapCmds = new List>(); - // Map saves and commands + // Map saves and commands of the first section foreach (var entry in zip.Entries) { if (!entry.FullName.StartsWith("maps/")) continue; var parts = entry.FullName.Replace("maps/", "").Split('_'); - if (parts.Length < 3) continue; + if (parts.Length < 3 || parts[0] != "000") continue; if (!int.TryParse(parts[1], out int mapId)) continue; if (entry.FullName.EndsWith("_save")) AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), Compress(ReadEntry(entry))); else if (entry.FullName.EndsWith("_cmds")) - AtomicWrite(Path.Combine(MapsDir, $"{mapId}_cmds.dat"), ReadEntry(entry)); + mapCmds.Add(ScheduledCommand.DeserializeCmds(ReadEntry(entry))); } + AtomicWrite(CmdsPath, ScheduledCommand.SerializeCmds(ScheduledCommand.MergeLegacyCmds(worldCmds, mapCmds))); + DeleteLegacyCmdFiles(); + // Info/metadata var infoEntry = zip.GetEntry("info"); if (infoEntry != null) @@ -84,11 +98,14 @@ public void SeedFromSaveZip(string zipPath) try { - WritePersistedTick(GetLatestTick(ReplayInfo.Read(infoBytes))); + var info = ReplayInfo.Read(infoBytes); + WritePersistedTick(GetLatestTick(info)); + // The replay's commands were written by its protocol + WriteCmdsProtocol(info.protocol); } catch (Exception e) { - ServerLog.Error($"Failed to seed persisted tick from info.xml: {e.Message}"); + ServerLog.Error($"Failed to seed persisted tick and protocol from info.xml: {e.Message}"); } } @@ -119,34 +136,10 @@ public void SeedFromSaveZip(string zipPath) if (fileName.EndsWith("_cmds")) continue; if (int.TryParse(fileName, out int mapId)) - { server.worldData.mapData[mapId] = File.ReadAllBytes(mapFile); - - var cmdsPath = Path.Combine(MapsDir, $"{mapId}_cmds.dat"); - if (File.Exists(cmdsPath)) - { - server.worldData.mapCmds[mapId] = ScheduledCommand.DeserializeCmds(File.ReadAllBytes(cmdsPath)) - .Select(ScheduledCommand.Serialize).ToList(); - } - else - { - server.worldData.mapCmds[mapId] = new List(); - } - } } } - // Load world commands - if (File.Exists(WorldCmdsPath)) - { - server.worldData.mapCmds[-1] = ScheduledCommand.DeserializeCmds(File.ReadAllBytes(WorldCmdsPath)) - .Select(ScheduledCommand.Serialize).ToList(); - } - else - { - server.worldData.mapCmds[-1] = new List(); - } - // Load replay info for metadata ReplayInfo? info = null; if (File.Exists(InfoPath)) @@ -156,6 +149,7 @@ public void SeedFromSaveZip(string zipPath) } var loadedTick = ReadPersistedTick() ?? GetLatestTick(info); + server.worldData.cmds = LoadCmds(); server.gameTimer = loadedTick; server.startingTimer = loadedTick; server.worldData.standaloneWorldSnapshot.tick = loadedTick; @@ -175,45 +169,57 @@ public void SeedFromSaveZip(string zipPath) public void WriteJoinPoint(WorldData worldData, int tick) { EnsureDirectories(); + DeleteCmdsProtocol(); if (worldData.savedGame != null) AtomicWrite(WorldPath, worldData.savedGame); AtomicWrite(SessionPath, worldData.sessionData ?? Array.Empty()); - AtomicWrite(WorldCmdsPath, SerializeStoredCmds(worldData.mapCmds.GetValueOrDefault(ScheduledCommand.Global) ?? [])); var currentMapIds = worldData.mapData.Keys.ToHashSet(); DeleteStaleMapFiles(currentMapIds); foreach (var (mapId, mapData) in worldData.mapData) - { AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), mapData); - AtomicWrite( - Path.Combine(MapsDir, $"{mapId}_cmds.dat"), - SerializeStoredCmds(worldData.mapCmds.GetValueOrDefault(mapId) ?? [])); - } + + // cmds.dat takes precedence over the legacy files, so they are only deleted once it exists + AtomicWrite(CmdsPath, SerializeStoredCmds(worldData.cmds)); + DeleteLegacyCmdFiles(); WritePersistedTick(tick); + WriteCmdsProtocol(MpVersion.Protocol); } - /// - /// Write an accepted map snapshot to disk atomically. - /// - public void WriteMapSnapshot(int mapId, byte[] compressedMapData) + /// The command log is only replayed when this protocol wrote it. Commands of another protocol can be misread: + /// they then do something else than what was played, fail halfway or depend on client local state, and the + /// clients desync. Without them every client continues from the same saved world, which only loses the progress + /// since that save. Logs from before cmds.dat (world_cmds.dat, maps/N_cmds.dat) always come from older protocols. + private List LoadCmds() { - EnsureDirectories(); - AtomicWrite(Path.Combine(MapsDir, $"{mapId}.dat"), compressedMapData); - } + var protocol = ReadCmdsProtocol(); + if (protocol == MpVersion.Protocol) + return File.Exists(CmdsPath) + ? ScheduledCommand.DeserializeCmds(File.ReadAllBytes(CmdsPath)).Select(ScheduledCommand.Serialize).ToList() + : new List(); + + var cmdFiles = new[] { CmdsPath, LegacyWorldCmdsPath } + .Concat(Directory.Exists(MapsDir) ? Directory.GetFiles(MapsDir, "*_cmds.dat") : []); + // Every format starts with the command count + var discarded = cmdFiles.Where(File.Exists).Select(File.ReadAllBytes) + .Sum(data => data.Length >= sizeof(int) ? Math.Max(0L, BitConverter.ToInt32(data, 0)) : 0L); + + if (discarded > 0) + { + var writer = protocol == null + ? "an older version of the server (or its write was interrupted)" + : $"protocol {protocol}"; + ServerLog.Error($"Discarding the saved command log ({discarded} commands): it was written by {writer} " + + $"and this server (protocol {MpVersion.Protocol}) could misread it, replaying misread " + + "commands desyncs. The game continues from the last saved world, the progress since " + + "that save is lost."); + } - /// - /// Write an accepted world snapshot to disk atomically. - /// - public void WriteWorldSnapshot(byte[] compressedWorldData, byte[] sessionData, int tick) - { - EnsureDirectories(); - AtomicWrite(WorldPath, compressedWorldData); - AtomicWrite(SessionPath, sessionData); - WritePersistedTick(tick); + return new List(); } /// @@ -284,11 +290,43 @@ private void DeleteStaleMapFiles(HashSet currentMapIds) } } + private void DeleteLegacyCmdFiles() + { + if (File.Exists(LegacyWorldCmdsPath)) + File.Delete(LegacyWorldCmdsPath); + + if (!Directory.Exists(MapsDir)) + return; + + foreach (var path in Directory.GetFiles(MapsDir, "*_cmds.dat")) + File.Delete(path); + } + private void WritePersistedTick(int tick) { AtomicWrite(StatePath, BitConverter.GetBytes(tick)); } + private void WriteCmdsProtocol(int protocol) + { + AtomicWrite(CmdsProtocolPath, Encoding.UTF8.GetBytes(protocol.ToString(CultureInfo.InvariantCulture))); + } + + private void DeleteCmdsProtocol() + { + if (File.Exists(CmdsProtocolPath)) + File.Delete(CmdsProtocolPath); + } + + private int? ReadCmdsProtocol() + { + if (!File.Exists(CmdsProtocolPath)) + return null; + + return int.TryParse(File.ReadAllText(CmdsProtocolPath).Trim(), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var protocol) ? protocol : null; + } + private int? ReadPersistedTick() { if (!File.Exists(StatePath)) diff --git a/Source/Common/Syncing/SyncTypeHelper.cs b/Source/Common/Syncing/SyncTypeHelper.cs index 21c141470..ad94069fc 100644 --- a/Source/Common/Syncing/SyncTypeHelper.cs +++ b/Source/Common/Syncing/SyncTypeHelper.cs @@ -12,7 +12,12 @@ public Type GetImplementationByIndex(Type baseType, ushort index) public ushort GetIndexFromImplementation(Type baseType, Type type) { - return (ushort)GetImplementations(baseType).IndexOf(type); + // Writing ushort.MaxValue (the null marker) followed by the object's data would misalign the reader + int index = GetImplementations(baseType).IndexOf(type); + if (index < 0 || index >= ushort.MaxValue) + throw new Multiplayer.Client.SerializationException($"Type {type} isn't a known implementation of {baseType}"); + + return (ushort)index; } public abstract List GetImplementations(Type baseType); diff --git a/Source/Common/Syncing/Worker/ReadingSyncWorker.cs b/Source/Common/Syncing/Worker/ReadingSyncWorker.cs index 204bf3797..6de87d616 100644 --- a/Source/Common/Syncing/Worker/ReadingSyncWorker.cs +++ b/Source/Common/Syncing/Worker/ReadingSyncWorker.cs @@ -8,8 +8,6 @@ namespace Multiplayer.Client public class ReadingSyncWorker(ByteReader reader, SyncSerialization serialization) : SyncWorker(false) { internal readonly ByteReader reader = reader; - readonly int initialPos = reader.Position; - public ByteReader Reader => reader; public override void Bind(ref T obj, SyncType type) @@ -96,9 +94,11 @@ public override void BindType(ref Type type) type = serialization.TypeHelper.GetImplementationByIndex(typeof(T), reader.ReadUShort()); } - internal void Reset() + internal int Position => reader.Position; + + internal void Reset(int position) { - reader.Seek(initialPos); + reader.Seek(position); } } } diff --git a/Source/Common/Syncing/Worker/SyncWorkerEntry.cs b/Source/Common/Syncing/Worker/SyncWorkerEntry.cs index 739eaefe1..6b82a75d3 100644 --- a/Source/Common/Syncing/Worker/SyncWorkerEntry.cs +++ b/Source/Common/Syncing/Worker/SyncWorkerEntry.cs @@ -96,14 +96,23 @@ public bool Invoke(SyncWorker worker, ref object? obj) { parent?.Invoke(worker, ref obj); + // A worker which returns false gets rolled back to this point, i.e. after the parent's data. Rolling back + // further would drop the parent's data on the writer but not on the reader. + int start = worker switch + { + ReadingSyncWorker reader => reader.Position, + WritingSyncWorker writer => writer.Position, + _ => 0 + }; + for (int i = 0; i < syncWorkers.Count; i++) { if (syncWorkers[i](worker, ref obj)) return true; if (worker is ReadingSyncWorker reader) { - reader.Reset(); + reader.Reset(start); } else if (worker is WritingSyncWorker writer) { - writer.Reset(); + writer.Reset(start); } } diff --git a/Source/Common/Syncing/Worker/WritingSyncWorker.cs b/Source/Common/Syncing/Worker/WritingSyncWorker.cs index 4acd24cae..2509e0bb4 100644 --- a/Source/Common/Syncing/Worker/WritingSyncWorker.cs +++ b/Source/Common/Syncing/Worker/WritingSyncWorker.cs @@ -8,8 +8,6 @@ namespace Multiplayer.Client public class WritingSyncWorker(ByteWriter writer, SyncSerialization serialization) : SyncWorker(true) { internal readonly ByteWriter writer = writer; - readonly int initialPos = writer.Position; - public ByteWriter Writer => writer; public override void Bind(ref T obj, SyncType type) @@ -95,9 +93,11 @@ public override void BindType(ref Type type) writer.WriteUShort(serialization.TypeHelper.GetIndexFromImplementation(typeof(T), type)); } - internal void Reset() + internal int Position => writer.Position; + + internal void Reset(int position) { - writer.SetLength(initialPos); + writer.SetLength(position); } } } diff --git a/Source/Common/Util/Extensions.cs b/Source/Common/Util/Extensions.cs index 332a96778..ad45cfdef 100644 --- a/Source/Common/Util/Extensions.cs +++ b/Source/Common/Util/Extensions.cs @@ -59,6 +59,9 @@ public static T[] SubArray(this T[] data, int index) public static T[] SubArray(this T[] data, int index, int length) { + if (index < 0 || length < 0 || index > data.Length - length) + throw new ArgumentOutOfRangeException(nameof(length), $"Sub array {index}+{length} out of range ({data.Length})"); + T[] result = new T[length]; Array.Copy(data, index, result, 0, length); return result; @@ -133,6 +136,18 @@ public static float MaxOrZero(this IEnumerable items, Func map) public static T? ResultNowOrNull(this Task task) where T : class? => task.Status == TaskStatus.RanToCompletion ? task.Result : null; + /// Keeps the first and the last lines of a long text and notes how many lines were left out in between + public static string KeepHeadAndTailLines(this string text, int headLines, int tailLines) + { + var lines = text.Split('\n'); + var omitted = lines.Length - headLines - tailLines; + if (omitted <= 0) return text; + + return string.Join("\n", lines, 0, headLines) + + $"\n(... {omitted:N0} lines omitted ...)\n" + + string.Join("\n", lines, lines.Length - tailLines, tailLines); + } + public static string RemovePrefix(this string str, string? prefix) { if (prefix == null) return str; @@ -159,6 +174,27 @@ public static byte[] GetMD5(IEnumerable strings) } public static long MillisNow => DateTime.Now.Ticks / TimeSpan.TicksPerMillisecond; + + // Rewrites CRLF and lone CR as LF in place and returns the new length. Byte-wise, so it's meant for hashing + // text files that may come from another OS, not for decoding them + public static int NormalizeLineEndingsInPlace(byte[] data) + { + int len = 0; + for (int i = 0; i < data.Length; i++) + { + byte b = data[i]; + if (b == '\r') + { + if (i + 1 < data.Length && data[i + 1] == '\n') + i++; + b = (byte)'\n'; + } + + data[len++] = b; + } + + return len; + } } } 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/Common/WorldData.cs b/Source/Common/WorldData.cs index 26e7b596f..fccda65f7 100644 --- a/Source/Common/WorldData.cs +++ b/Source/Common/WorldData.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Security.Cryptography; using System.Threading.Tasks; +using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common; @@ -14,18 +15,43 @@ public class WorldData public byte[]? sessionData; // Compressed semi persistent data public Dictionary mapData = new(); // Map id to compressed map data - public Dictionary> mapCmds = new(); // Map id to serialized cmds list - public Dictionary>? tmpMapCmds; + // Serialized cmds since the last join point, in the order they were sent to clients. Live clients execute + // commands in arrival order, so joiners must get exactly this order (not grouped by map). + public List cmds = new(); + public List? tmpCmds; public int lastJoinPointAtTick = -1; + // The sync reference's newest opinions (oldest first). Joiners only use them to check their catch-up + // simulation, after that they get the live ones, so losing the oldest ones only skips a few checks. public List syncInfos = new(); + public const int MaxSyncInfos = 10; + // With desync traces on, one opinion can be a few MiB on big saves and they all go into the world data packet + public const int MaxSyncInfosLength = ClientSyncInfoPacket.MaxLength; public StandaloneWorldSnapshotState standaloneWorldSnapshot = new(); public Dictionary standaloneMapSnapshots = new(); private TaskCompletionSource? dataSource; - public bool CreatingJoinPoint => tmpMapCmds != null; + // The join point being created: the tick of its CreateJoinPoint command and the only player whose upload + // completes it + public int JoinPointTick { get; private set; } = -1; + public int JoinPointUploaderId { get; private set; } = ScheduledCommand.NoPlayer; + // The game timer of the last completed join point's save + public int CompletedJoinPointTick { get; private set; } = -1; + private int joinPointProgressAt; // NetTimer + private int? lastForcedJoinPointAt; // NetTimer + private int standaloneAutosaveBaseTick = -1; + + // Every client reloads at a join point and the game pauses meanwhile, so forced requests (saves, desyncs) are + // rate limited. Abort if the uploader shows no progress, joiners would wait forever otherwise. + public const int ForcedJoinPointCooldown = MultiplayerServer.NetTicksPerSecond * 10; + public const int JoinPointTimeout = MultiplayerServer.NetTicksPerSecond * 60; + // Keep alives are answered several times a second unless the client's main thread is blocked + public const int UploaderBusyAfter = MultiplayerServer.NetTicksPerSecond * 5; + private const int TicksPerMinute = 60 * 60; + + public bool CreatingJoinPoint => tmpCmds != null; public MultiplayerServer Server { get; } @@ -36,6 +62,8 @@ public WorldData(MultiplayerServer server) private int CurrentJoinPointTick => Server.IsStandaloneServer ? Server.gameTimer : Server.workTicks; + /// sourcePlayer is the requester, only used for logging: the upload always comes from the sync reference + /// (host/arbiter, or the longest connected player on standalone), the client every other one is compared against. public bool TryStartJoinPointCreation(bool force = false, ServerPlayer? sourcePlayer = null) { int currentTick = CurrentJoinPointTick; @@ -52,37 +80,92 @@ public bool TryStartJoinPointCreation(bool force = false, ServerPlayer? sourcePl return false; } - var issuingPlayer = sourcePlayer; - if (Server.IsStandaloneServer && issuingPlayer == null) + if (force && Server.NetTimer - lastForcedJoinPointAt < ForcedJoinPointCooldown) { - issuingPlayer = Server.PlayingPlayers.FirstOrDefault(player => player.IsHost) - ?? Server.PlayingPlayers.FirstOrDefault(); + ServerLog.Detail($"Join point skipped: forced join point rate limited (requested by {sourcePlayer?.Username})"); + return false; + } - if (issuingPlayer == null) - { - ServerLog.Detail("Join point skipped: no playing player available for standalone creation"); - return false; - } + var uploader = Server.playerManager.SyncReference; + if (uploader == null) + { + ServerLog.Detail("Join point skipped: no playing player available to upload it"); + return false; } - ServerLog.Detail($"Join point started at tick={currentTick}, force={force}, standalone={Server.IsStandaloneServer}"); + ServerLog.Detail($"Join point started at tick={Server.gameTimer}, force={force}, uploader={uploader.Username}, " + + $"requested by {sourcePlayer?.Username}, standalone={Server.IsStandaloneServer}"); Server.SendChat("Creating a join point..."); - Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, Array.Empty(), - sourcePlayer: Server.IsStandaloneServer ? issuingPlayer : null); - tmpMapCmds = new Dictionary>(); + JoinPointTick = Server.gameTimer; + JoinPointUploaderId = uploader.id; + joinPointProgressAt = Server.NetTimer; + if (force) + lastForcedJoinPointAt = Server.NetTimer; + + // Every client reloads when executing it, only the uploader sends the save back + Server.commands.Send(CommandType.CreateJoinPoint, ScheduledCommand.NoFaction, ScheduledCommand.Global, + ByteWriter.GetBytes(uploader.id)); + tmpCmds = new List(); dataSource = new TaskCompletionSource(); return true; } + /// A client saved the game at savedAtTick and wants the server's world to be at least as new. With in-game day + /// autosaves every client saves at the same tick and asks once its save is written, slow clients only after the + /// join point is done, so requests for a tick a join point already covers are ignored. + public bool TryStartJoinPointForSave(int savedAtTick, ServerPlayer? sourcePlayer = null) + { + var coveredUntil = Math.Max(CompletedJoinPointTick, JoinPointTick); + if (savedAtTick <= coveredUntil) + { + ServerLog.Detail($"Join point skipped: the save of {sourcePlayer?.Username} at tick={savedAtTick} is " + + $"covered by the join point at tick={coveredUntil}"); + return false; + } + + return TryStartJoinPointCreation(true, sourcePlayer); + } + + /// Accepts the uploaded world only if it belongs to the join point being created. World, maps and cmds are + /// replaced together so the stored save and its command log always match. + public bool TryCompleteJoinPoint(ServerPlayer player, int tick, Dictionary uploadedMaps, + byte[] uploadedGame, byte[] uploadedSession) + { + if (!CreatingJoinPoint) + { + ServerLog.Detail($"Rejected world upload from {player.Username}: no join point in progress"); + return false; + } + + if (player.id != JoinPointUploaderId || tick != JoinPointTick) + { + ServerLog.Detail($"Rejected world upload from {player.Username} at tick={tick}: expected " + + $"player {JoinPointUploaderId} at tick={JoinPointTick}"); + return false; + } + + mapData = uploadedMaps; + savedGame = uploadedGame; + sessionData = uploadedSession; + + if (Server.IsStandaloneServer) + RecordStandaloneSnapshot(player, tick); + + EndJoinPointCreation(); + return true; + } + public void EndJoinPointCreation() { int currentTick = CurrentJoinPointTick; ServerLog.Detail($"Join point completed at tick={currentTick}, standalone={Server.IsStandaloneServer}"); - mapCmds = tmpMapCmds!; - tmpMapCmds = null; + cmds = tmpCmds!; + tmpCmds = null; lastJoinPointAtTick = currentTick; + CompletedJoinPointTick = JoinPointTick; + ClearPendingJoinPoint(); if (Server.IsStandaloneServer && Server.persistence != null) { @@ -100,72 +183,130 @@ public void EndJoinPointCreation() dataSource = null; } - public void AbortJoinPointCreation() + /// The previous world and the full command log (including the CreateJoinPoint command, which joiners replay + /// as a reload like live clients did) stay valid, so waiting joiners continue with those. + public void AbortJoinPointCreation(string reason = "") { if (!CreatingJoinPoint) return; - tmpMapCmds = null; + ServerLog.Log($"Aborted join point creation at tick={JoinPointTick}. {reason}"); + + tmpCmds = null; + ClearPendingJoinPoint(); dataSource?.SetResult(this); dataSource = null; } - public Task WaitJoinPoint() + /// Called when the expected uploader disconnects, desyncs or starts rejoining + public void OnPlayerUnavailable(ServerPlayer player, string reason) { - return dataSource?.Task ?? Task.FromResult(this); + if (CreatingJoinPoint && player.id == JoinPointUploaderId) + AbortJoinPointCreation($"The uploader {player.Username} {reason}."); } - public bool TryAcceptStandaloneWorldSnapshot(ServerPlayer player, int tick, byte[] worldSnapshot, - byte[] sessionSnapshot, byte[] expectedHash) + public void OnUploadProgress(ServerPlayer player) { - if (tick < standaloneWorldSnapshot.tick) - return false; + if (CreatingJoinPoint && player.id == JoinPointUploaderId) + joinPointProgressAt = Server.NetTimer; + } - var actualHash = ComputeHash(worldSnapshot, sessionSnapshot); - if (expectedHash.Length > 0 && !actualHash.AsSpan().SequenceEqual(expectedHash)) - return false; + private void ClearPendingJoinPoint() + { + JoinPointTick = -1; + JoinPointUploaderId = ScheduledCommand.NoPlayer; + } - savedGame = worldSnapshot; - sessionData = sessionSnapshot; - standaloneWorldSnapshot = new StandaloneWorldSnapshotState + public void AddSyncInfo(byte[] rawSyncOpinion) + { + syncInfos.Add(rawSyncOpinion); + + long length = syncInfos.Sum(info => (long)info.Length); + while (syncInfos.Count > MaxSyncInfos || syncInfos.Count > 1 && length > MaxSyncInfosLength) { - tick = tick, - producerPlayerId = player.id, - producerUsername = player.Username, - sha256Hash = actualHash - }; + length -= syncInfos[0].Length; + syncInfos.RemoveAt(0); + } + } - // Persist to disk - Server.persistence?.WriteWorldSnapshot(worldSnapshot, sessionSnapshot, tick); + /// The newest opinions whose length prefixed size fits into maxLength, oldest first + public List NewestSyncInfosFitting(long maxLength) + { + int start = syncInfos.Count; + long length = 0; + while (start > 0 && length + sizeof(int) + syncInfos[start - 1].Length <= maxLength) + length += sizeof(int) + syncInfos[--start].Length; - return true; + return syncInfos.GetRange(start, syncInfos.Count - start); } - public bool TryAcceptStandaloneMapSnapshot(ServerPlayer player, int mapId, int tick, - byte[] mapSnapshot, byte[] expectedHash) + public Task WaitJoinPoint() { - if (mapId < 0) - return false; + return dataSource?.Task ?? Task.FromResult(this); + } - var snapshotState = standaloneMapSnapshots.GetOrAddNew(mapId); - if (tick < snapshotState.tick) - return false; + public void Tick() + { + CheckJoinPointTimeout(Server.NetTimer); + TickStandaloneAutosave(); + } - var actualHash = ComputeHash(mapSnapshot); - if (expectedHash.Length > 0 && !actualHash.AsSpan().SequenceEqual(expectedHash)) - return false; + public void CheckJoinPointTimeout(int netTimer) + { + if (!CreatingJoinPoint) return; - mapData[mapId] = mapSnapshot; - snapshotState.tick = tick; - snapshotState.producerPlayerId = player.id; - snapshotState.producerUsername = player.Username; - snapshotState.sha256Hash = actualHash; - standaloneMapSnapshots[mapId] = snapshotState; + // Saving and reloading for the join point blocks the uploader's main thread, for minutes with big games, so + // it doesn't answer keep alives meanwhile. That's progress too: a client that hangs for good is dropped by + // the stale keep alive timeout (PlayerManager.CheckTimeouts), which aborts the join point. + if (Server.GetPlayer(JoinPointUploaderId) is { } uploader && netTimer - uploader.keepAliveAt > UploaderBusyAfter) + joinPointProgressAt = netTimer; - // Persist to disk - Server.persistence?.WriteMapSnapshot(mapId, mapSnapshot); + if (netTimer - joinPointProgressAt > JoinPointTimeout) + AbortJoinPointCreation("Timed out waiting for the world upload."); + } - return true; + /// Standalone autosaves are join points issued by the server at a tick, so every client reloads at the same + /// moment and the persisted save matches its command log. Per-client timers would create a join point per client. + /// In-game day intervals are only known to clients (deterministically), see ConstantTicker.TickAutosave. + private void TickStandaloneAutosave() + { + var settings = Server.settings; + if (!Server.IsStandaloneServer || settings.autosaveUnit != AutosaveUnit.Minutes || settings.autosaveInterval <= 0) + return; + + if (standaloneAutosaveBaseTick < 0) + standaloneAutosaveBaseTick = Server.gameTimer; + + var since = Math.Max(standaloneAutosaveBaseTick, lastJoinPointAtTick); + if (CreatingJoinPoint || Server.gameTimer - since < settings.autosaveInterval * TicksPerMinute) + return; + + // Wait another interval if it can't start now + standaloneAutosaveBaseTick = Server.gameTimer; + TryStartJoinPointCreation(); + } + + private void RecordStandaloneSnapshot(ServerPlayer player, int tick) + { + standaloneWorldSnapshot = new StandaloneWorldSnapshotState + { + tick = tick, + producerPlayerId = player.id, + producerUsername = player.Username, + sha256Hash = ComputeHash(savedGame!, sessionData!) + }; + + standaloneMapSnapshots.Clear(); + foreach (var (mapId, data) in mapData) + { + standaloneMapSnapshots[mapId] = new StandaloneMapSnapshotState + { + tick = tick, + producerPlayerId = player.id, + producerUsername = player.Username, + sha256Hash = ComputeHash(data) + }; + } } private static byte[] ComputeHash(params byte[][] payloads) diff --git a/Source/MultiplayerLoader/SyncRituals.cs b/Source/MultiplayerLoader/SyncRituals.cs index 24a1c4af6..2dfd4520c 100644 --- a/Source/MultiplayerLoader/SyncRituals.cs +++ b/Source/MultiplayerLoader/SyncRituals.cs @@ -13,24 +13,64 @@ namespace MultiplayerLoader; public static class SyncRituals { private static List syncMethodsWithPrepatches = new(); + // Indexed like syncMethodsWithPrepatches + private static List> beforeExecutingWithPrepatches = new(); public static bool PrepatcherPrefix(int index, object __instance, object[] __args) { // This prefix is applied to a generic method and called by all possible instantiations // This check makes sure the type is correct if (syncMethodsWithPrepatches[index].TargetType == __instance.GetType()) - return !syncMethodsWithPrepatches[index].DoSync(__instance, __args); + { + if (syncMethodsWithPrepatches[index].DoSync(__instance, __args)) + return false; + + // Also runs for calls nested in another synced method + if (MP.IsExecutingSyncCommand) + beforeExecutingWithPrepatches[index]?.Invoke(__instance); + } return true; } private static MethodInfo syncMethod = AccessTools.Method("Multiplayer.Client.Sync:Method"); + /// + /// Whether the methods below run the sync prefix, i.e. Prepatcher ran the first pass. Prepatcher is a mod + /// dependency but can be missing or fail to load (e.g. on macOS). Then the role assignments of ritual dialogs + /// stay local. Set by the second pass from the patched code itself: a flag set by the first pass is lost because + /// Prepatcher restarts the game with the patched assemblies. + /// + public static bool PrepatchesApplied { get; private set; } + + private static bool HasPrepatcherPrefix(MethodBase method) + { + try + { + // By name: the patched assembly refers to this method through its own assembly reference + return PatchProcessor.ReadMethodBody(method).Any(inst => + inst.Value is MethodInfo m && + m.Name == nameof(PrepatcherPrefix) && + m.DeclaringType?.FullName == typeof(SyncRituals).FullName); + } + catch (Exception e) + { + Verse.Log.Warning($"Multiplayer: couldn't check whether Prepatcher patched {method}: {e}"); + return false; + } + } + // This method is called twice (two passes) // The first pass with module != null is called from Prepatcher and applies prepatches // The second pass with module == null is called from Sync initialization and registers SyncMethods public static void ApplyPrepatches(ModuleDefinition module) { - void Register(Type baseType, string method, Type derivedType, Action postProcess = null) + if (module == null) + PrepatchesApplied = HasPrepatcherPrefix(AccessTools.Method( + typeof(PawnRoleSelectionWidgetBase), + nameof(PawnRoleSelectionWidgetBase.TryAssignReplace))); + + void Register(Type baseType, string method, Type derivedType, Action postProcess = null, + Action beforeExecuting = null) { if (module != null) { @@ -51,6 +91,8 @@ void Register(Type baseType, string method, Type derivedType, Action m.TransformArgument(1, psychicRitualRolesSerializer)); Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnRitualRoleSelectionWidget)); + typeof(PawnRitualRoleSelectionWidget), + beforeExecuting: target => RefreshRoleGroups((PawnRitualRoleSelectionWidget)target)); Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnPsychicRitualRoleSelectionWidget)); + typeof(PawnPsychicRitualRoleSelectionWidget), + beforeExecuting: target => RefreshRoleGroups((PawnPsychicRitualRoleSelectionWidget)target)); Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), typeof(PawnRitualRoleSelectionWidget), @@ -118,6 +162,17 @@ void Register(Type baseType, string method, Type derivedType, Action(PawnRoleSelectionWidgetBase widget) + where RoleType : class, ILordJobRole + { + var groups = PawnRoleSelectionWidgetBase.rolesGroupedTmp; + groups.Clear(); + groups.AddRange(widget.assignments.RoleGroups()); + } + private static (WidgetType roleSelectionWidget, string Key) RitualRoleWriter(IEnumerable roles, object target, object[] _) where WidgetType : PawnRoleSelectionWidgetBase where RoleType : class, ILordJobRole diff --git a/Source/Server/Server.cs b/Source/Server/Server.cs index 30890e715..754493b2a 100644 --- a/Source/Server/Server.cs +++ b/Source/Server/Server.cs @@ -82,6 +82,12 @@ server.BootstrapMode = bootstrap; +// The loaded game's viewer counts belong to players of an earlier session, who are not connected anymore. Runs on the +// server thread before any player can connect, so it's the first command everyone executes after the loaded state. +if (!bootstrap) + server.Enqueue(() => server.commands.Send(CommandType.ResetPlayerCounts, ScheduledCommand.NoFaction, + ScheduledCommand.Global, [])); + if (settings.direct) { var badEndpoint = settings.TryParseEndpoints(out var endpoints); if (badEndpoint != null) diff --git a/Source/Tests/ChatCommandManagerTest.cs b/Source/Tests/ChatCommandManagerTest.cs index 6dc33c554..516b4417a 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, @@ -483,7 +485,7 @@ public void TimeControlCommands_RespectHostOnlyTimeControlSetting() server.HandleChatCommand(guest, "pause"); - Assert.That(server.worldData.mapCmds.ContainsKey(ScheduledCommand.Global), Is.False); + Assert.That(server.worldData.cmds, Is.Empty); Assert.That(((RecordingConnection)guest.conn).ChatMessages, Does.Contain("No permission")); } @@ -565,7 +567,7 @@ private static ServerPlayer AddPlayingPlayer( private static ScheduledCommand LastGlobalCommand(MultiplayerServer server) { - return ScheduledCommand.Deserialize(new ByteReader(server.worldData.mapCmds[ScheduledCommand.Global].Last())); + return ScheduledCommand.Deserialize(new ByteReader(server.worldData.cmds.Last(cmd => ScheduledCommand.Deserialize(new ByteReader(cmd)).mapId == ScheduledCommand.Global))); } private static ClientInitDataPacket.ModData ModData(string name, string packageId) diff --git a/Source/Tests/CommandLogTest.cs b/Source/Tests/CommandLogTest.cs new file mode 100644 index 000000000..2c231bc21 --- /dev/null +++ b/Source/Tests/CommandLogTest.cs @@ -0,0 +1,196 @@ +using System.Collections.Generic; +using System.Linq; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +/// Joiners must execute exactly the command sequence live clients executed (same order across tickables) +[TestFixture] +public class CommandLogTest +{ + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + nextPlayerId = 0; + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private (ServerPlayer player, RecordingConnection conn) AddPlayer(string username, ConnectionStateEnum state) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn) { hasJoined = true }; + conn.serverPlayer = player; + conn.ChangeState(state); + server.playerManager.Players.Add(player); + return (player, conn); + } + + private void SendCmd(int mapId, byte marker) => + server.commands.Send(CommandType.Sync, 0, mapId, [marker]); + + private static List<(int mapId, byte marker)> Decode(IEnumerable cmds) => + cmds.Select(raw => ScheduledCommand.Deserialize(new ByteReader(raw))) + .Where(cmd => cmd.type == CommandType.Sync) + .Select(cmd => (cmd.mapId, cmd.data[0])) + .ToList(); + + [Test] + public void CommandsAreLoggedInSendOrderAcrossTickables() + { + var (_, conn) = AddPlayer("live", ConnectionStateEnum.ServerPlaying); + + SendCmd(5, 1); + SendCmd(ScheduledCommand.Global, 2); + SendCmd(3, 3); + SendCmd(5, 4); + + var expected = new List<(int, byte)> { (5, 1), (ScheduledCommand.Global, 2), (3, 3), (5, 4) }; + Assert.That(Decode(server.worldData.cmds), Is.EqualTo(expected)); + + // Live clients receive the same order + var live = conn.SentMessages + .Where(m => m.id == Packets.Server_Command) + .Select(m => ScheduledCommand.Serialize(ToCmd(m.data))); + Assert.That(Decode(live), Is.EqualTo(expected)); + } + + [Test] + public void WorldDataContainsAllCommandsInLogOrder() + { + server.worldData.savedGame = [9]; + server.worldData.sessionData = [8]; + AddPlayer("live", ConnectionStateEnum.ServerPlaying); + + SendCmd(5, 1); + SendCmd(ScheduledCommand.Global, 2); + SendCmd(3, 3); + + var (joiner, joinerConn) = AddPlayer("joiner", ConnectionStateEnum.ServerPlaying); + new ServerLoadingState(joinerConn).SendWorldData(); + + var data = joinerConn.SentMessages.Single(m => m.id == Packets.Server_WorldData).data; + var reader = new ByteReader(data); + reader.ReadInt32(); // faction + reader.ReadInt32(); // timer + Assert.That(reader.ReadInt32(), Is.EqualTo(server.commands.SentCmds)); + reader.ReadBool(); // frozen + Assert.That(reader.ReadPrefixedBytes(), Is.EqualTo(new byte[] { 9 })); + Assert.That(reader.ReadPrefixedBytes(), Is.EqualTo(new byte[] { 8 })); + + int count = reader.ReadInt32(); + var cmds = Enumerable.Range(0, count).Select(_ => reader.ReadPrefixedBytes()!).ToList(); + Assert.That(Decode(cmds), Is.EqualTo(new List<(int, byte)> { (5, 1), (ScheduledCommand.Global, 2), (3, 3) })); + } + + [Test] + public void CompletedJoinPointKeepsOnlyCommandsAfterIt() + { + AddPlayer("live", ConnectionStateEnum.ServerPlaying); + server.hostUsername = "live"; + + SendCmd(5, 1); + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + SendCmd(ScheduledCommand.Global, 2); + SendCmd(5, 3); + server.worldData.EndJoinPointCreation(); + + var cmds = server.worldData.cmds.Select(raw => ScheduledCommand.Deserialize(new ByteReader(raw))).ToList(); + Assert.That(cmds.Any(c => c.type == CommandType.CreateJoinPoint), Is.False); + Assert.That(Decode(server.worldData.cmds), Is.EqualTo(new List<(int, byte)> { (ScheduledCommand.Global, 2), (5, 3) })); + } + + [Test] + public void AbortedJoinPointKeepsTheFullLog() + { + AddPlayer("live", ConnectionStateEnum.ServerPlaying); + server.hostUsername = "live"; + + SendCmd(5, 1); + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + SendCmd(ScheduledCommand.Global, 2); + server.worldData.AbortJoinPointCreation(); + + var types = server.worldData.cmds.Select(raw => ScheduledCommand.Deserialize(new ByteReader(raw)).type); + // The joiner replays the aborted CreateJoinPoint too, so it reloads at the same tick as live clients did + Assert.That(types, Is.EqualTo(new[] { CommandType.Sync, CommandType.CreateJoinPoint, CommandType.Sync })); + } + + [Test] + public void LargestAllowedCommandIsLoggedAndBroadcast() + { + var (_, conn) = AddPlayer("live", ConnectionStateEnum.ServerPlaying); + + server.commands.Send(CommandType.Sync, 0, 1, new byte[ServerCommandPacket.MaxDataLength]); + + Assert.That(server.worldData.cmds, Has.Count.EqualTo(1)); + Assert.That(conn.SentPackets, Does.Contain(Packets.Server_Command)); + } + + [Test] + public void TooLargeCommandIsNeitherLoggedNorBroadcast() + { + var (_, conn) = AddPlayer("live", ConnectionStateEnum.ServerPlaying); + var errors = new List(); + var prevError = ServerLog.error; + ServerLog.error = errors.Add; + + try + { + server.commands.Send(CommandType.Sync, 0, 1, new byte[ServerCommandPacket.MaxDataLength + 1]); + } + finally + { + ServerLog.error = prevError; + } + + Assert.That(server.worldData.cmds, Is.Empty); + Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_Command)); + Assert.That(server.commands.SentCmds, Is.EqualTo(0)); + Assert.That(errors, Has.Count.EqualTo(1)); + } + + [Test] + public void ClientCommandDataIsCappedAtRebroadcastLimit() + { + var ok = new ClientCommandPacket(CommandType.Sync, 1, new byte[ServerCommandPacket.MaxDataLength]); + Assert.DoesNotThrow(() => ok.Serialize()); + + var tooBig = new ClientCommandPacket(CommandType.Sync, 1, new byte[ServerCommandPacket.MaxDataLength + 1]); + Assert.Catch(() => tooBig.Serialize()); + } + + [Test] + public void CurrentFormatLogsAreNotReordered() + { + var log = new List + { + new(CommandType.Sync, 10, 0, 5, 0, [1]), + new(CommandType.Sync, 10, 0, ScheduledCommand.Global, 0, [2]), + new(CommandType.Sync, 3, 0, 5, 0, [3]), // e.g. after a debug timer reset + }; + + Assert.That(ScheduledCommand.MergeLegacyCmds(log, [[], []]), Is.SameAs(log)); + } + + private static ScheduledCommand ToCmd(byte[] packetData) + { + var packet = default(ServerCommandPacket); + packet.Bind(new PacketReader(new ByteReader(packetData))); + return packet.ToCommand(); + } +} diff --git a/Source/Tests/FragmentedPacketTest.cs b/Source/Tests/FragmentedPacketTest.cs new file mode 100644 index 000000000..64c10abe5 --- /dev/null +++ b/Source/Tests/FragmentedPacketTest.cs @@ -0,0 +1,110 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[NonParallelizable] +public class FragmentedPacketTest +{ + private MultiplayerServer server = null!; + private Action? prevErrorLog; + private readonly List errors = new(); + + private class CapturingConnection : ConnectionBase + { + public readonly List sent = new(); + protected override void SendRaw(byte[] raw, bool reliable) => sent.Add(raw); + protected override void OnClose(ServerDisconnectPacket? goodbye) { } + } + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + server.hostUsername = "host"; + + prevErrorLog = ServerLog.error; + errors.Clear(); + ServerLog.error = errors.Add; + } + + [TearDown] + public void TearDown() + { + ServerLog.error = prevErrorLog; + MultiplayerServer.instance = null; + } + + private RecordingConnection AddHost() + { + var conn = new RecordingConnection("host"); + var player = new ServerPlayer(0, conn); + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return conn; + } + + private static List Fragments(byte[] syncOpinion) + { + var sender = new CapturingConnection(); + sender.SendFragmented(new ClientSyncInfoPacket { rawSyncOpinion = syncOpinion }.Serialize()); + return sender.sent; + } + + private static byte[] Payload(byte fill) => Enumerable.Repeat(fill, 5000).ToArray(); + + private static void Receive(ConnectionBase conn, IEnumerable raws) + { + foreach (var raw in raws) + conn.HandleReceiveRaw(new ByteReader(raw), true); + } + + [Test] + public void StateChangeDropsPartialPacket() + { + var conn = AddHost(); + var dropped = Fragments(Payload(1)); + Assert.That(dropped, Has.Count.GreaterThan(3)); + + Receive(conn, dropped.Take(2)); + conn.ChangeState(ConnectionStateEnum.ServerLoading); // Goes straight back to playing + Assert.That(conn.State, Is.EqualTo(ConnectionStateEnum.ServerPlaying)); + + // The rest of the dropped packet is ignored, the next fragmented packet goes through + Receive(conn, dropped.Skip(2)); + Receive(conn, Fragments(Payload(2))); + + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(1)); + Assert.That(server.worldData.syncInfos[0], Is.EqualTo(Payload(2))); + Assert.That(errors, Is.Empty); + } + + [Test] + public void FragmentWithoutHandlerDropsWholePacket() + { + var conn = AddHost(); + conn.ChangeState(ConnectionStateEnum.ServerJoining); // No handler for sync info + conn.Lenient = true; + + var dropped = Fragments(Payload(1)); + Receive(conn, dropped.Take(1)); + Assert.That(errors, Has.Count.EqualTo(1)); // "No handler" is logged once + + conn.Lenient = false; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + + // The remaining fragments must not be mistaken for the start of a new packet + Receive(conn, dropped.Skip(1)); + Receive(conn, Fragments(Payload(2))); + + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(1)); + Assert.That(server.worldData.syncInfos[0], Is.EqualTo(Payload(2))); + Assert.That(errors, Has.Count.EqualTo(1)); + } +} diff --git a/Source/Tests/Helper/RecordingConnection.cs b/Source/Tests/Helper/RecordingConnection.cs index e219992a1..eb4e407b5 100644 --- a/Source/Tests/Helper/RecordingConnection.cs +++ b/Source/Tests/Helper/RecordingConnection.cs @@ -7,6 +7,9 @@ namespace Tests; public class RecordingConnection : ConnectionBase { public List SentPackets { get; } = new(); + // Payloads of non-fragmented packets + public List<(Packets id, byte[] data)> SentMessages { get; } = new(); + public List SentRaw { get; } = new(); public RecordingConnection(string username) { @@ -21,7 +24,12 @@ protected override void SendRaw(byte[] raw, bool reliable) return; SentPackets.Add((Packets)(raw[0] & 0x3F)); + if ((raw[0] & 0xC0) == 0) + SentMessages.Add(((Packets)(raw[0] & 0x3F), raw[1..])); + SentRaw.Add(raw); } - protected override void OnClose(ServerDisconnectPacket? goodbye) { } + public ServerDisconnectPacket? Goodbye { get; private set; } + + protected override void OnClose(ServerDisconnectPacket? goodbye) => Goodbye = goodbye; } \ No newline at end of file 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/JoinPointTest.cs b/Source/Tests/JoinPointTest.cs new file mode 100644 index 000000000..2cf1fe6bd --- /dev/null +++ b/Source/Tests/JoinPointTest.cs @@ -0,0 +1,438 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[TestFixture] +public class JoinPointTest +{ + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false, + autosaveInterval = 0 + }); + server.worldData.savedGame = [1]; + nextPlayerId = 0; + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private ServerPlayer AddPlayer(string username, PlayerStatus status = PlayerStatus.Playing, + PlayerType type = PlayerType.Normal) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn) { hasJoined = true, status = status, type = type }; + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return player; + } + + private static void Upload(ServerPlayer player, int tick, byte[] game, int mapId = 0, byte[]? map = null) + { + var writer = new ByteWriter(); + writer.WriteInt32(tick); + writer.WriteInt32(1); + writer.WriteInt32(mapId); + writer.WritePrefixedBytes(map ?? [7]); + writer.WritePrefixedBytes(game); + writer.WritePrefixedBytes([5]); + player.conn.GetState()!.HandleWorldDataUpload(new ByteReader(writer.ToArray())); + } + + private void SendCmd(byte marker) => server.commands.Send(CommandType.Sync, 0, 1, [marker]); + + private List Cmds => server.worldData.cmds + .Select(raw => ScheduledCommand.Deserialize(new ByteReader(raw))).ToList(); + + [Test] + public void StandaloneUploaderIsLongestConnectedPlayerInSync() + { + server.IsStandaloneServer = true; + AddPlayer("desynced", PlayerStatus.Desynced); + AddPlayer("simulating", PlayerStatus.Simulating); + var reference = AddPlayer("reference"); + AddPlayer("other"); + + Assert.That(server.playerManager.SyncReference, Is.SameAs(reference)); + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + Assert.That(server.worldData.JoinPointUploaderId, Is.EqualTo(reference.id)); + + // The uploader is announced in the command itself, every client reloads + var cjp = Cmds.Single(c => c.type == CommandType.CreateJoinPoint); + Assert.That(new ByteReader(cjp.data).ReadInt32(), Is.EqualTo(reference.id)); + } + + [Test] + public void HostedUploaderIsArbiterWhenPlaying() + { + var host = AddPlayer("host"); + server.hostUsername = "host"; + var arbiter = AddPlayer("arbiter", type: PlayerType.Arbiter); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + Assert.That(server.worldData.JoinPointUploaderId, Is.EqualTo(arbiter.id)); + + Upload(host, 0, [2]); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + + Upload(arbiter, 0, [3]); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 3 })); + } + + [Test] + public void NoJoinPointWithoutAnUploader() + { + server.IsStandaloneServer = true; + AddPlayer("simulating", PlayerStatus.Simulating); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.False); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(server.worldData.cmds, Is.Empty); + } + + [Test] + public void OnlyTheExpectedUploadAtTheJoinPointTickCompletesIt() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + var other = AddPlayer("b"); + server.gameTimer = 100; + + SendCmd(1); + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + server.gameTimer = 105; + SendCmd(2); + + Upload(other, 100, [2]); + Upload(uploader, 99, [2]); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + + Upload(uploader, 100, [2], mapId: 3, map: [4]); + + // World, maps and log are swapped together + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 2 })); + Assert.That(server.worldData.mapData.Keys, Is.EqualTo(new[] { 3 })); + Assert.That(Cmds.Select(c => c.data[0]), Is.EqualTo(new byte[] { 2 })); + Assert.That(Cmds.All(c => c.ticks >= 100), Is.True); + Assert.That(server.worldData.standaloneWorldSnapshot.tick, Is.EqualTo(100)); + Assert.That(server.worldData.standaloneWorldSnapshot.producerPlayerId, Is.EqualTo(uploader.id)); + } + + [Test] + public void UploadOutsideOfJoinPointIsRejected([Values] bool standalone) + { + server.IsStandaloneServer = standalone; + var host = AddPlayer("host"); + server.hostUsername = standalone ? null : "host"; + + Upload(host, 0, [2]); + + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + Assert.That(server.worldData.mapData, Is.Empty); + } + + [Test] + public void InitialHostUploadIsAccepted() + { + server.worldData.savedGame = null; + var host = AddPlayer("host"); + server.hostUsername = "host"; + + Upload(host, 0, [2]); + + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 2 })); + } + + [Test] + public void JoinPointAbortsWhenUploaderDisconnects() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + AddPlayer("b"); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + var waiter = server.worldData.WaitJoinPoint(); + + uploader.Disconnect(MpDisconnectReason.Generic); + + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(waiter.IsCompleted, Is.True); + // Joiners get the previous world with the full log, including the aborted CreateJoinPoint + Assert.That(Cmds.Any(c => c.type == CommandType.CreateJoinPoint), Is.True); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + } + + [Test] + public void JoinPointAbortsWhenUploaderDesyncs() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + AddPlayer("b"); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + server.playerManager.OnDesync(uploader, 0, 0); + + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(uploader.status, Is.EqualTo(PlayerStatus.Desynced)); + } + + [Test] + public void JoinPointAbortsWhenUploaderRejoins() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + AddPlayer("b"); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + uploader.conn.GetState()!.HandleRejoin(new ByteReader([])); + + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(uploader.status, Is.EqualTo(PlayerStatus.Simulating)); + } + + [Test] + public void JoinPointTimesOutWithoutUpload() + { + var host = AddPlayer("host"); + server.hostUsername = "host"; + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + + // The uploader keeps answering keep alives (it isn't busy saving) but never uploads + host.keepAliveAt = WorldData.JoinPointTimeout; + server.worldData.CheckJoinPointTimeout(WorldData.JoinPointTimeout); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + + host.keepAliveAt = WorldData.JoinPointTimeout + 1; + server.worldData.CheckJoinPointTimeout(WorldData.JoinPointTimeout + 1); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + } + + [Test] + public void JoinPointWaitsWhileTheUploaderIsBusySaving() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + AddPlayer("b"); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + + // Saving and reloading a big game blocks the uploader's main thread: no keep alives, no fragments + var reloaded = WorldData.JoinPointTimeout * 5; + server.worldData.CheckJoinPointTimeout(reloaded); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + + // Once it answers keep alives again, the upload has to start within the timeout + uploader.keepAliveAt = reloaded + WorldData.JoinPointTimeout; + server.worldData.CheckJoinPointTimeout(reloaded + WorldData.JoinPointTimeout); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + + uploader.keepAliveAt = reloaded + WorldData.JoinPointTimeout + 1; + server.worldData.CheckJoinPointTimeout(reloaded + WorldData.JoinPointTimeout + 1); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + } + + [Test] + public void ForcedJoinPointsAreRateLimited() + { + AddPlayer("host"); + server.hostUsername = "host"; + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + server.worldData.EndJoinPointCreation(); + + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.False); + } + + [Test] + public void RepeatedDesyncReportsAreIgnored() + { + server.settings.autoJoinPoint = AutoJoinPointFlags.Desync; + var host = AddPlayer("host"); + server.hostUsername = "host"; + var player = AddPlayer("player"); + + server.playerManager.OnDesync(player, 0, 0); + var cmdCount = server.worldData.cmds.Count; + server.playerManager.OnDesync(player, 0, 0); + + Assert.That(server.worldData.cmds, Has.Count.EqualTo(cmdCount)); + Assert.That(((RecordingConnection)host.conn).SentPackets.Count(p => p == Packets.Server_Traces), Is.EqualTo(1)); + } + + [Test] + public void StandaloneDesyncRequestsTracesFromSyncReference() + { + server.IsStandaloneServer = true; + var reference = AddPlayer("reference"); + var player = AddPlayer("player"); + + Assert.DoesNotThrow(() => server.playerManager.OnDesync(player, 0, 0)); + Assert.That(((RecordingConnection)reference.conn).SentPackets, Does.Contain(Packets.Server_Traces)); + } + + [Test] + public void StandaloneAutosaveIsIssuedByServer() + { + server.IsStandaloneServer = true; + server.settings.autosaveUnit = AutosaveUnit.Minutes; + server.settings.autosaveInterval = 1; + var uploader = AddPlayer("a"); + + server.worldData.Tick(); + server.gameTimer = 60 * 60 - 1; + server.worldData.Tick(); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + + server.gameTimer = 60 * 60; + server.worldData.Tick(); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + Assert.That(server.worldData.JoinPointUploaderId, Is.EqualTo(uploader.id)); + Assert.That(server.worldData.JoinPointTick, Is.EqualTo(60 * 60)); + } + + [Test] + public void WorldTravelRequestsDoNotCreateJoinPoints() + { + server.IsStandaloneServer = true; + var player = AddPlayer("a"); + + player.conn.GetState()!.HandleAutosaving(new ClientAutosavingPacket(JoinPointRequestReason.WorldTravel, 0)); + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + + player.conn.GetState()!.HandleAutosaving(new ClientAutosavingPacket(JoinPointRequestReason.Save, 0)); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + } + + private void AdvanceNetTimer(int ticks) => + typeof(MultiplayerServer).GetProperty(nameof(MultiplayerServer.NetTimer))! + .SetValue(server, server.NetTimer + ticks); + + private static void RequestSave(ServerPlayer player, int tick) => + player.conn.GetState()!.HandleAutosaving( + new ClientAutosavingPacket(JoinPointRequestReason.Save, tick)); + + [Test] + public void DaysAutosaveRequestsForOneTickCreateOneJoinPoint() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + var other = AddPlayer("b"); + var slow = AddPlayer("slow"); + + // Every client autosaves at tick 90 and asks once its save is written + server.gameTimer = 100; + RequestSave(uploader, 90); + Assert.That(server.worldData.JoinPointTick, Is.EqualTo(100)); + RequestSave(other, 90); + Upload(uploader, 100, [2]); + Assert.That(server.worldData.CompletedJoinPointTick, Is.EqualTo(100)); + + // Saving took the slow client longer than the forced join point cooldown + AdvanceNetTimer(WorldData.ForcedJoinPointCooldown + 1); + server.gameTimer = 400; + var cmdCount = server.worldData.cmds.Count; + RequestSave(slow, 90); + + Assert.That(server.worldData.CreatingJoinPoint, Is.False); + Assert.That(server.worldData.cmds, Has.Count.EqualTo(cmdCount)); + + // A save of a newer state still gets one + RequestSave(other, 300); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + Assert.That(server.worldData.JoinPointTick, Is.EqualTo(400)); + } + + [Test] + public void SaveRequestAfterAnAbortedJoinPointCreatesOne() + { + server.IsStandaloneServer = true; + var uploader = AddPlayer("a"); + var slow = AddPlayer("slow"); + + server.gameTimer = 100; + RequestSave(uploader, 90); + server.worldData.AbortJoinPointCreation(); + + // The server's world is still older than the save + AdvanceNetTimer(WorldData.ForcedJoinPointCooldown + 1); + RequestSave(slow, 90); + Assert.That(server.worldData.CreatingJoinPoint, Is.True); + } + + [Test] + public void StandaloneSnapshotsDoNotReplaceTheWorld() + { + server.IsStandaloneServer = true; + var player = AddPlayer("a"); + var state = player.conn.GetState()!; + + state.HandleStandaloneWorldSnapshot(new ClientStandaloneWorldSnapshotPacket + { + tick = 10, worldData = [2], sessionData = [3], sha256Hash = [] + }); + state.HandleStandaloneMapSnapshot(new ClientStandaloneMapSnapshotPacket + { + mapId = 0, tick = 10, mapData = [4], sha256Hash = [] + }); + + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + Assert.That(server.worldData.mapData, Is.Empty); + } + + [Test] + public void StandaloneJoinPointIsPersistedWithItsLog() + { + var dir = Path.Combine(Path.GetTempPath(), $"mp-joinpoint-{Guid.NewGuid():N}"); + try + { + server.IsStandaloneServer = true; + server.persistence = new StandalonePersistence(dir); + var uploader = AddPlayer("a"); + server.gameTimer = 50; + + SendCmd(1); + Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + server.gameTimer = 60; + SendCmd(2); + Upload(uploader, 50, [2]); + + var reloaded = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(dir), + }; + reloaded.persistence.LoadInto(reloaded); + + Assert.That(reloaded.worldData.savedGame, Is.EqualTo(new byte[] { 2 })); + Assert.That(reloaded.worldData.cmds.Select(c => ScheduledCommand.Deserialize(new ByteReader(c)).data[0]), + Is.EqualTo(new byte[] { 2 })); + // Never behind the last logged command + Assert.That(reloaded.gameTimer, Is.EqualTo(60)); + } + finally + { + if (Directory.Exists(dir)) + Directory.Delete(dir, true); + } + } +} diff --git a/Source/Tests/JoiningStateTest.cs b/Source/Tests/JoiningStateTest.cs new file mode 100644 index 000000000..f4ee64776 --- /dev/null +++ b/Source/Tests/JoiningStateTest.cs @@ -0,0 +1,228 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[NonParallelizable] +public class JoiningStateTest +{ + private MultiplayerServer server = null!; + private Action? prevErrorLog; + private readonly List errors = new(); + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false, + autoJoinPoint = 0 + }); + server.worldData.savedGame = []; + + prevErrorLog = ServerLog.error; + errors.Clear(); + ServerLog.error = errors.Add; + } + + [TearDown] + public void TearDown() + { + ServerLog.error = prevErrorLog; + ServerJoiningState.ReplyTimeout = TimeSpan.FromMinutes(2); + ServerJoiningState.InitDataTimeout = TimeSpan.FromMinutes(5); + MultiplayerServer.instance = null; + } + + private (ServerPlayer player, RecordingConnection conn) Connect() + { + // Username gets set by the joining state + var conn = new RecordingConnection(null!); + conn.ChangeState(ConnectionStateEnum.ServerJoining); + var player = server.playerManager.OnConnected(conn); + return (player, conn); + } + + private static void Receive(ConnectionBase conn, T packet) where T : struct, IPacket + { + var serialized = packet.Serialize(); + var raw = new byte[serialized.data.Length + 1]; + raw[0] = (byte)serialized.id; + serialized.data.CopyTo(raw, 1); + conn.HandleReceiveRaw(new ByteReader(raw), true); + } + + private static void SendHandshake(ConnectionBase conn, string username) + { + Receive(conn, ClientProtocolPacket.Current()); + Receive(conn, new ClientUsernamePacket(username)); + } + + private static ClientInitDataPacket InitData(params KeyedDefInfo[] defInfos) => new() + { + rwVersion = "1.0.0", + language = "English", + debugOnlySyncCmds = [], + hostOnlySyncCmds = [], + modCtorRoundMode = RoundModeEnum.ToNearest, + staticCtorRoundMode = RoundModeEnum.ToNearest, + defInfos = defInfos, + Mods = [] + }; + + [Test] + public void InitDataRequesterDisconnecting_NextJoinerIsAsked() + { + var (_, first) = Connect(); + SendHandshake(first, "first"); + + Assert.That(first.SentPackets, Does.Contain(Packets.Server_InitDataRequest)); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Requested)); + + server.playerManager.SetDisconnected(first, MpDisconnectReason.ClientLeft); + + // The dying connection must not request the init data again + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Waiting)); + Assert.That(first.SentPackets.Count(p => p == Packets.Server_InitDataRequest), Is.EqualTo(1)); + + var (_, second) = Connect(); + SendHandshake(second, "second"); + Assert.That(second.SentPackets, Does.Contain(Packets.Server_InitDataRequest)); + + Receive(second, InitData()); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Complete)); + Assert.That(second.SentPackets, Does.Contain(Packets.Server_UsernameOk)); + } + + [Test] + public void WaitingJoinerTakesOverInitDataRequest() + { + var (_, first) = Connect(); + SendHandshake(first, "first"); + var (_, second) = Connect(); + SendHandshake(second, "second"); + + Assert.That(second.SentPackets, Does.Not.Contain(Packets.Server_InitDataRequest)); + + server.playerManager.SetDisconnected(first, MpDisconnectReason.ClientLeft); + + Assert.That(second.SentPackets, Does.Contain(Packets.Server_InitDataRequest)); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Requested)); + } + + [Test] + public void InvalidInitData_DisconnectsAndResetsInitDataState() + { + var (_, first) = Connect(); + SendHandshake(first, "first"); + + // Duplicate def type names make ServerInitData.FromNet throw + Receive(first, InitData( + new KeyedDefInfo { name = "ThingDef", count = 1, hash = 1 }, + new KeyedDefInfo { name = "ThingDef", count = 2, hash = 2 })); + + Assert.That(first.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Waiting)); + Assert.That(errors, Is.Not.Empty); + } + + [Test] + public void InitDataRequestTimesOut() + { + var (_, first) = Connect(); + ServerJoiningState.InitDataTimeout = TimeSpan.Zero; + SendHandshake(first, "first"); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Requested)); + + Thread.Sleep(10); + server.playerManager.CheckTimeouts(); + + Assert.That(first.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(server.playerManager.Players, Is.Empty); + Assert.That(server.InitDataState, Is.EqualTo(InitDataState.Waiting)); + } + + [Test] + public void IdleHandshakeTimesOut() + { + ServerJoiningState.ReplyTimeout = TimeSpan.Zero; + var (_, conn) = Connect(); + + Thread.Sleep(10); + server.playerManager.CheckTimeouts(); + + Assert.That(conn.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(server.playerManager.Players, Is.Empty); + } + + [Test] + public void DefTypesMissingOnClientFailTheDefCheck() + { + var a = new KeyedDefInfo { name = "A", count = 1, hash = 1 }; + var b = new KeyedDefInfo { name = "B", count = 2, hash = 2 }; + + var (first, firstConn) = Connect(); + SendHandshake(firstConn, "first"); + Receive(firstConn, InitData(a, b)); + + var (second, secondConn) = Connect(); + SendHandshake(secondConn, "second"); + Receive(secondConn, JoinData(a)); + + var raw = secondConn.SentRaw.Single(r => (Packets)(r[0] & 0x3F) == Packets.Server_JoinData); + var joinData = default(ServerJoinDataPacket); + joinData.Bind(new PacketReader(new ByteReader(raw.Skip(1).ToArray()))); + + Assert.That(joinData.defStatus, Is.EqualTo(new[] { DefCheckStatus.Ok })); + Assert.That(joinData.missingDefs, Is.EqualTo(new[] { "B" })); + Assert.That(second.hasJoined, Is.False); + + Receive(firstConn, JoinData(a, b)); + Assert.That(first.hasJoined, Is.True); + } + + [Test] + public void JoinDataCarriesTheInitDataLanguage() + { + var (_, firstConn) = Connect(); + SendHandshake(firstConn, "first"); + Receive(firstConn, InitData() with { language = "Russian" }); + + var (_, secondConn) = Connect(); + SendHandshake(secondConn, "second"); + Receive(secondConn, JoinData()); + + var raw = secondConn.SentRaw.Single(r => (Packets)(r[0] & 0x3F) == Packets.Server_JoinData); + var joinData = default(ServerJoinDataPacket); + joinData.Bind(new PacketReader(new ByteReader(raw.Skip(1).ToArray()))); + + // Joiners compare it with their own language and warn on a mismatch + Assert.That(joinData.language, Is.EqualTo("Russian")); + } + + private static ClientJoinDataPacket JoinData(params KeyedDefInfo[] defInfos) => new() + { + modCtorRoundMode = RoundModeEnum.ToNearest, + staticCtorRoundMode = RoundModeEnum.ToNearest, + defInfos = defInfos + }; + + [Test] + public void JoinerLeavingWhileWaitingForInitDataStops() + { + var (_, first) = Connect(); + SendHandshake(first, "first"); + var (_, second) = Connect(); + SendHandshake(second, "second"); + + var secondState = second.GetState()!; + server.playerManager.SetDisconnected(second, MpDisconnectReason.ClientLeft); + Receive(first, InitData()); + + Assert.That(first.SentPackets, Does.Contain(Packets.Server_UsernameOk)); + // The dead state's flow stopped instead of going on to wait for join data + Assert.That(secondState.AwaitedPacket, Is.Null); + } +} diff --git a/Source/Tests/LargeCommandTest.cs b/Source/Tests/LargeCommandTest.cs new file mode 100644 index 000000000..f088780d7 --- /dev/null +++ b/Source/Tests/LargeCommandTest.cs @@ -0,0 +1,116 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +/// Commands bigger than a single packet (big designations, stockpile pastes) are sent fragmented both ways +[TestFixture] +[NonParallelizable] +public class LargeCommandTest +{ + // Stands in for the client's playing state + public class CommandReceiverState(ConnectionBase conn) : MpConnectionState(conn) + { + public static readonly List received = new(); + + [TypedPacketHandler] + public void HandleCommand(ServerCommandPacket packet) => received.Add(packet); + } + + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + nextPlayerId = 0; + MpConnectionState.SetImplementation(ConnectionStateEnum.ClientPlaying, typeof(CommandReceiverState)); + CommandReceiverState.received.Clear(); + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private RecordingConnection AddPlayer(string username) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn) { hasJoined = true }; + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return conn; + } + + private static void Receive(ConnectionBase conn, IEnumerable raws) + { + foreach (var raw in raws) + conn.HandleReceiveRaw(new ByteReader(raw), true); + } + + private static bool IsFragment(byte[] raw) => (raw[0] & 0xC0) != 0; + + [Test] + public void BigCommandReachesLiveClientsWhole() + { + var senderConn = AddPlayer("sender"); + var liveConn = AddPlayer("live"); + var data = Enumerable.Range(0, 300_000).Select(i => (byte)(i * 7)).ToArray(); + + var client = new RecordingConnection("client"); + client.SendFragmentedIfNeeded(new ClientCommandPacket(CommandType.Designator, 3, data).Serialize()); + Assert.That(client.SentRaw, Has.Count.GreaterThan(1).And.All.Matches(IsFragment)); + + Receive(senderConn, client.SentRaw); + + var logged = ScheduledCommand.Deserialize(new ByteReader(server.worldData.cmds.Single())); + Assert.That(logged.type, Is.EqualTo(CommandType.Designator)); + Assert.That(logged.mapId, Is.EqualTo(3)); + Assert.That(logged.data, Is.EqualTo(data)); + + var liveClient = new RecordingConnection("live client"); + liveClient.ChangeState(ConnectionStateEnum.ClientPlaying); + Receive(liveClient, liveConn.SentRaw.Where(raw => (Packets)(raw[0] & 0x3F) == Packets.Server_Command)); + + var received = CommandReceiverState.received.Single(); + Assert.That(received.ToCommand().playerId, Is.EqualTo(0)); + Assert.That(received.data, Is.EqualTo(data)); + } + + [Test] + public void SmallCommandIsASinglePacket() + { + var liveConn = AddPlayer("live"); + + server.commands.Send(CommandType.Sync, 0, 1, new byte[ConnectionBase.MaxSinglePacketSize - 100]); + + var sent = liveConn.SentRaw.Where(raw => (Packets)(raw[0] & 0x3F) == Packets.Server_Command).ToList(); + Assert.That(sent, Has.Count.EqualTo(1)); + Assert.That(IsFragment(sent[0]), Is.False); + } + + [Test] + public void CommandsKeepTheirOrderAroundABigOne() + { + var liveConn = AddPlayer("live"); + + server.commands.Send(CommandType.Sync, 0, 1, [1]); + server.commands.Send(CommandType.Sync, 0, 1, Enumerable.Repeat((byte)2, 200_000).ToArray()); + server.commands.Send(CommandType.Sync, 0, 1, [3]); + + var liveClient = new RecordingConnection("live client"); + liveClient.ChangeState(ConnectionStateEnum.ClientPlaying); + Receive(liveClient, liveConn.SentRaw.Where(raw => (Packets)(raw[0] & 0x3F) == Packets.Server_Command)); + + Assert.That(CommandReceiverState.received.Select(p => p.data[0]), Is.EqualTo(new byte[] { 1, 2, 3 })); + Assert.That(CommandReceiverState.received[1].data, Has.Length.EqualTo(200_000)); + } +} diff --git a/Source/Tests/LineEndingNormalizationTest.cs b/Source/Tests/LineEndingNormalizationTest.cs new file mode 100644 index 000000000..6c32f243b --- /dev/null +++ b/Source/Tests/LineEndingNormalizationTest.cs @@ -0,0 +1,39 @@ +using System.Text; + +namespace Tests; + +[TestFixture] +public class LineEndingNormalizationTest +{ + private static string Normalize(string text) + { + var data = Encoding.UTF8.GetBytes(text); + var len = Multiplayer.Common.Utils.NormalizeLineEndingsInPlace(data); + return Encoding.UTF8.GetString(data, 0, len); + } + + [Test] + public void WindowsAndOldMacEndingsBecomeLf() + { + const string lf = "\n \n\n"; + + Assert.That(Normalize("\r\n \r\n\r\n"), Is.EqualTo(lf)); + Assert.That(Normalize("\r \r\r"), Is.EqualTo(lf)); + Assert.That(Normalize(lf), Is.EqualTo(lf)); + } + + [Test] + public void MixedEndingsAndBlankLines() + { + Assert.That(Normalize("a\r\n\r\nb\r\rc\n\rd"), Is.EqualTo("a\n\nb\n\nc\n\nd")); + Assert.That(Normalize("\r"), Is.EqualTo("\n")); + Assert.That(Normalize("\r\n"), Is.EqualTo("\n")); + Assert.That(Normalize(""), Is.EqualTo("")); + } + + [Test] + public void OtherBytesAreKept() + { + Assert.That(Normalize("ünïcødé\tтекст"), Is.EqualTo("ünïcødé\tтекст")); + } +} diff --git a/Source/Tests/LogTrimTest.cs b/Source/Tests/LogTrimTest.cs new file mode 100644 index 000000000..bcb5e166c --- /dev/null +++ b/Source/Tests/LogTrimTest.cs @@ -0,0 +1,33 @@ +using Multiplayer.Common; + +namespace Tests; + +[TestFixture] +public class LogTrimTest +{ + private static string Lines(int from, int to) => + string.Join("\n", Enumerable.Range(from, to - from + 1).Select(i => $"line {i}")); + + [Test] + public void ShortTextIsKept() + { + var text = Lines(1, 5); + Assert.That(text.KeepHeadAndTailLines(2, 3), Is.SameAs(text)); + } + + [Test] + public void LongTextKeepsHeadAndTail() + { + var trimmed = Lines(1, 10).KeepHeadAndTailLines(2, 3); + + Assert.That(trimmed, Is.EqualTo(Lines(1, 2) + "\n(... 5 lines omitted ...)\n" + Lines(8, 10))); + } + + [Test] + public void TrailingNewlineCountsAsAnEmptyLastLine() + { + var trimmed = (Lines(1, 10) + "\n").KeepHeadAndTailLines(2, 3); + + Assert.That(trimmed, Is.EqualTo(Lines(1, 2) + "\n(... 6 lines omitted ...)\n" + Lines(9, 10) + "\n")); + } +} diff --git a/Source/Tests/PacketTest.cs b/Source/Tests/PacketTest.cs index fbe1eac84..904233e56 100644 --- a/Source/Tests/PacketTest.cs +++ b/Source/Tests/PacketTest.cs @@ -203,11 +203,13 @@ private static IEnumerable RoundtripPackets() playerId = 1, rwVersion = "1.6.4566", mpVersion = "0.11.0+123456", + language = "English", defStatus = [ DefCheckStatus.Ok, DefCheckStatus.Ok, DefCheckStatus.Count_Diff, DefCheckStatus.Hash_Diff, DefCheckStatus.Not_Found ], + missingDefs = ["ServerOnlyDef"], configsIncluded = false, ServerInitData = [mpModData], }; @@ -222,6 +224,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, @@ -250,6 +253,9 @@ private static IEnumerable RoundtripPackets() yield return new ServerSelectedPacket(1, new ClientSelectedPacket { newlySelectedIds = [1, 10, 100], reset = false, unselectedIds = [] }); + yield return new ServerSyncReferencePacket(true); + yield return new ServerSyncReferencePacket(false); + yield return new ClientDesyncedPacket(1234, 4567); yield return new ClientDesyncedPacket(0, 0); yield return new ClientDesyncedPacket(100, 0); diff --git a/Source/Tests/ReaderBoundsTest.cs b/Source/Tests/ReaderBoundsTest.cs new file mode 100644 index 000000000..7e479fe3b --- /dev/null +++ b/Source/Tests/ReaderBoundsTest.cs @@ -0,0 +1,179 @@ +using System.IO.Compression; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +public class ReaderBoundsTest +{ + private static ByteReader LengthPrefixed(int len, int payload = 8) => + new(ByteWriter.GetBytes(len).Concat(new byte[payload]).ToArray()); + + [TestCase(int.MaxValue)] + [TestCase(9)] + [TestCase(-1)] + public void ReadRawRejectsLengthOutsideData(int len) + { + var reader = new ByteReader(new byte[8]); + Assert.Throws(() => reader.ReadRaw(len)); + Assert.That(reader.Position, Is.Zero); + } + + [Test] + public void ReadRawReadsExactLength() + { + var reader = new ByteReader([1, 2, 3, 4]); + Assert.That(reader.ReadRaw(4), Is.EqualTo(new byte[] { 1, 2, 3, 4 })); + Assert.That(reader.Left, Is.Zero); + } + + [Test] + public void PrefixedReadsRejectHugeLengths() + { + Assert.Throws(() => LengthPrefixed(int.MaxValue).ReadPrefixedBytes()); + Assert.Throws(() => LengthPrefixed(int.MaxValue).ReadPrefixedInts()); + Assert.Throws(() => LengthPrefixed(int.MaxValue).ReadPrefixedUInts()); + Assert.Throws(() => LengthPrefixed(int.MaxValue).ReadPrefixedULongs()); + Assert.Throws(() => LengthPrefixed(int.MaxValue).ReadPrefixedStrings()); + Assert.Throws(() => LengthPrefixed(-5).ReadPrefixedUInts()); + // 8 bytes hold 2 uints, not 3 + Assert.Throws(() => LengthPrefixed(3).ReadPrefixedUInts()); + Assert.That(LengthPrefixed(2).ReadPrefixedUInts(), Is.EqualTo(new uint[] { 0, 0 })); + } + + [Test] + public void BindBytesEnforcesMaxLength() + { + var bytes = new byte[100]; + var writer = new ByteWriter(); + new PacketWriter(writer).BindBytes(ref bytes, maxLength: -1); + + byte[] read = []; + Assert.Throws(() => new PacketReader(new ByteReader(writer.ToArray())).BindBytes(ref read, 99)); + + new PacketReader(new ByteReader(writer.ToArray())).BindBytes(ref read, 100); + Assert.That(read, Has.Length.EqualTo(100)); + + Assert.Throws(() => new PacketWriter(new ByteWriter()).BindBytes(ref bytes, 99)); + } + + [Test] + public void BindBytesRejectsLengthBeyondData() + { + byte[] read = []; + Assert.Throws(() => + new PacketReader(LengthPrefixed(int.MaxValue)).BindBytes(ref read, maxLength: -1)); + } + + [Test] + public void ListBindDoesNotPreallocateFromLength() + { + List list = []; + // A huge claimed count fails on the missing data instead of preallocating + Assert.Catch(() => + new PacketReader(LengthPrefixed(int.MaxValue - 1)).Bind(ref list, BinderOf.Int(), maxLength: int.MaxValue)); + } + + [Test] + public void ArrayBindRejectsNegativeAndUnbackedLengths() + { + int[] array = []; + Assert.Throws(() => new PacketReader(LengthPrefixed(-1)).Bind(ref array, BinderOf.Int())); + // A huge claimed count fails on the missing data instead of allocating the array up front + Assert.Catch(() => + new PacketReader(LengthPrefixed(int.MaxValue - 1)).Bind(ref array, BinderOf.Int(), maxLength: int.MaxValue)); + } + + [Test] + public void StringLimitCountsUtf8Bytes() + { + // 20 Cyrillic letters are 40 UTF-8 bytes: the writer has to refuse them for a 32 byte limit, the reader would + string tooLong = new('ж', 20); + Assert.Throws(() => new PacketWriter(new ByteWriter()).Bind(ref tooLong, maxLength: 32)); + + string fits = new('ж', 16); + var writer = new ByteWriter(); + new PacketWriter(writer).Bind(ref fits, maxLength: 32); + string read = ""; + new PacketReader(new ByteReader(writer.ToArray())).Bind(ref read, maxLength: 32); + Assert.That(read, Is.EqualTo(fits)); + } + + [Test] + public void PlayerListAcceptsLongNonAsciiSteamNames() + { + // Steam allows 32 characters, up to 128 UTF-8 bytes + var info = new ServerPlayerListPacket.PlayerInfo { username = "Host", steamPersonaName = new string('ж', 32) }; + var writer = new ByteWriter(); + info.Bind(new PacketWriter(writer)); + var read = default(ServerPlayerListPacket.PlayerInfo); + read.Bind(new PacketReader(new ByteReader(writer.ToArray()))); + Assert.That(read.steamPersonaName, Is.EqualTo(info.steamPersonaName)); + } + + [Test] + public void GzippedRejectsDecompressionBomb() + { + var binder = BinderOf.Identity().Gzipped(maxLength: 1 << 20, maxDecompressedLength: 1 << 16); + + var small = new Blob { data = new byte[1000] }; + Assert.That(binder.Deserialize(binder.Serialize(small)).data, Has.Length.EqualTo(1000)); + + // Zeros compress to almost nothing + var bomb = new Blob { data = new byte[1 << 20] }; + var serialized = binder.Serialize(bomb); + Assert.That(serialized, Has.Length.LessThan(1 << 16)); + Assert.Throws(() => binder.Deserialize(serialized)); + } + + [Test] + public void ModListLimitsMatchBetweenInitAndJoinData() + { + // Random config contents, so that they don't compress too well + var random = new Random(0); + string RandomText(int bytes) + { + var data = new byte[bytes]; + random.NextBytes(data); + return Convert.ToBase64String(data); + } + + var mods = Enumerable.Range(0, 1000).Select(i => new ClientInitDataPacket.ModData + { + packageIdNonUnique = $"mod.{i}", + name = $"Mod {i}", + files = [new ClientInitDataPacket.ModFile { path = "Assemblies/Mod.dll", hash = i }], + config = new ClientInitDataPacket.ModConfig { fileName = "Mod_Mod.xml", contents = RandomText(1000) } + }).ToList(); + + var initData = new ClientInitDataPacket + { + rwVersion = "1.6", language = "English", debugOnlySyncCmds = [], hostOnlySyncCmds = [], defInfos = [], Mods = mods + }; + // Bigger than the old 32 KiB and 512 KiB limits + Assert.That(initData.rawMods, Has.Length.GreaterThan(1 << 19)); + + var roundtrip = Roundtrip(initData); + var joinData = Roundtrip(new ServerJoinDataPacket + { + gameName = "", rwVersion = "", mpVersion = "", language = "", defStatus = [], missingDefs = [], + rawServerInitData = roundtrip.rawMods + }); + Assert.That(joinData.ServerInitData, Has.Count.EqualTo(mods.Count)); + } + + private static T Roundtrip(T packet) where T : struct, IPacket + { + var writer = new ByteWriter(); + packet.Bind(new PacketWriter(writer)); + var result = default(T); + result.Bind(new PacketReader(new ByteReader(writer.ToArray()))); + return result; + } + + private struct Blob : IPacketBufferable + { + public byte[] data; + public void Bind(PacketBuffer buf) => buf.BindBytes(ref data, maxLength: -1); + } +} diff --git a/Source/Tests/ReconnectTest.cs b/Source/Tests/ReconnectTest.cs new file mode 100644 index 000000000..37f2afb7b --- /dev/null +++ b/Source/Tests/ReconnectTest.cs @@ -0,0 +1,97 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[TestFixture] +public class ReconnectTest +{ + private long now; + + [SetUp] + public void SetUp() => now = 0; + + [Test] + public void PendingCloseIsDueAfterItsDelayOnce() + { + var closes = new PendingSessionCloses(() => now); + closes.Schedule(1, 3000); + + now = 2999; + Assert.That(closes.TakeDue(), Is.Empty); + + now = 3000; + Assert.That(closes.TakeDue(), Is.EqualTo(new[] { 1 })); + Assert.That(closes.TakeDue(), Is.Empty); + Assert.That(closes.Count, Is.Zero); + } + + [Test] + public void CancelledCloseNeverComesDue() + { + var closes = new PendingSessionCloses(() => now); + closes.Schedule(1, 3000); + closes.Schedule(2, 3000); + + Assert.That(closes.Cancel(1), Is.True); + Assert.That(closes.Cancel(1), Is.False); + + now = 5000; + Assert.That(closes.TakeDue(), Is.EqualTo(new[] { 2 })); + } + + [Test] + public void RescheduledCloseWaitsForTheLaterGoodbye() + { + var closes = new PendingSessionCloses(() => now); + closes.Schedule(1, 3000); + + now = 2000; + closes.Schedule(1, 3000); + + now = 4000; + Assert.That(closes.TakeDue(), Is.Empty); + + now = 5000; + Assert.That(closes.TakeDue(), Is.EqualTo(new[] { 1 })); + } + + [Test] + public void ReplaceStaleDropsTheOldConnectionWithoutClosingIt() + { + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", direct = false, lan = false + }); + + try + { + var other = AddPlayer(server, 0, "other"); + var stale = AddPlayer(server, 1, "stale"); + var replacement = new RecordingConnection("stale"); + + server.playerManager.ReplaceStale(stale, replacement); + + Assert.That(server.playerManager.Players, Is.EqualTo(new[] { other })); + Assert.That(stale.conn.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + // A goodbye would reach the reconnecting client on transports which share the session (Steam) + Assert.That(((RecordingConnection)stale.conn).Goodbye, Is.Null); + Assert.That(((RecordingConnection)stale.conn).SentPackets, Is.Empty); + Assert.That(((RecordingConnection)other.conn).SentPackets, Does.Contain(Packets.Server_PlayerList)); + } + finally + { + MultiplayerServer.instance = null; + } + } + + private static ServerPlayer AddPlayer(MultiplayerServer server, int id, string username) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(id, conn) { hasJoined = true, status = PlayerStatus.Playing }; + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return player; + } +} diff --git a/Source/Tests/SelectedPacketTest.cs b/Source/Tests/SelectedPacketTest.cs new file mode 100644 index 000000000..17cd6cbde --- /dev/null +++ b/Source/Tests/SelectedPacketTest.cs @@ -0,0 +1,69 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[TestFixture] +public class SelectedPacketTest +{ + // What the receiving client does with the packets + private static HashSet Apply(HashSet selection, IEnumerable packets) + { + foreach (var packet in packets) + { + var read = default(ClientSelectedPacket); + read.Bind(new PacketReader(new ByteReader(packet.Serialize().data))); + + if (read.reset) selection.Clear(); + selection.UnionWith(read.newlySelectedIds!); + selection.ExceptWith(read.unselectedIds!); + } + + return selection; + } + + [Test] + public void SmallChangeIsOnePacket() + { + var packets = ClientSelectedPacket.Split(true, [1, 2], [3]).ToList(); + + Assert.That(packets, Has.Count.EqualTo(1)); + Assert.That(packets[0].reset, Is.True); + Assert.That(packets[0].newlySelectedIds, Is.EqualTo(new[] { 1, 2 })); + Assert.That(packets[0].unselectedIds, Is.EqualTo(new[] { 3 })); + } + + [Test] + public void ResetWithoutChangesIsStillSent() + { + var packets = ClientSelectedPacket.Split(true, [], []).ToList(); + + Assert.That(packets, Has.Count.EqualTo(1)); + Assert.That(Apply([5], packets), Is.Empty); + } + + [Test] + public void BigSelectionIsSplitWithinTheLimit() + { + var added = Enumerable.Range(1000, 450).ToArray(); + var removed = Enumerable.Range(0, 10).ToArray(); + + var packets = ClientSelectedPacket.Split(false, added, removed).ToList(); + + Assert.That(packets, Has.Count.EqualTo(3)); + Assert.That(packets.All(p => p.newlySelectedIds.Length <= ClientSelectedPacket.MaxIds)); + Assert.That(packets.Any(p => p.reset), Is.False); + Assert.That(Apply([..removed, 9999], packets), Is.EquivalentTo(added.Append(9999))); + } + + [Test] + public void OnlyTheFirstPartResets() + { + var added = Enumerable.Range(0, 2 * ClientSelectedPacket.MaxIds + 1).ToArray(); + + var packets = ClientSelectedPacket.Split(true, added, []).ToList(); + + Assert.That(packets.Select(p => p.reset), Is.EqualTo(new[] { true, false, false })); + Assert.That(Apply([-1], packets), Is.EquivalentTo(added)); + } +} diff --git a/Source/Tests/SerializationTest.cs b/Source/Tests/SerializationTest.cs index fa1ef1b63..b15c3d842 100644 --- a/Source/Tests/SerializationTest.cs +++ b/Source/Tests/SerializationTest.cs @@ -1,4 +1,5 @@ -using Multiplayer.API; +using System.Reflection; +using Multiplayer.API; using Multiplayer.Client; using Multiplayer.Common; @@ -165,6 +166,64 @@ public void SyncWithImpl() } } + [Test] + public void SyncWithImplUnknownTypeThrows() + { + var ser = new SyncSerialization(new TestTypeHelper()); + ser.syncTree = new SyncWorkerDictionaryTree + { + { + (SyncWorker worker, ref C4 c) => + { + worker.Bind(ref c.a); + }, false, true + }, + }; + ser.RegisterForSyncWithImpl(typeof(C1)); + + // Writing the "null" implementation marker followed by the object's data would misalign the reader + Assert.Throws(() => ser.WriteSync(new ByteWriter(), new C4 { a = 1 })); + } + + [Test] + public void RejectingImplicitWorkerKeepsParentData() + { + var ser = new SyncSerialization(new TestTypeHelper()); + var tree = new SyncWorkerDictionaryTree + { + { + (SyncWorker worker, ref C2 c) => + { + worker.Bind(ref c.a); + }, true, true + }, + { + (SyncWorker worker, ref C3 c) => + { + worker.Bind(ref c.b); + }, true, true + }, + }; + + // Bool returning workers are inserted first; this one writes/reads some data and then gives up + tree.GetOrAddEntry(typeof(C3), isImplicit: true, shouldConstruct: true) + .Add(typeof(SerializationTest).GetMethod(nameof(RejectingWorker), BindingFlags.NonPublic | BindingFlags.Static)!); + ser.syncTree = tree; + + var input = new C3 { a = 1, b = 2 }; + Assert.That( + RoundtripSerialization(ser, input), + Is.EqualTo(input) + ); + } + + private static bool RejectingWorker(SyncWorker worker, ref object? obj) + { + int junk = 12345; + worker.Bind(ref junk); + return false; + } + class TestTypeHelper : SyncTypeHelper { public override List GetImplementations(Type baseType) @@ -198,6 +257,12 @@ public override bool Equals(object? obj) } } + // Not returned by TestTypeHelper + public class C4 : C1 + { + public int a; + } + public interface IA; // Using structs so that equality is by value which simplifies the test code diff --git a/Source/Tests/ServerPlayingStateTest.cs b/Source/Tests/ServerPlayingStateTest.cs new file mode 100644 index 000000000..303171233 --- /dev/null +++ b/Source/Tests/ServerPlayingStateTest.cs @@ -0,0 +1,186 @@ +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +[NonParallelizable] +public class ServerPlayingStateTest +{ + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + nextPlayerId = 0; + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private (ServerPlayer player, RecordingConnection conn) AddPlayer(string username) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn); + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return (player, conn); + } + + [TestCase(CommandType.PauseAll, ScheduledCommand.Global)] + [TestCase(CommandType.CreateJoinPoint, ScheduledCommand.Global)] + [TestCase(CommandType.InitPlayerData, ScheduledCommand.Global)] + [TestCase(CommandType.ResetPlayerCounts, ScheduledCommand.Global)] + [TestCase(CommandType.GlobalTimeSpeed, 0)] + [TestCase(CommandType.TimeSpeedVote, 3)] + [TestCase(CommandType.PlayerCount, 1)] + [TestCase(CommandType.Designator, ScheduledCommand.Global)] + [TestCase(CommandType.MapTimeSpeed, ScheduledCommand.Global)] + [TestCase(CommandType.Sync, -2)] + [TestCase(CommandType.DebugTools, int.MinValue)] + [TestCase((CommandType)200, ScheduledCommand.Global)] + public void InvalidClientCommandIsRejected(CommandType type, int mapId) + { + var (player, _) = AddPlayer("player"); + var state = player.conn.GetState()!; + + Assert.Throws(() => + state.HandleClientCommand(new ClientCommandPacket(type, mapId, ByteWriter.GetBytes(-1, 0)))); + Assert.That(server.commands.SentCmds, Is.Zero); + Assert.That(server.worldData.cmds, Is.Empty); + } + + [TestCase(CommandType.GlobalTimeSpeed, ScheduledCommand.Global)] + [TestCase(CommandType.TimeSpeedVote, ScheduledCommand.Global)] + [TestCase(CommandType.PlayerCount, ScheduledCommand.Global)] + [TestCase(CommandType.Designator, 0)] + [TestCase(CommandType.MapTimeSpeed, 5)] + [TestCase(CommandType.DebugTools, ScheduledCommand.Global)] + [TestCase(CommandType.DebugTools, 3)] + public void ValidClientCommandIsScheduled(CommandType type, int mapId) + { + var (player, conn) = AddPlayer("player"); + var state = player.conn.GetState()!; + server.settings.debugMode = true; + server.settings.devModeScope = DevModeScope.Everyone; + + state.HandleClientCommand(new ClientCommandPacket(type, mapId, ByteWriter.GetBytes(-1, 0))); + + Assert.That(server.commands.SentCmds, Is.EqualTo(1)); + Assert.That(conn.SentPackets, Does.Contain(Packets.Server_Command)); + } + + [Test] + public void ServerOnlyCommandDisconnectsSender() + { + var (player, conn) = AddPlayer("player"); + + var cmd = new ClientCommandPacket(CommandType.InitPlayerData, ScheduledCommand.Global, + ByteWriter.GetBytes(player.id, true)).Serialize(); + var raw = new byte[cmd.data.Length + 1]; + raw[0] = (byte)cmd.id; + cmd.data.CopyTo(raw, 1); + + var prevError = ServerLog.error; + ServerLog.error = _ => { }; + try + { + player.HandleReceive(new ByteReader(raw), true); + } + finally + { + ServerLog.error = prevError; + } + + Assert.That(conn.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(server.commands.SentCmds, Is.Zero); + } + + [Test] + public void PlayerCanOnlySetOwnFaction() + { + var (alice, aliceConn) = AddPlayer("alice"); + var (bob, _) = AddPlayer("bob"); + alice.FactionId = 1; + bob.FactionId = 1; + + var state = alice.conn.GetState()!; + state.HandleSetFaction(new ClientSetFactionPacket(bob.id, 2)); + + Assert.That(bob.FactionId, Is.EqualTo(1)); + Assert.That(aliceConn.SentPackets, Does.Not.Contain(Packets.Server_SetFaction)); + + state.HandleSetFaction(new ClientSetFactionPacket(alice.id, 2)); + + Assert.That(alice.FactionId, Is.EqualTo(2)); + Assert.That(aliceConn.SentPackets, Does.Contain(Packets.Server_SetFaction)); + } + + [Test] + public void HostCanSetOtherPlayersFaction() + { + var (host, _) = AddPlayer("host"); + var (bob, _) = AddPlayer("bob"); + server.hostUsername = "host"; + bob.FactionId = 1; + + host.conn.GetState()!.HandleSetFaction(new ClientSetFactionPacket(bob.id, 2)); + + Assert.That(bob.FactionId, Is.EqualTo(2)); + } + + [TestCase(-1000, 0)] + [TestCase(int.MinValue, 0)] + [TestCase(int.MaxValue, ServerPlayer.MaxTicksBehind)] + [TestCase(50, 50)] + public void KeepAliveTicksBehindIsClamped(int reported, int expected) + { + var (player, _) = AddPlayer("player"); + player.conn.GetState()!.HandleClientKeepAlive(new ClientKeepAlivePacket(0, reported, false, 0)); + Assert.That(player.ticksBehind, Is.EqualTo(expected)); + } + + [TestCase(float.NaN, 0f)] + [TestCase(float.PositiveInfinity, ServerPlayingState.MaxFrameTime)] + [TestCase(-5f, 0f)] + [TestCase(20f, 20f)] + public void FrameTimeIsSanitized(float reported, float expected) + { + var (player, _) = AddPlayer("player"); + player.conn.GetState()!.HandleFrameTime(new ClientFrameTimePacket(reported)); + Assert.That(player.frameTime, Is.EqualTo(expected)); + } + + [Test] + public void StaleKeepAliveDisconnectsPlayingPlayer() + { + var (host, hostConn) = AddPlayer("host"); + server.hostUsername = "host"; + var (loading, loadingConn) = AddPlayer("loading"); + var (stale, staleConn) = AddPlayer("stale"); + var (fresh, freshConn) = AddPlayer("fresh"); + + foreach (var p in new[] { host, stale, fresh }) + p.status = PlayerStatus.Playing; + + host.keepAliveAt = loading.keepAliveAt = stale.keepAliveAt = -PlayerManager.StaleKeepAliveNetTicks - 1; + fresh.keepAliveAt = -PlayerManager.StaleKeepAliveNetTicks + 10; + + server.playerManager.CheckTimeouts(); + + Assert.That(staleConn.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(hostConn.State, Is.EqualTo(ConnectionStateEnum.ServerPlaying)); // In-memory host connection + Assert.That(loadingConn.State, Is.EqualTo(ConnectionStateEnum.ServerPlaying)); // Not in game yet + Assert.That(freshConn.State, Is.EqualTo(ConnectionStateEnum.ServerPlaying)); + } +} diff --git a/Source/Tests/ServerStopTest.cs b/Source/Tests/ServerStopTest.cs new file mode 100644 index 000000000..44558f44f --- /dev/null +++ b/Source/Tests/ServerStopTest.cs @@ -0,0 +1,77 @@ +using Multiplayer.Common; + +namespace Tests; + +[TestFixture] +[NonParallelizable] +public class ServerStopTest +{ + private class StopRecorder : INetManager + { + public readonly List stopThreads = new(); + public volatile bool ticked; + + public void Tick() => ticked = true; + public void Stop() { lock (stopThreads) stopThreads.Add(Environment.CurrentManagedThreadId); } + public string GetDiagnosticsName() => "test"; + public string? GetDiagnosticsInfo() => null; + } + + private static MultiplayerServer MakeServer(StopRecorder recorder) + { + var server = new MultiplayerServer(new ServerSettings { gameName = "Test", direct = false, lan = false }) + { + running = true + }; + server.netManagers.Add(recorder); + return server; + } + + [TearDown] + public void TearDown() => MultiplayerServer.instance = null; + + [Test] + public void StopFromAnotherThreadIsDoneByTheServerThreadOnce() + { + var recorder = new StopRecorder(); + var server = MultiplayerServer.instance = MakeServer(recorder); + var thread = new Thread(server.Run) { IsBackground = true }; + thread.Start(); + SpinWait.SpinUntil(() => recorder.ticked, 2000); + + server.TryStop(); + server.TryStop(); + + Assert.That(thread.IsAlive, Is.False); + Assert.That(recorder.stopThreads, Is.EqualTo(new[] { thread.ManagedThreadId })); + Assert.That(MultiplayerServer.instance, Is.Null); + } + + [Test] + public void ServerStoppingLateKeepsTheNewerInstance() + { + var old = MakeServer(new StopRecorder()); + var newer = MultiplayerServer.instance = MakeServer(new StopRecorder()); + + old.TryStop(); + + Assert.That(MultiplayerServer.instance, Is.SameAs(newer)); + } + + [Test] + public void ServerThreadExitStopsTheServer() + { + var recorder = new StopRecorder(); + var server = MultiplayerServer.instance = MakeServer(recorder); + var thread = new Thread(server.Run) { IsBackground = true }; + thread.Start(); + SpinWait.SpinUntil(() => recorder.ticked, 2000); + + // What the bootstrap and the stop command do from the server thread + server.running = false; + + Assert.That(thread.Join(2000), Is.True); + Assert.That(recorder.stopThreads, Is.EqualTo(new[] { thread.ManagedThreadId })); + Assert.That(MultiplayerServer.instance, Is.Null); + } +} diff --git a/Source/Tests/ServerTest.cs b/Source/Tests/ServerTest.cs index 4cfff8bd5..23858678d 100644 --- a/Source/Tests/ServerTest.cs +++ b/Source/Tests/ServerTest.cs @@ -42,17 +42,21 @@ public void Test() Thread.Sleep(50); } + + // The join flow sends commands and mutates players, which must happen on the server thread + Assert.That(server.OffThreadCalls, Is.Zero); } [Test] public async Task JoinPointAbortUnblocksWaiters() { - var server = new MultiplayerServer(new ServerSettings + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings { gameName = "Test", direct = false, lan = false }); + AddHost(server); Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); Assert.That(server.worldData.CreatingJoinPoint, Is.True); @@ -72,57 +76,84 @@ public async Task JoinPointAbortUnblocksWaiters() [Test] public void LoadingStateHandlesKeepAliveWhileWaitingForJoinPoint() { - var server = MakeServer(out var port); - Assert.That(server.worldData.TryStartJoinPointCreation(true), Is.True); + var server = MakeServer(out var port, s => + { + AddHost(s); + Assert.That(s.worldData.TryStartJoinPointCreation(true), Is.True); + }); _ = Task.Run(async () => { await Task.Delay(200); - server.worldData.EndJoinPointCreation(); + // Join points end on the server thread (world upload handler) + server.Enqueue(() => server.worldData.EndJoinPointCreation()); }); ConnectClient(port, typeof(TestLoadingKeepAliveState)); + // The client joins, waits for the join point, receives the world and leaves; only the host remains + WaitUntil(() => server.playerManager.Players.Count == 2); + WaitUntil(() => server.playerManager.Players.Count == 1); + } + + private static void AddHost(MultiplayerServer server) + { + var conn = new RecordingConnection("host"); + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + var host = new ServerPlayer(100, conn) { hasJoined = true }; + conn.serverPlayer = host; + server.playerManager.Players.Add(host); + server.hostUsername = "host"; + } + + private static void WaitUntil(Func condition) + { var timeoutWatch = Stopwatch.StartNew(); - while (true) + while (!condition()) { - if (server.playerManager.Players.Count == 0) - break; - if (timeoutWatch.ElapsedMilliseconds > 2000) Assert.Fail("Timeout"); - Thread.Sleep(50); + Thread.Sleep(20); } } [Test] public void StandaloneJoinWithExistingPlayer_DoesNotStartJoinPoint() { - var server = MakeServer(out var port); - server.IsStandaloneServer = true; - - var existingConn = new RecordingConnection("existing"); - existingConn.ChangeState(ConnectionStateEnum.ServerPlaying); - var existingPlayer = new ServerPlayer(100, existingConn); - existingConn.serverPlayer = existingPlayer; - server.playerManager.Players.Add(existingPlayer); + var server = MakeServer(out var port, s => + { + s.IsStandaloneServer = true; + // Everything a hosted server would need to start a join point on join + s.settings.autoJoinPoint = AutoJoinPointFlags.Join; + + var existingConn = new RecordingConnection("existing"); + existingConn.ChangeState(ConnectionStateEnum.ServerPlaying); + var existingPlayer = new ServerPlayer(100, existingConn) { hasJoined = true, status = PlayerStatus.Playing }; + existingConn.serverPlayer = existingPlayer; + s.playerManager.Players.Add(existingPlayer); + }); ConnectClient(port, typeof(TestJoiningState)); - var timeoutWatch = Stopwatch.StartNew(); - while (true) - { - if (server.playerManager.Players.Count == 1) - break; + // The client leaving would abort a started join point, but its command stays in the log + bool JoinPointStarted() => OnServerThread(server, () => server.worldData.cmds + .Any(raw => ScheduledCommand.Deserialize(new ByteReader(raw)).type == CommandType.CreateJoinPoint)); - if (timeoutWatch.ElapsedMilliseconds > 2000) - Assert.Fail("Timeout"); + // The existing player alone makes the count 1: wait for the client to come and go (with a join point, it + // would wait for it instead) + WaitUntil(() => server.playerManager.Players.Count == 2); + WaitUntil(() => server.playerManager.Players.Count == 1 || JoinPointStarted()); - Thread.Sleep(50); - } + Assert.That(JoinPointStarted(), Is.False); + } - Assert.That(server.worldData.CreatingJoinPoint, Is.False); + private static T OnServerThread(MultiplayerServer server, Func func) + { + var result = new TaskCompletionSource(); + server.Enqueue(() => result.SetResult(func())); + Assert.That(result.Task.Wait(2000), Is.True, "The server thread didn't run the action"); + return result.Task.Result; } private void ConnectClient(int port, Type joiningStateType) @@ -146,7 +177,8 @@ private void ConnectClient(int port, Type joiningStateType) }) { IsBackground = true }.Start(); } - private MultiplayerServer MakeServer(out int port) + /// beforeStart sets up server state before the server thread runs, which owns it from then on + private MultiplayerServer MakeServer(out int port, Action? beforeStart = null) { var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings { @@ -170,14 +202,12 @@ private MultiplayerServer MakeServer(out int port) port = liteNet.netManagers[0].manager.LocalPort; + beforeStart?.Invoke(server); + var serverThread = new Thread(server.Run) { IsBackground = true }; serverThread.Start(); - teardownActions.Add(() => - { - server.running = false; - serverThread.Join(timeout: TimeSpan.FromSeconds(2)); - }); + teardownActions.Add(server.TryStop); return server; } diff --git a/Source/Tests/StandaloneMapStreamingTest.cs b/Source/Tests/StandaloneMapStreamingTest.cs index 6676875b1..95dbc7026 100644 --- a/Source/Tests/StandaloneMapStreamingTest.cs +++ b/Source/Tests/StandaloneMapStreamingTest.cs @@ -90,7 +90,7 @@ public void StandaloneStreamingDisabled_BroadcastsEvenWithWorldViewer() public void InitialPlayerCountMapReport_DoesNotSendMapResponse() { server.worldData.mapData[5] = [9, 9, 9]; - server.worldData.mapCmds[5] = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; + server.worldData.cmds = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; var (player, conn) = AddPlayer("player", -1, hasReportedCurrentMap: false); var state = player.conn.GetState()!; @@ -109,7 +109,7 @@ public void InitialPlayerCountMapReport_DoesNotSendMapResponse() public void WorldToMapTransition_DoesNotSendMapResponse() { server.worldData.mapData[5] = [9, 9, 9]; - server.worldData.mapCmds[5] = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; + server.worldData.cmds = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; var (player, conn) = AddPlayer("player", -2); var state = player.conn.GetState()!; @@ -127,7 +127,7 @@ public void WorldToMapTransition_DoesNotSendMapResponse() public void MapToMapTransition_DoesNotSendMapResponseWhenStreamingDisabled() { server.worldData.mapData[5] = [9, 9, 9]; - server.worldData.mapCmds[5] = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; + server.worldData.cmds = [ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 10, 0, 5, 1, []))]; var (player, conn) = AddPlayer("player", 3); var state = player.conn.GetState()!; @@ -146,14 +146,14 @@ public void HandleDebug_IgnoredWhenDevModeDisabled() { server.gameTimer = 123; server.startingTimer = 5; - server.worldData.mapCmds[1] = [[1]]; + server.worldData.cmds = [[1]]; var (player, conn) = AddPlayer("player", 1); var state = player.conn.GetState()!; state.HandleDebug(new ClientDebugPacket()); Assert.That(server.gameTimer, Is.EqualTo(123)); - Assert.That(server.worldData.mapCmds[1], Has.Count.EqualTo(1)); + Assert.That(server.worldData.cmds, Has.Count.EqualTo(1)); Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_Debug)); } @@ -164,14 +164,14 @@ public void HandleDebug_AllowsPlayersWhenDevModeEnabled() server.settings.devModeScope = DevModeScope.Everyone; server.gameTimer = 123; server.startingTimer = 5; - server.worldData.mapCmds[1] = [[1]]; + server.worldData.cmds = [[1]]; var (player, conn) = AddPlayer("player", 1); var state = player.conn.GetState()!; state.HandleDebug(new ClientDebugPacket()); Assert.That(server.gameTimer, Is.EqualTo(5)); - Assert.That(server.worldData.mapCmds, Is.Empty); + Assert.That(server.worldData.cmds, Is.Empty); Assert.That(conn.SentPackets, Does.Contain(Packets.Server_Debug)); } } diff --git a/Source/Tests/StandalonePersistenceTest.cs b/Source/Tests/StandalonePersistenceTest.cs index dd421a361..4fb31b5e0 100644 --- a/Source/Tests/StandalonePersistenceTest.cs +++ b/Source/Tests/StandalonePersistenceTest.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Compression; +using System.Linq; using Multiplayer.Common; namespace Tests; @@ -9,17 +11,26 @@ namespace Tests; public class StandalonePersistenceTest { private string tempDir = null!; + private readonly List errors = new(); + private Action? prevError; + + private string SavedDir => Path.Combine(tempDir, "Saved"); [SetUp] public void SetUp() { tempDir = Path.Combine(Path.GetTempPath(), $"mp-standalone-persistence-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); + + errors.Clear(); + prevError = ServerLog.error; + ServerLog.error = errors.Add; } [TearDown] public void TearDown() { + ServerLog.error = prevError; MultiplayerServer.instance = null; if (Directory.Exists(tempDir)) @@ -39,10 +50,10 @@ public void WriteJoinPoint_PersistsCommandsAndTickForReload() server.worldData.sessionData = [4, 5, 6]; server.worldData.mapData[7] = [7, 8, 9]; - var worldCmd = ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Sync, 1234, 1, ScheduledCommand.Global, 5, [10])); + // Map command first: the persisted log must keep the send order across tickables var mapCmd = ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Designator, 1234, 1, 7, 5, [11])); - server.worldData.mapCmds[ScheduledCommand.Global] = [worldCmd]; - server.worldData.mapCmds[7] = [mapCmd]; + var worldCmd = ScheduledCommand.Serialize(new ScheduledCommand(CommandType.Sync, 1234, 1, ScheduledCommand.Global, 5, [10])); + server.worldData.cmds = [mapCmd, worldCmd]; server.persistence.WriteJoinPoint(server.worldData, 1234); @@ -60,18 +71,188 @@ public void WriteJoinPoint_PersistsCommandsAndTickForReload() Assert.That(reloadedServer.worldData.savedGame, Is.EqualTo(new byte[] { 1, 2, 3 })); Assert.That(reloadedServer.worldData.sessionData, Is.EqualTo(new byte[] { 4, 5, 6 })); Assert.That(reloadedServer.worldData.mapData[7], Is.EqualTo(new byte[] { 7, 8, 9 })); - Assert.That(reloadedServer.worldData.mapCmds[ScheduledCommand.Global], Has.Count.EqualTo(1)); - Assert.That(reloadedServer.worldData.mapCmds[7], Has.Count.EqualTo(1)); - - var reloadedWorldCmd = ScheduledCommand.Deserialize(new ByteReader(reloadedServer.worldData.mapCmds[ScheduledCommand.Global][0])); - var reloadedMapCmd = ScheduledCommand.Deserialize(new ByteReader(reloadedServer.worldData.mapCmds[7][0])); - - Assert.That(reloadedWorldCmd.ticks, Is.EqualTo(1234)); - Assert.That(reloadedMapCmd.ticks, Is.EqualTo(1234)); + Assert.That(reloadedServer.worldData.cmds, Is.EqualTo(new[] { mapCmd, worldCmd })); Assert.That(reloadedServer.worldData.standaloneWorldSnapshot.tick, Is.EqualTo(1234)); Assert.That(reloadedServer.worldData.standaloneMapSnapshots[7].tick, Is.EqualTo(1234)); } + [Test] + public void LoadInto_DiscardsLegacyCommandFiles() + { + var persistence = new StandalonePersistence(tempDir); + persistence.EnsureDirectories(); + + var savedDir = Path.Combine(tempDir, "Saved"); + File.WriteAllBytes(Path.Combine(savedDir, "world.dat"), [1]); + File.WriteAllBytes(Path.Combine(savedDir, "maps", "7.dat"), [2]); + File.WriteAllBytes(Path.Combine(savedDir, "world_cmds.dat"), ScheduledCommand.SerializeCmds([ + Cmd(10, ScheduledCommand.Global, 1), + Cmd(20, ScheduledCommand.Global, 2), + ])); + File.WriteAllBytes(Path.Combine(savedDir, "maps", "7_cmds.dat"), ScheduledCommand.SerializeCmds([ + Cmd(5, 7, 3), + Cmd(10, 7, 4), + ])); + + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = persistence, + }; + + persistence.LoadInto(server); + + // Only versions before cmds.dat (an older protocol) wrote them + Assert.That(server.worldData.cmds, Is.Empty); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + Assert.That(server.worldData.mapData[7], Is.EqualTo(new byte[] { 2 })); + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0], Does.Contain("Discarding the saved command log (4 commands)")); + } + + [Test] + public void WriteJoinPoint_ReplacesLegacyCommandFiles() + { + var persistence = new StandalonePersistence(tempDir); + persistence.EnsureDirectories(); + + var savedDir = Path.Combine(tempDir, "Saved"); + File.WriteAllBytes(Path.Combine(savedDir, "world_cmds.dat"), ScheduledCommand.SerializeCmds([Cmd(1, ScheduledCommand.Global, 9)])); + File.WriteAllBytes(Path.Combine(savedDir, "maps", "7_cmds.dat"), ScheduledCommand.SerializeCmds([Cmd(1, 7, 9)])); + + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = persistence, + }; + server.worldData.savedGame = [1]; + server.worldData.mapData[7] = [2]; + server.worldData.cmds = [ScheduledCommand.Serialize(Cmd(50, 7, 1)), ScheduledCommand.Serialize(Cmd(50, ScheduledCommand.Global, 2))]; + + persistence.WriteJoinPoint(server.worldData, 50); + + Assert.That(File.Exists(Path.Combine(savedDir, "world_cmds.dat")), Is.False); + Assert.That(File.Exists(Path.Combine(savedDir, "maps", "7_cmds.dat")), Is.False); + + var reloaded = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(tempDir), + }; + reloaded.persistence.LoadInto(reloaded); + + Assert.That(DataBytes(reloaded.worldData.cmds), Is.EqualTo(new byte[] { 1, 2 })); + } + + private MultiplayerServer Reload() + { + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(tempDir), + }; + server.persistence.LoadInto(server); + return server; + } + + private void WriteJoinPoint(byte world, params byte[] cmdMarkers) + { + var server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings()) + { + IsStandaloneServer = true, + persistence = new StandalonePersistence(tempDir), + }; + server.worldData.savedGame = [world]; + server.worldData.mapData[7] = [world]; + server.worldData.cmds = cmdMarkers.Select(m => ScheduledCommand.Serialize(Cmd(50, ScheduledCommand.Global, m))).ToList(); + server.persistence.WriteJoinPoint(server.worldData, 60); + } + + [Test] + public void LoadInto_KeepsCommandsOfTheCurrentProtocol() + { + WriteJoinPoint(1, 5, 6); + + Assert.That(File.ReadAllText(Path.Combine(SavedDir, "cmds_protocol.txt")), Is.EqualTo(MpVersion.Protocol.ToString())); + Assert.That(DataBytes(Reload().worldData.cmds), Is.EqualTo(new byte[] { 5, 6 })); + Assert.That(errors, Is.Empty); + } + + [Test] + public void LoadInto_DiscardsCommandsOfAnotherProtocol([Values(true, false)] bool protocolKnown) + { + WriteJoinPoint(1, 5, 6); + + var protocolPath = Path.Combine(SavedDir, "cmds_protocol.txt"); + if (protocolKnown) + File.WriteAllText(protocolPath, "56"); + else + File.Delete(protocolPath); + + var server = Reload(); + + Assert.That(server.worldData.cmds, Is.Empty); + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 1 })); + Assert.That(server.worldData.mapData[7], Is.EqualTo(new byte[] { 1 })); + Assert.That(server.gameTimer, Is.EqualTo(60)); + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0], Does.Contain("Discarding the saved command log (2 commands)") + .And.Contain(protocolKnown ? "protocol 56" : "an older version")); + } + + [Test] + public void InterruptedJoinPointWriteDiscardsTheLog() + { + WriteJoinPoint(1, 5); + + // The next join point fails after writing the world + Directory.CreateDirectory(Path.Combine(SavedDir, "cmds.dat.tmp")); + Assert.That(() => WriteJoinPoint(2, 6), NUnit.Framework.Throws.Exception); + Directory.Delete(Path.Combine(SavedDir, "cmds.dat.tmp")); + + var server = Reload(); + + // The old log doesn't belong to the new world + Assert.That(server.worldData.savedGame, Is.EqualTo(new byte[] { 2 })); + Assert.That(server.worldData.cmds, Is.Empty); + } + + [Test] + public void SeedFromSaveZip_KeepsCommandsOnlyFromTheCurrentProtocol([Values(true, false)] bool currentProtocol) + { + var zipPath = Path.Combine(tempDir, "save.zip"); + using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + void Entry(string name, byte[] data) + { + using var stream = zip.CreateEntry(name).Open(); + stream.Write(data); + } + + Entry("world/000_save", [1]); + Entry("world/000_cmds", ScheduledCommand.SerializeCmds([Cmd(10, ScheduledCommand.Global, 5)])); + Entry("info", ReplayInfo.Write(new ReplayInfo + { + protocol = currentProtocol ? MpVersion.Protocol : MpVersion.Protocol - 1, + sections = [new ReplaySection(10, 20)] + })); + } + + new StandalonePersistence(tempDir).SeedFromSaveZip(zipPath); + var server = Reload(); + + Assert.That(server.worldData.savedGame, Is.Not.Null); + Assert.That(server.gameTimer, Is.EqualTo(20)); + Assert.That(DataBytes(server.worldData.cmds), Is.EqualTo(currentProtocol ? new byte[] { 5 } : [])); + Assert.That(errors, Has.Count.EqualTo(currentProtocol ? 0 : 1)); + } + + private static ScheduledCommand Cmd(int ticks, int mapId, byte marker) => + new(CommandType.Sync, ticks, 0, mapId, 0, [marker]); + + private static byte[] DataBytes(List cmds) => + cmds.Select(cmd => ScheduledCommand.Deserialize(new ByteReader(cmd)).data[0]).ToArray(); + [Test] public void LoadInto_FallsBackToLatestReplaySectionTick() { @@ -100,5 +281,7 @@ public void LoadInto_FallsBackToLatestReplaySectionTick() Assert.That(server.gameTimer, Is.EqualTo(999)); Assert.That(server.startingTimer, Is.EqualTo(999)); + // An empty legacy log loses nothing + Assert.That(errors, Is.Empty); } } \ No newline at end of file diff --git a/Source/Tests/SyncReferenceTest.cs b/Source/Tests/SyncReferenceTest.cs new file mode 100644 index 000000000..51a60c120 --- /dev/null +++ b/Source/Tests/SyncReferenceTest.cs @@ -0,0 +1,175 @@ +using System.Linq; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +/// Desync detection compares every client against one reference client's sync opinions +[TestFixture] +public class SyncReferenceTest +{ + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false, + pauseOnDesync = false, + autoJoinPoint = 0 + }); + nextPlayerId = 0; + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private ServerPlayer AddPlayer(string username, PlayerStatus status = PlayerStatus.Playing, + PlayerType type = PlayerType.Normal) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn) { hasJoined = true, status = status, type = type }; + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return player; + } + + private static RecordingConnection Conn(ServerPlayer p) => (RecordingConnection)p.conn; + + private static void SendOpinion(ServerPlayer from) => + from.conn.GetState()!.HandleDesyncCheck(new ClientSyncInfoPacket { rawSyncOpinion = [1, 2, 3] }); + + private static bool? LastReferenceFlag(ServerPlayer p) + { + var msg = Conn(p).SentMessages.LastOrDefault(m => m.id == Packets.Server_SyncReference); + return msg.data == null ? null : msg.data[0] == 1; + } + + [Test] + public void StandaloneForwardsOnlyReferenceOpinions() + { + server.IsStandaloneServer = true; + var reference = AddPlayer("reference"); + var other = AddPlayer("other"); + var joining = AddPlayer("joining", PlayerStatus.Simulating); + + SendOpinion(other); + Assert.That(server.worldData.syncInfos, Is.Empty); + Assert.That(Conn(reference).SentPackets, Does.Not.Contain(Packets.Server_SyncInfo)); + + SendOpinion(reference); + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(1)); + Assert.That(Conn(other).SentPackets, Does.Contain(Packets.Server_SyncInfo)); + Assert.That(Conn(joining).SentPackets, Does.Contain(Packets.Server_SyncInfo)); + Assert.That(Conn(reference).SentPackets, Does.Not.Contain(Packets.Server_SyncInfo)); + } + + [Test] + public void StandaloneReferenceChangeIsAnnounced() + { + server.IsStandaloneServer = true; + var first = AddPlayer("first"); + var second = AddPlayer("second"); + + server.playerManager.UpdateSyncReference(); + Assert.That(LastReferenceFlag(first), Is.True); + Assert.That(LastReferenceFlag(second), Is.Null); + + SendOpinion(first); + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(1)); + + server.playerManager.OnDesync(first, 0, 0); + + Assert.That(server.playerManager.SyncReference, Is.SameAs(second)); + Assert.That(LastReferenceFlag(first), Is.False); + Assert.That(LastReferenceFlag(second), Is.True); + // Opinions of the previous reference aren't used for joiners + Assert.That(server.worldData.syncInfos, Is.Empty); + } + + [Test] + public void StandaloneReferenceMovesWhenItRejoinsAndReturnsAfterWorldReady() + { + server.IsStandaloneServer = true; + var first = AddPlayer("first"); + var second = AddPlayer("second"); + server.playerManager.UpdateSyncReference(); + + var state = first.conn.GetState()!; + state.HandleRejoin(new ByteReader([])); + Assert.That(server.playerManager.SyncReference, Is.SameAs(second)); + Assert.That(LastReferenceFlag(first), Is.False); + + // Back in the playing state after loading, reference again once caught up + first.conn.ChangeState(ConnectionStateEnum.ServerPlaying); + first.conn.GetState()!.HandleWorldReady(new ByteReader([])); + Assert.That(server.playerManager.SyncReference, Is.SameAs(first)); + Assert.That(LastReferenceFlag(first), Is.True); + Assert.That(LastReferenceFlag(second), Is.False); + } + + [Test] + public void HostedForwardsHostOpinionsToOthers() + { + var host = AddPlayer("host"); + server.hostUsername = "host"; + var other = AddPlayer("other"); + + SendOpinion(other); + Assert.That(server.worldData.syncInfos, Is.Empty); + + SendOpinion(host); + Assert.That(Conn(other).SentPackets, Does.Contain(Packets.Server_SyncInfo)); + Assert.That(Conn(host).SentPackets, Does.Not.Contain(Packets.Server_SyncInfo)); + + // Hosted clients know their role, no announcements + server.playerManager.UpdateSyncReference(); + Assert.That(Conn(host).SentPackets, Does.Not.Contain(Packets.Server_SyncReference)); + } + + [Test] + public void StandaloneForwardsLargeTracesFragmented() + { + server.IsStandaloneServer = true; + var reference = AddPlayer("reference"); + var desynced = AddPlayer("desynced"); + + server.playerManager.OnDesync(desynced, 0, 0); + Assert.That(Conn(reference).SentPackets, Does.Contain(Packets.Server_Traces)); + + // Compressed traces and jitted methods can exceed a single packet + var traces = new byte[ConnectionBase.MaxSinglePacketSize * 2]; + new Random(1).NextBytes(traces); + Assert.DoesNotThrow(() => reference.conn.GetState()!.HandleTraces(new ClientTracesPacket + { + playerId = desynced.id, rawTraces = traces, rawJittedMethods = [1] + })); + + var sent = Conn(desynced).SentRaw.Where(raw => (Packets)(raw[0] & 0x3F) == Packets.Server_Traces).ToList(); + Assert.That(sent, Has.Count.GreaterThan(1)); + Assert.That(sent.All(raw => (raw[0] & 0xC0) != 0), Is.True); + } + + [Test] + public void HostedForwardsArbiterOpinionsToEveryoneButArbiter() + { + var host = AddPlayer("host"); + server.hostUsername = "host"; + var arbiter = AddPlayer("arbiter", type: PlayerType.Arbiter); + + SendOpinion(host); + Assert.That(server.worldData.syncInfos, Is.Empty); + + SendOpinion(arbiter); + Assert.That(Conn(host).SentPackets, Does.Contain(Packets.Server_SyncInfo)); + Assert.That(Conn(arbiter).SentPackets, Does.Not.Contain(Packets.Server_SyncInfo)); + } +} diff --git a/Source/Tests/WorldDataSizeTest.cs b/Source/Tests/WorldDataSizeTest.cs new file mode 100644 index 000000000..19ddcd3ab --- /dev/null +++ b/Source/Tests/WorldDataSizeTest.cs @@ -0,0 +1,182 @@ +using System.Collections.Generic; +using System.Linq; +using Multiplayer.Common; +using Multiplayer.Common.Networking.Packet; + +namespace Tests; + +/// The world data sent to joiners is one fragmented packet: the stored sync opinions must never push it over the limit +[TestFixture] +public class WorldDataSizeTest +{ + private const int MiB = 1024 * 1024; + + private MultiplayerServer server = null!; + private int nextPlayerId; + + [SetUp] + public void SetUp() + { + server = MultiplayerServer.instance = new MultiplayerServer(new ServerSettings + { + gameName = "Test", + direct = false, + lan = false + }); + server.worldData.savedGame = [1]; + server.worldData.sessionData = [2]; + nextPlayerId = 0; + } + + [TearDown] + public void TearDown() + { + MultiplayerServer.instance = null; + } + + private (ServerPlayer player, RecordingConnection conn) AddPlayer(string username) + { + var conn = new RecordingConnection(username); + var player = new ServerPlayer(nextPlayerId++, conn) { hasJoined = true }; + conn.serverPlayer = player; + conn.ChangeState(ConnectionStateEnum.ServerPlaying); + server.playerManager.Players.Add(player); + return (player, conn); + } + + private static byte[] Opinion(byte marker, int length) + { + var opinion = new byte[length]; + opinion[0] = marker; + return opinion; + } + + // Reassembles the fragments the way ConnectionBase.HandleReceiveFragment does + private static byte[] ReceivedFragmented(RecordingConnection conn, Packets id) + { + var data = new List(); + foreach (var raw in conn.SentRaw.Where(raw => (Packets)(raw[0] & 0x3F) == id && (raw[0] & 0xC0) != 0)) + data.AddRange(data.Count == 0 ? raw[8..] : raw[2..]); + return data.ToArray(); + } + + private static List SentOpinionMarkers(byte[] worldData) + { + var reader = new ByteReader(worldData); + reader.ReadInt32(); // faction + reader.ReadInt32(); // timer + reader.ReadInt32(); // sent cmds + reader.ReadBool(); // frozen + reader.ReadPrefixedBytes(); // game + reader.ReadPrefixedBytes(); // session + var cmds = reader.ReadInt32(); + for (int i = 0; i < cmds; i++) reader.ReadPrefixedBytes(); + var maps = reader.ReadInt32(); + for (int i = 0; i < maps; i++) + { + reader.ReadInt32(); + reader.ReadPrefixedBytes(); + } + + var opinions = reader.ReadInt32(); + var markers = Enumerable.Range(0, opinions).Select(_ => reader.ReadPrefixedBytes()![0]).ToList(); + Assert.That(reader.Left, Is.Zero); + return markers; + } + + [Test] + public void StoredOpinionsAreBoundedDroppingTheOldest() + { + for (byte i = 0; i < WorldData.MaxSyncInfos + 2; i++) + server.worldData.AddSyncInfo([i]); + + Assert.That(server.worldData.syncInfos.Select(o => o[0]), + Is.EqualTo(Enumerable.Range(2, WorldData.MaxSyncInfos).Select(i => (byte)i))); + + // Opinions with desync traces of a big save + for (byte i = 20; i < 24; i++) + server.worldData.AddSyncInfo(Opinion(i, 3 * MiB)); + + Assert.That(server.worldData.syncInfos.Select(o => o[0]), Is.EqualTo(new byte[] { 22, 23 })); + Assert.That(server.worldData.syncInfos.Sum(o => o.Length), Is.LessThanOrEqualTo(WorldData.MaxSyncInfosLength)); + } + + [Test] + public void TheNewestOpinionIsAlwaysKept() + { + server.worldData.AddSyncInfo(Opinion(1, 2 * MiB)); + server.worldData.AddSyncInfo(Opinion(2, WorldData.MaxSyncInfosLength)); + + Assert.That(server.worldData.syncInfos.Select(o => o[0]), Is.EqualTo(new byte[] { 2 })); + } + + [Test] + public void WorldDataSendsOnlyTheNewestOpinionsThatFit() + { + for (byte i = 0; i < 8; i++) + server.worldData.AddSyncInfo(Opinion(i, MiB)); + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(8)); + + // A big save leaves room for two and a half of them + server.worldData.savedGame = new byte[ConnectionBase.MaxFragmentedMessageLength - MiB * 5 / 2]; + + var (_, conn) = AddPlayer("joiner"); + Assert.That(new ServerLoadingState(conn).SendWorldData(), Is.True); + + var worldData = ReceivedFragmented(conn, Packets.Server_WorldData); + Assert.That(worldData.Length, Is.LessThanOrEqualTo(ConnectionBase.MaxFragmentedMessageLength)); + Assert.That(SentOpinionMarkers(worldData), Is.EqualTo(new byte[] { 6, 7 })); + Assert.That(conn.SentPackets.First(), Is.EqualTo(Packets.Server_WorldDataStart)); + // The stored ones stay for joiners with smaller world data + Assert.That(server.worldData.syncInfos, Has.Count.EqualTo(8)); + } + + [Test] + public void SmallWorldDataSendsAllOpinions() + { + for (byte i = 0; i < 3; i++) + server.worldData.AddSyncInfo([i]); + + var (_, conn) = AddPlayer("joiner"); + Assert.That(new ServerLoadingState(conn).SendWorldData(), Is.True); + + var worldData = conn.SentMessages.Single(m => m.id == Packets.Server_WorldData).data; + Assert.That(SentOpinionMarkers(worldData), Is.EqualTo(new byte[] { 0, 1, 2 })); + } + + [Test] + public void OversizeWorldDataDisconnectsWithAReason() + { + // The command log since the last join point comes on top of a world that fit into the upload + server.worldData.savedGame = new byte[ConnectionBase.MaxFragmentedMessageLength - 1000]; + var (_, liveConn) = AddPlayer("live"); + for (int i = 0; i < 10; i++) + server.commands.Send(CommandType.Sync, 0, 1, new byte[200]); + + var (joiner, conn) = AddPlayer("joiner"); + var errors = new List(); + var prevError = ServerLog.error; + ServerLog.error = errors.Add; + try + { + Assert.That(new ServerLoadingState(conn).SendWorldData(), Is.False); + } + finally + { + ServerLog.error = prevError; + } + + Assert.That(errors, Has.Count.EqualTo(1)); + Assert.That(errors[0], Does.Contain("joiner").And.Contain("too big")); + + Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_WorldData)); + Assert.That(conn.SentPackets, Does.Not.Contain(Packets.Server_WorldDataStart)); + Assert.That(conn.State, Is.EqualTo(ConnectionStateEnum.Disconnected)); + Assert.That(conn.Goodbye?.reason, Is.EqualTo(MpDisconnectReason.GenericKeyed)); + Assert.That(new ByteReader(conn.Goodbye!.Value.data).ReadString(), + Does.Contain("too big").And.Contain("10 commands")); + Assert.That(server.playerManager.Players, Does.Not.Contain(joiner)); + Assert.That(liveConn.SentPackets, Does.Contain(Packets.Server_Chat)); + Assert.That(liveConn.State, Is.EqualTo(ConnectionStateEnum.ServerPlaying)); + } +} diff --git a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt index b87199a17..6315fe5d8 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-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-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 (128 bytes) diff --git a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt index 98e950920..cb70302a5 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-07-00-00-00-45-6E-67-6C-69-73-68-05-00-00-00-00-00-02-03-01-01-00-00-00-0D-00-00-00-53-65-72-76-65-72-4F-6E-6C-79-44-65-66-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-02-00 (129 bytes) diff --git a/Source/Tests/packet-serializations/ServerSyncReferencePacket.verified.txt b/Source/Tests/packet-serializations/ServerSyncReferencePacket.verified.txt new file mode 100644 index 000000000..4f32769c1 --- /dev/null +++ b/Source/Tests/packet-serializations/ServerSyncReferencePacket.verified.txt @@ -0,0 +1,2 @@ +01 +00 diff --git a/docs/desync-review.md b/docs/desync-review.md new file mode 100644 index 000000000..2cc833ce4 --- /dev/null +++ b/docs/desync-review.md @@ -0,0 +1,788 @@ +# Desync review: changes on `fix/desync-review` + +This document lists every change on the `fix/desync-review` branch, grouped by area. It is written for reviewing the +pull request and can be removed before merging. + +## Scope + +The branch has 182 commits on top of `dev` at `e760d30` (192 files, +8957/−1468). The work was done in three stages: + +1. A full review of the code that decides whether clients stay in sync: the command log and join points, the server + and networking, RNG and simulation contexts, the sync framework, sessions, multifaction faction contexts, async + time and the tick loop, the storyteller and quests, and the Odyssey gravship. +2. A review of all 28 open pull requests and about 60 open issues against the branch. Useful parts of open PRs were + ported or reimplemented; the entries below credit them. +3. Fixes for the open issues that were still not addressed after stage 2. + +Most of the desyncs fixed here fall into a few groups: + +- Code that runs on only some clients (the interface, the client that issued a command, the camera or view, local + settings, the game language) changed shared state or used the synced RNG. +- Global state was set in a Harmony prefix and restored in a postfix, which does not run when the original method + throws. +- Joiners and reloading clients reached a state by a different path than live clients (command order, `ProgramState`, + state that is not saved). +- In multifaction, simulation code ran with the local player's faction data instead of a faction chosen by the + simulation. +- The interface and the simulation shared a cache, so a value cached by the interface on one client was later read by + the simulation. + +Conventions: hashes are commits on `fix/desync-review`; `#N` are issues and pull requests in rwmt/Multiplayer. +"Fixes #N" means the listed commits fully fix the issue; "part of #N" means they address part of it. + +Terms used below: + +- **Sync reference**: the client whose state counts as correct: the arbiter, else the host, else (on a standalone + server) the longest-connected player that is caught up and in sync. +- **Join point**: a saved world plus the command log after it, which joiners load and replay. +- **Sim long event**: a long event queued during a tick or command, which runs later in the frame, outside of it. +- **Forced faction push**: `map.PushFaction(f, force: true)`, which (after `c3889fa`) applies a faction's data to the + world and to every map. +- **`LocalOnlyScope`**: runs code with a pushed RNG state and local unique ids, with desync traces ignored + (`5a92b1d`). + +## 1. Joining, command log and join points + +### Command log + +- **One globally ordered command log for joiners.** Live clients run all commands from one queue in arrival order + (#734), but the server stored commands per map and joiners replayed them "world first, then each map", so any tick + with commands for two tickables ran in a different order on joiners (and `AsyncTimeComp.FinalizeInit` could drop or + duplicate map commands received during the load). The server now keeps one ordered list (`Saved/cmds.dat` on + standalone), clients build the queue from it in `Loader.PostLoad`, `RunCmds` drops a command for a past tick with an + error instead of blocking forever, and the `consistentCommandOrder` tweak value is removed; protocol bumped to 57. + `7d09265`, test `0bd4e06` +- **Command size is checked before the command is logged.** The server's rebroadcast adds a 17-byte header, so a + client command near the 65535-byte limit made `Send` throw after the command was already in the log, and joiners + executed a command that live clients never received. Command data is now capped at what fits the rebroadcast and + rejected before logging. `ec1900b` (the cap was later raised to 1 MiB by fragmented commands, `f8dd311`) +- **Saved standalone command logs from other protocols are discarded.** Nothing recorded which protocol wrote the + command log in `Saved/`, so a log from protocol 56 could be replayed with misread sync data. The protocol is now + written to `Saved/cmds_protocol.txt` and a log without a matching protocol is discarded with an error, while the + saved world, maps and session still load; legacy per-map logs are no longer merged. `2947cb7` + +### Join points + +- **Join points are atomic and have one designated uploader.** On standalone servers a join point reloaded only the + issuing client (#874), but a reload is not a no-op (LoadPatch's extra tick, the seed long event, statics reset by + `MultiplayerGame`, state that isn't saved), so that client drifted from the others and joiners loading its save + drifted from live clients. Now every client reloads, as in hosted mode; the server names the uploader (the sync + reference) in `CreateJoinPoint`, accepts only its upload for that tick and swaps world, maps and command log + together. A join point is aborted when the uploader leaves, desyncs or rejoins, or after 60 s without progress; + forced join points are rate-limited to one per 10 s; minute-based standalone autosaves are issued by the server at a + tick; world-travel join point requests are ignored. `bdf1995` +- **Join points don't time out while the uploader is saving.** Only upload fragments counted as progress, so a big + game whose save took over a minute always had its join point aborted. Missing keep-alive replies while the + uploader's main thread is blocked now count as progress; a client that hangs for good is still dropped by the + 10-minute keep-alive timeout. `940a691` +- **One join point per saved tick.** With day-based autosaves every client requests a join point after its save, and a + slower client started a second one, reloading everyone again. Save requests now carry the saved state's game timer + and the server ignores requests already covered by the current or last completed join point. `edd7d3c` +- **The arbiter runs in-game synchronous long events.** Such an event only starts after its loading window has been + drawn, and in batch mode windows are never drawn, so the arbiter never ran join point creation: it stopped + simulating and the server paused everyone waiting for it. On `dev` an arbiter game froze for everyone as soon as + someone joined. `c877df8` +- **World data for joiners stays within the fragmented packet limit.** `Server_WorldData` carries the save, the log + and up to 10 stored sync opinions, which with traces could exceed 32 MiB; `SendFragmented` then threw and every + later joiner got a state exception disconnect. Stored opinions are capped at 8 MiB (the newest are kept) and only + those that fit are sent; if the data is still too big the joiner gets a message with the sizes. `37302bd` +- **The standalone join point test can fail.** Auto join points were off and the existing player wasn't a valid + uploader, so the test passed either way. `324df63` + +### Joining handshake and compatibility check + +- **Init data requests no longer hang every later join.** A joiner disconnecting during the handshake left the server + in `InitDataState.Requested`, so every later joiner waited forever. `RequestInitData` now always completes, + handshake waits time out (2 min for automatic replies, 15 min for replies behind user input), the `rawMods` limit is + restored (4 MiB compressed, also for the forwarded copy), and the flow stops right after a protocol or username + disconnect. `7c88a31` +- **Players who left while waiting for init data stop their join flow** instead of waiting for join data on a dead + connection (`c189a0c`). The init data upload, which carries the mod list and synced configs, gets 5 minutes instead + of 2 (`1b011ec`). +- **Reloads run under `ProgramState.MapInitializing`.** Join point and resync reloads kept `ProgramState.Playing`, + unlike a normal load or a joiner, so load-time code behaved differently on reloading clients (e.g. + `GenSpawn.Spawn`'s per-cell item limit moved or destroyed stacked items only there). Fixes #854. `d67d559` +- **Different Multiplayer builds and missing def types are caught.** Mod file hashes covered only `Assemblies/`, but + Multiplayer's own DLLs are in `AssembliesCustom/`, and the def check only looked at def types the client sent. Those + DLLs are hashed now (shown as a file mismatch, "Connect anyway" still works), and the server also reports def types + missing on the client (`ServerJoinDataPacket.missingDefs`). `8205395` +- **Line endings are ignored when comparing mods.** Synced mod configs and Defs/Patches XML hashes were compared byte + by byte, so the same mod on Windows (CRLF) and Linux (LF) showed a mismatch; assemblies are still hashed raw. Config + comparison from #961. Fixes #386. `0198180`, `03f3ae5` +- **A different game language shows in the mismatch window.** Translated text is generated and saved during the + simulation and name generation retries depend on it, so different languages can desync. The init data provider's + language is sent in the join data and compared like the other entries. From #961; part of #264. `92af9a9` + +## 2. Server and networking + +### Threads and lifecycle + +- **The server join flow stays on the server thread.** `TypedPacket()` used `ContinueWith` without + `ExecuteSynchronously` and the server thread has no `SynchronizationContext`, so everything after the first awaited + typed packet in `ServerJoiningState` (pausing, join point creation, `OnJoin`, disconnects) ran on a thread-pool + thread at the same time as the server loop, racing `CommandHandler.Send` and the player list. Typed waits now resume + inline on the packet handler's thread, and `MultiplayerServer.CheckServerThread()` logs off-thread calls. `d2cf042` +- **The server stops once, on its own thread.** `TryStop` ran twice and cleared the static instance unconditionally, + so the rest of a server iteration could run without an instance (the `FreezeManager.Tick` NRE after which the game + timer never advanced). It is now latched, clears the instance only if it is still the same server, and from other + threads waits up to 5 s for the server thread. Reimplements #993 (@MhaWay). Likely fixes the NRE of #991 (its "players too far behind" symptom needs a retest). `cd6d1f5` + +### Client input validation + +- **Server-only commands and foreign faction changes are rejected.** The server forwarded any command type for any map + id, so a modified client could send `InitPlayerData` (granting dev mode) or spam `PauseAll`/`CreateJoinPoint`, and + anyone could move anyone to any faction. Unknown types and map ids outside the type's scope now disconnect the + sender; players may change only their own faction and moving others is host-only. `dd969f9` +- **Remote lengths are validated before allocating.** A length prefix of `int.MaxValue` made the host try to allocate + 2 GB, and `PacketReader.BindBytes` ignored `maxLength` (so the mod list limit from #878 never applied). Reads and + binds now check lengths against the remaining data, and `Gzipped` caps the decompressed size at 64 MiB; no wire + format change. `dba4ff2` +- **String limits count UTF-8 bytes.** The writer checked string limits by char count and the reader by UTF-8 bytes, + so a Steam name of 17+ Cyrillic letters passed the host's check and made every receiver of the player list drop the + connection. Steam names are bounded by Steam's own byte limit (from #961) and array binds reject negative lengths. + `41c8fab` +- **Unresponsive players time out; client timing reports are clamped.** `keepAliveAt` was never read, so a hung game + stayed connected and its `ticksBehind` paused everyone indefinitely; players in game that don't answer keep-alives + for 10 minutes are now dropped (the in-memory host connection is exempt). `ticksBehind` and `frameTime` are clamped + and NaN-safe: a NaN frame time used to stop the server's game timer for good. `b2fb3f1` + +### Packets + +- **Partially received fragmented packets are dropped on a state change.** The fragment buffer survived connection + state changes, so after a change in the middle of a transfer the next fragmented packet failed with "High number of + fragmented packets at once!" and the rejoin broke. Partial packets are discarded and their remaining fragments + skipped by fragment id. `e3edd35` +- **Big commands are sent fragmented.** Commands were single packets of at most 64 KiB, so big actions (designating or + pasting a large area, reforming an ideoligion) failed with a send exception halfway. Commands that don't fit are + sent fragmented on the same reliable ordered channel, capped at 1 MiB, and the client shows a message instead of + throwing. Fixes #415. `f8dd311` +- **Selections over 200 things are split.** The selection packet carries at most 200 ids per list, so bigger changes + (possible with mods) threw in the writer every 0.2 s; they are now split into several packets. Fixes #377. `9b94384` + +### Standalone servers + +- **Desyncs are detected on standalone servers.** Only the host or arbiter sent sync opinions, so a standalone server + never compared anything. The server now forwards the opinions of the sync reference, re-evaluates it when a player + becomes ready, desyncs, rejoins or leaves, and on standalone tells the old and new reference with a new + `Server_SyncReference` packet (appended, existing packet ids unchanged), so only that client uploads opinions. + `6c48789` + +### Steam and LiteNetLib + +- **Steam reconnects work and closed P2P sessions are freed.** A join packet from a remote that still had a connection + was dropped, so a player who rejoined before the old connection timed out waited forever; P2P sessions were never + closed, and a rejected prompt made the host ignore all later requests from that player. The stale connection is + replaced, sessions are closed on disconnect (3 s after a goodbye so it gets delivered), a rejected player's requests + are ignored for 30 s (`49cd635`), and due closes run after the tick's packets are read so a waiting reconnect + cancels them (`799efd4`). Steam part of #958 (@romangr); its same-IP replacement for direct connections was left out + because a player behind the same NAT could kick another. Fixes #843. `8ce4386`, `49cd635`, `799efd4` +- **Sends to a disconnecting LiteNetLib peer aren't reported as errors.** LiteNetLib changes the peer state on its own + thread, so the rest of the server tick still sent to the peer and logged an error (and failed `ServerTest` at + times). These are logged at verbose level now. Reimplements #977 (@M-r-A). `6b109e5` + +## 3. Determinism + +### RNG isolation + +- **Sound and music don't use the synced RNG.** `Fire.TickInterval` reaches a `SoundSizeAggregator` constructor + (`Rand.Value`) only when no aggregated sustainer exists locally, which depends on the local sound manager, so the + map RNG advanced on some clients only. The RNG is pushed and popped around the sound entry points reachable from + ticks and around the music manager triggers. Part of #618. `675ec35` +- **`Rand` push/pop is exception safe.** Several seeding patches (`SeedGameLoad`, `SeedMapLoad`, + `SeedMapFinalizeLoading`, `SeedCaravanEnter`, `SeedGrammar`, `SeedPawnGraphics`, `TryGetNearbyUnseenCell`, + `DeterministicUndercaveRockCollapse`) popped in a postfix, so an exception left an orphaned entry that the enclosing + tick saved as its own state, and the extra stack depth turned desync traces off for the session. They pop in + finalizers now, and ticks and commands unwind leftover pushes; `TryGetNearbyUnseenCell` is also seeded with the pawn + id. `f5a41e8` +- **Quest name generation retries are seeded.** The `GrammarRequest` overload of `NameGenerator.GenerateName` and + `QuestNode_ResolveQuestName` retry depending on the generated text, so on the game language. Port of #980 + (@romangr); part of #264. `b815de5` + +### Simulation contexts + +- **Sim long events run in a deterministic context.** Only the action of such an event was seeded: the faction context + was lost (under multifaction each client generated maps for its own faction), actions deferred with + `ExecuteWhenFinished` counted as interface code (unseeded RNG, local unique ids, sync methods sending commands), and + events could run on a worker thread sharing `Rand` with the UI. A `SimLongEventContext` (RNG seed and, in + multifaction, the faction) is now captured when the event is queued and applied around the whole update with + `InInterface` false, and such events run synchronously. `389678c` takes the seed without advancing the RNG (some + events are queued on the issuer only), skips autosave events and restores the context in `finally`. Part of #518 and + #621. `619bc19`, `389678c` +- **The ship countdown ends in the world context.** `ConstantTicker` ended it outside any tickable's context, so + `ShipCountdown.CountdownEnded` used the unsynced global RNG; it now runs inside the world's context, with the ship's + faction pushed (`0030d36`). `9427b7d` + +### Hashing and ordering + +- **Deterministic `HashCode.Combine` patches are applied early and always.** They switched on `Multiplayer.Client != + null`, so the hash function changed under containers alive across connecting and disconnecting, and containers + filled during def loading kept a per-process order. `6e8249c` +- **Inverted zone/plan cell shuffle check.** The transpiler emitted a bitwise `not`, so the condition was always true + and cells were never shuffled, even in singleplayer. Singleplayer gets vanilla's shuffle back; MP keeps the existing + no-shuffle behavior, since the shuffled flag isn't saved. `83d0cc8` +- **Job-loop counter for pawns without a map.** `Pawn_JobTracker.StartJob`'s frame count was replaced by the map's + event counter, but unspawned pawns (carried, in containers or caravans) fell back to the frame count, which differs + between clients. It now uses `MapHeld` with the tick timer as fallback. `fa7f328` + +### Threads + +- **Path jobs complete right after `PathFinderTick`.** The only guard against path jobs racing main-thread changes + (#900) ran after `MapPreTick`; jobs are now also completed in a `PathFinderTick` postfix so they can't overlap the + rest of `MapPreTick`. `9a7c278` +- **`FastTileFinder.Closest` is deterministic.** It kept a closest tile per worker thread, so equally close tiles went + to whichever thread found them first, and a cached result or cache regeneration triggered by one client's interface + could be reused by the simulation. `Closest` runs in a single batch like `Query`, and the simulation always + recomputes and regenerates a cache that the interface regenerated (`f145f9a`). Likely fixes #648 (the reported desync came from this path; not reproduced). `52cd03d`, `f145f9a` + +### Transpilers + +- **Fragile determinism transpilers fail loudly.** `TryGetRandomUnusedSolidName` loads its list the way the `Shuffle` + call does instead of hardcoding `ldloc.1`, the `TargetInfo`/`GlobalTargetInfo` hash code and + `ProcessPostTickVisuals` patches log when nothing was patched, and the plan color gizmo lambda is found with + `MpMethodUtil.GetLambda` instead of a hardcoded display class name. These errors are shown before hosting or joining + (`d4349f2`). `2e921f5` + +### Caches shared by the interface and the simulation + +- **Item accessibility cache per simulation step.** `PlayerItemAccessibilityUtility` reused its `Rand`-consuming scan + within a frame, so quest generation rescanned a client-dependent number of times and could read a scan made by the + interface. In MP it is reused only within one tick or command, and sim long events count as steps (`a3ccabd`). From + #961. `9b5661a`, `a3ccabd` +- **The goodwill situation cache isn't filled from the interface.** On a cache miss `GetSituations` filled the shared + cache and ran `CheckHostilityChanged`, which from the factions tab happened on one client only. The interface now + gets uncached values. From #961. `d3c7bb1` +- **Stat cache staleness is judged with the thing's own time.** With async time the interface checked + `StatWorker.GetValue`'s shared cache against the viewed map's or the world's time and could replace an entry that + the simulation would still have used. In the interface the time of the thing's map is used. `5923645` +- **The UI tile temperature cache is ticked.** It was only ticked when the real comp ticked in the interface, which + never happens, so its entries were never refreshed or evicted; it is now ticked at most once per frame when read, + and guarded against exceptions. `873f837`, `977e1bc` +- The grav engine cache is covered under the Odyssey section (`64e3aef`). + +## 4. Sync framework + +### Field watches and markers + +- **Watched fields are restored even if the watched code throws.** The watch postfix didn't run when the UI method + threw, leaving the user's change (or the buffered value) in that client's simulation and the watch stack unbalanced. + Watches now use finalizers and `try/finally`, always pop the whole scope, flush leaked scopes once per frame (also + at the end of `UIRootOnGUI`/`UIRootUpdate`, logged once), skip nested watches of an already watched field (as + `Bill_Production.paused` is), and clear the stack when MP stops. `a59e1c8`, `109b5f2`, `c436e65` +- **Sync state flags are reset in finalizers.** A new `MpFinalizer` attribute replaces postfixes where a skipped + postfix broke state for the session: `StorageSettingsClipboard.Copy` left `Multiplayer.dontSync` set (every later + sync handler ran locally), `ThingFilterMarkers` left a stale filter context, and the growth moment letter's RNG push + and the manual priorities marker could leak. `2aec6ac`, `417c0ac` + +### Serializers and sync workers + +- **Sync serializer reader and writer stay aligned.** Writers of implementation-indexed types wrote `0xFFFF` for an + unknown type followed by the object's data, which the reader didn't consume, and a failing implicit sync worker + truncated the writer to before the parent type's data while the reader re-read it. The writer now throws so the + command isn't sent, and both sides roll back to the same position. `5238392` +- **World pawns and genes sync reliably.** Pawns without a supported holder (world pawns, pawns kept by quests or + letters) were read as null, and genes were resolved by def alone, which picks the wrong one when a pawn has the same + def as endogene and xenogene. Unheld world pawns are looked up by id, genes are matched by exact `loadID`, and + `ChoiceLetter_ChoosePawn`'s option is cancelled if a captured field reads as null; protocol 57. `8bc60df`, `c436e65` +- **Lords are registered in the shared cross refs.** Synced data that references a lord (e.g. ceremony jobs) resolved + it to null; lords are registered at runtime and from the save, the same way on every client. From #961. `af91582` +- **The `WorldSelected` sync context includes the selected tile.** Only `WorldSelector.selected` was synced, so the + dev "Generate settlement" gizmo ran on every client's own selected tile. `selectedTile`/`selectedLayer` are synced, + the context is read inside `HandleCmd`'s `try/finally`, and `SyncUtil.prevSelected` holds the real previous + selection. `318bac4` + +### Sync methods and delegates + +- **The sync method transpiler emits valid default values.** For synced methods returning a struct (e.g. + `*Database.TryDelete` returning `AcceptanceReport`) the early return ran `ret` with an empty stack, and `out` + parameters of small value types were overwritten with 4 bytes. `1d5e411` +- **Colonist bar reordering is synced with pawns.** It was synced as the `ColonistBar.Entry` closure, whose indices + were resolved against each client's own cached bar, which differs in multifaction or with hidden maps. The sender + now resolves the pawns and the synced method applies vanilla's reorder rule to them. `dce32db`, `c436e65` +- **Thing filter edits stay out of the sender-only simulation.** Range setters run `settingsChangedCallback` (storage + rechecks the map's haulables), and the buffered watch and the slider called them every frame on the sender only. The + interface now writes only the backing field, edits without a map context are blocked, and "Clear all" leaves hidden + special filters alone as in vanilla. `85066f3` +- **`TransporterUtility.InitiateLoading` from float menu options is synced.** Carry to shuttle, place in transporter + and enter shuttle set the transporter group id on the clicking client only. From #961. `035d208` +- **A drafted stop order ends only the goto it was aimed at.** The synced `EndCurrentJob` runs ticks after the click, + so it could end a job (or another goto) the pawn had started meanwhile; it now checks the job id. From #961. + `017ed3c`, `aa636c5` +- **`PriorityWork.Clear` is allowed during ticks.** The guard meant to stop the interface from clearing prioritized + work also dropped vanilla's tick-time calls, so pawns stayed prioritized until the timeout. `393ac90` +- **The product style of bills is synced.** The bill dialog's style menu (Ideology, classic mode) set `Bill.style`, + `globalStyle` and `graphicIndexOverride` only locally. Reimplements #402 (@SokyranTheDragon) for 1.6. `5005f8f` +- **The health tab's dev "remove hediff" button is synced.** It removed the hediff locally; its `RemoveHediff` call is + redirected to a debug-only sync method. Fixes #358. `1fa6df1` +- **Debug actions replay under the acting player's view.** Vanilla debug actions (e.g. incident targets) read + `WorldRendererUtility.WorldSelected`, which is local view state. `ab816c3` (port of #976 by @M-r-A) overrides it + during the command, and `a3e2414` derives it from the command (world commands under the planet view, map commands + under their map) and reads all command data before installing overrides. `ab816c3`, `a3e2414` + +## 5. Sessions, issuer-only and interface-only code + +### Code that runs on a subset of clients inside the simulation + +- **`LocalOnlyScope` for issuer-only code in commands.** Commands run with the shared RNG and unique id counters, and + code that runs only on the issuing client (session windows, `FactionCreator`'s local setup) or uses local UI state + (`MainTabsRoot.Notify_SelectedObjectDespawned`) advanced them on one client only, the bug class #968 fixed at one + call site. Those call sites now run in `LocalOnlyScope`: ritual, psychic ritual, growth moment, transporter loading, + caravan forming, caravan splitting, map portal and trade windows. `5a92b1d`, `83f8853`, `ac8feeb` +- **Motes aren't null only on non-issuing clients.** During commands `MakeStaticMote` returned null on clients that + didn't issue the command but a mote on the issuer, so callers using the result threw or branched differently; + non-issuers now get an unspawned mote. `bcf9384` + +### Session lifecycle + +- **Saving doesn't change live sessions.** `SessionManager.WriteSessionData` removed invalid sessions from the live + lists, and saving can happen on one client only (e.g. the host's autosave), so session lists and map pausing could + differ. Invalid sessions are now only left out of the written data; inverted "log once" checks, the unsaveable + `CaravanFormingSession.onClosed` and an inverted early return in `HandleMissingDialogsGameStart` are fixed too. + `22f958b` +- **Growth moment sessions survive the child leaving the map (Biotech).** The session's map was `letter.pawn.Map`, so + once the child left, `Tick()` threw every tick and the session could never be removed, and the window's `PostClose` + ticked the session from UI code. The session stores its map, a dead or absent child invalidates it (checked by the + tick, not right after loading, when pawns aren't spawned yet), children held on the map (growth vat, carried, + shuttle) count as present, options are generated on every client, and one failing session no longer skips the + others. `de1999a`, `5452502`, `a1e0059` +- **The growth moment option is offered only to the child's faction.** A player of another faction with a pawn on the + map could choose the child's traits from the colonist bar. #961 makes the same check. `f3b6ca5` +- **Portal and transporter sessions are matched by their target.** A map can have one portal session per portal, but + the proxy took the first one, and transporter loading reused any session on the map; sessions despawning with their + portal threw. Sessions are looked up by portal or transporters, store their map, end with their portal, and caravan + forming sessions created in ticks use a player faction with pawns on the map instead of the spectator. `ee6e9af`, + `a1e0059` +- **Caravan splitting doesn't depend on the local dialog.** The synced cancel and accept used the session's dialog, + which exists only on clients that opened it, so cancelling threw elsewhere and accepting split the caravan only + there. Accept now splits through a proxy built from the session's transferables, and only the caravan's players get + the window. `e0161aa` + +### Trading + +- **Trade deals are recached by the simulation.** `MpTradeDeal.Recache()` changed the tradeables but was called only + from the trading window, i.e. on clients that had it open, so `TryExecute` traded different items elsewhere. The + session's world tick and the synced transferable lookup now recache with the negotiator's faction forced, and the + dirty state is saved and ignored while loading (`4649662`). `3f41f92`, `4649662` +- **Faction pushes are balanced in the trading UI and map faction ticks.** The trading window popped only when the + push returned non-null, leaking a stack entry every frame of an own-faction trade, and `MultiplayerMapComp.DoTick` + popped without `try/finally`. `b6f1126` + +## 6. Multifaction + +### Faction contexts + +- **Forced pushes apply the faction to every map.** Outside simulation contexts each map holds the local player's + faction data (areas, zones, designations, listers), and ticks and commands applied their faction only to their own + map, so code reaching into another map (portals, pocket maps, gravship landing) read client-specific data. Forced + pushes now apply the faction to the world and every map, and their pops restore exactly what each map held before. + Part of #621. `c3889fa`, `2da45bb` +- **Leaked faction pushes are unwound at tick and command boundaries.** A push left by an exception kept the wrong + `Faction.OfPlayer` and faction data after the context; the stack is popped back to the context's depth (reported + once), and leaked map pushes restore their map. `d412a1c`, `c5d0566` + +### Map generation + +- **The map generation faction is found from any player caravan.** `MapGenFactionPatch` used + `PlayerControlledCaravanAt`, which compares with `Faction.OfPlayer`, so only the caravan's owner found it and maps + were set up for different factions on different clients. The dead `TileFactionContext` fallback is removed. Fixes + #518 (with `619bc19`). `3b62baa` +- **Map generation uses the faction's world data.** The patch swapped only `Faction.OfPlayer`, so another faction's + map used the context faction's research, play settings, history and storyteller; it now does a forced `PushFaction`. + Part of #621. `b33e913`, `0030d36` + +### Creating a faction + +- **Factions are created with the same data on every client.** `FactionCreator`'s long event ran with each client's + own faction applied, so the fallback ideoligion, starting research, play settings and copied storyteller were the + local player's. It runs under a forced spectator push, `Faction.OfPlayer` is restored after `FactionManager.Add`, + new storytellers get their own difficulty copy, and the event keeps its faction data in its sim context (`d6a13aa`). + `578c0a6`, `d6a13aa` +- **Faction creation pages don't change shared state.** Opening the settle pages replaced `Current.Game.Scenario` for + good and registered the temporary pawn-generation faction (and possibly its ideoligion) in the live game on the + creating client only. The chosen scenario is applied only while the pages' own code runs, and the temporary faction + and its ideoligion stay unregistered. `8c2731e`, `8a091e5` +- **A new faction's ideoligion is generated for its own faction def**, not `Faction.OfPlayer`'s or the game scenario's + (wrong for e.g. tribal starts). `513af76` +- **Local starting pawn requests are cleared before faction map generation.** `PrepForMapGen` added custom xenotypes + from client-local static requests to the shared custom xenotype database. From #961. `e2a589f` +- **Synced starting pawns load like vanilla loads pawns.** The pawn was read in `Playing`, so the needs tracker hit an + NRE on hediffs without a pawn, and its ideoligion (from the unregistered page faction) didn't resolve, so a random + fallback was assigned and kept as a previous ideoligion. The pawn is sent without an ideoligion and read in + `MapInitializing` with the fallback disabled and the per-client `GameInitData` hidden; same wire format. Fixes #536 + and #537. `783d500`, `9992f50` + +### Letters + +- **The letter stack is the same on every client.** Each client removed other factions' letters from its stack, but + the simulation reads the stack (expiring letters and their default choices, growth moment sessions, archive + culling), and letters received in spectator contexts were dropped for everyone. Letters now stay in every stack with + their faction recorded and saved, only the UI filters them, and other factions' expiring letters open without their + windows or tab switches. `a95832b`, `ad4bc2f`, `b7a389f` + +### Seasons + +- **Season notifications run per player faction.** `DateNotifierTick` ran in the synced world tick with the local + player's faction, so clients saved different `lastSeason` values and the first summer warning letter came on some + clients only. It now runs once per player faction under a forced push with a saved `lastSeason` per faction, skips + factions without a home, uses the home map's whole clock and only the faction's own homes, and shows the message + only to that faction's players. Reimplements #904 (@Sakura-TA) per faction. `43a6493`, `0abd01f`, `ce0136a`, + `2da2603`, `81ba184` + +### World tick under the spectator faction + +- **Caravan battle wins and ideoligion ticks run for their player faction.** The world tick runs as the spectator, so + `CheckWonBattle` compared threats with the spectator and `Ideo.IdeoTick` never added date ritual obligations + (festivals). The battlefield's player faction and the first player faction following the ideoligion are force-pushed + around them, as in #961. `f409a3d`, `546b68d`, `bec7f23` + +### Other factions' data on shared maps + +- **Zone cells under new buildings are cleared for every faction, and other factions' designations on a destroyed + thing are removed.** Only the installed (context faction's) managers were updated, and the leftovers disappeared + only on a client that reloaded. From #961. `dceac58`, `6dc1e5b` +- **Other players are kept out of trade requests and ritual goodwill.** `Settlement.Visitable` excluded only + `Faction.OfPlayer`'s settlements, so trade request quests could target another player's colony, and the + `NearbyFactionGoodwill` ritual reward could raise goodwill with the nearest player. From #984 (@still222), with a + narrow transpiler instead of a global goodwill patch. `7399c62` + +### Converting to singleplayer + +- **The spectator faction is removed cleanly.** Removing it only from the faction list left references in + reservations, pawns, listers, letters, the play log, quests, ideoligions, tales and map events of the converted + save; it now goes through vanilla's faction removal. From #961. `c5aac4f`, `a6a4ab7` + +## 7. Async time, VTR and the tick loop + +### Tick loop + +- **The world and maps tick in lockstep without async time.** Each tickable ticked through its own accumulator (the + world N times, then each map N times), so new maps gained a tick, a rate change during a map tick came after the + world's ticks, and removing a map mid-tick made another one tick twice; world and map clocks drifted apart by a few + ticks. Every game tick now ticks the world and then a snapshot of the maps once each; new maps start at the world's + tick, also when generated later in the world tick (`925534c`), and at the game's speed (`34e19f8`). Async time is + unchanged. Fixes #339. `291c3c4`, `925534c`, `34e19f8` +- **Ticks and commands see the simulated paused state.** `TickManager.Paused` returned the paused state of the map the + local player was looking at, also inside ticks and commands, so for example `Pawn_JobTracker.StartJob`'s + `jobsGivenThisTick` differed between clients. Ticks now always see "not paused", and commands, the constant ticker + and sim long events see whether their own tickable is paused. `6fcaecb`, `2336353` +- **One subsystem's exception doesn't skip the rest of the tick.** `AsyncTimeComp.Tick` ran everything in one + `try/finally`, so an exception in `MapPreTick` skipped `mapTicks++` and everything after it, and the world could + skip `worldTicks++`. Each subsystem is caught separately, as in vanilla's `DoSingleTick`. `38fbe9d` + +### Time contexts + +- **Map time contexts nest.** A map's `PreContext` kept the previous time in one field, and quest time getters enter + their map's context while that map ticks the quest, so the outer `PostContext` left the map's clock installed and + the world clock jumped to the map's. Each context keeps its own frame, UI time restores run in finalizers, and maps + that are still being generated (no comps yet) are skipped instead of throwing. Fixes #789 (with `f8d9f49`). + `822b44c` + +### VTR (variable tick rate) + +- **VTR player counts are saved.** The per-map and world viewer counts affect the simulation but weren't saved, so a + reload reset them to 0 (on standalone, only on the reloading client); the world count is clamped at 0. `7b666d7` +- **The host's view is counted and old counts are reset.** The host's game starts through a save and reload, which + doesn't report a view, so the host's map ran at the no-viewer rate (projectiles moving in 15-tick steps) until the + host switched maps. The host now announces its view, the arbiter no longer counts as a viewer, and counts saved by + an earlier session are zeroed when hosting or starting a standalone server. `6ad2ef2` +- **The viewed map is followed every frame.** Counts changed only through the `CurrentMap` setter and world map + toggles, so a multifaction joiner starting on the world map was never counted and its first switch took a viewer + away from the host's map. The client now compares what it looks at with what it last reported once per frame. + Supersedes #956 (@MhaWay), whose out-of-band count packets would bring back #862. `65bfd9d` + +### Async time + +- **An accepted async world quest ticks once per tick.** Generation and acceptance both cached the quest, so its parts + ticked twice per tick until the next reload, and removal skipped the world list because of an operator precedence + bug. Supersedes the async quest part of #541 (@Tick-git); #539 itself is already fixed on `dev`. `1095720` +- **Animal hunger alerts use the animal's own time.** `Need_Food.TicksStarving` compares the current time with a + timestamp from the pawn's clock, but alerts run at the viewed map's time, so animals on other maps showed as + starving (or didn't) depending on the view. Reimplements #983 (@still222). `123ee05` +- **TimestampFixer handles destroyed pawns and more fields.** It ran a debug save also on pawns killed on a map and + passed to the world, logging a warning on each death even without async time; it now skips pawns whose clock doesn't + change and shifts destroyed pawns' fields directly, including all their abilities (`c181b79`). It also shifts the + starvation start, ability and royal permit cooldowns, the daily skill XP reset and the decree interval. Reimplements + #979 (@M-r-A) without its save pass; part of #964 and #422. `89dfd7a`, `c181b79` + +## 8. Storyteller and quests + +- **Story watcher adaptation grows once per game tick.** The world tick ticks the story watcher and every map tick + ticked it again, so with N maps adaptation (the raid points factor) grew 1+N times as fast as in singleplayer. Map + ticks now only resolve the colonists downed during them. Part of #659 and #884. `5fb7c98` +- **Quest threats fire in singleplayer.** The storyteller prefix set its flag in singleplayer too, so the postfix + dropped every `QuestPart_ThreatsGenerator` and quest threats never fired with the mod loaded. `17c7354` +- **Multifaction quest threats fire from one storyteller per map.** Every player faction's storyteller walks the + shared quest list on each map, and threat counts are seeded by world, target and salt, so all of them fired the same + quest raids at once; other quest incident makers fired 1+N times. Quest threats now run for one faction per map (the + owner, else the lowest-id player faction with pawns there), and other incident makers run in the world tick only; a + map owned by a faction without world data falls back to the pawn-based choice. Part of #659 and #884. `46c06a7`, + `99d1da2` +- **Multifaction storytellers stay off other players' maps.** Faction A's incidents (e.g. `Map_Misc` while its + colonists visit) could fire on faction B's home; each storyteller now targets its own maps (including pocket maps) + and unowned maps where it has pawns. From #961; part of #659 and #884. `0b4fde8`, `524e05e` +- **Quest map picks stay on the generating faction's maps.** Quests generated for a player faction could choose + another player's map (a gravship landing site counts as a home of every faction), so refugees arrived at a friend's + colony. Port of #961's owner filter for `QuestNode_GetMap`, `QuestGen_Get.GetMap`, work sites and reliquary + pilgrims. Part of #659. `46638cc` +- **Nested incidents keep the right map context.** A nested `IncidentWorker.TryExecute` during the world tick reset + `tickingMap` and popped the outer incident's context, and a nested incident for another map ran in the outer map's + context (time, RNG, faction). The context is now left and re-entered for another map and kept for the same map. From + #961. `fb00a6e`, `5373330` +- Quest name seeding is under RNG isolation (`b815de5`), the async world quest double tick under async time + (`1095720`). + +## 9. Odyssey: gravship + +- **Takeoff and landing ends run in a synced command.** Vanilla calls `TakeoffEnded`/`LandingEnded` from + `WorldComponentUpdate`, i.e. per frame in the interface at a different moment on each client, so synced methods + inside were sent as commands by every client, unique ids were local and `Faction.OfPlayer` was the local faction. + Each client now sends a command when its cutscene ends, the first one executed runs vanilla's method everywhere, and + the gravship faction is force-pushed (`8b2268a`). Fixes #640 and #638 (with #893 on `dev`); part of #789 and #687. + `f8d9f49`, `8b2268a` +- **Cutscene ends survive reloads.** The pending flag lived in `MultiplayerGame`, which is recreated on every load + (`d900efe`), and a join point during a cutscene lost the gravship, after which the ends threw and the game stayed in + the cutscene for good (`2bca475`). The flag is saved with the controller, cutscenes without a gravship end cleanly, + late aborts are ignored, and stale freeze state is cleared (`273b61b`). Part of the #643 fix. `d900efe`, `2bca475`, + `273b61b` +- **The landing pause is restored after loading.** The `GravshipTravelSession` pausing the landing map isn't saved, so + after a join point, or for a joiner, the map ran while players chose where to land; it is recreated from the saved + landing marker. Fixes #643 (with `2bca475`, `273b61b`). `3d8a29a` +- **The landing marker and rotation are the same on every client.** The move designator's sync worker read a field + that only the landing UI creates, so the command threw on other clients; the confirming client cleared its landing + marker before its command executed; and other players' designator previews could rotate the marker locally. The lazy + accessor is used, the marker stays until landing, and the confirming player's rotation is sent with the landing. + Fixes #791. `3682aea`, `e47be47`, `218c7e5` +- **The landing and departing gravship are placed in their map's context.** `PlaceGravship` and the takeoff's removal + run in long events queued from cutscene callbacks, outside any tick or command, so they used the interface's RNG and + the world's clock. Both now run in their map's context (time, RNG, faction), which also replaces the glow grid + workaround from #635. Fixes #646. `bdb2515` (landing), `2b952b9` (takeoff) +- **Every tickable gets the speed set after landing without async time.** Landing set Normal speed only on the landing + map, while without async time all tickables share one speed. `74a851a` +- **Camera shakes are timed with the MP timer.** Under async time the extended shake request came from a map's clock + and was checked against the world's, so the camera shook until the world caught up. UI only. Fixes #696. `44a946a` +- **The grav engine cache is out of the interface's reach.** `GravshipUtility` caches each map's grav engine for the + current tick, and the interface (at the viewed map's time) could fill it before a command changed the map; in + multifaction any player's engine made a map a home of every player faction. Ticks, commands and sim long events + start with an empty cache, and other players' engines are skipped. Different approach than #906 (@Sakura-TA), which + doesn't build on 1.6.4850. `64e3aef` + +## 10. Anomaly, Ideology and Biotech + +### Anomaly + +- **Unnatural corpse teleports don't depend on the camera.** `UnnaturalCorpse.IsOutsideView` and the container escape + check read `Find.CurrentMap` and the local camera rect, so corpses teleported or escaped on some clients only. In MP + the corpse counts as never seen by a camera, keeping the reservation check for every player faction. From #961. + Fixes #959. `6605f20`, `25558d4` +- **Psychic ritual offerings (Horax) work.** Vanilla moved the offering while enumerating the invoker's inventory, so + moving a whole stack threw and the ritual never completed; the inventory is snapshotted first, in MP only and up to + the required count. From #961. Fixes #855 (the rest of it was fixed on `dev` by #739). `c6c9fa6`, `64c25c4` + +### Ideology + +- **The ritual role group cache is refilled before a synced `TryAssignAnyRole`.** It iterates a static cache that is + filled only when the ritual dialog is drawn, so clients without the dialog assigned roles differently. Core of #995 + (@jangoergens), without its Roslyn test fixtures. `e05a679` +- **Rituals start with the starting player's role assignments.** Without Prepatcher the role selection widgets aren't + synced, so each client could hold its own assignments; the start command now carries the widget-changed assignments + of the starting player (no-op with Prepatcher). Part of #687. `6472678`, `425c18d` +- **Only the clicked ritual gizmo is processed.** Grouped `Command_Ritual` gizmos also processed another gizmo's + click, and in MP each opens a session, so the dialog opened for the wrong ideoligion. Fixes #506. `0d73a38` +- **A warning when Prepatcher didn't patch the ritual role widget.** Nothing told players that ritual role selection + isn't synced without Prepatcher; the patched method is checked, and a warning shows in the log, the host window, the + server browser and the ritual dialogs. Fixes #501. `ab76273` +- **`FixIdeoAfterCopy` handles non-fluid ideoligions and runs in ticks.** `Ideo.CopyTo` leaves `development` null for + a non-fluid target, so the postfix threw; precept id fixing also applies to copies made by simulation code. From + #961. `244f8ef` +- Bill product style: see sync methods (`5005f8f`). + +### Biotech + +- Growth moment sessions: see section 5 (`de1999a`, `5452502`, `a1e0059`, `f3b6ca5`). Gene sync by `loadID`: + `8bc60df`. Custom xenotypes on faction creation: `e2a589f`. + +## 11. Diagnostics + +### Desync tracer + +- **The deferred stack tracer fails safely.** It runs as a postfix of `Rand.Value`/`Rand.Int`, so any exception in it + aborted game code after the RNG had advanced, on that client only ("Unknown function header", a trace buffer + overflow from uncounted inlined frames, the LMF walk NRE from #944). The walk now ends on unknown frames, checks and + masks LMF pointers, has a step cap and never throws; a failed walk marks the window so its trace hashes aren't + compared. Covers the tracer overflow part of #988 (@Manaether). `93d6f86`, `9fb2dcd` +- **The trace hash depth doesn't depend on the tick loop call site.** Commands ran either from `TickPatch.Prefix` or + one frame deeper from `DoUpdate`, depending on frame timing, so identical clients could report "Trace hashes don't + match". Both paths now have the same depth, and the dev tick-once hotkey is limited to replays. Fixes #994. + `059510e` +- **Trace collection stops after the stack walk throws.** On some setups (macOS through Rosetta) the walk threw on + every `Rand` call and the caught exceptions stalled the game; tracing is turned off for the process after the first + one, and the affected window is marked unreliable first, so this can't cause a false trace desync. Likely fixes #856 (needs confirmation on macOS). + `0d00fcc`, `6c5bf26` +- `9fb2dcd` also keeps an exception from the main tabs update from skipping the rest of `ExecuteCmd`'s `finally` block + (RNG and faction restore). + +### Desync reports and sync opinions + +- **Pending sync opinions are kept across reloads.** Every reload creates a new `SyncCoordinator`, dropping opinions + still waiting for their counterpart, so a desync right before a join point went unnoticed; they are now carried + over, and skipped opinions are logged. `3f575f8` +- **Desync traces are sent fragmented.** On standalone the trace reply comes from a remote client and was sent as one + packet limited to 64 KiB; above that `Send` threw in the reference's handler and the server's forward disconnected + the sender. `66ee8bc` +- **Desync reports keep the end of the log and the join data diff.** Reports kept the first 10,000 log lines, losing + the lines around the desync in long sessions; they now keep 2,000 from the start and the newest 8,000, record what + didn't match when the player connected anyway, and say which part of a replay's desync data couldn't be read. From + #961. `77fb479` +- **Skipped simulation errors are counted and reported.** The catch blocks that keep one subsystem's exception from + skipping a tick only logged; the count and the first error are now added to desync reports and the player is told + once, and the count resets when world data arrives after a rejoin (`60b457b`). From #961. `a21fb66`, `60b457b` + +### Surfacing failures + +- **Loading errors are confirmed before hosting or joining.** Failed patches and sync registrations only showed a + marker on the main menu button; they are now collected through `Multiplayer.LoadingError` and listed in a + confirmation dialog before opening the server browser, hosting or accepting a Steam invite. From #961. `d4349f2` +- **Disconnects caused by a server state exception are explained.** The client only showed "Disconnected", with no + hint that the error is in the host's log. Likely fixes #797 together with the bigger init/join data limits of `7c88a31` (no host log in the report). `db58b65` + +## 12. Usability and robustness + +- **The game can be saved after a disconnect.** The disconnected window only allowed quitting, losing everything since + the host's last save. The last join point snapshot (which already gets every received command appended) is kept + while disconnecting, and a Save button writes it as a save that simulates to the tick this client reached, without + changing game state. Fixes #333 and #937. `201829a` +- **Selecting drafted pawns doesn't lag.** The interface melee verb patch built `VerbEntry` lists (O(n²) in the number + of verbs) several times per frame for every selected drafted pawn; it now reuses the current melee verb when usable, + otherwise picks in O(n) as vanilla would, and caches the answer per frame, still without `Rand`. Fixes #308. + `f59d6b7`, `03d89bd` +- **The launch destination targeter stops when the launchable is gone.** While one player picked a destination, + another could launch or cancel the group, after which the targeter threw every frame and couldn't be closed. UI + only. Fixes #365. `b9ad395` +- **Caravan meeting and demand dialogs pause the game.** MP disables `WindowsForcePause` because windows differ + between clients, so time kept running while these dialogs waited for an answer; the incidents now pause from the + simulation like a pausing letter, unless the host set pause on letter to never. Fixes #512. `016815f` +- **The connecting window reads the download speed once.** The speed was recomputed on every read, so the value + checked against 0 wasn't the one divided by (`DivideByZeroException`). From #961. `c3c3323` +- **Null guards in three patches**: the world time speed query, the forbiddables despawn prefix and the teaching work + giver prefix threw on null maps or jobs. From #974 (@Spagles). `1c20d17` + +## Compatibility and behavior changes + +### Protocol, replays and saved data + +- `MpVersion.Protocol` goes from 56 to 57. Within 57 the branch changes `Server_WorldData` (one command list), + `ServerJoinDataPacket` (missing defs, game language), `ClientInitDataPacket` (language), the `CreateJoinPoint` + payload (uploader), join point uploads and save requests (tick), adds `Server_SyncReference`, sends large commands + fragmented and changes the sync data of unheld pawns, genes and the `WorldSelected` context. Other open PRs also + bump the protocol with different formats (#956 to 57, #961 to 58), so the number needs coordinating. +- Replays recorded before this branch are not compatible (sync handler ids, command formats and tick order changed). + The old per-map command layout of replays is still read. +- Standalone servers keep their command log in `Saved/cmds.dat` with its protocol in `Saved/cmds_protocol.txt`. A log + written by an older server is discarded on start with an error; the saved world, maps and session still load, and + only the progress since that save is lost. +- The join compatibility check now also compares Multiplayer's own assemblies, def types missing on the client and the + game language, and ignores line endings in synced configs and XML files. + +### Server and join behavior + +- On standalone servers every client reloads at a join point (as in hosted mode). Only the sync reference uploads; + join points are aborted after 60 s without progress; forced join points are limited to one per 10 s; minute-based + autosaves are issued by the server; join point requests from world travel are ignored. +- Standalone servers now compare sync opinions (from the sync reference) and report desyncs. +- Clients can't send server-only commands; only the host can move other players between factions. +- Handshake timeouts: 2 minutes for automatic replies, 5 minutes for the init data upload, 15 minutes for replies that + wait on user input. Players in game who don't answer keep-alives for 10 minutes are dropped. +- Commands larger than a single packet are sent fragmented, up to 1 MiB; selections over 200 things are split. +- A joiner whose world data would exceed 32 MiB is disconnected with a message giving the sizes. +- Steam: P2P sessions are closed on disconnect; a rejected player's join requests are ignored for 30 s. + +### Gameplay-visible behavior (deterministic on all clients) + +- Storyteller adaptation grows once per game tick (it grew 1+N times with N maps). +- Multifaction storytellers target only their own maps and unowned maps where they have pawns; quest threats fire once + per map; other quest incident makers fire once. +- Quest threats fire again in singleplayer with the mod loaded. +- Without async time the world and maps tick in lockstep (no 1–3 tick drift). At speeds above 1x the order is W M M W + M M instead of W W M M M M. +- Code in ticks sees `TickManager.Paused` as false; commands see their own tickable's paused state. +- The arbiter doesn't count as a VTR viewer; the host's map is counted. +- Caravan meeting and demand dialogs pause the game (unless pause on letter is set to never). +- Rituals start with the starting player's role assignments (matters without Prepatcher). +- Zone and plan cells are shuffled again in singleplayer; MP keeps not shuffling them. +- Multifaction: every client keeps the full letter stack and the UI filters it; season messages are shown only to the + faction's players and are not archived. + +### UI and translations + +- New UI: a confirmation dialog before hosting or joining with loading errors, a Prepatcher-missing warning (host + window, server browser, ritual dialogs), a Save button in the disconnected window, a language row in the join data + window, an explanation for server state exception disconnects, and messages when an action is too big to send or a + simulation error was skipped. +- New translation keys, with English fallbacks in code, to add to Multiplayer-Locale: `MpMismatchLanguage`, + `MpPrepatcherMissing`, `MpPrepatcherMissingDesc`, `MpSaveGameAfterDisconnect`, `MpServerStateException`, + `MpServerStateExceptionDesc`. +- Some new texts are English only, without keys: the loading errors confirmation, "this action is too big to send to + the server", the skipped simulation error notice and the "world data too big" disconnect reason. + +### Code-level changes + +- Added: `MpFinalizer`, `LocalOnlyScope`, `SimLongEventContext`, `Multiplayer.LoadingError`, + `MultiplayerServer.CheckServerThread()`. +- Removed: the `consistentCommandOrder` tweak value, the `PlaceGravship` sync method registration (it never synced + inside the long event), `PushFactionOutsideContext`/`PopToLocalFaction`, the `TileFactionContext` fallback. Clients + no longer send standalone snapshot packets. + +## Known limitations / not addressed + +### Limitations of the fixes + +- Without Prepatcher the ritual role widgets themselves are still not synced; only the assignments at ritual start are + (#687 is only partly fixed). +- In multifaction only the first player faction that follows an ideoligion gets its festival and role letters + (`bec7f23`). +- Map ids in client commands are checked by scope only, not against existing maps, because the server doesn't know + maps created since the last join point. +- Gravship takeoff dialogs, the destination picker and landing cutscenes can't be restored after a reload; their + sessions stay unsaved. +- On setups where the stack walk throws (e.g. macOS through Rosetta) desync traces are off for the rest of the + process, so reports from that client carry no traces. +- Custom xenotypes of a new multifaction faction's starting pawns aren't added to the custom xenotype database + (`e2a589f`). +- A different game language is only reported (#264); translated text still differs between such clients. +- #618 is addressed only for the fire/sound path; #621, #659, #884, #964 and #422 are partly addressed. + +### Parts of open PRs deliberately not taken + +- #958: the same-IP replacement for direct connections (a player behind the same NAT could kick another). +- #961: its async clock model and VTR view table (they conflict with the sim long event context and the VTR fix here), + and its `DefeatAllEnemiesQuestComp`/`TimedForcedExit` parts (nothing starts that quest and no def uses the obsolete + comp in 1.6). +- #956: out-of-band player count packets (would bring back #862). +- #995: the Roslyn test fixtures. + +### Open issues not addressed + +- #546, #581: quests in multifaction need the quest owner's faction context (a large change). +- #669 (scenarios in multifaction), #639 (one global gravship controller in multifaction), #799 (permanent pawn + opinions), #898 (pens with same-tile settlements), #627 (ShuttleCrash_Rescue bodyguards leaving). +- #992 (Combat Extended bullet collision desync). +- #989, #990, #457 (MonoMod allocator in Prepatcher's Harmony). +- #754 (ARM float differences). + +## Testing + +- The solution builds and 284 unit tests pass. New test files cover the command log, join points, the joining state, + fragmented packets, large commands, reader bounds, reconnects, server stop, the sync reference, standalone + persistence, world data size, selection packets, line ending normalization and log trimming. +- Each stage was reviewed separately and the findings were fixed before it was merged into the branch. +- In-game tests used several real RimWorld 1.6.4850 instances with all DLCs on Linux (headless, Xvfb, software GL). A + harness mod drives the game through normal sync paths (draft/undraft, mining, stockpiles, work priorities, forbid, + plant cutting, raids, fires, incidents) and logs the sync state every 5 s. +- Scenarios: hosted with an arbiter and a joiner joining mid-game; hosted without an arbiter; multifaction with the + joiner creating its own faction and map; async time; standalone server (two players, server-driven autosave join + point, restart from the persisted state, start from an older save). +- Results on `dev`: a desync in 1 of 4 valid runs ("Random state from commands doesn't match" during the joiner's + catch-up), and the arbiter game froze for everyone as soon as someone joined. +- Results on the branch: no desyncs in about 30 sessions, the last ones a full run of every scenario on the complete branch, identical VTR viewer counts on all instances, and identical + world and map ticks without async time. +- Not covered in game, only by code review: Steam, Windows and macOS clients, other mods, gravship flights, rituals, + trading and caravans, large mod lists.