ad50ebe636
A TurtleWoW 1.12.1 addon that shows the healing returned by the Vampirism item stat. The game emits no event for that healing, so the addon computes it from the player's own outgoing damage using a formula measured in-game, and shows it live on a movable bar with a per-ability breakdown, an overheal split, automatic source detection from equipped gear, and optional scrolling combat text.
250 lines
13 KiB
Lua
250 lines
13 KiB
Lua
-- Vampify -- what happens TO the player: incoming damage and logged healing.
|
|
--
|
|
-- Separate from capture/damage.lua on purpose. That file listens to the *_SELF events, which mean
|
|
-- "the player is the CASTER" -- confirmed in-game, not assumed. Incoming
|
|
-- damage is therefore not in them at all; it needs the *_OTHER family, filtered on our own GUID.
|
|
--
|
|
-- THE COST. The _OTHER events fire for every foreign unit in range, which is why v0.1.0 avoided
|
|
-- them. Three things keep that in hand: they are registered only while something actually wants
|
|
-- them (see WANTED, below), a group/raid switches the WATCH reason off (the balance is worthless
|
|
-- there anyway -- but see WANTED for why that no longer means the events themselves stop), and the
|
|
-- GUID comparison is the first statement in the handler, before any decoding, allocating nothing.
|
|
--
|
|
-- HEALING IS TAKEN FROM THE BY_* EVENTS ONLY. nampower fires both perspectives for the same heal
|
|
-- (BY_* for the caster, ON_* for the target); registering both double-counts, measured elsewhere at
|
|
-- +16 % against ground truth. Registering only BY_SELF and BY_OTHER and filtering on the target
|
|
-- GUID sees every heal on us exactly once, and needs no deduplication.
|
|
--
|
|
-- SELF VS. EXTERNAL HEALING (follow-up change request, 2026-08-22). Both BY_SELF and BY_OTHER
|
|
-- were being summed into ONE "logged" total, which blurred exactly the distinction the per-hit
|
|
-- export needs: SPELL_HEAL_BY_SELF fires when the PLAYER is the caster (a self-cast heal, since
|
|
-- I.fromHeal's own filter on arg1==playerGuid then requires the player to ALSO be the target --
|
|
-- healing we cast on a party member is caster==player, target!=player, and is filtered out, same
|
|
-- as always); SPELL_HEAL_BY_OTHER fires when ANY OTHER unit is the caster, filtered the same way
|
|
-- on arg1==playerGuid. So yes -- Vampify DOES see foreign healing arriving on the player, via
|
|
-- SPELL_HEAL_BY_OTHER (nampower), and it was always being counted; it just was not being told
|
|
-- apart from a self-heal. That is exactly the "foreign healing that contaminates a group window"
|
|
-- this whole change is about, and it is now kept in its own running total (loggedExternal / the
|
|
-- EXTHEAL export line) instead of being folded into loggedSelf. No new event registration was
|
|
-- needed -- the two events were already both registered; only their dispatch was unified.
|
|
--
|
|
-- WANTED, NOT JUST ON/OFF (follow-up change request, 2026-08-22). There are now two
|
|
-- INDEPENDENT reasons this module's events might need to be registered: /vf watch (wants a
|
|
-- trustworthy HP-reconciliation BALANCE, which foreign healing/group play genuinely invalidates --
|
|
-- see core/watch.lua) and the per-hit debug export (core/perhit.lua; wants raw INC/SELFHEAL/
|
|
-- EXTHEAL lines regardless of group status, because the raw numbers are exactly what makes foreign
|
|
-- healing MEASURABLE instead of invisible). watchWanted and perhitWanted are tracked separately;
|
|
-- the underlying registration is active whenever EITHER is true (see WANTED below). A group join
|
|
-- clears ONLY watchWanted (I.isWatchOn() flips off, and core/commands.lua's addWindow call for the
|
|
-- watch balance is skipped) -- it does NOT touch perhitWanted, so the raw capture keeps running for
|
|
-- perhit even in a group. I.isOn() answers "is the capture running at all" (either reason);
|
|
-- I.isWatchOn() answers "is watch's OWN feature active" (core/commands.lua uses isOn() to gate the
|
|
-- window-tracking machinery itself, and isWatchOn() to gate feeding the watch balance).
|
|
|
|
VampifyIncoming = {}
|
|
local I = VampifyIncoming
|
|
|
|
local function num(v) return tonumber(v) or 0 end
|
|
|
|
-- The spell event carries its mitigation as a comma-joined STRING ("0,0,0"), not as numbers. The
|
|
-- all-zero case is the overwhelming majority and is compared as a whole, so the parse -- and its
|
|
-- allocations -- only happen on hits that actually mitigated something.
|
|
function I.mitigationOf(s)
|
|
if type(s) ~= "string" or s == "0,0,0" or s == "" then return 0 end
|
|
local total, from = 0, 1
|
|
while true do
|
|
local p = string.find(s, ",", from, true)
|
|
if not p then
|
|
total = total + num(string.sub(s, from))
|
|
break
|
|
end
|
|
total = total + num(string.sub(s, from, p - 1))
|
|
from = p + 1
|
|
end
|
|
return total
|
|
end
|
|
|
|
-- Returns the damage that actually left the health bar, or nil when the event was not about us.
|
|
--
|
|
-- WHETHER `amount` IS ALREADY NET IS UNMEASURED for the incoming direction. Subtracting mitigation
|
|
-- assumes it is gross. That assumption is not load-bearing: windows that took no damage at all form
|
|
-- a control branch in VampifyWatch, and if the two branches disagree, the reading is wrong rather
|
|
-- than the healing (see W.crossCheck). This is the one place where a wrong guess is caught rather
|
|
-- than believed.
|
|
function I.fromSpell(playerGuid, a1, a2, a3, a4, a5)
|
|
if not playerGuid or a1 ~= playerGuid then return nil end
|
|
local net = num(a4) - I.mitigationOf(a5)
|
|
if net < 0 then return 0 end
|
|
return net
|
|
end
|
|
|
|
-- AUTO_ATTACK_OTHER: 1 attacker 2 target 3 total 4 hitInfo 5 victimState 6 subDamageCount
|
|
-- 7 blocked 8 absorbed 9 resisted -- scalars here, unlike the spell event.
|
|
function I.fromAuto(playerGuid, a1, a2, a3, a4, a5, a6, a7, a8, a9)
|
|
if not playerGuid or a2 ~= playerGuid then return nil end
|
|
local net = num(a3) - num(a7) - num(a8) - num(a9)
|
|
if net < 0 then return 0 end
|
|
return net
|
|
end
|
|
|
|
-- SPELL_HEAL_BY_*: 1 target 2 caster 3 spellId 4 amount 5 critical 6 periodic. Which of self/
|
|
-- external this is comes from WHICH EVENT fired (BY_SELF vs BY_OTHER), not from arg2 -- see the
|
|
-- header comment. This function only answers "did it land on us", identically for both.
|
|
function I.fromHeal(playerGuid, a1, a2, a3, a4)
|
|
if not playerGuid or a1 ~= playerGuid then return nil end
|
|
return num(a4)
|
|
end
|
|
|
|
-- ---- running window totals ---------------------------------------------------------------------
|
|
|
|
local incoming, loggedSelf, loggedExternal = 0, 0, 0
|
|
|
|
function I.reset() incoming, loggedSelf, loggedExternal = 0, 0, 0 end
|
|
|
|
-- Reads the window's totals and starts the next one in the same breath. Three figures, no table:
|
|
-- this is called once per own hit, and an allocation there would be an allocation per hit.
|
|
function I.take()
|
|
local a, b, c = incoming, loggedSelf, loggedExternal
|
|
incoming, loggedSelf, loggedExternal = 0, 0, 0
|
|
return a, b, c
|
|
end
|
|
|
|
function I.addIncoming(n) incoming = incoming + n end
|
|
function I.addSelfHeal(n) loggedSelf = loggedSelf + n end
|
|
function I.addExternalHeal(n) loggedExternal = loggedExternal + n end
|
|
|
|
-- ---- WoW wiring --------------------------------------------------------------------------------
|
|
|
|
local playerGuid = nil
|
|
|
|
-- The two independent "wants" (see the header comment) and the merged registration state they
|
|
-- resolve to. `on` mirrors the OLD single boolean's meaning exactly (is the capture running at
|
|
-- all), so I.isOn() keeps its old contract unchanged.
|
|
local watchWanted, perhitWanted, on = false, false, false
|
|
|
|
-- SuperWoW returns the GUID as the second value of UnitExists. Wrapped: without SuperWoW there is
|
|
-- no GUID, and then this whole subsystem has nothing to filter on and stays off.
|
|
local function readGuid()
|
|
local g
|
|
pcall(function() local _, gg = UnitExists("player"); g = gg end)
|
|
return g
|
|
end
|
|
|
|
function I.playerGuid() return playerGuid end
|
|
function I.isOn() return on end
|
|
-- Watch's OWN feature state -- true only while the player asked for /vf watch on AND it is
|
|
-- actually running. False while only perhit wants the capture, even though I.isOn() is true then.
|
|
function I.isWatchOn() return watchWanted and on end
|
|
|
|
-- True while the player is in any group. The WATCH balance cannot survive foreign healing --
|
|
-- measured at +29..49 % phantom healing -- so for watch this is not a preference but a validity
|
|
-- condition. It says nothing about perhit, which wants the raw numbers (including foreign healing)
|
|
-- unconditionally -- see the header comment.
|
|
function I.inGroup()
|
|
local r = GetNumRaidMembers and GetNumRaidMembers() or 0
|
|
local p = GetNumPartyMembers and GetNumPartyMembers() or 0
|
|
return (r > 0) or (p > 0)
|
|
end
|
|
|
|
local frame = nil
|
|
|
|
local EVENTS = {
|
|
"SPELL_DAMAGE_EVENT_OTHER",
|
|
"AUTO_ATTACK_OTHER",
|
|
"SPELL_HEAL_BY_SELF",
|
|
"SPELL_HEAL_BY_OTHER",
|
|
}
|
|
|
|
-- Registers/unregisters the underlying events to match (watchWanted OR perhitWanted). Returns
|
|
-- whether the capture ended up running -- false only when activation was needed but no GUID could
|
|
-- be obtained (no SuperWoW). Idempotent: a call that changes nothing is a no-op, so re-asserting a
|
|
-- want that is already satisfied does not re-read the GUID or touch the frame.
|
|
local function applyWanted()
|
|
if not frame then return on end
|
|
local want = (watchWanted or perhitWanted) and true or false
|
|
if want == on then return on end
|
|
if want then
|
|
playerGuid = readGuid()
|
|
if not playerGuid then return on end -- nothing to filter on; stay off rather than
|
|
-- process every foreign unit for nothing
|
|
for i = 1, table.getn(EVENTS) do frame:RegisterEvent(EVENTS[i]) end
|
|
I.reset()
|
|
on = true
|
|
else
|
|
for i = 1, table.getn(EVENTS) do frame:UnregisterEvent(EVENTS[i]) end
|
|
on = false
|
|
end
|
|
return on
|
|
end
|
|
|
|
-- Sets watch's own want and applies it. Return contract matches the old I.enable(want): turning
|
|
-- off always reports false (from watch's own perspective it succeeded -- the underlying wire may
|
|
-- stay hot for perhit, which is the whole point of this change); turning on reports whether
|
|
-- activation actually succeeded (false only on a missing GUID).
|
|
function I.setWatchWanted(want)
|
|
want = want and true or false
|
|
watchWanted = want
|
|
applyWanted()
|
|
if want then return on end
|
|
return false
|
|
end
|
|
|
|
-- Sets perhit's want and applies it. Same activation semantics as setWatchWanted, just without a
|
|
-- group guard of its own -- perhit's caller (core/commands.lua) never refuses on group status.
|
|
function I.setPerHitWanted(want)
|
|
want = want and true or false
|
|
perhitWanted = want
|
|
applyWanted()
|
|
if want then return on end
|
|
return false
|
|
end
|
|
|
|
if CreateFrame then
|
|
frame = CreateFrame("Frame", "VampifyIncomingFrame")
|
|
-- Registered even while the subsystem is off: the GUID has to be refreshed after every world
|
|
-- load, and a group change invalidates watch's own mode at any time. Three events, not per-hit
|
|
-- cost.
|
|
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
|
|
frame:RegisterEvent("PARTY_MEMBERS_CHANGED")
|
|
frame:RegisterEvent("RAID_ROSTER_UPDATE")
|
|
frame:SetScript("OnEvent", function()
|
|
if event == "PLAYER_ENTERING_WORLD" then
|
|
playerGuid = readGuid()
|
|
return
|
|
end
|
|
-- This is the switch-off the header promises, but ONLY for watch's own want -- it must not
|
|
-- touch perhitWanted, or a raid night would silently blind the per-hit export too (the
|
|
-- follow-up change request this whole split exists for). Checking inGroup() inside the per-hit path
|
|
-- would call GetNumRaidMembers/GetNumPartyMembers for every foreign unit in range, which
|
|
-- costs more than the GUID compare it would precede -- so this stays event-driven.
|
|
-- Announced rather than silent -- watch reports its own state via /vf watch, and a reader
|
|
-- who never saw it change would read that as a bug. Not re-enabled automatically when the
|
|
-- group breaks up: /vf watch on is one keystroke and stays the player's decision.
|
|
if event == "PARTY_MEMBERS_CHANGED" or event == "RAID_ROSTER_UPDATE" then
|
|
if watchWanted and I.inGroup() then
|
|
watchWanted = false
|
|
applyWanted()
|
|
if DEFAULT_CHAT_FRAME then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff8080ffVampify|r watch: off -- you joined a"
|
|
.." group, and foreign healing makes the balance meaningless.")
|
|
end
|
|
end
|
|
return
|
|
end
|
|
if not on then return end
|
|
if event == "SPELL_DAMAGE_EVENT_OTHER" then
|
|
local d = I.fromSpell(playerGuid, arg1, arg2, arg3, arg4, arg5)
|
|
if d then incoming = incoming + d end
|
|
elseif event == "AUTO_ATTACK_OTHER" then
|
|
local d = I.fromAuto(playerGuid, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
|
|
if d then incoming = incoming + d end
|
|
elseif event == "SPELL_HEAL_BY_SELF" then
|
|
local h = I.fromHeal(playerGuid, arg1, arg2, arg3, arg4)
|
|
if h then loggedSelf = loggedSelf + h end
|
|
else -- SPELL_HEAL_BY_OTHER
|
|
local h = I.fromHeal(playerGuid, arg1, arg2, arg3, arg4)
|
|
if h then loggedExternal = loggedExternal + h end
|
|
end
|
|
end)
|
|
end
|