b3a282aeb5
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.
248 lines
12 KiB
Lua
248 lines
12 KiB
Lua
-- Vampify -- the watchdog. Pure Lua, no WoW API, offline-tested.
|
||
--
|
||
-- WHAT THIS CAN AND CANNOT DO. It finds contradictions in OUR OWN numbers. It cannot prove those
|
||
-- numbers are right -- nothing in this file compares against an independent source. A check that
|
||
-- held the displayed healing against the source percentages would be CIRCULAR, because the display
|
||
-- is computed from those percentages; such a check is always green and therefore worthless. So
|
||
-- these are invariants, not plausibility tests. The independent half is the HP reconciliation,
|
||
-- which lives elsewhere and is an ALARM, never a measurement (spec 2.1).
|
||
--
|
||
-- Call this on an interval or when the breakdown opens -- NOT per frame. It walks the breakdown
|
||
-- rows, which is cheap but not free, and the findings it produces are meant to be read by a human.
|
||
|
||
VampifyWatch = {}
|
||
local W = VampifyWatch
|
||
|
||
-- Repeated float addition does not land on the same value twice. The tolerance is relative so it
|
||
-- survives a long session, where the absolute drift grows with the total.
|
||
local function near(a, b)
|
||
local m = a
|
||
if m < 0 then m = -m end
|
||
if m < 1 then m = 1 end
|
||
local d = a - b
|
||
if d < 0 then d = -d end
|
||
return d <= 1e-6 * m
|
||
end
|
||
|
||
function W.new()
|
||
return {
|
||
-- Entry tables live here, not in the caller's buffer: the buffer is emptied on a clean run
|
||
-- so a stale finding cannot be read back, but the tables themselves are kept and refilled.
|
||
_pool = {},
|
||
-- key -> run number in which the finding last appeared, two levels deep (id, then spell) so
|
||
-- no string key has to be built per check. Bounded by invariant count times spellbook.
|
||
_seen = {},
|
||
run = 0,
|
||
}
|
||
end
|
||
|
||
local function emit(w, out, n, id, spell, a, b)
|
||
local e = w._pool[n]
|
||
if not e then e = {}; w._pool[n] = e end
|
||
e.id, e.spell, e.a, e.b = id, spell, a, b
|
||
|
||
local bucket = w._seen[id]
|
||
if not bucket then bucket = {}; w._seen[id] = bucket end
|
||
local k = spell or 0
|
||
e.isNew = (bucket[k] ~= w.run - 1)
|
||
bucket[k] = w.run
|
||
|
||
out[n] = e
|
||
return n
|
||
end
|
||
|
||
-- ctx fields, all optional unless noted:
|
||
-- rows, rowCount the breakdown as built by VampifyAggregate.spellBreakdown
|
||
-- sessionHeal the session total kept SEPARATELY from the rows -- that separateness is the
|
||
-- entire point of I1; taking both from one source would test nothing
|
||
-- emitted integers the SCT has shown; nil when it was not tracking (see I3)
|
||
-- accTotal the float accumulator behind those integers
|
||
-- correctedShown integers the retroactive AoE corrections removed from that accumulator,
|
||
-- counted where they were applied (see I3)
|
||
-- fightEpoch source-set epoch the current fight started in
|
||
-- sourceEpoch source-set epoch now
|
||
-- aoeAvailable whether AoE damping can be applied at all
|
||
-- multiTargetSeen whether damage was dealt to more than one target
|
||
function W.check(w, ctx, out)
|
||
w.run = w.run + 1
|
||
local n = 0
|
||
|
||
-- I1 -- the rows and the session total are kept by different code paths. If they disagree, one
|
||
-- of them is wrong and there is no way to tell which from here.
|
||
local rows, count = ctx.rows, ctx.rowCount or 0
|
||
if rows and ctx.sessionHeal then
|
||
local sum = 0
|
||
for i = 1, count do sum = sum + (rows[i].heal or 0) end
|
||
if not near(sum, ctx.sessionHeal) then
|
||
n = emit(w, out, n + 1, "I1", nil, sum, ctx.sessionHeal)
|
||
end
|
||
end
|
||
|
||
-- I2 -- overheal is a PART of the healing of that row. More than the whole, or less than
|
||
-- nothing, means the split is broken, and a broken split shows one heal as two.
|
||
if rows then
|
||
for i = 1, count do
|
||
local r = rows[i]
|
||
local oh, h = r.overheal or 0, r.heal or 0
|
||
if oh < 0 or (oh > h and not near(oh, h)) then
|
||
n = emit(w, out, n + 1, "I2", r.spell, oh, h)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- I3 -- every integer the SCT shows is a crossing of the accumulator, so their sum must BE the
|
||
-- accumulator's integer part. Skipped when emitted is nil: with the SCT off nothing is emitted
|
||
-- and the mismatch would be an artefact of the setting rather than a defect.
|
||
--
|
||
-- `correctedShown` is how many INTEGERS the retroactive AoE corrections took off the
|
||
-- accumulator (the derivation taking back healing credited before a proc was known to be area
|
||
-- damage). Those lower the accumulator AFTER their integers were already shown, so comparing
|
||
-- against the raw total would flag every single proc as a defect. Adding them back compares
|
||
-- like with like: what was shown against what the accumulator held WHEN it was shown.
|
||
--
|
||
-- It must be COUNTED where the correction is applied, not reconstructed here as
|
||
-- floor(accTotal - sum of the float deltas): VampifyModel.add reads floor(acc.total) after the
|
||
-- correction has already landed, so part of every correction is absorbed at an integer
|
||
-- boundary. That reconstruction differed from the sum of the increments actually returned in
|
||
-- roughly a fifth of single-correction sequences, and this comparison is exact equality (unlike
|
||
-- I1/I2, which use near()) -- so it fired on healthy data. Counted, the identity is exact by
|
||
-- construction.
|
||
if ctx.emitted and ctx.accTotal then
|
||
local shown = math.floor(ctx.accTotal) + (ctx.correctedShown or 0)
|
||
if ctx.emitted ~= shown then
|
||
n = emit(w, out, n + 1, "I3", nil, ctx.emitted, shown)
|
||
end
|
||
end
|
||
|
||
-- I4 -- a fight that straddles a gear change mixes two different source sums into one figure.
|
||
-- The numbers are not wrong per hit, but the fight's percentage is meaningless.
|
||
if ctx.fightEpoch and ctx.sourceEpoch and ctx.fightEpoch ~= ctx.sourceEpoch then
|
||
n = emit(w, out, n + 1, "I4", nil, ctx.fightEpoch, ctx.sourceEpoch)
|
||
end
|
||
|
||
-- I5 -- without AoE damping the total reads HIGH. This is the one direction in which this addon
|
||
-- can overstate its own number, which is why it is called out rather than left to the flag.
|
||
if ctx.multiTargetSeen and ctx.aoeAvailable == false then
|
||
n = emit(w, out, n + 1, "I5", nil, nil, nil)
|
||
end
|
||
|
||
for i = n + 1, table.getn(out) do out[i] = nil end
|
||
table.setn(out, n)
|
||
return n
|
||
end
|
||
|
||
-- I6 -- checked at record time rather than over the breakdown, because by the time a hit is a row
|
||
-- the zero has already been averaged away. Per source the floor is 1, so a hit that dealt damage
|
||
-- with sources equipped CANNOT return zero -- UNLESS damage <= 1, where the server's proc does not
|
||
-- fire at all (core/model.lua's header, confirmed from source 2026-08-22). The threshold here has
|
||
-- to match that exactly, or a legitimate damage=1 zero-heal reads as "the formula was bypassed".
|
||
function W.checkHit(damage, nSources, healFloat)
|
||
if not damage or damage <= 1 then return false end
|
||
if not nSources or nSources <= 0 then return false end
|
||
return (healFloat or 0) <= 0
|
||
end
|
||
|
||
-- ---- stage 2: the HP reconciliation ------------------------------------------------------------
|
||
--
|
||
-- The only independent evidence available: healing that Vampirism emits no event for still moves
|
||
-- the health bar. Per window,
|
||
--
|
||
-- implied = ΔHP + incoming damage − logged healing
|
||
--
|
||
-- and implied is compared against what we CREDITED for that window. This is systematically biased
|
||
-- -- see the rejection rules below for what is excluded, and note that overheal and any healing we
|
||
-- fail to observe both survive the filters. It is therefore an ALARM, never a measurement, and it
|
||
-- is never shown as a number (spec 2.1, 2.3).
|
||
|
||
-- Health this close to the maximum makes overheal invisible: the bar cannot rise, so a window there
|
||
-- reports "no healing arrived" no matter what happened.
|
||
local CAP_FRACTION = 0.98
|
||
-- Beyond this a window is not a window but an idle gap, and whatever drifted in during it has
|
||
-- nothing to do with the hit that opened it. The figure matches the one used when this balance was
|
||
-- run offline against recorded sessions.
|
||
local MAX_SECONDS = 3
|
||
|
||
function W.newBalance()
|
||
return {
|
||
sumImplied = 0, sumExpected = 0, n = 0,
|
||
-- BUILT-IN CONTROL. Windows in which no damage came in need no incoming figure at all, so
|
||
-- their balance holds regardless of whether the event's amount is gross or net -- a question
|
||
-- we have NOT measured for the incoming direction. Windows that did take damage depend on
|
||
-- that interpretation. Kept apart, the two branches check each other: if they disagree, the
|
||
-- interpretation is wrong, not the addon's healing. An analysis without a control on a known
|
||
-- case is worthless, and this project has already paid for that lesson twice.
|
||
zeroImplied = 0, zeroExpected = 0, zeroN = 0,
|
||
-- Counted, not just discarded: a run that rejects nearly everything has not measured
|
||
-- anything, and the counts are what makes that visible instead of silently producing a
|
||
-- confident verdict from four windows.
|
||
rejected = { cap = 0, group = 0, idle = 0, noExpect = 0 },
|
||
}
|
||
end
|
||
|
||
function W.resetBalance(b)
|
||
b.sumImplied, b.sumExpected, b.n = 0, 0, 0
|
||
b.zeroImplied, b.zeroExpected, b.zeroN = 0, 0, 0
|
||
local r = b.rejected
|
||
r.cap, r.group, r.idle, r.noExpect = 0, 0, 0, 0
|
||
end
|
||
|
||
function W.addWindow(b, hpPrev, hpNow, hpMax, incoming, logged, expected, seconds, inGroup)
|
||
local r = b.rejected
|
||
-- Group first: in a raid the foreign-healing error was measured at +29..49 %, which swamps
|
||
-- everything else this could find.
|
||
if inGroup then r.group = r.group + 1; return false end
|
||
if hpMax and hpMax > 0 and hpPrev >= hpMax * CAP_FRACTION then r.cap = r.cap + 1; return false end
|
||
if seconds and seconds > MAX_SECONDS then r.idle = r.idle + 1; return false end
|
||
if not expected or expected <= 0 then r.noExpect = r.noExpect + 1; return false end
|
||
|
||
-- Negative implied values are KEPT. Discarding them would bias the sum upward and hide exactly
|
||
-- the defect this exists to catch.
|
||
local inc = incoming or 0
|
||
local implied = (hpNow - hpPrev) + inc - (logged or 0)
|
||
b.sumImplied = b.sumImplied + implied
|
||
b.sumExpected = b.sumExpected + expected
|
||
b.n = b.n + 1
|
||
if inc == 0 then
|
||
b.zeroImplied = b.zeroImplied + implied
|
||
b.zeroExpected = b.zeroExpected + expected
|
||
b.zeroN = b.zeroN + 1
|
||
end
|
||
return true
|
||
end
|
||
|
||
-- The control. Both branches measure the same thing; only the damaged one depends on reading the
|
||
-- incoming amount correctly. A gap between them therefore indicts the READING, not the healing --
|
||
-- and it must be ruled out before any H1/H2 verdict is believed.
|
||
function W.crossCheck(b, minWindows, tol)
|
||
local m = minWindows or 30
|
||
local damagedN = b.n - b.zeroN
|
||
if b.zeroN < m or damagedN < m then return nil end
|
||
if b.zeroExpected <= 0 then return nil end
|
||
local damagedExpected = b.sumExpected - b.zeroExpected
|
||
if damagedExpected <= 0 then return nil end
|
||
local rz = b.zeroImplied / b.zeroExpected
|
||
local rd = (b.sumImplied - b.zeroImplied) / damagedExpected
|
||
local d = rz - rd
|
||
if d < 0 then d = -d end
|
||
if d > (tol or 0.25) then
|
||
return { id = "H3", ratioZero = rz, ratioDamaged = rd, windows = b.n }
|
||
end
|
||
return nil
|
||
end
|
||
|
||
-- Returns a finding or nil. Two directions, because they mean opposite things:
|
||
-- H1 we credit healing that never arrives -- a spell in the breakdown that does not trigger
|
||
-- H2 more arrives than we account for -- a trigger we are not capturing at all
|
||
function W.alarm(b, minWindows, threshold)
|
||
if b.n < (minWindows or 30) then return nil end
|
||
if b.sumExpected <= 0 then return nil end
|
||
local ratio = b.sumImplied / b.sumExpected
|
||
local t = threshold or 0.25
|
||
if ratio < 1 - t then
|
||
return { id = "H1", ratio = ratio, windows = b.n }
|
||
elseif ratio > 1 + t then
|
||
return { id = "H2", ratio = ratio, windows = b.n }
|
||
end
|
||
return nil
|
||
end
|