Files
HSUI/Helpers/MainMenuHelper.cs
2026-03-04 01:38:48 -05:00

435 lines
19 KiB
C#

using AtkValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Lumina.Excel;
using Lumina.Excel.Sheets;
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace HSUI.Helpers
{
public readonly struct MainMenuEntry
{
public uint CommandId { get; }
public uint IconId { get; }
public string Name { get; }
public MainMenuEntry(uint commandId, uint iconId, string name)
{
CommandId = commandId;
IconId = iconId;
Name = name ?? "";
}
}
public static class MainMenuHelper
{
/// <summary>Names of the 7 top-level Main Menu categories. Clicking these opens the context menu, not a direct action.</summary>
private static readonly string[] MainMenuCategoryNames =
{
"Character", "Duty", "Logs", "Travel", "Party", "Social", "System"
};
/// <summary>Fixed icon IDs for categories (from sheet). Used for display; command row is still resolved by name.</summary>
private static readonly IReadOnlyDictionary<string, uint> FixedCategoryIconIds = new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
{
{ "Duty", 5 },
{ "Logs", 21 },
{ "Travel", 7 },
{ "Social", 20 }
};
/// <summary>Categories that must use the category header row (smallest RowId in MainCommandCategory) so the context menu matches the game bar.</summary>
private static readonly HashSet<string> UseCategoryHeaderForMenu = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Logs",
"Travel"
};
/// <summary>Submenu item names that uniquely identify the Logs category (from default game context menu).</summary>
private static readonly string[] LogsCategoryAnchorNames = { "Hunting Log", "Sightseeing Log", "Crafting Log", "Gathering Log", "Fishing Log", "Fish Guide", "Orchestrion List", "Challenge Log" };
/// <summary>Submenu item names that uniquely identify the Travel category (from default game context menu).</summary>
private static readonly string[] TravelCategoryAnchorNames = { "Aether Currents", "Mount Speed", "Shared FATE", "Map", "Teleport", "Return" };
/// <summary>Get the 7 top-level Main Menu categories (Character, Duty, Logs, Travel, Party, Social, System) that are enabled. Clicking these opens the context menu.</summary>
public static List<MainMenuEntry> GetEnabledMainCommands()
{
var list = new List<MainMenuEntry>();
try
{
var sheet = Plugin.DataManager.GetExcelSheet<MainCommand>();
if (sheet == null) return list;
return GetEnabledMainCommandsUnsafe(list, sheet);
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI] MainMenuHelper.GetEnabledMainCommands: {ex.Message}");
}
return list;
}
/// <summary>True if the sheet row name matches the main menu category (exact or flexible).</summary>
private static bool MatchesCategoryName(string rowName, string categoryName)
{
if (string.IsNullOrEmpty(rowName)) return false;
string r = rowName.Trim();
if (string.Equals(r, categoryName, StringComparison.OrdinalIgnoreCase))
return true;
if (string.Equals(categoryName, "Duty", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("Duty", StringComparison.OrdinalIgnoreCase) >= 0;
if (string.Equals(categoryName, "Travel", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("Travel", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Teleport", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Return", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Aetheryte", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Port", StringComparison.OrdinalIgnoreCase) >= 0;
if (string.Equals(categoryName, "Party", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("Party", StringComparison.OrdinalIgnoreCase) >= 0;
if (string.Equals(categoryName, "Logs", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("Log", StringComparison.OrdinalIgnoreCase) >= 0
|| string.Equals(r, "Journal", StringComparison.OrdinalIgnoreCase)
|| r.IndexOf("Quest", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Completion", StringComparison.OrdinalIgnoreCase) >= 0;
if (string.Equals(categoryName, "Social", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("Social", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Friend", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Contact", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Linkshell", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Free Company", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Fellowship", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Group", StringComparison.OrdinalIgnoreCase) >= 0;
if (string.Equals(categoryName, "System", StringComparison.OrdinalIgnoreCase))
return r.IndexOf("System", StringComparison.OrdinalIgnoreCase) >= 0
|| r.IndexOf("Config", StringComparison.OrdinalIgnoreCase) >= 0;
return false;
}
/// <summary>Get the category header row ID (smallest RowId in the same MainCommandCategory). For Logs/Travel we identify the category by submenu item names from the default game menu.</summary>
private static unsafe uint GetCategoryHeaderCommandId(string categoryName, Lumina.Excel.ExcelSheet<MainCommand> sheet, AgentHUD* agentHud)
{
uint categoryGroupId = 0;
string[]? anchorNames = null;
if (string.Equals(categoryName, "Logs", StringComparison.OrdinalIgnoreCase))
anchorNames = LogsCategoryAnchorNames;
else if (string.Equals(categoryName, "Travel", StringComparison.OrdinalIgnoreCase))
anchorNames = TravelCategoryAnchorNames;
if (anchorNames != null)
{
// Find category by a row that matches one of the known submenu names (e.g. "Hunting Log", "Aether Currents").
foreach (var row in sheet)
{
uint id = row.RowId;
if (id == 0) continue;
if (!agentHud->IsMainCommandEnabled(id)) continue;
string name = "";
try { name = row.Name.ToString(); }
catch { }
name = name?.Trim() ?? "";
foreach (string anchor in anchorNames)
{
if (name.IndexOf(anchor, StringComparison.OrdinalIgnoreCase) >= 0)
{
try { categoryGroupId = row.MainCommandCategory.RowId; break; }
catch { }
break;
}
}
if (categoryGroupId != 0) break;
}
}
if (categoryGroupId == 0)
{
// Fallback: exact "Logs"/"Travel" or flexible category name match
bool exactOnly = string.Equals(categoryName, "Logs", StringComparison.OrdinalIgnoreCase)
|| string.Equals(categoryName, "Travel", StringComparison.OrdinalIgnoreCase);
foreach (var row in sheet)
{
uint id = row.RowId;
if (id == 0) continue;
string name = "";
try { name = row.Name.ToString(); }
catch { }
if (exactOnly && !string.Equals(name.Trim(), categoryName, StringComparison.OrdinalIgnoreCase))
continue;
if (!exactOnly && !MatchesCategoryName(name, categoryName))
continue;
if (!agentHud->IsMainCommandEnabled(id))
continue;
try { categoryGroupId = row.MainCommandCategory.RowId; break; }
catch { }
}
}
if (categoryGroupId == 0)
{
foreach (var row in sheet)
{
uint id = row.RowId;
if (id == 0) continue;
string name = "";
try { name = row.Name.ToString(); }
catch { }
if (!MatchesCategoryName(name, categoryName) || !agentHud->IsMainCommandEnabled(id))
continue;
try { categoryGroupId = row.MainCommandCategory.RowId; break; }
catch { }
}
}
if (categoryGroupId == 0) return 0;
uint headerId = 0;
foreach (var row in sheet)
{
if (row.RowId == 0) continue;
try { if (row.MainCommandCategory.RowId != categoryGroupId) continue; }
catch { continue; }
if (!agentHud->IsMainCommandEnabled(row.RowId)) continue;
if (headerId == 0 || row.RowId < headerId)
headerId = row.RowId;
}
return headerId;
}
private static unsafe List<MainMenuEntry> GetEnabledMainCommandsUnsafe(List<MainMenuEntry> list, Lumina.Excel.ExcelSheet<MainCommand> sheet)
{
var agentHud = AgentHUD.Instance();
if (agentHud == null) return list;
foreach (string categoryName in MainMenuCategoryNames)
{
// Logs and Travel: use category header row so context menu matches game bar.
if (UseCategoryHeaderForMenu.Contains(categoryName))
{
uint headerId = GetCategoryHeaderCommandId(categoryName, sheet, agentHud);
if (headerId != 0)
{
uint iconId = 0;
if (FixedCategoryIconIds.TryGetValue(categoryName, out uint fixedIconId))
iconId = fixedIconId;
if (iconId == 0)
iconId = GetFallbackIconIdForCategory(categoryName);
list.Add(new MainMenuEntry(headerId, iconId, categoryName));
continue;
}
}
foreach (var row in sheet)
{
uint id = row.RowId;
if (id == 0) continue;
string name = "";
try { name = row.Name.ToString(); }
catch { }
if (!MatchesCategoryName(name, categoryName))
continue;
if (!agentHud->IsMainCommandEnabled(id))
continue;
// Icon: use fixed icon ID for Duty/Logs/Travel/Social (from sheet); others use sheet → game → fallback.
uint iconId = 0;
if (FixedCategoryIconIds.TryGetValue(categoryName, out uint fixedIconId))
iconId = fixedIconId;
if (iconId == 0)
{
try { iconId = (uint)row.Icon; }
catch { }
}
if (iconId == 0)
iconId = GetIconIdForMainCommand(id);
if (iconId == 0)
iconId = GetFallbackIconIdForCategory(categoryName);
list.Add(new MainMenuEntry(id, iconId, categoryName));
break;
}
}
return list;
}
/// <summary>Resolve icon for a Main Command. Uses RaptureHotbarModule when sheet row.Icon is 0.</summary>
public static unsafe uint GetIconIdForMainCommand(uint commandId)
{
try
{
var module = RaptureHotbarModule.Instance();
if (module != null && module->ModuleReady)
{
var bar = module->StandardHotbars[0];
var slot = bar.GetHotbarSlot(0);
if (slot != null)
{
int gameIcon = slot->GetIconIdForSlot(RaptureHotbarModule.HotbarSlotType.MainCommand, commandId);
if (gameIcon > 0) return (uint)gameIcon;
}
}
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI] MainMenuHelper.GetIconIdForMainCommand({commandId}): {ex.Message}");
}
return 0;
}
/// <summary>Fallback icon when game and sheet both return 0. Duty = 61002 (exclamation) to match default bar.</summary>
private static uint GetFallbackIconIdForCategory(string categoryName)
{
return (categoryName?.ToLowerInvariant()) switch
{
"character" => 61001,
"duty" => 61002,
"logs" => 61003,
"travel" => 61004,
"party" => 61005,
"social" => 61006,
"system" => 61007,
_ => 0
};
}
/// <summary>Execute a main command by ID (e.g. Character, Social, Collection).</summary>
public static unsafe void ExecuteMainCommand(uint commandId)
{
try
{
var uiModule = Framework.Instance()->GetUIModule();
if (uiModule == null) return;
uiModule->ExecuteMainCommand(commandId);
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI] MainMenuHelper.ExecuteMainCommand({commandId}): {ex.Message}");
}
}
/// <summary>Open the context submenu for a category, or execute directly if no submenu. When sub-commands exist, the HUD shows a custom context menu; otherwise executes the command.</summary>
public static unsafe void OpenSubmenuOrExecute(uint categoryCommandId)
{
var subIds = GetSubCommandIds(categoryCommandId);
if (subIds != null && subIds.Count > 0)
return; // Caller should use GetSubCommandEntries and show custom menu; we don't call OpenSystemMenu (crashes)
ExecuteMainCommand(categoryCommandId);
}
/// <summary>Get sub-command entries (id + display name) for the category. Same options as the default main menu bar context menu. Returns empty if category has no sub-commands.</summary>
public static List<(uint CommandId, string Name)> GetSubCommandEntries(uint categoryCommandId)
{
var list = new List<(uint, string)>();
try
{
var subIds = GetSubCommandIds(categoryCommandId);
if (subIds == null || subIds.Count == 0) return list;
var sheet = Plugin.DataManager.GetExcelSheet<MainCommand>();
if (sheet == null) return list;
foreach (uint id in subIds)
{
string name = "";
if (sheet.TryGetRow(id, out var row))
{
try { name = row.Name.ToString(); }
catch { }
}
if (string.IsNullOrEmpty(name)) name = $"#{id}";
list.Add((id, name));
}
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI] MainMenuHelper.GetSubCommandEntries({categoryCommandId}): {ex.Message}");
}
return list;
}
/// <summary>Get MainCommand IDs that belong to the same category as the given command (for submenu).</summary>
private static unsafe List<uint> GetSubCommandIds(uint categoryCommandId)
{
var list = new List<uint>();
try
{
var sheet = Plugin.DataManager.GetExcelSheet<MainCommand>();
if (sheet == null) return list;
var agentHud = AgentHUD.Instance();
if (agentHud == null) return list;
if (!sheet.TryGetRow(categoryCommandId, out var categoryRow)) return list;
uint categoryGroupId;
try
{
categoryGroupId = categoryRow.MainCommandCategory.RowId;
}
catch { return list; }
foreach (var row in sheet)
{
if (row.RowId == 0) continue;
try
{
if (row.MainCommandCategory.RowId != categoryGroupId) continue;
}
catch { continue; }
if (!agentHud->IsMainCommandEnabled(row.RowId)) continue;
list.Add(row.RowId);
}
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI] MainMenuHelper.GetSubCommandIds({categoryCommandId}): {ex.Message}");
}
return list;
}
/// <summary>Open the game's system menu with the given MainCommand IDs (context submenu).</summary>
private static unsafe void OpenSystemMenuWithCommands(List<uint> commandIds)
{
if (commandIds == null || commandIds.Count == 0) return;
if (commandIds.Count > 17) return; // game limit
var agentHud = AgentHUD.Instance();
if (agentHud == null) return;
int count = commandIds.Count;
int allocSize = 5 + 17 + 18; // atkValueArgs layout: [4]=size, [5..21]=commands, [23..]=optional name strings
var ptr = (AtkValue*)Marshal.AllocHGlobal(allocSize * sizeof(AtkValue));
try
{
// Zero entire buffer so the game never reads garbage as string pointers (prevents crash in Utf8String.SetString)
for (int i = 0; i < allocSize; i++)
{
ptr[i].Type = AtkValueType.Null;
ptr[i].Int = 0;
}
ptr[4].ChangeType(AtkValueType.UInt);
ptr[4].UInt = (uint)count;
for (int i = 0; i < count; i++)
{
ptr[5 + i].ChangeType(AtkValueType.Int);
ptr[5 + i].Int = (int)commandIds[i];
}
agentHud->OpenSystemMenu(ptr, (uint)count);
}
finally
{
Marshal.FreeHGlobal(new IntPtr(ptr));
}
}
}
}