feat: Transforming abilities for all jobs + duplicate action ID fix

- Duplicate action IDs in combos (e.g. Flare, Flare): track step index in
  combo state so the sequence advances correctly instead of looping on
  the first match.

- Dragoon Drakesbane: after Fang and Claw or Wheeling Thrust, show/use
  Drakesbane (36952). Support both ID sets (3552/3553 and 3554/3556).
  Normalize Drakesbane in sequence lookup; trigger match when slot shows
  Drakesbane.

- Warrior: Inner Release → Primal Rend when Primal Rend Ready (status
  2070); Primal Rend → Primal Ruination when Primal Ruination Ready
  (status 2700, lv100). Add HasStatus() via IBattleChara.StatusList.

- Reaper: Grim Reaping → Communio when 1 Lemure Shroud in Enshroud
  (RPRGauge). Normalize and trigger match for Communio.

- Central pipeline ResolveTransformingAbilities() for RDM, DRG, WAR, RPR.
  ClearOverlay/isLastStep and trigger matching updated for all replacement
  actions.

Made-with: Cursor
This commit is contained in:
jorg
2026-02-25 20:38:40 -06:00
parent 77d9494447
commit c08d8dc400
+232 -63
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Dalamud.Game.ClientState.JobGauge.Types;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game;
@@ -28,15 +29,15 @@ internal unsafe sealed class IconReplacer : IDisposable
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>Cache last returned action per trigger actionID; only reuse when lastComboMove and lastExecutedIndex unchanged (so next press gets correct action; index matters for duplicate IDs).</summary>
private readonly Dictionary<uint, (uint value, long ticks, uint lastComboMove, int lastExecutedIndex)> _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: last action we caused to be executed, step index (-1 if unknown), expiry, and when we set it (ms). Index allows duplicate action IDs in sequence (e.g. Flare, Flare).</summary>
private readonly Dictionary<uint, (uint lastExecutedActionId, int lastExecutedIndex, 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();
@@ -130,6 +131,7 @@ internal unsafe sealed class IconReplacer : IDisposable
/// <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;
// === Transforming abilities (action replaces another when condition met) ===
// Red Mage melee combo: base (7504, 7512, 7516) vs Enchanted when Black & White mana >= 20 (7527, 7528, 7529)
private const uint JobIdRedMage = 35;
private const uint RdmRiposte = 7504;
@@ -140,12 +142,50 @@ internal unsafe sealed class IconReplacer : IDisposable
private const uint RdmEnchantedRedoublement = 7529;
private const byte RdmEnchantedManaThreshold = 20;
// Dragoon: after Fang and Claw, Wheeling Thrust becomes Drakesbane; after Wheeling Thrust, Fang and Claw becomes Drakesbane.
// Game uses 3554/3556 (or 3552/3553 in some data); accept both so combo and standalone slots both update.
private const uint JobIdDragoon = 22;
private const uint DrgFangAndClaw1 = 3552;
private const uint DrgWheelingThrust1 = 3553;
private const uint DrgFangAndClaw2 = 3554;
private const uint DrgWheelingThrust2 = 3556;
private const uint DrgDrakesbane = 36952;
// Warrior: Inner Release → Primal Rend when Primal Rend Ready; Primal Rend → Primal Ruination when Primal Ruination Ready (lv100)
private const uint JobIdWarrior = 21;
private const uint WarInnerRelease = 3547;
private const uint WarPrimalRend = 25753;
private const uint WarPrimalRuination = 25754;
private const uint StatusPrimalRendReady = 2070;
private const uint StatusPrimalRuinationReady = 2700;
// Reaper: during Enshroud with 1 Lemure Shroud, Grim Reaping → Communio
private const uint JobIdReaper = 39;
private const uint RprGrimReaping = 24858;
private const uint RprCommunio = 24854;
private static uint GetCurrentJobId()
{
var player = Service.ClientState.LocalPlayer;
return player?.ClassJob.RowId ?? 0;
}
/// <summary>True if the local player has the given status effect (e.g. Primal Rend Ready 2070).</summary>
private static bool HasStatus(uint statusId)
{
var player = Service.ClientState.LocalPlayer as IBattleChara;
if (player?.StatusList == null) return false;
try
{
foreach (var status in player.StatusList)
{
if (status?.StatusId == statusId) return true;
}
}
catch { }
return false;
}
/// <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)
{
@@ -153,8 +193,8 @@ internal unsafe sealed class IconReplacer : IDisposable
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)
/// <summary>Get effective combo state for a trigger: use our overlay if we recently executed an action for this combo, else game state. lastExecutedIndex is -1 when from game (enables first-match fallback for duplicates).</summary>
private unsafe (uint lastComboMove, float comboTime, int lastExecutedIndex) GetEffectiveComboState(uint triggerActionId)
{
long now = Environment.TickCount64;
lock (_ourComboState)
@@ -167,10 +207,10 @@ internal unsafe sealed class IconReplacer : IDisposable
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);
return (rawMove, rawTime, -1);
}
float remaining = (our.expiryTicks - now) / 1000f;
return (our.lastExecutedActionId, remaining);
return (our.lastExecutedActionId, remaining, our.lastExecutedIndex);
}
}
var rawMoveFinal = *(uint*)Service.Address.LastComboMove;
@@ -183,16 +223,16 @@ internal unsafe sealed class IconReplacer : IDisposable
if (_ourComboState.TryGetValue(triggerActionId, out var fallback) && now < fallback.expiryTicks)
{
float remaining = (fallback.expiryTicks - now) / 1000f;
return (fallback.lastExecutedActionId, remaining);
return (fallback.lastExecutedActionId, remaining, fallback.lastExecutedIndex);
}
}
}
return (rawMoveFinal, rawTimeFinal);
return (rawMoveFinal, rawTimeFinal, -1);
}
/// <summary>Record that we just executed (or are about to execute) this action for this combo trigger.</summary>
/// <summary>Record that we just executed this action at the given step index for this combo trigger. executedIndex &lt; 0 means unknown (no index tracking).</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)
private void SetOurComboState(uint triggerActionId, uint executedActionId, int executedIndex = -1, bool optimistic = false)
{
long now = Environment.TickCount64;
uint? lastStepActionId = GetLastStepActionIdForTrigger(triggerActionId);
@@ -207,10 +247,14 @@ internal unsafe sealed class IconReplacer : IDisposable
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);
_ourComboState[triggerActionId] = (executedActionId, executedIndex, now + ComboWindowMs, setTicks);
}
bool isLastStep = lastStepActionId.HasValue && (executedActionId == lastStepActionId.Value ||
(lastStepActionId.Value == RdmRedoublement && executedActionId == RdmEnchantedRedoublement));
(lastStepActionId.Value == RdmRedoublement && executedActionId == RdmEnchantedRedoublement) ||
((IsDrgFangAndClaw(lastStepActionId.Value) || IsDrgWheelingThrust(lastStepActionId.Value)) && executedActionId == DrgDrakesbane) ||
(lastStepActionId.Value == WarInnerRelease && executedActionId == WarPrimalRend) ||
(lastStepActionId.Value == WarPrimalRend && executedActionId == WarPrimalRuination) ||
(lastStepActionId.Value == RprGrimReaping && executedActionId == RprCommunio));
if (isLastStep)
{
lock (_lastStepOverlaySetTicks)
@@ -252,7 +296,10 @@ internal unsafe sealed class IconReplacer : IDisposable
continue;
uint trigger = combo.ActionIds[0];
bool actionInCombo = combo.ActionIds.Contains(executedActionId) ||
(IsRdmMeleeCombo(combo.ActionIds) && (executedActionId == RdmEnchantedRiposte || executedActionId == RdmEnchantedZwerchhau || executedActionId == RdmEnchantedRedoublement));
(IsRdmMeleeCombo(combo.ActionIds) && (executedActionId == RdmEnchantedRiposte || executedActionId == RdmEnchantedZwerchhau || executedActionId == RdmEnchantedRedoublement)) ||
(executedActionId == DrgDrakesbane && combo.ActionIds.Any(a => IsDrgFangAndClaw(a) || IsDrgWheelingThrust(a))) ||
((executedActionId == WarPrimalRend || executedActionId == WarPrimalRuination) && (combo.ActionIds.Contains(WarInnerRelease) || combo.ActionIds.Contains(WarPrimalRend))) ||
(executedActionId == RprCommunio && combo.ActionIds.Contains(RprGrimReaping));
if (!actionInCombo)
{
_ourComboState.Remove(trigger);
@@ -285,7 +332,10 @@ internal unsafe sealed class IconReplacer : IDisposable
continue;
if (!ComboAppliesToCurrentJob(combo))
continue;
bool triggerMatches = combo.ActionIds[0] == actionId || (IsRdmMeleeCombo(combo.ActionIds) && actionId == RdmEnchantedRiposte);
bool triggerMatches = combo.ActionIds[0] == actionId || (IsRdmMeleeCombo(combo.ActionIds) && actionId == RdmEnchantedRiposte) ||
(GetCurrentJobId() == JobIdDragoon && actionId == DrgDrakesbane && (IsDrgFangAndClaw(combo.ActionIds[0]) || IsDrgWheelingThrust(combo.ActionIds[0]))) ||
(GetCurrentJobId() == JobIdWarrior && (actionId == WarPrimalRend && combo.ActionIds[0] == WarInnerRelease || actionId == WarPrimalRuination && combo.ActionIds[0] == WarPrimalRend)) ||
(GetCurrentJobId() == JobIdReaper && actionId == RprCommunio && combo.ActionIds[0] == RprGrimReaping);
if (!triggerMatches)
continue;
matchedCombo = combo;
@@ -303,10 +353,11 @@ internal unsafe sealed class IconReplacer : IDisposable
lock (_useActionComboLock)
{
uint triggerForState = matchedTrigger;
var (lastComboMove, comboTime) = GetEffectiveComboState(triggerForState);
var (lastComboMove, comboTime, lastExecutedIndex) = GetEffectiveComboState(triggerForState);
effectiveLastCombo = lastComboMove;
effectiveComboTime = comboTime;
replaceWith = GetResolvedNextAction(matchedCombo.ActionIds, lastComboMove, comboTime);
var (replaceWithAction, nextIndex) = GetResolvedNextAction(matchedCombo.ActionIds, lastComboMove, comboTime, lastExecutedIndex);
replaceWith = replaceWithAction;
if (Service.Configuration.EnableDebugLogging && replaceWith != 0)
{
@@ -355,7 +406,7 @@ internal unsafe sealed class IconReplacer : IDisposable
bool result = _useActionHook!.Original(actionManager, actionType, replaceWith, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
if (result)
{
SetOurComboState(matchedTrigger, executedActionId, optimistic: false);
SetOurComboState(matchedTrigger, executedActionId, nextIndex, optimistic: false);
ClearOverlayIfActionNotInCombo(executedActionId);
}
// When the action fails (GCD, range, etc.) do NOT clear the overlay: leave last successful state
@@ -425,7 +476,7 @@ internal unsafe sealed class IconReplacer : IDisposable
uint triggerActionId = slot->CommandId;
uint triggerForState = triggerActionId;
var (lastComboMove, comboTime) = GetEffectiveComboState(triggerForState);
var (lastComboMove, comboTime, lastExecutedIndex) = GetEffectiveComboState(triggerForState);
uint nextActionId = 0;
int comboStepCount = 0;
foreach (var combo in Service.Configuration.UserCombos)
@@ -434,15 +485,19 @@ internal unsafe sealed class IconReplacer : IDisposable
continue;
if (!ComboAppliesToCurrentJob(combo))
continue;
bool triggerMatches = combo.ActionIds[0] == triggerActionId || (IsRdmMeleeCombo(combo.ActionIds) && triggerActionId == RdmEnchantedRiposte);
bool triggerMatches = combo.ActionIds[0] == triggerActionId || (IsRdmMeleeCombo(combo.ActionIds) && triggerActionId == RdmEnchantedRiposte) ||
(GetCurrentJobId() == JobIdDragoon && triggerActionId == DrgDrakesbane && (IsDrgFangAndClaw(combo.ActionIds[0]) || IsDrgWheelingThrust(combo.ActionIds[0]))) ||
(GetCurrentJobId() == JobIdWarrior && (triggerActionId == WarPrimalRend && combo.ActionIds[0] == WarInnerRelease || triggerActionId == WarPrimalRuination && combo.ActionIds[0] == WarPrimalRend)) ||
(GetCurrentJobId() == JobIdReaper && triggerActionId == RprCommunio && combo.ActionIds[0] == RprGrimReaping);
if (!triggerMatches)
continue;
triggerForState = combo.ActionIds[0];
var (lastComboMoveInner, comboTimeInner) = GetEffectiveComboState(triggerForState);
var (lastComboMoveInner, comboTimeInner, lastExecutedIndexInner) = GetEffectiveComboState(triggerForState);
lastComboMove = lastComboMoveInner;
comboTime = comboTimeInner;
lastExecutedIndex = lastExecutedIndexInner;
comboStepCount = combo.ActionIds.Count;
nextActionId = GetResolvedNextAction(combo.ActionIds, lastComboMove, comboTime);
nextActionId = GetResolvedNextAction(combo.ActionIds, lastComboMove, comboTime, lastExecutedIndex).actionId;
break;
}
@@ -476,13 +531,16 @@ internal unsafe sealed class IconReplacer : IDisposable
continue;
if (!ComboAppliesToCurrentJob(combo))
continue;
bool triggerMatches = combo.ActionIds[0] == triggerActionId || (IsRdmMeleeCombo(combo.ActionIds) && triggerActionId == RdmEnchantedRiposte);
bool triggerMatches = combo.ActionIds[0] == triggerActionId || (IsRdmMeleeCombo(combo.ActionIds) && triggerActionId == RdmEnchantedRiposte) ||
(GetCurrentJobId() == JobIdDragoon && triggerActionId == DrgDrakesbane && (IsDrgFangAndClaw(combo.ActionIds[0]) || IsDrgWheelingThrust(combo.ActionIds[0]))) ||
(GetCurrentJobId() == JobIdWarrior && (triggerActionId == WarPrimalRend && combo.ActionIds[0] == WarInnerRelease || triggerActionId == WarPrimalRuination && combo.ActionIds[0] == WarPrimalRend)) ||
(GetCurrentJobId() == JobIdReaper && triggerActionId == RprCommunio && combo.ActionIds[0] == RprGrimReaping);
if (!triggerMatches)
continue;
uint triggerForState = combo.ActionIds[0];
var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerForState);
uint nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime);
var (slotLastCombo, slotComboTime, slotLastIndex) = GetEffectiveComboState(triggerForState);
uint nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime, slotLastIndex);
// 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.
@@ -549,12 +607,15 @@ internal unsafe sealed class IconReplacer : IDisposable
continue;
if (!ComboAppliesToCurrentJob(combo))
continue;
bool triggerMatches = combo.ActionIds[0] == triggerActionId || (IsRdmMeleeCombo(combo.ActionIds) && triggerActionId == RdmEnchantedRiposte);
bool triggerMatches = combo.ActionIds[0] == triggerActionId || (IsRdmMeleeCombo(combo.ActionIds) && triggerActionId == RdmEnchantedRiposte) ||
(GetCurrentJobId() == JobIdDragoon && triggerActionId == DrgDrakesbane && (IsDrgFangAndClaw(combo.ActionIds[0]) || IsDrgWheelingThrust(combo.ActionIds[0]))) ||
(GetCurrentJobId() == JobIdWarrior && (triggerActionId == WarPrimalRend && combo.ActionIds[0] == WarInnerRelease || triggerActionId == WarPrimalRuination && combo.ActionIds[0] == WarPrimalRend)) ||
(GetCurrentJobId() == JobIdReaper && triggerActionId == RprCommunio && combo.ActionIds[0] == RprGrimReaping);
if (!triggerMatches)
continue;
uint triggerForState = combo.ActionIds[0];
var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerForState);
nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime);
var (slotLastCombo, slotComboTime, slotLastIndex) = GetEffectiveComboState(triggerForState);
nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime, slotLastIndex);
// Show next action immediately (Gust Slash with GCD overlay after Death Blossom).
slotMatched = true;
break;
@@ -594,24 +655,27 @@ internal unsafe sealed class IconReplacer : IDisposable
continue;
if (!ComboAppliesToCurrentJob(combo))
continue;
bool triggerMatches = actionID == combo.ActionIds[0] || (IsRdmMeleeCombo(combo.ActionIds) && actionID == RdmEnchantedRiposte);
bool triggerMatches = actionID == combo.ActionIds[0] || (IsRdmMeleeCombo(combo.ActionIds) && actionID == RdmEnchantedRiposte) ||
(GetCurrentJobId() == JobIdDragoon && actionID == DrgDrakesbane && (IsDrgFangAndClaw(combo.ActionIds[0]) || IsDrgWheelingThrust(combo.ActionIds[0]))) ||
(GetCurrentJobId() == JobIdWarrior && (actionID == WarPrimalRend && combo.ActionIds[0] == WarInnerRelease || actionID == WarPrimalRuination && combo.ActionIds[0] == WarPrimalRend)) ||
(GetCurrentJobId() == JobIdReaper && actionID == RprCommunio && combo.ActionIds[0] == RprGrimReaping);
if (!triggerMatches)
continue;
uint triggerForState = combo.ActionIds[0];
var (lastComboMove, comboTime) = GetEffectiveComboState(triggerForState);
var (lastComboMove, comboTime, lastExecutedIndex) = GetEffectiveComboState(triggerForState);
lock (_getIconCache)
{
if (throttleMs > 0 && _getIconCache.TryGetValue(actionID, out var cached) &&
(now - cached.ticks) < throttleMs && cached.lastComboMove == lastComboMove)
(now - cached.ticks) < throttleMs && cached.lastComboMove == lastComboMove && cached.lastExecutedIndex == lastExecutedIndex)
return cached.value;
}
uint result = GetDisplayActionIdForIcon(combo.ActionIds, lastComboMove, comboTime);
uint result = GetDisplayActionIdForIcon(combo.ActionIds, lastComboMove, comboTime, lastExecutedIndex);
// 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);
_getIconCache[actionID] = (result, now, lastComboMove, lastExecutedIndex);
}
}
if (Service.Configuration.EnableDebugLogging)
@@ -643,7 +707,7 @@ internal unsafe sealed class IconReplacer : IDisposable
{
lock (_getIconCache)
{
_getIconCache[actionID] = (gameResult, now, gameLastCombo);
_getIconCache[actionID] = (gameResult, now, gameLastCombo, -1);
}
}
return gameResult;
@@ -656,30 +720,81 @@ internal unsafe sealed class IconReplacer : IDisposable
}
/// <summary>Returns the action id to show in the slot icon (or execute): next in sequence with RDM Enchanted resolution when applicable.</summary>
private static uint GetDisplayActionIdForIcon(IList<uint> actionIds, uint lastComboMove, float comboTime)
private static uint GetDisplayActionIdForIcon(IList<uint> actionIds, uint lastComboMove, float comboTime, int lastExecutedIndex = -1)
{
return GetResolvedNextAction(actionIds, lastComboMove, comboTime);
return GetResolvedNextAction(actionIds, lastComboMove, comboTime, lastExecutedIndex).actionId;
}
/// <summary>Next action in sequence, with Red Mage Enchanted melee (7527/7528/7529) when both mana >= 20.</summary>
private static uint GetResolvedNextAction(IList<uint> actionIds, uint lastComboMove, float comboTime)
/// <summary>Next action in sequence, with all job transforming-ability resolution applied. Returns (actionId, indexOfNextStep) for index tracking.</summary>
/// <remarks>
/// Transforming abilities supported: RDM (Enchanted melee when 20+ B/W mana), DRG (Drakesbane after F&C or WT).
/// Other jobs with similar mechanics (add resolver in ResolveTransformingAbilities when status/gauge API used):
/// WAR Inner Release→Primal Rend (when Primal Rend Ready); RPR Grim Reaping→Communio (when 1 Lemure Shroud); etc.
/// </remarks>
private static (uint actionId, int nextIndex) GetResolvedNextAction(IList<uint> actionIds, uint lastComboMove, float comboTime, int lastExecutedIndex = -1)
{
uint normalizedLast = NormalizeLastComboMoveForSequence(actionIds, lastComboMove);
uint next = GetNextActionInSequence(actionIds, normalizedLast, comboTime);
return ResolveRdmEnchanted(next);
var (nextActionId, nextIndex) = GetNextActionInSequenceWithIndex(actionIds, normalizedLast, comboTime, lastExecutedIndex);
uint resolved = ResolveTransformingAbilities(nextActionId, lastComboMove);
return (resolved, nextIndex);
}
/// <summary>For RDM melee combo, game/overlay may report Enchanted IDs (7527/7528/7529); normalize to base (7504/7512/7516) for sequence index lookup.</summary>
/// <summary>Apply all job-specific transforming-ability logic. Order does not matter (each resolver returns unchanged if not that job). Add new jobs here.</summary>
private static uint ResolveTransformingAbilities(uint actionId, uint lastComboMove)
{
uint r = actionId;
r = ResolveRdmEnchanted(r);
r = ResolveDrgDrakesbane(r, lastComboMove);
r = ResolveWarPrimalRend(r);
r = ResolveWarPrimalRuination(r);
r = ResolveRprCommunio(r);
return r;
}
/// <summary>WAR: Inner Release → Primal Rend when Primal Rend Ready.</summary>
private static uint ResolveWarPrimalRend(uint actionId)
{
if (GetCurrentJobId() != JobIdWarrior) return actionId;
if (actionId == WarInnerRelease && HasStatus(StatusPrimalRendReady)) return WarPrimalRend;
return actionId;
}
/// <summary>WAR: Primal Rend → Primal Ruination when Primal Ruination Ready (lv100).</summary>
private static uint ResolveWarPrimalRuination(uint actionId)
{
if (GetCurrentJobId() != JobIdWarrior) return actionId;
if (actionId == WarPrimalRend && HasStatus(StatusPrimalRuinationReady)) return WarPrimalRuination;
return actionId;
}
/// <summary>RPR: Grim Reaping → Communio when 1 Lemure Shroud remaining in Enshroud.</summary>
private static uint ResolveRprCommunio(uint actionId)
{
if (GetCurrentJobId() != JobIdReaper) return actionId;
try
{
var gauge = Service.JobGauges.Get<RPRGauge>();
if (gauge == null || gauge.LemureShroud != 1 || gauge.EnshroudedTimeRemaining <= 0) return actionId;
}
catch { return actionId; }
if (actionId == RprGrimReaping) return RprCommunio;
return actionId;
}
/// <summary>Normalize lastComboMove for sequence index lookup: RDM Enchanted -> base; DRG Drakesbane is handled in GetNextActionInSequenceWithIndex.</summary>
private static uint NormalizeLastComboMoveForSequence(IList<uint> actionIds, uint lastComboMove)
{
if (!IsRdmMeleeCombo(actionIds)) return lastComboMove;
return lastComboMove switch
if (IsRdmMeleeCombo(actionIds))
{
RdmEnchantedRiposte => RdmRiposte,
RdmEnchantedZwerchhau => RdmZwerchhau,
RdmEnchantedRedoublement => RdmRedoublement,
_ => lastComboMove
};
return lastComboMove switch
{
RdmEnchantedRiposte => RdmRiposte,
RdmEnchantedZwerchhau => RdmZwerchhau,
RdmEnchantedRedoublement => RdmRedoublement,
_ => lastComboMove
};
}
return lastComboMove;
}
/// <summary>When RDM and both Black/White mana >= 20, return Enchanted melee action ID for the given base action.</summary>
@@ -708,32 +823,86 @@ internal unsafe sealed class IconReplacer : IDisposable
return actionIds.Count >= 3 && actionIds[0] == RdmRiposte && actionIds[1] == RdmZwerchhau && actionIds[2] == RdmRedoublement;
}
/// <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)
private static bool IsDrgFangAndClaw(uint actionId) => actionId == DrgFangAndClaw1 || actionId == DrgFangAndClaw2;
private static bool IsDrgWheelingThrust(uint actionId) => actionId == DrgWheelingThrust1 || actionId == DrgWheelingThrust2;
/// <summary>When DRG: after Fang and Claw, Wheeling Thrust becomes Drakesbane; after Wheeling Thrust, Fang and Claw becomes Drakesbane.</summary>
private static uint ResolveDrgDrakesbane(uint actionId, uint lastComboMove)
{
if (GetCurrentJobId() != JobIdDragoon) return actionId;
if (IsDrgWheelingThrust(actionId) && IsDrgFangAndClaw(lastComboMove)) return DrgDrakesbane;
if (IsDrgFangAndClaw(actionId) && IsDrgWheelingThrust(lastComboMove)) return DrgDrakesbane;
return actionId;
}
/// <summary>Returns the next action and its index. When lastExecutedIndex >= 0 we use it (supports duplicate action IDs, e.g. Flare, Flare). Otherwise first-match by lastComboMove.</summary>
private static (uint nextActionId, int nextIndex) GetNextActionInSequenceWithIndex(IList<uint> actionIds, uint lastComboMove, float comboTime, int lastExecutedIndex = -1)
{
// Combo timer expired or no combo in progress -> show first action
if (comboTime <= 0)
return actionIds[0];
return (actionIds[0], 0);
int lastIndex = -1;
for (int i = 0; i < actionIds.Count; i++)
int lastIndex;
if (lastExecutedIndex >= 0 && lastExecutedIndex < actionIds.Count)
{
if (actionIds[i] == lastComboMove)
// We know the exact step we're at; advance by one (handles duplicate IDs).
lastIndex = lastExecutedIndex;
}
else if (lastComboMove == DrgDrakesbane)
{
lastIndex = -1;
for (int i = 0; i < actionIds.Count; i++)
{
lastIndex = i;
break;
if (IsDrgFangAndClaw(actionIds[i]) || IsDrgWheelingThrust(actionIds[i]))
lastIndex = i;
}
if (lastIndex < 0) return (actionIds[0], 0);
}
else if (lastComboMove == WarPrimalRend || lastComboMove == WarPrimalRuination)
{
lastIndex = -1;
for (int i = 0; i < actionIds.Count; i++)
{
if (actionIds[i] == WarInnerRelease || actionIds[i] == WarPrimalRend) lastIndex = i;
}
if (lastIndex < 0) return (actionIds[0], 0);
}
else if (lastComboMove == RprCommunio)
{
lastIndex = -1;
for (int i = 0; i < actionIds.Count; i++)
{
if (actionIds[i] == RprGrimReaping) lastIndex = i;
}
if (lastIndex < 0) return (actionIds[0], 0);
}
else
{
// Fallback: find first occurrence of lastComboMove (legacy / game state).
lastIndex = -1;
for (int i = 0; i < actionIds.Count; i++)
{
if (actionIds[i] == lastComboMove)
{
lastIndex = i;
break;
}
}
if (lastIndex < 0)
return (actionIds[0], 0);
}
// 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];
return (actionIds[nextIndex], nextIndex);
}
/// <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, int lastExecutedIndex = -1)
{
var (nextActionId, _) = GetNextActionInSequenceWithIndex(actionIds, lastComboMove, comboTime, lastExecutedIndex);
return nextActionId;
}
}