From d3ad691770b8399f2576e1004c2896cb8c1f2f54 Mon Sep 17 00:00:00 2001 From: ShempError <689+shemperror@noreply.octowow.st> Date: Sun, 30 Aug 2026 15:37:26 +0200 Subject: [PATCH] Vampify v0.4.0 --- .gitignore | 4 -- CHANGELOG.md | 39 ++++++++++------- Vampify.toc | 2 +- capture/damage.lua | 40 +++++++++++++++++ core/commands.lua | 76 +++++++++++++++++++++++++++++--- core/const.lua | 2 +- core/perhit.lua | 64 ++++++++++++++++++++++++--- gui/options.lua | 85 ++++++++++++++++++++++++++++++++---- textures/hps_orb_fill.tga | Bin 0 -> 16402 bytes textures/hps_orb_shade.tga | Bin 0 -> 16402 bytes textures/hps_orb_shine.tga | Bin 0 -> 16402 bytes textures/pill_fill_cap.tga | Bin 0 -> 8210 bytes textures/pill_shade_cap.tga | Bin 0 -> 8210 bytes textures/pill_shine_cap.tga | Bin 0 -> 8210 bytes 14 files changed, 271 insertions(+), 41 deletions(-) delete mode 100644 .gitignore create mode 100644 textures/hps_orb_fill.tga create mode 100644 textures/hps_orb_shade.tga create mode 100644 textures/hps_orb_shine.tga create mode 100644 textures/pill_fill_cap.tga create mode 100644 textures/pill_shade_cap.tga create mode 100644 textures/pill_shine_cap.tga diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 9b712fd..0000000 --- a/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Editor / OS noise -.DS_Store -Thumbs.db -*.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b0ae0..789ce78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,26 +2,20 @@ All notable changes to Vampify are documented here. -## Unreleased +## 0.4.0 — 2026-08-30 ### 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. +- 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 + cycle button. Both are also reachable from the command line: `/vf sct duration ` and + `/vf sct shadow on|off`. +- The glass-bar textures the 0.3.0 upload left out (`textures/hps_orb_*` and + `textures/pill_*_cap`). Without them the bars drew without their fill, shading and gloss. ### 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. +- The per-hit export carries three more fields for the offline analysis: each source's own + percentage, the zone, and whether the hit followed a cast of the same spell (item and enchant + procs never do). All three are optional, so an older reader still parses the line. ## 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. - AoE damping is applied only where a hit is proven to have struck two or more distinct targets, 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 - 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. +- MIT license, this changelog, and a README written for players rather than for the author. ### Fixed - 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. - 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. +- 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 diff --git a/Vampify.toc b/Vampify.toc index 67159b8..b0ff1da 100644 --- a/Vampify.toc +++ b/Vampify.toc @@ -2,7 +2,7 @@ ## Title: Vampify ## Notes: Tracks the healing returned by the Vampirism item stat. ## Author: ShempError -## Version: 0.3.0 +## Version: 0.4.0 ## SavedVariables: VampifyDB ## SavedVariablesPerCharacter: VampifyCharDB diff --git a/capture/damage.lua b/capture/damage.lua index 9372a5b..88e6eee 100644 --- a/capture/damage.lua +++ b/capture/damage.lua @@ -117,6 +117,42 @@ function G.onDamage(fn) table.insert(listeners, fn) end local goSeen = 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) -------- -- -- 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 -- exact same distinct-target proof a no-cast proc already has to provide. 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 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. diff --git a/core/commands.lua b/core/commands.lua index 30f89e2..caa7f89 100644 --- a/core/commands.lua +++ b/core/commands.lua @@ -319,6 +319,28 @@ local lastHp, lastAt, lastExpected = nil, nil, nil local nextCheck = 0 local WATCH_INTERVAL = 15 -- slow on purpose: findings are read by a human, not a loop +-- ---- 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 -- the result read "Vampify Vampify watch:". -- @@ -485,6 +507,13 @@ if VampifyDamage.onDamage then pred_heal = healFloat, acc_total = VampifyState.acc.total, 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")) end @@ -823,6 +852,9 @@ if CreateFrame then f:RegisterEvent("PLAYER_CAMPING") f:RegisterEvent("PLAYER_QUITING") 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. -- Declared out here rather than beside the OnUpdate that uses it because the combat-end branch @@ -860,8 +892,13 @@ if CreateFrame then end return end + if event == "ZONE_CHANGED_NEW_AREA" then + refreshZone() + return + end if event == "PLAYER_LOGIN" or event == "PLAYER_ENTERING_WORLD" then if event == "PLAYER_ENTERING_WORLD" then wireSession() end + refreshZone() recompute() syncPerHit() 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 lock -- toggle frame dragging") print_("/vf fct / sct -- toggle scrolling combat text (either engine)") + print_("/vf sct duration -- 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 options -- open the options window") print_("/vf source add -- add a manual source, if tooltip detection fails") @@ -1163,12 +1202,39 @@ SlashCmdList["VAMPIFY"] = function(msg) elseif cmd == "fct" or cmd == "sct" then local cfg = VampifyConfig.get() if not cfg or not cfg.sct then print_("config not loaded yet.") return end - cfg.sct.enabled = not cfg.sct.enabled - if cfg.sct.enabled and cfg.sct.mode == "blizzard" and SHOW_COMBAT_TEXT ~= "1" then - print_("scrolling combat text ON -- but Blizzard's own combat text is disabled in its" - .." options, so nothing will show in blizzard mode.") + -- Bare "/vf sct" / "/vf fct" keeps its original meaning (toggle enabled); "sct " + -- is the same sub/val dispatch pattern /vf source and /vf exclude already use above. %S* + -- rather than %d* for val: duration needs a decimal ("1.5"), shadow needs a word (on/off). + 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 -- 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 - print_(cfg.sct.enabled and "scrolling combat text ON." or "scrolling combat text OFF.") + help() end elseif cmd == "sctmove" then if not (VampifySCT and VampifySCT.setMoveMode and VampifySCT.isMoveMode) then diff --git a/core/const.lua b/core/const.lua index 8bdd0b0..f9b5b3b 100644 --- a/core/const.lua +++ b/core/const.lua @@ -7,7 +7,7 @@ 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 -- `n` field, and nil-ing the indices by hand does not reset it -- so the next round of inserts diff --git a/core/perhit.lua b/core/perhit.lua index 8beb4be..ac3e91f 100644 --- a/core/perhit.lua +++ b/core/perhit.lua @@ -140,13 +140,59 @@ function PH.pushLine(state, line) 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) 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", + .."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, 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 function PH.formatIncomingLine(row) @@ -177,15 +223,23 @@ function PH.finalizePending(state, hpNow) 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. +-- fields: t, src, dmg, aoe, P, n, pred_heal, acc_total, crit, pcts, zone, cast -- 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. +-- +-- 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) 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, + pcts = PH.formatPercents(fields.pcts), zone = fields.zone, cast = fields.cast, hp_before = hpNow, hp_max = hpMaxNow, pendingAt = tNow, } state.totalHits = state.totalHits + 1 diff --git a/gui/options.lua b/gui/options.lua index f52878b..20be793 100644 --- a/gui/options.lua +++ b/gui/options.lua @@ -30,7 +30,7 @@ if CreateFrame then -- -- 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 + sliderPillH, sliderPanelBarH, sliderDuration 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(sliderRise, 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 -- The outline shapes OUR numbers only; Blizzard's engine draws its own. if btnOutline then W.SetButtonEnabled(btnOutline, posEnabled) end @@ -274,6 +278,12 @@ if CreateFrame then sliderPanelBarH:SetValue(v) getglobal("VampifyOptionsPanelBarHeightSliderText"):SetText(sliderPanelBarH.vfLabel .. ": " .. v) 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 -- ------------------------------------------------------------------ @@ -284,7 +294,10 @@ if CreateFrame then local f = CreateFrame("Frame", "VampifyOptionsFrame", UIParent) 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) -- 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 @@ -593,27 +606,42 @@ if CreateFrame then -- ---- 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) + -- 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") 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)) + getglobal(name .. "Low"):SetText(fmt and string.format(fmt, lo) or tostring(lo)) + getglobal(name .. "High"):SetText(fmt and string.format(fmt, hi) or tostring(hi)) local cap = getglobal(name .. "Text") W.ApplyFont(cap, 11) sl.vfLabel = label sl.vfSetter = setter sl.vfGetter = getter + sl.vfStep = step + sl.vfFmt = fmt 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) + -- OptionsSliderTemplate does not quantise for us in 1.12; round to the slider's own + -- step by hand. math.floor(v/step+0.5)*step generalises the old "nearest integer" + -- 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) - getglobal(this:GetName() .. "Text"):SetText(this.vfLabel .. ": " .. v) + getglobal(this:GetName() .. "Text"):SetText(this.vfLabel .. ": " .. txt) end) y = y - 42 return sl @@ -642,6 +670,24 @@ if CreateFrame then cfg.sct.rise = v 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, -- and the label always states the current one, so nothing is hidden behind a menu. local OUTLINES = { "THICKOUTLINE", "OUTLINE", "" } @@ -672,6 +718,27 @@ if CreateFrame then end 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 -- determine the height of the bar and the detail bars") ----------------------------------- -- Two sliders, same makeSlider() recipe as the SCT ones above, driving VampifyDisplay's own diff --git a/textures/hps_orb_fill.tga b/textures/hps_orb_fill.tga new file mode 100644 index 0000000000000000000000000000000000000000..bff13265698385de9bb93ff8d54b7be964c976f6 GIT binary patch literal 16402 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 literal 0 HcmV?d00001 diff --git a/textures/hps_orb_shine.tga b/textures/hps_orb_shine.tga new file mode 100644 index 0000000000000000000000000000000000000000..5c6fca1be0f355bc5d6dfffde6b5a4df32ae3e20 GIT binary patch literal 16402 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 literal 0 HcmV?d00001 diff --git a/textures/pill_fill_cap.tga b/textures/pill_fill_cap.tga new file mode 100644 index 0000000000000000000000000000000000000000..874dc4f243d224d039b5ae65dc3d1e98f9922ff8 GIT binary patch literal 8210 zcmd6sAx{HA5QQ&=6v7W64GBp-7J(vI91htZfJ7j02z~$piL9urK#+M;qPd1 zc=0X%d= z{Z8R?c!SL1=Zq2O#9C^Z<|nsszByw}#(9|?)BNl?xuvP|UB(-}kNG?LlAKCj%O!!I zIz}xapXcy^w+{Pv26tcPLx4 z%J=k&Jt0S~nWY!Gjyy}h<`8;he4q2o#dgx4(<8ftyFuz2H_tY5j%)AHi{6HNiA(w- zcEuhx4}~!wOhV3g%E(DBZa2T%|L*v6pFem0apzxm{c+b{cmLt;zuf(&yZ`^vm&tV_h(!(`> z4>~yCAA%0V12O4%e+fG9m$T`3e~NV=M{zco73Y3`i}j#hINl#)9jKesZuH3g{u=8+ dd@{#nUwQ&FMdo^`-=AY0!e@F~;&kEr`!98pD5U@Z literal 0 HcmV?d00001 diff --git a/textures/pill_shade_cap.tga b/textures/pill_shade_cap.tga new file mode 100644 index 0000000000000000000000000000000000000000..6d55676dafbbd4a941df1c27db8ad56f5feef90a GIT binary patch literal 8210 zcmchZ=Z{rY6h{Zb5I&eF0%CW-sGw-Xj-Y^oiim)WlmTP_9YGP5K|~m(Nk;@}QkB@S zAW9GwTTm4LEZ0izPEKy_eQ(|rU$U}K*=PSw+2`C>SJ$&HehqbF>Kgjg_wG~I4b&5J z01SoE&wA^oV+T7~Mn#<-^O<(Qd&Q&qE7SM9-E-I7UE0p8Oy6Adyt`x1)BPyY_l}w8=34689p$`!A2hw+ z-m$LC^Rl_;^*wXnmae+rWxCOx>ieGiruwc$e|Ou^_dDjd#P|IF*!Ngh==%-zJLb0} z{XQ3!V{CkLw37NpTWZt(D68*XckO;d_5ZU1@olT*Q%j%v{%>i9cxQg5T*U9R{{h-x z;g<~mYv`{HipCeUsc+721kYU`&mhlP+Kl}Z{0P>6fV^L=^?G9Xe=Xh`bH9j)>mGdv zr{G)gef*|?w7n=^wE5Qg&bl`9>Wc#ORZ~67U&09}@~yRPC~;!%)jtMmXalvr0%Ly$ zpTO~ikF&D&qXg^G7P0cy|L6WkR$GpOYds8yGSmxAJz@T+D78UH_m)^F6tY{ z`*;9S&4bkU!#>!Xu?H(3u5A~n6PoHd_WcasN_npn_}}I{ zsP7qjUMC>*)pH$csX72>XjL2hwkeUY5uwIn=@RyYgX5_ zy#&U29u_7npxhbE>$rcuGoHCQXP&uU*QKs=Rd+tjgXc2lVuLx9#d?&(&Ms*)r_O^p zcm&Kf_ulqqaJ-t9)wJG}F$+7hleoTfpuX?C=WWiLdYtbUbzN7L`VtA7?=1@qmUnvn4%*7>UY zG)#i0U}A>-Y1w+T=_k>qjkwyO;9WNdr4`IK&-$^1G>+pQ&q#gJvi(nFRPybQ&tixi zSVh_VHOI&1Fpe7I;L(gnu=;6JqF)>B`FPgr!M!^Up1}t2tj+U>b3~jmw0mxn_pu&* zW&P=xI{b%08@T`b!2EU&BXT@Q-La!!WQJ`Lb#2;jJ$$sr3~O2azR(1x;S{+4=K1{v z#MlSulkh1j=Y7IAwd(hP`(QU*0Q12dzpI3MX}>4qZmdK-tZk{E?ctloRbQ@v`EUkS zz<9W`0>caXqD`BpELQ0EWX$cb8Vn^d-$g@LtlFt%mMF$dttu& zKD38h@hhU**i_&05NHG6!3l74Haf4(iRo7Q^~!+YEaQ{W+RPB&#Zr?_|CDRZ>}Jd5^{ z>J^n^4l{{43p|gp5bv;WsqdV3-T8QbZh^QrG1oF%q?vDX4!#|8VF}C!_r(7q-ci5H z<1(C^`!o`qV>R+xWp(pmFnBLoVNGURunWLBX=NTX=a@}>I!uL0;C~b6T&7pneko(z zZ{xTJ#&k{{rA((%dvEjxf%9Jk>tH)<%dF+7Z=x*kz)DyKo}uFwLM5GQeU~u)Mi>oE zuoPB7TMbmxsMg;Tt;@muHy>S>Yuy36VL$AFo#6g#fvuUfTrGZk`^`_^ZQqCS&(e<{{S2Fz^DKK literal 0 HcmV?d00001 diff --git a/textures/pill_shine_cap.tga b/textures/pill_shine_cap.tga new file mode 100644 index 0000000000000000000000000000000000000000..c2c6ac240a4adf93755f76bd8070a997b3417ca7 GIT binary patch literal 8210 zcmeI1%Zgl86o$)qzr?6SO}tH9?J%sHm-GpdccOS0tbTMZVRzfbCi#i9Dtsj9B-AQpVR_gQ=Gwf0(j?NfD5_j;Rp`Sg1W zz5YcjD=WSKnJNe8kiSdede{vMa1>6$OYq7Frpf<>*uDmK!9G}o$Ki=Fn8g2i*uMny zy#wxmJ%zpW4}ohj7q(9gc$9V?_JeaK@xKvs+~@N*XubM2X4_2z&ZWJeTw4nl4`i;> zJ+f6UkvT7DYg8_)&K2n234L(yw}3w7GT1!mTN{ykHpDf!Ue~4nYIn6Y7;o%Y-%rC4 zu->i)ZLT(VTDiWj1!M6Xe*tIUMev+&1#?>WW?G+h{*?p!Cuo0xpWrks!A`gwy5z9h z?Amv~whjFJPWv}}3(tdRb~~7t#NO3z7d`94y;b)I@bf3U2aBNpK3I#^(^{{=*mGZ_ z`|syR{=NVY!|h;Q%(C{3{VVYE7kmcGFb{L!x$_J)WB0l#(^i$$zGD@C4;Y_${SEwl z57zxM+y~}HJ@-khLDyp6^Wa=tW#V{rtE_Du?(rY+GkgP|!Xj7~ZSxXqn~r0l#$Nk= zcmE1s!gugCc;=0x>cx+78FP(4{p7>BUE}x6e*o{m*RTZM2dnQ{^ph9;-2-pHyKom| ztl4IN6Tfk$Uvpp{97`UYm;6GLW6N+5thcH&Nk6q1zp-H+(|64KnyvTW=fLye z^(eGoV^_b*nspF+@t^Co=FaoxIwG4zTVrq9pToEJxu$%zYvaw{n+x@-%(1%vj>rG7 zHs3MB#?B^L;xx7zca$i2?iQD)RqjJY1OI>6k^XV~ zehx3g%@BEQ^=B+q*6a^kQ*`2gSo;We&%pbz0K;@^nDI=zoX;crr)(4Cuc{o|9asu`o4de#BbO2RlQyK z^8Huk!ND5*O;>ei(Kk+?$1Bfp?X%HU;B!@5E>dsmr0+V%*@N5Af3m?m*Y1PO^`(f<9bI#bX z*7|gRe1G8j#lA9ePFvji-wb`Y8}@d@{5${;4e-68eb3GPZ~)ZNUYmc%tXtoE`98+8 z|Lhtw%74~y9e(HGHSpckhaLF{J^2_u8MKjUql_iMP5RT))qk6wHRam2 zf;H;9=`FAgZh-CJ9xAtH)gM)RZT`-dG0&IrS{rk)8xFu>aF33|2{;8O;TRkO_55AE kk#UTBFY2;ZZMv`CTfEo!zXb0e)`ENQySye{?9NQ*9}*{8Jpcdz literal 0 HcmV?d00001