From 46a3e777ebbefa33fd3a3a94d82ba48e10b7c498 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 5 Oct 2026 13:54:51 -0500 Subject: [PATCH 01/13] fix: spawn a hybrid instance's ghost in its NetworkManager's world N4E spawns a ghost into its active world, which is whichever world updated last when several worlds share a process. Hybrid player prefabs, InstantiateAndSpawn and Instantiate followed by Spawn could therefore spawn the ghost into a client world and fail with "Invalid world ... for spawn". Hybrid prefabs now delay their ghost spawn (GhostObject.DelaySpawning, available in N4E 7.1.0), and the spawn authority spawns the ghost in its own NetworkManager's world before OnNetworkSpawn. The integration test harness no longer sets N4E's active world itself. Adds HybridSpawnWorldSelectionTests, which make a client world active before each spawn. --- .../Components/Helpers/NetworkObjectBridge.cs | 3 + .../Runtime/Spawning/NetworkSpawnManager.cs | 41 +++++++ .../HybridSpawnWorldSelectionTests.cs | 102 ++++++++++++++++++ .../HybridSpawnWorldSelectionTests.cs.meta | 2 + .../TestHelpers/NetcodeIntegrationTest.cs | 27 +---- 5 files changed, 150 insertions(+), 25 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs index 1e8f1f0673..61f7edb4a3 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, so it is spawned 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/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index 316fde4882..4f8f9525ca 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 whichever world updated last when several worlds share a process. + /// 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 it 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. /// @@ -1164,6 +1198,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 that NetworkBehaviours can access it there + if (networkObject.HasGhost) + { + SpawnGhost(networkObject); + } +#endif if (!SpawnNetworkObjectLocallyCommon(networkObject, networkId, playerObject, ownerClientId, destroyWithScene)) { 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..e8ac3c05d7 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs @@ -0,0 +1,102 @@ +#if UNIFIED_NETCODE +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; +using EntitiesNetcode = Unity.Netcode.Netcode; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// N4E spawns a ghost into its active world, which is whichever world updated last when several worlds share a process. + /// Validates that a hybrid instance's ghost is spawned in the spawning 's world anyway. + /// + [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; + } + + // The harness checks for a client's player as soon as it connects, and a hybrid player spawns on the client only once its ghost arrives. + // LateJoinPlayerSpawnsGhostInServerWorld waits for it instead. + 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(); + } + + /// + /// Makes a client's world N4E's active world, as it is when that client's world updated last. + /// + private void MakeClientWorldActive() + { + EntitiesNetcode.Instance.m_ActiveWorld = m_ClientNetworkManagers[0].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() + { + MakeClientWorldActive(); + var serverInstance = m_ServerNetworkManager.SpawnManager.InstantiateAndSpawn(m_HybridPrefab.GetComponent()); + yield return AssertGhostSpawnedInServerWorld(serverInstance, nameof(NetworkSpawnManager.InstantiateAndSpawn)); + } + + [UnityTest] + public IEnumerator InstantiateThenSpawnSpawnsGhostInServerWorld() + { + MakeClientWorldActive(); + 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)); + } + + [UnityTest] + public IEnumerator LateJoinPlayerSpawnsGhostInServerWorld() + { + MakeClientWorldActive(); + 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/TestHelpers/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs index 5da91ddde0..7f0270dba9 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); From caad1401076554db6aa94b956d7dfec0b11a1597 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 19 Oct 2026 08:34:23 -0500 Subject: [PATCH 02/13] test: force a client world active for the late join player spawn, re-enable hybrid NetworkObjectSpawning N4E makes the server world active again after each client world update, so making a client world active from the test body no longer reached the player spawn, and LateJoinPlayerSpawnsGhostInServerWorld passed without the fix. A server-side prefab handler now makes the client world active right before the player is instantiated. Re-adds the UnifiedHost fixture to the testproject NetworkObjectSpawning tests, now that InstantiateAndSpawn selects the NetworkManager's world for hybrid prefabs. SpawnOnSynchronizedNotification is skipped under hybrid (in-scene placed NetworkObjects). --- .../Runtime/Spawning/NetworkSpawnManager.cs | 2 +- .../HybridSpawnWorldSelectionTests.cs | 34 +++++++++++++++++-- .../Tests/Runtime/NetworkObjectSpawning.cs | 15 ++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index 4f8f9525ca..833266a3d0 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -968,7 +968,7 @@ internal NetworkObject InstantiateNetworkPrefab([NotNull] GameObject networkPref /// Spawns a hybrid instance's ghost in this 's world. /// /// - /// N4E spawns a ghost into its active world, which is whichever world updated last when several worlds share a process. + /// N4E spawns a ghost into its active world, which is the world currently updating, or the server world between world updates. /// 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 it is. /// diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs index e8ac3c05d7..19d1a58001 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs @@ -9,7 +9,7 @@ namespace Unity.Netcode.RuntimeTests { /// - /// N4E spawns a ghost into its active world, which is whichever world updated last when several worlds share a process. + /// N4E spawns a ghost into its active world, which is the world currently updating, or the server world between world updates. /// Validates that a hybrid instance's ghost is spawned in the spawning 's world anyway. /// [TestFixture(HostOrServer.UnifiedHost)] @@ -49,7 +49,7 @@ protected override void OnServerAndClientsCreated() } /// - /// Makes a client's world N4E's active world, as it is when that client's world updated last. + /// Makes a client's world N4E's active world, as it is while that client's world updates. /// private void MakeClientWorldActive() { @@ -85,10 +85,38 @@ public IEnumerator InstantiateThenSpawnSpawnsGhostInServerWorld() 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, and the server approves a + /// connection outside of any world update, so making the client world active from the test body has no effect. + /// + 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) + { + m_Test.MakeClientWorldActive(); + return Object.Instantiate(m_Test.m_PlayerPrefab, position, rotation).GetComponent(); + } + + public void Destroy(NetworkObject networkObject) + { + Object.Destroy(networkObject.gameObject); + } + } + [UnityTest] public IEnumerator LateJoinPlayerSpawnsGhostInServerWorld() { - MakeClientWorldActive(); + 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; 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; From 23957729895bb7644629ba702b8e6821228c2561 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 1 Oct 2026 18:04:38 -0500 Subject: [PATCH 03/13] feat: hybrid prefab parent replication compatibility When a hybrid prefab's GhostObject has ParentReplication enabled, Netcode for Entities owns parenting and NGO stands down: - TrySetParent and TryRemoveParent log a warning and return false. - OnTransformParentChanged and ApplyNetworkParenting exit silently. - The spawn message no longer carries the parent; the ghost's spawn does. - Despawning a parent finds first generation children by hierarchy. ParentReplication only exists in com.unity.netcode 7.0.1+, so this is gated on the new UNIFIED_NETCODE_PARENT_REPLICATION version define. When NGO handles parenting of a hybrid instance, the authority logs at Developer level when the result can show visual artifacts: re-parenting with worldPositionStays enabled, or parenting where only one of child and parent is a hybrid instance. --- .../Runtime/Core/NetworkObject.cs | 107 ++++++++ .../Runtime/Spawning/NetworkSpawnManager.cs | 7 + .../Runtime/Unity.Netcode.Runtime.asmdef | 5 + .../NetworkObject/HybridParentingTests.cs | 242 ++++++++++++++++++ .../HybridParentingTests.cs.meta | 11 + .../Unity.Netcode.Runtime.Tests.asmdef | 5 + 6 files changed, 377 insertions(+) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs index d3951a2732..fa2dcb7229 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs @@ -417,6 +417,56 @@ public void ApplyScale(Vector3 scale) GhostObject.ApplyPostTransformMatrixScale(scale); } } + +#if UNIFIED_NETCODE_PARENT_REPLICATION + /// + /// When true, Netcode for Entities replicates this instance's parent and NGO parenting stands down. + /// + 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; + } +#endif + + /// + /// NGO parenting of a hybrid instance, or under one, can conflict with the ghost's own transform replication. + /// + private void LogHybridParentingArtifacts(NetworkObject parentObject) + { + // The ghost replicates its local transform. With worldPositionStays the authority rebases it at the reparent, + // but clients apply ParentSyncMessage ahead of the interpolated snapshot that carries the rebased values. + if (HasGhost && m_CachedWorldPositionStays) + { + NetworkLog.LogWarning($"[{name}] Hybrid instance re-parented with worldPositionStays enabled. Clients apply the new parent before the " + + $"ghost's rebased transform is interpolated, so the object can pop for up to the interpolation delay. Parent with worldPositionStays " + +#if UNIFIED_NETCODE_PARENT_REPLICATION + $"disabled, or enable {nameof(GhostObject)}.{nameof(GhostObject.ParentReplication)}."); +#else + "disabled to avoid this."); +#endif + } + + 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. @@ -2517,6 +2567,12 @@ internal void ClearNetworkParenting() /// Whether or not re-parenting was successful. public bool TrySetParent(Transform parent, bool worldPositionStays = true) { +#if UNIFIED_NETCODE_PARENT_REPLICATION + if (WarnIfGhostReplicatesParent(nameof(TrySetParent))) + { + return false; + } +#endif // If we are removing ourself from a parent if (parent == null) { @@ -2537,6 +2593,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_PARENT_REPLICATION + if (WarnIfGhostReplicatesParent(nameof(TrySetParent))) + { + return false; + } +#endif // If we are removing ourself from a parent if (parent == null) { @@ -2567,6 +2629,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_PARENT_REPLICATION + if (WarnIfGhostReplicatesParent(nameof(TryRemoveParent))) + { + return false; + } +#endif return TrySetParent((NetworkObject)null, worldPositionStays); } @@ -2578,6 +2646,12 @@ public bool TryRemoveParent(bool worldPositionStays = true) /// Whether or not reparenting was successful. public bool TrySetParent(NetworkObject parent, bool worldPositionStays = true) { +#if UNIFIED_NETCODE_PARENT_REPLICATION + if (WarnIfGhostReplicatesParent(nameof(TrySetParent))) + { + return false; + } +#endif if (!AutoObjectParentSync || !IsSpawned || !NetworkManagerOwner.IsListening) { return false; @@ -2624,6 +2698,14 @@ private void OnTransformParentChanged() return; } +#if UNIFIED_NETCODE_PARENT_REPLICATION + // Silent: this fires for the parent changes Netcode for Entities applies as well as the user's own. + if (GhostReplicatesParent()) + { + return; + } +#endif + if (transform.parent == m_CachedParent) { return; @@ -2705,11 +2787,23 @@ private void OnTransformParentChanged() } m_LatestParent = parentObject.NetworkObjectId; +#if UNIFIED_NETCODE + if (networkManager.LogLevel <= LogLevel.Developer) + { + LogHybridParentingArtifacts(parentObject); + } +#endif } else { m_LatestParent = null; removeParent = m_CachedParent != null; +#if UNIFIED_NETCODE + if (networkManager.LogLevel <= LogLevel.Developer) + { + LogHybridParentingArtifacts(null); + } +#endif } // This can be reset within ApplyNetworkParenting @@ -2785,6 +2879,14 @@ internal bool ApplyNetworkParenting(bool removeParent = false, bool ignoreNotSpa return false; } +#if UNIFIED_NETCODE_PARENT_REPLICATION + // NGO never tracked this parent, so applying "no parent" here would undo the one Netcode for Entities 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 +3678,12 @@ internal SerializedObject SerializeSpawnedObject(ulong targetClientId = NetworkM { var obj = new SerializedObject { +#if UNIFIED_NETCODE_PARENT_REPLICATION + // 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 833266a3d0..0fcc0683d6 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -1820,6 +1820,13 @@ internal void OnDespawnObject([NotNull] NetworkObject networkObject, bool destro { continue; } +#if UNIFIED_NETCODE_PARENT_REPLICATION + // NGO does not track a parent that Netcode for Entities replicates, so the hierarchy decides the generation. + 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/Runtime/Unity.Netcode.Runtime.asmdef b/com.unity.netcode.gameobjects/Runtime/Unity.Netcode.Runtime.asmdef index e31ea05704..408c20ad7b 100644 --- a/com.unity.netcode.gameobjects/Runtime/Unity.Netcode.Runtime.asmdef +++ b/com.unity.netcode.gameobjects/Runtime/Unity.Netcode.Runtime.asmdef @@ -31,6 +31,11 @@ "expression": "7.2.0", "define": "UNIFIED_NETCODE" }, + { + "name": "com.unity.netcode", + "expression": "7.0.1", + "define": "UNIFIED_NETCODE_PARENT_REPLICATION" + }, { "name": "com.unity.multiplayer.tools", "expression": "", 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..863ec509a0 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs @@ -0,0 +1,242 @@ +#if UNIFIED_NETCODE +using System.Collections; +using System.Text; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// NGO parenting of hybrid prefab instances: the developer warnings when NGO handles it, and NGO standing down when + /// parent replication handles it. + /// + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] + internal class HybridParentingTests : NetcodeIntegrationTest + { + private const string k_WorldPositionStaysWarning = "re-parented with worldPositionStays enabled"; + 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); +#if UNIFIED_NETCODE_PARENT_REPLICATION + // N4E reads this when each world registers the prefab, so it is set before the session starts. + m_HybridPrefab.GetComponent().ParentReplication = TestContext.CurrentContext.Test.Name.StartsWith("ParentReplication_"); +#endif + 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; + } + + /// + /// With worldPositionStays enabled, clients apply the new parent ahead of the ghost's rebased local transform. + /// Disabled, the replicated local transform does not change at the reparent, so nothing is logged. + /// + [UnityTest] + public IEnumerator NgoParenting_WorldPositionStays_WarnsOnlyWhenEnabled([Values] bool worldPositionStays) + { + yield return SpawnParentAndChild(m_HybridPrefab); + m_Warnings.Clear(); + Application.logMessageReceived += OnLogMessageReceived; + try + { + Assert.IsTrue(m_Child.TrySetParent(m_Parent, worldPositionStays), $"{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(worldPositionStays), $"{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!"); + } + finally + { + Application.logMessageReceived -= OnLogMessageReceived; + } + + // Once for the parent and once for its removal, on the authority only. + Assert.AreEqual(worldPositionStays ? 2 : 0, WarningCount(k_WorldPositionStaysWarning), m_Warnings.ToString()); + Assert.AreEqual(0, WarningCount(k_MixedTimelinesWarning), m_Warnings.ToString()); + } + + /// + /// A hybrid child under a non-hybrid parent mixes N4E snapshot and NGO transform timelines. + /// + [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()); + Assert.AreEqual(0, WarningCount(k_WorldPositionStaysWarning), m_Warnings.ToString()); + } + +#if UNIFIED_NETCODE_PARENT_REPLICATION + [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()); + } + + /// + /// N4E replicates a made on the authority. Were NGO still handling + /// 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; + } + + Assert.AreEqual(0, WarningCount(k_WorldPositionStaysWarning), m_Warnings.ToString()); + } + + /// + /// NGO still moves first generation children to the root when their parent is despawned, and N4E replicates that. + /// + [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 + } +} +#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/Unity.Netcode.Runtime.Tests.asmdef b/com.unity.netcode.gameobjects/Tests/Runtime/Unity.Netcode.Runtime.Tests.asmdef index cb3dec690f..44fc22e7dc 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 @@ -35,6 +35,11 @@ "expression": "7.2.0", "define": "UNIFIED_NETCODE" }, + { + "name": "com.unity.netcode", + "expression": "7.0.1", + "define": "UNIFIED_NETCODE_PARENT_REPLICATION" + }, { "name": "com.unity.multiplayer.tools", "expression": "", From c7d0b997a6b086b02487f25f1f213f58b423c88d Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 14 Oct 2026 11:32:59 -0500 Subject: [PATCH 04/13] test: hybrid worldPositionStays reparent under host smoothing keeps the child in place Ignored until N4E syncs a reparent made without parent replication to the entity. --- .../NetworkObject/HybridParentingTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs index 863ec509a0..0b29ecb4ec 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs @@ -18,6 +18,10 @@ internal class HybridParentingTests : NetcodeIntegrationTest { private const string k_WorldPositionStaysWarning = "re-parented with worldPositionStays enabled"; private const string k_MixedTimelinesWarning = "only one of them is a hybrid instance"; + private const float k_PositionTolerance = 0.01f; + // Off the origin, so that the child's local position changes when it is reparented with worldPositionStays. + private static readonly Vector3 k_ParentPosition = new Vector3(0f, 1.25f, 0f); + private static readonly Vector3 k_ChildPosition = new Vector3(0f, 1.25f, 3f); protected override int NumberOfClients => 2; @@ -42,6 +46,11 @@ protected override void OnServerAndClientsCreated() // N4E reads this when each world registers the prefab, so it is set before the session starts. m_HybridPrefab.GetComponent().ParentReplication = TestContext.CurrentContext.Test.Name.StartsWith("ParentReplication_"); #endif + if (TestContext.CurrentContext.Test.Name.StartsWith("NgoParenting_HostSmoothing_")) + { + // What NetworkObjectBridge sets on a hybrid prefab with a Rigidbody. + m_HybridPrefab.GetComponent().SingleWorldHostInterpolationSmoothing = SingleWorldHostInterpolationMode.Interpolate; + } foreach (var networkManager in m_NetworkManagers) { networkManager.LogLevel = LogLevel.Developer; @@ -147,6 +156,48 @@ public IEnumerator NgoParenting_UnderNonHybridParent_WarnsMixedTimelines() Assert.AreEqual(0, WarningCount(k_WorldPositionStaysWarning), m_Warnings.ToString()); } + private bool ChildAtWorldPositionOnAllInstances(StringBuilder errorLog, Vector3 expected) + { + foreach (var networkManager in m_NetworkManagers) + { + var position = networkManager.SpawnManager.SpawnedObjects[m_Child.NetworkObjectId].transform.position; + if (Vector3.Distance(expected, position) > k_PositionTolerance) + { + errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] {m_Child.name} is at {position}, expected {expected}."); + } + } + return errorLog.Length == 0; + } + + /// + /// Under host smoothing N4E restores the entity's local transform onto the GameObject every frame, so a + /// worldPositionStays reparent made through NGO must still reach the entity, or the child moves by the parent's offset. + /// + [UnityTest] + [Ignore("Needs the N4E fix that syncs a reparent made without parent replication to the entity: the child settles at the parent's offset.")] + public IEnumerator NgoParenting_HostSmoothing_WorldPositionStays_KeepsWorldPosition() + { + yield return SpawnParentAndChild(m_HybridPrefab); + // Placed through the entity, since host smoothing reverts a direct transform write. + var parentGhost = m_Parent.GetComponent(); + parentGhost.Position = k_ParentPosition; + parentGhost.PublishTransform(); + var childGhost = m_Child.GetComponent(); + childGhost.Position = k_ChildPosition; + childGhost.PublishTransform(); + yield return WaitForConditionOrTimeOut(errorLog => ChildAtWorldPositionOnAllInstances(errorLog, k_ChildPosition)); + AssertOnTimeout("Timed out waiting for the child to reach its start position on all instances!"); + + Assert.IsTrue(m_Child.TrySetParent(m_Parent, true), $"{nameof(NetworkObject.TrySetParent)} failed!"); + yield return WaitForConditionOrTimeOut(errorLog => ChildParentedOnAllInstances(errorLog, true)); + AssertOnTimeout("Timed out waiting for the child to be parented on all instances!"); + // The displacement appears on the first restore after the reparent and then holds, so a settled check catches it. + yield return WaitForTicks(GetAuthorityNetworkManager(), 10); + + var errors = new StringBuilder(); + Assert.IsTrue(ChildAtWorldPositionOnAllInstances(errors, k_ChildPosition), errors.ToString()); + } + #if UNIFIED_NETCODE_PARENT_REPLICATION [UnityTest] public IEnumerator ParentReplication_NgoEntryPoints_WarnAndExit() From 7416428565d9426092eb4d3cd11d7f20f489406b Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Sun, 6 Sep 2026 19:24:04 -0500 Subject: [PATCH 05/13] test: compile the Rigidbody tests that never compiled Unity.Netcode.Runtime.Tests declared a versionDefine for com.unity.modules.physics but not for com.unity.modules.physics2d, while Unity.Netcode.Runtime declares both. Tests/Runtime/Physics/ NetworkRigidbodyTest.cs opens with #if COM_UNITY_MODULES_PHYSICS && COM_UNITY_MODULES_PHYSICS2D so COM_UNITY_MODULES_PHYSICS2D was never defined for the assembly the file belongs to and the whole fixture compiled to nothing - three TestFixture variants over five rigidbody interpolation configurations, the only NetworkRigidbody integration coverage there is. It reported no results rather than failing, so the suite has been green on tests that do not exist. Confirmed absent from the built Unity.Netcode.Runtime.Tests.dll, and present on develop-2.0.0 as well. The two sibling files guarded on the same pair use || rather than &&, so they were unaffected. PHYSICS2D_LINEAR is deliberately not added - no test references it. With the define in place the fixture compiles and passes 3/3 on 6000.7.0a5. --- .../Tests/Runtime/Unity.Netcode.Runtime.Tests.asmdef | 5 +++++ 1 file changed, 5 insertions(+) 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 44fc22e7dc..c6ed56f285 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 @@ -55,6 +55,11 @@ "expression": "", "define": "COM_UNITY_MODULES_PHYSICS" }, + { + "name": "com.unity.modules.physics2d", + "expression": "", + "define": "COM_UNITY_MODULES_PHYSICS2D" + }, { "name": "Unity", "expression": "6000.1.0a1", From 7a0702749f457bfdfa53adffb323ed52ec83da29 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 19 Oct 2026 09:10:23 -0500 Subject: [PATCH 06/13] chore: gate hybrid parent replication on UNIFIED_NETCODE UNIFIED_NETCODE already requires N4E 7.2.0, and GhostObject.ParentReplication ships from 7.1.0, so the separate N4E >= 7.0.1 define is no longer needed. --- .../Runtime/Core/NetworkObject.cs | 20 +++++++------------ .../Runtime/Spawning/NetworkSpawnManager.cs | 2 +- .../Runtime/Unity.Netcode.Runtime.asmdef | 5 ----- .../NetworkObject/HybridParentingTests.cs | 4 ---- .../Unity.Netcode.Runtime.Tests.asmdef | 5 ----- 5 files changed, 8 insertions(+), 28 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs index fa2dcb7229..7c6aaf3f96 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs @@ -418,7 +418,6 @@ public void ApplyScale(Vector3 scale) } } -#if UNIFIED_NETCODE_PARENT_REPLICATION /// /// When true, Netcode for Entities replicates this instance's parent and NGO parenting stands down. /// @@ -441,7 +440,6 @@ private bool WarnIfGhostReplicatesParent(string methodName) } return true; } -#endif /// /// NGO parenting of a hybrid instance, or under one, can conflict with the ghost's own transform replication. @@ -454,11 +452,7 @@ private void LogHybridParentingArtifacts(NetworkObject parentObject) { NetworkLog.LogWarning($"[{name}] Hybrid instance re-parented with worldPositionStays enabled. Clients apply the new parent before the " + $"ghost's rebased transform is interpolated, so the object can pop for up to the interpolation delay. Parent with worldPositionStays " + -#if UNIFIED_NETCODE_PARENT_REPLICATION $"disabled, or enable {nameof(GhostObject)}.{nameof(GhostObject.ParentReplication)}."); -#else - "disabled to avoid this."); -#endif } if (parentObject != null && parentObject.HasGhost != HasGhost) @@ -2567,7 +2561,7 @@ internal void ClearNetworkParenting() /// Whether or not re-parenting was successful. public bool TrySetParent(Transform parent, bool worldPositionStays = true) { -#if UNIFIED_NETCODE_PARENT_REPLICATION +#if UNIFIED_NETCODE if (WarnIfGhostReplicatesParent(nameof(TrySetParent))) { return false; @@ -2593,7 +2587,7 @@ 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_PARENT_REPLICATION +#if UNIFIED_NETCODE if (WarnIfGhostReplicatesParent(nameof(TrySetParent))) { return false; @@ -2629,7 +2623,7 @@ 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_PARENT_REPLICATION +#if UNIFIED_NETCODE if (WarnIfGhostReplicatesParent(nameof(TryRemoveParent))) { return false; @@ -2646,7 +2640,7 @@ public bool TryRemoveParent(bool worldPositionStays = true) /// Whether or not reparenting was successful. public bool TrySetParent(NetworkObject parent, bool worldPositionStays = true) { -#if UNIFIED_NETCODE_PARENT_REPLICATION +#if UNIFIED_NETCODE if (WarnIfGhostReplicatesParent(nameof(TrySetParent))) { return false; @@ -2698,7 +2692,7 @@ private void OnTransformParentChanged() return; } -#if UNIFIED_NETCODE_PARENT_REPLICATION +#if UNIFIED_NETCODE // Silent: this fires for the parent changes Netcode for Entities applies as well as the user's own. if (GhostReplicatesParent()) { @@ -2879,7 +2873,7 @@ internal bool ApplyNetworkParenting(bool removeParent = false, bool ignoreNotSpa return false; } -#if UNIFIED_NETCODE_PARENT_REPLICATION +#if UNIFIED_NETCODE // NGO never tracked this parent, so applying "no parent" here would undo the one Netcode for Entities replicated. if (GhostReplicatesParent()) { @@ -3678,7 +3672,7 @@ internal SerializedObject SerializeSpawnedObject(ulong targetClientId = NetworkM { var obj = new SerializedObject { -#if UNIFIED_NETCODE_PARENT_REPLICATION +#if UNIFIED_NETCODE // The ghost's spawn carries its own parent. HasParent = transform.parent != null && !GhostReplicatesParent(), #else diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index 0fcc0683d6..2440d56b38 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -1820,7 +1820,7 @@ internal void OnDespawnObject([NotNull] NetworkObject networkObject, bool destro { continue; } -#if UNIFIED_NETCODE_PARENT_REPLICATION +#if UNIFIED_NETCODE // NGO does not track a parent that Netcode for Entities replicates, so the hierarchy decides the generation. if (spawnedNetObj.GhostReplicatesParent() && spawnedNetObj.transform.parent != networkObject.transform) { diff --git a/com.unity.netcode.gameobjects/Runtime/Unity.Netcode.Runtime.asmdef b/com.unity.netcode.gameobjects/Runtime/Unity.Netcode.Runtime.asmdef index 408c20ad7b..e31ea05704 100644 --- a/com.unity.netcode.gameobjects/Runtime/Unity.Netcode.Runtime.asmdef +++ b/com.unity.netcode.gameobjects/Runtime/Unity.Netcode.Runtime.asmdef @@ -31,11 +31,6 @@ "expression": "7.2.0", "define": "UNIFIED_NETCODE" }, - { - "name": "com.unity.netcode", - "expression": "7.0.1", - "define": "UNIFIED_NETCODE_PARENT_REPLICATION" - }, { "name": "com.unity.multiplayer.tools", "expression": "", diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs index 0b29ecb4ec..2202ad8b3e 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs @@ -42,10 +42,8 @@ protected override void OnServerAndClientsCreated() { m_HybridPrefab = CreateNetworkObjectPrefab("HybridParentingObject"); m_NonHybridPrefab = NetcodeIntegrationTestHelpers.CreateNetworkObjectPrefab("NonHybridParentingObject", GetAuthorityNetworkManager(), m_ClientNetworkManagers); -#if UNIFIED_NETCODE_PARENT_REPLICATION // N4E reads this when each world registers the prefab, so it is set before the session starts. m_HybridPrefab.GetComponent().ParentReplication = TestContext.CurrentContext.Test.Name.StartsWith("ParentReplication_"); -#endif if (TestContext.CurrentContext.Test.Name.StartsWith("NgoParenting_HostSmoothing_")) { // What NetworkObjectBridge sets on a hybrid prefab with a Rigidbody. @@ -198,7 +196,6 @@ public IEnumerator NgoParenting_HostSmoothing_WorldPositionStays_KeepsWorldPosit Assert.IsTrue(ChildAtWorldPositionOnAllInstances(errors, k_ChildPosition), errors.ToString()); } -#if UNIFIED_NETCODE_PARENT_REPLICATION [UnityTest] public IEnumerator ParentReplication_NgoEntryPoints_WarnAndExit() { @@ -287,7 +284,6 @@ public IEnumerator ParentReplication_ParentDespawned_ChildSurvivesAtRoot() }); AssertOnTimeout("Timed out waiting for the child to survive at the root on all instances!"); } -#endif } } #endif 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 c6ed56f285..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 @@ -35,11 +35,6 @@ "expression": "7.2.0", "define": "UNIFIED_NETCODE" }, - { - "name": "com.unity.netcode", - "expression": "7.0.1", - "define": "UNIFIED_NETCODE_PARENT_REPLICATION" - }, { "name": "com.unity.multiplayer.tools", "expression": "", From ef1297fe2fffe87f4f7bd9089706dad4e503f369 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 19 Oct 2026 20:12:13 -0500 Subject: [PATCH 07/13] fix: reject worldPositionStays for NGO parenting of hybrid instances A hybrid instance's ghost replicates its local transform. With worldPositionStays the authority rebases that local transform at the reparent, but clients apply ParentSyncMessage ahead of the interpolated snapshot carrying the rebased values, so the object pops by the full reparent offset. With host smoothing it also stayed displaced. When GhostObject.ParentReplication is off, TrySetParent and TryRemoveParent with worldPositionStays enabled now log an error and return false. A direct Transform parent change, which carries no worldPositionStays value, is accepted when it kept the local transform and otherwise reverted to the previous parent and local transform, captured in OnBeforeTransformParentChanged. Hybrid fixtures that parented with worldPositionStays enabled now disable it. --- .../Runtime/Core/NetworkObject.cs | 110 +++++++++++++--- .../NetworkObject/HybridParentingTests.cs | 124 +++++++----------- .../NetworkObjectDontDestroyWithOwnerTests.cs | 3 +- .../NetworkTransformAutoParenting.cs | 12 +- 4 files changed, 156 insertions(+), 93 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs index 7c6aaf3f96..0f71a00f1a 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs @@ -442,19 +442,85 @@ private bool WarnIfGhostReplicatesParent(string methodName) } /// - /// NGO parenting of a hybrid instance, or under one, can conflict with the ghost's own transform replication. + /// NGO parenting of a hybrid instance only supports worldPositionStays disabled. /// - private void LogHybridParentingArtifacts(NetworkObject parentObject) + /// + /// The ghost replicates its local transform. With worldPositionStays the authority rebases it at the reparent, but clients + /// apply ParentSyncMessage ahead of the interpolated snapshot that carries the rebased values, 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; + } + + // The parent and local transform before a parent change, so a direct Transform parent change can be told apart and reverted. + private Transform m_HybridParentBeforeChange; + private Vector3 m_HybridLocalPositionBeforeChange; + private Quaternion m_HybridLocalRotationBeforeChange; + private Vector3 m_HybridLocalScaleBeforeChange; + // Above the round-trip error SetParent leaves behind, same as GhostObject. + private const float k_LocalSpaceEpsilon = 1e-4f; + private const float k_LocalSpaceAngleEpsilon = 0.01f; + private bool m_RevertingHybridParent; + + private void OnBeforeTransformParentChanged() { - // The ghost replicates its local transform. With worldPositionStays the authority rebases it at the reparent, - // but clients apply ParentSyncMessage ahead of the interpolated snapshot that carries the rebased values. - if (HasGhost && m_CachedWorldPositionStays) + // The revert's own SetParent must not overwrite the values it is restoring. + if (!HasGhost || m_RevertingHybridParent) + { + return; + } + var currentTransform = transform; + m_HybridParentBeforeChange = currentTransform.parent; + m_HybridLocalPositionBeforeChange = currentTransform.localPosition; + m_HybridLocalRotationBeforeChange = currentTransform.localRotation; + m_HybridLocalScaleBeforeChange = currentTransform.localScale; + } + + /// + /// A parent change that kept the local transform was made with worldPositionStays disabled. + /// + private bool HybridLocalTransformKept() + { + var currentTransform = transform; + return Vector3.Distance(currentTransform.localPosition, m_HybridLocalPositionBeforeChange) <= k_LocalSpaceEpsilon + && Quaternion.Angle(currentTransform.localRotation, m_HybridLocalRotationBeforeChange) <= k_LocalSpaceAngleEpsilon + && Vector3.Distance(currentTransform.localScale, m_HybridLocalScaleBeforeChange) <= k_LocalSpaceEpsilon; + } + + private void RevertHybridParentChange() + { + m_RevertingHybridParent = true; + try { - NetworkLog.LogWarning($"[{name}] Hybrid instance re-parented with worldPositionStays enabled. Clients apply the new parent before the " + - $"ghost's rebased transform is interpolated, so the object can pop for up to the interpolation delay. Parent with worldPositionStays " + - $"disabled, or enable {nameof(GhostObject)}.{nameof(GhostObject.ParentReplication)}."); + var currentTransform = transform; + currentTransform.SetParent(m_HybridParentBeforeChange, false); + currentTransform.localPosition = m_HybridLocalPositionBeforeChange; + currentTransform.localRotation = m_HybridLocalRotationBeforeChange; + currentTransform.localScale = m_HybridLocalScaleBeforeChange; } + finally + { + m_RevertingHybridParent = false; + } + } + /// + /// A hybrid instance parented under a non-hybrid one, or the reverse, can jitter relative to its parent. + /// + 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 " + @@ -2641,7 +2707,7 @@ public bool TryRemoveParent(bool worldPositionStays = true) public bool TrySetParent(NetworkObject parent, bool worldPositionStays = true) { #if UNIFIED_NETCODE - if (WarnIfGhostReplicatesParent(nameof(TrySetParent))) + if (WarnIfGhostReplicatesParent(nameof(TrySetParent)) || RejectHybridWorldPositionStays(worldPositionStays)) { return false; } @@ -2693,8 +2759,8 @@ private void OnTransformParentChanged() } #if UNIFIED_NETCODE - // Silent: this fires for the parent changes Netcode for Entities applies as well as the user's own. - if (GhostReplicatesParent()) + // Silent: this fires for the parent changes Netcode for Entities applies as well as the user's own, and for a reverted one. + if (GhostReplicatesParent() || m_RevertingHybridParent) { return; } @@ -2755,6 +2821,22 @@ private void OnTransformParentChanged() return; } +#if UNIFIED_NETCODE + // A direct Transform parent change does not pass a worldPositionStays value, so it is read from whether the local transform was kept. + if (HasGhost && !AuthorityAppliedParenting && !GhostObject.ParentReplication) + { + if (HybridLocalTransformKept()) + { + m_CachedWorldPositionStays = false; + } + else if (RejectHybridWorldPositionStays(true)) + { + RevertHybridParentChange(); + return; + } + } +#endif + var removeParent = false; var parentTransform = transform.parent; if (parentTransform != null) @@ -2792,12 +2874,6 @@ private void OnTransformParentChanged() { m_LatestParent = null; removeParent = m_CachedParent != null; -#if UNIFIED_NETCODE - if (networkManager.LogLevel <= LogLevel.Developer) - { - LogHybridParentingArtifacts(null); - } -#endif } // This can be reset within ApplyNetworkParenting diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs index 2202ad8b3e..0f5117c741 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs @@ -1,6 +1,7 @@ #if UNIFIED_NETCODE using System.Collections; using System.Text; +using System.Text.RegularExpressions; using NUnit.Framework; using Unity.Netcode.TestHelpers.Runtime; using UnityEngine; @@ -9,19 +10,15 @@ namespace Unity.Netcode.RuntimeTests { /// - /// NGO parenting of hybrid prefab instances: the developer warnings when NGO handles it, and NGO standing down when + /// NGO parenting of hybrid prefab instances: the worldPositionStays rejection and the mixed timelines warning when NGO handles it, and NGO standing down when /// parent replication handles it. /// [TestFixture(HostOrServer.UnifiedHost)] [TestFixture(HostOrServer.UnifiedServer)] internal class HybridParentingTests : NetcodeIntegrationTest { - private const string k_WorldPositionStaysWarning = "re-parented with worldPositionStays enabled"; + 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"; - private const float k_PositionTolerance = 0.01f; - // Off the origin, so that the child's local position changes when it is reparented with worldPositionStays. - private static readonly Vector3 k_ParentPosition = new Vector3(0f, 1.25f, 0f); - private static readonly Vector3 k_ChildPosition = new Vector3(0f, 1.25f, 3f); protected override int NumberOfClients => 2; @@ -44,11 +41,6 @@ protected override void OnServerAndClientsCreated() m_NonHybridPrefab = NetcodeIntegrationTestHelpers.CreateNetworkObjectPrefab("NonHybridParentingObject", GetAuthorityNetworkManager(), m_ClientNetworkManagers); // N4E reads this when each world registers the prefab, so it is set before the session starts. m_HybridPrefab.GetComponent().ParentReplication = TestContext.CurrentContext.Test.Name.StartsWith("ParentReplication_"); - if (TestContext.CurrentContext.Test.Name.StartsWith("NgoParenting_HostSmoothing_")) - { - // What NetworkObjectBridge sets on a hybrid prefab with a Rigidbody. - m_HybridPrefab.GetComponent().SingleWorldHostInterpolationSmoothing = SingleWorldHostInterpolationMode.Interpolate; - } foreach (var networkManager in m_NetworkManagers) { networkManager.LogLevel = LogLevel.Developer; @@ -101,33 +93,62 @@ private bool ChildParentedOnAllInstances(StringBuilder errorLog, bool parented) } /// - /// With worldPositionStays enabled, clients apply the new parent ahead of the ghost's rebased local transform. - /// Disabled, the replicated local transform does not change at the reparent, so nothing is logged. + /// Validates that NGO parenting of a hybrid instance is rejected with worldPositionStays enabled, since clients would apply the + /// new parent before the ghost's rebased local transform and the object would pop, and that it works with worldPositionStays disabled. /// [UnityTest] - public IEnumerator NgoParenting_WorldPositionStays_WarnsOnlyWhenEnabled([Values] bool worldPositionStays) + public IEnumerator NgoParenting_WorldPositionStays_RejectedWhenEnabled([Values] bool worldPositionStays) { yield return SpawnParentAndChild(m_HybridPrefab); - m_Warnings.Clear(); - Application.logMessageReceived += OnLogMessageReceived; - try + if (worldPositionStays) { - Assert.IsTrue(m_Child.TrySetParent(m_Parent, worldPositionStays), $"{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(worldPositionStays), $"{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!"); + 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; } - finally + + 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 that a direct parent change of a hybrid instance on the authority ( or + /// ) is replicated when it keeps the local transform, and is otherwise + /// reverted to the previous parent and local transform. + /// + [UnityTest] + public IEnumerator NgoParenting_DirectTransformParentChange([Values] bool worldPositionStays) + { + yield return SpawnParentAndChild(m_HybridPrefab); + // Off the parent's position, so that a worldPositionStays reparent changes the 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) { - Application.logMessageReceived -= OnLogMessageReceived; + 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; } - // Once for the parent and once for its removal, on the authority only. - Assert.AreEqual(worldPositionStays ? 2 : 0, WarningCount(k_WorldPositionStaysWarning), m_Warnings.ToString()); - Assert.AreEqual(0, WarningCount(k_MixedTimelinesWarning), m_Warnings.ToString()); + 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!"); } /// @@ -151,49 +172,6 @@ public IEnumerator NgoParenting_UnderNonHybridParent_WarnsMixedTimelines() } Assert.AreEqual(1, WarningCount(k_MixedTimelinesWarning), m_Warnings.ToString()); - Assert.AreEqual(0, WarningCount(k_WorldPositionStaysWarning), m_Warnings.ToString()); - } - - private bool ChildAtWorldPositionOnAllInstances(StringBuilder errorLog, Vector3 expected) - { - foreach (var networkManager in m_NetworkManagers) - { - var position = networkManager.SpawnManager.SpawnedObjects[m_Child.NetworkObjectId].transform.position; - if (Vector3.Distance(expected, position) > k_PositionTolerance) - { - errorLog.AppendLine($"[Client-{networkManager.LocalClientId}] {m_Child.name} is at {position}, expected {expected}."); - } - } - return errorLog.Length == 0; - } - - /// - /// Under host smoothing N4E restores the entity's local transform onto the GameObject every frame, so a - /// worldPositionStays reparent made through NGO must still reach the entity, or the child moves by the parent's offset. - /// - [UnityTest] - [Ignore("Needs the N4E fix that syncs a reparent made without parent replication to the entity: the child settles at the parent's offset.")] - public IEnumerator NgoParenting_HostSmoothing_WorldPositionStays_KeepsWorldPosition() - { - yield return SpawnParentAndChild(m_HybridPrefab); - // Placed through the entity, since host smoothing reverts a direct transform write. - var parentGhost = m_Parent.GetComponent(); - parentGhost.Position = k_ParentPosition; - parentGhost.PublishTransform(); - var childGhost = m_Child.GetComponent(); - childGhost.Position = k_ChildPosition; - childGhost.PublishTransform(); - yield return WaitForConditionOrTimeOut(errorLog => ChildAtWorldPositionOnAllInstances(errorLog, k_ChildPosition)); - AssertOnTimeout("Timed out waiting for the child to reach its start position on all instances!"); - - Assert.IsTrue(m_Child.TrySetParent(m_Parent, true), $"{nameof(NetworkObject.TrySetParent)} failed!"); - yield return WaitForConditionOrTimeOut(errorLog => ChildParentedOnAllInstances(errorLog, true)); - AssertOnTimeout("Timed out waiting for the child to be parented on all instances!"); - // The displacement appears on the first restore after the reparent and then holds, so a settled check catches it. - yield return WaitForTicks(GetAuthorityNetworkManager(), 10); - - var errors = new StringBuilder(); - Assert.IsTrue(ChildAtWorldPositionOnAllInstances(errors, k_ChildPosition), errors.ToString()); } [UnityTest] @@ -243,8 +221,6 @@ public IEnumerator ParentReplication_TransformSetParent_ReplicatesWithoutNgo([Va { Application.logMessageReceived -= OnLogMessageReceived; } - - Assert.AreEqual(0, WarningCount(k_WorldPositionStaysWarning), m_Warnings.ToString()); } /// diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs index a298640bc1..365703ae43 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}!"); + // worldPositionStays disabled, which NGO parenting of a hybrid instance requires. The pose does not matter here. + 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/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++) { From 4e9e5f3a1b4007cd3f19964971241dd7c189bf13 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 20 Oct 2026 10:00:17 -0500 Subject: [PATCH 08/13] fix: restore the original pose when a parent change is reverted When NGO rejected a parent change (no parenting authority, an unspawned NetworkObject, a parent without a NetworkObject), it reverted by assigning transform.parent, which keeps the world position. A change made with worldPositionStays disabled had already moved the object, so the parent came back but the object stayed at the moved position. OnBeforeTransformParentChanged now captures the local position, rotation and scale before every parent change, and a revert restores the cached parent with those values. The hybrid direct parent change check uses the same capture instead of its own. Adds NetworkObjectParentRevertTests (Host, Server, DAHost). --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 + .../Runtime/Core/NetworkObject.cs | 108 +++++++++--------- .../NetworkObjectParentRevertTests.cs | 98 ++++++++++++++++ .../NetworkObjectParentRevertTests.cs.meta | 2 + 4 files changed, 158 insertions(+), 51 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectParentRevertTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectParentRevertTests.cs.meta diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 38d75261b8..918257a9dd 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -23,6 +23,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed +- 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. - 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. - 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) diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs index 0f71a00f1a..d94c78d8a1 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs @@ -464,56 +464,19 @@ private bool RejectHybridWorldPositionStays(bool worldPositionStays) return true; } - // The parent and local transform before a parent change, so a direct Transform parent change can be told apart and reverted. - private Transform m_HybridParentBeforeChange; - private Vector3 m_HybridLocalPositionBeforeChange; - private Quaternion m_HybridLocalRotationBeforeChange; - private Vector3 m_HybridLocalScaleBeforeChange; // Above the round-trip error SetParent leaves behind, same as GhostObject. private const float k_LocalSpaceEpsilon = 1e-4f; private const float k_LocalSpaceAngleEpsilon = 0.01f; - private bool m_RevertingHybridParent; - - private void OnBeforeTransformParentChanged() - { - // The revert's own SetParent must not overwrite the values it is restoring. - if (!HasGhost || m_RevertingHybridParent) - { - return; - } - var currentTransform = transform; - m_HybridParentBeforeChange = currentTransform.parent; - m_HybridLocalPositionBeforeChange = currentTransform.localPosition; - m_HybridLocalRotationBeforeChange = currentTransform.localRotation; - m_HybridLocalScaleBeforeChange = currentTransform.localScale; - } /// /// A parent change that kept the local transform was made with worldPositionStays disabled. /// - private bool HybridLocalTransformKept() + private bool LocalTransformKeptByParentChange() { var currentTransform = transform; - return Vector3.Distance(currentTransform.localPosition, m_HybridLocalPositionBeforeChange) <= k_LocalSpaceEpsilon - && Quaternion.Angle(currentTransform.localRotation, m_HybridLocalRotationBeforeChange) <= k_LocalSpaceAngleEpsilon - && Vector3.Distance(currentTransform.localScale, m_HybridLocalScaleBeforeChange) <= k_LocalSpaceEpsilon; - } - - private void RevertHybridParentChange() - { - m_RevertingHybridParent = true; - try - { - var currentTransform = transform; - currentTransform.SetParent(m_HybridParentBeforeChange, false); - currentTransform.localPosition = m_HybridLocalPositionBeforeChange; - currentTransform.localRotation = m_HybridLocalRotationBeforeChange; - currentTransform.localScale = m_HybridLocalScaleBeforeChange; - } - finally - { - m_RevertingHybridParent = false; - } + 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; } /// @@ -2572,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 before a parent change, restored when the change is reverted. + private Vector3 m_LocalPositionBeforeParentChange; + private Quaternion m_LocalRotationBeforeParentChange; + private Vector3 m_LocalScaleBeforeParentChange; + private bool m_RevertingParentChange; + + private void OnBeforeTransformParentChanged() + { + // The revert's own SetParent must not overwrite the values it is restoring. + 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 from before the parent change. + /// + /// + /// Setting keeps the world position, so it 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. @@ -2753,14 +2759,14 @@ 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 - // Silent: this fires for the parent changes Netcode for Entities applies as well as the user's own, and for a reverted one. - if (GhostReplicatesParent() || m_RevertingHybridParent) + // Silent: this fires for the parent changes Netcode for Entities applies as well as the user's own. + if (GhostReplicatesParent()) { return; } @@ -2773,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; @@ -2791,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!"); @@ -2806,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) @@ -2825,13 +2831,13 @@ private void OnTransformParentChanged() // A direct Transform parent change does not pass a worldPositionStays value, so it is read from whether the local transform was kept. if (HasGhost && !AuthorityAppliedParenting && !GhostObject.ParentReplication) { - if (HybridLocalTransformKept()) + if (LocalTransformKeptByParentChange()) { m_CachedWorldPositionStays = false; } else if (RejectHybridWorldPositionStays(true)) { - RevertHybridParentChange(); + RevertParentChange(); return; } } @@ -2843,7 +2849,7 @@ private void OnTransformParentChanged() { if (!transform.parent.TryGetComponent(out NetworkObject parentObject)) { - transform.parent = m_CachedParent; + RevertParentChange(); AuthorityAppliedParenting = false; if (networkManager.LogLevel <= LogLevel.Error) { @@ -2853,7 +2859,7 @@ private void OnTransformParentChanged() } if (!parentObject.IsSpawned) { - transform.parent = m_CachedParent; + RevertParentChange(); AuthorityAppliedParenting = false; if (networkManager.LogLevel <= LogLevel.Error) { 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..7775acc968 --- /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 + { + // Off the child's position, so that 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 From 5208f62b03431167f685412bfda0aa91fdfe9b00 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 20 Oct 2026 10:28:05 -0500 Subject: [PATCH 09/13] chore: add missing PR numbers to the Unreleased CHANGELOG entries --- com.unity.netcode.gameobjects/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 918257a9dd..328fba074c 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -23,9 +23,9 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed -- 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. -- 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) From da74221365843d3a5b6cbb80de156be731280f10 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 20 Oct 2026 10:51:10 -0500 Subject: [PATCH 10/13] style: reword the comments added by this PR Short sentences, N4E instead of the full name, lists for separate cases, and full XML docs on the ShouldCheckForSpawnedPlayers override. MakeClientWorldActive becomes an inlined MakeWorldActive(NetworkManager) helper without a comment that restated its name. --- .../Components/Helpers/NetworkObjectBridge.cs | 2 +- .../Runtime/Core/NetworkObject.cs | 24 +++++++------- .../Runtime/Spawning/NetworkSpawnManager.cs | 10 +++--- .../NetworkObject/HybridParentingTests.cs | 26 +++++++-------- .../HybridSpawnWorldSelectionTests.cs | 32 +++++++++++-------- .../NetworkObjectDontDestroyWithOwnerTests.cs | 2 +- .../NetworkObjectParentRevertTests.cs | 2 +- .../TestHelpers/NetcodeIntegrationTest.cs | 2 +- 8 files changed, 52 insertions(+), 48 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs index 61f7edb4a3..601c0f1b34 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Helpers/NetworkObjectBridge.cs @@ -37,7 +37,7 @@ private void OnValidate() return; } - // NGO spawns the ghost when the NetworkObject spawns, so it is spawned in that NetworkManager's world + // 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 diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs index d94c78d8a1..ddf0ebf54a 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs @@ -419,7 +419,7 @@ public void ApplyScale(Vector3 scale) } /// - /// When true, Netcode for Entities replicates this instance's parent and NGO parenting stands down. + /// Returns true when N4E replicates this instance's parent. NGO parenting is disabled for the instance in that case. /// internal bool GhostReplicatesParent() { @@ -445,8 +445,8 @@ private bool WarnIfGhostReplicatesParent(string methodName) /// NGO parenting of a hybrid instance only supports worldPositionStays disabled. /// /// - /// The ghost replicates its local transform. With worldPositionStays the authority rebases it at the reparent, but clients - /// apply ParentSyncMessage ahead of the interpolated snapshot that carries the rebased values, so the object pops. + /// 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) @@ -464,12 +464,12 @@ private bool RejectHybridWorldPositionStays(bool worldPositionStays) return true; } - // Above the round-trip error SetParent leaves behind, same as GhostObject. + // 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; /// - /// A parent change that kept the local transform was made with worldPositionStays disabled. + /// Returns true when the parent change kept the local transform. A change that keeps it was made with worldPositionStays disabled. /// private bool LocalTransformKeptByParentChange() { @@ -480,7 +480,7 @@ private bool LocalTransformKeptByParentChange() } /// - /// A hybrid instance parented under a non-hybrid one, or the reverse, can jitter relative to its parent. + /// Logs a warning when a hybrid instance is parented under a non-hybrid one, or the reverse. The child can jitter relative to its parent. /// private void LogHybridParentingArtifacts(NetworkObject parentObject) { @@ -2543,7 +2543,7 @@ internal void InvokeBehaviourOnNetworkObjectParentChanged(NetworkObject parentNe private void OnBeforeTransformParentChanged() { - // The revert's own SetParent must not overwrite the values it is restoring. + // Skips the parent change RevertParentChange makes, so the values it restores are not overwritten. if (m_RevertingParentChange) { return; @@ -2558,8 +2558,8 @@ private void OnBeforeTransformParentChanged() /// Restores the cached parent and the local transform from before the parent change. /// /// - /// Setting keeps the world position, so it 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. + /// 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() { @@ -2765,7 +2765,7 @@ private void OnTransformParentChanged() } #if UNIFIED_NETCODE - // Silent: this fires for the parent changes Netcode for Entities applies as well as the user's own. + // No warning: this also runs for the parent changes N4E applies. if (GhostReplicatesParent()) { return; @@ -2828,7 +2828,7 @@ private void OnTransformParentChanged() } #if UNIFIED_NETCODE - // A direct Transform parent change does not pass a worldPositionStays value, so it is read from whether the local transform was kept. + // 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()) @@ -2956,7 +2956,7 @@ internal bool ApplyNetworkParenting(bool removeParent = false, bool ignoreNotSpa } #if UNIFIED_NETCODE - // NGO never tracked this parent, so applying "no parent" here would undo the one Netcode for Entities replicated. + // NGO does not track this parent. Applying "no parent" here would undo the parent N4E replicated. if (GhostReplicatesParent()) { return false; diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index 2440d56b38..aac7f9a84b 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -968,9 +968,9 @@ internal NetworkObject InstantiateNetworkPrefab([NotNull] GameObject networkPref /// Spawns a hybrid instance's ghost in this 's world. /// /// - /// N4E spawns a ghost into its active world, which is the world currently updating, or the server world between world updates. - /// 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 it is. + /// 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) { @@ -1199,7 +1199,7 @@ internal bool AuthorityLocalSpawn([NotNull] NetworkObject networkObject, ulong n } #if UNIFIED_NETCODE - // The ghost is spawned before OnNetworkSpawn so that NetworkBehaviours can access it there + // The ghost is spawned before OnNetworkSpawn, so NetworkBehaviours can access it there. if (networkObject.HasGhost) { SpawnGhost(networkObject); @@ -1821,7 +1821,7 @@ internal void OnDespawnObject([NotNull] NetworkObject networkObject, bool destro continue; } #if UNIFIED_NETCODE - // NGO does not track a parent that Netcode for Entities replicates, so the hierarchy decides the generation. + // NGO does not track a parent that N4E replicates. The hierarchy determines the generation instead. if (spawnedNetObj.GhostReplicatesParent() && spawnedNetObj.transform.parent != networkObject.transform) { continue; diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs index 0f5117c741..5e998dc0bd 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridParentingTests.cs @@ -10,8 +10,9 @@ namespace Unity.Netcode.RuntimeTests { /// - /// NGO parenting of hybrid prefab instances: the worldPositionStays rejection and the mixed timelines warning when NGO handles it, and NGO standing down when - /// parent replication handles it. + /// 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)] @@ -39,7 +40,7 @@ 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, so it is set before the session starts. + // 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) { @@ -93,8 +94,7 @@ private bool ChildParentedOnAllInstances(StringBuilder errorLog, bool parented) } /// - /// Validates that NGO parenting of a hybrid instance is rejected with worldPositionStays enabled, since clients would apply the - /// new parent before the ghost's rebased local transform and the object would pop, and that it works with worldPositionStays disabled. + /// 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) @@ -120,15 +120,15 @@ public IEnumerator NgoParenting_WorldPositionStays_RejectedWhenEnabled([Values] } /// - /// Validates that a direct parent change of a hybrid instance on the authority ( or - /// ) is replicated when it keeps the local transform, and is otherwise - /// reverted to the previous parent and local transform. + /// 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); - // Off the parent's position, so that a worldPositionStays reparent changes the local position. + // 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; @@ -152,7 +152,7 @@ public IEnumerator NgoParenting_DirectTransformParentChange([Values] bool worldP } /// - /// A hybrid child under a non-hybrid parent mixes N4E snapshot and NGO transform timelines. + /// Validates that a hybrid child parented under a non-hybrid parent logs the mixed timelines warning. /// [UnityTest] public IEnumerator NgoParenting_UnderNonHybridParent_WarnsMixedTimelines() @@ -198,8 +198,8 @@ public IEnumerator ParentReplication_NgoEntryPoints_WarnAndExit() } /// - /// N4E replicates a made on the authority. Were NGO still handling - /// the change, clients would revert it and log an error, which fails the test. + /// 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) @@ -224,7 +224,7 @@ public IEnumerator ParentReplication_TransformSetParent_ReplicatesWithoutNgo([Va } /// - /// NGO still moves first generation children to the root when their parent is despawned, and N4E replicates that. + /// 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.")] diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs index 19d1a58001..f3640b40c9 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridSpawnWorldSelectionTests.cs @@ -1,5 +1,6 @@ #if UNIFIED_NETCODE using System.Collections; +using System.Runtime.CompilerServices; using NUnit.Framework; using Unity.Netcode.TestHelpers.Runtime; using UnityEngine; @@ -9,8 +10,8 @@ namespace Unity.Netcode.RuntimeTests { /// - /// N4E spawns a ghost into its active world, which is the world currently updating, or the server world between world updates. - /// Validates that a hybrid instance's ghost is spawned in the spawning 's world anyway. + /// 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)] @@ -27,8 +28,13 @@ protected override bool UseUnifiedTests() return true; } - // The harness checks for a client's player as soon as it connects, and a hybrid player spawns on the client only once its ghost arrives. - // LateJoinPlayerSpawnsGhostInServerWorld waits for it instead. + /// + /// 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; @@ -48,12 +54,10 @@ protected override void OnServerAndClientsCreated() base.OnServerAndClientsCreated(); } - /// - /// Makes a client's world N4E's active world, as it is while that client's world updates. - /// - private void MakeClientWorldActive() + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void MakeWorldActive(NetworkManager networkManager) { - EntitiesNetcode.Instance.m_ActiveWorld = m_ClientNetworkManagers[0].NetcodeWorld; + EntitiesNetcode.Instance.m_ActiveWorld = networkManager.NetcodeWorld; } private IEnumerator AssertGhostSpawnedInServerWorld(NetworkObject serverInstance, string context) @@ -69,7 +73,7 @@ private IEnumerator AssertGhostSpawnedInServerWorld(NetworkObject serverInstance [UnityTest] public IEnumerator InstantiateAndSpawnSpawnsGhostInServerWorld() { - MakeClientWorldActive(); + MakeWorldActive(m_ClientNetworkManagers[0]); var serverInstance = m_ServerNetworkManager.SpawnManager.InstantiateAndSpawn(m_HybridPrefab.GetComponent()); yield return AssertGhostSpawnedInServerWorld(serverInstance, nameof(NetworkSpawnManager.InstantiateAndSpawn)); } @@ -77,7 +81,7 @@ public IEnumerator InstantiateAndSpawnSpawnsGhostInServerWorld() [UnityTest] public IEnumerator InstantiateThenSpawnSpawnsGhostInServerWorld() { - MakeClientWorldActive(); + 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; @@ -89,8 +93,8 @@ public IEnumerator InstantiateThenSpawnSpawnsGhostInServerWorld() /// 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, and the server approves a - /// connection outside of any world update, so making the client world active from the test body has no effect. + /// 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 { @@ -103,7 +107,7 @@ public ClientWorldActivePlayerHandler(HybridSpawnWorldSelectionTests test) public NetworkObject Instantiate(ulong ownerClientId, Vector3 position, Quaternion rotation) { - m_Test.MakeClientWorldActive(); + MakeWorldActive(m_Test.m_ClientNetworkManagers[0]); return Object.Instantiate(m_Test.m_PlayerPrefab, position, rotation).GetComponent(); } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectDontDestroyWithOwnerTests.cs index 365703ae43..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,7 @@ private void ParentObjects() var destroyObjectId = m_DestroyObjectIds[i]; var dontDestroyObject = networkManager.SpawnManager.SpawnedObjects[dontDestroyObjectId]; var destroyObject = networkManager.SpawnManager.SpawnedObjects[destroyObjectId]; - // worldPositionStays disabled, which NGO parenting of a hybrid instance requires. The pose does not matter here. + // 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 index 7775acc968..fa29b061e4 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectParentRevertTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectParentRevertTests.cs @@ -15,7 +15,7 @@ namespace Unity.Netcode.RuntimeTests [TestFixture(HostOrServer.DAHost)] internal class NetworkObjectParentRevertTests : NetcodeIntegrationTest { - // Off the child's position, so that a parent change with worldPositionStays disabled moves the child. + // 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); diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs index 7f0270dba9..7170782a10 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs @@ -2652,7 +2652,7 @@ 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 + // 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 From 50df251216b5e3f011389c3c3ee0b798922c5a4e Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 20 Oct 2026 11:11:48 -0500 Subject: [PATCH 11/13] style: reword four NetworkObject parenting comments --- .../Runtime/Core/NetworkObject.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs index ddf0ebf54a..f5f90c0a39 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs @@ -469,7 +469,7 @@ private bool RejectHybridWorldPositionStays(bool worldPositionStays) private const float k_LocalSpaceAngleEpsilon = 0.01f; /// - /// Returns true when the parent change kept the local transform. A change that keeps it was made with worldPositionStays disabled. + /// Returns true when parenting occurred with world position stays set to false. /// private bool LocalTransformKeptByParentChange() { @@ -480,7 +480,7 @@ private bool LocalTransformKeptByParentChange() } /// - /// Logs a warning when a hybrid instance is parented under a non-hybrid one, or the reverse. The child can jitter relative to its parent. + /// Logs a warning when a hybrid instance is parented under a non-hybrid one or vice versa. /// private void LogHybridParentingArtifacts(NetworkObject parentObject) { @@ -2535,7 +2535,7 @@ 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 before a parent change, restored when the change is reverted. + // 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; @@ -2555,7 +2555,7 @@ private void OnBeforeTransformParentChanged() } /// - /// Restores the cached parent and the local transform from before the parent change. + /// 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. From e9e2c20e248c6fbde02bbb7077a16054576704e6 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 20 Oct 2026 12:08:26 -0500 Subject: [PATCH 12/13] test: guard 3D rigidbody fixtures on COM_UNITY_MODULES_PHYSICS only NetworkTransformOwnershipTests and RigidbodyContactEventManagerTests use Rigidbody, NetworkRigidbody and RigidbodyContactEventManager, which only exist with com.unity.modules.physics. Now that the test assembly defines COM_UNITY_MODULES_PHYSICS2D, their PHYSICS || PHYSICS2D guard would fail to compile in a project with Physics2D but without 3D physics. --- .../Runtime/NetworkTransform/NetworkTransformOwnershipTests.cs | 2 +- .../Tests/Runtime/Physics/RigidbodyContactEventManagerTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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; From f19e6ef16d6187b5bc08f511ce1dfd07631e30c1 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 20 Oct 2026 15:34:45 -0500 Subject: [PATCH 13/13] fix: reject spawning a hybrid instance in a session without hybrid prefabs Only prefab registration rejected hybrid prefabs. An unregistered hybrid prefab instance, for example one spawned through a prefab handler, spawned into a session that never entered hybrid mode and ran its ghost spawn, ownership and bridge code without a unified world. The spawn authority now logs an error and refuses the spawn. --- .../Runtime/Configuration/NetworkPrefabs.cs | 1 + .../Runtime/Spawning/NetworkSpawnManager.cs | 12 ++++++++++ .../UnifiedHybridPrefabValidationTests.cs | 24 +++++++++++++++++++ 3 files changed, 37 insertions(+) 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/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index aac7f9a84b..0455b2e788 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -1171,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)); 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. ///