diff --git a/RuntimeUnityEditor.Core/Features/ContextMenu.cs b/RuntimeUnityEditor.Core/Features/ContextMenu.cs index 2d9522a..95150ef 100644 --- a/RuntimeUnityEditor.Core/Features/ContextMenu.cs +++ b/RuntimeUnityEditor.Core/Features/ContextMenu.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; using HarmonyLib; +using RuntimeUnityEditor.Core.Breakpoints; using RuntimeUnityEditor.Core.ChangeHistory; using RuntimeUnityEditor.Core.Inspector.Entries; using RuntimeUnityEditor.Core.ObjectTree; @@ -51,6 +52,7 @@ public override bool Enabled /// protected override void Initialize(InitSettings initSettings) { + // TODO This mess needs a rewrite with a sane API MenuContents.AddRange(new[] { new MenuEntry("! Destroyed unity Object !", obj => obj is UnityEngine.Object uobj && !uobj, null), @@ -75,7 +77,12 @@ protected override void Initialize(InitSettings initSettings) new MenuEntry("Send to REPL", o => o != null && REPL.ReplWindow.Initialized, o => REPL.ReplWindow.Instance.IngestObject(o)), new MenuEntry(), + }); + + AddBreakpointControls(MenuContents); + MenuContents.AddRange(new[] + { new MenuEntry("Copy to clipboard", o => o != null && Clipboard.ClipboardWindow.Initialized, o => { if (Clipboard.ClipboardWindow.Contents.LastOrDefault() != o) @@ -167,6 +174,42 @@ o is Sprite || DisplayType = FeatureDisplayType.Hidden; } + private void AddBreakpointControls(List menuContents) + { + menuContents.AddRange(AddGroup("call", (o, info) => info as MethodBase)); + menuContents.AddRange(AddGroup("getter", (o, info) => info is PropertyInfo pi ? pi.GetGetMethod(true) : null)); + menuContents.AddRange(AddGroup("setter", (o, info) => info is PropertyInfo pi ? pi.GetSetMethod(true) : null)); + menuContents.Add(new MenuEntry()); + return; + + IEnumerable AddGroup(string name, Func getMethod) + { + yield return new MenuEntry("Attach " + name + " breakpoint (this instance)", o => + { + if (o == null) return false; + var target = getMethod(o, _objMemberInfo); + return target != null && !Breakpoints.Breakpoints.IsAttached(target, o); + }, o => Breakpoints.Breakpoints.AttachBreakpoint(getMethod(o, _objMemberInfo), o)); + yield return new MenuEntry("Detach " + name + " breakpoint (this instance)", o => + { + if (o == null) return false; + var target = getMethod(o, _objMemberInfo); + return target != null && Breakpoints.Breakpoints.IsAttached(target, o); + }, o => Breakpoints.Breakpoints.DetachBreakpoint(getMethod(o, _objMemberInfo), o)); + + yield return new MenuEntry("Attach " + name + " breakpoint (all instances)", o => + { + var target = getMethod(o, _objMemberInfo); + return target != null && !Breakpoints.Breakpoints.IsAttached(target, null); + }, o => Breakpoints.Breakpoints.AttachBreakpoint(getMethod(o, _objMemberInfo), null)); + yield return new MenuEntry("Detach " + name + " breakpoint (all instances)", o => + { + var target = getMethod(o, _objMemberInfo); + return target != null && Breakpoints.Breakpoints.IsAttached(target, null); + }, o => Breakpoints.Breakpoints.DetachBreakpoint(getMethod(o, _objMemberInfo), null)); + } + } + /// /// Show the context menu at current cursor position. /// diff --git a/RuntimeUnityEditor.Core/RuntimeUnityEditor.Core.projitems b/RuntimeUnityEditor.Core/RuntimeUnityEditor.Core.projitems index 879adfa..032606c 100644 --- a/RuntimeUnityEditor.Core/RuntimeUnityEditor.Core.projitems +++ b/RuntimeUnityEditor.Core/RuntimeUnityEditor.Core.projitems @@ -57,6 +57,12 @@ + + + + + + diff --git a/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointHit.cs b/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointHit.cs new file mode 100644 index 0000000..cfa74a1 --- /dev/null +++ b/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointHit.cs @@ -0,0 +1,42 @@ +using System; +using System.Diagnostics; +using System.Linq; + +namespace RuntimeUnityEditor.Core.Breakpoints +{ + public sealed class BreakpointHit + { + public BreakpointHit(BreakpointPatchInfo origin, object instance, object[] args, object result, StackTrace trace) + { + Origin = origin; + Instance = instance; + Args = args; + Result = result; + Trace = trace; + TraceString = trace.ToString(); + Time = DateTime.UtcNow; + } + + public readonly BreakpointPatchInfo Origin; + public readonly object Instance; + public readonly object[] Args; + public readonly object Result; + public readonly StackTrace Trace; + internal readonly string TraceString; + public readonly DateTime Time; + + private string _toStr, _searchStr; + public string GetSearchableString() + { + if (_searchStr == null) + _searchStr = $"{Origin.Target.DeclaringType?.FullName}.{Origin.Target.Name}\t{Result}\t{string.Join("\t", Args.Select(x => x?.ToString() ?? "").ToArray())}"; + return _searchStr; + } + public override string ToString() + { + if (_toStr == null) + _toStr = $"{Origin.Target.DeclaringType?.FullName ?? "???"}.{Origin.Target.Name} |Result> {Result?.ToString() ?? "NULL"} |Args> {string.Join(" | ", Args.Select(x => x?.ToString() ?? "NULL").ToArray())}"; + return _toStr; + } + } +} diff --git a/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointHitException.cs b/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointHitException.cs new file mode 100644 index 0000000..cc1c328 --- /dev/null +++ b/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointHitException.cs @@ -0,0 +1,9 @@ +using System; + +namespace RuntimeUnityEditor.Core.Breakpoints +{ + internal sealed class BreakpointHitException : Exception + { + public BreakpointHitException(string message) : base(message) { } + } +} diff --git a/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointPatchInfo.cs b/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointPatchInfo.cs new file mode 100644 index 0000000..068eddc --- /dev/null +++ b/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointPatchInfo.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace RuntimeUnityEditor.Core.Breakpoints +{ + public sealed class BreakpointPatchInfo + { + public MethodBase Target { get; } + public MethodInfo Patch { get; } + public List InstanceFilters { get; } = new List(); + + public BreakpointPatchInfo(MethodBase target, MethodInfo patch, object instanceFilter) + { + Target = target; + Patch = patch; + if (instanceFilter != null) + InstanceFilters.Add(instanceFilter); + } + + private string _toStr, _searchStr; + + internal string GetSearchableString() + { + if (_searchStr == null) + _searchStr = $"{Target.DeclaringType?.FullName}.{Target.Name}\t{string.Join("\t", InstanceFilters.Select(x => x?.ToString()).ToArray())}"; + return _searchStr; + } + public override string ToString() + { + if (_toStr == null) + _toStr = $"{Target.DeclaringType?.FullName ?? "???"}.{Target.Name} |Instances> {string.Join(" | ", InstanceFilters.Select(x => x?.ToString() ?? "NULL").ToArray())}"; + return _toStr; + } + } +} diff --git a/RuntimeUnityEditor.Core/Windows/Breakpoints/Breakpoints.cs b/RuntimeUnityEditor.Core/Windows/Breakpoints/Breakpoints.cs new file mode 100644 index 0000000..94463aa --- /dev/null +++ b/RuntimeUnityEditor.Core/Windows/Breakpoints/Breakpoints.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using HarmonyLib; + +namespace RuntimeUnityEditor.Core.Breakpoints +{ + public static class Breakpoints + { + private static readonly Harmony _harmony = new Harmony("RuntimeUnityEditor.Core.Breakpoints"); + private static readonly HarmonyMethod _handlerMethodRet = new HarmonyMethod(typeof(Hooks), nameof(Hooks.BreakpointHandlerReturn)); + private static readonly HarmonyMethod _handlerMethodNoRet = new HarmonyMethod(typeof(Hooks), nameof(Hooks.BreakpointHandlerNoReturn)); + private static readonly Dictionary _appliedPatches = new Dictionary(); + public static ICollection AppliedPatches => _appliedPatches.Values; + + public static bool Enabled { get; set; } = true; + public static DebuggerBreakType DebuggerBreaking { get; set; } + + public static event Action OnBreakpointHit; + + public static bool AttachBreakpoint(MethodBase target, object instance) + { + if (_appliedPatches.TryGetValue(target, out var pi)) + { + if (instance != null) + pi.InstanceFilters.Add(instance); + else + pi.InstanceFilters.Clear(); + return true; + } + + var hasReturn = target is MethodInfo mi && mi.ReturnType != typeof(void); + var patch = _harmony.Patch(target, postfix: hasReturn ? _handlerMethodRet : _handlerMethodNoRet); + if (patch != null) + { + _appliedPatches[target] = new BreakpointPatchInfo(target, patch, instance); + return true; + } + + return false; + } + + public static bool DetachBreakpoint(MethodBase target, object instance) + { + if (_appliedPatches.TryGetValue(target, out var pi)) + { + if (instance == null) + pi.InstanceFilters.Clear(); + else + pi.InstanceFilters.Remove(instance); + + if (pi.InstanceFilters.Count == 0) + { + _harmony.Unpatch(target, pi.Patch); + _appliedPatches.Remove(target); + return true; + } + } + + return false; + } + + public static bool IsAttached(MethodBase target, object instance) + { + if (_appliedPatches.TryGetValue(target, out var pi)) + { + return instance == null && pi.InstanceFilters.Count == 0 || pi.InstanceFilters.Contains(instance); + } + + return false; + } + + public static void DetachAll() + { + _harmony.UnpatchSelf(); + _appliedPatches.Clear(); + } + + private static void AddHit(object __instance, MethodBase __originalMethod, object[] __args, object __result) + { + if (!Enabled) return; + + if (!_appliedPatches.TryGetValue(__originalMethod, out var pi)) return; + + if (pi.InstanceFilters.Count > 0 && !pi.InstanceFilters.Contains(__instance)) return; + + if (DebuggerBreaking == DebuggerBreakType.ThrowCatch) + { + try { throw new BreakpointHitException(pi.Target.Name); } + catch (BreakpointHitException) { } + } + else if (DebuggerBreaking == DebuggerBreakType.DebuggerBreak) + { + Debugger.Break(); + } + + OnBreakpointHit?.Invoke(new BreakpointHit(pi, __instance, __args, __result, new StackTrace(2, true))); + } + + private static class Hooks + { + public static void BreakpointHandlerReturn(object __instance, MethodBase __originalMethod, object[] __args, object __result) + { + AddHit(__instance, __originalMethod, __args, __result); + } + + public static void BreakpointHandlerNoReturn(object __instance, MethodBase __originalMethod, object[] __args) + { + AddHit(__instance, __originalMethod, __args, null); + } + } + } +} diff --git a/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointsWindow.cs b/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointsWindow.cs new file mode 100644 index 0000000..ba3f7e8 --- /dev/null +++ b/RuntimeUnityEditor.Core/Windows/Breakpoints/BreakpointsWindow.cs @@ -0,0 +1,227 @@ +using System.Collections.Generic; +using HarmonyLib; +using RuntimeUnityEditor.Core.Inspector.Entries; +using RuntimeUnityEditor.Core.Utils; +using RuntimeUnityEditor.Core.Utils.Abstractions; +using UnityEngine; + +namespace RuntimeUnityEditor.Core.Breakpoints +{ + // TODO aggregate results, etc. + public class BreakpointsWindow : Window + { + private static readonly List _hits = new List(); + + protected override void Initialize(InitSettings initSettings) + { + DisplayName = "Breakpoints"; + Title = "Breakpoint manager and breakpoint hit history"; + DefaultScreenPosition = ScreenPartition.CenterLower; + + Breakpoints.OnBreakpointHit += hit => _hits.Add(hit); + } + + protected override void LateUpdate() + { + if (_hits.Count > _maxHitsToKeep) + _hits.RemoveRange(0, _hits.Count - _maxHitsToKeep); + + base.LateUpdate(); + } + + private Vector2 _scrollPosHits, _scrollPosBreakpoints; + + private bool _showingHits = true; + private int _maxHitsToKeep = 100; + private string _searchString = ""; + + protected override void DrawContents() + { + GUILayout.BeginHorizontal(); + { + GUILayout.BeginHorizontal(GUI.skin.box); + { + Breakpoints.Enabled = GUILayout.Toggle(Breakpoints.Enabled, "Enabled", IMGUIUtils.LayoutOptionsExpandWidthFalse); + + if (!Breakpoints.Enabled) + GUI.color = Color.gray; + + GUILayout.Space(10); + + GUILayout.Label("Show ", IMGUIUtils.LayoutOptionsExpandWidthFalse); + if (GUILayout.Toggle(!_showingHits, "active breakpoints", IMGUIUtils.LayoutOptionsExpandWidthFalse)) + _showingHits = false; + if (GUILayout.Toggle(_showingHits, "breakpoint hits", IMGUIUtils.LayoutOptionsExpandWidthFalse)) + _showingHits = true; + + GUILayout.Space(10); + + if (_showingHits) + { + if (GUILayout.Button("Clear hits", IMGUIUtils.LayoutOptionsExpandWidthFalse)) + _hits.Clear(); + } + else + { + if (GUILayout.Button("Remove all", IMGUIUtils.LayoutOptionsExpandWidthFalse)) + Breakpoints.DetachAll(); + } + } + GUILayout.EndHorizontal(); + } + GUILayout.EndHorizontal(); + + GUILayout.BeginHorizontal(); + { + GUILayout.BeginHorizontal(GUI.skin.box); + { + GUILayout.Label("Search: ", IMGUIUtils.LayoutOptionsExpandWidthFalse); + _searchString = GUILayout.TextField(_searchString, IMGUIUtils.LayoutOptionsExpandWidthTrue); + + GUILayout.Space(10); + + GUILayout.Label("Break attached debugger: ", IMGUIUtils.LayoutOptionsExpandWidthFalse); + if (GUILayout.Toggle(Breakpoints.DebuggerBreaking == DebuggerBreakType.None, "No", IMGUIUtils.LayoutOptionsExpandWidthFalse)) + Breakpoints.DebuggerBreaking = DebuggerBreakType.None; + if (GUILayout.Toggle(Breakpoints.DebuggerBreaking == DebuggerBreakType.ThrowCatch, new GUIContent("Throw an exception", null, $"Throw and catch a {nameof(BreakpointHitException)}. The most reliable way."), IMGUIUtils.LayoutOptionsExpandWidthFalse)) + Breakpoints.DebuggerBreaking = DebuggerBreakType.ThrowCatch; + if (GUILayout.Toggle(Breakpoints.DebuggerBreaking == DebuggerBreakType.DebuggerBreak, new GUIContent("Debugger.Break", null, "This might not work with some debugging methods, and it might hard-crash some games."), IMGUIUtils.LayoutOptionsExpandWidthFalse)) + Breakpoints.DebuggerBreaking = DebuggerBreakType.DebuggerBreak; + } + GUILayout.EndHorizontal(); + } + GUILayout.EndHorizontal(); + + if (_showingHits) + DrawHits(); + else + DrawBreakpoints(); + + GUI.color = Color.white; + } + + private void DrawBreakpoints() + { + _scrollPosBreakpoints = GUILayout.BeginScrollView(_scrollPosBreakpoints, false, true); + { + if (Breakpoints.AppliedPatches.Count > 0) + { + foreach (var appliedPatch in Breakpoints.AppliedPatches) + { + if (!string.IsNullOrEmpty(_searchString)) + { + if (!appliedPatch.GetSearchableString().Contains(_searchString)) + continue; + } + + GUILayout.BeginHorizontal(GUI.skin.box); + { + DrawHitOriginButton(appliedPatch); + + if (GUILayout.Button("Remove breakpoint", IMGUIUtils.LayoutOptionsExpandWidthFalse)) + Breakpoints.DetachBreakpoint(appliedPatch.Target, null); + + if (appliedPatch.InstanceFilters.Count > 0) + { + GUILayout.Label("or remove watched instances:", IMGUIUtils.LayoutOptionsExpandWidthFalse); + + var instanceFilters = appliedPatch.InstanceFilters; + for (var i = 0; i < instanceFilters.Count; i++) + { + var obj = instanceFilters[i]; + if (GUILayout.Button(obj.ToString(), GUILayout.Width(80))) + Breakpoints.DetachBreakpoint(appliedPatch.Target, obj); + } + } + } + GUILayout.EndHorizontal(); + } + } + else + { + GUILayout.Label("This window lists breakpoints set on methods and on property getters/setters.\nA breakpoint is tiggered whenever a method/property is called. The stack trace as well as any parameters and result value of that trigger are then stored in the list of hits.\n\nTo add breakpoints right click on methods and properties, and look for the 'Attach breakpoint' options. It's easiest to do in inspector by right clicking on the member names."); + } + } + GUILayout.EndScrollView(); + } + + private void DrawHits() + { + _scrollPosHits = GUILayout.BeginScrollView(_scrollPosHits, false, true); + { + if (_hits.Count > 0) + { + for (int i = _hits.Count - 1; i >= 0; i--) + { + var hit = _hits[i]; + + if (!string.IsNullOrEmpty(_searchString)) + { + if (!hit.GetSearchableString().Contains(_searchString)) + continue; + } + + GUILayout.BeginHorizontal(GUI.skin.box); + { + GUILayout.Label($"{hit.Time:HH:mm:ss.fff}", GUILayout.Width(85)); + + DrawHitOriginButton(hit.Origin); + + if (GUILayout.Button(new GUIContent("Trace", null, hit.TraceString + "\n\nClick to copy to clipboard\nMiddle click to inspect\nRight click for more options"), GUI.skin.label, GUILayout.Width(60))) + { + if (IMGUIUtils.IsMouseRightClick()) + ContextMenu.Instance.Show(hit.Trace, null); + else if (IMGUIUtils.IsMouseWheelClick()) + Inspector.Inspector.Instance.Push(new InstanceStackEntry(hit.Trace.GetFrames(), "StackTrace"), true); + else + { + UnityFeatureHelper.systemCopyBuffer = hit.TraceString; + RuntimeUnityEditorCore.Logger.Log(LogLevel.Message, "Copied stack trace to clipboard"); + } + } + + ShowObjectButton(hit.Instance, "Call instance", GUILayout.Width(80)); + ShowObjectButton(hit.Result, "Method return value", GUILayout.Width(80)); + + //GUILayout.Label(hit.Args.Length.ToString() + ":", GUILayout.Width(40)); + + for (int j = 0; j < hit.Args.Length; j++) + { + ShowObjectButton(hit.Args[j], "Method argument #" + j, GUILayout.Width(80)); + } + } + GUILayout.EndHorizontal(); + } + } + else + { + GUILayout.Label("This window lists the last " + _maxHitsToKeep + " breakpoint hits that were caught (in IL2CPP some purely native calls can be missed and stacktraces may be crap).\nA breakpoint is tiggered whenever a method/property is called. The stack trace as well as any parameters and result value of that trigger are then stored in the list of hits.\n\nTo add breakpoints right click on methods and properties, and look for the 'Attach breakpoint' options. It's easiest to do in inspector by right clicking on the member names."); + } + } + GUILayout.EndScrollView(); + } + + private static void DrawHitOriginButton(BreakpointPatchInfo hitOrigin) + { + if (GUILayout.Button(new GUIContent(hitOrigin.Target.Name, null, $"Target: {hitOrigin.Target.FullDescription()}\n\nClick to open in dnSpy, right click for more options."), GUI.skin.label, GUILayout.Width(150))) + { + if (IMGUIUtils.IsMouseRightClick()) + ContextMenu.Instance.Show(null, hitOrigin.Target); + else + DnSpyHelper.OpenInDnSpy(hitOrigin.Target); + } + } + + private static void ShowObjectButton(object obj, string objName, params GUILayoutOption[] options) + { + var text = obj?.ToString() ?? "NULL"; + if (GUILayout.Button(new GUIContent(text, null, $"Name: {objName}\nType: {obj?.GetType().FullDescription() ?? "NULL"}\nToString: {text}\n\nClick to open in inspector, right click for more options."), GUI.skin.label, options) && obj != null) + { + if (IMGUIUtils.IsMouseRightClick()) + ContextMenu.Instance.Show(obj, null); + else + Inspector.Inspector.Instance.Push(new InstanceStackEntry(obj, objName), true); + } + } + } +} \ No newline at end of file diff --git a/RuntimeUnityEditor.Core/Windows/Breakpoints/DebuggerBreakType.cs b/RuntimeUnityEditor.Core/Windows/Breakpoints/DebuggerBreakType.cs new file mode 100644 index 0000000..9b49d57 --- /dev/null +++ b/RuntimeUnityEditor.Core/Windows/Breakpoints/DebuggerBreakType.cs @@ -0,0 +1,9 @@ +namespace RuntimeUnityEditor.Core.Breakpoints +{ + public enum DebuggerBreakType + { + None = 0, + DebuggerBreak, + ThrowCatch + } +}