6 Commits

Author SHA1 Message Date
jorg 7c174efa70 feat: Per-combo reset options and preserve-position toggle
- 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
2026-03-02 10:50:52 -06:00
KnackAtNite 9f77bc2916 chore: bump version to 1.0.1.0 for RDM enchanted melee release
Made-with: Cursor
2026-02-25 21:41:53 -05:00
KnackAtNite edcd4684d8 Merge pull request 'fix(RDM): Use Enchanted melee actions when Black & White mana ≥ 20' (#1) from fix/rdm-enchanted-melee-combo into main 2026-02-26 02:39:53 +00:00
jorg c08d8dc400 feat: Transforming abilities for all jobs + duplicate action ID fix
- Duplicate action IDs in combos (e.g. Flare, Flare): track step index in
  combo state so the sequence advances correctly instead of looping on
  the first match.

- Dragoon Drakesbane: after Fang and Claw or Wheeling Thrust, show/use
  Drakesbane (36952). Support both ID sets (3552/3553 and 3554/3556).
  Normalize Drakesbane in sequence lookup; trigger match when slot shows
  Drakesbane.

- Warrior: Inner Release → Primal Rend when Primal Rend Ready (status
  2070); Primal Rend → Primal Ruination when Primal Ruination Ready
  (status 2700, lv100). Add HasStatus() via IBattleChara.StatusList.

- Reaper: Grim Reaping → Communio when 1 Lemure Shroud in Enshroud
  (RPRGauge). Normalize and trigger match for Communio.

- Central pipeline ResolveTransformingAbilities() for RDM, DRG, WAR, RPR.
  ClearOverlay/isLastStep and trigger matching updated for all replacement
  actions.

Made-with: Cursor
2026-02-25 20:38:40 -06:00
jorg 77d9494447 fix(RDM): Use Enchanted melee actions when Black & White mana ≥ 20
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 <cursoragent@cursor.com>
2026-02-22 17:40:56 -06:00
KnackAtNite 5eeadc2b34 Author: Knack117 for Dalamud search
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-04 22:24:56 -05:00
8 changed files with 471 additions and 72 deletions
+42
View File
@@ -195,6 +195,48 @@ public class ConfigWindow : Window
Service.Configuration.Save(); Service.Configuration.Save();
} }
// Reset behavior (per-combo)
ImGui.Text("Reset combo when:");
ImGui.SameLine();
var resetMode = combo.ResetMode;
ImGui.SetNextItemWidth(180);
if (ImGui.BeginCombo("##resetMode", resetMode.ToString().Replace("_", " ")))
{
foreach (ComboResetMode mode in Enum.GetValues(typeof(ComboResetMode)))
{
if (ImGui.Selectable(mode.ToString().Replace("_", " "), resetMode == mode))
{
combo.ResetMode = mode;
Service.Configuration.Save();
}
}
ImGui.EndCombo();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Game timer: reset when game combo timer expires (~15s).\nNever: always run through sequence (cycle after last step).\nOn target change: reset when you change target.\nAfter seconds: reset after the time below with no combo use.");
if (combo.ResetMode == ComboResetMode.AfterSeconds)
{
ImGui.SameLine();
float sec = combo.ResetAfterSeconds;
ImGui.SetNextItemWidth(80);
if (ImGui.InputFloat("##resetSec", ref sec, 0.5f, 1f, "%.1fs", ImGuiInputTextFlags.CharsDecimal))
{
combo.ResetAfterSeconds = Math.Clamp(sec, 0.5f, 300f);
Service.Configuration.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Seconds of no combo use before reset (0.5300).");
}
bool preservePosition = combo.PreservePositionWhenUsingOtherActions;
if (ImGui.Checkbox("Preserve position when using other actions", ref preservePosition))
{
combo.PreservePositionWhenUsingOtherActions = preservePosition;
Service.Configuration.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("On (default): Using another combo or any ability never resets this combo's step; it only resets per \"Reset when\" above.\nOff (original): Using an action not in this combo resets this combo to step 1.");
// Action list // Action list
ImGui.Text("Action sequence (first = hotbar slot; order: 1, 2, 3a/3b on other keys...)"); ImGui.Text("Action sequence (first = hotbar slot; order: 1, 2, 3a/3b on other keys...)");
ImGui.TextColored(new Vector4(0.9f, 0.85f, 0.4f, 1f), "First action must be the combo starter (e.g. Spinning Edge for NIN), or the icon/state won't advance."); ImGui.TextColored(new Vector4(0.9f, 0.85f, 0.4f, 1f), "First action must be the combo starter (e.g. Spinning Edge for NIN), or the icon/state won't advance.");
+2 -5
View File
@@ -14,12 +14,9 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<AssemblyName>ConfigurableCombo</AssemblyName> <AssemblyName>ConfigurableCombo</AssemblyName>
<Version>1.0.0.0</Version> <Version>1.0.1.0</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <!-- DalamudLibPath: SDK picks ~/.xlcore/dalamud/Hooks/dev/ on Linux, %AppData%\XIVLauncher\addon\Hooks\dev\ on Windows; use DALAMUD_HOME env to override -->
<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 -->
<ItemGroup> <ItemGroup>
<Content Include="ConfigurableCombo.json" CopyToOutputDirectory="PreserveNewest" /> <Content Include="ConfigurableCombo.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"Author": "Custom", "Author": "Knack117",
"Name": "ConfigurableCombo", "Name": "ConfigurableCombo",
"InternalName": "ConfigurableCombo", "InternalName": "ConfigurableCombo",
"Description": "Define your own combo sequences in config. Put the first action on the hotbar; each press advances the sequence. Sequence resets to step 1 after the last step or after any other action (e.g. NIN 1-2 with 3a/3b on separate keybinds). /ccombo to open settings.", "Description": "Define your own combo sequences in config. Put the first action on the hotbar; each press advances the sequence. Sequence resets to step 1 after the last step or after any other action (e.g. NIN 1-2 with 3a/3b on separate keybinds). /ccombo to open settings.",
+4 -1
View File
@@ -15,10 +15,13 @@ public sealed class ConfigurableComboPlugin : IDalamudPlugin
public ConfigurableComboPlugin( public ConfigurableComboPlugin(
IDalamudPluginInterface pluginInterface, IDalamudPluginInterface pluginInterface,
ISigScanner sigScanner, ISigScanner sigScanner,
IGameInteropProvider gameInteropProvider) IGameInteropProvider gameInteropProvider,
ITargetManager? targetManager = null)
{ {
pluginInterface.Create<Service>(); pluginInterface.Create<Service>();
Service.TargetManager = targetManager;
Service.Configuration = pluginInterface.GetPluginConfig() as PluginConfiguration ?? new PluginConfiguration(); Service.Configuration = pluginInterface.GetPluginConfig() as PluginConfiguration ?? new PluginConfiguration();
Service.Address = new PluginAddressResolver(); Service.Address = new PluginAddressResolver();
Service.Address.Setup(sigScanner); Service.Address.Setup(sigScanner);
+378 -58
View File
@@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Dalamud.Game.ClientState.JobGauge.Types;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Hooking; using Dalamud.Hooking;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game;
@@ -27,17 +29,19 @@ internal unsafe sealed class IconReplacer : IDisposable
private readonly Hook<UseActionDelegate>? _useActionHook; private readonly Hook<UseActionDelegate>? _useActionHook;
private readonly Hook<GetSlotAppearanceDelegate>? _getSlotAppearanceHook; private readonly Hook<GetSlotAppearanceDelegate>? _getSlotAppearanceHook;
private IntPtr _actionManager; private IntPtr _actionManager;
/// <summary>Cache last returned action per trigger actionID; only reuse when lastComboMove unchanged (so next press gets correct action).</summary> /// <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)> _getIconCache = new(); 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> /// <summary>Last time we logged debug for this actionID (throttle GetIconDetour spam).</summary>
private readonly Dictionary<uint, long> _getIconDebugLogTicks = new(); 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> /// <summary>Combo window in ms (game updates lastComboMove after we run, so we track what we executed ourselves).</summary>
private const int ComboWindowMs = 15000; 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> /// <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, long expiryTicks, long setTicks)> _ourComboState = new(); 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> /// <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(); 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> /// <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(); private readonly object _useActionComboLock = new();
@@ -129,12 +133,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> /// <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; 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() private static uint GetCurrentJobId()
{ {
var player = Service.ClientState.LocalPlayer; var player = Service.ClientState.LocalPlayer;
return player?.ClassJob.RowId ?? 0; 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> /// <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) private static bool ComboAppliesToCurrentJob(UserComboDefinition combo)
{ {
@@ -142,63 +195,142 @@ internal unsafe sealed class IconReplacer : IDisposable
return combo.JobId == GetCurrentJobId(); 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> /// <summary>Get the first enabled combo for this trigger that applies to the current job.</summary>
private unsafe (uint lastComboMove, float comboTime) GetEffectiveComboState(uint triggerActionId) 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; long now = Environment.TickCount64;
var combo = GetComboForTrigger(triggerActionId);
lock (_ourComboState) lock (_ourComboState)
{ {
if (_ourComboState.TryGetValue(triggerActionId, out var our) && now < our.expiryTicks) 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) if (our.lastExecutedActionId == triggerActionId && (now - our.setTicks) < OverlayIgnoreSameTriggerMs)
{ {
var rawMove = *(uint*)Service.Address.LastComboMove; var rawMove = *(uint*)Service.Address.LastComboMove;
var rawTime = *(float*)Service.Address.ComboTimer; 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) if (rawTime > 0 || rawMove != 0)
return (rawMove, rawTime); return (rawMove, rawTime, -1);
} }
float remaining = (our.expiryTicks - now) / 1000f; float remaining = (our.expiryTicks - now) / 1000f;
return (our.lastExecutedActionId, remaining); return (our.lastExecutedActionId, remaining, our.lastExecutedIndex);
} }
} }
useRaw:
var rawMoveFinal = *(uint*)Service.Address.LastComboMove; var rawMoveFinal = *(uint*)Service.Address.LastComboMove;
var rawTimeFinal = *(float*)Service.Address.ComboTimer; var rawTimeFinal = *(float*)Service.Address.ComboTimer;
// When game state is (0,0) (e.g. after failed UseAction resets it), prefer our overlay so we keep trying the next step instead of resetting to step 1.
if (rawMoveFinal == 0 && rawTimeFinal <= 0) if (rawMoveFinal == 0 && rawTimeFinal <= 0)
{ {
lock (_ourComboState) lock (_ourComboState)
{ {
if (_ourComboState.TryGetValue(triggerActionId, out var fallback) && now < fallback.expiryTicks) 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; float remaining = (fallback.expiryTicks - now) / 1000f;
return (fallback.lastExecutedActionId, remaining); return (fallback.lastExecutedActionId, remaining, fallback.lastExecutedIndex);
}
}
else
{
float remaining = (fallback.expiryTicks - now) / 1000f;
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 &lt; 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="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) /// <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; long now = Environment.TickCount64;
int windowMs = GetComboWindowMs(combo);
uint? lastStepActionId = GetLastStepActionIdForTrigger(triggerActionId); uint? lastStepActionId = GetLastStepActionIdForTrigger(triggerActionId);
// We only allow sending step 1 (trigger) in UseAction when GCD has ended, so when we get here with executedActionId==trigger it's a real success — always update so the next press advances to step 2 instead of spamming step 1.
lock (_ourComboState) lock (_ourComboState)
{ {
long setTicks = now; long setTicks = now;
if (executedActionId == triggerActionId) if (executedActionId == triggerActionId)
{ {
if (optimistic) if (optimistic)
setTicks = now - OverlayIgnoreSameTriggerMs - 50; // backdate so next GetEffectiveComboState uses overlay, not raw (so we advance 2242->2254 when mashing) setTicks = now - OverlayIgnoreSameTriggerMs - 50;
else if (_ourComboState.TryGetValue(triggerActionId, out var prev) && prev.lastExecutedActionId == triggerActionId) else if (_ourComboState.TryGetValue(triggerActionId, out var prev) && prev.lastExecutedActionId == triggerActionId)
setTicks = prev.setTicks; // keep 200ms window from first execution of step 1 setTicks = prev.setTicks;
} }
_ourComboState[triggerActionId] = (executedActionId, now + ComboWindowMs, setTicks); _ourComboState[triggerActionId] = (executedActionId, executedIndex, now + windowMs, setTicks);
if (combo?.ResetMode == ComboResetMode.OnTargetChange)
_ourComboStateTarget[triggerActionId] = targetId;
} }
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) lock (_lastStepOverlaySetTicks)
{ {
@@ -227,7 +359,7 @@ internal unsafe sealed class IconReplacer : IDisposable
return null; return null;
} }
/// <summary>Clear overlay for combo triggers where the executed action is not in that combo's sequence, so the icon resets to step 1.</summary> /// <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) private void ClearOverlayIfActionNotInCombo(uint executedActionId)
{ {
if (executedActionId == 0) return; if (executedActionId == 0) return;
@@ -237,10 +369,18 @@ internal unsafe sealed class IconReplacer : IDisposable
{ {
if (!combo.Enabled || combo.ActionIds.Count == 0 || !ComboAppliesToCurrentJob(combo)) if (!combo.Enabled || combo.ActionIds.Count == 0 || !ComboAppliesToCurrentJob(combo))
continue; continue;
if (combo.PreservePositionWhenUsingOtherActions)
continue; // per-combo: don't clear this combo's position when using other actions
uint trigger = combo.ActionIds[0]; 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); _ourComboState.Remove(trigger);
_ourComboStateTarget.Remove(trigger);
lock (_getIconCache) { _getIconCache.Remove(trigger); } lock (_getIconCache) { _getIconCache.Remove(trigger); }
} }
} }
@@ -270,7 +410,11 @@ internal unsafe sealed class IconReplacer : IDisposable
continue; continue;
if (!ComboAppliesToCurrentJob(combo)) if (!ComboAppliesToCurrentJob(combo))
continue; 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; continue;
matchedCombo = combo; matchedCombo = combo;
matchedTrigger = combo.ActionIds[0]; matchedTrigger = combo.ActionIds[0];
@@ -286,10 +430,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. // This serializes mashing so only one action runs at a time and overlay advances only when the game actually executes.
lock (_useActionComboLock) lock (_useActionComboLock)
{ {
var (lastComboMove, comboTime) = GetEffectiveComboState(actionId); uint triggerForState = matchedTrigger;
var (lastComboMove, comboTime, lastExecutedIndex) = GetEffectiveComboState(triggerForState);
effectiveLastCombo = lastComboMove; effectiveLastCombo = lastComboMove;
effectiveComboTime = comboTime; 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) if (Service.Configuration.EnableDebugLogging && replaceWith != 0)
{ {
@@ -309,7 +455,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. // 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(); var am = ActionManager.Instance();
long now = Environment.TickCount64; long now = Environment.TickCount64;
if (replaceWith == matchedTrigger) bool isStep1 = replaceWith == matchedTrigger || (IsRdmMeleeCombo(matchedCombo.ActionIds) && replaceWith == RdmEnchantedRiposte);
if (isStep1)
{ {
lock (_lastStepOverlaySetTicks) lock (_lastStepOverlaySetTicks)
{ {
@@ -337,7 +484,7 @@ internal unsafe sealed class IconReplacer : IDisposable
bool result = _useActionHook!.Original(actionManager, actionType, replaceWith, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted); bool result = _useActionHook!.Original(actionManager, actionType, replaceWith, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
if (result) if (result)
{ {
SetOurComboState(matchedTrigger, executedActionId, optimistic: false); SetOurComboState(matchedTrigger, executedActionId, nextIndex, optimistic: false, combo: matchedCombo, targetId: targetId);
ClearOverlayIfActionNotInCombo(executedActionId); ClearOverlayIfActionNotInCombo(executedActionId);
} }
// When the action fails (GCD, range, etc.) do NOT clear the overlay: leave last successful state // When the action fails (GCD, range, etc.) do NOT clear the overlay: leave last successful state
@@ -378,6 +525,7 @@ internal unsafe sealed class IconReplacer : IDisposable
lock (_ourComboState) lock (_ourComboState)
{ {
_ourComboState.Remove(triggerActionId); _ourComboState.Remove(triggerActionId);
_ourComboStateTarget.Remove(triggerActionId);
} }
lock (_getIconCache) { _getIconCache.Remove(triggerActionId); } lock (_getIconCache) { _getIconCache.Remove(triggerActionId); }
} }
@@ -406,7 +554,8 @@ internal unsafe sealed class IconReplacer : IDisposable
} }
uint triggerActionId = slot->CommandId; uint triggerActionId = slot->CommandId;
var (lastComboMove, comboTime) = GetEffectiveComboState(triggerActionId); uint triggerForState = triggerActionId;
var (lastComboMove, comboTime, lastExecutedIndex) = GetEffectiveComboState(triggerForState);
uint nextActionId = 0; uint nextActionId = 0;
int comboStepCount = 0; int comboStepCount = 0;
foreach (var combo in Service.Configuration.UserCombos) foreach (var combo in Service.Configuration.UserCombos)
@@ -415,10 +564,19 @@ internal unsafe sealed class IconReplacer : IDisposable
continue; continue;
if (!ComboAppliesToCurrentJob(combo)) if (!ComboAppliesToCurrentJob(combo))
continue; 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; continue;
triggerForState = combo.ActionIds[0];
var (lastComboMoveInner, comboTimeInner, lastExecutedIndexInner) = GetEffectiveComboState(triggerForState);
lastComboMove = lastComboMoveInner;
comboTime = comboTimeInner;
lastExecutedIndex = lastExecutedIndexInner;
comboStepCount = combo.ActionIds.Count; comboStepCount = combo.ActionIds.Count;
nextActionId = GetNextActionInSequence(combo.ActionIds, lastComboMove, comboTime); nextActionId = GetResolvedNextAction(combo.ActionIds, lastComboMove, comboTime, lastExecutedIndex).actionId;
break; break;
} }
@@ -452,11 +610,16 @@ internal unsafe sealed class IconReplacer : IDisposable
continue; continue;
if (!ComboAppliesToCurrentJob(combo)) if (!ComboAppliesToCurrentJob(combo))
continue; 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; continue;
var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerActionId); uint triggerForState = combo.ActionIds[0];
uint nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime); var (slotLastCombo, slotComboTime, slotLastIndex) = GetEffectiveComboState(triggerForState);
uint nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime, slotLastIndex);
// Show next action immediately (e.g. Gust Slash with GCD overlay after Death Blossom); no "hold last step icon while GCD". // 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. // Override out params so callers that use GetSlotAppearance result get our icon.
@@ -523,10 +686,15 @@ internal unsafe sealed class IconReplacer : IDisposable
continue; continue;
if (!ComboAppliesToCurrentJob(combo)) if (!ComboAppliesToCurrentJob(combo))
continue; 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; continue;
var (slotLastCombo, slotComboTime) = GetEffectiveComboState(triggerActionId); uint triggerForState = combo.ActionIds[0];
nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime); var (slotLastCombo, slotComboTime, slotLastIndex) = GetEffectiveComboState(triggerForState);
nextActionId = GetDisplayActionIdForIcon(combo.ActionIds, slotLastCombo, slotComboTime, slotLastIndex);
// Show next action immediately (Gust Slash with GCD overlay after Death Blossom). // Show next action immediately (Gust Slash with GCD overlay after Death Blossom).
slotMatched = true; slotMatched = true;
break; break;
@@ -566,22 +734,27 @@ internal unsafe sealed class IconReplacer : IDisposable
continue; continue;
if (!ComboAppliesToCurrentJob(combo)) if (!ComboAppliesToCurrentJob(combo))
continue; 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; continue;
var (lastComboMove, comboTime) = GetEffectiveComboState(actionID); uint triggerForState = combo.ActionIds[0];
var (lastComboMove, comboTime, lastExecutedIndex) = GetEffectiveComboState(triggerForState);
lock (_getIconCache) lock (_getIconCache)
{ {
if (throttleMs > 0 && _getIconCache.TryGetValue(actionID, out var cached) && 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; 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". // Show next action immediately (Gust Slash with GCD overlay after Death Blossom); no "hold last step icon while GCD".
if (throttleMs > 0) if (throttleMs > 0)
{ {
lock (_getIconCache) lock (_getIconCache)
{ {
_getIconCache[actionID] = (result, now, lastComboMove); _getIconCache[actionID] = (result, now, lastComboMove, lastExecutedIndex);
} }
} }
if (Service.Configuration.EnableDebugLogging) if (Service.Configuration.EnableDebugLogging)
@@ -613,7 +786,7 @@ internal unsafe sealed class IconReplacer : IDisposable
{ {
lock (_getIconCache) lock (_getIconCache)
{ {
_getIconCache[actionID] = (gameResult, now, gameLastCombo); _getIconCache[actionID] = (gameResult, now, gameLastCombo, -1);
} }
} }
return gameResult; return gameResult;
@@ -625,25 +798,167 @@ internal unsafe sealed class IconReplacer : IDisposable
} }
} }
/// <summary> /// <summary>Returns the action id to show in the slot icon (or execute): next in sequence with RDM Enchanted resolution when applicable.</summary>
/// Returns the action id to show in the slot icon: the next action in the sequence (what will execute on press). private static uint GetDisplayActionIdForIcon(IList<uint> actionIds, uint lastComboMove, float comboTime, int lastExecutedIndex = -1)
/// 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)
{ {
return GetNextActionInSequence(actionIds, lastComboMove, comboTime); return GetResolvedNextAction(actionIds, lastComboMove, comboTime, lastExecutedIndex).actionId;
} }
/// <summary> /// <summary>Next action in sequence, with all job transforming-ability resolution applied. Returns (actionId, indexOfNextStep) for index tracking.</summary>
/// Returns the next action in the sequence. Resets to step 1 after the last step or when combo expired / another action was used. /// <remarks>
/// </summary> /// Transforming abilities supported: RDM (Enchanted melee when 20+ B/W mana), DRG (Drakesbane after F&C or WT).
private static uint GetNextActionInSequence(IList<uint> actionIds, uint lastComboMove, float comboTime) /// 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 // Combo timer expired or no combo in progress -> show first action
if (comboTime <= 0) 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++) for (int i = 0; i < actionIds.Count; i++)
{ {
if (actionIds[i] == lastComboMove) if (actionIds[i] == lastComboMove)
@@ -652,16 +967,21 @@ internal unsafe sealed class IconReplacer : IDisposable
break; break;
} }
} }
// Last action was not in this sequence (e.g. they pressed 3a/3b on another key) -> reset to step 1
if (lastIndex < 0) 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; int nextIndex = lastIndex + 1;
if (nextIndex >= actionIds.Count) if (nextIndex >= actionIds.Count)
nextIndex = 0; 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;
} }
} }
+3
View File
@@ -13,10 +13,13 @@ internal class Service
[PluginService] internal static IDalamudPluginInterface Interface { get; private set; } = null!; [PluginService] internal static IDalamudPluginInterface Interface { get; private set; } = null!;
[PluginService] internal static IClientState ClientState { get; private set; } = null!; [PluginService] internal static IClientState ClientState { get; private set; } = null!;
/// <summary>Set by plugin if available; not injected to avoid load failure when service missing. When null, OnTargetChange reset is skipped.</summary>
internal static ITargetManager? TargetManager { get; set; }
[PluginService] internal static IChatGui ChatGui { get; private set; } = null!; [PluginService] internal static IChatGui ChatGui { get; private set; } = null!;
[PluginService] internal static ICommandManager CommandManager { get; private set; } = null!; [PluginService] internal static ICommandManager CommandManager { get; private set; } = null!;
[PluginService] internal static IDataManager DataManager { get; private set; } = null!; [PluginService] internal static IDataManager DataManager { get; private set; } = null!;
[PluginService] internal static IPluginLog PluginLog { get; private set; } = null!; [PluginService] internal static IPluginLog PluginLog { get; private set; } = null!;
[PluginService] internal static ITextureProvider TextureProvider { 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 IFramework Framework { get; private set; } = null!;
[PluginService] internal static IJobGauges JobGauges { get; private set; } = null!;
} }
+34
View File
@@ -4,6 +4,23 @@ using Newtonsoft.Json;
namespace ConfigurableCombo; namespace ConfigurableCombo;
/// <summary>When to reset the combo sequence back to step 1.</summary>
[Serializable]
public enum ComboResetMode
{
/// <summary>Reset when the game combo timer expires (default, ~15s).</summary>
GameTimer = 0,
/// <summary>Never reset from time; always advance through the sequence (cycle after last step).</summary>
Never = 1,
/// <summary>Reset when the current target changes.</summary>
OnTargetChange = 2,
/// <summary>Reset after the configured number of seconds of inactivity.</summary>
AfterSeconds = 3,
}
/// <summary> /// <summary>
/// A single user-defined combo: put the first action on your hotbar; pressing it advances through the sequence. /// A single user-defined combo: put the first action on your hotbar; pressing it advances through the sequence.
/// </summary> /// </summary>
@@ -40,4 +57,21 @@ public class UserComboDefinition
/// The action ID that is "on the bar" — i.e. the trigger. This is ActionIds[0] when the list is non-empty. /// The action ID that is "on the bar" — i.e. the trigger. This is ActionIds[0] when the list is non-empty.
/// </summary> /// </summary>
public uint TriggerActionId => ActionIds.Count > 0 ? ActionIds[0] : 0; public uint TriggerActionId => ActionIds.Count > 0 ? ActionIds[0] : 0;
/// <summary>
/// When to reset this combo to step 1. Per-combo; e.g. one combo can use "Never" while another uses "After seconds".
/// </summary>
public ComboResetMode ResetMode { get; set; } = ComboResetMode.GameTimer;
/// <summary>
/// Seconds after which the combo resets when <see cref="ResetMode"/> is <see cref="ComboResetMode.AfterSeconds"/>.
/// Ignored for other modes.
/// </summary>
public float ResetAfterSeconds { get; set; } = 15f;
/// <summary>
/// When true (default): using another combo or any action never clears this combo's position; it only resets per its own "Reset when" setting.
/// When false (original): using an action that is not part of this combo clears this combo's state back to step 1.
/// </summary>
public bool PreservePositionWhenUsingOtherActions { get; set; } = true;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[ [
{ {
"Author": "Custom", "Author": "Knack117",
"Name": "ConfigurableCombo", "Name": "ConfigurableCombo",
"InternalName": "ConfigurableCombo", "InternalName": "ConfigurableCombo",
"Description": "Define your own combo sequences in config. Put the first action on the hotbar; each press advances the sequence. Sequence resets to step 1 after the last step or after any other action (e.g. NIN 1-2 with 3a/3b on separate keybinds). /ccombo to open settings.", "Description": "Define your own combo sequences in config. Put the first action on the hotbar; each press advances the sequence. Sequence resets to step 1 after the last step or after any other action (e.g. NIN 1-2 with 3a/3b on separate keybinds). /ccombo to open settings.",