Files
pfUI/libs/libpredict.lua
T
Brues 26c55bddb0 Drive the rez icon from ClassicAPI's incoming resurrections
ClassicAPI's UnitHasIncomingResurrection sees every resurrection cast in
view from SMSG_SPELL_START and ends it with the cast, and
INCOMING_RESURRECT_CHANGED signals each change. libpredict's own
tracking goes: the ress tables and 60s timers, parsing Resurrection and
RES/RESNO messages, clearing on UNIT_HEALTH, and the rez branches of the
cast handlers, along with UNIT_SPELLCAST_STOP and the foreign cast
targets only rez used. libpredict still announces the player's own
resurrections to HealComm and CTRA for clients without ClassicAPI.

The native state ends with the cast, so a rez that landed would vanish
before the target accepts it. libpredict records the SPELL_RESURRECT
combat log event per GUID and keeps the offer until the target is alive
again or the 60s accept popup runs out, unlike Grid2, which cannot tell
a landed rez from a cancelled one. UnitHasResurrection answers both.

The unit frames show the icon in RefreshIndicators, refreshed by
INCOMING_RESURRECT_CHANGED on the frame's own unit and by libpredict for
offers, instead of polling libpredict every 0.1s.
2026-10-29 23:49:28 -05:00

1166 lines
42 KiB
Lua

-- load pfUI environment
setfenv(1, pfUI:GetEnvironment())
--[[ libpredict ]]--
-- A pfUI library that detects, receives and sends heal 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)
-- UnitHasResurrection(unit)
--
-- UnitHasResurrection extends ClassicAPI's UnitHasIncomingResurrection, which
-- ends with the cast, to a resurrection that landed and waits for the target
-- to accept it.
--
-- The library is able to receive and send compatible messages to HealComm,
-- and announces the player's resurrections to HealComm and CTRA. It has an
-- option to disable the sending of those messages in case HealComm is
-- already active.
--
-- Casts are read from ClassicAPI's UNIT_SPELLCAST_* events and heal amounts
-- from COMBAT_LOG_EVENT_UNFILTERED. GetHotDuration() reads live HoT timers
-- from C_UnitAuras (via libdebuff) and falls back to the HealComm-fed table.
-- return instantly when another libpredict is already active
if pfUI.api.libpredict then return end
local heals, 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 healGuidToName = {} -- [casterGuid] = casterName, for interrupted-cast cleanup
local sentTargets = {} -- [castGUID] = target name of an own cast, from UNIT_SPELLCAST_SENT
local resOffers = {} -- [guid] = expiry of a landed resurrection the target has yet to accept
local RES_OFFER_TIMEOUT = 60 -- the target's resurrect popup declines itself 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("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" then
this:ParseChatMessage(arg4, arg2)
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
-- 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)
-- Own cast-time spell started (heals + rez)
local function OnOwnCastStart(spellId, castGUID)
local spellName = C_Spell.GetSpellName(spellId)
if not spellName then return end
-- SENT names the unit the cast went out at; self and no-target casts
-- carry none, so fall back to what the cast hooks recorded
local target = sentTargets[castGUID] or spell_queue[3]
local _, _, _, startMs, endMs = C_Spell.CastingInfo()
local castTime = startMs and endMs - startMs or 0
libpredict.sender.castGUID = castGUID
libpredict.sender.delayMs = 0
libpredict.sender.current_cast = spellName
libpredict.sender.current_cast_target = target
if L["resurrections"][spellName] then
if target then
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
libpredict:Heal(player, target, amount, casttime)
libpredict.sender:SendHealCommMsg("Heal/" .. (target or "") .. "/" .. amount .. "/" .. casttime .. "/")
libpredict.sender.healing = true
end
end
-- Own cast landed: HealStop, Regrowth timer, instant HoTs
local function OnOwnCastSucceeded(spellId, castGUID)
local target = sentTargets[castGUID] or spell_queue[3]
sentTargets[castGUID] = nil
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.castGUID = nil
libpredict.sender.current_cast = nil
libpredict.sender.current_cast_target = nil
if not target then return end
-- Instant HoTs — classify by name (rank-independent) instead of a
-- hardcoded per-rank ID table.
local hotType
if spellName == REJUVENATION then hotType = "Reju"
elseif spellName == RENEW then hotType = "Renew"
end
if hotType then
local duration = hotType == "Reju" and rejuvDuration or renewDuration
local rank = 0
local rankSub = C_Spell.GetSpellSubtext(spellId)
if rankSub and rankSub ~= "" then
rank = tonumber((string.gsub(rankSub, "Rank ", ""))) or 0
end
libpredict:Hot(player, target, hotType, duration, nil, "UNIT_SPELLCAST_SUCCEEDED", rank)
libpredict.sender:SendHealCommMsg(hotType .. "/" .. target .. "/" .. duration .. "/" .. rank .. "/")
end
end
-- Foreign cast-time heal started
local function OnOtherCastStart(unit, spellId)
local spellName = C_Spell.GetSpellName(spellId)
if not spellName then return end
local casterGuid = UnitGUID(unit)
local casterName = resolveNameFromGuid(casterGuid)
if not casterName then return end
local targetName = UnitSpellTargetName(unit)
if not targetName then return end
-- Heal cast: look up cached amount from a previous cast by this healer
local _, _, _, startMs, endMs = C_Spell.UnitCastingInfo(unit)
if not startMs then return end
local castTime = endMs - startMs
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
-- Foreign cast landed: the predicted heal is fulfilled
local function OnOtherCastSucceeded(unit)
local casterName = resolveNameFromGuid(UnitGUID(unit))
if not casterName then return end
libpredict:HealStop(casterName)
end
-- Foreign cast interrupted: remove cancelled heals
local function OnOtherCastInterrupted(unit)
local casterGuid = UnitGUID(unit)
if not casterGuid then return end
local healName = healGuidToName[casterGuid]
if healName then libpredict:HealStop(healName) end
end
-- Flag the unit frames showing `guid` to redraw their resurrection icon
local function RefreshResFrames(guid)
for _, frame in pairs(pfUI.uf.frames) do
if frame.unitstr and UnitGUID(frame.unitstr) == guid then
frame.update_indicators = true
end
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
-- drop resurrection offers the target accepted or let run out
for guid, expiry in pairs(resOffers) do
local unit = UnitTokenFromGUID(guid)
if now >= expiry or not (unit and UnitIsDeadOrGhost(unit)) then
resOffers[guid] = nil
RefreshResFrames(guid)
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 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] == "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)
local msgtype, target, heal, time, rank = libpredict:ParseComm(sender, msg)
if msgtype == "Stop" and sender then
libpredict:HealStop(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 == "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: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
-- Whether a resurrection is being cast at the unit, or landed and waits for
-- the unit to accept it
function libpredict:UnitHasResurrection(unit)
return UnitHasIncomingResurrection(unit) or resOffers[UnitGUID(unit)] ~= 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)
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: UNIT_SPELLCAST_SUCCEEDED and C_UnitAuras, 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)
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: UNIT_SPELLCAST_SUCCEEDED and C_UnitAuras, 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 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: UNIT_SPELLCAST_SUCCEEDED and C_UnitAuras, 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)
-- cleared when a HealComm addon already broadcasts this player's casts
libpredict.sender.enabled = true
libpredict.sender.SendHealCommMsg = function(self, msg)
if not self.enabled then return end
-- 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)
if not self.enabled then return end
-- 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)
-- Own casts arrive as "player"; every other caster fires once per unit token
-- it currently holds (raidN, target, nameplateN, ...), so other-unit handlers
-- drop the repeats by castGUID.
libpredict.sender:RegisterEvent("UNIT_SPELLCAST_SENT")
libpredict.sender:RegisterEvent("UNIT_SPELLCAST_START")
libpredict.sender:RegisterEvent("UNIT_SPELLCAST_DELAYED")
libpredict.sender:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
libpredict.sender:RegisterEvent("UNIT_SPELLCAST_FAILED")
libpredict.sender:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED")
libpredict.sender:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
-- 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 spell_queue[3]
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.castGUID = nil
s.current_cast = nil
s.current_cast_target = nil
end
-- Real heal amounts: own heals refresh the spell cache, other group healers'
-- heals fill foreignCache (one event per hit target for AoE heals like PoH).
local function OnHeal()
-- arg4=sourceGUID, arg12=spellId, arg13=spellName, arg15=amount, arg18=critical
local spellId, spellName, amount, isCrit = arg12, arg13, arg15, arg18 == 1
if not spellName or not amount or amount <= 0 then return end
if IsPlayerGuid(arg4) then
if spell_queue[1] == spellName then
UpdateCache(spell_queue[2], amount, isCrit)
end
return
end
local casterName = resolveNameFromGuid(arg4)
if not casterName then return end
local spellKey = spellName .. (C_Spell.GetSpellSubtext(spellId) 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
end
-- A landed resurrection: the target has an offer to accept, which outlasts
-- UnitHasIncomingResurrection's end with the cast
local function OnResurrect()
-- arg8=destGUID
resOffers[arg8] = GetTime() + RES_OFFER_TIMEOUT
RefreshResFrames(arg8)
end
local lastOtherCast = {} -- [event] = castGUID of the last other-unit event handled
libpredict.sender:SetScript("OnEvent", function()
if event == "COMBAT_LOG_EVENT_UNFILTERED" then
if arg2 == "SPELL_HEAL" then OnHeal()
elseif arg2 == "SPELL_RESURRECT" then OnResurrect() end
return
end
-- UNIT_SPELLCAST_SENT: arg1=unit, arg2=target token, arg3=castGUID, arg4=spellID
-- every other UNIT_SPELLCAST_*: arg1=unit, arg2=castGUID, arg3=spellID
local unit = arg1
if unit == "player" then
if event == "UNIT_SPELLCAST_SENT" then
sentTargets[arg3] = arg2 ~= "" and UnitName(arg2) or nil
elseif event == "UNIT_SPELLCAST_START" then
OnOwnCastStart(arg3, arg2)
elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
OnOwnCastSucceeded(arg3, arg2)
elseif event == "UNIT_SPELLCAST_FAILED" or event == "UNIT_SPELLCAST_INTERRUPTED" then
sentTargets[arg2] = nil
-- a refused cast (cooldown, range) fails under its own castGUID and
-- must not cancel the heal that is still casting
if arg2 == this.castGUID then onCastFailed() end
elseif event == "UNIT_SPELLCAST_DELAYED" then
-- delayTimeMs is the pushback so far; HealComm wants each step
local _, _, _, _, _, _, _, _, _, _, delayMs = C_Spell.CastingInfo()
if this.healing and delayMs and delayMs > this.delayMs then
local step = delayMs - this.delayMs
this.delayMs = delayMs
libpredict:HealDelay(player, step)
this:SendHealCommMsg("Healdelay/" .. step .. "/")
end
end
return
end
if UnitIsUnit(unit, "player") or lastOtherCast[event] == arg2 then return end
lastOtherCast[event] = arg2
if event == "UNIT_SPELLCAST_START" then
OnOtherCastStart(unit, arg3)
elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
OnOtherCastSucceeded(unit)
elseif event == "UNIT_SPELLCAST_INTERRUPTED" then
OnOtherCastInterrupted(unit)
end
end)
function libpredict:GetHotDuration(unit, spell)
if unit == UNKNOWNOBJECT or unit == UKNOWNBEING then return end
-- Live aura timer first (C_UnitAuras via libdebuff)
if pfUI.api.libdebuff then
local guid = UnitGUID(unit)
if guid then
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
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: the HealComm/cast-fed table, for HoTs C_UnitAuras has no timer for
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")
if pfUI.api.libdebuff then
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[PRIMARY]|r libdebuff integration: ACTIVE")
DEFAULT_CHAT_FRAME:AddMessage(" Using C_UnitAuras timers")
else
DEFAULT_CHAT_FRAME:AddMessage("|cffff9900[PRIMARY]|r libdebuff integration: NOT AVAILABLE")
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