BulwarkFrame v0.2.0

This commit is contained in:
2026-08-17 15:19:45 +02:00
parent 7cd508e2a3
commit 2b6de41d9a
8 changed files with 253 additions and 35 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
## Title: BulwarkFrame ## Title: BulwarkFrame
## Notes: Compact real-time readout for the shaman Earthen Bulwark. Requires SuperWoW. ## Notes: Compact real-time readout for the shaman Earthen Bulwark. Requires SuperWoW.
## Author: ShempError ## Author: ShempError
## Version: 0.2.0-dev ## Version: 0.2.0
## SavedVariables: BulwarkFrameDB ## SavedVariables: BulwarkFrameDB
core\env.lua core\env.lua
+54
View File
@@ -0,0 +1,54 @@
# Changelog
All notable changes to BulwarkFrame are documented here.
## v0.2.0 — 2026-08-15
### Fixed
- **Max health readout stays accurate through stamina buffs and level-ups.**
It was previously only re-read on zone-in and gear changes, so a Fortitude,
Gift of the Wild, food buff, or world buff picked up mid-raid left every
number on the readout roughly 10% low until the next zone change. It now
refreshes on any player max-health change and on level-up.
- **"Combat only" no longer gets stuck off after a mid-fight reload.** The
combat flag was only ever set by entering or leaving combat, so a `/reload`
taken during a fight left it false for the rest of the encounter and hid the
frame outright for anyone using the "Combat only" option. It is now seeded
from the live combat state on login and on entering the world.
- **The frame can no longer get stranded off-screen.** `/bulwark reset` and
the options panel's Reset button wrote the default position into the saved
settings but never moved the on-screen frame, so a frame dragged to the
edge stayed there until the next login. Reset now actually re-anchors the
frame, which is also clamped so it cannot be dragged off-screen in the
first place.
- **Right-clicking the minimap button now reliably hides the "SuperWoW
required" hint.** The manual-hide flag was checked after that hint, so
without SuperWoW installed the hint frame could never be dismissed, even
though the minimap toggle reported it as hidden.
- **`/bulwark show` and `/bulwark demo` now actually bring the frame back.**
Both commands cleared "hide when idle" and "combat only" but left a manual
minimap-toggle hide in place, so the frame stayed off-screen and demo mode
ran invisibly. Both commands now clear the manual hide as well, and say so
in their chat confirmation.
- **The options panel no longer shows stale values after a slash command.**
`/bulwark reset`, `/bulwark lock`, `/bulwark show`, and `/bulwark demo`
changed settings without refreshing an already-open options panel, so its
checkboxes, sliders, and buttons kept showing the pre-command state —
nudging a stale slider could even silently undo what the command had just
done. The panel now re-syncs itself after every one of these commands.
- **The absorb-rate readout no longer shows a number for an unlearned
talent.** A talent rank of zero was treated as "no value set" rather than
"rank 0", so an additive set-bonus reading could produce a plausible-looking
absorb rate for a talent the character never trained. It now correctly
shows `--` in that case.
### Changed
- **Lower CPU usage while the buffer is inactive.** The update loop now
detaches itself once there is nothing left to animate — no active buffer,
no swing in progress — instead of redrawing five times a second regardless,
and re-attaches automatically the moment a swing lands or the buffer
refills.
- **Removed the addon's remaining per-tick and per-swing memory
allocation.** The colour-threshold lookup and the aura scan that runs on
every incoming swing no longer build and discard temporary tables and
closures, avoiding needless garbage-collector churn during combat.
+9 -2
View File
@@ -81,10 +81,10 @@ a `git pull`.
| Command | Does | | Command | Does |
|---|---| |---|---|
| `/bulwark` or `/bulwark options` | opens the options panel (also: left-click the minimap button) | | `/bulwark` or `/bulwark options` | opens the options panel (also: left-click the minimap button) |
| `/bulwark demo` | runs the display off a synthetic 12 s cycle — judge colours and sizes without a fight | | `/bulwark demo` | runs the display off a synthetic 12 s cycle — judge colours and sizes without a fight (clears the manual hide, so the frame is actually on screen) |
| `/bulwark probe` | dumps the live aura / weapon / talent data to chat **and**, with SuperWoW, to `imports\bulwark_probe.txt` | | `/bulwark probe` | dumps the live aura / weapon / talent data to chat **and**, with SuperWoW, to `imports\bulwark_probe.txt` |
| `/bulwark lock` | locks or unlocks dragging | | `/bulwark lock` | locks or unlocks dragging |
| `/bulwark show` | pins the frame visible (turns off hide-when-idle and combat-only) | | `/bulwark show` | pins the frame visible (clears the manual hide and turns off hide-when-idle and combat-only) |
| `/bulwark reset` | restores every setting to its default | | `/bulwark reset` | restores every setting to its default |
`/bf` works as a short form of all of them. Right-clicking the minimap button shows or hides the `/bf` works as a short form of all of them. Right-clicking the minimap button shows or hides the
@@ -139,8 +139,15 @@ lua50 tools/luatests/test_calc.lua
lua50 tools/luatests/test_swing.lua lua50 tools/luatests/test_swing.lua
lua50 tools/luatests/test_env.lua lua50 tools/luatests/test_env.lua
lua50 tools/luatests/test_config.lua lua50 tools/luatests/test_config.lua
lua50 tools/luatests/test_slash.lua
``` ```
`test_slash.lua` is the exception to "no WoW API offline": it drives the real slash commands and
the real options panel against a small widget stub. The questions it asks — which of the three
suppressors wins, and whether a command re-syncs an open panel — are pure control flow, and that
is reproducible without a client. Rendering, templates and event ordering are not, and stay
in-game work.
**Looking at the UI without the game:** `node tools/uipreview/render.js` draws the frame as a PNG **Looking at the UI without the game:** `node tools/uipreview/render.js` draws the frame as a PNG
across six states (full → empty, including both colour steps) at 3× into `tools/uipreview/out/`. across six states (full → empty, including both colour steps) at 3× into `tools/uipreview/out/`.
Same geometry and colour buckets as the addon, so the look can be iterated without a client Same geometry and colour buckets as the addon, so the look can be iterated without a client
+4 -1
View File
@@ -168,7 +168,10 @@ C.RANK_RATE = { [0] = 0, [1] = 0.05, [2] = 0.10, [3] = 0.15 }
-- neither is assumed; the caller decides once measurement settles it. -- neither is assumed; the caller decides once measurement settles it.
function C.rateForRank(rank, addBonus, multBonus) function C.rateForRank(rank, addBonus, multBonus)
local base = C.RANK_RATE[rank or -1] local base = C.RANK_RATE[rank or -1]
if not base then return 0 end -- `<= 0` and not just `not base`: rank 0 maps to a rate of 0, and 0 is truthy in Lua, so a
-- bare nil check would let an additive set bonus resurrect a rate for a talent the character
-- has not learned -- exactly the confidently wrong number this guard exists to prevent.
if not base or base <= 0 then return 0 end
if type(addBonus) == "number" then base = base + addBonus end if type(addBonus) == "number" then base = base + addBonus end
if type(multBonus) == "number" then base = base * multBonus end if type(multBonus) == "number" then base = base * multBonus end
return base return base
+74 -22
View File
@@ -32,6 +32,43 @@ BF.state = {
-- ---- buff scan -------------------------------------------------------------------------- -- ---- buff scan --------------------------------------------------------------------------
-- Walks one of the 64-slot unit aura enumerations looking for the buffer, and returns the
-- (possibly raised) stack count plus the found flag.
--
-- Deliberately a file-scope function taking its inputs as arguments rather than a closure built
-- inside ScanAuras: as a closure it allocated one object per CALL plus one more per aura SLOT (the
-- per-slot pcall wrapper), and ScanAuras is the PLAYER_AURAS_CHANGED handler -- an event that
-- fires on every stack change of the buffer, i.e. on every absorbed hit. Roughly two dozen
-- throwaway closures per incoming melee swing is exactly the GC churn the rest of this addon
-- takes care to avoid.
--
-- The per-slot pcall went with it. It was never per-slot error recovery either: the old code
-- aborted the WHOLE walk as soon as one slot failed, so a single pcall around the walk (see the
-- call sites) covers the same ground -- and UnitBuff/UnitDebuff on "player" with an integer index
-- does not throw to begin with. The one behavioural difference is that a walk which errors part
-- way through now contributes nothing instead of its partial count, which is the safer of the two.
--
-- Note the id position differs between the two calls -- an easy trap: reading return 4 from
-- UnitBuff (or 3 from UnitDebuff) silently yields nil and the aura is never matched.
local function scanStacks(st, fn, idPos, poolId, timeId, stacks, found)
local i = 1
while i <= 64 do
local r1, r2, r3, r4 = fn("player", i)
if r1 == nil then break end
local id = (idPos == 4) and tonumber(r4) or tonumber(r3)
if id == poolId or id == timeId then
local n = tonumber(r2)
if n and n > stacks then
stacks = n
found = true
st.hasPool = true
end
end
i = i + 1
end
return stacks, found
end
-- Scans the player's buffs once and writes pool/time into BF.state. -- Scans the player's buffs once and writes pool/time into BF.state.
-- GetPlayerBuff walks HELPFUL slots; GetPlayerBuffID (SuperWoW) gives the spell id, which is the -- GetPlayerBuff walks HELPFUL slots; GetPlayerBuffID (SuperWoW) gives the spell id, which is the
-- whole reason SuperWoW is a hard requirement -- matching the icon texture instead would collide -- whole reason SuperWoW is a hard requirement -- matching the icon texture instead would collide
@@ -83,28 +120,17 @@ function BF.ScanAuras()
-- The durability aura runs as a DEBUFF, and the HELPFUL player-buff enumeration above never -- The durability aura runs as a DEBUFF, and the HELPFUL player-buff enumeration above never
-- lists it. Reading only that API is why a full buffer reported a single stack -- and one stack -- lists it. Reading only that API is why a full buffer reported a single stack -- and one stack
-- renders as 61 against a maximum of 6117: a plausible number, wrong by a factor of a hundred. -- renders as 61 against a maximum of 6117: a plausible number, wrong by a factor of a hundred.
-- Note the id position differs between the two calls -- an easy trap: reading return 4 from --
-- UnitBuff (or 3 from UnitDebuff) silently yields nil and the aura is never matched. -- The walk itself lives at file scope (see scanStacks above). pcall is handed the arguments
local scanStacks = function(fn, idPos) -- directly instead of a wrapper closure, so this path allocates nothing at all.
local i = 1 if UnitDebuff then
while i <= 64 do local ok, s, f = pcall(scanStacks, st, UnitDebuff, 4, poolId, timeId, stacks, found)
local r1, r2, r3, r4 if ok then stacks, found = s, f end
local ok = pcall(function() r1, r2, r3, r4 = fn("player", i) end) end
if not ok or r1 == nil then break end if UnitBuff then
local id = (idPos == 4) and tonumber(r4) or tonumber(r3) local ok, s, f = pcall(scanStacks, st, UnitBuff, 3, poolId, timeId, stacks, found)
if id == poolId or id == timeId then if ok then stacks, found = s, f end
local n = tonumber(r2)
if n and n > stacks then
stacks = n
found = true
st.hasPool = true
end
end
i = i + 1
end
end end
if UnitDebuff then pcall(function() scanStacks(UnitDebuff, 4) end) end
if UnitBuff then pcall(function() scanStacks(UnitBuff, 3) end) end
-- A buffer aura with no applications at all still means "buffer up": 1.12 reports a -- A buffer aura with no applications at all still means "buffer up": 1.12 reports a
-- single-stack aura without a count, and treating that as 0 would blank the bar while it is -- single-stack aura without a count, and treating that as 0 would blank the bar while it is
@@ -179,6 +205,15 @@ driver:RegisterEvent("PLAYER_REGEN_DISABLED")
driver:RegisterEvent("PLAYER_REGEN_ENABLED") driver:RegisterEvent("PLAYER_REGEN_ENABLED")
driver:RegisterEvent("UNIT_INVENTORY_CHANGED") driver:RegisterEvent("UNIT_INVENTORY_CHANGED")
-- Max health is the base of EVERY number on screen (maxPool = maxHP/5), so it must not be read
-- only on zone-in and gear swaps. A stamina buff landing at the raid entrance -- Fortitude, Gift
-- of the Wild, food, a world buff -- moves max health without either of those events, and the
-- readout would then be ~10% low for the whole encounter. UNIT_MAXHEALTH carries a unit token in
-- arg1 and fires for every group member, so it has to be filtered; PLAYER_LEVEL_UP carries the
-- new level in arg1 and must NOT go through that filter.
driver:RegisterEvent("UNIT_MAXHEALTH")
driver:RegisterEvent("PLAYER_LEVEL_UP")
-- SuperWoW's UNIT_CASTEVENT is the swing source: arg3 is "MAINHAND"/"OFFHAND" for auto-attacks. -- SuperWoW's UNIT_CASTEVENT is the swing source: arg3 is "MAINHAND"/"OFFHAND" for auto-attacks.
-- It is language independent and fires on misses too, unlike parsing combat-log text -- which is -- It is language independent and fires on misses too, unlike parsing combat-log text -- which is
-- how the third-party swing timers get it wrong on a dodge. -- how the third-party swing timers get it wrong on a dodge.
@@ -192,15 +227,32 @@ driver:SetScript("OnEvent", function()
if arg3 == "MAINHAND" and arg1 == BF.playerGuid then if arg3 == "MAINHAND" and arg1 == BF.playerGuid then
BF.state.lastSwingAt = GetTime() BF.state.lastSwingAt = GetTime()
BF.RefreshSwingInputs() BF.RefreshSwingInputs()
-- Redraw explicitly, because this branch returns before the shared RequestUpdate at
-- the end of the handler. Without it a swing landing while idle and out of combat
-- never re-attaches the update loop and the marker simply does not move -- the one
-- wakeup path that is neither an aura nor a combat event. The call stays INSIDE the
-- main-hand test on purpose: UNIT_CASTEVENT fires for every cast of every unit in
-- range, and redrawing on all of them is exactly the churn this addon avoids.
if BF.RequestUpdate then BF.RequestUpdate() end
end end
return return
end end
if event == "PLAYER_REGEN_DISABLED" then if event == "UNIT_MAXHEALTH" then
-- Health only: no GUID re-capture, no swing re-read. This fires for party and raid
-- members too, hence the filter.
if arg1 == "player" then BF.state.maxHP = UnitHealthMax("player") or 0 end
elseif event == "PLAYER_LEVEL_UP" then
BF.state.maxHP = UnitHealthMax("player") or 0
elseif event == "PLAYER_REGEN_DISABLED" then
BF.state.inCombat = true BF.state.inCombat = true
elseif event == "PLAYER_REGEN_ENABLED" then elseif event == "PLAYER_REGEN_ENABLED" then
BF.state.inCombat = false BF.state.inCombat = false
elseif event == "PLAYER_ENTERING_WORLD" or event == "UNIT_INVENTORY_CHANGED" then elseif event == "PLAYER_ENTERING_WORLD" or event == "UNIT_INVENTORY_CHANGED" then
BF.state.maxHP = UnitHealthMax("player") or 0 BF.state.maxHP = UnitHealthMax("player") or 0
-- Seeded from the live API, not only from the REGEN transitions: those fire on ENTERING
-- combat, never on login, so a /reload taken mid-fight would leave the flag false for the
-- rest of the encounter and strand anyone running "Combat only".
BF.state.inCombat = UnitAffectingCombat("player") and true or false
BF.CapturePlayerGuid() BF.CapturePlayerGuid()
BF.RefreshSwingInputs() BF.RefreshSwingInputs()
end end
+31 -2
View File
@@ -23,29 +23,58 @@ SLASH_BULWARKFRAME2 = "/bf"
SlashCmdList["BULWARKFRAME"] = function(input) SlashCmdList["BULWARKFRAME"] = function(input)
local cmd = string.lower(input or "") local cmd = string.lower(input or "")
-- No string.match / no ':' string methods -- Lua 5.0. -- No string.match / no ':' string methods -- Lua 5.0.
--
-- Set by every branch that writes BulwarkFrameDB. The options panel is built once and its
-- widgets then hold their own state, so a slash command that changes a setting behind an open
-- panel left every checkbox, slider and cycle button showing the pre-command values -- and the
-- first nudge of a stale slider writes the discarded value straight back over the change. The
-- panel's own Reset button already calls BF.RefreshOptions for exactly this reason; collecting
-- it here gives the slash side the same treatment, and keeps a future command from silently
-- drifting out of sync again.
local dirty = false
if cmd == "" or cmd == "options" or cmd == "config" then if cmd == "" or cmd == "options" or cmd == "config" then
BF.ToggleOptions() BF.ToggleOptions()
elseif cmd == "demo" then elseif cmd == "demo" then
-- Read before the toggle: BF.ToggleDemo clears the manual hide so the frame it promises
-- actually appears, and that is worth saying rather than leaving the user to wonder why
-- an unrelated setting moved.
local wasHidden = BulwarkFrameDB.hidden and true or false
local on = BF.ToggleDemo() local on = BF.ToggleDemo()
msg("demo " .. (on and "on" or "off")) if on and wasHidden then
msg("demo on (manual hide cleared)")
else
msg("demo " .. (on and "on" or "off"))
end
dirty = true
elseif cmd == "probe" then elseif cmd == "probe" then
BF.RunProbe() BF.RunProbe()
elseif cmd == "lock" then elseif cmd == "lock" then
BulwarkFrameDB.locked = not BulwarkFrameDB.locked BulwarkFrameDB.locked = not BulwarkFrameDB.locked
BF.ApplyLayout() BF.ApplyLayout()
msg(BulwarkFrameDB.locked and "frame locked" or "frame unlocked") msg(BulwarkFrameDB.locked and "frame locked" or "frame unlocked")
dirty = true
elseif cmd == "reset" then elseif cmd == "reset" then
BF.resetConfig() BF.resetConfig()
BF.ApplyLayout() BF.ApplyLayout()
BF.RequestUpdate() BF.RequestUpdate()
BF.UpdateMinimapButton() BF.UpdateMinimapButton()
msg("settings reset to defaults") msg("settings reset to defaults")
dirty = true
elseif cmd == "show" then elseif cmd == "show" then
-- All THREE suppressors, not just the two automatic ones. The manual hide (minimap
-- right-click) is a SavedVariable and overrides both of them in RequestUpdate, so leaving
-- it set meant '/bulwark show' reported success while the frame stayed off screen -- and
-- no other command except a full reset could clear it.
BulwarkFrameDB.hidden = false
BulwarkFrameDB.hideWhenInactive = false BulwarkFrameDB.hideWhenInactive = false
BulwarkFrameDB.hideOutOfCombat = false BulwarkFrameDB.hideOutOfCombat = false
BF.RequestUpdate() BF.RequestUpdate()
msg("frame pinned visible (hide-when-idle and combat-only turned off)") msg("frame pinned visible (manual hide, hide-when-idle and combat-only turned off)")
dirty = true
else else
msg("commands: options | demo | probe | lock | show | reset") msg("commands: options | demo | probe | lock | show | reset")
end end
-- Safe unconditionally: the refresher list is empty until the panel has been built, so this is
-- a no-op for anyone who never opened it.
if dirty and BF.RefreshOptions then BF.RefreshOptions() end
end end
+6
View File
@@ -434,6 +434,12 @@ local function BuildPanel()
this:SetText(on and "Demo: on" or "Demo") this:SetText(on and "Demo: on" or "Demo")
end) end)
demoBtn:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 24, 20) demoBtn:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 24, 20)
-- The one stateful widget that had no refresher, unlike modeBtn and setBtn above: demo mode can
-- also be toggled from '/bulwark demo', and without this the button kept whatever label it last
-- set itself -- reading "Demo" over a running demo, or "Demo: on" over a live readout.
table.insert(refreshers, function()
demoBtn:SetText(BF.IsDemo() and "Demo: on" or "Demo")
end)
local probeBtn = CreateButton(panel, "Probe", "Dump the live aura/weapon data we still need " local probeBtn = CreateButton(panel, "Probe", "Dump the live aura/weapon data we still need "
.. "(also /bulwark probe).", function() BF.RunProbe() end) .. "(also /bulwark probe).", function() BF.RunProbe() end)
+74 -7
View File
@@ -7,7 +7,7 @@
-- * nothing is allocated per OnUpdate frame -- no string building, no tables. Text is only -- * nothing is allocated per OnUpdate frame -- no string building, no tables. Text is only
-- pushed when it actually changed, and the loop returns early when there is nothing to draw; -- pushed when it actually changed, and the loop returns early when there is nothing to draw;
-- * when the buffer is down and we are out of combat the OnUpdate script is detached entirely -- * when the buffer is down and we are out of combat the OnUpdate script is detached entirely
-- rather than left spinning on a hidden frame. -- rather than left spinning -- whether the frame is hidden or merely idle on screen.
BulwarkFrame = BulwarkFrame or {} BulwarkFrame = BulwarkFrame or {}
local BF = BulwarkFrame local BF = BulwarkFrame
@@ -23,6 +23,13 @@ local demo = nil -- demo mode state, nil when off
local UPDATE_INTERVAL = 0.05 local UPDATE_INTERVAL = 0.05
local sinceUpdate = 0 local sinceUpdate = 0
-- Colour-threshold configs, allocated once and refilled in place. Built as table literals inside
-- the update loop they were the only per-tick allocation left in this file: two tables every
-- 50 ms, created purely to be read twice and thrown away. calc.lua reads .red/.yellow and never
-- retains the table, so reusing one instance is safe.
local poolCfg = { red = 0, yellow = 0 }
local timeCfg = { red = 0, yellow = 0 }
-- ---- helpers ---------------------------------------------------------------------------- -- ---- helpers ----------------------------------------------------------------------------
local function db() return BulwarkFrameDB end local function db() return BulwarkFrameDB end
@@ -70,9 +77,14 @@ local function BuildFrame()
frame = CreateFrame("Frame", "BulwarkFrameMain", UIParent) frame = CreateFrame("Frame", "BulwarkFrameMain", UIParent)
frame:SetWidth(d.width) frame:SetWidth(d.width)
frame:SetHeight(60) frame:SetHeight(60)
frame:SetPoint(d.point, UIParent, d.relPoint, d.x, d.y) -- The anchor is applied by ApplyLayout (called at the end of this function), so that reset and
-- the options panel go through the SAME code path a fresh login does. Anchoring here as well
-- would leave two placement sites and let them disagree.
frame:SetScale(d.scale) frame:SetScale(d.scale)
frame:SetMovable(true) frame:SetMovable(true)
-- Without this the frame can be dragged until only a sliver is left on screen, and the
-- documented rescue ('/bulwark reset') then has to be able to fetch it back.
frame:SetClampedToScreen(true)
frame:EnableMouse(not d.locked) frame:EnableMouse(not d.locked)
frame:RegisterForDrag("LeftButton") frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", function() if not db().locked then this:StartMoving() end end) frame:SetScript("OnDragStart", function() if not db().locked then this:StartMoving() end end)
@@ -153,6 +165,12 @@ function BF.ApplyLayout()
frame:SetWidth(w) frame:SetWidth(w)
frame:SetScale(d.scale) frame:SetScale(d.scale)
frame:EnableMouse(not d.locked) frame:EnableMouse(not d.locked)
-- Re-anchor from the saved placement. Without this, '/bulwark reset' (and the panel's Reset
-- button) put CENTER/0/-120 back into the config while the frame itself stayed wherever it was
-- dragged -- so the documented way to rescue a frame from the screen edge did nothing until
-- the next login.
frame:ClearAllPoints()
frame:SetPoint(d.point or "CENTER", UIParent, d.relPoint or "CENTER", d.x or 0, d.y or 0)
if d.showThreshold then if d.showThreshold then
poolBar:ClearAllPoints() poolBar:ClearAllPoints()
@@ -273,7 +291,8 @@ local function updateDisplay()
if d.showThreshold then if d.showThreshold then
local frac = C.barFraction(stacks) local frac = C.barFraction(stacks)
poolBar:SetValue(frac) poolBar:SetValue(frac)
local r, g, b = bucketRGB(C.poolColorName(frac, { red = d.poolRed, yellow = d.poolYellow })) poolCfg.red, poolCfg.yellow = d.poolRed, d.poolYellow
local r, g, b = bucketRGB(C.poolColorName(frac, poolCfg))
poolBar:SetStatusBarColor(r, g, b) poolBar:SetStatusBarColor(r, g, b)
if d.showThresholdText then if d.showThresholdText then
local points = C.poolPoints(stacks, maxHP) local points = C.poolPoints(stacks, maxHP)
@@ -291,7 +310,8 @@ local function updateDisplay()
if d.showExpiry then if d.showExpiry then
local tf = C.timeFraction(timeLeft, BF.BUFFER_DURATION) local tf = C.timeFraction(timeLeft, BF.BUFFER_DURATION)
timeBar:SetValue(tf) timeBar:SetValue(tf)
local r, g, b = bucketRGB(C.timeColorName(timeLeft, { red = d.timeRed, yellow = d.timeYellow })) timeCfg.red, timeCfg.yellow = d.timeRed, d.timeYellow
local r, g, b = bucketRGB(C.timeColorName(timeLeft, timeCfg))
-- Dimmed on purpose: same buckets, lower alpha, so the threshold bar stays the primary -- Dimmed on purpose: same buckets, lower alpha, so the threshold bar stays the primary
-- readout instead of two equally loud bars sitting on top of each other. -- readout instead of two equally loud bars sitting on top of each other.
timeBar:SetStatusBarColor(r, g, b, d.expiryAlpha or 0.65) timeBar:SetStatusBarColor(r, g, b, d.expiryAlpha or 0.65)
@@ -339,6 +359,16 @@ function BF.RequestUpdate()
local d = db() local d = db()
local st = BF.state local st = BF.state
-- The deliberate hide is honoured FIRST, ahead of demo mode and ahead of the environment
-- hint. Checked further down it was unreachable whenever SuperWoW was missing, so the
-- "SuperWoW required" frame could not be dismissed at all: right-clicking the minimap button
-- flipped the flag and announced "hidden" while the frame stayed on screen.
if d.hidden then
frame:Hide()
frame:SetScript("OnUpdate", nil)
return
end
if demo then if demo then
frame:Show() frame:Show()
if not frame:GetScript("OnUpdate") then frame:SetScript("OnUpdate", BF.OnUpdate) end if not frame:GetScript("OnUpdate") then frame:SetScript("OnUpdate", BF.OnUpdate) end
@@ -355,12 +385,34 @@ function BF.RequestUpdate()
local shouldShow = st.active or st.inCombat or not d.hideWhenInactive local shouldShow = st.active or st.inCombat or not d.hideWhenInactive
if d.hideOutOfCombat and not st.inCombat then shouldShow = false end if d.hideOutOfCombat and not st.inCombat then shouldShow = false end
if d.hidden then shouldShow = false end -- explicitly hidden via the minimap button
if shouldShow then if shouldShow then
hintText:Hide() hintText:Hide()
frame:Show() frame:Show()
if not frame:GetScript("OnUpdate") then frame:SetScript("OnUpdate", BF.OnUpdate) end -- "On screen" and "has work to do" are different questions. With hideWhenInactive off
-- (the default) the frame stays visible while idle -- but once the buffer is down and
-- combat has ended there is nothing left to animate, so the OnUpdate script is still
-- detached: it was otherwise left spinning from login to logout, redrawing a saturated
-- swing marker every rendered frame and walking the whole buff list five times a second
-- for a readout that cannot change. The aura, combat and swing handlers in data.lua each
-- end in a RequestUpdate call and re-attach it, so nothing has to poll for the wakeup.
--
-- A swing still in flight counts as work even with the buffer down and combat over:
-- auto-attacking out of combat is ordinary play, and parking on it would freeze the
-- marker where it stood. Once progress saturates this stops being true and the loop's
-- own RequestUpdate call parks it.
local swinging = d.showSwing and st.swingSpeed and st.swingSpeed > 0
and S.hasSwung(st.lastSwingAt)
and S.progress(st.lastSwingAt, GetTime(), st.swingSpeed, st.swingOffset) < 1
if st.active or st.inCombat or swinging then
if not frame:GetScript("OnUpdate") then frame:SetScript("OnUpdate", BF.OnUpdate) end
else
frame:SetScript("OnUpdate", nil)
-- One last pass, unconditionally: the frame is visible and nothing will tick it, so
-- it has to be left parked on the correct final state rather than on empty text.
updateDisplay()
updateSwingMarker()
end
else else
frame:Hide() frame:Hide()
frame:SetScript("OnUpdate", nil) -- idle costs nothing at all frame:SetScript("OnUpdate", nil) -- idle costs nothing at all
@@ -373,7 +425,10 @@ function BF.OnUpdate()
if sinceUpdate < UPDATE_INTERVAL then return end if sinceUpdate < UPDATE_INTERVAL then return end
sinceUpdate = 0 sinceUpdate = 0
local active = updateDisplay() local active = updateDisplay()
if not demo and not active and not BF.state.inCombat and db().hideWhenInactive then -- Re-evaluated whatever hideWhenInactive says: with it off the frame stays on screen, but the
-- loop still has nothing left to do, and RequestUpdate is what parks it. Gating this on the
-- setting is why the handler used to run for the whole session in the default configuration.
if not demo and not active and not BF.state.inCombat then
BF.RequestUpdate() BF.RequestUpdate()
end end
end end
@@ -388,6 +443,13 @@ function BF.ToggleDemo(on)
if on then if on then
demo = { start = GetTime(), maxHP = (UnitHealthMax("player") or 4588) } demo = { start = GetTime(), maxHP = (UnitHealthMax("player") or 4588) }
if demo.maxHP <= 0 then demo.maxHP = 4588 end if demo.maxHP <= 0 then demo.maxHP = 4588 end
-- Turning demo on is an explicit "show me the frame", so it clears the manual hide.
-- RequestUpdate honours that hide FIRST (it has to -- otherwise the "SuperWoW required"
-- frame cannot be dismissed), which meant '/bulwark demo' over a minimap-hidden frame
-- reported "demo on", drew nothing, ticked nothing, and left the user silently parked in
-- demo mode until they un-hid the frame. Clearing the flag here keeps the precedence
-- where it belongs and still makes the command deliver what it announces.
db().hidden = false
-- Synthetic numbers MUST be labelled as such. Unlabelled demo data is indistinguishable -- Synthetic numbers MUST be labelled as such. Unlabelled demo data is indistinguishable
-- from a live readout that happens to be wrong, and that is the one failure this addon -- from a live readout that happens to be wrong, and that is the one failure this addon
-- exists to avoid. -- exists to avoid.
@@ -421,6 +483,11 @@ function BF.InitUI()
local ok = BulwarkFrameEnv.check(SUPERWOW_VERSION) local ok = BulwarkFrameEnv.check(SUPERWOW_VERSION)
BF.envOk = ok BF.envOk = ok
BF.state.maxHP = UnitHealthMax("player") or 0 BF.state.maxHP = UnitHealthMax("player") or 0
-- Seeded here as well as in the PLAYER_ENTERING_WORLD handler: this runs off PLAYER_LOGIN and
-- the order of the two is not guaranteed. PLAYER_REGEN_DISABLED only fires on ENTERING combat,
-- so without a live read a /reload taken mid-fight leaves "Combat only" users with no frame
-- for the rest of the encounter.
BF.state.inCombat = UnitAffectingCombat("player") and true or false
BF.CapturePlayerGuid() BF.CapturePlayerGuid()
if ok then if ok then
BF.RefreshSwingInputs() BF.RefreshSwingInputs()