Files
2026-08-30 15:37:26 +02:00

441 lines
24 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
-- The INDIVIDUAL source percentages of the hit, as "3/2/2/2".
--
-- WHY IT HAD TO BE ADDED (offline analysis, 2026-08-30). The line carried only the SUMMED
-- percentage P and the source count n, and the server truncates every source SEPARATELY
-- (core/model.lua's header): max(1, floor(pct_i * damage / 100)) per source. From P and n alone
-- that is not reconstructable -- an offline reader has to approximate the list as n equal shares,
-- which is exactly VampifyModel.heal's documented-inexact compatibility shape. Measured against
-- the recorded pred_heal over 4907 windows that approximation is off by 4.7 %, and 4.7 % is the
-- same order as the effects the analysis is trying to separate. With the real list the offline
-- model becomes exact instead of approximate, and the AoE-ordering question core/model.lua's
-- header leaves open (damp the damage, or damp each source's heal) becomes decidable from
-- recorded data rather than needing a new in-game measurement.
--
-- "%g", not "%d" or "%.2f": a source percentage is normally a small integer (3, 2, 2) and should
-- print as one, but nothing guarantees it -- a fractional percentage must survive rather than be
-- silently truncated into a wrong number.
--
-- Buffer-recycled like every other reused list here (VampifyConst.resetList's comment): this runs
-- once per own hit, and a fresh table per hit is avoidable churn on a channel that is meant to run
-- permanently. The caller's list is READ ONLY -- and the result is a STRING, which matters: the
-- live source list is itself a recycled buffer (core/commands.lua's sourcePercents, refilled in
-- place on every gear change), so a row may not hold a reference to it. Serialising here, at
-- record time, is what keeps a buffered row from silently re-reading a list that has since changed.
local pctBuf = {}
function PH.formatPercents(list)
if not list then return "" end
local n = table.getn(list)
for i = table.getn(pctBuf), n + 1, -1 do pctBuf[i] = nil end
table.setn(pctBuf, n)
for i = 1, n do pctBuf[i] = string.format("%g", list[i] or 0) end
return table.concat(pctBuf, "/", 1, n)
end
-- pcts / zone / cast are the three fields the offline analysis asked for (docs/
-- VAMPIRISM-MODELL-STAND §4). All three are optional: an older caller that does not supply them
-- still produces a well-formed line ("", "?" and 0), so the export never depends on every wiring
-- site having been updated in the same change.
--
-- cast=1 means a SPELL_GO_SELF for THIS spell id arrived shortly before this hit -- i.e. the player
-- CAST it. cast=0 means no cast announced it, which for a damaging spell is the signature of a
-- proc. That distinction is the whole point: hypothesis H2 says Vampirism is fed by the TRIGGER
-- PATH ("was this triggered by an aura") rather than by "is this an item proc", and the cast event
-- is the one observable in the client that separates the two paths for the same spell id. Without
-- it the offline classification has to be inferred from timing against Lightning Strike hits and
-- incoming damage, which is what the current analysis does and why it can only report AMBIG for
-- 13.4 % of Tidal Wave hits.
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|pcts=%s|zone=%s|cast=%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), row.pcts or "", row.zone or "?", (row.cast 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, pcts, zone, cast -- 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.
--
-- fields.pcts is the LIVE source-percent list and is serialised HERE, not held. The row can sit
-- pending (and then buffered) for a long time, and that list is a recycled buffer refilled in
-- place whenever gear changes -- keeping the reference would make an already-recorded hit report
-- whatever the source set happens to be at flush time. Same reasoning as the health readings
-- above: a value that describes THIS hit is captured at this hit.
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,
pcts = PH.formatPercents(fields.pcts), zone = fields.zone, cast = fields.cast,
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