223 lines
10 KiB
Lua
223 lines
10 KiB
Lua
-- 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
|