feat: basic context menu port

This commit is contained in:
goaaats 2022-01-28 20:52:07 +01:00
parent a4d8b9c45b
commit 49d1bb7ee8
No known key found for this signature in database
GPG key ID: 49E2AA8C6A76498B
20 changed files with 1581 additions and 3 deletions

View file

@ -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
{
/// <summary>
/// Provides an interface to modify context menus.
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
public class ContextMenu : IDisposable
{
private const int MaxContextMenuItemsPerContextMenu = 32;
private readonly OpenSubContextMenuDelegate? openSubContextMenu;
#region Hooks
private readonly Hook<ContextMenuOpenedDelegate> contextMenuOpenedHook;
private readonly Hook<ContextMenuOpenedDelegate> subContextMenuOpenedHook;
private readonly Hook<ContextMenuItemSelectedDelegate> contextMenuItemSelectedHook;
private readonly Hook<ContextMenuOpeningDelegate> contextMenuOpeningHook;
private readonly Hook<SubContextMenuOpeningDelegate> subContextMenuOpeningHook;
#endregion
private unsafe AgentContextInterface* currentAgentContextInterface;
private IntPtr currentSubContextMenuTitle;
private OpenSubContextMenuItem? selectedOpenSubContextMenuItem;
private ContextMenuOpenedArgs? currentContextMenuOpenedArgs;
/// <summary>
/// Initializes a new instance of the <see cref="ContextMenu"/> class.
/// </summary>
/// <param name="address">Address resolver for context menu hooks.</param>
public ContextMenu()
{
this.Address = new ContextMenuAddressResolver();
this.Address.Setup();
unsafe
{
this.openSubContextMenu = Marshal.GetDelegateForFunctionPointer<OpenSubContextMenuDelegate>(this.Address.OpenSubContextMenuPtr);
this.contextMenuOpeningHook = new Hook<ContextMenuOpeningDelegate>(this.Address.ContextMenuOpeningPtr, this.ContextMenuOpeningDetour);
this.contextMenuOpenedHook = new Hook<ContextMenuOpenedDelegate>(this.Address.ContextMenuOpenedPtr, this.ContextMenuOpenedDetour);
this.contextMenuItemSelectedHook = new Hook<ContextMenuItemSelectedDelegate>(this.Address.ContextMenuItemSelectedPtr, this.ContextMenuItemSelectedDetour);
this.subContextMenuOpeningHook = new Hook<SubContextMenuOpeningDelegate>(this.Address.SubContextMenuOpeningPtr, this.SubContextMenuOpeningDetour);
this.subContextMenuOpenedHook = new Hook<ContextMenuOpenedDelegate>(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
/// <summary>
/// Occurs when a context menu is opened by the game.
/// </summary>
public event ContextMenus.ContextMenuOpenedDelegate? ContextMenuOpened;
private ContextMenuAddressResolver Address { get; set; }
/// <summary>
/// Enable this subsystem.
/// </summary>
public void Enable()
{
this.contextMenuOpeningHook.Enable();
this.contextMenuOpenedHook.Enable();
this.contextMenuItemSelectedHook.Enable();
this.subContextMenuOpeningHook.Enable();
this.subContextMenuOpenedHook.Enable();
}
/// <inheritdoc/>
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<ContextMenuItem> 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<ContextMenuItem> 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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -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
{
/// <summary>
/// An item in a context menu.
/// </summary>
public abstract class ContextMenuItem
{
/// <summary>
/// Initializes a new instance of the <see cref="ContextMenuItem" /> class.
/// </summary>
/// <param name="name">The name of the item.</param>
public ContextMenuItem(SeString name)
{
this.Name = name;
}
/// <summary>
/// Gets or sets the name of the item.
/// </summary>
public SeString Name { get; set; }
/// <summary>
/// Gets or sets a value indicating whether or not the item is enabled. When enabled, an item is selectable.
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// Gets or sets the indicator of the item.
/// </summary>
public ContextMenuItemIndicator Indicator { get; set; } = ContextMenuItemIndicator.None;
/// <inheritdoc/>
public override string ToString()
{
return this.Name.ToString();
}
/// <inheritdoc/>
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;
}
}
}
}

View file

@ -0,0 +1,21 @@
namespace Dalamud.Game.Gui.ContextMenus
{
/// <summary>
/// An indicator displayed on a context menu item.
/// </summary>
public enum ContextMenuItemIndicator
{
/// <summary>
/// The item has no indicator.
/// </summary>
None,
/// <summary>
/// The item has a previous indicator.
/// </summary>
Previous,
/// <summary>
/// The item has a next indicator.
/// </summary>
Next
}
}

View file

@ -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
{
/// <summary>
/// Provides data for <see cref="ContextMenuOpenedDelegate"/> methods.
/// </summary>
public unsafe class ContextMenuOpenedArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ContextMenuOpenedArgs"/> class.
/// </summary>
/// <param name="addon">The addon associated with the context menu.</param>
/// <param name="agent">The agent associated with the context menu.</param>
/// <param name="parentAddonName">The the name of the parent addon associated with the context menu.</param>
/// <param name="items">The items in the context menu.</param>
public ContextMenuOpenedArgs(AddonContextMenu* addon, AgentContextInterface* agent, string? parentAddonName, IEnumerable<ContextMenuItem> items)
{
this.Addon = addon;
this.Agent = agent;
this.ParentAddonName = parentAddonName;
this.Items = new List<ContextMenuItem>(items);
}
/// <summary>
/// Gets the addon associated with the context menu.
/// </summary>
public AddonContextMenu* Addon { get; }
/// <summary>
/// Gets the agent associated with the context menu.
/// </summary>
public AgentContextInterface* Agent { get; }
/// <summary>
/// Gets the name of the parent addon associated with the context menu.
/// </summary>
public string? ParentAddonName { get; }
/// <summary>
/// Gets the title of the context menu.
/// </summary>
public string? Title { get; init; }
/// <summary>
/// Gets the items in the context menu.
/// </summary>
public List<ContextMenuItem> Items { get; }
/// <summary>
/// Gets the game object context associated with the context menu.
/// </summary>
public GameObjectContext? GameObjectContext { get; init; }
/// <summary>
/// Gets the item context associated with the context menu.
/// </summary>
public InventoryItemContext? InventoryItemContext { get; init; }
}
}

View file

@ -0,0 +1,8 @@
namespace Dalamud.Game.Gui.ContextMenus
{
/// <summary>
/// Represents the method the <see cref="ContextMenu.ContextMenuOpened"/> event.
/// </summary>
/// <param name="args">The data associated with the <see cref="ContextMenu.ContextMenuOpened"/> event.</param>
public delegate void ContextMenuOpenedDelegate(ContextMenuOpenedArgs args);
}

View file

@ -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;
/// <summary>
/// Initializes a new instance of the <see cref="ContextMenuReaderWriter"/> class.
/// </summary>
/// <param name="agentContextInterface">The AgentContextInterface to act upon.</param>
/// <param name="atkValueCount">The number of ATK values to consider.</param>
/// <param name="atkValues">Pointer to the array of ATK values.</param>
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;
}
}
/// <summary>
/// 0x14000000 | action
/// </summary>
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;
}
}
/// <summary>
/// Read the context menu from the agent.
/// </summary>
/// <returns>Read menu items.</returns>
public GameContextMenuItem[] Read()
{
var gameContextMenuItems = new List<GameContextMenuItem>();
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<ContextMenuItem> 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<AtkValue>() * 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}");
}
}
}
}

View file

@ -0,0 +1,38 @@
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
namespace Dalamud.Game.Gui.ContextMenus
{
/// <summary>
/// An item in a context menu with a user defined action.
/// </summary>
public class CustomContextMenuItem : ContextMenuItem
{
/// <summary>
/// The action that will be called when the item is selected.
/// </summary>
public CustomContextMenuItemSelectedDelegate ItemSelected { get; }
/// <summary>
/// Initializes a new instance of the <see cref="CustomContextMenuItem"/> class.
/// </summary>
/// <param name="name">The name of the item.</param>
/// <param name="itemSelected">The action that will be called when the item is selected.</param>
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;
}
}
}
}

View file

@ -0,0 +1,29 @@
namespace Dalamud.Game.Gui.ContextMenus
{
/// <summary>
/// Provides data for <see cref="CustomContextMenuItemSelectedDelegate"/> methods.
/// </summary>
public class CustomContextMenuItemSelectedArgs
{
/// <summary>
/// The currently opened context menu.
/// </summary>
public ContextMenuOpenedArgs ContextMenuOpenedArgs { get; init; }
/// <summary>
/// The selected item within the currently opened context menu.
/// </summary>
public CustomContextMenuItem SelectedItem { get; init; }
/// <summary>
/// Initializes a new instance of the <see cref="CustomContextMenuItemSelectedArgs"/> class.
/// </summary>
/// <param name="contextMenuOpenedArgs">The currently opened context menu.</param>
/// <param name="selectedItem">The selected item within the currently opened context menu.</param>
public CustomContextMenuItemSelectedArgs(ContextMenuOpenedArgs contextMenuOpenedArgs, CustomContextMenuItem selectedItem)
{
ContextMenuOpenedArgs = contextMenuOpenedArgs;
SelectedItem = selectedItem;
}
}
}

View file

@ -0,0 +1,8 @@
namespace Dalamud.Game.Gui.ContextMenus
{
/// <summary>
/// Represents the method that handles when a <see cref="CustomContextMenuItem"/> is selected.
/// </summary>
/// <param name="args">The data associated with the selected <see cref="CustomContextMenuItem"/>.</param>
public delegate void CustomContextMenuItemSelectedDelegate(CustomContextMenuItemSelectedArgs args);
}

View file

@ -0,0 +1,36 @@
using Dalamud.Game.Text.SeStringHandling;
namespace Dalamud.Game.Gui.ContextMenus
{
/// <summary>
/// An item in a context menu that with a specific game action.
/// </summary>
public class GameContextMenuItem : ContextMenuItem
{
/// <summary>
/// The game action that will be handled when the item is selected.
/// </summary>
public byte SelectedAction { get; }
/// <summary>
/// Initializes a new instance of the <see cref="GameContextMenuItem"/> class.
/// </summary>
/// <param name="name">The name of the item.</param>
/// <param name="selectedAction">The game action that will be handled when the item is selected.</param>
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;
}
}
}
}

View file

@ -0,0 +1,45 @@
using Dalamud.Game.Text.SeStringHandling;
namespace Dalamud.Game.Gui.ContextMenus
{
/// <summary>
/// Provides game object context to a context menu.
/// </summary>
public class GameObjectContext
{
/// <summary>
/// Initializes a new instance of the <see cref="GameObjectContext"/> class.
/// </summary>
/// <param name="id">The id of the game object.</param>
/// <param name="contentId">The lower content id of the game object.</param>
/// <param name="name">The name of the game object.</param>
/// <param name="worldId">The world id of the game object.</param>
public GameObjectContext(uint? id, ulong? contentId, string? name, ushort? worldId)
{
this.Id = id;
this.ContentId = contentId;
this.Name = name;
this.WorldId = worldId;
}
/// <summary>
/// Gets the id of the game object.
/// </summary>
public uint? Id { get; }
/// <summary>
/// Gets the content id of the game object.
/// </summary>
public ulong? ContentId { get; }
/// <summary>
/// Gets the name of the game object.
/// </summary>
public string? Name { get; }
/// <summary>
/// Gets the world id of the game object.
/// </summary>
public ushort? WorldId { get; }
}
}

View file

@ -0,0 +1,36 @@
namespace Dalamud.Game.Gui.ContextMenus
{
/// <summary>
/// Provides inventory item context to a context menu.
/// </summary>
public class InventoryItemContext
{
/// <summary>
/// The id of the item.
/// </summary>
public uint Id { get; }
/// <summary>
/// The count of the item in the stack.
/// </summary>
public uint Count { get; }
/// <summary>
/// Whether the item is high quality.
/// </summary>
public bool IsHighQuality { get; }
/// <summary>
/// Initializes a new instance of the <see cref="InventoryItemContext"/> class.
/// </summary>
/// <param name="id">The id of the item.</param>
/// <param name="count">The count of the item in the stack.</param>
/// <param name="isHighQuality">Whether the item is high quality.</param>
public InventoryItemContext(uint id, uint count, bool isHighQuality)
{
Id = id;
Count = count;
IsHighQuality = isHighQuality;
}
}
}

View file

@ -0,0 +1,37 @@
using Dalamud.Game.Text.SeStringHandling;
namespace Dalamud.Game.Gui.ContextMenus
{
/// <summary>
/// An item in a context menu that can open a sub context menu.
/// </summary>
public class OpenSubContextMenuItem : ContextMenuItem
{
/// <summary>
/// The action that will be called when the item is selected.
/// </summary>
public ContextMenuOpenedDelegate Opened { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="OpenSubContextMenuItem"/> class.
/// </summary>
/// <param name="name">The name of the item.</param>
/// <param name="opened">The action that will be called when the item is selected.</param>
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;
}
}
}
}

View file

@ -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<PartyFinderGui>.Set();
Service<ToastGui>.Set();
Service<FlyTextGui>.Set();
Service<ContextMenu>.Set();
this.setGlobalBgmHook = new Hook<SetGlobalBgmDelegate>(this.address.SetGlobalBgm, this.HandleSetGlobalBgmDetour);
@ -439,6 +440,7 @@ namespace Dalamud.Game.Gui
Service<ToastGui>.Get().Enable();
Service<FlyTextGui>.Get().Enable();
Service<PartyFinderGui>.Get().Enable();
Service<ContextMenu>.Get().Enable();
this.setGlobalBgmHook.Enable();
this.handleItemHoverHook.Enable();
this.handleItemOutHook.Enable();
@ -458,6 +460,7 @@ namespace Dalamud.Game.Gui
Service<ToastGui>.Get().ExplicitDispose();
Service<FlyTextGui>.Get().ExplicitDispose();
Service<PartyFinderGui>.Get().ExplicitDispose();
Service<ContextMenu>.Get().ExplicitDispose();
this.setGlobalBgmHook.Dispose();
this.handleItemHoverHook.Dispose();
this.handleItemOutHook.Dispose();

View file

@ -0,0 +1,28 @@
namespace Dalamud.Game.Text
{
/// <summary>
/// Extension methods for <see cref="SeIconChar"/>
/// </summary>
public static class SeIconCharExtensions
{
/// <summary>
/// Convert the SeIconChar to a <see cref="char"/> type.
/// </summary>
/// <param name="icon">The icon to convert.</param>
/// <returns>The converted icon.</returns>
public static char ToIconChar(this SeIconChar icon)
{
return (char)icon;
}
/// <summary>
/// Conver the SeIconChar to a <see cref="string"/> type.
/// </summary>
/// <param name="icon">The icon to convert.</param>
/// <returns>The converted icon.</returns>
public static string ToIconString(this SeIconChar icon)
{
return string.Empty + (char)icon;
}
}
}

View file

@ -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
{
/// <summary>
/// Tests for context menu.
/// </summary>
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,
}
/// <inheritdoc/>
public string Name => "Test Context Menu";
/// <inheritdoc/>
public SelfTestStepResult RunStep()
{
var contextMenu = Service<ContextMenu>.Get();
var dataMgr = Service<DataManager>.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<Item>()!.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;
}
/// <inheritdoc/>
public void CleanUp()
{
var contextMenu = Service<ContextMenu>.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;
}
}
}
}

View file

@ -30,7 +30,7 @@ namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps
}
/// <inheritdoc/>
public string Name => "Item Payloads";
public string Name => "Test Item Payloads";
/// <inheritdoc/>
public SelfTestStepResult RunStep()

View file

@ -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(),

@ -1 +1 @@
Subproject commit 4f2a2b6e331e1860a07b27b2973493373a2456ad
Subproject commit 12ae8ab775b919884775fd59096d605d4bd22066