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