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;
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using Dalamud.Game.Gui.ContextMenus;
using Dalamud.Game.Gui.FlyText; using Dalamud.Game.Gui.FlyText;
using Dalamud.Game.Gui.PartyFinder; using Dalamud.Game.Gui.PartyFinder;
using Dalamud.Game.Gui.Toast; using Dalamud.Game.Gui.Toast;
@ -63,6 +63,7 @@ namespace Dalamud.Game.Gui
Service<PartyFinderGui>.Set(); Service<PartyFinderGui>.Set();
Service<ToastGui>.Set(); Service<ToastGui>.Set();
Service<FlyTextGui>.Set(); Service<FlyTextGui>.Set();
Service<ContextMenu>.Set();
this.setGlobalBgmHook = new Hook<SetGlobalBgmDelegate>(this.address.SetGlobalBgm, this.HandleSetGlobalBgmDetour); this.setGlobalBgmHook = new Hook<SetGlobalBgmDelegate>(this.address.SetGlobalBgm, this.HandleSetGlobalBgmDetour);
@ -439,6 +440,7 @@ namespace Dalamud.Game.Gui
Service<ToastGui>.Get().Enable(); Service<ToastGui>.Get().Enable();
Service<FlyTextGui>.Get().Enable(); Service<FlyTextGui>.Get().Enable();
Service<PartyFinderGui>.Get().Enable(); Service<PartyFinderGui>.Get().Enable();
Service<ContextMenu>.Get().Enable();
this.setGlobalBgmHook.Enable(); this.setGlobalBgmHook.Enable();
this.handleItemHoverHook.Enable(); this.handleItemHoverHook.Enable();
this.handleItemOutHook.Enable(); this.handleItemOutHook.Enable();
@ -458,6 +460,7 @@ namespace Dalamud.Game.Gui
Service<ToastGui>.Get().ExplicitDispose(); Service<ToastGui>.Get().ExplicitDispose();
Service<FlyTextGui>.Get().ExplicitDispose(); Service<FlyTextGui>.Get().ExplicitDispose();
Service<PartyFinderGui>.Get().ExplicitDispose(); Service<PartyFinderGui>.Get().ExplicitDispose();
Service<ContextMenu>.Get().ExplicitDispose();
this.setGlobalBgmHook.Dispose(); this.setGlobalBgmHook.Dispose();
this.handleItemHoverHook.Dispose(); this.handleItemHoverHook.Dispose();
this.handleItemOutHook.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/> /// <inheritdoc/>
public string Name => "Item Payloads"; public string Name => "Test Item Payloads";
/// <inheritdoc/> /// <inheritdoc/>
public SelfTestStepResult RunStep() public SelfTestStepResult RunStep()

View file

@ -28,6 +28,7 @@ namespace Dalamud.Interface.Internal.Windows.SelfTest
new WaitFramesAgingStep(1000), new WaitFramesAgingStep(1000),
new EnterTerritoryAgingStep(148, "Central Shroud"), new EnterTerritoryAgingStep(148, "Central Shroud"),
new ItemPayloadAgingStep(), new ItemPayloadAgingStep(),
new ContextMenuAgingStep(),
new ActorTableAgingStep(), new ActorTableAgingStep(),
new FateTableAgingStep(), new FateTableAgingStep(),
new AetheryteListAgingStep(), new AetheryteListAgingStep(),

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