From 7c174efa704993760ec19a33b6ee9e4c3db4d114 Mon Sep 17 00:00:00 2001 From: jorg Date: Mon, 2 Mar 2026 10:50:52 -0600 Subject: [PATCH] feat: Per-combo reset options and preserve-position toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ConfigWindow.cs | 42 +++++++++++++++ ConfigurableComboPlugin.cs | 5 +- IconReplacer.cs | 103 ++++++++++++++++++++++++++++++++----- Service.cs | 2 + UserComboDefinition.cs | 34 ++++++++++++ 5 files changed, 173 insertions(+), 13 deletions(-) diff --git a/ConfigWindow.cs b/ConfigWindow.cs index 88a3a7f..565894d 100644 --- a/ConfigWindow.cs +++ b/ConfigWindow.cs @@ -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.5–300)."); + } + + 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."); diff --git a/ConfigurableComboPlugin.cs b/ConfigurableComboPlugin.cs index 5b59afc..702b3f4 100644 --- a/ConfigurableComboPlugin.cs +++ b/ConfigurableComboPlugin.cs @@ -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.TargetManager = targetManager; + Service.Configuration = pluginInterface.GetPluginConfig() as PluginConfiguration ?? new PluginConfiguration(); Service.Address = new PluginAddressResolver(); Service.Address.Setup(sigScanner); diff --git a/IconReplacer.cs b/IconReplacer.cs index f8f2d28..2439488 100644 --- a/IconReplacer.cs +++ b/IconReplacer.cs @@ -40,6 +40,8 @@ internal unsafe sealed class IconReplacer : IDisposable private readonly Dictionary _ourComboState = new(); /// 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). private readonly Dictionary _lastStepOverlaySetTicks = new(); + /// Per-trigger: last target ObjectId when combo uses ResetMode.OnTargetChange; reset when target changes. + private readonly Dictionary _ourComboStateTarget = new(); /// Serializes combo replacement so rapid UseAction calls (mashing) don't all read state before any update; second caller waits and sees updated overlay. private readonly object _useActionComboLock = new(); @@ -193,19 +195,70 @@ internal unsafe sealed class IconReplacer : IDisposable return combo.JobId == GetCurrentJobId(); } - /// 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). + /// Get the first enabled combo for this trigger that applies to the current job. + private static UserComboDefinition? GetComboForTrigger(uint triggerActionId) + { + foreach (var combo in Service.Configuration.UserCombos ?? new List()) + { + if (!combo.Enabled || combo.ActionIds.Count == 0 || combo.ActionIds[0] != triggerActionId) + continue; + if (!ComboAppliesToCurrentJob(combo)) + continue; + return combo; + } + return null; + } + + /// Combo window in ms from combo settings. Never/OnTargetChange use a long window; expiry is overridden by target check for OnTargetChange. + 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; } + } + + /// 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). 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 /// Record that we just executed this action at the given step index for this combo trigger. executedIndex < 0 means unknown (no index tracking). /// 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. - private void SetOurComboState(uint triggerActionId, uint executedActionId, int executedIndex = -1, bool optimistic = false) + /// When set, combo window and target tracking use this combo's ResetMode / ResetAfterSeconds. + /// When combo uses OnTargetChange, we store this so the combo resets when target changes. + 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; } - /// Clear overlay for combo triggers where the executed action is not in that combo's sequence, so the icon resets to step 1. + /// 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. 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); } } diff --git a/Service.cs b/Service.cs index b272d6c..7e53a44 100644 --- a/Service.cs +++ b/Service.cs @@ -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!; + /// Set by plugin if available; not injected to avoid load failure when service missing. When null, OnTargetChange reset is skipped. + 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!; diff --git a/UserComboDefinition.cs b/UserComboDefinition.cs index 30d52f6..4449fb4 100644 --- a/UserComboDefinition.cs +++ b/UserComboDefinition.cs @@ -4,6 +4,23 @@ using Newtonsoft.Json; namespace ConfigurableCombo; +/// When to reset the combo sequence back to step 1. +[Serializable] +public enum ComboResetMode +{ + /// Reset when the game combo timer expires (default, ~15s). + GameTimer = 0, + + /// Never reset from time; always advance through the sequence (cycle after last step). + Never = 1, + + /// Reset when the current target changes. + OnTargetChange = 2, + + /// Reset after the configured number of seconds of inactivity. + AfterSeconds = 3, +} + /// /// A single user-defined combo: put the first action on your hotbar; pressing it advances through the sequence. /// @@ -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. /// public uint TriggerActionId => ActionIds.Count > 0 ? ActionIds[0] : 0; + + /// + /// When to reset this combo to step 1. Per-combo; e.g. one combo can use "Never" while another uses "After seconds". + /// + public ComboResetMode ResetMode { get; set; } = ComboResetMode.GameTimer; + + /// + /// Seconds after which the combo resets when is . + /// Ignored for other modes. + /// + public float ResetAfterSeconds { get; set; } = 15f; + + /// + /// 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. + /// + public bool PreservePositionWhenUsingOtherActions { get; set; } = true; }