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:
@@ -0,0 +1,716 @@
|
||||
-- Vampify -- running totals. Pure Lua, no WoW API, offline-tested.
|
||||
--
|
||||
-- Allocation budget per recorded event is ZERO: AoE grinding produces 30-60 damage events per
|
||||
-- second. The result tables are created once and refilled, never rebuilt.
|
||||
|
||||
VampifyAggregate = {}
|
||||
local A = VampifyAggregate
|
||||
|
||||
-- Sentinel bucket for auto attacks, which carry no spell id. A number, so the breakdown's sort
|
||||
-- never has to compare a number against a string.
|
||||
A.MELEE = -1
|
||||
|
||||
-- Human-readable names for WoW 1.12's inventory equipment slot ids 1-19 -- the same numbering
|
||||
-- GetInventoryItemLink("player", slot) and capture/detect.lua's D.scan use. Confirmed against the
|
||||
-- cross-verified in-game against pfUI, 2026-08-24, rather than assumed from memory; slot 0 (Ammo)
|
||||
-- is never scanned by D.scan (its loop starts at 1) so it is deliberately absent here.
|
||||
local SLOT_NAME = {
|
||||
[1] = "Head", [2] = "Neck", [3] = "Shoulder", [4] = "Shirt", [5] = "Chest", [6] = "Waist",
|
||||
[7] = "Legs", [8] = "Feet", [9] = "Wrist", [10] = "Hands", [11] = "Finger1", [12] = "Finger2",
|
||||
[13] = "Trinket1", [14] = "Trinket2", [15] = "Back", [16] = "Weapon", [17] = "Off Hand",
|
||||
[18] = "Ranged", [19] = "Tabard",
|
||||
}
|
||||
|
||||
-- ---- per-spell, per-source healing breakdown -----------------------------------------------------
|
||||
--
|
||||
-- core/model.lua's M.healBreakdown keeps a hit's per-source integers instead of collapsing them
|
||||
-- into one total; these two helpers turn one of those per-source rows into the STABLE identity and
|
||||
-- the human label the breakdown below is keyed and displayed by.
|
||||
--
|
||||
-- IDENTITY CHOICE: SLOT + KIND, not the array index into sourcePercents (core/commands.lua rebuilds
|
||||
-- that index-based list from scratch on every recompute(), so an EARLIER slot's gear change shifts
|
||||
-- every LATER index -- the index is not a durable key across a session) and not the item id either
|
||||
-- (capture/detect.lua's D.scan never reads one; the tooltip text is all it has). A slot is the one
|
||||
-- thing that survives a gear swap in it, and it is exactly the question a re-gearing player is
|
||||
-- asking ("is my weapon slot worth a Vampirism source"), not "which literal item". Kind is folded
|
||||
-- in because a single slot can legitimately carry BOTH an item-text source and an enchant-text one
|
||||
-- at once (D.scan's own loop does not stop at the first match per slot) -- without kind those two
|
||||
-- would collide into one row and silently sum away which of the two actually mattered.
|
||||
function A.sourceKey(slot, kind)
|
||||
return tostring(slot) .. ":" .. tostring(kind)
|
||||
end
|
||||
|
||||
function A.sourceLabel(slot, pct)
|
||||
local name = SLOT_NAME[slot] or ("Slot " .. tostring(slot))
|
||||
return name .. " +" .. tostring(pct) .. "%"
|
||||
end
|
||||
|
||||
function A.new()
|
||||
return {
|
||||
fHeal = 0, fDmg = 0, fStart = 0, fEnd = 0, fActive = false,
|
||||
sHeal = 0, sDmg = 0,
|
||||
_fight = {}, _session = {},
|
||||
-- Per-spell, per-source-identity breakdown (see A.sourceKey's own comment for the identity
|
||||
-- choice). spSrc[spellId][sourceKey] = { heal, floorHits, hits, label, pct } -- one nested
|
||||
-- table per spell id, exactly the same "bounded by the spellbook, not by mob GUIDs" shape
|
||||
-- spHeal etc. already have, with an extra dimension bounded by physical equipment slots
|
||||
-- (see A.recordSourceBreakdown's own comment on lifetime size). lfSrc is the LIFETIME twin,
|
||||
-- filled by the SAME call, same reasoning as lfHeal/spHeal below.
|
||||
spSrc = {}, lfSrc = {},
|
||||
-- Per-spell SESSION totals. Session scope on purpose: the bar's tooltip answers "what did
|
||||
-- which ability give me today", so a fight boundary must not wipe it. Keyed by spell id,
|
||||
-- therefore bounded by the spellbook rather than by mob GUIDs -- no eviction sweep needed,
|
||||
-- unlike a per-GUID table.
|
||||
--
|
||||
-- core/commands.lua may REPOINT these three fields (and the lf* ones below) at a
|
||||
-- SavedVariables sub-table after login/reload, so that every recordSpell call below is
|
||||
-- already a persisted write with no separate save step (spec 2026-08-10 sec 3). This file
|
||||
-- stays WoW-API-free either way -- it neither knows nor cares whether the table it is
|
||||
-- filling happens to be one WoW will write to disk.
|
||||
spHeal = {}, spDmg = {}, spOver = {}, _spOut = {},
|
||||
-- Per-spell SESSION hit counts, same key (spellId or A.MELEE), filled by the SAME
|
||||
-- recordSpell call as spHeal/spDmg/spOver above -- one landed hit, one increment, never a
|
||||
-- separate call, so this can never drift from the totals it counts hits for.
|
||||
spHits = {},
|
||||
-- Per-spell LIFETIME totals: the same shape, but never cleared by a fight or a session
|
||||
-- reset -- only by an explicit A.resetLifetime (wired to /vf reset lifetime|both). Filled
|
||||
-- by the SAME recordSpell call as the session tables above, so the two can never drift
|
||||
-- apart from missed or duplicated events -- there is exactly one place that writes either.
|
||||
lfHeal = {}, lfDmg = {}, lfOver = {},
|
||||
-- Lifetime twin of spHits, same reasoning as lfHeal/lfDmg/lfOver above.
|
||||
lfHits = {},
|
||||
-- ST/AoE split (2026-08-24, UI-redesign strand A; REDEFINED 2026-08-24 same day --
|
||||
-- see A.recordSpell's header for the two definitions in full). spHeal/spDmg/
|
||||
-- spHits above stay the GRAND total per spell, unchanged shape, unchanged meaning -- an
|
||||
-- existing SavedVariables character db must keep working without a migration. These four
|
||||
-- tables (names kept from the first cut of this feature -- the field names still say "Aoe",
|
||||
-- the MEANING no longer does, see below) track ONLY the OTHER-THAN-CURRENT-TARGET portion of
|
||||
-- that same total, filled by the SAME A.recordSpell call whenever it is told notOnTarget
|
||||
-- (see that function's own comment); the ST (current-target) portion is never stored, it is
|
||||
-- read back as spHeal[k] - spHealAoe[k] (A.spellSplit). spSplitSeen[k] is the flag that says
|
||||
-- whether ANY split-aware hit has landed for spell k yet -- false/absent means "this row may
|
||||
-- hold healing whose ST/other split was never recorded" (either pre-feature Altbestand, or
|
||||
-- pre-redefinition data discarded by core/config.lua's v<4 migration), not "confirmed zero
|
||||
-- off-target healing".
|
||||
spHealAoe = {}, spDmgAoe = {}, spHitsAoe = {}, spSplitSeen = {},
|
||||
-- Lifetime twins of the four above, same reasoning, same "filled by the same call" guarantee
|
||||
-- as lfHeal/lfDmg/lfHits already have relative to spHeal/spDmg/spHits.
|
||||
lfHealAoe = {}, lfDmgAoe = {}, lfHitsAoe = {}, lfSplitSeen = {},
|
||||
-- Per-spell "last fight" totals -- the THIRD scope (2026-08-25), prefix "la" matching sp=
|
||||
-- session/lf=lifetime. Same shapes, same "filled by the SAME recordSpell/
|
||||
-- recordSourceBreakdown call" guarantee as the other two, but a DIFFERENT lifecycle: cleared
|
||||
-- by A.startFight (a new fight beginning), NOT by a session/lifetime reset and NOT by a
|
||||
-- fight ENDING -- A.endFight only stops the clock, it never clears these, which is exactly
|
||||
-- what makes this scope read "the current fight if one is running, else the one that just
|
||||
-- ended" (see A.startFight's own comment for the chosen semantics and why). Deliberately
|
||||
-- NEVER aliased to a SavedVariables sub-table the way sp*/lf* are (core/commands.lua's
|
||||
-- wireSession) -- volatile by design, empty again after every /reload, same reasoning as
|
||||
-- the fight scalars (fHeal/fDmg) this scope extends to the per-spell/per-source level.
|
||||
laHeal = {}, laDmg = {}, laOver = {}, laHits = {}, laSrc = {},
|
||||
laHealAoe = {}, laDmgAoe = {}, laHitsAoe = {}, laSplitSeen = {},
|
||||
}
|
||||
end
|
||||
|
||||
-- Overheal is a PART of a row's healing, so 0 <= overheal <= heal is the shape of a valid row --
|
||||
-- exactly what the watchdog's I2 checks. Enforced here as well as checked there, because these
|
||||
-- tables may be ALIASED to SavedVariables (see A.new): a broken row is not a transient display
|
||||
-- artefact, it is written to disk and survives everything short of an explicit lifetime reset.
|
||||
local function clampRow(heal, over, k)
|
||||
if heal[k] < 0 then heal[k] = 0 end
|
||||
if over[k] < 0 then over[k] = 0 end
|
||||
if over[k] > heal[k] then over[k] = heal[k] end
|
||||
end
|
||||
|
||||
-- notOnTarget (sixth argument, OPTIONAL and nil-safe by construction -- every EXISTING caller and
|
||||
-- every existing test still calls this with five arguments, and nil behaves exactly as it always
|
||||
-- has: the grand totals below update, the ST/other split below does not).
|
||||
--
|
||||
-- DEFINITION, and its HISTORY, because this changed once already the same day (both 2026-08-24):
|
||||
-- v1 (superseded): notOnTarget meant "capture/damage.lua's G.deriveAoE classified this hit as
|
||||
-- area damage" (2+ distinct target GUIDs for the same spell id inside a short window).
|
||||
-- v2 (THIS version, corrected definition): notOnTarget means "this hit's target was NOT the
|
||||
-- player's CURRENT target at the moment it landed" -- a completely different axis. In-game
|
||||
-- redefinition: the ST figure is "what I get from my current target for vampheal" (including
|
||||
-- an AoE spell's share on that one target); the "AoE" figure is "everything else"; the two must
|
||||
-- sum to the grand total exactly, with no third category.
|
||||
-- The rename from "isAoE" to "notOnTarget" is deliberate, not cosmetic: keeping the old name after
|
||||
-- redefining it would read as area-damage classification to the next person who has to touch this,
|
||||
-- which is now false. WHERE the true/false actually comes from is core/commands.lua's onDamage
|
||||
-- listener (see its own comment) -- it reads the CURRENT target's GUID at hit time (via
|
||||
-- UnitExists("target"), the SAME SuperWoW pattern capture/incoming.lua already uses for the
|
||||
-- player's own GUID) and compares it against the hit's own targetGuid; no target equipped means
|
||||
-- notOnTarget=true unconditionally (explicit ruling: with no current target there is no ST
|
||||
-- bucket for the healing to belong to). deriveAoE's classification (capture/damage.lua) is
|
||||
-- UNCHANGED and UNRELATED -- it still drives the 0.7 AoE-damping FACTOR in the healing math
|
||||
-- (VampifyModel/core/commands.lua's onDamage), a real, separate, still-measured server mechanic.
|
||||
--
|
||||
-- The retroactive AoE-DAMPING-reclassification correction (core/commands.lua, unrelated to this
|
||||
-- split) deliberately still passes nothing here, for the same reason A.recordSourceBreakdown
|
||||
-- already declines to participate in that correction (see its own comment): reversing a per-source
|
||||
-- or per-split credit correctly would need to know which of the ORIGINAL hit's split it came from,
|
||||
-- which that call site does not keep around, so guessing would risk crediting or debiting the wrong
|
||||
-- bucket. Known, documented gap -- not a crash risk, and not silent: see A.spellSplit's own comment
|
||||
-- on what this means for a hit whose AoE-damping factor was corrected after the fact.
|
||||
function A.recordSpell(s, spellId, healFloat, damage, overheal, notOnTarget)
|
||||
local k = spellId or A.MELEE
|
||||
healFloat, damage, overheal = healFloat or 0, damage or 0, overheal or 0
|
||||
s.spHeal[k] = (s.spHeal[k] or 0) + healFloat
|
||||
s.spDmg[k] = (s.spDmg[k] or 0) + damage
|
||||
s.spOver[k] = (s.spOver[k] or 0) + overheal
|
||||
s.lfHeal[k] = (s.lfHeal[k] or 0) + healFloat
|
||||
s.lfDmg[k] = (s.lfDmg[k] or 0) + damage
|
||||
s.lfOver[k] = (s.lfOver[k] or 0) + overheal
|
||||
-- "last fight" scope: same call, same guards, third table set (see A.new's own comment on why
|
||||
-- this one is cleared on a DIFFERENT event than sp*/lf*).
|
||||
s.laHeal[k] = (s.laHeal[k] or 0) + healFloat
|
||||
s.laDmg[k] = (s.laDmg[k] or 0) + damage
|
||||
s.laOver[k] = (s.laOver[k] or 0) + overheal
|
||||
-- A HIT is a landed event, not a retroactive adjustment. The AoE correction below (core/
|
||||
-- commands.lua) is routed through this SAME function with a NEGATIVE healFloat and zero damage,
|
||||
-- to claw back healing already credited to a hit counted the first time it landed -- it must
|
||||
-- not also inflate the hit count for something that was never a fresh landing. Guarded on the
|
||||
-- same sign the clamp below already keys off of.
|
||||
if healFloat >= 0 then
|
||||
s.spHits[k] = (s.spHits[k] or 0) + 1
|
||||
s.lfHits[k] = (s.lfHits[k] or 0) + 1
|
||||
s.laHits[k] = (s.laHits[k] or 0) + 1
|
||||
end
|
||||
-- Only a retroactive correction (core/commands.lua's AoE derivation) passes a negative heal,
|
||||
-- and only a subtraction can break the shape above -- so the clamp costs the recording path one
|
||||
-- comparison per hit and never runs on it. It is a floor under a caller mistake, not a
|
||||
-- substitute for the caller getting it right: the correction listener already refuses to touch
|
||||
-- a row it did not credit, and this is what keeps the NEXT way of dropping a hit from writing a
|
||||
-- broken row to disk in silence.
|
||||
if healFloat < 0 then
|
||||
clampRow(s.spHeal, s.spOver, k)
|
||||
clampRow(s.lfHeal, s.lfOver, k)
|
||||
clampRow(s.laHeal, s.laOver, k)
|
||||
end
|
||||
|
||||
-- ST/other split: only for a FRESH landed hit (healFloat >= 0, same guard the hit counter above
|
||||
-- uses) whose target-relationship is actually known (notOnTarget ~= nil -- see this function's
|
||||
-- header comment for why the correction path is excluded). spSplitSeen/lfSplitSeen mark the
|
||||
-- spell as split-aware from this point on regardless of which side the hit landed on -- a spell
|
||||
-- seen only as ST so far still has a KNOWN (zero) off-target share, which is different from a row
|
||||
-- nothing has touched since the upgrade/redefinition (A.spellSplit's hasSplit reads exactly this
|
||||
-- flag).
|
||||
if notOnTarget ~= nil and healFloat >= 0 then
|
||||
s.spSplitSeen[k] = true
|
||||
s.lfSplitSeen[k] = true
|
||||
s.laSplitSeen[k] = true
|
||||
if notOnTarget then
|
||||
s.spHealAoe[k] = (s.spHealAoe[k] or 0) + healFloat
|
||||
s.spDmgAoe[k] = (s.spDmgAoe[k] or 0) + damage
|
||||
s.spHitsAoe[k] = (s.spHitsAoe[k] or 0) + 1
|
||||
s.lfHealAoe[k] = (s.lfHealAoe[k] or 0) + healFloat
|
||||
s.lfDmgAoe[k] = (s.lfDmgAoe[k] or 0) + damage
|
||||
s.lfHitsAoe[k] = (s.lfHitsAoe[k] or 0) + 1
|
||||
s.laHealAoe[k] = (s.laHealAoe[k] or 0) + healFloat
|
||||
s.laDmgAoe[k] = (s.laDmgAoe[k] or 0) + damage
|
||||
s.laHitsAoe[k] = (s.laHitsAoe[k] or 0) + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Accumulates one hit's per-source breakdown (core/model.lua's M.healBreakdown `out`, plus the
|
||||
-- PARALLEL `meta` array core/commands.lua builds alongside sourcePercents -- meta[i] = { key =
|
||||
-- A.sourceKey(...), label = A.sourceLabel(...) }, one entry per breakdown row) into both the
|
||||
-- SESSION and LIFETIME per-spell-per-source tables. Same "one call feeds both sets" shape as
|
||||
-- A.recordSpell above, for the same reason: the two can never drift apart from a missed or
|
||||
-- duplicated event because there is exactly one place that writes either.
|
||||
--
|
||||
-- Deliberately does NOT participate in the retroactive AoE correction path (core/commands.lua's
|
||||
-- negative-healFloat call to A.recordSpell): reversing a per-source credit correctly would need to
|
||||
-- know which of that hit's ORIGINAL per-source integers to claw back, which the correction site
|
||||
-- does not keep around (see core/commands.lua's own comment on aoeCorrectedShown for why only the
|
||||
-- aggregate total is retained). Left as a known gap -- a reclassified AoE hit's per-source rows stay
|
||||
-- at their originally-credited (slightly too high) figures even though the SPELL total is corrected
|
||||
-- -- rather than risk crediting or debiting the wrong source.
|
||||
function A.recordSourceBreakdown(s, spellId, breakdown, meta)
|
||||
local k = spellId or A.MELEE
|
||||
if not s.spSrc[k] then s.spSrc[k] = {} end
|
||||
if not s.lfSrc[k] then s.lfSrc[k] = {} end
|
||||
if not s.laSrc[k] then s.laSrc[k] = {} end
|
||||
local sp, lf, la = s.spSrc[k], s.lfSrc[k], s.laSrc[k]
|
||||
for i = 1, breakdown.n do
|
||||
local row, m = breakdown[i], meta[i]
|
||||
if row and m then
|
||||
local key = m.key
|
||||
local se = sp[key]
|
||||
if not se then se = { heal = 0, floorHits = 0, hits = 0 }; sp[key] = se end
|
||||
se.heal = se.heal + row.heal
|
||||
se.hits = se.hits + 1
|
||||
if row.floored then se.floorHits = se.floorHits + 1 end
|
||||
se.label, se.pct = m.label, row.pct
|
||||
|
||||
local le = lf[key]
|
||||
if not le then le = { heal = 0, floorHits = 0, hits = 0 }; lf[key] = le end
|
||||
le.heal = le.heal + row.heal
|
||||
le.hits = le.hits + 1
|
||||
if row.floored then le.floorHits = le.floorHits + 1 end
|
||||
le.label, le.pct = m.label, row.pct
|
||||
|
||||
-- "last fight" twin -- same accumulation, cleared by A.startFight instead of a
|
||||
-- session/lifetime reset (see A.new's comment).
|
||||
local ae = la[key]
|
||||
if not ae then ae = { heal = 0, floorHits = 0, hits = 0 }; la[key] = ae end
|
||||
ae.heal = ae.heal + row.heal
|
||||
ae.hits = ae.hits + 1
|
||||
if row.floored then ae.floorHits = ae.floorHits + 1 end
|
||||
ae.label, ae.pct = m.label, row.pct
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Clears the per-spell SESSION tables in place -- never reassigns s.spHeal etc to a new {}. If
|
||||
-- these fields alias a SavedVariables sub-table (see A.new's comment), replacing them would
|
||||
-- silently orphan the persisted table instead of clearing it; nil-ing every key in place clears
|
||||
-- whichever table is currently aliased, persisted or not, at no extra cost.
|
||||
function A.resetSpells(s)
|
||||
for k in pairs(s.spHeal) do s.spHeal[k] = nil end
|
||||
for k in pairs(s.spDmg) do s.spDmg[k] = nil end
|
||||
for k in pairs(s.spOver) do s.spOver[k] = nil end
|
||||
for k in pairs(s.spHits) do s.spHits[k] = nil end
|
||||
for k in pairs(s.spSrc) do s.spSrc[k] = nil end
|
||||
-- ST/AoE split twins -- same in-place reasoning, so a reset does not leave a stale AoE-only
|
||||
-- figure sitting above a freshly-zeroed grand total (A.spellSplit's stHeal = total - aoe would
|
||||
-- go negative otherwise; see its own defensive clamp for the belt-and-braces backstop).
|
||||
for k in pairs(s.spHealAoe) do s.spHealAoe[k] = nil end
|
||||
for k in pairs(s.spDmgAoe) do s.spDmgAoe[k] = nil end
|
||||
for k in pairs(s.spHitsAoe) do s.spHitsAoe[k] = nil end
|
||||
for k in pairs(s.spSplitSeen) do s.spSplitSeen[k] = nil end
|
||||
end
|
||||
|
||||
-- The lifetime twin of resetSpells above -- same in-place reasoning, wired to /vf reset
|
||||
-- lifetime|both rather than to a session boundary.
|
||||
function A.resetLifetime(s)
|
||||
for k in pairs(s.lfHeal) do s.lfHeal[k] = nil end
|
||||
for k in pairs(s.lfDmg) do s.lfDmg[k] = nil end
|
||||
for k in pairs(s.lfOver) do s.lfOver[k] = nil end
|
||||
for k in pairs(s.lfHits) do s.lfHits[k] = nil end
|
||||
for k in pairs(s.lfSrc) do s.lfSrc[k] = nil end
|
||||
for k in pairs(s.lfHealAoe) do s.lfHealAoe[k] = nil end
|
||||
for k in pairs(s.lfDmgAoe) do s.lfDmgAoe[k] = nil end
|
||||
for k in pairs(s.lfHitsAoe) do s.lfHitsAoe[k] = nil end
|
||||
for k in pairs(s.lfSplitSeen) do s.lfSplitSeen[k] = nil end
|
||||
end
|
||||
|
||||
-- The "last fight" twin of resetSpells/resetLifetime above -- same in-place reasoning. Two callers:
|
||||
-- A.startFight below (every new fight starting clears the PREVIOUS fight's last-scope breakdown --
|
||||
-- the normal, automatic path) and A.resetScope("last") (an explicit user-requested clear, e.g. a
|
||||
-- GUI reset button on the "last" tab -- less obviously useful than the session/lifetime ones since
|
||||
-- the next fight will overwrite it anyway, but asked for by the brief and cheap to give).
|
||||
function A.resetLast(s)
|
||||
for k in pairs(s.laHeal) do s.laHeal[k] = nil end
|
||||
for k in pairs(s.laDmg) do s.laDmg[k] = nil end
|
||||
for k in pairs(s.laOver) do s.laOver[k] = nil end
|
||||
for k in pairs(s.laHits) do s.laHits[k] = nil end
|
||||
for k in pairs(s.laSrc) do s.laSrc[k] = nil end
|
||||
for k in pairs(s.laHealAoe) do s.laHealAoe[k] = nil end
|
||||
for k in pairs(s.laDmgAoe) do s.laDmgAoe[k] = nil end
|
||||
for k in pairs(s.laHitsAoe) do s.laHitsAoe[k] = nil end
|
||||
for k in pairs(s.laSplitSeen) do s.laSplitSeen[k] = nil end
|
||||
end
|
||||
|
||||
-- READ/RESET API for the GUI's per-scope reset buttons (2026-08-25 -- "reset button in session and
|
||||
-- lifetime", plus "last" for symmetry with the other three scope-taking read APIs below). Reads the
|
||||
-- GLOBAL VampifyState, no `s` parameter -- the same agreed shape A.spellSourceBreakdown/splitTotals/
|
||||
-- spellSplit already use, since this is a GUI-facing entry point, not an internal helper threaded
|
||||
-- explicitly like A.resetSpells/resetLifetime/resetLast above (which this dispatches to).
|
||||
--
|
||||
-- scope: "lifetime" or "last" reset exactly that one bucket via the matching helper above. Anything
|
||||
-- else (including "session", nil, or an unrecognised string) resets the SESSION bucket -- the same
|
||||
-- lenient "session is the safe default" convention core/commands.lua's VampifyResetSession already
|
||||
-- uses for its own scope argument. "session" here means A.resetTotals (the fHeal/fDmg/sHeal/sDmg
|
||||
-- scalars A.fight/A.session read) PLUS A.resetSpells (the per-spell session tables) -- exactly what
|
||||
-- core/commands.lua's VampifyResetSession("session") already clears at the aggregate level; that
|
||||
-- function now calls THIS one for the aggregate half instead of duplicating the two calls, so this
|
||||
-- is the single source of truth for what a session reset clears down here (unchanged behaviour, see
|
||||
-- its own comment for the WATCHDOG-side clearing this function correctly knows nothing about).
|
||||
--
|
||||
-- LIFETIME RESET IS DESTRUCTIVE: A.resetLifetime below erases every lfHeal/lfDmg/lfOver/lfHits/
|
||||
-- lfSrc/lfHealAoe/lfDmgAoe/lfHitsAoe/lfSplitSeen row -- healing totals accumulated across every
|
||||
-- session since the character existed, with NO undo (this file's tables are the only copy; nothing
|
||||
-- upstream keeps a backup). This function does not ask for confirmation -- that is a GUI concern by
|
||||
-- design (see this function's own module header) -- so whatever calls A.resetScope("lifetime") must
|
||||
-- gate it behind its own confirmation.
|
||||
function A.resetScope(scope)
|
||||
local s = VampifyState
|
||||
if not s then return end
|
||||
if scope == "lifetime" then
|
||||
A.resetLifetime(s)
|
||||
elseif scope == "last" then
|
||||
A.resetLast(s)
|
||||
else
|
||||
A.resetTotals(s)
|
||||
A.resetSpells(s)
|
||||
end
|
||||
end
|
||||
|
||||
-- Clears the FIGHT and overall SESSION totals (fHeal/fDmg and sHeal/sDmg) in place -- the numbers
|
||||
-- A.fight/A.session read. Split out from the old inline "make a brand new state" reset so /vf
|
||||
-- reset can clear just this half without recreating VampifyState itself, which would sever any
|
||||
-- SavedVariables wiring on the per-spell tables (see A.new's comment). Does not touch the per-spell
|
||||
-- tables at all; pair with A.resetSpells and/or A.resetLifetime for those.
|
||||
function A.resetTotals(s)
|
||||
s.fHeal, s.fDmg, s.fStart, s.fEnd, s.fActive = 0, 0, 0, 0, false
|
||||
s.sHeal, s.sDmg = 0, 0
|
||||
end
|
||||
|
||||
local function sumTable(t)
|
||||
local sum = 0
|
||||
for _, v in pairs(t) do sum = sum + v end
|
||||
return sum
|
||||
end
|
||||
|
||||
-- Reseeds the overall session totals (sHeal/sDmg) from whatever is currently in the per-spell
|
||||
-- SESSION tables. Needed exactly once, right after core/commands.lua wires spHeal/spDmg to a
|
||||
-- restored SavedVariables table on a reload: sHeal/sDmg themselves are plain numbers, so they
|
||||
-- cannot be aliased the way the per-spell tables are, and without this call they would read 0
|
||||
-- against a non-empty breakdown -- which is precisely the mismatch the tooltip's own cross-check
|
||||
-- (gui/display.lua) exists to catch, so it would raise a false alarm on the very first hover after
|
||||
-- every reload. A fresh login also calls this safely: summing freshly-cleared tables yields 0,
|
||||
-- matching the state a brand new VampifyState would have had anyway.
|
||||
function A.seedSessionTotals(s)
|
||||
s.sHeal = sumTable(s.spHeal)
|
||||
s.sDmg = sumTable(s.spDmg)
|
||||
end
|
||||
|
||||
-- Fills `out` with { spell, heal, damage, overheal }, biggest healer first. Reuses the entry
|
||||
-- tables so an open tooltip refreshing on a timer does not allocate.
|
||||
--
|
||||
-- `scope`: falsy or "session" (default) reads the SESSION tables, `true` or "lifetime" reads the
|
||||
-- LIFETIME ones (the original boolean contract is kept exactly -- every existing caller/test still
|
||||
-- passes `true`/nil/omitted and gets the same table it always did), and "last" (2026-08-25, third
|
||||
-- detail tab) reads the current-or-last-fight tables (see A.startFight's own comment for their
|
||||
-- lifecycle). Three separate reads rather than a merged view, because the scopes answer different
|
||||
-- questions (spec 2026-08-10 sec 3, extended 2026-08-25) and mixing them would answer none of them.
|
||||
function A.spellBreakdown(s, out, scope)
|
||||
local heal, dmg, over, hits
|
||||
if scope == true or scope == "lifetime" then heal, dmg, over, hits = s.lfHeal, s.lfDmg, s.lfOver, s.lfHits
|
||||
elseif scope == "last" then heal, dmg, over, hits = s.laHeal, s.laDmg, s.laOver, s.laHits
|
||||
else heal, dmg, over, hits = s.spHeal, s.spDmg, s.spOver, s.spHits end
|
||||
local n = 0
|
||||
for k, h in pairs(heal) do
|
||||
n = n + 1
|
||||
local e = out[n]
|
||||
if not e then e = {}; out[n] = e end
|
||||
e.spell = k
|
||||
e.heal = h
|
||||
e.damage = dmg[k] or 0
|
||||
e.overheal = over[k] or 0
|
||||
-- 0, not nil, for a row whose hit count is unknown -- e.g. Altbestand: a persisted spHeal/
|
||||
-- lfHeal row from before this counter existed has no matching key in spHits/lfHits at all.
|
||||
e.hits = hits[k] or 0
|
||||
end
|
||||
for i = n + 1, table.getn(out) do out[i] = nil end
|
||||
table.setn(out, n)
|
||||
-- Insertion sort: n is the number of abilities used, i.e. single digits in practice, and it
|
||||
-- avoids handing table.sort a comparator that must stay consistent under equal values.
|
||||
for i = 2, n do
|
||||
local e, j = out[i], i - 1
|
||||
while j >= 1 and out[j].heal < e.heal do
|
||||
out[j + 1] = out[j]
|
||||
j = j - 1
|
||||
end
|
||||
out[j + 1] = e
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Internal scratch buffer for A.spellSourceBreakdown's floorShare pass below -- pooled at module
|
||||
-- scope like core/model.lua's own shareBuf/floorBuf/remBuf/tookBuf, never rebuilt per call.
|
||||
local _floorRowBuf = {}
|
||||
|
||||
-- READ API for the UI: which source contributed how much to ONE spell's healing, session or
|
||||
-- lifetime. Deliberately reads the GLOBAL VampifyState rather than taking a state argument -- the
|
||||
-- agreed shape for this call (unlike every other function above, which threads `s` explicitly) --
|
||||
-- matching how gui/display.lua already reads VampifyState as a bare global in several places
|
||||
-- (e.g. its V.format/V.shareOfTotal call sites) rather than receiving it as a parameter.
|
||||
--
|
||||
-- scope: "session" (default for anything other than exactly "lifetime" or "last"), "lifetime", or
|
||||
-- "last" (2026-08-25, third detail tab -- current-or-last-fight, see A.startFight's own comment) --
|
||||
-- same three-table split as A.spellBreakdown's own `scope` argument, just addressed by name here
|
||||
-- since this is the public-facing read API rather than an internal one.
|
||||
--
|
||||
-- Fills `out` with, per source, descending by heal: { label, pct, heal, floorHits, share,
|
||||
-- floorShare }, plus out.total/out.n/out.hits for the whole spell. `share` and `floorShare` are
|
||||
-- largest-remainder-rounded percentages (summing to exactly 100.0 across the rows) computed by
|
||||
-- core/model.lua's VampifyModel.shareOfTotal. This used to call gui/display.lua's
|
||||
-- VampifyDisplay.shareOfTotal instead -- a core/ file reaching into gui/, which only worked because
|
||||
-- the call resolved at runtime, after the whole addon had loaded, and broke the "core/ never
|
||||
-- depends on gui/" invariant every other function in this file (and core/model.lua) respects.
|
||||
-- Fixed (2026-08-24) by moving the rounding itself into core/model.lua, which already loads before
|
||||
-- both this file and gui/display.lua in Vampify.toc; gui/display.lua now keeps
|
||||
-- VampifyDisplay.shareOfTotal only as an alias for its own callers. The nil-guard below is kept
|
||||
-- anyway (belt and braces, matching this file's style) for an offline test that might dofile
|
||||
-- core/aggregate.lua without core/model.lua.
|
||||
function A.spellSourceBreakdown(spellId, scope, out)
|
||||
out = out or {}
|
||||
local k = spellId or A.MELEE
|
||||
local s = VampifyState
|
||||
local bucket
|
||||
if s then
|
||||
local map
|
||||
if scope == "lifetime" then map = s.lfSrc
|
||||
elseif scope == "last" then map = s.laSrc
|
||||
else map = s.spSrc end
|
||||
bucket = map and map[k]
|
||||
end
|
||||
|
||||
local n, total, hits = 0, 0, 0
|
||||
if bucket then
|
||||
for _, e in pairs(bucket) do
|
||||
n = n + 1
|
||||
local row = out[n]
|
||||
if not row then row = {}; out[n] = row end
|
||||
row.label = e.label
|
||||
row.pct = e.pct
|
||||
row.heal = e.heal
|
||||
row.floorHits = e.floorHits
|
||||
total = total + e.heal
|
||||
hits = hits + e.hits
|
||||
end
|
||||
end
|
||||
for i = n + 1, table.getn(out) do out[i] = nil end
|
||||
table.setn(out, n)
|
||||
|
||||
-- Same insertion sort as A.spellBreakdown above, same reasoning (n is single digits in
|
||||
-- practice; table.sort needs a strictly-consistent comparator that equal heals would violate).
|
||||
for i = 2, n do
|
||||
local e, j = out[i], i - 1
|
||||
while j >= 1 and out[j].heal < e.heal do
|
||||
out[j + 1] = out[j]
|
||||
j = j - 1
|
||||
end
|
||||
out[j + 1] = e
|
||||
end
|
||||
|
||||
if n > 0 and VampifyModel and VampifyModel.shareOfTotal then
|
||||
local shares = VampifyModel.shareOfTotal(out)
|
||||
for i = 1, n do out[i].share = shares[i] end
|
||||
|
||||
for i = table.getn(_floorRowBuf), n + 1, -1 do _floorRowBuf[i] = nil end
|
||||
for i = 1, n do
|
||||
local fe = _floorRowBuf[i]
|
||||
if not fe then fe = {}; _floorRowBuf[i] = fe end
|
||||
fe.heal = out[i].floorHits
|
||||
end
|
||||
table.setn(_floorRowBuf, n)
|
||||
local floorShares = VampifyModel.shareOfTotal(_floorRowBuf)
|
||||
for i = 1, n do out[i].floorShare = floorShares[i] end
|
||||
else
|
||||
for i = 1, n do out[i].share, out[i].floorShare = 0, 0 end
|
||||
end
|
||||
|
||||
out.total, out.n, out.hits = total, n, hits
|
||||
return out
|
||||
end
|
||||
|
||||
-- SEMANTICS OF THE "last" SCOPE (2026-08-25, third detail tab alongside session/lifetime): the
|
||||
-- per-spell/per-source breakdown a NEW fight starting here also resets (A.resetLast, la* tables in
|
||||
-- A.new) follows the EXACT lifecycle fHeal/fDmg already have -- cleared HERE (fight start), left
|
||||
-- alone by A.endFight (fight end just stops the clock). That means "last" shows the RUNNING fight
|
||||
-- live once combat starts, freezes the instant it ends, and goes back to empty the moment the NEXT
|
||||
-- fight begins -- chosen over the alternative (freeze the OLD fight until the new one fully ends,
|
||||
-- so "last" never changes mid-combat) because a live view of the fight actually in progress is the
|
||||
-- whole reason this tab is more useful than lifetime during a pull; the frozen-old-fight variant
|
||||
-- would show a stale number for the entire new fight, which is worse than a number that is honestly
|
||||
-- still moving. The GUI is expected to make this visible (e.g. a "live" indicator while
|
||||
-- VampifyState.fActive is true) rather than implying a truly static "last completed fight" figure.
|
||||
function A.startFight(s, now)
|
||||
s.fHeal, s.fDmg, s.fStart, s.fEnd, s.fActive = 0, 0, now or 0, now or 0, true
|
||||
A.resetLast(s)
|
||||
end
|
||||
|
||||
function A.endFight(s, now)
|
||||
s.fEnd, s.fActive = now or s.fEnd, false
|
||||
end
|
||||
|
||||
function A.record(s, increment, healFloat, damage, now)
|
||||
healFloat = healFloat or 0
|
||||
damage = damage or 0
|
||||
s.fHeal = s.fHeal + healFloat
|
||||
s.sHeal = s.sHeal + healFloat
|
||||
s.fDmg = s.fDmg + damage
|
||||
s.sDmg = s.sDmg + damage
|
||||
if s.fActive and now then s.fEnd = now end
|
||||
end
|
||||
|
||||
local function fill(out, heal, dmg, secs)
|
||||
out.heal = heal
|
||||
out.damage = dmg
|
||||
out.seconds = secs
|
||||
if secs and secs > 0 then out.hps = heal / secs else out.hps = 0 end
|
||||
if dmg > 0 then out.pct = heal / dmg else out.pct = 0 end
|
||||
return out
|
||||
end
|
||||
|
||||
function A.fight(s)
|
||||
return fill(s._fight, s.fHeal, s.fDmg, s.fEnd - s.fStart)
|
||||
end
|
||||
|
||||
function A.session(s)
|
||||
return fill(s._session, s.sHeal, s.sDmg, nil)
|
||||
end
|
||||
|
||||
-- Sources are summed BEFORE rounding, so there is no per-source integer to attribute. The
|
||||
-- breakdown is therefore proportional -- a share of the total, not a separately computed heal.
|
||||
function A.shares(s, sources, out)
|
||||
local n, sum = table.getn(sources), 0
|
||||
for i = 1, n do sum = sum + (sources[i].percent or 0) end
|
||||
for i = 1, n do
|
||||
local e = out[i]
|
||||
if not e then e = {}; out[i] = e end
|
||||
e.percent = sources[i].percent
|
||||
if sum > 0 then e.share = sources[i].percent / sum else e.share = 0 end
|
||||
e.heal = s.fHeal * e.share
|
||||
end
|
||||
for i = n + 1, table.getn(out) do out[i] = nil end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---- ST vs AoE ("other") split (2026-08-24, UI-redesign strand A) --------------------------------
|
||||
--
|
||||
-- DEFINITION (corrected same day, see A.recordSpell's header for the superseded v1): ST is
|
||||
-- the Vamp healing that landed on hits against the player's CURRENT TARGET at the moment they
|
||||
-- landed -- including an AoE spell's own share of a hit on that one target, no different from a
|
||||
-- single-target spell's hit. AoE/"other" is everything else: hits on any OTHER unit, and (by
|
||||
-- explicit ruling) the ENTIRE hit when the player has no current target at all, because there is
|
||||
-- then no current target for a "ST" figure to describe. ST + AoE == the grand total, exactly, with
|
||||
-- no third category -- this is enforced by construction below (ST is read back as total minus the
|
||||
-- tracked "other" portion, never stored on its own), not by a separate check.
|
||||
--
|
||||
-- Two READ APIs the bar's badges need: the GRAND total's split (A.splitTotals) and a single spell's
|
||||
-- split (A.spellSplit). Both, like A.spellSourceBreakdown above, deliberately read the GLOBAL
|
||||
-- VampifyState rather than taking a state argument -- the same agreed shape, for the same reason
|
||||
-- (matching gui/display.lua's existing bare-global reads of VampifyState).
|
||||
--
|
||||
-- Pooled 2-row buffer for VampifyModel.shareOfTotal's largest-remainder rounding, shared by both
|
||||
-- functions below (never used across a re-entrant call -- Lua is single-threaded and each function
|
||||
-- reads the returned shares into `out` immediately, before anything else could call in again).
|
||||
local _shareRows2 = { {}, {} }
|
||||
|
||||
local function roundSplit(stAmount, aoeAmount, out)
|
||||
if (stAmount + aoeAmount) > 0 and VampifyModel and VampifyModel.shareOfTotal then
|
||||
_shareRows2[1].heal, _shareRows2[2].heal = stAmount, aoeAmount
|
||||
local shares = VampifyModel.shareOfTotal(_shareRows2)
|
||||
out.stPct, out.aoePct = shares[1], shares[2]
|
||||
else
|
||||
out.stPct, out.aoePct = 0, 0
|
||||
end
|
||||
end
|
||||
|
||||
-- The GESAMT split. NOT read from the damage histogram (core/histogram.lua) any more -- that was
|
||||
-- v1's approach, and it is now the WRONG data source: the histogram's normal/AoE dimension is
|
||||
-- deriveAoE's area-damage CLASSIFICATION, which is a different axis from "which unit was hit" (see
|
||||
-- A.recordSpell's header). The histogram still exists and is still correct for what it actually
|
||||
-- backs (core/histogram.lua's G.totals/G.compare, the upgrade-preview feature, which legitimately
|
||||
-- needs the AoE-damping classification) -- it is simply not this function's data source any more.
|
||||
--
|
||||
-- Instead this FOLDS the per-spell split A.recordSpell already maintains (spHeal/spHealAoe and their
|
||||
-- lifetime twins) across every spell id, the same total-minus-tracked-other computation
|
||||
-- A.spellSplit does per spell, just summed. This reuses the SAME bookkeeping recordSpell already
|
||||
-- keeps (no second running total to keep in sync) and is bounded the same way A.spellBreakdown's own
|
||||
-- full-table iteration already is -- by the spellbook, not by play history -- so a `pairs` walk here
|
||||
-- costs nothing this addon does not already pay elsewhere. NOT on the zero-allocation hit path
|
||||
-- (called from a UI refresh timer, like A.spellSourceBreakdown), so the walk is not a budget concern.
|
||||
function A.splitTotals(scope, out)
|
||||
out = out or {}
|
||||
local s = VampifyState
|
||||
local heal, healOther
|
||||
if s then
|
||||
if scope == "lifetime" then heal, healOther = s.lfHeal, s.lfHealAoe
|
||||
elseif scope == "last" then heal, healOther = s.laHeal, s.laHealAoe
|
||||
else heal, healOther = s.spHeal, s.spHealAoe end
|
||||
end
|
||||
|
||||
local total, other = 0, 0
|
||||
if heal then
|
||||
for k, v in pairs(heal) do
|
||||
total = total + v
|
||||
other = other + ((healOther and healOther[k]) or 0)
|
||||
end
|
||||
end
|
||||
out.aoeHeal = other
|
||||
out.stHeal = total - other
|
||||
-- Same belt-and-braces floor as A.spellSplit's own (see its comment): the retroactive AoE-
|
||||
-- DAMPING correction can shrink a spell's grand total without touching its tracked "other"
|
||||
-- figure (A.recordSpell's documented gap), which could in principle pull the SUM below what is
|
||||
-- tracked as other for that one spell -- an edge case, not the normal path.
|
||||
if out.stHeal < 0 then out.stHeal = 0 end
|
||||
out.total = total
|
||||
roundSplit(out.stHeal, out.aoeHeal, out)
|
||||
return out
|
||||
end
|
||||
|
||||
-- The PER-SPELL split. `out.hasSplit` is FALSE (not nil -- an explicit, checkable flag) exactly when
|
||||
-- spell `spellId` has never been touched by the split-aware A.recordSpell path in this scope: either
|
||||
-- it has no data at all, or every hit currently in spHeal/lfHeal for it predates split-tracking
|
||||
-- (Altbestand from before this feature existed at all, OR data collected under the SUPERSEDED v1
|
||||
-- definition and discarded by core/config.lua's v<4 migration -- see that file) and its true
|
||||
-- ST/other split was never recorded under the CURRENT definition. The UI MUST NOT read
|
||||
-- hasSplit==false as "confirmed zero AoE" -- that is exactly the false-blue bug the brief calls out.
|
||||
--
|
||||
-- SavedVariables migration, spelled out here because this is the function whose output an upgrading
|
||||
-- player actually sees: spHeal[k]/lfHeal[k] (the GRAND total) is untouched by this feature and keeps
|
||||
-- meaning exactly what it always meant. spHealAoe[k]/lfHealAoe[k] (the "other"-only sub-portion)
|
||||
-- starts at 0/absent for EVERY existing character db (core/config.lua's fillDefaults recurses it in
|
||||
-- as an empty table, and its v<4 migration step explicitly WIPES any data collected under the
|
||||
-- superseded v1 definition) and is filled only from the moment a fresh split-aware hit lands for
|
||||
-- that spell under the CURRENT definition. So a spell with only pre-tracking history reads
|
||||
-- out.stHeal == the old total, out.aoeHeal == 0, and out.hasSplit == false -- "the old value counts
|
||||
-- entirely as ST, because that's the only number we have, and it is NOT a measurement of zero
|
||||
-- off-target healing". Once even one new hit lands for that spell, hasSplit flips true and the split
|
||||
-- becomes reliable for everything credited from then on (the pre-existing lump, whichever side it
|
||||
-- truly belonged to, stays folded into ST forever -- a known, documented approximation, same spirit
|
||||
-- as A.recordSourceBreakdown's own "known gap" comment).
|
||||
function A.spellSplit(spellId, scope, out)
|
||||
out = out or {}
|
||||
local k = spellId or A.MELEE
|
||||
local s = VampifyState
|
||||
local heal, dmg, hits, healAoe, dmgAoe, hitsAoe, seen
|
||||
|
||||
if s then
|
||||
if scope == "lifetime" then
|
||||
heal, dmg, hits = s.lfHeal, s.lfDmg, s.lfHits
|
||||
healAoe, dmgAoe, hitsAoe = s.lfHealAoe, s.lfDmgAoe, s.lfHitsAoe
|
||||
seen = s.lfSplitSeen
|
||||
elseif scope == "last" then
|
||||
heal, dmg, hits = s.laHeal, s.laDmg, s.laHits
|
||||
healAoe, dmgAoe, hitsAoe = s.laHealAoe, s.laDmgAoe, s.laHitsAoe
|
||||
seen = s.laSplitSeen
|
||||
else
|
||||
heal, dmg, hits = s.spHeal, s.spDmg, s.spHits
|
||||
healAoe, dmgAoe, hitsAoe = s.spHealAoe, s.spDmgAoe, s.spHitsAoe
|
||||
seen = s.spSplitSeen
|
||||
end
|
||||
end
|
||||
|
||||
local totalHeal = (heal and heal[k]) or 0
|
||||
local totalDmg = (dmg and dmg[k]) or 0
|
||||
local totalHits = (hits and hits[k]) or 0
|
||||
|
||||
out.aoeHeal = (healAoe and healAoe[k]) or 0
|
||||
out.aoeDmg = (dmgAoe and dmgAoe[k]) or 0
|
||||
out.aoeHits = (hitsAoe and hitsAoe[k]) or 0
|
||||
|
||||
-- ST is READ BACK as total minus the tracked "other" portion, never stored separately -- see the
|
||||
-- function header for why. Clamped at 0 as a belt-and-braces floor only: the retroactive AoE-
|
||||
-- DAMPING correction (core/commands.lua) can shrink the grand total via a negative healFloat
|
||||
-- without knowing notOnTarget (A.recordSpell's documented gap), which could in principle pull
|
||||
-- totalHeal below an "other" figure recorded before the correction landed -- an edge case, not
|
||||
-- the normal path.
|
||||
out.stHeal = totalHeal - out.aoeHeal
|
||||
out.stDmg = totalDmg - out.aoeDmg
|
||||
out.stHits = totalHits - out.aoeHits
|
||||
if out.stHeal < 0 then out.stHeal = 0 end
|
||||
if out.stDmg < 0 then out.stDmg = 0 end
|
||||
if out.stHits < 0 then out.stHits = 0 end
|
||||
|
||||
out.total = totalHeal
|
||||
out.hasSplit = (seen and seen[k]) and true or false
|
||||
|
||||
roundSplit(out.stHeal, out.aoeHeal, out)
|
||||
return out
|
||||
end
|
||||
+1314
File diff suppressed because it is too large
Load Diff
+237
@@ -0,0 +1,237 @@
|
||||
-- Vampify -- SavedVariables schema, defaults merge, and migration.
|
||||
--
|
||||
-- Migration policy: step FORWARD from a known older version; on an unknown or newer version,
|
||||
-- reset wholesale. A partial migration from a schema we do not know produces a config that looks
|
||||
-- valid and is not, which is worse than losing a frame position.
|
||||
|
||||
VampifyConfig = {}
|
||||
local C = VampifyConfig
|
||||
|
||||
C.DB_VERSION = 4
|
||||
|
||||
-- v1 put the display at CENTER/-150. Confirmed in-game 2026-08-10: on a pfUI layout that is right
|
||||
-- on top of the action bars, and at strata MEDIUM/level 1 the frame loses every overlap -- it
|
||||
-- reported shown, visible, alpha 1, and could not be found on screen.
|
||||
C.V1_POS = { point = "CENTER", x = 0, y = -150 }
|
||||
|
||||
C.DEFAULTS = {
|
||||
dbVersion = 3,
|
||||
pos = { point = "CENTER", x = 0, y = 200 },
|
||||
locked = false,
|
||||
minimap = true,
|
||||
mmAngle = 200, -- degrees around the minimap
|
||||
|
||||
-- v3: our own scrolling combat text, alongside (not instead of) Blizzard's. "showFct" (v2 and
|
||||
-- earlier) is gone -- superseded by sct.enabled + sct.mode, see the v2->v3 step below. Two
|
||||
-- overlapping on/off switches for the same feature is exactly the kind of SavedVariables bloat
|
||||
-- the project rules ask to avoid.
|
||||
sct = {
|
||||
enabled = false,
|
||||
mode = "own", -- "own" (this addon's SCT, free-floating anchor) | "bar" (this
|
||||
-- addon's SCT, anchored to the display bar, gui/sct.lua's
|
||||
-- reanchor()) | "blizzard" (gui/fct.lua). New enum value, no
|
||||
-- dbVersion bump: an existing "own"/"blizzard" profile is still
|
||||
-- valid, fillDefaults never touches an explicitly stored mode.
|
||||
pos = { point = "CENTER", x = -180, y = 0 },
|
||||
color = { r = 0.4, g = 0.9, b = 0.4 },
|
||||
duration = 1.5,
|
||||
-- Both slider-driven. No dbVersion bump is needed to add them: the defaults merge fills
|
||||
-- missing keys recursively, so an existing v3 profile simply gains them at their default.
|
||||
fontSize = 16, -- 8..48; the old 8..28 ceiling was reached in practice
|
||||
rise = 40, -- 20..320 px travelled per number; also widens the anti-overlap spread
|
||||
-- One number per CAST rather than per hit: an AoE on five targets shows its summed return
|
||||
-- once instead of five times. Display only -- the model still runs per hit, so totals are
|
||||
-- identical either way. On by default because it is what was asked for.
|
||||
coalesce = true,
|
||||
-- Text effect. Green numbers over grass are barely readable without one; an outline is
|
||||
-- what separates the glyph from whatever is behind it. "" | "OUTLINE" | "THICKOUTLINE".
|
||||
outline = "THICKOUTLINE",
|
||||
shadow = true,
|
||||
},
|
||||
-- Whether the display is wanted on screen. A stored PREFERENCE, not a reading of the frame:
|
||||
-- the frame's own IsShown() was the single definition of "is it on" until it turned out that
|
||||
-- recompute() (which runs on every loading screen and every committed gear scan) overwrites it,
|
||||
-- so a hide never survived the next portal. No dbVersion bump needed -- the defaults merge fills
|
||||
-- it in recursively, and `true` is what every existing profile was effectively running.
|
||||
shown = true,
|
||||
showOverheal = true,
|
||||
|
||||
-- Dev-only per-hit debug export (core/perhit.lua), toggled by /vf perhit on|off and the
|
||||
-- "Per-hit debug export" checkbox in gui/options.lua. DEFAULT ON: the module itself still
|
||||
-- defaults its in-memory state to disabled (core/perhit.lua's own comment), but the developer
|
||||
-- explicitly overrode that for THIS addon -- it is meant to run continuously like a background
|
||||
-- capture addon, not be armed by hand each session (follow-up change request, 2026-08-22). No dbVersion bump
|
||||
-- needed: the defaults merge below fills it in recursively for an existing profile, same as
|
||||
-- shown/showOverheal above -- and `true` for a MISSING key is exactly what a brand new
|
||||
-- default-on preference should read as (fillDefaults only ever fills a true nil, never a
|
||||
-- stored `false`, so a developer who explicitly turned it off keeps it off across reloads).
|
||||
perhitEnabled = true,
|
||||
}
|
||||
|
||||
C.CHAR_DEFAULTS = {
|
||||
dbVersion = 2,
|
||||
enabled = true,
|
||||
sessionHeal = 0,
|
||||
manual = {}, -- manual source overrides, /vf source add <percent>
|
||||
-- Runtime-extendable twin of VampifyConst.NO_TRIGGER, /vf exclude add|remove|list. Damage
|
||||
-- shields have no structural signal (see const.lua) -- a new one is only ever found once it
|
||||
-- shows up wrongly in the breakdown, so this list has to be growable without a client restart.
|
||||
-- Unioned with the hardcoded table at the check site (VampifyConst.triggersVampirism), never
|
||||
-- replacing it. No dbVersion bump needed: the defaults merge below fills it in recursively.
|
||||
noTrigger = {},
|
||||
|
||||
-- Persisted per-spell breakdown, two sets side by side (spec 2026-08-10 sec 3). Same shape as
|
||||
-- VampifyAggregate's in-memory spHeal/spDmg/spOver (and their lifetime twins): a plain spellId
|
||||
-- -> number table each. core/commands.lua points the aggregate's own tables AT these after
|
||||
-- login/reload, so a recorded hit is a SavedVariables write with no extra save step and no
|
||||
-- extra allocation -- see A.new's comment in core/aggregate.lua.
|
||||
-- session -- survives /reload and loading screens, cleared on a genuine login
|
||||
-- lifetime -- never cleared automatically; only /vf reset lifetime|both touches it
|
||||
-- No dbVersion bump needed: the defaults merge below fills both in recursively for an existing
|
||||
-- character db that predates this feature.
|
||||
--
|
||||
-- `hist` is the DAMAGE HISTOGRAM (core/histogram.lua) for the same two scopes: hit count per
|
||||
-- exact damage value, normal and AoE hits kept apart, with everything above the cap bundled
|
||||
-- into a count and a sum. It is what makes "what would one more source be worth" answerable
|
||||
-- exactly -- the floor makes healing non-linear in damage, so no total or average can answer
|
||||
-- it (see that file's header). Wired by reference the same way the per-spell tables are, so a
|
||||
-- recorded hit is already a SavedVariables write.
|
||||
--
|
||||
-- Bounded on purpose (VampifyHistogram.CAP): at most ~1000 exact keys per side, so a long
|
||||
-- session cannot grow this without limit the way a per-GUID table would. No dbVersion bump
|
||||
-- needed -- the defaults merge below fills it in recursively for a character db saved before
|
||||
-- this existed, including a half-filled one.
|
||||
-- `hits` is the per-spell HIT COUNT twin of heal/damage/overheal above, same key, same
|
||||
-- session/lifetime split, filled by the same VampifyAggregate.recordSpell call. No dbVersion
|
||||
-- bump needed here either: the defaults merge below fills it in as an empty table for a
|
||||
-- character db that predates the counter, so an existing profile's heal/damage/overheal HISTORY
|
||||
-- is kept while its hit counts start over at 0 -- there is nothing to backfill them from.
|
||||
-- healAoe/damageAoe/hitsAoe/splitSeen (2026-08-24, UI-redesign strand A; REDEFINED same day --
|
||||
-- see C.migrate's v<4 step and core/aggregate.lua's A.recordSpell for the full history): the
|
||||
-- ST/AoE split's persisted twin of heal/damage/hits above. Deliberately a SEPARATE, ADDITIVE
|
||||
-- set of tables rather than a reshape of heal/damage/hits -- those three keep their existing
|
||||
-- meaning (the GRAND total per spell) untouched by either version of this feature. The defaults
|
||||
-- merge below fills these four in as empty tables for a character db that predates the split
|
||||
-- entirely; core/aggregate.lua's A.spellSplit reads a spell with nothing in them as "ST equals
|
||||
-- the whole existing total, split unknown" (hasSplit=false) rather than guessing. See
|
||||
-- core/aggregate.lua's A.recordSpell and A.spellSplit for the read/write halves of this contract.
|
||||
--
|
||||
-- A VERSION BUMP WAS NEEDED after all, unlike most fields in this table: the split's DEFINITION
|
||||
-- changed the same day it shipped (deriveAoE-classification -> current-target-identity), so any
|
||||
-- data already collected under the first definition has to be discarded rather than silently
|
||||
-- reinterpreted under the second -- C.migrate's v<4 step does exactly that, leaving heal/damage/
|
||||
-- overheal/hits (the grand totals) untouched.
|
||||
session = { heal = {}, damage = {}, overheal = {}, hits = {},
|
||||
healAoe = {}, damageAoe = {}, hitsAoe = {}, splitSeen = {},
|
||||
hist = { n = {}, a = {}, nOverN = 0, nOverSum = 0, aOverN = 0, aOverSum = 0 } },
|
||||
lifetime = { heal = {}, damage = {}, overheal = {}, hits = {},
|
||||
healAoe = {}, damageAoe = {}, hitsAoe = {}, splitSeen = {},
|
||||
hist = { n = {}, a = {}, nOverN = 0, nOverSum = 0, aOverN = 0, aOverSum = 0 } },
|
||||
}
|
||||
|
||||
local function fillDefaults(db, defaults)
|
||||
for k, v in pairs(defaults) do
|
||||
if type(v) == "table" then
|
||||
if type(db[k]) ~= "table" then db[k] = {} end
|
||||
fillDefaults(db[k], v)
|
||||
elseif db[k] == nil then -- nil, NOT falsy: a stored `false` must survive
|
||||
db[k] = v
|
||||
end
|
||||
end
|
||||
return db
|
||||
end
|
||||
|
||||
local function copy(t)
|
||||
local out = {}
|
||||
for k, v in pairs(t) do
|
||||
if type(v) == "table" then out[k] = copy(v) else out[k] = v end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function C.migrate(db, defaults, currentVersion)
|
||||
if type(db) ~= "table" then return copy(defaults) end
|
||||
local v = db.dbVersion
|
||||
if type(v) ~= "number" or v > currentVersion then
|
||||
return copy(defaults) -- unknown or from the future: reset, do not guess
|
||||
end
|
||||
-- Known older versions step forward here.
|
||||
if v < 2 then
|
||||
-- Move the display off the action bars -- but ONLY for users who never dragged it. A
|
||||
-- position the user chose is theirs; silently relocating it would be worse than the bug.
|
||||
if db.pos and db.pos.point == C.V1_POS.point
|
||||
and db.pos.x == C.V1_POS.x and db.pos.y == C.V1_POS.y then
|
||||
db.pos = nil -- fillDefaults below restores the v2 default
|
||||
end
|
||||
end
|
||||
if v < 3 then
|
||||
-- showFct only ever meant "show Blizzard's combat text". Carry an explicitly-set value
|
||||
-- into the new sct.enabled switch, and into sct.mode = "blizzard" so a user who had it ON
|
||||
-- keeps seeing exactly what they had -- our own SCT (mode "own") is a different visual and
|
||||
-- must not silently replace what they were already looking at. A false/never-set showFct
|
||||
-- needs no mode opinion; the "own" default from C.DEFAULTS is fine for a switch that was
|
||||
-- off anyway. showFct itself is then dropped -- see the comment on C.DEFAULTS.sct.
|
||||
db.sct = db.sct or {}
|
||||
if db.sct.enabled == nil and db.showFct ~= nil then
|
||||
db.sct.enabled = db.showFct
|
||||
if db.showFct == true then db.sct.mode = "blizzard" end
|
||||
end
|
||||
db.showFct = nil
|
||||
end
|
||||
if v < 4 then
|
||||
-- ST/AoE split REDEFINED (2026-08-24, the same day the split shipped): from "was this hit
|
||||
-- AoE-CLASSIFIED (deriveAoE, area-damage burst detection)" to "did this hit land on
|
||||
-- something OTHER than the player's current target" -- a completely different question (see
|
||||
-- core/aggregate.lua's A.recordSpell for the full history). Any healAoe/damageAoe/hitsAoe/
|
||||
-- splitSeen data already collected under the SUPERSEDED definition would silently mix two
|
||||
-- incompatible meanings with data collected under the new one if left in place -- worse than
|
||||
-- losing it, so it is discarded ONCE, cleanly, here, exactly like a v<2/v<3 step above steps
|
||||
-- an old schema forward rather than half-migrating it.
|
||||
--
|
||||
-- GRAND TOTALS ARE NOT TOUCHED: db.session.heal/damage/overheal/hits and their lifetime
|
||||
-- twins keep every number a player has already earned -- only the split's OWN bookkeeping
|
||||
-- (which of that total was ST vs AoE) resets to "unknown" (A.spellSplit's hasSplit reads
|
||||
-- false again for every spell until a fresh, correctly-classified hit lands). Guarded on
|
||||
-- db.session/db.lifetime existing at all: this migrate() function also runs on VampifyDB
|
||||
-- (global settings), which has neither field, and must not error there.
|
||||
if db.session then
|
||||
db.session.healAoe, db.session.damageAoe, db.session.hitsAoe, db.session.splitSeen =
|
||||
{}, {}, {}, {}
|
||||
end
|
||||
if db.lifetime then
|
||||
db.lifetime.healAoe, db.lifetime.damageAoe, db.lifetime.hitsAoe, db.lifetime.splitSeen =
|
||||
{}, {}, {}, {}
|
||||
end
|
||||
end
|
||||
|
||||
db.dbVersion = currentVersion
|
||||
return fillDefaults(db, defaults)
|
||||
end
|
||||
|
||||
if CreateFrame then
|
||||
local done = false
|
||||
local function load_()
|
||||
if done then return end
|
||||
done = true
|
||||
VampifyDB = C.migrate(VampifyDB, C.DEFAULTS, C.DB_VERSION)
|
||||
VampifyCharDB = C.migrate(VampifyCharDB, C.CHAR_DEFAULTS, C.DB_VERSION)
|
||||
end
|
||||
|
||||
local f = CreateFrame("Frame", "VampifyConfigFrame")
|
||||
-- ADDON_LOADED is the earliest event at which the SavedVariables tables exist, and it fires on
|
||||
-- every load path -- migrating any later would leave the addon reading an unmigrated table in
|
||||
-- the meantime, invisible until the schema changes and then corrupting.
|
||||
-- PLAYER_ENTERING_WORLD is kept as a belt-and-braces second chance; `done` makes it idempotent.
|
||||
-- (An earlier version of this comment claimed PLAYER_LOGIN does not fire on /reload. It does --
|
||||
-- see core/commands.lua's wireSession for the evidence. Nothing here depended on the claim, but
|
||||
-- it propagated from here into code that did.)
|
||||
f:RegisterEvent("ADDON_LOADED")
|
||||
f:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
f:SetScript("OnEvent", function()
|
||||
if event == "ADDON_LOADED" and arg1 ~= "Vampify" then return end
|
||||
load_()
|
||||
end)
|
||||
end
|
||||
|
||||
function C.get() return VampifyDB end
|
||||
function C.getChar() return VampifyCharDB end
|
||||
+749
@@ -0,0 +1,749 @@
|
||||
-- Vampify -- shared constants and the pinned formula descriptor.
|
||||
--
|
||||
-- The formula was MEASURED in-game (a companion telemetry addon's instrumentation, 2026-08-07..09), it is not derived from tooltips
|
||||
-- or forum lore. It lives in a descriptor rather than inside the arithmetic so that a resolved
|
||||
-- open question (spec 4.3) is a one-line change here, not a refactor of model.lua.
|
||||
|
||||
VampifyConst = {}
|
||||
local C = VampifyConst
|
||||
|
||||
C.VERSION = "0.3.0"
|
||||
|
||||
-- Empty a list buffer for reuse. THE table.setn IS THE POINT: in Lua 5.0 table.insert maintains an
|
||||
-- `n` field, and nil-ing the indices by hand does not reset it -- so the next round of inserts
|
||||
-- lands BEHIND the stale n, leaving nil holes at 1..n. That crashed the options window
|
||||
-- ("table.concat: table contains non-strings") on its second refresh and silently grew the tooltip
|
||||
-- line buffer on every gear scan. One helper, so the trap exists in exactly one place.
|
||||
function C.resetList(t)
|
||||
for i = table.getn(t), 1, -1 do t[i] = nil end
|
||||
table.setn(t, 0)
|
||||
return t
|
||||
end
|
||||
|
||||
-- Vampirism source spell ids: items are ranks 1-5, enchants are bracer/boots at +1% each.
|
||||
C.SPELL_IDS = { 45420, 45421, 45422, 45423, 45424, 57146, 57148 }
|
||||
|
||||
-- ---- error capture -----------------------------------------------------------------------------
|
||||
--
|
||||
-- Two problems, one mechanism. A Lua error in an OnUpdate repeats every frame, which floods the
|
||||
-- chat and makes the game unpleasant; and the developer only learns about it if the player thinks
|
||||
-- to mention it. So: every error is written to <WoW>\imports\vampify_errors.txt where tooling
|
||||
-- can read it, and only the FIRST occurrence of each distinct message reaches the chat. Repeats are
|
||||
-- counted, not shown.
|
||||
--
|
||||
-- Lives here rather than in its own file purely so it loads first, before anything that could
|
||||
-- throw, without costing a client restart to add a new file to the toc.
|
||||
--
|
||||
-- Deliberately chains to the previous handler for that first occurrence: swallowing errors outright
|
||||
-- would trade a visible problem for an invisible one.
|
||||
|
||||
C._errSeen = {}
|
||||
C._errOrder = {}
|
||||
C._errPrev = nil
|
||||
|
||||
local function writeErrors()
|
||||
if not ExportFile then return end -- SuperWoW only; harmless without it
|
||||
local lines = {}
|
||||
table.insert(lines, "addon=Vampify version=" .. tostring(C.VERSION))
|
||||
for i = 1, table.getn(C._errOrder) do
|
||||
local m = C._errOrder[i]
|
||||
table.insert(lines, "[x" .. tostring(C._errSeen[m]) .. "] " .. m)
|
||||
end
|
||||
-- ExportFile appends .txt itself -- passing "vampify_errors.txt" would yield a double
|
||||
-- extension, which is a known in-game gotcha.
|
||||
ExportFile("vampify_errors", table.concat(lines, "\n"))
|
||||
end
|
||||
|
||||
function C.installErrorCapture()
|
||||
if C._errInstalled then return end
|
||||
C._errInstalled = true
|
||||
C._errPrev = geterrorhandler and geterrorhandler() or nil
|
||||
|
||||
seterrorhandler(function(msg)
|
||||
local show, write = C.recordError(msg)
|
||||
if write then writeErrors() end
|
||||
if show and C._errPrev then C._errPrev(msg) end
|
||||
end)
|
||||
end
|
||||
|
||||
-- Pure counting half, split out so it can be tested offline: WoW's error handler cannot be, and a
|
||||
-- bug in the thing that reports bugs is the worst kind. Returns (showInChat, writeToFile).
|
||||
function C.recordError(msg)
|
||||
msg = tostring(msg)
|
||||
local firstTime = C._errSeen[msg] == nil
|
||||
if firstTime then
|
||||
C._errSeen[msg] = 1
|
||||
table.insert(C._errOrder, msg)
|
||||
else
|
||||
C._errSeen[msg] = C._errSeen[msg] + 1
|
||||
end
|
||||
-- Show it once, then stay quiet: the player has been told, and a per-frame repeat adds nothing
|
||||
-- but noise. Write on the first sighting and every 50th repeat, so a runaway loop stays visible
|
||||
-- in the file without writing on every single frame.
|
||||
local write = firstTime or math.mod(C._errSeen[msg], 50) == 0
|
||||
return firstTime, write
|
||||
end
|
||||
|
||||
-- Installed at file scope, not from an event: an error thrown while a later file is still loading
|
||||
-- would otherwise be missed, and those are exactly the errors worth catching.
|
||||
if seterrorhandler then C.installErrorCapture() end
|
||||
|
||||
-- ---- spells that do NOT trigger Vampirism -------------------------------------------------------
|
||||
--
|
||||
-- Damage shields were MEASURED not to trigger (Thorns 236 triggers, Thorium Shield Spike 27 -- zero
|
||||
-- healing from either). Excluding the DAMAGE_SHIELD_SELF event is not enough: the client also
|
||||
-- reports shield procs as ordinary SPELL_DAMAGE_EVENT_SELF, so they slipped in and were credited
|
||||
-- with healing that never happened (spotted in-game via the per-ability breakdown, which showed
|
||||
-- Thorium Shield Spike at 12.4%).
|
||||
--
|
||||
-- There is no reliable structural signal that marks a proc as a damage shield, so this is a list.
|
||||
-- It is extendable at runtime with /vf exclude <id> for anything found later, and excluded spells
|
||||
-- are dropped from BOTH the healing and the damage side -- counting damage that cannot trigger
|
||||
-- would drag the headline percentage below the truth.
|
||||
|
||||
C.NO_TRIGGER = {
|
||||
[16624] = "Thorium Shield Spike", -- id read from the live client 2026-08-10
|
||||
}
|
||||
|
||||
function C.triggersVampirism(spellId)
|
||||
if not spellId then return true end -- auto attacks always trigger
|
||||
if C.NO_TRIGGER[spellId] then return false end
|
||||
local cfg = VampifyConfig and VampifyConfig.getChar and VampifyConfig.getChar()
|
||||
if cfg and cfg.noTrigger and cfg.noTrigger[spellId] then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ---- runtime exclusion management (cfg.noTrigger) -----------------------------------------------
|
||||
--
|
||||
-- cfg.noTrigger is the SAME mechanism as NO_TRIGGER above, made runtime-extendable: a new damage
|
||||
-- shield is only ever found once it shows up wrongly in the breakdown (there is no structural
|
||||
-- signal, see the comment above), so the list has to grow without a client restart. These three
|
||||
-- functions are the one place that reads/writes cfg.noTrigger, so core/commands.lua's slash
|
||||
-- handlers stay thin wiring and the union logic in triggersVampirism above is never duplicated.
|
||||
--
|
||||
-- New exclusions apply forward-only: they change what counts as triggering from the moment they
|
||||
-- are added, but do not reach back into totals already recorded for earlier hits. See the /vf
|
||||
-- exclude add command in commands.lua for the reasoning (kept there, next to where it is user-
|
||||
-- visible); /vf reset remains the explicit way to discard a tainted session.
|
||||
|
||||
-- Adds spellId to the character-scoped runtime set. Returns false (and changes nothing) if the id
|
||||
-- is already covered -- either hardcoded or already added -- with a reason string so the caller
|
||||
-- can tell the player why, rather than silently re-adding.
|
||||
function C.addNoTrigger(spellId, cfg)
|
||||
if C.NO_TRIGGER[spellId] then return false, "builtin" end
|
||||
if not cfg then return false, "no-config" end
|
||||
if not cfg.noTrigger then cfg.noTrigger = {} end
|
||||
if cfg.noTrigger[spellId] then return false, "already" end
|
||||
cfg.noTrigger[spellId] = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- Removes spellId from the runtime set. The hardcoded list is not reachable through this path --
|
||||
-- it is measured fact, not a preference -- so this only ever touches cfg.noTrigger.
|
||||
function C.removeNoTrigger(spellId, cfg)
|
||||
if not (cfg and cfg.noTrigger and cfg.noTrigger[spellId]) then return false end
|
||||
cfg.noTrigger[spellId] = nil
|
||||
return true
|
||||
end
|
||||
|
||||
-- Combined listing for /vf exclude list: hardcoded entries first, then runtime ones, each tagged
|
||||
-- so the caller can say which is which. `out` is reused like every other list buffer in this addon
|
||||
-- (see resetList above) so a repeated /vf exclude list stays allocation-free.
|
||||
function C.listNoTrigger(cfg, out)
|
||||
out = out or {}
|
||||
C.resetList(out)
|
||||
local n = 0
|
||||
for id in pairs(C.NO_TRIGGER) do
|
||||
n = n + 1
|
||||
out[n] = { id = id, builtin = true }
|
||||
end
|
||||
if cfg and cfg.noTrigger then
|
||||
for id in pairs(cfg.noTrigger) do
|
||||
n = n + 1
|
||||
out[n] = { id = id, builtin = false }
|
||||
end
|
||||
end
|
||||
table.setn(out, n)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---- spell id -> name --------------------------------------------------------------------------
|
||||
--
|
||||
-- nampower hands us numeric spell ids; the tooltip has to show something a player recognises. 1.12
|
||||
-- has no GetSpellInfo, but this client has two independent routes that do work, and the result is
|
||||
-- cached because a tooltip refresh must not re-resolve on every frame.
|
||||
--
|
||||
-- Explicitly NOT tried: GameTooltip:SetHyperlink("spell:<id>"). It is documented broken on
|
||||
-- 1.12/TurtleWoW -- it fails SILENTLY and leaves the tooltip empty -- so attempting it would cost
|
||||
-- work per unknown id and yield nothing.
|
||||
--
|
||||
-- (Belongs in its own file; it is here so it loads first and needs no toc change, which would cost
|
||||
-- a client restart. Move it when something else forces a restart anyway.)
|
||||
|
||||
C._spellNames = {}
|
||||
C.spellNameSource = "none"
|
||||
|
||||
function C.spellName(id)
|
||||
-- -1 is the aggregate's sentinel for auto attacks (VampifyAggregate.MELEE), which have no id.
|
||||
if type(id) ~= "number" or id < 0 then return "Melee" end
|
||||
local cached = C._spellNames[id]
|
||||
if cached then return cached end
|
||||
|
||||
local name
|
||||
|
||||
-- 1. SuperWoW's SpellInfo(spellId). It reads the client's spell DBC by numeric id rather than
|
||||
-- walking the spellbook, so it resolves foreign and NPC spells too, not just our own.
|
||||
if SpellInfo then
|
||||
local ok, n = pcall(SpellInfo, id)
|
||||
if ok and type(n) == "string" and n ~= "" then
|
||||
name = n
|
||||
C.spellNameSource = "SpellInfo"
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. nampower's own resolver, an independent second route in case SuperWoW is absent.
|
||||
if not name and GetSpellNameAndRankForId then
|
||||
local ok, n = pcall(GetSpellNameAndRankForId, id)
|
||||
if ok and type(n) == "string" and n ~= "" then
|
||||
name = n
|
||||
C.spellNameSource = "nampower"
|
||||
end
|
||||
end
|
||||
|
||||
if not name then name = "Spell #" .. id end
|
||||
C._spellNames[id] = name
|
||||
return name
|
||||
end
|
||||
|
||||
-- ---- spell id -> icon ---------------------------------------------------------------------------
|
||||
--
|
||||
-- The UI redesign's per-ability rows want an icon next to the name, not just text. SpellInfo(id)
|
||||
-- already returns this as its 3rd value (name, rank, texture, minRange, maxRange) -- C.spellName
|
||||
-- above discards it -- so this is a second thin reader over the SAME SuperWoW call, cached exactly
|
||||
-- like C.spellName (a tooltip/bar refresh must not re-resolve on every frame), just with its own
|
||||
-- cache table because the two calls can return a texture where the name lookup failed or vice versa
|
||||
-- (unlikely, but nothing guarantees the two are correlated) and sharing a cache would only save one
|
||||
-- pcall while entangling two independently-failing things for no reason.
|
||||
--
|
||||
-- Unlike C.spellName, a miss here has no readable-string fallback to manufacture -- "no icon" is a
|
||||
-- real, displayable answer (the caller substitutes its own placeholder texture), so this returns nil
|
||||
-- rather than inventing a path that does not exist on disk for a genuine SPELL id.
|
||||
--
|
||||
-- A.MELEE (-1) and any non-number id are NOT a SpellInfo lookup at all. Two revisions on
|
||||
-- this, both 2026-08-24, in order:
|
||||
-- (1) first: read the equipped main-hand weapon's texture directly (GetInventoryItemTexture) --
|
||||
-- superseded below;
|
||||
-- (2) then (this version): WoW's OWN spellbook already carries this mapping. Every character has
|
||||
-- a base "Attack" entry in their spellbook, and its icon IS the equipped weapon's icon,
|
||||
-- maintained by the client itself across a weapon swap (confirmed from an in-game spellbook
|
||||
-- screenshot). Reading it through GetSpellTexture on the "Attack" entry therefore needs no
|
||||
-- swap-invalidation machinery of its own -- WoW already does that job; see C.meleeIcon below
|
||||
-- for exactly what is and is not independently confirmed about this here.
|
||||
C.MELEE_ICON = "Interface\\Icons\\Ability_MeleeDamage" -- fixed fallback only, see C.meleeIcon
|
||||
|
||||
-- The "Attack" entry's SPELLBOOK INDEX, found once by NAME and cached -- an index is class- and
|
||||
-- level-dependent (talent respecs, new abilities learned into earlier tabs can shift it), so it is
|
||||
-- never hardcoded, exactly the same reasoning gui/display.lua's own buildSpellbookSet gives for
|
||||
-- never assuming a fixed spell id. `_attackIndexKnown` distinguishes "not searched yet" (nil, look
|
||||
-- again) from "searched, genuinely not found" (also nil, but do NOT re-scan every call -- that would
|
||||
-- turn a one-time cost into a per-frame one for a locale/build where no "Attack" entry exists).
|
||||
C._attackIndex = nil
|
||||
C._attackIndexKnown = false
|
||||
|
||||
-- Invalidates the cached index -- wired to SPELLS_CHANGED/LEARNED_SPELL_IN_TAB below, the same two
|
||||
-- events Blizzard's own FrameXML\SpellBookFrame.lua registers in 1.12.1 -- a newly learned ability
|
||||
-- can insert into an earlier tab and shift every
|
||||
-- later index, which is exactly the class of change that would silently point this at the wrong
|
||||
-- spellbook row if never re-checked. Exposed (not local) so an offline test can simulate the event
|
||||
-- without needing a real WoW event frame.
|
||||
function C.invalidateAttackIndex()
|
||||
C._attackIndex, C._attackIndexKnown = nil, false
|
||||
end
|
||||
|
||||
-- Iterates the spellbook by index from 1, same loop shape as gui/display.lua's buildSpellbookSet
|
||||
-- (GetSpellName(i, bookType) until nil) -- the established pattern in this codebase for "walk the
|
||||
-- whole spellbook", not a second invention of it. pcall-wrapped like every other WoW API read in
|
||||
-- this file: GetSpellName may be absent entirely (offline tests, or a hypothetical client without
|
||||
-- it) and must degrade to "not found" rather than error.
|
||||
local function findAttackIndex()
|
||||
if not GetSpellName then return nil end
|
||||
local bookType = BOOKTYPE_SPELL or "spell" -- literal fallback matches SpellBookFrame.lua's own
|
||||
-- BOOKTYPE_SPELL = "spell" in case load order ever
|
||||
-- left the global unset when this runs
|
||||
local i = 1
|
||||
while true do
|
||||
local ok, name = pcall(GetSpellName, i, bookType)
|
||||
if not ok or not name then break end
|
||||
if name == "Attack" then return i end
|
||||
i = i + 1
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The "Attack" spellbook entry's current icon. The INDEX is cached (found by name, invalidated on
|
||||
-- the events above); the TEXTURE itself is read FRESH on every call via GetSpellTexture, which is
|
||||
-- what carries a weapon swap through without this file needing its own swap-invalidation -- WoW's
|
||||
-- client is claimed to keep that entry's icon in sync with the equipped weapon on its own (confirmed
|
||||
-- from an in-game screenshot of the "Attack" spellbook row).
|
||||
--
|
||||
-- NOT INDEPENDENTLY VERIFIED HERE: this file (and the FrameXML source it was checked against) can
|
||||
-- only confirm the API SURFACE -- GetSpellName(i, bookType)/GetSpellTexture(i, bookType), bookType
|
||||
-- BOOKTYPE_SPELL == "spell", both used exactly this way by Blizzard's own SpellBookFrame.lua. Whether
|
||||
-- the "Attack" entry's texture is genuinely weapon-linked is a client-side (C++) behavior no Lua
|
||||
-- source exposes -- that rests on in-game observation and is still open for an in-game
|
||||
-- double-check (see this feature's own verdict).
|
||||
--
|
||||
-- Falls back to C.MELEE_ICON when there is no client API at all (offline tests), no "Attack" entry
|
||||
-- was found (unknown locale/build), or GetSpellTexture itself returns nothing for it.
|
||||
function C.meleeIcon()
|
||||
if not C._attackIndexKnown then
|
||||
C._attackIndex, C._attackIndexKnown = findAttackIndex(), true
|
||||
end
|
||||
if C._attackIndex and GetSpellTexture then
|
||||
local bookType = BOOKTYPE_SPELL or "spell"
|
||||
local ok, tex = pcall(GetSpellTexture, C._attackIndex, bookType)
|
||||
if ok and type(tex) == "string" and tex ~= "" then return tex end
|
||||
end
|
||||
return C.MELEE_ICON
|
||||
end
|
||||
|
||||
if CreateFrame then
|
||||
local f = CreateFrame("Frame", "VampifyConstSpellbookWatcher")
|
||||
f:RegisterEvent("SPELLS_CHANGED")
|
||||
f:RegisterEvent("LEARNED_SPELL_IN_TAB")
|
||||
f:SetScript("OnEvent", C.invalidateAttackIndex)
|
||||
end
|
||||
|
||||
C._spellIcons = {}
|
||||
|
||||
function C.spellIcon(id)
|
||||
if type(id) ~= "number" or id < 0 then return C.meleeIcon() end
|
||||
local cached = C._spellIcons[id]
|
||||
if cached ~= nil then
|
||||
if cached == false then return nil end -- a cached MISS, not "never looked up"
|
||||
return cached
|
||||
end
|
||||
|
||||
local icon
|
||||
if SpellInfo then
|
||||
-- pcall-wrapped like C.spellName: SuperWoW may be absent, or SpellInfo may throw for an id
|
||||
-- the client's DBC does not know. Either way this must degrade to nil, never propagate an
|
||||
-- error into the hot damage/tooltip path.
|
||||
local ok, _, _, tex = pcall(SpellInfo, id)
|
||||
if ok and type(tex) == "string" and tex ~= "" then icon = tex end
|
||||
end
|
||||
|
||||
-- Cache a MISS too (as `false`, distinguishable from "not yet looked up" = nil), so an id that
|
||||
-- keeps failing (no SuperWoW, or genuinely no icon) is not re-queried every frame -- the whole
|
||||
-- point of caching at all.
|
||||
C._spellIcons[id] = icon or false
|
||||
return icon
|
||||
end
|
||||
|
||||
function C.errorCount()
|
||||
local n = 0
|
||||
for _, c in pairs(C._errSeen) do n = n + c end
|
||||
return n, table.getn(C._errOrder)
|
||||
end
|
||||
|
||||
C.FORMULA = {
|
||||
-- AoE damping. Measured 0.7021 on Flame Wave, independent of the number of targets hit.
|
||||
-- An earlier 0.86 estimate was wrong and is retracted.
|
||||
aoeFactor = 0.7,
|
||||
|
||||
-- Per-hit floor equals the NUMBER OF SOURCES (3 items -> min 3 HP, 2 items -> exactly 2.000).
|
||||
-- A fixed floor of 3, and no floor at all, are both refuted by measurement.
|
||||
floorMode = "sources",
|
||||
|
||||
-- Settled 2026-08-10 (spec 4a): of 7239 own hits during active Vampirism epochs, 14 carried
|
||||
-- mitigation and 9 were decisive after discarding full-health windows -- 7 pointed clearly at
|
||||
-- net, one favoured gross by 0.6 HP (noise), one was a full-block window too noisy to count.
|
||||
-- The gross error tracked mitigation * sumPercent almost exactly, which is the shape the
|
||||
-- mechanism predicts. The server pays Vampirism on damage already reduced by block/absorb/
|
||||
-- resist, not on the logged (pre-mitigation) amount.
|
||||
damageBase = "net",
|
||||
|
||||
-- Three-valued. "undecided" behaves as "off" and makes the displayed total a LOWER BOUND.
|
||||
channels = {
|
||||
melee = "on", -- measured
|
||||
spell = "on", -- measured
|
||||
aoe = "on", -- measured, with aoeFactor
|
||||
proc = "on", -- measured: Tidal Wave 12/12 windows, 0/36 control
|
||||
dmgshield = "off", -- refuted: Thorns 236 + Thorium Spike 27 triggers -> 0 healing
|
||||
dot = "on", -- measured 2026-08-10: ticks trigger per tick, no special
|
||||
-- handling, measured in-game over two Flame Shock sequences.
|
||||
-- The old "never occurred in a Vampirism epoch" note predated
|
||||
-- that measurement.
|
||||
totempet = "off", -- the developer's read from play; undecidable as instrumented
|
||||
pvp = "undecided", -- no Vampirism-geared BG epoch with own damage
|
||||
},
|
||||
}
|
||||
|
||||
-- ---- item proc origins (spell id -> item name) --------------------------------------------------
|
||||
--
|
||||
-- C.ORIGINS itself is generated (see the block below) from the server-source
|
||||
-- item_template + spell_template dump, in two layers: DIRECT spelltrigger_N == 2 ("chance on hit")
|
||||
-- ids, which appear in the combat log exactly as-is; and CHAIN ids, reached by following item
|
||||
-- spells at trigger 1 (on-equip proc auras) OR 2 one spell_template effect hop via
|
||||
-- effectTriggerSpell1/2/3. The chain layer exists because a real proc can sit one hop away from
|
||||
-- the item: spell 16614 ("Lightning Strike") never appears as an item_template spellid_N at all --
|
||||
-- only its equip-aura wrapper (spell 16615, trigger==1) does, and 16615's own spell_template
|
||||
-- effect is what fires 16614. A direct-only lookup would silently miss exactly that case. Plain
|
||||
-- on-use procs (trigger 0) are still excluded -- they answer "what did this item grant", a
|
||||
-- different question than a damage-origin lookup needs.
|
||||
--
|
||||
-- Nil-safe by construction: returns nil both when spellId has no known item origin AND when the
|
||||
-- generator has never been run yet (C.ORIGINS absent entirely), so callers never need a separate
|
||||
-- guard for "table not generated yet" vs. "id not a proc".
|
||||
function C.itemProcOrigin(spellId)
|
||||
if not C.ORIGINS then return nil end
|
||||
return C.ORIGINS[spellId]
|
||||
end
|
||||
|
||||
-- BEGIN GENERATED ORIGINS (generated by an offline generator script -- do not edit by hand)
|
||||
--
|
||||
-- Generated by an offline generator script. Two sources, unioned, two layers:
|
||||
-- Pass 1 (item->spell edges): UNION of a live game-database dump of item-to-spell
|
||||
-- links and a local item_template dump. Conflict rule: for an
|
||||
-- item entry known to both, the live database wins outright (current server state); the local
|
||||
-- dump contributes ONLY items the live database does not know at all. See this generator's file
|
||||
-- header ("TWO SOURCES, UNION not replacement") for the full rule and why.
|
||||
-- Pass 2 (spell effect chain): local spell_template dump -- the live database
|
||||
-- has no equivalent effectTriggerSpell field, see this generator's file header.
|
||||
-- 1. DIRECT: item-attached spell at trigger == 2 ("chance on hit") --
|
||||
-- these appear in the combat log exactly as-is.
|
||||
-- 2. CHAIN: item-attached spell at trigger == 1 (on-equip proc auras) OR == 2,
|
||||
-- followed ONE spell_template effect hop via effectTriggerSpell1/2/3.
|
||||
-- This exists because a real proc (spell 16614 "Lightning Strike") never
|
||||
-- appears as an item-attached spell at all -- only the equip-aura wrapper
|
||||
-- (16615) does, at trigger==1, and 16615's own spell_template effect is what
|
||||
-- fires 16614.
|
||||
-- A direct-only lookup silently misses cases exactly like this one.
|
||||
-- An id reachable through both layers gets one entry with item names unioned.
|
||||
--
|
||||
-- Maps a proc spell id to the item(s) that carry it, so the addon can answer
|
||||
-- "is this spell id an item proc, and from what" via a plain table lookup.
|
||||
--
|
||||
-- Lives in const.lua (not its own file) for the same reason C.spellName does: a new
|
||||
-- .lua file needs a Vampify.toc entry, which costs a full client restart, while
|
||||
-- const.lua loads first as the very first module and a table literal here needs only
|
||||
-- a /reload. Re-run the generator and replace this block; do not hand-edit it.
|
||||
--
|
||||
-- Regenerate: run the offline generator script
|
||||
C.ORIGINS = {
|
||||
[56] = "Carved Ragetotem and others",
|
||||
[89] = "Fire Sword of Crippling",
|
||||
[695] = "Betrayer",
|
||||
[744] = "Gift of the Spider God",
|
||||
[772] = "Serrated Handaxe",
|
||||
[871] = "Glaive of the Defender",
|
||||
[1054] = "Lesser Firestone",
|
||||
[2606] = "Stormfist",
|
||||
[2912] = "Sentinel's Moonslicer",
|
||||
[3264] = "Bloodhowler",
|
||||
[3271] = "Chilton Wand",
|
||||
[3396] = "The Ripper, Vile Sting",
|
||||
[3424] = "Gift of the Spider God",
|
||||
[3742] = "Gahz'rilla Fang, Aura Proc Damage Sword",
|
||||
[5597] = "The Ripper",
|
||||
[6647] = "The Ripper",
|
||||
[6751] = "Venom Infused Blade",
|
||||
[7712] = "Fiery Retributer and others",
|
||||
[7714] = "Embergem Cuffs and others",
|
||||
[8191] = "Sword of Zeal",
|
||||
[8277] = "Cursed Shinbone",
|
||||
[8313] = "Bite of Serra'kis",
|
||||
[8348] = "Julie's Dagger",
|
||||
[8552] = "Staff of Horrors, Cursed Thornblade",
|
||||
[9057] = "Red Whelp Gloves, Helmet of the Scarlet Avenger",
|
||||
[9159] = "Green Whelp Armor",
|
||||
[9329] = "Soulstring and others",
|
||||
[9632] = "Ravager, The Cruel Blade",
|
||||
[9633] = "Ravager, The Cruel Blade",
|
||||
[9777] = "Truesilver Breastplate",
|
||||
[9796] = "Blight",
|
||||
[9800] = "Truesilver Champion",
|
||||
[9806] = "Phantom Blade",
|
||||
[10342] = "Guardian Talisman",
|
||||
[10351] = "Blade of the Basilisk",
|
||||
[10368] = "Uther's Strength",
|
||||
[10370] = "Mutilator",
|
||||
[10371] = "Obedient Whacker",
|
||||
[10373] = "Pendulum of Doom",
|
||||
[11657] = "Jang'thraze the Protector",
|
||||
[11658] = "Sul'thraze the Lasher",
|
||||
[11790] = "Toxic Revenger",
|
||||
[11791] = "Digmaster 5000, Vibroblade, Nail on a Plank",
|
||||
[11879] = "Shoni's Disarming Tool, The Murkfisher",
|
||||
[12484] = "Einhorn's Skinner",
|
||||
[12685] = "Stealthblade",
|
||||
[12686] = "Ragehammer",
|
||||
[12731] = "Stoneslayer",
|
||||
[13049] = "Dragon's Call",
|
||||
[13318] = "Barman Shanker, Blood Talon, Killmaim",
|
||||
[13438] = "Excavator's Brand",
|
||||
[13439] = "Winter's Bite and others",
|
||||
[13440] = "Grimclaw and others",
|
||||
[13441] = "Orb of Fire",
|
||||
[13442] = "Meteor Shard, Baron Charr's Sceptre, Scroll of Cow Portal",
|
||||
[13480] = "Night Reaver",
|
||||
[13482] = "The Ziggler, Electrocutioner Leg",
|
||||
[13486] = "Bloodletter Scalpel, Fleshrender",
|
||||
[13490] = "Howling Blade",
|
||||
[13491] = "Iron Knuckles",
|
||||
[13496] = "Mug O' Hurt",
|
||||
[13518] = "Blackvenom Blade, Fang of the Broodmother",
|
||||
[13519] = "Shortsword of Vengeance, Fishbringer",
|
||||
[13524] = "Stalvan's Reaper",
|
||||
[13526] = "Strike of the Hydra, Hookfang Shanker",
|
||||
[13527] = "Supercharger Battle Axe",
|
||||
[13528] = "Sword of Decay",
|
||||
[13530] = "Tainted Pierce",
|
||||
[13532] = "The Hand of Antu'sul",
|
||||
[13533] = "The Jackhammer, Carved Grimtotem",
|
||||
[13534] = "The Shatterer",
|
||||
[13752] = "Dazzling Longsword",
|
||||
[14106] = "The Black Knight",
|
||||
[14118] = "Drakefang Butcher",
|
||||
[14119] = "Phytoblade, Thunder 45, Thunderhorn",
|
||||
[14126] = "Grim Reaper, Rusty Coghammer",
|
||||
[15280] = "Dark Iron Sunderer",
|
||||
[15283] = "Dark Iron Pulverizer",
|
||||
[15494] = "Ironfoe",
|
||||
[15592] = "Skaldrenox's Rage",
|
||||
[15595] = "Force of Will",
|
||||
[15601] = "Hand of Justice",
|
||||
[15602] = "Lord General's Sword",
|
||||
[15661] = "Terrorblade Glaive",
|
||||
[15662] = "Smoldering Claw",
|
||||
[16393] = "Glutton's Cleaver",
|
||||
[16400] = "Widow's Kiss and others",
|
||||
[16401] = "Poison-tipped Bone Spear",
|
||||
[16403] = "The Goldtusk and others",
|
||||
[16405] = "Ripsaw and others",
|
||||
[16406] = "Gutwrencher, Hameya's Slayer",
|
||||
[16407] = "Edge of Winter",
|
||||
[16408] = "Darkwater Talwar",
|
||||
[16409] = "Ghoulfang and others",
|
||||
[16411] = "Deathblow",
|
||||
[16413] = "Firebreather, Searing Blade",
|
||||
[16414] = "Wraith Scythe, Scythe of the Harvest",
|
||||
[16415] = "Taran Icebreaker",
|
||||
[16433] = "Bloodfist and others",
|
||||
[16454] = "Searing Needle",
|
||||
[16528] = "Keris of Zul'Serak",
|
||||
[16549] = "Blackhand Doomsaw",
|
||||
[16551] = "Felstriker",
|
||||
[16559] = "Flame Wrath",
|
||||
[16560] = "Flame Wrath",
|
||||
[16602] = "Blackblade of Shahram",
|
||||
[16603] = "Demonfork",
|
||||
[16608] = "Demon Forged Breastplate, Breastplate of the Dark Reaver",
|
||||
[16614] = "Storm Gauntlets and others",
|
||||
[16621] = "Invulnerable Mail",
|
||||
[16782] = "Charged Servo Arm and others",
|
||||
[16783] = "Totem of Infliction, Girdle of Reprisal",
|
||||
[16784] = "Vile Protector",
|
||||
[16871] = "Bleakwood Hew",
|
||||
[16898] = "Blazing Rapier",
|
||||
[16908] = "Serenity",
|
||||
[16916] = "Arcanite Champion",
|
||||
[16921] = "Masterwork Stormhammer, Thunderforge Lance",
|
||||
[16927] = "Frostguard",
|
||||
[16928] = "Annihilator",
|
||||
[16939] = "Darkspear",
|
||||
[17144] = "Stormpike",
|
||||
[17148] = "Brain Hacker",
|
||||
[17152] = "Destiny",
|
||||
[17153] = "Kang the Decapitator",
|
||||
[17154] = "The Green Tower",
|
||||
[17196] = "Seeping Willow",
|
||||
[17308] = "Femur Club and others",
|
||||
[17315] = "Bashguuder, Rivenspike",
|
||||
[17331] = "Fang of the Crystal Spider",
|
||||
[17351] = "Argent Defender",
|
||||
[17352] = "Argent Avenger",
|
||||
[17407] = "The Nicker",
|
||||
[17483] = "Demonshear",
|
||||
[17484] = "Skullforge Reaver",
|
||||
[17496] = "Crest of Retribution",
|
||||
[17500] = "Malown's Slam",
|
||||
[17504] = "Bloodrazor",
|
||||
[17505] = "The Cruel Hand of Timmy",
|
||||
[17506] = "Soul Breaker",
|
||||
[17509] = "Dark Reaver",
|
||||
[17510] = "Sword of Corruption",
|
||||
[17511] = "Serpent Slicer, Ichor Spitter, Toxic Ripper",
|
||||
[17936] = "Firestone",
|
||||
[17940] = "Greater Firestone",
|
||||
[17942] = "Major Firestone",
|
||||
[18077] = "Venom Web Fang",
|
||||
[18078] = "Bloody Pick and others",
|
||||
[18081] = "Gryphon Rider's Stormhammer",
|
||||
[18082] = "Volcanic Hammer",
|
||||
[18083] = "Galgann's Firehammer",
|
||||
[18084] = "Fist of the Damned",
|
||||
[18086] = "Teebu's Blazing Longsword, Force of Magma",
|
||||
[18088] = "Blade of the Wretched, Corruption",
|
||||
[18089] = "Linken's Sword of Mastery",
|
||||
[18090] = "Gutrender, Fisher's Harpoon, Ancient Hakkari Flayer",
|
||||
[18091] = "Archeus",
|
||||
[18092] = "Shiver Blade, Coldheart Icicle",
|
||||
[18104] = "Axe of the Deep Woods",
|
||||
[18107] = "Gut Ripper, Fleshslasher",
|
||||
[18112] = "Ashbringer",
|
||||
[18138] = "Black Duskwood Staff, Shadowblade, Deathbringer",
|
||||
[18187] = "Pan of Po'rool, Overloaded Heating Coil, Fists of the Red Dawn",
|
||||
[18197] = "Serpent's Kiss, Stinging Viper",
|
||||
[18199] = "Burning War Axe",
|
||||
[18200] = "Bloodspiller",
|
||||
[18202] = "Bloodpike, Gargoyle Shredder Talons, Jaw of the Ancient",
|
||||
[18203] = "Venomspitter",
|
||||
[18204] = "Cobalt Crusher",
|
||||
[18205] = "Black Malice and others",
|
||||
[18206] = "Diabolic Skiver",
|
||||
[18208] = "Scorpion Sting",
|
||||
[18211] = "Nightblade, Doombringer, Ebon Hand",
|
||||
[18214] = "Witchfury, Sun's Tail",
|
||||
[18217] = "Duskbringer",
|
||||
[18276] = "Darrowspike, Bonechill Hammer",
|
||||
[18278] = "Silent Fang",
|
||||
[18289] = "Gravestone War Axe",
|
||||
[18350] = "Black Grasp of the Destroyer",
|
||||
[18381] = "Cursed Felblade",
|
||||
[18398] = "Sliverblade, Glacial Blade",
|
||||
[18543] = "Everflame Torch",
|
||||
[18633] = "Frightskull Shaft",
|
||||
[18652] = "Barovian Family Sword",
|
||||
[18656] = "Ebon Hilt of Marduk, Shadowbringer",
|
||||
[18796] = "Fiery War Axe",
|
||||
[18797] = "Flurry Axe",
|
||||
[18798] = "Freezing Band",
|
||||
[18803] = "Hand of Edward the Odd",
|
||||
[18817] = "Skullflame Shield",
|
||||
[18818] = "Skullflame Shield",
|
||||
[18819] = "Archaic Slicer",
|
||||
[18828] = "Wall of the Dead",
|
||||
[18833] = "Alcor's Sunrazor",
|
||||
[18946] = "The Lion Horn of Stormwind",
|
||||
[18980] = "Electrified Gloves",
|
||||
[19260] = "Chillpike",
|
||||
[19755] = "Frightalon",
|
||||
[19874] = "Shimmering Platinum Warhammer",
|
||||
[20586] = "Windreaper",
|
||||
[20869] = "Glacial Stone",
|
||||
[20883] = "Joonho's Mercy",
|
||||
[21140] = "Vis'kag the Bloodletter, Drake Talon Cleaver",
|
||||
[21151] = "Gutgore Ripper",
|
||||
[21152] = "Earthshaker",
|
||||
[21153] = "Bonereaver's Edge",
|
||||
[21159] = "Sulfuron Hammer",
|
||||
[21162] = "Sulfuras, Hand of Ragnaros",
|
||||
[21165] = "Empyrean Demolisher, Steamrigged Servohammer",
|
||||
[21170] = "Shadowstrike",
|
||||
[21179] = "Thunderstrike",
|
||||
[21186] = "Spinal Reaper",
|
||||
[21330] = "Eye of the Abyss",
|
||||
[21898] = "Cowl of Terror",
|
||||
[21919] = "Thrash Blade, Chronobreaker, Letashaz's Right Claw",
|
||||
[21949] = "Gatorbite Axe",
|
||||
[21951] = "Fist of Stone, Energized Spear",
|
||||
[21952] = "Claw of Celebras",
|
||||
[21961] = "Princess Theradras' Scepter, Carved Runetotem",
|
||||
[21970] = "Mark of the Chosen",
|
||||
[21992] = "Thunderfury, Blessed Blade of the Windseeker",
|
||||
[22600] = "Force Reactive Disk",
|
||||
[22619] = "Force Reactive Disk",
|
||||
[22639] = "Eskhandar's Left Claw",
|
||||
[22640] = "Eskhandar's Right Claw",
|
||||
[22850] = "Quel'Serrar",
|
||||
[22863] = "Sprinter's Sword",
|
||||
[23267] = "Perdition's Blade",
|
||||
[23454] = "Ironfist and others",
|
||||
[23592] = "Electrified Dagger",
|
||||
[23604] = "Black Amnesty",
|
||||
[23605] = "Nightfall",
|
||||
[23682] = "Darkmoon Card: Heroism",
|
||||
[23684] = "Darkmoon Card: Blue Dragon",
|
||||
[23687] = "Darkmoon Card: Maelstrom",
|
||||
[23719] = "The Untamed Blade",
|
||||
[24241] = "Halberd of Smiting",
|
||||
[24251] = "Zulian Slicer",
|
||||
[24254] = "Sceptre of Smiting",
|
||||
[24257] = "Jeklik's Crusher",
|
||||
[24362] = "Feralkin Necklace, Ancient Hakkari Flayer, Devilsaur Claws",
|
||||
[24388] = "The Lobotomizer",
|
||||
[24405] = "Glacial Spike, Scale of the Blue Drake",
|
||||
[24585] = "Ancient Hakkari Manslayer",
|
||||
[24993] = "Emerald Dragonfang",
|
||||
[25768] = "Staff of the Qiraji Prophets",
|
||||
[25907] = "Wrath of Cenarius",
|
||||
[26108] = "Dark Edge of Insanity",
|
||||
[26415] = "Kalimdor's Revenge",
|
||||
[26693] = "Neretzek, The Blood Drinker, Shadowbringer, Pulseseeker",
|
||||
[27039] = "Beastmaster's Cap, Deathmist Mask, Pendant of Kindred Spirit",
|
||||
[27042] = "Beastmaster's Gloves, Deathmist Wraps",
|
||||
[27205] = "Beastmaster's Boots, Deathmist Sandals, Charm of Dark Domination",
|
||||
[27208] = "Beastmaster's Tunic, Deathmist Robe",
|
||||
[27559] = "Hushblade, Jagged Obsidian Shield",
|
||||
[27648] = "Thunderfury, Blessed Blade of the Windseeker",
|
||||
[27655] = "Heart of Wyrmthalak",
|
||||
[27657] = "Inflatable Woman",
|
||||
[27860] = "Blade of Eternal Darkness",
|
||||
[27868] = "Icemail Jerkin",
|
||||
[28414] = "Corrupted Ashbringer",
|
||||
[28441] = "Corrupted Ashbringer",
|
||||
[28701] = "Tempest's Rage",
|
||||
[29151] = "Misplaced Servo Arm",
|
||||
[29155] = "Corrupted Ashbringer",
|
||||
[29164] = "Stygian Buckler",
|
||||
[29502] = "Hurricane",
|
||||
[29638] = "Bow of Searing Arrows",
|
||||
[29639] = "Dwarven Hand Cannon",
|
||||
[29640] = "Heartseeking Crossbow, Lodestone",
|
||||
[29641] = "Dark Iron Rifle",
|
||||
[29644] = "Galgann's Fireblaster",
|
||||
[29646] = "Quillshooter",
|
||||
[29647] = "Shell Launcher Shotgun",
|
||||
[29653] = "Venomstrike",
|
||||
[29655] = "Verdant Keeper's Aim",
|
||||
[45076] = "Aspect of Seradane",
|
||||
[45416] = "Vial of Potent Venoms",
|
||||
[45522] = "Idol of the Emerald Rot",
|
||||
[45841] = "Rod of Resuscitation",
|
||||
[45843] = "Mana Binding Signet",
|
||||
[45848] = "Fist of the Forgotten Order",
|
||||
[45849] = "Totem of Crackling Thunder",
|
||||
[45856] = "Shawl of the Castellan",
|
||||
[45858] = "Breath of Solnius",
|
||||
[45860] = "Chromie's Broken Pocket Watch",
|
||||
[45862] = "Libram of the Faithful",
|
||||
[45867] = "Crystal of Vengeance",
|
||||
[45869] = "Concentrated Power of Will",
|
||||
[45873] = "Black Widow Eggs",
|
||||
[45875] = "Vampire Heart",
|
||||
[46104] = "Tempered Runeblade",
|
||||
[46318] = "Demoralization Club",
|
||||
[46319] = "Horde Defender's Axe",
|
||||
[46431] = "Idol of Evergrowth",
|
||||
[47354] = "Breastplate of Beast Mastery",
|
||||
[48004] = "Dream's Herald",
|
||||
[48005] = "Frostbound Slasher",
|
||||
[48006] = "Pauldron of Deflection",
|
||||
[48008] = "Bloodletter Razor",
|
||||
[48048] = "Ornate Bloodstone Dagger",
|
||||
[48101] = "Totem of the Stonebreaker",
|
||||
[48102] = "Towerforge Demolisher",
|
||||
[49369] = "Modrag'zan, Heart of the Mountain",
|
||||
[51001] = "Ornate Pyrium Gauntlets",
|
||||
[51144] = "Shar'tateth, the Shattered Edge",
|
||||
[51250] = "Splinterspear Mace",
|
||||
[51251] = "Claw of the Mageweaver",
|
||||
[51266] = "Stonewrought Vambraces",
|
||||
[51277] = "Crystalvein Breastplate",
|
||||
[51740] = "Treant's Bane",
|
||||
[52843] = "Bloodcaller's Decapitator",
|
||||
[52853] = "Draenethyst Blade",
|
||||
[52854] = "Draenethyst Juggernaut",
|
||||
}
|
||||
-- END GENERATED ORIGINS
|
||||
@@ -0,0 +1,268 @@
|
||||
-- Vampify -- the distribution of triggering hits, and what an upgrade would be worth on it.
|
||||
-- Pure Lua, no WoW API, offline-tested.
|
||||
--
|
||||
-- WHY A DISTRIBUTION AND NOT AN AGGREGATE. Every Vampirism source heals max(1, trunc(pct*D/100))
|
||||
-- independently (core/model.lua), so healing is NOT linear in damage: while pct*D/100 < 1 -- that
|
||||
-- is, while D < 100/pct -- a source pays its floor of 1 whatever its percentage is. Two runs with
|
||||
-- identical total damage and identical hit counts therefore return different healing, and would
|
||||
-- answer "what is one more source worth" differently, purely because their hits were sized
|
||||
-- differently. No total, no average and no hit count can recover that. The hit-size distribution
|
||||
-- can, and exactly.
|
||||
--
|
||||
-- What that buys the player: below the threshold, EVERY source pays 1, so many small percentages
|
||||
-- beat few large ones; above it, they are near enough interchangeable (and truncation tips the
|
||||
-- balance slightly the other way -- see G.compare). Which regime a given player is actually in is
|
||||
-- invisible from any number the addon showed before this, and it is exactly the question "should I
|
||||
-- take the +1% enchant or the bigger item" turns on.
|
||||
--
|
||||
-- ALLOCATION BUDGET PER RECORDED HIT IS ZERO, like core/aggregate.lua's: AoE grinding produces
|
||||
-- 30-60 damage events per second, and G.add does one table-index increment and nothing else.
|
||||
|
||||
VampifyHistogram = {}
|
||||
local G = VampifyHistogram
|
||||
|
||||
-- Exact bucketing up to and including this damage value; everything above is bundled into a count
|
||||
-- and a sum. The bound exists because the key space is the damage space -- unbounded in principle,
|
||||
-- and a table keyed by it would grow for the whole session.
|
||||
--
|
||||
-- WHY 1000. Exactness only MATTERS below the floor threshold 100/pct_min: above it every source is
|
||||
-- paid its percentage, the non-linearity is gone, and a count plus a sum reproduce the healing to
|
||||
-- within the truncated remainder. The smallest Vampirism source that exists is 1% (the bracer and
|
||||
-- boot enchants, VampifyConst.SPELL_IDS), so the highest threshold any real source can have is
|
||||
-- 100/1 = 100 for a normal hit, and 100/(1*0.7) ~= 143 for an AoE one (the damping is applied to
|
||||
-- the damage, so it moves the threshold up with it). 1000 is seven times that -- the entire
|
||||
-- floor-sensitive range is counted exactly, with room for a hypothetical source well under 1%, and
|
||||
-- the exact table still cannot exceed 999 keys per side.
|
||||
--
|
||||
-- Above the cap the bundle is represented by its MEAN hit, weighted by the count. That is an
|
||||
-- approximation, and the size of its error is worth being explicit about: truncation loses between
|
||||
-- 0 and 1 HP per source per hit, so the bundle's healing can be off by up to (sources x overflow
|
||||
-- hits) HP in total -- but it is off in the SAME way for the baseline and for every hypothetical
|
||||
-- compared against it, so the DELTAS G.compare reports stay trustworthy even where the absolute
|
||||
-- figure drifts. The floor cannot bind inside the bundle at all (every member is above the cap,
|
||||
-- and so is their mean), which is the property the cap was chosen for.
|
||||
G.CAP = 1000
|
||||
|
||||
function G.new()
|
||||
return { n = {}, a = {}, nOverN = 0, nOverSum = 0, aOverN = 0, aOverSum = 0 }
|
||||
end
|
||||
|
||||
-- Empties in place -- never by replacing h.n/h.a with fresh tables. These may be ALIASED to a
|
||||
-- VampifyCharDB sub-table (core/commands.lua's wireSession, the same wiring the per-spell tables
|
||||
-- use), and reassigning them would orphan the persisted table instead of clearing it. Same
|
||||
-- reasoning as VampifyAggregate.resetSpells.
|
||||
function G.reset(h)
|
||||
if not h then return end
|
||||
for k in pairs(h.n) do h.n[k] = nil end
|
||||
for k in pairs(h.a) do h.a[k] = nil end
|
||||
h.nOverN, h.nOverSum, h.aOverN, h.aOverSum = 0, 0, 0, 0
|
||||
end
|
||||
|
||||
-- Records one TRIGGERING hit. The caller owes this function the same filtering the healing path
|
||||
-- applies (core/commands.lua drops VampifyConst.triggersVampirism == false before it gets here);
|
||||
-- what this function refuses on its own account is the one condition that is a property of the
|
||||
-- damage rather than of the spell: damage <= 1, on which the proc does not fire at all
|
||||
-- (core/model.lua). Floored FIRST, so the key and the value the model is later handed for that key
|
||||
-- are the same number.
|
||||
function G.add(h, damage, isAoE)
|
||||
if not h or not damage then return end
|
||||
damage = math.floor(damage)
|
||||
if damage <= 1 then return end
|
||||
if damage > G.CAP then
|
||||
if isAoE then
|
||||
h.aOverN, h.aOverSum = (h.aOverN or 0) + 1, (h.aOverSum or 0) + damage
|
||||
else
|
||||
h.nOverN, h.nOverSum = (h.nOverN or 0) + 1, (h.nOverSum or 0) + damage
|
||||
end
|
||||
return
|
||||
end
|
||||
local t = isAoE and h.a or h.n
|
||||
t[damage] = (t[damage] or 0) + 1
|
||||
end
|
||||
|
||||
-- Moves one already-recorded hit from the normal side to the AoE side. Exists for exactly one
|
||||
-- caller: core/commands.lua's retroactive AoE correction, which learns from a SECOND target that a
|
||||
-- burst already credited as normal damage was area damage after all. The healing is corrected
|
||||
-- there; without this the histogram would keep saying "normal" about a hit that was paid as AoE,
|
||||
-- and the baseline computed from it would no longer reproduce what was actually credited -- which
|
||||
-- is the one property the whole comparison rests on.
|
||||
--
|
||||
-- Does nothing if no such hit is on the normal side: inventing one on the AoE side would be worse
|
||||
-- than leaving the miscount, and the caller's own guards (it corrects only hits it credited) mean
|
||||
-- the miss is a bug elsewhere, not something to paper over here.
|
||||
function G.reclassify(h, damage)
|
||||
if not h or not damage then return end
|
||||
damage = math.floor(damage)
|
||||
if damage <= 1 then return end
|
||||
if damage > G.CAP then
|
||||
if not h.nOverN or h.nOverN <= 0 then return end
|
||||
h.nOverN, h.nOverSum = h.nOverN - 1, h.nOverSum - damage
|
||||
if h.nOverN <= 0 then h.nOverN, h.nOverSum = 0, 0 end
|
||||
h.aOverN, h.aOverSum = (h.aOverN or 0) + 1, (h.aOverSum or 0) + damage
|
||||
return
|
||||
end
|
||||
local c = h.n[damage]
|
||||
if not c or c <= 0 then return end
|
||||
-- nil, not 0: a zero-count key is a key, and keeping them would defeat the cap's whole point.
|
||||
if c <= 1 then h.n[damage] = nil else h.n[damage] = c - 1 end
|
||||
h.a[damage] = (h.a[damage] or 0) + 1
|
||||
end
|
||||
|
||||
-- ---- reading the distribution back ---------------------------------------------------------------
|
||||
--
|
||||
-- Everything below computes healing through VampifyModel -- never with its own copy of the
|
||||
-- formula. A second implementation of max(1, trunc(pct*D/100)) would drift from the real one the
|
||||
-- first time the model moves, and the drift would be invisible: both halves would still look
|
||||
-- self-consistent.
|
||||
|
||||
local function accumulate(t, overN, overSum, sources, factor, r)
|
||||
for d, c in pairs(t) do
|
||||
r.hits = r.hits + c
|
||||
r.heal = r.heal + VampifyModel.healPerSources(d, sources, factor) * c
|
||||
r.floorHeal = r.floorHeal + VampifyModel.floorHeal(d, sources, factor) * c
|
||||
end
|
||||
if overN and overN > 0 then
|
||||
-- The bundle, as its mean hit repeated overN times -- see G.CAP's comment for what that
|
||||
-- approximates and how far off it can be.
|
||||
local mean = overSum / overN
|
||||
r.hits = r.hits + overN
|
||||
r.heal = r.heal + VampifyModel.healPerSources(mean, sources, factor) * overN
|
||||
r.floorHeal = r.floorHeal + VampifyModel.floorHeal(mean, sources, factor) * overN
|
||||
end
|
||||
end
|
||||
|
||||
-- Total healing the recorded hits would return for `sources` (an array of PERCENT NUMBERS, the
|
||||
-- same shape core/commands.lua's sourcePercents has), and how much of it came from the FLOOR
|
||||
-- rather than from the percentages. `out` is reused rather than rebuilt, like every other result
|
||||
-- table in this addon.
|
||||
function G.totals(h, sources, out)
|
||||
out = out or {}
|
||||
out.hits, out.heal, out.floorHeal, out.floorShare = 0, 0, 0, 0
|
||||
if not h then return out end
|
||||
accumulate(h.n, h.nOverN, h.nOverSum, sources, 1, out)
|
||||
accumulate(h.a, h.aOverN, h.aOverSum, sources, VampifyConst.FORMULA.aoeFactor, out)
|
||||
if out.heal > 0 then out.floorShare = out.floorHeal / out.heal end
|
||||
return out
|
||||
end
|
||||
|
||||
-- NOTE (2026-08-24): this file USED to also carry G.splitHeal, an ST/AoE split read over h.n/h.a
|
||||
-- (deriveAoE's area-damage classification). Removed the same day the UI's ST/AoE split was
|
||||
-- redefined to be about WHICH UNIT WAS HIT (current target vs. everything else), not about area-damage
|
||||
-- classification -- see core/aggregate.lua's A.recordSpell and A.splitTotals for the full history.
|
||||
-- This histogram has no per-target dimension at all (it is keyed by damage VALUE, isAoE-classified
|
||||
-- normal/AoE only), so it cannot answer the new question and G.splitHeal became dead code; deleted
|
||||
-- rather than left unused. G.totals/G.compare below are UNCHANGED and still correct -- the
|
||||
-- upgrade-preview feature they back legitimately needs the AoE-DAMPING classification this histogram
|
||||
-- already keeps, which is a real, separate, still-measured server mechanic untouched by the split
|
||||
-- redefinition.
|
||||
|
||||
-- Scratch buffers for the hypothetical source lists and their results. Module-level and reused:
|
||||
-- G.compare runs from a slash command and an options-panel refresh, not from the damage path, but
|
||||
-- there is no reason for it to allocate four tables every time either.
|
||||
local addBuf, upBuf, swapBuf = {}, {}, {}
|
||||
local addOut, upOut, swapOut = {}, {}, {}
|
||||
|
||||
local function copyInto(buf, sources)
|
||||
VampifyConst.resetList(buf) -- table.insert's stale `n` would otherwise show through
|
||||
local n = sources and table.getn(sources) or 0
|
||||
for i = 1, n do table.insert(buf, sources[i]) end
|
||||
return buf
|
||||
end
|
||||
|
||||
-- "What would an upgrade be worth?", answered on the hits that actually fell rather than on a
|
||||
-- model example. Three hypotheticals against the current baseline:
|
||||
-- (a) ONE MORE source at p percent -- a spare enchant slot, another Vampirism piece
|
||||
-- (c) the WEAKEST existing source SWAPPED for one p points stronger
|
||||
-- (b) the STRONGEST existing source, p points stronger -- informative bound, not a decision
|
||||
--
|
||||
-- (a) VERSUS (c) IS THE REAL DECISION, and it is what the verdict below is computed from. Every
|
||||
-- Vampirism slot the player owns is already filled; a gear upgrade therefore does not ADD a
|
||||
-- percentage, it REPLACES one. And the piece a player replaces is the one holding them back --
|
||||
-- their WEAKEST Vampirism item, not their best. Both choices buy the same nominal point of total
|
||||
-- percentage, and they are worth wildly different amounts of healing, because a weak source is
|
||||
-- precisely the one still pinned to the floor: it is the only place where p points can be spent
|
||||
-- and return nothing at all (1% -> 2% of a 30 damage hit is 0.3 -> 0.6, both truncate to 0, both
|
||||
-- pay the same floor of 1), while a whole new source pays a full floor of 1 regardless.
|
||||
--
|
||||
-- (b) IS KEPT AS A BOUND, not as advice. Raising the STRONGEST source is the most generous thing
|
||||
-- p points can do -- that source is the likeliest to be clear of the floor, so it converts the
|
||||
-- points at full value. It answers "what is the very best a point could ever be worth here", which
|
||||
-- is worth showing, but nobody can buy it: it would mean finding a higher rank of the item you are
|
||||
-- already best served by. Reporting it as the alternative to (a) -- which this function did before
|
||||
-- the swap case existed -- overstated what an upgrade is worth exactly when the hits are small,
|
||||
-- which is the regime this whole feature exists to expose.
|
||||
--
|
||||
-- (a) and the raise cases are NOT symmetric even far above the floor, and the asymmetry runs the
|
||||
-- other way there: trunc(x+y) >= trunc(x) + trunc(y), so folding p into an existing source lets
|
||||
-- two truncated remainders combine into a whole point that two separate sources each throw away.
|
||||
-- Hence the reversal the feature is really about -- (a) wins on small hits, the swap on big ones,
|
||||
-- and far above the floor the two converge to within a rounding error of each other.
|
||||
--
|
||||
-- TIES for weakest raise exactly ONE source (strict > below): swapping one piece of gear is the
|
||||
-- move being priced, and upgrading every tied source at once would price a shopping trip.
|
||||
function G.compare(h, sources, p, out)
|
||||
out = out or {}
|
||||
p = p or 1
|
||||
G.totals(h, sources, out) -- fills hits / heal / floorHeal / floorShare
|
||||
out.p = p
|
||||
|
||||
copyInto(addBuf, sources)
|
||||
table.insert(addBuf, p)
|
||||
out.addHeal = G.totals(h, addBuf, addOut).heal
|
||||
out.addDelta = out.addHeal - out.heal
|
||||
out.addPct = 0
|
||||
if out.heal > 0 then out.addPct = out.addDelta / out.heal * 100 end
|
||||
|
||||
local n = sources and table.getn(sources) or 0
|
||||
local best, bestI, worst, worstI
|
||||
for i = 1, n do
|
||||
if best == nil or sources[i] > best then best, bestI = sources[i], i end
|
||||
-- Strict <, so the FIRST of several equally weak sources wins and only that one is
|
||||
-- swapped -- see the header. A <= here would keep sliding to the last tied index; still
|
||||
-- one source, but it would make which piece is named depend on list order for no reason.
|
||||
if worst == nil or sources[i] < worst then worst, worstI = sources[i], i end
|
||||
end
|
||||
|
||||
-- (c) the swap: the weakest source replaced by one p points stronger.
|
||||
out.swapIndex, out.swapFrom = worstI, worst
|
||||
if worstI then
|
||||
out.swapTo = worst + p
|
||||
copyInto(swapBuf, sources)
|
||||
swapBuf[worstI] = swapBuf[worstI] + p
|
||||
out.swapHeal = G.totals(h, swapBuf, swapOut).heal
|
||||
out.swapDelta = out.swapHeal - out.heal
|
||||
out.swapPct = 0
|
||||
if out.heal > 0 then out.swapPct = out.swapDelta / out.heal * 100 end
|
||||
else
|
||||
-- Nothing equipped: there is no piece to replace. Same reasoning as (b) below -- "+0" would
|
||||
-- read as a measurement rather than as "the question does not apply".
|
||||
out.swapTo, out.swapHeal, out.swapDelta, out.swapPct = nil, out.heal, 0, 0
|
||||
end
|
||||
|
||||
out.upIndex, out.upFrom = bestI, best
|
||||
if bestI then
|
||||
out.upTo = best + p
|
||||
copyInto(upBuf, sources)
|
||||
upBuf[bestI] = upBuf[bestI] + p
|
||||
out.upHeal = G.totals(h, upBuf, upOut).heal
|
||||
out.upDelta = out.upHeal - out.heal
|
||||
out.upPct = 0
|
||||
if out.heal > 0 then out.upPct = out.upDelta / out.heal * 100 end
|
||||
else
|
||||
-- Nothing equipped: there is no source to make stronger, and saying "+0" would read as a
|
||||
-- measurement rather than as "the question does not apply".
|
||||
out.upTo, out.upHeal, out.upDelta, out.upPct = nil, out.heal, 0, 0
|
||||
end
|
||||
|
||||
-- The verdict, decided between (a) and (c) ONLY -- the two things a player can actually go and
|
||||
-- do. (b) is deliberately not in the running: it is an upper bound on what a point is worth,
|
||||
-- not an option, and letting it win would answer a question nobody asked.
|
||||
if out.addDelta > out.swapDelta then
|
||||
out.winner, out.winnerBy = "add", out.addDelta - out.swapDelta
|
||||
elseif out.swapDelta > out.addDelta then
|
||||
out.winner, out.winnerBy = "swap", out.swapDelta - out.addDelta
|
||||
else
|
||||
out.winner, out.winnerBy = "tie", 0
|
||||
end
|
||||
return out
|
||||
end
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
-- Vampify -- the Vampirism formula. Pure Lua, no WoW API, offline-tested.
|
||||
--
|
||||
-- SERVER-CONFIRMED FORMULA (2026-08-22, TurtleWoW patch_1172 source,
|
||||
-- UnitAuraProcHandler.cpp:764-785). Every Vampirism source is its OWN aura. For a hit that
|
||||
-- qualifies at all, EACH source heals independently:
|
||||
--
|
||||
-- heal_i(D) = max(1, trunc(pct_i * D / 100)) -- C++ UNSIGNED integer division = truncation
|
||||
-- heal(D) = sum_i heal_i(D) for D > 1
|
||||
-- = 0 for D <= 1 (the proc does not fire at all)
|
||||
--
|
||||
-- No summed percentage, no single global floor, no float carry between hits -- the server computes
|
||||
-- and applies this exactly, per hit, per source, as an integer, immediately. This SUPERSEDES the
|
||||
-- v0.1/v0.2 model (heal = max(n, D * P * a), P the SUMMED percentage, one float accumulator with
|
||||
-- "crossings" as the shown number), which was a plausible reconstruction from AGGREGATE
|
||||
-- measurements (total percent, source count, in-game HP counter) made before the source was
|
||||
-- available to read. The two happen to agree at damage values every source's share divides evenly
|
||||
-- (the source ledger below is a case of exactly that), which is why the old model tracked live
|
||||
-- percentages as well as it did -- but they diverge as soon as truncation bites unevenly across
|
||||
-- sources, which is the common case.
|
||||
--
|
||||
-- WHY PER-SOURCE, NOT SUMMED-THEN-TRUNCATED: truncation is not linear. 40 damage with two sources
|
||||
-- at 2% each floors to 1 + 1 = 2 (each source's own share, 0.8, truncates to 0, floors to 1); the
|
||||
-- SUM first would compute trunc(4% * 40 / 100) = trunc(1.6) = 1, a different (and per the source,
|
||||
-- wrong) answer. A summed percentage cannot be truncated back into individual per-source integers
|
||||
-- after the fact -- the source list has to be truncated one at a time, in order, which is exactly
|
||||
-- what M.healPerSources below does.
|
||||
--
|
||||
-- THE AoE FACTOR IS NOT IN THIS SOURCE EXCERPT. The 0.7 damping applied to AoE hits was measured
|
||||
-- independently, TWICE, in-game (Flame Wave, 2026-08-07..09) -- UnitAuraProcHandler.cpp:764-785
|
||||
-- says nothing about area damage at all. It is kept here as a configurable extra factor
|
||||
-- (VampifyConst.FORMULA.aoeFactor, still 0.7 by default): the STRUCTURE (per-source, trunc, floor
|
||||
-- 1, integer, immediate) is confirmed from the server source; the AoE VALUE is confirmed only from
|
||||
-- measurement, and where in the pipeline it belongs is an OPEN QUESTION the source is silent on --
|
||||
-- see M.healPerSources' own comment for exactly where this file puts it, and how a future
|
||||
-- measurement (a single AoE-classified item-proc hit, where every source has a distinct percent so
|
||||
-- the two possible orderings give different totals) could refute the assumption.
|
||||
|
||||
VampifyModel = {}
|
||||
local M = VampifyModel
|
||||
|
||||
-- Per-source, per-hit integer heal -- the actual server computation, read straight off the source
|
||||
-- excerpt above. `sources` is a plain array of PERCENT NUMBERS (e.g. {3, 2, 2, 2} for a 3% item
|
||||
-- plus three 2% sources), NOT fractions and NOT pre-summed -- each entry is truncated
|
||||
-- INDEPENDENTLY (see the file header for why a summed percentage cannot reproduce this).
|
||||
--
|
||||
-- `factor` is the AoE damping multiplier (VampifyConst.FORMULA.aoeFactor for an AoE-classified
|
||||
-- hit, or nil/1 for a normal one) -- see the file header for the open question of whether this
|
||||
-- belongs here at all. APPLIED TO DAMAGE, before max(1, trunc(...)): a damped AoE hit still floors
|
||||
-- each source at 1 as a whole, rather than computing a fractional per-source result (e.g. 0.7) that
|
||||
-- would need a SECOND rounding step the source excerpt gives no basis for. This is the pragmatic
|
||||
-- choice, not a measured one -- the alternative (compute each source's un-damped heal_i, THEN
|
||||
-- multiply by 0.7 and round) would floor every source's contribution to at least 1 point BEFORE
|
||||
-- damping and could round it back up afterward, systematically healing MORE on AoE than this
|
||||
-- ordering does. A hit where the two orderings disagree (mixed-percent sources, moderate damage)
|
||||
-- and its measured healing is the only way to settle which one the server actually does.
|
||||
-- Fills the REUSABLE buffer `out` with the per-source breakdown of one hit's heal: `out[i].pct`
|
||||
-- (the source's own percent), `out[i].heal` (its truncated-and-floored integer share), `out[i].
|
||||
-- floored` (true if its own share truncated to 0 and it therefore paid the floor of 1, not its
|
||||
-- percentage), plus `out.total` (the summed heal, identical to M.healPerSources' return value) and
|
||||
-- `out.n` (the source count). THIS is where the truncation loop actually lives now --
|
||||
-- M.healPerSources and M.floorHeal below are both thin readers of this same computation, so the
|
||||
-- arithmetic itself exists in exactly one place (see the file header for why a second copy would
|
||||
-- drift the first time the formula moves).
|
||||
--
|
||||
-- Buffer-recycled like every other reused list in this addon (VampifyConst.resetList's own
|
||||
-- comment): row tables at 1..n are kept and overwritten, not reallocated, and any trailing rows
|
||||
-- from a longer previous call are nil'd out so a caller never sees stale data from a prior hit --
|
||||
-- the classic bug this shape invites. `out` may be a fresh {} or a table already used for a
|
||||
-- previous call; either way this function owns filling and trimming it completely.
|
||||
function M.healBreakdown(damage, sources, factor, out)
|
||||
out = out or {}
|
||||
local n = (not damage or damage <= 1) and 0 or (sources and table.getn(sources) or 0)
|
||||
-- damage <= 1: the proc does not fire at all -- confirmed in the source; NOT the old model's
|
||||
-- "damage <= 0" cutoff, deliberately wider (see M.healPerSources' old comment, now here).
|
||||
if n > 0 then
|
||||
factor = factor or 1
|
||||
local effDamage = damage * factor
|
||||
local total = 0
|
||||
for i = 1, n do
|
||||
local e = out[i]
|
||||
if not e then e = {}; out[i] = e end
|
||||
local pct = sources[i] or 0
|
||||
-- math.floor == C's truncation here because every operand is non-negative (percent,
|
||||
-- damage and factor are all >= 0 by construction -- a negative damage value already
|
||||
-- zeroed n above, and callers never pass a negative percent or factor).
|
||||
local raw = math.floor(pct * effDamage / 100)
|
||||
local floored = raw < 1
|
||||
if floored then raw = 1 end
|
||||
e.pct, e.heal, e.floored = pct, raw, floored
|
||||
total = total + raw
|
||||
end
|
||||
out.total = total
|
||||
else
|
||||
out.total = 0
|
||||
end
|
||||
for i = table.getn(out), n + 1, -1 do out[i] = nil end
|
||||
table.setn(out, n)
|
||||
out.n = n
|
||||
return out
|
||||
end
|
||||
|
||||
local _hpsBuf = {} -- module-local, NOT the caller's buffer: M.healPerSources only ever needs the
|
||||
-- total, so its own per-source rows are an internal scratch pad, reused
|
||||
-- across every call (module scope, never rebuilt) rather than allocated per
|
||||
-- hit -- the hot path (core/commands.lua's onDamage listener) calls this on
|
||||
-- every damage event.
|
||||
function M.healPerSources(damage, sources, factor)
|
||||
return M.healBreakdown(damage, sources, factor, _hpsBuf).total
|
||||
end
|
||||
|
||||
-- How much of M.healPerSources' answer came from the FLOOR rather than from the percentage: the
|
||||
-- number of sources whose own truncated share is 0, each of which therefore paid exactly 1. Same
|
||||
-- arguments, same truncation, same AoE ordering as M.healPerSources above -- deliberately in this
|
||||
-- file and directly beside it, because it is the SAME arithmetic read a second way, and a copy of
|
||||
-- that truncation anywhere else would drift from it the first time the formula moves.
|
||||
--
|
||||
-- Why this number is worth having: the floor is what makes healing non-linear in damage. While a
|
||||
-- source is on the floor it pays 1 whatever its percentage is, so on small hits MANY SMALL sources
|
||||
-- beat FEW LARGE ones, and above the floor they are near enough interchangeable. Whether a player
|
||||
-- is in that regime is not visible from any total -- only from this.
|
||||
local _floorBuf = {} -- module-local scratch pad, same reasoning as M.healPerSources' _hpsBuf
|
||||
-- above -- kept SEPARATE from it so the two callers never clobber each
|
||||
-- other's rows if used back-to-back for the same hit.
|
||||
function M.floorHeal(damage, sources, factor)
|
||||
local out = M.healBreakdown(damage, sources, factor, _floorBuf)
|
||||
local floored = 0
|
||||
for i = 1, out.n do
|
||||
if out[i].floored then floored = floored + 1 end
|
||||
end
|
||||
return floored
|
||||
end
|
||||
|
||||
-- Compatibility shape for a caller that only has the AGGREGATE figures (summed percent, source
|
||||
-- count) rather than the real per-source list -- e.g. an old fixture, or a display line that only
|
||||
-- ever wanted "total % from N sources" and never needed the individual percentages. It approximates
|
||||
-- the source list as N EQUAL shares of sumPercent (pct_i = sumPercent*100/n for every i) and runs
|
||||
-- the SAME M.healPerSources underneath, so it inherits the real truncation/floor behaviour rather
|
||||
-- than reviving the old linear formula in parallel.
|
||||
--
|
||||
-- NOT EXACT for an uneven real source set (truncation is non-linear -- see the file header), and no
|
||||
-- production call site uses this anymore: core/commands.lua's onDamage listener calls
|
||||
-- M.healPerSources directly with the real per-source list (recompute()'s sourcePercents). Kept so
|
||||
-- this call shape still means something sensible for whatever still uses it.
|
||||
function M.heal(damage, sumPercent, nSources, isAoE)
|
||||
if not nSources or nSources <= 0 then return 0 end
|
||||
local factor = 1
|
||||
if isAoE then factor = VampifyConst.FORMULA.aoeFactor end
|
||||
local pct = (sumPercent or 0) * 100 / nSources
|
||||
local sources = {}
|
||||
for i = 1, nSources do sources[i] = pct end
|
||||
return M.healPerSources(damage, sources, factor)
|
||||
end
|
||||
|
||||
-- ---- running totals -----------------------------------------------------------------------------
|
||||
--
|
||||
-- NO LONGER A FRACTIONAL CARRY. The v0.1/v0.2 model needed a float accumulator because its formula
|
||||
-- produced a continuous (non-integer) heal per hit, and the measured display behaviour (two
|
||||
-- identical 251-damage hits healing 18 then 17) only made sense as CROSSINGS of that running float,
|
||||
-- never a per-hit rounding. The server formula above produces an EXACT INTEGER per hit, always --
|
||||
-- there is no fractional remainder to carry, so there is no crossing to compute and no "shown
|
||||
-- amount can differ from the raw heal" case anymore: M.add's two return values are now always
|
||||
-- equal (see below).
|
||||
--
|
||||
-- acc.total is KEPT, not removed, but its job changed: it is now a plain running SUM of the exact
|
||||
-- integers credited so far, not a fractional ledger. Two things still need it in that shape:
|
||||
-- * core/commands.lua's retroactive AoE correction (a hit reclassified from non-AoE to AoE after
|
||||
-- a second target proves the burst was area damage) needs to know the OLD credited amount so it
|
||||
-- can un-credit it and credit the new one -- a running total is the natural place to apply that
|
||||
-- delta, and VampifyAggregate's fight/session sums stay in lockstep with it by construction
|
||||
-- (both are fed the identical per-hit integer, never a crossing).
|
||||
-- * core/watch.lua's I3 invariant (the SCT-shown total must equal what the model actually
|
||||
-- credited) still catches a real class of bug -- a hit recorded into the total but never
|
||||
-- emitted, or a correction applied wrong -- even though it can no longer catch the OLD bug
|
||||
-- class (floor-of-accumulator drifting from a sum of crossings), which cannot happen anymore
|
||||
-- because there is no floor step left to drift.
|
||||
function M.newAcc()
|
||||
return { total = 0 }
|
||||
end
|
||||
|
||||
-- Returns (inc, heal): the integer to show for THIS hit, and the raw heal it was computed from --
|
||||
-- the SAME number now, always. Kept as two return values (rather than collapsing to one) purely so
|
||||
-- core/commands.lua's existing shape ("inc is what the SCT shows, healFloat is what effInc/overheal
|
||||
-- get computed from") did not have to change, even though nothing can make them differ anymore.
|
||||
function M.add(acc, damage, sources, isAoE)
|
||||
local factor = 1
|
||||
if isAoE then factor = VampifyConst.FORMULA.aoeFactor end
|
||||
local h = M.healPerSources(damage, sources, factor)
|
||||
acc.total = acc.total + h
|
||||
return h, h
|
||||
end
|
||||
|
||||
function M.shown(acc)
|
||||
return math.floor(acc.total)
|
||||
end
|
||||
|
||||
-- Split the integer shown for one hit into the part that landed and the part that overhealed. The
|
||||
-- two MUST partition the integer: emitting the full amount and an overheal number beside it makes
|
||||
-- one hit look like two, which is what happened at full health before this existed.
|
||||
--
|
||||
-- Still meaningful with an exact-integer heal: healFloat (this hit's whole heal) and effFloat (the
|
||||
-- slice of it capped by the health deficit, from M.effective below) can still differ whenever the
|
||||
-- hit landed at or near full health, so there is still a real split to compute -- it is no longer
|
||||
-- "proportional because the shown integer is a crossing with no exact per-hit boundary" (there is
|
||||
-- no crossing left), it is proportional because splitOverheal is not told exactly which of the n
|
||||
-- per-source integers landed and which overhealed, only the hit's total and its capped total.
|
||||
function M.splitOverheal(inc, healFloat, effFloat)
|
||||
inc = inc or 0
|
||||
if inc <= 0 then return 0, 0 end
|
||||
if not healFloat or healFloat <= 0 then return inc, 0 end
|
||||
local landed = effFloat or 0
|
||||
if landed < 0 then landed = 0 end
|
||||
if landed > healFloat then landed = healFloat end
|
||||
local ohShare = (healFloat - landed) / healFloat
|
||||
local oh = math.floor(inc * ohShare + 0.5)
|
||||
if oh > inc then oh = inc end
|
||||
return inc - oh, oh
|
||||
end
|
||||
|
||||
function M.effective(healFloat, deficit)
|
||||
if not deficit or deficit <= 0 then return 0 end
|
||||
if healFloat > deficit then return deficit end
|
||||
return healFloat
|
||||
end
|
||||
|
||||
-- ---- percentage rounding (shared by core/aggregate.lua and gui/display.lua) ---------------------
|
||||
--
|
||||
-- Moved here from gui/display.lua (2026-08-24): both a core/ module (core/aggregate.lua's
|
||||
-- A.spellSourceBreakdown) and a gui/ module (gui/display.lua's V.buildDetailLines and its mouseover
|
||||
-- breakdown) need this rounding, and core/ must not depend on gui/ -- core/aggregate.lua was calling
|
||||
-- VampifyDisplay.shareOfTotal directly, which only worked because the call is resolved at runtime,
|
||||
-- after the whole addon (including gui/) has loaded; it broke the moment core/ needed to load or be
|
||||
-- tested without gui/. core/model.lua is the natural home: it already loads before both
|
||||
-- core/aggregate.lua and gui/display.lua in Vampify.toc, and it takes no dependency of its own on
|
||||
-- either. gui/display.lua keeps VampifyDisplay.shareOfTotal as a thin alias below, so nothing that
|
||||
-- calls it by that name (including the offline tests) has to change.
|
||||
--
|
||||
-- Each ability's share of the TOTAL Vampirism healing, in percent, one decimal.
|
||||
--
|
||||
-- This replaced a column that divided each row's healing by its own damage. That answered "how
|
||||
-- much does this ability give back" -- useful, but it left the column adding up to nothing, so
|
||||
-- the table could not answer the question people actually ask it: where does my healing come
|
||||
-- from. Both figures remain readable side by side anyway, since the row still prints its own
|
||||
-- Damage and Vamp.
|
||||
--
|
||||
-- Largest-remainder rounding, not plain rounding: three equal abilities are 33.333% each, and
|
||||
-- printing 33.3 three times gives a column that adds to 99.9. A percentage column a reader can
|
||||
-- add up and land somewhere other than 100 invites exactly the doubt the table exists to remove.
|
||||
-- The leftover tenths go to the rows with the largest remainders, which -- since the breakdown
|
||||
-- arrives sorted by healing -- means the biggest contributors absorb them and the relative error
|
||||
-- stays smallest.
|
||||
--
|
||||
-- Rows are read, never written. Returns a pooled buffer, same convention as gui/display.lua's
|
||||
-- mergeByName: the breakdown repaints on mouseover, and a fresh table per repaint is avoidable
|
||||
-- churn.
|
||||
local shareBuf = {}
|
||||
local floorBuf = {}
|
||||
local remBuf = {}
|
||||
local tookBuf = {}
|
||||
|
||||
function M.shareOfTotal(rows)
|
||||
local n = table.getn(rows)
|
||||
local i
|
||||
for i = table.getn(shareBuf), n + 1, -1 do shareBuf[i] = nil end
|
||||
|
||||
local total = 0
|
||||
for i = 1, n do total = total + (rows[i].heal or 0) end
|
||||
if total <= 0 then
|
||||
for i = 1, n do shareBuf[i] = 0 end
|
||||
return shareBuf
|
||||
end
|
||||
|
||||
-- Work in tenths of a percent so the whole distribution is integer arithmetic.
|
||||
local assigned = 0
|
||||
for i = 1, n do
|
||||
local raw = (rows[i].heal or 0) / total * 1000
|
||||
floorBuf[i] = math.floor(raw)
|
||||
remBuf[i] = raw - floorBuf[i]
|
||||
tookBuf[i] = false
|
||||
assigned = assigned + floorBuf[i]
|
||||
end
|
||||
|
||||
-- Hand out the leftover tenths, largest remainder first.
|
||||
-- Selection loop rather than table.sort -- a comparator that is not strict for equal values
|
||||
-- raises "invalid order function" in Lua 5.0, and equal remainders are the normal case here.
|
||||
--
|
||||
-- The heal > 0 test below is belt and braces and CANNOT currently fire: every row truncates
|
||||
-- away less than one tenth, so leftover is always smaller than the number of rows that had
|
||||
-- a fractional part -- and those are exactly the rows with healing. A zero-healing row has
|
||||
-- remainder 0 and sorts last, so the loop runs out of leftovers before reaching it. Kept
|
||||
-- because a future change to the rounding base would make the reasoning worth re-checking,
|
||||
-- not because it is load-bearing today. (Found by mutation: deleting it breaks no test.)
|
||||
local leftover = 1000 - assigned
|
||||
while leftover > 0 do
|
||||
local best, bestRem = nil, -1
|
||||
for i = 1, n do
|
||||
if not tookBuf[i] and (rows[i].heal or 0) > 0 and remBuf[i] > bestRem then
|
||||
best, bestRem = i, remBuf[i]
|
||||
end
|
||||
end
|
||||
if not best then break end -- nothing eligible left; drop the rest
|
||||
floorBuf[best] = floorBuf[best] + 1
|
||||
tookBuf[best] = true
|
||||
leftover = leftover - 1
|
||||
end
|
||||
|
||||
for i = 1, n do shareBuf[i] = floorBuf[i] / 10 end
|
||||
return shareBuf
|
||||
end
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
-- 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
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
-- 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
|
||||
Reference in New Issue
Block a user