From 49d1bb7ee8d76f39a1408e637434fb77cba2b115 Mon Sep 17 00:00:00 2001 From: goaaats Date: Fri, 28 Jan 2022 20:52:07 +0100 Subject: [PATCH] feat: basic context menu port --- Dalamud/Game/Gui/ContextMenus/ContextMenu.cs | 457 +++++++++++++++ .../ContextMenuAddressResolver.cs | 37 ++ .../Game/Gui/ContextMenus/ContextMenuItem.cs | 56 ++ .../ContextMenus/ContextMenuItemIndicator.cs | 21 + .../Gui/ContextMenus/ContextMenuOpenedArgs.cs | 64 +++ .../ContextMenus/ContextMenuOpenedDelegate.cs | 8 + .../ContextMenus/ContextMenuReaderWriter.cs | 519 ++++++++++++++++++ .../Gui/ContextMenus/CustomContextMenuItem.cs | 38 ++ .../CustomContextMenuItemSelectedArgs.cs | 29 + .../CustomContextMenuItemSelectedDelegate.cs | 8 + .../Gui/ContextMenus/GameContextMenuItem.cs | 36 ++ .../Gui/ContextMenus/GameObjectContext.cs | 45 ++ .../Gui/ContextMenus/InventoryItemContext.cs | 36 ++ .../ContextMenus/OpenSubContextMenuItem.cs | 37 ++ Dalamud/Game/Gui/GameGui.cs | 5 +- Dalamud/Game/Text/SeIconCharExtensions.cs | 28 + .../AgingSteps/ContextMenuAgingStep.cs | 155 ++++++ .../AgingSteps/ItemPayloadAgingStep.cs | 2 +- .../Windows/SelfTest/SelfTestWindow.cs | 1 + lib/FFXIVClientStructs | 2 +- 20 files changed, 1581 insertions(+), 3 deletions(-) create mode 100644 Dalamud/Game/Gui/ContextMenus/ContextMenu.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/ContextMenuAddressResolver.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/ContextMenuItem.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/ContextMenuItemIndicator.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/ContextMenuOpenedArgs.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/ContextMenuOpenedDelegate.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/ContextMenuReaderWriter.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/CustomContextMenuItem.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/CustomContextMenuItemSelectedArgs.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/CustomContextMenuItemSelectedDelegate.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/GameContextMenuItem.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/GameObjectContext.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/InventoryItemContext.cs create mode 100644 Dalamud/Game/Gui/ContextMenus/OpenSubContextMenuItem.cs create mode 100644 Dalamud/Game/Text/SeIconCharExtensions.cs create mode 100644 Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs diff --git a/Dalamud/Game/Gui/ContextMenus/ContextMenu.cs b/Dalamud/Game/Gui/ContextMenus/ContextMenu.cs new file mode 100644 index 000000000..401c2cbce --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/ContextMenu.cs @@ -0,0 +1,457 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; + +using Dalamud.Hooking; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Logging; +using Dalamud.Memory; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Serilog; +using SignatureHelper = Dalamud.Utility.Signatures.SignatureHelper; +using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType; + +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// Provides an interface to modify context menus. + /// + [PluginInterface] + [InterfaceVersion("1.0")] + public class ContextMenu : IDisposable + { + private const int MaxContextMenuItemsPerContextMenu = 32; + + private readonly OpenSubContextMenuDelegate? openSubContextMenu; + + #region Hooks + + private readonly Hook contextMenuOpenedHook; + private readonly Hook subContextMenuOpenedHook; + private readonly Hook contextMenuItemSelectedHook; + private readonly Hook contextMenuOpeningHook; + private readonly Hook subContextMenuOpeningHook; + + #endregion + + private unsafe AgentContextInterface* currentAgentContextInterface; + + private IntPtr currentSubContextMenuTitle; + + private OpenSubContextMenuItem? selectedOpenSubContextMenuItem; + private ContextMenuOpenedArgs? currentContextMenuOpenedArgs; + + /// + /// Initializes a new instance of the class. + /// + /// Address resolver for context menu hooks. + public ContextMenu() + { + this.Address = new ContextMenuAddressResolver(); + this.Address.Setup(); + + unsafe + { + this.openSubContextMenu = Marshal.GetDelegateForFunctionPointer(this.Address.OpenSubContextMenuPtr); + + this.contextMenuOpeningHook = new Hook(this.Address.ContextMenuOpeningPtr, this.ContextMenuOpeningDetour); + this.contextMenuOpenedHook = new Hook(this.Address.ContextMenuOpenedPtr, this.ContextMenuOpenedDetour); + this.contextMenuItemSelectedHook = new Hook(this.Address.ContextMenuItemSelectedPtr, this.ContextMenuItemSelectedDetour); + this.subContextMenuOpeningHook = new Hook(this.Address.SubContextMenuOpeningPtr, this.SubContextMenuOpeningDetour); + this.subContextMenuOpenedHook = new Hook(this.Address.SubContextMenuOpenedPtr, this.SubContextMenuOpenedDetour); + } + } + + + #region Delegates + + private unsafe delegate bool OpenSubContextMenuDelegate(AgentContext* agentContext); + + private unsafe delegate IntPtr ContextMenuOpeningDelegate(IntPtr a1, IntPtr a2, IntPtr a3, uint a4, IntPtr a5, AgentContextInterface* agentContextInterface, IntPtr a7, ushort a8); + + private unsafe delegate bool ContextMenuOpenedDelegate(AddonContextMenu* addonContextMenu, int menuSize, AtkValue* atkValueArgs); + + private unsafe delegate bool ContextMenuItemSelectedDelegate(AddonContextMenu* addonContextMenu, int selectedIndex, byte a3); + + private unsafe delegate bool SubContextMenuOpeningDelegate(AgentContext* agentContext); + + #endregion + + /// + /// Occurs when a context menu is opened by the game. + /// + public event ContextMenus.ContextMenuOpenedDelegate? ContextMenuOpened; + + private ContextMenuAddressResolver Address { get; set; } + + /// + /// Enable this subsystem. + /// + public void Enable() + { + this.contextMenuOpeningHook.Enable(); + this.contextMenuOpenedHook.Enable(); + this.contextMenuItemSelectedHook.Enable(); + this.subContextMenuOpeningHook.Enable(); + this.subContextMenuOpenedHook.Enable(); + } + + /// + void IDisposable.Dispose() + { + this.subContextMenuOpeningHook.Disable(); + this.contextMenuItemSelectedHook.Disable(); + this.subContextMenuOpenedHook.Disable(); + this.contextMenuOpenedHook.Disable(); + this.contextMenuOpeningHook.Disable(); + } + + private static unsafe bool IsInventoryContext(AgentContextInterface* agentContextInterface) + { + return agentContextInterface == AgentInventoryContext.Instance(); + } + + private static int GetContextMenuItemsHashCode(IEnumerable contextMenuItems) + { + unchecked + { + return contextMenuItems.Aggregate(17, (current, item) => (current * 23) + item.GetHashCode()); + } + } + + private unsafe IntPtr ContextMenuOpeningDetour(IntPtr a1, IntPtr a2, IntPtr a3, uint a4, IntPtr a5, AgentContextInterface* agentContextInterface, IntPtr a7, ushort a8) + { + this.currentAgentContextInterface = agentContextInterface; + return this.contextMenuOpeningHook!.Original(a1, a2, a3, a4, a5, agentContextInterface, a7, a8); + } + + private unsafe bool ContextMenuOpenedDetour(AddonContextMenu* addonContextMenu, int atkValueCount, AtkValue* atkValues) + { + Log.Information("Poop " + Marshal.PtrToStringUTF8(new IntPtr(((AtkUnitBase*)addonContextMenu)->Name))); + + try + { + this.ContextMenuOpenedImplementation(addonContextMenu, ref atkValueCount, ref atkValues); + } + catch (Exception ex) + { + PluginLog.Error(ex, "ContextMenuOpenedDetour"); + } + + return this.contextMenuOpenedHook.Original(addonContextMenu, atkValueCount, atkValues); + } + + private unsafe void ContextMenuOpenedImplementation(AddonContextMenu* addonContextMenu, ref int atkValueCount, ref AtkValue* atkValues) + { + if (this.ContextMenuOpened == null + || this.currentAgentContextInterface == null) + { + return; + } + + var contextMenuReaderWriter = new ContextMenuReaderWriter(this.currentAgentContextInterface, atkValueCount, atkValues); + + // Check for a title. + string? title = null; + if (this.selectedOpenSubContextMenuItem != null) + { + title = this.selectedOpenSubContextMenuItem.Name.TextValue; + + // Write the custom title + var titleAtkValue = &atkValues[1]; + fixed (byte* titlePtr = this.selectedOpenSubContextMenuItem.Name.Encode().NullTerminate()) + { + titleAtkValue->SetString(titlePtr); + } + } + else if (contextMenuReaderWriter.Title != null) + { + title = contextMenuReaderWriter.Title.TextValue; + } + + // Determine which event to raise. + var contextMenuOpenedDelegate = this.ContextMenuOpened; + + // this.selectedOpenSubContextMenuItem is OpenSubContextMenuItem openSubContextMenuItem + if (this.selectedOpenSubContextMenuItem != null) + { + contextMenuOpenedDelegate = this.selectedOpenSubContextMenuItem.Opened; + } + + // Get the existing items from the game. + // TODO: For inventory sub context menus, we take only the last item -- the return item. + // This is because we're doing a hack to spawn a Second Tier sub context menu and then appropriating it. + var contextMenuItems = contextMenuReaderWriter.Read(); + if (IsInventoryContext(this.currentAgentContextInterface) && this.selectedOpenSubContextMenuItem != null) + { + contextMenuItems = contextMenuItems.TakeLast(1).ToArray(); + } + + var beforeHashCode = GetContextMenuItemsHashCode(contextMenuItems); + + // Raise the event and get the context menu changes. + this.currentContextMenuOpenedArgs = this.NotifyContextMenuOpened(addonContextMenu, this.currentAgentContextInterface, title, contextMenuOpenedDelegate, contextMenuItems); + if (this.currentContextMenuOpenedArgs == null) + { + return; + } + + var afterHashCode = GetContextMenuItemsHashCode(this.currentContextMenuOpenedArgs.Items); + + PluginLog.Warning($"{beforeHashCode}={afterHashCode}"); + + // Only write to memory if the items were actually changed. + if (beforeHashCode != afterHashCode) + { + // Write the new changes. + contextMenuReaderWriter.Write(this.currentContextMenuOpenedArgs.Items); + + // Update the addon. + atkValueCount = *(&addonContextMenu->AtkValuesCount) = (ushort)contextMenuReaderWriter.AtkValueCount; + atkValues = *(&addonContextMenu->AtkValues) = contextMenuReaderWriter.AtkValues; + } + } + + private unsafe bool SubContextMenuOpeningDetour(AgentContext* agentContext) + { + return this.SubContextMenuOpeningImplementation(agentContext) || this.subContextMenuOpeningHook.Original(agentContext); + } + + private unsafe bool SubContextMenuOpeningImplementation(AgentContext* agentContext) + { + if (this.openSubContextMenu == null || this.selectedOpenSubContextMenuItem == null) + { + return false; + } + + // The important things to make this work are: + // 1. Allocate a temporary sub context menu title. The value doesn't matter, we'll set it later. + // 2. Context menu item count must equal 1 to tell the game there is enough space for the "< Return" item. + // 3. Atk value count must equal the index of the first context menu item. + // This is enough to keep the base data, but excludes the context menu item data. + // We want to exclude context menu item data in this function because the game sometimes includes garbage items which can cause problems. + // After this function, the game adds the "< Return" item, and THEN we add our own items after that. + + this.openSubContextMenu(agentContext); + + // Allocate a new 1 byte title. This is required for the game to render the titled context menu style. + // The actual value doesn't matter at this point, we'll set it later. + MemoryHelper.GameFree(ref this.currentSubContextMenuTitle, (ulong)IntPtr.Size); + this.currentSubContextMenuTitle = MemoryHelper.GameAllocateUi(1); + *(&(&agentContext->AgentContextInterface)->SubContextMenuTitle) = (byte*)this.currentSubContextMenuTitle; + *(byte*)this.currentSubContextMenuTitle = 0; + + // Expect at least 1 context menu item. + (&agentContext->Items->AtkValues)[0].UInt = 1; + + // Expect a title. This isn't needed by the game, it's needed by ContextMenuReaderWriter which uses this to check if it's a context menu + (&agentContext->Items->AtkValues)[1].ChangeType(ValueType.String); + + (&agentContext->Items->AtkValues)[1].String = (byte*)0; + + ContextMenuReaderWriter contextMenuReaderWriter = new ContextMenuReaderWriter(&agentContext->AgentContextInterface, agentContext->Items->AtkValueCount, &agentContext->Items->AtkValues); + *(&agentContext->Items->AtkValueCount) = (ushort)contextMenuReaderWriter.FirstContextMenuItemIndex; + + return true; + } + + private unsafe bool SubContextMenuOpenedDetour(AddonContextMenu* addonContextMenu, int atkValueCount, AtkValue* atkValues) + { + try + { + this.SubContextMenuOpenedImplementation(addonContextMenu, ref atkValueCount, ref atkValues); + } + catch (Exception ex) + { + PluginLog.Error(ex, "SubContextMenuOpenedDetour"); + } + + return this.subContextMenuOpenedHook.Original(addonContextMenu, atkValueCount, atkValues); + } + + private unsafe void SubContextMenuOpenedImplementation(AddonContextMenu* addonContextMenu, ref int atkValueCount, ref AtkValue* atkValues) + { + this.ContextMenuOpenedImplementation(addonContextMenu, ref atkValueCount, ref atkValues); + } + + private unsafe ContextMenuOpenedArgs? NotifyContextMenuOpened(AddonContextMenu* addonContextMenu, AgentContextInterface* agentContextInterface, string? title, ContextMenus.ContextMenuOpenedDelegate contextMenuOpenedDelegate, IEnumerable initialContextMenuItems) + { + var parentAddonName = this.GetParentAddonName(&addonContextMenu->AtkUnitBase); + + InventoryItemContext? inventoryItemContext = null; + GameObjectContext? gameObjectContext = null; + if (IsInventoryContext(agentContextInterface)) + { + var agentInventoryContext = (AgentInventoryContext*)agentContextInterface; + inventoryItemContext = new InventoryItemContext(agentInventoryContext->InventoryItemId, agentInventoryContext->InventoryItemCount, agentInventoryContext->InventoryItemIsHighQuality); + } + else + { + var agentContext = (AgentContext*)agentContextInterface; + + uint? id = agentContext->GameObjectId; + if (id == 0) + { + id = null; + } + + ulong? contentId = agentContext->GameObjectContentId; + if (contentId == 0) + { + contentId = null; + } + + var name = MemoryHelper.ReadSeStringNullTerminated((IntPtr)agentContext->GameObjectName.StringPtr).TextValue; + if (string.IsNullOrEmpty(name)) + { + name = null; + } + + ushort? worldId = agentContext->GameObjectWorldId; + if (worldId == 0) + { + worldId = null; + } + + if (id != null + || contentId != null + || name != null + || worldId != null) + { + gameObjectContext = new GameObjectContext(id, contentId, name, worldId); + } + } + + // Temporarily remove the < Return item, for UX we should enforce that it is always last in the list. + var lastContextMenuItem = initialContextMenuItems.LastOrDefault(); + if (lastContextMenuItem is GameContextMenuItem gameContextMenuItem && gameContextMenuItem.SelectedAction == 102) + { + initialContextMenuItems = initialContextMenuItems.SkipLast(1); + } + + var contextMenuOpenedArgs = new ContextMenuOpenedArgs(addonContextMenu, agentContextInterface, parentAddonName, initialContextMenuItems) + { + Title = title, + InventoryItemContext = inventoryItemContext, + GameObjectContext = gameObjectContext, + }; + + try + { + contextMenuOpenedDelegate.Invoke(contextMenuOpenedArgs); + } + catch (Exception ex) + { + PluginLog.LogError(ex, "NotifyContextMenuOpened"); + return null; + } + + // Readd the < Return item + if (lastContextMenuItem is GameContextMenuItem gameContextMenuItem1 && gameContextMenuItem1.SelectedAction == 102) + { + contextMenuOpenedArgs.Items.Add(lastContextMenuItem); + } + + foreach (var contextMenuItem in contextMenuOpenedArgs.Items.ToArray()) + { + // TODO: Game doesn't support nested sub context menus, but we might be able to. + if (contextMenuItem is OpenSubContextMenuItem && contextMenuOpenedArgs.Title != null) + { + contextMenuOpenedArgs.Items.Remove(contextMenuItem); + PluginLog.Warning($"Context menu '{contextMenuOpenedArgs.Title}' item '{contextMenuItem}' has been removed because nested sub context menus are not supported."); + } + } + + if (contextMenuOpenedArgs.Items.Count > MaxContextMenuItemsPerContextMenu) + { + PluginLog.LogWarning($"Context menu requesting {contextMenuOpenedArgs.Items.Count} of max {MaxContextMenuItemsPerContextMenu} items. Resizing list to compensate."); + contextMenuOpenedArgs.Items.RemoveRange(MaxContextMenuItemsPerContextMenu, contextMenuOpenedArgs.Items.Count - MaxContextMenuItemsPerContextMenu); + } + + return contextMenuOpenedArgs; + } + + private unsafe bool ContextMenuItemSelectedDetour(AddonContextMenu* addonContextMenu, int selectedIndex, byte a3) + { + try + { + this.ContextMenuItemSelectedImplementation(addonContextMenu, selectedIndex); + } + catch (Exception ex) + { + PluginLog.Error(ex, "ContextMenuItemSelectedDetour"); + } + + return this.contextMenuItemSelectedHook.Original(addonContextMenu, selectedIndex, a3); + } + + private unsafe void ContextMenuItemSelectedImplementation(AddonContextMenu* addonContextMenu, int selectedIndex) + { + if (this.currentContextMenuOpenedArgs == null || selectedIndex == -1) + { + this.currentContextMenuOpenedArgs = null; + this.selectedOpenSubContextMenuItem = null; + return; + } + + // Read the selected item directly from the game + ContextMenuReaderWriter contextMenuReaderWriter = new ContextMenuReaderWriter(this.currentAgentContextInterface, addonContextMenu->AtkValuesCount, addonContextMenu->AtkValues); + var gameContextMenuItems = contextMenuReaderWriter.Read(); + var gameSelectedItem = gameContextMenuItems.ElementAtOrDefault(selectedIndex); + + // This should be impossible + if (gameSelectedItem == null) + { + this.currentContextMenuOpenedArgs = null; + this.selectedOpenSubContextMenuItem = null; + return; + } + + // Match it with the items we already know about based on its name. + // We can get into a state where we have a game item we don't recognize when another plugin has added one. + var selectedItem = this.currentContextMenuOpenedArgs.Items.FirstOrDefault(item => item.Name.Encode().SequenceEqual(gameSelectedItem.Name.Encode())); + + this.selectedOpenSubContextMenuItem = null; + if (selectedItem is CustomContextMenuItem customContextMenuItem) + { + try + { + var customContextMenuItemSelectedArgs = new CustomContextMenuItemSelectedArgs(this.currentContextMenuOpenedArgs, customContextMenuItem); + customContextMenuItem.ItemSelected(customContextMenuItemSelectedArgs); + } + catch (Exception ex) + { + PluginLog.LogError(ex, "ContextMenuItemSelectedImplementation"); + } + } + else if (selectedItem is OpenSubContextMenuItem openSubContextMenuItem) + { + this.selectedOpenSubContextMenuItem = openSubContextMenuItem; + } + + this.currentContextMenuOpenedArgs = null; + } + + private unsafe string? GetParentAddonName(AtkUnitBase* addonInterface) + { + var parentAddonId = addonInterface->ContextMenuParentID; + if (parentAddonId == 0) + { + return null; + } + + var atkStage = AtkStage.GetSingleton(); + var parentAddon = atkStage->RaptureAtkUnitManager->GetAddonById(parentAddonId); + return Marshal.PtrToStringUTF8(new IntPtr(parentAddon->Name)); + } + + private unsafe AtkUnitBase* GetAddonFromAgent(AgentInterface* agentInterface) + { + return agentInterface->AddonId == 0 ? null : AtkStage.GetSingleton()->RaptureAtkUnitManager->GetAddonById((ushort)agentInterface->AddonId); + } + } +} diff --git a/Dalamud/Game/Gui/ContextMenus/ContextMenuAddressResolver.cs b/Dalamud/Game/Gui/ContextMenus/ContextMenuAddressResolver.cs new file mode 100644 index 000000000..29f3e21b2 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/ContextMenuAddressResolver.cs @@ -0,0 +1,37 @@ +using System; + +namespace Dalamud.Game.Gui.ContextMenus +{ + public class ContextMenuAddressResolver : BaseAddressResolver + { + + private const string SigOpenSubContextMenu = "E8 ?? ?? ?? ?? 44 39 A3 ?? ?? ?? ?? 0F 86"; + private const string SigContextMenuOpening = "E8 ?? ?? ?? ?? 0F B7 C0 48 83 C4 60"; + private const string SigContextMenuOpened = "48 8B C4 57 41 56 41 57 48 81 EC"; + private const string SigContextMenuItemSelected = "48 89 5C 24 ?? 55 57 41 56 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 44 24 ?? 80 B9"; + private const string SigSubContextMenuOpening = "E8 ?? ?? ?? ?? 44 39 A3 ?? ?? ?? ?? 0F 84"; + private const string SigSubContextMenuOpened = "48 8B C4 57 41 55 41 56 48 81 EC"; + + public IntPtr OpenSubContextMenuPtr { get; private set; } + + public IntPtr ContextMenuOpeningPtr { get; private set; } + + public IntPtr ContextMenuOpenedPtr { get; private set; } + + public IntPtr ContextMenuItemSelectedPtr { get; private set; } + + public IntPtr SubContextMenuOpeningPtr { get; private set; } + + public IntPtr SubContextMenuOpenedPtr { get; private set; } + + protected override void Setup64Bit(SigScanner scanner) + { + this.OpenSubContextMenuPtr = scanner.ScanText(SigOpenSubContextMenu); + this.ContextMenuOpeningPtr = scanner.ScanText(SigContextMenuOpening); + this.ContextMenuOpenedPtr = scanner.ScanText(SigContextMenuOpened); + this.ContextMenuItemSelectedPtr = scanner.ScanText(SigContextMenuItemSelected); + this.SubContextMenuOpeningPtr = scanner.ScanText(SigSubContextMenuOpening); + this.SubContextMenuOpenedPtr = scanner.ScanText(SigSubContextMenuOpened); + } + } +} diff --git a/Dalamud/Game/Gui/ContextMenus/ContextMenuItem.cs b/Dalamud/Game/Gui/ContextMenus/ContextMenuItem.cs new file mode 100644 index 000000000..f2a24aa53 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/ContextMenuItem.cs @@ -0,0 +1,56 @@ +using System.Numerics; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Game.Text.SeStringHandling.Payloads; + +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// An item in a context menu. + /// + public abstract class ContextMenuItem + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the item. + public ContextMenuItem(SeString name) + { + this.Name = name; + } + + /// + /// Gets or sets the name of the item. + /// + public SeString Name { get; set; } + + /// + /// Gets or sets a value indicating whether or not the item is enabled. When enabled, an item is selectable. + /// + public bool IsEnabled { get; set; } = true; + + /// + /// Gets or sets the indicator of the item. + /// + public ContextMenuItemIndicator Indicator { get; set; } = ContextMenuItemIndicator.None; + + /// + public override string ToString() + { + return this.Name.ToString(); + } + + /// + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 23) + new BigInteger(this.Name.Encode()).GetHashCode(); + hash = (hash * 23) + this.IsEnabled.GetHashCode(); + hash = (hash * 23) + ((int)this.Indicator).GetHashCode(); + return hash; + } + } + } +} diff --git a/Dalamud/Game/Gui/ContextMenus/ContextMenuItemIndicator.cs b/Dalamud/Game/Gui/ContextMenus/ContextMenuItemIndicator.cs new file mode 100644 index 000000000..b48c96cb7 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/ContextMenuItemIndicator.cs @@ -0,0 +1,21 @@ +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// An indicator displayed on a context menu item. + /// + public enum ContextMenuItemIndicator + { + /// + /// The item has no indicator. + /// + None, + /// + /// The item has a previous indicator. + /// + Previous, + /// + /// The item has a next indicator. + /// + Next + } +} diff --git a/Dalamud/Game/Gui/ContextMenus/ContextMenuOpenedArgs.cs b/Dalamud/Game/Gui/ContextMenus/ContextMenuOpenedArgs.cs new file mode 100644 index 000000000..aecc7141c --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/ContextMenuOpenedArgs.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// Provides data for methods. + /// + public unsafe class ContextMenuOpenedArgs + { + /// + /// Initializes a new instance of the class. + /// + /// The addon associated with the context menu. + /// The agent associated with the context menu. + /// The the name of the parent addon associated with the context menu. + /// The items in the context menu. + public ContextMenuOpenedArgs(AddonContextMenu* addon, AgentContextInterface* agent, string? parentAddonName, IEnumerable items) + { + this.Addon = addon; + this.Agent = agent; + this.ParentAddonName = parentAddonName; + this.Items = new List(items); + } + + /// + /// Gets the addon associated with the context menu. + /// + public AddonContextMenu* Addon { get; } + + /// + /// Gets the agent associated with the context menu. + /// + public AgentContextInterface* Agent { get; } + + /// + /// Gets the name of the parent addon associated with the context menu. + /// + public string? ParentAddonName { get; } + + /// + /// Gets the title of the context menu. + /// + public string? Title { get; init; } + + /// + /// Gets the items in the context menu. + /// + public List Items { get; } + + /// + /// Gets the game object context associated with the context menu. + /// + public GameObjectContext? GameObjectContext { get; init; } + + /// + /// Gets the item context associated with the context menu. + /// + public InventoryItemContext? InventoryItemContext { get; init; } + } +} diff --git a/Dalamud/Game/Gui/ContextMenus/ContextMenuOpenedDelegate.cs b/Dalamud/Game/Gui/ContextMenus/ContextMenuOpenedDelegate.cs new file mode 100644 index 000000000..4e3a162e2 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/ContextMenuOpenedDelegate.cs @@ -0,0 +1,8 @@ +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// Represents the method the event. + /// + /// The data associated with the event. + public delegate void ContextMenuOpenedDelegate(ContextMenuOpenedArgs args); +} diff --git a/Dalamud/Game/Gui/ContextMenus/ContextMenuReaderWriter.cs b/Dalamud/Game/Gui/ContextMenus/ContextMenuReaderWriter.cs new file mode 100644 index 000000000..3b03117c4 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/ContextMenuReaderWriter.cs @@ -0,0 +1,519 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Runtime.InteropServices; + +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Logging; +using Dalamud.Memory; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; + +using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType; + +namespace Dalamud.Game.Gui.ContextMenus +{ + internal unsafe class ContextMenuReaderWriter + { + private readonly AgentContextInterface* agentContextInterface; + + private int atkValueCount; + private AtkValue* atkValues; + + /// + /// Initializes a new instance of the class. + /// + /// The AgentContextInterface to act upon. + /// The number of ATK values to consider. + /// Pointer to the array of ATK values. + public ContextMenuReaderWriter(AgentContextInterface* agentContextInterface, int atkValueCount, AtkValue* atkValues) + { + PluginLog.Warning($"{(IntPtr)atkValues:X}"); + + this.agentContextInterface = agentContextInterface; + this.atkValueCount = atkValueCount; + this.atkValues = atkValues; + } + + private enum SubContextMenuStructLayout + { + Main, + Alternate, + } + + public int AtkValueCount => this.atkValueCount; + + public AtkValue* AtkValues => this.atkValues; + + public int ContextMenuItemCount => this.atkValues[0].Int; + + public bool HasTitle + { + get + { + bool isStringType = + (int)this.atkValues[1].Type == 8 + || (int)this.atkValues[1].Type == 38 + || this.atkValues[1].Type == FFXIVClientStructs.FFXIV.Component.GUI.ValueType.String; + + return isStringType; + } + } + + public SeString? Title + { + get + { + if (this.HasTitle && (&this.atkValues[1])->String != null) + { + MemoryHelper.ReadSeStringNullTerminated((IntPtr)(&this.atkValues[1])->String, out var str); + return str; + } + + return null; + } + } + + public int HasPreviousIndicatorFlagsIndex + { + get + { + if (this.HasTitle) + { + return 6; + } + + return 2; + } + } + + public int HasNextIndicatorFlagsIndex + { + get + { + if (this.HasTitle) + { + return 5; + } + + return 3; + } + } + + public int FirstContextMenuItemIndex + { + get + { + if (this.HasTitle) + { + return 8; + } + + return 7; + } + } + + public int NameIndexOffset + { + get + { + if (this.HasTitle && this.StructLayout == SubContextMenuStructLayout.Alternate) + { + return 1; + } + + return 0; + } + } + + public int IsDisabledIndexOffset + { + get + { + if (this.HasTitle && this.StructLayout == SubContextMenuStructLayout.Alternate) + { + return 2; + } + + return this.ContextMenuItemCount; + } + } + + /// + /// 0x14000000 | action + /// + public int? MaskedActionIndexOffset + { + get + { + if (this.HasTitle && this.StructLayout == SubContextMenuStructLayout.Alternate) return 3; + + return null; + } + } + + public int SequentialAtkValuesPerContextMenuItem + { + get + { + if (this.HasTitle && this.StructLayout == SubContextMenuStructLayout.Alternate) return 4; + + return 1; + } + } + + public int TotalDesiredAtkValuesPerContextMenuItem + { + get + { + if (this.HasTitle && this.StructLayout == SubContextMenuStructLayout.Alternate) return 4; + + return 2; + } + } + + public Vector2? Position + { + get + { + if (this.HasTitle) return new Vector2(this.atkValues[2].Int, this.atkValues[3].Int); + + return null; + } + } + + public unsafe bool IsInventoryContext + { + get + { + if ((IntPtr)this.agentContextInterface == (IntPtr)AgentInventoryContext.Instance()) + { + return true; + } + + return false; + } + } + + private SubContextMenuStructLayout? StructLayout + { + get + { + if (HasTitle) + { + if (this.atkValues[7].Int == 8) + { + return SubContextMenuStructLayout.Alternate; + } + else if (this.atkValues[7].Int == 1) + { + return SubContextMenuStructLayout.Main; + } + } + + return null; + } + } + + public byte NoopAction + { + get + { + if (IsInventoryContext) + { + return 0xff; + } + else + { + return 0x67; + } + } + } + + public byte OpenSubContextMenuAction + { + get + { + if (IsInventoryContext) + { + // This is actually the action to open the Second Tier context menu and we just hack around it + return 0x31; + } + else + { + return 0x66; + } + } + } + + public byte? FirstUnhandledAction + { + get + { + if (this.StructLayout is SubContextMenuStructLayout.Alternate) return 0x68; + + return null; + } + } + + /// + /// Read the context menu from the agent. + /// + /// Read menu items. + public GameContextMenuItem[] Read() + { + var gameContextMenuItems = new List(); + for (var contextMenuItemIndex = 0; contextMenuItemIndex < this.ContextMenuItemCount; contextMenuItemIndex++) + { + var contextMenuItemAtkValueBaseIndex = this.FirstContextMenuItemIndex + (contextMenuItemIndex * this.SequentialAtkValuesPerContextMenuItem); + + // Get the name + var nameAtkValue = &this.atkValues[contextMenuItemAtkValueBaseIndex + this.NameIndexOffset]; + if (nameAtkValue->Type == 0) + { + continue; + } + + var name = MemoryHelper.ReadSeStringNullTerminated((IntPtr)nameAtkValue->String); + + // Get the enabled state. Note that SE stores this as IsDisabled, NOT IsEnabled (those heathens) + var isEnabled = true; + var isDisabledDefined = this.FirstContextMenuItemIndex + this.ContextMenuItemCount < this.AtkValueCount; + if (isDisabledDefined) + { + var isDisabledAtkValue = &this.atkValues[contextMenuItemAtkValueBaseIndex + this.IsDisabledIndexOffset]; + isEnabled = isDisabledAtkValue->Int == 0; + } + + // Get the action + byte action; + if (this.IsInventoryContext) + { + var actions = &((AgentInventoryContext*)this.agentContextInterface)->Actions; + action = *(actions + contextMenuItemAtkValueBaseIndex); + } + else if (this.StructLayout is SubContextMenuStructLayout.Alternate) + { + var redButtonActions = &((AgentContext*)this.agentContextInterface)->Items->RedButtonActions; + action = (byte)*(redButtonActions + contextMenuItemIndex); + } + else + { + var actions = &((AgentContext*)this.agentContextInterface)->Items->Actions; + action = *(actions + contextMenuItemAtkValueBaseIndex); + } + + // Get the has previous indicator flag + var hasPreviousIndicatorFlagsAtkValue = &this.atkValues[this.HasPreviousIndicatorFlagsIndex]; + var hasPreviousIndicator = this.HasFlag(hasPreviousIndicatorFlagsAtkValue->UInt, contextMenuItemIndex); + + // Get the has next indicator flag + var hasNextIndicatorFlagsAtkValue = &this.atkValues[this.HasNextIndicatorFlagsIndex]; + var hasNextIndicator = this.HasFlag(hasNextIndicatorFlagsAtkValue->UInt, contextMenuItemIndex); + + var indicator = ContextMenuItemIndicator.None; + if (hasPreviousIndicator) + { + indicator = ContextMenuItemIndicator.Previous; + } + else if (hasNextIndicator) + { + indicator = ContextMenuItemIndicator.Next; + } + + var gameContextMenuItem = new GameContextMenuItem(name, action) + { + IsEnabled = isEnabled, + Indicator = indicator, + }; + + gameContextMenuItems.Add(gameContextMenuItem); + } + + return gameContextMenuItems.ToArray(); + } + + public void Write(IEnumerable contextMenuItems, bool allowReallocate = true) + { + if (allowReallocate) + { + var newAtkValuesCount = this.FirstContextMenuItemIndex + (contextMenuItems.Count() * this.TotalDesiredAtkValuesPerContextMenuItem); + + // Allocate the new array. We have to do a little dance with the first 8 bytes which represents the array count + const int arrayCountSize = 8; + var newAtkValuesArraySize = arrayCountSize + (Marshal.SizeOf() * newAtkValuesCount); + var newAtkValuesArray = MemoryHelper.GameAllocateUi((ulong)newAtkValuesArraySize); + if (newAtkValuesArray == IntPtr.Zero) + { + return; + } + + var newAtkValues = (AtkValue*)(newAtkValuesArray + arrayCountSize); + + // Zero the memory, then copy the atk values up to the first context menu item atk value + Marshal.Copy(new byte[newAtkValuesArraySize], 0, newAtkValuesArray, newAtkValuesArraySize); + Buffer.MemoryCopy(this.atkValues, newAtkValues, newAtkValuesArraySize - arrayCountSize, (long)sizeof(AtkValue) * FirstContextMenuItemIndex); + + // Free the old array + var oldArray = (IntPtr)this.atkValues - arrayCountSize; + var oldArrayCount = *(ulong*)oldArray; + var oldArraySize = arrayCountSize + ((ulong)sizeof(AtkValue) * oldArrayCount); + MemoryHelper.GameFree(ref oldArray, oldArraySize); + + // Set the array count + *(ulong*)newAtkValuesArray = (ulong)newAtkValuesCount; + + this.atkValueCount = newAtkValuesCount; + this.atkValues = newAtkValues; + } + + // Set the context menu item count + const int contextMenuItemCountAtkValueIndex = 0; + var contextMenuItemCountAtkValue = &this.atkValues[contextMenuItemCountAtkValueIndex]; + contextMenuItemCountAtkValue->UInt = (uint)contextMenuItems.Count(); + + // Clear the previous arrow flags + var hasPreviousIndicatorAtkValue = &this.atkValues[this.HasPreviousIndicatorFlagsIndex]; + hasPreviousIndicatorAtkValue->UInt = 0; + + // Clear the next arrow flags + var hasNextIndiactorFlagsAtkValue = &this.atkValues[this.HasNextIndicatorFlagsIndex]; + hasNextIndiactorFlagsAtkValue->UInt = 0; + + for (var contextMenuItemIndex = 0; contextMenuItemIndex < contextMenuItems.Count(); ++contextMenuItemIndex) + { + var contextMenuItem = contextMenuItems.ElementAt(contextMenuItemIndex); + + var contextMenuItemAtkValueBaseIndex = this.FirstContextMenuItemIndex + (contextMenuItemIndex * this.SequentialAtkValuesPerContextMenuItem); + + // Set the name + var nameAtkValue = &this.atkValues[contextMenuItemAtkValueBaseIndex + this.NameIndexOffset]; + nameAtkValue->ChangeType(ValueType.String); + fixed (byte* nameBytesPtr = contextMenuItem.Name.Encode().NullTerminate()) + { + nameAtkValue->SetString(nameBytesPtr); + } + + // Set the enabled state. Note that SE stores this as IsDisabled, NOT IsEnabled (those heathens) + var disabledAtkValue = &this.atkValues[contextMenuItemAtkValueBaseIndex + this.IsDisabledIndexOffset]; + disabledAtkValue->ChangeType(ValueType.Int); + disabledAtkValue->Int = contextMenuItem.IsEnabled ? 0 : 1; + + // Set the action + byte action = 0; + if (contextMenuItem is GameContextMenuItem gameContextMenuItem) + { + action = gameContextMenuItem.SelectedAction; + } + else if (contextMenuItem is CustomContextMenuItem customContextMenuItem) + { + action = this.NoopAction; + } + else if (contextMenuItem is OpenSubContextMenuItem openSubContextMenuItem) + { + action = this.OpenSubContextMenuAction; + } + + if (this.IsInventoryContext) + { + var actions = &((AgentInventoryContext*)this.agentContextInterface)->Actions; + *(actions + this.FirstContextMenuItemIndex + contextMenuItemIndex) = action; + } + else if (this.StructLayout is SubContextMenuStructLayout.Alternate && this.FirstUnhandledAction != null) + { + // Some weird placeholder goes here + var actions = &((AgentContext*)this.agentContextInterface)->Items->Actions; + *(actions + this.FirstContextMenuItemIndex + contextMenuItemIndex) = (byte)(this.FirstUnhandledAction.Value + contextMenuItemIndex); + + // Make sure there's one of these function pointers for every item. + // The function needs to be the same, so we just copy the first one into every index. + var unkFunctionPointers = &((AgentContext*)this.agentContextInterface)->Items->UnkFunctionPointers; + *(unkFunctionPointers + this.FirstContextMenuItemIndex + contextMenuItemIndex) = *(unkFunctionPointers + this.FirstContextMenuItemIndex); + + // The real action goes here + var redButtonActions = &((AgentContext*)this.agentContextInterface)->Items->RedButtonActions; + *(redButtonActions + contextMenuItemIndex) = action; + } + else + { + var actions = &((AgentContext*)this.agentContextInterface)->Items->Actions; + *(actions + this.FirstContextMenuItemIndex + contextMenuItemIndex) = action; + } + + if (contextMenuItem.Indicator == ContextMenuItemIndicator.Previous) + { + this.SetFlag(ref hasPreviousIndicatorAtkValue->UInt, contextMenuItemIndex, true); + } + else if (contextMenuItem.Indicator == ContextMenuItemIndicator.Next) + { + this.SetFlag(ref hasNextIndiactorFlagsAtkValue->UInt, contextMenuItemIndex, true); + } + } + } + + private bool HasFlag(uint mask, int itemIndex) + { + return (mask & (1 << itemIndex)) > 0; + } + + private void SetFlag(ref uint mask, int itemIndex, bool value) + { + mask &= ~(1U << itemIndex); + + if (value) + { + mask |= (uint)(1 << itemIndex); + } + } + + public void Log() + { + Log(this.atkValueCount, this.atkValues); + } + + public static void Log(int atkValueCount, AtkValue* atkValues) + { + PluginLog.Debug($"ContextMenuReader.Log"); + + for (int atkValueIndex = 0; atkValueIndex < atkValueCount; ++atkValueIndex) + { + var atkValue = &atkValues[atkValueIndex]; + + object? value; + switch (atkValue->Type) + { + case ValueType.Int: + value = atkValue->Int; + break; + case ValueType.Bool: + value = atkValue->Byte; + break; + case ValueType.UInt: + value = atkValue->UInt; + break; + case ValueType.Float: + value = atkValue->Float; + break; + default: + { + if (atkValue->Type == ValueType.String + || (int)atkValue->Type == 38 + || (int)atkValue->Type == 8) + { + value = MemoryHelper.ReadSeStringNullTerminated((IntPtr)atkValue->String); + } + else + { + value = $"{(IntPtr)atkValue->String:X}"; + } + + break; + } + } + + PluginLog.Debug($"atkValues[{atkValueIndex}]={(IntPtr)atkValue:X} {atkValue->Type}={value}"); + } + } + } +} diff --git a/Dalamud/Game/Gui/ContextMenus/CustomContextMenuItem.cs b/Dalamud/Game/Gui/ContextMenus/CustomContextMenuItem.cs new file mode 100644 index 000000000..7b9575a6a --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/CustomContextMenuItem.cs @@ -0,0 +1,38 @@ +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Game.Text.SeStringHandling.Payloads; + +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// An item in a context menu with a user defined action. + /// + public class CustomContextMenuItem : ContextMenuItem + { + /// + /// The action that will be called when the item is selected. + /// + public CustomContextMenuItemSelectedDelegate ItemSelected { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the item. + /// The action that will be called when the item is selected. + internal CustomContextMenuItem(SeString name, CustomContextMenuItemSelectedDelegate itemSelected) + : base(new SeString().Append(new UIForegroundPayload(539)).Append($"{SeIconChar.BoxedLetterD.ToIconString()} ").Append(new UIForegroundPayload(0)).Append(name)) + { + ItemSelected = itemSelected; + } + + public override int GetHashCode() + { + unchecked + { + int hash = base.GetHashCode(); + hash = hash * 23 + ItemSelected.GetHashCode(); + return hash; + } + } + } +} diff --git a/Dalamud/Game/Gui/ContextMenus/CustomContextMenuItemSelectedArgs.cs b/Dalamud/Game/Gui/ContextMenus/CustomContextMenuItemSelectedArgs.cs new file mode 100644 index 000000000..dc54c0a5e --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/CustomContextMenuItemSelectedArgs.cs @@ -0,0 +1,29 @@ +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// Provides data for methods. + /// + public class CustomContextMenuItemSelectedArgs + { + /// + /// The currently opened context menu. + /// + public ContextMenuOpenedArgs ContextMenuOpenedArgs { get; init; } + + /// + /// The selected item within the currently opened context menu. + /// + public CustomContextMenuItem SelectedItem { get; init; } + + /// + /// Initializes a new instance of the class. + /// + /// The currently opened context menu. + /// The selected item within the currently opened context menu. + public CustomContextMenuItemSelectedArgs(ContextMenuOpenedArgs contextMenuOpenedArgs, CustomContextMenuItem selectedItem) + { + ContextMenuOpenedArgs = contextMenuOpenedArgs; + SelectedItem = selectedItem; + } + } +} diff --git a/Dalamud/Game/Gui/ContextMenus/CustomContextMenuItemSelectedDelegate.cs b/Dalamud/Game/Gui/ContextMenus/CustomContextMenuItemSelectedDelegate.cs new file mode 100644 index 000000000..615c2e773 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/CustomContextMenuItemSelectedDelegate.cs @@ -0,0 +1,8 @@ +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// Represents the method that handles when a is selected. + /// + /// The data associated with the selected . + public delegate void CustomContextMenuItemSelectedDelegate(CustomContextMenuItemSelectedArgs args); +} diff --git a/Dalamud/Game/Gui/ContextMenus/GameContextMenuItem.cs b/Dalamud/Game/Gui/ContextMenus/GameContextMenuItem.cs new file mode 100644 index 000000000..bc77db5cc --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/GameContextMenuItem.cs @@ -0,0 +1,36 @@ +using Dalamud.Game.Text.SeStringHandling; + +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// An item in a context menu that with a specific game action. + /// + public class GameContextMenuItem : ContextMenuItem + { + /// + /// The game action that will be handled when the item is selected. + /// + public byte SelectedAction { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the item. + /// The game action that will be handled when the item is selected. + public GameContextMenuItem(SeString name, byte selectedAction) + : base(name) + { + SelectedAction = selectedAction; + } + + public override int GetHashCode() + { + unchecked + { + int hash = base.GetHashCode(); + hash = hash * 23 + SelectedAction; + return hash; + } + } + } +} diff --git a/Dalamud/Game/Gui/ContextMenus/GameObjectContext.cs b/Dalamud/Game/Gui/ContextMenus/GameObjectContext.cs new file mode 100644 index 000000000..29e6d39a3 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/GameObjectContext.cs @@ -0,0 +1,45 @@ +using Dalamud.Game.Text.SeStringHandling; + +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// Provides game object context to a context menu. + /// + public class GameObjectContext + { + /// + /// Initializes a new instance of the class. + /// + /// The id of the game object. + /// The lower content id of the game object. + /// The name of the game object. + /// The world id of the game object. + public GameObjectContext(uint? id, ulong? contentId, string? name, ushort? worldId) + { + this.Id = id; + this.ContentId = contentId; + this.Name = name; + this.WorldId = worldId; + } + + /// + /// Gets the id of the game object. + /// + public uint? Id { get; } + + /// + /// Gets the content id of the game object. + /// + public ulong? ContentId { get; } + + /// + /// Gets the name of the game object. + /// + public string? Name { get; } + + /// + /// Gets the world id of the game object. + /// + public ushort? WorldId { get; } + } +} diff --git a/Dalamud/Game/Gui/ContextMenus/InventoryItemContext.cs b/Dalamud/Game/Gui/ContextMenus/InventoryItemContext.cs new file mode 100644 index 000000000..b9f3fb3b6 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/InventoryItemContext.cs @@ -0,0 +1,36 @@ +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// Provides inventory item context to a context menu. + /// + public class InventoryItemContext + { + /// + /// The id of the item. + /// + public uint Id { get; } + + /// + /// The count of the item in the stack. + /// + public uint Count { get; } + + /// + /// Whether the item is high quality. + /// + public bool IsHighQuality { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The id of the item. + /// The count of the item in the stack. + /// Whether the item is high quality. + public InventoryItemContext(uint id, uint count, bool isHighQuality) + { + Id = id; + Count = count; + IsHighQuality = isHighQuality; + } + } +} diff --git a/Dalamud/Game/Gui/ContextMenus/OpenSubContextMenuItem.cs b/Dalamud/Game/Gui/ContextMenus/OpenSubContextMenuItem.cs new file mode 100644 index 000000000..859e9cf73 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenus/OpenSubContextMenuItem.cs @@ -0,0 +1,37 @@ +using Dalamud.Game.Text.SeStringHandling; + +namespace Dalamud.Game.Gui.ContextMenus +{ + /// + /// An item in a context menu that can open a sub context menu. + /// + public class OpenSubContextMenuItem : ContextMenuItem + { + /// + /// The action that will be called when the item is selected. + /// + public ContextMenuOpenedDelegate Opened { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the item. + /// The action that will be called when the item is selected. + internal OpenSubContextMenuItem(SeString name, ContextMenuOpenedDelegate opened) + : base(name) + { + Opened = opened; + Indicator = ContextMenuItemIndicator.Next; + } + + public override int GetHashCode() + { + unchecked + { + int hash = base.GetHashCode(); + hash = hash * 23 + Opened.GetHashCode(); + return hash; + } + } + } +} diff --git a/Dalamud/Game/Gui/GameGui.cs b/Dalamud/Game/Gui/GameGui.cs index 272b0e1f8..a4bd7abc1 100644 --- a/Dalamud/Game/Gui/GameGui.cs +++ b/Dalamud/Game/Gui/GameGui.cs @@ -1,7 +1,7 @@ using System; using System.Numerics; using System.Runtime.InteropServices; - +using Dalamud.Game.Gui.ContextMenus; using Dalamud.Game.Gui.FlyText; using Dalamud.Game.Gui.PartyFinder; using Dalamud.Game.Gui.Toast; @@ -63,6 +63,7 @@ namespace Dalamud.Game.Gui Service.Set(); Service.Set(); Service.Set(); + Service.Set(); this.setGlobalBgmHook = new Hook(this.address.SetGlobalBgm, this.HandleSetGlobalBgmDetour); @@ -439,6 +440,7 @@ namespace Dalamud.Game.Gui Service.Get().Enable(); Service.Get().Enable(); Service.Get().Enable(); + Service.Get().Enable(); this.setGlobalBgmHook.Enable(); this.handleItemHoverHook.Enable(); this.handleItemOutHook.Enable(); @@ -458,6 +460,7 @@ namespace Dalamud.Game.Gui Service.Get().ExplicitDispose(); Service.Get().ExplicitDispose(); Service.Get().ExplicitDispose(); + Service.Get().ExplicitDispose(); this.setGlobalBgmHook.Dispose(); this.handleItemHoverHook.Dispose(); this.handleItemOutHook.Dispose(); diff --git a/Dalamud/Game/Text/SeIconCharExtensions.cs b/Dalamud/Game/Text/SeIconCharExtensions.cs new file mode 100644 index 000000000..cee45e9d5 --- /dev/null +++ b/Dalamud/Game/Text/SeIconCharExtensions.cs @@ -0,0 +1,28 @@ +namespace Dalamud.Game.Text +{ + /// + /// Extension methods for + /// + public static class SeIconCharExtensions + { + /// + /// Convert the SeIconChar to a type. + /// + /// The icon to convert. + /// The converted icon. + public static char ToIconChar(this SeIconChar icon) + { + return (char)icon; + } + + /// + /// Conver the SeIconChar to a type. + /// + /// The icon to convert. + /// The converted icon. + public static string ToIconString(this SeIconChar icon) + { + return string.Empty + (char)icon; + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs new file mode 100644 index 000000000..9e085fbe2 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs @@ -0,0 +1,155 @@ +using System; +using System.Runtime.CompilerServices; +using Dalamud.Data; +using Dalamud.Game.Gui.ContextMenus; +using Dalamud.Utility; +using ImGuiNET; +using Lumina.Excel.GeneratedSheets; +using Lumina.Text; +using Serilog; +using SeString = Dalamud.Game.Text.SeStringHandling.SeString; + +namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps +{ + /// + /// Tests for context menu. + /// + internal class ContextMenuAgingStep : IAgingStep + { + private SubStep currentSubStep; + + private uint clickedItemId; + private bool clickedItemHq; + private uint clickedItemCount; + + private string clickedPlayerName; + private ushort? clickedPlayerWorld; + private ulong? clickedPlayerCid; + private uint? clickedPlayerId; + + private enum SubStep + { + Start, + TestItem, + TestGameObject, + } + + /// + public string Name => "Test Context Menu"; + + /// + public SelfTestStepResult RunStep() + { + var contextMenu = Service.Get(); + var dataMgr = Service.Get(); + + switch (this.currentSubStep) + { + case SubStep.Start: + contextMenu.ContextMenuOpened += this.ContextMenuOnContextMenuOpened; + this.currentSubStep++; + break; + case SubStep.TestItem: + if (this.clickedItemId != 0) + { + var item = dataMgr.GetExcelSheet()!.GetRow(this.clickedItemId); + ImGui.Text($"Did you click \"{item!.Name.RawString}\", hq:{this.clickedItemHq}, count:{this.clickedItemCount}?"); + + if (ImGui.Button("Yes")) + this.currentSubStep++; + + ImGui.SameLine(); + + if (ImGui.Button("No")) + return SelfTestStepResult.Fail; + } + else + { + ImGui.Text("Right-click an item."); + } + + break; + + case SubStep.TestGameObject: + if (!this.clickedPlayerName.IsNullOrEmpty()) + { + ImGui.Text($"Did you click \"{this.clickedPlayerName}\", world:{this.clickedPlayerWorld}, cid:{this.clickedPlayerCid}, id:{this.clickedPlayerId}?"); + + if (ImGui.Button("Yes")) + return SelfTestStepResult.Pass; + + ImGui.SameLine(); + + if (ImGui.Button("No")) + return SelfTestStepResult.Fail; + } + else + { + ImGui.Text("Right-click an item."); + } + + break; + default: + throw new ArgumentOutOfRangeException(); + } + + return SelfTestStepResult.Waiting; + } + + /// + public void CleanUp() + { + var contextMenu = Service.Get(); + contextMenu.ContextMenuOpened -= this.ContextMenuOnContextMenuOpened; + + this.currentSubStep = SubStep.Start; + this.clickedItemId = 0; + } + + private void ContextMenuOnContextMenuOpened(ContextMenuOpenedArgs args) + { + Log.Information("Got context menu with parent addon: {ParentAddonName}", args.ParentAddonName); + switch (args.ParentAddonName) + { + case "Inventory": + if (this.currentSubStep != SubStep.TestItem) + return; + + args.Items.Add(new CustomContextMenuItem("Aging Item Test", selectedArgs => + { + this.clickedItemId = args.InventoryItemContext!.Id; + this.clickedItemHq = args.InventoryItemContext!.IsHighQuality; + this.clickedItemCount = args.InventoryItemContext!.Count; + Log.Warning("Clicked item: {Id} hq:{Hq} count:{Count}", this.clickedItemId, this.clickedItemHq, this.clickedItemCount); + })); + break; + + case null: + case "_PartyList": + case "ChatLog": + case "ContactList": + case "ContentMemberList": + case "CrossWorldLinkshell": + case "FreeCompany": + case "FriendList": + case "LookingForGroup": + case "LinkShell": + case "PartyMemberList": + case "SocialList": + if (this.currentSubStep != SubStep.TestGameObject || args.GameObjectContext == null || args.GameObjectContext.Name.IsNullOrEmpty()) + return; + + args.Items.Add(new CustomContextMenuItem("Aging Character Test", selectedArgs => + { + this.clickedPlayerName = args.GameObjectContext.Name!; + this.clickedPlayerWorld = args.GameObjectContext.WorldId; + this.clickedPlayerCid = args.GameObjectContext.ContentId; + this.clickedPlayerId = args.GameObjectContext.Id; + + Log.Warning("Clicked player: {Name} world:{World} cid:{Cid} id:{Id}", this.clickedPlayerName, this.clickedPlayerWorld, this.clickedPlayerCid, this.clickedPlayerId); + })); + break; + } + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ItemPayloadAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ItemPayloadAgingStep.cs index e45e88715..3d876e6e1 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ItemPayloadAgingStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ItemPayloadAgingStep.cs @@ -30,7 +30,7 @@ namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps } /// - public string Name => "Item Payloads"; + public string Name => "Test Item Payloads"; /// public SelfTestStepResult RunStep() diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs b/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs index 09dfd920e..a26368d47 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs @@ -28,6 +28,7 @@ namespace Dalamud.Interface.Internal.Windows.SelfTest new WaitFramesAgingStep(1000), new EnterTerritoryAgingStep(148, "Central Shroud"), new ItemPayloadAgingStep(), + new ContextMenuAgingStep(), new ActorTableAgingStep(), new FateTableAgingStep(), new AetheryteListAgingStep(), diff --git a/lib/FFXIVClientStructs b/lib/FFXIVClientStructs index 4f2a2b6e3..12ae8ab77 160000 --- a/lib/FFXIVClientStructs +++ b/lib/FFXIVClientStructs @@ -1 +1 @@ -Subproject commit 4f2a2b6e331e1860a07b27b2973493373a2456ad +Subproject commit 12ae8ab775b919884775fd59096d605d4bd22066