From 3636fa243f1795e45a2a9c273c8833624ef87a95 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Sun, 6 Sep 2026 19:23:41 -0500 Subject: [PATCH 1/5] fix: gate hybrid prefab components instead of destroying them A hybrid prefab's NetworkRigidbodyBase derivatives were destroyed at runtime so a user could try hybrid mode without first stripping components from the prefab. Removing an entry from ChildNetworkBehaviours is not safe: InitializeChildNetworkBehaviours rebuilds unconditionally and has five call sites, two of them the public GetNetworkBehaviourOrderIndex and GetNetworkBehaviourAtOrderIndex, while Destroy is deferred to end of frame. A rebuild in a later frame therefore renumbers every behaviour after the removed one, and the NetworkVariable half of object synchronization is positional with no id, so a one-entry disagreement corrupts the whole buffer for that object. NetworkTransform was already handled by early-returning on HasGhost rather than being destroyed - the removal behind UNIFIED_NETCODE_DESTROY was dead, that define exists nowhere. This extends the same treatment to NetworkRigidbodyBase and closes four NetworkTransform paths that had no gate: OnSynchronize, InternalOnNetworkPostSpawn, InternalOnNetworkSessionSynchronized and InternalOnNetworkObjectParentChanged. The first of those means a hybrid object no longer writes a full teleport state to every joining client. The rigidbody is now forced kinematic on non-server peers rather than destroyed, restored from m_OriginalKinematicState on destroy as the class already provided for. That decision is made at spawn, not during Awake, because Initialize runs from Awake where an in-scene placed instance has no session to ask whether it is the server. UnregisterRigidbody had no call sites and is now called from the gate, which is what keeps a second Initialize from leaving a registration pointing at a component that no longer drives anything. [RequireComponent(typeof(Rigidbody))] is unconditional again. Behind #if !UNIFIED_NETCODE it was stripped from every NetworkRigidbody in the project the moment N4E was installed, hybrid or not, after which NetworkRigidbodyBase dereferences a null rigidbody in Awake. --- .../Components/NetworkRigidBodyBase.cs | 111 ++++-- .../Runtime/Components/NetworkRigidbody.cs | 3 - .../Runtime/Components/NetworkRigidbody2D.cs | 3 - .../Runtime/Components/NetworkTransform.cs | 38 +- .../Runtime/Core/NetworkObject.cs | 66 ---- .../UnifiedHybridPrefabBehaviourIdTests.cs | 331 ++++++++++++++++++ ...nifiedHybridPrefabBehaviourIdTests.cs.meta | 11 + 7 files changed, 459 insertions(+), 104 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs index bb6c6b54d8..c45f068667 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs @@ -21,13 +21,6 @@ public abstract class NetworkRigidbodyBase : NetworkBehaviour internal bool NetworkRigidbodyBaseExpanded; #endif - // TODO-UNIFIED: - // Provide an option to automatically remove the NetworkRigidbodyBase component at runtime if it is a hybrid prefab that is spawned since this - // component is primarily used for the Rigidbody interpolation and extrapolation features of NetworkTransform which are not relevant for a hybrid - // prefab that is spawned since it will be using N4E's built in interpolation and extrapolation features. This greatly improves performance on - // the client side. If using N4E prediction or distributed authority mode, then Rigibody and any component derived from this should always be used. - - /// /// When enabled, the associated will use the Rigidbody/Rigidbody2D to apply and synchronize changes in position, rotation, and /// allows for the use of Rigidbody interpolation/extrapolation. @@ -133,6 +126,13 @@ protected void Initialize(RigidbodyTypes rigidbodyType, NetworkTransform network NetworkTransform = GetComponent(); } +#if UNIFIED_NETCODE + if (InitializeHybrid()) + { + return; + } +#endif + if (NetworkTransform != null) { NetworkTransform.RegisterRigidbody(this); @@ -184,6 +184,13 @@ protected void Initialize(RigidbodyTypes rigidbodyType, NetworkTransform network NetworkTransform = GetComponent(); } +#if UNIFIED_NETCODE + if (InitializeHybrid()) + { + return; + } +#endif + if (NetworkTransform != null) { NetworkTransform.RegisterRigidbody(this); @@ -201,11 +208,6 @@ protected void Initialize(RigidbodyTypes rigidbodyType, NetworkTransform network #endif #if COM_UNITY_MODULES_PHYSICS && COM_UNITY_MODULES_PHYSICS2D -#if UNIFIED_NETCODE - // Used to keep track of the original kinematic state upon awake. - // (see OnDestroy below) - private bool m_OriginalKinematicState; -#endif /// /// Initializes the networked Rigidbody based on the /// passed in as a parameter. @@ -248,6 +250,13 @@ protected void Initialize(RigidbodyTypes rigidbodyType, NetworkTransform network NetworkTransform = GetComponent(); } +#if UNIFIED_NETCODE + if (InitializeHybrid()) + { + return; + } +#endif + if (NetworkTransform != null) { NetworkTransform.RegisterRigidbody(this); @@ -259,31 +268,74 @@ protected void Initialize(RigidbodyTypes rigidbodyType, NetworkTransform network if (AutoUpdateKinematicState) { -#if UNIFIED_NETCODE - // Keep track of the original kinematic state. (see OnDestroy) - m_OriginalKinematicState = IsKinematic(); -#endif SetIsKinematic(true); } } +#endif #if UNIFIED_NETCODE + // The kinematic state the prefab was authored with, captured before a hybrid prefab's body is + // forced kinematic. (see SetHybridKinematicState and OnDestroy) + private bool m_OriginalKinematicState; + + /// + /// A hybrid prefab's motion is driven by its GhostObject, so this component must not drive the + /// associated . It is left in place rather than destroyed so that + /// values stay identical on every peer. + /// + /// true when this is a hybrid prefab and normal initialization should be skipped + private bool InitializeHybrid() + { + if (NetworkObject == null || !NetworkObject.HasGhost) + { + return false; + } + + // Clears any registration left behind by a prior Initialize call. + if (NetworkTransform != null) + { + NetworkTransform.UnregisterRigidbody(); + } + + m_OriginalKinematicState = IsKinematic(); + return true; + } + + /// + /// Only the server simulates a hybrid prefab's body; everywhere else the ghost applies the + /// replicated motion. Deferred to spawn because initialization runs during Awake, where an + /// in-scene placed instance has no session to ask. + /// + private void SetHybridKinematicState() + { + if (!m_LocalNetworkManager.IsServer) + { + SetIsKinematic(true); + } + } + + /// public override void OnDestroy() { base.OnDestroy(); - // If the user has left this component on their prefab and this is a hybrid prefab, - // then we want to set the rigid body back to its original kinematic settings since - // we are automatically destroying these components at runtime when it is a hybrid - // prefab that is spawned. - if (NetworkObject && NetworkObject.HasGhost) + // Restore what InitializeHybrid overrode so the body is left as the prefab authored it. + if (!NetworkObject || !NetworkObject.HasGhost) { - if (m_InternalRigidbody || m_InternalRigidbody2D) - { - SetIsKinematic(m_OriginalKinematicState); - } + return; } - } +#if COM_UNITY_MODULES_PHYSICS && COM_UNITY_MODULES_PHYSICS2D + if (m_InternalRigidbody || m_InternalRigidbody2D) +#endif +#if COM_UNITY_MODULES_PHYSICS && !COM_UNITY_MODULES_PHYSICS2D + if (m_InternalRigidbody) +#endif +#if !COM_UNITY_MODULES_PHYSICS && COM_UNITY_MODULES_PHYSICS2D + if (m_InternalRigidbody2D) #endif + { + SetIsKinematic(m_OriginalKinematicState); + } + } #endif internal Vector3 GetAdjustedPositionThreshold() { @@ -1062,6 +1114,13 @@ internal override void InternalOnNetworkPreSpawn(ref NetworkManager networkManag /// public override void OnNetworkSpawn() { +#if UNIFIED_NETCODE + if (NetworkObject.HasGhost) + { + SetHybridKinematicState(); + return; + } +#endif m_TickFrequency = 1.0f / m_LocalNetworkManager.NetworkConfig.TickRate; m_TickRate = m_LocalNetworkManager.NetworkConfig.TickRate; UpdateOwnershipAuthority(); diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidbody.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidbody.cs index ffd82ff5f4..590fad77b7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidbody.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidbody.cs @@ -9,10 +9,7 @@ namespace Unity.Netcode.Components /// mode of the and disabling it on all peers but the authoritative one. /// [RequireComponent(typeof(NetworkTransform))] - // TODO-UNIFIED: We should not require this for unified and come up with a different way of handling the dependency -#if !UNIFIED_NETCODE [RequireComponent(typeof(Rigidbody))] -#endif [AddComponentMenu("Netcode/Network Rigidbody")] [HelpURL(HelpUrls.NetworkRigidbody)] public class NetworkRigidbody : NetworkRigidbodyBase diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidbody2D.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidbody2D.cs index 8912a2cff0..dd84d52252 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidbody2D.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidbody2D.cs @@ -9,10 +9,7 @@ namespace Unity.Netcode.Components /// mode of the rigidbody and disabling it on all peers but the authoritative one. /// [RequireComponent(typeof(NetworkTransform))] - // TODO-UNIFIED: We should not require this for unified and come up with a different way of handling the dependency -#if !UNIFIED_NETCODE [RequireComponent(typeof(Rigidbody2D))] -#endif [AddComponentMenu("Netcode/Network Rigidbody 2D")] [HelpURL(HelpUrls.NetworkRigidbody2D)] public class NetworkRigidbody2D : NetworkRigidbodyBase diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index 338d1f991f..3279ed38df 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -1908,6 +1908,13 @@ private bool ShouldSynchronizeHalfFloat(ulong targetClientId) /// protected override void OnSynchronize(ref BufferSerializer serializer) { +#if UNIFIED_NETCODE + // Writing nothing excludes this instance from the synchronization payload entirely. + if (NetworkObject.HasGhost) + { + return; + } +#endif var targetClientId = m_TargetIdBeingSynchronized; SynchronizeState = new NetworkTransformState() { @@ -3527,6 +3534,13 @@ private void NonAuthorityFinalizeSynchronization() /// protected internal override void InternalOnNetworkSessionSynchronized() { +#if UNIFIED_NETCODE + // Nothing was synchronized for a hybrid prefab, so there is nothing to finalize. + if (NetworkObject.HasGhost) + { + return; + } +#endif NonAuthorityFinalizeSynchronization(); base.InternalOnNetworkSessionSynchronized(); @@ -3548,6 +3562,13 @@ private void ApplyPlayerTransformState() /// protected internal override void InternalOnNetworkPostSpawn() { +#if UNIFIED_NETCODE + // Nothing was synchronized for a hybrid prefab, so there is nothing to finalize. + if (NetworkObject.HasGhost) + { + return; + } +#endif // This is a special case for client-server where a server is spawning an owner authoritative NetworkObject but has yet to serialize anything. // When the server detects that: // - We are not in a distributed authority session (DAHost check). @@ -3643,9 +3664,8 @@ internal override void InternalOnNetworkPreSpawn(ref NetworkManager networkManag public override void OnNetworkSpawn() { #if UNIFIED_NETCODE - // TODO-UNIFIED: - // Provide a notification to users that NetworkTransform component will be removed at runtime if it is a hybrid prefab that is spawned since - // it will be using N4E's built in interpolation and extrapolation features. + // A hybrid prefab's motion comes from its GhostObject, so this component stays inert. It is + // left in place rather than destroyed so NetworkBehaviourId values match on every peer. if (NetworkObject.HasGhost) { return; @@ -3743,9 +3763,8 @@ private void ResetInterpolatedStateToCurrentAuthoritativeState() internal virtual void InternalInitialization(bool isOwnershipChange = false) { #if UNIFIED_NETCODE - // TODO-UNIFIED: - // Provide a notification to users that NetworkTransform component will be removed at runtime if it is a hybrid prefab that is spawned since - // it will be using N4E's built in interpolation and extrapolation features. + // A hybrid prefab's motion comes from its GhostObject, so this component stays inert. It is + // left in place rather than destroyed so NetworkBehaviourId values match on every peer. if (NetworkObject.HasGhost) { return; @@ -3932,6 +3951,13 @@ private void DefaultParentChanged() internal override void InternalOnNetworkObjectParentChanged(NetworkObject parentNetworkObject) { +#if UNIFIED_NETCODE + // A hybrid prefab's transform space is driven by its GhostObject. + if (NetworkObject.HasGhost) + { + return; + } +#endif if (!SwitchTransformSpaceWhenParented) { // Motion authority doesn't need to adjust anything diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs index 2ec3eedacf..e258ea558c 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs @@ -3057,72 +3057,6 @@ internal bool InitializeChildNetworkBehaviours() } #endif } -#if UNIFIED_NETCODE - // For now, cycle through all known NetworkRigidbodyBase derived components - // and destroy them all if this is a hybrid prefab instance. - // This allows a user to not have to make direct adjustments until trying out their NGO prefab - // as a hybrid spawned prefab. - if (HasGhost && !NetworkManager.DistributedAuthorityMode) - { -#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D - // TODO-UNIFIED: This needs to be updated to make it "opt-in". - // If the GhostObject is not configured for prediction but is still using a Rigidbody, then go ahead and remove it on - // the client side to improve performance by default. - // TODO-UNIFIED: Determine if recent unified physics updates does not require checking for prediction. - if (NetworkRigidbodies != null) - { - var isServer = NetworkManager.IsServer; - for (int i = NetworkRigidbodies.Count - 1; i >= 0; i--) - { - var currenObject = NetworkRigidbodies[i].gameObject; - var currentHasGhostRigidBody = currenObject.GetComponent() != null; - if (!isServer) - { -#if COM_UNITY_MODULES_PHYSICS - var rigidBody = currenObject.GetComponent(); - - if (rigidBody != null && !currentHasGhostRigidBody) - { - Destroy(rigidBody); - } -#endif -#if COM_UNITY_MODULES_PHYSICS2D - var rigidBody2D = currenObject.GetComponent(); - if (rigidBody2D != null && !currentHasGhostRigidBody) - { - Destroy(rigidBody2D); - } -#endif - } - // Both the server and clients will still remove and destroy the NetworkRigidbody - // since there is no point in synchronizing these when it is handled via unified. - var networkRigidbody = NetworkRigidbodies[i]; - NetworkRigidbodies.Remove(networkRigidbody); - ChildNetworkBehaviours.Remove(networkRigidbody.NetworkBehaviourId); - Destroy(networkRigidbody); - } - } -#endif - // This is defined out since users might have derived NetworkTransforms -#if UNIFIED_NETCODE_DESTROY - // When hybrid spawning, the transform is synchronized by the GhostObject. - // As a convenience, we remove and destroy all NetworkTransforms. - // TODO-Parenting-Related-Area: We need to replicate this functionality in a GhostObject - // Possibly use a "Synchronize" property and display only on children of a root parent GhostObject. - if (NetworkTransforms != null) - { - NetworkManager.Log.Warning(new Logging.Context(LogLevel.Developer, $"[]{name} Hybrid spawned objects do not support {nameof(NetworkTransform)} and " + - $"are removed at runtime. If hybrid spawning is intended, then remove it from the network prefab to avoid allocating and de-allocating at runtime.")); - for (int i = NetworkTransforms.Count - 1; i >= 0; i--) - { - ChildNetworkBehaviours.Remove(NetworkTransforms[i].NetworkBehaviourId); - Destroy(NetworkTransforms[i]); - } - NetworkTransforms.Clear(); - } -#endif - } -#endif return true; } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs new file mode 100644 index 0000000000..dbb91eda56 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs @@ -0,0 +1,331 @@ +#if UNIFIED_NETCODE && COM_UNITY_MODULES_PHYSICS +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Authored after the on the hybrid prefab, which makes this the component + /// that gets renumbered when anything ahead of it leaves . + /// + internal class HybridTrailingBehaviour : NetworkBehaviour + { + public NetworkVariable SynchronizedValue = new NetworkVariable(); + + // Only ever written by the Rpc handlers below. An Rpc that resolved to a different component + // never reaches them, so these double as the "arrived at the right component" assertion. + public List PingSenders = new List(); + public int PongCount; + + [Rpc(SendTo.Server)] + public void PingRpc(ulong senderClientId) + { + PingSenders.Add(senderClientId); + PongRpc(); + } + + [Rpc(SendTo.Everyone)] + public void PongRpc() + { + PongCount++; + } + } + + /// + /// A hybrid prefab keeps its and components + /// and gates their initialization rather than destroying them at runtime. + /// + /// + /// Destroying them dropped an entry out of while the + /// component itself lived until the end of the frame, so the next rebuild renumbered every behaviour that + /// followed. Rpcs address a behaviour by and NetworkVariable + /// synchronization is positional within that same collection, so a one entry disagreement misroutes an Rpc + /// and corrupts the whole synchronization payload for the object. + /// Hybrid prefab spawning is client-server only, so there is no distributed authority case here. + /// + [TestFixture(HostOrServer.UnifiedHost)] + internal class UnifiedHybridPrefabBehaviourIdTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 2; + + private const int k_SynchronizedValue = 0x5AF3; + + // The authored component order, captured from the prefab. Every peer has to reproduce it, and the index + // of each entry is the NetworkBehaviourId that peer is expected to have assigned. Read from the prefab + // rather than written out here because the integration test helpers author components of their own. + private Type[] m_AuthoredBehaviourOrder; + + private GameObject m_Prefab; + private NetworkObject m_Instance; + + public UnifiedHybridPrefabBehaviourIdTests(HostOrServer hostOrServer) : base(hostOrServer) + { + } + + protected override bool UseUnifiedTests() + { + return true; + } + + protected override void OnServerAndClientsCreated() + { + m_Prefab = CreateNetworkObjectPrefab("HybridOrdering"); + m_Prefab.AddComponent(); + // NetworkRigidbody requires the Rigidbody. Adding it explicitly keeps the authored order visible + // instead of leaving it to RequireComponent. + m_Prefab.AddComponent(); + m_Prefab.AddComponent(); + m_Prefab.AddComponent(); + + var authoredBehaviours = m_Prefab.GetComponents(); + m_AuthoredBehaviourOrder = new Type[authoredBehaviours.Length]; + for (int i = 0; i < authoredBehaviours.Length; i++) + { + m_AuthoredBehaviourOrder[i] = authoredBehaviours[i].GetType(); + } + + base.OnServerAndClientsCreated(); + } + + /// + /// The fixture only tests anything if the trailing behaviour really is authored after the components + /// that used to be destroyed at runtime. + /// + private void AssertAuthoredOrder() + { + var transformIndex = Array.IndexOf(m_AuthoredBehaviourOrder, typeof(NetworkTransform)); + var rigidbodyIndex = Array.IndexOf(m_AuthoredBehaviourOrder, typeof(NetworkRigidbody)); + var trailingIndex = Array.IndexOf(m_AuthoredBehaviourOrder, typeof(HybridTrailingBehaviour)); + + Assert.Greater(rigidbodyIndex, transformIndex, $"The {nameof(NetworkRigidbody)} is not authored after the {nameof(NetworkTransform)}!"); + Assert.Greater(trailingIndex, rigidbodyIndex, $"The {nameof(HybridTrailingBehaviour)} is not authored after the {nameof(NetworkRigidbody)}!"); + } + + private HybridTrailingBehaviour GetTrailingBehaviour(NetworkManager networkManager) + { + return networkManager.SpawnManager.SpawnedObjects[m_Instance.NetworkObjectId].GetComponent(); + } + + private IEnumerator SpawnHybridInstance() + { + AssertAuthoredOrder(); + m_Instance = SpawnObject(m_Prefab, m_ServerNetworkManager).GetComponent(); + + yield return WaitForSpawnedOnAllOrTimeOut(m_Instance); + AssertOnTimeout($"Failed to spawn {m_Instance.name} on all clients!"); + } + + /// + /// Every peer has to hold the same behaviour table for the instance: the same number of entries, the + /// same type at each index, and the same on each. + /// + private bool ValidateBehaviourTable(StringBuilder errorLog) + { + foreach (var networkManager in m_NetworkManagers) + { + if (!networkManager.SpawnManager.SpawnedObjects.TryGetValue(m_Instance.NetworkObjectId, out var instance)) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has not spawned the instance!"); + continue; + } + + var childBehaviours = instance.ChildNetworkBehaviours; + if (childBehaviours.Count != m_AuthoredBehaviourOrder.Length) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] {nameof(NetworkObject.ChildNetworkBehaviours)} holds " + + $"{childBehaviours.Count} entries but {m_AuthoredBehaviourOrder.Length} were authored!"); + continue; + } + + for (ushort index = 0; index < m_AuthoredBehaviourOrder.Length; index++) + { + if (!childBehaviours.TryGetValue(index, out var behaviour)) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] No {nameof(NetworkBehaviour)} at index {index}!"); + continue; + } + + if (behaviour.GetType() != m_AuthoredBehaviourOrder[index]) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Index {index} holds a {behaviour.GetType().Name} " + + $"but a {m_AuthoredBehaviourOrder[index].Name} was authored there!"); + } + + if (behaviour.NetworkBehaviourId != index) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] {behaviour.GetType().Name} has a " + + $"{nameof(NetworkBehaviour.NetworkBehaviourId)} of {behaviour.NetworkBehaviourId} but is at index {index}!"); + } + } + + ValidateGatedComponents(networkManager, instance, errorLog); + } + + return errorLog.Length == 0; + } + + /// + /// The gated components are still present and still inert. + /// + private void ValidateGatedComponents(NetworkManager networkManager, NetworkObject instance, StringBuilder errorLog) + { + if (instance.GetComponent() == null) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] The {nameof(NetworkRigidbody)} was removed from the instance!"); + } + + var networkTransform = instance.GetComponent(); + if (networkTransform == null) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] The {nameof(NetworkTransform)} was removed from the instance!"); + return; + } + + // A NetworkTransform that initialized takes authority on the server and registers its NetworkObject + // for the per frame update pass everywhere else. A gated one does neither. + if (networkTransform.CanCommitToTransform) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] The {nameof(NetworkTransform)} took authority over a hybrid prefab!"); + } + + if (networkManager.NetworkTransformUpdate.ContainsKey(instance.NetworkObjectId)) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] The {nameof(NetworkTransform)} registered a hybrid prefab for updates!"); + } + } + + /// + /// Every client pings the authority on the trailing behaviour and the authority answers all of them. + /// + private bool ValidateRpcRoundTrip(StringBuilder errorLog) + { + var expectedPongs = m_ClientNetworkManagers.Length; + var authorityBehaviour = GetTrailingBehaviour(GetAuthorityNetworkManager()); + if (authorityBehaviour.PingSenders.Count != m_ClientNetworkManagers.Length) + { + errorLog.AppendLine($"[Authority] Received {authorityBehaviour.PingSenders.Count} pings but expected {m_ClientNetworkManagers.Length}!"); + } + + foreach (var client in m_ClientNetworkManagers) + { + if (!authorityBehaviour.PingSenders.Contains(client.LocalClientId)) + { + errorLog.AppendLine($"[Authority] Received no ping from Client-{client.LocalClientId}!"); + } + } + + foreach (var networkManager in m_NetworkManagers) + { + var pongCount = GetTrailingBehaviour(networkManager).PongCount; + if (pongCount != expectedPongs) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Received {pongCount} pongs but expected {expectedPongs}!"); + } + } + + return errorLog.Length == 0; + } + + private IEnumerator PingFromEveryClient() + { + foreach (var client in m_ClientNetworkManagers) + { + GetTrailingBehaviour(client).PingRpc(client.LocalClientId); + } + + yield return WaitForConditionOrTimeOut(ValidateRpcRoundTrip); + AssertOnTimeout($"An Rpc on the {nameof(HybridTrailingBehaviour)} did not complete its round trip!"); + } + + private bool ValidateSynchronizedValue(StringBuilder errorLog) + { + foreach (var networkManager in m_NetworkManagers) + { + if (!networkManager.SpawnManager.SpawnedObjects.TryGetValue(m_Instance.NetworkObjectId, out var instance)) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has not spawned the instance!"); + continue; + } + + var value = instance.GetComponent().SynchronizedValue.Value; + if (value != k_SynchronizedValue) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] {nameof(HybridTrailingBehaviour.SynchronizedValue)} is " + + $"{value} but {k_SynchronizedValue} was expected!"); + } + } + + return errorLog.Length == 0; + } + + [UnityTest] + public IEnumerator BehaviourIdsMatchOnAllPeers() + { + yield return SpawnHybridInstance(); + + yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); + AssertOnTimeout("A peer disagreed about the hybrid prefab's behaviour table!"); + + yield return PingFromEveryClient(); + } + + /// + /// rebuilds the table from the components + /// that are on the instance at that moment, and it is reachable after spawn through the public + /// and + /// . A rebuild on one peer must therefore + /// produce the same ids that peer already handed out, or it stops agreeing with everyone else. + /// + [UnityTest] + public IEnumerator BehaviourIdsSurviveARebuild() + { + yield return SpawnHybridInstance(); + + yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); + AssertOnTimeout("A peer disagreed about the hybrid prefab's behaviour table!"); + + foreach (var networkManager in m_NetworkManagers) + { + networkManager.SpawnManager.SpawnedObjects[m_Instance.NetworkObjectId].InitializeChildNetworkBehaviours(); + } + + yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); + AssertOnTimeout("Rebuilding the behaviour table after spawn moved the behaviour ids!"); + + // The ids are only worth anything if they still route a message, so exercise the wire too. + yield return PingFromEveryClient(); + } + + /// + /// NetworkVariable synchronization walks positionally + /// with nothing on the wire to identify a behaviour, so a late joiner reading a table the authority does + /// not share desynchronizes every value after the first disagreement. + /// + [UnityTest] + public IEnumerator NetworkVariableSynchronizesToALateJoiner() + { + yield return SpawnHybridInstance(); + + GetTrailingBehaviour(GetAuthorityNetworkManager()).SynchronizedValue.Value = k_SynchronizedValue; + + yield return WaitForConditionOrTimeOut(ValidateSynchronizedValue); + AssertOnTimeout($"A peer did not receive the {nameof(HybridTrailingBehaviour.SynchronizedValue)} update!"); + + yield return CreateAndStartNewClient(); + + yield return WaitForConditionOrTimeOut(ValidateSynchronizedValue); + AssertOnTimeout($"The late joining client did not synchronize the {nameof(HybridTrailingBehaviour.SynchronizedValue)}!"); + + yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); + AssertOnTimeout("The late joining client disagreed about the hybrid prefab's behaviour table!"); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs.meta new file mode 100644 index 0000000000..a761227255 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 95f1ef1b64600f04d9085d092ee72245 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 8922c7145d4af0b54a7908da6b2f04aac44404d7 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 5 Oct 2026 08:49:47 -0500 Subject: [PATCH 2/5] fix: reject hybrid prefabs in distributed authority sessions Distributed authority does not support hybrid prefabs. A start with any GhostObject prefab registered now fails in CanStart with an error naming each prefab, before Initialize replaces the transport with UnifiedNetcodeTransport. A hybrid prefab added during a distributed authority session is refused, so HasGhostPrefabs cannot switch the running session into hybrid mode and stop its send queue. --- .../Components/NetworkRigidBodyBase.cs | 14 +-- .../Runtime/Components/NetworkTransform.cs | 8 +- .../Runtime/Configuration/NetworkPrefabs.cs | 11 ++ .../Runtime/Core/NetworkManager.cs | 45 +++++++ .../UnifiedHybridPrefabBehaviourIdTests.cs | 35 +++--- ...edHybridPrefabDistributedAuthorityTests.cs | 117 ++++++++++++++++++ ...ridPrefabDistributedAuthorityTests.cs.meta | 11 ++ 7 files changed, 206 insertions(+), 35 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs index c45f068667..75b5449cb1 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs @@ -274,14 +274,12 @@ protected void Initialize(RigidbodyTypes rigidbodyType, NetworkTransform network #endif #if UNIFIED_NETCODE - // The kinematic state the prefab was authored with, captured before a hybrid prefab's body is - // forced kinematic. (see SetHybridKinematicState and OnDestroy) + // The authored kinematic state, restored in OnDestroy. private bool m_OriginalKinematicState; /// - /// A hybrid prefab's motion is driven by its GhostObject, so this component must not drive the - /// associated . It is left in place rather than destroyed so that - /// values stay identical on every peer. + /// A hybrid prefab's motion is driven by its GhostObject, so this component does not drive the .
+ /// The component stays on the instance so values match on every peer.
///
/// true when this is a hybrid prefab and normal initialization should be skipped private bool InitializeHybrid() @@ -302,9 +300,8 @@ private bool InitializeHybrid() } /// - /// Only the server simulates a hybrid prefab's body; everywhere else the ghost applies the - /// replicated motion. Deferred to spawn because initialization runs during Awake, where an - /// in-scene placed instance has no session to ask. + /// Makes a hybrid prefab's body kinematic on every peer except the server.
+ /// Invoked at spawn because an in-scene placed instance has no session during Awake.
///
private void SetHybridKinematicState() { @@ -318,7 +315,6 @@ private void SetHybridKinematicState() public override void OnDestroy() { base.OnDestroy(); - // Restore what InitializeHybrid overrode so the body is left as the prefab authored it. if (!NetworkObject || !NetworkObject.HasGhost) { return; diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index 3279ed38df..121cecef6e 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -1909,7 +1909,7 @@ private bool ShouldSynchronizeHalfFloat(ulong targetClientId) protected override void OnSynchronize(ref BufferSerializer serializer) { #if UNIFIED_NETCODE - // Writing nothing excludes this instance from the synchronization payload entirely. + // Late joining clients receive no transform state for a hybrid prefab. if (NetworkObject.HasGhost) { return; @@ -3563,7 +3563,6 @@ private void ApplyPlayerTransformState() protected internal override void InternalOnNetworkPostSpawn() { #if UNIFIED_NETCODE - // Nothing was synchronized for a hybrid prefab, so there is nothing to finalize. if (NetworkObject.HasGhost) { return; @@ -3664,8 +3663,6 @@ internal override void InternalOnNetworkPreSpawn(ref NetworkManager networkManag public override void OnNetworkSpawn() { #if UNIFIED_NETCODE - // A hybrid prefab's motion comes from its GhostObject, so this component stays inert. It is - // left in place rather than destroyed so NetworkBehaviourId values match on every peer. if (NetworkObject.HasGhost) { return; @@ -3763,8 +3760,7 @@ private void ResetInterpolatedStateToCurrentAuthoritativeState() internal virtual void InternalInitialization(bool isOwnershipChange = false) { #if UNIFIED_NETCODE - // A hybrid prefab's motion comes from its GhostObject, so this component stays inert. It is - // left in place rather than destroyed so NetworkBehaviourId values match on every peer. + // A hybrid prefab's motion comes from its GhostObject, so this component stays inert but remains on the instance so NetworkBehaviourId values match on every peer. if (NetworkObject.HasGhost) { return; diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs index 0603ba44a7..5350077be3 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs @@ -356,7 +356,12 @@ public bool Contains(NetworkPrefab prefab) } #if UNIFIED_NETCODE + internal const string DistributedAuthorityHybridPrefabError = "Distributed authority does not support hybrid prefabs."; + internal bool HasGhostPrefabs { get; private set; } + + // Set by NetworkManager for the duration of a distributed authority session. + internal bool RejectGhostPrefabs; #endif @@ -381,6 +386,12 @@ private bool AddPrefabRegistration(NetworkPrefab networkPrefab) #if UNIFIED_NETCODE if (networkPrefab.HasGhost) { + // Registering a hybrid prefab mid-session would switch NetworkManager into hybrid mode and stop its send queue. + if (RejectGhostPrefabs) + { + Debug.LogError($"{DistributedAuthorityHybridPrefabError} {networkPrefab.Prefab.name} was not added."); + return false; + } HasGhostPrefabs = true; } #endif diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 3c5600afa5..6607fcad38 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1358,6 +1358,12 @@ private bool CanStart(StartType type) } } +#if UNIFIED_NETCODE + if (!ValidateDistributedAuthorityPrefabs()) + { + return false; + } +#endif return true; } @@ -1408,6 +1414,40 @@ private bool UnifiedIsConfiguredCorrectly() } return true; } + + /// + /// Distributed authority does not support hybrid prefabs.
+ /// When the topology is distributed authority:
+ /// - If any registered prefab has a GhostObject: logs an error naming each one and returns false.
+ /// - Otherwise: hybrid prefabs added during the session are rejected.
+ ///
+ private bool ValidateDistributedAuthorityPrefabs() + { + if (NetworkConfig.NetworkTopology != NetworkTopologyTypes.DistributedAuthority) + { + return true; + } + + // Registers any prefab list assigned after Awake. + NetworkConfig.InitializePrefabs(); + var prefabs = NetworkConfig.Prefabs; + if (prefabs.HasGhostPrefabs) + { + var hybridPrefabNames = new List(); + foreach (var networkPrefab in prefabs.Prefabs) + { + if (networkPrefab.HasGhost) + { + hybridPrefabNames.Add(networkPrefab.Prefab.name); + } + } + Log.Error(new Context(LogLevel.Error, $"{NetworkPrefabs.DistributedAuthorityHybridPrefabError} Remove the GhostObject from these prefabs or use a prefab list without them: {string.Join(", ", hybridPrefabNames)}").AddTag("Unified")); + return false; + } + + prefabs.RejectGhostPrefabs = true; + return true; + } #endif /// @@ -1838,6 +1878,11 @@ internal void ShutdownInternal() Log.Exception(ex); } } + + if (NetworkConfig != null && NetworkConfig.Prefabs != null) + { + NetworkConfig.Prefabs.RejectGhostPrefabs = false; + } #endif // Generate a local notification that the host client is disconnected diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs index dbb91eda56..e6881fd410 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs @@ -39,17 +39,10 @@ public void PongRpc() } /// - /// A hybrid prefab keeps its and components - /// and gates their initialization rather than destroying them at runtime. + /// A hybrid prefab keeps its and components inert on the instance.
+ /// Validates that every peer assigns the same values, so Rpcs and NetworkVariable synchronization reach the right behaviour.
+ /// Distributed authority rejects hybrid prefabs (see ).
///
- /// - /// Destroying them dropped an entry out of while the - /// component itself lived until the end of the frame, so the next rebuild renumbered every behaviour that - /// followed. Rpcs address a behaviour by and NetworkVariable - /// synchronization is positional within that same collection, so a one entry disagreement misroutes an Rpc - /// and corrupts the whole synchronization payload for the object. - /// Hybrid prefab spawning is client-server only, so there is no distributed authority case here. - /// [TestFixture(HostOrServer.UnifiedHost)] internal class UnifiedHybridPrefabBehaviourIdTests : NetcodeIntegrationTest { @@ -57,9 +50,8 @@ internal class UnifiedHybridPrefabBehaviourIdTests : NetcodeIntegrationTest private const int k_SynchronizedValue = 0x5AF3; - // The authored component order, captured from the prefab. Every peer has to reproduce it, and the index - // of each entry is the NetworkBehaviourId that peer is expected to have assigned. Read from the prefab - // rather than written out here because the integration test helpers author components of their own. + // The authored component order, read from the prefab because the test helpers add components of their own. + // The index of each entry is the NetworkBehaviourId every peer is expected to assign. private Type[] m_AuthoredBehaviourOrder; private GameObject m_Prefab; @@ -78,8 +70,6 @@ protected override void OnServerAndClientsCreated() { m_Prefab = CreateNetworkObjectPrefab("HybridOrdering"); m_Prefab.AddComponent(); - // NetworkRigidbody requires the Rigidbody. Adding it explicitly keeps the authored order visible - // instead of leaving it to RequireComponent. m_Prefab.AddComponent(); m_Prefab.AddComponent(); m_Prefab.AddComponent(); @@ -95,8 +85,7 @@ protected override void OnServerAndClientsCreated() } /// - /// The fixture only tests anything if the trailing behaviour really is authored after the components - /// that used to be destroyed at runtime. + /// Validates that the trailing behaviour is authored after the gated components. /// private void AssertAuthoredOrder() { @@ -116,7 +105,7 @@ private HybridTrailingBehaviour GetTrailingBehaviour(NetworkManager networkManag private IEnumerator SpawnHybridInstance() { AssertAuthoredOrder(); - m_Instance = SpawnObject(m_Prefab, m_ServerNetworkManager).GetComponent(); + m_Instance = SpawnObject(m_Prefab, GetAuthorityNetworkManager()).GetComponent(); yield return WaitForSpawnedOnAllOrTimeOut(m_Instance); AssertOnTimeout($"Failed to spawn {m_Instance.name} on all clients!"); @@ -172,7 +161,8 @@ private bool ValidateBehaviourTable(StringBuilder errorLog) } /// - /// The gated components are still present and still inert. + /// The gated components are still present and still inert.
+ /// The body is kinematic on every peer except the server.
///
private void ValidateGatedComponents(NetworkManager networkManager, NetworkObject instance, StringBuilder errorLog) { @@ -181,6 +171,12 @@ private void ValidateGatedComponents(NetworkManager networkManager, NetworkObjec errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] The {nameof(NetworkRigidbody)} was removed from the instance!"); } + var isKinematic = instance.GetComponent().isKinematic; + if (isKinematic == networkManager.IsServer) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] {nameof(Rigidbody.isKinematic)} is {isKinematic} but {!networkManager.IsServer} was expected!"); + } + var networkTransform = instance.GetComponent(); if (networkTransform == null) { @@ -299,7 +295,6 @@ public IEnumerator BehaviourIdsSurviveARebuild() yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); AssertOnTimeout("Rebuilding the behaviour table after spawn moved the behaviour ids!"); - // The ids are only worth anything if they still route a message, so exercise the wire too. yield return PingFromEveryClient(); } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs new file mode 100644 index 0000000000..0b7087c3de --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs @@ -0,0 +1,117 @@ +#if UNIFIED_NETCODE +using System; +using System.Collections; +using System.Text.RegularExpressions; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates that a distributed authority session rejects hybrid prefabs:
+ /// - A start with a hybrid prefab registered fails before anything is initialized.
+ /// - A hybrid prefab added during the session is not registered.
+ ///
+ /// + /// The fixture's own session only provides the hybrid prefab.
+ /// Each test runs a separate distributed authority with a .
+ ///
+ [TestFixture(HostOrServer.UnifiedHost)] + internal class UnifiedHybridPrefabDistributedAuthorityTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 0; + + private GameObject m_HybridPrefab; + private NetworkManager m_DistributedAuthorityNetworkManager; + + public UnifiedHybridPrefabDistributedAuthorityTests(HostOrServer hostOrServer) : base(hostOrServer) + { + } + + protected override bool UseUnifiedTests() + { + return true; + } + + protected override void OnServerAndClientsCreated() + { + m_HybridPrefab = CreateNetworkObjectPrefab("DistributedAuthorityHybrid"); + base.OnServerAndClientsCreated(); + } + + protected override IEnumerator OnTearDown() + { + if (m_DistributedAuthorityNetworkManager != null) + { + if (m_DistributedAuthorityNetworkManager.IsListening) + { + m_DistributedAuthorityNetworkManager.Shutdown(); + } + Object.DestroyImmediate(m_DistributedAuthorityNetworkManager.gameObject); + m_DistributedAuthorityNetworkManager = null; + } + yield return base.OnTearDown(); + } + + private void CreateDistributedAuthorityNetworkManager() + { + var gameObject = new GameObject("DistributedAuthorityNetworkManager"); + m_DistributedAuthorityNetworkManager = gameObject.AddComponent(); + m_DistributedAuthorityNetworkManager.NetworkConfig = new NetworkConfig() + { + NetworkTransport = gameObject.AddComponent(), + NetworkTopology = NetworkTopologyTypes.DistributedAuthority, + ForceSamePrefabs = false, + }; + } + + [UnityTest] + public IEnumerator StartFailsWithHybridPrefabRegistered() + { + CreateDistributedAuthorityNetworkManager(); + m_DistributedAuthorityNetworkManager.AddNetworkPrefab(m_HybridPrefab); + var transport = m_DistributedAuthorityNetworkManager.NetworkConfig.NetworkTransport; + + var startMethods = new (string Name, Func Start)[] + { + (nameof(NetworkManager.StartServer), m_DistributedAuthorityNetworkManager.StartServer), + (nameof(NetworkManager.StartHost), m_DistributedAuthorityNetworkManager.StartHost), + (nameof(NetworkManager.StartClient), m_DistributedAuthorityNetworkManager.StartClient), + }; + + foreach (var startMethod in startMethods) + { + LogAssert.Expect(LogType.Error, new Regex($"{Regex.Escape(NetworkPrefabs.DistributedAuthorityHybridPrefabError)}.*{m_HybridPrefab.name}")); + Assert.IsFalse(startMethod.Start(), $"{startMethod.Name} started a distributed authority session with a hybrid prefab registered!"); + Assert.IsFalse(m_DistributedAuthorityNetworkManager.IsListening, $"{startMethod.Name} left the {nameof(NetworkManager)} listening!"); + Assert.AreSame(transport, m_DistributedAuthorityNetworkManager.NetworkConfig.NetworkTransport, $"{startMethod.Name} replaced the transport!"); + } + yield return null; + } + + [UnityTest] + public IEnumerator HybridPrefabAddedDuringSessionIsRejected() + { + CreateDistributedAuthorityNetworkManager(); + Assert.IsTrue(m_DistributedAuthorityNetworkManager.StartHost(), "Failed to start a distributed authority session without hybrid prefabs!"); + yield return null; + + var prefabs = m_DistributedAuthorityNetworkManager.NetworkConfig.Prefabs; + LogAssert.Expect(LogType.Error, new Regex($"{Regex.Escape(NetworkPrefabs.DistributedAuthorityHybridPrefabError)}.*{m_HybridPrefab.name}")); + m_DistributedAuthorityNetworkManager.AddNetworkPrefab(m_HybridPrefab); + Assert.IsFalse(prefabs.Contains(m_HybridPrefab), "The hybrid prefab was registered during a distributed authority session!"); + Assert.IsFalse(prefabs.HasGhostPrefabs, $"{nameof(NetworkPrefabs.HasGhostPrefabs)} was set during a distributed authority session!"); + + m_DistributedAuthorityNetworkManager.Shutdown(); + yield return WaitForConditionOrTimeOut(() => !m_DistributedAuthorityNetworkManager.IsListening); + AssertOnTimeout("The distributed authority session did not shut down!"); + + m_DistributedAuthorityNetworkManager.AddNetworkPrefab(m_HybridPrefab); + Assert.IsTrue(prefabs.Contains(m_HybridPrefab), "The hybrid prefab was rejected after the distributed authority session ended!"); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs.meta new file mode 100644 index 0000000000..20334057c4 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9dfaff802302483786620552292de1bc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From f3b9045e887f09816ff0d4bbd85c0798d5cb2f51 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 5 Oct 2026 12:16:11 -0500 Subject: [PATCH 3/5] fix: address u-pr review and reject hybrid prefab overrides - NetworkRigidbodyBase no longer re-applies the authority kinematic rule on an ownership change for a hybrid prefab. - NetworkPrefabs recomputes HasGhostPrefabs on initialize, and keeps a single subscription to each NetworkPrefabsList (a prefab added to a list during a session was registered more than once). - RejectGhostPrefabs is set in Initialize, after SetRole accepts the start, so a failed start cannot leave it set. - A NetworkPrefab override with a hybrid source or target prefab is detected as a hybrid registration. N4E instantiates the server's prefab from the ghost type, so overrides are not supported for hybrid prefabs yet: a start with one registered fails in any topology, and one added during a session is rejected. --- com.unity.netcode.gameobjects/CHANGELOG.md | 2 + .../Components/NetworkRigidBodyBase.cs | 7 +- .../Runtime/Configuration/NetworkPrefab.cs | 23 ++ .../Runtime/Configuration/NetworkPrefabs.cs | 19 +- .../Runtime/Core/NetworkManager.cs | 60 +++-- .../UnifiedHybridPrefabBehaviourIdTests.cs | 39 ++- ...edHybridPrefabDistributedAuthorityTests.cs | 117 --------- .../UnifiedHybridPrefabValidationTests.cs | 234 ++++++++++++++++++ ...nifiedHybridPrefabValidationTests.cs.meta} | 0 9 files changed, 359 insertions(+), 142 deletions(-) delete mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs rename com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/{UnifiedHybridPrefabDistributedAuthorityTests.cs.meta => UnifiedHybridPrefabValidationTests.cs.meta} (100%) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 15f400f9db..2b48042207 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -20,6 +20,8 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed +- Fixed issue where a prefab added to a `NetworkPrefabsList` while a session was running was registered more than once and logged a duplicate `GlobalObjectIdHash` error. (#4184) + ### Security ### Obsolete diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs index 75b5449cb1..068aaf2517 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs @@ -1067,7 +1067,12 @@ public void ResetInterpolation() protected override void OnOwnershipChanged(ulong previous, ulong current) { - UpdateOwnershipAuthority(); +#if UNIFIED_NETCODE + if (!NetworkObject.HasGhost) +#endif + { + UpdateOwnershipAuthority(); + } base.OnOwnershipChanged(previous, current); } diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefab.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefab.cs index a873404b3c..b57c70790f 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefab.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefab.cs @@ -270,9 +270,32 @@ public bool Validate(int index = -1) return false; } +#if UNIFIED_NETCODE + // An override is a hybrid registration when either its source or its target prefab has a GhostObject. + HasGhost = (Override == NetworkPrefabOverride.Prefab && SourcePrefabToOverride.GetComponent().HasGhost) + || (OverridingTargetPrefab.TryGetComponent(out NetworkObject targetNetworkObject) && targetNetworkObject.HasGhost); +#endif return true; } +#if UNIFIED_NETCODE + /// + /// Names the registered prefab, including its override target, for hybrid prefab error messages. + /// + internal string GetHybridDebugName() + { + switch (Override) + { + case NetworkPrefabOverride.Prefab: + return $"{SourcePrefabToOverride.name} (overridden by {OverridingTargetPrefab.name})"; + case NetworkPrefabOverride.Hash: + return $"{SourceHashToOverride} (overridden by {OverridingTargetPrefab.name})"; + default: + return Prefab.name; + } + } +#endif + /// /// Returns a string representation of this NetworkPrefab's source and target hash values /// diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs index 5350077be3..cffdf84b07 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs @@ -183,10 +183,17 @@ public void Initialize(bool warnInvalid = true) { m_PrefabHashIds.Clear(); m_Prefabs.Clear(); +#if UNIFIED_NETCODE + // Recomputed by the registrations below. + HasGhostPrefabs = false; +#endif NetworkPrefabsLists.RemoveAll(x => x == null); foreach (var list in NetworkPrefabsLists) { + // Initialize runs more than once per session, so unsubscribe first to keep a single subscription. + list.OnAdd -= AddTriggeredByNetworkPrefabList; list.OnAdd += AddTriggeredByNetworkPrefabList; + list.OnRemove -= RemoveTriggeredByNetworkPrefabList; list.OnRemove += RemoveTriggeredByNetworkPrefabList; } @@ -357,11 +364,15 @@ public bool Contains(NetworkPrefab prefab) #if UNIFIED_NETCODE internal const string DistributedAuthorityHybridPrefabError = "Distributed authority does not support hybrid prefabs."; + internal const string HybridPrefabOverrideError = "NetworkPrefab overrides are not supported for hybrid prefabs yet."; internal bool HasGhostPrefabs { get; private set; } // Set by NetworkManager for the duration of a distributed authority session. internal bool RejectGhostPrefabs; + + // Set by NetworkManager for the duration of any session. + internal bool RejectGhostOverrides; #endif @@ -389,7 +400,13 @@ private bool AddPrefabRegistration(NetworkPrefab networkPrefab) // Registering a hybrid prefab mid-session would switch NetworkManager into hybrid mode and stop its send queue. if (RejectGhostPrefabs) { - Debug.LogError($"{DistributedAuthorityHybridPrefabError} {networkPrefab.Prefab.name} was not added."); + Debug.LogError($"{DistributedAuthorityHybridPrefabError} {networkPrefab.GetHybridDebugName()} was not added."); + return false; + } + // N4E spawns the source prefab on every client, so the override would never be applied. + if (RejectGhostOverrides && networkPrefab.Override != NetworkPrefabOverride.None) + { + Debug.LogError($"{HybridPrefabOverrideError} {networkPrefab.GetHybridDebugName()} was not added."); return false; } HasGhostPrefabs = true; diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 71d49a82ae..804e778d6b 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1271,6 +1271,11 @@ internal void Initialize(bool server) NetworkConfig.NetworkTransport = gameObject.AddComponent(); } #endif +#if UNIFIED_NETCODE + // Both are cleared in ShutdownInternal. + NetworkConfig.Prefabs.RejectGhostPrefabs = DistributedAuthorityMode; + NetworkConfig.Prefabs.RejectGhostOverrides = true; +#endif MetricsManager.Initialize(this); @@ -1365,7 +1370,7 @@ private bool CanStart(StartType type) } #if UNIFIED_NETCODE - if (!ValidateDistributedAuthorityPrefabs()) + if (!ValidateHybridPrefabs()) { return false; } @@ -1422,37 +1427,49 @@ private bool UnifiedIsConfiguredCorrectly() } /// - /// Distributed authority does not support hybrid prefabs.
- /// When the topology is distributed authority:
- /// - If any registered prefab has a GhostObject: logs an error naming each one and returns false.
- /// - Otherwise: hybrid prefabs added during the session are rejected.
+ /// Validates the registered hybrid prefabs before a start.
+ /// Logs an error naming each registration and returns false when:
+ /// - A NetworkPrefab override has a hybrid source or target prefab (not supported yet).
+ /// - The topology is distributed authority and any hybrid prefab is registered.
+ /// rejects the same registrations when they are added during the session.
///
- private bool ValidateDistributedAuthorityPrefabs() + private bool ValidateHybridPrefabs() { - if (NetworkConfig.NetworkTopology != NetworkTopologyTypes.DistributedAuthority) + // Registers any prefab list assigned after Awake. + NetworkConfig.InitializePrefabs(); + var prefabs = NetworkConfig.Prefabs; + if (!prefabs.HasGhostPrefabs) { return true; } - // Registers any prefab list assigned after Awake. - NetworkConfig.InitializePrefabs(); - var prefabs = NetworkConfig.Prefabs; - if (prefabs.HasGhostPrefabs) + var hybridPrefabNames = new List(); + var hybridOverrideNames = new List(); + foreach (var networkPrefab in prefabs.Prefabs) { - var hybridPrefabNames = new List(); - foreach (var networkPrefab in prefabs.Prefabs) + if (!networkPrefab.HasGhost) { - if (networkPrefab.HasGhost) - { - hybridPrefabNames.Add(networkPrefab.Prefab.name); - } + continue; + } + hybridPrefabNames.Add(networkPrefab.GetHybridDebugName()); + if (networkPrefab.Override != NetworkPrefabOverride.None) + { + hybridOverrideNames.Add(networkPrefab.GetHybridDebugName()); } - Log.Error(new Context(LogLevel.Error, $"{NetworkPrefabs.DistributedAuthorityHybridPrefabError} Remove the GhostObject from these prefabs or use a prefab list without them: {string.Join(", ", hybridPrefabNames)}").AddTag("Unified")); - return false; } - prefabs.RejectGhostPrefabs = true; - return true; + var isValid = true; + if (hybridOverrideNames.Count > 0) + { + Log.Error(new Context(LogLevel.Error, $"{NetworkPrefabs.HybridPrefabOverrideError} Remove these overrides: {string.Join(", ", hybridOverrideNames)}").AddTag("Unified")); + isValid = false; + } + if (NetworkConfig.NetworkTopology == NetworkTopologyTypes.DistributedAuthority) + { + Log.Error(new Context(LogLevel.Error, $"{NetworkPrefabs.DistributedAuthorityHybridPrefabError} Remove the GhostObject from these prefabs or use a prefab list without them: {string.Join(", ", hybridPrefabNames)}").AddTag("Unified")); + isValid = false; + } + return isValid; } #endif @@ -1888,6 +1905,7 @@ internal void ShutdownInternal() if (NetworkConfig != null && NetworkConfig.Prefabs != null) { NetworkConfig.Prefabs.RejectGhostPrefabs = false; + NetworkConfig.Prefabs.RejectGhostOverrides = false; } #endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs index e6881fd410..033490056e 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs @@ -41,7 +41,7 @@ public void PongRpc() /// /// A hybrid prefab keeps its and components inert on the instance.
/// Validates that every peer assigns the same values, so Rpcs and NetworkVariable synchronization reach the right behaviour.
- /// Distributed authority rejects hybrid prefabs (see ).
+ /// Distributed authority rejects hybrid prefabs (see ).
///
[TestFixture(HostOrServer.UnifiedHost)] internal class UnifiedHybridPrefabBehaviourIdTests : NetcodeIntegrationTest @@ -69,7 +69,8 @@ protected override bool UseUnifiedTests() protected override void OnServerAndClientsCreated() { m_Prefab = CreateNetworkObjectPrefab("HybridOrdering"); - m_Prefab.AddComponent(); + // Owner authority makes an ungated NetworkRigidbody change the kinematic state on an ownership change. + m_Prefab.AddComponent().AuthorityMode = NetworkTransform.AuthorityModes.Owner; m_Prefab.AddComponent(); m_Prefab.AddComponent(); m_Prefab.AddComponent(); @@ -321,6 +322,40 @@ public IEnumerator NetworkVariableSynchronizesToALateJoiner() yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); AssertOnTimeout("The late joining client disagreed about the hybrid prefab's behaviour table!"); } + + private bool ValidateOwner(StringBuilder errorLog, ulong ownerClientId) + { + foreach (var networkManager in m_NetworkManagers) + { + var ownerOnPeer = networkManager.SpawnManager.SpawnedObjects[m_Instance.NetworkObjectId].OwnerClientId; + if (ownerOnPeer != ownerClientId) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Owner is Client-{ownerOnPeer} but Client-{ownerClientId} was expected!"); + } + } + return errorLog.Length == 0; + } + + /// + /// Validates that an ownership change leaves the body kinematic on every peer except the server. + /// + [UnityTest] + public IEnumerator KinematicStateSurvivesOwnershipChange() + { + yield return SpawnHybridInstance(); + + yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); + AssertOnTimeout("A peer disagreed about the hybrid prefab's behaviour table!"); + + var newOwnerClientId = m_ClientNetworkManagers[0].LocalClientId; + m_Instance.ChangeOwnership(newOwnerClientId); + + yield return WaitForConditionOrTimeOut(errorLog => ValidateOwner(errorLog, newOwnerClientId)); + AssertOnTimeout($"Ownership did not change to Client-{newOwnerClientId} on every peer!"); + + yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); + AssertOnTimeout("The ownership change altered the hybrid prefab's gated components!"); + } } } #endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs deleted file mode 100644 index 0b7087c3de..0000000000 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -#if UNIFIED_NETCODE -using System; -using System.Collections; -using System.Text.RegularExpressions; -using NUnit.Framework; -using Unity.Netcode.TestHelpers.Runtime; -using UnityEngine; -using UnityEngine.TestTools; -using Object = UnityEngine.Object; - -namespace Unity.Netcode.RuntimeTests -{ - /// - /// Validates that a distributed authority session rejects hybrid prefabs:
- /// - A start with a hybrid prefab registered fails before anything is initialized.
- /// - A hybrid prefab added during the session is not registered.
- ///
- /// - /// The fixture's own session only provides the hybrid prefab.
- /// Each test runs a separate distributed authority with a .
- ///
- [TestFixture(HostOrServer.UnifiedHost)] - internal class UnifiedHybridPrefabDistributedAuthorityTests : NetcodeIntegrationTest - { - protected override int NumberOfClients => 0; - - private GameObject m_HybridPrefab; - private NetworkManager m_DistributedAuthorityNetworkManager; - - public UnifiedHybridPrefabDistributedAuthorityTests(HostOrServer hostOrServer) : base(hostOrServer) - { - } - - protected override bool UseUnifiedTests() - { - return true; - } - - protected override void OnServerAndClientsCreated() - { - m_HybridPrefab = CreateNetworkObjectPrefab("DistributedAuthorityHybrid"); - base.OnServerAndClientsCreated(); - } - - protected override IEnumerator OnTearDown() - { - if (m_DistributedAuthorityNetworkManager != null) - { - if (m_DistributedAuthorityNetworkManager.IsListening) - { - m_DistributedAuthorityNetworkManager.Shutdown(); - } - Object.DestroyImmediate(m_DistributedAuthorityNetworkManager.gameObject); - m_DistributedAuthorityNetworkManager = null; - } - yield return base.OnTearDown(); - } - - private void CreateDistributedAuthorityNetworkManager() - { - var gameObject = new GameObject("DistributedAuthorityNetworkManager"); - m_DistributedAuthorityNetworkManager = gameObject.AddComponent(); - m_DistributedAuthorityNetworkManager.NetworkConfig = new NetworkConfig() - { - NetworkTransport = gameObject.AddComponent(), - NetworkTopology = NetworkTopologyTypes.DistributedAuthority, - ForceSamePrefabs = false, - }; - } - - [UnityTest] - public IEnumerator StartFailsWithHybridPrefabRegistered() - { - CreateDistributedAuthorityNetworkManager(); - m_DistributedAuthorityNetworkManager.AddNetworkPrefab(m_HybridPrefab); - var transport = m_DistributedAuthorityNetworkManager.NetworkConfig.NetworkTransport; - - var startMethods = new (string Name, Func Start)[] - { - (nameof(NetworkManager.StartServer), m_DistributedAuthorityNetworkManager.StartServer), - (nameof(NetworkManager.StartHost), m_DistributedAuthorityNetworkManager.StartHost), - (nameof(NetworkManager.StartClient), m_DistributedAuthorityNetworkManager.StartClient), - }; - - foreach (var startMethod in startMethods) - { - LogAssert.Expect(LogType.Error, new Regex($"{Regex.Escape(NetworkPrefabs.DistributedAuthorityHybridPrefabError)}.*{m_HybridPrefab.name}")); - Assert.IsFalse(startMethod.Start(), $"{startMethod.Name} started a distributed authority session with a hybrid prefab registered!"); - Assert.IsFalse(m_DistributedAuthorityNetworkManager.IsListening, $"{startMethod.Name} left the {nameof(NetworkManager)} listening!"); - Assert.AreSame(transport, m_DistributedAuthorityNetworkManager.NetworkConfig.NetworkTransport, $"{startMethod.Name} replaced the transport!"); - } - yield return null; - } - - [UnityTest] - public IEnumerator HybridPrefabAddedDuringSessionIsRejected() - { - CreateDistributedAuthorityNetworkManager(); - Assert.IsTrue(m_DistributedAuthorityNetworkManager.StartHost(), "Failed to start a distributed authority session without hybrid prefabs!"); - yield return null; - - var prefabs = m_DistributedAuthorityNetworkManager.NetworkConfig.Prefabs; - LogAssert.Expect(LogType.Error, new Regex($"{Regex.Escape(NetworkPrefabs.DistributedAuthorityHybridPrefabError)}.*{m_HybridPrefab.name}")); - m_DistributedAuthorityNetworkManager.AddNetworkPrefab(m_HybridPrefab); - Assert.IsFalse(prefabs.Contains(m_HybridPrefab), "The hybrid prefab was registered during a distributed authority session!"); - Assert.IsFalse(prefabs.HasGhostPrefabs, $"{nameof(NetworkPrefabs.HasGhostPrefabs)} was set during a distributed authority session!"); - - m_DistributedAuthorityNetworkManager.Shutdown(); - yield return WaitForConditionOrTimeOut(() => !m_DistributedAuthorityNetworkManager.IsListening); - AssertOnTimeout("The distributed authority session did not shut down!"); - - m_DistributedAuthorityNetworkManager.AddNetworkPrefab(m_HybridPrefab); - Assert.IsTrue(prefabs.Contains(m_HybridPrefab), "The hybrid prefab was rejected after the distributed authority session ended!"); - } - } -} -#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs new file mode 100644 index 0000000000..14fca7d781 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs @@ -0,0 +1,234 @@ +#if UNIFIED_NETCODE +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates the hybrid prefab registrations a rejects:
+ /// - Any hybrid prefab in a distributed authority session.
+ /// - A NetworkPrefab override with a hybrid source or target prefab, in any topology.
+ /// A start with a rejected registration fails before anything is initialized, and a rejected registration added during the session is not registered.
+ ///
+ /// + /// The fixture's own session only provides the hybrid prefab.
+ /// Each test runs a separate with a .
+ ///
+ [TestFixture(HostOrServer.UnifiedHost)] + internal class UnifiedHybridPrefabValidationTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 0; + + private GameObject m_HybridPrefab; + private NetworkManager m_StandaloneNetworkManager; + private readonly List m_CreatedObjects = new List(); + + public UnifiedHybridPrefabValidationTests(HostOrServer hostOrServer) : base(hostOrServer) + { + } + + protected override bool UseUnifiedTests() + { + return true; + } + + protected override void OnServerAndClientsCreated() + { + m_HybridPrefab = CreateNetworkObjectPrefab("ValidationHybrid"); + base.OnServerAndClientsCreated(); + } + + protected override IEnumerator OnTearDown() + { + DestroyStandaloneNetworkManager(); + foreach (var createdObject in m_CreatedObjects) + { + Object.DestroyImmediate(createdObject); + } + m_CreatedObjects.Clear(); + yield return base.OnTearDown(); + } + + private GameObject CreateNonHybridPrefab(string name) + { + var prefab = new GameObject(name); + NetcodeIntegrationTestHelpers.MakeNetworkObjectTestPrefab(prefab.AddComponent()); + m_CreatedObjects.Add(prefab); + return prefab; + } + + private void CreateStandaloneNetworkManager(NetworkTopologyTypes topology) + { + var gameObject = new GameObject($"{topology}NetworkManager"); + m_StandaloneNetworkManager = gameObject.AddComponent(); + m_StandaloneNetworkManager.NetworkConfig = new NetworkConfig() + { + NetworkTransport = gameObject.AddComponent(), + NetworkTopology = topology, + ForceSamePrefabs = false, + }; + } + + private void DestroyStandaloneNetworkManager() + { + if (m_StandaloneNetworkManager == null) + { + return; + } + if (m_StandaloneNetworkManager.IsListening) + { + m_StandaloneNetworkManager.Shutdown(); + } + Object.DestroyImmediate(m_StandaloneNetworkManager.gameObject); + m_StandaloneNetworkManager = null; + } + + private IEnumerator ShutdownStandaloneNetworkManager(string context) + { + m_StandaloneNetworkManager.Shutdown(); + yield return WaitForConditionOrTimeOut(() => !m_StandaloneNetworkManager.IsListening); + AssertOnTimeout($"{context} The session did not shut down!"); + } + + [UnityTest] + public IEnumerator StartFailsWithHybridPrefabRegistered() + { + CreateStandaloneNetworkManager(NetworkTopologyTypes.DistributedAuthority); + m_StandaloneNetworkManager.AddNetworkPrefab(m_HybridPrefab); + var transport = m_StandaloneNetworkManager.NetworkConfig.NetworkTransport; + + var startMethods = new (string Name, Func Start)[] + { + (nameof(NetworkManager.StartServer), m_StandaloneNetworkManager.StartServer), + (nameof(NetworkManager.StartHost), m_StandaloneNetworkManager.StartHost), + (nameof(NetworkManager.StartClient), m_StandaloneNetworkManager.StartClient), + }; + + foreach (var startMethod in startMethods) + { + LogAssert.Expect(LogType.Error, new Regex($"{Regex.Escape(NetworkPrefabs.DistributedAuthorityHybridPrefabError)}.*{m_HybridPrefab.name}")); + Assert.IsFalse(startMethod.Start(), $"{startMethod.Name} started a distributed authority session with a hybrid prefab registered!"); + Assert.IsFalse(m_StandaloneNetworkManager.IsListening, $"{startMethod.Name} left the {nameof(NetworkManager)} listening!"); + Assert.AreSame(transport, m_StandaloneNetworkManager.NetworkConfig.NetworkTransport, $"{startMethod.Name} replaced the transport!"); + } + yield return null; + } + + [UnityTest] + public IEnumerator HybridPrefabAddedDuringSessionIsRejected() + { + CreateStandaloneNetworkManager(NetworkTopologyTypes.DistributedAuthority); + + // A hybrid prefab removed before the start does not block it. + m_StandaloneNetworkManager.AddNetworkPrefab(m_HybridPrefab); + m_StandaloneNetworkManager.RemoveNetworkPrefab(m_HybridPrefab); + Assert.IsTrue(m_StandaloneNetworkManager.StartHost(), "Failed to start a distributed authority session without hybrid prefabs!"); + yield return null; + + var prefabs = m_StandaloneNetworkManager.NetworkConfig.Prefabs; + LogAssert.Expect(LogType.Error, new Regex($"{Regex.Escape(NetworkPrefabs.DistributedAuthorityHybridPrefabError)}.*{m_HybridPrefab.name}")); + m_StandaloneNetworkManager.AddNetworkPrefab(m_HybridPrefab); + Assert.IsFalse(prefabs.Contains(m_HybridPrefab), "The hybrid prefab was registered during a distributed authority session!"); + Assert.IsFalse(prefabs.HasGhostPrefabs, $"{nameof(NetworkPrefabs.HasGhostPrefabs)} was set during a distributed authority session!"); + + yield return ShutdownStandaloneNetworkManager("[DistributedAuthority]"); + + m_StandaloneNetworkManager.AddNetworkPrefab(m_HybridPrefab); + Assert.IsTrue(prefabs.Contains(m_HybridPrefab), "The hybrid prefab was rejected after the distributed authority session ended!"); + } + + [UnityTest] + public IEnumerator FailedStartDoesNotRejectHybridPrefabs() + { + CreateStandaloneNetworkManager(NetworkTopologyTypes.DistributedAuthority); + + // StartServer is not valid in a distributed authority session and fails after the hybrid prefab check. + LogAssert.Expect(LogType.Error, new Regex("distributed authority mode")); + Assert.IsFalse(m_StandaloneNetworkManager.StartServer(), "StartServer succeeded in a distributed authority session!"); + // SetRole keeps IsServer set when it rejects the start, which makes the NetworkManager throw when destroyed. + m_StandaloneNetworkManager.ConnectionManager.LocalClient.SetRole(false, false); + yield return null; + + m_StandaloneNetworkManager.NetworkConfig.NetworkTopology = NetworkTopologyTypes.ClientServer; + m_StandaloneNetworkManager.AddNetworkPrefab(m_HybridPrefab); + Assert.IsTrue(m_StandaloneNetworkManager.NetworkConfig.Prefabs.Contains(m_HybridPrefab), "The hybrid prefab was rejected after a failed distributed authority start!"); + } + + [UnityTest] + public IEnumerator PrefabListAddDuringSessionRegistersOnce() + { + CreateStandaloneNetworkManager(NetworkTopologyTypes.DistributedAuthority); + var prefabList = ScriptableObject.CreateInstance(); + m_CreatedObjects.Add(prefabList); + m_StandaloneNetworkManager.NetworkConfig.Prefabs.NetworkPrefabsLists.Add(prefabList); + Assert.IsTrue(m_StandaloneNetworkManager.StartHost(), "Failed to start a distributed authority session!"); + yield return null; + + var prefab = CreateNonHybridPrefab("ValidationListPrefab"); + + // A second subscription to the list would register the prefab twice and log a duplicate registration error. + prefabList.Add(new NetworkPrefab() { Prefab = prefab }); + Assert.IsTrue(m_StandaloneNetworkManager.NetworkConfig.Prefabs.Contains(prefab), "The prefab added to the list was not registered!"); + } + + /// + /// Validates that a NetworkPrefab override with a hybrid target, in either topology:
+ /// - Sets .
+ /// - Fails the start, with the distributed authority error as well when in that topology.
+ /// - Is rejected when added during the session.
+ ///
+ [UnityTest] + public IEnumerator HybridOverrideIsRejected() + { + var source = CreateNonHybridPrefab("ValidationOverrideSource"); + var sourceHash = source.GetComponent().GlobalObjectIdHash; + var overrides = new[] + { + new NetworkPrefab() { Override = NetworkPrefabOverride.Prefab, SourcePrefabToOverride = source, OverridingTargetPrefab = m_HybridPrefab }, + new NetworkPrefab() { Override = NetworkPrefabOverride.Hash, SourceHashToOverride = sourceHash, OverridingTargetPrefab = m_HybridPrefab }, + }; + var topologies = new[] { NetworkTopologyTypes.ClientServer, NetworkTopologyTypes.DistributedAuthority }; + var overrideError = new Regex($"{Regex.Escape(NetworkPrefabs.HybridPrefabOverrideError)}.*overridden by {m_HybridPrefab.name}"); + var distributedAuthorityError = new Regex($"{Regex.Escape(NetworkPrefabs.DistributedAuthorityHybridPrefabError)}.*overridden by {m_HybridPrefab.name}"); + + foreach (var topology in topologies) + { + var isDistributedAuthority = topology == NetworkTopologyTypes.DistributedAuthority; + foreach (var hybridOverride in overrides) + { + var context = $"[{topology}][{hybridOverride.Override}]"; + CreateStandaloneNetworkManager(topology); + var prefabs = m_StandaloneNetworkManager.NetworkConfig.Prefabs; + + Assert.IsTrue(prefabs.Add(hybridOverride), $"{context} The override was not registered!"); + Assert.IsTrue(prefabs.HasGhostPrefabs, $"{context} An override targeting a hybrid prefab did not set {nameof(NetworkPrefabs.HasGhostPrefabs)}!"); + + LogAssert.Expect(LogType.Error, overrideError); + if (isDistributedAuthority) + { + LogAssert.Expect(LogType.Error, distributedAuthorityError); + } + Assert.IsFalse(m_StandaloneNetworkManager.StartHost(), $"{context} A session started with a hybrid override registered!"); + + prefabs.Remove(hybridOverride); + Assert.IsTrue(m_StandaloneNetworkManager.StartHost(), $"{context} Failed to start once the override was removed!"); + yield return null; + + LogAssert.Expect(LogType.Error, isDistributedAuthority ? distributedAuthorityError : overrideError); + Assert.IsFalse(prefabs.Add(hybridOverride), $"{context} The override was registered during the session!"); + + yield return ShutdownStandaloneNetworkManager(context); + DestroyStandaloneNetworkManager(); + } + } + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs.meta similarity index 100% rename from com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabDistributedAuthorityTests.cs.meta rename to com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs.meta From 2f23dfc125d0655db45d1abe8a10c1c0a2088307 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 6 Oct 2026 14:57:40 -0500 Subject: [PATCH 4/5] fix: address review on hybrid prefab validation - Move the distributed authority hybrid prefab check and its session flag into NetworkPrefabs, called through NetworkConfig.InitializePrefabsForStart. - NetworkPrefab.Validate treats an override with a hybrid source or target as invalid, so it is logged and ignored in any topology. - Add NetworkPrefab.GetDebugName, used by the prefab logs and the debugger. - Add NetcodeIntegrationTest.CreateNetworkObjectPrefab(string, bool). - Trim the hybrid prefab tests to the behaviour id and kinematic checks, and rework the validation tests onto the harness helpers. --- .../Components/NetworkRigidBodyBase.cs | 8 +- .../Runtime/Components/NetworkTransform.cs | 6 +- .../Runtime/Configuration/NetworkConfig.cs | 11 + .../Runtime/Configuration/NetworkPrefab.cs | 33 ++- .../Runtime/Configuration/NetworkPrefabs.cs | 48 ++-- .../Runtime/Core/NetworkManager.cs | 58 +---- .../Runtime/Spawning/NetworkPrefabHandler.cs | 2 +- .../UnifiedHybridPrefabBehaviourIdTests.cs | 178 +-------------- .../UnifiedHybridPrefabValidationTests.cs | 212 ++++++------------ .../TestHelpers/NetcodeIntegrationTest.cs | 13 +- 10 files changed, 173 insertions(+), 396 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs index 068aaf2517..4e8b643db3 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs @@ -278,10 +278,9 @@ protected void Initialize(RigidbodyTypes rigidbodyType, NetworkTransform network private bool m_OriginalKinematicState; /// - /// A hybrid prefab's motion is driven by its GhostObject, so this component does not drive the .
- /// The component stays on the instance so values match on every peer.
+ /// Skips initialization on a hybrid prefab, whose GhostObject drives its motion. The component stays so values match on every peer. ///
- /// true when this is a hybrid prefab and normal initialization should be skipped + /// true for a hybrid prefab private bool InitializeHybrid() { if (NetworkObject == null || !NetworkObject.HasGhost) @@ -300,8 +299,7 @@ private bool InitializeHybrid() } /// - /// Makes a hybrid prefab's body kinematic on every peer except the server.
- /// Invoked at spawn because an in-scene placed instance has no session during Awake.
+ /// Kinematic on every peer except the server. Called at spawn because an in-scene placed instance has no session during Awake. ///
private void SetHybridKinematicState() { diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index 121cecef6e..614dc26f50 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -1909,7 +1909,7 @@ private bool ShouldSynchronizeHalfFloat(ulong targetClientId) protected override void OnSynchronize(ref BufferSerializer serializer) { #if UNIFIED_NETCODE - // Late joining clients receive no transform state for a hybrid prefab. + // No transform state is synchronized for a hybrid prefab. if (NetworkObject.HasGhost) { return; @@ -3535,7 +3535,7 @@ private void NonAuthorityFinalizeSynchronization() protected internal override void InternalOnNetworkSessionSynchronized() { #if UNIFIED_NETCODE - // Nothing was synchronized for a hybrid prefab, so there is nothing to finalize. + // Nothing was synchronized for a hybrid prefab. if (NetworkObject.HasGhost) { return; @@ -3760,7 +3760,7 @@ private void ResetInterpolatedStateToCurrentAuthoritativeState() internal virtual void InternalInitialization(bool isOwnershipChange = false) { #if UNIFIED_NETCODE - // A hybrid prefab's motion comes from its GhostObject, so this component stays inert but remains on the instance so NetworkBehaviourId values match on every peer. + // Inert on a hybrid prefab, but kept so NetworkBehaviourId values match on every peer. if (NetworkObject.HasGhost) { return; diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs index 00e7719e4a..13a5756f69 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs @@ -388,6 +388,17 @@ internal void InitializePrefabs() Prefabs.Initialize(); } +#if UNIFIED_NETCODE + /// + /// Registers any prefab list assigned after Awake, and returns false for a distributed authority session with a hybrid prefab registered. + /// + internal bool InitializePrefabsForStart() + { + InitializePrefabs(); + return NetworkTopology != NetworkTopologyTypes.DistributedAuthority || Prefabs.ValidateForDistributedAuthority(); + } +#endif + [NonSerialized] private bool m_DidWarnOldPrefabList = false; diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefab.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefab.cs index b57c70790f..68725f3bbd 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefab.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefab.cs @@ -29,6 +29,7 @@ public enum NetworkPrefabOverride /// Class that represents a NetworkPrefab /// [Serializable] + [System.Diagnostics.DebuggerDisplay("{GetDebugName()}")] public class NetworkPrefab { /// @@ -154,7 +155,8 @@ public uint TargetPrefabGlobalObjectIdHash /// True if the NetworkPrefab is valid and ready for use, false otherwise public bool Validate(int index = -1) { - NetworkObject networkObject; + // Null for a hash override. + NetworkObject networkObject = null; if (Override == NetworkPrefabOverride.None) { if (Prefab == null) @@ -271,30 +273,41 @@ public bool Validate(int index = -1) } #if UNIFIED_NETCODE - // An override is a hybrid registration when either its source or its target prefab has a GhostObject. - HasGhost = (Override == NetworkPrefabOverride.Prefab && SourcePrefabToOverride.GetComponent().HasGhost) - || (OverridingTargetPrefab.TryGetComponent(out NetworkObject targetNetworkObject) && targetNetworkObject.HasGhost); + // N4E spawns the ghost's own prefab on every client, so the override would never be applied. + if ((networkObject != null && networkObject.HasGhost) + || (OverridingTargetPrefab.TryGetComponent(out NetworkObject targetNetworkObject) && targetNetworkObject.HasGhost)) + { + NetworkLog.LogError($"{HybridPrefabOverrideError} {GetDebugName()} (entry will be ignored)."); + return false; + } #endif return true; } #if UNIFIED_NETCODE + internal const string HybridPrefabOverrideError = "NetworkPrefab overrides are not supported for hybrid prefabs yet."; +#endif + /// - /// Names the registered prefab, including its override target, for hybrid prefab error messages. + /// Names the prefab, including its override target, for logs and the debugger. /// - internal string GetHybridDebugName() + internal string GetDebugName() { switch (Override) { case NetworkPrefabOverride.Prefab: - return $"{SourcePrefabToOverride.name} (overridden by {OverridingTargetPrefab.name})"; + return $"{GetName(SourcePrefabToOverride)} (overridden by {GetName(OverridingTargetPrefab)})"; case NetworkPrefabOverride.Hash: - return $"{SourceHashToOverride} (overridden by {OverridingTargetPrefab.name})"; + return $"{SourceHashToOverride} (overridden by {GetName(OverridingTargetPrefab)})"; default: - return Prefab.name; + return GetName(Prefab); } } -#endif + + private static string GetName(GameObject prefab) + { + return prefab != null ? prefab.name : "null"; + } /// /// Returns a string representation of this NetworkPrefab's source and target hash values diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs index cffdf84b07..bfe91891b3 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs @@ -172,6 +172,9 @@ internal void Shutdown() list.OnAdd -= AddTriggeredByNetworkPrefabList; list.OnRemove -= RemoveTriggeredByNetworkPrefabList; } +#if UNIFIED_NETCODE + m_RejectGhostPrefabs = false; +#endif } /// @@ -364,15 +367,40 @@ public bool Contains(NetworkPrefab prefab) #if UNIFIED_NETCODE internal const string DistributedAuthorityHybridPrefabError = "Distributed authority does not support hybrid prefabs."; - internal const string HybridPrefabOverrideError = "NetworkPrefab overrides are not supported for hybrid prefabs yet."; internal bool HasGhostPrefabs { get; private set; } - // Set by NetworkManager for the duration of a distributed authority session. - internal bool RejectGhostPrefabs; + // Cleared in Shutdown. + private bool m_RejectGhostPrefabs; - // Set by NetworkManager for the duration of any session. - internal bool RejectGhostOverrides; + /// + /// A distributed authority session rejects hybrid prefabs added while it runs. + /// + internal void OnSessionStarting(bool distributedAuthority) + { + m_RejectGhostPrefabs = distributedAuthority; + } + + /// + /// Logs every registered hybrid prefab and returns false if there is any. + /// + internal bool ValidateForDistributedAuthority() + { + if (!HasGhostPrefabs) + { + return true; + } + var hybridPrefabNames = new List(); + foreach (var networkPrefab in m_Prefabs) + { + if (networkPrefab.HasGhost) + { + hybridPrefabNames.Add(networkPrefab.GetDebugName()); + } + } + NetworkLog.LogError($"{DistributedAuthorityHybridPrefabError} Remove the GhostObject from these prefabs or use a prefab list without them: {string.Join(", ", hybridPrefabNames)}"); + return false; + } #endif @@ -398,15 +426,9 @@ private bool AddPrefabRegistration(NetworkPrefab networkPrefab) if (networkPrefab.HasGhost) { // Registering a hybrid prefab mid-session would switch NetworkManager into hybrid mode and stop its send queue. - if (RejectGhostPrefabs) - { - Debug.LogError($"{DistributedAuthorityHybridPrefabError} {networkPrefab.GetHybridDebugName()} was not added."); - return false; - } - // N4E spawns the source prefab on every client, so the override would never be applied. - if (RejectGhostOverrides && networkPrefab.Override != NetworkPrefabOverride.None) + if (m_RejectGhostPrefabs) { - Debug.LogError($"{HybridPrefabOverrideError} {networkPrefab.GetHybridDebugName()} was not added."); + Debug.LogError($"{DistributedAuthorityHybridPrefabError} {networkPrefab.GetDebugName()} was not added."); return false; } HasGhostPrefabs = true; diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 804e778d6b..41f69dc2bf 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1272,9 +1272,7 @@ internal void Initialize(bool server) } #endif #if UNIFIED_NETCODE - // Both are cleared in ShutdownInternal. - NetworkConfig.Prefabs.RejectGhostPrefabs = DistributedAuthorityMode; - NetworkConfig.Prefabs.RejectGhostOverrides = true; + NetworkConfig.Prefabs.OnSessionStarting(DistributedAuthorityMode); #endif MetricsManager.Initialize(this); @@ -1370,7 +1368,7 @@ private bool CanStart(StartType type) } #if UNIFIED_NETCODE - if (!ValidateHybridPrefabs()) + if (!NetworkConfig.InitializePrefabsForStart()) { return false; } @@ -1425,52 +1423,6 @@ private bool UnifiedIsConfiguredCorrectly() } return true; } - - /// - /// Validates the registered hybrid prefabs before a start.
- /// Logs an error naming each registration and returns false when:
- /// - A NetworkPrefab override has a hybrid source or target prefab (not supported yet).
- /// - The topology is distributed authority and any hybrid prefab is registered.
- /// rejects the same registrations when they are added during the session.
- ///
- private bool ValidateHybridPrefabs() - { - // Registers any prefab list assigned after Awake. - NetworkConfig.InitializePrefabs(); - var prefabs = NetworkConfig.Prefabs; - if (!prefabs.HasGhostPrefabs) - { - return true; - } - - var hybridPrefabNames = new List(); - var hybridOverrideNames = new List(); - foreach (var networkPrefab in prefabs.Prefabs) - { - if (!networkPrefab.HasGhost) - { - continue; - } - hybridPrefabNames.Add(networkPrefab.GetHybridDebugName()); - if (networkPrefab.Override != NetworkPrefabOverride.None) - { - hybridOverrideNames.Add(networkPrefab.GetHybridDebugName()); - } - } - - var isValid = true; - if (hybridOverrideNames.Count > 0) - { - Log.Error(new Context(LogLevel.Error, $"{NetworkPrefabs.HybridPrefabOverrideError} Remove these overrides: {string.Join(", ", hybridOverrideNames)}").AddTag("Unified")); - isValid = false; - } - if (NetworkConfig.NetworkTopology == NetworkTopologyTypes.DistributedAuthority) - { - Log.Error(new Context(LogLevel.Error, $"{NetworkPrefabs.DistributedAuthorityHybridPrefabError} Remove the GhostObject from these prefabs or use a prefab list without them: {string.Join(", ", hybridPrefabNames)}").AddTag("Unified")); - isValid = false; - } - return isValid; - } #endif /// @@ -1901,12 +1853,6 @@ internal void ShutdownInternal() Log.Exception(ex); } } - - if (NetworkConfig != null && NetworkConfig.Prefabs != null) - { - NetworkConfig.Prefabs.RejectGhostPrefabs = false; - NetworkConfig.Prefabs.RejectGhostOverrides = false; - } #endif // Generate a local notification that the host client is disconnected diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkPrefabHandler.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkPrefabHandler.cs index daf8d153c0..6d4e1987d2 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkPrefabHandler.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkPrefabHandler.cs @@ -32,7 +32,7 @@ public class NetworkPrefabHandler /// private readonly Dictionary m_PrefabInstanceToPrefabAsset = new Dictionary(); - internal static string PrefabDebugHelper(NetworkPrefab networkPrefab) => $"{nameof(NetworkPrefab)} \"{networkPrefab.Prefab.name}\""; + internal static string PrefabDebugHelper(NetworkPrefab networkPrefab) => $"{nameof(NetworkPrefab)} \"{networkPrefab.GetDebugName()}\""; /// /// Use a to register a class that implements the interface with the diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs index 033490056e..3b8f6b82a1 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs @@ -1,7 +1,6 @@ #if UNIFIED_NETCODE && COM_UNITY_MODULES_PHYSICS using System; using System.Collections; -using System.Collections.Generic; using System.Text; using NUnit.Framework; using Unity.Netcode.Components; @@ -12,46 +11,26 @@ namespace Unity.Netcode.RuntimeTests { /// - /// Authored after the on the hybrid prefab, which makes this the component - /// that gets renumbered when anything ahead of it leaves . + /// Added to the hybrid prefab last, so its would change if the + /// or were removed from the instance. /// internal class HybridTrailingBehaviour : NetworkBehaviour { - public NetworkVariable SynchronizedValue = new NetworkVariable(); - - // Only ever written by the Rpc handlers below. An Rpc that resolved to a different component - // never reaches them, so these double as the "arrived at the right component" assertion. - public List PingSenders = new List(); - public int PongCount; - - [Rpc(SendTo.Server)] - public void PingRpc(ulong senderClientId) - { - PingSenders.Add(senderClientId); - PongRpc(); - } - - [Rpc(SendTo.Everyone)] - public void PongRpc() - { - PongCount++; - } } /// /// A hybrid prefab keeps its and components inert on the instance.
- /// Validates that every peer assigns the same values, so Rpcs and NetworkVariable synchronization reach the right behaviour.
- /// Distributed authority rejects hybrid prefabs (see ).
+ /// Validates that every peer assigns the same values and that the body stays kinematic on every peer except the server.
///
[TestFixture(HostOrServer.UnifiedHost)] internal class UnifiedHybridPrefabBehaviourIdTests : NetcodeIntegrationTest { protected override int NumberOfClients => 2; - private const int k_SynchronizedValue = 0x5AF3; - - // The authored component order, read from the prefab because the test helpers add components of their own. - // The index of each entry is the NetworkBehaviourId every peer is expected to assign. + /// + /// The types in component order, read from the prefab because the test helpers add their own.
+ /// Each index is the expected .
+ ///
private Type[] m_AuthoredBehaviourOrder; private GameObject m_Prefab; @@ -85,27 +64,9 @@ protected override void OnServerAndClientsCreated() base.OnServerAndClientsCreated(); } - /// - /// Validates that the trailing behaviour is authored after the gated components. - /// - private void AssertAuthoredOrder() - { - var transformIndex = Array.IndexOf(m_AuthoredBehaviourOrder, typeof(NetworkTransform)); - var rigidbodyIndex = Array.IndexOf(m_AuthoredBehaviourOrder, typeof(NetworkRigidbody)); - var trailingIndex = Array.IndexOf(m_AuthoredBehaviourOrder, typeof(HybridTrailingBehaviour)); - - Assert.Greater(rigidbodyIndex, transformIndex, $"The {nameof(NetworkRigidbody)} is not authored after the {nameof(NetworkTransform)}!"); - Assert.Greater(trailingIndex, rigidbodyIndex, $"The {nameof(HybridTrailingBehaviour)} is not authored after the {nameof(NetworkRigidbody)}!"); - } - - private HybridTrailingBehaviour GetTrailingBehaviour(NetworkManager networkManager) - { - return networkManager.SpawnManager.SpawnedObjects[m_Instance.NetworkObjectId].GetComponent(); - } - + [HideInCallstack] private IEnumerator SpawnHybridInstance() { - AssertAuthoredOrder(); m_Instance = SpawnObject(m_Prefab, GetAuthorityNetworkManager()).GetComponent(); yield return WaitForSpawnedOnAllOrTimeOut(m_Instance); @@ -120,12 +81,7 @@ private bool ValidateBehaviourTable(StringBuilder errorLog) { foreach (var networkManager in m_NetworkManagers) { - if (!networkManager.SpawnManager.SpawnedObjects.TryGetValue(m_Instance.NetworkObjectId, out var instance)) - { - errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has not spawned the instance!"); - continue; - } - + var instance = networkManager.SpawnManager.SpawnedObjects[m_Instance.NetworkObjectId]; var childBehaviours = instance.ChildNetworkBehaviours; if (childBehaviours.Count != m_AuthoredBehaviourOrder.Length) { @@ -198,70 +154,6 @@ private void ValidateGatedComponents(NetworkManager networkManager, NetworkObjec } } - /// - /// Every client pings the authority on the trailing behaviour and the authority answers all of them. - /// - private bool ValidateRpcRoundTrip(StringBuilder errorLog) - { - var expectedPongs = m_ClientNetworkManagers.Length; - var authorityBehaviour = GetTrailingBehaviour(GetAuthorityNetworkManager()); - if (authorityBehaviour.PingSenders.Count != m_ClientNetworkManagers.Length) - { - errorLog.AppendLine($"[Authority] Received {authorityBehaviour.PingSenders.Count} pings but expected {m_ClientNetworkManagers.Length}!"); - } - - foreach (var client in m_ClientNetworkManagers) - { - if (!authorityBehaviour.PingSenders.Contains(client.LocalClientId)) - { - errorLog.AppendLine($"[Authority] Received no ping from Client-{client.LocalClientId}!"); - } - } - - foreach (var networkManager in m_NetworkManagers) - { - var pongCount = GetTrailingBehaviour(networkManager).PongCount; - if (pongCount != expectedPongs) - { - errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Received {pongCount} pongs but expected {expectedPongs}!"); - } - } - - return errorLog.Length == 0; - } - - private IEnumerator PingFromEveryClient() - { - foreach (var client in m_ClientNetworkManagers) - { - GetTrailingBehaviour(client).PingRpc(client.LocalClientId); - } - - yield return WaitForConditionOrTimeOut(ValidateRpcRoundTrip); - AssertOnTimeout($"An Rpc on the {nameof(HybridTrailingBehaviour)} did not complete its round trip!"); - } - - private bool ValidateSynchronizedValue(StringBuilder errorLog) - { - foreach (var networkManager in m_NetworkManagers) - { - if (!networkManager.SpawnManager.SpawnedObjects.TryGetValue(m_Instance.NetworkObjectId, out var instance)) - { - errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] Has not spawned the instance!"); - continue; - } - - var value = instance.GetComponent().SynchronizedValue.Value; - if (value != k_SynchronizedValue) - { - errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] {nameof(HybridTrailingBehaviour.SynchronizedValue)} is " + - $"{value} but {k_SynchronizedValue} was expected!"); - } - } - - return errorLog.Length == 0; - } - [UnityTest] public IEnumerator BehaviourIdsMatchOnAllPeers() { @@ -269,58 +161,6 @@ public IEnumerator BehaviourIdsMatchOnAllPeers() yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); AssertOnTimeout("A peer disagreed about the hybrid prefab's behaviour table!"); - - yield return PingFromEveryClient(); - } - - /// - /// rebuilds the table from the components - /// that are on the instance at that moment, and it is reachable after spawn through the public - /// and - /// . A rebuild on one peer must therefore - /// produce the same ids that peer already handed out, or it stops agreeing with everyone else. - /// - [UnityTest] - public IEnumerator BehaviourIdsSurviveARebuild() - { - yield return SpawnHybridInstance(); - - yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); - AssertOnTimeout("A peer disagreed about the hybrid prefab's behaviour table!"); - - foreach (var networkManager in m_NetworkManagers) - { - networkManager.SpawnManager.SpawnedObjects[m_Instance.NetworkObjectId].InitializeChildNetworkBehaviours(); - } - - yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); - AssertOnTimeout("Rebuilding the behaviour table after spawn moved the behaviour ids!"); - - yield return PingFromEveryClient(); - } - - /// - /// NetworkVariable synchronization walks positionally - /// with nothing on the wire to identify a behaviour, so a late joiner reading a table the authority does - /// not share desynchronizes every value after the first disagreement. - /// - [UnityTest] - public IEnumerator NetworkVariableSynchronizesToALateJoiner() - { - yield return SpawnHybridInstance(); - - GetTrailingBehaviour(GetAuthorityNetworkManager()).SynchronizedValue.Value = k_SynchronizedValue; - - yield return WaitForConditionOrTimeOut(ValidateSynchronizedValue); - AssertOnTimeout($"A peer did not receive the {nameof(HybridTrailingBehaviour.SynchronizedValue)} update!"); - - yield return CreateAndStartNewClient(); - - yield return WaitForConditionOrTimeOut(ValidateSynchronizedValue); - AssertOnTimeout($"The late joining client did not synchronize the {nameof(HybridTrailingBehaviour.SynchronizedValue)}!"); - - yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); - AssertOnTimeout("The late joining client disagreed about the hybrid prefab's behaviour table!"); } private bool ValidateOwner(StringBuilder errorLog, ulong ownerClientId) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs index 14fca7d781..1769656bf8 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs @@ -1,34 +1,32 @@ #if UNIFIED_NETCODE using System; using System.Collections; -using System.Collections.Generic; using System.Text.RegularExpressions; using NUnit.Framework; using Unity.Netcode.TestHelpers.Runtime; using UnityEngine; using UnityEngine.TestTools; -using Object = UnityEngine.Object; namespace Unity.Netcode.RuntimeTests { /// - /// Validates the hybrid prefab registrations a rejects:
- /// - Any hybrid prefab in a distributed authority session.
- /// - A NetworkPrefab override with a hybrid source or target prefab, in any topology.
- /// A start with a rejected registration fails before anything is initialized, and a rejected registration added during the session is not registered.
+ /// Validates the hybrid prefab registrations that are rejected:
+ /// - Any hybrid prefab in a distributed authority session. The start fails, and one added during the session is not registered.
+ /// - A NetworkPrefab override with a hybrid source or target prefab, in any topology. The entry is ignored.
///
/// - /// The fixture's own session only provides the hybrid prefab.
- /// Each test runs a separate with a .
+ /// The fixture's managers are not started. Each test creates its own that registers only the prefabs it needs. ///
[TestFixture(HostOrServer.UnifiedHost)] internal class UnifiedHybridPrefabValidationTests : NetcodeIntegrationTest { protected override int NumberOfClients => 0; + protected override bool m_UseMockTransport => true; + private GameObject m_HybridPrefab; - private NetworkManager m_StandaloneNetworkManager; - private readonly List m_CreatedObjects = new List(); + private GameObject m_Prefab; + private NetworkManager m_NetworkManager; public UnifiedHybridPrefabValidationTests(HostOrServer hostOrServer) : base(hostOrServer) { @@ -39,194 +37,132 @@ protected override bool UseUnifiedTests() return true; } - protected override void OnServerAndClientsCreated() - { - m_HybridPrefab = CreateNetworkObjectPrefab("ValidationHybrid"); - base.OnServerAndClientsCreated(); - } - - protected override IEnumerator OnTearDown() + protected override bool CanStartServerAndClients() { - DestroyStandaloneNetworkManager(); - foreach (var createdObject in m_CreatedObjects) - { - Object.DestroyImmediate(createdObject); - } - m_CreatedObjects.Clear(); - yield return base.OnTearDown(); + return false; } - private GameObject CreateNonHybridPrefab(string name) + protected override void OnServerAndClientsCreated() { - var prefab = new GameObject(name); - NetcodeIntegrationTestHelpers.MakeNetworkObjectTestPrefab(prefab.AddComponent()); - m_CreatedObjects.Add(prefab); - return prefab; + m_HybridPrefab = CreateNetworkObjectPrefab("ValidationHybrid"); + m_Prefab = CreateNetworkObjectPrefab("ValidationPrefab", false); + base.OnServerAndClientsCreated(); } - private void CreateStandaloneNetworkManager(NetworkTopologyTypes topology) + protected override void OnNewClientCreated(NetworkManager networkManager) { - var gameObject = new GameObject($"{topology}NetworkManager"); - m_StandaloneNetworkManager = gameObject.AddComponent(); - m_StandaloneNetworkManager.NetworkConfig = new NetworkConfig() - { - NetworkTransport = gameObject.AddComponent(), - NetworkTopology = topology, - ForceSamePrefabs = false, - }; + // Each test registers only the prefabs it needs. } - private void DestroyStandaloneNetworkManager() + protected override IEnumerator OnTearDown() { - if (m_StandaloneNetworkManager == null) + if (m_NetworkManager != null) { - return; + yield return StopOneClient(m_NetworkManager, true); + m_NetworkManager = null; } - if (m_StandaloneNetworkManager.IsListening) - { - m_StandaloneNetworkManager.Shutdown(); - } - Object.DestroyImmediate(m_StandaloneNetworkManager.gameObject); - m_StandaloneNetworkManager = null; + yield return base.OnTearDown(); } - private IEnumerator ShutdownStandaloneNetworkManager(string context) + private NetworkManager CreateNetworkManager(NetworkTopologyTypes topology) { - m_StandaloneNetworkManager.Shutdown(); - yield return WaitForConditionOrTimeOut(() => !m_StandaloneNetworkManager.IsListening); - AssertOnTimeout($"{context} The session did not shut down!"); + m_NetworkManager = CreateNewClient(); + m_NetworkManager.NetworkConfig.NetworkTopology = topology; + // Allows prefabs to be added during the session. + m_NetworkManager.NetworkConfig.ForceSamePrefabs = false; + return m_NetworkManager; } [UnityTest] public IEnumerator StartFailsWithHybridPrefabRegistered() { - CreateStandaloneNetworkManager(NetworkTopologyTypes.DistributedAuthority); - m_StandaloneNetworkManager.AddNetworkPrefab(m_HybridPrefab); - var transport = m_StandaloneNetworkManager.NetworkConfig.NetworkTransport; + var networkManager = CreateNetworkManager(NetworkTopologyTypes.DistributedAuthority); + networkManager.AddNetworkPrefab(m_HybridPrefab); + var prefabs = networkManager.NetworkConfig.Prefabs; + var transport = networkManager.NetworkConfig.NetworkTransport; + var hybridPrefabError = new Regex($"{Regex.Escape(NetworkPrefabs.DistributedAuthorityHybridPrefabError)}.*{m_HybridPrefab.name}"); var startMethods = new (string Name, Func Start)[] { - (nameof(NetworkManager.StartServer), m_StandaloneNetworkManager.StartServer), - (nameof(NetworkManager.StartHost), m_StandaloneNetworkManager.StartHost), - (nameof(NetworkManager.StartClient), m_StandaloneNetworkManager.StartClient), + (nameof(NetworkManager.StartServer), networkManager.StartServer), + (nameof(NetworkManager.StartHost), networkManager.StartHost), + (nameof(NetworkManager.StartClient), networkManager.StartClient), }; foreach (var startMethod in startMethods) { - LogAssert.Expect(LogType.Error, new Regex($"{Regex.Escape(NetworkPrefabs.DistributedAuthorityHybridPrefabError)}.*{m_HybridPrefab.name}")); + LogAssert.Expect(LogType.Error, hybridPrefabError); Assert.IsFalse(startMethod.Start(), $"{startMethod.Name} started a distributed authority session with a hybrid prefab registered!"); - Assert.IsFalse(m_StandaloneNetworkManager.IsListening, $"{startMethod.Name} left the {nameof(NetworkManager)} listening!"); - Assert.AreSame(transport, m_StandaloneNetworkManager.NetworkConfig.NetworkTransport, $"{startMethod.Name} replaced the transport!"); + Assert.IsFalse(networkManager.IsListening, $"{startMethod.Name} left the {nameof(NetworkManager)} listening!"); + Assert.AreSame(transport, networkManager.NetworkConfig.NetworkTransport, $"{startMethod.Name} replaced the transport!"); } - yield return null; - } - [UnityTest] - public IEnumerator HybridPrefabAddedDuringSessionIsRejected() - { - CreateStandaloneNetworkManager(NetworkTopologyTypes.DistributedAuthority); + networkManager.RemoveNetworkPrefab(m_HybridPrefab); + Assert.IsTrue(networkManager.StartHost(), "Failed to start a distributed authority session once the hybrid prefab was removed!"); - // A hybrid prefab removed before the start does not block it. - m_StandaloneNetworkManager.AddNetworkPrefab(m_HybridPrefab); - m_StandaloneNetworkManager.RemoveNetworkPrefab(m_HybridPrefab); - Assert.IsTrue(m_StandaloneNetworkManager.StartHost(), "Failed to start a distributed authority session without hybrid prefabs!"); - yield return null; - - var prefabs = m_StandaloneNetworkManager.NetworkConfig.Prefabs; - LogAssert.Expect(LogType.Error, new Regex($"{Regex.Escape(NetworkPrefabs.DistributedAuthorityHybridPrefabError)}.*{m_HybridPrefab.name}")); - m_StandaloneNetworkManager.AddNetworkPrefab(m_HybridPrefab); + LogAssert.Expect(LogType.Error, hybridPrefabError); + networkManager.AddNetworkPrefab(m_HybridPrefab); Assert.IsFalse(prefabs.Contains(m_HybridPrefab), "The hybrid prefab was registered during a distributed authority session!"); Assert.IsFalse(prefabs.HasGhostPrefabs, $"{nameof(NetworkPrefabs.HasGhostPrefabs)} was set during a distributed authority session!"); - yield return ShutdownStandaloneNetworkManager("[DistributedAuthority]"); + networkManager.Shutdown(); + yield return WaitForConditionOrTimeOut(() => !networkManager.IsListening); + AssertOnTimeout("The distributed authority session did not shut down!"); - m_StandaloneNetworkManager.AddNetworkPrefab(m_HybridPrefab); + networkManager.AddNetworkPrefab(m_HybridPrefab); Assert.IsTrue(prefabs.Contains(m_HybridPrefab), "The hybrid prefab was rejected after the distributed authority session ended!"); } - [UnityTest] - public IEnumerator FailedStartDoesNotRejectHybridPrefabs() + [Test] + public void FailedStartDoesNotRejectHybridPrefabs() { - CreateStandaloneNetworkManager(NetworkTopologyTypes.DistributedAuthority); + var networkManager = CreateNetworkManager(NetworkTopologyTypes.DistributedAuthority); // StartServer is not valid in a distributed authority session and fails after the hybrid prefab check. LogAssert.Expect(LogType.Error, new Regex("distributed authority mode")); - Assert.IsFalse(m_StandaloneNetworkManager.StartServer(), "StartServer succeeded in a distributed authority session!"); + Assert.IsFalse(networkManager.StartServer(), "StartServer succeeded in a distributed authority session!"); // SetRole keeps IsServer set when it rejects the start, which makes the NetworkManager throw when destroyed. - m_StandaloneNetworkManager.ConnectionManager.LocalClient.SetRole(false, false); - yield return null; + networkManager.ConnectionManager.LocalClient.SetRole(false, false); - m_StandaloneNetworkManager.NetworkConfig.NetworkTopology = NetworkTopologyTypes.ClientServer; - m_StandaloneNetworkManager.AddNetworkPrefab(m_HybridPrefab); - Assert.IsTrue(m_StandaloneNetworkManager.NetworkConfig.Prefabs.Contains(m_HybridPrefab), "The hybrid prefab was rejected after a failed distributed authority start!"); + networkManager.NetworkConfig.NetworkTopology = NetworkTopologyTypes.ClientServer; + networkManager.AddNetworkPrefab(m_HybridPrefab); + Assert.IsTrue(networkManager.NetworkConfig.Prefabs.Contains(m_HybridPrefab), "The hybrid prefab was rejected after a failed distributed authority start!"); } - [UnityTest] - public IEnumerator PrefabListAddDuringSessionRegistersOnce() + [Test] + public void PrefabListAddDuringSessionRegistersOnce() { - CreateStandaloneNetworkManager(NetworkTopologyTypes.DistributedAuthority); + var networkManager = CreateNetworkManager(NetworkTopologyTypes.ClientServer); var prefabList = ScriptableObject.CreateInstance(); - m_CreatedObjects.Add(prefabList); - m_StandaloneNetworkManager.NetworkConfig.Prefabs.NetworkPrefabsLists.Add(prefabList); - Assert.IsTrue(m_StandaloneNetworkManager.StartHost(), "Failed to start a distributed authority session!"); - yield return null; - - var prefab = CreateNonHybridPrefab("ValidationListPrefab"); + networkManager.NetworkConfig.Prefabs.NetworkPrefabsLists.Add(prefabList); + Assert.IsTrue(networkManager.StartHost(), "Failed to start the session!"); // A second subscription to the list would register the prefab twice and log a duplicate registration error. - prefabList.Add(new NetworkPrefab() { Prefab = prefab }); - Assert.IsTrue(m_StandaloneNetworkManager.NetworkConfig.Prefabs.Contains(prefab), "The prefab added to the list was not registered!"); + prefabList.Add(new NetworkPrefab() { Prefab = m_Prefab }); + Assert.IsTrue(networkManager.NetworkConfig.Prefabs.Contains(m_Prefab), "The prefab added to the list was not registered!"); + UnityEngine.Object.Destroy(prefabList); } /// - /// Validates that a NetworkPrefab override with a hybrid target, in either topology:
- /// - Sets .
- /// - Fails the start, with the distributed authority error as well when in that topology.
- /// - Is rejected when added during the session.
+ /// Validates that an override with a hybrid source or target is ignored, with an error naming it. ///
- [UnityTest] - public IEnumerator HybridOverrideIsRejected() + [Test] + public void HybridOverrideIsRejected() { - var source = CreateNonHybridPrefab("ValidationOverrideSource"); - var sourceHash = source.GetComponent().GlobalObjectIdHash; + var prefabs = CreateNetworkManager(NetworkTopologyTypes.ClientServer).NetworkConfig.Prefabs; var overrides = new[] { - new NetworkPrefab() { Override = NetworkPrefabOverride.Prefab, SourcePrefabToOverride = source, OverridingTargetPrefab = m_HybridPrefab }, - new NetworkPrefab() { Override = NetworkPrefabOverride.Hash, SourceHashToOverride = sourceHash, OverridingTargetPrefab = m_HybridPrefab }, + new NetworkPrefab() { Override = NetworkPrefabOverride.Prefab, SourcePrefabToOverride = m_Prefab, OverridingTargetPrefab = m_HybridPrefab }, + new NetworkPrefab() { Override = NetworkPrefabOverride.Hash, SourceHashToOverride = m_Prefab.GetComponent().GlobalObjectIdHash, OverridingTargetPrefab = m_HybridPrefab }, + new NetworkPrefab() { Override = NetworkPrefabOverride.Prefab, SourcePrefabToOverride = m_HybridPrefab, OverridingTargetPrefab = m_Prefab }, }; - var topologies = new[] { NetworkTopologyTypes.ClientServer, NetworkTopologyTypes.DistributedAuthority }; - var overrideError = new Regex($"{Regex.Escape(NetworkPrefabs.HybridPrefabOverrideError)}.*overridden by {m_HybridPrefab.name}"); - var distributedAuthorityError = new Regex($"{Regex.Escape(NetworkPrefabs.DistributedAuthorityHybridPrefabError)}.*overridden by {m_HybridPrefab.name}"); - foreach (var topology in topologies) + foreach (var hybridOverride in overrides) { - var isDistributedAuthority = topology == NetworkTopologyTypes.DistributedAuthority; - foreach (var hybridOverride in overrides) - { - var context = $"[{topology}][{hybridOverride.Override}]"; - CreateStandaloneNetworkManager(topology); - var prefabs = m_StandaloneNetworkManager.NetworkConfig.Prefabs; - - Assert.IsTrue(prefabs.Add(hybridOverride), $"{context} The override was not registered!"); - Assert.IsTrue(prefabs.HasGhostPrefabs, $"{context} An override targeting a hybrid prefab did not set {nameof(NetworkPrefabs.HasGhostPrefabs)}!"); - - LogAssert.Expect(LogType.Error, overrideError); - if (isDistributedAuthority) - { - LogAssert.Expect(LogType.Error, distributedAuthorityError); - } - Assert.IsFalse(m_StandaloneNetworkManager.StartHost(), $"{context} A session started with a hybrid override registered!"); - - prefabs.Remove(hybridOverride); - Assert.IsTrue(m_StandaloneNetworkManager.StartHost(), $"{context} Failed to start once the override was removed!"); - yield return null; - - LogAssert.Expect(LogType.Error, isDistributedAuthority ? distributedAuthorityError : overrideError); - Assert.IsFalse(prefabs.Add(hybridOverride), $"{context} The override was registered during the session!"); - - yield return ShutdownStandaloneNetworkManager(context); - DestroyStandaloneNetworkManager(); - } + var debugName = hybridOverride.GetDebugName(); + LogAssert.Expect(LogType.Error, new Regex($"{Regex.Escape(NetworkPrefab.HybridPrefabOverrideError)}.*{Regex.Escape(debugName)}")); + Assert.IsFalse(prefabs.Add(hybridOverride), $"[{debugName}] The override was registered!"); + Assert.IsFalse(prefabs.HasGhostPrefabs, $"[{debugName}] {nameof(NetworkPrefabs.HasGhostPrefabs)} was set by a rejected override!"); } } } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs index 3d24452348..aab259cc27 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs @@ -2526,7 +2526,18 @@ internal void WaitForMessagesReceivedWithTimeTravel(List messagesInOrder, protected GameObject CreateNetworkObjectPrefab(string baseName) { #if UNIFIED_NETCODE - if (m_AllPrefabsAsHybrid) + return CreateNetworkObjectPrefab(baseName, m_AllPrefabsAsHybrid); + } + + /// + /// Creates a hybrid prefab only when is true, so a unified test can create a NetworkObject-only prefab. + /// + /// the basic name to be used for each instance + /// when true, the prefab also has a GhostObject + /// The assigned to the new NetworkPrefab entry + protected GameObject CreateNetworkObjectPrefab(string baseName, bool asHybrid) + { + if (asHybrid) { return CreateHybridPrefab(baseName, true); } From 11ac1eda6c24340479e989b85f220dec5eaa03b9 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 6 Oct 2026 17:34:35 -0500 Subject: [PATCH 5/5] test: remove BehaviourIdsMatchOnAllPeers KinematicStateSurvivesOwnershipChange already runs the same behaviour table and gated component checks. --- .../NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs index 3b8f6b82a1..b7971225c1 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabBehaviourIdTests.cs @@ -154,15 +154,6 @@ private void ValidateGatedComponents(NetworkManager networkManager, NetworkObjec } } - [UnityTest] - public IEnumerator BehaviourIdsMatchOnAllPeers() - { - yield return SpawnHybridInstance(); - - yield return WaitForConditionOrTimeOut(ValidateBehaviourTable); - AssertOnTimeout("A peer disagreed about the hybrid prefab's behaviour table!"); - } - private bool ValidateOwner(StringBuilder errorLog, ulong ownerClientId) { foreach (var networkManager in m_NetworkManagers)