commit b3a282aeb59562198c3eced0f85e08dc26d0b783 Author: ShempError <689+shemperror@noreply.octowow.st> Date: Tue Aug 25 18:05:45 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b712fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Editor / OS noise +.DS_Store +Thumbs.db +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..45b0ae0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,74 @@ +# Changelog + +All notable changes to Vampify are documented here. + +## Unreleased + +### Added +- MIT license, this changelog, and a README written for players rather than for the author. + +### Fixed +- Long lines in a row's detail panel are wrapped instead of being drawn over the lines below them. +- Tooltips raised while the breakdown panel is open now appear above it rather than behind it. +- The whole bar can be dragged when it is unlocked. Several parts of it — the leftmost number, the + ratio pill, the three small buttons — used to swallow the drag and act as dead spots. The resize + grip still resizes. +- Bar fills, their shading and their gloss are each drawn on their own layer. They previously shared + one, where this client leaves the order between them undefined and re-decides it on every reload, + so a bar could render flat, or in one reported case not at all. + +### Changed +- The display bar is compact: the prefix and the session ratio are gone, and it shows the equipped + loadout percentage together with the number of sources it found. +- The per-hit export records maximum health per hit, so an offline analysis can tell overheal apart + from healing that never arrived. + +## 0.3.0 — 2026-08-22 + +### Changed +- **The healing model now matches the server.** Each equipped source is evaluated as its own + integer contribution instead of one summed float, which is what the server actually does. The + displayed numbers changed accordingly. +- A damaging hit of 1 or less returns nothing at all — the proc does not fire there, and no + per-source floor applies. +- The damage-over-time channel is measured rather than estimated; the display no longer hedges it. +- AoE damping is applied only where a hit is proven to have struck two or more distinct targets, + rather than inferred from the spell. + +### Added +- Per-hit debug export for measuring the per-source factor, with a persisted toggle and an options + checkbox. +- Spell exclusions: a spell can be stopped from counting as a Vampirism trigger, and the exclusion + survives a reload. + +### Fixed +- The raw per-hit capture no longer depends on the watch-mode group guard. +- The persisted session breakdown survives a `/reload`. +- The "damage events appear to be missing" verdict is bounded to a single fight and to silence it + can actually trust, instead of firing on a quiet moment. +- An AoE correction removes the integers it actually credited rather than reconstructing them. +- A mid-fight gear swap is only checked for while a fight is running. + +## 0.2.0 — 2026-08-10 + +### Added +- Minimap button, options window, and overheal reporting. +- Per-ability mouseover breakdown with real columns, plus a reset icon on the bar. +- Vampify's own scrolling combat text, selectable alongside Blizzard's, with a colour picker, + font-size and rise-height sliders, and an SCT demo. +- The addon reports its own errors instead of waiting to be told about them. +- A watchdog that checks the running totals against the accumulator and surfaces findings. + +### Changed +- An AoE cast is coalesced into one number instead of one per hit. +- The display was raised out of the action bars and given a backdrop so it can be found. +- Damage shields are excluded from the calculation. + +### Fixed +- Reusing a list buffer needs `table.setn` under Lua 5.0 — without it the options window threw. +- Colour-picker layering and dragging, and a scoping bug that broke the breakdown panel. + +## 0.1.0 — 2026-08-10 + +- First working version: detects equipped Vampirism sources, computes the returned healing from + your own outgoing damage, and displays a running total. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1bc95c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ShempError + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b4a8ef --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# Vampify + +A TurtleWoW 1.12.1 addon that shows the healing you get back from the **Vampirism** item stat. + +The game emits no event for that healing — it happens silently, folded into your health bar. Vampify +computes it from your own outgoing damage using a formula measured in-game, and shows it live on a +small movable bar, optionally as scrolling combat text. + +The breakdown panel with one ability's detail open + +*Hover the bar for the per-ability breakdown; hover a row for that ability's own detail — hit count, +average damage per hit, the return rate it actually achieved against the rate your gear promises, +and whether the per-hit floor is what is carrying it.* + +## Screenshots + + + + + + + +
+ The compact bar
+ The bar — this fight's returned health, the single-target/AoE split, HPS, gear total +
+ The breakdown panel
+ Breakdown — every ability that returned health, session, lifetime or last fight +
+ The options window
+ Options — every setting, plus what the addon can and cannot see +
+ +## What it does + +- **Live total** of Vampirism healing for the current session and for the character's lifetime. +- **Per-ability breakdown** on mouseover: which spells and attacks actually returned health. +- **Overheal split** — how much of the returned healing was wasted at full health. +- **Automatic source detection**: reads your equipped items and enchants and sums their Vampirism + percentages. Sources it cannot read from a tooltip can be added by hand. +- **AoE handling**: a multi-target hit returns less per point of damage, and Vampify accounts for it + rather than reporting a confident wrong number. +- **Scrolling combat text**, either through Blizzard's own engine or Vampify's, with its own colour, + font size and anchor. +- **It tells you when it cannot see** — if no damage events arrive while you are visibly fighting, + it says so instead of displaying a reassuring zero. + +## The formula + +``` +heal(D) = 0 if D <= 1 + = max(nSources, D * P * a) otherwise +``` + +`P` is the summed percentage of your equipped Vampirism sources, `a` is `0.7` on AoE hits, and the +per-hit floor equals the number of equipped sources. Healing accumulates as a float; every integer +you see is a crossing of that accumulator rather than a per-hit rounding. That is what reproduces +the measured behaviour where two identical 251-damage hits healed 18 and then 17. + +The constants are pinned in `core/const.lua`, each with the observation it came from. + +## Install + +1. Close the game. +2. Copy the `Vampify` folder into `\Interface\AddOns\` so that `Vampify.toc` sits at + `\Interface\AddOns\Vampify\Vampify.toc`. +3. Start the game and enable **Vampify** in the character-select AddOns list. + +## Requirements + +- **WoW 1.12.1 client (TurtleWoW).** +- **nampower — required.** Vampify reads your outgoing damage from the packet-derived events + nampower provides (`SPELL_DAMAGE_EVENT_SELF`, `AUTO_ATTACK_SELF`, `SPELL_GO_SELF`). Without it + the addon has no damage to work from and will say so rather than show zeros. Developed against + nampower v4.6.2. +- **SuperWoW — optional.** Used to resolve spell names for the breakdown and to write the debug + export. Everything else works without it. + +## Usage + +`/vampify` or `/vf`: + +| Command | What it does | +|---|---| +| `/vf status` | equipped sources and current totals | +| `/vf options` | open the options window | +| `/vf reset [session\|lifetime\|both]` | reset totals (default: session) | +| `/vf pos` | move the display back to its default position | +| `/vf lock` | toggle dragging of the display | +| `/vf fct` / `/vf sct` | toggle scrolling combat text (either engine) | +| `/vf sctmove` | reposition Vampify's own SCT anchor | +| `/vf source add ` | add a source by hand, if tooltip detection missed it | +| `/vf source clear` | drop all manually added sources | +| `/vf exclude add ` | stop a spell from counting as a Vampirism trigger | +| `/vf exclude remove ` | undo a runtime exclusion | +| `/vf exclude list` | show excluded spells | +| `/vf perhit on\|off\|status` | per-hit debug export | + +There is also a **minimap button**: left-click toggles the display, right-click opens the options. + +## Options + +The options window (`/vf options`, the minimap button's right-click, or the button on the bar) +covers every setting: show and lock the display, minimap button, overheal reporting, the gear +percentage on the bar, scrolling combat text and which of the three engines draws it, one number +per cast instead of per hit, SCT colour, anchor, font size, rise height and outline, the heights of +the bar and of the breakdown panel's rows, the per-hit debug export, and buttons to reset the +totals or bring the bar home. + +Below the controls sits a panel that states what the addon currently sees: every Vampirism source +it read off your gear and enchants, the summed total, the per-hit floor, how much of your healing +came from that floor rather than from the percentage, what one more percent would be worth on your +present numbers — and, in plain words, the two things it cannot measure. + +## For developers + +Everything under `core/` is pure Lua with no WoW API calls, and every WoW-facing file keeps its +wiring behind a single `if CreateFrame then` guard. That split exists so the logic can be run and +tested outside the game, against a real Lua 5.0.3 interpreter — the same language version the 1.12 +client runs, which catches the classic traps (`#table`, string-method call syntax, `string.match`) +that a newer interpreter would silently accept and the client would then throw on. + +The test suite itself is developed alongside the addon but is not part of this distribution. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/Vampify.toc b/Vampify.toc new file mode 100644 index 0000000..67159b8 --- /dev/null +++ b/Vampify.toc @@ -0,0 +1,25 @@ +## Interface: 11200 +## Title: Vampify +## Notes: Tracks the healing returned by the Vampirism item stat. +## Author: ShempError +## Version: 0.3.0 +## SavedVariables: VampifyDB +## SavedVariablesPerCharacter: VampifyCharDB + +core\const.lua +core\model.lua +core\histogram.lua +core\aggregate.lua +core\watch.lua +core\perhit.lua +core\config.lua +capture\detect.lua +capture\damage.lua +capture\incoming.lua +gui\widgets.lua +gui\sct.lua +gui\display.lua +gui\fct.lua +gui\minimap.lua +gui\options.lua +core\commands.lua diff --git a/capture/damage.lua b/capture/damage.lua new file mode 100644 index 0000000..9372a5b --- /dev/null +++ b/capture/damage.lua @@ -0,0 +1,304 @@ +-- Vampify -- the player's own outgoing damage, via nampower's packet-derived events. +-- +-- Argument layouts are for nampower v4.6.2 (the KB documents v2.38, two majors back -- do not +-- take layouts from there). They are confirmed against RECORDED rows, not just documented: +-- +-- ev=SPELL_DAMAGE_EVENT_SELF;args=0xF130001B89000CFF~0x0000000000000663~45500~70~0,0,0~0~0~2,64,0,0 +-- target caster(player) spell amt +-- ev=AUTO_ATTACK_SELF;args=0x0000000000000663~0xF130001B8200154B~323~2~1~1~0~0~0 +-- attacker(player) target amt blk/abs/res +-- +-- Note args 5 and 8 of the spell event are COMMA-JOINED STRINGS ("0,0,0"), not numbers -- never +-- assume a scalar. Arg5 (mitigation) IS parsed now, by G.mitigationOf/G.netSpell below (summed +-- blind, field order unverified). Arg8 (effectAura) is still not parsed by anything here. +-- +-- The two damage events differ in shape AND in their hitInfo enum: +-- +-- SPELL_DAMAGE_EVENT_SELF: 1 targetGuid 2 casterGuid 3 spellId 4 amount 5 mitigation +-- 6 hitInfo (crit 0x02) 7 school 8 effectAura +-- AUTO_ATTACK_SELF: 1 attackerGuid 2 targetGuid 3 totalDamage 4 hitInfo (crit 0x80) +-- 5 victimState 6 subDamageCount 7 blocked 8 absorbed 9 resisted +-- SPELL_GO_SELF: 1 itemId 2 spellId 3 casterGuid 4 targetGuid 5 castFlags +-- 6 numTargetsHit 7 numTargetsMissed 8 corpseOwnerGuid +-- +-- DAMAGE_SHIELD_SELF is deliberately NOT registered: damage shields were measured NOT to trigger +-- Vampirism (Thorns 236 triggers, Thorium Shield Spike 27 -- zero healing from either). The +-- *_OTHER family is not registered either: totem and pet damage is believed not to trigger, and +-- those events fire for every foreign unit, which is a raid-load problem for no benefit. + +VampifyDamage = {} +local G = VampifyDamage + +local function num(v) return tonumber(v) or 0 end + +function G.decodeSpell(a1, a2, a3, a4, a5, a6, a7, a8) + return num(a4), a1, tonumber(a3), a2 +end + +function G.decodeAuto(a1, a2, a3, a4, a5, a6, a7, a8, a9) + return num(a3), a2, num(a7), num(a8), num(a9), a1 +end + +-- Crit extraction. The two events use DIFFERENT bits -- 0x02 on the spell event, 0x80 on the auto +-- attack -- and Lua 5.0 has no bitwise operators, so the bit is pulled out arithmetically. +-- +-- Vampirism itself cannot crit. This is carried only so a return CAUSED by a crit can be shown as +-- one, the way Blizzard's combat text distinguishes them. +local function bitSet(v, bitValue) + local n = tonumber(v) + if not n then return false end + return math.mod(math.floor(n / bitValue), 2) == 1 +end + +function G.isCritSpell(hitInfo) return bitSet(hitInfo, 2) end +function G.isCritMelee(hitInfo) return bitSet(hitInfo, 128) end + +function G.netOf(amount, blocked, absorbed, resisted) + local n = num(amount) - num(blocked) - num(absorbed) - num(resisted) + if n < 0 then return 0 end + return n +end + +-- The SPELL_DAMAGE_EVENT_SELF mitigation field (arg5) in one place: a comma-joined STRING +-- ("0,0,0"), never scalars -- same wire shape capture/incoming.lua's I.mitigationOf already +-- parses for the OTHER-direction twin of this exact event (SPELL_DAMAGE_EVENT_OTHER). Duplicated +-- here rather than called there: damage.lua loads BEFORE incoming.lua (Vampify.toc), and the two +-- files are deliberately independent (this file's own header -- _SELF vs _OTHER, different +-- concern). The FORMAT is not a second guess, only the parser is a second copy of it -- summed +-- blind, not attributed to blocked/absorbed/resisted, because the field ORDER within the string +-- is unverified (same caveat I.mitigationOf carries); only the total is needed here. +function G.mitigationOf(s) + if type(s) ~= "string" or s == "0,0,0" or s == "" then return 0 end + local total, from = 0, 1 + while true do + local p = string.find(s, ",", from, true) + if not p then + total = total + num(string.sub(s, from)) + break + end + total = total + num(string.sub(s, from, p - 1)) + from = p + 1 + end + return total +end + +-- Nets a SPELL_DAMAGE_EVENT_SELF amount the same way G.netOf nets an auto attack -- gated on the +-- SAME VampifyConst.FORMULA.damageBase setting (core/const.lua), which SETTLES the net-vs-gross +-- question (spec 4a, 2026-08-10: 7239 own hits during active Vampirism epochs, 9 decisive after +-- discarding full-health windows, 7 of 9 pointing at net) -- it is not a per-path guess, and this +-- function is what makes the SPELL path actually honor that settled answer instead of silently +-- assuming logged == net the way an earlier version of this file did (see the removed comment at +-- the SPELL_DAMAGE_EVENT_SELF branch below, and the report for why that assumption was wrong: it +-- predates spec 4a, which supersedes it for spells the same way it already governed auto attacks). +function G.netSpell(amount, mitigationStr) + if VampifyConst.FORMULA.damageBase ~= "net" then return num(amount) end + local n = num(amount) - G.mitigationOf(mitigationStr) + if n < 0 then return 0 end + return n +end + +-- ---- WoW wiring ------------------------------------------------------------------------------ + +local listeners = {} +function G.onDamage(fn) table.insert(listeners, fn) end + +-- SPELL_GO_SELF PRECEDES the damage events of a cast and carries arg6 (numTargetsHit) -- but that +-- field is NOT "how many distinct targets", it is a HIT COUNT. Measured in-game 2026-08-22: TWoW +-- Stormstrike (dual-wield, hits with both weapons) lands on exactly ONE target and still reports +-- numTargetsHit=2 for that single-target cast. An earlier version of this file trusted arg6>1 +-- outright and classified Stormstrike as AoE, applying the 0.7 damping to a hit ground truth says +-- should get the full 1.0 (measured mitigation-adjusted factor: median 0.95-1.0 across n=4, +-- tightly clustered -- not the ~0.7 that classification would have produced). So arg6 is no longer +-- trusted to decide AoE by itself; SPELL_GO_SELF is now used ONLY for goSeen (below) and to note +-- multi-hit casts (see the OnEvent handler), and the actual AoE decision for every spell hit -- +-- cast or not -- runs through G.deriveAoE, the distinct-target derivation described next. This +-- means a genuinely-AoE cast that happens to land on only one target this time is credited at 1.0 +-- instead of a possible 0.7 -- see G.deriveAoE's own comment for that open question. +local goSeen = false +local damageSeen = false + +-- ---- AoE derivation from distinct targets, cast or not (spec 4.2, extended 2026-08-22) -------- +-- +-- Originally written as the fallback for weapon/item procs (Force Reactive Disc's damage shield +-- return has no cast, so SPELL_GO_SELF never fires for it -- without this the factor silently +-- stayed 1.0 and the credited healing read about 43% high, 1 / 0.7). It is now the ONLY source of +-- an AoE classification, cast or not -- see the comment above SPELL_GO_SELF's handling for why +-- numTargetsHit stopped being trusted on its own. +-- +-- Rule, per spec 4.1: no spell-id list. The SAME spell id landing on TWO OR MORE DISTINCT target +-- GUIDs inside a short window is area damage. +-- +-- KNOWN COARSENESS, CONFIRMED (2026-08-24, Sigil of Ancient Accord investigation): a spell whose +-- OWN definition mixes an AoE effect (100 Arcane to target + all within 10y) with a bonus effect +-- aimed only at its primary target (+300 Arcane) is not distinguishable from a plain AoE hit at +-- this module's granularity. A dump of 8.4M real combat events shows the primary +-- target's hit already arrives as ONE combined ~400 amount (two clusters: ~100 splash-only, +-- ~400 primary-combined -- no separate ~300-only cluster exists), matching the server's own +-- per-target damage accumulation (Spell::DoAllEffectOnTarget, Spell.cpp:1253, sums every effect +-- that targets a unit before the single proc/log call at Spell.cpp:1518). So +-- when 2+ distinct targets are hit, THIS module correctly flags the primary's combined hit as AoE +-- too (same spellId, same window) -- there is no server-visible signal here that would let it +-- damp only the spell's 100-portion and not its 300-portion; the two never arrive as separate +-- numbers. This is a genuine data-granularity limit, not a classification bug: whatever the +-- server itself does internally with a spell like this, this module sees exactly what the server +-- sends, one number per target, same as the server's own proc/log call already collapsed it to. +-- +-- OPEN QUESTION, DELIBERATELY UNANSWERED: a genuinely multi-target spell that this time only +-- connects with ONE target (miss/resist/out of range on the others, or simply a single-target +-- pull) is now credited at the full 1.0 factor, never 0.7 -- there is no way to distinguish "this +-- spell is AoE but only hit one target" from "this spell was never AoE" from a single hit alone, +-- and guessing wrong in either direction was exactly the Stormstrike bug. Whether the SERVER pays +-- Vampirism at 0.7 or 1.0 in that specific case is UNMEASURED; do not assume either answer without +-- a dedicated single-target-hit-of-a-known-AoE-spell measurement. +-- +-- This is necessarily RETROACTIVE: the first hit is already recorded (its non-AoE heal already +-- added to the float accumulator) by the time the second target proves the whole burst was AoE. +-- This module cannot fix that itself -- the accumulator (VampifyState.acc, VampifyState.fHeal/ +-- sHeal/spHeal) is owned by core/model.lua + core/commands.lua, both out of bounds for this +-- change. So the qualifying hit instead fires G.onAoECorrection with the FIRST hit's own +-- (amount, targetGuid, spellId, isCrit), and a listener living in commands.lua is the piece that +-- still needs writing: it must redo that hit's heal with isAoE=true (VampifyModel.heal is pure and +-- already exposed, no model.lua change needed) and add the DIFFERENCE to VampifyState.acc.total, +-- .fHeal, .sHeal and .spHeal[spellId] -- see the report for the exact shape. +-- +-- Window: 1.0s. A proc's damage to several targets is dispatched from one server-side event and +-- its packets arrive within a fraction of a second of each other -- tighter than a full cast's +-- cast-to-impact time, not looser -- so this is a safe upper bound for either case, cast or proc. +-- +-- State is keyed by spellId, one scalar record per id (last target GUID, its amount/crit, and an +-- expiry) -- NOT a per-GUID table, so the eviction-sweep concern for per-GUID/_seen tables does +-- not apply here: this table is bounded by the spellbook plus a handful of item procs, the same +-- bound that already lets spHeal/spDmg/spOver (aggregate.lua) and the coalescing buckets +-- (commands.lua) skip a sweep. No table is created per event -- only key assignment on tables that +-- already exist -- so the zero-allocation-per-event budget holds. +local DERIVE_WINDOW = 1.0 +-- Exported because core/commands.lua's correction listener has to decide whether the hit being +-- corrected is one it credited, and "credited within this window" is the same span. Two independent +-- copies of the figure would drift the day one of them is tuned. +G.DERIVE_WINDOW = DERIVE_WINDOW +local deriveGuid, deriveAmount, deriveCrit, deriveExpires, deriveConfirmed = {}, {}, {}, {}, {} + +local correctionListeners = {} +-- fn(firstHitAmount, firstHitTargetGuid, spellId, firstHitWasCrit) -- fired once per window, on +-- the hit that supplies the SECOND distinct target GUID and thereby proves the first hit was AoE. +function G.onAoECorrection(fn) table.insert(correctionListeners, fn) end + +local function emitCorrection(amount, targetGuid, spellId, isCrit) + for i = 1, table.getn(correctionListeners) do + correctionListeners[i](amount, targetGuid, spellId, isCrit) + end +end + +-- Pure derivation step for one spell-damage hit. Returns true if THIS hit should be treated as +-- AoE. Exposed as a standalone function (not buried in the OnEvent closure below) so it is +-- reachable from the offline Lua 5.0 test harness without a WoW API stub. +function G.deriveAoE(spellId, targetGuid, amount, isCrit, now) + if not spellId then return false end -- auto attacks carry no spell id, nothing to key on + local expires = deriveExpires[spellId] + if not expires or now > expires then + -- No window in flight (or it lapsed): this hit becomes the new, so-far-unconfirmed first + -- hit. Not AoE yet -- a single target proves nothing. + deriveGuid[spellId], deriveAmount[spellId], deriveCrit[spellId] = targetGuid, amount, isCrit + deriveExpires[spellId] = now + DERIVE_WINDOW + deriveConfirmed[spellId] = false + return false + end + if deriveConfirmed[spellId] then + -- Already proven AoE inside this window: every further hit of this spell id is AoE too, + -- and the correction for the original hit already fired -- do not repeat it. + deriveExpires[spellId] = now + DERIVE_WINDOW + return true + end + if targetGuid ~= deriveGuid[spellId] then + -- Second DISTINCT target: this proves both this hit and the remembered first hit were AoE. + deriveConfirmed[spellId] = true + deriveExpires[spellId] = now + DERIVE_WINDOW + emitCorrection(deriveAmount[spellId], deriveGuid[spellId], spellId, deriveCrit[spellId]) + return true + end + -- Same target again inside the window: still no evidence of AoE (could just be two ticks on + -- one enemy), so only refresh the window and keep waiting. + deriveExpires[spellId] = now + DERIVE_WINDOW + return false +end + +-- Running mitigation totals. Auto attacks report blocked/absorbed/resisted separately, so the +-- net-vs-gross question (spec 4.3.2) can be settled from a normal play session instead of a new +-- measurement campaign -- but only if the numbers are actually kept. They are cheap scalars, not +-- per-event tables, so this respects the zero-allocation budget. +local mitBlocked, mitAbsorbed, mitResisted, mitGross = 0, 0, 0, 0 + +-- spellId is passed through so consumers can group the hits of ONE cast (an AoE landing on five +-- targets is five events but one spell). nil for auto attacks, which have no cast to group by. +local function emit(amount, isAoE, targetGuid, spellId, isCrit) + for i = 1, table.getn(listeners) do + listeners[i](amount, isAoE, targetGuid, spellId, isCrit) + end +end + +if CreateFrame then + local f = CreateFrame("Frame", "VampifyDamageFrame") + f:RegisterEvent("SPELL_DAMAGE_EVENT_SELF") + f:RegisterEvent("AUTO_ATTACK_SELF") + f:RegisterEvent("SPELL_GO_SELF") + f:SetScript("OnEvent", function() + if event == "SPELL_GO_SELF" then + -- goSeen is the only lasting effect of this event now -- see the comment above this + -- section for why numTargetsHit (arg6) is no longer used to classify AoE directly. The + -- actual decision for every hit of this cast still runs through G.deriveAoE below, the + -- exact same distinct-target proof a no-cast proc already has to provide. + goSeen = true + elseif event == "SPELL_DAMAGE_EVENT_SELF" then + local amount, tgt, spellId, caster = G.decodeSpell(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) + -- Trust arg2 rather than the _SELF suffix, and never count a hit on ourselves. + if caster and tgt and caster ~= tgt and amount > 0 then + damageSeen = true + local now = GetTime() + local isCrit = G.isCritSpell(arg6) + -- Net via G.netSpell (above) -- honors VampifyConst.FORMULA.damageBase = "net" + -- (spec 4a) the same way the AUTO_ATTACK_SELF branch below already does. A hit + -- that partially resisted (Arcane school, e.g. Ancient Accord's proc, is a + -- measured case of this) must not credit its resisted portion as damage the + -- server paid Vampirism on. + local d = G.netSpell(amount, arg5) + -- Distinct-target proof only, cast or not -- see the section comment above. Fed + -- the NET amount: this is the figure the correction listener re-credits later, + -- and it must match what emit() below hands the model. + local isAoE = false + if spellId then + isAoE = G.deriveAoE(spellId, tgt, d, isCrit, now) + end + mitGross = mitGross + amount + emit(d, isAoE, tgt, spellId, isCrit) + end + elseif event == "AUTO_ATTACK_SELF" then + local amount, tgt, blocked, absorbed, resisted, attacker = + G.decodeAuto(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9) + if attacker and tgt and attacker ~= tgt and amount > 0 then + damageSeen = true + mitGross = mitGross + amount + mitBlocked = mitBlocked + blocked + mitAbsorbed = mitAbsorbed + absorbed + mitResisted = mitResisted + resisted + local d = amount + if VampifyConst.FORMULA.damageBase == "net" then + d = G.netOf(amount, blocked, absorbed, resisted) + end + emit(d, false, tgt, nil, G.isCritMelee(arg4)) + end + end + end) +end + +-- True once a SPELL_GO_SELF has ever been seen. Until then, AoE damping cannot be applied and +-- the total may read HIGH -- the one direction in which this addon can overstate the number. +function G.aoeAvailable() return goSeen end + +-- True once ANY own-damage event has arrived. nampower is a client module, not an addon, so there +-- is nothing to query with IsAddOnLoaded -- its absence can only be observed as silence. Silence +-- while the player is visibly fighting means the addon is inert, and spec 7 requires that it say +-- so rather than display a confident zero. +function G.damageSeen() return damageSeen end + +-- Mitigation totals for settling net-vs-gross (spec 4.3.2) offline. +function G.mitigation() return mitGross, mitBlocked, mitAbsorbed, mitResisted end diff --git a/capture/detect.lua b/capture/detect.lua new file mode 100644 index 0000000..d1dffb3 --- /dev/null +++ b/capture/detect.lua @@ -0,0 +1,183 @@ +-- Vampify -- which Vampirism sources the player is wearing. +-- +-- Vampirism is not a stat field but a passive item spell (45420-45424 = 1-5%), so it can only be +-- read from the RENDERED tooltip. +-- +-- THE TRAP: item-borne and enchant-borne sources use DISJOINT wording -- +-- item: "1% of damage dealt is returned as healing." +-- enchant: "Increase vampirism by 1%." (Enchant Bracer/Boots - Vampirism, 57146/57148) +-- Matching only the first phrase would silently miss every enchant. A single item can carry both, +-- so never stop at the first match in a slot. + +VampifyDetect = {} +local D = VampifyDetect + +-- Pure: accessors are injected, so this is WoW-free and offline-testable. +function D.scan(getLink, setTooltip, readLines) + local out = {} + for slot = 1, 19 do + local link = getLink(slot) + if link then + setTooltip(link) + local lines = readLines(slot) or {} + for i = 1, table.getn(lines) do + local text = lines[i] + if text then + local low = string.lower(text) + local _, _, pct = string.find(low, "(%d+)%%") + if pct then + if string.find(low, "returned as healing", 1, true) then + table.insert(out, { slot = slot, percent = tonumber(pct), kind = "item" }) + elseif string.find(low, "vampirism", 1, true) then + table.insert(out, { slot = slot, percent = tonumber(pct), kind = "enchant" }) + end + end + end + end + end + end + return out +end + +-- SetHyperlink in 1.12 needs the BARE "item:id:enchant:suffix:unique" form. Handing it the full +-- link (with |cff..|Hitem:..|h[Name]|h|r) fails with "Unknown link type" -- confirmed in-game +-- 2026-08-10, where it failed on all 19 equipped slots at once and the detector therefore reported +-- "no sources" for gear that was plainly equipped. This is a known trap already documented by a +-- companion telemetry addon's instrumentation; this addon reproduced the bug by porting the pure +-- scan but rewriting the wiring. Pure, so the extraction itself is covered by tests. +function D.bareLink(link) + if type(link) ~= "string" then return nil end + local _, _, bare = string.find(link, "(item:%d+:%d+:%d+:%d+)") + if bare then return bare end + -- Some links carry fewer fields. + local _, _, loose = string.find(link, "(item:%d+[:%d]*)") + return loose or link +end + +function D.summarise(sources) + local sum, n = 0, table.getn(sources) + for i = 1, n do sum = sum + (sources[i].percent or 0) end + return sum / 100, n +end + +-- ---- WoW wiring ------------------------------------------------------------------------------ + +local sources, epoch, listeners = {}, 0, {} +local tip, pending + +local function ensureTip() + if tip then return tip end + -- Created ONCE at module scope: frame handles are finite. Owned by WorldFrame rather than + -- UIParent, which can flash visible on /reload. Never the global GameTooltip -- that fires + -- every other addon's OnTooltipSetItem hooks 19 times per rescan. + tip = CreateFrame("GameTooltip", "VampifyScanTooltip", nil, "GameTooltipTemplate") + tip:SetOwner(WorldFrame, "ANCHOR_NONE") + return tip +end + +local lineBuf = {} +local function readLines() + -- The tooltip's FontStrings are REUSED, so ClearLines() before each SetHyperlink is what keeps + -- the previous item's text out of this scan, and NumLines() bounds the read to real lines. + -- + -- Deliberately NOT filtered on fs:IsVisible(): this tooltip is owned by WorldFrame with + -- ANCHOR_NONE and is never shown, so its FontStrings report invisible even when their text is + -- current. Filtering on visibility would silently return zero lines for every item -- the same + -- silent-zero failure mode as the bare-link bug above. A companion capture addon's proven + -- detector does not filter either. + -- Filled DENSELY with table.insert: GetText() returns nil for a blank line, and an indexed + -- assignment would leave a hole. table.getn on a table with holes is undefined in Lua 5.0, and + -- D.scan bounds its loop with exactly that -- so a hole could truncate the scan before the + -- Vampirism line and lose the source silently. + VampifyConst.resetList(lineBuf) -- see the comment there: nil-ing indices is not enough in 5.0 + local n = tip:NumLines() + for i = 1, n do + local fs = getglobal("VampifyScanTooltipTextLeft"..i) + local t = fs and fs:GetText() + if t then table.insert(lineBuf, t) end + end + return lineBuf +end + +local emptyScan +local retries = 0 +local function doScan() + ensureTip() + emptyScan = false + local found = D.scan( + function(slot) return GetInventoryItemLink("player", slot) end, + function(link) + tip:ClearLines() -- mandatory, see readLines() + tip:SetOwner(WorldFrame, "ANCHOR_NONE") -- re-assert in case another addon stole it + local bare = D.bareLink(link) + if not bare then emptyScan = true return end + -- pcall: one unparseable link must not abort the whole 19-slot scan. Without this the + -- error propagates out of OnUpdate, doScan never reaches its epoch bump, and the addon + -- sits at zero sources forever while throwing an error every time gear changes. + if not pcall(function() tip:SetHyperlink(bare) end) then emptyScan = true return end + -- An uncached item renders nothing; SetHyperlink itself asks the server for it. + if tip:NumLines() == 0 then emptyScan = true end + end, + readLines) + if emptyScan then + -- A slot rendered nothing, so this scan cannot distinguish "no Vampirism" from "item not + -- cached yet" -- waits of ~25 s have been observed. Retry rather than commit a zero. + -- + -- The condition is emptyScan ALONE, deliberately: what a still-cold slot might be carrying + -- has nothing to do with what the other slots already yielded. An earlier version also + -- required `table.getn(found) == 0`, which disarmed the retry the moment ONE source warmed + -- up -- so a player wearing an item plus a bracer enchant committed the item alone and ran + -- the rest of the session on a percentage and a per-hit floor that were both too low. That + -- is invisible from the inside: the rows, the session total and the accumulator all agree + -- on the understated figure, so no invariant can catch it. + -- + -- This covers the COLD START too, where `sources` is still empty and committing would leave + -- the addon dormant for the whole session (5.11) until the next gear change. + -- + -- Cost of the wider condition: a slot that can NEVER render (an unparseable link hitting the + -- pcall above) now spends the full retry budget before every commit. Bounded by the cap + -- below, and the previously committed `sources` stay live throughout -- only a cold start + -- has nothing to fall back on. + retries = retries + 1 + if retries <= 40 then return false end -- ~40 * 0.2 s, then accept reality + end + retries = 0 + sources = found + epoch = epoch + 1 + for i = 1, table.getn(listeners) do listeners[i](sources, epoch) end + return true +end + +function D.getSources() return sources end +function D.getEpoch() return epoch end +function D.onChange(fn) table.insert(listeners, fn) end + +-- Cheap change detection: UNIT_INVENTORY_CHANGED fires constantly (bags, ammo, durability), so +-- compare the 19 links first and only pay for a tooltip parse on a real change. +local lastLinks = {} +local function linksChanged() + local changed = false + for slot = 1, 19 do + local l = GetInventoryItemLink("player", slot) + if l ~= lastLinks[slot] then lastLinks[slot] = l; changed = true end + end + return changed +end + +if CreateFrame then + local f = CreateFrame("Frame", "VampifyDetectFrame") + f:RegisterEvent("PLAYER_ENTERING_WORLD") + f:RegisterEvent("UNIT_INVENTORY_CHANGED") + f:SetScript("OnEvent", function() + if event == "UNIT_INVENTORY_CHANGED" and arg1 ~= "player" then return end + if not linksChanged() and event ~= "PLAYER_ENTERING_WORLD" then return end + pending = 0 -- coalesce bursts: parse on the next OnUpdate, not once per event + end) + f:SetScript("OnUpdate", function() + if not pending then return end + pending = pending + arg1 + if pending < 0.2 then return end + pending = nil + if not doScan() then pending = 0 end -- uncached tooltip: try again shortly + end) +end diff --git a/capture/incoming.lua b/capture/incoming.lua new file mode 100644 index 0000000..c48634e --- /dev/null +++ b/capture/incoming.lua @@ -0,0 +1,249 @@ +-- Vampify -- what happens TO the player: incoming damage and logged healing. +-- +-- Separate from capture/damage.lua on purpose. That file listens to the *_SELF events, which mean +-- "the player is the CASTER" -- confirmed in-game, not assumed. Incoming +-- damage is therefore not in them at all; it needs the *_OTHER family, filtered on our own GUID. +-- +-- THE COST. The _OTHER events fire for every foreign unit in range, which is why v0.1.0 avoided +-- them. Three things keep that in hand: they are registered only while something actually wants +-- them (see WANTED, below), a group/raid switches the WATCH reason off (the balance is worthless +-- there anyway -- but see WANTED for why that no longer means the events themselves stop), and the +-- GUID comparison is the first statement in the handler, before any decoding, allocating nothing. +-- +-- HEALING IS TAKEN FROM THE BY_* EVENTS ONLY. nampower fires both perspectives for the same heal +-- (BY_* for the caster, ON_* for the target); registering both double-counts, measured elsewhere at +-- +16 % against ground truth. Registering only BY_SELF and BY_OTHER and filtering on the target +-- GUID sees every heal on us exactly once, and needs no deduplication. +-- +-- SELF VS. EXTERNAL HEALING (follow-up change request, 2026-08-22). Both BY_SELF and BY_OTHER +-- were being summed into ONE "logged" total, which blurred exactly the distinction the per-hit +-- export needs: SPELL_HEAL_BY_SELF fires when the PLAYER is the caster (a self-cast heal, since +-- I.fromHeal's own filter on arg1==playerGuid then requires the player to ALSO be the target -- +-- healing we cast on a party member is caster==player, target!=player, and is filtered out, same +-- as always); SPELL_HEAL_BY_OTHER fires when ANY OTHER unit is the caster, filtered the same way +-- on arg1==playerGuid. So yes -- Vampify DOES see foreign healing arriving on the player, via +-- SPELL_HEAL_BY_OTHER (nampower), and it was always being counted; it just was not being told +-- apart from a self-heal. That is exactly the "foreign healing that contaminates a group window" +-- this whole change is about, and it is now kept in its own running total (loggedExternal / the +-- EXTHEAL export line) instead of being folded into loggedSelf. No new event registration was +-- needed -- the two events were already both registered; only their dispatch was unified. +-- +-- WANTED, NOT JUST ON/OFF (follow-up change request, 2026-08-22). There are now two +-- INDEPENDENT reasons this module's events might need to be registered: /vf watch (wants a +-- trustworthy HP-reconciliation BALANCE, which foreign healing/group play genuinely invalidates -- +-- see core/watch.lua) and the per-hit debug export (core/perhit.lua; wants raw INC/SELFHEAL/ +-- EXTHEAL lines regardless of group status, because the raw numbers are exactly what makes foreign +-- healing MEASURABLE instead of invisible). watchWanted and perhitWanted are tracked separately; +-- the underlying registration is active whenever EITHER is true (see WANTED below). A group join +-- clears ONLY watchWanted (I.isWatchOn() flips off, and core/commands.lua's addWindow call for the +-- watch balance is skipped) -- it does NOT touch perhitWanted, so the raw capture keeps running for +-- perhit even in a group. I.isOn() answers "is the capture running at all" (either reason); +-- I.isWatchOn() answers "is watch's OWN feature active" (core/commands.lua uses isOn() to gate the +-- window-tracking machinery itself, and isWatchOn() to gate feeding the watch balance). + +VampifyIncoming = {} +local I = VampifyIncoming + +local function num(v) return tonumber(v) or 0 end + +-- The spell event carries its mitigation as a comma-joined STRING ("0,0,0"), not as numbers. The +-- all-zero case is the overwhelming majority and is compared as a whole, so the parse -- and its +-- allocations -- only happen on hits that actually mitigated something. +function I.mitigationOf(s) + if type(s) ~= "string" or s == "0,0,0" or s == "" then return 0 end + local total, from = 0, 1 + while true do + local p = string.find(s, ",", from, true) + if not p then + total = total + num(string.sub(s, from)) + break + end + total = total + num(string.sub(s, from, p - 1)) + from = p + 1 + end + return total +end + +-- Returns the damage that actually left the health bar, or nil when the event was not about us. +-- +-- WHETHER `amount` IS ALREADY NET IS UNMEASURED for the incoming direction. Subtracting mitigation +-- assumes it is gross. That assumption is not load-bearing: windows that took no damage at all form +-- a control branch in VampifyWatch, and if the two branches disagree, the reading is wrong rather +-- than the healing (see W.crossCheck). This is the one place where a wrong guess is caught rather +-- than believed. +function I.fromSpell(playerGuid, a1, a2, a3, a4, a5) + if not playerGuid or a1 ~= playerGuid then return nil end + local net = num(a4) - I.mitigationOf(a5) + if net < 0 then return 0 end + return net +end + +-- AUTO_ATTACK_OTHER: 1 attacker 2 target 3 total 4 hitInfo 5 victimState 6 subDamageCount +-- 7 blocked 8 absorbed 9 resisted -- scalars here, unlike the spell event. +function I.fromAuto(playerGuid, a1, a2, a3, a4, a5, a6, a7, a8, a9) + if not playerGuid or a2 ~= playerGuid then return nil end + local net = num(a3) - num(a7) - num(a8) - num(a9) + if net < 0 then return 0 end + return net +end + +-- SPELL_HEAL_BY_*: 1 target 2 caster 3 spellId 4 amount 5 critical 6 periodic. Which of self/ +-- external this is comes from WHICH EVENT fired (BY_SELF vs BY_OTHER), not from arg2 -- see the +-- header comment. This function only answers "did it land on us", identically for both. +function I.fromHeal(playerGuid, a1, a2, a3, a4) + if not playerGuid or a1 ~= playerGuid then return nil end + return num(a4) +end + +-- ---- running window totals --------------------------------------------------------------------- + +local incoming, loggedSelf, loggedExternal = 0, 0, 0 + +function I.reset() incoming, loggedSelf, loggedExternal = 0, 0, 0 end + +-- Reads the window's totals and starts the next one in the same breath. Three figures, no table: +-- this is called once per own hit, and an allocation there would be an allocation per hit. +function I.take() + local a, b, c = incoming, loggedSelf, loggedExternal + incoming, loggedSelf, loggedExternal = 0, 0, 0 + return a, b, c +end + +function I.addIncoming(n) incoming = incoming + n end +function I.addSelfHeal(n) loggedSelf = loggedSelf + n end +function I.addExternalHeal(n) loggedExternal = loggedExternal + n end + +-- ---- WoW wiring -------------------------------------------------------------------------------- + +local playerGuid = nil + +-- The two independent "wants" (see the header comment) and the merged registration state they +-- resolve to. `on` mirrors the OLD single boolean's meaning exactly (is the capture running at +-- all), so I.isOn() keeps its old contract unchanged. +local watchWanted, perhitWanted, on = false, false, false + +-- SuperWoW returns the GUID as the second value of UnitExists. Wrapped: without SuperWoW there is +-- no GUID, and then this whole subsystem has nothing to filter on and stays off. +local function readGuid() + local g + pcall(function() local _, gg = UnitExists("player"); g = gg end) + return g +end + +function I.playerGuid() return playerGuid end +function I.isOn() return on end +-- Watch's OWN feature state -- true only while the player asked for /vf watch on AND it is +-- actually running. False while only perhit wants the capture, even though I.isOn() is true then. +function I.isWatchOn() return watchWanted and on end + +-- True while the player is in any group. The WATCH balance cannot survive foreign healing -- +-- measured at +29..49 % phantom healing -- so for watch this is not a preference but a validity +-- condition. It says nothing about perhit, which wants the raw numbers (including foreign healing) +-- unconditionally -- see the header comment. +function I.inGroup() + local r = GetNumRaidMembers and GetNumRaidMembers() or 0 + local p = GetNumPartyMembers and GetNumPartyMembers() or 0 + return (r > 0) or (p > 0) +end + +local frame = nil + +local EVENTS = { + "SPELL_DAMAGE_EVENT_OTHER", + "AUTO_ATTACK_OTHER", + "SPELL_HEAL_BY_SELF", + "SPELL_HEAL_BY_OTHER", +} + +-- Registers/unregisters the underlying events to match (watchWanted OR perhitWanted). Returns +-- whether the capture ended up running -- false only when activation was needed but no GUID could +-- be obtained (no SuperWoW). Idempotent: a call that changes nothing is a no-op, so re-asserting a +-- want that is already satisfied does not re-read the GUID or touch the frame. +local function applyWanted() + if not frame then return on end + local want = (watchWanted or perhitWanted) and true or false + if want == on then return on end + if want then + playerGuid = readGuid() + if not playerGuid then return on end -- nothing to filter on; stay off rather than + -- process every foreign unit for nothing + for i = 1, table.getn(EVENTS) do frame:RegisterEvent(EVENTS[i]) end + I.reset() + on = true + else + for i = 1, table.getn(EVENTS) do frame:UnregisterEvent(EVENTS[i]) end + on = false + end + return on +end + +-- Sets watch's own want and applies it. Return contract matches the old I.enable(want): turning +-- off always reports false (from watch's own perspective it succeeded -- the underlying wire may +-- stay hot for perhit, which is the whole point of this change); turning on reports whether +-- activation actually succeeded (false only on a missing GUID). +function I.setWatchWanted(want) + want = want and true or false + watchWanted = want + applyWanted() + if want then return on end + return false +end + +-- Sets perhit's want and applies it. Same activation semantics as setWatchWanted, just without a +-- group guard of its own -- perhit's caller (core/commands.lua) never refuses on group status. +function I.setPerHitWanted(want) + want = want and true or false + perhitWanted = want + applyWanted() + if want then return on end + return false +end + +if CreateFrame then + frame = CreateFrame("Frame", "VampifyIncomingFrame") + -- Registered even while the subsystem is off: the GUID has to be refreshed after every world + -- load, and a group change invalidates watch's own mode at any time. Three events, not per-hit + -- cost. + frame:RegisterEvent("PLAYER_ENTERING_WORLD") + frame:RegisterEvent("PARTY_MEMBERS_CHANGED") + frame:RegisterEvent("RAID_ROSTER_UPDATE") + frame:SetScript("OnEvent", function() + if event == "PLAYER_ENTERING_WORLD" then + playerGuid = readGuid() + return + end + -- This is the switch-off the header promises, but ONLY for watch's own want -- it must not + -- touch perhitWanted, or a raid night would silently blind the per-hit export too (the + -- follow-up change request this whole split exists for). Checking inGroup() inside the per-hit path + -- would call GetNumRaidMembers/GetNumPartyMembers for every foreign unit in range, which + -- costs more than the GUID compare it would precede -- so this stays event-driven. + -- Announced rather than silent -- watch reports its own state via /vf watch, and a reader + -- who never saw it change would read that as a bug. Not re-enabled automatically when the + -- group breaks up: /vf watch on is one keystroke and stays the player's decision. + if event == "PARTY_MEMBERS_CHANGED" or event == "RAID_ROSTER_UPDATE" then + if watchWanted and I.inGroup() then + watchWanted = false + applyWanted() + if DEFAULT_CHAT_FRAME then + DEFAULT_CHAT_FRAME:AddMessage("|cff8080ffVampify|r watch: off -- you joined a" + .." group, and foreign healing makes the balance meaningless.") + end + end + return + end + if not on then return end + if event == "SPELL_DAMAGE_EVENT_OTHER" then + local d = I.fromSpell(playerGuid, arg1, arg2, arg3, arg4, arg5) + if d then incoming = incoming + d end + elseif event == "AUTO_ATTACK_OTHER" then + local d = I.fromAuto(playerGuid, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9) + if d then incoming = incoming + d end + elseif event == "SPELL_HEAL_BY_SELF" then + local h = I.fromHeal(playerGuid, arg1, arg2, arg3, arg4) + if h then loggedSelf = loggedSelf + h end + else -- SPELL_HEAL_BY_OTHER + local h = I.fromHeal(playerGuid, arg1, arg2, arg3, arg4) + if h then loggedExternal = loggedExternal + h end + end + end) +end diff --git a/core/aggregate.lua b/core/aggregate.lua new file mode 100644 index 0000000..f151268 --- /dev/null +++ b/core/aggregate.lua @@ -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 diff --git a/core/commands.lua b/core/commands.lua new file mode 100644 index 0000000..30f89e2 --- /dev/null +++ b/core/commands.lua @@ -0,0 +1,1314 @@ +-- Vampify -- wiring and slash commands. +-- +-- This file is the only place where the pure core meets the WoW-bound capture layer. Everything it +-- does is glue; if you are looking for behaviour, it is in model.lua (the formula) or detect.lua +-- (the sources). + +VampifyState = VampifyAggregate.new() +-- The aggregate tracks totals; VampifyState.acc tracks a running sum of the SAME per-hit integers, +-- kept separately so the retroactive AoE correction (below) has one place to apply its delta and +-- core/watch.lua's I3 has something to check the emitted total against (see core/model.lua's +-- header for why this is a plain sum now, not a fractional carry). +VampifyState.acc = VampifyModel.newAcc() +VampifyState.effHeal = 0 +-- The DISTRIBUTION of triggering hits (core/histogram.lua), session and lifetime, alongside the +-- totals above. A total cannot answer "what would one more Vampirism source be worth" -- the floor +-- makes healing non-linear in damage, so the answer depends on how the damage was split across +-- hits, not just on how much of it there was. Created here like the accumulator; wireSession below +-- REPOINTS both at VampifyCharDB sub-tables, exactly as it does for the per-spell tables, so a +-- recorded hit is already a persisted write. +VampifyState.hist = VampifyHistogram.new() +VampifyState.lfHist = VampifyHistogram.new() + +local sumPercent, nSources = 0, 0 +-- The REAL per-source percent list (e.g. {3, 2, 2, 2}), rebuilt by recompute() below alongside +-- sumPercent/nSources. This -- not the summed sumPercent -- is what feeds the model now +-- (core/model.lua's M.healPerSources): the server truncates each source independently, and a +-- summed percentage cannot be truncated back into that after the fact (see model.lua's header). +-- sumPercent/nSources remain for the places that only ever wanted the aggregate (the status line, +-- the options info panel, I4's epoch comparison, /vf source add's zero-percent guard) -- they are +-- still meaningful numbers, just no longer the model's own input. +local sourcePercents = {} +-- Same table, reachable from gui/display.lua (follow-up change request, 2026-08-22, compact bar). Assigned +-- ONCE, here: recompute() below refills sourcePercents in place via VampifyConst.resetList (never +-- replaces the table), so this reference stays valid for the addon's whole lifetime and the bar +-- always sees the current source set without commands.lua having to push updates anywhere. +VampifyState.sourcePercents = sourcePercents +-- Parallel to sourcePercents (SAME indices, rebuilt alongside it in recompute() below): the STABLE +-- identity + human label core/aggregate.lua's A.recordSourceBreakdown needs per source, since the +-- index into sourcePercents itself is not stable across a gear change (see A.sourceKey's own +-- comment for why). meta[i] = { key = A.sourceKey(...), label = A.sourceLabel(...) }. +local sourceMeta = {} +-- Module-local scratch pad for the per-hit source breakdown (core/model.lua's M.healBreakdown), +-- reused across every damage event -- the hot path below must not allocate. +local breakdownBuf = {} + +-- The player's CURRENT target's GUID, read FRESH at the moment a hit lands -- feeds the ST/AoE +-- split below (2026-08-24, redefined: ST = healing from hits on the current target, +-- INCLUDING an AoE spell's own share of a hit on that one target; AoE = everything else, and a hit +-- with no current target equipped at all counts entirely as AoE by explicit ruling -- there +-- is then no "current target" for a ST figure to describe). Deliberately NOT cached across hits: +-- the comparison has to use whatever target was current AT THE MOMENT THIS HIT LANDED, and a mid- +-- fight target switch must not retroactively move an already-recorded hit from one bucket to the +-- other -- reading fresh every time is what guarantees that by construction, no invalidation logic +-- needed. +-- +-- Same SuperWoW pattern capture/incoming.lua already uses for the PLAYER's own GUID +-- (`local _, gg = UnitExists("player")`, that file's readGuid): UnitExists returns the unit's GUID +-- as a second value under SuperWoW. pcall-wrapped for the same reason -- without SuperWoW, +-- UnitExists returns only one value, and the second read must not error, it must just come back +-- nil, which this function then reads (correctly) as "no known current target". +local function currentTargetGuid() + local g + pcall(function() local _, gg = UnitExists("target"); g = gg end) + return g +end +-- Bumped whenever the detected source set changes; a fight remembers the epoch it began in, so a +-- gear swap mid-fight becomes visible as a mismatch (watchdog I4) instead of quietly mixing two +-- figures into one percentage. Declared here rather than beside the rest of the watchdog state +-- because recompute() below already needs it. +local sourceEpoch, fightEpoch = 0, 0 +-- Watchdog counters for I3 (shown integers vs. the accumulator). Declared up here, ABOVE +-- VampifyResetSession, because that function has to clear them together with the accumulator -- +-- it zeroed the accumulator alone, the counters kept running, and the check then reported a gap +-- that was real: after a reset the two no longer described the same span. Found in the field by +-- the check itself. +-- +-- aoeCorrectedShown is the number of INTEGERS the retroactive AoE corrections took off the +-- accumulator, counted where they were applied. It is deliberately not the sum of the float deltas: +-- see the correction listener below for why that sum cannot be used to reconstruct what was shown. +local emittedFight, aoeCorrectedShown = 0, 0 + +-- spellId -> the time its last CREDITED hit was recorded. Read by the retroactive AoE correction +-- below, which may only take healing back out of a row it actually put in. The correction cannot +-- work that out for itself: by the time it runs, the reason the hit was dropped is gone (no source +-- set committed yet, the spell excluded since, a reset in between), so the damage listener records +-- the fact instead. Keyed by spell id, i.e. bounded by the spellbook plus a handful of item procs +-- -- the same bound that lets the coalescing buckets below skip an eviction sweep. +-- +-- Declared up here, like the counters above, because VampifyResetSession has to clear it: a reset +-- empties the rows, so nothing recorded before it may be corrected after it. +local creditedAt = {} + +local function print_(msg) + if DEFAULT_CHAT_FRAME then DEFAULT_CHAT_FRAME:AddMessage("|cff8080ffVampify|r "..msg) end +end + +-- ---- visibility -------------------------------------------------------------------------------- +-- +-- Three different questions, and conflating them is what made a hide unstick: +-- * nSources > 0 a capability. With nothing equipped there is no number, whatever anyone wants. +-- * cfg.shown the user's PREFERENCE (minimap button, "Show display" checkbox). +-- * inertWarned the addon's own refusal to show a number it does not trust (see the OnUpdate +-- verdict below). Not a preference either -- it must outlive a loading screen. +-- Everything that used to call VampifyDisplay.show()/hide() on its own account goes through here, +-- so no automatic path can quietly overrule any of the three. Before this, recompute() ended in an +-- unconditional show() and ran on every zone, boat, instance and gear change: a hidden bar came +-- back after every portal, and so did a bar the inert verdict had hidden on purpose -- with the +-- verdict's latch still closed, so the diagnosis could not even be repeated. +local inertWarned = false + +local function applyVisibility() + -- cfg is nil until the ADDON_LOADED migration runs; default to shown, as the schema does. + local cfg = VampifyConfig.get() + if nSources > 0 and not inertWarned and (not cfg or cfg.shown) then + VampifyDisplay.show() + else + VampifyDisplay.hide() + end +end + +-- ---- source set ------------------------------------------------------------------------------ + +local function recompute() + local sources = VampifyDetect.getSources() + local sum, n = VampifyDetect.summarise(sources) + + -- The real per-source percent list, rebuilt fresh here alongside the aggregate above. Read + -- straight from sources[i].percent -- VampifyDetect already carries the individual values, it + -- was only ever summarise() that collapsed them. resetList (not a bare {}), like every other + -- reused list buffer in this addon: table.insert's stale `n` would otherwise show through the + -- next rebuild (see VampifyConst.resetList's own comment). + VampifyConst.resetList(sourcePercents) + VampifyConst.resetList(sourceMeta) + for i = 1, table.getn(sources) do + table.insert(sourcePercents, sources[i].percent) + table.insert(sourceMeta, { + key = VampifyAggregate.sourceKey(sources[i].slot, sources[i].kind), + label = VampifyAggregate.sourceLabel(sources[i].slot, sources[i].percent), + }) + end + + -- Manual overrides exist as a fallback for the day the tooltip wording changes. They add to + -- the detected set rather than replacing it -- in BOTH the aggregate and the per-source list, + -- so the model and the status line never disagree about what a manual source contributed. + -- + -- Identity for a manual entry: its APPEND index into cfg.manual, not a slot (capture/detect.lua + -- never gives a manual override one). Weaker than a real slot -- see core/aggregate.lua's own + -- comment on A.sourceKey -- but cfg.manual only ever grows (table.insert, /vf source add) or is + -- wiped whole (cfg.manual = {}, /vf source clear), never reordered or spliced, so the index is + -- stable for as long as the manual list itself is not cleared. + local cfg = VampifyConfig.getChar() + if cfg and cfg.manual then + for i = 1, table.getn(cfg.manual) do + sum = sum + (cfg.manual[i] / 100) + n = n + 1 + table.insert(sourcePercents, cfg.manual[i]) + table.insert(sourceMeta, { + key = "manual:" .. i, + label = "Manual +" .. cfg.manual[i] .. "%", + }) + end + end + + -- A CHANGE of the source set opens a new epoch. Compared before assigning, and only on a real + -- difference: recompute runs on every inventory event, and bumping unconditionally would make + -- every fight look like it straddled a gear swap. + if sum ~= sumPercent or n ~= nSources then + sourceEpoch = sourceEpoch + 1 + end + sumPercent, nSources = sum, n + applyVisibility() +end + +if VampifyDetect.onChange then VampifyDetect.onChange(recompute) end + +-- Single dispatch point between the two SCT engines: gui/sct.lua (mode "own" or "bar", positionable +-- and fully colored -- "bar" is the same engine anchored to the display bar instead of a free +-- position, gui/sct.lua's own concern) and gui/fct.lua (mode "blizzard", Blizzard_CombatText -- +-- position/timing are Blizzard's, only the color is ours). Everything but "blizzard" falls through +-- to VampifySCT.emit unchanged, so this dispatch needed no edit to support "bar". Everything +-- upstream of this just calls emitSct(); nothing else in the file needs to know which engine is live. +local function emitSct(amount, isOverheal, isCrit) + local cfg = VampifyConfig.get() + local mode = cfg and cfg.sct and cfg.sct.mode + if mode == "blizzard" then + -- Blizzard's engine has no overheal styling of its own (no parentheses/dimming channel); + -- an overheal number rides through it looking like a real heal. Degraded, not wrong. + VampifyFct.emit(amount, isCrit) + else + VampifySCT.emit(amount, isOverheal, isCrit) + end +end + +-- One reset, used by both /vf reset and the bar's reset icon. A second copy would drift: the bar +-- button would forget the accumulator or the per-spell breakdown the day one of them is added. +-- +-- scope: "session" (default), "lifetime", or "both". VampifyState is never REPLACED here (the old +-- `VampifyState = VampifyAggregate.new()` behaviour) -- its per-spell tables may be pointing at a +-- SavedVariables sub-table (see wireSession below), and reassigning the whole state would silently +-- orphan that table instead of clearing it. Every branch below clears fields on the SAME object, +-- via VampifyAggregate helpers that themselves clear in place. +-- +-- The default is "session" ON PURPOSE, even though the old unscoped behaviour cleared everything +-- there was to clear at the time. That is no longer true now that a lifetime set exists: a +-- destructive default would make existing "/vf reset" muscle memory silently wipe totals meant to +-- survive forever. The more destructive scopes ("lifetime", "both") must be asked for by name. +-- +-- "last" (2026-08-25, third detail tab): unlike session/lifetime/both, this scope clears ONLY +-- VampifyAggregate's last-fight tables (via A.resetScope("last")) -- there is no watchdog +-- accumulator, histogram or emittedFight counter tied to that scope to clear alongside it (those +-- all belong to the fHeal/fDmg fight-scalar lifecycle, which A.startFight already resets on its own +-- at the next fight boundary; see A.startFight's own comment). Not folded into "both": "both" is the +-- existing, load-bearing session+lifetime combination every call site above already relies on, and +-- adding a third bucket to it would silently make "both" more destructive for every existing caller. +-- +-- The session/lifetime branches below now DELEGATE their aggregate-level clearing to +-- VampifyAggregate.resetScope (2026-08-25) instead of calling A.resetTotals/resetSpells/ +-- resetLifetime directly -- same two calls, same order, so this is not a behaviour change, just one +-- fewer place that has to agree on what "reset this scope" means at the aggregate level. Everything +-- below the delegation (accumulator, histogram, watchdog counters, creditedAt) is watchdog/telemetry +-- state that lives in THIS file, not in core/aggregate.lua, and stays here. +function VampifyResetSession(scope) + scope = scope or "session" + if scope == "session" or scope == "both" then + VampifyAggregate.resetScope("session") + VampifyState.acc = VampifyModel.newAcc() + VampifyState.effHeal = 0 + -- Must move with the totals for the same reason the accumulator does: the histogram and + -- the session total have to describe the SAME span, or the upgrade preview computed from + -- one would be compared against the other. Cleared in place -- it may be the persisted + -- table (see wireSession below). + VampifyHistogram.reset(VampifyState.hist) + -- Must move with the accumulator: I3 compares the two, and a reset that touches only one + -- of them makes every later comparison meaningless. + emittedFight, aoeCorrectedShown = 0, 0 + -- Same reasoning one level down: the rows this reset just emptied must not be corrected by + -- a derive window that is still in flight. Cleared in place, not replaced -- the correction + -- listener holds this table as an upvalue. + for k in pairs(creditedAt) do creditedAt[k] = nil end + end + if scope == "lifetime" or scope == "both" then + VampifyAggregate.resetScope("lifetime") + VampifyHistogram.reset(VampifyState.lfHist) + end + if scope == "last" then + VampifyAggregate.resetScope("last") + end + if DEFAULT_CHAT_FRAME then + if scope == "both" then + DEFAULT_CHAT_FRAME:AddMessage("|cff8080ffVampify|r session and lifetime totals reset.") + elseif scope == "lifetime" then + DEFAULT_CHAT_FRAME:AddMessage("|cff8080ffVampify|r lifetime total reset.") + elseif scope == "last" then + DEFAULT_CHAT_FRAME:AddMessage("|cff8080ffVampify|r last-fight breakdown reset.") + else + DEFAULT_CHAT_FRAME:AddMessage("|cff8080ffVampify|r session reset.") + end + end +end + +-- ---- per-cast coalescing ----------------------------------------------------------------------- +-- +-- One AoE cast on five targets is five damage events, and therefore five floating numbers. With +-- sct.coalesce on, the numbers of a single cast are summed and shown ONCE -- "on cast" instead of +-- "on hit". Only the DISPLAY changes: the model still runs per hit, so totals, HPS and the carry +-- accumulator are bit-for-bit identical either way. +-- +-- Keyed by spellId, which is what identifies a cast; auto attacks carry no spellId and are never +-- coalesced. The bucket table is bounded by the player's spellbook, not by mob GUIDs, so it needs +-- no eviction sweep. +local pendHeal, pendOver, pendAt, pendCrit = {}, {}, {}, {} +local COALESCE_WINDOW = 0.35 -- a cast's damage events all land well inside this + +local function coalesceOn() + local cfg = VampifyConfig.get() + return cfg and cfg.sct and cfg.sct.coalesce and true or false +end + +-- Emits every bucket whose cast has gone quiet. Driven from the 0.25s display throttle, so a cast +-- shows up about a third of a second late -- imperceptible next to the 1.5s the number floats for. +local function flushCoalesced(now, force) + -- Clearing keys during a pairs() traversal is defined behaviour in Lua (only ADDING keys mid- + -- traversal is not), so emptying a bucket in place here is safe. + for spell, at in pairs(pendAt) do + if force or (now - at) >= COALESCE_WINDOW then + local h, o, c = pendHeal[spell] or 0, pendOver[spell] or 0, pendCrit[spell] + pendHeal[spell], pendOver[spell], pendAt[spell], pendCrit[spell] = nil, nil, nil, nil + if h > 0 then emitSct(h, false, c) end + if o > 0 then emitSct(o, true, c) end + end + end +end + +-- ---- the damage path ------------------------------------------------------------------------- + +local function beginFight(now) + VampifyAggregate.startFight(VampifyState, now) + -- The carry restarts per fight. Whether the server does the same is UNKNOWN -- this is a + -- choice, not a measurement. It bounds any resulting error to under 1 HP per fight, and the + -- session total is unaffected because the aggregate sums the float heal, not the increments. + VampifyState.acc = VampifyModel.newAcc() + VampifyState.effHeal = 0 + -- The watchdog's I3 compares displayed integers against the accumulator, and the accumulator + -- restarts here -- so the counter it is compared against has to restart with it. + emittedFight = 0 + aoeCorrectedShown = 0 + fightEpoch = sourceEpoch +end + +-- ---- the watchdog ------------------------------------------------------------------------------ +-- +-- Stage 1 (invariants) runs on a slow timer over our own numbers. Stage 2 (the HP reconciliation) +-- closes one window per own hit: the window opened by the PREVIOUS hit, because the healing that +-- hit caused needs time to reach the health bar. Neither ever writes a number into the display -- +-- both are alarms (spec 2.1). +local balance = VampifyWatch.newBalance() +local findings = {} +local lastHp, lastAt, lastExpected = nil, nil, nil +local nextCheck = 0 +local WATCH_INTERVAL = 15 -- slow on purpose: findings are read by a human, not a loop + +-- print_ already prefixes "Vampify", so this must not repeat it -- the first field build did, and +-- the result read "Vampify Vampify watch:". +-- +-- The file half is a ROLLING LOG, not a single line. ExportFile overwrites -- it has no append +-- mode -- so handing it one bare finding erased the previous one, and the file ended up holding +-- whichever finding happened to fire last while the earlier (often more serious) one left no trace +-- on disk at all. Same discipline as the error capture in core/const.lua, which this comment used +-- to claim without doing: rebuild the whole text on every write, with a self-identifying header. +-- Bounded so a flapping finding cannot grow it without limit. +local watchLog, WATCH_LOG_MAX = {}, 50 + +local function watchLine(text) + print_("|cffffcc00watchdog:|r " .. text) + if not ExportFile then return end -- SuperWoW only; harmless without it + local stamp = date and date("%H:%M:%S") or string.format("%.1f", GetTime and GetTime() or 0) + table.insert(watchLog, "[" .. stamp .. "] " .. text) + -- table.remove, not a hand-rolled shift: it keeps `n` in step, which table.getn reads. + while table.getn(watchLog) > WATCH_LOG_MAX do table.remove(watchLog, 1) end + ExportFile("vampify_watch", "addon=Vampify version=" .. tostring(VampifyConst.VERSION) + .. "\n" .. table.concat(watchLog, "\n")) +end + +-- Written for someone reading their chat frame mid-fight, not for whoever wrote the check. Each +-- line says what is inconsistent and what it means for the number on screen -- a finding nobody can +-- act on is noise, and noise trains you to ignore the next one. +local function describe(f) + if f.id == "I1" then + return string.format("the per-ability rows add up to %.0f but the session total says %.0f " + .. "-- one of the two is wrong", f.a, f.b) + elseif f.id == "I2" then + return string.format("%s shows more overhealing than healing -- the split is broken", + VampifyConst.spellName(f.spell)) + elseif f.id == "I3" then + return string.format("the floating numbers add up to %d but the running total is at %d " + .. "-- some were shown twice or not at all", f.a, f.b) + elseif f.id == "I4" then + return "your gear changed during this fight -- its percentage mixes two different setups" + elseif f.id == "I5" then + return "you are hitting several targets but the client never reported the area-damage " + .. "event, so the 30% reduction cannot be applied -- the total reads HIGH" + elseif f.id == "H1" then + return string.format("over %d checks only %.0f%% of the healing credited here actually " + .. "reached your health bar -- something in the breakdown may not " + .. "return any healing at all", f.windows, f.ratio * 100) + elseif f.id == "H2" then + return string.format("over %d checks %.0f%% more healing arrived than this addon can " + .. "explain -- a source is being missed", f.windows, f.ratio * 100) + elseif f.id == "H3" then + return "self-check failed: incoming damage is not being read correctly, so the two checks " + .. "above cannot be trusted and are being withheld" + end + return f.id +end + +-- Whether multi-target damage was ever seen. Set by the AoE derivation rather than by +-- SPELL_GO_SELF -- if the cast event were arriving, there would be nothing for I5 to warn about. +-- (emittedFight and aoeCorrectedShown are declared further up, next to the reset that has to clear +-- them.) +local multiTargetSeen = false + +local watcher = VampifyWatch.new() +local watchCtx = { rows = nil, rowCount = 0 } +local rowBuf = {} +-- The stage-2 verdicts carry no isNew of their own -- they are computed from the running sums, so +-- they would repeat on every interval once they fire. Reported once per session instead. +local reportedOnce = {} + +-- The control comes FIRST and suppresses the verdicts: if the two branches disagree, the reading of +-- incoming damage is wrong, and an H1/H2 built on it would be a confident wrong answer -- which is +-- worse than none. This is the lesson two earlier analyses paid for. +local function runWatch() + watchCtx.rows = VampifyAggregate.spellBreakdown(VampifyState, rowBuf) + watchCtx.rowCount = table.getn(rowBuf) + watchCtx.sessionHeal = VampifyState.sHeal + watchCtx.emitted = emittedFight + watchCtx.accTotal = VampifyState.acc and VampifyState.acc.total or 0 + watchCtx.correctedShown = aoeCorrectedShown + -- I4 only means anything WHILE a fight is running. Out of combat, changing gear is the normal + -- thing to do, and the fight epoch keeps its last value -- so comparing the two outside a fight + -- reports every single swap, forever, and no reset clears it because the epochs are not what a + -- reset touches. Reported in the field within minutes of shipping. + if VampifyState.fActive then + watchCtx.fightEpoch = fightEpoch + watchCtx.sourceEpoch = sourceEpoch + else + watchCtx.fightEpoch, watchCtx.sourceEpoch = nil, nil + end + watchCtx.aoeAvailable = VampifyDamage.aoeAvailable() + watchCtx.multiTargetSeen = multiTargetSeen + + local n = VampifyWatch.check(watcher, watchCtx, findings) + local flag = nil + for i = 1, n do + local d = describe(findings[i]) + if findings[i].isNew then watchLine(d) end + -- The marker shows the FIRST finding: they are emitted in check order, and I1 (the totals + -- disagreeing) is the one that makes every other number suspect. + if not flag then flag = d end + end + -- Cleared on a clean pass, so the marker tracks the current state rather than the worst thing + -- that ever happened. Chat says it once; the marker says whether it is still true. + if VampifyDisplay.setWatchFlag then VampifyDisplay.setWatchFlag(flag) end + + local control = VampifyWatch.crossCheck(balance, 30, 0.25) + if control then + if not reportedOnce[control.id] then + reportedOnce[control.id] = true + watchLine(describe(control)) + end + return -- verdicts built on a failed control are not reported at all + end + local verdict = VampifyWatch.alarm(balance, 30, 0.25) + if verdict and not reportedOnce[verdict.id] then + reportedOnce[verdict.id] = true + watchLine(describe(verdict)) + end +end + +if VampifyDamage.onDamage then + VampifyDamage.onDamage(function(amount, isAoE, targetGuid, spellId, isCrit) + -- Dormant: with no sources there is no work and no allocation. + if nSources <= 0 then return end + + -- Damage that cannot trigger Vampirism is dropped ENTIRELY -- not just its healing. Its + -- damage would otherwise sit in the denominator of every percentage and drag the headline + -- number below the truth. See VampifyConst.NO_TRIGGER. + if not VampifyConst.triggersVampirism(spellId) then return end + + -- A damage event outside an active fight means the fight boundary was missed -- a /reload + -- mid-combat, or a packet arriving after PLAYER_REGEN_ENABLED. Start a fight rather than + -- adding to the previous one, which would silently inflate a total already shown as final. + if not VampifyState.fActive then beginFight(GetTime()) end + + -- sourcePercents, not sumPercent/nSources: the server truncates each source + -- INDEPENDENTLY (core/model.lua's header), so the real per-source list is what the model + -- needs now. inc and healFloat are the SAME number always (see M.add's own comment) -- + -- kept as two names because everything below still reads them as two different things. + local inc, healFloat = VampifyModel.add(VampifyState.acc, amount, sourcePercents, isAoE) + + -- The per-source breakdown of the SAME hit, for the UI's "item X contributed Y to THIS + -- spell" question (core/aggregate.lua's A.spellSourceBreakdown). Deliberately a SEPARATE + -- call rather than a change to M.add's return shape above: M.add's arithmetic (and + -- therefore VampifyState.acc.total / spHeal) must stay bit-for-bit what it already is, and + -- computing the breakdown here cannot perturb that -- it only reads sourcePercents/factor a + -- second time into a scratch buffer. Same factor logic as M.add uses internally (core/ + -- model.lua) and the retroactive AoE correction below duplicates for the same reason: the + -- arithmetic itself lives in exactly one place (M.healBreakdown), so a second caller + -- deriving the same factor is not a second copy of the truncation logic. + local aoeFactor = 1 + if isAoE then aoeFactor = VampifyConst.FORMULA.aoeFactor end + VampifyModel.healBreakdown(amount, sourcePercents, aoeFactor, breakdownBuf) + VampifyAggregate.recordSourceBreakdown(VampifyState, spellId, breakdownBuf, sourceMeta) + + -- Dev-only per-hit debug export (core/perhit.lua). PH.hit no-ops in one check when + -- /vf perhit is off (the default), so this costs nothing on the normal path. + if VampifyPerHit.isEnabled() then + VampifyPerHit.hit({ + t = GetTime(), + src = VampifyConst.spellName(spellId or VampifyAggregate.MELEE), + dmg = amount, + aoe = isAoE, + P = sumPercent, + n = nSources, + pred_heal = healFloat, + acc_total = VampifyState.acc.total, + crit = isCrit, + }, UnitHealth and UnitHealth("player"), GetTime(), UnitHealthMax and UnitHealthMax("player")) + end + + VampifyAggregate.record(VampifyState, inc, healFloat, amount, GetTime()) + -- Session-scoped per-ability totals for the bar's mouseover breakdown. Overheal is added + -- further down, once the health deficit for this hit is known. + + -- Effective (what actually landed) is tracked alongside gross, because a heal at full + -- health returns nothing. Gross is what the display shows; effective is in /vf status. + local effInc = 0 + if UnitHealth and UnitHealthMax then + local deficit = UnitHealthMax("player") - UnitHealth("player") + effInc = VampifyModel.effective(healFloat, deficit) + VampifyState.effHeal = VampifyState.effHeal + effInc + end + + -- Per-hit overheal, not the cumulative fight-total kind the display/options info panel + -- show: healFloat is this hit's raw float heal, effInc is the slice of it that actually + -- landed (both computed above), so the remainder is what this hit overhealed by. Floored + -- like every other number shown, and only emitted if that floor is actually positive -- + -- sub-1 overheal is real but not worth a "+0"-shaped number on screen. + -- + -- notOnTarget (core/aggregate.lua's A.recordSpell, ST/AoE split): true unless THIS hit's own + -- targetGuid matches the CURRENT target's GUID, read fresh right here -- see + -- currentTargetGuid's own comment for why fresh-per-hit is what makes a mid-fight target + -- switch leave already-recorded hits alone. Deliberately NOT `isAoE` (the deriveAoE + -- area-damage classification a few lines up) -- that drives the AoE-DAMPING factor in the + -- healing math above and is a different axis entirely; see A.recordSpell's header for the + -- full history of why these two used to be conflated and no longer are. + local notOnTarget = true + local curTarget = currentTargetGuid() + if curTarget and targetGuid and targetGuid == curTarget then notOnTarget = false end + VampifyAggregate.recordSpell(VampifyState, spellId, healFloat, amount, healFloat - effInc, notOnTarget) + -- The hit's SIZE, into both scopes' distributions -- one table-index increment each, no + -- allocation (core/histogram.lua). Deliberately fed the same `amount` and `isAoE` the model + -- was given a few lines up, and from BELOW every drop above (no sources, excluded spell), + -- so the histogram describes exactly the hits that were credited and nothing else. That + -- identity is what makes the upgrade preview's baseline reproduce the real total. + VampifyHistogram.add(VampifyState.hist, amount, isAoE) + VampifyHistogram.add(VampifyState.lfHist, amount, isAoE) + -- The row for this spell now holds this hit. That is what the retroactive AoE correction + -- below needs to know before it takes any of it back, and here -- past every drop -- is the + -- only place that knows it. + creditedAt[spellId or VampifyAggregate.MELEE] = GetTime() + + -- I6: with sources equipped, a hit that dealt damage cannot return nothing -- the floor is + -- the source count. Checked here rather than over the breakdown, where the zero would + -- already have been averaged away. + -- Reported once per session, like the stage-2 verdicts: W.checkHit is an unthrottled + -- predicate over a single hit and carries no isNew of its own, so an unguarded call would + -- repeat per damage event -- a chat flood, and 30-60 rolls of the file log per second. + if not reportedOnce["I6"] and VampifyWatch.checkHit(amount, nSources, healFloat) then + reportedOnce["I6"] = true + watchLine(string.format("a %d damage hit returned nothing with %d sources equipped " + .. "-- the formula was bypassed", amount, nSources)) + end + + -- Stage 2 closes the window the PREVIOUS hit opened: the healing that hit caused needs time + -- to reach the bar, so it can only be measured against the health read at the next one. + -- + -- VampifyIncoming.isOn() is now true whenever EITHER /vf watch OR the per-hit export wants + -- the underlying capture (capture/incoming.lua's watchWanted/perhitWanted split, + -- follow-up change request, 2026-08-22) -- so this window-tracking block itself runs for + -- either reason. The two consumers below are gated SEPARATELY: the watch balance only on + -- I.isWatchOn() (watch's own group-refusal guard, unchanged), the per-hit lines only on + -- VampifyPerHit.isEnabled() (no group/watch dependency at all -- that decoupling is the + -- entire point of the split). + if VampifyIncoming and VampifyIncoming.isOn() and UnitHealth and UnitHealthMax then + local hpNow, now = UnitHealth("player"), GetTime() + if lastHp then + local incDmg, selfHeal, extHeal = VampifyIncoming.take() + if VampifyIncoming.isWatchOn() then + VampifyWatch.addWindow(balance, lastHp, hpNow, UnitHealthMax("player"), + incDmg, (selfHeal or 0) + (extHeal or 0), lastExpected, + now - lastAt, VampifyIncoming.inGroup()) + end + -- Same window the reconciliation above closes when watch wants it -- no new event + -- registration either way: this rides VampifyIncoming.take(), which now accumulates + -- whenever perhit OR watch wants it, independent of group status or watch's guard. + if VampifyPerHit.isEnabled() then + VampifyPerHit.incoming(now, incDmg) + VampifyPerHit.selfheal(now, selfHeal) + VampifyPerHit.extheal(now, extHeal) + end + else + VampifyIncoming.take() -- discard whatever accumulated before the first window + end + lastHp, lastAt, lastExpected = hpNow, now, effInc + end + + -- Split the shown integer into the part that LANDED and the part that overhealed. These + -- partition it -- emitting the full amount and an overheal number beside it made one hit + -- look like two, which is why at full health nothing appeared in parentheses. + local cfg = VampifyConfig.get() + local landedAmt, overhealAmt = VampifyModel.splitOverheal(inc, healFloat, effInc) + if not (cfg and cfg.showOverheal) then + -- Overheal display off: show the whole return as healing, as before. + landedAmt, overhealAmt = inc, 0 + end + + -- Counted before the coalescing branch, so buffering a cast's numbers does not look like + -- losing them. This is the figure I3 holds against the accumulator; the split sits between + -- the two, which is what makes the comparison worth making rather than circular. + emittedFight = emittedFight + landedAmt + overhealAmt + + if spellId and coalesceOn() then + -- Sum into this cast's bucket; the throttle flushes it once the cast goes quiet. A cast + -- counts as a crit if any of its hits was one. + pendHeal[spellId] = (pendHeal[spellId] or 0) + landedAmt + pendOver[spellId] = (pendOver[spellId] or 0) + overhealAmt + pendAt[spellId] = GetTime() + if isCrit then pendCrit[spellId] = true end + else + if landedAmt > 0 then emitSct(landedAmt, false, isCrit) end + if overhealAmt > 0 then emitSct(overhealAmt, true, isCrit) end + end + end) +end + +-- ---- retroactive AoE correction ---------------------------------------------------------------- +-- +-- An item proc has no cast, so no SPELL_GO_SELF announces it and its first hit is credited at the +-- full rate. The second target proves it was area damage -- after the fact. The correction goes +-- through the accumulator as an exact integer delta (VampifyModel.healPerSources at the AoE factor +-- minus at 1) rather than by rewriting the hit -- there is nothing to "rewrite" the display of, +-- since the original hit's SCT number already showed the exact amount it was credited (spec 4.2). +-- +-- effHeal is deliberately NOT corrected. It was capped by the health deficit at the moment of that +-- hit, which is gone; a proportional guess would look precise without being it. Gross and per-spell +-- figures are exact, /vf status' effective figure runs at most a fraction high on proc AoE. +if VampifyDamage.onAoECorrection then + VampifyDamage.onAoECorrection(function(amount, targetGuid, spellId, isCrit) + -- The same exclusion the damage listener applies, and BEFORE multiTargetSeen on purpose. + -- Every hit of an excluded spell was dropped whole, so a correction here would subtract + -- healing that was never credited -- driving the row, the session total and the persisted + -- lifetime set negative for exactly the multi-target damage-shield case NO_TRIGGER exists + -- for. And a spell that cannot trigger Vampirism needs no AoE damping either, so it must + -- not raise I5 ("damping was needed and unavailable") on its own account. + if not VampifyConst.triggersVampirism(spellId) then return end + -- Seen at all: this is what tells I5 that AoE damping was needed while unavailable. Above + -- the credit check below on purpose: whether WE booked the first hit says nothing about + -- whether area damage happened, which is all I5 is about. + multiTargetSeen = true + if nSources <= 0 then return end + + -- Only a hit that was actually CREDITED may be corrected. The exclusion guard above closes + -- one way the first hit can be dropped; it is not the only one. The damage listener also + -- drops every hit while no source set has been committed yet (the seconds after a loading + -- screen, while detect.lua is still retrying an uncached tooltip), and a reset can empty + -- the row between the two hits of a burst -- during AoE grinding the derive windows are + -- continuous, so that is ordinary, not a race. In each case the reason is gone by the time + -- this listener runs, so it does not re-derive eligibility; it reads what the damage + -- listener recorded. One check for every reason there is, including the next one. + -- + -- Subtracting healing that was never added drove the row, the session total and the + -- PERSISTED lifetime set negative: overheal above healing, which is what I2 calls a broken + -- split, in a table nothing clears but /vf reset lifetime. + local k = spellId or VampifyAggregate.MELEE + local creditAt = creditedAt[k] + if not creditAt then return end + if (GetTime() - creditAt) > VampifyDamage.DERIVE_WINDOW then return end + + -- The histogram learns the reclassification too, and BEFORE the delta==0 shortcut below: + -- whether the healing happens to change is a separate question from which side of the + -- distribution the hit belongs on. Left uncorrected, the histogram would keep calling a + -- hit "normal" that was paid as AoE, and its baseline would stop reproducing the credited + -- total -- the one property every upgrade figure rests on. + VampifyHistogram.reclassify(VampifyState.hist, amount) + VampifyHistogram.reclassify(VampifyState.lfHist, amount) + + local delta = VampifyModel.healPerSources(amount, sourcePercents, VampifyConst.FORMULA.aoeFactor) + - VampifyModel.healPerSources(amount, sourcePercents, 1) + if delta == 0 then return end + + if VampifyState.acc then + VampifyState.acc.total = VampifyState.acc.total + delta + -- Heals are exact integers now (core/model.lua's header -- no more fractional carry), + -- so delta itself is ALWAYS a whole number: the number of integers this correction + -- removes from what was already shown is exactly -delta, with no floor-straddling + -- reconstruction needed (the old accumulator's fractional part could make that + -- reconstruction off by one; there is no fractional part left to straddle). + aoeCorrectedShown = aoeCorrectedShown - delta + end + + -- Overheal is a PART of the row's healing, so healing taken back has to take its share of + -- overheal with it -- otherwise the row reads overheal > heal, which is precisely what the + -- watchdog's I2 calls a broken split, and at (or near) full health that is every row. The + -- exact per-hit split is unrecoverable at this point: the health deficit at that hit is + -- gone, which is the same reason effHeal is left alone above. The row's own pre-correction + -- ratio is the honest approximation, and it is exact in the full-health case that produced + -- the false alarm. (`k` is the row key resolved with the credit check above.) + -- + -- What this scaling does NOT do is guarantee the result is a valid row. That rests on the + -- credit check above -- on the row having received this hit in the first place -- and an + -- earlier version of this file asserted the guarantee outright ("h + delta >= 0 is assured") + -- on the strength of the exclusion guard alone, which assures nothing of the kind. The + -- shape (0 <= overheal <= heal) is therefore also ENFORCED where it is written, in + -- VampifyAggregate.recordSpell, on the negative-delta path only. + local h, share = VampifyState.spHeal[k] or 0, 0 + if h > 0 then share = (VampifyState.spOver[k] or 0) / h end + if share > 1 then share = 1 end + + -- Routed through the normal recording calls so both the session and lifetime sets are + -- adjusted by the same code that filled them. inc is 0: nothing new is shown for this. + VampifyAggregate.record(VampifyState, 0, delta, 0, nil) + VampifyAggregate.recordSpell(VampifyState, spellId, delta, 0, delta * share) + end) +end + +-- ---- persistence: two per-spell sets in VampifyCharDB, wired by reference ---------------------- +-- +-- core/aggregate.lua stays WoW-API-free, so it does not know about SavedVariables; its per-spell +-- tables (spHeal/spDmg/spOver/spHits, and their lifetime twins) start out as the plain local tables +-- A.new() creates. wireSession() below REPOINTS them, once, at sub-tables of VampifyCharDB, so +-- every A.recordSpell call from that moment on is already a SavedVariables write -- no explicit +-- save step, no allocation beyond the one-time reassignment (spec 2026-08-10 sec 3). +-- +-- Reload vs. login -- the criterion and its reasoning: +-- +-- PLAYER_LOGIN CANNOT ANSWER THIS, and an earlier version of this file that branched on it wiped +-- the session breakdown on every single /reload -- the exact loss this persistence exists to +-- prevent. 1.12 fires PLAYER_LOGIN on a UI reload as well, immediately before +-- PLAYER_ENTERING_WORLD, so it carries no login-vs-reload information at all. That is not a +-- reading of documentation: another addon on the same machine builds its reload detector on exactly +-- this (writes a fresh session id from its PLAYER_LOGIN handler, and a CHANGED id is what proves a +-- /reload happened), and installed 1.12 addons initialise from it -- aux-addon prints its load banner from +-- PLAYER_LOGIN, which appears after every reload. PLAYER_ENTERING_WORLD fires on every path +-- (login, reload AND every zone change), so it proves nothing on its own either. +-- +-- What DOES separate the two is the logout. PLAYER_CAMPING and PLAYER_QUITING are raised when the +-- server accepts a logout or quit request (1.12 FrameXML shows the camp/quit countdown from them); +-- a /reload asks the server for nothing and raises neither. So the marker goes there, which is also +-- what the design doc originally prescribed. LOGOUT_CANCEL takes it back if the player changes +-- their mind, and wireSession consumes it unconditionally, so a marker can never go stale. +-- +-- The marker HAS holes -- a crash, a dropped connection, or any exit that never reaches those +-- events leaves none behind. That is the SAFE direction on purpose: with no marker the session is +-- CONTINUED (an over-counted session) rather than a breakdown destroyed that nothing can recover. +-- The LIFETIME set is not conditioned on this criterion at all (see below), so none of this can +-- cost it anything. +local sessionWired = false + +local function wireSession() + if sessionWired then return end + local cfg = VampifyConfig.getChar() + if not cfg then return end -- config not migrated yet -- retry on the next PLAYER_ENTERING_WORLD + sessionWired = true + + -- Consumed whichever branch runs: a marker must not outlive the load that read it, or the next + -- reload after a cancelled-but-marked logout would be taken for a login. + local afterLogout = cfg.loggingOut + cfg.loggingOut = nil + + if afterLogout then + -- Genuine login: the session set starts over. Cleared IN PLACE, not by replacing the + -- table with a new {} -- see A.resetSpells' comment for why that distinction matters here. + for k in pairs(cfg.session.heal) do cfg.session.heal[k] = nil end + for k in pairs(cfg.session.damage) do cfg.session.damage[k] = nil end + for k in pairs(cfg.session.overheal) do cfg.session.overheal[k] = nil end + for k in pairs(cfg.session.hits) do cfg.session.hits[k] = nil end + -- The ST/AoE split twins start over on the same criterion, for the same reason -- otherwise + -- a genuine login would keep last session's AoE-only figures sitting above a freshly-zeroed + -- session total, which A.spellSplit's defensive clamp would then have to paper over. + for k in pairs(cfg.session.healAoe) do cfg.session.healAoe[k] = nil end + for k in pairs(cfg.session.damageAoe) do cfg.session.damageAoe[k] = nil end + for k in pairs(cfg.session.hitsAoe) do cfg.session.hitsAoe[k] = nil end + for k in pairs(cfg.session.splitSeen) do cfg.session.splitSeen[k] = nil end + -- The session DISTRIBUTION starts over on the same criterion and for the same reason: it + -- describes the same span the session rows do, and a distribution outliving the totals it + -- belongs to would answer "what is an upgrade worth this session" from last session's hits. + VampifyHistogram.reset(cfg.session.hist) + end + -- Reload path (no marker): the session tables are left exactly as SavedVariables handed them + -- back. That IS "continuing the session" -- there is nothing else to do here. + + VampifyState.spHeal, VampifyState.spDmg, VampifyState.spOver = + cfg.session.heal, cfg.session.damage, cfg.session.overheal + VampifyState.spHits = cfg.session.hits + VampifyState.lfHeal, VampifyState.lfDmg, VampifyState.lfOver = + cfg.lifetime.heal, cfg.lifetime.damage, cfg.lifetime.overheal + VampifyState.lfHits = cfg.lifetime.hits + -- Same aliasing for the ST/AoE split tables (core/aggregate.lua's A.recordSpell/A.spellSplit), + -- same reasoning: every A.recordSpell call from here on is already a SavedVariables write. + VampifyState.spHealAoe, VampifyState.spDmgAoe, VampifyState.spHitsAoe, VampifyState.spSplitSeen = + cfg.session.healAoe, cfg.session.damageAoe, cfg.session.hitsAoe, cfg.session.splitSeen + VampifyState.lfHealAoe, VampifyState.lfDmgAoe, VampifyState.lfHitsAoe, VampifyState.lfSplitSeen = + cfg.lifetime.healAoe, cfg.lifetime.damageAoe, cfg.lifetime.hitsAoe, cfg.lifetime.splitSeen + -- Same aliasing for the two histograms. The tables the config migration created (or filled in + -- for a character db that predates the feature -- core/config.lua's CHAR_DEFAULTS) become THE + -- histograms from here on; the ones VampifyState was created with are dropped. + VampifyState.hist, VampifyState.lfHist = cfg.session.hist, cfg.lifetime.hist + + -- sHeal/sDmg (the SEPARATELY kept session total the tooltip's cross-check compares the + -- per-spell rows against, gui/display.lua) are plain numbers, not tables, so they cannot be + -- aliased the way the per-spell tables just were -- they need reseeding from whatever the + -- per-spell session table now holds, or they would read 0 against a non-empty breakdown right + -- after a reload and raise a false alarm on the very first hover. + VampifyAggregate.seedSessionTotals(VampifyState) +end + +-- ---- per-hit export enable/disable, coupled to the incoming capture's "perhit" want ------------ +-- +-- File scope, not nested inside "if CreateFrame then" below: it is used both by syncPerHit() +-- (which IS inside that block) and by the /vf perhit slash handler (which is not -- the slash +-- commands are wired unconditionally further down). One place that flips VampifyPerHit AND +-- VampifyIncoming's perhitWanted together, so the two can never drift -- forgetting the second +-- call at just one of the two call sites would either leave INC/SELFHEAL/EXTHEAL silently absent +-- (perhit on, incoming not wanted) or leave the _OTHER events registered forever after perhit off +-- (incoming still wanted, nothing consuming it). +local function applyPerHitWant(want, hpNow) + if want then + if not VampifyPerHit.isEnabled() then VampifyPerHit.enable() end + else + if VampifyPerHit.isEnabled() then VampifyPerHit.disable(hpNow) end + end + if VampifyIncoming and VampifyIncoming.setPerHitWanted then + VampifyIncoming.setPerHitWanted(want) + end +end + +-- ---- fight boundaries and the display throttle ------------------------------------------------- + +if CreateFrame then + local f = CreateFrame("Frame", "VampifyCoreFrame") + local elapsed = 0 + + -- Both PLAYER_LOGIN and PLAYER_ENTERING_WORLD: the two together cover every path into the + -- world, and a reloaded session with a stale source set would compute against the wrong gear. + -- Neither says WHICH path it was -- see wireSession above for what does. + f:RegisterEvent("PLAYER_LOGIN") + f:RegisterEvent("PLAYER_ENTERING_WORLD") + f:RegisterEvent("PLAYER_REGEN_DISABLED") -- entering combat + f:RegisterEvent("PLAYER_REGEN_ENABLED") -- leaving combat + f:RegisterEvent("PLAYER_DEAD") + -- The logout marker wireSession reads. A /reload raises none of these three. + f:RegisterEvent("PLAYER_CAMPING") + f:RegisterEvent("PLAYER_QUITING") + f:RegisterEvent("LOGOUT_CANCEL") + + -- The running count of combat seconds the "no damage events at all" verdict is given from. + -- Declared out here rather than beside the OnUpdate that uses it because the combat-end branch + -- below has to reset it -- 20 seconds means 20 seconds of ONE fight, not a session-long sum of + -- unrelated pulls. (The latch itself, inertWarned, lives at file scope: applyVisibility above + -- has to see it, or a loading screen would put the untrusted zero straight back on screen.) + local combatSecs = 0 + + -- Applies the persisted preference (VampifyDB.perhitEnabled, default true -- core/config.lua) + -- to the actual channel state via applyPerHitWant (above), which is idempotent, so calling it + -- on every PLAYER_ENTERING_WORLD -- not just login/reload, every zone change too -- is + -- harmless; it exists so a login before the config migration has run cannot silently skip + -- turning the channel on. Mirrors /vf perhit on|off below, which is the other writer of this + -- same field. + local function syncPerHit() + local cfg = VampifyConfig.get() + if not cfg then return end + applyPerHitWant(cfg.perhitEnabled, UnitHealth and UnitHealth("player")) + end + + f:SetScript("OnEvent", function() + if event == "PLAYER_CAMPING" or event == "PLAYER_QUITING" or event == "LOGOUT_CANCEL" then + local c = VampifyConfig.getChar() + if c then + if event == "LOGOUT_CANCEL" then c.loggingOut = nil else c.loggingOut = true end + end + -- A real logout/camp request (never a cancel) is the one point that MUST guarantee a + -- write: whatever is still buffered would otherwise be lost the moment the client + -- closes -- ExportFile writes immediately on call, but nothing has called it yet for + -- these lines. The SavedVariables persistence marker above has the same "holes on a + -- hard crash" caveat (wireSession's comment); this is the same tradeoff for the + -- per-hit channel, not a new one. + if event ~= "LOGOUT_CANCEL" and VampifyPerHit.isEnabled() then + VampifyPerHit.flushNow(UnitHealth and UnitHealth("player")) + end + return + end + if event == "PLAYER_LOGIN" or event == "PLAYER_ENTERING_WORLD" then + if event == "PLAYER_ENTERING_WORLD" then wireSession() end + recompute() + syncPerHit() + if VampifyMinimap and VampifyMinimap.update then VampifyMinimap.update() end + -- A /reload mid-combat never delivers PLAYER_REGEN_DISABLED, so fStart would stay at 0 + -- and HPS would be computed against the whole client uptime. + if UnitAffectingCombat and UnitAffectingCombat("player") then beginFight(GetTime()) end + elseif event == "PLAYER_REGEN_DISABLED" then + beginFight(GetTime()) + elseif event == "PLAYER_REGEN_ENABLED" or event == "PLAYER_DEAD" then + flushCoalesced(GetTime(), true) -- do not strand a cast's number at combat end + VampifyAggregate.endFight(VampifyState, GetTime()) + combatSecs = 0 -- the inert verdict counts ONE fight, not their sum + VampifyDisplay.update() + -- Closes the last hit's pending hp_after window WITHOUT forcing a disk write -- + -- core/perhit.lua's PH.closeWindow. Grinding mobs one at a time drops combat between + -- every pull, and forcing a full chunk flush here would turn that into a file per kill; + -- several short fights' hits accumulate in the SAME buffer until FLUSH_LINES/FLUSH_SECS + -- fires naturally (or a real logout forces it, above). + if VampifyPerHit.isEnabled() then + VampifyPerHit.closeWindow(UnitHealth and UnitHealth("player")) + end + end + end) + + f:SetScript("OnUpdate", function() + elapsed = elapsed + arg1 + if elapsed < 0.25 then return end + + -- The watchdog rides this throttle rather than owning a frame: it runs on its own slow + -- interval, and one OnUpdate handler is cheaper than two (a lesson from profiling a sibling + -- addon, where parallel per-window handlers tripled the call count). + local nowT = GetTime() + if nowT >= nextCheck then + nextCheck = nowT + WATCH_INTERVAL + if nSources > 0 then runWatch() end + end + + -- nampower cannot be queried, only observed (see damage.lua). If the player has Vampirism + -- sources and has been in combat for a while without a single own-damage event, the + -- capture layer may be inert -- say so once, rather than displaying a confident zero. + -- + -- This is a diagnosis made from SILENCE, and silence is weak evidence: being attacked while + -- feared, stunned, mounted or letting a pet tank produces exactly the same reading. Three + -- things bound it, all of them because it was a confident wrong answer without them: + -- * the timer counts ONE fight -- reset at combat end above, not a session-long sum; + -- * a SPELL_GO_SELF that has arrived (aoeAvailable) is positive proof that nampower is + -- loaded and its events are on, which makes the verdict provably false; + -- * the latch releases the moment a damage event does arrive, so a wrong verdict repairs + -- itself instead of keeping the bar hidden for the rest of the session. + if inertWarned and VampifyDamage.damageSeen() then + inertWarned, combatSecs = false, 0 + -- Through applyVisibility, not a bare show(): a bar the USER hid must stay hidden. + applyVisibility() + elseif not inertWarned and nSources > 0 and UnitAffectingCombat and UnitAffectingCombat("player") then + combatSecs = combatSecs + elapsed + if combatSecs > 20 and not VampifyDamage.damageSeen() + and not VampifyDamage.aoeAvailable() then + inertWarned = true + print_("no own-damage events received in this fight -- nampower may be absent or" + .." its events are off.") + print_("the number would be a confident zero, so the display stays hidden.") + applyVisibility() + end + end + + -- Dev-only per-hit channel: closes a hit that never got a follow-up event (see + -- core/perhit.lua's PENDING_TIMEOUT) and drives its time-based flush. Rides this same + -- throttle rather than its own OnUpdate, for the same reason the watchdog above does. + if VampifyPerHit.isEnabled() then + VampifyPerHit.onTick(UnitHealth and UnitHealth("player"), nowT, elapsed) + end + + elapsed = 0 + flushCoalesced(GetTime(), false) + VampifyDisplay.update() + end) +end + +-- ---- slash commands ---------------------------------------------------------------------------- + +local shareBuf = {} +local excludeBuf = {} +-- One result table per scope, reused: /vf upgrade prints both, and VampifyHistogram.compare fills +-- whatever it is handed rather than building a new one. +local cmpSession, cmpLifetime = {}, {} + +local function status() + local sources = VampifyDetect.getSources() + local n = table.getn(sources) + -- "at least N" not "floor N": each source now floors its OWN contribution at 1 (see + -- core/model.lua), so the per-hit minimum is still exactly nSources (one per source, summed) + -- for any hit above the damage<=1 proc-fail cutoff -- same number as before, different reason. + print_("version "..VampifyConst.VERSION.." -- "..n.." detected source(s), " + ..string.format("%.0f%%", sumPercent * 100).." total, at least "..nSources.." per hit") + for i = 1, n do + print_(string.format(" slot %d: %d%% (%s)", sources[i].slot, sources[i].percent, sources[i].kind)) + end + local cfg = VampifyConfig.getChar() + if cfg and cfg.manual and table.getn(cfg.manual) > 0 then + for i = 1, table.getn(cfg.manual) do + print_(" manual override: "..cfg.manual[i].."%") + end + end + + local fight = VampifyAggregate.fight(VampifyState) + local session = VampifyAggregate.session(VampifyState) + -- Overheal is CALCULATED (deficit at hit time), not measured -- Vampirism emits no heal event. + -- Floored, like every other total here, never rounded up. + local eff = VampifyState.effHeal or 0 + local overheal = fight.heal - eff + if overheal < 0 then overheal = 0 end + local ohPct = 0 + if fight.heal > 0 then ohPct = overheal / fight.heal * 100 end + print_(string.format("fight: %d healed, %d effective, %d overheal (%.0f%%) over %d damage," + .." %.1f HPS, %.2f%%", + math.floor(fight.heal), math.floor(eff), math.floor(overheal), ohPct, + fight.damage, fight.hps, fight.pct * 100)) + print_(string.format("session: %d healed over %d damage, %.2f%%", + math.floor(session.heal), session.damage, session.pct * 100)) + + -- Per-source breakdown. Sources are summed BEFORE rounding, so there is no per-source integer + -- to attribute -- these are proportional shares of the fight total, not separate heals. + if n > 0 and fight.heal > 0 then + VampifyAggregate.shares(VampifyState, sources, shareBuf) + for i = 1, table.getn(shareBuf) do + print_(string.format(" share slot %d: %.1f HP (%.0f%% of the total)", + sources[i].slot, shareBuf[i].heal, shareBuf[i].share * 100)) + end + end + + local g, b, a, r = VampifyDamage.mitigation() + if b + a + r > 0 then + print_(string.format("mitigation seen: %d blocked, %d absorbed, %d resisted of %d gross" + .." (damageBase=%s)", b, a, r, g, VampifyConst.FORMULA.damageBase)) + end + if not VampifyDamage.damageSeen() then + print_("no own-damage events received yet -- nampower may be absent.") + end + + local flag = VampifyDisplay.boundFlag(VampifyConst.FORMULA.channels, VampifyDamage.aoeAvailable()) + if flag == "lower" or flag == "uncertain" then + print_("|cffffff00>|r the total is a LOWER bound: DoT and PvP damage are unmeasured and excluded.") + end + if flag == "upper" or flag == "uncertain" then + print_("|cffffff00<|r the total is an UPPER bound: no SPELL_GO seen, so AoE damping is not applied.") + end +end + +-- ---- /vf upgrade -- what a Vampirism upgrade would have been worth on the hits that fell -------- +-- +-- The question this answers cannot be answered from any total (core/histogram.lua's header): while +-- pct*D/100 < 1 a source pays its floor of 1 whatever its percentage is, so on small hits MANY +-- SMALL sources beat FEW LARGE ones, and on big hits it reverses (truncation lets a fatter source +-- keep remainders two thinner ones each throw away). Which regime a player is in is invisible from +-- the numbers this addon showed before, and it is exactly what "+1% enchant or the bigger item" +-- turns on. Both variants are computed by replaying the RECORDED hits through the same model the +-- live path uses -- never on a worked example. +local function upgradeScope(label, hist, p, out) + local c = VampifyHistogram.compare(hist, sourcePercents, p, out) + if c.hits <= 0 then + print_(label .. ": no hits recorded yet.") + return c + end + print_(string.format("%s: %d hits, %d healed, %d of it from the floor (%.0f%%)", + label, c.hits, math.floor(c.heal), math.floor(c.floorHeal), c.floorShare * 100)) + -- These two lines are the decision, and they are printed ADJACENT on purpose: both buy the + -- same nominal point of total percentage, and the whole point of the feature is how far apart + -- they can be worth. Anything between them would break the comparison the eye is meant to make. + -- Padded to a common label width so the three figures form a column. The chat font is + -- proportional, so this is approximate rather than exact -- close enough to read down. + local function option(label, delta, pct) + print_(string.format(" %-34s +%d (+%.1f%%)", label, math.floor(delta), pct)) + end + option(string.format("one more %d%% source:", p), c.addDelta, c.addPct) + if c.swapIndex then + option(string.format("swap your weakest (%d%% -> %d%%):", c.swapFrom, c.swapTo), + c.swapDelta, c.swapPct) + end + -- The ceiling, printed after and labelled as one: raising the STRONGEST source is the most a + -- point can ever be worth on these hits, but no shop sells it (see core/histogram.lua's + -- header). It is a bound on the two above, not a third option. + if c.upIndex then + option(string.format("ceiling, a stronger source (%d%% -> %d%%):", c.upFrom, c.upTo), + c.upDelta, c.upPct) + end + return c +end + +local function upgradeReport(p) + p = p or 1 + if nSources <= 0 then + print_("upgrade: no Vampirism sources detected, so there is nothing to compare against.") + return + end + print_(string.format("upgrade preview at +%d%% -- computed on the hits you actually took," + .." not on an example.", p)) + local s = upgradeScope("session", VampifyState.hist, p, cmpSession) + upgradeScope("lifetime", VampifyState.lfHist, p, cmpLifetime) + -- The verdict, from the SESSION scope: it describes what the player is doing now, whereas the + -- lifetime distribution mixes every kind of content they have ever fought in. + -- + -- Decided between the two BUYABLE options only -- one more source against the swap. The + -- ceiling line is not in the running; see core/histogram.lua's G.compare header. + if s and s.hits > 0 then + local by = math.floor(s.winnerBy) + if s.winner == "add" then + print_(string.format(" |cffe0c98a->|r one MORE source wins by %d HP (+%d against +%d):" + .." your hits are small enough that the floor dominates.", + by, math.floor(s.addDelta), math.floor(s.swapDelta))) + elseif s.winner == "swap" then + print_(string.format(" |cffe0c98a->|r the SWAP wins by %d HP (+%d against +%d):" + .." your hits are big enough that the percentage dominates.", + by, math.floor(s.swapDelta), math.floor(s.addDelta))) + else + print_(string.format(" |cffe0c98a->|r on these hits one more source and the swap are" + .." worth exactly the same (+%d each).", math.floor(s.addDelta))) + end + end +end + +local function help() + print_("/vf status -- sources and totals") + print_("/vf reset [session|lifetime|last|both]") + print_(" -- clear totals; default is session only (lifetime is untouched)." + .." 'last' clears only the last-fight breakdown; 'both' means session+lifetime, not 'last'") + print_("/vf pos -- move the display back to its default position") + print_("/vf lock -- toggle frame dragging") + print_("/vf fct / sct -- toggle scrolling combat text (either engine)") + print_("/vf sctmove -- move our own SCT anchor (options window has the same button)") + print_("/vf options -- open the options window") + print_("/vf source add -- add a manual source, if tooltip detection fails") + print_("/vf source clear -- drop all manual sources") + print_("/vf exclude add -- stop a spell from triggering Vampirism, from now on") + print_("/vf exclude remove -- undo a runtime exclusion (built-ins cannot be removed)") + print_("/vf exclude list -- show excluded spells, built-in and runtime") + print_("/vf perhit on|off|status -- per-hit debug export, default ON, setting is saved") + print_("/vf upgrade [pct] -- how much of your healing is the floor, and whether one more" + .." source or swapping your weakest one is worth more (default +1%)") +end + +SLASH_VAMPIFY1 = "/vampify" +SLASH_VAMPIFY2 = "/vf" +SlashCmdList = SlashCmdList or {} +SlashCmdList["VAMPIFY"] = function(msg) + msg = string.lower(msg or "") + -- Lua 5.0 has no pattern-matching shorthand, so captures come from string.find. + local _, _, cmd, rest = string.find(msg, "^(%a*)%s*(.*)$") + cmd = cmd or "" + + if cmd == "status" or cmd == "" then + status() + elseif cmd == "watch" then + local _, _, arg = string.find(rest, "^(%a*)") + arg = arg or "" + if arg == "on" or arg == "off" then + local want = (arg == "on") + if want and VampifyIncoming.inGroup() then + print_("watch: you are in a group -- foreign healing makes the balance" + .." meaningless, so this stays off. Try again when solo.") + elseif VampifyIncoming.setWatchWanted(want) == want then + lastHp, lastAt, lastExpected = nil, nil, nil -- no window straddles the switch + print_("watch: HP reconciliation " .. arg .. ".") + else + print_("watch: could not switch -- SuperWoW is needed for the player GUID.") + end + else + local b = balance + print_(string.format("watch: %s, %d clean windows (%d without incoming damage)", + VampifyIncoming.isWatchOn() and "on" or "off", b.n, b.zeroN)) + print_(string.format(" discarded: %d at full health, %d in a group, %d too long, " + .. "%d expecting nothing", b.rejected.cap, b.rejected.group, + b.rejected.idle, b.rejected.noExpect)) + -- Deliberately no ratio here. It is an alarm input, not a measurement, and printing it + -- would invite exactly the reading the whole design refuses (spec 2.1). + runWatch() + end + elseif cmd == "reset" then + local _, _, scopeArg = string.find(rest, "^(%a*)") + scopeArg = scopeArg or "" + if scopeArg == "" or scopeArg == "session" then + VampifyResetSession("session") + elseif scopeArg == "lifetime" then + VampifyResetSession("lifetime") + elseif scopeArg == "last" then + VampifyResetSession("last") + elseif scopeArg == "both" then + VampifyResetSession("both") + else + print_("/vf reset [session|lifetime|last|both] -- default is session, lifetime is untouched") + end + elseif cmd == "pos" then + VampifyDisplay.resetPos() + print_("display moved back to its default position.") + elseif cmd == "lock" then + local cfg = VampifyConfig.get() + if not cfg then print_("config not loaded yet.") return end + cfg.locked = not cfg.locked + print_(cfg.locked and "frame locked." or "frame unlocked.") + elseif cmd == "fct" or cmd == "sct" then + local cfg = VampifyConfig.get() + if not cfg or not cfg.sct then print_("config not loaded yet.") return end + cfg.sct.enabled = not cfg.sct.enabled + if cfg.sct.enabled and cfg.sct.mode == "blizzard" and SHOW_COMBAT_TEXT ~= "1" then + print_("scrolling combat text ON -- but Blizzard's own combat text is disabled in its" + .." options, so nothing will show in blizzard mode.") + else + print_(cfg.sct.enabled and "scrolling combat text ON." or "scrolling combat text OFF.") + end + elseif cmd == "sctmove" then + if not (VampifySCT and VampifySCT.setMoveMode and VampifySCT.isMoveMode) then + print_("SCT not loaded yet.") + return + end + local on = not VampifySCT.isMoveMode() + -- setMoveMode refuses to turn ON while anchored to the bar (mode == "bar") -- dragging is + -- meaningless there -- and hands back a reason instead. Turning OFF always succeeds. + local ok, why = VampifySCT.setMoveMode(on) + if not ok then + print_(why or "SCT move mode is unavailable right now.") + else + print_(on and "SCT move mode ON -- drag the anchor, /vf sctmove again when done." + or "SCT move mode OFF.") + end + elseif cmd == "options" then + if VampifyOptions and VampifyOptions.toggle then + VampifyOptions.toggle() + else + print_("options window not loaded.") + end + elseif cmd == "source" then + local cfg = VampifyConfig.getChar() + if not cfg then print_("config not loaded yet.") return end + if not cfg.manual then cfg.manual = {} end + local _, _, sub, val = string.find(rest, "^(%a+)%s*(%d*)") + if sub == "add" then + local pct = tonumber(val) + -- Reject 0: it would raise nSources without raising sumPercent, so the per-hit floor + -- (which IS the source count) would silently add 1 HP to every hit forever. + if not pct or pct <= 0 then + print_("a manual source needs a percentage above zero, e.g. /vf source add 3") + else + table.insert(cfg.manual, pct) + print_("manual source added: "..pct.."%") + recompute() + end + elseif sub == "clear" then + cfg.manual = {} + print_("manual sources cleared.") + recompute() + else + help() + end + elseif cmd == "exclude" then + local cfgc = VampifyConfig.getChar() + if not cfgc then print_("config not loaded yet.") return end + local _, _, sub, val = string.find(rest, "^(%a*)%s*(%d*)") + if sub == "" then sub = "list" end + + if sub == "add" then + local id = tonumber(val) + if not id or id <= 0 then + print_("/vf exclude add -- needs a numeric spell id, e.g. /vf exclude add 16624") + else + local ok, why = VampifyConst.addNoTrigger(id, cfgc) + if ok then + -- Forward-only: this changes what counts as triggering from now on, but does + -- not reach back into totals already recorded for earlier hits. The current + -- session total already reflects whatever this spell contributed before it was + -- spotted and excluded; /vf reset remains the explicit way to discard that. + -- Retroactively subtracting it would need a per-spell breakdown of the + -- session's EFFECTIVE (deficit-clipped) healing, which is only tracked as a + -- single running total (VampifyState.effHeal in this file), not per spell -- + -- correcting the gross total while leaving effective/overheal inconsistent + -- would be worse than leaving both alone. + print_("excluded "..VampifyConst.spellName(id).." (#"..id..") from Vampirism" + .." triggering, from now on. /vf reset clears the current session if it" + .." already includes this spell.") + elseif why == "builtin" then + print_(VampifyConst.spellName(id).." (#"..id..") is already excluded (built in).") + elseif why == "already" then + print_(VampifyConst.spellName(id).." (#"..id..") is already excluded.") + end + end + elseif sub == "remove" then + local id = tonumber(val) + if not id then + print_("/vf exclude remove ") + elseif VampifyConst.NO_TRIGGER[id] then + print_(VampifyConst.spellName(id).." (#"..id..") is a built-in exclusion and cannot be removed.") + elseif VampifyConst.removeNoTrigger(id, cfgc) then + print_("removed "..VampifyConst.spellName(id).." (#"..id..") from the exclusion list," + .." from now on.") + else + print_(VampifyConst.spellName(id).." (#"..id..") was not excluded.") + end + elseif sub == "list" then + local list = VampifyConst.listNoTrigger(cfgc, excludeBuf) + local n = table.getn(list) + if n == 0 then + print_("no excluded spells.") + else + print_(n.." excluded spell(s):") + for i = 1, n do + local e = list[i] + print_(" #"..e.id.." "..VampifyConst.spellName(e.id)..(e.builtin and " (built in)" or " (runtime)")) + end + end + else + print_("/vf exclude add | remove | list") + end + elseif cmd == "upgrade" then + -- Optional size in whole percent. Anything unparseable or non-positive falls back to 1 + -- rather than erroring: 1% is the smallest real source that exists (the bracer and boot + -- enchants), which makes it both the default and the only sensible fallback. + local _, _, val = string.find(rest, "^(%d*)") + local p = tonumber(val) + if not p or p <= 0 then p = 1 end + upgradeReport(p) + elseif cmd == "perhit" then + local _, _, arg = string.find(rest, "^(%a*)") + arg = arg or "" + if arg == "on" then + local cfg = VampifyConfig.get() + if cfg then cfg.perhitEnabled = true end -- survives /reload and the next login + if VampifyPerHit.isEnabled() then + print_("perhit: already on.") + else + applyPerHitWant(true) + print_("perhit: ON -- session "..tostring(VampifyPerHit.status().sid) + ..". Chunks flush to imports\\vampify_perhit_*.txt.") + end + elseif arg == "off" then + local cfg = VampifyConfig.get() + if cfg then cfg.perhitEnabled = false end -- survives /reload and the next login + if not VampifyPerHit.isEnabled() then + print_("perhit: already off.") + else + applyPerHitWant(false, UnitHealth and UnitHealth("player")) + print_("perhit: OFF -- final chunk flushed.") + end + else + local st = VampifyPerHit.status() + print_(string.format("perhit: %s -- %d hit(s) recorded, %d line(s) buffered, " + .."%d chunk(s) flushed this session", + st.enabled and "on" or "off", st.totalHits, st.buffered, st.chunk)) + end + else + help() + end +end diff --git a/core/config.lua b/core/config.lua new file mode 100644 index 0000000..bb833b6 --- /dev/null +++ b/core/config.lua @@ -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 + -- 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 diff --git a/core/const.lua b/core/const.lua new file mode 100644 index 0000000..8bdd0b0 --- /dev/null +++ b/core/const.lua @@ -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 \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 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:"). 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 diff --git a/core/histogram.lua b/core/histogram.lua new file mode 100644 index 0000000..e096080 --- /dev/null +++ b/core/histogram.lua @@ -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 diff --git a/core/model.lua b/core/model.lua new file mode 100644 index 0000000..8c1f087 --- /dev/null +++ b/core/model.lua @@ -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 diff --git a/core/perhit.lua b/core/perhit.lua new file mode 100644 index 0000000..8beb4be --- /dev/null +++ b/core/perhit.lua @@ -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 +-- \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 diff --git a/core/watch.lua b/core/watch.lua new file mode 100644 index 0000000..56248db --- /dev/null +++ b/core/watch.lua @@ -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 diff --git a/gui/display.lua b/gui/display.lua new file mode 100644 index 0000000..632fd38 --- /dev/null +++ b/gui/display.lua @@ -0,0 +1,4028 @@ +-- Vampify -- the one-line display. +-- +-- Shows total + HPS + the equipped sources' combined Vamp percentage with their count. Per-source +-- detail lives in /vf status, not on screen. +-- +-- Compact-bar rewrite (follow-up change request, 2026-08-22). The line used to open with a "> Vamp " prefix +-- (a bound glyph plus the addon's own name) and end with the SESSION heal/damage ratio +-- (stats.pct, still computed by VampifyAggregate.fight -- see V.format's own comment for why that +-- figure and the one shown here are not the same thing). Both are gone now, with nothing put back +-- in their place: the prefix +-- because the bar already IS Vampify (nothing else lives at that screen position), and the ratio +-- because it answers "how much came back this session" rather than "what is the gear configured to +-- return", which is what a player glancing at the bar while re-gearing actually wants. The bound +-- glyph itself (V.boundFlag) is untouched and still tested below -- only its wiring into this line +-- was removed; nothing currently calls it. + +VampifyDisplay = {} +local V = VampifyDisplay + +-- Which set the mouseover breakdown currently shows: "session" (since login, the default), +-- "lifetime" (never auto-resets), or "last" (the current-or-most-recently-completed fight, third +-- tab added 2026-08-25 -- "ebenso dritten detailtab einfuegen: last"). A field on V rather than a +-- file-local, so it is reachable from both V.build() and V.showTooltip() regardless of which is +-- defined first in this file -- see the comment on frame:SetScript("OnMouseUp", ...) below for why +-- that ordering actually bites in Lua 5.0. Not persisted: it is a display preference, not data, and +-- resets to "session" on reload. +-- +-- Tab order [SESSION | LIFETIME | LAST], LAST appended rather than inserted first: SESSION stays +-- the login default (unchanged muscle memory for anyone already used to the two-tab toggle), and a +-- single right-click still lands on LIFETIME exactly as it always has -- LAST costs one extra click +-- to reach instead of reordering two tabs people already navigate today. Putting LAST first (it is +-- arguably the most frequently needed tab) is a reasonable alternative too; this is the more +-- conservative of the two, not the only defensible one. +V.tipScope = "session" + +-- Normalizes any V.tipScope value to one of the three real scopes, defaulting an unrecognised or +-- nil value to "session" (the same "session is the safe default" convention +-- core/commands.lua's VampifyResetSession already uses for its own scope argument) -- a single +-- choke point so every reader below agrees on what an invalid scope means instead of each +-- reimplementing its own fallback. +function V.normalizeScope(scope) + if scope == "lifetime" or scope == "last" then return scope end + return "session" +end + +-- True unless `scope` is "last" and the core-side last-fight read API has not shipped yet +-- (mandatory fallback for when core/ has not yet added last-fight support). Gated on +-- VampifyAggregate.resetScope's +-- PRESENCE specifically, not on VampifyAggregate.spellBreakdown's (which already existed, boolean- +-- only, before "last" existed) -- resetScope is a brand new symbol that ships in the exact same +-- core/aggregate.lua change as "last" scope support in every read function, so its presence is a +-- reliable, cheap capability probe: trusting spellBreakdown/splitTotals/spellSplit/ +-- spellSourceBreakdown's OWN "last" branch without this guard would, against an un-upgraded core, +-- silently read a DIFFERENT scope's real data (a truthy third argument reads as lifetime under the +-- old boolean-only spellBreakdown; an unrecognised scope string falls through to session in the +-- other three) and label it "Last" -- wrong data shown with confidence is worse than an honest +-- empty panel, so this is checked before trusting any of the four "last" calls below. +function V.lastScopeReady() + return VampifyAggregate ~= nil and type(VampifyAggregate.resetScope) == "function" +end + +-- Rotates through the tab order [SESSION | LIFETIME | LAST] -- the single right-click handler on +-- the compact bar (V.build's frame:OnMouseUp) and every scope-toggle button's own OnClick funnel +-- through this one function, so the two input paths can never disagree on what "next scope" means. +-- Pure (no WoW call, no VampifyAggregate read) and offline-testable on its own. +function V.nextScope(scope) + if scope == "session" then return "lifetime" end + if scope == "lifetime" then return "last" end + return "session" +end + +-- Each ability's share of the TOTAL Vampirism healing, in percent, one decimal. +-- +-- Moved to core/model.lua as VampifyModel.shareOfTotal (2026-08-24) -- core/aggregate.lua needed +-- the same largest-remainder rounding and calling this gui/ function from core/ was backwards +-- (core/ must not depend on gui/). Kept here as a thin alias: everything in this file already +-- calls it as V.shareOfTotal, and the offline tests reference that name directly. +V.shareOfTotal = VampifyModel.shareOfTotal + +-- Copies V.shareOfTotal's per-row percentages into a CALLER-OWNED buffer, immediately, before +-- anything else runs. Exists because of an in-game bug (2026-08-24, post-mockup pass): V.shareOfTotal +-- (== VampifyModel.shareOfTotal, core/model.lua:255-307) returns a POOLED, MODULE-LEVEL buffer -- +-- not a copy -- and VampifyAggregate.spellSplit/.splitTotals/.spellSourceBreakdown ALSO call into +-- that exact same pooled function internally, for their own unrelated ST/AoE-of-ONE-row rounding +-- (core/aggregate.lua's local roundSplit). A caller that reads V.shareOfTotal's result across a +-- loop that ALSO calls any of those three functions (as the row loop below now does, for each +-- row's two-color bar) gets the buffer overwritten -- and truncated to a couple of elements -- +-- out from under it after the very first iteration: every row after the first read a stale or +-- absent value. Reproduced and pinned by an existing regression test (that test's own header +-- comment has the full trace). +-- Every call site that reads a share array across more than one statement now snapshots it here +-- FIRST -- see V.showTooltip and V.buildDetailLines below. +function V.snapshotShares(shares, n, out) + out = out or {} + local i + for i = 1, n do out[i] = shares[i] or 0 end + for i = n + 1, table.getn(out) do out[i] = nil end + return out +end + + +function V.boundFlag(channels, aoeAvailable) + local lower = false + if channels then + for _, state in pairs(channels) do + if state == "undecided" then lower = true end + end + end + local upper = not aoeAvailable + if lower and upper then return "uncertain" end + if lower then return "lower" end + if upper then return "upper" end + return nil +end + +-- Sum + round pass for V.formatSources below -- the compact bar's own gear-percent segment reuses +-- V.formatSources directly (2026-08-24 in-game feedback pass dropped a separate, "Gear"-suffixed +-- wording that used to live here: a redundant unit suffix, since +-- V.formatSources's bare "9% (4)" already says the same thing shorter). Whole number when it lands +-- exactly on one, otherwise one decimal place (rounded to one decimal first, which also kills the float noise a +-- repeated sum can pick up). +local function sumSourcesPct(sources) + sources = sources or {} + local n = table.getn(sources) + local sum = 0 + for i = 1, n do sum = sum + (sources[i] or 0) end + local r = math.floor(sum * 10 + 0.5) / 10 + local pctStr + if r == math.floor(r) then + pctStr = string.format("%d%%", r) + else + pctStr = string.format("%.1f%%", r) + end + return pctStr, n +end + +-- sources: the per-source percent list (e.g. {3, 2, 2, 2}) that feeds core/commands.lua's own +-- sourcePercents -- VampifyDetect.getSources() plus manual overrides, reached here via +-- VampifyState.sourcePercents (see commands.lua's comment on that assignment). Summed and counted +-- HERE rather than read back from commands.lua's sumPercent/nSources fraction: those are computed +-- as sum/100 in capture/detect.lua's D.summarise and would have to be multiplied by 100 again to +-- get back to percent units, a round-trip this function has no reason to repeat when the source +-- list it needs anyway is right there. +function V.formatSources(sources) + local pctStr, n = sumSourcesPct(sources) + return pctStr.." ("..n..")" +end + +-- stats.overheal is optional: nil or 0 leaves the line exactly as it was before overheal existed +-- (callers that never set it, and the existing tests, must see no difference). Vampirism emits no +-- heal event, so overheal here is CALCULATED from the health deficit at hit time (see +-- VampifyModel.effective / VampifyState.effHeal), not measured -- it is exactly as trustworthy as +-- that deficit read, no more. +-- +-- sources is passed straight to V.formatSources -- see that function's comment for where it comes +-- from and why it replaces stats.pct (the session heal/damage ratio) on this line. +function V.format(stats, sources) + stats = stats or {} + local oh = "" + if stats.overheal and stats.overheal > 0 then + oh = string.format(" (%d OH)", math.floor(stats.overheal)) + end + return string.format("%d%s | %.1f HPS | %s", + math.floor(stats.heal or 0), oh, stats.hps or 0, V.formatSources(sources)) +end + +-- ---- compact-bar segment formatters (mockup redesign, 2026-08-24) ----------------------------- +-- +-- The bar used to be ONE FontString rendering V.format's whole line. The mockup segments it -- +-- heal(+OH), an ST/AoE badge group, " HPS" (colored), then the gear percent -- each of which +-- needs its OWN FontString so it can be individually colored, hidden, or reflowed when a segment +-- is absent. These are the pure text pieces the WoW-wiring shell below (V.build/V.update) places +-- into that many FontStrings; V.format itself is UNCHANGED (still the old single-line text) -- +-- nothing in this file calls it any more, but an existing regression test still pins +-- its exact behaviour, so it stays rather than being repurposed out from under that test. + +-- "608" -- no "Heal" word and no icon (2026-08-24, in-game feedback pass: the leading +-- blood-drop icon was purely decorative, and the word was redundant with it -- the number is the +-- first, most prominent thing on the bar in a Vampirism-only addon; nothing else it could be +-- needs saying). +-- +-- The "(N OH)" suffix this used to carry is GONE (2026-08-24 pixel measurement pass: the +-- bar's plain text alone measured 457px against a 340px frame, and this suffix was pure length +-- with no length budget to spend -- the same figure already lives in the row-detail panel and the +-- breakdown panel's own Total line, so nothing is actually lost, only de-duplicated off the +-- bar). stats.overheal itself is intentionally left untouched by this change -- V.update still +-- computes it (cfg.showOverheal also drives a SEPARATE combat-text feature outside this file, see +-- gui/options.lua's cbOverheal), this function just no longer reads it. +function V.formatHealSegment(stats) + stats = stats or {} + return V.formatAmount(stats.heal or 0) +end + +-- "27.2 HPS" -- its own segment (rather than folded into V.format's one string) so the WoW-wiring +-- shell can color it independently (green, per the mockup) without re-coloring the rest of the bar. +-- Kept its unit suffix (unlike the heal/gear segments) -- "HPS" is not redundant here, it is the +-- one thing telling this number apart from every OTHER bare number on the bar. +function V.formatHpsSegment(stats) + stats = stats or {} + return string.format("%.1f HPS", stats.hps or 0) +end + +-- ST/AoE badge-group text for the compact bar, from VampifyAggregate.splitTotals's `out` shape +-- (out.stHeal, .aoeHeal, .total, .stPct, .aoePct -- the VampifyAggregate.splitTotals interface this +-- file programs against). `out` is the ALREADY-POPULATED table (the +-- WoW-wiring shell calls splitTotals itself and passes the result here), not the function -- same +-- injection convention as V.buildDetailLines' ctx.* callbacks, kept pure and testable with a plain +-- stub table. Returns nil, nil when there is nothing to show: out itself absent (splitTotals not +-- wired up yet -- the fallback contract, "badge group hidden, rest of the bar stays") or +-- out.total <= 0 (nothing measured yet -- must not fabricate a 0 ST / 0 AoE badge pair). +-- +-- History: first shortened from "29.7k ST | 2.9k AoE (9%)" (measured 119px for the AoE half +-- ALONE) down to bare k-notation amounts as the pill's adjoining value label; NOW UNUSED by the +-- wiring entirely (2026-08-24, fourth correction, in-game report: the adjoining numbers were +-- redundant with the pill and the main reason the bar overshot its own compactness target). Kept as +-- a pure, tested formatter (same convention as V.format above -- a prior generation's bar text, +-- superseded but not deleted) in case some future compact surface wants bare ST/AoE k-values +-- again without a third competing formatter being written from scratch. +function V.formatBadgeGroup(out) + if not out or not out.total or out.total <= 0 then return nil, nil end + return V.formatAmount(out.stHeal or 0), V.formatAmount(out.aoeHeal or 0) +end + +-- ---- two-color proportional bar geometry ------------------------------------------------------- +-- +-- Shared pixel-width math for every two-color bar this redesign adds: the breakdown panel's own +-- ST/AoE split row, and each ability row's two-color fill (see V.buildDetailLines' neighbourhood +-- below for -- no, see the WoW-wiring shell's setRow -- where both call this). +-- +-- rightW is the COMPLEMENT of leftW (maxW - leftW), never independently rounded off leftPct's own +-- twin percentage -- that is what keeps a 100/0 or 0/100 split exact (no stray 1px sliver of the +-- empty color) and guarantees the two segments always tile the full maxW with no gap or overlap +-- regardless of rounding noise in leftPct/rightPct themselves not summing to exactly 100. +-- maxW <= 0, or both percentages <= 0 (nothing to show -- e.g. a spell with zero recorded heal), +-- returns 0, 0 -- callers are expected to have already decided whether to draw/show the bar at all +-- (see the panel's total==0 guard in V.showTooltip below), but this stays safe even if one calls it +-- anyway rather than divide-by-zero or hand back a negative width. +function V.splitBarWidths(maxW, leftPct, rightPct) + maxW = maxW or 0 + leftPct = leftPct or 0 + rightPct = rightPct or 0 + if maxW <= 0 or (leftPct <= 0 and rightPct <= 0) then return 0, 0 end + local leftW = math.floor(maxW * (leftPct / 100) + 0.5) + if leftW < 0 then leftW = 0 end + if leftW > maxW then leftW = maxW end + return leftW, maxW - leftW +end + +-- V.pillSegments (the cap-width-clamp math for the compact-bar pill's rounded ends) is REMOVED +-- (in-game correction: drop the rounded ends entirely -- the pill lost its end caps +-- entirely, see gui/display.lua's layoutPill/buildPill for the current rectangular shape). Its +-- whole reason to exist was deciding how a share narrower than one cap gets clamped up, and how a +-- share of exactly 0 recolors its own now-orphaned cap -- neither question exists any more with no +-- caps to clamp or recolor. The pill's geometry is now exactly V.splitBarWidths above (unchanged), +-- already used elsewhere in this file for the same "two segments summing to a bar width" shape and +-- already covered by the existing splitBarWidths regression tests -- nothing else needed +-- porting. The old test file that tested only this function is deleted along +-- with it rather than left testing a function that no longer exists. + +-- x-offset (from whatever origin `rightEdge`/`minX` are themselves measured from) to draw a +-- RIGHT-ANCHORED label of pixel width `textW`, so its right edge lands `pad` px inside `rightEdge` +-- -- regardless of how narrow the color segment it visually sits over is. Fixes a real bug (in-game +-- report, 2026-08-24): the split row's AoE label used to anchor to its OWN segment's left edge, so at a +-- narrow AoE share the label (wider than its own segment) ran out past the panel's right border. +-- Anchoring the label to a FIXED boundary (the bar's own right edge, not the segment's) instead +-- means it can never run off that side no matter how small the segment gets. +-- Clamped at the OTHER end too (`minX`) -- a label wider than the whole available span is pushed +-- back to minX rather than given a negative offset, which would run it off the panel's LEFT side +-- instead: "never overflow" means both directions, not just the one that was reported. +function V.rightAnchoredLabelX(rightEdge, textW, pad, minX) + pad = pad or 0 + minX = minX or 0 + local x = (rightEdge or 0) - pad - (textW or 0) + if x < minX then x = minX end + return x +end + +-- Chooses which of the split row's two label variants fits INSIDE a color segment of width segW, +-- so the label never lies about which segment it belongs to by spilling onto its neighbour +-- (2026-08-24 pixel measurement pass, Finding 5: the label must stay inside its own color segment +-- -- measured, the AoE label used to right-anchor to the BAR's fixed right edge +-- (V.rightAnchoredLabelX above) regardless of its OWN segment's width, so a narrow AoE share +-- (9%) put 42 of the label's pixels on top of the blue ST fill it does not belong to). +-- V.rightAnchoredLabelX is NOT wrong and stays in use elsewhere (the panel's row-detail value +-- labels, which sit on a FIXED-width row, not a variable-width color segment) -- this is a +-- different problem: here the available space itself shrinks with the segment. +-- +-- Two-tier degradation, both measured against the ACTUAL segment (not the whole bar): +-- 1. full "Single-Target: 29.7k (91%)" -- used if it fits with `pad` clearance on both sides +-- 2. short "29.7k" -- the bare amount, if the full label does not fit +-- 3. nil -- neither fits (segment too thin even for the bare +-- amount) -- caller hides the label rather than draw truncated/overlapping text; the +-- segment's own COLOR still shows something is there, which is honest -- a hidden number +-- beats a lying one. +-- textWidthFn(text) -- injected (FontString:GetStringWidth in production) so this stays pure. +function V.pickSplitLabel(fullText, shortText, segW, pad, textWidthFn) + pad = pad or 0 + segW = segW or 0 + local fullW = textWidthFn(fullText) + if segW >= fullW + 2 * pad then return fullText, fullW end + local shortW = textWidthFn(shortText) + if segW >= shortW + 2 * pad then return shortText, shortW end + return nil, nil +end + +-- "575" (< 1000, unabbreviated) / "14.0k" (1000 <= |n| < 1,000,000, one decimal) / "8.8M" +-- (|n| >= 1,000,000, one decimal). +-- +-- THE addon-wide large-number formatter (2026-08-24 in-game feedback, superseding an +-- earlier thousands-separator ask: switch to a "k" suffix once a value reaches the thousands -- and, +-- for the million range, use "8.8M" rather than the ugly "8790.0k" that "k" alone would produce +-- for 8790016). Used +-- EVERYWHERE a large number is shown -- compact bar, split row, ability rows, the panel's Total +-- line, the row-detail panel -- rather than a second, competing formatter per screen: this file +-- used to have a narrower version of exactly this ("V.formatSplitAmount", the split row's own two +-- labels only) which is now just this function under its general name; every other call site +-- switched to it rather than growing its own. +-- +-- One decimal at both the k and M scale -- enough to tell close values apart (14.0k vs 14.3k) +-- without turning into a second exact number; the whole point is a number a reader can take in at +-- a glance. Ratio is deliberately NOT this formatter's job any more (follow-up in-game feedback, +-- 2026-08-24: the ratio is already readable straight off the graphical bar) +-- -- the bar length already carries that, so this only ever needs to carry the MAGNITUDE. +-- 1,000,000 (not "whenever the k-form would print 4+ integer digits") is the fixed M threshold -- +-- a predictable cutover a reader can learn once, rather than one that moves around depending on +-- the exact value. +-- +-- Handles a negative input (sign preserved, magnitude formatted the same way) even though nothing +-- in this addon's own data can currently go negative -- cheap defensiveness, not a real case. +function V.formatAmount(n) + n = n or 0 + local sign = "" + if n < 0 then + sign = "-" + n = -n + end + if n >= 1000000 then + return sign .. string.format("%.1fM", n / 1000000) + elseif n >= 1000 then + return sign .. string.format("%.1fk", n / 1000) + end + -- Floored, not rounded, below 1000 -- matches this addon's own established convention + -- (V.format's overheal branch, V.formatIdLine, ... all "floored, matching the accumulator, + -- never rounded up") for exact, unabbreviated numbers: a displayed figure must never claim + -- more than what has actually accumulated. Abbreviated k/M values above DO round (a normal + -- printf %.1f) -- losing sub-unit precision is already the deal at that scale, and this + -- matches the pre-existing formatSplitAmount behaviour nothing ever complained about. + return sign .. tostring(math.floor(n)) +end + +-- "Single-Target: 28.4k (61%)" / "AoE Cleave: 18.2k (39%)" -- the split row's own two half-labels. +function V.formatSplitLabel(kind, heal, pct) + return string.format("%s: %s (%d%%)", kind, V.formatAmount(heal), math.floor((pct or 0) + 0.5)) +end + +-- Two spell ids can carry the same name (two ranks of Lightning Strike showed up as separate rows +-- in-game). Merges the per-spell breakdown (VampifyAggregate.spellBreakdown's rows, keyed by spell +-- id) onto the name the player actually distinguishes -- but keeps every merged-away id reachable, +-- as out[j].ids, a list of { spell, damage, heal, overheal, hits }, biggest healer first WITHIN the +-- row. A single-spell row still gets a one-element ids list, so a caller never has to special-case +-- "was this name backed by one spell or several". +-- +-- File scope rather than nested inside the CreateFrame block below (where it used to live, as a +-- bare local): it is pure Lua over its two table arguments plus VampifyConst, exactly like +-- V.shareOfTotal/V.formatSources/V.format above, and belongs with them so it can be exercised +-- offline without a CreateFrame stub. +-- +-- POOLING at both levels, same convention as VampifyAggregate.spellBreakdown and VampifyConst. +-- resetList: the panel repaints on every hover-enter, on the right-click scope toggle, and -- +-- while pinned -- at most once a second via V.update's own gate (V.shouldRepaintPin, see below), +-- so neither the row entries nor their ids sublists are rebuilt from scratch when the ability set +-- is unchanged from the previous call -- only a comparator would need to look past +-- `used`/`table.getn`, and this file's own sort loops already do. +local function mergeIdEntry(ids, idx, r) + local e = ids[idx] + if not e then e = {}; ids[idx] = e end + e.spell = r.spell + e.damage = r.damage + e.heal = r.heal + e.overheal = r.overheal or 0 + e.hits = r.hits or 0 + return e +end + +function V.mergeByName(rows, out) + local n, used = table.getn(rows), 0 + for i = 1, n do + local r = rows[i] + local name = VampifyConst.spellName(r.spell) + local hit = nil + for j = 1, used do + if out[j].name == name then hit = out[j]; break end + end + if hit then + hit.damage = hit.damage + r.damage + hit.heal = hit.heal + r.heal + hit.overheal = hit.overheal + (r.overheal or 0) + hit.hits = hit.hits + (r.hits or 0) + local idsN = table.getn(hit.ids) + mergeIdEntry(hit.ids, idsN + 1, r) + table.setn(hit.ids, idsN + 1) + else + used = used + 1 + local e = out[used] + if not e then e = { ids = {} }; out[used] = e end + e.name = name + e.damage = r.damage + e.heal = r.heal + e.overheal = r.overheal or 0 + e.hits = r.hits or 0 + VampifyConst.resetList(e.ids) + mergeIdEntry(e.ids, 1, r) + table.setn(e.ids, 1) + end + end + for i = used + 1, table.getn(out) do out[i] = nil end + table.setn(out, used) + + -- Re-sort after merging: two merged halves can outrank a row that was above both. + for i = 2, used 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 + + -- Within each row, the ids sublist is sorted biggest healer first too -- independent of the + -- row order above, since a row's own rank says nothing about which of its ids contributed most. + for i = 1, used do + local ids = out[i].ids + local m = table.getn(ids) + for a = 2, m do + local e, b = ids[a], a - 1 + while b >= 1 and ids[b].heal < e.heal do ids[b + 1] = ids[b]; b = b - 1 end + ids[b + 1] = e + end + end + return out +end + +-- Each row's share of the LARGEST single row's heal, in percent (0..100, the biggest row always +-- reads 100) -- NOT share of the total (that is still V.shareOfTotal/VampifyModel.shareOfTotal, +-- untouched, still used for the id-level percentages in the row-detail panel). This is BAR-LENGTH +-- math only (2026-08-24 pixel measurement pass, Finding 1): share-of-total was being used to +-- size the ability-row bars, so even the single largest ability only ever reached ~19% of the +-- panel's width -- 80% of every bar's own space sat empty, and the ability NAME (drawn to the +-- right of the bar in the old column layout, now drawn INSIDE it, see setRow below) overran the +-- bar in 9 of 10 rows because the bar itself was starved of the width the name needed to sit +-- inside. Share-of-max fixes that by construction: the biggest healer gets the FULL row width, and +-- every other row is sized relative to it -- the same normalization Details!/Recount/ShaguDPS use, +-- and the reason "icon+name inside the bar" works there. The underlying PERCENT-OF-TOTAL figure +-- (what actually gets shown, where it is still shown -- e.g. via V.shareOfTotal for id-level +-- shares) is completely unchanged by this function; only how a row's LENGTH is derived changes. +-- +-- out: caller-owned, filled in place (positions 1..n set, anything past n cleared) -- no pooled +-- module-level buffer of its own, unlike VampifyModel.shareOfTotal, precisely BECAUSE that pooled- +-- buffer design already caused one real in-game bug (see V.snapshotShares' own header +-- comment for the full trace) -- this function sidesteps the whole hazard class by never owning +-- state a second caller could read out from under a first one. +function V.shareOfMax(rows, out) + out = out or {} + local n = table.getn(rows) + local maxHeal = 0 + local i + for i = 1, n do + local h = rows[i].heal or 0 + if h > maxHeal then maxHeal = h end + end + for i = 1, n do + if maxHeal > 0 then + out[i] = ((rows[i].heal or 0) / maxHeal) * 100 + else + out[i] = 0 + end + end + for i = n + 1, table.getn(out) do out[i] = nil end + return out +end + +-- How many individual ability rows the breakdown panel draws before collapsing the rest into one +-- "Other (N)" row (Finding 4, 2026-08-24: a real character's ~21 tracked abilities would draw +-- a 581px-tall panel with sub-4px slivers for the smallest rows). ONE named constant, so the panel +-- height/row count is changed in exactly one place, not re-derived at each call site. +V.TOP_ROWS = 8 + +-- Caps `merged` (V.mergeByName's already-sorted, biggest-healer-first output) at V.TOP_ROWS real +-- rows, folding whatever is left into ONE synthetic "Other (N)" row at the end -- summed damage/ +-- heal/overheal/hits, and every folded-away row's OWN ids concatenated onto the Other row's ids +-- list (reusing mergeIdEntry, the same per-id copy mergeByName itself uses). That last part is +-- what makes "hover the Other row and see what is in it" work for free: V.buildDetailLines already +-- walks entry.ids and resolves each id's own name/damage/heal via ctx.nameFn -- an id merged in +-- from a folded-away ability is a real spell id with real per-ability numbers, so the existing +-- detail-panel code lists it correctly with no special-casing, exactly as if it were one more rank +-- of a multi-rank spell (which is the whole reason mergeIdEntry already existed). +-- +-- out[V.TOP_ROWS + 1] (the Other row itself, when one exists) is a POOLED, PERSISTENT table reused +-- across repaints, same convention as mergeByName's own out[used] reuse above -- only its fields +-- are overwritten each call, the table identity itself does not change. rowMeta.isOther (read by +-- setRow in the WoW-wiring shell below) is what tells the Other row apart from a real ability row +-- so it renders in a distinct neutral color rather than a fabricated ST/AoE split of a row that, +-- by construction, mixes many unrelated abilities (visually more subdued, since it mixes ST and +-- AoE). +function V.buildTopRows(merged, n, out) + n = n or V.TOP_ROWS + out = out or {} + local total = table.getn(merged) + local keep = total + if keep > n then keep = n end + local i + for i = 1, keep do + out[i] = merged[i] + end + if total > n then + local other = out[n + 1] + if not other then other = { ids = {}, isOther = true }; out[n + 1] = other end + other.name = "Other (" .. (total - n) .. ")" + other.damage, other.heal, other.overheal, other.hits = 0, 0, 0, 0 + other.isOther = true + VampifyConst.resetList(other.ids) + local idsUsed = 0 + for i = n + 1, total do + local r = merged[i] + other.damage = other.damage + r.damage + other.heal = other.heal + r.heal + other.overheal = other.overheal + (r.overheal or 0) + other.hits = other.hits + (r.hits or 0) + local ids = r.ids + local m = table.getn(ids) + local j + for j = 1, m do + idsUsed = idsUsed + 1 + mergeIdEntry(other.ids, idsUsed, ids[j]) + end + end + table.setn(other.ids, idsUsed) + table.setn(out, n + 1) + else + for i = keep + 1, table.getn(out) do out[i] = nil end + table.setn(out, keep) + end + return out +end + +-- ---- row-detail panel: pure text-building -------------------------------------------------- +-- +-- The row-detail panel (hover a data row in the PINNED breakdown -> a second panel with per-id +-- origin, hit averages, effective rate, overheal, and the other scope's totals) is built from the +-- functions below, every one of them pure Lua returning a string (or nil, meaning "the frame layer +-- omits this line"). None of them touch CreateFrame, VampifyConst, or any WoW API -- the data they +-- need (item-origin lookup, spellbook name set, spell-name lookup) is INJECTED by the caller, same +-- reasoning as VampifyConst.spellName being reached for only inside mergeByName above and not +-- baked into these. That keeps them exercisable offline exactly like V.shareOfTotal/V.format, and +-- keeps the WoW-API shell (further down, inside the CreateFrame guard) a thin adapter that only +-- supplies the real functions/tables and calls these to get text. + +-- id -> { kind = "item"|"ability"|"unknown", label = }. +-- +-- itemOriginFn(id): the item-proc lookup (VampifyConst.itemProcOrigin in production) -- returns an +-- item name or nil. Tried FIRST: a proc id can never itself be a spellbook entry, but if some +-- future id were resolvable both ways, the item is the more specific fact and +-- wins. +-- spellbookSet: a set (name -> true) of the player's current spellbook, built by the WoW-API shell +-- from GetSpellName(i, BOOKTYPE_SPELL); membership is checked by NAME, not id, because spellbook +-- slot indices are not spell ids and the shell has no cheaper way to ask "is this ability mine". +-- nameFn(id): VampifyConst.spellName in production -- may return nil for an unresolved id, which +-- must read as "no spellbook match", not error. +function V.resolveOrigin(id, itemOriginFn, spellbookSet, nameFn) + local itemName = itemOriginFn and itemOriginFn(id) + if itemName then + return { kind = "item", label = "Item: " .. itemName } + end + local name = nameFn and nameFn(id) + if name and spellbookSet and spellbookSet[name] then + return { kind = "ability", label = "Class ability" } + end + return { kind = "unknown", label = "origin unknown (#" .. tostring(id) .. ")" } +end + +-- One ability-id's own line: " (#) dmg, vamp, %". damage/heal +-- go through V.formatAmount (k/M-notation once they run large, 2026-08-24 in-game feedback -- +-- see that function's own header comment); share is already a percent (largest-remainder rounded +-- by V.shareOfTotal, one decimal here to match the main panel's own percent column). +function V.formatIdLine(name, id, damage, heal, sharePct) + return string.format("%s (#%d) %s dmg, %s vamp, %.1f%%", + name, id, V.formatAmount(damage or 0), V.formatAmount(heal or 0), sharePct or 0) +end + +-- "hits: (avg dmg -> vamp per hit)", or an explicit not-recorded line +-- for hits == 0 -- legacy rows (persisted before the hit counter existed) carry hits == 0, not +-- nil (see VampifyAggregate.spellBreakdown's own comment), so this must not read hits == 0 as +-- "zero hits landed" and print a bogus "avg 0 dmg -> 0 vamp". +function V.formatHitsLine(hits, damage, heal) + hits = hits or 0 + if hits <= 0 then return "hits: not recorded yet" end + return string.format("hits: %d (avg %s dmg -> %s vamp per hit)", + hits, V.formatAmount((damage or 0) / hits), V.formatAmount((heal or 0) / hits)) +end + +-- Classifies a row's effective return rate against the equipped gear's source total: "floor" when +-- the row returns MORE than gear would predict (the per-hit floor is binding -- small hits still +-- return at least one HP per source), "aoe" when it returns LESS, "neutral" within +/-0.2 +-- percentage points of gear (the tolerance band matches the panel's own note text on the main +-- breakdown, gui/display.lua's V.showTooltip). +-- +-- NAMING NOTE (bug fix, in-game report 2026-08-25, Earth Shock #10414 -- a pure single-target +-- spell whose detail panel showed "(AoE damping)" purely because its measured rate sat below gear, +-- with no AoE hit ever recorded for it): "aoe" here is ONLY a numeric direction (below gear beyond +-- tolerance), never a causal claim by itself. AoE damping is one of SEVERAL possible reasons a rate +-- can sit below its gear nominal -- a lower spell rank (smaller hits let the per-hit floor bite +-- harder, or simply a different true rate), a per-source floor rounding a small hit up, a gear +-- change mid-session against a now-stale nominal, or partial resist/absorb bookkeeping -- and this +-- function has no way to tell which. V.formatRateLine below is the one place that turns "aoe" into +-- WORDS, and it now only says "AoE damping" when the caller hands it POSITIVE evidence (an actual +-- classified AoE hit) that AoE is the real cause -- see its own comment. +function V.classifyRate(effectivePct, gearPct) + local diff = effectivePct - gearPct + if diff > 0.2 then return "floor" end + if diff < -0.2 then return "aoe" end + return "neutral" +end + +-- Effective return rate, in percent, from heal/damage -- shared by V.formatRateLine and +-- V.formatFloorBadge below so both agree on the same number without either recomputing it +-- differently. 0 for zero/negative damage rather than dividing by zero. +function V.effectiveRatePct(heal, damage) + if not damage or damage <= 0 then return 0 end + return (heal or 0) / damage * 100 +end + +-- "Return: % vs % Nominal" plus an explanatory parenthetical when applicable, or nil +-- when there is no gear percent to compare against (an empty/zero VampifyState.sourcePercents +-- omits the line entirely rather than printing "vs 0% Nominal", which would misread as "everything +-- is AoE-damped"). Badge-worded (2026-08-24 badge pass) -- the floor-binding case used +-- to carry its own parenthetical here too; it is now the separate standalone V.formatFloorBadge +-- below, so a floor-binding row does not say the same thing twice across two lines. +-- +-- hasAoeHits/idCount (bug fix, in-game report 2026-08-25 -- see V.classifyRate's own comment +-- for the Earth Shock symptom this answers): the parenthetical is now chosen by CHECKING PROVABLE +-- causes, in order, never by guessing from the rate alone -- +-- 1. hasAoeHits true (this row has at least one hit VampifyAggregate.spellSplit actually +-- classified as off-target, in the CURRENT scope): "(AoE damping)" -- the one case with direct +-- evidence. +-- 2. hasAoeHits false but idCount > 1 (the row merges more than one spell id -- V.mergeByName +-- folds every rank of the same-named spell into one row): "(mixed ranks)" -- downranking is a +-- real, PROVABLE-FROM-THE-DATA alternative cause (a lower rank's smaller hits +-- let the per-hit floor bite harder, dragging a blended average down even with zero AoE) -- +-- more than one id under one name IS direct evidence multiple ranks were actually cast, not a +-- guess. +-- 3. Neither: "(unexplained)" -- chosen over silently dropping the parenthetical (which would +-- read as "this number is unremarkable") and over asserting either cause without evidence -- +-- an honest "we do not know why" beats a confident wrong answer. +function V.formatRateLine(heal, damage, gearPct, hasAoeHits, idCount) + if not gearPct or gearPct <= 0 then return nil end + local eff = V.effectiveRatePct(heal, damage) + local cls = V.classifyRate(eff, gearPct) + local suffix = "" + if cls == "aoe" then + if hasAoeHits then + suffix = " (AoE damping)" + elseif idCount and idCount > 1 then + suffix = " (mixed ranks)" + else + suffix = " (unexplained)" + end + end + return string.format("Return: %.1f%% vs %.1f%% Nominal%s", eff, gearPct, suffix) +end + +-- One merged-id's OWN effective return rate ("-> 12.3% return this rank"), shown ONLY for a row +-- that merges more than one spell id (V.buildDetailLines gates the call on idCount > 1) -- a +-- single-id row already gets this exact number from the row-level "Return: X% vs Y% Nominal" line, +-- so repeating it per id there would say the same thing twice. For a multi-rank row the row-level +-- rate is a BLENDED average across differently-sized hits (see V.formatRateLine's "(mixed ranks)" +-- comment); this exposes each rank's own number so a player can see directly whether one specific +-- rank is the one dragging the blend down, rather than only being told the blend exists. nil for +-- zero/negative damage (no rate to show), same guard as V.effectiveRatePct. +function V.formatIdRateLine(heal, damage) + if not damage or damage <= 0 then return nil end + return string.format(" -> %.1f%% return this rank", (heal or 0) / damage * 100) +end + +-- "Multiple ranks combined (N)" note, shown once near the top of a merged row's detail (right after +-- the split-status note) when idCount > 1 -- nil for a single-id row (nothing to disclose). Pure +-- disclosure, not a diagnosis: it states the provable fact (more than one spell id landed under +-- this name) without claiming that fact explains any particular number -- V.formatRateLine's own +-- "(mixed ranks)" parenthetical is where that connection, when applicable, actually gets made. +function V.formatMultiRankNote(idCount) + if not idCount or idCount <= 1 then return nil end + return "Multiple ranks combined (" .. idCount .. ") -- the return rate below is a blended" + .. " average across differently-sized hits; see each rank's own line for its individual rate." +end + +-- Greedy word wrap into pieces that each measure at most maxWidth. +-- +-- Exists because gui/display.lua's row-detail panel lays its lines out on a fixed cursor -- one +-- DETAIL_LINE_H slot per line ENTRY -- while a FontString handed a long string wraps on its own and +-- silently grows past that slot, drawing the next entry on top of its own overflow (in-game report, +-- 2026-08-25: overlapping text, the "Multiple ranks combined (13)" note over the two rows +-- below it). Splitting the entry BEFORE it reaches the cursor makes the renderer's "one entry = one +-- slot" assumption true instead of merely assumed; it is not a workaround for a missing measurement +-- API, it is the layout contract. +-- +-- Deliberately NOT built on the client measuring the wrapped result for us: 1.12 has no +-- GetStringHeight (that is a 2.3.0 addition), and FontString:GetHeight() does not reliably report a +-- wrapped height -- both confirmed in-game. GetStringWidth, which +-- this function's `measure` is in the client, DOES exist and returns the string's SINGLE-LINE width +-- regardless of any SetWidth -- exactly the primitive a greedy wrap needs. +-- +-- measure(text) -> width. Any measure that cannot answer (returns nil -- GetStringWidth does that +-- before a region's first render pass) collapses the whole thing to one unsplit piece, which is the +-- safe direction: the caller is expected to supply its own fallback metric for that case rather +-- than have this function invent one. Always returns at least one piece, so a caller iterating the +-- result can never silently lose a line (and, with it, that line's bar). +function V.wrapText(text, maxWidth, measure) + if not text or text == "" then return { "" } end + if not maxWidth or maxWidth <= 0 or not measure then return { text } end + local probe = measure(text) + if not probe then return { text } end + if probe <= maxWidth then return { text } end + + -- Splits one word that does not fit on a line of its own into character-sized chunks. Without + -- this the piece would be handed to the client over-long and wrapped THERE, which is the very + -- overflow this function exists to prevent. Grows a chunk one character at a time and closes it + -- one character before it would break the budget; a budget too narrow for even one character + -- would loop forever, so a chunk is never allowed to close empty. + local function splitWord(word, out) + local chunk = "" + local i + for i = 1, string.len(word) do + local ch = string.sub(word, i, i) + local grown = chunk .. ch + if chunk ~= "" and measure(grown) > maxWidth then + table.insert(out, chunk) + chunk = ch + else + chunk = grown + end + end + if chunk ~= "" then table.insert(out, chunk) end + end + + local out = {} + local line = "" + -- string.gfind, not gmatch: gmatch is 5.1+ and is a nil call under the client's Lua 5.0. + local word + for word in string.gfind(text, "%S+") do + if line == "" then + if measure(word) > maxWidth then + splitWord(word, out) + -- splitWord's last chunk stays open as the current line, so the next word can still + -- join it rather than starting a needless new one. + line = out[table.getn(out)] or "" + table.remove(out) + else + line = word + end + elseif measure(line .. " " .. word) <= maxWidth then + line = line .. " " .. word + else + table.insert(out, line) + if measure(word) > maxWidth then + splitWord(word, out) + line = out[table.getn(out)] or "" + table.remove(out) + else + line = word + end + end + end + if line ~= "" then table.insert(out, line) end + if table.getn(out) == 0 then return { text } end + return out +end + +-- Standalone "[Floor Active]" marker for a row whose effective return sits above gear beyond +-- V.classifyRate's tolerance band -- the per-hit floor is binding (small hits still return at least +-- one HP per source). Same gating as V.formatRateLine (no gear percent -> nil) and the same +-- effective-rate math (V.effectiveRatePct); kept as its own function rather than a second return +-- value off formatRateLine so both stay a plain string-or-nil the frame layer can push() as-is. +function V.formatFloorBadge(heal, damage, gearPct) + if not gearPct or gearPct <= 0 then return nil end + local eff = V.effectiveRatePct(heal, damage) + if V.classifyRate(eff, gearPct) ~= "floor" then return nil end + return "[Floor Active]" +end + +-- " HP (% lost)", or nil when there is nothing to report -- mirrors V.format's own +-- overheal branch (gui/display.lua above), which likewise shows nothing for oh <= 0 rather than an +-- "(0 OH)" that would clutter every row. +function V.formatOverhealLine(oh, heal) + oh = oh or 0 + if oh <= 0 then return nil end + local pct = 0 + if heal and heal > 0 then pct = oh / heal * 100 end + return string.format("%s HP (%.1f%% lost)", V.formatAmount(oh), pct) +end + +-- The row's twin figure from the OTHER scope (session<->lifetime): ": dmg, +-- vamp" when a same-named row exists there, else "no data". `row` is nil or a merged row +-- (same shape as V.mergeByName's output) already looked up by the caller in a SEPARATE pooled +-- buffer -- see the WoW-wiring section below for why it must not reuse tipBuf/mergeBuf. +function V.formatOtherScopeLine(scopeName, row) + if not row then return "no " .. scopeName .. " data" end + return string.format("%s: %s dmg, %s vamp", + scopeName, V.formatAmount(row.damage or 0), V.formatAmount(row.heal or 0)) +end + +-- Combines an already-resolved origin (V.resolveOrigin's return shape) with the id it belongs to +-- into ONE self-contained badge line -- "[Item: ] #" / "[Class ability] #" +-- -- so a reader looking at just this line does not have to glance back at the id line above it to +-- know which spell id it is talking about (2026-08-24 badge pass -- the two used to be +-- separate, only loosely connected lines). +-- +-- Returns NIL (no line at all) for kind=="unknown" -- in-game correction: why does this show +-- "origin unknown"? (asked about spell 41825 "Tidal Wave"). Investigated: V.resolveOrigin has only +-- two positive buckets (an item-proc match via C.ORIGINS, or a name match against the player's +-- VISIBLE spellbook via GetSpellName) -- neither is a bug, but together they cannot see a genuine +-- third category: a hidden/passive proc effect that is never granted to the spellbook at all +-- (verified: 41825 is a custom server-side passive damage proc off the Water Shield buff, +-- not a talent and not a castable spell -- structurally invisible to GetSpellName's walk; no known +-- 1.12 API or list closes this gap, and building one is a separate, larger task, not this fix). +-- "[origin unknown] #41825" read as "we don't even know what this spell is" -- false, its NAME is +-- already shown correctly one line above (V.formatIdLine). The honest fix, per the agreed fallback +-- (show the spell name and id without the misleading origin claim): make no origin claim +-- at all when there is none to make, rather than dress up "we could not classify this" as if it +-- were "we do not know what this is". The caller's own push() helper already no-ops on a nil text +-- (V.buildDetailLines), so returning nil here is enough to simply omit the line. +function V.formatOriginBadge(origin, spellId) + if origin.kind == "item" or origin.kind == "ability" then + return "[" .. origin.label .. "] #" .. tostring(spellId) + end + return nil +end + +-- One row from ctx.sourceBreakdownFn's per-id item-source breakdown (the pure-function contract for +-- the not-yet-built VampifyAggregate.spellSourceBreakdown -- see V.buildDetailLines below for the +-- fallback when it is absent). Expected row shape: { label, heal, share (0..100), floorHits, ... }. +-- "