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