Skip to content

feat: client network variables #1522

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

Closed
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ public void Handle(ref NetworkContext context)
{
for (int i = 0; i < behaviour.NetworkVariableFields.Count; i++)
{
var field = behaviour.NetworkVariableFields[i];
ushort varSize = 0;

if (networkManager.NetworkConfig.EnsureNetworkVariableLengthSafety)
Expand All @@ -152,15 +153,15 @@ public void Handle(ref NetworkContext context)
}
}

if (networkManager.IsServer)
if (networkManager.IsServer && !field.CanClientWrite(context.SenderId))
{
// we are choosing not to fire an exception here, because otherwise a malicious client could use this to crash the server
if (networkManager.NetworkConfig.EnsureNetworkVariableLengthSafety)
{
if (NetworkLog.CurrentLogLevel <= LogLevel.Normal)
{
NetworkLog.LogWarning($"Client wrote to {typeof(NetworkVariable<>).Name} without permission. => {nameof(NetworkObjectId)}: {NetworkObjectId} - {nameof(NetworkObject.GetNetworkBehaviourOrderIndex)}(): {networkObject.GetNetworkBehaviourOrderIndex(behaviour)} - VariableIndex: {i}");
NetworkLog.LogError($"[{behaviour.NetworkVariableFields[i].GetType().Name}]");
NetworkLog.LogError($"[{field.GetType().Name}]");
}

m_ReceivedNetworkVariableData.Seek(m_ReceivedNetworkVariableData.Position + varSize);
Expand All @@ -177,19 +178,19 @@ public void Handle(ref NetworkContext context)
if (NetworkLog.CurrentLogLevel <= LogLevel.Error)
{
NetworkLog.LogError($"Client wrote to {typeof(NetworkVariable<>).Name} without permission. No more variables can be read. This is critical. => {nameof(NetworkObjectId)}: {NetworkObjectId} - {nameof(NetworkObject.GetNetworkBehaviourOrderIndex)}(): {networkObject.GetNetworkBehaviourOrderIndex(behaviour)} - VariableIndex: {i}");
NetworkLog.LogError($"[{behaviour.NetworkVariableFields[i].GetType().Name}]");
NetworkLog.LogError($"[{field.GetType().Name}]");
}

return;
}
int readStartPos = m_ReceivedNetworkVariableData.Position;

behaviour.NetworkVariableFields[i].ReadDelta(m_ReceivedNetworkVariableData, networkManager.IsServer);
field.ReadDelta(m_ReceivedNetworkVariableData, networkManager.IsServer);

networkManager.NetworkMetrics.TrackNetworkVariableDeltaReceived(
context.SenderId,
networkObject,
behaviour.NetworkVariableFields[i].Name,
field.Name,
behaviour.__getTypeName(),
context.MessageSize);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;

namespace Unity.Netcode
{
/// <summary>
/// A ClientNetworkVariable is special in that:
/// - only the owner of the variable can write to it
/// - not even the server can write to it
Copy link
Contributor

@SamuelBellomo SamuelBellomo Dec 10, 2021

Choose a reason for hiding this comment

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

not even the server can write to it

I might have missed this with Owernship, but to me this would be covered by the IsOwner check no? IsOwner checks for
NetworkManager != null && OwnerClientId == NetworkManager.LocalClientId;
This would be false for the server?

/// - it is not snapshotted
Copy link
Collaborator

Choose a reason for hiding this comment

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

I thought the direction we were taking was for everything to be snapshotted. AFAIK the client can send snapshots to the server as well.

/// - it must be sent reliably
///
/// (This class may be removed in the future when integrated into NetworkVariable natively)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Glad I read this comment, I was gonna ask this question.

/// </summary>
[Serializable]
public class ClientNetworkVariable<T> : NetworkVariable<T> where T : unmanaged
{
/// <summary>
/// Creates a ClientNetworkVariable with the default read permission
/// </summary>
public ClientNetworkVariable()
{
}

/// <summary>
/// Creates a ClientNetworkVariable with a initial value
/// </summary>
/// <param name="value">The initial value to use for the ClientNetworkVariable</param>
public ClientNetworkVariable(T value) : base(value)
{
}

/// <summary>
/// Creates a ClientNetworkVariable with a specified read permission
/// </summary>
/// <param name="readPerm">The readPermission to use</param>
public ClientNetworkVariable(NetworkVariableReadPermission readPerm) : base(readPerm)
{
}

/// <summary>
/// Creates a ClientNetworkVariable with a initial value and the specified read permission
/// </summary>
/// <param name="readPerm">The initial read permission to use for the ClientNetworkVariable</param>
/// <param name="value">The initial value to use for the ClientNetworkVariable</param>
public ClientNetworkVariable(NetworkVariableReadPermission readPerm, T value) : base(readPerm, value)
{
}

public override bool CanClientWrite(ulong clientId)
{
return m_NetworkBehaviour.OwnerClientId == clientId;
}

public override bool ShouldWrite(ulong clientId, bool isServer)
{
return m_IsDirty && !isServer && m_NetworkBehaviour.IsOwner;
Copy link
Contributor

Choose a reason for hiding this comment

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

A host wouldn't be able to write? If my character is client driven, I don't want different code for my host and my clients, both should be able to update their own characters.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, hosts should be able to write these, although I still yearn for the day when a "host" is just a client and a server running in the same process but each having their own version of each object.

}

/// <summary>
/// The value of the ClientNetworkVariable container
/// </summary>
public override T Value
{
get => m_InternalValue;
set
{
// this could be improved. The Networking Manager is not always initialized here
// Good place to decouple network manager from the network variable

// Also, note this is not really very water-tight, if you are running as a host
// we cannot tell if a ClientNetworkVariable write is happening inside server-ish code
if (m_NetworkBehaviour && m_NetworkBehaviour.NetworkManager.IsServer)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This might be an opportunity to think about how we can decouple this from NetworkBehaviour and NetworkManager. We have NetworkBehaviour doing some initialization work on network variables, what about removing m_NetworkBehaviour and replacing it with internal void SetLocalPermissions(bool iCanRead, bool iCanWrite) and letting NetworkBehaviour tell NetworkVariable what its configuration should be, instead of having NetworkVariable poll for it the other way? This isn't going to change over the variable's lifetime and that would make network variables easier to unit test.

Copy link
Collaborator

Choose a reason for hiding this comment

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

probably also needs to pass OwnerClientId. Or possibly it could pass in delegates for "can write" and "can read" instead of booleans. That could give us the opportunity to give more advanced permission control to the user and let them write more complicated ownership and authority logic.

{
throw new InvalidOperationException("Server not allowed to write to ClientNetworkVariables");
}
Set(value);
}
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ public virtual bool ShouldWrite(ulong clientId, bool isServer)
return IsDirty() && isServer && CanClientRead(clientId);
}

/// <summary>
/// Gets Whether or not a specific client can read to the varaible
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
/// Gets Whether or not a specific client can read to the varaible
/// Gets Whether or not a specific client can write to the variable

/// </summary>
/// <param name="clientId">The clientId of the remote client</param>
/// <returns>Whether or not the client can read to the variable</returns>
public virtual bool CanClientWrite(ulong clientId)
{
return false;
}

/// <summary>
/// Gets Whether or not a specific client can read to the varaible
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReade

public class NetworkVariableTest : NetworkBehaviour
{
public readonly ClientNetworkVariable<int> ClientVar = new ClientNetworkVariable<int>();

public readonly ClientNetworkVariable<int> ClientVarPrivate =
new ClientNetworkVariable<int>(NetworkVariableReadPermission.OwnerOnly);

public readonly NetworkVariable<int> TheScalar = new NetworkVariable<int>();
public readonly NetworkList<int> TheList = new NetworkList<int>();
public readonly NetworkList<FixedString128Bytes> TheLargeList = new NetworkList<FixedString128Bytes>();
Expand Down Expand Up @@ -70,9 +75,18 @@ public class NetworkVariableTests : BaseMultiInstanceTest
// Player1 component on the server
private NetworkVariableTest m_Player1OnServer;

// Player2 component on the server
private NetworkVariableTest m_Player2OnServer;

// Player1 component on client1
private NetworkVariableTest m_Player1OnClient1;

// Player2 component on client1
private NetworkVariableTest m_Player1OnClient2;

// client2's version of client1's player object
private NetworkVariableTest m_Player1FromClient2;

private bool m_TestWithHost;

private bool m_EnsureLengthSafety;
Expand Down Expand Up @@ -105,13 +119,33 @@ public override IEnumerator Setup()
m_ServerNetworkManager, result));
m_Player1OnServer = result.Result.GetComponent<NetworkVariableTest>();

yield return MultiInstanceHelpers.Run(MultiInstanceHelpers.GetNetworkObjectByRepresentation(
x => x.IsPlayerObject && x.OwnerClientId == m_ClientNetworkManagers[1].LocalClientId,
m_ServerNetworkManager, result));
m_Player2OnServer = result.Result.GetComponent<NetworkVariableTest>();

// This is client1's view of itself
yield return MultiInstanceHelpers.Run(MultiInstanceHelpers.GetNetworkObjectByRepresentation(
x => x.IsPlayerObject && x.OwnerClientId == m_ClientNetworkManagers[0].LocalClientId,
m_ClientNetworkManagers[0], result));

m_Player1OnClient1 = result.Result.GetComponent<NetworkVariableTest>();

// This is client2's view of itself
result = new MultiInstanceHelpers.CoroutineResultWrapper<NetworkObject>();
yield return MultiInstanceHelpers.Run(MultiInstanceHelpers.GetNetworkObjectByRepresentation(
x => x.IsPlayerObject && x.OwnerClientId == m_ClientNetworkManagers[1].LocalClientId,
m_ClientNetworkManagers[1], result));

m_Player1OnClient2 = result.Result.GetComponent<NetworkVariableTest>();

// This is client2's view of client 1's object
yield return MultiInstanceHelpers.Run(MultiInstanceHelpers.GetNetworkObjectByRepresentation(
x => x.IsPlayerObject && x.OwnerClientId == m_ClientNetworkManagers[0].LocalClientId,
m_ClientNetworkManagers[1], result));

m_Player1FromClient2 = result.Result.GetComponent<NetworkVariableTest>();

m_Player1OnServer.TheList.Clear();

if (m_Player1OnServer.TheList.Count > 0)
Expand Down Expand Up @@ -181,6 +215,65 @@ public void ClientWritePermissionTest([Values(true, false)] bool useHost)
Assert.Throws<InvalidOperationException>(() => m_Player1OnClient1.TheScalar.Value = k_TestVal1);
}

[Test]
public void ServerWritePermissionTest([Values(true, false)] bool useHost)
{
m_TestWithHost = useHost;

// server must not be allowed to write to a client auth variable
Assert.Throws<InvalidOperationException>(() => m_Player1OnServer.ClientVar.Value = k_TestVal1);
}

[UnityTest]
public IEnumerator ClientTest([Values(true, false)] bool useHost)
{
m_TestWithHost = useHost;

yield return MultiInstanceHelpers.RunAndWaitForCondition(
() =>
{
m_Player1OnClient1.ClientVar.Value = k_TestVal2;
m_Player1OnClient2.ClientVar.Value = k_TestVal3;
},
() =>
{
// the client's values should win on the objects it owns
return
m_Player1OnServer.ClientVar.Value == k_TestVal2 &&
m_Player2OnServer.ClientVar.Value == k_TestVal3 &&
m_Player1OnClient1.ClientVar.Value == k_TestVal2 &&
m_Player1OnClient2.ClientVar.Value == k_TestVal3;
}
);
}

[UnityTest]
public IEnumerator PrivateClientTest([Values(true, false)] bool useHost)
{
m_TestWithHost = useHost;

yield return MultiInstanceHelpers.RunAndWaitForCondition(
() =>
{
// we are writing to the private and public variables on player 1's object...
m_Player1OnClient1.ClientVarPrivate.Value = k_TestVal1;
m_Player1OnClient1.ClientVar.Value = k_TestVal2;
},
() =>
{
// ...and we should see the writes to the private var only on the server & the owner,
// but the public variable everywhere
return
m_Player1FromClient2.ClientVarPrivate.Value != k_TestVal1 &&
m_Player1OnClient1.ClientVarPrivate.Value == k_TestVal1 &&
m_Player1FromClient2.ClientVar.Value != k_TestVal2 &&
m_Player1OnClient1.ClientVar.Value == k_TestVal2 &&
m_Player1OnServer.ClientVarPrivate.Value == k_TestVal1 &&
m_Player1OnServer.ClientVar.Value == k_TestVal2;
}
);
}

[UnityTest]
public IEnumerator FixedString32Test([Values(true, false)] bool useHost)
{
Expand Down