Skip to content

fix: OnValueChanged not being triggered on collections when the collection changes back to the previous value #3539

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions com.unity.netcode.gameobjects/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Additional documentation and release notes are available at [Multiplayer Documen

### Fixed

- Fixed ensuring OnValueChanged callback is still triggered on the authority when a collection changes and then reverts to the previous value in the same frame. (#3539)
- Fixed synchronizing the destroyGameObject parameter to clients for InScenePlaced network objects. (#3514)
- Fixed distributed authority related issue where enabling the `NetworkObject.DestroyWithScene` would cause errors when a destroying non-authority instances due to loading (single mode) or unloading scene events. (#3500)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public override void OnInitialize()
base.OnInitialize();

m_HasPreviousValue = true;
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_InternalOriginalValue);
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_LastInternalValue);
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_PreviousValue);
}

Expand All @@ -73,7 +73,7 @@ public NetworkVariable(T value = default,
: base(readPerm, writePerm)
{
m_InternalValue = value;
m_InternalOriginalValue = default;
m_LastInternalValue = default;
// Since we start with IsDirty = true, this doesn't need to be duplicated
// right away. It won't get read until after ResetDirty() is called, and
// the duplicate will be made there. Avoiding calling
Expand All @@ -92,25 +92,45 @@ public void Reset(T value = default)
if (m_NetworkBehaviour == null || m_NetworkBehaviour != null && !m_NetworkBehaviour.NetworkObject.IsSpawned)
{
m_InternalValue = value;
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_InternalOriginalValue);
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_LastInternalValue);
m_PreviousValue = default;
}
}

/// <summary>
/// The internal value of the NetworkVariable
/// The current internal value of the NetworkVariable.
/// </summary>
/// <remarks>
/// When using collections, this InternalValue can be updated directly without going through the <see cref="NetworkVariable{T}.Value"/> setter.
/// </remarks>
[SerializeField]
private protected T m_InternalValue;

// The introduction of standard .NET collections caused an issue with permissions since there is no way to detect changes in the
// collection without doing a full comparison. While this approach does consume more memory per collection instance, it is the
// lowest risk approach to resolving the issue where a client with no write permissions could make changes to a collection locally
// which can cause a myriad of issues.
private protected T m_InternalOriginalValue;
/// <summary>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😻

/// The last valid/authorized value of the network variable.
/// </summary>
/// <remarks>
/// The introduction of standard .NET collections caused an issue with permissions since there is no way to detect changes in the
/// collection without doing a full comparison. While this approach does consume more memory per collection instance, it is the
/// lowest risk approach to resolving the issue where a client with no write permissions could make changes to a collection locally
/// which can cause a myriad of issues.
/// </remarks>
private protected T m_LastInternalValue;

/// <summary>
/// The most recent value that was synchronized over the network.
/// Synchronized over the network at the end of the frame in which the <see cref="NetworkVariable{T}"/> was marked dirty.
/// </summary>
/// <remarks>
/// Only contains the value synchronized over the network at the end of the last frame.
/// All in-between changes on the authority are tracked by <see cref="m_LastInternalValue"/>.
/// </remarks>
private protected T m_PreviousValue;

/// <summary>
/// Whether this network variable has had changes synchronized over the network.
/// Indicates whether <see cref="m_PreviousValue"/> is populated and valid.
/// </summary>
private bool m_HasPreviousValue;
private bool m_IsDisposed;

Expand Down Expand Up @@ -139,7 +159,7 @@ public virtual T Value
{
T previousValue = m_InternalValue;
m_InternalValue = value;
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_InternalOriginalValue);
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_LastInternalValue);
SetDirty(true);
m_IsDisposed = false;
OnValueChanged?.Invoke(previousValue, m_InternalValue);
Expand All @@ -165,20 +185,21 @@ public bool CheckDirtyState(bool forceCheck = false)
if (CannotWrite)
{
// If modifications are detected, then revert back to the last known current value
if (!NetworkVariableSerialization<T>.AreEqual(ref m_InternalValue, ref m_InternalOriginalValue))
if (!NetworkVariableSerialization<T>.AreEqual(ref m_InternalValue, ref m_LastInternalValue))
{
NetworkVariableSerialization<T>.Duplicate(m_InternalOriginalValue, ref m_InternalValue);
NetworkVariableSerialization<T>.Duplicate(m_LastInternalValue, ref m_InternalValue);
}
return false;
}

// Compare the previous with the current if not dirty or forcing a check.
if ((!isDirty || forceCheck) && !NetworkVariableSerialization<T>.AreEqual(ref m_PreviousValue, ref m_InternalValue))
// Compare the last internal value with the current value if not dirty or forcing a check.
if ((!isDirty || forceCheck) && !NetworkVariableSerialization<T>.AreEqual(ref m_LastInternalValue, ref m_InternalValue))
{
SetDirty(true);
OnValueChanged?.Invoke(m_PreviousValue, m_InternalValue);
OnValueChanged?.Invoke(m_LastInternalValue, m_InternalValue);
m_IsDisposed = false;
isDirty = true;
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_LastInternalValue);
}
return isDirty;
}
Expand Down Expand Up @@ -212,11 +233,11 @@ public override void Dispose()
m_InternalValue = default;

// Dispose the internal original value
if (m_InternalOriginalValue is IDisposable internalOriginalValueDisposable)
if (m_LastInternalValue is IDisposable internalOriginalValueDisposable)
{
internalOriginalValueDisposable.Dispose();
}
m_InternalOriginalValue = default;
m_LastInternalValue = default;

// Dispose the previous value if there is one
if (m_HasPreviousValue && m_PreviousValue is IDisposable previousValueDisposable)
Expand Down Expand Up @@ -245,9 +266,9 @@ public override bool IsDirty()
{
// If the client does not have write permissions but the internal value is determined to be locally modified and we are applying updates, then we should revert
// to the original collection value prior to applying updates (primarily for collections).
if (!NetworkUpdaterCheck && CannotWrite && !NetworkVariableSerialization<T>.AreEqual(ref m_InternalValue, ref m_InternalOriginalValue))
if (!NetworkUpdaterCheck && CannotWrite && !NetworkVariableSerialization<T>.AreEqual(ref m_InternalValue, ref m_LastInternalValue))
{
NetworkVariableSerialization<T>.Duplicate(m_InternalOriginalValue, ref m_InternalValue);
NetworkVariableSerialization<T>.Duplicate(m_LastInternalValue, ref m_InternalValue);
return true;
}
// For most cases we can use the dirty flag.
Expand Down Expand Up @@ -284,7 +305,7 @@ public override void ResetDirty()
m_HasPreviousValue = true;
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_PreviousValue);
// Once updated, assure the original current value is updated for future comparison purposes
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_InternalOriginalValue);
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_LastInternalValue);
}
base.ResetDirty();
}
Expand All @@ -307,9 +328,9 @@ public override void ReadDelta(FastBufferReader reader, bool keepDirtyDelta)
{
// If the client does not have write permissions but the internal value is determined to be locally modified and we are applying updates, then we should revert
// to the original collection value prior to applying updates (primarily for collections).
if (CannotWrite && !NetworkVariableSerialization<T>.AreEqual(ref m_InternalOriginalValue, ref m_InternalValue))
if (CannotWrite && !NetworkVariableSerialization<T>.AreEqual(ref m_LastInternalValue, ref m_InternalValue))
{
NetworkVariableSerialization<T>.Duplicate(m_InternalOriginalValue, ref m_InternalValue);
NetworkVariableSerialization<T>.Duplicate(m_LastInternalValue, ref m_InternalValue);
}

NetworkVariableSerialization<T>.ReadDelta(reader, ref m_InternalValue);
Expand Down Expand Up @@ -341,17 +362,17 @@ internal override void PostDeltaRead()
m_HasPreviousValue = true;
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_PreviousValue);
// Once updated, assure the original current value is updated for future comparison purposes
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_InternalOriginalValue);
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_LastInternalValue);
}

/// <inheritdoc />
public override void ReadField(FastBufferReader reader)
{
// If the client does not have write permissions but the internal value is determined to be locally modified and we are applying updates, then we should revert
// to the original collection value prior to applying updates (primarily for collections).
if (CannotWrite && !NetworkVariableSerialization<T>.AreEqual(ref m_InternalOriginalValue, ref m_InternalValue))
if (CannotWrite && !NetworkVariableSerialization<T>.AreEqual(ref m_LastInternalValue, ref m_InternalValue))
{
NetworkVariableSerialization<T>.Duplicate(m_InternalOriginalValue, ref m_InternalValue);
NetworkVariableSerialization<T>.Duplicate(m_LastInternalValue, ref m_InternalValue);
}

NetworkVariableSerialization<T>.Read(reader, ref m_InternalValue);
Expand All @@ -363,7 +384,7 @@ public override void ReadField(FastBufferReader reader)
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_PreviousValue);

// Once updated, assure the original current value is updated for future comparison purposes
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_InternalOriginalValue);
NetworkVariableSerialization<T>.Duplicate(m_InternalValue, ref m_LastInternalValue);
}

/// <inheritdoc />
Expand Down
Loading