TotemBar v0.3.0

This commit is contained in:
2026-08-17 15:19:36 +02:00
parent 8dbc411e80
commit 166d870dec
12 changed files with 1009 additions and 98 deletions
+641 -21
View File
@@ -13,6 +13,18 @@ TotemBar.DEFAULT_GAP_SECONDS = 2
-- cooldown and couldn't be re-placed).
TotemBar.DEFAULT_RECALL_GUARD = 2
-- The spell the recall paths cast. One source for the name so the guard, the
-- cooldown lookup and the cast itself can never disagree.
local RECALL_SPELL_NAME = "Totemic Recall"
-- How long a manual Recall press that the "nothing is out" gate BLOCKED stays
-- remembered, so a deliberate re-press can override the gate (see
-- TotemBar.manualRecallAction). The lower bound exists so an accidental
-- double-click cannot burn the very 6s cooldown the gate is there to save; the
-- upper bound expires the override again.
TotemBar.RECALL_OVERRIDE_MIN = 0.5
TotemBar.RECALL_OVERRIDE_MAX = 5
-- Default gap (px) between bar buttons. Matches ui.lua's file-scope
-- BUTTON_GAP default; the options panel's "Button spacing" slider (range
-- 10-30px) live-applies changes via TotemBar.SetButtonGap (ui.lua) and
@@ -124,6 +136,64 @@ function TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining)
return nil
end
-- Pure: should the out-of-range red tint treat this element as ACTIVE?
--
-- Own tracking is the base signal. When pfUI's libtotem is present its
-- GetTotemInfo may VETO it, so a totem DESTROYED before its timer ran out
-- stops flashing red -- but only for a slot libtotem demonstrably tracked
-- (gtiTracked, latched in ui.lua when GTI reported the slot active under this
-- record's totem name). "GTI has no record for this slot" is no information,
-- not a contradiction: libtotem keeps a single non-slot-indexed cast queue
-- committed on ONE SPELLCAST_STOP, so a four-totem drop leaves three slots
-- with no GTI record at all. Vetoing on those silenced both the red tint and
-- the Recall button's out-of-range pulse for most of the set -- the same
-- missing-GTI race resolveRemaining above already fixed for the timer path.
function TotemBar.rangeTintActive(hasOwnRecord, hasGTI, gtiTracked, gtiActive)
if not hasOwnRecord then
return false
end
if hasGTI and gtiTracked and not gtiActive then
return false
end
return true
end
-- Out-of-mana dim level. Blizzard's own "unusable" grey from FrameXML
-- ActionButton.lua:280 (ActionButton_UpdateUsable). Blizzard reserves a BLUE
-- tint (0.5,0.5,1.0) for the not-enough-mana case specifically and grey for
-- every other reason; TotemBar dims instead, because its icons already carry a
-- red state and a second HUE would compete with it, while a brightness step
-- reads on top of any hue.
TotemBar.OOM_DIM = 0.4
-- Pure: the icon's vertex colour, composed from the two independent reasons a
-- totem button can be tinted -- out of range (red, buff-presence based, see
-- rangeTintActive above) and out of mana (dimmed, new). They MULTIPLY rather
-- than override, so an out-of-range totem the player also cannot afford stays
-- recognisably red while reading as unavailable; two separate SetVertexColor
-- call sites would instead have raced, and whichever ran last would have won.
--
-- The fourth return is a cache key: the caller stores it on the button and only
-- touches the texture when it changes (0 allocations, no redundant API calls on
-- the 5Hz tick).
function TotemBar.iconTintFor(rangeRed, oom)
local r, g, b = 1, 1, 1
if rangeRed then
r, g, b = 1, 0.35, 0.35
end
local key = 0
if rangeRed then
key = key + 1
end
if oom then
key = key + 2
r = r * TotemBar.OOM_DIM
g = g * TotemBar.OOM_DIM
b = b * TotemBar.OOM_DIM
end
return r, g, b, key
end
-- Pure: OmniCC-style text for an already-known-positive remaining
-- seconds value: whole minutes rounded up from 60s on, plain rounded-up
-- integer seconds below that.
@@ -174,6 +244,91 @@ end
-- off). Both now route through core/spellindex.lua's cached
-- TotemBar.findSpellIndex (loaded earlier in the TOC) instead.
-- ===== Cast gate: don't start a countdown for a cast that never went out =====
--
-- The symptom this fixes: the timer starts even when the totem could not be
-- placed. recordCast runs right after CastSpellByName, and 1.12 gives Lua no
-- return value saying whether the cast was accepted -- so every refused press
-- used to start a full-length phantom countdown (and made anyTotemOut() true,
-- which then burned Totemic Recall's 6s cooldown on an empty board).
--
-- The verdict is taken BEFORE the cast, by the CastSpellByName/CastSpell hooks
-- below. Measuring afterwards cannot work: by then the mana is already spent
-- (so every successful cast looks unaffordable) and the GCD is already running
-- (so every successful cast looks cooldown-blocked).
-- A cooldown at or below this is treated as the global cooldown and never
-- blocks. Two reasons: GetSpellCooldown reports the GCD in the same fields as a
-- real cooldown, and nampower QUEUES a GCD-blocked instant
-- (NP_QueueInstantSpells, default on) so it still goes out a moment later --
-- refusing to record it would drop the timer of a totem that IS standing.
TotemBar.GCD_MAX = 1.6
-- Pure: why can this cast not have gone out? Returns nil (fail open), "mana" or
-- "cooldown". Every input may be nil -- an unknown never blocks, exactly like
-- notEnoughMana/confidentNoneOut. Mana is reported first because it is the one
-- the player can act on.
function TotemBar.castGateReason(cost, mana, cdStart, cdDuration, gcdMax, clearcasting)
if not clearcasting and TotemBar.notEnoughMana(cost, mana) then
return "mana"
end
if cdStart and cdDuration and cdStart > 0 and cdDuration > (gcdMax or TotemBar.GCD_MAX) then
return "cooldown"
end
return nil
end
-- The pre-cast verdict, stamped with the tick it was taken in. recordCast below
-- honours it only for the SAME element, the SAME totem and the SAME GetTime()
-- tick, so a stale verdict can never suppress a later, legitimate cast.
TotemBar.castBlock = nil
-- WoW-side snapshot taken immediately before the cast leaves. Reads the cached
-- tooltip mana cost, live mana, and the spell's own cooldown.
local function computeCastBlock(element, totemName)
local cost = TotemBar.getTotemManaCost and TotemBar.getTotemManaCost(totemName)
local mana = (type(UnitMana) == "function") and UnitMana("player") or nil
local cdStart, cdDuration = nil, nil
if type(GetSpellCooldown) == "function" and TotemBar.findSpellIndex then
local idx = TotemBar.findSpellIndex(totemName)
if idx then
cdStart, cdDuration = GetSpellCooldown(idx, BOOKTYPE_SPELL)
end
end
return TotemBar.castGateReason(cost, mana, cdStart, cdDuration,
TotemBar.GCD_MAX, TotemBar.hasClearcasting and TotemBar.hasClearcasting())
end
-- This runs INSIDE the CastSpellByName hook, i.e. in front of every totem cast
-- the client makes -- including the first one for a totem, where the cost is
-- still unknown and getTotemManaCost scans a hidden tooltip. Wrapped so that
-- nothing in that chain (tooltip, spellbook scan, buff walk) can abort the cast
-- itself. A failure yields no verdict, which is the fail-open state this gate
-- has anyway whenever an input is unknown -- the cast proceeds and is tracked
-- exactly as it was before this feature existed.
function TotemBar.measureCastBlock(element, totemName)
if not element or not totemName then
return nil
end
local ok, reason = pcall(computeCastBlock, element, totemName)
if not ok then
reason = nil
end
if reason then
TotemBar.castBlock = {
element = element,
name = totemName,
at = GetTime(),
reason = reason,
}
else
-- Clear rather than leave: a verdict from an earlier cast in this same
-- tick must not outlive the cast it belongs to.
TotemBar.castBlock = nil
end
return reason
end
-- Records that `totemName` was just cast into `element`'s slot, into
-- TotemBar's own tracking table (see activeTotems above). Touches
-- GetTime(), a spellbook index/texture scan and (for Searing Totem) a
@@ -191,6 +346,14 @@ function TotemBar.recordCast(element, totemName)
if not element or not totemName then
return
end
-- Refused before it left (see measureCastBlock): no timer, and -- like the
-- unknown-totem guard below -- no castState.everCast either, since a cast
-- that never happened is not evidence that anything is out.
local blk = TotemBar.castBlock
if blk and blk.reason and blk.element == element and blk.name == totemName
and blk.at == GetTime() then
return
end
local highestRank = nil
if totemName == "Searing Totem" and TotemBar.highestKnownRank then
highestRank = TotemBar.highestKnownRank(totemName)
@@ -199,6 +362,18 @@ function TotemBar.recordCast(element, totemName)
local idx = TotemBar.findSpellIndex(totemName)
if idx then
icon = GetSpellTexture(idx, BOOKTYPE_SPELL)
elseif TotemBar.spellbookEntryCount and TotemBar.spellbookEntryCount() > 0 then
-- The spell is provably NOT in this character's spellbook, so the cast
-- that just went out was a guaranteed no-op -- don't start a full-length
-- phantom countdown (and a true anyTotemOut(), which then burns Totemic
-- Recall's 6s cooldown on an empty board). Reachable because
-- TotemBarDB is account-wide: a chosen set carried over from another
-- shaman lands on an alt that hasn't learned those totems.
-- Guarded on a NON-EMPTY spellbook scan on purpose: the index cache is
-- built lazily and the book isn't reliably populated at login, so an
-- empty scan means "no data", not "not known" -- fail OPEN there,
-- exactly like confidentNoneOut's unknown-state policy below.
return
end
local rec = {
start = GetTime(),
@@ -209,8 +384,25 @@ function TotemBar.recordCast(element, totemName)
totemName = totemName,
icon = icon,
everHadBuff = false,
-- Latched by ui.lua once GetTotemInfo reports this slot active under
-- this record's totem name; see TotemBar.rangeTintActive below.
gtiTracked = false,
}
-- Kept for revokeRecentCast below: if this cast turns out to have been
-- refused, the honest correction is the record as it was BEFORE the press,
-- not an empty slot (a refused REPEAT press must not lose the still-running
-- timer of the totem the first press placed).
TotemBar.lastOverwritten = {
element = element,
rec = TotemBar.activeTotems[element],
at = rec.start,
}
TotemBar.activeTotems[element] = rec
-- Sticky session evidence for confidentNoneOut() below. Deliberately NOT
-- derived from activeTotems' occupancy: ui.lua's timer tick evicts each
-- record the moment it expires, and clearActiveTotems() wipes the table
-- after every recall.
TotemBar.castState.everCast = true
end
-- Records a totem cast caught by the universal CastSpellByName/CastSpell
@@ -233,6 +425,158 @@ function TotemBar.recordCastFromHook(element, totemName)
TotemBar.recordCast(element, totemName)
end
-- ===== Revoking a timer a failure event proves wrong =====
--
-- Second line after the pre-cast gate, for refusals it cannot predict (the
-- server's own "no", an element recovery too short to tell apart from the
-- GCD, movement, or a client-side refusal like "another action in
-- progress"). nampower's SPELL_FAILED_SELF (carries a SPELL ID) is the exact
-- source when nampower is present; UI_ERROR_MESSAGE/SPELLCAST_FAILED/
-- SPELLCAST_INTERRUPTED below are the fallback for everyone else.
--
-- Vanilla's SPELLCAST_FAILED and UI_ERROR_MESSAGE were refused for a long
-- time: both are global and carry no hint of which action failed
-- (UI_ERROR_MESSAGE's single arg is just the message text). During key spam a
-- refused Lightning Bolt could delete the timer of a totem that is standing --
-- the classic mis-attribution that hits every addon inferring its own outcome
-- from those two events. This is now wired up (see lastCastAttempt and
-- attributeCastFailure below), gated tightly enough that the Lightning Bolt
-- case above cannot happen:
-- 1. the LAST spell the client attempted through CastSpellByName/CastSpell
-- -- ANY spell, not only totems -- must have been the totem itself. A
-- Lightning Bolt cast after it, even a fraction of a second later,
-- becomes the new "last attempt" and the totem is never touched.
-- 2. that attempt must be within CAST_FAIL_WINDOW seconds.
-- 3. for UI_ERROR_MESSAGE specifically (the one event of the three that
-- fires for unrelated things too -- loot, trade, chat, ...) the message
-- text must be one of the client's own "a spell cast was refused"
-- strings (read live off the globals, so this self-localizes).
-- A missing timer is still a worse failure than a phantom one, so anything
-- that fails any of the three checks is left alone.
-- How long after a cast a failure can still belong to it.
TotemBar.CAST_FAIL_WINDOW = 0.4
-- Pure: the one element whose record was created inside the window, or nil if
-- that is ambiguous. A four-totem drop puts several records in the same window;
-- with no spell id to disambiguate, ANY pick would be a guess, and a wrong
-- guess deletes a standing totem's timer.
function TotemBar.soleRecentElement(activeTotems, elements, now, window)
if not activeTotems then
return nil
end
local found = nil
for i = 1, table.getn(elements) do
local element = elements[i]
local rec = activeTotems[element]
if rec and rec.start and (now - rec.start) <= window then
if found then
return nil -- ambiguous: refuse to guess
end
found = element
end
end
return found
end
-- Undoes the tracking of a cast that the client/server refused. `spellName` is
-- the failed totem's name when it could be resolved (exact pick, even with
-- several casts in flight); nil falls back to the sole-recent rule above.
--
-- Restores the record the refused press overwrote instead of clearing the slot
-- -- see TotemBar.lastOverwritten in recordCast.
function TotemBar.revokeRecentCast(spellName)
local now = GetTime()
local element = nil
if spellName then
for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
local el = TotemBar.TOTEM_ELEMENTS[i]
local rec = TotemBar.activeTotems[el]
if rec and rec.totemName == spellName and rec.start
and (now - rec.start) <= TotemBar.CAST_FAIL_WINDOW then
element = el
end
end
end
if not element then
element = TotemBar.soleRecentElement(TotemBar.activeTotems,
TotemBar.TOTEM_ELEMENTS, now, TotemBar.CAST_FAIL_WINDOW)
end
if not element then
return nil
end
local prev = TotemBar.lastOverwritten
local restore = nil
if prev and prev.element == element and prev.at == TotemBar.activeTotems[element].start then
restore = prev.rec
end
TotemBar.activeTotems[element] = restore
TotemBar.lastOverwritten = nil
return element
end
-- Last spell cast attempt the client made through CastSpellByName/CastSpell --
-- ANY spell, not only totems (element/name are nil for a non-totem attempt).
-- Written by both hooks below, unconditionally, on every call. This is what
-- makes attributeCastFailure's attribution exact instead of a guess: it
-- always names the ONE totem that was actually last attempted, and a later,
-- different spell attempt overwrites it -- so a failure belonging to that
-- later spell can never be blamed on the totem that came before it.
TotemBar.lastCastAttempt = nil
local function noteCastAttempt(element, totemName)
TotemBar.lastCastAttempt = { element = element, name = totemName, at = GetTime() }
end
-- Pure: turns a flat, HOLE-FREE array of strings (built with table.insert,
-- see buildCastFailureMessages below -- a literal array with a nil in the
-- MIDDLE is undefined for table.getn in Lua 5.0 and would silently drop
-- every entry after it) into a lookup set. Used to build the
-- UI_ERROR_MESSAGE allowlist from live client globals, and independently
-- testable offline with a hand-built array.
function TotemBar.messageSet(list)
local set = {}
if not list then
return set
end
for i = 1, table.getn(list) do
if list[i] then
set[list[i]] = true
end
end
return set
end
-- Pure: should a global failure event revoke lastAttempt's totem timer?
-- Returns the element and totem name to revoke, or nil, nil.
--
-- lastAttempt the last thing this addon's hooks saw the client attempt
-- (ANY spell -- see noteCastAttempt above), or nil.
-- now, window lastAttempt.at must be within `window` seconds of `now`.
-- message the UI_ERROR_MESSAGE text, or nil for SPELLCAST_FAILED/
-- SPELLCAST_INTERRUPTED (neither carries one in 1.12).
-- allowlist set of message strings that mean "a spell cast was
-- refused" (see messageSet/buildCastFailureMessages).
-- Checked ONLY when a message was given -- SPELLCAST_FAILED/
-- SPELLCAST_INTERRUPTED are already scoped to the player's
-- own current cast by the client, so they need no text
-- filter; UI_ERROR_MESSAGE does, since it also fires for
-- loot/trade/chat/etc. A message that isn't recognised (or a
-- missing/empty allowlist) fails CLOSED -- no revoke -- same
-- policy as an unattributable failure above.
function TotemBar.attributeCastFailure(lastAttempt, now, window, message, allowlist)
if not lastAttempt or not lastAttempt.element then
return nil, nil
end
if not lastAttempt.at or not now or not window or (now - lastAttempt.at) > window then
return nil, nil
end
if message ~= nil and (not allowlist or not allowlist[message]) then
return nil, nil
end
return lastAttempt.element, lastAttempt.name
end
-- ===== Universal cast hooks: catch totem casts from ANY path =====
-- Defense-in-depth for totems cast WITHOUT going through any TotemBar
-- function at all -- e.g. a hand-written macro's own `/cast Searing Totem`,
@@ -266,11 +610,21 @@ end
-- covers action-bar-dragged totem casts as the resolveRemaining/
-- resolveDuration fallback source when pfUI is present, this gap was
-- judged not worth the added dependency/complexity.
-- Both hooks measure the cast gate BEFORE calling through (see
-- measureCastBlock: afterwards the mana is spent and the GCD is running, so the
-- reading would accuse every successful cast). This placement also covers the
-- paths that record for themselves -- ui.lua's click handlers, castNext/castAll,
-- bind.lua -- because they all reach the client through these same globals, so
-- their own recordCast call sees the verdict too.
if type(CastSpellByName) == "function" then
local origCastSpellByName = CastSpellByName
CastSpellByName = function(name, onSelf)
origCastSpellByName(name, onSelf)
local element, baseName = TotemBar.elementFromCastName(name)
noteCastAttempt(element, baseName)
if element then
TotemBar.measureCastBlock(element, baseName)
end
origCastSpellByName(name, onSelf)
if element then
TotemBar.recordCastFromHook(element, baseName)
end
@@ -280,17 +634,128 @@ end
if type(CastSpell) == "function" then
local origCastSpell = CastSpell
CastSpell = function(spellId, bookType)
origCastSpell(spellId, bookType)
local element, baseName = nil, nil
if type(GetSpellName) == "function" then
local rawName = GetSpellName(spellId, bookType)
local element, baseName = TotemBar.elementFromCastName(rawName)
if element then
TotemBar.recordCastFromHook(element, baseName)
end
element, baseName = TotemBar.elementFromCastName(GetSpellName(spellId, bookType))
end
noteCastAttempt(element, baseName)
if element then
TotemBar.measureCastBlock(element, baseName)
end
origCastSpell(spellId, bookType)
if element then
TotemBar.recordCastFromHook(element, baseName)
end
end
end
-- nampower's per-cast failure event (arg1 = spell id, arg2 = result, arg3 = 1
-- when the SERVER rejected it). It only fires with NP_EnableSpellFailedEvents
-- on, and nampower silently RETRIES some results (NP_RetryServerRejectedSpells,
-- default on: NOT_READY / ITEM_NOT_READY / SPELL_IN_PROGRESS), so those may
-- never arrive at all -- which is correct for us, since a retried cast that
-- lands really did place the totem.
--
-- Registering it costs nothing when nampower is absent (the event simply never
-- fires). The CVar is NOT set here: flipping a client-wide nampower switch is a
-- side effect on every other addon, and this is a safety net, not the primary
-- fix.
--
-- UI_ERROR_MESSAGE / SPELLCAST_FAILED / SPELLCAST_INTERRUPTED cover the same
-- ground for players WITHOUT nampower (see the header comment above for why
-- these were declined before, and why attributeCastFailure now makes them
-- safe: exact last-attempted-spell match, a narrow window, and -- for
-- UI_ERROR_MESSAGE specifically -- a message-text allowlist).
--
-- Guarded on CreateFrame so the file still loads under plain Lua for the tests.
if CreateFrame then
local failFrame = CreateFrame("Frame", "TotemBarCastFailFrame", UIParent)
failFrame:RegisterEvent("SPELL_FAILED_SELF")
failFrame:RegisterEvent("UI_ERROR_MESSAGE")
failFrame:RegisterEvent("SPELLCAST_FAILED")
failFrame:RegisterEvent("SPELLCAST_INTERRUPTED")
-- Built lazily from the client's own global error strings on first use
-- (see attributeCastFailure's `allowlist` doc above) -- self-localizes,
-- and never goes stale against a client string update. Cached: this can
-- fire several times a fight.
local castFailureMessages = nil
local function buildCastFailureMessages()
-- Built with table.insert, not a literal array, so a global that
-- doesn't exist on this client build never leaves a HOLE in the
-- middle of the list -- table.getn (which messageSet uses) is
-- undefined over a table with holes in Lua 5.0 and would silently
-- drop every entry after the first missing one.
local raw = {}
local function add(v) if v then table.insert(raw, v) end end
add(ERR_OUT_OF_MANA)
add(ERR_SPELL_COOLDOWN)
add(ERR_OUT_OF_RANGE)
add(ERR_SPELL_OUT_OF_RANGE)
add(SPELL_FAILED_MOVING)
add(SPELL_FAILED_NOT_READY)
add(SPELL_FAILED_ITEM_NOT_READY)
add(SPELL_FAILED_SPELL_IN_PROGRESS)
add(SPELL_FAILED_STUNNED)
add(SPELL_FAILED_SILENCED)
add(SPELL_FAILED_PACIFIED)
add(SPELL_FAILED_CONFUSED)
add(SPELL_FAILED_CASTER_DEAD)
add(SPELL_FAILED_CASTER_AURASTATE)
add(SPELL_FAILED_FLEEING)
add(SPELL_FAILED_AFFECTING_COMBAT)
return TotemBar.messageSet(raw)
end
failFrame:SetScript("OnEvent", function()
if event == "SPELL_FAILED_SELF" then
-- Resolve the id to a name. SuperWoW's SpellInfo reads the client
-- DBC; nampower ships its own lookup (and is what fired this
-- event, so one of the two is normally there).
local name = nil
local id = arg1
if id then
if type(SpellInfo) == "function" then
name = SpellInfo(id)
elseif type(GetSpellNameAndRankForId) == "function" then
name = GetSpellNameAndRankForId(id)
end
if name then
name = TotemBar.stripRankSuffix(name)
end
end
-- No name, no revoke. An unnamed failure could just as well be
-- the Lightning Bolt the player pressed a moment after the totem.
if not name or not TotemBar.elementOf(name) then
return
end
TotemBar.revokeRecentCast(name)
TotemBar.lastCastAttempt = nil
return
end
if event == "UI_ERROR_MESSAGE" or event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then
local message = nil
if event == "UI_ERROR_MESSAGE" then
message = arg1
if not castFailureMessages then
castFailureMessages = buildCastFailureMessages()
end
end
local element, name = TotemBar.attributeCastFailure(TotemBar.lastCastAttempt,
GetTime(), TotemBar.CAST_FAIL_WINDOW, message, castFailureMessages)
if element then
TotemBar.revokeRecentCast(name)
-- Consume: a second event for the SAME refusal (e.g.
-- SPELLCAST_FAILED right after UI_ERROR_MESSAGE) must not
-- attribute again, and a later, unrelated failure must not
-- reuse this now-stale attempt either.
TotemBar.lastCastAttempt = nil
end
end
end)
end
-- Module-scratch table for the buff-texture scan below, reused every
-- call (hasBuffWithIcon runs ~5x/sec, from ui.lua's throttled timer
-- tick) so it doesn't allocate a new table each time. buffScratchLen
@@ -300,20 +765,39 @@ end
local buffScratch = {}
local buffScratchLen = 0
-- Pure: the final path component of a texture path, lowercased -- i.e. the
-- bare icon name. Keeps the case/prefix tolerance the comparison below needs
-- (GetSpellTexture and UnitBuff don't have to return identically-spelled
-- paths) without the false positives of a substring search.
local function textureTail(path)
local s = string.lower(path)
local _, sep = string.find(s, ".*[\\/]")
if sep then
return string.sub(s, sep + 1)
end
return s
end
-- Pure: given a flat array of buff texture path strings (some entries
-- may be nil) and a totem spell's icon texture path, returns true if
-- any buff texture matches iconPath via a case-insensitive literal
-- substring search (tolerates path/casing differences between
-- GetSpellTexture's and UnitBuff's returned strings). Returns false if
-- iconPath or buffTexList is nil, or nothing matches.
-- any buff's ICON NAME equals iconPath's, case-insensitively and
-- independent of the leading path. Returns false if iconPath or
-- buffTexList is nil, or nothing matches.
--
-- Compares the trailing path component for EQUALITY, not containment: a
-- substring search let any icon name that merely starts with the totem's
-- count as a match, and 1.12 ships exactly such a pair -- Windwall Totem is
-- Spell_Nature_EarthBind, Strength of Earth Totem is
-- Spell_Nature_EarthBindTotem. Carrying the Strength of Earth buff therefore
-- made Windwall look permanently in range, so its slot never turned red.
function TotemBar.buffTexturesMatch(buffTexList, iconPath)
if not iconPath or not buffTexList then
return false
end
local needle = string.lower(iconPath)
local needle = textureTail(iconPath)
for i = 1, table.getn(buffTexList) do
local tex = buffTexList[i]
if tex and string.find(string.lower(tex), needle, 1, true) then
if tex and textureTail(tex) == needle then
return true
end
end
@@ -378,9 +862,10 @@ function TotemBar.anyTotemOut()
end
-- pure: does the addon currently hold ANY tracked totem record (regardless of expiry)?
-- TotemBar.activeTotems is an in-memory table cleared to {} on load (see top of this file,
-- NOT persisted to SavedVariables), so a non-empty table proves we have cast at least one
-- totem THIS session and can reason about whether it is still out.
-- NOTE: this is NOT the recall gate's session evidence any more -- see
-- confidentNoneOut below. Table occupancy cannot answer "did we cast something this
-- session": ui.lua's 0.1s timer tick EVICTS each record the moment it expires, and
-- clearActiveTotems() wipes the whole table after every recall.
function TotemBar.hasTrackedTotems(activeTotems, elements)
if not activeTotems then return false end
for i = 1, table.getn(elements) do
@@ -399,8 +884,16 @@ end
-- totems out" and blocking a legitimate recall of a still-standing totem (the bug this fixes:
-- our reloads wiped the tracking while the totems stayed physically out). Worst case of
-- fail-open is one needless recall right after a reload -- far cheaper than a blocked one.
--
-- The "cast something this session" half reads the STICKY castState.everCast flag set in
-- recordCast, not the occupancy of activeTotems. Occupancy was the original evidence and it
-- was wrong: ui.lua's 0.1s tick deletes a record the moment it expires and clearActiveTotems()
-- empties the table after every recall, so the gate fell open again ~0.1s after the last totem
-- ran out -- it only ever blocked while the bar was HIDDEN (hidden frame, no OnUpdate, no
-- eviction), the exact inverse of the intent above. castState is in-memory like activeTotems,
-- so a /reload still resets it to the fail-open state.
function TotemBar.confidentNoneOut()
if not TotemBar.hasTrackedTotems(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS) then
if not TotemBar.castState.everCast then
return false
end
return not TotemBar.anyTotemOut()
@@ -498,12 +991,134 @@ end
-- is nampower's queue-bypass cast.)
local function castRecallNoQueue()
if type(CastSpellByNameNoQueue) == "function" then
CastSpellByNameNoQueue("Totemic Recall")
CastSpellByNameNoQueue(RECALL_SPELL_NAME)
else
CastSpellByName("Totemic Recall")
CastSpellByName(RECALL_SPELL_NAME)
end
end
-- pure: does GetSpellCooldown's (start, duration) pair describe a cooldown
-- that is RUNNING right now? 1.12 reports a live cooldown as start > 0 AND
-- duration > 0. It reports the GLOBAL cooldown here too for a GCD-tied spell
-- like Totemic Recall, which is exactly what we want: castRecallNoQueue above
-- deliberately does not queue, so a GCD-blocked press simply fails -- treating
-- it as "cannot cast" keeps this check honest about what the client will do.
--
-- Missing data (nil) means "no cooldown information", never "on cooldown" --
-- FAIL OPEN, so an unreadable cooldown can never block a legitimate recall
-- (same policy as confidentNoneOut above).
function TotemBar.cooldownActive(start, duration)
if not start or not duration then
return false
end
return start > 0 and duration > 0
end
-- Can Totemic Recall actually be cast right now? Reads the live cooldown via
-- the shared spellbook index cache (core/spellindex.lua). Unknowns fail OPEN --
-- no GetSpellCooldown, no index cache at all -- so an unreadable cooldown can
-- never block a legitimate recall.
--
-- "Not in the book" is deliberately NOT an unknown: it is only unknown while
-- the scan itself is unusable. A nil index against a NON-EMPTY cached scan is
-- KNOWN state -- the spell cannot be cast, so a press cannot have gone out --
-- and failing open there was the same evidence-destroying bug this function
-- exists to prevent: a shaman below level 30 has not learned Totemic Recall
-- (learned at 30), so every press returned "cast", ran clearActiveTotems() on a
-- still-standing set and left confidentNoneOut() saying "confidently nothing
-- out" -- the fail-CLOSED state that gate forbids. Same non-empty-scan idiom as
-- recordCast above: the book is not reliably populated at login and the cache
-- is built lazily, so an EMPTY scan means "no data", not "not known".
function TotemBar.recallReady()
if type(GetSpellCooldown) ~= "function" or not TotemBar.findSpellIndex then
return true
end
local idx = TotemBar.findSpellIndex(RECALL_SPELL_NAME)
if not idx then
return not (TotemBar.spellbookEntryCount and TotemBar.spellbookEntryCount() > 0)
end
return not TotemBar.cooldownActive(GetSpellCooldown(idx, BOOKTYPE_SPELL))
end
-- pure: what should a MANUAL Totemic Recall press do?
-- "none-out" the gate is confident nothing is out -> skip the cast, saving
-- Totemic Recall's 6s cooldown.
-- "cooldown" Recall is not castable right now (its own 6s cooldown, a GCD,
-- or never learned -- see recallReady) -> skip the cast AND keep
-- the own-tracking. A recall that cannot have gone out must never
-- destroy the evidence that the totems are still standing: the
-- tracking is one half of confidentNoneOut's gate while
-- castState.everCast (the other half) is sticky for the whole
-- session, so wiping it on a refused press left the gate saying
-- "confidently nothing out" with a full set on the ground --
-- fail-CLOSED, the one outcome confidentNoneOut forbids.
-- "cast" cast it.
--
-- The override exists for the same reason: any state where a totem is out but
-- UNTRACKED still trips the gate -- e.g. a totem dropped from the action bar
-- on a client without pfUI's GetTotemInfo (1.12 cannot read an action slot's
-- spell, see the UseAction note above). So the gate is never a dead end: a
-- DELIBERATE re-press after a blocked one lets the recall through. The
-- overrideMin/Max window separates that from an accidental double-click and
-- expires the override again for the next, unrelated press.
function TotemBar.manualRecallAction(noneOut, ready, lastBlockedAt, now, overrideMin, overrideMax)
if noneOut then
local override = false
if lastBlockedAt and overrideMin and overrideMax then
local since = now - lastBlockedAt
override = (since >= overrideMin) and (since <= overrideMax)
end
if not override then
return "none-out"
end
end
if not ready then
return "cooldown"
end
return "cast"
end
-- The ONE manual-recall implementation, shared by the Recall button's
-- left-click (ui.lua) and the TOTEMBAR_RECALL keybind (bind.lua) so the two
-- can never drift apart again. Returns the action taken ("none-out" /
-- "cooldown" / "cast"); the callers own the chat feedback.
--
-- Casts through castRecallNoQueue exactly like the auto paths: a plain
-- CastSpellByName here was queueable under nampower, so a manual press during
-- a GCD could pop AFTER the next set was placed and sweep it away -- the very
-- teardown that helper exists to prevent.
function TotemBar.manualRecall()
local now = GetTime()
local noneOut = TotemBar.confidentNoneOut and TotemBar.confidentNoneOut()
local action = TotemBar.manualRecallAction(noneOut, TotemBar.recallReady(),
TotemBar.castState.recallBlockedAt, now,
TotemBar.RECALL_OVERRIDE_MIN, TotemBar.RECALL_OVERRIDE_MAX)
if action == "none-out" then
-- Remember the refusal so a deliberate re-press can override it.
TotemBar.castState.recallBlockedAt = now
return action
end
if action == "cooldown" then
-- Deliberately KEEPS recallBlockedAt: this press was refused by the
-- client, not by the gate, so an override the player already expressed
-- must survive a transient cooldown/GCD instead of costing them another
-- paired press once it clears.
return action
end
TotemBar.castState.recallBlockedAt = nil
castRecallNoQueue()
-- The refund learner's snapshot runs AFTER the cast but BEFORE the wipe:
-- it sums the cost of the totems still held in activeTotems.
if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end
-- Totemic Recall drops every active totem at once; clear own-tracking so
-- the icons' countdowns disappear too (GetTotemInfo, if present, will also
-- reflect this).
TotemBar.clearActiveTotems()
return action
end
-- Recall-then-deploy: when TotemBarDB.autoRecall is on (the default -
-- toggleable via the Recall button's right-click, see ui.lua), casts
-- Totemic Recall FIRST (drops existing totems and refunds some mana)
@@ -518,13 +1133,18 @@ end
-- since the totems are still up and each element is on its own ~1.5s
-- cooldown) instead of recalling the totems that were just placed.
--
-- Also gated on recallReady(): a Recall the client cannot cast right now (its
-- own 6s cooldown, or a GCD -- castRecallNoQueue never defers, so such a press
-- just fails) must not run clearActiveTotems() either, or it wipes the timers
-- of totems that are still standing.
--
-- Intended for a macro: `/script TotemBar.recallAndCastAll()`
function TotemBar.recallAndCastAll()
local now = GetTime()
local autoRecall = TotemBarDB and TotemBarDB.autoRecall
local guard = (TotemBarDB and TotemBarDB.recallGuardSeconds) or TotemBar.DEFAULT_RECALL_GUARD
if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard)
and TotemBar.anyTotemOut() then
and TotemBar.recallReady() and TotemBar.anyTotemOut() then
castRecallNoQueue()
TotemBar.clearActiveTotems()
end
@@ -555,7 +1175,7 @@ function TotemBar.dropSetKey(keystate)
local autoRecall = TotemBarDB and TotemBarDB.autoRecall
local guard = (TotemBarDB and TotemBarDB.recallGuardSeconds) or TotemBar.DEFAULT_RECALL_GUARD
if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard)
and TotemBar.anyTotemOut() then
and TotemBar.recallReady() and TotemBar.anyTotemOut() then
castRecallNoQueue()
TotemBar.clearActiveTotems()
end