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
This commit is contained in:
+91
-12
@@ -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 < 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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user