From 653b4a8373db816516be4dca7a3a34ef8a3fe2c3 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sun, 30 Apr 2023 18:28:39 +0900 Subject: [PATCH 01/41] feat: initial commit for Error Reporting System Co-authored-by: bd_ --- Internal/ErrorReporter.meta | 3 + Internal/ErrorReporter/Editor.meta | 3 + .../Editor/ComponentValidation.cs | 185 +++++++++ .../Editor/ComponentValidation.cs.meta | 3 + Internal/ErrorReporter/Editor/ErrorElement.cs | 104 +++++ .../ErrorReporter/Editor/ErrorElement.cs.meta | 3 + Internal/ErrorReporter/Editor/ErrorLog.cs | 388 ++++++++++++++++++ .../ErrorReporter/Editor/ErrorLog.cs.meta | 3 + .../ErrorReporter/Editor/ErrorReportUI.cs | 304 ++++++++++++++ .../Editor/ErrorReportUI.cs.meta | 3 + .../ErrorReporter/Editor/NominalException.cs | 28 ++ .../Editor/NominalException.cs.meta | 3 + Internal/ErrorReporter/Editor/README.txt | 27 ++ Internal/ErrorReporter/Editor/README.txt.meta | 3 + Internal/ErrorReporter/Editor/Resources.meta | 3 + .../Resources/ModularAvatarErrorReport.uss | 91 ++++ .../ModularAvatarErrorReport.uss.meta | 11 + .../ErrorReporter/Editor/SelectionButton.cs | 32 ++ .../Editor/SelectionButton.cs.meta | 3 + ...zer.internal.prefab-safe-set.editor.asmdef | 18 + ...nternal.prefab-safe-set.editor.asmdef.meta | 3 + 21 files changed, 1221 insertions(+) create mode 100644 Internal/ErrorReporter.meta create mode 100644 Internal/ErrorReporter/Editor.meta create mode 100644 Internal/ErrorReporter/Editor/ComponentValidation.cs create mode 100644 Internal/ErrorReporter/Editor/ComponentValidation.cs.meta create mode 100644 Internal/ErrorReporter/Editor/ErrorElement.cs create mode 100644 Internal/ErrorReporter/Editor/ErrorElement.cs.meta create mode 100644 Internal/ErrorReporter/Editor/ErrorLog.cs create mode 100644 Internal/ErrorReporter/Editor/ErrorLog.cs.meta create mode 100644 Internal/ErrorReporter/Editor/ErrorReportUI.cs create mode 100644 Internal/ErrorReporter/Editor/ErrorReportUI.cs.meta create mode 100644 Internal/ErrorReporter/Editor/NominalException.cs create mode 100644 Internal/ErrorReporter/Editor/NominalException.cs.meta create mode 100644 Internal/ErrorReporter/Editor/README.txt create mode 100644 Internal/ErrorReporter/Editor/README.txt.meta create mode 100644 Internal/ErrorReporter/Editor/Resources.meta create mode 100644 Internal/ErrorReporter/Editor/Resources/ModularAvatarErrorReport.uss create mode 100644 Internal/ErrorReporter/Editor/Resources/ModularAvatarErrorReport.uss.meta create mode 100644 Internal/ErrorReporter/Editor/SelectionButton.cs create mode 100644 Internal/ErrorReporter/Editor/SelectionButton.cs.meta create mode 100644 Internal/ErrorReporter/Editor/com.anatawa12.avatar-optimizer.internal.prefab-safe-set.editor.asmdef create mode 100644 Internal/ErrorReporter/Editor/com.anatawa12.avatar-optimizer.internal.prefab-safe-set.editor.asmdef.meta diff --git a/Internal/ErrorReporter.meta b/Internal/ErrorReporter.meta new file mode 100644 index 000000000..0bf0afc5d --- /dev/null +++ b/Internal/ErrorReporter.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5b6a11b6cf8b4cb19d98e8b9133516b2 +timeCreated: 1682837060 diff --git a/Internal/ErrorReporter/Editor.meta b/Internal/ErrorReporter/Editor.meta new file mode 100644 index 000000000..41a972661 --- /dev/null +++ b/Internal/ErrorReporter/Editor.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 950e3fae7aac4e6594cb4e0854338f8f +timeCreated: 1682838469 diff --git a/Internal/ErrorReporter/Editor/ComponentValidation.cs b/Internal/ErrorReporter/Editor/ComponentValidation.cs new file mode 100644 index 000000000..d407bb3ac --- /dev/null +++ b/Internal/ErrorReporter/Editor/ComponentValidation.cs @@ -0,0 +1,185 @@ +using System.Collections.Generic; +using UnityEngine; +using VRC.SDK3.Avatars.Components; + +namespace Anatawa12.AvatarOptimizer.ErrorReporting +{ + internal static class ComponentValidation + { + /// + /// Validates the provided tag component. + /// + /// + /// Null if valid, otherwise a list of configuration errors + internal static List CheckComponent(this AvatarTagComponent tagComponent) + { + switch (tagComponent) + { + case ModularAvatarBlendshapeSync bs: + return CheckInternal(bs); + case ModularAvatarBoneProxy bp: + return CheckInternal(bp); + case ModularAvatarMenuInstaller mi: + return CheckInternal(mi); + case ModularAvatarMergeAnimator obj: + return CheckInternal(obj); + case ModularAvatarMergeArmature obj: + return CheckInternal(obj); + default: + return null; + } + } + + internal static List ValidateAll(GameObject root) + { + List logs = new List(); + foreach (var component in root.GetComponentsInChildren(true)) + { + var componentLogs = component.CheckComponent(); + if (componentLogs != null) + { + logs.AddRange(componentLogs); + } + } + + return logs; + } + + private static List CheckInternal(ModularAvatarBlendshapeSync bs) + { + var localMesh = bs.GetComponent(); + if (localMesh == null) + { + return new List + {new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.no_local_renderer", bs)}; + } + + if (localMesh.sharedMesh == null) + { + return new List + {new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.no_local_mesh", bs)}; + } + + if (bs.Bindings == null || bs.Bindings.Count == 0) + { + return new List + {new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.no_bindings", bs)}; + } + + List errorLogs = new List(); + foreach (var binding in bs.Bindings) + { + var localShape = string.IsNullOrWhiteSpace(binding.LocalBlendshape) + ? binding.Blendshape + : binding.LocalBlendshape; + + if (localMesh.sharedMesh.GetBlendShapeIndex(localShape) == -1) + { + errorLogs.Add(new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.missing_local_shape", + new string[] {localShape}, bs)); + } + + var targetObj = binding.ReferenceMesh.Get(bs.transform); + if (targetObj == null) + { + errorLogs.Add(new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.no_target", bs)); + continue; + } + + var targetRenderer = targetObj.GetComponent(); + if (targetRenderer == null) + { + errorLogs.Add(new ErrorLog(ReportLevel.Validation, + "validation.blendshape_sync.missing_target_renderer", bs, targetRenderer)); + continue; + } + + var targetMesh = targetRenderer.sharedMesh; + if (targetMesh == null) + { + errorLogs.Add(new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.missing_target_mesh", + bs, targetRenderer)); + continue; + } + + if (targetMesh.GetBlendShapeIndex(binding.Blendshape) == -1) + { + errorLogs.Add(new ErrorLog(ReportLevel.Validation, + "validation.blendshape_sync.missing_target_shape", new string[] {binding.Blendshape}, bs, + targetRenderer)); + } + } + + if (errorLogs.Count == 0) + { + return null; + } + else + { + return errorLogs; + } + } + + private static List CheckInternal(ModularAvatarBoneProxy bp) + { + if (bp.target == null) + { + return new List() + { + new ErrorLog(ReportLevel.Validation, "validation.bone_proxy.no_target", bp) + }; + } + + return null; + } + + private static List CheckInternal(ModularAvatarMenuInstaller mi) + { + // TODO - check that target menu is in the avatar + if (mi.menuToAppend == null && mi.GetComponent() == null) + { + return new List() + { + new ErrorLog(ReportLevel.Validation, "validation.menu_installer.no_menu", mi) + }; + } + + return null; + } + + private static List CheckInternal(ModularAvatarMergeAnimator ma) + { + if (ma.animator == null) + { + return new List() + { + new ErrorLog(ReportLevel.Validation, "validation.merge_animator.no_animator", ma) + }; + } + + return null; + } + + private static List CheckInternal(ModularAvatarMergeArmature ma) + { + if (ma.mergeTargetObject == null) + { + return new List() + { + new ErrorLog(ReportLevel.Validation, "validation.merge_armature.no_target", ma) + }; + } + + if (ma.mergeTargetObject == ma.gameObject || ma.mergeTargetObject.transform.IsChildOf(ma.transform)) + { + return new List() + { + new ErrorLog(ReportLevel.Validation, "error.merge_armature.merge_into_self", ma, + ma.mergeTargetObject) + }; + } + + return null; + } + } +} diff --git a/Internal/ErrorReporter/Editor/ComponentValidation.cs.meta b/Internal/ErrorReporter/Editor/ComponentValidation.cs.meta new file mode 100644 index 000000000..e589c3181 --- /dev/null +++ b/Internal/ErrorReporter/Editor/ComponentValidation.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3340de0ad53d42d1aa990be35777457a +timeCreated: 1682840642 diff --git a/Internal/ErrorReporter/Editor/ErrorElement.cs b/Internal/ErrorReporter/Editor/ErrorElement.cs new file mode 100644 index 000000000..9660141c0 --- /dev/null +++ b/Internal/ErrorReporter/Editor/ErrorElement.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.UIElements; + +namespace Anatawa12.AvatarOptimizer.ErrorReporting +{ + internal class ErrorElement : Box + { + private readonly ErrorLog log; + + Texture2D GetIcon() + { + switch (log.reportLevel) + { + case ReportLevel.Info: + return EditorGUIUtility.FindTexture("d_console.infoicon"); + case ReportLevel.Warning: + return EditorGUIUtility.FindTexture("d_console.warnicon"); + default: + return EditorGUIUtility.FindTexture("d_console.erroricon"); + } + } + + public ErrorElement(ErrorLog log, ObjectRefLookupCache cache) + { + this.log = log; + + AddToClassList("ErrorElement"); + var tex = GetIcon(); + if (tex != null) + { + var image = new Image(); + image.image = tex; + Add(image); + } + + var inner = new Box(); + Add(inner); + + var label = new Label(GetLabelText()); + inner.Add(label); + + foreach (var obj in log.referencedObjects) + { + var referenced = obj.Lookup(cache); + if (referenced != null) + { + inner.Add(new SelectionButton(obj.typeName, referenced)); + } + } + + if (!string.IsNullOrWhiteSpace(log.stacktrace)) + { + var foldout = new Foldout(); + foldout.text = Localization.S("error.stack_trace"); + var field = new TextField(); + field.value = log.stacktrace; + field.isReadOnly = true; + field.multiline = true; + foldout.Add(field); + foldout.value = false; + inner.Add(foldout); + } + } + + private static GameObject FindObject(string path) + { + var scene = SceneManager.GetActiveScene(); + foreach (var root in scene.GetRootGameObjects()) + { + if (root.name == path) return root; + if (path.StartsWith(root.name + "/")) + { + return root.transform.Find(path.Substring(root.name.Length + 1))?.gameObject; + } + } + + return null; + } + + private string GetLabelText() + { + var objArray = new object[log.substitutions.Length]; + for (int i = 0; i < log.substitutions.Length; i++) + { + objArray[i] = log.substitutions[i]; + } + + try + { + return string.Format(Localization.S(log.messageCode), objArray); + } + catch (FormatException e) + { + Debug.LogError("Error formatting message code: " + log.messageCode); + Debug.LogException(e); + return log.messageCode + "\n" + string.Join("\n", objArray); + } + } + } +} diff --git a/Internal/ErrorReporter/Editor/ErrorElement.cs.meta b/Internal/ErrorReporter/Editor/ErrorElement.cs.meta new file mode 100644 index 000000000..c264581f9 --- /dev/null +++ b/Internal/ErrorReporter/Editor/ErrorElement.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: fdf6fd69b5e34039b10619c37ac3b3f3 +timeCreated: 1682840642 diff --git a/Internal/ErrorReporter/Editor/ErrorLog.cs b/Internal/ErrorReporter/Editor/ErrorLog.cs new file mode 100644 index 000000000..71a70a3d6 --- /dev/null +++ b/Internal/ErrorReporter/Editor/ErrorLog.cs @@ -0,0 +1,388 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Newtonsoft.Json; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +using UnityEditor; +using UnityEngine.SceneManagement; +using UnityEngine.Serialization; +using Object = UnityEngine.Object; + +namespace Anatawa12.AvatarOptimizer.ErrorReporting +{ + internal class AvatarReport + { + [JsonProperty] internal ObjectRef objectRef; + + [JsonProperty] internal bool successful; + + [JsonProperty] internal List logs = new List(); + } + + internal class ObjectRefLookupCache + { + private Dictionary> _cache = + new Dictionary>(); + + internal UnityEngine.Object FindByGuidAndLocalId(string guid, long localId) + { + if (!_cache.TryGetValue(guid, out var fileContents)) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + if (string.IsNullOrEmpty(path)) + { + return null; + } + + var assets = AssetDatabase.LoadAllAssetsAtPath(path); + fileContents = new Dictionary(assets.Length); + foreach (var asset in assets) + { + if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out var _, out long detectedId)) + { + fileContents[detectedId] = asset; + } + } + + _cache[guid] = fileContents; + } + + if (fileContents.TryGetValue(localId, out var obj)) + { + return obj; + } + else + { + return null; + } + } + } + + internal struct ObjectRef + { + [JsonProperty] internal string guid; + [JsonProperty] internal long? localId; + [JsonProperty] internal string path, name; + [JsonProperty] internal string typeName; + + internal ObjectRef(Object obj) + { + this.guid = null; + localId = null; + + if (obj == null) + { + this.guid = path = name = null; + localId = null; + typeName = null; + return; + } + + typeName = obj.GetType().Name; + + long id; + if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out var guid, out id)) + { + this.guid = guid; + localId = id; + } + + if (obj is Component c) + { + path = RuntimeUtil.RelativePath(null, c.gameObject); + } + else if (obj is GameObject go) + { + path = RuntimeUtil.RelativePath(null, go); + } + else + { + path = null; + } + + name = string.IsNullOrWhiteSpace(obj.name) ? "" : obj.name; + } + + internal UnityEngine.Object Lookup(ObjectRefLookupCache cache) + { + if (path != null) + { + return FindObject(path); + } + else if (guid != null && localId.HasValue) + { + return cache.FindByGuidAndLocalId(guid, localId.Value); + } + else + { + return null; + } + } + + private static GameObject FindObject(string path) + { + var scene = SceneManager.GetActiveScene(); + foreach (var root in scene.GetRootGameObjects()) + { + if (root.name == path) return root; + if (path.StartsWith(root.name + "/")) + { + return root.transform.Find(path.Substring(root.name.Length + 1))?.gameObject; + } + } + + return null; + } + + public ObjectRef Remap(string original, string cloned) + { + if (path == cloned) + { + path = original; + name = path.Substring(path.LastIndexOf('/') + 1); + } + else if (path != null && path.StartsWith(cloned + "/")) + { + path = original + path.Substring(cloned.Length); + name = path.Substring(path.LastIndexOf('/') + 1); + } + + return this; + } + } + + internal enum ReportLevel + { + Validation, + Info, + Warning, + Error, + InternalError, + } + + internal class ErrorLog + { + [JsonProperty] internal List referencedObjects; + [JsonProperty] internal ReportLevel reportLevel; + [JsonProperty] internal string messageCode; + [JsonProperty] internal string[] substitutions; + [JsonProperty] internal string stacktrace; + + internal ErrorLog(ReportLevel level, string code, string[] strings, params object[] args) + { + reportLevel = level; + + substitutions = strings.Select(s => s.ToString()).ToArray(); + + referencedObjects = args.Where(o => o is Component || o is GameObject) + .Select(o => new ObjectRef(o is Component c ? c.gameObject : (GameObject) o)) + .ToList(); + referencedObjects.AddRange(BuildReport.CurrentReport.GetActiveReferences()); + + messageCode = code; + stacktrace = null; + } + + internal ErrorLog(ReportLevel level, string code, params object[] args) : this(level, code, + Array.Empty(), args) + { + } + + internal ErrorLog(Exception e, string additionalStackTrace = "") + { + reportLevel = ReportLevel.InternalError; + messageCode = "error.internal_error"; + substitutions = new string[] {e.Message, e.TargetSite?.Name}; + referencedObjects = BuildReport.CurrentReport.GetActiveReferences().ToList(); + stacktrace = e.ToString() + additionalStackTrace; + } + + public string ToString() + { + return "[" + reportLevel + "] " + messageCode + " " + "subst: " + string.Join(", ", substitutions); + } + } + + internal class BuildReport + { + private const string Path = "Library/ModularAvatarBuildReport.json"; + + private static BuildReport _report; + private AvatarReport _currentAvatar; + private Stack _references = new Stack(); + + [JsonProperty] internal List Avatars = new List(); + internal AvatarReport CurrentAvatar => _currentAvatar; + + public static BuildReport CurrentReport + { + get + { + if (_report == null) _report = LoadReport() ?? new BuildReport(); + return _report; + } + } + + static BuildReport() + { + EditorApplication.playModeStateChanged += change => + { + switch (change) + { + case PlayModeStateChange.ExitingEditMode: + // TODO - skip if we're doing a VRCSDK build + _report = new BuildReport(); + break; + } + }; + } + + private static BuildReport LoadReport() + { + try + { + var data = File.ReadAllText(Path); + return JsonConvert.DeserializeObject(data); + } + catch (Exception e) + { + return null; + } + } + + internal static void SaveReport() + { + var report = CurrentReport; + var json = JsonConvert.SerializeObject(report); + + File.WriteAllText(Path, json); + + ErrorReportUI.reloadErrorReport(); + } + + private class AvatarReportScope : IDisposable + { + public void Dispose() + { + var successful = CurrentReport._currentAvatar.successful; + CurrentReport._currentAvatar = null; + BuildReport.SaveReport(); + if (!successful) throw new Exception("Avatar processing failed"); + } + } + + internal IDisposable ReportingOnAvatar(VRCAvatarDescriptor descriptor) + { + if (descriptor != null) + { + AvatarReport report = new AvatarReport(); + report.objectRef = new ObjectRef(descriptor.gameObject); + Avatars.Add(report); + _currentAvatar = report; + _currentAvatar.successful = true; + + _currentAvatar.logs.AddRange(ComponentValidation.ValidateAll(descriptor.gameObject)); + } + + return new AvatarReportScope(); + } + + internal static void Log(ReportLevel level, string code, object[] strings, params Object[] objects) + { + ErrorLog errorLog = + new ErrorLog(level, code, strings: strings.Select(s => s.ToString()).ToArray(), objects); + + var avatarReport = CurrentReport._currentAvatar; + if (avatarReport == null) + { + Debug.LogWarning("Error logged when not processing an avatar: " + errorLog); + return; + } + + avatarReport.logs.Add(errorLog); + } + + internal static void LogFatal(string code, object[] strings, params Object[] objects) + { + Log(ReportLevel.Error, code, strings: strings, objects: objects); + if (CurrentReport._currentAvatar != null) + { + CurrentReport._currentAvatar.successful = false; + } + else + { + throw new Exception("Fatal error without error reporting scope"); + } + } + + internal static void LogException(Exception e, string additionalStackTrace = "") + { + var avatarReport = CurrentReport._currentAvatar; + if (avatarReport == null) + { + Debug.LogException(e); + return; + } + else + { + avatarReport.logs.Add(new ErrorLog(e, additionalStackTrace)); + } + } + + internal static T ReportingObject(UnityEngine.Object obj, Func action) + { + if (obj != null) CurrentReport._references.Push(obj); + try + { + return action(); + } + catch (Exception e) + { + var additionalStackTrace = string.Join("\n", Environment.StackTrace.Split('\n').Skip(1)) + "\n"; + LogException(e, additionalStackTrace); + return default; + } + finally + { + if (obj != null) CurrentReport._references.Pop(); + } + } + + internal static void ReportingObject(UnityEngine.Object obj, Action action) + { + ReportingObject(obj, () => + { + action(); + return true; + }); + } + + internal IEnumerable GetActiveReferences() + { + return _references.Select(o => new ObjectRef(o)); + } + + public static void Clear() + { + _report = new BuildReport(); + } + + public static void RemapPaths(string original, string cloned) + { + foreach (var av in CurrentReport.Avatars) + { + av.objectRef = av.objectRef.Remap(original, cloned); + + foreach (var log in av.logs) + { + log.referencedObjects = log.referencedObjects.Select(o => o.Remap(original, cloned)).ToList(); + } + } + + ErrorReportUI.reloadErrorReport(); + } + } +} diff --git a/Internal/ErrorReporter/Editor/ErrorLog.cs.meta b/Internal/ErrorReporter/Editor/ErrorLog.cs.meta new file mode 100644 index 000000000..620ce84fb --- /dev/null +++ b/Internal/ErrorReporter/Editor/ErrorLog.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b4087c2d2b42442884386c60e0f2036d +timeCreated: 1682840642 diff --git a/Internal/ErrorReporter/Editor/ErrorReportUI.cs b/Internal/ErrorReporter/Editor/ErrorReportUI.cs new file mode 100644 index 000000000..d51ded754 --- /dev/null +++ b/Internal/ErrorReporter/Editor/ErrorReportUI.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace Anatawa12.AvatarOptimizer.ErrorReporting +{ + internal class ErrorReportUI : EditorWindow + { + internal static Action reloadErrorReport = () => { }; + + [MenuItem("Tools/Modular Avatar/Show error report", false, 100)] + public static void OpenErrorReportUI() + { + GetWindow().Show(); + } + + public static void MaybeOpenErrorReportUI() + { + if (BuildReport.CurrentReport.Avatars.Any(av => av.logs.Count > 0)) + { + OpenErrorReportUI(); + } + } + + private Vector2 _avatarScrollPos, _errorScrollPos; + private int _selectedAvatar = -1; + private List