From 69a99a935b922ea14a33ff95a4f6b1798cc70cc5 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 24 Aug 2026 18:19:49 -0500 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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