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