From 662278858bca3bce42b95051485da7ad7f9b8a8a Mon Sep 17 00:00:00 2001 From: "s0wlz (Matthias Puchstein)" Date: Tue, 10 Feb 2026 21:15:17 +0100 Subject: [PATCH] Add bindable SWE server diagnostics and TMP HUD adapter --- Assets/FloodSWE/Scripts/IO/SweTileLoader.cs | 180 +++++++++++++++- .../Server/SweServerDiagnosticsHud.cs | 140 ++++++++++++ .../Server/SweServerDiagnosticsHud.cs.meta | 2 + .../Networking/Server/SweServerRuntime.cs | 204 +++++++++++++++++- 4 files changed, 519 insertions(+), 7 deletions(-) create mode 100644 Assets/Scripts/Networking/Server/SweServerDiagnosticsHud.cs create mode 100644 Assets/Scripts/Networking/Server/SweServerDiagnosticsHud.cs.meta diff --git a/Assets/FloodSWE/Scripts/IO/SweTileLoader.cs b/Assets/FloodSWE/Scripts/IO/SweTileLoader.cs index 5b0bda708..426062f65 100644 --- a/Assets/FloodSWE/Scripts/IO/SweTileLoader.cs +++ b/Assets/FloodSWE/Scripts/IO/SweTileLoader.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Text; using UnityEngine; namespace FloodSWE.IO @@ -24,30 +25,85 @@ namespace FloodSWE.IO [Header("Runtime")] public bool cacheTextures = true; + public bool verboseDiagnostics = true; + public bool logMissingTextures = true; private readonly Dictionary records = new Dictionary(); private readonly Dictionary textureCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly HashSet missingTextureWarnings = new HashSet(StringComparer.OrdinalIgnoreCase); private bool indexLoaded; + private int loadAttempts; + private int loadSuccesses; + private string lastRequestedTileKey = "n/a"; + private string lastLoadedTileKey = "n/a"; + private string lastLoadSummary = "No tile load attempted yet."; + private bool lastLoadSucceeded; + private int indexRecordCount; + private int indexSkippedRows; + + public string LastRequestedTileKey => lastRequestedTileKey; + public string LastLoadedTileKey => lastLoadedTileKey; + public string LastLoadSummary => lastLoadSummary; + public bool LastLoadSucceeded => lastLoadSucceeded; + public int IndexedTileCount => indexRecordCount; + public int IndexSkippedRows => indexSkippedRows; + public int LoadAttempts => loadAttempts; + public int LoadSuccesses => loadSuccesses; public bool TryLoadTile(string lod, int tileX, int tileY, out SweTileData data) { data = default; + loadAttempts++; + lastRequestedTileKey = MakeTileKeyLabel(lod, tileX, tileY); EnsureIndexLoaded(); TileKey key = new TileKey(lod, tileX, tileY); if (!records.TryGetValue(key, out TileRecord record)) { + lastLoadSucceeded = false; + lastLoadSummary = $"tile={lastRequestedTileKey}; status=missing_in_index; indexed={records.Count}"; + Debug.LogWarning($"SweTileLoader: {lastLoadSummary}"); return false; } Texture2D height = LoadTexture(record.HeightPath); Texture2D porosity = LoadTexture(record.PorosityPath); Texture2D buildings = LoadTexture(record.BuildingPath); - Texture2D sourceIds = LoadTexture(BuildBoundaryMaskPath(record.Lod, record.X, record.Y, "source_ids")); - Texture2D sinkIds = LoadTexture(BuildBoundaryMaskPath(record.Lod, record.X, record.Y, "sink_ids")); + string sourcePath = BuildBoundaryMaskPath(record.Lod, record.X, record.Y, "source_ids"); + string sinkPath = BuildBoundaryMaskPath(record.Lod, record.X, record.Y, "sink_ids"); + Texture2D sourceIds = LoadTexture(sourcePath); + Texture2D sinkIds = LoadTexture(sinkPath); data = new SweTileData(record, height, porosity, buildings, sourceIds, sinkIds); - return height != null || porosity != null || buildings != null || sourceIds != null || sinkIds != null; + bool loadedAny = height != null || porosity != null || buildings != null || sourceIds != null || sinkIds != null; + lastLoadSucceeded = loadedAny; + if (loadedAny) + { + loadSuccesses++; + lastLoadedTileKey = lastRequestedTileKey; + } + + lastLoadSummary = BuildLoadSummary( + lastRequestedTileKey, + record, + height, + porosity, + buildings, + sourceIds, + sinkIds, + sourcePath, + sinkPath); + + if (!loadedAny) + { + Debug.LogWarning($"SweTileLoader: {lastLoadSummary}"); + } + else if (verboseDiagnostics) + { + Debug.Log($"SweTileLoader: {lastLoadSummary}"); + } + + return loadedAny; } public bool ApplyToSimulator(string lod, int tileX, int tileY, SweTileSimulator simulator) @@ -74,6 +130,21 @@ namespace FloodSWE.IO simulator.sourceIdMask = data.SourceIds; simulator.sinkIdMask = data.SinkIds; + if (verboseDiagnostics) + { + Debug.Log( + $"SweTileLoader: applied tile {MakeTileKeyLabel(data.Lod, data.TileX, data.TileY)} " + + $"to simulator (height={DescribeTexture(data.Height)}, porosity={DescribeTexture(data.Porosity)}, " + + $"sourceMask={DescribeTexture(data.SourceIds)}, sinkMask={DescribeTexture(data.SinkIds)})."); + } + + if (data.SourceIds == null || data.SinkIds == null) + { + Debug.LogWarning( + $"SweTileLoader: boundary mask coverage is incomplete for {MakeTileKeyLabel(data.Lod, data.TileX, data.TileY)} " + + $"(sourceMask={DescribeTexture(data.SourceIds)}, sinkMask={DescribeTexture(data.SinkIds)})."); + } + return true; } @@ -90,6 +161,8 @@ namespace FloodSWE.IO } records.Clear(); + indexRecordCount = 0; + indexSkippedRows = 0; string text = tileIndexCsv != null ? tileIndexCsv.text : LoadTextFromPath(); if (string.IsNullOrWhiteSpace(text)) @@ -124,16 +197,24 @@ namespace FloodSWE.IO if (parts.Length < headerColumns) { + indexSkippedRows++; + continue; + } + if (parts.Length <= 11) + { + indexSkippedRows++; continue; } string lod = parts[0].Trim(); if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int tileX)) { + indexSkippedRows++; continue; } if (!int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int tileY)) { + indexSkippedRows++; continue; } @@ -148,7 +229,14 @@ namespace FloodSWE.IO ); } + indexRecordCount = records.Count; indexLoaded = true; + if (verboseDiagnostics) + { + Debug.Log( + $"SweTileLoader: tile index loaded (records={indexRecordCount}, skipped={indexSkippedRows}, " + + $"source={DescribeIndexSource()})."); + } } private string LoadTextFromPath() @@ -201,7 +289,12 @@ namespace FloodSWE.IO switch (sourceMode) { case SourceMode.Resources: - texture = Resources.Load(ToResourcesPath(path)); + string resourcePath = ToResourcesPath(path); + texture = Resources.Load(resourcePath); + if (texture == null) + { + LogMissingTextureOnce(path, $"Resources/{resourcePath}"); + } break; case SourceMode.StreamingAssets: case SourceMode.AbsolutePath: @@ -279,7 +372,7 @@ namespace FloodSWE.IO } if (!File.Exists(resolved)) { - Debug.LogWarning($"SweTileLoader: texture not found {resolved}"); + LogMissingTextureOnce(path, resolved); return null; } @@ -298,6 +391,83 @@ namespace FloodSWE.IO return texture; } + private string BuildLoadSummary( + string requestedTile, + in TileRecord record, + Texture2D height, + Texture2D porosity, + Texture2D buildings, + Texture2D sourceIds, + Texture2D sinkIds, + string sourcePath, + string sinkPath) + { + var sb = new StringBuilder(256); + sb.Append("tile=").Append(requestedTile) + .Append("; index=").Append(indexRecordCount) + .Append("; height=").Append(DescribeTexture(height)) + .Append("; porosity=").Append(DescribeTexture(porosity)) + .Append("; buildings=").Append(DescribeTexture(buildings)) + .Append("; sourceMask=").Append(DescribeTexture(sourceIds)) + .Append(" (").Append(sourcePath).Append(")") + .Append("; sinkMask=").Append(DescribeTexture(sinkIds)) + .Append(" (").Append(sinkPath).Append(")") + .Append("; loads=").Append(loadSuccesses).Append("/").Append(loadAttempts); + return sb.ToString(); + } + + private void LogMissingTextureOnce(string requestedPath, string resolvedPath) + { + if (!logMissingTextures) + { + return; + } + + if (!missingTextureWarnings.Add(resolvedPath ?? requestedPath ?? "unknown")) + { + return; + } + + Debug.LogWarning( + $"SweTileLoader: texture not found (requested='{requestedPath}', resolved='{resolvedPath}', mode={sourceMode})."); + } + + private string DescribeIndexSource() + { + if (tileIndexCsv != null) + { + return $"TextAsset:{tileIndexCsv.name}"; + } + + if (sourceMode == SourceMode.StreamingAssets) + { + return $"StreamingAssets/{tileIndexPath}"; + } + + if (sourceMode == SourceMode.AbsolutePath) + { + return tileIndexPath; + } + + return $"Resources/{ToResourcesPath(tileIndexPath)}"; + } + + private static string MakeTileKeyLabel(string lod, int x, int y) + { + string normalized = string.IsNullOrWhiteSpace(lod) ? "lod?" : lod.Trim(); + return $"{normalized} ({x},{y})"; + } + + private static string DescribeTexture(Texture2D texture) + { + if (texture == null) + { + return "missing"; + } + + return $"{texture.width}x{texture.height},readable={texture.isReadable}"; + } + private readonly struct TileKey : IEquatable { public readonly string Lod; diff --git a/Assets/Scripts/Networking/Server/SweServerDiagnosticsHud.cs b/Assets/Scripts/Networking/Server/SweServerDiagnosticsHud.cs new file mode 100644 index 000000000..a2fd85e5c --- /dev/null +++ b/Assets/Scripts/Networking/Server/SweServerDiagnosticsHud.cs @@ -0,0 +1,140 @@ +using TMPro; +using UnityEngine; + +namespace FloodSWE.Networking +{ + /// + /// Binds SweServerRuntime diagnostics to user-provided TMP text fields. + /// + public sealed class SweServerDiagnosticsHud : MonoBehaviour + { + [Header("References")] + [SerializeField] private SweServerRuntime serverRuntime; + [SerializeField] private TMP_Text simulationText; + [SerializeField] private TMP_Text connectionText; + [SerializeField] private TMP_Text commandText; + [SerializeField] private TMP_Text forcingText; + [SerializeField] private TMP_Text tileLoadText; + + [Header("Behavior")] + [SerializeField] private bool autoFindRuntime = true; + [SerializeField] private float refreshIntervalSeconds = 0.15f; + + [Header("Labels")] + [SerializeField] private string connectedLabel = "Connected"; + [SerializeField] private string staleLabel = "Stale"; + [SerializeField] private string disconnectedLabel = "Disconnected"; + [SerializeField] private string unknownAgeLabel = "n/a"; + [SerializeField] private string numberFormat = "0.00"; + + [Header("Colors")] + [SerializeField] private Color connectedColor = new Color(0.4f, 1.0f, 0.4f); + [SerializeField] private Color staleColor = new Color(1.0f, 0.9f, 0.3f); + [SerializeField] private Color disconnectedColor = new Color(1.0f, 0.5f, 0.5f); + + private float nextRefreshTime; + + private void OnEnable() + { + if (serverRuntime == null && autoFindRuntime) + { + serverRuntime = FindFirstObjectByType(); + } + + Refresh(true); + } + + private void Update() + { + if (Time.unscaledTime < nextRefreshTime) + { + return; + } + + Refresh(false); + } + + private void Refresh(bool immediate) + { + nextRefreshTime = Time.unscaledTime + Mathf.Max(0.02f, refreshIntervalSeconds); + if (serverRuntime == null) + { + SetText(simulationText, "Sim Speed: n/a"); + SetText(connectionText, "Connection: runtime missing"); + SetText(commandText, "Last Command: n/a"); + SetText(forcingText, "Forcing: n/a"); + SetText(tileLoadText, "Tile Load: runtime missing"); + if (connectionText != null) + { + connectionText.color = disconnectedColor; + } + return; + } + + string simLine = $"Sim Speed: {serverRuntime.SimulatedSecondsPerSecond.ToString(numberFormat)} sim-s/s"; + if (!serverRuntime.HasSimulationThroughput) + { + simLine = "Sim Speed: warming up"; + } + SetText(simulationText, simLine); + + string state; + Color stateColor; + if (!serverRuntime.HasConnectedClients) + { + state = disconnectedLabel; + stateColor = disconnectedColor; + } + else if (serverRuntime.HasRecentClientSignal) + { + state = connectedLabel; + stateColor = connectedColor; + } + else + { + state = staleLabel; + stateColor = staleColor; + } + + string connectionLine = + $"Connection: {state} | clients={serverRuntime.ConnectedClientCount} | endpoint={serverRuntime.QuestEndpointLabel}"; + SetText(connectionText, connectionLine); + if (connectionText != null) + { + connectionText.color = stateColor; + } + + string age = float.IsInfinity(serverRuntime.LastCommandAgeSeconds) + ? unknownAgeLabel + : $"{serverRuntime.LastCommandAgeSeconds.ToString(numberFormat)}s"; + string commandLine = + $"Last Command: {serverRuntime.LastCommandName} from {serverRuntime.LastCommandSender} ({age}) | " + + $"ctrl={serverRuntime.DecodedControlPackets}/{serverRuntime.ReceivedControlPackets} invalid={serverRuntime.InvalidControlPackets} ack={serverRuntime.AckPacketsSent}"; + SetText(commandText, commandLine); + + string forcingLine = + $"Forcing: {serverRuntime.LastForcingStatus} | cells={serverRuntime.LastForcedCellCount} | tile={serverRuntime.ActiveTileLabel}"; + SetText(forcingText, forcingLine); + + string tileLine = + $"Tile Load: {serverRuntime.TileLoadSummary} | framePayload={serverRuntime.LastFramePayloadBytes}B " + + $"frames={serverRuntime.TransmittedFramePackets} dropped={serverRuntime.DroppedFramePackets}"; + SetText(tileLoadText, tileLine); + + if (immediate) + { + nextRefreshTime = Time.unscaledTime; + } + } + + private static void SetText(TMP_Text target, string value) + { + if (target == null || target.text == value) + { + return; + } + + target.text = value; + } + } +} diff --git a/Assets/Scripts/Networking/Server/SweServerDiagnosticsHud.cs.meta b/Assets/Scripts/Networking/Server/SweServerDiagnosticsHud.cs.meta new file mode 100644 index 000000000..ef3fe164a --- /dev/null +++ b/Assets/Scripts/Networking/Server/SweServerDiagnosticsHud.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d968f37c7f174572b92aa1b0f2de2ce7 diff --git a/Assets/Scripts/Networking/Server/SweServerRuntime.cs b/Assets/Scripts/Networking/Server/SweServerRuntime.cs index b7161cc4b..a82de5b4b 100644 --- a/Assets/Scripts/Networking/Server/SweServerRuntime.cs +++ b/Assets/Scripts/Networking/Server/SweServerRuntime.cs @@ -49,6 +49,11 @@ namespace FloodSWE.Networking public bool applyDefaultSourceOnStart = false; public int defaultSourceId = 1; public float defaultSourceLevel = 1.0f; + public bool verboseDiagnostics = true; + public float simThroughputAveragingSeconds = 1.0f; + [Range(0.0f, 1.0f)] + public float simThroughputSmoothing = 0.35f; + public float clientSignalTimeoutSeconds = 3.0f; private readonly Dictionary sourceLevels = new Dictionary(); private readonly Dictionary sinkLevels = new Dictionary(); @@ -64,9 +69,53 @@ namespace FloodSWE.Networking private readonly Dictionary frameSubscribers = new Dictionary(StringComparer.Ordinal); private int lastOversizeWarningFrameId = -1; private string lastMissingMaskWarningTileKey = ""; + private int receivedControlPackets; + private int decodedControlPackets; + private int invalidControlPackets; + private int ackPacketsSent; + private int droppedFramePackets; + private int transmittedFramePackets; + private long transmittedFrameBytes; + private int lastFramePayloadBytes; + private string lastCommandName = "none"; + private string lastCommandSender = "n/a"; + private float lastCommandTimeUnscaled = -1.0f; + private float lastClientChangeTimeUnscaled = -1.0f; + private float simulatedSecondsPerSecond; + private int lastPacketFrameId = -1; + private float lastPacketTimeUnscaled = -1.0f; + private float throughputWindowRealSeconds; + private float throughputWindowSimSeconds; + private int lastForcedCellCount; + private string lastForcingStatus = "idle"; + + public string ActiveTileLabel => $"{activeLod} ({activeTileX},{activeTileY})"; + public int ConnectedClientCount => frameSubscribers.Count; + public bool HasConnectedClients => frameSubscribers.Count > 0; + public string QuestEndpointLabel => questEndpoint != null ? questEndpoint.ToString() : "none"; + public float SimulatedSecondsPerSecond => simulatedSecondsPerSecond; + public bool HasSimulationThroughput => simulatedSecondsPerSecond > 0.0f; + public string LastCommandName => lastCommandName; + public string LastCommandSender => lastCommandSender; + public float LastCommandAgeSeconds => lastCommandTimeUnscaled >= 0.0f ? Time.unscaledTime - lastCommandTimeUnscaled : float.PositiveInfinity; + public bool HasRecentClientSignal => lastCommandTimeUnscaled >= 0.0f && LastCommandAgeSeconds <= Mathf.Max(0.1f, clientSignalTimeoutSeconds); + public int ReceivedControlPackets => receivedControlPackets; + public int DecodedControlPackets => decodedControlPackets; + public int InvalidControlPackets => invalidControlPackets; + public int AckPacketsSent => ackPacketsSent; + public int DroppedFramePackets => droppedFramePackets; + public int TransmittedFramePackets => transmittedFramePackets; + public long TransmittedFrameBytes => transmittedFrameBytes; + public int LastFramePayloadBytes => lastFramePayloadBytes; + public string LastForcingStatus => lastForcingStatus; + public int LastForcedCellCount => lastForcedCellCount; + public string TileLoadSummary => tileLoader != null ? tileLoader.LastLoadSummary : "SweTileLoader missing"; + public float LastClientChangeAgeSeconds => lastClientChangeTimeUnscaled >= 0.0f ? Time.unscaledTime - lastClientChangeTimeUnscaled : float.PositiveInfinity; + public string DiagnosticsSummary => BuildDiagnosticsSummary(); private void OnEnable() { + ResetDiagnostics(); if (simulator == null) { simulator = GetComponent(); @@ -112,10 +161,12 @@ namespace FloodSWE.Networking private void Update() { PollCommands(); + UpdateThroughputSmoothing(); } private void OnHeightmapPacket(HeightmapPacket packet) { + TrackSimulationProgress(packet); if (frameSubscribers.Count == 0) { return; @@ -132,6 +183,9 @@ namespace FloodSWE.Networking try { commandSocket.Send(payload, payload.Length, kv.Value); + transmittedFramePackets++; + transmittedFrameBytes += payload.Length; + lastFramePayloadBytes = payload.Length; } catch (Exception ex) { @@ -142,7 +196,10 @@ namespace FloodSWE.Networking for (int i = 0; i < stale.Count; i++) { - frameSubscribers.Remove(stale[i]); + if (frameSubscribers.Remove(stale[i])) + { + lastClientChangeTimeUnscaled = Time.unscaledTime; + } } } @@ -159,6 +216,7 @@ namespace FloodSWE.Networking if (!downsampleOversizedFrames) { WarnOversizedFrame(originalPacket.FrameId, originalPacket.Resolution, originalPacket.Resolution, payload.Length, maxBytes); + droppedFramePackets++; return false; } @@ -169,6 +227,7 @@ namespace FloodSWE.Networking lastOversizeWarningFrameId = originalPacket.FrameId; Debug.LogWarning($"SweServerRuntime: oversized frame {originalPacket.FrameId} ({payload.Length} bytes) and decode failed."); } + droppedFramePackets++; return false; } @@ -190,6 +249,7 @@ namespace FloodSWE.Networking } WarnOversizedFrame(originalPacket.FrameId, sourceRes, targetRes, payload.Length, maxBytes); + droppedFramePackets++; return false; } @@ -270,6 +330,10 @@ namespace FloodSWE.Networking commandEndpoint = null; questEndpoint = null; + if (frameSubscribers.Count > 0) + { + lastClientChangeTimeUnscaled = Time.unscaledTime; + } frameSubscribers.Clear(); } @@ -297,6 +361,7 @@ namespace FloodSWE.Networking { continue; } + receivedControlPackets++; if (acceptFirstCommandSenderAsQuest && questEndpoint == null) { @@ -307,9 +372,14 @@ namespace FloodSWE.Networking if (!SweUdpProtocol.TryDecodeControl(raw, out SweControlCommand command) || command == null) { + invalidControlPackets++; continue; } + decodedControlPackets++; + lastCommandName = string.IsNullOrWhiteSpace(command.command) ? "unknown" : command.command.Trim(); + lastCommandSender = sender.ToString(); + lastCommandTimeUnscaled = Time.unscaledTime; HandleCommand(command, sender); } } @@ -403,6 +473,10 @@ namespace FloodSWE.Networking bool loaded = tileLoader.ApplyToSimulator(targetLod, targetX, targetY, simulator); if (!loaded) { + if (verboseDiagnostics && tileLoader != null) + { + Debug.LogWarning($"SweServerRuntime: tile load diagnostics => {tileLoader.LastLoadSummary}"); + } return false; } @@ -412,6 +486,10 @@ namespace FloodSWE.Networking simulator.packetTileId = new TileId(SweBoundaryManifest.ParseLod(activeLod), activeTileX, activeTileY); RefreshActiveTileBoundaryIds(); RecomputeExternalDepthRate(); + if (verboseDiagnostics && tileLoader != null) + { + Debug.Log($"SweServerRuntime: active tile set => {tileLoader.LastLoadSummary}"); + } return true; } @@ -426,6 +504,7 @@ namespace FloodSWE.Networking try { commandSocket.Send(payload, payload.Length, target); + ackPacketsSent++; } catch (Exception ex) { @@ -458,6 +537,7 @@ namespace FloodSWE.Networking { Debug.LogWarning("SweServerRuntime: boundary manifest missing or invalid. Source/sink control disabled for this run."); boundaryManifest = null; + lastForcingStatus = "disabled:manifest_missing_or_invalid"; } } @@ -468,12 +548,14 @@ namespace FloodSWE.Networking if (boundaryManifest == null) { + lastForcingStatus = "disabled:no_manifest"; return; } if (!boundaryManifest.TryGetTile(activeLod, activeTileX, activeTileY, out SweBoundaryTile tile) || tile == null) { Debug.LogWarning($"SweServerRuntime: no boundary entry for tile {activeLod} ({activeTileX},{activeTileY})."); + lastForcingStatus = "disabled:no_tile_boundary_entry"; return; } @@ -552,6 +634,8 @@ namespace FloodSWE.Networking bool hasBoundaryRates = sourceRates.Count > 0 || sinkRates.Count > 0; if (!hasBoundaryRates) { + lastForcedCellCount = 0; + lastForcingStatus = "disabled:no_active_source_or_sink_levels"; simulator.ClearExternalDepthRateMap(); simulator.SetExternalDepthRate(0.0f); return; @@ -602,9 +686,14 @@ namespace FloodSWE.Networking if (anyNonZero && simulator.SetExternalDepthRateMap(perCell)) { simulator.SetExternalDepthRate(0.0f); + lastForcedCellCount = forcedCellCount; + lastForcingStatus = "localized"; Debug.Log($"SweServerRuntime: applied localized boundary forcing cells={forcedCellCount}"); return; } + + lastForcedCellCount = 0; + lastForcingStatus = "disabled:boundary_masks_zero_or_apply_failed"; } else { @@ -616,6 +705,9 @@ namespace FloodSWE.Networking $"SweServerRuntime: no readable source/sink ID masks for tile {activeLod} ({activeTileX},{activeTileY}); " + "disabling source/sink forcing."); } + + lastForcedCellCount = 0; + lastForcingStatus = "disabled:masks_unavailable"; } // Strict mode: no masks means no forcing. This prevents non-physical uniform flooding. @@ -767,6 +859,7 @@ namespace FloodSWE.Networking } frameSubscribers[MakeSubscriberKey(endpoint)] = endpoint; + lastClientChangeTimeUnscaled = Time.unscaledTime; } private void RemoveFrameSubscriber(IPAddress address, int port) @@ -777,7 +870,10 @@ namespace FloodSWE.Networking } string key = MakeSubscriberKey(address, port); - frameSubscribers.Remove(key); + if (frameSubscribers.Remove(key)) + { + lastClientChangeTimeUnscaled = Time.unscaledTime; + } if (questEndpoint != null && questEndpoint.Address.Equals(address) && questEndpoint.Port == port) @@ -796,6 +892,110 @@ namespace FloodSWE.Networking return $"{address}:{port}"; } + private void TrackSimulationProgress(HeightmapPacket packet) + { + if (simulator == null || packet == null) + { + return; + } + + float now = Time.unscaledTime; + if (lastPacketFrameId < 0 || lastPacketTimeUnscaled < 0.0f) + { + lastPacketFrameId = packet.FrameId; + lastPacketTimeUnscaled = now; + return; + } + + int frameDelta = packet.FrameId - lastPacketFrameId; + if (frameDelta <= 0) + { + lastPacketFrameId = packet.FrameId; + lastPacketTimeUnscaled = now; + return; + } + + float realDelta = Mathf.Max(0.0001f, now - lastPacketTimeUnscaled); + float simDelta = frameDelta * Mathf.Max(0.0001f, simulator.tickSeconds); + throughputWindowRealSeconds += realDelta; + throughputWindowSimSeconds += simDelta; + lastPacketFrameId = packet.FrameId; + lastPacketTimeUnscaled = now; + + float minWindow = Mathf.Max(0.1f, simThroughputAveragingSeconds); + if (throughputWindowRealSeconds < minWindow) + { + return; + } + + float raw = throughputWindowSimSeconds / Mathf.Max(0.0001f, throughputWindowRealSeconds); + float blend = Mathf.Clamp01(simThroughputSmoothing); + if (simulatedSecondsPerSecond <= 0.0f) + { + simulatedSecondsPerSecond = raw; + } + else + { + simulatedSecondsPerSecond = Mathf.Lerp(simulatedSecondsPerSecond, raw, blend); + } + + throughputWindowRealSeconds = 0.0f; + throughputWindowSimSeconds = 0.0f; + } + + private void UpdateThroughputSmoothing() + { + if (simulatedSecondsPerSecond <= 0.0f || lastPacketTimeUnscaled < 0.0f) + { + return; + } + + float idleAge = Time.unscaledTime - lastPacketTimeUnscaled; + if (idleAge <= Mathf.Max(0.1f, simThroughputAveragingSeconds) * 2.0f) + { + return; + } + + simulatedSecondsPerSecond = Mathf.MoveTowards(simulatedSecondsPerSecond, 0.0f, Time.unscaledDeltaTime * 2.0f); + if (simulatedSecondsPerSecond < 0.001f) + { + simulatedSecondsPerSecond = 0.0f; + } + } + + private string BuildDiagnosticsSummary() + { + return + $"tile={ActiveTileLabel}; clients={ConnectedClientCount}; recentSignal={HasRecentClientSignal}; " + + $"simSecPerSec={simulatedSecondsPerSecond:0.00}; forcing={lastForcingStatus}; forcedCells={lastForcedCellCount}; " + + $"cmd={lastCommandName}@{lastCommandSender}; ctl={decodedControlPackets}/{receivedControlPackets} ack={ackPacketsSent}; " + + $"frames={transmittedFramePackets} dropped={droppedFramePackets} bytes={transmittedFrameBytes}; " + + $"loader={TileLoadSummary}"; + } + + private void ResetDiagnostics() + { + receivedControlPackets = 0; + decodedControlPackets = 0; + invalidControlPackets = 0; + ackPacketsSent = 0; + droppedFramePackets = 0; + transmittedFramePackets = 0; + transmittedFrameBytes = 0; + lastFramePayloadBytes = 0; + lastCommandName = "none"; + lastCommandSender = "n/a"; + lastCommandTimeUnscaled = -1.0f; + lastClientChangeTimeUnscaled = -1.0f; + simulatedSecondsPerSecond = 0.0f; + lastPacketFrameId = -1; + lastPacketTimeUnscaled = -1.0f; + throughputWindowRealSeconds = 0.0f; + throughputWindowSimSeconds = 0.0f; + lastForcedCellCount = 0; + lastForcingStatus = "idle"; + } + [Serializable] private sealed class SweCheckpointState {