Initial commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-04 22:12:08 -05:00
commit f72031ae60
13 changed files with 1954 additions and 0 deletions
+667
View File
@@ -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];
}
}