diff --git a/.editorconfig b/.editorconfig index 66e123f53..141e8c9c9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -104,13 +104,14 @@ resharper_can_use_global_alias = false resharper_csharp_align_multiline_parameter = true resharper_csharp_align_multiple_declaration = true resharper_csharp_empty_block_style = multiline -resharper_csharp_int_align_comments = true +resharper_csharp_int_align_comments = false resharper_csharp_new_line_before_while = true resharper_csharp_wrap_after_declaration_lpar = true resharper_csharp_wrap_after_invocation_lpar = true resharper_csharp_wrap_arguments_style = chop_if_long resharper_enforce_line_ending_style = true resharper_instance_members_qualify_declared_in = this_class, base_class +resharper_int_align = false resharper_member_can_be_private_global_highlighting = none resharper_member_can_be_private_local_highlighting = none resharper_new_line_before_finally = true diff --git a/Dalamud.Boot/DalamudStartInfo.cpp b/Dalamud.Boot/DalamudStartInfo.cpp index 15faf82ad..e2fed1beb 100644 --- a/Dalamud.Boot/DalamudStartInfo.cpp +++ b/Dalamud.Boot/DalamudStartInfo.cpp @@ -103,6 +103,7 @@ void from_json(const nlohmann::json& json, DalamudStartInfo& config) { } config.CrashHandlerShow = json.value("CrashHandlerShow", config.CrashHandlerShow); + config.NoExceptionHandlers = json.value("NoExceptionHandlers", config.NoExceptionHandlers); } void DalamudStartInfo::from_envvars() { diff --git a/Dalamud.Boot/DalamudStartInfo.h b/Dalamud.Boot/DalamudStartInfo.h index 66109abf7..73a1a0d34 100644 --- a/Dalamud.Boot/DalamudStartInfo.h +++ b/Dalamud.Boot/DalamudStartInfo.h @@ -49,6 +49,7 @@ struct DalamudStartInfo { std::set BootUnhookDlls{}; bool CrashHandlerShow = false; + bool NoExceptionHandlers = false; friend void from_json(const nlohmann::json&, DalamudStartInfo&); void from_envvars(); diff --git a/Dalamud.Boot/dllmain.cpp b/Dalamud.Boot/dllmain.cpp index 94f1c7d0f..8ffef40b0 100644 --- a/Dalamud.Boot/dllmain.cpp +++ b/Dalamud.Boot/dllmain.cpp @@ -133,7 +133,9 @@ DWORD WINAPI InitializeImpl(LPVOID lpParam, HANDLE hMainThreadContinue) { // ============================== VEH ======================================== // logging::I("Initializing VEH..."); - if (utils::is_running_on_wine()) { + if (g_startInfo.NoExceptionHandlers) { + logging::W("=> Exception handlers are disabled from DalamudStartInfo."); + } else if (utils::is_running_on_wine()) { logging::I("=> VEH was disabled, running on wine"); } else if (g_startInfo.BootVehEnabled) { if (veh::add_handler(g_startInfo.BootVehFull, g_startInfo.WorkingDirectory)) diff --git a/Dalamud.Common/DalamudStartInfo.cs b/Dalamud.Common/DalamudStartInfo.cs index 069a0ef9f..5126fe3a4 100644 --- a/Dalamud.Common/DalamudStartInfo.cs +++ b/Dalamud.Common/DalamudStartInfo.cs @@ -17,38 +17,6 @@ public record DalamudStartInfo // ignored } - /// - /// Initializes a new instance of the class. - /// - /// Object to copy values from. - public DalamudStartInfo(DalamudStartInfo other) - { - this.WorkingDirectory = other.WorkingDirectory; - this.ConfigurationPath = other.ConfigurationPath; - this.LogPath = other.LogPath; - this.LogName = other.LogName; - this.PluginDirectory = other.PluginDirectory; - this.AssetDirectory = other.AssetDirectory; - this.Language = other.Language; - this.GameVersion = other.GameVersion; - this.DelayInitializeMs = other.DelayInitializeMs; - this.TroubleshootingPackData = other.TroubleshootingPackData; - this.NoLoadPlugins = other.NoLoadPlugins; - this.NoLoadThirdPartyPlugins = other.NoLoadThirdPartyPlugins; - this.BootLogPath = other.BootLogPath; - this.BootShowConsole = other.BootShowConsole; - this.BootDisableFallbackConsole = other.BootDisableFallbackConsole; - this.BootWaitMessageBox = other.BootWaitMessageBox; - this.BootWaitDebugger = other.BootWaitDebugger; - this.BootVehEnabled = other.BootVehEnabled; - this.BootVehFull = other.BootVehFull; - this.BootEnableEtw = other.BootEnableEtw; - this.BootDotnetOpenProcessHookMode = other.BootDotnetOpenProcessHookMode; - this.BootEnabledGameFixes = other.BootEnabledGameFixes; - this.BootUnhookDlls = other.BootUnhookDlls; - this.CrashHandlerShow = other.CrashHandlerShow; - } - /// /// Gets or sets the working directory of the XIVLauncher installations. /// @@ -169,4 +137,9 @@ public record DalamudStartInfo /// Gets or sets a value indicating whether to show crash handler console window. /// public bool CrashHandlerShow { get; set; } + + /// + /// Gets or sets a value indicating whether to disable all kinds of global exception handlers. + /// + public bool NoExceptionHandlers { get; set; } } diff --git a/Dalamud.Injector/EntryPoint.cs b/Dalamud.Injector/EntryPoint.cs index bd9fa87f8..3ffb7ba18 100644 --- a/Dalamud.Injector/EntryPoint.cs +++ b/Dalamud.Injector/EntryPoint.cs @@ -96,6 +96,7 @@ namespace Dalamud.Injector args.Remove("--no-plugin"); args.Remove("--no-3rd-plugin"); args.Remove("--crash-handler-console"); + args.Remove("--no-exception-handlers"); var mainCommand = args[1].ToLowerInvariant(); if (mainCommand.Length > 0 && mainCommand.Length <= 6 && "inject"[..mainCommand.Length] == mainCommand) @@ -393,6 +394,7 @@ namespace Dalamud.Injector startInfo.NoLoadThirdPartyPlugins = args.Contains("--no-3rd-plugin"); // startInfo.BootUnhookDlls = new List() { "kernel32.dll", "ntdll.dll", "user32.dll" }; startInfo.CrashHandlerShow = args.Contains("--crash-handler-console"); + startInfo.NoExceptionHandlers = args.Contains("--no-exception-handlers"); return startInfo; } @@ -434,7 +436,7 @@ namespace Dalamud.Injector Console.WriteLine("Verbose logging:\t[-v]"); Console.WriteLine("Show Console:\t[--console] [--crash-handler-console]"); Console.WriteLine("Enable ETW:\t[--etw]"); - Console.WriteLine("Enable VEH:\t[--veh], [--veh-full]"); + Console.WriteLine("Enable VEH:\t[--veh], [--veh-full], [--no-exception-handlers]"); Console.WriteLine("Show messagebox:\t[--msgbox1], [--msgbox2], [--msgbox3]"); Console.WriteLine("No plugins:\t[--no-plugin] [--no-3rd-plugin]"); Console.WriteLine("Logging:\t[--logname=] [--logpath=]"); @@ -889,7 +891,7 @@ namespace Dalamud.Injector var gameVerStr = File.ReadAllText(Path.Combine(ffxivDir, "ffxivgame.ver")); var gameVer = GameVersion.Parse(gameVerStr); - return new DalamudStartInfo(startInfo) + return startInfo with { GameVersion = gameVer, }; diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index b1a45bfd0..92b468dde 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -8,7 +8,7 @@ - 9.0.0.13 + 9.0.0.14 XIV Launcher addon framework $(DalamudVersion) $(DalamudVersion) @@ -89,6 +89,7 @@ + diff --git a/Dalamud/EntryPoint.cs b/Dalamud/EntryPoint.cs index c9537eda6..d0f9e8845 100644 --- a/Dalamud/EntryPoint.cs +++ b/Dalamud/EntryPoint.cs @@ -147,7 +147,8 @@ public sealed class EntryPoint LogLevelSwitch.MinimumLevel = configuration.LogLevel; // Log any unhandled exception. - AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; + if (!info.NoExceptionHandlers) + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; var unloadFailed = false; @@ -196,7 +197,8 @@ public sealed class EntryPoint finally { TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException; - AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException; + if (!info.NoExceptionHandlers) + AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException; Log.Information("Session has ended."); Log.CloseAndFlush(); diff --git a/Dalamud/Game/Addon/Events/AddonEventManager.cs b/Dalamud/Game/Addon/Events/AddonEventManager.cs index d8f3427ef..af713a771 100644 --- a/Dalamud/Game/Addon/Events/AddonEventManager.cs +++ b/Dalamud/Game/Addon/Events/AddonEventManager.cs @@ -9,6 +9,8 @@ using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Services; +using Dalamud.Utility; + using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Component.GUI; @@ -31,6 +33,9 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType [ServiceManager.ServiceDependency] private readonly AddonLifecycle addonLifecycle = Service.Get(); + [ServiceManager.ServiceDependency] + private readonly Framework framework = Service.Get(); + private readonly AddonLifecycleEventListener finalizeEventListener; private readonly AddonEventManagerAddressResolver address; @@ -57,6 +62,8 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType this.finalizeEventListener = new AddonLifecycleEventListener(AddonEvent.PreFinalize, string.Empty, this.OnAddonFinalize); this.addonLifecycle.RegisterListener(this.finalizeEventListener); + + this.onUpdateCursor.Enable(); } private delegate nint UpdateCursorDelegate(RaptureAtkModule* module); @@ -85,6 +92,8 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType /// IAddonEventHandle used to remove the event. internal IAddonEventHandle? AddEvent(string pluginId, IntPtr atkUnitBase, IntPtr atkResNode, AddonEventType eventType, IAddonEventManager.AddonEventHandler eventHandler) { + if (!ThreadSafety.IsMainThread) throw new InvalidOperationException("This should be done only from the main thread. Modifying active native code on non-main thread is not supported."); + if (this.pluginEventControllers.FirstOrDefault(entry => entry.PluginId == pluginId) is { } eventController) { return eventController.AddEvent(atkUnitBase, atkResNode, eventType, eventHandler); @@ -101,6 +110,8 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType /// The Unique Id for this event. internal void RemoveEvent(string pluginId, IAddonEventHandle eventHandle) { + if (!ThreadSafety.IsMainThread) throw new InvalidOperationException("This should be done only from the main thread. Modifying active native code on non-main thread is not supported."); + if (this.pluginEventControllers.FirstOrDefault(entry => entry.PluginId == pluginId) is { } eventController) { eventController.RemoveEvent(eventHandle); @@ -128,11 +139,14 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType /// Unique ID for this plugin. internal void AddPluginEventController(string pluginId) { - if (this.pluginEventControllers.All(entry => entry.PluginId != pluginId)) + this.framework.RunOnFrameworkThread(() => { - Log.Verbose($"Creating new PluginEventController for: {pluginId}"); - this.pluginEventControllers.Add(new PluginEventController(pluginId)); - } + if (this.pluginEventControllers.All(entry => entry.PluginId != pluginId)) + { + Log.Verbose($"Creating new PluginEventController for: {pluginId}"); + this.pluginEventControllers.Add(new PluginEventController(pluginId)); + } + }); } /// @@ -141,18 +155,15 @@ internal unsafe class AddonEventManager : IDisposable, IServiceType /// Unique ID for this plugin. internal void RemovePluginEventController(string pluginId) { - if (this.pluginEventControllers.FirstOrDefault(entry => entry.PluginId == pluginId) is { } controller) + this.framework.RunOnFrameworkThread(() => { - Log.Verbose($"Removing PluginEventController for: {pluginId}"); - this.pluginEventControllers.Remove(controller); - controller.Dispose(); - } - } - - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.onUpdateCursor.Enable(); + if (this.pluginEventControllers.FirstOrDefault(entry => entry.PluginId == pluginId) is { } controller) + { + Log.Verbose($"Removing PluginEventController for: {pluginId}"); + this.pluginEventControllers.Remove(controller); + controller.Dispose(); + } + }); } /// diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs index 334542c71..4ab3de5ca 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs @@ -1,4 +1,5 @@ using Dalamud.Memory; + using FFXIVClientStructs.FFXIV.Component.GUI; namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; @@ -12,24 +13,62 @@ public abstract unsafe class AddonArgs /// Constant string representing the name of an addon that is invalid. /// public const string InvalidAddon = "NullAddon"; - + private string? addonName; + private IntPtr addon; /// /// Gets the name of the addon this args referrers to. /// public string AddonName => this.GetAddonName(); - + /// /// Gets the pointer to the addons AtkUnitBase. /// - public nint Addon { get; init; } - + public nint Addon + { + get => this.AddonInternal; + init => this.AddonInternal = value; + } + /// /// Gets the type of these args. /// public abstract AddonArgsType Type { get; } + /// + /// Gets or sets the pointer to the addons AtkUnitBase. + /// + internal nint AddonInternal + { + get => this.addon; + set + { + if (this.addon == value) + return; + + this.addon = value; + this.addonName = null; + } + } + + /// + /// Checks if addon name matches the given span of char. + /// + /// The name to check. + /// Whether it is the case. + internal bool IsAddon(ReadOnlySpan name) + { + if (this.Addon == nint.Zero) return false; + if (name.Length is 0 or > 0x20) + return false; + + var addonPointer = (AtkUnitBase*)this.Addon; + if (addonPointer->Name is null) return false; + + return MemoryHelper.EqualsZeroTerminatedString(name, (nint)addonPointer->Name, null, 0x20); + } + /// /// Helper method for ensuring the name of the addon is valid. /// diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonDrawArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonDrawArgs.cs index 10d46a573..989e11912 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonDrawArgs.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonDrawArgs.cs @@ -3,8 +3,22 @@ /// /// Addon argument data for Draw events. /// -public class AddonDrawArgs : AddonArgs +public class AddonDrawArgs : AddonArgs, ICloneable { + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonDrawArgs() + { + } + /// public override AddonArgsType Type => AddonArgsType.Draw; + + /// + public AddonDrawArgs Clone() => (AddonDrawArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonFinalizeArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonFinalizeArgs.cs index caf422927..d9401b414 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonFinalizeArgs.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonFinalizeArgs.cs @@ -3,8 +3,22 @@ namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; /// /// Addon argument data for ReceiveEvent events. /// -public class AddonFinalizeArgs : AddonArgs +public class AddonFinalizeArgs : AddonArgs, ICloneable { + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonFinalizeArgs() + { + } + /// public override AddonArgsType Type => AddonArgsType.Finalize; + + /// + public AddonFinalizeArgs Clone() => (AddonFinalizeArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonReceiveEventArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonReceiveEventArgs.cs index df75307f1..a557b0cb3 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonReceiveEventArgs.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonReceiveEventArgs.cs @@ -3,28 +3,42 @@ namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; /// /// Addon argument data for ReceiveEvent events. /// -public class AddonReceiveEventArgs : AddonArgs +public class AddonReceiveEventArgs : AddonArgs, ICloneable { + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonReceiveEventArgs() + { + } + /// public override AddonArgsType Type => AddonArgsType.ReceiveEvent; - + /// - /// Gets the AtkEventType for this event message. + /// Gets or sets the AtkEventType for this event message. /// - public byte AtkEventType { get; init; } - + public byte AtkEventType { get; set; } + /// - /// Gets the event id for this event message. + /// Gets or sets the event id for this event message. /// - public int EventParam { get; init; } - + public int EventParam { get; set; } + /// - /// Gets the pointer to an AtkEvent for this event message. + /// Gets or sets the pointer to an AtkEvent for this event message. /// - public nint AtkEvent { get; init; } - + public nint AtkEvent { get; set; } + /// - /// Gets the pointer to a block of data for this event message. + /// Gets or sets the pointer to a block of data for this event message. /// - public nint Data { get; init; } + public nint Data { get; set; } + + /// + public AddonReceiveEventArgs Clone() => (AddonReceiveEventArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRefreshArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRefreshArgs.cs index b6ac6d8b6..6e1b11ead 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRefreshArgs.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRefreshArgs.cs @@ -5,23 +5,37 @@ namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; /// /// Addon argument data for Refresh events. /// -public class AddonRefreshArgs : AddonArgs +public class AddonRefreshArgs : AddonArgs, ICloneable { + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonRefreshArgs() + { + } + /// public override AddonArgsType Type => AddonArgsType.Refresh; - + /// - /// Gets the number of AtkValues. + /// Gets or sets the number of AtkValues. /// - public uint AtkValueCount { get; init; } - + public uint AtkValueCount { get; set; } + /// - /// Gets the address of the AtkValue array. + /// Gets or sets the address of the AtkValue array. /// - public nint AtkValues { get; init; } - + public nint AtkValues { get; set; } + /// /// Gets the AtkValues in the form of a span. /// public unsafe Span AtkValueSpan => new(this.AtkValues.ToPointer(), (int)this.AtkValueCount); + + /// + public AddonRefreshArgs Clone() => (AddonRefreshArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRequestedUpdateArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRequestedUpdateArgs.cs index 1b743b31a..26357abb0 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRequestedUpdateArgs.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonRequestedUpdateArgs.cs @@ -3,18 +3,32 @@ /// /// Addon argument data for OnRequestedUpdate events. /// -public class AddonRequestedUpdateArgs : AddonArgs +public class AddonRequestedUpdateArgs : AddonArgs, ICloneable { + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonRequestedUpdateArgs() + { + } + /// public override AddonArgsType Type => AddonArgsType.RequestedUpdate; - + /// - /// Gets the NumberArrayData** for this event. + /// Gets or sets the NumberArrayData** for this event. /// - public nint NumberArrayData { get; init; } - + public nint NumberArrayData { get; set; } + /// - /// Gets the StringArrayData** for this event. + /// Gets or sets the StringArrayData** for this event. /// - public nint StringArrayData { get; init; } + public nint StringArrayData { get; set; } + + /// + public AddonRequestedUpdateArgs Clone() => (AddonRequestedUpdateArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonSetupArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonSetupArgs.cs index df2ec26be..19c93ce25 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonSetupArgs.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonSetupArgs.cs @@ -5,23 +5,37 @@ namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; /// /// Addon argument data for Setup events. /// -public class AddonSetupArgs : AddonArgs +public class AddonSetupArgs : AddonArgs, ICloneable { + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonSetupArgs() + { + } + /// public override AddonArgsType Type => AddonArgsType.Setup; - + /// - /// Gets the number of AtkValues. + /// Gets or sets the number of AtkValues. /// - public uint AtkValueCount { get; init; } - + public uint AtkValueCount { get; set; } + /// - /// Gets the address of the AtkValue array. + /// Gets or sets the address of the AtkValue array. /// - public nint AtkValues { get; init; } - + public nint AtkValues { get; set; } + /// /// Gets the AtkValues in the form of a span. /// public unsafe Span AtkValueSpan => new(this.AtkValues.ToPointer(), (int)this.AtkValueCount); + + /// + public AddonSetupArgs Clone() => (AddonSetupArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonUpdateArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonUpdateArgs.cs index 651fbcafb..cc34a7531 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonUpdateArgs.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonUpdateArgs.cs @@ -3,13 +3,36 @@ namespace Dalamud.Game.Addon.Lifecycle.AddonArgTypes; /// /// Addon argument data for Update events. /// -public class AddonUpdateArgs : AddonArgs +public class AddonUpdateArgs : AddonArgs, ICloneable { + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Not intended for public construction.", false)] + public AddonUpdateArgs() + { + } + /// public override AddonArgsType Type => AddonArgsType.Update; - + /// /// Gets the time since the last update. /// - public float TimeDelta { get; init; } + public float TimeDelta + { + get => this.TimeDeltaInternal; + init => this.TimeDeltaInternal = value; + } + + /// + /// Gets or sets the time since the last update. + /// + internal float TimeDeltaInternal { get; set; } + + /// + public AddonUpdateArgs Clone() => (AddonUpdateArgs)this.MemberwiseClone(); + + /// + object ICloneable.Clone() => this.Clone(); } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs index 08a2d59ef..beaab7fcd 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Hooking; @@ -37,8 +38,17 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType private readonly Hook onAddonRefreshHook; private readonly CallHook onAddonRequestedUpdateHook; - private readonly ConcurrentBag newEventListeners = new(); - private readonly ConcurrentBag removeEventListeners = new(); + // Note: these can be sourced from ObjectPool of appropriate types instead, but since we don't import that NuGet + // package, and these events are always called from the main thread, this is fine. +#pragma warning disable CS0618 // Type or member is obsolete + // TODO: turn constructors of these internal + private readonly AddonSetupArgs recyclingSetupArgs = new(); + private readonly AddonFinalizeArgs recyclingFinalizeArgs = new(); + private readonly AddonDrawArgs recyclingDrawArgs = new(); + private readonly AddonUpdateArgs recyclingUpdateArgs = new(); + private readonly AddonRefreshArgs recyclingRefreshArgs = new(); + private readonly AddonRequestedUpdateArgs recyclingRequestedUpdateArgs = new(); +#pragma warning restore CS0618 // Type or member is obsolete [ServiceManager.ServiceConstructor] private AddonLifecycle(TargetSigScanner sigScanner) @@ -48,8 +58,6 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType // We want value of the function pointer at vFunc[2] this.disallowedReceiveEventAddress = ((nint*)this.address.AtkEventListener)![2]; - - this.framework.Update += this.OnFrameworkUpdate; this.onAddonSetupHook = new CallHook(this.address.AddonSetup, this.OnAddonSetup); this.onAddonSetup2Hook = new CallHook(this.address.AddonSetup2, this.OnAddonSetup); @@ -58,6 +66,14 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType this.onAddonUpdateHook = new CallHook(this.address.AddonUpdate, this.OnAddonUpdate); this.onAddonRefreshHook = Hook.FromAddress(this.address.AddonOnRefresh, this.OnAddonRefresh); this.onAddonRequestedUpdateHook = new CallHook(this.address.AddonOnRequestedUpdate, this.OnRequestedUpdate); + + this.onAddonSetupHook.Enable(); + this.onAddonSetup2Hook.Enable(); + this.onAddonFinalizeHook.Enable(); + this.onAddonDrawHook.Enable(); + this.onAddonUpdateHook.Enable(); + this.onAddonRefreshHook.Enable(); + this.onAddonRequestedUpdateHook.Enable(); } private delegate void AddonSetupDelegate(AtkUnitBase* addon, uint valueCount, AtkValue* values); @@ -85,8 +101,6 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType /// public void Dispose() { - this.framework.Update -= this.OnFrameworkUpdate; - this.onAddonSetupHook.Dispose(); this.onAddonSetup2Hook.Dispose(); this.onAddonFinalizeHook.Dispose(); @@ -107,7 +121,20 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType /// The listener to register. internal void RegisterListener(AddonLifecycleEventListener listener) { - this.newEventListeners.Add(listener); + this.framework.RunOnTick(() => + { + this.EventListeners.Add(listener); + + // 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 (listener is { EventType: AddonEvent.PreReceiveEvent or AddonEvent.PostReceiveEvent }) + { + if (this.ReceiveEventListeners.FirstOrDefault(listeners => listeners.AddonNames.Contains(listener.AddonName)) is { } receiveEventListener) + { + receiveEventListener.Hook?.Enable(); + } + } + }); } /// @@ -116,7 +143,24 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType /// The listener to unregister. internal void UnregisterListener(AddonLifecycleEventListener listener) { - this.removeEventListeners.Add(listener); + this.framework.RunOnTick(() => + { + this.EventListeners.Remove(listener); + + // If we are disabling an ReceiveEvent listener, check if we should disable the hook. + if (listener is { EventType: AddonEvent.PreReceiveEvent or AddonEvent.PostReceiveEvent }) + { + // Get the ReceiveEvent Listener for this addon + if (this.ReceiveEventListeners.FirstOrDefault(listeners => listeners.AddonNames.Contains(listener.AddonName)) is { } receiveEventListener) + { + // If there are no other listeners listening for this event, disable the hook. + if (!this.EventListeners.Any(listeners => listeners.AddonName.Contains(listener.AddonName) && listener.EventType is AddonEvent.PreReceiveEvent or AddonEvent.PostReceiveEvent)) + { + receiveEventListener.Hook?.Disable(); + } + } + } + }); } /// @@ -124,75 +168,30 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType /// /// Event Type. /// AddonArgs. - internal void InvokeListeners(AddonEvent eventType, AddonArgs args) + /// What to blame on errors. + internal void InvokeListenersSafely(AddonEvent eventType, AddonArgs args, [CallerMemberName] string blame = "") { - // 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))) + // Do not use linq; this is a high-traffic function, and more heap allocations avoided, the better. + foreach (var listener in this.EventListeners) { - listener.FunctionDelegate.Invoke(eventType, args); - } - } + if (listener.EventType != eventType) + continue; - // Used to prevent concurrency issues if plugins try to register during iteration of listeners. - private void OnFrameworkUpdate(IFramework unused) - { - if (this.newEventListeners.Any()) - { - foreach (var toAddListener in this.newEventListeners) + // Match on string.empty for listeners that want events for all addons. + if (!string.IsNullOrWhiteSpace(listener.AddonName) && !args.IsAddon(listener.AddonName)) + continue; + + try { - 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.ReceiveEventListeners.FirstOrDefault(listener => listener.AddonNames.Contains(toAddListener.AddonName)) is { } receiveEventListener) - { - receiveEventListener.Hook?.Enable(); - } - } + listener.FunctionDelegate.Invoke(eventType, args); } - - this.newEventListeners.Clear(); - } - - if (this.removeEventListeners.Any()) - { - foreach (var toRemoveListener in this.removeEventListeners) + catch (Exception e) { - 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(); - } - } - } + Log.Error(e, $"Exception in {blame} during {eventType} invoke."); } - - this.removeEventListeners.Clear(); } } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.onAddonSetupHook.Enable(); - this.onAddonSetup2Hook.Enable(); - this.onAddonFinalizeHook.Enable(); - this.onAddonDrawHook.Enable(); - this.onAddonUpdateHook.Enable(); - this.onAddonRefreshHook.Enable(); - this.onAddonRequestedUpdateHook.Enable(); - } - private void RegisterReceiveEventHook(AtkUnitBase* addon) { // Hook the addon's ReceiveEvent function here, but only enable the hook if we have an active listener. @@ -253,20 +252,13 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType { Log.Error(e, "Exception in OnAddonSetup ReceiveEvent Registration."); } - - try - { - this.InvokeListeners(AddonEvent.PreSetup, new AddonSetupArgs - { - Addon = (nint)addon, - AtkValueCount = valueCount, - AtkValues = (nint)values, - }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnAddonSetup pre-setup invoke."); - } + + this.recyclingSetupArgs.AddonInternal = (nint)addon; + this.recyclingSetupArgs.AtkValueCount = valueCount; + this.recyclingSetupArgs.AtkValues = (nint)values; + this.InvokeListenersSafely(AddonEvent.PreSetup, this.recyclingSetupArgs); + valueCount = this.recyclingSetupArgs.AtkValueCount; + values = (AtkValue*)this.recyclingSetupArgs.AtkValues; try { @@ -277,19 +269,7 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonSetup. This may be a bug in the game or another plugin hooking this method."); } - try - { - this.InvokeListeners(AddonEvent.PostSetup, new AddonSetupArgs - { - Addon = (nint)addon, - AtkValueCount = valueCount, - AtkValues = (nint)values, - }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnAddonSetup post-setup invoke."); - } + this.InvokeListenersSafely(AddonEvent.PostSetup, this.recyclingSetupArgs); } private void OnAddonFinalize(AtkUnitManager* unitManager, AtkUnitBase** atkUnitBase) @@ -303,15 +283,9 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType { Log.Error(e, "Exception in OnAddonFinalize ReceiveEvent Removal."); } - - try - { - this.InvokeListeners(AddonEvent.PreFinalize, new AddonFinalizeArgs { Addon = (nint)atkUnitBase[0] }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnAddonFinalize pre-finalize invoke."); - } + + this.recyclingFinalizeArgs.AddonInternal = (nint)atkUnitBase[0]; + this.InvokeListenersSafely(AddonEvent.PreFinalize, this.recyclingFinalizeArgs); try { @@ -325,14 +299,8 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType private void OnAddonDraw(AtkUnitBase* addon) { - try - { - this.InvokeListeners(AddonEvent.PreDraw, new AddonDrawArgs { Addon = (nint)addon }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnAddonDraw pre-draw invoke."); - } + this.recyclingDrawArgs.AddonInternal = (nint)addon; + this.InvokeListenersSafely(AddonEvent.PreDraw, this.recyclingDrawArgs); try { @@ -343,26 +311,14 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonDraw. This may be a bug in the game or another plugin hooking this method."); } - try - { - this.InvokeListeners(AddonEvent.PostDraw, new AddonDrawArgs { Addon = (nint)addon }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnAddonDraw post-draw invoke."); - } + this.InvokeListenersSafely(AddonEvent.PostDraw, this.recyclingDrawArgs); } private void OnAddonUpdate(AtkUnitBase* addon, float delta) { - try - { - this.InvokeListeners(AddonEvent.PreUpdate, new AddonUpdateArgs { Addon = (nint)addon, TimeDelta = delta }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnAddonUpdate pre-update invoke."); - } + this.recyclingUpdateArgs.AddonInternal = (nint)addon; + this.recyclingUpdateArgs.TimeDeltaInternal = delta; + this.InvokeListenersSafely(AddonEvent.PreUpdate, this.recyclingUpdateArgs); try { @@ -373,33 +329,19 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonUpdate. This may be a bug in the game or another plugin hooking this method."); } - try - { - this.InvokeListeners(AddonEvent.PostUpdate, new AddonUpdateArgs { Addon = (nint)addon, TimeDelta = delta }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnAddonUpdate post-update invoke."); - } + this.InvokeListenersSafely(AddonEvent.PostUpdate, this.recyclingUpdateArgs); } private byte OnAddonRefresh(AtkUnitManager* atkUnitManager, AtkUnitBase* addon, uint valueCount, AtkValue* values) { byte result = 0; - - try - { - this.InvokeListeners(AddonEvent.PreRefresh, new AddonRefreshArgs - { - Addon = (nint)addon, - AtkValueCount = valueCount, - AtkValues = (nint)values, - }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnAddonRefresh pre-refresh invoke."); - } + + this.recyclingRefreshArgs.AddonInternal = (nint)addon; + this.recyclingRefreshArgs.AtkValueCount = valueCount; + this.recyclingRefreshArgs.AtkValues = (nint)values; + this.InvokeListenersSafely(AddonEvent.PreRefresh, this.recyclingRefreshArgs); + valueCount = this.recyclingRefreshArgs.AtkValueCount; + values = (AtkValue*)this.recyclingRefreshArgs.AtkValues; try { @@ -410,38 +352,18 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonRefresh. This may be a bug in the game or another plugin hooking this method."); } - try - { - this.InvokeListeners(AddonEvent.PostRefresh, new AddonRefreshArgs - { - Addon = (nint)addon, - AtkValueCount = valueCount, - AtkValues = (nint)values, - }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnAddonRefresh post-refresh invoke."); - } - + this.InvokeListenersSafely(AddonEvent.PostRefresh, this.recyclingRefreshArgs); return result; } private void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData) { - try - { - this.InvokeListeners(AddonEvent.PreRequestedUpdate, new AddonRequestedUpdateArgs - { - Addon = (nint)addon, - NumberArrayData = (nint)numberArrayData, - StringArrayData = (nint)stringArrayData, - }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnRequestedUpdate pre-requestedUpdate invoke."); - } + this.recyclingRequestedUpdateArgs.AddonInternal = (nint)addon; + this.recyclingRequestedUpdateArgs.NumberArrayData = (nint)numberArrayData; + this.recyclingRequestedUpdateArgs.StringArrayData = (nint)stringArrayData; + this.InvokeListenersSafely(AddonEvent.PreRequestedUpdate, this.recyclingRequestedUpdateArgs); + numberArrayData = (NumberArrayData**)this.recyclingRequestedUpdateArgs.NumberArrayData; + stringArrayData = (StringArrayData**)this.recyclingRequestedUpdateArgs.StringArrayData; try { @@ -452,19 +374,7 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonRequestedUpdate. This may be a bug in the game or another plugin hooking this method."); } - try - { - this.InvokeListeners(AddonEvent.PostRequestedUpdate, new AddonRequestedUpdateArgs - { - Addon = (nint)addon, - NumberArrayData = (nint)numberArrayData, - StringArrayData = (nint)stringArrayData, - }); - } - catch (Exception e) - { - Log.Error(e, "Exception in OnRequestedUpdate post-requestedUpdate invoke."); - } + this.InvokeListenersSafely(AddonEvent.PostRequestedUpdate, this.recyclingRequestedUpdateArgs); } } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs index 10171eb16..43aa71661 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs @@ -16,6 +16,13 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable { private static readonly ModuleLog Log = new("AddonLifecycle"); + // Note: these can be sourced from ObjectPool of appropriate types instead, but since we don't import that NuGet + // package, and these events are always called from the main thread, this is fine. +#pragma warning disable CS0618 // Type or member is obsolete + // TODO: turn constructors of these internal + private readonly AddonReceiveEventArgs recyclingReceiveEventArgs = new(); +#pragma warning restore CS0618 // Type or member is obsolete + /// /// Initializes a new instance of the class. /// @@ -74,22 +81,17 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable 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."); - } + + this.recyclingReceiveEventArgs.AddonInternal = (nint)addon; + this.recyclingReceiveEventArgs.AtkEventType = (byte)eventType; + this.recyclingReceiveEventArgs.EventParam = eventParam; + this.recyclingReceiveEventArgs.AtkEvent = (IntPtr)atkEvent; + this.recyclingReceiveEventArgs.Data = data; + this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PreReceiveEvent, this.recyclingReceiveEventArgs); + eventType = (AtkEventType)this.recyclingReceiveEventArgs.AtkEventType; + eventParam = this.recyclingReceiveEventArgs.EventParam; + atkEvent = (AtkEvent*)this.recyclingReceiveEventArgs.AtkEvent; + data = this.recyclingReceiveEventArgs.Data; try { @@ -100,20 +102,6 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable 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."); - } + this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PostReceiveEvent, this.recyclingReceiveEventArgs); } } diff --git a/Dalamud/Game/ClientState/ClientState.cs b/Dalamud/Game/ClientState/ClientState.cs index 3b3f65128..d387c2e2d 100644 --- a/Dalamud/Game/ClientState/ClientState.cs +++ b/Dalamud/Game/ClientState/ClientState.cs @@ -58,6 +58,8 @@ internal sealed class ClientState : IDisposable, IServiceType, IClientState this.framework.Update += this.FrameworkOnOnUpdateEvent; this.networkHandlers.CfPop += this.NetworkHandlersOnCfPop; + + this.setupTerritoryTypeHook.Enable(); } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] @@ -120,12 +122,6 @@ internal sealed class ClientState : IDisposable, IServiceType, IClientState this.networkHandlers.CfPop -= this.NetworkHandlersOnCfPop; } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.setupTerritoryTypeHook.Enable(); - } - private IntPtr SetupTerritoryTypeDetour(IntPtr manager, ushort terriType) { this.TerritoryType = terriType; diff --git a/Dalamud/Game/ClientState/Conditions/Condition.cs b/Dalamud/Game/ClientState/Conditions/Condition.cs index 2db47ea4d..a298b1502 100644 --- a/Dalamud/Game/ClientState/Conditions/Condition.cs +++ b/Dalamud/Game/ClientState/Conditions/Condition.cs @@ -16,6 +16,9 @@ internal sealed partial class Condition : IServiceType, ICondition /// Gets the current max number of conditions. You can get this just by looking at the condition sheet and how many rows it has. /// internal const int MaxConditionEntries = 104; + + [ServiceManager.ServiceDependency] + private readonly Framework framework = Service.Get(); private readonly bool[] cache = new bool[MaxConditionEntries]; @@ -24,6 +27,12 @@ internal sealed partial class Condition : IServiceType, ICondition { var resolver = clientState.AddressResolver; this.Address = resolver.ConditionFlags; + + // Initialization + for (var i = 0; i < MaxConditionEntries; i++) + this.cache[i] = this[i]; + + this.framework.Update += this.FrameworkUpdate; } /// @@ -80,17 +89,7 @@ internal sealed partial class Condition : IServiceType, ICondition return false; } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(Framework framework) - { - // Initialization - for (var i = 0; i < MaxConditionEntries; i++) - this.cache[i] = this[i]; - - framework.Update += this.FrameworkUpdate; - } - - private void FrameworkUpdate(IFramework framework) + private void FrameworkUpdate(IFramework unused) { for (var i = 0; i < MaxConditionEntries; i++) { @@ -144,7 +143,7 @@ internal sealed partial class Condition : IDisposable if (disposing) { - Service.Get().Update -= this.FrameworkUpdate; + this.framework.Update -= this.FrameworkUpdate; } this.isDisposed = true; diff --git a/Dalamud/Game/ClientState/GamePad/GamepadState.cs b/Dalamud/Game/ClientState/GamePad/GamepadState.cs index b03db6df2..40e632113 100644 --- a/Dalamud/Game/ClientState/GamePad/GamepadState.cs +++ b/Dalamud/Game/ClientState/GamePad/GamepadState.cs @@ -38,6 +38,7 @@ internal unsafe class GamepadState : IDisposable, IServiceType, IGamepadState var resolver = clientState.AddressResolver; Log.Verbose($"GamepadPoll address 0x{resolver.GamepadPoll.ToInt64():X}"); this.gamepadPoll = Hook.FromAddress(resolver.GamepadPoll, this.GamepadPollDetour); + this.gamepadPoll?.Enable(); } private delegate int ControllerPoll(IntPtr controllerInput); @@ -114,12 +115,6 @@ internal unsafe class GamepadState : IDisposable, IServiceType, IGamepadState GC.SuppressFinalize(this); } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.gamepadPoll?.Enable(); - } - private int GamepadPollDetour(IntPtr gamepadInput) { var original = this.gamepadPoll!.Original(gamepadInput); diff --git a/Dalamud/Game/DutyState/DutyState.cs b/Dalamud/Game/DutyState/DutyState.cs index 66356033b..c4bda0d19 100644 --- a/Dalamud/Game/DutyState/DutyState.cs +++ b/Dalamud/Game/DutyState/DutyState.cs @@ -37,6 +37,8 @@ internal unsafe class DutyState : IDisposable, IServiceType, IDutyState this.framework.Update += this.FrameworkOnUpdateEvent; this.clientState.TerritoryChanged += this.TerritoryOnChangedEvent; + + this.contentDirectorNetworkMessageHook.Enable(); } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] @@ -67,12 +69,6 @@ internal unsafe class DutyState : IDisposable, IServiceType, IDutyState this.clientState.TerritoryChanged -= this.TerritoryOnChangedEvent; } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.contentDirectorNetworkMessageHook.Enable(); - } - private byte ContentDirectorNetworkMessageDetour(IntPtr a1, IntPtr a2, ushort* a3) { var category = *a3; diff --git a/Dalamud/Game/Framework.cs b/Dalamud/Game/Framework.cs index 6db9f7312..ce34f2c06 100644 --- a/Dalamud/Game/Framework.cs +++ b/Dalamud/Game/Framework.cs @@ -58,6 +58,9 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework this.updateHook = Hook.FromAddress(this.addressResolver.TickAddress, this.HandleFrameworkUpdate); this.destroyHook = Hook.FromAddress(this.addressResolver.DestroyAddress, this.HandleFrameworkDestroy); + + this.updateHook.Enable(); + this.destroyHook.Enable(); } /// @@ -330,13 +333,6 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework } } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.updateHook.Enable(); - this.destroyHook.Enable(); - } - private void RunPendingTickTasks() { if (this.runOnNextTickTaskList.Count == 0 && this.runOnNextTickTaskList2.Count == 0) diff --git a/Dalamud/Game/Gui/ChatGui.cs b/Dalamud/Game/Gui/ChatGui.cs index 50c5b2908..02b52ee56 100644 --- a/Dalamud/Game/Gui/ChatGui.cs +++ b/Dalamud/Game/Gui/ChatGui.cs @@ -1,29 +1,38 @@ -using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Runtime.InteropServices; using Dalamud.Configuration.Internal; -using Dalamud.Game.Libc; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Hooking; using Dalamud.IoC; using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; +using Dalamud.Memory; using Dalamud.Plugin.Services; using Dalamud.Utility; -using Serilog; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; namespace Dalamud.Game.Gui; +// TODO(api10): Update IChatGui, ChatGui and XivChatEntry to use correct types and names: +// "uint SenderId" should be "int Timestamp". +// "IntPtr Parameters" should be something like "bool Silent". It suppresses new message sounds in certain channels. +// This has to be a 1 byte boolean, so only change it to bool if marshalling is disabled. + /// /// This class handles interacting with the native chat UI. /// [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] -internal sealed class ChatGui : IDisposable, IServiceType, IChatGui +internal sealed unsafe class ChatGui : IDisposable, IServiceType, IChatGui { + private static readonly ModuleLog Log = new("ChatGui"); + private readonly ChatGuiAddressResolver address; private readonly Queue chatQueue = new(); @@ -36,10 +45,7 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); - [ServiceManager.ServiceDependency] - private readonly LibcFunction libcFunction = Service.Get(); - - private IntPtr baseAddress = IntPtr.Zero; + private ImmutableDictionary<(string PluginName, uint CommandId), Action>? dalamudLinkHandlersCopy; [ServiceManager.ServiceConstructor] private ChatGui(TargetSigScanner sigScanner) @@ -47,13 +53,17 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui this.address = new ChatGuiAddressResolver(); this.address.Setup(sigScanner); - this.printMessageHook = Hook.FromAddress(this.address.PrintMessage, this.HandlePrintMessageDetour); + this.printMessageHook = Hook.FromAddress((nint)RaptureLogModule.Addresses.PrintMessage.Value, this.HandlePrintMessageDetour); this.populateItemLinkHook = Hook.FromAddress(this.address.PopulateItemLinkObject, this.HandlePopulateItemLinkDetour); this.interactableLinkClickedHook = Hook.FromAddress(this.address.InteractableLinkClicked, this.InteractableLinkClickedDetour); + + this.printMessageHook.Enable(); + this.populateItemLinkHook.Enable(); + this.interactableLinkClickedHook.Enable(); } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] - private delegate IntPtr PrintMessageDelegate(IntPtr manager, XivChatType chatType, IntPtr senderName, IntPtr message, uint senderId, IntPtr parameter); + private delegate uint PrintMessageDelegate(RaptureLogModule* manager, XivChatType chatType, Utf8String* sender, Utf8String* message, int timestamp, byte silent); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void PopulateItemLinkDelegate(IntPtr linkObjectPtr, IntPtr itemInfoPtr); @@ -80,7 +90,21 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui public byte LastLinkedItemFlags { get; private set; } /// - public IReadOnlyDictionary<(string PluginName, uint CommandId), Action> RegisteredLinkHandlers => this.dalamudLinkHandlers; + public IReadOnlyDictionary<(string PluginName, uint CommandId), Action> RegisteredLinkHandlers + { + get + { + var copy = this.dalamudLinkHandlersCopy; + if (copy is not null) + return copy; + + lock (this.dalamudLinkHandlers) + { + return this.dalamudLinkHandlersCopy ??= + this.dalamudLinkHandlers.ToImmutableDictionary(x => x.Key, x => x.Value); + } + } + } /// /// Dispose of managed and unmanaged resources. @@ -131,18 +155,13 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui { var chat = this.chatQueue.Dequeue(); - if (this.baseAddress == IntPtr.Zero) - { - continue; - } + var sender = Utf8String.FromSequence(chat.Name.Encode()); + var message = Utf8String.FromSequence(chat.Message.Encode()); - var senderRaw = (chat.Name ?? string.Empty).Encode(); - using var senderOwned = this.libcFunction.NewString(senderRaw); + this.HandlePrintMessageDetour(RaptureLogModule.Instance(), chat.Type, sender, message, (int)chat.SenderId, (byte)(chat.Parameters != 0 ? 1 : 0)); - var messageRaw = (chat.Message ?? string.Empty).Encode(); - using var messageOwned = this.libcFunction.NewString(messageRaw); - - this.HandlePrintMessageDetour(this.baseAddress, chat.Type, senderOwned.Address, messageOwned.Address, chat.SenderId, chat.Parameters); + sender->Dtor(true); + message->Dtor(true); } } @@ -156,7 +175,12 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui internal DalamudLinkPayload AddChatLinkHandler(string pluginName, uint commandId, Action commandAction) { var payload = new DalamudLinkPayload { Plugin = pluginName, CommandId = commandId }; - this.dalamudLinkHandlers.Add((pluginName, commandId), commandAction); + lock (this.dalamudLinkHandlers) + { + this.dalamudLinkHandlers.Add((pluginName, commandId), commandAction); + this.dalamudLinkHandlersCopy = null; + } + return payload; } @@ -166,9 +190,14 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui /// The name of the plugin handling the links. internal void RemoveChatLinkHandler(string pluginName) { - foreach (var handler in this.dalamudLinkHandlers.Keys.ToList().Where(k => k.PluginName == pluginName)) + lock (this.dalamudLinkHandlers) { - this.dalamudLinkHandlers.Remove(handler); + var changed = false; + + foreach (var handler in this.RegisteredLinkHandlers.Keys.Where(k => k.PluginName == pluginName)) + changed |= this.dalamudLinkHandlers.Remove(handler); + if (changed) + this.dalamudLinkHandlersCopy = null; } } @@ -179,15 +208,11 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui /// The ID of the command to be removed. internal void RemoveChatLinkHandler(string pluginName, uint commandId) { - this.dalamudLinkHandlers.Remove((pluginName, commandId)); - } - - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.printMessageHook.Enable(); - this.populateItemLinkHook.Enable(); - this.interactableLinkClickedHook.Enable(); + lock (this.dalamudLinkHandlers) + { + if (this.dalamudLinkHandlers.Remove((pluginName, commandId))) + this.dalamudLinkHandlersCopy = null; + } } private void PrintTagged(string message, XivChatType channel, string? tag, ushort? color) @@ -254,29 +279,17 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui } } - private IntPtr HandlePrintMessageDetour(IntPtr manager, XivChatType chatType, IntPtr pSenderName, IntPtr pMessage, uint senderId, IntPtr parameter) + private uint HandlePrintMessageDetour(RaptureLogModule* manager, XivChatType chatType, Utf8String* sender, Utf8String* message, int timestamp, byte silent) { - var retVal = IntPtr.Zero; + var messageId = 0u; try { - var sender = StdString.ReadFromPointer(pSenderName); - var parsedSender = SeString.Parse(sender.RawData); - var originalSenderData = (byte[])sender.RawData.Clone(); - var oldEditedSender = parsedSender.Encode(); - var senderPtr = pSenderName; - OwnedStdString allocatedString = null; + var originalSenderData = sender->AsSpan().ToArray(); + var originalMessageData = message->AsSpan().ToArray(); - var message = StdString.ReadFromPointer(pMessage); - var parsedMessage = SeString.Parse(message.RawData); - var originalMessageData = (byte[])message.RawData.Clone(); - var oldEdited = parsedMessage.Encode(); - var messagePtr = pMessage; - OwnedStdString allocatedStringSender = null; - - // Log.Verbose("[CHATGUI][{0}][{1}]", parsedSender.TextValue, parsedMessage.TextValue); - - // Log.Debug($"HandlePrintMessageDetour {manager} - [{chattype}] [{BitConverter.ToString(message.RawData).Replace("-", " ")}] {message.Value} from {senderName.Value}"); + var parsedSender = SeString.Parse(originalSenderData); + var parsedMessage = SeString.Parse(originalMessageData); // Call events var isHandled = false; @@ -287,7 +300,7 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui try { var messageHandledDelegate = @delegate as IChatGui.OnCheckMessageHandledDelegate; - messageHandledDelegate!.Invoke(chatType, senderId, ref parsedSender, ref parsedMessage, ref isHandled); + messageHandledDelegate!.Invoke(chatType, (uint)timestamp, ref parsedSender, ref parsedMessage, ref isHandled); } catch (Exception e) { @@ -303,7 +316,7 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui try { var messageHandledDelegate = @delegate as IChatGui.OnMessageDelegate; - messageHandledDelegate!.Invoke(chatType, senderId, ref parsedSender, ref parsedMessage, ref isHandled); + messageHandledDelegate!.Invoke(chatType, (uint)timestamp, ref parsedSender, ref parsedMessage, ref isHandled); } catch (Exception e) { @@ -312,61 +325,39 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui } } - var newEdited = parsedMessage.Encode(); - if (!Util.FastByteArrayCompare(oldEdited, newEdited)) + var possiblyModifiedSenderData = parsedSender.Encode(); + var possiblyModifiedMessageData = parsedMessage.Encode(); + + if (!Util.FastByteArrayCompare(originalSenderData, possiblyModifiedSenderData)) { - Log.Verbose("SeString was edited, taking precedence over StdString edit."); - message.RawData = newEdited; - // Log.Debug($"\nOLD: {BitConverter.ToString(originalMessageData)}\nNEW: {BitConverter.ToString(newEdited)}"); + Log.Verbose($"HandlePrintMessageDetour Sender modified: {SeString.Parse(originalSenderData)} -> {parsedSender}"); + sender->SetString(possiblyModifiedSenderData); } - if (!Util.FastByteArrayCompare(originalMessageData, message.RawData)) + if (!Util.FastByteArrayCompare(originalMessageData, possiblyModifiedMessageData)) { - allocatedString = this.libcFunction.NewString(message.RawData); - Log.Debug($"HandlePrintMessageDetour String modified: {originalMessageData}({messagePtr}) -> {message}({allocatedString.Address})"); - messagePtr = allocatedString.Address; - } - - var newEditedSender = parsedSender.Encode(); - if (!Util.FastByteArrayCompare(oldEditedSender, newEditedSender)) - { - Log.Verbose("SeString was edited, taking precedence over StdString edit."); - sender.RawData = newEditedSender; - // Log.Debug($"\nOLD: {BitConverter.ToString(originalMessageData)}\nNEW: {BitConverter.ToString(newEdited)}"); - } - - if (!Util.FastByteArrayCompare(originalSenderData, sender.RawData)) - { - allocatedStringSender = this.libcFunction.NewString(sender.RawData); - Log.Debug( - $"HandlePrintMessageDetour Sender modified: {originalSenderData}({senderPtr}) -> {sender}({allocatedStringSender.Address})"); - senderPtr = allocatedStringSender.Address; + Log.Verbose($"HandlePrintMessageDetour Message modified: {SeString.Parse(originalMessageData)} -> {parsedMessage}"); + message->SetString(possiblyModifiedMessageData); } // Print the original chat if it's handled. if (isHandled) { - this.ChatMessageHandled?.Invoke(chatType, senderId, parsedSender, parsedMessage); + this.ChatMessageHandled?.Invoke(chatType, (uint)timestamp, parsedSender, parsedMessage); } else { - retVal = this.printMessageHook.Original(manager, chatType, senderPtr, messagePtr, senderId, parameter); - this.ChatMessageUnhandled?.Invoke(chatType, senderId, parsedSender, parsedMessage); + messageId = this.printMessageHook.Original(manager, chatType, sender, message, timestamp, silent); + this.ChatMessageUnhandled?.Invoke(chatType, (uint)timestamp, parsedSender, parsedMessage); } - - if (this.baseAddress == IntPtr.Zero) - this.baseAddress = manager; - - allocatedString?.Dispose(); - allocatedStringSender?.Dispose(); } catch (Exception ex) { Log.Error(ex, "Exception on OnChatMessage hook."); - retVal = this.printMessageHook.Original(manager, chatType, pSenderName, pMessage, senderId, parameter); + messageId = this.printMessageHook.Original(manager, chatType, sender, message, timestamp, silent); } - return retVal; + return messageId; } private void InteractableLinkClickedDetour(IntPtr managerPtr, IntPtr messagePtr) @@ -384,18 +375,14 @@ internal sealed class ChatGui : IDisposable, IServiceType, IChatGui Log.Verbose($"InteractableLinkClicked: {Payload.EmbeddedInfoType.DalamudLink}"); var payloadPtr = Marshal.ReadIntPtr(messagePtr, 0x10); - var messageSize = 0; - while (Marshal.ReadByte(payloadPtr, messageSize) != 0) messageSize++; - var payloadBytes = new byte[messageSize]; - Marshal.Copy(payloadPtr, payloadBytes, 0, messageSize); - var seStr = SeString.Parse(payloadBytes); + var seStr = MemoryHelper.ReadSeStringNullTerminated(payloadPtr); var terminatorIndex = seStr.Payloads.IndexOf(RawPayload.LinkTerminator); var payloads = terminatorIndex >= 0 ? seStr.Payloads.Take(terminatorIndex + 1).ToList() : seStr.Payloads; if (payloads.Count == 0) return; var linkPayload = payloads[0]; if (linkPayload is DalamudLinkPayload link) { - if (this.dalamudLinkHandlers.TryGetValue((link.Plugin, link.CommandId), out var value)) + if (this.RegisteredLinkHandlers.TryGetValue((link.Plugin, link.CommandId), out var value)) { Log.Verbose($"Sending DalamudLink to {link.Plugin}: {link.CommandId}"); value.Invoke(link.CommandId, new SeString(payloads)); diff --git a/Dalamud/Game/Gui/ChatGuiAddressResolver.cs b/Dalamud/Game/Gui/ChatGuiAddressResolver.cs index d653ec146..ae53f90e9 100644 --- a/Dalamud/Game/Gui/ChatGuiAddressResolver.cs +++ b/Dalamud/Game/Gui/ChatGuiAddressResolver.cs @@ -5,11 +5,6 @@ namespace Dalamud.Game.Gui; /// internal sealed class ChatGuiAddressResolver : BaseAddressResolver { - /// - /// Gets the address of the native PrintMessage method. - /// - public IntPtr PrintMessage { get; private set; } - /// /// Gets the address of the native PopulateItemLinkObject method. /// @@ -20,77 +15,9 @@ internal sealed class ChatGuiAddressResolver : BaseAddressResolver /// public IntPtr InteractableLinkClicked { get; private set; } - /* - --- for reference: 4.57 --- - .text:00000001405CD210 ; __int64 __fastcall Xiv::Gui::ChatGui::PrintMessage(__int64 handler, unsigned __int16 chatType, __int64 senderName, __int64 message, int senderActorId, char isLocal) - .text:00000001405CD210 Xiv__Gui__ChatGui__PrintMessage proc near - .text:00000001405CD210 ; CODE XREF: sub_1401419F0+201↑p - .text:00000001405CD210 ; sub_140141D10+220↑p ... - .text:00000001405CD210 - .text:00000001405CD210 var_220 = qword ptr -220h - .text:00000001405CD210 var_218 = byte ptr -218h - .text:00000001405CD210 var_210 = word ptr -210h - .text:00000001405CD210 var_208 = byte ptr -208h - .text:00000001405CD210 var_200 = word ptr -200h - .text:00000001405CD210 var_1FC = dword ptr -1FCh - .text:00000001405CD210 var_1F8 = qword ptr -1F8h - .text:00000001405CD210 var_1F0 = qword ptr -1F0h - .text:00000001405CD210 var_1E8 = qword ptr -1E8h - .text:00000001405CD210 var_1E0 = dword ptr -1E0h - .text:00000001405CD210 var_1DC = word ptr -1DCh - .text:00000001405CD210 var_1DA = word ptr -1DAh - .text:00000001405CD210 var_1D8 = qword ptr -1D8h - .text:00000001405CD210 var_1D0 = byte ptr -1D0h - .text:00000001405CD210 var_1C8 = qword ptr -1C8h - .text:00000001405CD210 var_1B0 = dword ptr -1B0h - .text:00000001405CD210 var_1AC = dword ptr -1ACh - .text:00000001405CD210 var_1A8 = dword ptr -1A8h - .text:00000001405CD210 var_1A4 = dword ptr -1A4h - .text:00000001405CD210 var_1A0 = dword ptr -1A0h - .text:00000001405CD210 var_160 = dword ptr -160h - .text:00000001405CD210 var_15C = dword ptr -15Ch - .text:00000001405CD210 var_140 = dword ptr -140h - .text:00000001405CD210 var_138 = dword ptr -138h - .text:00000001405CD210 var_130 = byte ptr -130h - .text:00000001405CD210 var_C0 = byte ptr -0C0h - .text:00000001405CD210 var_50 = qword ptr -50h - .text:00000001405CD210 var_38 = qword ptr -38h - .text:00000001405CD210 var_30 = qword ptr -30h - .text:00000001405CD210 var_28 = qword ptr -28h - .text:00000001405CD210 var_20 = qword ptr -20h - .text:00000001405CD210 senderActorId = dword ptr 30h - .text:00000001405CD210 isLocal = byte ptr 38h - .text:00000001405CD210 - .text:00000001405CD210 ; __unwind { // __GSHandlerCheck - .text:00000001405CD210 push rbp - .text:00000001405CD212 push rdi - .text:00000001405CD213 push r14 - .text:00000001405CD215 push r15 - .text:00000001405CD217 lea rbp, [rsp-128h] - .text:00000001405CD21F sub rsp, 228h - .text:00000001405CD226 mov rax, cs:__security_cookie - .text:00000001405CD22D xor rax, rsp - .text:00000001405CD230 mov [rbp+140h+var_50], rax - .text:00000001405CD237 xor r10b, r10b - .text:00000001405CD23A mov [rsp+240h+var_1F8], rcx - .text:00000001405CD23F xor eax, eax - .text:00000001405CD241 mov r11, r9 - .text:00000001405CD244 mov r14, r8 - .text:00000001405CD247 mov r9d, eax - .text:00000001405CD24A movzx r15d, dx - .text:00000001405CD24E lea r8, [rcx+0C10h] - .text:00000001405CD255 mov rdi, rcx - */ - /// protected override void Setup64Bit(ISigScanner sig) { - // PrintMessage = sig.ScanText("4055 57 41 ?? 41 ?? 488DAC24D8FEFFFF 4881EC28020000 488B05???????? 4833C4 488985F0000000 4532D2 48894C2448"); LAST PART FOR 5.1??? - this.PrintMessage = sig.ScanText("40 55 53 56 41 54 41 57 48 8D AC 24 ?? ?? ?? ?? 48 81 EC 20 02 00 00 48 8B 05"); - // PrintMessage = sig.ScanText("4055 57 41 ?? 41 ?? 488DAC24E8FEFFFF 4881EC18020000 488B05???????? 4833C4 488985E0000000 4532D2 48894C2438"); old - - // PrintMessage = sig.ScanText("40 55 57 41 56 41 57 48 8D AC 24 D8 FE FF FF 48 81 EC 28 02 00 00 48 8B 05 63 47 4A 01 48 33 C4 48 89 85 F0 00 00 00 45 32 D2 48 89 4C 24 48 33"); - // PopulateItemLinkObject = sig.ScanText("48 89 5C 24 08 57 48 83 EC 20 80 7A 06 00 48 8B DA 48 8B F9 74 14 48 8B CA E8 32 03 00 00 48 8B C8 E8 FA F2 B0 FF 8B C8 EB 1D 0F B6 42 14 8B 4A"); // PopulateItemLinkObject = sig.ScanText( "48 89 5C 24 08 57 48 83 EC 20 80 7A 06 00 48 8B DA 48 8B F9 74 14 48 8B CA E8 32 03 00 00 48 8B C8 E8 ?? ?? B0 FF 8B C8 EB 1D 0F B6 42 14 8B 4A"); 5.0 diff --git a/Dalamud/Game/Gui/FlyText/FlyTextGui.cs b/Dalamud/Game/Gui/FlyText/FlyTextGui.cs index 36056883e..2383b4e53 100644 --- a/Dalamud/Game/Gui/FlyText/FlyTextGui.cs +++ b/Dalamud/Game/Gui/FlyText/FlyTextGui.cs @@ -36,6 +36,8 @@ internal sealed class FlyTextGui : IDisposable, IServiceType, IFlyTextGui this.addFlyTextNative = Marshal.GetDelegateForFunctionPointer(this.Address.AddFlyText); this.createFlyTextHook = Hook.FromAddress(this.Address.CreateFlyText, this.CreateFlyTextDetour); + + this.createFlyTextHook.Enable(); } /// @@ -143,12 +145,6 @@ internal sealed class FlyTextGui : IDisposable, IServiceType, IFlyTextGui return terminated; } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(GameGui gameGui) - { - this.createFlyTextHook.Enable(); - } - private IntPtr CreateFlyTextDetour( IntPtr addonFlyText, FlyTextKind kind, diff --git a/Dalamud/Game/Gui/GameGui.cs b/Dalamud/Game/Gui/GameGui.cs index a1a17436e..a97e19a0a 100644 --- a/Dalamud/Game/Gui/GameGui.cs +++ b/Dalamud/Game/Gui/GameGui.cs @@ -75,6 +75,15 @@ internal sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui this.toggleUiHideHook = Hook.FromAddress(this.address.ToggleUiHide, this.ToggleUiHideDetour); this.utf8StringFromSequenceHook = Hook.FromAddress(this.address.Utf8StringFromSequence, this.Utf8StringFromSequenceDetour); + + this.setGlobalBgmHook.Enable(); + this.handleItemHoverHook.Enable(); + this.handleItemOutHook.Enable(); + this.handleImmHook.Enable(); + this.toggleUiHideHook.Enable(); + this.handleActionHoverHook.Enable(); + this.handleActionOutHook.Enable(); + this.utf8StringFromSequenceHook.Enable(); } // Marshaled delegates @@ -376,19 +385,6 @@ internal sealed unsafe class GameGui : IDisposable, IServiceType, IGameGui this.GameUiHidden = false; } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.setGlobalBgmHook.Enable(); - this.handleItemHoverHook.Enable(); - this.handleItemOutHook.Enable(); - this.handleImmHook.Enable(); - this.toggleUiHideHook.Enable(); - this.handleActionHoverHook.Enable(); - this.handleActionOutHook.Enable(); - this.utf8StringFromSequenceHook.Enable(); - } - private IntPtr HandleSetGlobalBgmDetour(ushort bgmKey, byte a2, uint a3, uint a4, uint a5, byte a6) { var retVal = this.setGlobalBgmHook.Original(bgmKey, a2, a3, a4, a5, a6); diff --git a/Dalamud/Game/Gui/Internal/DalamudIME.cs b/Dalamud/Game/Gui/Internal/DalamudIME.cs index 37c072806..a9f6991ae 100644 --- a/Dalamud/Game/Gui/Internal/DalamudIME.cs +++ b/Dalamud/Game/Gui/Internal/DalamudIME.cs @@ -253,7 +253,7 @@ internal unsafe class DalamudIME : IDisposable, IServiceType } } - [ServiceManager.CallWhenServicesReady] + [ServiceManager.CallWhenServicesReady("Effectively waiting for cimgui.dll to become available.")] private void ContinueConstruction(InterfaceManager.InterfaceManagerWithScene interfaceManagerWithScene) { try diff --git a/Dalamud/Game/Gui/PartyFinder/PartyFinderGui.cs b/Dalamud/Game/Gui/PartyFinder/PartyFinderGui.cs index 61c0f62e4..4a8332d24 100644 --- a/Dalamud/Game/Gui/PartyFinder/PartyFinderGui.cs +++ b/Dalamud/Game/Gui/PartyFinder/PartyFinderGui.cs @@ -35,6 +35,7 @@ internal sealed class PartyFinderGui : IDisposable, IServiceType, IPartyFinderGu this.memory = Marshal.AllocHGlobal(PartyFinderPacket.PacketSize); this.receiveListingHook = Hook.FromAddress(this.address.ReceiveListing, this.HandleReceiveListingDetour); + this.receiveListingHook.Enable(); } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] @@ -60,12 +61,6 @@ internal sealed class PartyFinderGui : IDisposable, IServiceType, IPartyFinderGu } } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(GameGui gameGui) - { - this.receiveListingHook.Enable(); - } - private void HandleReceiveListingDetour(IntPtr managerPtr, IntPtr data) { try diff --git a/Dalamud/Game/Gui/Toast/ToastGui.cs b/Dalamud/Game/Gui/Toast/ToastGui.cs index 362edb3be..7491b7f13 100644 --- a/Dalamud/Game/Gui/Toast/ToastGui.cs +++ b/Dalamud/Game/Gui/Toast/ToastGui.cs @@ -41,6 +41,10 @@ internal sealed partial class ToastGui : IDisposable, IServiceType, IToastGui this.showNormalToastHook = Hook.FromAddress(this.address.ShowNormalToast, this.HandleNormalToastDetour); this.showQuestToastHook = Hook.FromAddress(this.address.ShowQuestToast, this.HandleQuestToastDetour); this.showErrorToastHook = Hook.FromAddress(this.address.ShowErrorToast, this.HandleErrorToastDetour); + + this.showNormalToastHook.Enable(); + this.showQuestToastHook.Enable(); + this.showErrorToastHook.Enable(); } #region Marshal delegates @@ -109,14 +113,6 @@ internal sealed partial class ToastGui : IDisposable, IServiceType, IToastGui return terminated; } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(GameGui gameGui) - { - this.showNormalToastHook.Enable(); - this.showQuestToastHook.Enable(); - this.showErrorToastHook.Enable(); - } - private SeString ParseString(IntPtr text) { var bytes = new List(); diff --git a/Dalamud/Game/Internal/DalamudAtkTweaks.cs b/Dalamud/Game/Internal/DalamudAtkTweaks.cs index 0013dca4d..4eb605a76 100644 --- a/Dalamud/Game/Internal/DalamudAtkTweaks.cs +++ b/Dalamud/Game/Internal/DalamudAtkTweaks.cs @@ -63,6 +63,10 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType this.locDalamudSettings = Loc.Localize("SystemMenuSettings", "Dalamud Settings"); // this.contextMenu.ContextMenuOpened += this.ContextMenuOnContextMenuOpened; + + this.hookAgentHudOpenSystemMenu.Enable(); + this.hookUiModuleRequestMainCommand.Enable(); + this.hookAtkUnitBaseReceiveGlobalEvent.Enable(); } private delegate void AgentHudOpenSystemMenuPrototype(void* thisPtr, AtkValue* atkValueArgs, uint menuSize); @@ -75,14 +79,6 @@ internal sealed unsafe partial class DalamudAtkTweaks : IServiceType private delegate IntPtr AtkUnitBaseReceiveGlobalEvent(AtkUnitBase* thisPtr, ushort cmd, uint a3, IntPtr a4, uint* a5); - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction(DalamudInterface dalamudInterface) - { - this.hookAgentHudOpenSystemMenu.Enable(); - this.hookUiModuleRequestMainCommand.Enable(); - this.hookAtkUnitBaseReceiveGlobalEvent.Enable(); - } - /* private void ContextMenuOnContextMenuOpened(ContextMenuOpenedArgs args) { diff --git a/Dalamud/Game/Inventory/GameInventory.cs b/Dalamud/Game/Inventory/GameInventory.cs new file mode 100644 index 000000000..1c7f3e3bf --- /dev/null +++ b/Dalamud/Game/Inventory/GameInventory.cs @@ -0,0 +1,547 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +using Dalamud.Game.Inventory.InventoryEventArgTypes; +using Dalamud.Hooking; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; +using Dalamud.Plugin.Internal; +using Dalamud.Plugin.Services; + +using FFXIVClientStructs.FFXIV.Client.UI; + +namespace Dalamud.Game.Inventory; + +/// +/// This class provides events for the players in-game inventory. +/// +[InterfaceVersion("1.0")] +[ServiceManager.BlockingEarlyLoadedService] +internal class GameInventory : IDisposable, IServiceType +{ + private readonly List subscribersPendingChange = new(); + private readonly List subscribers = new(); + + private readonly List addedEvents = new(); + private readonly List removedEvents = new(); + private readonly List changedEvents = new(); + private readonly List movedEvents = new(); + private readonly List splitEvents = new(); + private readonly List mergedEvents = new(); + + [ServiceManager.ServiceDependency] + private readonly Framework framework = Service.Get(); + + private readonly Hook raptureAtkModuleUpdateHook; + + private readonly GameInventoryType[] inventoryTypes; + private readonly GameInventoryItem[]?[] inventoryItems; + + private bool subscribersChanged; + private bool inventoriesMightBeChanged; + + [ServiceManager.ServiceConstructor] + private GameInventory() + { + this.inventoryTypes = Enum.GetValues(); + this.inventoryItems = new GameInventoryItem[this.inventoryTypes.Length][]; + + unsafe + { + this.raptureAtkModuleUpdateHook = Hook.FromFunctionPointerVariable( + new(&((RaptureAtkModule.RaptureAtkModuleVTable*)RaptureAtkModule.StaticAddressPointers.VTable)->Update), + this.RaptureAtkModuleUpdateDetour); + } + + this.raptureAtkModuleUpdateHook.Enable(); + } + + private unsafe delegate void RaptureAtkModuleUpdateDelegate(RaptureAtkModule* ram, float f1); + + /// + public void Dispose() + { + lock (this.subscribersPendingChange) + { + this.subscribers.Clear(); + this.subscribersPendingChange.Clear(); + this.subscribersChanged = false; + this.framework.Update -= this.OnFrameworkUpdate; + this.raptureAtkModuleUpdateHook.Dispose(); + } + } + + /// + /// Subscribe to events. + /// + /// The event target. + public void Subscribe(GameInventoryPluginScoped s) + { + lock (this.subscribersPendingChange) + { + this.subscribersPendingChange.Add(s); + this.subscribersChanged = true; + if (this.subscribersPendingChange.Count == 1) + { + this.inventoriesMightBeChanged = true; + this.framework.Update += this.OnFrameworkUpdate; + } + } + } + + /// + /// Unsubscribe from events. + /// + /// The event target. + public void Unsubscribe(GameInventoryPluginScoped s) + { + lock (this.subscribersPendingChange) + { + if (!this.subscribersPendingChange.Remove(s)) + return; + this.subscribersChanged = true; + if (this.subscribersPendingChange.Count == 0) + this.framework.Update -= this.OnFrameworkUpdate; + } + } + + private void OnFrameworkUpdate(IFramework framework1) + { + if (!this.inventoriesMightBeChanged) + return; + + this.inventoriesMightBeChanged = false; + + for (var i = 0; i < this.inventoryTypes.Length; i++) + { + var newItems = GameInventoryItem.GetReadOnlySpanOfInventory(this.inventoryTypes[i]); + if (newItems.IsEmpty) + continue; + + // Assumption: newItems is sorted by slots, and the last item has the highest slot number. + var oldItems = this.inventoryItems[i] ??= new GameInventoryItem[newItems[^1].InternalItem.Slot + 1]; + + foreach (ref readonly var newItem in newItems) + { + ref var oldItem = ref oldItems[newItem.InternalItem.Slot]; + + if (oldItem.IsEmpty) + { + if (!newItem.IsEmpty) + { + this.addedEvents.Add(new(newItem)); + oldItem = newItem; + } + } + else + { + if (newItem.IsEmpty) + { + this.removedEvents.Add(new(oldItem)); + oldItem = newItem; + } + else if (!oldItem.Equals(newItem)) + { + this.changedEvents.Add(new(oldItem, newItem)); + oldItem = newItem; + } + } + } + } + + // Was there any change? If not, stop further processing. + // Note that only these three are checked; the rest will be populated after this check. + if (this.addedEvents.Count == 0 && this.removedEvents.Count == 0 && this.changedEvents.Count == 0) + return; + + // Make a copy of subscribers, to accommodate self removal during the loop. + if (this.subscribersChanged) + { + bool isNew; + lock (this.subscribersPendingChange) + { + isNew = this.subscribersPendingChange.Any() && !this.subscribers.Any(); + this.subscribers.Clear(); + this.subscribers.AddRange(this.subscribersPendingChange); + this.subscribersChanged = false; + } + + // Is this the first time (resuming) scanning for changes? Then discard the "changes". + if (isNew) + { + this.addedEvents.Clear(); + this.removedEvents.Clear(); + this.changedEvents.Clear(); + return; + } + } + + // Broadcast InventoryChangedRaw. + // Same reason with the above on why are there 3 lists of events involved. + var allRawEventsCollection = new DeferredReadOnlyCollection( + this.addedEvents.Count + + this.removedEvents.Count + + this.changedEvents.Count, + () => Array.Empty() + .Concat(this.addedEvents) + .Concat(this.removedEvents) + .Concat(this.changedEvents)); + foreach (var s in this.subscribers) + s.InvokeChangedRaw(allRawEventsCollection); + + // Resolve moved items, from 1 added + 1 removed event. + for (var iAdded = this.addedEvents.Count - 1; iAdded >= 0; --iAdded) + { + var added = this.addedEvents[iAdded]; + for (var iRemoved = this.removedEvents.Count - 1; iRemoved >= 0; --iRemoved) + { + var removed = this.removedEvents[iRemoved]; + if (added.Item.ItemId != removed.Item.ItemId) + continue; + + this.movedEvents.Add(new(removed, added)); + + // Remove the reinterpreted entries. + this.addedEvents.RemoveAt(iAdded); + this.removedEvents.RemoveAt(iRemoved); + break; + } + } + + // Resolve moved items, from 2 changed events. + for (var i = this.changedEvents.Count - 1; i >= 0; --i) + { + var e1 = this.changedEvents[i]; + for (var j = i - 1; j >= 0; --j) + { + var e2 = this.changedEvents[j]; + if (e1.Item.ItemId != e2.OldItemState.ItemId || e1.OldItemState.ItemId != e2.Item.ItemId) + continue; + + // Move happened, and e2 has an item. + if (!e2.Item.IsEmpty) + this.movedEvents.Add(new(e1, e2)); + + // Move happened, and e1 has an item. + if (!e1.Item.IsEmpty) + this.movedEvents.Add(new(e2, e1)); + + // Remove the reinterpreted entries. Note that i > j. + this.changedEvents.RemoveAt(i); + this.changedEvents.RemoveAt(j); + + // We've removed two. Adjust the outer counter. + --i; + break; + } + } + + // Resolve split items, from 1 added + 1 changed event. + for (var iAdded = this.addedEvents.Count - 1; iAdded >= 0; --iAdded) + { + var added = this.addedEvents[iAdded]; + for (var iChanged = this.changedEvents.Count - 1; iChanged >= 0; --iChanged) + { + var changed = this.changedEvents[iChanged]; + if (added.Item.ItemId != changed.Item.ItemId || added.Item.ItemId != changed.OldItemState.ItemId) + continue; + + this.splitEvents.Add(new(changed, added)); + + // Remove the reinterpreted entries. + this.addedEvents.RemoveAt(iAdded); + this.changedEvents.RemoveAt(iChanged); + break; + } + } + + // Resolve merged items, from 1 removed + 1 changed event. + for (var iRemoved = this.removedEvents.Count - 1; iRemoved >= 0; --iRemoved) + { + var removed = this.removedEvents[iRemoved]; + for (var iChanged = this.changedEvents.Count - 1; iChanged >= 0; --iChanged) + { + var changed = this.changedEvents[iChanged]; + if (removed.Item.ItemId != changed.Item.ItemId || removed.Item.ItemId != changed.OldItemState.ItemId) + continue; + + this.mergedEvents.Add(new(removed, changed)); + + // Remove the reinterpreted entries. + this.removedEvents.RemoveAt(iRemoved); + this.changedEvents.RemoveAt(iChanged); + break; + } + } + + // Create a collection view of all events. + var allEventsCollection = new DeferredReadOnlyCollection( + this.addedEvents.Count + + this.removedEvents.Count + + this.changedEvents.Count + + this.movedEvents.Count + + this.splitEvents.Count + + this.mergedEvents.Count, + () => Array.Empty() + .Concat(this.addedEvents) + .Concat(this.removedEvents) + .Concat(this.changedEvents) + .Concat(this.movedEvents) + .Concat(this.splitEvents) + .Concat(this.mergedEvents)); + + // Broadcast the rest. + foreach (var s in this.subscribers) + { + s.InvokeChanged(allEventsCollection); + s.Invoke(this.addedEvents); + s.Invoke(this.removedEvents); + s.Invoke(this.changedEvents); + s.Invoke(this.movedEvents); + s.Invoke(this.splitEvents); + s.Invoke(this.mergedEvents); + } + + // We're done using the lists. Clean them up. + this.addedEvents.Clear(); + this.removedEvents.Clear(); + this.changedEvents.Clear(); + this.movedEvents.Clear(); + this.splitEvents.Clear(); + this.mergedEvents.Clear(); + } + + private unsafe void RaptureAtkModuleUpdateDetour(RaptureAtkModule* ram, float f1) + { + this.inventoriesMightBeChanged |= ram->AgentUpdateFlag != 0; + this.raptureAtkModuleUpdateHook.Original(ram, f1); + } + + /// + /// A view of , so that the number of items + /// contained within can be known in advance, and it can be enumerated multiple times. + /// + /// The type of elements being enumerated. + private class DeferredReadOnlyCollection : IReadOnlyCollection + { + private readonly Func> enumerableGenerator; + + public DeferredReadOnlyCollection(int count, Func> enumerableGenerator) + { + this.enumerableGenerator = enumerableGenerator; + this.Count = count; + } + + public int Count { get; } + + public IEnumerator GetEnumerator() => this.enumerableGenerator().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => this.enumerableGenerator().GetEnumerator(); + } +} + +/// +/// Plugin-scoped version of a GameInventory service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class GameInventoryPluginScoped : IDisposable, IServiceType, IGameInventory +{ + private static readonly ModuleLog Log = new(nameof(GameInventoryPluginScoped)); + + [ServiceManager.ServiceDependency] + private readonly GameInventory gameInventoryService = Service.Get(); + + /// + /// Initializes a new instance of the class. + /// + public GameInventoryPluginScoped() => this.gameInventoryService.Subscribe(this); + + /// + public event IGameInventory.InventoryChangelogDelegate? InventoryChanged; + + /// + public event IGameInventory.InventoryChangelogDelegate? InventoryChangedRaw; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemAdded; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemRemoved; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemChanged; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemMoved; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemSplit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemMerged; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemAddedExplicit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemRemovedExplicit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemChangedExplicit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemMovedExplicit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemSplitExplicit; + + /// + public event IGameInventory.InventoryChangedDelegate? ItemMergedExplicit; + + /// + public void Dispose() + { + this.gameInventoryService.Unsubscribe(this); + + this.InventoryChanged = null; + this.InventoryChangedRaw = null; + this.ItemAdded = null; + this.ItemRemoved = null; + this.ItemChanged = null; + this.ItemMoved = null; + this.ItemSplit = null; + this.ItemMerged = null; + this.ItemAddedExplicit = null; + this.ItemRemovedExplicit = null; + this.ItemChangedExplicit = null; + this.ItemMovedExplicit = null; + this.ItemSplitExplicit = null; + this.ItemMergedExplicit = null; + } + + /// + /// Invoke . + /// + /// The data. + internal void InvokeChanged(IReadOnlyCollection data) + { + try + { + this.InventoryChanged?.Invoke(data); + } + catch (Exception e) + { + Log.Error( + e, + "[{plugin}] Exception during {argType} callback", + Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)", + nameof(this.InventoryChanged)); + } + } + + /// + /// Invoke . + /// + /// The data. + internal void InvokeChangedRaw(IReadOnlyCollection data) + { + try + { + this.InventoryChangedRaw?.Invoke(data); + } + catch (Exception e) + { + Log.Error( + e, + "[{plugin}] Exception during {argType} callback", + Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)", + nameof(this.InventoryChangedRaw)); + } + } + + // Note below: using List instead of IEnumerable, since List has a specialized lightweight enumerator. + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemAdded, this.ItemAddedExplicit, events); + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemRemoved, this.ItemRemovedExplicit, events); + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemChanged, this.ItemChangedExplicit, events); + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemMoved, this.ItemMovedExplicit, events); + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemSplit, this.ItemSplitExplicit, events); + + /// + /// Invoke the appropriate event handler. + /// + /// The data. + internal void Invoke(List events) => + Invoke(this.ItemMerged, this.ItemMergedExplicit, events); + + private static void Invoke( + IGameInventory.InventoryChangedDelegate? cb, + IGameInventory.InventoryChangedDelegate? cbt, + List events) where T : InventoryEventArgs + { + foreach (var evt in events) + { + try + { + cb?.Invoke(evt.Type, evt); + } + catch (Exception e) + { + Log.Error( + e, + "[{plugin}] Exception during untyped callback for {evt}", + Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)", + evt); + } + + try + { + cbt?.Invoke(evt); + } + catch (Exception e) + { + Log.Error( + e, + "[{plugin}] Exception during typed callback for {evt}", + Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)", + evt); + } + } + } +} diff --git a/Dalamud/Game/Inventory/GameInventoryEvent.cs b/Dalamud/Game/Inventory/GameInventoryEvent.cs new file mode 100644 index 000000000..16efab648 --- /dev/null +++ b/Dalamud/Game/Inventory/GameInventoryEvent.cs @@ -0,0 +1,43 @@ +namespace Dalamud.Game.Inventory; + +/// +/// Class representing a item's changelog state. +/// +public enum GameInventoryEvent +{ + /// + /// A value indicating that there was no event.
+ /// You should not see this value, unless you explicitly used it yourself, or APIs using this enum say otherwise. + ///
+ Empty = 0, + + /// + /// Item was added to an inventory. + /// + Added = 1, + + /// + /// Item was removed from an inventory. + /// + Removed = 2, + + /// + /// Properties are changed for an item in an inventory. + /// + Changed = 3, + + /// + /// Item has been moved, possibly across different inventories. + /// + Moved = 4, + + /// + /// Item has been split into two stacks from one, possibly across different inventories. + /// + Split = 5, + + /// + /// Item has been merged into one stack from two, possibly across different inventories. + /// + Merged = 6, +} diff --git a/Dalamud/Game/Inventory/GameInventoryItem.cs b/Dalamud/Game/Inventory/GameInventoryItem.cs new file mode 100644 index 000000000..912b91f53 --- /dev/null +++ b/Dalamud/Game/Inventory/GameInventoryItem.cs @@ -0,0 +1,203 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +using FFXIVClientStructs.FFXIV.Client.Game; + +namespace Dalamud.Game.Inventory; + +/// +/// Dalamud wrapper around a ClientStructs InventoryItem. +/// +[StructLayout(LayoutKind.Explicit, Size = StructSizeInBytes)] +public unsafe struct GameInventoryItem : IEquatable +{ + /// + /// The actual data. + /// + [FieldOffset(0)] + internal readonly InventoryItem InternalItem; + + private const int StructSizeInBytes = 0x38; + + /// + /// The view of the backing data, in . + /// + [FieldOffset(0)] + private fixed ulong dataUInt64[StructSizeInBytes / 0x8]; + + static GameInventoryItem() + { + Debug.Assert( + sizeof(InventoryItem) == StructSizeInBytes, + $"Definition of {nameof(InventoryItem)} has been changed. " + + $"Update {nameof(StructSizeInBytes)} to {sizeof(InventoryItem)} to accommodate for the size change."); + } + + /// + /// Initializes a new instance of the struct. + /// + /// Inventory item to wrap. + internal GameInventoryItem(InventoryItem item) => this.InternalItem = item; + + /// + /// Gets a value indicating whether the this is empty. + /// + public bool IsEmpty => this.InternalItem.ItemID == 0; + + /// + /// Gets the container inventory type. + /// + public GameInventoryType ContainerType => (GameInventoryType)this.InternalItem.Container; + + /// + /// Gets the inventory slot index this item is in. + /// + public uint InventorySlot => (uint)this.InternalItem.Slot; + + /// + /// Gets the item id. + /// + public uint ItemId => this.InternalItem.ItemID; + + /// + /// Gets the quantity of items in this item stack. + /// + public uint Quantity => this.InternalItem.Quantity; + + /// + /// Gets the spiritbond of this item. + /// + public uint Spiritbond => this.InternalItem.Spiritbond; + + /// + /// Gets the repair condition of this item. + /// + public uint Condition => this.InternalItem.Condition; + + /// + /// Gets a value indicating whether the item is High Quality. + /// + public bool IsHq => (this.InternalItem.Flags & InventoryItem.ItemFlags.HQ) != 0; + + /// + /// Gets a value indicating whether the item has a company crest applied. + /// + public bool IsCompanyCrestApplied => (this.InternalItem.Flags & InventoryItem.ItemFlags.CompanyCrestApplied) != 0; + + /// + /// Gets a value indicating whether the item is a relic. + /// + public bool IsRelic => (this.InternalItem.Flags & InventoryItem.ItemFlags.Relic) != 0; + + /// + /// Gets a value indicating whether the is a collectable. + /// + public bool IsCollectable => (this.InternalItem.Flags & InventoryItem.ItemFlags.Collectable) != 0; + + /// + /// Gets the array of materia types. + /// + public ReadOnlySpan Materia => new(Unsafe.AsPointer(ref Unsafe.AsRef(in this.InternalItem.Materia[0])), 5); + + /// + /// Gets the array of materia grades. + /// + public ReadOnlySpan MateriaGrade => + new(Unsafe.AsPointer(ref Unsafe.AsRef(in this.InternalItem.MateriaGrade[0])), 5); + + /// + /// Gets the address of native inventory item in the game.
+ /// Can be 0 if this instance of does not point to a valid set of container type and slot.
+ /// Note that this instance of can be a snapshot; it may not necessarily match the + /// data you can query from the game using this address value. + ///
+ public nint Address + { + get + { + var s = GetReadOnlySpanOfInventory(this.ContainerType); + if (s.IsEmpty) + return 0; + + foreach (ref readonly var i in s) + { + if (i.InventorySlot == this.InventorySlot) + return (nint)Unsafe.AsPointer(ref Unsafe.AsRef(in i)); + } + + return 0; + } + } + + /// + /// Gets the color used for this item. + /// + public byte Stain => this.InternalItem.Stain; + + /// + /// Gets the glamour id for this item. + /// + public uint GlamourId => this.InternalItem.GlamourID; + + /// + /// Gets the items crafter's content id. + /// NOTE: I'm not sure if this is a good idea to include or not in the dalamud api. Marked internal for now. + /// + internal ulong CrafterContentId => this.InternalItem.CrafterContentID; + + public static bool operator ==(in GameInventoryItem l, in GameInventoryItem r) => l.Equals(r); + + public static bool operator !=(in GameInventoryItem l, in GameInventoryItem r) => !l.Equals(r); + + /// + readonly bool IEquatable.Equals(GameInventoryItem other) => this.Equals(other); + + /// Indicates whether the current object is equal to another object of the same type. + /// An object to compare with this object. + /// true if the current object is equal to the parameter; otherwise, false. + public readonly bool Equals(in GameInventoryItem other) + { + for (var i = 0; i < StructSizeInBytes / 8; i++) + { + if (this.dataUInt64[i] != other.dataUInt64[i]) + return false; + } + + return true; + } + + /// + public override bool Equals(object obj) => obj is GameInventoryItem gii && this.Equals(gii); + + /// + public override int GetHashCode() + { + var k = 0x5a8447b91aff51b4UL; + for (var i = 0; i < StructSizeInBytes / 8; i++) + k ^= this.dataUInt64[i]; + return unchecked((int)(k ^ (k >> 32))); + } + + /// + public override string ToString() => + this.IsEmpty + ? "empty" + : $"item({this.ItemId}@{this.ContainerType}#{this.InventorySlot})"; + + /// + /// Gets a view of s, wrapped as . + /// + /// The inventory type. + /// The span. + internal static ReadOnlySpan GetReadOnlySpanOfInventory(GameInventoryType type) + { + var inventoryManager = InventoryManager.Instance(); + if (inventoryManager is null) return default; + + var inventory = inventoryManager->GetInventoryContainer((InventoryType)type); + if (inventory is null) return default; + + return new ReadOnlySpan(inventory->Items, (int)inventory->Size); + } +} diff --git a/Dalamud/Game/Inventory/GameInventoryType.cs b/Dalamud/Game/Inventory/GameInventoryType.cs new file mode 100644 index 000000000..00c65046f --- /dev/null +++ b/Dalamud/Game/Inventory/GameInventoryType.cs @@ -0,0 +1,356 @@ +namespace Dalamud.Game.Inventory; + +/// +/// Enum representing various player inventories. +/// +public enum GameInventoryType : ushort +{ + /// + /// First panel of main player inventory. + /// + Inventory1 = 0, + + /// + /// Second panel of main player inventory. + /// + Inventory2 = 1, + + /// + /// Third panel of main player inventory. + /// + Inventory3 = 2, + + /// + /// Fourth panel of main player inventory. + /// + Inventory4 = 3, + + /// + /// Items that are currently equipped by the player. + /// + EquippedItems = 1000, + + /// + /// Player currency container. + /// ie, gil, serpent seals, sacks of nuts. + /// + Currency = 2000, + + /// + /// Crystal container. + /// + Crystals = 2001, + + /// + /// Mail container. + /// + Mail = 2003, + + /// + /// Key item container. + /// + KeyItems = 2004, + + /// + /// Quest item hand-in inventory. + /// + HandIn = 2005, + + /// + /// DamagedGear container. + /// + DamagedGear = 2007, + + /// + /// Examine window container. + /// + Examine = 2009, + + /// + /// Doman Enclave Reconstruction Reclamation Box. + /// + ReconstructionBuyback = 2013, + + /// + /// Armory off-hand weapon container. + /// + ArmoryOffHand = 3200, + + /// + /// Armory head container. + /// + ArmoryHead = 3201, + + /// + /// Armory body container. + /// + ArmoryBody = 3202, + + /// + /// Armory hand/gloves container. + /// + ArmoryHands = 3203, + + /// + /// Armory waist container. + /// + /// This container should be unused as belt items were removed from the game in Shadowbringers. + /// + /// + ArmoryWaist = 3204, + + /// + /// Armory legs/pants/skirt container. + /// + ArmoryLegs = 3205, + + /// + /// Armory feet/boots/shoes container. + /// + ArmoryFeets = 3206, + + /// + /// Armory earring container. + /// + ArmoryEar = 3207, + + /// + /// Armory necklace container. + /// + ArmoryNeck = 3208, + + /// + /// Armory bracelet container. + /// + ArmoryWrist = 3209, + + /// + /// Armory ring container. + /// + ArmoryRings = 3300, + + /// + /// Armory soul crystal container. + /// + ArmorySoulCrystal = 3400, + + /// + /// Armory main-hand weapon container. + /// + ArmoryMainHand = 3500, + + /// + /// First panel of saddelbag inventory. + /// + SaddleBag1 = 4000, + + /// + /// Second panel of Saddlebag inventory. + /// + SaddleBag2 = 4001, + + /// + /// First panel of premium saddlebag inventory. + /// + PremiumSaddleBag1 = 4100, + + /// + /// Second panel of premium saddlebag inventory. + /// + PremiumSaddleBag2 = 4101, + + /// + /// First panel of retainer inventory. + /// + RetainerPage1 = 10000, + + /// + /// Second panel of retainer inventory. + /// + RetainerPage2 = 10001, + + /// + /// Third panel of retainer inventory. + /// + RetainerPage3 = 10002, + + /// + /// Fourth panel of retainer inventory. + /// + RetainerPage4 = 10003, + + /// + /// Fifth panel of retainer inventory. + /// + RetainerPage5 = 10004, + + /// + /// Sixth panel of retainer inventory. + /// + RetainerPage6 = 10005, + + /// + /// Seventh panel of retainer inventory. + /// + RetainerPage7 = 10006, + + /// + /// Retainer equipment container. + /// + RetainerEquippedItems = 11000, + + /// + /// Retainer currency container. + /// + RetainerGil = 12000, + + /// + /// Retainer crystal container. + /// + RetainerCrystals = 12001, + + /// + /// Retainer market item container. + /// + RetainerMarket = 12002, + + /// + /// First panel of Free Company inventory. + /// + FreeCompanyPage1 = 20000, + + /// + /// Second panel of Free Company inventory. + /// + FreeCompanyPage2 = 20001, + + /// + /// Third panel of Free Company inventory. + /// + FreeCompanyPage3 = 20002, + + /// + /// Fourth panel of Free Company inventory. + /// + FreeCompanyPage4 = 20003, + + /// + /// Fifth panel of Free Company inventory. + /// + FreeCompanyPage5 = 20004, + + /// + /// Free Company currency container. + /// + FreeCompanyGil = 22000, + + /// + /// Free Company crystal container. + /// + FreeCompanyCrystals = 22001, + + /// + /// Housing exterior appearance container. + /// + HousingExteriorAppearance = 25000, + + /// + /// Housing exterior placed items container. + /// + HousingExteriorPlacedItems = 25001, + + /// + /// Housing interior appearance container. + /// + HousingInteriorAppearance = 25002, + + /// + /// First panel of housing interior inventory. + /// + HousingInteriorPlacedItems1 = 25003, + + /// + /// Second panel of housing interior inventory. + /// + HousingInteriorPlacedItems2 = 25004, + + /// + /// Third panel of housing interior inventory. + /// + HousingInteriorPlacedItems3 = 25005, + + /// + /// Fourth panel of housing interior inventory. + /// + HousingInteriorPlacedItems4 = 25006, + + /// + /// Fifth panel of housing interior inventory. + /// + HousingInteriorPlacedItems5 = 25007, + + /// + /// Sixth panel of housing interior inventory. + /// + HousingInteriorPlacedItems6 = 25008, + + /// + /// Seventh panel of housing interior inventory. + /// + HousingInteriorPlacedItems7 = 25009, + + /// + /// Eighth panel of housing interior inventory. + /// + HousingInteriorPlacedItems8 = 25010, + + /// + /// Housing exterior storeroom inventory. + /// + HousingExteriorStoreroom = 27000, + + /// + /// First panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom1 = 27001, + + /// + /// Second panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom2 = 27002, + + /// + /// Third panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom3 = 27003, + + /// + /// Fourth panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom4 = 27004, + + /// + /// Fifth panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom5 = 27005, + + /// + /// Sixth panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom6 = 27006, + + /// + /// Seventh panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom7 = 27007, + + /// + /// Eighth panel of housing interior storeroom inventory. + /// + HousingInteriorStoreroom8 = 27008, + + /// + /// An invalid value. + /// + Invalid = ushort.MaxValue, +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryComplexEventArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryComplexEventArgs.cs new file mode 100644 index 000000000..95d7e8238 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryComplexEventArgs.cs @@ -0,0 +1,54 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being affected across different slots, possibly in different containers. +/// +public abstract class InventoryComplexEventArgs : InventoryEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// Type of the event. + /// The item at before slot. + /// The item at after slot. + internal InventoryComplexEventArgs( + GameInventoryEvent type, InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent) + : base(type, targetEvent.Item) + { + this.SourceEvent = sourceEvent; + this.TargetEvent = targetEvent; + } + + /// + /// Gets the inventory this item was at. + /// + public GameInventoryType SourceInventory => this.SourceEvent.Item.ContainerType; + + /// + /// Gets the inventory this item now is. + /// + public GameInventoryType TargetInventory => this.Item.ContainerType; + + /// + /// Gets the slot this item was at. + /// + public uint SourceSlot => this.SourceEvent.Item.InventorySlot; + + /// + /// Gets the slot this item now is. + /// + public uint TargetSlot => this.Item.InventorySlot; + + /// + /// Gets the associated source event. + /// + public InventoryEventArgs SourceEvent { get; } + + /// + /// Gets the associated target event. + /// + public InventoryEventArgs TargetEvent { get; } + + /// + public override string ToString() => $"{this.Type}({this.SourceEvent}, {this.TargetEvent})"; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryEventArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryEventArgs.cs new file mode 100644 index 000000000..198e0395b --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryEventArgs.cs @@ -0,0 +1,37 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Abstract base class representing inventory changed events. +/// +public abstract class InventoryEventArgs +{ + private readonly GameInventoryItem item; + + /// + /// Initializes a new instance of the class. + /// + /// Type of the event. + /// Item about the event. + protected InventoryEventArgs(GameInventoryEvent type, in GameInventoryItem item) + { + this.Type = type; + this.item = item; + } + + /// + /// Gets the type of event for these args. + /// + public GameInventoryEvent Type { get; } + + /// + /// Gets the item associated with this event. + /// This is a copy of the item data. + /// + // impl note: we return a ref readonly view, to avoid making copies every time this property is accessed. + // see: https://devblogs.microsoft.com/premier-developer/avoiding-struct-and-readonly-reference-performance-pitfalls-with-errorprone-net/ + // "Consider using ref readonly locals and ref return for library code" + public ref readonly GameInventoryItem Item => ref this.item; + + /// + public override string ToString() => $"{this.Type}({this.Item})"; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemAddedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemAddedArgs.cs new file mode 100644 index 000000000..ceb64c6f9 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemAddedArgs.cs @@ -0,0 +1,26 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being added to an inventory. +/// +public sealed class InventoryItemAddedArgs : InventoryEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The item. + internal InventoryItemAddedArgs(in GameInventoryItem item) + : base(GameInventoryEvent.Added, item) + { + } + + /// + /// Gets the inventory this item was added to. + /// + public GameInventoryType Inventory => this.Item.ContainerType; + + /// + /// Gets the slot this item was added to. + /// + public uint Slot => this.Item.InventorySlot; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemChangedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemChangedArgs.cs new file mode 100644 index 000000000..372418793 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemChangedArgs.cs @@ -0,0 +1,38 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an items properties being changed. +/// This also includes an items stack count changing. +/// +public sealed class InventoryItemChangedArgs : InventoryEventArgs +{ + private readonly GameInventoryItem oldItemState; + + /// + /// Initializes a new instance of the class. + /// + /// The item before change. + /// The item after change. + internal InventoryItemChangedArgs(in GameInventoryItem oldItem, in GameInventoryItem newItem) + : base(GameInventoryEvent.Changed, newItem) + { + this.oldItemState = oldItem; + } + + /// + /// Gets the inventory this item is in. + /// + public GameInventoryType Inventory => this.Item.ContainerType; + + /// + /// Gets the inventory slot this item is in. + /// + public uint Slot => this.Item.InventorySlot; + + /// + /// Gets the state of the item from before it was changed. + /// This is a copy of the item data. + /// + // impl note: see InventoryEventArgs.Item. + public ref readonly GameInventoryItem OldItemState => ref this.oldItemState; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMergedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMergedArgs.cs new file mode 100644 index 000000000..d7056356e --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMergedArgs.cs @@ -0,0 +1,26 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being merged from two stacks into one. +/// +public sealed class InventoryItemMergedArgs : InventoryComplexEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The item at before slot. + /// The item at after slot. + internal InventoryItemMergedArgs(InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent) + : base(GameInventoryEvent.Merged, sourceEvent, targetEvent) + { + } + + /// + public override string ToString() => + this.TargetEvent is InventoryItemChangedArgs iica + ? $"{this.Type}(" + + $"item({this.Item.ItemId}), " + + $"{this.SourceInventory}#{this.SourceSlot}({this.SourceEvent.Item.Quantity} to 0), " + + $"{this.TargetInventory}#{this.TargetSlot}({iica.OldItemState.Quantity} to {iica.Item.Quantity}))" + : base.ToString(); +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMovedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMovedArgs.cs new file mode 100644 index 000000000..8d0bbca17 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMovedArgs.cs @@ -0,0 +1,21 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being moved from one inventory and added to another. +/// +public sealed class InventoryItemMovedArgs : InventoryComplexEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The item at before slot. + /// The item at after slot. + internal InventoryItemMovedArgs(InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent) + : base(GameInventoryEvent.Moved, sourceEvent, targetEvent) + { + } + + /// + public override string ToString() => + $"{this.Type}(item({this.Item.ItemId}) from {this.SourceInventory}#{this.SourceSlot} to {this.TargetInventory}#{this.TargetSlot})"; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemRemovedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemRemovedArgs.cs new file mode 100644 index 000000000..5677e3cc4 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemRemovedArgs.cs @@ -0,0 +1,26 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being removed from an inventory. +/// +public sealed class InventoryItemRemovedArgs : InventoryEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The item. + internal InventoryItemRemovedArgs(in GameInventoryItem item) + : base(GameInventoryEvent.Removed, item) + { + } + + /// + /// Gets the inventory this item was removed from. + /// + public GameInventoryType Inventory => this.Item.ContainerType; + + /// + /// Gets the slot this item was removed from. + /// + public uint Slot => this.Item.InventorySlot; +} diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemSplitArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemSplitArgs.cs new file mode 100644 index 000000000..5f717cf60 --- /dev/null +++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemSplitArgs.cs @@ -0,0 +1,26 @@ +namespace Dalamud.Game.Inventory.InventoryEventArgTypes; + +/// +/// Represents the data associated with an item being split from one stack into two. +/// +public sealed class InventoryItemSplitArgs : InventoryComplexEventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The item at before slot. + /// The item at after slot. + internal InventoryItemSplitArgs(InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent) + : base(GameInventoryEvent.Split, sourceEvent, targetEvent) + { + } + + /// + public override string ToString() => + this.SourceEvent is InventoryItemChangedArgs iica + ? $"{this.Type}(" + + $"item({this.Item.ItemId}), " + + $"{this.SourceInventory}#{this.SourceSlot}({iica.OldItemState.Quantity} to {iica.Item.Quantity}), " + + $"{this.TargetInventory}#{this.TargetSlot}(0 to {this.Item.Quantity}))" + : base.ToString(); +} diff --git a/Dalamud/Game/Network/GameNetwork.cs b/Dalamud/Game/Network/GameNetwork.cs index 9ea3e491e..4099f228e 100644 --- a/Dalamud/Game/Network/GameNetwork.cs +++ b/Dalamud/Game/Network/GameNetwork.cs @@ -44,6 +44,9 @@ internal sealed class GameNetwork : IDisposable, IServiceType, IGameNetwork this.processZonePacketDownHook = Hook.FromAddress(this.address.ProcessZonePacketDown, this.ProcessZonePacketDownDetour); this.processZonePacketUpHook = Hook.FromAddress(this.address.ProcessZonePacketUp, this.ProcessZonePacketUpDetour); + + this.processZonePacketDownHook.Enable(); + this.processZonePacketUpHook.Enable(); } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] @@ -62,13 +65,6 @@ internal sealed class GameNetwork : IDisposable, IServiceType, IGameNetwork this.processZonePacketUpHook.Dispose(); } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction() - { - this.processZonePacketDownHook.Enable(); - this.processZonePacketUpHook.Enable(); - } - private void ProcessZonePacketDownDetour(IntPtr a, uint targetId, IntPtr dataPtr) { this.baseAddress = a; diff --git a/Dalamud/Interface/Internal/ImGuiClipboardFunctionProvider.cs b/Dalamud/Interface/Internal/ImGuiClipboardFunctionProvider.cs new file mode 100644 index 000000000..fd07d824f --- /dev/null +++ b/Dalamud/Interface/Internal/ImGuiClipboardFunctionProvider.cs @@ -0,0 +1,199 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +using CheapLoc; + +using Dalamud.Game.Gui.Toast; +using Dalamud.Interface.Utility; +using Dalamud.Logging.Internal; + +using ImGuiNET; + +using TerraFX.Interop.Windows; + +using static TerraFX.Interop.Windows.Windows; + +namespace Dalamud.Interface.Internal; + +/// +/// Configures the ImGui clipboard behaviour to work nicely with XIV. +/// +/// +/// +/// XIV uses '\r' for line endings and will truncate all text after a '\n' character. +/// This means that copy/pasting multi-line text from ImGui to XIV will only copy the first line. +/// +/// +/// ImGui uses '\n' for line endings and will ignore '\r' entirely. +/// This means that copy/pasting multi-line text from XIV to ImGui will copy all the text +/// without line breaks. +/// +/// +/// To fix this we normalize all clipboard line endings entering/exiting ImGui to '\r\n' which +/// works for both ImGui and XIV. +/// +/// +[ServiceManager.EarlyLoadedService] +internal sealed unsafe class ImGuiClipboardFunctionProvider : IServiceType, IDisposable +{ + private static readonly ModuleLog Log = new(nameof(ImGuiClipboardFunctionProvider)); + private readonly nint clipboardUserDataOriginal; + private readonly nint setTextOriginal; + private readonly nint getTextOriginal; + + [ServiceManager.ServiceDependency] + private readonly ToastGui toastGui = Service.Get(); + + private ImVectorWrapper clipboardData; + private GCHandle clipboardUserData; + + [ServiceManager.ServiceConstructor] + private ImGuiClipboardFunctionProvider(InterfaceManager.InterfaceManagerWithScene imws) + { + // Effectively waiting for ImGui to become available. + _ = imws; + Debug.Assert(ImGuiHelpers.IsImGuiInitialized, "IMWS initialized but IsImGuiInitialized is false?"); + + var io = ImGui.GetIO(); + this.clipboardUserDataOriginal = io.ClipboardUserData; + this.setTextOriginal = io.SetClipboardTextFn; + this.getTextOriginal = io.GetClipboardTextFn; + io.ClipboardUserData = GCHandle.ToIntPtr(this.clipboardUserData = GCHandle.Alloc(this)); + io.SetClipboardTextFn = (nint)(delegate* unmanaged)&StaticSetClipboardTextImpl; + io.GetClipboardTextFn = (nint)(delegate* unmanaged)&StaticGetClipboardTextImpl; + + this.clipboardData = new(0); + return; + + [UnmanagedCallersOnly] + static void StaticSetClipboardTextImpl(nint userData, byte* text) => + ((ImGuiClipboardFunctionProvider)GCHandle.FromIntPtr(userData).Target)!.SetClipboardTextImpl(text); + + [UnmanagedCallersOnly] + static byte* StaticGetClipboardTextImpl(nint userData) => + ((ImGuiClipboardFunctionProvider)GCHandle.FromIntPtr(userData).Target)!.GetClipboardTextImpl(); + } + + /// + public void Dispose() + { + if (!this.clipboardUserData.IsAllocated) + return; + + var io = ImGui.GetIO(); + io.SetClipboardTextFn = this.setTextOriginal; + io.GetClipboardTextFn = this.getTextOriginal; + io.ClipboardUserData = this.clipboardUserDataOriginal; + + this.clipboardUserData.Free(); + this.clipboardData.Dispose(); + } + + private bool OpenClipboardOrShowError() + { + if (!OpenClipboard(default)) + { + this.toastGui.ShowError( + Loc.Localize( + "ImGuiClipboardFunctionProviderClipboardInUse", + "Some other application is using the clipboard. Try again later.")); + return false; + } + + return true; + } + + private void SetClipboardTextImpl(byte* text) + { + if (!this.OpenClipboardOrShowError()) + return; + + try + { + var len = 0; + while (text[len] != 0) + len++; + var str = Encoding.UTF8.GetString(text, len); + str = str.ReplaceLineEndings("\r\n"); + var hMem = GlobalAlloc(GMEM.GMEM_MOVEABLE, (nuint)((str.Length + 1) * 2)); + if (hMem == 0) + throw new OutOfMemoryException(); + + var ptr = (char*)GlobalLock(hMem); + if (ptr == null) + { + throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error()) + ?? throw new InvalidOperationException($"{nameof(GlobalLock)} failed."); + } + + str.AsSpan().CopyTo(new(ptr, str.Length)); + ptr[str.Length] = default; + GlobalUnlock(hMem); + + SetClipboardData(CF.CF_UNICODETEXT, hMem); + } + catch (Exception e) + { + Log.Error(e, $"Error in {nameof(this.SetClipboardTextImpl)}"); + this.toastGui.ShowError( + Loc.Localize( + "ImGuiClipboardFunctionProviderErrorCopy", + "Failed to copy. See logs for details.")); + } + finally + { + CloseClipboard(); + } + } + + private byte* GetClipboardTextImpl() + { + this.clipboardData.Clear(); + + var formats = stackalloc uint[] { CF.CF_UNICODETEXT, CF.CF_TEXT }; + if (GetPriorityClipboardFormat(formats, 2) < 1 || !this.OpenClipboardOrShowError()) + { + this.clipboardData.Add(0); + return this.clipboardData.Data; + } + + try + { + var hMem = (HGLOBAL)GetClipboardData(CF.CF_UNICODETEXT); + if (hMem != default) + { + var ptr = (char*)GlobalLock(hMem); + if (ptr == null) + { + throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error()) + ?? throw new InvalidOperationException($"{nameof(GlobalLock)} failed."); + } + + var str = new string(ptr); + str = str.ReplaceLineEndings("\r\n"); + this.clipboardData.Resize(Encoding.UTF8.GetByteCount(str) + 1); + Encoding.UTF8.GetBytes(str, this.clipboardData.DataSpan); + this.clipboardData[^1] = 0; + } + else + { + this.clipboardData.Add(0); + } + } + catch (Exception e) + { + Log.Error(e, $"Error in {nameof(this.GetClipboardTextImpl)}"); + this.toastGui.ShowError( + Loc.Localize( + "ImGuiClipboardFunctionProviderErrorPaste", + "Failed to paste. See logs for details.")); + } + finally + { + CloseClipboard(); + } + + return this.clipboardData.Data; + } +} diff --git a/Dalamud/Interface/Internal/InterfaceManager.cs b/Dalamud/Interface/Internal/InterfaceManager.cs index 52e849c0e..1b12fd853 100644 --- a/Dalamud/Interface/Internal/InterfaceManager.cs +++ b/Dalamud/Interface/Internal/InterfaceManager.cs @@ -1063,14 +1063,10 @@ internal class InterfaceManager : IDisposable, IServiceType } } - [ServiceManager.CallWhenServicesReady] - private void ContinueConstruction( - TargetSigScanner sigScanner, - DalamudAssetManager dalamudAssetManager, - DalamudConfiguration configuration) + [ServiceManager.CallWhenServicesReady( + "InterfaceManager accepts event registration and stuff even when the game window is not ready.")] + private void ContinueConstruction(TargetSigScanner sigScanner, DalamudConfiguration configuration) { - dalamudAssetManager.WaitForAllRequiredAssets().Wait(); - this.address.Setup(sigScanner); this.framework.RunOnFrameworkThread(() => { diff --git a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs index b285520d4..89dd153cc 100644 --- a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs @@ -679,6 +679,9 @@ internal class ConsoleWindow : Window, IDisposable private bool IsFilterApplicable(LogEntry entry) { + if (this.regexError) + return false; + try { // If this entry is below a newly set minimum level, fail it diff --git a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs index e9d4152a5..20c3d6d01 100644 --- a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs +++ b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs @@ -33,6 +33,7 @@ internal class DataWindow : Window new FateTableWidget(), new FlyTextWidget(), new FontAwesomeTestWidget(), + new GameInventoryTestWidget(), new GamepadWidget(), new GaugeWidget(), new HookWidget(), diff --git a/Dalamud/Interface/Internal/Windows/Data/GameInventoryTestWidget.cs b/Dalamud/Interface/Internal/Windows/Data/GameInventoryTestWidget.cs new file mode 100644 index 000000000..c19f56654 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/GameInventoryTestWidget.cs @@ -0,0 +1,163 @@ +using System.Collections.Generic; + +using Dalamud.Configuration.Internal; +using Dalamud.Game.Inventory; +using Dalamud.Game.Inventory.InventoryEventArgTypes; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Logging.Internal; + +using ImGuiNET; + +using Serilog.Events; + +namespace Dalamud.Interface.Internal.Windows.Data; + +/// +/// Tester for . +/// +internal class GameInventoryTestWidget : IDataWindowWidget +{ + private static readonly ModuleLog Log = new(nameof(GameInventoryTestWidget)); + + private GameInventoryPluginScoped? scoped; + private bool standardEnabled; + private bool rawEnabled; + + /// + public string[]? CommandShortcuts { get; init; } = { "gameinventorytest" }; + + /// + public string DisplayName { get; init; } = "GameInventory Test"; + + /// + public bool Ready { get; set; } + + /// + public void Load() => this.Ready = true; + + /// + public void Draw() + { + if (Service.Get().LogLevel > LogEventLevel.Information) + { + ImGuiHelpers.SafeTextColoredWrapped( + ImGuiColors.DalamudRed, + "Enable LogLevel=Information display to see the logs."); + } + + using var table = ImRaii.Table(this.DisplayName, 3, ImGuiTableFlags.SizingFixedFit); + if (!table.Success) + return; + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("Standard Logging"); + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(this.standardEnabled)) + { + if (ImGui.Button("Enable##standard-enable") && !this.standardEnabled) + { + this.scoped ??= new(); + this.scoped.InventoryChanged += ScopedOnInventoryChanged; + this.standardEnabled = true; + } + } + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(!this.standardEnabled)) + { + if (ImGui.Button("Disable##standard-disable") && this.scoped is not null && this.standardEnabled) + { + this.scoped.InventoryChanged -= ScopedOnInventoryChanged; + this.standardEnabled = false; + if (!this.rawEnabled) + { + this.scoped.Dispose(); + this.scoped = null; + } + } + } + + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("Raw Logging"); + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(this.rawEnabled)) + { + if (ImGui.Button("Enable##raw-enable") && !this.rawEnabled) + { + this.scoped ??= new(); + this.scoped.InventoryChangedRaw += ScopedOnInventoryChangedRaw; + this.rawEnabled = true; + } + } + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(!this.rawEnabled)) + { + if (ImGui.Button("Disable##raw-disable") && this.scoped is not null && this.rawEnabled) + { + this.scoped.InventoryChangedRaw -= ScopedOnInventoryChangedRaw; + this.rawEnabled = false; + if (!this.standardEnabled) + { + this.scoped.Dispose(); + this.scoped = null; + } + } + } + + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("All"); + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(this.standardEnabled && this.rawEnabled)) + { + if (ImGui.Button("Enable##all-enable")) + { + this.scoped ??= new(); + if (!this.standardEnabled) + this.scoped.InventoryChanged += ScopedOnInventoryChanged; + if (!this.rawEnabled) + this.scoped.InventoryChangedRaw += ScopedOnInventoryChangedRaw; + this.standardEnabled = this.rawEnabled = true; + } + } + + ImGui.TableNextColumn(); + using (ImRaii.Disabled(this.scoped is null)) + { + if (ImGui.Button("Disable##all-disable")) + { + this.scoped?.Dispose(); + this.scoped = null; + this.standardEnabled = this.rawEnabled = false; + } + } + } + + private static void ScopedOnInventoryChangedRaw(IReadOnlyCollection events) + { + var i = 0; + foreach (var e in events) + Log.Information($"[{++i}/{events.Count}] Raw: {e}"); + } + + private static void ScopedOnInventoryChanged(IReadOnlyCollection events) + { + var i = 0; + foreach (var e in events) + { + if (e is InventoryComplexEventArgs icea) + Log.Information($"[{++i}/{events.Count}] {icea}\n\t├ {icea.SourceEvent}\n\t└ {icea.TargetEvent}"); + else + Log.Information($"[{++i}/{events.Count}] {e}"); + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs index 570b63332..92f340a7b 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/DataShareWidget.cs @@ -1,19 +1,44 @@ -using Dalamud.Interface.Utility; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; +using System.Reflection; +using System.Text; + +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin.Ipc.Internal; + using ImGuiNET; +using Newtonsoft.Json; + +using Formatting = Newtonsoft.Json.Formatting; + namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// /// Widget for displaying plugin data share modules. /// +[SuppressMessage( + "StyleCop.CSharp.LayoutRules", + "SA1519:Braces should not be omitted from multi-line child statement", + Justification = "Multiple fixed blocks")] internal class DataShareWidget : IDataWindowWidget { + private const ImGuiTabItemFlags NoCloseButton = (ImGuiTabItemFlags)(1 << 20); + + private readonly List<(string Name, byte[]? Data)> dataView = new(); + private int nextTab = -1; + private IReadOnlyDictionary? gates; + private List? gatesSorted; + /// public string[]? CommandShortcuts { get; init; } = { "datashare" }; - + /// - public string DisplayName { get; init; } = "Data Share"; + public string DisplayName { get; init; } = "Data Share & Call Gate"; /// public bool Ready { get; set; } @@ -25,28 +50,290 @@ internal class DataShareWidget : IDataWindowWidget } /// - public void Draw() + public unsafe void Draw() { - if (!ImGui.BeginTable("###DataShareTable", 4, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg)) + using var tabbar = ImRaii.TabBar("##tabbar"); + if (!tabbar.Success) + return; + + var d = true; + using (var tabitem = ImRaii.TabItem( + "Data Share##tabbar-datashare", + ref d, + NoCloseButton | (this.nextTab == 0 ? ImGuiTabItemFlags.SetSelected : 0))) + { + if (tabitem.Success) + this.DrawDataShare(); + } + + using (var tabitem = ImRaii.TabItem( + "Call Gate##tabbar-callgate", + ref d, + NoCloseButton | (this.nextTab == 1 ? ImGuiTabItemFlags.SetSelected : 0))) + { + if (tabitem.Success) + this.DrawCallGate(); + } + + for (var i = 0; i < this.dataView.Count; i++) + { + using var idpush = ImRaii.PushId($"##tabbar-data-{i}"); + var (name, data) = this.dataView[i]; + d = true; + using var tabitem = ImRaii.TabItem( + name, + ref d, + this.nextTab == 2 + i ? ImGuiTabItemFlags.SetSelected : 0); + if (!d) + this.dataView.RemoveAt(i--); + if (!tabitem.Success) + continue; + + if (ImGui.Button("Refresh")) + data = null; + + if (data is null) + { + try + { + var dataShare = Service.Get(); + var data2 = dataShare.GetData(name); + try + { + data = Encoding.UTF8.GetBytes( + JsonConvert.SerializeObject( + data2, + Formatting.Indented, + new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All })); + } + finally + { + dataShare.RelinquishData(name); + } + } + catch (Exception e) + { + data = Encoding.UTF8.GetBytes(e.ToString()); + } + + this.dataView[i] = (name, data); + } + + ImGui.SameLine(); + if (ImGui.Button("Copy")) + { + fixed (byte* pData = data) + ImGuiNative.igSetClipboardText(pData); + } + + fixed (byte* pLabel = "text"u8) + fixed (byte* pData = data) + { + ImGuiNative.igInputTextMultiline( + pLabel, + pData, + (uint)data.Length, + ImGui.GetContentRegionAvail(), + ImGuiInputTextFlags.ReadOnly, + null, + null); + } + } + + this.nextTab = -1; + } + + private static string ReprMethod(MethodInfo? mi, bool withParams) + { + if (mi is null) + return "-"; + + var sb = new StringBuilder(); + sb.Append(ReprType(mi.DeclaringType)) + .Append("::") + .Append(mi.Name); + if (!withParams) + return sb.ToString(); + sb.Append('('); + var parfirst = true; + foreach (var par in mi.GetParameters()) + { + if (!parfirst) + sb.Append(", "); + else + parfirst = false; + sb.AppendLine() + .Append('\t') + .Append(ReprType(par.ParameterType)) + .Append(' ') + .Append(par.Name); + } + + if (!parfirst) + sb.AppendLine(); + sb.Append(')'); + if (mi.ReturnType != typeof(void)) + sb.Append(" -> ").Append(ReprType(mi.ReturnType)); + return sb.ToString(); + + static string WithoutGeneric(string s) + { + var i = s.IndexOf('`'); + return i != -1 ? s[..i] : s; + } + + static string ReprType(Type? t) => + t switch + { + null => "null", + _ when t == typeof(string) => "string", + _ when t == typeof(object) => "object", + _ when t == typeof(void) => "void", + _ when t == typeof(decimal) => "decimal", + _ when t == typeof(bool) => "bool", + _ when t == typeof(double) => "double", + _ when t == typeof(float) => "float", + _ when t == typeof(char) => "char", + _ when t == typeof(ulong) => "ulong", + _ when t == typeof(long) => "long", + _ when t == typeof(uint) => "uint", + _ when t == typeof(int) => "int", + _ when t == typeof(ushort) => "ushort", + _ when t == typeof(short) => "short", + _ when t == typeof(byte) => "byte", + _ when t == typeof(sbyte) => "sbyte", + _ when t == typeof(nint) => "nint", + _ when t == typeof(nuint) => "nuint", + _ when t.IsArray && t.HasElementType => ReprType(t.GetElementType()) + "[]", + _ when t.IsPointer && t.HasElementType => ReprType(t.GetElementType()) + "*", + _ when t.IsGenericTypeDefinition => + t.Assembly == typeof(object).Assembly + ? t.Name + "<>" + : (t.FullName ?? t.Name) + "<>", + _ when t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>) => + ReprType(t.GetGenericArguments()[0]) + "?", + _ when t.IsGenericType => + WithoutGeneric(ReprType(t.GetGenericTypeDefinition())) + + "<" + string.Join(", ", t.GetGenericArguments().Select(ReprType)) + ">", + _ => t.Assembly == typeof(object).Assembly ? t.Name : t.FullName ?? t.Name, + }; + } + + private void DrawTextCell(string s, Func? tooltip = null, bool framepad = false) + { + ImGui.TableNextColumn(); + var offset = ImGui.GetCursorScreenPos() + new Vector2(0, framepad ? ImGui.GetStyle().FramePadding.Y : 0); + if (framepad) + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(s); + if (ImGui.IsItemHovered()) + { + ImGui.SetNextWindowPos(offset - ImGui.GetStyle().WindowPadding); + var vp = ImGui.GetWindowViewport(); + var wrx = (vp.WorkPos.X + vp.WorkSize.X) - offset.X; + ImGui.SetNextWindowSizeConstraints(Vector2.One, new(wrx, float.MaxValue)); + using (ImRaii.Tooltip()) + { + ImGui.PushTextWrapPos(wrx); + ImGui.TextWrapped((tooltip?.Invoke() ?? s).Replace("%", "%%")); + ImGui.PopTextWrapPos(); + } + } + + if (ImGui.IsItemClicked()) + { + ImGui.SetClipboardText(tooltip?.Invoke() ?? s); + Service.Get().AddNotification( + $"Copied {ImGui.TableGetColumnName()} to clipboard.", + this.DisplayName, + NotificationType.Success); + } + } + + private void DrawCallGate() + { + var callGate = Service.Get(); + if (ImGui.Button("Purge empty call gates")) + callGate.PurgeEmptyGates(); + + using var table = ImRaii.Table("##callgate-table", 5); + ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.DefaultSort); + ImGui.TableSetupColumn("Action"); + ImGui.TableSetupColumn("Func"); + ImGui.TableSetupColumn("#", ImGuiTableColumnFlags.WidthFixed, 30 * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("Subscriber"); + ImGui.TableHeadersRow(); + + var gates2 = callGate.Gates; + if (!ReferenceEquals(gates2, this.gates) || this.gatesSorted is null) + { + this.gatesSorted = (this.gates = gates2).Values.ToList(); + this.gatesSorted.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + } + + foreach (var item in this.gatesSorted) + { + var subs = item.Subscriptions; + for (var i = 0; i < subs.Count || i == 0; i++) + { + ImGui.TableNextRow(); + this.DrawTextCell(item.Name); + this.DrawTextCell( + ReprMethod(item.Action?.Method, false), + () => ReprMethod(item.Action?.Method, true)); + this.DrawTextCell( + ReprMethod(item.Func?.Method, false), + () => ReprMethod(item.Func?.Method, true)); + if (subs.Count == 0) + { + this.DrawTextCell("0"); + continue; + } + + this.DrawTextCell($"{i + 1}/{subs.Count}"); + this.DrawTextCell($"{subs[i].Method.DeclaringType}::{subs[i].Method.Name}"); + } + } + } + + private void DrawDataShare() + { + if (!ImGui.BeginTable("###DataShareTable", 5, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg)) return; try { ImGui.TableSetupColumn("Shared Tag"); + ImGui.TableSetupColumn("Show"); ImGui.TableSetupColumn("Creator Assembly"); ImGui.TableSetupColumn("#", ImGuiTableColumnFlags.WidthFixed, 30 * ImGuiHelpers.GlobalScale); ImGui.TableSetupColumn("Consumers"); ImGui.TableHeadersRow(); foreach (var share in Service.Get().GetAllShares()) { + ImGui.TableNextRow(); + this.DrawTextCell(share.Tag, null, true); + ImGui.TableNextColumn(); - ImGui.TextUnformatted(share.Tag); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(share.CreatorAssembly); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(share.Users.Length.ToString()); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(string.Join(", ", share.Users)); + if (ImGui.Button($"Show##datasharetable-show-{share.Tag}")) + { + var index = 0; + for (; index < this.dataView.Count; index++) + { + if (this.dataView[index].Name == share.Tag) + break; + } + + if (index == this.dataView.Count) + this.dataView.Add((share.Tag, null)); + else + this.dataView[index] = (share.Tag, null); + this.nextTab = 2 + index; + } + + this.DrawTextCell(share.CreatorAssembly, null, true); + this.DrawTextCell(share.Users.Length.ToString(), null, true); + this.DrawTextCell(string.Join(", ", share.Users), null, true); } } finally diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/ServicesWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/ServicesWidget.cs index 49f3c1b90..22b53cdaa 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/ServicesWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/ServicesWidget.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; @@ -13,6 +15,13 @@ namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// internal class ServicesWidget : IDataWindowWidget { + private readonly Dictionary nodeRects = new(); + private readonly HashSet selectedNodes = new(); + private readonly HashSet tempRelatedNodes = new(); + + private bool includeUnloadDependencies; + private List>? dependencyNodes; + /// public string[]? CommandShortcuts { get; init; } = { "services" }; @@ -33,27 +42,294 @@ internal class ServicesWidget : IDataWindowWidget { var container = Service.Get(); - foreach (var instance in container.Instances) + if (ImGui.CollapsingHeader("Dependencies")) { - var hasInterface = container.InterfaceToTypeMap.Values.Any(x => x == instance.Key); - var isPublic = instance.Key.IsPublic; + if (ImGui.Button("Clear selection")) + this.selectedNodes.Clear(); - ImGui.BulletText($"{instance.Key.FullName} ({instance.Key.GetServiceKind()})"); - - using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed, !hasInterface)) + ImGui.SameLine(); + switch (this.includeUnloadDependencies) { - ImGui.Text(hasInterface - ? $"\t => Provided via interface: {container.InterfaceToTypeMap.First(x => x.Value == instance.Key).Key.FullName}" - : "\t => NO INTERFACE!!!"); + case true when ImGui.Button("Show load-time dependencies"): + this.includeUnloadDependencies = false; + this.dependencyNodes = null; + break; + case false when ImGui.Button("Show unload-time dependencies"): + this.includeUnloadDependencies = true; + this.dependencyNodes = null; + break; } - if (isPublic) + this.dependencyNodes ??= ServiceDependencyNode.CreateTreeByLevel(this.includeUnloadDependencies); + var cellPad = ImGui.CalcTextSize("WW"); + var margin = ImGui.CalcTextSize("W\nW\nW"); + var rowHeight = cellPad.Y * 3; + var width = ImGui.GetContentRegionAvail().X; + if (ImGui.BeginChild( + "dependency-graph", + new(width, (this.dependencyNodes.Count * (rowHeight + margin.Y)) + cellPad.Y), + false, + ImGuiWindowFlags.HorizontalScrollbar)) { - using var color = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed); - ImGui.Text("\t => PUBLIC!!!"); + const uint rectBaseBorderColor = 0xFFFFFFFF; + const uint rectHoverFillColor = 0xFF404040; + const uint rectHoverRelatedFillColor = 0xFF802020; + const uint rectSelectedFillColor = 0xFF20A020; + const uint rectSelectedRelatedFillColor = 0xFF204020; + const uint lineBaseColor = 0xFF808080; + const uint lineHoverColor = 0xFFFF8080; + const uint lineHoverNotColor = 0xFF404040; + const uint lineSelectedColor = 0xFF80FF00; + const uint lineInvalidColor = 0xFFFF0000; + + ServiceDependencyNode? hoveredNode = null; + + var pos = ImGui.GetCursorScreenPos(); + var dl = ImGui.GetWindowDrawList(); + var mouse = ImGui.GetMousePos(); + var maxRowWidth = 0f; + + // 1. Layout + for (var level = 0; level < this.dependencyNodes.Count; level++) + { + var levelNodes = this.dependencyNodes[level]; + + var rowWidth = 0f; + foreach (var node in levelNodes) + rowWidth += ImGui.CalcTextSize(node.TypeName).X + cellPad.X + margin.X; + + var off = cellPad / 2; + if (rowWidth < width) + off.X += ImGui.GetScrollX() + ((width - rowWidth) / 2); + else if (rowWidth - ImGui.GetScrollX() < width) + off.X += width - (rowWidth - ImGui.GetScrollX()); + off.Y = (rowHeight + margin.Y) * level; + + foreach (var node in levelNodes) + { + var textSize = ImGui.CalcTextSize(node.TypeName); + var cellSize = textSize + cellPad; + + var rc = new Vector4(pos + off, pos.X + off.X + cellSize.X, pos.Y + off.Y + cellSize.Y); + this.nodeRects[node] = rc; + if (rc.X <= mouse.X && mouse.X < rc.Z && rc.Y <= mouse.Y && mouse.Y < rc.W) + { + hoveredNode = node; + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + if (this.selectedNodes.Contains(node.Type)) + this.selectedNodes.Remove(node.Type); + else + this.selectedNodes.Add(node.Type); + } + } + + off.X += cellSize.X + margin.X; + } + + maxRowWidth = Math.Max(maxRowWidth, rowWidth); + } + + // 2. Draw non-hovered lines + foreach (var levelNodes in this.dependencyNodes) + { + foreach (var node in levelNodes) + { + var rect = this.nodeRects[node]; + var point1 = new Vector2((rect.X + rect.Z) / 2, rect.Y); + + foreach (var parent in node.InvalidParents) + { + rect = this.nodeRects[parent]; + var point2 = new Vector2((rect.X + rect.Z) / 2, rect.W); + if (node == hoveredNode || parent == hoveredNode) + continue; + + dl.AddLine(point1, point2, lineInvalidColor, 2f * ImGuiHelpers.GlobalScale); + } + + foreach (var parent in node.Parents) + { + rect = this.nodeRects[parent]; + var point2 = new Vector2((rect.X + rect.Z) / 2, rect.W); + if (node == hoveredNode || parent == hoveredNode) + continue; + + var isSelected = this.selectedNodes.Contains(node.Type) || + this.selectedNodes.Contains(parent.Type); + dl.AddLine( + point1, + point2, + isSelected + ? lineSelectedColor + : hoveredNode is not null + ? lineHoverNotColor + : lineBaseColor); + } + } + } + + // 3. Draw boxes + foreach (var levelNodes in this.dependencyNodes) + { + foreach (var node in levelNodes) + { + var textSize = ImGui.CalcTextSize(node.TypeName); + var cellSize = textSize + cellPad; + + var rc = this.nodeRects[node]; + if (hoveredNode == node) + dl.AddRectFilled(new(rc.X, rc.Y), new(rc.Z, rc.W), rectHoverFillColor); + else if (this.selectedNodes.Contains(node.Type)) + dl.AddRectFilled(new(rc.X, rc.Y), new(rc.Z, rc.W), rectSelectedFillColor); + else if (node.Relatives.Any(x => this.selectedNodes.Contains(x.Type))) + dl.AddRectFilled(new(rc.X, rc.Y), new(rc.Z, rc.W), rectSelectedRelatedFillColor); + else if (hoveredNode?.Relatives.Select(x => x.Type).Contains(node.Type) is true) + dl.AddRectFilled(new(rc.X, rc.Y), new(rc.Z, rc.W), rectHoverRelatedFillColor); + + dl.AddRect(new(rc.X, rc.Y), new(rc.Z, rc.W), rectBaseBorderColor); + ImGui.SetCursorPos((new Vector2(rc.X, rc.Y) - pos) + ((cellSize - textSize) / 2)); + ImGui.TextUnformatted(node.TypeName); + } + } + + // 4. Draw hovered lines + if (hoveredNode is not null) + { + foreach (var levelNodes in this.dependencyNodes) + { + foreach (var node in levelNodes) + { + var rect = this.nodeRects[node]; + var point1 = new Vector2((rect.X + rect.Z) / 2, rect.Y); + foreach (var parent in node.Parents) + { + if (node == hoveredNode || parent == hoveredNode) + { + rect = this.nodeRects[parent]; + var point2 = new Vector2((rect.X + rect.Z) / 2, rect.W); + dl.AddLine( + point1, + point2, + lineHoverColor, + 2 * ImGuiHelpers.GlobalScale); + } + } + } + } + } + + ImGui.SetCursorPos(default); + ImGui.Dummy(new(maxRowWidth, this.dependencyNodes.Count * rowHeight)); + ImGui.EndChild(); } - - ImGuiHelpers.ScaledDummy(2); + } + + if (ImGui.CollapsingHeader("Plugin-facing Services")) + { + foreach (var instance in container.Instances) + { + var hasInterface = container.InterfaceToTypeMap.Values.Any(x => x == instance.Key); + var isPublic = instance.Key.IsPublic; + + ImGui.BulletText($"{instance.Key.FullName} ({instance.Key.GetServiceKind()})"); + + using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed, !hasInterface)) + { + ImGui.Text( + hasInterface + ? $"\t => Provided via interface: {container.InterfaceToTypeMap.First(x => x.Value == instance.Key).Key.FullName}" + : "\t => NO INTERFACE!!!"); + } + + if (isPublic) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed); + ImGui.Text("\t => PUBLIC!!!"); + } + + ImGuiHelpers.ScaledDummy(2); + } + } + } + + private class ServiceDependencyNode + { + private readonly List parents = new(); + private readonly List children = new(); + private readonly List invalidParents = new(); + + private ServiceDependencyNode(Type t) => this.Type = t; + + public Type Type { get; } + + public string TypeName => this.Type.Name; + + public IReadOnlyList Parents => this.parents; + + public IReadOnlyList Children => this.children; + + public IReadOnlyList InvalidParents => this.invalidParents; + + public IEnumerable Relatives => + this.parents.Concat(this.children).Concat(this.invalidParents); + + public int Level { get; private set; } + + public static List CreateTree(bool includeUnloadDependencies) + { + var nodes = new Dictionary(); + foreach (var t in ServiceManager.GetConcreteServiceTypes()) + nodes.Add(typeof(Service<>).MakeGenericType(t), new(t)); + foreach (var t in ServiceManager.GetConcreteServiceTypes()) + { + var st = typeof(Service<>).MakeGenericType(t); + var node = nodes[st]; + foreach (var depType in ServiceHelpers.GetDependencies(st, includeUnloadDependencies)) + { + var depServiceType = typeof(Service<>).MakeGenericType(depType); + var depNode = nodes[depServiceType]; + if (node.IsAncestorOf(depType)) + { + node.invalidParents.Add(depNode); + } + else + { + depNode.UpdateNodeLevel(1); + node.UpdateNodeLevel(depNode.Level + 1); + node.parents.Add(depNode); + depNode.children.Add(node); + } + } + } + + return nodes.Values.OrderBy(x => x.Level).ThenBy(x => x.Type.Name).ToList(); + } + + public static List> CreateTreeByLevel(bool includeUnloadDependencies) + { + var res = new List>(); + foreach (var n in CreateTree(includeUnloadDependencies)) + { + while (res.Count <= n.Level) + res.Add(new()); + res[n.Level].Add(n); + } + + return res; + } + + private bool IsAncestorOf(Type type) => + this.children.Any(x => x.Type == type) || this.children.Any(x => x.IsAncestorOf(type)); + + private void UpdateNodeLevel(int newLevel) + { + if (this.Level >= newLevel) + return; + + this.Level = newLevel; + foreach (var c in this.children) + c.UpdateNodeLevel(newLevel + 1); } } } diff --git a/Dalamud/Interface/UldWrapper.cs b/Dalamud/Interface/UldWrapper.cs index e78546ed9..127ea85ec 100644 --- a/Dalamud/Interface/UldWrapper.cs +++ b/Dalamud/Interface/UldWrapper.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; +using System.IO; using System.Linq; using Dalamud.Data; using Dalamud.Interface.Internal; using Dalamud.Utility; -using ImGuiScene; using Lumina.Data.Files; using Lumina.Data.Parsing.Uld; @@ -155,20 +155,27 @@ public class UldWrapper : IDisposable // Try to load HD textures first. var hrPath = texturePath.Replace(".tex", "_hr1.tex"); + var substitution = Service.Get(); + hrPath = substitution.GetSubstitutedPath(hrPath); var hd = true; - var file = this.data.GetFile(hrPath); - if (file == null) + var tex = Path.IsPathRooted(hrPath) + ? this.data.GameData.GetFileFromDisk(hrPath) + : this.data.GetFile(hrPath); + if (tex == null) { hd = false; - file = this.data.GetFile(texturePath); + texturePath = substitution.GetSubstitutedPath(texturePath); + tex = Path.IsPathRooted(texturePath) + ? this.data.GameData.GetFileFromDisk(texturePath) + : this.data.GetFile(texturePath); // Neither texture could be loaded. - if (file == null) + if (tex == null) { return null; } } - return (id, file.Header.Width, file.Header.Height, hd, file.GetRgbaImageData()); + return (id, tex.Header.Width, tex.Header.Height, hd, tex.GetRgbaImageData()); } } diff --git a/Dalamud/Interface/Utility/ImVectorWrapper.cs b/Dalamud/Interface/Utility/ImVectorWrapper.cs index 67b002179..5ba1aec2f 100644 --- a/Dalamud/Interface/Utility/ImVectorWrapper.cs +++ b/Dalamud/Interface/Utility/ImVectorWrapper.cs @@ -208,7 +208,7 @@ public unsafe struct ImVectorWrapper : IList, IList, IReadOnlyList, IDi /// /// The initial capacity. /// The destroyer function to call on item removal. - public ImVectorWrapper(int initialCapacity = 0, ImGuiNativeDestroyDelegate? destroyer = null) + public ImVectorWrapper(int initialCapacity, ImGuiNativeDestroyDelegate? destroyer = null) { if (initialCapacity < 0) { @@ -394,7 +394,7 @@ public unsafe struct ImVectorWrapper : IList, IList, IReadOnlyList, IDi } /// - public void AddRange(Span items) + public void AddRange(ReadOnlySpan items) { this.EnsureCapacityExponential(this.LengthUnsafe + items.Length); foreach (var item in items) @@ -466,7 +466,7 @@ public unsafe struct ImVectorWrapper : IList, IList, IReadOnlyList, IDi /// The minimum capacity to ensure. /// Whether the capacity has been changed. public bool EnsureCapacityExponential(int capacity) - => this.EnsureCapacity(1 << ((sizeof(int) * 8) - BitOperations.LeadingZeroCount((uint)this.LengthUnsafe))); + => this.EnsureCapacity(1 << ((sizeof(int) * 8) - BitOperations.LeadingZeroCount((uint)capacity))); /// /// Resizes the underlying array and fills with zeroes if grown. @@ -519,10 +519,11 @@ public unsafe struct ImVectorWrapper : IList, IList, IReadOnlyList, IDi if (index < 0 || index > this.LengthUnsafe) throw new IndexOutOfRangeException(); - this.EnsureCapacityExponential(this.CapacityUnsafe + 1); + this.EnsureCapacityExponential(this.LengthUnsafe + 1); var num = this.LengthUnsafe - index; Buffer.MemoryCopy(this.DataUnsafe + index, this.DataUnsafe + index + 1, num * sizeof(T), num * sizeof(T)); this.DataUnsafe[index] = item; + this.LengthUnsafe += 1; } /// @@ -535,6 +536,7 @@ public unsafe struct ImVectorWrapper : IList, IList, IReadOnlyList, IDi Buffer.MemoryCopy(this.DataUnsafe + index, this.DataUnsafe + index + count, num * sizeof(T), num * sizeof(T)); foreach (var item in items) this.DataUnsafe[index++] = item; + this.LengthUnsafe += count; } else { @@ -543,14 +545,15 @@ public unsafe struct ImVectorWrapper : IList, IList, IReadOnlyList, IDi } } - /// - public void InsertRange(int index, Span items) + /// + public void InsertRange(int index, ReadOnlySpan items) { this.EnsureCapacityExponential(this.LengthUnsafe + items.Length); var num = this.LengthUnsafe - index; Buffer.MemoryCopy(this.DataUnsafe + index, this.DataUnsafe + index + items.Length, num * sizeof(T), num * sizeof(T)); foreach (var item in items) this.DataUnsafe[index++] = item; + this.LengthUnsafe += items.Length; } /// @@ -558,15 +561,7 @@ public unsafe struct ImVectorWrapper : IList, IList, IReadOnlyList, IDi /// /// The index. /// Whether to skip calling the destroyer function. - public void RemoveAt(int index, bool skipDestroyer = false) - { - this.EnsureIndex(index); - var num = this.LengthUnsafe - index - 1; - if (!skipDestroyer) - this.destroyer?.Invoke(&this.DataUnsafe[index]); - - Buffer.MemoryCopy(this.DataUnsafe + index + 1, this.DataUnsafe + index, num * sizeof(T), num * sizeof(T)); - } + public void RemoveAt(int index, bool skipDestroyer = false) => this.RemoveRange(index, 1, skipDestroyer); /// void IList.RemoveAt(int index) => this.RemoveAt(index); @@ -574,6 +569,73 @@ public unsafe struct ImVectorWrapper : IList, IList, IReadOnlyList, IDi /// void IList.RemoveAt(int index) => this.RemoveAt(index); + /// + /// Removes elements at the given index. + /// + /// The index of the first item to remove. + /// Number of items to remove. + /// Whether to skip calling the destroyer function. + public void RemoveRange(int index, int count, bool skipDestroyer = false) + { + this.EnsureIndex(index); + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), count, "Must be positive."); + if (count == 0) + return; + + if (!skipDestroyer && this.destroyer is { } d) + { + for (var i = 0; i < count; i++) + d(this.DataUnsafe + index + i); + } + + var numItemsToMove = this.LengthUnsafe - index - count; + var numBytesToMove = numItemsToMove * sizeof(T); + Buffer.MemoryCopy(this.DataUnsafe + index + count, this.DataUnsafe + index, numBytesToMove, numBytesToMove); + this.LengthUnsafe -= count; + } + + /// + /// Replaces a sequence at given offset of items with + /// . + /// + /// The index of the first item to be replaced. + /// The number of items to be replaced. + /// The replacement. + /// Whether to skip calling the destroyer function. + public void ReplaceRange(int index, int count, ReadOnlySpan replacement, bool skipDestroyer = false) + { + this.EnsureIndex(index); + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), count, "Must be positive."); + if (count == 0) + return; + + // Ensure the capacity first, so that we can safely destroy the items first. + this.EnsureCapacityExponential((this.LengthUnsafe + replacement.Length) - count); + + if (!skipDestroyer && this.destroyer is { } d) + { + for (var i = 0; i < count; i++) + d(this.DataUnsafe + index + i); + } + + if (count == replacement.Length) + { + replacement.CopyTo(this.DataSpan[index..]); + } + else if (count > replacement.Length) + { + replacement.CopyTo(this.DataSpan[index..]); + this.RemoveRange(index + replacement.Length, count - replacement.Length); + } + else + { + replacement[..count].CopyTo(this.DataSpan[index..]); + this.InsertRange(index + count, replacement[count..]); + } + } + /// /// Sets the capacity exactly as requested. /// @@ -611,9 +673,6 @@ public unsafe struct ImVectorWrapper : IList, IList, IReadOnlyList, IDi if (!oldSpan.IsEmpty && !newSpan.IsEmpty) oldSpan[..this.LengthUnsafe].CopyTo(newSpan); -// #if DEBUG -// new Span(newAlloc + this.LengthUnsafe, sizeof(T) * (capacity - this.LengthUnsafe)).Fill(0xCC); -// #endif if (oldAlloc != null) ImGuiNative.igMemFree(oldAlloc); diff --git a/Dalamud/Memory/MemoryHelper.cs b/Dalamud/Memory/MemoryHelper.cs index 3ceecf6a6..552817646 100644 --- a/Dalamud/Memory/MemoryHelper.cs +++ b/Dalamud/Memory/MemoryHelper.cs @@ -163,6 +163,38 @@ public static unsafe class MemoryHelper #region ReadString + /// + /// Compares if the given char span equals to the null-terminated string at . + /// + /// The character span. + /// The address of null-terminated string. + /// The encoding of the null-terminated string. + /// The maximum length of the null-terminated string. + /// Whether they are equal. + public static bool EqualsZeroTerminatedString( + ReadOnlySpan charSpan, + nint memoryAddress, + Encoding? encoding = null, + int maxLength = int.MaxValue) + { + encoding ??= Encoding.UTF8; + maxLength = Math.Min(maxLength, charSpan.Length + 4); + + var pmem = ((byte*)memoryAddress)!; + var length = 0; + while (length < maxLength && pmem[length] != 0) + length++; + + var mem = new Span(pmem, length); + var memCharCount = encoding.GetCharCount(mem); + if (memCharCount != charSpan.Length) + return false; + + Span chars = stackalloc char[memCharCount]; + encoding.GetChars(mem, chars); + return charSpan.SequenceEqual(chars); + } + /// /// Read a UTF-8 encoded string from a specified memory address. /// diff --git a/Dalamud/Plugin/Internal/PluginManager.cs b/Dalamud/Plugin/Internal/PluginManager.cs index b49ae0f13..894fabca6 100644 --- a/Dalamud/Plugin/Internal/PluginManager.cs +++ b/Dalamud/Plugin/Internal/PluginManager.cs @@ -21,6 +21,7 @@ using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface.Internal; using Dalamud.Interface.Internal.Windows.PluginInstaller; +using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Networking.Http; @@ -29,6 +30,7 @@ using Dalamud.Plugin.Internal.Profiles; using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Internal.Types.Manifest; using Dalamud.Plugin.Ipc.Internal; +using Dalamud.Support; using Dalamud.Utility; using Dalamud.Utility.Timing; using Newtonsoft.Json; @@ -93,7 +95,9 @@ internal partial class PluginManager : IDisposable, IServiceType } [ServiceManager.ServiceConstructor] - private PluginManager() + private PluginManager( + ServiceManager.RegisterStartupBlockerDelegate registerStartupBlocker, + ServiceManager.RegisterUnloadAfterDelegate registerUnloadAfter) { this.pluginDirectory = new DirectoryInfo(this.dalamud.StartInfo.PluginDirectory!); @@ -1204,6 +1208,49 @@ internal partial class PluginManager : IDisposable, IServiceType /// The calling plugin, or null. public LocalPlugin? FindCallingPlugin() => this.FindCallingPlugin(new StackTrace()); + /// + /// Resolves the services that a plugin may have a dependency on.
+ /// This is required, as the lifetime of a plugin cannot be longer than PluginManager, + /// and we want to ensure that dependency services to be kept alive at least until all the plugins, and thus + /// PluginManager to be gone. + ///
+ /// The dependency services. + private static IEnumerable ResolvePossiblePluginDependencyServices() + { + foreach (var serviceType in ServiceManager.GetConcreteServiceTypes()) + { + if (serviceType == typeof(PluginManager)) + continue; + + // Scoped plugin services lifetime is tied to their scopes. They go away when LocalPlugin goes away. + // Nonetheless, their direct dependencies must be considered. + if (serviceType.GetServiceKind() == ServiceManager.ServiceKind.ScopedService) + { + var typeAsServiceT = ServiceHelpers.GetAsService(serviceType); + var dependencies = ServiceHelpers.GetDependencies(typeAsServiceT, false); + ServiceManager.Log.Verbose("Found dependencies of scoped plugin service {Type} ({Cnt})", serviceType.FullName!, dependencies!.Count); + + foreach (var scopedDep in dependencies) + { + if (scopedDep == typeof(PluginManager)) + throw new Exception("Scoped plugin services cannot depend on PluginManager."); + + ServiceManager.Log.Verbose("PluginManager MUST depend on {Type} via {BaseType}", scopedDep.FullName!, serviceType.FullName!); + yield return scopedDep; + } + + continue; + } + + var pluginInterfaceAttribute = serviceType.GetCustomAttribute(true); + if (pluginInterfaceAttribute == null) + continue; + + ServiceManager.Log.Verbose("PluginManager MUST depend on {Type}", serviceType.FullName!); + yield return serviceType; + } + } + private async Task DownloadPluginAsync(RemotePluginManifest repoManifest, bool useTesting) { var downloadUrl = useTesting ? repoManifest.DownloadLinkTesting : repoManifest.DownloadLinkInstall; @@ -1595,6 +1642,38 @@ internal partial class PluginManager : IDisposable, IServiceType } } + private void LoadAndStartLoadSyncPlugins() + { + try + { + using (Timings.Start("PM Load Plugin Repos")) + { + _ = this.SetPluginReposFromConfigAsync(false); + this.OnInstalledPluginsChanged += () => Task.Run(Troubleshooting.LogTroubleshooting); + + Log.Information("[T3] PM repos OK!"); + } + + using (Timings.Start("PM Cleanup Plugins")) + { + this.CleanupPlugins(); + Log.Information("[T3] PMC OK!"); + } + + using (Timings.Start("PM Load Sync Plugins")) + { + this.LoadAllPlugins().Wait(); + Log.Information("[T3] PML OK!"); + } + + _ = Task.Run(Troubleshooting.LogTroubleshooting); + } + catch (Exception ex) + { + Log.Error(ex, "Plugin load failed"); + } + } + private static class Locs { public static string DalamudPluginUpdateSuccessful(string name, Version version) => Loc.Localize("DalamudPluginUpdateSuccessful", " 》 {0} updated to v{1}.").Format(name, version); diff --git a/Dalamud/Plugin/Internal/StartupPluginLoader.cs b/Dalamud/Plugin/Internal/StartupPluginLoader.cs deleted file mode 100644 index 4f68d39fc..000000000 --- a/Dalamud/Plugin/Internal/StartupPluginLoader.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Threading.Tasks; - -using Dalamud.Logging.Internal; -using Dalamud.Support; -using Dalamud.Utility.Timing; - -namespace Dalamud.Plugin.Internal; - -/// -/// Class responsible for loading plugins on startup. -/// -[ServiceManager.BlockingEarlyLoadedService] -public class StartupPluginLoader : IServiceType -{ - private static readonly ModuleLog Log = new("SPL"); - - [ServiceManager.ServiceConstructor] - private StartupPluginLoader(PluginManager pluginManager) - { - try - { - using (Timings.Start("PM Load Plugin Repos")) - { - _ = pluginManager.SetPluginReposFromConfigAsync(false); - pluginManager.OnInstalledPluginsChanged += () => Task.Run(Troubleshooting.LogTroubleshooting); - - Log.Information("[T3] PM repos OK!"); - } - - using (Timings.Start("PM Cleanup Plugins")) - { - pluginManager.CleanupPlugins(); - Log.Information("[T3] PMC OK!"); - } - - using (Timings.Start("PM Load Sync Plugins")) - { - pluginManager.LoadAllPlugins().Wait(); - Log.Information("[T3] PML OK!"); - } - - Task.Run(Troubleshooting.LogTroubleshooting); - } - catch (Exception ex) - { - Log.Error(ex, "Plugin load failed"); - } - } -} diff --git a/Dalamud/Plugin/Ipc/Internal/CallGate.cs b/Dalamud/Plugin/Ipc/Internal/CallGate.cs index 7d0f90cb6..fef4b97d0 100644 --- a/Dalamud/Plugin/Ipc/Internal/CallGate.cs +++ b/Dalamud/Plugin/Ipc/Internal/CallGate.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Collections.Immutable; namespace Dalamud.Plugin.Ipc.Internal; @@ -10,11 +11,28 @@ internal class CallGate : IServiceType { private readonly Dictionary gates = new(); + private ImmutableDictionary? gatesCopy; + [ServiceManager.ServiceConstructor] private CallGate() { } + /// + /// Gets the thread-safe view of the registered gates. + /// + public IReadOnlyDictionary Gates + { + get + { + var copy = this.gatesCopy; + if (copy is not null) + return copy; + lock (this.gates) + return this.gatesCopy ??= this.gates.ToImmutableDictionary(x => x.Key, x => x.Value); + } + } + /// /// Gets the provider associated with the specified name. /// @@ -22,8 +40,34 @@ internal class CallGate : IServiceType /// A CallGate registered under the given name. public CallGateChannel GetOrCreateChannel(string name) { - if (!this.gates.TryGetValue(name, out var gate)) - gate = this.gates[name] = new CallGateChannel(name); - return gate; + lock (this.gates) + { + if (!this.gates.TryGetValue(name, out var gate)) + { + gate = this.gates[name] = new(name); + this.gatesCopy = null; + } + + return gate; + } + } + + /// + /// Remove empty gates from . + /// + public void PurgeEmptyGates() + { + lock (this.gates) + { + var changed = false; + foreach (var (k, v) in this.Gates) + { + if (v.IsEmpty) + changed |= this.gates.Remove(k); + } + + if (changed) + this.gatesCopy = null; + } } } diff --git a/Dalamud/Plugin/Ipc/Internal/CallGateChannel.cs b/Dalamud/Plugin/Ipc/Internal/CallGateChannel.cs index 2e2c7249e..54adf2163 100644 --- a/Dalamud/Plugin/Ipc/Internal/CallGateChannel.cs +++ b/Dalamud/Plugin/Ipc/Internal/CallGateChannel.cs @@ -1,5 +1,5 @@ -using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Reflection; @@ -14,6 +14,17 @@ namespace Dalamud.Plugin.Ipc.Internal; ///
internal class CallGateChannel { + /// + /// The actual storage. + /// + private readonly HashSet subscriptions = new(); + + /// + /// A copy of the actual storage, that will be cleared and populated depending on changes made to + /// . + /// + private ImmutableList? subscriptionsCopy; + /// /// Initializes a new instance of the class. /// @@ -31,17 +42,52 @@ internal class CallGateChannel /// /// Gets a list of delegate subscriptions for when SendMessage is called. /// - public List Subscriptions { get; } = new(); + public IReadOnlyList Subscriptions + { + get + { + var copy = this.subscriptionsCopy; + if (copy is not null) + return copy; + lock (this.subscriptions) + return this.subscriptionsCopy ??= this.subscriptions.ToImmutableList(); + } + } /// /// Gets or sets an action for when InvokeAction is called. /// - public Delegate Action { get; set; } + public Delegate? Action { get; set; } /// /// Gets or sets a func for when InvokeFunc is called. /// - public Delegate Func { get; set; } + public Delegate? Func { get; set; } + + /// + /// Gets a value indicating whether this is not being used. + /// + public bool IsEmpty => this.Action is null && this.Func is null && this.Subscriptions.Count == 0; + + /// + internal void Subscribe(Delegate action) + { + lock (this.subscriptions) + { + this.subscriptionsCopy = null; + this.subscriptions.Add(action); + } + } + + /// + internal void Unsubscribe(Delegate action) + { + lock (this.subscriptions) + { + this.subscriptionsCopy = null; + this.subscriptions.Remove(action); + } + } /// /// Invoke all actions that have subscribed to this IPC. @@ -49,9 +95,6 @@ internal class CallGateChannel /// Message arguments. internal void SendMessage(object?[]? args) { - if (this.Subscriptions.Count == 0) - return; - foreach (var subscription in this.Subscriptions) { var methodInfo = subscription.GetMethodInfo(); @@ -105,7 +148,14 @@ internal class CallGateChannel var paramTypes = methodInfo.GetParameters() .Select(pi => pi.ParameterType).ToArray(); - if (args?.Length != paramTypes.Length) + if (args is null) + { + if (paramTypes.Length == 0) + return; + throw new IpcLengthMismatchError(this.Name, 0, paramTypes.Length); + } + + if (args.Length != paramTypes.Length) throw new IpcLengthMismatchError(this.Name, args.Length, paramTypes.Length); for (var i = 0; i < args.Length; i++) @@ -137,7 +187,7 @@ internal class CallGateChannel } } - private IEnumerable GenerateTypes(Type type) + private IEnumerable GenerateTypes(Type? type) { while (type != null && type != typeof(object)) { @@ -148,6 +198,9 @@ internal class CallGateChannel private object? ConvertObject(object? obj, Type type) { + if (obj is null) + return null; + var json = JsonConvert.SerializeObject(obj); try diff --git a/Dalamud/Plugin/Ipc/Internal/CallGatePubSub.cs b/Dalamud/Plugin/Ipc/Internal/CallGatePubSub.cs index 39d5b9f4d..cc54a563b 100644 --- a/Dalamud/Plugin/Ipc/Internal/CallGatePubSub.cs +++ b/Dalamud/Plugin/Ipc/Internal/CallGatePubSub.cs @@ -1,5 +1,3 @@ -using System; - #pragma warning disable SA1402 // File may only contain a single type namespace Dalamud.Plugin.Ipc.Internal; @@ -37,7 +35,7 @@ internal class CallGatePubSub : CallGatePubSubBase, ICallGateProvider base.InvokeAction(); - /// + /// public TRet InvokeFunc() => this.InvokeFunc(); } @@ -75,7 +73,7 @@ internal class CallGatePubSub : CallGatePubSubBase, ICallGateProvider< public void InvokeAction(T1 arg1) => base.InvokeAction(arg1); - /// + /// public TRet InvokeFunc(T1 arg1) => this.InvokeFunc(arg1); } @@ -113,7 +111,7 @@ internal class CallGatePubSub : CallGatePubSubBase, ICallGateProvi public void InvokeAction(T1 arg1, T2 arg2) => base.InvokeAction(arg1, arg2); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2) => this.InvokeFunc(arg1, arg2); } @@ -151,7 +149,7 @@ internal class CallGatePubSub : CallGatePubSubBase, ICallGateP public void InvokeAction(T1 arg1, T2 arg2, T3 arg3) => base.InvokeAction(arg1, arg2, arg3); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3) => this.InvokeFunc(arg1, arg2, arg3); } @@ -189,7 +187,7 @@ internal class CallGatePubSub : CallGatePubSubBase, ICallG public void InvokeAction(T1 arg1, T2 arg2, T3 arg3, T4 arg4) => base.InvokeAction(arg1, arg2, arg3, arg4); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3, T4 arg4) => this.InvokeFunc(arg1, arg2, arg3, arg4); } @@ -227,7 +225,7 @@ internal class CallGatePubSub : CallGatePubSubBase, IC public void InvokeAction(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5) => base.InvokeAction(arg1, arg2, arg3, arg4, arg5); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5) => this.InvokeFunc(arg1, arg2, arg3, arg4, arg5); } @@ -265,7 +263,7 @@ internal class CallGatePubSub : CallGatePubSubBase public void InvokeAction(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6) => base.InvokeAction(arg1, arg2, arg3, arg4, arg5, arg6); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6) => this.InvokeFunc(arg1, arg2, arg3, arg4, arg5, arg6); } @@ -303,7 +301,7 @@ internal class CallGatePubSub : CallGatePubSub public void InvokeAction(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7) => base.InvokeAction(arg1, arg2, arg3, arg4, arg5, arg6, arg7); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7) => this.InvokeFunc(arg1, arg2, arg3, arg4, arg5, arg6, arg7); } @@ -341,7 +339,7 @@ internal class CallGatePubSub : CallGatePu public void InvokeAction(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8) => base.InvokeAction(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8); - /// + /// public TRet InvokeFunc(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8) => this.InvokeFunc(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8); } diff --git a/Dalamud/Plugin/Ipc/Internal/CallGatePubSubBase.cs b/Dalamud/Plugin/Ipc/Internal/CallGatePubSubBase.cs index 40c0c4a59..b6a4e8a61 100644 --- a/Dalamud/Plugin/Ipc/Internal/CallGatePubSubBase.cs +++ b/Dalamud/Plugin/Ipc/Internal/CallGatePubSubBase.cs @@ -1,5 +1,3 @@ -using System; - using Dalamud.Plugin.Ipc.Exceptions; namespace Dalamud.Plugin.Ipc.Internal; @@ -13,7 +11,7 @@ internal abstract class CallGatePubSubBase /// Initializes a new instance of the class. /// /// The name of the IPC registration. - public CallGatePubSubBase(string name) + protected CallGatePubSubBase(string name) { this.Channel = Service.Get().GetOrCreateChannel(name); } @@ -54,14 +52,14 @@ internal abstract class CallGatePubSubBase /// /// Action to subscribe. private protected void Subscribe(Delegate action) - => this.Channel.Subscriptions.Add(action); + => this.Channel.Subscribe(action); /// /// Unsubscribe an expression from this registration. /// /// Action to unsubscribe. private protected void Unsubscribe(Delegate action) - => this.Channel.Subscriptions.Remove(action); + => this.Channel.Unsubscribe(action); /// /// Invoke an action registered for inter-plugin communication. diff --git a/Dalamud/Plugin/Ipc/Internal/DataCache.cs b/Dalamud/Plugin/Ipc/Internal/DataCache.cs index c357f77c2..38cea4866 100644 --- a/Dalamud/Plugin/Ipc/Internal/DataCache.cs +++ b/Dalamud/Plugin/Ipc/Internal/DataCache.cs @@ -1,5 +1,10 @@ -using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; + +using Dalamud.Plugin.Ipc.Exceptions; + +using Serilog; namespace Dalamud.Plugin.Ipc.Internal; @@ -8,10 +13,14 @@ namespace Dalamud.Plugin.Ipc.Internal; /// internal readonly struct DataCache { + /// Name of the data. + internal readonly string Tag; + /// The assembly name of the initial creator. internal readonly string CreatorAssemblyName; /// A not-necessarily distinct list of current users. + /// Also used as a reference count tracker. internal readonly List UserAssemblyNames; /// The type the data was registered as. @@ -23,14 +32,83 @@ internal readonly struct DataCache /// /// Initializes a new instance of the struct. /// + /// Name of the data. /// The assembly name of the initial creator. /// A reference to data. /// The type of the data. - public DataCache(string creatorAssemblyName, object? data, Type type) + public DataCache(string tag, string creatorAssemblyName, object? data, Type type) { + this.Tag = tag; this.CreatorAssemblyName = creatorAssemblyName; - this.UserAssemblyNames = new List { creatorAssemblyName }; + this.UserAssemblyNames = new(); this.Data = data; this.Type = type; } + + /// + /// Creates a new instance of the struct, using the given data generator function. + /// + /// The name for the data cache. + /// The assembly name of the initial creator. + /// The function that generates the data if it does not already exist. + /// The type of the stored data - needs to be a reference type that is shared through Dalamud itself, not loaded by the plugin. + /// The new instance of . + public static DataCache From(string tag, string creatorAssemblyName, Func dataGenerator) + where T : class + { + try + { + var result = new DataCache(tag, creatorAssemblyName, dataGenerator.Invoke(), typeof(T)); + Log.Verbose( + "[{who}] Created new data for [{Tag:l}] for creator {Creator:l}.", + nameof(DataShare), + tag, + creatorAssemblyName); + return result; + } + catch (Exception e) + { + throw ExceptionDispatchInfo.SetCurrentStackTrace( + new DataCacheCreationError(tag, creatorAssemblyName, typeof(T), e)); + } + } + + /// + /// Attempts to fetch the data. + /// + /// The name of the caller assembly. + /// The value, if succeeded. + /// The exception, if failed. + /// Desired type of the data. + /// true on success. + public bool TryGetData( + string callerName, + [NotNullWhen(true)] out T? value, + [NotNullWhen(false)] out Exception? ex) + where T : class + { + switch (this.Data) + { + case null: + value = null; + ex = ExceptionDispatchInfo.SetCurrentStackTrace(new DataCacheValueNullError(this.Tag, this.Type)); + return false; + + case T data: + value = data; + ex = null; + + // Register the access history + lock (this.UserAssemblyNames) + this.UserAssemblyNames.Add(callerName); + + return true; + + default: + value = null; + ex = ExceptionDispatchInfo.SetCurrentStackTrace( + new DataCacheTypeMismatchError(this.Tag, this.CreatorAssemblyName, typeof(T), this.Type)); + return false; + } + } } diff --git a/Dalamud/Plugin/Ipc/Internal/DataShare.cs b/Dalamud/Plugin/Ipc/Internal/DataShare.cs index a3e314b80..b122f481d 100644 --- a/Dalamud/Plugin/Ipc/Internal/DataShare.cs +++ b/Dalamud/Plugin/Ipc/Internal/DataShare.cs @@ -1,9 +1,7 @@ -using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Reflection; using Dalamud.Plugin.Ipc.Exceptions; using Serilog; @@ -16,7 +14,11 @@ namespace Dalamud.Plugin.Ipc.Internal; [ServiceManager.BlockingEarlyLoadedService] internal class DataShare : IServiceType { - private readonly Dictionary caches = new(); + /// + /// Dictionary of cached values. Note that is being used, as it does its own locking, + /// effectively preventing calling the data generator multiple times concurrently. + /// + private readonly Dictionary> caches = new(); [ServiceManager.ServiceConstructor] private DataShare() @@ -39,38 +41,15 @@ internal class DataShare : IServiceType where T : class { var callerName = GetCallerName(); + + Lazy cacheLazy; lock (this.caches) { - if (this.caches.TryGetValue(tag, out var cache)) - { - if (!cache.Type.IsAssignableTo(typeof(T))) - { - throw new DataCacheTypeMismatchError(tag, cache.CreatorAssemblyName, typeof(T), cache.Type); - } - - cache.UserAssemblyNames.Add(callerName); - return cache.Data as T ?? throw new DataCacheValueNullError(tag, cache.Type); - } - - try - { - var obj = dataGenerator.Invoke(); - if (obj == null) - { - throw new Exception("Returned data was null."); - } - - cache = new DataCache(callerName, obj, typeof(T)); - this.caches[tag] = cache; - - Log.Verbose("[DataShare] Created new data for [{Tag:l}] for creator {Creator:l}.", tag, callerName); - return obj; - } - catch (Exception e) - { - throw new DataCacheCreationError(tag, callerName, typeof(T), e); - } + if (!this.caches.TryGetValue(tag, out cacheLazy)) + this.caches[tag] = cacheLazy = new(() => DataCache.From(tag, callerName, dataGenerator)); } + + return cacheLazy.Value.TryGetData(callerName, out var value, out var ex) ? value : throw ex; } /// @@ -80,34 +59,36 @@ internal class DataShare : IServiceType /// The name for the data cache. public void RelinquishData(string tag) { + DataCache cache; lock (this.caches) { - if (!this.caches.TryGetValue(tag, out var cache)) - { + if (!this.caches.TryGetValue(tag, out var cacheLazy)) return; - } var callerName = GetCallerName(); - lock (this.caches) - { - if (!cache.UserAssemblyNames.Remove(callerName) || cache.UserAssemblyNames.Count > 0) - { - return; - } - if (this.caches.Remove(tag)) - { - if (cache.Data is IDisposable disposable) - { - disposable.Dispose(); - Log.Verbose("[DataShare] Disposed [{Tag:l}] after it was removed from all shares.", tag); - } - else - { - Log.Verbose("[DataShare] Removed [{Tag:l}] from all shares.", tag); - } - } + cache = cacheLazy.Value; + if (!cache.UserAssemblyNames.Remove(callerName) || cache.UserAssemblyNames.Count > 0) + return; + if (!this.caches.Remove(tag)) + return; + } + + if (cache.Data is IDisposable disposable) + { + try + { + disposable.Dispose(); + Log.Verbose("[DataShare] Disposed [{Tag:l}] after it was removed from all shares.", tag); } + catch (Exception e) + { + Log.Error(e, "[DataShare] Failed to dispose [{Tag:l}] after it was removed from all shares.", tag); + } + } + else + { + Log.Verbose("[DataShare] Removed [{Tag:l}] from all shares.", tag); } } @@ -123,23 +104,14 @@ internal class DataShare : IServiceType where T : class { data = null; + Lazy cacheLazy; lock (this.caches) { - if (!this.caches.TryGetValue(tag, out var cache) || !cache.Type.IsAssignableTo(typeof(T))) - { + if (!this.caches.TryGetValue(tag, out cacheLazy)) return false; - } - - var callerName = GetCallerName(); - data = cache.Data as T; - if (data == null) - { - return false; - } - - cache.UserAssemblyNames.Add(callerName); - return true; } + + return cacheLazy.Value.TryGetData(GetCallerName(), out data, out _); } /// @@ -155,27 +127,14 @@ internal class DataShare : IServiceType public T GetData(string tag) where T : class { + Lazy cacheLazy; lock (this.caches) { - if (!this.caches.TryGetValue(tag, out var cache)) - { + if (!this.caches.TryGetValue(tag, out cacheLazy)) throw new KeyNotFoundException($"The data cache [{tag}] is not registered."); - } - - var callerName = Assembly.GetCallingAssembly().GetName().Name ?? string.Empty; - if (!cache.Type.IsAssignableTo(typeof(T))) - { - throw new DataCacheTypeMismatchError(tag, callerName, typeof(T), cache.Type); - } - - if (cache.Data is not T data) - { - throw new DataCacheValueNullError(tag, typeof(T)); - } - - cache.UserAssemblyNames.Add(callerName); - return data; } + + return cacheLazy.Value.TryGetData(GetCallerName(), out var value, out var ex) ? value : throw ex; } /// @@ -186,7 +145,8 @@ internal class DataShare : IServiceType { lock (this.caches) { - return this.caches.Select(kvp => (kvp.Key, kvp.Value.CreatorAssemblyName, kvp.Value.UserAssemblyNames.ToArray())); + return this.caches.Select( + kvp => (kvp.Key, kvp.Value.Value.CreatorAssemblyName, kvp.Value.Value.UserAssemblyNames.ToArray())); } } diff --git a/Dalamud/Plugin/Services/IGameInventory.cs b/Dalamud/Plugin/Services/IGameInventory.cs new file mode 100644 index 000000000..a1b1114d7 --- /dev/null +++ b/Dalamud/Plugin/Services/IGameInventory.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; + +using Dalamud.Game.Inventory; +using Dalamud.Game.Inventory.InventoryEventArgTypes; + +namespace Dalamud.Plugin.Services; + +/// +/// This class provides events for the in-game inventory. +/// +public interface IGameInventory +{ + /// + /// Delegate function to be called when inventories have been changed. + /// This delegate sends the entire set of changes recorded. + /// + /// The events. + public delegate void InventoryChangelogDelegate(IReadOnlyCollection events); + + /// + /// Delegate function to be called for each change to inventories. + /// This delegate sends individual events for changes. + /// + /// The event try that triggered this message. + /// Data for the triggered event. + public delegate void InventoryChangedDelegate(GameInventoryEvent type, InventoryEventArgs data); + + /// + /// Delegate function to be called for each change to inventories. + /// This delegate sends individual events for changes. + /// + /// The event arg type. + /// Data for the triggered event. + public delegate void InventoryChangedDelegate(T data) where T : InventoryEventArgs; + + /// + /// Event that is fired when the inventory has been changed.
+ /// Note that some events, such as , , and + /// currently is subject to reinterpretation as , , and + /// .
+ /// Use if you do not want such reinterpretation. + ///
+ public event InventoryChangelogDelegate InventoryChanged; + + /// + /// Event that is fired when the inventory has been changed, without trying to interpret two inventory slot changes + /// as a move event as appropriate.
+ /// In other words, , , and + /// currently do not fire in this event. + ///
+ public event InventoryChangelogDelegate InventoryChangedRaw; + + /// + /// Event that is fired when an item is added to an inventory.
+ /// If this event is a part of multi-step event, then this event will not be called.
+ /// Use if you do not want such reinterpretation. + ///
+ public event InventoryChangedDelegate ItemAdded; + + /// + /// Event that is fired when an item is removed from an inventory.
+ /// If this event is a part of multi-step event, then this event will not be called.
+ /// Use if you do not want such reinterpretation. + ///
+ public event InventoryChangedDelegate ItemRemoved; + + /// + /// Event that is fired when an items properties are changed.
+ /// If this event is a part of multi-step event, then this event will not be called.
+ /// Use if you do not want such reinterpretation. + ///
+ public event InventoryChangedDelegate ItemChanged; + + /// + /// Event that is fired when an item is moved from one inventory into another. + /// + public event InventoryChangedDelegate ItemMoved; + + /// + /// Event that is fired when an item is split from one stack into two. + /// + public event InventoryChangedDelegate ItemSplit; + + /// + /// Event that is fired when an item is merged from two stacks into one. + /// + public event InventoryChangedDelegate ItemMerged; + + /// + public event InventoryChangedDelegate ItemAddedExplicit; + + /// + public event InventoryChangedDelegate ItemRemovedExplicit; + + /// + public event InventoryChangedDelegate ItemChangedExplicit; + + /// + public event InventoryChangedDelegate ItemMovedExplicit; + + /// + public event InventoryChangedDelegate ItemSplitExplicit; + + /// + public event InventoryChangedDelegate ItemMergedExplicit; +} diff --git a/Dalamud/ServiceManager.cs b/Dalamud/ServiceManager.cs index 46a6ba509..3ff7cde76 100644 --- a/Dalamud/ServiceManager.cs +++ b/Dalamud/ServiceManager.cs @@ -11,6 +11,7 @@ using Dalamud.Game; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Storage; +using Dalamud.Utility; using Dalamud.Utility.Timing; using JetBrains.Annotations; @@ -21,7 +22,7 @@ namespace Dalamud; // - Visualize/output .dot or imgui thing /// -/// Class to initialize Service<T>s. +/// Class to initialize . /// internal static class ServiceManager { @@ -43,6 +44,26 @@ internal static class ServiceManager private static readonly TaskCompletionSource BlockingServicesLoadedTaskCompletionSource = new(); private static ManualResetEvent unloadResetEvent = new(false); + + /// + /// Delegate for registering startup blocker task.
+ /// Do not use this delegate outside the constructor. + ///
+ /// The blocker task. + /// The justification for using this feature. + [InjectableType] + public delegate void RegisterStartupBlockerDelegate(Task t, string justification); + + /// + /// Delegate for registering services that should be unloaded before self.
+ /// Intended for use with . If you think you need to use this outside + /// of that, consider having a discussion first.
+ /// Do not use this delegate outside the constructor. + ///
+ /// Services that should be unloaded first. + /// The justification for using this feature. + [InjectableType] + public delegate void RegisterUnloadAfterDelegate(IEnumerable unloadAfter, string justification); /// /// Kinds of services. @@ -125,6 +146,15 @@ internal static class ServiceManager #endif } + /// + /// Gets the concrete types of services, i.e. the non-abstract non-interface types. + /// + /// The enumerable of service types, that may be enumerated only once per call. + public static IEnumerable GetConcreteServiceTypes() => + Assembly.GetExecutingAssembly() + .GetTypes() + .Where(x => x.IsAssignableTo(typeof(IServiceType)) && !x.IsInterface && !x.IsAbstract); + /// /// Kicks off construction of services that can handle early loading. /// @@ -141,7 +171,7 @@ internal static class ServiceManager var serviceContainer = Service.Get(); - foreach (var serviceType in Assembly.GetExecutingAssembly().GetTypes().Where(x => x.IsAssignableTo(typeof(IServiceType)) && !x.IsInterface && !x.IsAbstract)) + foreach (var serviceType in GetConcreteServiceTypes()) { var serviceKind = serviceType.GetServiceKind(); Debug.Assert(serviceKind != ServiceKind.None, $"Service<{serviceType.FullName}> did not specify a kind"); @@ -157,7 +187,7 @@ internal static class ServiceManager var getTask = (Task)genericWrappedServiceType .InvokeMember( - "GetAsync", + nameof(Service.GetAsync), BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.Public, null, null, @@ -184,17 +214,42 @@ internal static class ServiceManager } var typeAsServiceT = ServiceHelpers.GetAsService(serviceType); - dependencyServicesMap[serviceType] = ServiceHelpers.GetDependencies(typeAsServiceT) + dependencyServicesMap[serviceType] = ServiceHelpers.GetDependencies(typeAsServiceT, false) .Select(x => typeof(Service<>).MakeGenericType(x)) .ToList(); } + var blockerTasks = new List(); _ = Task.Run(async () => { try { - var whenBlockingComplete = Task.WhenAll(blockingEarlyLoadingServices.Select(x => getAsyncTaskMap[x])); - while (await Task.WhenAny(whenBlockingComplete, Task.Delay(30000)) != whenBlockingComplete) + // Wait for all blocking constructors to complete first. + await WaitWithTimeoutConsent(blockingEarlyLoadingServices.Select(x => getAsyncTaskMap[x])); + + // All the BlockingEarlyLoadedService constructors have been run, + // and blockerTasks now will not change. Now wait for them. + // Note that ServiceManager.CallWhenServicesReady does not get to register a blocker. + await WaitWithTimeoutConsent(blockerTasks); + + BlockingServicesLoadedTaskCompletionSource.SetResult(); + Timings.Event("BlockingServices Initialized"); + } + catch (Exception e) + { + BlockingServicesLoadedTaskCompletionSource.SetException(e); + } + + return; + + async Task WaitWithTimeoutConsent(IEnumerable tasksEnumerable) + { + var tasks = tasksEnumerable.AsReadOnlyCollection(); + if (tasks.Count == 0) + return; + + var aggregatedTask = Task.WhenAll(tasks); + while (await Task.WhenAny(aggregatedTask, Task.Delay(120000)) != aggregatedTask) { if (NativeFunctions.MessageBoxW( IntPtr.Zero, @@ -208,13 +263,6 @@ internal static class ServiceManager "and the user chose to continue without Dalamud."); } } - - BlockingServicesLoadedTaskCompletionSource.SetResult(); - Timings.Event("BlockingServices Initialized"); - } - catch (Exception e) - { - BlockingServicesLoadedTaskCompletionSource.SetException(e); } }).ConfigureAwait(false); @@ -249,6 +297,25 @@ internal static class ServiceManager if (!hasDeps) continue; + // This object will be used in a task. Each task must receive a new object. + var startLoaderArgs = new List(); + if (serviceType.GetCustomAttribute() is not null) + { + startLoaderArgs.Add( + new RegisterStartupBlockerDelegate( + (task, justification) => + { +#if DEBUG + if (CurrentConstructorServiceType.Value != serviceType) + throw new InvalidOperationException("Forbidden."); +#endif + blockerTasks.Add(task); + + // No need to store the justification; the fact that the reason is specified is good enough. + _ = justification; + })); + } + tasks.Add((Task)typeof(Service<>) .MakeGenericType(serviceType) .InvokeMember( @@ -256,7 +323,7 @@ internal static class ServiceManager BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.NonPublic, null, null, - null)); + new object[] { startLoaderArgs })); servicesToLoad.Remove(serviceType); #if DEBUG @@ -328,13 +395,13 @@ internal static class ServiceManager unloadResetEvent.Reset(); - var dependencyServicesMap = new Dictionary>(); + var dependencyServicesMap = new Dictionary>(); var allToUnload = new HashSet(); var unloadOrder = new List(); Log.Information("==== COLLECTING SERVICES TO UNLOAD ===="); - foreach (var serviceType in Assembly.GetExecutingAssembly().GetTypes()) + foreach (var serviceType in GetConcreteServiceTypes()) { if (!serviceType.IsAssignableTo(typeof(IServiceType))) continue; @@ -347,7 +414,7 @@ internal static class ServiceManager Log.Verbose("Calling GetDependencyServices for '{ServiceName}'", serviceType.FullName!); var typeAsServiceT = ServiceHelpers.GetAsService(serviceType); - dependencyServicesMap[serviceType] = ServiceHelpers.GetDependencies(typeAsServiceT); + dependencyServicesMap[serviceType] = ServiceHelpers.GetDependencies(typeAsServiceT, true); allToUnload.Add(serviceType); } @@ -541,11 +608,35 @@ internal static class ServiceManager } /// - /// Indicates that the method should be called when the services given in the constructor are ready. + /// Indicates that the method should be called when the services given in the marked method's parameters are ready. + /// This will be executed immediately after the constructor has run, if all services specified as its parameters + /// are already ready, or no parameter is given. /// [AttributeUsage(AttributeTargets.Method)] [MeansImplicitUse] public class CallWhenServicesReady : Attribute + { + /// + /// Initializes a new instance of the class. + /// + /// Specify the reason here. + public CallWhenServicesReady(string justification) + { + // No need to store the justification; the fact that the reason is specified is good enough. + _ = justification; + } + } + + /// + /// Indicates that something is a candidate for being considered as an injected parameter for constructors. + /// + [AttributeUsage( + AttributeTargets.Delegate + | AttributeTargets.Class + | AttributeTargets.Struct + | AttributeTargets.Enum + | AttributeTargets.Interface)] + public class InjectableTypeAttribute : Attribute { } } diff --git a/Dalamud/Service{T}.cs b/Dalamud/Service{T}.cs index 9c7f0411d..08f592826 100644 --- a/Dalamud/Service{T}.cs +++ b/Dalamud/Service{T}.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using Dalamud.IoC; using Dalamud.IoC.Internal; -using Dalamud.Plugin.Internal; using Dalamud.Utility.Timing; using JetBrains.Annotations; @@ -25,6 +24,7 @@ internal static class Service where T : IServiceType private static readonly ServiceManager.ServiceAttribute ServiceAttribute; private static TaskCompletionSource instanceTcs = new(); private static List? dependencyServices; + private static List? dependencyServicesForUnload; static Service() { @@ -95,7 +95,7 @@ internal static class Service where T : IServiceType if (ServiceAttribute.Kind != ServiceManager.ServiceKind.ProvidedService && ServiceManager.CurrentConstructorServiceType.Value is { } currentServiceType) { - var deps = ServiceHelpers.GetDependencies(currentServiceType); + var deps = ServiceHelpers.GetDependencies(typeof(Service<>).MakeGenericType(currentServiceType), false); if (!deps.Contains(typeof(T))) { throw new InvalidOperationException( @@ -115,7 +115,6 @@ internal static class Service where T : IServiceType /// Pull the instance out of the service locator, waiting if necessary. /// /// The object. - [UsedImplicitly] public static Task GetAsync() => instanceTcs.Task; /// @@ -141,11 +140,15 @@ internal static class Service where T : IServiceType /// /// Gets an enumerable containing s that are required for this Service to initialize /// without blocking. + /// These are NOT returned as types; raw types will be returned. /// + /// Whether to include the unload dependencies. /// List of dependency services. - [UsedImplicitly] - public static List GetDependencyServices() + public static IReadOnlyCollection GetDependencyServices(bool includeUnloadDependencies) { + if (includeUnloadDependencies && dependencyServicesForUnload is not null) + return dependencyServicesForUnload; + if (dependencyServices is not null) return dependencyServices; @@ -158,7 +161,8 @@ internal static class Service where T : IServiceType { res.AddRange(ctor .GetParameters() - .Select(x => x.ParameterType)); + .Select(x => x.ParameterType) + .Where(x => x.GetServiceKind() != ServiceManager.ServiceKind.None)); } res.AddRange(typeof(T) @@ -171,50 +175,8 @@ internal static class Service where T : IServiceType .OfType() .Select(x => x.GetType().GetGenericArguments().First())); - // HACK: PluginManager needs to depend on ALL plugin exposed services - if (typeof(T) == typeof(PluginManager)) - { - foreach (var serviceType in Assembly.GetExecutingAssembly().GetTypes()) - { - if (!serviceType.IsAssignableTo(typeof(IServiceType))) - continue; - - if (serviceType == typeof(PluginManager)) - continue; - - // Scoped plugin services lifetime is tied to their scopes. They go away when LocalPlugin goes away. - // Nonetheless, their direct dependencies must be considered. - if (serviceType.GetServiceKind() == ServiceManager.ServiceKind.ScopedService) - { - var typeAsServiceT = ServiceHelpers.GetAsService(serviceType); - var dependencies = ServiceHelpers.GetDependencies(typeAsServiceT); - ServiceManager.Log.Verbose("Found dependencies of scoped plugin service {Type} ({Cnt})", serviceType.FullName!, dependencies!.Count); - - foreach (var scopedDep in dependencies) - { - if (scopedDep == typeof(PluginManager)) - throw new Exception("Scoped plugin services cannot depend on PluginManager."); - - ServiceManager.Log.Verbose("PluginManager MUST depend on {Type} via {BaseType}", scopedDep.FullName!, serviceType.FullName!); - res.Add(scopedDep); - } - - continue; - } - - var pluginInterfaceAttribute = serviceType.GetCustomAttribute(true); - if (pluginInterfaceAttribute == null) - continue; - - ServiceManager.Log.Verbose("PluginManager MUST depend on {Type}", serviceType.FullName!); - res.Add(serviceType); - } - } - foreach (var type in res) - { ServiceManager.Log.Verbose("Service<{0}>: => Dependency: {1}", typeof(T).Name, type.Name); - } var deps = res .Distinct() @@ -244,8 +206,9 @@ internal static class Service where T : IServiceType /// /// Starts the service loader. Only to be called from . /// + /// Additional objects available to constructors. /// The loader task. - internal static Task StartLoader() + internal static Task StartLoader(IReadOnlyCollection additionalProvidedTypedObjects) { if (instanceTcs.Task.IsCompleted) throw new InvalidOperationException($"{typeof(T).Name} is already loaded or disposed."); @@ -256,10 +219,27 @@ internal static class Service where T : IServiceType return Task.Run(Timings.AttachTimingHandle(async () => { + var ctorArgs = new List(additionalProvidedTypedObjects.Count + 1); + ctorArgs.AddRange(additionalProvidedTypedObjects); + ctorArgs.Add( + new ServiceManager.RegisterUnloadAfterDelegate( + (additionalDependencies, justification) => + { +#if DEBUG + if (ServiceManager.CurrentConstructorServiceType.Value != typeof(T)) + throw new InvalidOperationException("Forbidden."); +#endif + dependencyServicesForUnload ??= new(GetDependencyServices(false)); + dependencyServicesForUnload.AddRange(additionalDependencies); + + // No need to store the justification; the fact that the reason is specified is good enough. + _ = justification; + })); + ServiceManager.Log.Debug("Service<{0}>: Begin construction", typeof(T).Name); try { - var instance = await ConstructObject(); + var instance = await ConstructObject(ctorArgs).ConfigureAwait(false); instanceTcs.SetResult(instance); List? tasks = null; @@ -270,8 +250,17 @@ internal static class Service where T : IServiceType continue; ServiceManager.Log.Debug("Service<{0}>: Calling {1}", typeof(T).Name, method.Name); - var args = await Task.WhenAll(method.GetParameters().Select( - x => ResolveServiceFromTypeAsync(x.ParameterType))); + var args = await ResolveInjectedParameters( + method.GetParameters(), + Array.Empty()).ConfigureAwait(false); + if (args.Length == 0) + { + ServiceManager.Log.Warning( + "Service<{0}>: Method {1} does not have any arguments. Consider merging it with the ctor.", + typeof(T).Name, + method.Name); + } + try { if (method.Invoke(instance, args) is Task task) @@ -331,24 +320,6 @@ internal static class Service where T : IServiceType instanceTcs.SetException(new UnloadedException()); } - private static async Task ResolveServiceFromTypeAsync(Type type) - { - var task = (Task)typeof(Service<>) - .MakeGenericType(type) - .InvokeMember( - "GetAsync", - BindingFlags.InvokeMethod | - BindingFlags.Static | - BindingFlags.Public, - null, - null, - null)!; - await task; - return typeof(Task<>).MakeGenericType(type) - .GetProperty("Result", BindingFlags.Instance | BindingFlags.Public)! - .GetValue(task); - } - private static ConstructorInfo? GetServiceConstructor() { const BindingFlags ctorBindingFlags = @@ -359,18 +330,18 @@ internal static class Service where T : IServiceType .SingleOrDefault(x => x.GetCustomAttributes(typeof(ServiceManager.ServiceConstructor), true).Any()); } - private static async Task ConstructObject() + private static async Task ConstructObject(IReadOnlyCollection additionalProvidedTypedObjects) { var ctor = GetServiceConstructor(); if (ctor == null) throw new Exception($"Service \"{typeof(T).FullName}\" had no applicable constructor"); - var args = await Task.WhenAll( - ctor.GetParameters().Select(x => ResolveServiceFromTypeAsync(x.ParameterType))); + var args = await ResolveInjectedParameters(ctor.GetParameters(), additionalProvidedTypedObjects) + .ConfigureAwait(false); using (Timings.Start($"{typeof(T).Name} Construct")) { #if DEBUG - ServiceManager.CurrentConstructorServiceType.Value = typeof(Service); + ServiceManager.CurrentConstructorServiceType.Value = typeof(T); try { return (T)ctor.Invoke(args)!; @@ -385,6 +356,43 @@ internal static class Service where T : IServiceType } } + private static Task ResolveInjectedParameters( + IReadOnlyList argDefs, + IReadOnlyCollection additionalProvidedTypedObjects) + { + var argTasks = new Task[argDefs.Count]; + for (var i = 0; i < argDefs.Count; i++) + { + var argType = argDefs[i].ParameterType; + ref var argTask = ref argTasks[i]; + + if (argType.GetCustomAttribute() is not null) + { + argTask = Task.FromResult(additionalProvidedTypedObjects.Single(x => x.GetType() == argType)); + continue; + } + + argTask = (Task)typeof(Service<>) + .MakeGenericType(argType) + .InvokeMember( + nameof(GetAsyncAsObject), + BindingFlags.InvokeMethod | + BindingFlags.Static | + BindingFlags.NonPublic, + null, + null, + null)!; + } + + return Task.WhenAll(argTasks); + } + + /// + /// Pull the instance out of the service locator, waiting if necessary. + /// + /// The object. + private static Task GetAsyncAsObject() => instanceTcs.Task.ContinueWith(r => (object)r.Result); + /// /// Exception thrown when service is attempted to be retrieved when it's unloaded. /// @@ -407,11 +415,12 @@ internal static class ServiceHelpers { /// /// Get a list of dependencies for a service. Only accepts types. - /// These are returned as types. + /// These are NOT returned as types; raw types will be returned. /// /// The dependencies for this service. + /// Whether to include the unload dependencies. /// A list of dependencies. - public static List GetDependencies(Type serviceType) + public static IReadOnlyCollection GetDependencies(Type serviceType, bool includeUnloadDependencies) { #if DEBUG if (!serviceType.IsGenericType || serviceType.GetGenericTypeDefinition() != typeof(Service<>)) @@ -422,12 +431,12 @@ internal static class ServiceHelpers } #endif - return (List)serviceType.InvokeMember( + return (IReadOnlyCollection)serviceType.InvokeMember( nameof(Service.GetDependencyServices), BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.Public, null, null, - null) ?? new List(); + new object?[] { includeUnloadDependencies }) ?? new List(); } /// diff --git a/Dalamud/Storage/Assets/DalamudAssetManager.cs b/Dalamud/Storage/Assets/DalamudAssetManager.cs index 30441f479..70a91c4bf 100644 --- a/Dalamud/Storage/Assets/DalamudAssetManager.cs +++ b/Dalamud/Storage/Assets/DalamudAssetManager.cs @@ -44,7 +44,10 @@ internal sealed class DalamudAssetManager : IServiceType, IDisposable, IDalamudA private bool isDisposed; [ServiceManager.ServiceConstructor] - private DalamudAssetManager(Dalamud dalamud, HappyHttpClient httpClient) + private DalamudAssetManager( + Dalamud dalamud, + HappyHttpClient httpClient, + ServiceManager.RegisterStartupBlockerDelegate registerStartupBlocker) { this.dalamud = dalamud; this.httpClient = httpClient; @@ -55,8 +58,17 @@ internal sealed class DalamudAssetManager : IServiceType, IDisposable, IDalamudA this.fileStreams = Enum.GetValues().ToDictionary(x => x, _ => (Task?)null); this.textureWraps = Enum.GetValues().ToDictionary(x => x, _ => (Task?)null); + // Block until all the required assets to be ready. var loadTimings = Timings.Start("DAM LoadAll"); - this.WaitForAllRequiredAssets().ContinueWith(_ => loadTimings.Dispose()); + registerStartupBlocker( + Task.WhenAll( + Enum.GetValues() + .Where(x => x is not DalamudAsset.Empty4X4) + .Where(x => x.GetAttribute()?.Required is true) + .Select(this.CreateStreamAsync) + .Select(x => x.ToContentDisposedTask())) + .ContinueWith(_ => loadTimings.Dispose()), + "Prevent Dalamud from loading more stuff, until we've ensured that all required assets are available."); } /// @@ -83,25 +95,6 @@ internal sealed class DalamudAssetManager : IServiceType, IDisposable, IDalamudA this.scopedFinalizer.Dispose(); } - /// - /// Waits for all the required assets to be ready. Will result in a faulted task, if any of the required assets - /// has failed to load. - /// - /// The task. - [Pure] - public Task WaitForAllRequiredAssets() - { - lock (this.syncRoot) - { - return Task.WhenAll( - Enum.GetValues() - .Where(x => x is not DalamudAsset.Empty4X4) - .Where(x => x.GetAttribute()?.Required is true) - .Select(this.CreateStreamAsync) - .Select(x => x.ToContentDisposedTask())); - } - } - /// [Pure] public bool IsStreamImmediatelyAvailable(DalamudAsset asset) => diff --git a/Dalamud/Utility/ArrayExtensions.cs b/Dalamud/Utility/ArrayExtensions.cs index afb1511e3..fa6e3dbe9 100644 --- a/Dalamud/Utility/ArrayExtensions.cs +++ b/Dalamud/Utility/ArrayExtensions.cs @@ -87,4 +87,14 @@ internal static class ArrayExtensions result = default; return false; } + + /// + /// Interprets the given array as an , so that you can enumerate it multiple + /// times, and know the number of elements within. + /// + /// The enumerable. + /// The element type. + /// casted as a if it is one; otherwise the result of . + public static IReadOnlyCollection AsReadOnlyCollection(this IEnumerable array) => + array as IReadOnlyCollection ?? array.ToArray(); } diff --git a/lib/FFXIVClientStructs b/lib/FFXIVClientStructs index cc6687524..3364dfea7 160000 --- a/lib/FFXIVClientStructs +++ b/lib/FFXIVClientStructs @@ -1 +1 @@ -Subproject commit cc668752416a8459a3c23345c51277e359803de8 +Subproject commit 3364dfea769b79e43aebaa955b6b98ec1d6eb458