From c08d8dc40046ded5d246db5cef0c9704138c01e0 Mon Sep 17 00:00:00 2001 From: jorg Date: Wed, 25 Feb 2026 20:38:40 -0600 Subject: [PATCH] feat: Transforming abilities for all jobs + duplicate action ID fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- IconReplacer.cs | 295 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 232 insertions(+), 63 deletions(-) diff --git a/IconReplacer.cs b/IconReplacer.cs index 35ceebd..f8f2d28 100644 --- a/IconReplacer.cs +++ b/IconReplacer.cs @@ -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? _useActionHook; private readonly Hook? _getSlotAppearanceHook; private IntPtr _actionManager; - /// Cache last returned action per trigger actionID; only reuse when lastComboMove unchanged (so next press gets correct action). - private readonly Dictionary _getIconCache = new(); + /// 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). + private readonly Dictionary _getIconCache = new(); /// Last time we logged debug for this actionID (throttle GetIconDetour spam). private readonly Dictionary _getIconDebugLogTicks = new(); /// Combo window in ms (game updates lastComboMove after we run, so we track what we executed ourselves). private const int ComboWindowMs = 15000; - /// Per-trigger: last action we caused to be executed, when that combo window expires, and when we set it (ms). - private readonly Dictionary _ourComboState = new(); + /// 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). + private readonly Dictionary _ourComboState = new(); /// 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). private readonly Dictionary _lastStepOverlaySetTicks = new(); @@ -130,6 +131,7 @@ internal unsafe sealed class IconReplacer : IDisposable /// 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). 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; } + /// True if the local player has the given status effect (e.g. Primal Rend Ready 2070). + 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; + } + /// True if this combo applies to the player's current job (JobId 0 = all jobs, else must match). private static bool ComboAppliesToCurrentJob(UserComboDefinition combo) { @@ -153,8 +193,8 @@ internal unsafe sealed class IconReplacer : IDisposable return combo.JobId == GetCurrentJobId(); } - /// Get effective combo state for a trigger: use our overlay if we recently executed an action for this combo, else game state. - private unsafe (uint lastComboMove, float comboTime) GetEffectiveComboState(uint triggerActionId) + /// 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). + 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); } - /// Record that we just executed (or are about to execute) this action for this combo trigger. + /// Record that we just executed this action at the given step index for this combo trigger. executedIndex < 0 means unknown (no index tracking). /// 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. - 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 } /// Returns the action id to show in the slot icon (or execute): next in sequence with RDM Enchanted resolution when applicable. - private static uint GetDisplayActionIdForIcon(IList actionIds, uint lastComboMove, float comboTime) + private static uint GetDisplayActionIdForIcon(IList actionIds, uint lastComboMove, float comboTime, int lastExecutedIndex = -1) { - return GetResolvedNextAction(actionIds, lastComboMove, comboTime); + return GetResolvedNextAction(actionIds, lastComboMove, comboTime, lastExecutedIndex).actionId; } - /// Next action in sequence, with Red Mage Enchanted melee (7527/7528/7529) when both mana >= 20. - private static uint GetResolvedNextAction(IList actionIds, uint lastComboMove, float comboTime) + /// Next action in sequence, with all job transforming-ability resolution applied. Returns (actionId, indexOfNextStep) for index tracking. + /// + /// 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. + /// + private static (uint actionId, int nextIndex) GetResolvedNextAction(IList 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); } - /// For RDM melee combo, game/overlay may report Enchanted IDs (7527/7528/7529); normalize to base (7504/7512/7516) for sequence index lookup. + /// Apply all job-specific transforming-ability logic. Order does not matter (each resolver returns unchanged if not that job). Add new jobs here. + 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; + } + + /// WAR: Inner Release → Primal Rend when Primal Rend Ready. + private static uint ResolveWarPrimalRend(uint actionId) + { + if (GetCurrentJobId() != JobIdWarrior) return actionId; + if (actionId == WarInnerRelease && HasStatus(StatusPrimalRendReady)) return WarPrimalRend; + return actionId; + } + + /// WAR: Primal Rend → Primal Ruination when Primal Ruination Ready (lv100). + private static uint ResolveWarPrimalRuination(uint actionId) + { + if (GetCurrentJobId() != JobIdWarrior) return actionId; + if (actionId == WarPrimalRend && HasStatus(StatusPrimalRuinationReady)) return WarPrimalRuination; + return actionId; + } + + /// RPR: Grim Reaping → Communio when 1 Lemure Shroud remaining in Enshroud. + private static uint ResolveRprCommunio(uint actionId) + { + if (GetCurrentJobId() != JobIdReaper) return actionId; + try + { + var gauge = Service.JobGauges.Get(); + if (gauge == null || gauge.LemureShroud != 1 || gauge.EnshroudedTimeRemaining <= 0) return actionId; + } + catch { return actionId; } + if (actionId == RprGrimReaping) return RprCommunio; + return actionId; + } + + /// Normalize lastComboMove for sequence index lookup: RDM Enchanted -> base; DRG Drakesbane is handled in GetNextActionInSequenceWithIndex. private static uint NormalizeLastComboMoveForSequence(IList 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; } /// When RDM and both Black/White mana >= 20, return Enchanted melee action ID for the given base action. @@ -708,32 +823,86 @@ internal unsafe sealed class IconReplacer : IDisposable return actionIds.Count >= 3 && actionIds[0] == RdmRiposte && actionIds[1] == RdmZwerchhau && actionIds[2] == RdmRedoublement; } - /// Returns the next action in the sequence. Resets to step 1 after the last step or when combo expired / another action was used. - private static uint GetNextActionInSequence(IList 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; + + /// When DRG: after Fang and Claw, Wheeling Thrust becomes Drakesbane; after Wheeling Thrust, Fang and Claw becomes Drakesbane. + 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; + } + + /// 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. + private static (uint nextActionId, int nextIndex) GetNextActionInSequenceWithIndex(IList 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); + } + + /// Returns the next action in the sequence. Resets to step 1 after the last step or when combo expired / another action was used. + private static uint GetNextActionInSequence(IList actionIds, uint lastComboMove, float comboTime, int lastExecutedIndex = -1) + { + var (nextActionId, _) = GetNextActionInSequenceWithIndex(actionIds, lastComboMove, comboTime, lastExecutedIndex); + return nextActionId; } }