diff --git a/Filger.lua b/Filger.lua index 5be6de4..cdd22d0 100644 --- a/Filger.lua +++ b/Filger.lua @@ -1,22 +1,20 @@ local _, ns = ... local Filger = ns.Filger -local LibDispel = LibStub("LibDispel") -assert(LibDispel, "Filger requires LibDispel") - -local blacklist = ns.Filger.blacklist or {} - -- Blizzard local CreateFrame = _G.CreateFrame -local UnitIsUnit = _G.UnitIsUnit -local UnitIsOwnerOrControllerOfUnit = _G.UnitIsOwnerOrControllerOfUnit local IsSpellKnown = C_SpellBook.IsSpellKnown local GetInventoryItemLink = _G.GetInventoryItemLink local GetItemInfo = C_Item.GetItemInfo local GetItemCooldown = C_Container.GetItemCooldown local GetInventoryItemCooldown = _G.GetInventoryItemCooldown +local GetOverrideSpell = C_Spell and C_Spell.GetOverrideSpell local C_Secrets = _G.C_Secrets local issecretvalue = _G.issecretvalue +local C_StringUtil = _G.C_StringUtil +local C_CurveUtil = _G.C_CurveUtil +local C_DurationUtil = _G.C_DurationUtil +local Enum = _G.Enum -------------------------------------------------- -- Filger @@ -29,16 +27,33 @@ local isInit = false do local button_proto = {} + -- secret durations: text and color like OnUpdate ("2m", "45s", "4.3"; red under 5s) + local timeFormatter = C_StringUtil.CreateSecondsFormatter() + timeFormatter:SetDefaultAbbreviation(Enum.SecondsFormatterAbbreviation.OneLetter) + timeFormatter:SetStripIntervalWhitespace(Enum.SecondsFormatterIntervalWhitespace.StripIgnoreLocale) + timeFormatter:SetDesiredUnitCount(1) + timeFormatter:SetCanRoundUpLastUnit(true) + timeFormatter:SetMillisecondsThreshold(5) + + local timeColorCurve = C_CurveUtil.CreateColorCurve() + timeColorCurve:SetType(Enum.LuaCurveType.Step) + timeColorCurve:AddPoint(0, CreateColor(0.99, 0.31, 0.31)) + timeColorCurve:AddPoint(5, CreateColor(0.84, 0.75, 0.65)) + + -- aura container duration text (Mainline); Classic bindings have no SetTextColorCurve + if Filger.isMainline then + Filger.timeTextOptions = { + textFormatter = timeFormatter, + textColor = { curve = timeColorCurve, property = Enum.DurationTextBindingProperty.RemainingDuration } + } + end + function button_proto:OnEnter() if (GameTooltip:IsForbidden() and not self:IsVisible()) then return end GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - if self.auraIndex then - -- classic aura tooltip - GameTooltip:SetUnitAura(self.unit, self.auraIndex, self.isHarmful and "HARMFUL" or "HELPFUL") - elseif self.auraInstanceID and GameTooltip.SetUnitDebuffByAuraInstanceID then - -- retail aura tooltip + if self.auraInstanceID and GameTooltip.SetUnitDebuffByAuraInstanceID then if self.isHarmful then GameTooltip:SetUnitDebuffByAuraInstanceID(self.unit, self.auraInstanceID) else @@ -53,14 +68,16 @@ do end end - -- secret durations can't drive the OnUpdate timer text, Blizzard countdown numbers are used instead + -- secret durations can't drive the OnUpdate timer text, the duration text binding is used instead function button_proto:SetSecret(isSecret) if self.isSecret == isSecret then return end self.isSecret = isSecret - self.Cooldown:SetHideCountdownNumbers(not isSecret) + if self.TimeBinding then + self.TimeBinding:SetEnabled(isSecret) + end + self.Time:SetText("") if isSecret then self:SetScript("OnUpdate", nil) - self.Time:SetText("") end end @@ -91,25 +108,19 @@ do end end - --[[ Callback: button_proto:CreateButton(element, index) - Creates an aura/cooldown button at a given position. - - * self - the widget holding the aura buttons - * index - the position at which the aura button is to be created (number) - - ## Returns - * button - the button used to represent the aura (Button) + --[[ Filger:StyleButton(button) + Creates the button's visual regions (backdrop, cooldown, icon, count, time text). + Shared by Filger buttons and aura container buttons. --]] - function Filger:CreateButton(element, index) + function Filger:StyleButton(button) -- resources local font, fontSize, fontStyle = Filger.fonts.normal, 12, "OUTLINE" + -- aura container buttons are unnamed; "$parent" would create globals like "Icon" + local name = button:GetName() - local button = Mixin(CreateFrame("Frame", element:GetName() .. "Aura" .. index, element), button_proto) - button:SetWidth(element.size) - button:SetHeight(element.size) button:CreateBackdrop() - local cooldown = CreateFrame("Cooldown", "$parentCD", button, "CooldownFrameTemplate") + local cooldown = CreateFrame("Cooldown", name and name .. "CD", button, "CooldownFrameTemplate") cooldown:SetAllPoints() cooldown:SetReverse(true) cooldown:SetDrawEdge(false) @@ -118,29 +129,59 @@ do -- cooldown.noCooldownCount = true button.Cooldown = cooldown - local icon = button:CreateTexture("$parentIcon", "ARTWORK") + local icon = button:CreateTexture(name and name .. "Icon", "ARTWORK") icon:SetAllPoints() icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) button.Icon = icon - local overlay = CreateFrame("Frame", "$parentOverlay", button, nil) + local overlay = CreateFrame("Frame", name and name .. "Overlay", button, nil) overlay:SetAllPoints(button) overlay:SetFrameLevel(cooldown:GetFrameLevel() + 1) button.Overlay = overlay - local count = overlay:CreateFontString("$parentCount", "OVERLAY") + local count = overlay:CreateFontString(name and name .. "OverlayCount", "OVERLAY") count:SetPoint("BOTTOMRIGHT", button, "BOTTOMRIGHT", 0, 0) count:SetFont(font, fontSize, fontStyle) count:SetJustifyH("RIGHT") count:SetTextColor(0.84, 0.75, 0.65) button.Count = count - local time = overlay:CreateFontString("$parentTime", "OVERLAY") + local time = overlay:CreateFontString(name and name .. "OverlayTime", "OVERLAY") time:SetPoint("CENTER", button, "CENTER", 0, 0) time:SetFont(font, 20, fontStyle) time:SetJustifyH("CENTER") time:SetTextColor(0.84, 0.75, 0.65) button.Time = time + end + + --[[ Callback: button_proto:CreateButton(element, index) + Creates an aura/cooldown button at a given position. + + * self - the widget holding the aura buttons + * index - the position at which the aura button is to be created (number) + + ## Returns + * button - the button used to represent the aura (Button) + --]] + function Filger:CreateButton(element, index) + local button = Mixin(CreateFrame("Frame", element:GetName() .. "Aura" .. index, element), button_proto) + button:SetWidth(element.size) + button:SetHeight(element.size) + self:StyleButton(button) + + local time = button.Time + + -- only secret cooldowns (Mainline) need it; Classic bindings have no SetTextColorCurve + if Filger.isMainline then + local timeBinding = C_DurationUtil.CreateDurationTextBinding() + timeBinding:SetFontString(time) + timeBinding:SetFormatter(timeFormatter) + timeBinding:SetTextColorCurve(timeColorCurve, Enum.DurationTextBindingProperty.RemainingDuration) + timeBinding:SetExpiredText("") + timeBinding:SetZeroDurationText("") + timeBinding:SetEnabled(false) + button.TimeBinding = timeBinding + end local animation = button:CreateAnimationGroup() animation:SetLooping("BOUNCE") @@ -162,358 +203,6 @@ do end end --------------------------------------------------- --- AURAS --------------------------------------------------- -local aura_proto = { - createdButtons = 0, - anchoredButtons = 0, - visibleButtons = 0 -} - -do - --[[ Callback: button:UpdateColor(button, unit, data) - Called after the aura button has been updated. - - * self - the widget holding the aura buttons - * button - the updated aura button (Button) - * unit - the unit on which the aura is cast (string) - * data - the [UnitAuraInfo](https://wowpedia.fandom.com/wiki/Struct_UnitAuraInfo) object (table) - * index - the actual position of the aura button (number) - ]]-- - function aura_proto:UpdateColor(button, unit, data) - if button.Backdrop then - if (data.isHarmful and UnitCanAssist("player", unit)) or (data.isHelpful and UnitCanAttack(unit, "player") and UnitCanAttack("player", unit)) then - local color = Filger.colors[data.dispelName] - button.Backdrop:SetBackdropBorderColor(color.r, color.g, color.b, color.a or 1) - else - local color = Filger.config.general.backdrop.color - button.Backdrop:SetBackdropBorderColor(color.r, color.g, color.b, color.a or 1) - end - end - - if button.Icon and self.desaturated == true then - button.Icon:SetDesaturated(data.isHarmful and not data.isPlayerAura and not UnitIsUnit(unit, "player")) - end - - if button.Animation then - if data.isDispelable or data.isStealable then - button.Animation:Play() - button.Animation.playing = true - else - button.Animation:Stop() - button.Animation.playing = false - end - end - end - - --[[ Callback: aura_proto:Update(unit, data, index) - Update the aura button and its components. - - * self - the widget holding the aura buttons - * unit - the unit on which the aura is cast (string) - * data - the [UnitAuraInfo](https://wowpedia.fandom.com/wiki/Struct_UnitAuraInfo) object (table) - * index - the actual position of the aura button (number) - ]]-- - function aura_proto:Update(unit, data, index) - if not data.name then return end - - local button = self[index] - if not button then - button = Filger:CreateButton(self, index) - - table.insert(self, button) - - self.createdButtons = self.createdButtons + 1 - end - - -- tooltips - button.unit = unit - button.filter = self.filter - button.auraInstanceID = data.auraInstanceID - button.auraIndex = data.auraIndex - button.isHarmful = data.isHarmful - - -- timers - button.name = data.name - button.spellId = data.spellId - button.duration = data.duration or 0 - button.expirationTime = data.expirationTime or GetTime() - - if button.Cooldown then - if data.duration and data.duration > 0 then - button.Cooldown:SetCooldown(data.expirationTime - data.duration, data.duration, data.timeMod) - button.Cooldown:Show() - else - button.Cooldown:Hide() - end - end - - if button.Icon then - if data.icon then - button.Icon:SetTexture(data.icon) - button.Icon:Show() - else - button.Icon:Hide() - end - end - - if button.Count then - button.Count:SetText((data.applications and data.applications > 1) and data.applications or "") - end - - if data.duration and data.duration > 0 then - button:SetScript("OnUpdate", button.OnUpdate) - end - - button:Show() - - self:UpdateColor(button, unit, data) - end - - --[[ Override: aura_proto.SortAuras(a, b) - Defines a custom sorting algorithm for ordering the auras. - - Defaults to [AuraUtil.DefaultAuraCompare](https://github.com/Gethe/wow-ui-source/search?q=DefaultAuraCompare). - - Overridden by the more specific SortAuras overrides if they are defined. - --]] - aura_proto.SortAuras = function(a, b) - if (a.priority ~= b.priority) then - return a.priority > b.priority - end - - if (a.expirationTime ~= b.expirationTime) then - return a.expirationTime < b.expirationTime - end - - -- if (a.canApplyAura ~= b.canApplyAura) then - -- return a.canApplyAura - -- end - - return a.auraInstanceID < b.auraInstanceID - end - - --[[ Override: aura_proto:FilterAura(unit, data) - Defines a custom filter that controls if the aura button should be shown. - - * self - the widget holding the aura buttons - * unit - the unit for which the update has been triggered (string) - * data - [UnitAuraInfo](https://wowpedia.fandom.com/wiki/Struct_UnitAuraInfo) object (table) - - ## Returns - - * show - indicates whether the aura button should be shown (boolean) - --]] - function aura_proto:FilterAura(unit, data) - return data.enabled and data.applications >= data.stackThreshold - end - - function aura_proto:GetAuraIndex(unit, auraInstanceID, filter) - local index = 1 - repeat - local data = C_UnitAuras.GetAuraDataByIndex(unit, index, filter) - if not data then - return nil - elseif data.auraInstanceID == auraInstanceID then - return index - end - index = index + 1 - until (not data) - return nil - end - - --[[ Override: aura_proto:ProcessData(unit, data) - Add some custom value sto the UnitAuraInfo. - - * self - the widget holding the aura buttons - * unit - the unit for which the update has been triggered (string) - * data - [UnitAuraInfo](https://wowpedia.fandom.com/wiki/Struct_UnitAuraInfo) object (table) - - ## Returns - - * data - a custom 'UnitAuraInfo' object (table) - --]] - function aura_proto:ProcessData(unit, data) - if not data then return end - - if self.restricted then - -- unit comparisons may be secret while restricted - data.isPlayerAura = data.isFromPlayerOrPlayerPet - else - data.isPlayerAura = data.sourceUnit and (UnitIsUnit("player", data.sourceUnit) or UnitIsOwnerOrControllerOfUnit("player", data.sourceUnit)) - end - - data.dispelName = LibDispel:GetDispelType(data.spellId, data.dispelName) - data.isDispelable = LibDispel:IsDispelable(unit, data.spellId, data.dispelName, data.isHarmful) - - local spell = self.spells[data.spellId] - if spell then - data.enabled = spell.enabled or false - data.priority = spell.priority or 0 - data.stackThreshold = spell.stackThreshold or 0 - else - data.enabled = true - data.priority = 0 - data.stackThreshold = 0 - end - - if blacklist[data.spellId] or blacklist[data.name] then - data.enabled = false - end - - if Filger.isVanilla then - data.auraIndex = self:GetAuraIndex(unit, data.auraInstanceID, self.filter) - end - - return data - end - - -- Retail: while auras are secret, tainted code can't list them; only spells whose aura - -- isn't secret can be looked up one by one, so only listed spells are shown. - function aura_proto:ScanSpellAuras(unit) - local isHarmful = self.filter:find("HARMFUL") ~= nil - local isPlayer = self.filter:find("PLAYER") ~= nil - - for spellId in next, self.spells do - if not C_Secrets.ShouldSpellAuraBeSecret(spellId) then - local data = C_UnitAuras.GetUnitAuraBySpellID(unit, spellId) - if data and data.isHarmful == isHarmful and (not isPlayer or data.isFromPlayerOrPlayerPet) then - data = self:ProcessData(unit, data) - self.all[data.auraInstanceID] = data - - if self:FilterAura(unit, data) then - self.actives[data.auraInstanceID] = true - end - end - end - end - end - - function aura_proto:UpdateAuras(event, unit, updateInfo) - if (self.unit ~= unit) then return end - - local isRestricted = C_Secrets and C_Secrets.ShouldAurasBeSecret() - -- updateInfo is secret while restricted; cached auras are stale right after it - local isFullUpdate = isRestricted or self.restricted or not updateInfo or updateInfo.isFullUpdate - self.restricted = isRestricted - - local changed = false - - if isFullUpdate then - self.all = table.wipe(self.all or {}) - self.actives = table.wipe(self.actives or {}) - changed = true - - if isRestricted then - self:ScanSpellAuras(unit) - else - local slots = { C_UnitAuras.GetAuraSlots(unit, self.filter) } - for i = 2, #slots do -- #1 return is continuationToken, we don't care about it - local data = self:ProcessData(unit, C_UnitAuras.GetAuraDataBySlot(unit, slots[i])) - self.all[data.auraInstanceID] = data - - if self:FilterAura(unit, data) then - self.actives[data.auraInstanceID] = true - end - end - end - else - if updateInfo.addedAuras then - for _, data in next, updateInfo.addedAuras do - if not C_UnitAuras.IsAuraFilteredOutByInstanceID(unit, data.auraInstanceID, self.filter) then - self.all[data.auraInstanceID] = self:ProcessData(unit, data) - if self:FilterAura(unit, data) then - self.actives[data.auraInstanceID] = true - changed = true - end - end - end - end - - if updateInfo.updatedAuraInstanceIDs then - for _, auraInstanceID in next, updateInfo.updatedAuraInstanceIDs do - local data = self.all[auraInstanceID] - if data then - self.all[auraInstanceID] = self:ProcessData(unit, C_UnitAuras.GetAuraDataByAuraInstanceID(unit, auraInstanceID)) - if self:FilterAura(unit, data) then - self.actives[auraInstanceID] = true - changed = true - end - end - end - end - - if updateInfo.removedAuraInstanceIDs then - for _, auraInstanceID in next, updateInfo.removedAuraInstanceIDs do - if self.all[auraInstanceID] then - self.all[auraInstanceID] = nil - end - - if self.actives[auraInstanceID] then - self.actives[auraInstanceID] = nil - changed = true - end - end - end - end - - if changed then - -- instead of removing auras one by one, just wipe the tables entirely - -- and repopulate them, multiple table.remove calls are insanely slow - self.sorted = table.wipe(self.sorted or {}) - - for auraInstanceID, _ in next, self.actives do - table.insert(self.sorted, self.all[auraInstanceID]) - end - - table.sort(self.sorted, self.SortAuras) - - local numVisible = math.min(self.limit, #self.sorted) - - for i = 1, numVisible do - self:Update(unit, self.sorted[i], i) - end - - local visibleChanged = true - - if (numVisible ~= self.visibleButtons) then - self.visibleButtons = numVisible - visibleChanged = true - end - - for i = numVisible + 1, #self do - self[i]:Hide() - end - - if (visibleChanged or self.createdButtons > self.anchoredButtons) then - if visibleChanged then - self:SetPosition(1, numVisible) - else - self:SetPosition(self.anchoredButtons + 1, self.createdButtons) - self.anchoredButtons = self.createdButtons - end - end - end - end - - function aura_proto:ForceUpdate() - self:UpdateAuras("ForceUpdate", self.unit, nil) - end - - function aura_proto:UNIT_AURA(unit, updateInfo) - self:UpdateAuras("UNIT_AURA", unit, updateInfo) - end - - function aura_proto:PLAYER_TARGET_CHANGED(...) - self:ForceUpdate() - end - - function aura_proto:PLAYER_FOCUS_CHANGED(...) - self:ForceUpdate() - end -end - -------------------------------------------------- -- COOLDOWNS -------------------------------------------------- @@ -563,6 +252,7 @@ do if button.Cooldown then if data.durationObject then button.Cooldown:SetCooldownFromDurationObject(data.durationObject) + button.TimeBinding:SetDuration(data.durationObject) button.Cooldown:Show() elseif data.duration and data.duration > 0 then button.Cooldown:SetCooldown(data.expirationTime - data.duration, data.duration, data.timeMod) @@ -645,7 +335,7 @@ do start = cdInfo.startTime duration = cdInfo.duration end - enabled = cdInfo.enabled + enabled = cdInfo.isEnabled and 1 or 0 end elseif data.slotId then index = "SLOT_" .. data.slotId @@ -669,6 +359,8 @@ do index = index, name = name, spellId = data.spellId, + -- a talent can replace one tracked spell with another (e.g. Sentinel overrides Avenging Wrath) + activeSpellId = data.spellId and GetOverrideSpell and GetOverrideSpell(data.spellId) or data.spellId, slotId = data.slotId, itemId = data.itemId, icon = icon, @@ -682,19 +374,28 @@ do } end - function cooldown_proto:UpdateCooldowns(event, unit) + function cooldown_proto:UpdateCooldowns(event, unit, spellID, baseSpellID) local changed = true - self.all = table.wipe(self.all or {}) - self.active = table.wipe(self.active or {}) + self.active = self.active or {} + if not spellID then + table.wipe(self.active) + end for _, row in next, self.spells do - local data = self:ProcessCooldownData(unit, row) - self.all[data.index] = data - - if self:FilterCooldown(data) then - self.active[data.index] = true - elseif data.spellId then - self.casted[data.spellId] = nil + -- no event fires when a cooldown ends, so shown rows are re-checked on every event; + -- slots and items are always re-checked because their events carry the item's spell ID + if not spellID or not row.spellId or self.active[row] + or row.spellId == spellID or row.spellId == baseSpellID then + local data = self:ProcessCooldownData(unit, row) + + if self:FilterCooldown(data) then + self.active[row] = data + else + self.active[row] = nil + if data.spellId then + self.casted[data.spellId] = nil + end + end end end @@ -702,9 +403,14 @@ do -- instead of removing auras one by one, just wipe the tables entirely -- and repopulate them, multiple table.remove calls are insanely slow self.sorted = table.wipe(self.sorted or {}) + self.shownSpells = table.wipe(self.shownSpells or {}) - for index, _ in next, self.active do - table.insert(self.sorted, self.all[index]) + for _, data in next, self.active do + local id = data.activeSpellId + if not id or not self.shownSpells[id] then + if id then self.shownSpells[id] = true end + table.insert(self.sorted, data) + end end table.sort(self.sorted, self.SortCooldowns) @@ -741,8 +447,9 @@ do self:UpdateCooldowns("ForceUpdate", self.unit, nil) end - function cooldown_proto:SPELL_UPDATE_COOLDOWN() - self:UpdateCooldowns("SPELL_UPDATE_COOLDOWN", self.unit) + -- spellID is nil when every cooldown should be updated + function cooldown_proto:SPELL_UPDATE_COOLDOWN(spellID, baseSpellID) + self:UpdateCooldowns("SPELL_UPDATE_COOLDOWN", self.unit, spellID, baseSpellID) end -- register which spell the player cast @@ -795,10 +502,10 @@ do end end - function Filger:Spawn(data) - local index = #self - local unit = data.unit:gsub("^%l", string.upper):gsub("t(arget)", "T%1") - local element = Mixin(CreateFrame("Frame", self:GetName() .. unit .. index, self), element_proto) + function Filger:Spawn(key, data) + -- "PLAYER_AURAS" -> "FilgerPlayerAuras" + local name = key:lower():gsub("^%l", string.upper):gsub("_(%l)", string.upper) + local element = Mixin(CreateFrame("Frame", self:GetName() .. name, self), element_proto) -- configuration element.unit = data.unit or "player" @@ -834,8 +541,12 @@ do element:RegisterEvent("SPELL_UPDATE_COOLDOWN") element:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", self.unit) + elseif data.groups then + -- Mainline only: Blizzard's secure aura container filters and displays the auras + element = Mixin(element, ns.container_proto) + element:CreateContainer(data) else - element = Mixin(element, aura_proto) + element = Mixin(element, ns.aura_proto) -- overrider element functions with configurations element.SortAuras = data.SortAuras or element.SortAuras @@ -888,9 +599,9 @@ function Filger:PLAYER_LOGIN() -- self:RegisterEvent("PLAYER_ENTERING_WORLD") -- create panels based on spell list - for _, data in pairs(self.config.frames) do + for key, data in pairs(self.config.frames) do if (data.enabled) then - local element = self:Spawn(data) + local element = self:Spawn(key, data) element:ForceUpdate() table.insert(self, element) end diff --git a/Filger.toc b/Filger.toc index 6013deb..8d32ae1 100644 --- a/Filger.toc +++ b/Filger.toc @@ -23,4 +23,5 @@ data\shared\blacklist.lua data\shared\cooldowns.lua core\init.xml config.lua +core\[Family]\auras.lua Filger.lua diff --git a/config.lua b/config.lua index c17308e..ff902aa 100644 --- a/config.lua +++ b/config.lua @@ -3,10 +3,7 @@ local Filger = ns.Filger ns.data.Finalize() -local class = Filger.class local cooldowns = Filger.cooldowns or {} -local spells = Filger.spells or {} -local all = Filger.all or {} ------------------------------------------------------------ -- Config @@ -28,10 +25,6 @@ local all = Filger.all or {} * hidePlayer -- ]] -local size = 35 -local spacing = 5 -local xOffset, yOffset = 239, 39 - Filger.config = { ["general"] = { ["backdrop"] = { @@ -44,133 +37,14 @@ Filger.config = { ["well_fed"] = false, -- enabled filter to hide well fed buff (too many spells with this name) ["gametooltip"] = true, -- enabled tooltips }, + -- shared by the aura frames in core//auras.lua + ["layout"] = { + size = 35, + spacing = 5, + xOffset = 239, + yOffset = 39 + }, ["frames"] = { - -- LEFT - ["PLAYER_DEBUFFS"] = { - enabled = true, - unit = "player", - filter = "HARMFUL", - anchor = { "RIGHT", UIParent, "CENTER", -xOffset, yOffset }, - limit = 8, - size = size, - spacing = spacing, - initialAnchor = "BOTTOMRIGHT", - ["growth-x"] = "LEFT", - ["growth-y"] = "DOWN", - spells = { - [124275] = { enabled = true, priority = 3 }, -- Light Stagger - [124274] = { enabled = true, priority = 3 }, -- Moderate Stagger - [124273] = { enabled = true, priority = 3 } -- Heavy Stagger - } - }, - ["PLAYER_AURAS"] = { - enabled = true, - unit = "player", - filter = "PLAYER|HELPFUL", - anchor = { "RIGHT", UIParent, "CENTER", -xOffset, 0 }, - limit = 8, - size = size, - spacing = spacing, - initialAnchor = "BOTTOMRIGHT", - ["growth-x"] = "LEFT", - ["growth-y"] = "DOWN", - spells = Mixin(spells[class] or {}, spells["ALL"] or {}) - }, - ["PLAYER_BUFFS"] = { - enabled = true, - unit = "player", - filter = "HELPFUL", - caster = "all", - hidePlayer = true, - anchor = { "RIGHT", UIParent, "CENTER", -xOffset, -yOffset }, - limit = 8, - size = size, - spacing = spacing, - initialAnchor = "BOTTOMRIGHT", - ["growth-x"] = "LEFT", - ["growth-y"] = "DOWN", - FilterAura = function(element, unit, data) - return data.enabled and not data.isPlayerAura - end - }, - ["TARGET_DEBUFFS"] = { - enabled = true, - unit = "target", - filter = "HARMFUL", - anchor = { "LEFT", UIParent, "CENTER", xOffset, yOffset }, - limit = 8, - size = size, - spacing = spacing, - initialAnchor = "BOTTOMLEFT", - ["growth-x"] = "RIGHT", - ["growth-y"] = "DOWN", - spells = all, - FilterAura = function(element, unit, data) - if UnitCanAttack(unit, "player") then - return data.enabled and data.isPlayerAura - end - return data.enabled and not data.isPlayerAura - end, - SortAuras = function(a, b) - if (a.priority ~= b.priority) then - return a.priority < b.priority - end - - if (a.expirationTime ~= b.expirationTime) then - return a.expirationTime < b.expirationTime - end - - if (a.isPlayerAura ~= b.isPlayerAura) then - return a.isPlayerAura - end - - return a.auraInstanceID < b.auraInstanceID - end - }, - ["TARGET_AURAS"] = { - enabled = true, - unit = "target", - filter = "HELPFUL", - anchor = { "LEFT", UIParent, "CENTER", xOffset, 0 }, - limit = 8, - size = size, - spacing = spacing, - initialAnchor = "BOTTOMLEFT", - ["growth-x"] = "RIGHT", - ["growth-y"] = "DOWN", - spells = all - }, - ["FOCUS_DEBUFFS"] = { - enabled = true, - unit = "focus", - filter = "HARMFUL", - anchor = { "LEFT", UIParent, "CENTER", xOffset, -yOffset }, - limit = 8, - size = size, - spacing = spacing, - initialAnchor = "BOTTOMLEFT", - ["growth-x"] = "RIGHT", - ["growth-y"] = "DOWN", - desaturated = false, - FilterAura = function(element, unit, data) - return data.enabled and data.isBossAura or (data.sourceUnit == nil) or not data.isFromPlayerOrPlayerPet - end - }, - ["BOSS_DEBUFFS"] = { - enabled = false, - unit = "player", - filter = "HARMFUL", - anchor = { "CENTER", UIParent, "CENTER", 0, 160 }, - limit = 1, - size = math.floor(1.5 * size), - spacing = spacing, - initialAnchor = "BOTTOMLEFT", - ["growth-x"] = "RIGHT", - ["growth-y"] = "DOWN", - FilterAura = function(element, unit, data) - return data.enabled and data.isBossAura or (data.sourceUnit == nil) - end - }, ["PLAYER_COOLDOWNS"] = { enabled = true, unit = "player", diff --git a/core/Classic/auras.lua b/core/Classic/auras.lua new file mode 100644 index 0000000..7e6cce5 --- /dev/null +++ b/core/Classic/auras.lua @@ -0,0 +1,487 @@ +local _, ns = ... +local Filger = ns.Filger + +local LibDispel = LibStub("LibDispel") +assert(LibDispel, "Filger requires LibDispel") + +local blacklist = ns.Filger.blacklist or {} + +-- Blizzard +local UnitIsUnit = _G.UnitIsUnit +local UnitIsOwnerOrControllerOfUnit = _G.UnitIsOwnerOrControllerOfUnit +local C_Secrets = _G.C_Secrets + +-------------------------------------------------- +-- AURAS +-------------------------------------------------- +local aura_proto = { + createdButtons = 0, + anchoredButtons = 0, + visibleButtons = 0 +} + +do + --[[ Callback: button:UpdateColor(button, unit, data) + Called after the aura button has been updated. + + * self - the widget holding the aura buttons + * button - the updated aura button (Button) + * unit - the unit on which the aura is cast (string) + * data - the [UnitAuraInfo](https://wowpedia.fandom.com/wiki/Struct_UnitAuraInfo) object (table) + * index - the actual position of the aura button (number) + ]]-- + function aura_proto:UpdateColor(button, unit, data) + if button.Backdrop then + if (data.isHarmful and UnitCanAssist("player", unit)) or (data.isHelpful and UnitCanAttack(unit, "player") and UnitCanAttack("player", unit)) then + local color = Filger.colors[data.dispelName] + button.Backdrop:SetBackdropBorderColor(color.r, color.g, color.b, color.a or 1) + else + local color = Filger.config.general.backdrop.color + button.Backdrop:SetBackdropBorderColor(color.r, color.g, color.b, color.a or 1) + end + end + + if button.Icon and self.desaturated == true then + button.Icon:SetDesaturated(data.isHarmful and not data.isPlayerAura and not UnitIsUnit(unit, "player")) + end + + if button.Animation then + if data.isDispelable or data.isStealable then + button.Animation:Play() + button.Animation.playing = true + else + button.Animation:Stop() + button.Animation.playing = false + end + end + end + + --[[ Callback: aura_proto:Update(unit, data, index) + Update the aura button and its components. + + * self - the widget holding the aura buttons + * unit - the unit on which the aura is cast (string) + * data - the [UnitAuraInfo](https://wowpedia.fandom.com/wiki/Struct_UnitAuraInfo) object (table) + * index - the actual position of the aura button (number) + ]]-- + function aura_proto:Update(unit, data, index) + if not data.name then return end + + local button = self[index] + if not button then + button = Filger:CreateButton(self, index) + + table.insert(self, button) + + self.createdButtons = self.createdButtons + 1 + end + + -- tooltips + button.unit = unit + button.filter = self.filter + button.auraInstanceID = data.auraInstanceID + button.isHarmful = data.isHarmful + + -- timers + button.name = data.name + button.spellId = data.spellId + button.duration = data.duration or 0 + button.expirationTime = data.expirationTime or GetTime() + + if button.Cooldown then + if data.duration and data.duration > 0 then + button.Cooldown:SetCooldown(data.expirationTime - data.duration, data.duration, data.timeMod) + button.Cooldown:Show() + else + button.Cooldown:Hide() + end + end + + if button.Icon then + if data.icon then + button.Icon:SetTexture(data.icon) + button.Icon:Show() + else + button.Icon:Hide() + end + end + + if button.Count then + button.Count:SetText((data.applications and data.applications > 1) and data.applications or "") + end + + if data.duration and data.duration > 0 then + button:SetScript("OnUpdate", button.OnUpdate) + end + + button:Show() + + self:UpdateColor(button, unit, data) + end + + --[[ Override: aura_proto.SortAuras(a, b) + Defines a custom sorting algorithm for ordering the auras. + + Defaults to [AuraUtil.DefaultAuraCompare](https://github.com/Gethe/wow-ui-source/search?q=DefaultAuraCompare). + + Overridden by the more specific SortAuras overrides if they are defined. + --]] + aura_proto.SortAuras = function(a, b) + if (a.priority ~= b.priority) then + return a.priority > b.priority + end + + if (a.expirationTime ~= b.expirationTime) then + return a.expirationTime < b.expirationTime + end + + -- if (a.canApplyAura ~= b.canApplyAura) then + -- return a.canApplyAura + -- end + + return a.auraInstanceID < b.auraInstanceID + end + + --[[ Override: aura_proto:FilterAura(unit, data) + Defines a custom filter that controls if the aura button should be shown. + + * self - the widget holding the aura buttons + * unit - the unit for which the update has been triggered (string) + * data - [UnitAuraInfo](https://wowpedia.fandom.com/wiki/Struct_UnitAuraInfo) object (table) + + ## Returns + + * show - indicates whether the aura button should be shown (boolean) + --]] + function aura_proto:FilterAura(unit, data) + return data.enabled and data.applications >= data.stackThreshold + end + + --[[ Override: aura_proto:ProcessData(unit, data) + Add some custom value sto the UnitAuraInfo. + + * self - the widget holding the aura buttons + * unit - the unit for which the update has been triggered (string) + * data - [UnitAuraInfo](https://wowpedia.fandom.com/wiki/Struct_UnitAuraInfo) object (table) + + ## Returns + + * data - a custom 'UnitAuraInfo' object (table) + --]] + function aura_proto:ProcessData(unit, data) + if not data then return end + + if self.restricted then + -- unit comparisons may be secret while restricted + data.isPlayerAura = data.isFromPlayerOrPlayerPet + else + data.isPlayerAura = data.sourceUnit and (UnitIsUnit("player", data.sourceUnit) or UnitIsOwnerOrControllerOfUnit("player", data.sourceUnit)) + end + + data.dispelName = LibDispel:GetDispelType(data.spellId, data.dispelName) + data.isDispelable = LibDispel:IsDispelable(unit, data.spellId, data.dispelName, data.isHarmful) + + local spell = self.spells[data.spellId] + if spell then + data.enabled = spell.enabled or false + data.priority = spell.priority or 0 + data.stackThreshold = spell.stackThreshold or 0 + else + data.enabled = true + data.priority = 0 + data.stackThreshold = 0 + end + + if blacklist[data.spellId] or blacklist[data.name] then + data.enabled = false + end + + return data + end + + -- Retail: while auras are secret, tainted code can't list them; only spells whose aura + -- isn't secret can be looked up one by one, so only listed spells are shown. + function aura_proto:ScanSpellAuras(unit) + local isHarmful = self.filter:find("HARMFUL") ~= nil + local isPlayer = self.filter:find("PLAYER") ~= nil + + for spellId in next, self.spells do + if not C_Secrets.ShouldSpellAuraBeSecret(spellId) then + local data = C_UnitAuras.GetUnitAuraBySpellID(unit, spellId) + if data and data.isHarmful == isHarmful and (not isPlayer or data.isFromPlayerOrPlayerPet) then + data = self:ProcessData(unit, data) + self.all[data.auraInstanceID] = data + + if self:FilterAura(unit, data) then + self.actives[data.auraInstanceID] = true + end + end + end + end + end + + function aura_proto:UpdateAuras(event, unit, updateInfo) + if (self.unit ~= unit) then return end + + local isRestricted = C_Secrets and C_Secrets.ShouldAurasBeSecret() + -- updateInfo is secret while restricted; cached auras are stale right after it + local isFullUpdate = isRestricted or self.restricted or not updateInfo or updateInfo.isFullUpdate + self.restricted = isRestricted + + local changed = false + + if isFullUpdate then + self.all = table.wipe(self.all or {}) + self.actives = table.wipe(self.actives or {}) + changed = true + + if isRestricted then + self:ScanSpellAuras(unit) + else + local slots = { C_UnitAuras.GetAuraSlots(unit, self.filter) } + for i = 2, #slots do -- #1 return is continuationToken, we don't care about it + local data = self:ProcessData(unit, C_UnitAuras.GetAuraDataBySlot(unit, slots[i])) + self.all[data.auraInstanceID] = data + + if self:FilterAura(unit, data) then + self.actives[data.auraInstanceID] = true + end + end + end + else + if updateInfo.addedAuras then + for _, data in next, updateInfo.addedAuras do + if not C_UnitAuras.IsAuraFilteredOutByInstanceID(unit, data.auraInstanceID, self.filter) then + self.all[data.auraInstanceID] = self:ProcessData(unit, data) + if self:FilterAura(unit, data) then + self.actives[data.auraInstanceID] = true + changed = true + end + end + end + end + + if updateInfo.updatedAuraInstanceIDs then + for _, auraInstanceID in next, updateInfo.updatedAuraInstanceIDs do + local data = self.all[auraInstanceID] + if data then + self.all[auraInstanceID] = self:ProcessData(unit, C_UnitAuras.GetAuraDataByAuraInstanceID(unit, auraInstanceID)) + if self:FilterAura(unit, data) then + self.actives[auraInstanceID] = true + changed = true + end + end + end + end + + if updateInfo.removedAuraInstanceIDs then + for _, auraInstanceID in next, updateInfo.removedAuraInstanceIDs do + if self.all[auraInstanceID] then + self.all[auraInstanceID] = nil + end + + if self.actives[auraInstanceID] then + self.actives[auraInstanceID] = nil + changed = true + end + end + end + end + + if changed then + -- instead of removing auras one by one, just wipe the tables entirely + -- and repopulate them, multiple table.remove calls are insanely slow + self.sorted = table.wipe(self.sorted or {}) + + for auraInstanceID, _ in next, self.actives do + table.insert(self.sorted, self.all[auraInstanceID]) + end + + table.sort(self.sorted, self.SortAuras) + + local numVisible = math.min(self.limit, #self.sorted) + + for i = 1, numVisible do + self:Update(unit, self.sorted[i], i) + end + + local visibleChanged = true + + if (numVisible ~= self.visibleButtons) then + self.visibleButtons = numVisible + visibleChanged = true + end + + for i = numVisible + 1, #self do + self[i]:Hide() + end + + if (visibleChanged or self.createdButtons > self.anchoredButtons) then + if visibleChanged then + self:SetPosition(1, numVisible) + else + self:SetPosition(self.anchoredButtons + 1, self.createdButtons) + self.anchoredButtons = self.createdButtons + end + end + end + end + + function aura_proto:ForceUpdate() + self:UpdateAuras("ForceUpdate", self.unit, nil) + end + + function aura_proto:UNIT_AURA(unit, updateInfo) + self:UpdateAuras("UNIT_AURA", unit, updateInfo) + end + + function aura_proto:PLAYER_TARGET_CHANGED(...) + self:ForceUpdate() + end + + function aura_proto:PLAYER_FOCUS_CHANGED(...) + self:ForceUpdate() + end +end + +ns.aura_proto = aura_proto + +-------------------------------------------------- +-- FRAMES +-------------------------------------------------- +local class = Filger.class +local spells = Filger.spells or {} +local all = Filger.all or {} + +local layout = Filger.config.layout +local size, spacing = layout.size, layout.spacing +local xOffset, yOffset = layout.xOffset, layout.yOffset + +Mixin(Filger.config.frames, { + -- LEFT + ["PLAYER_DEBUFFS"] = { + enabled = true, + unit = "player", + filter = "HARMFUL", + anchor = { "RIGHT", UIParent, "CENTER", -xOffset, yOffset }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMRIGHT", + ["growth-x"] = "LEFT", + ["growth-y"] = "DOWN", + spells = { + [124275] = { enabled = true, priority = 3 }, -- Light Stagger + [124274] = { enabled = true, priority = 3 }, -- Moderate Stagger + [124273] = { enabled = true, priority = 3 } -- Heavy Stagger + } + }, + ["PLAYER_AURAS"] = { + enabled = true, + unit = "player", + filter = "PLAYER|HELPFUL", + anchor = { "RIGHT", UIParent, "CENTER", -xOffset, 0 }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMRIGHT", + ["growth-x"] = "LEFT", + ["growth-y"] = "DOWN", + spells = Mixin(spells[class] or {}, spells["ALL"] or {}) + }, + ["PLAYER_BUFFS"] = { + enabled = true, + unit = "player", + filter = "HELPFUL", + caster = "all", + hidePlayer = true, + anchor = { "RIGHT", UIParent, "CENTER", -xOffset, -yOffset }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMRIGHT", + ["growth-x"] = "LEFT", + ["growth-y"] = "DOWN", + FilterAura = function(element, unit, data) + return data.enabled and not data.isPlayerAura + end + }, + ["TARGET_DEBUFFS"] = { + enabled = true, + unit = "target", + filter = "HARMFUL", + anchor = { "LEFT", UIParent, "CENTER", xOffset, yOffset }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMLEFT", + ["growth-x"] = "RIGHT", + ["growth-y"] = "DOWN", + spells = all, + FilterAura = function(element, unit, data) + if UnitCanAttack(unit, "player") then + return data.enabled and data.isPlayerAura + end + return data.enabled and not data.isPlayerAura + end, + SortAuras = function(a, b) + if (a.priority ~= b.priority) then + return a.priority < b.priority + end + + if (a.expirationTime ~= b.expirationTime) then + return a.expirationTime < b.expirationTime + end + + if (a.isPlayerAura ~= b.isPlayerAura) then + return a.isPlayerAura + end + + return a.auraInstanceID < b.auraInstanceID + end + }, + ["TARGET_AURAS"] = { + enabled = true, + unit = "target", + filter = "HELPFUL", + anchor = { "LEFT", UIParent, "CENTER", xOffset, 0 }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMLEFT", + ["growth-x"] = "RIGHT", + ["growth-y"] = "DOWN", + spells = all + }, + ["FOCUS_DEBUFFS"] = { + enabled = true, + unit = "focus", + filter = "HARMFUL", + anchor = { "LEFT", UIParent, "CENTER", xOffset, -yOffset }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMLEFT", + ["growth-x"] = "RIGHT", + ["growth-y"] = "DOWN", + desaturated = false, + FilterAura = function(element, unit, data) + return data.enabled and data.isBossAura or (data.sourceUnit == nil) or not data.isFromPlayerOrPlayerPet + end + }, + ["BOSS_DEBUFFS"] = { + enabled = false, + unit = "player", + filter = "HARMFUL", + anchor = { "CENTER", UIParent, "CENTER", 0, 160 }, + limit = 1, + size = math.floor(1.5 * size), + spacing = spacing, + initialAnchor = "BOTTOMLEFT", + ["growth-x"] = "RIGHT", + ["growth-y"] = "DOWN", + FilterAura = function(element, unit, data) + return data.enabled and data.isBossAura or (data.sourceUnit == nil) + end + }, +}) diff --git a/core/Mainline/auras.lua b/core/Mainline/auras.lua new file mode 100644 index 0000000..86b81d1 --- /dev/null +++ b/core/Mainline/auras.lua @@ -0,0 +1,292 @@ +local _, ns = ... +local Filger = ns.Filger + +local blacklist = ns.Filger.blacklist or {} + +-------------------------------------------------- +-- AURA CONTAINERS +-------------------------------------------------- +-- Blizzard's secure container filters, sorts and displays auras that are secret to Filger. +local container_proto = {} + +do + local AnchorUtil = _G.AnchorUtil + local AuraContainerSortMethod = _G.AuraContainerSortMethod + local C_XMLUtil = _G.C_XMLUtil + + local TEMPLATE = "CustomAuraContainerTemplate" + + -- container buttons can only be styled here: once auras are secret, tainted code can't touch them + local function CreateInitializer(element) + local size = element.size + local tooltip = Filger.config.general.gametooltip + + return function(button) + button:SetSize(size, size) + Filger:StyleButton(button) + + button:SetIcon(button.Icon) + button:SetDurationCooldown(button.Cooldown) + button:SetApplicationCount(button.Count) + button:SetDurationText(button.Time, Filger.timeTextOptions) + + if tooltip then + button:SetTooltipAnchorPoint("ANCHOR_RIGHT") + else + button:SetMouseMotionEnabled(false) + end + end + end + + function container_proto:CreateContainer(data) + if not C_XMLUtil.GetTemplateInfo(TEMPLATE) then + Filger:warn(TEMPLATE .. " not found, " .. self:GetName() .. " is disabled.") + return + end + + local container = CreateFrame("AuraContainer", self:GetName() .. "Container", self, TEMPLATE) + container:SetPoint(self.initialAnchor, self, self.initialAnchor) + container:SetFlowLayoutAnchorPoint(self.initialAnchor) + container:SetFlowLayoutGrowthDirection( + (self["growth-x"] == "LEFT") and AnchorUtil.FlowDirection.Left or AnchorUtil.FlowDirection.Right, + (self["growth-y"] == "DOWN") and AnchorUtil.FlowDirection.Down or AnchorUtil.FlowDirection.Up) + container:SetUnit(self.unit) + + local initializeFrame = CreateInitializer(self) + local layout = { + elementSpacing = self.spacing, + lineSpacing = self.spacing, + elementWidth = self.size, + elementHeight = self.size + } + + for i, group in ipairs(data.groups) do + local key = self:GetName() .. "Group" .. i + container:AddAuraGroup(key, group.filter, { + maxFrameCount = self.limit, + candidateFilters = group.candidateFilters, + sortMethod = AuraContainerSortMethod.ExpirationOnly, + layout = layout, + initializeFrame = initializeFrame + }) + + -- groups with `hostile` are only enabled while the unit's reaction matches + if group.hostile ~= nil then + self.reactionGroups = self.reactionGroups or {} + self.reactionGroups[key] = group.hostile + end + end + + -- private auras (boss mechanics) never reach C_UnitAuras; Blizzard draws them into these anchors + local growthx = (self["growth-x"] == "LEFT") and -1 or 1 + for i = 1, data.privateAuras or 0 do + local anchor = CreateFrame("Frame", nil, self) + anchor:SetSize(self.size, self.size) + anchor:SetPoint(self.initialAnchor, self, self.initialAnchor, (self.limit + i - 1) * (self.size + self.spacing) * growthx, 0) + C_UnitAuras.AddPrivateAuraAnchor({ + unitToken = self.unit, + auraIndex = i, + parent = anchor, + showCooldownFrame = true, + showCountdownNumbers = true, + iconInfo = { + iconAnchor = { point = "CENTER", relativeTo = anchor, relativePoint = "CENTER", offsetX = 0, offsetY = 0 }, + iconWidth = self.size, + iconHeight = self.size + } + }) + end + + self.container = container + + -- the container doesn't follow unit token changes by itself + if (self.unit == "target") then + self:RegisterEvent("PLAYER_TARGET_CHANGED") + elseif (self.unit == "focus") then + self:RegisterEvent("PLAYER_FOCUS_CHANGED") + end + end + + function container_proto:ForceUpdate() + local container = self.container + if not container then return end + + if self.reactionGroups then + local hostile = UnitCanAttack(self.unit, "player") + for key, groupHostile in next, self.reactionGroups do + container:SetAuraGroupEnabled(key, groupHostile == hostile) + end + end + + container:UpdateAllAuras() + end + + function container_proto:PLAYER_TARGET_CHANGED() + self:ForceUpdate() + end + + function container_proto:PLAYER_FOCUS_CHANGED() + self:ForceUpdate() + end +end + +ns.container_proto = container_proto + +-------------------------------------------------- +-- FRAMES +-------------------------------------------------- +local class = Filger.class +local spells = Filger.spells or {} + +local layout = Filger.config.layout +local size, spacing = layout.size, layout.spacing +local xOffset, yOffset = layout.xOffset, layout.yOffset + +-- container candidate filters take { [spellId] = true }; name keys can't be matched +local function ToSpellIDs(list) + local ids = {} + for spellId, value in next, list do + if type(spellId) == "number" and (value == true or (type(value) == "table" and value.enabled)) then + ids[spellId] = true + end + end + return ids +end + +Mixin(Filger.config.frames, { + -- LEFT + ["PLAYER_DEBUFFS"] = { + enabled = true, + unit = "player", + anchor = { "RIGHT", UIParent, "CENTER", -xOffset, yOffset }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMRIGHT", + ["growth-x"] = "LEFT", + ["growth-y"] = "DOWN", + -- debuffs on the player can't be filtered by spell ID (Stagger is contextually secret); + -- only the important ones, one group each + groups = { + { filter = "HARMFUL|RAID_PLAYER_DISPELLABLE" }, + { filter = "HARMFUL|CROWD_CONTROL" }, + { + filter = "HARMFUL", + candidateFilters = { isBossAura = true } + } + } + }, + ["PLAYER_AURAS"] = { + enabled = true, + unit = "player", + anchor = { "RIGHT", UIParent, "CENTER", -xOffset, 0 }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMRIGHT", + ["growth-x"] = "LEFT", + ["growth-y"] = "DOWN", + groups = { + { + filter = "HELPFUL|PLAYER", + candidateFilters = { + includeSpellIDs = ToSpellIDs(Mixin(spells[class] or {}, spells["ALL"] or {})) + } + } + } + }, + ["PLAYER_BUFFS"] = { + enabled = true, + unit = "player", + anchor = { "RIGHT", UIParent, "CENTER", -xOffset, -yOffset }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMRIGHT", + ["growth-x"] = "LEFT", + ["growth-y"] = "DOWN", + groups = { + { + filter = "HELPFUL|!PLAYER", + candidateFilters = { + excludeSpellIDs = ToSpellIDs(blacklist) + } + } + } + }, + ["TARGET_DEBUFFS"] = { + enabled = true, + unit = "target", + anchor = { "LEFT", UIParent, "CENTER", xOffset, yOffset }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMLEFT", + ["growth-x"] = "RIGHT", + ["growth-y"] = "DOWN", + groups = { + { filter = "HARMFUL|PLAYER", hostile = true }, + { filter = "HARMFUL|!PLAYER", hostile = false } + } + }, + ["TARGET_AURAS"] = { + enabled = true, + unit = "target", + anchor = { "LEFT", UIParent, "CENTER", xOffset, 0 }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMLEFT", + ["growth-x"] = "RIGHT", + ["growth-y"] = "DOWN", + groups = { + { + filter = "HELPFUL", + hostile = false, + candidateFilters = { + excludeSpellIDs = ToSpellIDs(blacklist) + } + }, + -- buffs on hostile units can't be filtered by spell ID; keep the groups to show + { filter = "HELPFUL|IMPORTANT", hostile = true }, + { filter = "HELPFUL|DISPELLABLE", hostile = true }, + -- { filter = "HELPFUL", hostile = true, candidateFilters = { isStealable = true } }, + -- { filter = "HELPFUL", hostile = true }, + } + }, + ["FOCUS_DEBUFFS"] = { + enabled = true, + unit = "focus", + anchor = { "LEFT", UIParent, "CENTER", xOffset, -yOffset }, + limit = 8, + size = size, + spacing = spacing, + initialAnchor = "BOTTOMLEFT", + ["growth-x"] = "RIGHT", + ["growth-y"] = "DOWN", + groups = { + { + filter = "HARMFUL", + candidateFilters = { isBossAura = true } + } + } + }, + ["BOSS_DEBUFFS"] = { + enabled = true, + unit = "player", + anchor = { "CENTER", UIParent, "CENTER", 0, 160 }, + limit = 1, + size = math.floor(1.5 * size), + spacing = spacing, + initialAnchor = "BOTTOMLEFT", + ["growth-x"] = "RIGHT", + ["growth-y"] = "DOWN", + groups = { + { + filter = "HARMFUL", + candidateFilters = { isBossAura = true } + } + }, + privateAuras = 2 + }, +}) diff --git a/core/commands.lua b/core/commands.lua index 300e2a4..077c686 100644 --- a/core/commands.lua +++ b/core/commands.lua @@ -1,6 +1,9 @@ local _, ns = ... local Filger = ns.Filger +-- Blizzard +local C_Secrets = _G.C_Secrets + ------------------------------------------------------------ -- Slash Commands ------------------------------------------------------------ @@ -12,9 +15,9 @@ SlashCmdList["FILGER"] = function(cmd) local command, arg = string.split(" ", msg, 2) arg = arg and arg:gsub(" ", "") - if commands[command] then - commands[command].func(arg) - end + -- empty or unknown command: show help + local entry = commands[command] or commands["help"] + entry.func(arg) end function Filger:AddCommand(command, handler, description) @@ -32,4 +35,30 @@ local help = function() end end -Filger:AddCommand("help", help) +Filger:AddCommand("help", help, "list commands") + +-- lists the unit's own auras with their spell IDs, to fill data//spells.lua +-- usage: /filger auras player buffs (arg arrives without spaces: "playerbuffs") +local auras = function(arg) + local unit, kind = (arg or ""):match("^(%a-)(d?e?buffs)$") + if not unit or unit == "" or (kind ~= "buffs" and kind ~= "debuffs") then + print(Filger.title, "usage: /filger auras ") + return + end + + -- tainted code can't read secret auras (in combat on Retail), GetAuraSlots would error + if C_Secrets and C_Secrets.ShouldAurasBeSecret() then + print(Filger.title, "auras are secret right now, leave combat and try again.") + return + end + + local filter = ((kind == "buffs") and "HELPFUL" or "HARMFUL") .. "|PLAYER" + for _, slot in ipairs({ select(2, C_UnitAuras.GetAuraSlots(unit, filter)) }) do + local data = C_UnitAuras.GetAuraDataBySlot(unit, slot) + if data then + print(Filger.title, data.spellId, data.name) + end + end +end + +Filger:AddCommand("auras", auras, "list your auras on a unit with their spell IDs (/filger auras player buffs)") diff --git a/core/data.lua b/core/data.lua index b228288..9119765 100644 --- a/core/data.lua +++ b/core/data.lua @@ -94,10 +94,14 @@ end -- player class rows first, then "ALL" rows (racials, gear), each in source order local buildCooldowns = function(Filger) local cooldowns = {} + local seen = {} for _, key in ipairs({ Filger.class, "ALL" }) do for _, source in ipairs(cooldownSources) do for _, row in ipairs(source[key] or {}) do - if row.enabled then + if row.spellId and seen[row.spellId] then + Filger:warn("COOLDOWN", "Spell " .. row.spellId .. " is duplicated.") + elseif row.enabled then + if row.spellId then seen[row.spellId] = true end if row.slotId or Filger.GetSpellInfo(row.spellId) then table.insert(cooldowns, row) else diff --git a/core/development.lua b/core/development.lua index 9835a7e..44249a7 100644 --- a/core/development.lua +++ b/core/development.lua @@ -1,6 +1,222 @@ -local _, ns = ... -local Filger = ns.Filger - ------------------------------------------------------------- --- Development (write anything here) ------------------------------------------------------------- +local _, ns = ... +local Filger = ns.Filger + +------------------------------------------------------------ +-- Development (write anything here) +------------------------------------------------------------ + +-- Experiment 4 (docs/secret-values-options.md): spell-ID gate of CustomAuraContainerTemplate +-- usage: /filger exp4 , (out of combat, then enter combat and compare) +local exp4 = function(arg) + local buff, debuff = (arg or ""):match("^(%d+),(%d+)$") + if not buff then + print(Filger.title, "usage: /filger exp4 ,") + return + elseif not C_XMLUtil or not C_XMLUtil.GetTemplateInfo("CustomAuraContainerTemplate") then + print(Filger.title, "exp4: no CustomAuraContainerTemplate on this client") + return + elseif InCombatLockdown() then + print(Filger.title, "exp4: leave combat first") + return + elseif _G.FilgerExp4_T1 then + print(Filger.title, "exp4: already created, /reload to change IDs") + return + end + buff, debuff = tonumber(buff), tonumber(debuff) + + -- CustomAuraButtonTemplate has no regions; without an icon the button draws nothing + local function initializeFrame(button) + button:SetSize(32, 32) + local icon = button:CreateTexture(nil, "ARTWORK") + icon:SetAllPoints() + button:SetIcon(icon) + local count = button:CreateFontString(nil, "OVERLAY", "NumberFontNormal") + count:SetPoint("BOTTOMRIGHT") + button:SetApplicationCount(count) + end + + local function Make(name, unit, filter, candidateFilters, y) + local c = CreateFrame("AuraContainer", "FilgerExp4_" .. name, UIParent, "CustomAuraContainerTemplate") + c:SetPoint("LEFT", UIParent, "CENTER", 0, y) + c:SetUnit(unit) + c:AddAuraGroup(name, filter, { + candidateFilters = candidateFilters, + initializeFrame = initializeFrame, + layout = { elementWidth = 32, elementHeight = 32, elementSpacing = 2, lineSpacing = 2 } + }) + + local label = UIParent:CreateFontString(nil, "OVERLAY", "GameFontNormal") + label:SetPoint("RIGHT", UIParent, "CENTER", -10, y) + label:SetText(name .. " " .. unit .. " " .. filter) + end + + -- expected in combat (Retail): T1 buff, T2 empty, T3 debuff still shown, T4 no error + Make("T1", "player", "HELPFUL|PLAYER", { includeSpellIDs = { [buff] = true } }, 120) + Make("T2", "player", "HARMFUL", { includeSpellIDs = { [debuff] = true } }, 80) + Make("T3", "player", "HARMFUL", { excludeSpellIDs = { [debuff] = true } }, 40) + Make("T4", "focus", "HARMFUL", nil, 0) + print(Filger.title, "exp4: created T1-T4, buff", buff, "debuff", debuff) +end + +Filger:AddCommand("exp4", exp4, "experiment 4: aura container spell-ID gate (/filger exp4 ,)") + +-- Experiment 5: which filter strings each client accepts and fills +-- usage: /filger exp5 (out of combat; GetAuraSlots errors on secret auras) +local filters = { + -- complementary pairs must add up to HELPFUL, so they can be checked solo + "HELPFUL", "HARMFUL", "HELPFUL|PLAYER", "HELPFUL|!PLAYER", "HELPFUL|CANCELABLE", "HELPFUL|!CANCELABLE", + "HELPFUL|NOT_CANCELABLE", "HELPFUL|BIG_DEFENSIVE", "HELPFUL|IMPORTANT", "HARMFUL|RAID_PLAYER_DISPELLABLE", + "HARMFUL|DISPELLABLE" +} + +local exp5 = function() + if C_Secrets and C_Secrets.ShouldAurasBeSecret() then + print(Filger.title, "exp5: auras are secret right now, leave combat first") + return + end + + for _, filter in ipairs(filters) do + -- unknown tokens may error; the first return is a continuation token, not a slot + local ok, n = pcall(function() return select("#", C_UnitAuras.GetAuraSlots("player", filter)) - 1 end) + -- "||" so chat doesn't read "|R" / "|N" as escape codes + print(Filger.title, (filter:gsub("|", "||")), ok and n or ("error: " .. tostring(n))) + end +end + +Filger:AddCommand("exp5", exp5, "experiment 5: aura counts per filter string on the player") + +-- Experiment 6: private aura anchors from addon code +-- usage: /filger exp6 (anywhere; the private aura icon draws inside the grey boxes) +local exp6 = function() + if not C_UnitAuras.AddPrivateAuraAnchor then + print(Filger.title, "exp6: no AddPrivateAuraAnchor on this client") + return + elseif _G.FilgerExp6 then + print(Filger.title, "exp6: already created") + return + end + + local frame = CreateFrame("Frame", "FilgerExp6", UIParent) + frame:SetSize(3 * 44, 44) + frame:SetPoint("CENTER", UIParent, "CENTER", 0, 200) + + -- one anchor per private aura slot on the player + for i = 1, 3 do + local slot = CreateFrame("Frame", nil, frame) + slot:SetSize(40, 40) + slot:SetPoint("LEFT", frame, "LEFT", (i - 1) * 44, 0) + local bg = slot:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + bg:SetColorTexture(0.3, 0.3, 0.3, 0.5) + + local ok, anchorID = pcall(C_UnitAuras.AddPrivateAuraAnchor, { + unitToken = "player", + auraIndex = i, + parent = slot, + showCooldownFrame = true, + showCountdownNumbers = true, + iconInfo = { + iconAnchor = { point = "CENTER", relativeTo = slot, relativePoint = "CENTER", offsetX = 0, offsetY = 0 }, + iconWidth = 40, + iconHeight = 40 + } + }) + print(Filger.title, "exp6: slot", i, "aurasSecret", C_Secrets and C_Secrets.ShouldAurasBeSecret(), + ok and "anchorID" or "error", tostring(anchorID)) + end +end + +Filger:AddCommand("exp6", exp6, "experiment 6: private aura anchors on the player") + +-- Experiment 7: cooldown edges (item/slot cooldowns, secret cast spellID) +-- usage: /filger exp7 [itemID] toggles logging; enter combat and cast spells / use the item +-- on each player cast: is the spellID secret, are slot 13/14 and item cooldowns secret +local exp7Frame +-- prints start/duration/enabled when readable, so an active cooldown is visible +local function secrecy(...) + local ok, start, duration, enabled = pcall(...) + if not ok then return "error: " .. tostring(start) end + if start == nil then return "nil" end + if issecretvalue and (issecretvalue(start) or issecretvalue(duration)) then + return "|cffff0000secret|r " .. tostring(issecretvalue(start)) .. "/" .. tostring(issecretvalue(duration)) + end + return format("%.1f/%.1f/%s", start, duration, tostring(enabled)) +end + +local exp7 = function(arg) + if not issecretvalue then + print(Filger.title, "exp7: no issecretvalue on this client") + return + elseif exp7Frame then + exp7Frame:UnregisterAllEvents() + exp7Frame = nil + print(Filger.title, "exp7: stopped") + return + end + local itemID = tonumber(arg) + + exp7Frame = CreateFrame("Frame") + exp7Frame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player") + exp7Frame:SetScript("OnEvent", function(_, _, _, _, spellID) + -- a secret spellID can't be printed or looked up; the cast order tells which spell it was + local id = issecretvalue(spellID) and "|cffff0000secret|r" or tostring(spellID) + print(Filger.title, "exp7:", format("%.1f", GetTime()), "combat", tostring(InCombatLockdown()), + "spellID", id, + "slot13", secrecy(GetInventoryItemCooldown, "player", 13), + "slot14", secrecy(GetInventoryItemCooldown, "player", 14), + "item", itemID and secrecy(C_Container.GetItemCooldown, itemID) or "-") + end) + print(Filger.title, "exp7: logging player casts (start/duration or secret), run again to stop") +end + +Filger:AddCommand("exp7", exp7, "experiment 7: cooldown edges, logs player casts (/filger exp7 [itemID])") + +-- Experiment 8: which aura tooltip methods each client has +-- usage: /filger exp8 (anywhere) +local exp8 = function() + for _, method in ipairs({ "SetUnitAura", "SetUnitBuff", "SetUnitDebuff", "SetUnitBuffByAuraInstanceID", + "SetUnitDebuffByAuraInstanceID" }) do + print(Filger.title, "exp8:", method, GameTooltip[method] ~= nil) + end +end + +Filger:AddCommand("exp8", exp8, "experiment 8: aura tooltip methods on GameTooltip") + +-- Experiment 10: which event reports each item/slot cooldown change +-- usage: /filger exp10 [itemID] toggles logging; use a trinket and a potion, compare with Filger's icons +-- prints a line when an event sees slot 13/14/item values that differ from what that same event saw last +local exp10Frame +local exp10Events = { "SPELL_UPDATE_COOLDOWN", "BAG_UPDATE_COOLDOWN", "ACTIONBAR_UPDATE_COOLDOWN" } + +local exp10 = function(arg) + if exp10Frame then + exp10Frame:UnregisterAllEvents() + exp10Frame = nil + print(Filger.title, "exp10: stopped") + return + end + local itemID = tonumber(arg) + local last = {} + + exp10Frame = CreateFrame("Frame") + for _, event in ipairs(exp10Events) do + exp10Frame:RegisterEvent(event) + end + exp10Frame:SetScript("OnEvent", function(_, event, ...) + local slot13 = secrecy(GetInventoryItemCooldown, "player", 13) + local slot14 = secrecy(GetInventoryItemCooldown, "player", 14) + local item = itemID and secrecy(C_Container.GetItemCooldown, itemID) or "-" + local snapshot = slot13 .. " " .. slot14 .. " " .. item + if last[event] == snapshot then return end + last[event] = snapshot + -- SPELL_UPDATE_COOLDOWN payload: spellID, baseSpellID, category, startRecoveryCategory, itemID (Retail only) + local spellID, _, _, _, payloadItemID = ... + local payload = event == "SPELL_UPDATE_COOLDOWN" + and ("spellID " .. tostring(spellID) .. " itemID " .. tostring(payloadItemID)) or "" + print(Filger.title, "exp10:", format("%.1f", GetTime()), event, payload, + "slot13", slot13, "slot14", slot14, "item", item) + end) + print(Filger.title, "exp10: logging slot 13/14/item cooldown changes per event, run again to stop") +end + +Filger:AddCommand("exp10", exp10, "experiment 10: item cooldown refresh events (/filger exp10 [itemID])") diff --git a/data/Standard/blacklist.lua b/data/Standard/blacklist.lua index bb41f11..690904c 100644 --- a/data/Standard/blacklist.lua +++ b/data/Standard/blacklist.lua @@ -34,6 +34,8 @@ do [166646] = true, -- Windwalking [129914] = true, -- Combat Wisdom [450380] = true, -- Chi Wave + [451021] = true, -- Flurry Charge + [454494] = true, -- August Blessing -- PRIEST [21562] = true, -- Power Word: Fortitude diff --git a/data/Standard/cooldowns.lua b/data/Standard/cooldowns.lua index f2af806..13449c9 100644 --- a/data/Standard/cooldowns.lua +++ b/data/Standard/cooldowns.lua @@ -155,7 +155,7 @@ local data = { CreateSpellCooldown(1022), -- Blessing of Protection CreateSpellCooldown(1044), -- Blessing of Freedom CreateSpellCooldown(6940), -- Blessing of Sacrifice - CreateSpellCooldown(31884), -- Avenging Wrath (Holy) + CreateSpellCooldown(31884), -- Avenging Wrath CreateSpellCooldown(96231, false), -- Rebuke CreateSpellCooldown(115750), -- Blinding Light CreateSpellCooldown(375576), -- Divine Toll @@ -176,13 +176,11 @@ local data = { CreateSpellCooldown(204018), -- Blessing of Spellwarding CreateSpellCooldown(327193), -- Moment of Glory CreateSpellCooldown(378974), -- Bastion of Light - CreateSpellCooldown(31884), -- Sanctified Wrath CreateSpellCooldown(389539), -- Sentinel CreateSpellCooldown(387174), -- Eye of Tyr -- Retribution CreateSpellCooldown(403876), -- Divine Protection - CreateSpellCooldown(31884), -- Avenging Wrath: Might CreateSpellCooldown(255937, false), -- Wake of Ashes CreateSpellCooldown(343721), -- Final Reckoning CreateSpellCooldown(343527, false), -- Execution Sentence diff --git a/data/Standard/spells.lua b/data/Standard/spells.lua index 0766cf5..159e1de 100644 --- a/data/Standard/spells.lua +++ b/data/Standard/spells.lua @@ -51,6 +51,9 @@ local data = { [384267] = CreateSpellPriority(3), -- Siphon Storm }, ["MONK"] = { + -- All + [101643] = CreateSpellPriority(1), -- Transcendence + -- Mistweaver [432180] = CreateSpellPriority(false), -- Dance of the Wind [388193] = CreateSpellPriority(10), -- Jadefire Stomp @@ -60,6 +63,7 @@ local data = { [122278] = CreateSpellPriority(1), -- Dampen Harm [122783] = CreateSpellPriority(1), -- Diffuse Magic [322507] = CreateSpellPriority(3), -- Celestial Brew + [1260619] = CreateSpellPriority(3), -- Elevated Stagger [325092] = CreateSpellPriority(10), -- Purified Chi }, ["PALADIN"] = { @@ -99,7 +103,7 @@ local data = { [198069] = CreateSpellPriority(9), -- Power of the Dark Side [428933] = CreateSpellPriority(10), -- Premonition of Insight (-cooldown) - [428930] = CreateSpellPriority(10), -- Premonition of Piety (+ healing) + -- [428930] = CreateSpellPriority(10), -- Premonition of Piety (+ healing) [428934] = CreateSpellPriority(10), -- Premonition of Solace (single heal to shield) -- Holy @@ -131,7 +135,7 @@ local data = { [470532] = CreateSpellPriority(7), -- Arc Discharge [375986] = CreateSpellPriority(7), -- Primordial Wave [187878] = CreateSpellPriority(7), -- Crash Lightning - [470058] = CreateSpellPriority(1), -- Vulcanic Blaze + -- [470058] = CreateSpellPriority(1), -- Vulcanic Blaze [201900] = CreateSpellPriority(1), -- Hot Hand [384411] = CreateSpellPriority(1), -- Static Accumulation [455110] = CreateSpellPriority(1), -- Supercharge @@ -154,6 +158,7 @@ local data = { [457925] = CreateSpellPriority(false), -- Venomous Potential (Seal of the Poisoned Pact) [462513] = CreateSpellPriority(8, 1), -- Severed Strands (Spymaster's Web) [449947] = CreateSpellPriority(20), -- Realigning Nexus Convergence Divergence (Treacherous Transmitter) + [1219102] = CreateSpellPriority(8), -- Mudborne (trinket) -- Consumables [431932] = CreateSpellPriority(5), -- Tempered Potion diff --git a/docs/future-plan.md b/docs/future-plan.md new file mode 100644 index 0000000..2515238 --- /dev/null +++ b/docs/future-plan.md @@ -0,0 +1,54 @@ +# Future plan + +## Potion cooldown + +Show one icon for the shared potion cooldown, without listing potion item IDs. + +### Why not item IDs + +- Every potion in the bags goes on cooldown when one is used, so one icon per potion would duplicate + the same timer. +- Potion IDs change every patch and expansion, so a list in `data/` would need constant upkeep. + +### Today + +- `ProcessCooldownData` already has a `data.itemId` branch (`GetItemInfo`, `C_Container.GetItemCooldown`), + and tooltips handle `itemId` (`Filger.lua`, `button_proto` `OnEnter`). +- The branch is unreachable: `core/data.lua` has no item constructor, and `buildCooldowns` drops any + row that has no `slotId` or known `spellId`. +- Events are enough. On Retail and MoP, `SPELL_UPDATE_COOLDOWN` reported every item cooldown change + (Experiment 10, [secret-values-options.md](secret-values-options.md)). Item rows are always re-checked + because they have no `spellId`. + +### Idea + +One "potion" row, with no ID. The row resolves to whichever potion is in the bags: + +- Find it by item class, not by ID: `C_Item.GetItemInfoInstant(itemID)` returns `classID, subClassID`. + A potion is `Enum.ItemClass.Consumable` (0) with `Enum.ItemConsumableSubclass.Potion` (1). + `ItemConsumableSubclass` and `GetItemInfoInstant` exist in the generated docs of every worktree + (live, forever, classic, classic_titan, classic_anniversary, classic_era). +- Scan the bags (`C_Container.GetContainerNumSlots`, `C_Container.GetContainerItemID`) only when they + change (`BAG_UPDATE_DELAYED`), never on cooldown events. Cache the item ID found. +- Keep the last ID found after the last potion is drunk, so the running cooldown still shows. +- Read the cooldown with the existing `itemId` branch, which works the same for any potion that shares it. + +### Phases + +1. **Experiments** (in game, `/filger exp10 `, which now prints `enabled`): + - MoP: drink in combat, then leave combat. Does `enabled` read 0 in combat while `start` follows + `GetTime()`, and does the cooldown start when combat ends? + - Era and TBC: the same. + - Retail: does a healing potion share its cooldown with a combat potion? Healthstones? (**UNVERIFIED**) + - Era: are potions classID 0 / subClassID 1, or Generic (0)? (**UNVERIFIED**, check with + `/run print(C_Item.GetItemInfoInstant())`) +2. **Data:** `data.CreatePotionCooldown(enabled)` in `core/data.lua`, one row in + `data/shared/cooldowns.lua` `gear`, and accept it in `buildCooldowns`. +3. **Resolve:** a bag scan that sets the row's `itemId`, run at login and on `BAG_UPDATE_DELAYED`, + registered only by the cooldown element. +4. **Classic in combat:** if phase 1 confirms the pending cooldown, keep the icon hidden while + `enabled` is 0 (`FilterCooldown` already requires `data.enabled`). + +### Open questions + +- If the bags hold potions from different cooldown groups, which one wins? Unknown until phase 1. diff --git a/docs/secret-values-options.md b/docs/secret-values-options.md new file mode 100644 index 0000000..3328715 --- /dev/null +++ b/docs/secret-values-options.md @@ -0,0 +1,702 @@ +# Secret values: options for Retail and Classic + +Analysis only. No code was changed. As of 2026-10-19. + +- Sources: `../refs/wow-ui-source` live **12.1.5.70448** (2026-10-16), the `forever` (1.60.1), + `classic` (5.5.4), `classic_titan` (3.80.2), `classic_anniversary` (2.5.6) and `classic_era` (1.15.9) + worktrees, `../refs/ElvUI`, and warcraft.wiki.gg + [Secret values](https://warcraft.wiki.gg/wiki/Secret_values) (last edited 2026-09-23, change log up to + 12.1.5). +- Unless stated otherwise, paths are relative to `../refs/wow-ui-source/Interface/AddOns/`, and + `APIDoc/` means `Blizzard_APIDocumentationGenerated/`. +- This builds on [cooldown-manager.md](cooldown-manager.md) (CDM, AuraContainer, ElvUI) and doesn't repeat + it. That doc was written against 12.1.0. Client facts are in [compatibility.md](compatibility.md). +- Evidence tags: **VERIFIED** means read in Blizzard source or API docs (path given). **UNVERIFIED** means + an assumption that needs an in-game check (see [Experiments](#experiments)). + +## Summary + +1. On Retail and Classic Forever, phase 2 can't work in combat or in encounters. Phase 2 lists every aura + and then decides in Lua by spellId, name, caster, dispel type, stacks or expiration. All of those fields + are secret, and secrets can't be compared, used as table keys, counted with `#` or used in arithmetic. +2. Four things still work. Server-side filter strings (`HARMFUL|PLAYER`, `!` negation, and the new + `BIG_DEFENSIVE`, `EXTERNAL_DEFENSIVE`, `CROWD_CONTROL`, `IMPORTANT`, `RAID_PLAYER_DISPELLABLE`, + `DISPELLABLE`). Blizzard's secure `CustomAuraContainerTemplate` (filters, spell-ID include/exclude where + allowed, expiration sort). Never-secret spells queried one at a time. Duration objects and curves, for + display only. +3. Retail blocks spell-ID filtering of **debuffs on the player or a friendly unit** by design. An include + list then shows nothing, and an exclude list is ignored. A better boss-debuff table can't help on Retail; + it only helps Classic. +4. Decision: **two aura implementations, loaded per client family** with `[Family]` in the TOC. On + Mainline (Retail and Forever), use AuraContainer only (no `UNIT_AURA` path): filter strings and flags, + plus curated `includeSpellIDs`/`excludeSpellIDs` where ID filtering is allowed. On Classic, keep + today's implementation unchanged. Buttons, layout, `data/` and cooldowns stay shared. +5. These features don't survive on Retail: stack thresholds, numeric priority sort, the blacklist on + debuff frames, filtering by name, the `sourceUnit == nil` checks, LibDispel's flash, and Filger's own + timer text. +6. Classic: same branch and same TOC. Only its aura file differs, and that file is today's code. Don't + freeze a branch. + +## Progress + +Details in [Migration](#migration-each-phase-ships-on-its-own) and [Experiments](#experiments). + +- [x] **Phase 0: fixes that work today** + - [x] Experiment 1: restriction state on every client (Forever and Titan not tested) + - [x] Experiment 2: `GetAuraDuration` and `#` on secret instance IDs (Retail, in combat): **fails**, + tainted code can't call any `RequiresUnitAuraAccess` API while auras are secret + - [x] Experiment 3: never-secret coverage of `data/`, Stagger (spells 1/100, blacklist 17/313, + cooldowns 0/28, Stagger contextually secret) + - [x] Read `cdInfo.isEnabled` (`Filger.lua:648`) + - [x] Refresh only the event's `spellID` in `SPELL_UPDATE_COOLDOWN` (nil → all; override/base IDs; + items and slots still refreshed). Shown rows are also re-checked on every event: logged on + Retail and MoP (2026-10-21), the event fires when a cooldown starts, never when it ends. + `spellID` was never secret in combat, and `baseSpellID` was always nil. + - [ ] ~~Secret auras: `GetAuraDuration` + `SetCooldownFromDurationObject`, no `duration > 0` compare~~ + Blocked by Experiment 2: no instance ID is reachable from tainted code in combat + - [x] Secret cooldown timer text: a `C_DurationUtil` duration text binding on `button.Time`, with a + seconds formatter (`2m`, `45s`, `4.3`) and a step color curve (red under 5 s). Verified on Retail + in combat (2026-10-21). Blizzard countdown numbers stay hidden. Created on Mainline only: Classic + bindings have no `SetTextColorCurve` (`APIDoc/DurationTextBindingObjectAPIDocumentation.lua`, + crashed on MoP 2026-10-21). +- [x] **Phase 1: family split, no behavior change** + - [x] Move `aura_proto` and aura frame definitions to `core/Classic/auras.lua` (`ns.aura_proto`, + frames added to `Filger.config.frames`); add `core\[Family]\auras.lua` to the TOC. Shared layout + numbers live in `Filger.config.layout` + - [x] `core/Mainline/auras.lua` starts as a copy + - [x] Experiment 9: `[Family]` resolves on every client (Forever and Titan not tested) +- [x] **Phase 2: Mainline container for PLAYER_AURAS and PLAYER_BUFFS** + - [x] Experiment 4: container spell-ID gate (Retail, in combat): passes, `includeSpellIDs` refused + and `excludeSpellIDs` ignored on player debuffs, as the gate table says + - [x] Experiment 5: filter strings (Retail): `!` negation, `BIG_DEFENSIVE` and `IMPORTANT` work; + `NOT_CANCELABLE` silently ignored. Classic not needed: its aura file keeps Lua filtering + - [x] PLAYER_AURAS: `HELPFUL|PLAYER` + `includeSpellIDs` (strict allowlist: only listed, enabled + numeric spell IDs are shown) + - [x] PLAYER_BUFFS: `HELPFUL|!PLAYER` + `excludeSpellIDs` + - [x] Frames are named from their `config.frames` key (`FilgerPlayerAuras`, ...) +- [x] **Phase 3: remaining Mainline frames** + - [x] Experiment 6: private aura anchors: **pass**, addon code can add them in combat while auras + are secret + - [x] TARGET_DEBUFFS, TARGET_AURAS, FOCUS_DEBUFFS: groups with `hostile` are enabled by + `UnitCanAttack` on target/focus change. TARGET_DEBUFFS `HARMFUL|PLAYER` (hostile) / + `HARMFUL|!PLAYER` (friendly); TARGET_AURAS `HELPFUL` + blacklist (friendly) / + `HELPFUL|IMPORTANT` + `HELPFUL|DISPELLABLE` (hostile); FOCUS_DEBUFFS `HARMFUL` + `isBossAura` + - [x] BOSS_DEBUFFS: enabled on Mainline. `HARMFUL` + `isBossAura` (limit 1), plus 2 private-aura + anchors (`privateAuras`) after the container slot + - [x] PLAYER_DEBUFFS: `HARMFUL|RAID_PLAYER_DISPELLABLE`, `HARMFUL|CROWD_CONTROL`, `HARMFUL` + + `isBossAura` groups (Stagger dropped, Experiment 3) + - [x] Delete the copied event path from `core/Mainline/` +- [x] **Other experiments** + - [x] Experiment 7: cooldown edges (Retail, dummy and boss): nothing secret; item cooldowns aren't + applied yet at `UNIT_SPELLCAST_SUCCEEDED` + - [x] Experiment 8: tooltip methods on each Classic client: all five exist on Retail, Era, TBC, MoP + (Forever and Titan not tested); index branch removed, instance-ID tooltips verified on all four + - [x] Experiment 10: item and slot cooldowns refresh when they start and when they shorten: + `SPELL_UPDATE_COOLDOWN` is enough on Retail (start, shortening, end) and MoP (start); + `BAG_UPDATE_COOLDOWN` isn't needed. Era, TBC and the rest of MoP not tested +- [ ] **Later iteration:** spell data ([spell-data-generation.md](spell-data-generation.md)) + +## Facts + +### Where Filger reads aura and cooldown data and decides on it + +| Location | APIs | Data read → decision | Client path | +|---|---|---|---| +| `Filger.lua:32-54` `OnEnter` | `SetUnit{Buff,Debuff}ByAuraInstanceID`, `SetSpellByID` | `auraInstanceID` → tooltip | All (index branch removed) | +| `Filger.lua:72-92` `OnUpdate` | `GetTime` | `expirationTime - GetTime()`, `<= 5` → red text, hide on expiry | All | +| `Filger.lua:184-208` `UpdateColor` | `UnitCanAssist`, `UnitCanAttack` | `isHarmful/isHelpful`, `Filger.colors[dispelName]` (table key), `isPlayerAura` → desaturate, `isDispelable/isStealable` → flash | All | +| `Filger.lua:218-272` `Update` | `Cooldown:SetCooldown`, `SetTexture`, `SetText` | `name` check, `duration > 0`, `expirationTime - duration`, `applications > 1` | All | +| `Filger.lua:281-295` `SortAuras` | — | Compares `priority`, `expirationTime`, `auraInstanceID` | All | +| `Filger.lua:308-310` `FilterAura` | — | `applications >= stackThreshold` | All | +| `Filger.lua:337-370` `ProcessData` | `UnitIsUnit`, `UnitIsOwnerOrControllerOfUnit`, LibDispel | `sourceUnit` (or `isFromPlayerOrPlayerPet` when restricted), `spellId`/`dispelName` → LibDispel, `self.spells[spellId]`, `blacklist[spellId]`, `blacklist[name]` | All | +| `Filger.lua:374-391` `ScanSpellAuras` | `C_Secrets.ShouldSpellAuraBeSecret`, `C_UnitAuras.GetUnitAuraBySpellID` | `isHarmful ==`, `isFromPlayerOrPlayerPet` | Retail/Forever while restricted | +| `Filger.lua:393-498` `UpdateAuras` | `C_Secrets.ShouldAurasBeSecret`, `GetAuraSlots`, `GetAuraDataBySlot`, `GetAuraDataByAuraInstanceID`, `IsAuraFilteredOutByInstanceID`, `UNIT_AURA` | `updateInfo` fields, `self.all[auraInstanceID]` keys | All (restricted branch on Retail/Forever) | +| `Filger.lua:601-613` `FilterCooldown` | — | `enabled`, `isKnown`, `casted`, `duration > 1.5`, `start > 0` | All | +| `Filger.lua:620-625` `SortCooldowns` | — | Compares `expirationTime` | All | +| `Filger.lua:627-683` `ProcessCooldownData` | `C_Secrets.ShouldSpellCooldownBeSecret`, `Filger.GetSpellCooldown`, `C_Spell.GetSpellCooldownDuration`, `GetInventoryItemCooldown`, `C_Container.GetItemCooldown`, `C_SpellBook.IsSpellKnown` | `isActive and not isOnGCD` → duration object; `start/duration` otherwise; reads `cdInfo.enabled`, but the field is `isEnabled` (bug, see `core/utils.lua:61-72`) | Secret branch on Retail/Forever | +| `Filger.lua:749-752` `UNIT_SPELLCAST_SUCCEEDED` | `issecretvalue` | `casted[spellId]` key, skipped when secret | All | +| `config.lua:92-94` PLAYER_BUFFS | — | `not isPlayerAura` | All | +| `config.lua:108-128` TARGET_DEBUFFS | `UnitCanAttack` | `isPlayerAura`; sort on `priority`, `expirationTime`, `isPlayerAura` | All | +| `config.lua:155-157` FOCUS_DEBUFFS | — | `isBossAura`, `sourceUnit == nil`, `isFromPlayerOrPlayerPet` | All | +| `config.lua:170-172` BOSS_DEBUFFS (disabled) | — | `isBossAura`, `sourceUnit == nil` | All | +| `core/data.lua:58-111` | `Filger.GetSpellInfo` | Validates the data tables at load, out of combat; safe | All | + +### Secret-value rules (Retail 12.1.5) + +**Conditions** + +- `SecretWhenUnitAuraRestricted`: secret "when combat, encounter, challenge mode, or PvP match addon + restrictions are in effect. Individual spells may be flagged as never or always secret, which takes + priority". `SecretWhenCooldownsRestricted` has the same wording. `SecretWhenAurasRestricted` (used by + `UNIT_AURA`) has no per-spell override. **VERIFIED** `APIDoc/SecretPredicatesDocumentation.lua`. +- Restrictions depend on combat/encounter/M+/PvP state, not on the unit type. The cast payload is the + exception: `SecretWhenUnitSpellCastRestricted` is secret "if the unit … is not the player or their pet". + **VERIFIED** (same file). +- The wiki says dungeons and raids are restricted maps even out of combat (`SecretOnRestrictedMaps`). None + of the aura or cooldown APIs Filger calls carry that predicate. **VERIFIED** (grep of + `UnitAuraDocumentation.lua`, `SpellDocumentation.lua`). + +**What a secret allows** (wiki, **VERIFIED**) + +- Not allowed in tainted code: arithmetic, comparison, use as a table key, indexing, `#`, calling. Any of + these raises an immediate Lua error. +- Allowed: storing a secret in a variable or table, passing it to functions, concatenating it, and testing + it as a boolean when it isn't a boolean (the type isn't secret). + +**Aura APIs** (`APIDoc/UnitAuraDocumentation.lua`, **VERIFIED**) + +| API | Flag | Meaning for Filger | +|---|---|---| +| `GetAuraDataBySlot`, `GetAuraDataByIndex`, `GetAuraDataByAuraInstanceID`, `GetBuffDataByIndex`, `GetDebuffDataByIndex` | `SecretWhenUnitAuraRestricted` | Every field is secret while restricted (except never-secret spells) | +| `GetUnitAuras` | `ConditionalSecretContents` | Same; one can be sorted server-side (`sortRule`) | +| `GetUnitAuraBySpellID`, `GetPlayerAuraBySpellID`, `GetAuraDataBySpellName` | `RequiresNonSecretAura` | Full readable data for never-secret spells; **nothing** (no error) otherwise | +| `GetAuraSlots`, `GetUnitAuraInstanceIDs`, `IsAuraFilteredOutByInstanceID`, `GetAuraDuration` | No `SecretWhen…`; `SecretArguments = "AllowedWhenUntainted"` | The calls themselves aren't secret, but they take an instance ID, which is secret (next row) | +| `auraInstanceID` | Secret: "Aura instance IDs are secret" (`Blizzard_CooldownViewer/CooldownViewerSecure.lua:4`); `UNIT_AURA_BLOCKED` `auraInstanceID SecretValue = true` | It can't be a table key, which breaks `self.all[auraInstanceID]` | +| `GetAuraApplicationDisplayCount`, `GetAuraDispelTypeColor(curve)`, `GetAuraBaseDuration`, `GetAuraCasterGUID` | `SecretWhenUnitAuraRestricted` | Usable for display only (`SetText`, `SetVertexColor`) | +| `UNIT_AURA` | `SecretWhenAurasRestricted` | `updateInfo` is secret, so incremental updates are impossible; Filger already falls back (`Filger.lua:396-398`) | +| `AuraIsBigDefensive(spellID)`, `AuraIsPrivate(spellID)` | `AllowedWhenTainted` | Accept a secret spellID; whether they return a secret is **UNVERIFIED** | + +- Whether a tainted caller can pass a **secret** instance ID into the `AllowedWhenUntainted` functions is + **UNVERIFIED**. The flag suggests it can't. But ElvUI calls `GetAuraDuration(button.unit, + button.auraInstanceID)` (`../refs/ElvUI/ElvUI/Game/Shared/Modules/Auras/Auras.lua:388`), probably with IDs + handed over by the container. This is Experiment 2. + +**Secrecy predictions** (`APIDoc/SecretPredicateAPIDocumentation.lua`, +`APIDoc/SecretWrapperConstantsDocumentation.lua`, **VERIFIED**) + +- They return plain booleans: `C_Secrets.HasSecretRestrictions`, `ShouldAurasBeSecret`, + `ShouldCooldownsBeSecret`, `ShouldSpellAuraBeSecret(spellID)`, `ShouldSpellCooldownBeSecret(spellID)`, + `ShouldUnitAuraIndexBeSecret`, `ShouldTotemSlotBeSecret`. +- `GetSpellAuraSecrecy`, `GetSpellCooldownSecrecy` and `GetSpellCastSecrecy` return `Enum.SecrecyLevel` + (`NeverSecret`, `AlwaysSecret`, `ContextuallySecret`). This is the only "whitelist" there is, and it is + per spell, set by Blizzard, and not published as a list. Which spells are `NeverSecret` is **UNVERIFIED** + (Experiment 3). The source mentions Exhaustion and Sated as examples + (`Blizzard_AuraContainer/Blizzard_AuraContainerUtil.lua:61-63`). + +**Cooldowns** (**VERIFIED**) + +- `C_Spell.GetSpellCooldown` is `SecretWhenCooldownsRestricted`. In `SpellCooldownInfo`, `isEnabled`, + `isActive` and `isOnGCD` are `NeverSecret`; `startTime`, `duration` and `modRate` aren't + (`APIDoc/SpellSharedDocumentation.lua:19-31`). +- `C_Spell.GetSpellCooldownDuration` returns a duration object. See cooldown-manager.md §4 for charges and + cast counts. +- `UNIT_SPELLCAST_SUCCEEDED` `spellID` is secret for non-player/pet units and for flagged spells + (`APIDoc/UnitDocumentation.lua:4699-4709`). +- Item and slot cooldowns have no secret flag in the docs. Behavior in game is **UNVERIFIED**. + +**What can still be done with a secret** (display only) + +- Duration objects: only `Copy` and `HasSecretValues` are `ReturnsNeverSecret`. `GetRemainingDuration`, + `HasExpired`, `IsActive` and `Evaluate*(curve)` all follow the underlying data, so they are secret when it + is (`APIDoc/LuaDurationObjectAPIDocumentation.lua`, **VERIFIED**). +- Sinks for duration objects: `Cooldown:SetCooldownFromDurationObject`, `StatusBar:SetTimerDuration`, + `C_DurationUtil.CreateDurationTextBinding`. +- `C_CurveUtil.CreateCurve` and `CreateColorCurve` map a secret onto alpha, color or desaturation + (`APIDoc/CurveUtilDocumentation.lua`, `DurationUtilDocumentation.lua`, **VERIFIED**). +- Setters that accept secrets from addons: `SetText`, `SetTexture`, `SetVertexColor`, `SetAlpha`, + `SetDesaturated`, `StatusBar:SetValue` (cooldown-manager.md §4, **VERIFIED** there). +- `Cooldown:SetCooldown` **rejects** secrets, so `Filger.lua:245` errors on a secret aura. The cooldown + path (`Filger.lua:564-566`) already avoids it. + +**Server-side filters** (**VERIFIED**) + +- Filter strings, from `Blizzard_FrameXMLUtil/AuraUtil.lua:270-288`: `HELPFUL`, `HARMFUL`, `PLAYER`, + `RAID`, `CANCELABLE`, `INCLUDE_NAME_PLATE_ONLY`, `MAW`, `EXTERNAL_DEFENSIVE`, `CROWD_CONTROL`, + `RAID_IN_COMBAT`, `RAID_PLAYER_DISPELLABLE`, `BIG_DEFENSIVE`, `IMPORTANT`, `DISPELLABLE`. + - A leading `!` negates a component (`!PLAYER`). `NOT_CANCELABLE` was removed; use `!CANCELABLE`. The + `config.lua:21` comment is outdated on Retail. + - The server applies them, so they work on secret auras. +- Server sort: `Enum.UnitAuraSortRule` `Default`, `BigDefensive`, `Expiration`, `ExpirationOnly`, `Name`, + `NameOnly` (`APIDoc/UnitAuraSharedDocumentation.lua`). +- `CustomAuraContainerTemplate` candidate filters, checked in secure code + (`Blizzard_AuraContainer/Blizzard_AuraContainerUtil.lua:89-170`): + - Spell and dispel lists: `includeSpellIDs`, `excludeSpellIDs`, `includeDispelTypes`, + `excludeDispelTypes`. + - Source and type flags: `canApplyAura`, `isBossAura`, `isBossOrRoleAura`, `isFromPlayerOrPlayerPet`, + `isRoleAura`, `isPriorityAura`, `isStealable`. + - Other: `maxDuration`, `nameplateShowAll`, `nameplateShowPersonal`, `processedAuraType`. +- Spell-ID filters are gated by `CanApplyIdentityCandidateFilters` + (`Blizzard_AuraContainerUtil.lua:52-87`): + +| Aura | ID filter allowed? | If not allowed | +|---|---|---| +| Spell is `NeverSecret` | Always | — | +| Helpful, on player / group member / their pet | Yes | — | +| Harmful, on a unit the player can assist (player, friendly target) | **No** | `includeSpellIDs` → aura **rejected**; `excludeSpellIDs` → ignored, aura **passes** (`:106-111`) | +| Helpful, on a unit the player can't assist (hostile target) | **No** | Same | +| Harmful on hostile; helpful on friendly | Yes | — | + + The source says this restriction is deliberate: "it shouldn't be possible to filter out debuffs on + player targets … if encounter-specific debuffs are applied to the player". cooldown-manager.md §6 says + every aura passes when the gate refuses. That is only true for exclude lists. + +**Other exposed systems** + +- Aura containers: `Blizzard_AuraContainer` frames are `DenyTaintedAccessWhenAurasAreSecret` + (`Blizzard_AuraContainerShared.lua:108`). The buttons offer `AddDispelTypeTexture` + (`Blizzard_CustomAuraButton.lua:112`) and `SetDurationText` (`:178`). **VERIFIED**. Whether Filger can + create, anchor and style them without taint is **UNVERIFIED** (ElvUI does). +- Cooldown Manager: addons can read the static catalog; the live state is secret. See cooldown-manager.md + §4–§8. +- Private auras (many boss mechanics) are never returned by `C_UnitAuras`. Blizzard shows them through + `C_UnitAuras.AddPrivateAuraAnchor(args)`. Addon code can call it in combat while auras are secret: + **VERIFIED** (Experiment 6). + +### Classic clients + +| Client | Secret flags on aura APIs | Modern aura/duration API present | CDM loads | AuraContainer | Filter strings | +|---|---|---|---|---|---| +| Retail 12.1.5 (`standard`) | Yes (15 functions) | Yes | Yes | Yes | Full list incl. `IMPORTANT`, `DISPELLABLE`, `!` | +| Forever 1.60.1 (`camelot`) | Yes (15) | Yes | Yes (`standard, camelot`) | Yes | Same as Retail (**UNVERIFIED** line by line) | +| MoP 5.5.4 | 0 | Yes | No (`standard` only) | No | Up to `BIG_DEFENSIVE` in `AuraUtil.lua`, but in game (Experiment 5): no `!` negation, `BIG_DEFENSIVE` ignored; keeps `NOT_CANCELABLE`; no `IMPORTANT`/`DISPELLABLE` | +| Titan WotLK 3.80.2 | 0 | Yes | No | No | Same as MoP (**VERIFIED** for MoP and Era; Titan/TBC assumed identical, **UNVERIFIED**) | +| TBC 2.5.6 | 0 | Yes | No | No | See above | +| Era 1.15.9 | 0 | Yes | No | No | Same as MoP | +| Cata | No worktree | **UNVERIFIED** | — | — | — | + +- **VERIFIED** by grep in each worktree: the `classic*` API docs list the same functions as Retail. + - Auras: `C_UnitAuras.GetAuraSlots`, `GetAuraDataBySlot`, `GetUnitAuraInstanceIDs`, `GetAuraDuration`, + `GetUnitAuraBySpellID`. + - Secrets and display: `C_Secrets.*`, `SetCooldownFromDurationObject`, `GetSpellCooldownDuration`, + `CreateColorCurve`. + - What differs is that the secret flags are missing and the Blizzard addons that load. +- Whether the Classic servers fill the new flags (`BIG_DEFENSIVE`, `EXTERNAL_DEFENSIVE`, …) is + **UNVERIFIED**. +- What Filger must branch on: + - Secret mode: `C_Secrets.HasSecretRestrictions()`, which exists everywhere and is documented to be false + when the build has no restrictions. + - Whether `CustomAuraContainerTemplate` exists. + - ~~The Vanilla tooltip by index~~ Removed: the instance-ID tooltip works on Era (Experiment 8). + - The filter-string vocabulary (`!CANCELABLE` vs `NOT_CANCELABLE`, the Retail-only `IMPORTANT` and + `DISPELLABLE`). + - Whether `GameTooltip:SetUnit{Buff,Debuff}ByAuraInstanceID` exists. It's a widget method that isn't in + the API docs; already guarded at `Filger.lua:40`. Exists on Retail, Era, TBC and MoP (Experiment 8). + +## Options + +Each option is a different design, not a variant of another one. Every option keeps cooldowns list-driven +(spell IDs are Filger's own and are queried directly), plus `isActive`/`isOnGCD` and duration objects. + +### A. Secret-safe filters: only what the server can decide + +- **What:** each aura frame becomes an AuraContainer built from filter strings and boolean candidate + filters. Nothing reads aura fields in Lua. Proposed mapping (Filger frame → server filter): + - PLAYER_DEBUFFS → `HARMFUL` (all debuffs; Stagger only if never-secret) + - PLAYER_AURAS → `HELPFUL|PLAYER` + `includeSpellIDs` (allowed) + - PLAYER_BUFFS → `HELPFUL|!PLAYER` + `excludeSpellIDs` (blacklist allowed) + - TARGET_DEBUFFS hostile → `HARMFUL|PLAYER`; friendly → `HARMFUL|!PLAYER` (no ID filters) + - TARGET_AURAS → `HELPFUL`; on hostile units, `isStealable` or `IMPORTANT` + - FOCUS_DEBUFFS / BOSS_DEBUFFS → `HARMFUL` + `isBossAura = true` +- **Avoids secrets:** the server and Blizzard secure code do every comparison. +- **Works on:** Retail and Forever. Classic doesn't need it (no container there). +- **Screen:** cleaner than plain phase 2 on player buffs and own DoTs. More cluttered on player debuffs and + hostile-target buffs. +- **Lost:** stack thresholds, priority, the blacklist on debuff frames, name filters, `sourceUnit == nil`, + the LibDispel flash, Filger's own timer text. +- **Maintenance:** low. The filter strings live in `config.lua`. +- **Effort:** M. **Risk:** medium. The container is new in 12.1, and its API and the identity gate can + change. +- **Verify out of combat:** the container builds and the filter strings return the expected auras on a + dummy (Experiments 4, 5). The identity gate only bites in combat (Experiment 4). +- **Evidence:** filters and the gate are VERIFIED. That Filger can use the container is UNVERIFIED. + +### B. Curated lists plus coarse filters (all inside the container) + +- **What:** A, plus a small exact list where ID filtering is allowed. Both live in container groups. On + Mainline, B doesn't mix the container with a `UNIT_AURA` path: in combat that path sees only secrets, + and out of combat the container already works. + - Each class's defensives and procs on the player: `includeSpellIDs`, sorted by `Expiration`. + - The player's own debuffs on a hostile target. + - Priority tiers become separate aura groups ordered by `layout.layoutIndex` (UNVERIFIED that the order + holds). +- **Avoids secrets:** the list is handed to the container; Filger never compares it. +- **Works on:** both. On Classic the same `data/` list feeds today's Lua filter, priorities and stack + thresholds included. +- **Screen:** close to phase 1 on the player and target frames. Phase-2 style on the debuff frames. +- **Lost:** as in A, but the curated frames keep exactness. +- **Maintenance:** a handful of spells per class, and no boss-debuff list on Retail. +- **Effort:** M. **Risk:** the same as A. +- **Verify:** Experiments 3 and 4. +- **Evidence:** VERIFIED design; usability is UNVERIFIED. + +### C, D. Spell data + +Moved to [spell-data-generation.md](spell-data-generation.md) (learn mode, generated data). A later +iteration, after Filger works on every game version. Until then `data/` stays unchanged. + +### E. Lean on Blizzard's frames + +- **What:** skin the CDM viewers and anchor Filger next to them. Show boss mechanics through private-aura + anchors. Blizzard's buff frames are the fallback for everything else. +- **Avoids secrets:** Blizzard does all the logic. +- **Works on:** Retail and Forever. +- **Cost:** the tracked set lives in `/cdm` (per character, outside `data/`), which goes against "decisions + in code". Edit Mode owns position. There's no focus support, and the CDM shows only auras the player + cast. +- **Effort:** S (skin). **Risk:** high coupling to Blizzard internals. +- **Evidence:** cooldown-manager.md §7–§10 (VERIFIED). Private-aura anchors from addons work (Experiment 6). + +### F. Architecture: split vs one path + +- **F1 (chosen): split the aura implementation per client family** with the TOC `[Family]` token. The + families match the secret boundary exactly: + + | `[Family]` folder | Clients | Secrets | Container | Aura implementation | + |---|---|---|---|---| + | `Mainline` | Retail (`standard`), Forever (`camelot`) | yes | yes | AuraContainer only | + | `Classic` | Era, TBC, Titan, MoP (and Cata) | no | no | Today's `UNIT_AURA` + `GetAuraSlots` + Lua filters | + + - `[Game]` would need identical copies in `Standard/` and `Camelot/`, and again in every Classic folder. + `[Family]` is too coarse for spell data (compatibility.md), but it is the right grain for the + implementation. Blizzard uses the same pattern (`Blizzard_AuraContainer.toc`: + `[Family]\Blizzard_AuraButtonTooltip.xml`, folders `Classic/` and `Mainline/`, **VERIFIED**). + - For: + - No runtime client branches. + - The Classic file is today's code, so there's no regression risk there. + - The Mainline file can drop `ScanSpellAuras`, `self.restricted` and the `issecretvalue` guards in the + aura path. + - Against: + - The look must be kept identical across two files. + - A client that changes family behavior later (Classic gaining secrets) means moving a file instead of + flipping a check. +- **F2 (rejected): one file**, with `Spawn` choosing the source via + `C_Secrets.HasSecretRestrictions() and CustomAuraContainerTemplate`. Once Mainline is container-only, + the two paths share no aura logic, so the branch would only select between two unrelated + implementations inside one file. +- **What's split and what's shared:** + + | Per family (`core\[Family]\...`) | Shared (untagged) | + |---|---| + | Aura element: container groups vs today's `aura_proto` | `Filger:CreateButton` / button styling | + | Aura frame definitions: filter strings and groups vs `FilterAura`/`SortAuras` closures | Layout (`SetPosition`), `Spawn` skeleton | + | Aura tooltips | `data/`, colors, fonts, commands, `config.lua` general settings | + | | Cooldowns: the secret branch is per spell via `ShouldSpellCooldownBeSecret`, non-secret on Classic | + +- TOC sketch (file names illustrative): + + ```text + core\init.xml + config.lua + core\[Family]\auras.lua + Filger.lua + ``` + + Every family folder must contain every listed file, as with `[Game]`. +- Guard: the Mainline file should warn once at load if `CustomAuraContainerTemplate` is missing, instead + of silently showing nothing. + +### G. Drop what can't survive + +- **What:** in secret mode, hide the frames that depend on "show everything except" logic + (PLAYER_DEBUFFS, TARGET_AURAS, FOCUS_DEBUFFS), or let them fall back to raw filter strings. Keep player + auras, own DoTs and cooldowns. +- **Works on:** Retail and Forever. Classic is unchanged. +- **Screen:** cleaner but less informative. Debuffs on the player must come from Blizzard's frames. +- **Effort:** S. **Risk:** low. +- **Evidence:** VERIFIED. + +## Comparison + +| Option | Clients | Screen | Lost vs phase 1 / phase 2 | Maintenance | Effort | Risk | Evidence | +|---|---|---|---|---|---|---|---| +| A. Secret-safe filters | Retail, Forever | Mixed: clean on own buffs/DoTs, cluttered on debuffs | Stacks, priority, debuff blacklist, name filters, dispel flash, own timer | Low | M | Medium (new container) | Rules VERIFIED; use UNVERIFIED | +| B. Curated lists + coarse filters | All (Classic via current logic) | Near phase 1 on player/target; phase 2 on debuffs | As A, minus exactness on curated frames | Low–medium (short lists) | M | Medium | As A | +| E. Blizzard frames | Retail, Forever | Blizzard's look | Units, data in code, position | Lowest code, user-side `/cdm` | S | High coupling | Mostly VERIFIED | +| F1. Family split (chosen) | All | — | — | Two aura files, one `data/` | Part of A/B | Low; `[Family]` on Forever unverified | TOC pattern VERIFIED in Blizzard source | +| F2. One path + feature check (rejected) | All | — | — | One file, two unrelated paths | Part of A/B | Low | VERIFIED APIs | +| G. Drop features | Retail, Forever | Cleaner, less info | Debuff frames | Lowest | S | Low | VERIFIED | + +## Recommendation + +**Decided: F1 family split.** + +- **Mainline (Retail, Forever):** AuraContainer only. Option A's filter strings and flags, plus B's + curated `includeSpellIDs`/`excludeSpellIDs` on the frames where ID filtering is allowed: PLAYER_AURAS + (class list), PLAYER_BUFFS (blacklist), own DoTs on a hostile target. No `UNIT_AURA` path. +- **Classic:** today's implementation, unchanged. +- **Shared:** buttons, layout, `data/`, cooldowns. +- E isn't recommended: it moves the decisions out of code. Spell data (C, D) is a later iteration + ([spell-data-generation.md](spell-data-generation.md)). + +### Migration (each phase ships on its own) + +1. **Phase 0: fixes that work today.** + - Read `cdInfo.isEnabled` (`Filger.lua:648`). + - On secret auras, use `C_UnitAuras.GetAuraDuration` + `SetCooldownFromDurationObject` instead of + `SetCooldown` (`Filger.lua:244-245`), provided Experiment 2 passes. + - Refresh only the event's `spellID` in `SPELL_UPDATE_COOLDOWN`. +2. **Phase 1: the split, with no behavior change.** + - Move `aura_proto` and the aura frame definitions into `core/Classic/` and add `core\[Family]\...` to + the TOC. + - Start `core/Mainline/` as a copy, so Retail behaves as it does today. + - Check in game that `[Family]` resolves on every client, Forever included. +3. **Phase 2: Mainline container for PLAYER_AURAS and PLAYER_BUFFS.** PLAYER_AURAS uses + `HELPFUL|PLAYER` + `includeSpellIDs`; PLAYER_BUFFS uses `HELPFUL|!PLAYER` + `excludeSpellIDs`. Both + frames are allowed to filter by ID. +4. **Phase 3: the remaining Mainline frames.** + - TARGET_DEBUFFS (`HARMFUL|PLAYER` on hostile units), TARGET_AURAS, FOCUS_DEBUFFS. + - BOSS_DEBUFFS (`isBossAura`; plus private-aura anchors, Experiment 6). + - PLAYER_DEBUFFS shows only important debuffs, one group each: `HARMFUL|RAID_PLAYER_DISPELLABLE`, + `HARMFUL|CROWD_CONTROL`, `isBossAura`. ~~Stagger by ID~~: not possible, Stagger is contextually + secret (Experiment 3). + - Then delete the copied event path from `core/Mainline/`. + +### Impossible in secret mode on Retail and Forever (drop or replace) + +| Feature | Status | Replacement | +|---|---|---| +| `stackThreshold` (`Filger.lua:309`) | Impossible | None | +| Numeric `priority` sort (`Filger.lua:282`, `config.lua:115`) | Impossible | Approximate with aura groups (UNVERIFIED) | +| Blacklist/whitelist by ID on debuffs on the player or a friendly target, and on buffs on a hostile target | Blocked by design | Never-secret spells only | +| Blacklist by name (`"Well Fed"`, `"Food"`, …) | Impossible | Exclude by spell ID where allowed | +| `sourceUnit == nil` (`config.lua:156`, `171`) | Impossible | `isBossAura` | +| LibDispel type/dispelable lookups, dispel flash (`Filger.lua:347-348`, `200-206`) | Impossible | `RAID_PLAYER_DISPELLABLE`/`DISPELLABLE` group + `AddDispelTypeTexture` / `GetAuraDispelTypeColor` (UNVERIFIED) | +| Dispel-colored border via `Filger.colors[dispelName]` (`Filger.lua:187`) | Impossible as a table key | `GetAuraDispelTypeColor(curve)` → `SetBackdropBorderColor` (UNVERIFIED that the backdrop accepts secrets) | +| Own timer text and red under 5 s (`Filger.lua:72-92`) | Impossible | Duration text binding + color curve (works for cooldowns, verified on Retail) | +| Sorting auras by expiration in Lua | Impossible | Container `Expiration` sort | +| Sorting cooldowns by remaining time while secret (`Filger.lua:620`) | Impossible | Fixed list order | +| Incremental `UNIT_AURA` bookkeeping keyed by `auraInstanceID` | Impossible | Container handles updates | + +### Classic + +Same branch, same TOC, its own aura file (`core/Classic/`), which is today's code. Classic has no +secret flags, so today's logic keeps working there, including the boss-debuff and blacklist data. +Filter-string differences (`NOT_CANCELABLE`) stay inside each family's frame definitions. + +## Experiments + +Run each one on Retail, and on the Classic clients where noted. Unless stated otherwise, run them **in +combat** (target dummy, then a dungeon boss). + +1. **Restriction state.** + `/run print(C_Secrets.HasSecretRestrictions(), C_Secrets.ShouldAurasBeSecret(), C_Secrets.ShouldCooldownsBeSecret())` + on every client: out of combat, in combat in the open world, in a dungeon out of combat, and in a boss + fight. Pass: Classic prints `false false false` everywhere. + - **Result (2026-10-21):** + - Retail: `true false false` out of combat (open world and dungeon); `true true true` in combat + (dummy and boss). Restrictions follow combat state; a dungeon alone doesn't restrict these APIs. + `HasSecretRestrictions()` is always `true`, so it tells which client this is, not whether data + is secret right now. + - Era, TBC, MoP: `false false false` (open world, in and out of combat). Dungeon steps skipped, + since `HasSecretRestrictions()` is false on these clients. + - Forever, Titan: not tested (no client). +2. **Instance IDs from tainted code.** + `/run local t=C_UnitAuras.GetUnitAuraInstanceIDs("player","HELPFUL"); local id=t[1]; print(issecretvalue(id), pcall(function() return #t end), pcall(C_UnitAuras.GetAuraDuration,"player",id))`. + Settles whether `GetAuraDuration` and the `#` count work on secret IDs. + - **Result (2026-10-21, Retail, in a dungeon):** + - Out of combat: `false true true LuaDurationObject: …`. IDs aren't secret, and `GetAuraDuration` + works. The middle `pcall` printed only `true` because a call that isn't last in an argument list + keeps only its first return value. That's a flaw in the snippet, not a finding. + - In combat: the first call already errors with `GetUnitAuraInstanceIDs(): Auras cannot be accessed + when secret while tainted by '*** ForceTaint_Strong ***'`. `RequiresUnitAuraAccess` is a + `Precondition` with `FailureMode = "Error"` (`APIDoc/SecretPredicatesDocumentation.lua:48`). It + is set on 18 `C_UnitAuras` functions, including `GetAuraSlots`, `GetAuraDataBySlot`, + `GetAuraDataByAuraInstanceID`, `GetUnitAuraInstanceIDs`, `GetUnitAuras`, `GetAuraDuration`, + `IsAuraFilteredOutByInstanceID`, `GetAuraDispelTypeColor` and `GetAuraApplicationDisplayCount`. + So the problem isn't that instance IDs are secret: tainted code can't reach any instance ID at + all. + - Consequence: in combat on Retail, the only aura data an addon can read itself comes from the + `RequiresNonSecretAura` lookups (`GetPlayerAuraBySpellID`, `GetUnitAuraBySpellID`, …) for + never-secret spells. Everything else needs the AuraContainer. + - Caveat: `/run` is tainted with `ForceTaint_Strong`. Addon code is tainted too, so the result + should be the same for Filger, but that is **UNVERIFIED** from an addon file. +3. **Never-secret coverage** (out of combat is fine). A dev command loops over `Filger.spells`, + `Filger.blacklist` and `Filger.cooldowns` and counts `C_Secrets.GetSpellAuraSecrecy(id) == Enum.SecrecyLevel.NeverSecret`. + Also check Stagger: `/run for _,id in ipairs({124273,124274,124275}) do print(id, C_Secrets.GetSpellAuraSecrecy(id)) end`. + - **Result (2026-10-21, Retail, `/run` loops; cooldowns checked with `GetSpellCooldownSecrecy`):** + - `Filger.spells` (all classes): 1/100 never-secret (344179). In combat, `ScanSpellAuras` + finds almost nothing. + - `Filger.blacklist` (numeric keys): 17/313 never-secret: 80354 404464 404468 462854 390435 + 381753 1126 206151 2823 3408 6673 1459 57723 57724 369459 264689 21562. Only these can be + ID-filtered on debuffs on the player. + - `Filger.cooldowns`: 0/28. Every cooldown is contextually secret, so the duration-object + path is required in combat. + - Stagger 124273/124274/124275: `2` (`ContextuallySecret`). Stagger is a harmful aura on the + player, so the identity gate blocks it. A Stagger group by ID is impossible on Retail. +4. **Container test addon** (about 30 lines, `CreateFrame("AuraContainer", nil, UIParent, "CustomAuraContainerTemplate")`): + - one group `HELPFUL|PLAYER` with `includeSpellIDs` + - one group `HARMFUL` on `player` with `includeSpellIDs` (expected empty in combat) and with + `excludeSpellIDs` (expected unfiltered) + - `SetUnit("focus")` + - Pass: no taint errors, and the filters behave as the gate table says. + - **Result (2026-10-22, Retail, Brewmaster, target dummy):** `/filger exp4 101643,124275` + (`core/development.lua`), Transcendence as the buff, Light Stagger as the debuff. + - Out of combat: T1 shows Transcendence. T2/T3 not checked (Stagger falls off after combat). + - In combat: T1 Transcendence; T2 (`HARMFUL` + include Stagger) **empty**; T3 (`HARMFUL` + + exclude Stagger) **shows Light Stagger**; T4 (focus `HARMFUL`) shows Keg Smash and Mystic Touch. + No errors reported. Matches the gate table: ID filters on debuffs on the player are refused. + - `CustomAuraButtonTemplate` has no regions. Without an `initializeFrame` that calls `SetIcon`, + buttons draw nothing (first run showed an empty T1). +5. **Filter strings.** `/run print(#{C_UnitAuras.GetAuraSlots("player","HELPFUL|!PLAYER")}-1)`, then the + same for `HELPFUL|BIG_DEFENSIVE`, `HARMFUL|RAID_PLAYER_DISPELLABLE` and `HELPFUL|!CANCELABLE`. Run it + with a known defensive up, on Retail and each Classic client. Out of combat is fine. + - Now `/filger exp5` (`core/development.lua`). Unknown tokens don't error, they're ignored: a count + equal to the `HELPFUL`/`HARMFUL` baseline means the token is unknown or matches everything. + - **Result (2026-10-22, Retail, solo, out of combat):** Celestial Brew, Fortifying Brew, Tiger's + Lust and a trinket buff up; no debuffs. + - `HELPFUL` 17; `PLAYER` 17 + `!PLAYER` 0 = 17; `CANCELABLE` 17 + `!CANCELABLE` 0 = 17. Negation + works. + - `NOT_CANCELABLE` 17 (= baseline): removed on Retail and silently ignored, no error. + - `BIG_DEFENSIVE` 1, `IMPORTANT` 1: applied by the server (below baseline). Which buff each + matched wasn't recorded. + - `RAID_PLAYER_DISPELLABLE`, `DISPELLABLE` 0: inconclusive, `HARMFUL` was 0. + - Not tested: a buff from another caster, a dispellable debuff. + - **Result (2026-10-22, MoP, solo, out of combat):** no debuffs. + - `HELPFUL` 7; `PLAYER` 6 works. + - `!` negation is **not supported**: `!PLAYER` 7 and `!CANCELABLE` 7 equal the baseline, so the + negated token is ignored. Confirmed with a Paladin's Hand of Freedom on the player: `HELPFUL` 4, + `PLAYER` 2, `!PLAYER` 4 (should be 2). + - `CANCELABLE` 7 + `NOT_CANCELABLE` 0 = 7: `NOT_CANCELABLE` works on MoP. + - `BIG_DEFENSIVE` 7 and `IMPORTANT` 7 (= baseline): ignored. This contradicts the client table, + which lists `BIG_DEFENSIVE` for MoP from `AuraUtil.lua`; the token is known to the UI code but + the server doesn't apply it. + - Dispel filters 0: inconclusive. + - Classic auras use only `HELPFUL`, `HARMFUL` and `PLAYER|HELPFUL` (`core/Classic/auras.lua`), + so nothing breaks today. Classic filters must not use `!` or the new flags. + - Era and TBC skipped: the container filter strings are Mainline only, and Classic keeps its + Lua filtering. +6. **Private aura anchors.** In a test addon, call `C_UnitAuras.AddPrivateAuraAnchor{ unitToken="player", + auraIndex=1, parent=frame, showCooldownFrame=true, iconInfo={ iconAnchor={point="CENTER", + relativeTo=frame, relativePoint="CENTER", offsetX=0, offsetY=0}, iconWidth=40, iconHeight=40 } }`. It + needs a dungeon boss that applies a private aura. Pass: no error, and the icon shows. + - Now `/filger exp6` (`core/development.lua`): anchors for `auraIndex` 1–3 on the player. + - **Result (2026-10-22, Retail, Aberrus, Echo of Neltharion, solo):** run in combat: + `aurasSecret true`, anchor IDs 3133–3135, no error. Two different private debuffs (Volcanic Heart + among them) drew in the anchor slots. Blizzard's raid frames didn't show them (solo, so likely no + raid frames; not investigated). +7. **Cooldown edges.** In combat, print `issecretvalue` for `GetInventoryItemCooldown("player",13)` and + `C_Container.GetItemCooldown()`. Log which of the player's own spells arrive with a secret + `spellID` in `UNIT_SPELLCAST_SUCCEEDED`. + - Now `/filger exp7 [itemID]` (`core/development.lua`): toggles a log line per player cast with + `spellID` (or `secret`) and start/duration (or `secret`) for slots 13, 14 and the item. + - **Result (2026-10-23, Retail, Brewmaster, target dummy):** `/filger exp7 211880`, 15 casts, 14 of + them in combat. + - `UNIT_SPELLCAST_SUCCEEDED` on `player`: no secret `spellID`, including procs and + triggered spells (1241059, 1221146, 1219102). The `issecretvalue` skip in + `cooldown_proto:UNIT_SPELLCAST_SUCCEEDED` never fired. + - `GetInventoryItemCooldown` slots 13/14 and `C_Container.GetItemCooldown(211880)`: start and + duration not secret in combat, unlike spell cooldowns (Experiment 3: 0/28 never-secret). Matches + the API docs, which list no secret returns for `GetItemCooldown`. That run printed only + `issecretvalue`, so it doesn't show whether a cooldown was active; the boss run below does. + - **Result (2026-10-23, Retail, Brewmaster, dungeon boss):** `/filger exp7 211878` (Algari Healing + Potion), values printed. Trinket in slot 14 and the potion used in combat. + - Nothing secret: every `spellID` readable, and slots 13/14 and the item return plain numbers + while their cooldowns run (slot 14 `…/120.0`, item `…/300.0`). + - Item ID 211878 showed the 300 s cooldown of a potion drunk in the previous run (start 35327.8, + cast of 431416 at 35326.9), so the potion ranks share one cooldown, or 211880 and 211878 were + the same rank there. Not separated. + - At `UNIT_SPELLCAST_SUCCEEDED` the new item cooldown isn't applied yet: the potion cast (431416) + read `item 0.0/0.0`, the next cast read start 35630.9 (before the event's `GetTime` 35631.1). + Same for the trinket: the 1221146/1219102 cast at 35494.8 read `slot14 0.0/0.0`, the next cast + read start 35494.6. 1221146/1219102 are likely the trinket's use (also seen on the dummy); + **UNVERIFIED**. + - The slot 14 start moved earlier while the cooldown ran (35494.6 → 35468.6) and the cooldown ended + early at about 35590. Something reduced the trinket cooldown; the cause wasn't checked. Item + cooldowns must be re-read on update events, not computed once from the first read. + - Consequence for Filger: item and slot cooldowns need no secret branch. Filger refreshes them only + on `SPELL_UPDATE_COOLDOWN` (`Filger.lua:538`); whether that fires for item cooldowns, or + `BAG_UPDATE_COOLDOWN` is needed, is **UNVERIFIED**. +8. **Tooltips.** `/run print(GameTooltip.SetUnitBuffByAuraInstanceID ~= nil, GameTooltip.SetUnitAura ~= nil)` + on each Classic client. + - Now `/filger exp8` (`core/development.lua`): prints which `GameTooltip` aura methods exist. + - **Result (2026-10-23, Retail, Era, TBC, MoP):** `SetUnitAura`, `SetUnitBuff`, `SetUnitDebuff`, + `SetUnitBuffByAuraInstanceID` and `SetUnitDebuffByAuraInstanceID` all exist on every client. + Forever and Titan not tested. + - Rendering by instance ID (`/run`: `GetAuraDataByIndex("player",1,"HELPFUL")`, then + `SetUnitBuffByAuraInstanceID` with its `auraInstanceID`): + - Era: index 1 was Honorless Target (instance ID 2, Fortitude also up); the tooltip showed + Honorless Target. **Pass** for one buff. + - MoP: the tooltip showed Cyclonic Inspiration, the index-1 buff (the buff bar orders buffs + differently). The name wasn't printed, so the match is likely but not confirmed. + - After removing the index branch (2026-10-23): Filger buff and debuff tooltips show the right + aura on Retail, Era, TBC and MoP. + - Consequence: the Vanilla index branch (`Filger.isVanilla` → `GetAuraIndex` in + `core/Classic/auras.lua`, a full aura scan per processed aura) was removed (2026-10-23). Every + client now uses the instance-ID tooltip. +9. **`[Family]` loading.** Add a one-line `print` to each `core//` stub. `/reload` on every + client: Retail and Forever must print Mainline, the rest Classic, and exactly one line must print per + client. Out of combat is fine. + - **Result (2026-10-21):** Retail prints `Mainline`; Era, TBC and MoP print `Classic`, one line + each. Forever and Titan not tested (no client). +10. **Item cooldown refresh.** Filger refreshes item and slot cooldowns only on `SPELL_UPDATE_COOLDOWN` + (`Filger.lua:538`). Experiment 7 showed that an item cooldown starts just after + `UNIT_SPELLCAST_SUCCEEDED` and can shorten while it runs. Check which event reports each change: + - Search `../refs` for how Blizzard's UI refreshes item cooldowns (`BAG_UPDATE_COOLDOWN`, + `ACTIONBAR_UPDATE_COOLDOWN`). + - In game, on Retail and each Classic client: use a trinket and a potion in combat, and check that + Filger's icons start and follow the cooldown, including a cooldown that shortens. + - Pass: the icons stay correct with today's events. Otherwise register the missing event for item + and slot rows only. + - Now `/filger exp10 [itemID]` (`core/development.lua`): toggles a log of `SPELL_UPDATE_COOLDOWN`, + `BAG_UPDATE_COOLDOWN` and `ACTIONBAR_UPDATE_COOLDOWN`. A line prints when an event sees slot + 13/14/item values that differ from what that same event saw last, so each event's first view of a + start, a shortening and an end shows up once. + - **Source (2026-10-23, `wow-ui-source` live, classic, classic_anniversary, classic_era, forever, + classic_titan):** + - `BAG_UPDATE_COOLDOWN` exists on every client, with no payload (`ContainerDocumentation.lua`). + - Blizzard refreshes item and slot cooldowns on `BAG_UPDATE_COOLDOWN`: equipped slots + (`PaperDollItemSlotButton_OnEvent`, Retail and Classic), bags (`ContainerFrame`), the bank, + the objective tracker's quest items and the Retail `CooldownViewer` (`OnBagUpdateCooldownEvent`). + Action buttons use `ACTIONBAR_UPDATE_COOLDOWN`. No Blizzard item or slot button uses + `SPELL_UPDATE_COOLDOWN`. + - Retail and Forever `SPELL_UPDATE_COOLDOWN` carry a fifth argument `itemID` ("the itemID associated + with the cast of the spell"); Classic clients don't. So on Retail an item use likely fires + `SPELL_UPDATE_COOLDOWN` too, but whether it fires again when an item cooldown shortens is + **UNVERIFIED**. + - **Result (2026-10-23, Retail, first run):** `/filger exp10 211878`, trinket in slot 14 and the potion + used. The client is inferred from spell 1221146 (seen on Retail in Experiment 7); combat state not + recorded. + - Both starts are already readable in the same frame on all three events, in the order + `ACTIONBAR_UPDATE_COOLDOWN`, `BAG_UPDATE_COOLDOWN`, `SPELL_UPDATE_COOLDOWN`: trinket at 37940.8 + (`slot14 37940.6/120.0`, `spellID 1221146`), potion at 37957.9 (`item 37957.7/300.0`, + `spellID 216521`). This confirms 1221146 is the trinket's use spell. + - The `itemID` argument was `nil` on both item uses, despite its documentation. + - So starts work with today's events: Filger re-checks slot and item rows on every + `SPELL_UPDATE_COOLDOWN` (`cooldown_proto:UpdateCooldowns`). + - Not covered: a shortening, the end of a cooldown (the log stopped before either), Filger's icons, + and the Classic clients. + - **Result (2026-10-23, Retail, same session continued):** + - **No event fires when a cooldown ends.** The trinket was due at 38060.6, and 0/0 first showed at + 38063.0 on an unrelated cast (`spellID 458503`). The potion was due at 38257.7 and showed 0/0 at + 38261.4 (`spellID 450552`). Filger doesn't depend on this: `button_proto:OnUpdate` hides the icon + at `expirationTime`. + - **Shortening is reported.** The trinket started at 38290.4, then its start moved 2 s earlier about + once a second (38288.4 … 38214.4) and it ended at 38334.0, roughly 76 s early. Every step showed + up in the same frame on all three events, with `SPELL_UPDATE_COOLDOWN` carrying a player spell + (1219102, 437965, 1263408, …). Filger re-reads slot rows on each of these events, so + `expirationTime` follows the shortening. Each line is a change, so this doesn't show whether the + shortening triggers the event or rides on the casts. + - `BAG_UPDATE_COOLDOWN` never reported a change that `SPELL_UPDATE_COOLDOWN` missed. At 38213.1 it + didn't fire while `SPELL_UPDATE_COOLDOWN` (`spellID nil`) and `ACTIONBAR_UPDATE_COOLDOWN` did, + with `GetItemCooldown(211878)` returning `nil`. Then 0/0 at 38213.7, then 37958.9/300 at 38214.3: + the start moved 1.2 s later. Cause not checked. + - The `itemID` argument stayed `nil` on every event. + - **Retail passes for starts, shortenings and ends with today's events.** Filger's trinket icon + followed the cooldown. The potion showed no icon: `data/` has slot rows only + (`data/shared/cooldowns.lua`: slots 2, 6, 13, 14), and there's no item-row constructor. + `buildCooldowns` (`core/data.lua`) would also drop an `itemId` row, since it keeps only rows + with `slotId` or a known `spellId`. The `data.itemId` branch in `ProcessCooldownData` is + unreachable today. Classic clients still to run. + - **Result (2026-10-23, MoP, Monk, in combat):** `/filger exp10 76097` (Master Healing Potion), trinket + in slot 14 and the potion used. + - Trinket start: `slot14 40321.4/90.0` readable at 40321.6 on all three events in the same frame + (`SPELL_UPDATE_COOLDOWN` `spellID 146395`). The `itemID` argument is `nil`, as the MoP docs say. + - **Potion in combat:** from 40326.1 (`spellID 105708`), `GetItemCooldown(76097)` returned + `/60.0`: the start moved forward with every read (40326.1 … 40330.7). This looks like + Classic's "potion cooldown starts when you leave combat". The log didn't show `enabled` and + stopped before combat ended, so it's **UNVERIFIED**. If it holds, Filger's `start + duration` + would show a full 60 s forever while in combat, unless `enabled` is 0 (`FilterCooldown` hides + it). Moot: potions aren't tracked (decided 2026-10-23; all potions share one cooldown and the + item IDs change every patch and expansion). + - `SPELL_UPDATE_COOLDOWN` saw every change. `BAG_UPDATE_COOLDOWN` skipped some (40326.8, 40327.6, + 40327.8, 40328.7) and fired 0.2 s after the others at 40272.4. `ACTIONBAR_UPDATE_COOLDOWN` fired + alone once (40326.2). + - Not covered: the trinket end, a shortening, the potion after leaving combat, Filger's trinket icon. + - `/filger exp10` now also prints `enabled` (third value) for slots and the item. + +## Open questions for you + +1. ~~PLAYER_DEBUFFS on Retail~~ Decided: important debuffs only (dispellable, crowd control, boss, + Stagger); not every DoT. See Phase 3. +2. ~~Spell data (`FilgerData` dump, Classic boss data, `data/Cata/`)~~ Moved to + [spell-data-generation.md](spell-data-generation.md). diff --git a/docs/spell-data-generation.md b/docs/spell-data-generation.md new file mode 100644 index 0000000..a5bfe5a --- /dev/null +++ b/docs/spell-data-generation.md @@ -0,0 +1,108 @@ +# Spell data + +Plan only. No code was changed. As of 2026-10-21. + +- Split out of [secret-values-options.md](secret-values-options.md) (its former options C and D). Client + facts are in [compatibility.md](compatibility.md). +- Unless stated otherwise, paths are relative to `../refs/wow-ui-source/Interface/AddOns/`, and + `APIDoc/` means `Blizzard_APIDocumentationGenerated/`. +- Evidence tags: **VERIFIED** means read in Blizzard source or API docs (path given). **UNVERIFIED** means + an assumption that needs an in-game check. + +## When + +A later iteration, on its own branch, after Filger works on every game version +([secret-values-options.md](secret-values-options.md)). Until then `data/` stays unchanged. + +## Decisions + +- Nothing in `data/` is dropped or trimmed. Old content stays playable (Classic raids of any expansion, + legacy raids on Retail), so the boss and blacklist data still matter. +- Classic boss-debuff data: keep the existing data. +- `data/Cata/` and `Filger.isCata`: keep both. + +## Goal + +Maintain the spell tables in `data/` (boss debuffs, class lists, blacklist) with less hand work. Any tool +is maintainer-only: nothing runs for players and nothing becomes configurable. + +## Where it helps + +- **Retail and Forever:** class lists only. Spell-ID filtering of debuffs on the player or a friendly unit + is blocked by design (identity gate, see secret-values-options.md), so a boss-debuff list can't take + effect there in restricted content. Boss mechanics come from `isBossAura`, `RAID_PLAYER_DISPELLABLE` and + `CROWD_CONTROL` instead. Whether legacy raids on Retail run under addon restrictions is **UNVERIFIED**. +- **Classic:** boss debuffs and class lists, for raids of any expansion. + +## Sources + +### D1. In-game Encounter Journal dump + +1. A dev-only `/filger` command walks the journal out of combat: instance → encounter → section. +2. For each section, `C_EncounterJournal.GetSectionInfo(sectionID)` returns `spellID`, `title`, + `headerType`, … (`APIDoc/EncounterJournalDocumentation.lua:382-397`, **VERIFIED**). Keep sections with a + `spellID`, plus instance, boss and title. +3. Get the output out of the game: print for copy/paste, or write to `FilgerData` (declared, unused) and + read it from `WTF/` after logout. Using `FilgerData` this way is dev-only and temporary, not player + configuration. +4. A maintainer script turns the output into `data//bosses.lua`, or merges it into the existing file. + +Facts: + +- `GetSectionInfo` is in the API docs of every worktree: live, `classic`, `classic_titan`, + `classic_anniversary`, `classic_era`. Each also ships `Blizzard_EncounterJournal`. **VERIFIED** by grep. +- Whether the Classic journals contain their own raids and abilities is **UNVERIFIED**. +- Whether the section `spellID` is the debuff ID or the ID of the cast that applies it is **UNVERIFIED**. + They often differ, so the output needs review by hand. +- The journal has no class spells. D1 can't produce class lists. +- No third-party license involved; the data comes from the client. + +### D2. Build-time DB2 export + +- wago.tools CSV for `JournalEncounterSection`, `SpellEffect`, `SpellMisc`, processed in the maintainer's + environment, never at runtime. +- `SpellEffect` can link a cast to the aura it applies, which addresses D1's cast-vs-debuff problem + (**UNVERIFIED**). +- The data belongs to Blizzard. Bulk use and attribution under wago.tools' terms are **UNVERIFIED**; check + before relying on it. + +### D3. Community data + +- BigWigs, LittleWigs, DBM boss modules. +- Their licenses vary and are **UNVERIFIED** here. Read each LICENSE before taking anything. +- Take IDs only, never code, and credit the source. + +### C. Learn or log mode + +- **What:** while not restricted, record the spellIds of the auras seen. Promote the useful ones into the + whitelist or blacklist. +- **Avoids secrets:** reads happen only while `C_Secrets.ShouldAurasBeSecret()` is false. +- **The catch:** on Retail, encounter and M+ debuffs mostly exist only **while restricted**, so they can't + be learned, and ID lists on player debuffs are ignored anyway (identity gate). There it only helps class + buffs, which a curated list covers already. +- **Persistence:** it needs `FilgerData`. Kept as a dev-only command whose output the maintainer copies + into `data/`, it doesn't add configuration. As a player-facing feature it breaks the no-configuration + rule. +- **Works on:** all clients. Classic never restricts, so learning works there in combat too. +- **Evidence:** VERIFIED reasoning from the predicates; whether boss auras show up out of combat is + UNVERIFIED. + +## Comparison + +| Source | Boss data | Class lists | Runs | License | Effort | +|---|---|---|---|---|---| +| D1. EJ dump | Yes (needs review) | No | In game, out of combat | None | M | +| D2. DB2 export | Yes | Possible (**UNVERIFIED**) | Offline | wago.tools terms **UNVERIFIED** | M | +| D3. Community | Yes, curated | No | Offline | Per project, **UNVERIFIED** | S–M | +| C. Learn mode | Classic only; Retail can't see them | Yes, from what the maintainer plays | In game | None | S–M | + +Maintenance for D1–D3: one run per patch. Risk: low (offline or dev-only). + +## Recommendation + +D1 first (no license question); D2 if class lists outgrow hand maintenance. C only as a dev aid on +Classic. + +## Open questions + +1. If D1 or C is built: print for copy/paste, or temporary `FilgerData`?