mirror of
https://github.com/NovaElysium/CawDPSMeter.git
synced 2026-11-10 20:17:34 +00:00
With the DPSLog Community Edition installed, a native COMBAT_LOG_EVENT_UNFILTERED fired by another addon or client mod was read as an "unsupported header" and, if it arrived during the start-up probe, dropped the whole session to combat text so DPSLog did nothing (community reports from Icekronik and Taroven). Caw now takes DPSLog events only through its DpsLogPopEvent polling channel and ignores unrelated native combat-log events; older DLLs without polling still use the native event. The one-time rejected-event chat warning is removed; counts remain under /cawinput. Adds a regression test for both producer paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6497 lines
319 KiB
Lua
6497 lines
319 KiB
Lua
-- Caw DPS Meter v1.1.8
|
|
-- RavenCraft/Octo / WoW 1.12 + SuperWoW/SuperAPI
|
|
-- Lua 5.0 compatible. RAW_COMBATLOG based damage + utility meter.
|
|
|
|
CAW_DPS_METER = CAW_DPS_METER or {}
|
|
local D = CAW_DPS_METER
|
|
D.version = "1.1.8"
|
|
function D.parserEnabled()
|
|
return not CawDPSMeterCharDB or CawDPSMeterCharDB.parserEnabled~=false
|
|
end
|
|
function D.combatSyncEnabled()
|
|
return D.parserEnabled() and (not CawDPSMeterCharDB or CawDPSMeterCharDB.combatSyncEnabled~=false)
|
|
end
|
|
D.parserEvents={RAW_COMBATLOG=true,UNIT_CASTEVENT=true,CHAT_MSG_COMBAT_FRIENDLY_DEATH=true,
|
|
PLAYER_REGEN_DISABLED=true,PLAYER_REGEN_ENABLED=true,UNIT_INVENTORY_CHANGED=true,PLAYER_AURAS_CHANGED=true}
|
|
D.inCombat = false
|
|
D.startTime = 0
|
|
D.fightStartedAt = nil
|
|
D.fightStartClock = nil
|
|
-- Keep the wall-clock label separate from GetTime-based fight duration.
|
|
-- Adjust the original wall-clock anchor when a sync snapshot reveals an
|
|
-- earlier pull, instead of recording receipt time or combat-end time.
|
|
function D.setFightStartTime(start)
|
|
if D.fightStartedAt and D.fightStartClock then
|
|
D.fightStartedAt=D.fightStartedAt+start-D.fightStartClock
|
|
elseif type(time)=="function" then
|
|
D.fightStartedAt=time()-(GetTime()-start)
|
|
end
|
|
D.startTime=start; D.fightStartClock=start
|
|
end
|
|
D.lastDuration = 0
|
|
D.rawTotal = 0
|
|
D.parsedTotal = 0
|
|
D.lastParsed = "none"
|
|
D.actors = {}
|
|
D.guidToActor = {}
|
|
D.petOwner = {}
|
|
D.summonActors = {}
|
|
D.history = {}
|
|
D.maxHistory = 10
|
|
D.segment = "current"
|
|
D.segmentIndex = 0
|
|
D.fightHistory = {}
|
|
D.currentEnemyDamage = {}
|
|
D.deadEnemies = {}
|
|
D.currentEnemyNames = {}
|
|
D.currentEnemyBestGuid = nil
|
|
D.currentEnemyBestDamage = 0
|
|
D.currentFightName = "Current"
|
|
D.overallSegment = {actors={}, duration=0, fights=0}
|
|
D.mode = "damage"
|
|
D.scrollOffset = 0
|
|
D.rawUnknown = {}
|
|
D.rawUnknownCount = 0
|
|
D.rawDebugEnabled = false
|
|
D.rawDebugLines = {}
|
|
D.rawDebugCount = 0
|
|
D.threatDebugEnabled = false
|
|
D.threatDebugLines = {}
|
|
D.threatDebugCount = 0
|
|
D.threatDebugLastTarget = ""
|
|
D.threatDebugNextSnapshot = 0
|
|
D.utilityUnknown = {}
|
|
D.utilityUnknownCount = 0
|
|
D.ignoredOutsiders = 0
|
|
D.globalUtility = {buffs={}, debuffsReceived={}, debuffsCast={}, cc={}, ccBreaks={}, interrupts={}, dispels={}}
|
|
D.recentAuraCasts = {}
|
|
D.recentAuraOrigins = {}
|
|
D.recentDirectRosterAuraCasts = {}
|
|
D.activeAuraSources = {}
|
|
D.activeRosterBuffs = {}
|
|
D.weaponBuffState = {main=nil,off=nil}
|
|
D.pendingSelfTotem = nil
|
|
D.pendingItemSummons = {}
|
|
D.activeCC = {}
|
|
D.lastCCDamage = nil
|
|
D.ccDamageByTarget = {}
|
|
D.lastCCBreak = nil
|
|
D.lastCCLanded = nil
|
|
D.lastCCCastEvent = nil
|
|
D.lastCCSpellInfo = nil
|
|
D.lastCCSegmentOpen = nil
|
|
D.lastIncomingDamage = {}
|
|
D.lastDeathStamp = {}
|
|
D.lastDeathRaw = nil
|
|
D.lastDeathRecord = nil
|
|
D.lastSelfIncomingDamage = nil
|
|
D.lastRosterCombatActivity = 0
|
|
D.localPlayerDead = false
|
|
D.deadSyncLastReceived = 0
|
|
D.deadSyncNextRequest = 0
|
|
D.combatStateCheckAt = 0
|
|
D.outOfCombatSince = 0
|
|
D.weaponBuffKeywords = {
|
|
"Poison",
|
|
"Rockbiter",
|
|
"Flametongue",
|
|
"Frostbrand",
|
|
"Windfury",
|
|
"Shadow Oil",
|
|
"Wizard Oil",
|
|
"Mana Oil",
|
|
"Sharpening Stone",
|
|
"Weightstone"
|
|
}
|
|
|
|
D.getWeaponBuffName = function(slot,label)
|
|
if not CreateFrame then return nil end
|
|
if not D.weaponTooltip then
|
|
D.weaponTooltip=CreateFrame("GameTooltip","CawDPSMeterWeaponTooltip",UIParent,"GameTooltipTemplate")
|
|
if D.weaponTooltip.SetOwner then D.weaponTooltip:SetOwner(UIParent,"ANCHOR_NONE") end
|
|
end
|
|
|
|
local tt=D.weaponTooltip
|
|
if tt.ClearLines then tt:ClearLines() end
|
|
if not tt.SetInventoryItem then return nil end
|
|
local ok=pcall(tt.SetInventoryItem,tt,"player",slot)
|
|
if not ok then return nil end
|
|
|
|
local i=1
|
|
local n=tt.NumLines and tt:NumLines() or 0
|
|
while i<=n do
|
|
local fs=getglobal and getglobal("CawDPSMeterWeaponTooltipTextLeft"..tostring(i)) or nil
|
|
local line=fs and fs.GetText and fs:GetText() or nil
|
|
if line and line~="" then
|
|
local k=1
|
|
while k<=table.getn(D.weaponBuffKeywords) do
|
|
if string.find(line,D.weaponBuffKeywords[k],1,true) then
|
|
local name=line
|
|
local _,_,trim=string.find(name,"^(.-) %(")
|
|
if trim and trim~="" then name=trim end
|
|
return label..": "..name
|
|
end
|
|
k=k+1
|
|
end
|
|
end
|
|
i=i+1
|
|
end
|
|
return nil
|
|
end
|
|
|
|
D.scanWeaponBuffs = function()
|
|
if not D.parserEnabled() then return end
|
|
if not GetWeaponEnchantInfo then return end
|
|
|
|
-- Remove only weapon entries; ordinary aura-scan entries stay untouched.
|
|
local key,b
|
|
local remove={}
|
|
for key,b in D.activeRosterBuffs do
|
|
if b and b.weaponBuff then table.insert(remove,key) end
|
|
end
|
|
local i=1
|
|
while i<=table.getn(remove) do
|
|
D.activeRosterBuffs[remove[i]]=nil
|
|
i=i+1
|
|
end
|
|
|
|
-- Own player: preserve Vanilla/SuperWoW's original six-value form because
|
|
-- it contains remaining duration/charges rather than enchant names.
|
|
local ok,hasMain,mainExp,mainCharges,hasOff,offExp,offCharges=pcall(GetWeaponEnchantInfo)
|
|
if ok then
|
|
local mainName=nil
|
|
local offName=nil
|
|
if hasMain then mainName=D.getWeaponBuffName(16,"Main Hand") or "Main Hand: Weapon Enchant" end
|
|
if hasOff then offName=D.getWeaponBuffName(17,"Off Hand") or "Off Hand: Weapon Enchant" end
|
|
D.weaponBuffState.main=mainName
|
|
D.weaponBuffState.off=offName
|
|
if mainName then
|
|
D.activeRosterBuffs["player|"..mainName]={target="player",spell=mainName,weaponBuff=true,weaponSlot="main",unit="player"}
|
|
end
|
|
if offName then
|
|
D.activeRosterBuffs["player|"..offName]={target="player",spell=offName,weaponBuff=true,weaponSlot="off",unit="player"}
|
|
end
|
|
end
|
|
|
|
-- SuperWoW extension: GetWeaponEnchantInfo(friendlyUnit) returns the
|
|
-- temporary enchant NAMES for that unit's main hand and off hand.
|
|
-- Scan only the active group roster; no name heuristics or proc inference.
|
|
local raidCount=GetNumRaidMembers and GetNumRaidMembers() or 0
|
|
local maxCount=raidCount
|
|
local prefix="raid"
|
|
if raidCount<=0 then
|
|
maxCount=GetNumPartyMembers and GetNumPartyMembers() or 0
|
|
prefix="party"
|
|
end
|
|
|
|
i=1
|
|
while i<=maxCount do
|
|
local unit=prefix..tostring(i)
|
|
local exists,guid=UnitExists(unit)
|
|
if exists and guid then
|
|
local wok,mainEnchant,offEnchant=pcall(GetWeaponEnchantInfo,unit)
|
|
if wok then
|
|
if mainEnchant and mainEnchant~="" then
|
|
local mainName="Main Hand: "..tostring(mainEnchant)
|
|
D.activeRosterBuffs[tostring(guid).."|"..mainName]={target=guid,spell=mainName,weaponBuff=true,weaponSlot="main",unit=unit}
|
|
end
|
|
if offEnchant and offEnchant~="" then
|
|
local offName="Off Hand: "..tostring(offEnchant)
|
|
D.activeRosterBuffs[tostring(guid).."|"..offName]={target=guid,spell=offName,weaponBuff=true,weaponSlot="off",unit=unit}
|
|
end
|
|
end
|
|
end
|
|
i=i+1
|
|
end
|
|
end
|
|
|
|
D.buffSpellNames = {
|
|
[19740]="Blessing of Might",
|
|
[20217]="Blessing of Kings",
|
|
[1038]="Blessing of Salvation",
|
|
[20911]="Blessing of Sanctuary",
|
|
[19742]="Blessing of Wisdom",
|
|
[19746]="Concentration Aura",
|
|
[465]="Devotion Aura",
|
|
[7294]="Retribution Aura",
|
|
[19876]="Shadow Resistance Aura",
|
|
[19888]="Frost Resistance Aura",
|
|
[19891]="Fire Resistance Aura",
|
|
[1243]="Power Word: Fortitude",
|
|
[21562]="Prayer of Fortitude",
|
|
[14752]="Divine Spirit",
|
|
[27681]="Prayer of Spirit",
|
|
[976]="Shadow Protection",
|
|
[27683]="Prayer of Shadow Protection",
|
|
[1459]="Arcane Intellect",
|
|
[23028]="Arcane Brilliance",
|
|
[1126]="Mark of the Wild",
|
|
[21849]="Gift of the Wild",
|
|
[467]="Thorns"
|
|
}
|
|
|
|
D.playerBuffScanState = {}
|
|
|
|
D.getUnitBuffName = function(unit,index,spellId)
|
|
if spellId and D.buffSpellNames[spellId] then return D.buffSpellNames[spellId] end
|
|
|
|
-- DPSLog/compatible extensions expose names directly. Resolve each new
|
|
-- spell ID once without constructing a tooltip during the opening scan.
|
|
if type(spellId)=="number" and type(GetSpellInfo)=="function" then
|
|
local ok,name=pcall(GetSpellInfo,spellId)
|
|
if ok and type(name)=="string" and name~="" then
|
|
D.buffSpellNames[spellId]=name
|
|
return name
|
|
end
|
|
end
|
|
|
|
-- RavenCraft's UnitBuff exposes the spell ID as return #3 but not the
|
|
-- localized aura name. Read the first line of Blizzard's buff tooltip so
|
|
-- item buffs, Aspects, Paladin auras and other unknown spell IDs do not
|
|
-- require a hardcoded lookup table.
|
|
if not CreateFrame then return nil end
|
|
if not D.buffScanTooltip then
|
|
D.buffScanTooltip=CreateFrame("GameTooltip","CawDPSMeterBuffScanTooltip",UIParent,"GameTooltipTemplate")
|
|
if D.buffScanTooltip.SetOwner then D.buffScanTooltip:SetOwner(UIParent,"ANCHOR_NONE") end
|
|
end
|
|
|
|
local tt=D.buffScanTooltip
|
|
if not tt.SetUnitBuff then return nil end
|
|
if tt.ClearLines then tt:ClearLines() end
|
|
local ok=pcall(tt.SetUnitBuff,tt,unit,index)
|
|
if not ok then return nil end
|
|
|
|
local fs=getglobal and getglobal("CawDPSMeterBuffScanTooltipTextLeft1") or nil
|
|
local name=fs and fs.GetText and fs:GetText() or nil
|
|
if name and name~="" then
|
|
if spellId then D.buffSpellNames[spellId]=name end
|
|
return name
|
|
end
|
|
return nil
|
|
end
|
|
|
|
D.scanUnitBuffs = function(unit,targetKey,current)
|
|
if not D.parserEnabled() then return end
|
|
if not UnitBuff or not unit or not targetKey then return end
|
|
local observed={}
|
|
local i
|
|
for i=1,32 do
|
|
local ok,texture,count,spellId=pcall(UnitBuff,unit,i)
|
|
if not ok then return false end
|
|
if not texture then break end
|
|
|
|
local name=D.getUnitBuffName(unit,i,spellId)
|
|
if not name or name=="" then return false end
|
|
if name and name~="" then
|
|
observed[name]=true
|
|
end
|
|
end
|
|
local key,b
|
|
for key,b in D.activeRosterBuffs do
|
|
if b.target==targetKey and not b.weaponBuff and not observed[b.spell] then D.activeRosterBuffs[key]=nil end
|
|
end
|
|
local name
|
|
for name in observed do
|
|
if current then current[name]=true end
|
|
D.activeRosterBuffs[targetKey.."|"..name]={target=targetKey,spell=name}
|
|
end
|
|
return true
|
|
end
|
|
|
|
D.scanPlayerBuffs = function(applyLive)
|
|
if not UnitBuff then return end
|
|
|
|
local current={}
|
|
if not D.scanUnitBuffs("player","player",current) then return end
|
|
|
|
if applyLive and D.inCombat then
|
|
local pg=nil
|
|
if UnitExists then
|
|
local exists,guid=UnitExists("player")
|
|
if exists and guid then pg=guid end
|
|
end
|
|
local ti=D.guidToActor[pg or D.selfKey]
|
|
if ti and D.applyLivePlayerAuraDiff then
|
|
D.applyLivePlayerAuraDiff(ti,current)
|
|
end
|
|
end
|
|
|
|
-- Keep the pre-combat roster cache authoritative too. PLAYER_AURAS_CHANGED
|
|
-- also fires while we are out of combat (for example when Drink or First Aid
|
|
-- expires). Previously those vanished from playerBuffScanState but remained
|
|
-- in activeRosterBuffs, so the next fight seeded them again as if they were
|
|
-- still active for the whole encounter.
|
|
local cachedKey,cachedBuff
|
|
for cachedKey,cachedBuff in D.activeRosterBuffs do
|
|
if cachedBuff and cachedBuff.target=="player" and not cachedBuff.weaponBuff then
|
|
if not current[cachedBuff.spell] then D.activeRosterBuffs[cachedKey]=nil end
|
|
end
|
|
end
|
|
|
|
D.playerBuffScanState=current
|
|
end
|
|
|
|
D.scanGroupBuffs = function()
|
|
if not UnitBuff or not UnitExists then return end
|
|
|
|
-- Player is kept as the stable "player" target because the live
|
|
-- PLAYER_AURAS_CHANGED path uses that key.
|
|
D.scanPlayerBuffs(false)
|
|
|
|
local raidCount=GetNumRaidMembers and GetNumRaidMembers() or 0
|
|
local i=1
|
|
if raidCount>0 then
|
|
while i<=raidCount do
|
|
local unit="raid"..tostring(i)
|
|
local exists,guid=UnitExists(unit)
|
|
if exists and guid then D.scanUnitBuffs(unit,guid,nil) end
|
|
i=i+1
|
|
end
|
|
else
|
|
while i<=4 do
|
|
local unit="party"..tostring(i)
|
|
local exists,guid=UnitExists(unit)
|
|
if exists and guid then D.scanUnitBuffs(unit,guid,nil) end
|
|
i=i+1
|
|
end
|
|
end
|
|
end
|
|
|
|
D.activeEnemyCasts = {}
|
|
D.pendingSelfDispel = nil
|
|
D.recentSelfDispelCast = nil
|
|
D.lastSelfDispelRecord = nil
|
|
|
|
-- Caw Sync v1: current-fight snapshot exchange between party/raid members.
|
|
-- The sync is intentionally conservative: no per-hit network spam. A client
|
|
-- entering an already-running fight requests one cumulative snapshot and then
|
|
-- continues from its own RAW_COMBATLOG.
|
|
D.syncPrefix = "CAWDPS2"
|
|
D.syncNonce = nil
|
|
D.syncRequested = false
|
|
D.syncReceived = 0
|
|
D.syncLastSource = "none"
|
|
D.syncBestAge = 0
|
|
D.syncQueue = {}
|
|
D.syncQueueDropped = 0
|
|
D.syncQueueMax = 1000
|
|
D.syncNextSend = 0
|
|
D.syncSent = 0
|
|
D.syncAddonEvents = 0
|
|
D.syncRequestsSeen = 0
|
|
D.syncSendAttempts = 0
|
|
D.syncRequestSent = false
|
|
D.syncLastError = "none"
|
|
D.syncLastChannel = "none"
|
|
D.syncAPI = "unknown"
|
|
D.syncOffers = {}
|
|
D.syncOfferDeadline = 0
|
|
D.syncSelectedSource = nil
|
|
D.syncIncoming = nil
|
|
D.syncRejectedSources = 0
|
|
|
|
-- Short combat-end grace window:
|
|
-- keeps brief combat drops in one segment and gives Caw Sync time for one
|
|
-- final reconciliation snapshot before the fight is committed.
|
|
D.pendingCombatEndAt = 0
|
|
D.pendingCombatEndStopTime = 0
|
|
D.pendingCombatEndFirstAt = 0
|
|
D.lastFinalizeAt = 0
|
|
D.combatEndGrace = 1.50
|
|
-- Never let a stuck crowd-control entry hold the fight open longer than
|
|
-- grace + this many seconds past the moment the player actually left combat.
|
|
D.combatEndHardCap = 4.0
|
|
|
|
-- === Local diagnostic log =================================================
|
|
-- Off by default. Never committed or shipped. Toggle/export with /cdlog.
|
|
-- Traces the combat-end lifecycle so a mis-timed fight boundary can be seen.
|
|
D.logEnabled = false
|
|
D.log = {}
|
|
D.logMax = 800
|
|
D.logSeq = 0
|
|
D.logStart = 0
|
|
D.logLine = function(cat, text)
|
|
if not D.logEnabled then return end
|
|
local now = GetTime()
|
|
if D.logStart == 0 then D.logStart = now end
|
|
D.logSeq = D.logSeq + 1
|
|
table.insert(D.log, {seq=D.logSeq, rel=now-D.logStart, cat=tostring(cat or "?"), text=tostring(text or "")})
|
|
while table.getn(D.log) > D.logMax do table.remove(D.log, 1) end
|
|
end
|
|
D.logState = function(tag)
|
|
if not D.logEnabled then return end
|
|
local uac = "?"
|
|
if UnitAffectingCombat then local ok,v=pcall(UnitAffectingCombat,"player"); if ok then uac=(v and "1" or "0") end end
|
|
local grace = 0
|
|
if D.pendingCombatEndAt and D.pendingCombatEndAt>0 then grace=D.pendingCombatEndAt-GetTime() end
|
|
local nCC=0; local kk,vv; for kk,vv in D.activeCC do nCC=nCC+1 end
|
|
local rAge=-1; if D.lastRosterCombatActivity and D.lastRosterCombatActivity>0 then rAge=GetTime()-D.lastRosterCombatActivity end
|
|
D.logLine("state", tostring(tag)
|
|
.." inCombat="..tostring(D.inCombat and 1 or 0)
|
|
.." uac="..uac
|
|
.." pendingEnd="..string.format("%.2f", grace)
|
|
.." rosterAge="..string.format("%.2f", rAge)
|
|
.." activeCC="..tostring(nCC)
|
|
.." dead="..tostring(D.localPlayerDead and 1 or 0))
|
|
end
|
|
-- =========================================================================
|
|
|
|
local requestCombatSync = nil
|
|
local finalizeSyncOfferSelection = nil
|
|
|
|
-- Persistent settings -------------------------------------------------------
|
|
-- IMPORTANT for Vanilla 1.12/custom clients:
|
|
-- SavedVariables are guaranteed to be available only when ADDON_LOADED fires.
|
|
-- Do NOT bind a local DB reference at file-load time; otherwise the client may
|
|
-- later replace the global SavedVariables table and our local reference would
|
|
-- point at an orphan table that is never written to disk.
|
|
local DB = nil
|
|
local CharDB = nil
|
|
D.savedVariablesReady = false
|
|
D.locked = false
|
|
|
|
|
|
local function skinCawHoverTooltip(tt)
|
|
if not tt or not tt.SetBackdrop then return end
|
|
tt:SetBackdrop({
|
|
bgFile="Interface\\Buttons\\WHITE8X8",
|
|
edgeFile="Interface\\Buttons\\WHITE8X8",
|
|
tile=false,
|
|
edgeSize=1,
|
|
insets={left=1,right=1,top=1,bottom=1}
|
|
})
|
|
tt:SetBackdropColor(0.10,0.105,0.12,0.98)
|
|
tt:SetBackdropBorderColor(0.23,0.24,0.27,1)
|
|
end
|
|
|
|
local playerTooltip=nil
|
|
local function getPlayerTooltip()
|
|
if not playerTooltip then
|
|
playerTooltip=CreateFrame("GameTooltip","CawDPSMeterPlayerTooltip",UIParent,"GameTooltipTemplate")
|
|
skinCawHoverTooltip(playerTooltip)
|
|
end
|
|
return playerTooltip
|
|
end
|
|
|
|
function D.getControlTooltip()
|
|
if not D.controlTooltip then
|
|
D.controlTooltip=CreateFrame("GameTooltip","CawDPSMeterControlTooltip",UIParent,"GameTooltipTemplate")
|
|
skinCawHoverTooltip(D.controlTooltip)
|
|
end
|
|
return D.controlTooltip
|
|
end
|
|
|
|
-- Window layouts are shared across characters. Mirror only these UI fields;
|
|
-- recording, sync and calibration preferences remain character-specific.
|
|
local windowSettingsKeys={"width","height","point","relativePoint","x","y","left","top",
|
|
"centerX","centerY","layoutVersion","layoutSaved","locked","mode","segment","segmentIndex",
|
|
"appearance","windowHidden","pfDockMain","pfDockAlternate","pfDockRevision","bagLayerAlwaysBehind",
|
|
"extraWindows","extraWindowsVersion","extraWindowsExplicit","extraWindowCount","extraWindowsRevision",
|
|
"closedWindows","windowSettingsVersion"}
|
|
local function copyWindowValue(value)
|
|
if type(value)~="table" then return value end
|
|
local copy={}
|
|
for k,v in pairs(value) do copy[k]=copyWindowValue(v) end
|
|
return copy
|
|
end
|
|
local function copyWindowSettings(src,dst)
|
|
for _,key in ipairs(windowSettingsKeys) do dst[key]=copyWindowValue(src[key]) end
|
|
end
|
|
|
|
local function initializeSavedVariables()
|
|
if D.savedVariablesReady then return end
|
|
if not CawDPSMeterDB then CawDPSMeterDB = {} end
|
|
if not CawDPSMeterCharDB then CawDPSMeterCharDB = {} end
|
|
DB = CawDPSMeterDB
|
|
CharDB = CawDPSMeterCharDB
|
|
|
|
-- One-time legacy SavedVariables migration; all active state uses CawDPSMeterDB.
|
|
if not DB.migratedFromDetails and DetailsRavenDB then
|
|
if DB.width == nil then DB.width = DetailsRavenDB.width end
|
|
if DB.height == nil then DB.height = DetailsRavenDB.height end
|
|
if DB.point == nil then DB.point = DetailsRavenDB.point end
|
|
if DB.relativePoint == nil then DB.relativePoint = DetailsRavenDB.relativePoint end
|
|
if DB.x == nil then DB.x = DetailsRavenDB.x end
|
|
if DB.y == nil then DB.y = DetailsRavenDB.y end
|
|
if DB.left == nil then DB.left = DetailsRavenDB.left end
|
|
if DB.top == nil then DB.top = DetailsRavenDB.top end
|
|
if DB.locked == nil then DB.locked = DetailsRavenDB.locked end
|
|
DB.migratedFromDetails = true
|
|
end
|
|
|
|
if DB.windowSettingsVersion==1 then
|
|
copyWindowSettings(DB,CharDB)
|
|
elseif CharDB.windowSettingsVersion==1 then
|
|
copyWindowSettings(CharDB,DB)
|
|
end
|
|
if DB.locked == nil then DB.locked = false end
|
|
D.locked = DB.locked and true or false
|
|
if D.uiCopySettings then D.appearance=D.uiCopySettings(DB.appearance) end
|
|
|
|
-- Restore the last selected meter mode. Keep validation explicit so a
|
|
-- stale/invalid SavedVariable can never break the mode selector.
|
|
local savedMode=nil
|
|
if CharDB and CharDB.mode then savedMode=CharDB.mode
|
|
elseif DB and DB.mode then savedMode=DB.mode end
|
|
if savedMode=="damage" or savedMode=="healing" or savedMode=="overhealing" or savedMode=="threat" or savedMode=="damageTaken" or savedMode=="deaths"
|
|
or savedMode=="interrupts" or savedMode=="cc" or savedMode=="ccBreaks" or savedMode=="dispels"
|
|
or savedMode=="buffs" or savedMode=="debuffsCast" or savedMode=="debuffsReceived" then
|
|
D.mode=savedMode
|
|
end
|
|
|
|
-- Persist the primary window's selected segment as well. History entries are
|
|
-- intentionally not restored across a reload because fightHistory itself is
|
|
-- session-only; Current and Overall can be restored safely.
|
|
local savedSegment=nil
|
|
if CharDB and CharDB.segment then savedSegment=CharDB.segment
|
|
elseif DB and DB.segment then savedSegment=DB.segment end
|
|
if savedSegment=="overall" or savedSegment=="current" then
|
|
D.segment=savedSegment
|
|
D.segmentIndex=0
|
|
end
|
|
|
|
D.savedVariablesReady = true
|
|
end
|
|
|
|
|
|
local CLASS_COLORS = {
|
|
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}
|
|
}
|
|
|
|
-- Blizzard's classic class-icon atlas. Keeping this on a built-in WoW texture
|
|
-- avoids shipping nine separate icon files and works on the 1.12 client.
|
|
local CLASS_ICON_TEXTURE="Interface\\Glues\\CharacterCreate\\UI-CharacterCreate-Classes"
|
|
local CLASS_ICON_TCOORDS={
|
|
WARRIOR={0.000000,0.250000,0.000000,0.250000},
|
|
MAGE={0.250000,0.496094,0.000000,0.250000},
|
|
ROGUE={0.496094,0.742188,0.000000,0.250000},
|
|
DRUID={0.742188,0.988281,0.000000,0.250000},
|
|
HUNTER={0.000000,0.250000,0.250000,0.500000},
|
|
SHAMAN={0.250000,0.496094,0.250000,0.500000},
|
|
PRIEST={0.496094,0.742188,0.250000,0.500000},
|
|
WARLOCK={0.742188,0.988281,0.250000,0.500000},
|
|
PALADIN={0.000000,0.250000,0.500000,0.750000}
|
|
}
|
|
|
|
local CC_SPELLS = {
|
|
["Polymorph"]=true, ["Sap"]=true, ["Blind"]=true, ["Gouge"]=true,
|
|
["Fear"]=true, ["Psychic Scream"]=true, ["Intimidating Shout"]=true,
|
|
["Seduction"]=true, ["Banish"]=true, ["Hibernate"]=true,
|
|
["Freezing Trap Effect"]=true, ["Freezing Trap"]=true, ["Scatter Shot"]=true,
|
|
["Wyvern Sting"]=true, ["Scare Beast"]=true, ["Repentance"]=true,
|
|
["Entangling Roots"]=true, ["Frost Nova"]=true, ["Hammer of Justice"]=true,
|
|
["Cheap Shot"]=true, ["Kidney Shot"]=true, ["Bash"]=true, ["Pounce"]=true,
|
|
["War Stomp"]=true, ["Death Coil"]=true
|
|
}
|
|
|
|
local function chat(msg)
|
|
if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff7fbf4dCaw DPS Meter|r: " .. tostring(msg))
|
|
end
|
|
end
|
|
|
|
local function comma(n)
|
|
n = math.floor(tonumber(n) or 0)
|
|
local s = tostring(n)
|
|
local len = string.len(s)
|
|
if len <= 3 then return s end
|
|
local out = ""
|
|
local first = len - math.floor((len - 1) / 3) * 3
|
|
out = string.sub(s, 1, first)
|
|
local i = first + 1
|
|
while i <= len do out = out .. "," .. string.sub(s, i, i + 2); i = i + 3 end
|
|
return out
|
|
end
|
|
|
|
local function clearTable(t) local k; for k in t do t[k] = nil end end
|
|
local function currentFightDuration()
|
|
if D.startTime == 0 then return D.lastDuration or 0 end
|
|
if D.inCombat then
|
|
-- Once a grace close is scheduled the fight is over for display: freeze
|
|
-- the duration at the moment combat actually ended so DPS/HPS stop
|
|
-- decaying while the grace window and trailing reconciliation run.
|
|
if D.pendingCombatEndAt and D.pendingCombatEndAt>0
|
|
and D.pendingCombatEndStopTime and D.pendingCombatEndStopTime>D.startTime then
|
|
return D.pendingCombatEndStopTime-D.startTime
|
|
end
|
|
local t=GetTime()-D.startTime; if t<0 then t=0 end; return t
|
|
end
|
|
return D.lastDuration or 0
|
|
end
|
|
local function getSelectedHistoryFight()
|
|
if D.segment~="history" then return nil end
|
|
local idx=D.segmentIndex or 1
|
|
return D.fightHistory[idx]
|
|
end
|
|
local function getDisplayActors()
|
|
if D.mode=="threat" and D.segment=="current" and D.serverThreatActors then return D.serverThreatActors() end
|
|
local h=getSelectedHistoryFight()
|
|
if h and h.actors then return h.actors end
|
|
if D.segment=="overall" and D.overallSegment and D.overallSegment.actors then return D.overallSegment.actors end
|
|
return D.actors
|
|
end
|
|
D.threatDisplayActors=getDisplayActors
|
|
D.threatHistoryFight=getSelectedHistoryFight
|
|
local function getDuration()
|
|
local h=getSelectedHistoryFight()
|
|
if h then return h.duration or 0 end
|
|
if D.segment=="overall" and D.overallSegment then return D.overallSegment.duration or 0 end
|
|
return currentFightDuration()
|
|
end
|
|
|
|
local function newCounterTable() return {} end
|
|
local function newActor(key,name,guid,ownerKey,isPet,classToken)
|
|
local a={key=key,name=name or key,guid=guid,ownerKey=ownerKey,isPet=isPet,classToken=classToken,
|
|
damage=0,hits=0,crits=0,spells={},healing=0,heals=0,healCrits=0,healSpells={},
|
|
damageTaken=0,damageTakenSpells={},deaths=0,deathCauses={},deathLast=nil,
|
|
buffs=newCounterTable(),debuffsReceived=newCounterTable(),debuffsCast=newCounterTable(),
|
|
interrupts=newCounterTable(),cc=newCounterTable(),ccBreaks=newCounterTable(),dispels=newCounterTable()}
|
|
-- Backward-compatible alias used by older tooltip/debug code.
|
|
a.debuffs=a.debuffsReceived
|
|
D.actors[key]=a; return a
|
|
end
|
|
local function getActor(key,name,guid,ownerKey,isPet,classToken)
|
|
-- Damage events only contain GUIDs. Pull the class from the current party/raid
|
|
-- roster when the caller did not explicitly pass it. This is what makes
|
|
-- group members use their real class colour instead of the grey fallback.
|
|
if not classToken and guid and D.guidToActor and D.guidToActor[guid] then
|
|
classToken=D.guidToActor[guid].classToken
|
|
end
|
|
if not classToken and key and D.guidToActor and D.guidToActor[key] then
|
|
classToken=D.guidToActor[key].classToken
|
|
end
|
|
local a=D.actors[key]; if not a then a=newActor(key,name,guid,ownerKey,isPet,classToken) end
|
|
if name then a.name=name end; if guid then a.guid=guid end; if ownerKey then a.ownerKey=ownerKey end
|
|
if isPet~=nil then a.isPet=isPet end; if classToken then a.classToken=classToken end
|
|
local meta=nil
|
|
if guid and D.guidToActor then meta=D.guidToActor[guid] end
|
|
if not meta and key and D.guidToActor then meta=D.guidToActor[key] end
|
|
if meta and meta.isTotem then a.isTotem=true end
|
|
if isPet and ownerKey and ownerKey~=key and not D.actors[ownerKey] then
|
|
local owner=D.guidToActor[ownerKey]
|
|
if owner then newActor(owner.key,owner.name,owner.guid,nil,false,owner.classToken) end
|
|
end
|
|
return a
|
|
end
|
|
-- CawThreat loads after the main file and needs the same actor constructor when
|
|
-- a threat-only cast (for example pet Growl) opens combat before any damage.
|
|
D.getActor=getActor
|
|
local function addCount(tbl,name,amount)
|
|
if not name or name=="" then name="Unknown" end
|
|
local e=tbl[name]; if not e then e={count=0}; tbl[name]=e end
|
|
e.count=e.count+(amount or 1); return e
|
|
end
|
|
|
|
-- Aura uptime helpers. Each aura keeps independent active timers per target so
|
|
-- multi-target debuffs can be measured without losing applications.
|
|
local function auraEntry(tbl,name)
|
|
if not name or name=="" then name="Unknown" end
|
|
local e=tbl[name]
|
|
if not e then e={count=0,total=0,active={},targets={}}; tbl[name]=e end
|
|
if not e.active then e.active={} end
|
|
if not e.targets then e.targets={} end
|
|
if not e.total then e.total=0 end
|
|
if not e.count then e.count=0 end
|
|
if not e.sources then e.sources={} end
|
|
return e
|
|
end
|
|
local function auraClock()
|
|
if D.inCombat then return GetTime() end
|
|
if D.startTime and D.startTime>0 and D.lastDuration and D.lastDuration>0 then return D.startTime+D.lastDuration end
|
|
return GetTime()
|
|
end
|
|
local function startAura(tbl,spell,targetKey,sourceName,sourceKey)
|
|
if not tbl or not spell then return nil end
|
|
targetKey=targetKey or "self"
|
|
local e=auraEntry(tbl,spell)
|
|
if sourceName and sourceName~="" and sourceName~="Unknown" then e.sources[sourceKey or sourceName]=sourceName end
|
|
if not e.active[targetKey] then
|
|
e.active[targetKey]=auraClock()
|
|
e.count=e.count+1
|
|
if not e.targets then e.targets={} end
|
|
if e.targets[targetKey]==nil then e.targets[targetKey]=0 end
|
|
end
|
|
return e
|
|
end
|
|
local function stopAura(tbl,spell,targetKey,stopTime)
|
|
if not tbl or not spell then return false end
|
|
local e=tbl[spell]; if not e or not e.active then return false end
|
|
targetKey=targetKey or "self"
|
|
local st=e.active[targetKey]
|
|
if st then
|
|
local et=stopTime or auraClock()
|
|
if et>st then
|
|
local delta=et-st
|
|
e.total=(e.total or 0)+delta
|
|
if not e.targets then e.targets={} end
|
|
e.targets[targetKey]=(e.targets[targetKey] or 0)+delta
|
|
end
|
|
e.active[targetKey]=nil
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
local function auraEntryDuration(e)
|
|
if not e then return 0 end
|
|
local total=e.total or 0; local now=auraClock(); local k,st
|
|
if e.active then for k,st in e.active do if st and now>st then total=total+(now-st) end end end
|
|
return total
|
|
end
|
|
local function auraTableDuration(tbl)
|
|
local total=0; if not tbl then return 0 end; local k,e; for k,e in tbl do total=total+auraEntryDuration(e) end; return total
|
|
end
|
|
local function auraTargetCount(e)
|
|
if not e then return 1 end
|
|
local n=0; local k,v
|
|
if e.targets then for k,v in e.targets do n=n+1 end end
|
|
if e.active then for k,v in e.active do if not e.targets or e.targets[k]==nil then n=n+1 end end end
|
|
if n<1 then n=1 end
|
|
return n
|
|
end
|
|
local function auraExposureCount(tbl)
|
|
if not tbl then return 0 end
|
|
local n=0; local k,e
|
|
for k,e in tbl do n=n+auraTargetCount(e) end
|
|
return n
|
|
end
|
|
local function rememberAuraCast(sourceInfo,spell,target)
|
|
if not sourceInfo or not spell or not target then return end
|
|
D.recentAuraCasts[target.."|"..spell]={source=sourceInfo,time=GetTime()}
|
|
end
|
|
local function recentAuraCaster(target,spell)
|
|
local r=D.recentAuraCasts[target.."|"..spell]; if not r then return nil end
|
|
if GetTime()-(r.time or 0)>4 then D.recentAuraCasts[target.."|"..spell]=nil; return nil end
|
|
return r.source
|
|
end
|
|
local function clearRecentAuraCast(target,spell)
|
|
if not target or not spell then return end
|
|
D.recentAuraCasts[target.."|"..spell]=nil
|
|
end
|
|
D.rememberAuraOrigin = function(sourceToken,spell,target)
|
|
if not sourceToken or not spell or not target then return end
|
|
local info=D.guidToActor and D.guidToActor[sourceToken] or nil
|
|
local name=nil
|
|
if info then name=info.name else name=D.currentEnemyNames[sourceToken] or D.enemyNameFromGUID(sourceToken) end
|
|
if name then D.recentAuraOrigins[target.."|"..spell]={name=name,key=sourceToken,time=GetTime()} end
|
|
end
|
|
D.recentAuraOrigin = function(target,spell)
|
|
local r=D.recentAuraOrigins[target.."|"..spell]; if not r then return nil end
|
|
if GetTime()-(r.time or 0)>4 then D.recentAuraOrigins[target.."|"..spell]=nil; return nil end
|
|
return r
|
|
end
|
|
D.backfillAuraSource = function(tbl,spell,sourceName,sourceKey)
|
|
if not tbl or not spell or not sourceName or sourceName=="" or sourceName=="Unknown" then return end
|
|
-- Never expose unresolved RAW GUIDs as player/mob names.
|
|
if string.find(sourceName,"^0x[%x]+$") then return end
|
|
local e=tbl[spell]
|
|
if not e then return end
|
|
if not e.sources then e.sources={} end
|
|
e.sources[sourceKey or sourceName]=sourceName
|
|
end
|
|
D.auraSourceText = function(e)
|
|
if not e or not e.sources then return nil end
|
|
local names={}; local n=0; local k,v
|
|
for k,v in e.sources do if v and v~="" and v~="Unknown" then n=n+1; names[n]=v end end
|
|
if n<1 then return nil end
|
|
table.sort(names)
|
|
-- Keep aura tooltips compact in large raids: show at most five source names.
|
|
-- The complete source set remains stored on the aura entry; this only limits display.
|
|
local limit=n; if limit>5 then limit=5 end
|
|
local out=names[1]; local i=2
|
|
while i<=limit do out=out..", "..names[i]; i=i+1 end
|
|
if n>limit then out=out.." +"..tostring(n-limit).." more" end
|
|
return out
|
|
end
|
|
D.auraSourceCount = function(e)
|
|
if not e or not e.sources then return 0 end
|
|
local n=0; local k,v
|
|
for k,v in e.sources do if v and v~="" and v~="Unknown" then n=n+1 end end
|
|
return n
|
|
end
|
|
D.addAuraSourceHealing = function(tbl,spell,sourceName,sourceKey,amount)
|
|
if not tbl or not spell or not sourceName or sourceName=="" or sourceName=="Unknown" then return end
|
|
if string.find(sourceName,"^0x[%x]+$") then return end
|
|
amount=tonumber(amount); if not amount or amount<=0 then return end
|
|
local e=tbl[spell]; if not e then return end
|
|
local key=sourceKey or sourceName
|
|
if not e.sources then e.sources={} end
|
|
e.sources[key]=sourceName
|
|
if not e.sourceHealing then e.sourceHealing={} end
|
|
local h=e.sourceHealing[key]
|
|
if not h then h={name=sourceName,healing=0}; e.sourceHealing[key]=h end
|
|
h.name=sourceName; h.healing=(h.healing or 0)+amount
|
|
end
|
|
D.auraHealingSourceRows = function(e)
|
|
if not e or not e.sourceHealing then return nil,0,0 end
|
|
local rows={}; local n=0; local k,h
|
|
for k,h in e.sourceHealing do
|
|
if h and h.name and h.name~="" and (h.healing or 0)>0 then n=n+1; rows[n]={name=h.name,healing=h.healing or 0} end
|
|
end
|
|
if n<1 then return nil,0,0 end
|
|
table.sort(rows,function(x,y) return (x.healing or 0)>(y.healing or 0) end)
|
|
local shown=n; if shown>5 then shown=5 end
|
|
return rows,shown,n
|
|
end
|
|
D.addAuraSourceDamage = function(tbl,spell,sourceName,sourceKey,amount)
|
|
if not tbl or not spell or not sourceName or sourceName=="" or sourceName=="Unknown" then return end
|
|
if string.find(sourceName,"^0x[%x]+$") then return end
|
|
amount=tonumber(amount); if not amount or amount<=0 then return end
|
|
-- Damage may only enrich an aura that was already observed as a debuff.
|
|
-- Never manufacture Debuffs Received entries from combat damage alone.
|
|
local e=tbl[spell]; if not e then return end
|
|
local key=sourceKey or sourceName
|
|
if not e.sources then e.sources={} end
|
|
e.sources[key]=sourceName
|
|
if not e.sourceDamage then e.sourceDamage={} end
|
|
local h=e.sourceDamage[key]
|
|
if not h then h={name=sourceName,damage=0}; e.sourceDamage[key]=h end
|
|
h.name=sourceName; h.damage=(h.damage or 0)+amount
|
|
end
|
|
D.auraDamageSourceRows = function(e)
|
|
if not e or not e.sourceDamage then return nil,0,0 end
|
|
local rows={}; local n=0; local k,h
|
|
for k,h in e.sourceDamage do
|
|
if h and h.name and h.name~="" and (h.damage or 0)>0 then n=n+1; rows[n]={name=h.name,damage=h.damage or 0} end
|
|
end
|
|
if n<1 then return nil,0,0 end
|
|
table.sort(rows,function(x,y) return (x.damage or 0)>(y.damage or 0) end)
|
|
local shown=n; if shown>5 then shown=5 end
|
|
return rows,shown,n
|
|
end
|
|
local function resetFight()
|
|
if D.threatCalRecordEvent then D.threatCalRecordEvent({kind="segment-reset",source="Current reset/start"}) end
|
|
clearTable(D.actors); D.startTime=0; D.lastDuration=0; D.parsedTotal=0; D.lastParsed="none"
|
|
D.fightStartedAt=nil; D.fightStartClock=nil
|
|
D.rawUnknown={}; D.rawUnknownCount=0; D.utilityUnknown={}; D.utilityUnknownCount=0; D.ignoredOutsiders=0
|
|
D.globalUtility={buffs={},debuffsReceived={},debuffsCast={},cc={},ccBreaks={},interrupts={},dispels={}}
|
|
D.pendingSelfTotem=nil
|
|
D.activeCC={}; D.lastCCDamage=nil; D.ccDamageByTarget={}; D.lastCCBreak=nil; D.lastCCLanded=nil
|
|
D.lastIncomingDamage={}; D.lastDeathStamp={}
|
|
D.lastSelfIncomingDamage=nil
|
|
D.lastRosterCombatActivity=0
|
|
D.deadSyncLastReceived=0; D.deadSyncNextRequest=0
|
|
D.combatStateCheckAt=0; D.outOfCombatSince=0
|
|
D.recentAuraCasts={}; D.recentAuraOrigins={}; D.recentDirectRosterAuraCasts={}; D.activeAuraSources={}
|
|
D.pendingSelfDispel=nil; D.recentSelfDispelCast=nil; D.lastSelfDispelRecord=nil
|
|
D.currentEnemyDamage={}; D.currentEnemyNames={}; D.currentEnemyBestGuid=nil; D.currentEnemyBestDamage=0; D.currentFightName="Current"
|
|
clearTable(D.activeEnemyCasts)
|
|
D.syncNonce=nil; D.syncRequested=false; D.syncRequestSent=false; D.syncReceived=0; D.syncLastSource="none"; D.syncBestAge=0
|
|
D.syncLastError="none"; D.syncLastChannel="none"
|
|
D.syncOffers={}; D.syncOfferDeadline=0; D.syncSelectedSource=nil; D.syncIncoming=nil; D.syncRejectedSources=0
|
|
D.pendingCombatEndAt=0; D.pendingCombatEndStopTime=0; D.pendingCombatEndFirstAt=0
|
|
D.deadEnemies={}
|
|
D.recentInterrupts={}
|
|
D.threatPendingCast={}; D.threatPendingReset={}; D.threatFeignEarly={}; D.threatFeignCommitted={}; D.threatFeignUndo={}
|
|
D.segmentSerial=(D.segmentSerial or 0)+1
|
|
end
|
|
local function ensureStarted()
|
|
if not D.parserEnabled() then return false end
|
|
-- A roster damage/heal event can arrive before this client receives
|
|
-- PLAYER_REGEN_DISABLED. If the previous fight is already closed,
|
|
-- start a fresh segment *before* counting this first RAW event.
|
|
local opened=false
|
|
if not D.inCombat then
|
|
-- Do not spawn a phantom segment from group activity in the first
|
|
-- seconds after the local player's fight ended. The healer topping the
|
|
-- tank off right after a pull is the common case. A genuine next pull
|
|
-- re-arms through PLAYER_REGEN_DISABLED, or once the local player is
|
|
-- actually fighting again.
|
|
if D.lastFinalizeAt and D.lastFinalizeAt>0 and GetTime()-D.lastFinalizeAt<3.0 then
|
|
local playerOOC=false
|
|
if UnitAffectingCombat then local ok,v=pcall(UnitAffectingCombat,"player"); if ok and not v then playerOOC=true end end
|
|
-- A SELF event means the local player is the one acting/being hit:
|
|
-- they are engaging, the combat flag just has not flipped yet. Only
|
|
-- suppress pure group/creature activity.
|
|
local selfEvent=(D.currentRawEv and string.find(D.currentRawEv,"SELF",1,true)) or D.threatOpeningCastActive
|
|
if playerOOC and not selfEvent then
|
|
D.logLine("seg","ensureStarted SUPPRESSED (<3s after finalize, player OOC) via "..tostring(D.currentRawEv or "?"))
|
|
return false
|
|
end
|
|
end
|
|
D.logLine("seg","ensureStarted OPEN (was closed) via "..tostring(D.currentRawEv or "?").." | "..tostring(D.currentRawText or ""))
|
|
resetFight()
|
|
D.inCombat=true
|
|
D.setFightStartTime(GetTime())
|
|
opened=true
|
|
if D.seedActiveRosterBuffs then D.seedActiveRosterBuffs() end
|
|
elseif D.startTime==0 then
|
|
D.logLine("seg","ensureStarted set startTime (inCombat, start 0) via "..tostring(D.currentRawEv or "?"))
|
|
D.setFightStartTime(GetTime())
|
|
opened=true
|
|
end
|
|
if opened and requestCombatSync then requestCombatSync() end
|
|
return true
|
|
end
|
|
-- Threat-generating UNIT_CASTEVENTs can arrive before the first RAW damage line.
|
|
-- Expose the existing segment opener so CawThreat can start the same fight before
|
|
-- booking flat threat such as Hunter pet Growl; otherwise resetFight() on the
|
|
-- first damage event would erase that pre-combat threat.
|
|
D.ensureStarted=ensureStarted
|
|
local function addSpell(a,spell,amount,crit,spellId)
|
|
spell=spell or "Melee"; local s=a.spells[spell]
|
|
if not s then s={damage=0,hits=0,crits=0}; a.spells[spell]=s end
|
|
if spellId and not s.spellId then s.spellId=tonumber(spellId) end
|
|
s.damage=s.damage+amount; s.hits=s.hits+1; if crit then s.crits=s.crits+1 end
|
|
end
|
|
local function addDamage(actorKey,actorName,guid,ownerKey,isPet,amount,spell,crit,sourceEvent,spellId)
|
|
amount=tonumber(amount); if not amount or amount<=0 then return false end
|
|
if not ensureStarted() then return false end
|
|
if sourceEvent~="DPSLog" then D.dpsLogRawCommitted=true end
|
|
local a=getActor(actorKey,actorName,guid,ownerKey,isPet)
|
|
a.damage=a.damage+amount; a.hits=a.hits+1; if crit then a.crits=a.crits+1 end
|
|
addSpell(a,spell,amount,crit,spellId); if D.threatOnDamage then D.threatOnDamage(a,amount,spell,crit,D.threatEventTarget) end; D.parsedTotal=D.parsedTotal+1
|
|
D.lastRosterCombatActivity=GetTime()
|
|
D.lastParsed=tostring(sourceEvent).." "..tostring(a.name).." "..tostring(spell).." +"..tostring(amount); return true
|
|
end
|
|
|
|
|
|
local function addHealSpell(a,spell,amount,crit,spellId)
|
|
spell=spell or "Healing"; local h=a.healSpells[spell]
|
|
if not h then h={healing=0,hits=0,crits=0}; a.healSpells[spell]=h end
|
|
if spellId and not h.spellId then h.spellId=tonumber(spellId) end
|
|
h.healing=h.healing+amount; h.hits=h.hits+1; if crit then h.crits=h.crits+1 end
|
|
return h
|
|
end
|
|
D.overhealFields={"overhealing","overhealTotal","overhealHits","overhealCrits"}
|
|
function D.overhealSummary(actors,actor)
|
|
local over,gross=nil,nil
|
|
for _,a in pairs(actors or {}) do
|
|
if (not actor or a==actor or (not actor.isPet and a.isPet and a.ownerKey==actor.key))
|
|
and a.overhealTotal~=nil then
|
|
over=(over or 0)+(a.overhealing or 0); gross=(gross or 0)+a.overhealTotal
|
|
end
|
|
end
|
|
return over,gross
|
|
end
|
|
function D.overhealPercent(over,gross)
|
|
if over==nil or gross==nil then return "--" end
|
|
return string.format("%.1f%%",gross>0 and 100*over/gross or 0)
|
|
end
|
|
function D.overhealFooter(actors)
|
|
local over,gross=D.overhealSummary(actors)
|
|
if over==nil then return "No overheal data" end
|
|
return "Total: "..comma(over).." | "..D.overhealPercent(over,gross).." OH"
|
|
end
|
|
local function addHealing(actorKey,actorName,guid,ownerKey,isPet,amount,spell,crit,sourceEvent,spellId,over)
|
|
amount=tonumber(amount); if not amount or amount<0 or (amount==0 and (not over or over<=0)) then return false end
|
|
if not ensureStarted() then return false end
|
|
if sourceEvent~="DPSLog" then D.dpsLogRawCommitted=true end
|
|
local a=getActor(actorKey,actorName,guid,ownerKey,isPet)
|
|
a.healing=a.healing+amount; a.heals=a.heals+1; if crit then a.healCrits=a.healCrits+1 end
|
|
local h=addHealSpell(a,spell,amount,crit,spellId)
|
|
if over~=nil then
|
|
a.overhealing=(a.overhealing or 0)+over; h.overhealing=(h.overhealing or 0)+over
|
|
a.overhealTotal=(a.overhealTotal or 0)+amount+over; h.overhealTotal=(h.overhealTotal or 0)+amount+over
|
|
a.overhealHits=(a.overhealHits or 0)+1; h.overhealHits=(h.overhealHits or 0)+1
|
|
local critical=crit and 1 or 0
|
|
a.overhealCrits=(a.overhealCrits or 0)+critical; h.overhealCrits=(h.overhealCrits or 0)+critical
|
|
end
|
|
if amount>0 and D.threatOnHealing then D.threatOnHealing(a,amount,spell) end; D.parsedTotal=D.parsedTotal+1
|
|
D.lastRosterCombatActivity=GetTime()
|
|
D.lastParsed=tostring(sourceEvent).." "..tostring(a.name).." "..tostring(spell).." +"..tostring(amount).." heal"; return true
|
|
end
|
|
|
|
local function safeUnitGUID(unit)
|
|
if UnitExists then local ok,exists,guid=pcall(UnitExists,unit); if ok and exists and guid and guid~="" then return guid end end
|
|
if UnitGUID then local ok,guid=pcall(UnitGUID,unit); if ok and guid and guid~="" then return guid end end
|
|
return nil
|
|
end
|
|
local function safeUnitName(unit)
|
|
if not unit or not UnitName then return nil end
|
|
local ok,name=pcall(UnitName,unit)
|
|
if ok and name and name~="" and name~="Unknown" and name~="UNKNOWN" then return name end
|
|
return nil
|
|
end
|
|
|
|
D.enemyNameFromGUID = function(guid)
|
|
if not guid then return nil end
|
|
local name=safeUnitName(guid)
|
|
if name then return name end
|
|
local units={"target","mouseover","targettarget"}
|
|
local i=1
|
|
while i<=table.getn(units) do
|
|
local u=units[i]
|
|
local ug=safeUnitGUID(u)
|
|
if ug and ug==guid then
|
|
name=safeUnitName(u)
|
|
if name then return name end
|
|
end
|
|
i=i+1
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function noteEnemyTarget(guid,amount)
|
|
if not guid or not string.find(guid,"^0x") then return end
|
|
if not ensureStarted() then return end
|
|
D.threatEventTarget=guid
|
|
amount=tonumber(amount) or 0
|
|
|
|
local total=(D.currentEnemyDamage[guid] or 0)+amount
|
|
D.currentEnemyDamage[guid]=total
|
|
|
|
if not D.currentEnemyNames[guid] then
|
|
D.currentEnemyNames[guid]=D.enemyNameFromGUID(guid)
|
|
end
|
|
|
|
-- Incremental dominant-target cache. Damage totals only ever increase
|
|
-- during a segment, so a full scan of every enemy on every hit is not
|
|
-- necessary.
|
|
if not D.currentEnemyBestGuid or total>D.currentEnemyBestDamage then
|
|
D.currentEnemyBestGuid=guid
|
|
D.currentEnemyBestDamage=total
|
|
end
|
|
|
|
local bestGuid=D.currentEnemyBestGuid
|
|
if bestGuid then
|
|
local bestName=D.currentEnemyNames[bestGuid] or D.enemyNameFromGUID(bestGuid)
|
|
if bestName then
|
|
D.currentEnemyNames[bestGuid]=bestName
|
|
D.currentFightName=bestName
|
|
elseif D.currentFightName=="Current" then
|
|
D.currentFightName="Unknown Enemy"
|
|
end
|
|
end
|
|
end
|
|
|
|
local function isHostileUnit(unit)
|
|
if not unit or not UnitCanAttack then return false end
|
|
local ok,hostile=pcall(UnitCanAttack,"player",unit)
|
|
return ok and hostile and true or false
|
|
end
|
|
|
|
local function currentFightLabel()
|
|
if D.currentFightName and D.currentFightName~="" and D.currentFightName~="Current" then return D.currentFightName end
|
|
-- Only use the current target as a fallback when it is actually hostile.
|
|
-- This prevents healing-only fights from being named after a friendly target.
|
|
if isHostileUnit("target") then
|
|
local tn=safeUnitName("target")
|
|
if tn then return tn end
|
|
end
|
|
return "Current"
|
|
end
|
|
|
|
local function safeClass(unit)
|
|
if not UnitClass then return nil end
|
|
local ok,localized,token=pcall(UnitClass,unit)
|
|
if not ok then return nil end
|
|
if token and CLASS_COLORS[token] then return token end
|
|
-- Some 1.12/SuperWoW builds only expose the localized class name.
|
|
-- Normalize the common English return value to the class token used above.
|
|
if localized then
|
|
local up=string.upper(localized)
|
|
if CLASS_COLORS[up] then return up end
|
|
local names={Krieger="WARRIOR",Magier="MAGE",Schurke="ROGUE",Druide="DRUID",Jaeger="HUNTER",Jager="HUNTER",Schamane="SHAMAN",Priester="PRIEST",Hexenmeister="WARLOCK",Paladin="PALADIN"}
|
|
if names[localized] then return names[localized] end
|
|
end
|
|
return token or localized
|
|
end
|
|
local function addRosterUnit(unit,ownerKey,isPet)
|
|
if not UnitExists or not UnitExists(unit) then return end
|
|
local name=UnitName(unit) or unit; local guid=safeUnitGUID(unit); local key=guid or unit
|
|
local classToken=nil; if not isPet then classToken=safeClass(unit) end
|
|
D.guidToActor[guid or key]={key=key,name=name,guid=guid,ownerKey=ownerKey,isPet=isPet,classToken=classToken}
|
|
if isPet and ownerKey then D.petOwner[guid or key]=ownerKey end
|
|
end
|
|
local function refreshRoster()
|
|
clearTable(D.guidToActor); clearTable(D.petOwner)
|
|
local pg=safeUnitGUID("player"); local pn=UnitName("player") or "You"; local pk=pg or "player"; D.selfKey=pk
|
|
D.guidToActor[pg or pk]={key=pk,name=pn,guid=pg,isPet=false,classToken=safeClass("player")}
|
|
addRosterUnit("pet",pk,true)
|
|
local i=1
|
|
while i<=4 do local u="party"..tostring(i); if UnitExists and UnitExists(u) then
|
|
local g=safeUnitGUID(u); local k=g or u; addRosterUnit(u,nil,false); addRosterUnit("partypet"..tostring(i),k,true)
|
|
end; i=i+1 end
|
|
i=1
|
|
while i<=40 do local u="raid"..tostring(i); if UnitExists and UnitExists(u) then
|
|
local g=safeUnitGUID(u); local k=g or u; addRosterUnit(u,nil,false); addRosterUnit("raidpet"..tostring(i),k,true)
|
|
end; i=i+1 end
|
|
-- Roster rebuilds must not erase already observed summons still owned by a member.
|
|
local summonGuid,summon
|
|
for summonGuid,summon in D.summonActors do
|
|
if summon and D.guidToActor[summon.ownerKey] then
|
|
D.guidToActor[summonGuid]=summon; D.petOwner[summonGuid]=summon.ownerKey
|
|
else D.summonActors[summonGuid]=nil end
|
|
end
|
|
local buffKey,buff
|
|
for buffKey,buff in D.activeRosterBuffs do
|
|
if buff.target~="player" and not D.guidToActor[buff.target] then D.activeRosterBuffs[buffKey]=nil end
|
|
end
|
|
-- Refresh metadata for actors that already exist (for example when joining
|
|
-- a party during a session or after a roster update).
|
|
local ak,av
|
|
for ak,av in D.actors do
|
|
local ri=D.guidToActor[av.guid or ak]
|
|
if ri then
|
|
av.name=ri.name or av.name
|
|
av.classToken=ri.classToken or av.classToken
|
|
av.ownerKey=ri.ownerKey or av.ownerKey
|
|
av.isPet=ri.isPet
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Caw Sync -----------------------------------------------------------------
|
|
function D.cancelCombatSync()
|
|
if D.cancelTargetSync then D.cancelTargetSync() end
|
|
D.syncQueue={}; D.syncNonce=nil; D.syncIncoming=nil; D.syncSelectedSource=nil
|
|
D.syncOffers={}; D.syncOfferDeadline=0; D.syncRequested=false; D.syncRequestSent=false
|
|
D.nextCombatSyncCheck=0
|
|
end
|
|
function D.setCombatSyncEnabled(enabled)
|
|
CawDPSMeterCharDB=CawDPSMeterCharDB or {}
|
|
CawDPSMeterCharDB.combatSyncEnabled=enabled and true or false
|
|
D.cancelCombatSync()
|
|
end
|
|
local function syncChannel()
|
|
if GetNumRaidMembers and GetNumRaidMembers()>0 then return "RAID" end
|
|
if GetNumPartyMembers and GetNumPartyMembers()>0 then return "PARTY" end
|
|
return nil
|
|
end
|
|
|
|
local function syncRosterSender(sender)
|
|
if not sender or sender=="" then return false end
|
|
local me=UnitName("player")
|
|
if me and sender==me then return true end
|
|
local i=1
|
|
while i<=4 do
|
|
local u="party"..tostring(i)
|
|
if UnitExists and UnitExists(u) and UnitName(u)==sender then return true end
|
|
i=i+1
|
|
end
|
|
i=1
|
|
while i<=40 do
|
|
local u="raid"..tostring(i)
|
|
if UnitExists and UnitExists(u) and UnitName(u)==sender then return true end
|
|
i=i+1
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function syncField(v)
|
|
local s=tostring(v or "")
|
|
s=string.gsub(s,"~","/")
|
|
s=string.gsub(s,"|","/")
|
|
s=string.gsub(s,"\n"," ")
|
|
return s
|
|
end
|
|
|
|
local function splitSync(msg)
|
|
local out={}; local n=0; local pos=1
|
|
while true do
|
|
local a,b=string.find(msg,"~",pos,true)
|
|
if not a then n=n+1; out[n]=string.sub(msg,pos); break end
|
|
n=n+1; out[n]=string.sub(msg,pos,a-1); pos=b+1
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function queueSync(message,channel,target)
|
|
if not D.combatSyncEnabled() then return end
|
|
if not message or string.len(message)>240 then return end
|
|
if table.getn(D.syncQueue)>=(D.syncQueueMax or 1000) then
|
|
D.syncQueueDropped=(D.syncQueueDropped or 0)+1
|
|
D.syncLastError="Sync queue limit reached"
|
|
return
|
|
end
|
|
table.insert(D.syncQueue,{message=message,channel=channel,target=target})
|
|
end
|
|
|
|
local function getAddonSender()
|
|
if type(SendAddonMessage)=="function" then
|
|
D.syncAPI="SendAddonMessage"
|
|
return SendAddonMessage
|
|
end
|
|
if C_ChatInfo and type(C_ChatInfo.SendAddonMessage)=="function" then
|
|
D.syncAPI="C_ChatInfo.SendAddonMessage"
|
|
return C_ChatInfo.SendAddonMessage
|
|
end
|
|
D.syncAPI="missing"
|
|
return nil
|
|
end
|
|
|
|
local function sendSyncNow(message,channel,target)
|
|
D.syncSendAttempts=(D.syncSendAttempts or 0)+1
|
|
D.syncLastChannel=tostring(channel or "none")
|
|
local sender=getAddonSender()
|
|
if not sender then
|
|
D.syncLastError="No addon-message API available"
|
|
return false
|
|
end
|
|
if not channel then
|
|
D.syncLastError="No PARTY/RAID channel available"
|
|
return false
|
|
end
|
|
|
|
local ok,err
|
|
if channel=="WHISPER" then
|
|
ok,err=pcall(sender,D.syncPrefix,message,channel,target)
|
|
else
|
|
ok,err=pcall(sender,D.syncPrefix,message,channel)
|
|
end
|
|
if ok then
|
|
D.syncSent=(D.syncSent or 0)+1
|
|
D.syncLastError="none"
|
|
return true
|
|
end
|
|
D.syncLastError=tostring(err or "unknown send error")
|
|
return false
|
|
end
|
|
|
|
local function flushSyncQueue()
|
|
if not D.combatSyncEnabled() then D.cancelCombatSync(); return end
|
|
if table.getn(D.syncQueue)<=0 or not getAddonSender() then return end
|
|
local now=GetTime()
|
|
if now<(D.syncNextSend or 0) then return end
|
|
local item=table.remove(D.syncQueue,1)
|
|
if item then sendSyncNow(item.message,item.channel,item.target) end
|
|
D.syncNextSend=now+0.04
|
|
end
|
|
|
|
local function dominantEnemyGUID()
|
|
return D.currentEnemyBestGuid or ""
|
|
end
|
|
|
|
local function buildSyncSnapshot(nonce,target)
|
|
if not D.inCombat or not nonce or not target then return end
|
|
local replyChannel=syncChannel()
|
|
if not replyChannel then return end
|
|
local age=0
|
|
if D.startTime and D.startTime>0 then age=GetTime()-D.startTime end
|
|
if age<0 then age=0 end
|
|
|
|
-- Snapshot values are read now and queued immediately after the header.
|
|
-- The receiver buffers the complete snapshot and merges it atomically at Z.
|
|
queueSync("H~"..syncField(nonce).."~"..tostring(math.floor(age*10)).."~"..syncField(dominantEnemyGUID()).."~"..syncField(currentFightLabel()).."~"..D.syncEnemySet(),replyChannel,nil)
|
|
|
|
local key,a
|
|
for key,a in D.actors do
|
|
queueSync("A~"..syncField(nonce).."~"..syncField(a.key or key).."~"..syncField(a.name).."~"..syncField(a.guid).."~"..syncField(a.ownerKey).."~"..(a.isPet and "1" or "0").."~"..syncField(a.classToken).."~"..tostring(math.floor(a.damage or 0)).."~"..tostring(math.floor(a.healing or 0)).."~"..tostring(math.floor(a.hits or 0)).."~"..tostring(math.floor(a.crits or 0)).."~"..tostring(math.floor(a.heals or 0)).."~"..tostring(math.floor(a.healCrits or 0)),replyChannel,nil)
|
|
local spell,s
|
|
for spell,s in a.spells do
|
|
queueSync("D~"..syncField(nonce).."~"..syncField(a.key or key).."~"..syncField(spell).."~"..tostring(math.floor(s.damage or 0)).."~"..tostring(math.floor(s.hits or 0)).."~"..tostring(math.floor(s.crits or 0)).."~"..syncField(s.spellId),replyChannel,nil)
|
|
end
|
|
local hname,h
|
|
for hname,h in a.healSpells do
|
|
local message="E~"..syncField(nonce).."~"..syncField(a.key or key).."~"..syncField(hname).."~"..tostring(math.floor(h.healing or 0)).."~"..tostring(math.floor(h.hits or 0)).."~"..tostring(math.floor(h.crits or 0)).."~"..syncField(h.spellId)
|
|
if h.overhealTotal~=nil then
|
|
local extended=message
|
|
for _,field in ipairs(D.overhealFields) do extended=extended.."~"..tostring(math.floor(h[field] or 0)) end
|
|
-- Older clients ignore trailing fields. Never drop the original
|
|
-- healing packet if the optional extension would exceed its cap.
|
|
if string.len(extended)<=240 then message=extended end
|
|
end
|
|
queueSync(message,replyChannel,nil)
|
|
end
|
|
end
|
|
queueSync("Z~"..syncField(nonce),replyChannel,nil)
|
|
end
|
|
|
|
local function syncOfferCount()
|
|
local n=0; local k,v
|
|
for k,v in D.syncOffers do n=n+1 end
|
|
return n
|
|
end
|
|
|
|
local function captureSyncBaseline()
|
|
local base={actors={}}
|
|
local key,a
|
|
for key,a in D.actors do
|
|
local b={
|
|
damage=a.damage or 0, healing=a.healing or 0,
|
|
hits=a.hits or 0, crits=a.crits or 0,
|
|
heals=a.heals or 0, healCrits=a.healCrits or 0,
|
|
spells={}, healSpells={}
|
|
}
|
|
local spell,s
|
|
for spell,s in a.spells do
|
|
b.spells[spell]={damage=s.damage or 0,hits=s.hits or 0,crits=s.crits or 0,spellId=s.spellId}
|
|
end
|
|
local hname,h
|
|
for hname,h in a.healSpells do
|
|
b.healSpells[hname]={healing=h.healing or 0,hits=h.hits or 0,crits=h.crits or 0,spellId=h.spellId}
|
|
for _,field in ipairs(D.overhealFields) do b.healSpells[hname][field]=h[field] end
|
|
end
|
|
base.actors[key]=b
|
|
end
|
|
return base
|
|
end
|
|
|
|
local function syncNumber(v)
|
|
return tonumber(v) or 0
|
|
end
|
|
|
|
local function syncMergedValue(remote,baseline,current)
|
|
remote=syncNumber(remote); baseline=syncNumber(baseline); current=syncNumber(current)
|
|
local floorValue=baseline
|
|
if remote>floorValue then floorValue=remote end
|
|
local delta=current-baseline
|
|
if delta<0 then delta=0 end
|
|
return floorValue+delta
|
|
end
|
|
|
|
local function applyBufferedSnapshot(incoming)
|
|
if not incoming or not incoming.actors then return false end
|
|
local baseline=incoming.baseline or {actors={}}
|
|
local key,r
|
|
for key,r in incoming.actors do
|
|
local a=getActor(key,r.name,r.guid,r.ownerKey,r.isPet,r.classToken)
|
|
local b=baseline.actors[key] or {spells={},healSpells={}}
|
|
a.damage=syncMergedValue(r.damage,b.damage,a.damage)
|
|
a.healing=syncMergedValue(r.healing,b.healing,a.healing)
|
|
a.hits=syncMergedValue(r.hits,b.hits,a.hits)
|
|
a.crits=syncMergedValue(r.crits,b.crits,a.crits)
|
|
a.heals=syncMergedValue(r.heals,b.heals,a.heals)
|
|
a.healCrits=syncMergedValue(r.healCrits,b.healCrits,a.healCrits)
|
|
|
|
local spell,rs
|
|
for spell,rs in r.spells do
|
|
local live=a.spells[spell]
|
|
if not live then live={damage=0,hits=0,crits=0}; a.spells[spell]=live end
|
|
if rs.spellId and not live.spellId then live.spellId=tonumber(rs.spellId) end
|
|
local bs=b.spells and b.spells[spell] or nil
|
|
if not bs then bs={damage=0,hits=0,crits=0} end
|
|
live.damage=syncMergedValue(rs.damage,bs.damage,live.damage)
|
|
live.hits=syncMergedValue(rs.hits,bs.hits,live.hits)
|
|
live.crits=syncMergedValue(rs.crits,bs.crits,live.crits)
|
|
end
|
|
|
|
local hname,rh
|
|
for hname,rh in r.healSpells do
|
|
local live=a.healSpells[hname]
|
|
if not live then live={healing=0,hits=0,crits=0}; a.healSpells[hname]=live end
|
|
if rh.spellId and not live.spellId then live.spellId=tonumber(rh.spellId) end
|
|
local bh=b.healSpells and b.healSpells[hname] or nil
|
|
if not bh then bh={healing=0,hits=0,crits=0} end
|
|
live.healing=syncMergedValue(rh.healing,bh.healing,live.healing)
|
|
live.hits=syncMergedValue(rh.hits,bh.hits,live.hits)
|
|
live.crits=syncMergedValue(rh.crits,bh.crits,live.crits)
|
|
if rh.overhealTotal~=nil then
|
|
for _,field in ipairs(D.overhealFields) do live[field]=syncMergedValue(rh[field],bh[field],live[field]) end
|
|
end
|
|
end
|
|
|
|
-- Keep aggregate actor totals consistent with the spell breakdown
|
|
-- after independent baseline+delta network merges.
|
|
local sumDamage=0; local sumHits=0; local sumCrits=0
|
|
local sn,sv
|
|
for sn,sv in a.spells do
|
|
sumDamage=sumDamage+(sv.damage or 0)
|
|
sumHits=sumHits+(sv.hits or 0)
|
|
sumCrits=sumCrits+(sv.crits or 0)
|
|
end
|
|
a.damage=sumDamage; a.hits=sumHits; a.crits=sumCrits
|
|
|
|
local sumHealing=0; local sumHeals=0; local sumHealCrits=0
|
|
for sn,sv in a.healSpells do
|
|
sumHealing=sumHealing+(sv.healing or 0)
|
|
sumHeals=sumHeals+(sv.hits or 0)
|
|
sumHealCrits=sumHealCrits+(sv.crits or 0)
|
|
end
|
|
a.healing=sumHealing; a.heals=sumHeals; a.healCrits=sumHealCrits
|
|
-- Derive optional totals from the spell records that actually carry
|
|
-- structured coverage; legacy snapshots cannot erase known data.
|
|
for _,field in ipairs(D.overhealFields) do
|
|
local sum=nil
|
|
for _,h in pairs(a.healSpells) do if h[field]~=nil then sum=(sum or 0)+h[field] end end
|
|
a[field]=sum
|
|
end
|
|
end
|
|
|
|
-- Preserve the older source's fight age. Time spent receiving the snapshot
|
|
-- is added so DPS/HPS duration does not become artificially short.
|
|
local remoteAge=incoming.remoteAge or 0
|
|
if incoming.headerReceivedAt then remoteAge=remoteAge+(GetTime()-incoming.headerReceivedAt) end
|
|
local localAge=0
|
|
if D.startTime and D.startTime>0 then localAge=GetTime()-D.startTime end
|
|
if remoteAge>localAge and remoteAge>D.syncBestAge then
|
|
D.setFightStartTime(GetTime()-remoteAge)
|
|
D.syncBestAge=remoteAge
|
|
end
|
|
if incoming.fightName and incoming.fightName~="" and (not D.currentFightName or D.currentFightName=="Current" or D.currentFightName=="Unknown Enemy") then
|
|
D.currentFightName=incoming.fightName
|
|
end
|
|
return true
|
|
end
|
|
|
|
requestCombatSync=function(force)
|
|
if not D.combatSyncEnabled() then return end
|
|
if D.syncRequested and not force then return end
|
|
if not D.inCombat and not force then return end
|
|
if D.syncEnemySet and D.syncEnemySet()=="" then return end
|
|
if D.syncIncoming and GetTime()-(D.syncIncoming.headerReceivedAt or 0)<8 then return end
|
|
local ch=syncChannel()
|
|
D.syncRequested=true
|
|
D.syncRequestSent=false
|
|
D.syncOffers={}
|
|
D.syncOfferDeadline=0
|
|
D.syncSelectedSource=nil
|
|
D.syncIncoming=nil
|
|
if not ch then
|
|
D.syncLastError="Not in PARTY/RAID"
|
|
D.syncLastChannel="none"
|
|
return
|
|
end
|
|
D.syncNonce=tostring(math.floor(GetTime()*1000)).."-"..syncField(UnitName("player") or "player")
|
|
if sendSyncNow("R~"..D.syncNonce.."~"..D.syncEnemySet(),ch,nil) then
|
|
D.syncRequestSent=true
|
|
end
|
|
end
|
|
|
|
D.syncEnemySet=function()
|
|
local set={}; local guid,amount; local n=0
|
|
for guid,amount in D.currentEnemyDamage do
|
|
if not D.deadEnemies[guid] then set[guid]=true end
|
|
end
|
|
local target=safeUnitGUID("target")
|
|
if target and isHostileUnit("target") then set[target]=true end
|
|
local out=""
|
|
for guid in set do
|
|
if n<8 then if out~="" then out=out.."," end; out=out..guid; n=n+1 end
|
|
end
|
|
return out
|
|
end
|
|
D.syncEnemyCompatible=function(remote)
|
|
if not remote or remote=="" then return false end
|
|
local own=","..D.syncEnemySet()..","; local guid
|
|
for guid in string.gfind(remote,"(0x[%x]+)") do
|
|
if string.find(own,","..guid..",",1,true) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
finalizeSyncOfferSelection=function()
|
|
if not D.syncNonce or D.syncSelectedSource or (D.syncOfferDeadline or 0)<=0 then return end
|
|
if GetTime()<D.syncOfferDeadline then return end
|
|
|
|
local bestName=nil; local bestAge=-1; local name,offer
|
|
for name,offer in D.syncOffers do
|
|
local age=offer.age or 0
|
|
if D.syncEnemyCompatible(offer.enemies) and age>bestAge then bestAge=age; bestName=name end
|
|
end
|
|
D.syncOfferDeadline=0
|
|
if not bestName then return end
|
|
|
|
D.syncSelectedSource=bestName
|
|
local ch=syncChannel()
|
|
if ch then
|
|
queueSync("P~"..syncField(D.syncNonce).."~"..syncField(bestName).."~"..D.syncEnemySet(),ch,nil)
|
|
end
|
|
end
|
|
|
|
local function applySyncMessage(sender,msg,channel)
|
|
if not syncRosterSender(sender) then return end
|
|
local p=splitSync(msg)
|
|
local kind=p[1]
|
|
if kind=="V" then
|
|
if D.talentViewReceive then D.talentViewReceive(sender,p,channel) end
|
|
return
|
|
end
|
|
if kind=="K" then
|
|
if D.talentSyncReceive then D.talentSyncReceive(sender,p,channel) end
|
|
return
|
|
end
|
|
-- P~1~GUID announces a peer; P~nonce~name selects a combat source.
|
|
-- Combat nonces contain a timestamp and player name, never just "1".
|
|
-- Keep both wire formats so existing clients can still request snapshots.
|
|
if kind=="P" and p[2]=="1" then
|
|
if D.threatPeerReceive then D.threatPeerReceive(sender,p,channel) end
|
|
return
|
|
end
|
|
if kind=="T" then
|
|
if D.threatSyncReceive then D.threatSyncReceive(sender,p,channel) end
|
|
return
|
|
end
|
|
if kind=="Y" then
|
|
if D.targetSyncReceive then D.targetSyncReceive(sender,p,channel) end
|
|
return
|
|
end
|
|
if not D.combatSyncEnabled() then return end
|
|
local selfName=UnitName("player") or ""
|
|
|
|
-- Phase 1: responders advertise only their current fight age. This avoids
|
|
-- every Caw client blasting a full snapshot into a raid simultaneously.
|
|
if kind=="R" then
|
|
D.syncRequestsSeen=(D.syncRequestsSeen or 0)+1
|
|
local nonce=p[2]
|
|
if sender==selfName then return end
|
|
if nonce and D.inCombat and D.startTime and D.startTime>0 and D.syncEnemyCompatible(p[3]) then
|
|
local age=GetTime()-D.startTime; if age<0 then age=0 end
|
|
local ch=syncChannel()
|
|
if ch then
|
|
queueSync("O~"..syncField(nonce).."~"..tostring(math.floor(age*10)).."~"..syncField(dominantEnemyGUID()).."~"..syncField(currentFightLabel()).."~"..D.syncEnemySet(),ch,nil)
|
|
end
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Phase 2: requester gathers offers for 0.30s and then chooses the oldest
|
|
-- available source. Other responders remain silent.
|
|
if kind=="O" then
|
|
if not D.syncNonce or p[2]~=D.syncNonce or sender==selfName then return end
|
|
if not D.inCombat or not D.syncEnemyCompatible(p[6]) then return end
|
|
D.syncOffers[sender]={age=(tonumber(p[3]) or 0)/10,enemy=p[4],fightName=p[5],enemies=p[6]}
|
|
if (D.syncOfferDeadline or 0)<=0 then D.syncOfferDeadline=GetTime()+0.30 end
|
|
return
|
|
end
|
|
|
|
if kind=="P" then
|
|
local nonce=p[2]; local selected=p[3]
|
|
if nonce and selected and selected==selfName and sender~=selfName and D.inCombat and D.startTime and D.startTime>0 and D.syncEnemyCompatible(p[4]) then
|
|
buildSyncSnapshot(nonce,sender)
|
|
end
|
|
return
|
|
end
|
|
|
|
if not D.inCombat or not D.syncNonce or p[2]~=D.syncNonce then return end
|
|
if not D.syncSelectedSource or sender~=D.syncSelectedSource then
|
|
D.syncRejectedSources=(D.syncRejectedSources or 0)+1
|
|
return
|
|
end
|
|
|
|
-- Phase 3: buffer one selected source atomically.
|
|
if kind=="H" then
|
|
if not D.syncEnemyCompatible(p[6]) then D.syncIncoming=nil; return end
|
|
D.syncIncoming={
|
|
segmentSerial=D.segmentSerial,
|
|
source=sender,
|
|
remoteAge=(tonumber(p[3]) or 0)/10,
|
|
enemy=p[4],
|
|
fightName=p[5],
|
|
headerReceivedAt=GetTime(),
|
|
baseline=captureSyncBaseline(),
|
|
actors={}
|
|
}
|
|
D.syncLastSource=sender
|
|
return
|
|
end
|
|
|
|
local incoming=D.syncIncoming
|
|
if not incoming or incoming.source~=sender then return end
|
|
if incoming.segmentSerial~=D.segmentSerial then D.syncIncoming=nil; return end
|
|
|
|
if kind=="A" then
|
|
local key=p[3]; if not key or key=="" then return end
|
|
local guid=p[5]; if guid=="" then guid=nil end
|
|
local owner=p[6]; if owner=="" then owner=nil end
|
|
local classToken=p[8]; if classToken=="" then classToken=nil end
|
|
local r=incoming.actors[key]
|
|
if not r then r={spells={},healSpells={}}; incoming.actors[key]=r end
|
|
r.name=p[4]; r.guid=guid; r.ownerKey=owner; r.isPet=(p[7]=="1"); r.classToken=classToken
|
|
r.damage=p[9]; r.healing=p[10]; r.hits=p[11]; r.crits=p[12]; r.heals=p[13]; r.healCrits=p[14]
|
|
return
|
|
end
|
|
|
|
if kind=="D" then
|
|
local key=p[3]; local spell=p[4]; if not key or not spell then return end
|
|
local r=incoming.actors[key]
|
|
if not r then r={spells={},healSpells={}}; incoming.actors[key]=r end
|
|
r.spells[spell]={damage=p[5],hits=p[6],crits=p[7],spellId=tonumber(p[8])}
|
|
return
|
|
end
|
|
|
|
if kind=="E" then
|
|
local key=p[3]; local spell=p[4]; if not key or not spell then return end
|
|
local r=incoming.actors[key]
|
|
if not r then r={spells={},healSpells={}}; incoming.actors[key]=r end
|
|
r.healSpells[spell]={healing=p[5],hits=p[6],crits=p[7],spellId=tonumber(p[8])}
|
|
local over,gross,hits,crits=tonumber(p[9]),tonumber(p[10]),tonumber(p[11]),tonumber(p[12])
|
|
if over and gross and hits and crits and over>=0 and gross>=over and gross<math.huge
|
|
and hits>=1 and hits<math.huge and hits==math.floor(hits) and crits>=0 and crits<=hits and crits==math.floor(crits) then
|
|
local h=r.healSpells[spell]
|
|
h.overhealing=over; h.overhealTotal=gross; h.overhealHits=hits; h.overhealCrits=crits
|
|
end
|
|
return
|
|
end
|
|
|
|
if kind=="Z" then
|
|
if applyBufferedSnapshot(incoming) then
|
|
D.syncReceived=(D.syncReceived or 0)+1
|
|
D.syncLastSource=sender
|
|
if D.localPlayerDead then
|
|
D.deadSyncLastReceived=GetTime()
|
|
if D.pendingCombatEndAt and D.pendingCombatEndAt>0 then
|
|
D.pendingCombatEndAt=GetTime()+2.00
|
|
D.pendingCombatEndStopTime=GetTime()
|
|
end
|
|
end
|
|
end
|
|
D.syncIncoming=nil
|
|
return
|
|
end
|
|
end
|
|
|
|
local function actorFromSourceToken(token)
|
|
if token=="You" or token=="your" or token=="Your" then return D.guidToActor[safeUnitGUID("player") or D.selfKey] end
|
|
return D.guidToActor[token]
|
|
end
|
|
local function ignoreOutsideRoster(source)
|
|
if source and string.find(source,"^0x") and not D.guidToActor[source] then D.ignoredOutsiders=D.ignoredOutsiders+1; return true end
|
|
return false
|
|
end
|
|
local function isCritText(text)
|
|
if string.find(text," crits ",1,true) or string.find(text," crit ",1,true) or string.find(text," critically ",1,true) then return true end
|
|
return false
|
|
end
|
|
local function ringCapture(tbl,countField,ev,text)
|
|
D[countField]=D[countField]+1; local idx=math.mod(D[countField]-1,20)+1; tbl[idx]=tostring(ev).." | "..tostring(text)
|
|
end
|
|
local function captureUnknown(ev,text) ringCapture(D.rawUnknown,"rawUnknownCount",ev,text) end
|
|
local function captureUtilityUnknown(ev,text) ringCapture(D.utilityUnknown,"utilityUnknownCount",ev,text) end
|
|
|
|
local function captureRawDebug(ev,text)
|
|
if not D.rawDebugEnabled then return end
|
|
D.rawDebugCount=D.rawDebugCount+1
|
|
if D.rawDebugCount<=80 then
|
|
D.rawDebugLines[D.rawDebugCount]=tostring(ev).." | "..tostring(text)
|
|
end
|
|
end
|
|
|
|
-- Threat diagnostic only: capture evidence without estimating threat yet.
|
|
-- Kept on D to avoid adding more top-level locals on the old Lua 5.0 client.
|
|
D.captureThreatDebug = function(kind,text)
|
|
if not D.threatDebugEnabled then return end
|
|
D.threatDebugCount=(D.threatDebugCount or 0)+1
|
|
if D.threatDebugCount<=160 then
|
|
D.threatDebugLines[D.threatDebugCount]=string.format("%.2f",GetTime()).." | "..tostring(kind).." | "..tostring(text)
|
|
end
|
|
end
|
|
|
|
D.captureThreatTargetState = function(force)
|
|
if not D.threatDebugEnabled then return end
|
|
local now=GetTime()
|
|
if not force and now<(D.threatDebugNextSnapshot or 0) then return end
|
|
D.threatDebugNextSnapshot=now+0.20
|
|
local tName=UnitName and UnitName("target") or nil
|
|
local tGuid=safeUnitGUID("target")
|
|
local ttName=UnitName and UnitName("targettarget") or nil
|
|
local ttGuid=safeUnitGUID("targettarget")
|
|
local state=tostring(tGuid or "nil").."/"..tostring(tName or "nil").." -> "..tostring(ttGuid or "nil").."/"..tostring(ttName or "nil")
|
|
if force or state~=(D.threatDebugLastTarget or "") then
|
|
D.threatDebugLastTarget=state
|
|
D.captureThreatDebug("TARGET",state)
|
|
end
|
|
end
|
|
|
|
-- Interrupt abilities supported by the Vanilla/RavenCraft ruleset.
|
|
-- RAW_COMBATLOG often does not emit a separate "interrupts" line, so a landed
|
|
-- interrupt ability is correlated with the target's current "begins to cast" entry.
|
|
local INTERRUPT_SPELLS = {
|
|
["Kick"]=true,
|
|
["Pummel"]=true,
|
|
["Shield Bash"]=true,
|
|
["Counterspell"]=true,
|
|
["Earth Shock"]=true,
|
|
["Feral Charge"]=true,
|
|
["Feral Charge - Bear"]=true,
|
|
["Spell Lock"]=true,
|
|
["Silence"]=true
|
|
}
|
|
|
|
-- RavenCraft reports an interrupted enemy cast as UNIT_CASTEVENT FAIL at the
|
|
-- same timestamp as the player's Kick/Pummel hit. Keep that cast alive for a
|
|
-- short hand-off window so the damage line can still prove the interrupt.
|
|
-- A normal failed cast is discarded by tryRecordInterrupt once this window
|
|
-- expires, so it cannot turn a later Kick into a false interrupt.
|
|
local INTERRUPT_FAIL_GRACE=0.75
|
|
|
|
-- Common Vanilla/RavenCraft dispel/cleanse abilities. RavenCraft can log a
|
|
-- successful self-dispel as "Your <aura> is removed." followed by "You cast X."
|
|
-- rather than an explicit "X dispels Y" combat-log line.
|
|
local DISPEL_SPELLS = {
|
|
["Purify"]=true,
|
|
["Cleanse"]=true,
|
|
["Dispel Magic"]=true,
|
|
["Purge"]=true,
|
|
["Remove Curse"]=true,
|
|
["Remove Lesser Curse"]=true,
|
|
["Abolish Disease"]=true,
|
|
["Cure Disease"]=true,
|
|
["Abolish Poison"]=true,
|
|
["Cure Poison"]=true,
|
|
["Devour Magic"]=true
|
|
}
|
|
|
|
local function rememberEnemyCast(target,spell)
|
|
if not target or not spell then return end
|
|
D.activeEnemyCasts[target]={spell=spell,time=GetTime()}
|
|
end
|
|
local function clearEnemyCastOnResult(source,spell)
|
|
if not source or not spell then return end
|
|
local cast=D.activeEnemyCasts[source]
|
|
if cast and cast.spell==spell then D.activeEnemyCasts[source]=nil end
|
|
end
|
|
|
|
local function utilityActor(info)
|
|
if not info then return nil end
|
|
return getActor(info.key,info.name,info.guid,info.ownerKey,info.isPet,info.classToken)
|
|
end
|
|
|
|
D.captureIncomingDamage = function(ev,text)
|
|
if not ev or not text then return false end
|
|
-- Every supported incoming hit contains one of these literal fragments.
|
|
-- Aura, miss and cast messages need none of the damage-pattern scans.
|
|
if not string.find(text," for ",1,true)
|
|
and not string.find(text," damage from ",1,true) then return false end
|
|
|
|
local targetInfo=nil
|
|
local sourceGuid=nil
|
|
local ability=nil
|
|
local amount=nil
|
|
local crit=false
|
|
local targetGuid=nil
|
|
local isSelfTarget=false
|
|
local _,_,a,b,c,d
|
|
|
|
-- Only direct self-target hits can match the first four patterns.
|
|
if string.find(text," you for ",1,true) then
|
|
-- Enemy melee -> player.
|
|
_,_,sourceGuid,amount=string.find(text,"^(0x[%x]+) hits you for ([0-9]+)")
|
|
if not amount then
|
|
_,_,sourceGuid,amount=string.find(text,"^(0x[%x]+) crits you for ([0-9]+)")
|
|
if amount then crit=true end
|
|
end
|
|
if amount then
|
|
isSelfTarget=true
|
|
targetInfo=D.guidToActor[safeUnitGUID("player") or D.selfKey]
|
|
targetGuid=targetInfo and (targetInfo.guid or targetInfo.key) or nil
|
|
ability="Melee"
|
|
end
|
|
|
|
-- Enemy spell/ability -> player.
|
|
if not amount then
|
|
_,_,sourceGuid,ability,amount=string.find(text,"^(0x[%x]+)'s (.-) hits you for ([0-9]+)")
|
|
if not amount then
|
|
_,_,sourceGuid,ability,amount=string.find(text,"^(0x[%x]+)'s (.-) crits you for ([0-9]+)")
|
|
if amount then crit=true end
|
|
end
|
|
if amount then
|
|
isSelfTarget=true
|
|
targetInfo=D.guidToActor[safeUnitGUID("player") or D.selfKey]
|
|
targetGuid=targetInfo and (targetInfo.guid or targetInfo.key) or nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- GUID -> roster member melee.
|
|
if not amount then
|
|
_,_,sourceGuid,targetGuid,amount=string.find(text,"^(0x[%x]+) hits (0x[%x]+) for ([0-9]+)")
|
|
if not amount then
|
|
_,_,sourceGuid,targetGuid,amount=string.find(text,"^(0x[%x]+) crits (0x[%x]+) for ([0-9]+)")
|
|
if amount then crit=true end
|
|
end
|
|
if amount then
|
|
targetInfo=D.guidToActor[targetGuid]
|
|
ability="Melee"
|
|
end
|
|
end
|
|
|
|
-- GUID -> roster member spell/ability.
|
|
if not amount then
|
|
_,_,sourceGuid,ability,targetGuid,amount=string.find(text,"^(0x[%x]+)'s (.-) hits (0x[%x]+) for ([0-9]+)")
|
|
if not amount then
|
|
_,_,sourceGuid,ability,targetGuid,amount=string.find(text,"^(0x[%x]+)'s (.-) crits (0x[%x]+) for ([0-9]+)")
|
|
if amount then crit=true end
|
|
end
|
|
if amount then targetInfo=D.guidToActor[targetGuid] end
|
|
end
|
|
|
|
-- Periodic damage -> player.
|
|
if not amount then
|
|
_,_,amount,sourceGuid,ability=string.find(text,"^You suffer ([0-9]+) .- damage from (0x[%x]+)'s (.-)%.")
|
|
if amount then
|
|
isSelfTarget=true
|
|
targetInfo=D.guidToActor[safeUnitGUID("player") or D.selfKey]
|
|
targetGuid=targetInfo and (targetInfo.guid or targetInfo.key) or nil
|
|
end
|
|
end
|
|
|
|
-- Periodic damage -> roster member.
|
|
if not amount then
|
|
_,_,targetGuid,amount,sourceGuid,ability=string.find(text,"^(0x[%x]+) suffers ([0-9]+) .- damage from (0x[%x]+)'s (.-)%.")
|
|
if amount then targetInfo=D.guidToActor[targetGuid] end
|
|
end
|
|
|
|
if not amount or not targetInfo or targetInfo.isPet then return false end
|
|
|
|
amount=tonumber(amount) or 0
|
|
if amount<=0 then return false end
|
|
|
|
-- After the local player dies, preserve the death segment. Once its
|
|
-- combat-end grace has finalized, continuing party combat must not create
|
|
-- a fresh "Current" segment and visually erase Damage Taken / Deaths.
|
|
if D.localPlayerDead and not D.inCombat then return false end
|
|
|
|
-- Group members can be observed taking damage even when this client never
|
|
-- enters personal combat. That incoming roster damage is valid encounter
|
|
-- activity and may open the segment while the local player is alive.
|
|
if not ensureStarted() then return false end
|
|
D.lastRosterCombatActivity=GetTime()
|
|
|
|
local actor=utilityActor(targetInfo)
|
|
if not actor then return false end
|
|
|
|
local sourceName=nil
|
|
if sourceGuid then
|
|
sourceName=D.currentEnemyNames[sourceGuid] or D.enemyNameFromGUID(sourceGuid)
|
|
if sourceName then D.currentEnemyNames[sourceGuid]=sourceName end
|
|
end
|
|
if not sourceName then sourceName=sourceGuid or "Environment" end
|
|
if not ability or ability=="" then ability="Melee" end
|
|
|
|
-- Aura application lines often omit their caster on Vanilla. Once a later
|
|
-- tick/hit gives us a resolvable enemy name, attach it to an already tracked
|
|
-- Debuffs Received entry with the same spell. This backfills the tooltip
|
|
-- without guessing and never creates a debuff from damage alone.
|
|
if sourceName and not string.find(sourceName,"^0x[%x]+$") then
|
|
D.backfillAuraSource(actor.debuffsReceived,ability,sourceName,sourceGuid or sourceName)
|
|
end
|
|
|
|
-- For periodic harmful auras, keep the same compact per-source breakdown
|
|
-- as HoT buffs: source -> actual damage dealt by this debuff. Direct hits
|
|
-- are deliberately excluded so a spell's impact damage is not presented
|
|
-- as DoT/debuff damage.
|
|
if sourceName and not string.find(sourceName,"^0x[%x]+$") and
|
|
(string.find(text,"^You suffer ") or string.find(text,"^0x[%x]+ suffers ")) then
|
|
D.addAuraSourceDamage(actor.debuffsReceived,ability,sourceName,sourceGuid or sourceName,amount)
|
|
end
|
|
|
|
-- Only trust overkill when the combat text explicitly exposes it.
|
|
local overkill=nil
|
|
_,_,overkill=string.find(text,"%(([0-9]+) overkill%)")
|
|
if not overkill then _,_,overkill=string.find(text,"%(([0-9]+) Overkill%)") end
|
|
if not overkill then _,_,overkill=string.find(text,"overkill:? ([0-9]+)") end
|
|
if overkill then overkill=tonumber(overkill) end
|
|
|
|
actor.damageTaken=(actor.damageTaken or 0)+amount
|
|
if not actor.damageTakenSpells then actor.damageTakenSpells={} end
|
|
local detail=sourceName.." - "..ability
|
|
local entry=actor.damageTakenSpells[detail]
|
|
if not entry then entry={damage=0,hits=0,crits=0,critDamage=0,maxCrit=0,overkill=0,overkillKnown=0}; actor.damageTakenSpells[detail]=entry end
|
|
entry.damage=(entry.damage or 0)+amount
|
|
entry.hits=(entry.hits or 0)+1
|
|
if crit then
|
|
entry.crits=(entry.crits or 0)+1
|
|
entry.critDamage=(entry.critDamage or 0)+amount
|
|
if amount>(entry.maxCrit or 0) then entry.maxCrit=amount end
|
|
end
|
|
if overkill~=nil then
|
|
entry.overkill=(entry.overkill or 0)+overkill
|
|
entry.overkillKnown=(entry.overkillKnown or 0)+1
|
|
end
|
|
|
|
local hit={
|
|
targetKey=actor.key,
|
|
sourceGuid=sourceGuid,
|
|
sourceName=sourceName,
|
|
ability=ability,
|
|
amount=amount,
|
|
crit=crit,
|
|
overkill=overkill,
|
|
time=GetTime()
|
|
}
|
|
D.lastIncomingDamage[actor.key]=hit
|
|
if isSelfTarget then
|
|
D.lastSelfIncomingDamage=hit
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- An enemy dying is often the last time we ever hear about its GUID. Drop any
|
|
-- control / cast state bound to it so a CC whose "fades from" line never
|
|
-- arrives (target killed while stunned) cannot block combat end.
|
|
D.clearDeadEnemyState = function(ev,text)
|
|
if not text then return end
|
|
local _,_,guid=string.find(text,"^(0x[%x]+) dies%.$")
|
|
if not guid then return end
|
|
if D.summonActors[guid] then
|
|
D.summonActors[guid]=nil; D.guidToActor[guid]=nil; D.petOwner[guid]=nil
|
|
return
|
|
end
|
|
if D.guidToActor and D.guidToActor[guid] then return end
|
|
D.deadEnemies[guid]=true
|
|
if D.activeCC[guid] then
|
|
D.logLine("cc","enemy died, purging CC "..tostring(D.activeCC[guid].spell).." on "..tostring(guid))
|
|
D.activeCC[guid]=nil
|
|
end
|
|
if D.ccDamageByTarget then D.ccDamageByTarget[guid]=nil end
|
|
if D.activeEnemyCasts then D.activeEnemyCasts[guid]=nil end
|
|
end
|
|
|
|
D.captureFriendlyDeath = function(ev,text)
|
|
if not text or text=="" then return false end
|
|
if not string.find(text,"die",1,true) and not string.find(text,"dies",1,true) then return false end
|
|
|
|
local targetInfo=nil
|
|
local targetGuid=nil
|
|
local deadName=nil
|
|
local selfDeath=false
|
|
local _,_,token
|
|
|
|
if string.find(text,"^You die%.$") or string.find(text,"^You have died%.$") then
|
|
selfDeath=true
|
|
D.localPlayerDead=true
|
|
D.deadSyncLastReceived=0
|
|
D.deadSyncNextRequest=GetTime()
|
|
targetInfo=D.guidToActor[safeUnitGUID("player") or D.selfKey]
|
|
else
|
|
_,_,targetGuid=string.find(text,"^(0x[%x]+) dies%.$")
|
|
if targetGuid then
|
|
targetInfo=D.guidToActor[targetGuid]
|
|
else
|
|
_,_,deadName=string.find(text,"^(.-) dies%.$")
|
|
if deadName and deadName~="" then
|
|
local k,info
|
|
for k,info in D.guidToActor do
|
|
if info and not info.isPet and info.name==deadName then targetInfo=info; break end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if not targetInfo or targetInfo.isPet then return false end
|
|
if D.localPlayerDead and not selfDeath
|
|
and (not D.inCombat or not D.startTime or D.startTime<=0) then
|
|
return false
|
|
end
|
|
if not D.inCombat or not D.startTime or D.startTime<=0 then if not ensureStarted() then return false end end
|
|
D.lastRosterCombatActivity=GetTime()
|
|
|
|
local actor=utilityActor(targetInfo)
|
|
if not actor then return false end
|
|
|
|
local now=GetTime()
|
|
local lastStamp=D.lastDeathStamp[actor.key] or 0
|
|
if now-lastStamp<1.0 then return true end
|
|
D.lastDeathStamp[actor.key]=now
|
|
|
|
local hit=D.lastIncomingDamage[actor.key]
|
|
-- On RavenCraft the player GUID/actor key can transiently disagree around
|
|
-- the death boundary. "You die." is authoritative, so fall back to the
|
|
-- separately captured last hit that explicitly targeted "you".
|
|
if selfDeath and (not hit or now-(hit.time or 0)>2.0) then
|
|
local selfHit=D.lastSelfIncomingDamage
|
|
if selfHit and now-(selfHit.time or 0)<=2.0 then hit=selfHit end
|
|
end
|
|
if hit and now-(hit.time or 0)>2.0 then hit=nil end
|
|
|
|
actor.deaths=(actor.deaths or 0)+1
|
|
if not actor.deathCauses then actor.deathCauses={} end
|
|
|
|
local sourceName=hit and hit.sourceName or "Unknown"
|
|
local ability=hit and hit.ability or "Unknown"
|
|
local damage=hit and (hit.amount or 0) or 0
|
|
local crit=hit and hit.crit or false
|
|
local overkill=hit and hit.overkill or nil
|
|
local causeName=sourceName.." - "..ability
|
|
local cause=actor.deathCauses[causeName]
|
|
if not cause then cause={count=0,damage=0,crits=0,overkill=0,overkillKnown=0}; actor.deathCauses[causeName]=cause end
|
|
cause.count=(cause.count or 0)+1
|
|
cause.damage=(cause.damage or 0)+damage
|
|
if crit then cause.crits=(cause.crits or 0)+1 end
|
|
if overkill~=nil then
|
|
cause.overkill=(cause.overkill or 0)+overkill
|
|
cause.overkillKnown=(cause.overkillKnown or 0)+1
|
|
end
|
|
|
|
actor.deathLast={
|
|
sourceName=sourceName,
|
|
ability=ability,
|
|
damage=damage,
|
|
crit=crit,
|
|
overkill=overkill,
|
|
time=now
|
|
}
|
|
|
|
-- If combat end is already pending, give this late death event time to
|
|
-- be committed into the same segment before snapshot/finalization.
|
|
if D.pendingCombatEndAt and D.pendingCombatEndAt>0 then
|
|
local minEnd=now+0.50
|
|
if not D.pendingCombatEndAt or D.pendingCombatEndAt<minEnd then D.pendingCombatEndAt=minEnd end
|
|
end
|
|
|
|
D.lastDeathRaw=tostring(ev).." | "..tostring(text)
|
|
D.lastDeathRecord=actor.name.." | "..causeName.." | "..tostring(damage).." dmg"
|
|
if overkill~=nil then D.lastDeathRecord=D.lastDeathRecord.." | "..tostring(overkill).." overkill" end
|
|
if crit then D.lastDeathRecord=D.lastDeathRecord.." | CRIT" end
|
|
return true
|
|
end
|
|
|
|
D.tryClaimSelfTotemSource = function(source,spell,ev)
|
|
if not source or not spell or ev~="CHAT_MSG_SPELL_PET_DAMAGE" then return nil end
|
|
if D.guidToActor[source] then return D.guidToActor[source] end
|
|
|
|
local p=D.pendingSelfTotem
|
|
if not p or not p.time or (GetTime()-p.time)>12 then return nil end
|
|
|
|
-- The last cast is only an ownership hint, never the source's identity.
|
|
-- Different elemental totems may be active at the same time.
|
|
local names={['Searing Bolt']='Searing Totem',['Fire Nova']='Fire Nova Totem',
|
|
['Magma Totem']='Magma Totem',['Magma Totem Effect']='Magma Totem'}
|
|
local totemName=names[spell]
|
|
if not totemName then return nil end
|
|
|
|
local owner=D.guidToActor[safeUnitGUID("player") or D.selfKey]
|
|
if not owner then return nil end
|
|
|
|
local info={
|
|
key=source,
|
|
name=totemName,
|
|
guid=source,
|
|
ownerKey=owner.key,
|
|
isPet=true,
|
|
classToken=nil,
|
|
isTotem=true
|
|
}
|
|
D.guidToActor[source]=info
|
|
D.summonActors[source]=info
|
|
D.petOwner[source]=owner.key
|
|
-- Keep the short-lived ownership hint for other simultaneous totem GUIDs.
|
|
return info
|
|
end
|
|
|
|
-- Item summons do not always emit SPELL_SUMMON on the vanilla combat-text
|
|
-- path. The item cast is visible as a UNIT_CASTEVENT from the player, while
|
|
-- the summoned creature later reports its own cast from a new GUID. Keep a
|
|
-- short ownership chain for those known pairs instead of assigning the source
|
|
-- to the normal Hunter pet or to the last totem.
|
|
D.itemSummonCasts = {
|
|
[4074]={name="Explosive Sheep",lifetime=20,petSpells={[4050]=true}},
|
|
}
|
|
D.itemPetSpells = {
|
|
[4050]={name="Explosive Sheep",summonSpell=4074},
|
|
}
|
|
|
|
function D.itemSummonSelfInfo()
|
|
local pg=safeUnitGUID("player") or D.selfKey
|
|
return pg and D.guidToActor and D.guidToActor[pg]
|
|
end
|
|
|
|
function D.itemSummonOwnerKey()
|
|
local owner=D.itemSummonSelfInfo()
|
|
return owner and (owner.key or owner.guid) or nil
|
|
end
|
|
|
|
function D.bindSelfItemSummon(source,definition)
|
|
if not source or not definition then return nil end
|
|
local owner=D.itemSummonSelfInfo(); if not owner then return nil end
|
|
local ownerKey=owner.key or owner.guid
|
|
if source==ownerKey or source==owner.guid then return nil end
|
|
local existing=D.guidToActor[source]
|
|
if existing and existing.ownerKey and existing.ownerKey~=ownerKey then return nil end
|
|
local info=existing or {key=source,guid=source}
|
|
info.name=definition.name; info.guid=source; info.key=source
|
|
info.ownerKey=ownerKey; info.isPet=true; info.classToken=nil
|
|
info.isTotem=false; info.itemSummon=true; info.itemSummonSpell=definition.summonSpell
|
|
D.guidToActor[source]=info; D.summonActors[source]=info; D.petOwner[source]=ownerKey
|
|
if D.logLine then D.logLine("pet","bound item summon "..tostring(definition.name).." "..tostring(source).." -> "..tostring(ownerKey)) end
|
|
return info
|
|
end
|
|
|
|
function D.observeItemSummonCast(casterGuid,targetGuid,castType,spellId)
|
|
local id=tonumber(spellId); if not id then return nil end
|
|
local now=GetTime()
|
|
local pg=safeUnitGUID("player") or D.selfKey
|
|
local ownerKey=D.itemSummonOwnerKey()
|
|
local summon=D.itemSummonCasts[id]
|
|
if summon and casterGuid==pg then
|
|
D.pendingItemSummons=D.pendingItemSummons or {}
|
|
table.insert(D.pendingItemSummons,{spellId=id,name=summon.name,time=now,ownerKey=ownerKey,
|
|
targetGuid=targetGuid,lifetime=summon.lifetime})
|
|
while table.getn(D.pendingItemSummons)>8 do table.remove(D.pendingItemSummons,1) end
|
|
if D.logLine then D.logLine("pet","item summon cast "..tostring(summon.name).." ("..tostring(id)..")") end
|
|
return true
|
|
end
|
|
local petSpell=D.itemPetSpells[id]
|
|
if not petSpell or not casterGuid or casterGuid==pg then return nil end
|
|
local list=D.pendingItemSummons or {}; local i=table.getn(list)
|
|
while i>=1 do
|
|
local pending=list[i]
|
|
local age=now-(pending.time or 0)
|
|
if age>(pending.lifetime or 20) then table.remove(list,i)
|
|
elseif not pending.used and pending.ownerKey==ownerKey
|
|
and (not pending.targetGuid or not targetGuid or pending.targetGuid==targetGuid) then
|
|
local definition={name=petSpell.name,summonSpell=petSpell.summonSpell}
|
|
local info=D.bindSelfItemSummon(casterGuid,definition)
|
|
if info then pending.used=true; return info end
|
|
end
|
|
i=i-1
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function D.tryClaimSelfItemSource(source,spell,ev)
|
|
if not source or not spell or D.guidToActor[source] then return D.guidToActor[source] end
|
|
local lowered=string.lower(tostring(spell))
|
|
local petSpell
|
|
if string.find(lowered,"explosive sheep",1,true) then petSpell=D.itemPetSpells[4050] end
|
|
if not petSpell then return nil end
|
|
local list=D.pendingItemSummons or {}; local now=GetTime(); local i=table.getn(list)
|
|
while i>=1 do
|
|
local pending=list[i]
|
|
if now-(pending.time or 0)>=(pending.lifetime or 20) then table.remove(list,i)
|
|
elseif not pending.used and pending.ownerKey==D.itemSummonOwnerKey() then
|
|
local info=D.bindSelfItemSummon(source,{name=petSpell.name,summonSpell=petSpell.summonSpell})
|
|
if info then pending.used=true; return info end
|
|
end
|
|
i=i-1
|
|
end
|
|
return nil
|
|
end
|
|
|
|
D.applyLivePlayerAuraDiff = function(ti,current)
|
|
if not ti or not current then return end
|
|
local a=utilityActor(ti)
|
|
local tk=a and (a.guid or a.key) or nil
|
|
if not a or not tk then return end
|
|
|
|
local name,v
|
|
for name,v in current do
|
|
if not D.playerBuffScanState[name] then startAura(a.buffs,name,tk) end
|
|
end
|
|
for name,v in D.playerBuffScanState do
|
|
if not current[name] then
|
|
stopAura(a.buffs,name,tk)
|
|
D.activeRosterBuffs["player|"..name]=nil
|
|
end
|
|
end
|
|
end
|
|
local function recordUtility(kind,sourceInfo,spell,targetToken)
|
|
if not ensureStarted() then return false end
|
|
local targetInfo=targetToken and D.guidToActor[targetToken] or nil
|
|
local a=utilityActor(sourceInfo)
|
|
if a and a[kind] then addCount(a[kind],spell,1) else addCount(D.globalUtility[kind],spell,1) end
|
|
D.lastUtility=(a and a.name or "Global").." "..kind.." "..tostring(spell)..(targetInfo and (" -> "..targetInfo.name) or "")
|
|
return true
|
|
end
|
|
D.recordUtility=recordUtility -- exposed for CawDPSLog.lua's DPSLog-driven dispel path
|
|
|
|
D.handleUnitCastAuraSource = function(casterGUID,targetGUID,eventType,spellId)
|
|
-- UNIT_CASTEVENT is the only reliable source for many direct, non-ticking
|
|
-- auras (for example Power Word: Shield). Cache the cast only; the later
|
|
-- RAW aura gain/afflicted message still proves that the aura actually landed.
|
|
if not casterGUID or not targetGUID or eventType~="CAST" or not spellId then return end
|
|
if not D.guidToActor or not D.guidToActor[targetGUID] then return end
|
|
if not SpellInfo then return end
|
|
local ok,spell=pcall(SpellInfo,spellId)
|
|
if not ok or not spell or spell=="" then return end
|
|
|
|
local sourceInfo=actorFromSourceToken(casterGUID)
|
|
if sourceInfo then
|
|
rememberAuraCast(sourceInfo,spell,targetGUID)
|
|
D.rememberAuraOrigin(sourceInfo.guid or sourceInfo.key or casterGUID,spell,targetGUID)
|
|
-- Some spells apply a secondary aura with a different name. Example:
|
|
-- Power Word: Shield -> Weakened Soul. The afflicted line contains no
|
|
-- caster, so keep the most recent roster cast for this exact target as
|
|
-- a short-lived causal fallback. Exact spell correlation still wins.
|
|
D.recentDirectRosterAuraCasts[targetGUID]={source=sourceInfo,spell=spell,time=GetTime()}
|
|
return
|
|
end
|
|
|
|
-- Hostile casts can also be the source of Debuffs Received. Keep an origin
|
|
-- only when the GUID can be resolved to a real enemy name; never expose a
|
|
-- raw GUID as tooltip text.
|
|
D.rememberAuraOrigin(casterGUID,spell,targetGUID)
|
|
end
|
|
|
|
D.handleUnitCastCC = function(casterGUID,targetGUID,eventType,spellId)
|
|
-- Keep the latest CAST visible in /cdcc. RavenCraft/SuperWoW builds can
|
|
-- differ in SpellInfo return shape, and this tells us exactly what this
|
|
-- client provides without changing the proven combat parser blindly.
|
|
if eventType=="CAST" then
|
|
D.lastCCCastEvent=tostring(casterGUID).." | "..tostring(targetGUID).." | "..tostring(eventType).." | "..tostring(spellId)
|
|
end
|
|
|
|
if not casterGUID or not targetGUID or not eventType or not spellId then return end
|
|
if eventType~="CAST" then return end
|
|
if not SpellInfo then
|
|
D.lastCCSpellInfo="SpellInfo unavailable"
|
|
return
|
|
end
|
|
|
|
local ok,s1,s2,s3,s4,s5=pcall(SpellInfo,spellId)
|
|
if ok then
|
|
D.lastCCSpellInfo=tostring(s1).." | "..tostring(s2).." | "..tostring(s3).." | "..tostring(s4).." | "..tostring(s5)
|
|
else
|
|
D.lastCCSpellInfo="ERROR: "..tostring(s1)
|
|
return
|
|
end
|
|
|
|
local spell=s1
|
|
if not spell or not CC_SPELLS[spell] then return end
|
|
|
|
local si=actorFromSourceToken(casterGUID)
|
|
if not si then return end
|
|
|
|
-- UNIT_CASTEVENT supplies the source GUID that the RAW aura line lacks.
|
|
-- Do not count here: the actual "is afflicted by" line proves the CC landed
|
|
-- and avoids counting resisted/immune/failed casts.
|
|
rememberAuraCast(si,spell,targetGUID)
|
|
end
|
|
|
|
D.captureCCDamageCandidate = function(ev,text)
|
|
if not D.inCombat or not ev or not text then return end
|
|
if not string.find(text," for ",1,true) then return end
|
|
|
|
local source=nil
|
|
local target=nil
|
|
local ability="Melee"
|
|
local namedAbility
|
|
|
|
-- Self melee/spell damage.
|
|
local _,_,selfTarget=string.find(text,"^You hit (0x[%x]+) for ")
|
|
if not selfTarget then _,_,selfTarget=string.find(text,"^You crit (0x[%x]+) for ") end
|
|
if not selfTarget then _,_,namedAbility,selfTarget=string.find(text,"^Your (.-) hits (0x[%x]+) for ") end
|
|
if not selfTarget then _,_,namedAbility,selfTarget=string.find(text,"^Your (.-) crits (0x[%x]+) for ") end
|
|
if selfTarget then
|
|
source=D.guidToActor[safeUnitGUID("player") or D.selfKey]
|
|
target=selfTarget
|
|
else
|
|
-- Group melee/spell damage. This is deliberately roster-only.
|
|
local _,_,guid,guidTarget=string.find(text,"^(0x[%x]+) hits (0x[%x]+) for ")
|
|
if not guid then _,_,guid,guidTarget=string.find(text,"^(0x[%x]+) crits (0x[%x]+) for ") end
|
|
if not guid then _,_,guid,namedAbility,guidTarget=string.find(text,"^(0x[%x]+)'s (.-) hits (0x[%x]+) for ") end
|
|
if not guid then _,_,guid,namedAbility,guidTarget=string.find(text,"^(0x[%x]+)'s (.-) crits (0x[%x]+) for ") end
|
|
if guid then
|
|
source=actorFromSourceToken(guid)
|
|
target=guidTarget
|
|
end
|
|
end
|
|
|
|
if source and target then
|
|
-- The successful source/target pattern already extracted the ability.
|
|
if namedAbility and namedAbility~="" then ability=namedAbility end
|
|
|
|
local _,_,breakAmount=string.find(text," for ([0-9]+)")
|
|
breakAmount=tonumber(breakAmount) or 0
|
|
local hit={source=source,target=target,ability=ability,amount=breakAmount,time=GetTime()}
|
|
D.lastCCDamage=hit
|
|
D.ccDamageByTarget[target]=hit
|
|
|
|
-- Bind the breaking hit to the active CC at damage time. RAW ordering
|
|
-- on RavenCraft is not guaranteed to leave D.activeCC intact until the
|
|
-- later AURA_GONE parser runs.
|
|
local active=D.activeCC[target]
|
|
if active then
|
|
active.breakSource=source
|
|
active.breakAbility=ability
|
|
active.breakAmount=breakAmount
|
|
active.breakTime=hit.time
|
|
end
|
|
end
|
|
end
|
|
|
|
D.recordInterrupt=function(sourceInfo,ability,target,interruptedSpell)
|
|
if not sourceInfo or not target then return false end
|
|
D.recentInterrupts=D.recentInterrupts or {}
|
|
local last=D.recentInterrupts[target]
|
|
if last and last.source==sourceInfo.key and last.ability==ability and GetTime()-last.time<0.50 then return true end
|
|
local cast=D.activeEnemyCasts[target]
|
|
if not recordUtility("interrupts",sourceInfo,interruptedSpell or (cast and cast.spell) or ability,target) then return false end
|
|
D.activeEnemyCasts[target]=nil
|
|
D.recentInterrupts[target]={source=sourceInfo.key,ability=ability,time=GetTime()}
|
|
return true
|
|
end
|
|
local function tryRecordInterrupt(sourceInfo,ability,target)
|
|
if not sourceInfo or not ability or not target or not INTERRUPT_SPELLS[ability] then return false end
|
|
local cast=D.activeEnemyCasts[target]
|
|
if not cast then return false end
|
|
local age=GetTime()-(cast.time or 0)
|
|
if age<0 or age>8 then
|
|
D.activeEnemyCasts[target]=nil
|
|
return false
|
|
end
|
|
if cast.failedAt and GetTime()-(cast.failedAt or 0)>INTERRUPT_FAIL_GRACE then
|
|
D.activeEnemyCasts[target]=nil
|
|
return false
|
|
end
|
|
local interruptedSpell=cast.spell or "Unknown Spell"
|
|
return D.recordInterrupt(sourceInfo,ability,target,interruptedSpell)
|
|
end
|
|
|
|
-- Damage shields (Thorns, Retribution Aura, item effects) log their retaliation as
|
|
-- "You reflect N School damage to X." / "Y reflects N School damage to X." without
|
|
-- naming the spell, so the school labels the entry. Enemy shields hitting the group
|
|
-- and units outside the roster are ignored. DPSLog reports these as DAMAGE_SHIELD.
|
|
local function parseReflect(ev,text)
|
|
if D.dpsLogActive then return false end
|
|
local _,_,amount,school,target=string.find(text,"^You reflect ([0-9]+) (.-) damage to (0x[%x]+)")
|
|
local a
|
|
if amount then
|
|
a=actorFromSourceToken("You")
|
|
else
|
|
local source
|
|
_,_,source,amount,school,target=string.find(text,"^(0x[%x]+) reflects ([0-9]+) (.-) damage to (0x[%x]+)")
|
|
if not amount then return false end
|
|
a=actorFromSourceToken(source)
|
|
if not a then return ignoreOutsideRoster(source) end
|
|
end
|
|
if not a or a.isTotem then return false end
|
|
if D.guidToActor[target] then return true end -- a shield never counts for hitting a group member
|
|
noteEnemyTarget(target,amount)
|
|
return addDamage(a.key,a.name,a.guid,a.ownerKey,a.isPet,amount,"Damage Shield ("..school..")",false,ev)
|
|
end
|
|
local function parseSelf(ev,text)
|
|
if D.dpsLogActive then return false end
|
|
local crit=isCritText(text); local _,_,amount; local spell
|
|
|
|
if ev=="CHAT_MSG_SPELL_SELF_BUFF" then
|
|
local _,_,totemName=string.find(text,"^You cast (.- Totem)%.")
|
|
if totemName then
|
|
D.pendingSelfTotem={name=totemName,time=GetTime()}
|
|
return false
|
|
end
|
|
end
|
|
if ev=="CHAT_MSG_COMBAT_SELF_HITS" then
|
|
local target
|
|
_,_,target,amount=string.find(text,"^You hit (0x[%x]+) for ([0-9]+)")
|
|
if not amount then _,_,target,amount=string.find(text,"^You crit (0x[%x]+) for ([0-9]+)") end
|
|
if not amount then _,_,amount=string.find(text," for ([0-9]+)") end
|
|
if amount then if target then noteEnemyTarget(target,amount) end; return addDamage(D.selfKey,UnitName("player") or "You",safeUnitGUID("player"),nil,false,amount,"Melee",crit,ev) end
|
|
elseif ev=="CHAT_MSG_SPELL_SELF_DAMAGE" then
|
|
local target
|
|
_,_,spell,target,amount=string.find(text,"Your (.-) hits (0x[%x]+) for ([0-9]+)")
|
|
if not amount then _,_,spell,target,amount=string.find(text,"Your (.-) crits (0x[%x]+) for ([0-9]+)") end
|
|
if amount then noteEnemyTarget(target,amount); local si=D.guidToActor[safeUnitGUID("player") or D.selfKey]; if si then rememberAuraCast(si,spell,target); tryRecordInterrupt(si,spell,target) end; return addDamage(D.selfKey,UnitName("player") or "You",safeUnitGUID("player"),nil,false,amount,spell or "Spell",crit,ev) end
|
|
elseif ev=="CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE" then
|
|
local target
|
|
_,_,target,amount,spell=string.find(text,"^(0x[%x]+) suffers ([0-9]+) .- damage from your (.-)%.")
|
|
if amount then noteEnemyTarget(target,amount); local si=D.guidToActor[safeUnitGUID("player") or D.selfKey]; if si then rememberAuraCast(si,spell,target); local a=utilityActor(si); startAura(a.debuffsCast,spell,target); D.activeAuraSources[target.."|"..spell]=a.key end; return addDamage(D.selfKey,UnitName("player") or "You",safeUnitGUID("player"),nil,false,amount,spell or "DoT",false,ev) end
|
|
elseif ev=="CHAT_MSG_COMBAT_PET_HITS" or ev=="CHAT_MSG_SPELL_PET_DAMAGE" then
|
|
local pg=safeUnitGUID("pet"); local pi=D.guidToActor[pg or "pet"]; if not pi then return false end
|
|
local _,_,explicitSource=string.find(text,"^(0x[%x]+)")
|
|
if explicitSource and explicitSource~=pg then return false end
|
|
local livePetName=safeUnitName("pet"); if livePetName and (not pi.name or pi.name=="Unknown" or pi.name=="UNKNOWN") then pi.name=livePetName end
|
|
local target
|
|
_,_,target,amount=string.find(text," (0x[%x]+) for ([0-9]+)")
|
|
if not amount then _,_,amount=string.find(text," for ([0-9]+)") end
|
|
if amount then
|
|
if target then noteEnemyTarget(target,amount) end
|
|
spell="Melee"; local _,_,s=string.find(text,"'s (.-) hits "); if s then spell=s end
|
|
local _,_,cs=string.find(text,"'s (.-) crits "); if cs then spell=cs end
|
|
return addDamage(pi.key,pi.name,pi.guid,pi.ownerKey,true,amount,spell,crit,ev)
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
local function parseGeneric(ev,text)
|
|
if D.dpsLogActive then return false end
|
|
if string.sub(text,1,2)~="0x" then return false end
|
|
local crit=isCritText(text); local _,_,source,spell,amount
|
|
local meleeTarget
|
|
_,_,source,meleeTarget,amount=string.find(text,"^(0x[%x]+) hits (0x[%x]+) for ([0-9]+)")
|
|
if not amount then _,_,source,meleeTarget,amount=string.find(text,"^(0x[%x]+) crits (0x[%x]+) for ([0-9]+)") end
|
|
if amount then local a=actorFromSourceToken(source); if a then noteEnemyTarget(meleeTarget,amount); return addDamage(a.key,a.name,a.guid,a.ownerKey,a.isPet,amount,"Melee",crit,ev) end; if ignoreOutsideRoster(source) then return true end end
|
|
local hitTarget
|
|
_,_,source,spell,hitTarget,amount=string.find(text,"^(0x[%x]+)'s (.-) hits (0x[%x]+) for ([0-9]+)")
|
|
if not amount then _,_,source,spell,hitTarget,amount=string.find(text,"^(0x[%x]+)'s (.-) crits (0x[%x]+) for ([0-9]+)") end
|
|
if amount then
|
|
local a=actorFromSourceToken(source)
|
|
if not a and D.tryClaimSelfTotemSource then a=D.tryClaimSelfTotemSource(source,spell,ev) end
|
|
if not a and D.tryClaimSelfItemSource then a=D.tryClaimSelfItemSource(source,spell,ev) end
|
|
if a then
|
|
noteEnemyTarget(hitTarget,amount)
|
|
rememberAuraCast(a,spell,hitTarget)
|
|
tryRecordInterrupt(a,spell,hitTarget)
|
|
return addDamage(a.key,a.name,a.guid,a.ownerKey,a.isPet,amount,spell,crit,ev)
|
|
end
|
|
if ignoreOutsideRoster(source) then return true end
|
|
end
|
|
local target
|
|
_,_,target,amount,source,spell=string.find(text,"^(0x[%x]+) suffers ([0-9]+) .- damage from (0x[%x]+)'s (.-)%.")
|
|
if amount then local a=actorFromSourceToken(source); if a then noteEnemyTarget(target,amount); rememberAuraCast(a,spell,target); local aa=utilityActor(a); startAura(aa.debuffsCast,spell,target); D.activeAuraSources[target.."|"..spell]=aa.key; return addDamage(a.key,a.name,a.guid,a.ownerKey,a.isPet,amount,spell,false,ev) end; if ignoreOutsideRoster(source) then return true end end
|
|
return false
|
|
end
|
|
|
|
|
|
local function parseHealing(ev,text)
|
|
if D.dpsLogActive then return false end
|
|
if not string.find(text," heals ",1,true)
|
|
and not string.find(text," health from ",1,true) then
|
|
if string.find(ev,"HEAL",1,true) then captureUtilityUnknown(ev,text) end
|
|
return false
|
|
end
|
|
local crit=isCritText(text); local _,_,source,spell,target,amount
|
|
|
|
-- Self direct heals. Check the critical form first: the generic
|
|
-- "heals" pattern would otherwise capture "Spell critically" as the name.
|
|
_,_,spell,amount=string.find(text,"^Your (.-) critically heals .- for ([0-9]+)")
|
|
if not amount then _,_,spell,amount=string.find(text,"^Your (.-) heals .- for ([0-9]+)") end
|
|
if amount then return addHealing(D.selfKey,UnitName("player") or "You",safeUnitGUID("player"),nil,false,amount,spell,crit,ev) end
|
|
|
|
-- Generic direct heals from a player/pet GUID.
|
|
_,_,source,spell,target,amount=string.find(text,"^(0x[%x]+)'s (.-) critically heals (0x[%x]+) for ([0-9]+)")
|
|
if not amount then _,_,source,spell,target,amount=string.find(text,"^(0x[%x]+)'s (.-) heals (0x[%x]+) for ([0-9]+)") end
|
|
if amount then
|
|
local a=actorFromSourceToken(source); if a then
|
|
local ti=D.guidToActor[target]
|
|
if ti then local ta=utilityActor(ti); D.backfillAuraSource(ta and ta.buffs,spell,a.name,a.guid or a.key); D.addAuraSourceHealing(ta and ta.buffs,spell,a.name,a.guid or a.key,amount) end
|
|
return addHealing(a.key,a.name,a.guid,a.ownerKey,a.isPet,amount,spell,crit,ev)
|
|
end
|
|
if ignoreOutsideRoster(source) then return true end
|
|
end
|
|
|
|
-- HoT forms: target gains 123 health from source's Rejuvenation.
|
|
_,_,target,amount,source,spell=string.find(text,"^(0x[%x]+) gains ([0-9]+) health from (0x[%x]+)'s (.-)%.")
|
|
if amount then
|
|
local a=actorFromSourceToken(source); if a then
|
|
local ti=D.guidToActor[target]
|
|
if ti then local ta=utilityActor(ti); D.backfillAuraSource(ta and ta.buffs,spell,a.name,a.guid or a.key); D.addAuraSourceHealing(ta and ta.buffs,spell,a.name,a.guid or a.key,amount) end
|
|
return addHealing(a.key,a.name,a.guid,a.ownerKey,a.isPet,amount,spell,false,ev)
|
|
end
|
|
if ignoreOutsideRoster(source) then return true end
|
|
end
|
|
|
|
-- Target-local HoT form used by RavenCraft on the receiving client:
|
|
-- You gain 123 health from <caster GUID>'s Rejuvenation.
|
|
-- Healing/HPS already knows how to count the GUID-target form above; this
|
|
-- local-target form must also feed the recipient's Buff Uptime entry.
|
|
_,_,amount,source,spell=string.find(text,"^You gain ([0-9]+) health from (0x[%x]+)'s (.-)%.")
|
|
if amount then
|
|
local a=actorFromSourceToken(source)
|
|
if a then
|
|
local pi=D.guidToActor[safeUnitGUID("player") or D.selfKey]
|
|
local pa=utilityActor(pi)
|
|
D.backfillAuraSource(pa and pa.buffs,spell,a.name,a.guid or a.key)
|
|
D.addAuraSourceHealing(pa and pa.buffs,spell,a.name,a.guid or a.key,amount)
|
|
return addHealing(a.key,a.name,a.guid,a.ownerKey,a.isPet,amount,spell,false,ev)
|
|
end
|
|
if ignoreOutsideRoster(source) then return true end
|
|
end
|
|
|
|
-- Your HoT on anyone / yourself. Feed the same per-source aura-healing
|
|
-- accumulator instead of only the general Healing/HPS totals.
|
|
_,_,target,amount,spell=string.find(text,"^(0x[%x]+) gains ([0-9]+) health from your (.-)%.")
|
|
if amount then
|
|
local ti=D.guidToActor[target]
|
|
local ta=utilityActor(ti)
|
|
local selfName=UnitName("player") or "You"
|
|
local selfGuid=safeUnitGUID("player")
|
|
D.backfillAuraSource(ta and ta.buffs,spell,selfName,selfGuid or D.selfKey)
|
|
D.addAuraSourceHealing(ta and ta.buffs,spell,selfName,selfGuid or D.selfKey,amount)
|
|
return addHealing(D.selfKey,selfName,selfGuid,nil,false,amount,spell,false,ev)
|
|
end
|
|
_,_,amount,spell=string.find(text,"^You gain ([0-9]+) health from your (.-)%.")
|
|
if amount then
|
|
local pi=D.guidToActor[safeUnitGUID("player") or D.selfKey]
|
|
local pa=utilityActor(pi)
|
|
local selfName=UnitName("player") or "You"
|
|
local selfGuid=safeUnitGUID("player")
|
|
D.backfillAuraSource(pa and pa.buffs,spell,selfName,selfGuid or D.selfKey)
|
|
D.addAuraSourceHealing(pa and pa.buffs,spell,selfName,selfGuid or D.selfKey,amount)
|
|
return addHealing(D.selfKey,selfName,selfGuid,nil,false,amount,spell,false,ev)
|
|
end
|
|
|
|
-- Some 1.12 clients phrase self-target direct heals as 'Your X heals you for N'.
|
|
_,_,spell,amount=string.find(text,"^Your (.-) critically heals you for ([0-9]+)")
|
|
if not amount then _,_,spell,amount=string.find(text,"^Your (.-) heals you for ([0-9]+)") end
|
|
if amount then return addHealing(D.selfKey,UnitName("player") or "You",safeUnitGUID("player"),nil,false,amount,spell,crit,ev) end
|
|
|
|
if string.find(ev,"HEAL",1,true) then captureUtilityUnknown(ev,text) end
|
|
return false
|
|
end
|
|
|
|
local function selfSourceInfo()
|
|
return D.guidToActor[safeUnitGUID("player") or D.selfKey]
|
|
end
|
|
|
|
local function selfDispelAlreadyRecorded(spell,aura)
|
|
local r=D.lastSelfDispelRecord
|
|
if not r then return false end
|
|
if GetTime()-(r.time or 0)>0.75 then return false end
|
|
return r.spell==spell and r.aura==aura
|
|
end
|
|
|
|
local function recordSelfDispel(spell,aura)
|
|
if not spell or not DISPEL_SPELLS[spell] then return false end
|
|
if selfDispelAlreadyRecorded(spell,aura) then return true end
|
|
local si=selfSourceInfo()
|
|
if not si then return false end
|
|
D.lastSelfDispelRecord={spell=spell,aura=aura,time=GetTime()}
|
|
return recordUtility("dispels",si,spell,si.guid or si.key)
|
|
end
|
|
|
|
-- Utility parser. Vanilla combat log formats vary; source-GUID formats get actor attribution,
|
|
-- target-only aura lines are still tracked globally/at target and kept diagnosable.
|
|
D.seedActiveRosterBuffs = function()
|
|
if D.scanGroupBuffs then D.scanGroupBuffs() else D.scanPlayerBuffs(false) end
|
|
D.scanWeaponBuffs()
|
|
local key,b
|
|
for key,b in D.activeRosterBuffs do
|
|
if b and b.target and b.spell then
|
|
local targetKey=b.target
|
|
local ti=nil
|
|
if targetKey=="player" then
|
|
local pg=safeUnitGUID("player")
|
|
ti=D.guidToActor[pg or D.selfKey]
|
|
if ti then targetKey=ti.guid or ti.key end
|
|
else
|
|
ti=D.guidToActor[targetKey]
|
|
end
|
|
if ti then
|
|
local a=utilityActor(ti)
|
|
if a then startAura(a.buffs,b.spell,targetKey) end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
D.updateWeaponBuffs = function()
|
|
if not D.inCombat or not GetWeaponEnchantInfo then return end
|
|
|
|
-- Snapshot the current weapon cache, rebuild it for the full friendly
|
|
-- roster, then apply only the actual additions/removals to aura uptime.
|
|
local old={}
|
|
local key,b
|
|
for key,b in D.activeRosterBuffs do
|
|
if b and b.weaponBuff then old[key]=b end
|
|
end
|
|
|
|
D.scanWeaponBuffs()
|
|
|
|
for key,b in old do
|
|
if not D.activeRosterBuffs[key] and b and b.target and b.spell then
|
|
local ti=nil
|
|
local tk=b.target
|
|
if tk=="player" then
|
|
local pg=safeUnitGUID("player")
|
|
ti=D.guidToActor[pg or D.selfKey]
|
|
if ti then tk=ti.guid or ti.key end
|
|
else
|
|
ti=D.guidToActor[tk]
|
|
end
|
|
if ti then
|
|
local a=utilityActor(ti)
|
|
if a then stopAura(a.buffs,b.spell,tk) end
|
|
end
|
|
end
|
|
end
|
|
|
|
for key,b in D.activeRosterBuffs do
|
|
if b and b.weaponBuff and not old[key] and b.target and b.spell then
|
|
local ti=nil
|
|
local tk=b.target
|
|
if tk=="player" then
|
|
local pg=safeUnitGUID("player")
|
|
ti=D.guidToActor[pg or D.selfKey]
|
|
if ti then tk=ti.guid or ti.key end
|
|
else
|
|
ti=D.guidToActor[tk]
|
|
end
|
|
if ti then
|
|
local a=utilityActor(ti)
|
|
if a then startAura(a.buffs,b.spell,tk) end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
-- Resource ticks share one shape. Keep the accepted spellings exact; the
|
|
-- pre-combat boundary deliberately normalizes case before using this helper.
|
|
do
|
|
local resourceWords={health=true,Health=true,mana=true,Mana=true,
|
|
rage=true,Rage=true,energy=true,Energy=true}
|
|
function D.isRawResourceGain(text)
|
|
local _,_,resource=string.find(text,"^[0-9]+ ([^ ]+) from ")
|
|
return resource and resourceWords[resource]
|
|
end
|
|
end
|
|
|
|
-- Literal guards only reject impossible matches. Custom RAW subtypes still
|
|
-- reach every applicable pattern in the original order and keep diagnostics.
|
|
local function parseUtility(ev,text)
|
|
local _,_,source,spell,target,removed
|
|
|
|
if string.find(text," begins to cast ",1,true) then
|
|
-- Enemy cast start, e.g. "0xF130... begins to cast Lizard Bolt."
|
|
_,_,target,spell=string.find(text,"^(0x[%x]+) begins to cast (.-)%.")
|
|
if target and spell then
|
|
rememberEnemyCast(target,spell)
|
|
return true
|
|
end
|
|
end
|
|
|
|
if string.find(text," misses ",1,true) or string.find(text," by ",1,true) then
|
|
-- Failed interrupt attempts do not count and leave the enemy cast active.
|
|
_,_,spell,target=string.find(text,"^Your (.-) misses (0x[%x]+)%.")
|
|
if not spell then _,_,spell,target=string.find(text,"^Your (.-) was dodged by (0x[%x]+)%.") end
|
|
if not spell then _,_,spell,target=string.find(text,"^Your (.-) was parried by (0x[%x]+)%.") end
|
|
if not spell then _,_,spell,target=string.find(text,"^Your (.-) was resisted by (0x[%x]+)%.") end
|
|
if not spell then _,_,spell,target=string.find(text,"^Your (.-) is resisted by (0x[%x]+)%.") end
|
|
if spell and target then
|
|
local pg=safeUnitGUID("player") or D.selfKey
|
|
local si=D.guidToActor[pg]
|
|
if D.threatOnSpellFailed then D.threatOnSpellFailed(pg,target,spell) end
|
|
clearRecentAuraCast(target,spell)
|
|
if si then return true end
|
|
end
|
|
|
|
_,_,source,spell,target=string.find(text,"^(0x[%x]+)'s (.-) misses (0x[%x]+)%.")
|
|
if not source then _,_,source,spell,target=string.find(text,"^(0x[%x]+)'s (.-) was dodged by (0x[%x]+)%.") end
|
|
if not source then _,_,source,spell,target=string.find(text,"^(0x[%x]+)'s (.-) was parried by (0x[%x]+)%.") end
|
|
if not source then _,_,source,spell,target=string.find(text,"^(0x[%x]+)'s (.-) was resisted by (0x[%x]+)%.") end
|
|
if not source then _,_,source,spell,target=string.find(text,"^(0x[%x]+)'s (.-) is resisted by (0x[%x]+)%.") end
|
|
if source and spell and target then
|
|
local si=actorFromSourceToken(source)
|
|
if si then
|
|
if D.threatOnSpellFailed then D.threatOnSpellFailed(source,target,spell) end
|
|
clearRecentAuraCast(target,spell)
|
|
return true
|
|
end
|
|
if ignoreOutsideRoster(source) then return true end
|
|
end
|
|
end
|
|
|
|
local hasHit=string.find(text," hits ",1,true)
|
|
if hasHit then
|
|
-- Non-damaging interrupt abilities such as Counterspell/Spell Lock can
|
|
-- appear as a plain hit without a numeric damage amount.
|
|
_,_,spell,target=string.find(text,"^Your (.-) hits (0x[%x]+)%.")
|
|
if spell and target then
|
|
local pg=safeUnitGUID("player") or D.selfKey
|
|
if D.threatOnSpellLanded then D.threatOnSpellLanded(pg,target,spell) end
|
|
if INTERRUPT_SPELLS[spell] then
|
|
local si=D.guidToActor[pg]
|
|
if si then tryRecordInterrupt(si,spell,target); return true end
|
|
end
|
|
end
|
|
_,_,source,spell,target=string.find(text,"^(0x[%x]+)'s (.-) hits (0x[%x]+)%.")
|
|
if source and spell then
|
|
local si=actorFromSourceToken(source)
|
|
if si then
|
|
if D.threatOnSpellLanded then D.threatOnSpellLanded(source,target,spell) end
|
|
if INTERRUPT_SPELLS[spell] then tryRecordInterrupt(si,spell,target); return true end
|
|
elseif ignoreOutsideRoster(source) then return true end
|
|
end
|
|
end
|
|
|
|
if string.find(text," interrupts ",1,true) then
|
|
-- Interrupts: GUID's Kick interrupts GUID's Spell.
|
|
_,_,source,spell,target,removed=string.find(text,"^(0x[%x]+)'s (.-) interrupts (0x[%x]+)'s (.-)%.")
|
|
if source then local si=actorFromSourceToken(source); if si then return D.recordInterrupt(si,spell,target,removed) end; if ignoreOutsideRoster(source) then return true end end
|
|
_,_,source,spell,target=string.find(text,"^(0x[%x]+)'s (.-) interrupts (0x[%x]+)%.")
|
|
if source then local si=actorFromSourceToken(source); if si then return D.recordInterrupt(si,spell,target) end; if ignoreOutsideRoster(source) then return true end end
|
|
end
|
|
|
|
-- DPSLog's SPELL_DISPEL (see CawDPSLog.lua's dpsLogReceive) fires directly
|
|
-- from the game's own dispel processing, self-dispels included, so it
|
|
-- doesn't need the two-line BREAK_AURA/cast correlation heuristic below.
|
|
-- Each dispel-specific branch is individually gated on D.dpsLogActive
|
|
-- rather than the whole block, since "You cast " here is shared with the
|
|
-- unrelated Feign Death check.
|
|
if not D.dpsLogActive and ev=="CHAT_MSG_SPELL_BREAK_AURA" and string.find(text," is removed.",1,true) then
|
|
-- RavenCraft self-dispel form observed live:
|
|
-- CHAT_MSG_SPELL_BREAK_AURA | Your Decayed Strength is removed.
|
|
-- followed immediately by one or more duplicate "You cast Purify." lines.
|
|
local removedAura
|
|
_,_,removedAura=string.find(text,"^Your (.-) is removed%.")
|
|
if removedAura then
|
|
local recent=D.recentSelfDispelCast
|
|
if recent and GetTime()-(recent.time or 0)<=1.0 and DISPEL_SPELLS[recent.spell] then
|
|
D.recentSelfDispelCast=nil
|
|
return recordSelfDispel(recent.spell,removedAura)
|
|
end
|
|
D.pendingSelfDispel={aura=removedAura,time=GetTime()}
|
|
return true
|
|
end
|
|
end
|
|
|
|
local isSelfCast=string.sub(text,1,9)=="You cast "
|
|
if isSelfCast then
|
|
_,_,spell=string.find(text,"^You cast (.-)%.")
|
|
if spell=="Feign Death" and D.threatOnFeignSuccess then D.threatOnFeignSuccess(safeUnitGUID("player") or D.selfKey); return true end
|
|
if not D.dpsLogActive and spell and DISPEL_SPELLS[spell] then
|
|
local pending=D.pendingSelfDispel
|
|
if pending and GetTime()-(pending.time or 0)<=1.0 then
|
|
D.pendingSelfDispel=nil
|
|
D.recentSelfDispelCast=nil
|
|
return recordSelfDispel(spell,pending.aura)
|
|
end
|
|
-- Keep a short reverse-order cache as some RavenCraft spell logs can
|
|
-- arrive before the corresponding BREAK_AURA line.
|
|
D.recentSelfDispelCast={spell=spell,time=GetTime()}
|
|
return true
|
|
end
|
|
end
|
|
|
|
if not D.dpsLogActive and (string.find(text," removes ",1,true) or string.find(text," dispels ",1,true)) then
|
|
-- Explicit dispel/remove forms with source.
|
|
_,_,source,spell,target,removed=string.find(text,"^(0x[%x]+)'s (.-) removes (.-) from (0x[%x]+)%.")
|
|
if source then local si=actorFromSourceToken(source); if si then return recordUtility("dispels",si,spell,target) end; if ignoreOutsideRoster(source) then return true end end
|
|
_,_,source,spell,target=string.find(text,"^(0x[%x]+)'s (.-) dispels (.-) from (0x[%x]+)%.")
|
|
if source then local si=actorFromSourceToken(source); if si then return recordUtility("dispels",si,spell,target) end; if ignoreOutsideRoster(source) then return true end end
|
|
end
|
|
|
|
-- Casts are cached for a few seconds. Vanilla aura-application messages often
|
|
-- omit the caster, so the subsequent 'is afflicted by' line is correlated
|
|
-- back to this source/target/spell tuple.
|
|
if isSelfCast then
|
|
_,_,spell,target=string.find(text,"^You cast (.-) on (0x[%x]+)%.")
|
|
if spell and target then
|
|
local pg=safeUnitGUID("player") or D.selfKey
|
|
-- RC24: RavenCraft emits this explicit RAW line after a successful
|
|
-- non-damaging cast (confirmed for Distracting Shot). Use it as the
|
|
-- success signal for pending flat-threat abilities instead of booking
|
|
-- threat on UNIT_CASTEVENT alone. Miss/resist paths therefore remain
|
|
-- able to clear the pending cast without false threat.
|
|
if D.threatOnSpellLanded then D.threatOnSpellLanded(pg,target,spell) end
|
|
local si=D.guidToActor[pg]
|
|
if si then D.rememberAuraOrigin(si.guid or si.key,spell,target); rememberAuraCast(si,spell,target); return true end
|
|
end
|
|
end
|
|
|
|
if string.find(text," casts ",1,true) then
|
|
_,_,source,spell,target=string.find(text,"^(0x[%x]+) casts (.-) on (0x[%x]+)%.")
|
|
if source then
|
|
D.rememberAuraOrigin(source,spell,target)
|
|
local si=actorFromSourceToken(source)
|
|
if si then if D.threatOnSpellLanded then D.threatOnSpellLanded(source,target,spell) end; rememberAuraCast(si,spell,target); return true end
|
|
if ignoreOutsideRoster(source) then return true end
|
|
end
|
|
end
|
|
if hasHit then
|
|
_,_,source,spell,target=string.find(text,"^(0x[%x]+)'s (.-) hits (0x[%x]+)%.")
|
|
if source and CC_SPELLS[spell] then
|
|
local si=actorFromSourceToken(source)
|
|
if si then rememberAuraCast(si,spell,target); return true end
|
|
if ignoreOutsideRoster(source) then return true end
|
|
end
|
|
end
|
|
|
|
-- Helpful aura gained by a roster member. RavenCraft uses *_BUFFS (plural)
|
|
-- for lines such as "You gain Blessing of Might.". Only parse "gain" as a
|
|
-- buff when the RAW subtype itself is a buff event, so XP gain cannot leak
|
|
-- into Buff Uptime.
|
|
if ev and string.find(ev,"_BUFF",1,true) and string.find(text," gain",1,true) then
|
|
_,_,target,spell=string.find(text,"^(0x[%x]+) gains (.-)%.")
|
|
if target and spell then
|
|
-- Periodic heal/resource ticks can arrive on *_BUFFS events too, e.g.
|
|
-- "GUID gains 38 health from GUID's Renew." They are healing/resource
|
|
-- combat-log lines, not aura applications. Never seed them as Buff Uptime.
|
|
if D.isRawResourceGain(spell) then
|
|
-- This is not an aura application, but it can carry the caster
|
|
-- that the actual aura-gain line omitted. Use it to backfill
|
|
-- the already existing Buff Uptime entry before discarding it
|
|
-- as a buff event. Example: "38 health from GUID's Renew".
|
|
local _,_,gainSource,gainSpell=string.find(spell,"^[0-9]+ [^ ]+ from (0x[%x]+)'s (.-)$")
|
|
if gainSource and gainSpell then
|
|
local gainActor=actorFromSourceToken(gainSource)
|
|
local gainTarget=D.guidToActor[target]
|
|
if gainActor and gainTarget then
|
|
local gainTargetActor=utilityActor(gainTarget)
|
|
D.backfillAuraSource(gainTargetActor and gainTargetActor.buffs,gainSpell,gainActor.name,gainActor.guid or gainActor.key)
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
local ti=D.guidToActor[target]
|
|
if ti then
|
|
D.activeRosterBuffs[target.."|"..spell]={target=target,spell=spell}
|
|
if D.inCombat then
|
|
local ta=utilityActor(ti); local origin=D.recentAuraOrigin(target,spell); local caster=recentAuraCaster(target,spell)
|
|
if caster then startAura(ta.buffs,spell,target,caster.name,caster.guid or caster.key)
|
|
elseif origin then startAura(ta.buffs,spell,target,origin.name,origin.key)
|
|
else startAura(ta.buffs,spell,target) end
|
|
D.lastUtility=ta.name.." buff "..spell
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
_,_,spell=string.find(text,"^You gain (.-)%.")
|
|
if spell then
|
|
-- Resource gains are not buffs.
|
|
if D.isRawResourceGain(spell) then
|
|
-- Same backfill for the self-target wording: "You gain ...".
|
|
local _,_,gainSource,gainSpell=string.find(spell,"^[0-9]+ [^ ]+ from (0x[%x]+)'s (.-)$")
|
|
if gainSource and gainSpell then
|
|
local gainActor=actorFromSourceToken(gainSource)
|
|
local selfInfo=D.guidToActor[safeUnitGUID("player") or D.selfKey]
|
|
if gainActor and selfInfo then
|
|
local selfActor=utilityActor(selfInfo)
|
|
D.backfillAuraSource(selfActor and selfActor.buffs,gainSpell,gainActor.name,gainActor.guid or gainActor.key)
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- RavenCraft stack updates arrive as separate names such as
|
|
-- "Zeal (2)" / "Zeal (3)". Keep one aura entry instead.
|
|
local _,_,baseSpell=string.find(spell,"^(.-) %([0-9]+%)$")
|
|
if baseSpell then spell=baseSpell end
|
|
|
|
D.activeRosterBuffs["player|"..spell]={target="player",spell=spell}
|
|
if D.inCombat then
|
|
local si=D.guidToActor[safeUnitGUID("player") or D.selfKey]
|
|
if si then
|
|
local a=utilityActor(si); local tk=a.guid or a.key; local origin=D.recentAuraOrigin(tk,spell); local caster=recentAuraCaster(tk,spell)
|
|
if caster then startAura(a.buffs,spell,tk,caster.name,caster.guid or caster.key)
|
|
elseif origin then startAura(a.buffs,spell,tk,origin.name,origin.key)
|
|
else startAura(a.buffs,spell,tk) end
|
|
D.lastUtility=a.name.." buff "..spell
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
|
|
if string.find(text," afflicted by ",1,true) then
|
|
-- Harmful aura applied. Always track it as Received when the target is in
|
|
-- our roster, and as Cast when a recent source can be correlated.
|
|
_,_,target,spell=string.find(text,"^(0x[%x]+) is afflicted by (.-)%.")
|
|
if target and spell then
|
|
if D.threatOnAuraLanded then D.threatOnAuraLanded(target,spell) end
|
|
local ti=D.guidToActor[target]
|
|
local si=recentAuraCaster(target,spell); local origin=D.recentAuraOrigin(target,spell)
|
|
-- Secondary debuffs can have a different name from the cast that caused
|
|
-- them (Power Word: Shield -> Weakened Soul). If exact spell matching
|
|
-- found nothing, accept only a very recent roster cast on this same
|
|
-- target. The narrow window avoids attributing unrelated raid debuffs.
|
|
if not si and not origin then
|
|
local direct=D.recentDirectRosterAuraCasts and D.recentDirectRosterAuraCasts[target] or nil
|
|
if direct then
|
|
if GetTime()-(direct.time or 0)<=1.25 then si=direct.source
|
|
else D.recentDirectRosterAuraCasts[target]=nil end
|
|
end
|
|
end
|
|
if ti then
|
|
local ta=utilityActor(ti)
|
|
if si then startAura(ta.debuffsReceived,spell,target,si.name,si.guid or si.key)
|
|
elseif origin then startAura(ta.debuffsReceived,spell,target,origin.name,origin.key)
|
|
else startAura(ta.debuffsReceived,spell,target) end
|
|
end
|
|
if si then
|
|
-- A landed hostile CC is itself enough to open the encounter.
|
|
-- This is especially important for Sap: RavenCraft may not put the
|
|
-- player into normal combat until the later breaking hit.
|
|
if CC_SPELLS[spell] and (not D.inCombat or D.startTime==0) then
|
|
if not ensureStarted() then return false end
|
|
end
|
|
local sa=utilityActor(si); startAura(sa.debuffsCast,spell,target); D.activeAuraSources[target.."|"..spell]=sa.key
|
|
clearRecentAuraCast(target,spell)
|
|
if CC_SPELLS[spell] then
|
|
addCount(sa.cc,spell,1)
|
|
D.activeCC[target]={spell=spell,sourceKey=sa.key,time=GetTime()}
|
|
D.logLine("cc","CC landed "..tostring(spell).." on "..tostring(target).." by "..tostring(sa.name))
|
|
D.lastCCLanded=spell.." | "..target.." | "..sa.name
|
|
D.lastCCSegmentOpen="inCombat="..tostring(D.inCombat).." | start="..tostring(D.startTime)
|
|
end
|
|
end
|
|
if not ti and not si then addCount(D.globalUtility.debuffsReceived,spell,1) end
|
|
D.lastUtility=(si and si.name or "Unknown").." debuff "..spell
|
|
return true
|
|
end
|
|
_,_,spell=string.find(text,"^You are afflicted by (.-)%.")
|
|
if spell then local si=D.guidToActor[safeUnitGUID("player") or D.selfKey]; if si then local a=utilityActor(si); startAura(a.debuffsReceived,spell,a.guid or a.key) end; if CC_SPELLS[spell] then addCount(D.globalUtility.cc,spell,1) end; return true end
|
|
end
|
|
|
|
if string.find(text," fades from ",1,true) then
|
|
-- Aura fades. Close both the target-side timer and the source-side Debuffs
|
|
-- Cast timer when we know who applied it.
|
|
_,_,spell,target=string.find(text,"^(.-) fades from (0x[%x]+)%.")
|
|
if spell and target then
|
|
local ti=D.guidToActor[target]
|
|
if ti then D.activeRosterBuffs[target.."|"..spell]=nil; local ta=utilityActor(ti); stopAura(ta.buffs,spell,target); stopAura(ta.debuffsReceived,spell,target) end
|
|
local sourceKey=D.activeAuraSources[target.."|"..spell]
|
|
if sourceKey and D.actors[sourceKey] then stopAura(D.actors[sourceKey].debuffsCast,spell,target); D.activeAuraSources[target.."|"..spell]=nil end
|
|
if CC_SPELLS[spell] then
|
|
local active=D.activeCC[target]
|
|
local hit=D.ccDamageByTarget[target]
|
|
local breakSource=nil
|
|
local breakAbility="Melee"
|
|
local breakAmount=0
|
|
local breakTime=0
|
|
if active and active.breakSource then
|
|
breakSource=active.breakSource
|
|
breakAbility=active.breakAbility or "Melee"
|
|
breakAmount=active.breakAmount or 0
|
|
breakTime=active.breakTime or 0
|
|
elseif hit then
|
|
breakSource=hit.source
|
|
breakAbility=hit.ability or "Melee"
|
|
breakAmount=hit.amount or 0
|
|
breakTime=hit.time or 0
|
|
end
|
|
if active and active.spell==spell and ev=="CHAT_MSG_SPELL_BREAK_AURA" and breakSource and GetTime()-breakTime<=2.00 then
|
|
local breaker=utilityActor(breakSource)
|
|
if breaker then
|
|
addCount(breaker.ccBreaks,spell,1)
|
|
if not breaker.ccBreakAbilities then breaker.ccBreakAbilities={} end
|
|
local breakEntry=addCount(breaker.ccBreakAbilities,spell.." | "..breakAbility,1)
|
|
breakEntry.damage=(breakEntry.damage or 0)+breakAmount
|
|
D.lastUtility=breaker.name.." ccBreaks "..spell.." with "..breakAbility.." for "..tostring(breakAmount)
|
|
D.lastCCBreak=breaker.name.." | "..spell.." | "..breakAbility.." | "..tostring(breakAmount).." | "..target
|
|
else
|
|
addCount(D.globalUtility.ccBreaks,spell,1)
|
|
D.lastCCBreak="Global | "..spell.." | "..breakAbility.." | "..target
|
|
end
|
|
end
|
|
D.logLine("cc","CC cleared (fade) "..tostring(spell).." on "..tostring(target))
|
|
D.activeCC[target]=nil
|
|
D.ccDamageByTarget[target]=nil
|
|
end
|
|
return true
|
|
end
|
|
_,_,spell=string.find(text,"^(.-) fades from you%.")
|
|
if spell then local _,_,baseSpell=string.find(spell,"^(.-) %([0-9]+%)$"); if baseSpell then spell=baseSpell end; D.activeRosterBuffs["player|"..spell]=nil; local si=D.guidToActor[safeUnitGUID("player") or D.selfKey]; if si then local a=utilityActor(si); local tk=a.guid or a.key; D.activeRosterBuffs[tk.."|"..spell]=nil; stopAura(a.buffs,spell,tk); stopAura(a.debuffsReceived,spell,tk) end; if CC_SPELLS[spell] and ev=="CHAT_MSG_SPELL_BREAK_AURA" then addCount(D.globalUtility.ccBreaks,spell,1) end; return true end
|
|
end
|
|
|
|
if string.find(ev,"AURA",1,true) or string.find(ev,"BUFF",1,true) or string.find(ev,"PERIODIC",1,true) or string.find(ev,"BREAK",1,true) then captureUtilityUnknown(ev,text) end
|
|
return false
|
|
end
|
|
|
|
-- Event names repeat for thousands of lines. Cache only their parser order,
|
|
-- never message results or actor data; unknown extension events keep the same
|
|
-- fallback. Cap the cache in case an extension generates dynamic event names.
|
|
local parseRaw
|
|
do
|
|
local rawEventRoutes,rawEventRouteCount={},0
|
|
local function rawEventRoute(ev)
|
|
local route=rawEventRoutes[ev]
|
|
if route then return route end
|
|
if string.find(ev,"HEAL",1,true) then route=1
|
|
elseif string.find(ev,"AURA_GONE",1,true)
|
|
or string.find(ev,"BREAK_AURA",1,true)
|
|
or string.find(ev,"_BUFF",1,true) then route=2
|
|
elseif string.find(ev,"COMBAT_SELF_HITS",1,true)
|
|
or string.find(ev,"SPELL_SELF_DAMAGE",1,true)
|
|
or string.find(ev,"COMBAT_PET_HITS",1,true) then route=3
|
|
elseif string.find(ev,"FRIENDLYPLAYER_HITS",1,true)
|
|
or string.find(ev,"CREATURE_VS_CREATURE_DAMAGE",1,true)
|
|
or string.find(ev,"PERIODIC_CREATURE_DAMAGE",1,true) then route=4
|
|
else route=5 end
|
|
if rawEventRouteCount<128 then
|
|
rawEventRoutes[ev]=route
|
|
rawEventRouteCount=rawEventRouteCount+1
|
|
end
|
|
return route
|
|
end
|
|
|
|
parseRaw=function(rawEvent,text)
|
|
if not D.parserEnabled() then return end
|
|
if not text then return end
|
|
if D.dpsLogRawGate and D.dpsLogRawGate(rawEvent,text) then return end
|
|
local ev=rawEvent or ""
|
|
local route=rawEventRoute(ev)
|
|
if string.find(ev,"DAMAGESHIELD",1,true) and parseReflect(ev,text) then return end
|
|
|
|
-- Conservative dispatch: only specialize event families whose purpose is
|
|
-- unambiguous. Unknown/custom RavenCraft events still use the exact proven
|
|
-- full parser chain at the bottom.
|
|
if route==1 then
|
|
if parseHealing(ev,text) then return end
|
|
if parseUtility(ev,text) then return end
|
|
if parseSelf(ev,text) then return end
|
|
if not parseGeneric(ev,text) then captureUnknown(ev,text) end
|
|
return
|
|
end
|
|
|
|
if route==2 then
|
|
-- RavenCraft/SuperWoW can deliver HoT/resource healing lines through
|
|
-- *_BUFFS events (for example: GUID gains 38 health from GUID's Rejuvenation).
|
|
-- Healing must get first refusal here. parseUtility intentionally consumes
|
|
-- resource-gain lines so they do not become fake Buff Uptime entries; if it
|
|
-- runs first, the same line never reaches addHealing.
|
|
if parseHealing(ev,text) then return end
|
|
if parseUtility(ev,text) then return end
|
|
if parseSelf(ev,text) then return end
|
|
if parseGeneric(ev,text) then return end
|
|
captureUnknown(ev,text)
|
|
return
|
|
end
|
|
|
|
if route==3 then
|
|
if parseSelf(ev,text) then return end
|
|
if parseGeneric(ev,text) then return end
|
|
if not parseUtility(ev,text) then captureUnknown(ev,text) end
|
|
return
|
|
end
|
|
|
|
if route==4 then
|
|
if parseGeneric(ev,text) then return end
|
|
if parseSelf(ev,text) then return end
|
|
if not parseUtility(ev,text) then captureUnknown(ev,text) end
|
|
return
|
|
end
|
|
|
|
-- Exact RC18 fallback for every unknown/custom subtype.
|
|
if parseSelf(ev,text) then return end
|
|
if parseGeneric(ev,text) then return end
|
|
if parseHealing(ev,text) then return end
|
|
if string.find(text," reflect",1,true) and parseReflect(ev,text) then return end
|
|
if not parseUtility(ev,text) then captureUnknown(ev,text) end
|
|
end
|
|
|
|
end -- private dispatch cache scope (Vanilla limits locals per function)
|
|
|
|
-- Narrow structured-input bridge; utility/death/RAW threat confirmation remain
|
|
-- on their existing paths. Only damage and healing have one exclusive producer.
|
|
D.parseRawReplay=parseRaw
|
|
D.acceptStructuredAmount=function(kind,info,target,targetName,spell,amount,crit,spellId,over)
|
|
if not D.parserEnabled() then return false end
|
|
if D.localPlayerDead and not D.inCombat then return false end
|
|
if not info or not ensureStarted() then return false end
|
|
D.threatEventTarget=nil
|
|
if kind=="damage" then
|
|
if target then
|
|
if targetName then D.currentEnemyNames[target]=targetName end
|
|
noteEnemyTarget(target,amount)
|
|
rememberAuraCast(info,spell,target)
|
|
local hit={source=info,target=target,ability=spell,amount=amount,time=GetTime()}
|
|
D.lastCCDamage=hit; D.ccDamageByTarget[target]=hit
|
|
local active=D.activeCC[target]
|
|
if active then
|
|
active.breakSource=info; active.breakAbility=spell
|
|
active.breakAmount=amount; active.breakTime=hit.time
|
|
end
|
|
end
|
|
local accepted=addDamage(info.key,info.name,info.guid,info.ownerKey,info.isPet,amount,spell,crit,"DPSLog",spellId)
|
|
if accepted and D.recordTargetAmount then D.recordTargetAmount(D.actors[info.key],kind,target,targetName,spell,amount,crit,spellId) end
|
|
return accepted
|
|
end
|
|
local ti=target and D.guidToActor[target]
|
|
if ti then
|
|
local ta=utilityActor(ti)
|
|
D.backfillAuraSource(ta and ta.buffs,spell,info.name,info.guid or info.key)
|
|
D.addAuraSourceHealing(ta and ta.buffs,spell,info.name,info.guid or info.key,amount)
|
|
end
|
|
local accepted=addHealing(info.key,info.name,info.guid,info.ownerKey,info.isPet,amount,spell,crit,"DPSLog",spellId,over)
|
|
if accepted and D.recordTargetAmount then D.recordTargetAmount(D.actors[info.key],"healing",target,targetName,spell,amount,crit,spellId,over) end
|
|
return accepted
|
|
end
|
|
|
|
local function deepCopyTable(src)
|
|
if type(src)~="table" then return src end
|
|
local dst={}; local k,v
|
|
for k,v in src do
|
|
if k~="debuffs" and k~="_cawDisplayValue" then
|
|
if type(v)=="table" then dst[k]=deepCopyTable(v) else dst[k]=v end
|
|
end
|
|
end
|
|
if dst.debuffsReceived then dst.debuffs=dst.debuffsReceived end
|
|
return dst
|
|
end
|
|
|
|
local function mergeNumericTable(dst,src)
|
|
if not src then return end
|
|
local k,v
|
|
for k,v in src do
|
|
if k~="active" and k~="debuffs" and k~="deathLast" and k~="_cawDisplayValue" then
|
|
if type(v)=="number" then
|
|
if k=="spellId" then
|
|
if not dst[k] then dst[k]=v end
|
|
elseif k=="maxCrit" then
|
|
if v>(dst[k] or 0) then dst[k]=v end
|
|
else
|
|
dst[k]=(dst[k] or 0)+v
|
|
end
|
|
elseif type(v)=="table" then
|
|
if not dst[k] then dst[k]={} end
|
|
mergeNumericTable(dst[k],v)
|
|
elseif dst[k]==nil then
|
|
dst[k]=v
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function mergeOverallActors(srcActors)
|
|
local key,a
|
|
for key,a in srcActors do
|
|
local dst=D.overallSegment.actors[key]
|
|
if not dst then
|
|
dst=deepCopyTable(a)
|
|
-- Overall auras must not carry active timers between fights.
|
|
local kinds={"buffs","debuffsCast","debuffsReceived"}; local i=1
|
|
while i<=table.getn(kinds) do
|
|
local tbl=dst[kinds[i]]; local spell,e
|
|
if tbl then for spell,e in tbl do e.active={} end end
|
|
i=i+1
|
|
end
|
|
D.overallSegment.actors[key]=dst
|
|
else
|
|
local preservedName=dst.name; local preservedClass=dst.classToken
|
|
mergeNumericTable(dst,a)
|
|
dst.name=a.name or preservedName
|
|
dst.classToken=a.classToken or preservedClass
|
|
dst.guid=a.guid or dst.guid
|
|
dst.ownerKey=a.ownerKey or dst.ownerKey
|
|
dst.isPet=a.isPet
|
|
if a.isTotem then dst.isTotem=true end
|
|
if a.deathLast then dst.deathLast=deepCopyTable(a.deathLast) end
|
|
if dst.debuffsReceived then dst.debuffs=dst.debuffsReceived end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function snapshotFinishedFight()
|
|
local total=0; local k,a
|
|
for k,a in D.actors do total=total+(a.damage or 0)+(a.healing or 0)+(a.overhealing or 0)+(a.damageTaken or 0)+(a.deaths or 0) end
|
|
if total<=0 then D.logLine("hist","snapshotFinishedFight SKIPPED (total 0)"); return end
|
|
D.logLine("hist","snapshotFinishedFight '"..tostring(currentFightLabel()).."' dur="..string.format("%.2f",D.lastDuration or 0))
|
|
local entry={
|
|
enemyGuid=D.currentEnemyBestGuid,
|
|
enemyName=D.currentEnemyBestGuid and D.currentEnemyNames[D.currentEnemyBestGuid] or nil,
|
|
actors=deepCopyTable(D.actors),
|
|
duration=D.lastDuration or 0,
|
|
name=currentFightLabel(),
|
|
startedAt=D.fightStartedAt,
|
|
when=GetTime()
|
|
}
|
|
if D.targetSyncSaveFight then D.targetSyncSaveFight(entry) end
|
|
table.insert(D.fightHistory,1,entry)
|
|
while table.getn(D.fightHistory)>10 do table.remove(D.fightHistory) end
|
|
mergeOverallActors(D.actors)
|
|
D.overallSegment.duration=(D.overallSegment.duration or 0)+(D.lastDuration or 0)
|
|
D.overallSegment.fights=(D.overallSegment.fights or 0)+1
|
|
if D.targetSyncFinishFight then D.targetSyncFinishFight(entry) end
|
|
end
|
|
|
|
local function totalDamage()
|
|
local total=0; local k,a; local actors=getDisplayActors()
|
|
for k,a in actors do total=total+(a.damage or 0) end
|
|
return total
|
|
end
|
|
local function actorDisplayDamage(a)
|
|
if not a then return 0 end
|
|
local val=a.damage or 0; local actors=getDisplayActors()
|
|
if not a.isPet then local k,p; for k,p in actors do if p.isPet and p.ownerKey==a.key then val=val+(p.damage or 0) end end end
|
|
return val
|
|
end
|
|
local function totalHealing()
|
|
local total=0; local k,a; local actors=getDisplayActors()
|
|
for k,a in actors do total=total+(a.healing or 0) end
|
|
return total
|
|
end
|
|
local function actorDisplayHealing(a)
|
|
if not a then return 0 end
|
|
local val=a.healing or 0; local actors=getDisplayActors()
|
|
if not a.isPet then local k,p; for k,p in actors do if p.isPet and p.ownerKey==a.key then val=val+(p.healing or 0) end end end
|
|
return val
|
|
end
|
|
local function utilityTotal(a,kind)
|
|
if not a or not a[kind] then return 0 end
|
|
if kind=="buffs" or kind=="debuffsCast" or kind=="debuffsReceived" then return auraTableDuration(a[kind]) end
|
|
local t=0; local k,e; for k,e in a[kind] do t=t+(e.count or 0) end; return t
|
|
end
|
|
D.auraEntryCount = function(tbl)
|
|
if not tbl then return 0 end
|
|
local n=0
|
|
local k,v
|
|
for k,v in tbl do n=n+1 end
|
|
return n
|
|
end
|
|
|
|
local function auraAverageUptime(a,kind,duration)
|
|
if not a or not a[kind] then return 0 end
|
|
local dur=duration or getDuration(); local exposures=auraExposureCount(a[kind]); if dur<=0 or exposures<=0 then return 0 end
|
|
local pct=(auraTableDuration(a[kind])/(dur*exposures))*100
|
|
if pct<0 then pct=0 end
|
|
if pct>100 then pct=100 end
|
|
return pct
|
|
end
|
|
do
|
|
-- Display-only caches: never copied into combat snapshots or saved data.
|
|
-- Validate actual values on every read, including late sync corrections.
|
|
-- Weak actor-map keys let discarded encounters and their caches expire.
|
|
local caches=setmetatable({}, {__mode="k"})
|
|
-- Server Threat rows carry the percentage of the pull threshold (110% melee,
|
|
-- 130% ranged), so a ranged player can hold more raw threat than the tank
|
|
-- while showing less than 100%. Like TWThreat, order those rows by percentage.
|
|
local function order(x,y)
|
|
local kx,ky=x.sortKey or x.value,y.sortKey or y.value
|
|
if kx==ky then
|
|
if x.value~=y.value then return x.value>y.value end
|
|
return tostring(x.actor.key)<tostring(y.actor.key)
|
|
end
|
|
return kx>ky
|
|
end
|
|
function D.meterSortedRows(actors,mode)
|
|
local modes=caches[actors]
|
|
if not modes then modes={}; caches[actors]=modes end
|
|
local c=modes[mode]
|
|
if not c then
|
|
c={rows={},actorList={},byActor={},pets={},petGross={},revision=0,pass=0}
|
|
modes[mode]=c
|
|
end
|
|
c.pass=c.pass+1
|
|
local changed=false; local petTotals=c.pets
|
|
clearTable(petTotals); clearTable(c.petGross)
|
|
local k,a
|
|
if mode=="damage" or mode=="healing" or mode=="overhealing" then
|
|
for k,a in actors do
|
|
if a.isPet and a.ownerKey then
|
|
petTotals[a.ownerKey]=(petTotals[a.ownerKey] or 0)+(a[mode] or 0)
|
|
if mode=="overhealing" then c.petGross[a.ownerKey]=(c.petGross[a.ownerKey] or 0)+(a.overhealTotal or 0) end
|
|
end
|
|
end
|
|
end
|
|
for k,a in actors do
|
|
if not a.isPet or mode=="threat" then
|
|
local val
|
|
if mode=="damage" or mode=="healing" or mode=="overhealing" then val=(a[mode] or 0)+(petTotals[a.key] or 0)
|
|
elseif mode=="threat" then val=D.threatValueForActor and D.threatValueForActor(a) or 0
|
|
elseif mode=="damageTaken" or mode=="deaths" then val=a[mode] or 0
|
|
elseif mode=="buffs" or mode=="debuffsCast" or mode=="debuffsReceived" then val=D.auraEntryCount(a[mode])
|
|
else val=utilityTotal(a,mode) end
|
|
if val>0 then
|
|
local gross=mode=="overhealing" and ((a.overhealTotal or 0)+(c.petGross[a.key] or 0)) or nil
|
|
local sortKey=nil
|
|
if mode=="threat" and a._serverThreatValue then sortKey=tonumber(a._serverThreatPercent) or 0 end
|
|
local row=c.byActor[a]
|
|
if not row then row={actor=a}; c.byActor[a]=row; changed=true end
|
|
if row.value~=val or row.name~=a.name or row.classToken~=a.classToken or row.sortKey~=sortKey
|
|
or row.key~=a.key or row.isPet~=a.isPet or row.isTotem~=a.isTotem or row.overhealTotal~=gross then changed=true end
|
|
row.overhealTotal=gross; row.sortKey=sortKey
|
|
row.value=val; row.name=a.name; row.classToken=a.classToken
|
|
row.key=a.key; row.isPet=a.isPet; row.isTotem=a.isTotem; row.pass=c.pass
|
|
end
|
|
end
|
|
end
|
|
for a,k in c.byActor do
|
|
if k.pass~=c.pass then c.byActor[a]=nil; changed=true end
|
|
end
|
|
if changed then
|
|
local n=0
|
|
for a,k in c.byActor do n=n+1; c.rows[n]=k end
|
|
for k=table.getn(c.rows),n+1,-1 do c.rows[k]=nil end
|
|
table.sort(c.rows,order)
|
|
for k=1,n do c.actorList[k]=c.rows[k].actor end
|
|
for k=table.getn(c.actorList),n+1,-1 do c.actorList[k]=nil end
|
|
c.revision=c.revision+1
|
|
end
|
|
return c.rows,table.getn(c.rows),c
|
|
end
|
|
function D.meterRowsUnchanged(f,c,duration,offset,mode,periodic,pinned)
|
|
if not c then f.cawRowsState=nil; return false end
|
|
local s=f.cawRowsState
|
|
if not s then s={}; f.cawRowsState=s end
|
|
local same=periodic and mode~="threat" and s.cache==c and s.revision==c.revision
|
|
and s.duration==duration and s.offset==offset and s.pinned==pinned
|
|
and s.width==f:GetWidth() and s.height==f:GetHeight()
|
|
s.cache=c; s.revision=c.revision; s.duration=duration; s.offset=offset; s.pinned=pinned
|
|
s.width=f:GetWidth(); s.height=f:GetHeight()
|
|
return same
|
|
end
|
|
end
|
|
local function sortedActors()
|
|
local rows,n,c=D.meterSortedRows(getDisplayActors(),D.mode)
|
|
-- Preserve the primary meter/report API without sharing mutable display
|
|
-- values across modes. Extra windows consume their own row.value instead.
|
|
local i
|
|
for i=1,n do rows[i].actor._cawDisplayValue=rows[i].value end
|
|
return c.actorList,n,c
|
|
end
|
|
|
|
-- "Always show me": when the window cannot show the local player's bar, the last
|
|
-- row is replaced by it and keeps the player's real rank. A bar already in view
|
|
-- stays in place, so no player appears twice. The scroll range grows by one step
|
|
-- so the actor covered by the pinned row can still be reached.
|
|
function D.uiIsSelf(a)
|
|
if not a or a.isPet then return false end
|
|
local pg=safeUnitGUID("player")
|
|
if pg and (a.guid==pg or a.key==pg) then return true end
|
|
if a.key==D.selfKey then return true end
|
|
local pn=safeUnitName("player")
|
|
return pn~=nil and a.name==pn
|
|
end
|
|
-- Returns the local player's rank when the pinned row can apply, otherwise nil.
|
|
-- list holds actors (main window) or rows with an .actor field (extra windows).
|
|
function D.uiPinState(list,count,visible,settings,isRows)
|
|
if not settings or not settings.alwaysShowMe or visible<2 or count<=visible then return nil end
|
|
local i=1
|
|
while i<=count do
|
|
local a=list[i]; if isRows then a=a and a.actor end
|
|
if D.uiIsSelf(a) then return i end
|
|
i=i+1
|
|
end
|
|
return nil
|
|
end
|
|
function D.uiMaxOffset(count,visible,selfRank)
|
|
local m=count-visible; if m<0 then m=0 end
|
|
if selfRank and selfRank<=count-visible then m=m+1 end
|
|
return m
|
|
end
|
|
-- Server Threat bars show the server percentage (full at 100%), matching the
|
|
-- percentage ordering; every other row scales against the top value.
|
|
function D.uiBarFill(mode,a,value,top)
|
|
if mode=="threat" and a and a._serverThreatValue then
|
|
local pct=tonumber(a._serverThreatPercent) or 0
|
|
if pct>100 then pct=100 end
|
|
return pct,100
|
|
end
|
|
return value,top
|
|
end
|
|
function D.uiRowIndex(r,offset,visible,selfRank)
|
|
if selfRank and r==visible and (selfRank<offset+1 or selfRank>offset+visible) then return selfRank end
|
|
return offset+r
|
|
end
|
|
local function sortedTable(tbl,field)
|
|
local list={}; local n=0; if not tbl then return list,0 end; local name,s
|
|
for name,s in tbl do local value=s[field or "count"] or 0; if field=="duration" then value=auraEntryDuration(s) end; n=n+1; list[n]={name=name,value=value,count=s.count or 0,hits=s.hits or 0,crits=s.crits or 0,damage=s.damage or 0,critDamage=s.critDamage or 0,maxCrit=s.maxCrit or 0,overkill=s.overkill or 0,overkillKnown=s.overkillKnown or 0,targetCount=auraTargetCount(s),sourceText=D.auraSourceText(s),sourceCount=D.auraSourceCount(s),sourceHealing=s.sourceHealing,sourceDamage=s.sourceDamage} end
|
|
table.sort(list,function(x,y) return x.value>y.value end); return list,n
|
|
end
|
|
local function sortedSpells(a)
|
|
local list={}; local n=0; if not a then return list,0 end; local name,s
|
|
for name,s in a.spells do n=n+1; list[n]={name=name,damage=s.damage,hits=s.hits,crits=s.crits,value=s.damage} end
|
|
table.sort(list,function(x,y) return x.damage>y.damage end); return list,n
|
|
end
|
|
local function sortedHeals(a)
|
|
local list={}; local n=0; if not a then return list,0 end; local name,h
|
|
for name,h in a.healSpells do n=n+1; list[n]={name=name,healing=h.healing,hits=h.hits,crits=h.crits,value=h.healing} end
|
|
table.sort(list,function(x,y) return x.healing>y.healing end); return list,n
|
|
end
|
|
local function classColor(a)
|
|
if a and a.classToken and CLASS_COLORS[a.classToken] then return CLASS_COLORS[a.classToken][1],CLASS_COLORS[a.classToken][2],CLASS_COLORS[a.classToken][3] end
|
|
return 0.45,0.45,0.45
|
|
end
|
|
|
|
local MODE_LABELS={damage="Damage / DPS",healing="Healing / HPS",overhealing="Overheal",threat="Threat",damageTaken="Damage Taken",deaths="Deaths",interrupts="Interrupts",cc="Crowd Control",ccBreaks="CC Breaks",dispels="Dispels",buffs="Buff Uptime",debuffsCast="Debuffs Cast",debuffsReceived="Debuffs Received"}
|
|
|
|
-- UI -----------------------------------------------------------------------
|
|
local frame=CreateFrame("Frame","CawDPSMeterWindow",UIParent); D.window=frame
|
|
frame:SetWidth(360); frame:SetHeight(178); frame:EnableMouse(true)
|
|
-- Do not call SetClampedToScreen on this custom 1.12 client.
|
|
-- Native frame-clamping has caused ACCESS_VIOLATION crashes while dragging on some builds;
|
|
-- Caw clamps manually after the drag instead.
|
|
-- The window must be movable/resizable before any drag handler can ever run.
|
|
-- Do this during base-frame creation; applyWindowLock later only toggles it.
|
|
if frame.SetMovable then frame:SetMovable(true) end
|
|
if frame.SetResizable then frame:SetResizable(true) end
|
|
frame:SetPoint("CENTER",UIParent,"CENTER",260,0)
|
|
D.layoutRestored=false
|
|
|
|
-- Keep account-wide and per-character layout copies for custom-client reliability.
|
|
-- Some 1.12/custom clients are inconsistent about one SavedVariables scope, so
|
|
-- either copy can restore the window. Restore is deliberately delayed until
|
|
-- PLAYER_ENTERING_WORLD, when UIParent has its final dimensions/scale.
|
|
local function layoutIsValid(t)
|
|
if not t then return false end
|
|
if t.layoutSaved and t.width and t.height then
|
|
if (t.layoutVersion and t.layoutVersion>=4 and t.centerX ~= nil and t.centerY ~= nil)
|
|
or (t.left ~= nil and t.top ~= nil) or (t.point and t.x ~= nil and t.y ~= nil) then return true end
|
|
end
|
|
-- Accept pre-v1.13 settings as a migration source as well.
|
|
if t.width and t.height and ((t.left ~= nil and t.top ~= nil) or (t.point and t.x ~= nil and t.y ~= nil)) then return true end
|
|
return false
|
|
end
|
|
|
|
local function copyLayout(src,dst)
|
|
if not src or not dst then return end
|
|
dst.width=src.width; dst.height=src.height
|
|
dst.point=src.point; dst.relativePoint=src.relativePoint
|
|
dst.x=src.x; dst.y=src.y
|
|
dst.left=src.left; dst.top=src.top
|
|
dst.centerX=src.centerX; dst.centerY=src.centerY
|
|
dst.locked=src.locked and true or false
|
|
dst.layoutVersion=4; dst.layoutSaved=true
|
|
end
|
|
|
|
local function resetWindowPosition()
|
|
frame:ClearAllPoints()
|
|
frame:SetPoint("CENTER",UIParent,"CENTER",260,0)
|
|
if frame:GetWidth()<160 or frame:GetWidth()>900 then frame:SetWidth(440) end
|
|
if frame:GetHeight()<70 or frame:GetHeight()>900 then frame:SetHeight(260) end
|
|
end
|
|
|
|
local function ensureWindowOnScreen()
|
|
if D.uiClamp then return D.uiClamp(frame) end
|
|
-- Manual clamp using the ACTUAL rendered edges of UIParent and the meter.
|
|
-- Do NOT use SetClampedToScreen on this custom 1.12 client: native clamping
|
|
-- has caused ACCESS_VIOLATION crashes while dragging.
|
|
--
|
|
-- Important: do not derive the right/top screen edge from GetWidth()/GetHeight().
|
|
-- On some 1.12/custom-client UI-scale combinations those values do not line up
|
|
-- exactly with the coordinate space returned by GetLeft/GetRight/GetTop/GetBottom.
|
|
-- That was the reason right/top appeared to rubber-band much earlier than
|
|
-- left/bottom. Compare edge coordinates directly instead.
|
|
if not UIParent then return false end
|
|
local pl=UIParent:GetLeft(); local pr=UIParent:GetRight()
|
|
local pb=UIParent:GetBottom(); local pt=UIParent:GetTop()
|
|
local l=frame:GetLeft(); local r=frame:GetRight()
|
|
local b=frame:GetBottom(); local t=frame:GetTop()
|
|
if not pl or not pr or not pb or not pt or not l or not r or not b or not t then return false end
|
|
|
|
-- Small visual tolerance keeps the one-pixel border flush with the screen edge
|
|
-- without allowing the meter to be meaningfully lost off-screen.
|
|
local edgeBleed=8
|
|
local dx=0
|
|
local dy=0
|
|
|
|
if l < pl-edgeBleed then
|
|
dx=(pl-edgeBleed)-l
|
|
elseif r > pr+edgeBleed then
|
|
dx=(pr+edgeBleed)-r
|
|
end
|
|
|
|
if b < pb-edgeBleed then
|
|
dy=(pb-edgeBleed)-b
|
|
elseif t > pt+edgeBleed then
|
|
dy=(pt+edgeBleed)-t
|
|
end
|
|
|
|
if dx==0 and dy==0 then return false end
|
|
|
|
-- Move by the required correction delta instead of reconstructing the frame
|
|
-- position from width/height. This preserves the exact dragged position and
|
|
-- avoids scale-dependent right/top snapping.
|
|
local cx,cy=frame:GetCenter()
|
|
local ux,uy=UIParent:GetCenter()
|
|
if not cx or not cy or not ux or not uy then return false end
|
|
frame:ClearAllPoints()
|
|
frame:SetPoint("CENTER",UIParent,"CENTER",(cx-ux)+dx,(cy-uy)+dy)
|
|
return true
|
|
end
|
|
|
|
local function restoreWindowState()
|
|
initializeSavedVariables()
|
|
-- Clamp the restored frame at its saved scale, not the initial 100% size.
|
|
-- Otherwise a valid small window near an edge is pushed inward on reload.
|
|
if D.uiSettings then frame:SetScale(D.uiSettings().scale) end
|
|
local src=nil
|
|
-- The meter layout is shared. Prefer the account-wide copy so a stale
|
|
-- per-character layout from another client/older build cannot reset or
|
|
-- override the position that was just saved on the other character.
|
|
if layoutIsValid(DB) then src=DB elseif layoutIsValid(CharDB) then src=CharDB end
|
|
if src then
|
|
if src.width and src.width>=160 and src.width<=900 then frame:SetWidth(src.width) end
|
|
if src.height and src.height>=70 and src.height<=900 then frame:SetHeight(src.height) end
|
|
frame:ClearAllPoints()
|
|
-- Layout v4 stores only a CENTER offset. This avoids restoring absolute
|
|
-- GetLeft/GetTop coordinates across UI-scale/resolution changes, which
|
|
-- could make the custom 1.12 client decide the frame was off-screen and
|
|
-- snap it back to Caw's default position.
|
|
if src.layoutVersion and src.layoutVersion>=4 and src.centerX ~= nil and src.centerY ~= nil then
|
|
if D.uiAnchorCenter then D.uiAnchorCenter(frame,src.centerX,src.centerY) else frame:SetPoint("CENTER",UIParent,"CENTER",src.centerX,src.centerY) end
|
|
elseif src.point and src.x ~= nil and src.y ~= nil then
|
|
frame:SetPoint(src.point,UIParent,src.relativePoint or src.point,src.x,src.y)
|
|
elseif src.left ~= nil and src.top ~= nil then
|
|
frame:SetPoint("TOPLEFT",UIParent,"BOTTOMLEFT",src.left,src.top)
|
|
else
|
|
frame:SetPoint("CENTER",UIParent,"CENTER",260,0)
|
|
end
|
|
D.locked=src.locked and true or false
|
|
copyLayout(src,DB); copyLayout(src,CharDB)
|
|
else
|
|
frame:ClearAllPoints(); frame:SetPoint("CENTER",UIParent,"CENTER",260,0)
|
|
frame:SetWidth(360); frame:SetHeight(178)
|
|
end
|
|
ensureWindowOnScreen()
|
|
D.layoutRestored=true
|
|
if D.uiSetMeterShown then D.uiSetMeterShown(frame,not DB.windowHidden) end
|
|
end
|
|
|
|
if frame.SetMinResize then pcall(frame.SetMinResize,frame,160,70) end
|
|
if frame.SetMaxResize then pcall(frame.SetMaxResize,frame,900,900) end
|
|
|
|
local function writeWindowState(dst)
|
|
if not dst then return end
|
|
local w=frame:GetWidth(); local h=frame:GetHeight()
|
|
if w and w>0 then dst.width=w end
|
|
if h and h>0 then dst.height=h end
|
|
local point,relativeTo,relativePoint,x,y=frame:GetPoint()
|
|
if point then
|
|
dst.point=point; dst.relativePoint=relativePoint or point; dst.x=x or 0; dst.y=y or 0
|
|
end
|
|
local left=frame:GetLeft(); local top=frame:GetTop()
|
|
if left ~= nil then dst.left=left end
|
|
if top ~= nil then dst.top=top end
|
|
-- Persist a resolution/UI-scale-stable center offset as the authoritative
|
|
-- position for new saves. GetCenter is read only after movement has stopped.
|
|
local cx,cy=frame:GetCenter()
|
|
local ux,uy=nil,nil
|
|
if UIParent and UIParent.GetCenter then ux,uy=UIParent:GetCenter() end
|
|
if cx and cy and ux and uy then
|
|
dst.centerX=cx-ux
|
|
dst.centerY=cy-uy
|
|
end
|
|
if D.uiCenterOffset then dst.centerX,dst.centerY=D.uiCenterOffset(frame) end
|
|
dst.locked=D.locked and true or false
|
|
if frame.cawDockFree then dst.centerX=frame.cawDockFree.x; dst.centerY=frame.cawDockFree.y end
|
|
dst.mode=D.mode
|
|
dst.segment=(D.segment=="overall") and "overall" or "current"
|
|
dst.segmentIndex=0
|
|
dst.layoutVersion=4
|
|
dst.layoutSaved=true
|
|
dst.appearance=D.uiCopySettings and D.uiCopySettings(D.uiSettings()) or nil
|
|
dst.windowHidden=frame.cawManuallyHidden and true or false
|
|
end
|
|
|
|
local function saveWindowState()
|
|
initializeSavedVariables()
|
|
if not D.layoutRestored or not D.multiWindowsRestored or D.restoringMultiWindows then return end
|
|
-- Re-bind to the globals immediately before every write. This is deliberate:
|
|
-- if a custom client swapped the SavedVariables table during loading, we
|
|
-- always write into the exact table WoW will serialize on logout.
|
|
DB = CawDPSMeterDB
|
|
CharDB = CawDPSMeterCharDB
|
|
writeWindowState(DB)
|
|
writeWindowState(CharDB)
|
|
DB.pfDockMain=CharDB.pfDockMain
|
|
DB.pfDockAlternate=CharDB.pfDockAlternate
|
|
DB.pfDockRevision=CharDB.pfDockRevision
|
|
DB.bagLayerAlwaysBehind=CharDB.bagLayerAlwaysBehind
|
|
DB.closedWindows=copyWindowValue(CharDB.closedWindows)
|
|
end
|
|
|
|
frame:RegisterForDrag("LeftButton")
|
|
frame:SetScript("OnDragStart",function() if not D.locked and not this.cawDockFree then this:StartMoving() end end)
|
|
frame:SetScript("OnDragStop",function()
|
|
this:StopMovingOrSizing()
|
|
ensureWindowOnScreen()
|
|
saveWindowState()
|
|
end)
|
|
|
|
-- Clean pfUI/ElvUI-inspired shell: square corners, very dark panels and
|
|
-- restrained one-pixel separators instead of Blizzard's ornate borders.
|
|
local FLAT_TEX="Interface\\Buttons\\WHITE8X8"
|
|
local function flatPanel(f,r,g,b,a,border)
|
|
f:SetBackdrop({bgFile=FLAT_TEX,edgeFile=FLAT_TEX,edgeSize=1,insets={left=1,right=1,top=1,bottom=1}})
|
|
f:SetBackdropColor(r or 0.035,g or 0.035,b or 0.035,a or 0.96)
|
|
local br=border or 0.20
|
|
f:SetBackdropBorderColor(br,br,br,1)
|
|
end
|
|
flatPanel(frame,0.025,0.025,0.025,0.98,0.18)
|
|
|
|
-- Top title bar, based on the approved Caw mockup.
|
|
local header=frame:CreateTexture(nil,"ARTWORK"); header:SetTexture(FLAT_TEX); header:SetVertexColor(0.035,0.035,0.035,1); header:SetPoint("TOPLEFT",frame,"TOPLEFT",1,-1); header:SetPoint("TOPRIGHT",frame,"TOPRIGHT",-1,-1); header:SetHeight(26)
|
|
local headerLine=frame:CreateTexture(nil,"ARTWORK"); headerLine:SetTexture(FLAT_TEX); headerLine:SetVertexColor(0.18,0.18,0.18,1); headerLine:SetPoint("TOPLEFT",frame,"TOPLEFT",1,-27); headerLine:SetPoint("TOPRIGHT",frame,"TOPRIGHT",-1,-27); headerLine:SetHeight(1)
|
|
-- Branded header artwork: the attack-claw and stylized CAW DPS METER wordmark
|
|
-- are baked into one transparent texture so the header stays compact and crisp.
|
|
local brand=frame:CreateTexture(nil,"ARTWORK"); brand:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawBrand.tga"); brand:SetWidth(168); brand:SetHeight(21); brand:SetPoint("TOPLEFT",frame,"TOPLEFT",6,-3)
|
|
|
|
-- Second toolbar: mode selector left, clearly labelled total right.
|
|
local toolbar=frame:CreateTexture(nil,"ARTWORK"); toolbar:SetTexture(FLAT_TEX); toolbar:SetVertexColor(0.065,0.065,0.065,1); toolbar:SetPoint("TOPLEFT",frame,"TOPLEFT",1,-28); toolbar:SetPoint("TOPRIGHT",frame,"TOPRIGHT",-1,-28); toolbar:SetHeight(22)
|
|
local toolbarLine=frame:CreateTexture(nil,"ARTWORK"); toolbarLine:SetTexture(FLAT_TEX); toolbarLine:SetVertexColor(0.18,0.18,0.18,1); toolbarLine:SetPoint("TOPLEFT",frame,"TOPLEFT",1,-50); toolbarLine:SetPoint("TOPRIGHT",frame,"TOPRIGHT",-1,-50); toolbarLine:SetHeight(1)
|
|
local summary=frame:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); summary:SetPoint("RIGHT",frame,"TOPRIGHT",-8,-39); summary:SetJustifyH("RIGHT"); summary:SetTextColor(0.90,0.90,0.90)
|
|
|
|
local updateUI
|
|
local segmentMenu=nil
|
|
local segmentText=nil
|
|
local segmentButton=nil
|
|
local segmentMenuButtons={}
|
|
local SEGMENT_MENU_VISIBLE=6
|
|
local segmentMenuOffset=0
|
|
local segmentItems={}
|
|
local segmentItemCount=0
|
|
|
|
local function shortFightName(name)
|
|
if not name or name=="" then return "Unknown Enemy" end
|
|
if string.len(name)<=14 then return name end
|
|
|
|
-- Prefer the common addon style: abbreviate earlier words and keep the
|
|
-- final creature name readable, e.g. "Thunderhawk Hatchling" -> "T. Hatchling".
|
|
local _,_,prefix,last=string.find(name,"^(.*) ([^ ]+)$")
|
|
if prefix and last then
|
|
local initials=string.sub(prefix,1,1).."."
|
|
local pos=1
|
|
while true do
|
|
local s=string.find(prefix," ",pos,true)
|
|
if not s then break end
|
|
if s<string.len(prefix) then
|
|
initials=initials.." "..string.sub(prefix,s+1,s+1).."."
|
|
end
|
|
pos=s+1
|
|
end
|
|
local compact=initials.." "..last
|
|
if string.len(compact)<=16 then return compact end
|
|
|
|
-- If the final word itself is unusually long, keep the initials and
|
|
-- trim only the tail rather than chopping the whole name arbitrarily.
|
|
local room=16-string.len(initials)-1
|
|
if room>=4 then return initials.." "..string.sub(last,1,room-2)..".." end
|
|
end
|
|
|
|
-- Single very long word fallback.
|
|
return string.sub(name,1,14)..".."
|
|
end
|
|
|
|
function D.historyLabel(h,index,compact)
|
|
local name=h and h.name or "Unknown Enemy"
|
|
if compact then name=shortFightName(name) end
|
|
if h and type(h.startedAt)=="number" and type(date)=="function" then
|
|
if h.labelStartedAt~=h.startedAt then
|
|
h.startLabel=date("%H:%M:%S",math.floor(h.startedAt))
|
|
h.labelStartedAt=h.startedAt
|
|
end
|
|
return h.startLabel.." - "..name
|
|
end
|
|
-- Older/incomplete records have no trustworthy wall-clock start.
|
|
return (index and (tostring(index)..". ") or "")..name
|
|
end
|
|
|
|
local function selectedSegmentLabel()
|
|
if D.mode=="threat" and D.segment=="current" and D.serverThreatTargetName then return shortFightName(D.serverThreatTargetName()) end
|
|
if D.segment=="overall" then return "Overall" end
|
|
if D.segment=="history" then
|
|
local h=D.fightHistory[D.segmentIndex or 1]
|
|
if h then return D.historyLabel(h,nil,true) end
|
|
return "Previous Fight"
|
|
end
|
|
local name=currentFightLabel()
|
|
if name=="Current" then return "Current" end
|
|
return shortFightName(name)
|
|
end
|
|
|
|
local function selectSegment(kind,index)
|
|
D.segment=kind
|
|
D.segmentIndex=index or 0
|
|
D.scrollOffset=0
|
|
initializeSavedVariables()
|
|
DB=CawDPSMeterDB
|
|
CharDB=CawDPSMeterCharDB
|
|
-- Fight history is not persisted across reloads, so only Current/Overall
|
|
-- are meaningful persistent selections for the primary window.
|
|
local persistSegment=(kind=="overall") and "overall" or "current"
|
|
if DB then DB.segment=persistSegment; DB.segmentIndex=0 end
|
|
if CharDB then CharDB.segment=persistSegment; CharDB.segmentIndex=0 end
|
|
if segmentMenu then segmentMenu:Hide() end
|
|
if updateUI then updateUI() end
|
|
end
|
|
|
|
local function buildSegmentItems()
|
|
segmentItems={}
|
|
segmentItemCount=0
|
|
segmentItemCount=segmentItemCount+1
|
|
segmentItems[segmentItemCount]={kind="current",label=(currentFightLabel()=="Current" and "Current" or ("Current - "..shortFightName(currentFightLabel())))}
|
|
local i=1
|
|
while i<=table.getn(D.fightHistory) and i<=10 do
|
|
local h=D.fightHistory[i]
|
|
segmentItemCount=segmentItemCount+1
|
|
segmentItems[segmentItemCount]={kind="history",index=i,label=D.historyLabel(h,i,true),fullLabel=D.historyLabel(h,i)}
|
|
i=i+1
|
|
end
|
|
segmentItemCount=segmentItemCount+1
|
|
segmentItems[segmentItemCount]={kind="overall",label="Overall ("..tostring(D.overallSegment.fights or 0)..")"}
|
|
end
|
|
|
|
local function clampSegmentOffset()
|
|
local maxOffset=segmentItemCount-SEGMENT_MENU_VISIBLE
|
|
if maxOffset<0 then maxOffset=0 end
|
|
if segmentMenuOffset<0 then segmentMenuOffset=0 end
|
|
if segmentMenuOffset>maxOffset then segmentMenuOffset=maxOffset end
|
|
end
|
|
|
|
local refreshSegmentMenu
|
|
|
|
segmentButton=CreateFrame("Button",nil,frame)
|
|
segmentButton:SetWidth(118); segmentButton:SetHeight(17); segmentButton:SetPoint("LEFT",frame,"TOPLEFT",145,-39)
|
|
flatPanel(segmentButton,0.065,0.065,0.065,1,0.25)
|
|
segmentButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
segmentText=segmentButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
|
|
segmentText:SetPoint("LEFT",segmentButton,"LEFT",6,0); segmentText:SetWidth(94); segmentText:SetJustifyH("LEFT"); segmentText:SetText("Current")
|
|
local segmentArrow=segmentButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
|
|
segmentArrow:SetPoint("RIGHT",segmentButton,"RIGHT",-6,0); segmentArrow:SetText("v")
|
|
|
|
segmentMenu=CreateFrame("Frame",nil,frame)
|
|
segmentMenu:SetWidth(178); segmentMenu:SetHeight((SEGMENT_MENU_VISIBLE*20)+30)
|
|
segmentMenu:SetPoint("TOPLEFT",segmentButton,"BOTTOMLEFT",0,-1)
|
|
flatPanel(segmentMenu,0.035,0.035,0.045,0.98,0.28)
|
|
segmentMenu.cawDropdown=true; segmentMenu:SetFrameStrata("DIALOG"); segmentMenu:SetFrameLevel(60)
|
|
segmentMenu:EnableMouseWheel(true)
|
|
segmentMenu:Hide()
|
|
|
|
local segUp=CreateFrame("Button",nil,segmentMenu)
|
|
segUp:SetWidth(22); segUp:SetHeight(18); segUp:SetPoint("TOPRIGHT",segmentMenu,"TOPRIGHT",-4,-4)
|
|
local segUpText=segUp:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); segUpText:SetPoint("CENTER",segUp,"CENTER",0,0); segUpText:SetText("^")
|
|
|
|
local segDown=CreateFrame("Button",nil,segmentMenu)
|
|
segDown:SetWidth(22); segDown:SetHeight(18); segDown:SetPoint("BOTTOMRIGHT",segmentMenu,"BOTTOMRIGHT",-4,4)
|
|
local segDownText=segDown:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); segDownText:SetPoint("CENTER",segDown,"CENTER",0,0); segDownText:SetText("v")
|
|
segmentMenu.buttons=segmentMenuButtons; segmentMenu.up=segUp; segmentMenu.down=segDown
|
|
|
|
refreshSegmentMenu=function()
|
|
buildSegmentItems()
|
|
clampSegmentOffset()
|
|
local i=1
|
|
while i<=SEGMENT_MENU_VISIBLE do
|
|
local b=segmentMenuButtons[i]
|
|
local item=segmentItems[segmentMenuOffset+i]
|
|
if b and item then
|
|
b.kind=item.kind
|
|
b.historyIndex=item.index
|
|
b.cawSegmentFullLabel=item.fullLabel or item.label
|
|
b.text:SetText(item.label)
|
|
local active=(D.segment==item.kind and (item.kind~="history" or D.segmentIndex==item.index))
|
|
if active then
|
|
b.bg:SetVertexColor(0.34,0.24,0.08,0.95)
|
|
b.text:SetTextColor(1.00,0.82,0.35)
|
|
else
|
|
b.bg:SetVertexColor(0.12,0.12,0.12,0)
|
|
b.text:SetTextColor(0.92,0.92,0.92)
|
|
end
|
|
b:Show()
|
|
elseif b then
|
|
b.kind=nil
|
|
b.historyIndex=nil
|
|
b:Hide()
|
|
end
|
|
i=i+1
|
|
end
|
|
if segmentMenuOffset>0 then segUp:Enable(); segUpText:SetTextColor(1,1,1) else segUp:Disable(); segUpText:SetTextColor(0.4,0.4,0.4) end
|
|
if segmentMenuOffset+SEGMENT_MENU_VISIBLE<segmentItemCount then segDown:Enable(); segDownText:SetTextColor(1,1,1) else segDown:Disable(); segDownText:SetTextColor(0.4,0.4,0.4) end
|
|
if D.layoutSegmentMenu and D.mainView then D.layoutSegmentMenu(D.mainView) end
|
|
end
|
|
|
|
local si=1
|
|
while si<=SEGMENT_MENU_VISIBLE do
|
|
local b=CreateFrame("Button",nil,segmentMenu)
|
|
b:SetHeight(20)
|
|
b:SetPoint("TOPLEFT",segmentMenu,"TOPLEFT",4,-4-((si-1)*20))
|
|
b:SetPoint("RIGHT",segmentMenu,"RIGHT",-30,0)
|
|
b.bg=b:CreateTexture(nil,"BACKGROUND"); b.bg:SetTexture(FLAT_TEX); b.bg:SetAllPoints(b); b.bg:SetVertexColor(0.12,0.12,0.12,0)
|
|
b.text=b:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); b.text:SetPoint("LEFT",b,"LEFT",6,0); b.text:SetPoint("RIGHT",b,"RIGHT",-4,0); b.text:SetJustifyH("LEFT")
|
|
b:SetScript("OnClick",function() if this.kind then selectSegment(this.kind,this.historyIndex) end end)
|
|
b:SetScript("OnEnter",function() this.bg:SetVertexColor(0.22,0.22,0.22,0.95) end)
|
|
b:SetScript("OnLeave",function()
|
|
local active=(D.segment==this.kind and (this.kind~="history" or D.segmentIndex==this.historyIndex))
|
|
if active then this.bg:SetVertexColor(0.34,0.24,0.08,0.95) else this.bg:SetVertexColor(0.12,0.12,0.12,0) end
|
|
end)
|
|
segmentMenuButtons[si]=b
|
|
si=si+1
|
|
end
|
|
|
|
local function scrollSegmentMenu(delta)
|
|
buildSegmentItems()
|
|
if delta>0 then segmentMenuOffset=segmentMenuOffset-1 else segmentMenuOffset=segmentMenuOffset+1 end
|
|
clampSegmentOffset()
|
|
refreshSegmentMenu()
|
|
end
|
|
|
|
segmentMenu:SetScript("OnMouseWheel",function() scrollSegmentMenu(arg1 or 0) end)
|
|
segUp:SetScript("OnClick",function() scrollSegmentMenu(1) end)
|
|
segDown:SetScript("OnClick",function() scrollSegmentMenu(-1) end)
|
|
|
|
segmentButton:SetScript("OnClick",function()
|
|
if segmentMenu:IsShown() then
|
|
segmentMenu:Hide()
|
|
else
|
|
buildSegmentItems()
|
|
if D.segment=="history" and D.segmentIndex and D.segmentIndex>0 then
|
|
segmentMenuOffset=D.segmentIndex-2
|
|
elseif D.segment=="overall" then
|
|
segmentMenuOffset=segmentItemCount-SEGMENT_MENU_VISIBLE
|
|
else
|
|
segmentMenuOffset=0
|
|
end
|
|
clampSegmentOffset()
|
|
refreshSegmentMenu()
|
|
segmentMenu:Show()
|
|
end
|
|
end)
|
|
segmentButton:SetScript("OnEnter",function() segmentButton:SetBackdropColor(0.14,0.14,0.14,1) end)
|
|
segmentButton:SetScript("OnLeave",function() segmentButton:SetBackdropColor(0.08,0.08,0.08,1) end)
|
|
|
|
-- MODE_LABELS is declared once above the UI section.
|
|
local MODE_ORDER={"damage","healing","overhealing","threat","damageTaken","deaths","interrupts","cc","ccBreaks","dispels","buffs","debuffsCast","debuffsReceived"}
|
|
local modeMenu
|
|
|
|
local modeButton=CreateFrame("Button",nil,frame)
|
|
modeButton:SetWidth(132); modeButton:SetHeight(17); modeButton:SetPoint("TOPLEFT",frame,"TOPLEFT",7,-31)
|
|
flatPanel(modeButton,0.065,0.065,0.065,1,0.25)
|
|
modeButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
local modeText=modeButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); modeText:SetPoint("LEFT",modeButton,"LEFT",7,0); modeText:SetJustifyH("LEFT")
|
|
-- Always render a valid initial selection even before PLAYER_ENTERING_WORLD/updateUI.
|
|
modeText:SetText(MODE_LABELS[D.mode] or "Damage / DPS")
|
|
local modeArrow=modeButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); modeArrow:SetPoint("RIGHT",modeButton,"RIGHT",-6,0); modeArrow:SetText("v")
|
|
|
|
modeMenu=CreateFrame("Frame",nil,frame)
|
|
-- Keep the mode selector compact. Only a subset of entries is shown at once;
|
|
-- the rest can be reached with the mouse wheel or the arrow buttons.
|
|
local MODE_MENU_VISIBLE=6
|
|
local modeMenuOffset=0
|
|
local modeMenuButtons={}
|
|
modeMenu:SetWidth(152); modeMenu:SetHeight((MODE_MENU_VISIBLE*20)+30); modeMenu:SetPoint("TOPLEFT",modeButton,"BOTTOMLEFT",0,-1)
|
|
flatPanel(modeMenu,0.025,0.025,0.025,0.99,0.25)
|
|
modeMenu:SetBackdropColor(0.025,0.025,0.025,0.99)
|
|
if modeMenu.SetFrameStrata then modeMenu.cawDropdown=true; modeMenu:SetFrameStrata("DIALOG"); modeMenu:SetFrameLevel(60) end
|
|
if modeMenu.EnableMouseWheel then modeMenu:EnableMouseWheel(true) end
|
|
modeMenu:Hide()
|
|
|
|
local function setMode(mode)
|
|
D.mode=mode
|
|
D.scrollOffset=0
|
|
initializeSavedVariables()
|
|
DB=CawDPSMeterDB
|
|
CharDB=CawDPSMeterCharDB
|
|
if DB then DB.mode=mode end
|
|
if CharDB then CharDB.mode=mode end
|
|
if modeMenu then modeMenu:Hide() end
|
|
if updateUI then updateUI() end
|
|
end
|
|
|
|
local function clampModeMenuOffset()
|
|
local maxOffset=table.getn(MODE_ORDER)-MODE_MENU_VISIBLE
|
|
if maxOffset<0 then maxOffset=0 end
|
|
if modeMenuOffset<0 then modeMenuOffset=0 end
|
|
if modeMenuOffset>maxOffset then modeMenuOffset=maxOffset end
|
|
end
|
|
|
|
local function refreshModeMenu()
|
|
clampModeMenuOffset()
|
|
local i=1
|
|
while i<=MODE_MENU_VISIBLE do
|
|
local b=modeMenuButtons[i]
|
|
local idx=modeMenuOffset+i
|
|
local m=MODE_ORDER[idx]
|
|
if m then
|
|
b.mode=m
|
|
b.text:SetText(MODE_LABELS[m] or m)
|
|
if m==D.mode then
|
|
b.hi:SetVertexColor(0.34,0.24,0.08,0.95)
|
|
b.text:SetTextColor(1.00,0.82,0.35)
|
|
else
|
|
b.hi:SetVertexColor(0.24,0.24,0.24,0)
|
|
b.text:SetTextColor(0.92,0.92,0.92)
|
|
end
|
|
b:Show()
|
|
else
|
|
b.mode=nil
|
|
b:Hide()
|
|
end
|
|
i=i+1
|
|
end
|
|
if D.uiStyleMeterMenu and D.mainView then D.uiStyleMeterMenu(D.mainView,modeMenu,"mode") end
|
|
end
|
|
|
|
local function scrollModeMenu(delta)
|
|
if not delta or delta==0 then return end
|
|
if delta>0 then modeMenuOffset=modeMenuOffset-1 else modeMenuOffset=modeMenuOffset+1 end
|
|
refreshModeMenu()
|
|
end
|
|
|
|
local modeUp=CreateFrame("Button",nil,modeMenu)
|
|
modeUp:SetWidth(144); modeUp:SetHeight(13); modeUp:SetPoint("TOPLEFT",modeMenu,"TOPLEFT",4,-3)
|
|
local modeUpHi=modeUp:CreateTexture(nil,"BACKGROUND"); modeUpHi:SetAllPoints(modeUp); modeUpHi:SetTexture(FLAT_TEX); modeUpHi:SetVertexColor(0.24,0.24,0.24,0)
|
|
local modeUpText=modeUp:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); modeUpText:SetPoint("CENTER",modeUp,"CENTER",0,0); modeUpText:SetText("^")
|
|
modeUp:SetScript("OnClick",function() scrollModeMenu(1) end)
|
|
modeUp:SetScript("OnEnter",function() modeUpHi:SetVertexColor(0.24,0.24,0.24,0.85) end)
|
|
modeUp:SetScript("OnLeave",function() modeUpHi:SetVertexColor(0.24,0.24,0.24,0) end)
|
|
|
|
local mi=1
|
|
while mi<=MODE_MENU_VISIBLE do
|
|
local b=CreateFrame("Button",nil,modeMenu)
|
|
b:SetWidth(144); b:SetHeight(19); b:SetPoint("TOPLEFT",modeMenu,"TOPLEFT",4,-16-((mi-1)*20))
|
|
local hi=b:CreateTexture(nil,"BACKGROUND"); hi:SetAllPoints(b); hi:SetTexture(FLAT_TEX); hi:SetVertexColor(0.24,0.24,0.24,0)
|
|
local fs=b:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); fs:SetPoint("LEFT",b,"LEFT",6,0)
|
|
b.hi=hi; b.text=fs
|
|
b:SetScript("OnClick",function() if this.mode then setMode(this.mode) end end)
|
|
b:SetScript("OnEnter",function() this.hi:SetVertexColor(0.24,0.24,0.24,0.85) end)
|
|
b:SetScript("OnLeave",function()
|
|
if this.mode==D.mode then
|
|
this.hi:SetVertexColor(0.34,0.24,0.08,0.95)
|
|
this.text:SetTextColor(1.00,0.82,0.35)
|
|
else
|
|
this.hi:SetVertexColor(0.24,0.24,0.24,0)
|
|
this.text:SetTextColor(0.92,0.92,0.92)
|
|
end
|
|
end)
|
|
modeMenuButtons[mi]=b
|
|
mi=mi+1
|
|
end
|
|
|
|
local modeDown=CreateFrame("Button",nil,modeMenu)
|
|
modeMenu.buttons=modeMenuButtons; modeMenu.up=modeUp; modeMenu.down=modeDown
|
|
modeDown:SetWidth(144); modeDown:SetHeight(13); modeDown:SetPoint("BOTTOMLEFT",modeMenu,"BOTTOMLEFT",4,3)
|
|
local modeDownHi=modeDown:CreateTexture(nil,"BACKGROUND"); modeDownHi:SetAllPoints(modeDown); modeDownHi:SetTexture(FLAT_TEX); modeDownHi:SetVertexColor(0.24,0.24,0.24,0)
|
|
local modeDownText=modeDown:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); modeDownText:SetPoint("CENTER",modeDown,"CENTER",0,0); modeDownText:SetText("v")
|
|
modeDown:SetScript("OnClick",function() scrollModeMenu(-1) end)
|
|
modeDown:SetScript("OnEnter",function() modeDownHi:SetVertexColor(0.24,0.24,0.24,0.85) end)
|
|
modeDown:SetScript("OnLeave",function() modeDownHi:SetVertexColor(0.24,0.24,0.24,0) end)
|
|
|
|
modeMenu:SetScript("OnMouseWheel",function() scrollModeMenu(arg1) end)
|
|
modeButton:SetScript("OnClick",function()
|
|
if modeMenu:IsVisible() then
|
|
modeMenu:Hide()
|
|
else
|
|
-- Open around the current selection where possible, so the active mode
|
|
-- is visible even when it is near the bottom of the list.
|
|
local currentIndex=1
|
|
local i=1
|
|
while i<=table.getn(MODE_ORDER) do
|
|
if MODE_ORDER[i]==D.mode then currentIndex=i; break end
|
|
i=i+1
|
|
end
|
|
if currentIndex>MODE_MENU_VISIBLE then modeMenuOffset=currentIndex-MODE_MENU_VISIBLE else modeMenuOffset=0 end
|
|
refreshModeMenu()
|
|
modeMenu:Show()
|
|
end
|
|
end)
|
|
modeButton:SetScript("OnEnter",function() modeButton:SetBackdropColor(0.14,0.14,0.14,1) end)
|
|
modeButton:SetScript("OnLeave",function() modeButton:SetBackdropColor(0.08,0.08,0.08,1) end)
|
|
|
|
-- Explicit text buttons: much clearer than the former single-letter controls.
|
|
local resetButton=CreateFrame("Button",nil,frame)
|
|
resetButton:SetWidth(56); resetButton:SetHeight(17); resetButton:SetPoint("TOPRIGHT",frame,"TOPRIGHT",-25,-5)
|
|
flatPanel(resetButton,0.065,0.065,0.065,1,0.25)
|
|
resetButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
local resetText=resetButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); resetText:SetPoint("CENTER",resetButton,"CENTER",0,0); resetText:SetText("")
|
|
resetButton.icon=resetButton:CreateTexture(nil,"ARTWORK"); resetButton.icon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawReset.tga"); resetButton.icon:SetWidth(12); resetButton.icon:SetHeight(12); resetButton.icon:SetPoint("CENTER",resetButton,"CENTER",0,0)
|
|
resetButton:SetScript("OnClick",function() if D.uiConfirmReset then D.uiConfirmReset() end end)
|
|
resetButton:SetScript("OnEnter",function()
|
|
this:SetBackdropColor(0.15,0.15,0.15,1)
|
|
local tt=D.getControlTooltip(); if tt then tt:SetOwner(this,"ANCHOR_TOP"); tt:SetText("Reset meter data",1,1,1); tt:Show() end
|
|
end)
|
|
resetButton:SetScript("OnLeave",function() this:SetBackdropColor(0.08,0.08,0.08,1); if D.controlTooltip then D.controlTooltip:Hide() end end)
|
|
|
|
local closeButton=CreateFrame("Button",nil,frame)
|
|
closeButton:SetWidth(17); closeButton:SetHeight(17); closeButton:SetPoint("TOPRIGHT",frame,"TOPRIGHT",-5,-5)
|
|
flatPanel(closeButton,0.065,0.065,0.065,1,0.25)
|
|
closeButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
local closeText=closeButton:CreateFontString(nil,"OVERLAY","GameFontHighlight"); closeText:SetPoint("CENTER",closeButton,"CENTER",0,0); closeText:SetText("")
|
|
closeButton.icon=closeButton:CreateTexture(nil,"ARTWORK"); closeButton.icon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawClose.tga"); closeButton.icon:SetWidth(12); closeButton.icon:SetHeight(12); closeButton.icon:SetPoint("CENTER",closeButton,"CENTER",0,0)
|
|
closeButton:SetScript("OnClick",function() if D.uiSetMeterShown then D.uiSetMeterShown(frame,false) else frame:Hide() end end)
|
|
closeButton:SetScript("OnEnter",function() this:SetBackdropColor(0.28,0.08,0.08,1); local tt=D.getControlTooltip(); if tt then tt:SetOwner(this,"ANCHOR_TOP"); tt:SetText("Close window",1,1,1); tt:Show() end end)
|
|
closeButton:SetScript("OnLeave",function() this:SetBackdropColor(0.08,0.08,0.08,1); if D.controlTooltip then D.controlTooltip:Hide() end end)
|
|
|
|
local sizeGrip
|
|
local lockButton=CreateFrame("Button",nil,frame)
|
|
lockButton:SetWidth(56); lockButton:SetHeight(17); lockButton:SetPoint("RIGHT",resetButton,"LEFT",-4,0)
|
|
flatPanel(lockButton,0.065,0.065,0.065,1,0.25)
|
|
local lockText=lockButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); lockText:SetPoint("CENTER",lockButton,"CENTER",0,0); lockText:SetText("")
|
|
lockButton.icon=lockButton:CreateTexture(nil,"ARTWORK"); lockButton.icon:SetWidth(12); lockButton.icon:SetHeight(12); lockButton.icon:SetPoint("CENTER",lockButton,"CENTER",0,0)
|
|
local function applyWindowLock()
|
|
-- UI construction happens before ADDON_LOADED. Never dereference DB here
|
|
-- until SavedVariables have actually been initialized.
|
|
if D.savedVariablesReady and DB then DB.locked=D.locked and true or false end
|
|
if D.savedVariablesReady and CharDB then CharDB.locked=D.locked and true or false end
|
|
if frame.SetMovable then frame:SetMovable(not D.locked) end
|
|
if frame.SetResizable then frame:SetResizable(not D.locked) end
|
|
if sizeGrip then
|
|
if D.locked then
|
|
sizeGrip:Hide()
|
|
else
|
|
sizeGrip:Show()
|
|
if sizeGrip.SetAlpha then sizeGrip:SetAlpha(0.92) end
|
|
end
|
|
end
|
|
if D.locked then
|
|
lockText:SetText(""); if lockButton.icon then lockButton.icon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawLock.tga") end; lockButton:SetBackdropColor(0.22,0.15,0.05,1)
|
|
else
|
|
lockText:SetText(""); if lockButton.icon then lockButton.icon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawUnlock.tga") end; lockButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
end
|
|
end
|
|
lockButton:SetScript("OnClick",function() D.locked=not D.locked; saveWindowState(); applyWindowLock(); if D.applyCompactWindowLayout then D.applyCompactWindowLayout() end end)
|
|
D.mainLockButton=lockButton
|
|
lockButton:SetScript("OnEnter",function()
|
|
local tt=D.getControlTooltip()
|
|
if tt then
|
|
tt:SetOwner(this,"ANCHOR_TOP")
|
|
if D.locked then tt:SetText("Unlock position and size",1,1,1)
|
|
else tt:SetText("Lock position and size",1,1,1) end
|
|
tt:Show()
|
|
end
|
|
end)
|
|
lockButton:SetScript("OnLeave",function() if D.controlTooltip then D.controlTooltip:Hide() end end)
|
|
|
|
function D.buildReportUI()
|
|
D.reportButton=CreateFrame("Button",nil,frame)
|
|
D.reportButton:SetWidth(56)
|
|
D.reportButton:SetHeight(17)
|
|
D.reportButton:SetPoint("RIGHT",lockButton,"LEFT",-4,0)
|
|
flatPanel(D.reportButton,0.065,0.065,0.065,1,0.25)
|
|
D.reportButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
|
|
D.reportText=D.reportButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
|
|
D.reportText:SetPoint("CENTER",D.reportButton,"CENTER",0,0)
|
|
D.reportText:SetText("Report")
|
|
D.reportIcon=D.reportButton:CreateTexture(nil,"ARTWORK")
|
|
D.reportIcon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawReport.tga")
|
|
D.reportIcon:SetWidth(12); D.reportIcon:SetHeight(12); D.reportIcon:SetPoint("CENTER",D.reportButton,"CENTER",0,0); D.reportIcon:Hide()
|
|
|
|
D.reportMenu=CreateFrame("Frame",nil,frame)
|
|
D.reportMenu:SetWidth(96)
|
|
D.reportMenu:SetHeight(106)
|
|
D.reportMenu:SetPoint("TOPRIGHT",D.reportButton,"BOTTOMRIGHT",0,-2)
|
|
flatPanel(D.reportMenu,0.025,0.025,0.025,0.99,0.25)
|
|
D.reportMenu:SetBackdropColor(0.025,0.025,0.025,0.99)
|
|
if D.reportMenu.SetFrameStrata then D.reportMenu.cawDropdown=true; D.reportMenu:SetFrameStrata("DIALOG"); D.reportMenu:SetFrameLevel(60) end
|
|
D.reportMenu:Hide()
|
|
|
|
local channels={
|
|
{label="Say",channel="SAY"},
|
|
{label="Party",channel="PARTY"},
|
|
{label="Raid",channel="RAID"},
|
|
{label="Guild",channel="GUILD"},
|
|
{label="Whisper...",channel="WHISPER"}
|
|
}
|
|
local i=1
|
|
while i<=table.getn(channels) do
|
|
local info=channels[i]
|
|
local b=CreateFrame("Button",nil,D.reportMenu)
|
|
b:SetWidth(88)
|
|
b:SetHeight(18)
|
|
b:SetPoint("TOPLEFT",D.reportMenu,"TOPLEFT",4,-4-((i-1)*20))
|
|
local hi=b:CreateTexture(nil,"BACKGROUND")
|
|
hi:SetAllPoints(b)
|
|
hi:SetTexture(FLAT_TEX)
|
|
hi:SetVertexColor(0.24,0.24,0.24,0)
|
|
local fs=b:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall")
|
|
fs:SetPoint("LEFT",b,"LEFT",7,0)
|
|
fs:SetText(info.label)
|
|
b.channel=info.channel
|
|
b.hi=hi; b.text=fs
|
|
D.reportMenu.buttons=D.reportMenu.buttons or {}; D.reportMenu.buttons[i]=b
|
|
b:SetScript("OnClick",function()
|
|
D.reportMenu:Hide()
|
|
if this.channel=="WHISPER" then D.openWhisperReport(nil) else D.sendReport(this.channel) end
|
|
end)
|
|
b:SetScript("OnEnter",function()
|
|
this.hi:SetVertexColor(0.24,0.24,0.24,0.85)
|
|
end)
|
|
b:SetScript("OnLeave",function()
|
|
this.hi:SetVertexColor(0.24,0.24,0.24,0)
|
|
end)
|
|
i=i+1
|
|
end
|
|
|
|
D.reportButton:SetScript("OnClick",function()
|
|
if D.reportMenu:IsShown() then
|
|
D.reportMenu:Hide()
|
|
else
|
|
if modeMenu then modeMenu:Hide() end
|
|
if segmentMenu then segmentMenu:Hide() end
|
|
D.reportMenu:Show()
|
|
end
|
|
end)
|
|
D.reportButton:SetScript("OnEnter",function()
|
|
D.reportButton:SetBackdropColor(0.14,0.14,0.14,1)
|
|
local tt=D.getControlTooltip(); if tt then tt:SetOwner(this,"ANCHOR_TOP"); tt:SetText("Report this view",1,1,1); tt:Show() end
|
|
end)
|
|
D.reportButton:SetScript("OnLeave",function()
|
|
D.reportButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
if D.controlTooltip then D.controlTooltip:Hide() end
|
|
end)
|
|
end
|
|
|
|
D.buildReportUI()
|
|
|
|
-- Main and additional windows share the same compact single-row header.
|
|
function D.applyCompactWindowLayout()
|
|
D.mainView=D.mainView or {id=1,frame=frame,brand=brand,header=header,headerLine=headerLine,toolbar=toolbar,toolbarLine=toolbarLine,
|
|
summary=summary,modeButton=modeButton,modeMenu=modeMenu,modeText=modeText,modeArrow=modeArrow,segmentButton=segmentButton,segmentMenu=segmentMenu,segmentText=segmentText,segmentArrow=segmentArrow,
|
|
closeButton=closeButton,resetButton=resetButton,lockButton=lockButton,reportButton=D.reportButton,reportMenu=D.reportMenu,addButton=D.multiAddButton}
|
|
D.layoutMeterHeader(D.mainView)
|
|
if D.uiStyleView then D.uiStyleView(D.mainView) end
|
|
D.reportText:SetText(""); resetText:SetText("")
|
|
if D.reportIcon then D.reportIcon:Show() end
|
|
if lockButton.icon then lockButton.icon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\"..(D.locked and "CawLock.tga" or "CawUnlock.tga")) end
|
|
end
|
|
|
|
-- Short labels leave room for the controls in narrower windows. The dropdown
|
|
-- menus still show the normal full labels when opened.
|
|
function D.compactModeLabel()
|
|
if D.mode=="damage" then return "DPS" end
|
|
if D.mode=="healing" then return "HPS" end
|
|
if D.mode=="overhealing" then return "Overheal" end
|
|
if D.mode=="threat" then return "Threat" end
|
|
if D.mode=="damageTaken" then return "Taken" end
|
|
if D.mode=="deaths" then return "Deaths" end
|
|
if D.mode=="interrupts" then return "Int" end
|
|
if D.mode=="cc" then return "CC" end
|
|
if D.mode=="ccBreaks" then return "CC Brk" end
|
|
if D.mode=="dispels" then return "Disp" end
|
|
if D.mode=="buffs" then return "Buffs" end
|
|
if D.mode=="debuffsCast" then return "D.Cast" end
|
|
if D.mode=="debuffsReceived" then return "D.Recv" end
|
|
return MODE_LABELS[D.mode] or D.mode
|
|
end
|
|
|
|
function D.compactSegmentLabel()
|
|
if D.segment=="overall" then return "All" end
|
|
if D.segment=="history" then return "#"..tostring(D.segmentIndex or 1) end
|
|
return "Cur"
|
|
end
|
|
|
|
function D.refreshSelectorLabels()
|
|
local w=frame:GetWidth() or 440
|
|
if w<360 then
|
|
modeText:SetText(D.compactModeLabel())
|
|
if segmentText then segmentText:SetText(selectedSegmentLabel()) end
|
|
else
|
|
modeText:SetText(MODE_LABELS[D.mode] or D.mode)
|
|
if segmentText then segmentText:SetText(selectedSegmentLabel()) end
|
|
end
|
|
end
|
|
|
|
sizeGrip=CreateFrame("Button",nil,frame)
|
|
-- Classic Details-style resize marker: three visible diagonal slashes.
|
|
-- The hit area remains larger than the artwork so resizing is easy.
|
|
sizeGrip:SetWidth(24); sizeGrip:SetHeight(24); sizeGrip:SetPoint("BOTTOMRIGHT",frame,"BOTTOMRIGHT",-1,1)
|
|
|
|
local gripSlash1=sizeGrip:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
|
|
gripSlash1:SetText("/")
|
|
gripSlash1:SetPoint("BOTTOMRIGHT",sizeGrip,"BOTTOMRIGHT",-3,1)
|
|
gripSlash1:SetTextColor(0.72,0.72,0.72,1)
|
|
|
|
local gripSlash2=sizeGrip:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
|
|
gripSlash2:SetText("/")
|
|
gripSlash2:SetPoint("BOTTOMRIGHT",sizeGrip,"BOTTOMRIGHT",-7,1)
|
|
gripSlash2:SetTextColor(0.72,0.72,0.72,1)
|
|
|
|
local gripSlash3=sizeGrip:CreateFontString(nil,"OVERLAY","GameFontNormalSmall")
|
|
gripSlash3:SetText("/")
|
|
gripSlash3:SetPoint("BOTTOMRIGHT",sizeGrip,"BOTTOMRIGHT",-11,1)
|
|
gripSlash3:SetTextColor(0.72,0.72,0.72,1)
|
|
|
|
sizeGrip.gripSlash1=gripSlash1
|
|
sizeGrip.gripSlash2=gripSlash2
|
|
sizeGrip.gripSlash3=gripSlash3
|
|
local function setGripSlashColor(r,g,b,a)
|
|
if sizeGrip.gripSlash1 then sizeGrip.gripSlash1:SetTextColor(r,g,b,a) end
|
|
if sizeGrip.gripSlash2 then sizeGrip.gripSlash2:SetTextColor(r,g,b,a) end
|
|
if sizeGrip.gripSlash3 then sizeGrip.gripSlash3:SetTextColor(r,g,b,a) end
|
|
end
|
|
sizeGrip:RegisterForDrag("LeftButton")
|
|
sizeGrip:SetScript("OnDragStart",function() if not D.locked and frame.StartSizing then frame:StartSizing("BOTTOMRIGHT") end end)
|
|
sizeGrip:SetScript("OnDragStop",function()
|
|
frame:StopMovingOrSizing()
|
|
ensureWindowOnScreen()
|
|
saveWindowState()
|
|
if D.applyCompactWindowLayout then D.applyCompactWindowLayout() end
|
|
if updateUI then updateUI() end
|
|
end)
|
|
sizeGrip:SetScript("OnEnter",function()
|
|
setGripSlashColor(1.00,0.82,0.35,1)
|
|
if not D.locked and this.SetAlpha then this:SetAlpha(1) end
|
|
local tt=D.getControlTooltip()
|
|
if tt then
|
|
tt:SetOwner(this,"ANCHOR_TOP")
|
|
if D.locked then tt:SetText("Window is locked - click Unlock to resize",1,1,1)
|
|
else tt:SetText("Drag this corner to resize",1,1,1) end
|
|
tt:Show()
|
|
end
|
|
end)
|
|
sizeGrip:SetScript("OnLeave",function()
|
|
setGripSlashColor(0.72,0.72,0.72,1)
|
|
if this.SetAlpha then this:SetAlpha(0.92) end
|
|
if D.controlTooltip then D.controlTooltip:Hide() end
|
|
end)
|
|
applyWindowLock()
|
|
|
|
D.fillThreatTooltip=function(tt,a)
|
|
if not tt or not a then return end
|
|
if a._serverThreatValue then
|
|
tt:AddDoubleLine("Server threat",comma(math.floor(a._serverThreatValue+0.5)),1,0.82,0,1,1,1)
|
|
tt:AddLine("Target: "..tostring(a._serverTarget),0.8,0.8,0.8)
|
|
tt:AddLine("Provisional target context: API has no target ID",0.85,0.75,0.45)
|
|
tt:AddLine("Percent uses server aggro thresholds",0.8,0.8,0.8)
|
|
if a.guid and D.threatPeerStatus then local status=D.threatPeerStatus(a); tt:AddDoubleLine("Caw Sync",status,0.8,0.8,0.8,1,1,1) end
|
|
if a._serverLocalValue then tt:AddDoubleLine("Local estimate",comma(math.floor(a._serverLocalValue+0.5)),0.8,0.8,0.8,1,1,1) end
|
|
return
|
|
end
|
|
tt:AddLine("Source: local estimate",0.85,0.75,0.45)
|
|
local tv=D.threatValueForActor and D.threatValueForActor(a) or 0
|
|
local refThreat=D.threatReferenceForDisplay and D.threatReferenceForDisplay() or (D.threatTopForDisplay and D.threatTopForDisplay() or 0)
|
|
local pct=D.threatPercentForActor and D.threatPercentForActor(a) or 0
|
|
tt:AddDoubleLine("Threat",comma(math.floor(tv+0.5)).." | "..string.format("%.1f%%",pct),1,0.82,0,1,1,1)
|
|
if D.segment=="current" and not a.isPet and D.threatPeerStatus then
|
|
local state,data,version=D.threatPeerStatus(a)
|
|
local labels={localState="Local client",detected="Detected",stale="Last response expired",["not-detected"]="Not detected (no current response)"}
|
|
tt:AddDoubleLine("Caw Sync",state=="local" and labels.localState or (labels[state] or state),0.82,0.82,0.82,1,1,1)
|
|
if version then tt:AddDoubleLine("Caw model",version,0.72,0.72,0.72,1,1,1) end
|
|
if a.classToken=="DRUID" then
|
|
local _,_,knowledge=D.threatDruidModifiers(a)
|
|
local texts={ ["feral-instinct-current"]="Feral Instinct received; other rules may be incomplete",["feral-instinct-unavailable"]="Feral Instinct supported; no current value",unsupported="No supported talent sync",unknown="Talents unknown; local estimate",["local-api"]="Own talent API" }
|
|
tt:AddLine(texts[data] or "Talent data unavailable",0.85,0.75,0.45)
|
|
if knowledge=="form-unknown" then tt:AddLine("Current form unavailable",0.85,0.75,0.45) end
|
|
elseif state~="local" then
|
|
local profile=D.talentProfileFor and D.talentProfileFor(a)
|
|
tt:AddLine(profile and profile.available and "Talent ranks received; threat rules incomplete" or "No current talent layout received",0.85,0.75,0.45)
|
|
end
|
|
end
|
|
local holder=D.threatAggroHolderGUID and D.threatAggroHolderGUID()
|
|
tt:AddLine(holder and "Aggro reference: current holder = 100%" or "Reference: highest displayed threat = 100%",0.72,0.72,0.72)
|
|
if D.segment=="current" then tt:AddLine("Pull threshold: 110% melee / 130% ranged",0.72,0.72,0.72) end
|
|
local tg=D.threatDisplayTargetGUID and D.threatDisplayTargetGUID() or nil
|
|
if tg then
|
|
local base=a.threatBase and (a.threatBase[tg] or 0) or 0
|
|
local mod=a.threatModifier and (a.threatModifier[tg] or 0) or 0
|
|
local special=a.threatSpecial and (a.threatSpecial[tg] or 0) or 0
|
|
tt:AddLine(" ")
|
|
tt:AddLine("Calculation",1,0.82,0)
|
|
tt:AddDoubleLine("Base threat",comma(math.floor(base+0.5)),0.82,0.82,0.82,1,1,1)
|
|
if math.abs(mod)>0.05 then tt:AddDoubleLine("Modifiers",(mod>=0 and "+" or "")..comma(math.floor(mod+0.5)),0.82,0.82,0.82,1,1,1) end
|
|
if math.abs(special)>0.05 then tt:AddDoubleLine("Special threat",(special>=0 and "+" or "")..comma(math.floor(special+0.5)),0.82,0.82,0.82,1,1,1) end
|
|
end
|
|
if a.threatAbilities and tg then
|
|
local ab=a.threatAbilities[tg]
|
|
if ab then
|
|
local al={}; local an=0; local ak,av
|
|
for ak,av in ab do an=an+1; al[an]={name=ak,value=av} end
|
|
table.sort(al,function(x,y) return x.value>y.value end)
|
|
if an>0 then tt:AddLine(" "); tt:AddLine("Threat sources",1,0.82,0) end
|
|
local ai=1
|
|
while ai<=an and ai<=10 do tt:AddDoubleLine(al[ai].name,comma(math.floor(al[ai].value+0.5)),1,1,1,0.86,0.86,0.86); ai=ai+1 end
|
|
if an>10 then tt:AddLine("+"..tostring(an-10).." more",0.7,0.7,0.7) end
|
|
end
|
|
end
|
|
end
|
|
|
|
D.rows={}
|
|
local MAX_ROWS=20
|
|
local ROW_HEIGHT=23
|
|
local ROW_STEP=26
|
|
local LIST_TOP=29
|
|
local i=1
|
|
while i<=MAX_ROWS do
|
|
local rowFrame=CreateFrame("Frame",nil,frame); rowFrame:SetHeight(ROW_HEIGHT); rowFrame:SetPoint("TOPLEFT",frame,"TOPLEFT",6,-LIST_TOP-((i-1)*ROW_STEP)); rowFrame:SetPoint("TOPRIGHT",frame,"TOPRIGHT",-6,-LIST_TOP-((i-1)*ROW_STEP))
|
|
flatPanel(rowFrame,0.055,0.055,0.055,0.96,0.16)
|
|
local bar=CreateFrame("StatusBar",nil,rowFrame); bar:SetPoint("TOPLEFT",rowFrame,"TOPLEFT",1,-1); bar:SetPoint("BOTTOMRIGHT",rowFrame,"BOTTOMRIGHT",-1,1); bar:SetStatusBarTexture(FLAT_TEX); bar:SetMinMaxValues(0,1); bar:SetValue(0); bar:EnableMouse(true)
|
|
if bar.EnableMouseWheel then bar:EnableMouseWheel(true) end
|
|
local bg=bar:CreateTexture(nil,"BACKGROUND"); bg:SetAllPoints(bar); bg:SetTexture(FLAT_TEX); bg:SetVertexColor(0.06,0.06,0.06,0.96)
|
|
local shade=bar:CreateTexture(nil,"ARTWORK"); shade:SetAllPoints(bar); shade:SetTexture(FLAT_TEX); shade:SetVertexColor(0,0,0,0.10)
|
|
|
|
-- Class icon lives inside the player bar. The actor's classToken already
|
|
-- comes from the party/raid roster, the same source used for class colours.
|
|
local classIcon=bar:CreateTexture(nil,"OVERLAY")
|
|
classIcon:SetTexture(CLASS_ICON_TEXTURE)
|
|
classIcon:SetWidth(18); classIcon:SetHeight(18)
|
|
classIcon:SetPoint("LEFT",bar,"LEFT",3,0)
|
|
classIcon:Hide()
|
|
|
|
local rank=bar:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); rank:SetPoint("LEFT",bar,"LEFT",24,0); rank:SetWidth(23); rank:SetJustifyH("RIGHT")
|
|
local left=bar:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); left:SetPoint("LEFT",rank,"RIGHT",7,0); left:SetJustifyH("LEFT")
|
|
local right=bar:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); right:SetPoint("RIGHT",bar,"RIGHT",-6,0); right:SetJustifyH("RIGHT")
|
|
local hover=bar:CreateTexture(nil,"HIGHLIGHT"); hover:SetAllPoints(bar); hover:SetTexture(FLAT_TEX); hover:SetVertexColor(1,1,1,0.06)
|
|
bar:SetScript("OnEnter",function()
|
|
local row=this.detailsRow; local a=row and row.actor
|
|
if D.showActorHover and D.showActorHover(this,a) then return end
|
|
local tt=getPlayerTooltip()
|
|
if not a or not tt then return end
|
|
local cr,cg,cb=classColor(a)
|
|
tt:SetOwner(this,"ANCHOR_RIGHT")
|
|
tt:ClearLines()
|
|
tt:SetText(a.name,cr,cg,cb)
|
|
local dur=getDuration()
|
|
-- Every contribution uses the displayed owner total, including summons.
|
|
-- Spell coverage can be incomplete, so never normalize to recorded spells.
|
|
local contributionTotal=0
|
|
if D.mode=="damage" then contributionTotal=actorDisplayDamage(a)
|
|
elseif D.mode=="healing" then contributionTotal=actorDisplayHealing(a) end
|
|
local function contributionText(value)
|
|
local pct=0
|
|
if contributionTotal>0 then pct=value/contributionTotal*100 end
|
|
return comma(value).." "..string.format("%.1f%%",pct)
|
|
end
|
|
|
|
if D.mode=="damage" then
|
|
local dmg=contributionTotal; local dps=0
|
|
if dur>0 then dps=dmg/dur end
|
|
tt:AddDoubleLine("Damage",comma(dmg).." | "..string.format("%.1f DPS",dps),0.78,0.78,0.78,1,1,1)
|
|
|
|
local spells,sn=sortedSpells(a); local x=1
|
|
if sn>0 then tt:AddLine(" "); tt:AddLine("Damage abilities",1,0.82,0) end
|
|
while x<=sn and x<=8 do
|
|
local sp=spells[x]
|
|
local critPct=0
|
|
if (sp.hits or 0)>0 then critPct=((sp.crits or 0)/(sp.hits or 1))*100 end
|
|
tt:AddDoubleLine(sp.name,contributionText(sp.damage).." | "..string.format("%.0f%% crit",critPct),1,1,1,0.88,0.88,0.88)
|
|
x=x+1
|
|
end
|
|
|
|
elseif D.mode=="threat" then
|
|
D.fillThreatTooltip(tt,a)
|
|
|
|
elseif D.mode=="damageTaken" then
|
|
tt:AddDoubleLine("Damage Taken",comma(a.damageTaken or 0),0.95,0.55,0.35,1,1,1)
|
|
local taken,tn=sortedTable(a.damageTakenSpells,"damage"); local tx=1
|
|
if tn>0 then tt:AddLine(" "); tt:AddLine("Incoming damage",1,0.82,0) end
|
|
while tx<=tn and tx<=10 do
|
|
local e=taken[tx]
|
|
local critPct=0
|
|
if (e.hits or 0)>0 then critPct=((e.crits or 0)/(e.hits or 1))*100 end
|
|
local right=comma(e.damage).." | "..tostring(e.hits or 0).." hits | "..string.format("%.0f%% crit",critPct)
|
|
if (e.crits or 0)>0 then
|
|
right=right.." | "..comma(e.critDamage or 0).." crit dmg"
|
|
right=right.." | max "..comma(e.maxCrit or 0)
|
|
end
|
|
if (e.overkillKnown or 0)>0 then right=right.." | "..comma(e.overkill or 0).." overkill" end
|
|
tt:AddDoubleLine(e.name,right,1,1,1,0.88,0.88,0.88)
|
|
tx=tx+1
|
|
end
|
|
if a.deathLast then
|
|
local dl=a.deathLast
|
|
tt:AddLine(" ")
|
|
local kb=tostring(dl.sourceName or "Unknown").." - "..tostring(dl.ability or "Unknown")
|
|
local kr=comma(dl.damage or 0).." dmg"
|
|
if dl.overkill~=nil then kr=kr.." | "..comma(dl.overkill).." overkill" end
|
|
if dl.crit then kr=kr.." | CRIT" end
|
|
tt:AddDoubleLine("Killing Blow: "..kb,kr,1,0.45,0.35,1,0.75,0.65)
|
|
tt:AddLine(tostring(a.name).." dies",1,0.25,0.25)
|
|
end
|
|
|
|
elseif D.mode=="deaths" then
|
|
tt:AddDoubleLine("Deaths",tostring(a.deaths or 0),1,0.35,0.35,1,1,1)
|
|
local causes,cn=sortedTable(a.deathCauses,"count"); local cx=1
|
|
if cn>0 then tt:AddLine(" "); tt:AddLine("Killing blows",1,0.82,0) end
|
|
while cx<=cn and cx<=8 do
|
|
local e=causes[cx]
|
|
local right=tostring(e.count or 0).."x | "..comma(e.damage or 0).." dmg"
|
|
if (e.overkillKnown or 0)>0 then right=right.." | "..comma(e.overkill or 0).." overkill" end
|
|
if (e.crits or 0)>0 then right=right.." | "..tostring(e.crits).." crit" end
|
|
tt:AddDoubleLine(e.name,right,1,1,1,0.88,0.88,0.88)
|
|
cx=cx+1
|
|
end
|
|
if a.deathLast then
|
|
local dl=a.deathLast
|
|
tt:AddLine(" ")
|
|
local last=tostring(dl.sourceName or "Unknown").." - "..tostring(dl.ability or "Unknown")
|
|
local lr=comma(dl.damage or 0).." dmg"
|
|
if dl.overkill~=nil then lr=lr.." | "..comma(dl.overkill).." overkill" end
|
|
if dl.crit then lr=lr.." | CRIT" end
|
|
tt:AddDoubleLine("Last death: "..last,lr,1,0.45,0.35,1,0.75,0.65)
|
|
end
|
|
|
|
elseif D.mode=="healing" then
|
|
local heal=contributionTotal; local hps=0
|
|
if dur>0 then hps=heal/dur end
|
|
tt:AddDoubleLine("Healing",comma(heal).." | "..string.format("%.1f HPS",hps),0.55,0.90,0.55,0.75,1,0.75)
|
|
|
|
local heals,hn=sortedHeals(a); local x=1
|
|
if hn>0 then tt:AddLine(" "); tt:AddLine("Healing abilities",1,0.82,0) end
|
|
while x<=hn and x<=8 do
|
|
local sp=heals[x]
|
|
local critPct=0
|
|
if (sp.hits or 0)>0 then critPct=((sp.crits or 0)/(sp.hits or 1))*100 end
|
|
tt:AddDoubleLine(sp.name,contributionText(sp.healing).." | "..string.format("%.0f%% crit",critPct),1,1,1,0.88,0.88,0.88)
|
|
x=x+1
|
|
end
|
|
|
|
elseif D.mode=="buffs" or D.mode=="debuffsCast" or D.mode=="debuffsReceived" then
|
|
local auraLabels={buffs="Buff Uptime",debuffsCast="Debuffs Cast",debuffsReceived="Debuffs Received"}
|
|
local auraCount=D.auraEntryCount(a[D.mode])
|
|
if D.mode=="buffs" then
|
|
tt:AddDoubleLine(auraLabels[D.mode] or D.mode,tostring(auraCount).." buffs",1,0.82,0,1,0.82,0)
|
|
else
|
|
tt:AddDoubleLine(auraLabels[D.mode] or D.mode,tostring(auraCount).." debuffs",1,0.82,0,1,0.82,0)
|
|
end
|
|
|
|
local al,an=sortedTable(a[D.mode],"duration"); local ax=1
|
|
while ax<=an and ax<=8 do
|
|
local ae=al[ax]; local pct=0
|
|
local exposure=(ae.targetCount or 1)
|
|
if dur>0 and exposure>0 then pct=(ae.value/(dur*exposure))*100 end
|
|
if pct>100 then pct=100 end
|
|
tt:AddDoubleLine(ae.name,string.format("%.1fs | %.1f%% | %dx",ae.value,pct,ae.count or 0),1,1,1,0.86,0.86,0.86)
|
|
if (D.mode=="buffs" or D.mode=="debuffsReceived") and ae.sourceText then
|
|
local healingRows,healingShown,healingTotal=D.auraHealingSourceRows(ae)
|
|
if D.mode=="buffs" and healingRows and healingShown>0 then
|
|
tt:AddLine(" Sources",0.65,0.65,0.65)
|
|
local hi=1
|
|
while hi<=healingShown do
|
|
local hr=healingRows[hi]
|
|
tt:AddDoubleLine(" "..tostring(hr.name),comma(hr.healing or 0).." healing",0.82,0.92,1,0.82,0.92,1)
|
|
hi=hi+1
|
|
end
|
|
if healingTotal>healingShown then tt:AddLine(" +"..tostring(healingTotal-healingShown).." more",0.65,0.65,0.65) end
|
|
elseif D.mode=="debuffsReceived" then
|
|
local damageRows,damageShown,damageTotal=D.auraDamageSourceRows(ae)
|
|
if damageRows and damageShown>0 then
|
|
tt:AddLine(" Sources",0.65,0.65,0.65)
|
|
local di=1
|
|
while di<=damageShown do
|
|
local dr=damageRows[di]
|
|
tt:AddDoubleLine(" "..tostring(dr.name),comma(dr.damage or 0).." damage",0.82,0.92,1,0.82,0.92,1)
|
|
di=di+1
|
|
end
|
|
if damageTotal>damageShown then tt:AddLine(" +"..tostring(damageTotal-damageShown).." more",0.65,0.65,0.65) end
|
|
else
|
|
local sourceLabel=" Source"
|
|
if (ae.sourceCount or 0)>1 then sourceLabel=" Sources ("..tostring(ae.sourceCount)..")" end
|
|
tt:AddDoubleLine(sourceLabel,ae.sourceText,0.65,0.65,0.65,0.82,0.92,1)
|
|
end
|
|
else
|
|
local sourceLabel=" Source"
|
|
if (ae.sourceCount or 0)>1 then sourceLabel=" Sources ("..tostring(ae.sourceCount)..")" end
|
|
tt:AddDoubleLine(sourceLabel,ae.sourceText,0.65,0.65,0.65,0.82,0.92,1)
|
|
end
|
|
end
|
|
ax=ax+1
|
|
end
|
|
|
|
else
|
|
local labels={interrupts="Interrupts",cc="Crowd Control",ccBreaks="CC Breaks",dispels="Dispels"}
|
|
local total=utilityTotal(a,D.mode)
|
|
tt:AddDoubleLine(labels[D.mode] or D.mode,tostring(total),1,0.82,0,1,0.82,0)
|
|
local ul,un=sortedTable(a[D.mode],"count"); local ux=1
|
|
while ux<=un and ux<=8 do
|
|
tt:AddDoubleLine(ul[ux].name,tostring(ul[ux].value),1,1,1,0.86,0.86,0.86)
|
|
if D.mode=="ccBreaks" and a.ccBreakAbilities then
|
|
local bk,bv
|
|
for bk,bv in a.ccBreakAbilities do
|
|
local _,_,ccName,abilityName=string.find(bk,"^(.-) | (.+)$")
|
|
if ccName==ul[ux].name then
|
|
local abilityDamage=0
|
|
if type(bv)=="table" then abilityDamage=bv.damage or 0 end
|
|
tt:AddDoubleLine(" "..tostring(abilityName),tostring(abilityDamage).." dmg",0.75,0.75,0.75,0.75,0.75,0.75)
|
|
end
|
|
end
|
|
end
|
|
ux=ux+1
|
|
end
|
|
end
|
|
|
|
local petDmg=0; local petHeal=0; local totemDmg=0; local totemHeal=0
|
|
local actors=getDisplayActors(); local k,p
|
|
local totemGroups={}
|
|
for k,p in actors do
|
|
if p.isPet and p.ownerKey==a.key then
|
|
if p.isTotem then
|
|
totemDmg=totemDmg+(p.damage or 0)
|
|
totemHeal=totemHeal+(p.healing or 0)
|
|
local tn=p.name or "Totem"
|
|
local tg=totemGroups[tn]
|
|
if not tg then tg={damage=0,healing=0,spells={},healSpells={}}; totemGroups[tn]=tg end
|
|
tg.damage=tg.damage+(p.damage or 0)
|
|
tg.healing=tg.healing+(p.healing or 0)
|
|
local sn,se
|
|
if p.spells then
|
|
for sn,se in p.spells do
|
|
local dst=tg.spells[sn]
|
|
if not dst then dst={damage=0}; tg.spells[sn]=dst end
|
|
dst.damage=dst.damage+(se.damage or 0)
|
|
end
|
|
end
|
|
if p.healSpells then
|
|
for sn,se in p.healSpells do
|
|
local dst=tg.healSpells[sn]
|
|
if not dst then dst={healing=0}; tg.healSpells[sn]=dst end
|
|
dst.healing=dst.healing+(se.healing or 0)
|
|
end
|
|
end
|
|
else
|
|
petDmg=petDmg+(p.damage or 0)
|
|
petHeal=petHeal+(p.healing or 0)
|
|
end
|
|
end
|
|
end
|
|
|
|
if D.mode=="damage" then
|
|
if petDmg>0 then
|
|
tt:AddLine(" "); tt:AddDoubleLine("Pet contribution",contributionText(petDmg),1,0.82,0,1,0.75,0.25)
|
|
end
|
|
if totemDmg>0 then
|
|
tt:AddLine(" "); tt:AddDoubleLine("Totem contribution",contributionText(totemDmg),1,0.82,0,1,0.75,0.25)
|
|
local tn,tg
|
|
for tn,tg in totemGroups do
|
|
if (tg.damage or 0)>0 then
|
|
tt:AddDoubleLine(tn,contributionText(tg.damage or 0),1,1,1,0.86,0.86,0.86)
|
|
end
|
|
end
|
|
end
|
|
elseif D.mode=="healing" then
|
|
if petHeal>0 then
|
|
tt:AddLine(" "); tt:AddDoubleLine("Pet contribution",contributionText(petHeal),1,0.82,0,0.7,1,0.7)
|
|
end
|
|
if totemHeal>0 then
|
|
tt:AddLine(" "); tt:AddDoubleLine("Totem contribution",contributionText(totemHeal),1,0.82,0,0.7,1,0.7)
|
|
local tn,tg
|
|
for tn,tg in totemGroups do
|
|
if (tg.healing or 0)>0 then
|
|
tt:AddDoubleLine(tn,contributionText(tg.healing or 0),1,1,1,0.86,0.86,0.86)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
skinCawHoverTooltip(tt)
|
|
tt:Show()
|
|
end)
|
|
bar:SetScript("OnLeave",function()
|
|
if D.hideActorHover then D.hideActorHover() end
|
|
local tt=getPlayerTooltip(); if tt then tt:Hide() end
|
|
end)
|
|
-- Keep one reference to the full primary actor-tooltip handler so extra
|
|
-- windows can render the exact same mouseover details for every mode.
|
|
if not D.actorTooltipOnEnter and bar.GetScript then D.actorTooltipOnEnter=bar:GetScript("OnEnter") end
|
|
bar:SetScript("OnMouseWheel",function() if D.scrollBy then if arg1 and arg1>0 then D.scrollBy(-1) else D.scrollBy(1) end end end)
|
|
local row={frame=rowFrame,bar=bar,background=bg,shade=shade,hover=hover,classIcon=classIcon,rank=rank,left=left,right=right,actor=nil}; bar.detailsRow=row; D.rows[i]=row; i=i+1
|
|
end
|
|
|
|
local function shortNumber(n)
|
|
n=tonumber(n) or 0
|
|
if n>=1000000 then return string.format("%.1fm",n/1000000) end
|
|
if n>=10000 then return string.format("%.1fk",n/1000) end
|
|
return comma(n)
|
|
end
|
|
|
|
-- Chat report --------------------------------------------------------------
|
|
function D.reportSegmentName()
|
|
if D.segment=="overall" then return "Overall" end
|
|
if D.segment=="history" then
|
|
local h=getSelectedHistoryFight()
|
|
if h and h.name and h.name~="" then return D.historyLabel(h) end
|
|
return "Previous Fight"
|
|
end
|
|
local name=currentFightLabel()
|
|
if name and name~="" then return name end
|
|
return "Current"
|
|
end
|
|
|
|
function D.reportLineForActor(a,rank,dur)
|
|
local prefix="#"..tostring(rank).." "..tostring(a.name)..": "
|
|
if D.mode=="damage" then
|
|
local value=actorDisplayDamage(a); local rate=0
|
|
if dur>0 then rate=value/dur end
|
|
return prefix..comma(value).." damage - "..string.format("%.1f",rate).." DPS"
|
|
elseif D.mode=="threat" then
|
|
local value=D.threatValueForActor and D.threatValueForActor(a) or 0
|
|
local pct=D.threatPercentForActor and D.threatPercentForActor(a) or 0
|
|
return prefix..comma(math.floor(value+0.5)).." threat - "..string.format("%.1f%%",pct)
|
|
elseif D.mode=="damageTaken" then
|
|
return prefix..comma(a.damageTaken or 0).." damage taken"
|
|
elseif D.mode=="deaths" then
|
|
local deaths=a.deaths or 0
|
|
local word="deaths"
|
|
if deaths==1 then word="death" end
|
|
local line=prefix..tostring(deaths).." "..word
|
|
if a.deathLast then
|
|
line=line.." - Killing Blow: "..tostring(a.deathLast.sourceName or "Unknown")
|
|
.." - "..tostring(a.deathLast.ability or "Unknown")
|
|
.." - "..comma(a.deathLast.damage or 0).." dmg"
|
|
if a.deathLast.crit then line=line.." (crit)" end
|
|
if a.deathLast.overkill~=nil then line=line.." - "..comma(a.deathLast.overkill).." overkill" end
|
|
end
|
|
return line
|
|
elseif D.mode=="healing" then
|
|
local value=actorDisplayHealing(a); local rate=0
|
|
if dur>0 then rate=value/dur end
|
|
return prefix..comma(value).." healing - "..string.format("%.1f",rate).." HPS"
|
|
elseif D.mode=="overhealing" then
|
|
local over,gross=D.overhealSummary(getDisplayActors(),a)
|
|
return prefix..comma(over).." overheal - "..D.overhealPercent(over,gross).." of recorded healing"
|
|
elseif D.mode=="buffs" or D.mode=="debuffsCast" or D.mode=="debuffsReceived" then
|
|
local value=utilityTotal(a,D.mode)
|
|
return prefix..string.format("%.1fs",value).." - "..string.format("%.1f%%",auraAverageUptime(a,D.mode)).." uptime"
|
|
end
|
|
return prefix..tostring(utilityTotal(a,D.mode))
|
|
end
|
|
|
|
function D.reportTotalLine(list,count,dur)
|
|
local total=0; local i=1
|
|
if D.mode=="overhealing" then return D.overhealFooter(getDisplayActors()) end
|
|
if D.mode=="damage" then
|
|
while i<=count do total=total+actorDisplayDamage(list[i]); i=i+1 end
|
|
local rate=0; if dur>0 then rate=total/dur end
|
|
return "Total: "..comma(total).." damage - "..string.format("%.1f",rate).." DPS"
|
|
elseif D.mode=="threat" then
|
|
local top=0; if count>0 then top=D.threatValueForActor and D.threatValueForActor(list[1]) or 0 end
|
|
return "Top threat: "..comma(math.floor(top+0.5)).." (100%)"
|
|
elseif D.mode=="damageTaken" then
|
|
while i<=count do total=total+(list[i].damageTaken or 0); i=i+1 end
|
|
return "Total: "..comma(total).." damage taken"
|
|
elseif D.mode=="deaths" then
|
|
while i<=count do total=total+(list[i].deaths or 0); i=i+1 end
|
|
local word="deaths"; if total==1 then word="death" end
|
|
return "Total: "..tostring(total).." "..word
|
|
elseif D.mode=="healing" then
|
|
while i<=count do total=total+actorDisplayHealing(list[i]); i=i+1 end
|
|
local rate=0; if dur>0 then rate=total/dur end
|
|
return "Total: "..comma(total).." healing - "..string.format("%.1f",rate).." HPS"
|
|
else
|
|
while i<=count do total=total+utilityTotal(list[i],D.mode); i=i+1 end
|
|
if D.mode=="buffs" or D.mode=="debuffsCast" or D.mode=="debuffsReceived" then
|
|
return "Total active time: "..string.format("%.1fs",total)
|
|
end
|
|
return "Total: "..tostring(total)
|
|
end
|
|
end
|
|
|
|
function D.safeReportText(line)
|
|
line=tostring(line or "")
|
|
line=string.gsub(line,"|","/")
|
|
line=string.gsub(line,"\n"," ")
|
|
return line
|
|
end
|
|
|
|
function D.reportRecipient(name)
|
|
if type(name)~="string" then return nil end
|
|
name=string.gsub(name,"^%s*(.-)%s*$","%1")
|
|
if string.len(name)<2 or string.len(name)>64 or not string.find(name,"^[%a\128-\255][%a\128-\255%-]*$") then return nil end
|
|
return name
|
|
end
|
|
local function reportFailure(message)
|
|
chat(message); return false,message
|
|
end
|
|
function D.buildReportLines(v)
|
|
if v and v.closed then return nil,"This window has been closed." end
|
|
local list,count,dur,mode,segment
|
|
if v then
|
|
list,count=D.multiViewSortedActors(v); dur=D.multiViewDuration(v)
|
|
mode=v.mode; segment=D.multiReportSegmentName(v)
|
|
else
|
|
list,count=sortedActors(); dur=getDuration(); mode=D.mode; segment=D.reportSegmentName()
|
|
end
|
|
if count<=0 then return nil,"Nothing to report for the selected mode/segment." end
|
|
local header="Caw DPS Meter: "..(MODE_LABELS[mode] or tostring(mode)).." - "..segment
|
|
if dur>0 then header=header.." - "..string.format("%.1fs",dur) end
|
|
local lines={D.safeReportText(header)}
|
|
for i=1,math.min(5,count) do
|
|
local line=v and D.multiReportLine(v,list[i],i,dur) or D.reportLineForActor(list[i],i,dur)
|
|
table.insert(lines,D.safeReportText(line))
|
|
end
|
|
table.insert(lines,D.safeReportText(v and D.multiReportTotalLine(v,list,count,dur) or D.reportTotalLine(list,count,dur)))
|
|
return lines
|
|
end
|
|
function D.sendReportLines(lines,channel,recipient)
|
|
if not channel or not SendChatMessage then return reportFailure("Chat reporting is unavailable on this client.") end
|
|
if channel=="WHISPER" then
|
|
recipient=D.reportRecipient(recipient)
|
|
if not recipient then return reportFailure("Enter a character name.") end
|
|
else recipient=nil end
|
|
if not lines or table.getn(lines)==0 then return reportFailure("Nothing to report for the selected mode/segment.") end
|
|
if channel=="PARTY" then
|
|
local inParty=GetNumPartyMembers and GetNumPartyMembers()>0
|
|
local inRaid=GetNumRaidMembers and GetNumRaidMembers()>0
|
|
if not inParty and not inRaid then return reportFailure("You are not in a party.") end
|
|
end
|
|
if channel=="RAID" and ((not GetNumRaidMembers) or GetNumRaidMembers()<=0) then
|
|
return reportFailure("You are not in a raid.")
|
|
end
|
|
if channel=="GUILD" and IsInGuild and not IsInGuild() then
|
|
return reportFailure("You are not in a guild.")
|
|
end
|
|
for _,line in ipairs(lines) do
|
|
local ok,err=pcall(SendChatMessage,D.safeReportText(line),channel,nil,recipient)
|
|
if not ok then return reportFailure("Report failed: "..tostring(err)) end
|
|
end
|
|
return true
|
|
end
|
|
function D.sendReport(channel,recipient)
|
|
local lines,err=D.buildReportLines(nil)
|
|
if not lines then return reportFailure(err) end
|
|
return D.sendReportLines(lines,channel,recipient)
|
|
end
|
|
|
|
local function visibleRowCount()
|
|
local h=frame:GetHeight() or 280
|
|
local n=D.uiVisibleRows and D.uiVisibleRows(nil,frame) or math.floor((h-(LIST_TOP+22))/ROW_STEP)
|
|
if n<1 then n=1 end; if n>MAX_ROWS then n=MAX_ROWS end
|
|
return n
|
|
end
|
|
|
|
-- Mouse-wheel scrolling reuses the small row pool for the full raid list,
|
|
-- without reserving a column for separate scroll controls.
|
|
local function clampScroll(total,selfRank)
|
|
local maxOffset=D.uiMaxOffset(total,visibleRowCount(),selfRank)
|
|
if D.scrollOffset<0 then D.scrollOffset=0 end
|
|
if D.scrollOffset>maxOffset then D.scrollOffset=maxOffset end
|
|
return maxOffset
|
|
end
|
|
local function scrollBy(delta)
|
|
local list,total=sortedActors()
|
|
local maxOffset=clampScroll(total,D.uiPinState(list,total,visibleRowCount(),D.uiSettings and D.uiSettings(),false))
|
|
D.scrollOffset=D.scrollOffset+delta
|
|
if D.scrollOffset<0 then D.scrollOffset=0 end
|
|
if D.scrollOffset>maxOffset then D.scrollOffset=maxOffset end
|
|
if D.hideActorHover then D.hideActorHover() end
|
|
if playerTooltip then playerTooltip:Hide() end
|
|
if updateUI then updateUI() end
|
|
end
|
|
D.scrollBy=scrollBy
|
|
|
|
local wheelArea=CreateFrame("Frame",nil,frame); wheelArea:SetPoint("TOPLEFT",frame,"TOPLEFT",4,-LIST_TOP); wheelArea:SetPoint("BOTTOMRIGHT",frame,"BOTTOMRIGHT",-4,4); wheelArea:EnableMouse(true)
|
|
D.mainWheelArea=wheelArea
|
|
if wheelArea.EnableMouseWheel then wheelArea:EnableMouseWheel(true) end
|
|
wheelArea:SetScript("OnMouseWheel",function() if arg1 and arg1>0 then scrollBy(-1) else scrollBy(1) end end)
|
|
if frame.EnableMouseWheel then frame:EnableMouseWheel(true) end
|
|
frame:SetScript("OnMouseWheel",function() if arg1 and arg1>0 then scrollBy(-1) else scrollBy(1) end end)
|
|
-- Keep row frames above the mouse-wheel catcher so hover tooltips still work.
|
|
if wheelArea.SetFrameLevel then wheelArea:SetFrameLevel(frame:GetFrameLevel()+1) end
|
|
local ri=1; while ri<=MAX_ROWS do if D.rows[ri].frame.SetFrameLevel then D.rows[ri].frame:SetFrameLevel(frame:GetFrameLevel()+2) end; ri=ri+1 end
|
|
|
|
-- Keep actor names from colliding with the numeric value at narrow widths.
|
|
-- The right-hand value always wins; names are shortened with an ellipsis and
|
|
-- the full actor name remains available in the existing hover tooltip.
|
|
function D.petIconForActor(a)
|
|
local owner=a.ownerKey and ((D.guidToActor and D.guidToActor[a.ownerKey]) or (D.actors and D.actors[a.ownerKey]))
|
|
local ownerClass=a.petOwnerClass or (owner and owner.classToken)
|
|
if ownerClass=="WARLOCK" then return "Interface\\Icons\\Spell_Shadow_SummonFelHunter" end
|
|
return "Interface\\Icons\\Ability_Hunter_BeastTaming"
|
|
end
|
|
|
|
function D.setBarActorIcon(row)
|
|
local a=row.actor
|
|
if not row.classIcon then return end
|
|
if a and a.isPet then
|
|
row.classIcon:SetTexture(D.petIconForActor(a))
|
|
row.classIcon:SetTexCoord(0,1,0,1)
|
|
row.lastClassToken="PET"
|
|
return true
|
|
else
|
|
local tc=a and a.classToken and CLASS_ICON_TCOORDS[a.classToken]
|
|
row.lastClassToken=a and a.classToken
|
|
if tc then
|
|
row.classIcon:SetTexture(CLASS_ICON_TEXTURE)
|
|
row.classIcon:SetTexCoord(tc[1],tc[2],tc[3],tc[4])
|
|
end
|
|
return tc~=nil
|
|
end
|
|
end
|
|
|
|
function D.fitBarActorName(row,fullName,startPad)
|
|
if not row or not row.left or not row.right or not row.bar then return end
|
|
fullName=tostring(fullName or "")
|
|
if D.uiFitRowText then D.uiFitRowText(row,fullName); return end
|
|
local bw=row.bar:GetWidth() or 0
|
|
local rw=0
|
|
if row.right.GetStringWidth then local ok,v=pcall(row.right.GetStringWidth,row.right); if ok and v then rw=v end end
|
|
local avail=bw-(startPad or 58)-rw-10
|
|
if row.classIcon and row.rank then
|
|
D.setBarActorIcon(row)
|
|
local token=row.actor and row.actor.classToken
|
|
local settings=D.uiSettings and D.uiSettings(row.view)
|
|
local showIcon=((row.actor and row.actor.isPet) or (token and CLASS_ICON_TCOORDS[token])) and avail>=48
|
|
if bw<220 and row.left.GetStringWidth and row.left:GetStringWidth()>avail then showIcon=false end
|
|
if settings and not settings.icons then showIcon=false end
|
|
row.rank:ClearAllPoints()
|
|
row.rank:SetPoint("LEFT",row.bar,"LEFT",showIcon and 24 or 3,0)
|
|
if showIcon then row.classIcon:Show()
|
|
else row.classIcon:Hide(); avail=avail+21 end
|
|
if settings and not settings.ranks then
|
|
row.rank:SetWidth(0); row.rank:SetText(""); avail=avail+23
|
|
else
|
|
local rankWidth=23
|
|
if bw<220 and row.rank.GetStringWidth then rankWidth=math.min(23,row.rank:GetStringWidth()+4) end
|
|
row.rank:SetWidth(rankWidth); avail=avail+23-rankWidth
|
|
end
|
|
end
|
|
if avail<0 then avail=0 end
|
|
row.left:SetWidth(avail)
|
|
if avail<12 then row.left:SetText(""); return end
|
|
row.left:SetText(fullName)
|
|
if not row.left.GetStringWidth then return end
|
|
local ok,tw=pcall(row.left.GetStringWidth,row.left)
|
|
if not ok or not tw or tw<=avail then return end
|
|
local n=string.len(fullName)
|
|
while n>1 do
|
|
local candidate=string.sub(fullName,1,n).."..."
|
|
row.left:SetText(candidate)
|
|
local ok2,cw=pcall(row.left.GetStringWidth,row.left)
|
|
if not ok2 or not cw or cw<=avail then return end
|
|
n=n-1
|
|
end
|
|
row.left:SetText("")
|
|
end
|
|
|
|
updateUI=function(periodic)
|
|
local layoutWidth=frame:GetWidth(); local layoutHeight=frame:GetHeight()
|
|
if D.lastLayoutWidth~=layoutWidth or D.lastLayoutHeight~=layoutHeight then
|
|
D.lastLayoutWidth=layoutWidth; D.lastLayoutHeight=layoutHeight
|
|
if D.applyCompactWindowLayout then D.applyCompactWindowLayout() end
|
|
end
|
|
local dur=getDuration(); local list,count,cache=sortedActors(); local top=1
|
|
|
|
if segmentText then segmentText:SetText(selectedSegmentLabel()) end
|
|
if count>0 then top=list[1]._cawDisplayValue or 0 end; if top<=0 then top=1 end
|
|
modeText:SetText(MODE_LABELS[D.mode] or D.mode)
|
|
if D.refreshSelectorLabels then D.refreshSelectorLabels() end
|
|
if D.mode=="damage" then
|
|
local total=totalDamage(); local totalDPS=0; if dur>0 then totalDPS=total/dur end
|
|
summary:SetText("Total: "..comma(total).." | "..string.format("%.1f",totalDPS).." DPS")
|
|
elseif D.mode=="healing" then
|
|
local total=totalHealing(); local totalHPS=0; if dur>0 then totalHPS=total/dur end
|
|
summary:SetText("Total: "..comma(total).." | "..string.format("%.1f",totalHPS).." HPS")
|
|
elseif D.mode=="overhealing" then summary:SetText(D.overhealFooter(getDisplayActors()))
|
|
elseif D.mode=="threat" then
|
|
local targetName=D.threatDisplayTargetName and D.threatDisplayTargetName() or "Current Target"
|
|
summary:SetText(D.segment=="current" and D.serverThreatLabel and D.serverThreatLabel() or tostring(targetName))
|
|
elseif D.mode=="damageTaken" then
|
|
local total=0; local ui=1; while ui<=count do total=total+(list[ui].damageTaken or 0); ui=ui+1 end
|
|
summary:SetText("Total: "..comma(total))
|
|
elseif D.mode=="deaths" then
|
|
local total=0; local ui=1; while ui<=count do total=total+(list[ui].deaths or 0); ui=ui+1 end
|
|
summary:SetText("Deaths: "..tostring(total))
|
|
else
|
|
if D.mode=="buffs" or D.mode=="debuffsCast" or D.mode=="debuffsReceived" then
|
|
local auraTotal=0; local ui=1
|
|
while ui<=count do auraTotal=auraTotal+D.auraEntryCount(list[ui][D.mode]); ui=ui+1 end
|
|
if D.mode=="buffs" then summary:SetText(tostring(auraTotal).." buffs")
|
|
else summary:SetText(tostring(auraTotal).." debuffs") end
|
|
else
|
|
local utilTotal=0; local ui=1; while ui<=count do utilTotal=utilTotal+utilityTotal(list[ui],D.mode); ui=ui+1 end
|
|
summary:SetText("Total: "..tostring(utilTotal))
|
|
end
|
|
end
|
|
if D.uiUpdateMeterFooter then D.uiUpdateMeterFooter(D.mainView) end
|
|
local rowsVisible=visibleRowCount()
|
|
local selfRank=D.uiPinState(list,count,rowsVisible,D.uiSettings and D.uiSettings(),false)
|
|
if D.meterRowsUnchanged(frame,cache,dur,D.scrollOffset,D.mode,periodic,selfRank) then return end
|
|
local displayTotal=0
|
|
for _,actor in ipairs(list) do displayTotal=displayTotal+(actor._cawDisplayValue or 0) end
|
|
clampScroll(count,selfRank)
|
|
local r=1
|
|
while r<=MAX_ROWS do
|
|
local row=D.rows[r]; local absoluteIndex=D.uiRowIndex(r,D.scrollOffset,rowsVisible,selfRank); local a=nil
|
|
if r<=rowsVisible then a=list[absoluteIndex] end
|
|
if a then
|
|
row.actor=a; row.frame:Show(); local value
|
|
row.overhealTotal=cache and cache.rows[absoluteIndex] and cache.rows[absoluteIndex].overhealTotal
|
|
value=a._cawDisplayValue or 0
|
|
local barValue,barMax=D.uiBarFill(D.mode,a,value,top)
|
|
row.bar:SetMinMaxValues(0,barMax); row.bar:SetValue(barValue); local cr,cg,cb=classColor(a); row.bar:SetStatusBarColor(cr,cg,cb)
|
|
row.rank:SetText(tostring(absoluteIndex).."."); row.left:SetText(tostring(a.name)); row.left:SetTextColor(cr,cg,cb)
|
|
if D.mode=="damage" then local dps=0; if dur>0 then dps=value/dur end; row.right:SetText(shortNumber(value).." | "..string.format("%.1f",dps).." DPS")
|
|
elseif D.mode=="healing" then local hps=0; if dur>0 then hps=value/dur end; row.right:SetText(shortNumber(value).." | "..string.format("%.1f",hps).." HPS")
|
|
elseif D.mode=="threat" then local pct=D.threatPercentForActor and D.threatPercentForActor(a) or 0; row.right:SetText(shortNumber(value).." | "..string.format("%.1f%%",pct))
|
|
elseif D.mode=="damageTaken" then row.right:SetText(shortNumber(value))
|
|
elseif D.mode=="deaths" then row.right:SetText(tostring(value))
|
|
elseif D.mode=="buffs" then row.right:SetText(tostring(value).." buffs")
|
|
elseif D.mode=="debuffsCast" or D.mode=="debuffsReceived" then row.right:SetText(tostring(value).." debuffs")
|
|
else row.right:SetText(tostring(value)) end
|
|
if D.uiFormatRow then D.uiFormatRow(row,value,dur,displayTotal,D.mode) end
|
|
D.fitBarActorName(row,a.name,58)
|
|
else row.actor=nil; row.lastClassToken=nil; if row.classIcon then row.classIcon:Hide() end; row.frame:Hide() end
|
|
r=r+1
|
|
end
|
|
end
|
|
|
|
D.uiClassColor=classColor
|
|
D.uiNumber=shortNumber
|
|
D.uiAuraDuration=auraEntryDuration
|
|
D.uiDuration=currentFightDuration
|
|
D.uiSaveMain=saveWindowState
|
|
D.uiSelectMain=selectSegment
|
|
D.uiRefreshMeters=function()
|
|
updateUI()
|
|
for _,v in pairs(D.multiWindows or {}) do D.updateMultiWindow(v) end
|
|
end
|
|
D.uiSetLock=function(v,locked)
|
|
if v then v.locked=locked; D.applyMultiWindowLock(v)
|
|
else D.locked=locked; applyWindowLock() end
|
|
end
|
|
D.uiResetData=function(kind)
|
|
if kind=="current" or kind=="all" then resetFight(); D.inCombat=false; D.scrollOffset=0 end
|
|
if kind=="overall" or kind=="all" then D.overallSegment={actors={},duration=0,fights=0} end
|
|
if kind=="all" then
|
|
-- Reset clears data, not window settings: every window keeps its Current or
|
|
-- Overall selection. Only a selected saved fight no longer exists.
|
|
D.fightHistory={}
|
|
if D.segment=="history" then selectSegment("current",0) end
|
|
for _,v in pairs(D.multiWindows or {}) do
|
|
if v.segment=="history" then v.segment="current"; v.segmentIndex=0 end
|
|
v.scrollOffset=0
|
|
end
|
|
end
|
|
D.uiRefreshMeters()
|
|
end
|
|
D.uiCombatSelection=function()
|
|
if D.uiSettings and D.uiSettings().autoCurrent then selectSegment("current",0) end
|
|
for _,v in pairs(D.multiWindows or {}) do
|
|
if D.uiSettings and D.uiSettings(v).autoCurrent then v.segment="current"; v.segmentIndex=0; v.scrollOffset=0 end
|
|
end
|
|
end
|
|
|
|
-- Multi-window -------------------------------------------------------------
|
|
-- Additional meter windows are display-only views over the SAME combat data.
|
|
-- They do not register combat events and do not run another parser. Each view
|
|
-- keeps its own mode, segment, position, size and scroll offset.
|
|
D.multiWindows={}
|
|
D.multiWindowPool={}
|
|
D.multiWindowMax=4 -- primary + up to three additional views
|
|
D.multiWindowsRestored=false
|
|
|
|
-- Keep the add-window control visible only while another view can actually be
|
|
-- created. This avoids a dead/disabled + button at the four-window limit.
|
|
function D.updateMultiAddButtons()
|
|
local count=1 -- the primary meter window
|
|
local i=2
|
|
while i<=D.multiWindowMax do
|
|
local v=D.multiWindows[i]
|
|
if v and v.frame and not v.closed then count=count+1 end
|
|
i=i+1
|
|
end
|
|
local canAdd=(count<D.multiWindowMax)
|
|
if D.multiAddButton then
|
|
if canAdd and (frame:GetWidth() or 440)>=300 then D.multiAddButton:Show() else D.multiAddButton:Hide() end
|
|
end
|
|
i=2
|
|
while i<=D.multiWindowMax do
|
|
local v=D.multiWindows[i]
|
|
if v and v.addButton then
|
|
if canAdd and (v.frame:GetWidth() or 440)>=300 then v.addButton:Show() else v.addButton:Hide() end
|
|
end
|
|
i=i+1
|
|
end
|
|
end
|
|
|
|
function D.multiViewActors(v)
|
|
if v.mode=="threat" and v.segment=="current" and D.serverThreatActors then return D.serverThreatActors() end
|
|
if v.segment=="history" then
|
|
local h=D.fightHistory[v.segmentIndex or 1]
|
|
if h and h.actors then return h.actors end
|
|
elseif v.segment=="overall" and D.overallSegment and D.overallSegment.actors then
|
|
return D.overallSegment.actors
|
|
end
|
|
return D.actors
|
|
end
|
|
|
|
function D.multiViewDuration(v)
|
|
if v.segment=="history" then
|
|
local h=D.fightHistory[v.segmentIndex or 1]
|
|
if h then return h.duration or 0 end
|
|
elseif v.segment=="overall" and D.overallSegment then
|
|
return D.overallSegment.duration or 0
|
|
end
|
|
return currentFightDuration()
|
|
end
|
|
|
|
function D.multiViewSegmentLabel(v)
|
|
-- The compact header reserves real space for the encounter name.
|
|
if v.mode=="threat" and v.segment=="current" and D.serverThreatTargetName then return shortFightName(D.serverThreatTargetName()) end
|
|
if v.segment=="overall" then return "Overall" end
|
|
if v.segment=="history" then
|
|
local h=D.fightHistory[v.segmentIndex or 1]
|
|
if h then return D.historyLabel(h,nil,true) end
|
|
return "Previous Fight"
|
|
end
|
|
local name=currentFightLabel()
|
|
if name=="Current" then return "Current" end
|
|
return shortFightName(name)
|
|
end
|
|
|
|
function D.multiViewModeLabel(v)
|
|
local w=v.frame and v.frame:GetWidth() or 440
|
|
-- Same ultra-compact threshold and abbreviations as the primary window.
|
|
if w<360 then
|
|
if v.mode=="damage" then return "DPS" end
|
|
if v.mode=="healing" then return "HPS" end
|
|
if v.mode=="overhealing" then return "Overheal" end
|
|
if v.mode=="threat" then return "Threat" end
|
|
if v.mode=="damageTaken" then return "Taken" end
|
|
if v.mode=="deaths" then return "Deaths" end
|
|
if v.mode=="interrupts" then return "Int" end
|
|
if v.mode=="cc" then return "CC" end
|
|
if v.mode=="ccBreaks" then return "CC Brk" end
|
|
if v.mode=="dispels" then return "Disp" end
|
|
if v.mode=="buffs" then return "Buffs" end
|
|
if v.mode=="debuffsCast" then return "D.Cast" end
|
|
if v.mode=="debuffsReceived" then return "D.Recv" end
|
|
end
|
|
return MODE_LABELS[v.mode] or v.mode
|
|
end
|
|
|
|
function D.multiViewSortedActors(v)
|
|
return D.meterSortedRows(D.multiViewActors(v),v.mode)
|
|
end
|
|
|
|
function D.clampMultiWindow(f)
|
|
if D.uiClamp then return D.uiClamp(f) end
|
|
if not f or not UIParent then return end
|
|
local pl=UIParent:GetLeft(); local pr=UIParent:GetRight(); local pb=UIParent:GetBottom(); local pt=UIParent:GetTop()
|
|
local l=f:GetLeft(); local r=f:GetRight(); local b=f:GetBottom(); local t=f:GetTop()
|
|
if not pl or not pr or not pb or not pt or not l or not r or not b or not t then return end
|
|
local bleed=8; local dx=0; local dy=0
|
|
if l<pl-bleed then dx=(pl-bleed)-l elseif r>pr+bleed then dx=(pr+bleed)-r end
|
|
if b<pb-bleed then dy=(pb-bleed)-b elseif t>pt+bleed then dy=(pt+bleed)-t end
|
|
if dx==0 and dy==0 then return end
|
|
local cx,cy=f:GetCenter(); local ux,uy=UIParent:GetCenter()
|
|
if cx and cy and ux and uy then
|
|
f:ClearAllPoints(); f:SetPoint("CENTER",UIParent,"CENTER",(cx-ux)+dx,(cy-uy)+dy)
|
|
end
|
|
end
|
|
|
|
function D.multiWindowSnapshot(v)
|
|
local f=v.frame
|
|
local cx,cy=f:GetCenter(); local ux,uy=UIParent:GetCenter()
|
|
local x,y=(cx and ux) and (cx-ux) or 0,(cy and uy) and (cy-uy) or 0
|
|
if D.uiCenterOffset then x,y=D.uiCenterOffset(f) end
|
|
if f.cawDockFree then x,y=f.cawDockFree.x,f.cawDockFree.y end
|
|
local seg=v.segment; local index=v.segmentIndex or 0
|
|
if seg=="history" then seg="current"; index=0 end
|
|
return {mode=v.mode,segment=seg,segmentIndex=index,locked=v.locked and true or false,
|
|
hidden=f.cawManuallyHidden and true or false,
|
|
pfDock=v.pfDock and true or false,appearance=D.uiCopySettings and D.uiCopySettings(v.appearance) or nil,
|
|
width=f:GetWidth(),height=f:GetHeight(),centerX=x,centerY=y}
|
|
end
|
|
|
|
function D.saveMultiWindows()
|
|
initializeSavedVariables()
|
|
if not D.layoutRestored or not D.multiWindowsRestored or D.restoringMultiWindows then return end
|
|
DB=CawDPSMeterDB
|
|
CharDB=CawDPSMeterCharDB
|
|
if not DB and not CharDB then return end
|
|
|
|
local saved={}
|
|
local out=1; local i=1
|
|
while i<=D.multiWindowMax do
|
|
local v=D.multiWindows[i]
|
|
-- RC35: a closed view is a tombstone and must never be re-serialized,
|
|
-- even if an old frame object still exists until the next reload.
|
|
-- Hidden is still an active layout (chat arrow, context rules or /cd hide).
|
|
-- Only explicitly closed windows leave the active list.
|
|
if v and not v.closed and v.frame then
|
|
saved[out]=D.multiWindowSnapshot(v)
|
|
out=out+1
|
|
end
|
|
i=i+1
|
|
end
|
|
|
|
local function copySavedWindows(dst)
|
|
if not dst then return end
|
|
dst.extraWindows={}
|
|
dst.extraWindowsVersion=3
|
|
dst.extraWindowsExplicit=true
|
|
dst.extraWindowCount=table.getn(saved)
|
|
dst.extraWindowsRevision=(tonumber(dst.extraWindowsRevision) or 0)+1
|
|
local si=1
|
|
while si<=table.getn(saved) do
|
|
local src=saved[si]
|
|
dst.extraWindows[si]={mode=src.mode,segment=src.segment,segmentIndex=src.segmentIndex,locked=src.locked,pfDock=src.pfDock,
|
|
hidden=src.hidden,
|
|
width=src.width,height=src.height,centerX=src.centerX,centerY=src.centerY,
|
|
appearance=D.uiCopySettings and D.uiCopySettings(src.appearance) or nil}
|
|
si=si+1
|
|
end
|
|
end
|
|
copySavedWindows(DB)
|
|
saveWindowState()
|
|
DB.windowSettingsVersion=1
|
|
copyWindowSettings(DB,CharDB)
|
|
end
|
|
|
|
function D.removeMultiWindow(v)
|
|
if not v or v.closed then return end
|
|
if D.uiStopMeterDrag then D.uiStopMeterDrag(v.frame.cawDragHandle) end
|
|
initializeSavedVariables()
|
|
-- Keep only the three most recently closed layouts. They are restored by
|
|
-- an explicit Add window / show action, never by login or reload.
|
|
if type(CawDPSMeterCharDB.closedWindows)~="table" then CawDPSMeterCharDB.closedWindows={} end
|
|
table.insert(CawDPSMeterCharDB.closedWindows,1,D.multiWindowSnapshot(v))
|
|
while table.getn(CawDPSMeterCharDB.closedWindows)>D.multiWindowMax-1 do table.remove(CawDPSMeterCharDB.closedWindows) end
|
|
-- RC35: mark closed before touching UI/state. This makes close persistence
|
|
-- independent of the frame's shown state or id bookkeeping.
|
|
v.closed=true
|
|
if D.pfDockDetach then D.pfDockDetach(v.frame) end
|
|
if v.modeMenu then v.modeMenu:Hide() end
|
|
if v.segmentMenu then v.segmentMenu:Hide() end
|
|
if v.reportMenu then v.reportMenu:Hide() end
|
|
if v.frame then
|
|
v.frame:SetScript("OnUpdate",nil)
|
|
v.frame:Hide()
|
|
end
|
|
if v.rows then
|
|
local ri=1
|
|
while ri<=20 do if v.rows[ri] then v.rows[ri].actor=nil end; ri=ri+1 end
|
|
end
|
|
D.multiWindowPool[v.id]=v
|
|
-- Remove by identity as well as id, so a stale/mismatched id cannot leave
|
|
-- a live entry behind to be written again during PLAYER_LOGOUT.
|
|
local i=1
|
|
while i<=D.multiWindowMax do
|
|
if D.multiWindows[i]==v then D.multiWindows[i]=nil end
|
|
i=i+1
|
|
end
|
|
D.saveMultiWindows()
|
|
if D.diagLog then D.diagLog("INFO","MW_CLOSE","extra window closed and persisted","count="..tostring((CawDPSMeterDB and CawDPSMeterDB.extraWindowCount) or -1),nil) end
|
|
D.updateMultiAddButtons()
|
|
end
|
|
|
|
function D.layoutMultiWindow(v)
|
|
if not v or not v.frame then return end
|
|
v.lastLayoutWidth=v.frame:GetWidth(); v.lastLayoutHeight=v.frame:GetHeight()
|
|
D.layoutMeterHeader(v)
|
|
if D.uiStyleView then D.uiStyleView(v) end
|
|
v.modeText:SetText(D.multiViewModeLabel(v)); v.segmentText:SetText(D.multiViewSegmentLabel(v))
|
|
if v.lockIcon then v.lockIcon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\"..(v.locked and "CawLock.tga" or "CawUnlock.tga")) end
|
|
end
|
|
|
|
function D.applyMultiWindowLock(v)
|
|
if not v or not v.frame then return end
|
|
if v.frame.SetMovable then v.frame:SetMovable(not v.locked) end
|
|
if v.frame.SetResizable then v.frame:SetResizable(not v.locked) end
|
|
if v.grip then
|
|
if v.locked then v.grip:Hide() else v.grip:Show() end
|
|
end
|
|
if v.lockButton then
|
|
if v.locked then v.lockButton:SetBackdropColor(0.22,0.15,0.05,1); if v.lockIcon then v.lockIcon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawLock.tga") end
|
|
else v.lockButton:SetBackdropColor(0.08,0.08,0.08,1); if v.lockIcon then v.lockIcon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawUnlock.tga") end end
|
|
end
|
|
D.layoutMultiWindow(v)
|
|
end
|
|
|
|
function D.multiReportSegmentName(v)
|
|
if v.segment=="overall" then return "Overall" end
|
|
if v.segment=="history" then
|
|
local h=D.fightHistory[v.segmentIndex or 1]
|
|
if h and h.name and h.name~="" then return D.historyLabel(h) end
|
|
return "Previous Fight"
|
|
end
|
|
local name=currentFightLabel()
|
|
if name and name~="" then return name end
|
|
return "Current"
|
|
end
|
|
|
|
function D.multiReportLine(v,item,rank,dur)
|
|
local a=item.actor; local value=item.value or 0
|
|
local prefix="#"..tostring(rank).." "..tostring(a.name)..": "
|
|
if v.mode=="damage" then
|
|
local rate=0; if dur>0 then rate=value/dur end
|
|
return prefix..comma(value).." damage - "..string.format("%.1f",rate).." DPS"
|
|
elseif v.mode=="healing" then
|
|
local rate=0; if dur>0 then rate=value/dur end
|
|
return prefix..comma(value).." healing - "..string.format("%.1f",rate).." HPS"
|
|
elseif v.mode=="overhealing" then
|
|
local over,gross=D.overhealSummary(D.multiViewActors(v),a)
|
|
return prefix..comma(over).." overheal - "..D.overhealPercent(over,gross).." of recorded healing"
|
|
elseif v.mode=="damageTaken" then return prefix..comma(value).." damage taken"
|
|
elseif v.mode=="deaths" then
|
|
local word="deaths"; if value==1 then word="death" end
|
|
return prefix..tostring(value).." "..word
|
|
elseif v.mode=="buffs" or v.mode=="debuffsCast" or v.mode=="debuffsReceived" then
|
|
return prefix..string.format("%.1fs",utilityTotal(a,v.mode)).." - "..string.format("%.1f%%",auraAverageUptime(a,v.mode,dur)).." uptime"
|
|
end
|
|
return prefix..tostring(value)
|
|
end
|
|
|
|
function D.multiReportTotalLine(v,list,count,dur)
|
|
local total=0; local i=1
|
|
if v.mode=="overhealing" then return D.overhealFooter(D.multiViewActors(v)) end
|
|
while i<=count do
|
|
if v.mode=="buffs" or v.mode=="debuffsCast" or v.mode=="debuffsReceived" then total=total+utilityTotal(list[i].actor,v.mode)
|
|
else total=total+(list[i].value or 0) end
|
|
i=i+1
|
|
end
|
|
if v.mode=="damage" then
|
|
local rate=0; if dur>0 then rate=total/dur end
|
|
return "Total: "..comma(total).." damage - "..string.format("%.1f",rate).." DPS"
|
|
elseif v.mode=="healing" then
|
|
local rate=0; if dur>0 then rate=total/dur end
|
|
return "Total: "..comma(total).." healing - "..string.format("%.1f",rate).." HPS"
|
|
elseif v.mode=="damageTaken" then return "Total: "..comma(total).." damage taken"
|
|
elseif v.mode=="deaths" then
|
|
local word="deaths"; if total==1 then word="death" end
|
|
return "Total: "..tostring(total).." "..word
|
|
elseif v.mode=="buffs" or v.mode=="debuffsCast" or v.mode=="debuffsReceived" then
|
|
return "Total active time: "..string.format("%.1fs",total)
|
|
end
|
|
return "Total: "..tostring(total)
|
|
end
|
|
|
|
function D.sendMultiReport(v,channel,recipient)
|
|
if not v then return reportFailure("This window is unavailable.") end
|
|
local lines,err=D.buildReportLines(v)
|
|
if not lines then return reportFailure(err) end
|
|
return D.sendReportLines(lines,channel,recipient)
|
|
end
|
|
|
|
function D.scrollMultiWindow(v,delta)
|
|
v.scrollOffset=(v.scrollOffset or 0)+delta
|
|
if D.hideActorHover then D.hideActorHover() end
|
|
if playerTooltip then playerTooltip:Hide() end
|
|
D.updateMultiWindow(v)
|
|
end
|
|
|
|
function D.updateMultiWindow(v,periodic)
|
|
if not v or v.closed or not v.frame or not v.frame:IsShown() then return end
|
|
local cw=v.frame:GetWidth() or 440; local ch=v.frame:GetHeight() or 260
|
|
if v.lastLayoutWidth~=cw or v.lastLayoutHeight~=ch then D.layoutMultiWindow(v) end
|
|
v.modeText:SetText(D.multiViewModeLabel(v))
|
|
v.segmentText:SetText(D.multiViewSegmentLabel(v))
|
|
local list,count,cache=D.multiViewSortedActors(v)
|
|
|
|
local dur=D.multiViewDuration(v)
|
|
local top=1
|
|
if count>0 then top=list[1].value or 1 end
|
|
if top<=0 then top=1 end
|
|
local total=0; local ti=1
|
|
while ti<=count do total=total+(list[ti].value or 0); ti=ti+1 end
|
|
if v.summary then
|
|
if v.mode=="damage" then local rate=0; if dur>0 then rate=total/dur end; v.summary:SetText("Total: "..comma(total).." | "..string.format("%.1f",rate).." DPS")
|
|
elseif v.mode=="healing" then local rate=0; if dur>0 then rate=total/dur end; v.summary:SetText("Total: "..comma(total).." | "..string.format("%.1f",rate).." HPS")
|
|
elseif v.mode=="overhealing" then v.summary:SetText(D.overhealFooter(D.multiViewActors(v)))
|
|
elseif v.mode=="threat" then local tn=D.threatDisplayTargetName and D.threatDisplayTargetName() or "Current Target"; v.summary:SetText(v.segment=="current" and D.serverThreatLabel and D.serverThreatLabel() or tostring(tn))
|
|
elseif v.mode=="damageTaken" then v.summary:SetText("Total: "..comma(total))
|
|
elseif v.mode=="deaths" then v.summary:SetText("Deaths: "..tostring(total))
|
|
elseif v.mode=="buffs" then v.summary:SetText(tostring(total).." buffs")
|
|
elseif v.mode=="debuffsCast" or v.mode=="debuffsReceived" then v.summary:SetText(tostring(total).." debuffs")
|
|
else v.summary:SetText("Total: "..tostring(total)) end
|
|
end
|
|
if D.uiUpdateMeterFooter then D.uiUpdateMeterFooter(v) end
|
|
local h=v.frame:GetHeight() or 260
|
|
local visible=D.uiVisibleRows and D.uiVisibleRows(v,v.frame) or math.floor((h-(LIST_TOP+22))/ROW_STEP)
|
|
if visible<1 then visible=1 end
|
|
if visible>20 then visible=20 end
|
|
local selfRank=D.uiPinState(list,count,visible,D.uiSettings and D.uiSettings(v),true)
|
|
if D.meterRowsUnchanged(v.frame,cache,dur,v.scrollOffset,v.mode,periodic,selfRank) then return end
|
|
local maxOffset=D.uiMaxOffset(count,visible,selfRank)
|
|
if v.scrollOffset<0 then v.scrollOffset=0 end
|
|
if v.scrollOffset>maxOffset then v.scrollOffset=maxOffset end
|
|
local i=1
|
|
while i<=20 do
|
|
local row=v.rows[i]
|
|
local item=nil
|
|
local rank=D.uiRowIndex(i,v.scrollOffset,visible,selfRank)
|
|
if i<=visible then item=list[rank] end
|
|
if item then
|
|
local a=item.actor; local value=item.value or 0
|
|
row.overhealTotal=item.overhealTotal
|
|
local barValue,barMax=D.uiBarFill(v.mode,a,value,top)
|
|
row.actor=a; row.frame:Show(); row.bar:SetMinMaxValues(0,barMax); row.bar:SetValue(barValue)
|
|
local cr,cg,cb=classColor(a); row.bar:SetStatusBarColor(cr,cg,cb)
|
|
row.rank:SetText(tostring(rank).."."); row.left:SetText(tostring(a.name)); row.left:SetTextColor(cr,cg,cb)
|
|
if v.mode=="damage" then local rate=0; if dur>0 then rate=value/dur end; row.right:SetText(shortNumber(value).." | "..string.format("%.1f",rate))
|
|
elseif v.mode=="healing" then local rate=0; if dur>0 then rate=value/dur end; row.right:SetText(shortNumber(value).." | "..string.format("%.1f",rate))
|
|
elseif v.mode=="threat" then local pct=D.threatPercentForActor and D.threatPercentForActor(a) or 0; row.right:SetText(shortNumber(value).." | "..string.format("%.1f%%",pct))
|
|
elseif v.mode=="damageTaken" then row.right:SetText(shortNumber(value))
|
|
elseif v.mode=="deaths" then row.right:SetText(tostring(value))
|
|
elseif v.mode=="buffs" then row.right:SetText(tostring(value).." buffs")
|
|
elseif v.mode=="debuffsCast" or v.mode=="debuffsReceived" then row.right:SetText(tostring(value).." debuffs")
|
|
else row.right:SetText(tostring(value)) end
|
|
if D.uiFormatRow then D.uiFormatRow(row,value,dur,total,v.mode) end
|
|
D.fitBarActorName(row,a.name,58)
|
|
else
|
|
row.actor=nil; row.classIcon:Hide(); row.frame:Hide()
|
|
end
|
|
i=i+1
|
|
end
|
|
end
|
|
|
|
function D.createMultiWindow(saved)
|
|
local id=nil; local i=2
|
|
while i<=D.multiWindowMax do if not D.multiWindows[i] then id=i; break end; i=i+1 end
|
|
if not id then D.updateMultiAddButtons(); return nil end
|
|
local closed=CawDPSMeterCharDB and CawDPSMeterCharDB.closedWindows
|
|
if not saved and type(closed)=="table" and table.getn(closed)>0 then
|
|
saved=table.remove(closed,1); saved.hidden=false
|
|
end
|
|
local savedMode=(saved and saved.mode) or "healing"
|
|
if not MODE_LABELS[savedMode] then savedMode="healing" end
|
|
local savedSegment=(saved and saved.segment) or "current"
|
|
if savedSegment~="current" and savedSegment~="overall" then savedSegment="current" end
|
|
local savedWidth=tonumber(saved and saved.width) or (frame:GetWidth() or 440)
|
|
local savedHeight=tonumber(saved and saved.height) or 178
|
|
if savedWidth<160 then savedWidth=160 elseif savedWidth>900 then savedWidth=900 end
|
|
if savedHeight<70 then savedHeight=70 elseif savedHeight>900 then savedHeight=900 end
|
|
local pooled=D.multiWindowPool[id]
|
|
if pooled then
|
|
D.multiWindowPool[id]=nil; D.multiWindows[id]=pooled
|
|
pooled.appearance=D.uiCopySettings and D.uiCopySettings(saved and saved.appearance) or nil
|
|
pooled.closed=false; pooled.mode=savedMode; pooled.segment=savedSegment; pooled.segmentIndex=0
|
|
pooled.scrollOffset=0; pooled.locked=(saved and saved.locked) and true or false
|
|
pooled.pfDock=(saved and saved.pfDock) and true or false
|
|
local pf=pooled.frame
|
|
pf:SetScale(pooled.appearance and pooled.appearance.scale or 1); pf:SetWidth(savedWidth); pf:SetHeight(savedHeight)
|
|
local cx=tonumber(saved and saved.centerX) or (220+id*26); local cy=tonumber(saved and saved.centerY) or (-40-id*20)
|
|
if D.uiAnchorCenter then D.uiAnchorCenter(pf,cx,cy)
|
|
else pf:ClearAllPoints(); pf:SetPoint("CENTER",UIParent,"CENTER",cx,cy) end
|
|
pf.nextUpdate=nil
|
|
pf:SetScript("OnUpdate",function() if not this.nextUpdate or GetTime()>=this.nextUpdate then this.nextUpdate=GetTime()+0.20; D.updateMultiWindow(pooled,true) end end)
|
|
D.clampMultiWindow(pf); D.applyMultiWindowLock(pooled)
|
|
if D.uiSetMeterShown then D.uiSetMeterShown(pf,not (saved and saved.hidden)) else pf:Show() end
|
|
D.updateMultiWindow(pooled); D.updateMultiAddButtons()
|
|
return pooled
|
|
end
|
|
local v={id=id,mode=savedMode,segment=savedSegment,segmentIndex=0,locked=(saved and saved.locked) and true or false,scrollOffset=0,rows={}}
|
|
v.appearance=D.uiCopySettings and D.uiCopySettings(saved and saved.appearance) or nil
|
|
v.pfDock=(saved and saved.pfDock) and true or false
|
|
local f=CreateFrame("Frame",nil,UIParent); v.frame=f; D.multiWindows[id]=v
|
|
-- A newly created view starts at the primary window's current dimensions.
|
|
-- Saved views keep their own dimensions, but identical dimensions now produce
|
|
-- the exact same compact/full-layout decision as the primary window.
|
|
f:SetWidth(savedWidth); f:SetHeight(savedHeight); f:EnableMouse(true)
|
|
if f.SetMovable then f:SetMovable(true) end; if f.SetResizable then f:SetResizable(true) end
|
|
if f.SetMinResize then pcall(f.SetMinResize,f,160,70) end; if f.SetMaxResize then pcall(f.SetMaxResize,f,900,900) end
|
|
-- Same shell/chrome as the primary window.
|
|
flatPanel(f,0.025,0.025,0.025,0.98,0.18)
|
|
v.header=f:CreateTexture(nil,"ARTWORK"); v.header:SetTexture(FLAT_TEX); v.header:SetVertexColor(0.035,0.035,0.035,1); v.header:SetPoint("TOPLEFT",f,"TOPLEFT",1,-1); v.header:SetPoint("TOPRIGHT",f,"TOPRIGHT",-1,-1); v.header:SetHeight(26)
|
|
v.headerLine=f:CreateTexture(nil,"ARTWORK"); v.headerLine:SetTexture(FLAT_TEX); v.headerLine:SetVertexColor(0.18,0.18,0.18,1); v.headerLine:SetPoint("TOPLEFT",f,"TOPLEFT",1,-27); v.headerLine:SetPoint("TOPRIGHT",f,"TOPRIGHT",-1,-27); v.headerLine:SetHeight(1)
|
|
v.toolbar=f:CreateTexture(nil,"ARTWORK"); v.toolbar:SetTexture(FLAT_TEX); v.toolbar:SetVertexColor(0.065,0.065,0.065,1); v.toolbar:SetPoint("TOPLEFT",f,"TOPLEFT",1,-28); v.toolbar:SetPoint("TOPRIGHT",f,"TOPRIGHT",-1,-28); v.toolbar:SetHeight(22)
|
|
v.toolbarLine=f:CreateTexture(nil,"ARTWORK"); v.toolbarLine:SetTexture(FLAT_TEX); v.toolbarLine:SetVertexColor(0.18,0.18,0.18,1); v.toolbarLine:SetPoint("TOPLEFT",f,"TOPLEFT",1,-50); v.toolbarLine:SetPoint("TOPRIGHT",f,"TOPRIGHT",-1,-50); v.toolbarLine:SetHeight(1)
|
|
local cx=tonumber(saved and saved.centerX) or (220+(id*26)); local cy=tonumber(saved and saved.centerY) or (-40-(id*20))
|
|
f:SetScale(v.appearance and v.appearance.scale or 1)
|
|
if D.uiAnchorCenter then D.uiAnchorCenter(f,cx,cy) else f:SetPoint("CENTER",UIParent,"CENTER",cx,cy) end
|
|
f:RegisterForDrag("LeftButton")
|
|
f:SetScript("OnDragStart",function() if not v.locked and not this.cawDockFree then this:StartMoving() end end)
|
|
f:SetScript("OnDragStop",function() this:StopMovingOrSizing(); D.clampMultiWindow(this); D.saveMultiWindows() end)
|
|
|
|
local brandTex=f:CreateTexture(nil,"ARTWORK"); v.brand=brandTex; brandTex:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawBrand.tga"); brandTex:SetWidth(168); brandTex:SetHeight(21); brandTex:SetPoint("TOPLEFT",f,"TOPLEFT",6,-3)
|
|
v.modeButton=CreateFrame("Button",nil,f); v.modeButton:SetHeight(17); v.modeButton:SetPoint("TOPLEFT",f,"TOPLEFT",7,-31); flatPanel(v.modeButton,0.065,0.065,0.065,1,0.24)
|
|
v.modeText=v.modeButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); v.modeText:SetPoint("LEFT",v.modeButton,"LEFT",5,0); v.modeText:SetPoint("RIGHT",v.modeButton,"RIGHT",-13,0); v.modeText:SetJustifyH("LEFT")
|
|
local ma=v.modeButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); v.modeArrow=ma; ma:SetPoint("RIGHT",v.modeButton,"RIGHT",-4,0); ma:SetText("v")
|
|
v.segmentButton=CreateFrame("Button",nil,f); v.segmentButton:SetHeight(17); v.segmentButton:SetPoint("LEFT",v.modeButton,"RIGHT",3,0); flatPanel(v.segmentButton,0.065,0.065,0.065,1,0.24)
|
|
v.segmentText=v.segmentButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); v.segmentText:SetPoint("LEFT",v.segmentButton,"LEFT",5,0); v.segmentText:SetPoint("RIGHT",v.segmentButton,"RIGHT",-13,0); v.segmentText:SetJustifyH("LEFT")
|
|
v.segmentArrow=v.segmentButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); v.segmentArrow:SetPoint("RIGHT",v.segmentButton,"RIGHT",-4,0); v.segmentArrow:SetText("v")
|
|
v.closeButton=CreateFrame("Button",nil,f); v.closeButton:SetWidth(17); v.closeButton:SetHeight(17); v.closeButton:SetPoint("TOPRIGHT",f,"TOPRIGHT",-5,-5); flatPanel(v.closeButton,0.065,0.065,0.065,1,0.25); v.closeButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
local xt=v.closeButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); xt:SetPoint("CENTER",v.closeButton,"CENTER",0,0); xt:SetText("")
|
|
v.closeIcon=v.closeButton:CreateTexture(nil,"ARTWORK"); v.closeIcon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawClose.tga"); v.closeIcon:SetWidth(12); v.closeIcon:SetHeight(12); v.closeIcon:SetPoint("CENTER",v.closeButton,"CENTER",0,0)
|
|
v.closeButton:SetScript("OnClick",function() D.removeMultiWindow(v) end)
|
|
v.closeButton:SetScript("OnEnter",function() this:SetBackdropColor(0.28,0.08,0.08,1); local tt=D.getControlTooltip(); if tt then tt:SetOwner(this,"ANCHOR_TOP"); tt:SetText("Close window",1,1,1); tt:Show() end end)
|
|
v.closeButton:SetScript("OnLeave",function() this:SetBackdropColor(0.08,0.08,0.08,1); if D.controlTooltip then D.controlTooltip:Hide() end end)
|
|
|
|
v.lockButton=CreateFrame("Button",nil,f); v.lockButton:SetHeight(17); v.lockButton:SetPoint("RIGHT",v.closeButton,"LEFT",-3,0); flatPanel(v.lockButton,0.065,0.065,0.065,1,0.25); v.lockButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
v.lockText=v.lockButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); v.lockText:SetPoint("CENTER",v.lockButton,"CENTER",0,0); v.lockText:SetText("")
|
|
v.lockIcon=v.lockButton:CreateTexture(nil,"ARTWORK"); v.lockIcon:SetWidth(12); v.lockIcon:SetHeight(12); v.lockIcon:SetPoint("CENTER",v.lockButton,"CENTER",0,0)
|
|
v.lockButton:SetScript("OnClick",function() v.locked=not v.locked; D.applyMultiWindowLock(v); D.saveMultiWindows() end)
|
|
v.lockButton:SetScript("OnEnter",function() local tt=D.getControlTooltip(); if tt then tt:SetOwner(this,"ANCHOR_TOP"); if v.locked then tt:SetText("Unlock this window",1,1,1) else tt:SetText("Lock this window",1,1,1) end; tt:Show() end end)
|
|
v.lockButton:SetScript("OnLeave",function() if D.controlTooltip then D.controlTooltip:Hide() end end)
|
|
|
|
v.resetButton=CreateFrame("Button",nil,f); v.resetButton:SetWidth(17); v.resetButton:SetHeight(17); v.resetButton:SetPoint("RIGHT",v.closeButton,"LEFT",-3,0); flatPanel(v.resetButton,0.065,0.065,0.065,1,0.25); v.resetButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
v.resetIcon=v.resetButton:CreateTexture(nil,"ARTWORK"); v.resetIcon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawReset.tga"); v.resetIcon:SetWidth(12); v.resetIcon:SetHeight(12); v.resetIcon:SetPoint("CENTER",v.resetButton,"CENTER",0,0)
|
|
v.resetButton:SetScript("OnClick",function() if D.uiConfirmReset then D.uiConfirmReset() end end)
|
|
v.resetButton:SetScript("OnEnter",function() this:SetBackdropColor(0.15,0.15,0.15,1); local tt=D.getControlTooltip(); if tt then tt:SetOwner(this,"ANCHOR_TOP"); tt:SetText("Reset meter data",1,1,1); tt:Show() end end)
|
|
v.resetButton:SetScript("OnLeave",function() this:SetBackdropColor(0.065,0.065,0.065,1); if D.controlTooltip then D.controlTooltip:Hide() end end)
|
|
|
|
v.reportButton=CreateFrame("Button",nil,f); v.reportButton:SetHeight(17); v.reportButton:SetPoint("RIGHT",v.lockButton,"LEFT",-3,0); flatPanel(v.reportButton,0.065,0.065,0.065,1,0.25); v.reportButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
v.reportText=v.reportButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); v.reportText:SetPoint("CENTER",v.reportButton,"CENTER",0,0); v.reportText:SetText("")
|
|
v.reportIcon=v.reportButton:CreateTexture(nil,"ARTWORK"); v.reportIcon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawReport.tga"); v.reportIcon:SetWidth(12); v.reportIcon:SetHeight(12); v.reportIcon:SetPoint("CENTER",v.reportButton,"CENTER",0,0)
|
|
v.addButton=CreateFrame("Button",nil,f); v.addButton:SetWidth(17); v.addButton:SetHeight(17); v.addButton:SetPoint("RIGHT",v.reportButton,"LEFT",-3,0); flatPanel(v.addButton,0.065,0.065,0.065,1,0.25); v.addButton:SetBackdropColor(0.08,0.08,0.08,1)
|
|
v.addText=v.addButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); v.addText:SetPoint("CENTER",v.addButton,"CENTER",0,0); v.addText:SetText("")
|
|
v.addIcon=v.addButton:CreateTexture(nil,"ARTWORK"); v.addIcon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawAdd.tga"); v.addIcon:SetWidth(12); v.addIcon:SetHeight(12); v.addIcon:SetPoint("CENTER",v.addButton,"CENTER",0,0)
|
|
v.addButton:SetScript("OnClick",function() D.createMultiWindow(nil); D.saveMultiWindows() end)
|
|
v.addButton:SetScript("OnEnter",function() this:SetBackdropColor(0.14,0.14,0.14,1); local tt=D.getControlTooltip(); if tt then tt:SetOwner(this,"ANCHOR_TOP"); tt:SetText("Add window",1,1,1); tt:AddLine("Reopens the last closed window with its position and settings.",0.8,0.8,0.8); tt:Show() end end)
|
|
v.addButton:SetScript("OnLeave",function() this:SetBackdropColor(0.055,0.055,0.055,1); if D.controlTooltip then D.controlTooltip:Hide() end end)
|
|
v.reportMenu=CreateFrame("Frame",nil,f); v.reportMenu:SetWidth(96); v.reportMenu:SetHeight(106); v.reportMenu:SetPoint("TOPRIGHT",v.reportButton,"BOTTOMRIGHT",0,-2); flatPanel(v.reportMenu,0.025,0.025,0.025,0.99,0.25); v.reportMenu.cawDropdown=true; v.reportMenu:SetFrameStrata("DIALOG"); v.reportMenu:SetFrameLevel(60); v.reportMenu:Hide()
|
|
local reportChannels={{label="Say",channel="SAY"},{label="Party",channel="PARTY"},{label="Raid",channel="RAID"},{label="Guild",channel="GUILD"},{label="Whisper...",channel="WHISPER"}}
|
|
local rci=1
|
|
while rci<=table.getn(reportChannels) do
|
|
local info=reportChannels[rci]; local rb=CreateFrame("Button",nil,v.reportMenu); rb:SetWidth(88); rb:SetHeight(18); rb:SetPoint("TOPLEFT",v.reportMenu,"TOPLEFT",4,-4-((rci-1)*20))
|
|
local rfs=rb:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); rfs:SetPoint("LEFT",rb,"LEFT",7,0); rfs:SetText(info.label); rb.channel=info.channel; rb.text=rfs
|
|
rb:SetScript("OnClick",function() v.reportMenu:Hide(); if this.channel=="WHISPER" then D.openWhisperReport(v) else D.sendMultiReport(v,this.channel) end end)
|
|
v.reportMenu.buttons=v.reportMenu.buttons or {}; v.reportMenu.buttons[rci]=rb; rci=rci+1
|
|
end
|
|
v.reportButton:SetScript("OnClick",function() if v.reportMenu:IsShown() then v.reportMenu:Hide() else v.modeMenu:Hide(); if v.segmentMenu then v.segmentMenu:Hide() end; v.reportMenu:Show() end end)
|
|
v.reportButton:SetScript("OnEnter",function() this:SetBackdropColor(0.14,0.14,0.14,1); local tt=D.getControlTooltip(); if tt then tt:SetOwner(this,"ANCHOR_TOP"); tt:SetText("Report this view",1,1,1); tt:Show() end end)
|
|
v.reportButton:SetScript("OnLeave",function() this:SetBackdropColor(0.08,0.08,0.08,1); if D.controlTooltip then D.controlTooltip:Hide() end end)
|
|
|
|
v.modeMenu=CreateFrame("Frame",nil,f); v.modeMenu:SetWidth(152); v.modeMenu:SetHeight((table.getn(MODE_ORDER)*18)+8); v.modeMenu:SetPoint("TOPLEFT",v.modeButton,"BOTTOMLEFT",0,-1); flatPanel(v.modeMenu,0.025,0.025,0.025,0.99,0.28); v.modeMenu.cawDropdown=true; v.modeMenu:SetFrameStrata("DIALOG"); v.modeMenu:SetFrameLevel(60); v.modeMenu:Hide()
|
|
i=1
|
|
while i<=table.getn(MODE_ORDER) do
|
|
local b=CreateFrame("Button",nil,v.modeMenu); b:SetHeight(18); b:SetPoint("TOPLEFT",v.modeMenu,"TOPLEFT",4,-4-((i-1)*18)); b:SetPoint("RIGHT",v.modeMenu,"RIGHT",-4,0)
|
|
local fs=b:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); fs:SetPoint("LEFT",b,"LEFT",4,0); fs:SetText(MODE_LABELS[MODE_ORDER[i]] or MODE_ORDER[i]); b.mode=MODE_ORDER[i]
|
|
b:SetScript("OnClick",function() v.mode=this.mode; v.scrollOffset=0; v.modeMenu:Hide(); D.updateMultiWindow(v); D.saveMultiWindows() end)
|
|
b.text=fs
|
|
v.modeMenu.buttons=v.modeMenu.buttons or {}; v.modeMenu.buttons[i]=b; i=i+1
|
|
end
|
|
v.modeButton:SetScript("OnClick",function() if v.modeMenu:IsShown() then v.modeMenu:Hide() else if v.segmentMenu then v.segmentMenu:Hide() end; if v.reportMenu then v.reportMenu:Hide() end; v.modeMenu:Show() end end)
|
|
|
|
v.segmentMenu=CreateFrame("Frame",nil,f); v.segmentMenu:SetWidth(178); v.segmentMenu:SetPoint("TOPLEFT",v.segmentButton,"BOTTOMLEFT",0,-1); flatPanel(v.segmentMenu,0.025,0.025,0.025,0.99,0.28); v.segmentMenu.cawDropdown=true; v.segmentMenu:SetFrameStrata("DIALOG"); v.segmentMenu:SetFrameLevel(60); v.segmentMenu:Hide()
|
|
function v.rebuildSegments()
|
|
local items={}; local n=1; items[n]={kind="current",label="Current"}; local hi=1
|
|
while hi<=table.getn(D.fightHistory) and hi<=10 do
|
|
local h=D.fightHistory[hi]; n=n+1
|
|
items[n]={kind="history",index=hi,label=D.historyLabel(h,hi,true),fullLabel=D.historyLabel(h,hi)}
|
|
hi=hi+1
|
|
end
|
|
n=n+1; items[n]={kind="overall",label="Overall"}; v.segmentMenu:SetHeight((n*18)+8)
|
|
local bi=1
|
|
while bi<=12 do
|
|
local b=v.segmentMenu.buttons and v.segmentMenu.buttons[bi] or nil
|
|
if bi<=n then
|
|
if not b then b=CreateFrame("Button",nil,v.segmentMenu); b:SetHeight(18); b:SetPoint("TOPLEFT",v.segmentMenu,"TOPLEFT",4,-4-((bi-1)*18)); b:SetPoint("RIGHT",v.segmentMenu,"RIGHT",-4,0); b.text=b:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); b.text:SetPoint("LEFT",b,"LEFT",4,0); b:SetScript("OnClick",function() v.segment=this.kind; v.segmentIndex=this.historyIndex or 0; v.scrollOffset=0; v.segmentMenu:Hide(); D.updateMultiWindow(v); D.saveMultiWindows() end); v.segmentMenu.buttons=v.segmentMenu.buttons or {}; v.segmentMenu.buttons[bi]=b end
|
|
b.kind=items[bi].kind; b.historyIndex=items[bi].index
|
|
b.cawSegmentFullLabel=items[bi].fullLabel or items[bi].label; b.text:SetText(items[bi].label); b:Show()
|
|
elseif b then b:Hide() end
|
|
bi=bi+1
|
|
end
|
|
D.layoutSegmentMenu(v)
|
|
end
|
|
v.segmentButton:SetScript("OnClick",function() if v.segmentMenu:IsShown() then v.segmentMenu:Hide() else v.modeMenu:Hide(); if v.reportMenu then v.reportMenu:Hide() end; v.rebuildSegments(); v.segmentMenu:Show() end end)
|
|
|
|
v.summary=f:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); v.summary:SetPoint("RIGHT",f,"TOPRIGHT",-8,-39); v.summary:SetJustifyH("RIGHT"); v.summary:SetTextColor(0.90,0.90,0.90)
|
|
|
|
local ri=1
|
|
while ri<=20 do
|
|
local rf=CreateFrame("Frame",nil,f); rf:SetHeight(ROW_HEIGHT); rf:SetPoint("TOPLEFT",f,"TOPLEFT",6,-LIST_TOP-((ri-1)*ROW_STEP)); rf:SetPoint("TOPRIGHT",f,"TOPRIGHT",-6,-LIST_TOP-((ri-1)*ROW_STEP))
|
|
flatPanel(rf,0.055,0.055,0.055,0.96,0.16)
|
|
local bar=CreateFrame("StatusBar",nil,rf); bar:SetPoint("TOPLEFT",rf,"TOPLEFT",1,-1); bar:SetPoint("BOTTOMRIGHT",rf,"BOTTOMRIGHT",-1,1); bar:SetStatusBarTexture(FLAT_TEX); bar:SetMinMaxValues(0,1); bar:SetValue(0)
|
|
local bg=bar:CreateTexture(nil,"BACKGROUND"); bg:SetAllPoints(bar); bg:SetTexture(FLAT_TEX); bg:SetVertexColor(0.055,0.055,0.055,0.96)
|
|
local classIcon=bar:CreateTexture(nil,"OVERLAY"); classIcon:SetTexture(CLASS_ICON_TEXTURE)
|
|
classIcon:SetWidth(18); classIcon:SetHeight(18); classIcon:SetPoint("LEFT",bar,"LEFT",3,0); classIcon:Hide()
|
|
local rank=bar:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); rank:SetPoint("LEFT",bar,"LEFT",24,0); rank:SetWidth(23); rank:SetJustifyH("RIGHT")
|
|
local left=bar:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); left:SetPoint("LEFT",rank,"RIGHT",7,0); left:SetJustifyH("LEFT")
|
|
local right=bar:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); right:SetPoint("RIGHT",bar,"RIGHT",-6,0); right:SetJustifyH("RIGHT")
|
|
v.rows[ri]={frame=rf,bar=bar,background=bg,classIcon=classIcon,rank=rank,left=left,right=right}
|
|
bar:EnableMouse(true)
|
|
if bar.EnableMouseWheel then bar:EnableMouseWheel(true) end
|
|
bar:SetScript("OnMouseWheel",function() D.scrollMultiWindow(v,arg1 and arg1>0 and -1 or 1) end)
|
|
bar:SetScript("OnEnter",function()
|
|
local row=this.multiRow; local a=row and row.actor
|
|
if not a then return end
|
|
-- Reuse the primary window's complete tooltip renderer. Temporarily
|
|
-- switch only the display context so getDisplayActors()/getDuration()
|
|
-- resolve against this extra window's selected mode and segment.
|
|
if D.actorTooltipOnEnter then
|
|
local oldMode=D.mode; local oldSegment=D.segment; local oldIndex=D.segmentIndex
|
|
local oldDetailsRow=this.detailsRow
|
|
D.mode=v.mode; D.segment=v.segment; D.segmentIndex=v.segmentIndex or 0
|
|
this.detailsRow=row
|
|
local tooltipOK,tooltipError=pcall(D.actorTooltipOnEnter)
|
|
this.detailsRow=oldDetailsRow
|
|
D.mode=oldMode; D.segment=oldSegment; D.segmentIndex=oldIndex
|
|
if not tooltipOK then error(tooltipError) end
|
|
else
|
|
local tt=getPlayerTooltip(); if not tt then return end
|
|
local cr,cg,cb=classColor(a)
|
|
tt:SetOwner(this,"ANCHOR_RIGHT"); tt:ClearLines(); tt:SetText(a.name,cr,cg,cb)
|
|
if v.mode=="threat" then D.fillThreatTooltip(tt,a) end
|
|
tt:Show()
|
|
end
|
|
end)
|
|
bar:SetScript("OnLeave",function()
|
|
if D.hideActorHover then D.hideActorHover() end
|
|
local tt=getPlayerTooltip(); if tt then tt:Hide() end
|
|
end)
|
|
bar.multiRow=v.rows[ri]
|
|
ri=ri+1
|
|
end
|
|
|
|
v.grip=CreateFrame("Button",nil,f); v.grip:SetWidth(24); v.grip:SetHeight(24); v.grip:SetPoint("BOTTOMRIGHT",f,"BOTTOMRIGHT",-1,1); v.grip:RegisterForDrag("LeftButton")
|
|
local gs=v.grip:CreateFontString(nil,"OVERLAY","GameFontNormalSmall"); gs:SetPoint("BOTTOMRIGHT",v.grip,"BOTTOMRIGHT",-3,1); gs:SetText("/"); gs:SetTextColor(0.72,0.72,0.72)
|
|
local gs2=v.grip:CreateFontString(nil,"OVERLAY","GameFontNormalSmall"); gs2:SetPoint("BOTTOMRIGHT",v.grip,"BOTTOMRIGHT",-7,1); gs2:SetText("/"); gs2:SetTextColor(0.72,0.72,0.72)
|
|
local gs3=v.grip:CreateFontString(nil,"OVERLAY","GameFontNormalSmall"); gs3:SetPoint("BOTTOMRIGHT",v.grip,"BOTTOMRIGHT",-11,1); gs3:SetText("/"); gs3:SetTextColor(0.72,0.72,0.72)
|
|
v.grip:SetScript("OnDragStart",function() if not v.locked and f.StartSizing then f:StartSizing("BOTTOMRIGHT") end end)
|
|
v.grip:SetScript("OnDragStop",function() f:StopMovingOrSizing(); D.clampMultiWindow(f); D.layoutMultiWindow(v); D.updateMultiWindow(v); D.saveMultiWindows() end)
|
|
if f.EnableMouseWheel then f:EnableMouseWheel(true) end
|
|
f:SetScript("OnMouseWheel",function() D.scrollMultiWindow(v,arg1 and arg1>0 and -1 or 1) end)
|
|
f:SetScript("OnUpdate",function() if not this.nextUpdate or GetTime()>=this.nextUpdate then this.nextUpdate=GetTime()+0.20; D.updateMultiWindow(v,true) end end)
|
|
D.clampMultiWindow(f); D.layoutMultiWindow(v); D.applyMultiWindowLock(v)
|
|
if D.uiSetMeterShown then D.uiSetMeterShown(f,not (saved and saved.hidden))
|
|
elseif D.uiUpdateWindowVisibility then D.uiUpdateWindowVisibility(v) end
|
|
D.updateMultiWindow(v)
|
|
D.updateMultiAddButtons()
|
|
return v
|
|
end
|
|
|
|
function D.restoreMultiWindows()
|
|
if D.multiWindowsRestored then return end
|
|
D.restoringMultiWindows=true
|
|
initializeSavedVariables(); DB=CawDPSMeterDB; CharDB=CawDPSMeterCharDB
|
|
|
|
-- Use the complete account layout after migration. Older installations
|
|
-- still honor an explicitly empty character list once, so previously closed
|
|
-- windows do not resurrect when moving to the shared layout.
|
|
local list=nil
|
|
local count=nil
|
|
local source="none"
|
|
-- The stored count is authoritative, including an explicit zero.
|
|
if DB and DB.windowSettingsVersion==1 then
|
|
list=DB.extraWindows or {}
|
|
count=tonumber(DB.extraWindowCount) or table.getn(list)
|
|
source="account-shared"
|
|
elseif CharDB and CharDB.extraWindowsExplicit then
|
|
list=CharDB.extraWindows or {}
|
|
count=tonumber(CharDB.extraWindowCount) or table.getn(list)
|
|
source="character-v3"
|
|
elseif CharDB and (CharDB.extraWindowsVersion or CharDB.extraWindows ~= nil) then
|
|
list=CharDB.extraWindows or {}
|
|
count=tonumber(CharDB.extraWindowCount) or table.getn(list)
|
|
source="character-legacy"
|
|
elseif DB and DB.extraWindowsExplicit then
|
|
list=DB.extraWindows or {}
|
|
count=tonumber(DB.extraWindowCount) or table.getn(list)
|
|
source="account-v3"
|
|
elseif DB and (DB.extraWindowsVersion or DB.extraWindows ~= nil) then
|
|
list=DB.extraWindows or {}
|
|
count=tonumber(DB.extraWindowCount) or table.getn(list)
|
|
source="account-legacy"
|
|
else list={}; count=0 end
|
|
|
|
if count<0 then count=0 elseif count>3 then count=3 end
|
|
local i=1
|
|
while i<=count do
|
|
if list[i] then D.createMultiWindow(list[i]) end
|
|
i=i+1
|
|
end
|
|
if D.diagLog then D.diagLog("INFO","MW_RESTORE","extra-window restore","source="..source.." count="..tostring(count),nil) end
|
|
|
|
D.restoringMultiWindows=nil; D.multiWindowsRestored=true
|
|
D.saveMultiWindows()
|
|
end
|
|
|
|
function D.reopenClosedMultiWindows()
|
|
local closed=CawDPSMeterCharDB and CawDPSMeterCharDB.closedWindows
|
|
while type(closed)=="table" and table.getn(closed)>0 do
|
|
if not D.createMultiWindow(nil) then break end
|
|
end
|
|
D.saveMultiWindows()
|
|
end
|
|
|
|
D.multiAddButton=CreateFrame("Button",nil,frame)
|
|
D.multiAddButton:SetWidth(17); D.multiAddButton:SetHeight(17)
|
|
flatPanel(D.multiAddButton,0.055,0.055,0.055,1,0.25)
|
|
D.multiAddText=D.multiAddButton:CreateFontString(nil,"OVERLAY","GameFontHighlightSmall"); D.multiAddText:SetPoint("CENTER",D.multiAddButton,"CENTER",0,0); D.multiAddText:SetText("")
|
|
D.multiAddIcon=D.multiAddButton:CreateTexture(nil,"ARTWORK"); D.multiAddIcon:SetTexture("Interface\\AddOns\\CawDPSMeter\\Media\\CawAdd.tga"); D.multiAddIcon:SetWidth(12); D.multiAddIcon:SetHeight(12); D.multiAddIcon:SetPoint("CENTER",D.multiAddButton,"CENTER",0,0)
|
|
D.multiAddButton:SetScript("OnClick",function() D.createMultiWindow(nil); D.saveMultiWindows() end)
|
|
D.multiAddButton:SetScript("OnEnter",function() this:SetBackdropColor(0.14,0.14,0.14,1); local tt=D.getControlTooltip(); if tt then tt:SetOwner(this,"ANCHOR_TOP"); tt:SetText("Add window",1,1,1); tt:AddLine("Reopens the last closed window with its position and settings.",0.8,0.8,0.8); tt:Show() end end)
|
|
D.multiAddButton:SetScript("OnLeave",function() this:SetBackdropColor(0.055,0.055,0.055,1); if D.controlTooltip then D.controlTooltip:Hide() end end)
|
|
if D.applyCompactWindowLayout then D.applyCompactWindowLayout() end
|
|
D.updateMultiAddButtons()
|
|
|
|
frame:SetScript("OnUpdate",function()
|
|
if not this.nextUpdate or GetTime()>=this.nextUpdate then
|
|
this.nextUpdate=GetTime()+0.20
|
|
updateUI(true)
|
|
end
|
|
end)
|
|
|
|
local function finalizeAuraTimers(stopTime)
|
|
local k,a; for k,a in D.actors do
|
|
local kinds={"buffs","debuffsCast","debuffsReceived"}; local i=1
|
|
while i<=table.getn(kinds) do local tbl=a[kinds[i]]; local spell,e; if tbl then for spell,e in tbl do local tk,st; if e.active then for tk,st in e.active do stopAura(tbl,spell,tk,stopTime) end end end end; i=i+1 end
|
|
end
|
|
end
|
|
|
|
local function finalizePendingCombatEnd(force)
|
|
if not D.pendingCombatEndAt or D.pendingCombatEndAt<=0 then return end
|
|
if GetTime()<D.pendingCombatEndAt then return end
|
|
|
|
if not D.pendingCombatEndFirstAt or D.pendingCombatEndFirstAt<=0 then
|
|
D.pendingCombatEndFirstAt=(D.pendingCombatEndStopTime and D.pendingCombatEndStopTime>0) and D.pendingCombatEndStopTime or GetTime()
|
|
end
|
|
-- Hard ceiling: once the player has been out of combat for grace + hardcap
|
|
-- seconds, close the fight no matter what a stuck CC entry claims. Dead-sync
|
|
-- is exempt (the player is dead and still wants the group's final numbers).
|
|
local hardCapHit=(GetTime()-D.pendingCombatEndFirstAt) > ((D.combatEndGrace or 1.5)+(D.combatEndHardCap or 4.0))
|
|
|
|
if not force and D.localPlayerDead and D.deadSyncLastReceived and D.deadSyncLastReceived>0
|
|
and GetTime()-D.deadSyncLastReceived<2.25 then
|
|
D.pendingCombatEndAt=GetTime()+0.75
|
|
D.logLine("end","finalize DEFER dead-sync recent ("..string.format("%.2f",GetTime()-D.deadSyncLastReceived).."s) -> +0.75")
|
|
return
|
|
end
|
|
|
|
-- Sap and similar crowd control can be applied before WoW considers the
|
|
-- player "in combat". Caw already opened a utility segment for the landed
|
|
-- CC, so do not close that segment while the CC is still active. Otherwise
|
|
-- the later damage event would start a fresh segment and the recorded Sap
|
|
-- would appear to vanish exactly when it was broken.
|
|
-- Guard against stale entries: if the CC target dies while controlled, or
|
|
-- the client never emits the fade/break line, the entry would otherwise
|
|
-- block every combat end until the next resetFight().
|
|
local ccTarget,ccData
|
|
local ccActive=false
|
|
for ccTarget,ccData in D.activeCC do
|
|
if ccData and ccData.time and GetTime()-ccData.time<=25 then
|
|
ccActive=true
|
|
elseif ccData then
|
|
D.logLine("cc","stale activeCC purged at finalize: "..tostring(ccData.spell).." on "..tostring(ccTarget).." (age "..string.format("%.1f",ccData.time and (GetTime()-ccData.time) or -1).."s)")
|
|
D.activeCC[ccTarget]=nil
|
|
if D.ccDamageByTarget then D.ccDamageByTarget[ccTarget]=nil end
|
|
end
|
|
end
|
|
if ccActive and not hardCapHit and not force then
|
|
D.pendingCombatEndAt=GetTime()+0.50
|
|
D.logLine("end","finalize DEFER activeCC not empty -> +0.50")
|
|
return
|
|
end
|
|
if ccActive and hardCapHit then
|
|
D.logLine("end","finalize FORCED past active CC (hard cap "..string.format("%.1f",(D.combatEndGrace or 1.5)+(D.combatEndHardCap or 4.0)).."s reached)")
|
|
clearTable(D.activeCC); clearTable(D.ccDamageByTarget)
|
|
end
|
|
|
|
local stopTime=D.pendingCombatEndStopTime
|
|
if not stopTime or stopTime<=0 then stopTime=GetTime() end
|
|
|
|
if D.startTime>0 then
|
|
D.lastDuration=stopTime-D.startTime
|
|
if D.lastDuration<0 then D.lastDuration=0 end
|
|
end
|
|
D.logLine("end","finalize DONE dur="..string.format("%.2f",(D.startTime>0) and (stopTime-D.startTime) or 0))
|
|
finalizeAuraTimers(stopTime)
|
|
D.inCombat=false
|
|
D.lastFinalizeAt=GetTime()
|
|
D.pendingCombatEndAt=0
|
|
D.pendingCombatEndStopTime=0
|
|
D.pendingCombatEndFirstAt=0
|
|
snapshotFinishedFight()
|
|
D.syncNonce=nil; D.syncIncoming=nil; D.syncSelectedSource=nil; D.syncOfferDeadline=0
|
|
D.startTime=0
|
|
updateUI()
|
|
end
|
|
|
|
-- Events -------------------------------------------------------------------
|
|
function D.setParserEnabled(enabled)
|
|
enabled=enabled and true or false
|
|
if D.parserEnabled()==enabled then return end
|
|
if not enabled and D.startTime>0 then
|
|
D.pendingCombatEndStopTime=GetTime(); D.pendingCombatEndAt=GetTime()
|
|
finalizePendingCombatEnd(true)
|
|
end
|
|
if not enabled and D.threatCalEnabled and D.threatCalSession then
|
|
D.threatCalSession.endedAt=D.threatCalStamp()
|
|
D.threatCalSession.stopReason="parser paused"
|
|
end
|
|
CawDPSMeterCharDB=CawDPSMeterCharDB or {}
|
|
CawDPSMeterCharDB.parserEnabled=enabled
|
|
if not enabled then D.inCombat=false end
|
|
D.cancelCombatSync()
|
|
D.dpsLogQueue={}; D.dpsLogProbeAt=nil
|
|
D.pendingCombatEndAt=0; D.pendingCombatEndStopTime=0; D.pendingCombatEndFirstAt=0
|
|
D.localPlayerDead=false; D.deadSyncLastReceived=0; D.deadSyncNextRequest=0
|
|
D.activeRosterBuffs={}; D.activeEnemyCasts={}; D.activeCC={}; D.ccDamageByTarget={}
|
|
D.pendingSelfTotem=nil; D.pendingItemSummons={}
|
|
if enabled then
|
|
D.lastFinalizeAt=0
|
|
refreshRoster()
|
|
if D.threatCalEnabled and D.threatCalNewSession then D.threatCalNewSession()
|
|
elseif CawDPSMeterCharDB.threatCalAutoStart and D.threatCalStart then D.threatCalStart() end
|
|
end
|
|
if D.uiRefreshMeters then D.uiRefreshMeters() else updateUI() end
|
|
end
|
|
local events=CreateFrame("Frame","CawDPSMeterEvents",UIParent); D.events=events
|
|
|
|
-- RC29: RavenCraft Threat API v4 normal-mob capability probe/sniffer.
|
|
-- Keeps the RC26 threat calculator untouched. Unlike RC28, this probe intentionally
|
|
-- removes TWThreat's elite/worldboss classification gate so RavenCraft can be tested
|
|
-- against normal mobs. It still requires party/raid, a living NPC target, and combat.
|
|
D.threatApiRequestPrefix = "TWT_UDTSv4"
|
|
D.threatApiResponseMarker = "TWTv4="
|
|
D.threatApiLimit = 10
|
|
D.threatApiProbeEnabled = false
|
|
D.threatApiNextRequest = 0
|
|
D.threatApiRequests = 0
|
|
D.threatApiResponses = 0
|
|
D.threatApiLastError = nil
|
|
D.threatApiDebugLines = D.threatApiDebugLines or {}
|
|
D.threatApiPacketsSeen = 0
|
|
D.threatApiRelevantPackets = 0
|
|
D.threatApiSkipped = 0
|
|
D.threatApiLastGate = "not checked"
|
|
D.threatApiRequestsNormal = 0
|
|
D.threatApiRequestsElite = 0
|
|
D.threatApiResponsesNormal = 0
|
|
D.threatApiResponsesElite = 0
|
|
D.threatApiLastRequestClass = nil
|
|
D.threatApiLastRequestAt = 0
|
|
|
|
D.threatApiProbeLog=function(line)
|
|
D.threatApiDebugLines=D.threatApiDebugLines or {}
|
|
D.threatApiDebugCount=(D.threatApiDebugCount or 0)+1
|
|
if table.getn(D.threatApiDebugLines)<200 then
|
|
table.insert(D.threatApiDebugLines,string.format("%.2f | %s",GetTime(),tostring(line)))
|
|
end
|
|
end
|
|
|
|
D.threatApiProbeChannel=function()
|
|
if GetNumRaidMembers and GetNumRaidMembers()>0 then return "RAID" end
|
|
if GetNumPartyMembers and GetNumPartyMembers()>0 then return "PARTY" end
|
|
return nil
|
|
end
|
|
|
|
|
|
|
|
D.threatApiTargetGate=function()
|
|
if not D.threatApiProbeChannel() then return false,"not in party/raid" end
|
|
if not UnitExists or not UnitExists("target") then return false,"no target" end
|
|
if UnitIsDead and UnitIsDead("target") then return false,"target dead" end
|
|
if UnitIsPlayer and UnitIsPlayer("target") then return false,"target is player" end
|
|
local c=(UnitClassification and UnitClassification("target")) or "unknown"
|
|
if UnitAffectingCombat and not UnitAffectingCombat("target") then return false,"target not in combat ["..tostring(c).."]" end
|
|
return true,"eligible: "..tostring(UnitName and UnitName("target") or "?").." ["..tostring(c).."]"
|
|
end
|
|
|
|
D.sendThreatApiProbe=function(force)
|
|
if D.threatCalEnabled then D.threatApiLastError="Calibration owns the threat API"; return false end
|
|
local channel=D.threatApiProbeChannel()
|
|
if not channel then
|
|
D.threatApiLastError="Not in party/raid"
|
|
D.threatApiLastGate=D.threatApiLastError
|
|
return false
|
|
end
|
|
if not force then
|
|
local eligible,reason=D.threatApiTargetGate()
|
|
D.threatApiLastGate=reason
|
|
if not eligible then
|
|
D.threatApiSkipped=(D.threatApiSkipped or 0)+1
|
|
return false
|
|
end
|
|
end
|
|
local sender=getAddonSender()
|
|
if not sender then
|
|
D.threatApiLastError="No SendAddonMessage API"
|
|
return false
|
|
end
|
|
local msg="limit="..tostring(D.threatApiLimit or 10)
|
|
local ok,err=pcall(sender,D.threatApiRequestPrefix,msg,channel)
|
|
if not ok then
|
|
D.threatApiLastError=tostring(err)
|
|
D.threatApiProbeLog("SEND FAILED | prefix="..D.threatApiRequestPrefix.." | channel="..channel.." | "..tostring(err))
|
|
return false
|
|
end
|
|
D.threatApiRequests=(D.threatApiRequests or 0)+1
|
|
local rc=(UnitClassification and UnitClassification("target")) or "unknown"
|
|
D.threatApiLastRequestClass=rc
|
|
D.threatApiLastRequestAt=GetTime()
|
|
if rc=="elite" or rc=="worldboss" or rc=="rareelite" then
|
|
D.threatApiRequestsElite=(D.threatApiRequestsElite or 0)+1
|
|
else
|
|
D.threatApiRequestsNormal=(D.threatApiRequestsNormal or 0)+1
|
|
end
|
|
D.threatApiLastError=nil
|
|
D.threatApiProbeLog("SEND | class="..tostring(rc).." | prefix="..D.threatApiRequestPrefix.." | channel="..channel.." | msg="..msg)
|
|
return true
|
|
end
|
|
-- Sync timing must not depend on the meter window being visible. Hidden frames
|
|
-- do not receive OnUpdate on the 1.12 client, so drive sync from this always-on frame.
|
|
events:SetScript("OnUpdate",function()
|
|
if D.threatAlertTick then D.threatAlertTick() end
|
|
if D.threatPeerTick then D.threatPeerTick(sendSyncNow,syncChannel()) end
|
|
if D.talentSyncTick then D.talentSyncTick(sendSyncNow,syncChannel()) end
|
|
if D.talentViewTick then D.talentViewTick(sendSyncNow,syncChannel()) end
|
|
if D.threatSyncTick then D.threatSyncTick(sendSyncNow,syncChannel()) end
|
|
if D.targetSyncTick then D.targetSyncTick(sendSyncNow,syncChannel()) end
|
|
if not D.parserEnabled() then return end
|
|
if D.inCombat and not D.syncRequested and D.combatSyncEnabled() and syncChannel()
|
|
and GetTime()>=(D.nextCombatSyncCheck or 0) then
|
|
D.nextCombatSyncCheck=GetTime()+0.25
|
|
requestCombatSync()
|
|
end
|
|
if D.threatPrunePending and GetTime()>=(D.nextThreatPrune or 0) then D.nextThreatPrune=GetTime()+1; D.threatPrunePending() end
|
|
if D.threatApiProbeEnabled and GetTime()>=(D.threatApiNextRequest or 0) then
|
|
D.threatApiNextRequest=GetTime()+0.50
|
|
D.sendThreatApiProbe()
|
|
end
|
|
if D.threatDebugEnabled and D.captureThreatTargetState then D.captureThreatTargetState(false) end
|
|
if D.threatCalcDebugEnabled and D.threatValidationTick then D.threatValidationTick() end
|
|
if finalizeSyncOfferSelection and D.syncNonce and not D.syncSelectedSource
|
|
and (D.syncOfferDeadline or 0)>0 then
|
|
finalizeSyncOfferSelection()
|
|
end
|
|
if table.getn(D.syncQueue)>0 then flushSyncQueue() end
|
|
|
|
-- Keep receiving the surviving group's Damage/Healing after the local
|
|
-- player dies. Use a lower-frequency cumulative snapshot so multiple dead
|
|
-- Caw clients do not create a 1 Hz snapshot storm in a raid. PARTY/RAID
|
|
-- transport is retained for Vanilla/RavenCraft compatibility.
|
|
if D.localPlayerDead and D.startTime and D.startTime>0 then
|
|
local deadNow=GetTime()
|
|
if deadNow>=(D.deadSyncNextRequest or 0) then
|
|
local lastAge=999
|
|
if D.deadSyncLastReceived and D.deadSyncLastReceived>0 then
|
|
lastAge=deadNow-D.deadSyncLastReceived
|
|
end
|
|
D.deadSyncNextRequest=deadNow+2.25
|
|
if lastAge>=1.75 and requestCombatSync then requestCombatSync(true) end
|
|
end
|
|
end
|
|
|
|
if D.pendingCombatEndAt and D.pendingCombatEndAt>0 then finalizePendingCombatEnd() end
|
|
|
|
-- RavenCraft/custom-client safety net: PLAYER_REGEN_ENABLED can
|
|
-- occasionally be missed on one client. While a fight is open, confirm
|
|
-- the player's actual combat state four times per second. Only schedule
|
|
-- the normal 1.5s grace close after being out of combat for 0.5s, so brief
|
|
-- combat-state flicker does not split an encounter.
|
|
if D.inCombat and UnitAffectingCombat then
|
|
local now=GetTime()
|
|
if now>=(D.combatStateCheckAt or 0) then
|
|
D.combatStateCheckAt=now+0.25
|
|
if UnitAffectingCombat("player") then
|
|
D.outOfCombatSince=0
|
|
else
|
|
local rosterRecent=false
|
|
if D.lastRosterCombatActivity and D.lastRosterCombatActivity>0
|
|
and now-D.lastRosterCombatActivity<4.00 then
|
|
rosterRecent=true
|
|
end
|
|
if D.pendingCombatEndAt and D.pendingCombatEndAt>0 then
|
|
-- A grace close is already scheduled, normally by
|
|
-- PLAYER_REGEN_ENABLED. Let it run: the local player has left
|
|
-- combat and their segment must end even while the rest of the
|
|
-- group is still fighting. Re-entering combat is handled by
|
|
-- PLAYER_REGEN_DISABLED, which cancels this.
|
|
elseif rosterRecent then
|
|
-- Group still active and nothing scheduled yet: hold off so a
|
|
-- brief personal combat drop mid-encounter does not split it.
|
|
D.outOfCombatSince=0
|
|
if not D.lastRosterHoldLog or now-D.lastRosterHoldLog>1.0 then
|
|
D.lastRosterHoldLog=now
|
|
D.logLine("net","HOLD player OOC, no grace scheduled, roster active "..string.format("%.2f",now-D.lastRosterCombatActivity).."s ago")
|
|
end
|
|
elseif not D.outOfCombatSince or D.outOfCombatSince<=0 then
|
|
D.outOfCombatSince=now
|
|
elseif now-D.outOfCombatSince>=0.50 then
|
|
D.pendingCombatEndStopTime=D.outOfCombatSince
|
|
D.pendingCombatEndAt=now+(D.combatEndGrace or 1.50)
|
|
if not D.pendingCombatEndFirstAt or D.pendingCombatEndFirstAt<=0 then D.pendingCombatEndFirstAt=D.outOfCombatSince end
|
|
D.logLine("net","FALLBACK PLAYER_REGEN_ENABLED missed, scheduling grace close +"..string.format("%.2f",D.combatEndGrace or 1.5))
|
|
if requestCombatSync then requestCombatSync(true) end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
local function reg(ev) local ok=pcall(events.RegisterEvent,events,ev); if ev=="RAW_COMBATLOG" then D.rawRegistered=ok end end
|
|
reg("ADDON_LOADED"); reg("RAW_COMBATLOG"); reg("UNIT_CASTEVENT"); reg("CHAT_MSG_ADDON"); reg("CHAT_MSG_COMBAT_FRIENDLY_DEATH"); reg("PLAYER_REGEN_DISABLED"); reg("PLAYER_REGEN_ENABLED"); reg("PLAYER_ENTERING_WORLD"); reg("PARTY_MEMBERS_CHANGED"); reg("RAID_ROSTER_UPDATE"); reg("UNIT_PET"); reg("UNIT_INVENTORY_CHANGED"); reg("PLAYER_AURAS_CHANGED"); reg("PLAYER_LOGOUT")
|
|
refreshRoster()
|
|
events:SetScript("OnEvent",function()
|
|
if D.parserEvents[event] and not D.parserEnabled() then return end
|
|
if event=="ADDON_LOADED" then
|
|
if arg1=="CawDPSMeter" or arg1==nil then initializeSavedVariables() end
|
|
elseif event=="UNIT_INVENTORY_CHANGED" then
|
|
-- SuperWoW exposes friendly players' temporary weapon enchants too.
|
|
-- Re-scan on any roster inventory-change event; the event is infrequent
|
|
-- and the updater diffs the cache before touching uptime timers.
|
|
if D.updateWeaponBuffs then D.updateWeaponBuffs() end
|
|
elseif event=="PLAYER_AURAS_CHANGED" then
|
|
if D.scanPlayerBuffs then D.scanPlayerBuffs(true) end
|
|
elseif event=="PLAYER_REGEN_DISABLED" then
|
|
D.logState("REGEN_DISABLED (enter combat)")
|
|
D.localPlayerDead=false
|
|
D.deadSyncLastReceived=0
|
|
D.deadSyncNextRequest=0
|
|
D.outOfCombatSince=0
|
|
D.combatStateCheckAt=0
|
|
-- Re-entering combat during the short grace window means this is still
|
|
-- the same encounter; cancel the pending close instead of resetting.
|
|
if D.pendingCombatEndAt and D.pendingCombatEndAt>0 then
|
|
local graceAge=(D.pendingCombatEndFirstAt and D.pendingCombatEndFirstAt>0) and (GetTime()-D.pendingCombatEndFirstAt) or 0
|
|
if graceAge<=1.5 then
|
|
D.logLine("regen","REGEN_DISABLED cancelled fresh grace (age "..string.format("%.2f",graceAge).."s) -> same fight")
|
|
D.pendingCombatEndAt=0
|
|
D.pendingCombatEndStopTime=0
|
|
D.pendingCombatEndFirstAt=0
|
|
else
|
|
-- The grace has been pending far longer than its window: the
|
|
-- prior fight's close was being blocked (stuck CC / dead-sync).
|
|
-- The player is genuinely re-entering combat now, so force the
|
|
-- prior fight closed and let a fresh segment open below.
|
|
D.logLine("regen","REGEN_DISABLED stale grace (age "..string.format("%.2f",graceAge).."s) -> force-close prior fight")
|
|
clearTable(D.activeCC); clearTable(D.ccDamageByTarget)
|
|
D.pendingCombatEndAt=GetTime()-0.01
|
|
finalizePendingCombatEnd()
|
|
end
|
|
end
|
|
|
|
-- RAW_COMBATLOG can reach us before this client fires PLAYER_REGEN_DISABLED.
|
|
-- In that case ensureStarted() has already opened the group fight.
|
|
refreshRoster()
|
|
local opened=false
|
|
if not D.inCombat or D.startTime==0 then
|
|
resetFight()
|
|
D.inCombat=true
|
|
D.setFightStartTime(GetTime())
|
|
opened=true
|
|
end
|
|
if opened then
|
|
D.logLine("seg","REGEN_DISABLED opened a NEW segment")
|
|
if D.seedActiveRosterBuffs then D.seedActiveRosterBuffs() end
|
|
if requestCombatSync then requestCombatSync() end
|
|
else
|
|
local segAge=(D.startTime and D.startTime>0) and (GetTime()-D.startTime) or -1
|
|
if segAge>=0 and segAge<1.5 then
|
|
D.logLine("seg","REGEN_DISABLED: segment already opened "..string.format("%.2f",segAge).."s ago by RAW, kept")
|
|
else
|
|
D.logLine("seg","REGEN_DISABLED: kept running segment (age "..string.format("%.2f",segAge).."s) = flicker within grace")
|
|
end
|
|
end
|
|
if D.uiCombatSelection then D.uiCombatSelection() end
|
|
updateUI()
|
|
elseif event=="PLAYER_REGEN_ENABLED" then
|
|
D.logState("REGEN_ENABLED (leave combat)")
|
|
D.outOfCombatSince=GetTime()
|
|
D.combatStateCheckAt=0
|
|
-- Keep the current fight alive briefly. During this window Caw performs
|
|
-- one final sync pass, trailing RAW events remain attached to the fight,
|
|
-- and very short Vanilla combat drops do not fragment the segment.
|
|
local stopTime=GetTime()
|
|
D.pendingCombatEndStopTime=stopTime
|
|
D.pendingCombatEndAt=stopTime+(D.combatEndGrace or 1.50)
|
|
if not D.pendingCombatEndFirstAt or D.pendingCombatEndFirstAt<=0 then D.pendingCombatEndFirstAt=stopTime end
|
|
D.logLine("regen","REGEN_ENABLED scheduled grace close +"..string.format("%.2f",D.combatEndGrace or 1.5))
|
|
if requestCombatSync then requestCombatSync(true) end
|
|
updateUI()
|
|
elseif event=="UNIT_CASTEVENT" then
|
|
local castGuid=arg1 or ""
|
|
if arg3=="CAST" then
|
|
-- CAST is a completed enemy cast. It must not remain eligible for
|
|
-- the next damaging ability as a ghost interrupt.
|
|
D.activeEnemyCasts[castGuid]=nil
|
|
elseif arg3=="FAIL" then
|
|
-- On RavenCraft, an interrupted cast emits FAIL in the same frame
|
|
-- as the player's Kick/Pummel hit. Defer removal briefly so the
|
|
-- RAW damage line can correlate the landed interrupt. A genuine
|
|
-- failed cast is removed by tryRecordInterrupt after the grace
|
|
-- window expires.
|
|
local pending=D.activeEnemyCasts[castGuid]
|
|
if pending then pending.failedAt=GetTime() end
|
|
end
|
|
if D.threatDebugEnabled and D.captureThreatDebug then
|
|
D.captureThreatDebug("CAST",tostring(arg1).." -> "..tostring(arg2).." | "..tostring(arg3).." | spellId "..tostring(arg4).." | duration "..tostring(arg5))
|
|
end
|
|
-- RC23 validation: retain every UNIT_CASTEVENT while /cdthreatcalc is
|
|
-- active. This lets us see the exact RavenCraft success/failure event
|
|
-- sequence for non-damaging threat abilities such as Distracting Shot.
|
|
if D.threatCalcDebugEnabled and D.threatCalcLog then
|
|
D.threatCalcLog("CAST RAW | "..tostring(arg1).." -> "..tostring(arg2).." | "..tostring(arg3).." | spellId "..tostring(arg4).." | duration "..tostring(arg5))
|
|
end
|
|
if D.handleUnitCastAuraSource then D.handleUnitCastAuraSource(arg1,arg2,arg3,arg4) end
|
|
if D.handleUnitCastCC then D.handleUnitCastCC(arg1,arg2,arg3,arg4) end
|
|
if D.observeItemSummonCast then D.observeItemSummonCast(arg1,arg2,arg3,arg4) end
|
|
if D.threatOnCast then D.threatOnCast(arg1,arg2,arg3,arg4) end
|
|
elseif event=="CHAT_MSG_COMBAT_FRIENDLY_DEATH" then
|
|
if D.captureFriendlyDeath then D.captureFriendlyDeath(event,arg1) end
|
|
elseif event=="RAW_COMBATLOG" then
|
|
D.threatEventTarget=nil
|
|
if D.threatCalRecordRaw then D.threatCalRecordRaw(arg1,arg2) end
|
|
if D.threatFeignRawFailure then D.threatFeignRawFailure(arg2) end
|
|
if D.threatFeignHitCheck then D.threatFeignHitCheck(arg2) end
|
|
if arg2 then
|
|
local _,_,castSource,castSpell=string.find(arg2,"^(0x[%x]+)'s (.-) hits ")
|
|
if not castSource then _,_,castSource,castSpell=string.find(arg2,"^(0x[%x]+)'s (.-) crits ") end
|
|
if not castSource then _,_,castSource,castSpell=string.find(arg2,"^(0x[%x]+) casts (.-) on ") end
|
|
if not castSource then _,_,castSource,castSpell=string.find(arg2,"^(0x[%x]+) casts (.-)%.") end
|
|
if castSource then clearEnemyCastOnResult(castSource,castSpell) end
|
|
if D.threatOnFeignSuccess then
|
|
if arg2=="You gain Feign Death." then D.threatOnFeignSuccess(safeUnitGUID("player") or D.selfKey)
|
|
else
|
|
local _,_,fg=string.find(arg2,"^(0x[%x]+) gains Feign Death%.$")
|
|
if not fg then _,_,fg=string.find(arg2,"^(0x[%x]+) casts Feign Death%.$") end
|
|
if fg then D.threatOnFeignSuccess(fg) end
|
|
end
|
|
end
|
|
end
|
|
D.rawTotal=D.rawTotal+1
|
|
D.currentRawEv=arg1; D.currentRawText=arg2
|
|
captureRawDebug(arg1,arg2)
|
|
-- RC23 validation: include the unmodified SuperWoW RAW stream in the
|
|
-- threat calculation log. The requested isolated test is intentionally
|
|
-- short, so the 300-line cap remains practical while preserving events
|
|
-- immediately before and after Distracting Shot.
|
|
if D.threatCalcDebugEnabled and D.threatCalcLog then
|
|
D.threatCalcLog("RAW | "..tostring(arg1).." | "..tostring(arg2))
|
|
end
|
|
if D.threatDebugEnabled and D.captureThreatDebug then D.captureThreatDebug("RAW",tostring(arg1).." | "..tostring(arg2)) end
|
|
if D.captureIncomingDamage then D.captureIncomingDamage(arg1,arg2) end
|
|
if D.captureFriendlyDeath then D.captureFriendlyDeath(arg1,arg2) end
|
|
if D.clearDeadEnemyState then D.clearDeadEnemyState(arg1,arg2) end
|
|
if D.captureCCDamageCandidate then D.captureCCDamageCandidate(arg1,arg2) end
|
|
|
|
-- Pre-combat long buffs must survive until the next segment starts.
|
|
-- Capture the observed RavenCraft self-buff form directly at the RAW
|
|
-- boundary so it cannot be lost in later parser routing:
|
|
-- CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS | You gain Blessing of Might.
|
|
if arg1 and arg2 and string.find(arg1,"_BUFF",1,true) then
|
|
local _,_,preBuff=string.find(arg2,"^You gain (.-)%.")
|
|
if preBuff then
|
|
if not D.isRawResourceGain(string.lower(preBuff)) then
|
|
local _,_,baseBuff=string.find(preBuff,"^(.-) %([0-9]+%)$")
|
|
if baseBuff then preBuff=baseBuff end
|
|
D.activeRosterBuffs["player|"..preBuff]={target="player",spell=preBuff}
|
|
D.lastPreCombatBuff=preBuff
|
|
end
|
|
end
|
|
end
|
|
|
|
if not (D.localPlayerDead and not D.inCombat) then
|
|
parseRaw(arg1,arg2)
|
|
end
|
|
elseif event=="CHAT_MSG_ADDON" then
|
|
if D.threatApiProbeEnabled then
|
|
D.threatApiPacketsSeen=(D.threatApiPacketsSeen or 0)+1
|
|
local packet="PACKET #"..tostring(D.threatApiPacketsSeen).." | prefix="..tostring(arg1).." | channel="..tostring(arg3).." | sender="..tostring(arg4).." | msg="..tostring(arg2)
|
|
D.threatApiProbeLog(packet)
|
|
local a1=tostring(arg1 or "")
|
|
local a2=tostring(arg2 or "")
|
|
local relevant=(string.find(a1,"TWT",1,true)~=nil) or (string.find(a2,"TWT",1,true)~=nil) or (string.find(a2,"Threat",1,true)~=nil) or (string.find(a2,"threat",1,true)~=nil)
|
|
if relevant then
|
|
D.threatApiRelevantPackets=(D.threatApiRelevantPackets or 0)+1
|
|
chat("|cffffff00Threat API sniff|r "..packet)
|
|
end
|
|
if arg2 and string.find(a2,D.threatApiResponseMarker,1,true) then
|
|
D.threatApiResponses=(D.threatApiResponses or 0)+1
|
|
local rc=(UnitClassification and UnitClassification("target")) or D.threatApiLastRequestClass or "unknown"
|
|
if rc=="elite" or rc=="worldboss" or rc=="rareelite" then
|
|
D.threatApiResponsesElite=(D.threatApiResponsesElite or 0)+1
|
|
else
|
|
D.threatApiResponsesNormal=(D.threatApiResponsesNormal or 0)+1
|
|
end
|
|
chat("|cff7fbf4dThreat API v4 response received!|r #"..tostring(D.threatApiResponses).." ["..tostring(rc).."] | "..a2)
|
|
end
|
|
end
|
|
-- Threat diagnostic: passively record every addon-message prefix seen on the
|
|
-- server. Do not send/probe unknown prefixes; this only observes traffic
|
|
-- the client already receives. Useful for spotting a RavenCraft/Octo
|
|
-- server-side threat service comparable in principle to Turtle's custom API.
|
|
if D.threatDebugEnabled and D.captureThreatDebug then
|
|
D.captureThreatDebug("ADDON", "prefix="..tostring(arg1).." | channel="..tostring(arg3).." | sender="..tostring(arg4).." | msg="..tostring(arg2))
|
|
end
|
|
if arg1==D.syncPrefix and arg2 then
|
|
D.syncAddonEvents=(D.syncAddonEvents or 0)+1
|
|
applySyncMessage(arg4,arg2,arg3)
|
|
end
|
|
elseif event=="PLAYER_LOGOUT" then saveWindowState(); if D.saveMultiWindows then D.saveMultiWindows() end
|
|
elseif event=="PLAYER_ENTERING_WORLD" then if not D.layoutRestored then restoreWindowState() end; refreshRoster(); if applyWindowLock then applyWindowLock() end; D.applyCompactWindowLayout(); updateUI(); if D.restoreMultiWindows then D.restoreMultiWindows() end
|
|
elseif event=="PARTY_MEMBERS_CHANGED" or event=="RAID_ROSTER_UPDATE" or event=="UNIT_PET" then refreshRoster() end
|
|
end)
|
|
|
|
-- Commands -----------------------------------------------------------------
|
|
|
|
-- Optional local diagnostic log control.
|
|
SLASH_CAWDPSLOG1="/cdlog"
|
|
SlashCmdList["CAWDPSLOG"]=function(msg)
|
|
msg=string.lower(tostring(msg or ""))
|
|
if msg=="on" then
|
|
D.logEnabled=true; D.log={}; D.logSeq=0; D.logStart=0
|
|
chat("|cff7fbf4dlog ON|r (cleared). Do your pulls, then /cdlog save + /reload, or /cdlog dump.")
|
|
elseif msg=="off" then
|
|
D.logEnabled=false
|
|
chat("log OFF ("..tostring(table.getn(D.log)).." lines kept in memory)")
|
|
elseif msg=="clear" then
|
|
D.log={}; D.logSeq=0; D.logStart=0
|
|
chat("log cleared")
|
|
elseif string.find(msg,"^dump") then
|
|
local _,_,num=string.find(msg,"^dump%s*(%d+)")
|
|
local n=tonumber(num) or 60
|
|
local total=table.getn(D.log)
|
|
local from=total-n+1; if from<1 then from=1 end
|
|
chat("--- log "..tostring(from).."-"..tostring(total).." of "..tostring(total).." ---")
|
|
local i=from
|
|
while i<=total do
|
|
local e=D.log[i]
|
|
if e then chat(string.format("%4d %8.2f [%-5s] %s", e.seq or 0, e.rel or 0, e.cat or "?", e.text or "")) end
|
|
i=i+1
|
|
end
|
|
chat("--- end ---")
|
|
elseif msg=="save" then
|
|
CawDPSMeterLog={
|
|
savedAt=(date and date("%Y-%m-%d %H:%M:%S")) or "",
|
|
version=D.version,
|
|
count=table.getn(D.log),
|
|
lines=D.log
|
|
}
|
|
chat("saved to CawDPSMeterLog ("..tostring(table.getn(D.log)).." lines). /reload now, then send me the file.")
|
|
else
|
|
chat("/cdlog on | off | clear | dump [n] | save (state: "..(D.logEnabled and "ON" or "OFF")..", "..tostring(table.getn(D.log)).." lines)")
|
|
end
|
|
end
|
|
|
|
-- /cd hide|show and the bare toggle only ever touched the primary window;
|
|
-- the up to three additional windows (D.multiWindows[2..4]) were left as-is,
|
|
-- so hiding the meter left extra windows sitting on screen. Apply the same
|
|
-- action to every open window instead.
|
|
local function forEachMeterWindow(fn)
|
|
fn(frame)
|
|
local i=2
|
|
while i<=D.multiWindowMax do
|
|
local v=D.multiWindows[i]
|
|
if v and not v.closed and v.frame then fn(v.frame) end
|
|
i=i+1
|
|
end
|
|
end
|
|
|
|
SLASH_CAWDPS1="/cawdps"
|
|
SLASH_CAWDPS2="/cd"
|
|
SlashCmdList["CAWDPS"]=function(msg)
|
|
if msg=="hide" then forEachMeterWindow(function(f) if D.uiSetMeterShown then D.uiSetMeterShown(f,false) else f:Hide() end end)
|
|
elseif msg=="show" then
|
|
forEachMeterWindow(function(f) if D.uiSetMeterShown then D.uiSetMeterShown(f,true) else f:Show() end end)
|
|
D.reopenClosedMultiWindows()
|
|
elseif msg=="reset" then D.uiResetData("all"); chat("All combat data reset. Window settings kept.")
|
|
elseif msg=="resetpos" then resetWindowPosition(); saveWindowState(); updateUI(); chat("Window position reset.")
|
|
elseif msg=="resetoverall" then D.overallSegment={actors={},duration=0,fights=0}; updateUI(); chat("Overall segment reset.")
|
|
elseif msg=="current" then D.segment="current"; D.segmentIndex=0; D.scrollOffset=0; updateUI()
|
|
elseif msg=="last" then if D.fightHistory[1] then D.segment="history"; D.segmentIndex=1; D.scrollOffset=0; updateUI() end
|
|
elseif msg=="overall" then D.segment="overall"; D.segmentIndex=0; D.scrollOffset=0; updateUI()
|
|
elseif msg=="damage" or msg=="healing" or msg=="overhealing" or msg=="damageTaken" or msg=="deaths" or msg=="interrupts" or msg=="cc" or msg=="ccBreaks" or msg=="dispels" or msg=="buffs" or msg=="debuffsCast" or msg=="debuffsReceived" then setMode(msg)
|
|
elseif msg=="lock" then D.locked=true; saveWindowState(); applyWindowLock(); chat("Window locked.")
|
|
elseif msg=="unlock" then D.locked=false; saveWindowState(); applyWindowLock(); chat("Window unlocked.")
|
|
elseif msg=="history" then
|
|
chat("Last fights:")
|
|
local i=1
|
|
while i<=table.getn(D.fightHistory) do
|
|
local h=D.fightHistory[i]
|
|
local dmg=0; local heal=0; local k,a
|
|
if h and h.actors then
|
|
for k,a in h.actors do
|
|
dmg=dmg+(a.damage or 0)
|
|
heal=heal+(a.healing or 0)
|
|
end
|
|
end
|
|
local dps=0
|
|
if h and h.duration and h.duration>0 then dps=dmg/h.duration end
|
|
chat(D.historyLabel(h,i).." - "..comma(dmg).." dmg, "..comma(heal).." heal, "..string.format("%.1fs",(h and h.duration) or 0)..", "..string.format("%.1f DPS",dps))
|
|
i=i+1
|
|
end
|
|
else
|
|
local shown=not frame:IsVisible()
|
|
forEachMeterWindow(function(f)
|
|
if D.uiSetMeterShown then D.uiSetMeterShown(f,shown)
|
|
elseif shown then f:Show() else f:Hide() end
|
|
end)
|
|
if shown then D.reopenClosedMultiWindows() end
|
|
end
|
|
end
|
|
SLASH_CAWDPSDEBUG1="/cddebug"
|
|
SlashCmdList["CAWDPSDEBUG"]=function(msg)
|
|
chat("Caw DPS Meter v"..D.version)
|
|
chat("Client: WoW 1.12 / RavenCraft-Octo compatibility mode")
|
|
chat("SuperAPI RAW_COMBATLOG: "..tostring(D.rawRegistered).." | RAW events: "..tostring(D.rawTotal))
|
|
getAddonSender()
|
|
chat("Caw Sync: requested "..tostring(D.syncRequested).." | request sent "..tostring(D.syncRequestSent).." | received "..tostring(D.syncReceived or 0).." | source "..tostring(D.syncLastSource or "none"))
|
|
chat("Caw Sync API: "..tostring(D.syncAPI or "unknown").." | last error "..tostring(D.syncLastError or "none"))
|
|
chat("Caw Sync transport: addon events "..tostring(D.syncAddonEvents or 0).." | requests seen "..tostring(D.syncRequestsSeen or 0).." | sent "..tostring(D.syncSent or 0))
|
|
chat("SavedVariables: "..tostring(D.savedVariablesReady).." | layout saved: "..tostring(CawDPSMeterDB and CawDPSMeterDB.layoutSaved).." | locked: "..tostring(D.locked))
|
|
chat("Mode: current "..tostring(D.mode).." | global "..tostring(CawDPSMeterDB and CawDPSMeterDB.mode).." | character "..tostring(CawDPSMeterCharDB and CawDPSMeterCharDB.mode))
|
|
chat("Segment: "..tostring(D.segment).." | selected history: "..tostring(D.segmentIndex or 0).." | current target: "..tostring(currentFightLabel()).." | stored fights: "..tostring(table.getn(D.fightHistory)).." | overall fights: "..tostring(D.overallSegment and D.overallSegment.fights or 0))
|
|
chat("Parsed hits: "..tostring(D.parsedTotal).." | last parsed: "..tostring(D.lastParsed))
|
|
chat("Last utility: "..tostring(D.lastUtility or "none").." | ignored outsiders: "..tostring(D.ignoredOutsiders or 0))
|
|
local list,actorCount=sortedActors(); local mapCount=0; local k,v; for k,v in D.guidToActor do mapCount=mapCount+1 end
|
|
chat("Displayed actors: "..tostring(actorCount).." | roster GUIDs: "..tostring(mapCount).." | damage: "..tostring(totalDamage()).." | healing: "..tostring(totalHealing()))
|
|
end
|
|
SLASH_CAWDPSSYNCDEBUG1="/cdsync"
|
|
SlashCmdList["CAWDPSSYNCDEBUG"]=function(msg)
|
|
if msg=="retry" or msg=="test" then
|
|
requestCombatSync(true)
|
|
chat("Sync manual send attempted.")
|
|
end
|
|
getAddonSender()
|
|
chat("Sync requested: "..tostring(D.syncRequested).." | request sent: "..tostring(D.syncRequestSent).." | snapshots received: "..tostring(D.syncReceived or 0).." | source: "..tostring(D.syncLastSource or "none").." | adopted age: "..string.format("%.1fs",D.syncBestAge or 0))
|
|
chat("Sync transport: API "..tostring(D.syncAPI or "unknown").." | attempts "..tostring(D.syncSendAttempts or 0).." | sent "..tostring(D.syncSent or 0).." | addon events "..tostring(D.syncAddonEvents or 0).." | requests seen "..tostring(D.syncRequestsSeen or 0).." | queued "..tostring(table.getn(D.syncQueue)))
|
|
chat("Sync last: channel "..tostring(D.syncLastChannel or "none").." | error: "..tostring(D.syncLastError or "none"))
|
|
chat("Sync wire format: tilde delimiter (chat-safe)")
|
|
chat("Sync source selection: offers "..tostring(syncOfferCount()).." | selected "..tostring(D.syncSelectedSource or "none").." | rejected packets "..tostring(D.syncRejectedSources or 0))
|
|
chat("Sync queue: "..tostring(table.getn(D.syncQueue)).." / "..tostring(D.syncQueueMax or 1000).." | dropped "..tostring(D.syncQueueDropped or 0))
|
|
local graceLeft=0
|
|
if D.pendingCombatEndAt and D.pendingCombatEndAt>GetTime() then graceLeft=D.pendingCombatEndAt-GetTime() end
|
|
if graceLeft>0 then
|
|
chat("Combat-end reconcile: pending "..string.format("%.2fs",graceLeft))
|
|
else
|
|
chat("Combat-end reconcile: idle")
|
|
end
|
|
end
|
|
|
|
SLASH_CAWDPSDAMAGEDEBUG1="/cddamage"
|
|
SlashCmdList["CAWDPSDAMAGEDEBUG"]=function(msg)
|
|
local actors=getDisplayActors()
|
|
chat("Damage debug for selected segment:")
|
|
local k,a
|
|
for k,a in actors do
|
|
if a and (a.damage or 0)>0 and not a.isPet then
|
|
chat(tostring(a.name)..": "..tostring(a.damage or 0))
|
|
local spell,entry
|
|
for spell,entry in a.spells do
|
|
if entry and (entry.damage or 0)>0 then
|
|
chat(" "..tostring(spell)..": "..tostring(entry.damage or 0))
|
|
end
|
|
end
|
|
local petDamage=0
|
|
local pk,pa
|
|
for pk,pa in actors do
|
|
if pa and pa.isPet and pa.ownerKey==a.key then
|
|
if pa.isTotem then
|
|
chat(" Totem "..tostring(pa.name or "Totem")..": "..tostring(pa.damage or 0))
|
|
local ts,te
|
|
for ts,te in pa.spells do
|
|
if te and (te.damage or 0)>0 then chat(" "..tostring(ts)..": "..tostring(te.damage or 0)) end
|
|
end
|
|
else
|
|
petDamage=petDamage+(pa.damage or 0)
|
|
end
|
|
end
|
|
end
|
|
if petDamage>0 then chat(" Pet: "..tostring(petDamage)) end
|
|
end
|
|
end
|
|
end
|
|
|
|
SLASH_CAWDPSCCDEBUG1="/cdcc"
|
|
SlashCmdList["CAWDPSCCDEBUG"]=function()
|
|
chat("CC debug:")
|
|
local n=0; local target,c
|
|
for target,c in D.activeCC do
|
|
n=n+1
|
|
chat("active "..tostring(n)..": "..tostring(c and c.spell).." | target "..tostring(target).." | source "..tostring(c and c.sourceKey))
|
|
end
|
|
if n==0 then chat("active CC: none") end
|
|
local h=D.lastCCDamage
|
|
if h then
|
|
chat("last damage: "..tostring(h.source and h.source.name or "?").." -> "..tostring(h.target).." | "..tostring(h.ability or "Melee").." | "..tostring(h.amount or 0).." dmg | age "..string.format("%.2f",GetTime()-(h.time or 0)))
|
|
else
|
|
chat("last damage: none")
|
|
end
|
|
local tk,th
|
|
for tk,th in D.ccDamageByTarget do
|
|
chat("break candidate: "..tostring(th.source and th.source.name or "?").." -> "..tostring(tk).." | "..tostring(th.ability or "Melee").." | "..tostring(th.amount or 0).." dmg | age "..string.format("%.2f",GetTime()-(th.time or 0)))
|
|
end
|
|
chat("last cast event: "..tostring(D.lastCCCastEvent or "none"))
|
|
chat("SpellInfo: "..tostring(D.lastCCSpellInfo or "none"))
|
|
chat("last landed: "..tostring(D.lastCCLanded or "none"))
|
|
chat("segment on land: "..tostring(D.lastCCSegmentOpen or "none"))
|
|
chat("last break: "..tostring(D.lastCCBreak or "none"))
|
|
end
|
|
|
|
SLASH_CAWDPSDEATHDEBUG1="/cddeath"
|
|
SlashCmdList["CAWDPSDEATHDEBUG"]=function()
|
|
chat("Damage Taken / Death debug:")
|
|
chat("last death RAW: "..tostring(D.lastDeathRaw or "none"))
|
|
chat("last death record: "..tostring(D.lastDeathRecord or "none"))
|
|
local activityAge="none"
|
|
if D.lastRosterCombatActivity and D.lastRosterCombatActivity>0 then activityAge=string.format("%.2f",GetTime()-D.lastRosterCombatActivity) end
|
|
chat("last roster combat activity age: "..tostring(activityAge))
|
|
chat("local player dead freeze: "..tostring(D.localPlayerDead and true or false).." | inCombat "..tostring(D.inCombat and true or false).." | start "..tostring(D.startTime or 0))
|
|
local deadSyncAge="none"
|
|
if D.deadSyncLastReceived and D.deadSyncLastReceived>0 then deadSyncAge=string.format("%.2f",GetTime()-D.deadSyncLastReceived) end
|
|
chat("dead-client sync age: "..tostring(deadSyncAge).." | next request "..tostring(D.deadSyncNextRequest or 0))
|
|
local selfHit=D.lastSelfIncomingDamage
|
|
if selfHit then
|
|
chat("self incoming: "..tostring(selfHit.sourceName).." | "..tostring(selfHit.ability).." | "..tostring(selfHit.amount).." dmg | age "..string.format("%.2f",GetTime()-(selfHit.time or 0)))
|
|
else
|
|
chat("self incoming: none")
|
|
end
|
|
local key,hit
|
|
local n=0
|
|
for key,hit in D.lastIncomingDamage do
|
|
n=n+1
|
|
chat("incoming "..tostring(key).." | "..tostring(hit.sourceName or hit.sourceGuid or "?").." | "..tostring(hit.ability or "?").." | "..tostring(hit.amount or 0).." dmg | crit "..tostring(hit.crit and true or false).." | overkill "..tostring(hit.overkill))
|
|
if n>=20 then chat("... output capped at 20 entries"); break end
|
|
end
|
|
if n==0 then chat("incoming: none") end
|
|
end
|
|
|
|
SLASH_CAWDPSGROUPAURADEBUG1="/cdgroupscan"
|
|
SlashCmdList["CAWDPSGROUPAURADEBUG"]=function()
|
|
if D.scanGroupBuffs then D.scanGroupBuffs() end
|
|
chat("Group aura cache:")
|
|
local key,b
|
|
local n=0
|
|
for key,b in D.activeRosterBuffs do
|
|
if b and b.target and b.spell then
|
|
n=n+1
|
|
chat(tostring(b.target).." | "..tostring(b.spell))
|
|
if n>=80 then chat("... output capped at 80 entries"); break end
|
|
end
|
|
end
|
|
if n==0 then chat("No roster buffs found.") end
|
|
end
|
|
|
|
SLASH_CAWDPSAURASCANDEBUG1="/cdaurascan"
|
|
SlashCmdList["CAWDPSAURASCANDEBUG"]=function()
|
|
chat("Player aura scan:")
|
|
if not UnitBuff then chat("UnitBuff unavailable."); return end
|
|
local i
|
|
for i=1,32 do
|
|
local ok,texture,count,spellId=pcall(UnitBuff,"player",i)
|
|
if not ok or not texture then break end
|
|
local name=D.getUnitBuffName("player",i,spellId)
|
|
chat(tostring(i)..": "..tostring(name or "unknown").." | spellId "..tostring(spellId or "nil"))
|
|
end
|
|
end
|
|
|
|
SLASH_CAWDPSWEAPONDEBUG1="/cdweapon"
|
|
SlashCmdList["CAWDPSWEAPONDEBUG"]=function()
|
|
D.scanWeaponBuffs()
|
|
chat("Weapon buff debug:")
|
|
chat("Player Main Hand: "..tostring(D.weaponBuffState.main))
|
|
chat("Player Off Hand: "..tostring(D.weaponBuffState.off))
|
|
local n=0; local key,b
|
|
for key,b in D.activeRosterBuffs do
|
|
if b and b.weaponBuff then
|
|
n=n+1
|
|
chat(tostring(n)..": "..tostring(b.unit or "?").." | "..tostring(b.target or "?").." | "..tostring(b.spell or "?"))
|
|
end
|
|
end
|
|
if n==0 then chat("Weapon cache: empty") end
|
|
end
|
|
|
|
SLASH_CAWDPSBUFFDEBUG1="/cdbuffs"
|
|
SlashCmdList["CAWDPSBUFFDEBUG"]=function()
|
|
chat("Buff debug:")
|
|
chat("inCombat="..tostring(D.inCombat).." | startTime="..tostring(D.startTime).." | selfKey="..tostring(D.selfKey))
|
|
local pg=safeUnitGUID("player")
|
|
chat("playerGUID="..tostring(pg).." | rosterSelf="..tostring(D.guidToActor[pg or D.selfKey]~=nil))
|
|
chat("last RAW self-buff="..tostring(D.lastPreCombatBuff or "none"))
|
|
local n=0; local k,b
|
|
for k,b in D.activeRosterBuffs do
|
|
n=n+1
|
|
chat("cache "..tostring(n)..": "..tostring(k).." -> "..tostring(b and b.spell).." / target "..tostring(b and b.target))
|
|
end
|
|
if n==0 then chat("cache: empty") end
|
|
local si=D.guidToActor[pg or D.selfKey]
|
|
local a=si and D.actors[si.key] or nil
|
|
if not a then
|
|
chat("current actor: none")
|
|
else
|
|
local bn=0; local spell,e
|
|
for spell,e in a.buffs do
|
|
bn=bn+1
|
|
chat("actor buff "..tostring(bn)..": "..tostring(spell).." | count "..tostring(e.count or 0).." | duration "..string.format("%.1f",auraEntryDuration(e)))
|
|
end
|
|
if bn==0 then chat("actor buffs: empty") end
|
|
end
|
|
end
|
|
|
|
SLASH_CAWDPSHEALINGDEBUG1="/cdhealing"
|
|
SlashCmdList["CAWDPSHEALINGDEBUG"]=function(msg)
|
|
local actors=getDisplayActors()
|
|
chat("Healing debug for selected segment:")
|
|
local k,a
|
|
for k,a in actors do
|
|
if a and (a.healing or 0)>0 and not a.isPet then
|
|
chat(tostring(a.name)..": "..tostring(a.healing or 0))
|
|
local spell,entry
|
|
for spell,entry in a.healSpells do
|
|
if entry and (entry.healing or 0)>0 then
|
|
local hits=entry.hits or 0
|
|
local crits=entry.crits or 0
|
|
local critPct=0
|
|
if hits>0 then critPct=(crits/hits)*100 end
|
|
chat(" "..tostring(spell)..": "..tostring(entry.healing or 0)..
|
|
" | heals "..tostring(hits)..
|
|
" | crits "..tostring(crits)..
|
|
" ("..string.format("%.1f%%",critPct)..")")
|
|
end
|
|
end
|
|
local petHealing=0
|
|
local pk,pa
|
|
for pk,pa in actors do
|
|
if pa and pa.isPet and pa.ownerKey==a.key then
|
|
petHealing=petHealing+(pa.healing or 0)
|
|
end
|
|
end
|
|
if petHealing>0 then chat(" Pet: "..tostring(petHealing)) end
|
|
end
|
|
end
|
|
end
|
|
|
|
SLASH_CAWDPSTHREATAPI1="/cdthreatapi"
|
|
SlashCmdList["CAWDPSTHREATAPI"]=function(msg)
|
|
msg=string.lower(tostring(msg or ""))
|
|
if D.threatCalEnabled and msg~="status" then chat("Calibration owns the threat API. Stop /cdthreatcal before probing."); return end
|
|
if msg=="status" then
|
|
local eligible,reason=D.threatApiTargetGate()
|
|
chat("Threat API RC29: "..(D.threatApiProbeEnabled and "ON" or "OFF").." | requests "..tostring(D.threatApiRequests or 0).." | v4 responses "..tostring(D.threatApiResponses or 0).." | packets "..tostring(D.threatApiPacketsSeen or 0).." | TWT/relevant "..tostring(D.threatApiRelevantPackets or 0))
|
|
chat("By class: normal req "..tostring(D.threatApiRequestsNormal or 0).." / resp "..tostring(D.threatApiResponsesNormal or 0).." | elite/boss req "..tostring(D.threatApiRequestsElite or 0).." / resp "..tostring(D.threatApiResponsesElite or 0))
|
|
chat("RC29 gate: "..tostring(reason).." | skipped ticks "..tostring(D.threatApiSkipped or 0).." | last error "..tostring(D.threatApiLastError or "none"))
|
|
if IsAddOnLoaded then chat("TWThreat loaded: "..tostring(IsAddOnLoaded("TWThreat") and true or false)) end
|
|
return
|
|
end
|
|
if msg=="once" then
|
|
local eligible,reason=D.threatApiTargetGate()
|
|
if not eligible then chat("Threat API request NOT sent: "..tostring(reason)); return end
|
|
if D.sendThreatApiProbe() then chat("Threat API v4 one-shot request sent (exact TWThreat format).")
|
|
else chat("Threat API request not sent: "..tostring(D.threatApiLastError or D.threatApiLastGate or "unknown error")) end
|
|
return
|
|
end
|
|
if msg=="force" then
|
|
if D.sendThreatApiProbe(true) then chat("Threat API v4 FORCE request sent (gate bypassed).")
|
|
else chat("Threat API FORCE request failed: "..tostring(D.threatApiLastError or "unknown error")) end
|
|
return
|
|
end
|
|
if D.threatApiProbeEnabled then
|
|
D.threatApiProbeEnabled=false
|
|
CawDPSMeterLog={savedAt=(date and date("%Y-%m-%d %H:%M:%S")) or "",version=D.version,type="THREAT_API_V4_RC29_NORMAL_MOB_PROBE",count=D.threatApiDebugCount or 0,requests=D.threatApiRequests or 0,responses=D.threatApiResponses or 0,normalRequests=D.threatApiRequestsNormal or 0,normalResponses=D.threatApiResponsesNormal or 0,eliteRequests=D.threatApiRequestsElite or 0,eliteResponses=D.threatApiResponsesElite or 0,packets=D.threatApiPacketsSeen or 0,relevantPackets=D.threatApiRelevantPackets or 0,skipped=D.threatApiSkipped or 0,lastGate=D.threatApiLastGate,lastError=D.threatApiLastError,lines=D.threatApiDebugLines or {}}
|
|
chat("Threat API RC29 stopped. Requests: "..tostring(D.threatApiRequests or 0).." | v4 responses: "..tostring(D.threatApiResponses or 0).." | addon packets seen: "..tostring(D.threatApiPacketsSeen or 0).." | relevant: "..tostring(D.threatApiRelevantPackets or 0)..".")
|
|
chat("Normal mobs: requests "..tostring(D.threatApiRequestsNormal or 0).." | responses "..tostring(D.threatApiResponsesNormal or 0)..". Elite/boss: requests "..tostring(D.threatApiRequestsElite or 0).." | responses "..tostring(D.threatApiResponsesElite or 0)..".")
|
|
chat("Full sniff saved to CawDPSMeterLog; /reload before copying SavedVariables.")
|
|
else
|
|
D.threatApiDebugLines={}
|
|
D.threatApiDebugCount=0
|
|
D.threatApiRequests=0
|
|
D.threatApiResponses=0
|
|
D.threatApiRequestsNormal=0
|
|
D.threatApiRequestsElite=0
|
|
D.threatApiResponsesNormal=0
|
|
D.threatApiResponsesElite=0
|
|
D.threatApiLastRequestClass=nil
|
|
D.threatApiLastRequestAt=0
|
|
D.threatApiPacketsSeen=0
|
|
D.threatApiRelevantPackets=0
|
|
D.threatApiSkipped=0
|
|
D.threatApiLastError=nil
|
|
D.threatApiLastGate="not checked"
|
|
D.threatApiNextRequest=0
|
|
D.threatApiProbeEnabled=true
|
|
chat("Threat API RC29 sniffer started. NORMAL mobs are now intentionally allowed; recording ALL CHAT_MSG_ADDON packets.")
|
|
if IsAddOnLoaded then chat("TWThreat loaded: "..tostring(IsAddOnLoaded("TWThreat") and true or false).." (it may run alongside Caw for comparison).") end
|
|
local eligible,reason=D.threatApiTargetGate()
|
|
chat("Current RC29 gate: "..tostring(reason))
|
|
if eligible then
|
|
D.sendThreatApiProbe()
|
|
D.threatApiNextRequest=GetTime()+0.50
|
|
else
|
|
chat("No request yet. Target ANY living non-player mob in combat while grouped; RC29 will begin automatically.")
|
|
end
|
|
end
|
|
end
|
|
|
|
SLASH_CAWDPSTHREATDEBUG1="/cdthreat"
|
|
SlashCmdList["CAWDPSTHREATDEBUG"]=function(msg)
|
|
if not D.threatDebugEnabled then
|
|
D.threatDebugLines={}
|
|
D.threatDebugCount=0
|
|
D.threatDebugLastTarget=""
|
|
D.threatDebugNextSnapshot=0
|
|
D.threatDebugEnabled=true
|
|
chat("Threat diagnostic started. Fight normally, then type /cdthreat again.")
|
|
chat("Capturing RAW_COMBATLOG, UNIT_CASTEVENT, target -> targettarget changes and passive CHAT_MSG_ADDON traffic.")
|
|
chat("RC14 does not transmit unknown threat requests; it only observes server/addon traffic already received.")
|
|
chat("Threat APIs: UnitThreatSituation="..tostring(UnitThreatSituation~=nil).." | UnitDetailedThreatSituation="..tostring(UnitDetailedThreatSituation~=nil))
|
|
if D.captureThreatTargetState then D.captureThreatTargetState(true) end
|
|
else
|
|
D.threatDebugEnabled=false
|
|
CawDPSMeterLog={
|
|
savedAt=(date and date("%Y-%m-%d %H:%M:%S")) or "",
|
|
version=D.version,
|
|
type="THREAT_DIAGNOSTIC_RC16",
|
|
count=D.threatDebugCount or 0,
|
|
lines=D.threatDebugLines
|
|
}
|
|
chat("Threat diagnostic stopped. Captured "..tostring(D.threatDebugCount or 0).." entries (showing up to 160):")
|
|
chat("Full captured diagnostic was also saved to CawDPSMeterLog. Use /reload before copying the SavedVariables file.")
|
|
local i=1
|
|
local n=table.getn(D.threatDebugLines)
|
|
while i<=n do
|
|
chat("["..tostring(i).."] "..tostring(D.threatDebugLines[i]))
|
|
i=i+1
|
|
end
|
|
if (D.threatDebugCount or 0)>160 then chat("More than 160 entries occurred; capture was truncated.") end
|
|
end
|
|
end
|
|
|
|
SLASH_CAWDPSTHREATCALC1="/cdthreatcalc"
|
|
SlashCmdList["CAWDPSTHREATCALC"]=function(msg)
|
|
if not D.threatCalcDebugEnabled then
|
|
D.threatCalcDebugLines={}; D.threatCalcDebugCount=0; D.threatCalcDebugEnabled=true
|
|
D.threatValidationLastTarget={}
|
|
D.threatValidationLastSnapshot={}
|
|
D.threatValidationLastEvent={}
|
|
D.threatValidationNext=0
|
|
chat("Threat validation logging started (RC26 RavenCraft threat validation). Run the isolated test, then type /cdthreatcalc again.")
|
|
else
|
|
D.threatCalcDebugEnabled=false
|
|
CawDPSMeterLog={savedAt=(date and date("%Y-%m-%d %H:%M:%S")) or "",version=D.version,type="THREAT_VALIDATION_RC23",count=D.threatCalcDebugCount or 0,lines=D.threatCalcDebugLines}
|
|
chat("Threat validation logging stopped. Captured "..tostring(D.threatCalcDebugCount or 0).." entries.")
|
|
chat("Full calculation log saved to CawDPSMeterLog. Use /reload before copying SavedVariables.")
|
|
local i=1; local n=table.getn(D.threatCalcDebugLines or {})
|
|
while i<=n and i<=60 do chat("["..tostring(i).."] "..tostring(D.threatCalcDebugLines[i])); i=i+1 end
|
|
if n>60 then chat("Chat output limited to 60 lines; SavedVariables contains up to 300 entries.") end
|
|
end
|
|
end
|
|
|
|
SLASH_CAWDPSRAWDEBUG1="/cdraw"
|
|
SlashCmdList["CAWDPSRAWDEBUG"]=function(msg)
|
|
if not D.rawDebugEnabled then
|
|
D.rawDebugLines={}
|
|
D.rawDebugCount=0
|
|
D.rawDebugEnabled=true
|
|
chat("RAW capture started. Perform the interrupt, then type /cdraw again.")
|
|
else
|
|
D.rawDebugEnabled=false
|
|
chat("RAW capture stopped. Captured "..tostring(D.rawDebugCount).." lines (showing up to 80):")
|
|
local n=table.getn(D.rawDebugLines)
|
|
local i=1
|
|
while i<=n do
|
|
chat("["..tostring(i).."] "..tostring(D.rawDebugLines[i]))
|
|
i=i+1
|
|
end
|
|
if D.rawDebugCount>80 then
|
|
chat("More than 80 RAW lines occurred; capture was truncated.")
|
|
end
|
|
end
|
|
end
|
|
SLASH_CAWDPSRAWUNKNOWN1="/cdunknown"
|
|
SlashCmdList["CAWDPSRAWUNKNOWN"]=function(msg) chat("Recent unparsed RAW lines (up to 20):"); local n=table.getn(D.rawUnknown); local i=1; while i<=n do chat("["..tostring(i).."] "..tostring(D.rawUnknown[i])); i=i+1 end end
|
|
SLASH_CAWDPSUTILITYUNKNOWN1="/cdutilityunknown"
|
|
SlashCmdList["CAWDPSUTILITYUNKNOWN"]=function(msg) chat("Recent unclassified aura/utility lines (up to 20):"); local n=table.getn(D.utilityUnknown); local i=1; while i<=n do chat("["..tostring(i).."] "..tostring(D.utilityUnknown[i])); i=i+1 end end
|
|
SLASH_CAWDPSROSTER1="/cdroster"
|
|
SlashCmdList["CAWDPSROSTER"]=function(msg) refreshRoster(); chat("Roster GUID map:"); local count=0; local guid,info; for guid,info in D.guidToActor do count=count+1; chat(tostring(info.name).." = "..tostring(guid)..(info.classToken and (" ["..info.classToken.."]") or "")..(info.isPet and " [pet]" or "")) end; chat("Mapped units: "..tostring(count)) end
|
|
SLASH_CAWDPSBOOT1="/cdboot"
|
|
SlashCmdList["CAWDPSBOOT"]=function(msg) chat("Caw DPS Meter v"..D.version.." loaded; RAW_COMBATLOG="..tostring(D.rawRegistered)) end
|
|
|
|
updateUI()
|