diff --git a/src/R3.Godot/addons/R3.Godot/GodotR3Plugin.cs b/src/R3.Godot/addons/R3.Godot/GodotR3Plugin.cs new file mode 100644 index 00000000..5f29c72b --- /dev/null +++ b/src/R3.Godot/addons/R3.Godot/GodotR3Plugin.cs @@ -0,0 +1,32 @@ +#if TOOLS +#nullable enable + +using Godot; + +namespace R3; + +[Tool] +public partial class GodotR3Plugin : EditorPlugin +{ + static ObservableTrackerDebuggerPlugin? observableTrackerDebugger; + public override void _EnterTree() + { + observableTrackerDebugger ??= new ObservableTrackerDebuggerPlugin(); + AddDebuggerPlugin(observableTrackerDebugger); + // Automatically install autoloads here for ease of use. + AddAutoloadSingleton(nameof(FrameProviderDispatcher), "res://addons/R3.Godot/FrameProviderDispatcher.cs"); + AddAutoloadSingleton(nameof(ObservableTrackerRuntimeHook), "res://addons/R3.Godot/ObservableTrackerRuntimeHook.cs"); + } + + public override void _ExitTree() + { + if (observableTrackerDebugger != null) + { + RemoveDebuggerPlugin(observableTrackerDebugger); + observableTrackerDebugger = null; + } + RemoveAutoloadSingleton(nameof(FrameProviderDispatcher)); + RemoveAutoloadSingleton(nameof(ObservableTrackerRuntimeHook)); + } +} +#endif diff --git a/src/R3.Godot/addons/R3.Godot/ObservableTrackerDebuggerPlugin.cs b/src/R3.Godot/addons/R3.Godot/ObservableTrackerDebuggerPlugin.cs new file mode 100644 index 00000000..cd117f06 --- /dev/null +++ b/src/R3.Godot/addons/R3.Godot/ObservableTrackerDebuggerPlugin.cs @@ -0,0 +1,148 @@ +#if TOOLS +#nullable enable + +using Godot; +using System; +using System.Collections.Generic; +using GDArray = Godot.Collections.Array; + +namespace R3; + +// ObservableTrackerDebuggerPlugin creates the Observable Tracker tab in the debugger, and communicates with ObservableTrackerRuntimeHook via EditorDebuggerSessions. +[Tool] +public partial class ObservableTrackerDebuggerPlugin : EditorDebuggerPlugin +{ + // Shared header used in IPC by ObservableTracker classes. + public const string MessageHeader = "ObservableTracker"; + + // Implemented by ObservableTrackerRuntimeHook. + public const string Message_RequestActiveTasks = "RequestActiveTasks"; + public const string Message_SetEnableStates = "SetEnableStates"; + public const string Message_InvokeGCCollect = "InvokeGCCollect"; + + // Implemented by ObservableTrackerDebuggerPlugin. + public const string Message_ReceiveActiveTasks = "ReceiveActiveTasks"; + + // A TrackerSession isolates each debugger session's states. + // There's no way to know if a session has been disposed for good, so we will never remove anything from this dictionary. + // This is similar to how it is handled in the Godot core (see: https://github.com/godotengine/godot/blob/master/modules/multiplayer/editor/multiplayer_editor_plugin.cpp) + readonly Dictionary sessions = new(); + + private class TrackerSession + { + public readonly EditorDebuggerSession debuggerSession; + public readonly List states = new(); + public event Action>? ReceivedActiveTasks; + + public TrackerSession(EditorDebuggerSession debuggerSession) + { + this.debuggerSession = debuggerSession; + } + + public void InvokeReceivedActiveTasks() + { + ReceivedActiveTasks?.Invoke(states); + } + } + + public override void _SetupSession(int sessionId) + { + var currentSession = GetSession(sessionId); + sessions[sessionId] = new TrackerSession(currentSession); + + // NotifyOnSessionSetup gives the tab a reference to the debugger plugin, as well as the sessionId which is needed for messages. + var tab = new ObservableTrackerTab(); + tab.NotifyOnSessionSetup(this, sessionId); + currentSession.AddSessionTab(tab); + + // As sessions don't seem to be ever disposed, we don't need to unregister these callbacks either. + currentSession.Started += () => + { + if (IsInstanceValid(tab)) + { + tab.SetProcess(true); + // Important! We need to tell the tab the session has started, so it can initialize the enabled states of the runtime SubscriptionTracker. + tab.NotifyOnSessionStart(); + } + }; + currentSession.Stopped += () => + { + if (IsInstanceValid(tab)) + { + tab.SetProcess(false); + } + }; + } + + public override bool _HasCapture(string capture) + { + return capture == MessageHeader; + } + + public override bool _Capture(string message, GDArray data, int sessionId) + { + // When EditorDebuggerPlugin._Capture receives messages, the header isn't trimmed (unlike how it is in EngineDebugger), + // so we need to trim it here. + string messageWithoutHeader = message.Substring(message.IndexOf(':') + 1); + //GD.Print(nameof(ObservableTrackerDebuggerPlugin) + " received " + messageWithoutHeader); + switch(messageWithoutHeader) + { + case Message_ReceiveActiveTasks: + // Only invoke event if updated. + if (data[0].AsBool()) + { + var session = sessions[sessionId]; + session.states.Clear(); + foreach (GDArray item in data[1].AsGodotArray()) + { + var state = new TrackingState() + { + TrackingId = item[0].AsInt32(), + FormattedType = item[1].AsString(), + AddTime = new DateTime(item[2].AsInt64()), + StackTrace = item[3].AsString(), + };; + session.states.Add(state); + } + session.InvokeReceivedActiveTasks(); + } + return true; + } + return base._Capture(message, data, sessionId); + } + + public void RegisterReceivedActiveTasks(int sessionId, Action> action) + { + sessions[sessionId].ReceivedActiveTasks += action; + } + + public void UnregisterReceivedActiveTasks(int sessionId, Action> action) + { + sessions[sessionId].ReceivedActiveTasks -= action; + } + + public void UpdateTrackingStates(int sessionId, bool forceUpdate = false) + { + if (sessions[sessionId].debuggerSession.IsActive()) + { + sessions[sessionId].debuggerSession.SendMessage(MessageHeader + ":" + Message_RequestActiveTasks, new () { forceUpdate }); + } + } + + public void SetEnableStates(int sessionId, bool enableTracking, bool enableStackTrace) + { + if (sessions[sessionId].debuggerSession.IsActive()) + { + sessions[sessionId].debuggerSession.SendMessage(MessageHeader + ":" + Message_SetEnableStates, new () { enableTracking, enableStackTrace}); + } + } + + public void InvokeGCCollect(int sessionId) + { + if (sessions[sessionId].debuggerSession.IsActive()) + { + sessions[sessionId].debuggerSession.SendMessage(MessageHeader + ":" + Message_InvokeGCCollect); + } + } +} +#endif diff --git a/src/R3.Godot/addons/R3.Godot/ObservableTrackerRuntimeHook.cs b/src/R3.Godot/addons/R3.Godot/ObservableTrackerRuntimeHook.cs new file mode 100644 index 00000000..476ba95a --- /dev/null +++ b/src/R3.Godot/addons/R3.Godot/ObservableTrackerRuntimeHook.cs @@ -0,0 +1,53 @@ +#nullable enable + +using Godot; +using System; +using GDArray = Godot.Collections.Array; + +namespace R3; + +// Sends runtime SubscriptionTracker information to ObservableTrackerDebuggerPlugin. +// Needs to be an Autoload. Should not be instantiated manually. +public partial class ObservableTrackerRuntimeHook : Node +{ + public override void _Ready() + { + EngineDebugger.RegisterMessageCapture(ObservableTrackerDebuggerPlugin.MessageHeader, Callable.From((string message, GDArray data) => + { + //GD.Print(nameof(ObservableTrackerRuntimeHook) + " received " + message); + switch (message) + { + case ObservableTrackerDebuggerPlugin.Message_RequestActiveTasks: + // data[0]: If true, force an update anyway. + if (SubscriptionTracker.CheckAndResetDirty() || data[0].AsBool()) + { + GDArray states = new(); + SubscriptionTracker.ForEachActiveTask(state => + { + // DateTime is not a Variant type, so we serialize it using Ticks instead. + states.Add(new GDArray { state.TrackingId, state.FormattedType, state.AddTime.Ticks, state.StackTrace }); + }); + EngineDebugger.SendMessage(ObservableTrackerDebuggerPlugin.MessageHeader + ":" + ObservableTrackerDebuggerPlugin.Message_ReceiveActiveTasks, new () { true, states }); + } + else + { + EngineDebugger.SendMessage(ObservableTrackerDebuggerPlugin.MessageHeader + ":" + ObservableTrackerDebuggerPlugin.Message_ReceiveActiveTasks, new () { false, }); + } + break; + case ObservableTrackerDebuggerPlugin.Message_SetEnableStates: + SubscriptionTracker.EnableTracking = data[0].AsBool(); + SubscriptionTracker.EnableStackTrace = data[1].AsBool(); + break; + case ObservableTrackerDebuggerPlugin.Message_InvokeGCCollect: + GC.Collect(0); + break; + } + return true; + })); + } + + public override void _ExitTree() + { + EngineDebugger.UnregisterMessageCapture(ObservableTrackerDebuggerPlugin.MessageHeader); + } +} diff --git a/src/R3.Godot/addons/R3.Godot/ObservableTrackerTab.cs b/src/R3.Godot/addons/R3.Godot/ObservableTrackerTab.cs new file mode 100644 index 00000000..a52ee320 --- /dev/null +++ b/src/R3.Godot/addons/R3.Godot/ObservableTrackerTab.cs @@ -0,0 +1,146 @@ +#if TOOLS +#nullable enable + +using Godot; +using System; + +namespace R3; + +[Tool] +public partial class ObservableTrackerTab : VBoxContainer +{ + public const string EnableAutoReloadKey = "ObservableTracker_EnableAutoReloadKey"; + public const string EnableTrackingKey = "ObservableTracker_EnableTrackingKey"; + public const string EnableStackTraceKey = "ObservableTracker_EnableStackTraceKey"; + bool enableAutoReload, enableTracking, enableStackTrace; + ObservableTrackerTree? tree; + ObservableTrackerDebuggerPlugin? debuggerPlugin; + int interval = 0; + int sessionId = 0; + + public void NotifyOnSessionSetup(ObservableTrackerDebuggerPlugin debuggerPlugin, int sessionId) + { + this.debuggerPlugin = debuggerPlugin; + this.sessionId = sessionId; + tree ??= new ObservableTrackerTree(); + tree.NotifyOnSessionSetup(debuggerPlugin!, sessionId); + } + + public void NotifyOnSessionStart() + { + debuggerPlugin!.SetEnableStates(sessionId, enableTracking, enableStackTrace); + } + + public override void _Ready() + { + Name = "Observable Tracker"; + + tree ??= new ObservableTrackerTree(); + + // Head panel + var headPanelLayout = new HBoxContainer(); + headPanelLayout.SetAnchor(Side.Left, 0); + headPanelLayout.SetAnchor(Side.Right, 0); + AddChild(headPanelLayout); + + // Toggle buttons (top left) + var enableAutoReloadToggle = new CheckButton + { + Text = "Enable AutoReload", + TooltipText = "Reload automatically." + }; + var enableTrackingToggle = new CheckButton + { + Text = "Enable Tracking", + TooltipText = "Start to track Observable subscription. Performance impact: low" + }; + var enableStackTraceToggle = new CheckButton + { + Text = "Enable StackTrace", + TooltipText = "Capture StackTrace when subscribed. Performance impact: high" + }; + + // For every button: Initialize pressed state and subscribe to Toggled event. + EditorSettings settings = EditorInterface.Singleton.GetEditorSettings(); + enableAutoReloadToggle.ButtonPressed = enableAutoReload = GetSettingOrDefault(settings, EnableAutoReloadKey, false).AsBool(); + enableAutoReloadToggle.Toggled += toggledOn => + { + settings.SetSetting(EnableAutoReloadKey, toggledOn); + enableAutoReload = toggledOn; + }; + enableTrackingToggle.ButtonPressed = enableTracking = GetSettingOrDefault(settings, EnableTrackingKey, false).AsBool(); + enableTrackingToggle.Toggled += toggledOn => + { + settings.SetSetting(EnableTrackingKey, toggledOn); + enableTracking = toggledOn; + debuggerPlugin!.SetEnableStates(sessionId, enableTracking, enableStackTrace); + }; + enableStackTraceToggle.ButtonPressed = enableStackTrace = GetSettingOrDefault(settings, EnableStackTraceKey, false).AsBool(); + enableStackTraceToggle.Toggled += toggledOn => + { + settings.SetSetting(EnableStackTraceKey, toggledOn); + enableStackTrace = toggledOn; + debuggerPlugin!.SetEnableStates(sessionId, enableTracking, enableStackTrace); + }; + + // Regular buttons (top right) + var reloadButton = new Button + { + Text = "Reload", + TooltipText = "Reload View." + }; + var GCButton = new Button + { + Text = "GC.Collect", + TooltipText = "Invoke GC.Collect." + }; + + reloadButton.Pressed += () => + { + debuggerPlugin!.UpdateTrackingStates(sessionId, true); + }; + GCButton.Pressed += () => + { + debuggerPlugin!.InvokeGCCollect(sessionId); + }; + + // Button layout. + headPanelLayout.AddChild(enableAutoReloadToggle); + headPanelLayout.AddChild(enableTrackingToggle); + headPanelLayout.AddChild(enableStackTraceToggle); + // Kind of like Unity's FlexibleSpace. Pushes the first three buttons to the left, and the remaining buttons to the right. + headPanelLayout.AddChild(new Control() + { + SizeFlagsHorizontal = SizeFlags.Expand, + }); + headPanelLayout.AddChild(reloadButton); + headPanelLayout.AddChild(GCButton); + + // Tree goes last. + AddChild(tree); + } + + public override void _Process(double delta) + { + if (enableAutoReload) + { + if (interval++ % 120 == 0) + { + debuggerPlugin!.UpdateTrackingStates(sessionId); + } + } + } + + static Variant GetSettingOrDefault(EditorSettings settings, string key, Variant @default) + { + if (settings.HasSetting(key)) + { + return settings.GetSetting(key); + } + else + { + return @default; + } + } +} +#endif diff --git a/src/R3.Godot/addons/R3.Godot/ObservableTrackerTree.cs b/src/R3.Godot/addons/R3.Godot/ObservableTrackerTree.cs new file mode 100644 index 00000000..54229960 --- /dev/null +++ b/src/R3.Godot/addons/R3.Godot/ObservableTrackerTree.cs @@ -0,0 +1,67 @@ +#if TOOLS +#nullable enable + +using Godot; +using System; +using System.Collections; +using System.Collections.Generic; + +namespace R3; + +[Tool] +public partial class ObservableTrackerTree : Tree +{ + ObservableTrackerDebuggerPlugin? debuggerPlugin; + int sessionId; + public void NotifyOnSessionSetup(ObservableTrackerDebuggerPlugin debuggerPlugin, int sessionId) + { + this.debuggerPlugin = debuggerPlugin; + this.sessionId = sessionId; + debuggerPlugin!.RegisterReceivedActiveTasks(sessionId, Reload); + Clear(); + } + + public override void _Ready() + { + AllowReselect = false; + Columns = 3; + ColumnTitlesVisible = true; + SetColumnTitle(0, "Type"); + SetColumnTitle(1, "Elapsed"); + SetColumnTitle(2, "Position"); + SetColumnExpand(0, true); + SetColumnExpand(1, true); + SetColumnExpand(2, true); + SetColumnExpandRatio(0, 3); + SetColumnExpandRatio(1, 1); + SetColumnExpandRatio(2, 6); + SetColumnClipContent(0, true); + SetColumnClipContent(1, true); + SetColumnClipContent(2, true); + HideRoot = true; + SizeFlagsVertical = SizeFlags.ExpandFill; + } + + public override void _ExitTree() + { + debuggerPlugin!.UnregisterReceivedActiveTasks(sessionId, Reload); + } + + public void Reload(IEnumerable states) + { + Clear(); + TreeItem root = CreateItem(); + foreach(TrackingState state in states) + { + TreeItem row = CreateItem(root); + var now = DateTime.Now; + // Type + row.SetText(0, state.FormattedType); + // Elapsed + row.SetText(1, (now - state.AddTime).TotalSeconds.ToString("00.00")); + // Position + row.SetText(2, state.StackTrace); + }; + } +} +#endif diff --git a/src/R3.Godot/addons/R3.Godot/plugin.cfg b/src/R3.Godot/addons/R3.Godot/plugin.cfg new file mode 100644 index 00000000..25fab04a --- /dev/null +++ b/src/R3.Godot/addons/R3.Godot/plugin.cfg @@ -0,0 +1,8 @@ +[plugin] + +name="R3.Godot" +description="The new future of dotnet/reactive and UniRx." +author="Yoshifumi Kawai" +version="0.1.5" +language="C-sharp" +script="GodotR3Plugin.cs"