using System; using System.Text; using TMPro; using UdonSharp; using UnityEngine; using VRC.SDK3.UdonNetworkCalling; using VRC.SDKBase; using VRC.Udon.Common; namespace Marro.PacManUdon { public enum NetworkEventType { FullSyncForced = 0, FullSync = 1, PacManTurn = 2, StartGameButtonPressed = 3, SyncPellets = 4, GhostUpdate = 5, } 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. // Each event contains a header. The first part contains the size of the event as a ushort, allowing a maximum size of 65,536 bytes. // After that timestamp is transferred in seconds since the owner started the game as a float, matching Unity's time representation. // Afterwards is a message id, a single byte which increments (with overflow) for every event to confirm data completeness. // Lastly, a single byte indicates the type of event. 0 is a full sync, which is reserved for syncing up from any unknown state. // 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]: (float) 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 Settings /// /// The root from which this will look for to control. /// [SerializeField] private GameObject root; /// /// The delay at which the receiving side replays events. /// [SerializeField] private float delay = 1f; /// /// The maximum amount of times a message is sent. /// [SerializeField] private int maxEventSendTries = 3; /// /// How long to wait since last message to send next ping. /// [SerializeField] private float pingDelay = 0.3f; #endregion #region Constants /// /// The maximum amount of events in the buffer. /// private const int BufferMaxTotalEvents = 255; /// /// The maximum amount of events in the buffer. /// private const int MaxEventSize = ushort.MaxValue; /// /// 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; #endregion #region Private attributes /// /// Objects which are controlled by this . /// private SyncedObject[] syncedObjects; /// /// 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; /// /// Amounot of retries in a row without a successful sync. /// private int retriesWithoutSuccess; /// /// For receiver: True if there's a full sync in the queue and we are not synced, otherwise false. /// For transmitter: True if there's a full sync in the queue which has not yet been transmitted, otherwise false. /// private bool hasFullSyncReady; /// /// True if serialization has been requestsed /// private bool serializationRequested; /// /// Events to send at the end of SyncedUpdate cycle /// private NetworkEventType[] eventsToSend; /// /// Index for /// private int eventsToSendIndex; /// /// Queue of events to be transmitted or processed. /// private byte[][] eventsQueue; /// /// Index of . /// private int eventsQueueIndex; /// /// The value of at the last transmission. /// private int eventsQueueIndexAtLastTransmission; /// /// Counts of new events at recent transmissions. /// private int[] eventTransmissionHistory; /// /// Index of . /// private int eventTransmissionHistoryIndex; /// /// Time of last event transmission. /// private float lastEventTransmissionTime; /// /// The message id of the most recent event created or received. /// private byte lastEventId; /// /// 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; /// /// Whether the current perspective is synced with the owner. (Always true if current perspective is owner.) /// public bool Synced { 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 SyncedDeltaTime { 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 network update this cycle. /// public bool IsEventUpdate { get; private set; } /// /// Is the local user owner? /// public bool IsOwner { get; private set; } #endregion #region General public void Awake() { // I would set the instance variable here, but it's somehow null by the time Initialize() is called... Udon moment? var syncedObjects = root.GetComponentsInChildren(includeInactive: true); foreach (var obj in syncedObjects) { obj.networkManager = this; } } public void Initialize() { if (!BitConverter.IsLittleEndian) { Debug.LogError($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Fatal: NetworkManager only supports little endian! Network sync will not be possible."); Ready = false; return; } syncedObjects = root.GetComponentsInChildren(includeInactive: true); foreach (var obj in syncedObjects) { obj.networkManager = this; } Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Found {syncedObjects.Length} {nameof(SyncedObject)} in children of {root.name}."); SetOwner(Networking.IsOwner(gameObject)); ClearBuffer(); Synced = IsOwner; // Owner is always synced retriesWithoutSuccess = 0; hasFullSyncReady = false; offsetTime = Time.fixedTime; internalTime = 0; SyncedTime = 0; SyncedDeltaTime = Time.fixedDeltaTime; Ready = true; Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Initialized, time offset: {offsetTime}"); } public void FixedUpdate() { if (!Ready) { return; } // Fetch the current time UpdateInternalTime(); if (Ready) { if (IsOwner) { ProgressPingTime(); // See if we need to send a ping } else { ProgressEventTime(); // See if there's events that need to be replayed } } // Forwards simulated time at the FixedUpdate pace PerformFixedSyncedUpdate(); if (IsOwner) { ProcessEventsToSend(); } } private void UpdateInternalTime() { internalTime = Time.fixedTime - offsetTime; } private void PerformFixedSyncedUpdate() { IsEventUpdate = false; ProgressSyncedTime(internalTime); for (int i = 0; i < syncedObjects.Length; i++) { var obj = syncedObjects[i]; if (obj.gameObject.activeInHierarchy) { obj.SyncedUpdate(); } } } private void HandleError(bool clearBuffer) { retriesWithoutSuccess++; if (retriesWithoutSuccess > 3) { Debug.LogError($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Fatal: Retried 3 times without success."); Ready = false; return; } Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Encountered data error, attempting to recover via full sync."); if (clearBuffer) { ClearBuffer(); } Synced = false; if (!IsOwner) { RequestEvent(NetworkEventType.FullSync); } else { SendEventSoon(NetworkEventType.FullSyncForced); } } private void SetOwner(bool isOwner) { IsOwner = isOwner; if (DebugImageToIndicateOwner != null) { DebugImageToIndicateOwner.SetFloat("Color", isOwner ? 1 : 0); } } #endregion #region Sender public void SendEventSoon(NetworkEventType eventType) { if (!Ready) { return; } if (!IsOwner) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Attempted {nameof(SendEventSoon)} while not the owner!"); return; } if (eventsQueueIndex > eventsToSend.Length) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) {nameof(eventsToSend)} overflow!"); HandleError(false); return; } eventsToSend[eventsToSendIndex++] = eventType; } private void ProcessEventsToSend() { for (int i = 0; i < eventsToSendIndex; i++) { SendEventNow(eventsToSend[0]); } eventsToSendIndex = 0; } public void SendEventNow(NetworkEventType eventType) { if (!Ready) { return; } if (!IsOwner) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Attempted {nameof(SendEventNow)} while not the owner!"); return; } var timestamp = SyncedTime; var eventId = GetNextEventId(lastEventId); InitializeEvent(eventType, timestamp, eventId, out byte[] data, out var index); foreach (var obj in syncedObjects) { obj.CollectSyncedData(data, ref index, eventType); } // Validate and fill in event size var eventSizeBytes = BitConverter.GetBytes((ushort)index); Array.Copy(eventSizeBytes, 0, data, HeaderEventSizeIndex, eventSizeBytes.Length); if (IsFullSync(eventType)) { hasFullSyncReady = true; } data = GetArrayPart(data, 0, index); QueueEventInBuffer(data); Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Prepared event of type {eventType} with {data.Length} bytes, timestamp {timestamp} and id {eventId} for serialization."); RequestSerializationForEvents(); lastEventId = eventId; retriesWithoutSuccess = 0; // We had success! } private static void InitializeEvent(NetworkEventType eventType, float timestamp, byte eventId, out byte[] data, out int index) { data = new byte[MaxEventSize]; // Create header (note: event size is added later) var timestampBytes = BitConverter.GetBytes(timestamp); Array.Copy(timestampBytes, 0, data, HeaderTimestampIndex, timestampBytes.Length); data[HeaderEventIdIndex] = eventId; data[HeaderEventTypeIndex] = Int32ToByte((int)eventType); index = HeaderLength; } private void RequestSerializationForEvents() { RequestSerialization(); serializationRequested = true; } [NetworkCallable] public void RequestEventReceived(NetworkEventType eventType) { if (!Ready) { return; } if (IsFullSync(eventType) && hasFullSyncReady) { return; // Don't send another full sync if we're already preparing to send one } if (eventType == NetworkEventType.FullSyncForced) { SendEventSoon(NetworkEventType.FullSync); // Remote is not allowed to request a forced full sync return; } SendEventSoon(eventType); } private void ProgressPingTime() { if (eventsQueueIndex > 0 && internalTime - lastEventTransmissionTime >= pingDelay) { RequestSerializationForEvents(); } } public override void OnPreSerialization() { if (!Ready || !IsOwner || !serializationRequested || eventsQueue == null || eventsQueueIndex == 0) { return; } networkedData = Flatten(eventsQueue, 0, eventsQueueIndex); eventTransmissionHistory[eventTransmissionHistoryIndex] = eventsQueueIndex - eventsQueueIndexAtLastTransmission; eventTransmissionHistoryIndex += 1; if (eventTransmissionHistoryIndex >= eventTransmissionHistory.Length) { eventTransmissionHistoryIndex = 0; } serializationRequested = false; Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Serializing {eventsQueueIndex} event(s), eventTransmissionHistory is now {ArrayToString(eventTransmissionHistory)} with index {eventTransmissionHistoryIndex}"); } public override void OnPostSerialization(SerializationResult result) { if (!Ready || !IsOwner || networkedData.Length == 0) { return; } if (!result.success) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Serialization failed! Tried to send {result.byteCount} bytes."); return; } Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Serialized with {networkedData.Length} bytes.\nBytes sent:\n{ArrayToString(networkedData)}"); // Remove data from the buffer that no longer needs to be (re)transmitted DequeueEventsFromBuffer(eventTransmissionHistory[eventTransmissionHistoryIndex]); eventTransmissionHistory[eventTransmissionHistoryIndex] = 0; // Prevent trying to remove the same events again eventsQueueIndexAtLastTransmission = eventsQueueIndex; networkedData = new byte[0]; // If there was a full sync in the queue, it has now been transmitted at least once hasFullSyncReady = false; lastEventTransmissionTime = internalTime; } #endregion #region Receiver public void RequestEvent(NetworkEventType eventType) { if (!Ready) { return; } 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{ArrayToString(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 ((index += eventSize) < length) { 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 timestamp = GetTimestampFromHeader(@event); var eventId = GetEventIdFromHeader(@event); var eventType = GetEventTypeFromHeader(@event); if (eventType != NetworkEventType.FullSyncForced && (Synced || hasFullSyncReady)) { // Check if event id is sequential if (eventId != GetNextEventId(lastEventId)) { if (index + eventSize >= length // If this is the last event of the batch && eventId != lastEventId) // Unless the eventId is the same, then this is probably just a duplicate { // The last event of the batch has to be synced up with our current event id count, else we've missed an event Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) EventIds were not sequential! Did we miss an event? Timestamp: {timestamp}, eventId: {eventId}, lastEventId: {lastEventId}."); HandleError(false); return; } // We've likely already processed this event, ignore it continue; } QueueEventInBuffer(@event); Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)} Queued event with id {eventId}"); } else { // If we're not yet synced, we only care about full sync events. if (IsFullSync(eventType)) { QueueFullSyncForReplay(@event); // Immediately process full sync } } lastEventId = eventId; } if (Synced) { UpdateNextEventTime(); } } private void QueueFullSyncForReplay(byte[] @event) { // Clear buffer and put the full sync into it ClearBuffer(); QueueEventInBuffer(@event); // Set this event to play after the default delay nextEventTime = internalTime + delay; hasFullSyncReady = true; Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Queued full sync in buffer, should execute at {nextEventTime}."); } private void ProgressEventTime() { IsEventUpdate = true; while (eventsQueueIndex > 0 && nextEventTime <= internalTime) { var success = PerformEvent(eventsQueue[0]); if (!success) { return; } DequeueEventsFromBuffer(1); UpdateNextEventTime(); } } private bool PerformEvent(byte[] @event) { var timestamp = GetTimestampFromHeader(@event); var eventType = GetEventTypeFromHeader(@event); var isFullSync = IsFullSync(eventType); if (!Synced || eventType == NetworkEventType.FullSyncForced) { SyncToTimestamp(timestamp); } var index = (int)HeaderLength; // Skip header ProgressSyncedTime(timestamp); foreach (var obj in syncedObjects) { obj.SyncedUpdate(); } foreach (var obj in syncedObjects) { var success = obj.WriteSyncedData(@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 false; } } 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 false; } if (!Synced && isFullSync) { hasFullSyncReady = false; Synced = true; } Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Processed incoming event! Total {index} bytes."); retriesWithoutSuccess = 0; // We had success! return true; } public override void OnDeserialization() { if (!Ready || IsOwner) { return; } StoreIncomingData(); } #endregion #region Buffer private void ClearBuffer() { eventsQueue = new byte[BufferMaxTotalEvents][]; eventsQueueIndex = 0; lastEventId = 0; hasFullSyncReady = false; eventTransmissionHistory = new int[maxEventSendTries]; eventTransmissionHistoryIndex = 0; eventsQueueIndexAtLastTransmission = 0; eventsToSend = new NetworkEventType[BufferMaxTotalEvents]; eventsToSendIndex = 0; } private void DequeueEventsFromBuffer(int eventCount) { if (eventCount > eventsQueueIndex) // Never remove more events than are in the queue { eventCount = eventsQueueIndex; } var oldBuffer = eventsQueue; eventsQueueIndex -= eventCount; eventsQueue = new byte[BufferMaxTotalEvents][]; Array.Copy(oldBuffer, eventCount, eventsQueue, 0, eventsQueueIndex); } private bool QueueEventInBuffer(byte[] @event) { if (eventsQueueIndex >= BufferMaxTotalEvents) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Buffer not large enough to store event! Maximum event count: {BufferMaxTotalEvents}."); HandleError(true); return false; } eventsQueue[eventsQueueIndex++] = @event; return true; } #endregion #region Time private void ProgressSyncedTime(float newTime) { //Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) updating SyncedTime from {SyncedTime} to {newTime}"); SyncedDeltaTime = newTime - SyncedTime; if (SyncedDeltaTime < 0) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Negative Dt: {SyncedDeltaTime}! Going from {SyncedTime} to {newTime}, IsEventUpdate: {IsEventUpdate}"); } SyncedTime = newTime; } private void SyncToTimestamp(float timestamp) { var oldOffset = offsetTime; offsetTime = Time.fixedTime - timestamp; var delta = offsetTime - oldOffset; internalTime -= delta; SyncedTime -= delta; nextEventTime -= delta; Debug.Log($"({nameof(PacManUdon)} {nameof(NetworkManager)}) Synced to timestamp {timestamp}, current time is {Time.fixedTime}, offsetTime is now {offsetTime}, internalTime is now {internalTime}, SyncedTime is now {SyncedTime}, nextEventTime is now {nextEventTime}"); } private void UpdateNextEventTime() { if (eventsQueueIndex == 0) { return; } var nextEventTime = GetTimestampFromHeader(eventsQueue[0]); if (nextEventTime < this.nextEventTime) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) New event is earlier than previous event!"); HandleError(true); return; } if (nextEventTime < SyncedTime) { Debug.LogWarning($"({nameof(PacManUdon)} {nameof(NetworkManager)}) New event timestamp is earlier than our current synced time by {SyncedTime - nextEventTime} seconds! nextEventTime: {nextEventTime} SyncedTime: {SyncedTime}, internalTime: {internalTime}"); HandleError(true); return; } this.nextEventTime = nextEventTime; } #endregion #region Header private static byte GetNextEventId(byte currentEventId) { if (currentEventId == byte.MaxValue) // Udon forces overflow checks { return 0; } currentEventId += 1; return currentEventId; } private static bool IsFullSync(NetworkEventType eventType) => eventType == NetworkEventType.FullSync || eventType == NetworkEventType.FullSyncForced; private static ushort GetEventSizeFromHeader(byte[] @event, int eventIndex = 0) => BitConverter.ToUInt16(@event, eventIndex + HeaderEventSizeIndex); private static NetworkEventType GetEventTypeFromHeader(byte[] @event, int eventIndex = 0) => (NetworkEventType)@event[eventIndex + HeaderEventTypeIndex]; private static float GetTimestampFromHeader(byte[] @event, int eventIndex = 0) => BitConverter.ToSingle(@event, eventIndex + HeaderTimestampIndex); private static byte GetEventIdFromHeader(byte[] @event, int eventIndex = 0) => @event[eventIndex + HeaderEventIdIndex]; #endregion #region VRC events public override void OnOwnershipTransferred(VRCPlayerApi newOwner) { if (!Ready) { return; } bool newOwnerIsLocalPlayer = newOwner == Networking.LocalPlayer; SetOwner(newOwnerIsLocalPlayer); if (newOwnerIsLocalPlayer) { SendEventSoon(NetworkEventType.FullSyncForced); } } #endregion #region Utils public string ArrayToString(byte[] bytes) { var sb = new StringBuilder("[ "); foreach (var b in bytes) { sb.Append(b + " "); } sb.Append("]"); return sb.ToString(); } public string ArrayToString(int[] bytes) { var sb = new StringBuilder("[ "); 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(float timestamp) { SyncToTimestamp(timestamp); } public void WriteDebugOutput(TMP_InputField debugOutput) { debugOutput.text += $"{nameof(NetworkManager)}:\n" + $"IsOwner: {IsOwner}\n" + $"Ready: {Ready}\n" + $"Synced: {Synced}\n" + $"hasFullSyncReady: {hasFullSyncReady}\n" + $"lastEventId: {lastEventId}" + $"Time.fixedTime: {Time.fixedTime}\n" + $"offsetTime: {offsetTime}\n" + $"internalTime: {internalTime}\n" + $"SyncedTime: {SyncedTime}\n" + $"Dt: {SyncedDeltaTime}\n" + $"BufferIndex: {eventsQueueIndex}\n" + $"BufferIndexHistory: {ArrayToString(eventTransmissionHistory)}\n" + $"\n"; } /// /// An animator which visualizes whether the current perspective is the owner. /// [SerializeField] private Animator DebugImageToIndicateOwner; public void DoFullSync() { SendEventSoon(NetworkEventType.FullSync); } public void DoPelletSync() { SendEventSoon(NetworkEventType.SyncPellets); } #endregion } }