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.
@@ -0,0 +1,4 @@
|
|||||||
|
# Editor / OS noise
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.log
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
<img src="screenshots/row-detail.png" alt="The breakdown panel with one ability's detail open" width="100%">
|
||||||
|
|
||||||
|
*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
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/bar.png"><img src="screenshots/bar.png" width="260" alt="The compact bar"></a><br>
|
||||||
|
<sub><b>The bar</b> — this fight's returned health, the single-target/AoE split, HPS, gear total</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/breakdown.png"><img src="screenshots/breakdown.png" width="260" alt="The breakdown panel"></a><br>
|
||||||
|
<sub><b>Breakdown</b> — every ability that returned health, session, lifetime or last fight</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="33%">
|
||||||
|
<a href="screenshots/options.png"><img src="screenshots/options.png" width="260" alt="The options window"></a><br>
|
||||||
|
<sub><b>Options</b> — every setting, plus what the addon can and cannot see</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## 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 `<WoW>\Interface\AddOns\` so that `Vampify.toc` sits at
|
||||||
|
`<WoW>\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 <pct>` | add a source by hand, if tooltip detection missed it |
|
||||||
|
| `/vf source clear` | drop all manually added sources |
|
||||||
|
| `/vf exclude add <spellId>` | stop a spell from counting as a Vampirism trigger |
|
||||||
|
| `/vf exclude remove <spellId>` | 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).
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
-- Vampify -- SavedVariables schema, defaults merge, and migration.
|
||||||
|
--
|
||||||
|
-- Migration policy: step FORWARD from a known older version; on an unknown or newer version,
|
||||||
|
-- reset wholesale. A partial migration from a schema we do not know produces a config that looks
|
||||||
|
-- valid and is not, which is worse than losing a frame position.
|
||||||
|
|
||||||
|
VampifyConfig = {}
|
||||||
|
local C = VampifyConfig
|
||||||
|
|
||||||
|
C.DB_VERSION = 4
|
||||||
|
|
||||||
|
-- v1 put the display at CENTER/-150. Confirmed in-game 2026-08-10: on a pfUI layout that is right
|
||||||
|
-- on top of the action bars, and at strata MEDIUM/level 1 the frame loses every overlap -- it
|
||||||
|
-- reported shown, visible, alpha 1, and could not be found on screen.
|
||||||
|
C.V1_POS = { point = "CENTER", x = 0, y = -150 }
|
||||||
|
|
||||||
|
C.DEFAULTS = {
|
||||||
|
dbVersion = 3,
|
||||||
|
pos = { point = "CENTER", x = 0, y = 200 },
|
||||||
|
locked = false,
|
||||||
|
minimap = true,
|
||||||
|
mmAngle = 200, -- degrees around the minimap
|
||||||
|
|
||||||
|
-- v3: our own scrolling combat text, alongside (not instead of) Blizzard's. "showFct" (v2 and
|
||||||
|
-- earlier) is gone -- superseded by sct.enabled + sct.mode, see the v2->v3 step below. Two
|
||||||
|
-- overlapping on/off switches for the same feature is exactly the kind of SavedVariables bloat
|
||||||
|
-- the project rules ask to avoid.
|
||||||
|
sct = {
|
||||||
|
enabled = false,
|
||||||
|
mode = "own", -- "own" (this addon's SCT, free-floating anchor) | "bar" (this
|
||||||
|
-- addon's SCT, anchored to the display bar, gui/sct.lua's
|
||||||
|
-- reanchor()) | "blizzard" (gui/fct.lua). New enum value, no
|
||||||
|
-- dbVersion bump: an existing "own"/"blizzard" profile is still
|
||||||
|
-- valid, fillDefaults never touches an explicitly stored mode.
|
||||||
|
pos = { point = "CENTER", x = -180, y = 0 },
|
||||||
|
color = { r = 0.4, g = 0.9, b = 0.4 },
|
||||||
|
duration = 1.5,
|
||||||
|
-- Both slider-driven. No dbVersion bump is needed to add them: the defaults merge fills
|
||||||
|
-- missing keys recursively, so an existing v3 profile simply gains them at their default.
|
||||||
|
fontSize = 16, -- 8..48; the old 8..28 ceiling was reached in practice
|
||||||
|
rise = 40, -- 20..320 px travelled per number; also widens the anti-overlap spread
|
||||||
|
-- One number per CAST rather than per hit: an AoE on five targets shows its summed return
|
||||||
|
-- once instead of five times. Display only -- the model still runs per hit, so totals are
|
||||||
|
-- identical either way. On by default because it is what was asked for.
|
||||||
|
coalesce = true,
|
||||||
|
-- Text effect. Green numbers over grass are barely readable without one; an outline is
|
||||||
|
-- what separates the glyph from whatever is behind it. "" | "OUTLINE" | "THICKOUTLINE".
|
||||||
|
outline = "THICKOUTLINE",
|
||||||
|
shadow = true,
|
||||||
|
},
|
||||||
|
-- Whether the display is wanted on screen. A stored PREFERENCE, not a reading of the frame:
|
||||||
|
-- the frame's own IsShown() was the single definition of "is it on" until it turned out that
|
||||||
|
-- recompute() (which runs on every loading screen and every committed gear scan) overwrites it,
|
||||||
|
-- so a hide never survived the next portal. No dbVersion bump needed -- the defaults merge fills
|
||||||
|
-- it in recursively, and `true` is what every existing profile was effectively running.
|
||||||
|
shown = true,
|
||||||
|
showOverheal = true,
|
||||||
|
|
||||||
|
-- Dev-only per-hit debug export (core/perhit.lua), toggled by /vf perhit on|off and the
|
||||||
|
-- "Per-hit debug export" checkbox in gui/options.lua. DEFAULT ON: the module itself still
|
||||||
|
-- defaults its in-memory state to disabled (core/perhit.lua's own comment), but the developer
|
||||||
|
-- explicitly overrode that for THIS addon -- it is meant to run continuously like a background
|
||||||
|
-- capture addon, not be armed by hand each session (follow-up change request, 2026-08-22). No dbVersion bump
|
||||||
|
-- needed: the defaults merge below fills it in recursively for an existing profile, same as
|
||||||
|
-- shown/showOverheal above -- and `true` for a MISSING key is exactly what a brand new
|
||||||
|
-- default-on preference should read as (fillDefaults only ever fills a true nil, never a
|
||||||
|
-- stored `false`, so a developer who explicitly turned it off keeps it off across reloads).
|
||||||
|
perhitEnabled = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
C.CHAR_DEFAULTS = {
|
||||||
|
dbVersion = 2,
|
||||||
|
enabled = true,
|
||||||
|
sessionHeal = 0,
|
||||||
|
manual = {}, -- manual source overrides, /vf source add <percent>
|
||||||
|
-- Runtime-extendable twin of VampifyConst.NO_TRIGGER, /vf exclude add|remove|list. Damage
|
||||||
|
-- shields have no structural signal (see const.lua) -- a new one is only ever found once it
|
||||||
|
-- shows up wrongly in the breakdown, so this list has to be growable without a client restart.
|
||||||
|
-- Unioned with the hardcoded table at the check site (VampifyConst.triggersVampirism), never
|
||||||
|
-- replacing it. No dbVersion bump needed: the defaults merge below fills it in recursively.
|
||||||
|
noTrigger = {},
|
||||||
|
|
||||||
|
-- Persisted per-spell breakdown, two sets side by side (spec 2026-08-10 sec 3). Same shape as
|
||||||
|
-- VampifyAggregate's in-memory spHeal/spDmg/spOver (and their lifetime twins): a plain spellId
|
||||||
|
-- -> number table each. core/commands.lua points the aggregate's own tables AT these after
|
||||||
|
-- login/reload, so a recorded hit is a SavedVariables write with no extra save step and no
|
||||||
|
-- extra allocation -- see A.new's comment in core/aggregate.lua.
|
||||||
|
-- session -- survives /reload and loading screens, cleared on a genuine login
|
||||||
|
-- lifetime -- never cleared automatically; only /vf reset lifetime|both touches it
|
||||||
|
-- No dbVersion bump needed: the defaults merge below fills both in recursively for an existing
|
||||||
|
-- character db that predates this feature.
|
||||||
|
--
|
||||||
|
-- `hist` is the DAMAGE HISTOGRAM (core/histogram.lua) for the same two scopes: hit count per
|
||||||
|
-- exact damage value, normal and AoE hits kept apart, with everything above the cap bundled
|
||||||
|
-- into a count and a sum. It is what makes "what would one more source be worth" answerable
|
||||||
|
-- exactly -- the floor makes healing non-linear in damage, so no total or average can answer
|
||||||
|
-- it (see that file's header). Wired by reference the same way the per-spell tables are, so a
|
||||||
|
-- recorded hit is already a SavedVariables write.
|
||||||
|
--
|
||||||
|
-- Bounded on purpose (VampifyHistogram.CAP): at most ~1000 exact keys per side, so a long
|
||||||
|
-- session cannot grow this without limit the way a per-GUID table would. No dbVersion bump
|
||||||
|
-- needed -- the defaults merge below fills it in recursively for a character db saved before
|
||||||
|
-- this existed, including a half-filled one.
|
||||||
|
-- `hits` is the per-spell HIT COUNT twin of heal/damage/overheal above, same key, same
|
||||||
|
-- session/lifetime split, filled by the same VampifyAggregate.recordSpell call. No dbVersion
|
||||||
|
-- bump needed here either: the defaults merge below fills it in as an empty table for a
|
||||||
|
-- character db that predates the counter, so an existing profile's heal/damage/overheal HISTORY
|
||||||
|
-- is kept while its hit counts start over at 0 -- there is nothing to backfill them from.
|
||||||
|
-- healAoe/damageAoe/hitsAoe/splitSeen (2026-08-24, UI-redesign strand A; REDEFINED same day --
|
||||||
|
-- see C.migrate's v<4 step and core/aggregate.lua's A.recordSpell for the full history): the
|
||||||
|
-- ST/AoE split's persisted twin of heal/damage/hits above. Deliberately a SEPARATE, ADDITIVE
|
||||||
|
-- set of tables rather than a reshape of heal/damage/hits -- those three keep their existing
|
||||||
|
-- meaning (the GRAND total per spell) untouched by either version of this feature. The defaults
|
||||||
|
-- merge below fills these four in as empty tables for a character db that predates the split
|
||||||
|
-- entirely; core/aggregate.lua's A.spellSplit reads a spell with nothing in them as "ST equals
|
||||||
|
-- the whole existing total, split unknown" (hasSplit=false) rather than guessing. See
|
||||||
|
-- core/aggregate.lua's A.recordSpell and A.spellSplit for the read/write halves of this contract.
|
||||||
|
--
|
||||||
|
-- A VERSION BUMP WAS NEEDED after all, unlike most fields in this table: the split's DEFINITION
|
||||||
|
-- changed the same day it shipped (deriveAoE-classification -> current-target-identity), so any
|
||||||
|
-- data already collected under the first definition has to be discarded rather than silently
|
||||||
|
-- reinterpreted under the second -- C.migrate's v<4 step does exactly that, leaving heal/damage/
|
||||||
|
-- overheal/hits (the grand totals) untouched.
|
||||||
|
session = { heal = {}, damage = {}, overheal = {}, hits = {},
|
||||||
|
healAoe = {}, damageAoe = {}, hitsAoe = {}, splitSeen = {},
|
||||||
|
hist = { n = {}, a = {}, nOverN = 0, nOverSum = 0, aOverN = 0, aOverSum = 0 } },
|
||||||
|
lifetime = { heal = {}, damage = {}, overheal = {}, hits = {},
|
||||||
|
healAoe = {}, damageAoe = {}, hitsAoe = {}, splitSeen = {},
|
||||||
|
hist = { n = {}, a = {}, nOverN = 0, nOverSum = 0, aOverN = 0, aOverSum = 0 } },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function fillDefaults(db, defaults)
|
||||||
|
for k, v in pairs(defaults) do
|
||||||
|
if type(v) == "table" then
|
||||||
|
if type(db[k]) ~= "table" then db[k] = {} end
|
||||||
|
fillDefaults(db[k], v)
|
||||||
|
elseif db[k] == nil then -- nil, NOT falsy: a stored `false` must survive
|
||||||
|
db[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return db
|
||||||
|
end
|
||||||
|
|
||||||
|
local function copy(t)
|
||||||
|
local out = {}
|
||||||
|
for k, v in pairs(t) do
|
||||||
|
if type(v) == "table" then out[k] = copy(v) else out[k] = v end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function C.migrate(db, defaults, currentVersion)
|
||||||
|
if type(db) ~= "table" then return copy(defaults) end
|
||||||
|
local v = db.dbVersion
|
||||||
|
if type(v) ~= "number" or v > currentVersion then
|
||||||
|
return copy(defaults) -- unknown or from the future: reset, do not guess
|
||||||
|
end
|
||||||
|
-- Known older versions step forward here.
|
||||||
|
if v < 2 then
|
||||||
|
-- Move the display off the action bars -- but ONLY for users who never dragged it. A
|
||||||
|
-- position the user chose is theirs; silently relocating it would be worse than the bug.
|
||||||
|
if db.pos and db.pos.point == C.V1_POS.point
|
||||||
|
and db.pos.x == C.V1_POS.x and db.pos.y == C.V1_POS.y then
|
||||||
|
db.pos = nil -- fillDefaults below restores the v2 default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if v < 3 then
|
||||||
|
-- showFct only ever meant "show Blizzard's combat text". Carry an explicitly-set value
|
||||||
|
-- into the new sct.enabled switch, and into sct.mode = "blizzard" so a user who had it ON
|
||||||
|
-- keeps seeing exactly what they had -- our own SCT (mode "own") is a different visual and
|
||||||
|
-- must not silently replace what they were already looking at. A false/never-set showFct
|
||||||
|
-- needs no mode opinion; the "own" default from C.DEFAULTS is fine for a switch that was
|
||||||
|
-- off anyway. showFct itself is then dropped -- see the comment on C.DEFAULTS.sct.
|
||||||
|
db.sct = db.sct or {}
|
||||||
|
if db.sct.enabled == nil and db.showFct ~= nil then
|
||||||
|
db.sct.enabled = db.showFct
|
||||||
|
if db.showFct == true then db.sct.mode = "blizzard" end
|
||||||
|
end
|
||||||
|
db.showFct = nil
|
||||||
|
end
|
||||||
|
if v < 4 then
|
||||||
|
-- ST/AoE split REDEFINED (2026-08-24, the same day the split shipped): from "was this hit
|
||||||
|
-- AoE-CLASSIFIED (deriveAoE, area-damage burst detection)" to "did this hit land on
|
||||||
|
-- something OTHER than the player's current target" -- a completely different question (see
|
||||||
|
-- core/aggregate.lua's A.recordSpell for the full history). Any healAoe/damageAoe/hitsAoe/
|
||||||
|
-- splitSeen data already collected under the SUPERSEDED definition would silently mix two
|
||||||
|
-- incompatible meanings with data collected under the new one if left in place -- worse than
|
||||||
|
-- losing it, so it is discarded ONCE, cleanly, here, exactly like a v<2/v<3 step above steps
|
||||||
|
-- an old schema forward rather than half-migrating it.
|
||||||
|
--
|
||||||
|
-- GRAND TOTALS ARE NOT TOUCHED: db.session.heal/damage/overheal/hits and their lifetime
|
||||||
|
-- twins keep every number a player has already earned -- only the split's OWN bookkeeping
|
||||||
|
-- (which of that total was ST vs AoE) resets to "unknown" (A.spellSplit's hasSplit reads
|
||||||
|
-- false again for every spell until a fresh, correctly-classified hit lands). Guarded on
|
||||||
|
-- db.session/db.lifetime existing at all: this migrate() function also runs on VampifyDB
|
||||||
|
-- (global settings), which has neither field, and must not error there.
|
||||||
|
if db.session then
|
||||||
|
db.session.healAoe, db.session.damageAoe, db.session.hitsAoe, db.session.splitSeen =
|
||||||
|
{}, {}, {}, {}
|
||||||
|
end
|
||||||
|
if db.lifetime then
|
||||||
|
db.lifetime.healAoe, db.lifetime.damageAoe, db.lifetime.hitsAoe, db.lifetime.splitSeen =
|
||||||
|
{}, {}, {}, {}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
db.dbVersion = currentVersion
|
||||||
|
return fillDefaults(db, defaults)
|
||||||
|
end
|
||||||
|
|
||||||
|
if CreateFrame then
|
||||||
|
local done = false
|
||||||
|
local function load_()
|
||||||
|
if done then return end
|
||||||
|
done = true
|
||||||
|
VampifyDB = C.migrate(VampifyDB, C.DEFAULTS, C.DB_VERSION)
|
||||||
|
VampifyCharDB = C.migrate(VampifyCharDB, C.CHAR_DEFAULTS, C.DB_VERSION)
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = CreateFrame("Frame", "VampifyConfigFrame")
|
||||||
|
-- ADDON_LOADED is the earliest event at which the SavedVariables tables exist, and it fires on
|
||||||
|
-- every load path -- migrating any later would leave the addon reading an unmigrated table in
|
||||||
|
-- the meantime, invisible until the schema changes and then corrupting.
|
||||||
|
-- PLAYER_ENTERING_WORLD is kept as a belt-and-braces second chance; `done` makes it idempotent.
|
||||||
|
-- (An earlier version of this comment claimed PLAYER_LOGIN does not fire on /reload. It does --
|
||||||
|
-- see core/commands.lua's wireSession for the evidence. Nothing here depended on the claim, but
|
||||||
|
-- it propagated from here into code that did.)
|
||||||
|
f:RegisterEvent("ADDON_LOADED")
|
||||||
|
f:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||||
|
f:SetScript("OnEvent", function()
|
||||||
|
if event == "ADDON_LOADED" and arg1 ~= "Vampify" then return end
|
||||||
|
load_()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function C.get() return VampifyDB end
|
||||||
|
function C.getChar() return VampifyCharDB end
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
-- Vampify -- shared constants and the pinned formula descriptor.
|
||||||
|
--
|
||||||
|
-- The formula was MEASURED in-game (a companion telemetry addon's instrumentation, 2026-08-07..09), it is not derived from tooltips
|
||||||
|
-- or forum lore. It lives in a descriptor rather than inside the arithmetic so that a resolved
|
||||||
|
-- open question (spec 4.3) is a one-line change here, not a refactor of model.lua.
|
||||||
|
|
||||||
|
VampifyConst = {}
|
||||||
|
local C = VampifyConst
|
||||||
|
|
||||||
|
C.VERSION = "0.3.0"
|
||||||
|
|
||||||
|
-- Empty a list buffer for reuse. THE table.setn IS THE POINT: in Lua 5.0 table.insert maintains an
|
||||||
|
-- `n` field, and nil-ing the indices by hand does not reset it -- so the next round of inserts
|
||||||
|
-- lands BEHIND the stale n, leaving nil holes at 1..n. That crashed the options window
|
||||||
|
-- ("table.concat: table contains non-strings") on its second refresh and silently grew the tooltip
|
||||||
|
-- line buffer on every gear scan. One helper, so the trap exists in exactly one place.
|
||||||
|
function C.resetList(t)
|
||||||
|
for i = table.getn(t), 1, -1 do t[i] = nil end
|
||||||
|
table.setn(t, 0)
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Vampirism source spell ids: items are ranks 1-5, enchants are bracer/boots at +1% each.
|
||||||
|
C.SPELL_IDS = { 45420, 45421, 45422, 45423, 45424, 57146, 57148 }
|
||||||
|
|
||||||
|
-- ---- error capture -----------------------------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- Two problems, one mechanism. A Lua error in an OnUpdate repeats every frame, which floods the
|
||||||
|
-- chat and makes the game unpleasant; and the developer only learns about it if the player thinks
|
||||||
|
-- to mention it. So: every error is written to <WoW>\imports\vampify_errors.txt where tooling
|
||||||
|
-- can read it, and only the FIRST occurrence of each distinct message reaches the chat. Repeats are
|
||||||
|
-- counted, not shown.
|
||||||
|
--
|
||||||
|
-- Lives here rather than in its own file purely so it loads first, before anything that could
|
||||||
|
-- throw, without costing a client restart to add a new file to the toc.
|
||||||
|
--
|
||||||
|
-- Deliberately chains to the previous handler for that first occurrence: swallowing errors outright
|
||||||
|
-- would trade a visible problem for an invisible one.
|
||||||
|
|
||||||
|
C._errSeen = {}
|
||||||
|
C._errOrder = {}
|
||||||
|
C._errPrev = nil
|
||||||
|
|
||||||
|
local function writeErrors()
|
||||||
|
if not ExportFile then return end -- SuperWoW only; harmless without it
|
||||||
|
local lines = {}
|
||||||
|
table.insert(lines, "addon=Vampify version=" .. tostring(C.VERSION))
|
||||||
|
for i = 1, table.getn(C._errOrder) do
|
||||||
|
local m = C._errOrder[i]
|
||||||
|
table.insert(lines, "[x" .. tostring(C._errSeen[m]) .. "] " .. m)
|
||||||
|
end
|
||||||
|
-- ExportFile appends .txt itself -- passing "vampify_errors.txt" would yield a double
|
||||||
|
-- extension, which is a known in-game gotcha.
|
||||||
|
ExportFile("vampify_errors", table.concat(lines, "\n"))
|
||||||
|
end
|
||||||
|
|
||||||
|
function C.installErrorCapture()
|
||||||
|
if C._errInstalled then return end
|
||||||
|
C._errInstalled = true
|
||||||
|
C._errPrev = geterrorhandler and geterrorhandler() or nil
|
||||||
|
|
||||||
|
seterrorhandler(function(msg)
|
||||||
|
local show, write = C.recordError(msg)
|
||||||
|
if write then writeErrors() end
|
||||||
|
if show and C._errPrev then C._errPrev(msg) end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure counting half, split out so it can be tested offline: WoW's error handler cannot be, and a
|
||||||
|
-- bug in the thing that reports bugs is the worst kind. Returns (showInChat, writeToFile).
|
||||||
|
function C.recordError(msg)
|
||||||
|
msg = tostring(msg)
|
||||||
|
local firstTime = C._errSeen[msg] == nil
|
||||||
|
if firstTime then
|
||||||
|
C._errSeen[msg] = 1
|
||||||
|
table.insert(C._errOrder, msg)
|
||||||
|
else
|
||||||
|
C._errSeen[msg] = C._errSeen[msg] + 1
|
||||||
|
end
|
||||||
|
-- Show it once, then stay quiet: the player has been told, and a per-frame repeat adds nothing
|
||||||
|
-- but noise. Write on the first sighting and every 50th repeat, so a runaway loop stays visible
|
||||||
|
-- in the file without writing on every single frame.
|
||||||
|
local write = firstTime or math.mod(C._errSeen[msg], 50) == 0
|
||||||
|
return firstTime, write
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Installed at file scope, not from an event: an error thrown while a later file is still loading
|
||||||
|
-- would otherwise be missed, and those are exactly the errors worth catching.
|
||||||
|
if seterrorhandler then C.installErrorCapture() end
|
||||||
|
|
||||||
|
-- ---- spells that do NOT trigger Vampirism -------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- Damage shields were MEASURED not to trigger (Thorns 236 triggers, Thorium Shield Spike 27 -- zero
|
||||||
|
-- healing from either). Excluding the DAMAGE_SHIELD_SELF event is not enough: the client also
|
||||||
|
-- reports shield procs as ordinary SPELL_DAMAGE_EVENT_SELF, so they slipped in and were credited
|
||||||
|
-- with healing that never happened (spotted in-game via the per-ability breakdown, which showed
|
||||||
|
-- Thorium Shield Spike at 12.4%).
|
||||||
|
--
|
||||||
|
-- There is no reliable structural signal that marks a proc as a damage shield, so this is a list.
|
||||||
|
-- It is extendable at runtime with /vf exclude <id> for anything found later, and excluded spells
|
||||||
|
-- are dropped from BOTH the healing and the damage side -- counting damage that cannot trigger
|
||||||
|
-- would drag the headline percentage below the truth.
|
||||||
|
|
||||||
|
C.NO_TRIGGER = {
|
||||||
|
[16624] = "Thorium Shield Spike", -- id read from the live client 2026-08-10
|
||||||
|
}
|
||||||
|
|
||||||
|
function C.triggersVampirism(spellId)
|
||||||
|
if not spellId then return true end -- auto attacks always trigger
|
||||||
|
if C.NO_TRIGGER[spellId] then return false end
|
||||||
|
local cfg = VampifyConfig and VampifyConfig.getChar and VampifyConfig.getChar()
|
||||||
|
if cfg and cfg.noTrigger and cfg.noTrigger[spellId] then return false end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- runtime exclusion management (cfg.noTrigger) -----------------------------------------------
|
||||||
|
--
|
||||||
|
-- cfg.noTrigger is the SAME mechanism as NO_TRIGGER above, made runtime-extendable: a new damage
|
||||||
|
-- shield is only ever found once it shows up wrongly in the breakdown (there is no structural
|
||||||
|
-- signal, see the comment above), so the list has to grow without a client restart. These three
|
||||||
|
-- functions are the one place that reads/writes cfg.noTrigger, so core/commands.lua's slash
|
||||||
|
-- handlers stay thin wiring and the union logic in triggersVampirism above is never duplicated.
|
||||||
|
--
|
||||||
|
-- New exclusions apply forward-only: they change what counts as triggering from the moment they
|
||||||
|
-- are added, but do not reach back into totals already recorded for earlier hits. See the /vf
|
||||||
|
-- exclude add command in commands.lua for the reasoning (kept there, next to where it is user-
|
||||||
|
-- visible); /vf reset remains the explicit way to discard a tainted session.
|
||||||
|
|
||||||
|
-- Adds spellId to the character-scoped runtime set. Returns false (and changes nothing) if the id
|
||||||
|
-- is already covered -- either hardcoded or already added -- with a reason string so the caller
|
||||||
|
-- can tell the player why, rather than silently re-adding.
|
||||||
|
function C.addNoTrigger(spellId, cfg)
|
||||||
|
if C.NO_TRIGGER[spellId] then return false, "builtin" end
|
||||||
|
if not cfg then return false, "no-config" end
|
||||||
|
if not cfg.noTrigger then cfg.noTrigger = {} end
|
||||||
|
if cfg.noTrigger[spellId] then return false, "already" end
|
||||||
|
cfg.noTrigger[spellId] = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Removes spellId from the runtime set. The hardcoded list is not reachable through this path --
|
||||||
|
-- it is measured fact, not a preference -- so this only ever touches cfg.noTrigger.
|
||||||
|
function C.removeNoTrigger(spellId, cfg)
|
||||||
|
if not (cfg and cfg.noTrigger and cfg.noTrigger[spellId]) then return false end
|
||||||
|
cfg.noTrigger[spellId] = nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Combined listing for /vf exclude list: hardcoded entries first, then runtime ones, each tagged
|
||||||
|
-- so the caller can say which is which. `out` is reused like every other list buffer in this addon
|
||||||
|
-- (see resetList above) so a repeated /vf exclude list stays allocation-free.
|
||||||
|
function C.listNoTrigger(cfg, out)
|
||||||
|
out = out or {}
|
||||||
|
C.resetList(out)
|
||||||
|
local n = 0
|
||||||
|
for id in pairs(C.NO_TRIGGER) do
|
||||||
|
n = n + 1
|
||||||
|
out[n] = { id = id, builtin = true }
|
||||||
|
end
|
||||||
|
if cfg and cfg.noTrigger then
|
||||||
|
for id in pairs(cfg.noTrigger) do
|
||||||
|
n = n + 1
|
||||||
|
out[n] = { id = id, builtin = false }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.setn(out, n)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- spell id -> name --------------------------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- nampower hands us numeric spell ids; the tooltip has to show something a player recognises. 1.12
|
||||||
|
-- has no GetSpellInfo, but this client has two independent routes that do work, and the result is
|
||||||
|
-- cached because a tooltip refresh must not re-resolve on every frame.
|
||||||
|
--
|
||||||
|
-- Explicitly NOT tried: GameTooltip:SetHyperlink("spell:<id>"). It is documented broken on
|
||||||
|
-- 1.12/TurtleWoW -- it fails SILENTLY and leaves the tooltip empty -- so attempting it would cost
|
||||||
|
-- work per unknown id and yield nothing.
|
||||||
|
--
|
||||||
|
-- (Belongs in its own file; it is here so it loads first and needs no toc change, which would cost
|
||||||
|
-- a client restart. Move it when something else forces a restart anyway.)
|
||||||
|
|
||||||
|
C._spellNames = {}
|
||||||
|
C.spellNameSource = "none"
|
||||||
|
|
||||||
|
function C.spellName(id)
|
||||||
|
-- -1 is the aggregate's sentinel for auto attacks (VampifyAggregate.MELEE), which have no id.
|
||||||
|
if type(id) ~= "number" or id < 0 then return "Melee" end
|
||||||
|
local cached = C._spellNames[id]
|
||||||
|
if cached then return cached end
|
||||||
|
|
||||||
|
local name
|
||||||
|
|
||||||
|
-- 1. SuperWoW's SpellInfo(spellId). It reads the client's spell DBC by numeric id rather than
|
||||||
|
-- walking the spellbook, so it resolves foreign and NPC spells too, not just our own.
|
||||||
|
if SpellInfo then
|
||||||
|
local ok, n = pcall(SpellInfo, id)
|
||||||
|
if ok and type(n) == "string" and n ~= "" then
|
||||||
|
name = n
|
||||||
|
C.spellNameSource = "SpellInfo"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. nampower's own resolver, an independent second route in case SuperWoW is absent.
|
||||||
|
if not name and GetSpellNameAndRankForId then
|
||||||
|
local ok, n = pcall(GetSpellNameAndRankForId, id)
|
||||||
|
if ok and type(n) == "string" and n ~= "" then
|
||||||
|
name = n
|
||||||
|
C.spellNameSource = "nampower"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not name then name = "Spell #" .. id end
|
||||||
|
C._spellNames[id] = name
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- spell id -> icon ---------------------------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- The UI redesign's per-ability rows want an icon next to the name, not just text. SpellInfo(id)
|
||||||
|
-- already returns this as its 3rd value (name, rank, texture, minRange, maxRange) -- C.spellName
|
||||||
|
-- above discards it -- so this is a second thin reader over the SAME SuperWoW call, cached exactly
|
||||||
|
-- like C.spellName (a tooltip/bar refresh must not re-resolve on every frame), just with its own
|
||||||
|
-- cache table because the two calls can return a texture where the name lookup failed or vice versa
|
||||||
|
-- (unlikely, but nothing guarantees the two are correlated) and sharing a cache would only save one
|
||||||
|
-- pcall while entangling two independently-failing things for no reason.
|
||||||
|
--
|
||||||
|
-- Unlike C.spellName, a miss here has no readable-string fallback to manufacture -- "no icon" is a
|
||||||
|
-- real, displayable answer (the caller substitutes its own placeholder texture), so this returns nil
|
||||||
|
-- rather than inventing a path that does not exist on disk for a genuine SPELL id.
|
||||||
|
--
|
||||||
|
-- A.MELEE (-1) and any non-number id are NOT a SpellInfo lookup at all. Two revisions on
|
||||||
|
-- this, both 2026-08-24, in order:
|
||||||
|
-- (1) first: read the equipped main-hand weapon's texture directly (GetInventoryItemTexture) --
|
||||||
|
-- superseded below;
|
||||||
|
-- (2) then (this version): WoW's OWN spellbook already carries this mapping. Every character has
|
||||||
|
-- a base "Attack" entry in their spellbook, and its icon IS the equipped weapon's icon,
|
||||||
|
-- maintained by the client itself across a weapon swap (confirmed from an in-game spellbook
|
||||||
|
-- screenshot). Reading it through GetSpellTexture on the "Attack" entry therefore needs no
|
||||||
|
-- swap-invalidation machinery of its own -- WoW already does that job; see C.meleeIcon below
|
||||||
|
-- for exactly what is and is not independently confirmed about this here.
|
||||||
|
C.MELEE_ICON = "Interface\\Icons\\Ability_MeleeDamage" -- fixed fallback only, see C.meleeIcon
|
||||||
|
|
||||||
|
-- The "Attack" entry's SPELLBOOK INDEX, found once by NAME and cached -- an index is class- and
|
||||||
|
-- level-dependent (talent respecs, new abilities learned into earlier tabs can shift it), so it is
|
||||||
|
-- never hardcoded, exactly the same reasoning gui/display.lua's own buildSpellbookSet gives for
|
||||||
|
-- never assuming a fixed spell id. `_attackIndexKnown` distinguishes "not searched yet" (nil, look
|
||||||
|
-- again) from "searched, genuinely not found" (also nil, but do NOT re-scan every call -- that would
|
||||||
|
-- turn a one-time cost into a per-frame one for a locale/build where no "Attack" entry exists).
|
||||||
|
C._attackIndex = nil
|
||||||
|
C._attackIndexKnown = false
|
||||||
|
|
||||||
|
-- Invalidates the cached index -- wired to SPELLS_CHANGED/LEARNED_SPELL_IN_TAB below, the same two
|
||||||
|
-- events Blizzard's own FrameXML\SpellBookFrame.lua registers in 1.12.1 -- a newly learned ability
|
||||||
|
-- can insert into an earlier tab and shift every
|
||||||
|
-- later index, which is exactly the class of change that would silently point this at the wrong
|
||||||
|
-- spellbook row if never re-checked. Exposed (not local) so an offline test can simulate the event
|
||||||
|
-- without needing a real WoW event frame.
|
||||||
|
function C.invalidateAttackIndex()
|
||||||
|
C._attackIndex, C._attackIndexKnown = nil, false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Iterates the spellbook by index from 1, same loop shape as gui/display.lua's buildSpellbookSet
|
||||||
|
-- (GetSpellName(i, bookType) until nil) -- the established pattern in this codebase for "walk the
|
||||||
|
-- whole spellbook", not a second invention of it. pcall-wrapped like every other WoW API read in
|
||||||
|
-- this file: GetSpellName may be absent entirely (offline tests, or a hypothetical client without
|
||||||
|
-- it) and must degrade to "not found" rather than error.
|
||||||
|
local function findAttackIndex()
|
||||||
|
if not GetSpellName then return nil end
|
||||||
|
local bookType = BOOKTYPE_SPELL or "spell" -- literal fallback matches SpellBookFrame.lua's own
|
||||||
|
-- BOOKTYPE_SPELL = "spell" in case load order ever
|
||||||
|
-- left the global unset when this runs
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local ok, name = pcall(GetSpellName, i, bookType)
|
||||||
|
if not ok or not name then break end
|
||||||
|
if name == "Attack" then return i end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The "Attack" spellbook entry's current icon. The INDEX is cached (found by name, invalidated on
|
||||||
|
-- the events above); the TEXTURE itself is read FRESH on every call via GetSpellTexture, which is
|
||||||
|
-- what carries a weapon swap through without this file needing its own swap-invalidation -- WoW's
|
||||||
|
-- client is claimed to keep that entry's icon in sync with the equipped weapon on its own (confirmed
|
||||||
|
-- from an in-game screenshot of the "Attack" spellbook row).
|
||||||
|
--
|
||||||
|
-- NOT INDEPENDENTLY VERIFIED HERE: this file (and the FrameXML source it was checked against) can
|
||||||
|
-- only confirm the API SURFACE -- GetSpellName(i, bookType)/GetSpellTexture(i, bookType), bookType
|
||||||
|
-- BOOKTYPE_SPELL == "spell", both used exactly this way by Blizzard's own SpellBookFrame.lua. Whether
|
||||||
|
-- the "Attack" entry's texture is genuinely weapon-linked is a client-side (C++) behavior no Lua
|
||||||
|
-- source exposes -- that rests on in-game observation and is still open for an in-game
|
||||||
|
-- double-check (see this feature's own verdict).
|
||||||
|
--
|
||||||
|
-- Falls back to C.MELEE_ICON when there is no client API at all (offline tests), no "Attack" entry
|
||||||
|
-- was found (unknown locale/build), or GetSpellTexture itself returns nothing for it.
|
||||||
|
function C.meleeIcon()
|
||||||
|
if not C._attackIndexKnown then
|
||||||
|
C._attackIndex, C._attackIndexKnown = findAttackIndex(), true
|
||||||
|
end
|
||||||
|
if C._attackIndex and GetSpellTexture then
|
||||||
|
local bookType = BOOKTYPE_SPELL or "spell"
|
||||||
|
local ok, tex = pcall(GetSpellTexture, C._attackIndex, bookType)
|
||||||
|
if ok and type(tex) == "string" and tex ~= "" then return tex end
|
||||||
|
end
|
||||||
|
return C.MELEE_ICON
|
||||||
|
end
|
||||||
|
|
||||||
|
if CreateFrame then
|
||||||
|
local f = CreateFrame("Frame", "VampifyConstSpellbookWatcher")
|
||||||
|
f:RegisterEvent("SPELLS_CHANGED")
|
||||||
|
f:RegisterEvent("LEARNED_SPELL_IN_TAB")
|
||||||
|
f:SetScript("OnEvent", C.invalidateAttackIndex)
|
||||||
|
end
|
||||||
|
|
||||||
|
C._spellIcons = {}
|
||||||
|
|
||||||
|
function C.spellIcon(id)
|
||||||
|
if type(id) ~= "number" or id < 0 then return C.meleeIcon() end
|
||||||
|
local cached = C._spellIcons[id]
|
||||||
|
if cached ~= nil then
|
||||||
|
if cached == false then return nil end -- a cached MISS, not "never looked up"
|
||||||
|
return cached
|
||||||
|
end
|
||||||
|
|
||||||
|
local icon
|
||||||
|
if SpellInfo then
|
||||||
|
-- pcall-wrapped like C.spellName: SuperWoW may be absent, or SpellInfo may throw for an id
|
||||||
|
-- the client's DBC does not know. Either way this must degrade to nil, never propagate an
|
||||||
|
-- error into the hot damage/tooltip path.
|
||||||
|
local ok, _, _, tex = pcall(SpellInfo, id)
|
||||||
|
if ok and type(tex) == "string" and tex ~= "" then icon = tex end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cache a MISS too (as `false`, distinguishable from "not yet looked up" = nil), so an id that
|
||||||
|
-- keeps failing (no SuperWoW, or genuinely no icon) is not re-queried every frame -- the whole
|
||||||
|
-- point of caching at all.
|
||||||
|
C._spellIcons[id] = icon or false
|
||||||
|
return icon
|
||||||
|
end
|
||||||
|
|
||||||
|
function C.errorCount()
|
||||||
|
local n = 0
|
||||||
|
for _, c in pairs(C._errSeen) do n = n + c end
|
||||||
|
return n, table.getn(C._errOrder)
|
||||||
|
end
|
||||||
|
|
||||||
|
C.FORMULA = {
|
||||||
|
-- AoE damping. Measured 0.7021 on Flame Wave, independent of the number of targets hit.
|
||||||
|
-- An earlier 0.86 estimate was wrong and is retracted.
|
||||||
|
aoeFactor = 0.7,
|
||||||
|
|
||||||
|
-- Per-hit floor equals the NUMBER OF SOURCES (3 items -> min 3 HP, 2 items -> exactly 2.000).
|
||||||
|
-- A fixed floor of 3, and no floor at all, are both refuted by measurement.
|
||||||
|
floorMode = "sources",
|
||||||
|
|
||||||
|
-- Settled 2026-08-10 (spec 4a): of 7239 own hits during active Vampirism epochs, 14 carried
|
||||||
|
-- mitigation and 9 were decisive after discarding full-health windows -- 7 pointed clearly at
|
||||||
|
-- net, one favoured gross by 0.6 HP (noise), one was a full-block window too noisy to count.
|
||||||
|
-- The gross error tracked mitigation * sumPercent almost exactly, which is the shape the
|
||||||
|
-- mechanism predicts. The server pays Vampirism on damage already reduced by block/absorb/
|
||||||
|
-- resist, not on the logged (pre-mitigation) amount.
|
||||||
|
damageBase = "net",
|
||||||
|
|
||||||
|
-- Three-valued. "undecided" behaves as "off" and makes the displayed total a LOWER BOUND.
|
||||||
|
channels = {
|
||||||
|
melee = "on", -- measured
|
||||||
|
spell = "on", -- measured
|
||||||
|
aoe = "on", -- measured, with aoeFactor
|
||||||
|
proc = "on", -- measured: Tidal Wave 12/12 windows, 0/36 control
|
||||||
|
dmgshield = "off", -- refuted: Thorns 236 + Thorium Spike 27 triggers -> 0 healing
|
||||||
|
dot = "on", -- measured 2026-08-10: ticks trigger per tick, no special
|
||||||
|
-- handling, measured in-game over two Flame Shock sequences.
|
||||||
|
-- The old "never occurred in a Vampirism epoch" note predated
|
||||||
|
-- that measurement.
|
||||||
|
totempet = "off", -- the developer's read from play; undecidable as instrumented
|
||||||
|
pvp = "undecided", -- no Vampirism-geared BG epoch with own damage
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ---- item proc origins (spell id -> item name) --------------------------------------------------
|
||||||
|
--
|
||||||
|
-- C.ORIGINS itself is generated (see the block below) from the server-source
|
||||||
|
-- item_template + spell_template dump, in two layers: DIRECT spelltrigger_N == 2 ("chance on hit")
|
||||||
|
-- ids, which appear in the combat log exactly as-is; and CHAIN ids, reached by following item
|
||||||
|
-- spells at trigger 1 (on-equip proc auras) OR 2 one spell_template effect hop via
|
||||||
|
-- effectTriggerSpell1/2/3. The chain layer exists because a real proc can sit one hop away from
|
||||||
|
-- the item: spell 16614 ("Lightning Strike") never appears as an item_template spellid_N at all --
|
||||||
|
-- only its equip-aura wrapper (spell 16615, trigger==1) does, and 16615's own spell_template
|
||||||
|
-- effect is what fires 16614. A direct-only lookup would silently miss exactly that case. Plain
|
||||||
|
-- on-use procs (trigger 0) are still excluded -- they answer "what did this item grant", a
|
||||||
|
-- different question than a damage-origin lookup needs.
|
||||||
|
--
|
||||||
|
-- Nil-safe by construction: returns nil both when spellId has no known item origin AND when the
|
||||||
|
-- generator has never been run yet (C.ORIGINS absent entirely), so callers never need a separate
|
||||||
|
-- guard for "table not generated yet" vs. "id not a proc".
|
||||||
|
function C.itemProcOrigin(spellId)
|
||||||
|
if not C.ORIGINS then return nil end
|
||||||
|
return C.ORIGINS[spellId]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- BEGIN GENERATED ORIGINS (generated by an offline generator script -- do not edit by hand)
|
||||||
|
--
|
||||||
|
-- Generated by an offline generator script. Two sources, unioned, two layers:
|
||||||
|
-- Pass 1 (item->spell edges): UNION of a live game-database dump of item-to-spell
|
||||||
|
-- links and a local item_template dump. Conflict rule: for an
|
||||||
|
-- item entry known to both, the live database wins outright (current server state); the local
|
||||||
|
-- dump contributes ONLY items the live database does not know at all. See this generator's file
|
||||||
|
-- header ("TWO SOURCES, UNION not replacement") for the full rule and why.
|
||||||
|
-- Pass 2 (spell effect chain): local spell_template dump -- the live database
|
||||||
|
-- has no equivalent effectTriggerSpell field, see this generator's file header.
|
||||||
|
-- 1. DIRECT: item-attached spell at trigger == 2 ("chance on hit") --
|
||||||
|
-- these appear in the combat log exactly as-is.
|
||||||
|
-- 2. CHAIN: item-attached spell at trigger == 1 (on-equip proc auras) OR == 2,
|
||||||
|
-- followed ONE spell_template effect hop via effectTriggerSpell1/2/3.
|
||||||
|
-- This exists because a real proc (spell 16614 "Lightning Strike") never
|
||||||
|
-- appears as an item-attached spell at all -- only the equip-aura wrapper
|
||||||
|
-- (16615) does, at trigger==1, and 16615's own spell_template effect is what
|
||||||
|
-- fires 16614.
|
||||||
|
-- A direct-only lookup silently misses cases exactly like this one.
|
||||||
|
-- An id reachable through both layers gets one entry with item names unioned.
|
||||||
|
--
|
||||||
|
-- Maps a proc spell id to the item(s) that carry it, so the addon can answer
|
||||||
|
-- "is this spell id an item proc, and from what" via a plain table lookup.
|
||||||
|
--
|
||||||
|
-- Lives in const.lua (not its own file) for the same reason C.spellName does: a new
|
||||||
|
-- .lua file needs a Vampify.toc entry, which costs a full client restart, while
|
||||||
|
-- const.lua loads first as the very first module and a table literal here needs only
|
||||||
|
-- a /reload. Re-run the generator and replace this block; do not hand-edit it.
|
||||||
|
--
|
||||||
|
-- Regenerate: run the offline generator script
|
||||||
|
C.ORIGINS = {
|
||||||
|
[56] = "Carved Ragetotem and others",
|
||||||
|
[89] = "Fire Sword of Crippling",
|
||||||
|
[695] = "Betrayer",
|
||||||
|
[744] = "Gift of the Spider God",
|
||||||
|
[772] = "Serrated Handaxe",
|
||||||
|
[871] = "Glaive of the Defender",
|
||||||
|
[1054] = "Lesser Firestone",
|
||||||
|
[2606] = "Stormfist",
|
||||||
|
[2912] = "Sentinel's Moonslicer",
|
||||||
|
[3264] = "Bloodhowler",
|
||||||
|
[3271] = "Chilton Wand",
|
||||||
|
[3396] = "The Ripper, Vile Sting",
|
||||||
|
[3424] = "Gift of the Spider God",
|
||||||
|
[3742] = "Gahz'rilla Fang, Aura Proc Damage Sword",
|
||||||
|
[5597] = "The Ripper",
|
||||||
|
[6647] = "The Ripper",
|
||||||
|
[6751] = "Venom Infused Blade",
|
||||||
|
[7712] = "Fiery Retributer and others",
|
||||||
|
[7714] = "Embergem Cuffs and others",
|
||||||
|
[8191] = "Sword of Zeal",
|
||||||
|
[8277] = "Cursed Shinbone",
|
||||||
|
[8313] = "Bite of Serra'kis",
|
||||||
|
[8348] = "Julie's Dagger",
|
||||||
|
[8552] = "Staff of Horrors, Cursed Thornblade",
|
||||||
|
[9057] = "Red Whelp Gloves, Helmet of the Scarlet Avenger",
|
||||||
|
[9159] = "Green Whelp Armor",
|
||||||
|
[9329] = "Soulstring and others",
|
||||||
|
[9632] = "Ravager, The Cruel Blade",
|
||||||
|
[9633] = "Ravager, The Cruel Blade",
|
||||||
|
[9777] = "Truesilver Breastplate",
|
||||||
|
[9796] = "Blight",
|
||||||
|
[9800] = "Truesilver Champion",
|
||||||
|
[9806] = "Phantom Blade",
|
||||||
|
[10342] = "Guardian Talisman",
|
||||||
|
[10351] = "Blade of the Basilisk",
|
||||||
|
[10368] = "Uther's Strength",
|
||||||
|
[10370] = "Mutilator",
|
||||||
|
[10371] = "Obedient Whacker",
|
||||||
|
[10373] = "Pendulum of Doom",
|
||||||
|
[11657] = "Jang'thraze the Protector",
|
||||||
|
[11658] = "Sul'thraze the Lasher",
|
||||||
|
[11790] = "Toxic Revenger",
|
||||||
|
[11791] = "Digmaster 5000, Vibroblade, Nail on a Plank",
|
||||||
|
[11879] = "Shoni's Disarming Tool, The Murkfisher",
|
||||||
|
[12484] = "Einhorn's Skinner",
|
||||||
|
[12685] = "Stealthblade",
|
||||||
|
[12686] = "Ragehammer",
|
||||||
|
[12731] = "Stoneslayer",
|
||||||
|
[13049] = "Dragon's Call",
|
||||||
|
[13318] = "Barman Shanker, Blood Talon, Killmaim",
|
||||||
|
[13438] = "Excavator's Brand",
|
||||||
|
[13439] = "Winter's Bite and others",
|
||||||
|
[13440] = "Grimclaw and others",
|
||||||
|
[13441] = "Orb of Fire",
|
||||||
|
[13442] = "Meteor Shard, Baron Charr's Sceptre, Scroll of Cow Portal",
|
||||||
|
[13480] = "Night Reaver",
|
||||||
|
[13482] = "The Ziggler, Electrocutioner Leg",
|
||||||
|
[13486] = "Bloodletter Scalpel, Fleshrender",
|
||||||
|
[13490] = "Howling Blade",
|
||||||
|
[13491] = "Iron Knuckles",
|
||||||
|
[13496] = "Mug O' Hurt",
|
||||||
|
[13518] = "Blackvenom Blade, Fang of the Broodmother",
|
||||||
|
[13519] = "Shortsword of Vengeance, Fishbringer",
|
||||||
|
[13524] = "Stalvan's Reaper",
|
||||||
|
[13526] = "Strike of the Hydra, Hookfang Shanker",
|
||||||
|
[13527] = "Supercharger Battle Axe",
|
||||||
|
[13528] = "Sword of Decay",
|
||||||
|
[13530] = "Tainted Pierce",
|
||||||
|
[13532] = "The Hand of Antu'sul",
|
||||||
|
[13533] = "The Jackhammer, Carved Grimtotem",
|
||||||
|
[13534] = "The Shatterer",
|
||||||
|
[13752] = "Dazzling Longsword",
|
||||||
|
[14106] = "The Black Knight",
|
||||||
|
[14118] = "Drakefang Butcher",
|
||||||
|
[14119] = "Phytoblade, Thunder 45, Thunderhorn",
|
||||||
|
[14126] = "Grim Reaper, Rusty Coghammer",
|
||||||
|
[15280] = "Dark Iron Sunderer",
|
||||||
|
[15283] = "Dark Iron Pulverizer",
|
||||||
|
[15494] = "Ironfoe",
|
||||||
|
[15592] = "Skaldrenox's Rage",
|
||||||
|
[15595] = "Force of Will",
|
||||||
|
[15601] = "Hand of Justice",
|
||||||
|
[15602] = "Lord General's Sword",
|
||||||
|
[15661] = "Terrorblade Glaive",
|
||||||
|
[15662] = "Smoldering Claw",
|
||||||
|
[16393] = "Glutton's Cleaver",
|
||||||
|
[16400] = "Widow's Kiss and others",
|
||||||
|
[16401] = "Poison-tipped Bone Spear",
|
||||||
|
[16403] = "The Goldtusk and others",
|
||||||
|
[16405] = "Ripsaw and others",
|
||||||
|
[16406] = "Gutwrencher, Hameya's Slayer",
|
||||||
|
[16407] = "Edge of Winter",
|
||||||
|
[16408] = "Darkwater Talwar",
|
||||||
|
[16409] = "Ghoulfang and others",
|
||||||
|
[16411] = "Deathblow",
|
||||||
|
[16413] = "Firebreather, Searing Blade",
|
||||||
|
[16414] = "Wraith Scythe, Scythe of the Harvest",
|
||||||
|
[16415] = "Taran Icebreaker",
|
||||||
|
[16433] = "Bloodfist and others",
|
||||||
|
[16454] = "Searing Needle",
|
||||||
|
[16528] = "Keris of Zul'Serak",
|
||||||
|
[16549] = "Blackhand Doomsaw",
|
||||||
|
[16551] = "Felstriker",
|
||||||
|
[16559] = "Flame Wrath",
|
||||||
|
[16560] = "Flame Wrath",
|
||||||
|
[16602] = "Blackblade of Shahram",
|
||||||
|
[16603] = "Demonfork",
|
||||||
|
[16608] = "Demon Forged Breastplate, Breastplate of the Dark Reaver",
|
||||||
|
[16614] = "Storm Gauntlets and others",
|
||||||
|
[16621] = "Invulnerable Mail",
|
||||||
|
[16782] = "Charged Servo Arm and others",
|
||||||
|
[16783] = "Totem of Infliction, Girdle of Reprisal",
|
||||||
|
[16784] = "Vile Protector",
|
||||||
|
[16871] = "Bleakwood Hew",
|
||||||
|
[16898] = "Blazing Rapier",
|
||||||
|
[16908] = "Serenity",
|
||||||
|
[16916] = "Arcanite Champion",
|
||||||
|
[16921] = "Masterwork Stormhammer, Thunderforge Lance",
|
||||||
|
[16927] = "Frostguard",
|
||||||
|
[16928] = "Annihilator",
|
||||||
|
[16939] = "Darkspear",
|
||||||
|
[17144] = "Stormpike",
|
||||||
|
[17148] = "Brain Hacker",
|
||||||
|
[17152] = "Destiny",
|
||||||
|
[17153] = "Kang the Decapitator",
|
||||||
|
[17154] = "The Green Tower",
|
||||||
|
[17196] = "Seeping Willow",
|
||||||
|
[17308] = "Femur Club and others",
|
||||||
|
[17315] = "Bashguuder, Rivenspike",
|
||||||
|
[17331] = "Fang of the Crystal Spider",
|
||||||
|
[17351] = "Argent Defender",
|
||||||
|
[17352] = "Argent Avenger",
|
||||||
|
[17407] = "The Nicker",
|
||||||
|
[17483] = "Demonshear",
|
||||||
|
[17484] = "Skullforge Reaver",
|
||||||
|
[17496] = "Crest of Retribution",
|
||||||
|
[17500] = "Malown's Slam",
|
||||||
|
[17504] = "Bloodrazor",
|
||||||
|
[17505] = "The Cruel Hand of Timmy",
|
||||||
|
[17506] = "Soul Breaker",
|
||||||
|
[17509] = "Dark Reaver",
|
||||||
|
[17510] = "Sword of Corruption",
|
||||||
|
[17511] = "Serpent Slicer, Ichor Spitter, Toxic Ripper",
|
||||||
|
[17936] = "Firestone",
|
||||||
|
[17940] = "Greater Firestone",
|
||||||
|
[17942] = "Major Firestone",
|
||||||
|
[18077] = "Venom Web Fang",
|
||||||
|
[18078] = "Bloody Pick and others",
|
||||||
|
[18081] = "Gryphon Rider's Stormhammer",
|
||||||
|
[18082] = "Volcanic Hammer",
|
||||||
|
[18083] = "Galgann's Firehammer",
|
||||||
|
[18084] = "Fist of the Damned",
|
||||||
|
[18086] = "Teebu's Blazing Longsword, Force of Magma",
|
||||||
|
[18088] = "Blade of the Wretched, Corruption",
|
||||||
|
[18089] = "Linken's Sword of Mastery",
|
||||||
|
[18090] = "Gutrender, Fisher's Harpoon, Ancient Hakkari Flayer",
|
||||||
|
[18091] = "Archeus",
|
||||||
|
[18092] = "Shiver Blade, Coldheart Icicle",
|
||||||
|
[18104] = "Axe of the Deep Woods",
|
||||||
|
[18107] = "Gut Ripper, Fleshslasher",
|
||||||
|
[18112] = "Ashbringer",
|
||||||
|
[18138] = "Black Duskwood Staff, Shadowblade, Deathbringer",
|
||||||
|
[18187] = "Pan of Po'rool, Overloaded Heating Coil, Fists of the Red Dawn",
|
||||||
|
[18197] = "Serpent's Kiss, Stinging Viper",
|
||||||
|
[18199] = "Burning War Axe",
|
||||||
|
[18200] = "Bloodspiller",
|
||||||
|
[18202] = "Bloodpike, Gargoyle Shredder Talons, Jaw of the Ancient",
|
||||||
|
[18203] = "Venomspitter",
|
||||||
|
[18204] = "Cobalt Crusher",
|
||||||
|
[18205] = "Black Malice and others",
|
||||||
|
[18206] = "Diabolic Skiver",
|
||||||
|
[18208] = "Scorpion Sting",
|
||||||
|
[18211] = "Nightblade, Doombringer, Ebon Hand",
|
||||||
|
[18214] = "Witchfury, Sun's Tail",
|
||||||
|
[18217] = "Duskbringer",
|
||||||
|
[18276] = "Darrowspike, Bonechill Hammer",
|
||||||
|
[18278] = "Silent Fang",
|
||||||
|
[18289] = "Gravestone War Axe",
|
||||||
|
[18350] = "Black Grasp of the Destroyer",
|
||||||
|
[18381] = "Cursed Felblade",
|
||||||
|
[18398] = "Sliverblade, Glacial Blade",
|
||||||
|
[18543] = "Everflame Torch",
|
||||||
|
[18633] = "Frightskull Shaft",
|
||||||
|
[18652] = "Barovian Family Sword",
|
||||||
|
[18656] = "Ebon Hilt of Marduk, Shadowbringer",
|
||||||
|
[18796] = "Fiery War Axe",
|
||||||
|
[18797] = "Flurry Axe",
|
||||||
|
[18798] = "Freezing Band",
|
||||||
|
[18803] = "Hand of Edward the Odd",
|
||||||
|
[18817] = "Skullflame Shield",
|
||||||
|
[18818] = "Skullflame Shield",
|
||||||
|
[18819] = "Archaic Slicer",
|
||||||
|
[18828] = "Wall of the Dead",
|
||||||
|
[18833] = "Alcor's Sunrazor",
|
||||||
|
[18946] = "The Lion Horn of Stormwind",
|
||||||
|
[18980] = "Electrified Gloves",
|
||||||
|
[19260] = "Chillpike",
|
||||||
|
[19755] = "Frightalon",
|
||||||
|
[19874] = "Shimmering Platinum Warhammer",
|
||||||
|
[20586] = "Windreaper",
|
||||||
|
[20869] = "Glacial Stone",
|
||||||
|
[20883] = "Joonho's Mercy",
|
||||||
|
[21140] = "Vis'kag the Bloodletter, Drake Talon Cleaver",
|
||||||
|
[21151] = "Gutgore Ripper",
|
||||||
|
[21152] = "Earthshaker",
|
||||||
|
[21153] = "Bonereaver's Edge",
|
||||||
|
[21159] = "Sulfuron Hammer",
|
||||||
|
[21162] = "Sulfuras, Hand of Ragnaros",
|
||||||
|
[21165] = "Empyrean Demolisher, Steamrigged Servohammer",
|
||||||
|
[21170] = "Shadowstrike",
|
||||||
|
[21179] = "Thunderstrike",
|
||||||
|
[21186] = "Spinal Reaper",
|
||||||
|
[21330] = "Eye of the Abyss",
|
||||||
|
[21898] = "Cowl of Terror",
|
||||||
|
[21919] = "Thrash Blade, Chronobreaker, Letashaz's Right Claw",
|
||||||
|
[21949] = "Gatorbite Axe",
|
||||||
|
[21951] = "Fist of Stone, Energized Spear",
|
||||||
|
[21952] = "Claw of Celebras",
|
||||||
|
[21961] = "Princess Theradras' Scepter, Carved Runetotem",
|
||||||
|
[21970] = "Mark of the Chosen",
|
||||||
|
[21992] = "Thunderfury, Blessed Blade of the Windseeker",
|
||||||
|
[22600] = "Force Reactive Disk",
|
||||||
|
[22619] = "Force Reactive Disk",
|
||||||
|
[22639] = "Eskhandar's Left Claw",
|
||||||
|
[22640] = "Eskhandar's Right Claw",
|
||||||
|
[22850] = "Quel'Serrar",
|
||||||
|
[22863] = "Sprinter's Sword",
|
||||||
|
[23267] = "Perdition's Blade",
|
||||||
|
[23454] = "Ironfist and others",
|
||||||
|
[23592] = "Electrified Dagger",
|
||||||
|
[23604] = "Black Amnesty",
|
||||||
|
[23605] = "Nightfall",
|
||||||
|
[23682] = "Darkmoon Card: Heroism",
|
||||||
|
[23684] = "Darkmoon Card: Blue Dragon",
|
||||||
|
[23687] = "Darkmoon Card: Maelstrom",
|
||||||
|
[23719] = "The Untamed Blade",
|
||||||
|
[24241] = "Halberd of Smiting",
|
||||||
|
[24251] = "Zulian Slicer",
|
||||||
|
[24254] = "Sceptre of Smiting",
|
||||||
|
[24257] = "Jeklik's Crusher",
|
||||||
|
[24362] = "Feralkin Necklace, Ancient Hakkari Flayer, Devilsaur Claws",
|
||||||
|
[24388] = "The Lobotomizer",
|
||||||
|
[24405] = "Glacial Spike, Scale of the Blue Drake",
|
||||||
|
[24585] = "Ancient Hakkari Manslayer",
|
||||||
|
[24993] = "Emerald Dragonfang",
|
||||||
|
[25768] = "Staff of the Qiraji Prophets",
|
||||||
|
[25907] = "Wrath of Cenarius",
|
||||||
|
[26108] = "Dark Edge of Insanity",
|
||||||
|
[26415] = "Kalimdor's Revenge",
|
||||||
|
[26693] = "Neretzek, The Blood Drinker, Shadowbringer, Pulseseeker",
|
||||||
|
[27039] = "Beastmaster's Cap, Deathmist Mask, Pendant of Kindred Spirit",
|
||||||
|
[27042] = "Beastmaster's Gloves, Deathmist Wraps",
|
||||||
|
[27205] = "Beastmaster's Boots, Deathmist Sandals, Charm of Dark Domination",
|
||||||
|
[27208] = "Beastmaster's Tunic, Deathmist Robe",
|
||||||
|
[27559] = "Hushblade, Jagged Obsidian Shield",
|
||||||
|
[27648] = "Thunderfury, Blessed Blade of the Windseeker",
|
||||||
|
[27655] = "Heart of Wyrmthalak",
|
||||||
|
[27657] = "Inflatable Woman",
|
||||||
|
[27860] = "Blade of Eternal Darkness",
|
||||||
|
[27868] = "Icemail Jerkin",
|
||||||
|
[28414] = "Corrupted Ashbringer",
|
||||||
|
[28441] = "Corrupted Ashbringer",
|
||||||
|
[28701] = "Tempest's Rage",
|
||||||
|
[29151] = "Misplaced Servo Arm",
|
||||||
|
[29155] = "Corrupted Ashbringer",
|
||||||
|
[29164] = "Stygian Buckler",
|
||||||
|
[29502] = "Hurricane",
|
||||||
|
[29638] = "Bow of Searing Arrows",
|
||||||
|
[29639] = "Dwarven Hand Cannon",
|
||||||
|
[29640] = "Heartseeking Crossbow, Lodestone",
|
||||||
|
[29641] = "Dark Iron Rifle",
|
||||||
|
[29644] = "Galgann's Fireblaster",
|
||||||
|
[29646] = "Quillshooter",
|
||||||
|
[29647] = "Shell Launcher Shotgun",
|
||||||
|
[29653] = "Venomstrike",
|
||||||
|
[29655] = "Verdant Keeper's Aim",
|
||||||
|
[45076] = "Aspect of Seradane",
|
||||||
|
[45416] = "Vial of Potent Venoms",
|
||||||
|
[45522] = "Idol of the Emerald Rot",
|
||||||
|
[45841] = "Rod of Resuscitation",
|
||||||
|
[45843] = "Mana Binding Signet",
|
||||||
|
[45848] = "Fist of the Forgotten Order",
|
||||||
|
[45849] = "Totem of Crackling Thunder",
|
||||||
|
[45856] = "Shawl of the Castellan",
|
||||||
|
[45858] = "Breath of Solnius",
|
||||||
|
[45860] = "Chromie's Broken Pocket Watch",
|
||||||
|
[45862] = "Libram of the Faithful",
|
||||||
|
[45867] = "Crystal of Vengeance",
|
||||||
|
[45869] = "Concentrated Power of Will",
|
||||||
|
[45873] = "Black Widow Eggs",
|
||||||
|
[45875] = "Vampire Heart",
|
||||||
|
[46104] = "Tempered Runeblade",
|
||||||
|
[46318] = "Demoralization Club",
|
||||||
|
[46319] = "Horde Defender's Axe",
|
||||||
|
[46431] = "Idol of Evergrowth",
|
||||||
|
[47354] = "Breastplate of Beast Mastery",
|
||||||
|
[48004] = "Dream's Herald",
|
||||||
|
[48005] = "Frostbound Slasher",
|
||||||
|
[48006] = "Pauldron of Deflection",
|
||||||
|
[48008] = "Bloodletter Razor",
|
||||||
|
[48048] = "Ornate Bloodstone Dagger",
|
||||||
|
[48101] = "Totem of the Stonebreaker",
|
||||||
|
[48102] = "Towerforge Demolisher",
|
||||||
|
[49369] = "Modrag'zan, Heart of the Mountain",
|
||||||
|
[51001] = "Ornate Pyrium Gauntlets",
|
||||||
|
[51144] = "Shar'tateth, the Shattered Edge",
|
||||||
|
[51250] = "Splinterspear Mace",
|
||||||
|
[51251] = "Claw of the Mageweaver",
|
||||||
|
[51266] = "Stonewrought Vambraces",
|
||||||
|
[51277] = "Crystalvein Breastplate",
|
||||||
|
[51740] = "Treant's Bane",
|
||||||
|
[52843] = "Bloodcaller's Decapitator",
|
||||||
|
[52853] = "Draenethyst Blade",
|
||||||
|
[52854] = "Draenethyst Juggernaut",
|
||||||
|
}
|
||||||
|
-- END GENERATED ORIGINS
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
-- Vampify -- the distribution of triggering hits, and what an upgrade would be worth on it.
|
||||||
|
-- Pure Lua, no WoW API, offline-tested.
|
||||||
|
--
|
||||||
|
-- WHY A DISTRIBUTION AND NOT AN AGGREGATE. Every Vampirism source heals max(1, trunc(pct*D/100))
|
||||||
|
-- independently (core/model.lua), so healing is NOT linear in damage: while pct*D/100 < 1 -- that
|
||||||
|
-- is, while D < 100/pct -- a source pays its floor of 1 whatever its percentage is. Two runs with
|
||||||
|
-- identical total damage and identical hit counts therefore return different healing, and would
|
||||||
|
-- answer "what is one more source worth" differently, purely because their hits were sized
|
||||||
|
-- differently. No total, no average and no hit count can recover that. The hit-size distribution
|
||||||
|
-- can, and exactly.
|
||||||
|
--
|
||||||
|
-- What that buys the player: below the threshold, EVERY source pays 1, so many small percentages
|
||||||
|
-- beat few large ones; above it, they are near enough interchangeable (and truncation tips the
|
||||||
|
-- balance slightly the other way -- see G.compare). Which regime a given player is actually in is
|
||||||
|
-- invisible from any number the addon showed before this, and it is exactly the question "should I
|
||||||
|
-- take the +1% enchant or the bigger item" turns on.
|
||||||
|
--
|
||||||
|
-- ALLOCATION BUDGET PER RECORDED HIT IS ZERO, like core/aggregate.lua's: AoE grinding produces
|
||||||
|
-- 30-60 damage events per second, and G.add does one table-index increment and nothing else.
|
||||||
|
|
||||||
|
VampifyHistogram = {}
|
||||||
|
local G = VampifyHistogram
|
||||||
|
|
||||||
|
-- Exact bucketing up to and including this damage value; everything above is bundled into a count
|
||||||
|
-- and a sum. The bound exists because the key space is the damage space -- unbounded in principle,
|
||||||
|
-- and a table keyed by it would grow for the whole session.
|
||||||
|
--
|
||||||
|
-- WHY 1000. Exactness only MATTERS below the floor threshold 100/pct_min: above it every source is
|
||||||
|
-- paid its percentage, the non-linearity is gone, and a count plus a sum reproduce the healing to
|
||||||
|
-- within the truncated remainder. The smallest Vampirism source that exists is 1% (the bracer and
|
||||||
|
-- boot enchants, VampifyConst.SPELL_IDS), so the highest threshold any real source can have is
|
||||||
|
-- 100/1 = 100 for a normal hit, and 100/(1*0.7) ~= 143 for an AoE one (the damping is applied to
|
||||||
|
-- the damage, so it moves the threshold up with it). 1000 is seven times that -- the entire
|
||||||
|
-- floor-sensitive range is counted exactly, with room for a hypothetical source well under 1%, and
|
||||||
|
-- the exact table still cannot exceed 999 keys per side.
|
||||||
|
--
|
||||||
|
-- Above the cap the bundle is represented by its MEAN hit, weighted by the count. That is an
|
||||||
|
-- approximation, and the size of its error is worth being explicit about: truncation loses between
|
||||||
|
-- 0 and 1 HP per source per hit, so the bundle's healing can be off by up to (sources x overflow
|
||||||
|
-- hits) HP in total -- but it is off in the SAME way for the baseline and for every hypothetical
|
||||||
|
-- compared against it, so the DELTAS G.compare reports stay trustworthy even where the absolute
|
||||||
|
-- figure drifts. The floor cannot bind inside the bundle at all (every member is above the cap,
|
||||||
|
-- and so is their mean), which is the property the cap was chosen for.
|
||||||
|
G.CAP = 1000
|
||||||
|
|
||||||
|
function G.new()
|
||||||
|
return { n = {}, a = {}, nOverN = 0, nOverSum = 0, aOverN = 0, aOverSum = 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Empties in place -- never by replacing h.n/h.a with fresh tables. These may be ALIASED to a
|
||||||
|
-- VampifyCharDB sub-table (core/commands.lua's wireSession, the same wiring the per-spell tables
|
||||||
|
-- use), and reassigning them would orphan the persisted table instead of clearing it. Same
|
||||||
|
-- reasoning as VampifyAggregate.resetSpells.
|
||||||
|
function G.reset(h)
|
||||||
|
if not h then return end
|
||||||
|
for k in pairs(h.n) do h.n[k] = nil end
|
||||||
|
for k in pairs(h.a) do h.a[k] = nil end
|
||||||
|
h.nOverN, h.nOverSum, h.aOverN, h.aOverSum = 0, 0, 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Records one TRIGGERING hit. The caller owes this function the same filtering the healing path
|
||||||
|
-- applies (core/commands.lua drops VampifyConst.triggersVampirism == false before it gets here);
|
||||||
|
-- what this function refuses on its own account is the one condition that is a property of the
|
||||||
|
-- damage rather than of the spell: damage <= 1, on which the proc does not fire at all
|
||||||
|
-- (core/model.lua). Floored FIRST, so the key and the value the model is later handed for that key
|
||||||
|
-- are the same number.
|
||||||
|
function G.add(h, damage, isAoE)
|
||||||
|
if not h or not damage then return end
|
||||||
|
damage = math.floor(damage)
|
||||||
|
if damage <= 1 then return end
|
||||||
|
if damage > G.CAP then
|
||||||
|
if isAoE then
|
||||||
|
h.aOverN, h.aOverSum = (h.aOverN or 0) + 1, (h.aOverSum or 0) + damage
|
||||||
|
else
|
||||||
|
h.nOverN, h.nOverSum = (h.nOverN or 0) + 1, (h.nOverSum or 0) + damage
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local t = isAoE and h.a or h.n
|
||||||
|
t[damage] = (t[damage] or 0) + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Moves one already-recorded hit from the normal side to the AoE side. Exists for exactly one
|
||||||
|
-- caller: core/commands.lua's retroactive AoE correction, which learns from a SECOND target that a
|
||||||
|
-- burst already credited as normal damage was area damage after all. The healing is corrected
|
||||||
|
-- there; without this the histogram would keep saying "normal" about a hit that was paid as AoE,
|
||||||
|
-- and the baseline computed from it would no longer reproduce what was actually credited -- which
|
||||||
|
-- is the one property the whole comparison rests on.
|
||||||
|
--
|
||||||
|
-- Does nothing if no such hit is on the normal side: inventing one on the AoE side would be worse
|
||||||
|
-- than leaving the miscount, and the caller's own guards (it corrects only hits it credited) mean
|
||||||
|
-- the miss is a bug elsewhere, not something to paper over here.
|
||||||
|
function G.reclassify(h, damage)
|
||||||
|
if not h or not damage then return end
|
||||||
|
damage = math.floor(damage)
|
||||||
|
if damage <= 1 then return end
|
||||||
|
if damage > G.CAP then
|
||||||
|
if not h.nOverN or h.nOverN <= 0 then return end
|
||||||
|
h.nOverN, h.nOverSum = h.nOverN - 1, h.nOverSum - damage
|
||||||
|
if h.nOverN <= 0 then h.nOverN, h.nOverSum = 0, 0 end
|
||||||
|
h.aOverN, h.aOverSum = (h.aOverN or 0) + 1, (h.aOverSum or 0) + damage
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local c = h.n[damage]
|
||||||
|
if not c or c <= 0 then return end
|
||||||
|
-- nil, not 0: a zero-count key is a key, and keeping them would defeat the cap's whole point.
|
||||||
|
if c <= 1 then h.n[damage] = nil else h.n[damage] = c - 1 end
|
||||||
|
h.a[damage] = (h.a[damage] or 0) + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- reading the distribution back ---------------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- Everything below computes healing through VampifyModel -- never with its own copy of the
|
||||||
|
-- formula. A second implementation of max(1, trunc(pct*D/100)) would drift from the real one the
|
||||||
|
-- first time the model moves, and the drift would be invisible: both halves would still look
|
||||||
|
-- self-consistent.
|
||||||
|
|
||||||
|
local function accumulate(t, overN, overSum, sources, factor, r)
|
||||||
|
for d, c in pairs(t) do
|
||||||
|
r.hits = r.hits + c
|
||||||
|
r.heal = r.heal + VampifyModel.healPerSources(d, sources, factor) * c
|
||||||
|
r.floorHeal = r.floorHeal + VampifyModel.floorHeal(d, sources, factor) * c
|
||||||
|
end
|
||||||
|
if overN and overN > 0 then
|
||||||
|
-- The bundle, as its mean hit repeated overN times -- see G.CAP's comment for what that
|
||||||
|
-- approximates and how far off it can be.
|
||||||
|
local mean = overSum / overN
|
||||||
|
r.hits = r.hits + overN
|
||||||
|
r.heal = r.heal + VampifyModel.healPerSources(mean, sources, factor) * overN
|
||||||
|
r.floorHeal = r.floorHeal + VampifyModel.floorHeal(mean, sources, factor) * overN
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Total healing the recorded hits would return for `sources` (an array of PERCENT NUMBERS, the
|
||||||
|
-- same shape core/commands.lua's sourcePercents has), and how much of it came from the FLOOR
|
||||||
|
-- rather than from the percentages. `out` is reused rather than rebuilt, like every other result
|
||||||
|
-- table in this addon.
|
||||||
|
function G.totals(h, sources, out)
|
||||||
|
out = out or {}
|
||||||
|
out.hits, out.heal, out.floorHeal, out.floorShare = 0, 0, 0, 0
|
||||||
|
if not h then return out end
|
||||||
|
accumulate(h.n, h.nOverN, h.nOverSum, sources, 1, out)
|
||||||
|
accumulate(h.a, h.aOverN, h.aOverSum, sources, VampifyConst.FORMULA.aoeFactor, out)
|
||||||
|
if out.heal > 0 then out.floorShare = out.floorHeal / out.heal end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- NOTE (2026-08-24): this file USED to also carry G.splitHeal, an ST/AoE split read over h.n/h.a
|
||||||
|
-- (deriveAoE's area-damage classification). Removed the same day the UI's ST/AoE split was
|
||||||
|
-- redefined to be about WHICH UNIT WAS HIT (current target vs. everything else), not about area-damage
|
||||||
|
-- classification -- see core/aggregate.lua's A.recordSpell and A.splitTotals for the full history.
|
||||||
|
-- This histogram has no per-target dimension at all (it is keyed by damage VALUE, isAoE-classified
|
||||||
|
-- normal/AoE only), so it cannot answer the new question and G.splitHeal became dead code; deleted
|
||||||
|
-- rather than left unused. G.totals/G.compare below are UNCHANGED and still correct -- the
|
||||||
|
-- upgrade-preview feature they back legitimately needs the AoE-DAMPING classification this histogram
|
||||||
|
-- already keeps, which is a real, separate, still-measured server mechanic untouched by the split
|
||||||
|
-- redefinition.
|
||||||
|
|
||||||
|
-- Scratch buffers for the hypothetical source lists and their results. Module-level and reused:
|
||||||
|
-- G.compare runs from a slash command and an options-panel refresh, not from the damage path, but
|
||||||
|
-- there is no reason for it to allocate four tables every time either.
|
||||||
|
local addBuf, upBuf, swapBuf = {}, {}, {}
|
||||||
|
local addOut, upOut, swapOut = {}, {}, {}
|
||||||
|
|
||||||
|
local function copyInto(buf, sources)
|
||||||
|
VampifyConst.resetList(buf) -- table.insert's stale `n` would otherwise show through
|
||||||
|
local n = sources and table.getn(sources) or 0
|
||||||
|
for i = 1, n do table.insert(buf, sources[i]) end
|
||||||
|
return buf
|
||||||
|
end
|
||||||
|
|
||||||
|
-- "What would an upgrade be worth?", answered on the hits that actually fell rather than on a
|
||||||
|
-- model example. Three hypotheticals against the current baseline:
|
||||||
|
-- (a) ONE MORE source at p percent -- a spare enchant slot, another Vampirism piece
|
||||||
|
-- (c) the WEAKEST existing source SWAPPED for one p points stronger
|
||||||
|
-- (b) the STRONGEST existing source, p points stronger -- informative bound, not a decision
|
||||||
|
--
|
||||||
|
-- (a) VERSUS (c) IS THE REAL DECISION, and it is what the verdict below is computed from. Every
|
||||||
|
-- Vampirism slot the player owns is already filled; a gear upgrade therefore does not ADD a
|
||||||
|
-- percentage, it REPLACES one. And the piece a player replaces is the one holding them back --
|
||||||
|
-- their WEAKEST Vampirism item, not their best. Both choices buy the same nominal point of total
|
||||||
|
-- percentage, and they are worth wildly different amounts of healing, because a weak source is
|
||||||
|
-- precisely the one still pinned to the floor: it is the only place where p points can be spent
|
||||||
|
-- and return nothing at all (1% -> 2% of a 30 damage hit is 0.3 -> 0.6, both truncate to 0, both
|
||||||
|
-- pay the same floor of 1), while a whole new source pays a full floor of 1 regardless.
|
||||||
|
--
|
||||||
|
-- (b) IS KEPT AS A BOUND, not as advice. Raising the STRONGEST source is the most generous thing
|
||||||
|
-- p points can do -- that source is the likeliest to be clear of the floor, so it converts the
|
||||||
|
-- points at full value. It answers "what is the very best a point could ever be worth here", which
|
||||||
|
-- is worth showing, but nobody can buy it: it would mean finding a higher rank of the item you are
|
||||||
|
-- already best served by. Reporting it as the alternative to (a) -- which this function did before
|
||||||
|
-- the swap case existed -- overstated what an upgrade is worth exactly when the hits are small,
|
||||||
|
-- which is the regime this whole feature exists to expose.
|
||||||
|
--
|
||||||
|
-- (a) and the raise cases are NOT symmetric even far above the floor, and the asymmetry runs the
|
||||||
|
-- other way there: trunc(x+y) >= trunc(x) + trunc(y), so folding p into an existing source lets
|
||||||
|
-- two truncated remainders combine into a whole point that two separate sources each throw away.
|
||||||
|
-- Hence the reversal the feature is really about -- (a) wins on small hits, the swap on big ones,
|
||||||
|
-- and far above the floor the two converge to within a rounding error of each other.
|
||||||
|
--
|
||||||
|
-- TIES for weakest raise exactly ONE source (strict > below): swapping one piece of gear is the
|
||||||
|
-- move being priced, and upgrading every tied source at once would price a shopping trip.
|
||||||
|
function G.compare(h, sources, p, out)
|
||||||
|
out = out or {}
|
||||||
|
p = p or 1
|
||||||
|
G.totals(h, sources, out) -- fills hits / heal / floorHeal / floorShare
|
||||||
|
out.p = p
|
||||||
|
|
||||||
|
copyInto(addBuf, sources)
|
||||||
|
table.insert(addBuf, p)
|
||||||
|
out.addHeal = G.totals(h, addBuf, addOut).heal
|
||||||
|
out.addDelta = out.addHeal - out.heal
|
||||||
|
out.addPct = 0
|
||||||
|
if out.heal > 0 then out.addPct = out.addDelta / out.heal * 100 end
|
||||||
|
|
||||||
|
local n = sources and table.getn(sources) or 0
|
||||||
|
local best, bestI, worst, worstI
|
||||||
|
for i = 1, n do
|
||||||
|
if best == nil or sources[i] > best then best, bestI = sources[i], i end
|
||||||
|
-- Strict <, so the FIRST of several equally weak sources wins and only that one is
|
||||||
|
-- swapped -- see the header. A <= here would keep sliding to the last tied index; still
|
||||||
|
-- one source, but it would make which piece is named depend on list order for no reason.
|
||||||
|
if worst == nil or sources[i] < worst then worst, worstI = sources[i], i end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- (c) the swap: the weakest source replaced by one p points stronger.
|
||||||
|
out.swapIndex, out.swapFrom = worstI, worst
|
||||||
|
if worstI then
|
||||||
|
out.swapTo = worst + p
|
||||||
|
copyInto(swapBuf, sources)
|
||||||
|
swapBuf[worstI] = swapBuf[worstI] + p
|
||||||
|
out.swapHeal = G.totals(h, swapBuf, swapOut).heal
|
||||||
|
out.swapDelta = out.swapHeal - out.heal
|
||||||
|
out.swapPct = 0
|
||||||
|
if out.heal > 0 then out.swapPct = out.swapDelta / out.heal * 100 end
|
||||||
|
else
|
||||||
|
-- Nothing equipped: there is no piece to replace. Same reasoning as (b) below -- "+0" would
|
||||||
|
-- read as a measurement rather than as "the question does not apply".
|
||||||
|
out.swapTo, out.swapHeal, out.swapDelta, out.swapPct = nil, out.heal, 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
out.upIndex, out.upFrom = bestI, best
|
||||||
|
if bestI then
|
||||||
|
out.upTo = best + p
|
||||||
|
copyInto(upBuf, sources)
|
||||||
|
upBuf[bestI] = upBuf[bestI] + p
|
||||||
|
out.upHeal = G.totals(h, upBuf, upOut).heal
|
||||||
|
out.upDelta = out.upHeal - out.heal
|
||||||
|
out.upPct = 0
|
||||||
|
if out.heal > 0 then out.upPct = out.upDelta / out.heal * 100 end
|
||||||
|
else
|
||||||
|
-- Nothing equipped: there is no source to make stronger, and saying "+0" would read as a
|
||||||
|
-- measurement rather than as "the question does not apply".
|
||||||
|
out.upTo, out.upHeal, out.upDelta, out.upPct = nil, out.heal, 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The verdict, decided between (a) and (c) ONLY -- the two things a player can actually go and
|
||||||
|
-- do. (b) is deliberately not in the running: it is an upper bound on what a point is worth,
|
||||||
|
-- not an option, and letting it win would answer a question nobody asked.
|
||||||
|
if out.addDelta > out.swapDelta then
|
||||||
|
out.winner, out.winnerBy = "add", out.addDelta - out.swapDelta
|
||||||
|
elseif out.swapDelta > out.addDelta then
|
||||||
|
out.winner, out.winnerBy = "swap", out.swapDelta - out.addDelta
|
||||||
|
else
|
||||||
|
out.winner, out.winnerBy = "tie", 0
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
-- Vampify -- dev-only per-hit debug export. Pure Lua ring buffer + line formatting (offline
|
||||||
|
-- tested), plus a thin session-local singleton and SuperWoW ExportFile flush.
|
||||||
|
--
|
||||||
|
-- WHAT THIS IS FOR. The v0.1/v0.2 formula (heal(D) = max(nSources, D * P * a)) uses ONE factor
|
||||||
|
-- per source, summed. Upgrading that to a PER-SOURCE factor needs exact measurements of what the
|
||||||
|
-- model predicted against what actually happened for every hit -- not the aggregated totals
|
||||||
|
-- /vf status already prints. This channel is that instrument: one line per own hit with the
|
||||||
|
-- inputs the model saw, the prediction it made, and the health readings around it, dumped to
|
||||||
|
-- <WoW>\imports\ for offline analysis.
|
||||||
|
--
|
||||||
|
-- INC / SELFHEAL / EXTHEAL RUN INDEPENDENTLY OF /vf watch AND GROUP STATUS (follow-up change
|
||||||
|
-- request, 2026-08-22). /vf watch on correctly REFUSES in a group -- the HP-reconciliation
|
||||||
|
-- BALANCE it computes is genuinely meaningless there (foreign healing, measured at +29..49%
|
||||||
|
-- phantom healing). That refusal is about watch's own verdict, not about whether the raw incoming/
|
||||||
|
-- healing numbers can be captured at all -- and this channel wants exactly those raw numbers,
|
||||||
|
-- group or not, watch-on or not, since they are what turns "some healing arrived and we don't know
|
||||||
|
-- why" into a measurable EXTHEAL line. So capture/incoming.lua now tracks two independent "wants"
|
||||||
|
-- (I.setWatchWanted / I.setPerHitWanted); whichever hit-line code in core/commands.lua feeds
|
||||||
|
-- PH.incoming/.selfheal/.extheal runs whenever EITHER wants the capture (I.isOn()), while the
|
||||||
|
-- watch balance itself (VampifyWatch.addWindow) stays gated on I.isWatchOn() specifically -- see
|
||||||
|
-- capture/incoming.lua's own header for the full mechanism and for the answer to "does Vampify see
|
||||||
|
-- foreign healing at all" (yes: SPELL_HEAL_BY_OTHER, always already registered; it was just being
|
||||||
|
-- folded into the self-heal total before this change, which is what the new EXTHEAL line fixes).
|
||||||
|
--
|
||||||
|
-- DEFAULT ON, PERSISTED (follow-up change request, 2026-08-22, overriding this addon's earlier
|
||||||
|
-- "dev-only, default off, never saved" stance for THIS specific channel by explicit developer
|
||||||
|
-- request): it is meant to run continuously across every session like a background capture addon's
|
||||||
|
-- capture, not be armed by hand. The persisted half of that --
|
||||||
|
-- VampifyDB.perhitEnabled and the auto-enable-on-login sync -- lives in core/commands.lua
|
||||||
|
-- (syncPerHit) and gui/options.lua (the "Per-hit debug export" checkbox); this module itself still
|
||||||
|
-- defaults its OWN in-memory VampifyPerHit._state to disabled (PH.newState()) and knows nothing
|
||||||
|
-- about VampifyConfig -- it only turns on when told to, the same as every other module here that
|
||||||
|
-- stays config-agnostic (core/model.lua, core/watch.lua). /vf perhit on|off|status
|
||||||
|
-- (core/commands.lua) remains the manual alternative and writes the same persisted field.
|
||||||
|
--
|
||||||
|
-- ARCHITECTURE. Same split as the rest of this addon: this file is pure and WoW-API-free except
|
||||||
|
-- for the disk flush at the bottom (mirrors core/const.lua's error capture and core/watch.lua's
|
||||||
|
-- watchLine, which are the same shape for the same reason). The actual per-hit DATA -- amount,
|
||||||
|
-- isAoE, spellId, the model's healFloat, sumPercent/nSources -- lives in core/commands.lua's
|
||||||
|
-- onDamage listener, which is "the only place where the pure core meets the WoW-bound capture
|
||||||
|
-- layer" (its own header comment). So commands.lua calls VampifyPerHit.hit(...)/.incoming(...)/
|
||||||
|
-- .selfheal(...)/.extheal(...)/.onTick(...) with values it already has; this file registers NO
|
||||||
|
-- event listeners of its own and needs no new ones in capture/*.lua.
|
||||||
|
--
|
||||||
|
-- hp_max (follow-up change request, 2026-08-22). Overheal windows are otherwise invisible to offline analysis: at
|
||||||
|
-- full health the Vampirism heal lands and immediately overflows, so hp_after - hp_before reads 0
|
||||||
|
-- even though pred_heal > 0 -- indistinguishable, from the numbers alone, from the heal genuinely
|
||||||
|
-- not landing (a real model deviation). hp_max fixes that: with hp_before/hp_max/pred_heal an
|
||||||
|
-- offline reader can tell "delta=0 because hp_before was already at/near hp_max" apart from
|
||||||
|
-- "delta=0 and hp_before had headroom -- something is actually wrong". Read via
|
||||||
|
-- UnitHealthMax("player") at the SAME point as hp_before (when the hit is recorded, not when the
|
||||||
|
-- window closes) -- hp_max does not change mid-fight under anything this addon cares about, so
|
||||||
|
-- there is no "before/after" question for it the way there is for hp itself, and reading it
|
||||||
|
-- alongside hp_before costs nothing new (no event, one more already-cheap API call at a point that
|
||||||
|
-- already reads UnitHealth).
|
||||||
|
--
|
||||||
|
-- hp_before / hp_after. Vampirism heals with NO event of its own (that is the addon's entire
|
||||||
|
-- reason to exist), so the only way to see it land is to read UnitHealth("player") before and
|
||||||
|
-- after. "Before" is the health at the moment this hit's damage event fires (before the healing
|
||||||
|
-- from THIS hit can possibly have landed). "After" is NOT available at that same instant --
|
||||||
|
-- 1.12's event order gives no signal for "the heal from this hit has now applied" -- so it is
|
||||||
|
-- read at the EARLIEST of two things that already exist in commands.lua: the next own damage
|
||||||
|
-- event (closing this hit's row the same way the HP-reconciliation watchdog already closes its
|
||||||
|
-- windows, core/watch.lua's "Stage 2" comment), or the next OnUpdate throttle tick (~0.25s) if no
|
||||||
|
-- further hit arrives in time. A hit right before combat ends is force-closed on
|
||||||
|
-- PLAYER_REGEN_ENABLED/PLAYER_DEAD (PH.closeWindow) so nothing is left pending across a long idle
|
||||||
|
-- period -- see PH.closeWindow's own comment for why that does NOT also force a disk write.
|
||||||
|
-- CONSEQUENCE FOR ANALYSIS: hp_after is a snapshot up to ~0.3s after the hit, not an
|
||||||
|
-- exact "immediately after this heal" read -- it can include a second hit's healing (or unrelated
|
||||||
|
-- incoming damage) if events land close together. Treat hp_after - hp_before as "what happened in
|
||||||
|
-- the window this hit opened", not as an exact isolation of this one heal; the /vf watch balance
|
||||||
|
-- (core/watch.lua) exists for the same reason and has the same caveat.
|
||||||
|
--
|
||||||
|
-- WHAT IS NOT COVERED. The retroactive AoE correction (VampifyDamage.onAoECorrection,
|
||||||
|
-- commands.lua) rewrites an already-recorded hit's healing after the fact once a second target
|
||||||
|
-- proves it was AoE -- but by the time that fires, this channel's line for the original hit may
|
||||||
|
-- already be flushed to disk. Reopening a flushed line is out of scope (this is an append-only
|
||||||
|
-- export, like every other channel in this addon); a hit that gets corrected will show its
|
||||||
|
-- ORIGINAL (non-AoE) aoe=0/pred_heal in its own HIT line. Cross-reference against
|
||||||
|
-- vampify_watch.txt / the session's own knowledge of which spells proc AoE if this matters to a
|
||||||
|
-- specific analysis.
|
||||||
|
|
||||||
|
VampifyPerHit = {}
|
||||||
|
local PH = VampifyPerHit
|
||||||
|
|
||||||
|
-- ---- pure: buffer + formatting ------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Hard safety cap on the in-memory buffer, independent of the flush thresholds below. Normal
|
||||||
|
-- operation flushes long before this; it exists so a flush that somehow never fires (ExportFile
|
||||||
|
-- missing, disk full) degrades into "oldest lines drop" instead of unbounded growth -- the same
|
||||||
|
-- discipline as core/watch.lua's WATCH_LOG_MAX, sized larger here because a per-hit line is
|
||||||
|
-- shorter and AoE grinding can produce 30-60 hits/sec.
|
||||||
|
local RING_CAP = 2000
|
||||||
|
PH.RING_CAP = RING_CAP
|
||||||
|
|
||||||
|
-- A hit is force-closed (hp_after read "now") if no further own hit arrives within this long.
|
||||||
|
-- Set just above the 0.25s display throttle commands.lua already runs PH.onTick from, so an
|
||||||
|
-- isolated hit's hp_after is captured on the very next tick rather than staying open until
|
||||||
|
-- whatever hit happens to come next (which could be seconds later, or never, in that fight).
|
||||||
|
local PENDING_TIMEOUT = 0.3
|
||||||
|
PH.PENDING_TIMEOUT = PENDING_TIMEOUT
|
||||||
|
|
||||||
|
function PH.newState()
|
||||||
|
return {
|
||||||
|
enabled = false,
|
||||||
|
sid = nil,
|
||||||
|
chunk = 0,
|
||||||
|
buf = {},
|
||||||
|
pending = nil,
|
||||||
|
totalHits = 0,
|
||||||
|
totalFlushed = 0,
|
||||||
|
elapsed = 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Only acts on a TRANSITION. Turning on (re)seeds sid/chunk/buffer/counters -- a fresh instrument
|
||||||
|
-- read, not a continuation of whatever a previous /vf perhit on..off cycle left behind. Turning
|
||||||
|
-- off leaves sid/chunk/buf alone on purpose: the caller (PH.disable) still needs them to flush
|
||||||
|
-- the final chunk under the SAME sid/chunk index the session was using.
|
||||||
|
function PH.setEnabled(state, on, sid)
|
||||||
|
on = on and true or false
|
||||||
|
if on == state.enabled then return state.enabled end
|
||||||
|
if on then
|
||||||
|
state.sid, state.chunk = sid, 0
|
||||||
|
VampifyConst.resetList(state.buf) -- see resetList's own comment: nil-ing indices alone
|
||||||
|
-- leaves table.getn reading a stale n in Lua 5.0
|
||||||
|
state.pending = nil
|
||||||
|
state.totalHits, state.totalFlushed, state.elapsed = 0, 0, 0
|
||||||
|
end
|
||||||
|
state.enabled = on
|
||||||
|
return state.enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
-- table.remove (not a hand-rolled shift) to evict the oldest line: it keeps `n` in step, the same
|
||||||
|
-- reasoning core/watch.lua's watchLine gives for using it over nil-ing indices by hand.
|
||||||
|
function PH.pushLine(state, line)
|
||||||
|
table.insert(state.buf, line)
|
||||||
|
if table.getn(state.buf) > RING_CAP then
|
||||||
|
table.remove(state.buf, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.formatHitLine(row)
|
||||||
|
return string.format(
|
||||||
|
"HIT|t=%.3f|src=%s|dmg=%d|aoe=%d|P=%.4f|n=%d|pred_heal=%.4f|hp_before=%s|hp_after=%s|"
|
||||||
|
.."hp_max=%s|acc_total=%.4f|crit=%d",
|
||||||
|
row.t, row.src, row.dmg, (row.aoe and 1 or 0), row.P, row.n, row.pred_heal,
|
||||||
|
tostring(row.hp_before), tostring(row.hp_after), tostring(row.hp_max), row.acc_total,
|
||||||
|
(row.crit and 1 or 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.formatIncomingLine(row)
|
||||||
|
return string.format("INC|t=%.3f|dmg=%.4f", row.t, row.dmg)
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.formatSelfHealLine(row)
|
||||||
|
return string.format("SELFHEAL|t=%.3f|heal=%.4f", row.t, row.heal)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Foreign healing landing on the player -- exactly the healing that contaminates a group /vf watch
|
||||||
|
-- window (see capture/incoming.lua's header comment), kept as its own line instead of folded into
|
||||||
|
-- SELFHEAL so it is measurable rather than invisible. Same shape/skip rule as formatSelfHealLine.
|
||||||
|
function PH.formatExternalHealLine(row)
|
||||||
|
return string.format("EXTHEAL|t=%.3f|heal=%.4f", row.t, row.heal)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Closes whatever hit is currently open (if any): stamps hp_after, formats and pushes its line,
|
||||||
|
-- clears the pending slot. Called from three places: the NEXT hit (below), the OnUpdate timeout
|
||||||
|
-- (PH.tick), and a forced flush (PH.flushNow/PH.disable) -- exactly one of these will ever close
|
||||||
|
-- a given pending row, since each clears it before returning.
|
||||||
|
function PH.finalizePending(state, hpNow)
|
||||||
|
local p = state.pending
|
||||||
|
if not p then return false end
|
||||||
|
p.hp_after = hpNow
|
||||||
|
PH.pushLine(state, PH.formatHitLine(p))
|
||||||
|
state.pending = nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- fields: t, src, dmg, aoe, P, n, pred_heal, acc_total, crit -- everything except the health
|
||||||
|
-- readings, which this function supplies itself: hp_before = hpNow and hp_max = hpMaxNow are both
|
||||||
|
-- stamped HERE, at the same point, and never touched again; hp_after comes later, at finalize.
|
||||||
|
function PH.recordHit(state, fields, hpNow, tNow, hpMaxNow)
|
||||||
|
PH.finalizePending(state, hpNow)
|
||||||
|
state.pending = {
|
||||||
|
t = fields.t, src = fields.src, dmg = fields.dmg, aoe = fields.aoe,
|
||||||
|
P = fields.P, n = fields.n, pred_heal = fields.pred_heal,
|
||||||
|
acc_total = fields.acc_total, crit = fields.crit,
|
||||||
|
hp_before = hpNow, hp_max = hpMaxNow, pendingAt = tNow,
|
||||||
|
}
|
||||||
|
state.totalHits = state.totalHits + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Force-closes a pending hit once it has sat open longer than PENDING_TIMEOUT with no follow-up
|
||||||
|
-- event to close it the normal way. Returns true if it actually closed one, for the tests.
|
||||||
|
function PH.tick(state, hpNow, tNow)
|
||||||
|
if state.pending and (tNow - state.pending.pendingAt) >= PENDING_TIMEOUT then
|
||||||
|
return PH.finalizePending(state, hpNow)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Zero/nil incoming damage or self-heal in a window is the common case (most windows have
|
||||||
|
-- neither) and is skipped rather than emitted as a line of noise -- an absent INC/SELFHEAL line
|
||||||
|
-- for a given HIT IS the "nothing happened" signal for offline analysis.
|
||||||
|
function PH.recordIncoming(state, t, dmg)
|
||||||
|
if not dmg or dmg == 0 then return false end
|
||||||
|
PH.pushLine(state, PH.formatIncomingLine({ t = t, dmg = dmg }))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.recordSelfHeal(state, t, heal)
|
||||||
|
if not heal or heal <= 0 then return false end
|
||||||
|
PH.pushLine(state, PH.formatSelfHealLine({ t = t, heal = heal }))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.recordExternalHeal(state, t, heal)
|
||||||
|
if not heal or heal <= 0 then return false end
|
||||||
|
PH.pushLine(state, PH.formatExternalHealLine({ t = t, heal = heal }))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.chunkName(sid, chunkIdx)
|
||||||
|
return "vampify_perhit_" .. tostring(sid) .. "_" .. tostring(chunkIdx)
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.chunkHeader(version, sid, chunkIdx, lineCount)
|
||||||
|
return string.format("addon=Vampify version=%s chunk=%d sid=%s lines=%d",
|
||||||
|
tostring(version), chunkIdx, tostring(sid), lineCount)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure half of a flush: builds the chunk's name and full text (header + every buffered line),
|
||||||
|
-- advances the chunk index, and empties the buffer -- all without touching ExportFile, so it is
|
||||||
|
-- testable without any WoW stub. Returns (nil, nil) when there is nothing to flush.
|
||||||
|
function PH.takeFlushText(state, version)
|
||||||
|
local lineCount = table.getn(state.buf)
|
||||||
|
if lineCount == 0 then return nil, nil end
|
||||||
|
local name = PH.chunkName(state.sid, state.chunk)
|
||||||
|
local header = PH.chunkHeader(version, state.sid, state.chunk, lineCount)
|
||||||
|
local text = header .. "\n" .. table.concat(state.buf, "\n") .. "\n"
|
||||||
|
state.chunk = state.chunk + 1
|
||||||
|
state.totalFlushed = state.totalFlushed + lineCount
|
||||||
|
VampifyConst.resetList(state.buf)
|
||||||
|
return name, text
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- session-local singleton + disk flush --------------------------------------------------
|
||||||
|
--
|
||||||
|
-- The module's OWN default is still disabled -- every addon load starts VampifyPerHit._state at
|
||||||
|
-- PH.newState()'s enabled=false, and this file never reads VampifyConfig. What makes the channel
|
||||||
|
-- actually run every session is core/commands.lua's syncPerHit(), called on every
|
||||||
|
-- PLAYER_LOGIN/PLAYER_ENTERING_WORLD, which reads VampifyDB.perhitEnabled (core/config.lua,
|
||||||
|
-- default true) and calls PH.enable()/.disable() accordingly -- the same "module stays
|
||||||
|
-- config-agnostic, the wiring layer bridges to SavedVariables" split core/model.lua and the rest
|
||||||
|
-- of core/*.lua already use.
|
||||||
|
|
||||||
|
VampifyPerHit._state = PH.newState()
|
||||||
|
|
||||||
|
-- Flush when the buffer reaches this many lines, or this many seconds have passed, whichever
|
||||||
|
-- comes first -- the same two-trigger shape (flush-on-count / flush-on-timer) used by a companion
|
||||||
|
-- capture addon's own chunk-rotation library, the reference pattern this mirrors. Deliberately NOT
|
||||||
|
-- reusing that library itself: this channel needs neither its chunked wire protocol (nothing
|
||||||
|
-- ingests these chunks automatically) nor its multi-producer plumbing, and pulling it in would be
|
||||||
|
-- exactly the kind of heavy new subsystem this channel is meant to avoid.
|
||||||
|
--
|
||||||
|
-- SIZED FOR PERMANENT OPERATION (follow-up change request, 2026-08-22: default on, runs every session, not
|
||||||
|
-- just an armed-by-hand dev probe). LibEmpBus's own FLUSH_SEC=2 is right for ITS job -- feeding a
|
||||||
|
-- near-realtime backend watcher -- but nothing ingests these chunks automatically, and the addon
|
||||||
|
-- cannot delete old ones (no filesystem delete from Lua). A short interval that flushed whatever
|
||||||
|
-- was buffered, even one line, would turn "grind mobs one at a time so combat drops between
|
||||||
|
-- pulls" into a file per kill. 500/60s bounds a busy AoE pull to a handful of chunks (a line is
|
||||||
|
-- ~130 bytes; 500 lines is ~65 KB) while keeping the worst-case unflushed tail small if the
|
||||||
|
-- client vanishes without a clean logout (PLAYER_CAMPING/PLAYER_QUITING force a flush too, see
|
||||||
|
-- core/commands.lua). See the addon's PR notes for the resulting bytes/hour estimate.
|
||||||
|
PH.FLUSH_LINES = 500
|
||||||
|
PH.FLUSH_SECS = 60
|
||||||
|
|
||||||
|
local function flushToDisk()
|
||||||
|
if not ExportFile then return end -- SuperWoW only; harmless without it
|
||||||
|
local name, text = PH.takeFlushText(VampifyPerHit._state, VampifyConst.VERSION)
|
||||||
|
-- ExportFile appends .txt itself -- name must NOT already carry it (see core/const.lua's
|
||||||
|
-- writeErrors for the same gotcha).
|
||||||
|
if name then ExportFile(name, text) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.isEnabled()
|
||||||
|
return VampifyPerHit._state.enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.status()
|
||||||
|
local s = VampifyPerHit._state
|
||||||
|
return {
|
||||||
|
enabled = s.enabled,
|
||||||
|
sid = s.sid,
|
||||||
|
chunk = s.chunk,
|
||||||
|
buffered = table.getn(s.buf),
|
||||||
|
totalHits = s.totalHits,
|
||||||
|
totalFlushed = s.totalFlushed,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- time() is a WoW global (used the same way by a companion capture addon); math.floor(GetTime())
|
||||||
|
-- is the fallback for an environment that somehow has GetTime but not time(). Session id only
|
||||||
|
-- has to be unique enough to tell two /vf perhit on sessions' chunks apart on disk, not globally
|
||||||
|
-- unique.
|
||||||
|
function PH.enable()
|
||||||
|
if PH.isEnabled() then return false end
|
||||||
|
local sid = time and time() or math.floor(GetTime and GetTime() or 0)
|
||||||
|
PH.setEnabled(VampifyPerHit._state, true, sid)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Finalizes whatever hit is still open and force-flushes the current chunk, THEN turns the
|
||||||
|
-- channel off -- so /vf perhit off never strands the last hit's hp_after in memory.
|
||||||
|
function PH.disable(hpNow)
|
||||||
|
if not PH.isEnabled() then return false end
|
||||||
|
PH.finalizePending(VampifyPerHit._state, hpNow)
|
||||||
|
PH.setEnabled(VampifyPerHit._state, false)
|
||||||
|
flushToDisk()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Same finalize+flush as disable(), without turning the channel off -- for a point that needs a
|
||||||
|
-- GUARANTEED write regardless of the size/time thresholds (currently only /vf perhit off and a
|
||||||
|
-- real logout/camp -- see core/commands.lua's PLAYER_CAMPING/PLAYER_QUITING handling).
|
||||||
|
function PH.flushNow(hpNow)
|
||||||
|
if not PH.isEnabled() then return end
|
||||||
|
PH.finalizePending(VampifyPerHit._state, hpNow)
|
||||||
|
flushToDisk()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function maybeFlush()
|
||||||
|
if table.getn(VampifyPerHit._state.buf) >= PH.FLUSH_LINES then flushToDisk() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Finalizes a pending hit WITHOUT forcing a disk write -- for combat end. A hit right before
|
||||||
|
-- PLAYER_REGEN_ENABLED must not be left open across what could be a long idle gap until the next
|
||||||
|
-- fight (same reasoning as flushNow), but forcing a chunk to disk on every single fight is exactly
|
||||||
|
-- the "file per kill" permanent-operation problem FLUSH_LINES/FLUSH_SECS above exist to avoid --
|
||||||
|
-- several short fights' hits accumulate in the SAME buffer until one of those thresholds fires
|
||||||
|
-- naturally. maybeFlush() still applies: a fight big enough to cross FLUSH_LINES on its own closes
|
||||||
|
-- its own chunk immediately, same as it would mid-fight.
|
||||||
|
function PH.closeWindow(hpNow)
|
||||||
|
if not PH.isEnabled() then return end
|
||||||
|
PH.finalizePending(VampifyPerHit._state, hpNow)
|
||||||
|
maybeFlush()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- fields: see PH.recordHit. Cost when the channel is off is one isEnabled() check and nothing
|
||||||
|
-- else -- no table built, no line formatted.
|
||||||
|
function PH.hit(fields, hpNow, tNow, hpMaxNow)
|
||||||
|
if not PH.isEnabled() then return end
|
||||||
|
PH.recordHit(VampifyPerHit._state, fields, hpNow, tNow, hpMaxNow)
|
||||||
|
maybeFlush()
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.incoming(t, dmg)
|
||||||
|
if not PH.isEnabled() then return end
|
||||||
|
if PH.recordIncoming(VampifyPerHit._state, t, dmg) then maybeFlush() end
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.selfheal(t, heal)
|
||||||
|
if not PH.isEnabled() then return end
|
||||||
|
if PH.recordSelfHeal(VampifyPerHit._state, t, heal) then maybeFlush() end
|
||||||
|
end
|
||||||
|
|
||||||
|
function PH.extheal(t, heal)
|
||||||
|
if not PH.isEnabled() then return end
|
||||||
|
if PH.recordExternalHeal(VampifyPerHit._state, t, heal) then maybeFlush() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Driven from the SAME 0.25s OnUpdate throttle core/commands.lua already runs everything else
|
||||||
|
-- from -- no new frame, per the project's OnUpdate-allocation rule. dt (elapsed since the last
|
||||||
|
-- tick) feeds the time-based flush trigger; hpNow/tNow close a pending hit that never got a
|
||||||
|
-- follow-up event (PH.tick's PENDING_TIMEOUT).
|
||||||
|
function PH.onTick(hpNow, tNow, dt)
|
||||||
|
if not PH.isEnabled() then return end
|
||||||
|
PH.tick(VampifyPerHit._state, hpNow, tNow)
|
||||||
|
local s = VampifyPerHit._state
|
||||||
|
s.elapsed = s.elapsed + (dt or 0)
|
||||||
|
if s.elapsed >= PH.FLUSH_SECS then
|
||||||
|
s.elapsed = 0
|
||||||
|
flushToDisk()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Vampify -- optional floating combat text, via Blizzard's own Blizzard_CombatText.
|
||||||
|
--
|
||||||
|
-- One of two SCT engines (the other is gui/sct.lua), selected by VampifyDB.sct.mode ==
|
||||||
|
-- "blizzard" and dispatched from core/commands.lua. Blizzard's engine owns its own position and
|
||||||
|
-- timing -- an addon cannot move or slow it down -- but CombatText_AddMessage takes an explicit
|
||||||
|
-- r,g,b, so the user's chosen SCT color (VampifyDB.sct.color) applies here too. That is the one
|
||||||
|
-- thing this engine and gui/sct.lua share.
|
||||||
|
--
|
||||||
|
-- There is no ambient FCT to hook: Blizzard_CombatText is LoadOnDemand ("## LoadOnDemand: 1") and
|
||||||
|
-- off by default (SHOW_COMBAT_TEXT is a saved variable, not a CVar), and no SCT/MSBT is installed.
|
||||||
|
-- So we write into Blizzard's FCT only if it is actually there, and are otherwise silent.
|
||||||
|
--
|
||||||
|
-- Signature verified against Blizzard 1.12.1 FrameXML, Blizzard_CombatText.lua:291 --
|
||||||
|
-- CombatText_AddMessage(message, scrollFunction, r, g, b, displayType, isStaggered)
|
||||||
|
-- The function does NOT itself honour SHOW_COMBAT_TEXT (that check lives in its event path), so we
|
||||||
|
-- honour it here. COMBAT_TEXT_SCROLL_FUNCTION is only assigned during that addon's setup and can
|
||||||
|
-- be nil even when the function exists, hence the fallback.
|
||||||
|
--
|
||||||
|
-- Increments of 0 are NOT emitted: the accumulator does not cross an integer on every hit. That is
|
||||||
|
-- also what keeps the number rate readable without a merge rule -- six sources cannot produce six
|
||||||
|
-- numbers per hit, because there is only ever one crossing.
|
||||||
|
|
||||||
|
VampifyFct = {}
|
||||||
|
local F = VampifyFct
|
||||||
|
|
||||||
|
function F.emit(increment, isCrit)
|
||||||
|
if not increment or increment <= 0 then return end
|
||||||
|
local cfg = VampifyConfig.get()
|
||||||
|
if not cfg or not cfg.sct or not cfg.sct.enabled or cfg.sct.mode ~= "blizzard" then return end
|
||||||
|
if not CombatText_AddMessage then return end
|
||||||
|
if SHOW_COMBAT_TEXT ~= "1" then return end
|
||||||
|
local scroll = COMBAT_TEXT_SCROLL_FUNCTION or CombatText_StandardScroll
|
||||||
|
local col = cfg.sct.color or { r = 0.4, g = 0.9, b = 0.4 }
|
||||||
|
CombatText_AddMessage("+"..increment, scroll, col.r, col.g, col.b)
|
||||||
|
end
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
-- Vampify -- minimap button, ported from DopingControl's features/minimap.lua as closely as
|
||||||
|
-- possible: that file's shape is in-game verified on 1.12, and its comments below carry facts that
|
||||||
|
-- must not be changed on the way over.
|
||||||
|
--
|
||||||
|
-- Design facts (in-game verified on 1.12, DopingControl):
|
||||||
|
-- - Hand-rolled, no LibDBIcon on 1.12.
|
||||||
|
-- - pfUI-safe: parent = Minimap, frame NAME contains "Minimap", SetFrameStrata("HIGH") +
|
||||||
|
-- SetFrameLevel(9) -- MEDIUM hides under pfUI (the exact failure this addon's own display frame
|
||||||
|
-- hit at strata MEDIUM, see gui/display.lua).
|
||||||
|
-- - Border recipe (vanilla FrameXML MiniMapTrackingButton): 54px MiniMap-TrackingBorder anchored
|
||||||
|
-- TOPLEFT(0,0) on a 33px button. The ring is deliberately off-centre within the texture -- do NOT
|
||||||
|
-- centre it.
|
||||||
|
-- - Drag + click coexist: drag flag on OnDragStart/Stop, plain clicks still fire OnClick.
|
||||||
|
-- - Radius = Minimap:GetWidth()/2 + 5 (follows resized minimaps).
|
||||||
|
-- - Highlight texture is "UI-Minimap-ZoomButton-Highlight" WITH the hyphen before "Highlight" --
|
||||||
|
-- confirmed against FrameXML-1.12.1/FrameXML/Minimap.xml. The un-hyphenated spelling does not
|
||||||
|
-- exist as a file and would render nothing.
|
||||||
|
--
|
||||||
|
-- Left click toggles the display frame (same code path as the "Show display" checkbox in
|
||||||
|
-- gui/options.lua -- one definition of "is it on", the frame's own IsShown()); right click opens
|
||||||
|
-- the options window.
|
||||||
|
--
|
||||||
|
-- Pure-Lua 5.0 dofile-loadable: all WoW wiring behind "if CreateFrame then"; BuildButton/PlaceButton
|
||||||
|
-- only touch the WoW API when called at runtime.
|
||||||
|
|
||||||
|
VampifyMinimap = {}
|
||||||
|
local M = VampifyMinimap
|
||||||
|
|
||||||
|
local button = nil
|
||||||
|
|
||||||
|
-- NOTE, measured in-game 2026-08-10 on this client: pfUI COLLECTS addon minimap buttons into its
|
||||||
|
-- own `pfMinimapButtons` panel -- it reparents the button and owns its anchor from then on. So on a
|
||||||
|
-- pfUI setup the angle below (and dragging around the ring) has no effect, and that is fine: the
|
||||||
|
-- collected panel is where a pfUI user expects to find addon buttons. The code stays because it is
|
||||||
|
-- what places the button correctly WITHOUT pfUI. Do not "fix" the placement by fighting pfUI for
|
||||||
|
-- the anchor.
|
||||||
|
--
|
||||||
|
-- Corollary for anyone debugging position here: never compare GetLeft/GetCenter across frames
|
||||||
|
-- without converting through GetEffectiveScale first. Once pfUI adopts the button, its effective
|
||||||
|
-- scale (0.47) differs from the Minimap's (0.71), and raw coordinates look wildly wrong while the
|
||||||
|
-- button sits exactly where it should.
|
||||||
|
local function PlaceButton(angleDeg)
|
||||||
|
if not button then return end
|
||||||
|
local radius = (Minimap:GetWidth() / 2) + 5
|
||||||
|
local rad = (angleDeg or 0) * math.pi / 180
|
||||||
|
button:ClearAllPoints()
|
||||||
|
button:SetPoint("CENTER", Minimap, "CENTER", math.cos(rad) * radius, math.sin(rad) * radius)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- WoW wiring ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if CreateFrame then
|
||||||
|
|
||||||
|
-- The frame's own IsShown() answers "is it on screen right now", which is what a toggle has to
|
||||||
|
-- flip -- but it is NOT where the answer is kept. core/commands.lua re-decides visibility on
|
||||||
|
-- every loading screen and every gear scan, so a hide that lived only in the frame lasted until
|
||||||
|
-- the next portal. The click therefore records the intent in VampifyDB.shown (the "Show
|
||||||
|
-- display" checkbox in gui/options.lua reads that same field, so the two cannot drift) and acts
|
||||||
|
-- on the frame afterwards.
|
||||||
|
function M.toggleDisplay()
|
||||||
|
local f = getglobal("VampifyDisplayFrame")
|
||||||
|
local on = not (f and f:IsShown())
|
||||||
|
local cfg = VampifyConfig.get()
|
||||||
|
if cfg then cfg.shown = on end
|
||||||
|
if on then VampifyDisplay.show() else VampifyDisplay.hide() end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function BuildButton()
|
||||||
|
if button then return end
|
||||||
|
local btn = CreateFrame("Button", "VampifyMinimapButton", Minimap)
|
||||||
|
btn:SetWidth(33); btn:SetHeight(33)
|
||||||
|
btn:SetFrameStrata("HIGH"); btn:SetFrameLevel(9)
|
||||||
|
|
||||||
|
local icon = btn:CreateTexture("VampifyMinimapIcon", "ARTWORK")
|
||||||
|
icon:SetTexture("Interface\\Icons\\Spell_Shadow_LifeDrain02")
|
||||||
|
icon:SetWidth(20); icon:SetHeight(20)
|
||||||
|
icon:SetPoint("CENTER", btn, "CENTER", 0, 0)
|
||||||
|
icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) -- standard action-button crop
|
||||||
|
|
||||||
|
local border = btn:CreateTexture("VampifyMinimapBorder", "OVERLAY")
|
||||||
|
border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
|
||||||
|
border:SetWidth(54); border:SetHeight(54)
|
||||||
|
border:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
|
||||||
|
|
||||||
|
btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
|
||||||
|
btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
|
||||||
|
btn:RegisterForDrag("LeftButton")
|
||||||
|
|
||||||
|
btn:SetScript("OnDragStart", function() this.isDragging = true end)
|
||||||
|
btn:SetScript("OnDragStop", function() this.isDragging = false end)
|
||||||
|
btn:SetScript("OnUpdate", function()
|
||||||
|
if not this.isDragging then return end
|
||||||
|
local mx, my = Minimap:GetCenter()
|
||||||
|
local cx, cy = GetCursorPosition()
|
||||||
|
local scale = Minimap:GetEffectiveScale()
|
||||||
|
cx, cy = cx / scale, cy / scale
|
||||||
|
-- atan2, computed by hand rather than trusting math.deg to be present: the
|
||||||
|
-- VampifyDB.mmAngle contract downstream (PlaceButton) is degrees, so this returns
|
||||||
|
-- degrees too.
|
||||||
|
local angle = math.atan2(cy - my, cx - mx) * 180 / math.pi
|
||||||
|
local cfg = VampifyConfig.get()
|
||||||
|
if cfg then cfg.mmAngle = angle end -- persist live while dragging
|
||||||
|
PlaceButton(angle)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- LEFT = display toggle, RIGHT = options.
|
||||||
|
btn:SetScript("OnClick", function()
|
||||||
|
if arg1 == "RightButton" then
|
||||||
|
VampifyOptions.toggle()
|
||||||
|
else
|
||||||
|
M.toggleDisplay()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
btn:SetScript("OnEnter", function()
|
||||||
|
GameTooltip:SetOwner(this, "ANCHOR_LEFT")
|
||||||
|
GameTooltip:SetText("Vampify", 1, 1, 1)
|
||||||
|
local fight = VampifyAggregate.fight(VampifyState)
|
||||||
|
GameTooltip:AddLine(string.format("%d healed this fight, %.1f%%",
|
||||||
|
math.floor(fight.heal or 0), (fight.pct or 0) * 100), 0.9, 0.9, 0.9)
|
||||||
|
GameTooltip:AddLine("Left click: show / hide the display", 0.8, 0.8, 0.8)
|
||||||
|
GameTooltip:AddLine("Right click: options", 0.8, 0.8, 0.8)
|
||||||
|
GameTooltip:AddLine("Drag: move around the minimap", 0.8, 0.8, 0.8)
|
||||||
|
GameTooltip:Show()
|
||||||
|
end)
|
||||||
|
btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
||||||
|
|
||||||
|
button = btn
|
||||||
|
local cfg = VampifyConfig.get()
|
||||||
|
PlaceButton((cfg and cfg.mmAngle) or VampifyConfig.DEFAULTS.mmAngle)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Honours the "Minimap button" checkbox (VampifyDB.minimap); gui/options.lua calls this after
|
||||||
|
-- toggling the setting.
|
||||||
|
function M.update()
|
||||||
|
local cfg = VampifyConfig.get()
|
||||||
|
if not cfg then return end
|
||||||
|
if not cfg.minimap then
|
||||||
|
if button then button:Hide() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
BuildButton()
|
||||||
|
PlaceButton(cfg.mmAngle)
|
||||||
|
button:Show()
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
@@ -0,0 +1,806 @@
|
|||||||
|
-- Vampify -- options window, built in DopingControl's visual language (gui/widgets.lua) so the two
|
||||||
|
-- addons read as the same toolkit rather than as a Blizzard dialog bolted onto a hand-rolled one.
|
||||||
|
--
|
||||||
|
-- Layout follows DopingControl's ui/options.lua pattern: a lazily-built frame, a layout cursor that
|
||||||
|
-- places widgets top-down, checkboxes with getter/setter pairs repopulated on OnShow, buttons via
|
||||||
|
-- VampifyWidgets.MakeButton, and a throttled info panel that only repaints text that changed
|
||||||
|
-- (VampifyWidgets.SetTextG). The info panel itself is specific to this addon -- there is no grid
|
||||||
|
-- here, just the source list and the honesty lines about what the number does and does not mean.
|
||||||
|
--
|
||||||
|
-- Pure-Lua 5.0 dofile-loadable: all WoW wiring behind "if CreateFrame then".
|
||||||
|
|
||||||
|
VampifyOptions = {}
|
||||||
|
local O = VampifyOptions
|
||||||
|
|
||||||
|
-- ---- WoW wiring ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if CreateFrame then
|
||||||
|
local W = VampifyWidgets
|
||||||
|
local C = W.COLORS
|
||||||
|
|
||||||
|
local frame
|
||||||
|
local checkboxes = {}
|
||||||
|
local infoFS
|
||||||
|
local elapsed = 0
|
||||||
|
local shareBuf = {}
|
||||||
|
|
||||||
|
-- SCT color/position controls -- declared here (not local to build()) so refreshSwatch() and
|
||||||
|
-- refreshEnabled() below can reach them from OnShow/OnUpdate as well as from the checkboxes'
|
||||||
|
-- own setters. sliderPillH/sliderPanelBarH (2026-08-25) join the same list for the same reason
|
||||||
|
-- -- refreshSliders() below needs to push their persisted values in on OnShow, same as
|
||||||
|
-- sliderFont/sliderRise already do.
|
||||||
|
local swatch, btnMove, btnPosReset, posLabel, sliderFont, sliderRise, btnDemo, btnOutline,
|
||||||
|
sliderPillH, sliderPanelBarH
|
||||||
|
|
||||||
|
local function db() return VampifyConfig.get() end
|
||||||
|
|
||||||
|
-- Same merge as core/commands.lua's recompute(): detected sources plus manual overrides. Kept
|
||||||
|
-- here rather than reading commands.lua's private locals (there is nothing exported to read),
|
||||||
|
-- so the info panel's Total/Floor line matches what the addon actually uses to size a hit.
|
||||||
|
local function totals()
|
||||||
|
local sources = VampifyDetect.getSources()
|
||||||
|
local sum, n = VampifyDetect.summarise(sources)
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return sources, sum, n
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- widget helpers
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local checkCounter = 0
|
||||||
|
local function CreateCheckbox(parent, label, getter, setter)
|
||||||
|
checkCounter = checkCounter + 1
|
||||||
|
local name = "VampifyOptionsCheck" .. checkCounter
|
||||||
|
local cb = CreateFrame("CheckButton", name, parent, "UICheckButtonTemplate")
|
||||||
|
cb:SetWidth(24)
|
||||||
|
cb:SetHeight(24)
|
||||||
|
local text = getglobal(name .. "Text")
|
||||||
|
text:SetText(label)
|
||||||
|
W.ApplyFont(text, 11)
|
||||||
|
W.SetTextColorG(text, C.ink)
|
||||||
|
cb.vfLabelFS = text -- exposed so a caller can chain another widget off the label's
|
||||||
|
-- right edge (measured, not estimated -- see the SCT engine row)
|
||||||
|
cb.vfGet = getter
|
||||||
|
cb.vfSet = setter
|
||||||
|
cb:SetScript("OnClick", function()
|
||||||
|
this.vfSet(this:GetChecked() == 1) -- GetChecked returns 1 | nil
|
||||||
|
end)
|
||||||
|
table.insert(checkboxes, cb)
|
||||||
|
return cb
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- info panel text -- rebuilt only when this fires (throttled OnUpdate), never per damage event
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local infoLines = {}
|
||||||
|
local floorBuf = {} -- reused result table for the floor-share line below
|
||||||
|
|
||||||
|
-- The upgrade verdict, THROTTLED to once a second and cached as a finished string.
|
||||||
|
--
|
||||||
|
-- WHY THROTTLED RATHER THAN COMPUTED ONCE ON OPEN. VampifyHistogram.compare is four passes over
|
||||||
|
-- the whole recorded distribution -- the baseline plus three hypotheticals -- and this panel
|
||||||
|
-- repaints twice a second for as long as it is open, so computing it per repaint would spend
|
||||||
|
-- eight passes a second on a line whose answer moves about as fast as the player's gear does.
|
||||||
|
-- But once-on-open would be worse in the case that matters: the panel is left open while
|
||||||
|
-- fighting, the distribution keeps filling, and a verdict frozen at the moment of opening would
|
||||||
|
-- be stale exactly when the player is watching it change. Once a second is two orders of
|
||||||
|
-- magnitude cheaper than the alternative and still live.
|
||||||
|
--
|
||||||
|
-- Everything is held in ONE table rather than three locals on purpose: this file's enclosing
|
||||||
|
-- closure is already wide, and Lua 5.0 caps locals and upvalues per function.
|
||||||
|
local cmp = { buf = {}, text = nil, at = nil }
|
||||||
|
local function upgradeLine()
|
||||||
|
local now = GetTime and GetTime() or nil
|
||||||
|
-- No GetTime (offline/stub): compute once and keep it, rather than every repaint.
|
||||||
|
if cmp.text and (not now or (cmp.at and (now - cmp.at) < 1)) then return cmp.text end
|
||||||
|
cmp.at, cmp.text = now, ""
|
||||||
|
if not (VampifyHistogram and VampifyState and VampifyState.hist) then return cmp.text end
|
||||||
|
-- Always at +1 point here. The panel states the CHOICE, not a shopping calculator; a player
|
||||||
|
-- who wants a different step has /vf upgrade <pct>, which prints both scopes as well.
|
||||||
|
local c = VampifyHistogram.compare(VampifyState.hist, VampifyState.sourcePercents, 1, cmp.buf)
|
||||||
|
if not (c.hits and c.hits > 0) then return cmp.text end
|
||||||
|
if c.winner == "swap" and c.swapIndex then
|
||||||
|
cmp.text = string.format("Upgrade: swap your weakest source (%d%% -> %d%%) - worth"
|
||||||
|
.." +%d HP here against +%d for one more 1%% source",
|
||||||
|
c.swapFrom, c.swapTo, math.floor(c.swapDelta), math.floor(c.addDelta))
|
||||||
|
elseif c.winner == "add" then
|
||||||
|
cmp.text = string.format("Upgrade: one more 1%% source - worth +%d HP here against"
|
||||||
|
.." +%d for swapping your weakest one",
|
||||||
|
math.floor(c.addDelta), math.floor(c.swapDelta))
|
||||||
|
else
|
||||||
|
cmp.text = string.format("Upgrade: one more 1%% source and swapping your weakest one"
|
||||||
|
.." are worth the same here (+%d HP each)", math.floor(c.addDelta))
|
||||||
|
end
|
||||||
|
return cmp.text
|
||||||
|
end
|
||||||
|
|
||||||
|
local function composeInfo()
|
||||||
|
VampifyConst.resetList(infoLines) -- nil-ing indices is not enough in 5.0; see const.lua
|
||||||
|
|
||||||
|
local sources, sumPercent, nSources = totals()
|
||||||
|
local n = table.getn(sources)
|
||||||
|
for i = 1, n do
|
||||||
|
table.insert(infoLines, string.format("Slot %d: %d%% (%s)",
|
||||||
|
sources[i].slot, sources[i].percent, sources[i].kind))
|
||||||
|
end
|
||||||
|
table.insert(infoLines, string.format("Total: %.0f%% from %d sources", sumPercent * 100, nSources))
|
||||||
|
table.insert(infoLines, string.format("Floor: %d HP per hit", nSources))
|
||||||
|
-- How much of this session's healing the floor ACTUALLY paid, over the recorded hit
|
||||||
|
-- distribution (core/histogram.lua) rather than as a per-hit minimum. A high share means
|
||||||
|
-- the hits are small enough that every source is pinned at 1 HP, which is the regime where
|
||||||
|
-- one MORE source beats swapping a weak one out. One pass over the distribution, cheap
|
||||||
|
-- enough to run on every repaint; the verdict line below it is the expensive part and is
|
||||||
|
-- throttled separately (see upgradeLine).
|
||||||
|
if VampifyHistogram and VampifyState and VampifyState.hist then
|
||||||
|
local t = VampifyHistogram.totals(VampifyState.hist, VampifyState.sourcePercents, floorBuf)
|
||||||
|
if t.hits > 0 then
|
||||||
|
table.insert(infoLines, string.format(
|
||||||
|
"Floor share: %d of %d HP (%.0f%%) over %d hits",
|
||||||
|
math.floor(t.floorHeal), math.floor(t.heal), t.floorShare * 100, t.hits))
|
||||||
|
end
|
||||||
|
-- The decision itself, so it is readable without typing anything. /vf upgrade still
|
||||||
|
-- gives the full report: both scopes, a chosen step size, and the ceiling figure.
|
||||||
|
local up = upgradeLine()
|
||||||
|
if up ~= "" then table.insert(infoLines, up) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local fight = VampifyAggregate.fight(VampifyState)
|
||||||
|
local session = VampifyAggregate.session(VampifyState)
|
||||||
|
local eff = VampifyState.effHeal or 0
|
||||||
|
local overheal = (fight.heal or 0) - eff
|
||||||
|
if overheal < 0 then overheal = 0 end
|
||||||
|
local ohPct = 0
|
||||||
|
if fight.heal and fight.heal > 0 then ohPct = overheal / fight.heal * 100 end
|
||||||
|
|
||||||
|
table.insert(infoLines, string.format("Fight: %d healed, %.2f%%",
|
||||||
|
math.floor(fight.heal or 0), (fight.pct or 0) * 100))
|
||||||
|
-- Overheal is CALCULATED from the health deficit at hit time, not measured -- Vampirism
|
||||||
|
-- emits no heal event. The line right below says so; do not let the number alone imply
|
||||||
|
-- more precision than that.
|
||||||
|
table.insert(infoLines, string.format("Effective: %d healed, %d overheal (%.0f%%)",
|
||||||
|
math.floor(eff), math.floor(overheal), ohPct))
|
||||||
|
table.insert(infoLines, "|cff" .. string.format("%02x%02x%02x",
|
||||||
|
C.faint.r * 255, C.faint.g * 255, C.faint.b * 255)
|
||||||
|
.. "Overheal is derived from the health deficit at the time of the hit, not from a"
|
||||||
|
.. " heal event - Vampirism emits none.|r")
|
||||||
|
table.insert(infoLines, string.format("Session: %d healed, %.2f%%",
|
||||||
|
math.floor(session.heal or 0), (session.pct or 0) * 100))
|
||||||
|
|
||||||
|
local flag = VampifyDisplay.boundFlag(VampifyConst.FORMULA.channels, VampifyDamage.aoeAvailable())
|
||||||
|
if flag == "lower" or flag == "uncertain" then
|
||||||
|
table.insert(infoLines, "|cffe0c98aLower bound: DoT and PvP damage are unmeasured and excluded.|r")
|
||||||
|
end
|
||||||
|
if flag == "upper" or flag == "uncertain" then
|
||||||
|
table.insert(infoLines, "|cffe0c98aUpper bound: no SPELL_GO seen, AoE damping not applied.|r")
|
||||||
|
end
|
||||||
|
if not VampifyDamage.damageSeen() then
|
||||||
|
table.insert(infoLines, "|cffd05252No damage events received - nampower may be absent.|r")
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(infoLines, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The info panel grows and shrinks with what it has to say: the source list is as long as the
|
||||||
|
-- player's gear, and the honesty lines come and go. A fixed frame height therefore cannot fit
|
||||||
|
-- it -- reported in-game as text hanging out of the bottom edge.
|
||||||
|
--
|
||||||
|
-- Note the panel is measured, not estimated: 1.12 has no GetStringHeight (only GetStringWidth
|
||||||
|
-- exists in Blizzard's own FrameXML), but a FontString with an explicit width lays its wrapped
|
||||||
|
-- text out and reports the real height through GetHeight().
|
||||||
|
local function fitHeight()
|
||||||
|
if not (frame and infoFS) then return end
|
||||||
|
local top, iTop, iH = frame:GetTop(), infoFS:GetTop(), infoFS:GetHeight()
|
||||||
|
if not (top and iTop and iH) then return end
|
||||||
|
local need = (top - iTop) + iH + 18 -- header block + text + bottom padding
|
||||||
|
if need < 300 then need = 300 end -- never collapse to nothing
|
||||||
|
if math.abs(frame:GetHeight() - need) > 2 then frame:SetHeight(need) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function refreshInfo()
|
||||||
|
if infoFS then
|
||||||
|
W.SetTextG(infoFS, composeInfo())
|
||||||
|
fitHeight()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function refreshChecks()
|
||||||
|
local cfg = db()
|
||||||
|
if not cfg then return end
|
||||||
|
for i = 1, table.getn(checkboxes) do
|
||||||
|
local cb = checkboxes[i]
|
||||||
|
cb:SetChecked(cb.vfGet() and 1 or nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Swatch background = the currently configured SCT color. Applies in both modes (see
|
||||||
|
-- gui/fct.lua), so it is never part of refreshEnabled()'s greying below.
|
||||||
|
local function refreshSwatch()
|
||||||
|
if not swatch then return end
|
||||||
|
local cfg = db()
|
||||||
|
local col = cfg and cfg.sct and cfg.sct.color
|
||||||
|
if col then swatch:SetBackdropColor(col.r, col.g, col.b, 1) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Greys out the controls that are genuinely inert outside "Free anchor" mode: Blizzard mode
|
||||||
|
-- owns its own position and timing (Blizzard_CombatText), and bar mode derives its position
|
||||||
|
-- from VampifyDisplayFrame rather than the stored free position -- in both cases the slider/
|
||||||
|
-- Move/Reset controls below have nothing to act on. NOT the color swatch, which still applies
|
||||||
|
-- in all three modes.
|
||||||
|
local function refreshEnabled()
|
||||||
|
local cfg = db()
|
||||||
|
local mode = cfg and cfg.sct and cfg.sct.mode
|
||||||
|
-- Active only in "own" -- nil (fresh/unmigrated profile) and any unrecognised value fall
|
||||||
|
-- back to enabled too, matching gui/sct.lua's resolveAnchorMode's "unknown -> free" rule.
|
||||||
|
local posEnabled = (mode ~= "bar" and mode ~= "blizzard")
|
||||||
|
|
||||||
|
if btnMove then W.SetButtonEnabled(btnMove, posEnabled) end
|
||||||
|
if btnPosReset then W.SetButtonEnabled(btnPosReset, posEnabled) end
|
||||||
|
if posLabel then W.SetTextColorG(posLabel, posEnabled and C.ink or C.faint) end
|
||||||
|
-- The sliders shape THIS addon's own SCT only; Blizzard's owns its font and travel.
|
||||||
|
-- NOT Enable()/Disable(): a Slider has neither in 1.12 (it threw in-game). See
|
||||||
|
-- W.SetSliderEnabled, which follows Blizzard's own hide-the-thumb recipe.
|
||||||
|
W.SetSliderEnabled(sliderRise, posEnabled)
|
||||||
|
W.SetSliderEnabled(sliderFont, posEnabled)
|
||||||
|
if btnDemo then W.SetButtonEnabled(btnDemo, posEnabled) end
|
||||||
|
-- The outline shapes OUR numbers only; Blizzard's engine draws its own.
|
||||||
|
if btnOutline then W.SetButtonEnabled(btnOutline, posEnabled) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Push stored values into the sliders without letting OnValueChanged write them straight back.
|
||||||
|
local function refreshSliders()
|
||||||
|
if sliderFont then
|
||||||
|
local v = sliderFont.vfGetter()
|
||||||
|
sliderFont:SetValue(v)
|
||||||
|
getglobal("VampifyOptionsFontSliderText"):SetText(sliderFont.vfLabel .. ": " .. v)
|
||||||
|
end
|
||||||
|
if sliderRise then
|
||||||
|
local v = sliderRise.vfGetter()
|
||||||
|
sliderRise:SetValue(v)
|
||||||
|
getglobal("VampifyOptionsRiseSliderText"):SetText(sliderRise.vfLabel .. ": " .. v)
|
||||||
|
end
|
||||||
|
if sliderPillH then
|
||||||
|
local v = sliderPillH.vfGetter()
|
||||||
|
sliderPillH:SetValue(v)
|
||||||
|
getglobal("VampifyOptionsPillHeightSliderText"):SetText(sliderPillH.vfLabel .. ": " .. v)
|
||||||
|
end
|
||||||
|
if sliderPanelBarH then
|
||||||
|
local v = sliderPanelBarH.vfGetter()
|
||||||
|
sliderPanelBarH:SetValue(v)
|
||||||
|
getglobal("VampifyOptionsPanelBarHeightSliderText"):SetText(sliderPanelBarH.vfLabel .. ": " .. v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- frame
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local function build()
|
||||||
|
if frame then return frame end
|
||||||
|
|
||||||
|
local f = CreateFrame("Frame", "VampifyOptionsFrame", UIParent)
|
||||||
|
f:SetWidth(340)
|
||||||
|
f:SetHeight(540)
|
||||||
|
f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
|
||||||
|
-- HIGH, deliberately NOT DIALOG. Blizzard's ColorPickerFrame lives at DIALOG, and a frame
|
||||||
|
-- created later in the same strata draws on top of it -- so at DIALOG this window buried
|
||||||
|
-- the very colour picker it opens (reported in-game). HIGH keeps us above the normal UI
|
||||||
|
-- while leaving DIALOG to Blizzard's own popups, which is where they belong.
|
||||||
|
f:SetFrameStrata("HIGH")
|
||||||
|
f:SetBackdrop(W.PANEL_BACKDROP)
|
||||||
|
f:SetBackdropColor(1, 1, 1, 0.97)
|
||||||
|
f:SetMovable(true)
|
||||||
|
f:EnableMouse(true)
|
||||||
|
f:SetClampedToScreen(true)
|
||||||
|
f:RegisterForDrag("LeftButton")
|
||||||
|
f:SetScript("OnDragStart", function() this:StartMoving() end)
|
||||||
|
f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
|
||||||
|
|
||||||
|
-- title (gold), version alongside it
|
||||||
|
local title = W.MakeText(f, 14, C.goldHi, "ARTWORK")
|
||||||
|
title:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -16)
|
||||||
|
title:SetText("Vampify v" .. VampifyConst.VERSION)
|
||||||
|
|
||||||
|
-- close X
|
||||||
|
local closeX = CreateFrame("Button", "VampifyOptionsCloseX", f, "UIPanelCloseButton")
|
||||||
|
closeX:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8)
|
||||||
|
|
||||||
|
-- layout cursor
|
||||||
|
local x, y = 24, -46
|
||||||
|
local function place(w, dy)
|
||||||
|
w:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
|
||||||
|
y = y + dy
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- checkboxes -------------------------------------------------------------------
|
||||||
|
-- Reads the stored preference rather than the frame, so it cannot drift from the minimap
|
||||||
|
-- button (which writes the same field) and cannot report "off" merely because the bar is
|
||||||
|
-- currently down for a reason of its own -- no sources equipped, or the inert verdict.
|
||||||
|
local cbShow = CreateCheckbox(f, "Show display",
|
||||||
|
function() local cfg = db(); return cfg and cfg.shown end,
|
||||||
|
function(v)
|
||||||
|
local cfg = db()
|
||||||
|
if cfg then cfg.shown = v end
|
||||||
|
if v then VampifyDisplay.show() else VampifyDisplay.hide() end
|
||||||
|
end)
|
||||||
|
place(cbShow, -26)
|
||||||
|
W.AttachTooltip(cbShow, "Same as left-clicking the minimap button.")
|
||||||
|
|
||||||
|
local cbLock = CreateCheckbox(f, "Lock display",
|
||||||
|
function() local cfg = db(); return cfg and cfg.locked end,
|
||||||
|
function(v) local cfg = db(); if cfg then cfg.locked = v end end)
|
||||||
|
place(cbLock, -26)
|
||||||
|
W.AttachTooltip(cbLock, "Prevents dragging the display frame around.")
|
||||||
|
|
||||||
|
local cbMinimap = CreateCheckbox(f, "Minimap button",
|
||||||
|
function() local cfg = db(); return cfg and cfg.minimap end,
|
||||||
|
function(v)
|
||||||
|
local cfg = db()
|
||||||
|
if cfg then cfg.minimap = v end
|
||||||
|
if VampifyMinimap and VampifyMinimap.update then VampifyMinimap.update() end
|
||||||
|
end)
|
||||||
|
place(cbMinimap, -26)
|
||||||
|
|
||||||
|
local cbOverheal = CreateCheckbox(f, "Show overheal",
|
||||||
|
function() local cfg = db(); return cfg and cfg.showOverheal end,
|
||||||
|
function(v) local cfg = db(); if cfg then cfg.showOverheal = v end end)
|
||||||
|
place(cbOverheal, -26)
|
||||||
|
-- Tooltip text updated 2026-08-24 (Befund 2, pixel measurement pass): the compact bar no
|
||||||
|
-- longer shows a "(N OH)" suffix at all (moved to the row-detail panel and the breakdown
|
||||||
|
-- panel's Total line, to make room on an overflowing bar) -- this option's only remaining
|
||||||
|
-- effect is the combat-text half, so the tooltip must not describe a bar effect that no
|
||||||
|
-- longer exists.
|
||||||
|
W.AttachTooltip(cbOverheal, "Spawns dimmed, parenthesized overheal numbers on the combat text.")
|
||||||
|
|
||||||
|
-- "Show vampirism % on bar" (2026-08-25, feature request: "one more: show vampirism % on
|
||||||
|
-- bar - with total%/source count"). This existed on the bar once before ("14.0% (9 Gear)")
|
||||||
|
-- and was cut entirely during the 340px->160px compaction pass -- it now comes back as an
|
||||||
|
-- OPT-IN toggle instead of a permanent segment. DEFAULT OFF (per the original suggestion,
|
||||||
|
-- taken as decided rather than merely offered): the bar stays at its established narrow
|
||||||
|
-- width for every existing player, and nobody is surprised by it growing on the very next
|
||||||
|
-- login -- the same "an existing profile sees zero visible change until it explicitly opts
|
||||||
|
-- in" principle this file's own sliders (pillHeight/panelBarHeight, added at the same time)
|
||||||
|
-- already follow. gui/display.lua's V.update() calls V.formatSources(VampifyState.sourcePercents)
|
||||||
|
-- for the actual text -- the SAME figure this window's own info panel already shows further
|
||||||
|
-- down ("Total: N% from M sources" -- composeInfo() above), fed by the same
|
||||||
|
-- VampifyDetect.getSources() + manual overrides core/commands.lua's recompute() already
|
||||||
|
-- merges into sourcePercents -- not a second computation.
|
||||||
|
local cbGearPct = CreateCheckbox(f, "Show vampirism % on bar",
|
||||||
|
function() local cfg = db(); return cfg and cfg.showGearPct end,
|
||||||
|
function(v)
|
||||||
|
local cfg = db()
|
||||||
|
if cfg then cfg.showGearPct = v end
|
||||||
|
-- Immediate effect, no /reload -- V.update() already runs the segment's own
|
||||||
|
-- show/hide + text + pill-width re-layout logic every tick; calling it once here
|
||||||
|
-- (rather than waiting up to ~0.5s for the next throttled tick) matches this file's
|
||||||
|
-- own "sofort" convention for every other live-applied option/slider.
|
||||||
|
if VampifyDisplay and VampifyDisplay.update then VampifyDisplay.update() end
|
||||||
|
end)
|
||||||
|
place(cbGearPct, -26)
|
||||||
|
W.AttachTooltip(cbGearPct, "Adds \"<total%> (<source count>)\" to the bar -- the sum of your"
|
||||||
|
.. " equipped gear's Vampirism percentages and how many sources make it up. Same figure"
|
||||||
|
.. " as the \"Total\" line in this window's own info panel below. The bar grows to fit it.")
|
||||||
|
|
||||||
|
-- Same field (VampifyDB.perhitEnabled) /vf perhit on|off writes, core/commands.lua -- the
|
||||||
|
-- two cannot drift, same as cbShow above and the minimap button's left-click both writing
|
||||||
|
-- cfg.shown. The actual enable/disable side effect (not just the saved preference) is
|
||||||
|
-- applied here directly, mirroring cbMinimap's direct VampifyMinimap.update() call below.
|
||||||
|
local cbPerHit = CreateCheckbox(f, "Per-hit debug export",
|
||||||
|
function() local cfg = db(); return cfg and cfg.perhitEnabled end,
|
||||||
|
function(v)
|
||||||
|
local cfg = db()
|
||||||
|
if cfg then cfg.perhitEnabled = v end
|
||||||
|
if not VampifyPerHit then return end
|
||||||
|
if v then
|
||||||
|
if not VampifyPerHit.isEnabled() then VampifyPerHit.enable() end
|
||||||
|
elseif VampifyPerHit.isEnabled() then
|
||||||
|
VampifyPerHit.disable(UnitHealth and UnitHealth("player"))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
place(cbPerHit, -26)
|
||||||
|
W.AttachTooltip(cbPerHit, "Writes one line per own hit to imports\\vampify_perhit_*.txt"
|
||||||
|
.." for offline analysis (per-source Vampirism factor measurement). On by default.")
|
||||||
|
|
||||||
|
local cbSct = CreateCheckbox(f, "Scrolling combat text",
|
||||||
|
function() local cfg = db(); return cfg and cfg.sct and cfg.sct.enabled end,
|
||||||
|
function(v)
|
||||||
|
local cfg = db()
|
||||||
|
if cfg and cfg.sct then cfg.sct.enabled = v end
|
||||||
|
end)
|
||||||
|
place(cbSct, -26)
|
||||||
|
|
||||||
|
local cbCoalesce = CreateCheckbox(f, "One number per cast (not per hit)",
|
||||||
|
function() local cfg = db(); return cfg and cfg.sct and cfg.sct.coalesce end,
|
||||||
|
function(v)
|
||||||
|
local cfg = db()
|
||||||
|
if cfg and cfg.sct then cfg.sct.coalesce = v end
|
||||||
|
end)
|
||||||
|
place(cbCoalesce, -18)
|
||||||
|
|
||||||
|
local coalesceHint = W.MakeText(f, 9, C.faint, "ARTWORK")
|
||||||
|
coalesceHint:SetPoint("TOPLEFT", f, "TOPLEFT", x + 26, y)
|
||||||
|
coalesceHint:SetWidth(268)
|
||||||
|
coalesceHint:SetJustifyH("LEFT")
|
||||||
|
coalesceHint:SetText("An AoE hitting five targets shows its summed return once instead of"
|
||||||
|
.. " five times. Totals are unaffected - this only changes what is drawn.")
|
||||||
|
y = y - 26
|
||||||
|
|
||||||
|
-- ---- SCT engine (three mutually exclusive modes, VampifyDB.sct.mode) --------------------
|
||||||
|
-- Not place()'s usual stacked column: three in a row reads as ONE choice, not three
|
||||||
|
-- independent switches. A plain CheckButton has no native radio grouping in 1.12, so
|
||||||
|
-- exclusivity is hand-rolled -- each setter writes cfg.sct.mode, then refreshChecks()
|
||||||
|
-- resyncs every checkbox in the window from its own getter (checkboxes[] already holds all
|
||||||
|
-- of them), which is also what snaps a click back to checked when it tried to uncheck the
|
||||||
|
-- currently active mode (there is always exactly one -- "off" is not a fourth option here).
|
||||||
|
local engineLabel = W.MakeText(f, 11, C.ink, "ARTWORK")
|
||||||
|
place(engineLabel, -18)
|
||||||
|
engineLabel:SetText("SCT engine")
|
||||||
|
|
||||||
|
local function setEngineMode(v)
|
||||||
|
local cfg = db()
|
||||||
|
if cfg and cfg.sct then cfg.sct.mode = v end
|
||||||
|
-- Dragging the free anchor only means anything in "own" mode -- leaving move mode
|
||||||
|
-- engaged while switching away would leave it silently unreachable from the (now grey)
|
||||||
|
-- Move button, so it is force-released the same moment the mode stops being "own".
|
||||||
|
if v ~= "own" and VampifySCT and VampifySCT.isMoveMode and VampifySCT.isMoveMode() then
|
||||||
|
VampifySCT.setMoveMode(false)
|
||||||
|
if btnMove then btnMove.text:SetText("Move") end
|
||||||
|
end
|
||||||
|
refreshChecks()
|
||||||
|
refreshEnabled()
|
||||||
|
-- Hangs the anchor immediately instead of waiting for the next SCT emit -- see
|
||||||
|
-- gui/sct.lua's S.applyPos/reanchor comments for why a forced, explicit call is needed
|
||||||
|
-- here on top of spawn()'s own lazy re-check.
|
||||||
|
if VampifySCT and VampifySCT.applyPos then VampifySCT.applyPos() end
|
||||||
|
end
|
||||||
|
|
||||||
|
local cbFree = CreateCheckbox(f, "Free anchor",
|
||||||
|
function() local cfg = db(); local m = cfg and cfg.sct and cfg.sct.mode
|
||||||
|
return m ~= "bar" and m ~= "blizzard" end,
|
||||||
|
function(v) if v then setEngineMode("own") else refreshChecks() end end)
|
||||||
|
place(cbFree, 0) -- dy 0: cbBar/cbBlizzard below share this row, y advances once after all three
|
||||||
|
W.AttachTooltip(cbFree, "Vampify's own combat text on a draggable anchor you position anywhere.")
|
||||||
|
|
||||||
|
local cbBar = CreateCheckbox(f, "At the bar",
|
||||||
|
function() local cfg = db(); return cfg and cfg.sct and cfg.sct.mode == "bar" end,
|
||||||
|
function(v) if v then setEngineMode("bar") else refreshChecks() end end)
|
||||||
|
cbBar:SetPoint("LEFT", cbFree.vfLabelFS, "RIGHT", 16, 0)
|
||||||
|
W.AttachTooltip(cbBar, "Vampify's own combat text, anchored just above the display bar.")
|
||||||
|
|
||||||
|
local cbBlizzard = CreateCheckbox(f, "Blizzard FCT",
|
||||||
|
function() local cfg = db(); return cfg and cfg.sct and cfg.sct.mode == "blizzard" end,
|
||||||
|
function(v) if v then setEngineMode("blizzard") else refreshChecks() end end)
|
||||||
|
cbBlizzard:SetPoint("LEFT", cbBar.vfLabelFS, "RIGHT", 16, 0)
|
||||||
|
W.AttachTooltip(cbBlizzard, "Blizzard's own combat text (Blizzard_CombatText) - only the"
|
||||||
|
.." colour is ours, position and timing are Blizzard's.")
|
||||||
|
y = y - 26
|
||||||
|
|
||||||
|
local engineHint = W.MakeText(f, 9, C.faint, "ARTWORK")
|
||||||
|
engineHint:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
|
||||||
|
engineHint:SetWidth(292)
|
||||||
|
engineHint:SetJustifyH("LEFT")
|
||||||
|
engineHint:SetText("Free anchor and At the bar both use Vampify's own combat text below;"
|
||||||
|
.." Blizzard FCT hands numbers to Blizzard's engine instead.")
|
||||||
|
y = y - 24
|
||||||
|
|
||||||
|
-- ---- SCT color -----------------------------------------------------------------------
|
||||||
|
swatch = CreateFrame("Button", "VampifyOptionsSctSwatch", f)
|
||||||
|
swatch:SetWidth(16)
|
||||||
|
swatch:SetHeight(16)
|
||||||
|
swatch:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
|
||||||
|
swatch:SetBackdrop(W.SOLID_BACKDROP)
|
||||||
|
swatch:SetBackdropBorderColor(C.goldSoft.r, C.goldSoft.g, C.goldSoft.b, 1)
|
||||||
|
|
||||||
|
local swatchLabel = W.MakeText(f, 11, C.ink, "ARTWORK")
|
||||||
|
swatchLabel:SetPoint("LEFT", swatch, "RIGHT", 8, 0)
|
||||||
|
swatchLabel:SetText("SCT color")
|
||||||
|
|
||||||
|
-- Applies to both engines (gui/sct.lua and gui/fct.lua) -- never greyed by refreshEnabled().
|
||||||
|
swatch:SetScript("OnClick", function()
|
||||||
|
local cfg = db()
|
||||||
|
if not (cfg and cfg.sct) then return end
|
||||||
|
local c = cfg.sct.color
|
||||||
|
|
||||||
|
-- Order matters and is verified against Blizzard 1.12.1 FrameXML (UIDropDownMenu.lua,
|
||||||
|
-- FloatingChatFrame.lua): ColorPickerFrame:SetColorRGB() FIRES .func immediately, so
|
||||||
|
-- func/opacityFunc/cancelFunc/previousValues must all be assigned BEFORE that call --
|
||||||
|
-- otherwise either a stale callback from whichever addon used the picker last fires, or
|
||||||
|
-- ours fires against state that is not set up yet. Do not reorder this "for tidiness".
|
||||||
|
ColorPickerFrame.func = function()
|
||||||
|
local r, g, b = ColorPickerFrame:GetColorRGB()
|
||||||
|
c.r, c.g, c.b = r, g, b
|
||||||
|
refreshSwatch()
|
||||||
|
end
|
||||||
|
ColorPickerFrame.hasOpacity = nil -- no transparency slider needed
|
||||||
|
ColorPickerFrame.opacityFunc = nil
|
||||||
|
ColorPickerFrame.cancelFunc = function(prevVals)
|
||||||
|
if prevVals then c.r, c.g, c.b = prevVals.r, prevVals.g, prevVals.b end
|
||||||
|
refreshSwatch()
|
||||||
|
end
|
||||||
|
ColorPickerFrame.previousValues = { r = c.r, g = c.g, b = c.b } -- named fields, not an array
|
||||||
|
ColorPickerFrame:SetColorRGB(c.r, c.g, c.b)
|
||||||
|
-- Blizzard's 1.12 colour picker is not draggable out of the box, which is painful when
|
||||||
|
-- it lands on top of what you are trying to recolour. Make it movable once, defensively
|
||||||
|
-- (another addon may already have done it), and leave it that way -- this is a strict
|
||||||
|
-- improvement for every consumer of the frame, not a behaviour change.
|
||||||
|
if not ColorPickerFrame.vfMadeMovable then
|
||||||
|
ColorPickerFrame.vfMadeMovable = true
|
||||||
|
ColorPickerFrame:SetMovable(true) -- already true in 1.12, but be explicit
|
||||||
|
ColorPickerFrame:EnableMouse(true)
|
||||||
|
-- The missing piece: 1.12's picker is movable but registers no drag handler, so
|
||||||
|
-- nothing ever picks it up. Measured in-game: IsMovable() == 1, yet it would not
|
||||||
|
-- budge.
|
||||||
|
ColorPickerFrame:RegisterForDrag("LeftButton")
|
||||||
|
ColorPickerFrame:SetScript("OnDragStart", function() this:StartMoving() end)
|
||||||
|
ColorPickerFrame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
|
||||||
|
end
|
||||||
|
-- Raise it above this window explicitly. Measured in-game: the picker sits at strata
|
||||||
|
-- MEDIUM, so moving our window down to HIGH was not enough -- HIGH still covers MEDIUM.
|
||||||
|
-- A colour picker is modal in spirit; putting it on top is what the user expects, and
|
||||||
|
-- it stays correct no matter what strata this window ends up at later.
|
||||||
|
ColorPickerFrame:SetFrameStrata("FULLSCREEN_DIALOG")
|
||||||
|
ShowUIPanel(ColorPickerFrame)
|
||||||
|
end)
|
||||||
|
y = y - 26
|
||||||
|
|
||||||
|
-- ---- SCT position ----------------------------------------------------------------------
|
||||||
|
posLabel = W.MakeText(f, 11, C.ink, "ARTWORK")
|
||||||
|
place(posLabel, -18)
|
||||||
|
posLabel:SetText("SCT position")
|
||||||
|
|
||||||
|
btnMove = W.MakeButton(f, "VampifyOptionsSctMove", "Move", 68, 20)
|
||||||
|
btnMove:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
|
||||||
|
btnMove:SetScript("OnClick", function()
|
||||||
|
if this.vfDisabled then return end
|
||||||
|
if not (VampifySCT and VampifySCT.setMoveMode and VampifySCT.isMoveMode) then return end
|
||||||
|
local on = not VampifySCT.isMoveMode()
|
||||||
|
VampifySCT.setMoveMode(on)
|
||||||
|
this.text:SetText(on and "Done" or "Move")
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- No X/Y entry fields. They existed briefly and were removed on sight: after a drag the
|
||||||
|
-- stored offsets are raw anchor coordinates (902.75 / -629.99 on a real screen), which
|
||||||
|
-- carry no meaning for anyone positioning a frame by eye. Dragging IS the interface here;
|
||||||
|
-- a number nobody can reason about is worse than no number.
|
||||||
|
|
||||||
|
-- Demo: tuning colour, size and height is guesswork without numbers on screen, and nobody
|
||||||
|
-- wants to pull a mob to check a slider. Fires a burst spread over a few seconds so spacing
|
||||||
|
-- and fade are judgeable, not just the colour.
|
||||||
|
btnDemo = W.MakeButton(f, "VampifyOptionsSctDemo", "Demo", 56, 20)
|
||||||
|
btnDemo:SetPoint("LEFT", btnMove, "RIGHT", 10, 0)
|
||||||
|
btnDemo:SetScript("OnClick", function()
|
||||||
|
if this.vfDisabled then return end
|
||||||
|
if VampifySCT and VampifySCT.demo then VampifySCT.demo(10) end
|
||||||
|
end)
|
||||||
|
|
||||||
|
btnPosReset = W.MakeButton(f, "VampifyOptionsSctPosReset", "Reset", 50, 20)
|
||||||
|
btnPosReset:SetPoint("LEFT", btnDemo, "RIGHT", 10, 0)
|
||||||
|
btnPosReset:SetScript("OnClick", function()
|
||||||
|
if this.vfDisabled then return end
|
||||||
|
local cfg = db()
|
||||||
|
if not (cfg and cfg.sct) then return end
|
||||||
|
local d = VampifyConfig.DEFAULTS.sct.pos
|
||||||
|
cfg.sct.pos = { point = d.point, x = d.x, y = d.y }
|
||||||
|
if VampifySCT and VampifySCT.applyPos then VampifySCT.applyPos() end
|
||||||
|
-- (position fields removed; nothing to mirror)
|
||||||
|
end)
|
||||||
|
y = y - 34
|
||||||
|
|
||||||
|
-- ---- SCT sliders -------------------------------------------------------------------
|
||||||
|
-- Replaces the X/Y entry boxes: raw anchor offsets meant nothing to a human, whereas these
|
||||||
|
-- two are the values you actually want to tune by eye.
|
||||||
|
local function makeSlider(name, label, lo, hi, step, getter, setter)
|
||||||
|
local sl = CreateFrame("Slider", name, f, "OptionsSliderTemplate")
|
||||||
|
sl:SetWidth(200)
|
||||||
|
sl:SetHeight(16)
|
||||||
|
sl:SetMinMaxValues(lo, hi)
|
||||||
|
sl:SetValueStep(step)
|
||||||
|
sl:SetPoint("TOPLEFT", f, "TOPLEFT", x + 6, y)
|
||||||
|
getglobal(name .. "Low"):SetText(tostring(lo))
|
||||||
|
getglobal(name .. "High"):SetText(tostring(hi))
|
||||||
|
local cap = getglobal(name .. "Text")
|
||||||
|
W.ApplyFont(cap, 11)
|
||||||
|
sl.vfLabel = label
|
||||||
|
sl.vfSetter = setter
|
||||||
|
sl.vfGetter = getter
|
||||||
|
sl:SetScript("OnValueChanged", function()
|
||||||
|
local v = this:GetValue()
|
||||||
|
-- OptionsSliderTemplate does not quantise for us in 1.12; round to the step so the
|
||||||
|
-- stored value stays a clean integer instead of 15.9999.
|
||||||
|
v = math.floor(v + 0.5)
|
||||||
|
this.vfSetter(v)
|
||||||
|
getglobal(this:GetName() .. "Text"):SetText(this.vfLabel .. ": " .. v)
|
||||||
|
end)
|
||||||
|
y = y - 42
|
||||||
|
return sl
|
||||||
|
end
|
||||||
|
|
||||||
|
sliderFont = makeSlider("VampifyOptionsFontSlider", "Font size", 8, 48, 1,
|
||||||
|
function()
|
||||||
|
local cfg = db()
|
||||||
|
return (cfg and cfg.sct and cfg.sct.fontSize) or 16
|
||||||
|
end,
|
||||||
|
function(v)
|
||||||
|
local cfg = db()
|
||||||
|
if not (cfg and cfg.sct) then return end
|
||||||
|
cfg.sct.fontSize = v
|
||||||
|
if VampifySCT and VampifySCT.applyFont then VampifySCT.applyFont() end
|
||||||
|
end)
|
||||||
|
|
||||||
|
sliderRise = makeSlider("VampifyOptionsRiseSlider", "SCT height", 20, 320, 5,
|
||||||
|
function()
|
||||||
|
local cfg = db()
|
||||||
|
return (cfg and cfg.sct and cfg.sct.rise) or 40
|
||||||
|
end,
|
||||||
|
function(v)
|
||||||
|
local cfg = db()
|
||||||
|
if not (cfg and cfg.sct) then return end
|
||||||
|
cfg.sct.rise = v
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Text effect. Cycles rather than three radio buttons: it is one setting with three steps,
|
||||||
|
-- and the label always states the current one, so nothing is hidden behind a menu.
|
||||||
|
local OUTLINES = { "THICKOUTLINE", "OUTLINE", "" }
|
||||||
|
local OUTLINE_LABEL = { THICKOUTLINE = "Thick", OUTLINE = "Thin", [""] = "None" }
|
||||||
|
local function outlineText()
|
||||||
|
local cfg = db()
|
||||||
|
local cur = (cfg and cfg.sct and cfg.sct.outline) or "THICKOUTLINE"
|
||||||
|
return "Outline: " .. (OUTLINE_LABEL[cur] or "Thick")
|
||||||
|
end
|
||||||
|
btnOutline = W.MakeButton(f, "VampifyOptionsOutline", outlineText(), 120, 20)
|
||||||
|
btnOutline:SetPoint("TOPLEFT", f, "TOPLEFT", x + 6, y)
|
||||||
|
btnOutline:SetScript("OnClick", function()
|
||||||
|
if this.vfDisabled then return end
|
||||||
|
local cfg = db()
|
||||||
|
if not (cfg and cfg.sct) then return end
|
||||||
|
local cur = cfg.sct.outline or "THICKOUTLINE"
|
||||||
|
local nxt = OUTLINES[1]
|
||||||
|
for i = 1, table.getn(OUTLINES) do
|
||||||
|
if OUTLINES[i] == cur then nxt = OUTLINES[math.mod(i, table.getn(OUTLINES)) + 1] end
|
||||||
|
end
|
||||||
|
cfg.sct.outline = nxt
|
||||||
|
this.text:SetText(outlineText())
|
||||||
|
if VampifySCT and VampifySCT.applyFont then VampifySCT.applyFont() end
|
||||||
|
if VampifySCT and VampifySCT.demo then VampifySCT.demo(4) end -- show the change at once
|
||||||
|
end)
|
||||||
|
O.refreshOutlineLabel = function()
|
||||||
|
if btnOutline then btnOutline.text:SetText(outlineText()) end
|
||||||
|
end
|
||||||
|
y = y - 28
|
||||||
|
|
||||||
|
-- ---- display size (2026-08-25, feature request: "two more sliders in the options that
|
||||||
|
-- determine the height of the bar and the detail bars") -----------------------------------
|
||||||
|
-- Two sliders, same makeSlider() recipe as the SCT ones above, driving VampifyDisplay's own
|
||||||
|
-- PILL_H/BAR_H (compact-bar capsule) and ROW_H/SPLIT_ROW_H/DETAIL_LINE_H (breakdown-panel
|
||||||
|
-- bars) -- see gui/display.lua's own comments on V.PILL_H_MIN/MAX and
|
||||||
|
-- V.PANEL_BAR_H_MIN/MAX for exactly why those two ranges were chosen (the readable-text
|
||||||
|
-- floor and the "too fat" ceiling it was named after). Bounds are read from VampifyDisplay itself
|
||||||
|
-- rather than copied here a second time, so the two files cannot silently drift apart.
|
||||||
|
-- Setters write the SavedVariables field AND call the matching VampifyDisplay.apply* entry
|
||||||
|
-- point immediately -- same split the SCT sliders above already use (VampifySCT.applyFont/
|
||||||
|
-- applyPos): this file owns persistence, gui/display.lua owns making the change visible with
|
||||||
|
-- no /reload.
|
||||||
|
local sizeLabel = W.MakeText(f, 11, C.ink, "ARTWORK")
|
||||||
|
place(sizeLabel, -18)
|
||||||
|
sizeLabel:SetText("Display size")
|
||||||
|
|
||||||
|
sliderPillH = makeSlider("VampifyOptionsPillHeightSlider", "Bar height",
|
||||||
|
(VampifyDisplay and VampifyDisplay.PILL_H_MIN) or 8,
|
||||||
|
(VampifyDisplay and VampifyDisplay.PILL_H_MAX) or 40, 1,
|
||||||
|
function()
|
||||||
|
local cfg = db()
|
||||||
|
return (cfg and cfg.pillHeight)
|
||||||
|
or (VampifyDisplay and VampifyDisplay.PILL_H_DEFAULT) or 10
|
||||||
|
end,
|
||||||
|
function(v)
|
||||||
|
local cfg = db()
|
||||||
|
if cfg then cfg.pillHeight = v end
|
||||||
|
if VampifyDisplay and VampifyDisplay.applyPillHeight then
|
||||||
|
VampifyDisplay.applyPillHeight(v)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
W.AttachTooltip(sliderPillH, "Height of the compact bar's capsule (the ST/AoE ratio pill)."
|
||||||
|
.. " The bar itself and its drag-resize grip follow automatically.")
|
||||||
|
|
||||||
|
sliderPanelBarH = makeSlider("VampifyOptionsPanelBarHeightSlider", "Detail bar height",
|
||||||
|
(VampifyDisplay and VampifyDisplay.PANEL_BAR_H_MIN) or 10,
|
||||||
|
(VampifyDisplay and VampifyDisplay.PANEL_BAR_H_MAX) or 40, 1,
|
||||||
|
function()
|
||||||
|
local cfg = db()
|
||||||
|
return (cfg and cfg.panelBarHeight)
|
||||||
|
or (VampifyDisplay and VampifyDisplay.PANEL_BAR_H_DEFAULT) or 18
|
||||||
|
end,
|
||||||
|
function(v)
|
||||||
|
local cfg = db()
|
||||||
|
if cfg then cfg.panelBarHeight = v end
|
||||||
|
if VampifyDisplay and VampifyDisplay.applyPanelBarHeight then
|
||||||
|
VampifyDisplay.applyPanelBarHeight(v)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
W.AttachTooltip(sliderPanelBarH, "Height of the ability/split bars in the hover breakdown"
|
||||||
|
.. " panel, and (scaled with them) the row-detail panel's own line height.")
|
||||||
|
|
||||||
|
-- divider
|
||||||
|
local divider = f:CreateTexture("VampifyOptionsDivider", "ARTWORK")
|
||||||
|
divider:SetHeight(1)
|
||||||
|
divider:SetWidth(292)
|
||||||
|
divider:SetTexture(C.line.r, C.line.g, C.line.b)
|
||||||
|
divider:SetPoint("TOPLEFT", f, "TOPLEFT", x, y - 4)
|
||||||
|
y = y - 16
|
||||||
|
|
||||||
|
-- ---- buttons ------------------------------------------------------------------------
|
||||||
|
local btnReset = W.MakeButton(f, "VampifyOptionsResetTotals", "Reset totals", 138, 22)
|
||||||
|
btnReset:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
|
||||||
|
-- Goes through the one reset path rather than repeating it. Reassigning VampifyState here
|
||||||
|
-- would ORPHAN the per-spell tables: since persistence landed they point INTO
|
||||||
|
-- VampifyCharDB, so a fresh state would keep recording to tables nothing saves, silently,
|
||||||
|
-- until the next reload re-wired them. VampifyResetSession clears in place for exactly
|
||||||
|
-- that reason. Scope is "session" -- a button labelled "Reset totals" must not wipe the
|
||||||
|
-- lifetime set, which has to be asked for by name.
|
||||||
|
btnReset:SetScript("OnClick", function()
|
||||||
|
if VampifyResetSession then VampifyResetSession("session") end
|
||||||
|
refreshInfo()
|
||||||
|
end)
|
||||||
|
|
||||||
|
local btnPos = W.MakeButton(f, "VampifyOptionsResetPos", "Reset position", 138, 22)
|
||||||
|
btnPos:SetPoint("TOPLEFT", f, "TOPLEFT", x + 154, y)
|
||||||
|
btnPos:SetScript("OnClick", function() VampifyDisplay.resetPos() end)
|
||||||
|
y = y - 32
|
||||||
|
|
||||||
|
-- divider before the info panel
|
||||||
|
local divider2 = f:CreateTexture("VampifyOptionsDivider2", "ARTWORK")
|
||||||
|
divider2:SetHeight(1)
|
||||||
|
divider2:SetWidth(292)
|
||||||
|
divider2:SetTexture(C.line.r, C.line.g, C.line.b)
|
||||||
|
divider2:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
|
||||||
|
y = y - 12
|
||||||
|
|
||||||
|
-- ---- info panel -----------------------------------------------------------------------
|
||||||
|
infoFS = W.MakeText(f, 10, C.ink, "ARTWORK")
|
||||||
|
infoFS:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
|
||||||
|
infoFS:SetWidth(292)
|
||||||
|
infoFS:SetJustifyH("LEFT")
|
||||||
|
infoFS:SetJustifyV("TOP")
|
||||||
|
|
||||||
|
f:SetScript("OnShow", function()
|
||||||
|
refreshChecks()
|
||||||
|
refreshInfo()
|
||||||
|
refreshSwatch()
|
||||||
|
refreshSliders()
|
||||||
|
if O.refreshOutlineLabel then O.refreshOutlineLabel() end
|
||||||
|
refreshEnabled()
|
||||||
|
if btnMove then
|
||||||
|
local on = VampifySCT and VampifySCT.isMoveMode and VampifySCT.isMoveMode()
|
||||||
|
btnMove.text:SetText(on and "Done" or "Move")
|
||||||
|
end
|
||||||
|
elapsed = 0
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Refreshed only while shown: a hidden frame never receives OnUpdate in 1.12, so this is
|
||||||
|
-- naturally free at rest without an extra IsShown() guard. The swatch rides along on the
|
||||||
|
-- same 0.5s tick. The sliders deliberately do NOT -- they are only pushed on OnShow, since
|
||||||
|
-- writing to a slider fires OnValueChanged and would fight the user mid-drag.
|
||||||
|
f:SetScript("OnUpdate", function()
|
||||||
|
elapsed = elapsed + arg1
|
||||||
|
if elapsed < 0.5 then return end
|
||||||
|
elapsed = 0
|
||||||
|
refreshInfo()
|
||||||
|
-- (position fields removed; nothing to mirror)
|
||||||
|
end)
|
||||||
|
|
||||||
|
table.insert(UISpecialFrames, "VampifyOptionsFrame")
|
||||||
|
|
||||||
|
frame = f
|
||||||
|
f:Hide()
|
||||||
|
return f
|
||||||
|
end
|
||||||
|
|
||||||
|
function O.toggle()
|
||||||
|
local f = build()
|
||||||
|
if f:IsShown() then f:Hide() else f:Show() end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
-- Vampify -- scrolling combat text, our own.
|
||||||
|
--
|
||||||
|
-- Blizzard's FCT (gui/fct.lua) cannot be positioned or fully recolored by an addon --
|
||||||
|
-- Blizzard_CombatText owns its own frame, and its position is not settable from outside. This is
|
||||||
|
-- the alternative engine: a positionable anchor frame, a pooled set of FontStrings, and one shared
|
||||||
|
-- OnUpdate driving every number in flight. Which engine actually runs is chosen by
|
||||||
|
-- VampifyDB.sct.mode ("own" | "bar" | "blizzard") and dispatched from core/commands.lua -- "bar"
|
||||||
|
-- is this SAME engine and pool, just anchored to the display bar instead of a free-floating
|
||||||
|
-- position (see reanchor() below); this file does not know or care that the Blizzard alternative
|
||||||
|
-- exists.
|
||||||
|
--
|
||||||
|
-- Pure-Lua 5.0 dofile-loadable: VampifySCT exists at file scope, but every function that touches
|
||||||
|
-- the WoW API lives inside "if CreateFrame then" -- same shape as gui/display.lua and
|
||||||
|
-- gui/options.lua. resolveAnchorMode below is the one exception: it is pure decision logic (mode +
|
||||||
|
-- bar-frame-exists -> "bar" | "free"), so it lives outside the guard, offline-testable the same
|
||||||
|
-- way gui/display.lua's V.boundFlag is (tools/luatests/test_sct.lua).
|
||||||
|
|
||||||
|
VampifySCT = {}
|
||||||
|
local S = VampifySCT
|
||||||
|
|
||||||
|
-- Which anchor a given mode resolves to. "bar" only when the mode actually asks for it AND the
|
||||||
|
-- bar frame exists yet -- reanchor() below is the reason existence, not visibility, is the gate:
|
||||||
|
-- a hidden-but-created bar can still anchor to, a not-yet-created one cannot. Every other mode,
|
||||||
|
-- including nil (fresh/unmigrated profile) and any future/unknown value, resolves to the
|
||||||
|
-- free-floating anchor rather than erroring -- the safe, always-available fallback.
|
||||||
|
function S.resolveAnchorMode(mode, barExists)
|
||||||
|
if mode == "bar" and barExists then return "bar" end
|
||||||
|
return "free"
|
||||||
|
end
|
||||||
|
|
||||||
|
if CreateFrame then
|
||||||
|
local W = VampifyWidgets
|
||||||
|
local C = W.COLORS
|
||||||
|
|
||||||
|
local POOL_MAX = 20
|
||||||
|
local DUR_FALLBACK = 1.5
|
||||||
|
|
||||||
|
-- Crit pop, in multiples of the configured font size. Ratios taken from Blizzard's own combat
|
||||||
|
-- text: MINHEIGHT 30 and MAXHEIGHT 60 against a base of 25, grown over 0.05s and shrunk back by
|
||||||
|
-- 0.2s (Blizzard_CombatText.lua:4-8).
|
||||||
|
local CRIT_MIN = 30 / 25
|
||||||
|
local CRIT_MAX = 60 / 25
|
||||||
|
local CRIT_GROW = 0.05
|
||||||
|
local CRIT_SHRINK = 0.2
|
||||||
|
|
||||||
|
local anchor, label
|
||||||
|
local moveMode = false
|
||||||
|
local spawnCounter = 0
|
||||||
|
-- Cache of the last anchor mode actually applied ("bar" | "free" | nil before the first
|
||||||
|
-- build). Lets reanchor() below be a cheap no-op on every spawn once nothing has changed,
|
||||||
|
-- instead of an unconditional ClearAllPoints+SetPoint per number -- which matters because it
|
||||||
|
-- would otherwise fight an in-progress free-anchor drag. It cannot: the mode cannot change
|
||||||
|
-- mid-drag (moveMode is only reachable in "own" mode, see S.setMoveMode below), so a drag never
|
||||||
|
-- sees anchorMode flip underneath it.
|
||||||
|
local lastAnchorMode = nil
|
||||||
|
|
||||||
|
-- Fixed-size slot pool: a pre-sized array of records, each either idle or carrying one number.
|
||||||
|
-- Free slots live on a manual LIFO stack (freeStack/freeTop) -- push/pop by hand, never
|
||||||
|
-- table.insert/table.remove/table.getn, so there is no growing "n" field and no need for
|
||||||
|
-- VampifyConst.resetList (core/const.lua): the Lua-5.0 trap that helper guards against simply
|
||||||
|
-- does not exist in this design.
|
||||||
|
local slots = {}
|
||||||
|
local freeStack, freeTop = {}, 0
|
||||||
|
for i = 1, POOL_MAX do
|
||||||
|
slots[i] = { active = false, fs = nil, t = 0, dur = DUR_FALLBACK, dx = 0, dy0 = 0,
|
||||||
|
alphaMul = 1, crit = false, base = 16 }
|
||||||
|
freeTop = freeTop + 1
|
||||||
|
freeStack[freeTop] = i
|
||||||
|
end
|
||||||
|
|
||||||
|
local function db() return VampifyConfig.get() end
|
||||||
|
|
||||||
|
-- Only the border/background/label are hidden in normal operation -- the frame itself always
|
||||||
|
-- stays shown, because the pooled FontStrings are its children and a hidden parent would hide
|
||||||
|
-- them too. "Invisible" here means alpha 0, not SetBackdrop(nil).
|
||||||
|
local function applyMoveVisuals()
|
||||||
|
if moveMode then
|
||||||
|
anchor:EnableMouse(true)
|
||||||
|
anchor:SetBackdropColor(0.08, 0.09, 0.12, 0.85)
|
||||||
|
anchor:SetBackdropBorderColor(C.gold.r, C.gold.g, C.gold.b, 1)
|
||||||
|
if label then label:Show() end
|
||||||
|
else
|
||||||
|
anchor:EnableMouse(false)
|
||||||
|
anchor:SetBackdropColor(0, 0, 0, 0)
|
||||||
|
anchor:SetBackdropBorderColor(0, 0, 0, 0)
|
||||||
|
if label then label:Hide() end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Applies the resolved anchor mode to the live frame -- "bar" hangs the anchor off
|
||||||
|
-- VampifyDisplayFrame's top edge (numbers rise FROM the bar's top upward, matching every other
|
||||||
|
-- rising-number layout in this file); anything else restores the stored free position, the
|
||||||
|
-- same positioning logic build() always used. `force` bypasses the lastAnchorMode cache: used
|
||||||
|
-- for the initial build() placement and for an explicit runtime switch (S.applyPos, called from
|
||||||
|
-- gui/options.lua on every mode-radio click and on Reset) so the anchor moves immediately
|
||||||
|
-- instead of waiting for the next spawn. Without force, a spawn's routine call is a no-op
|
||||||
|
-- unless the resolved mode actually changed since last time -- see lastAnchorMode's comment.
|
||||||
|
local function reanchor(force)
|
||||||
|
local cfg = db()
|
||||||
|
local mode = cfg and cfg.sct and cfg.sct.mode
|
||||||
|
local barExists = getglobal("VampifyDisplayFrame") and true or false
|
||||||
|
local anchorMode = S.resolveAnchorMode(mode, barExists)
|
||||||
|
if not force and anchorMode == lastAnchorMode then return end
|
||||||
|
lastAnchorMode = anchorMode
|
||||||
|
anchor:ClearAllPoints()
|
||||||
|
if anchorMode == "bar" then
|
||||||
|
anchor:SetPoint("BOTTOM", VampifyDisplayFrame, "TOP", 0, 6)
|
||||||
|
else
|
||||||
|
local pos = (cfg and cfg.sct and cfg.sct.pos) or { point = "CENTER", x = -180, y = 0 }
|
||||||
|
anchor:SetPoint(pos.point, UIParent, pos.point, pos.x, pos.y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build()
|
||||||
|
if anchor then return anchor end
|
||||||
|
|
||||||
|
local a = CreateFrame("Frame", "VampifySCTAnchor", UIParent)
|
||||||
|
a:SetWidth(140)
|
||||||
|
a:SetHeight(30)
|
||||||
|
-- HIGH, not the default MEDIUM/level 1 -- MEDIUM disappears under pfUI, the exact failure
|
||||||
|
-- gui/display.lua already hit and documented there.
|
||||||
|
a:SetFrameStrata("HIGH")
|
||||||
|
a:SetFrameLevel(10)
|
||||||
|
a:SetMovable(true)
|
||||||
|
a:EnableMouse(false) -- normal operation: out of the mouse's way entirely
|
||||||
|
a:SetClampedToScreen(true)
|
||||||
|
a:RegisterForDrag("LeftButton")
|
||||||
|
a:SetBackdrop(W.SOLID_BACKDROP)
|
||||||
|
|
||||||
|
a:SetScript("OnDragStart", function()
|
||||||
|
if moveMode then this:StartMoving() end
|
||||||
|
end)
|
||||||
|
a:SetScript("OnDragStop", function()
|
||||||
|
this:StopMovingOrSizing()
|
||||||
|
local point, _, _, x, y = this:GetPoint()
|
||||||
|
local c = db()
|
||||||
|
if c and c.sct then c.sct.pos = { point = point, x = x, y = y } end
|
||||||
|
end)
|
||||||
|
|
||||||
|
label = a:CreateFontString("VampifySCTAnchorLabel", "OVERLAY")
|
||||||
|
W.ApplyFont(label, 10)
|
||||||
|
W.SetTextColorG(label, C.goldHi)
|
||||||
|
label:SetPoint("CENTER", a, "CENTER", 0, 0)
|
||||||
|
label:SetText("Vampify SCT")
|
||||||
|
label:Hide()
|
||||||
|
|
||||||
|
anchor = a
|
||||||
|
-- Initial placement: bar anchor if mode == "bar" AND VampifyDisplayFrame already exists at
|
||||||
|
-- this point, else the stored free position. If the bar has not been created yet (load
|
||||||
|
-- order), this falls back to free without error -- spawn()'s own reanchor() call re-checks
|
||||||
|
-- on every emit, lazily, so the very next number picks up the bar once it exists.
|
||||||
|
reanchor(true)
|
||||||
|
anchor:Show() -- always shown; see applyMoveVisuals for why
|
||||||
|
applyMoveVisuals()
|
||||||
|
return a
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fontSize()
|
||||||
|
local cfg = db()
|
||||||
|
local sct = cfg and cfg.sct
|
||||||
|
return (sct and sct.fontSize) or 16
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Outline flag and shadow, the two halves of "readable over anything". Stored as a string
|
||||||
|
-- because that is exactly what SetFont's third argument takes; "" means no outline.
|
||||||
|
local function outlineFlag()
|
||||||
|
local cfg = db()
|
||||||
|
local sct = cfg and cfg.sct
|
||||||
|
local f = sct and sct.outline
|
||||||
|
if f == "" then return nil end
|
||||||
|
return f or "THICKOUTLINE"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shadowOn()
|
||||||
|
local cfg = db()
|
||||||
|
local sct = cfg and cfg.sct
|
||||||
|
if sct and sct.shadow == false then return false end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- How far a number travels over its lifetime. Doubles as the crowding control: a taller rise
|
||||||
|
-- spreads a burst over more vertical space, which is exactly what the user asked this slider
|
||||||
|
-- for -- so the numbers don't overlap so heavily and have more room.
|
||||||
|
local function riseHeight()
|
||||||
|
local cfg = db()
|
||||||
|
local sct = cfg and cfg.sct
|
||||||
|
return (sct and sct.rise) or 40
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensureFs(i)
|
||||||
|
local s = slots[i]
|
||||||
|
if s.fs then return s.fs end
|
||||||
|
local fs = anchor:CreateFontString("VampifySCTNum"..i, "OVERLAY")
|
||||||
|
W.ApplyFont(fs, fontSize(), outlineFlag())
|
||||||
|
W.ApplyShadow(fs, shadowOn())
|
||||||
|
s.fs = fs
|
||||||
|
return fs
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Re-apply the font to every pooled FontString. Called when the size slider moves: the pool is
|
||||||
|
-- built lazily and lives for the session, so without this only numbers spawned from fresh
|
||||||
|
-- slots would pick up the new size.
|
||||||
|
function S.applyFont()
|
||||||
|
local sz, fl, sh = fontSize(), outlineFlag(), shadowOn()
|
||||||
|
for i = 1, POOL_MAX do
|
||||||
|
if slots[i] and slots[i].fs then
|
||||||
|
W.ApplyFont(slots[i].fs, sz, fl)
|
||||||
|
W.ApplyShadow(slots[i].fs, sh)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pop a free slot off the LIFO stack -- O(1), no table.remove/table.getn. Only when the stack
|
||||||
|
-- is empty (all 20 numbers in flight at once) does this fall back to an O(n) scan, and even
|
||||||
|
-- then it picks the oldest-active slot: closest to fading out anyway, so recycling it is the
|
||||||
|
-- least noticeable choice.
|
||||||
|
local function acquire()
|
||||||
|
if freeTop > 0 then
|
||||||
|
local i = freeStack[freeTop]
|
||||||
|
freeStack[freeTop] = nil
|
||||||
|
freeTop = freeTop - 1
|
||||||
|
return i
|
||||||
|
end
|
||||||
|
local oldest, oldestT = 1, -1
|
||||||
|
for i = 1, POOL_MAX do
|
||||||
|
if slots[i].t > oldestT then oldestT = slots[i].t; oldest = i end
|
||||||
|
end
|
||||||
|
return oldest
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Push a slot back onto the free stack once its number has fully faded.
|
||||||
|
local function release(i)
|
||||||
|
freeTop = freeTop + 1
|
||||||
|
freeStack[freeTop] = i
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The one shared animation driver for every active number. Attached on the first spawn,
|
||||||
|
-- detached the moment nothing is left in flight -- an idle SCT costs nothing.
|
||||||
|
local function onUpdate()
|
||||||
|
local dt = arg1
|
||||||
|
local any = false
|
||||||
|
for i = 1, POOL_MAX do
|
||||||
|
local s = slots[i]
|
||||||
|
if s.active then
|
||||||
|
s.t = s.t + dt
|
||||||
|
if s.t >= s.dur then
|
||||||
|
s.active = false
|
||||||
|
s.fs:Hide()
|
||||||
|
release(i)
|
||||||
|
else
|
||||||
|
any = true
|
||||||
|
local frac = s.t / s.dur
|
||||||
|
-- Rises sct.rise px over its lifetime; dy0 is an extra starting offset that decays to
|
||||||
|
-- zero as frac -> 1, so a burst of simultaneous numbers starts spread out and
|
||||||
|
-- converges onto the same rising line instead of stacking exactly on top of
|
||||||
|
-- each other for their whole arc. UNVERIFIED: this staggering is not backed by
|
||||||
|
-- any researched pattern (none exists for this in the wow-domain knowledge base
|
||||||
|
-- at time of writing) -- it is a simple, untested heuristic. Watch it in-game.
|
||||||
|
local rise = s.rise
|
||||||
|
if s.crit then
|
||||||
|
-- Blizzard pins a crit at its start position; here it still drifts, but far
|
||||||
|
-- less, so it reads as "popped in place" without freezing on screen.
|
||||||
|
rise = s.rise * 0.25
|
||||||
|
-- Grow, then shrink back -- the pop that distinguishes a crit from a big
|
||||||
|
-- normal number.
|
||||||
|
local h
|
||||||
|
if s.t < CRIT_GROW then
|
||||||
|
h = CRIT_MIN + (CRIT_MAX - CRIT_MIN) * (s.t / CRIT_GROW)
|
||||||
|
elseif s.t < CRIT_SHRINK then
|
||||||
|
h = CRIT_MAX - (CRIT_MAX - CRIT_MIN)
|
||||||
|
* ((s.t - CRIT_GROW) / (CRIT_SHRINK - CRIT_GROW))
|
||||||
|
else
|
||||||
|
h = CRIT_MIN
|
||||||
|
end
|
||||||
|
s.fs:SetTextHeight(s.base * h)
|
||||||
|
end
|
||||||
|
local y = frac * rise + s.dy0 * (1 - frac)
|
||||||
|
s.fs:ClearAllPoints()
|
||||||
|
s.fs:SetPoint("CENTER", anchor, "CENTER", s.dx, y)
|
||||||
|
s.fs:SetAlpha((1 - frac) * s.alphaMul)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not any then anchor:SetScript("OnUpdate", nil) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The path that actually spawns a number. S.emit gates this on cfg.sct.enabled; S.demo calls it
|
||||||
|
-- directly so positioning still works while SCT is switched off in the options window.
|
||||||
|
local function spawn(amount, isOverheal, isCrit)
|
||||||
|
build()
|
||||||
|
-- Lazy re-check, not OnUpdate polling: cheap no-op once the resolved anchor mode has not
|
||||||
|
-- changed (see lastAnchorMode), but catches a bar that came into existence after this
|
||||||
|
-- anchor was first built (load-order dependent) on the very next number spawned.
|
||||||
|
reanchor()
|
||||||
|
local cfg = db()
|
||||||
|
local sct = cfg and cfg.sct
|
||||||
|
if not sct then return end
|
||||||
|
|
||||||
|
local i = acquire()
|
||||||
|
local s = slots[i]
|
||||||
|
local fs = ensureFs(i)
|
||||||
|
|
||||||
|
s.active = true
|
||||||
|
s.t = 0
|
||||||
|
s.dur = sct.duration or DUR_FALLBACK
|
||||||
|
s.rise = riseHeight()
|
||||||
|
|
||||||
|
spawnCounter = spawnCounter + 1
|
||||||
|
-- Stagger scales with the rise: a taller column gets proportionally wider lanes and bigger
|
||||||
|
-- starting offsets, so raising the slider genuinely buys room instead of just stretching
|
||||||
|
-- the same crowding over a longer arc.
|
||||||
|
local spread = s.rise / 40
|
||||||
|
if math.mod(spawnCounter, 2) == 0 then s.dx = 12 * spread else s.dx = -12 * spread end
|
||||||
|
s.dy0 = math.mod(spawnCounter, 3) * 6 * spread
|
||||||
|
if isOverheal then s.alphaMul = 0.5 else s.alphaMul = 1 end
|
||||||
|
|
||||||
|
-- Vampirism itself never crits. A return CAUSED by a crit is marked the way the client's
|
||||||
|
-- own combat text marks one -- and that is not merely "bigger": Blizzard grows the number,
|
||||||
|
-- shrinks it back, and keeps it in place instead of scrolling it away
|
||||||
|
-- (Blizzard_CombatText.lua:280-282, 336-339). Copying the behaviour, not just the size, is
|
||||||
|
-- what makes it read as a crit at a glance.
|
||||||
|
s.crit = isCrit and true or false
|
||||||
|
s.base = fontSize()
|
||||||
|
W.ApplyFont(fs, s.base, outlineFlag())
|
||||||
|
W.ApplyShadow(fs, shadowOn())
|
||||||
|
if s.crit then
|
||||||
|
-- SetTextHeight scales the rendered text without touching the font object, which is why
|
||||||
|
-- Blizzard animates with it rather than re-applying a font every frame.
|
||||||
|
fs:SetTextHeight(s.base * CRIT_MIN)
|
||||||
|
else
|
||||||
|
fs:SetTextHeight(s.base)
|
||||||
|
end
|
||||||
|
|
||||||
|
local col = sct.color or { r = 0.4, g = 0.9, b = 0.4 }
|
||||||
|
fs:SetTextColor(col.r, col.g, col.b)
|
||||||
|
-- Overheal is parenthesized rather than minus-prefixed: it is still healing, and a leading
|
||||||
|
-- "-" would read as damage at a glance. The halved alpha above is the other half of telling
|
||||||
|
-- it apart from a real crossing.
|
||||||
|
if isOverheal then
|
||||||
|
fs:SetText("("..amount..")")
|
||||||
|
else
|
||||||
|
fs:SetText("+"..amount)
|
||||||
|
end
|
||||||
|
fs:ClearAllPoints()
|
||||||
|
fs:SetPoint("CENTER", anchor, "CENTER", s.dx, 0)
|
||||||
|
fs:SetAlpha(1 * s.alphaMul)
|
||||||
|
fs:Show()
|
||||||
|
|
||||||
|
anchor:SetScript("OnUpdate", onUpdate)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- amount is the integer to show -- an accumulator crossing for real heals (core/model.lua),
|
||||||
|
-- a per-hit floored amount for overheal (core/commands.lua) -- never a raw float. Zero
|
||||||
|
-- crossings are not emitted: the accumulator does not cross an integer on every hit, and a
|
||||||
|
-- stream of "+0" would just be noise (the same rule gui/fct.lua enforced before it).
|
||||||
|
function S.emit(amount, isOverheal, isCrit)
|
||||||
|
if not amount or amount <= 0 then return end
|
||||||
|
local cfg = db()
|
||||||
|
if not cfg or not cfg.sct or not cfg.sct.enabled then return end
|
||||||
|
spawn(amount, isOverheal, isCrit)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Three sample numbers so the user can see where they land while dragging the anchor. Bypasses
|
||||||
|
-- the enabled gate on purpose: positioning must work even with SCT switched off.
|
||||||
|
-- A burst spread over a couple of seconds, not three numbers at once: the whole point of the
|
||||||
|
-- demo is to judge spacing, rise and fade while tuning the sliders, and all three of those are
|
||||||
|
-- only visible when numbers arrive the way they do in a fight. Runs on its own ticker frame so
|
||||||
|
-- it cannot interfere with the animation driver, and detaches itself when finished.
|
||||||
|
local demoLeft, demoAcc, demoTicker = 0, 0, nil
|
||||||
|
|
||||||
|
function S.demo(count)
|
||||||
|
build()
|
||||||
|
demoLeft = count or 10
|
||||||
|
demoAcc = 0
|
||||||
|
if not demoTicker then
|
||||||
|
demoTicker = CreateFrame("Frame", "VampifySCTDemoFrame", UIParent)
|
||||||
|
end
|
||||||
|
demoTicker:SetScript("OnUpdate", function()
|
||||||
|
demoAcc = demoAcc + arg1
|
||||||
|
if demoAcc < 0.25 then return end
|
||||||
|
demoAcc = 0
|
||||||
|
if demoLeft <= 0 then
|
||||||
|
this:SetScript("OnUpdate", nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
demoLeft = demoLeft - 1
|
||||||
|
-- Every style the real thing can produce, because a demo that only shows one of them
|
||||||
|
-- lets you tune half the appearance: plain returns, dimmed overheal in parentheses, and
|
||||||
|
-- crit-caused returns in the larger font. Crits carry a bigger number, since a crit is
|
||||||
|
-- a bigger hit and therefore a bigger return.
|
||||||
|
local phase = math.mod(demoLeft, 5)
|
||||||
|
local isOver = (phase == 0)
|
||||||
|
local isCrit = (phase == 2 or phase == 3)
|
||||||
|
local n = math.mod(demoLeft, 4) + 3
|
||||||
|
if isCrit then n = n * 2 end
|
||||||
|
spawn(n, isOver, isCrit)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Returns true on success. Refuses to turn ON while anchored to the bar (mode == "bar") --
|
||||||
|
-- dragging is meaningless there, the anchor point is derived from VampifyDisplayFrame, not a
|
||||||
|
-- stored position -- and returns false plus a one-line reason instead, so callers (currently
|
||||||
|
-- core/commands.lua's "/vf sctmove") can tell the player why nothing happened. Turning OFF is
|
||||||
|
-- always allowed; there is nothing to refuse there.
|
||||||
|
function S.setMoveMode(on)
|
||||||
|
build()
|
||||||
|
if on then
|
||||||
|
local cfg = db()
|
||||||
|
local mode = cfg and cfg.sct and cfg.sct.mode
|
||||||
|
if mode == "bar" then
|
||||||
|
return false, "SCT is anchored to the bar in this mode -- switch to \"Free anchor\""
|
||||||
|
.." in Options to move it."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
moveMode = on and true or false
|
||||||
|
applyMoveVisuals()
|
||||||
|
if moveMode then S.demo() end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function S.isMoveMode()
|
||||||
|
return moveMode
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Explicit, forced re-anchor: called from gui/options.lua on every engine-mode radio click and
|
||||||
|
-- on "Reset" -- both want the anchor to move at once, not wait for the next spawn's lazy check.
|
||||||
|
function S.applyPos()
|
||||||
|
build()
|
||||||
|
reanchor(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
-- Vampify -- shared UI building blocks, ported from DopingControl's ui/widgets.lua.
|
||||||
|
--
|
||||||
|
-- Slim port: only what this addon's two windows actually use -- panel backdrop, a solid 1px
|
||||||
|
-- backdrop, a pfUI-aware font helper, a text factory, a dark gold-trim button, the change-guarded
|
||||||
|
-- setters that keep an idle window at zero allocation, and a minimal tooltip attach helper. The
|
||||||
|
-- cell/pill/badge factories and the two-column tooltip helper in the source addon exist for its
|
||||||
|
-- raid-roster grid, which this addon does not have, so they are not ported.
|
||||||
|
--
|
||||||
|
-- Pure Lua 5.0, dofile-loadable: top level only defines tables/functions; CreateFrame and friends
|
||||||
|
-- are only referenced INSIDE factory functions, which are never called offline.
|
||||||
|
|
||||||
|
VampifyWidgets = {}
|
||||||
|
local W = VampifyWidgets
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Texture / font constants
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
W.SOLID_TEX = "Interface\\Buttons\\WHITE8X8"
|
||||||
|
W.FONT = "Fonts\\FRIZQT__.TTF"
|
||||||
|
|
||||||
|
-- Main window backdrop. Textures shipped in Vampify\textures\ -- copied from DopingControl's own
|
||||||
|
-- panel_bg/panel_border, NOT referenced through DopingControl's AddOns path: this addon must not
|
||||||
|
-- depend on DopingControl being installed.
|
||||||
|
W.PANEL_BACKDROP = {
|
||||||
|
bgFile = "Interface\\AddOns\\Vampify\\textures\\panel_bg",
|
||||||
|
edgeFile = "Interface\\AddOns\\Vampify\\textures\\panel_border",
|
||||||
|
tile = true, tileSize = 128, edgeSize = 16,
|
||||||
|
insets = { left = 5, right = 5, top = 5, bottom = 5 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Solid 1px-border backdrop (WHITE8X8 recipe): bg + border colored via SetBackdropColor /
|
||||||
|
-- SetBackdropBorderColor on the frame that uses it.
|
||||||
|
W.SOLID_BACKDROP = {
|
||||||
|
bgFile = W.SOLID_TEX,
|
||||||
|
edgeFile = W.SOLID_TEX,
|
||||||
|
tile = false, edgeSize = 1,
|
||||||
|
insets = { left = 1, right = 1, top = 1, bottom = 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Color palette. Only the entries this addon's two windows use: the gold family for the title and
|
||||||
|
-- button text, the panel/line neutrals for button states, and green/red for the two warning lines
|
||||||
|
-- that need to say something is wrong.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
local function hexrgb(hex)
|
||||||
|
return {
|
||||||
|
r = tonumber(string.sub(hex, 1, 2), 16) / 255,
|
||||||
|
g = tonumber(string.sub(hex, 3, 4), 16) / 255,
|
||||||
|
b = tonumber(string.sub(hex, 5, 6), 16) / 255,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
W.COLORS = {
|
||||||
|
goldHi = hexrgb("e6c87e"), -- title / highlight gold
|
||||||
|
gold = hexrgb("8f7440"), -- hover border gold
|
||||||
|
goldSoft = hexrgb("54462a"), -- resting border gold
|
||||||
|
panel2 = hexrgb("171b23"), -- resting button bg
|
||||||
|
panel3 = hexrgb("1d222c"), -- hover button bg
|
||||||
|
line = hexrgb("262c37"), -- standard borders / dividers
|
||||||
|
ink = hexrgb("c9cdd4"), -- body text
|
||||||
|
dim = hexrgb("868d99"), -- secondary text
|
||||||
|
faint = hexrgb("5b6270"), -- tertiary / hints
|
||||||
|
warn = hexrgb("e0c98a"), -- accuracy-hint yellow
|
||||||
|
bad = hexrgb("d05252"), -- no-data red
|
||||||
|
-- Added for the compact-bar/breakdown-panel mockup redesign (2026-08-24): Single-Target vs.
|
||||||
|
-- AoE get their own hue everywhere the two are told apart (the bar's badge group, the panel's
|
||||||
|
-- split row, each ability row's two-color fill), HPS gets its own accent on the bar, badgeBg
|
||||||
|
-- backs the bar's badge-group pill so it reads as its own grouped control rather than more
|
||||||
|
-- bar text.
|
||||||
|
stBlue = hexrgb("5b8fd9"), -- Single-Target bare color chips (the compact-bar pill segment)
|
||||||
|
aoeOrange = hexrgb("d98a3d"), -- AoE bare color chips (the compact-bar pill segment)
|
||||||
|
hpsGreen = hexrgb("7cc576"), -- compact-bar HPS segment
|
||||||
|
badgeBg = hexrgb("11151c"), -- compact-bar badge-group background (legacy, panel row bg reuse)
|
||||||
|
|
||||||
|
-- CANONICAL red/blue pair (in-game correction, reported verbatim: "same color coding as in the
|
||||||
|
-- bar (red/blue)") -- unify the breakdown panel onto the SAME two colors the compact bar's
|
||||||
|
-- own pill already uses, from this ONE shared source, not defined twice). RED = Vamp healing
|
||||||
|
-- landed on the player's CURRENT TARGET, BLUE = everything else -- the same semantics
|
||||||
|
-- everywhere these are used (the compact pill, the panel's split row, each ability row's
|
||||||
|
-- two-color fill). Values verbatim from what used to be gui/display.lua's own local PILL_RED/
|
||||||
|
-- PILL_BLUE (196/255,62/255,58/255 and 53/255,96/255,161/255) -- moved here so both call sites
|
||||||
|
-- read the identical table and can never drift apart again. Also passes this file's own 4.5:1
|
||||||
|
-- bar-text floor with real headroom (verified via an offline contrast-check render script:
|
||||||
|
-- ink-on-red 10.88:1, ink-on-blue 11.91:1 -- stronger than the retired stBlueFill/aoeOrangeFill
|
||||||
|
-- pair's own 6.01:1/5.15:1), so no separate "under text" darkened variant is needed the way the
|
||||||
|
-- old blue/orange pair required.
|
||||||
|
targetRed = hexrgb("C43E3A"),
|
||||||
|
restBlue = hexrgb("3560A1"),
|
||||||
|
-- Neutral grey bar fill for the breakdown panel's collapsed "Other (N)" row (Befund 4) -- a
|
||||||
|
-- deliberately different hue from targetRed/restBlue/goldSoft so a mixed ST+AoE summary
|
||||||
|
-- row never reads as a real, classified ability. White-on-otherGrey measures 7.96:1.
|
||||||
|
otherGrey = hexrgb("4a5160"),
|
||||||
|
-- Near-white text drawn directly on top of a bar fill (split row, ability rows) -- paired with
|
||||||
|
-- the OUTLINE font flag (W.ApplyFont's third argument) at those call sites for the "1px dark
|
||||||
|
-- outline" kept in addition to the contrast fix itself.
|
||||||
|
barText = { r = 0.97, g = 0.96, b = 0.94 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Font helper. Keeps OUR pixel size -- this addon's layout is hand-fitted to it -- and only takes
|
||||||
|
-- pfUI's font FAMILY when pfUI is present, so text reads consistently with the rest of the client
|
||||||
|
-- without pfUI's global size setting reflowing this window.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- `flags` is SetFont's third argument: nil, "OUTLINE" or "THICKOUTLINE" (also "MONOCHROME", which
|
||||||
|
-- we do not use). An outline is what makes light text survive a light background -- green numbers
|
||||||
|
-- over grass being the case that prompted it.
|
||||||
|
function W.ApplyFont(fs, size, flags)
|
||||||
|
size = size or 10
|
||||||
|
if pfUI and pfUI.font_default then
|
||||||
|
fs:SetFont(pfUI.font_default, size, flags)
|
||||||
|
else
|
||||||
|
fs:SetFont(W.FONT, size, flags)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A soft drop shadow on a whole BACKDROP frame (the compact bar, the breakdown panel) -- pfUI's
|
||||||
|
-- own pfUI.api.CreateBackdropShadow(f), confirmed live-used at pfUI/modules/gui.lua:495-family
|
||||||
|
-- call sites and real in this client (not a Retail-only API). No-op when pfUI is not loaded --
|
||||||
|
-- this is pure cosmetic polish for the mockup redesign (2026-08-24), never a load-bearing part of
|
||||||
|
-- the layout, so a client without pfUI must render exactly as before (flat 1px SOLID_BACKDROP
|
||||||
|
-- border, no shadow) rather than error or leave a gap where the shadow would have been.
|
||||||
|
function W.ApplyBackdropShadow(f)
|
||||||
|
if pfUI and pfUI.api and pfUI.api.CreateBackdropShadow then
|
||||||
|
pfUI.api.CreateBackdropShadow(f)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A drop shadow on top of the outline. The two solve different halves of the same problem: the
|
||||||
|
-- outline separates the glyph from the background, the shadow gives it depth against a background
|
||||||
|
-- of the SAME brightness, where an outline of the wrong colour would disappear too.
|
||||||
|
function W.ApplyShadow(fs, on)
|
||||||
|
if on then
|
||||||
|
fs:SetShadowColor(0, 0, 0, 0.9)
|
||||||
|
fs:SetShadowOffset(1, -1)
|
||||||
|
else
|
||||||
|
fs:SetShadowOffset(0, 0)
|
||||||
|
fs:SetShadowColor(0, 0, 0, 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Change-guarded setters. Convention: cache the last-applied value on the region as vfLast*; only
|
||||||
|
-- call the real setter when the value changed. This is what lets the options window's throttled
|
||||||
|
-- info panel repaint every 0.5s without a SetText call (and the redraw it costs) on every tick.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.SetTextG(fs, text)
|
||||||
|
if fs.vfLastText ~= text then
|
||||||
|
fs.vfLastText = text
|
||||||
|
fs:SetText(text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function W.SetTextColorG(fs, c)
|
||||||
|
if fs.vfLastColor ~= c then
|
||||||
|
fs.vfLastColor = c
|
||||||
|
fs:SetTextColor(c.r, c.g, c.b)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function W.SetBackdropG(f, bg, border)
|
||||||
|
if f.vfLastBg ~= bg then
|
||||||
|
f.vfLastBg = bg
|
||||||
|
f:SetBackdropColor(bg.r, bg.g, bg.b, bg.a or 1)
|
||||||
|
end
|
||||||
|
if f.vfLastBorder ~= border then
|
||||||
|
f.vfLastBorder = border
|
||||||
|
f:SetBackdropBorderColor(border.r, border.g, border.b, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- FontString factory.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.MakeText(parent, size, color, layer)
|
||||||
|
local fs = parent:CreateFontString(nil, layer or "OVERLAY")
|
||||||
|
W.ApplyFont(fs, size)
|
||||||
|
if color then
|
||||||
|
W.SetTextColorG(fs, color)
|
||||||
|
end
|
||||||
|
return fs
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Dark gold-trim button, the addon's one button style.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.MakeButton(parent, name, label, width, height)
|
||||||
|
local C = W.COLORS
|
||||||
|
local b = CreateFrame("Button", name, parent)
|
||||||
|
b:SetWidth(width or 100)
|
||||||
|
b:SetHeight(height or 22)
|
||||||
|
b:SetBackdrop(W.SOLID_BACKDROP)
|
||||||
|
W.SetBackdropG(b, C.panel2, C.goldSoft)
|
||||||
|
b.text = b:CreateFontString(nil, "OVERLAY")
|
||||||
|
W.ApplyFont(b.text, 10)
|
||||||
|
b.text:SetPoint("CENTER", b, "CENTER", 0, 0)
|
||||||
|
W.SetTextColorG(b.text, C.goldHi)
|
||||||
|
b.text:SetText(label)
|
||||||
|
b:SetScript("OnEnter", function()
|
||||||
|
if this.vfDisabled then return end
|
||||||
|
W.SetBackdropG(this, C.panel3, C.gold)
|
||||||
|
end)
|
||||||
|
b:SetScript("OnLeave", function()
|
||||||
|
if this.vfDisabled then return end
|
||||||
|
W.SetBackdropG(this, C.panel2, C.goldSoft)
|
||||||
|
end)
|
||||||
|
return b
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Enable/disable for MakeButton buttons. A hand-drawn button (SOLID_BACKDROP + its own text, no
|
||||||
|
-- Blizzard disabled-texture) needs its dimming done by hand: native Button:Disable() stops OnClick
|
||||||
|
-- from firing, but the resting/hover backdrop colors and the gold text would otherwise still read
|
||||||
|
-- as "clickable". b.vfDisabled additionally guards MakeButton's own OnEnter/OnLeave above so a
|
||||||
|
-- disabled button does not light up on hover.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Sliders in 1.12 have NO Enable()/Disable() -- those are Button methods, and calling them on a
|
||||||
|
-- Slider throws "attempt to call method `Enable' (a nil value)" (caught in-game 2026-08-10).
|
||||||
|
-- Blizzard's own way, from FrameXML/OptionsFrame.lua:481-493, is to hide the thumb and grey the
|
||||||
|
-- three label font strings; EnableMouse(false) is added here so the track ignores clicks too.
|
||||||
|
function W.SetSliderEnabled(sl, enabled)
|
||||||
|
if not sl then return end
|
||||||
|
local C = W.COLORS
|
||||||
|
local name = sl:GetName()
|
||||||
|
local thumb = getglobal(name .. "Thumb")
|
||||||
|
local txt, lo, hi = getglobal(name .. "Text"), getglobal(name .. "Low"), getglobal(name .. "High")
|
||||||
|
local col = enabled and C.ink or C.faint
|
||||||
|
sl:EnableMouse(enabled and true or false)
|
||||||
|
if thumb then if enabled then thumb:Show() else thumb:Hide() end end
|
||||||
|
if txt then txt:SetTextColor(col.r, col.g, col.b) end
|
||||||
|
if lo then lo:SetTextColor(col.r, col.g, col.b) end
|
||||||
|
if hi then hi:SetTextColor(col.r, col.g, col.b) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function W.SetButtonEnabled(b, enabled)
|
||||||
|
local C = W.COLORS
|
||||||
|
if enabled then
|
||||||
|
b.vfDisabled = nil
|
||||||
|
b:Enable()
|
||||||
|
W.SetBackdropG(b, C.panel2, C.goldSoft)
|
||||||
|
W.SetTextColorG(b.text, C.goldHi)
|
||||||
|
else
|
||||||
|
b.vfDisabled = true
|
||||||
|
b:Disable()
|
||||||
|
W.SetBackdropG(b, C.panel2, C.line)
|
||||||
|
W.SetTextColorG(b.text, C.faint)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Minimal tooltip attach: chains any existing OnEnter/OnLeave (no HookScript on 1.12), shows a
|
||||||
|
-- single-line tooltip. The convention matches DopingControl's AddTooltip/TipLine pair, trimmed to
|
||||||
|
-- the one line shape this addon needs.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
function W.TipLine(text, c)
|
||||||
|
if not GameTooltip then return end
|
||||||
|
local r, g, b = 1, 1, 1
|
||||||
|
if c then r, g, b = c.r, c.g, c.b end
|
||||||
|
GameTooltip:AddLine(text, r, g, b, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
function W.AttachTooltip(frame, text)
|
||||||
|
local oldEnter = frame:GetScript("OnEnter")
|
||||||
|
local oldLeave = frame:GetScript("OnLeave")
|
||||||
|
frame.vfTip = text
|
||||||
|
frame:SetScript("OnEnter", function()
|
||||||
|
if oldEnter then oldEnter() end
|
||||||
|
if GameTooltip and this.vfTip then
|
||||||
|
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
||||||
|
GameTooltip:SetText(this.vfTip, 1, 1, 1, 1, 1)
|
||||||
|
GameTooltip:Show()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
frame:SetScript("OnLeave", function()
|
||||||
|
if oldLeave then oldLeave() end
|
||||||
|
if GameTooltip then GameTooltip:Hide() end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |