diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs index c42481d63..fcfe7766f 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs @@ -21,6 +21,16 @@ namespace Dalamud.Game.Addon.Lifecycle; [ServiceManager.EarlyLoadedService] internal unsafe class AddonLifecycle : IDisposable, IServiceType { + /// + /// List of all AddonLifecycle ReceiveEvent Listener Hooks. + /// + internal readonly List ReceiveEventListeners = new(); + + /// + /// List of all AddonLifecycle Event Listeners. + /// + internal readonly List EventListeners = new(); + private static readonly ModuleLog Log = new("AddonLifecycle"); [ServiceManager.ServiceDependency] @@ -39,9 +49,6 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType private readonly ConcurrentBag newEventListeners = new(); private readonly ConcurrentBag removeEventListeners = new(); - private readonly List eventListeners = new(); - - private readonly Dictionary> receiveEventHooks = new(); [ServiceManager.ServiceConstructor] private AddonLifecycle(TargetSigScanner sigScanner) @@ -75,8 +82,6 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType private delegate byte AddonOnRefreshDelegate(AtkUnitManager* unitManager, AtkUnitBase* addon, uint valueCount, AtkValue* values); - private delegate void AddonReceiveEventDelegate(AtkUnitBase* addon, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, nint a5); - /// public void Dispose() { @@ -90,9 +95,9 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType this.onAddonRefreshHook.Dispose(); this.onAddonRequestedUpdateHook.Dispose(); - foreach (var (_, hook) in this.receiveEventHooks) + foreach (var receiveEventListener in this.ReceiveEventListeners) { - hook.Dispose(); + receiveEventListener.Dispose(); } } @@ -114,6 +119,20 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType this.removeEventListeners.Add(listener); } + /// + /// Invoke listeners for the specified event type. + /// + /// Event Type. + /// AddonArgs. + internal void InvokeListeners(AddonEvent eventType, AddonArgs args) + { + // Match on string.empty for listeners that want events for all addons. + foreach (var listener in this.EventListeners.Where(listener => listener.EventType == eventType && (listener.AddonName == args.AddonName || listener.AddonName == string.Empty))) + { + listener.FunctionDelegate.Invoke(eventType, args); + } + } + // Used to prevent concurrency issues if plugins try to register during iteration of listeners. private void OnFrameworkUpdate(IFramework unused) { @@ -121,15 +140,15 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType { foreach (var toAddListener in this.newEventListeners) { - this.eventListeners.Add(toAddListener); + this.EventListeners.Add(toAddListener); // If we want receive event messages have an already active addon, enable the receive event hook. // If the addon isn't active yet, we'll grab the hook when it sets up. if (toAddListener is { EventType: AddonEvent.PreReceiveEvent or AddonEvent.PostReceiveEvent }) { - if (this.receiveEventHooks.TryGetValue(toAddListener.AddonName, out var hook)) + if (this.ReceiveEventListeners.FirstOrDefault(listener => listener.AddonNames.Contains(toAddListener.AddonName)) is { } receiveEventListener) { - hook.Enable(); + receiveEventListener.Hook?.Enable(); } } } @@ -141,7 +160,21 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType { foreach (var toRemoveListener in this.removeEventListeners) { - this.eventListeners.Remove(toRemoveListener); + this.EventListeners.Remove(toRemoveListener); + + // If we are disabling an ReceiveEvent listener, check if we should disable the hook. + if (toRemoveListener is { EventType: AddonEvent.PreReceiveEvent or AddonEvent.PostReceiveEvent }) + { + // Get the ReceiveEvent Listener for this addon + if (this.ReceiveEventListeners.FirstOrDefault(listener => listener.AddonNames.Contains(toRemoveListener.AddonName)) is { } receiveEventListener) + { + // If there are no other listeners listening for this event, disable the hook. + if (!this.EventListeners.Any(listener => listener.AddonName.Contains(toRemoveListener.AddonName) && listener.EventType is AddonEvent.PreReceiveEvent or AddonEvent.PostReceiveEvent)) + { + receiveEventListener.Hook?.Disable(); + } + } + } } this.removeEventListeners.Clear(); @@ -160,12 +193,53 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType this.onAddonRequestedUpdateHook.Enable(); } - private void InvokeListeners(AddonEvent eventType, AddonArgs args) + private void RegisterReceiveEventHook(AtkUnitBase* addon) { - // Match on string.empty for listeners that want events for all addons. - foreach (var listener in this.eventListeners.Where(listener => listener.EventType == eventType && (listener.AddonName == args.AddonName || listener.AddonName == string.Empty))) + // Hook the addon's ReceiveEvent function here, but only enable the hook if we have an active listener. + // Disallows hooking the core internal event handler. + var addonName = MemoryHelper.ReadStringNullTerminated((nint)addon->Name); + var receiveEventAddress = (nint)addon->VTable->ReceiveEvent; + if (receiveEventAddress != this.disallowedReceiveEventAddress) { - listener.FunctionDelegate.Invoke(eventType, args); + // If we have a ReceiveEvent listener already made for this hook address, add this addon's name to that handler. + if (this.ReceiveEventListeners.FirstOrDefault(listener => listener.HookAddress == receiveEventAddress) is { } existingListener) + { + if (!existingListener.AddonNames.Contains(addonName)) + { + existingListener.AddonNames.Add(addonName); + } + } + + // Else, we have an addon that we don't have the ReceiveEvent for yet, make it. + else + { + this.ReceiveEventListeners.Add(new AddonLifecycleReceiveEventListener(this, addonName, receiveEventAddress)); + } + + // If we have an active listener for this addon already, we need to activate this hook. + if (this.EventListeners.Any(listener => (listener.EventType is AddonEvent.PostReceiveEvent or AddonEvent.PreReceiveEvent) && listener.AddonName == addonName)) + { + if (this.ReceiveEventListeners.FirstOrDefault(listener => listener.AddonNames.Contains(addonName)) is { } receiveEventListener) + { + receiveEventListener.Hook?.Enable(); + } + } + } + } + + private void UnregisterReceiveEventHook(string addonName) + { + // Remove this addons ReceiveEvent Registration + if (this.ReceiveEventListeners.FirstOrDefault(listener => listener.AddonNames.Contains(addonName)) is { } eventListener) + { + eventListener.AddonNames.Remove(addonName); + + // If there are no more listeners let's remove and dispose. + if (eventListener.AddonNames.Count is 0) + { + this.ReceiveEventListeners.Remove(eventListener); + eventListener.Dispose(); + } } } @@ -173,20 +247,7 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType { try { - // Hook the addon's ReceiveEvent function here, but only enable the hook if we have an active listener. - // Disallows hooking the core internal event handler. - var addonName = MemoryHelper.ReadStringNullTerminated((nint)addon->Name); - var receiveEventAddress = (nint)addon->VTable->ReceiveEvent; - if (receiveEventAddress != this.disallowedReceiveEventAddress) - { - var receiveEventHook = Hook.FromAddress(receiveEventAddress, this.OnReceiveEvent); - this.receiveEventHooks.TryAdd(addonName, receiveEventHook); - - if (this.eventListeners.Any(listener => (listener.EventType is AddonEvent.PostReceiveEvent or AddonEvent.PreReceiveEvent) && listener.AddonName == addonName)) - { - receiveEventHook.Enable(); - } - } + this.RegisterReceiveEventHook(addon); } catch (Exception e) { @@ -235,13 +296,8 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType { try { - // Remove this addons ReceiveEvent Registration var addonName = MemoryHelper.ReadStringNullTerminated((nint)atkUnitBase[0]->Name); - if (this.receiveEventHooks.TryGetValue(addonName, out var hook)) - { - hook.Dispose(); - this.receiveEventHooks.Remove(addonName); - } + this.UnregisterReceiveEventHook(addonName); } catch (Exception e) { @@ -410,51 +466,6 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Exception in OnRequestedUpdate post-requestedUpdate invoke."); } } - - private void OnReceiveEvent(AtkUnitBase* addon, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, nint data) - { - try - { - this.InvokeListeners(AddonEvent.PreReceiveEvent, new AddonReceiveEventArgs - { - Addon = (nint)addon, - AtkEventType = (byte)eventType, - EventParam = eventParam, - AtkEvent = (nint)atkEvent, - Data = data, - }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnReceiveEvent pre-receiveEvent invoke."); - } - - try - { - var addonName = MemoryHelper.ReadStringNullTerminated((nint)addon->Name); - this.receiveEventHooks[addonName].Original(addon, eventType, eventParam, atkEvent, data); - } - catch (Exception e) - { - Log.Error(e, "Caught exception when calling original AddonReceiveEvent. This may be a bug in the game or another plugin hooking this method."); - } - - try - { - this.InvokeListeners(AddonEvent.PostReceiveEvent, new AddonReceiveEventArgs - { - Addon = (nint)addon, - AtkEventType = (byte)eventType, - EventParam = eventParam, - AtkEvent = (nint)atkEvent, - Data = data, - }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnAddonRefresh post-receiveEvent invoke."); - } - } } /// diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs new file mode 100644 index 000000000..10171eb16 --- /dev/null +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; + +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Hooking; +using Dalamud.Logging.Internal; +using Dalamud.Memory; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Addon.Lifecycle; + +/// +/// This class is a helper for tracking and invoking listener delegates for Addon_OnReceiveEvent. +/// Multiple addons may use the same ReceiveEvent function, this helper makes sure that those addon events are handled properly. +/// +internal unsafe class AddonLifecycleReceiveEventListener : IDisposable +{ + private static readonly ModuleLog Log = new("AddonLifecycle"); + + /// + /// Initializes a new instance of the class. + /// + /// AddonLifecycle service instance. + /// Initial Addon Requesting this listener. + /// Address of Addon's ReceiveEvent function. + internal AddonLifecycleReceiveEventListener(AddonLifecycle service, string addonName, nint receiveEventAddress) + { + this.AddonLifecycle = service; + this.AddonNames = new List { addonName }; + this.Hook = Hook.FromAddress(receiveEventAddress, this.OnReceiveEvent); + } + + /// + /// Addon Receive Event Function delegate. + /// + /// Addon Pointer. + /// Event Type. + /// Unique Event ID. + /// Event Data. + /// Unknown. + public delegate void AddonReceiveEventDelegate(AtkUnitBase* addon, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, nint a5); + + /// + /// Gets the list of addons that use this receive event hook. + /// + public List AddonNames { get; init; } + + /// + /// Gets the address of the registered hook. + /// + public nint HookAddress => this.Hook?.Address ?? nint.Zero; + + /// + /// Gets the contained hook for these addons. + /// + public Hook? Hook { get; init; } + + /// + /// Gets or sets the Reference to AddonLifecycle service instance. + /// + private AddonLifecycle AddonLifecycle { get; set; } + + /// + public void Dispose() + { + this.Hook?.Dispose(); + } + + private void OnReceiveEvent(AtkUnitBase* addon, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, nint data) + { + // Check that we didn't get here through a call to another addons handler. + var addonName = MemoryHelper.ReadString((nint)addon->Name, 0x20); + if (!this.AddonNames.Contains(addonName)) + { + this.Hook!.Original(addon, eventType, eventParam, atkEvent, data); + return; + } + + try + { + this.AddonLifecycle.InvokeListeners(AddonEvent.PreReceiveEvent, new AddonReceiveEventArgs + { + Addon = (nint)addon, + AtkEventType = (byte)eventType, + EventParam = eventParam, + AtkEvent = (nint)atkEvent, + Data = data, + }); + } + catch (Exception e) + { + Log.Error(e, "Exception in OnReceiveEvent pre-receiveEvent invoke."); + } + + try + { + this.Hook!.Original(addon, eventType, eventParam, atkEvent, data); + } + catch (Exception e) + { + Log.Error(e, "Caught exception when calling original AddonReceiveEvent. This may be a bug in the game or another plugin hooking this method."); + } + + try + { + this.AddonLifecycle.InvokeListeners(AddonEvent.PostReceiveEvent, new AddonReceiveEventArgs + { + Addon = (nint)addon, + AtkEventType = (byte)eventType, + EventParam = eventParam, + AtkEvent = (nint)atkEvent, + Data = data, + }); + } + catch (Exception e) + { + Log.Error(e, "Exception in OnAddonRefresh post-receiveEvent invoke."); + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs index ba47d2c8e..d59b50e58 100644 --- a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs +++ b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs @@ -50,6 +50,7 @@ internal class DataWindow : Window new DataShareWidget(), new NetworkMonitorWidget(), new IconBrowserWidget(), + new AddonLifecycleWidget(), }; private readonly IOrderedEnumerable orderedModules; diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonLifecycleWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonLifecycleWidget.cs new file mode 100644 index 000000000..a0cba737c --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/AddonLifecycleWidget.cs @@ -0,0 +1,135 @@ +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Linq; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Interface.Utility; +using ImGuiNET; + +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; + +/// +/// Debug widget for displaying AddonLifecycle data. +/// +public class AddonLifecycleWidget : IDataWindowWidget +{ + /// + public string[]? CommandShortcuts { get; init; } = { "AddonLifecycle" }; + + /// + public string DisplayName { get; init; } = "Addon Lifecycle"; + + /// + [MemberNotNullWhen(true, "AddonLifecycle")] + public bool Ready { get; set; } + + private AddonLifecycle? AddonLifecycle { get; set; } + + /// + public void Load() + { + this.AddonLifecycle = Service.GetNullable(); + if (this.AddonLifecycle is not null) this.Ready = true; + } + + /// + public void Draw() + { + if (!this.Ready) + { + ImGui.Text("AddonLifecycle Reference is null, reload module."); + return; + } + + if (ImGui.CollapsingHeader("Listeners")) + { + ImGui.Indent(); + this.DrawEventListeners(); + ImGui.Unindent(); + } + + if (ImGui.CollapsingHeader("ReceiveEvent Hooks")) + { + ImGui.Indent(); + this.DrawReceiveEventHooks(); + ImGui.Unindent(); + } + } + + private void DrawEventListeners() + { + if (!this.Ready) return; + + foreach (var eventType in Enum.GetValues()) + { + if (ImGui.CollapsingHeader(eventType.ToString())) + { + ImGui.Indent(); + var listeners = this.AddonLifecycle.EventListeners.Where(listener => listener.EventType == eventType).ToList(); + + if (!listeners.Any()) + { + ImGui.Text("No Listeners Registered for Event"); + } + + if (ImGui.BeginTable("AddonLifecycleListenersTable", 2)) + { + ImGui.TableSetupColumn("##AddonName", ImGuiTableColumnFlags.WidthFixed, 100.0f * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("##MethodInvoke", ImGuiTableColumnFlags.WidthStretch); + + foreach (var listener in listeners) + { + ImGui.TableNextColumn(); + ImGui.Text(listener.AddonName is "" ? "GLOBAL" : listener.AddonName); + + ImGui.TableNextColumn(); + ImGui.Text($"{listener.FunctionDelegate.Target}::{listener.FunctionDelegate.Method.Name}"); + } + + ImGui.EndTable(); + } + + ImGui.Unindent(); + } + } + } + + private void DrawReceiveEventHooks() + { + if (!this.Ready) return; + + var listeners = this.AddonLifecycle.ReceiveEventListeners; + + if (!listeners.Any()) + { + ImGui.Text("No ReceiveEvent Hooks are Registered"); + } + + foreach (var receiveEventListener in this.AddonLifecycle.ReceiveEventListeners) + { + if (ImGui.CollapsingHeader(string.Join(", ", receiveEventListener.AddonNames))) + { + ImGui.Columns(2); + + ImGui.Text("Hook Address"); + ImGui.NextColumn(); + ImGui.Text(receiveEventListener.HookAddress.ToString("X")); + + ImGui.NextColumn(); + ImGui.Text("Hook Status"); + ImGui.NextColumn(); + if (receiveEventListener.Hook is null) + { + ImGui.Text("Hook is null"); + } + else + { + var color = receiveEventListener.Hook.IsEnabled ? KnownColor.Green.Vector() : KnownColor.OrangeRed.Vector(); + var text = receiveEventListener.Hook.IsEnabled ? "Enabled" : "Disabled"; + ImGui.TextColored(color, text); + } + + ImGui.Columns(1); + } + } + } +}