diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md
index 7b75f45d30..88e1f873a7 100644
--- a/com.unity.netcode.gameobjects/CHANGELOG.md
+++ b/com.unity.netcode.gameobjects/CHANGELOG.md
@@ -10,6 +10,9 @@ Additional documentation and release notes are available at [Multiplayer Documen
### Added
+- Added additional section under Project Settings > Multiplayer > Netcode for GameObjects, shown when Netcode for Entities is installed and provides users a way to restore the recommended settings. (#4144)
+- Added alignment of the Netcode for Entities tick rates with `NetworkConfig.TickRate` when a session with `GhostObject` prefabs is started, so ghost updates land on the same (relative) interval as the rest of Netcode for GameObjects. (#4144)
+
### Changed
### Deprecated
@@ -18,6 +21,8 @@ Additional documentation and release notes are available at [Multiplayer Documen
### Fixed
+- Issue where the hybrid mode `NetcodeConfig` validation messages were not interpolated and did not check that automatic bootstrapping was disabled. (#4144)
+
### Security
### Obsolete
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..21a3949d17
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs
@@ -0,0 +1,81 @@
+#if UNIFIED_NETCODE
+using Unity.Netcode.Logging;
+#if !UNIFIED_NETCODE_7_0_0
+using NetcodeConfig = Unity.NetCode.NetCodeConfig;
+#endif
+using UnityEditor;
+
+namespace Unity.Netcode.GameObjects.Editor.Configuration
+{
+ ///
+ /// Writes the values NGO recommends for hybrid mode, once, the first time a
+ /// is available.
+ ///
+ ///
+ /// This does not create . This finds the one N4E created and modifies it.
+ /// Nothing tracks the project after that write. The defaults are inert in a project with no hybrid prefabs, and
+ /// re-aligns the tick rate at start-up in a project that has them, so there is no
+ /// reason to scan for ghost prefabs from the editor.
+ ///
+ internal static class HybridNetcodeConfigApplier
+ {
+ [InitializeOnLoadMethod]
+ private static void OnApplicationStart()
+ {
+ // N4E assigns NetcodeConfig.Global from its own [InitializeOnLoadMethod] and creates the asset when
+ // there is none. delayCall runs after those have completed, which is what makes Global reliable here
+ // without a lookup of our own.
+ EditorApplication.delayCall += OnDelayCall;
+ }
+
+ private static void OnDelayCall()
+ {
+ EditorApplication.delayCall -= OnDelayCall;
+ ApplyDefaults(false);
+ }
+
+ ///
+ /// Writes the NGO hybrid mode defaults into the project's .
+ ///
+ ///
+ /// Driven by the button in Project Settings:
+ /// - When true: re-applies the full tuned set even though this project has already had it applied once.
+ /// - When false: writes only if this project has never had them written. From that point forward, the user's
+ /// edits are not overwritten.
+ ///
+ internal static void ApplyDefaults(bool force)
+ {
+ if (EditorApplication.isPlayingOrWillChangePlaymode)
+ {
+ return;
+ }
+
+ var settings = NetcodeForGameObjectsProjectSettings.instance;
+ if (!force && settings.HybridDefaultsVersion >= HybridNetcodeDefaults.Version)
+ {
+ return;
+ }
+
+ // A project with no config yet leaves the marker unrecorded so that the next domain reload tries again.
+ // N4E creates one on any domain reload that finds none.
+ var config = NetcodeConfig.Global;
+ if (config == null)
+ {
+ return;
+ }
+
+ if (HybridNetcodeDefaults.ApplyRecommended(config, HybridNetcodeDefaults.DefaultTickRate))
+ {
+ EditorUtility.SetDirty(config);
+ AssetDatabase.SaveAssetIfDirty(config);
+ new ContextualLogger(config).Info(new Context(LogLevel.Developer, $"Applied the hybrid mode defaults to '{config.name}'. These are tuned for Netcode for GameObjects and can be changed freely; they will not be re-applied automatically. Use Project Settings > Multiplayer > Netcode for GameObjects to restore them.").AddTag("Unified"));
+ }
+
+ // Recorded even when the config already matched and nothing was written. Leaving it unrecorded would make
+ // the next domain reload a first application again, which would revert the user's next edit.
+ settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version;
+ settings.SaveSettings();
+ }
+ }
+}
+#endif
diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta
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..350d942bac 100644
--- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs
+++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs
@@ -37,6 +37,19 @@ private void OnEnable()
[SerializeField]
public bool GenerateDefaultNetworkPrefabs = true;
+#if UNIFIED_NETCODE
+ ///
+ /// The version of the hybrid mode default values already written into this project's NetcodeConfig, or zero
+ /// when they have never been written.
+ ///
+ ///
+ /// A version rather than a flag so a later revision of those values re-applies exactly once. Recording it is
+ /// what keeps them a one-shot: a user who changes them is not overwritten on the next domain reload.
+ ///
+ [SerializeField]
+ public int HybridDefaultsVersion;
+#endif
+
internal void SaveSettings()
{
Save(true);
diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs
index a8b521117c..a313fcd4b4 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 && !UNIFIED_NETCODE_7_0_0
+using NetcodeConfig = Unity.NetCode.NetCodeConfig;
+#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,41 @@ private static void OnGuiHandler(string obj)
settings.SaveSettings();
}
}
+
+#if UNIFIED_NETCODE
+ ///
+ /// Displays the NetcodeConfig the NGO hybrid mode defaults were written into, and offers a way to restore
+ /// those defaults for anyone who has since changed them.
+ ///
+ /// The project settings holding the applied-defaults marker.
+ private static void DrawHybridSettings(NetcodeForGameObjectsProjectSettings settings)
+ {
+ GUILayout.BeginVertical("Box");
+ GUILayout.Label("Hybrid (Netcode for Entities)", EditorStyles.boldLabel);
+
+ var config = NetcodeConfig.Global;
+ if (config == null)
+ {
+ EditorGUILayout.HelpBox("No NetcodeConfig has been assigned yet. Open Project Settings > Multiplayer, which creates one, then reload the project.", MessageType.Warning);
+ GUILayout.EndVertical();
+ return;
+ }
+
+ EditorGUILayout.ObjectField(new GUIContent("Applied to", "The NetcodeConfig that Netcode for GameObjects wrote its hybrid mode defaults into."), config, typeof(NetcodeConfig), false);
+
+ if (settings.HybridDefaultsVersion < HybridNetcodeDefaults.Version)
+ {
+ EditorGUILayout.HelpBox("The Netcode for GameObjects hybrid defaults have not been applied to this config yet.", MessageType.Info);
+ }
+
+ if (GUILayout.Button(new GUIContent("Apply Recommended Hybrid Defaults", "Restores the snapshot, interpolation and transport values Netcode for GameObjects recommends for hybrid mode. Applied automatically once; use this to get back to them after changing them.")))
+ {
+ HybridNetcodeConfigApplier.ApplyDefaults(true);
+ }
+
+ GUILayout.EndVertical();
+ }
+#endif
}
internal class NetcodeSettingsLabel : NetcodeGUISettings
diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs
new file mode 100644
index 0000000000..ec9b1adb02
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs
@@ -0,0 +1,164 @@
+#if UNIFIED_NETCODE
+#if !UNIFIED_NETCODE_7_0_0
+using Unity.NetCode;
+using NetcodeConfig = Unity.NetCode.NetCodeConfig;
+#endif
+
+namespace Unity.Netcode
+{
+ ///
+ /// The values NGO needs when running in hybrid mode (i.e. Netcode for Entities is
+ /// installed and a registered network prefab carries a ).
+ ///
+ ///
+ /// This lives in the runtime assembly rather than the editor one because
+ /// is internal to Netcode for Entities, and Unity.Netcode.Runtime is the only NGO assembly it grants InternalsVisibleTo to.
+ /// Nothing here touches the AssetDatabase; the editor-side applier drives all of it.
+ ///
+ internal static class HybridNetcodeDefaults
+ {
+ ///
+ /// Bump whenever changes so that an upgrading project re-applies exactly once.
+ /// Persisted as NetcodeForGameObjectsProjectSettings.HybridDefaultsVersion.
+ ///
+ internal const int Version = 2;
+
+ // Mirrors NetworkConfig.TickRate's default. The editor writes the defaults before any NetworkManager is
+ // necessarily loaded, so it has nothing to read the real rate from. NetworkManager re-aligns the config when a
+ // session carrying ghost prefabs starts, which is what makes writing a fixed value here safe.
+ internal const uint DefaultTickRate = 30;
+
+ // Tuned against 2000 GenericPhysicsBallNGO instances in the ngo-examples project. A hybrid ghost costs ~4.87
+ // bytes per snapshot, so 4096 carries ~840 of them at the full tick rate. This is a cap and not a cost:
+ // below that count it puts no more on the wire than the N4E default would. Kept small because a snapshot is
+ // sent unreliably: losing any one of its fragments loses the whole snapshot.
+ internal const int SnapshotPacketSize = 4096;
+
+ // A ceiling on despawn bytes, not a reservation, so unused headroom is free. 0.2 is also N4E's clamp minimum.
+ internal const float PercentReservedForDespawn = 0.2f;
+
+ // Expressed in milliseconds rather than net ticks deliberately. N4E rounds this up to whole network ticks, so
+ // it holds >= 50ms of interpolation buffer at any tick rate. The net-tick form does not: 2 net ticks is 66.7ms
+ // at 30Hz but only 33.3ms at 60Hz, and 33.3ms is the buffer the stress test stuttered at.
+ internal const uint InterpolationTimeMS = 50;
+
+ internal const float InterpolationDelayMaxDeltaTicksFraction = 0.15f;
+ internal const float InterpolationTimeScaleMin = 0.9f;
+ internal const float InterpolationTimeScaleMax = 1.33f;
+
+ // A full snapshot fragments into ~3 datagrams and each fragment consumes a queue slot.
+ internal const int ClientQueueCapacity = 128;
+
+ ///
+ /// Applies the two settings hybrid mode cannot run without.
+ ///
+ /// The config to correct.
+ /// True if anything changed.
+ internal static bool ApplyRequired(NetcodeConfig config)
+ {
+ var changed = false;
+
+ // NetworkManager gates the world spin-up, so N4E must not bootstrap worlds on its own.
+ if (config.EnableClientServerBootstrap != NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap)
+ {
+ config.EnableClientServerBootstrap = NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap;
+ changed = true;
+ }
+
+ if (config.HostWorldModeSelection != NetcodeConfig.HostWorldMode.SingleWorld)
+ {
+ config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.SingleWorld;
+ changed = true;
+ }
+
+ return changed;
+ }
+
+ ///
+ /// Drives N4E's tick rates from so that ghost transform updates land on
+ /// the same interval NGO uses for everything else.
+ ///
+ /// 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 185d682f66..ec9a5d02a5 100644
--- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs
@@ -1407,13 +1407,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"));
+ Log.Error(new Context(LogLevel.Error, $"The {nameof(NetcodeConfig)} cannot be used in hybrid mode: {reason}.").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
///
@@ -1457,6 +1476,7 @@ public bool StartServer()
ShutdownInternal();
return false;
}
+ UnifiedAlignTickRate();
if (LogLevel <= LogLevel.Developer)
{
Log.Info(new Context(LogLevel.Developer, "Creating world: Default world"));
@@ -1536,6 +1556,7 @@ public bool StartClient()
ShutdownInternal();
return false;
}
+ UnifiedAlignTickRate();
Log.Info(new Context(LogLevel.Developer, "Creating world: Default world"));
InitializeNetcodeWorld();
}
@@ -1610,6 +1631,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..c4be6457ff
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs
@@ -0,0 +1,212 @@
+#if UNIFIED_NETCODE
+using NUnit.Framework;
+#if !UNIFIED_NETCODE_7_0_0
+using Unity.NetCode;
+using NetcodeConfig = Unity.NetCode.NetCodeConfig;
+#endif
+using Unity.Netcode.GameObjects.Editor.Configuration;
+using UnityEditor;
+using UnityEngine;
+
+namespace Unity.Netcode.GameObjects.EditorTests
+{
+ ///
+ /// Validates the values NGO applies in hybrid mode.
+ ///
+ 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 = NetcodeConfig.Global;
+ Assert.IsNotNull(config, "This project should have a NetcodeConfig to adjust.");
+
+ var settings = NetcodeForGameObjectsProjectSettings.instance;
+ var restoreVersion = settings.HybridDefaultsVersion;
+ var restoreConfig = EditorJsonUtility.ToJson(config);
+ try
+ {
+ settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version;
+ config.GhostSendSystemData.DefaultSnapshotPacketSize = k_UserPacketSize;
+
+ HybridNetcodeConfigApplier.ApplyDefaults(false);
+ Assert.AreEqual(k_UserPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "A recorded marker should stop the defaults from being written a second time.");
+
+ HybridNetcodeConfigApplier.ApplyDefaults(true);
+ Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "The Project Settings button should re-apply regardless of the marker.");
+ }
+ finally
+ {
+ Restore(config, restoreConfig);
+ settings.HybridDefaultsVersion = restoreVersion;
+ settings.SaveSettings();
+ }
+ }
+
+ ///
+ /// 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 c56c041e1e..4f5b07056a 100644
--- a/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef
+++ b/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef
@@ -35,6 +35,11 @@
"expression": "",
"define": "MULTIPLAYER_TOOLS"
},
+ {
+ "name": "com.unity.netcode",
+ "expression": "7.0.0",
+ "define": "UNIFIED_NETCODE_7_0_0"
+ },
{
"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..a2cd98ae4c
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs
@@ -0,0 +1,273 @@
+#if UNIFIED_NETCODE
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Entities;
+#if !UNIFIED_NETCODE_7_0_0
+using Unity.NetCode;
+#endif
+using Unity.Netcode.TestHelpers.Runtime;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+namespace Unity.Netcode.RuntimeTests
+{
+ ///
+ /// Measurement harness (not a pass/fail behaviour test) for how much of an N4E snapshot hybrid prefab transform
+ /// synchronization consumes, swept across and ghost
+ /// count. This is where HybridNetcodeDefaults.SnapshotPacketSize comes from; re-run it when that value is
+ /// reconsidered or when N4E changes its snapshot encoding.
+ ///
+ ///
+ /// Spawns N hybrid ghosts, keeps every one dirty on every tick, and reads the client-side
+ /// singleton over a fixed sample window. Each case emits one PKTSZ| line:
+ /// packet size, requested ghosts, ghosts the client spawned, samples, mean snapshot bits, p95 bits, max bits,
+ /// mean ghosts per snapshot, p95 ghosts, bytes per ghost, fraction of snapshots that hit the size cap, fraction
+ /// that could not carry every ghost, effective per ghost update rate, tick rate. Bytes per ghost and the
+ /// effective update rate are the two that decide the default: raising the cap only helps while the latter is
+ /// still below the tick rate.
+ ///
+ [TestFixture(HostOrServer.UnifiedHost)]
+ [Explicit("Measurement harness, not a regression test. The 24 auto-expanded cases take ~162s, so it only runs when selected by name: -testFilter \".*UnifiedSnapshotPacketSizeMeasurement.*\"")]
+ internal class UnifiedSnapshotPacketSizeMeasurement : NetcodeIntegrationTest
+ {
+ protected override int NumberOfClients => 1;
+
+ // Delta-compression baselines need several snapshots to settle; the first ones are much larger.
+ private const int k_WarmupSnapshots = 30;
+ private const int k_SampleSnapshots = 100;
+ private const int k_SpawnsPerFrame = 100;
+ private const float k_SpawnTimeout = 240.0f;
+ private const float k_SampleTimeout = 240.0f;
+
+ private GameObject m_Prefab;
+ private Transform[] m_Instances;
+ private GhostObject[] m_Ghosts;
+ private float[] m_Phases;
+ private int m_Frame;
+
+ public UnifiedSnapshotPacketSizeMeasurement(HostOrServer hostOrServer) : base(hostOrServer)
+ {
+ }
+
+ protected override bool OnSetVerboseDebug()
+ {
+ return false;
+ }
+
+ protected override IEnumerator OnSetup()
+ {
+ m_Instances = null;
+ m_Ghosts = null;
+ m_Phases = null;
+ m_Frame = 0;
+ // UnifiedHost sets m_AllPrefabsAsHybrid, so this yields a NetworkObject + GhostObject + NetworkObjectBridge prefab.
+ m_Prefab = CreateNetworkObjectPrefab("PktSizeGhost");
+ return base.OnSetup();
+ }
+
+ ///
+ /// Every instance orbits on its own phase so that no chunk is ever unchanged. N4E static-optimizes
+ /// unchanged chunks, so leaving these still would measure nothing.
+ ///
+ private void MoveAll()
+ {
+ if (m_Instances == null)
+ {
+ return;
+ }
+ m_Frame++;
+ var time = m_Frame * 0.01f;
+ for (int i = 0; i < m_Instances.Length; i++)
+ {
+ var instance = m_Instances[i];
+ if (instance == null)
+ {
+ continue;
+ }
+ var angle = time + m_Phases[i];
+ var radius = 20.0f + (i % 17);
+ var position = new Vector3(radius * Mathf.Cos(angle), (i % 32) * 0.5f, radius * Mathf.Sin(angle));
+ var rotation = Quaternion.Euler(0.0f, angle * Mathf.Rad2Deg, 0.0f);
+ instance.SetLocalPositionAndRotation(position, rotation);
+ // On a single-world host the GameObject transform is also written by the presentation-time smoothing
+ // system, so drive the authoritative LocalTransform directly as well.
+ var ghost = m_Ghosts[i];
+ if (ghost != null)
+ {
+ ghost.Position = position;
+ ghost.Rotation = rotation;
+ }
+ }
+ }
+
+ private static Entity CreateMetricsSingleton(EntityManager entityManager)
+ {
+ var typeList = new NativeArray(8, Allocator.Temp);
+ typeList[0] = ComponentType.ReadWrite();
+ typeList[1] = ComponentType.ReadWrite();
+ typeList[2] = ComponentType.ReadWrite();
+ typeList[3] = ComponentType.ReadWrite();
+ typeList[4] = ComponentType.ReadWrite();
+ typeList[5] = ComponentType.ReadWrite();
+ typeList[6] = ComponentType.ReadWrite();
+ typeList[7] = ComponentType.ReadWrite();
+ var singleton = entityManager.CreateEntity(entityManager.CreateArchetype(typeList));
+ typeList.Dispose();
+ entityManager.SetName(singleton, (FixedString64Bytes)"MetricsMonitor");
+ return singleton;
+ }
+
+ private static double Mean(List values)
+ {
+ double total = 0;
+ for (int i = 0; i < values.Count; i++)
+ {
+ total += values[i];
+ }
+ return values.Count == 0 ? 0 : total / values.Count;
+ }
+
+ private static uint Percentile(List values, double fraction)
+ {
+ if (values.Count == 0)
+ {
+ return 0;
+ }
+ var sorted = new List(values);
+ sorted.Sort();
+ var index = (int)Math.Round(fraction * (sorted.Count - 1));
+ return sorted[Mathf.Clamp(index, 0, sorted.Count - 1)];
+ }
+
+ [UnityTest]
+ public IEnumerator MeasureSnapshotSize(
+ [Values(0, 4000, 8000, 15000)] int packetSize,
+ [Values(250, 500, 1000, 2000, 2500, 3000)] int objectCount)
+ {
+ var hostWorld = m_ServerNetworkManager.NetcodeWorld;
+ var clientWorld = m_ClientNetworkManagers[0].NetcodeWorld;
+ Assert.IsNotNull(hostWorld, "Host has no NetcodeWorld!");
+ Assert.IsNotNull(clientWorld, "Client has no NetcodeWorld!");
+
+ var sendDataQuery = hostWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite());
+ var sendData = sendDataQuery.GetSingleton();
+ sendData.DefaultSnapshotPacketSize = packetSize;
+ sendDataQuery.SetSingleton(sendData);
+
+ var tickRate = 30;
+ var tickRateQuery = hostWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly());
+ if (tickRateQuery.CalculateEntityCount() == 1)
+ {
+ var configured = tickRateQuery.GetSingleton();
+ tickRate = configured.NetworkTickRate > 0 ? configured.NetworkTickRate : Mathf.Max(1, configured.SimulationTickRate);
+ }
+
+ CreateMetricsSingleton(clientWorld.EntityManager);
+ var snapshotMetricsQuery = clientWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly());
+
+ var clientSpawnManager = m_ClientNetworkManagers[0].SpawnManager;
+ var preSpawnCount = clientSpawnManager.SpawnedObjects.Count;
+
+ m_Instances = new Transform[objectCount];
+ m_Ghosts = new GhostObject[objectCount];
+ m_Phases = new float[objectCount];
+ var random = new System.Random(12345);
+ for (int i = 0; i < objectCount; i++)
+ {
+ m_Phases[i] = (float)(random.NextDouble() * Mathf.PI * 2.0f);
+ var spawned = SpawnObject(m_Prefab, m_ServerNetworkManager);
+ m_Instances[i] = spawned.transform;
+ m_Ghosts[i] = spawned.GetComponent();
+ if ((i + 1) % k_SpawnsPerFrame == 0)
+ {
+ MoveAll();
+ yield return null;
+ }
+ }
+
+ var deadline = Time.realtimeSinceStartup + k_SpawnTimeout;
+ while ((clientSpawnManager.SpawnedObjects.Count - preSpawnCount) < objectCount && Time.realtimeSinceStartup < deadline)
+ {
+ MoveAll();
+ yield return null;
+ }
+ var spawnedOnClient = clientSpawnManager.SpawnedObjects.Count - preSpawnCount;
+
+ var sizes = new List(k_SampleSnapshots);
+ var counts = new List(k_SampleSnapshots);
+ uint lastSnapshotTick = 0;
+ var snapshotsSeen = 0;
+ deadline = Time.realtimeSinceStartup + k_SampleTimeout;
+ while (snapshotsSeen < (k_WarmupSnapshots + k_SampleSnapshots) && Time.realtimeSinceStartup < deadline)
+ {
+ MoveAll();
+ yield return null;
+
+ if (snapshotMetricsQuery.CalculateEntityCount() != 1)
+ {
+ continue;
+ }
+ var metrics = snapshotMetricsQuery.GetSingleton();
+ if (metrics.SnapshotTick == 0 || metrics.SnapshotTick == lastSnapshotTick)
+ {
+ continue;
+ }
+ lastSnapshotTick = metrics.SnapshotTick;
+ snapshotsSeen++;
+ if (snapshotsSeen > k_WarmupSnapshots)
+ {
+ sizes.Add(metrics.TotalSizeInBits);
+ counts.Add(metrics.TotalGhostCount);
+ }
+ }
+
+ // Sanity check that the ghosts really did move (a static ghost measures nothing useful).
+ var hostNetworkObject = m_Instances[0].GetComponent();
+ if (clientSpawnManager.SpawnedObjects.TryGetValue(hostNetworkObject.NetworkObjectId, out var clientClone))
+ {
+ Debug.Log($"PKTDIAG|hostGO={m_Instances[0].position}|hostGhost={m_Ghosts[0].Position}|client={clientClone.transform.position}|frames={m_Frame}");
+ }
+
+ // The unfragmented default is driver derived; approximate with the configured MaxMessageSize for the cap check.
+ var effectiveCapBytes = packetSize > 0 ? packetSize : 1400;
+ var capHits = 0;
+ var incomplete = 0;
+ for (int i = 0; i < sizes.Count; i++)
+ {
+ if ((sizes[i] / 8.0) >= (effectiveCapBytes * 0.95))
+ {
+ capHits++;
+ }
+ if (counts[i] < objectCount)
+ {
+ incomplete++;
+ }
+ }
+
+ var meanBits = Mean(sizes);
+ var meanGhosts = Mean(counts);
+ var capHitFraction = sizes.Count == 0 ? 0.0 : (double)capHits / sizes.Count;
+ var incompleteFraction = sizes.Count == 0 ? 0.0 : (double)incomplete / sizes.Count;
+ var bytesPerGhost = meanGhosts <= 0 ? 0.0 : (meanBits / 8.0) / meanGhosts;
+ var effectiveHz = objectCount <= 0 ? 0.0 : (meanGhosts / objectCount) * tickRate;
+
+ Debug.Log($"PKTSZ|{packetSize}|{objectCount}|{spawnedOnClient}|{sizes.Count}|{meanBits:F1}|{Percentile(sizes, 0.95)}|" +
+ $"{Percentile(sizes, 1.0)}|{meanGhosts:F1}|{Percentile(counts, 0.95)}|{bytesPerGhost:F3}|{capHitFraction:F3}|{incompleteFraction:F3}|{effectiveHz:F2}|{tickRate}");
+
+ Assert.AreEqual(objectCount, spawnedOnClient, $"Client only spawned {spawnedOnClient} of {objectCount} hybrid ghosts!");
+ Assert.AreEqual(k_SampleSnapshots, sizes.Count, $"Only collected {sizes.Count} of {k_SampleSnapshots} snapshot samples!");
+ }
+
+ protected override IEnumerator OnTearDown()
+ {
+ m_Instances = null;
+ m_Ghosts = null;
+ m_Phases = null;
+ return base.OnTearDown();
+ }
+ }
+}
+#endif
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs.meta
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