using System;
using System.Globalization;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Dalamud.Game;
using Dalamud.Plugin.Services;
using Lumina.Excel.Sheets;
using Action = Lumina.Excel.Sheets.Action;
namespace ConfigurableCombo;
/// Job list filter: combat (DoW/DoM) or crafting & gathering (DoH/DoL).
public enum JobListKind
{
Combat,
CraftingGathering,
}
///
/// Fetches job list and actions by category for the config.
/// Uses dynamic to tolerate different Lumina API versions.
///
internal static class JobActionsHelper
{
private const uint ActionCategoryItem = 2;
/// Max Action RowId to scan (avoids relying on foreach which may skip rows in some Lumina versions).
private const uint MaxActionRowId = 60000u;
/// FFXIV ClassJob RowIds: 8-18 are DoH/DoL (CRP..FSH). 1-7 and 19+ are combat.
private static bool IsPvP(dynamic r)
{
try
{
if (r.IsPvP == null) return false;
if (r.IsPvP is bool b) return b;
return Convert.ToInt32(r.IsPvP) != 0;
}
catch { return false; }
}
private static uint GetActionCategoryRow(dynamic r)
{
try
{
dynamic cat;
try { cat = r.ActionCategory; } catch { return 0; }
try { return (uint)Convert.ToUInt32(cat.RowId); } catch { }
try { return (uint)Convert.ToUInt32(cat.Row); } catch { }
try { dynamic v = cat.Value; if (v != null) return (uint)Convert.ToUInt32(v.RowId); } catch { }
try { dynamic v = cat.Value; if (v != null) return (uint)Convert.ToUInt32(v.Row); } catch { }
try { dynamic v = cat.ValueNullable; if (v != null) return (uint)Convert.ToUInt32(v.RowId); } catch { }
}
catch { }
return 0;
}
private static uint GetRowId(dynamic r)
{
try { return (uint)(r.RowId ?? r.Key ?? 0); } catch { return 0; }
}
/// True if the name is a real display name (not #rowId fallback or _rsv_ placeholder).
private static bool HasProperName(string name, uint rowId)
{
if (string.IsNullOrWhiteSpace(name)) return false;
if (name.Trim() == $"#{rowId}") return false;
if (name.Contains("_rsv_", StringComparison.OrdinalIgnoreCase)) return false;
return true;
}
/// True if the name uses only English-friendly characters (ASCII printable + Latin-1 supplement). Filters out CJK and other scripts.
private static bool IsLikelyEnglish(string name)
{
if (string.IsNullOrWhiteSpace(name)) return false;
foreach (char c in name)
{
if (c >= 0x20 && c <= 0x7E) continue; // ASCII printable
if (c >= 0xA0 && c <= 0xFF) continue; // Latin-1 supplement (é, ñ, ü, etc.)
return false;
}
return true;
}
private static string GetActionName(dynamic r, uint rowId)
{
try
{
// Support both reference-type Name and value-type (e.g. ReadOnlySeString)
object? nameObj = null;
try { nameObj = r.Name; } catch { }
if (nameObj != null)
{
var n = nameObj.ToString();
if (!string.IsNullOrWhiteSpace(n)) return n.Trim();
}
try { nameObj = r.Singular; } catch { }
if (nameObj != null)
{
var n = nameObj.ToString();
if (!string.IsNullOrWhiteSpace(n)) return n.Trim();
}
}
catch { }
return $"#{rowId}";
}
private static uint GetIconId(dynamic r)
{
try { return (uint)(r.Icon ?? 0); } catch { return 0; }
}
private const uint CraftingGatheringMin = 8;
private const uint CraftingGatheringMax = 18;
private static readonly Dictionary> _jobActionsCache = new();
private static List<(uint JobId, string Name)>? _combatJobListCache;
private static List<(uint JobId, string Name)>? _craftingGatheringJobListCache;
private static readonly Dictionary _jobNameByRowId = new();
private static readonly Dictionary> _pvpActionsCacheByJob = new();
private static readonly Dictionary> _roleActionsCacheByJob = new();
private static List<(uint ActionId, string Name, uint IconId)>? _consumableActionsCache;
/// Jobs for the dropdown. Combat = DoW/DoM (RowId 1-7, 19+). CraftingGathering = DoH/DoL (RowId 8-18). Includes Unspecified. Cached per kind.
public static List<(uint JobId, string Name)> GetJobList(JobListKind kind)
{
if (kind == JobListKind.Combat && _combatJobListCache != null)
return _combatJobListCache;
if (kind == JobListKind.CraftingGathering && _craftingGatheringJobListCache != null)
return _craftingGatheringJobListCache;
var result = new List<(uint JobId, string Name)> { (0, "Unspecified") };
try
{
var sheet = Service.DataManager.GetExcelSheet(ClientLanguage.English);
if (sheet != null)
{
foreach (var row in sheet)
{
try
{
dynamic r = row;
uint rowId = (uint)r.RowId;
if (rowId == 0) continue;
if (kind == JobListKind.Combat && (rowId == 43 || rowId == 44 || rowId == 45)) continue;
bool isCraftGather = rowId >= CraftingGatheringMin && rowId <= CraftingGatheringMax;
if (kind == JobListKind.Combat && isCraftGather) continue;
if (kind == JobListKind.CraftingGathering && !isCraftGather) continue;
string name = r.Name?.ToString() ?? r.NameEnglish?.ToString() ?? r.Abbreviation?.ToString() ?? $"#{rowId}";
if (string.IsNullOrWhiteSpace(name)) name = $"#{rowId}";
name = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(name.Trim().ToLowerInvariant());
if (!HasProperName(name, rowId) || !IsLikelyEnglish(name)) continue;
result.Add((rowId, name));
_jobNameByRowId[rowId] = name;
}
catch { }
}
result = result.OrderBy(x => x.Name).ToList();
result.RemoveAll(x => x.JobId == 0);
result.Insert(0, (0, "Unspecified"));
}
}
catch { }
if (kind == JobListKind.Combat)
_combatJobListCache = result;
else
_craftingGatheringJobListCache = result;
return result;
}
/// Job-related PvE actions for the given job. Cached per job.
public static List<(uint ActionId, string Name, uint IconId)> GetActionsForJob(uint jobId)
{
if (jobId == 0) return new List<(uint, string, uint)>();
lock (_jobActionsCache)
{
if (_jobActionsCache.TryGetValue(jobId, out var cached))
return cached;
}
var list = BuildJobActions(jobId);
lock (_jobActionsCache)
{
_jobActionsCache[jobId] = list;
}
return list;
}
/// PvP actions for the given job (or all PvP if jobId 0). Cached per job.
public static List<(uint ActionId, string Name, uint IconId)> GetPvPActions(uint jobId)
{
lock (_pvpActionsCacheByJob)
{
if (_pvpActionsCacheByJob.TryGetValue(jobId, out var cached))
return cached;
}
var list = BuildPvPActions(jobId);
lock (_pvpActionsCacheByJob)
{
_pvpActionsCacheByJob[jobId] = list;
}
return list;
}
/// Role actions for the given job's category (shared actions like Second Wind, Leg Sweep). Cached per job.
public static List<(uint ActionId, string Name, uint IconId)> GetRoleActions(uint jobId)
{
lock (_roleActionsCacheByJob)
{
if (_roleActionsCacheByJob.TryGetValue(jobId, out var cached))
return cached;
}
var list = BuildRoleActions(jobId);
lock (_roleActionsCacheByJob)
{
_roleActionsCacheByJob[jobId] = list;
}
return list;
}
/// Consumable items (ActionCategory Item). Cached.
public static List<(uint ActionId, string Name, uint IconId)> GetConsumableActions()
{
if (_consumableActionsCache != null)
return _consumableActionsCache;
_consumableActionsCache = BuildConsumableActions();
return _consumableActionsCache;
}
private static List<(uint ActionId, string Name, uint IconId)> BuildPvPActions(uint jobId)
{
var result = new List<(uint, string, uint)>();
try
{
var sheet = Service.DataManager.GetExcelSheet(ClientLanguage.English);
if (sheet == null)
{
Service.PluginLog.Warning("[ConfigurableCombo] Action sheet is null (PvP).");
return result;
}
byte jobByte = jobId == 0 ? (byte)0 : (byte)Math.Clamp(jobId, 0, 255);
foreach (var row in sheet)
{
try
{
dynamic r = row;
if (!IsPvP(r)) continue;
uint rowId = GetRowId(r);
if (rowId == 0) continue;
if (jobId != 0 && !IsActionForJob(r, jobId, jobByte))
continue;
var name = GetActionName(r, rowId);
if (!HasProperName(name, rowId) || !IsLikelyEnglish(name)) continue;
result.Add((rowId, name, GetIconId(r)));
}
catch { }
}
result = result.OrderBy(x => x.Item2).ToList();
if (result.Count == 0)
Service.PluginLog.Debug("[ConfigurableCombo] BuildPvPActions: 0 actions for jobId={JobId}.", jobId);
}
catch (Exception ex) { Service.PluginLog.Warning(ex, "[ConfigurableCombo] BuildPvPActions failed."); }
return result;
}
/// Known PvE role action RowIds (from game data / WrathCombo). Displayed regardless of selected job.
private static readonly uint[] KnownRoleActionIds =
{
197, 198, 200, 201, 203, 204, 206, 207,
7531, 7533, 7535, 7537, 7538, 7540,
7541, 7542, 7546, 7548, 7549,
7551, 7553, 7554, 7557,
7559, 7560, 7561, 7562, 7568, 7571,
7863,
16560, 25880,
4238, 4239,
};
/// Role actions: built from known role action IDs so they always display (Tank, Healer, Melee, PhysRanged, Caster, Magic).
private static List<(uint ActionId, string Name, uint IconId)> BuildRoleActions(uint jobId)
{
var result = new List<(uint, string, uint)>();
try
{
var sheet = Service.DataManager.GetExcelSheet(ClientLanguage.English);
if (sheet == null)
{
Service.PluginLog.Warning("[ConfigurableCombo] Action sheet is null (Role Actions).");
return result;
}
foreach (uint id in KnownRoleActionIds)
{
try
{
if (!sheet.TryGetRow(id, out var row)) continue;
dynamic r = row;
if (IsPvP(r)) continue;
var name = GetActionName(r, id);
if (!HasProperName(name, id) || !IsLikelyEnglish(name)) continue;
result.Add((id, name, GetIconId(r)));
}
catch { }
}
result = result.OrderBy(x => x.Item2).ToList();
}
catch (Exception ex) { Service.PluginLog.Warning(ex, "[ConfigurableCombo] BuildRoleActions failed."); }
return result;
}
/// Get action's ClassJobCategory RowId via reflection (same pattern as GetJobCategoryRowId so Role Actions work).
private static uint GetActionClassJobCategoryRowId(object? actionRow)
{
if (actionRow == null) return 0;
var cat = GetRowRefPropertyValue(actionRow, "ClassJobCategory");
return GetRowRefRowId(cat);
}
/// Get a RowRef-like property value from a row object, trying exact and case-insensitive property names.
private static object? GetRowRefPropertyValue(object? row, string propertyName)
{
if (row == null) return null;
try
{
var type = row.GetType();
var p = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
if (p != null) return p.GetValue(row);
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase))
return prop.GetValue(row);
}
}
catch { }
return null;
}
/// Consumables: usable items from the Item sheet (ItemAction set). IDs are Item RowIds; game uses these for item hotbar slots.
private static List<(uint ActionId, string Name, uint IconId)> BuildConsumableActions()
{
var result = new List<(uint, string, uint)>();
try
{
// Build from Item sheet: items with a use action (ItemAction valid) are consumables
var itemSheet = Service.DataManager.GetExcelSheet(ClientLanguage.English);
if (itemSheet == null)
{
Service.PluginLog.Warning("[ConfigurableCombo] Item sheet is null (Consumable).");
return result;
}
foreach (var row in itemSheet)
{
try
{
dynamic r = row;
uint rowId = GetRowId(r);
if (rowId == 0) continue;
if (!ItemHasUseAction(r)) continue;
string name = GetItemName(r, rowId);
if (!HasProperName(name, rowId) || !IsLikelyEnglish(name)) continue;
result.Add((rowId, name, GetIconId(r)));
}
catch { }
}
result = result.OrderBy(x => x.Item2).ToList();
if (result.Count == 0)
Service.PluginLog.Debug("[ConfigurableCombo] BuildConsumableActions: 0 usable items found.");
}
catch (Exception ex) { Service.PluginLog.Warning(ex, "[ConfigurableCombo] BuildConsumableActions failed."); }
return result;
}
private static bool ItemHasUseAction(dynamic r)
{
try
{
dynamic ia;
try { ia = r.ItemAction; } catch { return false; }
try { if (Convert.ToUInt32(ia.RowId) != 0) return true; } catch { }
try { if (Convert.ToUInt32(ia.Row) != 0) return true; } catch { }
try { if (ia.IsValid == true) return true; } catch { }
}
catch { }
return false;
}
private static string GetItemName(dynamic r, uint rowId)
{
try
{
object? nameObj = null;
try { nameObj = r.Name; } catch { }
if (nameObj != null)
{
var n = nameObj.ToString();
if (!string.IsNullOrWhiteSpace(n)) return n.Trim();
}
try { nameObj = r.Singular; } catch { }
if (nameObj != null)
{
var n = nameObj.ToString();
if (!string.IsNullOrWhiteSpace(n)) return n.Trim();
}
}
catch { }
return $"#{rowId}";
}
/// True if the action belongs to the given job (ClassJob match, parent class match, or ClassJobCategory). Uses jobCategoryRowId as fallback when IsJobInCategory is unavailable.
private static bool IsActionForJob(dynamic r, uint jobId, byte jobByte, uint? jobCategoryRowId = null, uint? parentClassJobId = null)
{
try
{
uint actionJob = GetClassJobRowId(r);
if (actionJob == jobId) return true;
if (parentClassJobId != null && parentClassJobId.Value != 0 && actionJob == parentClassJobId.Value) return true;
dynamic? cat = null;
try { cat = r.ClassJobCategory; } catch { }
if (cat == null) return false;
uint catRowId = 0;
try { catRowId = (uint)Convert.ToUInt32(cat.RowId); } catch { }
try { if (catRowId == 0) catRowId = (uint)Convert.ToUInt32(cat.Row); } catch { }
if (catRowId == 1) return false;
dynamic? val = null;
try { val = cat.Value; } catch { }
try { if (val == null) val = cat.ValueNullable; } catch { }
if (val != null)
{
try { return (bool)val.IsJobInCategory(jobByte); } catch { }
try { return (bool)val.IsJobInCategory((byte)jobId); } catch { }
}
// Fallback: include if action's category matches the job's category (role actions)
if (jobCategoryRowId != null && jobCategoryRowId.Value != 0 && catRowId == jobCategoryRowId.Value)
return true;
}
catch { }
return false;
}
/// Gets the ClassJobCategory RowId for the given job from the ClassJob sheet (for role-action fallback).
private static uint GetJobCategoryRowId(uint jobId)
{
if (jobId == 0) return 0;
try
{
var sheet = Service.DataManager.GetExcelSheet(ClientLanguage.English);
if (sheet == null) return 0;
if (!sheet.TryGetRow(jobId, out var row)) return 0;
var cat = GetRowRefPropertyValue((object)row, "ClassJobCategory");
return GetRowRefRowId(cat);
}
catch { }
return 0;
}
/// Gets the parent class RowId for a job (e.g. NIN 30 -> ROG 29). Actions like Spinning Edge are tied to the class, not the job.
private static uint GetParentClassJobId(uint jobId)
{
if (jobId == 0) return 0;
try
{
var sheet = Service.DataManager.GetExcelSheet(ClientLanguage.English);
if (sheet == null) return 0;
if (!sheet.TryGetRow(jobId, out var row)) return 0;
object boxed = row;
var type = boxed.GetType();
var parentProp = type.GetProperty("ClassJobParent", BindingFlags.Public | BindingFlags.Instance);
if (parentProp == null) return 0;
var parent = parentProp.GetValue(boxed);
if (parent == null) return 0;
try { return (uint)Convert.ToUInt32(((dynamic)parent).RowId); } catch { }
try { return (uint)Convert.ToUInt32(((dynamic)parent).Row); } catch { }
return GetRowRefRowId(parent);
}
catch { }
return 0;
}
/// Get a uint row id from any RowRef-like object (Lumina 5 struct) via reflection when dynamic fails.
private static uint GetRowRefRowId(object? rowRef)
{
if (rowRef == null) return 0;
try
{
var t = rowRef.GetType();
foreach (var name in new[] { "RowId", "Row", "Key" })
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
if (p == null) continue;
var v = p.GetValue(rowRef);
if (v == null) continue;
if (v is uint u) return u;
if (v is int i && i >= 0) return (uint)i;
try { return Convert.ToUInt32(v); } catch { }
}
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
if (f.FieldType != typeof(uint) && f.FieldType != typeof(int)) continue;
var v = f.GetValue(rowRef);
if (v == null) continue;
try { var u = Convert.ToUInt32(v); if (u != 0) return u; } catch { }
}
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (p.PropertyType != typeof(uint) && p.PropertyType != typeof(int)) continue;
try
{
var v = p.GetValue(rowRef);
if (v == null) continue;
var u = Convert.ToUInt32(v);
if (u != 0) return u;
}
catch { }
}
}
catch { }
return 0;
}
private static uint GetClassJobRowId(dynamic r)
{
try
{
dynamic cj;
try { cj = r.ClassJob; } catch { return 0; }
try { return (uint)Convert.ToUInt32(cj.RowId); } catch { }
try { return (uint)Convert.ToUInt32(cj.Row); } catch { }
try { return (uint)Convert.ToUInt32(cj.Key); } catch { }
try { dynamic v = cj.Value; if (v != null) return (uint)Convert.ToUInt32(v.RowId); } catch { }
try { dynamic v = cj.Value; if (v != null) return (uint)Convert.ToUInt32(v.Row); } catch { }
try { dynamic v = cj.ValueNullable; if (v != null) return (uint)Convert.ToUInt32(v.RowId); } catch { }
return GetRowRefRowId((object?)cj);
}
catch { }
return 0;
}
private static List<(uint ActionId, string Name, uint IconId)> BuildJobActions(uint jobId)
{
var result = new List<(uint, string, uint)>();
try
{
var sheet = Service.DataManager.GetExcelSheet(ClientLanguage.English);
if (sheet == null)
{
Service.PluginLog.Warning("[ConfigurableCombo] Action sheet is null (Job).");
return result;
}
byte jobByte = (byte)Math.Clamp(jobId, 0, 255);
uint jobCategoryRowId = GetJobCategoryRowId(jobId);
uint parentClassJobId = GetParentClassJobId(jobId);
for (uint id = 1; id <= MaxActionRowId; id++)
{
try
{
if (!sheet.TryGetRow(id, out var row)) continue;
dynamic r = row;
if (IsPvP(r)) continue;
int classJobLevel = 0;
try { classJobLevel = (int)(r.ClassJobLevel ?? 0); } catch { }
bool inCategory = IsActionForJob(r, jobId, jobByte, jobCategoryRowId, parentClassJobId);
if (classJobLevel <= 0 && !inCategory) continue;
if (!inCategory) continue;
var name = GetActionName(r, id);
if (!HasProperName(name, id) || !IsLikelyEnglish(name)) continue;
result.Add((id, name, GetIconId(r)));
}
catch { }
}
result = result.OrderBy(x => x.Item2).ToList();
if (result.Count == 0)
Service.PluginLog.Debug("[ConfigurableCombo] BuildJobActions: 0 actions for jobId={JobId}. Select a job and ensure game data is loaded.", jobId);
}
catch (Exception ex) { Service.PluginLog.Warning(ex, "[ConfigurableCombo] BuildJobActions failed."); }
return result;
}
public static string GetJobName(uint jobId)
{
if (jobId == 0) return "Unspecified";
if (_jobNameByRowId.TryGetValue(jobId, out var name) && !string.IsNullOrEmpty(name))
return name;
GetJobList(JobListKind.Combat);
GetJobList(JobListKind.CraftingGathering);
return _jobNameByRowId.TryGetValue(jobId, out name) ? name : $"#{jobId}";
}
}