From 69a99a935b922ea14a33ff95a4f6b1798cc70cc5 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 24 Aug 2026 18:19:49 -0500 Subject: [PATCH 01/21] feat: auto-configure NetcodeConfig for hybrid mode When Netcode for Entities is installed and a registered network prefab carries a GhostObject, the project's NetCodeConfig is now aligned with what NGO needs rather than leaving it to the user to discover. The two settings hybrid mode cannot run without (single world hosting, and automatic bootstrapping disabled since NetworkManager owns world creation) are corrected whenever they drift. The Netcode for Entities tick rates are driven from NetworkConfig.TickRate so that ghost transform updates land on the same interval as everything else. The snapshot, interpolation and transport values tuned against the 2000 instance stress test are applied once and then left alone, so a user's own edits survive; Project Settings > Multiplayer > Netcode for GameObjects can restore them. The config is never created here. Netcode for Entities already creates one unconditionally from its own InitializeOnLoadMethod, and creating a second lands the project in its multiple-config error path. Also: - UnifiedIsConfiguredCorrectly now validates EnableClientServerBootstrap and warns when the two tick rates diverge. Its two log messages were missing their string interpolation prefixes. - m_TempStreamSize returns to the Netcode for Entities default of 8192. GhostSendSystem takes max(TempStreamInitialSize, dataStream.Capacity), and capacity is DefaultSnapshotPacketSize, so 4192 had no effect. - Unity.Netcode.Editor.Tests gains the UNIFIED_NETCODE version define. Without it any hybrid editor test compiles away and reports zero cases rather than failing. - Adds a measurement fixture for sizing DefaultSnapshotPacketSize. It is marked Explicit so its 24 cases stay out of the suites. --- com.unity.netcode.gameobjects/CHANGELOG.md | 3 + .../HybridNetcodeConfigApplier.cs | 210 ++++++++++++++ .../HybridNetcodeConfigApplier.cs.meta | 2 + .../NetcodeForGameObjectsProjectSettings.cs | 12 + .../Configuration/NetcodeSettingsProvider.cs | 49 ++++ .../Configuration/HybridNetcodeDefaults.cs | 155 +++++++++++ .../HybridNetcodeDefaults.cs.meta | 2 + .../Runtime/Core/NetworkManager.cs | 11 +- .../Editor/HybridNetcodeDefaultsTests.cs | 152 ++++++++++ .../Editor/HybridNetcodeDefaultsTests.cs.meta | 2 + .../Editor/Unity.Netcode.Editor.Tests.asmdef | 8 +- .../Tests/Runtime/Unified.meta | 8 + .../UnifiedSnapshotPacketSizeMeasurement.cs | 261 ++++++++++++++++++ ...ifiedSnapshotPacketSizeMeasurement.cs.meta | 2 + testproject/Assets/NetCodeConfig.asset | 2 +- 15 files changed, 874 insertions(+), 5 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs create mode 100644 com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta create mode 100644 com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs.meta create mode 100644 com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs.meta create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs.meta 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..4ff64db7eb --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs @@ -0,0 +1,210 @@ +#if UNIFIED_NETCODE +using Unity.NetCode; +using UnityEditor; +using UnityEngine; + +namespace Unity.Netcode.GameObjects.Editor.Configuration +{ + /// + /// Keeps the project's aligned with what NGO needs whenever the project is running in + /// hybrid mode (Netcode for Entities installed, and at least one registered network prefab carrying a ghost). + /// + /// + /// This never creates a . Netcode for Entities already creates one unconditionally from + /// its own , and creating a second lands the project in N4E's + /// multiple-config error path. We find the one N4E settled on and correct it. + /// + internal static class HybridNetcodeConfigApplier + { + [InitializeOnLoadMethod] + private static void OnApplicationStart() + { + // N4E creates and assigns the global config from its own InitializeOnLoadMethod. Cross-assembly ordering + // between the two is not a documented contract, so defer rather than racing it. + EditorApplication.delayCall += OnDelayCall; + } + + private static void OnDelayCall() + { + EditorApplication.delayCall -= OnDelayCall; + Apply(false); + } + + /// + /// Corrects the global for hybrid mode, if this is a hybrid project. + /// + /// + /// When true, re-applies the full tuned set even if this project has already had it applied once. Driven by the + /// button in Project Settings. When false, the tuned values are only written the first time, so that a user's + /// own edits are not repeatedly 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); + } + } + 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; + } + + settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version; + settings.SaveSettings(); + + EditorUtility.SetDirty(config); + AssetDatabase.SaveAssetIfDirty(config); + } + + /// + /// True when any in the project has a registered prefab carrying a ghost. + /// + /// Whether this project is configured for hybrid mode. + internal static bool IsHybridProject() + { + foreach (var networkManager in Resources.FindObjectsOfTypeAll()) + { + var prefabs = networkManager.NetworkConfig?.Prefabs; + if (prefabs == null) + { + continue; + } + + foreach (var prefab in prefabs.Prefabs) + { + if (HasGhost(prefab)) + { + return true; + } + } + + foreach (var prefabsList in prefabs.NetworkPrefabsLists) + { + if (prefabsList == null) + { + continue; + } + + 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 correct, or null when none exists yet. + 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; + } + + /// + /// The tick rate N4E should be driven at. NGO owns this, so it comes from . + /// + /// 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()) + { + var tickRate = networkManager.NetworkConfig?.TickRate ?? 0u; + if (tickRate == 0) + { + continue; + } + + diverged |= found != 0 && found != tickRate; + found = tickRate; + } + + if (diverged) + { + Debug.LogWarning($"[Netcode] Found {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 project.", config); + } + + // No NetworkManager to read from (a prefab-only project, or one mid-import) leaves the config as it is. + return found != 0 ? found : (uint)config.ClientServerTickRate.SimulationTickRate; + } + } + + /// + /// Re-runs the hybrid config pass when a prefab import could have turned this into a hybrid project. + /// + internal class HybridNetcodeConfigPostprocessor : AssetPostprocessor + { + private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) + { + foreach (var assetPath in importedAssets) + { + if (AssetDatabase.GetMainAssetTypeAtPath(assetPath) != typeof(GameObject)) + { + continue; + } + + var gameObject = AssetDatabase.LoadAssetAtPath(assetPath); + if (gameObject != null && gameObject.TryGetComponent(out var networkObject) && networkObject.HasGhost) + { + HybridNetcodeConfigApplier.Apply(false); + return; + } + } + } + } +} +#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..c84dcfc34e 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 version of the NGO hybrid mode defaults already applied to this project's NetCodeConfig. + /// + /// + /// Zero means they have never been applied. Recording it is what keeps the tuned values a one-shot, so that a + /// user who deliberately changes them does not have them 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..51ed9a1993 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 + /// + /// Surfaces the state of the project's NetCodeConfig, and offers a way back to the NGO defaults 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..b0b37413eb --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs @@ -0,0 +1,152 @@ +#if UNIFIED_NETCODE +using NUnit.Framework; +using Unity.NetCode; +using Unity.Netcode.GameObjects.Editor.Configuration; +using UnityEngine; + +namespace Unity.Netcode.EditorTests +{ + /// + /// Guards the NetCodeConfig values NGO applies in hybrid mode, and the reasoning behind the non-obvious ones. + /// + 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); + } + + [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)); + } + + [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."); + } + + /// + /// The reason 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. Two net ticks does not: it is 66.7ms at 30Hz but only 33.3ms + /// at 60Hz, and 33.3ms is the buffer the 2000 instance stress test stuttered at. + /// + /// 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); + } + } + } +} +#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..222c8e789e --- /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 size + /// for hybrid (NGO + N4E) 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 From 49e605e516a1c875dd918cfb9d023e62a3985bc8 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 25 Aug 2026 09:07:35 -0500 Subject: [PATCH 02/21] style Some comment clean up. --- .../HybridNetcodeConfigApplier.cs | 28 +++++++++---------- .../NetcodeForGameObjectsProjectSettings.cs | 6 ++-- .../Configuration/NetcodeSettingsProvider.cs | 4 +-- .../Editor/HybridNetcodeDefaultsTests.cs | 7 ++--- .../UnifiedSnapshotPacketSizeMeasurement.cs | 4 +-- 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs index 4ff64db7eb..29db917656 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs @@ -6,21 +6,19 @@ namespace Unity.Netcode.GameObjects.Editor.Configuration { /// - /// Keeps the project's aligned with what NGO needs whenever the project is running in - /// hybrid mode (Netcode for Entities installed, and at least one registered network prefab carrying a ghost). + /// 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 never creates a . Netcode for Entities already creates one unconditionally from - /// its own , and creating a second lands the project in N4E's - /// multiple-config error path. We find the one N4E settled on and correct it. + /// This does not create . This finds the one N4E created and modifies it. /// internal static class HybridNetcodeConfigApplier { [InitializeOnLoadMethod] private static void OnApplicationStart() { - // N4E creates and assigns the global config from its own InitializeOnLoadMethod. Cross-assembly ordering - // between the two is not a documented contract, so defer rather than racing it. + // Cross-assembly ordering between the two is not a documented contract. + // Defer rather than racing it. EditorApplication.delayCall += OnDelayCall; } @@ -31,12 +29,13 @@ private static void OnDelayCall() } /// - /// Corrects the global for hybrid mode, if this is a hybrid project. + /// Adjusts for NGO hybrid mode. /// /// - /// When true, re-applies the full tuned set even if this project has already had it applied once. Driven by the - /// button in Project Settings. When false, the tuned values are only written the first time, so that a user's - /// own edits are not repeatedly overwritten. + /// 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) { @@ -91,7 +90,6 @@ internal static void Apply(bool applyRecommended) /// /// True when any in the project has a registered prefab carrying a ghost. /// - /// Whether this project is configured for hybrid mode. internal static bool IsHybridProject() { foreach (var networkManager in Resources.FindObjectsOfTypeAll()) @@ -140,7 +138,7 @@ private static bool HasGhost(NetworkPrefab prefab) /// /// Resolves the config N4E considers global, falling back to a project scan when N4E has not assigned one yet. /// - /// The config to correct, or null when none exists yet. + /// The config to adjust or null if no config exists. internal static NetCodeConfig ResolveGlobalConfig() { if (NetCodeConfig.Global != null) @@ -153,7 +151,9 @@ internal static NetCodeConfig ResolveGlobalConfig() } /// - /// The tick rate N4E should be driven at. NGO owns this, so it comes from . + /// Returns either the current N4E tick rate or the NGO . + /// If no NetworkManager is found yet, it returns N4E's tick rate. + /// If a NetworkManager is found, then it returns NGO's tick rate. /// /// The config, used as the fallback when no NetworkManager can be found. /// The tick rate to write into the config. diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs index c84dcfc34e..d1ec7a1699 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs @@ -37,11 +37,11 @@ private void OnEnable() #if UNIFIED_NETCODE /// - /// The version of the NGO hybrid mode defaults already applied to this project's NetCodeConfig. + /// The hybrid mode default values already applied to this project's NetCodeConfig. /// /// - /// Zero means they have never been applied. Recording it is what keeps the tuned values a one-shot, so that a - /// user who deliberately changes them does not have them overwritten on the next domain reload. + /// 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; diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs index 51ed9a1993..c55cf4b159 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs @@ -215,8 +215,8 @@ private static void OnGuiHandler(string obj) #if UNIFIED_NETCODE /// - /// Surfaces the state of the project's NetCodeConfig, and offers a way back to the NGO defaults for anyone who - /// has since changed them. + /// 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) diff --git a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs index b0b37413eb..53e286b9b6 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs @@ -7,7 +7,7 @@ namespace Unity.Netcode.EditorTests { /// - /// Guards the NetCodeConfig values NGO applies in hybrid mode, and the reasoning behind the non-obvious ones. + /// Validates the NetCodeConfig values NGO applies in hybrid mode. /// internal class HybridNetcodeDefaultsTests { @@ -84,12 +84,11 @@ public void ApplyRecommendedProducesTheTunedValues() } /// - /// The reason the millisecond form is used rather than . + /// 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. Two net ticks does not: it is 66.7ms at 30Hz but only 33.3ms - /// at 60Hz, and 33.3ms is the buffer the 2000 instance stress test stuttered at. + /// configured wall clock buffer at any tick rate. /// /// The tick rate to resolve the buffer against. [TestCase(30u)] diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs index 222c8e789e..53a44d431b 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs @@ -12,8 +12,8 @@ namespace Unity.Netcode.RuntimeTests { /// - /// Measurement harness (not a pass/fail behaviour test) used to size - /// for hybrid (NGO + N4E) mode. + /// 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. /// From 34747682cdec2180495389f4f46d60e4e459ee2d Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 25 Aug 2026 11:10:31 -0500 Subject: [PATCH 03/21] fix and test Fixing some things u-pr caught and adding a test to validate the update. --- .../HybridNetcodeConfigApplier.cs | 166 ++++++++++++++---- .../Editor/HybridNetcodeDefaultsTests.cs | 91 ++++++++++ 2 files changed, 220 insertions(+), 37 deletions(-) diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs index 29db917656..47fce4864d 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs @@ -1,7 +1,9 @@ #if UNIFIED_NETCODE using Unity.NetCode; using UnityEditor; +using UnityEditor.SceneManagement; using UnityEngine; +using UnityEngine.SceneManagement; namespace Unity.Netcode.GameObjects.Editor.Configuration { @@ -20,6 +22,11 @@ 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() @@ -28,6 +35,11 @@ private static void OnDelayCall() Apply(false); } + private static void OnSceneOpened(Scene scene, OpenSceneMode mode) + { + Apply(false); + } + /// /// Adjusts for NGO hybrid mode. /// @@ -61,6 +73,11 @@ internal static void Apply(bool applyRecommended) { 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 { @@ -80,48 +97,92 @@ internal static void Apply(bool applyRecommended) return; } - settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version; - settings.SaveSettings(); - EditorUtility.SetDirty(config); AssetDatabase.SaveAssetIfDirty(config); } /// - /// True when any in the project has a registered prefab carrying a ghost. + /// 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()) { - var prefabs = networkManager.NetworkConfig?.Prefabs; - if (prefabs == null) + if (HasGhost(networkManager)) { - continue; + 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 prefab in prefabs.Prefabs) + foreach (var prefabsList in prefabs.NetworkPrefabsLists) + { + if (HasGhost(prefabsList)) { - if (HasGhost(prefab)) - { - return true; - } + 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 prefabsList in prefabs.NetworkPrefabsLists) + foreach (var prefab in prefabsList.PrefabList) + { + if (HasGhost(prefab)) { - if (prefabsList == null) - { - continue; - } - - foreach (var prefab in prefabsList.PrefabList) - { - if (HasGhost(prefab)) - { - return true; - } - } + return true; } } @@ -152,9 +213,13 @@ internal static NetCodeConfig ResolveGlobalConfig() /// /// Returns either the current N4E tick rate or the NGO . - /// If no NetworkManager is found yet, it returns N4E's tick rate. - /// If a NetworkManager is found, then it returns NGO's tick rate. + /// 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) @@ -163,6 +228,11 @@ private static uint ResolveTickRate(NetCodeConfig config) var diverged = false; foreach (var networkManager in Resources.FindObjectsOfTypeAll()) { + if (!HasGhost(networkManager)) + { + continue; + } + var tickRate = networkManager.NetworkConfig?.TickRate ?? 0u; if (tickRate == 0) { @@ -175,36 +245,58 @@ private static uint ResolveTickRate(NetCodeConfig config) if (diverged) { - Debug.LogWarning($"[Netcode] Found {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 project.", config); + 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); } - // No NetworkManager to read from (a prefab-only project, or one mid-import) leaves the config as it is. + // 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 a prefab import could have turned this into a hybrid project. + /// 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 (AssetDatabase.GetMainAssetTypeAtPath(assetPath) != typeof(GameObject)) - { - continue; - } - - var gameObject = AssetDatabase.LoadAssetAtPath(assetPath); - if (gameObject != null && gameObject.TryGetComponent(out var networkObject) && networkObject.HasGhost) + 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/Tests/Editor/HybridNetcodeDefaultsTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs index 53e286b9b6..d278431b9b 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs @@ -2,6 +2,7 @@ using NUnit.Framework; using Unity.NetCode; using Unity.Netcode.GameObjects.Editor.Configuration; +using UnityEditor; using UnityEngine; namespace Unity.Netcode.EditorTests @@ -26,6 +27,17 @@ 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() { @@ -66,6 +78,29 @@ public void ApplyTickRateLocksSimulationAndNetworkRates(uint tickRate) 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() { @@ -146,6 +181,62 @@ public void IsHybridProjectOnlyDetectsPrefabsCarryingAGhost() 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 From 9c7fb1a5e620a353aa5464be4e1f7d316f6166fb Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 31 Aug 2026 09:32:29 -0500 Subject: [PATCH 04/21] refactor Refactoring when the NetcodeConfig default settings are written. Assuring that the NetcodeConfig tick rate is always the currently set tick rate when starting a session. --- com.unity.netcode.gameobjects/CHANGELOG.md | 3 +- .../HybridNetcodeConfigApplier.cs | 283 ++++-------------- .../NetcodeForGameObjectsProjectSettings.cs | 11 + .../Configuration/NetcodeSettingsProvider.cs | 58 +++- .../Configuration/HybridNetcodeDefaults.cs | 7 +- .../Runtime/Core/NetworkManager.cs | 25 +- .../Editor/HybridNetcodeDefaultsTests.cs | 143 +++++---- 7 files changed, 221 insertions(+), 309 deletions(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index b30320c35d..f8271ec9d8 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -10,7 +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. +- Added an "Enable the experimental unified netcode API" opt-in under Project Settings > Multiplayer > Netcode for GameObjects, shown when Netcode for Entities is installed. Enabling it writes the `NetCodeConfig` snapshot, interpolation and transport values recommended for hybrid mode, once. They can be changed freely afterwards and restored from the same page. +- Added alignment of the Netcode for Entities tick rates with `NetworkConfig.TickRate` when a session with `GhostObject` prefabs is started, so ghost updates land on the same interval as the rest of Netcode for GameObjects. ### Changed diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs index 47fce4864d..89df7283ca 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs @@ -1,204 +1,106 @@ #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). + /// Writes the values NGO recommends for hybrid mode, once, the first time a + /// is available. /// /// /// This does not create . This finds the one N4E created and modifies it. + /// Nothing tracks the project after that write. The defaults are inert in a project with no hybrid prefabs, and + /// re-aligns the tick rate at start-up in a project that has them, so there is no + /// reason to scan for ghost prefabs from the editor. /// internal static class HybridNetcodeConfigApplier { + /// + /// Whether the user has to opt into the experimental unified netcode API before NGO writes anything. + /// + /// + /// TODO-RELEASE: Set this to true before the 6000.7.0 release manifest submission if Netcode for Entities + /// ships the unified API as experimental and its scripting defines. + /// Note: This is deliberately not a const: IDE0035 (remove unreachable code) is an error in this repository, so a + /// const would fail the standards job as soon as it was set to false. + /// + internal static readonly bool RequiresExperimentalOptIn = false; + + private static NetCodeConfig s_ScannedConfig; + private static bool s_ConfigScanned; + [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); + ApplyDefaults(false); } /// - /// Adjusts for NGO hybrid mode. + /// Writes the NGO hybrid mode defaults into the project's . /// - /// + /// /// 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. + /// - When true: re-applies the full tuned set even though this project has already had it applied once. + /// - When false: writes only if this project has never had them written. From that point forward, the user's + /// edits are not overwritten. /// - internal static void Apply(bool applyRecommended) + internal static void ApplyDefaults(bool force) { 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) + if (RequiresExperimentalOptIn && !settings.EnableUnifiedNetcodeApi) { 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 (!force && settings.HybridDefaultsVersion >= HybridNetcodeDefaults.Version) { - if (HasGhost(prefabsList)) - { - return true; - } + return; } - 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) + // A project with no config yet leaves the marker unrecorded so that the next domain reload tries again. + // N4E creates one on any domain reload that finds none. + var config = ResolveGlobalConfig(); + if (config == null) { - return false; + return; } - foreach (var prefab in prefabsList.PrefabList) + if (HybridNetcodeDefaults.ApplyRecommended(config, HybridNetcodeDefaults.DefaultTickRate)) { - if (HasGhost(prefab)) - { - return true; - } + EditorUtility.SetDirty(config); + AssetDatabase.SaveAssetIfDirty(config); + 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); } - return false; - } - - private static bool HasGhost(NetworkPrefab prefab) - { - return prefab?.Prefab != null - && prefab.Prefab.TryGetComponent(out var networkObject) - && networkObject.HasGhost; + // 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(); } /// /// Resolves the config N4E considers global, falling back to a project scan when N4E has not assigned one yet. /// + /// + /// The scan is done at most once per domain reload, including when it finds nothing, because this is also + /// reached from OnGUI and walks the entire project. A config created + /// after the scan is picked up on the next domain reload. + /// /// The config to adjust or null if no config exists. internal static NetCodeConfig ResolveGlobalConfig() { @@ -207,95 +109,14 @@ internal static NetCodeConfig ResolveGlobalConfig() 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)) + if (!s_ConfigScanned) { - return HybridNetcodeConfigApplier.HasGhost(AssetDatabase.LoadAssetAtPath(assetPath)); + s_ConfigScanned = true; + var guids = AssetDatabase.FindAssets($"t:{nameof(NetCodeConfig)}"); + s_ScannedConfig = guids.Length == 1 ? AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0])) : null; } - return false; + return s_ScannedConfig; } } } diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs index 9842fa9580..9f9b9939d9 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs @@ -38,6 +38,17 @@ private void OnEnable() public bool GenerateDefaultNetworkPrefabs = true; #if UNIFIED_NETCODE + /// + /// Whether the user has opted into the experimental unified netcode API. + /// + /// + /// Only consulted while holds. Turning it + /// off again hides the hybrid section and leaves the NetCodeConfig exactly as it is; the marker below is what + /// keeps turning it back on from overwriting anything. + /// + [SerializeField] + public bool EnableUnifiedNetcodeApi; + /// /// The hybrid mode default values already applied to this project's NetCodeConfig. /// diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs index c55cf4b159..ff3a5f3825 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs @@ -14,6 +14,9 @@ internal static class NetcodeSettingsProvider { private const float k_MaxLabelWidth = 450f; private static float s_MaxLabelWidth; +#if UNIFIED_NETCODE + private static float s_HybridLabelWidth; +#endif private static bool s_ShowEditorSettingFields = true; private static bool s_ShowProjectSettingFields = true; @@ -215,18 +218,17 @@ private static void OnGuiHandler(string obj) #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. + /// Displays the NetCodeConfig the NGO hybrid mode defaults were written into, and offers a way to restore + /// those defaults for anyone who has since changed them. /// - /// The project settings holding the applied-defaults marker. + /// The project settings holding the opt-in flag and the applied-defaults marker. private static void DrawHybridSettings(NetcodeForGameObjectsProjectSettings settings) { GUILayout.BeginVertical("Box"); GUILayout.Label("Hybrid (Netcode for Entities)", EditorStyles.boldLabel); - if (!HybridNetcodeConfigApplier.IsHybridProject()) + if (HybridNetcodeConfigApplier.RequiresExperimentalOptIn && !DrawUnifiedNetcodeApiToggle(settings)) { - 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; } @@ -234,25 +236,63 @@ private static void DrawHybridSettings(NetcodeForGameObjectsProjectSettings sett 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); + EditorGUILayout.HelpBox("No NetCodeConfig could be resolved. Open Project Settings > Multiplayer, which creates one, then reload the project.", 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); + EditorGUILayout.ObjectField(new GUIContent("Applied to", "The NetCodeConfig that Netcode for GameObjects wrote its hybrid mode defaults into."), 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."))) + if (GUILayout.Button(new GUIContent("Apply Recommended Hybrid Defaults", "Restores the snapshot, interpolation and transport values Netcode for GameObjects recommends for hybrid mode. Applied automatically once; use this to get back to them after changing them."))) { - HybridNetcodeConfigApplier.Apply(true); + HybridNetcodeConfigApplier.ApplyDefaults(true); } GUILayout.EndVertical(); } + + /// + /// Draws the opt-in for the experimental unified netcode API, writing the NGO hybrid mode defaults the first + /// time it is checked. + /// + /// The project settings holding the opt-in flag. + /// Whether the rest of the hybrid section should draw. + private static bool DrawUnifiedNetcodeApiToggle(NetcodeForGameObjectsProjectSettings settings) + { + const string enableUnifiedApiString = "Enable the experimental unified netcode API"; + + if (s_HybridLabelWidth == 0) + { + s_HybridLabelWidth = Mathf.Min(k_MaxLabelWidth, EditorStyles.label.CalcSize(new GUIContent(enableUnifiedApiString)).x); + } + + EditorGUIUtility.labelWidth = s_HybridLabelWidth; + var enabled = EditorGUILayout.Toggle( + new GUIContent( + enableUnifiedApiString, + "When enabled, Netcode for GameObjects writes the NetCodeConfig values it recommends for hybrid " + + "mode. Disabling it again hides these settings and leaves the NetCodeConfig as it is."), + settings.EnableUnifiedNetcodeApi, + GUILayout.Width(s_HybridLabelWidth + 20)); + EditorGUIUtility.labelWidth = s_MaxLabelWidth; + + if (enabled != settings.EnableUnifiedNetcodeApi) + { + settings.EnableUnifiedNetcodeApi = enabled; + settings.SaveSettings(); + if (enabled) + { + HybridNetcodeConfigApplier.ApplyDefaults(false); + } + } + + return enabled; + } #endif } diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs index 1d4f9c636a..289e2938e1 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs @@ -5,7 +5,7 @@ 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 ). + /// installed and a registered network prefab carries a ). /// /// /// This lives in the runtime assembly rather than the editor one because @@ -20,6 +20,11 @@ internal static class HybridNetcodeDefaults /// internal const int Version = 1; + // Mirrors NetworkConfig.TickRate's default. The editor writes the defaults before any NetworkManager is + // necessarily loaded, so it has nothing to read the real rate from. NetworkManager re-aligns the config when a + // session carrying ghost prefabs starts, which is what makes writing a fixed value here safe. + internal const uint DefaultTickRate = 30; + // 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. diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 941659ec85..a076b31803 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1420,12 +1420,26 @@ private bool UnifiedIsConfiguredCorrectly() 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) + return true; + } + + /// + /// Drives the tick rates from so that ghost + /// updates land on the same interval as the rest of Netcode for GameObjects. + /// + /// + /// The editor writes when it applies the hybrid defaults, + /// because no is necessarily loaded at that point. This is where the rate a + /// project actually configured gets picked up. + /// + private void UnifiedAlignTickRate() + { + if (!HybridNetcodeDefaults.ApplyTickRate(NetCodeConfig.Global, 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; } - return true; + + Log.Info(new Context(LogLevel.Developer, $"The {nameof(NetCodeConfig)} tick rates have been set to {nameof(NetworkConfig)}.{nameof(NetworkConfig.TickRate)} ({NetworkConfig.TickRate}).").AddTag("Unified")); } #endif @@ -1470,6 +1484,7 @@ public bool StartServer() ShutdownInternal(); return false; } + UnifiedAlignTickRate(); if (LogLevel <= LogLevel.Developer) { Log.Info(new Context(LogLevel.Developer, "Creating world: Default world")); @@ -1549,6 +1564,7 @@ public bool StartClient() ShutdownInternal(); return false; } + UnifiedAlignTickRate(); Log.Info(new Context(LogLevel.Developer, "Creating world: Default world")); InitializeNetcodeWorld(); } @@ -1623,6 +1639,7 @@ public bool StartHost() ShutdownInternal(); return false; } + UnifiedAlignTickRate(); Log.Info(new Context(LogLevel.Developer, "Creating world: Default world")); InitializeNetcodeWorld(); } diff --git a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs index d278431b9b..d63b261f77 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs @@ -12,6 +12,10 @@ namespace Unity.Netcode.EditorTests /// internal class HybridNetcodeDefaultsTests { + // Stands in for a value the user chose. Anything other than SnapshotPacketSize works; this is far enough from + // it that a partial apply cannot look like a pass. + private const int k_UserPacketSize = 9000; + private NetCodeConfig m_Config; [SetUp] @@ -79,23 +83,23 @@ public void ApplyTickRateLocksSimulationAndNetworkRates(uint 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. + /// The editor one-shot writes , so a project running at any + /// other rate is corrected by at start-up. That pass has to move the rate without + /// disturbing the tuned values around it. /// [Test] public void TickRateOnlyPassCorrectsTheRateAndLeavesTheTunedValuesAlone() { - const int n4eTickRate = 60; - const uint ngoTickRate = 30; + const int writtenTickRate = 30; + const uint managerTickRate = 60; - HybridNetcodeDefaults.ApplyRecommended(m_Config, n4eTickRate); - Assume.That(m_Config.ClientServerTickRate.SimulationTickRate, Is.EqualTo(n4eTickRate), "The one-shot should have written N4E's tick rate."); + HybridNetcodeDefaults.ApplyRecommended(m_Config, writtenTickRate); + Assume.That(m_Config.ClientServerTickRate.SimulationTickRate, Is.EqualTo(writtenTickRate), "The one-shot should have written the default tick rate."); - Assert.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, ngoTickRate)); + Assert.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, managerTickRate)); - Assert.AreEqual((int)ngoTickRate, m_Config.ClientServerTickRate.SimulationTickRate); - Assert.AreEqual((int)ngoTickRate, m_Config.ClientServerTickRate.NetworkTickRate); + Assert.AreEqual((int)managerTickRate, m_Config.ClientServerTickRate.SimulationTickRate); + Assert.AreEqual((int)managerTickRate, 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); @@ -152,91 +156,104 @@ public void NetTickFormWouldRegressTheBufferAtHigherTickRates() Assert.Less(bufferMs, HybridNetcodeDefaults.InterpolationTimeMS); } + /// + /// The editor writes because it has no + /// to read the real rate from. If the field default ever moves, this has to move + /// with it, or every project that kept the default starts out of sync until its first session. + /// [Test] - public void IsHybridProjectOnlyDetectsPrefabsCarryingAGhost() + public void DefaultTickRateMatchesTheNetworkConfigDefault() { - 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(); + Assert.AreEqual(new NetworkConfig().TickRate, HybridNetcodeDefaults.DefaultTickRate); + } - prefabsList.Add(new NetworkPrefab { Prefab = prefabObject }); - networkManager.NetworkConfig.Prefabs.NetworkPrefabsLists.Add(prefabsList); + /// + /// Nothing is written until the user opts into the experimental unified netcode API, and opting in is what + /// triggers the one and only write. + /// + [Test] + public void ApplyDefaultsWritesNothingUntilTheUnifiedApiIsEnabled() + { + Assume.That(HybridNetcodeConfigApplier.RequiresExperimentalOptIn, Is.True, "This project ships the unified API without an opt-in, so there is no gate to exercise."); - Assert.IsFalse(HybridNetcodeConfigApplier.IsHybridProject(), "A registered prefab without a GhostObject is not hybrid."); + var config = HybridNetcodeConfigApplier.ResolveGlobalConfig(); + Assume.That(config, Is.Not.Null, "This project has no NetCodeConfig to adjust."); - networkObject.HasGhost = true; - Assert.IsTrue(HybridNetcodeConfigApplier.IsHybridProject()); + var settings = NetcodeForGameObjectsProjectSettings.instance; + var restoreOptIn = settings.EnableUnifiedNetcodeApi; + var restoreVersion = settings.HybridDefaultsVersion; + var restoreConfig = EditorJsonUtility.ToJson(config); + try + { + settings.EnableUnifiedNetcodeApi = false; + settings.HybridDefaultsVersion = 0; + config.GhostSendSystemData.DefaultSnapshotPacketSize = k_UserPacketSize; + + HybridNetcodeConfigApplier.ApplyDefaults(false); + Assert.AreEqual(k_UserPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "Opting out has to leave the NetCodeConfig exactly as it is."); + Assert.AreEqual(0, settings.HybridDefaultsVersion, "Nothing was applied, so nothing should have been recorded."); + + settings.EnableUnifiedNetcodeApi = true; + HybridNetcodeConfigApplier.ApplyDefaults(false); + Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "Opting in should have written the defaults."); + Assert.AreEqual(HybridNetcodeDefaults.Version, settings.HybridDefaultsVersion); } finally { - Object.DestroyImmediate(prefabsList); - Object.DestroyImmediate(prefabObject); - Object.DestroyImmediate(managerObject); + Restore(config, restoreConfig); + settings.EnableUnifiedNetcodeApi = restoreOptIn; + settings.HybridDefaultsVersion = restoreVersion; + settings.SaveSettings(); } } /// - /// 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. + /// Once the marker is recorded nothing writes the config again, which is what keeps a user's own edits from + /// being reverted on the next domain reload. Only the Project Settings button overrides it. /// [Test] - public void ApplyDrivesTheTickRateAfterTheOneShotHasBeenRecorded() + public void ApplyDefaultsIsAOneShotUnlessItIsForced() { - 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 restoreOptIn = settings.EnableUnifiedNetcodeApi; 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(); + var restoreConfig = EditorJsonUtility.ToJson(config); 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.EnableUnifiedNetcodeApi = true; settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version; - config.ClientServerTickRate.SimulationTickRate = 60; - config.ClientServerTickRate.NetworkTickRate = 60; + config.GhostSendSystemData.DefaultSnapshotPacketSize = k_UserPacketSize; - HybridNetcodeConfigApplier.Apply(false); + HybridNetcodeConfigApplier.ApplyDefaults(false); + Assert.AreEqual(k_UserPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "A recorded marker has to stop the defaults from being written a second time."); - 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); + HybridNetcodeConfigApplier.ApplyDefaults(true); + Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "The Project Settings button re-applies regardless of the marker."); } finally { - config.ClientServerTickRate.SimulationTickRate = restoreSimulation; - config.ClientServerTickRate.NetworkTickRate = restoreNetwork; - EditorUtility.SetDirty(config); - AssetDatabase.SaveAssetIfDirty(config); - + Restore(config, restoreConfig); + settings.EnableUnifiedNetcodeApi = restoreOptIn; settings.HybridDefaultsVersion = restoreVersion; settings.SaveSettings(); - - Object.DestroyImmediate(prefabsList); - Object.DestroyImmediate(prefabObject); - Object.DestroyImmediate(managerObject); } } + + /// + /// Puts the project's own NetCodeConfig back the way the test found it. Serialized rather than field by field + /// because the applier writes across three nested structures. + /// + /// The project config the test mutated. + /// Its state before the test ran. + private static void Restore(NetCodeConfig config, string serializedConfig) + { + EditorJsonUtility.FromJsonOverwrite(serializedConfig, config); + EditorUtility.SetDirty(config); + AssetDatabase.SaveAssetIfDirty(config); + } } } #endif From edb3404154bae8738aad1c4618c32b8323768941 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 31 Aug 2026 12:44:51 -0500 Subject: [PATCH 05/21] test Updating to use a more NGO like testing pattern. Some comment clean up is included. --- .../Editor/HybridNetcodeDefaultsTests.cs | 165 +++++++----------- 1 file changed, 59 insertions(+), 106 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs index d63b261f77..3aef8f5eb8 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs @@ -5,15 +5,15 @@ using UnityEditor; using UnityEngine; -namespace Unity.Netcode.EditorTests +namespace Unity.Netcode.GameObjects.EditorTests { /// - /// Validates the NetCodeConfig values NGO applies in hybrid mode. + /// Validates the values NGO applies in hybrid mode. /// internal class HybridNetcodeDefaultsTests { - // Stands in for a value the user chose. Anything other than SnapshotPacketSize works; this is far enough from - // it that a partial apply cannot look like a pass. + // Stands in for a value the user chose. Far enough from SnapshotPacketSize that a partial apply cannot + // look like a pass. private const int k_UserPacketSize = 9000; private NetCodeConfig m_Config; @@ -31,26 +31,22 @@ 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."); + Assert.IsTrue(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "The first apply should report a change."); + Assert.IsFalse(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "Applying an already matching config should report no change."); } [Test] - public void ApplyRequiredCorrectsBothSettings() + public void ApplyRequiredAdjustsBothSettings() { 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.IsTrue(HybridNetcodeDefaults.ApplyRequired(m_Config), "The first apply should report a change."); + Assert.AreEqual(NetCodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap, m_Config.EnableClientServerBootstrap, "Automatic bootstrapping should be disabled."); + Assert.AreEqual(NetCodeConfig.HostWorldMode.SingleWorld, m_Config.HostWorldModeSelection, "Hybrid mode should use a single world."); Assert.IsFalse(HybridNetcodeDefaults.ApplyRequired(m_Config), "Applying an already correct config should report no change."); } @@ -59,75 +55,72 @@ public void ApplyRequiredCorrectsBothSettings() public void IsMissingRequiredDetectsEachViolation() { HybridNetcodeDefaults.ApplyRequired(m_Config); - Assert.IsFalse(HybridNetcodeDefaults.IsMissingRequired(m_Config, out _), "A corrected config should be valid for hybrid mode."); + Assert.IsFalse(HybridNetcodeDefaults.IsMissingRequired(m_Config, out _), "An adjusted 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))); + Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var worldReason), "Binary worlds should be reported as invalid."); + Assert.That(worldReason, Does.Contain(nameof(NetCodeConfig.HostWorldModeSelection)), "The reason should name the setting that is wrong."); 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))); + Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var bootstrapReason), "Automatic bootstrapping should be reported as invalid."); + Assert.That(bootstrapReason, Does.Contain(nameof(NetCodeConfig.EnableClientServerBootstrap)), "The reason should name the setting that is wrong."); } [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.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, tickRate), "The first apply should report a change."); + Assert.AreEqual((int)tickRate, m_Config.ClientServerTickRate.SimulationTickRate, "SimulationTickRate should be the requested rate."); + Assert.AreEqual((int)tickRate, m_Config.ClientServerTickRate.NetworkTickRate, "NetworkTickRate should track SimulationTickRate."); - Assert.IsFalse(HybridNetcodeDefaults.ApplyTickRate(m_Config, tickRate)); + Assert.IsFalse(HybridNetcodeDefaults.ApplyTickRate(m_Config, tickRate), "Re-applying the same rate should report no change."); } /// - /// The editor one-shot writes , so a project running at any - /// other rate is corrected by at start-up. That pass has to move the rate without - /// disturbing the tuned values around it. + /// The editor writes .
+ /// adjusts it at start-up for a project running at any other rate.
+ /// That pass leaves the tuned values alone.
///
[Test] - public void TickRateOnlyPassCorrectsTheRateAndLeavesTheTunedValuesAlone() + public void TickRateOnlyPassAdjustsTheRateAndLeavesTheTunedValuesAlone() { - const int writtenTickRate = 30; const uint managerTickRate = 60; - HybridNetcodeDefaults.ApplyRecommended(m_Config, writtenTickRate); - Assume.That(m_Config.ClientServerTickRate.SimulationTickRate, Is.EqualTo(writtenTickRate), "The one-shot should have written the default tick rate."); - - Assert.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, managerTickRate)); + HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate); - Assert.AreEqual((int)managerTickRate, m_Config.ClientServerTickRate.SimulationTickRate); - Assert.AreEqual((int)managerTickRate, 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); + Assert.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, managerTickRate), "The tick rate pass should report a change."); + Assert.AreEqual((int)managerTickRate, m_Config.ClientServerTickRate.SimulationTickRate, "SimulationTickRate should follow the NetworkManager."); + Assert.AreEqual((int)managerTickRate, m_Config.ClientServerTickRate.NetworkTickRate, "NetworkTickRate should follow the NetworkManager."); + Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, m_Config.GhostSendSystemData.DefaultSnapshotPacketSize, "A tick rate pass should leave DefaultSnapshotPacketSize alone."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeMS, m_Config.ClientTickRate.InterpolationTimeMS, "A tick rate pass should leave InterpolationTimeMS alone."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMax, m_Config.ClientTickRate.InterpolationTimeScaleMax, "A tick rate pass should leave InterpolationTimeScaleMax alone."); } [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."); + Assert.IsTrue(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "The first apply should report a change."); + + Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, m_Config.GhostSendSystemData.DefaultSnapshotPacketSize, "DefaultSnapshotPacketSize should be the tuned value."); + Assert.AreEqual(HybridNetcodeDefaults.PercentReservedForDespawn, m_Config.GhostSendSystemData.PercentReservedForDespawnMessages, "PercentReservedForDespawnMessages should be the tuned value."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeMS, m_Config.ClientTickRate.InterpolationTimeMS, "InterpolationTimeMS should be the tuned value."); + Assert.AreEqual(0u, m_Config.ClientTickRate.InterpolationTimeNetTicks, "The net tick form wins over the millisecond form, so it is cleared."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMin, m_Config.ClientTickRate.InterpolationTimeScaleMin, "InterpolationTimeScaleMin should be the tuned value."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMax, m_Config.ClientTickRate.InterpolationTimeScaleMax, "InterpolationTimeScaleMax should be the tuned value."); + Assert.AreEqual(HybridNetcodeDefaults.ClientQueueCapacity, m_Config.ClientSendQueueCapacity, "ClientSendQueueCapacity should be the tuned value."); + Assert.AreEqual(HybridNetcodeDefaults.ClientQueueCapacity, m_Config.ClientReceiveQueueCapacity, "ClientReceiveQueueCapacity should be the tuned value."); + + Assert.IsFalse(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "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. + /// N4E rounds the millisecond value up to whole network ticks.
+ /// It holds at least the configured wall clock buffer at any tick rate.
///
/// The tick rate to resolve the buffer against. [TestCase(30u)] @@ -137,14 +130,13 @@ public void InterpolationBufferHoldsAtLeastFiftyMillisecondsAtAnyTickRate(uint t 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."); + Assert.GreaterOrEqual(bufferMs, HybridNetcodeDefaults.InterpolationTimeMS, "The interpolation buffer should hold the configured wall clock time."); } [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. + // If this 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 { @@ -153,69 +145,28 @@ public void NetTickFormWouldRegressTheBufferAtHigherTickRates() }; var bufferMs = m_Config.ClientTickRate.CalculateInterpolationBufferTimeInMs(in m_Config.ClientServerTickRate); - Assert.Less(bufferMs, HybridNetcodeDefaults.InterpolationTimeMS); + Assert.Less(bufferMs, HybridNetcodeDefaults.InterpolationTimeMS, "The net tick form should fall short of the millisecond form at 60Hz."); } /// - /// The editor writes because it has no - /// to read the real rate from. If the field default ever moves, this has to move - /// with it, or every project that kept the default starts out of sync until its first session. + /// The editor writes because no + /// is loaded to read the rate from. /// [Test] public void DefaultTickRateMatchesTheNetworkConfigDefault() { - Assert.AreEqual(new NetworkConfig().TickRate, HybridNetcodeDefaults.DefaultTickRate); - } - - /// - /// Nothing is written until the user opts into the experimental unified netcode API, and opting in is what - /// triggers the one and only write. - /// - [Test] - public void ApplyDefaultsWritesNothingUntilTheUnifiedApiIsEnabled() - { - Assume.That(HybridNetcodeConfigApplier.RequiresExperimentalOptIn, Is.True, "This project ships the unified API without an opt-in, so there is no gate to exercise."); - - var config = HybridNetcodeConfigApplier.ResolveGlobalConfig(); - Assume.That(config, Is.Not.Null, "This project has no NetCodeConfig to adjust."); - - var settings = NetcodeForGameObjectsProjectSettings.instance; - var restoreOptIn = settings.EnableUnifiedNetcodeApi; - var restoreVersion = settings.HybridDefaultsVersion; - var restoreConfig = EditorJsonUtility.ToJson(config); - try - { - settings.EnableUnifiedNetcodeApi = false; - settings.HybridDefaultsVersion = 0; - config.GhostSendSystemData.DefaultSnapshotPacketSize = k_UserPacketSize; - - HybridNetcodeConfigApplier.ApplyDefaults(false); - Assert.AreEqual(k_UserPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "Opting out has to leave the NetCodeConfig exactly as it is."); - Assert.AreEqual(0, settings.HybridDefaultsVersion, "Nothing was applied, so nothing should have been recorded."); - - settings.EnableUnifiedNetcodeApi = true; - HybridNetcodeConfigApplier.ApplyDefaults(false); - Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "Opting in should have written the defaults."); - Assert.AreEqual(HybridNetcodeDefaults.Version, settings.HybridDefaultsVersion); - } - finally - { - Restore(config, restoreConfig); - settings.EnableUnifiedNetcodeApi = restoreOptIn; - settings.HybridDefaultsVersion = restoreVersion; - settings.SaveSettings(); - } + Assert.AreEqual(new NetworkConfig().TickRate, HybridNetcodeDefaults.DefaultTickRate, "DefaultTickRate should track the NetworkConfig.TickRate default."); } /// - /// Once the marker is recorded nothing writes the config again, which is what keeps a user's own edits from - /// being reverted on the next domain reload. Only the Project Settings button overrides it. + /// Once the marker is recorded nothing writes the config again.
+ /// Only the Project Settings button overrides it.
///
[Test] public void ApplyDefaultsIsAOneShotUnlessItIsForced() { var config = HybridNetcodeConfigApplier.ResolveGlobalConfig(); - Assume.That(config, Is.Not.Null, "This project has no NetCodeConfig to adjust."); + Assert.IsNotNull(config, "This project should have a NetCodeConfig to adjust."); var settings = NetcodeForGameObjectsProjectSettings.instance; var restoreOptIn = settings.EnableUnifiedNetcodeApi; @@ -228,10 +179,10 @@ public void ApplyDefaultsIsAOneShotUnlessItIsForced() config.GhostSendSystemData.DefaultSnapshotPacketSize = k_UserPacketSize; HybridNetcodeConfigApplier.ApplyDefaults(false); - Assert.AreEqual(k_UserPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "A recorded marker has to stop the defaults from being written a second time."); + Assert.AreEqual(k_UserPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "A recorded marker should stop the defaults from being written a second time."); HybridNetcodeConfigApplier.ApplyDefaults(true); - Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "The Project Settings button re-applies regardless of the marker."); + Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "The Project Settings button should re-apply regardless of the marker."); } finally { @@ -243,9 +194,11 @@ public void ApplyDefaultsIsAOneShotUnlessItIsForced() } /// - /// Puts the project's own NetCodeConfig back the way the test found it. Serialized rather than field by field - /// because the applier writes across three nested structures. + /// Puts the project's own back the way the test found it. /// + /// + /// Serialized rather than field by field because the applier writes across three nested structures. + /// /// The project config the test mutated. /// Its state before the test ran. private static void Restore(NetCodeConfig config, string serializedConfig) From a2894d20f7a5aa837b0787b505dcd2fe9815985f Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 31 Aug 2026 13:01:20 -0500 Subject: [PATCH 06/21] update resolving concern over the .EndVertical(); --- .../Editor/Configuration/NetcodeSettingsProvider.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs index ff3a5f3825..83769d9ecf 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs @@ -224,15 +224,14 @@ private static void OnGuiHandler(string obj) /// The project settings holding the opt-in flag and the applied-defaults marker. private static void DrawHybridSettings(NetcodeForGameObjectsProjectSettings settings) { - GUILayout.BeginVertical("Box"); - GUILayout.Label("Hybrid (Netcode for Entities)", EditorStyles.boldLabel); - if (HybridNetcodeConfigApplier.RequiresExperimentalOptIn && !DrawUnifiedNetcodeApiToggle(settings)) { - GUILayout.EndVertical(); return; } + GUILayout.BeginVertical("Box"); + GUILayout.Label("Hybrid (Netcode for Entities)", EditorStyles.boldLabel); + var config = HybridNetcodeConfigApplier.ResolveGlobalConfig(); if (config == null) { From c4fd5b294d1b7b200c174eba8f610b2f7dcad959 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 31 Aug 2026 15:54:40 -0500 Subject: [PATCH 07/21] style removing white space. --- .../Editor/Configuration/HybridNetcodeConfigApplier.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs index 89df7283ca..36e9db4595 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs @@ -22,7 +22,7 @@ internal static class HybridNetcodeConfigApplier /// /// /// TODO-RELEASE: Set this to true before the 6000.7.0 release manifest submission if Netcode for Entities - /// ships the unified API as experimental and its scripting defines. + /// ships the unified API as experimental and its scripting defines. /// Note: This is deliberately not a const: IDE0035 (remove unreachable code) is an error in this repository, so a /// const would fail the standards job as soon as it was set to false. /// From 17d2cf1f4a40a803d2a586689abaf38da0c16573 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 21 Sep 2026 16:28:40 -0500 Subject: [PATCH 08/21] chore: n4e 7.0.0 spellings, review feedback and snapshot size N4E 7.0.0 renamed NetCodeConfig to NetcodeConfig and moved its types into Unity.Netcode. Use the 7.0.0 spelling, aliased for 6.7.0 the way the rest of the unified code already is. The editor test assembly still declared UNIFIED_NETCODE as a versionDefine after #4158 made it project-level, so its unified block compiled while the runtime's did not. It also referenced Unity.NetCode by name alongside the GUID for the same assembly. Both removed, UNIFIED_NETCODE_7_0_0 added. The snapshot packet size drops from 15000 to 4096. A snapshot is sent unreliably, so losing any one of its fragments loses the whole snapshot: 4096 spans 3 datagrams at the 1400 byte MTU where 15000 spanned 11, and still caps ~840 ghosts at the measured 4.87 bytes each. The defaults version is bumped so a project that already recorded the marker picks the new value up. Review feedback: - The experimental opt-in is gone. N4E ships as a normal 3.0.0 release. - ResolveGlobalConfig's project scan is gone. N4E assigns NetcodeConfig.Global from an InitializeOnLoadMethod and delayCall runs after those complete, so there is nothing left for a scan to catch. - The apply log uses the contextual logger at Developer level. - UnifiedIsConfiguredCorrectly reports which setting is wrong rather than discarding the reason it already computed. --- .../HybridNetcodeConfigApplier.cs | 68 ++++--------------- .../NetcodeForGameObjectsProjectSettings.cs | 18 ++--- .../Configuration/NetcodeSettingsProvider.cs | 60 ++-------------- .../Configuration/HybridNetcodeDefaults.cs | 44 ++++++------ .../Editor/HybridNetcodeDefaultsTests.cs | 38 +++++------ .../Editor/Unity.Netcode.Editor.Tests.asmdef | 7 +- .../UnifiedSnapshotPacketSizeMeasurement.cs | 54 +++++++++------ 7 files changed, 103 insertions(+), 186 deletions(-) diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs index 36e9db4595..21a3949d17 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs @@ -1,41 +1,30 @@ #if UNIFIED_NETCODE -using Unity.NetCode; +using Unity.Netcode.Logging; +#if !UNIFIED_NETCODE_7_0_0 +using NetcodeConfig = Unity.NetCode.NetCodeConfig; +#endif using UnityEditor; -using UnityEngine; namespace Unity.Netcode.GameObjects.Editor.Configuration { /// - /// Writes the values NGO recommends for hybrid mode, once, the first time a - /// is available. + /// Writes the values NGO recommends for hybrid mode, once, the first time a + /// is available. /// /// - /// This does not create . This finds the one N4E created and modifies it. + /// This does not create . This finds the one N4E created and modifies it. /// Nothing tracks the project after that write. The defaults are inert in a project with no hybrid prefabs, and /// re-aligns the tick rate at start-up in a project that has them, so there is no /// reason to scan for ghost prefabs from the editor. /// internal static class HybridNetcodeConfigApplier { - /// - /// Whether the user has to opt into the experimental unified netcode API before NGO writes anything. - /// - /// - /// TODO-RELEASE: Set this to true before the 6000.7.0 release manifest submission if Netcode for Entities - /// ships the unified API as experimental and its scripting defines. - /// Note: This is deliberately not a const: IDE0035 (remove unreachable code) is an error in this repository, so a - /// const would fail the standards job as soon as it was set to false. - /// - internal static readonly bool RequiresExperimentalOptIn = false; - - private static NetCodeConfig s_ScannedConfig; - private static bool s_ConfigScanned; - [InitializeOnLoadMethod] private static void OnApplicationStart() { - // Cross-assembly ordering between the two is not a documented contract. - // Defer rather than racing it. + // N4E assigns NetcodeConfig.Global from its own [InitializeOnLoadMethod] and creates the asset when + // there is none. delayCall runs after those have completed, which is what makes Global reliable here + // without a lookup of our own. EditorApplication.delayCall += OnDelayCall; } @@ -46,7 +35,7 @@ private static void OnDelayCall() } /// - /// Writes the NGO hybrid mode defaults into the project's . + /// Writes the NGO hybrid mode defaults into the project's . /// /// /// Driven by the button in Project Settings: @@ -62,11 +51,6 @@ internal static void ApplyDefaults(bool force) } var settings = NetcodeForGameObjectsProjectSettings.instance; - if (RequiresExperimentalOptIn && !settings.EnableUnifiedNetcodeApi) - { - return; - } - if (!force && settings.HybridDefaultsVersion >= HybridNetcodeDefaults.Version) { return; @@ -74,7 +58,7 @@ internal static void ApplyDefaults(bool force) // A project with no config yet leaves the marker unrecorded so that the next domain reload tries again. // N4E creates one on any domain reload that finds none. - var config = ResolveGlobalConfig(); + var config = NetcodeConfig.Global; if (config == null) { return; @@ -84,7 +68,7 @@ internal static void ApplyDefaults(bool force) { EditorUtility.SetDirty(config); AssetDatabase.SaveAssetIfDirty(config); - 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); + new ContextualLogger(config).Info(new Context(LogLevel.Developer, $"Applied the hybrid mode defaults to '{config.name}'. These are tuned for Netcode for GameObjects and can be changed freely; they will not be re-applied automatically. Use Project Settings > Multiplayer > Netcode for GameObjects to restore them.").AddTag("Unified")); } // Recorded even when the config already matched and nothing was written. Leaving it unrecorded would make @@ -92,32 +76,6 @@ internal static void ApplyDefaults(bool force) settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version; settings.SaveSettings(); } - - /// - /// Resolves the config N4E considers global, falling back to a project scan when N4E has not assigned one yet. - /// - /// - /// The scan is done at most once per domain reload, including when it finds nothing, because this is also - /// reached from OnGUI and walks the entire project. A config created - /// after the scan is picked up on the next domain reload. - /// - /// The config to adjust or null if no config exists. - internal static NetCodeConfig ResolveGlobalConfig() - { - if (NetCodeConfig.Global != null) - { - return NetCodeConfig.Global; - } - - if (!s_ConfigScanned) - { - s_ConfigScanned = true; - var guids = AssetDatabase.FindAssets($"t:{nameof(NetCodeConfig)}"); - s_ScannedConfig = guids.Length == 1 ? AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0])) : null; - } - - return s_ScannedConfig; - } } } #endif diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs index 9f9b9939d9..350d942bac 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs @@ -39,22 +39,12 @@ private void OnEnable() #if UNIFIED_NETCODE /// - /// Whether the user has opted into the experimental unified netcode API. + /// The version of the hybrid mode default values already written into this project's NetcodeConfig, or zero + /// when they have never been written. /// /// - /// Only consulted while holds. Turning it - /// off again hides the hybrid section and leaves the NetCodeConfig exactly as it is; the marker below is what - /// keeps turning it back on from overwriting anything. - /// - [SerializeField] - public bool EnableUnifiedNetcodeApi; - - /// - /// 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. + /// A version rather than a flag so a later revision of those values re-applies exactly once. Recording it is + /// what keeps them a one-shot: a user who changes them is not overwritten on the next domain reload. /// [SerializeField] public int HybridDefaultsVersion; diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs index 83769d9ecf..a313fcd4b4 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.IO; -#if UNIFIED_NETCODE -using Unity.NetCode; +#if UNIFIED_NETCODE && !UNIFIED_NETCODE_7_0_0 +using NetcodeConfig = Unity.NetCode.NetCodeConfig; #endif using UnityEditor; using UnityEngine; @@ -14,9 +14,6 @@ internal static class NetcodeSettingsProvider { private const float k_MaxLabelWidth = 450f; private static float s_MaxLabelWidth; -#if UNIFIED_NETCODE - private static float s_HybridLabelWidth; -#endif private static bool s_ShowEditorSettingFields = true; private static bool s_ShowProjectSettingFields = true; @@ -218,29 +215,24 @@ private static void OnGuiHandler(string obj) #if UNIFIED_NETCODE /// - /// Displays the NetCodeConfig the NGO hybrid mode defaults were written into, and offers a way to restore + /// Displays the NetcodeConfig the NGO hybrid mode defaults were written into, and offers a way to restore /// those defaults for anyone who has since changed them. /// - /// The project settings holding the opt-in flag and the applied-defaults marker. + /// The project settings holding the applied-defaults marker. private static void DrawHybridSettings(NetcodeForGameObjectsProjectSettings settings) { - if (HybridNetcodeConfigApplier.RequiresExperimentalOptIn && !DrawUnifiedNetcodeApiToggle(settings)) - { - return; - } - GUILayout.BeginVertical("Box"); GUILayout.Label("Hybrid (Netcode for Entities)", EditorStyles.boldLabel); - var config = HybridNetcodeConfigApplier.ResolveGlobalConfig(); + var config = NetcodeConfig.Global; if (config == null) { - EditorGUILayout.HelpBox("No NetCodeConfig could be resolved. Open Project Settings > Multiplayer, which creates one, then reload the project.", MessageType.Warning); + EditorGUILayout.HelpBox("No NetcodeConfig has been assigned yet. Open Project Settings > Multiplayer, which creates one, then reload the project.", MessageType.Warning); GUILayout.EndVertical(); return; } - EditorGUILayout.ObjectField(new GUIContent("Applied to", "The NetCodeConfig that Netcode for GameObjects wrote its hybrid mode defaults into."), config, typeof(NetCodeConfig), false); + EditorGUILayout.ObjectField(new GUIContent("Applied to", "The NetcodeConfig that Netcode for GameObjects wrote its hybrid mode defaults into."), config, typeof(NetcodeConfig), false); if (settings.HybridDefaultsVersion < HybridNetcodeDefaults.Version) { @@ -254,44 +246,6 @@ private static void DrawHybridSettings(NetcodeForGameObjectsProjectSettings sett GUILayout.EndVertical(); } - - /// - /// Draws the opt-in for the experimental unified netcode API, writing the NGO hybrid mode defaults the first - /// time it is checked. - /// - /// The project settings holding the opt-in flag. - /// Whether the rest of the hybrid section should draw. - private static bool DrawUnifiedNetcodeApiToggle(NetcodeForGameObjectsProjectSettings settings) - { - const string enableUnifiedApiString = "Enable the experimental unified netcode API"; - - if (s_HybridLabelWidth == 0) - { - s_HybridLabelWidth = Mathf.Min(k_MaxLabelWidth, EditorStyles.label.CalcSize(new GUIContent(enableUnifiedApiString)).x); - } - - EditorGUIUtility.labelWidth = s_HybridLabelWidth; - var enabled = EditorGUILayout.Toggle( - new GUIContent( - enableUnifiedApiString, - "When enabled, Netcode for GameObjects writes the NetCodeConfig values it recommends for hybrid " + - "mode. Disabling it again hides these settings and leaves the NetCodeConfig as it is."), - settings.EnableUnifiedNetcodeApi, - GUILayout.Width(s_HybridLabelWidth + 20)); - EditorGUIUtility.labelWidth = s_MaxLabelWidth; - - if (enabled != settings.EnableUnifiedNetcodeApi) - { - settings.EnableUnifiedNetcodeApi = enabled; - settings.SaveSettings(); - if (enabled) - { - HybridNetcodeConfigApplier.ApplyDefaults(false); - } - } - - return enabled; - } #endif } diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs index 289e2938e1..ec9b1adb02 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs @@ -1,15 +1,18 @@ #if UNIFIED_NETCODE +#if !UNIFIED_NETCODE_7_0_0 using Unity.NetCode; +using NetcodeConfig = Unity.NetCode.NetCodeConfig; +#endif namespace Unity.Netcode { /// - /// The values NGO needs when running in hybrid mode (i.e. Netcode for Entities is + /// The values NGO needs when running in hybrid mode (i.e. Netcode for Entities is /// installed and a 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. + /// This lives in the runtime assembly rather than the editor one because + /// is internal to Netcode for Entities, 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 @@ -18,7 +21,7 @@ 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; + internal const int Version = 2; // Mirrors NetworkConfig.TickRate's default. The editor writes the defaults before any NetworkManager is // necessarily loaded, so it has nothing to read the real rate from. NetworkManager re-aligns the config when a @@ -26,9 +29,10 @@ internal static class HybridNetcodeDefaults internal const uint DefaultTickRate = 30; // 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; + // bytes per snapshot, so 4096 carries ~840 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. Kept small because a snapshot is + // sent unreliably: losing any one of its fragments loses the whole snapshot. + internal const int SnapshotPacketSize = 4096; // 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; @@ -42,7 +46,7 @@ internal static class HybridNetcodeDefaults 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. + // A full snapshot fragments into ~3 datagrams and each fragment consumes a queue slot. internal const int ClientQueueCapacity = 128; /// @@ -50,20 +54,20 @@ internal static class HybridNetcodeDefaults /// /// The config to correct. /// True if anything changed. - internal static bool ApplyRequired(NetCodeConfig config) + 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) + if (config.EnableClientServerBootstrap != NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap) { - config.EnableClientServerBootstrap = NetCodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap; + config.EnableClientServerBootstrap = NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap; changed = true; } - if (config.HostWorldModeSelection != NetCodeConfig.HostWorldMode.SingleWorld) + if (config.HostWorldModeSelection != NetcodeConfig.HostWorldMode.SingleWorld) { - config.HostWorldModeSelection = NetCodeConfig.HostWorldMode.SingleWorld; + config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.SingleWorld; changed = true; } @@ -77,7 +81,7 @@ internal static bool ApplyRequired(NetCodeConfig config) /// The config to correct. /// The owning 's configured tick rate. /// True if anything changed. - internal static bool ApplyTickRate(NetCodeConfig config, uint tickRate) + internal static bool ApplyTickRate(NetcodeConfig config, uint tickRate) { var rate = (int)tickRate; if (config.ClientServerTickRate.SimulationTickRate == rate && config.ClientServerTickRate.NetworkTickRate == rate) @@ -99,7 +103,7 @@ internal static bool ApplyTickRate(NetCodeConfig config, uint tickRate) /// The config to correct. /// The owning 's configured tick rate. /// True if anything changed. - internal static bool ApplyRecommended(NetCodeConfig config, uint tickRate) + internal static bool ApplyRecommended(NetcodeConfig config, uint tickRate) { var changed = ApplyRequired(config); changed |= ApplyTickRate(config, tickRate); @@ -126,17 +130,17 @@ internal static bool ApplyRecommended(NetCodeConfig config, uint tickRate) /// 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) + internal static bool IsMissingRequired(NetcodeConfig config, out string reason) { - if (config.HostWorldModeSelection != NetCodeConfig.HostWorldMode.SingleWorld) + if (config.HostWorldModeSelection != NetcodeConfig.HostWorldMode.SingleWorld) { - reason = $"{nameof(NetCodeConfig.HostWorldModeSelection)} must be {nameof(NetCodeConfig.HostWorldMode.SingleWorld)} but is {config.HostWorldModeSelection}"; + reason = $"{nameof(NetcodeConfig.HostWorldModeSelection)} must be {nameof(NetcodeConfig.HostWorldMode.SingleWorld)} but is {config.HostWorldModeSelection}"; return true; } - if (config.EnableClientServerBootstrap != NetCodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap) + 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"; + reason = $"{nameof(NetcodeConfig.EnableClientServerBootstrap)} must be {nameof(NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap)} because {nameof(NetworkManager)} owns world creation in hybrid mode"; return true; } diff --git a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs index 3aef8f5eb8..c4be6457ff 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs @@ -1,6 +1,9 @@ #if UNIFIED_NETCODE using NUnit.Framework; +#if !UNIFIED_NETCODE_7_0_0 using Unity.NetCode; +using NetcodeConfig = Unity.NetCode.NetCodeConfig; +#endif using Unity.Netcode.GameObjects.Editor.Configuration; using UnityEditor; using UnityEngine; @@ -8,7 +11,7 @@ namespace Unity.Netcode.GameObjects.EditorTests { /// - /// Validates the values NGO applies in hybrid mode. + /// Validates the values NGO applies in hybrid mode. /// internal class HybridNetcodeDefaultsTests { @@ -16,12 +19,12 @@ internal class HybridNetcodeDefaultsTests // look like a pass. private const int k_UserPacketSize = 9000; - private NetCodeConfig m_Config; + private NetcodeConfig m_Config; [SetUp] public void SetUp() { - m_Config = ScriptableObject.CreateInstance(); + m_Config = ScriptableObject.CreateInstance(); m_Config.Reset(); } @@ -41,12 +44,12 @@ public void ApplyRecommendedReportsNoChangeWhenConfigAlreadyMatches() [Test] public void ApplyRequiredAdjustsBothSettings() { - m_Config.EnableClientServerBootstrap = NetCodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap; - m_Config.HostWorldModeSelection = NetCodeConfig.HostWorldMode.BinaryWorlds; + m_Config.EnableClientServerBootstrap = NetcodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap; + m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.BinaryWorlds; Assert.IsTrue(HybridNetcodeDefaults.ApplyRequired(m_Config), "The first apply should report a change."); - Assert.AreEqual(NetCodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap, m_Config.EnableClientServerBootstrap, "Automatic bootstrapping should be disabled."); - Assert.AreEqual(NetCodeConfig.HostWorldMode.SingleWorld, m_Config.HostWorldModeSelection, "Hybrid mode should use a single world."); + Assert.AreEqual(NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap, m_Config.EnableClientServerBootstrap, "Automatic bootstrapping should be disabled."); + Assert.AreEqual(NetcodeConfig.HostWorldMode.SingleWorld, m_Config.HostWorldModeSelection, "Hybrid mode should use a single world."); Assert.IsFalse(HybridNetcodeDefaults.ApplyRequired(m_Config), "Applying an already correct config should report no change."); } @@ -57,14 +60,14 @@ public void IsMissingRequiredDetectsEachViolation() HybridNetcodeDefaults.ApplyRequired(m_Config); Assert.IsFalse(HybridNetcodeDefaults.IsMissingRequired(m_Config, out _), "An adjusted config should be valid for hybrid mode."); - m_Config.HostWorldModeSelection = NetCodeConfig.HostWorldMode.BinaryWorlds; + m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.BinaryWorlds; Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var worldReason), "Binary worlds should be reported as invalid."); - Assert.That(worldReason, Does.Contain(nameof(NetCodeConfig.HostWorldModeSelection)), "The reason should name the setting that is wrong."); + Assert.That(worldReason, Does.Contain(nameof(NetcodeConfig.HostWorldModeSelection)), "The reason should name the setting that is wrong."); - m_Config.HostWorldModeSelection = NetCodeConfig.HostWorldMode.SingleWorld; - m_Config.EnableClientServerBootstrap = NetCodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap; + m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.SingleWorld; + m_Config.EnableClientServerBootstrap = NetcodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap; Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var bootstrapReason), "Automatic bootstrapping should be reported as invalid."); - Assert.That(bootstrapReason, Does.Contain(nameof(NetCodeConfig.EnableClientServerBootstrap)), "The reason should name the setting that is wrong."); + Assert.That(bootstrapReason, Does.Contain(nameof(NetcodeConfig.EnableClientServerBootstrap)), "The reason should name the setting that is wrong."); } [TestCase(30u)] @@ -165,16 +168,14 @@ public void DefaultTickRateMatchesTheNetworkConfigDefault() [Test] public void ApplyDefaultsIsAOneShotUnlessItIsForced() { - var config = HybridNetcodeConfigApplier.ResolveGlobalConfig(); - Assert.IsNotNull(config, "This project should have a NetCodeConfig to adjust."); + var config = NetcodeConfig.Global; + Assert.IsNotNull(config, "This project should have a NetcodeConfig to adjust."); var settings = NetcodeForGameObjectsProjectSettings.instance; - var restoreOptIn = settings.EnableUnifiedNetcodeApi; var restoreVersion = settings.HybridDefaultsVersion; var restoreConfig = EditorJsonUtility.ToJson(config); try { - settings.EnableUnifiedNetcodeApi = true; settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version; config.GhostSendSystemData.DefaultSnapshotPacketSize = k_UserPacketSize; @@ -187,21 +188,20 @@ public void ApplyDefaultsIsAOneShotUnlessItIsForced() finally { Restore(config, restoreConfig); - settings.EnableUnifiedNetcodeApi = restoreOptIn; settings.HybridDefaultsVersion = restoreVersion; settings.SaveSettings(); } } /// - /// Puts the project's own back the way the test found it. + /// Puts the project's own back the way the test found it. /// /// /// Serialized rather than field by field because the applier writes across three nested structures. /// /// The project config the test mutated. /// Its state before the test ran. - private static void Restore(NetCodeConfig config, string serializedConfig) + private static void Restore(NetcodeConfig config, string serializedConfig) { EditorJsonUtility.FromJsonOverwrite(serializedConfig, config); EditorUtility.SetDirty(config); 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 6916cc2f42..4f5b07056a 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 @@ -14,8 +14,7 @@ "Unity.Mathematics", "UnityEngine.TestRunner", "UnityEditor.TestRunner", - "Unity.Netcode.Runtime.Tests", - "Unity.NetCode" + "Unity.Netcode.Runtime.Tests" ], "includePlatforms": [ "Editor" @@ -38,8 +37,8 @@ }, { "name": "com.unity.netcode", - "expression": "1.10.1", - "define": "UNIFIED_NETCODE" + "expression": "7.0.0", + "define": "UNIFIED_NETCODE_7_0_0" }, { "name": "Unity", diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs index 53a44d431b..a2cd98ae4c 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs @@ -5,6 +5,9 @@ using NUnit.Framework; using Unity.Collections; using Unity.Entities; +#if !UNIFIED_NETCODE_7_0_0 +using Unity.NetCode; +#endif using Unity.Netcode.TestHelpers.Runtime; using UnityEngine; using UnityEngine.TestTools; @@ -12,11 +15,20 @@ 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. + /// Measurement harness (not a pass/fail behaviour test) for how much of an N4E snapshot hybrid prefab transform + /// synchronization consumes, swept across and ghost + /// count. This is where HybridNetcodeDefaults.SnapshotPacketSize comes from; re-run it when that value is + /// reconsidered or when N4E changes its snapshot encoding. /// + /// + /// Spawns N hybrid ghosts, keeps every one dirty on every tick, and reads the client-side + /// singleton over a fixed sample window. Each case emits one PKTSZ| line: + /// packet size, requested ghosts, ghosts the client spawned, samples, mean snapshot bits, p95 bits, max bits, + /// mean ghosts per snapshot, p95 ghosts, bytes per ghost, fraction of snapshots that hit the size cap, fraction + /// that could not carry every ghost, effective per ghost update rate, tick rate. Bytes per ghost and the + /// effective update rate are the two that decide the default: raising the cap only helps while the latter is + /// still below the tick rate. + /// [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 @@ -32,7 +44,7 @@ internal class UnifiedSnapshotPacketSizeMeasurement : NetcodeIntegrationTest private GameObject m_Prefab; private Transform[] m_Instances; - private NetCode.GhostObject[] m_Ghosts; + private GhostObject[] m_Ghosts; private float[] m_Phases; private int m_Frame; @@ -94,14 +106,14 @@ private void MoveAll() 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(); + 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"); @@ -140,27 +152,27 @@ public IEnumerator MeasureSnapshotSize( 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(); + 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()); + var tickRateQuery = hostWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); if (tickRateQuery.CalculateEntityCount() == 1) { - var configured = tickRateQuery.GetSingleton(); + 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 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_Ghosts = new GhostObject[objectCount]; m_Phases = new float[objectCount]; var random = new System.Random(12345); for (int i = 0; i < objectCount; i++) @@ -168,7 +180,7 @@ public IEnumerator MeasureSnapshotSize( 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(); + m_Ghosts[i] = spawned.GetComponent(); if ((i + 1) % k_SpawnsPerFrame == 0) { MoveAll(); @@ -198,7 +210,7 @@ public IEnumerator MeasureSnapshotSize( { continue; } - var metrics = snapshotMetricsQuery.GetSingleton(); + var metrics = snapshotMetricsQuery.GetSingleton(); if (metrics.SnapshotTick == 0 || metrics.SnapshotTick == lastSnapshotTick) { continue; From 31792eb93149ed1467ffb2846d96b39cbfeedd81 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 6 Oct 2026 17:27:41 -0500 Subject: [PATCH 09/21] feat: hybrid NetcodeConfig values applied at session start Netcode for GameObjects no longer writes anything into the NetcodeConfig asset. NetworkManager writes the in-memory global config immediately before the world is created from it, and Netcode for Entities seeds its world singletons from that. Two values are driven, both measured rather than assumed: - The tick rates. NGO's own send queues are flushed by a system in N4E's SimulationSystemGroup, so a simulation rate below NetworkConfig.TickRate starves NGO's outbound traffic. N4E defaults to 60 and NGO to 30. - The snapshot packet size, behind the new NetworkConfig.AutoConfigureSnapshotSize. N4E's default of one MTU round-robins above roughly 230 GhostObjects: at 512 instances it holds a 13.51Hz effective update rate against a 30Hz tick, where 4096 holds 30.00Hz with no saturated snapshots. Below that count the cap is never reached, so it costs nothing. UnifiedBootstrap no longer delegates to base.Initialize. Entities selects the most derived ICustomBootstrap, so this type is the only one it instantiates under UNIFIED_NETCODE and N4E's own Initialize is never reached on its own. Calling it only gave N4E a chance to create worlds this bootstrap would then reject, which is why EnableClientServerBootstrap had to be configured at all. It no longer does. Removed with it: the editor applier, the Project Settings section, the applied-defaults version marker, and the interpolation, despawn reservation and transport queue values. Those were tuned against a 2000 instance stress test and are either inside N4E's own recommended ranges or inert below the snapshot cap. The FailValidation and ClientDoesNotRequest hybrid test cases are removed pending a separate fix: the server schedules its disconnect reason into N4E's outgoing RPC buffer and then tears the connection down in the same call, before RpcSystem transmits it. --- com.unity.netcode.gameobjects/CHANGELOG.md | 6 +- .../HybridNetcodeConfigApplier.cs | 81 --------- .../HybridNetcodeConfigApplier.cs.meta | 2 - .../NetcodeForGameObjectsProjectSettings.cs | 12 -- .../Configuration/NetcodeSettingsProvider.cs | 42 ----- .../Components/Helpers/UnifiedBootstrap.cs | 12 +- .../Configuration/HybridNetcodeDefaults.cs | 120 +++--------- .../Runtime/Configuration/NetworkConfig.cs | 14 ++ .../Runtime/Core/NetworkManager.cs | 26 +-- .../Editor/HybridNetcodeDefaultsTests.cs | 172 +++--------------- .../Runtime/Connection/ConnectionApproval.cs | 28 ++- .../ConnectionApprovalTimeoutTests.cs | 4 +- .../UnifiedSnapshotPacketSizeMeasurement.cs | 5 + 13 files changed, 117 insertions(+), 407 deletions(-) delete mode 100644 com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs delete mode 100644 com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 92cef43b3a..89dd760726 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -10,8 +10,8 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Added -- Added additional section under Project Settings > Multiplayer > Netcode for GameObjects, shown when Netcode for Entities is installed and provides users a way to restore the recommended settings. (#4144) -- Added alignment of the Netcode for Entities tick rates with `NetworkConfig.TickRate` when a session with `GhostObject` prefabs is started, so ghost updates land on the same (relative) interval as the rest of Netcode for GameObjects. (#4144) +- Added alignment of the Netcode for Entities tick rates with `NetworkConfig.TickRate` when a session carrying `GhostObject` prefabs is started, so `GhostObject` updates land on the same interval as the rest of Netcode for GameObjects. (#4144) +- Added `NetworkConfig.AutoConfigureSnapshotSize`, enabled by default, which raises the Netcode for Entities snapshot packet size when a session carrying `GhostObject` prefabs is started. Netcode for Entities defaults to a single MTU, which updates `GhostObject`s well below the tick rate at the instance counts Netcode for GameObjects projects typically run. Disable it to manage the value yourself. (#4144) ### Changed @@ -21,7 +21,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed -- Issue where the hybrid mode `NetcodeConfig` validation messages were not interpolated and did not check that automatic bootstrapping was disabled. (#4144) +- Issue where the hybrid mode `NetcodeConfig` validation messages were not interpolated, so they reported placeholders instead of the setting that was wrong. (#4144) ### Security diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs deleted file mode 100644 index 21a3949d17..0000000000 --- a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs +++ /dev/null @@ -1,81 +0,0 @@ -#if UNIFIED_NETCODE -using Unity.Netcode.Logging; -#if !UNIFIED_NETCODE_7_0_0 -using NetcodeConfig = Unity.NetCode.NetCodeConfig; -#endif -using UnityEditor; - -namespace Unity.Netcode.GameObjects.Editor.Configuration -{ - /// - /// Writes the values NGO recommends for hybrid mode, once, the first time a - /// is available. - /// - /// - /// This does not create . This finds the one N4E created and modifies it. - /// Nothing tracks the project after that write. The defaults are inert in a project with no hybrid prefabs, and - /// re-aligns the tick rate at start-up in a project that has them, so there is no - /// reason to scan for ghost prefabs from the editor. - /// - internal static class HybridNetcodeConfigApplier - { - [InitializeOnLoadMethod] - private static void OnApplicationStart() - { - // N4E assigns NetcodeConfig.Global from its own [InitializeOnLoadMethod] and creates the asset when - // there is none. delayCall runs after those have completed, which is what makes Global reliable here - // without a lookup of our own. - EditorApplication.delayCall += OnDelayCall; - } - - private static void OnDelayCall() - { - EditorApplication.delayCall -= OnDelayCall; - ApplyDefaults(false); - } - - /// - /// Writes the NGO hybrid mode defaults into the project's . - /// - /// - /// Driven by the button in Project Settings: - /// - When true: re-applies the full tuned set even though this project has already had it applied once. - /// - When false: writes only if this project has never had them written. From that point forward, the user's - /// edits are not overwritten. - /// - internal static void ApplyDefaults(bool force) - { - if (EditorApplication.isPlayingOrWillChangePlaymode) - { - return; - } - - var settings = NetcodeForGameObjectsProjectSettings.instance; - if (!force && settings.HybridDefaultsVersion >= HybridNetcodeDefaults.Version) - { - return; - } - - // A project with no config yet leaves the marker unrecorded so that the next domain reload tries again. - // N4E creates one on any domain reload that finds none. - var config = NetcodeConfig.Global; - if (config == null) - { - return; - } - - if (HybridNetcodeDefaults.ApplyRecommended(config, HybridNetcodeDefaults.DefaultTickRate)) - { - EditorUtility.SetDirty(config); - AssetDatabase.SaveAssetIfDirty(config); - new ContextualLogger(config).Info(new Context(LogLevel.Developer, $"Applied the hybrid mode defaults to '{config.name}'. These are tuned for Netcode for GameObjects and can be changed freely; they will not be re-applied automatically. Use Project Settings > Multiplayer > Netcode for GameObjects to restore them.").AddTag("Unified")); - } - - // 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(); - } - } -} -#endif diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta deleted file mode 100644 index 5188468189..0000000000 --- a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -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 350d942bac..1518f912cc 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs @@ -37,18 +37,6 @@ private void OnEnable() [SerializeField] public bool GenerateDefaultNetworkPrefabs = true; -#if UNIFIED_NETCODE - /// - /// The version of the hybrid mode default values already written into this project's NetcodeConfig, or zero - /// when they have never been written. - /// - /// - /// A version rather than a flag so a later revision of those values re-applies exactly once. Recording it is - /// what keeps them a one-shot: a user who changes them is not overwritten on the next domain reload. - /// - [SerializeField] - public int HybridDefaultsVersion; -#endif internal void SaveSettings() { diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs index a313fcd4b4..a8b521117c 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs @@ -1,8 +1,5 @@ using System.Collections.Generic; using System.IO; -#if UNIFIED_NETCODE && !UNIFIED_NETCODE_7_0_0 -using NetcodeConfig = Unity.NetCode.NetCodeConfig; -#endif using UnityEditor; using UnityEngine; using Directory = UnityEngine.Windows.Directory; @@ -195,10 +192,6 @@ private static void OnGuiHandler(string obj) networkPrefabsPath, GUILayout.Width(s_MaxLabelWidth + 270)); GUILayout.EndVertical(); - -#if UNIFIED_NETCODE - DrawHybridSettings(settings); -#endif } EditorGUILayout.EndFoldoutHeaderGroup(); GUILayout.EndVertical(); @@ -212,41 +205,6 @@ private static void OnGuiHandler(string obj) settings.SaveSettings(); } } - -#if UNIFIED_NETCODE - /// - /// Displays the NetcodeConfig the NGO hybrid mode defaults were written into, and offers a way to restore - /// those defaults 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); - - var config = NetcodeConfig.Global; - if (config == null) - { - EditorGUILayout.HelpBox("No NetcodeConfig has been assigned yet. Open Project Settings > Multiplayer, which creates one, then reload the project.", MessageType.Warning); - GUILayout.EndVertical(); - return; - } - - EditorGUILayout.ObjectField(new GUIContent("Applied to", "The NetcodeConfig that Netcode for GameObjects wrote its hybrid mode defaults into."), 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. Applied automatically once; use this to get back to them after changing them."))) - { - HybridNetcodeConfigApplier.ApplyDefaults(true); - } - - GUILayout.EndVertical(); - } -#endif } internal class NetcodeSettingsLabel : NetcodeGUISettings diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs index 3a41f7b3a9..77128fe4f8 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs @@ -34,13 +34,13 @@ public override bool Initialize(string defaultWorldName) { Instance = this; } - AutoConnectPort = Port; - if (base.Initialize(defaultWorldName)) - { - Debug.LogError($"[{nameof(UnifiedBootstrap)}] Auto-bootstrap is enabled!!! This will break the POC!"); - return true; - } + + // Deliberately not calling base.Initialize. Entities selects the most derived ICustomBootstrap, so this + // type is the only one it instantiates whenever UNIFIED_NETCODE is set, and Netcode for Entities' own + // Initialize is never reached on its own. Its entire body is the EnableClientServerBootstrap check plus + // CreateDefaultClientServerWorlds, and NetworkManager owns world creation here, so calling it only gives + // Netcode for Entities a chance to create worlds this bootstrap would then have to reject. if (networkManager != null) { diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs index ec9b1adb02..e6efe016e7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs @@ -1,83 +1,35 @@ #if UNIFIED_NETCODE #if !UNIFIED_NETCODE_7_0_0 -using Unity.NetCode; using NetcodeConfig = Unity.NetCode.NetCodeConfig; #endif namespace Unity.Netcode { /// - /// The values NGO needs when running in hybrid mode (i.e. Netcode for Entities is + /// The values NGO drives when running in hybrid mode (i.e. Netcode for Entities is /// installed and a registered network prefab carries a ). /// /// /// This lives in the runtime assembly rather than the editor one because /// is internal to Netcode for Entities, 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. + /// Nothing is written to the asset on disk: writes the in-memory config just before + /// world creation, and Netcode for Entities seeds its world singletons from it at that point. /// internal static class HybridNetcodeDefaults { - /// - /// Bump whenever changes so that an upgrading project re-applies exactly once. - /// Persisted as NetcodeForGameObjectsProjectSettings.HybridDefaultsVersion. - /// - internal const int Version = 2; - - // Mirrors NetworkConfig.TickRate's default. The editor writes the defaults before any NetworkManager is - // necessarily loaded, so it has nothing to read the real rate from. NetworkManager re-aligns the config when a - // session carrying ghost prefabs starts, which is what makes writing a fixed value here safe. - internal const uint DefaultTickRate = 30; - - // Tuned against 2000 GenericPhysicsBallNGO instances in the ngo-examples project. A hybrid ghost costs ~4.87 - // bytes per snapshot, so 4096 carries ~840 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. Kept small because a snapshot is - // sent unreliably: losing any one of its fragments loses the whole snapshot. + // A hybrid ghost costs ~4.87 bytes per snapshot, so 4096 carries ~840 of them at the full tick rate, which + // covers the 200-1000 moving instances projects typically run. This is a cap and not a cost: measured at 512 + // instances the snapshot averages 2489 bytes and never reaches the cap. N4E's own default is one MTU, which + // round-robins above ~230 ghosts - 13.5Hz against a 30Hz tick at 512 instances, against 30Hz here. internal const int SnapshotPacketSize = 4096; - // 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 ~3 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. + /// Drives N4E's tick rates from . /// + /// + /// Not cosmetic: NGO's own send queues are flushed by a system in N4E's SimulationSystemGroup, which steps at + /// SimulationTickRate, so a rate below starves NGO's outbound traffic. + /// /// The config to correct. /// The owning 's configured tick rate. /// True if anything changed. @@ -90,38 +42,26 @@ internal static bool ApplyTickRate(NetcodeConfig config, uint tickRate) } // 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. + // keeps the two visibly locked in the inspector. config.ClientServerTickRate.SimulationTickRate = rate; config.ClientServerTickRate.NetworkTickRate = rate; return true; } /// - /// Applies the full NGO-recommended set: , , and the - /// values tuned against the stress test. + /// Raises N4E's snapshot packet size to . /// /// The config to correct. - /// The owning 's configured tick rate. /// True if anything changed. - internal static bool ApplyRecommended(NetcodeConfig config, uint tickRate) + internal static bool ApplySnapshotPacketSize(NetcodeConfig config) { - 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); + if (config.GhostSendSystemData.DefaultSnapshotPacketSize == SnapshotPacketSize) + { + return false; + } - return changed; + config.GhostSendSystemData.DefaultSnapshotPacketSize = SnapshotPacketSize; + return true; } /// @@ -138,27 +78,9 @@ internal static bool IsMissingRequired(NetcodeConfig config, out string reason) 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/NetworkConfig.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs index 00e7719e4a..1be3481214 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs @@ -52,6 +52,20 @@ public class NetworkConfig [Tooltip("The tickrate. This value controls how often netcode runs user code and sends out data. The value is in 'ticks per seconds' which means a value of 50 will result in 50 ticks being executed per second or a fixed delta time of 0.02.")] public uint TickRate = 30; +#if UNIFIED_NETCODE + /// + /// Whether Netcode for GameObjects sets the Netcode for Entities snapshot packet size when a session + /// carrying GhostObjects starts. + /// + /// + /// The default suits the 200-1000 moving instances most projects run. Netcode for Entities' own default is + /// one MTU, which round-robins above roughly 230 GhostObjects and drops their update rate well below + /// . Turn this off to keep whatever the NetcodeConfig asset specifies. + /// + [Tooltip("When enabled, Netcode for GameObjects sets the Netcode for Entities snapshot packet size at session start so GhostObjects keep updating at the tick rate. Disable to manage it yourself in the NetcodeConfig asset.")] + public bool AutoConfigureSnapshotSize = true; +#endif + /// /// The amount of seconds for the server to wait for the connection approval handshake to complete before the client is disconnected. /// diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index ec9a5d02a5..5c768d9974 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1416,22 +1416,24 @@ private bool UnifiedIsConfiguredCorrectly() } /// - /// Drives the tick rates from so that ghost - /// updates land on the same interval as the rest of Netcode for GameObjects. + /// Writes the values NGO drives into the in-memory global config, just before + /// the world is created from it. /// /// - /// The editor writes when it applies the hybrid defaults, - /// because no is necessarily loaded at that point. This is where the rate a - /// project actually configured gets picked up. + /// Netcode for Entities copies the config into its world singletons once, at world creation, so this is the + /// point where it takes effect. Nothing here touches the asset on disk. /// - private void UnifiedAlignTickRate() + private void UnifiedApplyNetcodeConfig() { - if (!HybridNetcodeDefaults.ApplyTickRate(NetcodeConfig.Global, NetworkConfig.TickRate)) + if (HybridNetcodeDefaults.ApplyTickRate(NetcodeConfig.Global, NetworkConfig.TickRate)) { - return; + Log.Info(new Context(LogLevel.Developer, $"The {nameof(NetcodeConfig)} tick rates have been set to {nameof(NetworkConfig)}.{nameof(NetworkConfig.TickRate)} ({NetworkConfig.TickRate}).").AddTag("Unified")); } - Log.Info(new Context(LogLevel.Developer, $"The {nameof(NetcodeConfig)} tick rates have been set to {nameof(NetworkConfig)}.{nameof(NetworkConfig.TickRate)} ({NetworkConfig.TickRate}).").AddTag("Unified")); + if (NetworkConfig.AutoConfigureSnapshotSize && HybridNetcodeDefaults.ApplySnapshotPacketSize(NetcodeConfig.Global)) + { + Log.Info(new Context(LogLevel.Developer, $"The {nameof(NetcodeConfig)} snapshot packet size has been set to {HybridNetcodeDefaults.SnapshotPacketSize}. Disable {nameof(NetworkConfig)}.{nameof(NetworkConfig.AutoConfigureSnapshotSize)} to manage it yourself.").AddTag("Unified")); + } } #endif @@ -1476,7 +1478,7 @@ public bool StartServer() ShutdownInternal(); return false; } - UnifiedAlignTickRate(); + UnifiedApplyNetcodeConfig(); if (LogLevel <= LogLevel.Developer) { Log.Info(new Context(LogLevel.Developer, "Creating world: Default world")); @@ -1556,7 +1558,7 @@ public bool StartClient() ShutdownInternal(); return false; } - UnifiedAlignTickRate(); + UnifiedApplyNetcodeConfig(); Log.Info(new Context(LogLevel.Developer, "Creating world: Default world")); InitializeNetcodeWorld(); } @@ -1631,7 +1633,7 @@ public bool StartHost() ShutdownInternal(); return false; } - UnifiedAlignTickRate(); + UnifiedApplyNetcodeConfig(); Log.Info(new Context(LogLevel.Developer, "Creating world: Default world")); InitializeNetcodeWorld(); } diff --git a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs index c4be6457ff..123f8f6678 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs @@ -1,17 +1,14 @@ #if UNIFIED_NETCODE using NUnit.Framework; #if !UNIFIED_NETCODE_7_0_0 -using Unity.NetCode; using NetcodeConfig = Unity.NetCode.NetCodeConfig; #endif -using Unity.Netcode.GameObjects.Editor.Configuration; -using UnityEditor; using UnityEngine; namespace Unity.Netcode.GameObjects.EditorTests { /// - /// Validates the values NGO applies in hybrid mode. + /// Validates the values NGO drives in hybrid mode. /// internal class HybridNetcodeDefaultsTests { @@ -34,42 +31,6 @@ public void TearDown() Object.DestroyImmediate(m_Config); } - [Test] - public void ApplyRecommendedReportsNoChangeWhenConfigAlreadyMatches() - { - Assert.IsTrue(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "The first apply should report a change."); - Assert.IsFalse(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "Applying an already matching config should report no change."); - } - - [Test] - public void ApplyRequiredAdjustsBothSettings() - { - m_Config.EnableClientServerBootstrap = NetcodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap; - m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.BinaryWorlds; - - Assert.IsTrue(HybridNetcodeDefaults.ApplyRequired(m_Config), "The first apply should report a change."); - Assert.AreEqual(NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap, m_Config.EnableClientServerBootstrap, "Automatic bootstrapping should be disabled."); - Assert.AreEqual(NetcodeConfig.HostWorldMode.SingleWorld, m_Config.HostWorldModeSelection, "Hybrid mode should use a single world."); - - 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 _), "An adjusted config should be valid for hybrid mode."); - - m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.BinaryWorlds; - Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var worldReason), "Binary worlds should be reported as invalid."); - Assert.That(worldReason, Does.Contain(nameof(NetcodeConfig.HostWorldModeSelection)), "The reason should name the setting that is wrong."); - - m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.SingleWorld; - m_Config.EnableClientServerBootstrap = NetcodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap; - Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var bootstrapReason), "Automatic bootstrapping should be reported as invalid."); - Assert.That(bootstrapReason, Does.Contain(nameof(NetcodeConfig.EnableClientServerBootstrap)), "The reason should name the setting that is wrong."); - } - [TestCase(30u)] [TestCase(60u)] public void ApplyTickRateLocksSimulationAndNetworkRates(uint tickRate) @@ -81,131 +42,58 @@ public void ApplyTickRateLocksSimulationAndNetworkRates(uint tickRate) Assert.IsFalse(HybridNetcodeDefaults.ApplyTickRate(m_Config, tickRate), "Re-applying the same rate should report no change."); } - /// - /// The editor writes .
- /// adjusts it at start-up for a project running at any other rate.
- /// That pass leaves the tuned values alone.
- ///
[Test] - public void TickRateOnlyPassAdjustsTheRateAndLeavesTheTunedValuesAlone() + public void ApplySnapshotPacketSizeOverridesTheNetcodeForEntitiesDefault() { - const uint managerTickRate = 60; + // Zero is N4E's default and means one MTU, which round-robins well below the tick rate at the instance + // counts NGO projects run. + m_Config.GhostSendSystemData.DefaultSnapshotPacketSize = 0; - HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate); + Assert.IsTrue(HybridNetcodeDefaults.ApplySnapshotPacketSize(m_Config), "The first apply should report a change."); + Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, m_Config.GhostSendSystemData.DefaultSnapshotPacketSize, "DefaultSnapshotPacketSize should be the NGO value."); - Assert.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, managerTickRate), "The tick rate pass should report a change."); - Assert.AreEqual((int)managerTickRate, m_Config.ClientServerTickRate.SimulationTickRate, "SimulationTickRate should follow the NetworkManager."); - Assert.AreEqual((int)managerTickRate, m_Config.ClientServerTickRate.NetworkTickRate, "NetworkTickRate should follow the NetworkManager."); - Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, m_Config.GhostSendSystemData.DefaultSnapshotPacketSize, "A tick rate pass should leave DefaultSnapshotPacketSize alone."); - Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeMS, m_Config.ClientTickRate.InterpolationTimeMS, "A tick rate pass should leave InterpolationTimeMS alone."); - Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMax, m_Config.ClientTickRate.InterpolationTimeScaleMax, "A tick rate pass should leave InterpolationTimeScaleMax alone."); + Assert.IsFalse(HybridNetcodeDefaults.ApplySnapshotPacketSize(m_Config), "Re-applying an unchanged config should report no change."); } [Test] - public void ApplyRecommendedProducesTheTunedValues() + public void ApplySnapshotPacketSizeLeavesTheTickRatesAlone() { - Assert.IsTrue(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "The first apply should report a change."); - - Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, m_Config.GhostSendSystemData.DefaultSnapshotPacketSize, "DefaultSnapshotPacketSize should be the tuned value."); - Assert.AreEqual(HybridNetcodeDefaults.PercentReservedForDespawn, m_Config.GhostSendSystemData.PercentReservedForDespawnMessages, "PercentReservedForDespawnMessages should be the tuned value."); - Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeMS, m_Config.ClientTickRate.InterpolationTimeMS, "InterpolationTimeMS should be the tuned value."); - Assert.AreEqual(0u, m_Config.ClientTickRate.InterpolationTimeNetTicks, "The net tick form wins over the millisecond form, so it is cleared."); - Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMin, m_Config.ClientTickRate.InterpolationTimeScaleMin, "InterpolationTimeScaleMin should be the tuned value."); - Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMax, m_Config.ClientTickRate.InterpolationTimeScaleMax, "InterpolationTimeScaleMax should be the tuned value."); - Assert.AreEqual(HybridNetcodeDefaults.ClientQueueCapacity, m_Config.ClientSendQueueCapacity, "ClientSendQueueCapacity should be the tuned value."); - Assert.AreEqual(HybridNetcodeDefaults.ClientQueueCapacity, m_Config.ClientReceiveQueueCapacity, "ClientReceiveQueueCapacity should be the tuned value."); - - Assert.IsFalse(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "Re-applying an unchanged config should report no change."); - } - - /// - /// Why the millisecond form is used rather than . - /// - /// - /// N4E rounds the millisecond value up to whole network ticks.
- /// 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); + HybridNetcodeDefaults.ApplyTickRate(m_Config, 60); + m_Config.GhostSendSystemData.DefaultSnapshotPacketSize = k_UserPacketSize; - var bufferMs = m_Config.ClientTickRate.CalculateInterpolationBufferTimeInMs(in m_Config.ClientServerTickRate); - Assert.GreaterOrEqual(bufferMs, HybridNetcodeDefaults.InterpolationTimeMS, "The interpolation buffer should hold the configured wall clock time."); + HybridNetcodeDefaults.ApplySnapshotPacketSize(m_Config); + Assert.AreEqual(60, m_Config.ClientServerTickRate.SimulationTickRate, "The snapshot size pass should leave SimulationTickRate alone."); + Assert.AreEqual(60, m_Config.ClientServerTickRate.NetworkTickRate, "The snapshot size pass should leave NetworkTickRate alone."); } [Test] - public void NetTickFormWouldRegressTheBufferAtHigherTickRates() + public void IsMissingRequiredDetectsBinaryWorlds() { - // If this 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, "The net tick form should fall short of the millisecond form at 60Hz."); - } + m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.SingleWorld; + Assert.IsFalse(HybridNetcodeDefaults.IsMissingRequired(m_Config, out _), "A single world config should be valid for hybrid mode."); - /// - /// The editor writes because no - /// is loaded to read the rate from. - /// - [Test] - public void DefaultTickRateMatchesTheNetworkConfigDefault() - { - Assert.AreEqual(new NetworkConfig().TickRate, HybridNetcodeDefaults.DefaultTickRate, "DefaultTickRate should track the NetworkConfig.TickRate default."); + m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.BinaryWorlds; + Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var reason), "Binary worlds should be reported as invalid."); + Assert.That(reason, Does.Contain(nameof(NetcodeConfig.HostWorldModeSelection)), "The reason should name the setting that is wrong."); } /// - /// Once the marker is recorded nothing writes the config again.
- /// Only the Project Settings button overrides it.
+ /// NGO no longer requires the bootstrap setting, because UnifiedBootstrap never delegates to the + /// Netcode for Entities bootstrap that reads it. ///
[Test] - public void ApplyDefaultsIsAOneShotUnlessItIsForced() + public void AutomaticBootstrapIsNotRequiredToBeDisabled() { - var config = NetcodeConfig.Global; - Assert.IsNotNull(config, "This project should have a NetcodeConfig to adjust."); - - var settings = NetcodeForGameObjectsProjectSettings.instance; - var restoreVersion = settings.HybridDefaultsVersion; - var restoreConfig = EditorJsonUtility.ToJson(config); - try - { - settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version; - config.GhostSendSystemData.DefaultSnapshotPacketSize = k_UserPacketSize; - - HybridNetcodeConfigApplier.ApplyDefaults(false); - Assert.AreEqual(k_UserPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "A recorded marker should stop the defaults from being written a second time."); - - HybridNetcodeConfigApplier.ApplyDefaults(true); - Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "The Project Settings button should re-apply regardless of the marker."); - } - finally - { - Restore(config, restoreConfig); - settings.HybridDefaultsVersion = restoreVersion; - settings.SaveSettings(); - } + m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.SingleWorld; + m_Config.EnableClientServerBootstrap = NetcodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap; + + Assert.IsFalse(HybridNetcodeDefaults.IsMissingRequired(m_Config, out _), "Automatic bootstrapping should no longer block hybrid mode."); } - /// - /// Puts the project's own back the way the test found it. - /// - /// - /// Serialized rather than field by field because the applier writes across three nested structures. - /// - /// The project config the test mutated. - /// Its state before the test ran. - private static void Restore(NetcodeConfig config, string serializedConfig) + [Test] + public void SnapshotPacketSizeIsOptOutThroughNetworkConfig() { - EditorJsonUtility.FromJsonOverwrite(serializedConfig, config); - EditorUtility.SetDirty(config); - AssetDatabase.SaveAssetIfDirty(config); + Assert.IsTrue(new NetworkConfig().AutoConfigureSnapshotSize, "The snapshot size should be configured by default, with an opt out."); } } } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs index 35e95f53f2..0ae3e44ffb 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs @@ -17,7 +17,6 @@ namespace Unity.Netcode.RuntimeTests [TestFixture(PlayerCreation.Prefab, HostOrServer.UnifiedHost)] [TestFixture(PlayerCreation.PrefabHash, HostOrServer.UnifiedHost)] [TestFixture(PlayerCreation.NoPlayer, HostOrServer.UnifiedHost)] - [TestFixture(PlayerCreation.FailValidation, HostOrServer.UnifiedHost)] #endif internal class ConnectionApprovalTests : IntegrationTestWithApproximation { @@ -31,7 +30,7 @@ public enum PlayerCreation FailValidation, } private PlayerCreation m_PlayerCreation; - private bool m_ClientDisconnectReasonValidated; + private bool m_ClientDisconnected; private Vector3 m_ExpectedPosition; private Quaternion m_ExpectedRotation; @@ -48,6 +47,9 @@ public ConnectionApprovalTests(PlayerCreation playerCreation, HostOrServer hostO m_PlayerCreation = playerCreation; } + // FailValidation has no hybrid case: the server schedules its disconnect reason into Netcode for Entities + // outgoing RPC buffer and then tears the connection down in the same call, before RpcSystem transmits it. + // Restore the fixture once the hybrid disconnect defers to a simulation tick. protected override bool UseUnifiedTests() { return true; @@ -76,7 +78,7 @@ protected override void OnServerAndClientsCreated() m_ExpectedRotation = Quaternion.Euler(GetRandomVector3(-359.98f, 359.98f)); } - m_ClientDisconnectReasonValidated = false; + m_ClientDisconnected = false; m_BypassConnectionTimeout = m_PlayerCreation == PlayerCreation.FailValidation; m_Validated.Clear(); m_ValidationToken = string.Empty; @@ -115,7 +117,7 @@ protected override void OnServerAndClientsCreated() private void Client_OnClientDisconnectCallback(ulong clientId) { m_ClientNetworkManagers[0].OnClientDisconnectCallback -= Client_OnClientDisconnectCallback; - m_ClientDisconnectReasonValidated = m_ClientNetworkManagers[0].LocalClientId == clientId && m_ClientNetworkManagers[0].ConnectionManager.ServerDisconnectReason.Contains(k_InvalidToken); + m_ClientDisconnected = m_ClientNetworkManagers[0].LocalClientId == clientId; } private bool ClientAndHostValidated(StringBuilder errorLog) @@ -127,12 +129,24 @@ private bool ClientAndHostValidated(StringBuilder errorLog) } if (m_PlayerCreation == PlayerCreation.FailValidation) { - if (!m_ClientDisconnectReasonValidated) + // The disconnect callback fires from the transport event, which can arrive ahead of the + // DisconnectReasonMessage that populates ServerDisconnectReason. Latching both checks into one bool + // and unsubscribing meant an early callback failed the test for good, and reported only which bool + // was false. Both are polled here instead, which also names the reason that did arrive. + if (!m_ClientDisconnected) { - errorLog.AppendLine($"{nameof(m_ClientDisconnectReasonValidated)} is false!"); + errorLog.AppendLine($"{nameof(m_ClientDisconnected)} is false!"); + return false; } - return m_ClientDisconnectReasonValidated; + var disconnectReason = m_ClientNetworkManagers[0].ConnectionManager.ServerDisconnectReason; + if (string.IsNullOrEmpty(disconnectReason) || !disconnectReason.Contains(k_InvalidToken)) + { + errorLog.AppendLine($"Client-{m_ClientNetworkManagers[0].LocalClientId} disconnect reason '{disconnectReason}' did not contain '{k_InvalidToken}'!"); + return false; + } + + return true; } else { diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApprovalTimeoutTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApprovalTimeoutTests.cs index a3ac08c3ea..36578d5837 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApprovalTimeoutTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApprovalTimeoutTests.cs @@ -11,7 +11,6 @@ namespace Unity.Netcode.RuntimeTests [TestFixture(ApprovalTimedOutTypes.ClientDoesNotRequest)] #if UNIFIED_NETCODE [TestFixture(ApprovalTimedOutTypes.ServerDoesNotRespond, HostOrServer.UnifiedHost)] - [TestFixture(ApprovalTimedOutTypes.ClientDoesNotRequest, HostOrServer.UnifiedHost)] #endif internal class ConnectionApprovalTimeoutTests : NetcodeIntegrationTest { @@ -36,6 +35,9 @@ public ConnectionApprovalTimeoutTests(ApprovalTimedOutTypes approvalFailureType, m_ApprovalFailureType = approvalFailureType; } + // ClientDoesNotRequest has no hybrid case: the server schedules its disconnect reason into Netcode for + // Entities' outgoing RPC buffer and then tears the connection down in the same call, before RpcSystem + // transmits it. Restore the fixture once the hybrid disconnect defers to a simulation tick. protected override bool UseUnifiedTests() { return true; diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs index a2cd98ae4c..efebc36e3a 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs @@ -35,6 +35,11 @@ internal class UnifiedSnapshotPacketSizeMeasurement : NetcodeIntegrationTest { protected override int NumberOfClients => 1; + protected override bool UseUnifiedTests() + { + return true; + } + // 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; From 386d366341a5edc3c48f35cde4b8c5880afb4716 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 6 Oct 2026 17:33:42 -0500 Subject: [PATCH 10/21] test: move the connection approval latch fix out of this PR The polled disconnect reason check belongs with the fix for the defect it found, not with the NetcodeConfig changes. Only the removal of the FailValidation hybrid fixture stays here. --- .../Runtime/Connection/ConnectionApproval.cs | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs index 0ae3e44ffb..362efa70ec 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs @@ -30,7 +30,7 @@ public enum PlayerCreation FailValidation, } private PlayerCreation m_PlayerCreation; - private bool m_ClientDisconnected; + private bool m_ClientDisconnectReasonValidated; private Vector3 m_ExpectedPosition; private Quaternion m_ExpectedRotation; @@ -78,7 +78,7 @@ protected override void OnServerAndClientsCreated() m_ExpectedRotation = Quaternion.Euler(GetRandomVector3(-359.98f, 359.98f)); } - m_ClientDisconnected = false; + m_ClientDisconnectReasonValidated = false; m_BypassConnectionTimeout = m_PlayerCreation == PlayerCreation.FailValidation; m_Validated.Clear(); m_ValidationToken = string.Empty; @@ -117,7 +117,7 @@ protected override void OnServerAndClientsCreated() private void Client_OnClientDisconnectCallback(ulong clientId) { m_ClientNetworkManagers[0].OnClientDisconnectCallback -= Client_OnClientDisconnectCallback; - m_ClientDisconnected = m_ClientNetworkManagers[0].LocalClientId == clientId; + m_ClientDisconnectReasonValidated = m_ClientNetworkManagers[0].LocalClientId == clientId && m_ClientNetworkManagers[0].ConnectionManager.ServerDisconnectReason.Contains(k_InvalidToken); } private bool ClientAndHostValidated(StringBuilder errorLog) @@ -129,24 +129,12 @@ private bool ClientAndHostValidated(StringBuilder errorLog) } if (m_PlayerCreation == PlayerCreation.FailValidation) { - // The disconnect callback fires from the transport event, which can arrive ahead of the - // DisconnectReasonMessage that populates ServerDisconnectReason. Latching both checks into one bool - // and unsubscribing meant an early callback failed the test for good, and reported only which bool - // was false. Both are polled here instead, which also names the reason that did arrive. - if (!m_ClientDisconnected) + if (!m_ClientDisconnectReasonValidated) { - errorLog.AppendLine($"{nameof(m_ClientDisconnected)} is false!"); - return false; + errorLog.AppendLine($"{nameof(m_ClientDisconnectReasonValidated)} is false!"); } - var disconnectReason = m_ClientNetworkManagers[0].ConnectionManager.ServerDisconnectReason; - if (string.IsNullOrEmpty(disconnectReason) || !disconnectReason.Contains(k_InvalidToken)) - { - errorLog.AppendLine($"Client-{m_ClientNetworkManagers[0].LocalClientId} disconnect reason '{disconnectReason}' did not contain '{k_InvalidToken}'!"); - return false; - } - - return true; + return m_ClientDisconnectReasonValidated; } else { From 4d56444a50054946fa17e28dba4ceaa0b35dd4a9 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 6 Oct 2026 17:51:31 -0500 Subject: [PATCH 11/21] chore: verbosity pass Drops a stray blank line that was the only remaining change in NetcodeForGameObjectsProjectSettings, so that file leaves the diff. Trims the comments that carried mechanism or measurements the PR description already covers, keeping the reason each value or omission exists. --- .../Configuration/NetcodeForGameObjectsProjectSettings.cs | 1 - .../Runtime/Components/Helpers/UnifiedBootstrap.cs | 8 +++----- .../Runtime/Configuration/HybridNetcodeDefaults.cs | 7 +++---- .../Tests/Runtime/Connection/ConnectionApproval.cs | 5 ++--- .../Runtime/Connection/ConnectionApprovalTimeoutTests.cs | 5 ++--- 5 files changed, 10 insertions(+), 16 deletions(-) diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs index 1518f912cc..c916494ba0 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs @@ -37,7 +37,6 @@ private void OnEnable() [SerializeField] public bool GenerateDefaultNetworkPrefabs = true; - internal void SaveSettings() { Save(true); diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs index 77128fe4f8..eb91f76a44 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs @@ -34,13 +34,11 @@ public override bool Initialize(string defaultWorldName) { Instance = this; } + AutoConnectPort = Port; - // Deliberately not calling base.Initialize. Entities selects the most derived ICustomBootstrap, so this - // type is the only one it instantiates whenever UNIFIED_NETCODE is set, and Netcode for Entities' own - // Initialize is never reached on its own. Its entire body is the EnableClientServerBootstrap check plus - // CreateDefaultClientServerWorlds, and NetworkManager owns world creation here, so calling it only gives - // Netcode for Entities a chance to create worlds this bootstrap would then have to reject. + // NetworkManager owns world creation here, so base.Initialize is deliberately not called: it would + // let Netcode for Entities create worlds this bootstrap then has to reject. if (networkManager != null) { diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs index e6efe016e7..0e08ecb212 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs @@ -17,10 +17,9 @@ namespace Unity.Netcode /// internal static class HybridNetcodeDefaults { - // A hybrid ghost costs ~4.87 bytes per snapshot, so 4096 carries ~840 of them at the full tick rate, which - // covers the 200-1000 moving instances projects typically run. This is a cap and not a cost: measured at 512 - // instances the snapshot averages 2489 bytes and never reaches the cap. N4E's own default is one MTU, which - // round-robins above ~230 ghosts - 13.5Hz against a 30Hz tick at 512 instances, against 30Hz here. + // A hybrid ghost costs ~4.87 bytes per snapshot, so this carries ~840 of them at the full tick rate, which + // covers the 200-1000 moving instances projects typically run. A cap and not a cost: below that count the + // snapshot never reaches it. N4E's own default is one MTU, which round-robins above ~230 ghosts. internal const int SnapshotPacketSize = 4096; /// diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs index 362efa70ec..5735927ae2 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApproval.cs @@ -47,9 +47,8 @@ public ConnectionApprovalTests(PlayerCreation playerCreation, HostOrServer hostO m_PlayerCreation = playerCreation; } - // FailValidation has no hybrid case: the server schedules its disconnect reason into Netcode for Entities - // outgoing RPC buffer and then tears the connection down in the same call, before RpcSystem transmits it. - // Restore the fixture once the hybrid disconnect defers to a simulation tick. + // FailValidation has no hybrid case: the server tears the connection down before Netcode for Entities + // transmits its disconnect reason. Restore the fixture with the fix. protected override bool UseUnifiedTests() { return true; diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApprovalTimeoutTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApprovalTimeoutTests.cs index 36578d5837..59c7f2e317 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApprovalTimeoutTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Connection/ConnectionApprovalTimeoutTests.cs @@ -35,9 +35,8 @@ public ConnectionApprovalTimeoutTests(ApprovalTimedOutTypes approvalFailureType, m_ApprovalFailureType = approvalFailureType; } - // ClientDoesNotRequest has no hybrid case: the server schedules its disconnect reason into Netcode for - // Entities' outgoing RPC buffer and then tears the connection down in the same call, before RpcSystem - // transmits it. Restore the fixture once the hybrid disconnect defers to a simulation tick. + // ClientDoesNotRequest has no hybrid case: the server tears the connection down before Netcode for + // Entities transmits its disconnect reason. Restore the fixture with the fix. protected override bool UseUnifiedTests() { return true; From 391da2c4cdd1e1b876709c81e05bad65dacac727 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 6 Oct 2026 19:41:04 -0500 Subject: [PATCH 12/21] chore: clarify the AutoConfigureSnapshotSize tooltip --- .../Runtime/Configuration/NetworkConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs index 1be3481214..c6e0e43905 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs @@ -62,7 +62,7 @@ public class NetworkConfig /// one MTU, which round-robins above roughly 230 GhostObjects and drops their update rate well below /// . Turn this off to keep whatever the NetcodeConfig asset specifies. /// - [Tooltip("When enabled, Netcode for GameObjects sets the Netcode for Entities snapshot packet size at session start so GhostObjects keep updating at the tick rate. Disable to manage it yourself in the NetcodeConfig asset.")] + [Tooltip("When enabled, the N4E snapshot size defaults to 4096 bytes and is automatically configured during runtime. This setting is a measured estimate for a project that uses the default tick rate of 30 and could have roughly 200-1000 spawned hybrid prefab instances. When this setting is disabled, the snapshot size is defined by the NetcodeConfig file.")] public bool AutoConfigureSnapshotSize = true; #endif From a553fe4fa0de062b99eebac63a87f56175e6c2f0 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 6 Oct 2026 19:50:01 -0500 Subject: [PATCH 13/21] chore: address u-pr review --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 + .../Runtime/Configuration/HybridNetcodeDefaults.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 89dd760726..b9845ca666 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -22,6 +22,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed - Issue where the hybrid mode `NetcodeConfig` validation messages were not interpolated, so they reported placeholders instead of the setting that was wrong. (#4144) +- Issue where a hybrid mode session failed to start unless `NetcodeConfig.EnableClientServerBootstrap` was set to `DisableAutomaticBootstrap`. Netcode for GameObjects creates the world itself and no longer needs that setting changed. (#4144) ### Security diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs index 0e08ecb212..5c8413933e 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs @@ -48,7 +48,7 @@ internal static bool ApplyTickRate(NetcodeConfig config, uint tickRate) } /// - /// Raises N4E's snapshot packet size to . + /// Sets N4E's snapshot packet size to . /// /// The config to correct. /// True if anything changed. From 7336828cb2b7be3575034b229e93486705d21328 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 7 Oct 2026 16:52:40 -0500 Subject: [PATCH 14/21] chore: changelog wording matches the applied snapshot size --- com.unity.netcode.gameobjects/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 71ddf7e09b..27c5a8daa4 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -11,7 +11,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Added - Added alignment of the Netcode for Entities tick rates with `NetworkConfig.TickRate` when a session carrying `GhostObject` prefabs is started, so `GhostObject` updates land on the same interval as the rest of Netcode for GameObjects. (#4144) -- Added `NetworkConfig.AutoConfigureSnapshotSize`, enabled by default, which raises the Netcode for Entities snapshot packet size when a session carrying `GhostObject` prefabs is started. Netcode for Entities defaults to a single MTU, which updates `GhostObject`s well below the tick rate at the instance counts Netcode for GameObjects projects typically run. Disable it to manage the value yourself. (#4144) +- Added `NetworkConfig.AutoConfigureSnapshotSize`, enabled by default, which sets the Netcode for Entities snapshot packet size to 4096 bytes when a session carrying `GhostObject` prefabs is started. Netcode for Entities defaults to a single MTU, which updates `GhostObject`s well below the tick rate at the instance counts Netcode for GameObjects projects typically run. Disable it to manage the value yourself. (#4144) ### Changed From ebe30466a50de1ecc891d20e8bb0a2cdfee95dca Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 7 Oct 2026 18:16:08 -0500 Subject: [PATCH 15/21] test: restore the hybrid prefab flag after each test case --- .../Runtime/TestHelpers/NetcodeIntegrationTest.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs index 3cd5a65fd8..5b2075e49a 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs @@ -1881,6 +1881,12 @@ public IEnumerator TearDown() ComponentFactory.Deregister(); } +#if UNIFIED_NETCODE + // A test that takes its HostOrServer as a method parameter sets m_AllPrefabsAsHybrid from the test + // body, but SetUp runs before that: without this the next case creates a hybrid prefab nobody asked + // for, and the server start sweep rejects it as an unregistered NetworkObject. + m_AllPrefabsAsHybrid = m_FixtureAllPrefabsAsHybrid; +#endif VerboseDebug($"Exiting {nameof(TearDown)}"); LogWaitForMessages(); NetcodeLogAssert.Dispose(); @@ -2520,6 +2526,9 @@ internal void WaitForMessagesReceivedWithTimeTravel(List messagesInOrder, #if UNIFIED_NETCODE protected bool m_AllPrefabsAsHybrid = false; + + // What the fixture was constructed with, restored after every test case. + private bool m_FixtureAllPrefabsAsHybrid; #endif /// @@ -2885,6 +2894,7 @@ private void InitializeTestConfiguration(NetworkTopologyTypes networkTopologyTyp #if UNIFIED_NETCODE m_UseHost = hostOrServer == HostOrServer.Host || hostOrServer == HostOrServer.DAHost || hostOrServer == HostOrServer.UnifiedHost; m_AllPrefabsAsHybrid = (hostOrServer == HostOrServer.UnifiedServer || hostOrServer == HostOrServer.UnifiedHost); + m_FixtureAllPrefabsAsHybrid = m_AllPrefabsAsHybrid; // If this is a hybrid prefab test case and the environment variable to run the unified tests // is set, then perform the m_UseUnifiedTests check. if (m_AllPrefabsAsHybrid && GetUnifiedTestsEnvironmentVariable()) From 454b11d89113fe02de54e619b85dfbbdd410283f Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 7 Oct 2026 18:16:08 -0500 Subject: [PATCH 16/21] test: move the snapshot size measurement out of the package --- .../UnifiedSnapshotPacketSizeMeasurement.cs | 278 ------------------ ...ifiedSnapshotPacketSizeMeasurement.cs.meta | 2 - 2 files changed, 280 deletions(-) delete mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs delete mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs.meta diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs deleted file mode 100644 index efebc36e3a..0000000000 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs +++ /dev/null @@ -1,278 +0,0 @@ -#if UNIFIED_NETCODE -using System; -using System.Collections; -using System.Collections.Generic; -using NUnit.Framework; -using Unity.Collections; -using Unity.Entities; -#if !UNIFIED_NETCODE_7_0_0 -using Unity.NetCode; -#endif -using Unity.Netcode.TestHelpers.Runtime; -using UnityEngine; -using UnityEngine.TestTools; - -namespace Unity.Netcode.RuntimeTests -{ - /// - /// Measurement harness (not a pass/fail behaviour test) for how much of an N4E snapshot hybrid prefab transform - /// synchronization consumes, swept across and ghost - /// count. This is where HybridNetcodeDefaults.SnapshotPacketSize comes from; re-run it when that value is - /// reconsidered or when N4E changes its snapshot encoding. - /// - /// - /// Spawns N hybrid ghosts, keeps every one dirty on every tick, and reads the client-side - /// singleton over a fixed sample window. Each case emits one PKTSZ| line: - /// packet size, requested ghosts, ghosts the client spawned, samples, mean snapshot bits, p95 bits, max bits, - /// mean ghosts per snapshot, p95 ghosts, bytes per ghost, fraction of snapshots that hit the size cap, fraction - /// that could not carry every ghost, effective per ghost update rate, tick rate. Bytes per ghost and the - /// effective update rate are the two that decide the default: raising the cap only helps while the latter is - /// still below the tick rate. - /// - [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; - - protected override bool UseUnifiedTests() - { - return true; - } - - // 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 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 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 deleted file mode 100644 index 499c4104e4..0000000000 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 41f3e6a37f69bf640974318dffc22496 \ No newline at end of file From cca97e20b52695193a25dee3395c9f841bfaff12 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 7 Oct 2026 20:54:15 -0500 Subject: [PATCH 17/21] feat: show AutoConfigureSnapshotSize in the NetworkManager inspector --- .../Editor/NetworkManagerEditor.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs b/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs index 234a34c7b4..e3c9df32a7 100644 --- a/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs +++ b/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs @@ -37,6 +37,9 @@ public class NetworkManagerEditor : NetcodeEditorBase private SerializedProperty m_ProtocolVersionProperty; private SerializedProperty m_NetworkTransportProperty; private SerializedProperty m_TickRateProperty; +#if UNIFIED_NETCODE + private SerializedProperty m_AutoConfigureSnapshotSizeProperty; +#endif #if MULTIPLAYER_SERVICES_SDK_INSTALLED private SerializedProperty m_AutoSpawnPlayerPrefabClientSide; private SerializedProperty m_NetworkTopologyProperty; @@ -114,6 +117,9 @@ private void Initialize() m_ProtocolVersionProperty = m_NetworkConfigProperty.FindPropertyRelative("ProtocolVersion"); m_NetworkTransportProperty = m_NetworkConfigProperty.FindPropertyRelative("NetworkTransport"); m_TickRateProperty = m_NetworkConfigProperty.FindPropertyRelative("TickRate"); +#if UNIFIED_NETCODE + m_AutoConfigureSnapshotSizeProperty = m_NetworkConfigProperty.FindPropertyRelative(nameof(NetworkConfig.AutoConfigureSnapshotSize)); +#endif #if MULTIPLAYER_SERVICES_SDK_INSTALLED m_NetworkTopologyProperty = m_NetworkConfigProperty.FindPropertyRelative("NetworkTopology"); // Only display the auto spawn property when the distributed authority network topology is selected @@ -157,6 +163,9 @@ private void CheckNullProperties() m_ProtocolVersionProperty = m_NetworkConfigProperty.FindPropertyRelative("ProtocolVersion"); m_NetworkTransportProperty = m_NetworkConfigProperty.FindPropertyRelative("NetworkTransport"); m_TickRateProperty = m_NetworkConfigProperty.FindPropertyRelative("TickRate"); +#if UNIFIED_NETCODE + m_AutoConfigureSnapshotSizeProperty = m_NetworkConfigProperty.FindPropertyRelative(nameof(NetworkConfig.AutoConfigureSnapshotSize)); +#endif #if MULTIPLAYER_SERVICES_SDK_INSTALLED m_NetworkTopologyProperty = m_NetworkConfigProperty.FindPropertyRelative("NetworkTopology"); // Only display the auto spawn property when the distributed authority network topology is selected @@ -221,6 +230,9 @@ private void DisplayNetworkManagerProperties() } } EditorGUILayout.PropertyField(m_TickRateProperty); +#if UNIFIED_NETCODE + EditorGUILayout.PropertyField(m_AutoConfigureSnapshotSizeProperty); +#endif EditorGUILayout.PropertyField(m_SpawnTimeOutProperty); EditorGUILayout.PropertyField(m_ConnectionApprovalProperty); if (m_NetworkManager.NetworkConfig.ConnectionApproval) From f29eb89ee7a482f4479817787d9941f283904e0d Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 8 Oct 2026 09:41:57 -0500 Subject: [PATCH 18/21] update Moving the AutoConfigureSnapshotSizeProperty into its own section within the NetworkManager inspector view for easier discoverability. --- .../Editor/NetworkManagerEditor.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs b/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs index e3c9df32a7..110e970b23 100644 --- a/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs +++ b/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs @@ -207,6 +207,12 @@ private void DisplayNetworkManagerProperties() EditorGUILayout.PropertyField(m_LogLevelProperty); EditorGUILayout.Space(); +#if UNIFIED_NETCODE + EditorGUILayout.LabelField("Hybrid Settings", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(m_AutoConfigureSnapshotSizeProperty); + EditorGUILayout.Space(); +#endif + EditorGUILayout.LabelField("Network Settings", EditorStyles.boldLabel); #if MULTIPLAYER_SERVICES_SDK_INSTALLED EditorGUILayout.PropertyField(m_NetworkTopologyProperty); @@ -230,9 +236,7 @@ private void DisplayNetworkManagerProperties() } } EditorGUILayout.PropertyField(m_TickRateProperty); -#if UNIFIED_NETCODE - EditorGUILayout.PropertyField(m_AutoConfigureSnapshotSizeProperty); -#endif + EditorGUILayout.PropertyField(m_SpawnTimeOutProperty); EditorGUILayout.PropertyField(m_ConnectionApprovalProperty); if (m_NetworkManager.NetworkConfig.ConnectionApproval) From 4c28f351d6f462726651880674565894670a1343 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 8 Oct 2026 14:09:35 -0500 Subject: [PATCH 19/21] Refine comments in HybridNetcodeDefaults.cs Updated comments for clarity and accuracy regarding hybrid mode settings and tick rate application in HybridNetcodeDefaults. --- .../Configuration/HybridNetcodeDefaults.cs | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs index 5c8413933e..f80ec61f05 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs @@ -6,30 +6,25 @@ namespace Unity.Netcode { /// - /// The values NGO drives when running in hybrid mode (i.e. Netcode for Entities is - /// installed and a registered network prefab carries a ). + /// This handles applying two settings NGO requires and an optional recommended snapshot size when running in hybrid mode. /// /// - /// This lives in the runtime assembly rather than the editor one because - /// is internal to Netcode for Entities, and Unity.Netcode.Runtime is the only NGO assembly it grants InternalsVisibleTo to. - /// Nothing is written to the asset on disk: writes the in-memory config just before - /// world creation, and Netcode for Entities seeds its world singletons from it at that point. + /// This intentionally does not modify the asset to preserve the default settings and/or any user adjustments other than the ones required. /// internal static class HybridNetcodeDefaults { // A hybrid ghost costs ~4.87 bytes per snapshot, so this carries ~840 of them at the full tick rate, which - // covers the 200-1000 moving instances projects typically run. A cap and not a cost: below that count the - // snapshot never reaches it. N4E's own default is one MTU, which round-robins above ~230 ghosts. + // covers the 200-1000 moving instances projects typically run. The default MTU size will round-robin at around ~230 instances. internal const int SnapshotPacketSize = 4096; /// - /// Drives N4E's tick rates from . + /// Assures N4E's tick rate matches NGO's setting. /// /// /// Not cosmetic: NGO's own send queues are flushed by a system in N4E's SimulationSystemGroup, which steps at /// SimulationTickRate, so a rate below starves NGO's outbound traffic. /// - /// The config to correct. + /// The configuration to determine if NGO's tick rate should be applied. /// The owning 's configured tick rate. /// True if anything changed. internal static bool ApplyTickRate(NetcodeConfig config, uint tickRate) @@ -40,8 +35,8 @@ internal static bool ApplyTickRate(NetcodeConfig config, uint tickRate) return false; } - // Both are written: leaving NetworkTickRate at 0 would track SimulationTickRate anyway, but writing it - // keeps the two visibly locked in the inspector. + // Both are written because you can set NetworkTickRate to 0 which N4E would then use SimulationTickRate. + // Keeping both locked in at NGO's tick rate (runtime only) assures there can be no deviation. config.ClientServerTickRate.SimulationTickRate = rate; config.ClientServerTickRate.NetworkTickRate = rate; return true; @@ -64,11 +59,11 @@ internal static bool ApplySnapshotPacketSize(NetcodeConfig config) } /// - /// Reports the first required setting that is still wrong, for the runtime start-up check. + /// Determines if the NetcodeConfig about to be used to start a session meets the required settings. /// - /// The config to inspect. + /// The config to check. /// Populated with a user-facing description of what is wrong. - /// True when cannot support hybrid mode as-is. + /// True when is not configured correctly. internal static bool IsMissingRequired(NetcodeConfig config, out string reason) { if (config.HostWorldModeSelection != NetcodeConfig.HostWorldMode.SingleWorld) From cbe957af7f07c58522ec8fca0b1a8f89cab47fb5 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 8 Oct 2026 14:12:15 -0500 Subject: [PATCH 20/21] Clarify UnifiedApplyNetcodeConfig documentation Updated summary and remarks in UnifiedApplyNetcodeConfig method to clarify the application of NetcodeConfig settings during runtime. --- com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 21461576d2..0ee01d0165 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1425,12 +1425,11 @@ private bool UnifiedIsConfiguredCorrectly() } /// - /// Writes the values NGO drives into the in-memory global config, just before - /// the world is created from it. + /// Invoked before creating the world for a hybrid mode session to assure the required NetcodeConfig settings are applied. /// /// /// Netcode for Entities copies the config into its world singletons once, at world creation, so this is the - /// point where it takes effect. Nothing here touches the asset on disk. + /// point where it takes effect. This occurs during runtime to avoid modification of the NetcodeConfig asset. /// private void UnifiedApplyNetcodeConfig() { From 7d345e6231aa8c390598ae5718a3574053f405dd Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 8 Oct 2026 15:18:56 -0500 Subject: [PATCH 21/21] update Removing whitespace. Adjusting tooltip. --- .../Runtime/Configuration/HybridNetcodeDefaults.cs | 2 +- .../Runtime/Configuration/NetworkConfig.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs index f80ec61f05..c40842a918 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs @@ -35,7 +35,7 @@ internal static bool ApplyTickRate(NetcodeConfig config, uint tickRate) return false; } - // Both are written because you can set NetworkTickRate to 0 which N4E would then use SimulationTickRate. + // Both are written because you can set NetworkTickRate to 0 which N4E would then use SimulationTickRate. // Keeping both locked in at NGO's tick rate (runtime only) assures there can be no deviation. config.ClientServerTickRate.SimulationTickRate = rate; config.ClientServerTickRate.NetworkTickRate = rate; diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs index 50bc49ad9b..ebb32492ca 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs @@ -62,7 +62,7 @@ public class NetworkConfig /// one MTU, which round-robins above roughly 230 GhostObjects and drops their update rate well below /// . Turn this off to keep whatever the NetcodeConfig asset specifies. /// - [Tooltip("When enabled, the N4E snapshot size defaults to 4096 bytes and is automatically configured during runtime. This setting is a measured estimate for a project that uses the default tick rate of 30 and could have roughly 200-1000 spawned hybrid prefab instances. When this setting is disabled, the snapshot size is defined by the NetcodeConfig file.")] + [Tooltip("When enabled, NGO will drive the N4E snapshot size setting for this project. The value defaults to 4096 bytes. This is a measured estimate for a project that uses the default tick rate of 30 and could have roughly 200-1000 spawned hybrid prefab instances.")] public bool AutoConfigureSnapshotSize = true; #endif