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:
jorg
2026-03-02 10:50:52 -06:00
parent 9f77bc2916
commit 7c174efa70
5 changed files with 173 additions and 13 deletions
+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); }
}