+14
@@ -0,0 +1,14 @@
|
|||||||
|
# Build
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Dd]ebug/
|
||||||
|
[Rr]elease/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
packages/
|
||||||
|
*.nupkg
|
||||||
|
*.snupkg
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
+408
@@ -0,0 +1,408 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using Dalamud.Bindings.ImGui;
|
||||||
|
using Dalamud.Game;
|
||||||
|
using Dalamud.Interface.Textures;
|
||||||
|
using Dalamud.Interface.Utility.Raii;
|
||||||
|
using Dalamud.Interface.Windowing;
|
||||||
|
using Lumina.Excel.Sheets;
|
||||||
|
using Action = Lumina.Excel.Sheets.Action;
|
||||||
|
|
||||||
|
namespace ConfigurableCombo;
|
||||||
|
|
||||||
|
public class ConfigWindow : Window
|
||||||
|
{
|
||||||
|
private const float ActionIconSize = 24f;
|
||||||
|
|
||||||
|
private uint _selectedJobId;
|
||||||
|
private JobListKind _jobListKind = JobListKind.Combat;
|
||||||
|
private string _newComboName = "New Combo";
|
||||||
|
private UserComboDefinition? _editingCombo;
|
||||||
|
private int _editingActionIndex = -1;
|
||||||
|
private string _editingActionIdInput = "";
|
||||||
|
private UserComboDefinition? _addFromJobTargetCombo;
|
||||||
|
private string _jobActionFilter = "";
|
||||||
|
private bool _windowWasOpen;
|
||||||
|
|
||||||
|
public ConfigWindow() : base("ConfigurableCombo")
|
||||||
|
{
|
||||||
|
Size = new Vector2(500, 450);
|
||||||
|
SizeCondition = ImGuiCond.FirstUseEver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Set job dropdown to player's current job if it's in the current job-type list; otherwise keep selection if valid or Unspecified.</summary>
|
||||||
|
private void SyncJobDropdownToPlayerOrSelection()
|
||||||
|
{
|
||||||
|
var list = JobActionsHelper.GetJobList(_jobListKind);
|
||||||
|
var playerJobId = Service.ClientState.LocalPlayer?.ClassJob.RowId ?? 0u;
|
||||||
|
if (playerJobId != 0 && list.Any(x => x.JobId == playerJobId))
|
||||||
|
_selectedJobId = playerJobId;
|
||||||
|
else if (list.All(x => x.JobId != _selectedJobId))
|
||||||
|
_selectedJobId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Draw()
|
||||||
|
{
|
||||||
|
if (!Service.Configuration.EnablePlugin)
|
||||||
|
{
|
||||||
|
ImGui.TextColored(new Vector4(1, 0.4f, 0.4f, 1), "Plugin is disabled. Enable below to use combo replacement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool enablePlugin = Service.Configuration.EnablePlugin;
|
||||||
|
if (ImGui.Checkbox("Enable plugin", ref enablePlugin))
|
||||||
|
{
|
||||||
|
Service.Configuration.EnablePlugin = enablePlugin;
|
||||||
|
Service.Configuration.Save();
|
||||||
|
}
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.TextColored(new Vector4(0.6f, 0.7f, 0.8f, 1f), "Tip: Use /ccombo to open this window anytime.");
|
||||||
|
|
||||||
|
ImGui.Spacing();
|
||||||
|
ImGui.Separator();
|
||||||
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
// When window just opened, select the player's current job so their combos are shown by default
|
||||||
|
if (IsOpen && !_windowWasOpen)
|
||||||
|
{
|
||||||
|
_windowWasOpen = true;
|
||||||
|
var player = Service.ClientState.LocalPlayer;
|
||||||
|
var currentJobId = player?.ClassJob.RowId ?? 0u;
|
||||||
|
if (currentJobId != 0)
|
||||||
|
{
|
||||||
|
var combatList = JobActionsHelper.GetJobList(JobListKind.Combat);
|
||||||
|
var craftList = JobActionsHelper.GetJobList(JobListKind.CraftingGathering);
|
||||||
|
if (combatList.Any(x => x.JobId == currentJobId))
|
||||||
|
{
|
||||||
|
_jobListKind = JobListKind.Combat;
|
||||||
|
_selectedJobId = currentJobId;
|
||||||
|
}
|
||||||
|
else if (craftList.Any(x => x.JobId == currentJobId))
|
||||||
|
{
|
||||||
|
_jobListKind = JobListKind.CraftingGathering;
|
||||||
|
_selectedJobId = currentJobId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!IsOpen)
|
||||||
|
{
|
||||||
|
_windowWasOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job type toggle: Combat vs Crafting & Gathering
|
||||||
|
ImGui.Text("Job type:");
|
||||||
|
ImGui.SameLine();
|
||||||
|
bool showCombatJobs = _jobListKind == JobListKind.Combat;
|
||||||
|
if (ImGui.Checkbox("Combat (DoW/DoM)", ref showCombatJobs))
|
||||||
|
{
|
||||||
|
_jobListKind = showCombatJobs ? JobListKind.Combat : JobListKind.CraftingGathering;
|
||||||
|
SyncJobDropdownToPlayerOrSelection();
|
||||||
|
}
|
||||||
|
ImGui.SameLine();
|
||||||
|
bool showCraftGather = _jobListKind == JobListKind.CraftingGathering;
|
||||||
|
if (ImGui.Checkbox("Crafting & Gathering (DoH/DoL)", ref showCraftGather))
|
||||||
|
{
|
||||||
|
_jobListKind = showCraftGather ? JobListKind.CraftingGathering : JobListKind.Combat;
|
||||||
|
SyncJobDropdownToPlayerOrSelection();
|
||||||
|
}
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
ImGui.SetTooltip("Switch between battle jobs and crafters/gatherers. Combos are per job.");
|
||||||
|
|
||||||
|
ImGui.Text("Job:");
|
||||||
|
ImGui.SameLine();
|
||||||
|
var jobList = JobActionsHelper.GetJobList(_jobListKind);
|
||||||
|
var currentJobIndex = jobList.FindIndex(x => x.JobId == _selectedJobId);
|
||||||
|
if (currentJobIndex < 0) currentJobIndex = 0;
|
||||||
|
ImGui.SetNextItemWidth(180);
|
||||||
|
if (ImGui.BeginCombo("##job", jobList[currentJobIndex].Name))
|
||||||
|
{
|
||||||
|
for (int j = 0; j < jobList.Count; j++)
|
||||||
|
{
|
||||||
|
var (jobId, name) = jobList[j];
|
||||||
|
if (ImGui.Selectable(name, _selectedJobId == jobId))
|
||||||
|
{
|
||||||
|
_selectedJobId = jobId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui.EndCombo();
|
||||||
|
}
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
ImGui.SetTooltip("Combos below are for this job. New combos will be saved to this job.");
|
||||||
|
|
||||||
|
ImGui.Spacing();
|
||||||
|
ImGui.Text("Define action sequences. Put the first action on your hotbar; each press advances the sequence.");
|
||||||
|
ImGui.TextColored(new Vector4(0.7f, 0.8f, 1f, 1f),
|
||||||
|
"Sequence resets to step 1 after the last step or after any other action.");
|
||||||
|
ImGui.Spacing();
|
||||||
|
ImGui.TextWrapped("Example (NIN 1-2 with 3a/3b on separate keys): Add a combo with actions 2240, 2242 (Spinning Edge, Gust Slash). Put 2240 on one hotbar slot. Use that slot for 1->2; put Aeolian Edge (2255) and Armor Crush (3563) on other keybinds.");
|
||||||
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
// Add new combo (for selected job)
|
||||||
|
ImGui.SetNextItemWidth(180);
|
||||||
|
ImGui.InputTextWithHint("##name", "Combo name", ref _newComboName, 64);
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button("Add combo"))
|
||||||
|
{
|
||||||
|
Service.Configuration.UserCombos.Add(new UserComboDefinition { Name = _newComboName, JobId = _selectedJobId });
|
||||||
|
Service.Configuration.Save();
|
||||||
|
_newComboName = "New Combo";
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.Spacing();
|
||||||
|
ImGui.Separator();
|
||||||
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
// List combos for selected job only
|
||||||
|
for (int i = 0; i < Service.Configuration.UserCombos.Count; i++)
|
||||||
|
{
|
||||||
|
var combo = Service.Configuration.UserCombos[i];
|
||||||
|
if (combo.JobId != _selectedJobId) continue;
|
||||||
|
DrawCombo(combo, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawCombo(UserComboDefinition combo, int index)
|
||||||
|
{
|
||||||
|
using (var id = ImRaii.PushId(index))
|
||||||
|
{
|
||||||
|
bool enabled = combo.Enabled;
|
||||||
|
if (ImGui.Checkbox("##enabled", ref enabled))
|
||||||
|
{
|
||||||
|
combo.Enabled = enabled;
|
||||||
|
Service.Configuration.Save();
|
||||||
|
}
|
||||||
|
ImGui.SameLine();
|
||||||
|
|
||||||
|
bool open = ImGui.TreeNodeEx($"{combo.Name} ({combo.ActionIds.Count} actions)", ImGuiTreeNodeFlags.DefaultOpen);
|
||||||
|
ImGui.SameLine(ImGui.GetContentRegionAvail().X - 80);
|
||||||
|
if (ImGui.SmallButton("Delete"))
|
||||||
|
{
|
||||||
|
Service.Configuration.UserCombos.RemoveAt(index);
|
||||||
|
Service.Configuration.Save();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open)
|
||||||
|
{
|
||||||
|
ImGui.Indent();
|
||||||
|
|
||||||
|
// Name
|
||||||
|
string name = combo.Name;
|
||||||
|
if (ImGui.InputText("Name", ref name, 64))
|
||||||
|
{
|
||||||
|
combo.Name = name;
|
||||||
|
Service.Configuration.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action list
|
||||||
|
ImGui.Text("Action sequence (first = hotbar slot; order: 1, 2, 3a/3b on other keys...)");
|
||||||
|
ImGui.TextColored(new Vector4(0.9f, 0.85f, 0.4f, 1f), "First action must be the combo starter (e.g. Spinning Edge for NIN), or the icon/state won't advance.");
|
||||||
|
for (int a = 0; a < combo.ActionIds.Count; a++)
|
||||||
|
{
|
||||||
|
uint aid = combo.ActionIds[a];
|
||||||
|
string label = GetActionName(aid);
|
||||||
|
if (ImGui.SmallButton($"{label} (##{a})"))
|
||||||
|
{
|
||||||
|
_editingCombo = combo;
|
||||||
|
_editingActionIndex = a;
|
||||||
|
_editingActionIdInput = aid.ToString();
|
||||||
|
}
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.SmallButton($"X##del{a}"))
|
||||||
|
{
|
||||||
|
combo.ActionIds.RemoveAt(a);
|
||||||
|
Service.Configuration.Save();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_editingCombo == combo && _editingActionIndex >= 0)
|
||||||
|
{
|
||||||
|
ImGui.SetNextItemWidth(120);
|
||||||
|
if (ImGui.InputText("Action ID", ref _editingActionIdInput, 16, ImGuiInputTextFlags.CharsDecimal))
|
||||||
|
{ }
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button("OK"))
|
||||||
|
{
|
||||||
|
if (uint.TryParse(_editingActionIdInput, out uint newId))
|
||||||
|
{
|
||||||
|
if (_editingActionIndex < combo.ActionIds.Count)
|
||||||
|
combo.ActionIds[_editingActionIndex] = newId;
|
||||||
|
else
|
||||||
|
combo.ActionIds.Add(newId);
|
||||||
|
Service.Configuration.Save();
|
||||||
|
}
|
||||||
|
_editingCombo = null;
|
||||||
|
_editingActionIndex = -1;
|
||||||
|
}
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button("Cancel"))
|
||||||
|
{
|
||||||
|
_editingCombo = null;
|
||||||
|
_editingActionIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.Spacing();
|
||||||
|
ImGui.Text("Add action:");
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button("Action browser..."))
|
||||||
|
{
|
||||||
|
_addFromJobTargetCombo = combo;
|
||||||
|
}
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
ImGui.SetTooltip("Browse Job-Related, PvP, or Pet/Other actions. Click an action to add to sequence.");
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.Button("Add by ID"))
|
||||||
|
{
|
||||||
|
_editingCombo = combo;
|
||||||
|
_editingActionIndex = combo.ActionIds.Count;
|
||||||
|
_editingActionIdInput = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_addFromJobTargetCombo == combo)
|
||||||
|
{
|
||||||
|
DrawActionBrowser(combo);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.Unindent();
|
||||||
|
ImGui.TreePop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawActionBrowser(UserComboDefinition targetCombo)
|
||||||
|
{
|
||||||
|
ImGui.Spacing();
|
||||||
|
using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 5f))
|
||||||
|
using (ImRaii.Child("ActionBrowser", new Vector2(-1, 340), true))
|
||||||
|
{
|
||||||
|
ImGui.TextColored(new Vector4(0.7f, 0.9f, 1f, 1f), "Action browser — select a category, then click an action to add.");
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.SmallButton("Close"))
|
||||||
|
{
|
||||||
|
_addFromJobTargetCombo = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ImGui.SetNextItemWidth(220f);
|
||||||
|
ImGui.InputTextWithHint("##filter", "Filter by name or ID...", ref _jobActionFilter, 64);
|
||||||
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
var filter = _jobActionFilter.AsSpan().Trim().ToString();
|
||||||
|
bool hasFilter = !string.IsNullOrEmpty(filter);
|
||||||
|
|
||||||
|
if (ImGui.BeginTabBar("ActionBrowserTabs"))
|
||||||
|
{
|
||||||
|
if (ImGui.BeginTabItem("Job-Related"))
|
||||||
|
{
|
||||||
|
var actions = JobActionsHelper.GetActionsForJob(_selectedJobId);
|
||||||
|
DrawActionTable(actions, targetCombo, filter, hasFilter);
|
||||||
|
ImGui.EndTabItem();
|
||||||
|
}
|
||||||
|
if (ImGui.BeginTabItem("PvP Actions"))
|
||||||
|
{
|
||||||
|
var actions = JobActionsHelper.GetPvPActions(_selectedJobId);
|
||||||
|
DrawActionTable(actions, targetCombo, filter, hasFilter);
|
||||||
|
ImGui.EndTabItem();
|
||||||
|
}
|
||||||
|
if (ImGui.BeginTabItem("Role Actions"))
|
||||||
|
{
|
||||||
|
var actions = JobActionsHelper.GetRoleActions(_selectedJobId);
|
||||||
|
DrawActionTable(actions, targetCombo, filter, hasFilter);
|
||||||
|
ImGui.EndTabItem();
|
||||||
|
}
|
||||||
|
if (ImGui.BeginTabItem("Consumable Items"))
|
||||||
|
{
|
||||||
|
var actions = JobActionsHelper.GetConsumableActions();
|
||||||
|
DrawActionTable(actions, targetCombo, filter, hasFilter);
|
||||||
|
ImGui.EndTabItem();
|
||||||
|
}
|
||||||
|
ImGui.EndTabBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawActionTable(
|
||||||
|
List<(uint ActionId, string Name, uint IconId)> actions,
|
||||||
|
UserComboDefinition targetCombo,
|
||||||
|
string filter,
|
||||||
|
bool hasFilter)
|
||||||
|
{
|
||||||
|
if (actions.Count == 0)
|
||||||
|
{
|
||||||
|
ImGui.TextDisabled("No actions in this category.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ImGui.BeginTable("ActionBrowserTable", 3, ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingStretchProp, new Vector2(-1, -1)))
|
||||||
|
{
|
||||||
|
ImGui.TableSetupColumn("Icon", ImGuiTableColumnFlags.WidthFixed, ActionIconSize + 8);
|
||||||
|
ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthStretch);
|
||||||
|
ImGui.TableSetupColumn("ID", ImGuiTableColumnFlags.WidthFixed, 48);
|
||||||
|
ImGui.TableHeadersRow();
|
||||||
|
|
||||||
|
foreach (var (actionId, name, iconId) in actions)
|
||||||
|
{
|
||||||
|
if (hasFilter)
|
||||||
|
{
|
||||||
|
if (name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0 &&
|
||||||
|
actionId.ToString().IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.TableNextRow();
|
||||||
|
ImGui.TableNextColumn();
|
||||||
|
if (iconId > 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tex = Service.TextureProvider.GetFromGameIcon(new GameIconLookup(iconId));
|
||||||
|
var wrap = tex.GetWrapOrEmpty();
|
||||||
|
if (wrap != null)
|
||||||
|
ImGui.Image(wrap.Handle, new Vector2(ActionIconSize, ActionIconSize));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
ImGui.TableNextColumn();
|
||||||
|
if (ImGui.Selectable($"{name}##{actionId}", false))
|
||||||
|
{
|
||||||
|
targetCombo.ActionIds.Add(actionId);
|
||||||
|
Service.Configuration.Save();
|
||||||
|
_addFromJobTargetCombo = null;
|
||||||
|
ImGui.EndTable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ImGui.TableNextColumn();
|
||||||
|
ImGui.TextUnformatted(actionId.ToString());
|
||||||
|
}
|
||||||
|
ImGui.EndTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetActionName(uint actionId)
|
||||||
|
{
|
||||||
|
if (actionId == 0) return "0";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sheet = Service.DataManager.GetExcelSheet<Action>(ClientLanguage.English);
|
||||||
|
var row = sheet?.GetRow(actionId);
|
||||||
|
if (row != null)
|
||||||
|
{
|
||||||
|
var nameStr = (row as dynamic)?.Name?.ToString() ?? (row as dynamic)?.Singular?.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(nameStr))
|
||||||
|
return $"{nameStr} ({actionId})";
|
||||||
|
}
|
||||||
|
// Fallback: may be an item ID (e.g. from Consumable Items tab)
|
||||||
|
var itemSheet = Service.DataManager.GetExcelSheet<Lumina.Excel.Sheets.Item>(ClientLanguage.English);
|
||||||
|
var itemRow = itemSheet?.GetRow(actionId);
|
||||||
|
if (itemRow != null)
|
||||||
|
{
|
||||||
|
var itemName = (itemRow as dynamic)?.Name?.ToString() ?? (itemRow as dynamic)?.Singular?.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(itemName))
|
||||||
|
return $"{itemName} [Item] ({actionId})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return actionId.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Dalamud.NET.Sdk/14.0.1">
|
||||||
|
<PropertyGroup Label="Target">
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Platforms>x64</Platforms>
|
||||||
|
<Configurations>Debug;Release</Configurations>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Label="Build">
|
||||||
|
<OutputType>Library</OutputType>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<AppendPlatformToOutputPath>false</AppendPlatformToOutputPath>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup>
|
||||||
|
<AssemblyName>ConfigurableCombo</AssemblyName>
|
||||||
|
<Version>1.0.0.0</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup>
|
||||||
|
<DalamudLibPath>$(appdata)\XIVLauncher\addon\Hooks\dev\</DalamudLibPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
<!-- FFXIVClientStructs comes from Dalamud dev folder; do not add a second reference or build fails with CS1704 -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="ConfigurableCombo.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Author": "Custom",
|
||||||
|
"Name": "ConfigurableCombo",
|
||||||
|
"InternalName": "ConfigurableCombo",
|
||||||
|
"Description": "Define your own combo sequences in config. Put the first action on the hotbar; each press advances the sequence. Sequence resets to step 1 after the last step or after any other action (e.g. NIN 1-2 with 3a/3b on separate keybinds). /ccombo to open settings.",
|
||||||
|
"ApplicableVersion": "any",
|
||||||
|
"Tags": ["Combo", "Custom", "Hotbar"],
|
||||||
|
"Punchline": "User-defined one-button combo chains with optional split finishers."
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
using Dalamud.Game.Command;
|
||||||
|
using Dalamud.Interface.Windowing;
|
||||||
|
using Dalamud.Plugin;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
|
||||||
|
namespace ConfigurableCombo;
|
||||||
|
|
||||||
|
public sealed class ConfigurableComboPlugin : IDalamudPlugin
|
||||||
|
{
|
||||||
|
private const string Command = "/ccombo";
|
||||||
|
private readonly WindowSystem _windowSystem;
|
||||||
|
private readonly ConfigWindow _configWindow;
|
||||||
|
|
||||||
|
public ConfigurableComboPlugin(
|
||||||
|
IDalamudPluginInterface pluginInterface,
|
||||||
|
ISigScanner sigScanner,
|
||||||
|
IGameInteropProvider gameInteropProvider)
|
||||||
|
{
|
||||||
|
pluginInterface.Create<Service>();
|
||||||
|
|
||||||
|
Service.Configuration = pluginInterface.GetPluginConfig() as PluginConfiguration ?? new PluginConfiguration();
|
||||||
|
Service.Address = new PluginAddressResolver();
|
||||||
|
Service.Address.Setup(sigScanner);
|
||||||
|
Service.IconReplacer = new IconReplacer(gameInteropProvider);
|
||||||
|
|
||||||
|
_configWindow = new ConfigWindow();
|
||||||
|
_windowSystem = new WindowSystem("ConfigurableCombo");
|
||||||
|
_windowSystem.AddWindow(_configWindow);
|
||||||
|
|
||||||
|
pluginInterface.UiBuilder.OpenConfigUi += OnOpenConfigUi;
|
||||||
|
pluginInterface.UiBuilder.Draw += _windowSystem.Draw;
|
||||||
|
|
||||||
|
Service.CommandManager.AddHandler(Command, new CommandInfo(OnCommand)
|
||||||
|
{
|
||||||
|
HelpMessage = "Open ConfigurableCombo settings. Use '/ccombo debug' to toggle debug logging (for support).",
|
||||||
|
ShowInHelp = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "ConfigurableCombo";
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Service.CommandManager.RemoveHandler(Command);
|
||||||
|
Service.Interface.UiBuilder.OpenConfigUi -= OnOpenConfigUi;
|
||||||
|
Service.Interface.UiBuilder.Draw -= _windowSystem.Draw;
|
||||||
|
Service.IconReplacer?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOpenConfigUi() => _configWindow.Toggle();
|
||||||
|
|
||||||
|
private void OnCommand(string command, string args)
|
||||||
|
{
|
||||||
|
var arg = args.Trim();
|
||||||
|
if (arg.Equals("debug", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Service.Configuration.EnableDebugLogging = !Service.Configuration.EnableDebugLogging;
|
||||||
|
Service.Configuration.Save();
|
||||||
|
Service.ChatGui.Print(
|
||||||
|
$"ConfigurableCombo: Debug logging is now {(Service.Configuration.EnableDebugLogging ? "ON" : "OFF")}. Check the plugin log (Dalamud → Log) when reporting issues.",
|
||||||
|
"ConfigurableCombo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_configWindow.Toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
+667
@@ -0,0 +1,667 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Dalamud.Hooking;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
||||||
|
|
||||||
|
namespace ConfigurableCombo;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Icon/combo handling: same two hooks as WrathCombo/XIVCombo (GetAdjustedActionId + IsActionIdReplaceable),
|
||||||
|
/// plus optional Framework.Update hotbar refresh and UseAction/ExecuteSlotById for custom combos.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// WrathCombo does it with ONLY these two hooks (no Framework.Update, no ExecuteSlotById, no UseAction).
|
||||||
|
/// They throttle the GetAdjustedAction detour (e.g. 50ms) and cache the result per actionID so the game
|
||||||
|
/// gets a stable "next action" for both icon and execution. We do the same throttling; we also add
|
||||||
|
/// Framework.Update (ApparentActionId + LoadIconId) and UseAction/ExecuteSlotById so custom sequences
|
||||||
|
/// that the game never asks GetAdjustedActionId for still work.
|
||||||
|
/// </remarks>
|
||||||
|
internal unsafe sealed class IconReplacer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Hook<GetIconDelegate> _getIconHook;
|
||||||
|
private readonly Hook<IsIconReplaceableDelegate> _isIconReplaceableHook;
|
||||||
|
private readonly Hook<ExecuteSlotByIdDelegate>? _executeSlotByIdHook;
|
||||||
|
private readonly Hook<UseActionDelegate>? _useActionHook;
|
||||||
|
private readonly Hook<GetSlotAppearanceDelegate>? _getSlotAppearanceHook;
|
||||||
|
private IntPtr _actionManager;
|
||||||
|
/// <summary>Cache last returned action per trigger actionID; only reuse when lastComboMove unchanged (so next press gets correct action).</summary>
|
||||||
|
private readonly Dictionary<uint, (uint value, long ticks, uint lastComboMove)> _getIconCache = new();
|
||||||
|
/// <summary>Last time we logged debug for this actionID (throttle GetIconDetour spam).</summary>
|
||||||
|
private readonly Dictionary<uint, long> _getIconDebugLogTicks = new();
|
||||||
|
|
||||||
|
/// <summary>Combo window in ms (game updates lastComboMove after we run, so we track what we executed ourselves).</summary>
|
||||||
|
private const int ComboWindowMs = 15000;
|
||||||
|
/// <summary>Per-trigger: last action we caused to be executed, when that combo window expires, and when we set it (ms).</summary>
|
||||||
|
private readonly Dictionary<uint, (uint lastExecutedActionId, long expiryTicks, long setTicks)> _ourComboState = new();
|
||||||
|
/// <summary>Per-trigger: TickCount64 when we last set overlay to the combo's last step (so we can block flipping to step 1 for one GCD).</summary>
|
||||||
|
private readonly Dictionary<uint, long> _lastStepOverlaySetTicks = new();
|
||||||
|
|
||||||
|
/// <summary>Serializes combo replacement so rapid UseAction calls (mashing) don't all read state before any update; second caller waits and sees updated overlay.</summary>
|
||||||
|
private readonly object _useActionComboLock = new();
|
||||||
|
|
||||||
|
/// <summary>Recast group ID for the global cooldown (weaponskills/spells).</summary>
|
||||||
|
private const int GcdRecastGroupId = 57;
|
||||||
|
|
||||||
|
private delegate uint GetIconDelegate(IntPtr actionManager, uint actionID);
|
||||||
|
private delegate ulong IsIconReplaceableDelegate(uint actionID);
|
||||||
|
private delegate byte ExecuteSlotByIdDelegate(IntPtr hotbarModule, uint hotbarId, uint slotId);
|
||||||
|
private delegate bool UseActionDelegate(IntPtr actionManager, ActionType actionType, uint actionId, ulong targetId, uint extraParam, uint mode, uint comboRouteId, bool* outOptAreaTargeted);
|
||||||
|
private delegate uint GetSlotAppearanceDelegate(RaptureHotbarModule.HotbarSlotType* actionType, uint* actionId, ushort* UNK_0xC4, RaptureHotbarModule* hotbarModule, RaptureHotbarModule.HotbarSlot* slot);
|
||||||
|
|
||||||
|
public IconReplacer(IGameInteropProvider gameInteropProvider)
|
||||||
|
{
|
||||||
|
// XIVCombo: hook GetAdjustedActionId at ActionManager.Addresses.GetAdjustedActionId and
|
||||||
|
// IsActionIdReplaceable via signature scan. Same addresses here for compatibility.
|
||||||
|
var getAdjustedActionIdAddress = (IntPtr)ActionManager.Addresses.GetAdjustedActionId.Value;
|
||||||
|
_getIconHook = gameInteropProvider.HookFromAddress<GetIconDelegate>(getAdjustedActionIdAddress, GetIconDetour);
|
||||||
|
_isIconReplaceableHook = gameInteropProvider.HookFromAddress<IsIconReplaceableDelegate>(Service.Address.IsActionIdReplaceable, _ => 1);
|
||||||
|
_getIconHook.Enable();
|
||||||
|
_isIconReplaceableHook.Enable();
|
||||||
|
|
||||||
|
if (Service.Address.ExecuteSlotById != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
var execHook = gameInteropProvider.HookFromAddress<ExecuteSlotByIdDelegate>(Service.Address.ExecuteSlotById, ExecuteSlotByIdDetour);
|
||||||
|
execHook.Enable();
|
||||||
|
_executeSlotByIdHook = execHook;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_executeSlotByIdHook = null;
|
||||||
|
Service.PluginLog.Warning("ConfigurableCombo: ExecuteSlotById signature not found; custom combos may not execute.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Service.Address.UseAction != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
var useHook = gameInteropProvider.HookFromAddress<UseActionDelegate>(Service.Address.UseAction, UseActionDetour);
|
||||||
|
useHook.Enable();
|
||||||
|
_useActionHook = useHook;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_useActionHook = null;
|
||||||
|
Service.PluginLog.Warning("ConfigurableCombo: UseAction signature not found; custom combo execution fallback unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Service.Address.GetSlotAppearance != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
var slotAppearanceHook = gameInteropProvider.HookFromAddress<GetSlotAppearanceDelegate>(Service.Address.GetSlotAppearance, GetSlotAppearanceDetour);
|
||||||
|
slotAppearanceHook.Enable();
|
||||||
|
_getSlotAppearanceHook = slotAppearanceHook;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_getSlotAppearanceHook = null;
|
||||||
|
Service.PluginLog.Warning("ConfigurableCombo: GetSlotAppearance signature not found; default hotbar icon may not update.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Service.Framework.Update += OnFrameworkUpdate;
|
||||||
|
|
||||||
|
var comboCount = Service.Configuration.UserCombos?.Count(c => c.Enabled && c.ActionIds.Count > 0) ?? 0;
|
||||||
|
foreach (var c in Service.Configuration.UserCombos ?? new List<UserComboDefinition>())
|
||||||
|
{
|
||||||
|
if (c.Enabled && c.ActionIds.Count == 1)
|
||||||
|
Service.PluginLog.Warning("ConfigurableCombo: Combo \"{0}\" has only 1 action (trigger {1}). Add a second action so the combo can advance.", c.Name, c.ActionIds[0]);
|
||||||
|
}
|
||||||
|
Service.PluginLog.Information(
|
||||||
|
"ConfigurableCombo: Hooks installed. GetAdjustedActionId=on, IsActionIdReplaceable=on, ExecuteSlotById={0}, UseAction={1}, GetSlotAppearance={2}. Active combos: {3}. DebugLogging={4}.",
|
||||||
|
_executeSlotByIdHook != null ? "on" : "NOT FOUND",
|
||||||
|
_useActionHook != null ? "on" : "NOT FOUND",
|
||||||
|
_getSlotAppearanceHook != null ? "on" : "NOT FOUND",
|
||||||
|
comboCount,
|
||||||
|
Service.Configuration.EnableDebugLogging);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Service.Framework.Update -= OnFrameworkUpdate;
|
||||||
|
_getSlotAppearanceHook?.Dispose();
|
||||||
|
_useActionHook?.Dispose();
|
||||||
|
_executeSlotByIdHook?.Dispose();
|
||||||
|
_getIconHook?.Dispose();
|
||||||
|
_isIconReplaceableHook?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ignore overlay for 200ms when lastExecuted==trigger so UseAction doesn't replace with next on the same physical press. If raw game state is still (0,0), use overlay so button mashers still advance.</summary>
|
||||||
|
private const int OverlayIgnoreSameTriggerMs = 200;
|
||||||
|
/// <summary>After setting overlay to the last step (e.g. 2254), don't allow flipping back to step 1 for this long — keeps icon on last step until GCD has finished so the next cast is in the right order. One full GCD + buffer (base 2.5s, can be lower with SkS).</summary>
|
||||||
|
private const int MinMsBeforeOverlayStep1AfterLastStep = 3500;
|
||||||
|
|
||||||
|
private static uint GetCurrentJobId()
|
||||||
|
{
|
||||||
|
var player = Service.ClientState.LocalPlayer;
|
||||||
|
return player?.ClassJob.RowId ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if this combo applies to the player's current job (JobId 0 = all jobs, else must match).</summary>
|
||||||
|
private static bool ComboAppliesToCurrentJob(UserComboDefinition combo)
|
||||||
|
{
|
||||||
|
if (combo.JobId == 0) return true;
|
||||||
|
return combo.JobId == GetCurrentJobId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get effective combo state for a trigger: use our overlay if we recently executed an action for this combo, else game state.</summary>
|
||||||
|
private unsafe (uint lastComboMove, float comboTime) GetEffectiveComboState(uint triggerActionId)
|
||||||
|
{
|
||||||
|
long now = Environment.TickCount64;
|
||||||
|
lock (_ourComboState)
|
||||||
|
{
|
||||||
|
if (_ourComboState.TryGetValue(triggerActionId, out var our) && now < our.expiryTicks)
|
||||||
|
{
|
||||||
|
if (our.lastExecutedActionId == triggerActionId && (now - our.setTicks) < OverlayIgnoreSameTriggerMs)
|
||||||
|
{
|
||||||
|
var rawMove = *(uint*)Service.Address.LastComboMove;
|
||||||
|
var rawTime = *(float*)Service.Address.ComboTimer;
|
||||||
|
// Only use raw state when the game has actually updated (avoids stuck combo when mashing: raw is often still 0,0).
|
||||||
|
if (rawTime > 0 || rawMove != 0)
|
||||||
|
return (rawMove, rawTime);
|
||||||
|
}
|
||||||
|
float remaining = (our.expiryTicks - now) / 1000f;
|
||||||
|
return (our.lastExecutedActionId, remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var rawMoveFinal = *(uint*)Service.Address.LastComboMove;
|
||||||
|
var rawTimeFinal = *(float*)Service.Address.ComboTimer;
|
||||||
|
// When game state is (0,0) (e.g. after failed UseAction resets it), prefer our overlay so we keep trying the next step instead of resetting to step 1.
|
||||||
|
if (rawMoveFinal == 0 && rawTimeFinal <= 0)
|
||||||
|
{
|
||||||
|
lock (_ourComboState)
|
||||||
|
{
|
||||||
|
if (_ourComboState.TryGetValue(triggerActionId, out var fallback) && now < fallback.expiryTicks)
|
||||||
|
{
|
||||||
|
float remaining = (fallback.expiryTicks - now) / 1000f;
|
||||||
|
return (fallback.lastExecutedActionId, remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (rawMoveFinal, rawTimeFinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Record that we just executed (or are about to execute) this action for this combo trigger.</summary>
|
||||||
|
/// <param name="optimistic">If true, we're updating before Original() returns. When cycling back (executed==trigger), backdate setTicks so the 200ms overlay-ignore doesn't trigger and the next UseAction sees our overlay and advances to step 2 instead of raw game state (2254) and replacing with 2242 again.</param>
|
||||||
|
private void SetOurComboState(uint triggerActionId, uint executedActionId, bool optimistic = false)
|
||||||
|
{
|
||||||
|
long now = Environment.TickCount64;
|
||||||
|
uint? lastStepActionId = GetLastStepActionIdForTrigger(triggerActionId);
|
||||||
|
// We only allow sending step 1 (trigger) in UseAction when GCD has ended, so when we get here with executedActionId==trigger it's a real success — always update so the next press advances to step 2 instead of spamming step 1.
|
||||||
|
lock (_ourComboState)
|
||||||
|
{
|
||||||
|
long setTicks = now;
|
||||||
|
if (executedActionId == triggerActionId)
|
||||||
|
{
|
||||||
|
if (optimistic)
|
||||||
|
setTicks = now - OverlayIgnoreSameTriggerMs - 50; // backdate so next GetEffectiveComboState uses overlay, not raw (so we advance 2242->2254 when mashing)
|
||||||
|
else if (_ourComboState.TryGetValue(triggerActionId, out var prev) && prev.lastExecutedActionId == triggerActionId)
|
||||||
|
setTicks = prev.setTicks; // keep 200ms window from first execution of step 1
|
||||||
|
}
|
||||||
|
_ourComboState[triggerActionId] = (executedActionId, now + ComboWindowMs, setTicks);
|
||||||
|
}
|
||||||
|
if (lastStepActionId.HasValue && executedActionId == lastStepActionId.Value)
|
||||||
|
{
|
||||||
|
lock (_lastStepOverlaySetTicks)
|
||||||
|
{
|
||||||
|
_lastStepOverlaySetTicks[triggerActionId] = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Service.Configuration.EnableDebugLogging)
|
||||||
|
Service.PluginLog.Information("ConfigurableCombo: SetOurComboState trigger={0} executed={1}", triggerActionId, executedActionId);
|
||||||
|
lock (_getIconCache)
|
||||||
|
{
|
||||||
|
_getIconCache.Remove(triggerActionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the last (final) action ID in the combo that starts with triggerActionId, or null if not found.</summary>
|
||||||
|
private static uint? GetLastStepActionIdForTrigger(uint triggerActionId)
|
||||||
|
{
|
||||||
|
foreach (var combo in Service.Configuration.UserCombos ?? new List<UserComboDefinition>())
|
||||||
|
{
|
||||||
|
if (!combo.Enabled || combo.ActionIds.Count == 0 || combo.ActionIds[0] != triggerActionId)
|
||||||
|
continue;
|
||||||
|
if (!ComboAppliesToCurrentJob(combo))
|
||||||
|
continue;
|
||||||
|
return combo.ActionIds[combo.ActionIds.Count - 1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clear overlay for combo triggers where the executed action is not in that combo's sequence, so the icon resets to step 1.</summary>
|
||||||
|
private void ClearOverlayIfActionNotInCombo(uint executedActionId)
|
||||||
|
{
|
||||||
|
if (executedActionId == 0) return;
|
||||||
|
lock (_ourComboState)
|
||||||
|
{
|
||||||
|
foreach (var combo in Service.Configuration.UserCombos)
|
||||||
|
{
|
||||||
|
if (!combo.Enabled || combo.ActionIds.Count == 0 || !ComboAppliesToCurrentJob(combo))
|
||||||
|
continue;
|
||||||
|
uint trigger = combo.ActionIds[0];
|
||||||
|
if (!combo.ActionIds.Contains(executedActionId))
|
||||||
|
{
|
||||||
|
_ourComboState.Remove(trigger);
|
||||||
|
lock (_getIconCache) { _getIconCache.Remove(trigger); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Intercept at the moment the game uses an action. Replace trigger action with next in our combo so custom combos execute.
|
||||||
|
/// This runs for every action use (hotbar, macro, etc.) so we only replace when actionId is a combo trigger.
|
||||||
|
/// </summary>
|
||||||
|
private bool UseActionDetour(IntPtr actionManager, ActionType actionType, uint actionId, ulong targetId, uint extraParam, uint mode, uint comboRouteId, bool* outOptAreaTargeted)
|
||||||
|
{
|
||||||
|
if (!Service.Configuration.EnablePlugin || Service.ClientState.LocalPlayer == null)
|
||||||
|
return _useActionHook!.Original(actionManager, actionType, actionId, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
|
||||||
|
|
||||||
|
if (actionType != ActionType.Action)
|
||||||
|
return _useActionHook!.Original(actionManager, actionType, actionId, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
|
||||||
|
|
||||||
|
uint replaceWith = 0;
|
||||||
|
uint matchedTrigger = 0;
|
||||||
|
uint effectiveLastCombo = 0;
|
||||||
|
float effectiveComboTime = 0f;
|
||||||
|
UserComboDefinition? matchedCombo = null;
|
||||||
|
foreach (var combo in Service.Configuration.UserCombos)
|
||||||
|
{
|
||||||
|
if (!combo.Enabled || combo.ActionIds.Count == 0)
|
||||||
|
continue;
|
||||||
|
if (!ComboAppliesToCurrentJob(combo))
|
||||||
|
continue;
|
||||||
|
if (combo.ActionIds[0] != actionId)
|
||||||
|
continue;
|
||||||
|
matchedCombo = combo;
|
||||||
|
matchedTrigger = combo.ActionIds[0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedCombo == null)
|
||||||
|
{
|
||||||
|
return _useActionHook!.Original(actionManager, actionType, actionId, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold lock for the entire combo path: read state → execute → update state only on success.
|
||||||
|
// This serializes mashing so only one action runs at a time and overlay advances only when the game actually executes.
|
||||||
|
lock (_useActionComboLock)
|
||||||
|
{
|
||||||
|
var (lastComboMove, comboTime) = GetEffectiveComboState(actionId);
|
||||||
|
effectiveLastCombo = lastComboMove;
|
||||||
|
effectiveComboTime = comboTime;
|
||||||
|
replaceWith = GetNextActionInSequence(matchedCombo.ActionIds, lastComboMove, comboTime);
|
||||||
|
|
||||||
|
if (Service.Configuration.EnableDebugLogging && replaceWith != 0)
|
||||||
|
{
|
||||||
|
Service.PluginLog.Information(
|
||||||
|
"ConfigurableCombo: UseAction(actionId={0}, type={1}) effective lastComboMove={2} comboTime={3:F2} => replaceWith={4}",
|
||||||
|
actionId, actionType, effectiveLastCombo, effectiveComboTime, replaceWith);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceWith == 0)
|
||||||
|
{
|
||||||
|
bool ok = _useActionHook!.Original(actionManager, actionType, actionId, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
|
||||||
|
if (ok)
|
||||||
|
ClearOverlayIfActionNotInCombo(actionId);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When mashing: block sending step 1 (trigger) only while GCD is still active after we set overlay to the last step, so we allow execution as soon as GCD ends. Cap with timestamp so we don't block forever if GCD isn't available.
|
||||||
|
var am = ActionManager.Instance();
|
||||||
|
long now = Environment.TickCount64;
|
||||||
|
if (replaceWith == matchedTrigger)
|
||||||
|
{
|
||||||
|
lock (_lastStepOverlaySetTicks)
|
||||||
|
{
|
||||||
|
if (_lastStepOverlaySetTicks.TryGetValue(matchedTrigger, out long lastSet) &&
|
||||||
|
(now - lastSet) < MinMsBeforeOverlayStep1AfterLastStep &&
|
||||||
|
am != null && IsGcdActive(am))
|
||||||
|
{
|
||||||
|
if (Service.Configuration.EnableDebugLogging)
|
||||||
|
Service.PluginLog.Information("ConfigurableCombo: UseAction SKIP (block step 1 while GCD active after last step) replaceWith={0}", replaceWith);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the replacement action is a GCD action and is on cooldown, don't call Original() — it would fail and we'd spam. oGCD actions (e.g. Ninja handsigns) use other recast groups; let the game handle their cooldown and only skip when we know it's GCD.
|
||||||
|
if (am != null && ActionUsesGcd(am, replaceWith) && IsActionOnCooldown(am, replaceWith))
|
||||||
|
{
|
||||||
|
if (Service.Configuration.EnableDebugLogging)
|
||||||
|
Service.PluginLog.Information("ConfigurableCombo: UseAction SKIP (GCD on cooldown) replaceWith={0}", replaceWith);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Service.PluginLog.Information("ConfigurableCombo: UseAction REPLACING {0} -> {1}", actionId, replaceWith);
|
||||||
|
uint executedActionId = replaceWith;
|
||||||
|
bool result = _useActionHook!.Original(actionManager, actionType, replaceWith, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
|
||||||
|
if (result)
|
||||||
|
{
|
||||||
|
SetOurComboState(matchedTrigger, executedActionId, optimistic: false);
|
||||||
|
ClearOverlayIfActionNotInCombo(executedActionId);
|
||||||
|
}
|
||||||
|
// When the action fails (GCD, range, etc.) do NOT clear the overlay: leave last successful state
|
||||||
|
// so the next press retries the same replacement (e.g. 2254) instead of resetting to (0,0) and sending 2242 again (stuck on step 1).
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the action uses the global cooldown (recast group 57). oGCD abilities use other recast groups; we only skip execution when on cooldown for GCD actions.</summary>
|
||||||
|
private static unsafe bool ActionUsesGcd(ActionManager* am, uint actionId)
|
||||||
|
=> am != null && am->GetRecastGroup((int)ActionType.Action, actionId) == GcdRecastGroupId;
|
||||||
|
|
||||||
|
/// <summary>True if the global cooldown (recast group 57) is currently active. Used so we only hold the "last step" icon / block step 1 while GCD is actually running, then allow as soon as GCD ends.</summary>
|
||||||
|
private static unsafe bool IsGcdActive(ActionManager* am)
|
||||||
|
{
|
||||||
|
if (am == null) return false;
|
||||||
|
var gcd = am->GetRecastGroupDetail(GcdRecastGroupId);
|
||||||
|
return gcd != null && gcd->IsActive && gcd->Elapsed < gcd->Total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the action is currently on cooldown (recast). When true, calling UseAction would fail; skip it so we don't spam. Checks GCD group 57 first (reliable for weaponskills), then per-action recast.</summary>
|
||||||
|
private static unsafe bool IsActionOnCooldown(ActionManager* am, uint actionId)
|
||||||
|
{
|
||||||
|
if (am == null) return false;
|
||||||
|
// Combo actions are typically GCD; check recast group 57 (global cooldown) first.
|
||||||
|
if (IsGcdActive(am))
|
||||||
|
return true;
|
||||||
|
if (am->IsRecastTimerActive(ActionType.Action, actionId))
|
||||||
|
return true;
|
||||||
|
float total = am->GetRecastTime(ActionType.Action, actionId);
|
||||||
|
float elapsed = am->GetRecastTimeElapsed(ActionType.Action, actionId);
|
||||||
|
return total > 0.001f && elapsed < total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clear overlay for a single combo trigger (e.g. when UseAction failed so our optimistic update was wrong).</summary>
|
||||||
|
private void ClearComboOverlayForTrigger(uint triggerActionId)
|
||||||
|
{
|
||||||
|
lock (_ourComboState)
|
||||||
|
{
|
||||||
|
_ourComboState.Remove(triggerActionId);
|
||||||
|
}
|
||||||
|
lock (_getIconCache) { _getIconCache.Remove(triggerActionId); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the player executes a hotbar slot that is one of our combo triggers, we do NOT swap the slot here.
|
||||||
|
/// The game will call UseAction(slot->CommandId) = UseAction(trigger). Our UseAction detour then
|
||||||
|
/// replaces trigger -> next and only calls SetOurComboState when the action actually succeeds (return true).
|
||||||
|
/// This prevents the icon from advancing when the action fails (GCD, out of range, etc.).
|
||||||
|
/// </summary>
|
||||||
|
private unsafe byte ExecuteSlotByIdDetour(IntPtr hotbarModule, uint hotbarId, uint slotId)
|
||||||
|
{
|
||||||
|
if (Service.Configuration.EnableDebugLogging)
|
||||||
|
Service.PluginLog.Information("ConfigurableCombo: ExecuteSlotById INVOKED bar={0} slot={1}", hotbarId, slotId);
|
||||||
|
|
||||||
|
if (!Service.Configuration.EnablePlugin || Service.ClientState.LocalPlayer == null)
|
||||||
|
return _executeSlotByIdHook!.Original(hotbarModule, hotbarId, slotId);
|
||||||
|
|
||||||
|
var module = (RaptureHotbarModule*)hotbarModule;
|
||||||
|
var slot = module->GetSlotById(hotbarId, slotId);
|
||||||
|
if (slot == null || slot->CommandType != RaptureHotbarModule.HotbarSlotType.Action)
|
||||||
|
{
|
||||||
|
if (Service.Configuration.EnableDebugLogging && slot != null)
|
||||||
|
Service.PluginLog.Information("ConfigurableCombo: ExecuteSlotById bar={0} slot={1} not Action (type={2})", hotbarId, slotId, slot->CommandType);
|
||||||
|
return _executeSlotByIdHook!.Original(hotbarModule, hotbarId, slotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint triggerActionId = slot->CommandId;
|
||||||
|
var (lastComboMove, comboTime) = GetEffectiveComboState(triggerActionId);
|
||||||
|
uint nextActionId = 0;
|
||||||
|
int comboStepCount = 0;
|
||||||
|
foreach (var combo in Service.Configuration.UserCombos)
|
||||||
|
{
|
||||||
|
if (!combo.Enabled || combo.ActionIds.Count == 0)
|
||||||
|
continue;
|
||||||
|
if (!ComboAppliesToCurrentJob(combo))
|
||||||
|
continue;
|
||||||
|
if (combo.ActionIds[0] != triggerActionId)
|
||||||
|
continue;
|
||||||
|
comboStepCount = combo.ActionIds.Count;
|
||||||
|
nextActionId = GetNextActionInSequence(combo.ActionIds, lastComboMove, comboTime);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Service.Configuration.EnableDebugLogging)
|
||||||
|
Service.PluginLog.Information(
|
||||||
|
"ConfigurableCombo: ExecuteSlotById(bar={0}, slot={1}) CommandId={2} lastComboMove={3} comboTime={4:F2} => nextActionId={5} comboSteps={6}",
|
||||||
|
hotbarId, slotId, triggerActionId, lastComboMove, comboTime, nextActionId, comboStepCount);
|
||||||
|
|
||||||
|
// Do not swap slot->CommandId. Let the game call UseAction(trigger). UseAction detour will
|
||||||
|
// replace trigger->next and only advance combo state when the action actually succeeds.
|
||||||
|
return _executeSlotByIdHook!.Original(hotbarModule, hotbarId, slotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Game calls this every frame for each visible hotbar slot to get the display action/icon. We override *actionId (and *actionType)
|
||||||
|
/// and the slot's ApparentActionId/IconId for combo slots so the default game hotbar shows the correct combo step icon.
|
||||||
|
/// </summary>
|
||||||
|
private unsafe uint GetSlotAppearanceDetour(RaptureHotbarModule.HotbarSlotType* actionType, uint* actionId, ushort* UNK_0xC4, RaptureHotbarModule* hotbarModule, RaptureHotbarModule.HotbarSlot* slot)
|
||||||
|
{
|
||||||
|
uint result = _getSlotAppearanceHook!.Original(actionType, actionId, UNK_0xC4, hotbarModule, slot);
|
||||||
|
|
||||||
|
if (!Service.Configuration.EnablePlugin || Service.ClientState.LocalPlayer == null || slot == null)
|
||||||
|
return result;
|
||||||
|
if (slot->CommandType != RaptureHotbarModule.HotbarSlotType.Action)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
uint triggerActionId = slot->CommandId;
|
||||||
|
foreach (var combo in Service.Configuration.UserCombos ?? new List<UserComboDefinition>())
|
||||||
|
{
|
||||||
|
if (!combo.Enabled || combo.ActionIds.Count == 0)
|
||||||
|
continue;
|
||||||
|
if (!ComboAppliesToCurrentJob(combo))
|
||||||
|
continue;
|
||||||
|
if (combo.ActionIds[0] != triggerActionId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerActionId);
|
||||||
|
uint nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime);
|
||||||
|
// Show next action immediately (e.g. Gust Slash with GCD overlay after Death Blossom); no "hold last step icon while GCD".
|
||||||
|
|
||||||
|
// Override out params so callers that use GetSlotAppearance result get our icon.
|
||||||
|
*actionId = nextActionId;
|
||||||
|
*actionType = RaptureHotbarModule.HotbarSlotType.Action;
|
||||||
|
// Also update the slot struct so any path that reads slot->ApparentActionId / IconId (e.g. copy to NumberArray) sees our icon.
|
||||||
|
slot->ApparentActionId = nextActionId;
|
||||||
|
slot->ApparentSlotType = RaptureHotbarModule.HotbarSlotType.Action;
|
||||||
|
int iconId = slot->GetIconIdForSlot(RaptureHotbarModule.HotbarSlotType.Action, nextActionId);
|
||||||
|
if (iconId > 0)
|
||||||
|
slot->IconId = (uint)iconId;
|
||||||
|
else
|
||||||
|
slot->LoadIconId();
|
||||||
|
|
||||||
|
if (Service.Configuration.EnableDebugLogging)
|
||||||
|
{
|
||||||
|
long now = Environment.TickCount64;
|
||||||
|
lock (_getIconDebugLogTicks)
|
||||||
|
{
|
||||||
|
if (!_getIconDebugLogTicks.TryGetValue(triggerActionId + 100000, out var last) || (now - last) >= 2000)
|
||||||
|
{
|
||||||
|
_getIconDebugLogTicks[triggerActionId + 100000] = now;
|
||||||
|
Service.PluginLog.Information("ConfigurableCombo: GetSlotAppearance(trigger={0}) => actionId={1} (comboTime={2:F2})", triggerActionId, nextActionId, slotComboTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Each frame, set each slot that has a combo trigger to show the next action's icon (ApparentActionId + LoadIconId).
|
||||||
|
/// This targets the game's native action bars (RaptureHotbarModule), not any third-party UI. We apply every frame
|
||||||
|
/// so we override any game logic that may reset slot appearance.
|
||||||
|
/// </summary>
|
||||||
|
private unsafe void OnFrameworkUpdate(IFramework framework)
|
||||||
|
{
|
||||||
|
if (!Service.Configuration.EnablePlugin || Service.ClientState.LocalPlayer == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var module = RaptureHotbarModule.Instance();
|
||||||
|
if (module == null || !module->ModuleReady)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Game's native hotbars: 10 standard + 8 cross (barId 0..17), 16 slots per bar
|
||||||
|
for (uint barId = 0; barId < 18; barId++)
|
||||||
|
{
|
||||||
|
for (uint slotId = 0; slotId < 16; slotId++)
|
||||||
|
{
|
||||||
|
var slot = module->GetSlotById(barId, slotId);
|
||||||
|
if (slot == null)
|
||||||
|
continue;
|
||||||
|
if (slot->CommandType != RaptureHotbarModule.HotbarSlotType.Action)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
uint triggerActionId = slot->CommandId;
|
||||||
|
uint nextActionId = 0;
|
||||||
|
bool slotMatched = false;
|
||||||
|
foreach (var combo in Service.Configuration.UserCombos)
|
||||||
|
{
|
||||||
|
if (!combo.Enabled || combo.ActionIds.Count == 0)
|
||||||
|
continue;
|
||||||
|
if (!ComboAppliesToCurrentJob(combo))
|
||||||
|
continue;
|
||||||
|
if (combo.ActionIds[0] != triggerActionId)
|
||||||
|
continue;
|
||||||
|
var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerActionId);
|
||||||
|
nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime);
|
||||||
|
// Show next action immediately (Gust Slash with GCD overlay after Death Blossom).
|
||||||
|
slotMatched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slotMatched)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
slot->ApparentActionId = nextActionId;
|
||||||
|
slot->ApparentSlotType = RaptureHotbarModule.HotbarSlotType.Action;
|
||||||
|
int iconId = slot->GetIconIdForSlot(RaptureHotbarModule.HotbarSlotType.Action, nextActionId);
|
||||||
|
if (iconId > 0)
|
||||||
|
slot->IconId = (uint)iconId;
|
||||||
|
else
|
||||||
|
slot->LoadIconId();
|
||||||
|
// Invalidate GetAdjustedActionId cache so the next call returns the same nextActionId we just drew.
|
||||||
|
lock (_getIconCache) { _getIconCache.Remove(triggerActionId); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe uint GetIconDetour(IntPtr actionManager, uint actionID)
|
||||||
|
{
|
||||||
|
_actionManager = actionManager;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Service.Configuration.EnablePlugin || Service.ClientState.LocalPlayer == null)
|
||||||
|
return _getIconHook.Original(actionManager, actionID);
|
||||||
|
|
||||||
|
int throttleMs = Math.Max(0, Service.Configuration.ThrottleMs);
|
||||||
|
long now = Environment.TickCount64;
|
||||||
|
|
||||||
|
foreach (var combo in Service.Configuration.UserCombos)
|
||||||
|
{
|
||||||
|
if (!combo.Enabled || combo.ActionIds.Count == 0)
|
||||||
|
continue;
|
||||||
|
if (!ComboAppliesToCurrentJob(combo))
|
||||||
|
continue;
|
||||||
|
if (actionID != combo.ActionIds[0])
|
||||||
|
continue;
|
||||||
|
var (lastComboMove, comboTime) = GetEffectiveComboState(actionID);
|
||||||
|
lock (_getIconCache)
|
||||||
|
{
|
||||||
|
if (throttleMs > 0 && _getIconCache.TryGetValue(actionID, out var cached) &&
|
||||||
|
(now - cached.ticks) < throttleMs && cached.lastComboMove == lastComboMove)
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
uint result = GetDisplayActionIdForIcon(combo.ActionIds, lastComboMove, comboTime);
|
||||||
|
// Show next action immediately (Gust Slash with GCD overlay after Death Blossom); no "hold last step icon while GCD".
|
||||||
|
if (throttleMs > 0)
|
||||||
|
{
|
||||||
|
lock (_getIconCache)
|
||||||
|
{
|
||||||
|
_getIconCache[actionID] = (result, now, lastComboMove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Service.Configuration.EnableDebugLogging)
|
||||||
|
{
|
||||||
|
lock (_getIconDebugLogTicks)
|
||||||
|
{
|
||||||
|
if (!_getIconDebugLogTicks.TryGetValue(actionID, out var last) || (now - last) >= 1000)
|
||||||
|
{
|
||||||
|
_getIconDebugLogTicks[actionID] = now;
|
||||||
|
Service.PluginLog.Information(
|
||||||
|
"ConfigurableCombo: GetAdjustedActionId(actionID={0}) lastComboMove={1} comboTime={2:F2} => return {3}",
|
||||||
|
actionID, lastComboMove, comboTime, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gameLastCombo = *(uint*)Service.Address.LastComboMove;
|
||||||
|
var gameComboTime = *(float*)Service.Address.ComboTimer;
|
||||||
|
lock (_getIconCache)
|
||||||
|
{
|
||||||
|
if (throttleMs > 0 && _getIconCache.TryGetValue(actionID, out var cached) &&
|
||||||
|
(now - cached.ticks) < throttleMs && cached.lastComboMove == gameLastCombo)
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
uint gameResult = _getIconHook.Original(actionManager, actionID);
|
||||||
|
if (throttleMs > 0)
|
||||||
|
{
|
||||||
|
lock (_getIconCache)
|
||||||
|
{
|
||||||
|
_getIconCache[actionID] = (gameResult, now, gameLastCombo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gameResult;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Service.PluginLog.Error(ex, "ConfigurableCombo: GetIconDetour error");
|
||||||
|
return _getIconHook.Original(actionManager, actionID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the action id to show in the slot icon: the next action in the sequence (what will execute on press).
|
||||||
|
/// That icon persists until that next action actually executes (we only advance lastComboMove on UseAction success).
|
||||||
|
/// </summary>
|
||||||
|
private static uint GetDisplayActionIdForIcon(IList<uint> actionIds, uint lastComboMove, float comboTime)
|
||||||
|
{
|
||||||
|
return GetNextActionInSequence(actionIds, lastComboMove, comboTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the next action in the sequence. Resets to step 1 after the last step or when combo expired / another action was used.
|
||||||
|
/// </summary>
|
||||||
|
private static uint GetNextActionInSequence(IList<uint> actionIds, uint lastComboMove, float comboTime)
|
||||||
|
{
|
||||||
|
// Combo timer expired or no combo in progress -> show first action
|
||||||
|
if (comboTime <= 0)
|
||||||
|
return actionIds[0];
|
||||||
|
|
||||||
|
int lastIndex = -1;
|
||||||
|
for (int i = 0; i < actionIds.Count; i++)
|
||||||
|
{
|
||||||
|
if (actionIds[i] == lastComboMove)
|
||||||
|
{
|
||||||
|
lastIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last action was not in this sequence (e.g. they pressed 3a/3b on another key) -> reset to step 1
|
||||||
|
if (lastIndex < 0)
|
||||||
|
return actionIds[0];
|
||||||
|
|
||||||
|
// Advance: next is lastIndex+1. After the last step, reset to 1 (revert after casting 2 or any other action).
|
||||||
|
int nextIndex = lastIndex + 1;
|
||||||
|
if (nextIndex >= actionIds.Count)
|
||||||
|
nextIndex = 0;
|
||||||
|
|
||||||
|
return actionIds[nextIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Job list filter: combat (DoW/DoM) or crafting & gathering (DoH/DoL).</summary>
|
||||||
|
public enum JobListKind
|
||||||
|
{
|
||||||
|
Combat,
|
||||||
|
CraftingGathering,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches job list and actions by category for the config.
|
||||||
|
/// Uses dynamic to tolerate different Lumina API versions.
|
||||||
|
/// </summary>
|
||||||
|
internal static class JobActionsHelper
|
||||||
|
{
|
||||||
|
private const uint ActionCategoryItem = 2;
|
||||||
|
/// <summary>Max Action RowId to scan (avoids relying on foreach which may skip rows in some Lumina versions).</summary>
|
||||||
|
private const uint MaxActionRowId = 60000u;
|
||||||
|
|
||||||
|
/// <summary>FFXIV ClassJob RowIds: 8-18 are DoH/DoL (CRP..FSH). 1-7 and 19+ are combat.</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the name is a real display name (not #rowId fallback or _rsv_ placeholder).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the name uses only English-friendly characters (ASCII printable + Latin-1 supplement). Filters out CJK and other scripts.</summary>
|
||||||
|
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<uint, List<(uint ActionId, string Name, uint IconId)>> _jobActionsCache = new();
|
||||||
|
private static List<(uint JobId, string Name)>? _combatJobListCache;
|
||||||
|
private static List<(uint JobId, string Name)>? _craftingGatheringJobListCache;
|
||||||
|
private static readonly Dictionary<uint, string> _jobNameByRowId = new();
|
||||||
|
private static readonly Dictionary<uint, List<(uint ActionId, string Name, uint IconId)>> _pvpActionsCacheByJob = new();
|
||||||
|
private static readonly Dictionary<uint, List<(uint ActionId, string Name, uint IconId)>> _roleActionsCacheByJob = new();
|
||||||
|
private static List<(uint ActionId, string Name, uint IconId)>? _consumableActionsCache;
|
||||||
|
|
||||||
|
/// <summary>Jobs for the dropdown. Combat = DoW/DoM (RowId 1-7, 19+). CraftingGathering = DoH/DoL (RowId 8-18). Includes Unspecified. Cached per kind.</summary>
|
||||||
|
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<Lumina.Excel.Sheets.ClassJob>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Job-related PvE actions for the given job. Cached per job.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PvP actions for the given job (or all PvP if jobId 0). Cached per job.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Role actions for the given job's category (shared actions like Second Wind, Leg Sweep). Cached per job.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Consumable items (ActionCategory Item). Cached.</summary>
|
||||||
|
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<Action>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Known PvE role action RowIds (from game data / WrathCombo). Displayed regardless of selected job.</summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Role actions: built from known role action IDs so they always display (Tank, Healer, Melee, PhysRanged, Caster, Magic).</summary>
|
||||||
|
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<Action>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get action's ClassJobCategory RowId via reflection (same pattern as GetJobCategoryRowId so Role Actions work).</summary>
|
||||||
|
private static uint GetActionClassJobCategoryRowId(object? actionRow)
|
||||||
|
{
|
||||||
|
if (actionRow == null) return 0;
|
||||||
|
var cat = GetRowRefPropertyValue(actionRow, "ClassJobCategory");
|
||||||
|
return GetRowRefRowId(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get a RowRef-like property value from a row object, trying exact and case-insensitive property names.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Consumables: usable items from the Item sheet (ItemAction set). IDs are Item RowIds; game uses these for item hotbar slots.</summary>
|
||||||
|
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<Lumina.Excel.Sheets.Item>(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}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the action belongs to the given job (ClassJob match, parent class match, or ClassJobCategory). Uses jobCategoryRowId as fallback when IsJobInCategory is unavailable.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the ClassJobCategory RowId for the given job from the ClassJob sheet (for role-action fallback).</summary>
|
||||||
|
private static uint GetJobCategoryRowId(uint jobId)
|
||||||
|
{
|
||||||
|
if (jobId == 0) return 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sheet = Service.DataManager.GetExcelSheet<Lumina.Excel.Sheets.ClassJob>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
private static uint GetParentClassJobId(uint jobId)
|
||||||
|
{
|
||||||
|
if (jobId == 0) return 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sheet = Service.DataManager.GetExcelSheet<Lumina.Excel.Sheets.ClassJob>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get a uint row id from any RowRef-like object (Lumina 5 struct) via reflection when dynamic fails.</summary>
|
||||||
|
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<Action>(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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using Dalamud.Game;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||||
|
|
||||||
|
namespace ConfigurableCombo;
|
||||||
|
|
||||||
|
internal class PluginAddressResolver : BaseAddressResolver
|
||||||
|
{
|
||||||
|
public IntPtr ComboTimer { get; private set; }
|
||||||
|
public IntPtr LastComboMove => ComboTimer + 0x4;
|
||||||
|
public IntPtr IsActionIdReplaceable { get; private set; }
|
||||||
|
/// <summary>RaptureHotbarModule.ExecuteSlotById — used to run custom combos (replace slot action before execution).</summary>
|
||||||
|
public IntPtr ExecuteSlotById { get; private set; }
|
||||||
|
/// <summary>ActionManager.UseAction — final execution point; hook here so custom combos work regardless of hotbar path.</summary>
|
||||||
|
public IntPtr UseAction { get; private set; }
|
||||||
|
/// <summary>RaptureHotbarModule.GetSlotAppearance — game calls this every frame for visible hotbar slots to get display action/icon; we override so default hotbar shows combo icon.</summary>
|
||||||
|
public IntPtr GetSlotAppearance { get; private set; }
|
||||||
|
|
||||||
|
protected unsafe override void Setup64Bit(ISigScanner scanner)
|
||||||
|
{
|
||||||
|
ComboTimer = new IntPtr(&ActionManager.Instance()->Combo.Timer);
|
||||||
|
IsActionIdReplaceable = scanner.ScanText("40 53 48 83 EC 20 8B D9 48 8B 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 85 C0 74 1F");
|
||||||
|
ExecuteSlotById = scanner.ScanText("4C 8B C9 41 83 F8 10 73 45");
|
||||||
|
UseAction = scanner.ScanText("E8 ?? ?? ?? ?? B0 01 EB B6");
|
||||||
|
GetSlotAppearance = scanner.ScanText("48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC 30 48 8B DA 49 8B F0");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Dalamud.Configuration;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace ConfigurableCombo;
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public class PluginConfiguration : IPluginConfiguration
|
||||||
|
{
|
||||||
|
public int Version { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Master switch for the plugin.
|
||||||
|
/// </summary>
|
||||||
|
public bool EnablePlugin { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How often (ms) to refresh the combo "next action" per slot. Higher = more stable icons, slightly slower to update.
|
||||||
|
/// WrathCombo uses 50ms; same default here.
|
||||||
|
/// </summary>
|
||||||
|
public int ThrottleMs { get; set; } = 50;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, log every combo-related hook call and replacement to the plugin log (throttled where needed).
|
||||||
|
/// Toggled via /ccombo debug for support; not shown in settings UI.
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableDebugLogging { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User-defined combos. Each combo is a named sequence of action IDs; the first action is the hotbar slot.
|
||||||
|
/// </summary>
|
||||||
|
public List<UserComboDefinition> UserCombos { get; set; } = new();
|
||||||
|
|
||||||
|
public void Save() => Service.Interface!.SavePluginConfig(this);
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
using Dalamud.Game.ClientState.Objects;
|
||||||
|
using Dalamud.IoC;
|
||||||
|
using Dalamud.Plugin;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
|
||||||
|
namespace ConfigurableCombo;
|
||||||
|
|
||||||
|
internal class Service
|
||||||
|
{
|
||||||
|
internal static PluginConfiguration Configuration { get; set; } = null!;
|
||||||
|
internal static PluginAddressResolver Address { get; set; } = null!;
|
||||||
|
internal static IconReplacer IconReplacer { get; set; } = null!;
|
||||||
|
|
||||||
|
[PluginService] internal static IDalamudPluginInterface Interface { get; private set; } = null!;
|
||||||
|
[PluginService] internal static IClientState ClientState { get; private set; } = null!;
|
||||||
|
[PluginService] internal static IChatGui ChatGui { get; private set; } = null!;
|
||||||
|
[PluginService] internal static ICommandManager CommandManager { get; private set; } = null!;
|
||||||
|
[PluginService] internal static IDataManager DataManager { get; private set; } = null!;
|
||||||
|
[PluginService] internal static IPluginLog PluginLog { get; private set; } = null!;
|
||||||
|
[PluginService] internal static ITextureProvider TextureProvider { get; private set; } = null!;
|
||||||
|
[PluginService] internal static IFramework Framework { get; private set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace ConfigurableCombo;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single user-defined combo: put the first action on your hotbar; pressing it advances through the sequence.
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class UserComboDefinition
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Display name for this combo (e.g. "WAR ST Combo").
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = "New Combo";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ordered list of action IDs. The first action is the one you place on the hotbar; each press advances to the next.
|
||||||
|
/// Combo resets to the first action when the game combo timer expires or the sequence is broken.
|
||||||
|
/// </summary>
|
||||||
|
public List<uint> ActionIds { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this combo is enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ClassJob.RowId this combo belongs to. 0 = Unspecified (shown when "Unspecified" job is selected).
|
||||||
|
/// </summary>
|
||||||
|
public uint JobId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unique id for this combo (for UI and serialization).
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The action ID that is "on the bar" — i.e. the trigger. This is ActionIds[0] when the list is non-empty.
|
||||||
|
/// </summary>
|
||||||
|
public uint TriggerActionId => ActionIds.Count > 0 ? ActionIds[0] : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"dependencies": {
|
||||||
|
"net10.0-windows7.0": {
|
||||||
|
"DalamudPackager": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[14.0.1, )",
|
||||||
|
"resolved": "14.0.1",
|
||||||
|
"contentHash": "y0WWyUE6dhpGdolK3iKgwys05/nZaVf4ZPtIjpLhJBZvHxkkiE23zYRo7K7uqAgoK/QvK5cqF6l3VG5AbgC6KA=="
|
||||||
|
},
|
||||||
|
"DotNet.ReproducibleBuilds": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[1.2.39, )",
|
||||||
|
"resolved": "1.2.39",
|
||||||
|
"contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg=="
|
||||||
|
},
|
||||||
|
"ffxivclientstructs": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"InteropGenerator.Runtime": "[1.0.0, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interopgenerator.runtime": {
|
||||||
|
"type": "Project"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"Author": "Custom",
|
||||||
|
"Name": "ConfigurableCombo",
|
||||||
|
"InternalName": "ConfigurableCombo",
|
||||||
|
"Description": "Define your own combo sequences in config. Put the first action on the hotbar; each press advances the sequence. Sequence resets to step 1 after the last step or after any other action (e.g. NIN 1-2 with 3a/3b on separate keybinds). /ccombo to open settings.",
|
||||||
|
"ApplicableVersion": "any",
|
||||||
|
"Tags": ["Combo", "Custom", "Hotbar"],
|
||||||
|
"Punchline": "User-defined one-button combo chains with optional split finishers.",
|
||||||
|
"AssemblyVersion": "1.0.0.0",
|
||||||
|
"RepoUrl": "https://github.com/ConfigurableCombo/ConfigurableCombo",
|
||||||
|
"DownloadLinkInstall": "https://github.com/ConfigurableCombo/ConfigurableCombo/releases/download/v1.0.0/latest.zip",
|
||||||
|
"DownloadLinkUpdate": "https://github.com/ConfigurableCombo/ConfigurableCombo/releases/download/v1.0.0/latest.zip"
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user