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(); } } }