BulwarkFrame - compact Earthen Bulwark readout for TurtleWoW
This commit is contained in:
+175
@@ -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
|
||||
+125
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user