diff --git a/BlizzNameplatesPlus.toc b/BlizzNameplatesPlus.toc new file mode 100644 index 0000000..1745482 --- /dev/null +++ b/BlizzNameplatesPlus.toc @@ -0,0 +1,38 @@ +## Interface: 11200 +## Title: Blizz Nameplates+ +## Notes: Lightweight personal debuffs and class colors for Blizzard nameplates. Requires SuperWoW. +## Author: Wurmschwanz; nameplate discovery inspired by Shagu's Vanilla projects +## OptionalDeps: ShaguTweaks +## Version: 1.0.5 +## 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\classcolors.lua +modules\castbar.lua +modules\guiddebug.lua +modules\tankmode.lua +modules\healthpercent.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..4ca2fe8 --- /dev/null +++ b/core/config.lua @@ -0,0 +1,104 @@ +BNP = BNP or {} + +BNP.defaults = BNP.defaults or { + nameplateScale = 1.0, + iconSize = 18, + tankMode = false, + classColors = true, + castbars = true, + castbarHeight = 6, + minimapAngle = 225, + healthPercent = false, + healthText = "off", + targetFocus = true, + targetGlowColor = "white", + targetOnTop = true, +} + +function BNP:InitConfig() + BNP_DB = BNP_DB or {} + + if BNP_DB.nameplateScale == nil then BNP_DB.nameplateScale = self.defaults.nameplateScale end + if BNP_DB.iconSize == nil then BNP_DB.iconSize = self.defaults.iconSize 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.castbarHeight == nil then BNP_DB.castbarHeight = self.defaults.castbarHeight 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.targetOnTop == nil then BNP_DB.targetOnTop = self.defaults.targetOnTop end +end + +function BNP:GetNameplateScale() + return (BNP_DB and tonumber(BNP_DB.nameplateScale)) or self.defaults.nameplateScale +end + +function BNP:GetIconSize() + return (BNP_DB and tonumber(BNP_DB.iconSize)) or self.defaults.iconSize +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: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:IsTargetOnTopEnabled() + return not BNP_DB or BNP_DB.targetOnTop ~= 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 diff --git a/core/core.lua b/core/core.lua new file mode 100644 index 0000000..8a1e7e2 --- /dev/null +++ b/core/core.lua @@ -0,0 +1,37 @@ +BNP = BNP or {} +BNP.version = "1.0.5" +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 + + if CombatLogAdd and SpellInfo then + BNP:Print(BNP.version .. " loaded. Standalone nameplates + SuperWoW tracking active.") + 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 + else + BNP:Print("ERROR: SuperWoW API not detected. Blizz Nameplates+ requires SuperWoW.") + 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..b02dd26 --- /dev/null +++ b/core/options.lua @@ -0,0 +1,413 @@ +BNP = BNP or {} + +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) + local check = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate") + check:SetPoint("TOPLEFT", parent, "TOPLEFT", 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:SetScript("OnClick", onclick) + return check +end + +function BNP:CreateOptions() + if self.optionsFrame then return end + + local frame = CreateFrame("Frame", "BNPOptionsFrame", UIParent) + frame:SetWidth(290) + frame:SetHeight(575) + frame:SetPoint("CENTER", UIParent, "CENTER", 0, 80) + 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 scale = CreateSlider(frame, "Nameplate Scale", 0.70, 1.50, 0.05, -78) + 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 icon = CreateSlider(frame, "Debuff Icon Size", 12, 32, 1, -133) + 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("Debuff Icon Size: " .. value) + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + end) + frame.iconSlider = icon + + local castbarHeight = CreateSlider(frame, "Castbar Height", 4, 14, 1, -188) + 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.RefreshCastbarHeights then BNP:RefreshCastbarHeights() end + end) + frame.castbarHeightSlider = castbarHeight + + local classColors = CreateCheck(frame, "Class Colors", -226, 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 castbars = CreateCheck(frame, "Castbars", -256, 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 tank = CreateCheck(frame, "Tank Mode", -286, function() + BNP_DB.tankMode = this:GetChecked() and true or false + if BNP.UpdateTankMode then BNP:UpdateTankMode() end + end) + frame.tankCheck = tank + + local healthTextLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + healthTextLabel:SetPoint("TOPLEFT", frame, "TOPLEFT", 28, -318) + healthTextLabel:SetText("Health Text") + + local healthTextDropdown = CreateFrame("Frame", "BNPHealthTextDropdown", frame, "UIDropDownMenuTemplate") + healthTextDropdown:SetPoint("TOPLEFT", frame, "TOPLEFT", 10, -330) + 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 + -- Keep the legacy setting synchronized for backwards compatibility. + 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 i + for i = 1, table.getn(modes) do + local mode = modes[i] + 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 + + local targetFocus = CreateCheck(frame, "Target Glow", -374, function() + BNP_DB.targetFocus = this:GetChecked() and true or false + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + end) + frame.targetFocusCheck = targetFocus + + local glowColorLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + glowColorLabel:SetPoint("TOPLEFT", frame, "TOPLEFT", 28, -406) + glowColorLabel:SetText("Target Glow Color") + + local glowColorDropdown = CreateFrame("Frame", "BNPTargetGlowColorDropdown", frame, "UIDropDownMenuTemplate") + glowColorDropdown:SetPoint("TOPLEFT", frame, "TOPLEFT", 10, -418) + 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 i + for i = 1, table.getn(colors) do + local color = colors[i] + 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 + + local targetOnTop = CreateCheck(frame, "Target Plate on Top", -462, function() + BNP_DB.targetOnTop = this:GetChecked() and true or false + if BNP.RefreshTargetOnTop then BNP:RefreshTargetOnTop() end + end) + frame.targetOnTopCheck = targetOnTop + + local note = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + note:SetPoint("TOPLEFT", frame, "TOPLEFT", 28, -502) + note:SetWidth(235) + note:SetJustifyH("LEFT") + note:SetText("Changes are applied immediately.\nTank Mode: green = targeting you, red = targeting someone else.") + + self.optionsFrame = frame +end + +function BNP:SyncOptions() + self:CreateOptions() + local frame = self.optionsFrame + frame.scaleSlider:SetValue(self:GetNameplateScale()) + frame.iconSlider:SetValue(self:GetIconSize()) + frame.castbarHeightSlider:SetValue(self:GetCastbarHeight()) + frame.classColorsCheck:SetChecked(self:AreClassColorsEnabled()) + frame.castbarsCheck:SetChecked(self:AreCastbarsEnabled()) + frame.tankCheck:SetChecked(self:IsTankModeEnabled()) + 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 + + frame.targetOnTopCheck:SetChecked(self:IsTargetOnTopEnabled()) + 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/auras.lua b/modules/auras.lua new file mode 100644 index 0000000..665921e --- /dev/null +++ b/modules/auras.lua @@ -0,0 +1,1698 @@ +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 ICON_OFFSET_Y = UI.ICON_OFFSET_Y or 15 +local ICON_SPACING = UI.ICON_SPACING or 2 +local UPDATE_INTERVAL = 0.05 + +-- 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.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 MAX_VISIBLE_ICONS = 8 + +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(40) + + 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 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, plate) + container:SetWidth(width) + container:SetHeight(size) + container:SetFrameLevel(40) + + if plate.healthbar then + container:SetPoint("BOTTOM", plate.healthbar, "TOP", 0, ICON_OFFSET_Y) + else + container:SetPoint("BOTTOM", plate, "BOTTOM", 0, 24) + end + + container.icons = {} + for i = 1, MAX_VISIBLE_ICONS do + local icon = CreateAuraIcon(container, i) + if i == 1 then + icon:SetPoint("LEFT", container, "LEFT", 0, 0) + else + icon:SetPoint("LEFT", container.icons[i - 1], "RIGHT", ICON_SPACING, 0) + end + container.icons[i] = icon + end + + container:Hide() + plate.BNPAuraContainer = 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.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 + +-- Central all-class aura tracking policy ------------------------------------- +-- Modeled after ShaguPlates' SuperWoW/libdebuff approach: +-- the player's exact SuperWoW CAST can seed a local debuff timer, while live +-- UnitDebuff data is used to correct/upgrade that cached state. +-- +-- BNP intentionally keeps risky effects strict: +-- * Warlock curses (Turtle dual-curse talent can resist each curse separately) +-- * AoE effects (must be verified per victim) +-- * CC / roots / stuns / traps / silences / indirect talent procs +-- * melee effects where application can fail independently +-- +-- Anything not explicitly audited below defaults to strict AURA confirmation. +local TRACK_CAST = { + DRUID = { + moonfire=true, + insect_swarm=true, + faerie_fire=true, + faerie_fire_feral=true, + }, + + HUNTER = { + serpent_sting=true, + viper_sting=true, + scorpid_sting=true, + }, + + PRIEST = { + shadow_word_pain=true, + devouring_plague=true, + vampiric_embrace=true, + holy_fire=true, + starshards=true, + }, + + SHAMAN = { + flame_shock=true, + }, + + WARLOCK = { + corruption=true, + siphon_life=true, + immolate=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 classPolicy = TRACK_CAST[playerClass] + if classPolicy and classPolicy[def.key] then + return "CAST" + end + + return "AURA" +end + +BNP.castPrimaryCommits = BNP.castPrimaryCommits or {} + +local function MarkCastPrimaryCommit(guid, def, spellID) + if not guid or not def or not def.key or not spellID then return end + BNP.castPrimaryCommits[guid] = BNP.castPrimaryCommits[guid] or {} + BNP.castPrimaryCommits[guid][def.key] = { + spellID = spellID, + time = GetTime(), + } +end + +local function RetractCastPrimaryOnFail(guid, spellID) + if not guid or not spellID then return end + + local byGUID = BNP.castPrimaryCommits[guid] + if not byGUID then return end + + local key, info + for key, info in pairs(byGUID) do + if info and info.spellID == spellID and (GetTime() - (info.time or 0)) <= 4.0 then + local cache = BNP.guidAuras[guid] + if cache and cache[key] and cache[key].castPrimary then + cache[key] = nil + RaidTrace("RETRACT", guid, key, spellID) + end + byGUID[key] = nil + end + end +end + +local function IsCastPrimary(def) + return GetTrackingMode(def) == "CAST" +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 CommitPendingAura(guid, key, pending) + 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 + + -- Only replace mutually-exclusive curses after the new curse is confirmed + -- on the target. A resisted curse must never remove the existing one. + if def.category == "CURSE" and def.curseExclusive ~= false then + local _, oldDef + for _, oldDef in ipairs(AURA_DEFS) do + if oldDef.category == "CURSE" and oldDef.curseExclusive ~= false then + cache[oldDef.key] = nil + end + end + end + + 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 + 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) + 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) + 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, + }) + 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 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) + RetractCastPrimaryOnFail(targetGUID, spellID) + return + end + + -- Dark Harvest is a CHANNEL event, not CAST. Handle only this exact + -- Turtle spell here so normal aura creation remains CAST-only. + if eventType == "CHANNEL" and spellID == DARK_HARVEST_SPELL_ID then + StartDarkHarvest(targetGUID, arg5) + return + end + + if eventType ~= "CAST" 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 + local pendingForGUID = BNP.pendingAuras[targetGUID] + if not pendingForGUID then + pendingForGUID = {} + BNP.pendingAuras[targetGUID] = pendingForGUID + end + + pendingForGUID.__judgement = { + judgementProbe = true, + created = GetTime(), + } + return + end + + local def, texture = FindAuraDef(spellID) + if not def then return end + + local duration = nil + if BNP.GetSpellbookDurationForDef then + duration = BNP:GetSpellbookDurationForDef(def) + end + duration = duration or (def.durations and def.durations[spellID]) or def.duration + + if def.aoe then + QueueAoEAura(def, spellID, texture, duration) + return + end + + if not targetGUID then return end + + 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 + MarkCastPrimaryCommit(targetGUID, def, spellID) + 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 function FindSharedAuraOnUnit(unit, def) + if not unit or not def then return nil, nil, nil, nil end + + local i + for i = 1, 64 do + local a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = UnitDebuff(unit, i) + local texture = a1 + local auraSpellID = a4 + if not texture then break end + + local auraName = nil + if auraSpellID and SpellInfo then + auraName = SpellInfo(auraSpellID) + end + + if 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 > GetTime() then + duration = d + expires = e + break + end + end + + return auraSpellID, texture, duration, expires + end + end + + return nil, nil, nil, nil +end + +local function SyncSharedAuras(unit, guid, now) + 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 = FindSharedAuraOnUnit(unit, def) + 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 + + if liveExpires and liveExpires > now then + -- Best case: the client exposes the real remaining duration. + aura.duration = liveDuration + aura.expires = liveExpires + aura.sharedEstimated = nil + elseif not aura.expires then + -- Vanilla fallback: the original application time is unavailable. + -- Start a local countdown when BNP first sees the foreign mark. + -- This is intentionally marked as estimated and will still be + -- removed immediately when the real aura disappears. + 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 + cache[def.key] = nil + 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 + +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 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 SyncAuraRemoval(unit, guid, now) + 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 + for i = 1, 64 do + local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) + if not texture then break 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 + + local key, aura + for key, aura in pairs(cache) do + if type(aura) == "table" and aura.spellID and aura.expires 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 exact live removal on every visible plate + -- because those effects genuinely need immediate break/dispel detection. + local playerRemoval = false + if unit == "target" and UnitIsPlayer and UnitIsPlayer("target") then + local currentTargetGUID = GetTargetGUID() + playerRemoval = currentTargetGUID and currentTargetGUID == guid + end + + 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 + if aura.castPrimary then + aura.castPrimary = nil + aura.castPrimaryAt = nil + aura.liveConfirmed = true + end + elseif exactRemoval then + RaidTrace("REMOVE", guid, key, aura.spellID) + -- CC/roots/traps must disappear immediately when broken/dispelled. + cache[key] = nil + 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 function GetStablePlateGUID(plate) + local guid = GetPlateGUID(plate) + + if not guid then + plate.BNPAuraGUIDCandidate = nil + plate.BNPAuraGUIDStable = nil + return nil + end + + if plate.BNPAuraGUIDCandidate ~= guid then + plate.BNPAuraGUIDCandidate = guid + plate.BNPAuraGUIDStable = nil + return nil + end + + plate.BNPAuraGUIDStable = guid + return guid +end + +local function UpdatePlate(plate) + if not plate or not plate:IsShown() then return end + if not plate.BNPAuraContainer then CreateAuraContainer(plate) end + + local container = plate.BNPAuraContainer + if not container then return end + + local guid = GetStablePlateGUID(plate) + 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 + local now = 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 + 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 + + 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 + end + elseif cache and aura then + cache[def.key] = nil + end + end + + table.sort(active, function(a, b) + return (a.aura.order or 0) < (b.aura.order or 0) + end) + + local visibleCount = table.getn(active) + local iconSize = GetIconSize() + local maxWidth = iconSize * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + local rowWidth = 0 + if visibleCount > 0 then + rowWidth = iconSize * visibleCount + ICON_SPACING * (visibleCount - 1) + end + local startX = (maxWidth - rowWidth) / 2 + + for i = 1, table.getn(container.icons) do + local icon = container.icons[i] + local entry = active[i] + + if entry then + icon:ClearAllPoints() + icon:SetPoint("LEFT", container, "LEFT", startX + (i - 1) * (iconSize + ICON_SPACING), 0) + 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 = FormatTimer(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 + + +function BNP:RefreshAuraLayout(plate) + if not plate or not plate.BNPAuraContainer then return end + + local container = plate.BNPAuraContainer + local size = GetIconSize() + local width = size * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + + container:SetWidth(width) + container:SetHeight(size) + + local i + for i = 1, table.getn(container.icons) do + container.icons[i]:SetWidth(size) + container.icons[i]:SetHeight(size) + end +end + +function BNP:RefreshAllAuraLayouts() + local plate + for plate in pairs(BNP.plates or {}) do + self:RefreshAuraLayout(plate) + end +end + +ScanSpellbook() +table.insert(BNP.libnameplate.OnInit, CreateAuraContainer) +table.insert(BNP.libnameplate.OnShow, CreateAuraContainer) + +-- 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) + + local rguid, entries, rkey, info + for rguid, entries in pairs(BNP.castPrimaryCommits or {}) do + local any = false + for rkey, info in pairs(entries) do + if not info or now - (info.time or 0) > 2.0 then + entries[rkey] = nil + else + any = true + end + end + if not any then BNP.castPrimaryCommits[rguid] = nil end + end + + 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 + SyncSharedAuras("target", targetGUID, now) + + if BNP.guidAuras[targetGUID] then + SyncAuraRemoval("target", targetGUID, now) + 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. + if hasPending then + ConfirmPendingForUnit(token, guid, now) + end + + if hasPendingAoE then + ConfirmAoEAurasOnUnit(token, guid) + end + + if doRemovalScan then + SyncSharedAuras(token, guid, now) + + -- Current target is already handled above via the real "target" unit. + -- Non-target exact-removal waits briefly after a target switch so a + -- transient empty UnitDebuff list cannot delete valid auras. + local currentTargetGUID = targetGUID + local isCurrentTarget = currentTargetGUID and currentTargetGUID == guid + SyncAuraRemoval(token, guid, now) + end + end + end + + UpdatePlate(plate) + end + + if hasPending then + CleanupPending(now) + end + + if hasPendingAoE then + CleanupPendingAoE(now) + end +end) + + +-- Shadow Vulnerability [17794]: isolated live-aura detector. +local svElapsed = 0 +local svPresent = {} +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 svFrame=CreateFrame("Frame") +svFrame:SetScript("OnUpdate",function() + if playerClass~="WARLOCK" then return end + svElapsed=svElapsed+arg1 + if svElapsed<0.10 then return end + svElapsed=0 + local ok,guid=UnitExists("target") + if not ok or not guid then return end + local found,tex,count=false,nil,0 + local i + for i=1,32 do + local t,c,dt,id=UnitDebuff("target",i) + if not t then break end + if id==17794 then found,tex,count=true,t,tonumber(c) or 0 break end + end + local def=SVDef() + if not def then return end + if found then + 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 + if not svPresent[guid] then + aura.expires=GetTime()+10 + aura.duration=10 + end + aura.spellID=17794 + aura.texture=tex or def.texture + aura.stacks=count + aura.confirmedAt=GetTime() + aura.missingScans=nil + svPresent[guid]=true + elseif svPresent[guid] then + local cache=BNP.guidAuras[guid] + if cache then cache[def.key]=nil end + svPresent[guid]=nil + end +end) + + + +-- Shadow Vulnerability refresh detector ------------------------------------- +-- The aura can proc again while already active. The direct aura watcher keeps +-- existence correct; this helper only refreshes its local 10s timer when a +-- Shadow Bolt / Drain Soul event is followed by an active aura 17794. +local svRefreshGUID = nil +local svRefreshUntil = 0 +local svRefreshElapsed = 0 + +local function IsShadowVulnerabilityTrigger(spellID) + if not spellID or not SpellInfo then return false end + local name = SpellInfo(spellID) + return name == "Shadow Bolt" or name == "Drain Soul" +end + +local function RefreshShadowVulnerabilityIfPresent(guid) + if playerClass ~= "WARLOCK" or not guid then return false end + + local exists, currentGUID = UnitExists("target") + if not exists or not currentGUID or currentGUID ~= guid then + return false + end + + local found = false + local texture = nil + local stacks = 0 + local i + for i = 1, 64 do + local t, c, dtype, auraSpellID = UnitDebuff("target", i) + if not t then break end + if auraSpellID == 17794 then + found = true + texture = t + stacks = tonumber(c) or 0 + break + end + end + + if not found then return false end + + local def = nil + for i = 1, table.getn(AURA_DEFS) do + if AURA_DEFS[i] and AURA_DEFS[i].key == "shadow_vulnerability" then + def = AURA_DEFS[i] + break + end + end + if not def then return false end + + 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.duration = 10 + aura.expires = GetTime() + 10 + aura.spellID = 17794 + aura.texture = texture or def.texture + aura.stacks = stacks + aura.confirmedAt = GetTime() + aura.missingScans = nil + + return true +end + +local svRefreshEvent = CreateFrame("Frame") +svRefreshEvent:RegisterEvent("UNIT_CASTEVENT") +svRefreshEvent:SetScript("OnEvent", function() + if playerClass ~= "WARLOCK" then return end + + local playerExists, playerGUID = UnitExists("player") + if not playerExists or not playerGUID or arg1 ~= playerGUID then return end + + local targetGUID = arg2 + local eventType = arg3 + local spellID = arg4 + + if eventType ~= "CAST" and eventType ~= "CHANNEL" then return end + if not IsShadowVulnerabilityTrigger(spellID) then return end + if not targetGUID then return end + + -- Give the proc aura a brief moment to appear on the target. + svRefreshGUID = targetGUID + svRefreshUntil = GetTime() + 0.8 + svRefreshElapsed = 0 +end) + +local svRefreshFrame = CreateFrame("Frame") +svRefreshFrame:SetScript("OnUpdate", function() + if not svRefreshGUID then return end + + svRefreshElapsed = svRefreshElapsed + arg1 + if svRefreshElapsed < 0.05 then return end + svRefreshElapsed = 0 + + if GetTime() > svRefreshUntil then + svRefreshGUID = nil + return + end + + if RefreshShadowVulnerabilityIfPresent(svRefreshGUID) then + svRefreshGUID = nil + end +end) + diff --git a/modules/castbar.lua b/modules/castbar.lua new file mode 100644 index 0000000..df14dbc --- /dev/null +++ b/modules/castbar.lua @@ -0,0 +1,264 @@ +BNP = BNP or {} + +local casts = {} +local BAR_HEIGHT = 6 + +local function GetCastbarHeight() + if BNP.GetCastbarHeight then return BNP:GetCastbarHeight() end + return BAR_HEIGHT +end +local BAR_GAP = 2 + +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. + bar:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", BAR_HEIGHT + 1, -BAR_GAP) + bar:SetPoint("TOPRIGHT", plate.healthbar, "BOTTOMRIGHT", 14, -BAR_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, -BAR_GAP) + icon:SetFrameLevel(bar:GetFrameLevel()) + + 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() 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 + 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 + 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() then return end + + local now = GetTime() + local plate + for plate in pairs(activeCastPlates) do + if not plate:IsShown() then + MarkCastPlateInactive(plate) + else + local guid = GetPlateGUID(plate) + local cast = guid and casts[guid] + + if not cast or now >= cast.endTime then + if 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:RefreshCastbarHeights() + local plate + local height = GetCastbarHeight() + + for plate in pairs(BNP.plates or {}) do + if plate and plate.BNPCastbar then + plate.BNPCastbar:SetHeight(height) + end + end +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/commands.lua b/modules/commands.lua new file mode 100644 index 0000000..13e8bf1 --- /dev/null +++ b/modules/commands.lua @@ -0,0 +1,146 @@ +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 + ) + end + end + end + if n==0 then DEFAULT_CHAT_FRAME:AddMessage("CACHE: EMPTY") 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)) + 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 == "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 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..db80e39 --- /dev/null +++ b/modules/fullalpha.lua @@ -0,0 +1,242 @@ +BNP = BNP or {} + +local NON_TARGET_ALPHA = 0.55 + +local function IsValidFrameStrata(value) + return value == "BACKGROUND" + or value == "LOW" + or value == "MEDIUM" + or value == "HIGH" + or value == "DIALOG" + or value == "FULLSCREEN" + or value == "FULLSCREEN_DIALOG" + or value == "TOOLTIP" +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 + return token +end + +local function GetTargetGUID() + local exists, guid = UnitExists("target") + if exists and guid then return guid end + return nil +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) + -- Target focus is now indicated by a ShaguPlates-style glow instead of + -- fading every non-target nameplate. + 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 + + local wrapper = plate.BNPScaleWrapper + local target = false + + if BNP.IsTargetOnTopEnabled and BNP:IsTargetOnTopEnabled() then + local targetGUID = GetTargetGUID() + local plateGUID = GetPlateGUID(plate) + target = targetGUID and plateGUID and targetGUID == plateGUID + end + + -- Never feed GetFrameStrata() values back into SetFrameStrata(). + -- Some Vanilla/SuperWoW projected frames report "UNKNOWN". + if target then + if plate.SetFrameStrata then plate:SetFrameStrata("HIGH") end + if wrapper and wrapper.SetFrameStrata then wrapper:SetFrameStrata("HIGH") end + else + if plate.SetFrameStrata then plate:SetFrameStrata("MEDIUM") end + if wrapper and wrapper.SetFrameStrata then wrapper:SetFrameStrata("MEDIUM") end + 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 EnsureTargetGlow(plate) + if not plate then return nil end + if plate.BNPTargetGlow then return plate.BNPTargetGlow end + + local anchor = plate.healthbar or plate + local glow = plate:CreateTexture(nil, "BACKGROUND") + glow:SetTexture("Interface\\AddOns\\BlizzNameplatesPlus\\media\\shagu_target_glow.tga") + glow:SetPoint("CENTER", anchor, "CENTER", 10, 0) + + -- ShaguPlates uses health width + 60 and health height + 30. + -- Use the actual Blizzard/BNP bar size so this also follows BNP scaling. + local width = anchor.GetWidth and anchor:GetWidth() or 100 + local height = anchor.GetHeight and anchor:GetHeight() or 10 + glow:SetWidth(width + 59) + glow:SetHeight(height + 22) + 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 100 + local height = anchor.GetHeight and anchor:GetHeight() or 10 + glow:ClearAllPoints() + glow:SetPoint("CENTER", anchor, "CENTER", 10, 0) + glow:SetWidth(width + 59) + glow:SetHeight(height + 22) + if BNP.GetTargetGlowColor then + local r, g, b = BNP:GetTargetGlowColor() + glow:SetVertexColor(r, g, b, 1) + end + glow:Show() + else + glow:Hide() + end +end + +local function ApplyTargetAlpha(plate) + if not plate or not plate:IsShown() then return end + local wanted = DesiredAlpha(plate) + if plate:GetAlpha() ~= wanted then + plate:SetAlpha(wanted) + end +end + +local function InstallAlphaGuard(plate) + plate = plate or this + if not plate or plate.BNPFullAlphaGuard then return end + + local oldOnUpdate = plate:GetScript("OnUpdate") + plate:SetScript("OnUpdate", function() + if oldOnUpdate then oldOnUpdate() end + ApplyTargetAlpha(this or plate) + ApplyTargetGlow(this or plate) + ApplyForeignTagVisual(this or plate) + ApplyTargetFrameLevel(this or plate) + 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 + InstallAlphaGuard(current) + ApplyTargetAlpha(current) + ApplyTargetGlow(current) + ApplyForeignTagVisual(current) + ApplyTargetFrameLevel(current) + end) +end + +function BNP:RefreshTargetFocus() + local plate + for plate in pairs(self.plates or {}) do + ApplyTargetAlpha(plate) + ApplyTargetGlow(plate) + ApplyForeignTagVisual(plate) + end +end + +function BNP:RefreshTargetOnTop() + local plate + for plate in pairs(self.plates or {}) do + ApplyTargetFrameLevel(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..aff9e72 --- /dev/null +++ b/modules/healthpercent.lua @@ -0,0 +1,199 @@ +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 + + -- Purely visual/read-only overlay. No hooks or writes to the healthbar. + local parent = plate.BNPScaleWrapper or plate + local text = parent:CreateFontString(nil, "OVERLAY") + text:SetFont("Fonts\\FRIZQT__.TTF", 9, "OUTLINE") + text:SetPoint("CENTER", plate.healthbar, "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 diff --git a/modules/scale.lua b/modules/scale.lua new file mode 100644 index 0000000..09f60e8 --- /dev/null +++ b/modules/scale.lua @@ -0,0 +1,114 @@ +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 EnsureScaleWrapper(plate) + if not plate or plate.BNPScaleWrapper then return end + + local originalWidth = plate:GetWidth() + local originalHeight = plate:GetHeight() + + local wrapper = CreateFrame("Frame", nil, plate) + wrapper:SetAllPoints(plate) + wrapper.plate = plate + wrapper.originalWidth = originalWidth + wrapper.originalHeight = originalHeight + + plate.BNPScaleWrapper = wrapper + + -- Match ShaguTweaks: move Blizzard nameplate visuals into a child frame. + -- Never SetScale() on the original WorldFrame nameplate itself, otherwise + -- its projected screen position shifts toward the unit's feet. + if plate.healthbar then + plate.healthbar:SetParent(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 + object:SetParent(wrapper) + end + end +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) + + -- 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: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..1912da8 --- /dev/null +++ b/modules/spellprobe.lua @@ -0,0 +1,177 @@ +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 + + +BNP.spellbookDurations = BNP.spellbookDurations or {} + +function BNP:RefreshSpellbookDurations() + 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: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..3432483 --- /dev/null +++ b/modules/tankmode.lua @@ -0,0 +1,164 @@ +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 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 + + 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/unknown.lua b/modules/unknown.lua new file mode 100644 index 0000000..cbf3753 --- /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("A screenshot of this output is enough. Raw data: /bnp unknown verbose") + 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..2082700 --- /dev/null +++ b/spells/paladin.lua @@ -0,0 +1,54 @@ +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, + }, +} diff --git a/spells/priest.lua b/spells/priest.lua new file mode 100644 index 0000000..46d4b49 --- /dev/null +++ b/spells/priest.lua @@ -0,0 +1,75 @@ +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", + 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", + names = { "Starshards" }, + duration = 6, + }, + { + key = "shadow_vulnerability", + names = { "Shadow Vulnerability" }, + duration = 15, + }, + { + key = "shackle_undead", + names = { "Shackle Undead" }, + duration = 50, + }, + { + key = "mind_control", + 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..1f85d0c --- /dev/null +++ b/spells/warlock.lua @@ -0,0 +1,167 @@ +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", + curseExclusive = false, -- Turtle talent allows CoA alongside one regular 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", + 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", + 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 }, + }, +} diff --git a/spells/warrior.lua b/spells/warrior.lua new file mode 100644 index 0000000..b3742a3 --- /dev/null +++ b/spells/warrior.lua @@ -0,0 +1,109 @@ +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, + }, + { + 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, + }, +}