3 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
6 changed files with 174 additions and 14 deletions
+42
View File
@@ -195,6 +195,48 @@ public class ConfigWindow : Window
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
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.");
+1 -1
View File
@@ -14,7 +14,7 @@
</PropertyGroup>
<PropertyGroup>
<AssemblyName>ConfigurableCombo</AssemblyName>
<Version>1.0.0.0</Version>
<Version>1.0.1.0</Version>
</PropertyGroup>
<!-- DalamudLibPath: SDK picks ~/.xlcore/dalamud/Hooks/dev/ on Linux, %AppData%\XIVLauncher\addon\Hooks\dev\ on Windows; use DALAMUD_HOME env to override -->
<ItemGroup>
+4 -1
View File
@@ -15,10 +15,13 @@ public sealed class ConfigurableComboPlugin : IDalamudPlugin
public ConfigurableComboPlugin(
IDalamudPluginInterface pluginInterface,
ISigScanner sigScanner,
IGameInteropProvider gameInteropProvider)
IGameInteropProvider gameInteropProvider,
ITargetManager? targetManager = null)
{
pluginInterface.Create<Service>();
Service.TargetManager = targetManager;
Service.Configuration = pluginInterface.GetPluginConfig() as PluginConfiguration ?? new PluginConfiguration();
Service.Address = new PluginAddressResolver();
Service.Address.Setup(sigScanner);
+91 -12
View File
@@ -40,6 +40,8 @@ internal unsafe sealed class IconReplacer : IDisposable
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();
@@ -193,19 +195,70 @@ internal unsafe sealed class IconReplacer : IDisposable
return combo.JobId == GetCurrentJobId();
}
/// <summary>Get effective combo state for a trigger: use our overlay if we recently executed an action for this combo, else game state. lastExecutedIndex is -1 when from game (enables first-match fallback for duplicates).</summary>
/// <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;
// Only use raw state when the game has actually updated (avoids stuck combo when mashing: raw is often still 0,0).
if (rawTime > 0 || rawMove != 0)
return (rawMove, rawTime, -1);
}
@@ -213,17 +266,35 @@ internal unsafe sealed class IconReplacer : IDisposable
return (our.lastExecutedActionId, remaining, our.lastExecutedIndex);
}
}
useRaw:
var rawMoveFinal = *(uint*)Service.Address.LastComboMove;
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)
{
lock (_ourComboState)
{
if (_ourComboState.TryGetValue(triggerActionId, out var fallback) && now < fallback.expiryTicks)
{
float remaining = (fallback.expiryTicks - now) / 1000f;
return (fallback.lastExecutedActionId, remaining, fallback.lastExecutedIndex);
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);
}
}
}
}
@@ -232,22 +303,26 @@ internal unsafe sealed class IconReplacer : IDisposable
/// <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>
private void SetOurComboState(uint triggerActionId, uint executedActionId, int executedIndex = -1, 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;
int windowMs = GetComboWindowMs(combo);
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)
{
long setTicks = now;
if (executedActionId == triggerActionId)
{
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)
setTicks = prev.setTicks; // keep 200ms window from first execution of step 1
setTicks = prev.setTicks;
}
_ourComboState[triggerActionId] = (executedActionId, executedIndex, now + ComboWindowMs, 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) ||
@@ -284,7 +359,7 @@ internal unsafe sealed class IconReplacer : IDisposable
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)
{
if (executedActionId == 0) return;
@@ -294,6 +369,8 @@ internal unsafe sealed class IconReplacer : IDisposable
{
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)) ||
@@ -303,6 +380,7 @@ internal unsafe sealed class IconReplacer : IDisposable
if (!actionInCombo)
{
_ourComboState.Remove(trigger);
_ourComboStateTarget.Remove(trigger);
lock (_getIconCache) { _getIconCache.Remove(trigger); }
}
}
@@ -406,7 +484,7 @@ internal unsafe sealed class IconReplacer : IDisposable
bool result = _useActionHook!.Original(actionManager, actionType, replaceWith, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted);
if (result)
{
SetOurComboState(matchedTrigger, executedActionId, nextIndex, optimistic: false);
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
@@ -447,6 +525,7 @@ internal unsafe sealed class IconReplacer : IDisposable
lock (_ourComboState)
{
_ourComboState.Remove(triggerActionId);
_ourComboStateTarget.Remove(triggerActionId);
}
lock (_getIconCache) { _getIconCache.Remove(triggerActionId); }
}
+2
View File
@@ -13,6 +13,8 @@ internal class Service
[PluginService] internal static IDalamudPluginInterface Interface { 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 ICommandManager CommandManager { get; private set; } = null!;
[PluginService] internal static IDataManager DataManager { get; private set; } = null!;
+34
View File
@@ -4,6 +4,23 @@ using Newtonsoft.Json;
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>
/// A single user-defined combo: put the first action on your hotbar; pressing it advances through the sequence.
/// </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.
/// </summary>
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;
}