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>
331 lines
17 KiB
Lua
331 lines
17 KiB
Lua
-- Optional DPSLog input for the vanilla client. This API starts with subevent,
|
|
-- NOT the timestamp/hideCaster prefix used by other CLEU implementations.
|
|
local D=CAW_DPS_METER
|
|
D.dpsLogVersion="3"
|
|
D.dpsLogEvents=0
|
|
D.dpsLogRejected=0
|
|
-- Describe the current producer, not the provenance of saved fights. Reading
|
|
-- this status must never probe a DLL, change producers or trigger a sync.
|
|
function D.combatInputStatus()
|
|
local source,detail,sync,syncDetail
|
|
if not D.parserEnabled() then
|
|
source="Paused"
|
|
detail="Local recording is paused. Saved fights remain available."
|
|
elseif D.dpsLogProbing then
|
|
source="Waiting for DPSLog"
|
|
detail="Waiting for a combat event to check DPSLog. No extra details have been confirmed yet."
|
|
elseif D.dpsLogActive then
|
|
source="DPSLog"
|
|
detail="Records damage, healing and targets. Overheal is shown when supplied. Combat text fills in other events."
|
|
else
|
|
source="Combat text"
|
|
if D.dpsLogRawCommitted and not D.dpsLogInitialized and type(CombatLogGetCurrentEventInfo)=="function" then
|
|
detail="DPSLog appeared after recording began. Combat text is active; use /reload to switch."
|
|
elseif D.dpsLogFallback and type(CombatLogGetCurrentEventInfo)=="function" then
|
|
detail="DPSLog could not be used. Combat text records damage and healing. See /cawinput for details."
|
|
else
|
|
detail="Records damage and healing. Overheal and target details need DPSLog data from Caw Sync."
|
|
end
|
|
end
|
|
if not D.parserEnabled() then
|
|
sync="Paused"
|
|
syncDetail="Turn on Record combat to resume sharing. Existing shared data stays available."
|
|
elseif not D.combatSyncEnabled() then
|
|
sync="Off"
|
|
syncDetail="Only local combat is recorded. Existing shared data stays available."
|
|
elseif GetNumRaidMembers()==0 and GetNumPartyMembers()==0 then
|
|
sync="Waiting for group"
|
|
syncDetail="Enabled. Shares combat data with other Caw users in your party or raid."
|
|
else
|
|
sync="On"
|
|
syncDetail="Other Caw users can fill gaps. Target details load when opened and may be incomplete."
|
|
end
|
|
return source,detail,sync,syncDetail
|
|
end
|
|
local moduleCache,moduleCacheAt
|
|
local function moduleDiagnostics()
|
|
local now=GetTime()
|
|
if moduleCache and now-moduleCacheAt<1 then return moduleCache end
|
|
local result={versionApiType=type(GetWeirdUtilsVersion),
|
|
moduleTableType=type(WeirdUtils),getSpellInfoType=type(GetSpellInfo),
|
|
castingApiType=type(UnitCastingInfo),channelApiType=type(UnitChannelInfo),
|
|
logPathApiType=type(GetCombatLogPath),versions={}}
|
|
local names={"dpslog","healtextfix","customassets","logsessions"}
|
|
local i,name
|
|
for i=1,table.getn(names) do
|
|
name=names[i]
|
|
if type(GetWeirdUtilsVersion)=="function" then
|
|
local ok,value=pcall(GetWeirdUtilsVersion,name)
|
|
if ok then
|
|
result.versions[name]=type(value)=="string" and value or "not reported"
|
|
else result.versions[name]="query failed" end
|
|
elseif type(WeirdUtils)=="table" and type(WeirdUtils[name])=="string" then
|
|
result.versions[name]=WeirdUtils[name]
|
|
end
|
|
end
|
|
moduleCache=result; moduleCacheAt=now
|
|
return result
|
|
end
|
|
local damage={SWING_DAMAGE=1,SPELL_DAMAGE=4,SPELL_PERIODIC_DAMAGE=4,
|
|
RANGE_DAMAGE=4,DAMAGE_SHIELD=4,DAMAGE_SPLIT=4}
|
|
local healing={SPELL_HEAL=true,SPELL_PERIODIC_HEAL=true}
|
|
local power={SPELL_ENERGIZE=true,SPELL_PERIODIC_ENERGIZE=true,
|
|
SPELL_DRAIN=true,SPELL_PERIODIC_DRAIN=true,SPELL_LEECH=true,SPELL_PERIODIC_LEECH=true}
|
|
local function guid(value)
|
|
return type(value)=="string" and string.find(value,"^0x[%x]+$")
|
|
and not string.find(value,"^0x0+$")
|
|
end
|
|
local function number(value)
|
|
return type(value)=="number" and value==value and value>=0 and value<math.huge
|
|
end
|
|
local function truth(value) return value==true or value==1 or value=="1" end
|
|
function D.dpsLogSaveStatus(session,countsOnly)
|
|
local s=session or (D.threatCalEnabled and D.threatCalSession)
|
|
if s then
|
|
s.combatInput=D.dpsLogActive and "DPSLog+RAW-utility" or "RAW"
|
|
local t=s.dpsLogStatus
|
|
if not t then t={}; s.dpsLogStatus=t; countsOnly=false end
|
|
t.received=D.dpsLogEvents; t.rejected=D.dpsLogRejected
|
|
-- Keep counters exact without rebuilding diagnostic metadata per hit.
|
|
-- Full snapshots still run on transitions, errors, the 1s tick/logout.
|
|
if countsOnly then return end
|
|
t.error=D.dpsLogLastError; t.fallback=D.dpsLogFallback
|
|
t.adapterVersion=D.dpsLogVersion; t.apiType=type(CombatLogGetCurrentEventInfo)
|
|
t.initialApiType=D.dpsLogInitialApiType; t.probing=D.dpsLogProbing or false
|
|
t.registered=D.dpsLogRegistered or false; t.rawCommitted=D.dpsLogRawCommitted or false
|
|
t.initializationAttempts=D.dpsLogInitAttempts or 0
|
|
t.registrationError=D.dpsLogRegistrationError; t.modules=moduleDiagnostics()
|
|
end
|
|
end
|
|
local status=D.dpsLogSaveStatus
|
|
local function choose(active,reason)
|
|
D.dpsLogProbing=false; D.dpsLogActive=active; D.dpsLogFallback=reason
|
|
local queued=D.dpsLogQueue or {}; D.dpsLogQueue={}
|
|
local i
|
|
for i=1,table.getn(queued) do D.parseRawReplay(queued[i][1],queued[i][2]) end
|
|
status()
|
|
end
|
|
function D.dpsLogRawGate(ev,text)
|
|
if not D.parserEnabled() then return true end
|
|
-- A DLL may publish its Lua functions after addon loading. Recheck before
|
|
-- handling the first amount, not only on a timer after it has been counted.
|
|
if not D.dpsLogInitialized and not D.dpsLogRawCommitted
|
|
and type(CombatLogGetCurrentEventInfo)=="function" then D.dpsLogInitialize() end
|
|
if not D.dpsLogProbing then return false end
|
|
if table.getn(D.dpsLogQueue)>=128 then choose(false,"probe queue limit"); return false end
|
|
if not D.dpsLogProbeAt then D.dpsLogProbeAt=GetTime() end
|
|
table.insert(D.dpsLogQueue,{ev,text})
|
|
return true
|
|
end
|
|
local function reject(reason)
|
|
-- Record for /cawinput only. The common cause -- another addon or client mod
|
|
-- firing COMBAT_LOG_EVENT_UNFILTERED -- is now ignored before it reaches
|
|
-- here (see the OnEvent handler), so an unsolicited chat warning would be
|
|
-- noise for the player; the count and last reason remain under /cawinput.
|
|
D.dpsLogRejected=D.dpsLogRejected+1; D.dpsLogLastError=reason
|
|
if D.dpsLogProbing then choose(false,reason) end
|
|
status()
|
|
return false
|
|
end
|
|
local function spellName(id,name)
|
|
if type(name)=="string" and name~="" then return name end
|
|
if type(GetSpellInfo)=="function" and type(id)=="number" then
|
|
local ok,n=pcall(GetSpellInfo,id)
|
|
if ok and type(n)=="string" and n~="" then return n end
|
|
end
|
|
return "Spell #"..tostring(id or "?")
|
|
end
|
|
function D.dpsLogReceive(sub,src,srcName,srcFlags,srcRaid,dst,dstName,dstFlags,dstRaid,
|
|
a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)
|
|
if not D.parserEnabled() then return false end
|
|
if not D.dpsLogActive and not D.dpsLogProbing then return false end
|
|
if type(src)=="string" and string.find(src,"^0x0+$") then src=nil end
|
|
if type(dst)=="string" and string.find(dst,"^0x0+$") then dst=nil end
|
|
if type(sub)~="string" or not string.find(sub,"^[A-Z_]+$")
|
|
or (src~=nil and not guid(src)) or (dst~=nil and not guid(dst)) then
|
|
return reject("unsupported header")
|
|
end
|
|
if D.dpsLogProbing then choose(true) end
|
|
D.dpsLogEvents=D.dpsLogEvents+1
|
|
status(nil,true)
|
|
-- This vanilla DPSLog's damageShieldDetour reports the original melee
|
|
-- attacker as source and the shield owner as destination (the spell is
|
|
-- resolved from that destination's auras). Normalize retaliation BEFORE
|
|
-- roster lookup: enemy shields must not become player/pet damage, while
|
|
-- our own shields still belong to the unit carrying the shield.
|
|
if sub=="DAMAGE_SHIELD" then
|
|
src,dst=dst,src; srcName,dstName=dstName,srcName
|
|
srcFlags,dstFlags=dstFlags,srcFlags; srcRaid,dstRaid=dstRaid,srcRaid
|
|
end
|
|
local info=src and D.guidToActor[src]
|
|
-- Membership comes from our roster or a proven summon chain, not just a
|
|
-- friendly flag. Never pull unrelated nearby players into group totals.
|
|
if sub=="SPELL_SUMMON" then
|
|
if not info or not guid(dst) then return false end
|
|
local owner=info.ownerKey or info.key
|
|
local name=dstName or spellName(a1,a2)
|
|
local summoned=D.guidToActor[dst] or {key=dst,guid=dst}
|
|
summoned.name=name; summoned.ownerKey=owner; summoned.isPet=true
|
|
summoned.classToken=nil
|
|
summoned.isTotem=string.find(name,"Totem",1,true)~=nil
|
|
D.guidToActor[dst]=summoned; D.summonActors[dst]=summoned; D.petOwner[dst]=owner
|
|
if D.threatCalRecordEvent then D.threatCalRecordEvent({kind="summon",guid=src,
|
|
target=dst,source=a2,spellId=a1,summonName=name,owner=owner,input="DPSLog"}) end
|
|
return true
|
|
end
|
|
if not info then return false end
|
|
if srcName and srcName~="" then info.name=srcName end
|
|
local offset=damage[sub]
|
|
if offset or healing[sub] then
|
|
if not guid(dst) then return reject("missing amount target") end
|
|
local amount,over,critical,spell,id
|
|
if offset==1 then
|
|
amount=a1; over=a2; critical=a7; spell="Melee"
|
|
else
|
|
amount=a4; over=a5; id=a1; spell=spellName(a1,a2)
|
|
critical=healing[sub] and a7 or a10
|
|
end
|
|
if not number(amount) or (over~=nil and (type(over)~="number" or over~=over or over>=math.huge or over<=-math.huge)) then
|
|
return reject("invalid amount suffix")
|
|
end
|
|
local effective=amount
|
|
local recordedOver=nil
|
|
if healing[sub] then
|
|
if over~=nil and over>=0 then
|
|
if not number(over) or over>amount then return reject("invalid overheal suffix") end
|
|
recordedOver=over
|
|
end
|
|
effective=amount-(recordedOver or 0)
|
|
end
|
|
-- Enrich existing bounded calibration events, not a second raw recorder.
|
|
D.dpsLogCurrent={subevent=sub,sourceGuid=src,destGuid=dst,spellId=id,
|
|
amount=amount,overAmount=over,sourceFlags=srcFlags,destFlags=dstFlags,
|
|
sourceRaidFlags=srcRaid,destRaidFlags=dstRaid,spellSchool=offset==1 and nil or a3,
|
|
critical=truth(critical)}
|
|
if sub=="DAMAGE_SHIELD" then D.dpsLogCurrent.shieldDirectionCorrected=true end
|
|
if healing[sub] then D.dpsLogCurrent.absorbed=a6
|
|
elseif offset==1 then
|
|
D.dpsLogCurrent.resisted=a4; D.dpsLogCurrent.blocked=a5; D.dpsLogCurrent.absorbed=a6
|
|
D.dpsLogCurrent.glancing=truth(a8); D.dpsLogCurrent.crushing=truth(a9)
|
|
else
|
|
D.dpsLogCurrent.resisted=a7; D.dpsLogCurrent.blocked=a8; D.dpsLogCurrent.absorbed=a9
|
|
D.dpsLogCurrent.glancing=truth(a11); D.dpsLogCurrent.crushing=truth(a12)
|
|
end
|
|
local ok,result=pcall(D.acceptStructuredAmount,healing[sub] and "healing" or "damage",
|
|
info,dst,dstName,spell,effective,truth(critical),id,recordedOver)
|
|
D.dpsLogCurrent=nil; D.threatEventTarget=nil
|
|
if not ok then return reject("amount handler: "..tostring(result)) end
|
|
return result
|
|
end
|
|
if power[sub] and guid(dst) and number(a4) then
|
|
if D.threatCalRecordEvent then D.threatCalRecordEvent({kind="power-observation",guid=src,
|
|
target=dst,source=a2,spellId=a1,amount=a4,powerType=a5,extraAmount=a6,
|
|
subevent=sub,input="DPSLog"}) end
|
|
return true -- Observation only: no invented mana/grounding threat formula.
|
|
end
|
|
if sub=="SPELL_INTERRUPT" and guid(dst) and D.recordInterrupt then
|
|
return D.recordInterrupt(info,spellName(a1,a2),dst,spellName(a4,a5))
|
|
end
|
|
if sub=="SPELL_DISPEL" and guid(dst) and D.recordUtility then
|
|
-- a1/a2 (the dispelling ability) are always spellId 0/unknown at the
|
|
-- source (see WeirdUtils dpslog.zig's dispelDetour) -- DPSLog can't
|
|
-- see which ability was used, only what got removed. Track dispels
|
|
-- by the removed aura (a4/a5) instead; RAW tracked them by ability
|
|
-- name, so this fork's "Dispels" breakdown now reads differently
|
|
-- (e.g. "Weakened Soul: 3" instead of "Purify: 3") but the count
|
|
-- itself is at least as accurate, without the old two-line
|
|
-- self-dispel heuristic's failure modes.
|
|
return D.recordUtility("dispels",info,spellName(a4,a5),dst)
|
|
end
|
|
return false -- Remaining utility/casts/deaths retain the existing RAW path.
|
|
end
|
|
local f=CreateFrame("Frame")
|
|
D.dpsLogFrame=f
|
|
function D.dpsLogInitialize()
|
|
-- Probe event delivery before selecting the producer. Queue the first RAW
|
|
-- lines briefly so event ordering cannot duplicate the first attack. Once
|
|
-- selected, the producer is stable until reload, including late CLEU events.
|
|
if D.dpsLogInitialized then status(); return end
|
|
D.dpsLogInitAttempts=(D.dpsLogInitAttempts or 0)+1
|
|
D.dpsLogInitialApiType=D.dpsLogInitialApiType or type(CombatLogGetCurrentEventInfo)
|
|
D.dpsLogActive=false
|
|
D.dpsLogQueue={}; D.dpsLogProbeAt=nil; D.dpsLogProbing=false
|
|
if type(CombatLogGetCurrentEventInfo)~="function" then
|
|
D.dpsLogFallback="CombatLogGetCurrentEventInfo unavailable; waiting for API"
|
|
status(); return -- Missing at load is not a permanent initialization.
|
|
end
|
|
if D.dpsLogRawCommitted then
|
|
D.dpsLogFallback="API appeared after RAW amounts; reload required to avoid double counting"
|
|
status(); return
|
|
end
|
|
local ok,err=pcall(f.RegisterEvent,f,"COMBAT_LOG_EVENT_UNFILTERED")
|
|
D.dpsLogRegistered=ok; D.dpsLogProbing=ok
|
|
if ok then
|
|
D.dpsLogInitialized=true; D.dpsLogFallback=nil; D.dpsLogRegistrationError=nil
|
|
else
|
|
D.dpsLogFallback="COMBAT_LOG_EVENT_UNFILTERED registration failed"
|
|
D.dpsLogRegistrationError=tostring(err)
|
|
end
|
|
status()
|
|
end
|
|
f:RegisterEvent("PLAYER_ENTERING_WORLD")
|
|
f:SetScript("OnEvent",function()
|
|
if not D.parserEnabled() then return end
|
|
if event=="PLAYER_ENTERING_WORLD" then D.dpsLogInitialize()
|
|
elseif event=="COMBAT_LOG_EVENT_UNFILTERED" and (D.dpsLogActive or D.dpsLogProbing) then
|
|
-- The DPSLog Community Edition delivers events through DpsLogPopEvent
|
|
-- polling and does not fire this native event (its custom event-table
|
|
-- write was removed). When polling is available a native
|
|
-- COMBAT_LOG_EVENT_UNFILTERED therefore comes from another addon or
|
|
-- client mod, not DPSLog: its arguments are an unrelated layout that
|
|
-- would be rejected as an unsupported header and, during the probe,
|
|
-- would drop the whole session to combat text. Let polling own delivery.
|
|
if type(DpsLogPopEvent)~="function" then
|
|
local ok,err=pcall(function() D.dpsLogReceive(CombatLogGetCurrentEventInfo()) end)
|
|
if not ok then reject("event API: "..tostring(err)) end
|
|
end
|
|
end
|
|
end)
|
|
-- Polling fallback: native COMBAT_LOG_EVENT_UNFILTERED delivery through the
|
|
-- engine's custom-event table isn't reaching this frame (undocumented struct
|
|
-- layout). DpsLogPopEvent drains the same resolved-event queue by polling.
|
|
local function dpsLogPollOnce()
|
|
local sub,src,srcName,srcFlags,srcRaid,dst,dstName,dstFlags,dstRaid,
|
|
a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12=DpsLogPopEvent()
|
|
if sub==nil then return false end
|
|
D.dpsLogReceive(sub,src,srcName,srcFlags,srcRaid,dst,dstName,dstFlags,dstRaid,
|
|
a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12)
|
|
return true
|
|
end
|
|
f:SetScript("OnUpdate",function()
|
|
if not D.parserEnabled() then return end
|
|
if type(DpsLogPopEvent)=="function" and (D.dpsLogActive or D.dpsLogProbing) then
|
|
local ok,err=pcall(function()
|
|
local n=0
|
|
while n<64 and dpsLogPollOnce() do n=n+1 end
|
|
end)
|
|
if not ok then reject("poll API: "..tostring(err)) end
|
|
end
|
|
if D.dpsLogProbing and D.dpsLogProbeAt and GetTime()-D.dpsLogProbeAt>=0.5 then
|
|
choose(false,"no structured event during RAW probe")
|
|
end
|
|
if GetTime()>=(D.dpsLogNextCheck or 0) then
|
|
D.dpsLogNextCheck=GetTime()+1
|
|
if not D.dpsLogInitialized then D.dpsLogInitialize() else status() end
|
|
end
|
|
end)
|
|
-- Initial attempt plus world-entry/timer/first-RAW retries for late Lua APIs.
|
|
D.dpsLogInitialize()
|
|
SLASH_CAWINPUT1="/cawinput"
|
|
SlashCmdList.CAWINPUT=function()
|
|
if not D.parserEnabled() then DEFAULT_CHAT_FRAME:AddMessage("Caw: Combat parser paused. Resume in Settings > Combat & sync."); return end
|
|
DEFAULT_CHAT_FRAME:AddMessage("Caw input: "..(D.dpsLogProbing and "waiting for DPSLog event" or (D.dpsLogActive and "DPSLog damage/healing + RAW utility" or "SuperWoW RAW"))
|
|
.." | Events: "..D.dpsLogEvents.." | Rejected: "..D.dpsLogRejected)
|
|
if D.dpsLogLastError then DEFAULT_CHAT_FRAME:AddMessage(D.dpsLogLastError) end
|
|
if D.dpsLogFallback then DEFAULT_CHAT_FRAME:AddMessage(D.dpsLogFallback) end
|
|
-- GetCombatLogPath (logsessions.dll) has crashed the client with a native
|
|
-- ACCESS_VIOLATION on at least one setup. pcall cannot protect against a
|
|
-- crash inside injected native code, so this call is removed rather than
|
|
-- guarded; the reported path was cosmetic-only diagnostic output.
|
|
end
|