diff --git a/.gitignore b/.gitignore index f93c7ab3..799f6bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -151,10 +151,12 @@ publish/ # in these scripts will be unencrypted PublishScripts/ +# Commented out .nupkg and **/packages/* so we can use a custom package # NuGet Packages -*.nupkg +# *.nupkg # The packages folder can be ignored because of Package Restore -**/packages/* +# **/packages/* + # except build/, which is used as an MSBuild target. !**/packages/build/ # Uncomment if necessary however generally it will be regenerated when needed diff --git a/Languages b/Languages index 0c38ba2e..89fc845b 160000 --- a/Languages +++ b/Languages @@ -1 +1 @@ -Subproject commit 0c38ba2e075ab40f676d3658959765464985ceda +Subproject commit 89fc845b7875d042593d3f0fb1b771aba9b34001 diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 8a91bfcc..444d5cb5 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -10,6 +10,7 @@ using Multiplayer.Client.Patches; using Multiplayer.Client.Saving; using Multiplayer.Client.Util; +using System.Linq; namespace Multiplayer.Client { @@ -98,6 +99,9 @@ public int GameStartAbsTick public Queue cmds = new(); + public int CurrentPlayerCount { get; private set; } = 0; + public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; + public AsyncTimeComp(Map map, int gameStartAbsTick = 0) { this.map = map; @@ -228,6 +232,9 @@ public void ExposeData() Scribe_Custom.LookULong(ref randState, "randState", 1); } + public void IncreasePlayerCount() => CurrentPlayerCount++; + public void DecreasePlayerCount() => CurrentPlayerCount = Math.Max(0, CurrentPlayerCount - 1); + public void FinalizeInit() { cmds = new Queue( diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index a5f135b0..e2a360d1 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -147,7 +147,7 @@ static class TickManagerPausedPatch static void Postfix(ref bool __result) { if (Multiplayer.Client == null) return; - if (WorldRendererUtility.WorldRendered) return; + if (WorldRendererUtility.WorldSelected) return; if (FactionCreator.generatingMap) return; var asyncTime = Find.CurrentMap.AsyncTime(); diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 9674e4c1..97bb1a65 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -223,6 +223,19 @@ public void ExecuteCmd(ScheduledCommand cmd) var canUseDevMode = data.ReadBool(); Multiplayer.GameComp.playerData[playerId] = new PlayerData { canUseDevMode = canUseDevMode }; } + + if (cmdType == CommandType.PlayerCount) + { + int previousMapId = data.ReadInt32(); + int newMapId = data.ReadInt32(); + int mapCount = Find.Maps.Count; + + if (0 <= previousMapId && previousMapId < mapCount) + Find.Maps[previousMapId]?.AsyncTime()?.DecreasePlayerCount(); + + if (0 <= newMapId && newMapId < mapCount) + Find.Maps[newMapId]?.AsyncTime()?.IncreasePlayerCount(); + } } catch (Exception e) { diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 628aa981..c8093061 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -65,6 +65,7 @@ public static class Multiplayer public static string ReplaysDir => GenFilePaths.FolderUnderSaveData("MpReplays"); public static string DesyncsDir => GenFilePaths.FolderUnderSaveData("MpDesyncs"); + public static string MpLogsDir => GenFilePaths.FolderUnderSaveData("MpLogs"); public static Stopwatch clock = Stopwatch.StartNew(); diff --git a/Source/Client/OnMainThread.cs b/Source/Client/OnMainThread.cs index b07999b6..f6d0e099 100644 --- a/Source/Client/OnMainThread.cs +++ b/Source/Client/OnMainThread.cs @@ -1,3 +1,4 @@ +using Multiplayer.Client.DebugUi; using Multiplayer.Client.Networking; using Multiplayer.Common; using System; @@ -42,6 +43,11 @@ public void Update() Multiplayer.session.Update(); SyncFieldUtil.UpdateSync(); + if (PerformanceRecorder.IsRecording) + { + PerformanceRecorder.RecordFrame(); + } + if (!Multiplayer.arbiterInstance && Application.isFocused && !TickPatch.Simulating && !Multiplayer.session.desynced) Multiplayer.session.playerCursors.SendVisuals(); diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs new file mode 100644 index 00000000..40704525 --- /dev/null +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -0,0 +1,128 @@ +using HarmonyLib; +using Multiplayer.Client.Patches; +using Multiplayer.Client.Util; +using Multiplayer.Common; +using RimWorld.Planet; +using System; +using Verse; + +namespace Multiplayer.Client.Patches +{ + [HarmonyPatch(typeof(GenTicks), nameof(GenTicks.GetCameraUpdateRate))] + public static class VTRSyncPatch + { + static bool Prefix(Thing thing, ref int __result) + { + if (Multiplayer.Client == null) + return true; + + __result = VTRSync.GetSynchronizedUpdateRate(thing); + return false; + } + } + + [HarmonyPatch(typeof(Projectile), nameof(Projectile.UpdateRateTicks), MethodType.Getter)] + public static class VtrSyncProjectilePatch + { + static bool Prefix(ref int __result, Projectile __instance) + { + if (Multiplayer.Client == null) + return true; + + __result = __instance.Spawned ? VTRSync.GetSynchronizedUpdateRate(__instance) : VTRSync.MaximumVtr; + return false; + } + } + + [HarmonyPatch(typeof(WorldObject), nameof(WorldObject.UpdateRateTicks), MethodType.Getter)] + public static class VtrSyncWorldObjectPatch + { + static bool Prefix(ref int __result, WorldObject __instance) + { + if (Multiplayer.Client == null) + return true; + + __result = VTRSync.MaximumVtr; + return false; + } + } + + static class VTRSync + { + // Special identifier for world map (since it doesn't have a uniqueID like regular maps) + public const int WorldMapId = -2; + public static int lastMovedToMap = -1; + public static int lastSentTick = -1; + + // Vtr rates + public const int MaximumVtr = 15; + public const int MinimumVtr = 1; + + public static int GetSynchronizedUpdateRate(Thing thing) => thing?.MapHeld?.AsyncTime()?.VTR ?? VTRSync.MaximumVtr; + } + + [HarmonyPatch(typeof(Game), nameof(Game.CurrentMap), MethodType.Setter)] + static class MapSwitchPatch + { + static void Prefix(Map value) + { + if (Multiplayer.Client == null || Client.Multiplayer.session == null) return; + + try + { + int previousMap = Find.CurrentMap?.uniqueID ?? -1; + int newMap = value?.uniqueID ?? -1; + int currentTick = Find.TickManager?.TicksGame ?? 0; + + // If no change in map, do nothing + if (previousMap == newMap) + return; + + // Prevent duplicate commands for the same transition, but allow retry after a tick + if (VTRSync.lastMovedToMap == newMap && currentTick == VTRSync.lastSentTick) + return; + + // Send map change command to server + // Send as global command since it affects multiple maps + Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(previousMap, newMap)); + + // Track this command to prevent duplicates + VTRSync.lastMovedToMap = newMap; + VTRSync.lastSentTick = currentTick; + } + catch (Exception ex) + { + MpLog.Error($"VTR MapSwitchPatch error: {ex.Message}"); + } + } + } + + [HarmonyPatch(typeof(WorldRendererUtility), nameof(WorldRendererUtility.CurrentWorldRenderMode), MethodType.Getter)] + static class WorldRenderModePatch + { + private static WorldRenderMode lastRenderMode = WorldRenderMode.None; + + static void Postfix(WorldRenderMode __result) + { + if (Multiplayer.Client == null) return; + + try + { + // Detect transition to world map (Planet mode) + if (__result == WorldRenderMode.Planet && lastRenderMode != WorldRenderMode.Planet) + { + if (VTRSync.lastMovedToMap != -1) + { + Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(VTRSync.lastMovedToMap, VTRSync.WorldMapId)); + } + } + + lastRenderMode = __result; + } + catch (Exception ex) + { + MpLog.Error($"WorldRenderModePatch error: {ex.Message}"); + } + } + } +} diff --git a/Source/Client/Persistent/CaravanFormingPatches.cs b/Source/Client/Persistent/CaravanFormingPatches.cs index 8a35de1f..ffdee910 100644 --- a/Source/Client/Persistent/CaravanFormingPatches.cs +++ b/Source/Client/Persistent/CaravanFormingPatches.cs @@ -204,7 +204,7 @@ internal static void StartFormingCaravan(Faction faction, Map map, bool reform = // RNG shouldn't be invoked but TryAddWaypoint is quite complex and calls pathfinding Rand.PushState(); - var worldRoutePlanner = Find.WorldRoutePlanner; + WorldRoutePlanner worldRoutePlanner = Find.WorldRoutePlanner; worldRoutePlanner.Start(dialog); worldRoutePlanner.TryAddWaypoint(routePlannerWaypoint.Value); } diff --git a/Source/Client/UI/DebugPanel/DebugLine.cs b/Source/Client/UI/DebugPanel/DebugLine.cs new file mode 100644 index 00000000..ff5de421 --- /dev/null +++ b/Source/Client/UI/DebugPanel/DebugLine.cs @@ -0,0 +1,20 @@ +using UnityEngine; + +namespace Multiplayer.Client.DebugUi +{ + // Data structures for organizing debug sections + internal struct DebugLine + { + public string Label; + public string Value; + public Color Color; + + public DebugLine(string label, string value, Color color) + { + Label = label; + Value = value; + Color = color; + } + } + +} diff --git a/Source/Client/UI/DebugPanel/DebugSection.cs b/Source/Client/UI/DebugPanel/DebugSection.cs new file mode 100644 index 00000000..119390c3 --- /dev/null +++ b/Source/Client/UI/DebugPanel/DebugSection.cs @@ -0,0 +1,14 @@ +namespace Multiplayer.Client.DebugUi +{ + internal struct DebugSection + { + public string Title; + public DebugLine[] Lines; + + public DebugSection(string title, DebugLine[] lines) + { + Title = title; + Lines = lines; + } + } +} diff --git a/Source/Client/UI/DebugPanel/PerformanceCalculator.cs b/Source/Client/UI/DebugPanel/PerformanceCalculator.cs new file mode 100644 index 00000000..0dadd319 --- /dev/null +++ b/Source/Client/UI/DebugPanel/PerformanceCalculator.cs @@ -0,0 +1,191 @@ +using Multiplayer.Client.AsyncTime; +using System; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.DebugUi +{ + /// + /// Calculates performance based on current TPS and target TPS. Handles speed changes and stabilization periods. + /// + public static class PerformanceCalculator + { + private static TimeSpeed lastTimeSpeed = TimeSpeed.Normal; + private static float lastSpeedChangeTime = 0f; + private const float SpeedStabilizationTime = 2.5f; + + // AsyncTime caching for performance + private static Map lastCachedMap = null; + private static AsyncTimeComp cachedAsyncTimeComp = null; + private static float lastCacheTime = 0f; + private const float CacheValidTime = 0.1f; // 100ms + + private static AsyncTimeComp GetCachedAsyncTimeComp() + { + var currentMap = Find.CurrentMap; + var currentTime = Time.realtimeSinceStartup; + + if (currentMap == lastCachedMap && + cachedAsyncTimeComp != null && + currentTime - lastCacheTime < CacheValidTime) + { + return cachedAsyncTimeComp; + } + + lastCachedMap = currentMap; + lastCacheTime = currentTime; + cachedAsyncTimeComp = currentMap?.AsyncTime(); + + return cachedAsyncTimeComp; + } + + /// + /// Get the effective TPS target considering all speed-limiting mechanisms + /// + public static float GetTargetTPS() + { + if (TickPatch.Frozen) return 0f; + + if (Multiplayer.GameComp?.asyncTime == true) + { + // Try map async time first + if (GetCachedAsyncTimeComp() is AsyncTimeComp asyncTimeComp) + { + var desiredSpeed = asyncTimeComp.DesiredTimeSpeed; + float rateMultiplier = asyncTimeComp.TickRateMultiplier(desiredSpeed); + + var currentTicks = asyncTimeComp.mapTicks; + var forceNormalUntil = Find.TickManager?.slower?.forceNormalSpeedUntil ?? 0; + + if (currentTicks < forceNormalUntil) return 60f; + return CalculateTPS(rateMultiplier, desiredSpeed); + } + + // Fallback to world async time + if (Multiplayer.AsyncWorldTime is AsyncWorldTimeComp comp) + { + var worldDesiredSpeed = comp.DesiredTimeSpeed; + float worldRateMultiplier = comp.TickRateMultiplier(worldDesiredSpeed); + return CalculateTPS(worldRateMultiplier, worldDesiredSpeed); + } + } + else + { + // Non-async mode, the actual rate is the minimum across all maps + if (GetCachedAsyncTimeComp() is AsyncTimeComp currentTickable) + { + var desiredSpeed = currentTickable.DesiredTimeSpeed; + float actualRateMultiplier = currentTickable.ActualRateMultiplier(desiredSpeed); + return CalculateTPS(actualRateMultiplier, desiredSpeed); + } + } + + // Fallback to standard TickManager calculation + if (IsForcedNormal() || Find.TickManager is not TickManager tickManager) + { + return 60f; + } + + if (tickManager.Paused || tickManager.ForcePaused) return 0f; + + return tickManager.CurTimeSpeed switch + { + TimeSpeed.Paused => 0f, + TimeSpeed.Normal => 60f, + TimeSpeed.Fast => 180f, + TimeSpeed.Superfast => 360f, + TimeSpeed.Ultrafast => 900f, + _ => 60f + }; + } + + /// + /// Check if we're in a stabilization period after a speed change + /// + public static bool IsInStabilizationPeriod() + { + if (Find.TickManager is not TickManager tickManager) return false; + + TimeSpeed currentSpeed; + if (Multiplayer.GameComp?.asyncTime == true) + { + if (GetCachedAsyncTimeComp() is AsyncTimeComp asyncTimeComp) + { + currentSpeed = asyncTimeComp.DesiredTimeSpeed; + } + else + { + currentSpeed = Multiplayer.AsyncWorldTime?.DesiredTimeSpeed ?? + (tickManager.Paused ? TimeSpeed.Paused : tickManager.CurTimeSpeed); + } + } + else + { + currentSpeed = tickManager.Paused ? TimeSpeed.Paused : tickManager.CurTimeSpeed; + } + + if (currentSpeed != lastTimeSpeed) + { + lastTimeSpeed = currentSpeed; + lastSpeedChangeTime = Time.realtimeSinceStartup; + + // Invalidate AsyncTime cache to pick up changes quickly + cachedAsyncTimeComp = null; + lastCachedMap = null; + + return true; + } + + return Time.realtimeSinceStartup - lastSpeedChangeTime < SpeedStabilizationTime; + } + + /// + /// Get normalized TPS performance as percentage (0-100%) + /// + public static float GetNormalizedTPS(float currentTps) + { + float targetTps = GetTargetTPS(); + + if (targetTps == 0f) return 100f; + + float percentage = (currentTps / targetTps) * 100f; + return Math.Min(percentage, 100f); + } + + /// + /// Get normalized TPS performance or null if in stabilization period + /// + public static float? GetStableNormalizedTPS(float currentTps) + { + if (IsInStabilizationPeriod()) + return null; + + return GetNormalizedTPS(currentTps); + } + + /// + /// Unified color logic for performance metrics + /// + public static Color GetPerformanceColor(float value, float goodThreshold, float moderateThreshold, bool lowerIsBetter = false) + { + return lowerIsBetter switch + { + false when value >= goodThreshold => Color.green, + false when value >= moderateThreshold => Color.yellow, + true when value <= goodThreshold => Color.green, + true when value <= moderateThreshold => Color.yellow, + _ => Color.red + }; + } + + private static bool IsForcedNormal() => Find.TickManager?.slower?.ForcedNormalSpeed == true; + + private static float CalculateTPS(float rateMultiplier, TimeSpeed speed) + { + if (speed == TimeSpeed.Paused || rateMultiplier == 0f) return 0f; + if (IsForcedNormal()) return 60f; + return rateMultiplier * 60f; + } + + } +} diff --git a/Source/Client/UI/DebugPanel/PerformanceRecorder.cs b/Source/Client/UI/DebugPanel/PerformanceRecorder.cs new file mode 100644 index 00000000..c5d2a220 --- /dev/null +++ b/Source/Client/UI/DebugPanel/PerformanceRecorder.cs @@ -0,0 +1,449 @@ +using Multiplayer.Client.Patches; +using Multiplayer.Client.Util; +using Multiplayer.Common; +using RimWorld; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.DebugUi; + +/// +/// Records performance and networking diagnostics during multiplayer sessions. +/// Tracks averages, highs, and lows for various metrics and outputs results to file and console. +/// +public static class PerformanceRecorder +{ + private static bool isRecording = false; + private static DateTime recordingStartTime; + private static int recordingFrameCount = 0; + + // Performance metrics + private static List frameTimeSamples = new List(); + private static List tickTimeSamples = new List(); + private static List deltaTimeSamples = new List(); + private static List fpsSamples = new List(); + private static List tpsSamples = new List(); + private static List normalizedTpsSamples = new List(); + private static List serverTPTSamples = new List(); + + // Networking metrics + private static List timerLagSamples = new List(); + private static List receivedCmdsSamples = new List(); + private static List sentCmdsSamples = new List(); + private static List bufferedChangesSamples = new List(); + private static List mapCmdsSamples = new List(); + private static List worldCmdsSamples = new List(); + + // Memory/GC metrics + private static List clientOpinionsSamples = new List(); + private static List worldPawnsSamples = new List(); + private static List windowCountSamples = new List(); + + public static bool IsRecording => isRecording; + public static int FrameCount => recordingFrameCount; + public static TimeSpan RecordingDuration => isRecording ? DateTime.Now - recordingStartTime : TimeSpan.Zero; + public static float AverageFPS => fpsSamples.Count > 0 ? fpsSamples.Average() : 0f; + public static float AverageTPS => tpsSamples.Count > 0 ? tpsSamples.Average() : 0f; + public static float AverageNormalizedTPS => normalizedTpsSamples.Count > 0 ? normalizedTpsSamples.Average() : 0f; + + /// + /// Get recording status for debug panel display + /// + public static StatusBadge GetRecordingStatus() + { + if (!isRecording) + return new StatusBadge("⏸", Color.gray, "REC", "Performance recording is stopped"); + + var duration = DateTime.Now - recordingStartTime; + return new StatusBadge("⏺", Color.red, $"REC {duration.TotalSeconds:F0}s", $"Recording performance for {duration.TotalSeconds:F1} seconds"); + } + + public static void StartRecording() + { + if (isRecording) return; + + isRecording = true; + recordingStartTime = DateTime.Now; + recordingFrameCount = 0; + + // Clear previous samples + frameTimeSamples.Clear(); + tickTimeSamples.Clear(); + deltaTimeSamples.Clear(); + fpsSamples.Clear(); + tpsSamples.Clear(); + normalizedTpsSamples.Clear(); + serverTPTSamples.Clear(); + timerLagSamples.Clear(); + receivedCmdsSamples.Clear(); + sentCmdsSamples.Clear(); + bufferedChangesSamples.Clear(); + mapCmdsSamples.Clear(); + worldCmdsSamples.Clear(); + clientOpinionsSamples.Clear(); + worldPawnsSamples.Clear(); + windowCountSamples.Clear(); + + Verse.Log.Message("[PerformanceRecorder] Recording started"); + } + + public static void StopRecording() + { + if (!isRecording) return; + + isRecording = false; + var recordingDuration = DateTime.Now - recordingStartTime; + + Verse.Log.Message("[PerformanceRecorder] Recording stopped"); + + // Generate and output results + var results = GenerateResults(recordingDuration); + OutputToConsole(results); + OutputToFile(results); + } + + public static void RecordFrame() + { + if (!isRecording) return; + + recordingFrameCount++; + + // Performance metrics + frameTimeSamples.Add(Time.deltaTime * 1000f); + tickTimeSamples.Add(TickPatch.tickTimer?.ElapsedMilliseconds ?? 0); + deltaTimeSamples.Add(Time.deltaTime * 60f); + fpsSamples.Add(1f / Time.deltaTime); + + // Networking metrics + if (Multiplayer.Client != null) + { + timerLagSamples.Add(TickPatch.tickUntil - TickPatch.Timer); + receivedCmdsSamples.Add(Multiplayer.session?.receivedCmds ?? 0); + sentCmdsSamples.Add(Multiplayer.session?.remoteSentCmds ?? 0); + bufferedChangesSamples.Add(SyncFieldUtil.bufferedChanges?.Sum(kv => kv.Value?.Count ?? 0) ?? 0); + + if (Find.CurrentMap?.AsyncTime() != null) + { + var async = Find.CurrentMap.AsyncTime(); + float currentTps = IngameUIPatch.tps; + tpsSamples.Add(currentTps); + + // Only record normalized TPS if we're not in stabilization period + if (PerformanceCalculator.GetStableNormalizedTPS(currentTps) is float stableNormalizedTps) + { + normalizedTpsSamples.Add(stableNormalizedTps); + } + + serverTPTSamples.Add(TickPatch.serverTimePerTick); + mapCmdsSamples.Add(async.cmds?.Count ?? 0); + worldCmdsSamples.Add(Multiplayer.AsyncWorldTime?.cmds?.Count ?? 0); + } + + clientOpinionsSamples.Add(Multiplayer.game?.sync?.knownClientOpinions?.Count ?? 0); + } + + // Memory/system metrics + worldPawnsSamples.Add(Find.WorldPawns?.AllPawnsAliveOrDead?.Count ?? 0); + windowCountSamples.Add(Find.WindowStack?.windows?.Count ?? 0); + } + + private static PerformanceResults GenerateResults(TimeSpan duration) + { + return new PerformanceResults + { + Duration = duration, + FrameCount = recordingFrameCount, + StartTime = recordingStartTime, + + // Performance stats + FrameTime = CalculateStats(frameTimeSamples), + TickTime = CalculateStats(tickTimeSamples), + DeltaTime = CalculateStats(deltaTimeSamples), + FPS = CalculateStats(fpsSamples), + TPS = CalculateStats(tpsSamples), + NormalizedTPS = CalculateStats(normalizedTpsSamples), + ServerTPT = CalculateStats(serverTPTSamples), + + // Networking stats + TimerLag = CalculateStats(timerLagSamples.Select(x => (float)x)), + ReceivedCmds = CalculateStats(receivedCmdsSamples.Select(x => (float)x)), + SentCmds = CalculateStats(sentCmdsSamples.Select(x => (float)x)), + BufferedChanges = CalculateStats(bufferedChangesSamples.Select(x => (float)x)), + MapCmds = CalculateStats(mapCmdsSamples.Select(x => (float)x)), + WorldCmds = CalculateStats(worldCmdsSamples.Select(x => (float)x)), + + // Memory stats + ClientOpinions = CalculateStats(clientOpinionsSamples.Select(x => (float)x)), + WorldPawns = CalculateStats(worldPawnsSamples.Select(x => (float)x)), + WindowCount = CalculateStats(windowCountSamples.Select(x => (float)x)) + }; + } + + private static StatResult CalculateStats(IEnumerable samples) + { + var list = samples.ToList(); + if (list.Count == 0) + return new StatResult { Average = 0, Min = 0, Max = 0, Count = 0 }; + + return new StatResult + { + Average = list.Average(), + Min = list.Min(), + Max = list.Max(), + Count = list.Count + }; + } + + private static void OutputToConsole(PerformanceResults results) + { + var sb = new StringBuilder(); + sb.AppendLine("-- MULTIPLAYER PERFORMANCE RECORDING RESULTS --"); + sb.AppendLine($"Recording Duration: {results.Duration.TotalSeconds:F2} seconds"); + sb.AppendLine($"Total Frames: {results.FrameCount:N0}"); + sb.AppendLine($"Recording Start: {results.StartTime:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine(); + + sb.AppendLine("PERFORMANCE METRICS:"); + sb.AppendLine($" Frame Time: Avg {results.FrameTime.Average:F2}ms Min {results.FrameTime.Min:F2}ms Max {results.FrameTime.Max:F2}ms"); + sb.AppendLine($" Tick Time: Avg {results.TickTime.Average:F2}ms Min {results.TickTime.Min:F2}ms Max {results.TickTime.Max:F2}ms"); + sb.AppendLine($" FPS: Avg {results.FPS.Average:F1} Min {results.FPS.Min:F1} Max {results.FPS.Max:F1}"); + sb.AppendLine($" TPS (Raw): Avg {results.TPS.Average:F2} Min {results.TPS.Min:F2} Max {results.TPS.Max:F2}"); + sb.AppendLine($" TPS (Norm %): Avg {results.NormalizedTPS.Average:F1}% Min {results.NormalizedTPS.Min:F1}% Max {results.NormalizedTPS.Max:F1}%"); + sb.AppendLine($" Server TPT: Avg {results.ServerTPT.Average:F2}ms Min {results.ServerTPT.Min:F2}ms Max {results.ServerTPT.Max:F2}ms"); + sb.AppendLine(); + + sb.AppendLine("NETWORKING METRICS:"); + sb.AppendLine($" Timer Lag: Avg {results.TimerLag.Average:F1} Min {results.TimerLag.Min:F0} Max {results.TimerLag.Max:F0}"); + sb.AppendLine($" Received Cmds: Avg {results.ReceivedCmds.Average:F0} Min {results.ReceivedCmds.Min:F0} Max {results.ReceivedCmds.Max:F0}"); + sb.AppendLine($" Sent Cmds: Avg {results.SentCmds.Average:F0} Min {results.SentCmds.Min:F0} Max {results.SentCmds.Max:F0}"); + sb.AppendLine($" Buffered Changes: Avg {results.BufferedChanges.Average:F0} Min {results.BufferedChanges.Min:F0} Max {results.BufferedChanges.Max:F0}"); + sb.AppendLine($" Map Commands: Avg {results.MapCmds.Average:F0} Min {results.MapCmds.Min:F0} Max {results.MapCmds.Max:F0}"); + sb.AppendLine($" World Commands: Avg {results.WorldCmds.Average:F0} Min {results.WorldCmds.Min:F0} Max {results.WorldCmds.Max:F0}"); + sb.AppendLine(); + + sb.AppendLine("SYSTEM METRICS:"); + sb.AppendLine($" Client Opinions: Avg {results.ClientOpinions.Average:F0} Min {results.ClientOpinions.Min:F0} Max {results.ClientOpinions.Max:F0}"); + sb.AppendLine($" World Pawns: Avg {results.WorldPawns.Average:F0} Min {results.WorldPawns.Min:F0} Max {results.WorldPawns.Max:F0}"); + sb.AppendLine($" Window Count: Avg {results.WindowCount.Average:F0} Min {results.WindowCount.Min:F0} Max {results.WindowCount.Max:F0}"); + sb.AppendLine(); + sb.AppendLine("-- END RECORDING RESULTS --"); + + Verse.Log.Message(sb.ToString()); + } + + private static void OutputToFile(PerformanceResults results) + { + try + { + var fileName = $"MpLogs/MpPerf-{results.StartTime:MMddHHmmss}.txt"; + var filePath = Path.Combine(GenFilePaths.SaveDataFolderPath, fileName); + + var sb = new StringBuilder(); + sb.AppendLine("MULTIPLAYER PERFORMANCE RECORDING RESULTS"); + sb.AppendLine("-----------------------------------------"); + sb.AppendLine($"Recording Start: {results.StartTime:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"Recording Duration: {results.Duration.TotalSeconds:F2} seconds"); + sb.AppendLine($"Total Frames Recorded: {results.FrameCount:N0}"); + sb.AppendLine($"Average Frame Rate: {results.FrameCount / results.Duration.TotalSeconds:F1} FPS"); + sb.AppendLine(); + + sb.AppendLine("PERFORMANCE METRICS"); + sb.AppendLine("-------------------"); + AppendDetailedStats(sb, "Frame Time (ms)", results.FrameTime); + AppendDetailedStats(sb, "Tick Time (ms)", results.TickTime); + AppendDetailedStats(sb, "Delta Time", results.DeltaTime); + AppendDetailedStats(sb, "Frames Per Second", results.FPS); + AppendDetailedStats(sb, "Ticks Per Second (Raw)", results.TPS); + AppendDetailedStats(sb, "TPS Performance (%)", results.NormalizedTPS); + AppendDetailedStats(sb, "Server Time Per Tick (ms)", results.ServerTPT); + AppendDetailedStats(sb, "Timer Lag", results.TimerLag); + + sb.AppendLine(); + + + + sb.AppendLine("MISC METRICS"); + sb.AppendLine("------------"); + AppendDetailedStats(sb, "Client Opinions", results.ClientOpinions); + AppendDetailedStats(sb, "World Pawns", results.WorldPawns); + AppendDetailedStats(sb, "Window Count", results.WindowCount); + sb.AppendLine(); + + sb.AppendLine("COMMAND METRICS"); + sb.AppendLine("---------------"); + AppendCountStats(sb, "Received Commands", (int)results.ReceivedCmds.Max); + AppendCountStats(sb, "Sent Commands", (int)results.SentCmds.Max); + AppendCountStats(sb, "Buffered Changes", (int)results.BufferedChanges.Max); + AppendCountStats(sb, "Map Commands", (int)results.MapCmds.Max); + AppendCountStats(sb, "World Commands", (int)results.WorldCmds.Max); + sb.AppendLine(); + + sb.AppendLine("SYSTEM INFORMATION"); + sb.AppendLine("------------------"); + sb.AppendLine($"RimWorld Version: {VersionControl.CurrentVersionStringWithRev}"); + sb.AppendLine($"Multiplayer Version: {MpVersion.Version}"); + sb.AppendLine($"Unity Version: {Application.unityVersion}"); + sb.AppendLine($"Platform: {Application.platform}"); + sb.AppendLine($"System Memory: {SystemInfo.systemMemorySize} MB"); + sb.AppendLine($"Graphics Memory: {SystemInfo.graphicsMemorySize} MB"); + sb.AppendLine($"Processor: {SystemInfo.processorType} ({SystemInfo.processorCount} cores)"); + + File.WriteAllText(filePath, sb.ToString()); + Verse.Log.Message($"[PerformanceRecorder] Results saved to: {filePath}"); + } + catch (Exception ex) + { + Verse.Log.Error($"[PerformanceRecorder] Failed to save results to file: {ex.Message}"); + } + } + + private static void AppendDetailedStats(StringBuilder sb, string name, StatResult stats) + { + sb.AppendLine($"{name,-25} Avg: {stats.Average,8:F2} Min: {stats.Min,8:F2} Max: {stats.Max,8:F2} Samples: {stats.Count,6:N0}"); + } + + private static void AppendCountStats(StringBuilder sb, string name, int count) + { + sb.AppendLine($"{name,-25} Count: {count,6:N0}"); + } + /// + /// Draw performance recorder section for debug panel + /// + public static float DrawPerformanceRecorderSection(float x, float y, float width) + { + StatusBadge recordingStatus = GetRecordingStatus(); + + var lines = new List + { + new DebugLine("Status:", recordingStatus.text, recordingStatus.color), + new DebugLine("Frame Count:", recordingFrameCount.ToString(), Color.white) + }; + + if (isRecording) + { + var duration = DateTime.Now - recordingStartTime; + lines.Add(new DebugLine("Duration:", $"{duration.TotalSeconds:F1}s", Color.white)); + lines.Add(new DebugLine("Avg FPS:", frameTimeSamples.Count > 0 ? $"{fpsSamples.Average():F1}" : "N/A", Color.white)); + lines.Add(new DebugLine("Avg TPS:", tpsSamples.Count > 0 ? $"{tpsSamples.Average():F1}" : "N/A", Color.white)); + } + + var section = new DebugSection("PERFORMANCE RECORDER", lines.ToArray()); + return DrawSection(x, y, width, section); + } + + /// + /// Draw performance recorder controls for debug panel + /// + public static float DrawPerformanceRecorderControls(float x, float y, float width) + { + var buttonWidth = 60f; + var buttonHeight = 20f; + var spacing = 4f; + + var startRect = new Rect(x, y, buttonWidth, buttonHeight); + var stopRect = new Rect(x + buttonWidth + spacing, y, buttonWidth, buttonHeight); + + GUI.color = isRecording ? Color.gray : Color.white; + + if (Widgets.ButtonText(startRect, "Start") && !isRecording) + { + StartRecording(); + } + + + GUI.color = !isRecording ? Color.gray : Color.white; + + if (Widgets.ButtonText(stopRect, "Stop") && isRecording) + { + StopRecording(); + } + + + UnityEngine.GUI.color = Color.white; + return buttonHeight + spacing; + } + + // Helper method to draw sections (copied from SyncDebugPanel pattern) + private static float DrawSection(float x, float y, float width, DebugSection section) + { + float startY = y; + const float LineHeight = 18f; + const float SectionSpacing = 10f; + const float LabelColumnWidth = 0.5f; + + // Draw section header + var headerRect = new Rect(x, y, width, LineHeight + 2f); + using (MpStyle.Set(GameFont.Small).Set(Color.cyan).Set(TextAnchor.MiddleLeft)) + { + Widgets.Label(headerRect, $"── {section.Title} ──"); + } + y += LineHeight + 6f; + + // Draw section lines + foreach (var line in section.Lines) + { + var labelWidth = width * LabelColumnWidth; + var valueWidth = width * (1f - LabelColumnWidth); + + // Label + using (MpStyle.Set(GameFont.Tiny).Set(Color.white).Set(TextAnchor.MiddleLeft)) + { + var labelRect = new Rect(x, y, labelWidth - 4f, LineHeight); + Widgets.Label(labelRect, line.Label); + } + + // Value + using (MpStyle.Set(GameFont.Tiny).Set(line.Color).Set(TextAnchor.MiddleLeft)) + { + var valueRect = new Rect(x + labelWidth + 4f, y, valueWidth - 4f, LineHeight); + Widgets.Label(valueRect, line.Value); + } + + y += LineHeight + 1f; + } + + return y - startY + SectionSpacing; + } +} + +public class PerformanceResults +{ + public TimeSpan Duration; + public int FrameCount; + public DateTime StartTime; + + // Performance + public StatResult FrameTime; + public StatResult TickTime; + public StatResult DeltaTime; + public StatResult FPS; + public StatResult TPS; + public StatResult NormalizedTPS; + public StatResult ServerTPT; + + // Networking + public StatResult TimerLag; + public StatResult ReceivedCmds; + public StatResult SentCmds; + public StatResult BufferedChanges; + public StatResult MapCmds; + public StatResult WorldCmds; + + // System + public StatResult ClientOpinions; + public StatResult WorldPawns; + public StatResult WindowCount; +} + +public struct StatResult +{ + public float Average; + public float Min; + public float Max; + public int Count; +} diff --git a/Source/Client/UI/DebugPanel/StatusBadge.cs b/Source/Client/UI/DebugPanel/StatusBadge.cs new file mode 100644 index 00000000..f7a4998c --- /dev/null +++ b/Source/Client/UI/DebugPanel/StatusBadge.cs @@ -0,0 +1,76 @@ +using Multiplayer.Client.Patches; +using System; +using UnityEngine; +using Verse; + +namespace Multiplayer.Client.DebugUi +{ + public struct StatusBadge + { + public string icon; + public Color color; + public string text; + public string tooltip; + + public StatusBadge(string icon, Color color, string text, string tooltip) + { + this.icon = icon; + this.color = color; + this.text = text; + this.tooltip = tooltip; + } + + public static StatusBadge GetSyncStatus() + { + if (Multiplayer.session == null) + return new StatusBadge("●", Color.yellow, "N/A", "No active session"); + + if (Multiplayer.session.desynced) + return new StatusBadge("●", Color.red, "DESYNC", "Session has desynced"); + + return new StatusBadge("●", Color.green, "SYNC", "All players are synchronized"); + } + + public static StatusBadge GetPerformanceStatus() + { + float tps = IngameUIPatch.tps; + + if (PerformanceCalculator.IsInStabilizationPeriod()) + { + return new StatusBadge("▲", Color.yellow, "STAB", "Stabilizing after speed change"); + } + + float normalizedTps = PerformanceCalculator.GetNormalizedTPS(tps); + + string tooltip = normalizedTps >= 90f ? "Performance is excellent" : + normalizedTps >= 70f ? "Performance is good" : + normalizedTps >= 50f ? "Performance is moderate" : + normalizedTps >= 25f ? "Performance is poor" : + "Performance is very poor"; + + return new StatusBadge("▲", PerformanceCalculator.GetPerformanceColor(normalizedTps, 90f, 70f), $"{normalizedTps:F0}%", tooltip); + } + + + public static StatusBadge GetTickStatus() + { + int behind = TickPatch.tickUntil - TickPatch.Timer; + Color color = PerformanceCalculator.GetPerformanceColor(behind, 5, 15, true); + string tooltip = behind <= 5 ? "Timing is good" : behind <= 15 ? "Slightly behind" : "Significantly behind"; + return new StatusBadge("♦", color, behind.ToString(), tooltip); + } + + public static StatusBadge GetVtrStatus() + { + int rate = Find.CurrentMap?.AsyncTime()?.VTR ?? VTRSync.MaximumVtr; + return new StatusBadge("V", rate == 15 ? Color.red : Color.green, rate.ToString(), $"Variable Tick Rate: Things update every {rate} tick(s)"); + } + + public static StatusBadge GetNumOfPlayersStatus() + { + int playerCount = Find.CurrentMap?.AsyncTime()?.CurrentPlayerCount ?? 0; + return new StatusBadge("P", playerCount > 0 ? Color.green : Color.red, $"{playerCount}", "Active players in the current map"); + } + + } +} diff --git a/Source/Client/UI/DebugPanel/SyncDebugPanel.cs b/Source/Client/UI/DebugPanel/SyncDebugPanel.cs new file mode 100644 index 00000000..707d58e1 --- /dev/null +++ b/Source/Client/UI/DebugPanel/SyncDebugPanel.cs @@ -0,0 +1,613 @@ +using System; +using System.Linq; +using Multiplayer.Client.Desyncs; +using Multiplayer.Client.Util; +using Multiplayer.Client.Patches; +using Multiplayer.Common; +using RimWorld; +using UnityEngine; +using Verse; +using System.Collections.Generic; // Added for List + +namespace Multiplayer.Client.DebugUi +{ + /// + /// Enhanced expandable debug panel that organizes existing comprehensive debug information + /// with modern UI/UX while preserving all developer functionality + /// + public static partial class SyncDebugPanel + { + // Panel state + private static bool isExpanded = false; + private static Vector2 scrollPosition = Vector2.zero; + + // Panel dimensions + private const float HeaderHeight = 40f; + private const float VisibleExpandedHeight = 400f; + private const float ContentHeight = 1500f; + private const float PanelWidth = 275f; + + // Visual constants + private const float Margin = 8f; + private const float StatusBadgePadding = 4f; + private const float SectionSpacing = 10f; + private const float LineHeight = 18f; + private const float LabelColumnWidth = 0.5f; + + /// + /// Main entry point for the enhanced debug panel + /// + public static float DoSyncDebugPanel(float y) + { + // Safety checks + if (Multiplayer.session == null || !MpVersion.IsDebug || !Multiplayer.ShowDevInfo || Multiplayer.WriterLog == null) return 0; + + try + { + float x = 100f; + float panelHeight = isExpanded ? VisibleExpandedHeight : HeaderHeight; + + Rect panelRect = new(x, y, PanelWidth, panelHeight); + + // Draw panel background + Widgets.DrawBoxSolid(panelRect, new Color(0f, 0f, 0f, 0.7f)); + Widgets.DrawBox(panelRect); + + if (isExpanded) + DrawExpandedPanel(panelRect); + else + DrawHeader(panelRect); + + return panelHeight + Margin; + } + catch (Exception ex) + { + // Fallback in case of any errors - don't crash the game + Log.Error($"SyncDebugPanel error: {ex.Message}"); + return 0; + } + } + + /// + /// Draw the compact status summary view + /// + private static void DrawHeader(Rect rect) + { + // Draw border around entire header for expanded mode only + if (isExpanded) + Widgets.DrawBox(rect); + + Rect contentRect = rect.ContractedBy(Margin); + + try + { + // Draw compact status indicators with text values + float currentX = contentRect.x + 2f; + float centerY = contentRect.y + (contentRect.height / 2f); + + // Draw all status badges + StatusBadge[] statusBadges = + [ + StatusBadge.GetSyncStatus(), + StatusBadge.GetPerformanceStatus(), + StatusBadge.GetVtrStatus(), + StatusBadge.GetNumOfPlayersStatus(), + StatusBadge.GetTickStatus() + ]; + + foreach (StatusBadge status in statusBadges) + { + currentX = DrawCompactStatusBadge(currentX, centerY, status); + currentX += StatusBadgePadding; + } + + // Button (expand/collapse) + float buttonWidth = 20f; + Rect buttonRect = new(contentRect.xMax - buttonWidth - StatusBadgePadding, contentRect.y + (contentRect.height - 16f) / 2f, buttonWidth, 16f); + + // Draw button as a badge (no border for consistency) + Widgets.DrawBoxSolid(buttonRect, new Color(0.2f, 0.2f, 0.2f, 0.8f)); + + using (MpStyle.Set(GameFont.Tiny).Set(Color.white).Set(TextAnchor.MiddleCenter)) + { + if (Widgets.ButtonText(buttonRect, isExpanded ? "^" : "v")) + { + isExpanded = !isExpanded; + } + } + + // Click anywhere else to expand (only in compact mode) + if (!isExpanded && Event.current.type == EventType.MouseDown && + Event.current.button == 0 && + contentRect.Contains(Event.current.mousePosition) && + !buttonRect.Contains(Event.current.mousePosition)) + { + isExpanded = !isExpanded; + Event.current.Use(); + } + } + catch (Exception ex) + { + // Fallback display + using (MpStyle.Set(GameFont.Tiny).Set(Color.red).Set(TextAnchor.MiddleLeft)) + { + Widgets.Label(contentRect, $"Debug Panel Error: {ex.Message}"); + } + } + } + + /// + /// Draw a compact status badge with icon and text [🟢 SYNC] + /// + private static float DrawCompactStatusBadge(float x, float centerY, StatusBadge status) + { + // Calculate badge dimensions + string badgeText = $"{status.icon} {status.text}"; + float textWidth = Text.CalcSize(badgeText).x + 8f; // padding + float badgeHeight = 16f; + + Rect badgeRect = new(x, centerY - badgeHeight / 2f, textWidth, badgeHeight); + + // Draw badge background (no border for cleaner look) + Widgets.DrawBoxSolid(badgeRect, new Color(0.1f, 0.1f, 0.1f, 0.7f)); + + // Draw badge text + using (MpStyle.Set(GameFont.Tiny).Set(status.color).Set(TextAnchor.MiddleCenter)) + { + Widgets.Label(badgeRect, badgeText); + } + + // Add tooltip + if (!string.IsNullOrEmpty(status.tooltip)) + { + TooltipHandler.TipRegion(badgeRect, status.tooltip); + } + + return x + textWidth; + } + + /// + /// Draw the expanded comprehensive debug view + /// + private static void DrawExpandedPanel(Rect rect) + { + Rect headerRect = new(rect.x, rect.y, rect.width, 30f); + Rect contentRect = new(rect.x, rect.y + 30f, rect.width, rect.height - 30f); + + DrawHeader(headerRect); + Rect viewRect = new(0f, 0f, contentRect.width - 16f, ContentHeight); + Widgets.BeginScrollView(contentRect, ref scrollPosition, viewRect); + + float currentY = 0f; + + // Draw the content + currentY += DrawStatusSummarySection(viewRect.x + Margin, currentY, viewRect.width - Margin); + currentY += DrawRngStatesSection(viewRect.x + Margin, currentY, viewRect.width - Margin); + currentY += DrawPerformanceSection(viewRect.x + Margin, currentY, viewRect.width - Margin); + currentY += DrawPerformanceRecorderSection(viewRect.x + Margin, currentY, viewRect.width - Margin); + currentY += DrawNetworkSyncSection(viewRect.x + Margin, currentY, viewRect.width - Margin); + currentY += DrawCoreSystemSection(viewRect.x + Margin, currentY, viewRect.width - Margin); + currentY += DrawTimingSyncSection(viewRect.x + Margin, currentY, viewRect.width - Margin); + currentY += DrawGameStateSection(viewRect.x + Margin, currentY, viewRect.width - Margin); + currentY += DrawRngDebugSection(viewRect.x + Margin, currentY, viewRect.width - Margin); + currentY += DrawCommandSyncSection(viewRect.x + Margin, currentY, viewRect.width - Margin); + currentY += DrawMemoryPerformanceSection(viewRect.x + Margin, currentY, viewRect.width - Margin); + currentY += DrawMapManagementSection(viewRect.x + Margin, currentY, viewRect.width - Margin); + + Widgets.EndScrollView(); + } + + /// + /// Generic section drawing method + /// + private static float DrawSection(float x, float y, float width, DebugSection section) + { + float startY = y; + + y = DrawSectionHeader(x, y, width, section.Title); + + foreach (DebugLine line in section.Lines) + { + y = DrawStatusLine(x, y, width, line.Label, line.Value, line.Color); + } + + return y - startY + SectionSpacing; + } + + /// + /// Draw status summary section with key indicators + /// + private static float DrawStatusSummarySection(float x, float y, float width) + { + StatusBadge syncStatus = StatusBadge.GetSyncStatus(); + StatusBadge performanceStatus = StatusBadge.GetPerformanceStatus(); + StatusBadge vtrStatus = StatusBadge.GetVtrStatus(); + StatusBadge tickStatus = StatusBadge.GetTickStatus(); + + DebugLine[] lines = [ + new("Sync Status:", syncStatus.text, syncStatus.color), + new("Performance:", performanceStatus.text, performanceStatus.color), + new("VTR Status:", vtrStatus.text, vtrStatus.color), + new("Tick Status:", tickStatus.text, tickStatus.color) + ]; + + DebugSection section = new("STATUS SUMMARY", lines); + return DrawSection(x, y, width, section); + } + + /// + /// Draw performance recorder section with controls + /// + private static float DrawPerformanceRecorderSection(float x, float y, float width) + { + StatusBadge recordingStatus = PerformanceRecorder.GetRecordingStatus(); + + var lines = new List + { + new("Status:", recordingStatus.text, recordingStatus.color), + new("Frame Count:", PerformanceRecorder.FrameCount.ToString(), Color.white) + }; + + if (PerformanceRecorder.IsRecording) + { + var duration = PerformanceRecorder.RecordingDuration; + lines.Add(new("Duration:", $"{duration.TotalSeconds:F1}s", Color.white)); + lines.Add(new("Avg FPS:", PerformanceRecorder.AverageFPS > 0 ? $"{PerformanceRecorder.AverageFPS:F1}" : "N/A", Color.white)); + lines.Add(new("Avg TPS:", PerformanceRecorder.AverageTPS > 0 ? $"{PerformanceRecorder.AverageTPS:F1}" : "N/A", Color.white)); + + if (PerformanceCalculator.IsInStabilizationPeriod()) + { + lines.Add(new("TPS Perf:", "STABILIZING", Color.yellow)); + } + else + { + lines.Add(new("TPS Perf:", PerformanceRecorder.AverageNormalizedTPS > 0 ? $"{PerformanceRecorder.AverageNormalizedTPS:F1}%" : "N/A", Color.white)); + } + } + + var section = new DebugSection("PERFORMANCE RECORDER", lines.ToArray()); + float sectionHeight = DrawSection(x, y, width, section); + + // Add control buttons below the section + float buttonY = y + sectionHeight - SectionSpacing + 4f; + float buttonHeight = DrawRecorderControls(x, buttonY, width); + + return sectionHeight + buttonHeight; + } + + /// + /// Draw performance recorder control buttons + /// + private static float DrawRecorderControls(float x, float y, float width) + { + var buttonWidth = 60f; + var buttonHeight = 20f; + var spacing = 4f; + + var startRect = new Rect(x, y, buttonWidth, buttonHeight); + var stopRect = new Rect(x + buttonWidth + spacing, y, buttonWidth, buttonHeight); + + GUI.color = PerformanceRecorder.IsRecording ? Color.gray : Color.white; + + if (Widgets.ButtonText(startRect, "Start") && !PerformanceRecorder.IsRecording) + { + PerformanceRecorder.StartRecording(); + } + + GUI.color = !PerformanceRecorder.IsRecording ? Color.gray : Color.white; + + if (Widgets.ButtonText(stopRect, "Stop") && PerformanceRecorder.IsRecording) + { + PerformanceRecorder.StopRecording(); + } + + GUI.color = Color.white; + return buttonHeight + spacing; + } + + /// + /// Draw RNG states comparison section + /// + private static float DrawRngStatesSection(float x, float y, float width) + { + List lines; + + try + { + AsyncTimeComp async = Find.CurrentMap?.AsyncTime(); + AsyncTime.AsyncWorldTimeComp worldAsync = Multiplayer.AsyncWorldTime; + + if (async != null && worldAsync != null) + { + lines = [ + new("Current Map:", GetRngStatesString(async.randState), Color.white), + new("World RNG:", GetRngStatesString(worldAsync.randState), Color.white), + new("Round Mode:", $"{RoundMode.GetCurrentRoundMode()}", Color.white) + ]; + } + else + { + lines = [new("RNG States:", "No async time available", Color.gray)]; + } + } + catch (Exception ex) + { + lines = [new("RNG States:", $"Error: {ex.Message}", Color.red)]; + } + + return DrawSection(x, y, width, new("RNG STATES", lines.ToArray())); + } + + private static string GetRngStatesString(ulong randState) => $"{(uint)(randState >> 32):X8} | {(uint)randState:X8}"; + + /// + /// Draw performance metrics section + /// + private static float DrawPerformanceSection(float x, float y, float width) + { + // TPS + float tps = IngameUIPatch.tps; + float targetTps = PerformanceCalculator.GetTargetTPS(); + string tpsPerformanceText; + Color tpsColor; + + if (PerformanceCalculator.IsInStabilizationPeriod()) + { + tpsPerformanceText = "STABILIZING"; + tpsColor = Color.yellow; + } + else + { + float normalizedTps = PerformanceCalculator.GetNormalizedTPS(tps); + tpsPerformanceText = $"{normalizedTps:F1}%"; + tpsColor = PerformanceCalculator.GetPerformanceColor(normalizedTps, 90f, 70f); + } + + // Frame time + float frameTime = Time.deltaTime * 1000f; + Color frameColor = PerformanceCalculator.GetPerformanceColor(frameTime, 20f, 35f, true); + + DebugLine[] lines = [ + new("Map TPS:", $"{tps:F1}", Color.white), + new("Target TPS:", $"{targetTps:F0}", Color.gray), + new("TPS Perf:", tpsPerformanceText, tpsColor), + new("Frame Time:", $"{frameTime:F1}ms", frameColor), + new("Server TPT:", $"{TickPatch.serverTimePerTick:F1}ms", Color.white), + new("Avg Frame:", $"{TickPatch.avgFrameTime:F1}ms", Color.white) + ]; + + return DrawSection(x, y, width, new("PERFORMANCE", lines)); + } + + /// + /// Draw network and sync status section + /// + private static float DrawNetworkSyncSection(float x, float y, float width) + { + DebugLine[] lines; + + if (Multiplayer.session != null) + { + // Player count + int playerCount = Multiplayer.session.players.Count; + bool hasDesynced = Multiplayer.session.players.Any(p => p.status == PlayerStatus.Desynced); + Color playerColor = hasDesynced ? Color.red : Color.green; + + // Server status + string serverStatus = TickPatch.serverFrozen ? "Frozen" : "Running"; + Color serverColor = TickPatch.serverFrozen ? Color.yellow : Color.green; + + lines = [ + new("Players:", $"{playerCount}", playerColor), + new("Received Cmds:", $"{Multiplayer.session.receivedCmds}", Color.white), + new("Remote Sent:", $"{Multiplayer.session.remoteSentCmds}", Color.white), + new("Remote Tick:", $"{Multiplayer.session.remoteTickUntil}", Color.white), + new("Server:", serverStatus, serverColor), + ]; + } + else + { + lines = [(new("Network:", "No active session", Color.gray))]; + } + + return DrawSection(x, y, width, new("NETWORK & SYNC", lines)); + } + + /// + /// Draw core system data section + /// + private static float DrawCoreSystemSection(float x, float y, float width) + { + try + { + DebugLine[] coreLines = [ + new("Faction Stack:", $"{FactionContext.stack?.Count ?? 0}", Color.white), + new("Player Faction:", $"{Faction.OfPlayer?.loadID ?? -1}", Color.white), + new("Real Player:", $"{Multiplayer.RealPlayerFaction?.loadID ?? -1}", Color.white), + new("Next Thing ID:", $"{Find.UniqueIDsManager?.nextThingID ?? -1}", Color.white), + new("Next Job ID:", $"{Find.UniqueIDsManager?.nextJobID ?? -1}", Color.white), + new("Game Ticks:", $"{Find.TickManager?.TicksGame ?? -1}", Color.white), + new("Time Speed:", $"{Find.TickManager?.CurTimeSpeed ?? TimeSpeed.Paused}", Color.white) + ]; + + return DrawSection(x, y, width, new("CORE SYSTEM", coreLines)); + } + catch (Exception ex) + { + DebugLine[] errorLines = [new("Core System:", $"Error: {ex.Message}", Color.red)]; + return DrawSection(x, y, width, new("CORE SYSTEM", errorLines)); + } + } + + /// + /// Draw timing and sync data section + /// + private static float DrawTimingSyncSection(float x, float y, float width) + { + try + { + int timerLag = TickPatch.tickUntil - TickPatch.Timer; + Color lagColor = PerformanceCalculator.GetPerformanceColor(timerLag, 15, 30); + + DebugLine[] timingLines = [ + new("Timer Lag:", $"{timerLag}", lagColor), + new("Timer:", $"{TickPatch.Timer}", Color.white), + new("Tick Until:", $"{TickPatch.tickUntil}", Color.white), + new("Raw Tick Timer:", $"{TickPatch.tickTimer?.ElapsedMilliseconds ?? 0}ms", Color.white), + new("World Settlements:", $"{Find.World?.worldObjects?.settlements?.Count ?? 0}", Color.white) + ]; + + return DrawSection(x, y, width, new("TIMING & SYNC", timingLines)); + } + catch (Exception ex) + { + DebugLine[] errorLines = [new("Timing Sync:", $"Error: {ex.Message}", Color.red)]; + return DrawSection(x, y, width, new("TIMING & SYNC", errorLines)); + } + } + + /// + /// Draw game state data section + /// + private static float DrawGameStateSection(float x, float y, float width) + { + try + { + AsyncTimeComp async = Find.CurrentMap?.AsyncTime(); + + DebugLine[] gameStateLines = [ + new("Classic Mode:", $"{Find.IdeoManager?.classicMode ?? false}", Color.white), + new("Client Opinions:", $"{Multiplayer.game?.sync?.knownClientOpinions?.Count ?? 0}", Color.white), + new("First Opinion Tick:", $"{Multiplayer.game?.sync?.knownClientOpinions?.FirstOrDefault()?.startTick ?? -1}", Color.white), + new("Map Ticks:", $"{async?.mapTicks ?? -1}", Color.white), + new("Frozen At:", $"{TickPatch.frozenAt}", Color.white) + ]; + + return DrawSection(x, y, width, new("GAME STATE", gameStateLines)); + } + catch (Exception ex) + { + DebugLine[] errorLines = [new("Game State:", $"Error: {ex.Message}", Color.red)]; + return DrawSection(x, y, width, new("GAME STATE", errorLines)); + } + } + + /// + /// Draw RNG and debug data section + /// + private static float DrawRngDebugSection(float x, float y, float width) + { + DebugLine[] rngLines = [ + new("Rand Calls:", $"{DeferredStackTracing.acc}", Color.white), + new("Max Trace Depth:", $"{DeferredStackTracing.maxTraceDepth}", Color.white), + new("Hash Entries:", $"{DeferredStackTracingImpl.hashtableEntries}/{DeferredStackTracingImpl.hashtableSize}", Color.white), + new("Hash Collisions:", $"{DeferredStackTracingImpl.collisions}", Color.white) + ]; + + return DrawSection(x, y, width, new("RNG & DEBUG", rngLines)); + } + + /// + /// Draw command and sync data section + /// + private static float DrawCommandSyncSection(float x, float y, float width) + { + try + { + AsyncTimeComp async = Find.CurrentMap?.AsyncTime(); + + DebugLine[] commandLines = [ + new("Async Commands:", $"{async?.cmds?.Count ?? 0}", Color.white), + new("World Commands:", $"{Multiplayer.AsyncWorldTime?.cmds?.Count ?? 0}", Color.white), + new("Force Normal Speed:", $"{async?.slower?.forceNormalSpeedUntil ?? -1}", Color.white), + new("Async Time Status:", $"{Multiplayer.GameComp?.asyncTime ?? false}", Color.white), + new("Buffered Changes:", $"{SyncFieldUtil.bufferedChanges?.Sum(kv => kv.Value?.Count ?? 0) ?? 0}", Color.white) + ]; + + return DrawSection(x, y, width, new("COMMAND & SYNC", commandLines)); + } + catch (Exception ex) + { + DebugLine[] errorLines = [new("Command Sync:", $"Error: {ex.Message}", Color.red)]; + return DrawSection(x, y, width, new("COMMAND & SYNC", errorLines)); + } + } + + /// + /// Draw memory and performance data section + /// + private static float DrawMemoryPerformanceSection(float x, float y, float width) + { + float serverTpt = TickPatch.tickUntil - TickPatch.Timer <= 3 ? TickPatch.serverTimePerTick * 1.2f : + TickPatch.tickUntil - TickPatch.Timer >= 7 ? TickPatch.serverTimePerTick * 0.8f : + TickPatch.serverTimePerTick; + + DebugLine[] memoryLines = [ + new("World Pawns:", $"{Find.WorldPawns.AllPawnsAliveOrDead.Count}", Color.white), + new("Pool Free Items:", $"{SimplePool.FreeItemsCount}", Color.white), + new("Calc Server TPT:", $"{serverTpt:F1}ms", Color.white) + ]; + + return DrawSection(x, y, width, new("MEMORY & PERFORMANCE", memoryLines)); + } + + /// + /// Draw map management data section + /// + private static float DrawMapManagementSection(float x, float y, float width) + { + try + { + DebugLine[] mapLines = [ + new("Haul Destinations:", $"{Find.CurrentMap?.haulDestinationManager?.AllHaulDestinationsListForReading?.Count ?? 0}", Color.white), + new("Designations:", $"{Find.CurrentMap?.designationManager?.designationsByDef?.Count ?? 0}", Color.white), + new("Haulable Items:", $"{Find.CurrentMap?.listerHaulables?.ThingsPotentiallyNeedingHauling()?.Count ?? 0}", Color.white), + new("Mining Designations:", $"{Find.CurrentMap?.designationManager?.SpawnedDesignationsOfDef(DesignationDefOf.Mine)?.Count() ?? 0}", Color.white), + new("First Ideology ID:", $"{Find.IdeoManager?.IdeosInViewOrder?.FirstOrDefault()?.id ?? -1}", Color.white) + ]; + + return DrawSection(x, y, width, new("MAP MANAGEMENT", mapLines)); + } + catch (Exception ex) + { + DebugLine[] errorLines = [new("Map Management:", $"Error: {ex.Message}", Color.red)]; + return DrawSection(x, y, width, new("MAP MANAGEMENT", errorLines)); + } + } + + // Helper methods for UI drawing + + private static float DrawSectionHeader(float x, float y, float width, string title) + { + Rect headerRect = new(x, y, width, LineHeight + 2f); + using (MpStyle.Set(GameFont.Small).Set(Color.cyan).Set(TextAnchor.MiddleLeft)) + { + Widgets.Label(headerRect, $"── {title} ──"); + } + return y + LineHeight + 6f; // More spacing after headers + } + + private static float DrawStatusLine(float x, float y, float width, string label, string value, Color valueColor) + { + // Use consistent column widths and add padding + float labelWidth = width * LabelColumnWidth; + float valueWidth = width * (1f - LabelColumnWidth); + + // Label with right padding + using (MpStyle.Set(GameFont.Tiny).Set(Color.white).Set(TextAnchor.MiddleLeft)) + { + Rect labelRect = new(x, y, labelWidth - 4f, LineHeight); + Widgets.Label(labelRect, label); + } + + // Value with left padding + using (MpStyle.Set(GameFont.Tiny).Set(valueColor).Set(TextAnchor.MiddleLeft)) + { + Rect valueRect = new(x + labelWidth + 4f, y, valueWidth - 4f, LineHeight); + Widgets.Label(valueRect, value); + } + + return y + LineHeight + 1f; // Small padding between lines + } + } +} diff --git a/Source/Client/UI/IngameUI.cs b/Source/Client/UI/IngameUI.cs index 18c70520..42b157df 100644 --- a/Source/Client/UI/IngameUI.cs +++ b/Source/Client/UI/IngameUI.cs @@ -6,6 +6,7 @@ using UnityEngine; using Verse; using RimWorld.Planet; +using Multiplayer.Client.DebugUi; namespace Multiplayer.Client { @@ -15,7 +16,7 @@ public static class IngameUIPatch public static List> upperLeftDrawers = new() { DoChatAndTicksBehind, - IngameDebug.DoDevInfo, + SyncDebugPanel.DoSyncDebugPanel, // Enhanced expandable debug panel IngameDebug.DoDebugModeLabel, IngameDebug.DoTimeDiffLabel }; @@ -33,9 +34,10 @@ static bool Prefix() { Text.Font = GameFont.Small; - if (MpVersion.IsDebug) { - IngameDebug.DoDebugPrintout(); - } + // Legacy debug printout disabled - now handled by SyncDebugPanel + // if (MpVersion.IsDebug) { + // IngameDebug.DoDebugPrintout(); + // } if (Multiplayer.Client != null && Find.CurrentMap != null && Time.time - lastTicksAt > 0.5f) { diff --git a/Source/Client/Util/MpLog.cs b/Source/Client/Util/MpLog.cs index 4045ab3c..1793c18e 100644 --- a/Source/Client/Util/MpLog.cs +++ b/Source/Client/Util/MpLog.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; namespace Multiplayer.Client.Util { diff --git a/Source/Common/CommandType.cs b/Source/Common/CommandType.cs index ba3ce05d..0239db07 100644 --- a/Source/Common/CommandType.cs +++ b/Source/Common/CommandType.cs @@ -16,4 +16,5 @@ public enum CommandType : byte // Map scope MapTimeSpeed, Designator, + PlayerCount, } diff --git a/Source/Common/Networking/State/ServerPlayingState.cs b/Source/Common/Networking/State/ServerPlayingState.cs index 041c0d2b..04a65728 100644 --- a/Source/Common/Networking/State/ServerPlayingState.cs +++ b/Source/Common/Networking/State/ServerPlayingState.cs @@ -109,6 +109,9 @@ public void HandleCursor(ByteReader data) byte seq = data.ReadByte(); byte map = data.ReadByte(); + + // Track the player's current map from cursor updates + Player.currentMap = map; writer.WriteInt32(Player.id); writer.WriteByte(seq); diff --git a/Source/Common/PlayerManager.cs b/Source/Common/PlayerManager.cs index b8bc69a5..13bf67e1 100644 --- a/Source/Common/PlayerManager.cs +++ b/Source/Common/PlayerManager.cs @@ -80,6 +80,16 @@ public void SetDisconnected(ConnectionBase conn, MpDisconnectReason reason) if (player.hasJoined) { + // Handle unexpected disconnections by sending PlayerCount command + if (reason == MpDisconnectReason.ClientLeft || reason == MpDisconnectReason.NetFailed) + { + // Send PlayerCount command to remove player from their last known map + if (player.currentMap != -1) + { + byte[] playerCountData = ByteWriter.GetBytes(player.currentMap, -1); // previousMap: player's map, newMap: -1 (disconnected) + server.commands.Send(CommandType.PlayerCount, ScheduledCommand.NoFaction, ScheduledCommand.Global, playerCountData); + } + } // todo check player.IsPlaying? // todo FactionId might throw when called for not fully initialized players // if (Players.All(p => p.FactionId != player.FactionId)) diff --git a/Source/Common/ServerPlayer.cs b/Source/Common/ServerPlayer.cs index 3c88afa4..758e4d5a 100644 --- a/Source/Common/ServerPlayer.cs +++ b/Source/Common/ServerPlayer.cs @@ -30,6 +30,9 @@ public class ServerPlayer : IChatSource public bool frozen; public int unfrozenAt; + + // Track which map the player is currently on (from cursor updates) + public int currentMap = -1; public string Username => conn.username; public int Latency => conn.Latency; diff --git a/Source/NuGet.config b/Source/NuGet.config new file mode 100644 index 00000000..362f7cd1 --- /dev/null +++ b/Source/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Source/Packages/RimWorld.MultiplayerAPI.0.6.0.nupkg b/Source/Packages/RimWorld.MultiplayerAPI.0.6.0.nupkg new file mode 100644 index 00000000..f0af65f9 Binary files /dev/null and b/Source/Packages/RimWorld.MultiplayerAPI.0.6.0.nupkg differ