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.
387 lines
20 KiB
Lua
387 lines
20 KiB
Lua
-- Vampify -- dev-only per-hit debug export. Pure Lua ring buffer + line formatting (offline
|
|
-- tested), plus a thin session-local singleton and SuperWoW ExportFile flush.
|
|
--
|
|
-- WHAT THIS IS FOR. The v0.1/v0.2 formula (heal(D) = max(nSources, D * P * a)) uses ONE factor
|
|
-- per source, summed. Upgrading that to a PER-SOURCE factor needs exact measurements of what the
|
|
-- model predicted against what actually happened for every hit -- not the aggregated totals
|
|
-- /vf status already prints. This channel is that instrument: one line per own hit with the
|
|
-- inputs the model saw, the prediction it made, and the health readings around it, dumped to
|
|
-- <WoW>\imports\ for offline analysis.
|
|
--
|
|
-- INC / SELFHEAL / EXTHEAL RUN INDEPENDENTLY OF /vf watch AND GROUP STATUS (follow-up change
|
|
-- request, 2026-08-22). /vf watch on correctly REFUSES in a group -- the HP-reconciliation
|
|
-- BALANCE it computes is genuinely meaningless there (foreign healing, measured at +29..49%
|
|
-- phantom healing). That refusal is about watch's own verdict, not about whether the raw incoming/
|
|
-- healing numbers can be captured at all -- and this channel wants exactly those raw numbers,
|
|
-- group or not, watch-on or not, since they are what turns "some healing arrived and we don't know
|
|
-- why" into a measurable EXTHEAL line. So capture/incoming.lua now tracks two independent "wants"
|
|
-- (I.setWatchWanted / I.setPerHitWanted); whichever hit-line code in core/commands.lua feeds
|
|
-- PH.incoming/.selfheal/.extheal runs whenever EITHER wants the capture (I.isOn()), while the
|
|
-- watch balance itself (VampifyWatch.addWindow) stays gated on I.isWatchOn() specifically -- see
|
|
-- capture/incoming.lua's own header for the full mechanism and for the answer to "does Vampify see
|
|
-- foreign healing at all" (yes: SPELL_HEAL_BY_OTHER, always already registered; it was just being
|
|
-- folded into the self-heal total before this change, which is what the new EXTHEAL line fixes).
|
|
--
|
|
-- DEFAULT ON, PERSISTED (follow-up change request, 2026-08-22, overriding this addon's earlier
|
|
-- "dev-only, default off, never saved" stance for THIS specific channel by explicit developer
|
|
-- request): it is meant to run continuously across every session like a background capture addon's
|
|
-- capture, not be armed by hand. The persisted half of that --
|
|
-- VampifyDB.perhitEnabled and the auto-enable-on-login sync -- lives in core/commands.lua
|
|
-- (syncPerHit) and gui/options.lua (the "Per-hit debug export" checkbox); this module itself still
|
|
-- defaults its OWN in-memory VampifyPerHit._state to disabled (PH.newState()) and knows nothing
|
|
-- about VampifyConfig -- it only turns on when told to, the same as every other module here that
|
|
-- stays config-agnostic (core/model.lua, core/watch.lua). /vf perhit on|off|status
|
|
-- (core/commands.lua) remains the manual alternative and writes the same persisted field.
|
|
--
|
|
-- ARCHITECTURE. Same split as the rest of this addon: this file is pure and WoW-API-free except
|
|
-- for the disk flush at the bottom (mirrors core/const.lua's error capture and core/watch.lua's
|
|
-- watchLine, which are the same shape for the same reason). The actual per-hit DATA -- amount,
|
|
-- isAoE, spellId, the model's healFloat, sumPercent/nSources -- lives in core/commands.lua's
|
|
-- onDamage listener, which is "the only place where the pure core meets the WoW-bound capture
|
|
-- layer" (its own header comment). So commands.lua calls VampifyPerHit.hit(...)/.incoming(...)/
|
|
-- .selfheal(...)/.extheal(...)/.onTick(...) with values it already has; this file registers NO
|
|
-- event listeners of its own and needs no new ones in capture/*.lua.
|
|
--
|
|
-- hp_max (follow-up change request, 2026-08-22). Overheal windows are otherwise invisible to offline analysis: at
|
|
-- full health the Vampirism heal lands and immediately overflows, so hp_after - hp_before reads 0
|
|
-- even though pred_heal > 0 -- indistinguishable, from the numbers alone, from the heal genuinely
|
|
-- not landing (a real model deviation). hp_max fixes that: with hp_before/hp_max/pred_heal an
|
|
-- offline reader can tell "delta=0 because hp_before was already at/near hp_max" apart from
|
|
-- "delta=0 and hp_before had headroom -- something is actually wrong". Read via
|
|
-- UnitHealthMax("player") at the SAME point as hp_before (when the hit is recorded, not when the
|
|
-- window closes) -- hp_max does not change mid-fight under anything this addon cares about, so
|
|
-- there is no "before/after" question for it the way there is for hp itself, and reading it
|
|
-- alongside hp_before costs nothing new (no event, one more already-cheap API call at a point that
|
|
-- already reads UnitHealth).
|
|
--
|
|
-- hp_before / hp_after. Vampirism heals with NO event of its own (that is the addon's entire
|
|
-- reason to exist), so the only way to see it land is to read UnitHealth("player") before and
|
|
-- after. "Before" is the health at the moment this hit's damage event fires (before the healing
|
|
-- from THIS hit can possibly have landed). "After" is NOT available at that same instant --
|
|
-- 1.12's event order gives no signal for "the heal from this hit has now applied" -- so it is
|
|
-- read at the EARLIEST of two things that already exist in commands.lua: the next own damage
|
|
-- event (closing this hit's row the same way the HP-reconciliation watchdog already closes its
|
|
-- windows, core/watch.lua's "Stage 2" comment), or the next OnUpdate throttle tick (~0.25s) if no
|
|
-- further hit arrives in time. A hit right before combat ends is force-closed on
|
|
-- PLAYER_REGEN_ENABLED/PLAYER_DEAD (PH.closeWindow) so nothing is left pending across a long idle
|
|
-- period -- see PH.closeWindow's own comment for why that does NOT also force a disk write.
|
|
-- CONSEQUENCE FOR ANALYSIS: hp_after is a snapshot up to ~0.3s after the hit, not an
|
|
-- exact "immediately after this heal" read -- it can include a second hit's healing (or unrelated
|
|
-- incoming damage) if events land close together. Treat hp_after - hp_before as "what happened in
|
|
-- the window this hit opened", not as an exact isolation of this one heal; the /vf watch balance
|
|
-- (core/watch.lua) exists for the same reason and has the same caveat.
|
|
--
|
|
-- WHAT IS NOT COVERED. The retroactive AoE correction (VampifyDamage.onAoECorrection,
|
|
-- commands.lua) rewrites an already-recorded hit's healing after the fact once a second target
|
|
-- proves it was AoE -- but by the time that fires, this channel's line for the original hit may
|
|
-- already be flushed to disk. Reopening a flushed line is out of scope (this is an append-only
|
|
-- export, like every other channel in this addon); a hit that gets corrected will show its
|
|
-- ORIGINAL (non-AoE) aoe=0/pred_heal in its own HIT line. Cross-reference against
|
|
-- vampify_watch.txt / the session's own knowledge of which spells proc AoE if this matters to a
|
|
-- specific analysis.
|
|
|
|
VampifyPerHit = {}
|
|
local PH = VampifyPerHit
|
|
|
|
-- ---- pure: buffer + formatting ------------------------------------------------------------------
|
|
|
|
-- Hard safety cap on the in-memory buffer, independent of the flush thresholds below. Normal
|
|
-- operation flushes long before this; it exists so a flush that somehow never fires (ExportFile
|
|
-- missing, disk full) degrades into "oldest lines drop" instead of unbounded growth -- the same
|
|
-- discipline as core/watch.lua's WATCH_LOG_MAX, sized larger here because a per-hit line is
|
|
-- shorter and AoE grinding can produce 30-60 hits/sec.
|
|
local RING_CAP = 2000
|
|
PH.RING_CAP = RING_CAP
|
|
|
|
-- A hit is force-closed (hp_after read "now") if no further own hit arrives within this long.
|
|
-- Set just above the 0.25s display throttle commands.lua already runs PH.onTick from, so an
|
|
-- isolated hit's hp_after is captured on the very next tick rather than staying open until
|
|
-- whatever hit happens to come next (which could be seconds later, or never, in that fight).
|
|
local PENDING_TIMEOUT = 0.3
|
|
PH.PENDING_TIMEOUT = PENDING_TIMEOUT
|
|
|
|
function PH.newState()
|
|
return {
|
|
enabled = false,
|
|
sid = nil,
|
|
chunk = 0,
|
|
buf = {},
|
|
pending = nil,
|
|
totalHits = 0,
|
|
totalFlushed = 0,
|
|
elapsed = 0,
|
|
}
|
|
end
|
|
|
|
-- Only acts on a TRANSITION. Turning on (re)seeds sid/chunk/buffer/counters -- a fresh instrument
|
|
-- read, not a continuation of whatever a previous /vf perhit on..off cycle left behind. Turning
|
|
-- off leaves sid/chunk/buf alone on purpose: the caller (PH.disable) still needs them to flush
|
|
-- the final chunk under the SAME sid/chunk index the session was using.
|
|
function PH.setEnabled(state, on, sid)
|
|
on = on and true or false
|
|
if on == state.enabled then return state.enabled end
|
|
if on then
|
|
state.sid, state.chunk = sid, 0
|
|
VampifyConst.resetList(state.buf) -- see resetList's own comment: nil-ing indices alone
|
|
-- leaves table.getn reading a stale n in Lua 5.0
|
|
state.pending = nil
|
|
state.totalHits, state.totalFlushed, state.elapsed = 0, 0, 0
|
|
end
|
|
state.enabled = on
|
|
return state.enabled
|
|
end
|
|
|
|
-- table.remove (not a hand-rolled shift) to evict the oldest line: it keeps `n` in step, the same
|
|
-- reasoning core/watch.lua's watchLine gives for using it over nil-ing indices by hand.
|
|
function PH.pushLine(state, line)
|
|
table.insert(state.buf, line)
|
|
if table.getn(state.buf) > RING_CAP then
|
|
table.remove(state.buf, 1)
|
|
end
|
|
end
|
|
|
|
function PH.formatHitLine(row)
|
|
return string.format(
|
|
"HIT|t=%.3f|src=%s|dmg=%d|aoe=%d|P=%.4f|n=%d|pred_heal=%.4f|hp_before=%s|hp_after=%s|"
|
|
.."hp_max=%s|acc_total=%.4f|crit=%d",
|
|
row.t, row.src, row.dmg, (row.aoe and 1 or 0), row.P, row.n, row.pred_heal,
|
|
tostring(row.hp_before), tostring(row.hp_after), tostring(row.hp_max), row.acc_total,
|
|
(row.crit and 1 or 0))
|
|
end
|
|
|
|
function PH.formatIncomingLine(row)
|
|
return string.format("INC|t=%.3f|dmg=%.4f", row.t, row.dmg)
|
|
end
|
|
|
|
function PH.formatSelfHealLine(row)
|
|
return string.format("SELFHEAL|t=%.3f|heal=%.4f", row.t, row.heal)
|
|
end
|
|
|
|
-- Foreign healing landing on the player -- exactly the healing that contaminates a group /vf watch
|
|
-- window (see capture/incoming.lua's header comment), kept as its own line instead of folded into
|
|
-- SELFHEAL so it is measurable rather than invisible. Same shape/skip rule as formatSelfHealLine.
|
|
function PH.formatExternalHealLine(row)
|
|
return string.format("EXTHEAL|t=%.3f|heal=%.4f", row.t, row.heal)
|
|
end
|
|
|
|
-- Closes whatever hit is currently open (if any): stamps hp_after, formats and pushes its line,
|
|
-- clears the pending slot. Called from three places: the NEXT hit (below), the OnUpdate timeout
|
|
-- (PH.tick), and a forced flush (PH.flushNow/PH.disable) -- exactly one of these will ever close
|
|
-- a given pending row, since each clears it before returning.
|
|
function PH.finalizePending(state, hpNow)
|
|
local p = state.pending
|
|
if not p then return false end
|
|
p.hp_after = hpNow
|
|
PH.pushLine(state, PH.formatHitLine(p))
|
|
state.pending = nil
|
|
return true
|
|
end
|
|
|
|
-- fields: t, src, dmg, aoe, P, n, pred_heal, acc_total, crit -- everything except the health
|
|
-- readings, which this function supplies itself: hp_before = hpNow and hp_max = hpMaxNow are both
|
|
-- stamped HERE, at the same point, and never touched again; hp_after comes later, at finalize.
|
|
function PH.recordHit(state, fields, hpNow, tNow, hpMaxNow)
|
|
PH.finalizePending(state, hpNow)
|
|
state.pending = {
|
|
t = fields.t, src = fields.src, dmg = fields.dmg, aoe = fields.aoe,
|
|
P = fields.P, n = fields.n, pred_heal = fields.pred_heal,
|
|
acc_total = fields.acc_total, crit = fields.crit,
|
|
hp_before = hpNow, hp_max = hpMaxNow, pendingAt = tNow,
|
|
}
|
|
state.totalHits = state.totalHits + 1
|
|
end
|
|
|
|
-- Force-closes a pending hit once it has sat open longer than PENDING_TIMEOUT with no follow-up
|
|
-- event to close it the normal way. Returns true if it actually closed one, for the tests.
|
|
function PH.tick(state, hpNow, tNow)
|
|
if state.pending and (tNow - state.pending.pendingAt) >= PENDING_TIMEOUT then
|
|
return PH.finalizePending(state, hpNow)
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Zero/nil incoming damage or self-heal in a window is the common case (most windows have
|
|
-- neither) and is skipped rather than emitted as a line of noise -- an absent INC/SELFHEAL line
|
|
-- for a given HIT IS the "nothing happened" signal for offline analysis.
|
|
function PH.recordIncoming(state, t, dmg)
|
|
if not dmg or dmg == 0 then return false end
|
|
PH.pushLine(state, PH.formatIncomingLine({ t = t, dmg = dmg }))
|
|
return true
|
|
end
|
|
|
|
function PH.recordSelfHeal(state, t, heal)
|
|
if not heal or heal <= 0 then return false end
|
|
PH.pushLine(state, PH.formatSelfHealLine({ t = t, heal = heal }))
|
|
return true
|
|
end
|
|
|
|
function PH.recordExternalHeal(state, t, heal)
|
|
if not heal or heal <= 0 then return false end
|
|
PH.pushLine(state, PH.formatExternalHealLine({ t = t, heal = heal }))
|
|
return true
|
|
end
|
|
|
|
function PH.chunkName(sid, chunkIdx)
|
|
return "vampify_perhit_" .. tostring(sid) .. "_" .. tostring(chunkIdx)
|
|
end
|
|
|
|
function PH.chunkHeader(version, sid, chunkIdx, lineCount)
|
|
return string.format("addon=Vampify version=%s chunk=%d sid=%s lines=%d",
|
|
tostring(version), chunkIdx, tostring(sid), lineCount)
|
|
end
|
|
|
|
-- Pure half of a flush: builds the chunk's name and full text (header + every buffered line),
|
|
-- advances the chunk index, and empties the buffer -- all without touching ExportFile, so it is
|
|
-- testable without any WoW stub. Returns (nil, nil) when there is nothing to flush.
|
|
function PH.takeFlushText(state, version)
|
|
local lineCount = table.getn(state.buf)
|
|
if lineCount == 0 then return nil, nil end
|
|
local name = PH.chunkName(state.sid, state.chunk)
|
|
local header = PH.chunkHeader(version, state.sid, state.chunk, lineCount)
|
|
local text = header .. "\n" .. table.concat(state.buf, "\n") .. "\n"
|
|
state.chunk = state.chunk + 1
|
|
state.totalFlushed = state.totalFlushed + lineCount
|
|
VampifyConst.resetList(state.buf)
|
|
return name, text
|
|
end
|
|
|
|
-- ---- session-local singleton + disk flush --------------------------------------------------
|
|
--
|
|
-- The module's OWN default is still disabled -- every addon load starts VampifyPerHit._state at
|
|
-- PH.newState()'s enabled=false, and this file never reads VampifyConfig. What makes the channel
|
|
-- actually run every session is core/commands.lua's syncPerHit(), called on every
|
|
-- PLAYER_LOGIN/PLAYER_ENTERING_WORLD, which reads VampifyDB.perhitEnabled (core/config.lua,
|
|
-- default true) and calls PH.enable()/.disable() accordingly -- the same "module stays
|
|
-- config-agnostic, the wiring layer bridges to SavedVariables" split core/model.lua and the rest
|
|
-- of core/*.lua already use.
|
|
|
|
VampifyPerHit._state = PH.newState()
|
|
|
|
-- Flush when the buffer reaches this many lines, or this many seconds have passed, whichever
|
|
-- comes first -- the same two-trigger shape (flush-on-count / flush-on-timer) used by a companion
|
|
-- capture addon's own chunk-rotation library, the reference pattern this mirrors. Deliberately NOT
|
|
-- reusing that library itself: this channel needs neither its chunked wire protocol (nothing
|
|
-- ingests these chunks automatically) nor its multi-producer plumbing, and pulling it in would be
|
|
-- exactly the kind of heavy new subsystem this channel is meant to avoid.
|
|
--
|
|
-- SIZED FOR PERMANENT OPERATION (follow-up change request, 2026-08-22: default on, runs every session, not
|
|
-- just an armed-by-hand dev probe). LibEmpBus's own FLUSH_SEC=2 is right for ITS job -- feeding a
|
|
-- near-realtime backend watcher -- but nothing ingests these chunks automatically, and the addon
|
|
-- cannot delete old ones (no filesystem delete from Lua). A short interval that flushed whatever
|
|
-- was buffered, even one line, would turn "grind mobs one at a time so combat drops between
|
|
-- pulls" into a file per kill. 500/60s bounds a busy AoE pull to a handful of chunks (a line is
|
|
-- ~130 bytes; 500 lines is ~65 KB) while keeping the worst-case unflushed tail small if the
|
|
-- client vanishes without a clean logout (PLAYER_CAMPING/PLAYER_QUITING force a flush too, see
|
|
-- core/commands.lua). See the addon's PR notes for the resulting bytes/hour estimate.
|
|
PH.FLUSH_LINES = 500
|
|
PH.FLUSH_SECS = 60
|
|
|
|
local function flushToDisk()
|
|
if not ExportFile then return end -- SuperWoW only; harmless without it
|
|
local name, text = PH.takeFlushText(VampifyPerHit._state, VampifyConst.VERSION)
|
|
-- ExportFile appends .txt itself -- name must NOT already carry it (see core/const.lua's
|
|
-- writeErrors for the same gotcha).
|
|
if name then ExportFile(name, text) end
|
|
end
|
|
|
|
function PH.isEnabled()
|
|
return VampifyPerHit._state.enabled
|
|
end
|
|
|
|
function PH.status()
|
|
local s = VampifyPerHit._state
|
|
return {
|
|
enabled = s.enabled,
|
|
sid = s.sid,
|
|
chunk = s.chunk,
|
|
buffered = table.getn(s.buf),
|
|
totalHits = s.totalHits,
|
|
totalFlushed = s.totalFlushed,
|
|
}
|
|
end
|
|
|
|
-- time() is a WoW global (used the same way by a companion capture addon); math.floor(GetTime())
|
|
-- is the fallback for an environment that somehow has GetTime but not time(). Session id only
|
|
-- has to be unique enough to tell two /vf perhit on sessions' chunks apart on disk, not globally
|
|
-- unique.
|
|
function PH.enable()
|
|
if PH.isEnabled() then return false end
|
|
local sid = time and time() or math.floor(GetTime and GetTime() or 0)
|
|
PH.setEnabled(VampifyPerHit._state, true, sid)
|
|
return true
|
|
end
|
|
|
|
-- Finalizes whatever hit is still open and force-flushes the current chunk, THEN turns the
|
|
-- channel off -- so /vf perhit off never strands the last hit's hp_after in memory.
|
|
function PH.disable(hpNow)
|
|
if not PH.isEnabled() then return false end
|
|
PH.finalizePending(VampifyPerHit._state, hpNow)
|
|
PH.setEnabled(VampifyPerHit._state, false)
|
|
flushToDisk()
|
|
return true
|
|
end
|
|
|
|
-- Same finalize+flush as disable(), without turning the channel off -- for a point that needs a
|
|
-- GUARANTEED write regardless of the size/time thresholds (currently only /vf perhit off and a
|
|
-- real logout/camp -- see core/commands.lua's PLAYER_CAMPING/PLAYER_QUITING handling).
|
|
function PH.flushNow(hpNow)
|
|
if not PH.isEnabled() then return end
|
|
PH.finalizePending(VampifyPerHit._state, hpNow)
|
|
flushToDisk()
|
|
end
|
|
|
|
local function maybeFlush()
|
|
if table.getn(VampifyPerHit._state.buf) >= PH.FLUSH_LINES then flushToDisk() end
|
|
end
|
|
|
|
-- Finalizes a pending hit WITHOUT forcing a disk write -- for combat end. A hit right before
|
|
-- PLAYER_REGEN_ENABLED must not be left open across what could be a long idle gap until the next
|
|
-- fight (same reasoning as flushNow), but forcing a chunk to disk on every single fight is exactly
|
|
-- the "file per kill" permanent-operation problem FLUSH_LINES/FLUSH_SECS above exist to avoid --
|
|
-- several short fights' hits accumulate in the SAME buffer until one of those thresholds fires
|
|
-- naturally. maybeFlush() still applies: a fight big enough to cross FLUSH_LINES on its own closes
|
|
-- its own chunk immediately, same as it would mid-fight.
|
|
function PH.closeWindow(hpNow)
|
|
if not PH.isEnabled() then return end
|
|
PH.finalizePending(VampifyPerHit._state, hpNow)
|
|
maybeFlush()
|
|
end
|
|
|
|
-- fields: see PH.recordHit. Cost when the channel is off is one isEnabled() check and nothing
|
|
-- else -- no table built, no line formatted.
|
|
function PH.hit(fields, hpNow, tNow, hpMaxNow)
|
|
if not PH.isEnabled() then return end
|
|
PH.recordHit(VampifyPerHit._state, fields, hpNow, tNow, hpMaxNow)
|
|
maybeFlush()
|
|
end
|
|
|
|
function PH.incoming(t, dmg)
|
|
if not PH.isEnabled() then return end
|
|
if PH.recordIncoming(VampifyPerHit._state, t, dmg) then maybeFlush() end
|
|
end
|
|
|
|
function PH.selfheal(t, heal)
|
|
if not PH.isEnabled() then return end
|
|
if PH.recordSelfHeal(VampifyPerHit._state, t, heal) then maybeFlush() end
|
|
end
|
|
|
|
function PH.extheal(t, heal)
|
|
if not PH.isEnabled() then return end
|
|
if PH.recordExternalHeal(VampifyPerHit._state, t, heal) then maybeFlush() end
|
|
end
|
|
|
|
-- Driven from the SAME 0.25s OnUpdate throttle core/commands.lua already runs everything else
|
|
-- from -- no new frame, per the project's OnUpdate-allocation rule. dt (elapsed since the last
|
|
-- tick) feeds the time-based flush trigger; hpNow/tNow close a pending hit that never got a
|
|
-- follow-up event (PH.tick's PENDING_TIMEOUT).
|
|
function PH.onTick(hpNow, tNow, dt)
|
|
if not PH.isEnabled() then return end
|
|
PH.tick(VampifyPerHit._state, hpNow, tNow)
|
|
local s = VampifyPerHit._state
|
|
s.elapsed = s.elapsed + (dt or 0)
|
|
if s.elapsed >= PH.FLUSH_SECS then
|
|
s.elapsed = 0
|
|
flushToDisk()
|
|
end
|
|
end
|