SFrames.AuraTracker = SFrames.AuraTracker or {} local AT = SFrames.AuraTracker AT.units = AT.units or {} AT.unitRefs = AT.unitRefs or {} AT.durationCache = AT.durationCache or { buff = {}, debuff = {} } AT.initialized = AT.initialized or false local SOURCE_PRIORITY = { player_native = 6, superwow = 5, nanamiplates = 4, shagutweaks = 3, combat_log = 2, estimated = 1, } local function GetNow() return GetTime and GetTime() or 0 end local function IsBuffType(auraType) return auraType == "buff" end local function ClampPositive(value) value = tonumber(value) or 0 if value > 0 then return value end return nil end local function SafeUnitGUID(unit) if unit and UnitGUID then local ok, guid = pcall(UnitGUID, unit) if ok and guid and guid ~= "" then return guid end end return nil end local function SafeUnitName(unit) if unit and UnitName then local ok, name = pcall(UnitName, unit) if ok and name and name ~= "" then return name end end return nil end local function GetUnitKey(unit) local guid = SafeUnitGUID(unit) if guid then return guid end local name = SafeUnitName(unit) if name then return "name:" .. name end return unit and ("unit:" .. unit) or nil end local function DurationCacheKey(auraType, spellId, name, texture) if spellId and spellId > 0 then return auraType .. ":id:" .. tostring(spellId) end if name and name ~= "" then return auraType .. ":name:" .. string.lower(name) end return auraType .. ":tex:" .. tostring(texture or "") end local function BuildStateKeys(auraType, spellId, name, texture, casterKey) local keys = {} if spellId and spellId > 0 then tinsert(keys, auraType .. ":id:" .. tostring(spellId)) if casterKey and casterKey ~= "" then tinsert(keys, auraType .. ":id:" .. tostring(spellId) .. ":caster:" .. casterKey) end end if name and name ~= "" then local lowerName = string.lower(name) tinsert(keys, auraType .. ":name:" .. lowerName) if casterKey and casterKey ~= "" then tinsert(keys, auraType .. ":name:" .. lowerName .. ":caster:" .. casterKey) end if texture and texture ~= "" then tinsert(keys, auraType .. ":name:" .. lowerName .. ":tex:" .. texture) end elseif texture and texture ~= "" then tinsert(keys, auraType .. ":tex:" .. texture) end return keys end local function TooltipLine1() return getglobal("SFramesScanTooltipTextLeft1") end local function CLMatch(msg, pattern) if not msg or not pattern or pattern == "" then return nil end local pat = string.gsub(pattern, "%%%d?%$?s", "(.+)") pat = string.gsub(pat, "%%%d?%$?d", "(%%d+)") for a, b, c in string.gfind(msg, pat) do return a, b, c end return nil end function AT:GetUnitState(unitOrGUID) local key = unitOrGUID if not key then return nil end if string.find(key, "^target") or string.find(key, "^player") or string.find(key, "^party") or string.find(key, "^raid") or string.find(key, "^pet") then key = GetUnitKey(key) end if not key then return nil end if not self.units[key] then self.units[key] = { guid = key, buffs = {}, debuffs = {}, maps = { buff = {}, debuff = {} }, snapshotCount = 0, lastSeen = 0, name = nil, level = 0, } end return self.units[key] end function AT:ClearUnit(unitOrGUID) local key = unitOrGUID if not key then return end if not self.units[key] then key = GetUnitKey(unitOrGUID) end if not key then return end self.units[key] = nil for unit, guid in pairs(self.unitRefs) do if guid == key then self.unitRefs[unit] = nil end end end function AT:ClearCurrentTarget() local oldGUID = self.unitRefs["target"] self.unitRefs["target"] = nil if oldGUID then local state = self.units[oldGUID] if state then state.maps.buff = {} state.maps.debuff = {} end end end function AT:RememberDuration(auraType, spellId, name, texture, duration) duration = ClampPositive(duration) if not duration then return end self.durationCache[auraType][DurationCacheKey(auraType, spellId, name, texture)] = duration end function AT:GetRememberedDuration(auraType, spellId, name, texture) return self.durationCache[auraType][DurationCacheKey(auraType, spellId, name, texture)] end function AT:ReadAuraName(unit, index, isBuff, auraID) if auraID and auraID > 0 and SpellInfo then local ok, spellName = pcall(SpellInfo, auraID) if ok and spellName and spellName ~= "" then return spellName end end -- Tooltip scan is expensive and can crash on invalid unit/index state. -- Only attempt if unit still exists and the aura slot is still valid. if not SFrames.Tooltip then return nil end if not unit or not UnitExists or not UnitExists(unit) then return nil end local ok, text = pcall(function() SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE") SFrames.Tooltip:ClearLines() if isBuff then SFrames.Tooltip:SetUnitBuff(unit, index) else SFrames.Tooltip:SetUnitDebuff(unit, index) end local line = TooltipLine1() local t = line and line.GetText and line:GetText() or nil SFrames.Tooltip:Hide() return t end) if ok and text and text ~= "" then return text end return nil end function AT:GetPlayerAuraTime(unit, index, isBuff, texture) if not UnitIsUnit or not UnitIsUnit(unit, "player") then return nil, nil, nil end if not GetPlayerBuff or not GetPlayerBuffTexture or not GetPlayerBuffTimeLeft then return nil, nil, nil end local filter = isBuff and "HELPFUL" or "HARMFUL" for i = 0, 31 do local buffIndex = GetPlayerBuff(i, filter) if buffIndex and buffIndex >= 0 and GetPlayerBuffTexture(buffIndex) == texture then local timeLeft = ClampPositive(GetPlayerBuffTimeLeft(buffIndex)) if timeLeft then return timeLeft, nil, "player_native" end end end return nil, nil, nil end function AT:GetExternalTime(unit, auraType, index, state) local isBuff = IsBuffType(auraType) local timeLeft, duration, source if state and state.texture then timeLeft, duration, source = self:GetPlayerAuraTime(unit, index, isBuff, state.texture) if timeLeft then return timeLeft, duration, source end end if not isBuff and NanamiPlates_SpellDB and NanamiPlates_SpellDB.UnitDebuff then local effect, _, _, _, _, npDuration, npTimeLeft = NanamiPlates_SpellDB:UnitDebuff(unit, index) npTimeLeft = ClampPositive(npTimeLeft) npDuration = ClampPositive(npDuration) if effect and effect ~= "" and (not state.name or state.name == "") then state.name = effect end if npTimeLeft then return npTimeLeft, npDuration, "nanamiplates" end end if not isBuff and ShaguTweaks and ShaguTweaks.libdebuff and ShaguTweaks.libdebuff.UnitDebuff then local _, _, _, _, _, shaguDuration, shaguTimeLeft = ShaguTweaks.libdebuff:UnitDebuff(unit, index) shaguTimeLeft = ClampPositive(shaguTimeLeft) shaguDuration = ClampPositive(shaguDuration) if shaguTimeLeft then return shaguTimeLeft, shaguDuration, "shagutweaks" end end return nil, nil, nil end function AT:ApplyTiming(state, timeLeft, duration, source, now, allowWeaker) timeLeft = ClampPositive(timeLeft) if not timeLeft then return false end duration = ClampPositive(duration) or state.duration or timeLeft local newPriority = SOURCE_PRIORITY[source] or 0 local oldPriority = SOURCE_PRIORITY[state.source] or 0 if not allowWeaker and state.expirationTime and state.expirationTime > now and oldPriority > newPriority then return false end state.duration = duration state.expirationTime = now + timeLeft state.appliedAt = state.expirationTime - duration state.source = source state.isEstimated = (source == "estimated") and 1 or nil self:RememberDuration(state.auraType, state.spellId, state.name, state.texture, duration) return true end function AT:CaptureAura(unit, auraType, index) local isBuff = IsBuffType(auraType) local texture, count, dispelType, auraID local hasSuperWoW = SFrames.superwow_active and SpellInfo if isBuff then texture, auraID = UnitBuff(unit, index) else texture, count, dispelType, auraID = UnitDebuff(unit, index) end if not texture then return nil end local spellId = (hasSuperWoW and type(auraID) == "number" and auraID > 0) and auraID or nil -- Only call ReadAuraName when we have a spellId (fast path) or for the first 16 slots -- to avoid spamming tooltip API on every aura slot every UNIT_AURA event. local name if spellId or index <= 16 then name = self:ReadAuraName(unit, index, isBuff, spellId) end return { auraType = auraType, index = index, spellId = spellId, name = name, texture = texture, stacks = tonumber(count) or 0, dispelType = dispelType, casterKey = nil, } end function AT:FindPreviousState(unitState, auraType, index, info) local previous = unitState.maps[auraType][index] if previous then local keys = BuildStateKeys(auraType, info.spellId, info.name, info.texture, info.casterKey) local prevKeys = BuildStateKeys(auraType, previous.spellId, previous.name, previous.texture, previous.casterKey) for _, key in ipairs(keys) do for _, prevKey in ipairs(prevKeys) do if key == prevKey then return previous end end end end local states = IsBuffType(auraType) and unitState.buffs or unitState.debuffs local keys = BuildStateKeys(auraType, info.spellId, info.name, info.texture, info.casterKey) for _, key in ipairs(keys) do local state = states[key] if state then return state end end return nil end function AT:UpdateSnapshotState(unitState, unit, auraType, index, info, now) local state = self:FindPreviousState(unitState, auraType, index, info) if not state then state = { guid = unitState.guid, auraType = auraType, firstSeenAt = now, } end state.guid = unitState.guid state.auraType = auraType state.spellId = info.spellId state.name = info.name or state.name state.texture = info.texture state.stacks = info.stacks state.dispelType = info.dispelType state.casterKey = info.casterKey state.lastSeen = now state.index = index local timeLeft, duration, source = self:GetExternalTime(unit, auraType, index, state) if timeLeft then self:ApplyTiming(state, timeLeft, duration, source, now) elseif state.expirationTime and state.expirationTime <= now then state.expirationTime = nil state.duration = nil state.appliedAt = nil state.source = nil end return state end function AT:RebuildStateMaps(unitState, auraType, slots, now) local active = {} local newMap = {} for index, state in pairs(slots) do if state then for _, key in ipairs(BuildStateKeys(auraType, state.spellId, state.name, state.texture, state.casterKey)) do active[key] = state end newMap[index] = state end end if IsBuffType(auraType) then unitState.buffs = active else unitState.debuffs = active end unitState.maps[auraType] = newMap unitState.lastSeen = now end function AT:ApplyCombatHint(auraType, auraName) if not auraName or auraName == "" then return end if auraType ~= "debuff" then return end local guid = self.unitRefs["target"] if not guid then return end local unitState = self.units[guid] if not unitState then return end local active = IsBuffType(auraType) and unitState.buffs or unitState.debuffs local state = active[auraType .. ":name:" .. string.lower(auraName)] if not state then return end local remembered = self:GetRememberedDuration(auraType, state.spellId, state.name, state.texture) if remembered then self:ApplyTiming(state, remembered, remembered, "combat_log", GetNow(), true) end end function AT:ClearCombatHint(auraName) if not auraName or auraName == "" then return end local guid = self.unitRefs["target"] if not guid then return end local unitState = self.units[guid] if not unitState then return end local lowerName = string.lower(auraName) for _, auraType in ipairs({ "buff", "debuff" }) do local active = IsBuffType(auraType) and unitState.buffs or unitState.debuffs local state = active[auraType .. ":name:" .. lowerName] if state then state.expirationTime = nil state.duration = nil state.appliedAt = nil state.source = nil end end end function AT:HandleCombatMessage(msg) if not msg or msg == "" or not UnitExists or not UnitExists("target") then return end local targetName = SafeUnitName("target") if not targetName then return end local targetUnit, auraName = CLMatch(msg, AURAADDEDOTHERHARMFUL or "%s is afflicted by %s.") if targetUnit == targetName and auraName then self:ApplyCombatHint("debuff", auraName) return end auraName, targetUnit = CLMatch(msg, AURAREMOVEDOTHER or "%s fades from %s.") if targetUnit == targetName and auraName then self:ClearCombatHint(auraName) end end function AT:HandleAuraSnapshot(unit) if not unit then return end if not UnitExists or not UnitExists(unit) then if unit == "target" then self:ClearCurrentTarget() end return end local guid = GetUnitKey(unit) if not guid then return end local now = GetNow() local unitState = self:GetUnitState(guid) unitState.guid = guid unitState.name = SafeUnitName(unit) unitState.level = (UnitLevel and UnitLevel(unit)) or 0 unitState.lastSeen = now self.unitRefs[unit] = guid local buffSlots = {} local debuffSlots = {} for i = 1, 32 do local info = self:CaptureAura(unit, "buff", i) if info then buffSlots[i] = self:UpdateSnapshotState(unitState, unit, "buff", i, info, now) end end for i = 1, 32 do local info = self:CaptureAura(unit, "debuff", i) if info then debuffSlots[i] = self:UpdateSnapshotState(unitState, unit, "debuff", i, info, now) end end self:RebuildStateMaps(unitState, "buff", buffSlots, now) self:RebuildStateMaps(unitState, "debuff", debuffSlots, now) unitState.snapshotCount = unitState.snapshotCount + 1 end function AT:GetAuraState(unit, auraType, index) local unitState = self:GetUnitState(unit) if not unitState then return nil end local map = unitState.maps[auraType] return map and map[index] or nil end function AT:GetAuraTimeLeft(unit, auraType, index) local state = self:GetAuraState(unit, auraType, index) if not state or not state.expirationTime then return nil end local remaining = state.expirationTime - GetNow() if remaining > 0 then return remaining end return nil end function AT:PurgeStaleUnits() local now = GetNow() local activeTargetGUID = self.unitRefs["target"] for guid, state in pairs(self.units) do if guid ~= activeTargetGUID and state.lastSeen and (now - state.lastSeen) > 120 then self.units[guid] = nil end end end function AT:OnEvent() if event == "PLAYER_TARGET_CHANGED" then if UnitExists and UnitExists("target") then self:HandleAuraSnapshot("target") else self:ClearCurrentTarget() end self:PurgeStaleUnits() return end if event == "UNIT_AURA" and arg1 == "target" then self:HandleAuraSnapshot("target") return end if event == "PLAYER_ENTERING_WORLD" then self:ClearCurrentTarget() self:PurgeStaleUnits() return end if arg1 and string.find(event, "CHAT_MSG_SPELL") then self:HandleCombatMessage(arg1) end end function AT:Initialize() if self.initialized then return end self.initialized = true local frame = CreateFrame("Frame", "SFramesAuraTracker", UIParent) self.frame = frame frame:RegisterEvent("PLAYER_TARGET_CHANGED") frame:RegisterEvent("UNIT_AURA") frame:RegisterEvent("PLAYER_ENTERING_WORLD") local chatEvents = { "CHAT_MSG_SPELL_SELF_DAMAGE", "CHAT_MSG_SPELL_SELF_BUFF", "CHAT_MSG_SPELL_PARTY_DAMAGE", "CHAT_MSG_SPELL_PARTY_BUFF", "CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE", "CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF", "CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE", "CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF", "CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE", "CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE", "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE", "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF", "CHAT_MSG_SPELL_CREATURE_VS_PARTY_BUFF", "CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF", } for _, ev in ipairs(chatEvents) do frame:RegisterEvent(ev) end frame:SetScript("OnEvent", function() AT:OnEvent() end) end