Vampify 0.3.0

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.
This commit is contained in:
2026-08-25 18:14:18 +02:00
commit ad50ebe636
33 changed files with 10941 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
-- Vampify -- the player's own outgoing damage, via nampower's packet-derived events.
--
-- Argument layouts are for nampower v4.6.2 (the KB documents v2.38, two majors back -- do not
-- take layouts from there). They are confirmed against RECORDED rows, not just documented:
--
-- ev=SPELL_DAMAGE_EVENT_SELF;args=0xF130001B89000CFF~0x0000000000000663~45500~70~0,0,0~0~0~2,64,0,0
-- target caster(player) spell amt
-- ev=AUTO_ATTACK_SELF;args=0x0000000000000663~0xF130001B8200154B~323~2~1~1~0~0~0
-- attacker(player) target amt blk/abs/res
--
-- Note args 5 and 8 of the spell event are COMMA-JOINED STRINGS ("0,0,0"), not numbers -- never
-- assume a scalar. Arg5 (mitigation) IS parsed now, by G.mitigationOf/G.netSpell below (summed
-- blind, field order unverified). Arg8 (effectAura) is still not parsed by anything here.
--
-- The two damage events differ in shape AND in their hitInfo enum:
--
-- SPELL_DAMAGE_EVENT_SELF: 1 targetGuid 2 casterGuid 3 spellId 4 amount 5 mitigation
-- 6 hitInfo (crit 0x02) 7 school 8 effectAura
-- AUTO_ATTACK_SELF: 1 attackerGuid 2 targetGuid 3 totalDamage 4 hitInfo (crit 0x80)
-- 5 victimState 6 subDamageCount 7 blocked 8 absorbed 9 resisted
-- SPELL_GO_SELF: 1 itemId 2 spellId 3 casterGuid 4 targetGuid 5 castFlags
-- 6 numTargetsHit 7 numTargetsMissed 8 corpseOwnerGuid
--
-- DAMAGE_SHIELD_SELF is deliberately NOT registered: damage shields were measured NOT to trigger
-- Vampirism (Thorns 236 triggers, Thorium Shield Spike 27 -- zero healing from either). The
-- *_OTHER family is not registered either: totem and pet damage is believed not to trigger, and
-- those events fire for every foreign unit, which is a raid-load problem for no benefit.
VampifyDamage = {}
local G = VampifyDamage
local function num(v) return tonumber(v) or 0 end
function G.decodeSpell(a1, a2, a3, a4, a5, a6, a7, a8)
return num(a4), a1, tonumber(a3), a2
end
function G.decodeAuto(a1, a2, a3, a4, a5, a6, a7, a8, a9)
return num(a3), a2, num(a7), num(a8), num(a9), a1
end
-- Crit extraction. The two events use DIFFERENT bits -- 0x02 on the spell event, 0x80 on the auto
-- attack -- and Lua 5.0 has no bitwise operators, so the bit is pulled out arithmetically.
--
-- Vampirism itself cannot crit. This is carried only so a return CAUSED by a crit can be shown as
-- one, the way Blizzard's combat text distinguishes them.
local function bitSet(v, bitValue)
local n = tonumber(v)
if not n then return false end
return math.mod(math.floor(n / bitValue), 2) == 1
end
function G.isCritSpell(hitInfo) return bitSet(hitInfo, 2) end
function G.isCritMelee(hitInfo) return bitSet(hitInfo, 128) end
function G.netOf(amount, blocked, absorbed, resisted)
local n = num(amount) - num(blocked) - num(absorbed) - num(resisted)
if n < 0 then return 0 end
return n
end
-- The SPELL_DAMAGE_EVENT_SELF mitigation field (arg5) in one place: a comma-joined STRING
-- ("0,0,0"), never scalars -- same wire shape capture/incoming.lua's I.mitigationOf already
-- parses for the OTHER-direction twin of this exact event (SPELL_DAMAGE_EVENT_OTHER). Duplicated
-- here rather than called there: damage.lua loads BEFORE incoming.lua (Vampify.toc), and the two
-- files are deliberately independent (this file's own header -- _SELF vs _OTHER, different
-- concern). The FORMAT is not a second guess, only the parser is a second copy of it -- summed
-- blind, not attributed to blocked/absorbed/resisted, because the field ORDER within the string
-- is unverified (same caveat I.mitigationOf carries); only the total is needed here.
function G.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
-- Nets a SPELL_DAMAGE_EVENT_SELF amount the same way G.netOf nets an auto attack -- gated on the
-- SAME VampifyConst.FORMULA.damageBase setting (core/const.lua), which SETTLES the net-vs-gross
-- question (spec 4a, 2026-08-10: 7239 own hits during active Vampirism epochs, 9 decisive after
-- discarding full-health windows, 7 of 9 pointing at net) -- it is not a per-path guess, and this
-- function is what makes the SPELL path actually honor that settled answer instead of silently
-- assuming logged == net the way an earlier version of this file did (see the removed comment at
-- the SPELL_DAMAGE_EVENT_SELF branch below, and the report for why that assumption was wrong: it
-- predates spec 4a, which supersedes it for spells the same way it already governed auto attacks).
function G.netSpell(amount, mitigationStr)
if VampifyConst.FORMULA.damageBase ~= "net" then return num(amount) end
local n = num(amount) - G.mitigationOf(mitigationStr)
if n < 0 then return 0 end
return n
end
-- ---- WoW wiring ------------------------------------------------------------------------------
local listeners = {}
function G.onDamage(fn) table.insert(listeners, fn) end
-- SPELL_GO_SELF PRECEDES the damage events of a cast and carries arg6 (numTargetsHit) -- but that
-- field is NOT "how many distinct targets", it is a HIT COUNT. Measured in-game 2026-08-22: TWoW
-- Stormstrike (dual-wield, hits with both weapons) lands on exactly ONE target and still reports
-- numTargetsHit=2 for that single-target cast. An earlier version of this file trusted arg6>1
-- outright and classified Stormstrike as AoE, applying the 0.7 damping to a hit ground truth says
-- should get the full 1.0 (measured mitigation-adjusted factor: median 0.95-1.0 across n=4,
-- tightly clustered -- not the ~0.7 that classification would have produced). So arg6 is no longer
-- trusted to decide AoE by itself; SPELL_GO_SELF is now used ONLY for goSeen (below) and to note
-- multi-hit casts (see the OnEvent handler), and the actual AoE decision for every spell hit --
-- cast or not -- runs through G.deriveAoE, the distinct-target derivation described next. This
-- means a genuinely-AoE cast that happens to land on only one target this time is credited at 1.0
-- instead of a possible 0.7 -- see G.deriveAoE's own comment for that open question.
local goSeen = false
local damageSeen = false
-- ---- AoE derivation from distinct targets, cast or not (spec 4.2, extended 2026-08-22) --------
--
-- Originally written as the fallback for weapon/item procs (Force Reactive Disc's damage shield
-- return has no cast, so SPELL_GO_SELF never fires for it -- without this the factor silently
-- stayed 1.0 and the credited healing read about 43% high, 1 / 0.7). It is now the ONLY source of
-- an AoE classification, cast or not -- see the comment above SPELL_GO_SELF's handling for why
-- numTargetsHit stopped being trusted on its own.
--
-- Rule, per spec 4.1: no spell-id list. The SAME spell id landing on TWO OR MORE DISTINCT target
-- GUIDs inside a short window is area damage.
--
-- KNOWN COARSENESS, CONFIRMED (2026-08-24, Sigil of Ancient Accord investigation): a spell whose
-- OWN definition mixes an AoE effect (100 Arcane to target + all within 10y) with a bonus effect
-- aimed only at its primary target (+300 Arcane) is not distinguishable from a plain AoE hit at
-- this module's granularity. A dump of 8.4M real combat events shows the primary
-- target's hit already arrives as ONE combined ~400 amount (two clusters: ~100 splash-only,
-- ~400 primary-combined -- no separate ~300-only cluster exists), matching the server's own
-- per-target damage accumulation (Spell::DoAllEffectOnTarget, Spell.cpp:1253, sums every effect
-- that targets a unit before the single proc/log call at Spell.cpp:1518). So
-- when 2+ distinct targets are hit, THIS module correctly flags the primary's combined hit as AoE
-- too (same spellId, same window) -- there is no server-visible signal here that would let it
-- damp only the spell's 100-portion and not its 300-portion; the two never arrive as separate
-- numbers. This is a genuine data-granularity limit, not a classification bug: whatever the
-- server itself does internally with a spell like this, this module sees exactly what the server
-- sends, one number per target, same as the server's own proc/log call already collapsed it to.
--
-- OPEN QUESTION, DELIBERATELY UNANSWERED: a genuinely multi-target spell that this time only
-- connects with ONE target (miss/resist/out of range on the others, or simply a single-target
-- pull) is now credited at the full 1.0 factor, never 0.7 -- there is no way to distinguish "this
-- spell is AoE but only hit one target" from "this spell was never AoE" from a single hit alone,
-- and guessing wrong in either direction was exactly the Stormstrike bug. Whether the SERVER pays
-- Vampirism at 0.7 or 1.0 in that specific case is UNMEASURED; do not assume either answer without
-- a dedicated single-target-hit-of-a-known-AoE-spell measurement.
--
-- This is necessarily RETROACTIVE: the first hit is already recorded (its non-AoE heal already
-- added to the float accumulator) by the time the second target proves the whole burst was AoE.
-- This module cannot fix that itself -- the accumulator (VampifyState.acc, VampifyState.fHeal/
-- sHeal/spHeal) is owned by core/model.lua + core/commands.lua, both out of bounds for this
-- change. So the qualifying hit instead fires G.onAoECorrection with the FIRST hit's own
-- (amount, targetGuid, spellId, isCrit), and a listener living in commands.lua is the piece that
-- still needs writing: it must redo that hit's heal with isAoE=true (VampifyModel.heal is pure and
-- already exposed, no model.lua change needed) and add the DIFFERENCE to VampifyState.acc.total,
-- .fHeal, .sHeal and .spHeal[spellId] -- see the report for the exact shape.
--
-- Window: 1.0s. A proc's damage to several targets is dispatched from one server-side event and
-- its packets arrive within a fraction of a second of each other -- tighter than a full cast's
-- cast-to-impact time, not looser -- so this is a safe upper bound for either case, cast or proc.
--
-- State is keyed by spellId, one scalar record per id (last target GUID, its amount/crit, and an
-- expiry) -- NOT a per-GUID table, so the eviction-sweep concern for per-GUID/_seen tables does
-- not apply here: this table is bounded by the spellbook plus a handful of item procs, the same
-- bound that already lets spHeal/spDmg/spOver (aggregate.lua) and the coalescing buckets
-- (commands.lua) skip a sweep. No table is created per event -- only key assignment on tables that
-- already exist -- so the zero-allocation-per-event budget holds.
local DERIVE_WINDOW = 1.0
-- Exported because core/commands.lua's correction listener has to decide whether the hit being
-- corrected is one it credited, and "credited within this window" is the same span. Two independent
-- copies of the figure would drift the day one of them is tuned.
G.DERIVE_WINDOW = DERIVE_WINDOW
local deriveGuid, deriveAmount, deriveCrit, deriveExpires, deriveConfirmed = {}, {}, {}, {}, {}
local correctionListeners = {}
-- fn(firstHitAmount, firstHitTargetGuid, spellId, firstHitWasCrit) -- fired once per window, on
-- the hit that supplies the SECOND distinct target GUID and thereby proves the first hit was AoE.
function G.onAoECorrection(fn) table.insert(correctionListeners, fn) end
local function emitCorrection(amount, targetGuid, spellId, isCrit)
for i = 1, table.getn(correctionListeners) do
correctionListeners[i](amount, targetGuid, spellId, isCrit)
end
end
-- Pure derivation step for one spell-damage hit. Returns true if THIS hit should be treated as
-- AoE. Exposed as a standalone function (not buried in the OnEvent closure below) so it is
-- reachable from the offline Lua 5.0 test harness without a WoW API stub.
function G.deriveAoE(spellId, targetGuid, amount, isCrit, now)
if not spellId then return false end -- auto attacks carry no spell id, nothing to key on
local expires = deriveExpires[spellId]
if not expires or now > expires then
-- No window in flight (or it lapsed): this hit becomes the new, so-far-unconfirmed first
-- hit. Not AoE yet -- a single target proves nothing.
deriveGuid[spellId], deriveAmount[spellId], deriveCrit[spellId] = targetGuid, amount, isCrit
deriveExpires[spellId] = now + DERIVE_WINDOW
deriveConfirmed[spellId] = false
return false
end
if deriveConfirmed[spellId] then
-- Already proven AoE inside this window: every further hit of this spell id is AoE too,
-- and the correction for the original hit already fired -- do not repeat it.
deriveExpires[spellId] = now + DERIVE_WINDOW
return true
end
if targetGuid ~= deriveGuid[spellId] then
-- Second DISTINCT target: this proves both this hit and the remembered first hit were AoE.
deriveConfirmed[spellId] = true
deriveExpires[spellId] = now + DERIVE_WINDOW
emitCorrection(deriveAmount[spellId], deriveGuid[spellId], spellId, deriveCrit[spellId])
return true
end
-- Same target again inside the window: still no evidence of AoE (could just be two ticks on
-- one enemy), so only refresh the window and keep waiting.
deriveExpires[spellId] = now + DERIVE_WINDOW
return false
end
-- Running mitigation totals. Auto attacks report blocked/absorbed/resisted separately, so the
-- net-vs-gross question (spec 4.3.2) can be settled from a normal play session instead of a new
-- measurement campaign -- but only if the numbers are actually kept. They are cheap scalars, not
-- per-event tables, so this respects the zero-allocation budget.
local mitBlocked, mitAbsorbed, mitResisted, mitGross = 0, 0, 0, 0
-- spellId is passed through so consumers can group the hits of ONE cast (an AoE landing on five
-- targets is five events but one spell). nil for auto attacks, which have no cast to group by.
local function emit(amount, isAoE, targetGuid, spellId, isCrit)
for i = 1, table.getn(listeners) do
listeners[i](amount, isAoE, targetGuid, spellId, isCrit)
end
end
if CreateFrame then
local f = CreateFrame("Frame", "VampifyDamageFrame")
f:RegisterEvent("SPELL_DAMAGE_EVENT_SELF")
f:RegisterEvent("AUTO_ATTACK_SELF")
f:RegisterEvent("SPELL_GO_SELF")
f:SetScript("OnEvent", function()
if event == "SPELL_GO_SELF" then
-- goSeen is the only lasting effect of this event now -- see the comment above this
-- section for why numTargetsHit (arg6) is no longer used to classify AoE directly. The
-- actual decision for every hit of this cast still runs through G.deriveAoE below, the
-- exact same distinct-target proof a no-cast proc already has to provide.
goSeen = true
elseif event == "SPELL_DAMAGE_EVENT_SELF" then
local amount, tgt, spellId, caster = G.decodeSpell(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8)
-- Trust arg2 rather than the _SELF suffix, and never count a hit on ourselves.
if caster and tgt and caster ~= tgt and amount > 0 then
damageSeen = true
local now = GetTime()
local isCrit = G.isCritSpell(arg6)
-- Net via G.netSpell (above) -- honors VampifyConst.FORMULA.damageBase = "net"
-- (spec 4a) the same way the AUTO_ATTACK_SELF branch below already does. A hit
-- that partially resisted (Arcane school, e.g. Ancient Accord's proc, is a
-- measured case of this) must not credit its resisted portion as damage the
-- server paid Vampirism on.
local d = G.netSpell(amount, arg5)
-- Distinct-target proof only, cast or not -- see the section comment above. Fed
-- the NET amount: this is the figure the correction listener re-credits later,
-- and it must match what emit() below hands the model.
local isAoE = false
if spellId then
isAoE = G.deriveAoE(spellId, tgt, d, isCrit, now)
end
mitGross = mitGross + amount
emit(d, isAoE, tgt, spellId, isCrit)
end
elseif event == "AUTO_ATTACK_SELF" then
local amount, tgt, blocked, absorbed, resisted, attacker =
G.decodeAuto(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
if attacker and tgt and attacker ~= tgt and amount > 0 then
damageSeen = true
mitGross = mitGross + amount
mitBlocked = mitBlocked + blocked
mitAbsorbed = mitAbsorbed + absorbed
mitResisted = mitResisted + resisted
local d = amount
if VampifyConst.FORMULA.damageBase == "net" then
d = G.netOf(amount, blocked, absorbed, resisted)
end
emit(d, false, tgt, nil, G.isCritMelee(arg4))
end
end
end)
end
-- True once a SPELL_GO_SELF has ever been seen. Until then, AoE damping cannot be applied and
-- the total may read HIGH -- the one direction in which this addon can overstate the number.
function G.aoeAvailable() return goSeen end
-- True once ANY own-damage event has arrived. nampower is a client module, not an addon, so there
-- is nothing to query with IsAddOnLoaded -- its absence can only be observed as silence. Silence
-- while the player is visibly fighting means the addon is inert, and spec 7 requires that it say
-- so rather than display a confident zero.
function G.damageSeen() return damageSeen end
-- Mitigation totals for settling net-vs-gross (spec 4.3.2) offline.
function G.mitigation() return mitGross, mitBlocked, mitAbsorbed, mitResisted end
+183
View File
@@ -0,0 +1,183 @@
-- Vampify -- which Vampirism sources the player is wearing.
--
-- Vampirism is not a stat field but a passive item spell (45420-45424 = 1-5%), so it can only be
-- read from the RENDERED tooltip.
--
-- THE TRAP: item-borne and enchant-borne sources use DISJOINT wording --
-- item: "1% of damage dealt is returned as healing."
-- enchant: "Increase vampirism by 1%." (Enchant Bracer/Boots - Vampirism, 57146/57148)
-- Matching only the first phrase would silently miss every enchant. A single item can carry both,
-- so never stop at the first match in a slot.
VampifyDetect = {}
local D = VampifyDetect
-- Pure: accessors are injected, so this is WoW-free and offline-testable.
function D.scan(getLink, setTooltip, readLines)
local out = {}
for slot = 1, 19 do
local link = getLink(slot)
if link then
setTooltip(link)
local lines = readLines(slot) or {}
for i = 1, table.getn(lines) do
local text = lines[i]
if text then
local low = string.lower(text)
local _, _, pct = string.find(low, "(%d+)%%")
if pct then
if string.find(low, "returned as healing", 1, true) then
table.insert(out, { slot = slot, percent = tonumber(pct), kind = "item" })
elseif string.find(low, "vampirism", 1, true) then
table.insert(out, { slot = slot, percent = tonumber(pct), kind = "enchant" })
end
end
end
end
end
end
return out
end
-- SetHyperlink in 1.12 needs the BARE "item:id:enchant:suffix:unique" form. Handing it the full
-- link (with |cff..|Hitem:..|h[Name]|h|r) fails with "Unknown link type" -- confirmed in-game
-- 2026-08-10, where it failed on all 19 equipped slots at once and the detector therefore reported
-- "no sources" for gear that was plainly equipped. This is a known trap already documented by a
-- companion telemetry addon's instrumentation; this addon reproduced the bug by porting the pure
-- scan but rewriting the wiring. Pure, so the extraction itself is covered by tests.
function D.bareLink(link)
if type(link) ~= "string" then return nil end
local _, _, bare = string.find(link, "(item:%d+:%d+:%d+:%d+)")
if bare then return bare end
-- Some links carry fewer fields.
local _, _, loose = string.find(link, "(item:%d+[:%d]*)")
return loose or link
end
function D.summarise(sources)
local sum, n = 0, table.getn(sources)
for i = 1, n do sum = sum + (sources[i].percent or 0) end
return sum / 100, n
end
-- ---- WoW wiring ------------------------------------------------------------------------------
local sources, epoch, listeners = {}, 0, {}
local tip, pending
local function ensureTip()
if tip then return tip end
-- Created ONCE at module scope: frame handles are finite. Owned by WorldFrame rather than
-- UIParent, which can flash visible on /reload. Never the global GameTooltip -- that fires
-- every other addon's OnTooltipSetItem hooks 19 times per rescan.
tip = CreateFrame("GameTooltip", "VampifyScanTooltip", nil, "GameTooltipTemplate")
tip:SetOwner(WorldFrame, "ANCHOR_NONE")
return tip
end
local lineBuf = {}
local function readLines()
-- The tooltip's FontStrings are REUSED, so ClearLines() before each SetHyperlink is what keeps
-- the previous item's text out of this scan, and NumLines() bounds the read to real lines.
--
-- Deliberately NOT filtered on fs:IsVisible(): this tooltip is owned by WorldFrame with
-- ANCHOR_NONE and is never shown, so its FontStrings report invisible even when their text is
-- current. Filtering on visibility would silently return zero lines for every item -- the same
-- silent-zero failure mode as the bare-link bug above. A companion capture addon's proven
-- detector does not filter either.
-- Filled DENSELY with table.insert: GetText() returns nil for a blank line, and an indexed
-- assignment would leave a hole. table.getn on a table with holes is undefined in Lua 5.0, and
-- D.scan bounds its loop with exactly that -- so a hole could truncate the scan before the
-- Vampirism line and lose the source silently.
VampifyConst.resetList(lineBuf) -- see the comment there: nil-ing indices is not enough in 5.0
local n = tip:NumLines()
for i = 1, n do
local fs = getglobal("VampifyScanTooltipTextLeft"..i)
local t = fs and fs:GetText()
if t then table.insert(lineBuf, t) end
end
return lineBuf
end
local emptyScan
local retries = 0
local function doScan()
ensureTip()
emptyScan = false
local found = D.scan(
function(slot) return GetInventoryItemLink("player", slot) end,
function(link)
tip:ClearLines() -- mandatory, see readLines()
tip:SetOwner(WorldFrame, "ANCHOR_NONE") -- re-assert in case another addon stole it
local bare = D.bareLink(link)
if not bare then emptyScan = true return end
-- pcall: one unparseable link must not abort the whole 19-slot scan. Without this the
-- error propagates out of OnUpdate, doScan never reaches its epoch bump, and the addon
-- sits at zero sources forever while throwing an error every time gear changes.
if not pcall(function() tip:SetHyperlink(bare) end) then emptyScan = true return end
-- An uncached item renders nothing; SetHyperlink itself asks the server for it.
if tip:NumLines() == 0 then emptyScan = true end
end,
readLines)
if emptyScan then
-- A slot rendered nothing, so this scan cannot distinguish "no Vampirism" from "item not
-- cached yet" -- waits of ~25 s have been observed. Retry rather than commit a zero.
--
-- The condition is emptyScan ALONE, deliberately: what a still-cold slot might be carrying
-- has nothing to do with what the other slots already yielded. An earlier version also
-- required `table.getn(found) == 0`, which disarmed the retry the moment ONE source warmed
-- up -- so a player wearing an item plus a bracer enchant committed the item alone and ran
-- the rest of the session on a percentage and a per-hit floor that were both too low. That
-- is invisible from the inside: the rows, the session total and the accumulator all agree
-- on the understated figure, so no invariant can catch it.
--
-- This covers the COLD START too, where `sources` is still empty and committing would leave
-- the addon dormant for the whole session (5.11) until the next gear change.
--
-- Cost of the wider condition: a slot that can NEVER render (an unparseable link hitting the
-- pcall above) now spends the full retry budget before every commit. Bounded by the cap
-- below, and the previously committed `sources` stay live throughout -- only a cold start
-- has nothing to fall back on.
retries = retries + 1
if retries <= 40 then return false end -- ~40 * 0.2 s, then accept reality
end
retries = 0
sources = found
epoch = epoch + 1
for i = 1, table.getn(listeners) do listeners[i](sources, epoch) end
return true
end
function D.getSources() return sources end
function D.getEpoch() return epoch end
function D.onChange(fn) table.insert(listeners, fn) end
-- Cheap change detection: UNIT_INVENTORY_CHANGED fires constantly (bags, ammo, durability), so
-- compare the 19 links first and only pay for a tooltip parse on a real change.
local lastLinks = {}
local function linksChanged()
local changed = false
for slot = 1, 19 do
local l = GetInventoryItemLink("player", slot)
if l ~= lastLinks[slot] then lastLinks[slot] = l; changed = true end
end
return changed
end
if CreateFrame then
local f = CreateFrame("Frame", "VampifyDetectFrame")
f:RegisterEvent("PLAYER_ENTERING_WORLD")
f:RegisterEvent("UNIT_INVENTORY_CHANGED")
f:SetScript("OnEvent", function()
if event == "UNIT_INVENTORY_CHANGED" and arg1 ~= "player" then return end
if not linksChanged() and event ~= "PLAYER_ENTERING_WORLD" then return end
pending = 0 -- coalesce bursts: parse on the next OnUpdate, not once per event
end)
f:SetScript("OnUpdate", function()
if not pending then return end
pending = pending + arg1
if pending < 0.2 then return end
pending = nil
if not doScan() then pending = 0 end -- uncached tooltip: try again shortly
end)
end
+249
View File
@@ -0,0 +1,249 @@
-- 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