diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 85157fe510..16531c1600 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -10,6 +10,8 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Added +- Added automatic `NetCodeConfig` configuration for hybrid mode. When Netcode for Entities is installed and a registered network prefab has a `GhostObject`, the settings hybrid mode requires are corrected automatically and the Netcode for Entities tick rates are driven from `NetworkConfig.TickRate`. The recommended snapshot, interpolation and transport values are applied once, and can be restored from Project Settings > Multiplayer > Netcode for GameObjects. + ### Changed @@ -32,6 +34,7 @@ Additional documentation and release notes are available at [Multiplayer Documen - Issue with not being able to spawn initially disabled in-scene placed objects. (#4093) - Issue with pre-instantiated network prefab instances being marked as in-scene placed. Now pre-instantiated network prefabs are dynamically spawned. (#4093) - Issue where a user could spawn runtime created `NetworkObject` that has a GlobalObjectIdHash of zero. These are not valid instances and will no longer be allowed to spawn. (#4093) +- Issue where the hybrid mode `NetCodeConfig` validation messages were not interpolated and did not check that automatic bootstrapping was disabled. ### Security diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs new file mode 100644 index 0000000000..47fce4864d --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs @@ -0,0 +1,302 @@ +#if UNIFIED_NETCODE +using Unity.NetCode; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace Unity.Netcode.GameObjects.Editor.Configuration +{ + /// + /// Keeps the project's aligned with NGO needs whenever the project is running in + /// hybrid mode (N4E installed and at least one registered NGO network prefab has a GhostObject component). + /// + /// + /// This does not create . This finds the one N4E created and modifies it. + /// + internal static class HybridNetcodeConfigApplier + { + [InitializeOnLoadMethod] + private static void OnApplicationStart() + { + // Cross-assembly ordering between the two is not a documented contract. + // Defer rather than racing it. + EditorApplication.delayCall += OnDelayCall; + + // A NetworkManager in an unopened scene is not loaded, so its tick rate cannot be read at this point. + // Rescan when a scene opens to pick it up. + EditorSceneManager.sceneOpened -= OnSceneOpened; + EditorSceneManager.sceneOpened += OnSceneOpened; + } + + private static void OnDelayCall() + { + EditorApplication.delayCall -= OnDelayCall; + Apply(false); + } + + private static void OnSceneOpened(Scene scene, OpenSceneMode mode) + { + Apply(false); + } + + /// + /// Adjusts for NGO hybrid mode. + /// + /// + /// Driven by the button in Project Settings: + /// - When true: it re-applies the full tuned set even if this project has already had it applied once. + /// - When false: default NGO settings are only written once, the first time they are applied. From that + /// point forward, the user's edits are not overwritten. + /// + internal static void Apply(bool applyRecommended) + { + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + return; + } + + var config = ResolveGlobalConfig(); + if (config == null || !IsHybridProject()) + { + return; + } + + var settings = NetcodeForGameObjectsProjectSettings.instance; + var isFirstApply = settings.HybridDefaultsVersion < HybridNetcodeDefaults.Version; + var changed = false; + + if (applyRecommended || isFirstApply) + { + changed = HybridNetcodeDefaults.ApplyRecommended(config, ResolveTickRate(config)); + if (changed) + { + Debug.Log($"[Netcode] Applied the NGO hybrid mode defaults to '{config.name}'. These are tuned for NGO and can be changed freely; they will not be re-applied automatically. Use Project Settings > Multiplayer > Netcode for GameObjects to restore them.", config); + } + + // Recorded even when the config already matched and nothing was written. Leaving it unrecorded would + // make the next domain reload a first application again, which would revert the user's next edit. + settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version; + settings.SaveSettings(); + } + else + { + // Outside the one-shot, only the settings hybrid mode cannot run without are enforced, plus the tick + // rate, which NGO owns. + changed = HybridNetcodeDefaults.ApplyRequired(config); + if (changed) + { + Debug.LogWarning($"[Netcode] Corrected required hybrid mode settings on '{config.name}'. Netcode for GameObjects owns world creation and requires single world hosting, so these two cannot be changed while ghost prefabs are registered.", config); + } + + changed |= HybridNetcodeDefaults.ApplyTickRate(config, ResolveTickRate(config)); + } + + if (!changed) + { + return; + } + + EditorUtility.SetDirty(config); + AssetDatabase.SaveAssetIfDirty(config); + } + + /// + /// True when a registered network prefab carries a ghost. + /// + /// + /// The assets are scanned rather than the loaded s + /// because a manager living in an unopened scene is not loaded and would not be found. A ghost prefab sitting + /// in a list is treated as intent to run hybrid mode even if no manager references that list yet. + /// + internal static bool IsHybridProject() + { + foreach (var guid in AssetDatabase.FindAssets($"t:{nameof(NetworkPrefabsList)}")) + { + var prefabsList = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); + if (HasGhost(prefabsList)) + { + return true; + } + } + + // Prefabs added directly to a NetworkManager never reach a list asset, so the loaded managers still have + // to be checked. + foreach (var networkManager in Resources.FindObjectsOfTypeAll()) + { + if (HasGhost(networkManager)) + { + return true; + } + } + + return false; + } + + /// + /// True when this registers a prefab carrying a ghost, either directly or through + /// one of its assigned assets. + /// + /// The manager to inspect. + /// Whether this manager takes part in hybrid mode. + private static bool HasGhost(NetworkManager networkManager) + { + var prefabs = networkManager == null ? null : networkManager.NetworkConfig?.Prefabs; + if (prefabs == null) + { + return false; + } + + foreach (var prefab in prefabs.Prefabs) + { + if (HasGhost(prefab)) + { + return true; + } + } + + foreach (var prefabsList in prefabs.NetworkPrefabsLists) + { + if (HasGhost(prefabsList)) + { + return true; + } + } + + return false; + } + + /// + /// True when this holds a prefab carrying a ghost. + /// + /// The list to inspect. + /// Whether this list takes part in hybrid mode. + internal static bool HasGhost(NetworkPrefabsList prefabsList) + { + if (prefabsList == null) + { + return false; + } + + foreach (var prefab in prefabsList.PrefabList) + { + if (HasGhost(prefab)) + { + return true; + } + } + + return false; + } + + private static bool HasGhost(NetworkPrefab prefab) + { + return prefab?.Prefab != null + && prefab.Prefab.TryGetComponent(out var networkObject) + && networkObject.HasGhost; + } + + /// + /// Resolves the config N4E considers global, falling back to a project scan when N4E has not assigned one yet. + /// + /// The config to adjust or null if no config exists. + internal static NetCodeConfig ResolveGlobalConfig() + { + if (NetCodeConfig.Global != null) + { + return NetCodeConfig.Global; + } + + var guids = AssetDatabase.FindAssets($"t:{nameof(NetCodeConfig)}"); + return guids.Length == 1 ? AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0])) : null; + } + + /// + /// Returns either the current N4E tick rate or the NGO . + /// If no NetworkManager taking part in hybrid mode is loaded, it returns N4E's tick rate. + /// If one is loaded, then it returns NGO's tick rate. + /// + /// + /// Only managers registering a ghost prefab are considered. A conventional NGO manager running at a different + /// tick rate has no bearing on the interval N4E should synchronize ghosts at. + /// + /// The config, used as the fallback when no NetworkManager can be found. + /// The tick rate to write into the config. + private static uint ResolveTickRate(NetCodeConfig config) + { + var found = 0u; + var diverged = false; + foreach (var networkManager in Resources.FindObjectsOfTypeAll()) + { + if (!HasGhost(networkManager)) + { + continue; + } + + var tickRate = networkManager.NetworkConfig?.TickRate ?? 0u; + if (tickRate == 0) + { + continue; + } + + diverged |= found != 0 && found != tickRate; + found = tickRate; + } + + if (diverged) + { + Debug.LogWarning($"[Netcode] Found hybrid mode {nameof(NetworkManager)}s with differing {nameof(NetworkConfig.TickRate)} values. '{config.name}' has been set to {found}; hybrid mode expects a single tick rate across the network prefabs carrying a ghost.", config); + } + + // Nothing to read from (a prefab-only project, one mid-import, or the manager's scene is not open yet) + // leaves the config as it is. Opening that scene runs this again. + return found != 0 ? found : (uint)config.ClientServerTickRate.SimulationTickRate; + } + } + + /// + /// Re-runs the hybrid config pass when an import could have turned this into a hybrid project. + /// + /// + /// Both a prefab gaining a GhostObject and a prefab list gaining an existing ghost prefab reach hybrid mode, so + /// both imports are watched. + /// + internal class HybridNetcodeConfigPostprocessor : AssetPostprocessor + { + private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) + { + foreach (var assetPath in importedAssets) + { + if (ImportReachesHybridMode(assetPath)) + { + HybridNetcodeConfigApplier.Apply(false); + return; + } + } + } + + /// + /// Cheap check for whether an imported asset could have introduced a ghost, so that the full project scan in + /// is only paid when it might matter. + /// + /// The imported asset. + /// Whether the import is worth a rescan. + private static bool ImportReachesHybridMode(string assetPath) + { + var assetType = AssetDatabase.GetMainAssetTypeAtPath(assetPath); + if (assetType == typeof(GameObject)) + { + var gameObject = AssetDatabase.LoadAssetAtPath(assetPath); + return gameObject != null && gameObject.TryGetComponent(out var networkObject) && networkObject.HasGhost; + } + + if (assetType == typeof(NetworkPrefabsList)) + { + return HybridNetcodeConfigApplier.HasGhost(AssetDatabase.LoadAssetAtPath(assetPath)); + } + + return false; + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta new file mode 100644 index 0000000000..5188468189 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6ee59cdde40fe6846a172aecaff9e8e3 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs index 05f83876bd..d1ec7a1699 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs @@ -35,6 +35,18 @@ private void OnEnable() [SerializeField] public bool GenerateDefaultNetworkPrefabs = true; +#if UNIFIED_NETCODE + /// + /// The hybrid mode default values already applied to this project's NetCodeConfig. + /// + /// + /// Zero means they have never been applied. Persisting this value is what keeps the tuned values a one-shot. + /// For users who deliberately change them, they are not overwritten on the next domain reload. + /// + [SerializeField] + public int HybridDefaultsVersion; +#endif + internal void SaveSettings() { Save(true); diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs index a8b521117c..c55cf4b159 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs @@ -1,5 +1,8 @@ using System.Collections.Generic; using System.IO; +#if UNIFIED_NETCODE +using Unity.NetCode; +#endif using UnityEditor; using UnityEngine; using Directory = UnityEngine.Windows.Directory; @@ -192,6 +195,10 @@ private static void OnGuiHandler(string obj) networkPrefabsPath, GUILayout.Width(s_MaxLabelWidth + 270)); GUILayout.EndVertical(); + +#if UNIFIED_NETCODE + DrawHybridSettings(settings); +#endif } EditorGUILayout.EndFoldoutHeaderGroup(); GUILayout.EndVertical(); @@ -205,6 +212,48 @@ private static void OnGuiHandler(string obj) settings.SaveSettings(); } } + +#if UNIFIED_NETCODE + /// + /// Displays the current state of the project's NetCodeConfig and offers a way to reset back to the + /// NGO default values for anyone who has since changed them. + /// + /// The project settings holding the applied-defaults marker. + private static void DrawHybridSettings(NetcodeForGameObjectsProjectSettings settings) + { + GUILayout.BeginVertical("Box"); + GUILayout.Label("Hybrid (Netcode for Entities)", EditorStyles.boldLabel); + + if (!HybridNetcodeConfigApplier.IsHybridProject()) + { + EditorGUILayout.HelpBox("No registered network prefab has a GhostObject, so this project is not using hybrid mode. Netcode for GameObjects leaves the NetCodeConfig alone until one does.", MessageType.Info); + GUILayout.EndVertical(); + return; + } + + var config = HybridNetcodeConfigApplier.ResolveGlobalConfig(); + if (config == null) + { + EditorGUILayout.HelpBox("No NetCodeConfig could be resolved. Open Project Settings > Multiplayer, which creates one, then return here.", MessageType.Warning); + GUILayout.EndVertical(); + return; + } + + EditorGUILayout.ObjectField(new GUIContent("Applied to", "The NetCodeConfig that Netcode for GameObjects keeps aligned for hybrid mode."), config, typeof(NetCodeConfig), false); + + if (settings.HybridDefaultsVersion < HybridNetcodeDefaults.Version) + { + EditorGUILayout.HelpBox("The Netcode for GameObjects hybrid defaults have not been applied to this config yet.", MessageType.Info); + } + + if (GUILayout.Button(new GUIContent("Apply Recommended Hybrid Defaults", "Restores the snapshot, interpolation and transport values Netcode for GameObjects recommends for hybrid mode, and re-syncs the tick rate from your NetworkManager. Applied automatically once; use this to get back to them after changing them."))) + { + HybridNetcodeConfigApplier.Apply(true); + } + + GUILayout.EndVertical(); + } +#endif } internal class NetcodeSettingsLabel : NetcodeGUISettings diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs new file mode 100644 index 0000000000..1d4f9c636a --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs @@ -0,0 +1,155 @@ +#if UNIFIED_NETCODE +using Unity.NetCode; + +namespace Unity.Netcode +{ + /// + /// The values NGO needs when running in hybrid mode (i.e. Netcode for Entities is + /// installed and at least one registered network prefab carries a ). + /// + /// + /// This lives in the runtime assembly rather than the editor one because + /// is internal to Unity.NetCode, and Unity.Netcode.Runtime is the only NGO assembly it grants InternalsVisibleTo to. + /// Nothing here touches the AssetDatabase; the editor-side applier drives all of it. + /// + internal static class HybridNetcodeDefaults + { + /// + /// Bump whenever changes so that an upgrading project re-applies exactly once. + /// Persisted as NetcodeForGameObjectsProjectSettings.HybridDefaultsVersion. + /// + internal const int Version = 1; + + // Tuned against 2000 GenericPhysicsBallNGO instances in the ngo-examples project. A hybrid ghost costs ~4.87 + // bytes per snapshot, so 15000 carries ~3000 of them at the full tick rate. This is a cap and not a cost: + // below that count it puts no more on the wire than the N4E default would. + internal const int SnapshotPacketSize = 15000; + + // A ceiling on despawn bytes, not a reservation, so unused headroom is free. 0.2 is also N4E's clamp minimum. + internal const float PercentReservedForDespawn = 0.2f; + + // Expressed in milliseconds rather than net ticks deliberately. N4E rounds this up to whole network ticks, so + // it holds >= 50ms of interpolation buffer at any tick rate. The net-tick form does not: 2 net ticks is 66.7ms + // at 30Hz but only 33.3ms at 60Hz, and 33.3ms is the buffer the stress test stuttered at. + internal const uint InterpolationTimeMS = 50; + + internal const float InterpolationDelayMaxDeltaTicksFraction = 0.15f; + internal const float InterpolationTimeScaleMin = 0.9f; + internal const float InterpolationTimeScaleMax = 1.33f; + + // A full snapshot fragments into ~11 datagrams and each fragment consumes a queue slot. + internal const int ClientQueueCapacity = 128; + + /// + /// Applies the two settings hybrid mode cannot run without. + /// + /// The config to correct. + /// True if anything changed. + internal static bool ApplyRequired(NetCodeConfig config) + { + var changed = false; + + // NetworkManager gates the world spin-up, so N4E must not bootstrap worlds on its own. + if (config.EnableClientServerBootstrap != NetCodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap) + { + config.EnableClientServerBootstrap = NetCodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap; + changed = true; + } + + if (config.HostWorldModeSelection != NetCodeConfig.HostWorldMode.SingleWorld) + { + config.HostWorldModeSelection = NetCodeConfig.HostWorldMode.SingleWorld; + changed = true; + } + + return changed; + } + + /// + /// Drives N4E's tick rates from so that ghost transform updates land on + /// the same interval NGO uses for everything else. + /// + /// The config to correct. + /// The owning 's configured tick rate. + /// True if anything changed. + internal static bool ApplyTickRate(NetCodeConfig config, uint tickRate) + { + var rate = (int)tickRate; + if (config.ClientServerTickRate.SimulationTickRate == rate && config.ClientServerTickRate.NetworkTickRate == rate) + { + return false; + } + + // Both are written: leaving NetworkTickRate at 0 would track SimulationTickRate anyway, but writing it + // keeps the two visibly locked in the inspector, which is the invariant InterpolationTimeMS relies on. + config.ClientServerTickRate.SimulationTickRate = rate; + config.ClientServerTickRate.NetworkTickRate = rate; + return true; + } + + /// + /// Applies the full NGO-recommended set: , , and the + /// values tuned against the stress test. + /// + /// The config to correct. + /// The owning 's configured tick rate. + /// True if anything changed. + internal static bool ApplyRecommended(NetCodeConfig config, uint tickRate) + { + var changed = ApplyRequired(config); + changed |= ApplyTickRate(config, tickRate); + + changed |= Set(ref config.GhostSendSystemData.DefaultSnapshotPacketSize, SnapshotPacketSize); + changed |= Set(ref config.GhostSendSystemData.PercentReservedForDespawnMessages, PercentReservedForDespawn); + + // The net-tick form has to be cleared or it wins over the millisecond form. + changed |= Set(ref config.ClientTickRate.InterpolationTimeNetTicks, 0u); + changed |= Set(ref config.ClientTickRate.InterpolationTimeMS, InterpolationTimeMS); + changed |= Set(ref config.ClientTickRate.InterpolationDelayMaxDeltaTicksFraction, InterpolationDelayMaxDeltaTicksFraction); + changed |= Set(ref config.ClientTickRate.InterpolationTimeScaleMin, InterpolationTimeScaleMin); + changed |= Set(ref config.ClientTickRate.InterpolationTimeScaleMax, InterpolationTimeScaleMax); + + changed |= Set(ref config.ClientSendQueueCapacity, ClientQueueCapacity); + changed |= Set(ref config.ClientReceiveQueueCapacity, ClientQueueCapacity); + + return changed; + } + + /// + /// Reports the first required setting that is still wrong, for the runtime start-up check. + /// + /// The config to inspect. + /// Populated with a user-facing description of what is wrong. + /// True when cannot support hybrid mode as-is. + internal static bool IsMissingRequired(NetCodeConfig config, out string reason) + { + if (config.HostWorldModeSelection != NetCodeConfig.HostWorldMode.SingleWorld) + { + reason = $"{nameof(NetCodeConfig.HostWorldModeSelection)} must be {nameof(NetCodeConfig.HostWorldMode.SingleWorld)} but is {config.HostWorldModeSelection}"; + return true; + } + + if (config.EnableClientServerBootstrap != NetCodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap) + { + reason = $"{nameof(NetCodeConfig.EnableClientServerBootstrap)} must be {nameof(NetCodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap)} because {nameof(NetworkManager)} owns world creation in hybrid mode"; + return true; + } + + reason = null; + return false; + } + + private static bool Set(ref T target, T value) + where T : System.IEquatable + { + if (target.Equals(value)) + { + return false; + } + + target = value; + return true; + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs.meta b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs.meta new file mode 100644 index 0000000000..243ea091b2 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6c63255817e13664ea56764bbb3e76c7 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 1b702e76fc..941659ec85 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1412,14 +1412,19 @@ private bool UnifiedIsConfiguredCorrectly() { if (NetCodeConfig.Global == null) { - Log.Error(new Context(LogLevel.Error, "You must create a {nameof(NetCodeConfig)} and set it to a single world in order to run in hybrid mode!").AddTag("Unified")); + Log.Error(new Context(LogLevel.Error, $"You must create a {nameof(NetCodeConfig)} and set it to a single world in order to run in hybrid mode!").AddTag("Unified")); return false; } - if (NetCodeConfig.Global.HostWorldModeSelection != NetCodeConfig.HostWorldMode.SingleWorld) + if (HybridNetcodeDefaults.IsMissingRequired(NetCodeConfig.Global, out var reason)) { - Log.Error(new Context(LogLevel.Error, "You must configure {nameof(NetCodeConfig)} to only use a single world in order to run in hybrid mode!").AddTag("Unified")); + Log.Error(new Context(LogLevel.Error, $"The {nameof(NetCodeConfig)} is not valid for hybrid mode: {reason}.").AddTag("Unified")); return false; } + // Not fatal, but the two timelines diverging is rarely intentional and is hard to spot from behaviour alone. + if (NetCodeConfig.Global.ClientServerTickRate.SimulationTickRate != NetworkConfig.TickRate) + { + Log.Warning(new Context(LogLevel.Normal, $"{nameof(NetworkConfig)}.{nameof(NetworkConfig.TickRate)} is {NetworkConfig.TickRate} but the {nameof(NetCodeConfig)} simulates at {NetCodeConfig.Global.ClientServerTickRate.SimulationTickRate}. Ghost transform updates will not land on the same interval as the rest of Netcode for GameObjects.").AddTag("Unified")); + } return true; } #endif diff --git a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs new file mode 100644 index 0000000000..d278431b9b --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs @@ -0,0 +1,242 @@ +#if UNIFIED_NETCODE +using NUnit.Framework; +using Unity.NetCode; +using Unity.Netcode.GameObjects.Editor.Configuration; +using UnityEditor; +using UnityEngine; + +namespace Unity.Netcode.EditorTests +{ + /// + /// Validates the NetCodeConfig values NGO applies in hybrid mode. + /// + internal class HybridNetcodeDefaultsTests + { + private NetCodeConfig m_Config; + + [SetUp] + public void SetUp() + { + m_Config = ScriptableObject.CreateInstance(); + m_Config.Reset(); + } + + [TearDown] + public void TearDown() + { + Object.DestroyImmediate(m_Config); + } + + /// + /// A config that already matches reports no change, which is why the applier records the version marker + /// independently of whether anything was written. + /// + [Test] + public void ApplyRecommendedReportsNoChangeWhenConfigAlreadyMatches() + { + Assert.IsTrue(HybridNetcodeDefaults.ApplyRecommended(m_Config, 30u), "Expected the first apply to report a change."); + Assert.IsFalse(HybridNetcodeDefaults.ApplyRecommended(m_Config, 30u), "Applying an already matching config should report no change."); + } + + [Test] + public void ApplyRequiredCorrectsBothSettings() + { + m_Config.EnableClientServerBootstrap = NetCodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap; + m_Config.HostWorldModeSelection = NetCodeConfig.HostWorldMode.BinaryWorlds; + + Assert.IsTrue(HybridNetcodeDefaults.ApplyRequired(m_Config), "Expected the first apply to report a change."); + Assert.AreEqual(NetCodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap, m_Config.EnableClientServerBootstrap); + Assert.AreEqual(NetCodeConfig.HostWorldMode.SingleWorld, m_Config.HostWorldModeSelection); + + Assert.IsFalse(HybridNetcodeDefaults.ApplyRequired(m_Config), "Applying an already correct config should report no change."); + } + + [Test] + public void IsMissingRequiredDetectsEachViolation() + { + HybridNetcodeDefaults.ApplyRequired(m_Config); + Assert.IsFalse(HybridNetcodeDefaults.IsMissingRequired(m_Config, out _), "A corrected config should be valid for hybrid mode."); + + m_Config.HostWorldModeSelection = NetCodeConfig.HostWorldMode.BinaryWorlds; + Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var worldReason)); + Assert.That(worldReason, Does.Contain(nameof(NetCodeConfig.HostWorldModeSelection))); + + m_Config.HostWorldModeSelection = NetCodeConfig.HostWorldMode.SingleWorld; + m_Config.EnableClientServerBootstrap = NetCodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap; + Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var bootstrapReason)); + Assert.That(bootstrapReason, Does.Contain(nameof(NetCodeConfig.EnableClientServerBootstrap))); + } + + [TestCase(30u)] + [TestCase(60u)] + public void ApplyTickRateLocksSimulationAndNetworkRates(uint tickRate) + { + Assert.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, tickRate)); + Assert.AreEqual((int)tickRate, m_Config.ClientServerTickRate.SimulationTickRate); + Assert.AreEqual((int)tickRate, m_Config.ClientServerTickRate.NetworkTickRate, "NetworkTickRate must track SimulationTickRate; the interpolation buffer depends on it."); + + Assert.IsFalse(HybridNetcodeDefaults.ApplyTickRate(m_Config, tickRate)); + } + + /// + /// The one-shot can run before the hybrid 's scene is open, in which case it writes + /// N4E's tick rate rather than NGO's. Opening that scene runs a tick rate only pass, which has to correct the + /// rate without disturbing the tuned values. + /// + [Test] + public void TickRateOnlyPassCorrectsTheRateAndLeavesTheTunedValuesAlone() + { + const int n4eTickRate = 60; + const uint ngoTickRate = 30; + + HybridNetcodeDefaults.ApplyRecommended(m_Config, n4eTickRate); + Assume.That(m_Config.ClientServerTickRate.SimulationTickRate, Is.EqualTo(n4eTickRate), "The one-shot should have written N4E's tick rate."); + + Assert.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, ngoTickRate)); + + Assert.AreEqual((int)ngoTickRate, m_Config.ClientServerTickRate.SimulationTickRate); + Assert.AreEqual((int)ngoTickRate, m_Config.ClientServerTickRate.NetworkTickRate); + Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, m_Config.GhostSendSystemData.DefaultSnapshotPacketSize, "A tick rate pass must not disturb the tuned values."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeMS, m_Config.ClientTickRate.InterpolationTimeMS); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMax, m_Config.ClientTickRate.InterpolationTimeScaleMax); + } + + [Test] + public void ApplyRecommendedProducesTheTunedValues() + { + Assert.IsTrue(HybridNetcodeDefaults.ApplyRecommended(m_Config, 30)); + + Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, m_Config.GhostSendSystemData.DefaultSnapshotPacketSize); + Assert.AreEqual(HybridNetcodeDefaults.PercentReservedForDespawn, m_Config.GhostSendSystemData.PercentReservedForDespawnMessages); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeMS, m_Config.ClientTickRate.InterpolationTimeMS); + Assert.AreEqual(0u, m_Config.ClientTickRate.InterpolationTimeNetTicks, "The net tick form wins over the millisecond form, so it has to be cleared."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMin, m_Config.ClientTickRate.InterpolationTimeScaleMin); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMax, m_Config.ClientTickRate.InterpolationTimeScaleMax); + Assert.AreEqual(HybridNetcodeDefaults.ClientQueueCapacity, m_Config.ClientSendQueueCapacity); + Assert.AreEqual(HybridNetcodeDefaults.ClientQueueCapacity, m_Config.ClientReceiveQueueCapacity); + + Assert.IsFalse(HybridNetcodeDefaults.ApplyRecommended(m_Config, 30), "Re-applying an unchanged config should report no change."); + } + + /// + /// Why the millisecond form is used rather than . + /// + /// + /// Netcode for Entities rounds the millisecond value up to whole network ticks, so it holds at least the + /// configured wall clock buffer at any tick rate. + /// + /// The tick rate to resolve the buffer against. + [TestCase(30u)] + [TestCase(60u)] + public void InterpolationBufferHoldsAtLeastFiftyMillisecondsAtAnyTickRate(uint tickRate) + { + HybridNetcodeDefaults.ApplyRecommended(m_Config, tickRate); + + var bufferMs = m_Config.ClientTickRate.CalculateInterpolationBufferTimeInMs(in m_Config.ClientServerTickRate); + Assert.GreaterOrEqual(bufferMs, HybridNetcodeDefaults.InterpolationTimeMS, $"Interpolation buffer collapsed to {bufferMs}ms at {tickRate}Hz."); + } + + [Test] + public void NetTickFormWouldRegressTheBufferAtHigherTickRates() + { + // Documents why the net tick form is not used. If this ever stops being true, the millisecond form and its + // extra rounding are no longer buying anything. + HybridNetcodeDefaults.ApplyTickRate(m_Config, 60); + m_Config.ClientTickRate = new ClientTickRate + { + InterpolationTimeNetTicks = 2, + InterpolationTimeMS = 0, + }; + + var bufferMs = m_Config.ClientTickRate.CalculateInterpolationBufferTimeInMs(in m_Config.ClientServerTickRate); + Assert.Less(bufferMs, HybridNetcodeDefaults.InterpolationTimeMS); + } + + [Test] + public void IsHybridProjectOnlyDetectsPrefabsCarryingAGhost() + { + Assume.That(HybridNetcodeConfigApplier.IsHybridProject(), Is.False, "Another loaded NetworkManager already registers a ghost prefab."); + + var managerObject = new GameObject(nameof(IsHybridProjectOnlyDetectsPrefabsCarryingAGhost)); + var prefabObject = new GameObject("GhostPrefab"); + var prefabsList = ScriptableObject.CreateInstance(); + try + { + var networkManager = managerObject.AddComponent(); + networkManager.NetworkConfig = new NetworkConfig(); + var networkObject = prefabObject.AddComponent(); + + prefabsList.Add(new NetworkPrefab { Prefab = prefabObject }); + networkManager.NetworkConfig.Prefabs.NetworkPrefabsLists.Add(prefabsList); + + Assert.IsFalse(HybridNetcodeConfigApplier.IsHybridProject(), "A registered prefab without a GhostObject is not hybrid."); + + networkObject.HasGhost = true; + Assert.IsTrue(HybridNetcodeConfigApplier.IsHybridProject()); + } + finally + { + Object.DestroyImmediate(prefabsList); + Object.DestroyImmediate(prefabObject); + Object.DestroyImmediate(managerObject); + } + } + + /// + /// Once the one-shot has been recorded, every later pass still drives the tick rate from the hybrid + /// . This is what corrects the rate when the manager's scene opens after the + /// defaults were already applied. + /// + [Test] + public void ApplyDrivesTheTickRateAfterTheOneShotHasBeenRecorded() + { + const uint managerTickRate = 45; + + var config = HybridNetcodeConfigApplier.ResolveGlobalConfig(); + Assume.That(config, Is.Not.Null, "This project has no NetCodeConfig to adjust."); + Assume.That(HybridNetcodeConfigApplier.IsHybridProject(), Is.False, "Another loaded NetworkManager already registers a ghost prefab."); + + var settings = NetcodeForGameObjectsProjectSettings.instance; + var restoreVersion = settings.HybridDefaultsVersion; + var restoreSimulation = config.ClientServerTickRate.SimulationTickRate; + var restoreNetwork = config.ClientServerTickRate.NetworkTickRate; + + var managerObject = new GameObject(nameof(ApplyDrivesTheTickRateAfterTheOneShotHasBeenRecorded)); + var prefabObject = new GameObject("GhostPrefab"); + var prefabsList = ScriptableObject.CreateInstance(); + try + { + var networkManager = managerObject.AddComponent(); + networkManager.NetworkConfig = new NetworkConfig { TickRate = managerTickRate, }; + prefabObject.AddComponent().HasGhost = true; + prefabsList.Add(new NetworkPrefab { Prefab = prefabObject }); + networkManager.NetworkConfig.Prefabs.NetworkPrefabsLists.Add(prefabsList); + + // Past the one-shot, so this exercises the required plus tick rate path rather than ApplyRecommended. + settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version; + config.ClientServerTickRate.SimulationTickRate = 60; + config.ClientServerTickRate.NetworkTickRate = 60; + + HybridNetcodeConfigApplier.Apply(false); + + Assert.AreEqual((int)managerTickRate, config.ClientServerTickRate.SimulationTickRate, "The tick rate should have been driven from the hybrid NetworkManager."); + Assert.AreEqual((int)managerTickRate, config.ClientServerTickRate.NetworkTickRate); + } + finally + { + config.ClientServerTickRate.SimulationTickRate = restoreSimulation; + config.ClientServerTickRate.NetworkTickRate = restoreNetwork; + EditorUtility.SetDirty(config); + AssetDatabase.SaveAssetIfDirty(config); + + settings.HybridDefaultsVersion = restoreVersion; + settings.SaveSettings(); + + Object.DestroyImmediate(prefabsList); + Object.DestroyImmediate(prefabObject); + Object.DestroyImmediate(managerObject); + } + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs.meta new file mode 100644 index 0000000000..d3d6b0bac1 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e5729f2ab235729478cc8552f87478fc \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef b/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef index a9a05da02b..3a1300b538 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef +++ b/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef @@ -13,7 +13,8 @@ "Unity.Mathematics", "UnityEngine.TestRunner", "UnityEditor.TestRunner", - "Unity.Netcode.Runtime.Tests" + "Unity.Netcode.Runtime.Tests", + "Unity.NetCode" ], "includePlatforms": [ "Editor" @@ -34,6 +35,11 @@ "expression": "", "define": "MULTIPLAYER_TOOLS" }, + { + "name": "com.unity.netcode", + "expression": "1.10.1", + "define": "UNIFIED_NETCODE" + }, { "name": "Unity", "expression": "6000.1.0a1", diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta new file mode 100644 index 0000000000..ebb2d11c0b --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4368bd44e3db2794bb788f8102cc171a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs new file mode 100644 index 0000000000..53a44d431b --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs @@ -0,0 +1,261 @@ +#if UNIFIED_NETCODE +using System; +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Measurement harness (not a pass/fail behaviour test) used to determine bandwidth consumption based + /// on the when running in hybrid mode. + /// Spawns N hybrid ghosts, keeps every one of them dirty on every tick, and reads the N4E client-side + /// snapshot metrics singleton for a fixed sample window. Results are emitted as "PKTSZ|" log lines. + /// + [TestFixture(HostOrServer.UnifiedHost)] + [Explicit("Measurement harness, not a regression test. The 24 auto-expanded cases take ~162s, so it only runs when selected by name: -testFilter \".*UnifiedSnapshotPacketSizeMeasurement.*\"")] + internal class UnifiedSnapshotPacketSizeMeasurement : NetcodeIntegrationTest + { + protected override int NumberOfClients => 1; + + // Delta-compression baselines need several snapshots to settle; the first ones are much larger. + private const int k_WarmupSnapshots = 30; + private const int k_SampleSnapshots = 100; + private const int k_SpawnsPerFrame = 100; + private const float k_SpawnTimeout = 240.0f; + private const float k_SampleTimeout = 240.0f; + + private GameObject m_Prefab; + private Transform[] m_Instances; + private NetCode.GhostObject[] m_Ghosts; + private float[] m_Phases; + private int m_Frame; + + public UnifiedSnapshotPacketSizeMeasurement(HostOrServer hostOrServer) : base(hostOrServer) + { + } + + protected override bool OnSetVerboseDebug() + { + return false; + } + + protected override IEnumerator OnSetup() + { + m_Instances = null; + m_Ghosts = null; + m_Phases = null; + m_Frame = 0; + // UnifiedHost sets m_AllPrefabsAsHybrid, so this yields a NetworkObject + GhostObject + NetworkObjectBridge prefab. + m_Prefab = CreateNetworkObjectPrefab("PktSizeGhost"); + return base.OnSetup(); + } + + /// + /// Every instance orbits on its own phase so that no chunk is ever unchanged. N4E static-optimizes + /// unchanged chunks, so leaving these still would measure nothing. + /// + private void MoveAll() + { + if (m_Instances == null) + { + return; + } + m_Frame++; + var time = m_Frame * 0.01f; + for (int i = 0; i < m_Instances.Length; i++) + { + var instance = m_Instances[i]; + if (instance == null) + { + continue; + } + var angle = time + m_Phases[i]; + var radius = 20.0f + (i % 17); + var position = new Vector3(radius * Mathf.Cos(angle), (i % 32) * 0.5f, radius * Mathf.Sin(angle)); + var rotation = Quaternion.Euler(0.0f, angle * Mathf.Rad2Deg, 0.0f); + instance.SetLocalPositionAndRotation(position, rotation); + // On a single-world host the GameObject transform is also written by the presentation-time smoothing + // system, so drive the authoritative LocalTransform directly as well. + var ghost = m_Ghosts[i]; + if (ghost != null) + { + ghost.Position = position; + ghost.Rotation = rotation; + } + } + } + + private static Entity CreateMetricsSingleton(EntityManager entityManager) + { + var typeList = new NativeArray(8, Allocator.Temp); + typeList[0] = ComponentType.ReadWrite(); + typeList[1] = ComponentType.ReadWrite(); + typeList[2] = ComponentType.ReadWrite(); + typeList[3] = ComponentType.ReadWrite(); + typeList[4] = ComponentType.ReadWrite(); + typeList[5] = ComponentType.ReadWrite(); + typeList[6] = ComponentType.ReadWrite(); + typeList[7] = ComponentType.ReadWrite(); + var singleton = entityManager.CreateEntity(entityManager.CreateArchetype(typeList)); + typeList.Dispose(); + entityManager.SetName(singleton, (FixedString64Bytes)"MetricsMonitor"); + return singleton; + } + + private static double Mean(List values) + { + double total = 0; + for (int i = 0; i < values.Count; i++) + { + total += values[i]; + } + return values.Count == 0 ? 0 : total / values.Count; + } + + private static uint Percentile(List values, double fraction) + { + if (values.Count == 0) + { + return 0; + } + var sorted = new List(values); + sorted.Sort(); + var index = (int)Math.Round(fraction * (sorted.Count - 1)); + return sorted[Mathf.Clamp(index, 0, sorted.Count - 1)]; + } + + [UnityTest] + public IEnumerator MeasureSnapshotSize( + [Values(0, 4000, 8000, 15000)] int packetSize, + [Values(250, 500, 1000, 2000, 2500, 3000)] int objectCount) + { + var hostWorld = m_ServerNetworkManager.NetcodeWorld; + var clientWorld = m_ClientNetworkManagers[0].NetcodeWorld; + Assert.IsNotNull(hostWorld, "Host has no NetcodeWorld!"); + Assert.IsNotNull(clientWorld, "Client has no NetcodeWorld!"); + + var sendDataQuery = hostWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite()); + var sendData = sendDataQuery.GetSingleton(); + sendData.DefaultSnapshotPacketSize = packetSize; + sendDataQuery.SetSingleton(sendData); + + var tickRate = 30; + var tickRateQuery = hostWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); + if (tickRateQuery.CalculateEntityCount() == 1) + { + var configured = tickRateQuery.GetSingleton(); + tickRate = configured.NetworkTickRate > 0 ? configured.NetworkTickRate : Mathf.Max(1, configured.SimulationTickRate); + } + + CreateMetricsSingleton(clientWorld.EntityManager); + var snapshotMetricsQuery = clientWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); + + var clientSpawnManager = m_ClientNetworkManagers[0].SpawnManager; + var preSpawnCount = clientSpawnManager.SpawnedObjects.Count; + + m_Instances = new Transform[objectCount]; + m_Ghosts = new NetCode.GhostObject[objectCount]; + m_Phases = new float[objectCount]; + var random = new System.Random(12345); + for (int i = 0; i < objectCount; i++) + { + m_Phases[i] = (float)(random.NextDouble() * Mathf.PI * 2.0f); + var spawned = SpawnObject(m_Prefab, m_ServerNetworkManager); + m_Instances[i] = spawned.transform; + m_Ghosts[i] = spawned.GetComponent(); + if ((i + 1) % k_SpawnsPerFrame == 0) + { + MoveAll(); + yield return null; + } + } + + var deadline = Time.realtimeSinceStartup + k_SpawnTimeout; + while ((clientSpawnManager.SpawnedObjects.Count - preSpawnCount) < objectCount && Time.realtimeSinceStartup < deadline) + { + MoveAll(); + yield return null; + } + var spawnedOnClient = clientSpawnManager.SpawnedObjects.Count - preSpawnCount; + + var sizes = new List(k_SampleSnapshots); + var counts = new List(k_SampleSnapshots); + uint lastSnapshotTick = 0; + var snapshotsSeen = 0; + deadline = Time.realtimeSinceStartup + k_SampleTimeout; + while (snapshotsSeen < (k_WarmupSnapshots + k_SampleSnapshots) && Time.realtimeSinceStartup < deadline) + { + MoveAll(); + yield return null; + + if (snapshotMetricsQuery.CalculateEntityCount() != 1) + { + continue; + } + var metrics = snapshotMetricsQuery.GetSingleton(); + if (metrics.SnapshotTick == 0 || metrics.SnapshotTick == lastSnapshotTick) + { + continue; + } + lastSnapshotTick = metrics.SnapshotTick; + snapshotsSeen++; + if (snapshotsSeen > k_WarmupSnapshots) + { + sizes.Add(metrics.TotalSizeInBits); + counts.Add(metrics.TotalGhostCount); + } + } + + // Sanity check that the ghosts really did move (a static ghost measures nothing useful). + var hostNetworkObject = m_Instances[0].GetComponent(); + if (clientSpawnManager.SpawnedObjects.TryGetValue(hostNetworkObject.NetworkObjectId, out var clientClone)) + { + Debug.Log($"PKTDIAG|hostGO={m_Instances[0].position}|hostGhost={m_Ghosts[0].Position}|client={clientClone.transform.position}|frames={m_Frame}"); + } + + // The unfragmented default is driver derived; approximate with the configured MaxMessageSize for the cap check. + var effectiveCapBytes = packetSize > 0 ? packetSize : 1400; + var capHits = 0; + var incomplete = 0; + for (int i = 0; i < sizes.Count; i++) + { + if ((sizes[i] / 8.0) >= (effectiveCapBytes * 0.95)) + { + capHits++; + } + if (counts[i] < objectCount) + { + incomplete++; + } + } + + var meanBits = Mean(sizes); + var meanGhosts = Mean(counts); + var capHitFraction = sizes.Count == 0 ? 0.0 : (double)capHits / sizes.Count; + var incompleteFraction = sizes.Count == 0 ? 0.0 : (double)incomplete / sizes.Count; + var bytesPerGhost = meanGhosts <= 0 ? 0.0 : (meanBits / 8.0) / meanGhosts; + var effectiveHz = objectCount <= 0 ? 0.0 : (meanGhosts / objectCount) * tickRate; + + Debug.Log($"PKTSZ|{packetSize}|{objectCount}|{spawnedOnClient}|{sizes.Count}|{meanBits:F1}|{Percentile(sizes, 0.95)}|" + + $"{Percentile(sizes, 1.0)}|{meanGhosts:F1}|{Percentile(counts, 0.95)}|{bytesPerGhost:F3}|{capHitFraction:F3}|{incompleteFraction:F3}|{effectiveHz:F2}|{tickRate}"); + + Assert.AreEqual(objectCount, spawnedOnClient, $"Client only spawned {spawnedOnClient} of {objectCount} hybrid ghosts!"); + Assert.AreEqual(k_SampleSnapshots, sizes.Count, $"Only collected {sizes.Count} of {k_SampleSnapshots} snapshot samples!"); + } + + protected override IEnumerator OnTearDown() + { + m_Instances = null; + m_Ghosts = null; + m_Phases = null; + return base.OnTearDown(); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs.meta new file mode 100644 index 0000000000..499c4104e4 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 41f3e6a37f69bf640974318dffc22496 \ No newline at end of file diff --git a/testproject/Assets/NetCodeConfig.asset b/testproject/Assets/NetCodeConfig.asset index 8988bbd48c..4f87186053 100644 --- a/testproject/Assets/NetCodeConfig.asset +++ b/testproject/Assets/NetCodeConfig.asset @@ -63,7 +63,7 @@ MonoBehaviour: CleanupConnectionStatePerTick: 1 m_FirstSendImportanceMultiplier: 1 m_IrrelevantImportanceDownScale: 1 - m_TempStreamSize: 4192 + m_TempStreamSize: 8192 m_UseCustomSerializer: 0 ConnectTimeoutMS: 1000 MaxConnectAttempts: 60