diff --git a/CHANGELOG.md b/CHANGELOG.md index 222e171..7b96861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,82 @@ 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 `" () 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 `" 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 ### Added diff --git a/README.md b/README.md index 8ea3e16..83e5bec 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ server data, not guesswork. No dependencies; adopts the **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 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 diff --git a/TotemBar.toc b/TotemBar.toc index a433096..00dd32c 100644 --- a/TotemBar.toc +++ b/TotemBar.toc @@ -2,7 +2,7 @@ ## Title: TotemBar ## Notes: Shaman totem bar - pick one totem per element, cast it, or spam-cycle all four from a macro. ## Author: ShempError -## Version: 0.3.0 +## Version: 0.4.0 ## SavedVariables: TotemBarDB core\totemdata.lua diff --git a/core/cast.lua b/core/cast.lua index fb3a797..28022ca 100644 --- a/core/cast.lua +++ b/core/cast.lua @@ -31,6 +31,13 @@ TotemBar.RECALL_OVERRIDE_MAX = 5 -- 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) @@ -44,6 +51,20 @@ TotemBar.castState = TotemBar.castState or { -- 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. @@ -120,14 +141,25 @@ end -- 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) +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). - 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 end if ownRemaining and ownRemaining > 0 then @@ -136,6 +168,18 @@ function TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining) 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 @@ -158,6 +202,359 @@ function TotemBar.rangeTintActive(hasOwnRecord, hasGTI, gtiTracked, gtiActive) 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 @@ -268,8 +665,19 @@ TotemBar.GCD_MAX = 1.6 -- "cooldown". Every input may be nil -- an unknown never blocks, exactly like -- notEnoughMana/confidentNoneOut. Mana is reported first because it is the one -- the player can act on. -function TotemBar.castGateReason(cost, mana, cdStart, cdDuration, gcdMax, clearcasting) - if not clearcasting and TotemBar.notEnoughMana(cost, mana) then +-- +-- 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 @@ -295,8 +703,7 @@ local function computeCastBlock(element, totemName) cdStart, cdDuration = GetSpellCooldown(idx, BOOKTYPE_SPELL) end end - return TotemBar.castGateReason(cost, mana, cdStart, cdDuration, - TotemBar.GCD_MAX, TotemBar.hasClearcasting and TotemBar.hasClearcasting()) + 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 @@ -387,6 +794,12 @@ function TotemBar.recordCast(element, totemName) -- 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, @@ -398,6 +811,14 @@ function TotemBar.recordCast(element, totemName) 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 @@ -548,7 +969,10 @@ function TotemBar.messageSet(list) end -- 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 -- (ANY spell -- see noteCastAttempt above), or nil. @@ -565,16 +989,16 @@ end -- missing/empty allowlist) fails CLOSED -- no revoke -- same -- policy as an unattributable failure above. function TotemBar.attributeCastFailure(lastAttempt, now, window, message, allowlist) - if not lastAttempt or not lastAttempt.element then - return nil, nil + 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 + return nil, nil, nil end if message ~= nil and (not allowlist or not allowlist[message]) then - return nil, nil + return nil, nil, nil end - return lastAttempt.element, lastAttempt.name + return lastAttempt.element, lastAttempt.name, lastAttempt.recall end -- ===== Universal cast hooks: catch totem casts from ANY path ===== @@ -726,7 +1150,19 @@ if CreateFrame then end -- No name, no revoke. An unnamed failure could just as well be -- the Lightning Bolt the player pressed a moment after the totem. - if not name or not TotemBar.elementOf(name) then + 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) @@ -742,9 +1178,14 @@ if CreateFrame then castFailureMessages = buildCastFailureMessages() end end - local element, name = TotemBar.attributeCastFailure(TotemBar.lastCastAttempt, + local element, name, recall = TotemBar.attributeCastFailure(TotemBar.lastCastAttempt, 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) -- Consume: a second event for the SAME refusal (e.g. -- SPELLCAST_FAILED right after UI_ERROR_MESSAGE) must not @@ -756,6 +1197,183 @@ if CreateFrame then 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 @@ -839,6 +1457,63 @@ function TotemBar.clearActiveTotems() 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 @@ -995,6 +1670,21 @@ local function castRecallNoQueue() 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 @@ -1114,8 +1804,11 @@ function TotemBar.manualRecall() if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end -- Totemic Recall drops every active totem at once; clear own-tracking so -- the icons' countdowns disappear too (GetTotemInfo, if present, will also - -- reflect this). - TotemBar.clearActiveTotems() + -- 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 @@ -1146,7 +1839,12 @@ function TotemBar.recallAndCastAll() if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard) and TotemBar.recallReady() and TotemBar.anyTotemOut() then 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 TotemBar.castAll() TotemBar.castState.lastDeployTime = now @@ -1177,7 +1875,11 @@ function TotemBar.dropSetKey(keystate) if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard) and TotemBar.recallReady() and TotemBar.anyTotemOut() then 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 else -- Release (or nil fallback): place the set now, in this hardware frame. @@ -1215,12 +1917,40 @@ function TotemBar.DumpTimerState() 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) .. "\n" + .. " remaining=" .. tostring(rem) + .. " guid=" .. tostring(rec.guid) + .. " exists=" .. tostring(existsRaw) + .. " health=" .. tostring(healthRaw) + .. " deadOrGhost=" .. tostring(deadRaw) .. "\n" 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 diff --git a/core/manacost.lua b/core/manacost.lua index 3fb3723..06f8b7e 100644 --- a/core/manacost.lua +++ b/core/manacost.lua @@ -73,23 +73,14 @@ function TotemBar.notEnoughMana(cost, mana) return mana < cost end --- Clearcasting (Elemental Focus) makes the next damage spell free while the --- tooltip still shows the full price, so every mana verdict has to stand down --- while it is up -- otherwise the bar greys out a totem that casts fine and the --- cast gate drops its timer. --- --- Matched by buff ICON, like the out-of-range tint's totem detection (see --- TotemBar.hasBuffWithIcon): 1.12 gives no buff NAME without a tooltip scan per --- buff per check. VERIFY in-game -- if this icon is wrong the only effect is --- that the two features lose their Clearcasting exemption, not that they break. -TotemBar.CLEARCAST_ICON = "Spell_Shadow_ManaBurn" - -function TotemBar.hasClearcasting() - if not TotemBar.hasBuffWithIcon then - return false - end - return TotemBar.hasBuffWithIcon(TotemBar.CLEARCAST_ICON) -end +-- Clearcasting (Elemental Focus) used to exempt every mana verdict here. That was +-- WRONG and is gone (2026-08-28, player-reported): Elemental Focus zeroes the next +-- DAMAGE spell, not a totem summon, so a totem costs full price while Clearcasting +-- 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 +-- up. TotemBar.hasClearcasting/CLEARCAST_ICON were removed with it -- the icon was +-- never verified in-game and now has no consumer. TotemBar.hasBuffWithIcon stays: +-- the out-of-range tint still uses it. -- 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 @@ -99,9 +90,6 @@ function TotemBar.totemOutOfMana(name) if not name or type(UnitMana) ~= "function" then return false end - if TotemBar.hasClearcasting() then - return false - end return TotemBar.notEnoughMana(TotemBar.getTotemManaCost(name), UnitMana("player")) end diff --git a/core/pulsecal.lua b/core/pulsecal.lua index dddf2ee..277c8cb 100644 --- a/core/pulsecal.lua +++ b/core/pulsecal.lua @@ -10,6 +10,18 @@ TotemBar = TotemBar or {} 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, -- idx = next write slot 1..cap }. Record tables are REUSED on wrap (no -- allocation growth while capturing). @@ -57,6 +69,103 @@ function TotemBar.pulsecalFormat(buf, cap, state) return table.concat(lines, "\n") 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=" 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). if CreateFrame then @@ -91,8 +200,11 @@ if CreateFrame then -- SuperWoW: casterGUID, targetGUID, type, spellId, castTime. -- Only record casts whose spell name mentions Totem (SpellInfo -- is a SuperWoW API; guarded because the event only exists there - -- anyway). - local sname = SpellInfo and SpellInfo(arg4) + -- anyway). arg4 is guarded too (adversarial review, 2026-08-19 + -- 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 TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event, tostring(arg1) .. ";" .. tostring(arg3) .. ";" .. tostring(sname)) @@ -152,4 +264,129 @@ if CreateFrame then .. " records. Usage: /tb pulsecal start|stop|dump") 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 diff --git a/ui.lua b/ui.lua index a9c7211..f44b4d7 100644 --- a/ui.lua +++ b/ui.lua @@ -53,6 +53,14 @@ TotemBar.BAR_LAYOUTS = { "1x6", "2x3", "3x2" } local TIMER_UPDATE_INTERVAL = 0.1 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, -- ResolveTotemIcon, the pending-assignment panel). Element buttons' own -- empty-slot state uses the custom sheet's per-element glyph instead @@ -709,14 +717,12 @@ RefreshFlyoutMana = function() return end local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil - local clearcasting = TotemBar.hasClearcasting and TotemBar.hasClearcasting() for i = 1, MAX_FLYOUT_ICONS do local ico = flyoutIcons[i] if ico:IsShown() and ico.totemName then - local oom = false - if not clearcasting then - oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(ico.totemName), playerMana) - end + -- No Clearcasting exemption (2026-08-28): Elemental Focus does not + -- cover totems, see core/manacost.lua. + local oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(ico.totemName), playerMana) local r, g, b, key = TotemBar.iconTintFor(false, oom) if key ~= ico.tintKey then ico.icon:SetVertexColor(r, g, b) @@ -1388,11 +1394,21 @@ UpdateTimerDisplays = function() local activeTotems = TotemBar.activeTotems local outOfRangeFound = false -- OR-accumulator across this pass; written to anyOutOfRange at the end - -- Mana inputs for the out-of-mana dim, read ONCE per pass rather than per - -- button: hasClearcasting walks the player's buffs, and there is no reason - -- to do that four times for one tick. + -- Destroyed-totem liveness poll gate for this pass (see local + -- lastGuidLivenessCheck above and TotemBar.GUID_LIVENESS_POLL_INTERVAL, + -- 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 clearcasting = TotemBar.hasClearcasting and TotemBar.hasClearcasting() for i = 1, table.getn(elements) do local element = elements[i] @@ -1411,6 +1427,48 @@ UpdateTimerDisplays = function() 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. -- The freshness check keeps restored/old records from replaying. -- Gated on showPulseWaves (the ripple's own toggle), not @@ -1453,7 +1511,19 @@ UpdateTimerDisplays = function() 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 not btn.timerVisible then @@ -1604,7 +1674,7 @@ UpdateTimerDisplays = function() -- whichever ran last would win (see TotemBar.iconTintFor). local chosenName = TotemBarDB.chosen and TotemBarDB.chosen[element] local oom = false - if chosenName and not clearcasting then + if chosenName then oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(chosenName), playerMana) end @@ -1850,7 +1920,11 @@ function TotemBar.DumpRingRenderState() 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, ownRemaining, ownRecord and ownRecord.duration) local idxStr = "n/a" @@ -1871,7 +1945,8 @@ function TotemBar.DumpRingRenderState() .. " | recompute: remainingVal=" .. tostring(remainingVal) .. " totalDur=" .. tostring(totalDur) .. " ringIdx=" .. idxStr - .. " timeColor=" .. colorStr .. "\n" + .. " timeColor=" .. colorStr + .. " tombstoned=" .. tostring(tombstoned) .. "\n" -- Control group: the countdown text the player actually sees, so a -- mismatch (text shown, ring both cached+live hidden) pins the @@ -2526,8 +2601,11 @@ local function HandleSlashCommand(msg) elseif string.find(cmd, "^pulsecal") then local _, _, sub = string.find(cmd, "^pulsecal%s*(%a*)") 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 - 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