diff --git a/BlizzNameplatesPlus.toc b/BlizzNameplatesPlus.toc new file mode 100644 index 0000000..78ea85d --- /dev/null +++ b/BlizzNameplatesPlus.toc @@ -0,0 +1,41 @@ +## Interface: 11200 +## Title: Blizz Nameplates+ +## Notes: Lightweight personal debuffs and class colors for Blizzard nameplates. Requires SuperWoW and ClassicAPI. +## Author: Wurmschwanz; nameplate discovery inspired by Shagu's Vanilla projects +## OptionalDeps: ShaguTweaks +## Version: 1.0.8 +## SavedVariables: BNP_DB + +core\core.lua +core\compat_shagutweaks.lua +core\ui.lua +core\config.lua +core\bridge.lua +core\libnameplate.lua +core\options.lua +spells\warlock.lua +spells\priest.lua +spells\warrior.lua +spells\rogue.lua +spells\hunter.lua +spells\mage.lua +spells\druid.lua +spells\shaman.lua +spells\paladin.lua +spells\registry_shadow.lua +modules\debug.lua +modules\spellprobe.lua +modules\scale.lua +modules\fullalpha.lua +modules\shagu_darkmode.lua +modules\unknown.lua +modules\auras.lua +modules\totems.lua +modules\aurarecorder.lua +modules\classcolors.lua +modules\castbar.lua +modules\guiddebug.lua +modules\tankmode.lua +modules\healthpercent.lua +modules\combopoints.lua +modules\commands.lua diff --git a/core/bridge.lua b/core/bridge.lua new file mode 100644 index 0000000..3921255 --- /dev/null +++ b/core/bridge.lua @@ -0,0 +1,10 @@ +BNP = BNP or {} + +function BNP:RegisterPlate(plate) + plate = plate or this + if not plate then return end + if BNP.plates[plate] then return end + + BNP.plates[plate] = true + BNP.detected = BNP.detected + 1 +end diff --git a/core/compat_shagutweaks.lua b/core/compat_shagutweaks.lua new file mode 100644 index 0000000..a4ccf97 --- /dev/null +++ b/core/compat_shagutweaks.lua @@ -0,0 +1,94 @@ +BNP = BNP or {} + +-- Robust ShaguTweaks compatibility. +-- BNP owns Scale / Class Colors / Castbar while loaded. +-- ShaguTweaks settings are NOT changed. + +BNP.shaguCompat = BNP.shaguCompat or { + patched = {}, + registerHooked = false, +} + +local wanted = { + ["Nameplate Scale"] = true, + ["Nameplate Class Colors"] = true, + ["Nameplate Castbar"] = true, +} + +local function IsWantedTitle(title) + if not title then return false end + + if wanted[title] then return true end + + if ShaguTweaks and ShaguTweaks.T then + if title == ShaguTweaks.T["Nameplate Scale"] then return true end + if title == ShaguTweaks.T["Nameplate Class Colors"] then return true end + if title == ShaguTweaks.T["Nameplate Castbar"] then return true end + end + + return false +end + +local function DisableModule(mod) + if not mod or type(mod) ~= "table" then return false end + if not IsWantedTitle(mod.title) then return false end + if mod.BNPCompatibilityPatched then return true end + + mod.BNPOriginalEnable = mod.enable + mod.enable = function(self) + return + end + + mod.BNPCompatibilityPatched = true + BNP.shaguCompat.patched[mod.title or "?"] = true + return true +end + +local function PatchExistingModules() + if not ShaguTweaks or not ShaguTweaks.mods then return end + + local title, mod + for title, mod in pairs(ShaguTweaks.mods) do + -- Some registered module tables use localized title as the map key. + if mod and not mod.title then mod.title = title end + DisableModule(mod) + end +end + +local function HookRegister() + if not ShaguTweaks or not ShaguTweaks.register then return end + if BNP.shaguCompat.registerHooked then return end + + local originalRegister = ShaguTweaks.register + + ShaguTweaks.register = function(self, mod) + local registered = originalRegister(self, mod) + DisableModule(registered or mod) + return registered + end + + BNP.shaguCompat.registerHooked = true +end + +function BNP:ApplyShaguTweaksCompatibility() + if not ShaguTweaks then return false end + + -- Covers BNP loading before Shagu's modules register. + HookRegister() + + -- Covers BNP loading after Shagu's modules registered. + PatchExistingModules() + + return true +end + +-- Try immediately in case ShaguTweaks is already loaded. +BNP:ApplyShaguTweaksCompatibility() + +-- Also retry after every addon load. This safely covers all Vanilla/Turtle +-- load-order variations without touching ShaguTweaks saved settings. +local loader = CreateFrame("Frame") +loader:RegisterEvent("ADDON_LOADED") +loader:SetScript("OnEvent", function() + BNP:ApplyShaguTweaksCompatibility() +end) diff --git a/core/config.lua b/core/config.lua new file mode 100644 index 0000000..69efb2a --- /dev/null +++ b/core/config.lua @@ -0,0 +1,204 @@ +BNP = BNP or {} + +BNP.defaults = BNP.defaults or { + nameplateScale = 1.0, + nameplateYOffset = 0, + nonTargetAlpha = 1.0, + iconSize = 18, + ccIconSize = 18, + debuffPosition = "top", + ccPosition = "top", + tankMode = false, + classColors = true, + castbars = true, + debuffs = true, + crowdControl = true, + separateCCRow = true, + showOtherCCs = true, + castbarHeight = 6, + castbarSpacing = 4, + minimapAngle = 225, + healthPercent = false, + healthText = "off", + targetFocus = true, + targetGlowColor = "white", + comboPoints = true, + totemIndicators = true, + totemIconSize = 24, +} + +function BNP:InitConfig() + BNP_DB = BNP_DB or {} + + if BNP_DB.nameplateScale == nil then BNP_DB.nameplateScale = self.defaults.nameplateScale end + if BNP_DB.nameplateYOffset == nil then BNP_DB.nameplateYOffset = self.defaults.nameplateYOffset end + if BNP_DB.nonTargetAlpha == nil then BNP_DB.nonTargetAlpha = self.defaults.nonTargetAlpha end + if BNP_DB.iconSize == nil then BNP_DB.iconSize = self.defaults.iconSize end + if BNP_DB.ccIconSize == nil then BNP_DB.ccIconSize = BNP_DB.iconSize or self.defaults.ccIconSize end + if BNP_DB.debuffPosition == nil then BNP_DB.debuffPosition = self.defaults.debuffPosition end + if BNP_DB.ccPosition == nil then BNP_DB.ccPosition = BNP_DB.debuffPosition or self.defaults.ccPosition end + if BNP_DB.tankMode == nil then BNP_DB.tankMode = self.defaults.tankMode end + if BNP_DB.classColors == nil then BNP_DB.classColors = self.defaults.classColors end + if BNP_DB.castbars == nil then BNP_DB.castbars = self.defaults.castbars end + if BNP_DB.debuffs == nil then BNP_DB.debuffs = self.defaults.debuffs end + if BNP_DB.crowdControl == nil then BNP_DB.crowdControl = (BNP_DB.debuffs ~= false) and true or false end + if BNP_DB.separateCCRow == nil then BNP_DB.separateCCRow = self.defaults.separateCCRow end + if BNP_DB.showOtherCCs == nil then BNP_DB.showOtherCCs = self.defaults.showOtherCCs end + if BNP_DB.castbarHeight == nil then BNP_DB.castbarHeight = self.defaults.castbarHeight end + if BNP_DB.castbarSpacing == nil then BNP_DB.castbarSpacing = self.defaults.castbarSpacing end + if BNP_DB.minimapAngle == nil then BNP_DB.minimapAngle = self.defaults.minimapAngle end + if BNP_DB.healthPercent == nil then BNP_DB.healthPercent = self.defaults.healthPercent end + if BNP_DB.healthText == nil then + -- Backwards-compatible migration from the old Health % checkbox. + BNP_DB.healthText = BNP_DB.healthPercent and "percent" or self.defaults.healthText + end + if BNP_DB.targetFocus == nil then BNP_DB.targetFocus = self.defaults.targetFocus end + if BNP_DB.targetGlowColor == nil then BNP_DB.targetGlowColor = self.defaults.targetGlowColor end + if BNP_DB.comboPoints == nil then BNP_DB.comboPoints = self.defaults.comboPoints end + if BNP_DB.totemIndicators == nil then BNP_DB.totemIndicators = self.defaults.totemIndicators end + if BNP_DB.totemIconSize == nil then BNP_DB.totemIconSize = self.defaults.totemIconSize end +end + +function BNP:GetNameplateScale() + return (BNP_DB and tonumber(BNP_DB.nameplateScale)) or self.defaults.nameplateScale +end + +function BNP:GetNameplateYOffset() + local value = (BNP_DB and tonumber(BNP_DB.nameplateYOffset)) or self.defaults.nameplateYOffset or 0 + if value < 0 then value = 0 end + if value > 50 then value = 50 end + return value +end + +function BNP:GetNonTargetAlpha() + local value = (BNP_DB and tonumber(BNP_DB.nonTargetAlpha)) or self.defaults.nonTargetAlpha or 1.0 + if value < 0.30 then value = 0.30 end + if value > 1.00 then value = 1.00 end + return value +end + +function BNP:GetIconSize() + return (BNP_DB and tonumber(BNP_DB.iconSize)) or self.defaults.iconSize +end + +function BNP:GetCCIconSize() + local value = (BNP_DB and tonumber(BNP_DB.ccIconSize)) or self.defaults.ccIconSize or self:GetIconSize() + if value < 12 then value = 12 end + if value > 32 then value = 32 end + return value +end + +function BNP:GetDebuffPosition() + local position = (BNP_DB and BNP_DB.debuffPosition) or self.defaults.debuffPosition or "top" + if position ~= "top" and position ~= "left" and position ~= "right" then + position = "top" + end + return position +end + +function BNP:GetCCPosition() + local position = (BNP_DB and BNP_DB.ccPosition) or self.defaults.ccPosition or self:GetDebuffPosition() or "top" + if position ~= "top" and position ~= "left" and position ~= "right" then + position = "top" + end + return position +end + +function BNP:IsTankModeEnabled() + return BNP_DB and BNP_DB.tankMode and true or false +end + +function BNP:AreClassColorsEnabled() + return not BNP_DB or BNP_DB.classColors ~= false +end + +function BNP:AreCastbarsEnabled() + return not BNP_DB or BNP_DB.castbars ~= false +end + +function BNP:GetCastbarHeight() + local value = (BNP_DB and tonumber(BNP_DB.castbarHeight)) or self.defaults.castbarHeight + if value < 4 then value = 4 end + if value > 14 then value = 14 end + return value +end + +function BNP:GetCastbarSpacing() + local value = (BNP_DB and tonumber(BNP_DB.castbarSpacing)) or self.defaults.castbarSpacing or 4 + if value < 0 then value = 0 end + if value > 10 then value = 10 end + return value +end + +function BNP:GetHealthTextMode() + local mode = (BNP_DB and BNP_DB.healthText) or self.defaults.healthText or "off" + if mode ~= "off" and mode ~= "percent" and mode ~= "hp" and mode ~= "both" then + mode = "off" + end + return mode +end + +function BNP:IsHealthPercentEnabled() + local mode = self:GetHealthTextMode() + return mode == "percent" or mode == "both" +end + +function BNP:IsHealthTextEnabled() + return self:GetHealthTextMode() ~= "off" +end + +function BNP:IsTargetFocusEnabled() + return not BNP_DB or BNP_DB.targetFocus ~= false +end + +function BNP:GetTargetGlowColor() + local key = (BNP_DB and BNP_DB.targetGlowColor) or self.defaults.targetGlowColor or "white" + + local colors = { + white = { 1.00, 1.00, 1.00 }, + gold = { 1.00, 0.82, 0.10 }, + blue = { 0.25, 0.55, 1.00 }, + green = { 0.25, 1.00, 0.35 }, + red = { 1.00, 0.20, 0.20 }, + purple = { 0.75, 0.35, 1.00 }, + } + + local c = colors[key] or colors.white + return c[1], c[2], c[3], key +end + +function BNP:AreComboPointsEnabled() + return not BNP_DB or BNP_DB.comboPoints ~= false +end + +function BNP:AreTotemIndicatorsEnabled() + return not BNP_DB or BNP_DB.totemIndicators ~= false +end + +function BNP:GetTotemIconSize() + local value = (BNP_DB and tonumber(BNP_DB.totemIconSize)) or self.defaults.totemIconSize or 24 + if value < 16 then value = 16 end + if value > 36 then value = 36 end + return value +end + + +function BNP:AreDebuffsEnabled() + return not BNP_DB or BNP_DB.debuffs ~= false +end + +function BNP:AreCrowdControlEnabled() + return not BNP_DB or BNP_DB.crowdControl ~= false +end + +function BNP:AreAnyAurasEnabled() + return self:AreDebuffsEnabled() or self:AreCrowdControlEnabled() +end + +function BNP:IsSeparateCCRowEnabled() + return not BNP_DB or BNP_DB.separateCCRow ~= false +end + +function BNP:ShowOtherPlayersCCs() + return not BNP_DB or BNP_DB.showOtherCCs ~= false +end diff --git a/core/core.lua b/core/core.lua new file mode 100644 index 0000000..bbb6526 --- /dev/null +++ b/core/core.lua @@ -0,0 +1,43 @@ +BNP = BNP or {} +BNP.version = "1.0.8" +BNP.plates = BNP.plates or {} +BNP.detected = 0 +BNP.debugEnabled = false + +function BNP:Print(msg) + if DEFAULT_CHAT_FRAME then + DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99Blizz Nameplates+:|r " .. tostring(msg)) + end +end + +local eventFrame = CreateFrame("Frame") +eventFrame:RegisterEvent("VARIABLES_LOADED") +eventFrame:SetScript("OnEvent", function() + BNP_DB = BNP_DB or {} + + if BNP.InitConfig then BNP:InitConfig() end + if BNP.CreateMinimapButton then BNP:CreateMinimapButton() end + if BNP.InstallTankMode then BNP:InstallTankMode() end + + if BNP_DB.debugEnabled == nil then BNP_DB.debugEnabled = false end + BNP.debugEnabled = BNP_DB.debugEnabled + + local classicNameplates = CLASSIC_API_VERSION and C_NamePlate and + type(C_NamePlate.GetNamePlateForUnit) == "function" + + if CombatLogAdd and SpellInfo and classicNameplates then + BNP:Print(BNP.version .. " loaded. SuperWoW + ClassicAPI nameplate tracking active.") + BNP:Print("Dragon command test build loaded.") + if ShaguTweaks then + BNP:ApplyShaguTweaksCompatibility() + local count = 0 + local k + for k in pairs(BNP.shaguCompat.patched or {}) do count = count + 1 end + BNP:Print("ShaguTweaks compatibility active (" .. tostring(count) .. "/3 nameplate modules suppressed).") + end + elseif not CombatLogAdd or not SpellInfo then + BNP:Print("ERROR: SuperWoW API not detected. Blizz Nameplates+ requires SuperWoW.") + else + BNP:Print("ERROR: ClassicAPI nameplate API not detected. Please update ClassicAPI.dll.") + end +end) diff --git a/core/libnameplate.lua b/core/libnameplate.lua new file mode 100644 index 0000000..c275cfd --- /dev/null +++ b/core/libnameplate.lua @@ -0,0 +1,119 @@ +BNP = BNP or {} + +BNP.libnameplate = BNP.libnameplate or { + OnInit = {}, + OnShow = {}, + OnUpdate = {}, +} + +local lib = BNP.libnameplate +local known = {} +local initializedChildren = 0 + +local function IsNamePlate(frame) + if not frame or not frame.GetObjectType then return false end + if frame:GetObjectType() ~= "Button" then return false end + + local firstRegion = frame:GetRegions() + if not firstRegion or not firstRegion.GetObjectType or not firstRegion.GetTexture then + return false + end + + if firstRegion:GetObjectType() ~= "Texture" then return false end + return firstRegion:GetTexture() == "Interface\\Tooltips\\Nameplate-Border" +end + +local function AttachPlateShortcuts(plate) + if plate.BNPShortcutsReady then return end + + -- Vanilla Blizzard nameplates expose the health StatusBar as their first child. + local healthbar = plate:GetChildren() + plate.healthbar = healthbar + + -- Vanilla region order: + -- 1 border, 2 glow, 3 name, 4 level, 5 level icon, 6 raid icon. + local regions = { plate:GetRegions() } + plate.border = regions[1] + plate.glow = regions[2] + plate.name = regions[3] + plate.level = regions[4] + plate.levelicon = regions[5] + plate.raidicon = regions[6] + + plate.BNPShortcutsReady = true +end + +local function Fire(list, plate) + plate = plate or this + if not plate then return end + + local i + for i = 1, table.getn(list) do + if list[i] then + pcall(list[i], plate) + end + end +end + +local function RegisterPlate(plate) + plate = plate or this + if not plate then return end + if known[plate] then return end + known[plate] = true + + AttachPlateShortcuts(plate) + + if BNP.RegisterPlate then + BNP:RegisterPlate(plate) + end + + Fire(lib.OnInit, plate) + if plate:IsShown() then + Fire(lib.OnShow, plate) + end + + -- Run OnShow callbacks when Blizzard reuses the same plate. + local oldOnShow = plate:GetScript("OnShow") + plate:SetScript("OnShow", function(self) + local current = self or this or plate + if not current then return end + if oldOnShow then oldOnShow(current) end + AttachPlateShortcuts(current) + Fire(lib.OnShow, current) + end) +end + +local scanner = CreateFrame("Frame") +local scanElapsed = 0 + +scanner:SetScript("OnUpdate", function() + scanElapsed = scanElapsed + arg1 + if scanElapsed < 0.10 then return end + scanElapsed = 0 + + local count = WorldFrame:GetNumChildren() + + -- Full scan only when WorldFrame gained children. This mirrors the lightweight + -- discovery strategy used by Shagu's Vanilla nameplate code. + if count > initializedChildren then + local children = { WorldFrame:GetChildren() } + local i + for i = 1, table.getn(children) do + local frame = children[i] + if not known[frame] and IsNamePlate(frame) then + RegisterPlate(frame) + end + end + initializedChildren = count + end + + -- Compatibility callback list for modules that want a throttled plate update. + if table.getn(lib.OnUpdate) > 0 then + local plate + for plate in pairs(known) do + if plate:IsShown() then + Fire(lib.OnUpdate, plate) + end + end + end +end) diff --git a/core/options.lua b/core/options.lua new file mode 100644 index 0000000..46124a7 --- /dev/null +++ b/core/options.lua @@ -0,0 +1,1162 @@ +BNP = BNP or {} + +local _, optionsPlayerClass = UnitClass("player") +local comboOptionsClass = (optionsPlayerClass == "ROGUE" or optionsPlayerClass == "DRUID") + +local function Round(value, step) + return math.floor((value / step) + 0.5) * step +end + +local sliderCount = 0 +local function CreateSlider(parent, label, minValue, maxValue, step, y) + sliderCount = sliderCount + 1 + local slider = CreateFrame("Slider", "BNPOptionsSlider" .. sliderCount, parent, "OptionsSliderTemplate") + slider:SetPoint("TOPLEFT", parent, "TOPLEFT", 28, y) + slider:SetWidth(220) + slider:SetHeight(16) + slider:SetMinMaxValues(minValue, maxValue) + slider:SetValueStep(step) + getglobal(slider:GetName() .. "Low"):SetText(tostring(minValue)) + getglobal(slider:GetName() .. "High"):SetText(tostring(maxValue)) + getglobal(slider:GetName() .. "Text"):SetText(label) + return slider +end + +local function CreateCheck(parent, label, y, onclick, x) + local check = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate") + check:SetPoint("TOPLEFT", parent, "TOPLEFT", x or 22, y) + check:SetWidth(24) + check:SetHeight(24) + + local text = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal") + text:SetPoint("LEFT", check, "RIGHT", 4, 0) + text:SetText(label) + check.BNPLabel = text + + check:SetScript("OnClick", onclick) + return check +end + +-- 1.12-safe emergency recorder ------------------------------------------------ +-- +-- Keep a compact recorder directly in this already proven options file. The +-- full diagnostic module replaces BNP.OpenAuraRecorder later when it loads. +-- If an old/private 1.12 client refuses that larger module, this fallback still +-- provides the window and the ClassicAPI aura data we actually need. + +local emergency = BNP.emergencyAuraRecorder or {} +BNP.emergencyAuraRecorder = emergency +emergency.lines = emergency.lines or {} +emergency.active = false + +local function EmergencyValue(value, limit) + if value == nil then return "nil" end + local text = tostring(value) + text = string.gsub(text, "\r", "\\r") + text = string.gsub(text, "\n", "\\n") + text = string.gsub(text, "\t", "\\t") + limit = limit or 240 + if string.len(text) > limit then text = string.sub(text, 1, limit) .. "..." end + return text +end + +local function EmergencyAppend(text) + text = EmergencyValue(text, 1300) + if string.len(table.concat(emergency.lines, "\n")) + string.len(text) > 60000 then + if not emergency.limitHit then + table.insert(emergency.lines, "[REPORT LIMIT REACHED - recording stopped]") + emergency.limitHit = true + emergency.active = false + end + return + end + table.insert(emergency.lines, text) + emergency.dirty = true +end + +local function EmergencyFields(data) + if type(data) ~= "table" then return EmergencyValue(data) end + local fields = {} + local key, value + for key, value in pairs(data) do + local kind = type(value) + if kind ~= "table" and kind ~= "function" and kind ~= "userdata" then + table.insert(fields, EmergencyValue(key, 80) .. "=" .. EmergencyValue(value, 180)) + end + end + table.sort(fields) + return table.concat(fields, " | ") +end + +local function EmergencyGUID(unit) + local exists, guid = UnitExists(unit) + if exists and guid then return guid end + if UnitGUID then + local ok, value = pcall(UnitGUID, unit) + if ok then return value end + end + return nil +end + +local function EmergencyWantedAura(aura) + if type(aura) ~= "table" then return true end + local spellID = tonumber(aura.spellId or aura.spellID) + local name = string.lower(tostring(aura.name or "")) + if spellID == 17794 or spellID == 17797 or spellID == 17798 or spellID == 17799 or spellID == 17800 then return true end + if name == "shadow vulnerability" or name == "schattenverwundbarkeit" then return true end + + local playerGUID = EmergencyGUID("player") + if aura.sourceGUID and playerGUID then return aura.sourceGUID == playerGUID end + if aura.sourceUnit then return aura.sourceUnit == "player" end + return false +end + +local function EmergencyIsShadowBolt(spellID, spellName) + local numeric = tonumber(spellID) + if numeric == 686 or numeric == 695 or numeric == 705 or numeric == 1088 or + numeric == 1106 or numeric == 7641 or numeric == 11659 or numeric == 11660 or + numeric == 11661 or numeric == 25307 then return true end + local name = string.lower(tostring(spellName or "")) + return name == "shadow bolt" or name == "schattenblitz" +end + +local function EmergencyRefresh() + if not emergency.edit then return end + emergency.edit:SetText(table.concat(emergency.lines, "\n")) + emergency.edit:SetHeight(math.max(300, table.getn(emergency.lines) * 16 + 30)) + if emergency.status then + if emergency.active then + emergency.status:SetText("RECORDING | Target: " .. EmergencyValue(UnitName("target") or "none", 100)) + emergency.status:SetTextColor(0.2, 1, 0.35) + else + emergency.status:SetText("STOPPED | " .. tostring(table.getn(emergency.lines)) .. " lines") + emergency.status:SetTextColor(1, 0.82, 0) + end + end + emergency.dirty = false +end + +local function EmergencySnapshot(reason, force) + if not UnitExists("target") then + if force then EmergencyAppend("AURA_SNAPSHOT | reason=" .. reason .. " | no target") end + return + end + + local auraLines = {} + local signatureParts = {} + local count = 0 + local classic = type(C_UnitAuras) == "table" and type(C_UnitAuras.GetDebuffDataByIndex) == "function" + local i + + if classic then + for i = 1, 64 do + local ok, aura = pcall(C_UnitAuras.GetDebuffDataByIndex, "target", i) + if not ok then + EmergencyAppend("CLASSICAPI_ERROR | " .. EmergencyValue(aura, 800)) + break + end + if not aura then break end + if EmergencyWantedAura(aura) then + count = count + 1 + local fields = EmergencyFields(aura) + table.insert(signatureParts, fields) + table.insert(auraLines, " CLASSIC_AURA " .. tostring(i) .. " | " .. fields) + end + end + else + for i = 1, 64 do + local ok, texture, stacks, dispelType, spellID, r5, r6, r7, r8, r9, r10 = pcall(UnitDebuff, "target", i) + if not ok or not texture then break end + count = count + 1 + local fields = "spellId=" .. EmergencyValue(spellID) .. + " | stacks=" .. EmergencyValue(stacks) .. + " | type=" .. EmergencyValue(dispelType) .. + " | texture=" .. EmergencyValue(texture, 160) .. + " | r5=" .. EmergencyValue(r5) .. " | r6=" .. EmergencyValue(r6) .. + " | r7=" .. EmergencyValue(r7) .. " | r8=" .. EmergencyValue(r8) .. + " | r9=" .. EmergencyValue(r9) .. " | r10=" .. EmergencyValue(r10) + table.insert(signatureParts, fields) + table.insert(auraLines, " LEGACY_AURA " .. tostring(i) .. " | " .. fields) + end + end + + local signature = table.concat(signatureParts, "||") + if emergency.baselineOnly then + emergency.lastSignature = signature + return + end + if not force and signature == emergency.lastSignature then return end + emergency.lastSignature = signature + EmergencyAppend( + "[+" .. string.format("%.3f", GetTime() - (emergency.startedAt or GetTime())) .. "] AURA_SNAPSHOT" .. + " | reason=" .. EmergencyValue(reason) .. + " | guid=" .. EmergencyValue(EmergencyGUID("target")) .. + " | source=" .. (classic and "ClassicAPI" or "UnitDebuff") .. + " | count=" .. tostring(count) + ) + if count == 0 then + EmergencyAppend(" AURA none") + else + for i = 1, table.getn(auraLines) do EmergencyAppend(auraLines[i]) end + end +end + +local function EmergencyUnregister() + if emergency.eventFrame then pcall(emergency.eventFrame.UnregisterAllEvents, emergency.eventFrame) end +end + +local function EmergencyStop(reason) + if not emergency.active then EmergencyRefresh() return end + EmergencySnapshot("final", true) + emergency.active = false + EmergencyUnregister() + if emergency.frame then emergency.frame:SetScript("OnUpdate", nil) end + EmergencyAppend("=== END | reason=" .. EmergencyValue(reason or "user") .. " ===") + EmergencyRefresh() +end + +local function EmergencyStart() + EmergencyUnregister() + emergency.lines = {} + emergency.limitHit = false + emergency.lastSignature = nil + emergency.startedAt = GetTime() + emergency.elapsed = 0 + emergency.active = true + if emergency.frame and emergency.onUpdate then emergency.frame:SetScript("OnUpdate", emergency.onUpdate) end + EmergencyAppend("BNP_CLASSICAPI_AURA_REPORT v2") + EmergencyAppend( + "ClassicAPI | version=" .. EmergencyValue(CLASSIC_API_VERSION or "not detected") .. + " | C_UnitAuras=" .. ((type(C_UnitAuras) == "table" and type(C_UnitAuras.GetDebuffDataByIndex) == "function") and "yes" or "no") .. + " | CopyToClipboard=" .. (CopyToClipboard and "yes" or "no") + ) + EmergencyAppend("Aura filter | own auras + Shadow Vulnerability from any Warlock") + EmergencyAppend("Target | name=" .. EmergencyValue(UnitName("target")) .. " | guid=" .. EmergencyValue(EmergencyGUID("target"))) + EmergencyAppend("Test | proc aura, refresh it while active, then let it expire") + EmergencyAppend("=== LIVE RECORDING ===") + + local events = { + "UNIT_CASTEVENT", "UNIT_AURA", "PLAYER_TARGET_CHANGED", + "UNIT_SPELLCAST_SENT", "UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP", + "UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_CHANNEL_START", "UNIT_SPELLCAST_CHANNEL_STOP", + } + local i + for i = 1, table.getn(events) do pcall(emergency.eventFrame.RegisterEvent, emergency.eventFrame, events[i]) end + -- Learn the target's existing auras without printing them. Only changes + -- occurring after Start Recording belong in the diagnostic report. + emergency.baselineOnly = true + EmergencySnapshot("baseline", false) + emergency.baselineOnly = false + EmergencyRefresh() +end + +local function EmergencyCreateWindow() + if emergency.frame then return emergency.frame end + + local frame = CreateFrame("Frame", "BNPEmergencyAuraRecorderFrame", UIParent) + frame:SetWidth(720) + frame:SetHeight(520) + frame:SetPoint("CENTER", UIParent, "CENTER", 0, 20) + frame:SetFrameStrata("DIALOG") + frame:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 } + }) + frame:SetMovable(true) + frame:EnableMouse(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function() this:StartMoving() end) + frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + frame:SetScript("OnHide", function() EmergencyStop("window closed") end) + emergency.frame = frame + + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + title:SetPoint("TOP", frame, "TOP", 0, -18) + title:SetText("Missing Spell / Aura Recorder") + + local status = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + status:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -50) + status:SetWidth(660) + status:SetJustifyH("LEFT") + emergency.status = status + + local start = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + start:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -76) + start:SetWidth(126) + start:SetHeight(24) + start:SetText("Start Recording") + start:SetScript("OnClick", EmergencyStart) + + local stop = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + stop:SetPoint("LEFT", start, "RIGHT", 8, 0) + stop:SetWidth(82) + stop:SetHeight(24) + stop:SetText("Stop") + stop:SetScript("OnClick", function() EmergencyStop("user") end) + + local copy = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + copy:SetPoint("LEFT", stop, "RIGHT", 8, 0) + copy:SetWidth(110) + copy:SetHeight(24) + copy:SetText(CopyToClipboard and "Copy Report" or "Select All") + copy:SetScript("OnClick", function() + EmergencyRefresh() + local report = table.concat(emergency.lines, "\n") + if CopyToClipboard and pcall(CopyToClipboard, report) then + emergency.status:SetText("COPIED | Paste with Ctrl+V") + emergency.status:SetTextColor(0.2, 1, 0.35) + else + emergency.edit:SetFocus() + emergency.edit:HighlightText() + emergency.status:SetText("SELECTED | Press Ctrl+C") + end + end) + + local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -5, -5) + + local scroll = CreateFrame("ScrollFrame", "BNPEmergencyAuraRecorderScroll", frame, "UIPanelScrollFrameTemplate") + scroll:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -112) + scroll:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -48, 30) + + local edit = CreateFrame("EditBox", "BNPEmergencyAuraRecorderEdit", scroll) + edit:SetWidth(640) + edit:SetHeight(300) + edit:SetMultiLine(true) + edit:SetAutoFocus(false) + edit:SetFontObject(ChatFontNormal) + edit:SetMaxLetters(65000) + edit:SetScript("OnEscapePressed", function() this:ClearFocus() end) + scroll:SetScrollChild(edit) + emergency.edit = edit + + emergency.eventFrame = CreateFrame("Frame", "BNPEmergencyAuraRecorderEvents") + emergency.eventFrame:SetScript("OnEvent", function() + if not emergency.active then return end + if event == "UNIT_AURA" and arg1 ~= "target" then return end + if event == "UNIT_CASTEVENT" and arg1 ~= EmergencyGUID("player") then return end + EmergencyAppend( + "[+" .. string.format("%.3f", GetTime() - emergency.startedAt) .. "] EVENT | " .. EmergencyValue(event) .. + " | a1=" .. EmergencyValue(arg1) .. " | a2=" .. EmergencyValue(arg2) .. + " | a3=" .. EmergencyValue(arg3) .. " | a4=" .. EmergencyValue(arg4) .. + " | a5=" .. EmergencyValue(arg5) .. " | a6=" .. EmergencyValue(arg6) + ) + emergency.forceSnapshot = event == "PLAYER_TARGET_CHANGED" or + (event == "UNIT_CASTEVENT" and EmergencyIsShadowBolt(arg4)) or + (event == "UNIT_SPELLCAST_SENT" and EmergencyIsShadowBolt(arg4, arg5)) or + (event ~= "UNIT_SPELLCAST_SENT" and string.find(tostring(event or ""), "UNIT_SPELLCAST_", 1, true) and EmergencyIsShadowBolt(arg3, arg4)) + end) + + emergency.onUpdate = function() + if not emergency.active then return end + emergency.elapsed = (emergency.elapsed or 0) + arg1 + if emergency.elapsed >= 0.12 then + emergency.elapsed = 0 + EmergencySnapshot(emergency.forceSnapshot and "event" or "poll", emergency.forceSnapshot) + emergency.forceSnapshot = false + if emergency.dirty then EmergencyRefresh() end + end + end + + return frame +end + +-- This remains available even if the full recorder loads, so that module can +-- fall back when a private client rejects one of the larger window calls. +function BNP:OpenEmergencyAuraRecorder() + local frame = EmergencyCreateWindow() + frame:Show() + EmergencyRefresh() +end + +function BNP:StartEmergencyAuraRecorder() + EmergencyStart() +end + +-- This is intentionally defined before the large optional recorder module. +-- A successful module load overwrites it; otherwise the button still works. +function BNP:OpenAuraRecorder() + self:OpenEmergencyAuraRecorder() +end + +function BNP:CreateOptions() + if self.optionsFrame then return end + + local frame = CreateFrame("Frame", "BNPOptionsFrame", UIParent) + frame:SetWidth(430) + frame:SetHeight(440) + frame:SetPoint("CENTER", UIParent, "CENTER", 0, 40) + frame:SetFrameStrata("DIALOG") + frame:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 } + }) + frame:SetMovable(true) + frame:EnableMouse(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function() this:StartMoving() end) + frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + frame:Hide() + + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + title:SetPoint("TOP", frame, "TOP", 0, -18) + title:SetText("Blizz Nameplates+") + + local version = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + version:SetPoint("TOP", title, "BOTTOM", 0, -4) + version:SetText("Version " .. tostring(BNP.version or "?")) + frame.versionText = version + + local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -5, -5) + + local function CreatePage() + local page = CreateFrame("Frame", nil, frame) + page:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -104) + page:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -18, 22) + page:Hide() + return page + end + + local function CreateSection(parent, label, y) + local text = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal") + text:SetPoint("TOPLEFT", parent, "TOPLEFT", 8, y) + text:SetText(label) + + local line = parent:CreateTexture(nil, "ARTWORK") + line:SetTexture(1, 1, 1, 0.16) + line:SetPoint("LEFT", text, "RIGHT", 8, 0) + line:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + line:SetHeight(1) + + return text + end + + local function SetCheckEnabled(check, enabled) + if not check then return end + if enabled then + if check.Enable then check:Enable() end + check:SetAlpha(1.0) + if check.BNPLabel then check.BNPLabel:SetTextColor(1, 0.82, 0) end + else + if check.Disable then check:Disable() end + check:SetAlpha(0.45) + if check.BNPLabel then check.BNPLabel:SetTextColor(0.5, 0.5, 0.5) end + end + end + + -- Compact tab navigation. Totems stays separate from aura tracking so PvP + -- nameplate indicators never touch the debuff/CC state machine. + frame.pages = { + nameplates = CreatePage(), + auras = CreatePage(), + totems = CreatePage(), + castbar = CreatePage(), + target = CreatePage(), + tools = CreatePage(), + } + frame.tabs = {} + + local tabDefs = { + { key = "nameplates", label = "Nameplates", width = 76 }, + { key = "auras", label = "Auras", width = 52 }, + { key = "totems", label = "Totems", width = 58 }, + { key = "castbar", label = "Castbar", width = 60 }, + { key = "target", label = "Target", width = 56 }, + { key = "tools", label = "Tools", width = 50 }, + } + + function frame:ShowTab(key) + local pageKey, page + for pageKey, page in pairs(self.pages) do + if pageKey == key then page:Show() else page:Hide() end + end + + local i + for i = 1, table.getn(tabDefs) do + local def = tabDefs[i] + local button = self.tabs[def.key] + if button then + if def.key == key then + if button.Disable then button:Disable() end + button:SetAlpha(1.0) + else + if button.Enable then button:Enable() end + button:SetAlpha(0.80) + end + end + end + self.selectedTab = key + end + + local tabX = 28 + local i + for i = 1, table.getn(tabDefs) do + local def = tabDefs[i] + local button = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + button:SetWidth(def.width) + button:SetHeight(24) + button:SetPoint("TOPLEFT", frame, "TOPLEFT", tabX, -68) + button:SetText(def.label) + button.BNPTabKey = def.key + button:SetScript("OnClick", function() + frame:ShowTab(this.BNPTabKey) + end) + frame.tabs[def.key] = button + tabX = tabX + def.width + 4 + end + + -- NAMEPLATES TAB --------------------------------------------------------- + local nameplatesPage = frame.pages.nameplates + CreateSection(nameplatesPage, "Nameplates", -4) + + local scale = CreateSlider(nameplatesPage, "Nameplate Scale", 0.70, 1.50, 0.05, -38) + scale:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = Round(this:GetValue(), 0.05) + BNP_DB.nameplateScale = value + getglobal(this:GetName() .. "Text"):SetText("Nameplate Scale: " .. string.format("%.2f", value)) + if BNP.ApplyNameplateScaleAll then BNP:ApplyNameplateScaleAll() end + end) + frame.scaleSlider = scale + + local yOffset = CreateSlider(nameplatesPage, "Nameplate Y Offset", 0, 50, 1, -86) + yOffset:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.nameplateYOffset = value + getglobal(this:GetName() .. "Text"):SetText("Nameplate Y Offset: +" .. value) + if BNP.ApplyNameplateYOffsetAll then BNP:ApplyNameplateYOffsetAll() end + end) + frame.yOffsetSlider = yOffset + + local nonTargetAlpha = CreateSlider(nameplatesPage, "Non-Target Alpha", 30, 100, 5, -134) + nonTargetAlpha:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local percent = math.floor((this:GetValue() / 5) + 0.5) * 5 + if percent < 30 then percent = 30 end + if percent > 100 then percent = 100 end + BNP_DB.nonTargetAlpha = percent / 100 + getglobal(this:GetName() .. "Text"):SetText("Non-Target Alpha: " .. percent .. "%") + if BNP.RefreshNonTargetAlpha then BNP:RefreshNonTargetAlpha() end + end) + frame.nonTargetAlphaSlider = nonTargetAlpha + + local classColors = CreateCheck(nameplatesPage, "Class Colors", -180, function() + BNP_DB.classColors = this:GetChecked() and true or false + if BNP_DB.classColors then + local plate + for plate in pairs(BNP.plates or {}) do + plate.BNPClassColorApplied = nil + plate.BNPClassR = nil + plate.BNPClassG = nil + plate.BNPClassB = nil + end + end + end) + frame.classColorsCheck = classColors + + local tank = CreateCheck(nameplatesPage, "Tank Mode", -208, function() + BNP_DB.tankMode = this:GetChecked() and true or false + if BNP.UpdateTankMode then BNP:UpdateTankMode() end + end) + tank:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Tank Mode", 1, 0.82, 0) + GameTooltip:AddLine("Green: the unit is targeting you.", 1, 1, 1) + GameTooltip:AddLine("Red: the unit is targeting someone else.", 1, 1, 1) + GameTooltip:Show() + end) + tank:SetScript("OnLeave", function() GameTooltip:Hide() end) + frame.tankCheck = tank + + local healthTextLabel = nameplatesPage:CreateFontString(nil, "OVERLAY", "GameFontNormal") + healthTextLabel:SetPoint("TOPLEFT", nameplatesPage, "TOPLEFT", 28, -248) + healthTextLabel:SetText("Health Text") + + local healthTextDropdown = CreateFrame("Frame", "BNPHealthTextDropdown", nameplatesPage, "UIDropDownMenuTemplate") + healthTextDropdown:SetPoint("TOPLEFT", nameplatesPage, "TOPLEFT", 10, -260) + UIDropDownMenu_SetWidth(150, healthTextDropdown) + + local healthModeLabels = { + off = "Off", + percent = "Percent", + hp = "HP", + both = "HP + Percent", + } + + local function SetHealthTextMode(mode) + if not healthModeLabels[mode] then mode = "off" end + BNP_DB.healthText = mode + BNP_DB.healthPercent = (mode == "percent" or mode == "both") + UIDropDownMenu_SetSelectedValue(healthTextDropdown, mode) + UIDropDownMenu_SetText(healthModeLabels[mode], healthTextDropdown) + if BNP.RefreshHealthPercent then BNP:RefreshHealthPercent() end + end + + UIDropDownMenu_Initialize(healthTextDropdown, function() + local modes = { "off", "percent", "hp", "both" } + local n + for n = 1, table.getn(modes) do + local mode = modes[n] + local info = {} + info.text = healthModeLabels[mode] + info.value = mode + info.func = function() SetHealthTextMode(this.value) end + info.checked = (BNP:GetHealthTextMode() == mode) + UIDropDownMenu_AddButton(info) + end + end) + frame.healthTextDropdown = healthTextDropdown + frame.SetHealthTextMode = SetHealthTextMode + + if comboOptionsClass then + local comboPoints = CreateCheck(nameplatesPage, "Combo Points", -208, function() + BNP_DB.comboPoints = this:GetChecked() and true or false + if BNP.RefreshComboPoints then BNP:RefreshComboPoints() end + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + end, 190) + frame.comboPointsCheck = comboPoints + end + + -- AURAS TAB -------------------------------------------------------------- + local aurasPage = frame.pages.auras + CreateSection(aurasPage, "Auras & Crowd Control", -4) + + local icon = CreateSlider(aurasPage, "Aura Icon Size", 12, 32, 1, -38) + icon:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.iconSize = value + getglobal(this:GetName() .. "Text"):SetText("Aura Icon Size: " .. value) + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + end) + frame.iconSlider = icon + + local ccIcon = CreateSlider(aurasPage, "CC Icon Size", 12, 32, 1, -86) + ccIcon:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.ccIconSize = value + getglobal(this:GetName() .. "Text"):SetText("CC Icon Size: " .. value) + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + end) + frame.ccIconSlider = ccIcon + + local debuffs = CreateCheck(aurasPage, "Debuffs", -134, function() + BNP_DB.debuffs = this:GetChecked() and true or false + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + end) + frame.debuffsCheck = debuffs + + local debuffPositionDropdown = CreateFrame("Frame", "BNPDebuffPositionDropdown", aurasPage, "UIDropDownMenuTemplate") + debuffPositionDropdown:SetPoint("TOPLEFT", aurasPage, "TOPLEFT", 188, -128) + UIDropDownMenu_SetWidth(92, debuffPositionDropdown) + + local debuffPositionLabels = { + top = "Top", + left = "Left", + right = "Right", + } + + local function SetDebuffPosition(position) + if not debuffPositionLabels[position] then position = "top" end + BNP_DB.debuffPosition = position + UIDropDownMenu_SetSelectedValue(debuffPositionDropdown, position) + UIDropDownMenu_SetText(debuffPositionLabels[position], debuffPositionDropdown) + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + if BNP.RefreshTotemIndicators then BNP:RefreshTotemIndicators() end + end + + UIDropDownMenu_Initialize(debuffPositionDropdown, function() + local positions = { "top", "left", "right" } + local n + for n = 1, table.getn(positions) do + local position = positions[n] + local info = {} + info.text = debuffPositionLabels[position] + info.value = position + info.func = function() SetDebuffPosition(this.value) end + info.checked = (BNP:GetDebuffPosition() == position) + UIDropDownMenu_AddButton(info) + end + end) + frame.debuffPositionDropdown = debuffPositionDropdown + frame.SetDebuffPosition = SetDebuffPosition + + local debuffPositionLabel = aurasPage:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + debuffPositionLabel:SetPoint("BOTTOMLEFT", debuffPositionDropdown, "TOPLEFT", 18, 5) + debuffPositionLabel:SetText("Debuff Position") + frame.debuffPositionLabel = debuffPositionLabel + + local crowdControl = CreateCheck(aurasPage, "Crowd Control", -190, function() + BNP_DB.crowdControl = this:GetChecked() and true or false + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + end) + frame.crowdControlCheck = crowdControl + + local ccPositionDropdown = CreateFrame("Frame", "BNPCCPositionDropdown", aurasPage, "UIDropDownMenuTemplate") + ccPositionDropdown:SetPoint("TOPLEFT", aurasPage, "TOPLEFT", 188, -184) + UIDropDownMenu_SetWidth(92, ccPositionDropdown) + + local ccPositionLabels = { + top = "Top", + left = "Left", + right = "Right", + } + + local function SetCCPosition(position) + if not ccPositionLabels[position] then position = "top" end + BNP_DB.ccPosition = position + UIDropDownMenu_SetSelectedValue(ccPositionDropdown, position) + UIDropDownMenu_SetText(ccPositionLabels[position], ccPositionDropdown) + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + end + + UIDropDownMenu_Initialize(ccPositionDropdown, function() + local positions = { "top", "left", "right" } + local n + for n = 1, table.getn(positions) do + local position = positions[n] + local info = {} + info.text = ccPositionLabels[position] + info.value = position + info.func = function() SetCCPosition(this.value) end + info.checked = (BNP:GetCCPosition() == position) + UIDropDownMenu_AddButton(info) + end + end) + frame.ccPositionDropdown = ccPositionDropdown + frame.SetCCPosition = SetCCPosition + + local ccPositionLabel = aurasPage:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + ccPositionLabel:SetPoint("BOTTOMLEFT", ccPositionDropdown, "TOPLEFT", 18, 5) + ccPositionLabel:SetText("CC Position") + frame.ccPositionLabel = ccPositionLabel + + local separateCCRow = CreateCheck(aurasPage, "Display CCs in Separate Row", -230, function() + BNP_DB.separateCCRow = this:GetChecked() and true or false + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + end, 42) + frame.separateCCRowCheck = separateCCRow + + local showOtherCCs = CreateCheck(aurasPage, "Show CCs from Other Players", -262, function() + BNP_DB.showOtherCCs = this:GetChecked() and true or false + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + end, 42) + frame.showOtherCCsCheck = showOtherCCs + + -- TOTEMS TAB ------------------------------------------------------------- + local totemsPage = frame.pages.totems + CreateSection(totemsPage, "Totem Indicators", -4) + + local totemIndicators = CreateCheck(totemsPage, "Enable Totem Icons", -42, function() + BNP_DB.totemIndicators = this:GetChecked() and true or false + if BNP.RefreshTotemIndicators then BNP:RefreshTotemIndicators() end + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + end) + frame.totemIndicatorsCheck = totemIndicators + + local totemIcon = CreateSlider(totemsPage, "Totem Icon Size", 16, 36, 1, -92) + totemIcon:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.totemIconSize = value + getglobal(this:GetName() .. "Text"):SetText("Totem Icon Size: " .. value) + if BNP.RefreshTotemIndicators then BNP:RefreshTotemIndicators() end + end) + frame.totemIconSlider = totemIcon + + local totemNote = totemsPage:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + totemNote:SetPoint("TOPLEFT", totemsPage, "TOPLEFT", 28, -150) + totemNote:SetWidth(340) + totemNote:SetJustifyH("LEFT") + totemNote:SetText("Replaces all visible shaman totem nameplates with compact icons. There are no individual totem filters.") + + + -- CASTBAR TAB ------------------------------------------------------------ + local castbarPage = frame.pages.castbar + CreateSection(castbarPage, "Castbar", -4) + + local castbars = CreateCheck(castbarPage, "Castbars", -42, function() + local enabled = this:GetChecked() and true or false + if BNP.SetCastbarsEnabled then BNP:SetCastbarsEnabled(enabled) else BNP_DB.castbars = enabled end + end) + frame.castbarsCheck = castbars + + local castbarTest = CreateCheck(castbarPage, "Test Castbars", -42, function() + if BNP.SetCastbarTestMode then BNP:SetCastbarTestMode(this:GetChecked() and true or false) end + end, 164) + castbarTest:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Test Castbars", 1, 0.82, 0) + GameTooltip:AddLine("Shows a looping simulated castbar on every visible nameplate.", 1, 1, 1, true) + GameTooltip:AddLine("Use it to adjust height and spacing live. The test mode is not saved.", 0.8, 0.8, 0.8, true) + GameTooltip:Show() + end) + castbarTest:SetScript("OnLeave", function() GameTooltip:Hide() end) + frame.castbarTestCheck = castbarTest + + local castbarHeight = CreateSlider(castbarPage, "Castbar Height", 4, 14, 1, -94) + castbarHeight:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.castbarHeight = value + getglobal(this:GetName() .. "Text"):SetText("Castbar Height: " .. value) + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() elseif BNP.RefreshCastbarHeights then BNP:RefreshCastbarHeights() end + end) + frame.castbarHeightSlider = castbarHeight + + local castbarSpacing = CreateSlider(castbarPage, "Castbar Spacing", 0, 10, 1, -148) + castbarSpacing:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.castbarSpacing = value + getglobal(this:GetName() .. "Text"):SetText("Castbar Spacing: " .. value .. " px") + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end) + frame.castbarSpacingSlider = castbarSpacing + + -- TARGET TAB ------------------------------------------------------------- + local targetPage = frame.pages.target + CreateSection(targetPage, "Target", -4) + + local targetFocus = CreateCheck(targetPage, "Target Glow", -42, function() + BNP_DB.targetFocus = this:GetChecked() and true or false + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + end) + frame.targetFocusCheck = targetFocus + + local glowColorLabel = targetPage:CreateFontString(nil, "OVERLAY", "GameFontNormal") + glowColorLabel:SetPoint("TOPLEFT", targetPage, "TOPLEFT", 28, -88) + glowColorLabel:SetText("Glow Color") + frame.glowColorLabel = glowColorLabel + + local glowColorDropdown = CreateFrame("Frame", "BNPTargetGlowColorDropdown", targetPage, "UIDropDownMenuTemplate") + glowColorDropdown:SetPoint("TOPLEFT", targetPage, "TOPLEFT", 10, -100) + UIDropDownMenu_SetWidth(150, glowColorDropdown) + + local glowColorLabels = { + white = "White", + gold = "Gold", + blue = "Blue", + green = "Green", + red = "Red", + purple = "Purple", + } + + local function SetTargetGlowColor(color) + if not glowColorLabels[color] then color = "white" end + BNP_DB.targetGlowColor = color + UIDropDownMenu_SetSelectedValue(glowColorDropdown, color) + UIDropDownMenu_SetText(glowColorLabels[color], glowColorDropdown) + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + end + + UIDropDownMenu_Initialize(glowColorDropdown, function() + local colors = { "white", "gold", "blue", "green", "red", "purple" } + local n + for n = 1, table.getn(colors) do + local color = colors[n] + local info = {} + info.text = glowColorLabels[color] + info.value = color + info.func = function() SetTargetGlowColor(this.value) end + info.checked = ((BNP_DB and BNP_DB.targetGlowColor) or "white") == color + UIDropDownMenu_AddButton(info) + end + end) + frame.glowColorDropdown = glowColorDropdown + frame.SetTargetGlowColor = SetTargetGlowColor + + -- TOOLS TAB -------------------------------------------------------------- + local toolsPage = frame.pages.tools + CreateSection(toolsPage, "Tools", -4) + + local auraRecorder = CreateFrame("Button", nil, toolsPage, "UIPanelButtonTemplate") + auraRecorder:SetPoint("TOPLEFT", toolsPage, "TOPLEFT", 28, -48) + auraRecorder:SetWidth(254) + auraRecorder:SetHeight(24) + auraRecorder:SetText("Missing Spell / Aura...") + auraRecorder:SetScript("OnClick", function() BNP:OpenAuraRecorder() end) + auraRecorder:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Missing Spell / Aura Recorder", 1, 0.82, 0) + GameTooltip:AddLine("Records target aura IDs, SuperWoW events and refresh signals in one copyable report.", 1, 1, 1, true) + GameTooltip:Show() + end) + auraRecorder:SetScript("OnLeave", function() GameTooltip:Hide() end) + frame.auraRecorderButton = auraRecorder + + local note = toolsPage:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + note:SetPoint("TOPLEFT", toolsPage, "TOPLEFT", 28, -88) + note:SetWidth(330) + note:SetJustifyH("LEFT") + note:SetText("Diagnostic tools for testing missing spells and aura refresh behavior.") + + function frame:UpdateDependentControls() + local ccEnabled = BNP:AreCrowdControlEnabled() + local debuffPosition = BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top" + local ccPosition = BNP.GetCCPosition and BNP:GetCCPosition() or debuffPosition + local separateRowAvailable = ccEnabled and debuffPosition == "top" and ccPosition == "top" + + SetCheckEnabled(self.separateCCRowCheck, separateRowAvailable) + if self.separateCCRowCheck then + self.separateCCRowCheck:SetChecked(separateRowAvailable and BNP:IsSeparateCCRowEnabled() or false) + end + SetCheckEnabled(self.showOtherCCsCheck, ccEnabled) + + local totemsEnabled = BNP.AreTotemIndicatorsEnabled and BNP:AreTotemIndicatorsEnabled() or false + if self.totemIconSlider then + self.totemIconSlider:SetAlpha(totemsEnabled and 1.0 or 0.45) + if self.totemIconSlider.EnableMouse then self.totemIconSlider:EnableMouse(totemsEnabled) end + end + + local glowEnabled = BNP:IsTargetFocusEnabled() + if self.glowColorLabel then + self.glowColorLabel:SetTextColor(glowEnabled and 1 or 0.5, glowEnabled and 0.82 or 0.5, glowEnabled and 0 or 0.5) + end + if self.glowColorDropdown then + self.glowColorDropdown:SetAlpha(glowEnabled and 1.0 or 0.45) + if glowEnabled then + if UIDropDownMenu_EnableDropDown then UIDropDownMenu_EnableDropDown(self.glowColorDropdown) end + else + if UIDropDownMenu_DisableDropDown then UIDropDownMenu_DisableDropDown(self.glowColorDropdown) end + end + end + end + + frame:ShowTab("nameplates") + self.optionsFrame = frame +end + +function BNP:SyncOptions() + self:CreateOptions() + local frame = self.optionsFrame + frame.scaleSlider:SetValue(self:GetNameplateScale()) + if frame.yOffsetSlider then + local yOffset = self:GetNameplateYOffset() + frame.yOffsetSlider:SetValue(yOffset) + getglobal(frame.yOffsetSlider:GetName() .. "Text"):SetText("Nameplate Y Offset: +" .. yOffset) + end + if frame.nonTargetAlphaSlider then + local alphaPercent = math.floor((self:GetNonTargetAlpha() * 100) + 0.5) + frame.nonTargetAlphaSlider:SetValue(alphaPercent) + getglobal(frame.nonTargetAlphaSlider:GetName() .. "Text"):SetText("Non-Target Alpha: " .. alphaPercent .. "%") + end + frame.iconSlider:SetValue(self:GetIconSize()) + if frame.ccIconSlider then + local ccSize = self:GetCCIconSize() + frame.ccIconSlider:SetValue(ccSize) + getglobal(frame.ccIconSlider:GetName() .. "Text"):SetText("CC Icon Size: " .. ccSize) + end + if frame.totemIconSlider then + local totemSize = self:GetTotemIconSize() + frame.totemIconSlider:SetValue(totemSize) + getglobal(frame.totemIconSlider:GetName() .. "Text"):SetText("Totem Icon Size: " .. totemSize) + end + frame.castbarHeightSlider:SetValue(self:GetCastbarHeight()) + if frame.castbarSpacingSlider then + local spacing = self:GetCastbarSpacing() + frame.castbarSpacingSlider:SetValue(spacing) + getglobal(frame.castbarSpacingSlider:GetName() .. "Text"):SetText("Castbar Spacing: " .. spacing .. " px") + end + frame.classColorsCheck:SetChecked(self:AreClassColorsEnabled()) + if frame.debuffsCheck then frame.debuffsCheck:SetChecked(self:AreDebuffsEnabled()) end + if frame.debuffPositionDropdown then + local position = self:GetDebuffPosition() + local labels = { top = "Top", left = "Left", right = "Right" } + UIDropDownMenu_SetSelectedValue(frame.debuffPositionDropdown, position) + UIDropDownMenu_SetText(labels[position] or "Top", frame.debuffPositionDropdown) + end + if frame.ccPositionDropdown then + local position = self:GetCCPosition() + local labels = { top = "Top", left = "Left", right = "Right" } + UIDropDownMenu_SetSelectedValue(frame.ccPositionDropdown, position) + UIDropDownMenu_SetText(labels[position] or "Top", frame.ccPositionDropdown) + end + if frame.crowdControlCheck then frame.crowdControlCheck:SetChecked(self:AreCrowdControlEnabled()) end + if frame.separateCCRowCheck then frame.separateCCRowCheck:SetChecked(self:IsSeparateCCRowEnabled()) end + if frame.showOtherCCsCheck then frame.showOtherCCsCheck:SetChecked(self:ShowOtherPlayersCCs()) end + if frame.totemIndicatorsCheck then frame.totemIndicatorsCheck:SetChecked(self:AreTotemIndicatorsEnabled()) end + frame.castbarsCheck:SetChecked(self:AreCastbarsEnabled()) + if frame.castbarTestCheck then + frame.castbarTestCheck:SetChecked(self.IsCastbarTestMode and self:IsCastbarTestMode() or false) + end + frame.tankCheck:SetChecked(self:IsTankModeEnabled()) + if frame.comboPointsCheck and comboOptionsClass then + frame.comboPointsCheck:SetChecked(self:AreComboPointsEnabled()) + end + if frame.healthTextDropdown then + local mode = self:GetHealthTextMode() + UIDropDownMenu_SetSelectedValue(frame.healthTextDropdown, mode) + local labels = { off = "Off", percent = "Percent", hp = "HP", both = "HP + Percent" } + UIDropDownMenu_SetText(labels[mode] or "Off", frame.healthTextDropdown) + end + frame.targetFocusCheck:SetChecked(self:IsTargetFocusEnabled()) + + if frame.glowColorDropdown then + local _, _, _, color = self:GetTargetGlowColor() + local labels = { + white = "White", + gold = "Gold", + blue = "Blue", + green = "Green", + red = "Red", + purple = "Purple", + } + UIDropDownMenu_SetSelectedValue(frame.glowColorDropdown, color or "white") + UIDropDownMenu_SetText(labels[color] or "White", frame.glowColorDropdown) + end + + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + frame.versionText:SetText("Version " .. tostring(self.version or "?")) +end + +function BNP:ToggleOptions() + self:SyncOptions() + if self.optionsFrame:IsShown() then + self.optionsFrame:Hide() + else + self.optionsFrame:Show() + end +end + +function BNP:CreateMinimapButton() + if self.minimapButton then return end + + BNP_DB = BNP_DB or {} + if BNP_DB.minimapAngle == nil then BNP_DB.minimapAngle = 225 end + + local button = CreateFrame("Button", "BNPMinimapButton", Minimap) + button:SetWidth(31) + button:SetHeight(31) + button:SetFrameStrata("MEDIUM") + button:SetFrameLevel((Minimap:GetFrameLevel() or 1) + 8) + button:RegisterForClicks("LeftButtonUp") + button:RegisterForDrag("LeftButton") + button:EnableMouse(true) + button:SetMovable(true) + + -- Classic round minimap-button background. + local bg = button:CreateTexture(nil, "BACKGROUND") + bg:SetTexture("Interface\\Minimap\\UI-Minimap-Background") + bg:SetWidth(22) + bg:SetHeight(22) + bg:SetPoint("CENTER", button, "CENTER", 0, 0) + button.bg = bg + + -- Addon identity: simple BN+ lettering instead of a spell/item icon. + -- Rendered directly by the client, so no external texture file is needed. + local iconBG = button:CreateTexture(nil, "ARTWORK") + iconBG:SetTexture(0.03, 0.03, 0.03, 1) + iconBG:SetWidth(20) + iconBG:SetHeight(20) + iconBG:SetPoint("CENTER", button, "CENTER", 0, 0) + button.iconBG = iconBG + + local label = button:CreateFontString(nil, "OVERLAY") + label:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") + label:SetPoint("CENTER", button, "CENTER", 0, 0) + label:SetText("BN+") + label:SetTextColor(0.20, 0.60, 1.00) + button.label = label + + -- Standard Blizzard circular minimap border. + local border = button:CreateTexture(nil, "OVERLAY") + border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder") + border:SetWidth(54) + border:SetHeight(54) + border:SetPoint("TOPLEFT", button, "TOPLEFT", 0, 0) + button.border = border + + local highlight = button:CreateTexture(nil, "HIGHLIGHT") + highlight:SetTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight") + highlight:SetBlendMode("ADD") + highlight:SetAllPoints(button) + button.highlight = highlight + + local function UpdatePosition() + local angle = tonumber(BNP_DB.minimapAngle) or 225 + local radius = 78 + local rad = math.rad(angle) + + button:ClearAllPoints() + button:SetPoint( + "CENTER", + Minimap, + "CENTER", + math.cos(rad) * radius, + math.sin(rad) * radius + ) + end + + local function UpdateAngleFromCursor() + local mx, my = Minimap:GetCenter() + local scale = 1 + if UIParent.GetEffectiveScale then + scale = UIParent:GetEffectiveScale() or 1 + elseif UIParent.GetScale then + scale = UIParent:GetScale() or 1 + end + local cx, cy = GetCursorPosition() + cx = cx / scale + cy = cy / scale + + local dx = cx - mx + local dy = cy - my + + local angle + if math.atan2 then + angle = math.deg(math.atan2(dy, dx)) + else + if dx == 0 then + angle = dy >= 0 and 90 or -90 + else + angle = math.deg(math.atan(dy / dx)) + if dx < 0 then angle = angle + 180 end + end + end + BNP_DB.minimapAngle = angle + UpdatePosition() + end + + button:SetScript("OnDragStart", function() + this:SetScript("OnUpdate", UpdateAngleFromCursor) + end) + + button:SetScript("OnDragStop", function() + this:SetScript("OnUpdate", nil) + UpdateAngleFromCursor() + end) + + button:SetScript("OnClick", function() + BNP:ToggleOptions() + end) + + button:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_LEFT") + GameTooltip:SetText("Blizz Nameplates+") + GameTooltip:AddLine("Left-click: Open settings", 1, 1, 1) + GameTooltip:AddLine("Drag: Move minimap button", 0.8, 0.8, 0.8) + GameTooltip:Show() + end) + + button:SetScript("OnLeave", function() + GameTooltip:Hide() + end) + + UpdatePosition() + self.minimapButton = button +end diff --git a/core/ui.lua b/core/ui.lua new file mode 100644 index 0000000..57b6782 --- /dev/null +++ b/core/ui.lua @@ -0,0 +1,15 @@ +BNP = BNP or {} + +-- Central visual constants. Keeping these values in one place lets us polish +-- the Blizzard-style presentation without touching aura tracking logic. +BNP.UI = BNP.UI or { + ICON_SIZE = 18, + ICON_OFFSET_Y = 15, + ICON_SPACING = 2, + TIMER_FONT = "Fonts\\FRIZQT__.TTF", + TIMER_SIZE = 8, + TIMER_OFFSET_X = 0, + TIMER_OFFSET_Y = 0, + TIMER_SHADOW_X = 1, + TIMER_SHADOW_Y = -1, +} diff --git a/media/shagu_target_glow.tga b/media/shagu_target_glow.tga new file mode 100644 index 0000000..8c8e74c Binary files /dev/null and b/media/shagu_target_glow.tga differ diff --git a/modules/aurarecorder.lua b/modules/aurarecorder.lua new file mode 100644 index 0000000..4a9a1d9 --- /dev/null +++ b/modules/aurarecorder.lua @@ -0,0 +1,1155 @@ +BNP = BNP or {} + +-- Missing Spell / Aura Recorder -------------------------------------------- +-- +-- This diagnostic is deliberately dormant until the user presses New +-- Recording. While active it records only the selected target, player casts, +-- SuperWoW combat events and a throttled snapshot of the selected target's +-- negative auras. Closing or stopping the recorder unregisters every event and +-- removes its OnUpdate handler. + +local recorder = BNP.auraRecorder or {} +BNP.auraRecorder = recorder + +recorder.lines = recorder.lines or {} +recorder.registered = recorder.registered or {} +recorder.pendingSnapshots = recorder.pendingSnapshots or {} +recorder.active = false +recorder.dirty = false +recorder.limitHit = false +recorder.charCount = recorder.charCount or 0 + +local CAPTURE_CHAR_LIMIT = 48000 +local REPORT_CHAR_LIMIT = 62000 +local CAPTURE_LINE_LIMIT = 700 +local REPORT_LINE_LIMIT = 900 +local AURA_SCAN_INTERVAL = 0.15 +local EDIT_REFRESH_INTERVAL = 0.25 + +local EVENT_NAMES = { + "UNIT_CASTEVENT", + "RAW_COMBATLOG", + "SUPERWOW_COMBAT_LOG_EVENT", + "UNIT_AURA", + "PLAYER_TARGET_CHANGED", + "UNIT_SPELLCAST_SENT", + "UNIT_SPELLCAST_START", + "UNIT_SPELLCAST_STOP", + "UNIT_SPELLCAST_DELAYED", + "UNIT_SPELLCAST_SUCCEEDED", + "UNIT_SPELLCAST_INTERRUPTED", + "UNIT_SPELLCAST_FAILED", + "UNIT_SPELLCAST_FAILED_QUIET", + "UNIT_SPELLCAST_CHANNEL_START", + "UNIT_SPELLCAST_CHANNEL_UPDATE", + "UNIT_SPELLCAST_CHANNEL_STOP", + "UNIT_SPELLCAST_RETICLE_TARGET", + "UNIT_SPELLCAST_RETICLE_CLEAR", + "CHAT_MSG_COMBAT_SELF_HITS", + "CHAT_MSG_SPELL_SELF_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE", + "CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE", + "CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE", + "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE", + "CHAT_MSG_SPELL_BREAK_AURA", + "CHAT_MSG_SPELL_AURA_GONE_OTHER", + "CHAT_MSG_SPELL_AURA_GONE_SELF", +} + +local SELF_COMBAT_EVENTS = { + CHAT_MSG_COMBAT_SELF_HITS = true, + CHAT_MSG_SPELL_SELF_DAMAGE = true, +} + +local CLASSICAPI_SPELLCAST_EVENTS = { + UNIT_SPELLCAST_SENT = true, + UNIT_SPELLCAST_START = true, + UNIT_SPELLCAST_STOP = true, + UNIT_SPELLCAST_DELAYED = true, + UNIT_SPELLCAST_SUCCEEDED = true, + UNIT_SPELLCAST_INTERRUPTED = true, + UNIT_SPELLCAST_FAILED = true, + UNIT_SPELLCAST_FAILED_QUIET = true, + UNIT_SPELLCAST_CHANNEL_START = true, + UNIT_SPELLCAST_CHANNEL_UPDATE = true, + UNIT_SPELLCAST_CHANNEL_STOP = true, + UNIT_SPELLCAST_RETICLE_TARGET = true, + UNIT_SPELLCAST_RETICLE_CLEAR = true, +} + +local SHADOW_VULNERABILITY_IDS = { + [17794] = true, + [17797] = true, + [17798] = true, + [17799] = true, + [17800] = true, +} + +local SHADOW_BOLT_IDS = { + [686] = true, [695] = true, [705] = true, [1088] = true, + [1106] = true, [7641] = true, [11659] = true, [11660] = true, + [11661] = true, [25307] = true, +} + +local function CleanValue(value, maxLength) + if value == nil then return "nil" end + local text = tostring(value) + text = string.gsub(text, "\r", "\\r") + text = string.gsub(text, "\n", "\\n") + text = string.gsub(text, "\t", "\\t") + maxLength = maxLength or 260 + if string.len(text) > maxLength then + text = string.sub(text, 1, maxLength) .. "..." + end + return text +end + +local function GetUnitGUID(unit) + if not unit then return nil end + local ok, exists, guid = pcall(UnitExists, unit) + if ok and exists and guid then return guid end + if UnitGUID then + local guidOK, classicGUID = pcall(UnitGUID, unit) + if guidOK and classicGUID then return classicGUID end + end + return nil +end + +local function SpellLabel(spellID) + if not spellID then return "nil/?" end + if SpellInfo then + local ok, name, rank = pcall(SpellInfo, spellID) + if ok and name then + local label = tostring(spellID) .. "/" .. CleanValue(name, 100) + if rank and rank ~= "" then label = label .. " " .. CleanValue(rank, 60) end + return label + end + end + if GetSpellInfo then + local ok, name, rank = pcall(GetSpellInfo, spellID) + if ok and name then + local label = tostring(spellID) .. "/" .. CleanValue(name, 100) + if rank and rank ~= "" then label = label .. " " .. CleanValue(rank, 60) end + return label + end + end + return tostring(spellID) .. "/?" +end + +local function RelativeTime() + if not recorder.startedAt then return "+0.000" end + return string.format("+%.3f", GetTime() - recorder.startedAt) +end + +local function ClassicAPIVersionLabel() + if CLASSIC_API_VERSION == nil then return "not detected" end + local numeric = tonumber(CLASSIC_API_VERSION) + if not numeric then return CleanValue(CLASSIC_API_VERSION) end + local major = math.floor(numeric / 10000) + local minor = math.floor((numeric - (major * 10000)) / 100) + local patch = numeric - (major * 10000) - (minor * 100) + return tostring(numeric) .. " (" .. tostring(major) .. "." .. tostring(minor) .. "." .. tostring(patch) .. ")" +end + +local function AppendLine(text, important) + text = CleanValue(text, 1400) + local charLimit = important and REPORT_CHAR_LIMIT or CAPTURE_CHAR_LIMIT + local lineLimit = important and REPORT_LINE_LIMIT or CAPTURE_LINE_LIMIT + local nextChars = (recorder.charCount or 0) + string.len(text) + 1 + + if nextChars > charLimit or table.getn(recorder.lines) >= lineLimit then + if not recorder.limitHit then + local marker = "[CAPTURE LIMIT REACHED - recording stopped before the report became too large]" + table.insert(recorder.lines, marker) + recorder.charCount = (recorder.charCount or 0) + string.len(marker) + 1 + recorder.limitHit = true + recorder.stopRequested = true + recorder.dirty = true + end + return false + end + + table.insert(recorder.lines, text) + recorder.charCount = nextChars + recorder.dirty = true + return true +end + +local function AppendTimed(kind, text) + return AppendLine("[" .. RelativeTime() .. "] " .. tostring(kind) .. " | " .. tostring(text or ""), false) +end + +local function ShallowFields(data) + if type(data) ~= "table" then return CleanValue(data) end + local fields = {} + local key, value + for key, value in pairs(data) do + local valueType = type(value) + if valueType ~= "table" and valueType ~= "function" and valueType ~= "userdata" then + table.insert(fields, CleanValue(key, 80) .. "=" .. CleanValue(value, 220)) + end + end + table.sort(fields) + if table.getn(fields) == 0 then return "(no scalar fields)" end + return table.concat(fields, " | ") +end + +local function IsRelatedText(raw) + if not raw or raw == "" then return false end + raw = tostring(raw) + + if recorder.traceGUID and string.find(raw, tostring(recorder.traceGUID), 1, true) then return true end + if recorder.playerGUID and string.find(raw, tostring(recorder.playerGUID), 1, true) then return true end + if recorder.traceName and recorder.traceName ~= "" and string.find(raw, recorder.traceName, 1, true) then return true end + if recorder.playerName and recorder.playerName ~= "" and string.find(raw, recorder.playerName, 1, true) then return true end + return false +end + +local function CurrentSnapshotUnit() + local targetGUID = GetUnitGUID("target") + if recorder.traceGUID and targetGUID == recorder.traceGUID then return "target" end + if recorder.traceGUID and UnitTokenFromGUID then + local ok, token = pcall(UnitTokenFromGUID, recorder.traceGUID) + if ok and token then return token end + end + if recorder.traceGUID then return recorder.traceGUID end + if UnitName("target") then return "target" end + return nil +end + +local function QueueSnapshot(delay, reason, force) + local pending = recorder.pendingSnapshots + if table.getn(pending) >= 18 then return end + table.insert(pending, { + at = GetTime() + (delay or 0), + reason = reason or "event", + force = force and true or false, + }) +end + +local function HasClassicAuraAPI() + return type(C_UnitAuras) == "table" and type(C_UnitAuras.GetDebuffDataByIndex) == "function" +end + +local function IsShadowVulnerability(spellID, spellName) + local numeric = tonumber(spellID) + if SHADOW_VULNERABILITY_IDS[numeric] then return true end + local name = string.lower(tostring(spellName or "")) + return name == "shadow vulnerability" or name == "schattenverwundbarkeit" +end + +local function IsShadowBolt(spellID, spellName) + local numeric = tonumber(spellID) + if SHADOW_BOLT_IDS[numeric] then return true end + local name = spellName + if (not name or name == "") and numeric and SpellInfo then + local ok, resolved = pcall(SpellInfo, numeric) + if ok then name = resolved end + end + name = string.lower(tostring(name or "")) + return name == "shadow bolt" or name == "schattenblitz" +end + +local function ShouldCaptureClassicAura(aura) + if type(aura) ~= "table" then return true end + local spellID = tonumber(aura.spellId or aura.spellID) + if IsShadowVulnerability(spellID, aura.name) then return true end + + if aura.sourceGUID and recorder.playerGUID then return aura.sourceGUID == recorder.playerGUID end + if aura.sourceUnit then return aura.sourceUnit == "player" end + -- ClassicAPI may omit source data for foreign auras. Unknown ownership is not + -- enough to include a normal aura; Shadow Vulnerability was allowed above. + return false +end + +local function SnapshotClassicAPIAuras(unit, reason, force) + if not HasClassicAuraAPI() then return end + + local signatureParts = {} + local auraLines = {} + local count = 0 + local shadowOnly = force and string.find(tostring(reason or ""), "Shadow", 1, true) + local i + for i = 1, 64 do + local ok, aura = pcall(C_UnitAuras.GetDebuffDataByIndex, unit, i) + if not ok then + if force then + AppendTimed( + "CLASSICAPI_AURA_SNAPSHOT", + "reason=" .. CleanValue(reason) .. " | unit=" .. CleanValue(unit) .. + " | error=" .. CleanValue(aura) + ) + end + return + end + if not aura then break end + + if ShouldCaptureClassicAura(aura) and (not shadowOnly or IsShadowVulnerability(aura.spellId or aura.spellID, aura.name)) then + count = count + 1 + local fields = ShallowFields(aura) + table.insert(signatureParts, fields) + + local remaining = "unknown" + local expiration = tonumber(aura.expirationTime) + if expiration and expiration > 0 then + remaining = string.format("%.3f", expiration - GetTime()) + end + table.insert( + auraLines, + " CLASSIC_AURA " .. tostring(i) .. + " | remaining=" .. remaining .. + " | " .. fields + ) + end + end + + local signature = table.concat(signatureParts, "||") + local snapshotKey = (shadowOnly and "classic-shadow:" or "classic:") .. tostring(recorder.traceGUID or unit) + local previous = recorder.lastAuraSignatures and recorder.lastAuraSignatures[snapshotKey] + local changed = previous ~= signature + + recorder.lastAuraSignatures = recorder.lastAuraSignatures or {} + recorder.lastAuraSignatures[snapshotKey] = signature + if recorder.baselineOnly then return end + if not changed and not force then return end + + AppendTimed( + "CLASSICAPI_AURA_SNAPSHOT", + "reason=" .. CleanValue(reason) .. + " | unit=" .. CleanValue(unit) .. + " | guid=" .. CleanValue(recorder.traceGUID) .. + " | count=" .. tostring(count) .. + " | changed=" .. (changed and "1" or "0") + ) + if count == 0 then + AppendLine(" CLASSIC_AURA none", false) + else + for i = 1, table.getn(auraLines) do AppendLine(auraLines[i], false) end + end +end + +local function SnapshotAuras(reason, force) + local unit = CurrentSnapshotUnit() + if not unit then + if force then AppendTimed("AURA_SNAPSHOT", "reason=" .. CleanValue(reason) .. " | no target unit") end + return + end + + -- ClassicAPI exposes a richer AuraData table including spellId, duration, + -- expirationTime and (where observed) sourceUnit/sourceGUID. Keep this + -- snapshot independent from legacy UnitDebuff so a duration-only refresh is + -- visible even when the vanilla aura slots themselves do not change. + SnapshotClassicAPIAuras(unit, reason, force) + if HasClassicAuraAPI() then return end + + local signatureParts = {} + local auraLines = {} + local count = 0 + local i + for i = 1, 64 do + local ok, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = pcall(UnitDebuff, unit, i) + if not ok then + if force then + AppendTimed("AURA_SNAPSHOT", "reason=" .. CleanValue(reason) .. " | UnitDebuff error=" .. CleanValue(a1)) + end + return + end + if not a1 then break end + + count = count + 1 + local spellName = "?" + local spellRank = nil + if a4 and SpellInfo then + local spellOK, name, rank = pcall(SpellInfo, a4) + if spellOK and name then spellName = name end + if spellOK then spellRank = rank end + end + + table.insert(signatureParts, + CleanValue(a1) .. ":" .. CleanValue(a2) .. ":" .. CleanValue(a3) .. ":" .. + CleanValue(a4) .. ":" .. CleanValue(a5) .. ":" .. CleanValue(a6) .. ":" .. + CleanValue(a7) .. ":" .. CleanValue(a8) .. ":" .. CleanValue(a9) .. ":" .. CleanValue(a10) + ) + + table.insert(auraLines, + " AURA " .. tostring(i) .. + " | id=" .. CleanValue(a4) .. + " | name=" .. CleanValue(spellName, 120) .. + " | rank=" .. CleanValue(spellRank, 80) .. + " | stacks=" .. CleanValue(a2) .. + " | type=" .. CleanValue(a3) .. + " | texture=" .. CleanValue(a1, 180) .. + " | r5=" .. CleanValue(a5) .. + " | r6=" .. CleanValue(a6) .. + " | r7=" .. CleanValue(a7) .. + " | r8=" .. CleanValue(a8) .. + " | r9=" .. CleanValue(a9) .. + " | r10=" .. CleanValue(a10) + ) + end + + local signature = table.concat(signatureParts, "||") + local snapshotKey = tostring(recorder.traceGUID or unit) + local previous = recorder.lastAuraSignatures and recorder.lastAuraSignatures[snapshotKey] + local changed = previous ~= signature + + recorder.lastAuraSignatures = recorder.lastAuraSignatures or {} + recorder.lastAuraSignatures[snapshotKey] = signature + + if recorder.baselineOnly then return end + + if not changed and not force then return end + + AppendTimed( + "AURA_SNAPSHOT", + "reason=" .. CleanValue(reason) .. + " | unit=" .. CleanValue(unit) .. + " | guid=" .. CleanValue(recorder.traceGUID) .. + " | count=" .. tostring(count) .. + " | changed=" .. (changed and "1" or "0") + ) + + if count == 0 then + AppendLine(" AURA none", false) + else + for i = 1, table.getn(auraLines) do AppendLine(auraLines[i], false) end + end +end + +local function UpdateTarget(reason) + local exists, guid = UnitExists("target") + local name = UnitName("target") + if exists and guid then + local changed = guid ~= recorder.traceGUID + recorder.traceGUID = guid + recorder.traceName = name or "?" + if changed then + AppendTimed( + "TARGET", + "reason=" .. CleanValue(reason) .. " | name=" .. CleanValue(recorder.traceName) .. " | guid=" .. CleanValue(guid) + ) + QueueSnapshot(0.03, "target changed", false) + end + elseif not recorder.traceGUID then + AppendTimed("TARGET", "reason=" .. CleanValue(reason) .. " | no target / no SuperWoW GUID") + else + AppendTimed("TARGET", "reason=" .. CleanValue(reason) .. " | target cleared; keeping guid=" .. CleanValue(recorder.traceGUID)) + end +end + +local function IsRelevantCast(casterGUID, targetGUID) + if recorder.playerGUID and casterGUID == recorder.playerGUID then return true end + return false +end + +local function HandleCastEvent() + local casterGUID = arg1 + local targetGUID = arg2 + local eventType = arg3 + local spellID = arg4 + local duration = arg5 + if not IsRelevantCast(casterGUID, targetGUID) then return end + + AppendTimed( + "UNIT_CASTEVENT", + "type=" .. CleanValue(eventType) .. + " | spell=" .. SpellLabel(spellID) .. + " | caster=" .. CleanValue(casterGUID) .. + " | target=" .. CleanValue(targetGUID) .. + " | castDuration=" .. CleanValue(duration) + ) + + if IsShadowBolt(spellID) then + QueueSnapshot(0.20, "200ms after Shadow Bolt UNIT_CASTEVENT", true) + end +end + +local function HandleRawCombatLog() + local originalEvent = arg1 + local raw = arg2 + if not IsRelatedText(raw) then return end + local lowerRaw = string.lower(tostring(raw or "")) + local ownSignal = false + if recorder.playerGUID and string.find(lowerRaw, string.lower(tostring(recorder.playerGUID)), 1, true) then ownSignal = true end + if recorder.playerName and string.find(lowerRaw, string.lower(tostring(recorder.playerName)), 1, true) then ownSignal = true end + local sharedSignal = string.find(lowerRaw, "shadow vulnerability", 1, true) or + string.find(lowerRaw, "schattenverwundbarkeit", 1, true) or + string.find(lowerRaw, "17794", 1, true) or string.find(lowerRaw, "17797", 1, true) or + string.find(lowerRaw, "17798", 1, true) or string.find(lowerRaw, "17799", 1, true) or + string.find(lowerRaw, "17800", 1, true) + if not ownSignal and not sharedSignal then return end + AppendTimed("RAW_COMBATLOG", "event=" .. CleanValue(originalEvent) .. " | raw=" .. CleanValue(raw, 1100)) + + if sharedSignal then + QueueSnapshot(0.04, "after Shadow Vulnerability RAW_COMBATLOG", true) + QueueSnapshot(0.30, "300ms after Shadow Vulnerability RAW_COMBATLOG", true) + end +end + +local function HandleStructuredCombatLog() + local timestamp = arg1 + local eventType = arg2 + local sourceGUID = arg3 + local sourceName = arg4 + local sourceFlags = arg5 + local destGUID = arg6 + local destName = arg7 + local destFlags = arg8 + local spellID = arg9 + local spellName = arg10 + local spellSchool = arg11 + + local relevant = recorder.playerGUID and sourceGUID == recorder.playerGUID + if recorder.traceGUID and destGUID == recorder.traceGUID and IsShadowVulnerability(spellID, spellName) then relevant = true end + if not relevant then return end + + AppendTimed( + "SUPERWOW_COMBAT_LOG_EVENT", + "timestamp=" .. CleanValue(timestamp) .. + " | event=" .. CleanValue(eventType) .. + " | source=" .. CleanValue(sourceGUID) .. "/" .. CleanValue(sourceName, 100) .. "/" .. CleanValue(sourceFlags) .. + " | dest=" .. CleanValue(destGUID) .. "/" .. CleanValue(destName, 100) .. "/" .. CleanValue(destFlags) .. + " | spell=" .. CleanValue(spellID) .. "/" .. CleanValue(spellName, 120) .. + " | school=" .. CleanValue(spellSchool) .. + " | p12=" .. CleanValue(arg12) .. " | p13=" .. CleanValue(arg13) .. + " | p14=" .. CleanValue(arg14) .. " | p15=" .. CleanValue(arg15) .. + " | p16=" .. CleanValue(arg16) .. " | p17=" .. CleanValue(arg17) .. + " | p18=" .. CleanValue(arg18) .. " | p19=" .. CleanValue(arg19) .. + " | p20=" .. CleanValue(arg20) + ) + + if IsShadowVulnerability(spellID, spellName) then + QueueSnapshot(0.03, "after structured Shadow Vulnerability", true) + QueueSnapshot(0.25, "250ms after structured Shadow Vulnerability", true) + end +end + +local function HandleUnitAura() + local unit = arg1 + if unit ~= "target" then return end + local guid = GetUnitGUID(unit) + AppendTimed("UNIT_AURA", "unit=" .. CleanValue(unit) .. " | guid=" .. CleanValue(guid)) + QueueSnapshot(0.02, "after UNIT_AURA", false) +end + +local function HandleClassicAPISpellcast(eventName) + local unit = arg1 + local targetName = nil + local castGUID, spellID, spellName, rank + + if eventName == "UNIT_SPELLCAST_SENT" then + targetName = arg2 + castGUID = arg3 + spellID = arg4 + spellName = arg5 + rank = arg6 + else + castGUID = arg2 + spellID = arg3 + spellName = arg4 + rank = arg5 + end + + local unitGUID = GetUnitGUID(unit) + local relevant = unit == "player" + if recorder.traceGUID and (unitGUID == recorder.traceGUID or unit == recorder.traceGUID) then relevant = true end + if targetName and recorder.traceName and targetName == recorder.traceName then relevant = true end + if not relevant then return end + + AppendTimed( + "CLASSICAPI_" .. tostring(eventName), + "unit=" .. CleanValue(unit) .. + " | unitGUID=" .. CleanValue(unitGUID) .. + " | castGUID=" .. CleanValue(castGUID) .. + " | spell=" .. CleanValue(spellID) .. "/" .. CleanValue(spellName, 120) .. + " | rank=" .. CleanValue(rank, 80) .. + " | targetName=" .. CleanValue(targetName, 120) + ) + + -- UNIT_CASTEVENT supplies the single post-cast control snapshot. Keeping the + -- modern spellcast events as text avoids three duplicate aura dumps per cast. +end + +local function HandleCombatText(eventName) + local raw = arg1 + if not SELF_COMBAT_EVENTS[eventName] then + if not IsRelatedText(raw) then return end + local lower = string.lower(tostring(raw or "")) + local ownSignal = string.find(lower, "your ", 1, true) + if recorder.playerName and string.find(lower, string.lower(tostring(recorder.playerName)), 1, true) then ownSignal = true end + local sharedSignal = string.find(lower, "shadow vulnerability", 1, true) or + string.find(lower, "schattenverwundbarkeit", 1, true) + if not ownSignal and not sharedSignal then return end + end + AppendTimed( + eventName, + "a1=" .. CleanValue(raw, 1050) .. + " | a2=" .. CleanValue(arg2, 160) .. + " | a3=" .. CleanValue(arg3, 160) .. + " | a4=" .. CleanValue(arg4, 160) + ) +end + +local eventFrame = recorder.eventFrame or CreateFrame("Frame", "BNPAuraRecorderEventFrame") +recorder.eventFrame = eventFrame +eventFrame:SetScript("OnEvent", function() + if not recorder.active then return end + local eventName = event + + if eventName == "UNIT_CASTEVENT" then + HandleCastEvent() + elseif eventName == "RAW_COMBATLOG" then + HandleRawCombatLog() + elseif eventName == "SUPERWOW_COMBAT_LOG_EVENT" then + HandleStructuredCombatLog() + elseif eventName == "UNIT_AURA" then + HandleUnitAura() + elseif eventName == "PLAYER_TARGET_CHANGED" then + UpdateTarget("PLAYER_TARGET_CHANGED") + elseif CLASSICAPI_SPELLCAST_EVENTS[eventName] then + HandleClassicAPISpellcast(eventName) + else + HandleCombatText(eventName) + end +end) + +local function SafeRegister(eventName) + if type(C_EventUtils) == "table" and type(C_EventUtils.IsEventValid) == "function" then + local validOK, valid = pcall(C_EventUtils.IsEventValid, eventName) + if validOK and not valid then return false end + end + local ok = pcall(eventFrame.RegisterEvent, eventFrame, eventName) + if ok then recorder.registered[eventName] = true end + return ok +end + +local function UnregisterRecorderEvents() + pcall(eventFrame.UnregisterAllEvents, eventFrame) + recorder.registered = {} +end + +local function StatusText() + local target = tostring(recorder.traceName or "no target") + local guid = tostring(recorder.traceGUID or "no GUID") + local lineCount = table.getn(recorder.lines) + if recorder.copyNotice then return recorder.copyNotice end + if recorder.active then + return "RECORDING | Target: " .. target .. " | " .. guid .. " | " .. tostring(lineCount) .. " lines" + end + if recorder.limitHit then + return "STOPPED (size limit) | " .. tostring(lineCount) .. " lines | Copy the report" + end + return "STOPPED | Target: " .. target .. " | " .. tostring(lineCount) .. " lines | Copy the report" +end + +local function RefreshRecorderText(force) + if not recorder.frame or not recorder.editBox then return end + if not force and not recorder.dirty then return end + + local report = table.concat(recorder.lines, "\n") + recorder.editBox:SetText(report) + + local visualLines = 0 + local i + for i = 1, table.getn(recorder.lines) do + local length = string.len(recorder.lines[i] or "") + visualLines = visualLines + math.max(1, math.ceil(length / 96)) + end + recorder.editBox:SetHeight(math.max(330, (visualLines * 13) + 24)) + if recorder.scrollFrame and recorder.scrollFrame.UpdateScrollChildRect then + recorder.scrollFrame:UpdateScrollChildRect() + end + + if recorder.status then + recorder.status:SetText(StatusText()) + if recorder.copyNotice then + recorder.status:SetTextColor(0.2, 1, 0.35) + elseif recorder.active then + recorder.status:SetTextColor(0.2, 1, 0.35) + elseif recorder.limitHit then + recorder.status:SetTextColor(1, 0.35, 0.2) + else + recorder.status:SetTextColor(1, 0.82, 0) + end + end + + if recorder.stopButton then + if recorder.active then recorder.stopButton:Enable() else recorder.stopButton:Disable() end + end + recorder.dirty = false +end + +local function DumpScalarTable(label, data, important) + if type(data) ~= "table" then + AppendLine(label .. " | " .. CleanValue(data), important) + return + end + AppendLine(label .. " | " .. ShallowFields(data), important) +end + +local function DumpInternalDiagnostics() + AppendLine("", true) + AppendLine("=== INTERNAL BNP DIAGNOSTICS ===", true) + AppendLine("Target | name=" .. CleanValue(recorder.traceName) .. " | guid=" .. CleanValue(recorder.traceGUID), true) + + local guid = recorder.traceGUID + local cache = guid and BNP.guidAuras and BNP.guidAuras[guid] + AppendLine("--- GUID AURA CACHE ---", true) + if not cache then + AppendLine("CACHE | empty", true) + else + local cacheKey, aura + local cacheCount = 0 + for cacheKey, aura in pairs(cache) do + if type(aura) == "table" then + cacheCount = cacheCount + 1 + AppendLine("CACHE | key=" .. CleanValue(cacheKey) .. " | " .. ShallowFields(aura), true) + end + end + if cacheCount == 0 then AppendLine("CACHE | no aura tables | " .. ShallowFields(cache), true) end + end + + AppendLine("--- SHADOW VULNERABILITY STATE ---", true) + local svState = guid and BNP.svStateByGUID and BNP.svStateByGUID[guid] + if svState then DumpScalarTable("SV_STATE", svState, true) else AppendLine("SV_STATE | none", true) end + + local rendered = false + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() and plate.GetName and plate:GetName(1) == guid then + local container = plate.BNPAuraContainer + if container and container.icons then + local iconIndex + for iconIndex = 1, table.getn(container.icons) do + local icon = container.icons[iconIndex] + if icon and icon.BNPSVRenderedExpires then + AppendLine( + "SV_RENDER | icon=" .. tostring(iconIndex) .. + " | timerText=" .. CleanValue(icon.timer and icon.timer:GetText()) .. + " | renderedExpires=" .. CleanValue(icon.BNPSVRenderedExpires) .. + " | revision=" .. CleanValue(icon.BNPSVRenderRevision), + true + ) + rendered = true + end + end + end + end + end + if not rendered then AppendLine("SV_RENDER | no visible SV icon", true) end + + AppendLine("--- PENDING AURAS ---", true) + local pending = guid and BNP.pendingAuras and BNP.pendingAuras[guid] + if not pending then + AppendLine("PENDING | none", true) + else + local pendingKey, pendingAura + for pendingKey, pendingAura in pairs(pending) do + AppendLine("PENDING | key=" .. CleanValue(pendingKey) .. " | " .. ShallowFields(pendingAura), true) + end + end + + AppendLine("--- LAST TRACKED AURA TRANSITIONS ---", true) + local trace = BNP.raidTrace or {} + local start = math.max(1, table.getn(trace) - 29) + local i + if table.getn(trace) == 0 then AppendLine("RAID_TRACE | none", true) end + for i = start, table.getn(trace) do + local traceEntry = trace[i] + AppendLine("RAID_TRACE " .. tostring(i) .. " | " .. ShallowFields(traceEntry), true) + end + + AppendLine("--- UNKNOWN SPELL / AURA COLLECTOR ---", true) + local unknown = BNP.unknownBeta + if not unknown or table.getn(unknown.order or {}) == 0 then + AppendLine("UNKNOWN | no indexed entries", true) + else + for i = 1, table.getn(unknown.order) do + local unknownKey = unknown.order[i] + local unknownEntry = unknown.entries and unknown.entries[unknownKey] + if unknownEntry then + AppendLine("UNKNOWN | key=" .. CleanValue(unknownKey) .. " | " .. ShallowFields(unknownEntry), true) + end + end + end + if unknown and unknown.combat then + local effectKey, effect + for effectKey, effect in pairs(unknown.combat) do + AppendLine("UNKNOWN_EFFECT | key=" .. CleanValue(effectKey, 220) .. " | " .. ShallowFields(effect), true) + end + end + + AppendLine("--- DIRECT PLAYER CAST EVENTS ---", true) + local directCount = 0 + local directKey, directEntry + for directKey, directEntry in pairs(BNP.unknownDirectEvents or {}) do + directCount = directCount + 1 + AppendLine("DIRECT | key=" .. CleanValue(directKey) .. " | " .. ShallowFields(directEntry), true) + end + if directCount == 0 then AppendLine("DIRECT | none", true) end + + AppendLine("--- SPELLDB SHADOW AUDIT ---", true) + local spellDB = BNP.SpellDBShadow + if not spellDB then + AppendLine("SPELLDB | unavailable", true) + else + DumpScalarTable("SPELLDB_STATS", spellDB.stats, true) + local recent = spellDB.recent or {} + for i = 1, table.getn(recent) do + AppendLine("SPELLDB_RECENT " .. tostring(i) .. " | " .. ShallowFields(recent[i]), true) + end + end + + AppendLine("--- SHADOW VULNERABILITY PROBE ---", true) + local probe = BNP.svProbe + if not probe then + AppendLine("SV_PROBE | unavailable", true) + else + if probe.last then DumpScalarTable("SV_PROBE_LAST", probe.last, true) else AppendLine("SV_PROBE_LAST | none", true) end + local probeEvents = probe.events or {} + for i = 1, table.getn(probeEvents) do + AppendLine("SV_PROBE_EVENT " .. tostring(i) .. " | " .. ShallowFields(probeEvents[i]), true) + end + local rawEvents = probe.raw or {} + for i = 1, table.getn(rawEvents) do + AppendLine("SV_PROBE_RAW " .. tostring(i) .. " | " .. ShallowFields(rawEvents[i]), true) + end + end +end + +local updateFrame = recorder.updateFrame or CreateFrame("Frame", "BNPAuraRecorderUpdateFrame") +recorder.updateFrame = updateFrame + +local function RecorderOnUpdate() + recorder.scanElapsed = (recorder.scanElapsed or 0) + arg1 + recorder.editElapsed = (recorder.editElapsed or 0) + arg1 + + if recorder.scanElapsed >= AURA_SCAN_INTERVAL then + recorder.scanElapsed = 0 + SnapshotAuras("poll change", false) + + local now = GetTime() + local i = 1 + while i <= table.getn(recorder.pendingSnapshots) do + local pending = recorder.pendingSnapshots[i] + if pending and now >= (pending.at or 0) then + table.remove(recorder.pendingSnapshots, i) + SnapshotAuras(pending.reason, pending.force) + else + i = i + 1 + end + end + end + + if recorder.editElapsed >= EDIT_REFRESH_INTERVAL then + recorder.editElapsed = 0 + RefreshRecorderText(false) + end + + if recorder.stopRequested then + BNP:StopAuraRecorder("capture limit") + end +end + +function BNP:StartAuraRecorder() + -- New Recording is also a clean restart when a capture is already running. + if recorder.active then + recorder.active = false + UnregisterRecorderEvents() + updateFrame:SetScript("OnUpdate", nil) + end + + UnregisterRecorderEvents() + recorder.lines = {} + recorder.charCount = 0 + recorder.limitHit = false + recorder.stopRequested = false + recorder.copyNotice = nil + recorder.pendingSnapshots = {} + recorder.lastAuraSignatures = {} + recorder.startedAt = GetTime() + recorder.stoppedAt = nil + recorder.scanElapsed = 0 + recorder.editElapsed = 0 + recorder.playerGUID = GetUnitGUID("player") + recorder.playerName = UnitName("player") or "?" + recorder.traceGUID = nil + recorder.traceName = nil + recorder.active = true + + local registeredCount = 0 + local i + for i = 1, table.getn(EVENT_NAMES) do + if SafeRegister(EVENT_NAMES[i]) then registeredCount = registeredCount + 1 end + end + local classicSpellcastCount = 0 + local classicEventName + for classicEventName in pairs(CLASSICAPI_SPELLCAST_EVENTS) do + if recorder.registered[classicEventName] then classicSpellcastCount = classicSpellcastCount + 1 end + end + + AppendLine("BNP_MISSING_SPELL_AURA_REPORT v1", true) + AppendLine("Addon | version=" .. CleanValue(BNP.version), true) + AppendLine( + "SuperWoW | string=" .. CleanValue(SUPERWOW_STRING) .. + " | version=" .. CleanValue(SUPERWOW_VERSION) .. + " | CombatLogAdd=" .. (CombatLogAdd and "yes" or "no") .. + " | SpellInfo=" .. (SpellInfo and "yes" or "no"), + true + ) + AppendLine( + "ClassicAPI | version=" .. ClassicAPIVersionLabel() .. + " | C_UnitAuras=" .. (HasClassicAuraAPI() and "yes" or "no") .. + " | UnitGUID=" .. (UnitGUID and "yes" or "no") .. + " | UnitTokenFromGUID=" .. (UnitTokenFromGUID and "yes" or "no") .. + " | CopyToClipboard=" .. (CopyToClipboard and "yes" or "no") .. + " | spellcastEvents=" .. tostring(classicSpellcastCount), + true + ) + if GetBuildInfo then + local version, build, buildDate, interfaceVersion = GetBuildInfo() + AppendLine( + "Client | version=" .. CleanValue(version) .. " | build=" .. CleanValue(build) .. + " | date=" .. CleanValue(buildDate) .. " | interface=" .. CleanValue(interfaceVersion) .. + " | locale=" .. CleanValue(GetLocale and GetLocale()), + true + ) + end + local _, class = UnitClass("player") + AppendLine( + "Player | name=" .. CleanValue(recorder.playerName) .. " | class=" .. CleanValue(class) .. + " | guid=" .. CleanValue(recorder.playerGUID), + true + ) + AppendLine( + "Sources | registered=" .. tostring(registeredCount) .. "/" .. tostring(table.getn(EVENT_NAMES)) .. + " | RAW_COMBATLOG=" .. (recorder.registered.RAW_COMBATLOG and "yes" or "no") .. + " | STRUCTURED=" .. (recorder.registered.SUPERWOW_COMBAT_LOG_EVENT and "yes" or "no") .. + " | CLASSICAPI_AURAS=" .. (HasClassicAuraAPI() and "yes" or "no"), + true + ) + AppendLine("Aura filter | own auras + Shadow Vulnerability from any Warlock", true) + AppendLine("Test | apply aura, refresh while active, then let it expire or remove it", true) + AppendLine("=== LIVE RECORDING ===", true) + + UpdateTarget("recording started") + -- Establish a silent baseline. Existing foreign auras must not appear just + -- because the user pressed Start Recording; only later changes are logged. + recorder.baselineOnly = true + SnapshotAuras("baseline", false) + recorder.baselineOnly = false + + updateFrame:SetScript("OnUpdate", RecorderOnUpdate) + recorder.dirty = true + RefreshRecorderText(true) +end + +function BNP:StopAuraRecorder(reason) + if not recorder.active then + RefreshRecorderText(true) + return + end + + recorder.stopRequested = false + SnapshotAuras("final before stop", true) + recorder.active = false + recorder.stoppedAt = GetTime() + UnregisterRecorderEvents() + updateFrame:SetScript("OnUpdate", nil) + DumpInternalDiagnostics() + AppendLine("=== END REPORT | reason=" .. CleanValue(reason or "user") .. " | elapsed=" .. + string.format("%.3f", recorder.stoppedAt - (recorder.startedAt or recorder.stoppedAt)) .. "s ===", true) + recorder.dirty = true + RefreshRecorderText(true) +end + +function BNP:ClearAuraRecorder() + if recorder.active then self:StopAuraRecorder("cleared") end + recorder.lines = {} + recorder.charCount = 0 + recorder.limitHit = false + recorder.stopRequested = false + recorder.copyNotice = nil + recorder.pendingSnapshots = {} + recorder.lastAuraSignatures = {} + recorder.traceGUID = nil + recorder.traceName = nil + recorder.dirty = true + RefreshRecorderText(true) +end + +function BNP:SelectAllAuraRecorderText() + if not recorder.editBox then return end + RefreshRecorderText(true) + recorder.editBox:SetFocus() + recorder.editBox:HighlightText() +end + +function BNP:CopyAuraRecorderText() + RefreshRecorderText(true) + local report = table.concat(recorder.lines, "\n") + + if CopyToClipboard then + local ok = pcall(CopyToClipboard, report) + if ok then + recorder.copyNotice = "COPIED TO CLIPBOARD | Paste the report with Ctrl+V" + recorder.dirty = true + RefreshRecorderText(true) + return true + end + end + + -- Older ClassicAPI builds and plain 1.12 clients have no direct clipboard + -- function. Keep the normal edit-box path as a complete fallback. + self:SelectAllAuraRecorderText() + recorder.copyNotice = "TEXT SELECTED | Press Ctrl+C, then paste the report" + recorder.dirty = true + RefreshRecorderText(true) + recorder.editBox:SetFocus() + recorder.editBox:HighlightText() + return false +end + +function BNP:CreateAuraRecorderWindow() + if recorder.frame then return recorder.frame end + + local frame = CreateFrame("Frame", "BNPAuraRecorderFrame", UIParent) + frame:SetWidth(760) + frame:SetHeight(560) + frame:SetPoint("CENTER", UIParent, "CENTER", 0, 20) + frame:SetFrameStrata("DIALOG") + frame:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 } + }) + frame:SetMovable(true) + frame:EnableMouse(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function() this:StartMoving() end) + frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + frame:SetScript("OnHide", function() + if recorder.active then BNP:StopAuraRecorder("window closed") end + end) + frame:Hide() + recorder.frame = frame + + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + title:SetPoint("TOP", frame, "TOP", 0, -18) + title:SetText("Missing Spell / Aura Recorder") + + local instructions = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + instructions:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -46) + instructions:SetWidth(706) + instructions:SetJustifyH("LEFT") + if CopyToClipboard then + instructions:SetText("1. Select the affected unit. 2. Start Recording. 3. Apply the aura, refresh it while active, then let it expire or remove it. 4. Stop. 5. Copy Report and paste it.") + else + instructions:SetText("1. Select the affected unit. 2. Start Recording. 3. Apply the aura, refresh it while active, then let it expire or remove it. 4. Stop. 5. Select All, press Ctrl+C and paste it.") + end + + local status = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + status:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -86) + status:SetWidth(706) + status:SetJustifyH("LEFT") + recorder.status = status + + local startButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + startButton:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -112) + startButton:SetWidth(126) + startButton:SetHeight(24) + startButton:SetText("Start Recording") + startButton:SetScript("OnClick", function() BNP:StartAuraRecorder() end) + recorder.startButton = startButton + + local stopButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + stopButton:SetPoint("LEFT", startButton, "RIGHT", 8, 0) + stopButton:SetWidth(82) + stopButton:SetHeight(24) + stopButton:SetText("Stop") + stopButton:SetScript("OnClick", function() BNP:StopAuraRecorder("user") end) + recorder.stopButton = stopButton + + local clearButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + clearButton:SetPoint("LEFT", stopButton, "RIGHT", 8, 0) + clearButton:SetWidth(82) + clearButton:SetHeight(24) + clearButton:SetText("Clear") + clearButton:SetScript("OnClick", function() BNP:ClearAuraRecorder() end) + + local selectButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + selectButton:SetPoint("LEFT", clearButton, "RIGHT", 8, 0) + selectButton:SetWidth(108) + selectButton:SetHeight(24) + selectButton:SetText(CopyToClipboard and "Copy Report" or "Select All") + selectButton:SetScript("OnClick", function() BNP:CopyAuraRecorderText() end) + + local copyHint = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + copyHint:SetPoint("LEFT", selectButton, "RIGHT", 10, 0) + copyHint:SetText(CopyToClipboard and "then paste" or "then Ctrl+C") + + local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -5, -5) + + local textBG = CreateFrame("Frame", nil, frame) + textBG:SetPoint("TOPLEFT", frame, "TOPLEFT", 24, -146) + textBG:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -24, 48) + textBG:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 3, right = 3, top = 3, bottom = 3 } + }) + textBG:SetBackdropColor(0.015, 0.015, 0.015, 0.95) + textBG:SetBackdropBorderColor(0.35, 0.35, 0.35, 1) + + local scroll = CreateFrame("ScrollFrame", "BNPAuraRecorderScrollFrame", textBG, "UIPanelScrollFrameTemplate") + scroll:SetPoint("TOPLEFT", textBG, "TOPLEFT", 8, -8) + scroll:SetPoint("BOTTOMRIGHT", textBG, "BOTTOMRIGHT", -28, 8) + recorder.scrollFrame = scroll + + local edit = CreateFrame("EditBox", "BNPAuraRecorderEditBox", scroll) + edit:SetPoint("TOPLEFT", scroll, "TOPLEFT", 0, 0) + edit:SetWidth(676) + edit:SetHeight(330) + edit:SetMultiLine(true) + edit:SetAutoFocus(false) + edit:SetFontObject(ChatFontNormal) + edit:SetTextColor(0.92, 0.92, 0.92) + edit:SetMaxLetters(65000) + edit:EnableMouse(true) + edit:SetScript("OnEscapePressed", function() this:ClearFocus() end) + edit:SetScript("OnTextChanged", function() + if recorder.scrollFrame and recorder.scrollFrame.UpdateScrollChildRect then + recorder.scrollFrame:UpdateScrollChildRect() + end + end) + scroll:SetScrollChild(edit) + recorder.editBox = edit + + local footer = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + footer:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 26, 24) + footer:SetWidth(706) + footer:SetJustifyH("LEFT") + footer:SetText("The recorder changes no tracking settings. It is completely inactive after Stop or Close.") + + RefreshRecorderText(true) + return frame +end + +function BNP:OpenAuraRecorder() + local function OpenFullRecorder() + local frame = BNP:CreateAuraRecorderWindow() + frame:Show() + RefreshRecorderText(true) + end + + local ok = pcall(OpenFullRecorder) + if not ok and self.OpenEmergencyAuraRecorder then + self:OpenEmergencyAuraRecorder() + end +end diff --git a/modules/auras.lua b/modules/auras.lua new file mode 100644 index 0000000..562f12a --- /dev/null +++ b/modules/auras.lua @@ -0,0 +1,3329 @@ +if not BNP.libnameplate then return end +if not CombatLogAdd or not SpellInfo then return end + +local _, playerClass = UnitClass("player") +local UI = BNP.UI or {} +local ICON_SIZE = UI.ICON_SIZE or 18 +local function GetIconSize() + return (BNP.GetIconSize and BNP:GetIconSize()) or ICON_SIZE +end +local function GetCCIconSize() + return (BNP.GetCCIconSize and BNP:GetCCIconSize()) or GetIconSize() +end +local ICON_OFFSET_Y = UI.ICON_OFFSET_Y or 15 +local COMBO_LAYOUT_CLASS = (playerClass == "ROGUE" or playerClass == "DRUID") +local COMBO_LAYOUT_OFFSET = 16 +local ROW_SPACING = 2 + +local function GetBaseAuraOffsetY() + if COMBO_LAYOUT_CLASS and BNP.AreComboPointsEnabled and BNP:AreComboPointsEnabled() then + return ICON_OFFSET_Y + COMBO_LAYOUT_OFFSET + end + return ICON_OFFSET_Y +end + +local function GetDebuffPosition() + if BNP.GetDebuffPosition then return BNP:GetDebuffPosition() end + return "top" +end + +local function GetCCPosition() + if BNP.GetCCPosition then return BNP:GetCCPosition() end + return GetDebuffPosition() +end + +local function UseSeparateCCRow() + -- The old extra row remains meaningful only when both aura groups are Top. + if GetDebuffPosition() ~= "top" or GetCCPosition() ~= "top" then + return false + end + return BNP.IsSeparateCCRowEnabled and BNP:IsSeparateCCRowEnabled() +end + +local function UseDedicatedCCContainer() + -- Different positions must always use the independent CC container. + return GetCCPosition() ~= GetDebuffPosition() or UseSeparateCCRow() +end + +local function GetAuraOffsetY() + return GetBaseAuraOffsetY() +end + +local function GetCCOffsetY() + local y = GetBaseAuraOffsetY() + if UseSeparateCCRow() and BNP.AreCrowdControlEnabled and BNP:AreCrowdControlEnabled() then + y = y + GetIconSize() + ROW_SPACING + end + return y +end +local ICON_SPACING = UI.ICON_SPACING or 2 +local SIDE_SPACING = 4 +local UPDATE_INTERVAL = 0.05 + +local function GetSideCCOffsetY() + if UseSeparateCCRow() and BNP.AreCrowdControlEnabled and BNP:AreCrowdControlEnabled() then + return GetIconSize() + ROW_SPACING + end + return 0 +end + +local function GetLevelRegionWidth(region) + if not region then return 0 end + if region.IsShown and not region:IsShown() then return 0 end + if region.GetStringWidth then + local width = tonumber(region:GetStringWidth()) or 0 + if width > 0 then return width end + end + if region.GetWidth then + return math.max(0, tonumber(region:GetWidth()) or 0) + end + return 0 +end + +local function GetRightAuraSpacing(plate) + local extra = 0 + if plate then + extra = extra + GetLevelRegionWidth(plate.level) + extra = extra + GetLevelRegionWidth(plate.levelicon) + end + + if extra > 0 then + extra = extra + 6 + end + + return SIDE_SPACING + extra +end + +local function AnchorAuraContainer(plate, container, isCC) + if not plate or not container then return end + container:ClearAllPoints() + + local position = isCC and GetCCPosition() or GetDebuffPosition() + if plate.healthbar then + if position == "left" then + container:SetPoint("RIGHT", plate.healthbar, "LEFT", -SIDE_SPACING, isCC and GetSideCCOffsetY() or 0) + elseif position == "right" then + container:SetPoint("LEFT", plate.healthbar, "RIGHT", GetRightAuraSpacing(plate), isCC and GetSideCCOffsetY() or 0) + else + container:SetPoint("BOTTOM", plate.healthbar, "TOP", 0, isCC and GetCCOffsetY() or GetAuraOffsetY()) + end + else + if position == "left" then + container:SetPoint("RIGHT", plate, "LEFT", -SIDE_SPACING, isCC and GetSideCCOffsetY() or 0) + elseif position == "right" then + container:SetPoint("LEFT", plate, "RIGHT", GetRightAuraSpacing(plate), isCC and GetSideCCOffsetY() or 0) + else + container:SetPoint("BOTTOM", plate, "BOTTOM", 0, 24 + (isCC and (GetCCOffsetY() - GetAuraOffsetY()) or 0)) + end + end +end + +-- Aura rows are anchored to the projected nameplate but parented beside it. +-- That keeps their opacity independent from transient Vanilla nameplate alpha +-- changes while preserving the native world/nameplate layer. + +-- SuperWoW GUID cache. Auras are learned while a unit is the current target +-- and remain attached to that unit's unique GUID after the target changes. +BNP.guidAuras = BNP.guidAuras or {} +BNP.guidNames = BNP.guidNames or {} +BNP.guidLiveCCs = BNP.guidLiveCCs or {} +-- Shadow Vulnerability is a shared proc aura whose refresh can be invisible to +-- vanilla UnitDebuff (same spell ID, same stack count, no duration data). Keep +-- its countdown in a dedicated GUID state so generic shared-aura scans can +-- never accidentally replace or freeze the timer. +BNP.svExpiryByGUID = BNP.svExpiryByGUID or {} +-- Fully isolated Shadow Vulnerability state. Generic shared-aura code may +-- mirror this state for rendering, but it must never own or rewrite the timer. +BNP.svStateByGUID = BNP.svStateByGUID or {} +-- Monotonic render revision per GUID. A confirmed refresh increments this so +-- the normal nameplate renderer can invalidate any cached timer text safely. +BNP.svRenderRevisionByGUID = BNP.svRenderRevisionByGUID or {} + +BNP.pvpAuraProtection = BNP.pvpAuraProtection or {} +local PVP_AURA_PROTECTION_TIME = 6.0 + +local function ProtectAuraCache(guid, reason) + if not guid then return end + BNP.pvpAuraProtection[guid] = { + untilTime = GetTime() + PVP_AURA_PROTECTION_TIME, + reason = reason, + } +end + +local function AuraCacheProtected(guid, now) + local state = guid and BNP.pvpAuraProtection[guid] + if not state then return false end + if now <= (state.untilTime or 0) then return true end + BNP.pvpAuraProtection[guid] = nil + return false +end + +local CLASS_AURAS = { + WARLOCK = BNP.WarlockAuras, + PRIEST = BNP.PriestAuras, + WARRIOR = BNP.WarriorAuras, + ROGUE = BNP.RogueAuras, + HUNTER = BNP.HunterAuras, + MAGE = BNP.MageAuras, + DRUID = BNP.DruidAuras, + SHAMAN = BNP.ShamanAuras, + PALADIN = BNP.PaladinAuras, +} +local AURA_DEFS = CLASS_AURAS[playerClass] or {} +local HAS_SHARED_AURAS = false +do + local _, def + for _, def in ipairs(AURA_DEFS) do + if def.shared then + HAS_SHARED_AURAS = true + break + end + end +end +local MAX_VISIBLE_ICONS = 8 + +local function PositionAuraIcon(icon, container, index, visibleCount, iconSize, isCC) + if not icon or not container then return end + local position = isCC and GetCCPosition() or GetDebuffPosition() + local step = iconSize + ICON_SPACING + + icon:ClearAllPoints() + + if position == "left" then + icon:SetPoint("RIGHT", container, "RIGHT", -((index - 1) * step), 0) + elseif position == "right" then + icon:SetPoint("LEFT", container, "LEFT", (index - 1) * step, 0) + else + local maxWidth = iconSize * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + local rowWidth = 0 + if visibleCount and visibleCount > 0 then + rowWidth = iconSize * visibleCount + ICON_SPACING * (visibleCount - 1) + end + local startX = (maxWidth - rowWidth) / 2 + icon:SetPoint("LEFT", container, "LEFT", startX + (index - 1) * step, 0) + end +end + +local function GetAuraFrameLevel(plate) + local plateLevel = 0 + if plate and plate.GetFrameLevel then + plateLevel = tonumber(plate:GetFrameLevel()) or plateLevel + end + return math.max(0, plateLevel + 1) +end + +local function ApplyAuraFrameLevel(plate, container) + if not container then return end + + -- Aura containers are siblings of the projected plate (not children) so they + -- do not inherit transient plate alpha. Mirror only the plate's native strata + -- and relative level; never modify the projected nameplate's own strata. + if plate and plate.GetFrameStrata and container.GetFrameStrata and container.SetFrameStrata then + local strata = plate:GetFrameStrata() + if strata and container:GetFrameStrata() ~= strata then + container:SetFrameStrata(strata) + end + end + + local level = GetAuraFrameLevel(plate) + if container.GetFrameLevel and container.SetFrameLevel and + container:GetFrameLevel() ~= level then + container:SetFrameLevel(level) + end + + local i + for i = 1, table.getn(container.icons or {}) do + local icon = container.icons[i] + if icon and icon.GetFrameLevel and icon.SetFrameLevel and + icon:GetFrameLevel() ~= level then + icon:SetFrameLevel(level) + end + end +end + +-- Target auras stay opaque and non-target auras remain consistently faded. +-- This is identity-based and never changes with camera or screen position. +local TARGET_PRIORITY_AURA_ALPHA = 0.35 +local TARGET_PRIORITY_PLATE_HOLD = 0.25 +local targetPriorityPlate = nil +local targetPriorityGUID = nil +local targetPriorityPlateSeenAt = -100 + +local function RefreshTargetPriorityIdentity() + local exists, guid = UnitExists("target") + if not exists or not guid then + targetPriorityPlate = nil + targetPriorityGUID = nil + targetPriorityPlateSeenAt = -100 + return + end + + local now = GetTime() + + -- A real target switch invalidates the previous frame immediately. For the + -- same GUID, however, keep the last exact ClassicAPI target plate through a + -- very short nil result. Dense raid stacks can make projected plate lookup + -- flicker for a frame; dropping the cached target here made its DoTs/Banish + -- alternate between opaque and the 0.35 non-target alpha. + if targetPriorityGUID ~= guid then + targetPriorityPlate = nil + targetPriorityPlateSeenAt = -100 + end + targetPriorityGUID = guid + + local resolved = nil + if C_NamePlate and + type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, plate = pcall(C_NamePlate.GetNamePlateForUnit, "target") + if ok and plate then resolved = plate end + end + + if resolved then + targetPriorityPlate = resolved + targetPriorityPlateSeenAt = now + elseif targetPriorityPlate and + now - targetPriorityPlateSeenAt > TARGET_PRIORITY_PLATE_HOLD then + targetPriorityPlate = nil + end +end + +local function SetTargetPriorityAuraAlpha(container, alpha) + if not container then return end + alpha = alpha or 1 + if container.SetAlpha and + (not container.GetAlpha or container:GetAlpha() ~= alpha) then + container:SetAlpha(alpha) + end +end + +local function ApplyTargetAuraPriority(plate, container) + if not container then return end + + -- Flicker fix: aura opacity must not depend on transient target/nameplate + -- resolution. In dense raid stacks ClassicAPI/SuperWoW can briefly resolve + -- a projected plate differently, which previously made the whole debuff/CC + -- row alternate between alpha 1.0 and 0.35. This alpha is separate from the + -- user-facing Non-Target Alpha slider, so setting that slider to 100% did + -- not disable the flicker. Keep aura containers fully opaque and let target + -- priority be handled by nameplate layering only. + SetTargetPriorityAuraAlpha(container, 1) +end + +function BNP:RefreshAuraPriorityAlpha() + RefreshTargetPriorityIdentity() + + local plate + for plate in pairs(self.plates or {}) do + ApplyAuraFrameLevel(plate, plate.BNPAuraContainer) + ApplyAuraFrameLevel(plate, plate.BNPCCContainer) + ApplyTargetAuraPriority(plate, plate.BNPAuraContainer) + ApplyTargetAuraPriority(plate, plate.BNPCCContainer) + end +end + +local function CleanText(text) + if not text then return nil end + text = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "") + text = string.gsub(text, "|r", "") + return text +end + +local function NameMatches(def, name) + if not name then return false end + local lower = string.lower(name) + for _, known in ipairs(def.names) do + if lower == string.lower(known) then return true end + end + return false +end + +local function AuraMatches(def, effect, texture) + if NameMatches(def, effect) then return true end + if texture and def.textureMatch then + return string.find(string.lower(texture), def.textureMatch) and true or false + end + return false +end + +local function ScanSpellbook() + if table.getn(AURA_DEFS) == 0 then return end + + local i = 1 + while true do + local name = GetSpellName(i, BOOKTYPE_SPELL) + if not name then break end + + local texture = GetSpellTexture(i, BOOKTYPE_SPELL) + local lowerTexture = texture and string.lower(texture) or "" + + for _, def in ipairs(AURA_DEFS) do + if NameMatches(def, name) or (def.textureMatch and string.find(lowerTexture, def.textureMatch)) then + def.localizedName = name + def.texture = texture or def.texture + end + end + + i = i + 1 + end + if BNP.RefreshSpellbookDurations then BNP:RefreshSpellbookDurations() end +end + +local function CreateAuraIcon(parent, index) + local icon = CreateFrame("Frame", nil, parent) + local size = GetIconSize() + icon:SetWidth(size) + icon:SetHeight(size) + icon:SetFrameLevel(parent.GetFrameLevel and parent:GetFrameLevel() or 0) + + local texture = icon:CreateTexture(nil, "ARTWORK") + texture:SetAllPoints(icon) + icon.texture = texture + + local timer = icon:CreateFontString(nil, "OVERLAY") + timer:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", UI.TIMER_SIZE or 8, "OUTLINE") + timer:SetPoint("CENTER", icon, "CENTER", UI.TIMER_OFFSET_X or 0, UI.TIMER_OFFSET_Y or 0) + timer:SetTextColor(1, 1, 1) + icon.timer = timer + + local stack = icon:CreateFontString(nil, "OVERLAY") + stack:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", 8, "OUTLINE") + stack:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -1, 1) + stack:SetTextColor(1, 1, 1) + stack:SetText("") + icon.stack = stack + + icon.index = index + icon:Hide() + + return icon +end + +local function HidePlateAuraFrames(plate) + if not plate then return end + + local containers = { plate.BNPAuraContainer, plate.BNPCCContainer } + local _, container, i + for _, container in ipairs(containers) do + if container then + for i = 1, table.getn(container.icons or {}) do + container.icons[i]:Hide() + end + container:Hide() + end + end +end + +local function InstallAuraHideGuard(plate) + if not plate or plate.BNPAuraHideGuard then return end + + local oldOnHide = plate:GetScript("OnHide") + plate:SetScript("OnHide", function() + if oldOnHide then oldOnHide() end + local current = this or plate + HidePlateAuraFrames(current) + -- If Blizzard reuses this frame for another unit, do not hold the old GUID + -- through the next OnShow. The next visible identity must be reconfirmed. + current.BNPAuraForceFreshIdentity = true + current.BNPAuraGUIDCandidate = nil + current.BNPAuraGUIDCandidateCount = 0 + end) + plate.BNPAuraHideGuard = true +end + +local function GetAuraVisualParent(plate) + -- Aura rows must not inherit the projected nameplate's alpha. In large raids + -- the Vanilla client can transiently adjust/recycle a non-target plate's alpha + -- while resolving dense stacks. A child frame inherits that alpha even when + -- its own alpha is forced to 1, which made debuffs pulse grey/colored. + -- Use the nameplate's native parent (normally WorldFrame) as a sibling parent + -- and keep anchoring to the real healthbar so position still follows the plate. + if plate and plate.GetParent then + local parent = plate:GetParent() + if parent then return parent end + end + return WorldFrame or UIParent +end + +local function CreateAuraContainer(plate) + if not plate then return end + BNP:RegisterPlate(plate) + if plate.BNPAuraContainer then return end + + local size = GetIconSize() + local width = size * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + local container = CreateFrame("Frame", nil, GetAuraVisualParent(plate)) + container:SetWidth(width) + container:SetHeight(size) + container:SetFrameLevel(GetAuraFrameLevel(plate)) + + AnchorAuraContainer(plate, container, false) + + container.icons = {} + for i = 1, MAX_VISIBLE_ICONS do + local icon = CreateAuraIcon(container, i) + PositionAuraIcon(icon, container, i, 0, size) + container.icons[i] = icon + end + + container:Hide() + plate.BNPAuraContainer = container + ApplyAuraFrameLevel(plate, container) + InstallAuraHideGuard(plate) +end + +local function CreateCCContainer(plate) + if not plate then return end + BNP:RegisterPlate(plate) + if plate.BNPCCContainer then return end + + local size = GetCCIconSize() + local width = size * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + local container = CreateFrame("Frame", nil, GetAuraVisualParent(plate)) + container:SetWidth(width) + container:SetHeight(size) + container:SetFrameLevel(GetAuraFrameLevel(plate)) + + AnchorAuraContainer(plate, container, true) + + container.icons = {} + for i = 1, MAX_VISIBLE_ICONS do + local icon = CreateAuraIcon(container, i) + PositionAuraIcon(icon, container, i, 0, size, true) + container.icons[i] = icon + end + + container:Hide() + plate.BNPCCContainer = container + ApplyAuraFrameLevel(plate, container) +end + +local function GetPlateGUID(plate) + if not plate or not plate.GetName then return nil end + + -- SuperWoW stores the unit GUID/token directly on the nameplate name slot. + -- Use that exact token, just like Tank Mode and ShaguTweaks do. This avoids + -- unreliable UnitExists() resolution and, importantly, avoids same-name + -- fallback collisions between two mobs with identical names. + local token = plate:GetName(1) + if token and token ~= "" then return token end + return nil +end + +local function GetTargetGUID() + local exists, guid = UnitExists("target") + if exists and guid then return guid end + return nil +end + +local function ClearGUIDAuraState(guid) + if not guid then return end + BNP.guidAuras[guid] = nil + if BNP.svStateByGUID then BNP.svStateByGUID[guid] = nil end + if BNP.svExpiryByGUID then BNP.svExpiryByGUID[guid] = nil end + if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end + if BNP.guidLiveCCs then BNP.guidLiveCCs[guid] = nil end + if BNP.pendingAuras then BNP.pendingAuras[guid] = nil end + if BNP.pvpAuraProtection then BNP.pvpAuraProtection[guid] = nil end + if BNP.darkHarvest and BNP.darkHarvest.guid == guid then BNP.darkHarvest = nil end + if BNP.guidNames then BNP.guidNames[guid] = nil end +end + +local function CleanupDeadUnit(unit, guid, now) + if not unit or not guid or not UnitIsDeadOrGhost then return false end + if AuraCacheProtected and AuraCacheProtected(guid, now) then return false end + if UnitIsDeadOrGhost(unit) then + ClearGUIDAuraState(guid) + return true + end + return false +end + + +-- Fallback for players/NPCs whose nameplate disappears before the periodic +-- dead-state check sees them. Vanilla combat chat reports " dies." only, +-- so this fallback must NEVER clear multiple same-name GUIDs. +local function ClearAuraStateByName(name) + if not name or name == "" then return end + + -- Combat death chat gives us only a name, never the exact GUID. + -- Count ALL known GUIDs with this name, not only GUIDs that currently have + -- tracked auras. Otherwise, if two same-name mobs exist but only one has our + -- debuffs, the death of the other mob could wrongly clear the survivor. + local matchGUID = nil + local matches = 0 + local guid, knownName + + for guid, knownName in pairs(BNP.guidNames or {}) do + if knownName == name then + matches = matches + 1 + matchGUID = guid + + if matches > 1 then + -- Ambiguous same-name death: never clear anything by name. + -- Exact GUID-based target death cleanup remains authoritative. + return + end + end + end + + if matches == 1 and matchGUID then + ClearGUIDAuraState(matchGUID) + end +end + + +-- Boss / NPC evade-reset cleanup -------------------------------------------- +-- On a real reset the server restores the NPC to full health and drops combat, +-- while BNP's local aura timers would otherwise keep running. Track only the +-- current hostile NPC and clear its exact GUID cache when that reset signature +-- is observed. +local resetWatch = {} +local resetElapsed = 0 + +local function CheckCurrentTargetReset(now) + local exists, guid = UnitExists("target") + if not exists or not guid then + resetWatch.guid = nil + resetWatch.wasDamaged = nil + return + end + + if UnitIsPlayer and UnitIsPlayer("target") then + resetWatch.guid = guid + resetWatch.wasDamaged = nil + return + end + + if resetWatch.guid ~= guid then + resetWatch.guid = guid + resetWatch.wasDamaged = nil + end + + if not UnitHealth or not UnitHealthMax then return end + local hp = tonumber(UnitHealth("target")) + local maxhp = tonumber(UnitHealthMax("target")) + if not hp or not maxhp or maxhp <= 0 then return end + + local pct = hp / maxhp + if pct < 0.95 then + resetWatch.wasDamaged = true + end + + if not resetWatch.wasDamaged then return end + if pct < 0.995 then return end + + -- Prefer the target's combat state when available. If the API is missing, + -- require the player to be out of combat as a conservative fallback. + local outOfCombat = false + if UnitAffectingCombat then + outOfCombat = not UnitAffectingCombat("target") + elseif PlayerFrame and PlayerFrame.inCombat ~= nil then + outOfCombat = not PlayerFrame.inCombat + end + + if not outOfCombat then return end + + if BNP.guidAuras and BNP.guidAuras[guid] then + ClearGUIDAuraState(guid) + if RaidTrace then RaidTrace("RESET", guid, "-", nil) end + end + + resetWatch.wasDamaged = nil +end + +local resetFrame = CreateFrame("Frame") +resetFrame:SetScript("OnUpdate", function() + resetElapsed = resetElapsed + arg1 + if resetElapsed < 0.10 then return end + resetElapsed = 0 + CheckCurrentTargetReset(GetTime()) +end) + +local deathChatFrame = CreateFrame("Frame") +deathChatFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH") +deathChatFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH") +deathChatFrame:SetScript("OnEvent", function() + local raw = arg1 + if not raw then return end + local _, _, deadName = string.find(raw, "^(.+) dies%.$") + if deadName then ClearAuraStateByName(deadName) end +end) + +-- Only successful casts by the local player are allowed to create aura +-- entries. This prevents target scans from importing debuffs cast by other +-- players. SuperWoW supplies caster GUID, target GUID and spell ID. +local function GetPlayerGUID() + local exists, guid = UnitExists("player") + if exists and guid then return guid end + return nil +end + +local function DefHasSpellID(def, spellID) + if not def or not spellID then return false end + + local i + if def.spellIDs then + for i = 1, table.getn(def.spellIDs) do + if def.spellIDs[i] == spellID then return true end + end + end + + if def.durations and def.durations[spellID] then return true end + return false +end + +local function FindAuraDef(spellID) + if not spellID or not SpellInfo then return nil, nil end + local spellName, _, texture = SpellInfo(spellID) + if not spellName and not texture then return nil, nil end + + -- Exact IDs are authoritative and must win over shared spell textures. + local _, def + for _, def in ipairs(AURA_DEFS) do + if DefHasSpellID(def, spellID) then + return def, texture, spellID + end + end + + for _, def in ipairs(AURA_DEFS) do + if NameMatches(def, spellName) then + return def, texture, spellID + end + end + + for _, def in ipairs(AURA_DEFS) do + if texture and def.textureMatch and string.find(string.lower(texture), def.textureMatch) then + return def, texture, spellID + end + end + + return nil, nil +end + + +-- Paladin Judgements -------------------------------------------------------- +-- SuperWoW reports the button press as Judgement [20271], while the target +-- receives a different aura ID (e.g. Light 20344, Justice 20184, Crusader +-- 20302, Wisdom 20353). Therefore normal CAST-ID == AURA-ID confirmation +-- cannot work for Judgements. +local PALADIN_JUDGEMENT_CAST_ID = 20271 + +local function IsJudgementDef(def) + if not def or not def.key then return false end + return string.find(def.key, "^judgement_") and true or false +end + +local function FindJudgementAuraOnUnit(unit) + if playerClass ~= "PALADIN" or not unit then return nil end + + local i, texture, stacks, dtype, auraSpellID + for i = 1, 64 do + texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) + if not texture then break end + + if auraSpellID then + local auraName = nil + if SpellInfo then auraName = SpellInfo(auraSpellID) end + + local _, def + for _, def in ipairs(AURA_DEFS) do + if IsJudgementDef(def) then + local explicitMatch = false + local j + if def.spellIDs then + for j = 1, table.getn(def.spellIDs) do + if def.spellIDs[j] == auraSpellID then + explicitMatch = true + break + end + end + end + + if explicitMatch or NameMatches(def, auraName) then + return def, auraSpellID, texture + end + end + end + end + end + + return nil +end + + +-- Turtle WoW Dark Harvest (spell 52552). +-- It is reported by SuperWoW as CHANNEL; arg5 is channel length in ms. +-- A 30% shorter tick interval means aura time advances at 1/0.70 speed. +local DARK_HARVEST_SPELL_ID = 52552 +local DARK_HARVEST_SPEED = 1 / 0.70 +local DARK_HARVEST_AURAS = { + corruption=true, curse_of_agony=true, siphon_life=true, + curse_of_doom=true, drain_life=true, drain_soul=true, +} +BNP.darkHarvest = nil + +local function StartDarkHarvest(guid, durationMS) + local now=GetTime() + local duration=tonumber(durationMS) or 8000 + if duration > 100 then duration=duration/1000 end + BNP.darkHarvest={guid=guid, lastUpdate=now, ends=now+duration} +end + +local function UpdateDarkHarvest(now) + local st=BNP.darkHarvest + if not st then return end + local untilTime=now + if untilTime > st.ends then untilTime=st.ends end + local delta=untilTime-st.lastUpdate + if delta > 0 then + local cache=BNP.guidAuras[st.guid] + if cache then + local bonus=delta*(DARK_HARVEST_SPEED-1) + local key,aura + for key,aura in pairs(cache) do + if DARK_HARVEST_AURAS[key] and type(aura)=="table" and aura.expires and aura.expires > st.lastUpdate then + aura.expires=aura.expires-bonus + end + end + end + st.lastUpdate=untilTime + end + if now >= st.ends then BNP.darkHarvest=nil end +end + + + +-- Raid confirmation trace ---------------------------------------------------- +-- Tiny always-on ring buffer for the last tracked own-aura transitions. +-- No chat output and no scanning; entries are only added at existing events. +BNP.raidTrace = BNP.raidTrace or {} +BNP.raidTraceMax = 30 + +local function RaidTrace(stage, guid, key, spellID) + local t = BNP.raidTrace + table.insert(t, { + time = GetTime(), + stage = stage, + guid = guid, + key = key, + spellID = spellID, + }) + while table.getn(t) > BNP.raidTraceMax do + table.remove(t, 1) + end +end + +-- Cast confirmation --------------------------------------------------------- +-- UNIT_CASTEVENT "CAST" means the player finished the cast, but it does not +-- guarantee that the hostile aura actually landed. Resist/Miss/Immune must +-- therefore never create a visible timer. +-- +-- We first remember the cast as "pending". It becomes a real aura only after +-- UnitDebuff confirms the matching aura on that exact SuperWoW GUID. +-- Never infer success from CAST alone: melee abilities can still miss/dodge, +-- and hostile targets may resist or be immune after the cast event. +BNP.pendingAuras = BNP.pendingAuras or {} +BNP.pendingAoEAuras = BNP.pendingAoEAuras or {} + +-- Pending failures are matched by exact SuperWoW UNIT_CASTEVENT data. +-- Never use global combat-text "miss/resist" lines here: in a raid those can +-- belong to another spell and previously caused unrelated pending DoTs to die. + +local function FailPendingAura(targetGUID, spellID) + if not targetGUID or not spellID then return end + local entries = BNP.pendingAuras[targetGUID] + if not entries then return end + + local def = FindAuraDef(spellID) + if def and entries[def.key] then + entries[def.key] = nil + end + + -- Generic Paladin Judgement failure. + if playerClass == "PALADIN" and spellID == PALADIN_JUDGEMENT_CAST_ID then + entries.__judgement = nil + end +end + +local PENDING_TIMEOUT = 4.0 + +-- Shagu-style authoritative own-cast tracking -------------------------------- +-- Exact own SuperWoW CAST on an exact GUID creates normal direct single-target +-- debuffs immediately. The application remains provisional for a short window: +-- SuperWoW FAIL and localized combat-log miss/resist/immune messages can roll it +-- back, including restoring the old timer after a resisted refresh. +local STRICT_AURA_KEYS = { + -- Shared / proc / secondary effects + shadow_vulnerability=true, hunters_mark=true, + + -- Warlock CC + fear=true, howl_of_terror=true, banish=true, death_coil=true, + + -- Hunter traps / CC / talent procs + immolation_trap_effect=true, explosive_trap_effect=true, freezing_trap_effect=true, + improved_scorpid_sting=true, improved_wing_clip=true, improved_concussive_shot=true, + scatter_shot=true, wyvern_sting=true, entrapment=true, counterattack=true, + intimidation=true, scare_beast=true, + + -- Mage CC / proc effects + polymorph=true, frost_nova=true, counterspell=true, counterspell_silence=true, + ignite=true, impact=true, frostbite=true, fire_vulnerability=true, winters_chill=true, + + -- Priest CC / proc effects + psychic_scream=true, silence=true, blackout=true, touch_of_weakness=true, + shackle_undead=true, mind_control=true, + + -- Paladin CC + hammer_of_justice=true, repentance=true, turn_undead=true, + + -- Rogue CC / poison procs + gouge=true, kidney_shot=true, cheap_shot=true, sap=true, blind=true, + kick_silenced=true, crippling_poison=true, mind_numbing_poison=true, + wound_poison=true, deadly_poison=true, + + -- Druid CC / secondary effects + entangling_roots=true, bash=true, pounce=true, pounce_bleed=true, + hibernate=true, feral_charge_effect=true, + + -- Warrior CC / talent procs + improved_hamstring=true, intimidating_shout=true, disarm=true, + charge_stun=true, intercept_stun=true, concussion_blow=true, + revenge_stun=true, pummel=true, shield_bash=true, deep_wound=true, + + -- Shaman totem / proc effects + earthbind=true, earthgrab=true, frostbrand_attack=true, +} + +local function GetTrackingMode(def) + if not def or not def.key then return "AURA" end + if def.category == "CURSE" then return "AURA" end + if def.aoe then return "AURA" end + local cat = string.upper(tostring(def.category or "")) + if cat == "CC" or cat == "ROOT" or cat == "STUN" + or cat == "TRAP" or cat == "SILENCE" or cat == "PROC" then + return "AURA" + end + if STRICT_AURA_KEYS[def.key] then return "AURA" end + return "CAST" +end + +local function IsCastPrimary(def) + return GetTrackingMode(def) == "CAST" +end + +local AURA_ATTEMPT_WINDOW = 5.0 +BNP.auraCastAttempts = BNP.auraCastAttempts or {} + +local function CopyAuraState(aura) + if type(aura) ~= "table" then return nil end + local copy = {} + local k, v + for k, v in pairs(aura) do copy[k] = v end + return copy +end + +local function BuildAffectedKeys(def) + local keys = {} + if not def or not def.key then return keys end + + keys[def.key] = true + + -- Any Warlock curse cast can change the target's complete curse state. + -- Snapshot every tracked curse so a later resist/fail can restore exactly + -- what was active before the attempt. This also supports Turtle mechanics + -- where Curse of Agony may coexist with one regular curse. + if def.category == "CURSE" then + local _, oldDef + for _, oldDef in ipairs(AURA_DEFS) do + if oldDef.category == "CURSE" then + keys[oldDef.key] = true + end + end + end + + return keys +end + +local function StartAuraCastAttempt(guid, def, spellID, affectedKeys) + if not guid or not spellID then return nil end + + local keys = affectedKeys or BuildAffectedKeys(def) + local cache = BNP.guidAuras[guid] + local before = {} + local key + + for key in pairs(keys) do + local aura = cache and cache[key] + before[key] = { + exists = type(aura) == "table" and true or false, + aura = CopyAuraState(aura), + } + end + + local spellName = nil + if SpellInfo then spellName = SpellInfo(spellID) end + if not spellName and def and def.localizedName then spellName = def.localizedName end + if not spellName and def and def.names then spellName = def.names[1] end + + local attempt = { + guid = guid, + key = def and def.key or "__special", + spellID = spellID, + spellName = spellName, + time = GetTime(), + before = before, + done = false, + } + + table.insert(BNP.auraCastAttempts, attempt) + while table.getn(BNP.auraCastAttempts) > 32 do + table.remove(BNP.auraCastAttempts, 1) + end + + return attempt +end + +local function RollbackAuraCastAttempt(attempt, reason) + if not attempt or attempt.done then return false end + + local guid = attempt.guid + local cache = BNP.guidAuras[guid] + if not cache then + cache = {} + BNP.guidAuras[guid] = cache + end + + local key, state + for key, state in pairs(attempt.before or {}) do + if state.exists and state.aura then + cache[key] = CopyAuraState(state.aura) + else + cache[key] = nil + end + end + + attempt.done = true + RaidTrace("ROLLBACK", guid, attempt.key, attempt.spellID) + return true +end + +local function RollbackAuraAttemptBySpell(guid, spellID, reason) + if not spellID then return false end + local now = GetTime() + local i + + for i = table.getn(BNP.auraCastAttempts), 1, -1 do + local attempt = BNP.auraCastAttempts[i] + if attempt and not attempt.done + and attempt.spellID == spellID + and (not guid or attempt.guid == guid) + and now - (attempt.time or 0) <= AURA_ATTEMPT_WINDOW then + return RollbackAuraCastAttempt(attempt, reason) + end + end + + return false +end + +local function CleanupAuraCastAttempts(now) + local i + for i = table.getn(BNP.auraCastAttempts), 1, -1 do + local attempt = BNP.auraCastAttempts[i] + if not attempt or attempt.done or now - (attempt.time or 0) > AURA_ATTEMPT_WINDOW then + table.remove(BNP.auraCastAttempts, i) + end + end +end + +-- ShaguPlates does more than just seed debuffs from UNIT_CASTEVENT CAST: +-- libdebuff also watches the local player's failure combat messages and +-- reverts the last application on miss/resist/immune/evade/etc. BNP mirrors +-- that safety, but keeps the exact SuperWoW GUID and can restore the previous +-- timer when a refresh fails. +local failurePatterns = {} + +local function EscapeLuaPattern(text) + return string.gsub(text, "([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") +end + +local function CombatFormatToPattern(formatText) + if not formatText or formatText == "" then return nil end + + local text = formatText + text = string.gsub(text, "%%%%", "__BNP_PERCENT__") + text = string.gsub(text, "%%%d+%$s", "__BNP_STRING__") + text = string.gsub(text, "%%s", "__BNP_STRING__") + text = string.gsub(text, "%%%d+%$d", "__BNP_NUMBER__") + text = string.gsub(text, "%%d", "__BNP_NUMBER__") + text = EscapeLuaPattern(text) + text = string.gsub(text, "__BNP_STRING__", ".+") + text = string.gsub(text, "__BNP_NUMBER__", "%%d+") + text = string.gsub(text, "__BNP_PERCENT__", "%%%%") + return "^" .. text .. "$" +end + +local function AddFailurePattern(formatText) + local pattern = CombatFormatToPattern(formatText) + if pattern then table.insert(failurePatterns, pattern) end +end + +AddFailurePattern(SPELLIMMUNESELFOTHER) +AddFailurePattern(IMMUNEDAMAGECLASSSELFOTHER) +AddFailurePattern(SPELLMISSSELFOTHER) +AddFailurePattern(SPELLRESISTSELFOTHER) +AddFailurePattern(SPELLEVADEDSELFOTHER) +AddFailurePattern(SPELLDODGEDSELFOTHER) +AddFailurePattern(SPELLDEFLECTEDSELFOTHER) +AddFailurePattern(SPELLREFLECTSELFOTHER) +AddFailurePattern(SPELLPARRIEDSELFOTHER) +AddFailurePattern(SPELLFAILCASTSELF) + +local function IsLocalFailureMessage(raw) + if not raw or raw == "" then return false end + local i + for i = 1, table.getn(failurePatterns) do + if string.find(raw, failurePatterns[i]) then return true end + end + return false +end + +local function RollbackAuraAttemptFromCombatMessage(raw) + if not IsLocalFailureMessage(raw) then return false end + + local now = GetTime() + local i + for i = table.getn(BNP.auraCastAttempts), 1, -1 do + local attempt = BNP.auraCastAttempts[i] + if attempt and not attempt.done + and now - (attempt.time or 0) <= AURA_ATTEMPT_WINDOW + and attempt.spellName + and string.find(raw, attempt.spellName, 1, true) then + return RollbackAuraCastAttempt(attempt, "COMBAT_FAIL") + end + end + + return false +end + +local auraFailureFrame = CreateFrame("Frame") +auraFailureFrame:RegisterEvent("CHAT_MSG_SPELL_FAILED_LOCALPLAYER") +auraFailureFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") +auraFailureFrame:SetScript("OnEvent", function() + RollbackAuraAttemptFromCombatMessage(arg1) +end) + +local function HasPendingAuras() + local guid, entries, key, pending + for guid, entries in pairs(BNP.pendingAuras) do + for key, pending in pairs(entries) do + if type(pending) == "table" then + return true + end + end + end + return false +end + +local function GetUnitTokenForPlate(plate) + if not plate or not plate.GetName then return nil end + return plate:GetName(1) +end + +local function FindMatchingAuraOnUnit(unit, def, castSpellID) + if not unit or not def then return nil, nil, nil end + + -- SuperWoW can expose more than the stock 16/32 debuff slots. In a 40-player + -- raid our own debuff may therefore sit beyond slot 32. Stopping at 32 made + -- valid casts time out instead of entering guidAuras. + local i = 1 + while i <= 64 do + local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) + if not texture then break end + + if auraSpellID then + if auraSpellID == castSpellID or DefHasSpellID(def, auraSpellID) then + return auraSpellID, texture, tonumber(stacks) or 0 + end + + local auraName = SpellInfo and SpellInfo(auraSpellID) or nil + if NameMatches(def, auraName) then + return auraSpellID, texture, tonumber(stacks) or 0 + end + end + + if texture and def.textureMatch and string.find(string.lower(texture), def.textureMatch) then + return auraSpellID or castSpellID, texture, tonumber(stacks) or 0 + end + + i = i + 1 + end + + return nil, nil, nil +end + +local function ReconcileLiveCurseCache(unit, guid) + if playerClass ~= "WARLOCK" or not unit or not guid then return end + + local cache = BNP.guidAuras[guid] + if not cache then return end + + -- Curse casts are already strict aura-confirmed. Once a new curse has really + -- appeared, use that same live target aura state to decide which older curse + -- timers are still valid. This avoids hard-coding whether CoA is exclusive: + -- if the server keeps two curses, BNP keeps two; if one was replaced, BNP + -- removes only the aura that is actually gone. + local _, curseDef + for _, curseDef in ipairs(AURA_DEFS) do + if curseDef.category == "CURSE" and cache[curseDef.key] then + local cachedAura = cache[curseDef.key] + local liveSpellID = FindMatchingAuraOnUnit( + unit, + curseDef, + cachedAura and cachedAura.spellID or nil + ) + + if not liveSpellID then + RaidTrace("CURSE_REMOVE", guid, curseDef.key, cachedAura and cachedAura.spellID) + cache[curseDef.key] = nil + end + end + end +end + +local function CommitPendingAura(guid, key, pending, unit) + if not guid or not pending then return end + + local def = pending.def + local cache = BNP.guidAuras[guid] + if not cache then + cache = {} + BNP.guidAuras[guid] = cache + end + + -- Do not guess curse exclusivity here. Warlock curses are strict aura- + -- confirmed and are reconciled against the live target state after commit. + -- This supports both normal Vanilla replacement and Turtle dual-curse rules. + + cache._nextOrder = (cache._nextOrder or 0) + 1 + local aura = cache[key] or {} + aura.order = cache._nextOrder + aura.duration = pending.duration + aura.expires = GetTime() + pending.duration + aura.spellID = pending.spellID + aura.texture = pending.texture or def.texture + aura.stacks = tonumber(pending.stacks) or 0 + aura.confirmedAt = GetTime() + aura.missingScans = nil + + if pending.liveConfirmed then + aura.castPrimary = nil + aura.castPrimaryAt = nil + aura.liveConfirmed = true + elseif pending.castPrimary then + aura.castPrimary = true + aura.castPrimaryAt = GetTime() + aura.liveConfirmed = nil + end + + cache[key] = aura + + if def.category == "CURSE" and unit then + ReconcileLiveCurseCache(unit, guid) + end + + RaidTrace("CONFIRM", guid, key, aura.spellID) +end + +local function ConfirmPendingForUnit(unit, guid, now) + if not unit or not guid then return end + local pendingForGUID = BNP.pendingAuras[guid] + if not pendingForGUID then return end + + local key, pending + for key, pending in pairs(pendingForGUID) do + if type(pending) == "table" then + if pending.judgementProbe then + local def, auraSpellID, texture = FindJudgementAuraOnUnit(unit) + + if def and auraSpellID then + local duration = def.duration + if BNP.GetSpellbookDurationForDef then + duration = BNP:GetSpellbookDurationForDef(def) or duration + end + + local confirmed = { + def = def, + spellID = auraSpellID, + texture = texture or def.texture, + duration = duration, + created = pending.created, + liveConfirmed = true, + } + + CommitPendingAura(guid, def.key, confirmed, unit) + pendingForGUID[key] = nil + elseif now - pending.created > PENDING_TIMEOUT then + RaidTrace("TIMEOUT", guid, key, pending.spellID) + -- No Judgement aura appeared: resist, miss, immune or failed cast. + pendingForGUID[key] = nil + end + else + local auraSpellID, auraTexture, auraStacks = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID) + if auraSpellID then + local confirmed = { + def = pending.def, + spellID = auraSpellID, + texture = auraTexture or pending.texture, + duration = pending.duration, + created = pending.created, + stacks = auraStacks or 0, + liveConfirmed = true, + } + CommitPendingAura(guid, key, confirmed, unit) + pendingForGUID[key] = nil + elseif now - pending.created > PENDING_TIMEOUT then + RaidTrace("TIMEOUT", guid, key, pending.spellID) + -- Effects not approved for the raid fallback remain strict. + pendingForGUID[key] = nil + end + end + end + end +end + +local function CleanupPending(now) + local guid, entries, key, pending + for guid, entries in pairs(BNP.pendingAuras) do + local any = false + for key, pending in pairs(entries) do + if type(pending) == "table" then + if now - pending.created > PENDING_TIMEOUT then + entries[key] = nil + else + any = true + end + end + end + if not any then BNP.pendingAuras[guid] = nil end + end +end + + +local AOE_PENDING_TIMEOUT = 1.5 + +local function QueueAoEAura(def, spellID, texture, duration) + if not def or not spellID then return end + BNP.pendingAoEAuras[def.key] = { + def = def, + spellID = spellID, + texture = texture or def.texture, + duration = duration, + created = GetTime(), + } +end + +local function ConfirmAoEAurasOnUnit(unit, guid) + if not unit or not guid then return end + local key, pending + for key, pending in pairs(BNP.pendingAoEAuras) do + if type(pending) == "table" then + local auraSpellID, auraTexture, auraStacks = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID) + if auraSpellID then + CommitPendingAura(guid, pending.def.key, { + def = pending.def, + spellID = auraSpellID, + texture = auraTexture or pending.texture, + duration = pending.duration, + created = pending.created, + stacks = auraStacks or 0, + liveConfirmed = true, + }, unit) + end + end + end +end + +local function HasPendingAoEAuras() + local key, pending + for key, pending in pairs(BNP.pendingAoEAuras) do + if type(pending) == "table" then return true end + end + return false +end + +local function CleanupPendingAoE(now) + local key, pending + for key, pending in pairs(BNP.pendingAoEAuras) do + if type(pending) == "table" and now - (pending.created or 0) > AOE_PENDING_TIMEOUT then + BNP.pendingAoEAuras[key] = nil + end + end +end + +local recentChannelAuras = {} + +local function ChannelKey(guid, spellID) + return tostring(guid or "-") .. ":" .. tostring(spellID or 0) +end + +local castEvents = CreateFrame("Frame") +castEvents:RegisterEvent("UNIT_CASTEVENT") +castEvents:SetScript("OnEvent", function() + local casterGUID = arg1 + local targetGUID = arg2 + local eventType = arg3 + local spellID = arg4 + local playerGUID = GetPlayerGUID() + + if not playerGUID or casterGUID ~= playerGUID then return end + + if eventType == "FAIL" then + RaidTrace("FAIL", targetGUID, "-", spellID) + FailPendingAura(targetGUID, spellID) + RollbackAuraAttemptBySpell(targetGUID, spellID, "UNIT_FAIL") + return + end + + -- Dark Harvest has its own channel implementation and must not enter the + -- generic aura pipeline below. + if eventType == "CHANNEL" and spellID == DARK_HARVEST_SPELL_ID then + StartDarkHarvest(targetGUID, arg5) + return + end + + if eventType ~= "CAST" and eventType ~= "CHANNEL" then return end + + -- Shadow audit only: this never changes the existing lookup or renderer. + if BNP.AuditSpellDB then BNP:AuditSpellDB(spellID) end + + -- Paladin Judgement is a generic cast (20271). The actual debuff ID is only + -- known after it lands on the target, so defer identification to UnitDebuff. + if playerClass == "PALADIN" and spellID == PALADIN_JUDGEMENT_CAST_ID then + if not targetGUID then return end + + if targetGUID then + local judgementKeys = {} + local _, judgementDef + for _, judgementDef in ipairs(AURA_DEFS) do + if IsJudgementDef(judgementDef) then judgementKeys[judgementDef.key] = true end + end + StartAuraCastAttempt(targetGUID, { key = "__judgement" }, spellID, judgementKeys) + end + + local pendingForGUID = BNP.pendingAuras[targetGUID] + if not pendingForGUID then + pendingForGUID = {} + BNP.pendingAuras[targetGUID] = pendingForGUID + end + + pendingForGUID.__judgement = { + judgementProbe = true, + created = GetTime(), + } + return + end + + -- Shadow Vulnerability uses one aura ID per talent rank. It is a shared + -- raid debuff and has its own any-Warlock path below, so none of its ranks + -- should enter the normal own-cast pending pipeline. + local sid = tonumber(spellID) + if playerClass == "WARLOCK" and (sid == 17794 or sid == 17797 or sid == 17798 or sid == 17799 or sid == 17800) then return end + + local def, texture = FindAuraDef(spellID) + if not def then return end + + -- A few SuperWoW channel paths can omit arg2 briefly. A channel cannot move to + -- a different target after it starts, so the real current target is a safe + -- fallback here (unlike blindly doing this for every normal CAST event). + if eventType == "CHANNEL" and not targetGUID then + targetGUID = GetTargetGUID() + end + + -- Channeled hostile effects are applied when SuperWoW emits CHANNEL, not when + -- the channel later ends. Some clients may additionally emit CAST at the end; + -- suppress that duplicate so the timer cannot restart after the channel. If a + -- server exposes only CAST, the fallback remains available. + if eventType == "CHANNEL" then + if not def.channel then return end + local channelMS = tonumber(arg5) or 0 + local hold = channelMS > 0 and (channelMS / 1000) or (def.duration or 1) + recentChannelAuras[ChannelKey(targetGUID, spellID)] = GetTime() + hold + 0.75 + elseif def.channel then + local untilTime = recentChannelAuras[ChannelKey(targetGUID, spellID)] + if untilTime and GetTime() <= untilTime then return end + end + + local duration = nil + -- Resolve the exact rank that was cast. This preserves tooltip-derived talent + -- or server modifications for that rank, while still falling back to the + -- explicit rank table when the exact spellbook slot cannot be resolved. + if BNP.GetSpellbookDurationForSpellID then + duration = BNP:GetSpellbookDurationForSpellID(def, spellID) + end + duration = duration or (def.durations and def.durations[spellID]) + if not duration and BNP.GetSpellbookDurationForDef then + duration = BNP:GetSpellbookDurationForDef(def) + end + duration = duration or def.duration + + if def.aoe then + QueueAoEAura(def, spellID, texture, duration) + return + end + + if not targetGUID then return end + + StartAuraCastAttempt(targetGUID, def, spellID) + + local pendingForGUID = BNP.pendingAuras[targetGUID] + if not pendingForGUID then + pendingForGUID = {} + BNP.pendingAuras[targetGUID] = pendingForGUID + end + + pendingForGUID[def.key] = { + def = def, + spellID = spellID, + texture = texture or def.texture, + duration = duration, + created = GetTime(), + } + + RaidTrace("CAST", targetGUID, def.key, spellID) + + if IsCastPrimary(def) then + local direct = { + def = def, + spellID = spellID, + texture = texture or def.texture, + duration = duration, + created = GetTime(), + stacks = 0, + castPrimary = true, + } + CommitPendingAura(targetGUID, def.key, direct) + local cache = BNP.guidAuras and BNP.guidAuras[targetGUID] + if cache and cache[def.key] then + cache[def.key].castPrimary = true + cache[def.key].castPrimaryAt = GetTime() + cache[def.key].liveConfirmed = nil + end + pendingForGUID[def.key] = nil + RaidTrace("DIRECT", targetGUID, def.key, spellID) + return + end + + -- Fast path: if the cast target is still our current target, try to confirm + -- immediately. The normal pending loop remains as the retry path. + local currentTargetGUID = GetTargetGUID() + if currentTargetGUID and currentTargetGUID == targetGUID then + ConfirmPendingForUnit("target", targetGUID, GetTime()) + end +end) + + + +-- Paladin Judgement melee refresh ------------------------------------------ +-- Vanilla Judgement debuffs refresh when the judging Paladin lands a melee +-- strike on that target. The aura itself does not get re-cast, so UNIT_CASTEVENT +-- cannot update our timer. Refresh only an already-confirmed OWN Judgement. +local function RefreshOwnJudgementForGUID(guid) + if playerClass ~= "PALADIN" or not guid then return end + + local cache = BNP.guidAuras[guid] + if not cache then return end + + local _, def + for _, def in ipairs(AURA_DEFS) do + if IsJudgementDef(def) then + local aura = cache[def.key] + if type(aura) == "table" and aura.expires then + local duration = aura.duration or def.duration or 10 + aura.duration = duration + aura.expires = GetTime() + duration + aura.confirmedAt = GetTime() + aura.missingScans = nil + end + end + end +end + +-- SuperWoW gives us the target GUID of the player's current target. For normal +-- auto-attacks, the combat text event is sufficient to know a successful melee +-- hit occurred; matching the current target GUID keeps the refresh scoped to +-- the unit actually being attacked. +local judgementMeleeFrame = CreateFrame("Frame") +judgementMeleeFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_HITS") +judgementMeleeFrame:SetScript("OnEvent", function() + if playerClass ~= "PALADIN" then return end + + local guid = GetTargetGUID() + if not guid then return end + + -- CHAT_MSG_COMBAT_SELF_HITS only fires for landed melee attacks (hit/crit). + RefreshOwnJudgementForGUID(guid) +end) + + + +-- Holy Strike also counts as a melee-style Judgement refresh on Turtle/Octo. +-- It is reported via CHAT_MSG_SPELL_SELF_DAMAGE rather than the normal melee +-- hit event and does not expose a useful spell ID in this client. +local holyStrikeRefreshFrame = CreateFrame("Frame") +holyStrikeRefreshFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") +holyStrikeRefreshFrame:SetScript("OnEvent", function() + if playerClass ~= "PALADIN" then return end + + local raw = arg1 + if not raw then return end + + -- Only successful Holy Strike hit messages refresh the timer. + -- Miss/resist/immune lines do not match this pattern. + if not string.find(raw, "^Your Holy Strike hits ") then return end + + local guid = GetTargetGUID() + if not guid then return end + + -- Extra safety: when the target name is available, require it to be present + -- in the combat-log line so a stale/current target cannot refresh another GUID. + local targetName = UnitName("target") + if targetName and targetName ~= "" and not string.find(raw, targetName, 1, true) then + return + end + + RefreshOwnJudgementForGUID(guid) +end) + + +-- Enemy Feign Death / Vanish protection. +local pvpDisappearFrame = CreateFrame("Frame") +pvpDisappearFrame:RegisterEvent("UNIT_CASTEVENT") +pvpDisappearFrame:SetScript("OnEvent", function() + local casterGUID = arg1 + local eventType = arg3 + local spellID = arg4 + + if not casterGUID or not spellID or not SpellInfo then return end + if eventType ~= "CAST" and eventType ~= "START" then return end + + local spellName = SpellInfo(spellID) + + -- Abilities that can temporarily drop target/nameplate visibility without + -- actually cleansing existing harmful effects. + local TEMPORARY_DISAPPEAR = { + ["Feign Death"] = true, + ["Vanish"] = true, + ["Shadowmeld"] = true, + } + + if spellName and TEMPORARY_DISAPPEAR[spellName] then + ProtectAuraCache(casterGUID, spellName) + end +end) + + + +-- Shared/global aura exceptions --------------------------------------------- +-- Most BNP debuffs are strictly "own casts only". A very small number of +-- effects are globally unique on a target and are useful even when applied by +-- another player. These are explicitly opted in via def.shared = true. +local LIVE_DEBUFF_SNAPSHOT = { entries = {}, count = 0, time = 0 } + +-- Capture UnitDebuff once and reuse that exact snapshot for shared-aura sync +-- and removal sync in the same 0.10s scan. This avoids scanning the same +-- SuperWoW unit twice (or more) per plate while keeping behavior identical. +local function CaptureLiveDebuffSnapshot(unit, now) + local snap = LIVE_DEBUFF_SNAPSHOT + local entries = snap.entries + local count = 0 + local i + + snap.time = now or GetTime() + + for i = 1, 64 do + local a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = UnitDebuff(unit, i) + if not a1 then break end + + count = count + 1 + local entry = entries[count] + if not entry then + entry = {} + entries[count] = entry + end + + entry[1] = a1 + entry[2] = a2 + entry[3] = a3 + entry[4] = a4 + entry[5] = a5 + entry[6] = a6 + entry[7] = a7 + entry[8] = a8 + entry[9] = a9 + entry[10] = a10 + end + + snap.count = count + return snap +end + +local function ClassicAPIAuraDataAvailable() + return type(C_UnitAuras) == "table" and type(C_UnitAuras.GetDebuffDataByIndex) == "function" +end + +-- ClassicAPI exposes the real hostile-aura duration and absolute expiration. +-- Use it only for the isolated shared Shadow Vulnerability path; all normal own +-- debuffs keep their existing proven tracking pipeline unchanged. +local function FindClassicAPISharedAura(unit, def) + if not ClassicAPIAuraDataAvailable() then return nil, nil, nil, nil, nil, false end + + local i + for i = 1, 64 do + local ok, aura = pcall(C_UnitAuras.GetDebuffDataByIndex, unit, i) + if not ok then return nil, nil, nil, nil, nil, false end + if not aura then break end + + local spellID = tonumber(aura.spellId or aura.spellID) + local matches = false + if def.sharedSpellIDs and spellID then + local _, knownID + for _, knownID in ipairs(def.sharedSpellIDs) do + if spellID == knownID then + matches = true + break + end + end + end + if not matches and aura.name then matches = NameMatches(def, aura.name) end + + if matches then + return spellID, aura.icon, tonumber(aura.duration), tonumber(aura.expirationTime), + tonumber(aura.applications) or 0, true + end + end + + -- A successful empty ClassicAPI scan is authoritative for absence. The + -- existing short missing grace protects against transient token updates. + return nil, nil, nil, nil, nil, true +end + +local function FindSharedAuraOnUnit(unit, def, snapshot) + if not unit or not def then return nil, nil, nil, nil, nil, false end + + if def.key == "shadow_vulnerability" then + local spellID, texture, duration, expires, stacks, authoritative = + FindClassicAPISharedAura(unit, def) + if authoritative then return spellID, texture, duration, expires, stacks, true end + end + + local maxIndex = snapshot and snapshot.count or 64 + local now = snapshot and snapshot.time or GetTime() + local i + for i = 1, maxIndex do + local a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 + + if snapshot then + local entry = snapshot.entries[i] + if not entry then break end + a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = + entry[1], entry[2], entry[3], entry[4], entry[5], + entry[6], entry[7], entry[8], entry[9], entry[10] + else + a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = UnitDebuff(unit, i) + if not a1 then break end + end + + local texture = a1 + local auraSpellID = a4 + if not texture then break end + + local auraName = nil + if auraSpellID and SpellInfo then + auraName = SpellInfo(auraSpellID) + end + + local sharedMatches = nil + if def.sharedSpellIDs and auraSpellID then + sharedMatches = false + local _, sharedSpellID + for _, sharedSpellID in ipairs(def.sharedSpellIDs) do + if auraSpellID == sharedSpellID then + sharedMatches = true + break + end + end + end + + -- Some different class talents use the same localized aura name. Shared + -- effects may therefore opt into exact aura IDs instead of name matching. + if sharedMatches == true or (sharedMatches == nil and AuraMatches(def, auraName, texture)) then + -- SuperWoW/extended clients may append duration/expiration data. + -- Probe plausible numeric return pairs without assuming one exact fork. + local duration, expires + local vals = { a5, a6, a7, a8, a9, a10 } + local n + for n = 1, table.getn(vals) - 1 do + local d = vals[n] + local e = vals[n + 1] + if type(d) == "number" and type(e) == "number" + and d > 0 and d <= 3600 and e > now then + duration = d + expires = e + break + end + end + + return auraSpellID, texture, duration, expires, tonumber(a2) or 0, false + end + end + + return nil, nil, nil, nil, nil, false +end + +local SV_SHARED_MISSING_GRACE = 0.45 + +local function SyncShadowVulnerabilityPresence(unit, guid, now, def, auraSpellID, texture, liveStacks, liveDuration, liveExpires, classicAPIAuthoritative) + local cache = BNP.guidAuras[guid] + local state = BNP.svStateByGUID[guid] + + -- ClassicAPI can keep an expired AuraData row around briefly before the + -- next UNIT_AURA cleanup. A positive expirationTime in the past is proof + -- that this row is stale; never turn it into a fresh estimated 10 seconds. + -- A newer RAW_COMBATLOG application may temporarily outrun the API update, + -- so preserve that independently proven future expiration when present. + local previousExpires = state and tonumber(state.expires) or nil + local explicitExpired = liveExpires and liveExpires > 0 and liveExpires <= now + local provenFuture = state and state.provenExpires and state.provenExpires > now + if explicitExpired and not provenFuture then + BNP.svStateByGUID[guid] = nil + BNP.svExpiryByGUID[guid] = nil + if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end + if cache then cache[def.key] = nil end + return + end + + -- Some ClassicAPI builds replace expirationTime with 0/nil while retaining + -- the expired AuraData row for another update or two. Once ClassicAPI has + -- answered authoritatively, never manufacture a timer from that incomplete + -- row. Keep only an already-known future expiry or a proven raw reapply. + local classicRowWithoutFutureExpiry = classicAPIAuthoritative and + (auraSpellID or texture) and not (liveExpires and liveExpires > now) + local previousFuture = previousExpires and previousExpires > now + if classicRowWithoutFutureExpiry and not provenFuture and not previousFuture then + BNP.svStateByGUID[guid] = nil + BNP.svExpiryByGUID[guid] = nil + if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end + if cache then cache[def.key] = nil end + return + end + + if auraSpellID or texture then + if not state then + state = { + present = true, + lastSeen = now, + } + BNP.svStateByGUID[guid] = state + end + + state.present = true + state.lastSeen = now + state.duration = (liveDuration and liveDuration > 0 and liveDuration) or state.duration or def.duration or 10 + + if state.provenExpires and state.provenExpires <= now then state.provenExpires = nil end + local apiExpires = liveExpires and liveExpires > now and liveExpires or nil + local chosenExpires = apiExpires + if state.provenExpires and (not chosenExpires or state.provenExpires > chosenExpires) then + chosenExpires = state.provenExpires + end + if not chosenExpires and previousExpires and previousExpires > now then chosenExpires = previousExpires end + if not chosenExpires then + chosenExpires = now + state.duration + state.estimated = true + else + state.estimated = nil + end + + state.expires = chosenExpires + state.apiExpires = apiExpires + if previousExpires and chosenExpires > previousExpires + 0.05 then + state.lastRefresh = now + BNP.svRenderRevisionByGUID[guid] = (BNP.svRenderRevisionByGUID[guid] or 0) + 1 + state.renderRevision = BNP.svRenderRevisionByGUID[guid] + RaidTrace("SV_CLASSICAPI_REFRESH", guid, def.key, auraSpellID or 17794) + end + + state.spellID = auraSpellID or state.spellID or 17794 + state.texture = texture or state.texture or def.texture + state.stacks = tonumber(liveStacks) or state.stacks or 0 + BNP.svExpiryByGUID[guid] = state.expires + + if not cache then + cache = {} + BNP.guidAuras[guid] = cache + end + local aura = cache[def.key] + if not aura then + cache._nextOrder = (cache._nextOrder or 0) + 1 + aura = { order = cache._nextOrder } + cache[def.key] = aura + end + + aura.sharedLive = true + aura.spellID = state.spellID + aura.texture = state.texture + aura.stacks = state.stacks + aura.duration = state.duration + aura.expires = state.expires + aura.confirmedAt = aura.confirmedAt or now + aura.sharedLastSeen = now + aura.sharedEstimated = state.estimated and true or nil + return + end + + -- One empty SuperWoW/UnitDebuff snapshot is not authoritative. Only remove + -- the shared state after no token has positively seen it for a short grace. + if state and now - (state.lastSeen or 0) >= SV_SHARED_MISSING_GRACE then + BNP.svStateByGUID[guid] = nil + BNP.svExpiryByGUID[guid] = nil + if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end + if cache then cache[def.key] = nil end + end +end + +local function SyncSharedAuras(unit, guid, now, snapshot) + if not unit or not guid then return end + + local _, def + for _, def in ipairs(AURA_DEFS) do + if def.shared then + local auraSpellID, texture, liveDuration, liveExpires, liveStacks, classicAPIAuthoritative = + FindSharedAuraOnUnit(unit, def, snapshot) + + -- Shadow Vulnerability is deliberately isolated from the generic shared + -- aura timer path. Its GUID state is the only owner of its countdown. + if def.key == "shadow_vulnerability" then + SyncShadowVulnerabilityPresence( + unit, guid, now, def, auraSpellID, texture, liveStacks, liveDuration, + liveExpires, classicAPIAuthoritative + ) + else + local cache = BNP.guidAuras[guid] + + if auraSpellID or texture then + if not cache then + cache = {} + BNP.guidAuras[guid] = cache + end + + local aura = cache[def.key] + -- Never overwrite an accurately timed own aura with the shared/live + -- fallback. Shared entries exist only when we don't own the timer. + if not aura or aura.sharedLive then + if not aura then + cache._nextOrder = (cache._nextOrder or 0) + 1 + aura = { order = cache._nextOrder } + end + + aura.sharedLive = true + aura.spellID = auraSpellID + aura.texture = texture or def.texture + aura.confirmedAt = now + aura.sharedLastSeen = now + + local previousStacks = tonumber(aura.stacks) or 0 + local currentStacks = tonumber(liveStacks) or 0 + if currentStacks > previousStacks and aura.expires then + aura.duration = def.duration or aura.duration or 10 + aura.expires = now + aura.duration + aura.sharedEstimated = true + RaidTrace("SHARED_STACK_REFRESH", guid, def.key, auraSpellID) + end + aura.stacks = currentStacks + + if liveExpires and liveExpires > now then + aura.duration = liveDuration + aura.expires = liveExpires + aura.sharedEstimated = nil + elseif not aura.expires then + aura.duration = def.duration or 120 + aura.expires = now + aura.duration + aura.sharedEstimated = true + end + + cache[def.key] = aura + end + elseif cache and cache[def.key] and cache[def.key].sharedLive then + local aura = cache[def.key] + local lastSeen = aura.sharedLastSeen or aura.confirmedAt or 0 + if now - lastSeen >= 0.40 then cache[def.key] = nil end + end + end + end + end +end + + +-- Live aura removal --------------------------------------------------------- +-- Timers alone are not enough for CC/roots/traps: effects may be dispelled, +-- broken by damage, removed by trinkets, or otherwise end early. +-- +-- We NEVER create auras from this scan. It may only remove already-confirmed +-- own auras from the GUID cache when their exact spell ID is no longer present. +local REMOVAL_SCAN_INTERVAL = 0.10 +local REMOVAL_GRACE = 0.35 +local NORMAL_REMOVAL_MISSING_SCANS = 4 +-- Projected non-target nameplate tokens can briefly return an incomplete +-- UnitDebuff list when many plates overlap in raids. CC used to be deleted on +-- the first such miss, which made Banish/roots/stuns blink. Require a few +-- consecutive misses for non-target CC while keeping the real target strict. +local EXACT_NON_TARGET_MISSING_SCANS = 3 + +local function CacheHasTrackedAuras(cache) + if not cache then return false end + local key, aura + for key, aura in pairs(cache) do + if type(aura) == "table" and aura.spellID and aura.expires then + return true + end + end + return false +end + +local CHANNEL_LIVE_REMOVAL = { + drain_life=true, drain_soul=true, + mind_flay=true, starshards=true, mind_control=true, +} + +local EXACT_LIVE_REMOVAL = { + -- Warlock + fear=true, howl_of_terror=true, banish=true, death_coil=true, + -- Hunter + freezing_trap_effect=true, improved_wing_clip=true, improved_concussive_shot=true, + scatter_shot=true, wyvern_sting=true, entrapment=true, counterattack=true, + intimidation=true, scare_beast=true, + -- Mage + polymorph=true, frost_nova=true, counterspell_silence=true, impact=true, frostbite=true, + -- Priest + psychic_scream=true, silence=true, blackout=true, shackle_undead=true, mind_control=true, + -- Paladin + hammer_of_justice=true, repentance=true, turn_undead=true, + -- Rogue + gouge=true, kidney_shot=true, cheap_shot=true, sap=true, blind=true, kick_silenced=true, + -- Druid + entangling_roots=true, bash=true, pounce=true, hibernate=true, feral_charge_effect=true, + -- Warrior + improved_hamstring=true, intimidating_shout=true, disarm=true, charge_stun=true, + intercept_stun=true, concussion_blow=true, revenge_stun=true, pummel=true, shield_bash=true, + -- Shaman / custom roots + earthgrab=true, +} + +local function NeedsExactLiveRemoval(key) + return EXACT_LIVE_REMOVAL[key] and true or false +end + +local function IsCrowdControlDef(def) + return def and EXACT_LIVE_REMOVAL[def.key] and true or false +end + +local function GetEntryIconSize(entry, forceCC) + if forceCC or (entry and IsCrowdControlDef(entry.def)) then + return GetCCIconSize() + end + return GetIconSize() +end + +local function ApplyIconDimensions(icon, size) + if not icon or not size then return end + if icon.BNPLayoutSize ~= size then + icon:SetWidth(size) + icon:SetHeight(size) + icon.BNPLayoutSize = size + end +end + +local function LayoutActiveIcons(container, active, visibleCount, forceCC) + if not container then return end + local position = forceCC and GetCCPosition() or GetDebuffPosition() + local sizes = {} + local totalWidth = 0 + local maxSize = 0 + local i + + for i = 1, visibleCount do + local size = GetEntryIconSize(active[i], forceCC) + sizes[i] = size + totalWidth = totalWidth + size + if size > maxSize then maxSize = size end + end + if visibleCount > 1 then totalWidth = totalWidth + ICON_SPACING * (visibleCount - 1) end + + local maxConfigured = math.max(GetIconSize(), GetCCIconSize()) + local maxWidth = maxConfigured * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + container:SetWidth(maxWidth) + container:SetHeight(maxSize > 0 and maxSize or maxConfigured) + + local cursor + if position == "left" then + cursor = 0 + for i = 1, visibleCount do + local icon = container.icons[i] + local size = sizes[i] + ApplyIconDimensions(icon, size) + icon:ClearAllPoints() + icon:SetPoint("RIGHT", container, "RIGHT", -cursor, 0) + cursor = cursor + size + ICON_SPACING + end + elseif position == "right" then + cursor = 0 + for i = 1, visibleCount do + local icon = container.icons[i] + local size = sizes[i] + ApplyIconDimensions(icon, size) + icon:ClearAllPoints() + icon:SetPoint("LEFT", container, "LEFT", cursor, 0) + cursor = cursor + size + ICON_SPACING + end + else + cursor = (maxWidth - totalWidth) / 2 + for i = 1, visibleCount do + local icon = container.icons[i] + local size = sizes[i] + ApplyIconDimensions(icon, size) + icon:ClearAllPoints() + icon:SetPoint("LEFT", container, "LEFT", cursor, 0) + cursor = cursor + size + ICON_SPACING + end + end +end + +local function ShouldDisplayAuraDef(def) + if IsCrowdControlDef(def) then + if BNP.AreCrowdControlEnabled and not BNP:AreCrowdControlEnabled() then return false end + if UseDedicatedCCContainer() then return false end + return true + end + return not BNP.AreDebuffsEnabled or BNP:AreDebuffsEnabled() +end + + +-- Separate CC display ------------------------------------------------------- +-- This layer is intentionally independent from guidAuras. It may display live +-- CCs from other players, but it can never create, refresh, remove or alter an +-- own DoT/debuff timer. +local GLOBAL_CC_DEFS = {} +local GLOBAL_CC_BY_ID = {} +local GLOBAL_CC_NEGATIVE = {} +local seenGlobalCC = {} + +local function BuildGlobalCCDefs() + local _, defs, _, ccDef, _, sid + for _, defs in pairs(CLASS_AURAS) do + for _, ccDef in ipairs(defs or {}) do + if IsCrowdControlDef(ccDef) and not seenGlobalCC[ccDef.key] then + seenGlobalCC[ccDef.key] = true + table.insert(GLOBAL_CC_DEFS, ccDef) + if ccDef.spellIDs then + for _, sid in ipairs(ccDef.spellIDs) do GLOBAL_CC_BY_ID[sid] = ccDef end + end + if ccDef.durations then + for sid in pairs(ccDef.durations) do GLOBAL_CC_BY_ID[sid] = ccDef end + end + end + end + end +end +BuildGlobalCCDefs() + +local function ResolveGlobalCCDef(spellID, texture) + if spellID and GLOBAL_CC_BY_ID[spellID] then return GLOBAL_CC_BY_ID[spellID] end + if spellID and GLOBAL_CC_NEGATIVE[spellID] then return nil end + + local auraName = spellID and SpellInfo and SpellInfo(spellID) or nil + local _, ccDef + for _, ccDef in ipairs(GLOBAL_CC_DEFS) do + if NameMatches(ccDef, auraName) + or (texture and ccDef.textureMatch and string.find(string.lower(texture), ccDef.textureMatch)) then + if spellID then GLOBAL_CC_BY_ID[spellID] = ccDef end + return ccDef + end + end + + if spellID then GLOBAL_CC_NEGATIVE[spellID] = true end + return nil +end + +local function WantsForeignCCs() + return BNP.AreCrowdControlEnabled and BNP:AreCrowdControlEnabled() + and BNP.ShowOtherPlayersCCs and BNP:ShowOtherPlayersCCs() +end + +local function SyncForeignCCs(unit, guid, now) + if not unit or not guid then return end + if not WantsForeignCCs() then + BNP.guidLiveCCs[guid] = nil + return + end + + local seen = {} + local live = BNP.guidLiveCCs[guid] + local i + for i = 1, 64 do + local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) + if not texture then break end + + local ccDef = ResolveGlobalCCDef(auraSpellID, texture) + if ccDef then + seen[ccDef.key] = true + if not live then + live = {} + BNP.guidLiveCCs[guid] = live + end + + local aura = live[ccDef.key] + if not aura then + aura = { firstSeen = now, order = now } + live[ccDef.key] = aura + end + aura.def = ccDef + aura.spellID = auraSpellID + aura.texture = texture or ccDef.texture + aura.stacks = tonumber(stacks) or 0 + aura.lastSeen = now + aura.missingScans = nil + -- Vanilla/SuperWoW does not reliably provide foreign application time. + -- Use a conservative local estimate for text only; live aura presence is + -- authoritative for appearance/removal. + if not aura.expires and ccDef.duration then + aura.expires = now + ccDef.duration + end + end + end + + if live then + local key, aura + for key, aura in pairs(live) do + if not seen[key] then + aura.missingScans = (aura.missingScans or 0) + 1 + -- A single empty projected UnitDebuff scan is common with tightly + -- stacked raid nameplates. Keep the last positive CC sighting through + -- two misses so foreign Banish/CC does not visibly blink. + if aura.missingScans >= 3 and now - (aura.lastSeen or 0) >= 0.40 then + live[key] = nil + end + else + aura.missingScans = nil + end + end + end +end + +local function HideCCRow(plate) + local container = plate and plate.BNPCCContainer + if not container then return end + local i + for i = 1, table.getn(container.icons or {}) do + local icon = container.icons[i] + icon.lastTimerText = nil + icon.timer:SetText("") + if icon.stack then icon.stack:SetText("") end + icon:Hide() + end + container:Hide() +end + +local function FormatCCTimer(remaining) + if not remaining or remaining <= 0 then return "" end + if remaining >= 3600 then + return math.ceil(remaining / 3600) .. "h" + elseif remaining >= 60 then + return math.ceil(remaining / 60) .. "m" + end + return tostring(math.ceil(remaining)) +end + +local function UpdateCCRow(plate, guid, cache, now) + if not UseDedicatedCCContainer() + or (BNP.AreCrowdControlEnabled and not BNP:AreCrowdControlEnabled()) then + HideCCRow(plate) + return + end + + if not plate.BNPCCContainer then CreateCCContainer(plate) end + local container = plate.BNPCCContainer + if not container then return end + + local active = container.activeAuras + if not active then active = {}; container.activeAuras = active end + local pool = container.activeAuraPool + if not pool then + pool = {} + for i = 1, MAX_VISIBLE_ICONS do pool[i] = {} end + container.activeAuraPool = pool + end + local oldCount = table.getn(active) + for i = 1, oldCount do active[i] = nil end + local count = 0 + local seenOwn = {} + + local _, ccDef + for _, ccDef in ipairs(AURA_DEFS) do + if IsCrowdControlDef(ccDef) then + local aura = cache and cache[ccDef.key] + local remaining = aura and aura.expires and (aura.expires - now) or nil + local sharedLive = aura and aura.sharedLive + if aura and (sharedLive or (remaining and remaining > 0)) then + count = count + 1 + if count <= MAX_VISIBLE_ICONS then + local entry = pool[count] + active[count] = entry + entry.def = ccDef + entry.aura = aura + entry.remaining = remaining + seenOwn[ccDef.key] = true + end + end + end + end + + if WantsForeignCCs() then + local live = guid and BNP.guidLiveCCs[guid] + local key, aura + for key, aura in pairs(live or {}) do + if count >= MAX_VISIBLE_ICONS then break end + if aura.def and not seenOwn[key] and now - (aura.lastSeen or 0) <= 0.60 then + count = count + 1 + local entry = pool[count] + active[count] = entry + entry.def = aura.def + entry.aura = aura + entry.remaining = aura.expires and (aura.expires - now) or nil + end + end + end + + table.sort(active, function(a, b) + return (a.aura.order or a.aura.firstSeen or 0) < (b.aura.order or b.aura.firstSeen or 0) + end) + + local visibleCount = table.getn(active) + local iconSize = GetCCIconSize() + local position = GetCCPosition() + local layoutChanged = container.BNPLastVisibleCount ~= visibleCount + or container.BNPLastCCIconSize ~= iconSize + or container.BNPLastPosition ~= position + if layoutChanged then + container.BNPLastVisibleCount = visibleCount + container.BNPLastCCIconSize = iconSize + container.BNPLastPosition = position + if visibleCount > 0 then LayoutActiveIcons(container, active, visibleCount, true) end + end + + local i + for i = 1, table.getn(container.icons) do + local icon = container.icons[i] + local entry = active[i] + if entry then + icon.texture:SetTexture(entry.aura.texture or entry.def.texture) + local stackCount = tonumber(entry.aura.stacks) or 0 + if icon.stack then + if stackCount > 1 then icon.stack:SetText(tostring(stackCount)) else icon.stack:SetText("") end + end + local timerText = FormatCCTimer(entry.remaining) + if icon.lastTimerText ~= timerText then icon.timer:SetText(timerText); icon.lastTimerText = timerText end + icon:Show() + else + icon.lastTimerText = nil + icon.timer:SetText("") + if icon.stack then icon.stack:SetText("") end + icon:Hide() + end + end + + if visibleCount > 0 then container:Show() else container:Hide() end +end + +local function SyncAuraRemoval(unit, guid, now, snapshot) + if not unit or not guid then return end + if AuraCacheProtected(guid, now) then return end + local cache = BNP.guidAuras[guid] + if not CacheHasTrackedAuras(cache) then return end + + local present = {} + local i + local maxIndex = snapshot and snapshot.count or 64 + for i = 1, maxIndex do + local texture, stacks, dtype, auraSpellID + if snapshot then + local entry = snapshot.entries[i] + if not entry then break end + texture, stacks, dtype, auraSpellID = entry[1], entry[2], entry[3], entry[4] + else + texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) + if not texture then break end + end + + if auraSpellID then + present[auraSpellID] = true + + local trackedKey, trackedAura + for trackedKey, trackedAura in pairs(cache) do + if type(trackedAura) == "table" and trackedAura.spellID == auraSpellID then + trackedAura.stacks = tonumber(stacks) or 0 + end + end + end + end + + -- Resolve current-target identity once per scan, not once per tracked aura. + -- Knowing the target GUID for projected units also lets exact CC removal stay + -- immediate on the real target but conservative on crowded non-target plates. + local currentTargetGUID = GetTargetGUID() + local playerRemoval = false + if unit == "target" and UnitIsPlayer and UnitIsPlayer("target") then + playerRemoval = currentTargetGUID and currentTargetGUID == guid + end + + local key, aura + for key, aura in pairs(cache) do + if type(aura) == "table" and aura.spellID and aura.expires and key ~= "shadow_vulnerability" then + -- Give the client a tiny grace period immediately after confirmation so + -- transient aura-list updates cannot remove a freshly-landed effect. + local age = now - (aura.confirmedAt or 0) + local exactRemoval = NeedsExactLiveRemoval(key) + + -- Normal DoTs on player nameplates are only safe to live-remove from the + -- CURRENT target. During target switches, non-target SuperWoW nameplate + -- tokens can briefly expose an incomplete/empty UnitDebuff list. Treating + -- that as a dispel caused valid DoTs to disappear from the GUID cache. + -- + -- CC/root/trap effects still use live removal on every visible plate. The + -- real target is immediate; projected non-targets require a few misses so + -- one crowded-raid scan cannot make Banish/CC blink. + -- Channeled debuffs should end when the channel is interrupted/cancelled, + -- but only trust the real current-target UnitDebuff list for this. Busy + -- non-target nameplate tokens remain intentionally non-authoritative. + local channelRemoval = CHANNEL_LIVE_REMOVAL[key] + and currentTargetGUID and currentTargetGUID == guid + + if age >= REMOVAL_GRACE then + if present[aura.spellID] then + -- Positive live sighting upgrades a CAST-seeded timer. Once upgraded, + -- normal dispel/early-removal logic is safe again. + aura.missingScans = nil + aura.exactMissingScans = nil + if aura.castPrimary then + aura.castPrimary = nil + aura.castPrimaryAt = nil + aura.liveConfirmed = true + end + elseif channelRemoval then + RaidTrace("CHANNEL_REMOVE", guid, key, aura.spellID) + cache[key] = nil + elseif exactRemoval then + if currentTargetGUID and currentTargetGUID == guid then + RaidTrace("REMOVE", guid, key, aura.spellID) + -- The real target unit is authoritative, so CC can still disappear + -- immediately when it is broken/dispelled. + cache[key] = nil + else + -- Non-target SuperWoW tokens can return one empty/incomplete aura + -- list while plates overlap. Do not turn that one bad sample into + -- a visible Banish/CC blink. + aura.exactMissingScans = (aura.exactMissingScans or 0) + 1 + if aura.exactMissingScans >= EXACT_NON_TARGET_MISSING_SCANS then + RaidTrace("REMOVE", guid, key, aura.spellID) + cache[key] = nil + end + end + elseif playerRemoval then + if aura.castPrimary then + -- CAST-seeded auras may be omitted by crowded raid UnitDebuff lists. + -- Do not use that same missing list to remove it early. + aura.missingScans = nil + else + -- Normal positively-confirmed player DoTs/curses use conservative + -- removal and still react to real dispels. + aura.missingScans = (aura.missingScans or 0) + 1 + + if aura.missingScans >= NORMAL_REMOVAL_MISSING_SCANS then + RaidTrace("REMOVE", guid, key, aura.spellID) + cache[key] = nil + end + end + end + end + end + end +end + +local function FormatTimer(remaining) + if not remaining or remaining <= 0 then return "" end + + -- Keep long durations compact on the small nameplate icons. + -- Examples: 300s -> 5m, 61s -> 2m, 59s -> 59. + if remaining >= 3600 then + return math.ceil(remaining / 3600) .. "h" + elseif remaining >= 60 then + return math.ceil(remaining / 60) .. "m" + end + + return tostring(math.ceil(remaining)) +end + +-- Nameplate GUID stability --------------------------------------------------- +-- Blizzard/SuperWoW can briefly recycle a visible plate while LOS/occlusion +-- changes (for example when a wall is between the player and nearby mobs). +-- During that tiny transition plate:GetName(1) may still expose the previous +-- unit GUID. Rendering that GUID's cache causes "ghost DoTs" on an unrelated +-- mob for a frame or two. +-- +-- Require the same GUID on two consecutive renderer updates before using its +-- aura cache. This does not change aura tracking itself; it only gates display. +local AURA_GUID_TRANSIENT_HOLD = 0.75 +local AURA_GUID_CONFIRM_UPDATES = 2 +local AURA_GUID_SWITCH_CONFIRM_UPDATES = 8 + +local function PinExactTargetPlate(plate, now) + if not plate or not C_NamePlate or + type(C_NamePlate.GetNamePlateForUnit) ~= "function" then + return nil + end + + local targetGUID = GetTargetGUID() + if not targetGUID then return nil end + + local ok, targetPlate = pcall(C_NamePlate.GetNamePlateForUnit, "target") + if not ok or not targetPlate or targetPlate ~= plate then return nil end + + -- The real target is the one projected unit ClassicAPI can resolve exactly. + -- Pinning it here prevents a crowded SuperWoW plate token from replacing the + -- target GUID for a few renderer ticks. When the player switches away, this + -- stable mapping remains useful for that now-non-target plate. + plate.BNPAuraGUIDStable = targetGUID + plate.BNPAuraGUIDCandidate = targetGUID + plate.BNPAuraGUIDCandidateCount = AURA_GUID_SWITCH_CONFIRM_UPDATES + plate.BNPAuraGUIDLastGoodAt = now + plate.BNPAuraForceFreshIdentity = nil + return targetGUID +end + +local function StableNameStillMatches(plate, stableGUID) + if not plate or not stableGUID or not plate.name or not plate.name.GetText then + return true + end + local oldName = BNP.guidNames and BNP.guidNames[stableGUID] + local visibleName = plate.name:GetText() + if oldName and visibleName and oldName ~= "" and visibleName ~= "" then + return oldName == visibleName + end + return true +end + +local function GetStablePlateGUID(plate, now) + if not plate then return nil end + now = now or GetTime() + + -- Exact current-target frame identity always wins over projected token data. + local exactTargetGUID = PinExactTargetPlate(plate, now) + if exactTargetGUID then return exactTargetGUID end + + local guid = GetPlateGUID(plate) + local stable = plate.BNPAuraGUIDStable + + -- Dense 40-player stacks can briefly lose the projected token. Keep the last + -- confirmed identity long enough to bridge those gaps instead of hiding the + -- whole icon row for one or two renderer ticks. + if not guid then + plate.BNPAuraGUIDCandidate = nil + plate.BNPAuraGUIDCandidateCount = 0 + if stable and plate.BNPAuraGUIDLastGoodAt and + now - plate.BNPAuraGUIDLastGoodAt <= AURA_GUID_TRANSIENT_HOLD and + not plate.BNPAuraForceFreshIdentity then + return stable + end + return nil + end + + if stable and guid == stable then + plate.BNPAuraGUIDCandidate = guid + plate.BNPAuraGUIDCandidateCount = AURA_GUID_SWITCH_CONFIRM_UPDATES + plate.BNPAuraGUIDLastGoodAt = now + plate.BNPAuraForceFreshIdentity = nil + return stable + end + + if plate.BNPAuraGUIDCandidate ~= guid then + plate.BNPAuraGUIDCandidate = guid + plate.BNPAuraGUIDCandidateCount = 1 + + -- If Blizzard really reused the frame for a differently named unit, switch + -- quickly. Same-name raid mobs are exactly where transient GUID/token swaps + -- happen, so keep the previous stable GUID until the new candidate persists. + if stable and not plate.BNPAuraForceFreshIdentity and + StableNameStillMatches(plate, stable) then + return stable + end + return nil + end + + plate.BNPAuraGUIDCandidateCount = (plate.BNPAuraGUIDCandidateCount or 1) + 1 + + local required = AURA_GUID_SWITCH_CONFIRM_UPDATES + if plate.BNPAuraForceFreshIdentity or + (stable and not StableNameStillMatches(plate, stable)) then + required = AURA_GUID_CONFIRM_UPDATES + end + + if plate.BNPAuraGUIDCandidateCount < required then + if stable and not plate.BNPAuraForceFreshIdentity and + StableNameStillMatches(plate, stable) then + return stable + end + return nil + end + + plate.BNPAuraGUIDStable = guid + plate.BNPAuraGUIDLastGoodAt = now + plate.BNPAuraForceFreshIdentity = nil + return guid +end + +local function UpdatePlate(plate, now) + if BNP.AreAnyAurasEnabled and not BNP:AreAnyAurasEnabled() then + if plate and plate.BNPAuraContainer then + local i + for i = 1, MAX_VISIBLE_ICONS do + local icon = plate.BNPAuraContainer.icons and plate.BNPAuraContainer.icons[i] + if icon then icon:Hide() end + end + plate.BNPAuraContainer:Hide() + end + HideCCRow(plate) + return + end + + if not plate or not plate:IsShown() then + HidePlateAuraFrames(plate) + return + end + if not plate.BNPAuraContainer then CreateAuraContainer(plate) end + + local container = plate.BNPAuraContainer + if not container then return end + + local guid = GetStablePlateGUID(plate, now) + if guid and plate.name and plate.name.GetText then + local visibleName = plate.name:GetText() + if visibleName and visibleName ~= "" then BNP.guidNames[guid] = visibleName end + end + local cache = guid and BNP.guidAuras[guid] or nil + now = now or GetTime() + + -- Reuse one small table per plate. Active auras are packed without gaps and + -- sorted by the order in which they were first applied to this GUID. + local active = container.activeAuras + if not active then + active = {} + container.activeAuras = active + end + local activePool = container.activeAuraPool + if not activePool then + activePool = {} + for i = 1, MAX_VISIBLE_ICONS do activePool[i] = {} end + container.activeAuraPool = activePool + end + local previousCount = table.getn(active) + for i = 1, previousCount do active[i] = nil end + local activeCount = 0 + + for _, def in ipairs(AURA_DEFS) do + if ShouldDisplayAuraDef(def) then + local aura = cache and cache[def.key] + local remaining = aura and aura.expires and (aura.expires - now) or nil + local sharedLive = aura and aura.sharedLive + local svRenderRevision = nil + + if def.key == "shadow_vulnerability" and guid then + local svState = BNP.svStateByGUID[guid] + if svState and svState.present then + -- Shadow Vulnerability rendering is owned entirely by svState. + -- Do not require the mirrored generic cache entry to already exist: + -- rebuild it from state if another cleanup path removed it. + if not cache then + cache = {} + BNP.guidAuras[guid] = cache + end + aura = cache[def.key] + if not aura then + cache._nextOrder = (cache._nextOrder or 0) + 1 + aura = { order = cache._nextOrder } + cache[def.key] = aura + end + + aura.sharedLive = true + aura.sharedEstimated = true + aura.spellID = svState.spellID or aura.spellID or 17794 + aura.texture = svState.texture or aura.texture or def.texture + aura.stacks = tonumber(svState.stacks) or aura.stacks or 0 + aura.duration = def.duration or 10 + aura.expires = svState.expires + + remaining = svState.expires and (svState.expires - now) or nil + sharedLive = true + svRenderRevision = BNP.svRenderRevisionByGUID[guid] or 0 + else + remaining = nil + sharedLive = false + aura = nil + end + end + + if sharedLive or (remaining and remaining > 0) then + if sharedLive and remaining and remaining <= 0 then + remaining = nil + end + if activeCount < MAX_VISIBLE_ICONS then + activeCount = activeCount + 1 + local entry = activePool[activeCount] + active[activeCount] = entry + entry.def = def + entry.aura = aura + entry.remaining = remaining + entry.svRenderRevision = svRenderRevision + end + elseif cache and aura then + cache[def.key] = nil + end + end + end + + if activeCount > 1 then + table.sort(active, function(a, b) + return (a.aura.order or 0) < (b.aura.order or 0) + end) + end + + local visibleCount = activeCount + local iconSize = GetIconSize() + local ccIconSize = GetCCIconSize() + local position = GetDebuffPosition() + local layoutMask = 0 + local maskValue = 1 + for i = 1, visibleCount do + if active[i] and IsCrowdControlDef(active[i].def) then + layoutMask = layoutMask + maskValue + end + maskValue = maskValue * 2 + end + + local layoutChanged = container.BNPLastVisibleCount ~= visibleCount + or container.BNPLastIconSize ~= iconSize + or container.BNPLastCCIconSize ~= ccIconSize + or container.BNPLastPosition ~= position + or container.BNPLastLayoutMask ~= layoutMask + if layoutChanged then + container.BNPLastVisibleCount = visibleCount + container.BNPLastIconSize = iconSize + container.BNPLastCCIconSize = ccIconSize + container.BNPLastPosition = position + container.BNPLastLayoutMask = layoutMask + if visibleCount > 0 then + -- CCs may share the normal row for Left/Right (and optionally Top), so + -- lay out each icon using its own configured size without touching tracking. + LayoutActiveIcons(container, active, visibleCount, false) + end + end + + for i = 1, table.getn(container.icons) do + local icon = container.icons[i] + local entry = active[i] + + if entry then + local texture = entry.aura.texture or entry.def.texture + if icon.BNPLastTexture ~= texture then + icon.texture:SetTexture(texture) + icon.BNPLastTexture = texture + end + + local stackCount = tonumber(entry.aura.stacks) or 0 + local stackText = stackCount > 1 and tostring(stackCount) or "" + if icon.stack and icon.BNPLastStackText ~= stackText then + icon.stack:SetText(stackText) + icon.BNPLastStackText = stackText + end + + local timerText = FormatTimer(entry.remaining) + if entry.def and entry.def.key == "shadow_vulnerability" then + local revision = entry.svRenderRevision or 0 + if icon.BNPSVRenderRevision ~= revision then + -- A confirmed proc/refresh happened. Invalidate the text cache so the + -- refreshed countdown is written by the normal renderer, never from + -- inside a combat-log event callback. + icon.lastTimerText = nil + icon.BNPSVRenderRevision = revision + end + icon.BNPSVRenderedExpires = entry.aura and entry.aura.expires or nil + icon.BNPSVRenderedRemaining = entry.remaining + else + icon.BNPSVRenderRevision = nil + icon.BNPSVRenderedExpires = nil + icon.BNPSVRenderedRemaining = nil + end + + if icon.lastTimerText ~= timerText then + icon.timer:SetText(timerText) + icon.lastTimerText = timerText + end + + icon:Show() + else + icon.lastTimerText = nil + icon.BNPLastTexture = nil + icon.BNPLastStackText = nil + icon.timer:SetText("") + if icon.stack then icon.stack:SetText("") end + icon:Hide() + end + end + + if visibleCount > 0 then + container:Show() + else + container:Hide() + end + + UpdateCCRow(plate, guid, cache, now) + ApplyAuraFrameLevel(plate, container) + ApplyAuraFrameLevel(plate, plate.BNPCCContainer) + ApplyTargetAuraPriority(plate, container) + ApplyTargetAuraPriority(plate, plate.BNPCCContainer) +end + + +function BNP:RefreshAuraLayout(plate) + if not plate then return end + local auraSize = GetIconSize() + local ccSize = GetCCIconSize() + local maxSize = math.max(auraSize, ccSize) + local width = maxSize * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + local containers = { plate.BNPAuraContainer, plate.BNPCCContainer } + local index, container + for index, container in ipairs(containers) do + if container then + ApplyAuraFrameLevel(plate, container) + local defaultSize = (index == 2) and ccSize or auraSize + container:SetWidth(width) + container:SetHeight(defaultSize) + container.BNPLastVisibleCount = nil + container.BNPLastIconSize = nil + container.BNPLastCCIconSize = nil + container.BNPLastPosition = nil + container.BNPLastLayoutMask = nil + local i + for i = 1, table.getn(container.icons or {}) do + ApplyIconDimensions(container.icons[i], defaultSize) + end + end + end +end + +function BNP:RefreshAllAuraLayouts() + local plate + for plate in pairs(BNP.plates or {}) do + if plate then + if plate.BNPAuraContainer then + AnchorAuraContainer(plate, plate.BNPAuraContainer, false) + end + if plate.BNPCCContainer then + AnchorAuraContainer(plate, plate.BNPCCContainer, true) + end + end + self:RefreshAuraLayout(plate) + end +end + +ScanSpellbook() +table.insert(BNP.libnameplate.OnInit, CreateAuraContainer) +table.insert(BNP.libnameplate.OnInit, CreateCCContainer) +table.insert(BNP.libnameplate.OnShow, CreateAuraContainer) +table.insert(BNP.libnameplate.OnShow, CreateCCContainer) + +-- Keep the proven central update loop and the existing icon implementation. +-- No CooldownFrame templates and no frames or tables are created here. +local renderer = CreateFrame("Frame") +local elapsedTotal = 0 +local removalElapsed = 0 +renderer:SetScript("OnUpdate", function() + elapsedTotal = elapsedTotal + arg1 + removalElapsed = removalElapsed + arg1 + if elapsedTotal < UPDATE_INTERVAL then return end + elapsedTotal = 0 + + local now = GetTime() + UpdateDarkHarvest(now) + RefreshTargetPriorityIdentity() + + CleanupAuraCastAttempts(now) + + local hasPending = HasPendingAuras() + local hasPendingAoE = HasPendingAoEAuras() + local doRemovalScan = removalElapsed >= REMOVAL_SCAN_INTERVAL + if doRemovalScan then removalElapsed = 0 end + + local targetGUID = nil + if hasPending or hasPendingAoE or doRemovalScan then + targetGUID = GetTargetGUID() + end + + if hasPending and targetGUID then + -- Current target is the most reliable confirmation source. + ConfirmPendingForUnit("target", targetGUID, now) + end + + if targetGUID then + CleanupDeadUnit("target", targetGUID, now) + end + + if doRemovalScan and targetGUID then + local targetSnapshot = nil + if HAS_SHARED_AURAS or CacheHasTrackedAuras(BNP.guidAuras[targetGUID]) then + targetSnapshot = CaptureLiveDebuffSnapshot("target", now) + end + + SyncSharedAuras("target", targetGUID, now, targetSnapshot) + + if BNP.guidAuras[targetGUID] then + SyncAuraRemoval("target", targetGUID, now, targetSnapshot) + end + end + + -- The normal renderer always runs. Extra GUID/token/UnitDebuff work is only + -- performed while pending casts exist or during the throttled removal scan. + for plate in pairs(BNP.plates) do + if plate:IsShown() and (hasPending or hasPendingAoE or doRemovalScan) then + local guid = GetPlateGUID(plate) + local token = GetUnitTokenForPlate(plate) + + if guid and token then + -- Do NOT call UnitIsDeadOrGhost() on SuperWoW nameplate tokens. + -- During target changes/recycling those tokens can briefly report an + -- invalid dead state and wipe the entire GUID aura cache. + local isCurrentTarget = targetGUID and targetGUID == guid + + -- The real "target" unit was already confirmed above and is more + -- authoritative than its projected nameplate token. Avoid duplicate + -- confirmation work for that same GUID. + if hasPending and not isCurrentTarget then + ConfirmPendingForUnit(token, guid, now) + end + + if hasPendingAoE then + ConfirmAoEAurasOnUnit(token, guid) + end + + if doRemovalScan and not isCurrentTarget then + -- Removal/shared scans are destructive and therefore must never use + -- a raw projected GUID while the renderer still considers the plate + -- identity unstable. This is the key raid-stack safeguard: one stale + -- SuperWoW token can no longer remove auras from the wrong GUID. + local stableGUID = plate.BNPAuraGUIDStable + local identityTrusted = stableGUID and stableGUID == guid + if identityTrusted then + local snapshot = nil + if HAS_SHARED_AURAS or CacheHasTrackedAuras(BNP.guidAuras[stableGUID]) then + snapshot = CaptureLiveDebuffSnapshot(token, now) + end + + SyncSharedAuras(token, stableGUID, now, snapshot) + + -- Non-target exact-removal also requires multiple misses below, so + -- crowded raid aura lists cannot make Banish/CC blink. + SyncAuraRemoval(token, stableGUID, now, snapshot) + end + end + end + end + + UpdatePlate(plate, now) + end + + if hasPending then + CleanupPending(now) + end + + if hasPendingAoE then + CleanupPendingAoE(now) + end +end) + + +-- Foreign CC scanner --------------------------------------------------------- +-- Runs on its own frame so this optional feature can never interrupt the proven +-- own-debuff renderer/update loop. +local FOREIGN_CC_SCAN_INTERVAL = 0.15 +local foreignCCElapsed = 0 +local foreignCCScanner = CreateFrame("Frame") +foreignCCScanner:SetScript("OnUpdate", function() + if not WantsForeignCCs() then return end + foreignCCElapsed = foreignCCElapsed + arg1 + if foreignCCElapsed < FOREIGN_CC_SCAN_INTERVAL then return end + foreignCCElapsed = 0 + + local now = GetTime() + local targetGUID = GetTargetGUID() + if targetGUID then pcall(SyncForeignCCs, "target", targetGUID, now) end + + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() then + local guid = GetPlateGUID(plate) + if guid and guid ~= targetGUID then + local token = GetUnitTokenForPlate(plate) + if token then pcall(SyncForeignCCs, token, guid, now) end + end + end + end +end) + + +-- Shadow Vulnerability [17794/17797/17798/17799/17800]: shared Warlock raid debuff ------------------- +-- Improved Shadow Bolt's Shadow Vulnerability belongs to the TARGET, not to a +-- single BNP player. If any Warlock procs any Shadow Vulnerability rank, every Warlock running BNP +-- should display the same aura. +-- +-- Tracking sources: +-- * SyncSharedAuras() is authoritative for appearance/removal and scans the +-- real target plus visible SuperWoW nameplate tokens every 0.10s. +-- * RAW_COMBATLOG is the primary refresh source for other Warlocks. SuperWoW +-- puts GUIDs into the raw combat text, so an actual Shadow Vulnerability +-- application can be tied to the exact target without guessing by name. +-- * UNIT_CASTEVENT for any SV rank remains as a harmless compatibility +-- fast-path for clients/servers that publish the proc itself as a cast event. +-- * Charge/stack increases are used as an additional live fallback. +-- +-- No caster ownership is stored or required here. +local SHADOW_VULNERABILITY_IDS = { + [17794] = true, -- Rank 1 + [17797] = true, -- Rank 2 + [17798] = true, -- Rank 3 + [17799] = true, -- Rank 4 + [17800] = true, -- Rank 5 +} + +local function IsShadowVulnerabilitySpellID(spellID) + local sid = tonumber(spellID) + if sid and SHADOW_VULNERABILITY_IDS[sid] then return true end + + -- Custom 1.12 servers may keep the visible aura on the classic ID while + -- publishing a hidden proc/cast event on a custom spell ID. Accept that + -- event by its exact localized spell name as well. + if sid and SpellInfo then + local name = SpellInfo(sid) + if name == "Shadow Vulnerability" or name == "Schattenverwundbarkeit" then + return true + end + end + return false +end + +-- Small diagnostic state for the one thing vanilla does not expose directly: +-- the remaining duration of hostile debuffs. This lets /bnp raidcheck show +-- whether a refresh produced any observable client-side signal (stack change, +-- UNIT_AURA, hidden UNIT_CASTEVENT, or changed UnitDebuff return values). +BNP.svProbe = BNP.svProbe or { events = {} } +BNP.svProbe.events = BNP.svProbe.events or {} +BNP.svProbe.raw = BNP.svProbe.raw or {} +local function SVProbeEvent(stage, guid, spellID, extra) + local t = BNP.svProbe.events + table.insert(t, { + time = GetTime(), stage = stage, guid = guid, spellID = spellID, extra = extra, + }) + while table.getn(t) > 18 do table.remove(t, 1) end +end + +local function SnapshotSVUnit(unit, guid, reason) + if not unit then return end + local i + for i = 1, 64 do + local a1,a2,a3,a4,a5,a6,a7,a8,a9,a10 = UnitDebuff(unit, i) + if not a1 then break end + if IsShadowVulnerabilitySpellID(a4) then + BNP.svProbe.last = { + time = GetTime(), reason = reason, unit = unit, guid = guid, index = i, + spellID = a4, stacks = a2, a3 = a3, a5 = a5, a6 = a6, a7 = a7, + a8 = a8, a9 = a9, a10 = a10, + } + return + end + end +end + +local function SVDef() + local i + for i = 1, table.getn(AURA_DEFS) do + if AURA_DEFS[i] and AURA_DEFS[i].key == "shadow_vulnerability" then + return AURA_DEFS[i] + end + end +end + +local function FindSharedShadowVulnerabilityByGUID(guid, def) + if not guid or not def then return nil, nil, nil, nil end + + local targetGUID = GetTargetGUID() + if targetGUID and targetGUID == guid then + local spellID, texture, duration, expires, stacks = FindSharedAuraOnUnit("target", def) + if spellID or texture then return spellID, texture, duration, expires, stacks end + end + + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() and GetPlateGUID(plate) == guid then + local token = GetUnitTokenForPlate(plate) + if token then + local spellID, texture, duration, expires, stacks = FindSharedAuraOnUnit(token, def) + if spellID or texture then return spellID, texture, duration, expires, stacks end + end + end + end + + return nil, nil, nil, nil, nil +end + +local function RefreshSharedShadowVulnerability(guid) + if playerClass ~= "WARLOCK" or not guid then return false end + local def = SVDef() + if not def then return false end + + local now = GetTime() + local liveSpellID, texture, liveDuration, liveExpires, liveStacks = + FindSharedShadowVulnerabilityByGUID(guid, def) + + BNP.guidAuras[guid] = BNP.guidAuras[guid] or {} + local cache = BNP.guidAuras[guid] + local aura = cache[def.key] + if not aura then + cache._nextOrder = (cache._nextOrder or 0) + 1 + aura = { order = cache._nextOrder } + cache[def.key] = aura + end + + aura.sharedLive = true + aura.sharedEstimated = nil + aura.spellID = liveSpellID or aura.spellID or 17794 + aura.texture = texture or aura.texture or def.texture + aura.confirmedAt = now + aura.stacks = tonumber(liveStacks) or aura.stacks or 0 + if liveSpellID or texture then aura.sharedLastSeen = now end + + -- A real RAW_COMBATLOG re-application is proof of a fresh duration. Prefer + -- ClassicAPI's absolute expiration when it has already updated; otherwise + -- keep the proven local now+duration value until the API catches up. + aura.duration = (liveDuration and liveDuration > 0 and liveDuration) or def.duration or 10 + local state = BNP.svStateByGUID[guid] + if not state then + state = {} + BNP.svStateByGUID[guid] = state + end + state.present = true + state.duration = aura.duration + state.provenExpires = now + aura.duration + state.rawRefreshAt = now + local apiExpires = liveExpires and liveExpires > now and liveExpires or nil + state.apiExpires = apiExpires + if apiExpires and apiExpires > state.provenExpires then + state.expires = apiExpires + else + state.expires = state.provenExpires + end + state.lastRefresh = now + BNP.svRenderRevisionByGUID[guid] = (BNP.svRenderRevisionByGUID[guid] or 0) + 1 + state.renderRevision = BNP.svRenderRevisionByGUID[guid] + if liveSpellID or texture then state.lastSeen = now end + state.spellID = liveSpellID or state.spellID or aura.spellID or 17794 + state.texture = texture or state.texture or aura.texture or def.texture + state.stacks = tonumber(liveStacks) or state.stacks or 0 + + BNP.svExpiryByGUID[guid] = state.expires + aura.spellID = state.spellID + aura.texture = state.texture + aura.stacks = state.stacks + aura.expires = state.expires + aura.sharedEstimated = nil + + RaidTrace("SV_SHARED_REFRESH", guid, def.key, aura.spellID or 17794) + return true +end + +-- SuperWoW's raw event contains the exact affected GUID. Unlike cast/channel +-- heuristics, an explicit "is afflicted by Shadow Vulnerability" line proves +-- that the proc really happened, including procs caused by another Warlock. +local svRawEvent = CreateFrame("Frame") +pcall(svRawEvent.RegisterEvent, svRawEvent, "RAW_COMBATLOG") +svRawEvent:SetScript("OnEvent", function() + if playerClass ~= "WARLOCK" or event ~= "RAW_COMBATLOG" then return end + local raw = tostring(arg2 or "") + local lower = string.lower(raw) + local hasName = string.find(lower, "shadow vulnerability", 1, true) or + string.find(lower, "schattenverwundbarkeit", 1, true) + if not hasName then return end + if not string.find(lower, "is afflicted", 1, true) and + not string.find(lower, "wird von", 1, true) then return end + + local _, _, guid = string.find(raw, "^(0x%x+)") + if not guid then return end + if RefreshSharedShadowVulnerability(guid) then + SVProbeEvent("SV_RAW_REFRESH", guid, 17794, "proven application") + RaidTrace("SV_RAW_REFRESH", guid, "shadow_vulnerability", 17794) + end +end) + +-- Shadow Vulnerability refresh: legacy compatibility fallback --------------- +-- +-- The original v1.0.5 build was the most reliable version on this client. +-- It did not try to prove the hidden proc itself. Instead, every successful +-- Shadow Bolt CAST / Drain Soul CHANNEL opened a very short verification +-- window. If Shadow Vulnerability was live on that exact target GUID, the +-- local countdown was restarted at 10 seconds. +-- +-- Keep that behavior only as a compatibility fallback when ClassicAPI aura +-- data is unavailable. ClassicAPI users must never refresh from a cast alone: +-- the report proved that this can reset the local clock without a real proc. +-- Unlike old v1.0.5, this listener accepts the trigger from ANY Warlock, not +-- only the local player, as long as UNIT_CASTEVENT supplies the target GUID. +local SHADOW_BOLT_IDS = { + [686]=true, [695]=true, [705]=true, [1088]=true, [1106]=true, + [7641]=true, [11659]=true, [11660]=true, [11661]=true, [25307]=true, +} + +local DRAIN_SOUL_IDS = { + [1120]=true, [8288]=true, [8289]=true, [11675]=true, + [51687]=true, -- Octo/custom rank observed in UNIT_CASTEVENT +} + +local function IsShadowBoltSpellID(spellID) + spellID = tonumber(spellID) + if spellID and SHADOW_BOLT_IDS[spellID] then return true end + if not spellID or not SpellInfo then return false end + local spellName = SpellInfo(spellID) + return spellName == "Shadow Bolt" or spellName == "Schattenblitz" +end + +local function IsDrainSoulSpellID(spellID) + spellID = tonumber(spellID) + if spellID and DRAIN_SOUL_IDS[spellID] then return true end + if not spellID or not SpellInfo then return false end + local spellName = SpellInfo(spellID) + return spellName == "Drain Soul" or spellName == "Seelendieb" +end + +local function ShadowVulnerabilityIsLive(guid) + if not guid then return false end + local def = SVDef() + if not def then return false end + local spellID, texture = FindSharedShadowVulnerabilityByGUID(guid, def) + return IsShadowVulnerabilitySpellID(spellID) or texture ~= nil +end + +-- One pending verification per target GUID so several Warlocks can cast at +-- different mobs during the same frame without overwriting each other. +local sv105Pending = {} +local sv105Elapsed = 0 +local SV105_VERIFY_WINDOW = 0.80 +local SV105_VERIFY_INTERVAL = 0.05 + +local function QueueSV105Refresh(guid, spellID, casterGUID, eventType) + if not guid then return end + sv105Pending[guid] = GetTime() + SV105_VERIFY_WINDOW + SVProbeEvent("SV_105_ARM", guid, spellID, tostring(eventType or "?") .. ":" .. tostring(casterGUID or "?")) + + -- Mid-duration refreshes already have the aura visible. Handle them in the + -- same event immediately so the icon number jumps back to 10 without waiting + -- for the next scanner tick. + if ShadowVulnerabilityIsLive(guid) then + if RefreshSharedShadowVulnerability(guid) then + sv105Pending[guid] = nil + SVProbeEvent("SV_105_REFRESH", guid, spellID, "immediate") + RaidTrace("SV_105_REFRESH", guid, "shadow_vulnerability", spellID) + end + end +end + +local sv105Event = CreateFrame("Frame") +sv105Event:RegisterEvent("UNIT_CASTEVENT") +sv105Event:SetScript("OnEvent", function() + if playerClass ~= "WARLOCK" then return end + if ClassicAPIAuraDataAvailable() then return end + + local casterGUID = arg1 + local targetGUID = arg2 + local eventType = arg3 + local spellID = tonumber(arg4) + + if eventType ~= "CAST" and eventType ~= "CHANNEL" then return end + if not IsShadowBoltSpellID(spellID) and not IsDrainSoulSpellID(spellID) then return end + + -- Old v1.0.5 used only the local player's exact target GUID. For the shared + -- version we trust arg2 for any Warlock. Only our own cast may fall back to + -- the current target when this old SuperWoW build omits arg2. + if not targetGUID then + local playerGUID = GetPlayerGUID() + if playerGUID and casterGUID == playerGUID then targetGUID = GetTargetGUID() end + end + if not targetGUID then return end + + QueueSV105Refresh(targetGUID, spellID, casterGUID, eventType) +end) + +local sv105RefreshFrame = CreateFrame("Frame") +sv105RefreshFrame:SetScript("OnUpdate", function() + if playerClass ~= "WARLOCK" or not next(sv105Pending) then return end + if ClassicAPIAuraDataAvailable() then + sv105Pending = {} + return + end + + sv105Elapsed = sv105Elapsed + arg1 + if sv105Elapsed < SV105_VERIFY_INTERVAL then return end + sv105Elapsed = 0 + + local now = GetTime() + local guid, untilTime + for guid, untilTime in pairs(sv105Pending) do + if now > untilTime then + sv105Pending[guid] = nil + elseif ShadowVulnerabilityIsLive(guid) then + if RefreshSharedShadowVulnerability(guid) then + sv105Pending[guid] = nil + SVProbeEvent("SV_105_REFRESH", guid, 17794, "verified") + RaidTrace("SV_105_REFRESH", guid, "shadow_vulnerability", 17794) + end + end + end +end) + +function BNP:RefreshDebuffVisibility() + if not self:AreCrowdControlEnabled() or not self:ShowOtherPlayersCCs() then + BNP.guidLiveCCs = {} + end + self:RefreshAllAuraLayouts() + + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate.BNPAuraContainer then + if self:AreAnyAurasEnabled() then + UpdatePlate(plate) + else + local i + for i = 1, MAX_VISIBLE_ICONS do + local icon = plate.BNPAuraContainer.icons and plate.BNPAuraContainer.icons[i] + if icon then icon:Hide() end + end + plate.BNPAuraContainer:Hide() + HideCCRow(plate) + end + end + end +end diff --git a/modules/castbar.lua b/modules/castbar.lua new file mode 100644 index 0000000..8ba604b --- /dev/null +++ b/modules/castbar.lua @@ -0,0 +1,356 @@ +BNP = BNP or {} + +local casts = {} +local BAR_HEIGHT = 6 + +-- Runtime-only visual preview. This is intentionally not saved in BNP_DB so +-- a /reload always returns to normal cast detection. +local castbarTestMode = false +local testCastStart = 0 +local TEST_CAST_DURATION = 4 +local TEST_CAST_TEXTURE = "Interface\\Icons\\Spell_Fire_Fireball02" + +local testCast = { + name = "Test Cast", + texture = TEST_CAST_TEXTURE, + startTime = 0, + endTime = 0, + duration = TEST_CAST_DURATION, + channel = false, +} + +local function ResetTestCast(now) + now = now or GetTime() + testCastStart = now + testCast.startTime = now + testCast.endTime = now + TEST_CAST_DURATION + testCast.duration = TEST_CAST_DURATION +end + +local function GetTestCast(now) + if testCastStart == 0 or now >= testCast.endTime then + ResetTestCast(now) + end + return testCast +end + +local function GetCastbarHeight() + if BNP.GetCastbarHeight then return BNP:GetCastbarHeight() end + return BAR_HEIGHT +end +local BAR_GAP = 4 + +local function GetCastbarSpacing() + if BNP.GetCastbarSpacing then return BNP:GetCastbarSpacing() end + return BAR_GAP +end + +local function ApplyCastbarLayout(plate, bar) + if not plate or not plate.healthbar or not bar then return end + local gap = GetCastbarSpacing() + + bar:ClearAllPoints() + bar:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", 15, -gap) + bar:SetPoint("TOPRIGHT", plate.healthbar, "BOTTOMRIGHT", 14, -gap) + + if bar.icon then + bar.icon:ClearAllPoints() + bar.icon:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", 0, -gap) + end +end + +local function Enabled() + if BNP.AreCastbarsEnabled then return BNP:AreCastbarsEnabled() end + return not BNP_DB or BNP_DB.castbars ~= false +end + +local function GetPlateGUID(plate) + if not plate or not plate.GetName then return nil end + + local token = plate:GetName(1) + if not token then return nil end + + local exists, guid = UnitExists(token) + if exists and guid then return guid end + + -- On SuperWoW the plate token itself can already be the GUID. + return token +end + +local function SpellData(spellID) + if SpellInfo and spellID then + local name, _, texture = SpellInfo(spellID) + return name or "Casting", texture + end + return "Casting", nil +end + +local function CreateCastbar(plate) + if plate.BNPCastbar then return plate.BNPCastbar end + if not plate.healthbar then return nil end + + local parent = plate.BNPScaleWrapper or plate + local bar = CreateFrame("StatusBar", nil, parent) + bar:SetHeight(GetCastbarHeight()) + -- Center the castbar under the complete Blizzard nameplate. + -- Extend beneath the level badge instead of stopping at the healthbar edge. + -- Compact row: icon + castbar together span the complete plate width. + -- The icon begins at the healthbar's left edge; the bar continues directly + -- beside it and ends beneath the level badge. + local gap = GetCastbarSpacing() + bar:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", 15, -gap) + bar:SetPoint("TOPRIGHT", plate.healthbar, "BOTTOMRIGHT", 14, -gap) + bar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar") + bar:SetStatusBarColor(1.0, 0.8, 0.0) + bar:SetMinMaxValues(0, 1) + bar:SetValue(0) + bar:SetFrameLevel((plate.healthbar:GetFrameLevel() or 1) + 1) + bar:Hide() + + local bg = bar:CreateTexture(nil, "BACKGROUND") + bg:SetTexture("Interface\\TargetingFrame\\UI-StatusBar") + bg:SetVertexColor(0.10, 0.10, 0.00, 0.85) + bg:SetAllPoints(bar) + + local text = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + text:SetPoint("CENTER", bar, "CENTER", 0, 0) + text:SetTextColor(1, 1, 1) + bar.text = text + + -- Spell icon to the left of the castbar. + local icon = CreateFrame("Frame", nil, parent) + icon:SetWidth(14) + icon:SetHeight(14) + icon:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", 0, -gap) + icon:SetFrameLevel(bar:GetFrameLevel() + 1) + + local texture = icon:CreateTexture(nil, "ARTWORK") + texture:SetAllPoints(icon) + icon.texture = texture + + local border = icon:CreateTexture(nil, "OVERLAY") + border:SetTexture("Interface\\Buttons\\UI-Quickslot2") + border:SetPoint("CENTER", icon, "CENTER", 0, 0) + border:SetWidth(23) + border:SetHeight(23) + icon.border = border + + icon:Hide() + bar.icon = icon + + plate.BNPCastbar = bar + return bar +end + +local function Hide(plate) + if plate and plate.BNPCastbar then + plate.BNPCastbar:Hide() + if plate.BNPCastbar.icon then + plate.BNPCastbar.icon:Hide() + end + end +end + +-- Exact SuperWoW event path used by ShaguTweaks' superwow module: +-- START / CAST / CHANNEL create cast state; FAIL removes it. +local eventFrame = CreateFrame("Frame") +eventFrame:RegisterEvent("UNIT_CASTEVENT") +eventFrame:SetScript("OnEvent", function() + local guid = arg1 + local eventType = arg3 + local spellID = arg4 + local timerMS = tonumber(arg5) or 0 + + if not guid then return end + + if eventType == "START" or eventType == "CAST" or eventType == "CHANNEL" then + if timerMS <= 0 then + -- Instant casts do not need a visible castbar. + casts[guid] = nil + return + end + + local name, texture = SpellData(spellID) + local now = GetTime() + local duration = timerMS / 1000 + + casts[guid] = { + name = name, + texture = texture, + startTime = now, + endTime = now + duration, + duration = duration, + channel = eventType == "CHANNEL", + } + elseif eventType == "FAIL" then + casts[guid] = nil + end +end) + +local activeCastPlates = {} + +local function MarkCastPlateActive(plate) + if not plate then return end + activeCastPlates[plate] = true +end + +local function MarkCastPlateInactive(plate) + if not plate then return end + activeCastPlates[plate] = nil + Hide(plate) +end + +-- Lightweight discovery pass. This does NOT animate bars; it only checks +-- visible plates for active cast state at 20 Hz. +local discovery = CreateFrame("Frame") +local discoveryElapsed = 0 +discovery:SetScript("OnUpdate", function() + discoveryElapsed = discoveryElapsed + arg1 + if discoveryElapsed < 0.05 then return end + discoveryElapsed = 0 + + if not Enabled() and not castbarTestMode then + local plate + for plate in pairs(activeCastPlates) do + MarkCastPlateInactive(plate) + end + return + end + + local now = GetTime() + local plate + for plate in pairs(BNP.plates) do + if plate:IsShown() then + if castbarTestMode then + -- Preview every visible nameplate. No GUID/cast lookup is needed. + MarkCastPlateActive(plate) + else + local guid = GetPlateGUID(plate) + local cast = guid and casts[guid] + + if cast and now < cast.endTime then + MarkCastPlateActive(plate) + elseif activeCastPlates[plate] then + MarkCastPlateInactive(plate) + end + end + elseif activeCastPlates[plate] then + MarkCastPlateInactive(plate) + end + end +end) + +-- Frame-perfect animation remains unchanged visually, but only active +-- castbars are updated every rendered frame. +local updater = CreateFrame("Frame") +updater:SetScript("OnUpdate", function() + if not Enabled() and not castbarTestMode then return end + + local now = GetTime() + local plate + for plate in pairs(activeCastPlates) do + if not plate:IsShown() then + MarkCastPlateInactive(plate) + else + local guid + local cast + + if castbarTestMode then + cast = GetTestCast(now) + else + guid = GetPlateGUID(plate) + cast = guid and casts[guid] + end + + if not cast or now >= cast.endTime then + if (not castbarTestMode) and guid and cast and now >= cast.endTime then + casts[guid] = nil + end + MarkCastPlateInactive(plate) + else + local bar = CreateCastbar(plate) + if bar then + local current = now - cast.startTime + if current < 0 then current = 0 end + if current > cast.duration then current = cast.duration end + + bar:SetMinMaxValues(0, cast.duration) + + if cast.channel then + bar:SetValue(cast.duration - current) + else + bar:SetValue(current) + end + + bar.text:SetText(cast.name) + bar:SetAlpha(1) + + if bar.icon then + if cast.texture then + bar.icon.texture:SetTexture(cast.texture) + bar.icon:SetAlpha(1) + bar.icon:Show() + else + bar.icon:Hide() + end + end + + bar:Show() + end + end + end + end +end) + + +function BNP:IsCastbarTestMode() + return castbarTestMode and true or false +end + +function BNP:SetCastbarTestMode(enabled) + castbarTestMode = enabled and true or false + + if castbarTestMode then + ResetTestCast(GetTime()) + else + -- Remove every preview immediately. Real casts are rediscovered on the + -- next lightweight discovery pass without touching their stored state. + local plate + for plate in pairs(activeCastPlates) do + MarkCastPlateInactive(plate) + end + end + + if self.optionsFrame and self.optionsFrame.castbarTestCheck then + self.optionsFrame.castbarTestCheck:SetChecked(castbarTestMode) + end +end + + +function BNP:RefreshCastbarLayout() + local plate + local height = GetCastbarHeight() + + for plate in pairs(BNP.plates or {}) do + if plate and plate.BNPCastbar then + plate.BNPCastbar:SetHeight(height) + ApplyCastbarLayout(plate, plate.BNPCastbar) + end + end +end + +function BNP:RefreshCastbarHeights() + self:RefreshCastbarLayout() +end + +function BNP:SetCastbarsEnabled(enabled) + BNP_DB = BNP_DB or {} + BNP_DB.castbars = enabled and true or false + + if not enabled then + local plate + for plate in pairs(BNP.plates) do Hide(plate) end + end + + self:Print("Nameplate castbars " .. (enabled and "enabled." or "disabled.")) +end diff --git a/modules/classcolors.lua b/modules/classcolors.lua new file mode 100644 index 0000000..9f57734 --- /dev/null +++ b/modules/classcolors.lua @@ -0,0 +1,169 @@ +BNP = BNP or {} + +local fallbackColors = { + WARRIOR = { 0.78, 0.61, 0.43 }, + MAGE = { 0.41, 0.80, 0.94 }, + ROGUE = { 1.00, 0.96, 0.41 }, + DRUID = { 1.00, 0.49, 0.04 }, + HUNTER = { 0.67, 0.83, 0.45 }, + SHAMAN = { 0.00, 0.44, 0.87 }, + PRIEST = { 1.00, 1.00, 1.00 }, + WARLOCK = { 0.58, 0.51, 0.79 }, + PALADIN = { 0.96, 0.55, 0.73 }, +} + +local function GetColor(class) + if RAID_CLASS_COLORS and RAID_CLASS_COLORS[class] then + local c = RAID_CLASS_COLORS[class] + return c.r, c.g, c.b + end + + local c = fallbackColors[class] + if c then return c[1], c[2], c[3] end +end + +local function Enabled() + if BNP.AreClassColorsEnabled then return BNP:AreClassColorsEnabled() end + return not BNP_DB or BNP_DB.classColors ~= false +end + +local function ClearClassCache(plate) + if not plate then return end + plate.BNPClassColorApplied = nil + plate.BNPClassColorToken = nil + plate.BNPClassGUID = nil + plate.BNPClassR = nil + plate.BNPClassG = nil + plate.BNPClassB = nil +end + +local function ResolvePlateUnit(plate) + if not plate or not plate.GetName then return nil, nil end + + local token = plate:GetName(1) + if not token or not UnitExists(token) then + return token, nil + end + + local exists, guid = UnitExists(token) + if exists then + return token, guid + end + + return token, nil +end + +local function ApplyClassColor(plate) + if not Enabled() then + ClearClassCache(plate) + return false + end + + if not plate or not plate.healthbar then return false end + + local token, guid = ResolvePlateUnit(plate) + + -- BNP only owns PLAYER class colors. + -- NPC/neutral/friendly-NPC colors stay completely under Blizzard control. + if not token or not guid or not UnitIsPlayer(token) then + ClearClassCache(plate) + return false + end + + local _, class = UnitClass(token) + local r, g, b = GetColor(class) + if not r then + ClearClassCache(plate) + return false + end + + plate.BNPClassColorApplied = true + plate.BNPClassColorToken = token + plate.BNPClassGUID = guid + plate.BNPClassR = r + plate.BNPClassG = g + plate.BNPClassB = b + + plate.healthbar:SetStatusBarColor(r, g, b) + return true +end + +BNP.ApplyClassColor = ApplyClassColor + +local function EnforceClassColor(plate) + if not Enabled() then return end + if not plate or not plate.healthbar or not plate.BNPClassGUID then return end + + local token, guid = ResolvePlateUnit(plate) + + -- Never enforce a cached color after Blizzard recycled the frame. + if not token or not guid or guid ~= plate.BNPClassGUID or not UnitIsPlayer(token) then + ClearClassCache(plate) + return + end + + local r, g, b = plate.healthbar:GetStatusBarColor() + if math.abs(r - plate.BNPClassR) > 0.01 + or math.abs(g - plate.BNPClassG) > 0.01 + or math.abs(b - plate.BNPClassB) > 0.01 then + plate.healthbar:SetStatusBarColor( + plate.BNPClassR, + plate.BNPClassG, + plate.BNPClassB + ) + end +end + +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnInit, function(plate) + local current = plate or this + if not current then return end + + ClearClassCache(current) + ApplyClassColor(current) + + local bar = current.healthbar + if bar and not current.BNPClassColorHooked then + local oldValueChanged = bar:GetScript("OnValueChanged") + bar:SetScript("OnValueChanged", function() + if oldValueChanged then oldValueChanged() end + EnforceClassColor(current) + end) + current.BNPClassColorHooked = true + end + end) + + table.insert(BNP.libnameplate.OnShow, function(plate) + local current = plate or this + if not current then return end + ClearClassCache(current) + ApplyClassColor(current) + end) +end + +-- Blizzard may rewrite player colors during combat/threat updates. +-- Re-apply only while the plate still resolves to the SAME player GUID. +-- If a recycled frame now represents an NPC/neutral unit, drop the cache +-- immediately and never touch its Blizzard color. +local elapsed = 0 +local updater = CreateFrame("Frame") +updater:SetScript("OnUpdate", function() + elapsed = elapsed + arg1 + if elapsed < 0.10 then return end + elapsed = 0 + + if not Enabled() then return end + + local plate + for plate in pairs(BNP.plates) do + if plate:IsShown() and plate.healthbar then + local token, guid = ResolvePlateUnit(plate) + + if plate.BNPClassGUID then + EnforceClassColor(plate) + elseif token and guid and UnitIsPlayer(token) then + ApplyClassColor(plate) + end + end + end +end) diff --git a/modules/combopoints.lua b/modules/combopoints.lua new file mode 100644 index 0000000..2f0805a --- /dev/null +++ b/modules/combopoints.lua @@ -0,0 +1,299 @@ +BNP = BNP or {} + +local _, playerClass = UnitClass("player") +local COMBO_CLASS = (playerClass == "ROGUE" or playerClass == "DRUID") + +local MAX_POINTS = 5 +local POINT_GAP = 1 + +local function Enabled() + return COMBO_CLASS and BNP.AreComboPointsEnabled and BNP:AreComboPointsEnabled() +end + +local function GetTargetGUID() + local exists, guid = UnitExists("target") + if exists and guid then return guid end + return nil +end + +local function GetComboCount() + if not GetComboPoints then return 0 end + local count = tonumber(GetComboPoints()) or 0 + if count < 0 then count = 0 end + if count > MAX_POINTS then count = MAX_POINTS end + return count +end + +local function GetSourcePoint() + if getglobal then + return getglobal("TargetFrameComboPoint1") + end + return nil +end + +local function GetSourceSize() + local source = GetSourcePoint() + local w, h = 12, 16 + + if source then + if source.GetWidth then w = tonumber(source:GetWidth()) or w end + if source.GetHeight then h = tonumber(source:GetHeight()) or h end + end + + if w <= 0 then w = 12 end + if h <= 0 then h = 16 end + return w, h +end + +local function ApplyTargetFrameScale(holder) + if not holder then return end + + local source = GetSourcePoint() + local parent = holder:GetParent() + + local sourceScale = 1.0 + local parentScale = 1.0 + + -- Match the actual on-screen scale of Blizzard's TargetFrame combo points. + if source and source.GetEffectiveScale then + sourceScale = tonumber(source:GetEffectiveScale()) or 1.0 + elseif UIParent and UIParent.GetEffectiveScale then + sourceScale = tonumber(UIParent:GetEffectiveScale()) or 1.0 + elseif UIParent and UIParent.GetScale then + sourceScale = tonumber(UIParent:GetScale()) or 1.0 + end + + if parent and parent.GetEffectiveScale then + parentScale = tonumber(parent:GetEffectiveScale()) or 1.0 + end + + if sourceScale <= 0 then sourceScale = 1.0 end + if parentScale <= 0 then parentScale = 1.0 end + + holder:SetScale(sourceScale / parentScale) +end + +local function CopySourceTextureAppearance(dst, src) + if not dst or not src then return end + + if src.GetTexture and dst.SetTexture then + dst:SetTexture(src:GetTexture()) + end + + if src.GetTexCoord and dst.SetTexCoord then + local l, r, t, b = src:GetTexCoord() + if l then dst:SetTexCoord(l, r, t, b) end + end + + if src.GetVertexColor and dst.SetVertexColor then + local r, g, b, a = src:GetVertexColor() + if r then dst:SetVertexColor(r, g, b, a or 1) end + end + + if src.GetBlendMode and dst.SetBlendMode then + local mode = src:GetBlendMode() + if mode then dst:SetBlendMode(mode) end + end +end + +local function CreateExactPoint(parent) + local source = GetSourcePoint() + local pointW, pointH = GetSourceSize() + + local point = CreateFrame("Frame", nil, parent) + point:SetWidth(pointW) + point:SetHeight(pointH) + + local copied = false + + -- Clone the actual texture regions from Blizzard's TargetFrame combo point. + -- This guarantees the same client artwork/skin instead of guessing atlas cuts. + if source and source.GetRegions then + local regions = { source:GetRegions() } + local i + + for i = 1, table.getn(regions) do + local src = regions[i] + if src and src.GetObjectType and src:GetObjectType() == "Texture" then + local tex = point:CreateTexture(nil, "ARTWORK") + CopySourceTextureAppearance(tex, src) + + local w = src.GetWidth and tonumber(src:GetWidth()) or pointW + local h = src.GetHeight and tonumber(src:GetHeight()) or pointH + if not w or w <= 0 then w = pointW end + if not h or h <= 0 then h = pointH end + + tex:SetWidth(w) + tex:SetHeight(h) + + -- Preserve the standard TargetFrame relative offsets where possible. + local anchored = false + if src.GetPoint then + local pnt, rel, relp, x, y = src:GetPoint(1) + if pnt then + -- Source textures are normally anchored to their own combo frame. + -- Re-anchor them to our clone with the same point/offset. + tex:SetPoint(pnt, point, relp or pnt, x or 0, y or 0) + anchored = true + end + end + + if not anchored then + tex:SetPoint("CENTER", point, "CENTER", 0, 0) + end + + copied = true + end + end + end + + -- Fallback only if the client does not expose TargetFrameComboPoint1 regions. + if not copied then + local base = point:CreateTexture(nil, "BACKGROUND") + base:SetTexture("Interface\\ComboFrame\\ComboPoint") + base:SetWidth(12) + base:SetHeight(16) + base:SetPoint("TOPLEFT", point, "TOPLEFT", 0, 0) + base:SetTexCoord(0, 0.375, 0, 1) + + local highlight = point:CreateTexture(nil, "ARTWORK") + highlight:SetTexture("Interface\\ComboFrame\\ComboPoint") + highlight:SetWidth(8) + highlight:SetHeight(16) + highlight:SetPoint("TOPLEFT", point, "TOPLEFT", 2, 0) + highlight:SetTexCoord(0.375, 0.5625, 0, 1) + end + + point:Hide() + return point +end + +local function EnsurePoints(plate) + if plate.BNPComboPoints then + ApplyTargetFrameScale(plate.BNPComboPoints) + return plate.BNPComboPoints + end + + if not plate or not plate.name then return nil end + + local pointW, pointH = GetSourceSize() + local parent = plate.BNPScaleWrapper or plate + + local holder = CreateFrame("Frame", nil, parent) + holder:SetWidth(MAX_POINTS * pointW + (MAX_POINTS - 1) * POINT_GAP) + holder:SetHeight(pointH) + + -- Keep the position the user already approved. + holder:SetPoint("BOTTOM", plate.name, "TOP", 0, 2) + holder:SetFrameLevel((plate:GetFrameLevel() or 1) + 12) + holder.points = {} + holder:Hide() + + local i + for i = 1, MAX_POINTS do + local point = CreateExactPoint(holder) + + if i == 1 then + point:SetPoint("LEFT", holder, "LEFT", 0, 0) + else + point:SetPoint("LEFT", holder.points[i - 1], "RIGHT", POINT_GAP, 0) + end + + holder.points[i] = point + end + + ApplyTargetFrameScale(holder) + plate.BNPComboPoints = holder + return holder +end + +local function HidePoints(plate) + local holder = plate and plate.BNPComboPoints + if not holder then return end + + local i + for i = 1, MAX_POINTS do + holder.points[i]:Hide() + end + holder:Hide() +end + +local function UpdatePlate(plate) + if not plate or not plate:IsShown() then return end + + if not Enabled() then + HidePoints(plate) + return + end + + local targetGUID = GetTargetGUID() + local plateGUID = plate.GetName and plate:GetName(1) or nil + + if not targetGUID or not plateGUID or targetGUID ~= plateGUID then + HidePoints(plate) + return + end + + local count = GetComboCount() + if count <= 0 then + HidePoints(plate) + return + end + + local holder = EnsurePoints(plate) + if not holder then return end + ApplyTargetFrameScale(holder) + + local i + for i = 1, MAX_POINTS do + if i <= count then + holder.points[i]:Show() + else + holder.points[i]:Hide() + end + end + + holder:Show() +end + +function BNP:RefreshComboPoints() + local plate + for plate in pairs(BNP.plates or {}) do + if plate:IsShown() then + UpdatePlate(plate) + else + HidePoints(plate) + end + end + + if BNP.RefreshAllAuraLayouts then + BNP:RefreshAllAuraLayouts() + end +end + +table.insert(BNP.libnameplate.OnInit, function(plate) + if COMBO_CLASS then + EnsurePoints(plate) + HidePoints(plate) + end +end) + +table.insert(BNP.libnameplate.OnShow, function(plate) + if COMBO_CLASS then + HidePoints(plate) + UpdatePlate(plate) + end +end) + +table.insert(BNP.libnameplate.OnUpdate, function(plate) + if COMBO_CLASS then UpdatePlate(plate) end +end) + +if COMBO_CLASS then + local events = CreateFrame("Frame") + events:RegisterEvent("PLAYER_TARGET_CHANGED") + events:RegisterEvent("PLAYER_COMBO_POINTS") + events:SetScript("OnEvent", function() + BNP:RefreshComboPoints() + end) +end diff --git a/modules/commands.lua b/modules/commands.lua new file mode 100644 index 0000000..c30698b --- /dev/null +++ b/modules/commands.lua @@ -0,0 +1,592 @@ +-- Elite dragon render probe --------------------------------------------------- +-- Kept directly in commands.lua on purpose so /bnp dragontest cannot depend +-- on a separate module loading successfully. This is render-only: it does not +-- alter level text, classification state, nameplate discovery, or saved data. +local function BNP_FindTargetPlateForDragonTest() + if C_NamePlate and type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, plate = pcall(C_NamePlate.GetNamePlateForUnit, "target") + if ok and plate then return plate end + end + + local exists, guid = UnitExists("target") + if not exists or not guid then return nil end + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() and plate.GetName then + local ok, token = pcall(plate.GetName, plate, 1) + if ok and token == guid then return plate end + end + end + return nil +end + +local function BNP_ToggleDirectDragonRenderTest() + if not UnitExists("target") then + BNP:Print("Target a visible unit first.") + return + end + + local plate = BNP_FindTargetPlateForDragonTest() + if not plate then + BNP:Print("Target nameplate not found. Keep the target nameplate visible and try again.") + return + end + + local healthbar = plate.healthbar + if not healthbar and plate.GetChildren then + healthbar = plate:GetChildren() + plate.healthbar = healthbar + end + if not healthbar then + BNP:Print("Target healthbar not found.") + return + end + + if not plate.BNPDirectDragonTestFrame then + -- Put the dragon directly on the healthbar BACKGROUND layer. + -- This keeps the exact size/position/scaling, but lets the bar, border + -- and level text draw in front of it at all times. + local t = healthbar:CreateTexture(nil, "BACKGROUND") + t:SetWidth(37) + t:SetHeight(28) + t:SetPoint("CENTER", healthbar, "RIGHT", 20, -1) + t:SetTexture("Interface\\TargetingFrame\\UI-TargetingFrame-Elite") + t:SetTexCoord(0.58, 1.00, 0.00, 0.78125) + + t:Hide() + plate.BNPDirectDragonTestFrame = t + end + + local f = plate.BNPDirectDragonTestFrame + if f:IsShown() then + f:Hide() + BNP:Print("Dragon test OFF.") + else + f:Show() + local classification = UnitClassification and UnitClassification("target") or "?" + BNP:Print("Dragon test ON. Classification: " .. tostring(classification) .. ".") + end +end + + +-- Automatic Elite / World Boss dragon --------------------------------------- +-- Keep this in commands.lua deliberately: /bnp dragontest and /bnp eliteinfo +-- prove that this file and this exact plate/render path work on the 1.12 client. +-- The automatic path reuses the SAME frame created by dragontest. +local BNP_EliteAutoNextScan = 0 +local BNP_EliteAutoState = { + ticks = 0, + visible = 0, + elite = 0, + shown = 0, + lastClass = "nil", + lastError = "none", +} + +local function BNP_GetOrCreateAutoDragon(plate) + if not plate then return nil end + + -- Reuse the exact proven dragontest frame if it already exists. + if plate.BNPDirectDragonTestFrame then + return plate.BNPDirectDragonTestFrame + end + + local healthbar = plate.healthbar + if not healthbar and plate.GetChildren then + healthbar = plate:GetChildren() + plate.healthbar = healthbar + end + if not healthbar then return nil end + + -- Same render path as /bnp dragontest: a BACKGROUND texture directly + -- on the healthbar, so the dragon always stays behind bar/level elements. + local t = healthbar:CreateTexture(nil, "BACKGROUND") + t:SetWidth(37) + t:SetHeight(28) + t:SetPoint("CENTER", healthbar, "RIGHT", 20, -1) + t:SetTexture("Interface\\TargetingFrame\\UI-TargetingFrame-Elite") + t:SetTexCoord(0.58, 1.00, 0.00, 0.78125) + + t:Hide() + plate.BNPDirectDragonTestFrame = t + return t +end + +local function BNP_UpdateEliteDragonPlate(plate, unit) + if not plate or not plate:IsShown() then + if plate and plate.BNPDirectDragonTestFrame then + plate.BNPDirectDragonTestFrame:Hide() + end + return + end + + local token = unit + if not token and plate.GetName then + token = plate:GetName(1) + end + + local classification = nil + if token and UnitClassification then + classification = UnitClassification(token) + end + + local dragon = BNP_GetOrCreateAutoDragon(plate) + if not dragon then return end + + if classification == "elite" or classification == "worldboss" then + dragon:Show() + else + dragon:Hide() + end +end + +local function BNP_RunEliteAutoScan() + local visible = 0 + local elite = 0 + local shown = 0 + local lastClass = "nil" + + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() then + visible = visible + 1 + + local classification = nil + local token = nil + if plate.GetName then + token = plate:GetName(1) + end + if token and UnitClassification then + classification = UnitClassification(token) + end + lastClass = tostring(classification or "nil") + + local dragon = BNP_GetOrCreateAutoDragon(plate) + if dragon then + if classification == "elite" or classification == "worldboss" then + elite = elite + 1 + dragon:Show() + shown = shown + 1 + else + dragon:Hide() + end + end + elseif plate and plate.BNPDirectDragonTestFrame then + plate.BNPDirectDragonTestFrame:Hide() + end + end + + BNP_EliteAutoState.visible = visible + BNP_EliteAutoState.elite = elite + BNP_EliteAutoState.shown = shown + BNP_EliteAutoState.lastClass = lastClass +end + +-- Instant path --------------------------------------------------------------- +-- 1) BNP's own OnShow fires immediately when an already-known Blizzard plate +-- is shown/reused. +if BNP.libnameplate and BNP.libnameplate.OnShow then + table.insert(BNP.libnameplate.OnShow, function(plate) + BNP_UpdateEliteDragonPlate(plate) + end) +end + +-- 2) ClassicAPI's nameplate event can resolve the unit token directly on the +-- frame it belongs to. This is the fastest path for newly appearing plates. +local BNP_EliteInstantEvents = CreateFrame("Frame", nil, UIParent) +local addedOK = pcall(BNP_EliteInstantEvents.RegisterEvent, BNP_EliteInstantEvents, "NAME_PLATE_UNIT_ADDED") +local removedOK = pcall(BNP_EliteInstantEvents.RegisterEvent, BNP_EliteInstantEvents, "NAME_PLATE_UNIT_REMOVED") + +if addedOK or removedOK then + BNP_EliteInstantEvents:SetScript("OnEvent", function() + local unit = arg1 + if not unit then return end + + if event == "NAME_PLATE_UNIT_ADDED" then + if C_NamePlate and type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, plate = pcall(C_NamePlate.GetNamePlateForUnit, unit) + if ok and plate then + BNP_UpdateEliteDragonPlate(plate, unit) + end + end + elseif event == "NAME_PLATE_UNIT_REMOVED" then + -- The fallback pass clears hidden/recycled frames. If ClassicAPI still + -- exposes the frame at removal time, hide it immediately as well. + if C_NamePlate and type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, plate = pcall(C_NamePlate.GetNamePlateForUnit, unit) + if ok and plate and plate.BNPDirectDragonTestFrame then + plate.BNPDirectDragonTestFrame:Hide() + end + end + end + end) +end + +-- Apply immediately to any plates that were already visible when this module +-- loaded. Afterwards the 0.50s scan is only a safety net. +pcall(BNP_RunEliteAutoScan) + +local BNP_EliteAutoFrame = CreateFrame("Frame", nil, UIParent) +BNP_EliteAutoFrame:SetScript("OnUpdate", function() + local now = GetTime() + if now < BNP_EliteAutoNextScan then return end + BNP_EliteAutoNextScan = now + 0.50 + BNP_EliteAutoState.ticks = BNP_EliteAutoState.ticks + 1 + + local ok, err = pcall(BNP_RunEliteAutoScan) + if not ok then + BNP_EliteAutoState.lastError = tostring(err or "unknown") + else + BNP_EliteAutoState.lastError = "none" + end +end) +BNP_EliteAutoFrame:Show() + + +local function BNP_PrintEliteInfo() + DEFAULT_CHAT_FRAME:AddMessage("|cff66ccffBNP ELITEINFO|r") + + local targetExists, targetGUID = UnitExists("target") + local targetName = UnitName("target") or "?" + local targetClass = UnitClassification and UnitClassification("target") or "?" + local targetLevel = UnitLevel and UnitLevel("target") or "?" + + DEFAULT_CHAT_FRAME:AddMessage( + "TARGET | "..tostring(targetName).. + " | level "..tostring(targetLevel).. + " | class "..tostring(targetClass).. + " | guid "..tostring(targetGUID or "nil") + ) + + if not targetExists then + DEFAULT_CHAT_FRAME:AddMessage("No target. Target a visible Elite and run /bnp eliteinfo again.") + return + end + + local apiTargetPlate = nil + if C_NamePlate and type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, frame = pcall(C_NamePlate.GetNamePlateForUnit, "target") + if ok then apiTargetPlate = frame end + end + DEFAULT_CHAT_FRAME:AddMessage("C_NamePlate(target) | "..tostring(apiTargetPlate and "FOUND" or "nil")) + + local matchedPlate = nil + local visible = 0 + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() then + visible = visible + 1 + local rawToken = nil + if plate.GetName then + local ok, value = pcall(plate.GetName, plate, 1) + if ok then rawToken = value end + end + + local directMatch = apiTargetPlate and plate == apiTargetPlate + local guidMatch = targetGUID and rawToken == targetGUID + if directMatch or guidMatch then + matchedPlate = plate + local rawExists, rawGUID = false, nil + if rawToken then rawExists, rawGUID = UnitExists(rawToken) end + local rawClass = "nil" + if rawToken and UnitClassification then + local ok, value = pcall(UnitClassification, rawToken) + if ok then rawClass = value end + end + DEFAULT_CHAT_FRAME:AddMessage( + "BNP MATCH | direct "..tostring(directMatch and 1 or 0).. + " | guid "..tostring(guidMatch and 1 or 0).. + " | token "..tostring(rawToken or "nil") + ) + DEFAULT_CHAT_FRAME:AddMessage( + "BNP TOKEN | exists "..tostring(rawExists and 1 or 0).. + " | guid "..tostring(rawGUID or "nil").. + " | class "..tostring(rawClass or "nil") + ) + break + end + end + end + DEFAULT_CHAT_FRAME:AddMessage("BNP visible plates | "..tostring(visible).." | target match "..tostring(matchedPlate and "YES" or "NO")) + + local existing = 0 + local guidMatchUnit = nil + local frameMatchUnit = nil + local i + for i = 1, 40 do + local unit = "nameplate"..tostring(i) + local exists, guid = UnitExists(unit) + if exists then + existing = existing + 1 + local class = UnitClassification and UnitClassification(unit) or "?" + local frame = nil + if C_NamePlate and type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, value = pcall(C_NamePlate.GetNamePlateForUnit, unit) + if ok then frame = value end + end + + if targetGUID and guid == targetGUID then + guidMatchUnit = unit + DEFAULT_CHAT_FRAME:AddMessage( + "TOKEN GUID MATCH | "..unit.. + " | class "..tostring(class).. + " | frame "..tostring(frame and "FOUND" or "nil") + ) + end + + if apiTargetPlate and frame == apiTargetPlate then + frameMatchUnit = unit + DEFAULT_CHAT_FRAME:AddMessage( + "TOKEN FRAME MATCH | "..unit.. + " | guid "..tostring(guid or "nil").. + " | class "..tostring(class) + ) + end + end + end + + DEFAULT_CHAT_FRAME:AddMessage( + "nameplateN | existing "..tostring(existing).. + " | guidMatch "..tostring(guidMatchUnit or "NONE").. + " | frameMatch "..tostring(frameMatchUnit or "NONE") + ) +end + +SLASH_BLIZZNAMEPLATESPLUS1 = "/bnp" +SlashCmdList["BLIZZNAMEPLATESPLUS"] = function(msg) + msg = string.lower(msg or "") + + if msg == "" or msg == "config" or msg == "options" then + if BNP.ToggleOptions then BNP:ToggleOptions() end + elseif msg == "spellprobe" or msg == "spellbook" then + if BNP.SpellbookProbe then BNP:SpellbookProbe() else BNP:Print("Spellbook probe is not loaded.") end + elseif msg == "spellprobe corruption" then + if BNP.SpellbookProbe then BNP:SpellbookProbe("corruption") end + elseif msg == "spellprobe agony" then + if BNP.SpellbookProbe then BNP:SpellbookProbe("agony") end + elseif msg == "spellprobe siphon" then + if BNP.SpellbookProbe then BNP:SpellbookProbe("siphon") end + elseif msg == "unknown" or msg == "learn" then + if BNP.PrintUnknownBeta then BNP:PrintUnknownBeta(false) else BNP:Print("Unknown collector is not loaded.") end + elseif msg == "unknown verbose" or msg == "learn verbose" then + if BNP.PrintUnknownBeta then BNP:PrintUnknownBeta(true) else BNP:Print("Unknown collector is not loaded.") end + elseif msg == "unknown reset" or msg == "learn reset" then + if BNP.ResetUnknownBeta then BNP:ResetUnknownBeta() end + BNP.unknownDirectEvents = {} + elseif msg == "auras" or msg == "targetauras" then + if BNP.PrintTargetAuraProbe then + BNP:PrintTargetAuraProbe() + else + BNP:Print("Target aura probe is not loaded.") + end + elseif msg == "raidcheck" then + local exists, guid = UnitExists("target") + DEFAULT_CHAT_FRAME:AddMessage("|cff66ccffBNP RAIDCHECK|r") + if not exists or not guid then + DEFAULT_CHAT_FRAME:AddMessage("No target / no target GUID.") + return + end + DEFAULT_CHAT_FRAME:AddMessage("Target: "..tostring(UnitName("target") or "?").." | GUID: "..tostring(guid)) + local cache=BNP.guidAuras and BNP.guidAuras[guid] + local n=0 + if cache then + local key,aura + for key,aura in pairs(cache) do + if type(aura)=="table" and aura.spellID then + n=n+1 + local mode = aura.castPrimary and "CAST" or (aura.liveConfirmed and "LIVE" or "?") + DEFAULT_CHAT_FRAME:AddMessage( + "CACHE | "..tostring(key).." | ID "..tostring(aura.spellID).." | "..mode.. + " | stacks "..tostring(aura.stacks or 0).. + " | left "..(aura.expires and string.format("%.1f", aura.expires-GetTime()) or "?") + ) + end + end + end + if n==0 then DEFAULT_CHAT_FRAME:AddMessage("CACHE: EMPTY") end + local svState = BNP.svStateByGUID and BNP.svStateByGUID[guid] + if svState then + DEFAULT_CHAT_FRAME:AddMessage( + "SV STATE | present "..tostring(svState.present and 1 or 0).. + " | ID "..tostring(svState.spellID or "?").. + " | left "..(svState.expires and string.format("%.1f", svState.expires-GetTime()) or "?").. + " | refreshed "..(svState.lastRefresh and string.format("%.1fs ago", GetTime()-svState.lastRefresh) or "?") + ) + else + DEFAULT_CHAT_FRAME:AddMessage("SV STATE: none") + end + + -- Renderer-side SV diagnostics: tells us what the actual visible icon has + -- consumed from the GUID state, not just what the cache contains. + local rendered = false + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() and plate.GetName and plate:GetName(1) == guid then + local c = plate.BNPAuraContainer + if c and c.icons then + local ri + for ri = 1, table.getn(c.icons) do + local icon = c.icons[ri] + if icon and icon.BNPSVRenderedExpires then + DEFAULT_CHAT_FRAME:AddMessage( + "SV RENDER | icon "..tostring(ri).. + " | text "..tostring(icon.timer and icon.timer:GetText() or "?").. + " | left "..string.format("%.1f", (icon.BNPSVRenderedExpires or GetTime())-GetTime()).. + " | rev "..tostring(icon.BNPSVRenderRevision or 0) + ) + rendered = true + break + end + end + end + end + end + if not rendered then DEFAULT_CHAT_FRAME:AddMessage("SV RENDER: no visible SV icon") end + + local pending = BNP.pendingAuras and BNP.pendingAuras[guid] + if pending then + DEFAULT_CHAT_FRAME:AddMessage("-- PENDING FOR TARGET --") + local pk,pv + for pk,pv in pairs(pending) do + if type(pv)=="table" then + DEFAULT_CHAT_FRAME:AddMessage( + "PENDING | "..tostring(pk).. + " | ID "..tostring(pv.spellID or "?").. + " | age "..string.format("%.1fs", GetTime()-(pv.created or GetTime())) + ) + end + end + else + DEFAULT_CHAT_FRAME:AddMessage("PENDING: none") + end + + DEFAULT_CHAT_FRAME:AddMessage("-- LAST TRACKED CAST EVENTS --") + local trace=BNP.raidTrace or {} + local start=math.max(1,table.getn(trace)-14) + local ti + for ti=start,table.getn(trace) do + local e=trace[ti] + DEFAULT_CHAT_FRAME:AddMessage( + tostring(e.stage).. + " | "..tostring(e.key or "-").. + " | ID "..tostring(e.spellID or "?").. + " | GUID "..tostring(e.guid or "nil").. + " | "..string.format("%.1fs ago",GetTime()-(e.time or GetTime())) + ) + end + + DEFAULT_CHAT_FRAME:AddMessage("-- LIVE TARGET AURAS (up to 64) --") + local i + for i=1,64 do + local tex,stacks,dtype,id=UnitDebuff("target",i) + if not tex then break end + local name=(id and SpellInfo and SpellInfo(id)) or "?" + DEFAULT_CHAT_FRAME:AddMessage("LIVE "..tostring(i).." | ID "..tostring(id or "?").." | "..tostring(name).." | stacks "..tostring(stacks or 0)) + end + + local svp = BNP.svProbe + if svp then + DEFAULT_CHAT_FRAME:AddMessage("-- SV PROBE --") + local last = svp.last + if last then + DEFAULT_CHAT_FRAME:AddMessage( + "SV LIVE | "..tostring(last.reason or "?").." | unit "..tostring(last.unit or "?").. + " | idx "..tostring(last.index or "?").." | ID "..tostring(last.spellID or "?").. + " | stacks "..tostring(last.stacks or 0) + ) + DEFAULT_CHAT_FRAME:AddMessage( + "RET a3="..tostring(last.a3).." a5="..tostring(last.a5).." a6="..tostring(last.a6).. + " a7="..tostring(last.a7).." a8="..tostring(last.a8).." a9="..tostring(last.a9).." a10="..tostring(last.a10) + ) + else + DEFAULT_CHAT_FRAME:AddMessage("SV LIVE: no captured snapshot") + end + local ev = svp.events or {} + local es = math.max(1, table.getn(ev)-9) + local ei + for ei=es,table.getn(ev) do + local e=ev[ei] + DEFAULT_CHAT_FRAME:AddMessage( + tostring(e.stage).." | ID "..tostring(e.spellID or "?").." | GUID "..tostring(e.guid or "nil").. + " | "..tostring(e.extra or "-").." | "..string.format("%.1fs ago",GetTime()-(e.time or GetTime())) + ) + end + + DEFAULT_CHAT_FRAME:AddMessage("-- SV RAW WINDOW --") + local raw = svp.raw or {} + if table.getn(raw) == 0 then + DEFAULT_CHAT_FRAME:AddMessage("RAW: no matching lines captured") + else + local rs = math.max(1, table.getn(raw)-13) + local ri + for ri=rs,table.getn(raw) do + local r=raw[ri] + DEFAULT_CHAT_FRAME:AddMessage( + "RAW "..string.format("%.1fs",GetTime()-(r.time or GetTime())).. + " | "..tostring(r.event or "?").." | "..tostring(r.raw or "") + ) + end + end + end + elseif msg == "direct" then + if BNP.PrintUnknownDirectEvents then + BNP:PrintUnknownDirectEvents() + else + BNP:Print("Direct event collector is not loaded.") + end + elseif msg == "spelldb" or msg == "audit" then + if BNP.PrintSpellDBAudit then BNP:PrintSpellDBAudit() else BNP:Print("SpellDB audit is not loaded.") end + elseif msg == "spelldb reset" or msg == "audit reset" then + if BNP.ResetSpellDBAudit then BNP:ResetSpellDBAudit() end + elseif msg == "guid" or msg == "guiddebug" or msg == "diag" then + if BNP.PrintGUIDDiagnostic then BNP:PrintGUIDDiagnostic() else BNP:Print("GUID diagnostics are not loaded.") end + elseif msg == "dragontest" then + BNP_ToggleDirectDragonRenderTest() + elseif msg == "eliteinfo" then + BNP_PrintEliteInfo() + elseif msg == "eliteauto" then + BNP:Print("Elite auto | ticks " .. tostring(BNP_EliteAutoState.ticks) .. " | visible " .. tostring(BNP_EliteAutoState.visible) .. " | elite " .. tostring(BNP_EliteAutoState.elite) .. " | shown " .. tostring(BNP_EliteAutoState.shown) .. " | lastClass " .. tostring(BNP_EliteAutoState.lastClass) .. " | error " .. tostring(BNP_EliteAutoState.lastError)) + elseif msg == "status" then + local visible = 0 + local plate + for plate in pairs(BNP.plates) do + if plate:IsShown() then visible = visible + 1 end + end + BNP:Print("registered: " .. BNP.detected .. ", visible: " .. visible .. ", test spell: " .. tostring(BNP.corruptionName or "not found")) + elseif msg == "cache" then + local name = UnitName("target") + if not name then + BNP:Print("No target selected.") + return + end + local remaining = BNP.FindCachedCorruption and BNP.FindCachedCorruption(name) + BNP:Print("Cache for " .. name .. ": " .. tostring(remaining or "no Corruption found")) + elseif msg == "castbars on" then + if BNP.SetCastbarsEnabled then BNP:SetCastbarsEnabled(true) end + elseif msg == "castbars off" then + if BNP.SetCastbarsEnabled then BNP:SetCastbarsEnabled(false) end + elseif msg == "scale" then + BNP:Print("Current nameplate scale: " .. tostring(BNP:GetNameplateScale())) + elseif msg == "scale reset" then + if BNP.ResetNameplateScale then BNP:ResetNameplateScale() end + elseif string.find(msg, "^scale%s+") then + local _, _, value = string.find(msg, "^scale%s+([0-9%.]+)") + if BNP.SetNameplateScale then BNP:SetNameplateScale(value) end + elseif msg == "classcolors on" then + BNP_DB = BNP_DB or {} + BNP_DB.classColors = true + BNP:Print("Class colors enabled.") + elseif msg == "classcolors off" then + BNP_DB = BNP_DB or {} + BNP_DB.classColors = false + BNP:Print("Class colors disabled.") + elseif msg == "debug on" then + BNP:SetDebug(true) + elseif msg == "debug off" then + BNP:SetDebug(false) + elseif msg == "debug" then + BNP:SetDebug(not BNP.debugEnabled) + else + BNP:Print("Commands: /bnp unknown, /bnp direct, /bnp spellprobe, /bnp spelldb, /bnp guid, /bnp dragontest, /bnp eliteinfo, /bnp eliteauto, /bnp status, /bnp castbars on/off, /bnp scale <0.5-1.5>, /bnp scale reset, /bnp classcolors on/off, /bnp debug on, /bnp debug off") + end +end diff --git a/modules/debug.lua b/modules/debug.lua new file mode 100644 index 0000000..7cd623a --- /dev/null +++ b/modules/debug.lua @@ -0,0 +1,43 @@ +if not BNP.libnameplate then return end + +local function CreateMarker(plate) + if not plate then return end + BNP:RegisterPlate(plate) + + if not plate.BNPMarker then + local marker = CreateFrame("Frame", nil, plate) + marker:SetWidth(14) + marker:SetHeight(14) + marker:SetPoint("BOTTOM", plate, "TOP", 0, 34) + marker:SetFrameLevel(30) + + marker.texture = marker:CreateTexture(nil, "OVERLAY") + marker.texture:SetAllPoints(marker) + marker.texture:SetTexture("Interface\\Buttons\\WHITE8X8") + marker.texture:SetVertexColor(1, 0, 0, 1) + + marker.text = marker:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + marker.text:SetPoint("CENTER", marker, "CENTER", 0, 0) + marker.text:SetText("+") + marker.text:SetTextColor(1, 1, 1) + plate.BNPMarker = marker + end + + if BNP.debugEnabled then plate.BNPMarker:Show() else plate.BNPMarker:Hide() end +end + +table.insert(BNP.libnameplate.OnInit, CreateMarker) +table.insert(BNP.libnameplate.OnShow, CreateMarker) + +function BNP:SetDebug(enabled) + BNP.debugEnabled = enabled + BNP_DB = BNP_DB or {} + BNP_DB.debugEnabled = enabled + local plate + for plate in pairs(BNP.plates) do + if plate.BNPMarker then + if enabled then plate.BNPMarker:Show() else plate.BNPMarker:Hide() end + end + end + BNP:Print("Debug-Marker " .. (enabled and "aktiviert" or "deaktiviert") .. ".") +end diff --git a/modules/fullalpha.lua b/modules/fullalpha.lua new file mode 100644 index 0000000..cdbdf1a --- /dev/null +++ b/modules/fullalpha.lua @@ -0,0 +1,569 @@ +BNP = BNP or {} + +local NON_TARGET_ALPHA = 0.55 +local TARGET_RESOLVE_CACHE_TIME = 0.05 +local ALPHA_IDENTITY_HOLD_TIME = 0.20 + +local targetResolveCacheGUID = nil +local targetResolveCacheAt = -100 +local targetResolveCacheFound = false +local classicTargetCacheGUID = nil +local classicTargetCacheAt = -100 +local classicTargetCachePlate = nil +local classicTargetCacheAvailable = false + +local function GetPlateGUID(plate) + if not plate or not plate.GetName then return nil end + local token = plate:GetName(1) + if not token then return nil end + local exists, guid = UnitExists(token) + if exists and guid then return guid end + return token +end + +local function GetVerifiedPlateGUID(plate) + if not plate or not plate.GetName then return nil end + local token = plate:GetName(1) + if not token then return nil end + + -- Alpha must only be lowered after SuperWoW has positively resolved the + -- projected plate token. While rotating the camera Blizzard can recycle and + -- re-show a plate before its token is live again. During that short window + -- treating nil/stale identity as "non-target" caused random low-alpha pops. + local exists, guid = UnitExists(token) + if exists and guid then return guid end + return nil +end + +local function GetTargetGUID() + local exists, guid = UnitExists("target") + if exists and guid then return guid end + return nil +end + +local function InvalidateTargetResolution() + targetResolveCacheGUID = nil + targetResolveCacheAt = -100 + targetResolveCacheFound = false + classicTargetCacheGUID = nil + classicTargetCacheAt = -100 + classicTargetCachePlate = nil + classicTargetCacheAvailable = false +end + +local function GetClassicAPITargetPlate(targetGUID) + local now = GetTime() + if classicTargetCacheGUID == targetGUID and + now - classicTargetCacheAt < TARGET_RESOLVE_CACHE_TIME then + return classicTargetCachePlate, classicTargetCacheAvailable + end + + local available = C_NamePlate and + type(C_NamePlate.GetNamePlateForUnit) == "function" + local targetPlate = nil + + if available then + local ok, plate = pcall(C_NamePlate.GetNamePlateForUnit, "target") + if ok then targetPlate = plate end + end + + classicTargetCacheGUID = targetGUID + classicTargetCacheAt = now + classicTargetCachePlate = targetPlate + classicTargetCacheAvailable = available and true or false + return targetPlate, classicTargetCacheAvailable +end + +local function GetPlateToken(plate) + if not plate or not plate.GetName then return nil end + return plate:GetName(1) +end + +local function GetRegionText(region) + if not region or not region.GetText then return nil end + return region:GetText() +end + +local function PlateMatchesTargetVisual(plate) + if not UnitName then return false end + + local targetName = UnitName("target") + local plateName = GetRegionText(plate and plate.name) + if not targetName or not plateName or targetName ~= plateName then + return false + end + + -- Same-name mobs are common. A known, different level therefore rules the + -- plate out. If the level is hidden/"??", keeping another same-name plate at + -- full alpha is the safe failure mode; it is preferable to dimming the real + -- target because a projected token still carries its previous GUID. + if UnitLevel then + local targetLevel = UnitLevel("target") + local plateLevel = tonumber(GetRegionText(plate and plate.level)) + if targetLevel and targetLevel > 0 and plateLevel and plateLevel > 0 and + targetLevel ~= plateLevel then + return false + end + end + + return true +end + +local function IsPlateCurrentTarget(plate, targetGUID) + if not plate or not targetGUID then return false end + + local token = GetPlateToken(plate) + if token and UnitIsUnit then + local ok, sameUnit = pcall(UnitIsUnit, token, "target") + if ok and sameUnit then return true end + end + + local plateGUID = GetVerifiedPlateGUID(plate) + if plateGUID and plateGUID == targetGUID then return true end + + -- SuperWoW can briefly return the old, but still syntactically valid, GUID + -- for a recycled projected nameplate. The displayed Blizzard name/level has + -- already switched by then and protects the actual target from being dimmed. + return PlateMatchesTargetVisual(plate) +end + +local function HasResolvedVisibleTarget(targetGUID) + local now = GetTime() + if targetResolveCacheGUID == targetGUID and + now - targetResolveCacheAt < TARGET_RESOLVE_CACHE_TIME then + return targetResolveCacheFound + end + + local found = false + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate.IsShown and plate:IsShown() and + IsPlateCurrentTarget(plate, targetGUID) then + found = true + break + end + end + + targetResolveCacheGUID = targetGUID + targetResolveCacheAt = now + targetResolveCacheFound = found + return found +end + +local function IsForeignTagged(plate) + if not plate or not plate.GetName then return false end + local token = plate:GetName(1) + if not token then return false end + + local exists = UnitExists(token) + if not exists then return false end + + if UnitCanAttack and not UnitCanAttack("player", token) then + return false + end + + if not UnitIsTapped or not UnitIsTapped(token) then + return false + end + + if UnitIsTappedByPlayer and UnitIsTappedByPlayer(token) then + return false + end + + return true +end + +local function DesiredAlpha(plate) + -- No selected target: keep all nameplates fully visible. + local targetGUID = GetTargetGUID() + if not targetGUID then return 1 end + + local classicTargetPlate, classicTargetAvailable = + GetClassicAPITargetPlate(targetGUID) + + if classicTargetAvailable then + -- Exact ClassicAPI frame resolution makes the old transition hold + -- unnecessary. More importantly, it lets us keep alpha stable even when a + -- crowded projected nameplate token briefly stops resolving. The previous + -- code treated an unresolved non-target token as alpha 1 and then faded it + -- again on the next tick, which made both the plate and its aura children + -- visibly pulse/flash in large stacked raids. + if not classicTargetPlate then return 1 end + + -- The exact target frame is authoritative and must always stay opaque, + -- even while its SuperWoW token is transiently unavailable. + if plate == classicTargetPlate then return 1 end + + -- GUID equality remains as a conservative fallback in case a client build + -- returns a wrapper/alias for the target nameplate rather than the exact + -- frame object tracked by BNP. + local plateGUID = GetVerifiedPlateGUID(plate) + if plateGUID and plateGUID == targetGUID then return 1 end + + -- Once ClassicAPI has positively identified the real target frame, every + -- other visible plate is safely a non-target. Do not bounce it to alpha 1 + -- merely because its projected token was nil for one raid-stack tick. + if BNP.GetNonTargetAlpha then + return BNP:GetNonTargetAlpha() + end + return 1 + end + + -- On target changes and recycled OnShow transitions, the projected token can + -- temporarily expose its previous valid GUID. Keep the plate neutral until + -- Blizzard/SuperWoW have had time to publish the new identity. This is only + -- the legacy path used when exact ClassicAPI nameplate resolution is absent. + if plate.BNPAlphaIdentityHoldUntil and + GetTime() < plate.BNPAlphaIdentityHoldUntil then + return 1 + end + + -- GUID equality is preferred. UnitIsUnit and the displayed Blizzard + -- name/level are conservative fallbacks for the stale-valid-GUID window. + if IsPlateCurrentTarget(plate, targetGUID) then + return 1 + end + + -- Do not dim anything until the selected target itself can be associated + -- with a currently visible plate. Uncertainty must fail to full opacity. + if not HasResolvedVisibleTarget(targetGUID) then + return 1 + end + + -- A recycled/reappearing plate can exist for a moment before SuperWoW has + -- attached a trustworthy GUID again. Never dim an unidentified plate; doing + -- so is what caused the random low-alpha flash when rotating the camera. + local plateGUID = GetVerifiedPlateGUID(plate) + if not plateGUID then return 1 end + + -- Current target always stays fully visible. + if plateGUID == targetGUID then + return 1 + end + + -- Only positively identified non-target plates use the configured alpha. + if BNP.GetNonTargetAlpha then + return BNP:GetNonTargetAlpha() + end + + return 1 +end + +local function SetLevelSafe(frame, level) + if frame and frame.SetFrameLevel and level then + if frame:GetFrameLevel() ~= level then + frame:SetFrameLevel(level) + end + end +end + +local function ApplyTargetFrameLevel(plate) + if not plate or not plate:IsShown() then return end + + -- IMPORTANT: + -- Never change the FrameStrata of projected Vanilla nameplates. + -- Their native client/world layer keeps them behind Blizzard UI frames. + -- Permanent target priority is therefore implemented only with FrameLevel. + + if plate.BNPBaseFrameLevel == nil then + plate.BNPBaseFrameLevel = plate:GetFrameLevel() or 0 + end + + local wrapper = plate.BNPScaleWrapper + if wrapper and plate.BNPBaseWrapperLevel == nil then + plate.BNPBaseWrapperLevel = wrapper:GetFrameLevel() or plate.BNPBaseFrameLevel + end + + local healthbar = plate.healthbar + if healthbar and plate.BNPBaseHealthbarLevel == nil then + plate.BNPBaseHealthbarLevel = healthbar:GetFrameLevel() or 1 + end + + -- ApplyTargetAlpha runs immediately before this function and publishes the + -- exact ClassicAPI/SuperWoW target identity on the plate. + local target = plate.BNPIsCurrentTarget and true or false + + local plateLevel = plate.BNPBaseFrameLevel + local wrapperLevel = plate.BNPBaseWrapperLevel or plateLevel + local healthbarLevel = plate.BNPBaseHealthbarLevel or 1 + + if target then + -- Raise the target only relative to other nameplates while preserving the + -- native projected-nameplate strata beneath the Blizzard interface. + plateLevel = plateLevel + 50 + wrapperLevel = wrapperLevel + 50 + healthbarLevel = healthbarLevel + 50 + end + + SetLevelSafe(plate, plateLevel) + if wrapper then + SetLevelSafe(wrapper, wrapperLevel) + end + if healthbar then + -- The Blizzard border/name are regions of the raised wrapper, but the + -- actual StatusBar is its own frame and must be raised explicitly. + SetLevelSafe(healthbar, healthbarLevel) + end +end + +local function ApplyForeignTagVisual(plate) + if not plate or not plate:IsShown() then return end + + local bar = plate.healthbar + if not bar or not bar.SetStatusBarColor then return end + + local foreign = IsForeignTagged(plate) + + if foreign then + if not plate.BNPForeignTagGrey then + local r, g, b, a = bar:GetStatusBarColor() + plate.BNPForeignTagOldR = r + plate.BNPForeignTagOldG = g + plate.BNPForeignTagOldB = b + plate.BNPForeignTagOldA = a + plate.BNPForeignTagGrey = true + end + + -- Neutral grey: visually distinct from Target Focus transparency. + bar:SetStatusBarColor(0.45, 0.45, 0.45, plate.BNPForeignTagOldA or 1) + elseif plate.BNPForeignTagGrey then + -- Restore the color that BNP/Shagu/class-color logic had before the mob + -- became foreign-tagged. Clear state so future color changes can be learned. + bar:SetStatusBarColor( + plate.BNPForeignTagOldR or 1, + plate.BNPForeignTagOldG or 0, + plate.BNPForeignTagOldB or 0, + plate.BNPForeignTagOldA or 1 + ) + plate.BNPForeignTagGrey = nil + plate.BNPForeignTagOldR = nil + plate.BNPForeignTagOldG = nil + plate.BNPForeignTagOldB = nil + plate.BNPForeignTagOldA = nil + end +end + +local function GetGlowVisualParent(plate) + if not plate then return nil end + + local anchor = plate.healthbar or plate + local visualParent = plate.BNPScaleWrapper + + if not visualParent and anchor and anchor.GetParent then + visualParent = anchor:GetParent() + end + + return visualParent or plate +end + +local function EnsureTargetGlow(plate) + if not plate then return nil end + if plate.BNPTargetGlow then return plate.BNPTargetGlow end + + local anchor = plate.healthbar or plate + if not anchor then return nil end + + -- Keep Blizzard's original healthbar/frame layering completely untouched. + -- The glow belongs to the healthbar itself on BACKGROUND, so it stays behind + -- the statusbar fill without raising/reparenting the Blizzard healthbar. + -- + -- Direct port of ShaguPlates' target glow geometry: + -- nameplate.glow = nameplate:CreateTexture(nil, "BACKGROUND") + -- glow anchored to health CENTER + -- width = health width + 60 + -- height = health height + 30 + -- + -- BNP's only intentional visual change is +3 px on X. + local glow = anchor:CreateTexture(nil, "BACKGROUND") + glow:SetTexture("Interface\\AddOns\\BlizzNameplatesPlus\\media\\shagu_target_glow.tga") + glow:SetPoint("CENTER", anchor, "CENTER", 3, 0) + + local width = anchor.GetWidth and anchor:GetWidth() or 120 + local height = anchor.GetHeight and anchor:GetHeight() or 8 + glow:SetWidth(width + 60) + glow:SetHeight(height + 30) + + local r, g, b = 1, 1, 1 + if BNP.GetTargetGlowColor then + r, g, b = BNP:GetTargetGlowColor() + end + glow:SetVertexColor(r, g, b, 1) + glow:Hide() + + plate.BNPTargetGlow = glow + return glow +end + +local function ApplyTargetGlow(plate) + if not plate or not plate:IsShown() then return end + + local glow = EnsureTargetGlow(plate) + if not glow then return end + + local enabled = BNP.IsTargetFocusEnabled and BNP:IsTargetFocusEnabled() + if not enabled then + glow:Hide() + return + end + + local targetGUID = GetTargetGUID() + local plateGUID = GetPlateGUID(plate) + + if targetGUID and plateGUID and targetGUID == plateGUID then + local anchor = plate.healthbar or plate + local width = anchor.GetWidth and anchor:GetWidth() or 120 + local height = anchor.GetHeight and anchor:GetHeight() or 8 + + + glow:ClearAllPoints() + glow:SetPoint("CENTER", anchor, "CENTER", 3, 0) + glow:SetWidth(width + 60) + glow:SetHeight(height + 30) + + if BNP.GetTargetGlowColor then + local r, g, b = BNP:GetTargetGlowColor() + glow:SetVertexColor(r, g, b, 1) + end + + glow:SetAlpha(1) + glow:Show() + else + glow:Hide() + end +end + +local function ApplyTargetAlpha(plate) + if not plate or not plate:IsShown() then return end + local totemIconOnly = plate.BNPTotemLastKey and + BNP.AreTotemIndicatorsEnabled and BNP:AreTotemIndicatorsEnabled() + local wanted = totemIconOnly and 1 or DesiredAlpha(plate) + if plate:GetAlpha() ~= wanted then + plate:SetAlpha(wanted) + end + + -- Publish the same resolved target identity for detached aura containers. + -- They cannot inherit it through their shared WorldFrame parent. + local targetGUID = GetTargetGUID() + local targetPlate = nil + local targetAvailable = false + local isCurrentTarget = false + if targetGUID then + targetPlate, targetAvailable = GetClassicAPITargetPlate(targetGUID) + if targetAvailable and targetPlate then + isCurrentTarget = plate == targetPlate + if not isCurrentTarget then + local plateGUID = GetVerifiedPlateGUID(plate) + isCurrentTarget = plateGUID and plateGUID == targetGUID + end + else + isCurrentTarget = IsPlateCurrentTarget(plate, targetGUID) + end + end + plate.BNPIsCurrentTarget = isCurrentTarget and true or false + + -- ClassicAPI returns the underlying target nameplate frame itself. Force it + -- opaque after every alpha application. Even if SuperWoW momentarily reports + -- the recycled frame's previous valid GUID, both writes happen in the same + -- Lua update and the real target finishes the frame at alpha 1. + if targetGUID then + if targetAvailable and targetPlate and targetPlate.GetAlpha and targetPlate.SetAlpha then + targetPlate.BNPIsCurrentTarget = true + local ok, alpha = pcall(targetPlate.GetAlpha, targetPlate) + if not ok or alpha ~= 1 then + pcall(targetPlate.SetAlpha, targetPlate, 1) + end + end + end +end + +local function InstallAlphaGuard(plate) + plate = plate or this + if not plate or plate.BNPFullAlphaGuard then return end + + plate.BNPAlphaIdentityHoldUntil = GetTime() + ALPHA_IDENTITY_HOLD_TIME + + local oldOnUpdate = plate:GetScript("OnUpdate") + plate:SetScript("OnUpdate", function() + if oldOnUpdate then oldOnUpdate() end + local current = this or plate + + -- Apply the optional Y-offset after Blizzard has had a chance to update + -- the projected nameplate for this frame. This uses the OnUpdate BNP + -- already needs for alpha/glow/target-level handling. + if BNP.MaintainNameplateYOffset then + BNP:MaintainNameplateYOffset(current) + end + + ApplyTargetAlpha(current) + ApplyTargetGlow(current) + ApplyForeignTagVisual(current) + ApplyTargetFrameLevel(current) + end) + + plate.BNPFullAlphaGuard = true + ApplyTargetAlpha(plate) +end + +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnInit, function(plate) + InstallAlphaGuard(plate or this) + end) + + table.insert(BNP.libnameplate.OnShow, function(plate) + local current = plate or this + if not current then return end + InvalidateTargetResolution() + current.BNPAlphaIdentityHoldUntil = GetTime() + ALPHA_IDENTITY_HOLD_TIME + if current.GetAlpha and current.SetAlpha and current:GetAlpha() ~= 1 then + current:SetAlpha(1) + end + InstallAlphaGuard(current) + ApplyTargetAlpha(current) + ApplyTargetGlow(current) + ApplyForeignTagVisual(current) + ApplyTargetFrameLevel(current) + end) +end + +local targetEvents = CreateFrame("Frame") +targetEvents:RegisterEvent("PLAYER_TARGET_CHANGED") +targetEvents:SetScript("OnEvent", function() + InvalidateTargetResolution() + + local targetGUID = GetTargetGUID() + local _, classicTargetAvailable = GetClassicAPITargetPlate(targetGUID) + local holdUntil = GetTime() + ALPHA_IDENTITY_HOLD_TIME + local plate + for plate in pairs(BNP.plates or {}) do + if classicTargetAvailable then + plate.BNPAlphaIdentityHoldUntil = nil + ApplyTargetAlpha(plate) + else + plate.BNPAlphaIdentityHoldUntil = holdUntil + if plate.GetAlpha and plate.SetAlpha and plate:GetAlpha() ~= 1 then + plate:SetAlpha(1) + end + end + ApplyTargetFrameLevel(plate) + end + + if BNP.RefreshAuraPriorityAlpha then BNP:RefreshAuraPriorityAlpha() end +end) + +function BNP:RefreshTargetFocus() + InvalidateTargetResolution() + local plate + for plate in pairs(self.plates or {}) do + ApplyTargetAlpha(plate) + ApplyTargetGlow(plate) + ApplyForeignTagVisual(plate) + end +end + +function BNP:RefreshNonTargetAlpha() + InvalidateTargetResolution() + local plate + for plate in pairs(self.plates or {}) do + ApplyTargetAlpha(plate) + end +end diff --git a/modules/guiddebug.lua b/modules/guiddebug.lua new file mode 100644 index 0000000..0eedb2d --- /dev/null +++ b/modules/guiddebug.lua @@ -0,0 +1,73 @@ +-- SuperWoW GUID probe. +-- This module does not alter aura tracking or rendering. It only inspects the +-- extended UnitExists return value and the unit token exposed by nameplates. + +local function CleanText(text) + if not text then return nil end + text = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "") + text = string.gsub(text, "|r", "") + return text +end + +local function SafePlateToken(plate) + if not plate or not plate.GetName then return nil end + + -- SuperWoW extends frame:GetName(index). ShaguTweaks already uses + -- plate:GetName(1) for its SuperWoW nameplate castbar implementation. + local ok, token = pcall(function() return plate:GetName(1) end) + if ok then return token end + return nil +end + +local function UnitExistsData(unit) + if not unit then return nil, nil end + local ok, exists, guid = pcall(UnitExists, unit) + if not ok then return nil, nil end + return exists, guid +end + +function BNP:GetSuperWoWGUID(unit) + local exists, guid = UnitExistsData(unit) + if exists and guid then return guid end + return nil +end + +function BNP:GetPlateSuperWoWData(plate) + local token = SafePlateToken(plate) + local exists, guid = UnitExistsData(token) + return token, exists, guid +end + +function BNP:PrintGUIDDiagnostic() + BNP:Print("=== SuperWoW GUID Diagnose ===") + + local targetName = UnitName("target") + local targetExists, targetGUID = UnitExistsData("target") + BNP:Print("Target: " .. tostring(targetName or "kein Ziel")) + BNP:Print("UnitExists(target): " .. tostring(targetExists) .. " | GUID: " .. tostring(targetGUID)) + + local visible = 0 + local sameName = 0 + local guidMatches = 0 + local index = 0 + + for plate in pairs(BNP.plates) do + if plate and plate:IsShown() then + visible = visible + 1 + local plateName = plate.name and plate.name.GetText and CleanText(plate.name:GetText()) or nil + + if not targetName or plateName == targetName then + sameName = sameName + 1 + index = index + 1 + local token, exists, guid = BNP:GetPlateSuperWoWData(plate) + if targetGUID and guid and targetGUID == guid then guidMatches = guidMatches + 1 end + + BNP:Print("Plate " .. index .. ": " .. tostring(plateName)) + BNP:Print(" Token: " .. tostring(token) .. " | Exists: " .. tostring(exists)) + BNP:Print(" GUID: " .. tostring(guid) .. " | Target-Match: " .. tostring(targetGUID and guid == targetGUID or false)) + end + end + end + + BNP:Print("Sichtbar: " .. visible .. " | gleicher Name: " .. sameName .. " | GUID-Matches: " .. guidMatches) +end diff --git a/modules/healthpercent.lua b/modules/healthpercent.lua new file mode 100644 index 0000000..817e3d7 --- /dev/null +++ b/modules/healthpercent.lua @@ -0,0 +1,223 @@ +BNP = BNP or {} + +local function Enabled() + return BNP.IsHealthTextEnabled and BNP:IsHealthTextEnabled() +end + +local function CreateHealthText(plate) + if plate.BNPHealthPercent then return plate.BNPHealthPercent end + if not plate or not plate.healthbar then return nil end + + -- Parent the text directly to the Blizzard health StatusBar. This keeps it + -- above the bar texture and automatically follows every frame-level change + -- made for Target Glow / permanent target priority. No separate holder can + -- become stale during target switches or nameplate recycling. + local anchor = plate.healthbar + local text = anchor:CreateFontString(nil, "OVERLAY") + text:SetFont("Fonts\\FRIZQT__.TTF", 9, "OUTLINE") + text:SetPoint("CENTER", anchor, "CENTER", 3, 0) + text:SetTextColor(1, 1, 1) + text:Hide() + + plate.BNPHealthPercent = text + return text +end + +local function GetPercentFromBar(plate) + if not plate or not plate.healthbar then return nil end + + local minValue, maxValue = plate.healthbar:GetMinMaxValues() + local value = plate.healthbar:GetValue() + + if value and minValue and maxValue and maxValue > minValue then + local pct = ((value - minValue) / (maxValue - minValue)) * 100 + return math.floor(pct + 0.5) + end + + return nil +end + +local function GetPercentFromUnit(plate) + if not plate or not plate.GetName then return nil end + + local token = plate:GetName(1) + if not token or not UnitExists(token) then return nil end + if not UnitHealth or not UnitHealthMax then return nil end + + local health = UnitHealth(token) + local maxHealth = UnitHealthMax(token) + + if health and maxHealth and maxHealth > 0 then + return math.floor((health / maxHealth) * 100 + 0.5) + end + + return nil +end + +local function GetHealthFromUnit(plate) + if not plate or not plate.GetName then return nil, nil end + + local token = plate:GetName(1) + if not token or not UnitExists(token) then return nil, nil end + if not UnitHealth or not UnitHealthMax then return nil, nil end + + local health = tonumber(UnitHealth(token)) + local maxHealth = tonumber(UnitHealthMax(token)) + + if health and maxHealth and maxHealth > 0 then + return health, maxHealth + end + + return nil, nil +end + +local function GetHealthFromBar(plate) + if not plate or not plate.healthbar then return nil, nil end + + local minValue, maxValue = plate.healthbar:GetMinMaxValues() + local value = plate.healthbar:GetValue() + + value = tonumber(value) + minValue = tonumber(minValue) + maxValue = tonumber(maxValue) + + if value and minValue and maxValue and maxValue > minValue then + return value - minValue, maxValue - minValue + end + + return nil, nil +end + +local function FormatHealth(value) + value = tonumber(value) + if not value then return nil end + if value < 0 then value = 0 end + + -- Keep boss/player values readable inside the compact Blizzard healthbar. + if value >= 1000000 then + local shown = math.floor((value / 1000000) * 10 + 0.5) / 10 + return tostring(shown) .. "m" + elseif value >= 10000 then + return tostring(math.floor(value / 1000 + 0.5)) .. "k" + elseif value >= 1000 then + local shown = math.floor((value / 1000) * 10 + 0.5) / 10 + return tostring(shown) .. "k" + end + + return tostring(math.floor(value + 0.5)) +end + +local function UpdatePlate(plate) + if not plate or not plate.healthbar then return end + + local text = plate.BNPHealthPercent + + if not Enabled() or not plate:IsShown() then + if text then text:Hide() end + return + end + + text = text or CreateHealthText(plate) + if not text then return end + + local mode = BNP:GetHealthTextMode() + local health, maxHealth = GetHealthFromUnit(plate) + + -- Fallback for clients/units where the SuperWoW unit token is temporarily + -- unavailable. This may be normalized health on stock Vanilla. + if health == nil or maxHealth == nil then + health, maxHealth = GetHealthFromBar(plate) + end + + local pct = nil + if health and maxHealth and maxHealth > 0 then + pct = math.floor((health / maxHealth) * 100 + 0.5) + else + pct = GetPercentFromBar(plate) + if pct == nil then pct = GetPercentFromUnit(plate) end + end + + if pct ~= nil then + if pct < 0 then pct = 0 end + if pct > 100 then pct = 100 end + end + + local hpText = FormatHealth(health) + local display = nil + + if mode == "percent" then + if pct ~= nil then display = tostring(pct) .. "%" end + elseif mode == "hp" then + display = hpText + elseif mode == "both" then + if hpText and pct ~= nil then + display = hpText .. " | " .. tostring(pct) .. "%" + elseif hpText then + display = hpText + elseif pct ~= nil then + display = tostring(pct) .. "%" + end + end + + if not display or display == "" then + text:Hide() + return + end + + if text.BNPLastHealthText ~= display then + text:SetText(display) + text.BNPLastHealthText = display + end + text:Show() +end + +-- Intentionally simple fallback: only reads values and updates text. +-- No SetScript calls on healthbar, no SetAlpha, no color changes. +local updater = CreateFrame("Frame") +local elapsed = 0 +updater:SetScript("OnUpdate", function() + elapsed = elapsed + arg1 + if elapsed < 0.10 then return end + elapsed = 0 + + local plate + for plate in pairs(BNP.plates or {}) do + if plate:IsShown() then + UpdatePlate(plate) + elseif plate.BNPHealthPercent then + plate.BNPHealthPercent:Hide() + end + end +end) + +function BNP:RefreshHealthPercent() + local plate + for plate in pairs(self.plates or {}) do + if plate and plate:IsShown() then + UpdatePlate(plate) + elseif plate and plate.BNPHealthPercent then + plate.BNPHealthPercent:Hide() + end + end +end + +-- Refresh immediately when Blizzard reuses a nameplate instead of waiting for +-- the next 0.10 s fallback pass. +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnShow, function(plate) + local current = plate or this + if current then UpdatePlate(current) end + end) +end + +-- Target switches can change nameplate frame levels in the same frame. Because +-- the text now belongs to the healthbar this is only a value/visibility refresh, +-- not a layering workaround. +local healthEvents = CreateFrame("Frame") +healthEvents:RegisterEvent("PLAYER_TARGET_CHANGED") +healthEvents:RegisterEvent("PLAYER_ENTERING_WORLD") +healthEvents:SetScript("OnEvent", function() + if BNP.RefreshHealthPercent then + BNP:RefreshHealthPercent() + end +end) diff --git a/modules/scale.lua b/modules/scale.lua new file mode 100644 index 0000000..ebedae4 --- /dev/null +++ b/modules/scale.lua @@ -0,0 +1,353 @@ +BNP = BNP or {} + +local MIN_SCALE = 0.50 +local MAX_SCALE = 1.50 +local DEFAULT_SCALE = 1.00 + +local function Clamp(value) + if value < MIN_SCALE then return MIN_SCALE end + if value > MAX_SCALE then return MAX_SCALE end + return value +end + +function BNP:GetNameplateScale() + BNP_DB = BNP_DB or {} + return Clamp(tonumber(BNP_DB.nameplateScale) or DEFAULT_SCALE) +end + +local function EffectiveScale() + local uiScale = 1.0 + if UIParent and UIParent.GetScale then + uiScale = UIParent:GetScale() or 1.0 + end + return uiScale * BNP:GetNameplateScale() +end + +local function CaptureAnchorState(object, plate, wrapper) + if not object or not object.GetPoint then return nil end + + local state = { + object = object, + points = {}, + } + + local count = 1 + if object.GetNumPoints then + count = object:GetNumPoints() or 1 + if count < 1 then count = 1 end + end + + local i + for i = 1, count do + local point, relativeTo, relativePoint, x, y = object:GetPoint(i) + if point then + if relativeTo == plate then + relativeTo = wrapper + end + + state.points[table.getn(state.points) + 1] = { + point = point, + relativeTo = relativeTo or wrapper, + relativePoint = relativePoint or point, + x = x or 0, + y = y or 0, + } + end + end + + return state +end + +local function AnchorStateMatches(state) + if not state or not state.object or not state.object.GetPoint then return false end + + local object = state.object + local expectedCount = table.getn(state.points) + local currentCount = expectedCount + + if object.GetNumPoints then + currentCount = object:GetNumPoints() or 0 + end + + if currentCount ~= expectedCount then + return false + end + + local i + for i = 1, expectedCount do + local expected = state.points[i] + local point, relativeTo, relativePoint, x, y = object:GetPoint(i) + + if point ~= expected.point + or relativeTo ~= expected.relativeTo + or relativePoint ~= expected.relativePoint + or math.abs((x or 0) - expected.x) > 0.01 + or math.abs((y or 0) - expected.y) > 0.01 then + return false + end + end + + return true +end + +local function RestoreAnchorState(state, wrapper) + if not state or not state.object then return end + + local object = state.object + + if object.GetParent and object.SetParent and object:GetParent() ~= wrapper then + object:SetParent(wrapper) + end + + -- Do nothing in the common case. Blizzard normally only rewrites a subset + -- of nameplate anchors while moving, most notably the level FontString. + if AnchorStateMatches(state) then return end + + if not object.ClearAllPoints or not object.SetPoint then return end + + object:ClearAllPoints() + + local i + for i = 1, table.getn(state.points) do + local p = state.points[i] + object:SetPoint( + p.point, + p.relativeTo or wrapper, + p.relativePoint or p.point, + p.x, + p.y + ) + end +end + +local function SetHoverGlow(plate, shown) + if not plate or not plate.glow then return end + + if shown then + if plate.glow.Show then + plate.glow:Show() + end + else + if plate.glow.Hide then + plate.glow:Hide() + end + end +end + +local function SetYOffsetMouseMode(plate, wrapper, offset) + if not plate or not wrapper then return end + + -- Always use the visual wrapper as BNP's interactive frame, including at + -- Y Offset 0. Previously offset 0 used Blizzard's native Button while + -- positive offsets used BNPScaleWrapper. That split mouse path caused the + -- original Blizzard mouseover glow to disappear at offset 0. + -- + -- Keeping clicks, hit geometry and hover feedback on the same frame also + -- guarantees that the clickable area follows the visible plate at every + -- configured Y offset. + if plate.EnableMouse then + plate:EnableMouse(false) + end + + if wrapper.EnableMouse then + wrapper:EnableMouse(true) + end + + if wrapper.RegisterForClicks then + -- Preserve the classic Blizzard nameplate mouse behavior: + -- left click selects the unit, right click selects it and starts auto attack. + wrapper:RegisterForClicks("LeftButtonUp", "RightButtonUp") + end + + if not wrapper.BNPClickForwardInstalled then + wrapper:SetScript("OnClick", function() + -- The wrapper exists only because BNP visually offsets/scales the original + -- Blizzard plate. Keep Blizzard's native nameplate click semantics by + -- forwarding the ACTUAL mouse button to the original Button. + -- + -- parent:Click() without an argument always simulates LeftButton. That was + -- the reason right click turned into a plain target-select while BNP was + -- enabled. Do not call AttackTarget() manually here: it toggles auto attack + -- and is not equivalent to Blizzard's native nameplate right-click path. + local mouseButton = arg1 or "LeftButton" + local current = this or wrapper + local parent = current and current.plate or plate + if parent and parent:IsShown() and parent.Click then + parent:Click(mouseButton) + end + end) + wrapper.BNPClickForwardInstalled = true + end + + if not wrapper.BNPMouseoverFeedbackInstalled then + wrapper:SetScript("OnEnter", function() + local current = this or wrapper + local parent = current and current.plate or plate + SetHoverGlow(parent, true) + end) + + wrapper:SetScript("OnLeave", function() + local current = this or wrapper + local parent = current and current.plate or plate + SetHoverGlow(parent, false) + end) + + wrapper.BNPMouseoverFeedbackInstalled = true + end +end + +local function ApplyWrapperOffset(plate, wrapper) + if not plate or not wrapper then return end + + local offset = 0 + if BNP.GetNameplateYOffset then + offset = BNP:GetNameplateYOffset() + end + + -- Move only the child visual container. The WorldFrame-projected Blizzard + -- nameplate itself keeps its original anchor and can continue to be + -- repositioned by the client every frame. + wrapper:ClearAllPoints() + wrapper:SetPoint("TOPLEFT", plate, "TOPLEFT", 0, offset) + wrapper:SetPoint("BOTTOMRIGHT", plate, "BOTTOMRIGHT", 0, offset) + + SetYOffsetMouseMode(plate, wrapper, offset) +end + +local function EnsureScaleWrapper(plate) + if not plate or plate.BNPScaleWrapper then return end + + local originalWidth = plate:GetWidth() + local originalHeight = plate:GetHeight() + + local wrapper = CreateFrame("Button", nil, plate) + wrapper:SetAllPoints(plate) + wrapper.plate = plate + wrapper.originalWidth = originalWidth + wrapper.originalHeight = originalHeight + wrapper.BNPAnchorStates = {} + wrapper:EnableMouse(false) + + plate.BNPScaleWrapper = wrapper + + -- Capture the ORIGINAL Blizzard anchors before changing parentage. + -- Those stored anchors become BNP's authoritative visual layout whenever + -- Y-offset is enabled. + if plate.healthbar then + local state = CaptureAnchorState(plate.healthbar, plate, wrapper) + if state then + table.insert(wrapper.BNPAnchorStates, state) + end + + plate.healthbar:SetParent(wrapper) + RestoreAnchorState(state, wrapper) + plate.healthbar:SetFrameLevel(1) + end + + local regions = { plate:GetRegions() } + local i, object + for i, object in pairs(regions) do + if object and object.SetParent then + local state = CaptureAnchorState(object, plate, wrapper) + if state then + table.insert(wrapper.BNPAnchorStates, state) + end + + object:SetParent(wrapper) + RestoreAnchorState(state, wrapper) + end + end + + ApplyWrapperOffset(plate, wrapper) +end + +function BNP:ApplyNameplateScale(plate) + if not plate then return end + EnsureScaleWrapper(plate) + + local wrapper = plate.BNPScaleWrapper + if not wrapper then return end + + local scale = EffectiveScale() + wrapper:SetScale(scale) + ApplyWrapperOffset(plate, wrapper) + + -- ShaguTweaks also adjusts the original frame bounds so Blizzard's world + -- positioning remains correct while the child visuals honor UI scale. + plate:SetWidth(wrapper.originalWidth * scale) + plate:SetHeight(wrapper.originalHeight * scale) +end + +function BNP:ApplyNameplateScaleAll() + local plate + for plate in pairs(self.plates) do + self:ApplyNameplateScale(plate) + end +end + +function BNP:ApplyNameplateYOffsetAll() + local plate + for plate in pairs(self.plates or {}) do + if plate then + self:ApplyNameplateScale(plate) + end + end +end + +function BNP:MaintainNameplateYOffset(plate) + if not plate or not plate:IsShown() then return end + if not self.GetNameplateYOffset or self:GetNameplateYOffset() <= 0 then return end + + EnsureScaleWrapper(plate) + + local wrapper = plate.BNPScaleWrapper + if not wrapper then return end + + -- Never rediscover regions through plate:GetRegions() here. Once BNP moves + -- Blizzard regions into the wrapper they are no longer guaranteed to be + -- returned by plate:GetRegions(). Keep and maintain the original region + -- objects captured during wrapper creation instead. + local states = wrapper.BNPAnchorStates or {} + local i + for i = 1, table.getn(states) do + RestoreAnchorState(states[i], wrapper) + end + + ApplyWrapperOffset(plate, wrapper) +end + +function BNP:SetNameplateScale(value) + value = tonumber(value) + if not value then + self:Print("Usage: /bnp scale 0.5-1.5") + return + end + + value = Clamp(value) + BNP_DB = BNP_DB or {} + BNP_DB.nameplateScale = value + self:ApplyNameplateScaleAll() + + self:Print("Nameplate scale multiplier set to " .. tostring(value) .. ".") +end + +function BNP:ResetNameplateScale() + BNP_DB = BNP_DB or {} + BNP_DB.nameplateScale = DEFAULT_SCALE + self:ApplyNameplateScaleAll() + self:Print("Nameplate scale multiplier reset to 1.0 (Shagu/UI scale behavior).") +end + +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnInit, function(plate) + local current = plate or this + if not current then return end + BNP:ApplyNameplateScale(current) + end) + + table.insert(BNP.libnameplate.OnShow, function(plate) + local current = plate or this + if not current then return end + BNP:ApplyNameplateScale(current) + end) +end diff --git a/modules/shagu_darkmode.lua b/modules/shagu_darkmode.lua new file mode 100644 index 0000000..3b02ebe --- /dev/null +++ b/modules/shagu_darkmode.lua @@ -0,0 +1,67 @@ +BNP = BNP or {} + +-- ShaguTweaks Darkened UI compatibility ------------------------------------- +-- ShaguTweaks darkens a nameplate once and marks it with plate.darkened=true. +-- Blizzard can later recycle that same plate and restore its original gold +-- textures while the flag remains true. Shagu then skips the recycled plate. +-- +-- BNP does not implement its own dark mode here. It simply asks ShaguTweaks +-- to run its own DarkenFrame() again whenever Blizzard reuses a nameplate. + +local pending = {} + +local function ShaguDarkModeActive() + return ShaguTweaks + and ShaguTweaks.DarkMode + and ShaguTweaks.DarkenFrame +end + +local function ReapplyShaguDarkMode(plate) + if not plate or not plate:IsShown() then return end + if not ShaguDarkModeActive() then return end + + -- Clear Shagu's one-time marker because this Blizzard plate may have been + -- recycled for another unit and had its textures reset. + plate.darkened = nil + + -- Use ShaguTweaks' own implementation/configuration as the single source of + -- truth. Its blacklist already protects StatusBars, spell icons, portraits, + -- etc. from being incorrectly darkened. + ShaguTweaks.DarkenFrame(plate) + plate.darkened = true +end + +local function QueueReapply(plate) + if not plate then return end + + -- Immediate pass handles normal OnShow. + ReapplyShaguDarkMode(plate) + + -- Blizzard may restore some nameplate texture state immediately after + -- OnShow. Do one delayed pass and then stop; no permanent scanner is needed. + pending[plate] = GetTime() + 0.08 +end + +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnInit, function(plate) + QueueReapply(plate or this) + end) + + table.insert(BNP.libnameplate.OnShow, function(plate) + QueueReapply(plate or this) + end) +end + +local updater = CreateFrame("Frame") +updater:SetScript("OnUpdate", function() + if not next(pending) then return end + + local now = GetTime() + local plate, due + for plate, due in pairs(pending) do + if now >= due then + pending[plate] = nil + ReapplyShaguDarkMode(plate) + end + end +end) diff --git a/modules/spellprobe.lua b/modules/spellprobe.lua new file mode 100644 index 0000000..636dc47 --- /dev/null +++ b/modules/spellprobe.lua @@ -0,0 +1,227 @@ +BNP = BNP or {} + +local wanted = { + corruption = { "Corruption", "Verderbnis" }, + agony = { "Curse of Agony", "Fluch der Pein" }, + siphon = { "Siphon Life", "Lebensentzug" }, + shadow_word_pain = { "Shadow Word: Pain", "Schattenwort: Schmerz" }, +} + +local function isWanted(name, list) + if not name then return false end + local lower = string.lower(name) + local i + for i = 1, table.getn(list) do + if lower == string.lower(list[i]) then return true end + end + return false +end + +local function normalizeNumber(value) + if not value then return nil end + value = string.gsub(value, ",", ".") + return tonumber(value) +end + +-- WoW 1.12 uses Lua 5.0. Use string.find captures instead of string.match. +local function durationFromLine(text) + if not text then return nil end + local lower = string.lower(text) + local _, _, num + + -- German: 14,95 Sek. / 14.95 Sekunden + _, _, num = string.find(lower, "([0-9]+[%,%.]?[0-9]*)%s*sek") + if num then return normalizeNumber(num) end + + -- English: 14.95 sec / seconds + _, _, num = string.find(lower, "([0-9]+[%,%.]?[0-9]*)%s*sec") + if num then return normalizeNumber(num) end + + -- Generic short form: "over 14.95 s" / "14,95 s" + _, _, num = string.find(lower, "over%s+([0-9]+[%,%.]?[0-9]*)%s+s") + if num then return normalizeNumber(num) end + + return nil +end + +local function getTooltip() + local tooltip = getglobal("BNPSpellProbeTooltip") + if tooltip then return tooltip end + + tooltip = CreateFrame("GameTooltip", "BNPSpellProbeTooltip", UIParent, "GameTooltipTemplate") + tooltip:SetOwner(WorldFrame, "ANCHOR_NONE") + return tooltip +end + +local function scanSpell(slot) + local tooltip = getTooltip() + tooltip:ClearLines() + tooltip:SetOwner(WorldFrame, "ANCHOR_NONE") + tooltip:SetSpell(slot, BOOKTYPE_SPELL) + + local lines = {} + local bestDuration = nil + local i + for i = 1, tooltip:NumLines() do + local fs = getglobal("BNPSpellProbeTooltipTextLeft" .. i) + local text = fs and fs:GetText() + if text and text ~= "" then + table.insert(lines, text) + local parsed = durationFromLine(text) + if parsed and parsed > 0 then + bestDuration = parsed + end + end + end + + return bestDuration, lines +end + +local function findSpell(wantedNames) + local slot = 1 + local found = nil + while true do + local name, rank = GetSpellName(slot, BOOKTYPE_SPELL) + if not name then break end + if isWanted(name, wantedNames) then + found = { slot = slot, name = name, rank = rank } + -- keep scanning so the highest learned rank wins + end + slot = slot + 1 + end + return found +end + +local function findSpellByID(def, spellID) + if not def or not spellID or not SpellInfo then return nil end + + local wantedName, wantedRank = SpellInfo(spellID) + if not wantedName then return nil end + + local slot = 1 + local name, rank + while true do + name, rank = GetSpellName(slot, BOOKTYPE_SPELL) + if not name then break end + + if isWanted(name, def.names or { wantedName }) then + -- SuperWoW SpellInfo exposes the localized rank when available. Matching + -- both name and rank lets us scan the exact spellbook rank that was cast + -- instead of always inheriting the highest learned rank's tooltip. + if wantedRank and wantedRank ~= "" and rank == wantedRank then + return { slot = slot, name = name, rank = rank } + end + end + + slot = slot + 1 + end + + return nil +end + + +BNP.spellbookDurations = BNP.spellbookDurations or {} +BNP.spellbookDurationsByID = BNP.spellbookDurationsByID or {} + +function BNP:RefreshSpellbookDurations() + -- Exact-rank tooltip values can change after talent/spellbook updates. + BNP.spellbookDurationsByID = {} + local id + for id, names in pairs(wanted) do + local spell = findSpell(names) + if spell then + local ok, duration = pcall(scanSpell, spell.slot) + if ok and duration and duration > 0 then + BNP.spellbookDurations[id] = duration + BNP.spellbookDurations[string.lower(spell.name)] = duration + end + end + end +end + +function BNP:GetSpellbookDurationForSpellID(def, spellID) + if not def or not spellID then return nil end + BNP.spellbookDurationsByID = BNP.spellbookDurationsByID or {} + + if BNP.spellbookDurationsByID[spellID] then + return BNP.spellbookDurationsByID[spellID] + end + + local spell = findSpellByID(def, spellID) + if spell then + local ok, duration = pcall(scanSpell, spell.slot) + if ok and duration and duration > 0 then + BNP.spellbookDurationsByID[spellID] = duration + return duration + end + end + + return nil +end + +function BNP:GetSpellbookDurationForDef(def) + if not def then return nil end + BNP.spellbookDurations = BNP.spellbookDurations or {} + + if def.key and BNP.spellbookDurations[def.key] then + return BNP.spellbookDurations[def.key] + end + + if def.names then + local i + for i = 1, table.getn(def.names) do + local key = string.lower(def.names[i]) + if BNP.spellbookDurations[key] then + return BNP.spellbookDurations[key] + end + end + + -- Generic on-demand scan: works for every class, not only the original + -- Warlock/Priest probe list. + local spell = findSpell(def.names) + if spell then + local ok, duration = pcall(scanSpell, spell.slot) + if ok and duration and duration > 0 then + if def.key then BNP.spellbookDurations[def.key] = duration end + BNP.spellbookDurations[string.lower(spell.name)] = duration + return duration + end + end + end + + return nil +end + +function BNP:SpellbookProbe(key) + self:RefreshSpellbookDurations() + local keys = {} + if key and key ~= "" and wanted[key] then + table.insert(keys, key) + else + table.insert(keys, "corruption") + table.insert(keys, "agony") + table.insert(keys, "siphon") + table.insert(keys, "shadow_word_pain") + end + + self:Print("=== Spellbook Tooltip Probe ===") + local k + for k = 1, table.getn(keys) do + local id = keys[k] + local spell = findSpell(wanted[id]) + if not spell then + self:Print(id .. ": not found in spellbook") + else + local ok, duration, lines = pcall(scanSpell, spell.slot) + if not ok then + self:Print(spell.name .. ": tooltip scan ERROR: " .. tostring(duration)) + else + self:Print(spell.name .. " " .. tostring(spell.rank or "") .. " | Slot " .. spell.slot .. " | Duration: " .. tostring(duration or "NOT DETECTED")) + local i + for i = 1, table.getn(lines) do + self:Print(" " .. i .. ": " .. lines[i]) + end + end + end + end +end diff --git a/modules/tankmode.lua b/modules/tankmode.lua new file mode 100644 index 0000000..fb12eb7 --- /dev/null +++ b/modules/tankmode.lua @@ -0,0 +1,181 @@ +BNP = BNP or {} + +-- Tank Mode for SuperWoW GUID nameplates. +-- GREEN = hostile NPC currently targets the player. +-- RED = hostile NPC currently targets another unit. +-- Idle hostile NPCs keep their normal Blizzard color. + +local GREEN_R, GREEN_G, GREEN_B = 0.00, 1.00, 0.00 +local RED_R, RED_G, RED_B = 1.00, 0.00, 0.00 + +local playerGUID = nil + +local function GetPlayerGUID() + local exists, guid = UnitExists("player") + if exists and guid then playerGUID = guid end + return playerGUID +end + +local function GetPlateGUID(plate) + if not plate or not plate.GetName then return nil end + local guid = plate:GetName(1) + if guid and guid ~= "" then return guid end + return nil +end + +local function GetTargetGUID(guid) + if not guid then return nil end + local exists, targetGUID = UnitExists(guid .. "target") + if exists then return targetGUID end + return nil +end + +local function GetBar(plate) + return plate and (plate.healthbar or plate.healthBar) or nil +end + +local function IsHostileNPC(guid) + if not guid or not UnitExists(guid) then return false end + if UnitIsPlayer(guid) then return false end + return UnitCanAttack("player", guid) and true or false +end + +local function IsForeignTaggedGUID(guid) + if not guid or not UnitExists(guid) then return false end + if UnitCanAttack and not UnitCanAttack("player", guid) then return false end + if not UnitIsTapped or not UnitIsTapped(guid) then return false end + if UnitIsTappedByPlayer and UnitIsTappedByPlayer(guid) then return false end + return true +end + +local function RestoreNormalColor(plate, bar) + -- Do not restore a cached RGB value. Outside an active Tank Mode state, + -- Blizzard owns the NPC healthbar color completely. + plate.BNPTankActive = nil + plate.BNPTankR = nil + plate.BNPTankG = nil + plate.BNPTankB = nil +end + +local function ClearTankState(plate) + if not plate then return end + plate.BNPTankActive = nil + plate.BNPTankR = nil + plate.BNPTankG = nil + plate.BNPTankB = nil +end + +local function ApplyTankColor(plate, bar, r, g, b) + bar:SetStatusBarColor(r, g, b, 1) + plate.BNPTankActive = true + plate.BNPTankR = r + plate.BNPTankG = g + plate.BNPTankB = b +end + +local function EnforceTankColor(plate) + if not plate or not plate.BNPTankActive then return end + local bar = GetBar(plate) + if not bar or not plate.BNPTankR then return end + + local r, g, b = bar:GetStatusBarColor() + if math.abs(r - plate.BNPTankR) > 0.01 + or math.abs(g - plate.BNPTankG) > 0.01 + or math.abs(b - plate.BNPTankB) > 0.01 then + bar:SetStatusBarColor(plate.BNPTankR, plate.BNPTankG, plate.BNPTankB, 1) + end +end + +function BNP:UpdateTankModePlate(plate) + if not plate or not plate:IsShown() then return end + + local bar = GetBar(plate) + if not bar then return end + + if not self:IsTankModeEnabled() then + RestoreNormalColor(plate, bar) + return + end + + local guid = GetPlateGUID(plate) + if not IsHostileNPC(guid) then + -- NPC reaction colors belong to Blizzard. Never restore a stale cached + -- Tank Mode color onto neutral/friendly/recycled nameplates. + ClearTankState(plate) + return + end + + -- Foreign-tag grey has priority over Tank Mode. Previously Tank Mode wrote + -- RED every 0.10s while fullalpha.lua restored GREY every frame, producing + -- the visible grey/red flicker. Clear any cached tank color and let the + -- foreign-tag renderer own the bar until the mob is ours/untapped again. + if IsForeignTaggedGUID(guid) then + ClearTankState(plate) + return + end + + local myGUID = GetPlayerGUID() + if not myGUID then return end + + local targetGUID = GetTargetGUID(guid) + + if targetGUID and targetGUID == myGUID then + ApplyTankColor(plate, bar, GREEN_R, GREEN_G, GREEN_B) + elseif targetGUID then + ApplyTankColor(plate, bar, RED_R, RED_G, RED_B) + else + RestoreNormalColor(plate, bar) + end +end + +function BNP:RefreshTankMode() + local plate + for plate in pairs(self.plates or {}) do + if plate and plate:IsShown() then + self:UpdateTankModePlate(plate) + end + end +end + +function BNP:UpdateTankMode() + self:RefreshTankMode() +end + +function BNP:InstallTankMode() + if self.BNPTankModeInstalled then return end + if not self.libnameplate or not self.libnameplate.OnUpdate then return end + + GetPlayerGUID() + + table.insert(self.libnameplate.OnInit, function(plate) + local current = plate or this + if not current then return end + + ClearTankState(current) + + local bar = GetBar(current) + if bar and not current.BNPTankColorHooked then + local old = bar:GetScript("OnValueChanged") + bar:SetScript("OnValueChanged", function() + if old then old() end + EnforceTankColor(current) + end) + current.BNPTankColorHooked = true + end + end) + + table.insert(self.libnameplate.OnShow, function(plate) + local current = plate or this + if not current then return end + ClearTankState(current) + end) + + table.insert(self.libnameplate.OnUpdate, function(plate, elapsed) + local current = plate or this + if not current then return end + BNP:UpdateTankModePlate(current) + EnforceTankColor(current) + end) + + self.BNPTankModeInstalled = true +end diff --git a/modules/totems.lua b/modules/totems.lua new file mode 100644 index 0000000..c52fefd --- /dev/null +++ b/modules/totems.lua @@ -0,0 +1,368 @@ +if not BNP.libnameplate then return end + +-- Lightweight PvP totem indicators ------------------------------------------ +-- Totem detection is intentionally nameplate/name based. It never reads or +-- modifies the aura cache, combat log timers or CC tracking. + +local TOTEM_DEFS = { + { key = "earthbind", spellID = 2484, names = { "Earthbind Totem", "Totem der Erdbindung" }, texture = "Interface\\Icons\\Spell_Nature_StrengthOfEarthTotem02" }, + { key = "stoneskin", spellID = 8071, names = { "Stoneskin Totem", "Totem der Steinhaut" }, texture = "Interface\\Icons\\Spell_Nature_StoneSkinTotem" }, + { key = "strengthofearth", spellID = 8075, names = { "Strength of Earth Totem", "Totem der Erdstärke" }, texture = "Interface\\Icons\\Spell_Nature_EarthBindTotem" }, + { key = "tremor", spellID = 8143, names = { "Tremor Totem", "Totem des Erdstosses", "Totem des Erdstoßes" }, texture = "Interface\\Icons\\Spell_Nature_TremorTotem" }, + { key = "grounding", spellID = 8177, names = { "Grounding Totem", "Totem der Erdung" }, texture = "Interface\\Icons\\Spell_Nature_GroundingTotem" }, + { key = "stoneclaw", spellID = 5730, names = { "Stoneclaw Totem", "Totem der Steinklaue" }, texture = "Interface\\Icons\\Spell_Nature_StoneClawTotem" }, + + { key = "searing", spellID = 3599, names = { "Searing Totem", "Totem der Verbrennung" }, texture = "Interface\\Icons\\Spell_Fire_SearingTotem" }, + { key = "flametongue", spellID = 8227, names = { "Flametongue Totem", "Totem der Flammenzunge" }, texture = "Interface\\Icons\\Spell_Nature_GuardianWard" }, + { key = "magma", spellID = 8190, names = { "Magma Totem", "Totem des glühenden Magmas" }, texture = "Interface\\Icons\\Spell_Fire_SelfDestruct" }, + { key = "firenova", spellID = 1535, names = { "Fire Nova Totem", "Totem der Feuernova" }, texture = "Interface\\Icons\\Spell_Fire_SealOfFire" }, + { key = "frostres", spellID = 8181, names = { "Frost Resistance Totem", "Totem des Frostwiderstands" }, texture = "Interface\\Icons\\Spell_FrostResistanceTotem_01" }, + { key = "fireres", spellID = 8184, names = { "Fire Resistance Totem", "Totem des Feuerwiderstands" }, texture = "Interface\\Icons\\Spell_FireResistanceTotem_01" }, + + { key = "healingstream", spellID = 5394, names = { "Healing Stream Totem", "Totem des heilenden Flusses" }, texture = "Interface\\Icons\\INV_Spear_04" }, + { key = "manaspring", spellID = 5675, names = { "Mana Spring Totem", "Totem der Manaquelle" }, texture = "Interface\\Icons\\Spell_Nature_ManaRegenTotem" }, + { key = "poisoncleansing", spellID = 8166, names = { "Poison Cleansing Totem", "Totem der Giftreinigung" }, texture = "Interface\\Icons\\Spell_Nature_PoisonCleansingTotem" }, + { key = "diseasecleansing", spellID = 8170, names = { "Disease Cleansing Totem", "Totem der Krankheitsreinigung" }, texture = "Interface\\Icons\\Spell_Nature_DiseaseCleansingTotem" }, + { key = "manatide", spellID = 16190, names = { "Mana Tide Totem", "Totem der Manaflut" }, texture = "Interface\\Icons\\Spell_Frost_SummonWaterElemental" }, + { key = "natureres", spellID = 10595, names = { "Nature Resistance Totem", "Totem des Naturwiderstands" }, texture = "Interface\\Icons\\Spell_Nature_NatureResistanceTotem" }, + + { key = "windfury", spellID = 8512, names = { "Windfury Totem", "Totem des Windzorns" }, texture = "Interface\\Icons\\Spell_Nature_Windfury" }, + { key = "sentry", spellID = 6495, names = { "Sentry Totem", "Totem des Wachens" }, texture = "Interface\\Icons\\Spell_Nature_RemoveCurse" }, + { key = "graceofair", spellID = 8835, names = { "Grace of Air Totem", "Totem der Luftanmut" }, texture = "Interface\\Icons\\Spell_Nature_InvisibilityTotem" }, + { key = "tranquilair", spellID = 25908, names = { "Tranquil Air Totem", "Totem der beruhigenden Winde" }, texture = "Interface\\Icons\\Spell_Nature_Brilliance" }, + { key = "windwall", spellID = 15107, names = { "Windwall Totem", "Totem der Windmauer" }, texture = "Interface\\Icons\\Spell_Nature_EarthBind" }, +} + + +local function CleanName(text) + if not text then return nil end + text = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "") + text = string.gsub(text, "|r", "") + text = string.gsub(text, "^%s+", "") + text = string.gsub(text, "%s+$", "") + if text == "" then return nil end + return text +end + +local function NormalizeTotemName(text) + text = CleanName(text) + if not text then return nil end + + text = string.lower(text) + -- Vanilla totem NPCs often carry their spell rank in the NPC name, e.g. + -- "Searing Totem VI" or "Windfury Totem II". Match the base totem name. + text = string.gsub(text, "%s+rank%s+%d+$", "") + text = string.gsub(text, "%s+%d+$", "") + text = string.gsub(text, "%s+[ivxlcdm]+$", "") + text = string.gsub(text, "%s+$", "") + return text +end + +local function AddName(def, name) + if not name or name == "" then return end + local normalized = NormalizeTotemName(name) + local i + for i = 1, table.getn(def.names) do + if NormalizeTotemName(def.names[i]) == normalized then return end + end + table.insert(def.names, name) +end + +local definitionsResolved = false +local function ResolveDefinitions() + if definitionsResolved then return end + definitionsResolved = true + + if not SpellInfo then return end + local i + for i = 1, table.getn(TOTEM_DEFS) do + local def = TOTEM_DEFS[i] + local ok, name, _, texture = pcall(SpellInfo, def.spellID) + if ok then + AddName(def, name) + if texture and texture ~= "" then def.texture = texture end + end + end +end + +local GENERIC_TOTEM_DEF = { + key = "generic", + names = {}, + texture = "Interface\\Icons\\Spell_Nature_StoneClawTotem", +} + +local function FindTotemDefinition(name) + local normalized = NormalizeTotemName(name) + if not normalized then return nil end + + local i, n + for i = 1, table.getn(TOTEM_DEFS) do + local def = TOTEM_DEFS[i] + for n = 1, table.getn(def.names) do + if normalized == NormalizeTotemName(def.names[n]) then return def end + end + end + + -- Future/custom server totems still get icon-only treatment even when BNP + -- does not yet know their exact spell icon. Standard Classic totems above + -- keep their proper icon; unknown names containing "totem" use a neutral + -- fallback icon instead of leaving the full nameplate visible. + if string.find(normalized, "totem", 1, true) then + return GENERIC_TOTEM_DEF + end + + return nil +end + +local function GetPlateName(plate) + if plate and plate.name and plate.name.GetText then + local name = CleanName(plate.name:GetText()) + if name then return name end + end + + if plate and plate.GetName and UnitName then + local token = plate:GetName(1) + if token then + local ok, name = pcall(UnitName, token) + if ok then return CleanName(name) end + end + end + + return nil +end + +local function PositionIndicator(plate, indicator) + if not plate or not indicator then return end + indicator:ClearAllPoints() + + -- Icon-only mode: replace the visible nameplate at its original position. + if plate.healthbar then + indicator:SetPoint("CENTER", plate.healthbar, "CENTER", 0, 0) + else + indicator:SetPoint("CENTER", plate, "CENTER", 0, 0) + end +end + +local function RememberAndHideVisual(object, state) + if not object or object == state.indicator then return end + if not object.SetAlpha or not object.GetAlpha then return end + + if state.alpha[object] == nil then + state.alpha[object] = object:GetAlpha() + end + object:SetAlpha(0) +end + +local function SuppressTotemPlateVisuals(plate) + if not plate then return end + + local indicator = plate.BNPTotemIndicator + local state = plate.BNPTotemVisualState + if not state then + state = { alpha = {}, indicator = indicator } + plate.BNPTotemVisualState = state + else + state.indicator = indicator + end + + -- Important PvP totems should remain clearly visible even when BNP's + -- non-target alpha option would normally fade the underlying nameplate. + if plate.SetAlpha then plate:SetAlpha(1) end + + local regions = { plate:GetRegions() } + local i + for i = 1, table.getn(regions) do + RememberAndHideVisual(regions[i], state) + end + + local children = { plate:GetChildren() } + for i = 1, table.getn(children) do + RememberAndHideVisual(children[i], state) + end + + -- BNPTotemIndicator is itself a direct child of the plate. + if indicator and indicator.SetAlpha then indicator:SetAlpha(1) end +end + +local function RestoreTotemPlateVisuals(plate) + if not plate then return end + local state = plate.BNPTotemVisualState + if not state then return end + + local object, alpha + for object, alpha in pairs(state.alpha or {}) do + if object and object.SetAlpha then + object:SetAlpha(alpha or 1) + end + end + + plate.BNPTotemVisualState = nil +end + +-- Blizzard can restore parts of a projected nameplate every frame. Once a +-- plate has been identified as a totem, suppress every normal visual again +-- after the plate's native/fullalpha OnUpdate has run. The replacement icon +-- remains a child of the plate and is explicitly excluded, so it keeps the +-- same proven projection/positioning path as the working detection version. +local function InstallTotemVisualGuard(plate) + if not plate or plate.BNPTotemVisualGuard then return end + + local oldOnUpdate = plate:GetScript("OnUpdate") + plate:SetScript("OnUpdate", function() + if oldOnUpdate then oldOnUpdate() end + local current = this or plate + if not current then return end + + if current.BNPTotemLastKey and + BNP.AreTotemIndicatorsEnabled and BNP:AreTotemIndicatorsEnabled() then + SuppressTotemPlateVisuals(current) + if current.BNPTotemIndicator then + current.BNPTotemIndicator:SetAlpha(1) + current.BNPTotemIndicator:Show() + end + elseif current.BNPTotemVisualState then + RestoreTotemPlateVisuals(current) + end + end) + + plate.BNPTotemVisualGuard = true +end + +local function InstallTotemHideGuard(plate) + if not plate or plate.BNPTotemHideGuard then return end + + local oldOnHide = plate:GetScript("OnHide") + plate:SetScript("OnHide", function() + if oldOnHide then oldOnHide() end + local current = this or plate + if not current then return end + + if current.BNPTotemIndicator then current.BNPTotemIndicator:Hide() end + RestoreTotemPlateVisuals(current) + current.BNPTotemLastName = nil + current.BNPTotemLastKey = nil + end) + + plate.BNPTotemHideGuard = true +end + +local function ApplyIndicatorLayout(plate, force) + local indicator = plate and plate.BNPTotemIndicator + if not indicator then return end + + local size = BNP.GetTotemIconSize and BNP:GetTotemIconSize() or 24 + local level = plate.GetFrameLevel and ((plate:GetFrameLevel() or 0) + 2) or 2 + + if not force and + indicator.BNPLastSize == size and + indicator.BNPLastFrameLevel == level then + return + end + + indicator.BNPLastSize = size + indicator.BNPLastFrameLevel = level + indicator:SetWidth(size) + indicator:SetHeight(size) + if indicator.SetFrameLevel then indicator:SetFrameLevel(level) end + PositionIndicator(plate, indicator) +end + +local function CreateIndicator(plate) + if not plate or plate.BNPTotemIndicator then return end + + local indicator = CreateFrame("Frame", nil, plate) + local size = BNP.GetTotemIconSize and BNP:GetTotemIconSize() or 24 + indicator:SetWidth(size) + indicator:SetHeight(size) + indicator:SetFrameLevel((plate.GetFrameLevel and plate:GetFrameLevel() or 0) + 2) + + local border = indicator:CreateTexture(nil, "BACKGROUND") + border:SetTexture(0, 0, 0, 1) + border:SetPoint("TOPLEFT", indicator, "TOPLEFT", -1, 1) + border:SetPoint("BOTTOMRIGHT", indicator, "BOTTOMRIGHT", 1, -1) + indicator.border = border + + local texture = indicator:CreateTexture(nil, "ARTWORK") + texture:SetAllPoints(indicator) + texture:SetTexCoord(0.07, 0.93, 0.07, 0.93) + indicator.texture = texture + + indicator:Hide() + plate.BNPTotemIndicator = indicator + ApplyIndicatorLayout(plate, true) +end + +local function UpdateIndicator(plate, force) + if not plate then return end + CreateIndicator(plate) + local indicator = plate.BNPTotemIndicator + if not indicator then return end + + ApplyIndicatorLayout(plate, force) + + if not (BNP.AreTotemIndicatorsEnabled and BNP:AreTotemIndicatorsEnabled()) then + indicator:Hide() + RestoreTotemPlateVisuals(plate) + plate.BNPTotemLastName = nil + plate.BNPTotemLastKey = nil + return + end + + ResolveDefinitions() + local name = GetPlateName(plate) + if not force and name == plate.BNPTotemLastName then + if plate.BNPTotemLastKey then + indicator:Show() + SuppressTotemPlateVisuals(plate) + else + indicator:Hide() + RestoreTotemPlateVisuals(plate) + end + return + end + + plate.BNPTotemLastName = name + plate.BNPTotemLastKey = nil + + local def = FindTotemDefinition(name) + if not def then + indicator:Hide() + RestoreTotemPlateVisuals(plate) + return + end + + plate.BNPTotemLastKey = def.key + indicator.texture:SetTexture(def.texture) + indicator:Show() + SuppressTotemPlateVisuals(plate) +end + +function BNP:RefreshTotemIndicators() + ResolveDefinitions() + local plate + for plate in pairs(self.plates or {}) do + if plate then UpdateIndicator(plate, true) end + end +end + +-- Public table keeps the totem definitions centralized and easy to extend. +BNP.TotemIndicatorDefinitions = TOTEM_DEFS + +table.insert(BNP.libnameplate.OnInit, function(plate) + CreateIndicator(plate) + InstallTotemVisualGuard(plate) + InstallTotemHideGuard(plate) + UpdateIndicator(plate, true) +end) + +table.insert(BNP.libnameplate.OnShow, function(plate) + InstallTotemVisualGuard(plate) + InstallTotemHideGuard(plate) + plate.BNPTotemLastName = nil + plate.BNPTotemLastKey = nil + UpdateIndicator(plate, true) +end) + +table.insert(BNP.libnameplate.OnUpdate, function(plate) + UpdateIndicator(plate, false) +end) diff --git a/modules/unknown.lua b/modules/unknown.lua new file mode 100644 index 0000000..15c2e01 --- /dev/null +++ b/modules/unknown.lua @@ -0,0 +1,498 @@ +BNP = BNP or {} +BNP.unknownDirectEvents = BNP.unknownDirectEvents or {} + +-- Beta spell collector ------------------------------------------------------- +-- Purpose: help testers report missing Turtle/custom spell IDs without +-- editing Lua files. It records unknown own cast IDs and unknown debuff IDs +-- that newly appear shortly after one of the player's casts. +-- +-- It is intentionally diagnostic only: it never changes tracking/rendering. + +BNP.unknownBeta = BNP.unknownBeta or { + entries = {}, + order = {}, + recentCasts = {}, + seenByGUID = {}, + activeUntil = 0, +} + +local _, playerClass = UnitClass("player") + +local CLASS_AURAS = { + WARLOCK = BNP.WarlockAuras, + PRIEST = BNP.PriestAuras, + WARRIOR = BNP.WarriorAuras, + ROGUE = BNP.RogueAuras, + HUNTER = BNP.HunterAuras, + MAGE = BNP.MageAuras, + DRUID = BNP.DruidAuras, + SHAMAN = BNP.ShamanAuras, + PALADIN = BNP.PaladinAuras, +} +local AURA_DEFS = CLASS_AURAS[playerClass] or {} + +local function GetPlayerGUID() + local exists, guid = UnitExists("player") + if exists and guid then return guid end + return nil +end + +local function GetUnitGUID(unit) + local exists, guid = UnitExists(unit) + if exists and guid then return guid end + return nil +end + +local function IsExplicitKnownID(spellID) + if not spellID then return false end + + -- Dark Harvest is handled by dedicated Warlock logic. + if spellID == 52552 then return true end + + local i, j, def + for i = 1, table.getn(AURA_DEFS) do + def = AURA_DEFS[i] + + if def.spellIDs then + for j = 1, table.getn(def.spellIDs) do + if def.spellIDs[j] == spellID then return true end + end + end + + if def.durations and def.durations[spellID] then + return true + end + end + + return false +end + +local function SpellName(spellID) + if not spellID or not SpellInfo then return "?" end + local name = SpellInfo(spellID) + return name or "?" +end + +local function AddEntry(kind, spellID, spellName, guid, trigger, confidence) + if not spellID then return end + + local key = tostring(kind) .. ":" .. tostring(spellID) + local data = BNP.unknownBeta + local old = data.entries[key] + + if old then + old.count = (old.count or 1) + 1 + old.lastSeen = GetTime() + if confidence == "TARGET" then old.confidence = confidence end + if trigger and not old.trigger then old.trigger = trigger end + return + end + + local entry = { + kind = kind, + spellID = spellID, + name = spellName or SpellName(spellID), + class = playerClass or "?", + guid = guid, + trigger = trigger, + confidence = confidence or "?", + count = 1, + firstSeen = GetTime(), + lastSeen = GetTime(), + } + + data.entries[key] = entry + table.insert(data.order, key) +end + +local function RememberCast(spellID, targetGUID, eventType) + local data = BNP.unknownBeta + local now = GetTime() + data.activeUntil = now + 8.0 + local cast = { + time = now, + spellID = spellID, + name = SpellName(spellID), + targetGUID = targetGUID, + eventType = eventType, + } + + table.insert(data.recentCasts, cast) + while table.getn(data.recentCasts) > 20 do + table.remove(data.recentCasts, 1) + end + + -- Unknown own CAST/CHANNEL IDs are useful too. Some trap/proc systems expose + -- the effect as a separate cast event before the aura is seen. + if spellID and not IsExplicitKnownID(spellID) then + AddEntry("CAST", spellID, cast.name, targetGUID, nil, "OWN") + end +end + +local function FindRecentTrigger(guid, now) + local casts = BNP.unknownBeta.recentCasts + local i + for i = table.getn(casts), 1, -1 do + local cast = casts[i] + local age = now - cast.time + + if age > 6 then + break + end + + if cast.targetGUID and guid and cast.targetGUID == guid then + return cast, "TARGET" + end + + -- Short global fallback catches effects such as traps where the placement + -- cast may not carry the eventual victim GUID. + if age <= 1.5 then + return cast, "RECENT" + end + end + + return nil, nil +end + +local function ScanUnit(unit, guid, now) + if not unit or not guid then return end + + local seen = BNP.unknownBeta.seenByGUID[guid] + if not seen then + seen = {} + BNP.unknownBeta.seenByGUID[guid] = seen + end + + local current = {} + local i + for i = 1, 32 do + local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) + if not texture then break end + + if auraSpellID then + current[auraSpellID] = true + + if not seen[auraSpellID] and not IsExplicitKnownID(auraSpellID) then + local trigger, confidence = FindRecentTrigger(guid, now) + if trigger then + AddEntry( + "AURA", + auraSpellID, + SpellName(auraSpellID), + guid, + tostring(trigger.name) .. " [" .. tostring(trigger.spellID) .. "]", + confidence + ) + end + end + end + end + + -- Keep only currently present aura IDs so a later re-application can be + -- detected again without growing this table forever. + BNP.unknownBeta.seenByGUID[guid] = current +end + + +-- Indirect Effect Audit: traps/procs may never expose a UnitDebuff ID. +BNP.unknownBeta.combat = BNP.unknownBeta.combat or {} +local EFFECT_WINDOW = 8.0 + + +local function IsUsefulEffectLine(raw) + if not raw or raw == "" then return false end + local lower = string.lower(raw) + + -- Ignore obvious player-action/noise lines. + if string.find(lower, "you cast ", 1, true) then return false end + if string.find(lower, "auto shot", 1, true) then return false end + if string.find(lower, "login", 1, true) then return false end + + -- Keep lines that actually describe a harmful aura/effect or its periodic damage. + if string.find(lower, "afflicted by", 1, true) then return true end + if string.find(lower, "is afflicted", 1, true) then return true end + if string.find(lower, "is affected by", 1, true) then return true end + if string.find(lower, "suffers", 1, true) then return true end + if string.find(lower, "periodic", 1, true) then return true end + + return false +end + +local function AddCombatEffect(raw) + if not IsUsefulEffectLine(raw) then return end + local now=GetTime() + local casts=BNP.unknownBeta.recentCasts + local trigger=nil + local i + for i=table.getn(casts),1,-1 do + local c=casts[i] + if now-c.time > EFFECT_WINDOW then break end + if c.spellID ~= 75 then trigger=c break end + end + if not trigger then return end + if string.find(string.lower(raw),"auto shot",1,true) then return end + + local key=tostring(trigger.spellID)..":"..raw + local old=BNP.unknownBeta.combat[key] + if old then old.count=old.count+1 return end + BNP.unknownBeta.combat[key]={ + raw=raw, triggerID=trigger.spellID, triggerName=trigger.name, count=1 + } +end + +local combatFrame=CreateFrame("Frame") +local combatEvents={ + "CHAT_MSG_COMBAT_SELF_HITS", + "CHAT_MSG_SPELL_SELF_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE", +} +local cei +for cei=1,table.getn(combatEvents) do combatFrame:RegisterEvent(combatEvents[cei]) end +combatFrame:SetScript("OnEvent",function() AddCombatEffect(arg1) end) + +local castFrame = CreateFrame("Frame") +castFrame:RegisterEvent("UNIT_CASTEVENT") +castFrame:SetScript("OnEvent", function() + local playerGUID = GetPlayerGUID() + if not playerGUID or arg1 ~= playerGUID then return end + + local eventType = arg3 + if eventType ~= "CAST" and eventType ~= "CHANNEL" then return end + + RememberCast(arg4, arg2, eventType) +end) + +local scanner = CreateFrame("Frame") +local elapsed = 0 +scanner:SetScript("OnUpdate", function() + elapsed = elapsed + arg1 + if elapsed < 0.35 then return end + elapsed = 0 + + local now = GetTime() + if not BNP.unknownBeta.activeUntil or now > BNP.unknownBeta.activeUntil then + return + end + + local targetGUID = GetUnitGUID("target") + if targetGUID then + ScanUnit("target", targetGUID, now) + end + + local plate + for plate in pairs(BNP.plates) do + if plate:IsShown() and plate.GetName then + local token = plate:GetName(1) + if token then + local guid = GetUnitGUID(token) + if guid then + ScanUnit(token, guid, now) + end + end + end + end +end) + +function BNP:PrintUnknownBeta(verbose) + local data = self.unknownBeta + self:Print("=== BNP Beta: Unknown Auras (" .. tostring(playerClass or "?") .. ") ===") + + local auraCount = 0 + local i + for i = 1, table.getn(data.order) do + local entry = data.entries[data.order[i]] + if entry and entry.kind == "AURA" then + auraCount = auraCount + 1 + local line = + tostring(auraCount) .. + " | AURA | ID " .. tostring(entry.spellID) .. + " | " .. tostring(entry.name) .. + " | x" .. tostring(entry.count or 1) + + if entry.confidence then + line = line .. " | " .. tostring(entry.confidence) + end + + self:Print(line) + + if entry.trigger then + self:Print(" Trigger: " .. tostring(entry.trigger)) + end + end + end + + local combatCount = 0 + local ck + for ck in pairs(data.combat or {}) do combatCount = combatCount + 1 end + + if combatCount > 0 then + self:Print("--- Indirect Effects ---") + local key, e + for key, e in pairs(data.combat) do + self:Print("EFFECT | Trigger " .. tostring(e.triggerName) .. " [" .. tostring(e.triggerID) .. "] | x" .. tostring(e.count)) + self:Print(" " .. tostring(e.raw)) + end + end + + if auraCount == 0 and combatCount == 0 then + self:Print("No unknown negative auras/effects found since login.") + end + + if verbose then + self:Print("--- Verbose: unknown player cast IDs ---") + local castCount = 0 + for i = 1, table.getn(data.order) do + local entry = data.entries[data.order[i]] + if entry and entry.kind == "CAST" then + castCount = castCount + 1 + self:Print( + tostring(castCount) .. + " | CAST | ID " .. tostring(entry.spellID) .. + " | " .. tostring(entry.name) .. + " | x" .. tostring(entry.count or 1) + ) + end + end + if castCount == 0 then + self:Print("No unknown cast IDs.") + end + else + self:Print("Open Missing Spell / Aura in the settings to copy a complete report.") + end +end + +function BNP:ResetUnknownBeta() + self.unknownBeta.entries = {} + self.unknownBeta.order = {} + self.unknownBeta.recentCasts = {} + self.unknownBeta.seenByGUID = {} + self.unknownBeta.combat = {} + self.unknownBeta.activeUntil = 0 + self:Print("Unknown spell/effect list cleared.") +end + + + +-- Target Aura Probe ---------------------------------------------------------- +-- Diagnostic helper for proc/talent auras that the normal unknown collector +-- may miss because they were already present in its seen-state. +function BNP:PrintTargetAuraProbe() + local exists, guid = UnitExists("target") + if not exists or not guid then + self:Print("Target Aura Probe: no target selected.") + return + end + + self:Print("=== BNP Target Negative Auras ===") + self:Print("Target: " .. tostring(UnitName("target") or "?") .. " | GUID " .. tostring(guid)) + + local count = 0 + local i + for i = 1, 32 do + local texture, stacks, dtype, auraSpellID = UnitDebuff("target", i) + if not texture then break end + + count = count + 1 + local name = auraSpellID and SpellName(auraSpellID) or "?" + self:Print( + tostring(count) .. + " | ID " .. tostring(auraSpellID or "?") .. + " | " .. tostring(name) .. + " | stacks " .. tostring(stacks or 0) .. + " | type " .. tostring(dtype or "?") + ) + end + + if count == 0 then + self:Print("No negative target auras found.") + end +end + +-- Direct player spell collector -------------------------------------------- +-- Captures successful player spell events that do not create a persistent +-- aura and therefore never appear in the normal unknown-aura collector. + +local function GetLocalPlayerGUID() + local exists, guid = UnitExists("player") + if exists and guid then return guid end + return nil +end + +local function RememberDirectEvent(eventType, spellID, targetGUID, extra) + if not spellID then return end + + local name = nil + if SpellInfo then name = SpellInfo(spellID) end + name = name or ("Spell " .. tostring(spellID)) + + local key = tostring(eventType) .. ":" .. tostring(spellID) + local entry = BNP.unknownDirectEvents[key] + + if not entry then + entry = { + eventType = eventType, + spellID = spellID, + name = name, + targetGUID = targetGUID, + count = 0, + extra = extra, + last = GetTime(), + } + BNP.unknownDirectEvents[key] = entry + end + + entry.count = (entry.count or 0) + 1 + entry.targetGUID = targetGUID or entry.targetGUID + entry.extra = extra or entry.extra + entry.last = GetTime() +end + +local directFrame = CreateFrame("Frame") +directFrame:RegisterEvent("UNIT_CASTEVENT") +directFrame:SetScript("OnEvent", function() + local casterGUID = arg1 + local targetGUID = arg2 + local eventType = arg3 + local spellID = arg4 + local playerGUID = GetLocalPlayerGUID() + + if not playerGUID or casterGUID ~= playerGUID then return end + if not spellID then return end + + if eventType == "START" or eventType == "CAST" or eventType == "CHANNEL" then + RememberDirectEvent(eventType, spellID, targetGUID, arg5) + end +end) + +function BNP:PrintUnknownDirectEvents() + local list = {} + local key, entry + + for key, entry in pairs(self.unknownDirectEvents or {}) do + table.insert(list, entry) + end + + table.sort(list, function(a, b) + return (a.last or 0) > (b.last or 0) + end) + + if table.getn(list) == 0 then + self:Print("--- Direct Player Events: none captured ---") + return + end + + self:Print("--- Direct Player Events ---") + + local i, e + for i = 1, table.getn(list) do + e = list[i] + self:Print( + tostring(i) .. " | " .. + tostring(e.eventType) .. " | ID " .. + tostring(e.spellID) .. " | " .. + tostring(e.name) .. " | x" .. + tostring(e.count or 1) + ) + end +end diff --git a/spells/druid.lua b/spells/druid.lua new file mode 100644 index 0000000..17be58b --- /dev/null +++ b/spells/druid.lua @@ -0,0 +1,85 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.DruidAuras = { + { + key = "moonfire", + names = { "Moonfire" }, + duration = 18, + }, + { + key = "rake", + names = { "Rake" }, + duration = 9, + }, + { + key = "rip", + names = { "Rip" }, + duration = 12, + }, + { + key = "faerie_fire", + names = { "Faerie Fire" }, + duration = 40, + }, + { + key = "faerie_fire_feral", + names = { "Faerie Fire (Feral)" }, + duration = 40, + }, + { + key = "entangling_roots", + names = { "Entangling Roots" }, + duration = 27, + }, + { + key = "insect_swarm", + names = { "Insect Swarm" }, + duration = 18, + }, + { + key = "bash", + names = { "Bash" }, + duration = 4, + }, + { + key = "pounce", + names = { "Pounce" }, + duration = 2, + }, + { + key = "pounce_bleed", + names = { "Pounce Bleed" }, + duration = 18, + }, + { + key = "hibernate", + names = { "Hibernate" }, + duration = 40, + }, + { + key = "demoralizing_roar", + names = { "Demoralizing Roar" }, + duration = 30, + }, + { + key = "feral_charge_effect", + names = { "Feral Charge Effect" }, + duration = 4, + }, + { + key = "challenging_roar", + names = { "Challenging Roar" }, + duration = 6, + }, + { + key = "lacerate", + names = { "Lacerate" }, + duration = 8, + }, + { + key = "mangle", + names = { "Mangle" }, + duration = 2, + }, +} diff --git a/spells/hunter.lua b/spells/hunter.lua new file mode 100644 index 0000000..0d4524c --- /dev/null +++ b/spells/hunter.lua @@ -0,0 +1,97 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.HunterAuras = { + { + key = "immolation_trap_effect", + names = { "Immolation Trap Effect" }, + duration = 15, + spellIDs = { 14298 }, + }, + { + key = "explosive_trap_effect", + names = { "Explosive Trap Effect" }, + duration = 20, + }, + { + key = "freezing_trap_effect", + names = { "Freezing Trap Effect" }, + duration = 20, + }, + { + key = "serpent_sting", + names = { "Serpent Sting" }, + duration = 15, + }, + { + key = "viper_sting", + names = { "Viper Sting" }, + duration = 8, + }, + { + key = "scorpid_sting", + names = { "Scorpid Sting" }, + duration = 20, + }, + { + key = "improved_scorpid_sting", + names = { "Improved Scorpid Sting" }, + duration = 20, + }, + { + key = "wing_clip", + names = { "Wing Clip" }, + duration = 10, + }, + { + key = "improved_wing_clip", + names = { "Improved Wing Clip" }, + duration = 5, + }, + { + key = "concussive_shot", + names = { "Concussive Shot" }, + duration = 4, + }, + { + key = "improved_concussive_shot", + names = { "Improved Concussive Shot" }, + duration = 3, + }, + { + key = "scatter_shot", + names = { "Scatter Shot" }, + duration = 4, + }, + { + key = "wyvern_sting", + names = { "Wyvern Sting" }, + duration = 12, + }, + { + key = "entrapment", + names = { "Entrapment" }, + duration = 5, + }, + { + key = "counterattack", + names = { "Counterattack" }, + duration = 5, + }, + { + key = "intimidation", + names = { "Intimidation" }, + duration = 3, + }, + { + key = "scare_beast", + names = { "Scare Beast" }, + duration = 20, + }, + { + key = "hunters_mark", + names = { "Hunter's Mark" }, + duration = 120, + shared = true, + }, +} diff --git a/spells/mage.lua b/spells/mage.lua new file mode 100644 index 0000000..cefc483 --- /dev/null +++ b/spells/mage.lua @@ -0,0 +1,70 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.MageAuras = { + { + key = "frostbolt", + names = { "Frostbolt" }, + duration = 9, + }, + { + key = "fireball", + names = { "Fireball" }, + duration = 8, + }, + { + key = "pyroblast", + names = { "Pyroblast" }, + duration = 12, + }, + { + key = "polymorph", + names = { "Polymorph" }, + duration = 50, + }, + { + key = "cone_of_cold", + names = { "Cone of Cold" }, + duration = 8, + }, + { + key = "frost_nova", + names = { "Frost Nova" }, + duration = 8, + }, + { + key = "counterspell", + names = { "Counterspell" }, + duration = 10, + }, + { + key = "counterspell_silence", + names = { "Counterspell - Silenced" }, + duration = 4, + }, + { + key = "ignite", + names = { "Ignite" }, + duration = 4, + }, + { + key = "impact", + names = { "Impact" }, + duration = 2, + }, + { + key = "frostbite", + names = { "Frostbite" }, + duration = 5, + }, + { + key = "fire_vulnerability", + names = { "Fire Vulnerability" }, + duration = 30, + }, + { + key = "winters_chill", + names = { "Winter's Chill" }, + duration = 15, + }, +} diff --git a/spells/paladin.lua b/spells/paladin.lua new file mode 100644 index 0000000..1930b03 --- /dev/null +++ b/spells/paladin.lua @@ -0,0 +1,60 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.PaladinAuras = { + { + key = "judgement_light", + names = { "Judgement of Light" }, + duration = 10, + spellIDs = { 20344 }, + }, + { + key = "judgement_wisdom", + names = { "Judgement of Wisdom" }, + duration = 10, + spellIDs = { 20355 }, + }, + { + key = "judgement_justice", + names = { "Judgement of Justice" }, + duration = 10, + spellIDs = { 20184 }, + }, + { + key = "judgement_crusader", + names = { "Judgement of the Crusader" }, + duration = 10, + spellIDs = { 20302 }, + }, + { + key = "hammer_of_justice", + names = { "Hammer of Justice" }, + duration = 6, + }, + { + key = "repentance", + names = { "Repentance" }, + duration = 6, + }, + { + key = "turn_undead", + names = { "Turn Undead" }, + duration = 20, + }, + { + key = "hand_of_reckoning", + names = { "Hand of Reckoning" }, + duration = 3, + }, + { + key = "crusader_strike", + names = { "Crusader Strike" }, + duration = 30, + + -- Server variants differ: some apply a real hostile Crusader Strike + -- aura, while others only grant a player-side buff. PROC forces BNP's + -- strict UnitDebuff-confirmation path so the cast itself can never create + -- a fake enemy-nameplate debuff. + category = "PROC", + }, +} diff --git a/spells/priest.lua b/spells/priest.lua new file mode 100644 index 0000000..0ebaff3 --- /dev/null +++ b/spells/priest.lua @@ -0,0 +1,78 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.PriestAuras = { + { + key = "shadow_word_pain", + names = { "Shadow Word: Pain", "Schattenwort: Schmerz" }, + duration = 18, + }, + { + key = "devouring_plague", + names = { "Devouring Plague" }, + duration = 24, + }, + { + key = "vampiric_embrace", + names = { "Vampiric Embrace" }, + duration = 60, + }, + { + key = "mind_flay", + channel = true, + names = { "Mind Flay" }, + duration = 3, + }, + { + key = "psychic_scream", + names = { "Psychic Scream" }, + duration = 8, + }, + { + key = "silence", + names = { "Silence" }, + duration = 5, + }, + { + key = "hex_of_weakness", + names = { "Hex of Weakness" }, + duration = 120, + }, + { + key = "touch_of_weakness", + names = { "Touch of Weakness" }, + duration = 120, + }, + { + key = "holy_fire", + names = { "Holy Fire" }, + duration = 10, + }, + { + key = "blackout", + names = { "Blackout" }, + duration = 3, + }, + { + key = "starshards", + channel = true, + names = { "Starshards" }, + duration = 6, + }, + { + key = "shadow_vulnerability", + names = { "Shadow Vulnerability" }, + duration = 15, + }, + { + key = "shackle_undead", + names = { "Shackle Undead" }, + duration = 50, + }, + { + key = "mind_control", + channel = true, + names = { "Mind Control" }, + duration = 60, + }, +} diff --git a/spells/registry_shadow.lua b/spells/registry_shadow.lua new file mode 100644 index 0000000..c3e2356 --- /dev/null +++ b/spells/registry_shadow.lua @@ -0,0 +1,153 @@ +BNP = BNP or {} + +-- Shadow-only spell registry. +-- This module never controls tracking or rendering. It only audits whether a +-- successful local cast could be resolved by a future SpellDB implementation. +BNP.SpellDBShadow = BNP.SpellDBShadow or { + byID = {}, + byName = {}, + byTexture = {}, + stats = { total = 0, id = 0, name = 0, texture = 0, unknown = 0, ignored = 0 }, + recent = {}, +} + +local DB = BNP.SpellDBShadow + +-- Successful SuperWoW cast events which are not hostile target auras and must +-- not count as missing SpellDB entries. More can be added as Turtle exposes +-- additional proc/engine events during testing. +local IGNORED_SPELL_IDS = { + [17941] = true, -- Shadow Trance / Nightfall proc + [836] = true, -- LOGINEFFECT engine event +} + +local function Lower(value) + if not value then return nil end + return string.lower(value) +end + +local function RegisterDef(def) + if not def then return end + + if def.names then + for _, name in ipairs(def.names) do + DB.byName[Lower(name)] = def + end + end + + if def.textureMatch then + DB.byTexture[Lower(def.textureMatch)] = def + end + + if def.durations then + for spellID in pairs(def.durations) do + DB.byID[spellID] = def + end + end + + if def.spellIDs then + for _, spellID in ipairs(def.spellIDs) do + DB.byID[spellID] = def + end + end +end + +for _, def in ipairs(BNP.WarlockAuras or {}) do + RegisterDef(def) +end + +local function FindByTexture(texture) + local lowerTexture = Lower(texture) + if not lowerTexture then return nil end + + for fragment, def in pairs(DB.byTexture) do + if string.find(lowerTexture, fragment) then + return def + end + end + + return nil +end + +local function PushRecent(spellID, spellName, result, key) + local recent = DB.recent + table.insert(recent, 1, { + id = spellID, + name = spellName or "?", + result = result, + key = key or "?", + }) + + while table.getn(recent) > 10 do + table.remove(recent) + end +end + +function BNP:AuditSpellDB(spellID) + if not spellID or not SpellInfo then return end + + if IGNORED_SPELL_IDS[spellID] then + DB.stats.ignored = (DB.stats.ignored or 0) + 1 + return + end + + local spellName, _, texture = SpellInfo(spellID) + local def = DB.byID[spellID] + local result = "ID" + + if not def and spellName then + def = DB.byName[Lower(spellName)] + result = "NAME" + end + + if not def and texture then + def = FindByTexture(texture) + result = "TEXTURE" + end + + DB.stats.total = DB.stats.total + 1 + + if def then + if result == "ID" then + DB.stats.id = DB.stats.id + 1 + elseif result == "NAME" then + DB.stats.name = DB.stats.name + 1 + else + DB.stats.texture = DB.stats.texture + 1 + end + PushRecent(spellID, spellName, result, def.key) + else + DB.stats.unknown = DB.stats.unknown + 1 + PushRecent(spellID, spellName, "UNKNOWN", "-") + end +end + +function BNP:PrintSpellDBAudit() + local stats = DB.stats + self:Print("SpellDB Schattenmodus – Renderer unverändert.") + self:Print("Casts: " .. stats.total .. " | ID: " .. stats.id .. " | Name: " .. stats.name .. " | Textur: " .. stats.texture .. " | Unbekannt: " .. stats.unknown .. " | Ignoriert: " .. (stats.ignored or 0)) + + if table.getn(DB.recent) == 0 then + self:Print("Noch keine eigenen erfolgreichen Casts protokolliert.") + return + end + + self:Print("Letzte Casts:") + for i = 1, table.getn(DB.recent) do + local entry = DB.recent[i] + self:Print(entry.name .. " [" .. tostring(entry.id) .. "] -> " .. entry.result .. " (" .. entry.key .. ")") + end +end + +function BNP:ResetSpellDBAudit() + DB.stats.total = 0 + DB.stats.id = 0 + DB.stats.name = 0 + DB.stats.texture = 0 + DB.stats.unknown = 0 + DB.stats.ignored = 0 + for i = table.getn(DB.recent), 1, -1 do + table.remove(DB.recent, i) + end + self:Print("SpellDB-Protokoll zurückgesetzt.") +end diff --git a/spells/rogue.lua b/spells/rogue.lua new file mode 100644 index 0000000..d685997 --- /dev/null +++ b/spells/rogue.lua @@ -0,0 +1,75 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.RogueAuras = { + { + key = "garrote", + names = { "Garrote" }, + duration = 18, + }, + { + key = "rupture", + names = { "Rupture" }, + duration = 6, + }, + { + key = "gouge", + names = { "Gouge" }, + duration = 4, + }, + { + key = "kidney_shot", + names = { "Kidney Shot" }, + duration = 6, + }, + { + key = "cheap_shot", + names = { "Cheap Shot" }, + duration = 4, + }, + { + key = "sap", + names = { "Sap" }, + duration = 45, + }, + { + key = "blind", + names = { "Blind" }, + duration = 10, + }, + { + key = "expose_armor", + names = { "Expose Armor" }, + duration = 30, + }, + { + key = "kick_silenced", + names = { "Kick - Silenced" }, + duration = 2, + }, + { + key = "hemorrhage", + names = { "Hemorrhage" }, + duration = 15, + }, + { + key = "crippling_poison", + names = { "Crippling Poison" }, + duration = 12, + }, + { + key = "mind_numbing_poison", + names = { "Mind-numbing Poison" }, + duration = 10, + }, + { + key = "wound_poison", + names = { "Wound Poison" }, + duration = 15, + }, + { + key = "deadly_poison", + names = { "Deadly Poison", "Deadly Poison II", "Deadly Poison III", "Deadly Poison IV", "Deadly Poison V" }, + duration = 12, + }, +} diff --git a/spells/shaman.lua b/spells/shaman.lua new file mode 100644 index 0000000..3a23dd9 --- /dev/null +++ b/spells/shaman.lua @@ -0,0 +1,40 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.ShamanAuras = { + { + key = "flame_shock", + names = { "Flame Shock" }, + duration = 12, + }, + { + key = "frost_shock", + names = { "Frost Shock" }, + duration = 8, + }, + { + key = "stormstrike", + names = { "Stormstrike" }, + duration = 12, + }, + { + key = "earth_shock", + names = { "Earth Shock" }, + duration = 2, + }, + { + key = "earthbind", + names = { "Earthbind" }, + duration = 5, + }, + { + key = "earthgrab", + names = { "Earthgrab" }, + duration = 4, + }, + { + key = "frostbrand_attack", + names = { "Frostbrand Attack" }, + duration = 8, + }, +} diff --git a/spells/warlock.lua b/spells/warlock.lua new file mode 100644 index 0000000..6c838a1 --- /dev/null +++ b/spells/warlock.lua @@ -0,0 +1,172 @@ +BNP = BNP or {} + +-- Warlock debuffs supported by the SuperWoW own-cast tracker. +-- Durations are Vanilla defaults. Rank-specific values are used where ranks +-- materially change the duration. Server-specific effects such as Dark +-- Harvest are intentionally handled in a later compatibility pass. +BNP.WarlockAuras = { + { + key = "corruption", + names = { "Corruption", "Verderbnis" }, + texture = "Interface\\Icons\\Spell_Shadow_AbominationExplosion", + textureMatch = "abominationexplosion", + duration = 18, + durations = { [172] = 12, [6222] = 15, [6223] = 18, [7648] = 18, [11671] = 18, [11672] = 18 }, + }, + { + key = "curse_of_agony", + category = "CURSE", + names = { "Curse of Agony", "Fluch der Pein" }, + texture = "Interface\\Icons\\Spell_Shadow_CurseOfSargeras", + textureMatch = "curseofsargeras", + duration = 24, + spellIDs = { 980, 1014, 6217, 11711, 11712, 11713 }, + }, + { + key = "siphon_life", + names = { "Siphon Life", "Lebensentzug" }, + texture = "Interface\\Icons\\Spell_Shadow_Requiem", + textureMatch = "requiem", + duration = 30, + spellIDs = { 18265, 18879, 18880, 18881 }, + }, + { + key = "immolate", + names = { "Immolate", "Feuerbrand" }, + texture = "Interface\\Icons\\Spell_Fire_Immolation", + textureMatch = "immolation", + duration = 15, + spellIDs = { 348, 707, 1094, 2941, 11665, 11667, 11668 }, + }, + { + key = "curse_of_doom", + category = "CURSE", + names = { "Curse of Doom", "Fluch der Verdammnis" }, + texture = "Interface\\Icons\\Spell_Shadow_AuraOfDarkness", + textureMatch = "auraofdarkness", + duration = 60, + spellIDs = { 603, 30910 }, + }, + { + key = "curse_of_weakness", + category = "CURSE", + names = { "Curse of Weakness", "Fluch der Schwäche" }, + texture = "Interface\\Icons\\Spell_Shadow_CurseOfMannoroth", + textureMatch = "curseofmannoroth", + duration = 120, + spellIDs = { 702, 1108, 6205, 7646, 11707, 11708 }, + }, + { + key = "curse_of_recklessness", + category = "CURSE", + names = { "Curse of Recklessness", "Fluch der Tollkühnheit" }, + texture = "Interface\\Icons\\Spell_Shadow_UnholyStrength", + textureMatch = "unholystrength", + duration = 120, + spellIDs = { 704, 7658, 7659, 11717 }, + }, + { + key = "curse_of_tongues", + category = "CURSE", + names = { "Curse of Tongues", "Fluch der Sprachen" }, + texture = "Interface\\Icons\\Spell_Shadow_CurseOfTounges", + textureMatch = "curseoftounges", + duration = 30, + spellIDs = { 1714, 11719 }, + }, + { + key = "curse_of_elements", + category = "CURSE", + names = { "Curse of the Elements", "Curse of Elements", "Fluch der Elemente" }, + texture = "Interface\\Icons\\Spell_Shadow_ChillTouch", + textureMatch = "chilltouch", + duration = 300, + spellIDs = { 1490, 11721, 11722 }, + }, + { + key = "curse_of_shadow", + category = "CURSE", + names = { "Curse of Shadow", "Fluch der Schatten" }, + texture = "Interface\\Icons\\Spell_Shadow_CurseOfAchimonde", + textureMatch = "curseofachimonde", + duration = 300, + spellIDs = { 17862, 17937 }, + }, + { + key = "curse_of_exhaustion", + category = "CURSE", + names = { "Curse of Exhaustion", "Fluch der Erschöpfung" }, + texture = "Interface\\Icons\\Spell_Shadow_GrimWard", + textureMatch = "grimward", + duration = 12, + spellIDs = { 18223 }, + }, + { + key = "fear", + names = { "Fear", "Furcht" }, + texture = "Interface\\Icons\\Spell_Shadow_Possession", + textureMatch = "possession", + duration = 20, + durations = { [5782] = 10, [6213] = 15, [6215] = 20 }, + }, + { + key = "howl_of_terror", + names = { "Howl of Terror", "Schreckensgeheul" }, + texture = "Interface\\Icons\\Spell_Shadow_DeathScream", + textureMatch = "deathscream", + duration = 15, + durations = { [5484] = 10, [17928] = 15 }, + }, + { + key = "banish", + names = { "Banish", "Verbannen" }, + texture = "Interface\\Icons\\Spell_Shadow_Cripple", + textureMatch = "cripple", + duration = 30, + durations = { [710] = 20, [18647] = 30 }, + }, + { + key = "death_coil", + names = { "Death Coil", "Todesmantel" }, + texture = "Interface\\Icons\\Spell_Shadow_DeathCoil", + textureMatch = "deathcoil", + duration = 3, + spellIDs = { 6789, 17925, 17926 }, + }, + { + key = "drain_life", + channel = true, + names = { "Drain Life", "Blutsauger" }, + texture = "Interface\\Icons\\Spell_Shadow_LifeDrain02_Purple", + textureMatch = "lifedrain02", + duration = 5, + spellIDs = { 689, 699, 709, 7651, 11699, 11700 }, + }, + { + key = "drain_soul", + channel = true, + names = { "Drain Soul", "Seelendieb" }, + texture = "Interface\\Icons\\Spell_Shadow_Haunting", + textureMatch = "haunting", + duration = 15, + spellIDs = { 1120, 8288, 8289, 11675 }, + }, + { + key = "enslave_demon", + names = { "Enslave Demon", "Dämonensklave" }, + texture = "Interface\\Icons\\Spell_Shadow_EnslaveDemon", + textureMatch = "enslavedemon", + duration = 300, + spellIDs = { 1098, 11725, 11726 }, + }, + { + key = "shadow_vulnerability", + names = { "Shadow Vulnerability" }, + duration = 10, + spellIDs = { 17794, 17797, 17798, 17799, 17800 }, + -- Shadow Vulnerability uses a different aura spell ID for each talent rank. + -- Track every rank so BNP also sees/refreshes Rank 5 [17800]. + shared = true, + sharedSpellIDs = { 17794, 17797, 17798, 17799, 17800 }, + }, +} diff --git a/spells/warrior.lua b/spells/warrior.lua new file mode 100644 index 0000000..93f613b --- /dev/null +++ b/spells/warrior.lua @@ -0,0 +1,113 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.WarriorAuras = { + { + key = "rend", + names = { "Rend" }, + duration = 21, + }, + { + key = "hamstring", + names = { "Hamstring" }, + duration = 15, + }, + { + key = "improved_hamstring", + names = { "Improved Hamstring" }, + duration = 5, + }, + { + key = "thunder_clap", + names = { "Thunder Clap" }, + duration = 30, + aoe = true, + }, + { + key = "demoralizing_shout", + names = { "Demoralizing Shout" }, + duration = 30, + aoe = true, + }, + { + key = "sunder_armor", + names = { "Sunder Armor" }, + duration = 30, + }, + { + key = "piercing_howl", + names = { "Piercing Howl" }, + duration = 6, + aoe = true, + }, + { + key = "mortal_strike", + names = { "Mortal Strike" }, + duration = 10, + }, + { + key = "intimidating_shout", + names = { "Intimidating Shout" }, + duration = 8, + -- Intimidating Shout applies its CC to the primary target and nearby + -- enemies. Treat it as an AoE aura so every affected visible GUID is + -- live-confirmed and receives its own 8-second timer. + aoe = true, + }, + { + key = "mocking_blow", + names = { "Mocking Blow" }, + duration = 6, + }, + { + key = "disarm", + names = { "Disarm" }, + duration = 10, + }, + { + key = "taunt", + names = { "Taunt" }, + duration = 3, + }, + { + key = "challenging_shout", + names = { "Challenging Shout" }, + duration = 6, + aoe = true, + }, + { + key = "charge_stun", + names = { "Charge Stun" }, + duration = 1, + }, + { + key = "intercept_stun", + names = { "Intercept Stun" }, + duration = 3, + }, + { + key = "concussion_blow", + names = { "Concussion Blow" }, + duration = 5, + }, + { + key = "revenge_stun", + names = { "Revenge Stun" }, + duration = 3, + }, + { + key = "pummel", + names = { "Pummel" }, + duration = 4, + }, + { + key = "shield_bash", + names = { "Shield Bash" }, + duration = 6, + }, + { + key = "deep_wound", + names = { "Deep Wound", "Deep Wounds" }, + duration = 12, + }, +}