diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 92609ff5..41ba57a8 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -15,6 +15,8 @@ jobs: build: name: Build beta runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -32,14 +34,22 @@ jobs: - name: Build Mod run: dotnet build ${{ env.SLN_PATH }} --configuration Release --no-restore - - run: mkdir -p output/Multiplayer - - - name: Move files - run: mv About/ Assemblies/ AssembliesCustom/ Defs/ Languages/ Textures/ output/Multiplayer + - name: Package files + run: zip -r Multiplayer-beta.zip About/ Assemblies/ AssembliesCustom/ Defs/ Languages/ Textures/ - name: Upload Mod Artifacts uses: actions/upload-artifact@v4 with: name: Multiplayer-beta - path: | - output/ + path: Multiplayer-beta.zip + + - name: Clean up last release + continue-on-error: true + run: | + gh release delete --cleanup-tag --yes "continuous" + + - name: Upload new release + run: | + gh release create --target "${{ github.sha }}" --title "Continuous" --notes "This 'alpha' release is an automatically generated snapshot of the current state of development. It is continuously updated with work-in-progress changes that may be broken, incomplete, or incompatible." --draft "continuous" + gh release upload "continuous" Multiplayer-beta.zip + gh release edit "continuous" --draft=false diff --git a/Source/.editorconfig b/Source/.editorconfig index 85b69d23..c7119b50 100644 --- a/Source/.editorconfig +++ b/Source/.editorconfig @@ -1,12 +1,17 @@ root = true - + [*] charset = utf-8 indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true - + [*.{cs,js,ts,sql}] indent_size = 4 -end_of_line = crlf \ No newline at end of file +end_of_line = crlf + +[*.cs] +# The conflicting classes provided in this assembly are essentially polyfills +# of .NET Core features to allow running on .NET Framework. +dotnet_diagnostic.cs0436.severity = none diff --git a/Source/Client/AsyncTime/AsyncTimePatches.cs b/Source/Client/AsyncTime/AsyncTimePatches.cs index a5f135b0..c2c4dd58 100644 --- a/Source/Client/AsyncTime/AsyncTimePatches.cs +++ b/Source/Client/AsyncTime/AsyncTimePatches.cs @@ -178,9 +178,9 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == AccessTools.PropertyGetter(typeof(Prefs), nameof(Prefs.AutomaticPauseMode))) + if (inst.operand as MethodInfo == AccessTools.PropertyGetter(typeof(Prefs), nameof(Prefs.AutomaticPauseMode))) inst.operand = AccessTools.Method(typeof(ReceiveLetterPause), nameof(AutomaticPauseMode)); - else if (inst.operand == AccessTools.Method(typeof(TickManager), nameof(TickManager.Pause))) + else if (inst.operand as MethodInfo == AccessTools.Method(typeof(TickManager), nameof(TickManager.Pause))) inst.operand = AccessTools.Method(typeof(ReceiveLetterPause), nameof(PauseOnLetter)); yield return inst; @@ -190,7 +190,7 @@ static IEnumerable Transpiler(IEnumerable inst private static AutomaticPauseMode AutomaticPauseMode() { return Multiplayer.Client != null - ? (AutomaticPauseMode) Multiplayer.GameComp.pauseOnLetter + ? (AutomaticPauseMode)Multiplayer.GameComp.pauseOnLetter : Prefs.AutomaticPauseMode; } diff --git a/Source/Client/AsyncTime/TimeControlUI.cs b/Source/Client/AsyncTime/TimeControlUI.cs index 4de29b4d..756539a9 100644 --- a/Source/Client/AsyncTime/TimeControlUI.cs +++ b/Source/Client/AsyncTime/TimeControlUI.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Reflection.Emit; using HarmonyLib; using Multiplayer.Client.Util; @@ -36,12 +37,12 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == AccessTools.Method(typeof(TimeControls), nameof(TimeControls.DoTimeControlsGUI))) + if (inst.operand as MethodInfo == AccessTools.Method(typeof(TimeControls), nameof(TimeControls.DoTimeControlsGUI))) inst.operand = AccessTools.Method(typeof(TimeControlPatch), nameof(DoTimeControlsGUI)); yield return inst; - if (inst.operand == AccessTools.Constructor(typeof(Rect), + if (inst.operand as MethodInfo == AccessTools.Constructor(typeof(Rect), new[] { typeof(float), typeof(float), typeof(float), typeof(float) })) { yield return new CodeInstruction(OpCodes.Ldloca_S, 1); @@ -360,7 +361,7 @@ static void DrawButtons() } else { - // There is a new blocking pause + // There is a new blocking pause flashDict.Add(flashPos, Time.time); } } @@ -416,7 +417,7 @@ static void DrawPauseFlash(Vector2 pos) // Only flash at flashInterval from the time the blocking pause began if (pauseDuration > 0f && pauseDuration % flashInterval < 1f) { - GenUI.DrawFlash(pos.x, pos.y, UI.screenWidth * 0.6f, Pulser.PulseBrightness(1f, 1f, pauseDuration) * 0.4f, flashColor); + GenUI.DrawFlash(pos.x, pos.y, UI.screenWidth * 0.6f, Pulser.PulseBrightness(1f, 1f, pauseDuration) * 0.4f, flashColor); } } } diff --git a/Source/Client/Debug/DebugPatches.cs b/Source/Client/Debug/DebugPatches.cs index 8c06c714..8f0541dd 100644 --- a/Source/Client/Debug/DebugPatches.cs +++ b/Source/Client/Debug/DebugPatches.cs @@ -90,10 +90,10 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == GizmoOnGUI) + if (inst.operand as MethodInfo == GizmoOnGUI) yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(GizmoDrawDebugInfo), nameof(GizmoOnGUIProxy))); - else if (inst.operand == GizmoOnGUIShrunk) + else if (inst.operand as MethodInfo == GizmoOnGUIShrunk) yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(GizmoDrawDebugInfo), nameof(GizmoOnGUIShrunkProxy))); else diff --git a/Source/Client/Factions/Blueprints.cs b/Source/Client/Factions/Blueprints.cs index 4f8b59b4..64309efe 100644 --- a/Source/Client/Factions/Blueprints.cs +++ b/Source/Client/Factions/Blueprints.cs @@ -30,7 +30,7 @@ static IEnumerable Transpiler(IEnumerable e, M yield return cur; - if (cur.opcode == OpCodes.Call && cur.operand == CanPlaceBlueprintOver) + if (cur.opcode == OpCodes.Call && cur.operand as MethodInfo == CanPlaceBlueprintOver) { var thingToIgnoreIndex = insts[i - 2].operand; @@ -103,7 +103,7 @@ static IEnumerable Transpiler(IEnumerable e, M new CodeInstruction(OpCodes.Call, CanPlaceBlueprintAtPatch.ShouldIgnore1Method), new CodeInstruction(OpCodes.Brtrue, insts[loop + 1].operand) ); - + return insts; } } @@ -173,7 +173,7 @@ static IEnumerable Transpiler(IEnumerable inst { yield return inst; - if (inst.opcode == OpCodes.Call && inst.operand == SpawningWipes) + if (inst.opcode == OpCodes.Call && inst.operand as MethodInfo == SpawningWipes) { yield return new CodeInstruction(OpCodes.Ldarg_2); yield return new CodeInstruction(OpCodes.Ldloc_3); @@ -196,7 +196,7 @@ static IEnumerable Transpiler(IEnumerable inst { yield return inst; - if (inst.opcode == OpCodes.Call && inst.operand == SpawningWipes) + if (inst.opcode == OpCodes.Call && inst.operand as MethodInfo == SpawningWipes) { yield return new CodeInstruction(OpCodes.Ldarg_2); yield return new CodeInstruction(OpCodes.Ldloc_S, 4); @@ -221,7 +221,7 @@ static IEnumerable Transpiler(IEnumerable inst { yield return inst; - if (inst.opcode == OpCodes.Call && inst.operand == SpawningWipes) + if (inst.opcode == OpCodes.Call && inst.operand as MethodInfo == SpawningWipes) { yield return new CodeInstruction(OpCodes.Ldarg_0); yield return new CodeInstruction(OpCodes.Ldfld, ThingDefField); @@ -335,7 +335,7 @@ static IEnumerable Transpiler(IEnumerable inst { yield return inst; - if (inst.opcode == OpCodes.Isinst && inst.operand == typeof(Blueprint)) + if (inst.opcode == OpCodes.Isinst && inst.operand as MethodInfo == typeof(Blueprint)) { yield return new CodeInstruction(OpCodes.Ldnull); yield return new CodeInstruction(OpCodes.Cgt_Un); diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index e570c21c..325b3a75 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -50,7 +50,7 @@ static bool Prefix(Quest quest, ref bool __result) if (quest.TryGetPlayerFaction(out playerFaction) && playerFaction != Faction.OfPlayer) { __result = false; - return false; + return false; } } return true; @@ -82,8 +82,8 @@ static class WorldInspectPanePaneTopYPatch { static void Postfix(ref float __result) { - if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) - __result += 35f; + if (Multiplayer.Client != null && Multiplayer.RealPlayerFaction == Multiplayer.WorldComp.spectatorFaction) + __result += 35f; } } @@ -198,13 +198,13 @@ static class MainButtonsRootDoButtonsPatch private static bool ReplaceOriginalDrawing(MainButtonsRoot __instance) { if (!IsSpectator) - return true; + return true; try { var allButtons = AllButtonsRef(__instance); if (allButtons == null) - return true; + return true; var toDraw = SpectatorButtons .Select(name => allButtons.Find(b => b.defName == name)) @@ -212,7 +212,7 @@ private static bool ReplaceOriginalDrawing(MainButtonsRoot __instance) .ToList(); DrawCornerButtons(toDraw); - return false; + return false; } catch (Exception ex) { @@ -421,7 +421,7 @@ static IEnumerable Transpiler(IEnumerable inst { yield return inst; - if (inst.operand == playerFactionField) + if (inst.operand as MethodInfo == playerFactionField) yield return new CodeInstruction(OpCodes.Call, factionOfPlayer.Method); } } @@ -442,7 +442,7 @@ static IEnumerable Transpiler(IEnumerable inst foreach (var inst in insts) { - if (inst.operand == isPlayerMethodGetter) + if (inst.operand as MethodInfo == isPlayerMethodGetter) inst.operand = factionIsPlayer.Method; yield return inst; @@ -464,7 +464,7 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == allColonists) + if (inst.operand as MethodInfo == allColonists) inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(ColonistsAllPlayerFactions)); yield return inst; } @@ -516,7 +516,7 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == isColonyMech) + if (inst.operand as MethodInfo == isColonyMech) inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonyMechAnyFaction)); yield return inst; @@ -557,10 +557,10 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == isColonist) + if (inst.operand as MethodInfo == isColonist) inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonistAnyFaction)); - if (inst.operand == isColonySubhuman) + if (inst.operand as MethodInfo == isColonySubhuman) inst.operand = AccessTools.Method(typeof(RecacheColonistBelieverCountPatch), nameof(RecacheColonistBelieverCountPatch.IsColonySubhumanAnyFaction)); yield return inst; @@ -577,7 +577,7 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == isFreeNonSlaveColonist) + if (inst.operand as MethodInfo == isFreeNonSlaveColonist) inst.operand = AccessTools.Method(typeof(ValidatePawnPatch), nameof(IsFreeNonSlaveColonistAnyFaction)); yield return inst; @@ -632,7 +632,7 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == PlayOneShotOnCamera) + if (inst.operand as MethodInfo == PlayOneShotOnCamera) yield return new CodeInstruction( OpCodes.Call, SymbolExtensions.GetMethodInfo((SoundDef s, Map m) => PlaySoundReplacement(s, m))); @@ -661,7 +661,7 @@ static IEnumerable Transpiler(IEnumerable inst // This instruction is part of wornGraphicPaths[thingIDNumber % wornGraphicPaths.Count] // The function makes sure the id is positive - if (inst.operand == thingIDNumberField) + if (inst.operand as MethodInfo == thingIDNumberField) yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(ApparelWornGraphicPathGetterPatch), nameof(MakeIdPositive))); } @@ -721,7 +721,7 @@ static IEnumerable Transpiler(IEnumerable inst yield return inst; // Don't draw the ideo plate while choosing starting pawns in multifaction - if (inst.operand == classicModeField) + if (inst.operand as MethodInfo == classicModeField) { yield return new CodeInstruction(OpCodes.Ldarg_2); yield return new CodeInstruction(OpCodes.Call, diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index a7f46a16..e85cad9d 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -1,4 +1,4 @@ - + net48 @@ -59,16 +59,21 @@ + + + ..\..\ + + - - + + - - - - - - + + + + + + diff --git a/Source/Client/Networking/NetworkingSteam.cs b/Source/Client/Networking/NetworkingSteam.cs index d38e77b3..e838549b 100644 --- a/Source/Client/Networking/NetworkingSteam.cs +++ b/Source/Client/Networking/NetworkingSteam.cs @@ -53,14 +53,10 @@ public override string ToString() } } - public class SteamClientConn : SteamBaseConn + public class SteamClientConn(CSteamID remoteId) : SteamBaseConn(remoteId, RandomChannelId(), 0) { static ushort RandomChannelId() => (ushort)new Random().Next(); - public SteamClientConn(CSteamID remoteId) : base(remoteId, RandomChannelId(), 0) - { - } - protected override void HandleReceiveMsg(int msgId, int fragState, ByteReader reader, bool reliable) { if (msgId == (int)Packets.Special_Steam_Disconnect) @@ -91,12 +87,8 @@ private void OnDisconnect() } } - public class SteamServerConn : SteamBaseConn + public class SteamServerConn(CSteamID remoteId, ushort clientChannel) : SteamBaseConn(remoteId, 0, clientChannel) { - public SteamServerConn(CSteamID remoteId, ushort clientChannel) : base(remoteId, 0, clientChannel) - { - } - protected override void HandleReceiveMsg(int msgId, int fragState, ByteReader reader, bool reliable) { if (msgId == (int)Packets.Special_Steam_Disconnect) diff --git a/Source/Client/Networking/State/ClientLoadingState.cs b/Source/Client/Networking/State/ClientLoadingState.cs index 832edfa0..7a190832 100644 --- a/Source/Client/Networking/State/ClientLoadingState.cs +++ b/Source/Client/Networking/State/ClientLoadingState.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Ionic.Zlib; using Multiplayer.Client.Saving; @@ -13,14 +14,29 @@ public enum LoadingState Downloading } -public class ClientLoadingState : ClientBaseState +public class ClientLoadingState(ConnectionBase connection) : ClientBaseState(connection) { public LoadingState subState = LoadingState.Waiting; - - public ClientLoadingState(ConnectionBase connection) : base(connection) + public uint WorldExpectedSize { get; private set; } + public uint WorldReceivedSize { get; private set; } + public float DownloadProgress => (float)WorldReceivedSize / WorldExpectedSize; + public int DownloadProgressPercent => (int)(DownloadProgress * 100); + public int DownloadSpeedKBps { + get + { + if (downloadCheckpoints.Count == 0) return -1; + var firstCheckpoint = downloadCheckpoints.First(); + var lastCheckpoint = downloadCheckpoints.Last(); + var timeTakenMs = Utils.MillisNow - firstCheckpoint.Item1; + var timeTakenSecs = Math.Max(1, timeTakenMs / 1000); + var downloadedBytes = lastCheckpoint.Item2 - firstCheckpoint.Item2; + return (int)(downloadedBytes / 1000 / timeTakenSecs); + } } + private List<(long, uint)> downloadCheckpoints = new(capacity: 64); + [PacketHandler(Packets.Server_WorldDataStart)] public void HandleWorldDataStart(ByteReader data) { @@ -28,6 +44,16 @@ public void HandleWorldDataStart(ByteReader data) connection.Lenient = false; // Lenient is set while rejoining } + [FragmentedPacketHandler(Packets.Server_WorldData)] + public void HandleWorldDataFragment(FragmentedPacket packet) + { + WorldExpectedSize = packet.ExpectedSize; + WorldReceivedSize = packet.ReceivedSize; + if (downloadCheckpoints.Count == downloadCheckpoints.Capacity) + downloadCheckpoints.RemoveAt(0); + downloadCheckpoints.Add((Utils.MillisNow, packet.ReceivedSize)); + } + [PacketHandler(Packets.Server_WorldData, allowFragmented: true)] public void HandleWorldData(ByteReader data) { diff --git a/Source/Client/Patches/AllGroupsPatch.cs b/Source/Client/Patches/AllGroupsPatch.cs index ca12be88..49e59c18 100644 --- a/Source/Client/Patches/AllGroupsPatch.cs +++ b/Source/Client/Patches/AllGroupsPatch.cs @@ -19,7 +19,7 @@ static IEnumerable Transpiler(IEnumerable inst foreach (CodeInstruction inst in insts) { - if (inst.operand == AllGroups) + if (inst.operand as MethodInfo == AllGroups) { yield return new CodeInstruction(OpCodes.Ldarg_1).MoveLabelsFrom(inst); yield return new CodeInstruction(OpCodes.Call, method); diff --git a/Source/Client/Patches/ArbiterPatches.cs b/Source/Client/Patches/ArbiterPatches.cs index 24326879..3314cb9e 100644 --- a/Source/Client/Patches/ArbiterPatches.cs +++ b/Source/Client/Patches/ArbiterPatches.cs @@ -43,7 +43,7 @@ static IEnumerable Transpiler(IEnumerable inst { yield return inst; - if (inst.operand == IsCreated) + if (inst.operand as MethodInfo == IsCreated) { yield return new CodeInstruction(OpCodes.Ldsfld, ArbiterField); yield return new CodeInstruction(OpCodes.Or); diff --git a/Source/Client/Patches/Determinism.cs b/Source/Client/Patches/Determinism.cs index d6455414..195fb6c4 100644 --- a/Source/Client/Patches/Determinism.cs +++ b/Source/Client/Patches/Determinism.cs @@ -76,7 +76,7 @@ static IEnumerable Transpiler(IEnumerable e) for (int i = insts.Count - 1; i >= 0; i--) { - if (insts[i].operand == FirstOrDefault) + if (insts[i].operand as MethodInfo == FirstOrDefault) insts.Insert( i + 1, new CodeInstruction(OpCodes.Ldloc_1), @@ -138,7 +138,7 @@ static IEnumerable Transpiler(IEnumerable inst { yield return inst; - if (inst.operand == CellRectContains) + if (inst.operand as MethodInfo == CellRectContains) { yield return new CodeInstruction(OpCodes.Ldc_I4_1); yield return new CodeInstruction(OpCodes.Or); @@ -155,7 +155,7 @@ public static IEnumerable Transpiler(IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == AccessTools.PropertyGetter(typeof(ModLister), nameof(ModLister.BiotechInstalled))) + if (inst.operand as MethodInfo == AccessTools.PropertyGetter(typeof(ModLister), nameof(ModLister.BiotechInstalled))) inst.operand = AccessTools.PropertyGetter(typeof(ModsConfig), nameof(ModsConfig.BiotechActive)); yield return inst; } @@ -302,7 +302,7 @@ static IEnumerable Transpiler(IEnumerable inst foreach (var inst in insts) { // Remove mutation of battleActive during saving which was a source of non-determinism - if (inst.opcode == OpCodes.Stfld && inst.operand == battleActiveField) + if (inst.opcode == OpCodes.Stfld && inst.operand as MethodInfo == battleActiveField) { yield return new CodeInstruction(OpCodes.Pop); yield return new CodeInstruction(OpCodes.Pop); @@ -341,7 +341,7 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.opcode == OpCodes.Stfld && inst.operand == queryTickField) + if (inst.opcode == OpCodes.Stfld && inst.operand as MethodInfo == queryTickField) { yield return new CodeInstruction(OpCodes.Ldarg_0); yield return new CodeInstruction(OpCodes.Ldarg_1); @@ -385,7 +385,7 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == clearMethod) + if (inst.operand as MethodInfo == clearMethod) yield return new CodeInstruction(OpCodes.Pop); else yield return inst; diff --git a/Source/Client/Patches/Feedback.cs b/Source/Client/Patches/Feedback.cs index ed0425fb..83a05ec3 100644 --- a/Source/Client/Patches/Feedback.cs +++ b/Source/Client/Patches/Feedback.cs @@ -121,7 +121,7 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == IsSelected) + if (inst.operand as MethodInfo == IsSelected) { yield return new CodeInstruction(OpCodes.Ldarg_0); yield return new CodeInstruction(OpCodes.Call, DeselectOnDespawnMethod); diff --git a/Source/Client/Patches/HashCodes.cs b/Source/Client/Patches/HashCodes.cs index 73cb0272..80430f4a 100644 --- a/Source/Client/Patches/HashCodes.cs +++ b/Source/Client/Patches/HashCodes.cs @@ -44,7 +44,7 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == Combine) + if (inst.operand as MethodInfo == Combine) inst.operand = AccessTools.Method(typeof(PatchTargetInfoHashCodes), nameof(CombineHashes)); yield return inst; diff --git a/Source/Client/Patches/Patches.cs b/Source/Client/Patches/Patches.cs index 9fa6c147..2966f976 100644 --- a/Source/Client/Patches/Patches.cs +++ b/Source/Client/Patches/Patches.cs @@ -37,7 +37,7 @@ static IEnumerable Transpiler(IEnumerable inst { yield return inst; - if (inst.operand == FrameCountField) + if (inst.operand as MethodInfo == FrameCountField) { yield return new CodeInstruction(OpCodes.Ldarg_0); yield return new CodeInstruction(OpCodes.Call, FrameCountReplacementMethod); diff --git a/Source/Client/Patches/Seeds.cs b/Source/Client/Patches/Seeds.cs index 2bf22b35..fe99dea2 100644 --- a/Source/Client/Patches/Seeds.cs +++ b/Source/Client/Patches/Seeds.cs @@ -121,7 +121,7 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (CodeInstruction inst in insts) { - if (inst.operand == Rot4GetRandom) + if (inst.operand as MethodInfo == Rot4GetRandom) { // Load newThing.thingIdNumber to the stack yield return new CodeInstruction(OpCodes.Ldarg_0); @@ -137,7 +137,7 @@ static IEnumerable Transpiler(IEnumerable inst yield return inst; - if (inst.operand == Rot4GetRandom) + if (inst.operand as MethodInfo == Rot4GetRandom) yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(Rand), nameof(Rand.PopState))); } } diff --git a/Source/Client/Persistent/ISwitchToMap.cs b/Source/Client/Persistent/ISwitchToMap.cs index 0208d06f..ed918b5b 100644 --- a/Source/Client/Persistent/ISwitchToMap.cs +++ b/Source/Client/Persistent/ISwitchToMap.cs @@ -19,7 +19,7 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.opcode == OpCodes.Ldfld && inst.operand == doCloseXField) + if (inst.opcode == OpCodes.Ldfld && inst.operand as MethodInfo == doCloseXField) { yield return new CodeInstruction(OpCodes.Ldarg_0); // This window yield return new CodeInstruction(OpCodes.Ldloc_0); // Window rect diff --git a/Source/Client/Persistent/RitualPatches.cs b/Source/Client/Persistent/RitualPatches.cs index 0555a370..396168d9 100644 --- a/Source/Client/Persistent/RitualPatches.cs +++ b/Source/Client/Persistent/RitualPatches.cs @@ -121,7 +121,7 @@ static IEnumerable Transpiler(IEnumerable inst foreach (var inst in list) { - if (inst.operand == listClear) + if (inst.operand as MethodInfo == listClear) { yield return new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(DontClearDialogBeginRitualCache), nameof(ShouldCancelCacheClear))); diff --git a/Source/Client/Persistent/Trading.cs b/Source/Client/Persistent/Trading.cs index 7861dd66..ff25e2a7 100644 --- a/Source/Client/Persistent/Trading.cs +++ b/Source/Client/Persistent/Trading.cs @@ -781,7 +781,7 @@ static IEnumerable Transpiler(IEnumerable inst { foreach (var inst in insts) { - if (inst.operand == AgeBiologicalFloat) + if (inst.operand as MethodInfo == AgeBiologicalFloat) { yield return new CodeInstruction(OpCodes.Callvirt, AgeBiologicalInt); yield return new CodeInstruction(OpCodes.Conv_R4); diff --git a/Source/Client/Syncing/Game/SyncFields.cs b/Source/Client/Syncing/Game/SyncFields.cs index bce10f86..77c86235 100644 --- a/Source/Client/Syncing/Game/SyncFields.cs +++ b/Source/Client/Syncing/Game/SyncFields.cs @@ -93,6 +93,8 @@ public static class SyncFields public static ISyncField SyncActivityCompTarget; public static ISyncField SyncActivityCompSuppression; + public static ISyncField SyncCompRefuelableValue; + public static void Init() { SyncMedCare = Sync.Field(typeof(Pawn), nameof(Pawn.playerSettings), nameof(Pawn_PlayerSettings.medCare)); @@ -227,6 +229,8 @@ public static void Init() SyncActivityGizmoTarget = Sync.Field(typeof(ActivityGizmo), nameof(ActivityGizmo.targetValuePct)).SetBufferChanges(); SyncActivityCompTarget = Sync.Field(typeof(CompActivity), nameof(CompActivity.suppressIfAbove)).SetBufferChanges(); SyncActivityCompSuppression = Sync.Field(typeof(CompActivity), nameof(CompActivity.suppressionEnabled)); + + SyncCompRefuelableValue = Sync.Field(typeof(CompRefuelable), nameof(CompRefuelable.allowAutoRefuel)).SetBufferChanges(); } [MpPrefix(typeof(StorytellerUI), nameof(StorytellerUI.DrawStorytellerSelectionInterface))] @@ -591,6 +595,11 @@ static void SyncGeneResourceChange(Gizmo_Slider __instance) SyncActivityCompTarget.Watch(comp); SyncActivityCompSuppression.Watch(comp); } + else if (__instance is Gizmo_SetFuelLevel fuelGizmo) + { + var refuelable = fuelGizmo.refuelable; + SyncCompRefuelableValue.Watch(refuelable); + } } [MpPrefix(typeof(ITab_ContentsGenepackHolder), nameof(ITab_ContentsGenepackHolder.DoItemsLists))] diff --git a/Source/Client/Syncing/Game/SyncMethods.cs b/Source/Client/Syncing/Game/SyncMethods.cs index fa682c69..f7817dfe 100644 --- a/Source/Client/Syncing/Game/SyncMethods.cs +++ b/Source/Client/Syncing/Game/SyncMethods.cs @@ -5,6 +5,7 @@ using RimWorld.Planet; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Reflection.Emit; using Multiplayer.Client.Util; using Verse; @@ -462,7 +463,7 @@ static IEnumerable CompPlantableTranspiler(IEnumerable CompPlantable_AddCell(t.Cell, this) - if (inst.operand == typeof(List).GetMethod("Add")) + if (inst.operand as MethodInfo == typeof(List).GetMethod("Add")) { // Load this yield return new CodeInstruction(OpCodes.Ldarg_0); diff --git a/Source/Client/UI/DebugPanel/PerformanceRecorder.cs b/Source/Client/UI/DebugPanel/PerformanceRecorder.cs index c5d2a220..85dff7e9 100644 --- a/Source/Client/UI/DebugPanel/PerformanceRecorder.cs +++ b/Source/Client/UI/DebugPanel/PerformanceRecorder.cs @@ -1,4 +1,3 @@ -using Multiplayer.Client.Patches; using Multiplayer.Client.Util; using Multiplayer.Common; using RimWorld; @@ -19,37 +18,112 @@ namespace Multiplayer.Client.DebugUi; public static class PerformanceRecorder { private static bool isRecording = false; + private static bool hasLoggedLimitWarning = false; private static DateTime recordingStartTime; private static int recordingFrameCount = 0; + private static int lastNonPerfMetricsFrameCount = 0; + + public static int MaxSampleCountSetting = 72000; // Default: 20 min at 60fps + public static int NonPerfFrameIntervalSetting = 60; + private static int cachedMaxSampleCount; + private static int cachedNonPerfMetricsFrameInterval; // 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(); + private static CircularBuffer frameTimeSamples; + private static CircularBuffer tickTimeSamples; + private static CircularBuffer deltaTimeSamples; + private static CircularBuffer fpsSamples; + private static CircularBuffer tpsSamples; + private static CircularBuffer normalizedTpsSamples; + private static CircularBuffer serverTPTSamples; // 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(); + private static CircularBuffer timerLagSamples; + private static CircularBuffer receivedCmdsSamples; + private static CircularBuffer sentCmdsSamples; + private static CircularBuffer bufferedChangesSamples; + private static CircularBuffer mapCmdsSamples; + private static CircularBuffer worldCmdsSamples; // Memory/GC metrics - private static List clientOpinionsSamples = new List(); - private static List worldPawnsSamples = new List(); - private static List windowCountSamples = new List(); + private static CircularBuffer clientOpinionsSamples; + private static CircularBuffer worldPawnsSamples; + private static CircularBuffer windowCountSamples; + private static int MaxSampleCount => cachedMaxSampleCount; + private static int NonPerfMetricsFrameInterval => cachedNonPerfMetricsFrameInterval; + 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; + public static float AverageFPS => fpsSamples.Count > 0 ? fpsSamples.GetValues().Average() : 0f; + public static float AverageTPS => tpsSamples.Count > 0 ? tpsSamples.GetValues().Average() : 0f; + public static float AverageNormalizedTPS => normalizedTpsSamples.Count > 0 ? normalizedTpsSamples.GetValues().Average() : 0f; + + /// + /// Draws the performance recorder Start/Stop control buttons on the interface. + /// + /// The x-coordinate of the starting position for the buttons. + /// The y-coordinate of the starting position for the buttons. + /// The total width available for the buttons. + /// The total height occupied by the buttons, including spacing. + 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; + } + + /// + /// Draws the performance recorder section on the interface. + /// + /// This method displays the current status of the performance recorder, including the frame count, + /// recording duration, and average frames per second (FPS) and ticks per second (TPS) if recording is active. + /// The x-coordinate of the section's top-left corner. + /// The y-coordinate of the section's top-left corner. + /// The width of the section to be drawn. + /// The height of the drawn section. + 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:", fpsSamples.Count > 0 ? $"{AverageFPS:F1}" : "N/A", Color.white)); + lines.Add(new DebugLine("Avg TPS:", tpsSamples.Count > 0 ? $"{AverageTPS:F1}" : "N/A", Color.white)); + } + + var section = new DebugSection("PERFORMANCE RECORDER", lines.ToArray()); + return DrawSection(x, y, width, section); + } /// /// Get recording status for debug panel display @@ -58,40 +132,87 @@ 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"); } + /// + /// Records the current frame's performance metrics, including frame time, FPS, and other relevant data. + /// + /// This method collects and stores various performance metrics for the current frame if + /// recording is active. It logs frame time, FPS, and other metrics related to multiplayer and asynchronous + /// operations. The method also checks and records non-performance metrics at specified intervals. + public static void RecordFrame() + { + if (!isRecording) return; + + recordingFrameCount++; + + LogTrimWarningIfNeeded(); + + float deltaTime = Time.deltaTime; + float frameTimeMs = deltaTime * 1000f; + float fps = deltaTime > 0 ? 1f / deltaTime : 0f; + + frameTimeSamples.Add(frameTimeMs); + tickTimeSamples.Add(TickPatch.tickTimer?.ElapsedMilliseconds ?? 0); + deltaTimeSamples.Add(deltaTime * 60f); + fpsSamples.Add(fps); + + if (Multiplayer.Client != null) + { + timerLagSamples.Add(TickPatch.tickUntil - TickPatch.Timer); + + if (Find.CurrentMap?.AsyncTime() is AsyncTimeComp asyncComp) + { + 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(asyncComp.cmds?.Count ?? 0); + } + } + // Check if enough frames have passed since last non-performance metrics update + if (recordingFrameCount >= lastNonPerfMetricsFrameCount + NonPerfMetricsFrameInterval) + { + RecordNonPerformanceMetrics(); + } + } + + /// + /// Initiates the recording process for performance metrics. + /// + /// This method starts recording performance data if it is not already in progress. It resets + /// the recording state and logs a message indicating the start of the recording. public static void StartRecording() { if (isRecording) return; + cachedMaxSampleCount = MaxSampleCountSetting; + cachedNonPerfMetricsFrameInterval = NonPerfFrameIntervalSetting; + + InitializeBuffers(); isRecording = true; recordingStartTime = DateTime.Now; recordingFrameCount = 0; + ClearSamples(); - // 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"); + Verse.Log.Message("[PerformanceRecorder] Recording started"); } + /// + /// Stops the current recording session and processes the recorded data. + /// + /// This method finalizes the recording session by calculating the duration of the recording, + /// generating results, and outputting them to the console and a file. It also clears any collected samples and + /// resets the logging state. public static void StopRecording() { if (!isRecording) return; @@ -99,59 +220,132 @@ public static void StopRecording() isRecording = false; var recordingDuration = DateTime.Now - recordingStartTime; - Verse.Log.Message("[PerformanceRecorder] Recording stopped"); - // Generate and output results var results = GenerateResults(recordingDuration); + Verse.Log.Message("[PerformanceRecorder] Recording stopped"); + OutputToConsole(results); OutputToFile(results); + ClearSamples(); + hasLoggedLimitWarning = false; } - public static void RecordFrame() + private static void AppendCountStats(StringBuilder sb, string name, int count) { - if (!isRecording) return; + sb.AppendLine($"{name,-25} Count: {count,6:N0}"); + } - recordingFrameCount++; + 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}"); + } - // Performance metrics - frameTimeSamples.Add(Time.deltaTime * 1000f); - tickTimeSamples.Add(TickPatch.tickTimer?.ElapsedMilliseconds ?? 0); - deltaTimeSamples.Add(Time.deltaTime * 60f); - fpsSamples.Add(1f / Time.deltaTime); + 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 }; - // Networking metrics - if (Multiplayer.Client != null) + return new StatResult { - 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); + Average = list.Average(), + Min = list.Min(), + Max = list.Max(), + Count = list.Count + }; + } + + private static StatResult CalculateStats(IEnumerable samples) + => CalculateStats(samples.Select(s => (float)s)); - if (Find.CurrentMap?.AsyncTime() != null) + private static void InitializeBuffers() + { + var maxSamples = MaxSampleCount; + var frameInterval = NonPerfMetricsFrameInterval; + var nonPerfSamples = Math.Max(1, maxSamples / frameInterval); + + frameTimeSamples = new CircularBuffer(maxSamples); + tickTimeSamples = new CircularBuffer(maxSamples); + deltaTimeSamples = new CircularBuffer(maxSamples); + fpsSamples = new CircularBuffer(maxSamples); + tpsSamples = new CircularBuffer(maxSamples); + normalizedTpsSamples = new CircularBuffer(maxSamples); + serverTPTSamples = new CircularBuffer(maxSamples); + timerLagSamples = new CircularBuffer(maxSamples); + mapCmdsSamples = new CircularBuffer(maxSamples); + + receivedCmdsSamples = new CircularBuffer(nonPerfSamples); + sentCmdsSamples = new CircularBuffer(nonPerfSamples); + bufferedChangesSamples = new CircularBuffer(nonPerfSamples); + worldCmdsSamples = new CircularBuffer(nonPerfSamples); + clientOpinionsSamples = new CircularBuffer(nonPerfSamples); + worldPawnsSamples = new CircularBuffer(nonPerfSamples); + windowCountSamples = new CircularBuffer(nonPerfSamples); + } + + private static void ClearSamples() + { + 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(); + lastNonPerfMetricsFrameCount = 0; + } + + // 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 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); + var labelRect = new Rect(x, y, labelWidth - 4f, LineHeight); + Widgets.Label(labelRect, line.Label); } - clientOpinionsSamples.Add(Multiplayer.game?.sync?.knownClientOpinions?.Count ?? 0); - } + // 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); + } - // Memory/system metrics - worldPawnsSamples.Add(Find.WorldPawns?.AllPawnsAliveOrDead?.Count ?? 0); - windowCountSamples.Add(Find.WindowStack?.windows?.Count ?? 0); + y += LineHeight + 1f; } - + + return y - startY + SectionSpacing; + } + private static PerformanceResults GenerateResults(TimeSpan duration) { return new PerformanceResults @@ -161,42 +355,36 @@ private static PerformanceResults GenerateResults(TimeSpan duration) 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), + FrameTime = CalculateStats(frameTimeSamples.GetValues()), + TickTime = CalculateStats(tickTimeSamples.GetValues()), + DeltaTime = CalculateStats(deltaTimeSamples.GetValues()), + FPS = CalculateStats(fpsSamples.GetValues()), + TPS = CalculateStats(tpsSamples.GetValues()), + NormalizedTPS = CalculateStats(normalizedTpsSamples.GetValues()), + ServerTPT = CalculateStats(serverTPTSamples.GetValues()), // 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)), + TimerLag = CalculateStats(timerLagSamples.GetValues()), + ReceivedCmds = CalculateStats(receivedCmdsSamples.GetValues()), + SentCmds = CalculateStats(sentCmdsSamples.GetValues()), + BufferedChanges = CalculateStats(bufferedChangesSamples.GetValues()), + MapCmds = CalculateStats(mapCmdsSamples.GetValues()), + WorldCmds = CalculateStats(worldCmdsSamples.GetValues()), // Memory stats - ClientOpinions = CalculateStats(clientOpinionsSamples.Select(x => (float)x)), - WorldPawns = CalculateStats(worldPawnsSamples.Select(x => (float)x)), - WindowCount = CalculateStats(windowCountSamples.Select(x => (float)x)) + ClientOpinions = CalculateStats(clientOpinionsSamples.GetValues()), + WorldPawns = CalculateStats(worldPawnsSamples.GetValues()), + WindowCount = CalculateStats(windowCountSamples.GetValues()) }; } - private static StatResult CalculateStats(IEnumerable samples) + private static void LogTrimWarningIfNeeded() { - var list = samples.ToList(); - if (list.Count == 0) - return new StatResult { Average = 0, Min = 0, Max = 0, Count = 0 }; - - return new StatResult + if (!hasLoggedLimitWarning && frameTimeSamples?.IsFull == true) { - Average = list.Average(), - Min = list.Min(), - Max = list.Max(), - Count = list.Count - }; + Verse.Log.Warning($"[PerformanceRecorder] Sample buffer full - oldest data will be overwritten to maintain rolling {MaxSampleCount} max sample count"); + hasLoggedLimitWarning = true; + } } private static void OutputToConsole(PerformanceResults results) @@ -227,9 +415,9 @@ private static void OutputToConsole(PerformanceResults results) 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($" Client Opinions: Avg {results.ClientOpinions.Average:F0} Min {results.ClientOpinions.Min:F0} Max {results.ClientOpinions.Max:F0} ({results.ClientOpinions.Count} samples)"); + sb.AppendLine($" World Pawns: Avg {results.WorldPawns.Average:F0} Min {results.WorldPawns.Min:F0} Max {results.WorldPawns.Max:F0} ({results.WorldPawns.Count} samples)"); + sb.AppendLine($" Window Count: Avg {results.WindowCount.Average:F0} Min {results.WindowCount.Min:F0} Max {results.WindowCount.Max:F0} ({results.WindowCount.Count} samples)"); sb.AppendLine(); sb.AppendLine("-- END RECORDING RESULTS --"); @@ -265,8 +453,6 @@ private static void OutputToFile(PerformanceResults results) sb.AppendLine(); - - sb.AppendLine("MISC METRICS"); sb.AppendLine("------------"); AppendDetailedStats(sb, "Client Opinions", results.ClientOpinions); @@ -302,148 +488,106 @@ private static void OutputToFile(PerformanceResults results) } } - private static void AppendDetailedStats(StringBuilder sb, string name, StatResult stats) + private static void RecordNonPerformanceMetrics() { - sb.AppendLine($"{name,-25} Avg: {stats.Average,8:F2} Min: {stats.Min,8:F2} Max: {stats.Max,8:F2} Samples: {stats.Count,6:N0}"); - } + lastNonPerfMetricsFrameCount = recordingFrameCount; - 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) + if (Multiplayer.Client != null) { - StartRecording(); + bufferedChangesSamples.Add(SyncFieldUtil.bufferedChanges?.Sum(kv => kv.Value?.Count ?? 0) ?? 0); + receivedCmdsSamples.Add(Multiplayer.session?.receivedCmds ?? 0); + sentCmdsSamples.Add(Multiplayer.session?.remoteSentCmds ?? 0); + worldCmdsSamples.Add(Multiplayer.AsyncWorldTime?.cmds?.Count ?? 0); + clientOpinionsSamples.Add(Multiplayer.game?.sync?.knownClientOpinions?.Count ?? 0); } - - GUI.color = !isRecording ? Color.gray : Color.white; - - if (Widgets.ButtonText(stopRect, "Stop") && isRecording) - { - StopRecording(); - } - - - UnityEngine.GUI.color = Color.white; - return buttonHeight + spacing; + worldPawnsSamples.Add(Find.WorldPawns?.AllPawnsAliveOrDead?.Count ?? 0); + windowCountSamples.Add(Find.WindowStack?.windows?.Count ?? 0); } +} - // 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; - } +#region PerformanceResults and StatResult - return y - startY + SectionSpacing; - } +internal struct StatResult +{ + public float Average; + public int Count; + public float Max; + public float Min; } -public class PerformanceResults +internal class PerformanceResults { + public StatResult BufferedChanges; + // System + public StatResult ClientOpinions; + + public StatResult DeltaTime; public TimeSpan Duration; + public StatResult FPS; public int FrameCount; - public DateTime StartTime; - // Performance public StatResult FrameTime; - public StatResult TickTime; - public StatResult DeltaTime; - public StatResult FPS; - public StatResult TPS; + + public StatResult MapCmds; public StatResult NormalizedTPS; + public StatResult ReceivedCmds; + public StatResult SentCmds; public StatResult ServerTPT; - + public DateTime StartTime; + public StatResult TickTime; // 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 TPS; public StatResult WindowCount; + public StatResult WorldCmds; + public StatResult WorldPawns; } +#endregion PerformanceResults and StatResult + +#region CircularBuffer -public struct StatResult +internal class CircularBuffer { - public float Average; - public float Min; - public float Max; - public int Count; + private readonly T[] buffer; + private readonly int capacity; + private int count = 0; + private int head = 0; + public CircularBuffer(int capacity) + { + this.capacity = capacity; + this.buffer = new T[capacity]; + } + + public int Count => count; + + public bool IsFull => count == capacity; + + public void Add(T item) + { + buffer[head] = item; + head = (head + 1) % capacity; + if (count < capacity) + count++; + } + + public void Clear() + { + head = 0; + count = 0; + Array.Clear(buffer, 0, capacity); + } + + public IEnumerable GetValues() + { + if (count == 0) yield break; + + int start = count < capacity ? 0 : head; + for (int i = 0; i < count; i++) + { + yield return buffer[(start + i) % capacity]; + } + } } +#endregion CircularBuffer diff --git a/Source/Client/UI/DebugPanel/SyncDebugPanel.cs b/Source/Client/UI/DebugPanel/SyncDebugPanel.cs index 707d58e1..d63c7265 100644 --- a/Source/Client/UI/DebugPanel/SyncDebugPanel.cs +++ b/Source/Client/UI/DebugPanel/SyncDebugPanel.cs @@ -276,16 +276,44 @@ private static float DrawPerformanceRecorderSection(float x, float y, float widt } /// - /// Draw performance recorder control buttons + /// Draw performance recorder control buttons and settings /// 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); + var lineHeight = 18f; + float currentY = y; + + if (!PerformanceRecorder.IsRecording) + { + using (MpStyle.Set(GameFont.Tiny).Set(Color.white).Set(TextAnchor.MiddleLeft)) + { + float sampleCount = PerformanceRecorder.MaxSampleCountSetting; + var sampleCountLabelRect = new Rect(x, currentY, width, lineHeight); + Widgets.Label(sampleCountLabelRect, $"Max samples: {sampleCount}"); + currentY+= lineHeight + 1; + + var sampleCountSliderRect = new Rect(x, currentY, width, lineHeight); + sampleCount = Widgets.HorizontalSlider(sampleCountSliderRect, sampleCount, 1000f, 500000f, roundTo: 0); + PerformanceRecorder.MaxSampleCountSetting = (int)sampleCount; + currentY += lineHeight + 1; + + float nonPerfInterval = PerformanceRecorder.NonPerfFrameIntervalSetting; + var nonPerfLabelRect = new Rect(x, currentY, width, lineHeight); + Widgets.Label(nonPerfLabelRect, $"Non-performance metric sample interval: {nonPerfInterval}"); + currentY += lineHeight + 1; + + var nonPerfSliderRect = new Rect(x, currentY, width, lineHeight); + nonPerfInterval = Widgets.HorizontalSlider(nonPerfSliderRect, nonPerfInterval, 1f, 300f, roundTo: 0); + PerformanceRecorder.NonPerfFrameIntervalSetting = (int)nonPerfInterval; + currentY += lineHeight + 1; + } + } + + var startRect = new Rect(x, currentY, buttonWidth, buttonHeight); + var stopRect = new Rect(x + buttonWidth + spacing, currentY, buttonWidth, buttonHeight); GUI.color = PerformanceRecorder.IsRecording ? Color.gray : Color.white; @@ -302,7 +330,8 @@ private static float DrawRecorderControls(float x, float y, float width) } GUI.color = Color.white; - return buttonHeight + spacing; + currentY += buttonHeight + spacing; + return currentY - y; } /// diff --git a/Source/Client/UI/Layouter.cs b/Source/Client/UI/Layouter.cs index a65664d5..e4a74681 100644 --- a/Source/Client/UI/Layouter.cs +++ b/Source/Client/UI/Layouter.cs @@ -6,6 +6,7 @@ namespace Multiplayer.Client; +#nullable enable public static class Layouter { #region Data diff --git a/Source/Client/Windows/ConnectingWindow.cs b/Source/Client/Windows/ConnectingWindow.cs index 6b40c8e4..804738a7 100644 --- a/Source/Client/Windows/ConnectingWindow.cs +++ b/Source/Client/Windows/ConnectingWindow.cs @@ -41,25 +41,75 @@ public override void DoWindowContents(Rect inRect) { string label; - if (Multiplayer.Client?.StateObj is ClientLoadingState { subState: LoadingState.Waiting }) - label = "MpWaitingForGameData".Translate() + MpUI.FixedEllipsis(); - else if (Multiplayer.Client?.StateObj is ClientLoadingState { subState: LoadingState.Downloading }) - label = "MpDownloading".Translate(Multiplayer.Client.FragmentProgress); - else - label = result ?? (ConnectingString + MpUI.FixedEllipsis()); + switch (Multiplayer.Client?.StateObj) + { + case ClientLoadingState { subState: LoadingState.Waiting }: + label = "MpWaitingForGameData".Translate() + MpUI.FixedEllipsis(); + break; + case ClientLoadingState { subState: LoadingState.Downloading, WorldExpectedSize: 0 } state: + label = "MpDownloading".Translate(); + label += $"\n{state.WorldReceivedSize / 1000}KB"; + break; + case ClientLoadingState { subState: LoadingState.Downloading } state: + label = "MpDownloading".Translate() + $" ({state.DownloadProgressPercent}%)"; + var leftToDownloadKBps = (state.WorldExpectedSize - state.WorldReceivedSize) / 1000; + if (state.DownloadSpeedKBps != 0) + { + var timeLeftSecs = leftToDownloadKBps / state.DownloadSpeedKBps; + label += + $"\n{timeLeftSecs}s – "; + } + else + label += "\n"; + + label += + $"{state.WorldReceivedSize / 1000}/{state.WorldExpectedSize / 1000} KB ({state.DownloadSpeedKBps} KB/s)"; + break; + default: + label = result ?? (ConnectingString + MpUI.FixedEllipsis()); + break; + } const float buttonHeight = 40f; const float buttonWidth = 120f; - Rect textRect = inRect; - textRect.yMax -= (buttonHeight + 10f); - Text.Anchor = TextAnchor.MiddleCenter; + var isDownloading = Multiplayer.Client?.StateObj is ClientLoadingState { subState: LoadingState.Downloading }; + + Rect textRect = new Rect(inRect); + if (isDownloading) + { + var textSize = Text.CalcSize(label); + textRect.height = textSize.y; + } else + textRect.height = 60f; + Text.Anchor = TextAnchor.MiddleCenter; Widgets.Label(textRect, label); Text.Anchor = TextAnchor.UpperLeft; - Rect buttonRect = new Rect((inRect.width - buttonWidth) / 2f, inRect.height - buttonHeight - 10f, buttonWidth, buttonHeight); - if (Widgets.ButtonText(buttonRect, "CancelButton".Translate(), true, false, true)) + Rect buttonRect = new Rect((inRect.width - buttonWidth) / 2f, inRect.yMax - buttonHeight - 10f, buttonWidth, buttonHeight); + if (Multiplayer.Client?.StateObj is ClientLoadingState { subState: LoadingState.Downloading } state2) + { + Rect progressBarRect = new Rect(inRect) + { + y = textRect.yMax + 10f, + height = 30f + }; + buttonRect.y = progressBarRect.yMax + 10f; + buttonRect.height = buttonHeight; + var oldHeight = inRect.height; + inRect.yMax = buttonRect.yMax + 10f; + windowRect.height += inRect.height - oldHeight; + + Widgets.FillableBar(progressBarRect, state2.DownloadProgress, Widgets.BarFullTexHor, + Widgets.DefaultBarBgTex, doBorder: true); + } + else + { + windowRect.height = InitialSize.y; + } + + if (Widgets.ButtonText(buttonRect, "CancelButton".Translate(), true, false)) { Close(); } @@ -82,31 +132,18 @@ public class RejoiningWindow : BaseConnectingWindow protected override string ConnectingString => "MpJoining".Translate(); } - public class ConnectingWindow : BaseConnectingWindow + public class ConnectingWindow(string address, int port) : BaseConnectingWindow { protected override string ConnectingString => string.Format("MpConnectingTo".Translate("{0}", port), address); - - private string address; - private int port; - - public ConnectingWindow(string address, int port) - { - this.address = address; - this.port = port; - } } - public class SteamConnectingWindow : BaseConnectingWindow + public class SteamConnectingWindow(CSteamID hostId) : BaseConnectingWindow { - protected override string ConnectingString => (hostUsername.NullOrEmpty() ? "" : $"{"MpSteamConnectingTo".Translate(hostUsername)}\n") + "MpSteamConnectingWaiting".Translate(); - - public string hostUsername; + protected override string ConnectingString => + (hostUsername.NullOrEmpty() ? "" : $"{"MpSteamConnectingTo".Translate(hostUsername)}\n") + + "MpSteamConnectingWaiting".Translate(); - public SteamConnectingWindow(CSteamID hostId) - { - hostUsername = SteamFriends.GetFriendPersonaName(hostId); - } + public string hostUsername = SteamFriends.GetFriendPersonaName(hostId); } - } diff --git a/Source/Client/Windows/ModCompatWindow.cs b/Source/Client/Windows/ModCompatWindow.cs index f820a8ca..656aff6a 100644 --- a/Source/Client/Windows/ModCompatWindow.cs +++ b/Source/Client/Windows/ModCompatWindow.cs @@ -412,13 +412,13 @@ static IEnumerable Transpiler(IEnumerable inst var endGroupMethod = AccessTools.Method(typeof(Widgets), nameof(Widgets.EndGroup)); - var saveLoadListString = list.First(i => i.operand == "SaveLoadList"); + var saveLoadListString = list.First(i => i.operand as string == "SaveLoadList"); // WidgetRow is not stored as a local, only staying on stack. We need to duplicate it before its last use so we can use it as well. var dupInst = new CodeInstruction(OpCodes.Dup); saveLoadListString.MoveLabelsTo(dupInst); list.Insert(list.IndexOf(saveLoadListString), dupInst); - var endGroupCall = list.First(i => i.operand == endGroupMethod); + var endGroupCall = list.First(i => i.operand as MethodInfo == endGroupMethod); var ldarg = new CodeInstruction(OpCodes.Ldarg_0); endGroupCall.MoveLabelsTo(ldarg); diff --git a/Source/Common/ByteReader.cs b/Source/Common/ByteReader.cs index 4baae858..4e87dbe4 100644 --- a/Source/Common/ByteReader.cs +++ b/Source/Common/ByteReader.cs @@ -8,11 +8,10 @@ public class ByteReader const int DefaultMaxStringLen = 32767; private readonly byte[] array; - private int index; public object? context; public int Length => array.Length; - public int Position => index; + public int Position { get; private set; } public int Left => Length - Position; public ByteReader(byte[] array) @@ -20,7 +19,7 @@ public ByteReader(byte[] array) this.array = array; } - public virtual byte PeekByte() => array[index]; + public virtual byte PeekByte() => array[Position]; public virtual byte ReadByte() => array[IncrementIndex(1)]; @@ -54,8 +53,8 @@ public ByteReader(byte[] array) if (bytes > maxLen) throw new ReaderException($"String too long ({bytes}>{maxLen})"); - string result = Encoding.UTF8.GetString(array, index, bytes); - index += bytes; + string result = Encoding.UTF8.GetString(array, Position, bytes); + Position += bytes; return result; } @@ -69,8 +68,8 @@ public virtual string ReadString(int maxLen = DefaultMaxStringLen) if (bytes > maxLen) throw new ReaderException($"String too long ({bytes}>{maxLen})"); - string result = Encoding.UTF8.GetString(array, index, bytes); - index += bytes; + string result = Encoding.UTF8.GetString(array, Position, bytes); + Position += bytes; return result; } @@ -182,22 +181,22 @@ public virtual T ReadEnum() where T : Enum private int IncrementIndex(int size) { - int i = index; - index += size; + int i = Position; + Position += size; return i; } public void Seek(int position) { - index = position; + Position = position; } - } - public class ReaderException : Exception - { - public ReaderException(string msg) : base(msg) + internal byte[] GetBuffer() { + return array; } } + public class ReaderException(string msg) : Exception(msg); + } diff --git a/Source/Common/ChatCommands.cs b/Source/Common/ChatCommands.cs index 09136f2c..48b9328a 100644 --- a/Source/Common/ChatCommands.cs +++ b/Source/Common/ChatCommands.cs @@ -4,7 +4,7 @@ public abstract class ChatCmdHandler { public bool requiresHost; - public MultiplayerServer Server => MultiplayerServer.instance; + public MultiplayerServer Server => MultiplayerServer.instance!; public abstract void Handle(IChatSource source, string[] args); diff --git a/Source/Common/Native.cs b/Source/Common/Native.cs index c04d0bef..e2c4959c 100644 --- a/Source/Common/Native.cs +++ b/Source/Common/Native.cs @@ -120,10 +120,10 @@ public static bool GetMethodAggressiveInlining(long addr) const string MonoOSX = "libmonobdwgc-2.0.dylib"; [DllImport(MonoLinux, EntryPoint = "mono_dllmap_insert")] - private static extern void mono_dllmap_insert_linux(IntPtr assembly, string dll, string func, string tdll, string tfunc); + private static extern void mono_dllmap_insert_linux(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); [DllImport(MonoOSX, EntryPoint = "mono_dllmap_insert")] - private static extern void mono_dllmap_insert_osx(IntPtr assembly, string dll, string func, string tdll, string tfunc); + private static extern void mono_dllmap_insert_osx(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); [DllImport(MonoWindows)] public static extern IntPtr mono_jit_info_table_find(IntPtr domain, IntPtr addr); diff --git a/Source/Common/Networking/ConnectionBase.cs b/Source/Common/Networking/ConnectionBase.cs index 5ce70d62..787a4e20 100644 --- a/Source/Common/Networking/ConnectionBase.cs +++ b/Source/Common/Networking/ConnectionBase.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace Multiplayer.Common { @@ -11,6 +12,8 @@ public abstract class ConnectionBase public ConnectionStateEnum State { get; private set; } public MpConnectionState? StateObj { get; private set; } + // If lenient is set, reliable packets without handlers are ignored instead of throwing an exception. + // This is set during rejoining and is usually caused by connection state mismatch. public bool Lenient { get; set; } public T? GetState() where T : MpConnectionState => (T?)StateObj; @@ -45,8 +48,8 @@ public virtual void Send(Packets id, byte[] message, bool reliable = true) if (State == ConnectionStateEnum.Disconnected) return; - if (message.Length > FragmentSize) - throw new PacketSendException($"Packet {id} too big for sending ({message.Length}>{FragmentSize})"); + if (message.Length > MaxSinglePacketSize) + throw new PacketSendException($"Packet {id} too big for sending ({message.Length}>{MaxSinglePacketSize})"); byte[] full = new byte[1 + message.Length]; full[0] = (byte)(Convert.ToByte(id) & 0x3F); @@ -56,12 +59,17 @@ public virtual void Send(Packets id, byte[] message, bool reliable = true) } // Steam doesn't like messages bigger than a megabyte - public const int FragmentSize = 65_536; + public const int MaxSinglePacketSize = 65_536; + // We can send a single packet up to MaxSinglePacketSize but when specifically sending a fragmented packet, + // use smaller sizes so that we have more control over them. + public const int MaxFragmentPacketSize = 1024; public const int MaxPacketSize = 33_554_432; private const int FragNone = 0x0; private const int FragMore = 0x40; - private const int FragEnd = 0x80; + private const int FragEnd = 0x80; + + private byte sendFragId; // All fragmented packets need to be sent from the same thread public void SendFragmented(Packets id, byte[] message) @@ -69,25 +77,48 @@ public void SendFragmented(Packets id, byte[] message) if (State == ConnectionStateEnum.Disconnected) return; + // +1 for Send metadata's overhead + if (message.Length + 1 <= MaxFragmentPacketSize) + { + Send(id, message); + return; + } + + var fragId = sendFragId++; + // every packet has an additional 2 bytes of overhead + const int maxFragmentSize = MaxFragmentPacketSize - 2; + // the first packet has an additional 6 bytes of overhead + var totalLength = message.Length + 6; + // Divide rounding up + var fragParts = (totalLength + maxFragmentSize - 1) / maxFragmentSize; int read = 0; + var writer = new ByteWriter(MaxFragmentPacketSize); while (read < message.Length) { - int len = Math.Min(FragmentSize, message.Length - read); + // 1st packet contains 6 bytes of extra metadata. + int len = read == 0 + ? Math.Min(maxFragmentSize - 6, message.Length - read) + : Math.Min(maxFragmentSize, message.Length - read); int fragState = (read + len >= message.Length) ? FragEnd : FragMore; byte headerByte = (byte)((Convert.ToByte(id) & 0x3F) | fragState); - var writer = new ByteWriter(1 + 4 + len); - // Write the packet id and fragment state: MORE or END writer.WriteByte(headerByte); + writer.WriteByte(fragId); // Send the message length with the first packet - if (read == 0) writer.WriteInt32(message.Length); + if (read == 0) + { + writer.WriteUShort(Convert.ToUInt16(fragParts)); + writer.WriteUInt32(Convert.ToUInt32(message.Length)); + } // Copy the message fragment writer.WriteFrom(message, read, len); - SendRaw(writer.ToArray()); + // SendRaw copies this data so we can freely clear the writer after it executes. + SendRaw(writer.ToArray(), reliable: true); + writer.SetLength(0); read += len; } @@ -115,10 +146,8 @@ public virtual void HandleReceiveRaw(ByteReader data, bool reliable) HandleReceiveMsg(msgId, fragState, data, reliable); } - private ByteWriter? fragmented; - private int fullSize; // Only for UI - - public int FragmentProgress => (fragmented?.Position * 100 / fullSize) ?? 0; + private const int MaxFragmentedPackets = 1; + private readonly List<(/* fragId */ byte, FragmentedPacket)> fragments = []; protected virtual void HandleReceiveMsg(int msgId, int fragState, ByteReader reader, bool reliable) { @@ -126,45 +155,70 @@ protected virtual void HandleReceiveMsg(int msgId, int fragState, ByteReader rea throw new PacketReadException($"Bad packet id {msgId}"); Packets packetType = (Packets)msgId; - ServerLog.Verbose($"Received packet {this}: {packetType}"); + if (reader.Left > MaxSinglePacketSize) + throw new PacketReadException($"Packet {packetType} too big {reader.Left}>{MaxSinglePacketSize}"); var handler = StateObj?.GetPacketHandler(packetType); if (handler == null) { if (reliable && !Lenient) throw new PacketReadException($"No handler for packet {packetType} in state {State}"); - + ServerLog.Error($"No handler for packet {packetType} in state {State}"); return; } - if (fragState != FragNone && fragmented == null) - fullSize = reader.ReadInt32(); - - if (reader.Left > FragmentSize) - throw new PacketReadException($"Packet {packetType} too big {reader.Left}>{FragmentSize}"); + if (fragState == FragNone) handler.Method(StateObj, reader); + else HandleReceiveFragment(reader, packetType, handler); + } - if (fragState == FragNone) - { - handler.Method.Invoke(StateObj, reader); - } - else if (!handler.Fragment) + private void HandleReceiveFragment(ByteReader reader, Packets packetType, PacketHandlerInfo handler) + { + if (!handler.Fragment) throw new PacketReadException($"Packet {packetType} can't be fragmented"); + if (reader.Left > MaxFragmentPacketSize) + throw new PacketReadException($"Packet fragment {packetType} too big {reader.Left}>{MaxFragmentPacketSize}"); + + var fragId = reader.ReadByte(); + var fragIndex = fragments.FindIndex(frag => frag.Item1 == fragId); + FragmentedPacket fragPacket; + if (fragIndex == -1) { - throw new PacketReadException($"Packet {packetType} can't be fragmented"); + if (fragments.Count >= MaxFragmentedPackets) + throw new PacketReadException( + $"High number of fragmented packets at once! {fragments.Count}/{MaxFragmentedPackets}. This will likely cause issues. Dropping the just received fragmented packet (packet type: {packetType}, fragment id: {fragId})."); + + var expectedParts = reader.ReadUShort(); + var expectedSize = reader.ReadUInt32(); + if (expectedParts < 2) + ServerLog.Error($"Received fragmented packet with only {expectedParts} expected parts (packet type: {packetType}, fragment id: {fragId}, expected size: {expectedSize})."); + if (expectedSize > MaxPacketSize) + throw new PacketReadException($"Full packet {packetType} too big {expectedSize}>{MaxPacketSize}"); + + fragPacket = FragmentedPacket.Create(packetType, expectedParts, expectedSize); + fragIndex = fragments.Count; + fragments.Add((fragId, fragPacket)); } else - { - fragmented ??= new ByteWriter(reader.Left); - fragmented.WriteRaw(reader.ReadRaw(reader.Left)); + (_, fragPacket) = fragments[fragIndex]; - if (fragmented.Position > MaxPacketSize) - throw new PacketReadException($"Full packet {packetType} too big {fragmented.Position}>{MaxPacketSize}"); + if (fragPacket.Id != packetType) + throw new PacketReadException( + $"Received fragment part with different packet id! {fragPacket.Id} != {packetType}"); - if (fragState == FragEnd) - { - handler.Method.Invoke(StateObj, new ByteReader(fragmented.ToArray())); - fragmented = null; - } + fragPacket.Data.Write(reader.GetBuffer(), reader.Position, reader.Left); + fragPacket.ReceivedSize += Convert.ToUInt32(reader.Left); + fragPacket.ReceivedPartsCount++; + + if (fragPacket.ReceivedPartsCount < fragPacket.ExpectedPartsCount) + { + handler.FragmentHandler?.Invoke(StateObj, fragPacket); + return; } + + if (fragPacket.ReceivedSize != fragPacket.ExpectedSize) + throw new PacketReadException($"Fragmented packet {packetType} (fragId {fragId}) recombined with different than expected size: {fragPacket.ReceivedSize} != {fragPacket.ExpectedSize}"); + + fragments.RemoveAt(fragIndex); + handler.Method(StateObj, new ByteReader(fragPacket.Data.GetBuffer())); } public abstract void Close(MpDisconnectReason reason, byte[]? data = null); diff --git a/Source/Common/Networking/FragmentedPacket.cs b/Source/Common/Networking/FragmentedPacket.cs new file mode 100644 index 00000000..03debfa4 --- /dev/null +++ b/Source/Common/Networking/FragmentedPacket.cs @@ -0,0 +1,20 @@ +using System; +using System.IO; + +namespace Multiplayer.Common; + +public record FragmentedPacket( + Packets Id, + MemoryStream Data, + ushort ReceivedPartsCount, + ushort ExpectedPartsCount, + uint ReceivedSize, + uint ExpectedSize) +{ + internal MemoryStream Data { get; } = Data; + public uint ReceivedSize { get; internal set; } = ReceivedSize; + public ushort ReceivedPartsCount { get; internal set; } = ReceivedPartsCount; + + internal static FragmentedPacket Create(Packets id, ushort expectedPartsCount, uint expectedSize) => new(id, + new MemoryStream(Convert.ToInt32(expectedSize)), ReceivedPartsCount: 0, expectedPartsCount, 0, expectedSize); +} diff --git a/Source/Common/Networking/MpConnectionState.cs b/Source/Common/Networking/MpConnectionState.cs index d53a4e28..99132211 100644 --- a/Source/Common/Networking/MpConnectionState.cs +++ b/Source/Common/Networking/MpConnectionState.cs @@ -35,18 +35,69 @@ public static void SetImplementation(ConnectionStateEnum state, Type type) foreach (var method in type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)) { var attr = method.GetAttribute(); - if (attr == null) - continue; + if (attr != null) RegisterPacketHandler(state, method, attr); - if (method.GetParameters().Length != 1 || method.GetParameters()[0].ParameterType != typeof(ByteReader)) - throw new Exception($"Bad packet handler signature for {method}"); + var attr2 = method.GetAttribute(); + if (attr2 != null) RegisterFragmentedPacketHandler(state, method, attr2); + } + + for (var packetId = 0; packetId < packetHandlers.GetLength(1); packetId++) + { + var handlerInfo = packetHandlers[(int)state, packetId]; + if (handlerInfo is { Method: null }) + { + throw new Exception( + $"Packet handler for {state}:{(Packets)packetId} only has a handler for fragments!"); + } + } + } - if (packetHandlers[(int)state, (int)attr.packet] != null) - throw new Exception($"Packet {state}:{type} already has a handler"); + private static void RegisterPacketHandler(ConnectionStateEnum state, MethodInfo method, PacketHandlerAttribute attr) + { + if (method.GetParameters().Length != 1 || method.GetParameters()[0].ParameterType != typeof(ByteReader)) + throw new Exception($"Bad packet handler signature for {method}"); + var packetHandlerInfo = packetHandlers[(int)state, (int)attr.packet]; + if (packetHandlerInfo == null) + { packetHandlers[(int)state, (int)attr.packet] = new PacketHandlerInfo(MethodInvoker.GetHandler(method), attr.allowFragmented); + return; + } + if (packetHandlerInfo.Method != null) + throw new Exception($"Packet {state}:{attr.packet} already has a handler"); + + if (!attr.allowFragmented && packetHandlerInfo.FragmentHandler != null) + throw new Exception($"Packet {state}:{attr.packet} has a fragment handler despite not being allowed to"); + + packetHandlers[(int)state, (int)attr.packet] = packetHandlerInfo with + { + Method = MethodInvoker.GetHandler(method), Fragment = attr.allowFragmented + }; + } + + private static void RegisterFragmentedPacketHandler(ConnectionStateEnum state, MethodInfo method, FragmentedPacketHandlerAttribute attr) + { + if (method.GetParameters().Length != 1 || method.GetParameters()[0].ParameterType != typeof(FragmentedPacket)) + throw new Exception($"Bad packet handler signature for {method}"); + + var packetHandlerInfo = packetHandlers[(int)state, (int)attr.packet]; + if (packetHandlerInfo == null) + { + packetHandlers[(int)state, (int)attr.packet] = + new PacketHandlerInfo(null!, false, MethodInvoker.GetHandler(method)); + return; } + if (packetHandlerInfo.FragmentHandler != null) + throw new Exception($"Packet {state}:{attr.packet} already has a fragment handler"); + + if (!packetHandlerInfo.Fragment) + throw new Exception($"Packet {state}:{attr.packet} has a fragment handler despite not being allowed to"); + + packetHandlers[(int)state, (int)attr.packet] = packetHandlerInfo with + { + FragmentHandler = MethodInvoker.GetHandler(method) + }; } } diff --git a/Source/Common/Networking/NetworkingLiteNet.cs b/Source/Common/Networking/NetworkingLiteNet.cs index 530caf2c..289b3c62 100644 --- a/Source/Common/Networking/NetworkingLiteNet.cs +++ b/Source/Common/Networking/NetworkingLiteNet.cs @@ -4,17 +4,8 @@ namespace Multiplayer.Common { - public class MpServerNetListener : INetEventListener + public class MpServerNetListener(MultiplayerServer server, bool arbiter) : INetEventListener { - private MultiplayerServer server; - private bool arbiter; - - public MpServerNetListener(MultiplayerServer server, bool arbiter) - { - this.server = server; - this.arbiter = arbiter; - } - public void OnConnectionRequest(ConnectionRequest req) { var result = server.playerManager.OnPreConnect(req.RemoteEndPoint.Address); diff --git a/Source/Common/Networking/PacketHandlerAttribute.cs b/Source/Common/Networking/PacketHandlerAttribute.cs index be678ff4..904dfcd3 100644 --- a/Source/Common/Networking/PacketHandlerAttribute.cs +++ b/Source/Common/Networking/PacketHandlerAttribute.cs @@ -5,11 +5,19 @@ namespace Multiplayer.Common { [MeansImplicitUse] + [AttributeUsage(AttributeTargets.Method)] public class PacketHandlerAttribute(Packets packet, bool allowFragmented = false) : Attribute { public readonly Packets packet = packet; public readonly bool allowFragmented = allowFragmented; } - public record PacketHandlerInfo(FastInvokeHandler Method, bool Fragment); + [MeansImplicitUse] + [AttributeUsage(AttributeTargets.Method)] + public class FragmentedPacketHandlerAttribute(Packets packet) : Attribute + { + public readonly Packets packet = packet; + } + + public record PacketHandlerInfo(FastInvokeHandler Method, bool Fragment, FastInvokeHandler? FragmentHandler = null); } diff --git a/Source/Common/Syncing/Logger/LoggingByteWriter.cs b/Source/Common/Syncing/Logger/LoggingByteWriter.cs index b1bf8530..b8d2eb26 100644 --- a/Source/Common/Syncing/Logger/LoggingByteWriter.cs +++ b/Source/Common/Syncing/Logger/LoggingByteWriter.cs @@ -48,14 +48,14 @@ public override void WriteLong(long val) base.WriteLong(val); } - public override void WritePrefixedBytes(byte[] bytes) + public override void WritePrefixedBytes(byte[]? bytes) { Log.Enter("byte[]"); base.WritePrefixedBytes(bytes); Log.Exit(); } - public override ByteWriter WriteString(string s) + public override ByteWriter WriteString(string? s) { Log.Enter("string: " + s); base.WriteString(s); diff --git a/Source/Common/Util/ServerLog.cs b/Source/Common/Util/ServerLog.cs index a5ea0d56..c5e124c7 100644 --- a/Source/Common/Util/ServerLog.cs +++ b/Source/Common/Util/ServerLog.cs @@ -5,8 +5,8 @@ namespace Multiplayer.Common { public class ServerLog : INetLogger { - public static Action info; - public static Action error; + public static Action? info; + public static Action? error; public static bool detailEnabled; public static bool verboseEnabled; diff --git a/Source/Common/Version.cs b/Source/Common/Version.cs index 3844aa44..4f62b1da 100644 --- a/Source/Common/Version.cs +++ b/Source/Common/Version.cs @@ -2,8 +2,8 @@ namespace Multiplayer.Common { public static class MpVersion { - public const string Version = "0.10.6"; - public const int Protocol = 48; + public const string Version = "0.11.0"; + public const int Protocol = 49; public const string ApiAssemblyName = "0MultiplayerAPI";