Vampify v0.4.0

This commit is contained in:
2026-08-30 15:37:26 +02:00
parent ad50ebe636
commit d3ad691770
14 changed files with 271 additions and 41 deletions
-4
View File
@@ -1,4 +0,0 @@
# Editor / OS noise
.DS_Store
Thumbs.db
*.log
+23 -16
View File
@@ -2,26 +2,20 @@
All notable changes to Vampify are documented here. All notable changes to Vampify are documented here.
## Unreleased ## 0.4.0 — 2026-08-30
### Added ### Added
- MIT license, this changelog, and a README written for players rather than for the author. - Options window controls for two settings that were already live but had no UI: a "Fade duration"
slider (0.5-4.0s) and a "Text shadow" checkbox for the scrolling combat text, next to the outline
### Fixed cycle button. Both are also reachable from the command line: `/vf sct duration <seconds>` and
- Long lines in a row's detail panel are wrapped instead of being drawn over the lines below them. `/vf sct shadow on|off`.
- Tooltips raised while the breakdown panel is open now appear above it rather than behind it. - The glass-bar textures the 0.3.0 upload left out (`textures/hps_orb_*` and
- The whole bar can be dragged when it is unlocked. Several parts of it — the leftmost number, the `textures/pill_*_cap`). Without them the bars drew without their fill, shading and gloss.
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 ### Changed
- The display bar is compact: the prefix and the session ratio are gone, and it shows the equipped - The per-hit export carries three more fields for the offline analysis: each source's own
loadout percentage together with the number of sources it found. percentage, the zone, and whether the hit followed a cast of the same spell (item and enchant
- The per-hit export records maximum health per hit, so an offline analysis can tell overheal apart procs never do). All three are optional, so an older reader still parses the line.
from healing that never arrived.
## 0.3.0 — 2026-08-22 ## 0.3.0 — 2026-08-22
@@ -34,12 +28,17 @@ All notable changes to Vampify are documented here.
- The damage-over-time channel is measured rather than estimated; the display no longer hedges it. - 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, - AoE damping is applied only where a hit is proven to have struck two or more distinct targets,
rather than inferred from the spell. rather than inferred from the spell.
- 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.
### Added ### Added
- Per-hit debug export for measuring the per-source factor, with a persisted toggle and an options - Per-hit debug export for measuring the per-source factor, with a persisted toggle and an options
checkbox. checkbox.
- Spell exclusions: a spell can be stopped from counting as a Vampirism trigger, and the exclusion - Spell exclusions: a spell can be stopped from counting as a Vampirism trigger, and the exclusion
survives a reload. survives a reload.
- MIT license, this changelog, and a README written for players rather than for the author.
### Fixed ### Fixed
- The raw per-hit capture no longer depends on the watch-mode group guard. - The raw per-hit capture no longer depends on the watch-mode group guard.
@@ -48,6 +47,14 @@ All notable changes to Vampify are documented here.
can actually trust, instead of firing on a quiet moment. can actually trust, instead of firing on a quiet moment.
- An AoE correction removes the integers it actually credited rather than reconstructing them. - 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. - A mid-fight gear swap is only checked for while a fight is running.
- 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.
## 0.2.0 — 2026-08-10 ## 0.2.0 — 2026-08-10
+1 -1
View File
@@ -2,7 +2,7 @@
## Title: Vampify ## Title: Vampify
## Notes: Tracks the healing returned by the Vampirism item stat. ## Notes: Tracks the healing returned by the Vampirism item stat.
## Author: ShempError ## Author: ShempError
## Version: 0.3.0 ## Version: 0.4.0
## SavedVariables: VampifyDB ## SavedVariables: VampifyDB
## SavedVariablesPerCharacter: VampifyCharDB ## SavedVariablesPerCharacter: VampifyCharDB
+40
View File
@@ -117,6 +117,42 @@ function G.onDamage(fn) table.insert(listeners, fn) end
local goSeen = false local goSeen = false
local damageSeen = false local damageSeen = false
-- ---- "did the PLAYER cast this, or did it proc?" (per-hit export field `cast`) ----------------
--
-- The same SPELL_GO_SELF that latches goSeen above is also the only observable in the client that
-- separates a CAST spell from a PROCCED one: it precedes the damage events of a cast and never
-- fires for an item/enchant proc (the sentence right above G.deriveAoE says exactly this, and it
-- is why deriveAoE had to exist at all). Hypothesis H2 -- that Vampirism is gated on the TRIGGER
-- PATH ("was this triggered by an aura") rather than on "is this an item proc" -- turns on that
-- distinction for one and the same spell id, so the offline analysis needs it recorded per hit
-- rather than inferred from timing (see core/perhit.lua's formatHitLine comment).
--
-- Keyed by spell id, and deliberately WITHOUT the eviction sweep this project requires of
-- per-GUID tables: the key space here is the player's own castable spell ids, which is bounded by
-- the spellbook and does not grow with time or with mob spawns. That is the exact property the
-- eviction rule exists to protect against, and it does not apply.
local lastGo = {}
-- Pure, so the predicate is testable without firing events: "was there a cast of this spell id
-- within `window` seconds before `now`". Default window 1.5s -- comfortably longer than the gap
-- between SPELL_GO_SELF and the damage events of the same cast (the GO precedes them within the
-- same or the next frame), and short enough that the PREVIOUS cast of the same spell (global
-- cooldown 1.5s at the very fastest) cannot be mistaken for this one.
local CAST_WINDOW = 1.5
G.CAST_WINDOW = CAST_WINDOW
function G.noteCast(spellId, t)
if spellId then lastGo[spellId] = t end
end
function G.castSeenFor(spellId, now, window)
if not spellId then return false end
local t = lastGo[spellId]
if not t or not now then return false end
local dt = now - t
return dt >= 0 and dt <= (window or CAST_WINDOW)
end
-- ---- AoE derivation from distinct targets, cast or not (spec 4.2, extended 2026-08-22) -------- -- ---- 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 -- Originally written as the fallback for weapon/item procs (Force Reactive Disc's damage shield
@@ -248,6 +284,10 @@ if CreateFrame then
-- actual decision for every hit of this cast still runs through G.deriveAoE below, 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. -- exact same distinct-target proof a no-cast proc already has to provide.
goSeen = true goSeen = true
-- arg2 is the cast's spell id (see the event's field list at the top of this file).
-- Noting it is what lets a later damage event of the SAME id say "this one was cast",
-- which is the `cast` field of the per-hit export -- see G.castSeenFor above.
G.noteCast(arg2, GetTime())
elseif event == "SPELL_DAMAGE_EVENT_SELF" then elseif event == "SPELL_DAMAGE_EVENT_SELF" then
local amount, tgt, spellId, caster = G.decodeSpell(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) 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. -- Trust arg2 rather than the _SELF suffix, and never count a hit on ourselves.
+71 -5
View File
@@ -319,6 +319,28 @@ local lastHp, lastAt, lastExpected = nil, nil, nil
local nextCheck = 0 local nextCheck = 0
local WATCH_INTERVAL = 15 -- slow on purpose: findings are read by a human, not a loop local WATCH_INTERVAL = 15 -- slow on purpose: findings are read by a human, not a loop
-- ---- the zone, for the per-hit export ---------------------------------------------------------
--
-- Cached rather than read per hit. GetRealZoneText() is cheap but it RETURNS A STRING, and a
-- string per damage event is exactly the per-frame allocation this project forbids in hot paths
-- (30-60 hits/sec during an AoE pull). The zone changes on a loading screen and nowhere else, so
-- it is refreshed from the two events that already fire there.
--
-- WHY IT IS RECORDED AT ALL: an offline analysis could not answer "use only the Shadowfang Keep
-- data" -- the export had no way to tell one grinding session's zone from another's, and mob
-- level (and therefore hit size, and therefore how often the per-source floor of 1 dominates the
-- percentage) varies enormously between them. See docs/VAMPIRISM-MODELL-STAND §4.
--
-- Pipes are stripped because "|" is this format's field separator; no real zone name contains one,
-- but a parser that splits on "|" must not be able to be broken by a name at all.
local zoneName = "?"
local function refreshZone()
local z = GetRealZoneText and GetRealZoneText()
if not z or z == "" then z = GetZoneText and GetZoneText() end
if not z or z == "" then zoneName = "?" return end
zoneName = string.gsub(z, "|", "")
end
-- print_ already prefixes "Vampify", so this must not repeat it -- the first field build did, and -- print_ already prefixes "Vampify", so this must not repeat it -- the first field build did, and
-- the result read "Vampify Vampify watch:". -- the result read "Vampify Vampify watch:".
-- --
@@ -485,6 +507,13 @@ if VampifyDamage.onDamage then
pred_heal = healFloat, pred_heal = healFloat,
acc_total = VampifyState.acc.total, acc_total = VampifyState.acc.total,
crit = isCrit, crit = isCrit,
-- The three fields the offline analysis was missing (docs/VAMPIRISM-MODELL-STAND
-- §4). pcts hands over the LIVE list -- PH.recordHit serialises it immediately
-- rather than holding the reference, which matters because this very table is
-- refilled in place on every gear change (see recompute above).
pcts = sourcePercents,
zone = zoneName,
cast = VampifyDamage.castSeenFor and VampifyDamage.castSeenFor(spellId, GetTime()),
}, UnitHealth and UnitHealth("player"), GetTime(), UnitHealthMax and UnitHealthMax("player")) }, UnitHealth and UnitHealth("player"), GetTime(), UnitHealthMax and UnitHealthMax("player"))
end end
@@ -823,6 +852,9 @@ if CreateFrame then
f:RegisterEvent("PLAYER_CAMPING") f:RegisterEvent("PLAYER_CAMPING")
f:RegisterEvent("PLAYER_QUITING") f:RegisterEvent("PLAYER_QUITING")
f:RegisterEvent("LOGOUT_CANCEL") f:RegisterEvent("LOGOUT_CANCEL")
-- Fires on every loading screen and on every zone border crossed on foot -- the only two ways
-- the cached zone name above can go stale.
f:RegisterEvent("ZONE_CHANGED_NEW_AREA")
-- The running count of combat seconds the "no damage events at all" verdict is given from. -- The running count of combat seconds the "no damage events at all" verdict is given from.
-- Declared out here rather than beside the OnUpdate that uses it because the combat-end branch -- Declared out here rather than beside the OnUpdate that uses it because the combat-end branch
@@ -860,8 +892,13 @@ if CreateFrame then
end end
return return
end end
if event == "ZONE_CHANGED_NEW_AREA" then
refreshZone()
return
end
if event == "PLAYER_LOGIN" or event == "PLAYER_ENTERING_WORLD" then if event == "PLAYER_LOGIN" or event == "PLAYER_ENTERING_WORLD" then
if event == "PLAYER_ENTERING_WORLD" then wireSession() end if event == "PLAYER_ENTERING_WORLD" then wireSession() end
refreshZone()
recompute() recompute()
syncPerHit() syncPerHit()
if VampifyMinimap and VampifyMinimap.update then VampifyMinimap.update() end if VampifyMinimap and VampifyMinimap.update then VampifyMinimap.update() end
@@ -1090,6 +1127,8 @@ local function help()
print_("/vf pos -- move the display back to its default position") print_("/vf pos -- move the display back to its default position")
print_("/vf lock -- toggle frame dragging") print_("/vf lock -- toggle frame dragging")
print_("/vf fct / sct -- toggle scrolling combat text (either engine)") print_("/vf fct / sct -- toggle scrolling combat text (either engine)")
print_("/vf sct duration <s> -- SCT fade duration, 0.5-4.0 seconds")
print_("/vf sct shadow on|off -- SCT text shadow")
print_("/vf sctmove -- move our own SCT anchor (options window has the same button)") print_("/vf sctmove -- move our own SCT anchor (options window has the same button)")
print_("/vf options -- open the options window") print_("/vf options -- open the options window")
print_("/vf source add <pct> -- add a manual source, if tooltip detection fails") print_("/vf source add <pct> -- add a manual source, if tooltip detection fails")
@@ -1163,12 +1202,39 @@ SlashCmdList["VAMPIFY"] = function(msg)
elseif cmd == "fct" or cmd == "sct" then elseif cmd == "fct" or cmd == "sct" then
local cfg = VampifyConfig.get() local cfg = VampifyConfig.get()
if not cfg or not cfg.sct then print_("config not loaded yet.") return end if not cfg or not cfg.sct then print_("config not loaded yet.") return end
cfg.sct.enabled = not cfg.sct.enabled -- Bare "/vf sct" / "/vf fct" keeps its original meaning (toggle enabled); "sct <sub> <val>"
if cfg.sct.enabled and cfg.sct.mode == "blizzard" and SHOW_COMBAT_TEXT ~= "1" then -- is the same sub/val dispatch pattern /vf source and /vf exclude already use above. %S*
print_("scrolling combat text ON -- but Blizzard's own combat text is disabled in its" -- rather than %d* for val: duration needs a decimal ("1.5"), shadow needs a word (on/off).
.." options, so nothing will show in blizzard mode.") local _, _, sub, val = string.find(rest, "^(%a*)%s*(%S*)")
if sub == "" then
cfg.sct.enabled = not cfg.sct.enabled
if cfg.sct.enabled and cfg.sct.mode == "blizzard" and SHOW_COMBAT_TEXT ~= "1" then
print_("scrolling combat text ON -- but Blizzard's own combat text is disabled in its"
.." options, so nothing will show in blizzard mode.")
else
print_(cfg.sct.enabled and "scrolling combat text ON." or "scrolling combat text OFF.")
end
elseif sub == "duration" then
local s = tonumber(val)
if not s or s < 0.5 or s > 4.0 then
print_("/vf sct duration <seconds> -- 0.5 to 4.0, e.g. /vf sct duration 1.5")
else
cfg.sct.duration = s
print_("SCT fade duration set to "..s.."s.")
end
elseif sub == "shadow" then
if val == "on" then
cfg.sct.shadow = true
elseif val == "off" then
cfg.sct.shadow = false
else
print_("/vf sct shadow on|off")
return
end
if VampifySCT and VampifySCT.applyFont then VampifySCT.applyFont() end
print_(cfg.sct.shadow and "SCT text shadow ON." or "SCT text shadow OFF.")
else else
print_(cfg.sct.enabled and "scrolling combat text ON." or "scrolling combat text OFF.") help()
end end
elseif cmd == "sctmove" then elseif cmd == "sctmove" then
if not (VampifySCT and VampifySCT.setMoveMode and VampifySCT.isMoveMode) then if not (VampifySCT and VampifySCT.setMoveMode and VampifySCT.isMoveMode) then
+1 -1
View File
@@ -7,7 +7,7 @@
VampifyConst = {} VampifyConst = {}
local C = VampifyConst local C = VampifyConst
C.VERSION = "0.3.0" C.VERSION = "0.4.0"
-- Empty a list buffer for reuse. THE table.setn IS THE POINT: in Lua 5.0 table.insert maintains an -- 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 -- `n` field, and nil-ing the indices by hand does not reset it -- so the next round of inserts
+59 -5
View File
@@ -140,13 +140,59 @@ function PH.pushLine(state, line)
end end
end end
-- The INDIVIDUAL source percentages of the hit, as "3/2/2/2".
--
-- WHY IT HAD TO BE ADDED (offline analysis, 2026-08-30). The line carried only the SUMMED
-- percentage P and the source count n, and the server truncates every source SEPARATELY
-- (core/model.lua's header): max(1, floor(pct_i * damage / 100)) per source. From P and n alone
-- that is not reconstructable -- an offline reader has to approximate the list as n equal shares,
-- which is exactly VampifyModel.heal's documented-inexact compatibility shape. Measured against
-- the recorded pred_heal over 4907 windows that approximation is off by 4.7 %, and 4.7 % is the
-- same order as the effects the analysis is trying to separate. With the real list the offline
-- model becomes exact instead of approximate, and the AoE-ordering question core/model.lua's
-- header leaves open (damp the damage, or damp each source's heal) becomes decidable from
-- recorded data rather than needing a new in-game measurement.
--
-- "%g", not "%d" or "%.2f": a source percentage is normally a small integer (3, 2, 2) and should
-- print as one, but nothing guarantees it -- a fractional percentage must survive rather than be
-- silently truncated into a wrong number.
--
-- Buffer-recycled like every other reused list here (VampifyConst.resetList's comment): this runs
-- once per own hit, and a fresh table per hit is avoidable churn on a channel that is meant to run
-- permanently. The caller's list is READ ONLY -- and the result is a STRING, which matters: the
-- live source list is itself a recycled buffer (core/commands.lua's sourcePercents, refilled in
-- place on every gear change), so a row may not hold a reference to it. Serialising here, at
-- record time, is what keeps a buffered row from silently re-reading a list that has since changed.
local pctBuf = {}
function PH.formatPercents(list)
if not list then return "" end
local n = table.getn(list)
for i = table.getn(pctBuf), n + 1, -1 do pctBuf[i] = nil end
table.setn(pctBuf, n)
for i = 1, n do pctBuf[i] = string.format("%g", list[i] or 0) end
return table.concat(pctBuf, "/", 1, n)
end
-- pcts / zone / cast are the three fields the offline analysis asked for (docs/
-- VAMPIRISM-MODELL-STAND §4). All three are optional: an older caller that does not supply them
-- still produces a well-formed line ("", "?" and 0), so the export never depends on every wiring
-- site having been updated in the same change.
--
-- cast=1 means a SPELL_GO_SELF for THIS spell id arrived shortly before this hit -- i.e. the player
-- CAST it. cast=0 means no cast announced it, which for a damaging spell is the signature of a
-- proc. That distinction is the whole point: hypothesis H2 says Vampirism is fed by the TRIGGER
-- PATH ("was this triggered by an aura") rather than by "is this an item proc", and the cast event
-- is the one observable in the client that separates the two paths for the same spell id. Without
-- it the offline classification has to be inferred from timing against Lightning Strike hits and
-- incoming damage, which is what the current analysis does and why it can only report AMBIG for
-- 13.4 % of Tidal Wave hits.
function PH.formatHitLine(row) function PH.formatHitLine(row)
return string.format( 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|" "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", .."hp_max=%s|acc_total=%.4f|crit=%d|pcts=%s|zone=%s|cast=%d",
row.t, row.src, row.dmg, (row.aoe and 1 or 0), row.P, row.n, row.pred_heal, 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, tostring(row.hp_before), tostring(row.hp_after), tostring(row.hp_max), row.acc_total,
(row.crit and 1 or 0)) (row.crit and 1 or 0), row.pcts or "", row.zone or "?", (row.cast and 1 or 0))
end end
function PH.formatIncomingLine(row) function PH.formatIncomingLine(row)
@@ -177,15 +223,23 @@ function PH.finalizePending(state, hpNow)
return true return true
end end
-- fields: t, src, dmg, aoe, P, n, pred_heal, acc_total, crit -- everything except the health -- fields: t, src, dmg, aoe, P, n, pred_heal, acc_total, crit, pcts, zone, cast -- everything
-- readings, which this function supplies itself: hp_before = hpNow and hp_max = hpMaxNow are both -- except the health readings, which this function supplies itself: hp_before = hpNow and
-- stamped HERE, at the same point, and never touched again; hp_after comes later, at finalize. -- hp_max = hpMaxNow are both stamped HERE, at the same point, and never touched again; hp_after
-- comes later, at finalize.
--
-- fields.pcts is the LIVE source-percent list and is serialised HERE, not held. The row can sit
-- pending (and then buffered) for a long time, and that list is a recycled buffer refilled in
-- place whenever gear changes -- keeping the reference would make an already-recorded hit report
-- whatever the source set happens to be at flush time. Same reasoning as the health readings
-- above: a value that describes THIS hit is captured at this hit.
function PH.recordHit(state, fields, hpNow, tNow, hpMaxNow) function PH.recordHit(state, fields, hpNow, tNow, hpMaxNow)
PH.finalizePending(state, hpNow) PH.finalizePending(state, hpNow)
state.pending = { state.pending = {
t = fields.t, src = fields.src, dmg = fields.dmg, aoe = fields.aoe, t = fields.t, src = fields.src, dmg = fields.dmg, aoe = fields.aoe,
P = fields.P, n = fields.n, pred_heal = fields.pred_heal, P = fields.P, n = fields.n, pred_heal = fields.pred_heal,
acc_total = fields.acc_total, crit = fields.crit, acc_total = fields.acc_total, crit = fields.crit,
pcts = PH.formatPercents(fields.pcts), zone = fields.zone, cast = fields.cast,
hp_before = hpNow, hp_max = hpMaxNow, pendingAt = tNow, hp_before = hpNow, hp_max = hpMaxNow, pendingAt = tNow,
} }
state.totalHits = state.totalHits + 1 state.totalHits = state.totalHits + 1
+76 -9
View File
@@ -30,7 +30,7 @@ if CreateFrame then
-- -- refreshSliders() below needs to push their persisted values in on OnShow, same as -- -- refreshSliders() below needs to push their persisted values in on OnShow, same as
-- sliderFont/sliderRise already do. -- sliderFont/sliderRise already do.
local swatch, btnMove, btnPosReset, posLabel, sliderFont, sliderRise, btnDemo, btnOutline, local swatch, btnMove, btnPosReset, posLabel, sliderFont, sliderRise, btnDemo, btnOutline,
sliderPillH, sliderPanelBarH sliderPillH, sliderPanelBarH, sliderDuration
local function db() return VampifyConfig.get() end local function db() return VampifyConfig.get() end
@@ -247,6 +247,10 @@ if CreateFrame then
-- W.SetSliderEnabled, which follows Blizzard's own hide-the-thumb recipe. -- W.SetSliderEnabled, which follows Blizzard's own hide-the-thumb recipe.
W.SetSliderEnabled(sliderRise, posEnabled) W.SetSliderEnabled(sliderRise, posEnabled)
W.SetSliderEnabled(sliderFont, posEnabled) W.SetSliderEnabled(sliderFont, posEnabled)
-- Fade duration is the same category as fontSize/rise above: it shapes OUR pooled
-- FontStrings only (gui/sct.lua's S.spawn reads sct.duration into s.dur), so it follows
-- the same posEnabled greying.
W.SetSliderEnabled(sliderDuration, posEnabled)
if btnDemo then W.SetButtonEnabled(btnDemo, posEnabled) end if btnDemo then W.SetButtonEnabled(btnDemo, posEnabled) end
-- The outline shapes OUR numbers only; Blizzard's engine draws its own. -- The outline shapes OUR numbers only; Blizzard's engine draws its own.
if btnOutline then W.SetButtonEnabled(btnOutline, posEnabled) end if btnOutline then W.SetButtonEnabled(btnOutline, posEnabled) end
@@ -274,6 +278,12 @@ if CreateFrame then
sliderPanelBarH:SetValue(v) sliderPanelBarH:SetValue(v)
getglobal("VampifyOptionsPanelBarHeightSliderText"):SetText(sliderPanelBarH.vfLabel .. ": " .. v) getglobal("VampifyOptionsPanelBarHeightSliderText"):SetText(sliderPanelBarH.vfLabel .. ": " .. v)
end end
if sliderDuration then
local v = sliderDuration.vfGetter()
sliderDuration:SetValue(v)
getglobal("VampifyOptionsDurationSliderText"):SetText(
sliderDuration.vfLabel .. ": " .. string.format(sliderDuration.vfFmt or "%s", v))
end
end end
-- ------------------------------------------------------------------ -- ------------------------------------------------------------------
@@ -284,7 +294,10 @@ if CreateFrame then
local f = CreateFrame("Frame", "VampifyOptionsFrame", UIParent) local f = CreateFrame("Frame", "VampifyOptionsFrame", UIParent)
f:SetWidth(340) f:SetWidth(340)
f:SetHeight(540) f:SetHeight(608) -- +42 (Fade duration slider) +26 (Text shadow checkbox) over the
-- previous 540; fitHeight() below still re-measures against the
-- real info-panel position on every OnShow, so this is only the
-- pre-first-show estimate, not the operative bound.
f:SetPoint("CENTER", UIParent, "CENTER", 0, 0) f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
-- HIGH, deliberately NOT DIALOG. Blizzard's ColorPickerFrame lives at DIALOG, and a frame -- 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 -- created later in the same strata draws on top of it -- so at DIALOG this window buried
@@ -593,27 +606,42 @@ if CreateFrame then
-- ---- SCT sliders ------------------------------------------------------------------- -- ---- SCT sliders -------------------------------------------------------------------
-- Replaces the X/Y entry boxes: raw anchor offsets meant nothing to a human, whereas these -- 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. -- two are the values you actually want to tune by eye.
local function makeSlider(name, label, lo, hi, step, getter, setter) -- fmt (optional): a string.format pattern for the slider's own value, e.g. "%.1f" for a
-- fractional-second slider (Fade duration below, step 0.1). Omitted, values print with a
-- plain tostring -- unchanged behaviour for every integer-step slider that existed before it.
local function makeSlider(name, label, lo, hi, step, getter, setter, fmt)
local sl = CreateFrame("Slider", name, f, "OptionsSliderTemplate") local sl = CreateFrame("Slider", name, f, "OptionsSliderTemplate")
sl:SetWidth(200) sl:SetWidth(200)
sl:SetHeight(16) sl:SetHeight(16)
sl:SetMinMaxValues(lo, hi) sl:SetMinMaxValues(lo, hi)
sl:SetValueStep(step) sl:SetValueStep(step)
sl:SetPoint("TOPLEFT", f, "TOPLEFT", x + 6, y) sl:SetPoint("TOPLEFT", f, "TOPLEFT", x + 6, y)
getglobal(name .. "Low"):SetText(tostring(lo)) getglobal(name .. "Low"):SetText(fmt and string.format(fmt, lo) or tostring(lo))
getglobal(name .. "High"):SetText(tostring(hi)) getglobal(name .. "High"):SetText(fmt and string.format(fmt, hi) or tostring(hi))
local cap = getglobal(name .. "Text") local cap = getglobal(name .. "Text")
W.ApplyFont(cap, 11) W.ApplyFont(cap, 11)
sl.vfLabel = label sl.vfLabel = label
sl.vfSetter = setter sl.vfSetter = setter
sl.vfGetter = getter sl.vfGetter = getter
sl.vfStep = step
sl.vfFmt = fmt
sl:SetScript("OnValueChanged", function() sl:SetScript("OnValueChanged", function()
local v = this:GetValue() local v = this:GetValue()
-- OptionsSliderTemplate does not quantise for us in 1.12; round to the step so the -- OptionsSliderTemplate does not quantise for us in 1.12; round to the slider's own
-- stored value stays a clean integer instead of 15.9999. -- step by hand. math.floor(v/step+0.5)*step generalises the old "nearest integer"
v = math.floor(v + 0.5) -- rounding (which was step-1-only, hardcoded) to the fractional step a duration-in-
-- seconds slider needs (0.1) -- and is a no-op for every existing step-1 slider.
v = math.floor(v / this.vfStep + 0.5) * this.vfStep
-- Round-trip a fractional value through its own format string (e.g. "%.1f") before
-- storing it. The multiply above leaves binary-float noise (1.2000000000000002),
-- which would both write an ugly value into SavedVariables and never compare equal
-- to the plain decimal literal a getter's own fallback or a test uses -- the
-- round-trip snaps it back to the canonical double for "1.2". No-op for integer
-- sliders (fmt is nil there).
local txt = this.vfFmt and string.format(this.vfFmt, v) or tostring(v)
if this.vfFmt then v = tonumber(txt) end
this.vfSetter(v) this.vfSetter(v)
getglobal(this:GetName() .. "Text"):SetText(this.vfLabel .. ": " .. v) getglobal(this:GetName() .. "Text"):SetText(this.vfLabel .. ": " .. txt)
end) end)
y = y - 42 y = y - 42
return sl return sl
@@ -642,6 +670,24 @@ if CreateFrame then
cfg.sct.rise = v cfg.sct.rise = v
end) end)
-- Fade duration -- how long a number stays on screen (gui/sct.lua's S.spawn does
-- "s.dur = sct.duration or DUR_FALLBACK" fresh on every spawn, not cached, so no applyX
-- entry point is needed here: the very next number picks up a changed value on its own,
-- same as the rise slider above.
sliderDuration = makeSlider("VampifyOptionsDurationSlider", "Fade duration", 0.5, 4.0, 0.1,
function()
local cfg = db()
return (cfg and cfg.sct and cfg.sct.duration) or 1.5
end,
function(v)
local cfg = db()
if not (cfg and cfg.sct) then return end
cfg.sct.duration = v
end,
"%.1f")
W.AttachTooltip(sliderDuration, "How long a Vampify combat-text number stays on screen"
.. " before fading out, in seconds.")
-- Text effect. Cycles rather than three radio buttons: it is one setting with three steps, -- 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. -- and the label always states the current one, so nothing is hidden behind a menu.
local OUTLINES = { "THICKOUTLINE", "OUTLINE", "" } local OUTLINES = { "THICKOUTLINE", "OUTLINE", "" }
@@ -672,6 +718,27 @@ if CreateFrame then
end end
y = y - 28 y = y - 28
-- Text shadow -- the other half of "readable over anything" alongside the outline above.
-- Getter mirrors gui/sct.lua's own shadowOn(): default ON, an EXPLICIT false is what turns
-- it off (a missing/unmigrated key must not read as off). applyFont() re-stamps every
-- pooled FontString immediately, same as the outline cycle button above -- otherwise
-- already-spawned slots would keep the old shadow state until the pool cycled.
local cbShadow = CreateCheckbox(f, "Text shadow",
function()
local cfg = db()
local sct = cfg and cfg.sct
if sct and sct.shadow == false then return false end
return true
end,
function(v)
local cfg = db()
if cfg and cfg.sct then cfg.sct.shadow = v end
if VampifySCT and VampifySCT.applyFont then VampifySCT.applyFont() end
end)
place(cbShadow, -26)
W.AttachTooltip(cbShadow, "Drops a shadow behind Vampify's own combat-text numbers, for"
.. " readability over bright backgrounds. On by default.")
-- ---- display size (2026-08-25, feature request: "two more sliders in the options that -- ---- display size (2026-08-25, feature request: "two more sliders in the options that
-- determine the height of the bar and the detail bars") ----------------------------------- -- determine the height of the bar and the detail bars") -----------------------------------
-- Two sliders, same makeSlider() recipe as the SCT ones above, driving VampifyDisplay's own -- Two sliders, same makeSlider() recipe as the SCT ones above, driving VampifyDisplay's own
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB