TotemBar v0.4.0

This commit is contained in:
2026-08-30 15:37:01 +02:00
parent 166d870dec
commit 4d9befbdf6
7 changed files with 1172 additions and 58 deletions
+76
View File
@@ -2,6 +2,82 @@
All notable changes to TotemBar are documented here. All notable changes to TotemBar are documented here.
## v0.4.0 — 2026-08-30
### Added
- **Destroyed totems can now be detected while SuperWoW is present (verified against captured
event log 2026-08-20, live eviction pending retest).** A totem killed before its timer ran
out used to keep counting down as if it were still standing — both TotemBar's own tracking
and pfUI's libtotem are blind, duration-only timers, and neither ever checked whether the
totem itself was still alive (pfUI's `GetTotemInfo` was previously assumed to cover this
case; checked against its source — it does not). A live event tap against the real client
(2026-08-20) captured totem spawn/pulse/death lines and found the original mechanism could
never have fired at all: the spawn/pulse latch paths compared totem names for *exact*
equality, but the live client hands back a *ranked* name ("Searing Totem VI"), never the bare
book name; and the pulse-cast latch mapped through the pulse *spell's* name ("Searing Bolt"),
which is not the totem's own name either. Both are now fixed, and a third, GUID-free signal
was added as the primary path:
1. **Primary — `CHAT_MSG_COMBAT_FRIENDLY_DEATH`** (native 1.12, needs no SuperWoW/nampower):
TurtleWoW emits a totem's own death as `"<Name> (<Owner>) is destroyed."` verbatim; parsed
and evicted once the owner resolves to the player and the name rank-tolerantly matches a
tracked totem. The generic vanilla `"<Name> dies."` form is accepted as a second, stricter
shape — since it carries no owner, it only evicts a candidate that already has a latched
GUID, confirmed via a live `UnitExists`/`UnitHealth`/`UnitIsDeadOrGhost` read.
2. **`UNIT_CASTEVENT`** (SuperWoW), for totems that periodically cast their own pulse — now
keyed off `UnitName(casterGUID)` (the totem unit's own, rank-tolerant name) instead of the
pulse spell's name; covers only pulsing totems (Searing/Magma/Fire Nova/Mana Spring/
Healing Stream/Mana Tide/the Cleansing totems), gated on the caster's resolved owner
matching the player.
3. **`UNIT_MODEL_CHANGED`** (native 1.12), for totems that never cast anything visible — now
rank-tolerant instead of an exact name compare.
4. **`UNIT_DIED`** (nampower), extended with a GUID-free fallback: when no GUID was ever
latched, a rank-tolerant name + owner match at the death instant (both resolve reliably
there per the captured log) still evicts.
A totem confirmed destroyed is evicted immediately and tombstoned for the remainder of its
own natural duration, so pfUI's libtotem — itself just another blind timer — cannot
resurrect the countdown/ring/pulse a moment later; a real re-cast on that element clears the
tombstone right away. This clears the countdown, duration ring, pulse animation and
out-of-range tint together, since all four already derive from the same tracking record. On
a client without SuperWoW, only the primary `CHAT_MSG_COMBAT_FRIENDLY_DEATH` signal is
active; without either SuperWoW or TurtleWoW's own death-line wording, behaviour is
byte-for-byte unchanged from before this feature. The decision logic (name matching, line
parsing, eviction gating) is fully offline-tested against the captured fixture lines (95
assertions in `tools/luatests/test_destroy.lua`); a live totem-kill retest with the addon
in the loop is still pending — `/tb tdump` prints each totem's latched `guid`, the raw
`UnitExists`/`UnitHealth`/`UnitIsDeadOrGhost` reads, and (for an evicted slot) its tombstone
status, for that verification.
### Fixed
- **A totem's countdown no longer starts when the cast was refused for lack of mana.** The
pre-cast mana gate stood down completely while Clearcasting (Elemental Focus) was up, on the
assumption that it zeroes the next cast's price. Elemental Focus covers the shaman's damage
spells, not totem summons -- so a totem costs full mana while Clearcasting is up, and since
totems are the only thing this addon casts, that exemption disabled the mana gate outright.
The client then refused the cast while the timer had already been recorded, leaving a
countdown running for a totem that was never placed. The exemption is gone from the cast
gate, the out-of-mana dim and the flyout tint alike; the unverified buff-icon constant it
relied on went with it. The gate still accepts (and ignores) the old sixth argument, so no
stale caller can quietly reinstate it.
- **A Totemic Recall the server refuses no longer wipes your totem timers.**
Recall already checked whether the cast could go out *before* touching the
tracking, but a press that passes that check can still be refused after the
fact — moving at the wrong moment, a stun or silence landing first, or plain
latency. The tracking was cleared unconditionally the instant the cast was
issued, so a refused Recall left the addon believing nothing was out while a
full set kept standing — the same phantom-state bug class the totem casts'
own two-layer guard already closed, mirrored. The recall paths now remember
what they cleared, and the same failure signals that take back a refused
totem's countdown (the exact per-spell one with nampower, the error-message
fallback without it) put the timers back. The restore follows the same
narrow rules as the totem-cast revoke: only when the Recall itself was the
last cast attempted, only within a short window afterward, and never over a
slot a new cast has already refilled — when attribution is uncertain it
stays silent, since here a *wrongly restored* timer would be the phantom.
Covers the Recall button, its keybind, and the DropSet keybind's recall
stroke alike.
## v0.3.0 — 2026-08-15 ## v0.3.0 — 2026-08-15
### Added ### Added
+5
View File
@@ -31,6 +31,11 @@ server data, not guesswork. No dependencies; adopts the
**Version history** — details in [CHANGELOG.md](CHANGELOG.md): **Version history** — details in [CHANGELOG.md](CHANGELOG.md):
- **v0.4.0** (2026-08-30) — a totem destroyed before its timer ran out is now
detected and cleared (countdown, ring, pulse and range tint together) instead
of counting down as if it still stood; a totem cast refused for lack of mana
no longer starts a countdown (the Clearcasting exemption never applied to
totems); a Totemic Recall the server refuses no longer wipes your timers.
- **v0.3.0** (2026-08-15) — element buttons and the flyout now dim totems you - **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 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 that already started can now be taken back after the fact (narrowly, and
+1 -1
View File
@@ -2,7 +2,7 @@
## Title: TotemBar ## Title: TotemBar
## Notes: Shaman totem bar - pick one totem per element, cast it, or spam-cycle all four from a macro. ## Notes: Shaman totem bar - pick one totem per element, cast it, or spam-cycle all four from a macro.
## Author: ShempError ## Author: ShempError
## Version: 0.3.0 ## Version: 0.4.0
## SavedVariables: TotemBarDB ## SavedVariables: TotemBarDB
core\totemdata.lua core\totemdata.lua
+751 -21
View File
@@ -31,6 +31,13 @@ TotemBar.RECALL_OVERRIDE_MAX = 5
-- persists them to TotemBarDB.buttonGap (core/config.lua). -- persists them to TotemBarDB.buttonGap (core/config.lua).
TotemBar.DEFAULT_BUTTON_GAP = 10 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. -- Cycle state: which slot was cast last, and when.
TotemBar.castState = TotemBar.castState or { TotemBar.castState = TotemBar.castState or {
index = 0, -- 0 = no cast yet (or state was reset) index = 0, -- 0 = no cast yet (or state was reset)
@@ -44,6 +51,20 @@ TotemBar.castState = TotemBar.castState or {
-- duration=}, both in TotemBar.recordCast() below. -- duration=}, both in TotemBar.recordCast() below.
TotemBar.activeTotems = TotemBar.activeTotems or {} 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 -- Pure: given the previously cast slot index, the time of that previous
-- cast, the current time, the allowed gap (seconds) and the number of -- cast, the current time, the allowed gap (seconds) and the number of
-- slots, returns the next slot index (1-based) to advance to. -- slots, returns the next slot index (1-based) to advance to.
@@ -120,14 +141,25 @@ end
-- otherwise (absent, or reporting the slot inactive) falls back to -- otherwise (absent, or reporting the slot inactive) falls back to
-- TotemBar's own cast-tracking. Returns nil when neither source has -- TotemBar's own cast-tracking. Returns nil when neither source has
-- time left. -- time left.
function TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining) function TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining, tombstoned)
-- FIX 2026-07-15: trust GTI (pfUI libtotem) ONLY when it reports the slot active -- 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 -- 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 -- 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 -- 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 -- 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). -- cast queue loses the race for the slots cast later in a multi-drop).
if gtiActive and gtiRemaining and gtiRemaining > 0 then --
-- 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 return gtiRemaining
end end
if ownRemaining and ownRemaining > 0 then if ownRemaining and ownRemaining > 0 then
@@ -136,6 +168,18 @@ function TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining)
return nil return nil
end 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? -- 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 -- Own tracking is the base signal. When pfUI's libtotem is present its
@@ -158,6 +202,359 @@ function TotemBar.rangeTintActive(hasOwnRecord, hasGTI, gtiTracked, gtiActive)
return true return true
end 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); "<unit>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 "<unit>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 "<unit>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
-- "<Name> (<Owner>) 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 "<Name> 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 "<Name> dies." combat line -> "<Name>",
-- 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 "<Name> 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 -- Out-of-mana dim level. Blizzard's own "unusable" grey from FrameXML
-- ActionButton.lua:280 (ActionButton_UpdateUsable). Blizzard reserves a BLUE -- 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 -- tint (0.5,0.5,1.0) for the not-enough-mana case specifically and grey for
@@ -268,8 +665,19 @@ TotemBar.GCD_MAX = 1.6
-- "cooldown". Every input may be nil -- an unknown never blocks, exactly like -- "cooldown". Every input may be nil -- an unknown never blocks, exactly like
-- notEnoughMana/confidentNoneOut. Mana is reported first because it is the one -- notEnoughMana/confidentNoneOut. Mana is reported first because it is the one
-- the player can act on. -- the player can act on.
function TotemBar.castGateReason(cost, mana, cdStart, cdDuration, gcdMax, clearcasting) --
if not clearcasting and TotemBar.notEnoughMana(cost, mana) then -- 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" return "mana"
end end
if cdStart and cdDuration and cdStart > 0 and cdDuration > (gcdMax or TotemBar.GCD_MAX) then if cdStart and cdDuration and cdStart > 0 and cdDuration > (gcdMax or TotemBar.GCD_MAX) then
@@ -295,8 +703,7 @@ local function computeCastBlock(element, totemName)
cdStart, cdDuration = GetSpellCooldown(idx, BOOKTYPE_SPELL) cdStart, cdDuration = GetSpellCooldown(idx, BOOKTYPE_SPELL)
end end
end end
return TotemBar.castGateReason(cost, mana, cdStart, cdDuration, return TotemBar.castGateReason(cost, mana, cdStart, cdDuration, TotemBar.GCD_MAX)
TotemBar.GCD_MAX, TotemBar.hasClearcasting and TotemBar.hasClearcasting())
end end
-- This runs INSIDE the CastSpellByName hook, i.e. in front of every totem cast -- This runs INSIDE the CastSpellByName hook, i.e. in front of every totem cast
@@ -387,6 +794,12 @@ function TotemBar.recordCast(element, totemName)
-- Latched by ui.lua once GetTotemInfo reports this slot active under -- Latched by ui.lua once GetTotemInfo reports this slot active under
-- this record's totem name; see TotemBar.rangeTintActive below. -- this record's totem name; see TotemBar.rangeTintActive below.
gtiTracked = false, 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 -- 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, -- refused, the honest correction is the record as it was BEFORE the press,
@@ -398,6 +811,14 @@ function TotemBar.recordCast(element, totemName)
at = rec.start, at = rec.start,
} }
TotemBar.activeTotems[element] = rec 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 -- Sticky session evidence for confidentNoneOut() below. Deliberately NOT
-- derived from activeTotems' occupancy: ui.lua's timer tick evicts each -- derived from activeTotems' occupancy: ui.lua's timer tick evicts each
-- record the moment it expires, and clearActiveTotems() wipes the table -- record the moment it expires, and clearActiveTotems() wipes the table
@@ -548,7 +969,10 @@ function TotemBar.messageSet(list)
end end
-- Pure: should a global failure event revoke lastAttempt's totem timer? -- Pure: should a global failure event revoke lastAttempt's totem timer?
-- Returns the element and totem name to revoke, or nil, nil. -- 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 -- lastAttempt the last thing this addon's hooks saw the client attempt
-- (ANY spell -- see noteCastAttempt above), or nil. -- (ANY spell -- see noteCastAttempt above), or nil.
@@ -565,16 +989,16 @@ end
-- missing/empty allowlist) fails CLOSED -- no revoke -- same -- missing/empty allowlist) fails CLOSED -- no revoke -- same
-- policy as an unattributable failure above. -- policy as an unattributable failure above.
function TotemBar.attributeCastFailure(lastAttempt, now, window, message, allowlist) function TotemBar.attributeCastFailure(lastAttempt, now, window, message, allowlist)
if not lastAttempt or not lastAttempt.element then if not lastAttempt or not (lastAttempt.element or lastAttempt.recall) then
return nil, nil return nil, nil, nil
end end
if not lastAttempt.at or not now or not window or (now - lastAttempt.at) > window then if not lastAttempt.at or not now or not window or (now - lastAttempt.at) > window then
return nil, nil return nil, nil, nil
end end
if message ~= nil and (not allowlist or not allowlist[message]) then if message ~= nil and (not allowlist or not allowlist[message]) then
return nil, nil return nil, nil, nil
end end
return lastAttempt.element, lastAttempt.name return lastAttempt.element, lastAttempt.name, lastAttempt.recall
end end
-- ===== Universal cast hooks: catch totem casts from ANY path ===== -- ===== Universal cast hooks: catch totem casts from ANY path =====
@@ -726,7 +1150,19 @@ if CreateFrame then
end end
-- No name, no revoke. An unnamed failure could just as well be -- No name, no revoke. An unnamed failure could just as well be
-- the Lightning Bolt the player pressed a moment after the totem. -- the Lightning Bolt the player pressed a moment after the totem.
if not name or not TotemBar.elementOf(name) then 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 return
end end
TotemBar.revokeRecentCast(name) TotemBar.revokeRecentCast(name)
@@ -742,9 +1178,14 @@ if CreateFrame then
castFailureMessages = buildCastFailureMessages() castFailureMessages = buildCastFailureMessages()
end end
end end
local element, name = TotemBar.attributeCastFailure(TotemBar.lastCastAttempt, local element, name, recall = TotemBar.attributeCastFailure(TotemBar.lastCastAttempt,
GetTime(), TotemBar.CAST_FAIL_WINDOW, message, castFailureMessages) GetTime(), TotemBar.CAST_FAIL_WINDOW, message, castFailureMessages)
if element then 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) TotemBar.revokeRecentCast(name)
-- Consume: a second event for the SAME refusal (e.g. -- Consume: a second event for the SAME refusal (e.g.
-- SPELLCAST_FAILED right after UI_ERROR_MESSAGE) must not -- SPELLCAST_FAILED right after UI_ERROR_MESSAGE) must not
@@ -756,6 +1197,183 @@ if CreateFrame then
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 "<Name> (<Owner>) is destroyed." -- GUID-free and
-- PvP-safe (needs neither latch path above to have ever fired at
-- all). The generic vanilla "<Name> 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 "<unit>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 -- Module-scratch table for the buff-texture scan below, reused every
-- call (hasBuffWithIcon runs ~5x/sec, from ui.lua's throttled timer -- call (hasBuffWithIcon runs ~5x/sec, from ui.lua's throttled timer
-- tick) so it doesn't allocate a new table each time. buffScratchLen -- tick) so it doesn't allocate a new table each time. buffScratchLen
@@ -839,6 +1457,63 @@ function TotemBar.clearActiveTotems()
end end
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 -- 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 -- 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 -- Recall on cooldown, so a fresh set placed right after can't be recalled for
@@ -995,6 +1670,21 @@ local function castRecallNoQueue()
else else
CastSpellByName(RECALL_SPELL_NAME) CastSpellByName(RECALL_SPELL_NAME)
end 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 end
-- pure: does GetSpellCooldown's (start, duration) pair describe a cooldown -- pure: does GetSpellCooldown's (start, duration) pair describe a cooldown
@@ -1114,8 +1804,11 @@ function TotemBar.manualRecall()
if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end
-- Totemic Recall drops every active totem at once; clear own-tracking so -- Totemic Recall drops every active totem at once; clear own-tracking so
-- the icons' countdowns disappear too (GetTotemInfo, if present, will also -- the icons' countdowns disappear too (GetTotemInfo, if present, will also
-- reflect this). -- reflect this). Wiped through the snapshotting helper: recallReady() was
TotemBar.clearActiveTotems() -- 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 return action
end end
@@ -1146,7 +1839,12 @@ function TotemBar.recallAndCastAll()
if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard) if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard)
and TotemBar.recallReady() and TotemBar.anyTotemOut() then and TotemBar.recallReady() and TotemBar.anyTotemOut() then
castRecallNoQueue() castRecallNoQueue()
TotemBar.clearActiveTotems() -- 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 end
TotemBar.castAll() TotemBar.castAll()
TotemBar.castState.lastDeployTime = now TotemBar.castState.lastDeployTime = now
@@ -1177,7 +1875,11 @@ function TotemBar.dropSetKey(keystate)
if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard) if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard)
and TotemBar.recallReady() and TotemBar.anyTotemOut() then and TotemBar.recallReady() and TotemBar.anyTotemOut() then
castRecallNoQueue() castRecallNoQueue()
TotemBar.clearActiveTotems() -- 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 end
else else
-- Release (or nil fallback): place the set now, in this hardware frame. -- Release (or nil fallback): place the set now, in this hardware frame.
@@ -1215,12 +1917,40 @@ function TotemBar.DumpTimerState()
local rec = activeTotems[element] local rec = activeTotems[element]
if rec then if rec then
local rem = TotemBar.remaining(rec.start, rec.duration, now) 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) .. "'" out = out .. element .. ": spell='" .. tostring(rec.totemName) .. "'"
.. " start=" .. tostring(rec.start) .. " start=" .. tostring(rec.start)
.. " duration=" .. tostring(rec.duration) .. " duration=" .. tostring(rec.duration)
.. " remaining=" .. tostring(rem) .. "\n" .. " remaining=" .. tostring(rem)
.. " guid=" .. tostring(rec.guid)
.. " exists=" .. tostring(existsRaw)
.. " health=" .. tostring(healthRaw)
.. " deadOrGhost=" .. tostring(deadRaw) .. "\n"
else else
out = out .. element .. ": (no record)\n" -- 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
end end
+8 -20
View File
@@ -73,23 +73,14 @@ function TotemBar.notEnoughMana(cost, mana)
return mana < cost return mana < cost
end end
-- Clearcasting (Elemental Focus) makes the next damage spell free while the -- Clearcasting (Elemental Focus) used to exempt every mana verdict here. That was
-- tooltip still shows the full price, so every mana verdict has to stand down -- WRONG and is gone (2026-08-28, player-reported): Elemental Focus zeroes the next
-- while it is up -- otherwise the bar greys out a totem that casts fine and the -- DAMAGE spell, not a totem summon, so a totem costs full price while Clearcasting
-- cast gate drops its timer. -- is up. Because totems are the only thing this addon casts, the exemption
-- -- disabled the mana gate and the out-of-mana dim outright whenever the buff was
-- Matched by buff ICON, like the out-of-range tint's totem detection (see -- up. TotemBar.hasClearcasting/CLEARCAST_ICON were removed with it -- the icon was
-- TotemBar.hasBuffWithIcon): 1.12 gives no buff NAME without a tooltip scan per -- never verified in-game and now has no consumer. TotemBar.hasBuffWithIcon stays:
-- buff per check. VERIFY in-game -- if this icon is wrong the only effect is -- the out-of-range tint still uses it.
-- 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, -- 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 -- the player's mana is below it, and no Clearcasting is up. Reads the cached
@@ -99,9 +90,6 @@ function TotemBar.totemOutOfMana(name)
if not name or type(UnitMana) ~= "function" then if not name or type(UnitMana) ~= "function" then
return false return false
end end
if TotemBar.hasClearcasting() then
return false
end
return TotemBar.notEnoughMana(TotemBar.getTotemManaCost(name), UnitMana("player")) return TotemBar.notEnoughMana(TotemBar.getTotemManaCost(name), UnitMana("player"))
end end
+239 -2
View File
@@ -10,6 +10,18 @@ TotemBar = TotemBar or {}
TotemBar.PULSECAL_CAP = 2000 TotemBar.PULSECAL_CAP = 2000
-- Dev-only diagnostic probe (/tb tprobe) for the destroyed-totem GUID-latch
-- paths in core/cast.lua ("Destroyed-totem detection (GUID liveness)"). Built
-- because two in-game measurement attempts against those latch paths were
-- both silently wiped by a /reload before their taps could be read (see
-- 2026-08-19/20 session) -- unlike pulsecal above (start, drop totems, dump
-- once by hand), this probe survives /reload on its own: it re-exports its
-- WHOLE ring buffer after every kept event (not only on demand) and re-arms
-- itself from TotemBarDB.tprobeArmed at ADDON_LOADED (see the WoW-gated
-- section below). Shares pulsecalPush/pulsecalFormat above for the ring
-- buffer itself -- only the filter and the per-line detail string are new.
TotemBar.TPROBE_CAP = 300
-- Pure: push one record into the ring buffer. state = { n = total pushed, -- Pure: push one record into the ring buffer. state = { n = total pushed,
-- idx = next write slot 1..cap }. Record tables are REUSED on wrap (no -- idx = next write slot 1..cap }. Record tables are REUSED on wrap (no
-- allocation growth while capturing). -- allocation growth while capturing).
@@ -57,6 +69,103 @@ function TotemBar.pulsecalFormat(buf, cap, state)
return table.concat(lines, "\n") return table.concat(lines, "\n")
end end
-- ===== /tb tprobe -- pure filter + line-builder (offline-tested) =====
-- Pure: does `name` look like a totem to a plain substring eye -- "Totem" or
-- "Searing" (the latter because Searing Totem's own pulse-cast spell is
-- suspected to be named "Searing Bolt"/"Attack" rather than "Searing Totem",
-- suspicion (a) in the probe's own brief -- a name check that only looked
-- for "Totem" would silently drop exactly the lines needed to confirm or
-- kill that suspicion). Case-sensitive plain substring, same convention the
-- WoW-gated capture below (and pulsecal's own filter above) already uses --
-- not a design decision unique to this function. nil-safe.
function TotemBar.tprobeNameHit(name)
if not name then
return false
end
if string.find(name, "Totem", 1, true) then
return true
end
if string.find(name, "Searing", 1, true) then
return true
end
return false
end
-- Events always kept regardless of name (a death is diagnostic on its own --
-- there is no "name" to filter on for CHAT_MSG_COMBAT_*_DEATH, and UNIT_DIED
-- firing for something that never matched a totem name is itself evidence,
-- e.g. against suspicion (b)/(c) in the probe's brief).
local TPROBE_ALWAYS_EVENTS = {
UNIT_DIED = true,
CHAT_MSG_COMBAT_FRIENDLY_DEATH = true,
CHAT_MSG_COMBAT_HOSTILE_DEATH = true,
}
-- Pure: should this event be kept? nameUnit is UnitName(arg1) as resolved by
-- the caller, nameSpell is SpellInfo(arg4) -- kept if EITHER hits
-- tprobeNameHit, or if the event is one of TPROBE_ALWAYS_EVENTS above.
function TotemBar.tprobeShouldKeep(event, nameUnit, nameSpell)
if event and TPROBE_ALWAYS_EVENTS[event] then
return true
end
if TotemBar.tprobeNameHit(nameUnit) then
return true
end
if TotemBar.tprobeNameHit(nameSpell) then
return true
end
return false
end
-- Pure: the "msg" half of one kept line (the ring buffer's "t;event;msg"
-- shape from pulsecalPush/pulsecalFormat above supplies t and event already).
-- nameUnit and nameSpell are BOTH kept, deliberately never collapsed into a
-- single "resolved name" -- the whole point of the probe is to see whether
-- they AGREE (suspicion (a): does a Searing Totem pulse's SpellInfo name
-- ever equal "Searing Totem" at all?). Every argument may be nil; tostring()
-- turns that into the literal string "nil", never a concat error.
function TotemBar.tprobeBuildDetail(a1, a2, a3, a4, nameUnit, nameSpell, ownerName, health, latched)
return "a1=" .. tostring(a1)
.. ";a2=" .. tostring(a2)
.. ";a3=" .. tostring(a3)
.. ";a4=" .. tostring(a4)
.. ";nameUnit=" .. tostring(nameUnit)
.. ";nameSpell=" .. tostring(nameSpell)
.. ";owner=" .. tostring(ownerName)
.. ";hp=" .. tostring(health)
.. ";latched=" .. tostring(latched)
end
-- Pure: would the PRODUCTIVE handler (core/cast.lua's guidFrame) have
-- latched a GUID for this event's totem, and what does that element's
-- record show right now? nameUnit is tried first (arg1's own UnitName --
-- the identity the productive UNIT_MODEL_CHANGED path itself keys off),
-- nameSpell as a fallback (a totem that only ever surfaces through a SPELL
-- name elementOfFn doesn't recognise -- e.g. "Searing Bolt" -- correctly
-- resolves to nil here, which is itself the diagnostic: the productive
-- UNIT_CASTEVENT path (core/cast.lua's TotemBar.elementFromCastName) has
-- exactly the same blind spot). activeTotems/elementOfFn are injected (same
-- dependency-injection convention core/cast.lua's own TotemBar.elementForGuid
-- uses) so this needs no WoW globals to test. Returns nil both when no
-- element matches AND when the matched element's record has no guid yet --
-- either way there is no guid to report, matching the brief's own
-- "latched=<guid|nil>" literally.
function TotemBar.tprobeLatchedGuid(activeTotems, elementOfFn, nameUnit, nameSpell)
if not elementOfFn then
return nil
end
local element = elementOfFn(nameUnit) or elementOfFn(nameSpell)
if not element or not activeTotems then
return nil
end
local rec = activeTotems[element]
if not rec then
return nil
end
return rec.guid
end
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- WoW-gated capture (skipped entirely by the offline test runner). -- WoW-gated capture (skipped entirely by the offline test runner).
if CreateFrame then if CreateFrame then
@@ -91,8 +200,11 @@ if CreateFrame then
-- SuperWoW: casterGUID, targetGUID, type, spellId, castTime. -- SuperWoW: casterGUID, targetGUID, type, spellId, castTime.
-- Only record casts whose spell name mentions Totem (SpellInfo -- Only record casts whose spell name mentions Totem (SpellInfo
-- is a SuperWoW API; guarded because the event only exists there -- is a SuperWoW API; guarded because the event only exists there
-- anyway). -- anyway). arg4 is guarded too (adversarial review, 2026-08-19
local sname = SpellInfo and SpellInfo(arg4) -- destroyed-totem session) -- SpellInfo(nil) is unproven safe on
-- this client, and a UNIT_CASTEVENT with no spellId is possible
-- in principle (e.g. a non-cast event type on this arg3 slot).
local sname = (SpellInfo and arg4) and SpellInfo(arg4)
if sname and string.find(sname, "Totem", 1, true) then if sname and string.find(sname, "Totem", 1, true) then
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event, TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event,
tostring(arg1) .. ";" .. tostring(arg3) .. ";" .. tostring(sname)) tostring(arg1) .. ";" .. tostring(arg3) .. ";" .. tostring(sname))
@@ -152,4 +264,129 @@ if CreateFrame then
.. " records. Usage: /tb pulsecal start|stop|dump") .. " records. Usage: /tb pulsecal start|stop|dump")
end end
end end
-- ===== /tb tprobe -- reload-survivable diagnostic probe =====
-- Pure observation only: never touches TotemBar.activeTotems or any
-- other productive state, only READS TotemBar.activeTotems/elementOf to
-- report what the productive handler would have seen (see
-- tprobeLatchedGuid above).
local tprobeCapturing = false
local tprobeBuf = {}
local tprobeState = { n = 0, idx = 1 }
local function tprobeExport()
if ExportFile then
-- Filename WITHOUT extension - the client appends .txt itself.
ExportFile("tb_tprobe", TotemBar.pulsecalFormat(tprobeBuf, TotemBar.TPROBE_CAP, tprobeState))
end
end
-- Takes explicit args (not the WoW globals directly) so the pcall below
-- wraps a real function value, not a closure built fresh every event.
local function tprobeHandleEvent(ev, a1, a2, a3, a4)
local nameUnit = nil
if a1 and type(UnitName) == "function" then
nameUnit = UnitName(a1)
end
local nameSpell = nil
if a4 and type(SpellInfo) == "function" then
nameSpell = SpellInfo(a4)
end
if not TotemBar.tprobeShouldKeep(ev, nameUnit, nameSpell) then
return
end
local ownerName = nil
if a1 and type(UnitName) == "function" then
ownerName = UnitName(a1 .. "owner")
end
local health = nil
if a1 and type(UnitHealth) == "function" then
health = UnitHealth(a1)
end
local latched = TotemBar.tprobeLatchedGuid(TotemBar.activeTotems, TotemBar.elementOf, nameUnit, nameSpell)
local detail = TotemBar.tprobeBuildDetail(a1, a2, a3, a4, nameUnit, nameSpell, ownerName, health, latched)
TotemBar.pulsecalPush(tprobeBuf, TotemBar.TPROBE_CAP, tprobeState, GetTime(), ev, detail)
-- Export on EVERY kept event, not only on /tb tprobe dump - the
-- whole reason this probe exists is that two earlier measurement
-- attempts were wiped by a /reload before a manual dump could run.
tprobeExport()
end
local tprobeFrame = CreateFrame("Frame", "TotemBarTProbeFrame", UIParent)
tprobeFrame:SetScript("OnEvent", function()
if not tprobeCapturing then
return
end
-- pcall-wrapped end to end (per spec): observation must never be
-- able to break anything else this client does.
pcall(tprobeHandleEvent, event, arg1, arg2, arg3, arg4)
end)
local function tprobeArm()
if tprobeCapturing then
return
end
tprobeCapturing = true
-- Native 1.12 events, safe to register directly.
tprobeFrame:RegisterEvent("UNIT_MODEL_CHANGED")
tprobeFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH")
tprobeFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH")
-- SuperWoW/nampower-only events; pcall-guarded exactly like
-- calFrame's own UNIT_CASTEVENT registration above.
pcall(function() tprobeFrame:RegisterEvent("UNIT_CASTEVENT") end)
pcall(function() tprobeFrame:RegisterEvent("UNIT_DIED") end)
end
local function tprobeDisarm()
tprobeCapturing = false
tprobeFrame:UnregisterAllEvents()
end
function TotemBar.TProbe(sub)
if sub == "on" then
tprobeArm()
if TotemBarDB then
TotemBarDB.tprobeArmed = true
end
ChatOut:AddMessage("TotemBar: tprobe capture STARTED (" .. tprobeState.n .. " records kept so far).")
elseif sub == "off" then
tprobeDisarm()
if TotemBarDB then
TotemBarDB.tprobeArmed = false
end
ChatOut:AddMessage("TotemBar: tprobe capture stopped (" .. tprobeState.n .. " records kept).")
elseif sub == "dump" then
if not ExportFile then
ChatOut:AddMessage("TotemBar: tprobe dump needs SuperWoW (ExportFile missing).")
return
end
tprobeExport()
ChatOut:AddMessage("TotemBar: tprobe dump written (imports\\tb_tprobe.txt, "
.. tprobeState.n .. " records).")
else
local on = tprobeCapturing and "ON" or "OFF"
ChatOut:AddMessage("TotemBar: tprobe " .. on .. ", " .. tprobeState.n
.. " records. Usage: /tb tprobe on|off|dump|status")
end
end
-- Re-arm after /reload if it was left on. SavedVariables survive a
-- /reload; this frame's event registrations do not (a fresh Lua state
-- runs on every reload) - so an on->reload->still-on session needs its
-- own ADDON_LOADED hook to re-attach tprobeFrame's events, same "wait for
-- ADDON_LOADED before trusting TotemBarDB" convention core/config.lua's
-- ensureDefaults (called from ui.lua's own ADDON_LOADED handler) and
-- minimap.lua's build-on-load both already use, rather than assuming
-- load order against either of those.
local tprobeInitFrame = CreateFrame("Frame", "TotemBarTProbeInitFrame", UIParent)
tprobeInitFrame:RegisterEvent("ADDON_LOADED")
tprobeInitFrame:SetScript("OnEvent", function()
if event == "ADDON_LOADED" and arg1 == "TotemBar" then
tprobeInitFrame:UnregisterEvent("ADDON_LOADED")
if TotemBarDB and TotemBarDB.tprobeArmed then
tprobeArm()
ChatOut:AddMessage("TotemBar: tprobe re-armed after reload (" .. tprobeState.n .. " records kept).")
end
end
end)
end end
+92 -14
View File
@@ -53,6 +53,14 @@ TotemBar.BAR_LAYOUTS = { "1x6", "2x3", "3x2" }
local TIMER_UPDATE_INTERVAL = 0.1 local TIMER_UPDATE_INTERVAL = 0.1
local timerElapsed = 0 local timerElapsed = 0
-- Destroyed-totem liveness poll throttle (see TotemBar.GUID_LIVENESS_POLL_INTERVAL,
-- core/cast.lua): a SLOWER gate layered on top of the 0.1s tick above, so
-- UnitExists/UnitHealth/UnitIsDeadOrGhost are only actually called once
-- every ~0.5s even though UpdateTimerDisplays itself runs 10x/sec. GetTime()-based
-- rather than an arg1 accumulator since UpdateTimerDisplays has no frame
-- delta of its own to accumulate.
local lastGuidLivenessCheck = nil
-- Fallback icon for an unresolved/unknown totem name (flyout icons, -- Fallback icon for an unresolved/unknown totem name (flyout icons,
-- ResolveTotemIcon, the pending-assignment panel). Element buttons' own -- ResolveTotemIcon, the pending-assignment panel). Element buttons' own
-- empty-slot state uses the custom sheet's per-element glyph instead -- empty-slot state uses the custom sheet's per-element glyph instead
@@ -709,14 +717,12 @@ RefreshFlyoutMana = function()
return return
end end
local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil
local clearcasting = TotemBar.hasClearcasting and TotemBar.hasClearcasting()
for i = 1, MAX_FLYOUT_ICONS do for i = 1, MAX_FLYOUT_ICONS do
local ico = flyoutIcons[i] local ico = flyoutIcons[i]
if ico:IsShown() and ico.totemName then if ico:IsShown() and ico.totemName then
local oom = false -- No Clearcasting exemption (2026-08-28): Elemental Focus does not
if not clearcasting then -- cover totems, see core/manacost.lua.
oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(ico.totemName), playerMana) local oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(ico.totemName), playerMana)
end
local r, g, b, key = TotemBar.iconTintFor(false, oom) local r, g, b, key = TotemBar.iconTintFor(false, oom)
if key ~= ico.tintKey then if key ~= ico.tintKey then
ico.icon:SetVertexColor(r, g, b) ico.icon:SetVertexColor(r, g, b)
@@ -1388,11 +1394,21 @@ UpdateTimerDisplays = function()
local activeTotems = TotemBar.activeTotems local activeTotems = TotemBar.activeTotems
local outOfRangeFound = false -- OR-accumulator across this pass; written to anyOutOfRange at the end 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 -- Destroyed-totem liveness poll gate for this pass (see local
-- button: hasClearcasting walks the player's buffs, and there is no reason -- lastGuidLivenessCheck above and TotemBar.GUID_LIVENESS_POLL_INTERVAL,
-- to do that four times for one tick. -- core/cast.lua) -- computed ONCE per call, not per element, so all four
-- slots share the same ~0.5s cadence.
local doLivenessPoll = TotemBar.shouldPollLiveness(lastGuidLivenessCheck, now,
TotemBar.GUID_LIVENESS_POLL_INTERVAL)
if doLivenessPoll then
lastGuidLivenessCheck = now
end
-- Mana input for the out-of-mana dim, read ONCE per pass rather than per
-- button. (The Clearcasting buff walk that used to sit here is gone with the
-- exemption itself -- Elemental Focus does not cover totems, see
-- core/manacost.lua -- so this pass no longer scans buffs at all.)
local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil
local clearcasting = TotemBar.hasClearcasting and TotemBar.hasClearcasting()
for i = 1, table.getn(elements) do for i = 1, table.getn(elements) do
local element = elements[i] local element = elements[i]
@@ -1411,6 +1427,48 @@ UpdateTimerDisplays = function()
end end
end end
-- Destroyed-totem liveness poll: only runs once a GUID was
-- latched (core/cast.lua's UNIT_CASTEVENT/UNIT_MODEL_CHANGED
-- hooks) AND this pass is due (doLivenessPoll, ~0.5s cadence).
-- On a client without SuperWoW no record ever gets a guid, so
-- this is a no-op there -- today's behaviour, unchanged. A
-- destroyed verdict clears the record the SAME way natural
-- expiry does above, so the countdown text, duration ring,
-- pulse animation and out-of-range tint below all disappear in
-- one step -- they already derive from ownRecord's presence.
if ownRecord and ownRecord.guid and doLivenessPoll then
local exists, guid = nil, nil
if type(UnitExists) == "function" then
exists, guid = UnitExists(ownRecord.guid)
end
local health, deadOrGhost = nil, nil
if exists then
if type(UnitHealth) == "function" then
health = UnitHealth(ownRecord.guid)
end
if type(UnitIsDeadOrGhost) == "function" then
deadOrGhost = UnitIsDeadOrGhost(ownRecord.guid)
end
end
if TotemBar.totemDestroyed(exists, health, deadOrGhost) then
-- Tombstones the element (see TotemBar.evictDestroyedTotem /
-- TotemBar.destroyedTombstone, core/cast.lua) so
-- resolveRemaining below stops trusting GTI for it until
-- the evicted record's own natural expiry -- otherwise
-- pfUI's libtotem (itself just another blind duration
-- timer) keeps reporting the SAME destroyed totem active
-- and the countdown/ring/pulse reappear from the GTI
-- branch a moment after being cleared here (adversarial-
-- review finding #1). Mutates TotemBar.activeTotems, the
-- SAME table `activeTotems` above already points to, so
-- ownRecord/ownRemaining are nil'd out explicitly to
-- match rather than re-reading it.
TotemBar.evictDestroyedTotem(element)
ownRemaining = nil
ownRecord = nil
end
end
-- Spawn ripple: a NEW own cast (start changed) fires one ripple. -- Spawn ripple: a NEW own cast (start changed) fires one ripple.
-- The freshness check keeps restored/old records from replaying. -- The freshness check keeps restored/old records from replaying.
-- Gated on showPulseWaves (the ripple's own toggle), not -- Gated on showPulseWaves (the ripple's own toggle), not
@@ -1453,7 +1511,19 @@ UpdateTimerDisplays = function()
end end
end end
local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining) -- Destroyed-totem tombstone (adversarial-review finding #1, see
-- TotemBar.evictDestroyedTotem above and TotemBar.tombstoneActive/
-- resolveRemaining, core/cast.lua): while active, resolveRemaining
-- below must not trust GTI for this element even though hasGTI/
-- gtiActive above were computed with no knowledge of the eviction.
-- Swept once expired so the table never holds a stale entry past
-- its own usefulness (bounded to 4 keys regardless, but tidy).
local tombstoned = TotemBar.tombstoneActive(TotemBar.destroyedTombstone[element], now)
if not tombstoned and TotemBar.destroyedTombstone[element] then
TotemBar.destroyedTombstone[element] = nil
end
local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining, tombstoned)
if remainingVal and TotemBarDB.showTimerText then if remainingVal and TotemBarDB.showTimerText then
if not btn.timerVisible then if not btn.timerVisible then
@@ -1604,7 +1674,7 @@ UpdateTimerDisplays = function()
-- whichever ran last would win (see TotemBar.iconTintFor). -- whichever ran last would win (see TotemBar.iconTintFor).
local chosenName = TotemBarDB.chosen and TotemBarDB.chosen[element] local chosenName = TotemBarDB.chosen and TotemBarDB.chosen[element]
local oom = false local oom = false
if chosenName and not clearcasting then if chosenName then
oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(chosenName), playerMana) oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(chosenName), playerMana)
end end
@@ -1850,7 +1920,11 @@ function TotemBar.DumpRingRenderState()
end end
end end
end end
local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining) -- Same tombstone read UpdateTimerDisplays' live pass does (see
-- its own comment) -- read-only here, the live pass owns
-- sweeping expired entries.
local tombstoned = TotemBar.tombstoneActive(TotemBar.destroyedTombstone[element], now)
local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining, tombstoned)
local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration, local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration,
ownRemaining, ownRecord and ownRecord.duration) ownRemaining, ownRecord and ownRecord.duration)
local idxStr = "n/a" local idxStr = "n/a"
@@ -1871,7 +1945,8 @@ function TotemBar.DumpRingRenderState()
.. " | recompute: remainingVal=" .. tostring(remainingVal) .. " | recompute: remainingVal=" .. tostring(remainingVal)
.. " totalDur=" .. tostring(totalDur) .. " totalDur=" .. tostring(totalDur)
.. " ringIdx=" .. idxStr .. " ringIdx=" .. idxStr
.. " timeColor=" .. colorStr .. "\n" .. " timeColor=" .. colorStr
.. " tombstoned=" .. tostring(tombstoned) .. "\n"
-- Control group: the countdown text the player actually sees, so a -- Control group: the countdown text the player actually sees, so a
-- mismatch (text shown, ring both cached+live hidden) pins the -- mismatch (text shown, ring both cached+live hidden) pins the
@@ -2526,8 +2601,11 @@ local function HandleSlashCommand(msg)
elseif string.find(cmd, "^pulsecal") then elseif string.find(cmd, "^pulsecal") then
local _, _, sub = string.find(cmd, "^pulsecal%s*(%a*)") local _, _, sub = string.find(cmd, "^pulsecal%s*(%a*)")
if TotemBar.PulseCal then TotemBar.PulseCal(sub or "") end if TotemBar.PulseCal then TotemBar.PulseCal(sub or "") end
elseif string.find(cmd, "^tprobe") then
local _, _, sub = string.find(cmd, "^tprobe%s*(%a*)")
if TotemBar.TProbe then TotemBar.TProbe(sub or "") end
else else
ChatOut:AddMessage("TotemBar: unknown command '" .. msg .. "'. Usage: /tb, /tb lock, /tb scan, /tb assign, /tb options, /tb bind, /tb manadump, /tb tdump, /tb pulsecal") ChatOut:AddMessage("TotemBar: unknown command '" .. msg .. "'. Usage: /tb, /tb lock, /tb scan, /tb assign, /tb options, /tb bind, /tb manadump, /tb tdump, /tb pulsecal, /tb tprobe")
end end
end end