From 2b6de41d9a37bd8c633cc041d1d4d0a3951b9719 Mon Sep 17 00:00:00 2001 From: ShempError <689+shemperror@noreply.octowow.st> Date: Mon, 17 Aug 2026 15:19:45 +0200 Subject: [PATCH] BulwarkFrame v0.2.0 --- BulwarkFrame.toc | 2 +- CHANGELOG.md | 54 +++++++++++++++++++++++++++ README.md | 11 +++++- core/calc.lua | 5 ++- data.lua | 96 +++++++++++++++++++++++++++++++++++++----------- main.lua | 33 ++++++++++++++++- options.lua | 6 +++ ui.lua | 81 ++++++++++++++++++++++++++++++++++++---- 8 files changed, 253 insertions(+), 35 deletions(-) create mode 100644 CHANGELOG.md diff --git a/BulwarkFrame.toc b/BulwarkFrame.toc index f64c76b..59a635e 100644 --- a/BulwarkFrame.toc +++ b/BulwarkFrame.toc @@ -2,7 +2,7 @@ ## Title: BulwarkFrame ## Notes: Compact real-time readout for the shaman Earthen Bulwark. Requires SuperWoW. ## Author: ShempError -## Version: 0.2.0-dev +## Version: 0.2.0 ## SavedVariables: BulwarkFrameDB core\env.lua diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ef49367 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 7e6b5df..2d8dcb2 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,10 @@ a `git pull`. | Command | Does | |---|---| | `/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 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 | `/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_env.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 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 diff --git a/core/calc.lua b/core/calc.lua index 52a57a1..4d44776 100644 --- a/core/calc.lua +++ b/core/calc.lua @@ -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. function C.rateForRank(rank, addBonus, multBonus) 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(multBonus) == "number" then base = base * multBonus end return base diff --git a/data.lua b/data.lua index 3208392..f9eac8a 100644 --- a/data.lua +++ b/data.lua @@ -32,6 +32,43 @@ BF.state = { -- ---- 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. -- 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 @@ -83,28 +120,17 @@ function BF.ScanAuras() -- 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 -- 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. - local scanStacks = function(fn, idPos) - local i = 1 - while i <= 64 do - local r1, r2, r3, r4 - local ok = pcall(function() r1, r2, r3, r4 = fn("player", i) end) - if not ok or 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 + -- + -- The walk itself lives at file scope (see scanStacks above). pcall is handed the arguments + -- directly instead of a wrapper closure, so this path allocates nothing at all. + if UnitDebuff then + local ok, s, f = pcall(scanStacks, st, UnitDebuff, 4, poolId, timeId, stacks, found) + if ok then stacks, found = s, f end + end + if UnitBuff then + local ok, s, f = pcall(scanStacks, st, UnitBuff, 3, poolId, timeId, stacks, found) + if ok then stacks, found = s, f 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 -- 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("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. -- 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. @@ -192,15 +227,32 @@ driver:SetScript("OnEvent", function() if arg3 == "MAINHAND" and arg1 == BF.playerGuid then BF.state.lastSwingAt = GetTime() 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 return 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 elseif event == "PLAYER_REGEN_ENABLED" then BF.state.inCombat = false elseif event == "PLAYER_ENTERING_WORLD" or event == "UNIT_INVENTORY_CHANGED" then 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.RefreshSwingInputs() end diff --git a/main.lua b/main.lua index 59ed407..0791634 100644 --- a/main.lua +++ b/main.lua @@ -23,29 +23,58 @@ SLASH_BULWARKFRAME2 = "/bf" SlashCmdList["BULWARKFRAME"] = function(input) local cmd = string.lower(input or "") -- 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 BF.ToggleOptions() 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() - 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 BF.RunProbe() elseif cmd == "lock" then BulwarkFrameDB.locked = not BulwarkFrameDB.locked BF.ApplyLayout() msg(BulwarkFrameDB.locked and "frame locked" or "frame unlocked") + dirty = true elseif cmd == "reset" then BF.resetConfig() BF.ApplyLayout() BF.RequestUpdate() BF.UpdateMinimapButton() msg("settings reset to defaults") + dirty = true 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.hideOutOfCombat = false 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 msg("commands: options | demo | probe | lock | show | reset") 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 diff --git a/options.lua b/options.lua index 3911484..ea5744e 100644 --- a/options.lua +++ b/options.lua @@ -434,6 +434,12 @@ local function BuildPanel() this:SetText(on and "Demo: on" or "Demo") end) 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 " .. "(also /bulwark probe).", function() BF.RunProbe() end) diff --git a/ui.lua b/ui.lua index 147d446..9b1c7cb 100644 --- a/ui.lua +++ b/ui.lua @@ -7,7 +7,7 @@ -- * 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; -- * 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 {} local BF = BulwarkFrame @@ -23,6 +23,13 @@ local demo = nil -- demo mode state, nil when off local UPDATE_INTERVAL = 0.05 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 ---------------------------------------------------------------------------- local function db() return BulwarkFrameDB end @@ -70,9 +77,14 @@ local function BuildFrame() frame = CreateFrame("Frame", "BulwarkFrameMain", UIParent) frame:SetWidth(d.width) 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: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:RegisterForDrag("LeftButton") frame:SetScript("OnDragStart", function() if not db().locked then this:StartMoving() end end) @@ -153,6 +165,12 @@ function BF.ApplyLayout() frame:SetWidth(w) frame:SetScale(d.scale) 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 poolBar:ClearAllPoints() @@ -273,7 +291,8 @@ local function updateDisplay() if d.showThreshold then local frac = C.barFraction(stacks) 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) if d.showThresholdText then local points = C.poolPoints(stacks, maxHP) @@ -291,7 +310,8 @@ local function updateDisplay() if d.showExpiry then local tf = C.timeFraction(timeLeft, BF.BUFFER_DURATION) 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 -- readout instead of two equally loud bars sitting on top of each other. timeBar:SetStatusBarColor(r, g, b, d.expiryAlpha or 0.65) @@ -339,6 +359,16 @@ function BF.RequestUpdate() local d = db() 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 frame:Show() 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 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 hintText:Hide() 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 frame:Hide() frame:SetScript("OnUpdate", nil) -- idle costs nothing at all @@ -373,7 +425,10 @@ function BF.OnUpdate() if sinceUpdate < UPDATE_INTERVAL then return end sinceUpdate = 0 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() end end @@ -388,6 +443,13 @@ function BF.ToggleDemo(on) if on then demo = { start = GetTime(), maxHP = (UnitHealthMax("player") or 4588) } 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 -- from a live readout that happens to be wrong, and that is the one failure this addon -- exists to avoid. @@ -421,6 +483,11 @@ function BF.InitUI() local ok = BulwarkFrameEnv.check(SUPERWOW_VERSION) BF.envOk = ok 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() if ok then BF.RefreshSwingInputs()