From 77d9494447993054a24bd6f783e76dbc496d40a2 Mon Sep 17 00:00:00 2001 From: jorg Date: Sun, 22 Feb 2026 17:40:56 -0600 Subject: [PATCH] =?UTF-8?q?fix(RDM):=20Use=20Enchanted=20melee=20actions?= =?UTF-8?q?=20when=20Black=20&=20White=20mana=20=E2=89=A5=2020?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red Mage melee combo (Riposte 7504 → Zwerchhau 7512 → Redoublement 7516) should switch to Enchanted variants (7527, 7528, 7529) when both mana gauges are 20 or higher. The plugin was forcing base actions instead. Changes: - Add IJobGauges to read RDM Black/White mana - ResolveRdmEnchanted(): substitute 7527/7528/7529 when mana ≥ 20 - NormalizeLastComboMoveForSequence(): game/overlay may report Enchanted IDs; map to base for correct sequence index lookup - Apply resolution in UseAction, ExecuteSlotById, GetSlotAppearance, GetIcon, OnFrameworkUpdate - Match combo trigger when slot shows Enchanted Riposte (7527) - ClearOverlayIfActionNotInCombo: treat Enchanted IDs as in-combo - SetOurComboState: Enchanted Redoublement = last step for GCD block Also: remove hardcoded DalamudLibPath so SDK uses platform defaults (~/.xlcore/dalamud/Hooks/dev/ on Linux, %AppData% on Windows) Co-authored-by: Cursor --- ConfigurableCombo.csproj | 5 +- IconReplacer.cs | 118 +++++++++++++++++++++++++++++++-------- Service.cs | 1 + 3 files changed, 97 insertions(+), 27 deletions(-) diff --git a/ConfigurableCombo.csproj b/ConfigurableCombo.csproj index b246d8e..4d4f9ce 100644 --- a/ConfigurableCombo.csproj +++ b/ConfigurableCombo.csproj @@ -16,10 +16,7 @@ ConfigurableCombo 1.0.0.0 - - $(appdata)\XIVLauncher\addon\Hooks\dev\ - - + diff --git a/IconReplacer.cs b/IconReplacer.cs index 2d4a858..35ceebd 100644 --- a/IconReplacer.cs +++ b/IconReplacer.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Dalamud.Game.ClientState.JobGauge.Types; using Dalamud.Hooking; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game; @@ -129,6 +130,16 @@ internal unsafe sealed class IconReplacer : IDisposable /// After setting overlay to the last step (e.g. 2254), don't allow flipping back to step 1 for this long — keeps icon on last step until GCD has finished so the next cast is in the right order. One full GCD + buffer (base 2.5s, can be lower with SkS). private const int MinMsBeforeOverlayStep1AfterLastStep = 3500; + // 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; + private static uint GetCurrentJobId() { var player = Service.ClientState.LocalPlayer; @@ -198,7 +209,9 @@ internal unsafe sealed class IconReplacer : IDisposable } _ourComboState[triggerActionId] = (executedActionId, now + ComboWindowMs, setTicks); } - if (lastStepActionId.HasValue && executedActionId == lastStepActionId.Value) + bool isLastStep = lastStepActionId.HasValue && (executedActionId == lastStepActionId.Value || + (lastStepActionId.Value == RdmRedoublement && executedActionId == RdmEnchantedRedoublement)); + if (isLastStep) { lock (_lastStepOverlaySetTicks) { @@ -238,7 +251,9 @@ 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)); + if (!actionInCombo) { _ourComboState.Remove(trigger); lock (_getIconCache) { _getIconCache.Remove(trigger); } @@ -270,7 +285,8 @@ 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); + if (!triggerMatches) continue; matchedCombo = combo; matchedTrigger = combo.ActionIds[0]; @@ -286,10 +302,11 @@ 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) = GetEffectiveComboState(triggerForState); effectiveLastCombo = lastComboMove; effectiveComboTime = comboTime; - replaceWith = GetNextActionInSequence(matchedCombo.ActionIds, lastComboMove, comboTime); + replaceWith = GetResolvedNextAction(matchedCombo.ActionIds, lastComboMove, comboTime); if (Service.Configuration.EnableDebugLogging && replaceWith != 0) { @@ -309,7 +326,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) { @@ -406,7 +424,8 @@ internal unsafe sealed class IconReplacer : IDisposable } uint triggerActionId = slot->CommandId; - var (lastComboMove, comboTime) = GetEffectiveComboState(triggerActionId); + uint triggerForState = triggerActionId; + var (lastComboMove, comboTime) = GetEffectiveComboState(triggerForState); uint nextActionId = 0; int comboStepCount = 0; foreach (var combo in Service.Configuration.UserCombos) @@ -415,10 +434,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); + if (!triggerMatches) continue; + triggerForState = combo.ActionIds[0]; + var (lastComboMoveInner, comboTimeInner) = GetEffectiveComboState(triggerForState); + lastComboMove = lastComboMoveInner; + comboTime = comboTimeInner; comboStepCount = combo.ActionIds.Count; - nextActionId = GetNextActionInSequence(combo.ActionIds, lastComboMove, comboTime); + nextActionId = GetResolvedNextAction(combo.ActionIds, lastComboMove, comboTime); break; } @@ -452,10 +476,12 @@ 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); + if (!triggerMatches) continue; - var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerActionId); + uint triggerForState = combo.ActionIds[0]; + var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerForState); uint nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime); // Show next action immediately (e.g. Gust Slash with GCD overlay after Death Blossom); no "hold last step icon while GCD". @@ -523,9 +549,11 @@ 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); + if (!triggerMatches) continue; - var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerActionId); + uint triggerForState = combo.ActionIds[0]; + var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerForState); nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime); // Show next action immediately (Gust Slash with GCD overlay after Death Blossom). slotMatched = true; @@ -566,9 +594,11 @@ 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); + if (!triggerMatches) continue; - var (lastComboMove, comboTime) = GetEffectiveComboState(actionID); + uint triggerForState = combo.ActionIds[0]; + var (lastComboMove, comboTime) = GetEffectiveComboState(triggerForState); lock (_getIconCache) { if (throttleMs > 0 && _getIconCache.TryGetValue(actionID, out var cached) && @@ -625,18 +655,60 @@ internal unsafe sealed class IconReplacer : IDisposable } } - /// - /// 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). - /// + /// Returns the action id to show in the slot icon (or execute): next in sequence with RDM Enchanted resolution when applicable. private static uint GetDisplayActionIdForIcon(IList actionIds, uint lastComboMove, float comboTime) { - return GetNextActionInSequence(actionIds, lastComboMove, comboTime); + return GetResolvedNextAction(actionIds, lastComboMove, comboTime); } - /// - /// Returns the next action in the sequence. Resets to step 1 after the last step or when combo expired / another action was used. - /// + /// Next action in sequence, with Red Mage Enchanted melee (7527/7528/7529) when both mana >= 20. + private static uint GetResolvedNextAction(IList actionIds, uint lastComboMove, float comboTime) + { + uint normalizedLast = NormalizeLastComboMoveForSequence(actionIds, lastComboMove); + uint next = GetNextActionInSequence(actionIds, normalizedLast, comboTime); + return ResolveRdmEnchanted(next); + } + + /// For RDM melee combo, game/overlay may report Enchanted IDs (7527/7528/7529); normalize to base (7504/7512/7516) for sequence index lookup. + private static uint NormalizeLastComboMoveForSequence(IList actionIds, uint lastComboMove) + { + if (!IsRdmMeleeCombo(actionIds)) return lastComboMove; + return lastComboMove switch + { + RdmEnchantedRiposte => RdmRiposte, + RdmEnchantedZwerchhau => RdmZwerchhau, + RdmEnchantedRedoublement => RdmRedoublement, + _ => lastComboMove + }; + } + + /// When RDM and both Black/White mana >= 20, return Enchanted melee action ID for the given base action. + private static uint ResolveRdmEnchanted(uint actionId) + { + if (GetCurrentJobId() != JobIdRedMage) return actionId; + try + { + var gauge = Service.JobGauges.Get(); + 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 actionIds) + { + return actionIds.Count >= 3 && actionIds[0] == RdmRiposte && actionIds[1] == RdmZwerchhau && actionIds[2] == RdmRedoublement; + } + + /// Returns the next action in the sequence. Resets to step 1 after the last step or when combo expired / another action was used. private static uint GetNextActionInSequence(IList actionIds, uint lastComboMove, float comboTime) { // Combo timer expired or no combo in progress -> show first action diff --git a/Service.cs b/Service.cs index eb58d90..b272d6c 100644 --- a/Service.cs +++ b/Service.cs @@ -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!; }