7c174efa70
- Per-combo reset when: Game timer (default), Never, On target change, After X seconds. Never = cycle sequence without time/target reset. After seconds uses configurable 0.5–300s window. - Per-combo 'Preserve position when using other actions' (default on): when on, using another combo or any ability never resets this combo's step; it only resets per its own 'Reset when' setting. When off (original), using an action not in this combo resets it to step 1. - Optional ITargetManager via constructor for On target change; plugin loads if service unavailable (On target change no-op when null). Made-with: Cursor
988 lines
52 KiB
C#
988 lines
52 KiB
C#
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;
|
|
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
|
|
|
namespace ConfigurableCombo;
|
|
|
|
/// <summary>
|
|
/// Icon/combo handling: same two hooks as WrathCombo/XIVCombo (GetAdjustedActionId + IsActionIdReplaceable),
|
|
/// plus optional Framework.Update hotbar refresh and UseAction/ExecuteSlotById for custom combos.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// WrathCombo does it with ONLY these two hooks (no Framework.Update, no ExecuteSlotById, no UseAction).
|
|
/// They throttle the GetAdjustedAction detour (e.g. 50ms) and cache the result per actionID so the game
|
|
/// gets a stable "next action" for both icon and execution. We do the same throttling; we also add
|
|
/// Framework.Update (ApparentActionId + LoadIconId) and UseAction/ExecuteSlotById so custom sequences
|
|
/// that the game never asks GetAdjustedActionId for still work.
|
|
/// </remarks>
|
|
internal unsafe sealed class IconReplacer : IDisposable
|
|
{
|
|
private readonly Hook<GetIconDelegate> _getIconHook;
|
|
private readonly Hook<IsIconReplaceableDelegate> _isIconReplaceableHook;
|
|
private readonly Hook<ExecuteSlotByIdDelegate>? _executeSlotByIdHook;
|
|
private readonly Hook<UseActionDelegate>? _useActionHook;
|
|
private readonly Hook<GetSlotAppearanceDelegate>? _getSlotAppearanceHook;
|
|
private IntPtr _actionManager;
|
|
/// <summary>Cache last returned action per trigger actionID; only reuse when lastComboMove 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, 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();
|
|
/// <summary>Per-trigger: last target ObjectId when combo uses ResetMode.OnTargetChange; reset when target changes.</summary>
|
|
private readonly Dictionary<uint, ulong> _ourComboStateTarget = new();
|
|
|
|
/// <summary>Serializes combo replacement so rapid UseAction calls (mashing) don't all read state before any update; second caller waits and sees updated overlay.</summary>
|
|
private readonly object _useActionComboLock = new();
|
|
|
|
/// <summary>Recast group ID for the global cooldown (weaponskills/spells).</summary>
|
|
private const int GcdRecastGroupId = 57;
|
|
|
|
private delegate uint GetIconDelegate(IntPtr actionManager, uint actionID);
|
|
private delegate ulong IsIconReplaceableDelegate(uint actionID);
|
|
private delegate byte ExecuteSlotByIdDelegate(IntPtr hotbarModule, uint hotbarId, uint slotId);
|
|
private delegate bool UseActionDelegate(IntPtr actionManager, ActionType actionType, uint actionId, ulong targetId, uint extraParam, uint mode, uint comboRouteId, bool* outOptAreaTargeted);
|
|
private delegate uint GetSlotAppearanceDelegate(RaptureHotbarModule.HotbarSlotType* actionType, uint* actionId, ushort* UNK_0xC4, RaptureHotbarModule* hotbarModule, RaptureHotbarModule.HotbarSlot* slot);
|
|
|
|
public IconReplacer(IGameInteropProvider gameInteropProvider)
|
|
{
|
|
// XIVCombo: hook GetAdjustedActionId at ActionManager.Addresses.GetAdjustedActionId and
|
|
// IsActionIdReplaceable via signature scan. Same addresses here for compatibility.
|
|
var getAdjustedActionIdAddress = (IntPtr)ActionManager.Addresses.GetAdjustedActionId.Value;
|
|
_getIconHook = gameInteropProvider.HookFromAddress<GetIconDelegate>(getAdjustedActionIdAddress, GetIconDetour);
|
|
_isIconReplaceableHook = gameInteropProvider.HookFromAddress<IsIconReplaceableDelegate>(Service.Address.IsActionIdReplaceable, _ => 1);
|
|
_getIconHook.Enable();
|
|
_isIconReplaceableHook.Enable();
|
|
|
|
if (Service.Address.ExecuteSlotById != IntPtr.Zero)
|
|
{
|
|
var execHook = gameInteropProvider.HookFromAddress<ExecuteSlotByIdDelegate>(Service.Address.ExecuteSlotById, ExecuteSlotByIdDetour);
|
|
execHook.Enable();
|
|
_executeSlotByIdHook = execHook;
|
|
}
|
|
else
|
|
{
|
|
_executeSlotByIdHook = null;
|
|
Service.PluginLog.Warning("ConfigurableCombo: ExecuteSlotById signature not found; custom combos may not execute.");
|
|
}
|
|
|
|
if (Service.Address.UseAction != IntPtr.Zero)
|
|
{
|
|
var useHook = gameInteropProvider.HookFromAddress<UseActionDelegate>(Service.Address.UseAction, UseActionDetour);
|
|
useHook.Enable();
|
|
_useActionHook = useHook;
|
|
}
|
|
else
|
|
{
|
|
_useActionHook = null;
|
|
Service.PluginLog.Warning("ConfigurableCombo: UseAction signature not found; custom combo execution fallback unavailable.");
|
|
}
|
|
|
|
if (Service.Address.GetSlotAppearance != IntPtr.Zero)
|
|
{
|
|
var slotAppearanceHook = gameInteropProvider.HookFromAddress<GetSlotAppearanceDelegate>(Service.Address.GetSlotAppearance, GetSlotAppearanceDetour);
|
|
slotAppearanceHook.Enable();
|
|
_getSlotAppearanceHook = slotAppearanceHook;
|
|
}
|
|
else
|
|
{
|
|
_getSlotAppearanceHook = null;
|
|
Service.PluginLog.Warning("ConfigurableCombo: GetSlotAppearance signature not found; default hotbar icon may not update.");
|
|
}
|
|
|
|
Service.Framework.Update += OnFrameworkUpdate;
|
|
|
|
var comboCount = Service.Configuration.UserCombos?.Count(c => c.Enabled && c.ActionIds.Count > 0) ?? 0;
|
|
foreach (var c in Service.Configuration.UserCombos ?? new List<UserComboDefinition>())
|
|
{
|
|
if (c.Enabled && c.ActionIds.Count == 1)
|
|
Service.PluginLog.Warning("ConfigurableCombo: Combo \"{0}\" has only 1 action (trigger {1}). Add a second action so the combo can advance.", c.Name, c.ActionIds[0]);
|
|
}
|
|
Service.PluginLog.Information(
|
|
"ConfigurableCombo: Hooks installed. GetAdjustedActionId=on, IsActionIdReplaceable=on, ExecuteSlotById={0}, UseAction={1}, GetSlotAppearance={2}. Active combos: {3}. DebugLogging={4}.",
|
|
_executeSlotByIdHook != null ? "on" : "NOT FOUND",
|
|
_useActionHook != null ? "on" : "NOT FOUND",
|
|
_getSlotAppearanceHook != null ? "on" : "NOT FOUND",
|
|
comboCount,
|
|
Service.Configuration.EnableDebugLogging);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Service.Framework.Update -= OnFrameworkUpdate;
|
|
_getSlotAppearanceHook?.Dispose();
|
|
_useActionHook?.Dispose();
|
|
_executeSlotByIdHook?.Dispose();
|
|
_getIconHook?.Dispose();
|
|
_isIconReplaceableHook?.Dispose();
|
|
}
|
|
|
|
/// <summary>Ignore overlay for 200ms when lastExecuted==trigger so UseAction doesn't replace with next on the same physical press. If raw game state is still (0,0), use overlay so button mashers still advance.</summary>
|
|
private const int OverlayIgnoreSameTriggerMs = 200;
|
|
/// <summary>After setting overlay to the last step (e.g. 2254), don't allow flipping back to step 1 for this long — keeps icon on last step until GCD has finished so the next cast is in the right order. One full GCD + buffer (base 2.5s, can be lower with SkS).</summary>
|
|
private const int MinMsBeforeOverlayStep1AfterLastStep = 3500;
|
|
|
|
// === 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)
|
|
{
|
|
if (combo.JobId == 0) return true;
|
|
return combo.JobId == GetCurrentJobId();
|
|
}
|
|
|
|
/// <summary>Get the first enabled combo for this trigger that applies to the current job.</summary>
|
|
private static UserComboDefinition? GetComboForTrigger(uint triggerActionId)
|
|
{
|
|
foreach (var combo in Service.Configuration.UserCombos ?? new List<UserComboDefinition>())
|
|
{
|
|
if (!combo.Enabled || combo.ActionIds.Count == 0 || combo.ActionIds[0] != triggerActionId)
|
|
continue;
|
|
if (!ComboAppliesToCurrentJob(combo))
|
|
continue;
|
|
return combo;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Combo window in ms from combo settings. Never/OnTargetChange use a long window; expiry is overridden by target check for OnTargetChange.</summary>
|
|
private static int GetComboWindowMs(UserComboDefinition? combo)
|
|
{
|
|
if (combo == null) return ComboWindowMs;
|
|
return combo.ResetMode switch
|
|
{
|
|
ComboResetMode.Never => 86400000, // 24h so we effectively never expire from time
|
|
ComboResetMode.OnTargetChange => 86400000,
|
|
ComboResetMode.AfterSeconds => (int)(Math.Clamp(combo.ResetAfterSeconds, 0.5f, 300f) * 1000),
|
|
_ => ComboWindowMs
|
|
};
|
|
}
|
|
|
|
private static ulong GetCurrentTargetObjectId()
|
|
{
|
|
try
|
|
{
|
|
var tm = Service.TargetManager;
|
|
if (tm == null) return 0;
|
|
var target = tm.Target;
|
|
return target?.GameObjectId ?? 0;
|
|
}
|
|
catch { return 0; }
|
|
}
|
|
|
|
/// <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). Respects per-combo ResetMode (Never, OnTargetChange, AfterSeconds).</summary>
|
|
private unsafe (uint lastComboMove, float comboTime, int lastExecutedIndex) GetEffectiveComboState(uint triggerActionId)
|
|
{
|
|
long now = Environment.TickCount64;
|
|
var combo = GetComboForTrigger(triggerActionId);
|
|
|
|
lock (_ourComboState)
|
|
{
|
|
if (_ourComboState.TryGetValue(triggerActionId, out var our) && now < our.expiryTicks)
|
|
{
|
|
if (combo?.ResetMode == ComboResetMode.OnTargetChange && Service.TargetManager != null)
|
|
{
|
|
ulong currentTarget = GetCurrentTargetObjectId();
|
|
if (_ourComboStateTarget.TryGetValue(triggerActionId, out ulong lastTarget) && currentTarget != lastTarget)
|
|
{
|
|
_ourComboState.Remove(triggerActionId);
|
|
_ourComboStateTarget.Remove(triggerActionId);
|
|
lock (_getIconCache) { _getIconCache.Remove(triggerActionId); }
|
|
goto useRaw;
|
|
}
|
|
}
|
|
if (our.lastExecutedActionId == triggerActionId && (now - our.setTicks) < OverlayIgnoreSameTriggerMs)
|
|
{
|
|
var rawMove = *(uint*)Service.Address.LastComboMove;
|
|
var rawTime = *(float*)Service.Address.ComboTimer;
|
|
if (rawTime > 0 || rawMove != 0)
|
|
return (rawMove, rawTime, -1);
|
|
}
|
|
float remaining = (our.expiryTicks - now) / 1000f;
|
|
return (our.lastExecutedActionId, remaining, our.lastExecutedIndex);
|
|
}
|
|
}
|
|
useRaw:
|
|
var rawMoveFinal = *(uint*)Service.Address.LastComboMove;
|
|
var rawTimeFinal = *(float*)Service.Address.ComboTimer;
|
|
if (rawMoveFinal == 0 && rawTimeFinal <= 0)
|
|
{
|
|
lock (_ourComboState)
|
|
{
|
|
if (_ourComboState.TryGetValue(triggerActionId, out var fallback) && now < fallback.expiryTicks)
|
|
{
|
|
if (combo?.ResetMode == ComboResetMode.OnTargetChange && Service.TargetManager != null)
|
|
{
|
|
ulong currentTarget = GetCurrentTargetObjectId();
|
|
if (_ourComboStateTarget.TryGetValue(triggerActionId, out ulong lastTarget) && currentTarget != lastTarget)
|
|
{
|
|
_ourComboState.Remove(triggerActionId);
|
|
_ourComboStateTarget.Remove(triggerActionId);
|
|
lock (_getIconCache) { _getIconCache.Remove(triggerActionId); }
|
|
}
|
|
else
|
|
{
|
|
float remaining = (fallback.expiryTicks - now) / 1000f;
|
|
return (fallback.lastExecutedActionId, remaining, fallback.lastExecutedIndex);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
float remaining = (fallback.expiryTicks - now) / 1000f;
|
|
return (fallback.lastExecutedActionId, remaining, fallback.lastExecutedIndex);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return (rawMoveFinal, rawTimeFinal, -1);
|
|
}
|
|
|
|
/// <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>
|
|
/// <param name="combo">When set, combo window and target tracking use this combo's ResetMode / ResetAfterSeconds.</param>
|
|
/// <param name="targetId">When combo uses OnTargetChange, we store this so the combo resets when target changes.</param>
|
|
private void SetOurComboState(uint triggerActionId, uint executedActionId, int executedIndex = -1, bool optimistic = false, UserComboDefinition? combo = null, ulong targetId = 0)
|
|
{
|
|
long now = Environment.TickCount64;
|
|
int windowMs = GetComboWindowMs(combo);
|
|
uint? lastStepActionId = GetLastStepActionIdForTrigger(triggerActionId);
|
|
lock (_ourComboState)
|
|
{
|
|
long setTicks = now;
|
|
if (executedActionId == triggerActionId)
|
|
{
|
|
if (optimistic)
|
|
setTicks = now - OverlayIgnoreSameTriggerMs - 50;
|
|
else if (_ourComboState.TryGetValue(triggerActionId, out var prev) && prev.lastExecutedActionId == triggerActionId)
|
|
setTicks = prev.setTicks;
|
|
}
|
|
_ourComboState[triggerActionId] = (executedActionId, executedIndex, now + windowMs, setTicks);
|
|
if (combo?.ResetMode == ComboResetMode.OnTargetChange)
|
|
_ourComboStateTarget[triggerActionId] = targetId;
|
|
}
|
|
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)
|
|
{
|
|
_lastStepOverlaySetTicks[triggerActionId] = now;
|
|
}
|
|
}
|
|
if (Service.Configuration.EnableDebugLogging)
|
|
Service.PluginLog.Information("ConfigurableCombo: SetOurComboState trigger={0} executed={1}", triggerActionId, executedActionId);
|
|
lock (_getIconCache)
|
|
{
|
|
_getIconCache.Remove(triggerActionId);
|
|
}
|
|
}
|
|
|
|
/// <summary>Returns the last (final) action ID in the combo that starts with triggerActionId, or null if not found.</summary>
|
|
private static uint? GetLastStepActionIdForTrigger(uint triggerActionId)
|
|
{
|
|
foreach (var combo in Service.Configuration.UserCombos ?? new List<UserComboDefinition>())
|
|
{
|
|
if (!combo.Enabled || combo.ActionIds.Count == 0 || combo.ActionIds[0] != triggerActionId)
|
|
continue;
|
|
if (!ComboAppliesToCurrentJob(combo))
|
|
continue;
|
|
return combo.ActionIds[combo.ActionIds.Count - 1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>For each combo with PreservePositionWhenUsingOtherActions false (original): clear overlay when the executed action is not in that combo. Combos with it true keep their position.</summary>
|
|
private void ClearOverlayIfActionNotInCombo(uint executedActionId)
|
|
{
|
|
if (executedActionId == 0) return;
|
|
lock (_ourComboState)
|
|
{
|
|
foreach (var combo in Service.Configuration.UserCombos)
|
|
{
|
|
if (!combo.Enabled || combo.ActionIds.Count == 0 || !ComboAppliesToCurrentJob(combo))
|
|
continue;
|
|
if (combo.PreservePositionWhenUsingOtherActions)
|
|
continue; // per-combo: don't clear this combo's position when using other actions
|
|
uint trigger = combo.ActionIds[0];
|
|
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);
|
|
_ourComboStateTarget.Remove(trigger);
|
|
lock (_getIconCache) { _getIconCache.Remove(trigger); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Intercept at the moment the game uses an action. Replace trigger action with next in our combo so custom combos execute.
|
|
/// This runs for every action use (hotbar, macro, etc.) so we only replace when actionId is a combo trigger.
|
|
/// </summary>
|
|
private bool UseActionDetour(IntPtr actionManager, ActionType actionType, uint actionId, ulong targetId, uint extraParam, uint mode, uint comboRouteId, bool* outOptAreaTargeted)
|
|
{
|
|
if (!Service.Configuration.EnablePlugin || Service.ClientState.LocalPlayer == null)
|
|
return _useActionHook!.Original(actionManager, actionType, actionId, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
|
|
|
|
if (actionType != ActionType.Action)
|
|
return _useActionHook!.Original(actionManager, actionType, actionId, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
|
|
|
|
uint replaceWith = 0;
|
|
uint matchedTrigger = 0;
|
|
uint effectiveLastCombo = 0;
|
|
float effectiveComboTime = 0f;
|
|
UserComboDefinition? matchedCombo = null;
|
|
foreach (var combo in Service.Configuration.UserCombos)
|
|
{
|
|
if (!combo.Enabled || combo.ActionIds.Count == 0)
|
|
continue;
|
|
if (!ComboAppliesToCurrentJob(combo))
|
|
continue;
|
|
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];
|
|
break;
|
|
}
|
|
|
|
if (matchedCombo == null)
|
|
{
|
|
return _useActionHook!.Original(actionManager, actionType, actionId, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
|
|
}
|
|
|
|
// Hold lock for the entire combo path: read state → execute → update state only on success.
|
|
// This serializes mashing so only one action runs at a time and overlay advances only when the game actually executes.
|
|
lock (_useActionComboLock)
|
|
{
|
|
uint triggerForState = matchedTrigger;
|
|
var (lastComboMove, comboTime, lastExecutedIndex) = GetEffectiveComboState(triggerForState);
|
|
effectiveLastCombo = lastComboMove;
|
|
effectiveComboTime = comboTime;
|
|
var (replaceWithAction, nextIndex) = GetResolvedNextAction(matchedCombo.ActionIds, lastComboMove, comboTime, lastExecutedIndex);
|
|
replaceWith = replaceWithAction;
|
|
|
|
if (Service.Configuration.EnableDebugLogging && replaceWith != 0)
|
|
{
|
|
Service.PluginLog.Information(
|
|
"ConfigurableCombo: UseAction(actionId={0}, type={1}) effective lastComboMove={2} comboTime={3:F2} => replaceWith={4}",
|
|
actionId, actionType, effectiveLastCombo, effectiveComboTime, replaceWith);
|
|
}
|
|
|
|
if (replaceWith == 0)
|
|
{
|
|
bool ok = _useActionHook!.Original(actionManager, actionType, actionId, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
|
|
if (ok)
|
|
ClearOverlayIfActionNotInCombo(actionId);
|
|
return ok;
|
|
}
|
|
|
|
// When mashing: block sending step 1 (trigger) only while GCD is still active after we set overlay to the last step, so we allow execution as soon as GCD ends. Cap with timestamp so we don't block forever if GCD isn't available.
|
|
var am = ActionManager.Instance();
|
|
long now = Environment.TickCount64;
|
|
bool isStep1 = replaceWith == matchedTrigger || (IsRdmMeleeCombo(matchedCombo.ActionIds) && replaceWith == RdmEnchantedRiposte);
|
|
if (isStep1)
|
|
{
|
|
lock (_lastStepOverlaySetTicks)
|
|
{
|
|
if (_lastStepOverlaySetTicks.TryGetValue(matchedTrigger, out long lastSet) &&
|
|
(now - lastSet) < MinMsBeforeOverlayStep1AfterLastStep &&
|
|
am != null && IsGcdActive(am))
|
|
{
|
|
if (Service.Configuration.EnableDebugLogging)
|
|
Service.PluginLog.Information("ConfigurableCombo: UseAction SKIP (block step 1 while GCD active after last step) replaceWith={0}", replaceWith);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the replacement action is a GCD action and is on cooldown, don't call Original() — it would fail and we'd spam. oGCD actions (e.g. Ninja handsigns) use other recast groups; let the game handle their cooldown and only skip when we know it's GCD.
|
|
if (am != null && ActionUsesGcd(am, replaceWith) && IsActionOnCooldown(am, replaceWith))
|
|
{
|
|
if (Service.Configuration.EnableDebugLogging)
|
|
Service.PluginLog.Information("ConfigurableCombo: UseAction SKIP (GCD on cooldown) replaceWith={0}", replaceWith);
|
|
return false;
|
|
}
|
|
|
|
Service.PluginLog.Information("ConfigurableCombo: UseAction REPLACING {0} -> {1}", actionId, replaceWith);
|
|
uint executedActionId = replaceWith;
|
|
bool result = _useActionHook!.Original(actionManager, actionType, replaceWith, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
|
|
if (result)
|
|
{
|
|
SetOurComboState(matchedTrigger, executedActionId, nextIndex, optimistic: false, combo: matchedCombo, targetId: targetId);
|
|
ClearOverlayIfActionNotInCombo(executedActionId);
|
|
}
|
|
// When the action fails (GCD, range, etc.) do NOT clear the overlay: leave last successful state
|
|
// so the next press retries the same replacement (e.g. 2254) instead of resetting to (0,0) and sending 2242 again (stuck on step 1).
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/// <summary>True if the action uses the global cooldown (recast group 57). oGCD abilities use other recast groups; we only skip execution when on cooldown for GCD actions.</summary>
|
|
private static unsafe bool ActionUsesGcd(ActionManager* am, uint actionId)
|
|
=> am != null && am->GetRecastGroup((int)ActionType.Action, actionId) == GcdRecastGroupId;
|
|
|
|
/// <summary>True if the global cooldown (recast group 57) is currently active. Used so we only hold the "last step" icon / block step 1 while GCD is actually running, then allow as soon as GCD ends.</summary>
|
|
private static unsafe bool IsGcdActive(ActionManager* am)
|
|
{
|
|
if (am == null) return false;
|
|
var gcd = am->GetRecastGroupDetail(GcdRecastGroupId);
|
|
return gcd != null && gcd->IsActive && gcd->Elapsed < gcd->Total;
|
|
}
|
|
|
|
/// <summary>True if the action is currently on cooldown (recast). When true, calling UseAction would fail; skip it so we don't spam. Checks GCD group 57 first (reliable for weaponskills), then per-action recast.</summary>
|
|
private static unsafe bool IsActionOnCooldown(ActionManager* am, uint actionId)
|
|
{
|
|
if (am == null) return false;
|
|
// Combo actions are typically GCD; check recast group 57 (global cooldown) first.
|
|
if (IsGcdActive(am))
|
|
return true;
|
|
if (am->IsRecastTimerActive(ActionType.Action, actionId))
|
|
return true;
|
|
float total = am->GetRecastTime(ActionType.Action, actionId);
|
|
float elapsed = am->GetRecastTimeElapsed(ActionType.Action, actionId);
|
|
return total > 0.001f && elapsed < total;
|
|
}
|
|
|
|
/// <summary>Clear overlay for a single combo trigger (e.g. when UseAction failed so our optimistic update was wrong).</summary>
|
|
private void ClearComboOverlayForTrigger(uint triggerActionId)
|
|
{
|
|
lock (_ourComboState)
|
|
{
|
|
_ourComboState.Remove(triggerActionId);
|
|
_ourComboStateTarget.Remove(triggerActionId);
|
|
}
|
|
lock (_getIconCache) { _getIconCache.Remove(triggerActionId); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// When the player executes a hotbar slot that is one of our combo triggers, we do NOT swap the slot here.
|
|
/// The game will call UseAction(slot->CommandId) = UseAction(trigger). Our UseAction detour then
|
|
/// replaces trigger -> next and only calls SetOurComboState when the action actually succeeds (return true).
|
|
/// This prevents the icon from advancing when the action fails (GCD, out of range, etc.).
|
|
/// </summary>
|
|
private unsafe byte ExecuteSlotByIdDetour(IntPtr hotbarModule, uint hotbarId, uint slotId)
|
|
{
|
|
if (Service.Configuration.EnableDebugLogging)
|
|
Service.PluginLog.Information("ConfigurableCombo: ExecuteSlotById INVOKED bar={0} slot={1}", hotbarId, slotId);
|
|
|
|
if (!Service.Configuration.EnablePlugin || Service.ClientState.LocalPlayer == null)
|
|
return _executeSlotByIdHook!.Original(hotbarModule, hotbarId, slotId);
|
|
|
|
var module = (RaptureHotbarModule*)hotbarModule;
|
|
var slot = module->GetSlotById(hotbarId, slotId);
|
|
if (slot == null || slot->CommandType != RaptureHotbarModule.HotbarSlotType.Action)
|
|
{
|
|
if (Service.Configuration.EnableDebugLogging && slot != null)
|
|
Service.PluginLog.Information("ConfigurableCombo: ExecuteSlotById bar={0} slot={1} not Action (type={2})", hotbarId, slotId, slot->CommandType);
|
|
return _executeSlotByIdHook!.Original(hotbarModule, hotbarId, slotId);
|
|
}
|
|
|
|
uint triggerActionId = slot->CommandId;
|
|
uint triggerForState = triggerActionId;
|
|
var (lastComboMove, comboTime, lastExecutedIndex) = GetEffectiveComboState(triggerForState);
|
|
uint nextActionId = 0;
|
|
int comboStepCount = 0;
|
|
foreach (var combo in Service.Configuration.UserCombos)
|
|
{
|
|
if (!combo.Enabled || combo.ActionIds.Count == 0)
|
|
continue;
|
|
if (!ComboAppliesToCurrentJob(combo))
|
|
continue;
|
|
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 = GetResolvedNextAction(combo.ActionIds, lastComboMove, comboTime, lastExecutedIndex).actionId;
|
|
break;
|
|
}
|
|
|
|
if (Service.Configuration.EnableDebugLogging)
|
|
Service.PluginLog.Information(
|
|
"ConfigurableCombo: ExecuteSlotById(bar={0}, slot={1}) CommandId={2} lastComboMove={3} comboTime={4:F2} => nextActionId={5} comboSteps={6}",
|
|
hotbarId, slotId, triggerActionId, lastComboMove, comboTime, nextActionId, comboStepCount);
|
|
|
|
// Do not swap slot->CommandId. Let the game call UseAction(trigger). UseAction detour will
|
|
// replace trigger->next and only advance combo state when the action actually succeeds.
|
|
return _executeSlotByIdHook!.Original(hotbarModule, hotbarId, slotId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Game calls this every frame for each visible hotbar slot to get the display action/icon. We override *actionId (and *actionType)
|
|
/// and the slot's ApparentActionId/IconId for combo slots so the default game hotbar shows the correct combo step icon.
|
|
/// </summary>
|
|
private unsafe uint GetSlotAppearanceDetour(RaptureHotbarModule.HotbarSlotType* actionType, uint* actionId, ushort* UNK_0xC4, RaptureHotbarModule* hotbarModule, RaptureHotbarModule.HotbarSlot* slot)
|
|
{
|
|
uint result = _getSlotAppearanceHook!.Original(actionType, actionId, UNK_0xC4, hotbarModule, slot);
|
|
|
|
if (!Service.Configuration.EnablePlugin || Service.ClientState.LocalPlayer == null || slot == null)
|
|
return result;
|
|
if (slot->CommandType != RaptureHotbarModule.HotbarSlotType.Action)
|
|
return result;
|
|
|
|
uint triggerActionId = slot->CommandId;
|
|
foreach (var combo in Service.Configuration.UserCombos ?? new List<UserComboDefinition>())
|
|
{
|
|
if (!combo.Enabled || combo.ActionIds.Count == 0)
|
|
continue;
|
|
if (!ComboAppliesToCurrentJob(combo))
|
|
continue;
|
|
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, 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.
|
|
*actionId = nextActionId;
|
|
*actionType = RaptureHotbarModule.HotbarSlotType.Action;
|
|
// Also update the slot struct so any path that reads slot->ApparentActionId / IconId (e.g. copy to NumberArray) sees our icon.
|
|
slot->ApparentActionId = nextActionId;
|
|
slot->ApparentSlotType = RaptureHotbarModule.HotbarSlotType.Action;
|
|
int iconId = slot->GetIconIdForSlot(RaptureHotbarModule.HotbarSlotType.Action, nextActionId);
|
|
if (iconId > 0)
|
|
slot->IconId = (uint)iconId;
|
|
else
|
|
slot->LoadIconId();
|
|
|
|
if (Service.Configuration.EnableDebugLogging)
|
|
{
|
|
long now = Environment.TickCount64;
|
|
lock (_getIconDebugLogTicks)
|
|
{
|
|
if (!_getIconDebugLogTicks.TryGetValue(triggerActionId + 100000, out var last) || (now - last) >= 2000)
|
|
{
|
|
_getIconDebugLogTicks[triggerActionId + 100000] = now;
|
|
Service.PluginLog.Information("ConfigurableCombo: GetSlotAppearance(trigger={0}) => actionId={1} (comboTime={2:F2})", triggerActionId, nextActionId, slotComboTime);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Each frame, set each slot that has a combo trigger to show the next action's icon (ApparentActionId + LoadIconId).
|
|
/// This targets the game's native action bars (RaptureHotbarModule), not any third-party UI. We apply every frame
|
|
/// so we override any game logic that may reset slot appearance.
|
|
/// </summary>
|
|
private unsafe void OnFrameworkUpdate(IFramework framework)
|
|
{
|
|
if (!Service.Configuration.EnablePlugin || Service.ClientState.LocalPlayer == null)
|
|
return;
|
|
|
|
var module = RaptureHotbarModule.Instance();
|
|
if (module == null || !module->ModuleReady)
|
|
return;
|
|
|
|
// Game's native hotbars: 10 standard + 8 cross (barId 0..17), 16 slots per bar
|
|
for (uint barId = 0; barId < 18; barId++)
|
|
{
|
|
for (uint slotId = 0; slotId < 16; slotId++)
|
|
{
|
|
var slot = module->GetSlotById(barId, slotId);
|
|
if (slot == null)
|
|
continue;
|
|
if (slot->CommandType != RaptureHotbarModule.HotbarSlotType.Action)
|
|
continue;
|
|
|
|
uint triggerActionId = slot->CommandId;
|
|
uint nextActionId = 0;
|
|
bool slotMatched = false;
|
|
foreach (var combo in Service.Configuration.UserCombos)
|
|
{
|
|
if (!combo.Enabled || combo.ActionIds.Count == 0)
|
|
continue;
|
|
if (!ComboAppliesToCurrentJob(combo))
|
|
continue;
|
|
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, 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;
|
|
}
|
|
|
|
if (!slotMatched)
|
|
continue;
|
|
|
|
slot->ApparentActionId = nextActionId;
|
|
slot->ApparentSlotType = RaptureHotbarModule.HotbarSlotType.Action;
|
|
int iconId = slot->GetIconIdForSlot(RaptureHotbarModule.HotbarSlotType.Action, nextActionId);
|
|
if (iconId > 0)
|
|
slot->IconId = (uint)iconId;
|
|
else
|
|
slot->LoadIconId();
|
|
// Invalidate GetAdjustedActionId cache so the next call returns the same nextActionId we just drew.
|
|
lock (_getIconCache) { _getIconCache.Remove(triggerActionId); }
|
|
}
|
|
}
|
|
}
|
|
|
|
private unsafe uint GetIconDetour(IntPtr actionManager, uint actionID)
|
|
{
|
|
_actionManager = actionManager;
|
|
|
|
try
|
|
{
|
|
if (!Service.Configuration.EnablePlugin || Service.ClientState.LocalPlayer == null)
|
|
return _getIconHook.Original(actionManager, actionID);
|
|
|
|
int throttleMs = Math.Max(0, Service.Configuration.ThrottleMs);
|
|
long now = Environment.TickCount64;
|
|
|
|
foreach (var combo in Service.Configuration.UserCombos)
|
|
{
|
|
if (!combo.Enabled || combo.ActionIds.Count == 0)
|
|
continue;
|
|
if (!ComboAppliesToCurrentJob(combo))
|
|
continue;
|
|
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, lastExecutedIndex) = GetEffectiveComboState(triggerForState);
|
|
lock (_getIconCache)
|
|
{
|
|
if (throttleMs > 0 && _getIconCache.TryGetValue(actionID, out var cached) &&
|
|
(now - cached.ticks) < throttleMs && cached.lastComboMove == lastComboMove && cached.lastExecutedIndex == lastExecutedIndex)
|
|
return cached.value;
|
|
}
|
|
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, lastExecutedIndex);
|
|
}
|
|
}
|
|
if (Service.Configuration.EnableDebugLogging)
|
|
{
|
|
lock (_getIconDebugLogTicks)
|
|
{
|
|
if (!_getIconDebugLogTicks.TryGetValue(actionID, out var last) || (now - last) >= 1000)
|
|
{
|
|
_getIconDebugLogTicks[actionID] = now;
|
|
Service.PluginLog.Information(
|
|
"ConfigurableCombo: GetAdjustedActionId(actionID={0}) lastComboMove={1} comboTime={2:F2} => return {3}",
|
|
actionID, lastComboMove, comboTime, result);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
var gameLastCombo = *(uint*)Service.Address.LastComboMove;
|
|
var gameComboTime = *(float*)Service.Address.ComboTimer;
|
|
lock (_getIconCache)
|
|
{
|
|
if (throttleMs > 0 && _getIconCache.TryGetValue(actionID, out var cached) &&
|
|
(now - cached.ticks) < throttleMs && cached.lastComboMove == gameLastCombo)
|
|
return cached.value;
|
|
}
|
|
uint gameResult = _getIconHook.Original(actionManager, actionID);
|
|
if (throttleMs > 0)
|
|
{
|
|
lock (_getIconCache)
|
|
{
|
|
_getIconCache[actionID] = (gameResult, now, gameLastCombo, -1);
|
|
}
|
|
}
|
|
return gameResult;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Service.PluginLog.Error(ex, "ConfigurableCombo: GetIconDetour error");
|
|
return _getIconHook.Original(actionManager, actionID);
|
|
}
|
|
}
|
|
|
|
/// <summary>Returns the action id to show in the slot icon (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 GetResolvedNextAction(actionIds, lastComboMove, comboTime, lastExecutedIndex).actionId;
|
|
}
|
|
|
|
/// <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], 0);
|
|
|
|
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)
|
|
{
|
|
lastIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
if (lastIndex < 0)
|
|
return (actionIds[0], 0);
|
|
}
|
|
|
|
int nextIndex = lastIndex + 1;
|
|
if (nextIndex >= actionIds.Count)
|
|
nextIndex = 0;
|
|
|
|
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;
|
|
}
|
|
}
|