diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 38d75261b8..328fba074c 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -23,8 +23,9 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed -- Issue where a client's clock kept alternating between running slightly fast and slightly slow on every frame, even after it had synchronized with the server. Interpolated objects juddered (a periodic motion artifact caused by mismatched frame rates): they moved in uneven steps instead of smoothly. Barely noticeable at the default `NetworkTimeSystem.AdjustmentRatio`, and clearly visible at higher values. -- Issue where the IL post-processor could not resolve core library types, such as `Guid` and `List`, in assemblies compiled against .NET reference assemblies, for example when targeting .NET 10. Those split the core library across `System.Runtime`, `System.Collections` and other assemblies. +- Issue where a rejected parent change, for example one made by a client without parenting authority, restored the `NetworkObject`'s parent but left it at the moved position when the change was made with `worldPositionStays` disabled. The original position, rotation and scale are now restored. (#4202) +- Issue where a client's clock kept alternating between running slightly fast and slightly slow on every frame, even after it had synchronized with the server. Interpolated objects juddered (a periodic motion artifact caused by mismatched frame rates): they moved in uneven steps instead of smoothly. Barely noticeable at the default `NetworkTimeSystem.AdjustmentRatio`, and clearly visible at higher values. (#4195) +- Issue where the IL post-processor could not resolve core library types, such as `Guid` and `List`, in assemblies compiled against .NET reference assemblies, for example when targeting .NET 10. Those split the core library across `System.Runtime`, `System.Collections` and other assemblies. (#4188) - 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) - Issue where moving a `NetworkObject` into another scene made the clients that did not observe it log "Trying to synchronize NetworkObjectId but it was not spawned". The scene migration is now only sent to the clients that observe the `NetworkObject`. (#4176) - Issue where a `NetworkObject` that was moved into another scene while hidden from a client spawned in that client's active scene when it was shown with `NetworkShow`, instead of the scene it is in on the server. (#4176) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs index 1e8f1f0673..601c0f1b34 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs @@ -37,6 +37,9 @@ private void OnValidate() return; } + // NGO spawns the ghost when the NetworkObject spawns. This assures the ghost spawns in that NetworkManager's world. + ghostAdapter.DelaySpawning = true; + #if COM_UNITY_MODULES_PHYSICS var rigidBody = GetComponent(); var ghostRigidBody = GetComponent(); diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs index 9ae47ce8d7..57c86e2398 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkPrefabs.cs @@ -368,6 +368,7 @@ public bool Contains(NetworkPrefab prefab) #if UNIFIED_NETCODE internal const string DistributedAuthorityHybridPrefabError = "Distributed authority does not support hybrid prefabs."; internal const string HybridPrefabAfterStartError = "Hybrid prefabs must be registered before the NetworkManager starts."; + internal const string HybridSpawnWithoutHybridPrefabsError = "Hybrid prefab instances can only be spawned in a session that started with a hybrid prefab registered."; internal bool HasGhostPrefabs { get; private set; } diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs index d3951a2732..f5f90c0a39 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs @@ -417,6 +417,79 @@ public void ApplyScale(Vector3 scale) GhostObject.ApplyPostTransformMatrixScale(scale); } } + + /// + /// Returns true when N4E replicates this instance's parent. NGO parenting is disabled for the instance in that case. + /// + internal bool GhostReplicatesParent() + { + return HasGhost && GhostObject.ParentReplication; + } + + private bool WarnIfGhostReplicatesParent(string methodName) + { + if (!GhostReplicatesParent()) + { + return false; + } + var networkManager = NetworkManager; + if (networkManager == null || networkManager.LogLevel <= LogLevel.Normal) + { + NetworkLog.LogWarning($"[{name}] {methodName} was ignored. {nameof(GhostObject)}.{nameof(GhostObject.ParentReplication)} is enabled, so " + + $"Netcode for Entities replicates the parent of this hybrid instance. Use {nameof(Transform)}.{nameof(Transform.SetParent)} instead."); + } + return true; + } + + /// + /// NGO parenting of a hybrid instance only supports worldPositionStays disabled. + /// + /// + /// The ghost replicates its local transform. With worldPositionStays enabled, the authority rebases that local transform when it reparents. + /// Clients apply the ParentSyncMessage before the snapshot with the rebased values arrives, so the object pops. + /// + /// true when the parent change is rejected + private bool RejectHybridWorldPositionStays(bool worldPositionStays) + { + if (!HasGhost || !worldPositionStays || GhostObject.ParentReplication) + { + return false; + } + var networkManager = NetworkManager; + if (networkManager == null || networkManager.LogLevel <= LogLevel.Error) + { + NetworkLog.LogError($"[{name}] Hybrid instances can only be parented with worldPositionStays disabled. Use {nameof(TrySetParent)} " + + $"or {nameof(TryRemoveParent)} with worldPositionStays set to false, or enable {nameof(GhostObject)}.{nameof(GhostObject.ParentReplication)}."); + } + return true; + } + + // Same tolerances as GhostObject. They are above the error a SetParent round trip leaves behind. + private const float k_LocalSpaceEpsilon = 1e-4f; + private const float k_LocalSpaceAngleEpsilon = 0.01f; + + /// + /// Returns true when parenting occurred with world position stays set to false. + /// + private bool LocalTransformKeptByParentChange() + { + var currentTransform = transform; + return Vector3.Distance(currentTransform.localPosition, m_LocalPositionBeforeParentChange) <= k_LocalSpaceEpsilon + && Quaternion.Angle(currentTransform.localRotation, m_LocalRotationBeforeParentChange) <= k_LocalSpaceAngleEpsilon + && Vector3.Distance(currentTransform.localScale, m_LocalScaleBeforeParentChange) <= k_LocalSpaceEpsilon; + } + + /// + /// Logs a warning when a hybrid instance is parented under a non-hybrid one or vice versa. + /// + private void LogHybridParentingArtifacts(NetworkObject parentObject) + { + if (parentObject != null && parentObject.HasGhost != HasGhost) + { + NetworkLog.LogWarning($"[{name}] Parented under {parentObject.name}, but only one of them is a hybrid instance. Their transforms are " + + $"synchronized and interpolated on different timelines, so the child can jitter relative to its parent while either one moves."); + } + } #endif /// /// This is intentionally private since this is a sealed class. @@ -2462,6 +2535,49 @@ internal void InvokeBehaviourOnNetworkObjectParentChanged(NetworkObject parentNe private Transform m_CachedParent; // What is our last set parent Transform reference? private bool m_CachedWorldPositionStays = true; // Used to preserve the world position stays parameter passed in TrySetParent + // The local transform's state before a parent change. These values are used if the parent change is reverted. + private Vector3 m_LocalPositionBeforeParentChange; + private Quaternion m_LocalRotationBeforeParentChange; + private Vector3 m_LocalScaleBeforeParentChange; + private bool m_RevertingParentChange; + + private void OnBeforeTransformParentChanged() + { + // Skips the parent change RevertParentChange makes, so the values it restores are not overwritten. + if (m_RevertingParentChange) + { + return; + } + var currentTransform = transform; + m_LocalPositionBeforeParentChange = currentTransform.localPosition; + m_LocalRotationBeforeParentChange = currentTransform.localRotation; + m_LocalScaleBeforeParentChange = currentTransform.localScale; + } + + /// + /// Restores the cached parent and the local transform prior to the parent change. + /// + /// + /// Setting keeps the world position. That only restores the original pose when the change also kept the world position. + /// Restoring the local transform under the cached parent works for either worldPositionStays value. + /// + private void RevertParentChange() + { + m_RevertingParentChange = true; + try + { + var currentTransform = transform; + currentTransform.SetParent(m_CachedParent, false); + currentTransform.localPosition = m_LocalPositionBeforeParentChange; + currentTransform.localRotation = m_LocalRotationBeforeParentChange; + currentTransform.localScale = m_LocalScaleBeforeParentChange; + } + finally + { + m_RevertingParentChange = false; + } + } + /// /// With distributed authority, we need to have a way to determine if the parenting action is authorized. /// This is set when handling an incoming ParentSyncMessage and when running as a DAHost and a client has disconnected. @@ -2517,6 +2633,12 @@ internal void ClearNetworkParenting() /// Whether or not re-parenting was successful. public bool TrySetParent(Transform parent, bool worldPositionStays = true) { +#if UNIFIED_NETCODE + if (WarnIfGhostReplicatesParent(nameof(TrySetParent))) + { + return false; + } +#endif // If we are removing ourself from a parent if (parent == null) { @@ -2537,6 +2659,12 @@ public bool TrySetParent(Transform parent, bool worldPositionStays = true) /// Whether or not re-parenting was successful. public bool TrySetParent(GameObject parent, bool worldPositionStays = true) { +#if UNIFIED_NETCODE + if (WarnIfGhostReplicatesParent(nameof(TrySetParent))) + { + return false; + } +#endif // If we are removing ourself from a parent if (parent == null) { @@ -2567,6 +2695,12 @@ internal bool TryRemoveParentCachedWorldPositionStays() /// True if the parent was successfully removed, false if the operation failed or the object was already parentless public bool TryRemoveParent(bool worldPositionStays = true) { +#if UNIFIED_NETCODE + if (WarnIfGhostReplicatesParent(nameof(TryRemoveParent))) + { + return false; + } +#endif return TrySetParent((NetworkObject)null, worldPositionStays); } @@ -2578,6 +2712,12 @@ public bool TryRemoveParent(bool worldPositionStays = true) /// Whether or not reparenting was successful. public bool TrySetParent(NetworkObject parent, bool worldPositionStays = true) { +#if UNIFIED_NETCODE + if (WarnIfGhostReplicatesParent(nameof(TrySetParent)) || RejectHybridWorldPositionStays(worldPositionStays)) + { + return false; + } +#endif if (!AutoObjectParentSync || !IsSpawned || !NetworkManagerOwner.IsListening) { return false; @@ -2619,11 +2759,19 @@ internal bool InternalTrySetParent(NetworkObject parent, bool worldPositionStays private void OnTransformParentChanged() { var networkManager = NetworkManager; - if (!AutoObjectParentSync || (networkManager != null && networkManager.ShutdownInProgress)) + if (m_RevertingParentChange || !AutoObjectParentSync || (networkManager != null && networkManager.ShutdownInProgress)) { return; } +#if UNIFIED_NETCODE + // No warning: this also runs for the parent changes N4E applies. + if (GhostReplicatesParent()) + { + return; + } +#endif + if (transform.parent == m_CachedParent) { return; @@ -2631,7 +2779,7 @@ private void OnTransformParentChanged() if (networkManager == null || !networkManager.IsListening) { - transform.parent = m_CachedParent; + RevertParentChange(); // We want to log at any LogLevel, since we may not have a network manager may here. NetworkLog.LogError($"[{name}] {nameof(networkManager)} is not listening, start a server or host before re-parenting."); return; @@ -2649,7 +2797,7 @@ private void OnTransformParentChanged() } else { - transform.parent = m_CachedParent; + RevertParentChange(); if (networkManager.LogLevel <= LogLevel.Error) { NetworkLog.LogErrorServer($"[{name}] {nameof(NetworkObject)} can only be re-parented after being spawned!"); @@ -2664,7 +2812,7 @@ private void OnTransformParentChanged() // If we are spawned and don't have authority; reset the parent back to the cached parent and exit if (!isParentingAuthority) { - transform.parent = m_CachedParent; + RevertParentChange(); if (networkManager.LogLevel <= LogLevel.Error) { if (networkManager.DistributedAuthorityMode) @@ -2679,13 +2827,29 @@ private void OnTransformParentChanged() return; } +#if UNIFIED_NETCODE + // A direct Transform parent change has no worldPositionStays value. It is derived from whether the local transform was kept. + if (HasGhost && !AuthorityAppliedParenting && !GhostObject.ParentReplication) + { + if (LocalTransformKeptByParentChange()) + { + m_CachedWorldPositionStays = false; + } + else if (RejectHybridWorldPositionStays(true)) + { + RevertParentChange(); + return; + } + } +#endif + var removeParent = false; var parentTransform = transform.parent; if (parentTransform != null) { if (!transform.parent.TryGetComponent(out NetworkObject parentObject)) { - transform.parent = m_CachedParent; + RevertParentChange(); AuthorityAppliedParenting = false; if (networkManager.LogLevel <= LogLevel.Error) { @@ -2695,7 +2859,7 @@ private void OnTransformParentChanged() } if (!parentObject.IsSpawned) { - transform.parent = m_CachedParent; + RevertParentChange(); AuthorityAppliedParenting = false; if (networkManager.LogLevel <= LogLevel.Error) { @@ -2705,6 +2869,12 @@ private void OnTransformParentChanged() } m_LatestParent = parentObject.NetworkObjectId; +#if UNIFIED_NETCODE + if (networkManager.LogLevel <= LogLevel.Developer) + { + LogHybridParentingArtifacts(parentObject); + } +#endif } else { @@ -2785,6 +2955,14 @@ internal bool ApplyNetworkParenting(bool removeParent = false, bool ignoreNotSpa return false; } +#if UNIFIED_NETCODE + // NGO does not track this parent. Applying "no parent" here would undo the parent N4E replicated. + if (GhostReplicatesParent()) + { + return false; + } +#endif + // SPECIAL CASE: // The ignoreNotSpawned is a special case scenario where a late joining client has joined // and loaded one or more scenes that contain nested in-scene placed NetworkObject children @@ -3576,7 +3754,12 @@ internal SerializedObject SerializeSpawnedObject(ulong targetClientId = NetworkM { var obj = new SerializedObject { +#if UNIFIED_NETCODE + // The ghost's spawn carries its own parent. + HasParent = transform.parent != null && !GhostReplicatesParent(), +#else HasParent = transform.parent != null, +#endif WorldPositionStays = m_CachedWorldPositionStays, NetworkObjectId = NetworkObjectId, OwnerClientId = OwnerClientId, diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index 316fde4882..0455b2e788 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -8,6 +8,10 @@ using Unity.Netcode.Logging; using UnityEngine; using Object = UnityEngine.Object; +#if UNIFIED_NETCODE +// Refer to NetworkManager.cs for why N4E's Netcode class gets its own name +using EntitiesNetcode = Unity.Netcode.Netcode; +#endif namespace Unity.Netcode { @@ -959,6 +963,36 @@ internal NetworkObject InstantiateNetworkPrefab([NotNull] GameObject networkPref return networkObject; } +#if UNIFIED_NETCODE + /// + /// Spawns a hybrid instance's ghost in this 's world. + /// + /// + /// N4E spawns a ghost into its active world, which is not always this NetworkManager's world. + /// Hybrid prefabs delay their ghost spawn (), so the world can be selected here. + /// A ghost that already spawned when it was instantiated (a prefab without delayed spawning) is left as is. + /// + private void SpawnGhost(NetworkObject networkObject) + { + var ghostObject = networkObject.GhostObject; + if (ghostObject == null || ghostObject.IsSpawned || !ghostObject.DelaySpawning) + { + return; + } + var entitiesNetcode = EntitiesNetcode.Instance; + var previousWorld = entitiesNetcode.m_ActiveWorld; + entitiesNetcode.m_ActiveWorld = NetworkManager.NetcodeWorld; + try + { + ghostObject.Spawn(); + } + finally + { + entitiesNetcode.m_ActiveWorld = previousWorld; + } + } +#endif + /// /// Creates a local NetworkObject to be spawned. /// @@ -1137,6 +1171,18 @@ internal bool AuthorityLocalSpawn([NotNull] NetworkObject networkObject, ulong n return false; } +#if UNIFIED_NETCODE + // Only registering a hybrid prefab before the session starts puts the NetworkManager in hybrid mode. + if (networkObject.HasGhost && !NetworkManager.NetworkConfig.Prefabs.HasGhostPrefabs) + { + if (NetworkManager.LogLevel <= LogLevel.Error) + { + NetworkLog.LogError($"{NetworkPrefabs.HybridSpawnWithoutHybridPrefabsError} {networkObject.name} was not spawned."); + } + return false; + } +#endif + if (playerObject && networkObject.InScenePlaced) { NetworkLog.LogError(new Context(LogLevel.Developer, "Player prefab is marked as belonging to a scene. This may cause issues.").AddNetworkObject(networkObject).AddInfo("SceneName", networkObject.SceneOrigin.name)); @@ -1164,6 +1210,13 @@ internal bool AuthorityLocalSpawn([NotNull] NetworkObject networkObject, ulong n networkObject.NetworkSceneHandle = NetworkManager.SceneManager.ClientSceneHandleToServerSceneHandle[networkObject.gameObject.scene.handle]; } +#if UNIFIED_NETCODE + // The ghost is spawned before OnNetworkSpawn, so NetworkBehaviours can access it there. + if (networkObject.HasGhost) + { + SpawnGhost(networkObject); + } +#endif if (!SpawnNetworkObjectLocallyCommon(networkObject, networkId, playerObject, ownerClientId, destroyWithScene)) { @@ -1779,6 +1832,13 @@ internal void OnDespawnObject([NotNull] NetworkObject networkObject, bool destro { continue; } +#if UNIFIED_NETCODE + // NGO does not track a parent that N4E replicates. The hierarchy determines the generation instead. + if (spawnedNetObj.GhostReplicatesParent() && spawnedNetObj.transform.parent != networkObject.transform) + { + continue; + } +#endif // For mixed authority hierarchies, if the parent is despawned then any removal of children // is considered "authority approved". Set the AuthorityAppliedParenting flag. spawnedNetObj.AuthorityAppliedParenting = distributedAuthority && !networkObject.HasAuthority; diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs new file mode 100644 index 0000000000..5e998dc0bd --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs @@ -0,0 +1,265 @@ +#if UNIFIED_NETCODE +using System.Collections; +using System.Text; +using System.Text.RegularExpressions; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates NGO parenting of hybrid prefab instances: + /// - When NGO handles the parenting, worldPositionStays is rejected and a mixed hierarchy logs a warning. + /// - When is enabled, NGO parenting is disabled and N4E replicates the parent. + /// + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] + internal class HybridParentingTests : NetcodeIntegrationTest + { + private const string k_WorldPositionStaysError = "can only be parented with worldPositionStays disabled"; + private const string k_MixedTimelinesWarning = "only one of them is a hybrid instance"; + + protected override int NumberOfClients => 2; + + private GameObject m_HybridPrefab; + private GameObject m_NonHybridPrefab; + private NetworkObject m_Parent; + private NetworkObject m_Child; + private readonly StringBuilder m_Warnings = new StringBuilder(); + + public HybridParentingTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override bool UseUnifiedTests() + { + return true; + } + + protected override void OnServerAndClientsCreated() + { + m_HybridPrefab = CreateNetworkObjectPrefab("HybridParentingObject"); + m_NonHybridPrefab = NetcodeIntegrationTestHelpers.CreateNetworkObjectPrefab("NonHybridParentingObject", GetAuthorityNetworkManager(), m_ClientNetworkManagers); + // N4E reads this when each world registers the prefab. It must be set before the session starts. + m_HybridPrefab.GetComponent().ParentReplication = TestContext.CurrentContext.Test.Name.StartsWith("ParentReplication_"); + foreach (var networkManager in m_NetworkManagers) + { + networkManager.LogLevel = LogLevel.Developer; + } + base.OnServerAndClientsCreated(); + } + + private void OnLogMessageReceived(string message, string stackTrace, LogType type) + { + if (type == LogType.Warning) + { + m_Warnings.AppendLine(message); + } + } + + private int WarningCount(string pattern) + { + var warnings = m_Warnings.ToString(); + var count = 0; + for (var index = warnings.IndexOf(pattern); index >= 0; index = warnings.IndexOf(pattern, index + pattern.Length)) + { + count++; + } + return count; + } + + private IEnumerator SpawnParentAndChild(GameObject parentPrefab) + { + var authority = GetAuthorityNetworkManager(); + m_Parent = SpawnObject(parentPrefab, authority).GetComponent(); + m_Child = SpawnObject(m_HybridPrefab, authority).GetComponent(); + yield return WaitForSpawnedOnAllOrTimeOut(m_Parent); + AssertOnTimeout($"Timed out waiting for {m_Parent.name} to spawn on all instances!"); + yield return WaitForSpawnedOnAllOrTimeOut(m_Child); + AssertOnTimeout($"Timed out waiting for {m_Child.name} to spawn on all instances!"); + } + + private bool ChildParentedOnAllInstances(StringBuilder errorLog, bool parented) + { + foreach (var networkManager in m_NetworkManagers) + { + var child = networkManager.SpawnManager.SpawnedObjects[m_Child.NetworkObjectId].transform; + var expected = parented ? networkManager.SpawnManager.SpawnedObjects[m_Parent.NetworkObjectId].transform : null; + if (child.parent != expected) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] {child.name}'s parent is {(child.parent ? child.parent.name : "root")}."); + } + } + return errorLog.Length == 0; + } + + /// + /// Validates that NGO parenting of a hybrid instance is rejected when worldPositionStays is enabled, and works when it is disabled. + /// + [UnityTest] + public IEnumerator NgoParenting_WorldPositionStays_RejectedWhenEnabled([Values] bool worldPositionStays) + { + yield return SpawnParentAndChild(m_HybridPrefab); + if (worldPositionStays) + { + LogAssert.Expect(LogType.Error, new Regex(k_WorldPositionStaysError)); + Assert.IsFalse(m_Child.TrySetParent(m_Parent, true), $"{nameof(NetworkObject.TrySetParent)} should be rejected!"); + yield return WaitForTicks(GetAuthorityNetworkManager(), 5); + var errors = new StringBuilder(); + Assert.IsTrue(ChildParentedOnAllInstances(errors, false), errors.ToString()); + yield break; + } + + Assert.IsTrue(m_Child.TrySetParent(m_Parent, false), $"{nameof(NetworkObject.TrySetParent)} failed!"); + yield return WaitForConditionOrTimeOut(errorLog => ChildParentedOnAllInstances(errorLog, true)); + AssertOnTimeout("Timed out waiting for the child to be parented on all instances!"); + + Assert.IsTrue(m_Child.TryRemoveParent(false), $"{nameof(NetworkObject.TryRemoveParent)} failed!"); + yield return WaitForConditionOrTimeOut(errorLog => ChildParentedOnAllInstances(errorLog, false)); + AssertOnTimeout("Timed out waiting for the child to be moved to the root on all instances!"); + } + + /// + /// Validates a direct parent change ( or ) of a hybrid instance on the authority: + /// - When it keeps the local transform, it is replicated. + /// - Otherwise, it is reverted to the previous parent and local transform. + /// + [UnityTest] + public IEnumerator NgoParenting_DirectTransformParentChange([Values] bool worldPositionStays) + { + yield return SpawnParentAndChild(m_HybridPrefab); + // The child is placed away from the parent, so a worldPositionStays reparent changes its local position. + m_Parent.transform.position = new Vector3(0f, 1.25f, 0f); + m_Child.transform.position = new Vector3(0f, 1.25f, 3f); + var childTransform = m_Child.transform; + var localPosition = childTransform.localPosition; + if (worldPositionStays) + { + LogAssert.Expect(LogType.Error, new Regex(k_WorldPositionStaysError)); + childTransform.parent = m_Parent.transform; + Assert.IsNull(childTransform.parent, "The parent change should have been reverted!"); + Assert.AreEqual(localPosition, childTransform.localPosition, "The revert should restore the local position!"); + yield return WaitForTicks(GetAuthorityNetworkManager(), 5); + var errors = new StringBuilder(); + Assert.IsTrue(ChildParentedOnAllInstances(errors, false), errors.ToString()); + yield break; + } + + childTransform.SetParent(m_Parent.transform, false); + Assert.IsFalse(m_Child.WorldPositionStays(), "A parent change that kept the local transform should be sent without worldPositionStays!"); + yield return WaitForConditionOrTimeOut(errorLog => ChildParentedOnAllInstances(errorLog, true)); + AssertOnTimeout("Timed out waiting for the child to be parented on all instances!"); + } + + /// + /// Validates that a hybrid child parented under a non-hybrid parent logs the mixed timelines warning. + /// + [UnityTest] + public IEnumerator NgoParenting_UnderNonHybridParent_WarnsMixedTimelines() + { + yield return SpawnParentAndChild(m_NonHybridPrefab); + m_Warnings.Clear(); + Application.logMessageReceived += OnLogMessageReceived; + try + { + Assert.IsTrue(m_Child.TrySetParent(m_Parent, false), $"{nameof(NetworkObject.TrySetParent)} failed!"); + yield return WaitForConditionOrTimeOut(errorLog => ChildParentedOnAllInstances(errorLog, true)); + AssertOnTimeout("Timed out waiting for the child to be parented on all instances!"); + } + finally + { + Application.logMessageReceived -= OnLogMessageReceived; + } + + Assert.AreEqual(1, WarningCount(k_MixedTimelinesWarning), m_Warnings.ToString()); + } + + [UnityTest] + public IEnumerator ParentReplication_NgoEntryPoints_WarnAndExit() + { + yield return SpawnParentAndChild(m_HybridPrefab); + m_Warnings.Clear(); + Application.logMessageReceived += OnLogMessageReceived; + try + { + Assert.IsFalse(m_Child.TrySetParent(m_Parent), $"{nameof(NetworkObject.TrySetParent)}({nameof(NetworkObject)}) should exit early!"); + Assert.IsFalse(m_Child.TrySetParent(m_Parent.transform), $"{nameof(NetworkObject.TrySetParent)}({nameof(Transform)}) should exit early!"); + Assert.IsFalse(m_Child.TrySetParent(m_Parent.gameObject), $"{nameof(NetworkObject.TrySetParent)}({nameof(GameObject)}) should exit early!"); + Assert.IsFalse(m_Child.TryRemoveParent(), $"{nameof(NetworkObject.TryRemoveParent)} should exit early!"); + } + finally + { + Application.logMessageReceived -= OnLogMessageReceived; + } + + Assert.IsNull(m_Child.transform.parent, "NGO parenting should not have changed the parent!"); + Assert.AreEqual(3, WarningCount($"{nameof(NetworkObject.TrySetParent)} was ignored"), m_Warnings.ToString()); + Assert.AreEqual(1, WarningCount($"{nameof(NetworkObject.TryRemoveParent)} was ignored"), m_Warnings.ToString()); + } + + /// + /// Validates that N4E replicates a made on the authority. + /// If NGO still handled the change, clients would revert it and log an error, which fails the test. + /// + [UnityTest] + public IEnumerator ParentReplication_TransformSetParent_ReplicatesWithoutNgo([Values] bool worldPositionStays) + { + yield return SpawnParentAndChild(m_HybridPrefab); + m_Warnings.Clear(); + Application.logMessageReceived += OnLogMessageReceived; + try + { + m_Child.transform.SetParent(m_Parent.transform, worldPositionStays); + yield return WaitForConditionOrTimeOut(errorLog => ChildParentedOnAllInstances(errorLog, true)); + AssertOnTimeout("Timed out waiting for N4E to replicate the parent to all instances!"); + + m_Child.transform.SetParent(null, worldPositionStays); + yield return WaitForConditionOrTimeOut(errorLog => ChildParentedOnAllInstances(errorLog, false)); + AssertOnTimeout("Timed out waiting for N4E to replicate the removed parent to all instances!"); + } + finally + { + Application.logMessageReceived -= OnLogMessageReceived; + } + } + + /// + /// Validates that NGO still moves first generation children to the root when their parent is despawned, and that N4E replicates it. + /// + [UnityTest] + [Ignore("Intermittent: a client can apply the parent's despawn before the replicated unparent, destroying the child with it.")] + public IEnumerator ParentReplication_ParentDespawned_ChildSurvivesAtRoot() + { + yield return SpawnParentAndChild(m_HybridPrefab); + + m_Child.transform.SetParent(m_Parent.transform, false); + yield return WaitForConditionOrTimeOut(errorLog => ChildParentedOnAllInstances(errorLog, true)); + AssertOnTimeout("Timed out waiting for N4E to replicate the parent to all instances!"); + + var parentId = m_Parent.NetworkObjectId; + var childId = m_Child.NetworkObjectId; + m_Parent.Despawn(); + yield return WaitForConditionOrTimeOut(errorLog => + { + foreach (var networkManager in m_NetworkManagers) + { + if (networkManager.SpawnManager.SpawnedObjects.ContainsKey(parentId)) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] The parent is still spawned."); + } + if (!networkManager.SpawnManager.SpawnedObjects.TryGetValue(childId, out var child) || child == null) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] The child was destroyed with its parent."); + } + else if (child.transform.parent != null) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] The child is still parented under {child.transform.parent.name}."); + } + } + return errorLog.Length == 0; + }); + AssertOnTimeout("Timed out waiting for the child to survive at the root on all instances!"); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs.meta new file mode 100644 index 0000000000..c15cf93df0 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f85bc7e8d6c84705b2ed7c88548b003a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs new file mode 100644 index 0000000000..f3640b40c9 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs @@ -0,0 +1,134 @@ +#if UNIFIED_NETCODE +using System.Collections; +using System.Runtime.CompilerServices; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; +using EntitiesNetcode = Unity.Netcode.Netcode; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates that a hybrid instance's ghost spawns in the spawning 's world, + /// even when another world is N4E's active world. + /// + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] + internal class HybridSpawnWorldSelectionTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 1; + + private GameObject m_HybridPrefab; + + public HybridSpawnWorldSelectionTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override bool UseUnifiedTests() + { + return true; + } + + /// + /// Disables the harness check for each client's player when the client connects. + /// + /// + /// A hybrid player spawns on a client only once its ghost arrives. waits for it instead. + /// + /// false + protected override bool ShouldCheckForSpawnedPlayers() + { + return false; + } + + protected override void OnServerAndClientsCreated() + { + m_HybridPrefab = CreateNetworkObjectPrefab("SpawnWorldPrefab"); + + // The default player prefab is not hybrid. Late joining clients also use m_PlayerPrefab. + Object.DestroyImmediate(m_PlayerPrefab); + m_PlayerPrefab = CreateNetworkObjectPrefab("HybridPlayer"); + foreach (var networkManager in m_NetworkManagers) + { + networkManager.NetworkConfig.PlayerPrefab = m_PlayerPrefab; + } + base.OnServerAndClientsCreated(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void MakeWorldActive(NetworkManager networkManager) + { + EntitiesNetcode.Instance.m_ActiveWorld = networkManager.NetcodeWorld; + } + + private IEnumerator AssertGhostSpawnedInServerWorld(NetworkObject serverInstance, string context) + { + Assert.IsTrue(serverInstance.HasGhost, $"[{context}] {serverInstance.name} is not a hybrid instance!"); + var ghostObject = serverInstance.GhostObject; + Assert.IsTrue(ghostObject.IsSpawned, $"[{context}] The ghost of {serverInstance.name} was not spawned!"); + Assert.AreEqual(m_ServerNetworkManager.NetcodeWorld, ghostObject.World, $"[{context}] The ghost of {serverInstance.name} was spawned in {ghostObject.World.Name} instead of the server's world!"); + yield return WaitForSpawnedOnAllOrTimeOut(serverInstance); + AssertOnTimeout($"[{context}] Timed out waiting for {serverInstance.name} to spawn on all clients!"); + } + + [UnityTest] + public IEnumerator InstantiateAndSpawnSpawnsGhostInServerWorld() + { + MakeWorldActive(m_ClientNetworkManagers[0]); + var serverInstance = m_ServerNetworkManager.SpawnManager.InstantiateAndSpawn(m_HybridPrefab.GetComponent()); + yield return AssertGhostSpawnedInServerWorld(serverInstance, nameof(NetworkSpawnManager.InstantiateAndSpawn)); + } + + [UnityTest] + public IEnumerator InstantiateThenSpawnSpawnsGhostInServerWorld() + { + MakeWorldActive(m_ClientNetworkManagers[0]); + var instance = Object.Instantiate(m_HybridPrefab).GetComponent(); + Assert.IsFalse(instance.GhostObject.IsSpawned, "The ghost was spawned when it was instantiated, before its NetworkObject was spawned!"); + instance.NetworkManagerOwner = m_ServerNetworkManager; + instance.Spawn(); + yield return AssertGhostSpawnedInServerWorld(instance, nameof(NetworkObject.Spawn)); + } + + /// + /// Makes a client's world active right before the server instantiates a player. + /// + /// + /// N4E makes the server world active again after each client world update. The server approves a connection outside + /// of any world update. Making the client world active from the test body would therefore not reach the player spawn. + /// + private class ClientWorldActivePlayerHandler : INetworkPrefabInstanceHandler + { + private readonly HybridSpawnWorldSelectionTests m_Test; + + public ClientWorldActivePlayerHandler(HybridSpawnWorldSelectionTests test) + { + m_Test = test; + } + + public NetworkObject Instantiate(ulong ownerClientId, Vector3 position, Quaternion rotation) + { + MakeWorldActive(m_Test.m_ClientNetworkManagers[0]); + return Object.Instantiate(m_Test.m_PlayerPrefab, position, rotation).GetComponent(); + } + + public void Destroy(NetworkObject networkObject) + { + Object.Destroy(networkObject.gameObject); + } + } + + [UnityTest] + public IEnumerator LateJoinPlayerSpawnsGhostInServerWorld() + { + m_ServerNetworkManager.PrefabHandler.AddHandler(m_PlayerPrefab, new ClientWorldActivePlayerHandler(this)); + yield return CreateAndStartNewClient(); + var newClient = m_ClientNetworkManagers[m_ClientNetworkManagers.Length - 1]; + var playerObject = m_ServerNetworkManager.ConnectionManager.ConnectedClients[newClient.LocalClientId].PlayerObject; + Assert.IsNotNull(playerObject, $"The server did not spawn a player for client {newClient.LocalClientId}!"); + yield return AssertGhostSpawnedInServerWorld(playerObject, "Player"); + yield return WaitForConditionOrTimeOut(() => newClient.LocalClient.PlayerObject != null); + AssertOnTimeout($"Client {newClient.LocalClientId} never had its player assigned!"); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs.meta new file mode 100644 index 0000000000..a0e74c5877 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b68772224ff14c559ee8e24430419b95 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs index a298640bc1..4916207fa9 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs @@ -165,7 +165,8 @@ private void ParentObjects() var destroyObjectId = m_DestroyObjectIds[i]; var dontDestroyObject = networkManager.SpawnManager.SpawnedObjects[dontDestroyObjectId]; var destroyObject = networkManager.SpawnManager.SpawnedObjects[destroyObjectId]; - Assert.IsTrue(dontDestroyObject.TrySetParent(destroyObject), $"[Client-{networkManager.LocalClientId}][Parent Failure] Could not parent {destroyObject.name} under {dontDestroyObject.name}!"); + // NGO parenting of a hybrid instance requires worldPositionStays disabled. The pose does not matter for this test. + Assert.IsTrue(dontDestroyObject.TrySetParent(destroyObject, false), $"[Client-{networkManager.LocalClientId}][Parent Failure] Could not parent {destroyObject.name} under {dontDestroyObject.name}!"); } } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectParentRevertTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectParentRevertTests.cs new file mode 100644 index 0000000000..fa29b061e4 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectParentRevertTests.cs @@ -0,0 +1,98 @@ +using System.Collections; +using System.Text.RegularExpressions; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates that a rejected parent change restores the NetworkObject's parent and its original pose. + /// + [TestFixture(HostOrServer.Host)] + [TestFixture(HostOrServer.Server)] + [TestFixture(HostOrServer.DAHost)] + internal class NetworkObjectParentRevertTests : NetcodeIntegrationTest + { + // The parent is placed away from the child, so a parent change with worldPositionStays disabled moves the child. + private static readonly Vector3 k_ParentPosition = new Vector3(5f, 1.25f, 0f); + private static readonly Vector3 k_ChildPosition = new Vector3(0f, 1.25f, 3f); + + protected override int NumberOfClients => 1; + + private GameObject m_Prefab; + + public NetworkObjectParentRevertTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override void OnServerAndClientsCreated() + { + m_Prefab = CreateNetworkObjectPrefab("ParentRevertObject"); + base.OnServerAndClientsCreated(); + } + + private void AssertPoseUnchanged(Transform child, Vector3 worldPosition, Vector3 localPosition) + { + Assert.IsNull(child.parent, $"{child.name}'s parent change should have been reverted!"); + Assert.AreEqual(worldPosition, child.position, $"{child.name}'s world position changed!"); + Assert.AreEqual(localPosition, child.localPosition, $"{child.name}'s local position changed!"); + } + + /// + /// Validates that a parent change made by a peer without parenting authority is reverted to the original pose. + /// + [UnityTest] + public IEnumerator NonAuthorityParentChangeRestoresPose([Values] bool worldPositionStays) + { + var authority = GetAuthorityNetworkManager(); + var parent = SpawnObject(m_Prefab, authority).GetComponent(); + var child = SpawnObject(m_Prefab, authority).GetComponent(); + yield return WaitForSpawnedOnAllOrTimeOut(parent); + AssertOnTimeout($"Timed out waiting for {parent.name} to spawn on all instances!"); + yield return WaitForSpawnedOnAllOrTimeOut(child); + AssertOnTimeout($"Timed out waiting for {child.name} to spawn on all instances!"); + + var nonAuthority = GetNonAuthorityNetworkManager(); + var localParent = nonAuthority.SpawnManager.SpawnedObjects[parent.NetworkObjectId].transform; + var localChild = nonAuthority.SpawnManager.SpawnedObjects[child.NetworkObjectId].transform; + localParent.position = k_ParentPosition; + localChild.position = k_ChildPosition; + var worldPosition = localChild.position; + var localPosition = localChild.localPosition; + + LogAssert.Expect(LogType.Error, new Regex("re-parent")); + localChild.SetParent(localParent, worldPositionStays); + AssertPoseUnchanged(localChild, worldPosition, localPosition); + } + + /// + /// Validates that a parent change under a GameObject without a is reverted to the original pose. + /// + [UnityTest] + public IEnumerator NonNetworkObjectParentRestoresPose([Values] bool worldPositionStays) + { + var authority = GetAuthorityNetworkManager(); + var child = SpawnObject(m_Prefab, authority).GetComponent(); + yield return WaitForSpawnedOnAllOrTimeOut(child); + AssertOnTimeout($"Timed out waiting for {child.name} to spawn on all instances!"); + + var plainParent = new GameObject("PlainParent"); + try + { + plainParent.transform.position = k_ParentPosition; + var childTransform = child.transform; + childTransform.position = k_ChildPosition; + var worldPosition = childTransform.position; + var localPosition = childTransform.localPosition; + + LogAssert.Expect(LogType.Error, new Regex("Invalid parenting")); + childTransform.SetParent(plainParent.transform, worldPositionStays); + AssertPoseUnchanged(childTransform, worldPosition, localPosition); + } + finally + { + Object.Destroy(plainParent); + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectParentRevertTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectParentRevertTests.cs.meta new file mode 100644 index 0000000000..48455a9c74 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectParentRevertTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1a2c352d1b4a19349abb616c964b02b8 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs index a117274b10..2cf756995d 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/UnifiedHybridPrefabValidationTests.cs @@ -14,6 +14,7 @@ namespace Unity.Netcode.RuntimeTests /// 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 hybrid prefab added during a session that started without hybrid prefabs. It is not registered.
+ /// - A hybrid prefab instance spawned during a session that started without hybrid prefabs. It is not spawned.
/// - A hybrid player prefab counts toward hybrid mode before the session starts, so distributed authority rejects it.
/// - A NetworkPrefab override with a hybrid source or target prefab, in any topology. The entry is ignored.
///
@@ -152,6 +153,29 @@ public IEnumerator HybridPrefabAddedAfterStartIsRejected() Assert.IsTrue(prefabs.Contains(m_HybridPrefab), "The hybrid prefab was rejected after the session ended!"); } + /// + /// Validates that an unregistered hybrid prefab instance, for example one spawned through a prefab handler, is not spawned in a session that started without hybrid prefabs. + /// + [UnityTest] + public IEnumerator HybridSpawnWithoutHybridPrefabsIsRejected() + { + var networkManager = CreateNetworkManager(NetworkTopologyTypes.ClientServer); + Assert.IsTrue(networkManager.StartHost(), "Failed to start the session!"); + + var instance = UnityEngine.Object.Instantiate(m_HybridPrefab).GetComponent(); + instance.NetworkManagerOwner = networkManager; + LogAssert.Expect(LogType.Error, new Regex($"{Regex.Escape(NetworkPrefabs.HybridSpawnWithoutHybridPrefabsError)}.*{Regex.Escape(instance.name)}")); + instance.Spawn(); + var isSpawned = instance.IsSpawned; + var isInSpawnedObjects = networkManager.SpawnManager.SpawnedObjectsList.Contains(instance); + + // Destroyed prior to asserting, so a failed assert leaves no instance behind and an error logged by its destruction fails this test. + UnityEngine.Object.Destroy(instance.gameObject); + yield return null; + Assert.IsFalse(isSpawned, "The hybrid instance was spawned in a session that started without hybrid prefabs!"); + Assert.IsFalse(isInSpawnedObjects, "The hybrid instance was added to the spawned objects!"); + } + /// /// A hybrid that is in no prefab list still decides hybrid mode before the session starts. /// diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformAutoParenting.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformAutoParenting.cs index c87d88c9bd..15e93d2e57 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformAutoParenting.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformAutoParenting.cs @@ -227,7 +227,13 @@ public IEnumerator SwitchTransformSpaceWhenParented() foreach (var prefabToSpawn in m_PrefabsToSpawn) { - yield return SpawnAndTest(prefabToSpawn, true); +#if UNIFIED_NETCODE + // NGO parenting of a hybrid instance requires worldPositionStays disabled. + if (!m_AllPrefabsAsHybrid) +#endif + { + yield return SpawnAndTest(prefabToSpawn, true); + } yield return SpawnAndTest(prefabToSpawn, false); } } @@ -259,7 +265,11 @@ private IEnumerator SpawnAndTest(NetworkObject prefabToSpawn, bool worldPosition { if (m_ChildInstance.transform.parent) { +#if UNIFIED_NETCODE + m_ChildInstance.TryRemoveParent(!m_AllPrefabsAsHybrid); +#else m_ChildInstance.TryRemoveParent(); +#endif } for (int j = 0; j < k_ParentsToSpawn; j++) { diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformOwnershipTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformOwnershipTests.cs index e003f438c1..d42610d09b 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformOwnershipTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformOwnershipTests.cs @@ -1,4 +1,4 @@ -#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D +#if COM_UNITY_MODULES_PHYSICS using System.Collections; using System.Collections.Generic; using System.Linq; diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Physics/RigidbodyContactEventManagerTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Physics/RigidbodyContactEventManagerTests.cs index 95ee2a28f1..f6574b93ed 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Physics/RigidbodyContactEventManagerTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Physics/RigidbodyContactEventManagerTests.cs @@ -1,4 +1,4 @@ -#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D +#if COM_UNITY_MODULES_PHYSICS using System.Collections; using System.Collections.Generic; using System.Text; diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs index 5da91ddde0..7170782a10 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs @@ -6,9 +6,6 @@ using System.Runtime.CompilerServices; using System.Text; using NUnit.Framework; -#if UNIFIED_NETCODE -using EntitiesNetcode = Unity.Netcode.Netcode; -#endif using Unity.Netcode.GameObjects.Timing; using Unity.Netcode.RuntimeTests; using Unity.Netcode.Transports.UTP; @@ -2655,6 +2652,8 @@ internal GameObject CreateHybridPrefab(string baseName, bool moveToDDOL = true, adapter.DefaultGhostMode = ghostMode; adapter.HasOwner = ghostMode == GhostMode.OwnerPredicted; } + // Same as NetworkObjectBridge.OnValidate, which does not run for a prefab created at runtime. + adapter.DelaySpawning = true; // Once done with setting up the GhostObject, we can set it back to active in the hierarchy gameObject.SetActive(true); @@ -2749,14 +2748,6 @@ internal void SpawnInstanceWithOwnership(NetworkObject networkObjectToSpawn, Net } else { -#if UNIFIED_NETCODE - // TODO-FixMe: the Netcode instance is a singleton and might cause issues - // assigning this. - if (networkObjectToSpawn.HasGhost) - { - EntitiesNetcode.Instance.m_ActiveWorld = m_ServerNetworkManager.NetcodeWorld; - } -#endif networkObjectToSpawn.NetworkManagerOwner = m_ServerNetworkManager; // Required to assure the server does the spawning if (spawnAuthority == m_ServerNetworkManager) { @@ -2814,20 +2805,6 @@ protected void SpawnObjectInstance(NetworkObject networkObjectToSpawn, NetworkMa private GameObject SpawnObject(NetworkObject prefabNetworkObject, NetworkManager owner, bool destroyWithScene = false, bool isPlayerObject = false) { Assert.IsTrue(prefabNetworkObject.GlobalObjectIdHash > 0, $"{nameof(GameObject)} {prefabNetworkObject.name} has a {nameof(NetworkObject.GlobalObjectIdHash)} value of 0! Make sure to make it a valid prefab before trying to spawn!"); -#if UNIFIED_NETCODE - // This has to happen *before* Instantiate, not after. The hybrid prefab is active, so the clone's - // GhostObject.Awake runs synchronously inside Object.Instantiate below. The clone is not a prefab - // (its prefabReference.Prefab points at the prefab, not at itself), so Awake acquires an entity - // reference, which resolves the world to spawn into from the EntitiesNetcode.Instance.m_ActiveWorld singleton. - // N4E's rate managers reassign that singleton on every world update, so by the time a test body runs - // it points at whichever world updated last - typically a client world - and the spawn is rejected with - // "You can only spawn a ghost on a server or during prediction on a client." - // TODO-UNIFIED: the Netcode instance is a singleton and might cause issues assigning this. - if (prefabNetworkObject.HasGhost) - { - EntitiesNetcode.Instance.m_ActiveWorld = m_ServerNetworkManager.NetcodeWorld; - } -#endif var newInstance = Object.Instantiate(prefabNetworkObject.gameObject); var networkObjectToSpawn = newInstance.GetComponent(); SpawnObjectInstance(networkObjectToSpawn, owner, destroyWithScene, isPlayerObject); diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unity.Netcode.Runtime.Tests.asmdef b/com.unity.netcode.gameobjects/Tests/Runtime/Unity.Netcode.Runtime.Tests.asmdef index cb3dec690f..5c13cad0b7 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/Unity.Netcode.Runtime.Tests.asmdef +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unity.Netcode.Runtime.Tests.asmdef @@ -50,6 +50,11 @@ "expression": "", "define": "COM_UNITY_MODULES_PHYSICS" }, + { + "name": "com.unity.modules.physics2d", + "expression": "", + "define": "COM_UNITY_MODULES_PHYSICS2D" + }, { "name": "Unity", "expression": "6000.1.0a1", diff --git a/testproject/Assets/Tests/Runtime/NetworkObjectSpawning.cs b/testproject/Assets/Tests/Runtime/NetworkObjectSpawning.cs index da3a82edba..eb21158014 100644 --- a/testproject/Assets/Tests/Runtime/NetworkObjectSpawning.cs +++ b/testproject/Assets/Tests/Runtime/NetworkObjectSpawning.cs @@ -10,6 +10,9 @@ namespace TestProject.RuntimeTests { [TestFixture(NetworkTopologyTypes.ClientServer, HostOrServer.Host)] +#if UNIFIED_NETCODE + [TestFixture(NetworkTopologyTypes.ClientServer, HostOrServer.UnifiedHost)] +#endif [TestFixture(NetworkTopologyTypes.DistributedAuthority, HostOrServer.DAHost)] internal class NetworkObjectSpawning : NetcodeIntegrationTest { @@ -29,6 +32,12 @@ protected override bool UseCMBService() public NetworkObjectSpawning(NetworkTopologyTypes networkTopology, HostOrServer hostOrServer) : base(networkTopology, hostOrServer) { } +#if UNIFIED_NETCODE + protected override bool UseUnifiedTests() + { + return true; + } +#endif protected override IEnumerator OnSetup() { @@ -96,6 +105,12 @@ public enum SynchronizeNotificationTypes [UnityTest] public IEnumerator SpawnOnSynchronizedNotification([Values] SynchronizeNotificationTypes synchronizeNotificationTypes) { +#if UNIFIED_NETCODE + if (m_AllPrefabsAsHybrid) + { + Assert.Ignore("In-scene placed NetworkObjects are not supported with hybrid prefabs."); + } +#endif m_SceneLoadCompleted = false; m_CanStartServerAndClients = true; NetworkObjectSpawnerForTests.SpawnAfterInSceneSynchronized = synchronizeNotificationTypes == SynchronizeNotificationTypes.InSceneObjects || synchronizeNotificationTypes == SynchronizeNotificationTypes.Both;