Files
pfUI/libs/libpredict.lua
T
Brues f497c71523 Filter unit events to their units, rework energytick, drop macrotweak
Eight commits off classicapi_next.

ClassicAPI's RegisterUnitEvent registers for an event but only delivers it
when arg1 is one of the given units, so a handler for one unit stops waking
for every other one in the world. The 26 registrations whose unit set is
fixed at registration time now name it. The rule throughout is register the
superset and keep the handler's own check -- the filter narrows what arrives,
it does not decide what to act on. Guards that look unreachable stay put: the
filter applies only when arg1 is a string, so an event that fires with a
number or no argument is delivered as if plainly registered.

Frames whose unit changes at runtime own their subscriptions instead of
sorting events out per event. unitframes points them at the unitstr
UpdateVisibility already computes -- replacing a string concat, and on a miss
a second concat plus a UnitGUID call, for every frame on every unit event in
the world -- and a frame that is not in use drops its unit events entirely.
nameplates registers per plate against the plate's own token, which is also
the only workable shape: slots have no cap, so any nameplate1..N list would
have been a guess that fails in exactly the crowded scenes where plates
matter. marktracking names mark1 through mark8. A registration keeps its
kind, so none of these can be plain-registered first.

Both teardown paths PLAYER_LOGOUT guards -- the crash 132 -- now cover the
per-frame subscriptions: plates tear down rather than dispatching through
logout, and a unit frame takes itself off the visibility scan so it cannot
re-register what it just dropped.

marktracking also drops its once-a-second full rebuild, which ran for the
whole session whether or not a marker existed anywhere. The ticker is created
and cancelled with group membership. It is deliberately not keyed on a mark
being visible -- a marker on an out-of-range unit shows no row, and that is
the case the poll exists to catch.

nameplates gates the per-plate update against the floor across all four
throttle categories before classifying it, instead of running a GetAlpha, a
castbar IsShown, a cast lookup and up to two libthrottle:Get resolutions on
plates throttled to 10fps that were going to return anyway. Nothing that
would have updated can be turned away by a floor. The four throttles resolve
in CacheConfig, where config changes already land.

energytick sweeps the clock the server actually runs. There is one regen
timer for every power, re-armed every 2s by Player::RegenerateAll and never
touched by casting; the five-second rule changes what a tick pays, not when
it lands. The sweep is a free-running phase lock on that clock, so
Illumination refunds, potions and a Mana Spring totem on its own phase no
longer snap the spark mid-cycle, and an 80ms band keeps a correct tick from
hitching it at the wrap. The FSR window shades rather than predicting a share
of spirit the client cannot compute -- the Casting Regen item ladder is equip
auras absent from the buff list. The energy period is summed from
SPELL_AURA_MOD_ENERGY_REGEN_TIME across the spellbook and buffs, so Blade
Rush is found without GetTalentInfo(2, 16), an ordinal that does not fail
when the tree changes but reads another talent's rank.

macrotweak is gone -- ClassicAPI 1.15 covers it -- with its config entry, its
GUI block, its translations in all eight locales, and actionbar's
ButtonMacroScan, the #showtooltip scanner that fed it.
2026-09-11 00:10:51 -05:00

1322 lines
48 KiB
Lua

-- load pfUI environment
setfenv(1, pfUI:GetEnvironment())
--[[ libpredict ]]--
-- A pfUI library that detects, receives and sends heal and resurrection predictions.
-- Healing predictions are done by caching the last known "normal" heal value of the
-- spell when last being used. Those chaches are cleared when new talents are detected.
-- The API provides function calls similar to later WoW expansions such as:
-- UnitGetIncomingHeals(unit)
-- UnitHasIncomingResurrection(unit)
--
-- The library is able to receive and send compatible messages to HealComm
-- including resurrections. It has an option to disable the sending of those
-- messages in case HealComm is already active.
--
-- HOT TRACKING INTEGRATION (NEW):
-- With Nampower enabled, HoT tracking now primarily uses libdebuff's AURA_CAST
-- event system for accurate server-side buff/debuff tracking with full rank
-- protection. GetHotDuration() first checks libdebuff, then falls back to the
-- legacy prediction system for backwards compatibility with non-Nampower clients.
-- This provides:
-- - Accurate duration from server (no prediction needed)
-- - Automatic rank protection (lower ranks won't overwrite higher ranks)
-- - Support for multiple casters of same HoT on one target
-- - Zero event overhead (libdebuff already tracks all auras)
-- return instantly when another libpredict is already active
if pfUI.api.libpredict then return end
-- Check if libdebuff integration is available
local libdebuff_available = (pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast) and true or false
-- Check if Nampower is available for SPELL_FAILED events
local senttarget
local heals, ress, events, hots = {}, {}, {}, {}
local spell_queue = { "DUMMY", "DUMMYRank 9", "TARGET" }
local player = UnitName("player")
local cache, gear_string = {}, ""
local foreignCache = {} -- [casterName][spellKey] = amount, in-memory cache for other healers
local rejuvDuration, renewDuration = 12, 15 --default durations
local ressGuidToName = {} -- [casterGuid] = casterName, for SPELL_FAILED_OTHER cleanup
local healGuidToName = {} -- [casterGuid] = casterName, for SPELL_FAILED_OTHER cleanup
local ress_timers = {} -- [target][sender] = expiry_timestamp (60s rez window)
local RESS_TIMEOUT = 60 -- Vanilla: rez offer expires after 60s
-- Localized spell names resolved once from canonical rank-1 spellIDs.
-- Every rank shares the same name, so per-rank comparisons elsewhere can
-- be done against these constants without per-locale or per-rank tables.
local PRAYER_OF_HEALING = C_Spell.GetSpellName(596) -- Prayer of Healing (Rank 1)
local REJUVENATION = C_Spell.GetSpellName(774) -- Rejuvenation (Rank 1)
local RENEW = C_Spell.GetSpellName(139) -- Renew (Rank 1)
local REGROWTH = C_Spell.GetSpellName(8936) -- Regrowth (Rank 1)
local libpredict = CreateFrame("Frame")
libpredict:RegisterEvent("UNIT_HEALTH")
libpredict:RegisterEvent("CHAT_MSG_ADDON")
libpredict:RegisterEvent("PLAYER_TARGET_CHANGED")
libpredict:RegisterEvent("PLAYER_LOGOUT")
libpredict:SetScript("OnEvent", function()
-- Handle shutdown to prevent crash 132
if event == "PLAYER_LOGOUT" then
this:UnregisterAllEvents()
this:SetScript("OnEvent", nil)
return
end
if event == "CHAT_MSG_ADDON" and (arg1 == "HealComm" or arg1 == "CTRA") then
this:ParseChatMessage(arg4, arg2, arg1)
elseif event == "UNIT_HEALTH" then
local name = UnitName(arg1)
if name and ress[name] and not UnitIsDeadOrGhost(arg1) then
ress[name] = nil
end
end
end)
-- GUID->Name cache for dead players (UnitExists/UnitGUID return nil for corpses)
local guidNameCache = {} -- [guid] = name
local function resolveNameFromGuid(guid)
if not guid then return nil end
if guidNameCache[guid] then return guidNameCache[guid] end
-- UnitExists(guid) reverse lookup not reliable; cache is primary source
return nil
end
-- Register with libdebuff hooks
local function cacheRaidNames()
-- Cache all raid/party members while they are still reachable
for i = 1, GetNumRaidMembers() do
local unit = "raid" .. i
local guid = UnitGUID(unit)
local name = UnitName(unit)
if guid and name then guidNameCache[guid] = name end
end
for i = 1, GetNumPartyMembers() do
local unit = "party" .. i
local guid = UnitGUID(unit)
local name = UnitName(unit)
if guid and name then guidNameCache[guid] = name end
end
-- player self
local pguid = UnitGUID("player")
if pguid then guidNameCache[pguid] = UnitName("player") end
end
local function isRezSpell(spellId)
if not L["resurrections"] then return false end
local spellName = C_Spell.GetSpellName(spellId)
return spellName and L["resurrections"][spellName]
end
-- Build cache on world enter
libpredict:RegisterEvent("PLAYER_ENTERING_WORLD")
libpredict:RegisterEvent("RAID_ROSTER_UPDATE")
libpredict:RegisterEvent("PARTY_MEMBERS_CHANGED")
local origOnEvent = libpredict:GetScript("OnEvent")
libpredict:SetScript("OnEvent", function()
if event == "PLAYER_ENTERING_WORLD" or event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then
cacheRaidNames()
end
if origOnEvent then origOnEvent() end
end)
-- SPELL_START_SELF: own cast started (heals + rez)
pfUI.libdebuff_spell_start_self_hooks = pfUI.libdebuff_spell_start_self_hooks or {}
pfUI.libdebuff_spell_start_self_hooks["libpredict"] = function(spellId, casterGuid, targetGuid, castTime)
local spellName = C_Spell.GetSpellName(spellId)
if not spellName then return end
local pendingTarget = nil
local pendingTargetGuid = nil
local pending = pfUI.libpredict_pending_cast
if pending and pending.spellName == spellName and pending.targetGuid
and pending.time and (GetTime() - pending.time) < 1 then
-- If SPELL_CAST_EVENT target differs from SPELL_START_SELF target,
-- the client redirected the cast (e.g. offline target → selfcast).
-- Discard pending and trust the actual targetGuid from SPELL_START_SELF.
if pending.targetGuid == targetGuid then
local name = UnitName(pending.targetGuid)
if name and name ~= UNKNOWNOBJECT and name ~= UKNOWNBEING then
pendingTarget = name
pendingTargetGuid = pending.targetGuid
end
end
pending.spellId = nil
pending.spellName = nil
pending.targetGuid = nil
pending.time = nil
end
local target = pendingTarget or resolveNameFromGuid(targetGuid) or senttarget or spell_queue[3]
-- resolved GUID: prefer pendingTargetGuid (mouseover/click-to-cast), fall back to targetGuid
local resolvedTargetGuid = pendingTargetGuid or targetGuid
libpredict.sender.current_cast = spellName
libpredict.sender.current_cast_target = target
if L["resurrections"][spellName] then
if target then
libpredict:Ress(player, target, casterGuid)
libpredict.sender:SendHealCommMsg("Resurrection/" .. target .. "/start/")
libpredict.sender:SendResCommMsg("RES " .. target)
libpredict.sender.resurrecting = true
end
return
end
spell_queue[1] = spellName
spell_queue[2] = spellName .. (C_Spell.GetSpellSubtext(spellId) or "")
if spell_queue[1] == spellName and cache[spell_queue[2]] then
local amount = cache[spell_queue[2]][1]
local casttime = castTime
if spellName == REGROWTH then
local fullSpell = spell_queue[2]
local _, _, rankStr = fullSpell and string.find(fullSpell, "Rank (%d+)")
local rankNum = rankStr and tonumber(rankStr) or nil
if libpredict.sender.regrowth_timer then
libpredict.sender.regrowth_target_next = target
libpredict.sender.regrowth_rank_next = rankNum
else
libpredict.sender.regrowth_target = target
libpredict.sender.regrowth_rank = rankNum
end
end
if spellName == PRAYER_OF_HEALING then
-- target is already correctly set from spell_queue[3]:
-- selfcast (ALT) = player, otherwise = current target
-- Use this to find the correct group to heal
local pohTarget = target or player
if IsInRaid() then
-- Raid: find pohTarget's subgroup and heal only those members
-- (Turtle WoW changed PoH to heal the target's group, not the caster's group)
local targetGroup
for i = 1, GetNumRaidMembers() do
local name, _, subgroup = GetRaidRosterInfo(i)
if name == pohTarget then targetGroup = subgroup break end
end
-- Fallback to caster's own group if target not in raid
if not targetGroup then
for i = 1, GetNumRaidMembers() do
local name, _, subgroup = GetRaidRosterInfo(i)
if name == player then targetGroup = subgroup break end
end
end
if targetGroup then
for i = 1, GetNumRaidMembers() do
local name, _, subgroup = GetRaidRosterInfo(i)
if subgroup == targetGroup then
libpredict:Heal(player, name, amount, casttime)
libpredict.sender:SendHealCommMsg("Heal/" .. name .. "/" .. amount .. "/" .. casttime .. "/")
libpredict.sender.healing = true
end
end
end
else
-- Party: heal all party members including self
libpredict:Heal(player, player, amount, casttime)
libpredict.sender:SendHealCommMsg("Heal/" .. player .. "/" .. amount .. "/" .. casttime .. "/")
libpredict.sender.healing = true
for i = 1, 4 do
if CheckInteractDistance("party"..i, 4) then
local pname = UnitName("party"..i)
libpredict:Heal(player, pname, amount, casttime)
libpredict.sender:SendHealCommMsg("Heal/" .. pname .. "/" .. amount .. "/" .. casttime .. "/")
libpredict.sender.healing = true
end
end
end
return -- skip the generic Heal call below
end
-- If the resolved heal target is itself hostile (e.g. self-cast heal while targeting an enemy),
-- store the heal under the player so the player frame shows the prediction.
-- Use resolvedTargetGuid (prefers pendingTargetGuid from mouseover/click-to-cast) so we
-- don't wrongly fall back to player when healing a friend via mouseover with a foe in target.
local healTarget = target
local unitTargetGuid = UnitGUID and UnitGUID("target")
local healTargetIsHostile = resolvedTargetGuid and unitTargetGuid
and resolvedTargetGuid == unitTargetGuid
and UnitExists("target")
and (UnitCanAttack("player", "target") or not UnitIsFriend("player", "target"))
if healTargetIsHostile then
healTarget = player
end
libpredict:Heal(player, healTarget, amount, casttime)
libpredict.sender:SendHealCommMsg("Heal/" .. (healTarget or "") .. "/" .. amount .. "/" .. casttime .. "/")
libpredict.sender.healing = true
end
end
-- SPELL_GO_SELF: own cast landed (HealStop + Regrowth timer)
pfUI.libdebuff_spell_go_hooks["libpredict_sender"] = function(spellId)
libpredict:HealStop(player)
local spellName = C_Spell.GetSpellName(spellId)
if spellName == REGROWTH then
local now = pfUI.uf.now or GetTime()
if libpredict.sender.regrowth_timer then
libpredict.sender.regrowth_start_next = now
else
libpredict.sender.regrowth_start = now
end
libpredict.sender.regrowth_timer = now + 0.1
end
libpredict.sender.current_cast = nil
libpredict.sender.current_cast_target = nil
end
-- SPELL_START_OTHER: foreign cast started (heals + rez)
-- Signature: fn(spellId, casterGuid, targetGuid, castTime)
pfUI.libdebuff_spell_start_other_hooks = pfUI.libdebuff_spell_start_other_hooks or {}
pfUI.libdebuff_spell_start_other_hooks["libpredict"] = function(spellId, casterGuid, targetGuid, castTime)
local spellName = C_Spell.GetSpellName(spellId)
if not spellName then return end
local casterName = resolveNameFromGuid(casterGuid)
if not casterName then return end
-- Resurrection cast
if L["resurrections"][spellName] then
local targetName = resolveNameFromGuid(targetGuid)
if targetName then
libpredict:Ress(casterName, targetName, casterGuid)
end
return
end
-- Heal cast: look up cached amount from a previous cast by this healer
local targetName = resolveNameFromGuid(targetGuid)
if not targetName then return end
local rankStr = C_Spell.GetSpellSubtext(spellId) or ""
local spellKey = spellName .. (rankStr or "")
local amount = foreignCache[casterName] and foreignCache[casterName][spellKey]
if not amount then return end -- no data yet, skip until we have a real value
-- Prayer of Healing: heal entire subgroup of the target
if spellName == PRAYER_OF_HEALING then
if IsInRaid() then
local targetGroup
for i = 1, GetNumRaidMembers() do
local rname, _, subgroup = GetRaidRosterInfo(i)
if rname == targetName then targetGroup = subgroup break end
end
if not targetGroup then
for i = 1, GetNumRaidMembers() do
local rname, _, subgroup = GetRaidRosterInfo(i)
if rname == casterName then targetGroup = subgroup break end
end
end
if targetGroup then
for i = 1, GetNumRaidMembers() do
local rname, _, subgroup = GetRaidRosterInfo(i)
if subgroup == targetGroup then
libpredict:Heal(casterName, rname, amount, castTime, casterGuid)
end
end
end
else
-- Party: heal self + all members
libpredict:Heal(casterName, casterName, amount, castTime, casterGuid)
for i = 1, 4 do
local pname = UnitName("party" .. i)
if pname then
libpredict:Heal(casterName, pname, amount, castTime, casterGuid)
end
end
end
return
end
libpredict:Heal(casterName, targetName, amount, castTime, casterGuid)
end
-- SPELL_GO_SELF: own cast landed - HoTs + own rez timer
-- Signature: fn(spellId, arg1, arg2, arg3, arg4, arg5, arg6, arg7)
pfUI.libdebuff_spell_go_hooks = pfUI.libdebuff_spell_go_hooks or {}
pfUI.libdebuff_spell_go_hooks["libpredict"] = function(spellId, a1, a2, a3, a4, a5, a6, a7)
-- Instant HoTs — classify by name (rank-independent) instead of a
-- hardcoded per-rank ID table.
local spellName = C_Spell.GetSpellName(spellId)
local hotType
if spellName == REJUVENATION then hotType = "Reju"
elseif spellName == RENEW then hotType = "Renew"
end
if hotType then
local targetGuid = a4
local targetName = resolveNameFromGuid(targetGuid)
if targetName then
local duration
if hotType == "Reju" then duration = rejuvDuration or 12
elseif hotType == "Renew" then duration = renewDuration or 15
end
local rank = 0
local rankSub = C_Spell.GetSpellSubtext(spellId)
if rankSub and rankSub ~= "" then
rank = tonumber((string.gsub(rankSub, "Rank ", ""))) or 0
end
local playerName = UnitName("player")
libpredict:Hot(playerName, targetName, hotType, duration, nil, "SPELL_GO_SELF", rank)
local rankStr = tostring(rank)
if libpredict.sender and libpredict.sender.SendHealCommMsg then
libpredict.sender:SendHealCommMsg(hotType .. "/" .. targetName .. "/" .. duration .. "/" .. rankStr .. "/")
elseif IsInRaid() then
SendAddonMessage("HealComm", hotType .. "/" .. targetName .. "/" .. duration .. "/" .. rankStr .. "/", "RAID")
elseif IsInGroup() then
SendAddonMessage("HealComm", hotType .. "/" .. targetName .. "/" .. duration .. "/" .. rankStr .. "/", "PARTY")
end
end
end
-- Own rez landed: set timer
if isRezSpell(spellId) then
local targetGuid = a4
local targetName = resolveNameFromGuid(targetGuid)
local playerName = UnitName("player")
if playerName and targetName then
libpredict:RessSetTimer(playerName, targetName)
end
end
end
-- SPELL_GO_OTHER: foreign cast landed - rez timer + HealStop
-- Signature: fn(spellId, casterGuid, targetGuid)
pfUI.libdebuff_spell_go_other_hooks = pfUI.libdebuff_spell_go_other_hooks or {}
pfUI.libdebuff_spell_go_other_hooks["libpredict"] = function(spellId, casterGuid, targetGuid)
local casterName = resolveNameFromGuid(casterGuid)
if not casterName then return end
if isRezSpell(spellId) then
local targetName = resolveNameFromGuid(targetGuid)
if targetName then
libpredict:RessSetTimer(casterName, targetName)
end
return
end
-- Heal landed → prediction fulfilled, clean up
libpredict:HealStop(casterName)
end
-- SPELL_FAILED_OTHER: remove cancelled resses/heals
-- Signature: fn(casterGuid, spellId)
pfUI.libdebuff_spell_failed_other_hooks = pfUI.libdebuff_spell_failed_other_hooks or {}
pfUI.libdebuff_spell_failed_other_hooks["libpredict"] = function(casterGuid, spellId)
if not casterGuid then return end
local ressName = ressGuidToName[casterGuid]
if ressName then
libpredict:RessStop(ressName)
end
local healName = healGuidToName[casterGuid]
if healName then libpredict:HealStop(healName) end
end
libpredict:SetScript("OnUpdate", function()
-- throttle cleanup - no need to check every frame
local now = pfUI.uf.now or GetTime()
if (this.tick or 0) > now then return end
this.tick = now + pfUI.throttle:Get("libpredict") -- Default: Normal (10 FPS)
-- update on timeout events
for timestamp, targets in pairs(events) do
if now >= timestamp then
events[timestamp] = nil
end
end
-- Rez timeout: remove offer after 60s if player doesn't accept
for target, senders in pairs(ress_timers) do
for sender, expiry in pairs(senders) do
if now >= expiry then
senders[sender] = nil
if ress[target] then ress[target][sender] = nil end
end
end
end
end)
function libpredict:ParseComm(sender, msg)
local msgtype, target, heal, time, rank
if msg == "HealStop" or msg == "Healstop" or msg == "GrpHealstop" then
msgtype = "Stop"
-- DEBUG: Log when HealStop received
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[libpredict RX]|r HealStop from " .. tostring(sender))
end
elseif msg == "Resurrection/stop/" then
msgtype = "RessStop"
elseif msg then
local msgobj = {strsplit("/", msg)}
if msgobj and msgobj[1] and msgobj[2] then
-- legacy healcomm object
if msgobj[1] == "GrpHealdelay" or msgobj[1] == "Healdelay" then
msgtype, time = "Delay", msgobj[2]
end
if msgobj[1] and msgobj[1] == "Resurrection" and msgobj[2] then
msgtype, target = "Ress", msgobj[2]
end
if msgobj[1] == "Heal" and msgobj[2] then
msgtype, target, heal, time = "Heal", msgobj[2], msgobj[3], msgobj[4]
end
if msgobj[1] == "GrpHeal" and msgobj[2] then
msgtype, target, heal, time = "Heal", {}, msgobj[2], msgobj[3]
for i=4,8 do
if msgobj[i] then table.insert(target, msgobj[i]) end
end
end
if msgobj[1] == "Reju" or msgobj[1] == "Renew" or msgobj[1] == "Regr" then --hots
msgtype, target, heal, time = "Hot", msgobj[2], msgobj[1], msgobj[3]
-- NEW: Parse rank (optional, backwards compatible)
-- Format: "Reju/Target/12/10/" where msgobj[3]=duration, msgobj[4]=rank
-- "0" = unknown rank (for clients without rank extraction)
local rankStr = msgobj[4]
if rankStr and rankStr ~= "" and rankStr ~= "/" and rankStr ~= "0" then
rank = tonumber(rankStr)
end
end
elseif select then
-- latest healcomm
msgtype = tonumber(string.sub(msg, 1, 3))
if not msgtype then return end
-- Resolve sender's name to a group unit token so C_Spell can query
-- the cast. Group rosters are tiny (44 slots max) so the walk is cheap
-- relative to the cost of receiving a HealComm message.
local function senderUnit()
if UnitName("player") == sender then return "player" end
for i = 1, GetNumPartyMembers() do
if UnitName("party"..i) == sender then return "party"..i end
end
for i = 1, GetNumRaidMembers() do
if UnitName("raid"..i) == sender then return "raid"..i end
end
end
if msgtype == 0 then
msgtype = "Heal"
heal = tonumber(string.sub(msg, 4, 8))
target = string.sub(msg,9, -1)
local unit = senderUnit()
if not unit then return end
local _, _, _, startMs, endMs = C_Spell.UnitCastingInfo(unit)
if not startMs or not endMs then return end
time = (endMs - startMs) / 1000
elseif msgtype == 1 then
msgtype = "Stop"
elseif msgtype == 2 then
msgtype = "Heal"
heal = tonumber(string.sub(msg,4, 8))
target = {strsplit(":", string.sub(msg,9, -1))}
local unit = senderUnit()
if not unit then return end
local _, _, _, startMs, endMs = C_Spell.UnitCastingInfo(unit)
if not startMs or not endMs then return end
time = (endMs - startMs) / 1000
end
end
end
return msgtype, target, heal, time, rank
end
-- Duplicate detection for HoT messages
local recentHots = {}
local DUPLICATE_WINDOW = 0.5 -- Ignoriere gleiche Nachricht innerhalb 0.5s
function libpredict:ParseChatMessage(sender, msg, comm)
local msgtype, target, heal, time, rank
if comm == "HealComm" then
msgtype, target, heal, time, rank = libpredict:ParseComm(sender, msg)
elseif comm == "CTRA" then
local _, _, cmd, ctratarget = string.find(msg, "(%a+)%s?([^#]*)")
if cmd and ctratarget and cmd == "RES" and ctratarget ~= "" and ctratarget ~= UNKNOWN then
msgtype = "Ress"
target = ctratarget
end
end
if msgtype == "Stop" and sender then
libpredict:HealStop(sender)
return
elseif ( msg == "RessStop" or msg == "RESNO" ) and sender then
libpredict:RessStop(sender)
return
elseif msgtype == "Delay" and time then
libpredict:HealDelay(sender, time)
elseif msgtype == "Heal" and target and heal and time then
if type(target) == "table" then
for _, name in pairs(target) do
libpredict:Heal(sender, name, heal, time)
end
else
libpredict:Heal(sender, target, heal, time)
end
elseif msgtype == "Ress" then
-- HealComm: only for other players (own resses tracked via SPELL_START_SELF hook)
local playerName = UnitName("player")
if sender ~= playerName then
local senderGuid
for i = 1, GetNumRaidMembers() do
local unit = "raid" .. i
if UnitName(unit) == sender then
senderGuid = UnitGUID(unit)
break
end
end
if not senderGuid then
for i = 1, GetNumPartyMembers() do
local unit = "party" .. i
if UnitName(unit) == sender then
senderGuid = UnitGUID(unit)
break
end
end
end
libpredict:Ress(sender, target, senderGuid)
end
elseif msgtype == "Hot" then
-- Duplicate check: ignore same sender+target+spell within DUPLICATE_WINDOW
local now = pfUI.uf.now or GetTime()
local key = sender .. target .. heal
if recentHots[key] and (now - recentHots[key]) < DUPLICATE_WINDOW then
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[DUPLICATE IGNORED]|r " .. key)
end
return
end
recentHots[key] = now
-- Cleanup old entries (every 10s)
if not libpredict.lastCleanup or (now - libpredict.lastCleanup) > 10 then
for k, v in pairs(recentHots) do
if (now - v) > DUPLICATE_WINDOW then
recentHots[k] = nil
end
end
libpredict.lastCleanup = now
end
-- For own HoTs: correct the startTime
if sender == UnitName("player") then
local existing = hots[target] and hots[target][heal]
-- Do not overwrite if an active timer already exists
if existing and (existing.start + existing.duration) > now then
return
end
-- Compensate for HealComm delay
local delay = (heal == "Regr") and 0.3 or 0
local correctedStart = now - delay
libpredict:Hot(sender, target, heal, time, correctedStart, "ParseComm-Self", rank)
return
end
libpredict:Hot(sender, target, heal, time, nil, "ParseComm", rank)
end
end
function libpredict:AddEvent(time, target)
events[time] = events[time] or {}
table.insert(events[time], target)
end
function libpredict:Heal(sender, target, amount, duration, senderGuid)
if not sender or not target or not amount or not duration then
return
end
local now = pfUI.uf.now or GetTime()
local timeout = duration/1000 + now
heals[target] = heals[target] or {}
heals[target][sender] = { amount, timeout }
if senderGuid then healGuidToName[senderGuid] = sender end
libpredict:AddEvent(timeout, target)
end
-- Debug flag
libpredict.debug = false
function libpredict:Hot(sender, target, spell, duration, startTime, source, rank)
hots[target] = hots[target] or {}
hots[target][spell] = hots[target][spell] or {}
-- Correct Regrowth duration (server returns 21, should be 20)
if spell == "Regr" then
duration = 20
end
-- Sicherstellen dass duration eine Zahl ist
duration = tonumber(duration) or duration
-- Rank protection: Don't overwrite higher rank HoT with lower rank
local existing = hots[target][spell]
if existing and existing.rank and rank then
local existingRank = tonumber(existing.rank) or 0
local newRank = tonumber(rank) or 0
local now = pfUI.uf.now or GetTime()
local existingTimeleft = (existing.start + existing.duration) - now
-- If existing HoT is still active and has higher rank, don't overwrite
if existingTimeleft > 0 and newRank > 0 and newRank < existingRank then
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[Hot RANK BLOCK]|r %s Rank %d cannot overwrite Rank %d on %s",
spell, newRank, existingRank, target))
end
return -- Don't overwrite!
end
end
local now = pfUI.uf.now or GetTime()
hots[target][spell].duration = duration
hots[target][spell].start = startTime or now
hots[target][spell].rank = rank -- Store rank for protection
-- Debug
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage("|cff33ffcc[Hot]|r src=" .. (source or "?") ..
" | sender=" .. (sender or "nil") ..
" | target=" .. (target or "nil") ..
" | spell=" .. (spell or "nil") ..
" | dur=" .. tostring(duration) .. " (" .. type(duration) .. ")" ..
" | rank=" .. tostring(rank or "?"))
end
-- update aura events of relevant unitframes
if pfUI and pfUI.uf and pfUI.uf.frames then
for _, frame in pairs(pfUI.uf.frames) do
if frame.namecache == target then
frame.update_aura = true
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage(" |cff00ff00-> Frame update triggered for " .. (frame:GetName() or "?") .. "|r")
end
end
end
end
end
function libpredict:HealStop(sender)
for ttarget, t in pairs(heals) do
for tsender in pairs(heals[ttarget]) do
if sender == tsender then
heals[ttarget][tsender] = nil
end
end
end
-- cleanup reverse lookup
for guid, name in pairs(healGuidToName) do
if name == sender then healGuidToName[guid] = nil end
end
end
function libpredict:HealDelay(sender, delay)
local delay = delay/1000
for target, t in pairs(heals) do
for tsender, amount in pairs(heals[target]) do
if sender == tsender then
amount[2] = amount[2] + delay
libpredict:AddEvent(amount[2], target)
end
end
end
end
function libpredict:Ress(sender, target, senderGuid)
ress[target] = ress[target] or {}
ress[target][sender] = true
if senderGuid then ressGuidToName[senderGuid] = sender end
-- no timer here - timer is only set once the cast actually completes
end
function libpredict:RessSetTimer(sender, target)
ress_timers[target] = ress_timers[target] or {}
local existing = ress_timers[target][sender]
if not existing or GetTime() >= existing then
ress_timers[target][sender] = GetTime() + RESS_TIMEOUT
else
end
end
function libpredict:RessStop(sender)
local now = GetTime()
for ttarget, t in pairs(ress) do
for tsender in pairs(ress[ttarget]) do
if sender == tsender then
local expiry = ress_timers[ttarget] and ress_timers[ttarget][tsender]
if not expiry or now >= expiry then
ress[ttarget][tsender] = nil
if ress_timers[ttarget] then ress_timers[ttarget][tsender] = nil end
else
end
end
end
end
for guid, name in pairs(ressGuidToName) do
if name == sender then ressGuidToName[guid] = nil end
end
end
function libpredict:UnitGetIncomingHeals(unit)
if not unit then return 0 end
local name = UnitName(unit)
if not name then return 0 end
if UnitIsDeadOrGhost(unit) then return 0 end
local sumheal = 0
if not heals[name] then
return sumheal
else
local now = pfUI.uf.now or GetTime()
for sender, amount in pairs(heals[name]) do
if amount[2] <= now then
heals[name][sender] = nil
else
sumheal = sumheal + amount[1]
end
end
end
return sumheal
end
function libpredict:UnitHasIncomingResurrection(unit)
if not unit then return nil end
local name = UnitName(unit)
if not name then return nil end
if not ress[name] then
return nil
else
for sender, val in pairs(ress[name]) do
if val == true then
return val
end
end
end
return nil
end
local realm = GetRealmName()
local resetcache = CreateFrame("Frame")
local hotsetbonus = libtipscan:GetScanner("hotsetbonus")
resetcache:RegisterEvent("PLAYER_ENTERING_WORLD")
resetcache:RegisterEvent("LEARNED_SPELL_IN_TAB")
resetcache:RegisterEvent("CHARACTER_POINTS_CHANGED")
resetcache:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
resetcache:SetScript("OnEvent", function()
if event == "PLAYER_ENTERING_WORLD" then
-- load and initialize previous caches of spell amounts
pfUI_cache["prediction"] = pfUI_cache["prediction"] or {}
pfUI_cache["prediction"][realm] = pfUI_cache["prediction"][realm] or {}
pfUI_cache["prediction"][realm][player] = pfUI_cache["prediction"][realm][player] or {}
pfUI_cache["prediction"][realm][player]["heals"] = pfUI_cache["prediction"][realm][player]["heals"] or {}
cache = pfUI_cache["prediction"][realm][player]["heals"]
end
if event == "PLAYER_EQUIPMENT_CHANGED" or event == "PLAYER_ENTERING_WORLD" then
local gear = ""
for id = 1, 18 do
gear = gear .. (GetInventoryItemLink("player",id) or "")
end
-- abort when inventory didn't change
if gear == gear_string then return end
gear_string = gear
local setBonusCounter
setBonusCounter = 0
for i=1,10 do --there is no need to check slots above 10
hotsetbonus:SetInventoryItem("player", i)
if hotsetbonus:Find(L["healduration"]["Rejuvenation"]) then setBonusCounter = setBonusCounter + 1 end
end
rejuvDuration = setBonusCounter == 8 and 15 or 12
setBonusCounter = 0
for i =1,10 do
hotsetbonus:SetInventoryItem("player", i)
if hotsetbonus:Find(L["healduration"]["Renew"]) then setBonusCounter = setBonusCounter + 1 end
end
renewDuration = setBonusCounter == 5 and 18 or 15
end
-- flag all cached heals for renewal
for k in pairs(cache) do
if type(cache[k]) == "number" or type(cache[k]) == "string" then
-- migrate old data
local oldval = cache[k]
cache[k] = { [1] = oldval }
end
-- flag for reset
cache[k][2] = true
end
end)
local function UpdateCache(spell, heal, crit)
local heal = heal and tonumber(heal)
if not spell or not heal then return end
if not cache[spell] then
-- no cache yet: save whatever we get
cache[spell] = {}
cache[spell][1] = crit and heal*2/3 or heal
cache[spell][2] = crit
elseif cache[spell][2] == true then
-- flagged as stale (gear/skill change): always overwrite
cache[spell][1] = crit and heal*2/3 or heal
cache[spell][2] = crit
elseif crit then
-- crit: don't overwrite existing non-crit value
else
-- non-crit: save best value
if cache[spell][1] < heal then
cache[spell][1] = heal
end
cache[spell][2] = false
end
end
-- Cooldown for local instant HoT hooks (prevents spam on click-to-cast)
local instantHotCooldown = {}
local INSTANT_HOT_COOLDOWN = 1.0 -- 1 Sekunde Cooldown (GCD ist 1.5s)
-- Pending HoTs Queue - wird nach Delay verifiziert
local pendingHots = {}
-- Gather Data by User Actions
hooksecurefunc("CastSpell", function(id, bookType)
if not libpredict.sender.enabled then return end
local effect, rank = libspell.GetSpellInfo(id, bookType)
if not effect then return end
spell_queue[1] = effect
spell_queue[2] = effect.. ( rank or "" )
spell_queue[3] = UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player")
-- Extract rank number
local rankNum = nil
if rank and rank ~= "" then
rankNum = tonumber((string.gsub(rank, "Rank ", ""))) or nil
end
-- Instant HoTs: libdebuff/Nampower via GetHotDuration, hook method as fallback
if effect == REJUVENATION then
local target = spell_queue[3]
local now = pfUI.uf.now or GetTime()
local key = "Reju" .. target
-- Cooldown-Check
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
return
end
instantHotCooldown[key] = now
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpell REJU INSTANT]|r target=%s rank=%s (Fallback)", target, tostring(rankNum or "?")))
end
libpredict:Hot(player, target, "Reju", rejuvDuration, nil, "CastSpell-Instant", rankNum)
local rankStr = rankNum and tostring(rankNum) or "0"
libpredict.sender:SendHealCommMsg("Reju/"..target.."/"..rejuvDuration.."/"..rankStr.."/")
elseif effect == RENEW then
local target = spell_queue[3]
local now = pfUI.uf.now or GetTime()
local key = "Renew" .. target
-- Cooldown-Check
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
return
end
instantHotCooldown[key] = now
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpell RENEW INSTANT]|r target=%s rank=%s (Fallback)", target, tostring(rankNum or "?")))
end
libpredict:Hot(player, target, "Renew", renewDuration, nil, "CastSpell-Instant", rankNum)
local rankStr = rankNum and tostring(rankNum) or "0"
libpredict.sender:SendHealCommMsg("Renew/"..target.."/"..renewDuration.."/"..rankStr.."/")
end
end)
hooksecurefunc("CastSpellByName", function(effect, target)
if not libpredict.sender.enabled then return end
local effect, rank = libspell.GetSpellInfo(effect)
if not effect then return end
local default = UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player")
target = target and type(target) == "string" and UnitName(target) or target
target = target and target == true and UnitName("player") or target
target = target and target == 1 and UnitName("player") or target
-- Extract rank number
local rankNum = nil
if rank and rank ~= "" then
rankNum = tonumber((string.gsub(rank, "Rank ", ""))) or nil
end
-- Only overwrite spell_queue if no cast is in progress
-- (prevents instant spam from destroying the queue during a Regrowth cast)
if not libpredict.sender.current_cast then
spell_queue[1] = effect
spell_queue[2] = effect.. ( rank or "" )
spell_queue[3] = target or default
end
-- Instant HoTs: libdebuff/Nampower via GetHotDuration, hook method as fallback
if effect == REJUVENATION then
local hotTarget = target or default
local now = pfUI.uf.now or GetTime()
local key = "Reju" .. hotTarget
-- Cooldown-Check
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
return
end
instantHotCooldown[key] = now
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpellByName REJU INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?")))
end
libpredict:Hot(player, hotTarget, "Reju", rejuvDuration, nil, "CastSpellByName-Instant", rankNum)
local rankStr = rankNum and tostring(rankNum) or "0"
libpredict.sender:SendHealCommMsg("Reju/"..hotTarget.."/"..rejuvDuration.."/"..rankStr.."/")
elseif effect == RENEW then
local hotTarget = target or default
local now = pfUI.uf.now or GetTime()
local key = "Renew" .. hotTarget
-- Cooldown-Check
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
return
end
instantHotCooldown[key] = now
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpellByName RENEW INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?")))
end
libpredict:Hot(player, hotTarget, "Renew", renewDuration, nil, "CastSpellByName-Instant", rankNum)
local rankStr = rankNum and tostring(rankNum) or "0"
libpredict.sender:SendHealCommMsg("Renew/"..hotTarget.."/"..renewDuration.."/"..rankStr.."/")
end
end)
hooksecurefunc("UseAction", function(slot, target, selfcast)
if not libpredict.sender.enabled then return end
if not IsCurrentAction(slot) then return end
local kind, id = GetActionInfo(slot)
local effect, rank
if kind == "spell" then
local spellInfo = C_Spell.GetSpellInfo(id)
effect, rank = spellInfo.name, spellInfo.rank
elseif kind == "macro" then
effect, rank = GetMacroSpell(id)
end
if not effect then return end
spell_queue[1] = effect
spell_queue[2] = effect.. ( rank or "" )
spell_queue[3] = selfcast and UnitName("player") or UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player")
-- Extract rank number
local rankNum = nil
if rank and rank ~= "" then
rankNum = tonumber((string.gsub(rank, "Rank ", ""))) or nil
end
-- Instant HoTs: libdebuff/Nampower via GetHotDuration, hook method as fallback
if effect == REJUVENATION then
local hotTarget = spell_queue[3]
local now = pfUI.uf.now or GetTime()
local key = "Reju" .. hotTarget
-- Cooldown-Check
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
return
end
instantHotCooldown[key] = now
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[UseAction REJU INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?")))
end
libpredict:Hot(player, hotTarget, "Reju", rejuvDuration, nil, "UseAction-Instant", rankNum)
local rankStr = rankNum and tostring(rankNum) or "0"
libpredict.sender:SendHealCommMsg("Reju/"..hotTarget.."/"..rejuvDuration.."/"..rankStr.."/")
elseif effect == RENEW then
local hotTarget = spell_queue[3]
local now = pfUI.uf.now or GetTime()
local key = "Renew" .. hotTarget
-- Cooldown-Check
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
return
end
instantHotCooldown[key] = now
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[UseAction RENEW INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?")))
end
libpredict:Hot(player, hotTarget, "Renew", renewDuration, nil, "UseAction-Instant", rankNum)
local rankStr = rankNum and tostring(rankNum) or "0"
libpredict.sender:SendHealCommMsg("Renew/"..hotTarget.."/"..renewDuration.."/"..rankStr.."/")
end
end)
libpredict.sender = CreateFrame("Frame", "pfPredictionSender", UIParent)
libpredict.sender.enabled = true
libpredict.sender.SendHealCommMsg = function(self, msg)
-- Smart channel selection: Only send to relevant channel to avoid duplicates
if IsInRaid() then
-- In raid: Only send to RAID (includes all raid members)
SendAddonMessage("HealComm", msg, "RAID")
elseif IsInGroup() then
-- In party: Only send to PARTY
SendAddonMessage("HealComm", msg, "PARTY")
end
-- Note: BATTLEGROUND channel not used (no reliable way to detect BG in Vanilla)
-- BG groups are handled by RAID channel
end
libpredict.sender.SendResCommMsg = function(self, msg)
-- Smart channel selection: Only send to relevant channel to avoid duplicates
if IsInRaid() then
-- In raid: Only send to RAID (includes all raid members)
SendAddonMessage("CTRA", msg, "RAID")
elseif IsInGroup() then
-- In party: Only send to PARTY
SendAddonMessage("CTRA", msg, "PARTY")
end
-- Note: BATTLEGROUND channel not used (no reliable way to detect BG in Vanilla)
-- BG groups are handled by RAID channel
end
libpredict.sender:SetScript("OnUpdate", function()
-- trigger delayed regrowth timers
local now = pfUI.uf.now or GetTime()
if this.regrowth_timer and now > this.regrowth_timer then
local target = this.regrowth_target or player
local duration = 20
local startTime = this.regrowth_start
local rank = this.regrowth_rank
libpredict:Hot(player, target, "Regr", duration, startTime, "OnUpdate", rank)
local rankStr = rank and tostring(rank) or "0"
libpredict.sender:SendHealCommMsg("Regr/"..target.."/"..duration.."/"..rankStr.."/")
-- Apply next queued Regrowth if available
this.regrowth_target = this.regrowth_target_next
this.regrowth_start = this.regrowth_start_next
this.regrowth_rank = this.regrowth_rank_next
this.regrowth_target_next = nil
this.regrowth_start_next = nil
this.regrowth_rank_next = nil
this.regrowth_timer = nil
end
end)
-- Nampower events
libpredict.sender:RegisterEvent("SPELL_FAILED_SELF")
libpredict.sender:RegisterEvent("SPELL_DELAYED_SELF")
libpredict.sender:RegisterEvent("SPELL_HEAL_BY_SELF")
libpredict.sender:RegisterEvent("SPELL_HEAL_BY_OTHER") -- populates foreignCache for other healers
-- force cache updates
libpredict.sender:RegisterUnitEvent("UNIT_INVENTORY_CHANGED", "player")
libpredict.sender:RegisterEvent("SKILL_LINES_CHANGED")
-- Shared cleanup helper for failed/interrupted casts
local function onCastFailed()
local s = libpredict.sender
if s.healing then
libpredict:HealStop(UnitName("player"))
s:SendHealCommMsg("Healstop")
s.healing = nil
elseif s.resurrecting then
local target = s.current_cast_target or senttarget or spell_queue[3]
libpredict:RessStop(UnitName("player"))
s:SendHealCommMsg("Resurrection/stop/")
s:SendResCommMsg("RESNO " .. (target or ""))
s.resurrecting = nil
end
if s.current_cast == REGROWTH then
s.regrowth_timer = nil
s.regrowth_start = nil
s.regrowth_target_next = nil
s.regrowth_start_next = nil
end
s.current_cast = nil
s.current_cast_target = nil
end
libpredict.sender:SetScript("OnEvent", function()
-- ============================================================
-- NAMPOWER PATH
-- ============================================================
if event == "SPELL_HEAL_BY_SELF" then
-- arg1=targetGuid, arg2=casterGuid, arg3=spellId, arg4=amount, arg5=critical, arg6=periodic
local spellId = arg3
local amount = arg4
local isCrit = arg5 == 1
local isPeriodic = arg6 == 1
local spellName = C_Spell.GetSpellName(spellId)
if spellName and spell_queue[1] == spellName then
UpdateCache(spell_queue[2], amount, isCrit)
end
elseif event == "SPELL_HEAL_BY_OTHER" then
-- Fires once per hit target for AoE heals (e.g. PoH).
-- Use this to build a per-caster cache of real heal amounts.
-- arg1=targetGuid, arg2=casterGuid, arg3=spellId, arg4=amount, arg5=critical, arg6=periodic
local casterGuid = arg2
local spellId = arg3
local amount = arg4
local isCrit = arg5 == 1
local isPeriodic = arg6 == 1
if isPeriodic or not spellId or not amount or amount <= 0 then return end
local casterName = resolveNameFromGuid(casterGuid)
if not casterName or casterName == player then return end -- own heals handled by SPELL_HEAL_BY_SELF
local spellName = C_Spell.GetSpellName(spellId)
if not spellName then return end
local rankStr = C_Spell.GetSpellSubtext(spellId)
local spellKey = spellName .. (rankStr or "")
foreignCache[casterName] = foreignCache[casterName] or {}
local existing = foreignCache[casterName][spellKey]
-- Store highest non-crit value for best prediction accuracy
if isCrit then
-- Estimate base from crit (vanilla crit = 150%)
local base = math.floor(amount * 2 / 3)
if not existing or base > existing then
foreignCache[casterName][spellKey] = base
end
else
if not existing or amount > existing then
foreignCache[casterName][spellKey] = amount
end
end
elseif event == "SPELL_FAILED_SELF" then
onCastFailed()
elseif event == "SPELL_DELAYED_SELF" then
-- arg1=casterGuid, arg2=delayMs
if libpredict.sender.healing then
libpredict:HealDelay(player, arg2)
libpredict.sender:SendHealCommMsg("Healdelay/" .. arg2 .. "/")
end
end
end)
function libpredict:GetHotDuration(unit, spell)
if unit == UNKNOWNOBJECT or unit == UKNOWNBEING then return end
-- NEW: Try libdebuff first (Nampower AURA_CAST events)
if pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast then
local guid = UnitGUID(unit)
if guid then
-- Get the best (highest rank) aura cast for this spell
local spellName = spell
-- Map short spell codes to full names
if spell == "Reju" then
spellName = REJUVENATION
elseif spell == "Regr" then
spellName = REGROWTH
elseif spell == "Renew" then
spellName = RENEW
end
local start, duration, timeleft, rank, casterGuid = pfUI.api.libdebuff:GetBestAuraCast(guid, spellName)
if start and duration and timeleft then
-- SUCCESS: libdebuff has accurate server-side data!
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[GetHotDuration]|r %s on %s via libdebuff: dur=%.1fs timeleft=%.1fs rank=%d",
spell, unit, duration, timeleft, rank or 0))
end
return start, duration, timeleft
end
end
end
-- FALLBACK: Use old prediction system (for non-Nampower clients or no AURA_CAST data)
local start, duration, timeleft
local now = pfUI.uf.now or GetTime()
local unitName = UnitName(unit)
local unitdata = hots[unitName]
if unitdata and unitdata[spell] then
local spellData = unitdata[spell]
if spellData.start and spellData.duration then
local endTime = spellData.start + spellData.duration
if endTime > now - 1 then
start = spellData.start
duration = spellData.duration
timeleft = endTime - now
if libpredict.debug then
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff9900[GetHotDuration]|r %s on %s via prediction: dur=%.1fs timeleft=%.1fs",
spell, unit, duration, timeleft))
end
end
end
end
return start, duration, timeleft
end
-- Debug command: /hotdebug - Show HoT tracking status
_G.SLASH_HOTDEBUG1 = "/hotdebug"
_G.SlashCmdList.HOTDEBUG = function()
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========================================|r")
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[HoT Tracking Debug]|r")
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========================================|r")
-- Check libdebuff availability
local libdebuff_now = (pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast) and true or false
if libdebuff_now then
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[PRIMARY]|r libdebuff integration: ACTIVE")
DEFAULT_CHAT_FRAME:AddMessage(" Using AURA_CAST events for server-side tracking")
DEFAULT_CHAT_FRAME:AddMessage(" Rank protection: ENABLED")
else
DEFAULT_CHAT_FRAME:AddMessage("|cffff9900[PRIMARY]|r libdebuff integration: NOT AVAILABLE")
DEFAULT_CHAT_FRAME:AddMessage(" Reason: Nampower not enabled or libdebuff outdated")
end
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[FALLBACK]|r Legacy prediction system: ACTIVE")
DEFAULT_CHAT_FRAME:AddMessage(" Using hook method + HealComm messages")
-- Show active HoTs in tracking
local hotCount = 0
for target, spells in pairs(hots) do
for spell, data in pairs(spells) do
hotCount = hotCount + 1
end
end
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ffff[TRACKED]|r %d HoTs in legacy system", hotCount))
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========================================|r")
DEFAULT_CHAT_FRAME:AddMessage("Tip: /libpredict.debug = true for verbose logging")
end
pfUI.api.libpredict = libpredict