From 2e0bfa8e6f3d6dd4f11b20bc69b10e8aabceef29 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 13 Nov 2026 11:11:26 -0600 Subject: [PATCH 1/3] fix: apply the base of a late half float base sync (#4248) With UseHalfFloatPrecision and UseUnreliableDeltas, the base sync goes reliably and the deltas after it unreliably. A base sync that arrived after a newer delta was dropped as out of order, so every later delta decoded against the old base. --- .../Runtime/Components/NetworkTransform.cs | 7 ++ .../NetworkTransformHalfFloatLateBaseTests.cs | 99 +++++++++++++++++++ ...orkTransformHalfFloatLateBaseTests.cs.meta | 2 + 3 files changed, 108 insertions(+) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index b0335356eb..9e9415ec7a 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -3344,6 +3344,13 @@ private void OnNetworkStateChanged(NetworkTransformState oldState, NetworkTransf // from the old state (with unreliable traffic and/or mixed unreliable and reliable) if (UseUnreliableDeltas && oldState.NetworkTick > newState.NetworkTick && !newState.FlagStates.IsTeleportingNextFrame && !newState.FlagStates.UnreliableFrameSync) { + // A half float base sync is sent reliably and the deltas after it unreliably, so it can arrive after them. + // Its position is stale but its base is not: every later delta is relative to it. + var flagStates = newState.FlagStates; + if (flagStates.SynchronizeBaseHalfFloat && flagStates.UseHalfFloatPrecision && flagStates.HasPositionChange) + { + m_HalfPositionState.CurrentBasePosition = newState.NetworkDeltaPosition.CurrentBasePosition; + } return; } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs new file mode 100644 index 0000000000..4a92558c2b --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs @@ -0,0 +1,99 @@ +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates that a half float base sync arriving after the unreliable deltas that depend on it still updates + /// the non-authority's base position. + /// + [TestFixture(HostOrServer.Host)] + [TestFixture(HostOrServer.DAHost)] + internal class NetworkTransformHalfFloatLateBaseTests : IntegrationTestWithApproximation + { + protected override int NumberOfClients => 1; + + private const float k_DeltaStep = 0.5f; + + private GameObject m_TestPrefab; + + public NetworkTransformHalfFloatLateBaseTests(HostOrServer hostOrServer) : base(hostOrServer) + { + } + + protected override void OnServerAndClientsCreated() + { + m_TestPrefab = CreateNetworkObjectPrefab("HalfFloatLateBase"); + var networkTransform = m_TestPrefab.AddComponent(); + networkTransform.UseHalfFloatPrecision = true; + networkTransform.UseUnreliableDeltas = true; + networkTransform.Interpolate = false; + + base.OnServerAndClientsCreated(); + } + + private static NetworkTransform.NetworkTransformState CreateState(NetworkDeltaPosition networkDeltaPosition, int networkTick) + { + return new NetworkTransform.NetworkTransformState() + { + NetworkTick = networkTick, + NetworkDeltaPosition = networkDeltaPosition, + FlagStates = + { + HasPositionX = true, + HasPositionChange = true, + UseHalfFloatPrecision = true, + UseUnreliableDeltas = true, + SynchronizeBaseHalfFloat = networkDeltaPosition.CollapsedDeltaIntoBase, + ReliableSequenced = networkDeltaPosition.CollapsedDeltaIntoBase, + }, + }; + } + + private static void Receive(NetworkTransform nonAuthority, NetworkTransform.NetworkTransformState state) + { + nonAuthority.InboundState = state; + nonAuthority.TransformStateUpdate(); + } + + [UnityTest] + public IEnumerator LateBaseSyncStillUpdatesBase() + { + var authority = GetAuthorityNetworkManager(); + var authorityInstance = SpawnObject(m_TestPrefab, authority).GetComponent(); + yield return WaitForSpawnedOnAllOrTimeOut(authorityInstance); + AssertOnTimeout($"Timed out waiting for {authorityInstance.name} to spawn on all clients!"); + + var nonAuthority = GetNonAuthorityNetworkManager().SpawnManager.SpawnedObjects[authorityInstance.NetworkObjectId].GetComponent(); + + // Encode the way the authority does: crossing MaxDeltaBeforeAdjustment collapses the delta into a new base. + var networkTick = authority.ServerTime.Tick + 10; + var authorityDeltaPosition = new NetworkDeltaPosition(Vector3.zero, networkTick); + var position = new Vector3(NetworkDeltaPosition.MaxDeltaBeforeAdjustment + k_DeltaStep, 0.0f, 0.0f); + authorityDeltaPosition.UpdateFrom(ref position, ++networkTick); + Assert.IsTrue(authorityDeltaPosition.CollapsedDeltaIntoBase, "The first update did not collapse into a new base!"); + var baseSync = CreateState(authorityDeltaPosition, networkTick); + + position.x += k_DeltaStep; + authorityDeltaPosition.UpdateFrom(ref position, ++networkTick); + var firstDelta = CreateState(authorityDeltaPosition, networkTick); + + position.x += k_DeltaStep; + authorityDeltaPosition.UpdateFrom(ref position, ++networkTick); + var secondDelta = CreateState(authorityDeltaPosition, networkTick); + + // The unreliable delta overtakes the reliable base sync it depends on. + Receive(nonAuthority, firstDelta); + Receive(nonAuthority, baseSync); + Receive(nonAuthority, secondDelta); + + Assert.IsTrue(Approximately(nonAuthority.transform.position, position), + $"{nonAuthority.NetworkManager.name} is at {nonAuthority.transform.position} but the authority is at {position}. " + + $"The late base sync was not applied, so the delta was decoded against the previous base."); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs.meta new file mode 100644 index 0000000000..44a536822c --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6df239c9d1394857b98523e7814ab41a From d6ff9b0b2e98b21fb0a0a1a64fbcd1ce63f8519c Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 13 Nov 2026 12:28:06 -0600 Subject: [PATCH 2/3] Add CHANGELOG entry --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 0b7a10e485..5b482a0e41 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -18,6 +18,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed +- Issue where a non-authority `NetworkTransform` using both `UseHalfFloatPrecision` and `UseUnreliableDeltas` could be drawn about 2 meters off until the next frame synchronization when a base position update arrived after the position deltas that followed it. (#4252) - Issue where every `NetworkObject` that started before a session logged a "Did not register because there is no session in progress!" warning when `NetworkManager.LogLevel` was `Developer`. Only `GhostObject` instances log it now. (#4245) - Fixed a NullReferenceException that would be thrown if a MonoBehaviour derived from NetworkManager defines it's own Awake or OnEnable. (#4244) - Issue where the unified bootstrap created a second world that ran a duplicate of every default system next to the default world. (#4243) From c027110c5efe86910f12f2bbacc0f80c91bf9c86 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 13 Nov 2026 13:50:01 -0600 Subject: [PATCH 3/3] Ignore a late half float base sync older than the applied base A base sync from a former owner can reach a non-authority after the new owner's teleport; it must not replace the newer base. --- .../Runtime/Components/NetworkTransform.cs | 11 +- .../NetworkTransformHalfFloatLateBaseTests.cs | 113 +++++++++++++----- 2 files changed, 95 insertions(+), 29 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index 9e9415ec7a..6d7e7cda4e 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -1869,6 +1869,9 @@ private void AddLogEntry(ref NetworkTransformState networkTransformState, ulong /// private NetworkDeltaPosition m_HalfPositionState = new NetworkDeltaPosition(Vector3.zero, 0); + // Non-authority only: the network tick of the state that last set the half float base position. + private int m_HalfPositionBaseTick; + internal void UpdatePositionSlerp() { if (m_PositionInterpolator != null) @@ -2989,6 +2992,7 @@ private void ApplyTeleportingState(NetworkTransformState newState) { // With delta position teleport updates or synchronization, we create a new instance and provide the current network tick. m_HalfPositionState = new NetworkDeltaPosition(newState.CurrentPosition, newState.NetworkTick, math.bool3(SyncPositionX, SyncPositionY, SyncPositionZ)); + m_HalfPositionBaseTick = newState.NetworkTick; // When first synchronizing we determine if we need to apply the current delta position // offset or not. This is specific to owner authoritative mode on the owner side only @@ -3192,6 +3196,7 @@ internal void ApplyUpdatedState(NetworkTransformState newState) if (flagStates.SynchronizeBaseHalfFloat) { m_HalfPositionState = m_LocalAuthoritativeNetworkState.NetworkDeltaPosition; + m_HalfPositionBaseTick = newState.NetworkTick; } else { @@ -3345,11 +3350,13 @@ private void OnNetworkStateChanged(NetworkTransformState oldState, NetworkTransf if (UseUnreliableDeltas && oldState.NetworkTick > newState.NetworkTick && !newState.FlagStates.IsTeleportingNextFrame && !newState.FlagStates.UnreliableFrameSync) { // A half float base sync is sent reliably and the deltas after it unreliably, so it can arrive after them. - // Its position is stale but its base is not: every later delta is relative to it. + // Its position is stale but its base is not: every later delta is relative to it. A base sync older than + // the applied base (from a former owner, after an ownership change) is still dropped. var flagStates = newState.FlagStates; - if (flagStates.SynchronizeBaseHalfFloat && flagStates.UseHalfFloatPrecision && flagStates.HasPositionChange) + if (flagStates.SynchronizeBaseHalfFloat && flagStates.UseHalfFloatPrecision && flagStates.HasPositionChange && newState.NetworkTick > m_HalfPositionBaseTick) { m_HalfPositionState.CurrentBasePosition = newState.NetworkDeltaPosition.CurrentBasePosition; + m_HalfPositionBaseTick = newState.NetworkTick; } return; } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs index 4a92558c2b..68a5a5631d 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatLateBaseTests.cs @@ -9,7 +9,7 @@ namespace Unity.Netcode.RuntimeTests { /// /// Validates that a half float base sync arriving after the unreliable deltas that depend on it still updates - /// the non-authority's base position. + /// the non-authority's base position, unless a newer base has already been applied. /// [TestFixture(HostOrServer.Host)] [TestFixture(HostOrServer.DAHost)] @@ -20,6 +20,8 @@ internal class NetworkTransformHalfFloatLateBaseTests : IntegrationTestWithAppro private const float k_DeltaStep = 0.5f; private GameObject m_TestPrefab; + private NetworkTransform m_NonAuthority; + private int m_NetworkTick; public NetworkTransformHalfFloatLateBaseTests(HostOrServer hostOrServer) : base(hostOrServer) { @@ -36,6 +38,17 @@ protected override void OnServerAndClientsCreated() base.OnServerAndClientsCreated(); } + private IEnumerator SpawnTestObject() + { + var authority = GetAuthorityNetworkManager(); + var authorityInstance = SpawnObject(m_TestPrefab, authority).GetComponent(); + yield return WaitForSpawnedOnAllOrTimeOut(authorityInstance); + AssertOnTimeout($"Timed out waiting for {authorityInstance.name} to spawn on all clients!"); + + m_NonAuthority = GetNonAuthorityNetworkManager().SpawnManager.SpawnedObjects[authorityInstance.NetworkObjectId].GetComponent(); + m_NetworkTick = authority.ServerTime.Tick + 10; + } + private static NetworkTransform.NetworkTransformState CreateState(NetworkDeltaPosition networkDeltaPosition, int networkTick) { return new NetworkTransform.NetworkTransformState() @@ -54,46 +67,92 @@ private static NetworkTransform.NetworkTransformState CreateState(NetworkDeltaPo }; } - private static void Receive(NetworkTransform nonAuthority, NetworkTransform.NetworkTransformState state) + /// + /// Encodes the way the authority does: crossing MaxDeltaBeforeAdjustment collapses the delta into a new base. + /// + private NetworkTransform.NetworkTransformState CreateBaseSync(ref NetworkDeltaPosition networkDeltaPosition, ref Vector3 position) { - nonAuthority.InboundState = state; - nonAuthority.TransformStateUpdate(); + position = new Vector3(NetworkDeltaPosition.MaxDeltaBeforeAdjustment + k_DeltaStep, 0.0f, 0.0f); + networkDeltaPosition.UpdateFrom(ref position, ++m_NetworkTick); + Assert.IsTrue(networkDeltaPosition.CollapsedDeltaIntoBase, "The update did not collapse into a new base!"); + return CreateState(networkDeltaPosition, m_NetworkTick); + } + + private void Receive(NetworkTransform.NetworkTransformState state) + { + m_NonAuthority.InboundState = state; + m_NonAuthority.TransformStateUpdate(); + } + + private void AssertNonAuthorityPosition(Vector3 position, string reason) + { + Assert.IsTrue(Approximately(m_NonAuthority.transform.position, position), + $"{m_NonAuthority.NetworkManager.name} is at {m_NonAuthority.transform.position} but the authority is at {position}. {reason}"); } [UnityTest] public IEnumerator LateBaseSyncStillUpdatesBase() { - var authority = GetAuthorityNetworkManager(); - var authorityInstance = SpawnObject(m_TestPrefab, authority).GetComponent(); - yield return WaitForSpawnedOnAllOrTimeOut(authorityInstance); - AssertOnTimeout($"Timed out waiting for {authorityInstance.name} to spawn on all clients!"); - - var nonAuthority = GetNonAuthorityNetworkManager().SpawnManager.SpawnedObjects[authorityInstance.NetworkObjectId].GetComponent(); + yield return SpawnTestObject(); - // Encode the way the authority does: crossing MaxDeltaBeforeAdjustment collapses the delta into a new base. - var networkTick = authority.ServerTime.Tick + 10; - var authorityDeltaPosition = new NetworkDeltaPosition(Vector3.zero, networkTick); - var position = new Vector3(NetworkDeltaPosition.MaxDeltaBeforeAdjustment + k_DeltaStep, 0.0f, 0.0f); - authorityDeltaPosition.UpdateFrom(ref position, ++networkTick); - Assert.IsTrue(authorityDeltaPosition.CollapsedDeltaIntoBase, "The first update did not collapse into a new base!"); - var baseSync = CreateState(authorityDeltaPosition, networkTick); + var authorityDeltaPosition = new NetworkDeltaPosition(Vector3.zero, m_NetworkTick); + var position = Vector3.zero; + var baseSync = CreateBaseSync(ref authorityDeltaPosition, ref position); position.x += k_DeltaStep; - authorityDeltaPosition.UpdateFrom(ref position, ++networkTick); - var firstDelta = CreateState(authorityDeltaPosition, networkTick); + authorityDeltaPosition.UpdateFrom(ref position, ++m_NetworkTick); + var firstDelta = CreateState(authorityDeltaPosition, m_NetworkTick); position.x += k_DeltaStep; - authorityDeltaPosition.UpdateFrom(ref position, ++networkTick); - var secondDelta = CreateState(authorityDeltaPosition, networkTick); + authorityDeltaPosition.UpdateFrom(ref position, ++m_NetworkTick); + var secondDelta = CreateState(authorityDeltaPosition, m_NetworkTick); // The unreliable delta overtakes the reliable base sync it depends on. - Receive(nonAuthority, firstDelta); - Receive(nonAuthority, baseSync); - Receive(nonAuthority, secondDelta); + Receive(firstDelta); + Receive(baseSync); + Receive(secondDelta); + + AssertNonAuthorityPosition(position, "The late base sync was not applied, so the delta was decoded against the previous base."); + } + + [UnityTest] + public IEnumerator BaseSyncOlderThanAppliedBaseIsIgnored() + { + yield return SpawnTestObject(); + + // The former owner's base sync, delayed past an ownership change. + var formerOwnerDeltaPosition = new NetworkDeltaPosition(Vector3.zero, m_NetworkTick); + var position = Vector3.zero; + var staleBaseSync = CreateBaseSync(ref formerOwnerDeltaPosition, ref position); + + // The new owner teleports to its current position when it gains ownership, which sets a new base. + m_NetworkTick += 10; + position = new Vector3(10.0f, 0.0f, 0.0f); + var teleport = new NetworkTransform.NetworkTransformState() + { + NetworkTick = m_NetworkTick, + CurrentPosition = position, + FlagStates = + { + HasPositionX = true, + HasPositionChange = true, + UseHalfFloatPrecision = true, + UseUnreliableDeltas = true, + IsTeleportingNextFrame = true, + ReliableSequenced = true, + }, + }; + + var newOwnerDeltaPosition = new NetworkDeltaPosition(position, m_NetworkTick); + position.x += k_DeltaStep; + newOwnerDeltaPosition.UpdateFrom(ref position, ++m_NetworkTick); + var newOwnerDelta = CreateState(newOwnerDeltaPosition, m_NetworkTick); + + Receive(teleport); + Receive(staleBaseSync); + Receive(newOwnerDelta); - Assert.IsTrue(Approximately(nonAuthority.transform.position, position), - $"{nonAuthority.NetworkManager.name} is at {nonAuthority.transform.position} but the authority is at {position}. " + - $"The late base sync was not applied, so the delta was decoded against the previous base."); + AssertNonAuthorityPosition(position, "The former owner's base sync replaced the new owner's base."); } } }