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 { /// Names of the 7 top-level Main Menu categories. Clicking these opens the context menu, not a direct action. private static readonly string[] MainMenuCategoryNames = { "Character", "Duty", "Logs", "Travel", "Party", "Social", "System" }; /// Fixed icon IDs for categories (from sheet). Used for display; command row is still resolved by name. private static readonly IReadOnlyDictionary FixedCategoryIconIds = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Duty", 5 }, { "Logs", 21 }, { "Travel", 7 }, { "Social", 20 } }; /// Categories that must use the category header row (smallest RowId in MainCommandCategory) so the context menu matches the game bar. private static readonly HashSet UseCategoryHeaderForMenu = new HashSet(StringComparer.OrdinalIgnoreCase) { "Logs", "Travel" }; /// Submenu item names that uniquely identify the Logs category (from default game context menu). private static readonly string[] LogsCategoryAnchorNames = { "Hunting Log", "Sightseeing Log", "Crafting Log", "Gathering Log", "Fishing Log", "Fish Guide", "Orchestrion List", "Challenge Log" }; /// Submenu item names that uniquely identify the Travel category (from default game context menu). private static readonly string[] TravelCategoryAnchorNames = { "Aether Currents", "Mount Speed", "Shared FATE", "Map", "Teleport", "Return" }; /// Get the 7 top-level Main Menu categories (Character, Duty, Logs, Travel, Party, Social, System) that are enabled. Clicking these opens the context menu. public static List GetEnabledMainCommands() { var list = new List(); try { var sheet = Plugin.DataManager.GetExcelSheet(); if (sheet == null) return list; return GetEnabledMainCommandsUnsafe(list, sheet); } catch (Exception ex) { Plugin.Logger.Warning($"[HSUI] MainMenuHelper.GetEnabledMainCommands: {ex.Message}"); } return list; } /// True if the sheet row name matches the main menu category (exact or flexible). 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; } /// 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. private static unsafe uint GetCategoryHeaderCommandId(string categoryName, Lumina.Excel.ExcelSheet 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 GetEnabledMainCommandsUnsafe(List list, Lumina.Excel.ExcelSheet 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; } /// Resolve icon for a Main Command. Uses RaptureHotbarModule when sheet row.Icon is 0. 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; } /// Fallback icon when game and sheet both return 0. Duty = 61002 (exclamation) to match default bar. 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 }; } /// Execute a main command by ID (e.g. Character, Social, Collection). 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}"); } } /// 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. 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); } /// 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. 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(); 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; } /// Get MainCommand IDs that belong to the same category as the given command (for submenu). private static unsafe List GetSubCommandIds(uint categoryCommandId) { var list = new List(); try { var sheet = Plugin.DataManager.GetExcelSheet(); 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; } /// Open the game's system menu with the given MainCommand IDs (context submenu). private static unsafe void OpenSystemMenuWithCommands(List 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)); } } } }