526 lines
18 KiB
C#
526 lines
18 KiB
C#
using Cysharp.Threading.Tasks.Triggers;
|
|
using JetBrains.Annotations;
|
|
using System;
|
|
using System.Drawing;
|
|
using System.Text;
|
|
using UdonSharp;
|
|
using UnityEngine;
|
|
using VRC.SDK3.UdonNetworkCalling;
|
|
using VRC.SDKBase;
|
|
using VRC.Udon.ClientBindings.Interfaces;
|
|
using VRC.Udon.Common;
|
|
|
|
namespace Marro.PacManUdon
|
|
{
|
|
public enum NetworkEventType
|
|
{
|
|
FullSync = 0,
|
|
PacManTurn = 1,
|
|
}
|
|
public class NetworkManager : UdonSharpBehaviour
|
|
{
|
|
// The network manager works by serializing event and state data into a byte array, including a timestamp for each event.
|
|
// If user is owner, this data is created and stored in a buffer which is cleared upon transmission.
|
|
// If user is not owner, this data is read into the same buffer and replayed based on the included timestamp.
|
|
// Data replay is delayed with an offset on the timestamp to hide inconsistency in latency.
|
|
|
|
// The timestamp is transferred in ms as a 32 bit uint, which gives a maximum time of about 49 days.
|
|
// The maximum allowed age of a VRChat instance is 7 days, so this should not cause issues.
|
|
|
|
// A byte array is used as a DataList or DataDictionary can only be transmitted as JSON which is much less efficient.
|
|
// As Udon does not support instantiating objects, I have not created classes to represent the data being sent.
|
|
// Correct parsing is dependend upon everything being read out identially to how it was created.
|
|
|
|
// An event has the following structure:
|
|
// [0-1]: (ushort) Size of event.
|
|
// [2-5]: (uint) Time in seconds at which event occured.
|
|
// [6]: (byte) Type of event. 0 = Full Sync, which is used to sync up from an undefinted state.
|
|
// [7+]: Event-specific data
|
|
|
|
private const int BufferMaxSizeBytes = 10000;
|
|
private const int BufferIncrementSizeBytes = 1000;
|
|
|
|
[SerializeField] private SyncedObject[] syncedObjects;
|
|
|
|
[SerializeField] private Animator DebugImageToIndicateOwner;
|
|
|
|
private bool isOwner;
|
|
private bool isSynced;
|
|
|
|
private long startTimeTicks = DateTime.UtcNow.Ticks; // Initialize to prevent errors
|
|
private long nextEventTimeTicks;
|
|
|
|
private int retriesWithoutSuccess;
|
|
|
|
// Main buffer of events
|
|
private byte[] buffer;
|
|
private int index;
|
|
|
|
private const ushort HeaderEventSizeIndex = 0;
|
|
private const ushort HeaderTimestampIndex = 2;
|
|
private const ushort HeaderEventTypeIndex = 6;
|
|
private const ushort HeaderLength = 7;
|
|
|
|
private const int Delay = 250;
|
|
|
|
[UdonSynced] private byte[] networkedData = new byte[0];
|
|
|
|
public long CurrentTimeTicks { get; private set; }
|
|
|
|
public void Initialize()
|
|
{
|
|
if (!BitConverter.IsLittleEndian)
|
|
{
|
|
Debug.LogError($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Fatal: NetworkManager only supports little endian! Network sync will not be possible.");
|
|
var zero = 0;
|
|
Debug.Log(1 / zero); // Intentionally crash
|
|
return;
|
|
}
|
|
|
|
buffer = new byte[BufferIncrementSizeBytes];
|
|
index = 0;
|
|
startTimeTicks = DateTime.UtcNow.Ticks;
|
|
isSynced = false;
|
|
retriesWithoutSuccess = 0;
|
|
SetOwner(Networking.IsOwner(gameObject));
|
|
|
|
Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Initialized, startTimeTicks: {startTimeTicks}");
|
|
}
|
|
|
|
public void Update()
|
|
{
|
|
if (!isOwner)
|
|
{
|
|
ProgressReplayTime();
|
|
}
|
|
|
|
UpdateTime(DateTime.UtcNow.Ticks);
|
|
}
|
|
|
|
public void SendEvent(NetworkEventType eventType)
|
|
{
|
|
if (!isOwner)
|
|
{
|
|
Debug.LogError($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Attempted {nameof(SendEvent)} while not the owner!");
|
|
return;
|
|
}
|
|
|
|
var eventTime = GetTimestamp(CurrentTimeTicks);
|
|
|
|
InitializeEvent(eventType, eventTime, BufferMaxSizeBytes, out byte[][] data, out var index);
|
|
|
|
foreach (var obj in syncedObjects)
|
|
{
|
|
obj.AppendSyncedData(data, ref index, eventType);
|
|
}
|
|
|
|
// Get event size, skipping over the event size which is not yet included
|
|
ushort eventSize = 0;
|
|
for (int i = 0; i < index; i++)
|
|
{
|
|
eventSize += (ushort)data[i].Length;
|
|
}
|
|
|
|
if (!EnsureSpaceToStoreEvent(eventSize))
|
|
{
|
|
return;
|
|
}
|
|
|
|
data[0] = BitConverter.GetBytes(eventSize);
|
|
|
|
var oldIndex = this.index;
|
|
|
|
FlattenAndCopy(data, index, buffer, ref this.index);
|
|
|
|
Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Prepared event with {eventSize} bytes and timestamp {eventTime} for serialization, index went from {oldIndex} to {this.index}");
|
|
|
|
RequestSerialization();
|
|
|
|
retriesWithoutSuccess = 0; // We had success!
|
|
}
|
|
|
|
public void RequestEvent(NetworkEventType eventType)
|
|
{
|
|
if (isOwner)
|
|
{
|
|
Debug.LogError($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Attempted {nameof(RequestEvent)} while we are the owner!");
|
|
return;
|
|
}
|
|
|
|
SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, "RequestEventReceived", eventType);
|
|
}
|
|
|
|
[NetworkCallable]
|
|
public void RequestEventReceived(NetworkEventType eventType)
|
|
{
|
|
if (!isOwner)
|
|
{
|
|
Debug.LogError($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Attempted {nameof(RequestEventReceived)} while we are not the owner!");
|
|
return;
|
|
}
|
|
|
|
SendEvent(eventType);
|
|
}
|
|
|
|
private void ProcessIncomingData()
|
|
{
|
|
if (networkedData.Length == 0)
|
|
{
|
|
return; // Nothing to process
|
|
}
|
|
|
|
var length = networkedData.Length;
|
|
int index = 0;
|
|
while (index < length)
|
|
{
|
|
if (length - index < HeaderLength)
|
|
{
|
|
Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) {nameof(ProcessIncomingData)}: Remaining data in networkedData is not long enough to form a complete event!");
|
|
HandleError();
|
|
return;
|
|
}
|
|
|
|
var eventSize = networkedData[index + HeaderEventSizeIndex];
|
|
var eventType = (NetworkEventType)networkedData[index + HeaderEventTypeIndex];
|
|
|
|
if (eventType == NetworkEventType.FullSync)
|
|
{
|
|
ProcessIncomingFullSync(index, eventSize); // Immediately process full sync
|
|
index += eventSize;
|
|
continue;
|
|
}
|
|
|
|
if (!isSynced)
|
|
{
|
|
Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Received event of type {eventType} while we are not yet synced to the remote time, ignoring event.");
|
|
index += eventSize;
|
|
continue;
|
|
}
|
|
|
|
AppendEventToBuffer(index, eventSize);
|
|
index += eventSize;
|
|
}
|
|
|
|
Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Received {networkedData.Length} bytes!\nBytes received:\n{BytesToString(networkedData)}");
|
|
}
|
|
|
|
private void ProcessIncomingFullSync(int index, int size)
|
|
{
|
|
// Intentionally not doing a buffer size check here, since this is not appending to the buffer
|
|
// (and there is no good way to continue if event is too large)
|
|
|
|
// Clear buffer and copy the full sync into it
|
|
buffer = new byte[size];
|
|
Array.Copy(networkedData, index, buffer, 0, size);
|
|
this.index = size;
|
|
|
|
// Sync up to the time in the full sync
|
|
var timestamp = BitConverter.ToUInt32(networkedData, index + HeaderTimestampIndex);
|
|
SyncToTimestamp(timestamp);
|
|
|
|
// Immediately apply the full sync
|
|
nextEventTimeTicks = GetTimeTicks(timestamp);
|
|
isSynced = true;
|
|
}
|
|
|
|
private void AppendEventToBuffer(int index, int size)
|
|
{
|
|
if (!EnsureSpaceToStoreEvent(size))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Array.Copy(networkedData, index, buffer, this.index, size);
|
|
this.index += size;
|
|
|
|
UpdateNextEventTime();
|
|
}
|
|
|
|
private void ProgressReplayTime()
|
|
{
|
|
while (index != 0 && nextEventTimeTicks <= CurrentTimeTicks)
|
|
{
|
|
ProcessIncomingEvent();
|
|
UpdateNextEventTime();
|
|
}
|
|
}
|
|
|
|
private void UpdateNextEventTime()
|
|
{
|
|
if (index == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var nextEventTimeTicks = GetTimeTicks(BitConverter.ToUInt32(buffer, HeaderTimestampIndex));
|
|
if (nextEventTimeTicks >= this.nextEventTimeTicks)
|
|
{
|
|
this.nextEventTimeTicks = nextEventTimeTicks;
|
|
}
|
|
else
|
|
{
|
|
Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) New event is earlier than previous event!");
|
|
HandleError();
|
|
return;
|
|
}
|
|
}
|
|
|
|
private void ProcessIncomingEvent()
|
|
{
|
|
var eventTimeTicks = GetTimeTicks(BitConverter.ToUInt32(buffer, HeaderTimestampIndex));
|
|
var eventType = (NetworkEventType)buffer[HeaderEventTypeIndex];
|
|
var index = (int)HeaderLength; // Skip header
|
|
|
|
UpdateTime(eventTimeTicks);
|
|
|
|
foreach (var obj in syncedObjects)
|
|
{
|
|
obj.FixedUpdate();
|
|
}
|
|
|
|
foreach (var obj in syncedObjects)
|
|
{
|
|
var success = obj.SetSyncedData(buffer, ref index, eventType);
|
|
|
|
if (!success)
|
|
{
|
|
Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Malformed data reported by {obj.name} during event type {eventType}!");
|
|
HandleError();
|
|
return;
|
|
}
|
|
|
|
if (index > this.index)
|
|
{
|
|
Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Buffer overflow during {nameof(SyncedObject.SetSyncedData)} for {obj.name} in event type {eventType}!");
|
|
HandleError();
|
|
return;
|
|
}
|
|
}
|
|
|
|
var eventSize = BitConverter.ToUInt16(buffer, HeaderEventSizeIndex);
|
|
if (index != eventSize)
|
|
{
|
|
Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Amount of data read does not match event size! Expected {eventSize}, read {index}.");
|
|
HandleError();
|
|
return;
|
|
}
|
|
|
|
RemoveProcessedDataFromBuffer(index);
|
|
|
|
Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Processed incoming event! Total {index} bytes.");
|
|
|
|
retriesWithoutSuccess = 0; // We had success!
|
|
}
|
|
|
|
private void SyncToTimestamp(uint newTime)
|
|
{
|
|
var timeToSyncTo = newTime - Delay;
|
|
startTimeTicks = DateTime.UtcNow.Ticks - timeToSyncTo * TimeSpan.TicksPerMillisecond;
|
|
|
|
Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Synced to time {newTime}, startTimeTicks is now {startTimeTicks}");
|
|
}
|
|
|
|
private bool EnsureSpaceToStoreEvent(int eventSize)
|
|
{
|
|
if (index + eventSize <= buffer.Length)
|
|
{
|
|
return true; // Enough space!
|
|
}
|
|
|
|
var newBufferSize = ((index + eventSize) / BufferIncrementSizeBytes + 1) * BufferIncrementSizeBytes;
|
|
|
|
var success = IncreaseBufferSize(newBufferSize);
|
|
if (success)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (index == 0)
|
|
{
|
|
Debug.LogError($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Buffer is not large enough to store event!");
|
|
}
|
|
else
|
|
{
|
|
Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Too much data in buffer to store event!");
|
|
}
|
|
|
|
HandleError(); // We can store event now that we cleared the buffer.
|
|
return false;
|
|
}
|
|
|
|
private bool IncreaseBufferSize(int newSize)
|
|
{
|
|
if (newSize < buffer.Length)
|
|
{
|
|
Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Cannot decrease the size of the buffer!");
|
|
return false;
|
|
}
|
|
|
|
if (newSize > BufferMaxSizeBytes)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var oldBuffer = buffer;
|
|
buffer = new byte[newSize];
|
|
oldBuffer.CopyTo(buffer, 0);
|
|
return true;
|
|
}
|
|
|
|
private void InitializeEvent(NetworkEventType eventType, uint eventTime, int maxSize, out byte[][] data, out int index)
|
|
{
|
|
data = new byte[maxSize][];
|
|
index = 3;
|
|
|
|
data[0] = new byte[2]; // Placeholder for event size
|
|
data[1] = BitConverter.GetBytes(eventTime);
|
|
data[2] = new byte[] { GameManager.Int32ToByte((int)eventType) };
|
|
}
|
|
|
|
private void FlattenAndCopy(byte[][] data, int length, byte[] target, ref int index)
|
|
{
|
|
for (int i = 0; i < length; i++)
|
|
{
|
|
var values = data[i];
|
|
Array.Copy(values, 0, target, index, values.Length);
|
|
index += values.Length;
|
|
}
|
|
}
|
|
|
|
private void RemoveProcessedDataFromBuffer(int amountProcessed)
|
|
{
|
|
var oldBuffer = buffer;
|
|
index -= amountProcessed;
|
|
buffer = new byte[BufferMaxSizeBytes];
|
|
Array.Copy(oldBuffer, amountProcessed, buffer, 0, index);
|
|
}
|
|
|
|
private void ClearBuffer()
|
|
{
|
|
buffer = new byte[BufferMaxSizeBytes];
|
|
index = 0;
|
|
}
|
|
|
|
private void HandleError()
|
|
{
|
|
retriesWithoutSuccess++;
|
|
|
|
if (retriesWithoutSuccess > 3)
|
|
{
|
|
Debug.LogError($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Fatal: Retried 3 times without success.");
|
|
var zero = 0;
|
|
Debug.Log(1 / zero); // Intentionally crash
|
|
return;
|
|
}
|
|
|
|
Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Encountered data error, attempting to recover via full sync.");
|
|
|
|
ClearBuffer();
|
|
|
|
if (!isOwner)
|
|
{
|
|
RequestEvent(NetworkEventType.FullSync);
|
|
}
|
|
else
|
|
{
|
|
SendEvent(NetworkEventType.FullSync);
|
|
}
|
|
}
|
|
|
|
private void SetOwner(bool isOwner)
|
|
{
|
|
this.isOwner = isOwner;
|
|
|
|
if (DebugImageToIndicateOwner != null)
|
|
{
|
|
DebugImageToIndicateOwner.SetFloat("Color", isOwner ? 1 : 0);
|
|
}
|
|
}
|
|
|
|
public override void OnOwnershipTransferred(VRCPlayerApi newOwner)
|
|
{
|
|
SetOwner(newOwner == Networking.LocalPlayer);
|
|
|
|
if(isOwner)
|
|
{
|
|
HandleError();
|
|
}
|
|
}
|
|
|
|
public override void OnPreSerialization()
|
|
{
|
|
if (isOwner)
|
|
{
|
|
networkedData = new byte[index];
|
|
Array.Copy(buffer, networkedData, index);
|
|
}
|
|
else
|
|
{
|
|
networkedData = new byte[0]; // Prevent exception loop in VRChat SDK
|
|
}
|
|
}
|
|
|
|
public override void OnPostSerialization(SerializationResult result)
|
|
{
|
|
if (!result.success)
|
|
{
|
|
Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Serialization failed! Tried to send {result.byteCount} bytes.");
|
|
return;
|
|
}
|
|
|
|
if (!isOwner || networkedData.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Serialized with {networkedData.Length} bytes!\nBytes sent:\n{BytesToString(networkedData)}");
|
|
|
|
// Remove all transferred data from the buffer, leaving data that came in after serialization
|
|
RemoveProcessedDataFromBuffer(networkedData.Length);
|
|
networkedData = null;
|
|
}
|
|
|
|
public override void OnDeserialization()
|
|
{
|
|
if (!isOwner)
|
|
{
|
|
ProcessIncomingData();
|
|
}
|
|
}
|
|
|
|
private void UpdateTime(long timeTicks)
|
|
{
|
|
CurrentTimeTicks = timeTicks;
|
|
|
|
foreach (var obj in syncedObjects)
|
|
{
|
|
obj.Dt = (timeTicks - obj.LastUpdateTicks) / (float)TimeSpan.TicksPerSecond;
|
|
obj.LastUpdateTicks = timeTicks;
|
|
}
|
|
}
|
|
|
|
public uint GetTimestamp(long timeTicks)
|
|
{
|
|
return (uint)((timeTicks - startTimeTicks) / TimeSpan.TicksPerMillisecond);
|
|
}
|
|
|
|
public long GetTimeTicks(uint timeStamp)
|
|
{
|
|
return timeStamp * TimeSpan.TicksPerMillisecond + startTimeTicks;
|
|
}
|
|
|
|
public bool IsOwner => isOwner;
|
|
|
|
public string BytesToString(byte[] bytes)
|
|
{
|
|
var sb = new StringBuilder("new byte[] { ");
|
|
foreach (var b in bytes)
|
|
{
|
|
sb.Append(b + ", ");
|
|
}
|
|
sb.Append("}");
|
|
return sb.ToString();
|
|
}
|
|
}
|
|
}
|