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
+87
View File
@@ -2,6 +2,93 @@
All notable changes to TotemBar are documented here.
## v0.3.0 — 2026-08-15
### Added
- **Totems you cannot afford are dimmed.** Element buttons and the hover flyout
grey out while your mana is below the totem's cost, so the bar shows what is
actually castable instead of only what is chosen. The cost is the real one for
your highest known rank, including cost talents, and the dim composes with the
out-of-range tint rather than replacing it — an out-of-range totem you also
cannot afford still reads as red, just darker. Clearcasting suspends it, since
the next spell is free while it is up.
### Fixed
- **A refused cast no longer starts a countdown, and one that already started
can now be taken back.** Pressing a totem without the mana for it (or while
it is genuinely on cooldown) used to start a full timer for a totem that was
never placed — which also made Totemic Recall believe something was out and
burn its 6s cooldown on an empty board. The cast's chances are now checked
*before* it leaves, so an attempt that cannot have gone through is never
tracked in the first place. A second layer catches what the pre-check
cannot predict — movement, another action already in progress, a stun, or a
server-side refusal — by watching the client's own "cast failed" signal
(the exact one with nampower, an error-message fallback without it) and
undoing the countdown after the fact. That revoke is filtered narrowly on
purpose: it only fires for the totem cast just attempted, only within a
short window afterward, and only for messages the client is known to use
for a refused cast, so it stays silent whenever it cannot be sure — a
refused *repeat* press restores the original timer rather than clearing it.
In every case where attribution is uncertain, this fails **closed**: no
revoke happens, and the existing timer is left standing, because a missing
timer is worse than a phantom one.
- **Totemic Recall no longer wipes your totem timers on a press that could not
have gone out.** Recall's own 6s cooldown or the global cooldown could
refuse a manual Recall press while the button and keybind still cleared the
addon's totem tracking as if it had fired — after which the addon believed
nothing was out and refused every further Recall press until something
re-armed it. Recall now checks whether the cast can actually go out before
it touches the tracking, and a press that cannot keeps it intact.
- **Totemic Recall no longer wastes its cooldown on a no-op moments after your
totems expire.** The check for "is anything even out" relied on the totem
tracking table staying occupied, but that table is emptied the instant each
totem's own timer runs out — so within a fraction of a second of the last
totem expiring, Recall forgot it had ever cast anything and let a needless
press through, burning the cooldown on an empty board. A separate flag now
answers "did we cast this session", so the check survives normal timer
expiry as intended.
- **The Recall button and its keybind can no longer sweep away a set you just
redeployed.** Both issued a plain Totemic Recall cast, which nampower
queues behind the global cooldown — so pressing Recall during a GCD could
fire it *after* you had already placed your totems again, recalling the
set you had just dropped. Both now share the same queue-safe cast the
automatic recall paths already used, so a GCD-blocked press is skipped
instead of firing late.
- **A totem you have not learned no longer starts a countdown.** The saved
totem set is account-wide and not checked against the spellbook, so a set
carried over from another character could land on one that never learned
those totems. Casting it was a guaranteed no-op, but it still started a
full phantom timer and made Totemic Recall think something was out.
- **Sentry Totem's timer no longer expires 180 seconds early.** It was
missing its own duration entry and silently fell back to a generic
120-second default, well short of the totem's real 5-minute duration.
- **The seventh Air totem is no longer dropped from the hover flyout.** The
flyout's icon pool was sized for six totems, but Air offers seven — with
the Air slot left empty, the last entry could neither be cast nor be set
as the slot's new default from the bar.
- **Windwall Totem no longer reads permanently in-range once you are also
carrying Strength of Earth Totem.** The range check matched buff icons by
an unanchored substring, and Strength of Earth Totem's icon name happens to
start with Windwall's, so carrying one made the other's out-of-range tint
never trigger.
- **A totem pulse from another shaman can no longer drag your own pulse ring
backward.** The periodic self-heal/self-mana messages TotemBar reads to
time the pulse ring carry no caster information, so an identically worded
tick from someone else's totem re-anchored the phase and made the ring
visibly jump. Anchoring is now limited to at most once per pulse interval.
- **The out-of-range tint no longer skips most of a freshly-placed set when
pfUI is loaded.** pfUI's own totem tracking commits at most one totem per
cast, so dropping a full four-totem set left three slots with no
information from it at all — and those slots were treated as "known to be
in range" instead of falling back to TotemBar's own tracking, so most of
the set never turned red out of range or pulsed the Recall button.
- **The duration ring no longer counts down, jumps back up, and counts down
again with Totemic Mastery talented.** TotemBar's own tracking stores the
Mastery-inflated duration while pfUI's totem data reports the shorter flat
spellbook value; the display preferred pfUI's number until it ran out, then
jumped back up to the correct remaining time. Both sources now agree on the
same, Mastery-scaled endpoint.
## v0.2.5 — 2026-07-25
### Added
+21 -1
View File
@@ -31,6 +31,16 @@ server data, not guesswork. No dependencies; adopts the
**Version history** — details in [CHANGELOG.md](CHANGELOG.md):
- **v0.3.0** (2026-08-15) — element buttons and the flyout now dim totems you
can't afford; a refused cast no longer starts a phantom countdown, and one
that already started can now be taken back after the fact (narrowly, and
never at the risk of clearing a real timer). Also fixes Totemic Recall
wiping or wasting itself around a press that couldn't go out or one queued
behind the global cooldown, Sentry Totem's timer expiring early, the
seventh Air totem being unreachable, Windwall's range tint sticking on
with Strength of Earth also up, the pulse ring jumping on another
shaman's tick, the out-of-range tint missing most of a set under pfUI, and
the duration ring double-counting with Totemic Mastery talented.
- **v0.2.5** (2026-07-25) — new option **"Show drop-all button"**: hide the
drop-all-totems button if you fire it from a keybind or macro and would rather
not spend a slot on it. The keybind keeps working while it's hidden, and in the
@@ -100,6 +110,16 @@ server data, not guesswork. No dependencies; adopts the
- Optional countdown text under each slot; totems tint red when you leave
their range (buff-presence based), and the Recall button pulses while
anything is out of range.
- **Totems you cannot afford are dimmed** — buttons and flyout, from the real
cost of your highest known rank; the dim composes with the range tint instead
of overwriting it, and stands down while Clearcasting is up.
- **Countdowns only start for casts that could go out** — a press refused for
mana or a real cooldown is not tracked, so no phantom timer and no wasted
Totemic Recall on an empty board. A second check catches what that cannot
predict (movement, a stun, a server-side refusal) and takes the countdown
back after the fact — narrowly filtered to the totem just pressed, so it
stays silent whenever it can't be sure rather than risk clearing a timer
for a totem that is actually still out.
- Native cooldown swipes and real in-game spell tooltips everywhere.
**Control**
@@ -190,7 +210,7 @@ timers as an extra source) and **SuperWoW** (only used by the `/tb scan` /
- Public API for companion addons: [`docs/API.md`](docs/API.md).
- Pure-logic modules under `core/` are unit-tested offline against a real
Lua 5.0.3 interpreter (`tools/luatests/`, 14 suites). WoW-API files are
Lua 5.0.3 interpreter (`tools/luatests/`, 19 suites). WoW-API files are
syntax-checked; in-game behavior is verified on TurtleWoW.
- All textures are **generated**: `tools/gen_*.js` (zero-dependency Node)
render the ring/FX/icon/panel sheets as 1.12-safe TGAs, and
+1 -1
View File
@@ -2,7 +2,7 @@
## Title: TotemBar
## Notes: Shaman totem bar - pick one totem per element, cast it, or spam-cycle all four from a macro.
## Author: ShempError
## Version: 0.2.5
## Version: 0.3.0
## SavedVariables: TotemBarDB
core\totemdata.lua
+13 -11
View File
@@ -62,20 +62,22 @@ function TotemBar.CastElement(element)
end
end
-- Cast Totemic Recall and clear own-tracking (mirrors the Recall button).
-- Cast Totemic Recall and clear own-tracking. Runs the exact same
-- TotemBar.manualRecall (core/cast.lua) as the Recall button's left-click, so
-- the keybind and the button can never diverge: it owns the "nothing is out"
-- gate, the "is Recall even castable right now" check (a press that cannot go
-- out must NOT wipe the timers of totems still standing), the queue-safe cast
-- that keeps nampower from firing a late Recall into a fresh set, the refund
-- snapshot and the own-tracking wipe. Only the chat feedback lives here.
function TotemBar.CastRecall()
-- Don't waste Totemic Recall's 6s cooldown when nothing is out -- but only block when we
-- are CONFIDENT of that (tracked totems this session, all expired). After a /reload the
-- tracking is empty and 1.12 has no way to re-detect a pre-reload totem, so confidentNoneOut
-- fails open there and lets the recall through (see core/cast.lua).
if TotemBar.confidentNoneOut and TotemBar.confidentNoneOut() then
ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown).")
if not TotemBar.manualRecall then
return
end
CastSpellByName("Totemic Recall")
if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end
if TotemBar.clearActiveTotems then
TotemBar.clearActiveTotems()
local action = TotemBar.manualRecall()
if action == "none-out" then
ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown). Press again to recall anyway.")
elseif action == "cooldown" then
ChatOut:AddMessage("TotemBar: Totemic Recall isn't ready yet - totem timers kept.")
end
end
+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
+1 -1
View File
@@ -55,7 +55,7 @@ function TotemBar.ensureDefaults()
TotemBarDB.recallRefundPct = TotemBarDB.recallRefundPct or 0.25
TotemBarDB.buttonGap = TotemBarDB.buttonGap or TotemBar.DEFAULT_BUTTON_GAP
-- Pulse UI (spec docs/superpowers/specs/2026-07-09-pulse-ui-design.md):
-- Pulse UI:
-- duration ring + pulse bars, all on by default; ringStyle "round" vs
-- "square" is the in-game comparison toggle.
if TotemBarDB.showDurationRing == nil then
+61
View File
@@ -59,6 +59,52 @@ function TotemBar.sumActiveCost(activeTotems, elements, now, costFn, remainingFn
return total
end
-- Pure: is a KNOWN mana shortfall in front of us? Both halves must be known
-- numbers -- an unresolved cost (the normal state until the tooltip scan runs,
-- and permanently for a totem that isn't in this character's book) is "I don't
-- know", never "too expensive". Greying a bar on "I don't know" would grey the
-- whole bar at login; refusing to record a timer on it would silently drop the
-- countdown of a totem that is standing. Both callers therefore fail OPEN here,
-- the same policy as confidentNoneOut/recallReady in core/cast.lua.
function TotemBar.notEnoughMana(cost, mana)
if not cost or not mana then
return false
end
return mana < cost
end
-- Clearcasting (Elemental Focus) makes the next damage spell free while the
-- tooltip still shows the full price, so every mana verdict has to stand down
-- while it is up -- otherwise the bar greys out a totem that casts fine and the
-- cast gate drops its timer.
--
-- Matched by buff ICON, like the out-of-range tint's totem detection (see
-- TotemBar.hasBuffWithIcon): 1.12 gives no buff NAME without a tooltip scan per
-- buff per check. VERIFY in-game -- if this icon is wrong the only effect is
-- that the two features lose their Clearcasting exemption, not that they break.
TotemBar.CLEARCAST_ICON = "Spell_Shadow_ManaBurn"
function TotemBar.hasClearcasting()
if not TotemBar.hasBuffWithIcon then
return false
end
return TotemBar.hasBuffWithIcon(TotemBar.CLEARCAST_ICON)
end
-- Live mana verdict for one totem by name: true only when its cost is known,
-- the player's mana is below it, and no Clearcasting is up. Reads the cached
-- tooltip cost (see getTotemManaCost), so this is a table lookup plus one
-- UnitMana call -- cheap enough for the 5Hz display tick that calls it.
function TotemBar.totemOutOfMana(name)
if not name or type(UnitMana) ~= "function" then
return false
end
if TotemBar.hasClearcasting() then
return false
end
return TotemBar.notEnoughMana(TotemBar.getTotemManaCost(name), UnitMana("player"))
end
-- Pure: floored refund.
function TotemBar.refundAmount(pct, activeCost)
if not pct or not activeCost then
@@ -105,6 +151,21 @@ function TotemBar.isHelpfulTotem(name)
return true
end
-- Pure: brings a pfUI-libtotem-reported duration onto the same scale as the
-- one recordCast stores. libtotem serves FLAT book durations; own tracking
-- applies Totemic Mastery's +20% to helpful totems. ui.lua's timer prefers
-- libtotem while it reports the slot active and falls back to own tracking
-- once libtotem evicts it -- so at the book duration the display flipped
-- source and the countdown jumped back UP (...3, 2, 1, 24) with the duration
-- ring re-filling, instead of running out. Scaling both sides by the same
-- factor makes them agree on the endpoint.
function TotemBar.gtiDurationWithMastery(gtiDuration, gtiName, hasMastery)
if not gtiDuration then
return nil
end
return TotemBar.durationWithMastery(gtiDuration, TotemBar.isHelpfulTotem(gtiName), hasMastery)
end
-- ===== WoW-API layer (not offline-executed) =====
-- TotemBar.findHighestRankSlot(name) used to be defined here as its own
+24
View File
@@ -24,6 +24,30 @@ function TotemBar.pulseRatio(placedAt, anchorAt, interval, now)
return math.mod(elapsed, interval) / interval
end
-- How far before the predicted next tick an observed gain may still re-anchor
-- the phase (seconds) -- absorbs normal message/latency jitter.
TotemBar.PULSE_ANCHOR_TOLERANCE = 0.25
-- Pure: may an observed periodic self-gain re-anchor this record's pulse phase?
--
-- The periodic-gain chat lines carry NO caster (1.12's ownerless
-- POWERGAIN/PERIODICAURAHEAL ...SELFSELF templates), so a second shaman's
-- identically-named totem produces byte-identical text to our own. Accepting
-- every message re-stamped the anchor off-beat, roughly twice per interval,
-- and the ripple/pulse arc jumped backwards. Rate-limiting to at most one
-- anchor per interval locks the display onto ONE tick train instead. A record
-- with no anchor yet (fresh cast) always gets its free first stamp, so a
-- recast re-acquires phase immediately.
function TotemBar.shouldAnchorPulse(lastAnchor, interval, now)
if not lastAnchor or not now then
return true
end
if not interval or interval <= 0 then
return true
end
return (now - lastAnchor) >= (interval - TotemBar.PULSE_ANCHOR_TOLERANCE)
end
-- 0..1 single fill from placement to detonation (Fire Nova). Clamped at
-- both ends; callers treat >=1 as "detonated" and hide the bar.
function TotemBar.oneshotRatio(placedAt, delay, now)
+1 -2
View File
@@ -10,8 +10,7 @@
-- values - source="server" records that provenance. verified=false stays
-- reserved for IN-GAME confirmation: /tb pulsecal (core/pulsecal.lua)
-- captures the real timings; once measured in-game, update the value AND
-- flip verified=true. Spec:
-- docs/superpowers/specs/2026-07-09-pulse-ui-design.md section 3.
-- flip verified=true.
--
-- firstTick: "immediate" means the aura's periodic tick fires at t=0 (the
-- totem is on SpellAuras.cpp's CalculatePeriodic exception list);
+10
View File
@@ -113,6 +113,16 @@ function TotemBar.getSpellIndex()
return cachedIndex
end
-- How many spellbook slots the cached scan holds (0 when the book was empty
-- at scan time). Lets a caller tell "this spell is provably NOT known" apart
-- from "we have no usable spellbook data yet" -- the book isn't reliably
-- populated at login, and the cache is built lazily, so a nil findSpellIndex
-- alone can't distinguish the two. Used by core/cast.lua's recordCast.
function TotemBar.spellbookEntryCount()
ensureBuilt()
return table.getn(cachedEntries)
end
-- Mirrors old FindSpellIndexByName (ui.lua) / findSpellIndexByName
-- (cast.lua): first-match spellbook slot, or nil if unknown.
function TotemBar.findSpellIndex(name)
+24
View File
@@ -45,6 +45,24 @@ TotemBar.TOTEMS_BY_ELEMENT = {
},
}
-- Pure: the largest number of totems any single element offers. ui.lua sizes
-- the hover flyout's icon pool from this. Computed from the data on purpose:
-- the pool used to be a hard-coded 6, but Air has SEVEN totems, and with the
-- Air slot empty the flyout lists ALL known totems of the element (not "all
-- minus the chosen default") -- so the 7th, Tranquil Air Totem, was silently
-- dropped and could not be cast or made default from the bar.
function TotemBar.maxTotemsPerElement()
local maxN = 0
for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
local list = TotemBar.TOTEMS_BY_ELEMENT[TotemBar.TOTEM_ELEMENTS[i]]
local n = (list and table.getn(list)) or 0
if n > maxN then
maxN = n
end
end
return maxN
end
-- Build the reverse lookup (totem name -> element) once at load time.
local elementByName = {}
for elemIdx = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
@@ -110,6 +128,12 @@ TotemBar.TOTEM_DURATIONS = {
["Grounding Totem"] = 45,
["Grace of Air Totem"] = 120,
["Nature Resistance Totem"] = 120,
-- 5 minutes (spell 6495, TurtleWoW 1.17.2 spell DB). Was missing here
-- entirely and fell through to the 120s default -- but that fallback is
-- meant as a safe OVERestimate, and for Sentry it cut the timer 180s
-- short. pfUI's libtotem has no Sentry entry either, so GetTotemInfo
-- could not correct it.
["Sentry Totem"] = 300,
["Tranquil Air Totem"] = 120,
["Windfury Totem"] = 120,
["Windwall Totem"] = 120,
+125 -61
View File
@@ -65,8 +65,7 @@ local EMPTY_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
local RECALL_SPELL_NAME = "Totemic Recall"
local RECALL_ICON_FALLBACK = "Interface\\Icons\\Spell_Nature_AstralRecal"
-- Pulse UI Rev 2 - floating round icons + ripple pulse (spec
-- docs/superpowers/specs/2026-07-09-pulse-ui-design.md, section "Rev 2").
-- Pulse UI - floating round icons + ripple pulse.
-- Ring flipbook: 8x8 grid texture (64 cells). Cells 1..62 = duration-arc
-- fill frames (0=empty .. 61=full), cell 63 = wave ring (ripple), cell 64 =
-- the decorative frame band - always shown, hides the icon's square
@@ -172,7 +171,11 @@ local anyOutOfRange = false
-- cast it ONCE without changing the slot's default; right-click one to
-- make it the slot's new default. Shares one frame + a pool of icon
-- buttons.
local MAX_FLYOUT_ICONS = 6 -- most totems any single element has
-- Computed from the static totem map, not hard-coded: Air has SEVEN totems,
-- and a hard-coded 6 silently dropped the last one (Tranquil Air Totem)
-- whenever the Air slot was empty -- the flyout then lists ALL known totems of
-- the element, not "all minus the chosen default".
local MAX_FLYOUT_ICONS = TotemBar.maxTotemsPerElement() -- most totems any single element has
local FLYOUT_PAD = 4 -- inner padding inside the flyout frame
local FLYOUT_GAP = 2 -- gap between element button top and flyout bottom
local FLYOUT_HIDE_INTERVAL = 0.1 -- throttle for the mouse-leave hide check
@@ -191,6 +194,7 @@ local RefreshCooldown
local EnsureFlyoutFrame
local ShowFlyout
local RefreshFlyoutCooldowns
local RefreshFlyoutMana
local HideFlyout
local OnFlyoutUpdate
local CreateElementButton
@@ -667,6 +671,7 @@ ShowFlyout = function(button, element)
if TotemBar.refreshBindOverlays then
TotemBar.refreshBindOverlays()
end
RefreshFlyoutMana()
end
-- Re-drives the cooldown swipe on every CURRENTLY-SHOWN pooled flyout
@@ -694,6 +699,33 @@ RefreshFlyoutCooldowns = function()
end
end
-- Dims every shown flyout icon the player cannot currently afford, on the same
-- rule as the bar buttons (TotemBar.iconTintFor / notEnoughMana). Flyout icons
-- have no range state of their own -- they are alternatives that aren't out --
-- so only the mana half applies. Cheap: the cost lookup is cached, and the
-- flyout only exists while it is hovered.
RefreshFlyoutMana = function()
if not (flyoutFrame and flyoutFrame:IsShown()) then
return
end
local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil
local clearcasting = TotemBar.hasClearcasting and TotemBar.hasClearcasting()
for i = 1, MAX_FLYOUT_ICONS do
local ico = flyoutIcons[i]
if ico:IsShown() and ico.totemName then
local oom = false
if not clearcasting then
oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(ico.totemName), playerMana)
end
local r, g, b, key = TotemBar.iconTintFor(false, oom)
if key ~= ico.tintKey then
ico.icon:SetVertexColor(r, g, b)
ico.tintKey = key
end
end
end
end
HideFlyout = function()
if flyoutFrame then
flyoutFrame:Hide()
@@ -713,6 +745,7 @@ OnFlyoutUpdate = function()
return
end
flyoutElapsed = 0
RefreshFlyoutMana()
if flyoutOwnerButton and (MouseIsOver(flyoutFrame) or MouseIsOver(flyoutOwnerButton)) then
return
end
@@ -890,7 +923,8 @@ CreateElementButton = function(element, index)
btn.timerVisible = false -- cached shown-state, avoid redundant Show/Hide
btn.timerLastText = nil -- cached last string, avoid redundant SetText
btn.timerLastLow = nil -- cached last <=5s tint state, avoid redundant SetTextColor
btn.tintRed = false -- cached out-of-range tint state, avoid redundant SetVertexColor
btn.tintRed = false -- last out-of-range verdict (read by the Recall button's pulse)
btn.tintKey = 0 -- cached composed tint (range + out-of-mana), avoid redundant SetVertexColor
-- Set here (ahead of the later btn.element = element below) because
-- ApplyRoundFrame, called at the end of this block, needs it for the
@@ -1222,21 +1256,22 @@ CreateRecallButton = function(index)
ChatOut:AddMessage("TotemBar: auto-recall before setting OFF")
end
RefreshRecallIndicator()
elseif TotemBar.confidentNoneOut and TotemBar.confidentNoneOut() then
-- Confidently nothing out (tracked this session, all expired): don't waste
-- Totemic Recall's 6s cooldown on a no-op cast. After a /reload the tracking is
-- empty and 1.12 can't re-detect a pre-reload totem, so confidentNoneOut fails
-- open there and the recall goes through the else branch below (see core/cast.lua).
ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown).")
else
-- Snapshot for the refund learner runs AFTER the cast: activeTotems
-- is still populated here (clearActiveTotems runs last).
CastSpellByName(RECALL_SPELL_NAME)
if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end
-- Totemic Recall drops every active totem at once; clear our
-- own-tracking timers so the icons' countdowns disappear too
-- (GetTotemInfo, if present, will also reflect this).
TotemBar.clearActiveTotems()
-- Shared with the TOTEMBAR_RECALL keybind (bind.lua) so the two manual
-- paths cannot drift apart: TotemBar.manualRecall does the gate, the
-- cooldown check, the queue-safe cast, the refund snapshot and the
-- own-tracking wipe (see core/cast.lua). Only the chat feedback is ours.
local action = TotemBar.manualRecall()
if action == "none-out" then
-- Confidently nothing out (cast something this session, all expired):
-- don't waste Totemic Recall's 6s cooldown on a no-op cast. After a
-- /reload the session flag is clear and 1.12 can't re-detect a pre-reload
-- totem, so the gate fails open there. Pressing again also overrides it,
-- for a totem we cannot see (e.g. dropped from the action bar).
ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown). Press again to recall anyway.")
elseif action == "cooldown" then
ChatOut:AddMessage("TotemBar: Totemic Recall isn't ready yet - totem timers kept.")
end
end
end)
@@ -1353,6 +1388,12 @@ UpdateTimerDisplays = function()
local activeTotems = TotemBar.activeTotems
local outOfRangeFound = false -- OR-accumulator across this pass; written to anyOutOfRange at the end
-- Mana inputs for the out-of-mana dim, read ONCE per pass rather than per
-- button: hasClearcasting walks the player's buffs, and there is no reason
-- to do that four times for one tick.
local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil
local clearcasting = TotemBar.hasClearcasting and TotemBar.hasClearcasting()
for i = 1, table.getn(elements) do
local element = elements[i]
local btn = elementButtons[element]
@@ -1394,9 +1435,21 @@ UpdateTimerDisplays = function()
gtiActive = active
gtiName = tname
gtiStart = start
gtiDuration = duration
if start and duration then
gtiRemaining = TotemBar.remaining(start, duration, now)
-- Mastery parity: libtotem reports FLAT book durations, own
-- tracking stores the Totemic Mastery-inflated one. Without
-- scaling GTI by the same factor the timer flips source at the
-- book duration and the countdown jumps back UP instead of
-- running out (see TotemBar.gtiDurationWithMastery).
gtiDuration = TotemBar.gtiDurationWithMastery(duration, tname,
TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery())
if start and gtiDuration then
gtiRemaining = TotemBar.remaining(start, gtiDuration, now)
end
-- Latch: did libtotem ever report THIS record's totem active?
-- Only then may a later "slot inactive" veto the range tint
-- below (TotemBar.rangeTintActive).
if ownRecord and gtiActive and gtiName == ownRecord.totemName then
ownRecord.gtiTracked = true
end
end
@@ -1517,52 +1570,51 @@ UpdateTimerDisplays = function()
-- buff whose texture matches this totem's icon?".
--
-- ACTIVE requires an own-tracking record (ownRecord, already
-- nil'd above once its stored duration expires) AND, when
-- pfUI's libtotem is present, GetTotemInfo(i) agreeing the
-- slot is still active. That second check is what keeps a
-- totem someone/something DESTROYED (burned, killed) before
-- its timer ran out from flashing red - once GTI says the
-- slot is gone, we just go back to normal, not red.
local rangeActive = (ownRecord ~= nil)
if rangeActive and hasGTI and not gtiActive then
rangeActive = false
end
-- nil'd above once its stored duration expires). pfUI's libtotem
-- may veto it - that is what keeps a totem someone/something
-- DESTROYED (burned, killed) before its timer ran out from
-- flashing red - but ONLY for a slot libtotem actually tracked
-- (gtiTracked, latched above). Its single cast queue records at
-- most one totem per multi-drop, so vetoing on a merely MISSING
-- GTI record silenced the tint and the Recall pulse for most of
-- a freshly dropped set. See TotemBar.rangeTintActive.
local rangeActive = TotemBar.rangeTintActive(ownRecord ~= nil, hasGTI,
ownRecord and ownRecord.gtiTracked, gtiActive)
if not rangeActive then
if btn.tintRed then
btn.icon:SetVertexColor(1, 1, 1)
btn.tintRed = false
end
else
local hasBuff = TotemBar.hasBuffWithIcon(ownRecord.icon)
if hasBuff then
local rangeRed = false
if rangeActive then
if TotemBar.hasBuffWithIcon(ownRecord.icon) then
-- In range: remember it (self-learning - marks this
-- as a buff totem so a later drop-off can be told
-- apart from a totem that simply never grants one).
ownRecord.everHadBuff = true
if btn.tintRed then
btn.icon:SetVertexColor(1, 1, 1)
btn.tintRed = false
end
elseif ownRecord.everHadBuff then
-- Had the buff earlier from this cast, don't have it
-- now: wandered out of the totem's range.
if not btn.tintRed then
btn.icon:SetVertexColor(1, 0.35, 0.35)
btn.tintRed = true
end
else
-- Either a non-buff totem (Searing/Magma/Grounding/
-- etc. never grant a matching buff, so everHadBuff
-- stays false forever -> never red) or we simply
-- haven't been in range yet since this cast.
if btn.tintRed then
btn.icon:SetVertexColor(1, 1, 1)
btn.tintRed = false
end
-- now: wandered out of the totem's range. (A non-buff
-- totem - Searing/Magma/Grounding - never sets
-- everHadBuff, so it can never turn red here.)
rangeRed = true
end
end
-- Out-of-mana dim for the totem THIS slot would cast (the chosen
-- one, not the record's - the record is a totem already standing,
-- which costs nothing to keep). Composed with the range tint in one
-- place: two independent SetVertexColor call sites would race, and
-- whichever ran last would win (see TotemBar.iconTintFor).
local chosenName = TotemBarDB.chosen and TotemBarDB.chosen[element]
local oom = false
if chosenName and not clearcasting then
oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(chosenName), playerMana)
end
local tr2, tg2, tb2, tintKey = TotemBar.iconTintFor(rangeRed, oom)
if tintKey ~= btn.tintKey then
btn.icon:SetVertexColor(tr2, tg2, tb2)
btn.tintKey = tintKey
end
btn.tintRed = rangeRed
if btn.tintRed then
outOfRangeFound = true
end
@@ -1788,9 +1840,13 @@ function TotemBar.DumpRingRenderState()
if hasGTI then
local ok, active, tname, start, duration = pcall(GetTotemInfo, i)
if ok then
gtiActive, gtiDuration = active, duration
if start and duration then
gtiRemaining = TotemBar.remaining(start, duration, now)
-- Same mastery scaling UpdateTimerDisplays applies, so this
-- dump keeps reporting what the render path would compute.
gtiActive = active
gtiDuration = TotemBar.gtiDurationWithMastery(duration, tname,
TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery())
if start and gtiDuration then
gtiRemaining = TotemBar.remaining(start, gtiDuration, now)
end
end
end
@@ -2530,7 +2586,15 @@ eventFrame:SetScript("OnEvent", function()
local element = TotemBar.elementOf(totemName)
local rec = element and TotemBar.activeTotems[element]
if rec and rec.totemName == totemName then
rec.pulseAnchor = GetTime()
-- These lines carry no caster, so another shaman's
-- identically-named totem is indistinguishable from ours.
-- Accept at most one anchor per pulse interval so a foreign
-- tick train can't drag our phase off-beat.
local pd = TotemBar.pulseInfo(totemName)
local nowGain = GetTime()
if TotemBar.shouldAnchorPulse(rec.pulseAnchor, pd and pd.interval, nowGain) then
rec.pulseAnchor = nowGain
end
end
end
end