Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c174efa70 | |||
| 9f77bc2916 | |||
| edcd4684d8 |
@@ -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.");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
+89
-10
@@ -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,41 +266,63 @@ 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)
|
||||
{
|
||||
if (combo?.ResetMode == ComboResetMode.OnTargetChange && Service.TargetManager != null)
|
||||
{
|
||||
ulong currentTarget = GetCurrentTargetObjectId();
|
||||
if (_ourComboStateTarget.TryGetValue(triggerActionId, out ulong lastTarget) && currentTarget != lastTarget)
|
||||
{
|
||||
_ourComboState.Remove(triggerActionId);
|
||||
_ourComboStateTarget.Remove(triggerActionId);
|
||||
lock (_getIconCache) { _getIconCache.Remove(triggerActionId); }
|
||||
}
|
||||
else
|
||||
{
|
||||
float remaining = (fallback.expiryTicks - now) / 1000f;
|
||||
return (fallback.lastExecutedActionId, remaining, fallback.lastExecutedIndex);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
float remaining = (fallback.expiryTicks - now) / 1000f;
|
||||
return (fallback.lastExecutedActionId, remaining, fallback.lastExecutedIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rawMoveFinal, rawTimeFinal, -1);
|
||||
}
|
||||
|
||||
/// <summary>Record that we just executed this action at the given step index for this combo trigger. executedIndex < 0 means unknown (no index tracking).</summary>
|
||||
/// <param name="optimistic">If true, we're updating before Original() returns. When cycling back (executed==trigger), backdate setTicks so the 200ms overlay-ignore doesn't trigger and the next UseAction sees our overlay and advances to step 2 instead of raw game state (2254) and replacing with 2242 again.</param>
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user