Files
pfUI/modules/swingtimer.lua
T
Meow c9468e4033 fix: swing timer reset bugs (extra attacks, parry, slam)
Extra attacks (Sword Specialization, Windfury, Hand of Justice) no longer double the swing timer. Added fallback detection for servers that don't set HITINFO_NOACTION correctly: if AUTO_ATTACK_SELF fires while more than 20% of the current swing remains, it's treated as an extra attack and ignored.
Parry reset now uses correct Vanilla mechanic: resets timer to exactly 60% of weapon speed instead of incorrectly subtracting 40% from remaining time.
Slam now hard-resets the swing timer from now instead of chaining from the previous nextSwing, which caused inflated swing times (e.g. 3.76s showing as 4.3s).
2026-03-04 00:13:37 +01:00

661 lines
27 KiB
Lua

pfUI:RegisterModule("swingtimer", "vanilla:tbc", function ()
local rawborder, border = GetBorderSize()
-- HitInfo flags (EVENTS.md)
local HITINFO_LEFTSWING = 4 -- 0x4: Off-hand attack
local HITINFO_NOACTION = 65536 -- 0x10000: server did not advance the swing clock
-- SPELL_QUEUE_EVENT codes (EVENTS.md)
local ON_SWING_QUEUED = 0
local ON_SWING_QUEUE_POPPED = 1
-- Swing state
local swingState = {
mainhand = { speed = 0, nextSwing = 0, swinging = false },
offhand = { speed = 0, nextSwing = 0, swinging = false },
ranged = { speed = 0, nextSwing = 0, swinging = false },
}
-- Ranged spell IDs that trigger the ranged swing timer (replaces MH)
local RANGED_SPELLIDS = {
[75] = true, -- Auto Shot (Hunter)
[2764] = true, -- Throw (Warrior/Rogue)
}
-- Create container frame
pfUI.swingtimer = CreateFrame("Frame", "pfSwingTimer", UIParent)
pfUI.swingtimer:SetFrameStrata("MEDIUM")
pfUI.swingtimer:Hide()
-- Read config once at load into locals
local sw_width = tonumber(C.unitframes.swingtimerwidth) or 200
local sw_height = tonumber(C.unitframes.swingtimerheight) or 12
local sw_texture = C.unitframes.swingtimertexture or "Interface\\AddOns\\pfUI\\img\\bar"
local sw_showtext = C.unitframes.swingtimertext ~= "0"
local sw_showlabel = C.unitframes.swingtimerlabel ~= "0"
local sw_showoh = C.unitframes.swingtimeroffhand ~= "0"
local sw_showranged = C.unitframes.swingtimerranged ~= "0"
local sw_fontsize = tonumber(C.unitframes.swingtimerfontsize) or 12
local sw_hsqueue = C.unitframes.swingtimerhsqueue ~= "0"
-- Parse color strings "r,g,b,a" into components
local function ParseColor(str, dr, dg, db, da)
if not str or str == "" then return dr, dg, db, da end
local _, _, r, g, b, a = string.find(str, "([%d%.]+),([%d%.]+),([%d%.]+),([%d%.]+)")
if r then
return tonumber(r) or dr, tonumber(g) or dg, tonumber(b) or db, tonumber(a) or da
end
return dr, dg, db, da
end
local mhR, mhG, mhB, mhA = ParseColor(C.unitframes.swingtimermhcolor, 0.8, 0.3, 0.3, 1)
local ohR, ohG, ohB, ohA = ParseColor(C.unitframes.swingtimerohcolor, 0.3, 0.8, 0.3, 1)
local raR, raG, raB, raA = ParseColor(C.unitframes.swingtimerrangedcolor, 0.3, 0.6, 1.0, 1)
local rwR, rwG, rwB, rwA = ParseColor(C.unitframes.swingtimerrangedwarncolor, 0.9, 0.0, 0.0, 1)
local isHunter = UnitClass("player") == "Hunter"
-- Store default MH color for HS/Cleave restore
local mhDefaultR, mhDefaultG, mhDefaultB = mhR, mhG, mhB
-- Mainhand bar
pfUI.swingtimer.mainhand = CreateFrame("StatusBar", "pfSwingTimerMainhand", UIParent)
pfUI.swingtimer.mainhand:SetPoint("CENTER", UIParent, "CENTER", 0, -100)
pfUI.swingtimer.mainhand:SetWidth(sw_width)
pfUI.swingtimer.mainhand:SetHeight(sw_height)
pfUI.swingtimer.mainhand:SetMinMaxValues(0, 1)
pfUI.swingtimer.mainhand:SetValue(0)
pfUI.swingtimer.mainhand:SetStatusBarTexture(sw_texture)
pfUI.swingtimer.mainhand:SetStatusBarColor(mhR, mhG, mhB, mhA)
pfUI.swingtimer.mainhand:Hide()
pfUI.swingtimer.mainhand.text = pfUI.swingtimer.mainhand:CreateFontString("Status", "DIALOG", "GameFontNormal")
pfUI.swingtimer.mainhand.text:SetPoint("CENTER", pfUI.swingtimer.mainhand, "CENTER", 0, 0)
pfUI.swingtimer.mainhand.text:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE")
pfUI.swingtimer.mainhand.text:SetTextColor(1, 1, 1, 1)
pfUI.swingtimer.mainhand.text:SetText("")
if not sw_showtext then pfUI.swingtimer.mainhand.text:Hide() end
pfUI.swingtimer.mainhand.label = pfUI.swingtimer.mainhand:CreateFontString("Status", "DIALOG", "GameFontNormal")
pfUI.swingtimer.mainhand.label:SetPoint("RIGHT", pfUI.swingtimer.mainhand, "LEFT", -4, 0)
pfUI.swingtimer.mainhand.label:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE")
pfUI.swingtimer.mainhand.label:SetTextColor(0.8, 0.8, 0.8, 1)
pfUI.swingtimer.mainhand.label:SetText(sw_showlabel and "MH" or "")
CreateBackdrop(pfUI.swingtimer.mainhand)
CreateBackdropShadow(pfUI.swingtimer.mainhand)
-- Offhand bar
pfUI.swingtimer.offhand = CreateFrame("StatusBar", "pfSwingTimerOffhand", UIParent)
pfUI.swingtimer.offhand:SetPoint("TOP", pfUI.swingtimer.mainhand, "BOTTOM", 0, -4)
pfUI.swingtimer.offhand:SetWidth(sw_width)
pfUI.swingtimer.offhand:SetHeight(sw_height)
pfUI.swingtimer.offhand:SetMinMaxValues(0, 1)
pfUI.swingtimer.offhand:SetValue(0)
pfUI.swingtimer.offhand:SetStatusBarTexture(sw_texture)
pfUI.swingtimer.offhand:SetStatusBarColor(ohR, ohG, ohB, ohA)
pfUI.swingtimer.offhand:Hide()
pfUI.swingtimer.offhand.text = pfUI.swingtimer.offhand:CreateFontString("Status", "DIALOG", "GameFontNormal")
pfUI.swingtimer.offhand.text:SetPoint("CENTER", pfUI.swingtimer.offhand, "CENTER", 0, 0)
pfUI.swingtimer.offhand.text:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE")
pfUI.swingtimer.offhand.text:SetTextColor(1, 1, 1, 1)
pfUI.swingtimer.offhand.text:SetText("")
if not sw_showtext then pfUI.swingtimer.offhand.text:Hide() end
pfUI.swingtimer.offhand.label = pfUI.swingtimer.offhand:CreateFontString("Status", "DIALOG", "GameFontNormal")
pfUI.swingtimer.offhand.label:SetPoint("RIGHT", pfUI.swingtimer.offhand, "LEFT", -4, 0)
pfUI.swingtimer.offhand.label:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE")
pfUI.swingtimer.offhand.label:SetTextColor(0.8, 0.8, 0.8, 1)
pfUI.swingtimer.offhand.label:SetText(sw_showlabel and "OH" or "")
CreateBackdrop(pfUI.swingtimer.offhand)
CreateBackdropShadow(pfUI.swingtimer.offhand)
-- Ranged bar (bow/gun/crossbow - triggered by SPELL_GO_SELF for Auto Shot / Throw)
-- Hunter uses a special "close from outside->in, open inside->out" animation
-- instead of a normal left->right StatusBar fill.
pfUI.swingtimer.ranged = CreateFrame("Frame", "pfSwingTimerRanged", UIParent)
pfUI.swingtimer.ranged:SetPoint("CENTER", UIParent, "CENTER", 0, -120)
pfUI.swingtimer.ranged:SetWidth(sw_width)
pfUI.swingtimer.ranged:SetHeight(sw_height)
pfUI.swingtimer.ranged:Hide()
-- Phase 1: left half, anchored to CENTER (right edge fixed), shrinks leftward = outside->in
pfUI.swingtimer.ranged.left = pfUI.swingtimer.ranged:CreateTexture(nil, "ARTWORK")
pfUI.swingtimer.ranged.left:SetTexture(sw_texture)
pfUI.swingtimer.ranged.left:SetPoint("RIGHT", pfUI.swingtimer.ranged, "CENTER", 0, 0)
pfUI.swingtimer.ranged.left:SetHeight(sw_height)
pfUI.swingtimer.ranged.left:SetWidth(sw_width / 2)
pfUI.swingtimer.ranged.left:SetTexCoord(0, 0.5, 0, 1)
pfUI.swingtimer.ranged.left:SetVertexColor(raR, raG, raB, raA)
-- Phase 1: right half, anchored to CENTER (left edge fixed), shrinks rightward = outside->in
pfUI.swingtimer.ranged.right = pfUI.swingtimer.ranged:CreateTexture(nil, "ARTWORK")
pfUI.swingtimer.ranged.right:SetTexture(sw_texture)
pfUI.swingtimer.ranged.right:SetPoint("LEFT", pfUI.swingtimer.ranged, "CENTER", 0, 0)
pfUI.swingtimer.ranged.right:SetHeight(sw_height)
pfUI.swingtimer.ranged.right:SetWidth(sw_width / 2)
pfUI.swingtimer.ranged.right:SetTexCoord(0.5, 1, 0, 1)
pfUI.swingtimer.ranged.right:SetVertexColor(raR, raG, raB, raA)
-- Phase 2: warning color, anchored CENTER, grows outward
pfUI.swingtimer.ranged.warn = pfUI.swingtimer.ranged:CreateTexture(nil, "ARTWORK")
pfUI.swingtimer.ranged.warn:SetTexture(sw_texture)
pfUI.swingtimer.ranged.warn:SetPoint("CENTER", pfUI.swingtimer.ranged, "CENTER", 0, 0)
pfUI.swingtimer.ranged.warn:SetHeight(sw_height)
pfUI.swingtimer.ranged.warn:SetWidth(1)
pfUI.swingtimer.ranged.warn:SetVertexColor(rwR, rwG, rwB, rwA)
pfUI.swingtimer.ranged.warn:Hide()
pfUI.swingtimer.ranged.text = pfUI.swingtimer.ranged:CreateFontString("Status", "DIALOG", "GameFontNormal")
pfUI.swingtimer.ranged.text:SetPoint("CENTER", pfUI.swingtimer.ranged, "CENTER", 0, 0)
pfUI.swingtimer.ranged.text:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE")
pfUI.swingtimer.ranged.text:SetTextColor(1, 1, 1, 1)
pfUI.swingtimer.ranged.text:SetText("")
if not sw_showtext then pfUI.swingtimer.ranged.text:Hide() end
pfUI.swingtimer.ranged.label = pfUI.swingtimer.ranged:CreateFontString("Status", "DIALOG", "GameFontNormal")
pfUI.swingtimer.ranged.label:SetPoint("RIGHT", pfUI.swingtimer.ranged, "LEFT", -4, 0)
pfUI.swingtimer.ranged.label:SetFont(pfUI.font_default, sw_fontsize, "OUTLINE")
pfUI.swingtimer.ranged.label:SetTextColor(0.8, 0.8, 0.8, 1)
pfUI.swingtimer.ranged.label:SetText(sw_showlabel and "Ra" or "")
CreateBackdrop(pfUI.swingtimer.ranged)
CreateBackdropShadow(pfUI.swingtimer.ranged)
-- Slam spell IDs (all ranks) - resets swing timer from now, not chained
local slamSpellIDs = {
[1464] = true, [8820] = true, [11604] = true, [11605] = true,
}
-- HS/Cleave queue state
local hsQueued = false
local cleaveQueued = false
local isWarrior = false
local cachedHSSlots = {}
local cachedCleaveSlots = {}
local useSpellQueueEvent = false
-- Heroic Strike spell IDs (all ranks)
local hsSpellIDs = {
[78] = true, [284] = true, [285] = true, [1608] = true,
[11564] = true, [11565] = true, [11566] = true, [11567] = true,
[25286] = true,
}
-- Cleave spell IDs (all ranks)
local cleaveSpellIDs = {
[845] = true, [7369] = true, [11608] = true, [11609] = true,
[20569] = true,
}
local function RebuildQueueSlotCache()
if not isWarrior or not sw_hsqueue or useSpellQueueEvent then return end
cachedHSSlots = {}
cachedCleaveSlots = {}
for slot = 1, 120 do
local tex = GetActionTexture(slot)
local name = GetActionText(slot)
if tex then
if string.find(tex, "Ability_Rogue_Ambush") then
table.insert(cachedHSSlots, slot)
elseif string.find(tex, "Ability_Warrior_Cleave") then
table.insert(cachedCleaveSlots, slot)
end
end
if name then
local lower = string.lower(name)
if lower == "heroic strike" or lower == "heroicstrike" or lower == "hs" then
table.insert(cachedHSSlots, slot)
elseif lower == "cleave" then
table.insert(cachedCleaveSlots, slot)
end
end
end
end
local function CheckQueuedAction(slotList)
for i = 1, table.getn(slotList) do
if IsCurrentAction(slotList[i]) then return true end
end
return false
end
local function IsHSOrCleaveQueued()
if not sw_hsqueue or not isWarrior then return false, false end
if useSpellQueueEvent then
return hsQueued, cleaveQueued
end
return CheckQueuedAction(cachedHSSlots), CheckQueuedAction(cachedCleaveSlots)
end
UpdateMovable(pfUI.swingtimer.mainhand)
UpdateMovable(pfUI.swingtimer.ranged)
-- VERSION B TEST: HasOffhandWeapon() removed.
-- The original used GetItemInfo() to detect OH weapon type, but GetItemInfo()
-- returns nil on first login before the item cache is populated, causing
-- offhand.speed to stay 0. Now we just read offhandAttackTime directly,
-- same as the old working version.
-- Check offhand slot for an actual weapon. GetItemInfo may return nil on first
-- login (item cache not yet populated), so we return nil in that case to signal
-- "unknown" rather than false, allowing the caller to keep the previous value.
-- inventoryType 13 = INVTYPE_WEAPONOFFHAND, 21 = INVTYPE_WEAPON (one-hand, dual wieldable)
-- Shields = 14, held-in-hand = 23, everything else = no swing
local OH_WEAPON_TYPES = { [13]=true, [21]=true }
local function HasOffhandWeapon()
local l = GetInventoryItemLink("player", 17)
if not l then return false end
local _, _, id = string.find(l, "item:(%d+)")
id = tonumber(id)
if not id then return false end
local s = GetItemStats and GetItemStats(id)
if not s then return false end
return OH_WEAPON_TYPES[s.inventoryType] == true
end
local function UpdateWeaponSpeeds()
if not GetUnitField then return end
local mhSpeed = GetUnitField("player", "baseAttackTime")
local ohSpeed = GetUnitField("player", "offhandAttackTime")
if mhSpeed and mhSpeed > 0 then
swingState.mainhand.speed = mhSpeed / 1000
end
if HasOffhandWeapon() and ohSpeed and ohSpeed > 0 then
swingState.offhand.speed = ohSpeed / 1000
else
swingState.offhand.speed = 0
end
local raSpeed = GetUnitField("player", "rangedAttackTime")
if raSpeed and raSpeed > 0 then
swingState.ranged.speed = raSpeed / 1000
else
swingState.ranged.speed = 0
end
end
local function StartSwing(isOffhand, resetFromNow)
local now = GetTime()
-- always refresh speeds to catch haste buffs/debuffs
UpdateWeaponSpeeds()
-- dual-wield guard: if MH swing just started (<100ms ago) and this isn't
-- flagged as offhand, it's likely an OH event with missing flag
if not isOffhand and swingState.offhand.speed > 0 then
local mhAge = now - (swingState.mainhand.nextSwing - swingState.mainhand.speed)
if swingState.mainhand.swinging and mhAge > 0 and mhAge < 0.1 then
isOffhand = true
end
end
if isOffhand and swingState.offhand.speed > 0 then
-- resetFromNow: hard reset (e.g. Slam) - always start from now
-- otherwise: chain from previous nextSwing to avoid timer jumps
local base = (not resetFromNow and swingState.offhand.swinging and swingState.offhand.nextSwing > (now - 0.5)) and swingState.offhand.nextSwing or now
swingState.offhand.nextSwing = base + swingState.offhand.speed
swingState.offhand.swinging = true
if sw_showoh then pfUI.swingtimer.offhand:Show() end
else
-- resetFromNow: hard reset (e.g. Slam) - always start from now
-- otherwise: chain from previous nextSwing to avoid timer jumps on HS/normal swing
local base = (not resetFromNow and swingState.mainhand.swinging and swingState.mainhand.nextSwing > (now - 0.5)) and swingState.mainhand.nextSwing or now
swingState.mainhand.nextSwing = base + swingState.mainhand.speed
swingState.mainhand.swinging = true
pfUI.swingtimer.mainhand:Show()
end
pfUI.swingtimer:Show()
end
local function StartRangedSwing()
if not sw_showranged then return end
UpdateWeaponSpeeds()
if swingState.ranged.speed <= 0 then return end
-- Ranged replaces MH: cancel mainhand swing
swingState.mainhand.swinging = false
pfUI.swingtimer.mainhand:Hide()
swingState.ranged.nextSwing = GetTime() + swingState.ranged.speed
swingState.ranged.swinging = true
if isHunter then
-- Hunter: left/right halves anchored to CENTER, shrink outside->in
pfUI.swingtimer.ranged.left:ClearAllPoints()
pfUI.swingtimer.ranged.left:SetPoint("RIGHT", pfUI.swingtimer.ranged, "CENTER", 0, 0)
pfUI.swingtimer.ranged.left:SetWidth(sw_width / 2)
pfUI.swingtimer.ranged.left:SetTexCoord(0, 0.5, 0, 1)
pfUI.swingtimer.ranged.right:SetWidth(sw_width / 2)
pfUI.swingtimer.ranged.right:SetTexCoord(0.5, 1, 0, 1)
else
-- Non-Hunter: left anchored to TOPLEFT, grows left->right like MH/OH
pfUI.swingtimer.ranged.left:ClearAllPoints()
pfUI.swingtimer.ranged.left:SetPoint("TOPLEFT", pfUI.swingtimer.ranged, "TOPLEFT", 0, 0)
pfUI.swingtimer.ranged.left:SetWidth(0.1)
pfUI.swingtimer.ranged.left:Hide()
pfUI.swingtimer.ranged.left:SetTexCoord(0, 0, 0, 1)
pfUI.swingtimer.ranged.right:SetWidth(0.1)
pfUI.swingtimer.ranged.right:Hide()
end
pfUI.swingtimer.ranged.left:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.right:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.warn:SetWidth(1)
pfUI.swingtimer.ranged.warn:Hide()
pfUI.swingtimer.ranged:Show()
pfUI.swingtimer:Show()
end
local function ResetSwingTimers()
swingState.mainhand.swinging = false
swingState.offhand.swinging = false
swingState.ranged.swinging = false
pfUI.swingtimer.mainhand:Hide()
pfUI.swingtimer.offhand:Hide()
pfUI.swingtimer.ranged:Hide()
pfUI.swingtimer:Hide()
end
local swingThrottle = 0
pfUI.swingtimer:SetScript("OnUpdate", function()
swingThrottle = swingThrottle + arg1
if swingThrottle < 0.016 then return end
swingThrottle = 0
local now = GetTime()
local anyActive = false
-- Execute delayed regen reset (only if combat hasn't resumed)
if swingState.regenResetPending and now >= swingState.regenResetPending then
swingState.regenResetPending = nil
ResetSwingTimers()
end
local curR, curG, curB = mhDefaultR, mhDefaultG, mhDefaultB
if sw_hsqueue and isWarrior then
local hs, cl = IsHSOrCleaveQueued()
if cl then
curR, curG, curB = 0.2, 0.9, 0.2
elseif hs then
curR, curG, curB = 0.9, 0.9, 0.2
end
end
if swingState.mainhand.swinging then
local remaining = swingState.mainhand.nextSwing - now
if remaining <= 0 then
swingState.mainhand.swinging = false
pfUI.swingtimer.mainhand:Hide()
else
local progress = 1 - (remaining / swingState.mainhand.speed)
pfUI.swingtimer.mainhand:SetValue(progress)
pfUI.swingtimer.mainhand:SetStatusBarColor(curR, curG, curB, mhA)
if sw_showtext then
pfUI.swingtimer.mainhand.text:SetText(string.format("%.1f", remaining))
end
anyActive = true
end
end
if sw_showoh and swingState.offhand.swinging then
local remaining = swingState.offhand.nextSwing - now
if remaining <= 0 then
swingState.offhand.swinging = false
pfUI.swingtimer.offhand:Hide()
else
local progress = 1 - (remaining / swingState.offhand.speed)
pfUI.swingtimer.offhand:SetValue(progress)
if sw_showtext then
pfUI.swingtimer.offhand.text:SetText(string.format("%.1f", remaining))
end
anyActive = true
end
elseif not sw_showoh then
pfUI.swingtimer.offhand:Hide()
end
if sw_showranged and swingState.ranged.swinging then
local remaining = swingState.ranged.nextSwing - now
if remaining <= 0 then
swingState.ranged.swinging = false
pfUI.swingtimer.ranged:Hide()
else
if isHunter then
-- Hunter ranged animation: two phases
-- Phase 1 (speed-0.5s): bar visible full, shrinks from outside->in toward center (normal color)
-- Phase 2 (0.5s): bar grows from center outward (warning color)
local DEADZONE = 0.5
local halfW = sw_width / 2
if remaining > DEADZONE then
-- Phase 1: left/right halves shrink from outside->in toward center
local elapsed = swingState.ranged.speed - remaining
local phase1dur = swingState.ranged.speed - DEADZONE
local p = elapsed / phase1dur -- 0 = full, 1 = gone
local w = halfW * (1 - p)
if w < 1 then w = 1 end
pfUI.swingtimer.ranged.left:Show()
pfUI.swingtimer.ranged.left:SetWidth(w)
pfUI.swingtimer.ranged.left:SetTexCoord(0, (1 - p) * 0.5, 0, 1)
pfUI.swingtimer.ranged.left:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.right:Show()
pfUI.swingtimer.ranged.right:SetWidth(w)
pfUI.swingtimer.ranged.right:SetTexCoord(1 - (1 - p) * 0.5, 1, 0, 1)
pfUI.swingtimer.ranged.right:SetVertexColor(raR, raG, raB, raA)
pfUI.swingtimer.ranged.warn:Hide()
else
-- Phase 2: warning color grows from center->outside
local p = 1 - (remaining / DEADZONE) -- 0 = nothing, 1 = full
local w = sw_width * p
if w < 1 then w = 1 end
pfUI.swingtimer.ranged.left:Hide()
pfUI.swingtimer.ranged.right:Hide()
pfUI.swingtimer.ranged.warn:SetWidth(w)
pfUI.swingtimer.ranged.warn:Show()
end
else
-- Non-Hunter (Warrior Throw, Rogue): simple left->right fill like MH/OH
local progress = 1 - (remaining / swingState.ranged.speed)
local w = sw_width * progress
if w < 1 then w = 1 end
pfUI.swingtimer.ranged.left:Show()
pfUI.swingtimer.ranged.left:SetWidth(w)
pfUI.swingtimer.ranged.left:SetTexCoord(0, progress, 0, 1)
pfUI.swingtimer.ranged.right:Hide()
pfUI.swingtimer.ranged.warn:Hide()
end
if sw_showtext then
if isHunter and remaining <= 0.5 then
pfUI.swingtimer.ranged.text:SetText(string.format("%.1f", remaining))
elseif isHunter then
-- Show time until deadzone starts, not full remaining
pfUI.swingtimer.ranged.text:SetText(string.format("%.1f", remaining - 0.5))
else
pfUI.swingtimer.ranged.text:SetText(string.format("%.1f", remaining))
end
end
anyActive = true
end
elseif not sw_showranged then
pfUI.swingtimer.ranged:Hide()
end
if not anyActive then
if not pfUI.swingtimer.mainhand:IsShown()
and not pfUI.swingtimer.offhand:IsShown()
and not pfUI.swingtimer.ranged:IsShown() then
this:Hide()
end
end
end)
local events = CreateFrame("Frame")
events:RegisterEvent("AUTO_ATTACK_SELF")
events:RegisterEvent("AUTO_ATTACK_OTHER")
events:RegisterEvent("PLAYER_ENTERING_WORLD")
events:RegisterEvent("UNIT_INVENTORY_CHANGED")
events:RegisterEvent("PLAYER_REGEN_DISABLED")
events:RegisterEvent("PLAYER_REGEN_ENABLED")
events:RegisterEvent("ACTIONBAR_SLOT_CHANGED")
events:RegisterEvent("UNIT_DIED")
events:RegisterEvent("SPELL_QUEUE_EVENT")
-- Use libdebuff SPELL_GO_SELF hook instead of registering the event separately
pfUI.libdebuff_spell_go_hooks = pfUI.libdebuff_spell_go_hooks or {}
pfUI.libdebuff_spell_go_hooks["swingtimer"] = function(spellId)
if RANGED_SPELLIDS[spellId] then
StartRangedSwing()
elseif slamSpellIDs[spellId] then
-- Slam resets the swing timer hard from now (not chained from previous nextSwing)
hsQueued = false; cleaveQueued = false
StartSwing(false, true)
elseif hsSpellIDs[spellId] then
hsQueued = false; cleaveQueued = false
StartSwing(false)
elseif cleaveSpellIDs[spellId] then
hsQueued = false; cleaveQueued = false
StartSwing(false)
end
end
-- Use libdebuff SPELL_CAST_EVENT hook instead of registering the event separately
pfUI.libdebuff_spell_cast_hooks = pfUI.libdebuff_spell_cast_hooks or {}
pfUI.libdebuff_spell_cast_hooks["swingtimer"] = function(success, spellId)
if success ~= 1 then return end
if hsSpellIDs[spellId] then
hsQueued = true; cleaveQueued = false
elseif cleaveSpellIDs[spellId] then
cleaveQueued = true; hsQueued = false
end
end
local playerGUID = nil
events:SetScript("OnEvent", function()
if event == "AUTO_ATTACK_SELF" then
local hitInfo = arg4 or 0
local isOffhand = bit.band(hitInfo, HITINFO_LEFTSWING) ~= 0
local noAction = bit.band(hitInfo, HITINFO_NOACTION) ~= 0
-- HITINFO_NOACTION: server did not advance the swing clock (extra attack), ignore
if noAction then return end
-- Fallback extra attack detection: some servers (e.g. Turtle WoW) do not set
-- HITINFO_NOACTION for extra attacks (Sword Specialization, Windfury, Hand of Justice).
-- An extra attack fires a second AUTO_ATTACK_SELF while the current swing timer is
-- still running with significant time remaining. In that case, the swing clock was
-- NOT reset by the server, so we must not reset our timer either.
local now = GetTime()
if not isOffhand and swingState.mainhand.swinging then
local remaining = swingState.mainhand.nextSwing - now
-- If more than 20% of swing speed remains, this is an extra attack, not a real swing
if remaining > swingState.mainhand.speed * 0.2 then return end
end
if isOffhand and swingState.offhand.swinging then
local remaining = swingState.offhand.nextSwing - now
if remaining > swingState.offhand.speed * 0.2 then return end
end
StartSwing(isOffhand)
elseif event == "AUTO_ATTACK_OTHER" then
-- arg1=attackerGuid, arg2=targetGuid, arg3=totalDamage, arg4=hitInfo, arg5=victimState
-- We want: someone attacked the player (targetGuid == playerGUID) and the player parried
local targetGuid = arg2
if not targetGuid or not playerGUID then return end
if targetGuid ~= playerGUID then return end
local victimState = arg5 or 0
-- VICTIMSTATE_PARRY = 3
-- Vanilla parry mechanic: resets swing timer to 60% of weapon speed,
-- but only if the remaining time is more than 40% of weapon speed (otherwise no change)
if victimState == 3 then
local now = GetTime()
local speed = swingState.mainhand.speed
local remaining = swingState.mainhand.nextSwing - now
local parryReset = speed * 0.6
local threshold = speed * 0.4
-- Only apply reset if it would actually reduce the remaining time
if remaining > parryReset then
swingState.mainhand.nextSwing = now + parryReset
swingState.mainhand.swinging = true
pfUI.swingtimer.mainhand:Show()
pfUI.swingtimer:Show()
elseif remaining <= 0 and threshold > 0 then
-- swing already fired but parry came in: reset to parryReset
swingState.mainhand.nextSwing = now + parryReset
swingState.mainhand.swinging = true
pfUI.swingtimer.mainhand:Show()
pfUI.swingtimer:Show()
end
end
elseif event == "SPELL_QUEUE_EVENT" then
local eventCode = arg1 or -1
local spellId = arg2 or 0
if eventCode == ON_SWING_QUEUED then
useSpellQueueEvent = true
if hsSpellIDs[spellId] then
hsQueued = true; cleaveQueued = false
elseif cleaveSpellIDs[spellId] then
cleaveQueued = true; hsQueued = false
else
end
elseif eventCode == ON_SWING_QUEUE_POPPED then
hsQueued = false; cleaveQueued = false
else
end
elseif event == "PLAYER_ENTERING_WORLD" then
local _, class = UnitClass("player")
isWarrior = (class == "WARRIOR")
local guid = GetUnitGUID("player")
playerGUID = guid
UpdateWeaponSpeeds()
RebuildQueueSlotCache()
elseif event == "UNIT_INVENTORY_CHANGED" then
if arg1 and arg1 ~= "player" then return end
UpdateWeaponSpeeds()
if swingState.offhand.speed == 0 then
swingState.offhand.swinging = false
pfUI.swingtimer.offhand:Hide()
end
if swingState.ranged.speed == 0 then
swingState.ranged.swinging = false
pfUI.swingtimer.ranged:Hide()
end
elseif event == "ACTIONBAR_SLOT_CHANGED" then
RebuildQueueSlotCache()
elseif event == "PLAYER_REGEN_DISABLED" then
swingState.regenResetPending = nil -- cancel delayed reset, combat resumed
UpdateWeaponSpeeds()
elseif event == "PLAYER_REGEN_ENABLED" then
-- Delay reset to avoid false resets between mobs in raids
-- If combat resumes within 2s (PLAYER_REGEN_DISABLED), cancel the reset
swingState.regenResetPending = GetTime() + 2.0
hsQueued = false
cleaveQueued = false
elseif event == "UNIT_DIED" then
-- Only reset if the player themselves died
local guid = arg1
if not guid then return end
if guid == playerGUID then
ResetSwingTimers()
end
end
end)
UpdateWeaponSpeeds()
end)