+
+## Screenshots
+
+| The readout | Options panel |
+| :---: | :---: |
+| 
|
|
+
+The two readouts show the same buffer answering different questions: **2875 / 6117** is the
+largest single hit it would still swallow, **807 / 918** is how much damage it absorbs. On the
+lower bar, `2.24` is the current attack speed and the white marker is the swing, riding the expiry
+bar the way pfUI's mana tick rides the mana bar.
+
+*(Click any image for full size.)*
+
+## Features
+
+**The readout** — three elements, no portrait, no name, no health or mana, no title:
+
+1. **Hit threshold** — `current / maximum`, e.g. `2875 / 6117`. The bar's fill is the buffer's
+ fill. One flat colour by threshold (red / yellow / green), no gradient. Switchable to
+ **max shield** (`807 / 918`), which is the same buffer expressed as damage absorbed rather
+ than as the hit that would drain it.
+2. **Expiry** — remaining seconds as a number *and* as the bar's width, over the buffer's
+ 8 second lifetime. Re-read five times a second, so it glides rather than steps.
+3. **Swing timer** — a marker riding the expiry bar (or its own 1–2 px line, if you prefer),
+ with the current attack speed printed on the left. Deliberately subordinate.
+
+**How the buffer works** (from the Elemental Weapons tooltip, verbatim): *"Rockbiter: Physical
+damage builds up an earthen bulwark equal to 20% of damage dealt, tripled while wearing a shield.
+The bulwark absorbs 15% of incoming damage for 8 sec or until it mitigates enough damage. Its
+durability cannot exceed 20% of maximum health."*
+
+Note the asymmetry, because it is easy to get backwards: the pool is built **only** by physical
+damage you deal, but it absorbs **incoming damage of any kind** — spell damage included.
+
+**Measured, not guessed.** The numbers the addon computes with were verified in game rather than
+taken from a wiki: the fill level is the stack count of aura 58127, a full pool is 20 % of max
+health, and the absorb rate at rank 3 is 15 % (median over 477 hits) — matching what the tooltip
+claims.
+
+**Honest about what it does not know.** Where a value cannot be established — an unknown talent
+rank, a missing aura — the frame shows `--` rather than a number derived from a guess.
+
+**What it deliberately is not:** no statistics, no combat reports, no graphs, no history. It is a
+live readout. Recording and analysis belong elsewhere.
+
+## Install
+
+**Option A — download (simplest):**
+
+1. Grab **`BulwarkFrame-vX.Y.Z.zip`** from
+ [**Releases**](https://github.com/ShempError/BulwarkFrame/releases).
+ *(Use the Release zip, not "Code → Download ZIP" — that names the folder
+ `BulwarkFrame-master`, which WoW won't load.)*
+2. Extract the **`BulwarkFrame`** folder into `Interface\AddOns\`.
+3. Restart the client.
+
+**Option B — git (auto-updatable):**
+
+```
+cd Interface/AddOns
+git clone https://github.com/ShempError/BulwarkFrame.git BulwarkFrame
+```
+
+`master` is the stable channel — git-based managers (GitAddonsManager, OctoWoW) stay current with
+a `git pull`.
+
+## Usage
+
+| 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 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 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
+readout.
+
+## Options
+
+**Frame** — lock, scale, width, per-bar heights, minimap button, hide-when-idle, combat-only.
+**Elements** — each of the three readouts and every text label individually; swing marker on the
+expiry bar or on its own line; max hit vs. max shield.
+**Colours** — the three threshold colours and the swing marker, via the standard colour picker.
+**Absorb model** — talent rank (5 / 10 / 15 %), the set-bonus reading, and whether the swing
+marker is latency-adjusted.
+
+Every change applies immediately — an options panel whose effect only shows after `/reload`
+teaches you to distrust it.
+
+### A note on units
+
+The threshold is in **post-mitigation** damage — the same numbers your combat log shows. The
+buffer sits behind armor, block, resist and Stoneskin, so it never sees a boss's raw swing.
+Compare the displayed value against the damage you actually take, not against a tooltip.
+
+### The Tier 2.5 set bonus
+
+The bonus is documented as "+3 % absorption" and it is *not* established whether that means
+15 → 18 or 15 × 1.03. Both readings are selectable and neither is assumed; the default is off.
+
+## Requirements
+
+**[SuperWoW](https://github.com/balakethelock/SuperWoW) is required.** The addon stays inactive
+without it and says so once on login.
+
+This is not a soft preference. The buffer's fill level is read from a specific aura *by spell id*,
+which needs SuperWoW; the fallback would be matching on the icon texture, which several unrelated
+auras share. That would produce a number that is confidently wrong rather than absent, and a wrong
+number on a defensive readout is worse than no readout.
+
+The swing timer likewise resets on SuperWoW's `UNIT_CASTEVENT`, which is language independent and
+fires on misses too — unlike parsing combat-log text.
+
+Nothing else is required. No other addon, no client patch.
+
+## For developers
+
+The calculation core is pure Lua with no WoW API, so it runs offline against a real Lua 5.0.3
+interpreter — the same version the 1.12 client uses, which is what catches `#table`, `str:find()`
+and `string.match` before they reach the game.
+
+```
+lua50 tools/luatests/test_calc.lua
+lua50 tools/luatests/test_swing.lua
+lua50 tools/luatests/test_env.lua
+lua50 tools/luatests/test_config.lua
+```
+
+**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
+restart per change.
+
+**Panel art:** `node tools/gen_panel_textures.js` regenerates the options panel's background and
+border textures. Zero dependencies, and it verifies its own TGA headers and tiling seams.
+
+## Licence
+
+MIT.
diff --git a/core/calc.lua b/core/calc.lua
new file mode 100644
index 0000000..52a57a1
--- /dev/null
+++ b/core/calc.lua
@@ -0,0 +1,175 @@
+-- BulwarkFrame -- pure calculation core.
+--
+-- Deliberately free of any WoW API: everything here is arithmetic over three inputs the
+-- display layer supplies (max health, the aura's stack count, the talent rank). That keeps
+-- the error-prone part testable offline under real Lua 5.0.3, which is where the 1.12 traps
+-- (#table, string methods via ':', string.match) actually bite.
+--
+-- The constants below are not folklore. All three were measured in-game on 2026-07-28:
+-- * the pool is encoded as the stack count of aura 58127, capped at 100
+-- * a full pool is 20% of max health => 1 stack = MaxHP/500 (4588 HP -> 9.176 points)
+-- * the absorb rate at talent rank 3 is 15.0% (median over 477 hits)
+-- The rate is NOT hardcoded here: rank 1/2/3 map to 5/10/15%, and a set bonus can be layered
+-- on top, because the T2.5 bonus ("+3% absorption") is not yet understood -- see rateForRank.
+
+BulwarkFrameCalc = {}
+local C = BulwarkFrameCalc
+
+local MAX_STACKS = 100 -- the aura caps here; more is not possible
+
+local function clamp(v, lo, hi)
+ if v < lo then return lo end
+ if v > hi then return hi end
+ return v
+end
+
+local function round(v) return math.floor(v + 0.5) end
+
+-- ---- points ------------------------------------------------------------------------------
+
+-- maxPool(maxHP): the full buffer in absorb points -- 20% of max health.
+-- Written as maxHP/5 rather than maxHP*0.20 so it is a single exact division; the stack scale
+-- below is derived FROM it, so the bar and the numbers can never drift apart.
+function C.maxPool(maxHP)
+ if type(maxHP) ~= "number" or maxHP <= 0 then return 0 end
+ return maxHP / 5
+end
+
+-- stackPoints(maxHP): what one stack of aura 58127 is worth, in absorb points.
+function C.stackPoints(maxHP)
+ return C.maxPool(maxHP) / MAX_STACKS
+end
+
+-- poolPoints(stacks, maxHP): the current buffer in absorb points.
+function C.poolPoints(stacks, maxHP)
+ if type(stacks) ~= "number" then return 0 end
+ return C.maxPool(maxHP) * (clamp(stacks, 0, MAX_STACKS) / MAX_STACKS)
+end
+
+function C.roundPoints(p)
+ if type(p) ~= "number" then return 0 end
+ return round(p)
+end
+
+-- ---- the headline number ------------------------------------------------------------------
+
+-- threshold(points, rate): how large a single incoming hit would have to be for the buffer to
+-- swallow `points` of it -- i.e. to be drained by that one hit.
+--
+-- UNIT NOTE, easy to misread: this is POST-MITIGATION damage. The buffer sits behind armor,
+-- block, resist and Stoneskin (all verified 2026-07-28), so the number is directly comparable
+-- to the damage figures in the combat log, NOT to a boss's raw swing.
+--
+-- Returns nil for a missing or zero rate (talent not learned) instead of dividing by zero: an
+-- "inf" on screen is worse than an empty frame.
+function C.threshold(points, rate)
+ if type(rate) ~= "number" or rate <= 0 then return nil end
+ if type(points) ~= "number" then return nil end
+ return points / rate
+end
+
+-- ---- bar fill -----------------------------------------------------------------------------
+
+-- barFraction(stacks): how full the pool bar is, 0..1.
+--
+-- This is exactly stacks/100 -- not a shortcut but the exact answer. The pair shown on the bar
+-- is (pool/rate) over (maxPool/rate); both the rate and max health cancel out, so the fill
+-- carries NO rounding error even though the two displayed numbers are rounded.
+function C.barFraction(stacks)
+ if type(stacks) ~= "number" then return 0 end
+ return clamp(stacks, 0, MAX_STACKS) / MAX_STACKS
+end
+
+-- timeFraction(timeLeft, duration): how full the expiry bar is, 0..1.
+-- A refresh can report slightly more than the nominal duration -> clamped, not trusted.
+function C.timeFraction(timeLeft, duration)
+ if type(timeLeft) ~= "number" or type(duration) ~= "number" or duration <= 0 then return 0 end
+ return clamp(timeLeft / duration, 0, 1)
+end
+
+-- ---- colours ------------------------------------------------------------------------------
+-- One flat colour for the whole filled area. No gradient inside a bar.
+
+C.POOL_THRESHOLDS = { red = 0.30, yellow = 0.70 } -- fractions
+C.TIME_THRESHOLDS = { red = 2.0, yellow = 5.0 } -- seconds remaining
+
+C.COLORS = {
+ red = { 0.80, 0.15, 0.15 },
+ yellow = { 0.85, 0.75, 0.20 },
+ green = { 0.25, 0.75, 0.30 },
+}
+
+function C.poolColorName(fraction, cfg)
+ cfg = cfg or C.POOL_THRESHOLDS
+ if type(fraction) ~= "number" then return "red" end
+ if fraction <= (cfg.red or 0.30) then return "red" end
+ if fraction <= (cfg.yellow or 0.70) then return "yellow" end
+ return "green"
+end
+
+-- Time buckets run the other way round: MORE seconds left is better.
+function C.timeColorName(timeLeft, cfg)
+ cfg = cfg or C.TIME_THRESHOLDS
+ if type(timeLeft) ~= "number" then return "red" end
+ if timeLeft < (cfg.red or 2.0) then return "red" end
+ if timeLeft <= (cfg.yellow or 5.0) then return "yellow" end
+ return "green"
+end
+
+-- colorRGB(name): never returns nil -- an unknown name yields a visible neutral colour rather
+-- than a Lua error deep inside a SetStatusBarColor call.
+function C.colorRGB(name)
+ local c = C.COLORS[name or ""]
+ if not c then return 0.7, 0.7, 0.7 end
+ return c[1], c[2], c[3]
+end
+
+-- ---- formatting ---------------------------------------------------------------------------
+
+-- fmtThresholdPair(curPoints, maxPoints, rate) -> "2820 / 6120"
+-- Rounds the RESULT, not the inputs. Rounding the point values first shifts the number by a
+-- few points; the two orders are not interchangeable (pinned by a test).
+function C.fmtThresholdPair(curPoints, maxPoints, rate)
+ local a, b = C.threshold(curPoints, rate), C.threshold(maxPoints, rate)
+ if not a or not b then return "--" end
+ return round(a) .. " / " .. round(b)
+end
+
+-- fmtShieldPair(curPoints, maxPoints) -> "459 / 918"
+--
+-- The other reading of the same buffer. fmtThresholdPair answers "how large may a single hit be
+-- before this is drained"; this one answers "how much damage does it still absorb". They differ by
+-- the absorb rate -- at rank 3 by a factor of about seven -- so which one is on screen has to be a
+-- deliberate choice, not an accident.
+--
+-- Note it takes no rate: the shield value is the pool itself. That also makes it the honest
+-- fallback when the talent rank is unknown, where fmtThresholdPair can only return "--".
+function C.fmtShieldPair(curPoints, maxPoints)
+ if type(curPoints) ~= "number" or type(maxPoints) ~= "number" then return "--" end
+ return round(curPoints) .. " / " .. round(maxPoints)
+end
+
+function C.fmtTime(t)
+ if type(t) ~= "number" or t < 0 then t = 0 end
+ return string.format("%.1f s", t)
+end
+
+-- ---- absorb rate --------------------------------------------------------------------------
+
+C.RANK_RATE = { [0] = 0, [1] = 0.05, [2] = 0.10, [3] = 0.15 }
+
+-- rateForRank(rank, addBonus, multBonus): the active absorb rate.
+--
+-- An unknown rank yields 0, never a silent 15%: a threshold computed from a guessed rate is
+-- worse than no threshold, because it looks authoritative.
+--
+-- The T2.5 set bonus is documented as "+3% absorption" and it is NOT established whether that
+-- means 15 -> 18 (additive) or 15 * 1.03 (multiplicative). Both readings are expressible and
+-- 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
+ if type(addBonus) == "number" then base = base + addBonus end
+ if type(multBonus) == "number" then base = base * multBonus end
+ return base
+end
diff --git a/core/config.lua b/core/config.lua
new file mode 100644
index 0000000..6aec5d8
--- /dev/null
+++ b/core/config.lua
@@ -0,0 +1,125 @@
+-- BulwarkFrame - core/config.lua
+-- SavedVariables model. BulwarkFrameDB is declared in the .toc; ensureDefaults() fills in
+-- anything missing, so a saved file from an older version keeps working after an update.
+--
+-- Pure table work, no WoW API -- covered by tools/luatests/test_config.lua.
+
+BulwarkFrame = BulwarkFrame or {}
+local BF = BulwarkFrame
+
+BulwarkFrameDB = BulwarkFrameDB or {}
+
+-- Aura ids of the buffer, both verified in game on 2026-07-28:
+-- 58130 "Earthen Bulwark" -- a BUFF, carries the remaining DURATION (8 s)
+-- 58127 "Earthen Bulwark Durability" -- a DEBUFF, its stack count IS the pool level (0..100)
+-- They stay configurable rather than baked in because Turtle can renumber a custom spell in any
+-- patch, and a hardcoded id would then read a buffer that is not there.
+BF.DEFAULT_POOL_AURA = 58127
+BF.DEFAULT_TIME_AURA = 58130
+BF.BUFFER_DURATION = 8
+
+BF.DEFAULTS = {
+ -- placement
+ point = "CENTER", relPoint = "CENTER", x = 0, y = -120,
+ locked = false,
+ hidden = false, -- explicitly hidden via minimap right-click / '/bulwark hide'
+ scale = 1.0,
+ minimapAngle = 215,
+ showMinimapButton = true,
+
+ -- layout
+ width = 150,
+ barHeight = 14,
+ timeBarHeight = 9,
+ swingHeight = 2,
+ spacing = 2,
+
+ -- elements
+ showThreshold = true,
+ showExpiry = true,
+ showSwing = true,
+ -- Swing marker rides the expiry bar (pfUI mana-tick style) instead of its own line.
+ swingOnExpiryBar = true,
+ -- What the number on the threshold bar means:
+ -- "hit" -- how large a single incoming hit may be before the buffer is drained
+ -- "shield" -- how much damage the buffer still absorbs
+ -- Same buffer, two questions; they differ by the absorb rate (factor ~7 at rank 3).
+ displayMode = "hit",
+ showThresholdText = true,
+ showExpiryText = true,
+ showSpeedText = true, -- current attack speed on the left of the expiry bar
+ hideOutOfCombat = false,
+ -- Visible by default. A readout that hides itself whenever there is nothing to read looks
+ -- broken on first install -- you cannot place it, and the obvious fix (clicking things until it
+ -- appears) is how a demo mode gets mistaken for live data. Opt in to hiding, not out of it.
+ hideWhenInactive = false,
+
+ -- colours (r,g,b 0..1); calc.lua picks WHICH one, these are the values
+ colorRed = { 0.80, 0.15, 0.15 },
+ colorYellow = { 0.85, 0.75, 0.20 },
+ colorGreen = { 0.25, 0.75, 0.30 },
+ colorSwing = { 0.90, 0.90, 0.95 },
+ colorBg = { 0.05, 0.05, 0.05, 0.80 },
+
+ -- The expiry bar shares the colour buckets but is drawn at this alpha. Seen side by side at
+ -- full saturation the two bars compete for attention even though the lower one is thinner --
+ -- the threshold is the primary readout and has to win. Set to 1.0 for the old, flat look.
+ expiryAlpha = 0.65,
+
+ -- colour thresholds (fraction of pool / seconds left)
+ poolRed = 0.30, poolYellow = 0.70,
+ timeRed = 2.0, timeYellow = 5.0,
+
+ -- absorb model
+ talentRank = 3, -- Elemental Weapons rank -> 5/10/15 %
+ setBonusMode = "none", -- "none" | "add" | "mult" (T2.5 reading, see README)
+ setBonusValue = 3,
+
+ -- swing timer
+ swingLatencyShare = 1.0, -- 1.0 = full round trip, 0.5 = downstream only
+ swingUseLatency = true,
+
+ -- aura ids
+ poolAura = 58127,
+ timeAura = 58130,
+}
+
+-- Deep-ish copy for the colour tables: handing out DEFAULTS directly would let a live edit of the
+-- saved value mutate the default and make a "reset" a no-op.
+local function copyColor(c)
+ if type(c) ~= "table" then return nil end
+ return { c[1], c[2], c[3], c[4] }
+end
+
+function BF.ensureDefaults(db)
+ db = db or BulwarkFrameDB
+ local k, v
+ for k, v in pairs(BF.DEFAULTS) do
+ if db[k] == nil then
+ if type(v) == "table" then db[k] = copyColor(v) else db[k] = v end
+ end
+ end
+ return db
+end
+
+-- Resets everything back to DEFAULTS in place (the caller keeps its table reference).
+function BF.resetConfig(db)
+ db = db or BulwarkFrameDB
+ local k
+ for k in pairs(db) do db[k] = nil end
+ return BF.ensureDefaults(db)
+end
+
+-- The absorb rate the calculation should use, from talent rank plus the optional set bonus.
+-- Delegates to BulwarkFrameCalc.rateForRank so the two readings of the T2.5 bonus stay in one
+-- place. "mult" passes a factor, so 3 % becomes 1.03 here rather than at the call site.
+function BF.currentRate(db)
+ db = db or BulwarkFrameDB
+ local add, mult = nil, nil
+ if db.setBonusMode == "add" then
+ add = (db.setBonusValue or 0) / 100
+ elseif db.setBonusMode == "mult" then
+ mult = 1 + ((db.setBonusValue or 0) / 100)
+ end
+ return BulwarkFrameCalc.rateForRank(db.talentRank, add, mult)
+end
diff --git a/core/env.lua b/core/env.lua
new file mode 100644
index 0000000..44959ad
--- /dev/null
+++ b/core/env.lua
@@ -0,0 +1,34 @@
+-- BulwarkFrame -- environment guard.
+--
+-- SuperWoW is a HARD requirement, not a nice-to-have. Two of the three things this addon does
+-- have no honest fallback without it:
+--
+-- * identifying the buffer aura by SPELL ID (GetPlayerBuffID). The alternative is matching
+-- on the texture path, which several unrelated auras share -- that produces a readout that
+-- is confidently wrong rather than absent, which is worse.
+-- * resetting the swing on UNIT_CASTEVENT (arg3 = "MAINHAND"/"OFFHAND"). Without it the only
+-- route is parsing localised combat-log strings, which breaks on any client whose locale
+-- or strings differ.
+--
+-- So there is deliberately no bypass switch. An addon that draws a plausible but wrong number
+-- is worse than one that says it cannot run.
+--
+-- SUPERWOW_VERSION is set before any addon loads, so this can be evaluated at module scope --
+-- unlike nampower and UnitXP, which are only detectable once in the world. Neither is used
+-- here; if that changes, their detection has to move to a later event.
+
+BulwarkFrameEnv = {}
+local E = BulwarkFrameEnv
+
+E.MESSAGE = "BulwarkFrame: SuperWoW is required and was not detected -- staying inactive. "
+ .. "The buffer pool is read by spell id, which needs SuperWoW."
+
+-- check(superwowVersion) -> ok(boolean), message(string or nil). PURE.
+--
+-- Presence is a plain nil check, matching what the other addons in this ecosystem do
+-- (`SUPERWOW_VERSION ~= nil`). A stricter test would risk rejecting a valid SuperWoW that
+-- reports its version in an unexpected shape.
+function E.check(superwowVersion)
+ if superwowVersion ~= nil then return true, nil end
+ return false, E.MESSAGE
+end
diff --git a/core/swing.lua b/core/swing.lua
new file mode 100644
index 0000000..ad763ca
--- /dev/null
+++ b/core/swing.lua
@@ -0,0 +1,80 @@
+-- BulwarkFrame -- swing-timer arithmetic (pure).
+--
+-- Why we build this instead of taking a dependency: the only Turtle-tested swing timer in
+-- circulation (SP_SwingTimer) resets on LOCALISED combat-log strings, which is fragile, and it
+-- has no extension points for the shaman specifics we care about -- Windfury extra attacks and
+-- Flurry haste. Both of those break a generic timer, so a small own module is easier to keep
+-- honest than a foreign addon to patch.
+--
+-- Two building blocks, both confirmed:
+-- * swing DURATION -- UnitAttackSpeed("player") returns (mainhand, offhand) in seconds.
+-- Present in 1.12: Blizzard's own PaperDollFrame.lua:285 uses it.
+-- * swing RESET -- SuperWoW's UNIT_CASTEVENT with arg3 = "MAINHAND"/"OFFHAND". Language
+-- independent and fires on misses too, unlike string parsing.
+-- Neither is touched here; this file is arithmetic only, so it runs under plain Lua 5.0.3.
+
+BulwarkFrameSwing = {}
+local S = BulwarkFrameSwing
+
+local function clamp(v, lo, hi)
+ if v < lo then return lo end
+ if v > hi then return hi end
+ return v
+end
+
+-- hasSwung(lastSwingAt): is there a swing to draw at all?
+-- 0 doubles as the "never swung" sentinel, matching how the runtime layer will initialise it.
+function S.hasSwung(lastSwingAt)
+ return type(lastSwingAt) == "number" and lastSwingAt > 0
+end
+
+-- progress(lastSwingAt, now, speed) -> 0..1 through the current swing.
+--
+-- Saturates at 1 rather than wrapping: between swings (target dead, out of range, moving) the
+-- marker should sit still at the end, not sweep round again as if an attack were imminent.
+--
+-- NOTE ON HASTE -- deliberately not decided here. When Flurry changes attack speed mid-swing it
+-- is unverified whether the running swing rescales or the new speed only applies to the next
+-- one. `speed` is therefore an argument: pass the value captured at swing start for the
+-- "next swing only" reading, or the live value for the "rescales" reading.
+-- LATENCY -- why it is an argument and not a constant.
+--
+-- The swing happens on the SERVER at T. The client only learns about it when the event
+-- arrives, so the timestamp we record is already T + downstream. Everything derived from it is
+-- therefore late by that much, and the two plausible corrections are NOT the same number:
+--
+-- * "show the true state" -> shift by the downstream leg alone (~rtt/2)
+-- * "show the action point" -> shift by the FULL rtt, because a keypress still needs the
+-- upstream leg to reach the server. The two legs ADD; they do
+-- not cancel.
+--
+-- Which one is wanted is a display decision, so this module takes a plain `offset` in seconds
+-- and applies it, rather than picking a model. Measured on this character: mean rtt 78 ms,
+-- p90 82 ms, spikes to 253 ms -- i.e. 3% of a 2.6 s swing normally and 10% on a spike. Worth
+-- offering, not worth hardcoding. (GetNetStats() supplies the rtt in ms, but only refreshes
+-- every ~30 s in 1.12, so it tracks the baseline and not a spike.)
+function S.progress(lastSwingAt, now, speed, offset)
+ if type(lastSwingAt) ~= "number" or type(now) ~= "number" then return 0 end
+ if type(speed) ~= "number" or speed <= 0 then return 0 end
+ if type(offset) ~= "number" then offset = 0 end
+ return clamp((now - lastSwingAt + offset) / speed, 0, 1)
+end
+
+-- remaining(lastSwingAt, now, speed, offset) -> seconds until the next swing, floored at 0.
+function S.remaining(lastSwingAt, now, speed, offset)
+ if type(lastSwingAt) ~= "number" or type(now) ~= "number" then return 0 end
+ if type(speed) ~= "number" or speed <= 0 then return 0 end
+ if type(offset) ~= "number" then offset = 0 end
+ return clamp(speed - (now - lastSwingAt + offset), 0, speed)
+end
+
+-- latencySeconds(ms, share): turn GetNetStats' round-trip milliseconds into the offset above.
+-- `share` selects the model: 0.5 for the downstream leg ("true state"), 1.0 for the full round
+-- trip ("action point"). Defaults to 1.0 -- if a timer is used to decide WHEN TO PRESS, the
+-- full trip is the honest number, and erring toward "press slightly early" is the harmless
+-- direction: an ability queued a touch too soon still lands, one queued late misses the window.
+function S.latencySeconds(ms, share)
+ if type(ms) ~= "number" or ms <= 0 then return 0 end
+ if type(share) ~= "number" then share = 1.0 end
+ return (ms / 1000) * share
+end
diff --git a/data.lua b/data.lua
new file mode 100644
index 0000000..3208392
--- /dev/null
+++ b/data.lua
@@ -0,0 +1,222 @@
+-- BulwarkFrame - data.lua
+-- Everything that touches the live client: the buffer aura, the swing timer, combat state.
+-- WoW-API file (parse-checked only; the arithmetic it feeds lives in core/ and is unit tested).
+--
+-- Two reads, and they come from DIFFERENT enumerations -- settled in game on 2026-07-28, no
+-- longer an open question:
+-- * TIME -- aura 58130 "Earthen Bulwark", a BUFF. Seen by GetPlayerBuff / UnitBuff (spell id at
+-- return 3). Counts 8 seconds down; verified across eight appearances.
+-- * POOL -- aura 58127 "Earthen Bulwark Durability", a DEBUFF. Its stack count IS the fill
+-- level, 0..100 (verified building up: 20, 44, 70, 91, 100). It is NOT listed by the
+-- HELPFUL player-buff enumeration at all, only by UnitDebuff (spell id at return 4).
+-- Both ids stay configurable, and the reader takes each value from whichever aura reports it.
+
+BulwarkFrame = BulwarkFrame or {}
+local BF = BulwarkFrame
+local S = BulwarkFrameSwing
+
+-- Live state, read by ui.lua. One table, updated in place -- no per-frame allocation.
+BF.state = {
+ active = false, -- buffer aura present
+ stacks = 0,
+ timeLeft = 0,
+ maxHP = 0,
+ inCombat = false,
+ timeLeftAt = 0, -- GetTime() of the last timeLeft reading (see ScanAuras)
+ lastSwingAt = 0,
+ swingSpeed = 0,
+ swingOffset = 0,
+ hasPool = false, -- pool aura seen at least once this session (probe hint)
+ hasTime = false,
+}
+
+-- ---- buff scan --------------------------------------------------------------------------
+
+-- 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
+-- with unrelated auras and produce a confidently wrong readout.
+--
+-- GOTCHA (in-game verified): GetPlayerBuffTimeLeft returns 0 for a permanent buff, not nil, so
+-- "has a duration" must be tested as > 0.
+function BF.ScanAuras()
+ local st = BF.state
+ local db = BulwarkFrameDB
+ local poolId, timeId = db.poolAura, db.timeAura
+
+ local stacks, timeLeft = 0, 0
+ local found = false
+ local i = 0
+ while i < 32 do
+ local slot = GetPlayerBuff(i, "HELPFUL")
+ if not slot or slot < 0 then break end
+ local id = GetPlayerBuffID and GetPlayerBuffID(slot)
+ if id == poolId or id == timeId then
+ found = true
+ -- Stacks AND duration are taken from whichever of the two auras carries them, rather
+ -- than assuming the pool id owns the count. Reading the count only from poolId meant
+ -- that if the buffer happens to sit on the other id, the aura was detected but the
+ -- count stayed 0 -- and the "no count means one stack" fallback below then reported a
+ -- single stack. On a full buffer that renders as 61 instead of 6117: a plausible
+ -- number, off by a factor of a hundred. Whichever aura reports more wins; they cannot
+ -- contradict each other because they describe the same buffer.
+ local n = GetPlayerBuffApplications and GetPlayerBuffApplications(slot)
+ if type(n) == "number" and n > stacks then
+ stacks = n
+ if id == poolId then st.hasPool = true end
+ end
+ local tl = GetPlayerBuffTimeLeft and GetPlayerBuffTimeLeft(slot)
+ if type(tl) == "number" and tl > 0 and tl > timeLeft then
+ timeLeft = tl
+ st.hasTime = true
+ end
+ end
+ i = i + 1
+ end
+
+ -- The fill level is NOT reachable through GetPlayerBuff. Measured in game 2026-07-28 while the
+ -- buffer was building (20 -> 44 -> 70 -> 91 -> 100):
+ --
+ -- 58130 "Earthen Bulwark" -- UnitBuff, spell id at return 3, carries the TIME
+ -- 58127 "Earthen Bulwark Durability" -- UnitDebuff, spell id at return 4, carries the STACKS
+ --
+ -- 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
+ 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
+ -- protecting us. Only reached when NO source reported a count.
+ if found and stacks == 0 then stacks = 1 end
+
+ st.active = found
+ st.stacks = stacks
+ st.timeLeft = timeLeft
+ -- When this reading was taken. GetPlayerBuffTimeLeft is only sampled on aura events, so the
+ -- display interpolates from here instead of holding the same number until the next event --
+ -- otherwise an 8 second bar visibly steps down in chunks.
+ st.timeLeftAt = GetTime()
+end
+
+-- Re-reads ONLY the remaining time, cheaply enough to run on a timer rather than waiting for an
+-- event. This exists because 1.12 does not reliably fire PLAYER_AURAS_CHANGED when an aura that is
+-- already up is merely REFRESHED: same aura, same stack count, new duration. The event-driven read
+-- then keeps its old timestamp and the interpolation counts down past a buffer that was in fact
+-- reset to 8 seconds -- which is exactly the reported symptom.
+--
+-- Deliberately not the full ScanAuras: the stack count needs the two 64-slot unit enumerations,
+-- while the duration only needs the player-buff walk, so the expensive part stays event-driven.
+function BF.ScanTime()
+ local st = BF.state
+ local db = BulwarkFrameDB
+ local poolId, timeId = db.poolAura, db.timeAura
+ local timeLeft = 0
+ local i = 0
+ while i < 32 do
+ local slot = GetPlayerBuff(i, "HELPFUL")
+ if not slot or slot < 0 then break end
+ local id = GetPlayerBuffID and GetPlayerBuffID(slot)
+ if id == poolId or id == timeId then
+ local tl = GetPlayerBuffTimeLeft and GetPlayerBuffTimeLeft(slot)
+ if type(tl) == "number" and tl > timeLeft then timeLeft = tl end
+ end
+ i = i + 1
+ end
+ -- Only accept a reading that moves the clock FORWARD (a refresh) or keeps it running down.
+ -- A zero here means the aura is gone; the caller's active flag comes from ScanAuras.
+ if timeLeft > 0 then
+ st.timeLeft = timeLeft
+ st.timeLeftAt = GetTime()
+ end
+ return timeLeft
+end
+
+-- ---- swing ------------------------------------------------------------------------------
+
+-- Refreshes weapon speed and the latency offset. Cheap, but not per-frame cheap: called on
+-- swing events and on the slow ticker, not from OnUpdate.
+function BF.RefreshSwingInputs()
+ local st = BF.state
+ local db = BulwarkFrameDB
+ local main = UnitAttackSpeed("player")
+ if type(main) == "number" and main > 0 then st.swingSpeed = main end
+ if db.swingUseLatency then
+ local _, _, lag = GetNetStats()
+ st.swingOffset = S.latencySeconds(lag, db.swingLatencyShare)
+ else
+ st.swingOffset = 0
+ end
+end
+
+-- ---- events -----------------------------------------------------------------------------
+
+local driver = CreateFrame("Frame", "BulwarkFrameDataDriver")
+driver:RegisterEvent("PLAYER_ENTERING_WORLD")
+driver:RegisterEvent("PLAYER_AURAS_CHANGED")
+driver:RegisterEvent("PLAYER_REGEN_DISABLED")
+driver:RegisterEvent("PLAYER_REGEN_ENABLED")
+driver:RegisterEvent("UNIT_INVENTORY_CHANGED")
+
+-- 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.
+driver:RegisterEvent("UNIT_CASTEVENT")
+
+driver:SetScript("OnEvent", function()
+ if event == "UNIT_CASTEVENT" then
+ -- arg1 = caster GUID, arg3 = type. "MAINHAND"/"OFFHAND" are auto-attacks, not spells.
+ -- Only the main hand drives the bar: an off-hand swing has its own clock, and drawing
+ -- both on one line would show a marker that jumps backwards.
+ if arg3 == "MAINHAND" and arg1 == BF.playerGuid then
+ BF.state.lastSwingAt = GetTime()
+ BF.RefreshSwingInputs()
+ end
+ return
+ end
+ if 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
+ BF.CapturePlayerGuid()
+ BF.RefreshSwingInputs()
+ end
+ if event == "PLAYER_AURAS_CHANGED" or event == "PLAYER_ENTERING_WORLD" then
+ BF.ScanAuras()
+ end
+ if BF.RequestUpdate then BF.RequestUpdate() end
+end)
+
+-- The player's own GUID, needed to tell our swings from everyone else's. SuperWoW appends it as a
+-- second return of UnitExists -- resolved the same way elsewhere, rather than via a second API call.
+-- Cached on world entry, not read per event.
+BF.playerGuid = nil
+
+function BF.CapturePlayerGuid()
+ local _, guid = UnitExists("player")
+ if guid then BF.playerGuid = guid end
+ return BF.playerGuid
+end
diff --git a/main.lua b/main.lua
new file mode 100644
index 0000000..59ed407
--- /dev/null
+++ b/main.lua
@@ -0,0 +1,51 @@
+-- BulwarkFrame - main.lua
+-- Load order tail: wires the slash commands and starts the UI once the player is in the world.
+-- WoW-API file (parse-checked only).
+
+BulwarkFrame = BulwarkFrame or {}
+local BF = BulwarkFrame
+
+local function msg(s) DEFAULT_CHAT_FRAME:AddMessage("|cff66bbffBulwarkFrame|r " .. s) end
+
+local loader = CreateFrame("Frame", "BulwarkFrameLoader")
+loader:RegisterEvent("PLAYER_LOGIN")
+loader:SetScript("OnEvent", function()
+ BF.ensureDefaults()
+ BF.InitUI()
+ BF.UpdateMinimapButton()
+ if not BF.envOk then
+ msg(BulwarkFrameEnv.MESSAGE)
+ end
+end)
+
+SLASH_BULWARKFRAME1 = "/bulwark"
+SLASH_BULWARKFRAME2 = "/bf"
+SlashCmdList["BULWARKFRAME"] = function(input)
+ local cmd = string.lower(input or "")
+ -- No string.match / no ':' string methods -- Lua 5.0.
+ if cmd == "" or cmd == "options" or cmd == "config" then
+ BF.ToggleOptions()
+ elseif cmd == "demo" then
+ local on = BF.ToggleDemo()
+ msg("demo " .. (on and "on" or "off"))
+ 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")
+ elseif cmd == "reset" then
+ BF.resetConfig()
+ BF.ApplyLayout()
+ BF.RequestUpdate()
+ BF.UpdateMinimapButton()
+ msg("settings reset to defaults")
+ elseif cmd == "show" then
+ BulwarkFrameDB.hideWhenInactive = false
+ BulwarkFrameDB.hideOutOfCombat = false
+ BF.RequestUpdate()
+ msg("frame pinned visible (hide-when-idle and combat-only turned off)")
+ else
+ msg("commands: options | demo | probe | lock | show | reset")
+ end
+end
diff --git a/minimap.lua b/minimap.lua
new file mode 100644
index 0000000..6bea3cf
--- /dev/null
+++ b/minimap.lua
@@ -0,0 +1,108 @@
+-- BulwarkFrame - minimap.lua
+-- Hand-rolled minimap button (no LibDBIcon on 1.12). Orbits the minimap at a saved angle,
+-- drag-repositions, left-click opens the options panel, right-click toggles demo mode.
+-- WoW-API file (parse-checked only).
+--
+-- pfUI-safe, the same way TotemBar's button is: parent = Minimap, the frame NAME contains
+-- "Minimap", strata HIGH with level 9 (MEDIUM ends up underneath pfUI). /bulwark options stays
+-- the guaranteed access path if a UI collects or hides the button anyway.
+
+BulwarkFrame = BulwarkFrame or {}
+local BF = BulwarkFrame
+
+local button = nil
+
+local function db() return BulwarkFrameDB end
+
+-- Position on the orbit for an angle in degrees. Plain trigonometry rather than a lib: the
+-- radius is the minimap's own half-width so it follows a resized minimap.
+local function PlaceButton(angleDeg)
+ if not button then return end
+ local radius = (Minimap:GetWidth() / 2) + 5
+ local rad = angleDeg * math.pi / 180
+ button:ClearAllPoints()
+ button:SetPoint("CENTER", Minimap, "CENTER", math.cos(rad) * radius, math.sin(rad) * radius)
+end
+
+local function BuildButton()
+ if button then return end
+
+ local btn = CreateFrame("Button", "BulwarkFrameMinimapButton", Minimap)
+ btn:SetWidth(33)
+ btn:SetHeight(33)
+ btn:SetFrameStrata("HIGH")
+ btn:SetFrameLevel(9)
+
+ -- No custom art yet: a shield-shaped stock icon says "defensive readout" well enough, and a
+ -- placeholder texture that fails to load would leave an invisible button.
+ local icon = btn:CreateTexture("BulwarkFrameMinimapIcon", "ARTWORK")
+ icon:SetTexture("Interface\\Icons\\Spell_Nature_StoneClawTotem")
+ icon:SetWidth(20)
+ icon:SetHeight(20)
+ icon:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
+
+ local border = btn:CreateTexture("BulwarkFrameMinimapBorder", "OVERLAY")
+ border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
+ -- Vanilla recipe (MiniMapTrackingButton): a 54px border anchored TOPLEFT(0,0) on a 33px
+ -- button. The ring sits off-centre WITHIN the texture, so this exact anchor is what makes it
+ -- concentric with the icon -- centring it pushes the ring off.
+ border:SetWidth(54)
+ border:SetHeight(54)
+ border:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
+
+ btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
+ btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+ btn:RegisterForDrag("LeftButton")
+
+ btn:SetScript("OnDragStart", function() this.isDragging = true end)
+ btn:SetScript("OnDragStop", function() this.isDragging = false end)
+ btn:SetScript("OnUpdate", function()
+ if not this.isDragging then return end
+ -- Follow the cursor around the orbit: angle from the minimap centre to the pointer.
+ local mx, my = Minimap:GetCenter()
+ local cx, cy = GetCursorPosition()
+ local scale = Minimap:GetEffectiveScale()
+ cx, cy = cx / scale, cy / scale
+ local angle = math.deg(math.atan2(cy - my, cx - mx))
+ db().minimapAngle = angle
+ PlaceButton(angle)
+ end)
+
+ btn:SetScript("OnClick", function()
+ if arg1 == "RightButton" then
+ -- Show/hide the readout, the same thing TotemBar's button does. This used to toggle
+ -- DEMO mode, which was a trap: the frame hides itself when no buffer is up, so the
+ -- obvious reaction is to right-click to make it appear -- and that silently replaced
+ -- the live readout with synthetic numbers.
+ local shown = BF.ToggleFrame()
+ DEFAULT_CHAT_FRAME:AddMessage("BulwarkFrame: " .. (shown and "shown" or "hidden"))
+ else
+ BF.ToggleOptions()
+ end
+ end)
+
+ btn:SetScript("OnEnter", function()
+ GameTooltip:SetOwner(this, "ANCHOR_LEFT")
+ GameTooltip:SetText("BulwarkFrame", 1, 1, 1)
+ GameTooltip:AddLine("Left click: options", 0.8, 0.8, 0.8)
+ GameTooltip:AddLine("Right click: show / hide the readout", 0.8, 0.8, 0.8)
+ GameTooltip:AddLine("Drag: move around the minimap", 0.8, 0.8, 0.8)
+ GameTooltip:Show()
+ end)
+ btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
+
+ button = btn
+ PlaceButton(db().minimapAngle or 215)
+end
+
+-- Creates the button on demand and honours the "show minimap button" setting.
+function BF.UpdateMinimapButton()
+ if not db().showMinimapButton then
+ if button then button:Hide() end
+ return
+ end
+ BuildButton()
+ PlaceButton(db().minimapAngle or 215)
+ button:Show()
+end
diff --git a/options.lua b/options.lua
new file mode 100644
index 0000000..3911484
--- /dev/null
+++ b/options.lua
@@ -0,0 +1,489 @@
+-- BulwarkFrame - options.lua
+-- Standalone options panel (no Blizzard InterfaceOptions integration on 1.12). Built lazily on
+-- first open, repopulated from BulwarkFrameDB on show. Opened by the minimap button's left click
+-- and by /bulwark options. WoW-API file (parse-checked only).
+--
+-- Widget wiring uses the 1.12 templates: UICheckButtonTemplate (GetChecked() -> 1/nil) and
+-- OptionsSliderTemplate (SetMinMaxValues BEFORE SetValue, else it clamps to 0). Colour swatches
+-- drive ColorPickerFrame the way pfUI does -- func/cancelFunc plus ShowUIPanel.
+
+BulwarkFrame = BulwarkFrame or {}
+local BF = BulwarkFrame
+
+local PANEL_W = 430
+-- Content width of one column: panel minus both outer margins minus the gutter, halved.
+local COL_W = (PANEL_W - 48 - 12) / 2
+local panel = nil
+local cbIndex, slIndex, swIndex = 0, 0, 0
+local refreshers = {}
+
+local function db() return BulwarkFrameDB end
+
+-- 1.12: a FontString created WITHOUT a font object has no font, and FontString:SetText() then
+-- throws "Font not set" -- which aborts the rest of the panel build. Every CreateFontString here
+-- therefore passes an inherit template, and this only swaps in the pfUI face afterwards.
+local function ApplyFont(fs, size)
+ if not fs then return end
+ if pfUI and pfUI.font_default then
+ fs:SetFont(pfUI.font_default, size or 12)
+ else
+ fs:SetFontObject(GameFontNormalSmall)
+ end
+end
+
+-- Extends a widget's hover area across the whole row, so the tooltip also appears over its label
+-- rather than only over the 22px box itself. 1.12 FontStrings take no mouse events, so this is an
+-- invisible button laid over the row; clicking it forwards to the real widget, which also makes
+-- the label clickable -- the behaviour anyone expects from a labelled checkbox.
+local function AddRowHover(widget, tip, onClick)
+ if not widget then return end
+ local hover = CreateFrame("Button", nil, widget:GetParent())
+ hover:SetPoint("TOPLEFT", widget, "TOPLEFT", 0, 0)
+ hover:SetWidth(COL_W)
+ hover:SetHeight(widget:GetHeight() or 22)
+ hover:SetFrameLevel((widget:GetFrameLevel() or 1) + 1)
+ hover:SetScript("OnClick", onClick)
+ return hover
+end
+
+local function AddTooltip(widget, text)
+ if not widget or not text then return end
+ widget.bfTip = text
+ local oldEnter = widget:GetScript("OnEnter")
+ local oldLeave = widget:GetScript("OnLeave")
+ widget:SetScript("OnEnter", function()
+ if oldEnter then oldEnter() end
+ GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
+ GameTooltip:SetText(this.bfTip, 1, 1, 1, 1, 1)
+ GameTooltip:Show()
+ end)
+ widget:SetScript("OnLeave", function()
+ if oldLeave then oldLeave() end
+ GameTooltip:Hide()
+ end)
+end
+
+-- Any change to a display option has to reach the frame immediately -- an options panel whose
+-- effect only shows after /reload trains you to distrust it.
+local function Applied()
+ BF.ApplyLayout()
+ BF.RequestUpdate()
+end
+
+-- ---- widget factories -------------------------------------------------------------------
+
+local function CreateCheckbox(parent, label, tip, getter, setter)
+ cbIndex = cbIndex + 1
+ local name = "BulwarkFrameOptCheck" .. cbIndex
+ local cb = CreateFrame("CheckButton", name, parent, "UICheckButtonTemplate")
+ cb:SetWidth(22)
+ cb:SetHeight(22)
+ local lbl = getglobal(name .. "Text")
+ if lbl then lbl:SetText(label); ApplyFont(lbl) end
+ cb.bfSet = setter
+ cb:SetScript("OnClick", function()
+ this.bfSet(this:GetChecked() == 1)
+ Applied()
+ end)
+ AddTooltip(cb, tip)
+ AddTooltip(AddRowHover(cb, tip, function()
+ cb:SetChecked(not (cb:GetChecked() == 1) and 1 or nil)
+ cb.bfSet(cb:GetChecked() == 1)
+ Applied()
+ end), tip)
+ if pfUI and pfUI.api and pfUI.api.SkinCheckbox then pfUI.api.SkinCheckbox(cb) end
+ table.insert(refreshers, function() cb:SetChecked(getter() and 1 or nil) end)
+ return cb
+end
+
+local function CreateSlider(parent, label, minVal, maxVal, step, fmt, tip, getter, setter)
+ slIndex = slIndex + 1
+ local name = "BulwarkFrameOptSlider" .. slIndex
+ local sl = CreateFrame("Slider", name, parent, "OptionsSliderTemplate")
+ sl:SetWidth(COL_W)
+ sl:SetHeight(16)
+ sl:SetMinMaxValues(minVal, maxVal)
+ sl:SetValueStep(step)
+ local low, high, txt = getglobal(name .. "Low"), getglobal(name .. "High"), getglobal(name .. "Text")
+ if low then low:SetText(tostring(minVal)); ApplyFont(low, 10) end
+ if high then high:SetText(tostring(maxVal)); ApplyFont(high, 10) end
+ if txt then
+ txt:ClearAllPoints()
+ txt:SetPoint("BOTTOMLEFT", sl, "TOPLEFT", 0, 2)
+ txt:SetJustifyH("LEFT")
+ ApplyFont(txt, 11)
+ end
+ sl.bfFmt = fmt
+ sl.bfText = txt
+ sl.bfSet = setter
+ sl:SetScript("OnValueChanged", function()
+ local v = this:GetValue()
+ if this.bfText then this.bfText:SetText(string.format(this.bfFmt, v)) end
+ this.bfSet(v)
+ Applied()
+ end)
+ AddTooltip(sl, tip)
+ if pfUI and pfUI.api and pfUI.api.SkinSlider then pfUI.api.SkinSlider(sl) end
+ table.insert(refreshers, function()
+ local v = getter()
+ sl:SetValue(v)
+ if txt then txt:SetText(string.format(fmt, v)) end
+ end)
+ return sl
+end
+
+-- A colour swatch. `key` names the config field holding {r,g,b}.
+local function CreateSwatch(parent, label, key, tip)
+ swIndex = swIndex + 1
+ local holder = CreateFrame("Button", "BulwarkFrameOptSwatch" .. swIndex, parent)
+ holder:SetWidth(18)
+ holder:SetHeight(18)
+
+ local tex = holder:CreateTexture("BulwarkFrameOptSwatchTex" .. swIndex, "ARTWORK")
+ tex:SetTexture("Interface\\Buttons\\WHITE8X8")
+ tex:SetPoint("TOPLEFT", holder, "TOPLEFT", 1, -1)
+ tex:SetPoint("BOTTOMRIGHT", holder, "BOTTOMRIGHT", -1, 1)
+
+ local border = holder:CreateTexture("BulwarkFrameOptSwatchBorder" .. swIndex, "BACKGROUND")
+ border:SetTexture("Interface\\Buttons\\WHITE8X8")
+ border:SetVertexColor(0, 0, 0, 1)
+ border:SetAllPoints(holder)
+
+ local lbl = holder:CreateFontString("BulwarkFrameOptSwatchLabel" .. swIndex, "OVERLAY", "GameFontNormalSmall")
+ lbl:SetPoint("LEFT", holder, "RIGHT", 6, 0)
+ lbl:SetText(label)
+ ApplyFont(lbl)
+
+ holder:SetScript("OnClick", function()
+ local c = db()[key] or { 1, 1, 1 }
+ local pr, pg, pb = c[1], c[2], c[3]
+ -- Captured for cancel: ColorPickerFrame keeps calling func while the user drags, so the
+ -- config is written live and cancel has to put the old value back explicitly.
+ ColorPickerFrame.func = function()
+ local r, g, b = ColorPickerFrame:GetColorRGB()
+ local cc = db()[key]
+ cc[1], cc[2], cc[3] = r, g, b
+ tex:SetVertexColor(r, g, b)
+ Applied()
+ end
+ ColorPickerFrame.cancelFunc = function()
+ local cc = db()[key]
+ cc[1], cc[2], cc[3] = pr, pg, pb
+ tex:SetVertexColor(pr, pg, pb)
+ Applied()
+ end
+ ColorPickerFrame.opacityFunc = nil
+ ColorPickerFrame.hasOpacity = nil
+ ColorPickerFrame:SetColorRGB(pr, pg, pb)
+ ColorPickerFrame:SetFrameStrata("DIALOG")
+ ShowUIPanel(ColorPickerFrame)
+ end)
+ AddTooltip(holder, tip)
+ AddTooltip(AddRowHover(holder, tip, function() holder:Click() end), tip)
+
+ table.insert(refreshers, function()
+ local c = db()[key] or { 1, 1, 1 }
+ tex:SetVertexColor(c[1], c[2], c[3])
+ end)
+ return holder
+end
+
+local function CreateButton(parent, label, tip, onClick)
+ local b = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
+ b:SetWidth(110)
+ b:SetHeight(20)
+ b:SetText(label)
+ b:SetScript("OnClick", onClick)
+ AddTooltip(b, tip)
+ if pfUI and pfUI.api and pfUI.api.SkinButton then pfUI.api.SkinButton(b) end
+ return b
+end
+
+local function CreateHeading(parent, text, x, y)
+ local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ fs:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
+ fs:SetText(text)
+ ApplyFont(fs, 13)
+ fs:SetTextColor(1, 0.82, 0)
+ return fs
+end
+
+-- ---- panel ------------------------------------------------------------------------------
+
+local function BuildPanel()
+ if panel then return end
+
+ -- Chrome copied from TotemBar's options panel so the two addons read as one family:
+ -- gold title top-left, close button top-right, clamped to screen, ESC closes, version footer.
+ -- The one thing NOT copied is its hardcoded height -- that file carries a comment warning that
+ -- every new row has to be added to the constant by hand or the last widget lands on the footer,
+ -- which is exactly the bug reported here. The height below is computed from the layout cursor.
+ panel = CreateFrame("Frame", "BulwarkFrameOptions", UIParent)
+ panel:SetWidth(PANEL_W)
+ panel:SetHeight(200) -- provisional; recomputed at the end of this function
+ panel:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+ panel:SetFrameStrata("DIALOG")
+ panel:SetMovable(true)
+ panel:EnableMouse(true)
+ panel:RegisterForDrag("LeftButton")
+ panel:SetScript("OnDragStart", function() this:StartMoving() end)
+ panel:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
+ panel:SetClampedToScreen(true)
+ -- Own frame art, generated by tools/gen_panel_textures.js with the same parameters TotemBar
+ -- uses, so the two panels read as one product. Deliberately not pfUI's generic backdrop and
+ -- not the Blizzard dialog frame: this is the addon's own chrome, and it ships with the addon.
+ panel:SetBackdrop({
+ bgFile = "Interface\\AddOns\\BulwarkFrame\\textures\\panel_bg",
+ edgeFile = "Interface\\AddOns\\BulwarkFrame\\textures\\panel_border",
+ tile = true, tileSize = 128, edgeSize = 16,
+ insets = { left = 5, right = 5, top = 5, bottom = 5 },
+ })
+ panel:SetBackdropColor(1, 1, 1, 0.97)
+
+ local title = panel:CreateFontString("BulwarkFrameOptionsTitle", "OVERLAY", "GameFontNormal")
+ title:SetPoint("TOPLEFT", panel, "TOPLEFT", 16, -16)
+ title:SetText("BulwarkFrame Options")
+ ApplyFont(title, 14)
+ title:SetTextColor(0.85, 0.66, 0.31)
+
+ local close = CreateFrame("Button", "BulwarkFrameOptionsClose", panel, "UIPanelCloseButton")
+ close:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -8, -8)
+ if pfUI and pfUI.api and pfUI.api.SkinCloseButton then pfUI.api.SkinCloseButton(close, panel) end
+
+ -- Layout cursors, one per column. Step sizes match TotemBar's: a checkbox row is 28, a slider
+ -- row 44 (it carries its value label above the track), a button row 28, a group heading 22.
+ local L, R = 24, PANEL_W / 2 + 6
+ local yL, yR = -44, -44
+ local lowest = -44
+
+ local function placeL(w, dy)
+ w:SetPoint("TOPLEFT", panel, "TOPLEFT", L, yL)
+ yL = yL - dy
+ if yL < lowest then lowest = yL end
+ end
+ local function placeR(w, dy)
+ w:SetPoint("TOPLEFT", panel, "TOPLEFT", R, yR)
+ yR = yR - dy
+ if yR < lowest then lowest = yR end
+ end
+ -- A slider carries its value label ABOVE the track (OptionsSliderTemplate's $parentText,
+ -- re-anchored in CreateSlider), so the cursor has to leave room for it before placing the
+ -- track -- otherwise the label overlaps whatever sits above, which is exactly how the
+ -- "Absorb model" heading ended up written through "Elemental Weapons rank". TotemBar's 44px
+ -- slider row is this same allowance, just folded into one number.
+ -- Measured, not guessed: with a 14px allowance the value label's top edge sat at rel-y 408.7
+ -- while the heading above ended at 414.7 -- a 6px overlap. 24 puts the label 4px clear.
+ local SLIDER_LABEL = 24
+ local function sliderL(w) yL = yL - SLIDER_LABEL; placeL(w, 26) end
+ local function sliderR(w) yR = yR - SLIDER_LABEL; placeR(w, 26) end
+ local function headL(text) CreateHeading(panel, text, L, yL); yL = yL - 22; if yL < lowest then lowest = yL end end
+ local function headR(text) CreateHeading(panel, text, R, yR); yR = yR - 22; if yR < lowest then lowest = yR end end
+
+ -- left column: frame ------------------------------------------------------------------
+ headL("Frame")
+
+ local lock = CreateCheckbox(panel, "Lock position", "Stops the frame from being dragged.",
+ function() return db().locked end,
+ function(v) db().locked = v end)
+ placeL(lock, 28)
+
+ local mm = CreateCheckbox(panel, "Minimap button", "Show the minimap button.",
+ function() return db().showMinimapButton end,
+ function(v) db().showMinimapButton = v; if BF.UpdateMinimapButton then BF.UpdateMinimapButton() end end)
+ placeL(mm, 28)
+
+ local hideInactive = CreateCheckbox(panel, "Hide when idle",
+ "Hide the frame when the buffer is down and you are out of combat.",
+ function() return db().hideWhenInactive end,
+ function(v) db().hideWhenInactive = v end)
+ placeL(hideInactive, 28)
+
+ local hideOOC = CreateCheckbox(panel, "Combat only", "Show the frame only while in combat.",
+ function() return db().hideOutOfCombat end,
+ function(v) db().hideOutOfCombat = v end)
+ placeL(hideOOC, 40)
+
+ local scale = CreateSlider(panel, "Scale", 0.5, 2.0, 0.05, "Scale: %.2f",
+ "Overall size of the frame.",
+ function() return db().scale end,
+ function(v) db().scale = v end)
+ sliderL(scale)
+
+ local width = CreateSlider(panel, "Width", 80, 400, 5, "Width: %d px",
+ "Frame width in pixels.",
+ function() return db().width end,
+ function(v) db().width = math.floor(v) end)
+ sliderL(width)
+
+ local barH = CreateSlider(panel, "Threshold bar height", 6, 30, 1, "Threshold bar: %d px",
+ "Height of the hit-threshold bar.",
+ function() return db().barHeight end,
+ function(v) db().barHeight = math.floor(v) end)
+ sliderL(barH)
+
+ local timeH = CreateSlider(panel, "Expiry bar height", 3, 24, 1, "Expiry bar: %d px",
+ "Height of the expiry bar. Deliberately flatter than the threshold bar.",
+ function() return db().timeBarHeight end,
+ function(v) db().timeBarHeight = math.floor(v) end)
+ sliderL(timeH)
+
+ local swingH = CreateSlider(panel, "Swing line height", 1, 8, 1, "Swing line: %d px",
+ "Thickness of the swing line.",
+ function() return db().swingHeight end,
+ function(v) db().swingHeight = math.floor(v) end)
+ sliderL(swingH)
+
+ -- right column: elements, colours, model ----------------------------------------------
+ local ry = -46
+ headR("Elements")
+
+ local e1 = CreateCheckbox(panel, "Hit threshold", "Show the threshold bar.",
+ function() return db().showThreshold end,
+ function(v) db().showThreshold = v end)
+ placeR(e1, 28)
+
+ local e2 = CreateCheckbox(panel, "Expiry", "Show the expiry bar.",
+ function() return db().showExpiry end,
+ function(v) db().showExpiry = v end)
+ placeR(e2, 28)
+
+ local e3 = CreateCheckbox(panel, "Swing timer", "Show the swing line.",
+ function() return db().showSwing end,
+ function(v) db().showSwing = v end)
+ placeR(e3, 28)
+
+ local e3b = CreateCheckbox(panel, "Swing on expiry bar",
+ "Let the swing marker ride the expiry bar like pfUI's mana tick, instead of taking its own line.",
+ function() return db().swingOnExpiryBar end,
+ function(v) db().swingOnExpiryBar = v end)
+ placeR(e3b, 28)
+
+ -- Which question the big number answers. A cycle button rather than two checkboxes: the two
+ -- readings are exclusive, and showing both at once would invite reading one as the other.
+ local modeBtn = CreateButton(panel, "Show: max hit",
+ "What the number means. 'max hit' = how large a single hit may be before the buffer is "
+ .. "drained. 'max shield' = how much damage it still absorbs. They differ by the absorb "
+ .. "rate, so this is not a formatting choice.",
+ function()
+ local dd = db()
+ dd.displayMode = (dd.displayMode == "hit") and "shield" or "hit"
+ this:SetText(dd.displayMode == "shield" and "Show: max shield" or "Show: max hit")
+ Applied()
+ end)
+ placeR(modeBtn, 28)
+ table.insert(refreshers, function()
+ modeBtn:SetText(db().displayMode == "shield" and "Show: max shield" or "Show: max hit")
+ end)
+
+ local e4 = CreateCheckbox(panel, "Threshold text", "Show the current / maximum numbers.",
+ function() return db().showThresholdText end,
+ function(v) db().showThresholdText = v end)
+ placeR(e4, 28)
+
+ local e5 = CreateCheckbox(panel, "Expiry text", "Show the remaining seconds as a number.",
+ function() return db().showExpiryText end,
+ function(v) db().showExpiryText = v end)
+ placeR(e5, 40)
+
+ headR("Colours")
+
+ local sw1 = CreateSwatch(panel, "Low", "colorRed", "Colour below the low threshold.")
+ placeR(sw1, 24)
+ local sw2 = CreateSwatch(panel, "Medium", "colorYellow", "Colour between the two thresholds.")
+ placeR(sw2, 24)
+ local sw3 = CreateSwatch(panel, "High", "colorGreen", "Colour above the high threshold.")
+ placeR(sw3, 24)
+ local sw4 = CreateSwatch(panel, "Swing marker", "colorSwing", "Colour of the swing marker.")
+ placeR(sw4, 34)
+
+ headR("Absorb model")
+
+ local rank = CreateSlider(panel, "Talent rank", 0, 3, 1, "Elemental Weapons rank: %d",
+ "Rank of Elemental Weapons: 5 / 10 / 15 % absorbed. Rank 0 means the talent is not learned.",
+ function() return db().talentRank end,
+ function(v) db().talentRank = math.floor(v) end)
+ sliderR(rank)
+
+ -- Set bonus: the T2.5 "+3 % absorption" wording is ambiguous (15 -> 18, or 15 x 1.03) and
+ -- unmeasured. Rather than pick one silently, both readings are selectable and "none" is the
+ -- default -- see README.
+ local setBtn = CreateButton(panel, "Set bonus: none",
+ "T2.5 set bonus reading. 'add' means 15 -> 18 %, 'mult' means 15 x 1.03. Not yet measured.",
+ function()
+ local d = db()
+ if d.setBonusMode == "none" then d.setBonusMode = "add"
+ elseif d.setBonusMode == "add" then d.setBonusMode = "mult"
+ else d.setBonusMode = "none" end
+ this:SetText("Set bonus: " .. d.setBonusMode)
+ Applied()
+ end)
+ placeR(setBtn, 28)
+ table.insert(refreshers, function() setBtn:SetText("Set bonus: " .. (db().setBonusMode or "none")) end)
+
+ local latency = CreateCheckbox(panel, "Latency-adjust swing",
+ "Shift the swing marker by your round-trip time, so it shows when to press rather than "
+ .. "when the server swung.",
+ function() return db().swingUseLatency end,
+ function(v) db().swingUseLatency = v; BF.RefreshSwingInputs() end)
+ placeR(latency, 28)
+
+ -- footer -------------------------------------------------------------------------------
+ local demoBtn = CreateButton(panel, "Demo", "Run the display off a synthetic cycle so colours "
+ .. "and sizes can be judged without a fight.", function()
+ local on = BF.ToggleDemo()
+ this:SetText(on and "Demo: on" or "Demo")
+ end)
+ demoBtn:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 24, 20)
+
+ local probeBtn = CreateButton(panel, "Probe", "Dump the live aura/weapon data we still need "
+ .. "(also /bulwark probe).", function() BF.RunProbe() end)
+ probeBtn:SetPoint("LEFT", demoBtn, "RIGHT", 8, 0)
+
+ local resetBtn = CreateButton(panel, "Reset", "Restore every setting to its default.", function()
+ BF.resetConfig()
+ Applied()
+ BF.RefreshOptions()
+ if BF.UpdateMinimapButton then BF.UpdateMinimapButton() end
+ end)
+ resetBtn:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -24, 20)
+
+ -- Height from the layout cursor, not from a hand-maintained constant. `lowest` tracks the
+ -- deepest point either column reached, and the footer row is added below it -- so a new option
+ -- can never again end up outside the frame (which is what happened: the last checkbox sat 14px
+ -- past the bottom edge after two options were added to a fixed 470).
+ local FOOTER = 56
+ panel:SetHeight(-lowest + FOOTER)
+
+ -- Footer buttons, full content width split three ways, mirroring TotemBar's block layout.
+ local btnW = (PANEL_W - 48 - 16) / 3
+ demoBtn:SetWidth(btnW)
+ probeBtn:SetWidth(btnW)
+ resetBtn:SetWidth(btnW)
+
+ local ver = (GetAddOnMetadata and GetAddOnMetadata("BulwarkFrame", "Version")) or "0.2.0"
+ local verFS = panel:CreateFontString("BulwarkFrameOptionsVersion", "OVERLAY", "GameFontNormalSmall")
+ ApplyFont(verFS, 10)
+ verFS:SetPoint("BOTTOM", panel, "BOTTOM", 0, 6)
+ verFS:SetText("v" .. ver)
+ verFS:SetTextColor(0.6, 0.6, 0.6)
+
+ -- ESC closes it, like every other panel in this UI.
+ tinsert(UISpecialFrames, "BulwarkFrameOptions")
+
+ panel:Hide()
+end
+
+function BF.RefreshOptions()
+ local i
+ for i = 1, table.getn(refreshers) do refreshers[i]() end
+end
+
+function BF.ToggleOptions()
+ BuildPanel()
+ if panel:IsShown() then
+ panel:Hide()
+ else
+ BF.RefreshOptions()
+ panel:Show()
+ end
+end
diff --git a/probe.lua b/probe.lua
new file mode 100644
index 0000000..588b627
--- /dev/null
+++ b/probe.lua
@@ -0,0 +1,103 @@
+-- BulwarkFrame - probe.lua
+-- `/bulwark probe` -- one read-only sweep of everything the display still needs answered, dumped
+-- both to chat and (with SuperWoW) to 9#dMTOby@z=^>c*QZtYVj=YNIlIo_=WW|}A&52Q_{X&$?cX?4<5wUF=ESGhoN4scHiYlK
z*g7X`&D?ob$k_1icl++3cMlM;?R4TGw0NgY^YWyRngHJHCl26SQwNrI5OXCkBFem*
zCe`+h*-Ve#bmfH%EO`9!D(Bk2UUpRT7!M>0bDRK{1rz{uM#WhvUR7VX5<#a8l>+8p
zT-{}8axf^E{8Z*7+b2>O=s89uH3eFP-PV|;*C~BJi4eut6wzFHN_{dIKxU-ttxNo<
zvbU)JO;}Z>+8lA@==FT_+q?UrnP^@MLzCotbwk9czp}|RjdvQFWG?P@GWry#ES9)r
z8w~3(cH~vSBrA8PbcPg)BO5kjFk_}_-keA|d^hE}sliy+`E*O_b!de)v$DvL*N^Bg
zDY~wlVf^bRReYZ0UJV9K*$OdM((2*-^Gkd#U
z7$KvngzO%Iyf|r)PoDU59gOU2no7D`GV_Dxh;I8}-EZ>EkFtJ;z7XUGP&G7vf2aLX
zPHD94@sjt8QKjfK(m!RBv5#3V0=7GjdYpYf(BKA}UTO5?0P?Lcs(~gv#NETLVquIz
z5GkOkml!(|?W^VyyU+nd`>qt}7wY8^k3RXkWqdUgsv`vWW@`EsV8pewD2W|{dI{e4
zY8to3Z(lXBkdNqRj+%m
zV2T1PDTDfP&kxvWm)8pKjVb#92~roKD)XY#Y_#V14wH7*-?eK=cSQEaL%s9K{_Q)+
zOuSpb8o+oeowvUu4<(K^O0=st2(!AiG1P14UMgf!B(L5uoYPe=fhvZ~E=MLOFNhrM
z)(Qvj>@>HuHWu-C+BR+y%yRy~JYhi8tD&~A*-e_k%R+tmV}-B)Y01lRt##QA_i|9Q
zs9A^w!C@+<{8}&HNxyi??<8i!xKGNCGa2~Xd*QUt^wVAZFtu!P_Zs@g=UEyQ3HU@P
zBKeTDQE0Pc!RX1&%d56vco0VH+LqD=r;w0uNjYQSy
MD*l
zeSt#*z8@m`qpGOt<2?He)Y^Ghvhyzk9N0_Fu+*%qcI>RhyRvaE94W-R1flpK
z-3o6xw&{GO^q-?HZzN4!cE-+*zF_;?6AECDe)7-+
5>Wu4dd`X5iK#)AI}S#w@khE~HUE8GQcw5ZM3C>SEWYeF+)SO6
zcwOjiFhcru
_mKuO@-!eZhliWdwUo|-Y+^By8)-O$l!w<&%P@5?{>1VwJMHvZx70QL-H
zH8l2|4-?<>Pnixbsk@s3dk<91rMEq&q)CSyvet(mrZ
z?>56kP`M#&t7rO)wwG@Tci;?vZQ~!$OmD}7QlrK9_jt;ujAqBkfrr`$!J?oGc%m4(=%Ca%LNZs?mX@%en0Rk5DzMH$qAopZ9h5X
zRo-MFJLeuX+AQ|QP2RhF7klQ+liI$B3I&Qal&nsq`U4>(YeNbx(;IusKlshPiI^81
z4_Y2HQ+;n9KX-mM-*!nJk