Files
Vampify/capture/detect.lua
T
ShempError b3a282aeb5 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.
2026-08-25 18:05:45 +02:00

184 lines
8.6 KiB
Lua

-- 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