From 616209416202cb559925ff43c36365d4e893ba65 Mon Sep 17 00:00:00 2001 From: wuwenbo Date: Fri, 15 May 2026 16:26:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8E=8B=E6=B5=8B=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/skills/th1-network-sync/SKILL.md | 92 ++ .../th1-network-sync/agents/openai.yaml | 4 + .../references/network-contract.md | 67 + .../Editor/NetworkStressEditorWindow.cs | 1338 +++++++++++++++++ .../Editor/NetworkStressEditorWindow.cs.meta | 3 + .../TH1_Logic/Steam/GameNetReceiver.cs | 1 + .../TH1_Logic/Steam/SteamObjectSerializer.cs | 47 +- 7 files changed, 1551 insertions(+), 1 deletion(-) create mode 100644 .codex/skills/th1-network-sync/SKILL.md create mode 100644 .codex/skills/th1-network-sync/agents/openai.yaml create mode 100644 .codex/skills/th1-network-sync/references/network-contract.md create mode 100644 Unity/Assets/Scripts/TH1_Logic/Editor/NetworkStressEditorWindow.cs create mode 100644 Unity/Assets/Scripts/TH1_Logic/Editor/NetworkStressEditorWindow.cs.meta diff --git a/.codex/skills/th1-network-sync/SKILL.md b/.codex/skills/th1-network-sync/SKILL.md new file mode 100644 index 000000000..8728971a4 --- /dev/null +++ b/.codex/skills/th1-network-sync/SKILL.md @@ -0,0 +1,92 @@ +--- +name: th1-network-sync +description: TH1 project-specific network synchronization guide for Unity C# multiplayer, Steam P2P, GameNetSender/GameNetReceiver, SteamLobbyManager, SimpleP2P, MapData/NetData recovery, action sync, host start/resume, ForceUpdate, heartbeat, ordered delivery, large message chunking, send-failure handling, and lobby UI rollback. Use whenever Codex works on TH1 networking, multiplayer saves, deterministic sync, P2P queueing, MapData broadcasts, ActionConfirm/ActionExecute, reconnect, Timer-driven network callbacks, or any bug that may affect multiplayer reliability or ordering. +--- + +# TH1 Network Sync + +## Core Rule + +Treat multiplayer changes as state-machine changes, not only message sends. Before editing, trace the full path: + +`GameNetSender -> SteamLobbyManager -> SimpleP2P -> GameNetReceiver -> Main/ActionLogic/MapData`. + +Do not let one peer advance local game state unless the matching network send/receive contract succeeded. + +## First Files To Read + +- `Unity/Assets/Scripts/TH1_Logic/Steam/SimpleP2P.cs` +- `Unity/Assets/Scripts/TH1_Logic/Steam/SteamLobbyManager.cs` +- `Unity/Assets/Scripts/TH1_Logic/Steam/GameNetSender.cs` +- `Unity/Assets/Scripts/TH1_Logic/Steam/GameNetReceiver.cs` +- `Unity/Assets/Scripts/TH1_Data/NetData.cs` +- `Unity/Assets/Scripts/TH1_Data/MapData.cs` +- `Unity/Assets/Scripts/TH1_Logic/Core/Main.cs` +- `Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs` +- `Unity/Assets/Scripts/TH1_Instance/Timer.cs` + +For the current network contract summary, read `references/network-contract.md`. + +## Workflow + +1. Classify the message. + - Critical: `GameStart`, `ForceUpdate`, `ActionConfirm`, `ActionExecute`, full `MapData`, reconnect/restore messages. + - Health/status: heartbeat, map confirm, lobby state, chat. + - Critical messages must return `bool` or otherwise expose failure to the caller before local state advances. + +2. Preserve ordering. + - Keep game messages on `SimpleP2P` per-peer outgoing queues. + - Do not bypass ordered envelopes for normal game messages. + - Do not send direct raw Steam messages for gameplay sync unless you also preserve FIFO and failure semantics. + +3. Preserve all-or-nothing broadcast for critical messages. + - Broadcast through `SteamLobbyManager.BroadcastMessage`. + - Preflight all lobby targets before enqueueing any target. + - If any target cannot queue, return failure and avoid local progression. + - Never accept "some peers got the critical message" as a valid state. + +4. Gate local state on send success. + - Host `GameStart` must succeed before `SaveMapData`, `RefreshTurn`, room UI close, or final success return. + - Owner `ActionExecute` broadcast must succeed before owner local execute. + - Client `ActionConfirm` must succeed before client local execute; `TurnEnd` may send and then stop local execution. + - Heartbeat send timestamps should only update after the send was accepted. + +5. Validate `MapData` before sending or applying. + - Reject null maps, `DeserializedMissingCriticalData`, wrong `NetMode`, and missing core maps. + - Call `Net.RefreshPlayerNet(mapData)` and respect its `bool` result. + - Ensure every current lobby member maps to a valid `PlayerId`. + - Do not repair missing critical data silently during deserialization. + +6. Roll back failed start/resume. + - Snapshot `MapData`, `InputLogic`, `MapInteractionLogic`, and `MapGeneratorLogic` before host start/resume mutation. + - On failure or exception, restore the snapshot, dispose/reinitialize render state as appropriate, cancel pending start timers, and keep lobby UI open. + - UI code must only invoke room-close/start callbacks after the start method returns `true`. + +7. Keep receiver failure atomic. + - Deserialize and dispatch inside try/catch. + - If incoming `GameStart` or `ForceUpdate` validation fails, do not hide room UI and do not leave the game in `ForceUpdating`. + - Restore previous game state if `NetResumeMatch` fails. + +## Checks Before Finishing + +Run: + +```powershell +dotnet build Unity/Assembly-CSharp.csproj --no-restore +``` + +For network-heavy changes, inspect these risks explicitly: + +- Could a critical broadcast partially enqueue? +- Could a caller ignore a failed send and still mutate game state? +- Could `MapData` deserialize with missing core fields and still be used? +- Could a timer callback fire after the target UI/object state is gone? +- Could a retry loop or ordered gap wait forever? + +## What Not To Do + +- Do not add a new direct send path around `SimpleP2P` queues for gameplay sync. +- Do not silently swallow `SendMessageToPeer` or `BroadcastMessage` failure. +- Do not call `GameNetReceiver` from a partial large-message chunk. +- Do not close the multiplayer room UI before host start returns success. +- Do not call `GC.Collect()` in match entry paths as a networking fix. diff --git a/.codex/skills/th1-network-sync/agents/openai.yaml b/.codex/skills/th1-network-sync/agents/openai.yaml new file mode 100644 index 000000000..50800517c --- /dev/null +++ b/.codex/skills/th1-network-sync/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "TH1 Network Sync" + short_description: "TH1 multiplayer sync and Steam P2P reliability rules" + default_prompt: "Use the TH1 Network Sync skill for TH1 multiplayer, P2P, GameNet, save resume, reconnect, or deterministic sync work." diff --git a/.codex/skills/th1-network-sync/references/network-contract.md b/.codex/skills/th1-network-sync/references/network-contract.md new file mode 100644 index 000000000..35afc70fe --- /dev/null +++ b/.codex/skills/th1-network-sync/references/network-contract.md @@ -0,0 +1,67 @@ +# TH1 Network Contract + +This reference summarizes the multiplayer contract after the May 2026 pre-release network audit. + +## SimpleP2P + +- All normal game messages use per-peer outgoing queues. +- Per-peer FIFO is required. +- Ordered envelopes are used for game payloads; do not bypass them for gameplay sync. +- Outgoing sequence is committed only after enqueue succeeds. +- Large payloads are chunked after ordered wrapping. +- Incoming large chunks must validate magic, version, message id, chunk index, chunk count, total length, and payload length. +- Large incoming messages and outgoing queues have per-peer and global byte budgets. +- Ordered gaps and large-message receives must timeout and disconnect/clean state rather than wait forever. +- Steam message pointers must be released in `finally`. + +## SteamLobbyManager + +- `SendMessageToPeer` returns `bool`; precheck failures must call the same failure path used by P2P send failures. +- `BroadcastMessage` returns `bool`. +- Critical broadcast must gather current lobby members, skip self, preflight all targets through `SimpleP2P.CanQueueMessages`, and only then enqueue. +- Missing connection, non-lobby target, invalid data, or queue budget failure must surface through `OnLobbyErrorEvent` / send-failure logging. + +## GameNetSender + +- Sender methods that gate local state must return `bool`. +- `GameStart` must validate `MapData` and return broadcast success. +- `ActionConfirm` must return send success. +- `ActionExecute` must return broadcast success. +- `ForceUpdate` and full `MapData` sends must validate multiplayer map data before sending. +- Heartbeat send timestamps should only update after send acceptance. + +## GameNetReceiver + +- Wrap deserialization and dispatch in try/catch. +- Validate incoming `GameStart` and `ForceUpdate` maps before applying. +- `NetStartGame` and `NetResumeMatch` return `bool`; UI should only close/hide after success. +- `ForceUpdate` should restore previous game state if resume fails. +- `MapConfirm` must guard null maps, missing actions, and null action payloads. + +## MapData And NetData + +- `MapData.DeserializedMissingCriticalData` means the save/network map must not be used. +- `OnAfterMemoryPackDeserialize` may coalesce non-critical containers to avoid callback NREs but must not silently accept missing core data. +- `NetData.RefreshPlayerNet(MapData)` returns `bool`. +- In multiplayer mode, every current lobby member must have a valid, non-duplicate `PlayerId`. +- Host resume/start must fail before assigning global `MapData` if player network mapping is invalid. + +## Main And UI + +- Host start/resume snapshots `MapData`, `InputLogic`, `MapInteractionLogic`, and `MapGeneratorLogic`. +- Host `GameStart` failure must roll back and must not save, refresh turn, or report success. +- Custom map load must check `mapRecord == null` before `RegenerateMap`. +- `UIOutsideMultiplayView.ShowLoadingAndStartGame` must only invoke `OnStartGame` after the host start/resume method returns `true`. +- Timer callbacks for start announcements should be cancelled during abort paths. + +## ActionLogic + +- Client `ActionConfirm` failure aborts local action execution. +- Owner `ActionExecute` broadcast failure aborts owner local action execution. +- `TurnEnd` can send confirmation and stop local execution as designed. + +## Timer/Event Notes + +- Timer callbacks should guard destroyed Unity targets. +- Timer mutation during callback must not remove the wrong task. +- Event publish should isolate listener exceptions so one bad listener does not block others. diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/NetworkStressEditorWindow.cs b/Unity/Assets/Scripts/TH1_Logic/Editor/NetworkStressEditorWindow.cs new file mode 100644 index 000000000..0a37557b7 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/NetworkStressEditorWindow.cs @@ -0,0 +1,1338 @@ +/* +* @Author: Codex +* @Description: Steam P2P network stress test editor. +* @Date: 2026年05月15日 星期五 +*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Logic.CrashSight; +using MemoryPack; +using Steamworks; +using TH1_Logic.Net; +using TH1_Logic.Steam; +using UnityEditor; +using UnityEngine; + +namespace Logic.Editor +{ + public class NetworkStressEditorWindow : EditorWindow + { + private enum StressPreset + { + Correctness, + SmallPackets, + LargePayloads, + Congestion, + WeakNetwork, + Soak, + Custom, + } + + private enum StressDirection + { + HostBroadcast, + ClientsToHost, + FullDuplex, + HostRoundRobin, + } + + private enum RunState + { + Idle, + Sending, + Draining, + } + + [Serializable] + private class StressConfig + { + public StressPreset Preset = StressPreset.Correctness; + public StressDirection Direction = StressDirection.FullDuplex; + public string ScenarioName = "Correctness"; + public float DurationSeconds = 30f; + public float MessagesPerSecond = 20f; + public int SmallPayloadBytes = 1024; + public int LargePayloadBytes = 1024 * 1024; + public int LargeEveryMessages; + public bool RequireAck = true; + public float DropPercent; + public float UnreliablePercent; + public int JitterMinMs; + public int JitterMaxMs; + public int BurstPauseEveryMessages; + public int BurstPauseMs; + public int RandomSeed = 1214; + public float AckTimeoutSeconds = 20f; + public float DrainSeconds = 8f; + public int MaxScheduledMessages = 4096; + + public StressConfig Clone() + { + return (StressConfig)MemberwiseClone(); + } + } + + private class PeerStats + { + public ulong MemberId; + public string Name; + public long SentAttempted; + public long SentEnqueued; + public long SendFailed; + public long DroppedByImpairment; + public long DroppedByLocalBackpressure; + public long SentBytes; + public long ReceivedProbes; + public long ReceivedBytes; + public long PayloadErrors; + public long DuplicateMessages; + public long OutOfOrderMessages; + public long SequenceGaps; + public long AckSent; + public long AckSendFailed; + public long AckReceived; + public long AckTimeouts; + public long AckUnexpected; + public long SendFailureEvents; + public long ConnectionErrors; + public long LastReceivedSequence; + public readonly HashSet ReceivedSequences = new HashSet(); + public readonly List LatencyMs = new List(4096); + + public void AddLatency(float value) + { + LatencyMs.Add(value); + if (LatencyMs.Count > 20000) + LatencyMs.RemoveRange(0, 5000); + } + } + + private class SentProbe + { + public ulong TargetMemberId; + public long Sequence; + public long SentUtcTicks; + public int PayloadBytes; + public int SerializedBytes; + } + + private class ScheduledProbe + { + public double DueTime; + public NetworkStressMessage Message; + public bool Broadcast; + public List Targets; + } + + private Vector2 _scrollPosition; + private StressConfig _config = new StressConfig(); + private RunState _state = RunState.Idle; + private string _sessionId = string.Empty; + private string _statusLine = "Idle"; + private string _lastReport = string.Empty; + private string _eventLog = string.Empty; + private bool _autoAcceptRemoteStart = true; + private bool _passiveAck = true; + private bool _coordinatorMode; + private bool _suiteRunning; + private double _scenarioStartTime; + private double _lastUpdateTime; + private double _drainUntilTime; + private double _burstPauseUntilTime; + private double _nextSuiteStartTime; + private double _nextRepaintTime; + private double _sendAccumulator; + private long _nextSequence; + private int _roundRobinIndex; + private System.Random _random = new System.Random(1214); + private StressPreset _lastAppliedPreset = StressPreset.Correctness; + private readonly Queue _suiteQueue = new Queue(); + private readonly Dictionary _stats = new Dictionary(); + private readonly Dictionary<(ulong target, long sequence), SentProbe> _pendingAcks = + new Dictionary<(ulong target, long sequence), SentProbe>(); + private readonly List _scheduledProbes = new List(); + private readonly Dictionary _reports = new Dictionary(); + + [MenuItem("Tools/Steam 网络压测工具")] + private static void ShowWindow() + { + var window = GetWindow(); + window.titleContent = new GUIContent("Steam 网络压测"); + window.minSize = new Vector2(720, 640); + window.Show(); + } + + private void OnEnable() + { + Subscribe(); + EditorApplication.update += OnEditorUpdate; + if (string.IsNullOrEmpty(_sessionId)) NewSession(); + } + + private void OnDisable() + { + Unsubscribe(); + EditorApplication.update -= OnEditorUpdate; + } + + private void Subscribe() + { + SimpleP2P.Instance.OnMessageReceivedEvent -= OnP2PMessageReceived; + SimpleP2P.Instance.OnMessageReceivedEvent += OnP2PMessageReceived; + SimpleP2P.Instance.OnMessageSendFailedEvent -= OnP2PMessageSendFailed; + SimpleP2P.Instance.OnMessageSendFailedEvent += OnP2PMessageSendFailed; + SimpleP2P.Instance.OnConnectionErrorEvent -= OnP2PConnectionError; + SimpleP2P.Instance.OnConnectionErrorEvent += OnP2PConnectionError; + } + + private void Unsubscribe() + { + SimpleP2P.Instance.OnMessageReceivedEvent -= OnP2PMessageReceived; + SimpleP2P.Instance.OnMessageSendFailedEvent -= OnP2PMessageSendFailed; + SimpleP2P.Instance.OnConnectionErrorEvent -= OnP2PConnectionError; + } + + private void OnGUI() + { + Subscribe(); + var lobby = LobbyManager.Instance.Lobby; + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); + DrawHeader(lobby); + EditorGUILayout.Space(8); + DrawControls(lobby); + EditorGUILayout.Space(8); + DrawConfig(); + EditorGUILayout.Space(8); + DrawMetrics(lobby); + EditorGUILayout.Space(8); + DrawReports(); + EditorGUILayout.Space(8); + DrawUsage(); + EditorGUILayout.EndScrollView(); + } + + private void DrawHeader(ILobby lobby) + { + EditorGUILayout.LabelField("Steam P2P 压测", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "所有压测包都走 Lobby.BroadcastMessage / SendMessageToPeer,因此会覆盖 SimpleP2P 的队列、顺序封包、大包分片、预算、重试和发送失败上报。", + MessageType.Info); + + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUILayout.LabelField($"Play Mode: {(EditorApplication.isPlaying ? "Yes" : "No")}"); + EditorGUILayout.LabelField($"Session: {_sessionId}"); + EditorGUILayout.LabelField($"状态: {_state} / {_statusLine}"); + + if (lobby == null) + { + EditorGUILayout.LabelField("Lobby: null"); + return; + } + + EditorGUILayout.LabelField($"Lobby 初始化: {lobby.IsInitialized()}"); + EditorGUILayout.LabelField($"房间中: {lobby.IsInLobby()}"); + EditorGUILayout.LabelField($"是否房主: {lobby.IsLobbyOwner()}"); + EditorGUILayout.LabelField($"自己: {lobby.GetSelfMemberId()}"); + EditorGUILayout.LabelField($"房主: {lobby.GetLobbyOwnerId()}"); + EditorGUILayout.LabelField($"成员数: {lobby.GetMemberCount()}/{lobby.GetMemberLimit()}"); + EditorGUILayout.LabelField($"P2P 连接数: {SimpleP2P.Instance.GetConnectionCount()}"); + } + } + + private void DrawControls(ILobby lobby) + { + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUILayout.LabelField("控制", EditorStyles.boldLabel); + _autoAcceptRemoteStart = EditorGUILayout.ToggleLeft("自动接受房主发起的压测", _autoAcceptRemoteStart); + _passiveAck = EditorGUILayout.ToggleLeft("未主动启动时也回复 Probe ACK", _passiveAck); + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("新 Session", GUILayout.Width(110))) + NewSession(); + if (GUILayout.Button("启动本机场景", GUILayout.Width(120))) + BeginLocalScenario(_config.Clone(), false); + if (GUILayout.Button("房主启动所有机器", GUILayout.Width(140))) + StartCoordinatedScenario(_config.Clone(), true); + if (GUILayout.Button("房主一键完整套件", GUILayout.Width(150))) + StartFullSuite(); + if (GUILayout.Button("停止所有", GUILayout.Width(100))) + StopAll(true); + if (GUILayout.Button("导出报告", GUILayout.Width(100))) + ExportReport(); + } + + using (new EditorGUILayout.HorizontalScope()) + { + GUI.enabled = lobby != null && lobby.IsInitialized() && !lobby.IsInLobby(); + if (GUILayout.Button("创建公开房间", GUILayout.Width(120))) + lobby.CreateLobby(); + GUI.enabled = lobby != null && lobby.IsInitialized() && lobby.IsInLobby(); + if (GUILayout.Button("离开房间", GUILayout.Width(100))) + lobby.LeaveLobby(); + GUI.enabled = true; + } + } + } + + private void DrawConfig() + { + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUILayout.LabelField("场景配置", EditorStyles.boldLabel); + var preset = (StressPreset)EditorGUILayout.EnumPopup("预设", _config.Preset); + if (preset != _config.Preset || preset != _lastAppliedPreset) + { + _config.Preset = preset; + ApplyPreset(_config, preset); + _lastAppliedPreset = preset; + } + + _config.ScenarioName = EditorGUILayout.TextField("场景名", _config.ScenarioName); + _config.Direction = (StressDirection)EditorGUILayout.EnumPopup("方向", _config.Direction); + _config.DurationSeconds = EditorGUILayout.FloatField("发送时长(秒)", _config.DurationSeconds); + _config.MessagesPerSecond = EditorGUILayout.FloatField("每秒 Probe 数", _config.MessagesPerSecond); + _config.SmallPayloadBytes = EditorGUILayout.IntField("小包字节", _config.SmallPayloadBytes); + _config.LargePayloadBytes = EditorGUILayout.IntField("大包字节", _config.LargePayloadBytes); + _config.LargeEveryMessages = EditorGUILayout.IntField("每 N 个插入大包(0=关闭)", _config.LargeEveryMessages); + _config.RequireAck = EditorGUILayout.Toggle("ACK 校验", _config.RequireAck); + _config.DropPercent = EditorGUILayout.Slider("应用层丢包%", _config.DropPercent, 0f, 100f); + _config.UnreliablePercent = EditorGUILayout.Slider("Unreliable 发送%", _config.UnreliablePercent, 0f, 100f); + _config.JitterMinMs = EditorGUILayout.IntField("最小抖动 ms", _config.JitterMinMs); + _config.JitterMaxMs = EditorGUILayout.IntField("最大抖动 ms", _config.JitterMaxMs); + _config.BurstPauseEveryMessages = EditorGUILayout.IntField("每 N 个突发停顿(0=关闭)", _config.BurstPauseEveryMessages); + _config.BurstPauseMs = EditorGUILayout.IntField("突发停顿 ms", _config.BurstPauseMs); + _config.AckTimeoutSeconds = EditorGUILayout.FloatField("ACK 超时(秒)", _config.AckTimeoutSeconds); + _config.DrainSeconds = EditorGUILayout.FloatField("收尾等待(秒)", _config.DrainSeconds); + _config.RandomSeed = EditorGUILayout.IntField("随机种子", _config.RandomSeed); + _config.MaxScheduledMessages = EditorGUILayout.IntField("本地延迟队列上限", _config.MaxScheduledMessages); + SanitizeConfig(_config); + } + } + + private void DrawMetrics(ILobby lobby) + { + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUILayout.LabelField("实时指标", EditorStyles.boldLabel); + var now = EditorApplication.timeSinceStartup; + var elapsed = _state == RunState.Idle ? 0d : now - _scenarioStartTime; + EditorGUILayout.LabelField($"场景: {_config.ScenarioName}"); + EditorGUILayout.LabelField($"已运行: {elapsed:0.0}s / 计划发送: {_config.DurationSeconds:0.0}s / Pending ACK: {_pendingAcks.Count} / 延迟队列: {_scheduledProbes.Count}"); + + DrawStatsLine(GetSelfStats(), "本机"); + + if (lobby != null && lobby.IsInLobby()) + { + foreach (var memberId in lobby.GetAllMemberIds()) + { + if (memberId == lobby.GetSelfMemberId()) continue; + DrawStatsLine(GetStats(memberId), $"{GetMemberName(memberId)} ({memberId})"); + } + } + + if (!string.IsNullOrEmpty(_eventLog)) + { + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("最近事件", EditorStyles.boldLabel); + EditorGUILayout.TextArea(_eventLog, GUILayout.MinHeight(72)); + } + } + } + + private void DrawStatsLine(PeerStats stats, string title) + { + if (stats == null) return; + var avg = GetAverage(stats.LatencyMs); + var p95 = GetPercentile(stats.LatencyMs, 95f); + var max = stats.LatencyMs.Count == 0 ? 0f : stats.LatencyMs.Max(); + EditorGUILayout.LabelField( + $"{title}: sent {stats.SentEnqueued}/{stats.SentAttempted}, fail {stats.SendFailed}, drop {stats.DroppedByImpairment}, rx {stats.ReceivedProbes}, bytes tx/rx {FormatBytes(stats.SentBytes)}/{FormatBytes(stats.ReceivedBytes)}, ack {stats.AckReceived}, timeout {stats.AckTimeouts}, dup {stats.DuplicateMessages}, gap {stats.SequenceGaps}, payloadErr {stats.PayloadErrors}, latency avg/p95/max {avg:0.0}/{p95:0.0}/{max:0.0} ms"); + } + + private void DrawReports() + { + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUILayout.LabelField("报告", EditorStyles.boldLabel); + EditorGUILayout.LabelField($"已收集报告: {_reports.Count}"); + if (!string.IsNullOrEmpty(_lastReport)) + EditorGUILayout.TextArea(_lastReport, GUILayout.MinHeight(120)); + } + } + + private void DrawUsage() + { + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUILayout.LabelField("使用步骤", EditorStyles.boldLabel); + EditorGUILayout.LabelField("1. 每台电脑打开 Unity Play Mode,进入同一个 Steam 房间,并打开 Tools/Steam 网络压测工具。"); + EditorGUILayout.LabelField("2. 房主选择预设后点“房主启动所有机器”;其他机器保持“自动接受房主发起的压测”开启。"); + EditorGUILayout.LabelField("3. SmallPackets 看大量小包顺序和 ACK;LargePayloads 看分片;Congestion 看队列预算;WeakNetwork 会注入丢包/抖动/突发停顿。"); + EditorGUILayout.LabelField("4. 完成后点“导出报告”,报告会写到 Unity/NetworkStressReports。"); + } + } + + private void OnEditorUpdate() + { + var now = EditorApplication.timeSinceStartup; + var delta = Math.Max(0d, now - _lastUpdateTime); + _lastUpdateTime = now; + + if (_state == RunState.Sending) + { + GenerateProbes(now, delta); + DrainScheduledProbes(now); + CheckAckTimeouts(); + + if (now - _scenarioStartTime >= _config.DurationSeconds) + { + _state = RunState.Draining; + _drainUntilTime = now + _config.DrainSeconds; + _statusLine = "Draining pending ACKs and queued delayed probes"; + } + } + else if (_state == RunState.Draining) + { + DrainScheduledProbes(now); + CheckAckTimeouts(); + if (now >= _drainUntilTime && _scheduledProbes.Count == 0) + FinishScenario(); + } + + if (_suiteRunning && _state == RunState.Idle && _suiteQueue.Count > 0 && now >= _nextSuiteStartTime) + StartCoordinatedScenario(_suiteQueue.Dequeue(), false); + + if (now >= _nextRepaintTime) + { + _nextRepaintTime = now + 0.25d; + Repaint(); + } + } + + private void GenerateProbes(double now, double delta) + { + if (_config.MessagesPerSecond <= 0f) return; + if (now < _burstPauseUntilTime) return; + + _sendAccumulator += delta * _config.MessagesPerSecond; + var generatedThisUpdate = 0; + while (_sendAccumulator >= 1d && generatedThisUpdate < 512) + { + _sendAccumulator -= 1d; + generatedThisUpdate++; + QueueProbe(now); + } + } + + private void QueueProbe(double now) + { + var targets = BuildTargets(_config, out var broadcast); + if (targets.Count == 0) return; + + var selfStats = GetSelfStats(); + var sequence = ++_nextSequence; + if (_random.NextDouble() * 100d < _config.DropPercent) + { + selfStats.DroppedByImpairment += targets.Count; + return; + } + + if (_scheduledProbes.Count >= _config.MaxScheduledMessages) + { + selfStats.DroppedByLocalBackpressure += targets.Count; + return; + } + + var payloadBytes = GetPayloadBytes(sequence); + var payload = BuildPayload(payloadBytes, sequence, _config.RandomSeed); + var reliable = _random.NextDouble() * 100d >= _config.UnreliablePercent; + var message = new NetworkStressMessage + { + StressKind = NetworkStressMessageKind.Probe, + SessionId = _sessionId, + ScenarioName = _config.ScenarioName, + SenderMemberId = GetSelfMemberId(), + TargetMemberId = broadcast ? 0 : targets[0], + Sequence = sequence, + PayloadBytes = payloadBytes, + PayloadHash = ComputeHash(payload), + Reliable = reliable, + RequiresAck = _config.RequireAck, + DurationSeconds = _config.DurationSeconds, + MessagesPerSecond = _config.MessagesPerSecond, + SmallPayloadBytes = _config.SmallPayloadBytes, + LargePayloadBytes = _config.LargePayloadBytes, + LargeEveryMessages = _config.LargeEveryMessages, + DropPercent = _config.DropPercent, + UnreliablePercent = _config.UnreliablePercent, + JitterMinMs = _config.JitterMinMs, + JitterMaxMs = _config.JitterMaxMs, + BurstPauseEveryMessages = _config.BurstPauseEveryMessages, + BurstPauseMs = _config.BurstPauseMs, + RandomSeed = _config.RandomSeed, + Payload = payload, + }; + + var jitterMs = GetJitterMs(); + _scheduledProbes.Add(new ScheduledProbe + { + DueTime = now + jitterMs / 1000d, + Message = message, + Broadcast = broadcast, + Targets = targets, + }); + + if (_config.BurstPauseEveryMessages > 0 + && _config.BurstPauseMs > 0 + && sequence % _config.BurstPauseEveryMessages == 0) + { + _burstPauseUntilTime = now + _config.BurstPauseMs / 1000d; + } + } + + private void DrainScheduledProbes(double now) + { + for (var i = _scheduledProbes.Count - 1; i >= 0; i--) + { + var scheduled = _scheduledProbes[i]; + if (scheduled.DueTime > now) continue; + _scheduledProbes.RemoveAt(i); + SendProbe(scheduled); + } + } + + private void SendProbe(ScheduledProbe scheduled) + { + var message = scheduled.Message; + message.SentUtcTicks = DateTime.UtcNow.Ticks; + var success = TrySendMessage(message, scheduled.Broadcast, scheduled.Broadcast ? 0 : scheduled.Targets[0], + message.Reliable, out var serializedBytes); + var targetCount = scheduled.Targets.Count; + var selfStats = GetSelfStats(); + selfStats.SentAttempted += targetCount; + + if (!success) + { + selfStats.SendFailed += targetCount; + return; + } + + selfStats.SentEnqueued += targetCount; + selfStats.SentBytes += (long)serializedBytes * targetCount; + if (!message.RequiresAck) return; + + foreach (var target in scheduled.Targets) + { + _pendingAcks[(target, message.Sequence)] = new SentProbe + { + TargetMemberId = target, + Sequence = message.Sequence, + SentUtcTicks = message.SentUtcTicks, + PayloadBytes = message.PayloadBytes, + SerializedBytes = serializedBytes, + }; + } + } + + private List BuildTargets(StressConfig config, out bool broadcast) + { + broadcast = false; + var lobby = LobbyManager.Instance.Lobby; + var result = new List(); + if (lobby == null || !lobby.IsInLobby()) return result; + + var self = lobby.GetSelfMemberId(); + var host = lobby.GetLobbyOwnerId(); + var peers = lobby.GetAllMemberIds().Where(id => id != self).ToList(); + + switch (config.Direction) + { + case StressDirection.HostBroadcast: + if (!lobby.IsLobbyOwner()) return result; + broadcast = true; + result.AddRange(peers); + return result; + + case StressDirection.ClientsToHost: + if (lobby.IsLobbyOwner() || host == 0 || host == self) return result; + result.Add(host); + return result; + + case StressDirection.FullDuplex: + if (lobby.IsLobbyOwner()) + { + broadcast = true; + result.AddRange(peers); + } + else if (host != 0 && host != self) + { + result.Add(host); + } + return result; + + case StressDirection.HostRoundRobin: + if (!lobby.IsLobbyOwner() || peers.Count == 0) return result; + if (_roundRobinIndex >= peers.Count) _roundRobinIndex = 0; + result.Add(peers[_roundRobinIndex]); + _roundRobinIndex = (_roundRobinIndex + 1) % peers.Count; + return result; + } + + return result; + } + + private void OnP2PMessageReceived(CSteamID steamId, byte[] bytes) + { + NetworkStressMessage message; + try + { + if (bytes == null || bytes.Length == 0) return; + message = MemoryPackSerializer.Deserialize(bytes) as NetworkStressMessage; + } + catch + { + return; + } + + if (message == null) return; + + switch (message.StressKind) + { + case NetworkStressMessageKind.ControlStart: + HandleControlStart(message); + break; + case NetworkStressMessageKind.ControlStop: + HandleControlStop(message); + break; + case NetworkStressMessageKind.Probe: + HandleProbe(message); + break; + case NetworkStressMessageKind.Ack: + HandleAck(message); + break; + case NetworkStressMessageKind.Report: + HandleReport(message); + break; + } + } + + private void HandleControlStart(NetworkStressMessage message) + { + if (!_autoAcceptRemoteStart) return; + if (message.SenderMemberId == GetSelfMemberId()) return; + var config = ConfigFromMessage(message); + _sessionId = message.SessionId; + BeginLocalScenario(config, false); + AppendEvent($"收到远端启动: {message.ScenarioName} from {message.SenderMemberId}"); + } + + private void HandleControlStop(NetworkStressMessage message) + { + if (!IsCurrentSession(message.SessionId)) return; + StopLocal(false); + AppendEvent($"收到远端停止: {message.SenderMemberId}"); + } + + private void HandleProbe(NetworkStressMessage message) + { + if (!ShouldAcceptSession(message.SessionId)) return; + var stats = GetStats(message.SenderMemberId); + stats.ReceivedProbes++; + stats.ReceivedBytes += message.Payload?.Length ?? 0; + + if (message.Payload == null + || message.Payload.Length != message.PayloadBytes + || ComputeHash(message.Payload) != message.PayloadHash) + { + stats.PayloadErrors++; + } + + if (!stats.ReceivedSequences.Add(message.Sequence)) + { + stats.DuplicateMessages++; + } + else + { + if (stats.LastReceivedSequence > 0 && message.Sequence < stats.LastReceivedSequence) + stats.OutOfOrderMessages++; + if (stats.LastReceivedSequence > 0 && message.Sequence > stats.LastReceivedSequence + 1) + stats.SequenceGaps += message.Sequence - stats.LastReceivedSequence - 1; + if (message.Sequence > stats.LastReceivedSequence) + stats.LastReceivedSequence = message.Sequence; + } + + if (stats.ReceivedSequences.Count > 200000) + stats.ReceivedSequences.Clear(); + + if (message.RequiresAck && _passiveAck) + SendAck(message); + } + + private void SendAck(NetworkStressMessage probe) + { + var self = GetSelfMemberId(); + if (self == 0 || probe.SenderMemberId == 0 || probe.SenderMemberId == self) return; + + var ack = new NetworkStressMessage + { + StressKind = NetworkStressMessageKind.Ack, + SessionId = probe.SessionId, + ScenarioName = probe.ScenarioName, + SenderMemberId = self, + TargetMemberId = probe.SenderMemberId, + AckSequence = probe.Sequence, + SentUtcTicks = probe.SentUtcTicks, + PayloadBytes = probe.PayloadBytes, + PayloadHash = probe.PayloadHash, + Reliable = true, + RequiresAck = false, + }; + + if (TrySendMessage(ack, false, probe.SenderMemberId, true, out _)) + GetStats(probe.SenderMemberId).AckSent++; + else + GetStats(probe.SenderMemberId).AckSendFailed++; + } + + private void HandleAck(NetworkStressMessage message) + { + if (!IsCurrentSession(message.SessionId)) return; + var key = (message.SenderMemberId, message.AckSequence); + if (!_pendingAcks.TryGetValue(key, out var sentProbe)) + { + GetStats(message.SenderMemberId).AckUnexpected++; + return; + } + + _pendingAcks.Remove(key); + var latencyMs = (float)((DateTime.UtcNow.Ticks - sentProbe.SentUtcTicks) / (double)TimeSpan.TicksPerMillisecond); + var peerStats = GetStats(message.SenderMemberId); + peerStats.AckReceived++; + peerStats.AddLatency(Math.Max(0f, latencyMs)); + GetSelfStats().AckReceived++; + GetSelfStats().AddLatency(Math.Max(0f, latencyMs)); + } + + private void HandleReport(NetworkStressMessage message) + { + if (!IsCurrentSession(message.SessionId)) return; + var key = $"{message.ScenarioName}/{message.SenderMemberId}"; + _reports[key] = message.Text ?? string.Empty; + AppendEvent($"收到报告: {key}"); + } + + private void OnP2PMessageSendFailed(CSteamID target, string reason) + { + GetStats(target.m_SteamID).SendFailureEvents++; + AppendEvent($"SendFailed {target}: {reason}"); + } + + private void OnP2PConnectionError(string reason) + { + GetSelfStats().ConnectionErrors++; + AppendEvent($"ConnectionError: {reason}"); + } + + private void StartCoordinatedScenario(StressConfig config, bool createNewSession) + { + var lobby = LobbyManager.Instance.Lobby; + if (lobby == null || !lobby.IsInLobby() || !lobby.IsLobbyOwner()) + { + AppendEvent("只有房主在房间中才能启动所有机器"); + return; + } + + if (createNewSession) NewSession(); + SanitizeConfig(config); + BroadcastControlStart(config); + BeginLocalScenario(config, true); + } + + private void StartFullSuite() + { + var lobby = LobbyManager.Instance.Lobby; + if (lobby == null || !lobby.IsInLobby() || !lobby.IsLobbyOwner()) + { + AppendEvent("完整套件需要房主启动"); + return; + } + + NewSession(); + _reports.Clear(); + _suiteQueue.Clear(); + _suiteQueue.Enqueue(CreatePreset(StressPreset.Correctness)); + _suiteQueue.Enqueue(CreatePreset(StressPreset.SmallPackets)); + _suiteQueue.Enqueue(CreatePreset(StressPreset.LargePayloads)); + _suiteQueue.Enqueue(CreatePreset(StressPreset.Congestion)); + _suiteQueue.Enqueue(CreatePreset(StressPreset.WeakNetwork)); + _suiteQueue.Enqueue(CreatePreset(StressPreset.Soak)); + _suiteRunning = true; + _coordinatorMode = true; + StartCoordinatedScenario(_suiteQueue.Dequeue(), false); + } + + private void BeginLocalScenario(StressConfig config, bool coordinator) + { + SanitizeConfig(config); + _config = config.Clone(); + _lastAppliedPreset = _config.Preset; + _coordinatorMode = coordinator; + _state = RunState.Sending; + _scenarioStartTime = EditorApplication.timeSinceStartup; + _lastUpdateTime = _scenarioStartTime; + _drainUntilTime = 0d; + _burstPauseUntilTime = 0d; + _sendAccumulator = 0d; + _nextSequence = 0; + _roundRobinIndex = 0; + _scheduledProbes.Clear(); + _pendingAcks.Clear(); + _stats.Clear(); + _lastReport = string.Empty; + _random = new System.Random(_config.RandomSeed ^ _sessionId.GetHashCode()); + _statusLine = $"Running {_config.ScenarioName}"; + AppendEvent($"启动场景: {_config.ScenarioName}"); + } + + private void StopAll(bool broadcast) + { + if (broadcast) + BroadcastControlStop(); + StopLocal(true); + } + + private void StopLocal(bool appendEvent) + { + if (appendEvent) AppendEvent("手动停止"); + if (_state != RunState.Idle) + FinishScenario(); + _state = RunState.Idle; + _suiteRunning = false; + _suiteQueue.Clear(); + _scheduledProbes.Clear(); + _pendingAcks.Clear(); + _statusLine = "Stopped"; + } + + private void FinishScenario() + { + foreach (var pending in _pendingAcks.Values.ToList()) + GetStats(pending.TargetMemberId).AckTimeouts++; + GetSelfStats().AckTimeouts += _pendingAcks.Count; + _pendingAcks.Clear(); + + _state = RunState.Idle; + _statusLine = $"Finished {_config.ScenarioName}"; + _lastReport = BuildReport(); + _reports[$"{_config.ScenarioName}/{GetSelfMemberId()}"] = _lastReport; + SendReportToHost(); + AppendEvent($"完成场景: {_config.ScenarioName}"); + + if (_suiteRunning && _coordinatorMode && _suiteQueue.Count > 0) + _nextSuiteStartTime = EditorApplication.timeSinceStartup + 3d; + else if (_suiteRunning && _coordinatorMode) + _suiteRunning = false; + } + + private void CheckAckTimeouts() + { + if (_pendingAcks.Count == 0) return; + var nowTicks = DateTime.UtcNow.Ticks; + var timeoutTicks = (long)(_config.AckTimeoutSeconds * TimeSpan.TicksPerSecond); + var timedOut = new List<(ulong target, long sequence)>(); + foreach (var kv in _pendingAcks) + { + if (nowTicks - kv.Value.SentUtcTicks >= timeoutTicks) + timedOut.Add(kv.Key); + } + + foreach (var key in timedOut) + { + if (!_pendingAcks.TryGetValue(key, out var sent)) continue; + _pendingAcks.Remove(key); + GetStats(sent.TargetMemberId).AckTimeouts++; + GetSelfStats().AckTimeouts++; + } + } + + private void BroadcastControlStart(StressConfig config) + { + var message = MessageFromConfig(config, NetworkStressMessageKind.ControlStart); + if (!TrySendMessage(message, true, 0, true, out _)) + AppendEvent("ControlStart 广播失败"); + } + + private void BroadcastControlStop() + { + var message = new NetworkStressMessage + { + StressKind = NetworkStressMessageKind.ControlStop, + SessionId = _sessionId, + ScenarioName = _config.ScenarioName, + SenderMemberId = GetSelfMemberId(), + }; + TrySendMessage(message, true, 0, true, out _); + } + + private void SendReportToHost() + { + var lobby = LobbyManager.Instance.Lobby; + if (lobby == null || !lobby.IsInLobby() || lobby.IsLobbyOwner()) return; + var host = lobby.GetLobbyOwnerId(); + if (host == 0) return; + var report = new NetworkStressMessage + { + StressKind = NetworkStressMessageKind.Report, + SessionId = _sessionId, + ScenarioName = _config.ScenarioName, + SenderMemberId = GetSelfMemberId(), + TargetMemberId = host, + Text = _lastReport, + }; + TrySendMessage(report, false, host, true, out _); + } + + private bool TrySendMessage(NetworkStressMessage message, bool broadcast, ulong target, bool reliable, + out int serializedBytes) + { + serializedBytes = 0; + var lobby = LobbyManager.Instance.Lobby; + if (lobby == null || !lobby.IsInLobby()) return false; + + byte[] bytes; + try + { + bytes = MemoryPackSerializer.Serialize(message); + } + catch (Exception e) + { + AppendEvent($"Serialize failed: {e.Message}"); + return false; + } + + serializedBytes = bytes.Length; + try + { + return broadcast + ? lobby.BroadcastMessage(bytes, reliable) + : lobby.SendMessageToPeer(target, bytes, reliable); + } + catch (Exception e) + { + AppendEvent($"Send failed: {e.Message}"); + return false; + } + } + + private NetworkStressMessage MessageFromConfig(StressConfig config, NetworkStressMessageKind kind) + { + return new NetworkStressMessage + { + StressKind = kind, + SessionId = _sessionId, + ScenarioName = config.ScenarioName, + SenderMemberId = GetSelfMemberId(), + DurationSeconds = config.DurationSeconds, + MessagesPerSecond = config.MessagesPerSecond, + SmallPayloadBytes = config.SmallPayloadBytes, + LargePayloadBytes = config.LargePayloadBytes, + LargeEveryMessages = config.LargeEveryMessages, + RequiresAck = config.RequireAck, + DropPercent = config.DropPercent, + UnreliablePercent = config.UnreliablePercent, + JitterMinMs = config.JitterMinMs, + JitterMaxMs = config.JitterMaxMs, + BurstPauseEveryMessages = config.BurstPauseEveryMessages, + BurstPauseMs = config.BurstPauseMs, + RandomSeed = config.RandomSeed, + Text = $"{(int)config.Preset}|{(int)config.Direction}|{config.AckTimeoutSeconds}|{config.DrainSeconds}|{config.MaxScheduledMessages}", + }; + } + + private StressConfig ConfigFromMessage(NetworkStressMessage message) + { + var config = new StressConfig + { + ScenarioName = message.ScenarioName, + DurationSeconds = message.DurationSeconds, + MessagesPerSecond = message.MessagesPerSecond, + SmallPayloadBytes = message.SmallPayloadBytes, + LargePayloadBytes = message.LargePayloadBytes, + LargeEveryMessages = message.LargeEveryMessages, + RequireAck = message.RequiresAck, + DropPercent = message.DropPercent, + UnreliablePercent = message.UnreliablePercent, + JitterMinMs = message.JitterMinMs, + JitterMaxMs = message.JitterMaxMs, + BurstPauseEveryMessages = message.BurstPauseEveryMessages, + BurstPauseMs = message.BurstPauseMs, + RandomSeed = message.RandomSeed, + }; + + if (!string.IsNullOrEmpty(message.Text)) + { + var parts = message.Text.Split('|'); + if (parts.Length >= 5) + { + if (int.TryParse(parts[0], out var preset)) config.Preset = (StressPreset)preset; + if (int.TryParse(parts[1], out var direction)) config.Direction = (StressDirection)direction; + if (float.TryParse(parts[2], out var ackTimeout)) config.AckTimeoutSeconds = ackTimeout; + if (float.TryParse(parts[3], out var drain)) config.DrainSeconds = drain; + if (int.TryParse(parts[4], out var maxScheduled)) config.MaxScheduledMessages = maxScheduled; + } + } + + SanitizeConfig(config); + return config; + } + + private void ApplyPreset(StressConfig config, StressPreset preset) + { + var created = CreatePreset(preset); + config.Preset = preset; + config.Direction = created.Direction; + config.ScenarioName = created.ScenarioName; + config.DurationSeconds = created.DurationSeconds; + config.MessagesPerSecond = created.MessagesPerSecond; + config.SmallPayloadBytes = created.SmallPayloadBytes; + config.LargePayloadBytes = created.LargePayloadBytes; + config.LargeEveryMessages = created.LargeEveryMessages; + config.RequireAck = created.RequireAck; + config.DropPercent = created.DropPercent; + config.UnreliablePercent = created.UnreliablePercent; + config.JitterMinMs = created.JitterMinMs; + config.JitterMaxMs = created.JitterMaxMs; + config.BurstPauseEveryMessages = created.BurstPauseEveryMessages; + config.BurstPauseMs = created.BurstPauseMs; + config.AckTimeoutSeconds = created.AckTimeoutSeconds; + config.DrainSeconds = created.DrainSeconds; + config.MaxScheduledMessages = created.MaxScheduledMessages; + } + + private StressConfig CreatePreset(StressPreset preset) + { + var config = new StressConfig { Preset = preset }; + switch (preset) + { + case StressPreset.Correctness: + config.ScenarioName = "01-Correctness"; + config.Direction = StressDirection.FullDuplex; + config.DurationSeconds = 30f; + config.MessagesPerSecond = 20f; + config.SmallPayloadBytes = 1024; + config.LargePayloadBytes = 256 * 1024; + config.LargeEveryMessages = 100; + break; + case StressPreset.SmallPackets: + config.ScenarioName = "02-SmallPackets"; + config.Direction = StressDirection.FullDuplex; + config.DurationSeconds = 60f; + config.MessagesPerSecond = 200f; + config.SmallPayloadBytes = 128; + config.LargeEveryMessages = 0; + config.AckTimeoutSeconds = 25f; + break; + case StressPreset.LargePayloads: + config.ScenarioName = "03-LargePayloads"; + config.Direction = StressDirection.HostBroadcast; + config.DurationSeconds = 45f; + config.MessagesPerSecond = 0.3f; + config.SmallPayloadBytes = 1024; + config.LargePayloadBytes = 8 * 1024 * 1024; + config.LargeEveryMessages = 1; + config.AckTimeoutSeconds = 60f; + config.DrainSeconds = 25f; + break; + case StressPreset.Congestion: + config.ScenarioName = "04-Congestion"; + config.Direction = StressDirection.FullDuplex; + config.DurationSeconds = 90f; + config.MessagesPerSecond = 80f; + config.SmallPayloadBytes = 2048; + config.LargePayloadBytes = 2 * 1024 * 1024; + config.LargeEveryMessages = 20; + config.AckTimeoutSeconds = 60f; + config.DrainSeconds = 25f; + break; + case StressPreset.WeakNetwork: + config.ScenarioName = "05-WeakNetwork"; + config.Direction = StressDirection.FullDuplex; + config.DurationSeconds = 75f; + config.MessagesPerSecond = 40f; + config.SmallPayloadBytes = 512; + config.LargePayloadBytes = 512 * 1024; + config.LargeEveryMessages = 50; + config.DropPercent = 5f; + config.UnreliablePercent = 20f; + config.JitterMinMs = 40; + config.JitterMaxMs = 400; + config.BurstPauseEveryMessages = 120; + config.BurstPauseMs = 1500; + config.AckTimeoutSeconds = 45f; + config.DrainSeconds = 20f; + break; + case StressPreset.Soak: + config.ScenarioName = "06-Soak"; + config.Direction = StressDirection.FullDuplex; + config.DurationSeconds = 10 * 60f; + config.MessagesPerSecond = 20f; + config.SmallPayloadBytes = 1024; + config.LargePayloadBytes = 1024 * 1024; + config.LargeEveryMessages = 120; + config.AckTimeoutSeconds = 60f; + config.DrainSeconds = 30f; + break; + case StressPreset.Custom: + config.ScenarioName = "Custom"; + break; + } + + return config; + } + + private void SanitizeConfig(StressConfig config) + { + config.DurationSeconds = Mathf.Max(1f, config.DurationSeconds); + config.MessagesPerSecond = Mathf.Clamp(config.MessagesPerSecond, 0.01f, 2000f); + config.SmallPayloadBytes = Mathf.Clamp(config.SmallPayloadBytes, 1, 60 * 1024 * 1024); + config.LargePayloadBytes = Mathf.Clamp(config.LargePayloadBytes, 1, 60 * 1024 * 1024); + config.LargeEveryMessages = Mathf.Max(0, config.LargeEveryMessages); + config.DropPercent = Mathf.Clamp(config.DropPercent, 0f, 100f); + config.UnreliablePercent = Mathf.Clamp(config.UnreliablePercent, 0f, 100f); + config.JitterMinMs = Mathf.Max(0, config.JitterMinMs); + config.JitterMaxMs = Mathf.Max(config.JitterMinMs, config.JitterMaxMs); + config.BurstPauseEveryMessages = Mathf.Max(0, config.BurstPauseEveryMessages); + config.BurstPauseMs = Mathf.Max(0, config.BurstPauseMs); + config.AckTimeoutSeconds = Mathf.Max(1f, config.AckTimeoutSeconds); + config.DrainSeconds = Mathf.Max(1f, config.DrainSeconds); + config.MaxScheduledMessages = Mathf.Clamp(config.MaxScheduledMessages, 128, 100000); + } + + private int GetPayloadBytes(long sequence) + { + if (_config.LargeEveryMessages > 0 && sequence % _config.LargeEveryMessages == 0) + return _config.LargePayloadBytes; + return _config.SmallPayloadBytes; + } + + private int GetJitterMs() + { + if (_config.JitterMaxMs <= 0) return 0; + if (_config.JitterMaxMs <= _config.JitterMinMs) return _config.JitterMinMs; + return _random.Next(_config.JitterMinMs, _config.JitterMaxMs + 1); + } + + private static byte[] BuildPayload(int length, long sequence, int seed) + { + var payload = new byte[length]; + uint state = unchecked((uint)seed ^ (uint)sequence ^ (uint)(sequence >> 32) ^ 0x9E3779B9u); + for (var i = 0; i < payload.Length; i++) + { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + payload[i] = (byte)state; + } + return payload; + } + + private static int ComputeHash(byte[] data) + { + if (data == null) return 0; + unchecked + { + var hash = (int)2166136261; + for (var i = 0; i < data.Length; i++) + hash = (hash ^ data[i]) * 16777619; + return hash; + } + } + + private bool ShouldAcceptSession(string sessionId) + { + if (string.IsNullOrEmpty(sessionId)) return false; + if (string.IsNullOrEmpty(_sessionId)) + { + _sessionId = sessionId; + return true; + } + return _sessionId == sessionId || _state == RunState.Idle; + } + + private bool IsCurrentSession(string sessionId) + { + return !string.IsNullOrEmpty(sessionId) && sessionId == _sessionId; + } + + private void NewSession() + { + _sessionId = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}".Substring(0, 27); + _reports.Clear(); + _lastReport = string.Empty; + AppendEvent($"新 Session: {_sessionId}"); + } + + private ulong GetSelfMemberId() + { + return LobbyManager.Instance.Lobby?.GetSelfMemberId() ?? 0; + } + + private PeerStats GetSelfStats() + { + return GetStats(GetSelfMemberId()); + } + + private PeerStats GetStats(ulong memberId) + { + if (!_stats.TryGetValue(memberId, out var stats)) + { + stats = new PeerStats + { + MemberId = memberId, + Name = GetMemberName(memberId), + }; + _stats[memberId] = stats; + } + return stats; + } + + private string GetMemberName(ulong memberId) + { + var lobby = LobbyManager.Instance.Lobby; + if (memberId == 0) return "Unknown"; + var memberInfo = lobby?.GetMemberInfo(memberId); + if (!string.IsNullOrEmpty(memberInfo?.Name)) return memberInfo.Name; + return memberId.ToString(); + } + + private string BuildReport() + { + var sb = new StringBuilder(4096); + sb.AppendLine("{"); + AppendJson(sb, "session", _sessionId, true); + AppendJson(sb, "scenario", _config.ScenarioName, true); + AppendJson(sb, "memberId", GetSelfMemberId().ToString(), true, false); + AppendJson(sb, "memberName", GetMemberName(GetSelfMemberId()), true); + AppendJson(sb, "state", _statusLine, true); + AppendJson(sb, "durationSeconds", _config.DurationSeconds.ToString("0.###"), true, false); + sb.AppendLine(" \"peers\": ["); + + var values = _stats.Values.OrderBy(s => s.MemberId).ToList(); + for (var i = 0; i < values.Count; i++) + { + var stats = values[i]; + sb.AppendLine(" {"); + AppendJson(sb, "memberId", stats.MemberId.ToString(), true, false, 6); + AppendJson(sb, "name", stats.Name ?? string.Empty, true, true, 6); + AppendJson(sb, "sentAttempted", stats.SentAttempted.ToString(), true, false, 6); + AppendJson(sb, "sentEnqueued", stats.SentEnqueued.ToString(), true, false, 6); + AppendJson(sb, "sendFailed", stats.SendFailed.ToString(), true, false, 6); + AppendJson(sb, "droppedByImpairment", stats.DroppedByImpairment.ToString(), true, false, 6); + AppendJson(sb, "receivedProbes", stats.ReceivedProbes.ToString(), true, false, 6); + AppendJson(sb, "payloadErrors", stats.PayloadErrors.ToString(), true, false, 6); + AppendJson(sb, "duplicates", stats.DuplicateMessages.ToString(), true, false, 6); + AppendJson(sb, "outOfOrder", stats.OutOfOrderMessages.ToString(), true, false, 6); + AppendJson(sb, "sequenceGaps", stats.SequenceGaps.ToString(), true, false, 6); + AppendJson(sb, "ackReceived", stats.AckReceived.ToString(), true, false, 6); + AppendJson(sb, "ackTimeouts", stats.AckTimeouts.ToString(), true, false, 6); + AppendJson(sb, "latencyAvgMs", GetAverage(stats.LatencyMs).ToString("0.###"), true, false, 6); + AppendJson(sb, "latencyP95Ms", GetPercentile(stats.LatencyMs, 95f).ToString("0.###"), false, false, 6); + sb.Append(" }"); + if (i < values.Count - 1) sb.Append(","); + sb.AppendLine(); + } + + sb.AppendLine(" ]"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static void AppendJson(StringBuilder sb, string key, string value, bool comma, bool quote = true, + int indent = 2) + { + sb.Append(' ', indent); + sb.Append('"').Append(EscapeJson(key)).Append("\": "); + if (quote) sb.Append('"').Append(EscapeJson(value)).Append('"'); + else sb.Append(value); + if (comma) sb.Append(','); + sb.AppendLine(); + } + + private static string EscapeJson(string value) + { + return (value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\""); + } + + private void ExportReport() + { + var root = Path.Combine(Directory.GetCurrentDirectory(), "NetworkStressReports"); + Directory.CreateDirectory(root); + var path = Path.Combine(root, $"TH1NetworkStress_{DateTime.Now:yyyyMMdd_HHmmss}.json"); + var sb = new StringBuilder(8192); + sb.AppendLine("{"); + AppendJson(sb, "exportedAt", DateTime.UtcNow.ToString("O"), true); + AppendJson(sb, "session", _sessionId, true); + sb.AppendLine(" \"reports\": ["); + var allReports = _reports.Values.ToList(); + for (var i = 0; i < allReports.Count; i++) + { + sb.Append(allReports[i]); + if (i < allReports.Count - 1) sb.Append(","); + sb.AppendLine(); + } + sb.AppendLine(" ]"); + sb.AppendLine("}"); + File.WriteAllText(path, sb.ToString(), Encoding.UTF8); + AppendEvent($"报告已导出: {path}"); + } + + private void AppendEvent(string line) + { + if (string.IsNullOrEmpty(line)) return; + _eventLog = $"[{DateTime.Now:HH:mm:ss}] {line}\n{_eventLog}"; + if (_eventLog.Length > 6000) + _eventLog = _eventLog.Substring(0, 6000); + try + { + LogSystem.LogInfo($"[NetworkStress] {line}"); + } + catch + { + Debug.Log($"[NetworkStress] {line}"); + } + } + + private static float GetAverage(List values) + { + if (values == null || values.Count == 0) return 0f; + double total = 0d; + for (var i = 0; i < values.Count; i++) + total += values[i]; + return (float)(total / values.Count); + } + + private static float GetPercentile(List values, float percentile) + { + if (values == null || values.Count == 0) return 0f; + var copy = values.ToArray(); + Array.Sort(copy); + var index = Mathf.Clamp(Mathf.CeilToInt(percentile / 100f * copy.Length) - 1, 0, copy.Length - 1); + return copy[index]; + } + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024f:0.0} KB"; + if (bytes < 1024L * 1024L * 1024L) return $"{bytes / 1024f / 1024f:0.0} MB"; + return $"{bytes / 1024f / 1024f / 1024f:0.0} GB"; + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/Editor/NetworkStressEditorWindow.cs.meta b/Unity/Assets/Scripts/TH1_Logic/Editor/NetworkStressEditorWindow.cs.meta new file mode 100644 index 000000000..9fc3cb865 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/Editor/NetworkStressEditorWindow.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 2a0b645d31ea4e8aaee2ffadbe47b7cb +timeCreated: 1778849300 diff --git a/Unity/Assets/Scripts/TH1_Logic/Steam/GameNetReceiver.cs b/Unity/Assets/Scripts/TH1_Logic/Steam/GameNetReceiver.cs index 67fef7da6..5f4b9a9f2 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Steam/GameNetReceiver.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Steam/GameNetReceiver.cs @@ -22,6 +22,7 @@ namespace TH1_Logic.Steam { var message = MemoryPack.MemoryPackSerializer.Deserialize(data); if (message == null) return; + if (message.MessageType == P2PMsgType.NetworkStress) return; if (message.MessageType == P2PMsgType.String) OnReceivedString((StringMessage)message); if (message.MessageType == P2PMsgType.GameStart) OnReceivedGameStart((GameStartMessage)message); diff --git a/Unity/Assets/Scripts/TH1_Logic/Steam/SteamObjectSerializer.cs b/Unity/Assets/Scripts/TH1_Logic/Steam/SteamObjectSerializer.cs index cd491d011..d5346956c 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Steam/SteamObjectSerializer.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Steam/SteamObjectSerializer.cs @@ -57,6 +57,17 @@ namespace TH1_Logic.Steam ChatMessage = 15, // 邀请 InviteMessage = 16, + // 网络压测工具消息 + NetworkStress = 17, + } + + public enum NetworkStressMessageKind : byte + { + Probe = 0, + Ack = 1, + ControlStart = 2, + ControlStop = 3, + Report = 4, } @@ -77,6 +88,7 @@ namespace TH1_Logic.Steam [MemoryPackUnion(14, typeof(HeartbeatReplyMessage))] [MemoryPackUnion(15, typeof(ChatMessage))] [MemoryPackUnion(16, typeof(InviteMessage))] + [MemoryPackUnion(17, typeof(NetworkStressMessage))] public abstract partial class BaseMessage { public abstract P2PMsgType MessageType { get; } @@ -217,4 +229,37 @@ namespace TH1_Logic.Steam public override P2PMsgType MessageType => P2PMsgType.InviteMessage; public LobbyListInfo LobbyInfo { get; set; } } -} \ No newline at end of file + + + [MemoryPackable] + public partial class NetworkStressMessage : BaseMessage + { + public override P2PMsgType MessageType => P2PMsgType.NetworkStress; + public NetworkStressMessageKind StressKind { get; set; } + public string SessionId { get; set; } + public string ScenarioName { get; set; } + public ulong SenderMemberId { get; set; } + public ulong TargetMemberId { get; set; } + public long Sequence { get; set; } + public long AckSequence { get; set; } + public long SentUtcTicks { get; set; } + public int PayloadBytes { get; set; } + public int PayloadHash { get; set; } + public bool Reliable { get; set; } + public bool RequiresAck { get; set; } + public float DurationSeconds { get; set; } + public float MessagesPerSecond { get; set; } + public int SmallPayloadBytes { get; set; } + public int LargePayloadBytes { get; set; } + public int LargeEveryMessages { get; set; } + public float DropPercent { get; set; } + public float UnreliablePercent { get; set; } + public int JitterMinMs { get; set; } + public int JitterMaxMs { get; set; } + public int BurstPauseEveryMessages { get; set; } + public int BurstPauseMs { get; set; } + public int RandomSeed { get; set; } + public byte[] Payload { get; set; } + public string Text { get; set; } + } +}