diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md
index ceaff931bb..072ebaae76 100644
--- a/com.unity.netcode.gameobjects/CHANGELOG.md
+++ b/com.unity.netcode.gameobjects/CHANGELOG.md
@@ -10,6 +10,10 @@ Additional documentation and release notes are available at [Multiplayer Documen
### Added
+- Added additional section under Project Settings > Multiplayer > Netcode for GameObjects, shown when Netcode for Entities is installed and provides users a way to restor back to the recommended settings. (#4144)
+- Added alignment of the Netcode for Entities tick rates with `NetworkConfig.TickRate` when a session with `GhostObject` prefabs is started, so ghost updates land on the same (relative) interval as the rest of Netcode for GameObjects. (#4144)
+
+
### Changed
- All editor assembly definitions are renamed with `Unity.Netcode.GameObjects.x` variants
@@ -32,6 +36,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..36e9db4595
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs
@@ -0,0 +1,123 @@
+#if UNIFIED_NETCODE
+using Unity.NetCode;
+using UnityEditor;
+using UnityEngine;
+
+namespace Unity.Netcode.GameObjects.Editor.Configuration
+{
+ ///
+ /// Writes the values NGO recommends for hybrid mode, once, the first time a
+ /// is available.
+ ///
+ ///
+ /// This does not create . This finds the one N4E created and modifies it.
+ /// Nothing tracks the project after that write. The defaults are inert in a project with no hybrid prefabs, and
+ /// re-aligns the tick rate at start-up in a project that has them, so there is no
+ /// reason to scan for ghost prefabs from the editor.
+ ///
+ internal static class HybridNetcodeConfigApplier
+ {
+ ///
+ /// Whether the user has to opt into the experimental unified netcode API before NGO writes anything.
+ ///
+ ///
+ /// TODO-RELEASE: Set this to true before the 6000.7.0 release manifest submission if Netcode for Entities
+ /// ships the unified API as experimental and its scripting defines.
+ /// Note: This is deliberately not a const: IDE0035 (remove unreachable code) is an error in this repository, so a
+ /// const would fail the standards job as soon as it was set to false.
+ ///
+ internal static readonly bool RequiresExperimentalOptIn = false;
+
+ private static NetCodeConfig s_ScannedConfig;
+ private static bool s_ConfigScanned;
+
+ [InitializeOnLoadMethod]
+ private static void OnApplicationStart()
+ {
+ // Cross-assembly ordering between the two is not a documented contract.
+ // Defer rather than racing it.
+ EditorApplication.delayCall += OnDelayCall;
+ }
+
+ private static void OnDelayCall()
+ {
+ EditorApplication.delayCall -= OnDelayCall;
+ ApplyDefaults(false);
+ }
+
+ ///
+ /// Writes the NGO hybrid mode defaults into the project's .
+ ///
+ ///
+ /// Driven by the button in Project Settings:
+ /// - When true: re-applies the full tuned set even though this project has already had it applied once.
+ /// - When false: writes only if this project has never had them written. From that point forward, the user's
+ /// edits are not overwritten.
+ ///
+ internal static void ApplyDefaults(bool force)
+ {
+ if (EditorApplication.isPlayingOrWillChangePlaymode)
+ {
+ return;
+ }
+
+ var settings = NetcodeForGameObjectsProjectSettings.instance;
+ if (RequiresExperimentalOptIn && !settings.EnableUnifiedNetcodeApi)
+ {
+ return;
+ }
+
+ if (!force && settings.HybridDefaultsVersion >= HybridNetcodeDefaults.Version)
+ {
+ return;
+ }
+
+ // A project with no config yet leaves the marker unrecorded so that the next domain reload tries again.
+ // N4E creates one on any domain reload that finds none.
+ var config = ResolveGlobalConfig();
+ if (config == null)
+ {
+ return;
+ }
+
+ if (HybridNetcodeDefaults.ApplyRecommended(config, HybridNetcodeDefaults.DefaultTickRate))
+ {
+ EditorUtility.SetDirty(config);
+ AssetDatabase.SaveAssetIfDirty(config);
+ Debug.Log($"[Netcode] Applied the NGO hybrid mode defaults to '{config.name}'. These are tuned for NGO and can be changed freely; they will not be re-applied automatically. Use Project Settings > Multiplayer > Netcode for GameObjects to restore them.", config);
+ }
+
+ // Recorded even when the config already matched and nothing was written. Leaving it unrecorded would make
+ // the next domain reload a first application again, which would revert the user's next edit.
+ settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version;
+ settings.SaveSettings();
+ }
+
+ ///
+ /// Resolves the config N4E considers global, falling back to a project scan when N4E has not assigned one yet.
+ ///
+ ///
+ /// The scan is done at most once per domain reload, including when it finds nothing, because this is also
+ /// reached from OnGUI and walks the entire project. A config created
+ /// after the scan is picked up on the next domain reload.
+ ///
+ /// The config to adjust or null if no config exists.
+ internal static NetCodeConfig ResolveGlobalConfig()
+ {
+ if (NetCodeConfig.Global != null)
+ {
+ return NetCodeConfig.Global;
+ }
+
+ if (!s_ConfigScanned)
+ {
+ s_ConfigScanned = true;
+ var guids = AssetDatabase.FindAssets($"t:{nameof(NetCodeConfig)}");
+ s_ScannedConfig = guids.Length == 1 ? AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0])) : null;
+ }
+
+ return s_ScannedConfig;
+ }
+ }
+}
+#endif
diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/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 c916494ba0..9f9b9939d9 100644
--- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs
+++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs
@@ -37,6 +37,29 @@ private void OnEnable()
[SerializeField]
public bool GenerateDefaultNetworkPrefabs = true;
+#if UNIFIED_NETCODE
+ ///
+ /// Whether the user has opted into the experimental unified netcode API.
+ ///
+ ///
+ /// Only consulted while holds. Turning it
+ /// off again hides the hybrid section and leaves the NetCodeConfig exactly as it is; the marker below is what
+ /// keeps turning it back on from overwriting anything.
+ ///
+ [SerializeField]
+ public bool EnableUnifiedNetcodeApi;
+
+ ///
+ /// The hybrid mode default values already applied to this project's NetCodeConfig.
+ ///
+ ///
+ /// 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..83769d9ecf 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;
@@ -11,6 +14,9 @@ internal static class NetcodeSettingsProvider
{
private const float k_MaxLabelWidth = 450f;
private static float s_MaxLabelWidth;
+#if UNIFIED_NETCODE
+ private static float s_HybridLabelWidth;
+#endif
private static bool s_ShowEditorSettingFields = true;
private static bool s_ShowProjectSettingFields = true;
@@ -192,6 +198,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 +215,84 @@ private static void OnGuiHandler(string obj)
settings.SaveSettings();
}
}
+
+#if UNIFIED_NETCODE
+ ///
+ /// Displays the NetCodeConfig the NGO hybrid mode defaults were written into, and offers a way to restore
+ /// those defaults for anyone who has since changed them.
+ ///
+ /// The project settings holding the opt-in flag and the applied-defaults marker.
+ private static void DrawHybridSettings(NetcodeForGameObjectsProjectSettings settings)
+ {
+ if (HybridNetcodeConfigApplier.RequiresExperimentalOptIn && !DrawUnifiedNetcodeApiToggle(settings))
+ {
+ return;
+ }
+
+ GUILayout.BeginVertical("Box");
+ GUILayout.Label("Hybrid (Netcode for Entities)", EditorStyles.boldLabel);
+
+ var config = HybridNetcodeConfigApplier.ResolveGlobalConfig();
+ if (config == null)
+ {
+ EditorGUILayout.HelpBox("No NetCodeConfig could be resolved. Open Project Settings > Multiplayer, which creates one, then reload the project.", MessageType.Warning);
+ GUILayout.EndVertical();
+ return;
+ }
+
+ EditorGUILayout.ObjectField(new GUIContent("Applied to", "The NetCodeConfig that Netcode for GameObjects wrote its hybrid mode defaults into."), config, typeof(NetCodeConfig), false);
+
+ if (settings.HybridDefaultsVersion < HybridNetcodeDefaults.Version)
+ {
+ EditorGUILayout.HelpBox("The Netcode for GameObjects hybrid defaults have not been applied to this config yet.", MessageType.Info);
+ }
+
+ if (GUILayout.Button(new GUIContent("Apply Recommended Hybrid Defaults", "Restores the snapshot, interpolation and transport values Netcode for GameObjects recommends for hybrid mode. Applied automatically once; use this to get back to them after changing them.")))
+ {
+ HybridNetcodeConfigApplier.ApplyDefaults(true);
+ }
+
+ GUILayout.EndVertical();
+ }
+
+ ///
+ /// Draws the opt-in for the experimental unified netcode API, writing the NGO hybrid mode defaults the first
+ /// time it is checked.
+ ///
+ /// The project settings holding the opt-in flag.
+ /// Whether the rest of the hybrid section should draw.
+ private static bool DrawUnifiedNetcodeApiToggle(NetcodeForGameObjectsProjectSettings settings)
+ {
+ const string enableUnifiedApiString = "Enable the experimental unified netcode API";
+
+ if (s_HybridLabelWidth == 0)
+ {
+ s_HybridLabelWidth = Mathf.Min(k_MaxLabelWidth, EditorStyles.label.CalcSize(new GUIContent(enableUnifiedApiString)).x);
+ }
+
+ EditorGUIUtility.labelWidth = s_HybridLabelWidth;
+ var enabled = EditorGUILayout.Toggle(
+ new GUIContent(
+ enableUnifiedApiString,
+ "When enabled, Netcode for GameObjects writes the NetCodeConfig values it recommends for hybrid " +
+ "mode. Disabling it again hides these settings and leaves the NetCodeConfig as it is."),
+ settings.EnableUnifiedNetcodeApi,
+ GUILayout.Width(s_HybridLabelWidth + 20));
+ EditorGUIUtility.labelWidth = s_MaxLabelWidth;
+
+ if (enabled != settings.EnableUnifiedNetcodeApi)
+ {
+ settings.EnableUnifiedNetcodeApi = enabled;
+ settings.SaveSettings();
+ if (enabled)
+ {
+ HybridNetcodeConfigApplier.ApplyDefaults(false);
+ }
+ }
+
+ return enabled;
+ }
+#endif
}
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..289e2938e1
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs
@@ -0,0 +1,160 @@
+#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 a registered network prefab carries a ).
+ ///
+ ///
+ /// This lives in the runtime assembly rather than the editor one because
+ /// is internal to Unity.NetCode, and Unity.Netcode.Runtime is the only NGO assembly it grants InternalsVisibleTo to.
+ /// 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;
+
+ // Mirrors NetworkConfig.TickRate's default. The editor writes the defaults before any NetworkManager is
+ // necessarily loaded, so it has nothing to read the real rate from. NetworkManager re-aligns the config when a
+ // session carrying ghost prefabs starts, which is what makes writing a fixed value here safe.
+ internal const uint DefaultTickRate = 30;
+
+ // Tuned against 2000 GenericPhysicsBallNGO instances in the ngo-examples project. A hybrid ghost costs ~4.87
+ // bytes per snapshot, so 15000 carries ~3000 of them at the full tick rate. This is a cap and not a cost:
+ // below that count it puts no more on the wire than the N4E default would.
+ 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 0a3fee02d7..46f00efc9a 100644
--- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs
@@ -1415,13 +1415,32 @@ private bool UnifiedIsConfiguredCorrectly()
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"));
return false;
}
return true;
}
+
+ ///
+ /// Drives the tick rates from so that ghost
+ /// updates land on the same interval as the rest of Netcode for GameObjects.
+ ///
+ ///
+ /// The editor writes when it applies the hybrid defaults,
+ /// because no is necessarily loaded at that point. This is where the rate a
+ /// project actually configured gets picked up.
+ ///
+ private void UnifiedAlignTickRate()
+ {
+ if (!HybridNetcodeDefaults.ApplyTickRate(NetCodeConfig.Global, NetworkConfig.TickRate))
+ {
+ return;
+ }
+
+ Log.Info(new Context(LogLevel.Developer, $"The {nameof(NetCodeConfig)} tick rates have been set to {nameof(NetworkConfig)}.{nameof(NetworkConfig.TickRate)} ({NetworkConfig.TickRate}).").AddTag("Unified"));
+ }
#endif
///
@@ -1465,6 +1484,7 @@ public bool StartServer()
ShutdownInternal();
return false;
}
+ UnifiedAlignTickRate();
if (LogLevel <= LogLevel.Developer)
{
Log.Info(new Context(LogLevel.Developer, "Creating world: Default world"));
@@ -1544,6 +1564,7 @@ public bool StartClient()
ShutdownInternal();
return false;
}
+ UnifiedAlignTickRate();
Log.Info(new Context(LogLevel.Developer, "Creating world: Default world"));
InitializeNetcodeWorld();
}
@@ -1618,6 +1639,7 @@ public bool StartHost()
ShutdownInternal();
return false;
}
+ UnifiedAlignTickRate();
Log.Info(new Context(LogLevel.Developer, "Creating world: Default world"));
InitializeNetcodeWorld();
}
diff --git a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs
new file mode 100644
index 0000000000..3aef8f5eb8
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs
@@ -0,0 +1,212 @@
+#if UNIFIED_NETCODE
+using NUnit.Framework;
+using Unity.NetCode;
+using Unity.Netcode.GameObjects.Editor.Configuration;
+using UnityEditor;
+using UnityEngine;
+
+namespace Unity.Netcode.GameObjects.EditorTests
+{
+ ///
+ /// Validates the values NGO applies in hybrid mode.
+ ///
+ internal class HybridNetcodeDefaultsTests
+ {
+ // Stands in for a value the user chose. Far enough from SnapshotPacketSize that a partial apply cannot
+ // look like a pass.
+ private const int k_UserPacketSize = 9000;
+
+ private NetCodeConfig m_Config;
+
+ [SetUp]
+ public void SetUp()
+ {
+ m_Config = ScriptableObject.CreateInstance();
+ m_Config.Reset();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ Object.DestroyImmediate(m_Config);
+ }
+
+ [Test]
+ public void ApplyRecommendedReportsNoChangeWhenConfigAlreadyMatches()
+ {
+ Assert.IsTrue(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "The first apply should report a change.");
+ Assert.IsFalse(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "Applying an already matching config should report no change.");
+ }
+
+ [Test]
+ public void ApplyRequiredAdjustsBothSettings()
+ {
+ m_Config.EnableClientServerBootstrap = NetCodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap;
+ m_Config.HostWorldModeSelection = NetCodeConfig.HostWorldMode.BinaryWorlds;
+
+ Assert.IsTrue(HybridNetcodeDefaults.ApplyRequired(m_Config), "The first apply should report a change.");
+ Assert.AreEqual(NetCodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap, m_Config.EnableClientServerBootstrap, "Automatic bootstrapping should be disabled.");
+ Assert.AreEqual(NetCodeConfig.HostWorldMode.SingleWorld, m_Config.HostWorldModeSelection, "Hybrid mode should use a single world.");
+
+ Assert.IsFalse(HybridNetcodeDefaults.ApplyRequired(m_Config), "Applying an already correct config should report no change.");
+ }
+
+ [Test]
+ public void IsMissingRequiredDetectsEachViolation()
+ {
+ HybridNetcodeDefaults.ApplyRequired(m_Config);
+ Assert.IsFalse(HybridNetcodeDefaults.IsMissingRequired(m_Config, out _), "An adjusted config should be valid for hybrid mode.");
+
+ m_Config.HostWorldModeSelection = NetCodeConfig.HostWorldMode.BinaryWorlds;
+ Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var worldReason), "Binary worlds should be reported as invalid.");
+ Assert.That(worldReason, Does.Contain(nameof(NetCodeConfig.HostWorldModeSelection)), "The reason should name the setting that is wrong.");
+
+ m_Config.HostWorldModeSelection = NetCodeConfig.HostWorldMode.SingleWorld;
+ m_Config.EnableClientServerBootstrap = NetCodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap;
+ Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var bootstrapReason), "Automatic bootstrapping should be reported as invalid.");
+ Assert.That(bootstrapReason, Does.Contain(nameof(NetCodeConfig.EnableClientServerBootstrap)), "The reason should name the setting that is wrong.");
+ }
+
+ [TestCase(30u)]
+ [TestCase(60u)]
+ public void ApplyTickRateLocksSimulationAndNetworkRates(uint tickRate)
+ {
+ Assert.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, tickRate), "The first apply should report a change.");
+ Assert.AreEqual((int)tickRate, m_Config.ClientServerTickRate.SimulationTickRate, "SimulationTickRate should be the requested rate.");
+ Assert.AreEqual((int)tickRate, m_Config.ClientServerTickRate.NetworkTickRate, "NetworkTickRate should track SimulationTickRate.");
+
+ Assert.IsFalse(HybridNetcodeDefaults.ApplyTickRate(m_Config, tickRate), "Re-applying the same rate should report no change.");
+ }
+
+ ///
+ /// The editor writes .
+ /// adjusts it at start-up for a project running at any other rate.
+ /// That pass leaves the tuned values alone.
+ ///
+ [Test]
+ public void TickRateOnlyPassAdjustsTheRateAndLeavesTheTunedValuesAlone()
+ {
+ const uint managerTickRate = 60;
+
+ HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate);
+
+ Assert.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, managerTickRate), "The tick rate pass should report a change.");
+ Assert.AreEqual((int)managerTickRate, m_Config.ClientServerTickRate.SimulationTickRate, "SimulationTickRate should follow the NetworkManager.");
+ Assert.AreEqual((int)managerTickRate, m_Config.ClientServerTickRate.NetworkTickRate, "NetworkTickRate should follow the NetworkManager.");
+ Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, m_Config.GhostSendSystemData.DefaultSnapshotPacketSize, "A tick rate pass should leave DefaultSnapshotPacketSize alone.");
+ Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeMS, m_Config.ClientTickRate.InterpolationTimeMS, "A tick rate pass should leave InterpolationTimeMS alone.");
+ Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMax, m_Config.ClientTickRate.InterpolationTimeScaleMax, "A tick rate pass should leave InterpolationTimeScaleMax alone.");
+ }
+
+ [Test]
+ public void ApplyRecommendedProducesTheTunedValues()
+ {
+ Assert.IsTrue(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "The first apply should report a change.");
+
+ Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, m_Config.GhostSendSystemData.DefaultSnapshotPacketSize, "DefaultSnapshotPacketSize should be the tuned value.");
+ Assert.AreEqual(HybridNetcodeDefaults.PercentReservedForDespawn, m_Config.GhostSendSystemData.PercentReservedForDespawnMessages, "PercentReservedForDespawnMessages should be the tuned value.");
+ Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeMS, m_Config.ClientTickRate.InterpolationTimeMS, "InterpolationTimeMS should be the tuned value.");
+ Assert.AreEqual(0u, m_Config.ClientTickRate.InterpolationTimeNetTicks, "The net tick form wins over the millisecond form, so it is cleared.");
+ Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMin, m_Config.ClientTickRate.InterpolationTimeScaleMin, "InterpolationTimeScaleMin should be the tuned value.");
+ Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMax, m_Config.ClientTickRate.InterpolationTimeScaleMax, "InterpolationTimeScaleMax should be the tuned value.");
+ Assert.AreEqual(HybridNetcodeDefaults.ClientQueueCapacity, m_Config.ClientSendQueueCapacity, "ClientSendQueueCapacity should be the tuned value.");
+ Assert.AreEqual(HybridNetcodeDefaults.ClientQueueCapacity, m_Config.ClientReceiveQueueCapacity, "ClientReceiveQueueCapacity should be the tuned value.");
+
+ Assert.IsFalse(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "Re-applying an unchanged config should report no change.");
+ }
+
+ ///
+ /// Why the millisecond form is used rather than .
+ ///
+ ///
+ /// N4E rounds the millisecond value up to whole network ticks.
+ /// It holds at least the configured wall clock buffer at any tick rate.
+ ///
+ /// The tick rate to resolve the buffer against.
+ [TestCase(30u)]
+ [TestCase(60u)]
+ public void InterpolationBufferHoldsAtLeastFiftyMillisecondsAtAnyTickRate(uint tickRate)
+ {
+ HybridNetcodeDefaults.ApplyRecommended(m_Config, tickRate);
+
+ var bufferMs = m_Config.ClientTickRate.CalculateInterpolationBufferTimeInMs(in m_Config.ClientServerTickRate);
+ Assert.GreaterOrEqual(bufferMs, HybridNetcodeDefaults.InterpolationTimeMS, "The interpolation buffer should hold the configured wall clock time.");
+ }
+
+ [Test]
+ public void NetTickFormWouldRegressTheBufferAtHigherTickRates()
+ {
+ // If this stops being true, the millisecond form and its extra rounding are no longer buying anything.
+ HybridNetcodeDefaults.ApplyTickRate(m_Config, 60);
+ m_Config.ClientTickRate = new ClientTickRate
+ {
+ InterpolationTimeNetTicks = 2,
+ InterpolationTimeMS = 0,
+ };
+
+ var bufferMs = m_Config.ClientTickRate.CalculateInterpolationBufferTimeInMs(in m_Config.ClientServerTickRate);
+ Assert.Less(bufferMs, HybridNetcodeDefaults.InterpolationTimeMS, "The net tick form should fall short of the millisecond form at 60Hz.");
+ }
+
+ ///
+ /// The editor writes because no
+ /// is loaded to read the rate from.
+ ///
+ [Test]
+ public void DefaultTickRateMatchesTheNetworkConfigDefault()
+ {
+ Assert.AreEqual(new NetworkConfig().TickRate, HybridNetcodeDefaults.DefaultTickRate, "DefaultTickRate should track the NetworkConfig.TickRate default.");
+ }
+
+ ///
+ /// Once the marker is recorded nothing writes the config again.
+ /// Only the Project Settings button overrides it.
+ ///
+ [Test]
+ public void ApplyDefaultsIsAOneShotUnlessItIsForced()
+ {
+ var config = HybridNetcodeConfigApplier.ResolveGlobalConfig();
+ Assert.IsNotNull(config, "This project should have a NetCodeConfig to adjust.");
+
+ var settings = NetcodeForGameObjectsProjectSettings.instance;
+ var restoreOptIn = settings.EnableUnifiedNetcodeApi;
+ var restoreVersion = settings.HybridDefaultsVersion;
+ var restoreConfig = EditorJsonUtility.ToJson(config);
+ try
+ {
+ settings.EnableUnifiedNetcodeApi = true;
+ settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version;
+ config.GhostSendSystemData.DefaultSnapshotPacketSize = k_UserPacketSize;
+
+ HybridNetcodeConfigApplier.ApplyDefaults(false);
+ Assert.AreEqual(k_UserPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "A recorded marker should stop the defaults from being written a second time.");
+
+ HybridNetcodeConfigApplier.ApplyDefaults(true);
+ Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "The Project Settings button should re-apply regardless of the marker.");
+ }
+ finally
+ {
+ Restore(config, restoreConfig);
+ settings.EnableUnifiedNetcodeApi = restoreOptIn;
+ settings.HybridDefaultsVersion = restoreVersion;
+ settings.SaveSettings();
+ }
+ }
+
+ ///
+ /// Puts the project's own back the way the test found it.
+ ///
+ ///
+ /// Serialized rather than field by field because the applier writes across three nested structures.
+ ///
+ /// The project config the test mutated.
+ /// Its state before the test ran.
+ private static void Restore(NetCodeConfig config, string serializedConfig)
+ {
+ EditorJsonUtility.FromJsonOverwrite(serializedConfig, config);
+ EditorUtility.SetDirty(config);
+ AssetDatabase.SaveAssetIfDirty(config);
+ }
+ }
+}
+#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