BNP = BNP or {} -- Missing Spell / Aura Recorder -------------------------------------------- -- -- This diagnostic is deliberately dormant until the user presses New -- Recording. While active it records only the selected target, player casts, -- SuperWoW combat events and a throttled snapshot of the selected target's -- negative auras. Closing or stopping the recorder unregisters every event and -- removes its OnUpdate handler. local recorder = BNP.auraRecorder or {} BNP.auraRecorder = recorder recorder.lines = recorder.lines or {} recorder.registered = recorder.registered or {} recorder.pendingSnapshots = recorder.pendingSnapshots or {} recorder.active = false recorder.dirty = false recorder.limitHit = false recorder.charCount = recorder.charCount or 0 local CAPTURE_CHAR_LIMIT = 48000 local REPORT_CHAR_LIMIT = 62000 local CAPTURE_LINE_LIMIT = 700 local REPORT_LINE_LIMIT = 900 local AURA_SCAN_INTERVAL = 0.15 local EDIT_REFRESH_INTERVAL = 0.25 local EVENT_NAMES = { "UNIT_CASTEVENT", "RAW_COMBATLOG", "SUPERWOW_COMBAT_LOG_EVENT", "UNIT_AURA", "PLAYER_TARGET_CHANGED", "UNIT_SPELLCAST_SENT", "UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP", "UNIT_SPELLCAST_DELAYED", "UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_INTERRUPTED", "UNIT_SPELLCAST_FAILED", "UNIT_SPELLCAST_FAILED_QUIET", "UNIT_SPELLCAST_CHANNEL_START", "UNIT_SPELLCAST_CHANNEL_UPDATE", "UNIT_SPELLCAST_CHANNEL_STOP", "UNIT_SPELLCAST_RETICLE_TARGET", "UNIT_SPELLCAST_RETICLE_CLEAR", "CHAT_MSG_COMBAT_SELF_HITS", "CHAT_MSG_SPELL_SELF_DAMAGE", "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE", "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE", "CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE", "CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE", "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE", "CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE", "CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE", "CHAT_MSG_SPELL_BREAK_AURA", "CHAT_MSG_SPELL_AURA_GONE_OTHER", "CHAT_MSG_SPELL_AURA_GONE_SELF", } local SELF_COMBAT_EVENTS = { CHAT_MSG_COMBAT_SELF_HITS = true, CHAT_MSG_SPELL_SELF_DAMAGE = true, } local CLASSICAPI_SPELLCAST_EVENTS = { UNIT_SPELLCAST_SENT = true, UNIT_SPELLCAST_START = true, UNIT_SPELLCAST_STOP = true, UNIT_SPELLCAST_DELAYED = true, UNIT_SPELLCAST_SUCCEEDED = true, UNIT_SPELLCAST_INTERRUPTED = true, UNIT_SPELLCAST_FAILED = true, UNIT_SPELLCAST_FAILED_QUIET = true, UNIT_SPELLCAST_CHANNEL_START = true, UNIT_SPELLCAST_CHANNEL_UPDATE = true, UNIT_SPELLCAST_CHANNEL_STOP = true, UNIT_SPELLCAST_RETICLE_TARGET = true, UNIT_SPELLCAST_RETICLE_CLEAR = true, } local SHADOW_VULNERABILITY_IDS = { [17794] = true, [17797] = true, [17798] = true, [17799] = true, [17800] = true, } local SHADOW_BOLT_IDS = { [686] = true, [695] = true, [705] = true, [1088] = true, [1106] = true, [7641] = true, [11659] = true, [11660] = true, [11661] = true, [25307] = true, } local function CleanValue(value, maxLength) if value == nil then return "nil" end local text = tostring(value) text = string.gsub(text, "\r", "\\r") text = string.gsub(text, "\n", "\\n") text = string.gsub(text, "\t", "\\t") maxLength = maxLength or 260 if string.len(text) > maxLength then text = string.sub(text, 1, maxLength) .. "..." end return text end local function GetUnitGUID(unit) if not unit then return nil end local ok, exists, guid = pcall(UnitExists, unit) if ok and exists and guid then return guid end if UnitGUID then local guidOK, classicGUID = pcall(UnitGUID, unit) if guidOK and classicGUID then return classicGUID end end return nil end local function SpellLabel(spellID) if not spellID then return "nil/?" end if SpellInfo then local ok, name, rank = pcall(SpellInfo, spellID) if ok and name then local label = tostring(spellID) .. "/" .. CleanValue(name, 100) if rank and rank ~= "" then label = label .. " " .. CleanValue(rank, 60) end return label end end if GetSpellInfo then local ok, name, rank = pcall(GetSpellInfo, spellID) if ok and name then local label = tostring(spellID) .. "/" .. CleanValue(name, 100) if rank and rank ~= "" then label = label .. " " .. CleanValue(rank, 60) end return label end end return tostring(spellID) .. "/?" end local function RelativeTime() if not recorder.startedAt then return "+0.000" end return string.format("+%.3f", GetTime() - recorder.startedAt) end local function ClassicAPIVersionLabel() if CLASSIC_API_VERSION == nil then return "not detected" end local numeric = tonumber(CLASSIC_API_VERSION) if not numeric then return CleanValue(CLASSIC_API_VERSION) end local major = math.floor(numeric / 10000) local minor = math.floor((numeric - (major * 10000)) / 100) local patch = numeric - (major * 10000) - (minor * 100) return tostring(numeric) .. " (" .. tostring(major) .. "." .. tostring(minor) .. "." .. tostring(patch) .. ")" end local function AppendLine(text, important) text = CleanValue(text, 1400) local charLimit = important and REPORT_CHAR_LIMIT or CAPTURE_CHAR_LIMIT local lineLimit = important and REPORT_LINE_LIMIT or CAPTURE_LINE_LIMIT local nextChars = (recorder.charCount or 0) + string.len(text) + 1 if nextChars > charLimit or table.getn(recorder.lines) >= lineLimit then if not recorder.limitHit then local marker = "[CAPTURE LIMIT REACHED - recording stopped before the report became too large]" table.insert(recorder.lines, marker) recorder.charCount = (recorder.charCount or 0) + string.len(marker) + 1 recorder.limitHit = true recorder.stopRequested = true recorder.dirty = true end return false end table.insert(recorder.lines, text) recorder.charCount = nextChars recorder.dirty = true return true end local function AppendTimed(kind, text) return AppendLine("[" .. RelativeTime() .. "] " .. tostring(kind) .. " | " .. tostring(text or ""), false) end local function ShallowFields(data) if type(data) ~= "table" then return CleanValue(data) end local fields = {} local key, value for key, value in pairs(data) do local valueType = type(value) if valueType ~= "table" and valueType ~= "function" and valueType ~= "userdata" then table.insert(fields, CleanValue(key, 80) .. "=" .. CleanValue(value, 220)) end end table.sort(fields) if table.getn(fields) == 0 then return "(no scalar fields)" end return table.concat(fields, " | ") end local function IsRelatedText(raw) if not raw or raw == "" then return false end raw = tostring(raw) if recorder.traceGUID and string.find(raw, tostring(recorder.traceGUID), 1, true) then return true end if recorder.playerGUID and string.find(raw, tostring(recorder.playerGUID), 1, true) then return true end if recorder.traceName and recorder.traceName ~= "" and string.find(raw, recorder.traceName, 1, true) then return true end if recorder.playerName and recorder.playerName ~= "" and string.find(raw, recorder.playerName, 1, true) then return true end return false end local function CurrentSnapshotUnit() local targetGUID = GetUnitGUID("target") if recorder.traceGUID and targetGUID == recorder.traceGUID then return "target" end if recorder.traceGUID and UnitTokenFromGUID then local ok, token = pcall(UnitTokenFromGUID, recorder.traceGUID) if ok and token then return token end end if recorder.traceGUID then return recorder.traceGUID end if UnitName("target") then return "target" end return nil end local function QueueSnapshot(delay, reason, force) local pending = recorder.pendingSnapshots if table.getn(pending) >= 18 then return end table.insert(pending, { at = GetTime() + (delay or 0), reason = reason or "event", force = force and true or false, }) end local function HasClassicAuraAPI() return type(C_UnitAuras) == "table" and type(C_UnitAuras.GetDebuffDataByIndex) == "function" end local function IsShadowVulnerability(spellID, spellName) local numeric = tonumber(spellID) if SHADOW_VULNERABILITY_IDS[numeric] then return true end local name = string.lower(tostring(spellName or "")) return name == "shadow vulnerability" or name == "schattenverwundbarkeit" end local function IsShadowBolt(spellID, spellName) local numeric = tonumber(spellID) if SHADOW_BOLT_IDS[numeric] then return true end local name = spellName if (not name or name == "") and numeric and SpellInfo then local ok, resolved = pcall(SpellInfo, numeric) if ok then name = resolved end end name = string.lower(tostring(name or "")) return name == "shadow bolt" or name == "schattenblitz" end local function ShouldCaptureClassicAura(aura) if type(aura) ~= "table" then return true end local spellID = tonumber(aura.spellId or aura.spellID) if IsShadowVulnerability(spellID, aura.name) then return true end if aura.sourceGUID and recorder.playerGUID then return aura.sourceGUID == recorder.playerGUID end if aura.sourceUnit then return aura.sourceUnit == "player" end -- ClassicAPI may omit source data for foreign auras. Unknown ownership is not -- enough to include a normal aura; Shadow Vulnerability was allowed above. return false end local function SnapshotClassicAPIAuras(unit, reason, force) if not HasClassicAuraAPI() then return end local signatureParts = {} local auraLines = {} local count = 0 local shadowOnly = force and string.find(tostring(reason or ""), "Shadow", 1, true) local i for i = 1, 64 do local ok, aura = pcall(C_UnitAuras.GetDebuffDataByIndex, unit, i) if not ok then if force then AppendTimed( "CLASSICAPI_AURA_SNAPSHOT", "reason=" .. CleanValue(reason) .. " | unit=" .. CleanValue(unit) .. " | error=" .. CleanValue(aura) ) end return end if not aura then break end if ShouldCaptureClassicAura(aura) and (not shadowOnly or IsShadowVulnerability(aura.spellId or aura.spellID, aura.name)) then count = count + 1 local fields = ShallowFields(aura) table.insert(signatureParts, fields) local remaining = "unknown" local expiration = tonumber(aura.expirationTime) if expiration and expiration > 0 then remaining = string.format("%.3f", expiration - GetTime()) end table.insert( auraLines, " CLASSIC_AURA " .. tostring(i) .. " | remaining=" .. remaining .. " | " .. fields ) end end local signature = table.concat(signatureParts, "||") local snapshotKey = (shadowOnly and "classic-shadow:" or "classic:") .. tostring(recorder.traceGUID or unit) local previous = recorder.lastAuraSignatures and recorder.lastAuraSignatures[snapshotKey] local changed = previous ~= signature recorder.lastAuraSignatures = recorder.lastAuraSignatures or {} recorder.lastAuraSignatures[snapshotKey] = signature if recorder.baselineOnly then return end if not changed and not force then return end AppendTimed( "CLASSICAPI_AURA_SNAPSHOT", "reason=" .. CleanValue(reason) .. " | unit=" .. CleanValue(unit) .. " | guid=" .. CleanValue(recorder.traceGUID) .. " | count=" .. tostring(count) .. " | changed=" .. (changed and "1" or "0") ) if count == 0 then AppendLine(" CLASSIC_AURA none", false) else for i = 1, table.getn(auraLines) do AppendLine(auraLines[i], false) end end end local function SnapshotAuras(reason, force) local unit = CurrentSnapshotUnit() if not unit then if force then AppendTimed("AURA_SNAPSHOT", "reason=" .. CleanValue(reason) .. " | no target unit") end return end -- ClassicAPI exposes a richer AuraData table including spellId, duration, -- expirationTime and (where observed) sourceUnit/sourceGUID. Keep this -- snapshot independent from legacy UnitDebuff so a duration-only refresh is -- visible even when the vanilla aura slots themselves do not change. SnapshotClassicAPIAuras(unit, reason, force) if HasClassicAuraAPI() then return end local signatureParts = {} local auraLines = {} local count = 0 local i for i = 1, 64 do local ok, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = pcall(UnitDebuff, unit, i) if not ok then if force then AppendTimed("AURA_SNAPSHOT", "reason=" .. CleanValue(reason) .. " | UnitDebuff error=" .. CleanValue(a1)) end return end if not a1 then break end count = count + 1 local spellName = "?" local spellRank = nil if a4 and SpellInfo then local spellOK, name, rank = pcall(SpellInfo, a4) if spellOK and name then spellName = name end if spellOK then spellRank = rank end end table.insert(signatureParts, CleanValue(a1) .. ":" .. CleanValue(a2) .. ":" .. CleanValue(a3) .. ":" .. CleanValue(a4) .. ":" .. CleanValue(a5) .. ":" .. CleanValue(a6) .. ":" .. CleanValue(a7) .. ":" .. CleanValue(a8) .. ":" .. CleanValue(a9) .. ":" .. CleanValue(a10) ) table.insert(auraLines, " AURA " .. tostring(i) .. " | id=" .. CleanValue(a4) .. " | name=" .. CleanValue(spellName, 120) .. " | rank=" .. CleanValue(spellRank, 80) .. " | stacks=" .. CleanValue(a2) .. " | type=" .. CleanValue(a3) .. " | texture=" .. CleanValue(a1, 180) .. " | r5=" .. CleanValue(a5) .. " | r6=" .. CleanValue(a6) .. " | r7=" .. CleanValue(a7) .. " | r8=" .. CleanValue(a8) .. " | r9=" .. CleanValue(a9) .. " | r10=" .. CleanValue(a10) ) end local signature = table.concat(signatureParts, "||") local snapshotKey = tostring(recorder.traceGUID or unit) local previous = recorder.lastAuraSignatures and recorder.lastAuraSignatures[snapshotKey] local changed = previous ~= signature recorder.lastAuraSignatures = recorder.lastAuraSignatures or {} recorder.lastAuraSignatures[snapshotKey] = signature if recorder.baselineOnly then return end if not changed and not force then return end AppendTimed( "AURA_SNAPSHOT", "reason=" .. CleanValue(reason) .. " | unit=" .. CleanValue(unit) .. " | guid=" .. CleanValue(recorder.traceGUID) .. " | count=" .. tostring(count) .. " | changed=" .. (changed and "1" or "0") ) if count == 0 then AppendLine(" AURA none", false) else for i = 1, table.getn(auraLines) do AppendLine(auraLines[i], false) end end end local function UpdateTarget(reason) local exists, guid = UnitExists("target") local name = UnitName("target") if exists and guid then local changed = guid ~= recorder.traceGUID recorder.traceGUID = guid recorder.traceName = name or "?" if changed then AppendTimed( "TARGET", "reason=" .. CleanValue(reason) .. " | name=" .. CleanValue(recorder.traceName) .. " | guid=" .. CleanValue(guid) ) QueueSnapshot(0.03, "target changed", false) end elseif not recorder.traceGUID then AppendTimed("TARGET", "reason=" .. CleanValue(reason) .. " | no target / no SuperWoW GUID") else AppendTimed("TARGET", "reason=" .. CleanValue(reason) .. " | target cleared; keeping guid=" .. CleanValue(recorder.traceGUID)) end end local function IsRelevantCast(casterGUID, targetGUID) if recorder.playerGUID and casterGUID == recorder.playerGUID then return true end return false end local function HandleCastEvent() local casterGUID = arg1 local targetGUID = arg2 local eventType = arg3 local spellID = arg4 local duration = arg5 if not IsRelevantCast(casterGUID, targetGUID) then return end AppendTimed( "UNIT_CASTEVENT", "type=" .. CleanValue(eventType) .. " | spell=" .. SpellLabel(spellID) .. " | caster=" .. CleanValue(casterGUID) .. " | target=" .. CleanValue(targetGUID) .. " | castDuration=" .. CleanValue(duration) ) if IsShadowBolt(spellID) then QueueSnapshot(0.20, "200ms after Shadow Bolt UNIT_CASTEVENT", true) end end local function HandleRawCombatLog() local originalEvent = arg1 local raw = arg2 if not IsRelatedText(raw) then return end local lowerRaw = string.lower(tostring(raw or "")) local ownSignal = false if recorder.playerGUID and string.find(lowerRaw, string.lower(tostring(recorder.playerGUID)), 1, true) then ownSignal = true end if recorder.playerName and string.find(lowerRaw, string.lower(tostring(recorder.playerName)), 1, true) then ownSignal = true end local sharedSignal = string.find(lowerRaw, "shadow vulnerability", 1, true) or string.find(lowerRaw, "schattenverwundbarkeit", 1, true) or string.find(lowerRaw, "17794", 1, true) or string.find(lowerRaw, "17797", 1, true) or string.find(lowerRaw, "17798", 1, true) or string.find(lowerRaw, "17799", 1, true) or string.find(lowerRaw, "17800", 1, true) if not ownSignal and not sharedSignal then return end AppendTimed("RAW_COMBATLOG", "event=" .. CleanValue(originalEvent) .. " | raw=" .. CleanValue(raw, 1100)) if sharedSignal then QueueSnapshot(0.04, "after Shadow Vulnerability RAW_COMBATLOG", true) QueueSnapshot(0.30, "300ms after Shadow Vulnerability RAW_COMBATLOG", true) end end local function HandleStructuredCombatLog() local timestamp = arg1 local eventType = arg2 local sourceGUID = arg3 local sourceName = arg4 local sourceFlags = arg5 local destGUID = arg6 local destName = arg7 local destFlags = arg8 local spellID = arg9 local spellName = arg10 local spellSchool = arg11 local relevant = recorder.playerGUID and sourceGUID == recorder.playerGUID if recorder.traceGUID and destGUID == recorder.traceGUID and IsShadowVulnerability(spellID, spellName) then relevant = true end if not relevant then return end AppendTimed( "SUPERWOW_COMBAT_LOG_EVENT", "timestamp=" .. CleanValue(timestamp) .. " | event=" .. CleanValue(eventType) .. " | source=" .. CleanValue(sourceGUID) .. "/" .. CleanValue(sourceName, 100) .. "/" .. CleanValue(sourceFlags) .. " | dest=" .. CleanValue(destGUID) .. "/" .. CleanValue(destName, 100) .. "/" .. CleanValue(destFlags) .. " | spell=" .. CleanValue(spellID) .. "/" .. CleanValue(spellName, 120) .. " | school=" .. CleanValue(spellSchool) .. " | p12=" .. CleanValue(arg12) .. " | p13=" .. CleanValue(arg13) .. " | p14=" .. CleanValue(arg14) .. " | p15=" .. CleanValue(arg15) .. " | p16=" .. CleanValue(arg16) .. " | p17=" .. CleanValue(arg17) .. " | p18=" .. CleanValue(arg18) .. " | p19=" .. CleanValue(arg19) .. " | p20=" .. CleanValue(arg20) ) if IsShadowVulnerability(spellID, spellName) then QueueSnapshot(0.03, "after structured Shadow Vulnerability", true) QueueSnapshot(0.25, "250ms after structured Shadow Vulnerability", true) end end local function HandleUnitAura() local unit = arg1 if unit ~= "target" then return end local guid = GetUnitGUID(unit) AppendTimed("UNIT_AURA", "unit=" .. CleanValue(unit) .. " | guid=" .. CleanValue(guid)) QueueSnapshot(0.02, "after UNIT_AURA", false) end local function HandleClassicAPISpellcast(eventName) local unit = arg1 local targetName = nil local castGUID, spellID, spellName, rank if eventName == "UNIT_SPELLCAST_SENT" then targetName = arg2 castGUID = arg3 spellID = arg4 spellName = arg5 rank = arg6 else castGUID = arg2 spellID = arg3 spellName = arg4 rank = arg5 end local unitGUID = GetUnitGUID(unit) local relevant = unit == "player" if recorder.traceGUID and (unitGUID == recorder.traceGUID or unit == recorder.traceGUID) then relevant = true end if targetName and recorder.traceName and targetName == recorder.traceName then relevant = true end if not relevant then return end AppendTimed( "CLASSICAPI_" .. tostring(eventName), "unit=" .. CleanValue(unit) .. " | unitGUID=" .. CleanValue(unitGUID) .. " | castGUID=" .. CleanValue(castGUID) .. " | spell=" .. CleanValue(spellID) .. "/" .. CleanValue(spellName, 120) .. " | rank=" .. CleanValue(rank, 80) .. " | targetName=" .. CleanValue(targetName, 120) ) -- UNIT_CASTEVENT supplies the single post-cast control snapshot. Keeping the -- modern spellcast events as text avoids three duplicate aura dumps per cast. end local function HandleCombatText(eventName) local raw = arg1 if not SELF_COMBAT_EVENTS[eventName] then if not IsRelatedText(raw) then return end local lower = string.lower(tostring(raw or "")) local ownSignal = string.find(lower, "your ", 1, true) if recorder.playerName and string.find(lower, string.lower(tostring(recorder.playerName)), 1, true) then ownSignal = true end local sharedSignal = string.find(lower, "shadow vulnerability", 1, true) or string.find(lower, "schattenverwundbarkeit", 1, true) if not ownSignal and not sharedSignal then return end end AppendTimed( eventName, "a1=" .. CleanValue(raw, 1050) .. " | a2=" .. CleanValue(arg2, 160) .. " | a3=" .. CleanValue(arg3, 160) .. " | a4=" .. CleanValue(arg4, 160) ) end local eventFrame = recorder.eventFrame or CreateFrame("Frame", "BNPAuraRecorderEventFrame") recorder.eventFrame = eventFrame eventFrame:SetScript("OnEvent", function() if not recorder.active then return end local eventName = event if eventName == "UNIT_CASTEVENT" then HandleCastEvent() elseif eventName == "RAW_COMBATLOG" then HandleRawCombatLog() elseif eventName == "SUPERWOW_COMBAT_LOG_EVENT" then HandleStructuredCombatLog() elseif eventName == "UNIT_AURA" then HandleUnitAura() elseif eventName == "PLAYER_TARGET_CHANGED" then UpdateTarget("PLAYER_TARGET_CHANGED") elseif CLASSICAPI_SPELLCAST_EVENTS[eventName] then HandleClassicAPISpellcast(eventName) else HandleCombatText(eventName) end end) local function SafeRegister(eventName) if type(C_EventUtils) == "table" and type(C_EventUtils.IsEventValid) == "function" then local validOK, valid = pcall(C_EventUtils.IsEventValid, eventName) if validOK and not valid then return false end end local ok = pcall(eventFrame.RegisterEvent, eventFrame, eventName) if ok then recorder.registered[eventName] = true end return ok end local function UnregisterRecorderEvents() pcall(eventFrame.UnregisterAllEvents, eventFrame) recorder.registered = {} end local function StatusText() local target = tostring(recorder.traceName or "no target") local guid = tostring(recorder.traceGUID or "no GUID") local lineCount = table.getn(recorder.lines) if recorder.copyNotice then return recorder.copyNotice end if recorder.active then return "RECORDING | Target: " .. target .. " | " .. guid .. " | " .. tostring(lineCount) .. " lines" end if recorder.limitHit then return "STOPPED (size limit) | " .. tostring(lineCount) .. " lines | Copy the report" end return "STOPPED | Target: " .. target .. " | " .. tostring(lineCount) .. " lines | Copy the report" end local function RefreshRecorderText(force) if not recorder.frame or not recorder.editBox then return end if not force and not recorder.dirty then return end local report = table.concat(recorder.lines, "\n") recorder.editBox:SetText(report) local visualLines = 0 local i for i = 1, table.getn(recorder.lines) do local length = string.len(recorder.lines[i] or "") visualLines = visualLines + math.max(1, math.ceil(length / 96)) end recorder.editBox:SetHeight(math.max(330, (visualLines * 13) + 24)) if recorder.scrollFrame and recorder.scrollFrame.UpdateScrollChildRect then recorder.scrollFrame:UpdateScrollChildRect() end if recorder.status then recorder.status:SetText(StatusText()) if recorder.copyNotice then recorder.status:SetTextColor(0.2, 1, 0.35) elseif recorder.active then recorder.status:SetTextColor(0.2, 1, 0.35) elseif recorder.limitHit then recorder.status:SetTextColor(1, 0.35, 0.2) else recorder.status:SetTextColor(1, 0.82, 0) end end if recorder.stopButton then if recorder.active then recorder.stopButton:Enable() else recorder.stopButton:Disable() end end recorder.dirty = false end local function DumpScalarTable(label, data, important) if type(data) ~= "table" then AppendLine(label .. " | " .. CleanValue(data), important) return end AppendLine(label .. " | " .. ShallowFields(data), important) end local function DumpInternalDiagnostics() AppendLine("", true) AppendLine("=== INTERNAL BNP DIAGNOSTICS ===", true) AppendLine("Target | name=" .. CleanValue(recorder.traceName) .. " | guid=" .. CleanValue(recorder.traceGUID), true) local guid = recorder.traceGUID local cache = guid and BNP.guidAuras and BNP.guidAuras[guid] AppendLine("--- GUID AURA CACHE ---", true) if not cache then AppendLine("CACHE | empty", true) else local cacheKey, aura local cacheCount = 0 for cacheKey, aura in pairs(cache) do if type(aura) == "table" then cacheCount = cacheCount + 1 AppendLine("CACHE | key=" .. CleanValue(cacheKey) .. " | " .. ShallowFields(aura), true) end end if cacheCount == 0 then AppendLine("CACHE | no aura tables | " .. ShallowFields(cache), true) end end AppendLine("--- SHADOW VULNERABILITY STATE ---", true) local svState = guid and BNP.svStateByGUID and BNP.svStateByGUID[guid] if svState then DumpScalarTable("SV_STATE", svState, true) else AppendLine("SV_STATE | none", true) end local rendered = false local plate for plate in pairs(BNP.plates or {}) do if plate and plate:IsShown() and plate.GetName and plate:GetName(1) == guid then local container = plate.BNPAuraContainer if container and container.icons then local iconIndex for iconIndex = 1, table.getn(container.icons) do local icon = container.icons[iconIndex] if icon and icon.BNPSVRenderedExpires then AppendLine( "SV_RENDER | icon=" .. tostring(iconIndex) .. " | timerText=" .. CleanValue(icon.timer and icon.timer:GetText()) .. " | renderedExpires=" .. CleanValue(icon.BNPSVRenderedExpires) .. " | revision=" .. CleanValue(icon.BNPSVRenderRevision), true ) rendered = true end end end end end if not rendered then AppendLine("SV_RENDER | no visible SV icon", true) end AppendLine("--- PENDING AURAS ---", true) local pending = guid and BNP.pendingAuras and BNP.pendingAuras[guid] if not pending then AppendLine("PENDING | none", true) else local pendingKey, pendingAura for pendingKey, pendingAura in pairs(pending) do AppendLine("PENDING | key=" .. CleanValue(pendingKey) .. " | " .. ShallowFields(pendingAura), true) end end AppendLine("--- LAST TRACKED AURA TRANSITIONS ---", true) local trace = BNP.raidTrace or {} local start = math.max(1, table.getn(trace) - 29) local i if table.getn(trace) == 0 then AppendLine("RAID_TRACE | none", true) end for i = start, table.getn(trace) do local traceEntry = trace[i] AppendLine("RAID_TRACE " .. tostring(i) .. " | " .. ShallowFields(traceEntry), true) end AppendLine("--- UNKNOWN SPELL / AURA COLLECTOR ---", true) local unknown = BNP.unknownBeta if not unknown or table.getn(unknown.order or {}) == 0 then AppendLine("UNKNOWN | no indexed entries", true) else for i = 1, table.getn(unknown.order) do local unknownKey = unknown.order[i] local unknownEntry = unknown.entries and unknown.entries[unknownKey] if unknownEntry then AppendLine("UNKNOWN | key=" .. CleanValue(unknownKey) .. " | " .. ShallowFields(unknownEntry), true) end end end if unknown and unknown.combat then local effectKey, effect for effectKey, effect in pairs(unknown.combat) do AppendLine("UNKNOWN_EFFECT | key=" .. CleanValue(effectKey, 220) .. " | " .. ShallowFields(effect), true) end end AppendLine("--- DIRECT PLAYER CAST EVENTS ---", true) local directCount = 0 local directKey, directEntry for directKey, directEntry in pairs(BNP.unknownDirectEvents or {}) do directCount = directCount + 1 AppendLine("DIRECT | key=" .. CleanValue(directKey) .. " | " .. ShallowFields(directEntry), true) end if directCount == 0 then AppendLine("DIRECT | none", true) end AppendLine("--- SPELLDB SHADOW AUDIT ---", true) local spellDB = BNP.SpellDBShadow if not spellDB then AppendLine("SPELLDB | unavailable", true) else DumpScalarTable("SPELLDB_STATS", spellDB.stats, true) local recent = spellDB.recent or {} for i = 1, table.getn(recent) do AppendLine("SPELLDB_RECENT " .. tostring(i) .. " | " .. ShallowFields(recent[i]), true) end end AppendLine("--- SHADOW VULNERABILITY PROBE ---", true) local probe = BNP.svProbe if not probe then AppendLine("SV_PROBE | unavailable", true) else if probe.last then DumpScalarTable("SV_PROBE_LAST", probe.last, true) else AppendLine("SV_PROBE_LAST | none", true) end local probeEvents = probe.events or {} for i = 1, table.getn(probeEvents) do AppendLine("SV_PROBE_EVENT " .. tostring(i) .. " | " .. ShallowFields(probeEvents[i]), true) end local rawEvents = probe.raw or {} for i = 1, table.getn(rawEvents) do AppendLine("SV_PROBE_RAW " .. tostring(i) .. " | " .. ShallowFields(rawEvents[i]), true) end end end local updateFrame = recorder.updateFrame or CreateFrame("Frame", "BNPAuraRecorderUpdateFrame") recorder.updateFrame = updateFrame local function RecorderOnUpdate() recorder.scanElapsed = (recorder.scanElapsed or 0) + arg1 recorder.editElapsed = (recorder.editElapsed or 0) + arg1 if recorder.scanElapsed >= AURA_SCAN_INTERVAL then recorder.scanElapsed = 0 SnapshotAuras("poll change", false) local now = GetTime() local i = 1 while i <= table.getn(recorder.pendingSnapshots) do local pending = recorder.pendingSnapshots[i] if pending and now >= (pending.at or 0) then table.remove(recorder.pendingSnapshots, i) SnapshotAuras(pending.reason, pending.force) else i = i + 1 end end end if recorder.editElapsed >= EDIT_REFRESH_INTERVAL then recorder.editElapsed = 0 RefreshRecorderText(false) end if recorder.stopRequested then BNP:StopAuraRecorder("capture limit") end end function BNP:StartAuraRecorder() -- New Recording is also a clean restart when a capture is already running. if recorder.active then recorder.active = false UnregisterRecorderEvents() updateFrame:SetScript("OnUpdate", nil) end UnregisterRecorderEvents() recorder.lines = {} recorder.charCount = 0 recorder.limitHit = false recorder.stopRequested = false recorder.copyNotice = nil recorder.pendingSnapshots = {} recorder.lastAuraSignatures = {} recorder.startedAt = GetTime() recorder.stoppedAt = nil recorder.scanElapsed = 0 recorder.editElapsed = 0 recorder.playerGUID = GetUnitGUID("player") recorder.playerName = UnitName("player") or "?" recorder.traceGUID = nil recorder.traceName = nil recorder.active = true local registeredCount = 0 local i for i = 1, table.getn(EVENT_NAMES) do if SafeRegister(EVENT_NAMES[i]) then registeredCount = registeredCount + 1 end end local classicSpellcastCount = 0 local classicEventName for classicEventName in pairs(CLASSICAPI_SPELLCAST_EVENTS) do if recorder.registered[classicEventName] then classicSpellcastCount = classicSpellcastCount + 1 end end AppendLine("BNP_MISSING_SPELL_AURA_REPORT v1", true) AppendLine("Addon | version=" .. CleanValue(BNP.version), true) AppendLine( "SuperWoW | string=" .. CleanValue(SUPERWOW_STRING) .. " | version=" .. CleanValue(SUPERWOW_VERSION) .. " | CombatLogAdd=" .. (CombatLogAdd and "yes" or "no") .. " | SpellInfo=" .. (SpellInfo and "yes" or "no"), true ) AppendLine( "ClassicAPI | version=" .. ClassicAPIVersionLabel() .. " | C_UnitAuras=" .. (HasClassicAuraAPI() and "yes" or "no") .. " | UnitGUID=" .. (UnitGUID and "yes" or "no") .. " | UnitTokenFromGUID=" .. (UnitTokenFromGUID and "yes" or "no") .. " | CopyToClipboard=" .. (CopyToClipboard and "yes" or "no") .. " | spellcastEvents=" .. tostring(classicSpellcastCount), true ) if GetBuildInfo then local version, build, buildDate, interfaceVersion = GetBuildInfo() AppendLine( "Client | version=" .. CleanValue(version) .. " | build=" .. CleanValue(build) .. " | date=" .. CleanValue(buildDate) .. " | interface=" .. CleanValue(interfaceVersion) .. " | locale=" .. CleanValue(GetLocale and GetLocale()), true ) end local _, class = UnitClass("player") AppendLine( "Player | name=" .. CleanValue(recorder.playerName) .. " | class=" .. CleanValue(class) .. " | guid=" .. CleanValue(recorder.playerGUID), true ) AppendLine( "Sources | registered=" .. tostring(registeredCount) .. "/" .. tostring(table.getn(EVENT_NAMES)) .. " | RAW_COMBATLOG=" .. (recorder.registered.RAW_COMBATLOG and "yes" or "no") .. " | STRUCTURED=" .. (recorder.registered.SUPERWOW_COMBAT_LOG_EVENT and "yes" or "no") .. " | CLASSICAPI_AURAS=" .. (HasClassicAuraAPI() and "yes" or "no"), true ) AppendLine("Aura filter | own auras + Shadow Vulnerability from any Warlock", true) AppendLine("Test | apply aura, refresh while active, then let it expire or remove it", true) AppendLine("=== LIVE RECORDING ===", true) UpdateTarget("recording started") -- Establish a silent baseline. Existing foreign auras must not appear just -- because the user pressed Start Recording; only later changes are logged. recorder.baselineOnly = true SnapshotAuras("baseline", false) recorder.baselineOnly = false updateFrame:SetScript("OnUpdate", RecorderOnUpdate) recorder.dirty = true RefreshRecorderText(true) end function BNP:StopAuraRecorder(reason) if not recorder.active then RefreshRecorderText(true) return end recorder.stopRequested = false SnapshotAuras("final before stop", true) recorder.active = false recorder.stoppedAt = GetTime() UnregisterRecorderEvents() updateFrame:SetScript("OnUpdate", nil) DumpInternalDiagnostics() AppendLine("=== END REPORT | reason=" .. CleanValue(reason or "user") .. " | elapsed=" .. string.format("%.3f", recorder.stoppedAt - (recorder.startedAt or recorder.stoppedAt)) .. "s ===", true) recorder.dirty = true RefreshRecorderText(true) end function BNP:ClearAuraRecorder() if recorder.active then self:StopAuraRecorder("cleared") end recorder.lines = {} recorder.charCount = 0 recorder.limitHit = false recorder.stopRequested = false recorder.copyNotice = nil recorder.pendingSnapshots = {} recorder.lastAuraSignatures = {} recorder.traceGUID = nil recorder.traceName = nil recorder.dirty = true RefreshRecorderText(true) end function BNP:SelectAllAuraRecorderText() if not recorder.editBox then return end RefreshRecorderText(true) recorder.editBox:SetFocus() recorder.editBox:HighlightText() end function BNP:CopyAuraRecorderText() RefreshRecorderText(true) local report = table.concat(recorder.lines, "\n") if CopyToClipboard then local ok = pcall(CopyToClipboard, report) if ok then recorder.copyNotice = "COPIED TO CLIPBOARD | Paste the report with Ctrl+V" recorder.dirty = true RefreshRecorderText(true) return true end end -- Older ClassicAPI builds and plain 1.12 clients have no direct clipboard -- function. Keep the normal edit-box path as a complete fallback. self:SelectAllAuraRecorderText() recorder.copyNotice = "TEXT SELECTED | Press Ctrl+C, then paste the report" recorder.dirty = true RefreshRecorderText(true) recorder.editBox:SetFocus() recorder.editBox:HighlightText() return false end function BNP:CreateAuraRecorderWindow() if recorder.frame then return recorder.frame end local frame = CreateFrame("Frame", "BNPAuraRecorderFrame", UIParent) frame:SetWidth(760) frame:SetHeight(560) frame:SetPoint("CENTER", UIParent, "CENTER", 0, 20) frame:SetFrameStrata("DIALOG") frame:SetBackdrop({ bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", tile = true, tileSize = 32, edgeSize = 32, insets = { left = 11, right = 12, top = 12, bottom = 11 } }) frame:SetMovable(true) frame:EnableMouse(true) frame:RegisterForDrag("LeftButton") frame:SetScript("OnDragStart", function() this:StartMoving() end) frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) frame:SetScript("OnHide", function() if recorder.active then BNP:StopAuraRecorder("window closed") end end) frame:Hide() recorder.frame = frame local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") title:SetPoint("TOP", frame, "TOP", 0, -18) title:SetText("Missing Spell / Aura Recorder") local instructions = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") instructions:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -46) instructions:SetWidth(706) instructions:SetJustifyH("LEFT") if CopyToClipboard then instructions:SetText("1. Select the affected unit. 2. Start Recording. 3. Apply the aura, refresh it while active, then let it expire or remove it. 4. Stop. 5. Copy Report and paste it.") else instructions:SetText("1. Select the affected unit. 2. Start Recording. 3. Apply the aura, refresh it while active, then let it expire or remove it. 4. Stop. 5. Select All, press Ctrl+C and paste it.") end local status = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") status:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -86) status:SetWidth(706) status:SetJustifyH("LEFT") recorder.status = status local startButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") startButton:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -112) startButton:SetWidth(126) startButton:SetHeight(24) startButton:SetText("Start Recording") startButton:SetScript("OnClick", function() BNP:StartAuraRecorder() end) recorder.startButton = startButton local stopButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") stopButton:SetPoint("LEFT", startButton, "RIGHT", 8, 0) stopButton:SetWidth(82) stopButton:SetHeight(24) stopButton:SetText("Stop") stopButton:SetScript("OnClick", function() BNP:StopAuraRecorder("user") end) recorder.stopButton = stopButton local clearButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") clearButton:SetPoint("LEFT", stopButton, "RIGHT", 8, 0) clearButton:SetWidth(82) clearButton:SetHeight(24) clearButton:SetText("Clear") clearButton:SetScript("OnClick", function() BNP:ClearAuraRecorder() end) local selectButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") selectButton:SetPoint("LEFT", clearButton, "RIGHT", 8, 0) selectButton:SetWidth(108) selectButton:SetHeight(24) selectButton:SetText(CopyToClipboard and "Copy Report" or "Select All") selectButton:SetScript("OnClick", function() BNP:CopyAuraRecorderText() end) local copyHint = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") copyHint:SetPoint("LEFT", selectButton, "RIGHT", 10, 0) copyHint:SetText(CopyToClipboard and "then paste" or "then Ctrl+C") local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton") close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -5, -5) local textBG = CreateFrame("Frame", nil, frame) textBG:SetPoint("TOPLEFT", frame, "TOPLEFT", 24, -146) textBG:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -24, 48) textBG:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16, edgeSize = 12, insets = { left = 3, right = 3, top = 3, bottom = 3 } }) textBG:SetBackdropColor(0.015, 0.015, 0.015, 0.95) textBG:SetBackdropBorderColor(0.35, 0.35, 0.35, 1) local scroll = CreateFrame("ScrollFrame", "BNPAuraRecorderScrollFrame", textBG, "UIPanelScrollFrameTemplate") scroll:SetPoint("TOPLEFT", textBG, "TOPLEFT", 8, -8) scroll:SetPoint("BOTTOMRIGHT", textBG, "BOTTOMRIGHT", -28, 8) recorder.scrollFrame = scroll local edit = CreateFrame("EditBox", "BNPAuraRecorderEditBox", scroll) edit:SetPoint("TOPLEFT", scroll, "TOPLEFT", 0, 0) edit:SetWidth(676) edit:SetHeight(330) edit:SetMultiLine(true) edit:SetAutoFocus(false) edit:SetFontObject(ChatFontNormal) edit:SetTextColor(0.92, 0.92, 0.92) edit:SetMaxLetters(65000) edit:EnableMouse(true) edit:SetScript("OnEscapePressed", function() this:ClearFocus() end) edit:SetScript("OnTextChanged", function() if recorder.scrollFrame and recorder.scrollFrame.UpdateScrollChildRect then recorder.scrollFrame:UpdateScrollChildRect() end end) scroll:SetScrollChild(edit) recorder.editBox = edit local footer = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") footer:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 26, 24) footer:SetWidth(706) footer:SetJustifyH("LEFT") footer:SetText("The recorder changes no tracking settings. It is completely inactive after Stop or Close.") RefreshRecorderText(true) return frame end function BNP:OpenAuraRecorder() local function OpenFullRecorder() local frame = BNP:CreateAuraRecorderWindow() frame:Show() RefreshRecorderText(true) end local ok = pcall(OpenFullRecorder) if not ok and self.OpenEmergencyAuraRecorder then self:OpenEmergencyAuraRecorder() end end