using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using UdonSharp.Compiler; using UnityEditor; using UnityEngine; using VRC.Udon.Common.Interfaces; namespace UdonSharp { public static class RuntimeLogWatcher { class LogFileState { public string playerName; public long lineOffset = -1; public string nameColor = "0000ff"; } static Queue debugOutputQueue = new Queue(); static Dictionary scriptLookup; // Log watcher vars static FileSystemWatcher logDirectoryWatcher; static object logModifiedLock = new object(); static Dictionary logFileStates = new Dictionary(); static HashSet modifiedLogPaths = new HashSet(); public static void InitLogWatcher() { EditorApplication.update += OnEditorUpdate; Application.logMessageReceived += OnLog; } static bool ShouldListenForVRC() { UdonSharpSettings udonSharpSettings = UdonSharpSettings.GetSettings(); if (udonSharpSettings == null) return false; if (udonSharpSettings.listenForVRCExceptions || udonSharpSettings.watcherMode != UdonSharpSettings.LogWatcherMode.Disabled) return true; return false; } static bool InitializeScriptLookup() { if (EditorApplication.isCompiling || EditorApplication.isUpdating) return false; if (logDirectoryWatcher == null && ShouldListenForVRC()) { AssemblyReloadEvents.beforeAssemblyReload += CleanupLogWatcher; // Now setup the filesystem watcher string[] splitPath = Application.persistentDataPath.Split('/', '\\'); string VRCDataPath = string.Join("\\", splitPath.Take(splitPath.Length - 2)) + "\\VRChat\\VRChat"; if (Directory.Exists(VRCDataPath)) { logDirectoryWatcher = new FileSystemWatcher(VRCDataPath, "output_log_*.txt"); logDirectoryWatcher.IncludeSubdirectories = false; logDirectoryWatcher.NotifyFilter = NotifyFilters.LastWrite; logDirectoryWatcher.Changed += OnLogFileChanged; logDirectoryWatcher.InternalBufferSize = 1024; logDirectoryWatcher.EnableRaisingEvents = false; } else { Debug.LogError("[UdonSharp] Could not locate VRChat data directory for exception watcher"); } } if (scriptLookup != null) return true; scriptLookup = new Dictionary(); string[] udonSharpDataAssets = AssetDatabase.FindAssets($"t:{typeof(UdonSharpProgramAsset).Name}"); UdonSharpEditorCache editorCache = UdonSharpEditorCache.Instance; foreach (string dataGuid in udonSharpDataAssets) { UdonSharpProgramAsset programAsset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(dataGuid)); if (programAsset.sourceCsScript == null) continue; if (programAsset.GetSerializedProgramAssetWithoutRefresh() == null) continue; IUdonProgram program = programAsset.GetSerializedProgramAssetWithoutRefresh().RetrieveProgram(); if (program == null || program.Heap == null || program.SymbolTable == null) { //Debug.LogWarning($"Could not load program for '{programAsset}', exceptions for this script will not be handled until scripts have been reloaded"); continue; } long programID; if (program.SymbolTable.TryGetAddressFromSymbol(programAsset.behaviourIDHeapVarName, out uint address)) programID = program.Heap.GetHeapVariable(address); else { Debug.LogWarning($"No symbol found for debug info on program asset '{programAsset}', exceptions for this program will not be caught until scripts have been reloaded."); continue; } if (scriptLookup.ContainsKey(programID)) continue; scriptLookup.Add(programID, (AssetDatabase.GetAssetPath(programAsset.sourceCsScript), programAsset)); } return true; } static void CleanupLogWatcher() { if (logDirectoryWatcher != null) { logDirectoryWatcher.EnableRaisingEvents = false; logDirectoryWatcher.Changed -= OnLogFileChanged; logDirectoryWatcher.Dispose(); logDirectoryWatcher = null; } EditorApplication.update -= OnEditorUpdate; Application.logMessageReceived -= OnLog; AssemblyReloadEvents.beforeAssemblyReload -= CleanupLogWatcher; } static void OnLogFileChanged(object source, FileSystemEventArgs args) { lock (logModifiedLock) { modifiedLogPaths.Add(args.FullPath); } } static void OnLog(string logStr, string stackTrace, LogType type) { if (type == LogType.Error || type == LogType.Exception) { debugOutputQueue.Enqueue(logStr); } } const string MATCH_STR = "\\n\\n\\r\\n\\d{4}.\\d{2}.\\d{2} \\d{2}:\\d{2}:\\d{2} "; static Regex lineMatch; static void OnEditorUpdate() { if (!InitializeScriptLookup()) return; while (debugOutputQueue.Count > 0) { HandleLogError(debugOutputQueue.Dequeue(), "Udon runtime exception detected!", null); } UdonSharpSettings udonSharpSettings = UdonSharpSettings.GetSettings(); bool shouldListenForVRC = udonSharpSettings != null && ShouldListenForVRC(); if (logDirectoryWatcher != null) logDirectoryWatcher.EnableRaisingEvents = shouldListenForVRC; if (shouldListenForVRC) { if (lineMatch == null) lineMatch = new Regex(MATCH_STR, RegexOptions.Compiled); List<(string, string)> modifiedFilesAndContents = null; lock (logModifiedLock) { if (modifiedLogPaths.Count > 0) { modifiedFilesAndContents = new List<(string, string)>(); HashSet newLogPaths = new HashSet(); foreach (string logPath in modifiedLogPaths) { if (!logFileStates.TryGetValue(logPath, out LogFileState logState)) logFileStates.Add(logPath, new LogFileState()); logState = logFileStates[logPath]; string newLogContent = ""; newLogPaths.Add(logPath); try { FileInfo fileInfo = new FileInfo(logPath); using (var stream = fileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { using (StreamReader reader = new StreamReader(stream)) { if (logState.playerName == null) // Search for the player name that this log belongs to { string fullFileContents = reader.ReadToEnd(); const string SEARCH_STR = "[Behaviour] User Authenticated: "; int userIdx = fullFileContents.IndexOf(SEARCH_STR); if (userIdx != -1) { userIdx += SEARCH_STR.Length; int endIdx = userIdx; while (fullFileContents[endIdx] != '\r' && fullFileContents[endIdx] != '\n') endIdx++; // Seek to end of name string username = fullFileContents.Substring(userIdx, endIdx - userIdx); logState.playerName = username; // Use the log path as well since Build & Test can have multiple of the same display named users System.Random random = new System.Random((username + logPath).GetHashCode()); Color randomUserColor = Color.HSVToRGB((float)random.NextDouble(), 1.00f, EditorGUIUtility.isProSkin ? 0.9f : 0.6f); string colorStr = ColorUtility.ToHtmlStringRGB(randomUserColor); logState.nameColor = colorStr; } } if (logState.lineOffset == -1) { reader.BaseStream.Seek(0, SeekOrigin.End); } else { reader.BaseStream.Seek(logState.lineOffset - 4 < 0 ? 0 : logState.lineOffset - 4, SeekOrigin.Begin); // Subtract 4 characters to pick up the newlines from the prior line for the log forwarding } newLogContent = reader.ReadToEnd(); logFileStates[logPath].lineOffset = reader.BaseStream.Position; reader.Close(); } stream.Close(); } newLogPaths.Remove(logPath); if (newLogContent != "") modifiedFilesAndContents.Add((logPath, newLogContent)); } catch (System.IO.IOException) { } } modifiedLogPaths = newLogPaths; } } if (modifiedFilesAndContents != null) { foreach (var modifiedFile in modifiedFilesAndContents) { LogFileState state = logFileStates[modifiedFile.Item1]; // Log forwarding if (udonSharpSettings.watcherMode != UdonSharpSettings.LogWatcherMode.Disabled) { int currentIdx = 0; Match match = null; do { currentIdx = (match?.Index ?? -1); match = lineMatch.Match(modifiedFile.Item2, currentIdx + 1); string logStr = null; if (currentIdx == -1) { if (match.Success) { Match nextMatch = lineMatch.Match(modifiedFile.Item2, match.Index + 1); if (nextMatch.Success) logStr = modifiedFile.Item2.Substring(0, nextMatch.Index); else logStr = modifiedFile.Item2; match = nextMatch; } } else if (match.Success) { logStr = modifiedFile.Item2.Substring(currentIdx < 0 ? 0 : currentIdx, match.Index - currentIdx); } else if (currentIdx != -1) { logStr = modifiedFile.Item2.Substring(currentIdx < 0 ? 0 : currentIdx, modifiedFile.Item2.Length - currentIdx); } if (logStr != null) { logStr = logStr.Trim('\n', '\r'); HandleForwardedLog(logStr, state, udonSharpSettings); } } while (match.Success); } if (udonSharpSettings.listenForVRCExceptions) { // Exception handling const string errorMatchStr = "[UdonBehaviour] An exception occurred during Udon execution, this UdonBehaviour will be halted."; int currentErrorIndex = modifiedFile.Item2.IndexOf(errorMatchStr); while (currentErrorIndex != -1) { HandleLogError(modifiedFile.Item2.Substring(currentErrorIndex, modifiedFile.Item2.Length - currentErrorIndex), $"VRChat client runtime Udon exception detected!", $"{ state.playerName ?? "Unknown"}"); currentErrorIndex = modifiedFile.Item2.IndexOf(errorMatchStr, currentErrorIndex + errorMatchStr.Length); } } } } } } // Common messages that can spam the log and have no use for debugging static readonly string[] filteredPrefixes = new string[] { "Received Notification: {playername}]{message}"); else if (trimmedStr.StartsWith("Warning")) Debug.LogWarning($"[{playername}]{message}"); else if (trimmedStr.StartsWith("Error")) Debug.LogError($"[{playername}]{message}"); } static void HandleLogError(string errorStr, string logPrefix, string prePrefix) { if (errorStr.StartsWith("ExecutionEngineException: String conversion error: Illegal byte sequence encounted in the input.")) // Nice typo Mono { Debug.LogError("ExecutionEngineException detected! This means you have hit a bug in Mono. To fix this, move your project to a path without any unicode characters."); return; } UdonSharpEditorCache.DebugInfoType debugType; if (errorStr.StartsWith("[UdonBehaviour] An exception occurred during Udon execution, this UdonBehaviour will be halted.")) // Editor { debugType = UdonSharpEditorCache.DebugInfoType.Editor; } else if (errorStr.StartsWith("[UdonBehaviour] An exception occurred during Udon execution, this UdonBehaviour will be halted.")) // Client { debugType = UdonSharpEditorCache.DebugInfoType.Client; } else return; const string exceptionMessageStr = "Exception Message:"; const string seperatorStr = "----------------------"; int errorMessageStart = errorStr.IndexOf(exceptionMessageStr) + exceptionMessageStr.Length; if (errorMessageStart == -1) return; int errorMessageEnd = errorStr.IndexOf(seperatorStr, errorMessageStart); if (errorMessageEnd == -1 || errorMessageEnd < errorMessageStart) { if (debugType == UdonSharpEditorCache.DebugInfoType.Client) { errorMessageEnd = errorStr.IndexOf("\n\n\r\n"); if (errorMessageEnd != -1) errorStr = errorStr.Substring(0, errorMessageEnd); Debug.LogError($"{(prePrefix != null ? $"[{prePrefix}]" : "")} Runtime error detected, but the client has not been launched with '--enable-udon-debug-logging' so the error cannot be traced. Add the argument to your client startup and try again. \n{errorStr}"); } return; } string errorMessage = errorStr.Substring(errorMessageStart, errorMessageEnd - errorMessageStart).TrimStart('\n', '\r'); int programCounter; long programID; string programName; try { Match programCounterMatch = Regex.Match(errorStr, @"Program Counter was at: (?\d+)"); programCounter = int.Parse(programCounterMatch.Groups["counter"].Value); Match programTypeMatch = Regex.Match(errorStr, @"Heap Dump:[\n\r\s]+[\d]x[\d]+: (?[-]?[\d]+)[\n\r\s]+[\d]x[\d]+: (?[\w]+)"); programID = long.Parse(programTypeMatch.Groups["programID"].Value); programName = programTypeMatch.Groups["programName"].Value; } catch (System.Exception) { return; } (string, UdonSharpProgramAsset) assetInfo; if (!scriptLookup.TryGetValue(programID, out assetInfo)) return; if (assetInfo.Item2 == null) return; ClassDebugInfo debugInfo = UdonSharpEditorCache.Instance.GetDebugInfo(assetInfo.Item2, debugType); // No debug info was built if (debugInfo == null) return; ClassDebugInfo.DebugLineSpan debugLineSpan = debugInfo.GetLineFromProgramCounter(programCounter); UdonSharpUtils.LogRuntimeError($"{logPrefix}\n{errorMessage}", prePrefix != null ? $"[{prePrefix}]" : "", assetInfo.Item1, debugLineSpan.line, debugLineSpan.lineChar); } } }