From 166d870dec1b7582810df807fdcd6792f4123d96 Mon Sep 17 00:00:00 2001 From: ShempError <689+shemperror@noreply.octowow.st> Date: Mon, 17 Aug 2026 15:19:36 +0200 Subject: [PATCH] TotemBar v0.3.0 --- CHANGELOG.md | 87 ++++++ README.md | 22 +- TotemBar.toc | 2 +- bind.lua | 24 +- core/cast.lua | 662 ++++++++++++++++++++++++++++++++++++++++++-- core/config.lua | 2 +- core/manacost.lua | 61 ++++ core/pulse.lua | 24 ++ core/pulsedata.lua | 3 +- core/spellindex.lua | 10 + core/totemdata.lua | 24 ++ ui.lua | 186 +++++++++---- 12 files changed, 1009 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b2c568..222e171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9c25184..8ea3e16 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TotemBar.toc b/TotemBar.toc index dc92068..a433096 100644 --- a/TotemBar.toc +++ b/TotemBar.toc @@ -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 diff --git a/bind.lua b/bind.lua index b923c91..3cc27d9 100644 --- a/bind.lua +++ b/bind.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 diff --git a/core/cast.lua b/core/cast.lua index f991a1b..fb3a797 100644 --- a/core/cast.lua +++ b/core/cast.lua @@ -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 diff --git a/core/config.lua b/core/config.lua index 1dfff24..1dd8f56 100644 --- a/core/config.lua +++ b/core/config.lua @@ -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 diff --git a/core/manacost.lua b/core/manacost.lua index c36c767..3fb3723 100644 --- a/core/manacost.lua +++ b/core/manacost.lua @@ -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 diff --git a/core/pulse.lua b/core/pulse.lua index 835e3ce..55f75d4 100644 --- a/core/pulse.lua +++ b/core/pulse.lua @@ -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) diff --git a/core/pulsedata.lua b/core/pulsedata.lua index 60462b6..ab238d5 100644 --- a/core/pulsedata.lua +++ b/core/pulsedata.lua @@ -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); diff --git a/core/spellindex.lua b/core/spellindex.lua index 0eda29e..9eeacc2 100644 --- a/core/spellindex.lua +++ b/core/spellindex.lua @@ -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) diff --git a/core/totemdata.lua b/core/totemdata.lua index d55bf5c..f9d19ac 100644 --- a/core/totemdata.lua +++ b/core/totemdata.lua @@ -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, diff --git a/ui.lua b/ui.lua index d92ba80..a9c7211 100644 --- a/ui.lua +++ b/ui.lua @@ -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