fix(RDM): Use Enchanted melee actions when Black & White mana ≥ 20 #1
@@ -16,10 +16,7 @@
|
||||
<AssemblyName>ConfigurableCombo</AssemblyName>
|
||||
<Version>1.0.0.0</Version>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<DalamudLibPath>$(appdata)\XIVLauncher\addon\Hooks\dev\</DalamudLibPath>
|
||||
</PropertyGroup>
|
||||
<!-- FFXIVClientStructs comes from Dalamud dev folder; do not add a second reference or build fails with CS1704 -->
|
||||
<!-- DalamudLibPath: SDK picks ~/.xlcore/dalamud/Hooks/dev/ on Linux, %AppData%\XIVLauncher\addon\Hooks\dev\ on Windows; use DALAMUD_HOME env to override -->
|
||||
<ItemGroup>
|
||||
<Content Include="ConfigurableCombo.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
+293
-52
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
@@ -27,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();
|
||||
|
||||
@@ -129,12 +131,61 @@ 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;
|
||||
private const uint RdmZwerchhau = 7512;
|
||||
private const uint RdmRedoublement = 7516;
|
||||
private const uint RdmEnchantedRiposte = 7527;
|
||||
private const uint RdmEnchantedZwerchhau = 7528;
|
||||
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)
|
||||
{
|
||||
@@ -142,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)
|
||||
@@ -156,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;
|
||||
@@ -172,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);
|
||||
@@ -196,9 +247,15 @@ 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);
|
||||
}
|
||||
if (lastStepActionId.HasValue && executedActionId == lastStepActionId.Value)
|
||||
bool isLastStep = lastStepActionId.HasValue && (executedActionId == lastStepActionId.Value ||
|
||||
(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)
|
||||
{
|
||||
@@ -238,7 +295,12 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
if (!combo.Enabled || combo.ActionIds.Count == 0 || !ComboAppliesToCurrentJob(combo))
|
||||
continue;
|
||||
uint trigger = combo.ActionIds[0];
|
||||
if (!combo.ActionIds.Contains(executedActionId))
|
||||
bool actionInCombo = combo.ActionIds.Contains(executedActionId) ||
|
||||
(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);
|
||||
lock (_getIconCache) { _getIconCache.Remove(trigger); }
|
||||
@@ -270,7 +332,11 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
continue;
|
||||
if (!ComboAppliesToCurrentJob(combo))
|
||||
continue;
|
||||
if (combo.ActionIds[0] != actionId)
|
||||
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;
|
||||
matchedTrigger = combo.ActionIds[0];
|
||||
@@ -286,10 +352,12 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
// 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);
|
||||
uint triggerForState = matchedTrigger;
|
||||
var (lastComboMove, comboTime, lastExecutedIndex) = GetEffectiveComboState(triggerForState);
|
||||
effectiveLastCombo = lastComboMove;
|
||||
effectiveComboTime = comboTime;
|
||||
replaceWith = GetNextActionInSequence(matchedCombo.ActionIds, lastComboMove, comboTime);
|
||||
var (replaceWithAction, nextIndex) = GetResolvedNextAction(matchedCombo.ActionIds, lastComboMove, comboTime, lastExecutedIndex);
|
||||
replaceWith = replaceWithAction;
|
||||
|
||||
if (Service.Configuration.EnableDebugLogging && replaceWith != 0)
|
||||
{
|
||||
@@ -309,7 +377,8 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
// 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)
|
||||
bool isStep1 = replaceWith == matchedTrigger || (IsRdmMeleeCombo(matchedCombo.ActionIds) && replaceWith == RdmEnchantedRiposte);
|
||||
if (isStep1)
|
||||
{
|
||||
lock (_lastStepOverlaySetTicks)
|
||||
{
|
||||
@@ -337,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
|
||||
@@ -406,7 +475,8 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
}
|
||||
|
||||
uint triggerActionId = slot->CommandId;
|
||||
var (lastComboMove, comboTime) = GetEffectiveComboState(triggerActionId);
|
||||
uint triggerForState = triggerActionId;
|
||||
var (lastComboMove, comboTime, lastExecutedIndex) = GetEffectiveComboState(triggerForState);
|
||||
uint nextActionId = 0;
|
||||
int comboStepCount = 0;
|
||||
foreach (var combo in Service.Configuration.UserCombos)
|
||||
@@ -415,10 +485,19 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
continue;
|
||||
if (!ComboAppliesToCurrentJob(combo))
|
||||
continue;
|
||||
if (combo.ActionIds[0] != triggerActionId)
|
||||
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, lastExecutedIndexInner) = GetEffectiveComboState(triggerForState);
|
||||
lastComboMove = lastComboMoveInner;
|
||||
comboTime = comboTimeInner;
|
||||
lastExecutedIndex = lastExecutedIndexInner;
|
||||
comboStepCount = combo.ActionIds.Count;
|
||||
nextActionId = GetNextActionInSequence(combo.ActionIds, lastComboMove, comboTime);
|
||||
nextActionId = GetResolvedNextAction(combo.ActionIds, lastComboMove, comboTime, lastExecutedIndex).actionId;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -452,11 +531,16 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
continue;
|
||||
if (!ComboAppliesToCurrentJob(combo))
|
||||
continue;
|
||||
if (combo.ActionIds[0] != triggerActionId)
|
||||
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;
|
||||
|
||||
var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerActionId);
|
||||
uint nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime);
|
||||
uint triggerForState = combo.ActionIds[0];
|
||||
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.
|
||||
@@ -523,10 +607,15 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
continue;
|
||||
if (!ComboAppliesToCurrentJob(combo))
|
||||
continue;
|
||||
if (combo.ActionIds[0] != triggerActionId)
|
||||
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;
|
||||
var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerActionId);
|
||||
nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime);
|
||||
uint triggerForState = combo.ActionIds[0];
|
||||
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;
|
||||
@@ -566,22 +655,27 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
continue;
|
||||
if (!ComboAppliesToCurrentJob(combo))
|
||||
continue;
|
||||
if (actionID != combo.ActionIds[0])
|
||||
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;
|
||||
var (lastComboMove, comboTime) = GetEffectiveComboState(actionID);
|
||||
uint triggerForState = combo.ActionIds[0];
|
||||
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)
|
||||
@@ -613,7 +707,7 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
{
|
||||
lock (_getIconCache)
|
||||
{
|
||||
_getIconCache[actionID] = (gameResult, now, gameLastCombo);
|
||||
_getIconCache[actionID] = (gameResult, now, gameLastCombo, -1);
|
||||
}
|
||||
}
|
||||
return gameResult;
|
||||
@@ -625,25 +719,167 @@ internal unsafe sealed class IconReplacer : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
/// <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, int lastExecutedIndex = -1)
|
||||
{
|
||||
return GetNextActionInSequence(actionIds, lastComboMove, comboTime);
|
||||
return GetResolvedNextAction(actionIds, lastComboMove, comboTime, lastExecutedIndex).actionId;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
/// <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);
|
||||
var (nextActionId, nextIndex) = GetNextActionInSequenceWithIndex(actionIds, normalizedLast, comboTime, lastExecutedIndex);
|
||||
uint resolved = ResolveTransformingAbilities(nextActionId, lastComboMove);
|
||||
return (resolved, nextIndex);
|
||||
}
|
||||
|
||||
/// <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 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>
|
||||
private static uint ResolveRdmEnchanted(uint actionId)
|
||||
{
|
||||
if (GetCurrentJobId() != JobIdRedMage) return actionId;
|
||||
try
|
||||
{
|
||||
var gauge = Service.JobGauges.Get<RDMGauge>();
|
||||
if (gauge == null || gauge.WhiteMana < RdmEnchantedManaThreshold || gauge.BlackMana < RdmEnchantedManaThreshold)
|
||||
return actionId;
|
||||
}
|
||||
catch { return actionId; }
|
||||
|
||||
return actionId switch
|
||||
{
|
||||
RdmRiposte => RdmEnchantedRiposte,
|
||||
RdmZwerchhau => RdmEnchantedZwerchhau,
|
||||
RdmRedoublement => RdmEnchantedRedoublement,
|
||||
_ => actionId
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsRdmMeleeCombo(IList<uint> actionIds)
|
||||
{
|
||||
return actionIds.Count >= 3 && actionIds[0] == RdmRiposte && actionIds[1] == RdmZwerchhau && actionIds[2] == RdmRedoublement;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -652,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,4 +19,5 @@ internal class Service
|
||||
[PluginService] internal static IPluginLog PluginLog { get; private set; } = null!;
|
||||
[PluginService] internal static ITextureProvider TextureProvider { get; private set; } = null!;
|
||||
[PluginService] internal static IFramework Framework { get; private set; } = null!;
|
||||
[PluginService] internal static IJobGauges JobGauges { get; private set; } = null!;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user