commit 7cd508e2a3d50d5e61de84bbf76a8efe37b87e14 Author: ShempError <689+shemperror@noreply.octowow.st> Date: Wed Aug 5 15:54:49 2026 +0200 BulwarkFrame - compact Earthen Bulwark readout for TurtleWoW diff --git a/BulwarkFrame.toc b/BulwarkFrame.toc new file mode 100644 index 0000000..f64c76b --- /dev/null +++ b/BulwarkFrame.toc @@ -0,0 +1,17 @@ +## Interface: 11200 +## Title: BulwarkFrame +## Notes: Compact real-time readout for the shaman Earthen Bulwark. Requires SuperWoW. +## Author: ShempError +## Version: 0.2.0-dev +## SavedVariables: BulwarkFrameDB + +core\env.lua +core\calc.lua +core\swing.lua +core\config.lua +data.lua +ui.lua +options.lua +minimap.lua +probe.lua +main.lua diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e6b5df --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# BulwarkFrame + +A compact real-time readout for the shaman **Earthen Bulwark** on **TurtleWoW / WoW 1.12.1 +(Vanilla)**. + +It answers one question at a glance: *how big would a single incoming hit have to be to use up my +buffer right now?* — alongside how long the buffer still lasts and where the auto-attack swing +stands. Three thin bars, nothing else. No dependencies beyond SuperWoW; adopts the +[pfUI](https://github.com/shagu/pfUI) look automatically when present. + +The readout: hit threshold, expiry bar with the swing marker riding on it + +## Screenshots + +| The readout | Options panel | +| :---: | :---: | +| Max hit: how large a single hit may be
Max shield: how much damage the buffer still absorbs | 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 \imports\bulwark_probe.txt so it can be read from +-- the dev side without anyone transcribing numbers. WoW-API file (parse-checked only). +-- +-- It answers, in one go: +-- 1. which aura carries the remaining DURATION -- 58130 or 58127 (the bar depends on it); +-- 2. the real aura ids of the buffs we may want to show. A CAST id is not a BUFF id: +-- Stormstrike is cast as 17364 but sits on the player as 52412; +-- 3. weapon speeds and the Elemental Weapons rank (-> absorb rate). +-- +-- Best run WITH THE BUFFS UP, in combat, Rockbiter active. A buff that is not up simply does not +-- appear -- that is a valid answer too, just a less complete one. +-- +-- ExportFile GOTCHA (in-game verified): it appends ".txt" itself, so the name is passed WITHOUT +-- an extension -- "bulwark_probe" becomes bulwark_probe.txt, "bulwark_probe.txt" would become +-- bulwark_probe.txt.txt. + +BulwarkFrame = BulwarkFrame or {} +local BF = BulwarkFrame + +local function out(lines, s) + table.insert(lines, s) + DEFAULT_CHAT_FRAME:AddMessage("|cff66bbffBF|r " .. s) +end + +function BF.RunProbe() + local lines = {} + out(lines, "probe " .. date("%Y-%m-%d %H:%M:%S")) + out(lines, "superwow=" .. tostring(SUPERWOW_VERSION) .. " nampower=" .. tostring(NAMPOWER_VERSION)) + + -- 1) every player buff with id, stacks and remaining time + local i = 0 + local shown = 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) + local tl = GetPlayerBuffTimeLeft and GetPlayerBuffTimeLeft(slot) + local n = GetPlayerBuffApplications and GetPlayerBuffApplications(slot) + local nm = "?" + if SpellInfo and type(id) == "number" then + local ok, s = pcall(SpellInfo, id) + if ok and s then nm = s end + end + out(lines, "buff id=" .. tostring(id) .. " name=" .. tostring(nm) + .. " stacks=" .. tostring(n) .. " timeLeft=" .. tostring(tl)) + shown = shown + 1 + i = i + 1 + end + if shown == 0 then out(lines, "buff NONE -- no player buffs found") end + + -- 2) the two candidate auras, called out explicitly so the answer is not buried in the list + local poolId, timeId = BulwarkFrameDB.poolAura, BulwarkFrameDB.timeAura + out(lines, "configured poolAura=" .. tostring(poolId) .. " timeAura=" .. tostring(timeId)) + out(lines, "state hasPool=" .. tostring(BF.state.hasPool) .. " hasTime=" .. tostring(BF.state.hasTime) + .. " stacks=" .. tostring(BF.state.stacks) .. " timeLeft=" .. tostring(BF.state.timeLeft)) + + -- 3) API availability -- a missing function should be data, not a silent nil later + local names = { "GetPlayerBuff", "GetPlayerBuffID", "GetPlayerBuffTimeLeft", + "GetPlayerBuffApplications", "UnitAttackSpeed", "SpellInfo", "ExportFile", + "GetNetStats", "UnitExists" } + local j + for j = 1, table.getn(names) do + out(lines, "api " .. names[j] .. "=" .. type(getglobal(names[j]))) + end + + -- 4) weapon speeds, health, latency + local main, off = nil, nil + pcall(function() main, off = UnitAttackSpeed("player") end) + out(lines, "attackSpeed main=" .. tostring(main) .. " off=" .. tostring(off)) + out(lines, "maxHP=" .. tostring(UnitHealthMax("player")) + .. " -> 1 stack = " .. tostring(BulwarkFrameCalc.stackPoints(UnitHealthMax("player"))) + .. " points, full pool = " .. tostring(BulwarkFrameCalc.maxPool(UnitHealthMax("player")))) + local _, _, lag = GetNetStats() + out(lines, "latency_ms=" .. tostring(lag)) + + -- 5) talent rank of Elemental Weapons, read rather than assumed + local rank = nil + if GetTalentInfo then + local tab, idx + for tab = 1, 3 do + for idx = 1, 20 do + local nm, _, _, _, cur = GetTalentInfo(tab, idx) + if nm == "Elemental Weapons" then rank = cur end + end + end + end + out(lines, "talent ElementalWeapons rank=" .. tostring(rank) + .. " configured=" .. tostring(BulwarkFrameDB.talentRank)) + + -- 6) to disk, so the dev side can read it directly instead of transcribing chat + if type(ExportFile) == "function" then + local blob = table.concat(lines, "\n") + local ok = pcall(ExportFile, "bulwark_probe", blob) + if ok then + DEFAULT_CHAT_FRAME:AddMessage("|cff66bbffBF|r written to imports\\bulwark_probe.txt") + else + DEFAULT_CHAT_FRAME:AddMessage("|cff66bbffBF|r ExportFile failed -- chat output only") + end + end + return lines +end diff --git a/screenshots/options.png b/screenshots/options.png new file mode 100644 index 0000000..da94254 Binary files /dev/null and b/screenshots/options.png differ diff --git a/screenshots/readout-hit.png b/screenshots/readout-hit.png new file mode 100644 index 0000000..7005073 Binary files /dev/null and b/screenshots/readout-hit.png differ diff --git a/screenshots/readout-shield.png b/screenshots/readout-shield.png new file mode 100644 index 0000000..e745f7a Binary files /dev/null and b/screenshots/readout-shield.png differ diff --git a/textures/panel_bg.tga b/textures/panel_bg.tga new file mode 100644 index 0000000..4ad5562 Binary files /dev/null and b/textures/panel_bg.tga differ diff --git a/textures/panel_border.tga b/textures/panel_border.tga new file mode 100644 index 0000000..0b292de Binary files /dev/null and b/textures/panel_border.tga differ diff --git a/ui.lua b/ui.lua new file mode 100644 index 0000000..147d446 --- /dev/null +++ b/ui.lua @@ -0,0 +1,432 @@ +-- BulwarkFrame - ui.lua +-- The frame: hit-threshold bar, expiry bar, swing line. WoW-API file (parse-checked only). +-- +-- Three rules this file follows, all from hard-won ecosystem experience: +-- * every frame gets a name with the addon prefix, so pfDebug's profiler can attribute its +-- cost instead of lumping it under ; +-- * 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. + +BulwarkFrame = BulwarkFrame or {} +local BF = BulwarkFrame +local C = BulwarkFrameCalc +local S = BulwarkFrameSwing + +local frame, poolBar, poolText, timeBar, timeText, speedText, swingLine, swingMark, hintText +local demoTag +-- Which widget the swing marker rides on: its own line, or the expiry bar (pfUI mana-tick style). +local swingHost +local lastPoolText, lastTimeText, lastSpeedText = nil, nil, nil +local demo = nil -- demo mode state, nil when off +local UPDATE_INTERVAL = 0.05 +local sinceUpdate = 0 + +-- ---- helpers ---------------------------------------------------------------------------- + +local function db() return BulwarkFrameDB end + +-- Colour lookup that honours the user's configured colours. calc.lua decides WHICH bucket +-- (red/yellow/green); the RGB values come from the saved config, not from calc's constants. +local function bucketRGB(name) + local d = db() + local c = (name == "green" and d.colorGreen) or (name == "yellow" and d.colorYellow) or d.colorRed + if type(c) ~= "table" then return 0.7, 0.7, 0.7 end + return c[1] or 0.7, c[2] or 0.7, c[3] or 0.7 +end + +local function applyBackdrop(f, d) + local bg = d.colorBg or { 0, 0, 0, 0.8 } + f:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + f:SetBackdropColor(bg[1] or 0, bg[2] or 0, bg[3] or 0, bg[4] or 0.8) + f:SetBackdropBorderColor(0, 0, 0, 1) +end + +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 10, "OUTLINE") + else + fs:SetFont("Fonts\\FRIZQT__.TTF", size or 10, "OUTLINE") + end + -- Outline plus a hard shadow: the number sits ON the bar, so half of it is over the fill and + -- half over the empty track. Either alone leaves one of the two halves muddy. + fs:SetShadowColor(0, 0, 0, 1) + fs:SetShadowOffset(1, -1) +end + +-- ---- construction ----------------------------------------------------------------------- + +local function BuildFrame() + if frame then return end + local d = db() + + frame = CreateFrame("Frame", "BulwarkFrameMain", UIParent) + frame:SetWidth(d.width) + frame:SetHeight(60) + frame:SetPoint(d.point, UIParent, d.relPoint, d.x, d.y) + frame:SetScale(d.scale) + frame:SetMovable(true) + frame:EnableMouse(not d.locked) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function() if not db().locked then this:StartMoving() end end) + frame:SetScript("OnDragStop", function() + this:StopMovingOrSizing() + local point, _, relPoint, x, y = this:GetPoint() + local dd = db() + dd.point, dd.relPoint, dd.x, dd.y = point, relPoint, x, y + end) + + -- Threshold bar: the primary readout. Fill = how full the buffer is. + poolBar = CreateFrame("StatusBar", "BulwarkFramePoolBar", frame) + poolBar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar") + poolBar:SetMinMaxValues(0, 1) + poolBar:SetValue(0) + applyBackdrop(poolBar, d) + + poolText = poolBar:CreateFontString("BulwarkFramePoolText", "OVERLAY") + poolText:SetPoint("CENTER", poolBar, "CENTER", 0, 0) + applyFont(poolText, 10) + + -- Expiry bar: deliberately flatter than the threshold bar -- it is secondary information. + timeBar = CreateFrame("StatusBar", "BulwarkFrameTimeBar", frame) + timeBar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar") + timeBar:SetMinMaxValues(0, 1) + timeBar:SetValue(0) + applyBackdrop(timeBar, d) + + timeText = timeBar:CreateFontString("BulwarkFrameTimeText", "OVERLAY") + timeText:SetPoint("RIGHT", timeBar, "RIGHT", -3, 0) + applyFont(timeText, 9) + + -- Current attack speed, on the left of the same bar the swing marker rides. It is the number + -- the marker's travel time is derived from, so having it visible makes the animation checkable + -- instead of something you have to trust -- and it shows haste changing live (Flurry drops it + -- by a fifth, which is exactly what desynced the bar before). + speedText = timeBar:CreateFontString("BulwarkFrameSpeedText", "OVERLAY") + speedText:SetPoint("LEFT", timeBar, "LEFT", 3, 0) + applyFont(speedText, 9) + + -- Swing line: a 1-2 px rule with a moving marker. Subordinate by construction, not by + -- colour alone -- it must never compete with the threshold for attention. + swingLine = CreateFrame("Frame", "BulwarkFrameSwingLine", frame) + local lineTex = swingLine:CreateTexture("BulwarkFrameSwingLineTex", "BACKGROUND") + lineTex:SetTexture("Interface\\Buttons\\WHITE8X8") + lineTex:SetVertexColor(0.25, 0.25, 0.28, 0.9) + lineTex:SetAllPoints(swingLine) + + swingMark = swingLine:CreateTexture("BulwarkFrameSwingMark", "OVERLAY") + swingMark:SetTexture("Interface\\Buttons\\WHITE8X8") + + -- One-line status, shown only when there is nothing else to show (no SuperWoW, buffer never + -- seen, aura ids unconfirmed). An empty frame with no explanation reads as "addon broken". + demoTag = frame:CreateFontString("BulwarkFrameDemoTag", "OVERLAY") + demoTag:SetPoint("BOTTOMLEFT", frame, "TOPLEFT", 0, 1) + applyFont(demoTag, 9) + demoTag:SetTextColor(1, 0.5, 0.2) + demoTag:SetText("DEMO - synthetic values") + demoTag:Hide() + + hintText = frame:CreateFontString("BulwarkFrameHintText", "OVERLAY") + hintText:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2) + applyFont(hintText, 9) + hintText:SetTextColor(0.7, 0.7, 0.75) + hintText:Hide() + + BF.ApplyLayout() +end + +-- Recomputes every size/position from the config. Called on load and after any options change, +-- so the panel needs no knowledge of the frame internals. +function BF.ApplyLayout() + if not frame then return end + local d = db() + local w = d.width + local y = 0 + + frame:SetWidth(w) + frame:SetScale(d.scale) + frame:EnableMouse(not d.locked) + + if d.showThreshold then + poolBar:ClearAllPoints() + poolBar:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, y) + poolBar:SetWidth(w) + poolBar:SetHeight(d.barHeight) + poolBar:Show() + y = y - d.barHeight - d.spacing + else + poolBar:Hide() + end + + if d.showExpiry then + timeBar:ClearAllPoints() + timeBar:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, y) + timeBar:SetWidth(w) + timeBar:SetHeight(d.timeBarHeight) + timeBar:Show() + y = y - d.timeBarHeight - d.spacing + else + timeBar:Hide() + end + + -- Two ways to show the swing. Riding the expiry bar is pfUI's mana-tick idiom: no extra row, + -- and the marker sits where the eye already is. Its own thin line stays available for anyone + -- who wants the swing readable independently of whether a buffer is up. + local rideExpiry = d.swingOnExpiryBar and d.showExpiry + if d.showSwing then + local sc = d.colorSwing or { 0.9, 0.9, 0.95 } + swingMark:SetVertexColor(sc[1], sc[2], sc[3]) + swingMark:SetWidth(2) + if rideExpiry then + swingLine:Hide() + swingHost = timeBar + swingMark:SetParent(timeBar) + swingMark:SetHeight(d.timeBarHeight) + else + swingLine:ClearAllPoints() + swingLine:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, y) + swingLine:SetWidth(w) + swingLine:SetHeight(d.swingHeight) + swingLine:Show() + swingHost = swingLine + swingMark:SetParent(swingLine) + swingMark:SetHeight(d.swingHeight + 2) + y = y - d.swingHeight - d.spacing + end + else + swingLine:Hide() + swingMark:Hide() + end + + if poolText then if d.showThresholdText then poolText:Show() else poolText:Hide() end end + if timeText then if d.showExpiryText then timeText:Show() else timeText:Hide() end end + if speedText then if d.showSpeedText then speedText:Show() else speedText:Hide() end end + + frame:SetHeight(math.max(8, -y)) + lastPoolText, lastTimeText, lastSpeedText = nil, nil, nil -- force a text refresh next tick +end + +-- ---- update loop ------------------------------------------------------------------------ + +-- Reads either the live state or the demo generator. Returns stacks, timeLeft, maxHP, active. +local function currentInputs() + if demo then + local t = GetTime() - demo.start + local cycle = math.mod(t, 12) + local stacks, timeLeft + if cycle < 8 then + -- drain the pool over 8 s, expiry ticking down alongside + stacks = math.floor(100 - (cycle / 8) * 100) + timeLeft = 8 - cycle + else + stacks, timeLeft = 0, 0 + end + return stacks, timeLeft, demo.maxHP, cycle < 8 + end + local st = BF.state + -- Interpolated remaining time: the aura is only re-read when the client fires an aura event, + -- which is far coarser than the display. Counting down from the last reading makes the bar + -- glide instead of stepping, and it re-syncs on every event, so it cannot drift. + local remaining = st.timeLeft + if remaining and remaining > 0 and st.timeLeftAt and st.timeLeftAt > 0 then + remaining = remaining - (GetTime() - st.timeLeftAt) + if remaining < 0 then remaining = 0 end + end + return st.stacks, remaining, st.maxHP, st.active +end + +local sinceTimeScan = 0 + +local function updateDisplay() + local d = db() + -- Re-read the remaining time on a timer, not only when the client fires an aura event: a + -- refresh of an aura that is already up does not reliably produce one, and the interpolation + -- would then keep counting down through a buffer that was reset. Five times a second is far + -- below what the eye resolves on an 8 second bar and costs one walk of the buff list. + if not demo then + sinceTimeScan = sinceTimeScan + UPDATE_INTERVAL + if sinceTimeScan >= 0.2 then + sinceTimeScan = 0 + if BF.ScanTime then BF.ScanTime() end + end + end + local stacks, timeLeft, maxHP, active = currentInputs() + + -- Weapon speed is re-read on the throttled tick, NOT only when a swing lands. Reading it once + -- per swing freezes whatever haste happened to be up at that moment: measured in game with + -- Flurry rank 5 active the state held 2.24 s while the character had gone back to 2.69 s, so + -- the marker finished a fifth of a second early and drifted out of sync with the real hits. + -- This is the "rescales" reading that core/swing.lua deliberately leaves to the caller -- for a + -- live readout it is the honest one: the bar should describe the swing you are in now. + if not demo then + local main = UnitAttackSpeed("player") + if type(main) == "number" and main > 0 then BF.state.swingSpeed = main end + end + + 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 })) + poolBar:SetStatusBarColor(r, g, b) + if d.showThresholdText then + local points = C.poolPoints(stacks, maxHP) + local maxPoints = C.maxPool(maxHP) + local txt + if d.displayMode == "shield" then + txt = C.fmtShieldPair(points, maxPoints) + else + txt = C.fmtThresholdPair(points, maxPoints, BF.currentRate(d)) + end + if txt ~= lastPoolText then poolText:SetText(txt); lastPoolText = txt end + end + end + + 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 })) + -- 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) + if d.showExpiryText then + local txt = C.fmtTime(timeLeft) + if txt ~= lastTimeText then timeText:SetText(txt); lastTimeText = txt end + end + -- Attack speed on the left of the same bar. Only pushed when the string actually changes, + -- so a steady speed costs nothing per tick. + if d.showSpeedText then + local sp = demo and 2.6 or BF.state.swingSpeed + local txt = (type(sp) == "number" and sp > 0) and (string.format("%.2f", sp)) or "" + if txt ~= lastSpeedText then speedText:SetText(txt); lastSpeedText = txt end + end + end + + return active +end + +-- The swing marker is updated on EVERY frame, not on the throttled tick: at 20 Hz a marker that +-- travels the whole bar in ~2.7 s visibly steps instead of gliding. It is cheap enough to justify +-- -- one SetPoint, no string building, no table allocation, and nothing at all while idle because +-- the OnUpdate script is detached when the frame has nothing to show. +local function updateSwingMarker() + local d = db() + if not d.showSwing then return end + local st = BF.state + local speed = demo and 2.6 or st.swingSpeed + local lastAt = demo and (GetTime() - math.mod(GetTime() - demo.start, 2.6)) or st.lastSwingAt + if not (S.hasSwung(lastAt) and speed and speed > 0) then + swingMark:Hide() + return + end + local p = S.progress(lastAt, GetTime(), speed, st.swingOffset) + -- SetPoint replaces the existing anchor of the same type, so ClearAllPoints per frame is + -- unnecessary work. + swingMark:SetPoint("LEFT", swingHost, "LEFT", p * (d.width - 2), 0) + swingMark:Show() +end + +-- Decides whether the frame should be on screen at all, and keeps the OnUpdate script attached +-- only while it has something to do. +function BF.RequestUpdate() + if not frame then return end + local d = db() + local st = BF.state + + if demo then + frame:Show() + if not frame:GetScript("OnUpdate") then frame:SetScript("OnUpdate", BF.OnUpdate) end + return + end + + if not BF.envOk then + frame:Show() + hintText:Show() + hintText:SetText("SuperWoW required") + frame:SetScript("OnUpdate", nil) + return + end + + 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 + else + frame:Hide() + frame:SetScript("OnUpdate", nil) -- idle costs nothing at all + end +end + +function BF.OnUpdate() + updateSwingMarker() + sinceUpdate = sinceUpdate + arg1 + 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 + BF.RequestUpdate() + end +end + +-- ---- demo mode -------------------------------------------------------------------------- + +-- Runs the display off a synthetic 12 second cycle so the layout and colours can be judged +-- without a shaman, a fight, or the aura question being settled. This is the difference between +-- "looks about right in my head" and actually seeing the red/yellow/green transitions. +function BF.ToggleDemo(on) + if on == nil then on = (demo == nil) end + if on then + demo = { start = GetTime(), maxHP = (UnitHealthMax("player") or 4588) } + if demo.maxHP <= 0 then demo.maxHP = 4588 end + -- 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. + if demoTag then demoTag:Show() end + else + demo = nil + if demoTag then demoTag:Hide() end + lastPoolText, lastTimeText, lastSpeedText = nil, nil, nil + end + BF.RequestUpdate() + return demo ~= nil +end + +function BF.IsDemo() return demo ~= nil end + +-- Deliberate show/hide, saved. Separate from the automatic hiding: this is the user saying "get +-- out of the way", not the addon deciding there is nothing to show. +function BF.ToggleFrame(on) + local d = db() + if on == nil then on = d.hidden and true or false end + d.hidden = not on + BF.RequestUpdate() + return not d.hidden +end + +-- ---- lifecycle -------------------------------------------------------------------------- + +function BF.InitUI() + BF.ensureDefaults() + BuildFrame() + local ok = BulwarkFrameEnv.check(SUPERWOW_VERSION) + BF.envOk = ok + BF.state.maxHP = UnitHealthMax("player") or 0 + BF.CapturePlayerGuid() + if ok then + BF.RefreshSwingInputs() + BF.ScanAuras() + end + BF.RequestUpdate() +end + +function BF.GetFrame() return frame end