-- TotemBar - core/cast.lua -- Cast-cycle logic. The decision-making is split into two PURE, -- offline-tested functions (nextIndex, findFilledSlot); castNext() is -- the thin wrapper that touches CastSpellByName / GetTime / TotemBarDB. TotemBar = TotemBar or {} TotemBar.DEFAULT_GAP_SECONDS = 2 -- Anti double-press guard for recallAndCastAll: if a deploy happened within -- this many seconds, a rapid second press SKIPS the recall (so it doesn't -- pull the just-placed totems, which are still on their ~1.5s element -- 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 -- persists them to TotemBarDB.buttonGap (core/config.lua). TotemBar.DEFAULT_BUTTON_GAP = 10 -- Destroyed-totem GUID-liveness poll interval (seconds). Throttled -- independently of ui.lua's faster 0.1s display tick (see -- UpdateTimerDisplays) -- UnitExists/UnitHealth/UnitIsDeadOrGhost are cheap -- but there is no reason to call them 10x/sec once a GUID is latched. See -- "Destroyed-totem detection (GUID liveness)" below for the full design. TotemBar.GUID_LIVENESS_POLL_INTERVAL = 0.5 -- Cycle state: which slot was cast last, and when. TotemBar.castState = TotemBar.castState or { index = 0, -- 0 = no cast yet (or state was reset) lastTime = 0, lastDeployTime = 0, } -- Own-tracking table for the OmniCC-style remaining-duration display in -- ui.lua: fallback source for when pfUI's libtotem (GetTotemInfo) isn't -- present, or reports a given slot inactive. element -> {start=, -- duration=}, both in TotemBar.recordCast() below. TotemBar.activeTotems = TotemBar.activeTotems or {} -- element -> the GetTime() a destroy-evicted record would naturally have -- expired at (its own start+duration). Set by ui.lua's liveness poll the -- moment it evicts a destroyed totem; read by resolveRemaining (via -- TotemBar.tombstoneActive) to stop trusting pfUI's libtotem for that -- element until then -- libtotem is ALSO just a blind duration timer (see -- "Destroyed-totem detection (GUID liveness)" below), so without this it -- keeps reporting the destroyed totem active and the countdown/ring/pulse -- reappear from the GTI branch even though activeTotems[element] was -- correctly cleared. Cleared the moment a real recordCast lands on the -- element again (see recordCast below) or once GetTime() passes the -- stored expiry (ui.lua sweeps it then) -- bounded to at most 4 keys -- either way, never a growing table. TotemBar.destroyedTombstone = TotemBar.destroyedTombstone or {} -- Pure: given the previously cast slot index, the time of that previous -- cast, the current time, the allowed gap (seconds) and the number of -- slots, returns the next slot index (1-based) to advance to. -- -- - prevIndex <= 0 (never cast yet) -> 1 -- - now - lastTime > gapSeconds -> 1 (fresh spam, start over) -- - otherwise -> prevIndex + 1, wrapping from -- numSlots back to 1 function TotemBar.nextIndex(prevIndex, lastTime, now, gapSeconds, numSlots) if not prevIndex or prevIndex <= 0 then return 1 end if not lastTime or (now - lastTime) > gapSeconds then return 1 end local nxt = prevIndex + 1 if nxt > numSlots then nxt = 1 end return nxt end -- Pure: starting at startIndex, walk forward through `elements` -- (wrapping past the end back to 1) and return the index of the first -- element for which chosen[element] is truthy (a totem name). Returns -- nil if none of the slots are filled. function TotemBar.findFilledSlot(chosen, elements, startIndex) local numSlots = table.getn(elements) if numSlots == 0 or not startIndex then return nil end for tries = 0, numSlots - 1 do local slot = startIndex + tries if slot > numSlots then slot = slot - numSlots end if chosen[elements[slot]] then return slot end end return nil end -- Pure: seconds remaining given a start time, a duration and the -- current time. May return <= 0 (already expired); callers decide how -- to treat that. Returns nil if either start or duration is missing. function TotemBar.remaining(start, duration, now) if not start or not duration then return nil end return start + duration - now end -- Pure: is any element's own-tracked totem still out (remaining > 0)? function TotemBar.anyActiveTracked(activeTotems, elements, now, remainingFn) if not activeTotems then return false end for i = 1, table.getn(elements) do local rec = activeTotems[elements[i]] if rec then local rem = remainingFn(rec.start, rec.duration, now) if rem and rem > 0 then return true end end end return false end -- Pure: decides which of two already-computed remaining-seconds values -- to show for one element's timer text. GetTotemInfo (pfUI's -- libtotem), when it reports the slot active, is authoritative; -- otherwise (absent, or reporting the slot inactive) falls back to -- TotemBar's own cast-tracking. Returns nil when neither source has -- time left. function TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining, tombstoned) -- FIX 2026-07-15: trust GTI (pfUI libtotem) ONLY when it reports the slot active -- AND with positive time left. Previously a stale-active GTI slot (active but -- gtiRemaining<=0 -- libtotem evicts lazily on read) hit the bare `return nil` and -- BLOCKED the timer even when own-tracking had a valid time. That produced the -- Fire/Earth-dead, Air-flickering, Water-ok pattern (pfUI's single non-slot-indexed -- cast queue loses the race for the slots cast later in a multi-drop). -- -- FIX (destroyed-totem review, adversarial-review finding #1): `tombstoned` -- (see TotemBar.tombstoneActive/TotemBar.destroyedTombstone) forces this to -- skip the GTI branch entirely for a totem this addon just evicted as -- destroyed. Without it, a destroy-eviction cleared ownRemaining/ownRecord -- correctly but pfUI's libtotem -- itself just another blind duration timer, -- see the header comment above TotemBar.shouldLatchGuidFromCastEvent below -- -- kept reporting the SAME destroyed totem active until ITS OWN timer ran -- out, so the countdown/ring/pulse reappeared from the GTI branch a moment -- after this addon had just cleared them. tombstoned=nil/false (every -- existing call site before this fix) reproduces the old behaviour exactly. if not tombstoned and gtiActive and gtiRemaining and gtiRemaining > 0 then return gtiRemaining end if ownRemaining and ownRemaining > 0 then return ownRemaining end return nil end -- Pure: is element's destroyed-tombstone still in effect? tombstoneExpiry is -- TotemBar.destroyedTombstone[element] (nil if none), the GetTime() the -- destroy-evicted record would naturally have expired at. Strictly less-than: -- at the exact expiry moment GTI is trusted again (by then GTI's own blind -- timer would have run out too, so there is nothing left to falsely resurrect). function TotemBar.tombstoneActive(tombstoneExpiry, now) if not tombstoneExpiry or not now then return false end return now < tombstoneExpiry 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 -- ===== Destroyed-totem detection (GUID liveness) ===== -- -- Everything above (rangeTintActive included) can tell a totem is out of -- the PLAYER's own buff range, but nothing in this file -- nor pfUI's -- libtotem (checked against its source, pfUI/libs/libtotem.lua: -- GetTotemInfo's "active" flag is ALSO just a blind start+duration timer, -- cleared only on natural expiry or PLAYER_DEAD, never on the totem -- actually dying) -- can tell that a totem was DESTROYED (killed by an -- enemy, an AoE, a dispel) before its timer ran out. Fixing that needs the -- totem's own GUID, which recordCast cannot know at cast time (1.12 gives -- no return value naming the newly summoned unit); it is latched -- OPPORTUNISTICALLY after the fact by the event hooks further below, once a -- GUID becomes observable through the client's own SuperWoW telemetry: -- -- 1. UNIT_CASTEVENT (SuperWoW): a totem that periodically CASTS its -- pulse (Fire Nova, Magma, Mana Spring, Healing Stream, Mana Tide, the -- Cleansing totems, ...) fires this with its own GUID as casterGUID -- the first time it pulses -- the same event core/pulsecal.lua already -- observes for calibration telemetry; this just also keeps the GUID. -- COVERAGE: pulsing totems only -- a totem that never casts anything -- never latches through this path. CONFIRMED (2026-08-20 event tap) -- that casterGUID (arg1) resolves via UnitName to the TOTEM's own name -- -- but the pulse SPELL's own name (arg4/SpellInfo) does NOT equal the -- totem's name (a Searing Totem's pulse is "Searing Bolt", Healing -- Stream Totem's is "Healing Stream") -- the original elementFromCastName -- exact-match-on-spell-name mapping was structurally unable to latch -- ANYTHING. Fixed: latch now maps through UnitName(arg1) (rank-tolerant, -- TotemBar.totemNameMatches), not the spell name at all. Also gated on -- the caster's resolved "owner" matching the player -- (TotemBar.shouldLatchGuidFromCastEvent) -- see that function's own -- comment for why (a stranger's same-named totem must never latch -- here); "owner" resolving for a SuperWoW casterGUID is still -- UNVERIFIED (the tap did not include another shaman's totem nearby). -- 2. UNIT_MODEL_CHANGED (native 1.12): fallback for totems that never -- cast anything visible (Stoneskin, Strength of Earth, Grace of Air, -- Windwall, Sentry, Grounding-until-triggered). CONFIRMED (2026-08-20 -- event tap) that it fires on totem spawn with UnitName(unit) resolving -- to the totem's own RANKED name ("Searing Totem VI", "Magma Totem -- IV" -- single-rank totems like Tremor Totem carry no suffix at all) -- -- the original exact-match against the bare book name could therefore -- never latch a ranked totem. Fixed via TotemBar.totemNameMatches -- (rank-tolerant). Whether "owner" resolves the totem's owner on -- this client is still UNVERIFIED -- the handler stays fail-open (any -- missing piece just means no latch, never a false one). -- -- Neither latch path is guaranteed to fire for every totem -- a record that -- never gets a rec.guid simply never runs the liveness poll below and keeps -- today's blind-timer behaviour, UNLESS the GUID-free destruction signals -- below (CHAT_MSG_COMBAT_FRIENDLY_DEATH / UNIT_DIED fallback) catch it -- instead -- see "Rank-tolerant name match + destruction signals" further -- down for those and for what the 2026-08-20 tap confirmed/fixed. This is -- additive, never a regression, and needs zero SuperWoW/nampower presence -- checks of its own: every path that touches a WoW global here already -- guards it. -- Pure: given a UNIT_CASTEVENT's resolved totem name (UnitName(casterGUID) -- -- the totem UNIT's own name, ranked, NOT the pulse spell's name; see the -- 2026-08-20 event-tap header comment further below for why this is keyed -- off the unit and not SpellInfo(arg4) any more), the element's CURRENT -- tracked record (or nil), the resolved "owner" of the casting unit -- (UnitName(casterGUID .. "owner"), see the UNIT_MODEL_CHANGED pendant -- below for the same idea) and the player's own name, should this event -- latch its casterGUID onto that record? -- -- The owner check exists because totem NAME alone is not unique: another -- shaman standing nearby with the same totem type pulsing at the same -- moment resolves to the SAME element/name match. Without verifying -- ownership, this event could latch a STRANGER's totem GUID onto our own -- element -- and when THEIR totem dies, the liveness poll would evict OUR -- still-standing one (a regression strictly worse than not having this -- feature at all). ownerName/playerName may be nil (out of range, -- unsupported "owner" token, ...) -- that fails CLOSED, i.e. no -- latch, same policy as everywhere else in this file: a totem that never -- gets a guid just keeps today's blind-timer behaviour, which can never -- falsely evict. -- -- Otherwise latches for the exact totem currently tracked (rank-tolerant, -- TotemBar.totemNameMatches -- the ranked unit name vs. the bare book name), -- and only once -- a record that already has a guid keeps it (the first -- pulse that resolves it wins; re-checking a later pulse from the SAME -- totem would just repeat the same GUID anyway). function TotemBar.shouldLatchGuidFromCastEvent(rec, castTotemName, ownerName, playerName) if not rec or rec.guid then return false end if not castTotemName or not ownerName or not playerName then return false end if not TotemBar.totemNameMatches(rec.totemName, castTotemName) then return false end return ownerName == playerName end -- Pure: given a UNIT_MODEL_CHANGED unit's resolved name (ranked, e.g. -- "Searing Totem VI" -- see the 2026-08-20 event-tap header comment further -- below), its resolved "owner" name and the player's own name, should the -- caller try to latch a GUID onto `rec` (the element whose totemName -- rank-tolerantly matches unitName, TotemBar.totemNameMatches)? Every input -- may be nil/empty; missing information never latches -- fail open, same -- policy as every other gate in this file. function TotemBar.shouldLatchGuidFromModelChanged(rec, unitName, ownerName, playerName) if not rec or rec.guid then return false end if not unitName or not ownerName or not playerName then return false end if not TotemBar.totemNameMatches(rec.totemName, unitName) then return false end return ownerName == playerName end -- Pure: normalizes a GUID string for tolerant comparison -- lowercased, and -- with a leading "0x"/"0X" prefix stripped if present. UNIT_CASTEVENT and -- UNIT_DIED are not guaranteed to format GUIDs identically (only confirmed -- that SOME SuperWoW events use a "0x"-prefixed hex string); comparing -- normalized forms avoids a silent non-match between the two sources. function TotemBar.normalizeGuid(guid) if not guid then return nil end if type(guid) ~= "string" then guid = tostring(guid) end local lowered = string.lower(guid) local _, _, stripped = string.find(lowered, "^0x(.+)$") return stripped or lowered end -- Pure: do two GUIDs (in whatever raw form each source handed us) refer to -- the same unit? nil on either side never matches. function TotemBar.guidsEqual(a, b) local na = TotemBar.normalizeGuid(a) local nb = TotemBar.normalizeGuid(b) if not na or not nb then return false end return na == nb end -- Pure: which element (if any) currently has a latched rec.guid matching -- rawGuid? Used by the UNIT_DIED handler further below, which -- unlike the -- latch paths above -- must NOT resolve unit names at the moment of death -- (see that handler's own comment for why): the raw GUID is all it has to -- go on. Scans the fixed 4-element table each time rather than keeping a -- separate guid->element map, so there is nothing extra to keep in sync or -- leak. equalFn is injected (TotemBar.guidsEqual in production, the same -- tolerant-comparison function the rest of this module uses) so this stays -- offline-testable without any WoW-side GUID assumptions baked in. function TotemBar.elementForGuid(activeTotems, elements, rawGuid, equalFn) if not activeTotems or not rawGuid then return nil end for i = 1, table.getn(elements) do local element = elements[i] local rec = activeTotems[element] if rec and rec.guid and equalFn(rec.guid, rawGuid) then return element end end return nil end -- Pure: given the raw (exists, health, deadOrGhost) tuple a liveness poll -- just read for a totem's GUID, was the totem destroyed? `exists` gates -- everything else: SuperWoW can only resolve a GUID it currently has in -- range/visibility, so "does not exist" is UNKNOWN (out of range, zoned -- away, simply not yet resolved -- the same class of read any GUID-liveness -- scanner has to guard identically), never "destroyed". Only once the unit -- DOES resolve does a zero health read or UnitIsDeadOrGhost==1 count as a -- verdict. Fails open on the unknown case, same policy as every other gate -- in this file: a totem merely out of visibility keeps its timer running -- exactly like it does today, instead of the poll wrongly clearing a totem -- that is still alive. function TotemBar.totemDestroyed(exists, health, deadOrGhost) if not exists then return false end if health == 0 or deadOrGhost == 1 then return true end return false end -- Pure: should the liveness poll actually touch UnitExists/UnitHealth/ -- UnitIsDeadOrGhost this tick? Throttled to TotemBar.GUID_LIVENESS_POLL_INTERVAL, -- independent of ui.lua's faster 0.1s display tick that calls this every -- pass. nil lastCheckTime (first call) always polls. function TotemBar.shouldPollLiveness(lastCheckTime, now, interval) if not lastCheckTime then return true end if not interval then return true end return (now - lastCheckTime) >= interval end -- Evicts activeTotems[element] as DESTROYED, tombstoning the element so -- resolveRemaining ignores pfUI's libtotem for it until the evicted -- record's own natural expiry (see TotemBar.destroyedTombstone/ -- tombstoneActive above) -- without this, libtotem's OWN blind timer keeps -- reporting the same destroyed totem active and the countdown/ring/pulse -- reappear from the GTI branch a moment after being correctly cleared here. -- Shared by ui.lua's liveness poll and the UNIT_DIED fast-path below, so -- both eviction routes protect the display identically -- not pure (reads/ -- writes the two live tracking tables directly), same category as -- TotemBar.clearActiveTotems further below. function TotemBar.evictDestroyedTotem(element) local rec = TotemBar.activeTotems[element] if not rec then return end if rec.start and rec.duration then TotemBar.destroyedTombstone[element] = rec.start + rec.duration end TotemBar.activeTotems[element] = nil end -- ===== Rank-tolerant name match + destruction signals (2026-08-20 event tap) ===== -- -- A live event tap against the real client (2026-08-20) confirmed and killed -- three of this section's own "UNVERIFIED" caveats above, and supplied the -- fix for each: -- -- (a) UNIT_MODEL_CHANGED's UnitName(unit) is RANKED ("Searing Totem VI"), -- never the bare book name recordCast stores (rec.totemName == -- "Searing Totem") -- shouldLatchGuidFromModelChanged's exact `==` -- compare could never latch a ranked totem at all. Fixed by -- TotemBar.totemNameMatches below (single-rank totems like "Tremor -- Totem" have no suffix and still match via plain equality). -- (b) UNIT_CASTEVENT's resolved SPELL name is NOT the totem's own name -- (a Searing Totem's pulse is "Searing Bolt", Healing Stream Totem's -- is "Healing Stream", ...) -- shouldLatchGuidFromCastEvent's mapping -- through SpellInfo(arg4)/elementFromCastName was structurally unable -- to match. Fixed by mapping through UnitName(arg1) instead (arg1 is -- the TOTEM's own casterGUID) -- the SpellInfo path is no longer -- needed as an additional condition for this latch. -- (c) TurtleWoW emits a totem's own death as -- `CHAT_MSG_COMBAT_FRIENDLY_DEATH` with arg1 literally -- " () is destroyed." -- a GUID-free, PvP-safe primary -- signal that needs neither latch path above to have ever fired. -- TotemBar.parseDestroyedLine/elementForOwnedTotemName below. -- -- Still unconfirmed: live in-game EVICTION on an actual destroy (the tap -- captured spawn/pulse/death lines, not a full addon-in-the-loop retest) -- -- see CHANGELOG.md's Unreleased entry for the precise envelope. -- -- Every totem this addon tracks is stored under its bare spellbook name -- (rec.totemName, set by recordCast from the spellbook/tooltip scan -- see -- recordCast above); TurtleWoW's live client hands back the RANKED display -- name ("Searing Totem VI") for both UNIT_MODEL_CHANGED's UnitName(unit) and -- the destroy/death chat lines below, but a single-rank totem like Tremor -- Totem carries no suffix at all. -- Pure: does `unitName` (a live client name, possibly rank-suffixed) refer -- to the SAME totem `recName` (this addon's own bare spellbook name)? True -- on an exact match, or when unitName is recName plus " " and a trailing -- roman-numeral rank ("Searing Totem VI" matches "Searing Totem"). The rank -- character class ([IVXLC]) matches core/pulseparse.lua's own stripRank -- -- one convention for "what a rank suffix looks like" project-wide, not a -- fresh decision here. Anchored ($) so a totem name that merely STARTS with -- recName ("Searing Totem of Doom") never false-matches: the leftover after -- stripping a trailing roman-numeral token must be nothing else. nil-safe. function TotemBar.totemNameMatches(recName, unitName) if not recName or not unitName then return false end if unitName == recName then return true end local _, _, base = string.find(unitName, "^(.-)%s+[IVXLC]+$") return base == recName end -- Pure: parses TurtleWoW's own totem-death chat line, e.g. -- `Searing Totem VI (Playername) is destroyed.` -> "Searing Totem VI", "Playername". -- Returns nil, nil for any line that doesn't fit the exact shape (including -- the generic vanilla " dies." form -- see parseDiesLine below for -- that one). Lua 5.0: string.find with captures, parens/period escaped -- -- never string.match (nil-call on this interpreter, see project rules). function TotemBar.parseDestroyedLine(line) if not line then return nil, nil end local _, _, name, owner = string.find(line, "^(.-) %((.-)%) is destroyed%.$") return name, owner end -- Pure: parses the generic vanilla " dies." combat line -> "", -- or nil if the line doesn't fit that shape. Deliberately carries no owner -- (vanilla's own death line has none) -- see elementForDiesLineCandidate -- below for why that makes this fallback path need its own, stricter gate. function TotemBar.parseDiesLine(line) if not line then return nil end local _, _, name = string.find(line, "^(.-) dies%.$") return name end -- Pure: which element (if any) has an active, OWN record whose totemName -- rank-tolerantly matches `name`, given that `ownerName` was resolved as -- the player's own name? Used both for the destroyed-line's parsed -- (name, owner) pair (GUID-free -- the destroy chat line alone is enough) -- and UNIT_DIED's GUID-free fallback (see the guidFrame handler below) -- -- same shape, same fail-closed policy as every latch gate in this file: a -- missing/foreign owner or no name match never evicts. Scans the fixed -- 4-element table like elementForGuid above, for the same reason (nothing -- extra to keep in sync). function TotemBar.elementForOwnedTotemName(activeTotems, elements, name, ownerName, playerName) if not activeTotems or not name or not ownerName or not playerName then return nil end if ownerName ~= playerName then return nil end for i = 1, table.getn(elements) do local element = elements[i] local rec = activeTotems[element] if rec and rec.totemName and TotemBar.totemNameMatches(rec.totemName, name) then return element end end return nil end -- Pure: which element (if any) is a CANDIDATE for the generic " dies." -- fallback -- name-matches an active record AND that record already has a -- latched rec.guid? The "dies." line carries no owner, so name alone is not -- enough to trust blindly (an unrelated mob sharing a totem's plain name -- would otherwise evict a live totem); requiring an already-latched GUID -- means the caller can go verify THAT SPECIFIC unit via -- UnitExists/UnitHealth/UnitIsDeadOrGhost (TotemBar.totemDestroyed, same -- verdict function the liveness poll uses) before evicting -- a -- confirmation query, not blind trust in the chat line. Returns the -- candidate only; the caller still has to run that verification. function TotemBar.elementForDiesLineCandidate(activeTotems, elements, name) if not activeTotems or not name then return nil end for i = 1, table.getn(elements) do local element = elements[i] local rec = activeTotems[element] if rec and rec.totemName and rec.guid and TotemBar.totemNameMatches(rec.totemName, name) then return element end end return nil 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. function TotemBar.formatRemaining(remaining) if remaining >= 60 then return string.format("%dm", math.ceil(remaining / 60)) end return string.format("%d", math.ceil(remaining)) end -- Pure: strips a trailing rank suffix from a spell name as CastSpellByName -- would receive it -- both "Searing Totem(Rank 4)" (no space, what the -- client itself produces) and "Searing Totem (Rank 4)" (a space, common in -- hand-typed macros) are accepted forms. Returns the input unchanged if it -- carries no such suffix, or nil if name is nil. function TotemBar.stripRankSuffix(name) if not name then return nil end local _, _, base = string.find(name, "^(.-)%s*%(.-%)%s*$") if base and base ~= "" then return base end return name end -- Pure: given a spell name as CastSpellByName/GetSpellName would produce it -- (optionally rank-suffixed, see stripRankSuffix above), returns the -- element it belongs to and its rank-stripped base name -- or nil, nil if -- rawName is nil or isn't one of TotemBar's known totems (core/totemdata.lua). -- Used by the universal CastSpellByName/CastSpell hooks below to decide -- whether a cast caught outside TotemBar's own paths is a totem at all. function TotemBar.elementFromCastName(rawName) if not rawName then return nil, nil end local baseName = TotemBar.stripRankSuffix(rawName) local element = TotemBar.elementOf(baseName) if not element then return nil, nil end return element, baseName end -- Finds the spellbook index of a known spell by exact name, or nil. -- Used to duplicate ui.lua's own file-local FindSpellIndexByName as its -- own fresh linear scan (no common "api" module to hang a single copy -- 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. -- -- NO CLEARCASTING EXEMPTION (removed 2026-08-28, player-reported bug). This gate -- used to stand down entirely while Clearcasting (Elemental Focus) was up, on the -- assumption that it zeroes the next cast's cost. It does NOT cover totems -- -- Elemental Focus applies to the shaman's damage spells only, so a totem still -- costs full price while Clearcasting is up. Since totems are the only thing this -- addon casts, that exemption disabled the mana gate outright for a shaman who -- crits regularly: the client refused a Magma Totem for lack of mana while the -- timer had already been recorded, so the bar showed a countdown for a totem that -- was never placed. A 6th argument is still ACCEPTED and ignored (Lua drops extra -- args) so no stale caller can quietly re-enable it. function TotemBar.castGateReason(cost, mana, cdStart, cdDuration, gcdMax) if 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) 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 -- rank scan, so it isn't pure; called from both the bar's left-click -- path (ui.lua) and castNext()/castAll() below. -- -- Also stashes the totem spell's icon texture (rec.icon) and a -- self-learning "did I ever see this totem's buff" flag -- (rec.everHadBuff, starts false). ui.lua's out-of-range red-tint -- feature is buff-presence based: a totem's party buff uses the SAME -- icon texture as the totem spell itself (verified in-game), so -- TotemBar.hasBuffWithIcon(rec.icon) tells whether the player is -- currently benefiting from THIS cast totem. 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) end local icon = nil 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(), duration = TotemBar.durationWithMastery( TotemBar.totemDuration(totemName, highestRank), TotemBar.isHelpfulTotem(totemName), TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery()), 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, -- Latched opportunistically by the UNIT_CASTEVENT/UNIT_MODEL_CHANGED -- hooks below (see "Destroyed-totem detection (GUID liveness)"), NOT -- by recordCast itself -- 1.12 gives no return value naming the -- newly summoned unit at cast time. A fresh table every call means a -- re-cast on this element can never inherit a previous totem's guid. guid = nil, } -- 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 -- A REAL cast landing on this element proves whatever this addon -- previously believed was destroyed there is no longer relevant -- -- clear the tombstone immediately so resolveRemaining trusts GTI again -- for this element's new totem right away, instead of waiting out the -- old totem's now-meaningless expiry stamp. Only reached on an actual -- recorded cast (the refused-cast early return above never gets here), -- matching "a new cast clears the tombstone", not "an attempted one". TotemBar.destroyedTombstone[element] = nil -- 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 -- hooks below, UNLESS TotemBar's own code already recorded this EXACT cast -- this same GetTime() tick. Every TotemBar-exposed cast entry point -- (bind.lua's CastTotem/CastElement, castNext/castAll/dropSetKey/ -- recallAndCastAll above, ui.lua's bar/flyout click handlers) already calls -- TotemBar.recordCast() itself right after casting -- verified by a full -- inventory of every Bindings.xml binding, the "Totems" macro, and every -- click handler (2026-07-16): none of them skip it. So a hook firing for a -- cast that ALSO went through one of those paths would otherwise -- double-record in the same frame (harmless -- recordCast just overwrites -- with a near-identical timestamp -- but trivially avoidable with this one -- check, so it is). function TotemBar.recordCastFromHook(element, totemName) local existing = TotemBar.activeTotems[element] if existing and existing.totemName == totemName and existing.start == GetTime() then return end 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), plus a third -- value that is true when the attributed attempt was a Totemic Recall (see -- the recall attempts note on castRecallNoQueue below) -- the caller then -- restores the recall-wiped tracking instead of revoking a single element. -- -- 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 or lastAttempt.recall) then return nil, nil, nil end if not lastAttempt.at or not now or not window or (now - lastAttempt.at) > window then return nil, nil, nil end if message ~= nil and (not allowlist or not allowlist[message]) then return nil, nil, nil end return lastAttempt.element, lastAttempt.name, lastAttempt.recall 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`, -- or another addon. Every path TotemBar itself exposes already calls -- recordCast() (see recordCastFromHook's comment above) so this is a -- safety net, not the primary fix for a "no countdown" report. -- -- Fixed arity on purpose (project rule: no vararg closures) -- both globals -- have a stable, known 1.12 signature: CastSpellByName(name, onSelf), -- CastSpell(spellId, bookType). -- -- Plain global reassignment, not hooksecurefunc -- pfUI provides -- hooksecurefunc as its own polyfill (compat/vanilla.lua; native 1.12 has -- no such function), and TotemBar must not hard-depend on pfUI being -- loaded. Saving whatever function is CURRENTLY bound to the global and -- calling it first (before doing our own extra work) composes correctly -- with pfUI's own libtotem hook on CastSpellByName/CastSpell regardless of -- addon load order: whichever wraps last just adds a layer on top of -- whatever was already there. -- -- UseAction is intentionally NOT hooked. 1.12 has no GetActionInfo / -- GetActionSpellId to read a spell off an action-bar slot directly; pfUI's -- own UseAction coverage (libtotem.lua) only works by scanning a hidden -- GameTooltip:SetAction(slot) via its private libtipscan library, wired up -- through hooksecurefunc -- both the tooltip-scan trick and hooksecurefunc -- are pfUI-internal, not something TotemBar can reimplement without either -- a hard pfUI dependency or a whole new action-slot tooltip scanner (this -- file already has a spellbook-index tooltip scanner for mana costs, see -- core/manacost.lua, but nothing that scans by action slot). Given every -- TotemBar-native path already records, and pfUI's GetTotemInfo already -- 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) 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 end end if type(CastSpell) == "function" then local origCastSpell = CastSpell CastSpell = function(spellId, bookType) local element, baseName = nil, nil if type(GetSpellName) == "function" then 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 then return end -- A failed Totemic Recall undoes the recall's tracking WIPE, not a -- single element's timer (see restoreRecalledTotems below; the -- window check lives there, same role revokeRecentCast's own -- record-age check plays for the element casts). if name == RECALL_SPELL_NAME then TotemBar.restoreRecalledTotems() TotemBar.lastCastAttempt = nil return end if 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, recall = TotemBar.attributeCastFailure(TotemBar.lastCastAttempt, GetTime(), TotemBar.CAST_FAIL_WINDOW, message, castFailureMessages) if recall then -- The attributed attempt was a Totemic Recall: undo the -- recall's tracking wipe instead of revoking one element. TotemBar.restoreRecalledTotems() TotemBar.lastCastAttempt = nil elseif 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 -- ===== GUID latch + destruction signals (destroyed-totem detection) ===== -- Wires the two latch paths, the CHAT_MSG_COMBAT_FRIENDLY_DEATH primary -- signal, and the UNIT_DIED fast-path (GUID match + GUID-free fallback) -- documented in "Destroyed-totem detection (GUID liveness)" / -- "Rank-tolerant name match + destruction signals" above onto -- TotemBar.activeTotems. Every path fails silently (no latch, no evict) on -- a client without SuperWoW/nampower -- this block adds nothing to that -- baseline beyond CHAT_MSG_COMBAT_FRIENDLY_DEATH, which is native 1.12 and -- needs neither. The liveness POLL itself (which also clears a record) lives -- in ui.lua's UpdateTimerDisplays, gated on a record having a rec.guid. if CreateFrame then local guidFrame = CreateFrame("Frame", "TotemBarGuidFrame", UIParent) -- UNIT_MODEL_CHANGED and CHAT_MSG_COMBAT_FRIENDLY_DEATH are native 1.12 -- -- safe to register directly, no pcall needed. guidFrame:RegisterEvent("UNIT_MODEL_CHANGED") guidFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH") -- UNIT_CASTEVENT (SuperWoW) and UNIT_DIED (nampower) may not exist. -- pcall-guarded exactly like core/pulsecal.lua's own UNIT_CASTEVENT -- registration, so an unknown event name can never break this frame. pcall(function() guidFrame:RegisterEvent("UNIT_CASTEVENT") end) pcall(function() guidFrame:RegisterEvent("UNIT_DIED") end) guidFrame:SetScript("OnEvent", function() if event == "UNIT_CASTEVENT" then -- SuperWoW: casterGUID, targetGUID, type, spellId, castTime. -- 2026-08-20 event tap: the pulse SPELL's name is NOT the -- totem's own name (Searing Totem's pulse is "Searing Bolt", -- Healing Stream Totem's is "Healing Stream", ...) -- mapping -- through SpellInfo(arg4) could never match. Keyed off -- UnitName(arg1) instead (arg1 is the TOTEM's own casterGUID); -- SpellInfo/arg4 are no longer needed for this latch at all. if not arg1 or type(UnitName) ~= "function" then return end local unitName = UnitName(arg1) if not unitName then return end -- Owner check (adversarial-review finding #2): totem NAME alone -- is not unique -- another shaman's same-type totem pulsing -- nearby would otherwise latch onto OUR element. See -- TotemBar.shouldLatchGuidFromCastEvent's own comment. local ownerName = UnitName(arg1 .. "owner") local playerName = UnitName("player") for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do local element = TotemBar.TOTEM_ELEMENTS[i] local rec = TotemBar.activeTotems[element] if TotemBar.shouldLatchGuidFromCastEvent(rec, unitName, ownerName, playerName) then rec.guid = arg1 return end end return end if event == "UNIT_MODEL_CHANGED" then local unit = arg1 if not unit or type(UnitName) ~= "function" then return end local unitName = UnitName(unit) if not unitName then return end -- 2026-08-20 event tap: UnitName(unit) here is RANKED ("Searing -- Totem VI"), never the bare book name recordCast stores -- so -- this can no longer go through TotemBar.elementOf (an exact -- static-map lookup); it scans activeTotems directly via -- shouldLatchGuidFromModelChanged's rank-tolerant match instead. local ownerName = UnitName(unit .. "owner") local playerName = UnitName("player") for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do local element = TotemBar.TOTEM_ELEMENTS[i] local rec = TotemBar.activeTotems[element] if TotemBar.shouldLatchGuidFromModelChanged(rec, unitName, ownerName, playerName) then local exists, guid if type(UnitExists) == "function" then exists, guid = UnitExists(unit) end if exists and guid then rec.guid = guid end return end end return end -- Primary destruction signal (2026-08-20 event tap): TurtleWoW -- emits a totem's own death as CHAT_MSG_COMBAT_FRIENDLY_DEATH with -- arg1 literally " () is destroyed." -- GUID-free and -- PvP-safe (needs neither latch path above to have ever fired at -- all). The generic vanilla " dies." form is accepted as a -- second, stricter form: since it carries no owner, it only evicts -- a candidate that already has a latched GUID, and only after -- verifying THAT GUID via TotemBar.totemDestroyed (the same -- UnitExists/UnitHealth/UnitIsDeadOrGhost verdict the liveness poll -- uses) -- a confirmation query, not blind trust in the chat line. if event == "CHAT_MSG_COMBAT_FRIENDLY_DEATH" then local line = arg1 if not line or type(UnitName) ~= "function" then return end local playerName = UnitName("player") local name, owner = TotemBar.parseDestroyedLine(line) if name and owner then local element = TotemBar.elementForOwnedTotemName(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, name, owner, playerName) if element then TotemBar.evictDestroyedTotem(element) end return end local diesName = TotemBar.parseDiesLine(line) if diesName then local element = TotemBar.elementForDiesLineCandidate(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, diesName) if element then local rec = TotemBar.activeTotems[element] local exists, guid, health, deadOrGhost = nil, nil, nil, nil if type(UnitExists) == "function" then exists, guid = UnitExists(rec.guid) end if exists then if type(UnitHealth) == "function" then health = UnitHealth(rec.guid) end if type(UnitIsDeadOrGhost) == "function" then deadOrGhost = UnitIsDeadOrGhost(rec.guid) end end if TotemBar.totemDestroyed(exists, health, deadOrGhost) then TotemBar.evictDestroyedTotem(element) end end end return end if event == "UNIT_DIED" then -- Deliberately NO UnitName/UnitExists lookup here for the -- PRIMARY match -- by the time UNIT_DIED fires, further queries -- against the unit were a documented risk (unverified). arg1 -- (whatever GUID form this build hands it) is compared directly -- against the already-latched rec.guid values via -- TotemBar.guidsEqual, so a "0x"/case mismatch between the two -- events cannot cause a silent miss. local element = TotemBar.elementForGuid(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, arg1, TotemBar.guidsEqual) if not element and arg1 and type(UnitName) == "function" then -- GUID-free fallback (2026-08-20 event tap: UnitName/owner -- DO resolve at the exact death instant on this client, for -- both the totem's own name and its "owner" token -- -- see the CHAT_MSG_COMBAT_FRIENDLY_DEATH/UNIT_DIED fixture -- pair) -- covers a totem whose GUID was never latched by -- either latch path at all. local unitName = UnitName(arg1) local ownerName = UnitName(arg1 .. "owner") local playerName = UnitName("player") element = TotemBar.elementForOwnedTotemName(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, unitName, ownerName, playerName) end if element then -- Same tombstoning eviction the liveness poll uses (see -- TotemBar.evictDestroyedTotem) -- a UNIT_DIED-triggered -- clear must protect the display from GTI's blind timer -- exactly like the poll path does, or this fast-path would -- reintroduce the same "cleared here, resurrected by GTI a -- moment later" bug for the one case it exists to short- -- circuit fastest. TotemBar.evictDestroyedTotem(element) end return 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 -- tracks how far the previous scan filled it, so leftover entries past -- the new scan's length get nilled out - keeping it a clean, hole-free -- 1..n array (table.getn needs that to be reliable in Lua 5.0). 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'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 = textureTail(iconPath) for i = 1, table.getn(buffTexList) do local tex = buffTexList[i] if tex and textureTail(tex) == needle then return true end end return false end -- Thin WoW-API wrapper: scans the player's current buffs (UnitBuff -- "player" 1..32, stopping at the first nil slot) into the reusable -- buffScratch table, then hands it to the pure -- TotemBar.buffTexturesMatch() above. This is the "am I in this totem's -- range?" signal for ui.lua's red-tint feature: a totem's party buff -- shares its spell's icon texture (verified in-game), so having a -- matching buff means the totem is currently affecting the player. function TotemBar.hasBuffWithIcon(iconPath) if not iconPath then return false end local n = 0 for i = 1, 32 do local tex = UnitBuff("player", i) if not tex then break end n = n + 1 buffScratch[n] = tex end for i = n + 1, buffScratchLen do buffScratch[i] = nil end buffScratchLen = n return TotemBar.buffTexturesMatch(buffScratch, iconPath) end -- Clears every own-tracked totem timer at once (e.g. after Totemic -- Recall, which drops all active totems simultaneously). function TotemBar.clearActiveTotems() for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do TotemBar.activeTotems[TotemBar.TOTEM_ELEMENTS[i]] = nil end end -- ===== Undoing a recall wipe a failure event proves wrong ===== -- -- The recall counterpart of revokeRecentCast above, closing the same -- phantom-state gap from the other direction: the element casts' bug was a -- timer for a totem that is NOT standing; a refused Totemic Recall's bug was -- NO timers for totems that ARE still standing. recallReady() is only the -- pre-check -- a press that passes it can still be refused server-side -- (movement, stun, silence, latency). Every recall path therefore wipes -- through recallWipeActiveTotems below, which keeps a snapshot of what it -- destroyed; when TotemBarCastFailFrame attributes a failure event to the -- recall (via lastCastAttempt.recall -- set in castRecallNoQueue, the one -- choke point every recall cast goes through), restoreRecalledTotems puts the -- snapshot back. -- Snapshot of the records the most recent recall wiped: { at =, recs = -- element -> record }. Only meaningful within CAST_FAIL_WINDOW of `at`. TotemBar.lastRecallWipe = nil -- Wipes the tracking for a recall cast that just went out, remembering what -- was destroyed so a failure event can restore it (see above). All recall -- call sites use this instead of a bare clearActiveTotems(). function TotemBar.recallWipeActiveTotems() local recs = {} for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do local element = TotemBar.TOTEM_ELEMENTS[i] recs[element] = TotemBar.activeTotems[element] end TotemBar.lastRecallWipe = { at = GetTime(), recs = recs } TotemBar.clearActiveTotems() end -- Restores the records the last recall wiped, because a failure event proved -- the recall never went through. Mirrors revokeRecentCast's policies: the -- snapshot must be recent (same CAST_FAIL_WINDOW -- an old snapshot means the -- failure is somebody else's), it is consumed on use (a second event for the -- same refusal must not restore again), and a slot a NEW cast has already -- refilled is left alone -- that record is younger truth than the snapshot. function TotemBar.restoreRecalledTotems() local wiped = TotemBar.lastRecallWipe TotemBar.lastRecallWipe = nil if not wiped or not wiped.at or not wiped.recs then return false end if (GetTime() - wiped.at) > TotemBar.CAST_FAIL_WINDOW then return false end local restored = false for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do local element = TotemBar.TOTEM_ELEMENTS[i] if wiped.recs[element] and not TotemBar.activeTotems[element] then TotemBar.activeTotems[element] = wiped.recs[element] restored = true end end return restored end -- Is at least one totem currently out? Used to avoid wasting Totemic Recall's -- own 6-second cooldown on a no-op cast: recalling with nothing out still puts -- Recall on cooldown, so a fresh set placed right after can't be recalled for -- 6s. Permissive on purpose - returns true if EITHER our own cast-tracking OR -- GetTotemInfo (pfUI libtotem / SuperWoW, when present) reports a totem out, so -- a legitimate recall is never wrongly blocked; only when both agree nothing is -- out do we suppress the cast. function TotemBar.anyTotemOut() if TotemBar.anyActiveTracked(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, GetTime(), TotemBar.remaining) then return true end if GetTotemInfo then for slot = 1, 4 do if GetTotemInfo(slot) then return true end end end return false end -- pure: does the addon currently hold ANY tracked totem record (regardless of expiry)? -- 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 if activeTotems[elements[i]] then return true end end return false end -- Should the manual-recall gate BLOCK the recall? Only when we are CONFIDENT that nothing -- is out: we have tracked at least one totem THIS session AND all tracked totems have -- expired. When tracking is empty (fresh load, or after a /reload -- activeTotems is -- in-memory only and reset on load) the state is UNKNOWN, not "none out": on 1.12 there is -- no GetTotemInfo / no "totemN" UnitID to re-detect a totem that was cast BEFORE the reload -- (KG-confirmed 2026-07-14 -- no reload-proof detection exists for all totem types). So in -- the unknown case FAIL OPEN -- let the recall through rather than falsely reporting "no -- 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.castState.everCast then return false end return not TotemBar.anyTotemOut() end -- Casts exactly ONE totem per call: the next slot in Fire -> Earth -> -- Water -> Air order, skipping empty (unassigned) slots. If more than -- gapSeconds has passed since the previous call, the cycle restarts at -- the first filled slot (so a fresh spam always begins with totem 1). -- -- Intended for a macro: `/script TotemBar.castNext()` function TotemBar.castNext() local db = TotemBarDB local chosen = (db and db.chosen) or {} local gap = (db and db.gapSeconds) or TotemBar.DEFAULT_GAP_SECONDS local elements = TotemBar.TOTEM_ELEMENTS local numSlots = table.getn(elements) local now = GetTime() local state = TotemBar.castState local startIdx = TotemBar.nextIndex(state.index, state.lastTime, now, gap, numSlots) local slot = TotemBar.findFilledSlot(chosen, elements, startIdx) if not slot then -- Nothing assigned to any element; nothing to cast. state.index = 0 state.lastTime = now return nil, nil end local element = elements[slot] local totemName = chosen[element] CastSpellByName(totemName) TotemBar.recordCast(element, totemName) state.index = slot state.lastTime = now return totemName, element end -- Casts ALL filled slots in a single call (Fire -> Earth -> Water -> -- Air). On TurtleWoW each totem element has its own cooldown, so this -- MAY drop all four from one keypress. Whether 4 CastSpellByName calls -- in one Lua frame all land (vs only the last "winning") is unverified -- on this client -- offered as a one-press alternative to castNext() to -- test in-game. -- -- Intended for a macro: `/script TotemBar.castAll()` function TotemBar.castAll() local db = TotemBarDB local chosen = (db and db.chosen) or {} local elements = TotemBar.TOTEM_ELEMENTS for i = 1, table.getn(elements) do local element = elements[i] local totemName = chosen[element] if totemName then CastSpellByName(totemName) TotemBar.recordCast(element, totemName) end end end -- Pure: should recallAndCastAll fire Totemic Recall this call? -- autoRecall off -> false (never recall) -- never deployed (nil / <=0) -> true (nothing fresh to protect) -- last deploy > guard ago -> true -- last deploy within guard -> false (protect just-placed totems from a -- rapid accidental second press) function TotemBar.shouldRecall(autoRecall, lastDeployTime, now, guardSeconds) if not autoRecall then return false end if not lastDeployTime or lastDeployTime <= 0 then return true end if not guardSeconds then return true end if (now - lastDeployTime) > guardSeconds then return true end return false end -- Casts Totemic Recall bypassing nampower's spell queue when that API is -- present. Root cause of the "place a set, it vanishes a moment later" bug: -- under nampower, a plain CastSpellByName of Totemic Recall (a GCD-tied -- instant) issued while a GCD is active is QUEUED, not cast, and pops at -- GCD-end -- by which time the totems are already down (TWoW gives each totem -- element its own non-GCD recovery category, so the 4 totems fire immediately -- with queue priority). The late Recall then sweeps the fresh set. Bypassing -- the queue means a GCD-blocked Recall simply fails that press instead of -- being deferred, so it can never fire late and tear the totems down. Falls -- back to a plain cast when nampower isn't installed (the function is nil). -- (KG: NP_QueueInstantSpells default 1 is the deferral; CastSpellByNameNoQueue -- is nampower's queue-bypass cast.) local function castRecallNoQueue() if type(CastSpellByNameNoQueue) == "function" then CastSpellByNameNoQueue(RECALL_SPELL_NAME) else CastSpellByName(RECALL_SPELL_NAME) end -- Note the recall as the last cast attempt, so TotemBarCastFailFrame can -- attribute a failure event to it and undo the tracking wipe (see -- restoreRecalledTotems above). Set AFTER the cast call on purpose: the -- plain-cast fallback goes through this addon's own CastSpellByName hook, -- which records a non-totem attempt for it -- this overwrite corrects -- that to the recall (the NoQueue path is not hooked and needs it too). -- Deliberately shaped like noteCastAttempt's records, plus the `recall` -- flag attributeCastFailure and the fail handler key off; element is nil -- because a recall names no single element -- it wipes all four. TotemBar.lastCastAttempt = { element = nil, name = RECALL_SPELL_NAME, recall = true, at = GetTime(), } 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). Wiped through the snapshotting helper: recallReady() was -- only the PRE-check, and a press it let through can still be refused -- server-side -- the failure events then restore what this wiped (see -- restoreRecalledTotems above). TotemBar.recallWipeActiveTotems() 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) -- and clears own-tracking, then always places all filled slots via -- castAll(). One keypress = recall + redeploy (or just redeploy, with -- the flag off). Like castAll, relies on TurtleWoW allowing several -- CastSpellByName calls in one Lua frame -- verify in-game. -- -- Guarded against rapid double-presses: shouldRecall() only fires Recall -- if the last deploy was more than DEFAULT_RECALL_GUARD seconds ago. A -- fast accidental second press then just re-attempts placement (a no-op, -- 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.recallReady() and TotemBar.anyTotemOut() then castRecallNoQueue() -- Snapshotting wipe (see restoreRecalledTotems): in THIS path the -- castAll below immediately overwrites lastCastAttempt with its totem -- casts, so a recall refusal is rarely attributable here -- but the -- snapshot costs nothing and any slot castAll refills is protected by -- the restore's younger-record rule anyway. TotemBar.recallWipeActiveTotems() end TotemBar.castAll() TotemBar.castState.lastDeployTime = now end -- Key-down/key-up split for the DropSet keybind. Bound in Bindings.xml with -- runOnUp="true", so this runs on BOTH flanks with the global `keystate` set -- to "down" / "up" beforehand. Casting Totemic Recall on the DOWN stroke and -- placing the set on the RELEASE. The real fix for the teardown is -- castRecallNoQueue() (see above) -- bypassing nampower's queue so a -- GCD-blocked Recall never fires late and sweeps the fresh set. The down/up -- split adds belt-and-suspenders temporal separation: the Recall on the down -- stroke has definitively resolved-or-failed (it is never queued) by the time -- the release places the set. Both flanks are real hardware events, so both -- may cast (Blizzard's own ActionButtonUp casts on release too), unlike a -- timer-deferred placement which this client blocks as a non-hardware cast. -- -- Still guarded by shouldRecall()'s 2s window (a rapid re-press within the -- guard skips the Recall so it can't pull the just-placed set). keystate is -- nil only if the binding ran without runOnUp (misconfig / older client); -- treat nil like "up" so a single fire at least still PLACES rather than -- silently doing nothing. function TotemBar.dropSetKey(keystate) if keystate == "down" then 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.recallReady() and TotemBar.anyTotemOut() then castRecallNoQueue() -- Snapshotting wipe (see restoreRecalledTotems): the down stroke -- places nothing itself, so a refused recall here IS attributable -- -- the failure event restores the timers before the release's -- castAll runs. TotemBar.recallWipeActiveTotems() end else -- Release (or nil fallback): place the set now, in this hardware frame. TotemBar.castAll() TotemBar.castState.lastDeployTime = GetTime() end end -- Dev aid (/tb tdump): dumps TotemBar's own per-element cast-tracking -- (TotemBar.activeTotems, the resolveRemaining/resolveDuration own-tracking -- fallback source, see this file's header comment) side by side with the -- RAW GetTotemInfo(1..4) (pfUI libtotem, when present -- the OTHER, -- normally-authoritative source), so a "countdown missing" report can be -- diagnosed off-client without an in-game screenshot: did TotemBar ever -- record this cast at all, and what does GTI say about the same slot right -- now? Also appends ui.lua's TotemBar.DumpRingRenderState() output (2026-07- -- 16, Searing-ring bug: countdown TEXT shows but no pulse RING) -- the pure -- ring math was already proven correct offline, so this section captures -- the WoW-side render state instead: cached ring flags plus the LIVE -- ringFill/ringTrack texture objects (shown/texture/alpha/texCoord/parent), -- see that function's own comment for the full field list. Written to -- \imports\tb_tdump.txt via SuperWoW's ExportFile (name WITHOUT -- the .txt extension -- ExportFile appends it itself); chat fallback when -- SuperWoW isn't present. GetTotemInfo is pcall-wrapped since it's -- third-party (pfUI) code this dump must never itself error on. function TotemBar.DumpTimerState() local now = GetTime() local els = TotemBar.TOTEM_ELEMENTS local activeTotems = TotemBar.activeTotems local out = "TotemBar timer-state dump (now=" .. tostring(now) .. ")\n" out = out .. "\n-- own tracking (TotemBar.activeTotems) --\n" for i = 1, table.getn(els) do local element = els[i] local rec = activeTotems[element] if rec then local rem = TotemBar.remaining(rec.start, rec.duration, now) -- Raw destroyed-totem liveness reads (see "Destroyed-totem -- detection (GUID liveness)" above) -- only meaningful once -- rec.guid was latched; nil/nil/nil otherwise means "no GUID -- yet", not "unit resolved as gone". local existsRaw, healthRaw, deadRaw = nil, nil, nil if rec.guid then if type(UnitExists) == "function" then existsRaw = UnitExists(rec.guid) end if type(UnitHealth) == "function" then healthRaw = UnitHealth(rec.guid) end if type(UnitIsDeadOrGhost) == "function" then deadRaw = UnitIsDeadOrGhost(rec.guid) end end out = out .. element .. ": spell='" .. tostring(rec.totemName) .. "'" .. " start=" .. tostring(rec.start) .. " duration=" .. tostring(rec.duration) .. " remaining=" .. tostring(rem) .. " guid=" .. tostring(rec.guid) .. " exists=" .. tostring(existsRaw) .. " health=" .. tostring(healthRaw) .. " deadOrGhost=" .. tostring(deadRaw) .. "\n" else -- Tombstone status (see TotemBar.destroyedTombstone/ -- tombstoneActive) is most relevant HERE -- a record just -- evicted as destroyed shows up as "no record", and this line -- is what confirms GTI is (correctly) being ignored for it -- rather than resurrecting the countdown a moment later. local tomb = TotemBar.destroyedTombstone[element] local tombActive = TotemBar.tombstoneActive(tomb, now) out = out .. element .. ": (no record) tombstone=" .. tostring(tomb) .. " tombstoneActive=" .. tostring(tombActive) .. "\n" end end out = out .. "\n-- raw GetTotemInfo(1..4) (pfUI libtotem, when present) --\n" if type(GetTotemInfo) == "function" then for slot = 1, 4 do local ok, active, name, start, duration = pcall(GetTotemInfo, slot) if ok then out = out .. "slot " .. slot .. ": active=" .. tostring(active) .. " name='" .. tostring(name) .. "'" .. " start=" .. tostring(start) .. " duration=" .. tostring(duration) .. "\n" else out = out .. "slot " .. slot .. ": pcall error: " .. tostring(active) .. "\n" end end else out = out .. "(GetTotemInfo not present -- no pfUI libtotem loaded)\n" end -- Render-state section (ui.lua): the pure timer/duration/ring math -- above was already proven correct offline for the Searing-ring -- report (text shows, no ring) -- this is the other half, the -- WoW-side render state (cached ring flags + LIVE texture objects) -- that this dump previously never captured. pcall-wrapped: ui.lua -- loads after this file (see TotemBar.toc), but by the time a player -- runs /tb tdump everything is loaded; the guard just keeps this dump -- from erroring if that ever isn't true (e.g. a future load-order -- change) or if TotemBar.DumpRingRenderState itself hits something -- unexpected. local okRender, renderOut = pcall(TotemBar.DumpRingRenderState) if okRender and renderOut then out = out .. renderOut else out = out .. "\n-- render state: unavailable (" .. tostring(renderOut) .. ") --\n" end if ExportFile then ExportFile("tb_tdump", out) end if DEFAULT_CHAT_FRAME then DEFAULT_CHAT_FRAME:AddMessage("TotemBar: tdump exported.") end end