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:
+221
-52
@@ -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 < 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,23 +720,72 @@ 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;
|
||||
if (IsRdmMeleeCombo(actionIds))
|
||||
{
|
||||
return lastComboMove switch
|
||||
{
|
||||
RdmEnchantedRiposte => RdmRiposte,
|
||||
@@ -681,6 +794,8 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
_ => lastComboMove
|
||||
};
|
||||
}
|
||||
return lastComboMove;
|
||||
}
|
||||
|
||||
/// <summary>When RDM and both Black/White mana >= 20, return Enchanted melee action ID for the given base action.</summary>
|
||||
private static uint ResolveRdmEnchanted(uint actionId)
|
||||
@@ -708,14 +823,63 @@ 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;
|
||||
int lastIndex;
|
||||
if (lastExecutedIndex >= 0 && lastExecutedIndex < actionIds.Count)
|
||||
{
|
||||
// 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++)
|
||||
{
|
||||
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)
|
||||
@@ -724,16 +888,21 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
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];
|
||||
return (actionIds[0], 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user