diff --git a/libs/libcast.lua b/libs/libcast.lua new file mode 100644 index 0000000..d909f2a --- /dev/null +++ b/libs/libcast.lua @@ -0,0 +1,578 @@ +-- load pfUI environment +setfenv(1, pfUI:GetEnvironment()) + +--[[ libcast ]]-- +-- A pfUI library that detects and saves all ongoing castbars of players, NPCs and enemies. +-- The library also includes spells that usually don't have a castbar like Multi-Shot and Aimed Shot. +-- This is exclusivly used for vanilla in order to provide pfGetChannelInfo and pfGetCastInfo functions. +-- +-- External functions: +-- pfGetChannelInfo(unit) +-- Returns information on the spell currently cast by the specified unit. +-- Returns nil if no spell is being cast. +-- +-- cast[String] - The name of the spell, or nil if no spell is being cast. +-- nameSubtext[String] - (DUMMY) The string describing the rank of the spell, e.g. "Rank 1". +-- text[String] - The name to be displayed. +-- texture[String] - The texture path associated with the spell. +-- startTime[Number] - Specifies when casting has begun, in milliseconds. +-- endTime[Number] - Specifies when casting will end, in milliseconds. +-- isTradeSkill[Boolean] - (DUMMY) Specifies if the cast is a tradeskill +-- +-- pfGetCastInfo(unit) +-- Returns information on the spell currently channeled by the specified unit. +-- Returns nil if no spell is being channeled. +-- +-- cast[String] - The name of the spell, or nil if no spell is being cast. +-- nameSubtext[String] - (DUMMY) The string describing the rank of the spell, e.g. "Rank 1". +-- text[String] - The name to be displayed. +-- texture[String] - The texture path associated with the spell. +-- startTime[Number] - Specifies when casting has begun, in milliseconds. +-- endTime[Number] - Specifies when casting will end, in milliseconds. +-- isTradeSkill[Boolean] - (DUMMY) Specifies if the cast is a tradeskill +-- +-- Internal functions: +-- libcast:AddAction(mob, spell, channel) +-- Adds a spell to the database by using pfUI's spell database +-- to obtain durations and icons +-- +-- libcast:RemoveAction(mob, spell) +-- Removes the castbar of a given mob, if `spell` is an interrupt. +-- spell can be set to "INTERRUPT" to force remove an action. +-- + +-- return instantly if we're not on a vanilla client +if pfUI.client > 11200 then return end + +-- return instantly when another libcast is already active +if pfUI.api.libcast then return end + +local lastcasttex, lastrank, _ +local scanner = libtipscan:GetScanner("libcast") + +local libcast = CreateFrame("Frame", "pfEnemyCast") +local player = UnitName("player") + +pfGetChannelInfo = function(unit) + -- convert to name if unitstring was given + local unitName = pfValidUnits[unit] and UnitName(unit) or unit + + -- Get GUID if Nampower is available + local guid = nil + + -- Check if unit itself is a GUID (starts with "0x") + if type(unit) == "string" and string.sub(unit, 1, 2) == "0x" then + guid = unit -- unit IS the GUID + elseif pfValidUnits[unit] and UnitExists then + -- unit is a token like "target" - get GUID from it + local unitGuid = GetUnitGUID(unit) + guid = unitGuid + end + + -- For player: ALWAYS use libcast.db because it handles channel updates correctly + local isPlayer = unit == "player" or unitName == player + + if isPlayer then + local cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill + local db = libcast.db[player] + + if db and db.cast and db.start + db.casttime / 1000 > GetTime() then + if not db.channel then return end + cast = db.cast + nameSubtext = db.rank + text = "" + texture = db.icon + startTime = db.start * 1000 + endTime = startTime + db.casttime + isTradeSkill = nil + elseif db then + db.cast = nil + db.rank = nil + db.start = nil + db.casttime = nil + db.icon = nil + db.channel = nil + end + + return cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill + end + + -- For non-player units: use libdebuff GUID-based tracking or libcast.db + -- Try GUID-based lookup first (from libdebuff's SPELL_START tracking) + local db = nil + if guid and pfUI.libdebuff_casts and pfUI.libdebuff_casts[guid] then + -- Use libdebuff's cast tracking (from SPELL_START_OTHER events) + local castData = pfUI.libdebuff_casts[guid] + if castData.event == "START" and castData.endTime and castData.endTime > GetTime() then + -- Convert libdebuff format to libcast format + db = { + cast = castData.spellName, + rank = nil, + start = castData.startTime, + casttime = castData.duration * 1000, -- Convert back to ms + icon = castData.icon, + channel = nil -- TODO: libdebuff should distinguish channel vs cast + } + end + end + + -- Fallback to name-based lookup (CHAT_MSG castbars) + -- Skip when GUID is available to avoid same-name mob bleed + if not db and not guid and libcast.db[unitName] then + db = libcast.db[unitName] + end + + -- Fallback to libcast.db for non-player units + local cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill + + if db and db.cast and db.start + db.casttime / 1000 > GetTime() then + if not db.channel then return end + cast = db.cast + nameSubtext = db.rank + text = "" + texture = db.icon + startTime = db.start * 1000 + endTime = startTime + db.casttime + isTradeSkill = nil + elseif db then + db.cast = nil + db.rank = nil + db.start = nil + db.casttime = nil + db.icon = nil + db.channel = nil + end + + return cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill +end + +pfGetCastInfo = function(unit) + -- convert to name if unitstring was given + local unitName = pfValidUnits[unit] and UnitName(unit) or unit + + -- Get GUID if Nampower is available + local guid = nil + + -- Check if unit itself is a GUID (starts with "0x") + if type(unit) == "string" and string.sub(unit, 1, 2) == "0x" then + guid = unit -- unit IS the GUID + elseif pfValidUnits[unit] and UnitExists then + -- unit is a token like "target" - get GUID from it + local unitGuid = GetUnitGUID(unit) + guid = unitGuid + end + + -- For player: ALWAYS use libcast.db because it handles pushback correctly + local isPlayer = unit == "player" or unitName == player + + if isPlayer then + local cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill + local db = libcast.db[player] + + if db and db.cast and db.start + db.casttime / 1000 > GetTime() then + if db.channel then return end + cast = db.cast + nameSubtext = db.rank or "" + text = "" + texture = db.icon + startTime = db.start * 1000 + endTime = startTime + db.casttime + isTradeSkill = nil + elseif db then + db.cast = nil + db.rank = nil + db.start = nil + db.casttime = nil + db.icon = nil + db.channel = nil + end + + return cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill + end + + -- For non-player units: use libdebuff GUID-based tracking or libcast.db + -- Try GUID-based lookup first (from libdebuff's SPELL_START tracking) + local db = nil + if guid and pfUI.libdebuff_casts and pfUI.libdebuff_casts[guid] then + -- Use libdebuff's cast tracking (from SPELL_START_OTHER events) + local castData = pfUI.libdebuff_casts[guid] + if castData.event == "START" and castData.endTime and castData.endTime > GetTime() then + -- Convert libdebuff format to libcast format + db = { + cast = castData.spellName, + rank = nil, + start = castData.startTime, + casttime = castData.duration * 1000, -- Convert back to ms + icon = castData.icon, + channel = nil -- TODO: libdebuff should distinguish channel vs cast + } + end + end + + -- Fallback to name-based lookup (CHAT_MSG castbars) + -- Skip when GUID is available to avoid same-name mob bleed + if not db and not guid and libcast.db[unitName] then + db = libcast.db[unitName] + end + + -- Fallback to libcast.db for non-player units + local cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill + + if db and db.cast and db.start + db.casttime / 1000 > GetTime() then + if db.channel then return end + cast = db.cast + nameSubtext = db.rank or "" + text = "" + texture = db.icon + startTime = db.start * 1000 + endTime = startTime + db.casttime + isTradeSkill = nil + elseif db then + db.cast = nil + db.rank = nil + db.start = nil + db.casttime = nil + db.icon = nil + db.channel = nil + end + + return cast, nameSubtext, text, texture, startTime, endTime, isTradeSkill +end + +function libcast:AddAction(mob, spell, channel) + if not mob or not spell then return nil end + + if L["spells"][spell] ~= nil then + local casttime = L["spells"][spell].t + local icon = L["spells"][spell].icon and string.format("%s%s", "Interface\\Icons\\", L["spells"][spell].icon) or nil + + -- add cast action to the database + if not self.db[mob] then self.db[mob] = {} end + self.db[mob].cast = spell + self.db[mob].rank = nil + self.db[mob].start = GetTime() + self.db[mob].casttime = casttime + self.db[mob].icon = icon + self.db[mob].channel = channel + + return true + end + + return nil +end + +function libcast:RemoveAction(mob, spell) + if self.db[mob] and ( L["interrupts"][spell] ~= nil or spell == "INTERRUPT" ) then + + -- remove cast action to the database + self.db[mob].cast = nil + self.db[mob].rank = nil + self.db[mob].start = nil + self.db[mob].casttime = nil + self.db[mob].icon = nil + self.db[mob].channel = nil + end +end + +-- main data +libcast.db = { [player] = {} } + +-- environmental casts +libcast:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") +libcast:RegisterEvent("CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE") +libcast:RegisterEvent("CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF") +libcast:RegisterEvent("CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE") +libcast:RegisterEvent("CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF") +libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_BUFFS") +libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS") +libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE") +libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE") +libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE") +libcast:RegisterEvent("CHAT_MSG_SPELL_PARTY_DAMAGE") +libcast:RegisterEvent("CHAT_MSG_SPELL_PARTY_BUFF") +libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE") +libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS") +libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE") +libcast:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS") +libcast:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE") +libcast:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF") + +-- player spells +libcast:RegisterEvent("SPELLCAST_START") +libcast:RegisterEvent("SPELLCAST_STOP") +libcast:RegisterEvent("SPELLCAST_FAILED") +libcast:RegisterEvent("SPELLCAST_INTERRUPTED") +libcast:RegisterEvent("SPELLCAST_DELAYED") +libcast:RegisterEvent("SPELLCAST_CHANNEL_START") +libcast:RegisterEvent("SPELLCAST_CHANNEL_STOP") +libcast:RegisterEvent("SPELLCAST_CHANNEL_UPDATE") + +local mob, spell, icon, _ +local lastSpellId = nil -- spellId cached from SPELL_START_SELF (Nampower) + +libcast:SetScript("OnEvent", function() + -- Fill database with player casts + if event == "SPELLCAST_START" then + -- Get icon from Nampower using spellId cached from SPELL_START_SELF + icon = nil + if lastSpellId and GetSpellRecField and GetSpellIconTexture then + local iconId = GetSpellRecField(lastSpellId, "spellIconID") + if iconId then + icon = GetSpellIconTexture(iconId) + if icon and not string.find(icon, "\\") then + icon = "Interface\\Icons\\" .. icon + end + end + end + -- fallback to L["spells"] / lastcasttex if Nampower didn't provide icon + if not icon then + icon = L["spells"][arg1] and L["spells"][arg1].icon and string.format("%s%s", "Interface\\Icons\\", L["spells"][arg1].icon) or lastcasttex + end + lastSpellId = nil + + this.db[player].cast = arg1 + this.db[player].rank = lastrank + this.db[player].start = GetTime() + this.db[player].casttime = arg2 + this.db[player].icon = icon + this.db[player].channel = nil + + if not L["spells"][arg1] or not L["spells"][arg1].icon or not L["spells"][arg1].t then + L["spells"][arg1] = L["spells"][arg1] or { } + L["spells"][arg1].icon = L["spells"][arg1].icon or icon + L["spells"][arg1].t = L["spells"][arg1].t or arg2 + end + lastcasttex, lastrank = nil, nil + elseif event == "SPELLCAST_STOP" or event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then + lastSpellId = nil + if this.db[player] and not this.db[player].channel then + -- remove cast action to the database + this.db[player].cast = nil + this.db[player].rank = nil + this.db[player].rank = nil + this.db[player].start = nil + this.db[player].casttime = nil + this.db[player].icon = nil + this.db[player].channel = nil + else + lastcasttex, lastrank = nil, nil + end + elseif event == "SPELLCAST_DELAYED" then + if this.db[player].cast then + -- Pushback: increase casttime instead of shifting start + -- arg1 is the delay amount in milliseconds + this.db[player].casttime = this.db[player].casttime + arg1 + end + elseif event == "SPELLCAST_CHANNEL_START" then + -- add cast action to the database + this.db[player].cast = arg2 + this.db[player].rank = lastrank + this.db[player].start = GetTime() + this.db[player].casttime = arg1 + this.db[player].icon = L["spells"][arg2] and L["spells"][arg2].icon and string.format("%s%s", "Interface\\Icons\\", L["spells"][arg2].icon) or lastcasttex + this.db[player].channel = true + + lastcasttex, lastrank = nil, nil + elseif event == "SPELLCAST_CHANNEL_STOP" then + if this.db[player] and this.db[player].channel then + -- remove cast action to the database + this.db[player].cast = nil + this.db[player].rank = nil + this.db[player].start = nil + this.db[player].casttime = nil + this.db[player].icon = nil + this.db[player].channel = nil + end + elseif event == "SPELLCAST_CHANNEL_UPDATE" then + if this.db[player].cast then + this.db[player].start = -this.db[player].casttime/1000 + GetTime() + arg1/1000 + end + -- Fill database with environmental casts + elseif arg1 then + -- (.+) begins to cast (.+). + mob, spell = cmatch(arg1, SPELLCASTOTHERSTART) + if libcast:AddAction(mob, spell) then return end + + -- (.+) begins to perform (.+). + mob, spell = cmatch(arg1, SPELLPERFORMOTHERSTART) + if libcast:AddAction(mob, spell) then return end + + -- (.+) gains (.+). + mob, spell = cmatch(arg1, AURAADDEDOTHERHELPFUL) + if libcast:RemoveAction(mob, spell) then return end + + -- (.+) is afflicted by (.+). + mob, spell = cmatch(arg1, AURAADDEDOTHERHARMFUL) + if libcast:RemoveAction(mob, spell) then return end + + -- Your (.+) hits (.+) for (%d+). + spell, mob = cmatch(arg1, SPELLLOGSELFOTHER) + if libcast:RemoveAction(mob, spell) then return end + + -- Your (.+) crits (.+) for (%d+). + spell, mob = cmatch(arg1, SPELLLOGCRITSELFOTHER) + if libcast:RemoveAction(mob, spell) then return end + + -- (.+)'s (.+) %a hits (.+) for (%d+). + _, spell, mob = cmatch(arg1, SPELLLOGOTHEROTHER) + if libcast:RemoveAction(mob, spell) then return end + + -- (.+)'s (.+) %a crits (.+) for (%d+). + _, spell, mob = cmatch(arg1, SPELLLOGCRITOTHEROTHER) + if libcast:RemoveAction(mob, spell) then return end + + -- You interrupt (.+)'s (.+). + mob, _ = cmatch(arg1, SPELLINTERRUPTSELFOTHER) + if libcast:RemoveAction(mob, "INTERRUPT") then return end + + -- (.+) interrupts (.+)'s (.+). + _, mob, _ = cmatch(arg1, SPELLINTERRUPTOTHEROTHER) + if libcast:RemoveAction(mob, "INTERRUPT") then return end + end +end) + +--[[ Custom Casts + Enable Castbars for spells that don't have a castbar by default + (e.g Multi-Shot and Aimed Shot) +]]-- +local aimedshot = L["customcast"]["AIMEDSHOT"] +local multishot = L["customcast"]["MULTISHOT"] + +libcast.customcast = {} +libcast.customcast[strlower(aimedshot)] = function(begin, duration) + if begin then + local duration = duration or 3000 + + for i=1,32 do + if UnitBuff("player", i) == "Interface\\Icons\\Racial_Troll_Berserk" then + local berserk = 0.3 + if((UnitHealth("player")/UnitHealthMax("player")) >= 0.40) then + berserk = (1.30 - (UnitHealth("player") / UnitHealthMax("player"))) / 3 + end + duration = duration / (1 + berserk) + elseif UnitBuff("player", i) == "Interface\\Icons\\Ability_Hunter_RunningShot" then + duration = duration / 1.4 + elseif UnitBuff("player", i) == "Interface\\Icons\\Ability_Warrior_InnerRage" then + duration = duration / 1.3 + elseif UnitBuff("player", i) == "Interface\\Icons\\Inv_Trinket_Naxxramas04" then + duration = duration / 1.2 + end + end + + local _,_, lag = GetNetStats() + local start = GetTime() + lag/1000 + + -- add cast action to the database + libcast.db[player].cast = aimedshot + libcast.db[player].rank = lastrank + libcast.db[player].start = start + libcast.db[player].casttime = duration + libcast.db[player].icon = "Interface\\Icons\\Inv_spear_07" + libcast.db[player].channel = nil + else + -- remove cast action to the database + libcast.db[player].cast = nil + libcast.db[player].rank = nil + libcast.db[player].start = nil + libcast.db[player].casttime = nil + libcast.db[player].icon = nil + libcast.db[player].channel = nil + end +end + +libcast.customcast[strlower(multishot)] = function(begin, duration) + if begin then + local duration = duration or 500 + + for i=1,32 do + if UnitBuff("player", i) == "Interface\\Icons\\Racial_Troll_Berserk" then + local berserk = 0.3 + if((UnitHealth("player")/UnitHealthMax("player")) >= 0.40) then + berserk = (1.30 - (UnitHealth("player") / UnitHealthMax("player"))) / 3 + end + duration = duration / (1 + berserk) + elseif UnitBuff("player", i) == "Interface\\Icons\\Ability_Hunter_RunningShot" then + duration = duration / 1.4 + elseif UnitBuff("player", i) == "Interface\\Icons\\Ability_Warrior_InnerRage" then + duration = duration / 1.3 + elseif UnitBuff("player", i) == "Interface\\Icons\\Inv_Trinket_Naxxramas04" then + duration = duration / 1.2 + end + end + + local _,_, lag = GetNetStats() + local start = GetTime() + lag/1000 + + -- add cast action to the database + libcast.db[player].cast = multishot + libcast.db[player].rank = lastrank + libcast.db[player].start = start + libcast.db[player].casttime = duration + libcast.db[player].icon = "Interface\\Icons\\Ability_upgrademoonglaive" + libcast.db[player].channel = nil + else + -- remove cast action to the database + libcast.db[player].cast = nil + libcast.db[player].rank = nil + libcast.db[player].start = nil + libcast.db[player].casttime = nil + libcast.db[player].icon = nil + libcast.db[player].channel = nil + end +end + +local function CastCustom(id, bookType, rawSpellName, rank, texture, castingTime) + if not id or not rawSpellName then return end -- ignore if the spell is not found + if not castingTime or castingTime == 0 then + -- instant-cast: clear lastcasttex so next cast doesn't inherit this icon + lastcasttex, lastrank = nil, nil + return + end + + lastrank = rank + lastcasttex = texture + + local func = libcast.customcast[strlower(rawSpellName)] + if not func then return end + + if GetSpellCooldown(id, bookType) == 0 or pfGetCastInfo(player) then return end -- detect casting + + func(true) +end + +hooksecurefunc("UseContainerItem", function(id, index) + lastcasttex = GetContainerItemInfo(id, index) +end) + +hooksecurefunc("CastSpell", function(id, bookType) + local cachedRawSpellName, cachedRank, cachedTexture, cachedCastingTime, _, _, cachedSpellId, cachedBookType = libspell.GetSpellInfo(id, bookType) + + CastCustom(cachedSpellId, cachedBookType, cachedRawSpellName, cachedRank, cachedTexture, cachedCastingTime) +end) + +hooksecurefunc("CastSpellByName", function(spellCasted, target) + local cachedRawSpellName, cachedRank, cachedTexture, cachedCastingTime, _, _, cachedSpellId, cachedBookType = libspell.GetSpellInfo(spellCasted) + + CastCustom(cachedSpellId, cachedBookType, cachedRawSpellName, cachedRank, cachedTexture, cachedCastingTime) +end) + +hooksecurefunc("UseAction", function(slot, target, button) + if GetActionText(slot) or not IsCurrentAction(slot) then return end + + scanner:SetAction(slot) + local rawSpellName, rank = scanner:Line(1) + if not rawSpellName then return end -- ignore if the spell is not found + + local cachedRawSpellName, cachedRank, cachedTexture, cachedCastingTime, _, _, cachedSpellId, cachedBookType = libspell.GetSpellInfo(rawSpellName .. (rank and ("(" .. rank .. ")") or "")) + + CastCustom(cachedSpellId, cachedBookType, cachedRawSpellName, cachedRank, cachedTexture, cachedCastingTime) +end) + +-- Cache spellId from SPELL_START_SELF so SPELLCAST_START can use it for icon lookup +pfUI.libdebuff_spell_start_self_hooks = pfUI.libdebuff_spell_start_self_hooks or {} +pfUI.libdebuff_spell_start_self_hooks["libcast_icon"] = function(spellId) + lastSpellId = spellId +end + +-- add libcast to pfUI API +pfUI.api.libcast = libcast \ No newline at end of file