using Cysharp.Threading.Tasks.Triggers; using JetBrains.Annotations; using System; using System.Drawing; using System.Linq; using System.Reflection; using System.Text; using TMPro; using UdonSharp; using UnityEngine; using UnityEngine.UI; 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) Message id, increments by one for each message with the same timestamp. // [7]: (byte) Type of event. 0 = Full Sync, which is used to sync up from an undefinted state. // [+]: Event-specific data. #region Constants /// /// The maximum size of the buffer in bytes. /// private const int BufferMaxTotalEvents = 255; /// /// The index in an event where the event size is stored. /// private const ushort HeaderEventSizeIndex = 0; /// /// The index in an event where the timestamp is stored. /// private const ushort HeaderTimestampIndex = 2; /// /// The index in an event where the event id is stored. /// private const ushort HeaderEventIdIndex = 6; /// /// The index in an event where the event type is stored. /// private const ushort HeaderEventTypeIndex = 7; /// /// The total length of the header of an event, in bytes. /// private const ushort HeaderLength = 8; /// /// The amount of parts of which the header consists. /// private const ushort HeaderPartsCount = 4; /// /// The multiplier from Unity time to a timestamp. /// private const int TimestampMultiplier = 1000; /// /// The zero value of a timestamp. Anything below this value is negative. /// private const uint TimestampZeroValue = 10000; /// /// The delay at which the receiving side replays events. /// private const float Delay = 1f; #endregion #region Private attributes /// /// Objects which are controlled by this . /// [SerializeField] private SyncedObject[] syncedObjects; /// /// Whether the current perspective is the transmitting side. /// private bool isOwner; /// /// Whether the current perspective is synced with the owner. (Always true if current perspective is owner.) /// private bool isSynced; /// /// Offset from system time to network time, including delay. /// private float offsetTime; /// /// Time since last full sync, captured when this FixedUpdate started, with network delay applied. /// private float internalTime; /// /// Time at which next received event occured. /// private float nextEventTime; /// /// The timestamp of the most recent event created or received. /// private uint lastEventTimestamp; /// /// The message id of the most recent event created or received. /// private byte lastEventId; /// /// Amounot of retries in a row without a successful sync. /// private int retriesWithoutSuccess; /// /// Main buffer of data to be transmitted or processed /// private byte[][] buffer; /// /// Index of . /// private int bufferIndex; /// /// Data which is currently available on the network. /// [UdonSynced] private byte[] networkedData = new byte[0]; #endregion #region Public fields /// /// Whether this is ready to transmit or receive data. /// If false, networking is disabled and this acts as a pass-through. /// public bool Ready { get; private set; } = false; /// /// The time since last full sync which is currently being simulated. /// public float SyncedTime { get; private set; } /// /// Time since the last simulation, in seconds. /// public float Dt { get; private set; } /// /// Is the current simulation to prepare for applying a network event? /// True = Yes, This update is preparing for a network update. /// False = No, this update is after the network update or there was no /// public bool IsEventUpdate { get; private set; } /// /// Is the local user owner? /// public bool IsOwner => isOwner; #endregion #region General public void Awake() { offsetTime = Time.fixedTime; } 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; } SetOwner(Networking.IsOwner(gameObject)); buffer = new byte[BufferMaxTotalEvents][]; bufferIndex = 0; isSynced = isOwner; // Owner is always synced retriesWithoutSuccess = 0; lastEventTimestamp = 0; lastEventId = 0; offsetTime = Time.fixedTime; internalTime = 0; SyncedTime = 0; Dt = Time.fixedDeltaTime; Ready = true; Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Initialized, time offset: {offsetTime}"); } public void FixedUpdate() { UpdateInternalTime(); if (!isOwner) { ProgressEventTime(); } PerformFixedSyncedUpdate(); } public void UpdateInternalTime() { internalTime = Time.fixedTime - offsetTime; } private void PerformFixedSyncedUpdate() { IsEventUpdate = false; ProgressSyncedTime(internalTime); foreach (var obj in syncedObjects) { obj.SyncedUpdate(); } } private void HandleError(bool clearBuffer) { 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."); if (clearBuffer) { ClearBuffer(); } isSynced = false; 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); } } #endregion #region Sender public void SendEvent(NetworkEventType eventType) { if (!isOwner) { Debug.LogError($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Attempted {nameof(SendEvent)} while not the owner!"); return; } var timestamp = TimeToTimestamp(SyncedTime); var eventId = GetNextEventId(lastEventId); InitializeEvent(eventType, timestamp, eventId, BufferMaxTotalEvents, out byte[][] data, out var index); foreach (var obj in syncedObjects) { obj.AppendSyncedData(data, ref index, eventType); } var oldIndex = this.bufferIndex; var result = Flatten(data, 0, index); // Validate and fill in event size var eventSize = result.Length; if (eventSize > ushort.MaxValue || eventSize < 0) { Debug.LogError($"({nameof(PacManUdon)} {nameof(NetworkManager)}) New event is too large or negative! Size is {eventSize}, maximum allowed is {ushort.MaxValue}"); HandleError(false); return; } var eventSizeBytes = BitConverter.GetBytes((ushort)eventSize); Array.Copy(eventSizeBytes, 0, result, HeaderEventSizeIndex, eventSizeBytes.Length); AppendEventToBuffer(result); Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Prepared event with {eventSize} bytes and timestamp {timestamp} for serialization, index went from {oldIndex} to {this.bufferIndex}"); RequestSerialization(); lastEventTimestamp = timestamp; lastEventId = eventId; retriesWithoutSuccess = 0; // We had success! } private static void InitializeEvent(NetworkEventType eventType, uint timestamp, byte eventId, int maxSize, out byte[][] data, out int index) { // Create header var timestampBytes = BitConverter.GetBytes(timestamp); var header = new byte[HeaderLength]; // Event size is added later Array.Copy(timestampBytes, 0, header, HeaderTimestampIndex, timestampBytes.Length); header[HeaderEventIdIndex] = eventId; header[HeaderEventTypeIndex] = Int32ToByte((int)eventType); // Initialize event container data = new byte[maxSize][]; data[0] = header; index = 1; } [NetworkCallable] public void RequestEventReceived(NetworkEventType eventType) { SendEvent(eventType); } #endregion #region Receiver 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); } private void StoreIncomingData() { if (networkedData == null || networkedData.Length == 0) { return; // Nothing to store } Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Received {networkedData.Length} bytes!\nBytes received:\n{BytesToString(networkedData)}"); var length = networkedData.Length; int index = 0; int eventSize = 0; // Store event size here so we can increment the index no matter how we increment the loop while (true) { index += eventSize; if (index >= length) { break; } if (length - index < HeaderLength) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) {nameof(StoreIncomingData)}: Remaining data in networkedData is not long enough to form a complete event! remaining: {length - index}."); HandleError(false); return; } eventSize = GetEventSizeFromHeader(networkedData, index); if (length - index < eventSize) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) {nameof(StoreIncomingData)}: Event size is larger than total remaining data! {nameof(eventSize)}: {eventSize}, remaining: {length - index}."); HandleError(false); return; } if (length - index < HeaderLength) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) {nameof(StoreIncomingData)}: Event size is not long enough to form a complete event! {nameof(eventSize)}: {eventSize}, minimum needed: {HeaderLength}."); HandleError(false); return; } var @event = GetArrayPart(networkedData, index, eventSize); var eventType = GetEventTypeFromHeader(@event); if (eventType == NetworkEventType.FullSync) { ProcessIncomingFullSync(@event); // Immediately process full sync 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."); continue; } var timestamp = GetTimestampFromHeader(@event); var eventId = GetEventIdFromHeader(@event); if (timestamp == lastEventTimestamp && eventId == lastEventId) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Duplicate message of type {eventType}, timestamp: {timestamp}, messageId: {eventId}."); continue; } if (eventId != GetNextEventId(lastEventId)) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) EventIds were not sequential! Did we miss a serialization? Timestamp: {timestamp}, eventId: {eventId}, lastEventId: {lastEventId}."); HandleError(false); return; } AppendEventToBuffer(@event); lastEventTimestamp = timestamp; lastEventId = eventId; } UpdateNextEventTime(); } private void ProcessIncomingFullSync(byte[] @event) { // Intentionally not doing a buffer size check here, since this is not appended to the buffer // (and there is no good way to continue if this event is too large) // Clear buffer and put the full sync into it ClearBuffer(); AppendEventToBuffer(@event); // Sync up to the time in the full sync var timestamp = GetTimestampFromHeader(@event); var eventId = GetEventIdFromHeader(@event); SyncToTimestamp(timestamp, eventId); // Immediately apply the full sync UpdateNextEventTime(ignoreOrder: true); isSynced = true; } private void ProgressEventTime() { IsEventUpdate = true; while (bufferIndex != 0 && nextEventTime <= internalTime) { ProcessIncomingEvent(); UpdateNextEventTime(); } } private void ProcessIncomingEvent() { var @event = NextEvent; var timestamp = GetTimestampFromHeader(@event); var eventType = GetEventTypeFromHeader(@event); var index = (int)HeaderLength; // Skip header ProgressSyncedTime(timestamp); foreach (var obj in syncedObjects) { obj.SyncedUpdate(); } foreach (var obj in syncedObjects) { var success = obj.SetSyncedData(@event, ref index, eventType); if (!success) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Malformed data reported by {obj.name} during event type {eventType}!"); HandleError(true); return; } } var eventSize = GetEventSizeFromHeader(@event); if (index != eventSize) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Amount of data read does not match event size! Expected {eventSize}, read {index}."); HandleError(true); return; } RemoveProcessedDataFromBuffer(1); Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Processed incoming event! Total {index} bytes."); retriesWithoutSuccess = 0; // We had success! } #endregion #region Buffer private void ClearBuffer() { buffer = new byte[BufferMaxTotalEvents][]; bufferIndex = 0; } private void RemoveProcessedDataFromBuffer(int amountProcessed) { var oldBuffer = buffer; bufferIndex -= amountProcessed; buffer = new byte[BufferMaxTotalEvents][]; Array.Copy(oldBuffer, amountProcessed, buffer, 0, bufferIndex); } private bool AppendEventToBuffer(byte[] @event) { if (bufferIndex >= BufferMaxTotalEvents) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Buffer not large enough to store event! Maximum event count: {BufferMaxTotalEvents}."); HandleError(true); return false; } buffer[bufferIndex++] = @event; return true; } private byte[] NextEvent => buffer[0]; #endregion #region Time private void ProgressSyncedTime(float newTime) { //Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) updating SyncedTime from {SyncedTime} to {newTime}"); Dt = newTime - SyncedTime; if (Dt < 0) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Negative Dt: {Dt}! Going from {SyncedTime} to {newTime}, IsEventUpdate: {IsEventUpdate}"); } SyncedTime = newTime; } private void SyncToTimestamp(uint timestamp, byte eventId) { var oldOffset = offsetTime; var timeToSyncTo = TimestampToTime(timestamp) - Delay; offsetTime = Time.fixedTime - timeToSyncTo; var delta = offsetTime - oldOffset; internalTime = internalTime - delta; SyncedTime = SyncedTime - delta; lastEventTimestamp = timestamp; lastEventId = eventId; Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Synced to timestamp {timestamp}, current time is {Time.fixedTime}, timeToSyncTo is {timeToSyncTo}, offsetTime is now {offsetTime}, internalTime is now {internalTime}, SyncedTime is now {SyncedTime}"); } private void UpdateNextEventTime(bool ignoreOrder = false) { if (bufferIndex == 0) { return; } var nextEventTime = TimestampToTime(GetTimestampFromHeader(NextEvent)); if (ignoreOrder || nextEventTime >= this.nextEventTime) { this.nextEventTime = nextEventTime; } else { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) New event is earlier than previous event!"); HandleError(true); return; } } public static uint TimeToTimestamp(float time) { return (uint)((time * TimestampMultiplier) + TimestampZeroValue); } public static float TimestampToTime(uint timeStamp) { return (timeStamp - (long)TimestampZeroValue) / (float)TimestampMultiplier; // Use a long here to prevent an underflow } #endregion #region Header public static byte GetNextEventId(byte currentEventId) { if (currentEventId == byte.MaxValue) // Udon forces overflow checks { return 0; } currentEventId += 1; return currentEventId; } public static ushort GetEventSizeFromHeader(byte[] @event, int eventIndex = 0) => BitConverter.ToUInt16(@event, eventIndex + HeaderEventSizeIndex); public static NetworkEventType GetEventTypeFromHeader(byte[] @event, int eventIndex = 0) => (NetworkEventType)@event[eventIndex + HeaderEventTypeIndex]; public static uint GetTimestampFromHeader(byte[] @event, int eventIndex = 0) => BitConverter.ToUInt32(@event, eventIndex + HeaderTimestampIndex); public static byte GetEventIdFromHeader(byte[] @event, int eventIndex = 0) => @event[eventIndex + HeaderEventIdIndex]; #endregion #region VRC events public override void OnOwnershipTransferred(VRCPlayerApi newOwner) { SetOwner(newOwner == Networking.LocalPlayer); } private int indexAtLastSerialization = 0; public override void OnPreSerialization() { if (!Ready) { return; } if (isOwner) { if (buffer == null || bufferIndex == 0) { return; } networkedData = Flatten(buffer, 0, bufferIndex); indexAtLastSerialization = bufferIndex; } else { networkedData = new byte[0]; // Prevent exception loop in VRChat SDK } } public override void OnPostSerialization(SerializationResult result) { if (!Ready) { return; } 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(indexAtLastSerialization); networkedData = new byte[0]; } public override void OnDeserialization() { if (!Ready || isOwner) { return; } StoreIncomingData(); } #endregion #region Utils public string BytesToString(byte[] bytes) { var sb = new StringBuilder("new byte[] { "); foreach (var b in bytes) { sb.Append(b + ", "); } sb.Append("}"); return sb.ToString(); } private static int GetFlattenedSize(byte[][] data, int start, int length) { var size = 0; for (int i = start; i < start + length; i++) { size += data[i].Length; } return size; } private static byte[] Flatten(byte[][] data, int start, int length) { var finalLength = GetFlattenedSize(data, start, length); var result = new byte[finalLength]; int resultIndex = 0; for (int sourceIndex = start; sourceIndex < start + length; sourceIndex++) { var array = data[sourceIndex]; Array.Copy(array, 0, result, resultIndex, array.Length); resultIndex += array.Length; } return result; } private static byte[] GetArrayPart(byte[] data, int start, int length) { var result = new byte[length]; Array.Copy(data, start, result, 0, length); return result; } public static byte Int32ToByte(int value) => (byte)value; // Doing this inline causes an error...? #endregion #region Debug public void SimulateSyncToTimestamp(uint timestamp) { SyncToTimestamp(timestamp, 0); } public void WriteDebugOutput(TMP_InputField debugOutput) { debugOutput.text += $"{nameof(NetworkManager)}:\n" + $"IsOwner: {isOwner}\n" + $"Ready: {Ready}\n" + $"Time.fixedTime: {Time.fixedTime}\n" + $"offsetTime: {offsetTime}\n" + $"internalTime: {internalTime}\n" + $"SyncedTime: {SyncedTime}\n" + $"Dt: {Dt}\n" + $"\n"; } /// /// An animator which visualizes whether the current perspective is the owner. /// [SerializeField] private Animator DebugImageToIndicateOwner; #endregion } }