TotemBar v0.3.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user