commit 06f581e8b5068ce57648ac2c063f3c63fddc8b32
Author: ShempError <689+shemperror@noreply.octowow.st>
Date: Thu Oct 15 12:29:38 2026 +0200
TotemBar 0.4.1
diff --git a/Bindings.xml b/Bindings.xml
new file mode 100644
index 0000000..90dda88
--- /dev/null
+++ b/Bindings.xml
@@ -0,0 +1,34 @@
+
+ TotemBar.dropSetKey(keystate);
+ TotemBar.CastRecall();
+ TotemBar.ToggleBar();
+ TotemBar.ToggleOptions();
+ TotemBar.ToggleBindMode();
+ TotemBar.CastElement("Fire");
+ TotemBar.CastElement("Earth");
+ TotemBar.CastElement("Water");
+ TotemBar.CastElement("Air");
+ TotemBar.CastTotem("Searing Totem");
+ TotemBar.CastTotem("Fire Nova Totem");
+ TotemBar.CastTotem("Magma Totem");
+ TotemBar.CastTotem("Flametongue Totem");
+ TotemBar.CastTotem("Frost Resistance Totem");
+ TotemBar.CastTotem("Earthbind Totem");
+ TotemBar.CastTotem("Stoneclaw Totem");
+ TotemBar.CastTotem("Stoneskin Totem");
+ TotemBar.CastTotem("Strength of Earth Totem");
+ TotemBar.CastTotem("Tremor Totem");
+ TotemBar.CastTotem("Fire Resistance Totem");
+ TotemBar.CastTotem("Healing Stream Totem");
+ TotemBar.CastTotem("Mana Spring Totem");
+ TotemBar.CastTotem("Poison Cleansing Totem");
+ TotemBar.CastTotem("Disease Cleansing Totem");
+ TotemBar.CastTotem("Mana Tide Totem");
+ TotemBar.CastTotem("Grace of Air Totem");
+ TotemBar.CastTotem("Grounding Totem");
+ TotemBar.CastTotem("Nature Resistance Totem");
+ TotemBar.CastTotem("Sentry Totem");
+ TotemBar.CastTotem("Windfury Totem");
+ TotemBar.CastTotem("Windwall Totem");
+ TotemBar.CastTotem("Tranquil Air Totem");
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..1ac3a73
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,365 @@
+# Changelog
+
+All notable changes to TotemBar are documented here.
+
+## v0.4.1 — 2026-08-31
+
+### Fixed
+- A totem destroyed by the enemy inside the ~0.4s window between a Totemic Recall wipe and its
+ refusal being attributed now stays dead instead of having its countdown resurrected by the
+ refused-recall restore (`core/cast.lua`).
+
+## v0.4.0 — 2026-08-30
+
+### Added
+- **Destroyed totems can now be detected while SuperWoW is present (verified against captured
+ event log 2026-08-20, live eviction pending retest).** A totem killed before its timer ran
+ out used to keep counting down as if it were still standing — both TotemBar's own tracking
+ and pfUI's libtotem are blind, duration-only timers, and neither ever checked whether the
+ totem itself was still alive (pfUI's `GetTotemInfo` was previously assumed to cover this
+ case; checked against its source — it does not). A live event tap against the real client
+ (2026-08-20) captured totem spawn/pulse/death lines and found the original mechanism could
+ never have fired at all: the spawn/pulse latch paths compared totem names for *exact*
+ equality, but the live client hands back a *ranked* name ("Searing Totem VI"), never the bare
+ book name; and the pulse-cast latch mapped through the pulse *spell's* name ("Searing Bolt"),
+ which is not the totem's own name either. Both are now fixed, and a third, GUID-free signal
+ was added as the primary path:
+ 1. **Primary — `CHAT_MSG_COMBAT_FRIENDLY_DEATH`** (native 1.12, needs no SuperWoW/nampower):
+ TurtleWoW emits a totem's own death as `" () is destroyed."` verbatim; parsed
+ and evicted once the owner resolves to the player and the name rank-tolerantly matches a
+ tracked totem. The generic vanilla `" dies."` form is accepted as a second, stricter
+ shape — since it carries no owner, it only evicts a candidate that already has a latched
+ GUID, confirmed via a live `UnitExists`/`UnitHealth`/`UnitIsDeadOrGhost` read.
+ 2. **`UNIT_CASTEVENT`** (SuperWoW), for totems that periodically cast their own pulse — now
+ keyed off `UnitName(casterGUID)` (the totem unit's own, rank-tolerant name) instead of the
+ pulse spell's name; covers only pulsing totems (Searing/Magma/Fire Nova/Mana Spring/
+ Healing Stream/Mana Tide/the Cleansing totems), gated on the caster's resolved owner
+ matching the player.
+ 3. **`UNIT_MODEL_CHANGED`** (native 1.12), for totems that never cast anything visible — now
+ rank-tolerant instead of an exact name compare.
+ 4. **`UNIT_DIED`** (nampower), extended with a GUID-free fallback: when no GUID was ever
+ latched, a rank-tolerant name + owner match at the death instant (both resolve reliably
+ there per the captured log) still evicts.
+
+ A totem confirmed destroyed is evicted immediately and tombstoned for the remainder of its
+ own natural duration, so pfUI's libtotem — itself just another blind timer — cannot
+ resurrect the countdown/ring/pulse a moment later; a real re-cast on that element clears the
+ tombstone right away. This clears the countdown, duration ring, pulse animation and
+ out-of-range tint together, since all four already derive from the same tracking record. On
+ a client without SuperWoW, only the primary `CHAT_MSG_COMBAT_FRIENDLY_DEATH` signal is
+ active; without either SuperWoW or TurtleWoW's own death-line wording, behaviour is
+ byte-for-byte unchanged from before this feature. The decision logic (name matching, line
+ parsing, eviction gating) is fully offline-tested against the captured fixture lines (95
+ assertions in `tools/luatests/test_destroy.lua`); a live totem-kill retest with the addon
+ in the loop is still pending — `/tb tdump` prints each totem's latched `guid`, the raw
+ `UnitExists`/`UnitHealth`/`UnitIsDeadOrGhost` reads, and (for an evicted slot) its tombstone
+ status, for that verification.
+
+### Fixed
+- **A totem's countdown no longer starts when the cast was refused for lack of mana.** The
+ pre-cast mana gate stood down completely while Clearcasting (Elemental Focus) was up, on the
+ assumption that it zeroes the next cast's price. Elemental Focus covers the shaman's damage
+ spells, not totem summons -- so a totem costs full mana while Clearcasting is up, and since
+ totems are the only thing this addon casts, that exemption disabled the mana gate outright.
+ The client then refused the cast while the timer had already been recorded, leaving a
+ countdown running for a totem that was never placed. The exemption is gone from the cast
+ gate, the out-of-mana dim and the flyout tint alike; the unverified buff-icon constant it
+ relied on went with it. The gate still accepts (and ignores) the old sixth argument, so no
+ stale caller can quietly reinstate it.
+
+- **A Totemic Recall the server refuses no longer wipes your totem timers.**
+ Recall already checked whether the cast could go out *before* touching the
+ tracking, but a press that passes that check can still be refused after the
+ fact — moving at the wrong moment, a stun or silence landing first, or plain
+ latency. The tracking was cleared unconditionally the instant the cast was
+ issued, so a refused Recall left the addon believing nothing was out while a
+ full set kept standing — the same phantom-state bug class the totem casts'
+ own two-layer guard already closed, mirrored. The recall paths now remember
+ what they cleared, and the same failure signals that take back a refused
+ totem's countdown (the exact per-spell one with nampower, the error-message
+ fallback without it) put the timers back. The restore follows the same
+ narrow rules as the totem-cast revoke: only when the Recall itself was the
+ last cast attempted, only within a short window afterward, and never over a
+ slot a new cast has already refilled — when attribution is uncertain it
+ stays silent, since here a *wrongly restored* timer would be the phantom.
+ Covers the Recall button, its keybind, and the DropSet keybind's recall
+ stroke alike.
+
+## v0.3.0 — 2026-08-15
+
+### Added
+- **Totems you cannot afford are dimmed.** Element buttons and the hover flyout
+ grey out while your mana is below the totem's cost, so the bar shows what is
+ actually castable instead of only what is chosen. The cost is the real one for
+ your highest known rank, including cost talents, and the dim composes with the
+ out-of-range tint rather than replacing it — an out-of-range totem you also
+ cannot afford still reads as red, just darker. Clearcasting suspends it, since
+ the next spell is free while it is up.
+
+### Fixed
+- **A refused cast no longer starts a countdown, and one that already started
+ can now be taken back.** Pressing a totem without the mana for it (or while
+ it is genuinely on cooldown) used to start a full timer for a totem that was
+ never placed — which also made Totemic Recall believe something was out and
+ burn its 6s cooldown on an empty board. The cast's chances are now checked
+ *before* it leaves, so an attempt that cannot have gone through is never
+ tracked in the first place. A second layer catches what the pre-check
+ cannot predict — movement, another action already in progress, a stun, or a
+ server-side refusal — by watching the client's own "cast failed" signal
+ (the exact one with nampower, an error-message fallback without it) and
+ undoing the countdown after the fact. That revoke is filtered narrowly on
+ purpose: it only fires for the totem cast just attempted, only within a
+ short window afterward, and only for messages the client is known to use
+ for a refused cast, so it stays silent whenever it cannot be sure — a
+ refused *repeat* press restores the original timer rather than clearing it.
+ In every case where attribution is uncertain, this fails **closed**: no
+ revoke happens, and the existing timer is left standing, because a missing
+ timer is worse than a phantom one.
+- **Totemic Recall no longer wipes your totem timers on a press that could not
+ have gone out.** Recall's own 6s cooldown or the global cooldown could
+ refuse a manual Recall press while the button and keybind still cleared the
+ addon's totem tracking as if it had fired — after which the addon believed
+ nothing was out and refused every further Recall press until something
+ re-armed it. Recall now checks whether the cast can actually go out before
+ it touches the tracking, and a press that cannot keeps it intact.
+- **Totemic Recall no longer wastes its cooldown on a no-op moments after your
+ totems expire.** The check for "is anything even out" relied on the totem
+ tracking table staying occupied, but that table is emptied the instant each
+ totem's own timer runs out — so within a fraction of a second of the last
+ totem expiring, Recall forgot it had ever cast anything and let a needless
+ press through, burning the cooldown on an empty board. A separate flag now
+ answers "did we cast this session", so the check survives normal timer
+ expiry as intended.
+- **The Recall button and its keybind can no longer sweep away a set you just
+ redeployed.** Both issued a plain Totemic Recall cast, which nampower
+ queues behind the global cooldown — so pressing Recall during a GCD could
+ fire it *after* you had already placed your totems again, recalling the
+ set you had just dropped. Both now share the same queue-safe cast the
+ automatic recall paths already used, so a GCD-blocked press is skipped
+ instead of firing late.
+- **A totem you have not learned no longer starts a countdown.** The saved
+ totem set is account-wide and not checked against the spellbook, so a set
+ carried over from another character could land on one that never learned
+ those totems. Casting it was a guaranteed no-op, but it still started a
+ full phantom timer and made Totemic Recall think something was out.
+- **Sentry Totem's timer no longer expires 180 seconds early.** It was
+ missing its own duration entry and silently fell back to a generic
+ 120-second default, well short of the totem's real 5-minute duration.
+- **The seventh Air totem is no longer dropped from the hover flyout.** The
+ flyout's icon pool was sized for six totems, but Air offers seven — with
+ the Air slot left empty, the last entry could neither be cast nor be set
+ as the slot's new default from the bar.
+- **Windwall Totem no longer reads permanently in-range once you are also
+ carrying Strength of Earth Totem.** The range check matched buff icons by
+ an unanchored substring, and Strength of Earth Totem's icon name happens to
+ start with Windwall's, so carrying one made the other's out-of-range tint
+ never trigger.
+- **A totem pulse from another shaman can no longer drag your own pulse ring
+ backward.** The periodic self-heal/self-mana messages TotemBar reads to
+ time the pulse ring carry no caster information, so an identically worded
+ tick from someone else's totem re-anchored the phase and made the ring
+ visibly jump. Anchoring is now limited to at most once per pulse interval.
+- **The out-of-range tint no longer skips most of a freshly-placed set when
+ pfUI is loaded.** pfUI's own totem tracking commits at most one totem per
+ cast, so dropping a full four-totem set left three slots with no
+ information from it at all — and those slots were treated as "known to be
+ in range" instead of falling back to TotemBar's own tracking, so most of
+ the set never turned red out of range or pulsed the Recall button.
+- **The duration ring no longer counts down, jumps back up, and counts down
+ again with Totemic Mastery talented.** TotemBar's own tracking stores the
+ Mastery-inflated duration while pfUI's totem data reports the shorter flat
+ spellbook value; the display preferred pfUI's number until it ran out, then
+ jumped back up to the correct remaining time. Both sources now agree on the
+ same, Mastery-scaled endpoint.
+
+## v0.2.5 — 2026-07-25
+
+### Added
+- **New option: "Show drop-all button".** The drop-all-totems button can now be
+ hidden from the bar, for anyone who drives it purely from a keybind or a macro
+ and would rather not spend the slot on it. On by default, so nothing changes
+ unless you turn it off.
+ - **The keybind keeps working while the button is hidden.** It is bound to the
+ cast function, not to the frame, and the entry stays listed in the Esc key
+ bindings menu — otherwise you could never assign it again.
+ - **The bar resizes instead of leaving a gap.** In the 1×6 layout it shrinks by
+ one slot. In 2×3 and 3×2 the frame keeps its size on purpose: Recall still
+ needs the last column, and shrinking there would break up the 2×2 block of
+ the four element totems. What remains is one empty grid cell in the trailing
+ corner — invisible, and it simply adds drag area.
+
+## v0.2.4 — 2026-07-14
+
+### Fixed
+- **Totemic Recall no longer refuses to fire after a /reload.** The "no totems
+ out — not recalling" guard could block a legitimate recall: TotemBar tracks
+ which totems you've dropped in memory only, and a `/reload` wipes that memory.
+ On 1.12 there is no `GetTotemInfo` / totem unit-id to re-detect a totem that
+ was placed before the reload, so the addon wrongly concluded nothing was out
+ and refused to recall. The guard now blocks **only** when it is certain nothing
+ is out (it tracked totems this session and they've all expired); after a
+ /reload the state is unknown, so it fails open and lets the recall through.
+ Worst case is one needless recall right after a reload — far better than a
+ blocked one.
+
+## v0.2.3 — 2026-07-13
+
+### Fixed
+- **Totemic Recall no longer sweeps away a freshly-placed set (nampower).**
+ With nampower installed, dropping your set while the global cooldown was
+ active could make the new totems appear and then instantly vanish: nampower
+ queued Totemic Recall behind the GCD, so it fired *after* the (off-GCD)
+ totems were placed and pulled them right back down. TotemBar now casts
+ Recall via `CastSpellByNameNoQueue` when nampower provides it, so a
+ GCD-blocked Recall is skipped for that press instead of firing late. No
+ effect for setups without nampower.
+
+### Changed
+- **The drop-set keybind now recalls on key-down and places on key-release.**
+ This keeps the recall and the totem placements as separate hardware events;
+ for a normal tap the difference is imperceptible.
+
+## v0.2.2 — 2026-07-10
+
+### Added
+- **"Button spacing" slider** in the options panel (10–30 px) — adjusts the
+ gap between bar buttons live, including the flyout and assignment panel.
+
+### Fixed
+- **Keybind casts now start TotemBar's own duration/pulse timers.**
+ Per-totem and element key bindings call the same tracking as click-casts.
+ Previously, setups without pfUI got no timer/ring/pulse feedback for
+ keybind-cast totems, and even with pfUI the spawn ripple and out-of-range
+ tint were missing for them.
+
+### Removed
+- **Dead `pulseGlow` setting** — the anticipation glow is part of "Show
+ pulse waves"; stale saved values are cleaned up automatically.
+
+## v0.2.1 — 2026-07-10
+
+### Fixed
+- **Tooltips showed rank 1 instead of your highest rank.** Both totem
+ tooltips (element buttons and the flyout) resolved the spell by its first
+ spellbook match — which is rank 1 — so mana cost, damage and duration read
+ far too low. Display-only: the actual cast is name-based and always used
+ your highest rank. Tooltips now resolve to the highest known rank **and
+ show the rank** next to the spell name (vanilla's tooltip omits it).
+
+## v0.2.0 — 2026-07-10
+
+**The Pulse UI release** — a complete visual rework of the bar around one idea:
+you should see at a glance *how long* each totem still stands and *when* it
+pulses next.
+
+### Added
+- **Round floating slots.** Buttons are free-floating round icons in a subtle
+ shadow-rim frame — the square boxes and backdrops are gone. Flyout, Recall
+ and drop-set share the round look (round hover/pressed states included).
+- **Duration ring (time traffic light).** The outer ring drains with the
+ totem's remaining lifetime and blends green → yellow → orange → red as time
+ runs out.
+- **Pulse ring.** A thin inner arc in the totem's element color fills toward
+ the next pulse and resets when it fires — animated per frame, no stutter.
+- **Server-accurate pulse timing.** Intervals and first-tick behavior come
+ from TurtleWoW server data, not guesswork: e.g. Tremor pulses every **4s**
+ (not the commonly cited 3s) and ticks **immediately** on placement, while
+ Magma/Healing Stream/Mana Spring first pulse one full interval after
+ placement. Water totems additionally re-anchor on the real observed
+ mana/heal ticks.
+- **Pulse waves & anticipation glow.** An expanding ripple in the element
+ color marks each pulse; a soft halo builds around the ring during the last
+ stretch before it. A spawn ripple confirms every placement.
+- **Bar layouts.** Cycle between 1×6, 2×3 (elements grouped as a 2×2 block)
+ and 3×2 in the options panel.
+- **Custom art set.** Element glyphs for empty slots (no more question
+ marks), a four-element drop-set icon, a minimap glyph, a custom options
+ panel skin with rounded corners, and grounding shadow plates under the
+ slots. All art is generated by scripts in `tools/` — reproducible, no
+ binary-only assets.
+- **New options:** Show duration ring · Show pulse ring · Show pulse waves ·
+ Show countdown text · Layout.
+- **`/tb pulsecal` (dev aid):** captures pulse-timing telemetry to
+ `imports\totembar_pulsecal.txt` (SuperWoW) for verifying timings in-game.
+
+### Changed
+- Button spacing widened (4 → 10 px) to give the ring frames air; multi-row
+ layouts use the same tight pitch vertically.
+- Options panel restyled with the custom skin and section dividers.
+- The remaining-time countdown text is now optional (on by default).
+
+## v0.1.3 — 2026-07-09
+
+### Fixed
+- **Totemic Recall no longer wastes its 6-second cooldown.** Recall — via the
+ button, its keybinding, or the "Totems" macro's auto-recall — now only fires
+ when you actually have totems out. Casting it with nothing down put Recall on
+ its 6s cooldown, so a set placed right after couldn't be recalled for 6
+ seconds. With nothing out, TotemBar now skips the cast (with a brief note).
+
+## v0.1.2 — 2026-07-09
+
+### Added
+- **Mana cost on the drop-set tooltip** — hovering the drop-set button (and the
+ "Totems" macro action) shows the total mana to drop your four chosen totems.
+- **Refund on the Totemic Recall tooltip** — shows the mana you'll get back,
+ computed live from the totems currently out (expired ones are excluded) and a
+ refund percentage that is learned from your real recall mana-gain (defaults to
+ 25% until learned).
+- **Totemic Mastery duration** — when the talent is skilled, helpful totems'
+ own-tracked timers get the +20% duration so they no longer read short.
+- `/tb manadump` dev aid — writes the raw mana-cost scan (resolved rank, tooltip
+ lines, parsed cost) to `imports\totembar_manadump.txt`.
+
+### Fixed
+- Totem mana costs were read from **rank 1** instead of the highest rank you
+ actually cast, so the displayed mana was far too low. The scan now resolves
+ the highest known rank.
+
+## v0.1.1 — 2026-07-09
+
+### Added
+- **Drop-set button** on the bar (right of the Totemic Recall button): one
+ left-click casts all four chosen totems (the same action as the "Totems"
+ macro, with the 2-second double-press guard).
+- **Key bindings** — a **TotemBar** section in the Esc → Key Bindings menu:
+ Drop all totems, Totemic Recall, Toggle bar, Toggle options, Toggle key-bind
+ mode, Cast *element* totem (chosen, one per element), and — under per-element
+ sub-sections — a binding to cast **every individual totem** (Cast Searing
+ Totem, Cast Windfury Totem, …).
+- **Hover-bind mode** (`/tb bind`, or the options panel's "Key bind mode"
+ button): hover any bar button or a flyout totem and press a key to bind it
+ (a button binds its action; a flyout totem binds that specific totem). ESC
+ over a button clears its key; ESC over empty space (or the options button)
+ exits. A "Key-Bind Mode ACTIVE" box is shown while active. The
+ currently-bound key is displayed on each button at all times (abbreviated,
+ e.g. `N7` for Num Pad 7). Bindings are saved by the game and persist.
+
+### Notes
+- Totemic Recall cast **deliberately** (the Recall button, or its keybinding)
+ is never guarded — only the Totems macro's built-in recall respects the 2s
+ double-press guard.
+
+## v0.1.0 — 2026-07-09
+
+Initial public release.
+
+### Added
+- Totem bar: four element buttons + a Totemic Recall button. Left-click an
+ element casts its chosen totem; right-click clears the slot. Hover an element
+ for a flyout of the element's other known totems (left-click to cast once,
+ right-click to set as the new default).
+- One-press "Totems" macro (`TotemBar.recallAndCastAll()`) with a 2-second
+ double-press recall guard (a rapid second press won't recall the totems you
+ just dropped).
+- Native cooldown swipes + OmniCC-style duration timers; out-of-range red tint
+ (buff-presence based); the Recall button pulses when a totem is out of range.
+- Real in-game spell tooltips on the element buttons and the flyout.
+- Minimap button (orbit-draggable; left-click = options, right-click = toggle
+ bar); pfUI-safe, with `/tb options` as a fallback.
+- Options panel: lock bar, auto-recall, show bar, recall guard, cycle-reset
+ gap, UI size (scales the bar, anchored top-left), reset position, create
+ "Totems" macro, and per-setting tooltips. Adopts pfUI's look when pfUI is
+ installed, a clean Blizzard look otherwise.
+- Assignment-receiver API (`TotemBar.ReceiveAssignment`) for a future raid
+ totem-assignment addon — see [`docs/API.md`](docs/API.md).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e1bc95c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 ShempError
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..56dc4ea
--- /dev/null
+++ b/README.md
@@ -0,0 +1,221 @@
+# TotemBar
+
+A lightweight shaman totem bar for **TurtleWoW / WoW 1.12.1 (Vanilla)**.
+
+Pick one totem per element, cast it with a click, cycle alternatives from a
+hover flyout, or drop all four with a single press. Every slot shows **how
+long the totem still stands** (outer ring, green → red) and **when it pulses
+next** (inner ring in the element's color) — with timings taken from actual
+server data, not guesswork. No dependencies; adopts the
+[pfUI](https://github.com/shagu/pfUI) look automatically when present.
+
+
+
+## What's new — v0.4.1 · 2026-08-31
+
+- **A totem destroyed inside the recall attribution window stays dead.** A totem the enemy
+ destroyed in the ~0.4s window between a Totemic Recall wipe and its refusal being attributed
+ no longer has its countdown resurrected by the refused-recall restore.
+
+**Version history** — details in [CHANGELOG.md](CHANGELOG.md):
+
+- **v0.4.1** (2026-08-31) — a totem destroyed by the enemy inside the recall attribution
+ window now stays dead instead of coming back when a refused Totemic Recall restores its
+ timers.
+- **v0.4.0** (2026-08-30) — a totem destroyed before its timer ran out is now
+ detected and cleared (countdown, ring, pulse and range tint together) instead
+ of counting down as if it still stood; a totem cast refused for lack of mana
+ no longer starts a countdown (the Clearcasting exemption never applied to
+ totems); a Totemic Recall the server refuses no longer wipes your timers.
+- **v0.3.0** (2026-08-15) — element buttons and the flyout now dim totems you
+ can't afford; a refused cast no longer starts a phantom countdown, and one
+ that already started can now be taken back after the fact (narrowly, and
+ never at the risk of clearing a real timer). Also fixes Totemic Recall
+ wiping or wasting itself around a press that couldn't go out or one queued
+ behind the global cooldown, Sentry Totem's timer expiring early, the
+ seventh Air totem being unreachable, Windwall's range tint sticking on
+ with Strength of Earth also up, the pulse ring jumping on another
+ shaman's tick, the out-of-range tint missing most of a set under pfUI, and
+ the duration ring double-counting with Totemic Mastery talented.
+- **v0.2.5** (2026-07-25) — new option **"Show drop-all button"**: hide the
+ drop-all-totems button if you fire it from a keybind or macro and would rather
+ not spend a slot on it. The keybind keeps working while it's hidden, and in the
+ 1×6 layout the bar shrinks instead of leaving dead space.
+- **v0.2.4** (2026-07-14) — Totemic Recall no longer refuses to fire after a
+ `/reload`: the "no totems out" guard relied on in-memory tracking that a reload
+ wipes, and 1.12 offers no way to re-detect totems placed beforehand. Also a
+ shared spellbook index, killing a multi-millisecond hitch when dropping four
+ totems from one macro press.
+- **v0.2.3** (2026-07-13) — fixes a bug where dropping your set could make the
+ new totems vanish an instant later: with nampower, Totemic Recall was queued
+ behind the global cooldown and fired *after* the (off-GCD) totems were
+ placed, sweeping them. Recall now bypasses the spell queue so it can't pull a
+ fresh set.
+- **v0.2.2** (2026-07-10) — "Button spacing" slider in the options panel;
+ keybind casts now feed TotemBar's own duration/pulse timers (previously
+ only click-casts and pfUI did); removed the dead `pulseGlow` setting.
+- **v0.2.1** (2026-07-10) — tooltips now show your highest known rank (they
+ read rank-1 values before; the cast itself was always highest-rank) and
+ display the rank next to the name.
+- **v0.1.3** (2026-07-09) — Totemic Recall no longer wastes its 6s cooldown
+ when no totems are out.
+- **v0.1.2** (2026-07-09) — mana cost on the drop-set tooltip, live refund
+ estimate on the Recall tooltip, Totemic Mastery duration bonus, rank-aware
+ mana scan.
+- **v0.1.1** (2026-07-09) — key bindings (Esc menu section for every action
+ and every single totem), drop-set bar button, hover-bind mode with
+ per-button key labels.
+- **v0.1.0** (2026-07-09) — first public release: element buttons, hover
+ flyout, "Totems" macro with recall guard, minimap button, options panel,
+ duration timers, out-of-range tint.
+
+## Screenshots
+
+| The bar & flyout | Options panel |
+| :---: | :---: |
+| 
|
|
+
+| Key bindings | Hover-bind mode |
+| :---: | :---: |
+|
|
|
+
+*(Click any image for full size.)*
+
+## Features
+
+**Casting**
+- One button per element (Fire / Earth / Water / Air): left-click casts the
+ chosen totem, right-click clears the slot.
+- Hover flyout with your other known totems per element — left-click casts
+ once, right-click makes it the new default.
+- **Drop-set button** and a one-press **"Totems" macro** cast all four chosen
+ totems at once, guarded against accidental double presses (so a fast second
+ press won't recall the set you just placed).
+- Dedicated **Totemic Recall** button that only fires when totems are
+ actually out — never wastes Recall's 6s cooldown. Right-click toggles
+ auto-recall-before-redeploy.
+
+**At-a-glance timing**
+- **Duration ring:** the outer ring drains with the totem's lifetime,
+ colored green → yellow → orange → red by remaining time.
+- **Pulse ring:** the inner arc (element color) fills toward the next pulse —
+ intervals and first-tick behavior derived from TurtleWoW server data, water
+ totems locked onto their real observed ticks.
+- **Pulse waves:** an expanding ripple marks each pulse, a soft halo
+ announces it just before, and a spawn ripple confirms each placement.
+- Optional countdown text under each slot; totems tint red when you leave
+ their range (buff-presence based), and the Recall button pulses while
+ anything is out of range.
+- **Totems you cannot afford are dimmed** — buttons and flyout, from the real
+ cost of your highest known rank; the dim composes with the range tint instead
+ of overwriting it, and stands down while Clearcasting is up.
+- **Countdowns only start for casts that could go out** — a press refused for
+ mana or a real cooldown is not tracked, so no phantom timer and no wasted
+ Totemic Recall on an empty board. A second check catches what that cannot
+ predict (movement, a stun, a server-side refusal) and takes the countdown
+ back after the fact — narrowly filtered to the totem just pressed, so it
+ stays silent whenever it can't be sure rather than risk clearing a timer
+ for a totem that is actually still out.
+- Native cooldown swipes and real in-game spell tooltips everywhere.
+
+**Control**
+- **Key bindings:** an Esc → Key Bindings section for every action — including
+ a binding for every single totem you know — plus a fast **hover-bind mode**
+ (hover a button, press a key). Bound keys show on the buttons.
+- **Layouts:** one row (1×6), two rows (2×3, elements grouped 2×2) or three
+ rows (3×2), switchable live.
+- **Minimap button** (drag to reposition, left-click options, right-click
+ toggles the bar) and a skinned **options panel** for everything else.
+- **Assignment seam** for future raid tools: an external addon can *suggest*
+ a totem set via `TotemBar.ReceiveAssignment` — shown as a one-click apply
+ panel, never auto-cast. See [`docs/API.md`](docs/API.md).
+
+## Install
+
+**Option A — download (simplest):**
+
+1. Grab **`TotemBar-vX.Y.Z.zip`** from
+ [**Releases**](https://github.com/ShempError/TotemBar/releases).
+ *(Use the Release zip, not "Code → Download ZIP" — that names the folder
+ `TotemBar-master`, which WoW won't load.)*
+2. Extract the **`TotemBar`** folder into `Interface\AddOns\`.
+3. Restart the client.
+
+**Option B — git (auto-updatable):**
+
+```
+cd Interface/AddOns
+git clone https://github.com/ShempError/TotemBar.git TotemBar
+```
+
+`master` is the stable release channel — git-based managers
+(GitAddonsManager, OctoWoW) stay current with a `git pull`. `dev` is
+work-in-progress.
+
+## Usage
+
+| Action | Effect |
+| --- | --- |
+| Left-click element button | Cast that element's chosen totem |
+| Right-click element button | Clear that element's slot |
+| Hover element button | Flyout with the element's other known totems |
+| Left-click flyout totem | Cast it once (default unchanged) |
+| Right-click flyout totem | Set it as the slot's new default |
+| Left-click Recall | Cast Totemic Recall (only when totems are out) |
+| Right-click Recall | Toggle auto-recall |
+| Left-click drop-set button | Cast all four chosen totems |
+| Minimap button | Left: options · Right: toggle bar · Drag: reposition |
+
+**Slash commands:** `/tb` (toggle bar) · `/tb options` · `/tb lock` ·
+`/tb bind` (hover-bind mode) · `/tb scan`, `/tb assign`, `/tb manadump`,
+`/tb pulsecal` (dev aids).
+
+**The "Totems" macro:** options panel → **Create 'Totems' macro**, then drag
+the `Totems` macro to your action bar. One press recalls (if auto-recall is
+on) and redeploys all four chosen totems.
+
+## Options
+
+Open with the minimap button or `/tb options`.
+
+| Option | Does |
+| --- | --- |
+| Lock bar | Disable dragging |
+| Auto-recall before setting | Recall first when the macro/drop-set fires |
+| Show bar | Show/hide the whole bar |
+| Show drop-all button | Show/hide the drop-all-totems button; its keybind keeps working either way |
+| Show duration ring | Outer remaining-time ring (traffic-light colors) |
+| Show pulse ring | Inner next-pulse arc (element color) |
+| Show pulse waves | Ripple + glow effects on pulses |
+| Show countdown text | Remaining-seconds text under each slot |
+| Layout | Cycle 1×6 / 2×3 / 3×2 |
+| Recall guard (sec) | Double-press protection window |
+| Cycle reset gap (sec) | When macro cycling restarts at totem 1 |
+| UI size | Scales bar + flyout from the top-left corner |
+| Reset position / Create macro / Key bind mode | One-click actions |
+
+## Requirements
+
+- A WoW 1.12.1 client (TurtleWoW). **No addon dependencies.**
+
+Optional, auto-detected: **pfUI** (skinned widgets; its `libtotem` feeds the
+timers as an extra source) and **SuperWoW** (only used by the `/tb scan` /
+`/tb pulsecal` dev aids for file exports).
+
+## For developers
+
+- Public API for companion addons: [`docs/API.md`](docs/API.md).
+- Pure-logic modules under `core/` are unit-tested offline against a real
+ Lua 5.0.3 interpreter (`tools/luatests/`, 19 suites). WoW-API files are
+ syntax-checked; in-game behavior is verified on TurtleWoW.
+- All textures are **generated**: `tools/gen_*.js` (zero-dependency Node)
+ render the ring/FX/icon/panel sheets as 1.12-safe TGAs, and
+ `tools/preview_*.js` composite them into PNG previews for review without
+ launching the client.
+- `/reload` covers changes to existing `.lua` files; a client restart is
+ needed for `.toc` changes, new files, or changed textures (the client
+ caches them).
+
+## License
+
+MIT (see LICENSE).
diff --git a/TotemBar.toc b/TotemBar.toc
new file mode 100644
index 0000000..c158cd5
--- /dev/null
+++ b/TotemBar.toc
@@ -0,0 +1,25 @@
+## Interface: 11200
+## Title: TotemBar
+## Notes: Shaman totem bar - pick one totem per element, cast it, or spam-cycle all four from a macro.
+## Author: ShempError
+## Version: 0.4.1
+## SavedVariables: TotemBarDB
+
+core\totemdata.lua
+core\spellindex.lua
+core\known.lua
+core\cast.lua
+core\config.lua
+core\assign.lua
+core\optionslogic.lua
+core\bindlogic.lua
+core\manacost.lua
+core\pulsedata.lua
+core\pulse.lua
+core\pulseparse.lua
+core\pulsecal.lua
+ui.lua
+options.lua
+minimap.lua
+Bindings.xml
+bind.lua
diff --git a/bind.lua b/bind.lua
new file mode 100644
index 0000000..3cc27d9
--- /dev/null
+++ b/bind.lua
@@ -0,0 +1,233 @@
+-- TotemBar - bind.lua
+-- Key-binding labels (for the Esc > Key Bindings menu) + the cast entry
+-- points the Bindings.xml bodies call. Hover-bind mode is added below in a
+-- later step. Loaded AFTER Bindings.xml (see the .toc).
+
+TotemBar = TotemBar or {}
+
+local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
+
+-- Section + fixed-action labels.
+BINDING_HEADER_TOTEMBAR = "TotemBar"
+BINDING_NAME_TOTEMBAR_DROPSET = "Drop all totems"
+BINDING_NAME_TOTEMBAR_RECALL = "Totemic Recall"
+BINDING_NAME_TOTEMBAR_TOGGLEBAR = "Toggle bar"
+BINDING_NAME_TOTEMBAR_TOGGLEOPTIONS = "Toggle options"
+BINDING_NAME_TOTEMBAR_TOGGLEBIND = "Toggle key-bind mode"
+
+-- Per-element "cast the chosen totem" labels + per-element sub-headers.
+do
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ local e = elements[i]
+ setglobal("BINDING_NAME_TOTEMBAR_CAST_" .. string.upper(e), "Cast " .. e .. " totem (chosen)")
+ setglobal("BINDING_HEADER_TOTEMBAR_" .. string.upper(e), "TotemBar: " .. e .. " Totems")
+ end
+ -- Per-totem labels (must match Bindings.xml names via bindingSuffix).
+ for i = 1, table.getn(elements) do
+ local e = elements[i]
+ local list = TotemBar.TOTEMS_BY_ELEMENT[e]
+ for j = 1, table.getn(list) do
+ local totem = list[j]
+ setglobal("BINDING_NAME_TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(totem), "Cast " .. totem)
+ end
+ end
+end
+
+-- Cast a specific totem by name (no-op in-game if not known).
+function TotemBar.CastTotem(name)
+ if name then
+ CastSpellByName(name)
+ -- keybind casts must feed the own-tracking timers exactly like the
+ -- flyout click handler does (ui.lua) - without this, vanilla-only
+ -- setups get no duration ring/pulse for keybind-cast totems.
+ local element = TotemBar.elementOf(name)
+ if element then
+ TotemBar.recordCast(element, name)
+ end
+ end
+end
+
+-- Cast the currently-chosen totem for an element.
+function TotemBar.CastElement(element)
+ local n = TotemBarDB and TotemBarDB.chosen and TotemBarDB.chosen[element]
+ if n then
+ CastSpellByName(n)
+ -- keybind casts must feed the own-tracking timers exactly like the
+ -- flyout click handler does (ui.lua) - without this, vanilla-only
+ -- setups get no duration ring/pulse for keybind-cast totems.
+ TotemBar.recordCast(element, n)
+ else
+ ChatOut:AddMessage("TotemBar: no totem chosen for " .. tostring(element) .. ".")
+ end
+end
+
+-- Cast Totemic Recall and clear own-tracking. Runs the exact same
+-- TotemBar.manualRecall (core/cast.lua) as the Recall button's left-click, so
+-- the keybind and the button can never diverge: it owns the "nothing is out"
+-- gate, the "is Recall even castable right now" check (a press that cannot go
+-- out must NOT wipe the timers of totems still standing), the queue-safe cast
+-- that keeps nampower from firing a late Recall into a fresh set, the refund
+-- snapshot and the own-tracking wipe. Only the chat feedback lives here.
+function TotemBar.CastRecall()
+ if not TotemBar.manualRecall then
+ return
+ end
+ local action = TotemBar.manualRecall()
+ if action == "none-out" then
+ ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown). Press again to recall anyway.")
+ elseif action == "cooldown" then
+ ChatOut:AddMessage("TotemBar: Totemic Recall isn't ready yet - totem timers kept.")
+ end
+end
+
+-- Stub so the TOTEMBAR_TOGGLEBIND binding never calls nil before Task 4
+-- fills it in. Replaced (same name) by the real implementation below.
+if not TotemBar.ToggleBindMode then
+ function TotemBar.ToggleBindMode()
+ ChatOut:AddMessage("TotemBar: key-bind mode not available.")
+ end
+end
+
+-- ===== Hover-bind mode =====
+-- Toggle a mode where hovering a bar button / flyout totem and pressing a
+-- key binds that key to the thing's action (buttons -> CLICK binding, flyout
+-- totems -> the named per-totem binding). ESC clears the hovered thing's
+-- binding. Bindings persist via the client (SaveBindings), no addon SV.
+
+local bindMode = false
+local captureFrame = nil
+local infoBox = nil
+
+-- Base keys that are pure modifiers - never bind these alone.
+local BARE_MODIFIERS = {
+ LSHIFT = true, RSHIFT = true, LCTRL = true, RCTRL = true,
+ LALT = true, RALT = true, UNKNOWN = true,
+}
+
+function TotemBar.isBindMode()
+ return bindMode
+end
+
+-- Resolves the mouse-focused frame to a binding action. Uses the pure
+-- TotemBar.actionForButton with the frame's global name and, for a flyout
+-- icon, its current .totemName.
+local function actionForFocus(focus)
+ if not focus then
+ return nil
+ end
+ local totemName = focus.totemName -- set on flyout icon buttons when shown
+ local fname = focus.GetName and focus:GetName() or nil
+ return TotemBar.actionForButton(fname, totemName)
+end
+
+local function ensureCaptureFrame()
+ if captureFrame then
+ return captureFrame
+ end
+ local f = CreateFrame("Frame", "TotemBarBindCapture", UIParent)
+ f:SetAllPoints(UIParent)
+ f:SetFrameStrata("FULLSCREEN_DIALOG")
+ f:EnableKeyboard(false)
+ f:Hide()
+ f:SetScript("OnKeyUp", function()
+ local key = arg1 -- NOTE: OnKeyUp arg1 = key name string (matches pfUI's hoverbind capture)
+ if not key or BARE_MODIFIERS[key] then
+ return
+ end
+ local focus = GetMouseFocus()
+ local action = actionForFocus(focus)
+ if key == "ESCAPE" and not action then
+ -- Keyboard capture swallows all keys while bind mode is on, so
+ -- ESC over nothing is the keyboard exit (the options button also
+ -- toggles it off). ESC over a button clears that button below.
+ TotemBar.ToggleBindMode()
+ return
+ end
+ if not action then
+ return
+ end
+ if key == "ESCAPE" then
+ local k1, k2 = GetBindingKey(action)
+ if k1 then SetBinding(k1) end
+ if k2 then SetBinding(k2) end
+ else
+ local full = TotemBar.modifierPrefix(IsAltKeyDown(), IsControlKeyDown(), IsShiftKeyDown()) .. key
+ SetBinding(full) -- clear whatever `full` was bound to
+ SetBinding(full, action) -- bind it to this action
+ end
+ SaveBindings(GetCurrentBindingSet())
+ if TotemBar.refreshBindOverlays then
+ TotemBar.refreshBindOverlays()
+ end
+ end)
+ captureFrame = f
+ return f
+end
+
+-- Lazily builds a visible on-screen info box shown while bind mode is
+-- active, so the mode (which otherwise only shows via a chat message and
+-- stays on until ESC) is hard to miss. Rev 4 UI chrome: shared bespoke
+-- panel skin (ui.lua's PANEL_BACKDROP), matching the rest of the addon's
+-- chrome, regardless of pfUI presence.
+local function ensureBindInfoBox()
+ if infoBox then
+ return infoBox
+ end
+ local f = CreateFrame("Frame", "TotemBarBindInfo", UIParent)
+ f:SetWidth(380)
+ f:SetHeight(80)
+ f:SetPoint("TOP", UIParent, "TOP", 0, -160)
+ f:SetFrameStrata("FULLSCREEN_DIALOG")
+ -- Cheap guard: bind.lua loads LAST per the .toc (after ui.lua), so
+ -- TotemBar.PANEL_BACKDROP is always set by the time this runs - the
+ -- fallback only protects against a future load-order change.
+ local backdrop = TotemBar.PANEL_BACKDROP or {
+ bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
+ edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
+ tile = true, tileSize = 32, edgeSize = 32,
+ insets = { left = 11, right = 12, top = 12, bottom = 11 },
+ }
+ f:SetBackdrop(backdrop)
+ f:SetBackdropColor(1, 1, 1, 0.97)
+ local t = f:CreateFontString("TotemBarBindInfoText", "OVERLAY", "GameFontNormal")
+ t:SetPoint("CENTER", f, "CENTER", 0, 0)
+ t:SetJustifyH("CENTER")
+ t:SetText("|cff33ffccKey-Bind Mode ACTIVE|r\nHover a bar button or flyout totem and press a key to bind it.\nESC over a button clears its key. ESC over empty space (or the options button) exits.")
+ if pfUI and pfUI.font_default then
+ t:SetFont(pfUI.font_default, 12)
+ end
+ f:Hide()
+ infoBox = f
+ return f
+end
+
+function TotemBar.ToggleBindMode()
+ bindMode = not bindMode
+ local f = ensureCaptureFrame()
+ if bindMode then
+ f:EnableKeyboard(true)
+ f:Show()
+ ensureBindInfoBox():Show()
+ ChatOut:AddMessage("TotemBar: key-bind mode ON - hover a button or flyout totem and press a key to bind. ESC over a button clears its key; ESC over nothing (or the options button) exits.")
+ else
+ f:EnableKeyboard(false)
+ f:Hide()
+ if infoBox then
+ infoBox:Hide()
+ end
+ ChatOut:AddMessage("TotemBar: key-bind mode OFF.")
+ end
+ if TotemBar.refreshBindOverlays then
+ TotemBar.refreshBindOverlays()
+ end
+end
+
+-- Refresh overlays whenever the client's bindings change.
+local bindEvents = CreateFrame("Frame", "TotemBarBindEventFrame", UIParent)
+bindEvents:RegisterEvent("UPDATE_BINDINGS")
+bindEvents:SetScript("OnEvent", function()
+ if TotemBar.refreshBindOverlays then
+ TotemBar.refreshBindOverlays()
+ end
+end)
diff --git a/core/assign.lua b/core/assign.lua
new file mode 100644
index 0000000..96a71a7
--- /dev/null
+++ b/core/assign.lua
@@ -0,0 +1,147 @@
+-- TotemBar - core/assign.lua
+-- Assignment receiver seam: the contract + pure logic by which an external
+-- assigner (future "ShamiPower") hands TotemBar a proposed totem set.
+-- WoW-API-light so the logic below is offline-testable under real Lua 5.0.
+-- The pending-suggestion UI lives in ui.lua and is reached through the
+-- optional hook slots (ShowAssignPanel/HideAssignPanel/isTotemKnown/
+-- RefreshAll) that ui.lua fills in at load time.
+--
+-- An "assignment set" is a table keyed by element -> totem spell name:
+-- { Fire = "Searing Totem", Air = "Windfury Totem", ... }
+-- Any subset of elements; a missing element means "nothing for that slot".
+
+TotemBar = TotemBar or {}
+
+-- True if `key` is one of the four totem elements.
+function TotemBar.isElement(key)
+ if not key then
+ return false
+ end
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ if elements[i] == key then
+ return true
+ end
+ end
+ return false
+end
+
+-- Validates an assignment set. Returns true, or false plus a reason string.
+function TotemBar.validateAssignment(set)
+ if type(set) ~= "table" then
+ return false, "set must be a table"
+ end
+ local count = 0
+ for k, v in pairs(set) do
+ if not TotemBar.isElement(k) then
+ return false, "unknown element key: " .. tostring(k)
+ end
+ if type(v) ~= "string" or v == "" then
+ return false, "totem name for " .. tostring(k) .. " must be a non-empty string"
+ end
+ count = count + 1
+ end
+ if count == 0 then
+ return false, "set is empty"
+ end
+ return true
+end
+
+-- Shallow copy of a set, keeping only valid element keys.
+function TotemBar.copySet(set)
+ local out = {}
+ if type(set) == "table" then
+ for k, v in pairs(set) do
+ if TotemBar.isElement(k) then
+ out[k] = v
+ end
+ end
+ end
+ return out
+end
+
+-- Fresh copy (element -> name) of the currently chosen totems.
+function TotemBar.GetChosenSet()
+ local out = {}
+ local chosen = TotemBarDB and TotemBarDB.chosen
+ if chosen then
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ local e = elements[i]
+ if chosen[e] then
+ out[e] = chosen[e]
+ end
+ end
+ end
+ return out
+end
+
+-- Splits a set into applied (isKnown(name) true) and skipped (false).
+function TotemBar.filterKnown(set, isKnown)
+ local applied, skipped = {}, {}
+ for k, v in pairs(set) do
+ if isKnown(v) then
+ applied[k] = v
+ else
+ skipped[k] = v
+ end
+ end
+ return applied, skipped
+end
+
+-- Current pending assignment: { set = {element=name,...}, label = string } or nil.
+-- In-memory only (not a SavedVariable) - an assignment is ephemeral coordination.
+TotemBar.pending = nil
+
+-- Optional hook, filled by an external assigner: called with the applied
+-- set table after the player accepts. Nil by default.
+TotemBar.onAssignmentApplied = nil
+
+-- THE SEAM. An external assigner calls this to propose a set. Validates,
+-- stores it as the pending suggestion (replacing any prior), and asks the
+-- UI to show the panel. Does NOT apply. Returns true, or false + reason.
+function TotemBar.ReceiveAssignment(set, label)
+ local ok, reason = TotemBar.validateAssignment(set)
+ if not ok then
+ return false, reason
+ end
+ TotemBar.pending = { set = TotemBar.copySet(set), label = label }
+ if TotemBar.ShowAssignPanel then
+ TotemBar.ShowAssignPanel()
+ end
+ return true
+end
+
+-- Drops any pending suggestion and hides the panel (decline / post-apply).
+function TotemBar.ClearAssignment()
+ TotemBar.pending = nil
+ if TotemBar.HideAssignPanel then
+ TotemBar.HideAssignPanel()
+ end
+end
+
+-- Applies the pending suggestion: sets each KNOWN totem as the chosen
+-- default for its element (unknown totems are skipped), refreshes the bar,
+-- clears pending, hides the panel, and fires onAssignmentApplied. No cast.
+function TotemBar.ApplyPending()
+ local p = TotemBar.pending
+ if not p then
+ return
+ end
+ local isKnown = TotemBar.isTotemKnown or function() return true end
+ local applied = TotemBar.filterKnown(p.set, isKnown)
+ TotemBarDB.chosen = TotemBarDB.chosen or {}
+ for element, name in pairs(applied) do
+ TotemBarDB.chosen[element] = name
+ end
+ if TotemBar.RefreshAll then
+ TotemBar.RefreshAll()
+ end
+ TotemBar.pending = nil
+ if TotemBar.HideAssignPanel then
+ TotemBar.HideAssignPanel()
+ end
+ if TotemBar.onAssignmentApplied then
+ TotemBar.onAssignmentApplied(applied)
+ end
+end
diff --git a/core/bindlogic.lua b/core/bindlogic.lua
new file mode 100644
index 0000000..ddaf54f
--- /dev/null
+++ b/core/bindlogic.lua
@@ -0,0 +1,73 @@
+-- TotemBar - core/bindlogic.lua
+-- PURE helpers for keybindings + hover-bind mode (no WoW API), offline-testable.
+
+TotemBar = TotemBar or {}
+
+-- Binding-name suffix for a totem: uppercase, non-alphanumeric runs -> "_",
+-- trimmed. "Grace of Air Totem" -> "GRACE_OF_AIR_TOTEM". MUST match the
+-- Bindings.xml generation and the BINDING_NAME_ globals.
+function TotemBar.bindingSuffix(name)
+ if not name then
+ return ""
+ end
+ local up = string.upper(name)
+ up = string.gsub(up, "[^A-Z0-9]+", "_")
+ up = string.gsub(up, "^_+", "")
+ up = string.gsub(up, "_+$", "")
+ return up
+end
+
+-- Modifier prefix for a key string, order ALT-CTRL-SHIFT (each arg is 1/nil
+-- as IsAltKeyDown()/IsControlKeyDown()/IsShiftKeyDown() return on 1.12).
+function TotemBar.modifierPrefix(isAlt, isCtrl, isShift)
+ local p = ""
+ if isAlt then p = p .. "ALT-" end
+ if isCtrl then p = p .. "CTRL-" end
+ if isShift then p = p .. "SHIFT-" end
+ return p
+end
+
+-- Binding COMMAND for a hovered thing, or nil. A flyout icon (totemName
+-- given) -> the named per-totem binding (casts that specific totem, same
+-- action as the Esc menu). A bar button (by global frame name) -> the
+-- matching NAMED binding that already exists in Bindings.xml (NOT a
+-- "CLICK ..." binding - those are unreliable on 1.12, see bind.lua).
+function TotemBar.actionForButton(frameName, totemName)
+ if totemName and totemName ~= "" then
+ return "TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(totemName)
+ end
+ if not frameName then
+ return nil
+ end
+ if frameName == "TotemBarButtonRecall" then
+ return "TOTEMBAR_RECALL"
+ end
+ if frameName == "TotemBarButtonDropSet" then
+ return "TOTEMBAR_DROPSET"
+ end
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ if frameName == "TotemBarButton" .. elements[i] then
+ return "TOTEMBAR_CAST_" .. string.upper(elements[i])
+ end
+ end
+ return nil
+end
+
+-- Compact a binding key string for a small button overlay:
+-- "SHIFT-NUMPAD7" -> "sN7", "BUTTON4" -> "M4", "MOUSEWHEELUP" -> "MwU".
+function TotemBar.shortenKey(key)
+ if not key or key == "" then
+ return ""
+ end
+ local s = key
+ s = string.gsub(s, "ALT%-", "a")
+ s = string.gsub(s, "CTRL%-", "c")
+ s = string.gsub(s, "SHIFT%-", "s")
+ s = string.gsub(s, "MOUSEWHEELUP", "MwU")
+ s = string.gsub(s, "MOUSEWHEELDOWN", "MwD")
+ s = string.gsub(s, "NUMPAD", "N")
+ s = string.gsub(s, "BUTTON", "M")
+ s = string.gsub(s, "SPACE", "Sp")
+ return s
+end
diff --git a/core/cast.lua b/core/cast.lua
new file mode 100644
index 0000000..ab3e992
--- /dev/null
+++ b/core/cast.lua
@@ -0,0 +1,2108 @@
+-- TotemBar - core/cast.lua
+-- Cast-cycle logic. The decision-making is split into two PURE,
+-- offline-tested functions (nextIndex, findFilledSlot); castNext() is
+-- the thin wrapper that touches CastSpellByName / GetTime / TotemBarDB.
+
+TotemBar = TotemBar or {}
+
+TotemBar.DEFAULT_GAP_SECONDS = 2
+
+-- Anti double-press guard for recallAndCastAll: if a deploy happened within
+-- this many seconds, a rapid second press SKIPS the recall (so it doesn't
+-- pull the just-placed totems, which are still on their ~1.5s element
+-- cooldown and couldn't be re-placed).
+TotemBar.DEFAULT_RECALL_GUARD = 2
+
+-- The spell the recall paths cast. One source for the name so the guard, the
+-- cooldown lookup and the cast itself can never disagree.
+local RECALL_SPELL_NAME = "Totemic Recall"
+
+-- How long a manual Recall press that the "nothing is out" gate BLOCKED stays
+-- remembered, so a deliberate re-press can override the gate (see
+-- TotemBar.manualRecallAction). The lower bound exists so an accidental
+-- double-click cannot burn the very 6s cooldown the gate is there to save; the
+-- upper bound expires the override again.
+TotemBar.RECALL_OVERRIDE_MIN = 0.5
+TotemBar.RECALL_OVERRIDE_MAX = 5
+
+-- Default gap (px) between bar buttons. Matches ui.lua's file-scope
+-- BUTTON_GAP default; the options panel's "Button spacing" slider (range
+-- 10-30px) live-applies changes via TotemBar.SetButtonGap (ui.lua) and
+-- persists them to TotemBarDB.buttonGap (core/config.lua).
+TotemBar.DEFAULT_BUTTON_GAP = 10
+
+-- Destroyed-totem GUID-liveness poll interval (seconds). Throttled
+-- independently of ui.lua's faster 0.1s display tick (see
+-- UpdateTimerDisplays) -- UnitExists/UnitHealth/UnitIsDeadOrGhost are cheap
+-- but there is no reason to call them 10x/sec once a GUID is latched. See
+-- "Destroyed-totem detection (GUID liveness)" below for the full design.
+TotemBar.GUID_LIVENESS_POLL_INTERVAL = 0.5
+
+-- Cycle state: which slot was cast last, and when.
+TotemBar.castState = TotemBar.castState or {
+ index = 0, -- 0 = no cast yet (or state was reset)
+ lastTime = 0,
+ lastDeployTime = 0,
+}
+
+-- Own-tracking table for the OmniCC-style remaining-duration display in
+-- ui.lua: fallback source for when pfUI's libtotem (GetTotemInfo) isn't
+-- present, or reports a given slot inactive. element -> {start=,
+-- duration=}, both in TotemBar.recordCast() below.
+TotemBar.activeTotems = TotemBar.activeTotems or {}
+
+-- element -> the GetTime() a destroy-evicted record would naturally have
+-- expired at (its own start+duration). Set by ui.lua's liveness poll the
+-- moment it evicts a destroyed totem; read by resolveRemaining (via
+-- TotemBar.tombstoneActive) to stop trusting pfUI's libtotem for that
+-- element until then -- libtotem is ALSO just a blind duration timer (see
+-- "Destroyed-totem detection (GUID liveness)" below), so without this it
+-- keeps reporting the destroyed totem active and the countdown/ring/pulse
+-- reappear from the GTI branch even though activeTotems[element] was
+-- correctly cleared. Cleared the moment a real recordCast lands on the
+-- element again (see recordCast below) or once GetTime() passes the
+-- stored expiry (ui.lua sweeps it then) -- bounded to at most 4 keys
+-- either way, never a growing table.
+TotemBar.destroyedTombstone = TotemBar.destroyedTombstone or {}
+
+-- Pure: given the previously cast slot index, the time of that previous
+-- cast, the current time, the allowed gap (seconds) and the number of
+-- slots, returns the next slot index (1-based) to advance to.
+--
+-- - prevIndex <= 0 (never cast yet) -> 1
+-- - now - lastTime > gapSeconds -> 1 (fresh spam, start over)
+-- - otherwise -> prevIndex + 1, wrapping from
+-- numSlots back to 1
+function TotemBar.nextIndex(prevIndex, lastTime, now, gapSeconds, numSlots)
+ if not prevIndex or prevIndex <= 0 then
+ return 1
+ end
+ if not lastTime or (now - lastTime) > gapSeconds then
+ return 1
+ end
+ local nxt = prevIndex + 1
+ if nxt > numSlots then
+ nxt = 1
+ end
+ return nxt
+end
+
+-- Pure: starting at startIndex, walk forward through `elements`
+-- (wrapping past the end back to 1) and return the index of the first
+-- element for which chosen[element] is truthy (a totem name). Returns
+-- nil if none of the slots are filled.
+function TotemBar.findFilledSlot(chosen, elements, startIndex)
+ local numSlots = table.getn(elements)
+ if numSlots == 0 or not startIndex then
+ return nil
+ end
+ for tries = 0, numSlots - 1 do
+ local slot = startIndex + tries
+ if slot > numSlots then
+ slot = slot - numSlots
+ end
+ if chosen[elements[slot]] then
+ return slot
+ end
+ end
+ return nil
+end
+
+-- Pure: seconds remaining given a start time, a duration and the
+-- current time. May return <= 0 (already expired); callers decide how
+-- to treat that. Returns nil if either start or duration is missing.
+function TotemBar.remaining(start, duration, now)
+ if not start or not duration then
+ return nil
+ end
+ return start + duration - now
+end
+
+-- Pure: is any element's own-tracked totem still out (remaining > 0)?
+function TotemBar.anyActiveTracked(activeTotems, elements, now, remainingFn)
+ if not activeTotems then
+ return false
+ end
+ for i = 1, table.getn(elements) do
+ local rec = activeTotems[elements[i]]
+ if rec then
+ local rem = remainingFn(rec.start, rec.duration, now)
+ if rem and rem > 0 then
+ return true
+ end
+ end
+ end
+ return false
+end
+
+-- Pure: decides which of two already-computed remaining-seconds values
+-- to show for one element's timer text. GetTotemInfo (pfUI's
+-- libtotem), when it reports the slot active, is authoritative;
+-- otherwise (absent, or reporting the slot inactive) falls back to
+-- TotemBar's own cast-tracking. Returns nil when neither source has
+-- time left.
+function TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining, tombstoned)
+ -- FIX 2026-07-15: trust GTI (pfUI libtotem) ONLY when it reports the slot active
+ -- AND with positive time left. Previously a stale-active GTI slot (active but
+ -- gtiRemaining<=0 -- libtotem evicts lazily on read) hit the bare `return nil` and
+ -- BLOCKED the timer even when own-tracking had a valid time. That produced the
+ -- Fire/Earth-dead, Air-flickering, Water-ok pattern (pfUI's single non-slot-indexed
+ -- cast queue loses the race for the slots cast later in a multi-drop).
+ --
+ -- FIX (destroyed-totem review, adversarial-review finding #1): `tombstoned`
+ -- (see TotemBar.tombstoneActive/TotemBar.destroyedTombstone) forces this to
+ -- skip the GTI branch entirely for a totem this addon just evicted as
+ -- destroyed. Without it, a destroy-eviction cleared ownRemaining/ownRecord
+ -- correctly but pfUI's libtotem -- itself just another blind duration timer,
+ -- see the header comment above TotemBar.shouldLatchGuidFromCastEvent below
+ -- -- kept reporting the SAME destroyed totem active until ITS OWN timer ran
+ -- out, so the countdown/ring/pulse reappeared from the GTI branch a moment
+ -- after this addon had just cleared them. tombstoned=nil/false (every
+ -- existing call site before this fix) reproduces the old behaviour exactly.
+ if not tombstoned and gtiActive and gtiRemaining and gtiRemaining > 0 then
+ return gtiRemaining
+ end
+ if ownRemaining and ownRemaining > 0 then
+ return ownRemaining
+ end
+ return nil
+end
+
+-- Pure: is element's destroyed-tombstone still in effect? tombstoneExpiry is
+-- TotemBar.destroyedTombstone[element] (nil if none), the GetTime() the
+-- destroy-evicted record would naturally have expired at. Strictly less-than:
+-- at the exact expiry moment GTI is trusted again (by then GTI's own blind
+-- timer would have run out too, so there is nothing left to falsely resurrect).
+function TotemBar.tombstoneActive(tombstoneExpiry, now)
+ if not tombstoneExpiry or not now then
+ return false
+ end
+ return now < tombstoneExpiry
+end
+
+-- Pure: should the out-of-range red tint treat this element as ACTIVE?
+--
+-- Own tracking is the base signal. When pfUI's libtotem is present its
+-- GetTotemInfo may VETO it, so a totem DESTROYED before its timer ran out
+-- stops flashing red -- but only for a slot libtotem demonstrably tracked
+-- (gtiTracked, latched in ui.lua when GTI reported the slot active under this
+-- record's totem name). "GTI has no record for this slot" is no information,
+-- not a contradiction: libtotem keeps a single non-slot-indexed cast queue
+-- committed on ONE SPELLCAST_STOP, so a four-totem drop leaves three slots
+-- with no GTI record at all. Vetoing on those silenced both the red tint and
+-- the Recall button's out-of-range pulse for most of the set -- the same
+-- missing-GTI race resolveRemaining above already fixed for the timer path.
+function TotemBar.rangeTintActive(hasOwnRecord, hasGTI, gtiTracked, gtiActive)
+ if not hasOwnRecord then
+ return false
+ end
+ if hasGTI and gtiTracked and not gtiActive then
+ return false
+ end
+ return true
+end
+
+-- ===== Destroyed-totem detection (GUID liveness) =====
+--
+-- Everything above (rangeTintActive included) can tell a totem is out of
+-- the PLAYER's own buff range, but nothing in this file -- nor pfUI's
+-- libtotem (checked against its source, pfUI/libs/libtotem.lua:
+-- GetTotemInfo's "active" flag is ALSO just a blind start+duration timer,
+-- cleared only on natural expiry or PLAYER_DEAD, never on the totem
+-- actually dying) -- can tell that a totem was DESTROYED (killed by an
+-- enemy, an AoE, a dispel) before its timer ran out. Fixing that needs the
+-- totem's own GUID, which recordCast cannot know at cast time (1.12 gives
+-- no return value naming the newly summoned unit); it is latched
+-- OPPORTUNISTICALLY after the fact by the event hooks further below, once a
+-- GUID becomes observable through the client's own SuperWoW telemetry:
+--
+-- 1. UNIT_CASTEVENT (SuperWoW): a totem that periodically CASTS its
+-- pulse (Fire Nova, Magma, Mana Spring, Healing Stream, Mana Tide, the
+-- Cleansing totems, ...) fires this with its own GUID as casterGUID
+-- the first time it pulses -- the same event core/pulsecal.lua already
+-- observes for calibration telemetry; this just also keeps the GUID.
+-- COVERAGE: pulsing totems only -- a totem that never casts anything
+-- never latches through this path. CONFIRMED (2026-08-20 event tap)
+-- that casterGUID (arg1) resolves via UnitName to the TOTEM's own name
+-- -- but the pulse SPELL's own name (arg4/SpellInfo) does NOT equal the
+-- totem's name (a Searing Totem's pulse is "Searing Bolt", Healing
+-- Stream Totem's is "Healing Stream") -- the original elementFromCastName
+-- exact-match-on-spell-name mapping was structurally unable to latch
+-- ANYTHING. Fixed: latch now maps through UnitName(arg1) (rank-tolerant,
+-- TotemBar.totemNameMatches), not the spell name at all. Also gated on
+-- the caster's resolved "owner" matching the player
+-- (TotemBar.shouldLatchGuidFromCastEvent) -- see that function's own
+-- comment for why (a stranger's same-named totem must never latch
+-- here); "owner" resolving for a SuperWoW casterGUID is still
+-- UNVERIFIED (the tap did not include another shaman's totem nearby).
+-- 2. UNIT_MODEL_CHANGED (native 1.12): fallback for totems that never
+-- cast anything visible (Stoneskin, Strength of Earth, Grace of Air,
+-- Windwall, Sentry, Grounding-until-triggered). CONFIRMED (2026-08-20
+-- event tap) that it fires on totem spawn with UnitName(unit) resolving
+-- to the totem's own RANKED name ("Searing Totem VI", "Magma Totem
+-- IV" -- single-rank totems like Tremor Totem carry no suffix at all) --
+-- the original exact-match against the bare book name could therefore
+-- never latch a ranked totem. Fixed via TotemBar.totemNameMatches
+-- (rank-tolerant). Whether "owner" resolves the totem's owner on
+-- this client is still UNVERIFIED -- the handler stays fail-open (any
+-- missing piece just means no latch, never a false one).
+--
+-- Neither latch path is guaranteed to fire for every totem -- a record that
+-- never gets a rec.guid simply never runs the liveness poll below and keeps
+-- today's blind-timer behaviour, UNLESS the GUID-free destruction signals
+-- below (CHAT_MSG_COMBAT_FRIENDLY_DEATH / UNIT_DIED fallback) catch it
+-- instead -- see "Rank-tolerant name match + destruction signals" further
+-- down for those and for what the 2026-08-20 tap confirmed/fixed. This is
+-- additive, never a regression, and needs zero SuperWoW/nampower presence
+-- checks of its own: every path that touches a WoW global here already
+-- guards it.
+
+-- Pure: given a UNIT_CASTEVENT's resolved totem name (UnitName(casterGUID)
+-- -- the totem UNIT's own name, ranked, NOT the pulse spell's name; see the
+-- 2026-08-20 event-tap header comment further below for why this is keyed
+-- off the unit and not SpellInfo(arg4) any more), the element's CURRENT
+-- tracked record (or nil), the resolved "owner" of the casting unit
+-- (UnitName(casterGUID .. "owner"), see the UNIT_MODEL_CHANGED pendant
+-- below for the same idea) and the player's own name, should this event
+-- latch its casterGUID onto that record?
+--
+-- The owner check exists because totem NAME alone is not unique: another
+-- shaman standing nearby with the same totem type pulsing at the same
+-- moment resolves to the SAME element/name match. Without verifying
+-- ownership, this event could latch a STRANGER's totem GUID onto our own
+-- element -- and when THEIR totem dies, the liveness poll would evict OUR
+-- still-standing one (a regression strictly worse than not having this
+-- feature at all). ownerName/playerName may be nil (out of range,
+-- unsupported "owner" token, ...) -- that fails CLOSED, i.e. no
+-- latch, same policy as everywhere else in this file: a totem that never
+-- gets a guid just keeps today's blind-timer behaviour, which can never
+-- falsely evict.
+--
+-- Otherwise latches for the exact totem currently tracked (rank-tolerant,
+-- TotemBar.totemNameMatches -- the ranked unit name vs. the bare book name),
+-- and only once -- a record that already has a guid keeps it (the first
+-- pulse that resolves it wins; re-checking a later pulse from the SAME
+-- totem would just repeat the same GUID anyway).
+function TotemBar.shouldLatchGuidFromCastEvent(rec, castTotemName, ownerName, playerName)
+ if not rec or rec.guid then
+ return false
+ end
+ if not castTotemName or not ownerName or not playerName then
+ return false
+ end
+ if not TotemBar.totemNameMatches(rec.totemName, castTotemName) then
+ return false
+ end
+ return ownerName == playerName
+end
+
+-- Pure: given a UNIT_MODEL_CHANGED unit's resolved name (ranked, e.g.
+-- "Searing Totem VI" -- see the 2026-08-20 event-tap header comment further
+-- below), its resolved "owner" name and the player's own name, should the
+-- caller try to latch a GUID onto `rec` (the element whose totemName
+-- rank-tolerantly matches unitName, TotemBar.totemNameMatches)? Every input
+-- may be nil/empty; missing information never latches -- fail open, same
+-- policy as every other gate in this file.
+function TotemBar.shouldLatchGuidFromModelChanged(rec, unitName, ownerName, playerName)
+ if not rec or rec.guid then
+ return false
+ end
+ if not unitName or not ownerName or not playerName then
+ return false
+ end
+ if not TotemBar.totemNameMatches(rec.totemName, unitName) then
+ return false
+ end
+ return ownerName == playerName
+end
+
+-- Pure: normalizes a GUID string for tolerant comparison -- lowercased, and
+-- with a leading "0x"/"0X" prefix stripped if present. UNIT_CASTEVENT and
+-- UNIT_DIED are not guaranteed to format GUIDs identically (only confirmed
+-- that SOME SuperWoW events use a "0x"-prefixed hex string); comparing
+-- normalized forms avoids a silent non-match between the two sources.
+function TotemBar.normalizeGuid(guid)
+ if not guid then
+ return nil
+ end
+ if type(guid) ~= "string" then
+ guid = tostring(guid)
+ end
+ local lowered = string.lower(guid)
+ local _, _, stripped = string.find(lowered, "^0x(.+)$")
+ return stripped or lowered
+end
+
+-- Pure: do two GUIDs (in whatever raw form each source handed us) refer to
+-- the same unit? nil on either side never matches.
+function TotemBar.guidsEqual(a, b)
+ local na = TotemBar.normalizeGuid(a)
+ local nb = TotemBar.normalizeGuid(b)
+ if not na or not nb then
+ return false
+ end
+ return na == nb
+end
+
+-- Pure: which element (if any) currently has a latched rec.guid matching
+-- rawGuid? Used by the UNIT_DIED handler further below, which -- unlike the
+-- latch paths above -- must NOT resolve unit names at the moment of death
+-- (see that handler's own comment for why): the raw GUID is all it has to
+-- go on. Scans the fixed 4-element table each time rather than keeping a
+-- separate guid->element map, so there is nothing extra to keep in sync or
+-- leak. equalFn is injected (TotemBar.guidsEqual in production, the same
+-- tolerant-comparison function the rest of this module uses) so this stays
+-- offline-testable without any WoW-side GUID assumptions baked in.
+function TotemBar.elementForGuid(activeTotems, elements, rawGuid, equalFn)
+ if not activeTotems or not rawGuid then
+ return nil
+ end
+ for i = 1, table.getn(elements) do
+ local element = elements[i]
+ local rec = activeTotems[element]
+ if rec and rec.guid and equalFn(rec.guid, rawGuid) then
+ return element
+ end
+ end
+ return nil
+end
+
+-- Pure: given the raw (exists, health, deadOrGhost) tuple a liveness poll
+-- just read for a totem's GUID, was the totem destroyed? `exists` gates
+-- everything else: SuperWoW can only resolve a GUID it currently has in
+-- range/visibility, so "does not exist" is UNKNOWN (out of range, zoned
+-- away, simply not yet resolved -- the same class of read any GUID-liveness
+-- scanner has to guard identically), never "destroyed". Only once the unit
+-- DOES resolve does a zero health read or UnitIsDeadOrGhost==1 count as a
+-- verdict. Fails open on the unknown case, same policy as every other gate
+-- in this file: a totem merely out of visibility keeps its timer running
+-- exactly like it does today, instead of the poll wrongly clearing a totem
+-- that is still alive.
+function TotemBar.totemDestroyed(exists, health, deadOrGhost)
+ if not exists then
+ return false
+ end
+ if health == 0 or deadOrGhost == 1 then
+ return true
+ end
+ return false
+end
+
+-- Pure: should the liveness poll actually touch UnitExists/UnitHealth/
+-- UnitIsDeadOrGhost this tick? Throttled to TotemBar.GUID_LIVENESS_POLL_INTERVAL,
+-- independent of ui.lua's faster 0.1s display tick that calls this every
+-- pass. nil lastCheckTime (first call) always polls.
+function TotemBar.shouldPollLiveness(lastCheckTime, now, interval)
+ if not lastCheckTime then
+ return true
+ end
+ if not interval then
+ return true
+ end
+ return (now - lastCheckTime) >= interval
+end
+
+-- Evicts activeTotems[element] as DESTROYED, tombstoning the element so
+-- resolveRemaining ignores pfUI's libtotem for it until the evicted
+-- record's own natural expiry (see TotemBar.destroyedTombstone/
+-- tombstoneActive above) -- without this, libtotem's OWN blind timer keeps
+-- reporting the same destroyed totem active and the countdown/ring/pulse
+-- reappear from the GTI branch a moment after being correctly cleared here.
+-- Shared by ui.lua's liveness poll and the UNIT_DIED fast-path below, so
+-- both eviction routes protect the display identically -- not pure (reads/
+-- writes the two live tracking tables directly), same category as
+-- TotemBar.clearActiveTotems further below.
+function TotemBar.evictDestroyedTotem(element)
+ local rec = TotemBar.activeTotems[element]
+ if not rec then
+ return
+ end
+ if rec.start and rec.duration then
+ TotemBar.destroyedTombstone[element] = rec.start + rec.duration
+ end
+ TotemBar.activeTotems[element] = nil
+end
+
+-- ===== Rank-tolerant name match + destruction signals (2026-08-20 event tap) =====
+--
+-- A live event tap against the real client (2026-08-20) confirmed and killed
+-- three of this section's own "UNVERIFIED" caveats above, and supplied the
+-- fix for each:
+--
+-- (a) UNIT_MODEL_CHANGED's UnitName(unit) is RANKED ("Searing Totem VI"),
+-- never the bare book name recordCast stores (rec.totemName ==
+-- "Searing Totem") -- shouldLatchGuidFromModelChanged's exact `==`
+-- compare could never latch a ranked totem at all. Fixed by
+-- TotemBar.totemNameMatches below (single-rank totems like "Tremor
+-- Totem" have no suffix and still match via plain equality).
+-- (b) UNIT_CASTEVENT's resolved SPELL name is NOT the totem's own name
+-- (a Searing Totem's pulse is "Searing Bolt", Healing Stream Totem's
+-- is "Healing Stream", ...) -- shouldLatchGuidFromCastEvent's mapping
+-- through SpellInfo(arg4)/elementFromCastName was structurally unable
+-- to match. Fixed by mapping through UnitName(arg1) instead (arg1 is
+-- the TOTEM's own casterGUID) -- the SpellInfo path is no longer
+-- needed as an additional condition for this latch.
+-- (c) TurtleWoW emits a totem's own death as
+-- `CHAT_MSG_COMBAT_FRIENDLY_DEATH` with arg1 literally
+-- " () is destroyed." -- a GUID-free, PvP-safe primary
+-- signal that needs neither latch path above to have ever fired.
+-- TotemBar.parseDestroyedLine/elementForOwnedTotemName below.
+--
+-- Still unconfirmed: live in-game EVICTION on an actual destroy (the tap
+-- captured spawn/pulse/death lines, not a full addon-in-the-loop retest) --
+-- see CHANGELOG.md's Unreleased entry for the precise envelope.
+--
+-- Every totem this addon tracks is stored under its bare spellbook name
+-- (rec.totemName, set by recordCast from the spellbook/tooltip scan -- see
+-- recordCast above); TurtleWoW's live client hands back the RANKED display
+-- name ("Searing Totem VI") for both UNIT_MODEL_CHANGED's UnitName(unit) and
+-- the destroy/death chat lines below, but a single-rank totem like Tremor
+-- Totem carries no suffix at all.
+
+-- Pure: does `unitName` (a live client name, possibly rank-suffixed) refer
+-- to the SAME totem `recName` (this addon's own bare spellbook name)? True
+-- on an exact match, or when unitName is recName plus " " and a trailing
+-- roman-numeral rank ("Searing Totem VI" matches "Searing Totem"). The rank
+-- character class ([IVXLC]) matches core/pulseparse.lua's own stripRank --
+-- one convention for "what a rank suffix looks like" project-wide, not a
+-- fresh decision here. Anchored ($) so a totem name that merely STARTS with
+-- recName ("Searing Totem of Doom") never false-matches: the leftover after
+-- stripping a trailing roman-numeral token must be nothing else. nil-safe.
+function TotemBar.totemNameMatches(recName, unitName)
+ if not recName or not unitName then
+ return false
+ end
+ if unitName == recName then
+ return true
+ end
+ local _, _, base = string.find(unitName, "^(.-)%s+[IVXLC]+$")
+ return base == recName
+end
+
+-- Pure: parses TurtleWoW's own totem-death chat line, e.g.
+-- `Searing Totem VI (Playername) is destroyed.` -> "Searing Totem VI", "Playername".
+-- Returns nil, nil for any line that doesn't fit the exact shape (including
+-- the generic vanilla " dies." form -- see parseDiesLine below for
+-- that one). Lua 5.0: string.find with captures, parens/period escaped --
+-- never string.match (nil-call on this interpreter, see project rules).
+function TotemBar.parseDestroyedLine(line)
+ if not line then
+ return nil, nil
+ end
+ local _, _, name, owner = string.find(line, "^(.-) %((.-)%) is destroyed%.$")
+ return name, owner
+end
+
+-- Pure: parses the generic vanilla " dies." combat line -> "",
+-- or nil if the line doesn't fit that shape. Deliberately carries no owner
+-- (vanilla's own death line has none) -- see elementForDiesLineCandidate
+-- below for why that makes this fallback path need its own, stricter gate.
+function TotemBar.parseDiesLine(line)
+ if not line then
+ return nil
+ end
+ local _, _, name = string.find(line, "^(.-) dies%.$")
+ return name
+end
+
+-- Pure: which element (if any) has an active, OWN record whose totemName
+-- rank-tolerantly matches `name`, given that `ownerName` was resolved as
+-- the player's own name? Used both for the destroyed-line's parsed
+-- (name, owner) pair (GUID-free -- the destroy chat line alone is enough)
+-- and UNIT_DIED's GUID-free fallback (see the guidFrame handler below) --
+-- same shape, same fail-closed policy as every latch gate in this file: a
+-- missing/foreign owner or no name match never evicts. Scans the fixed
+-- 4-element table like elementForGuid above, for the same reason (nothing
+-- extra to keep in sync).
+function TotemBar.elementForOwnedTotemName(activeTotems, elements, name, ownerName, playerName)
+ if not activeTotems or not name or not ownerName or not playerName then
+ return nil
+ end
+ if ownerName ~= playerName then
+ return nil
+ end
+ for i = 1, table.getn(elements) do
+ local element = elements[i]
+ local rec = activeTotems[element]
+ if rec and rec.totemName and TotemBar.totemNameMatches(rec.totemName, name) then
+ return element
+ end
+ end
+ return nil
+end
+
+-- Pure: which element (if any) is a CANDIDATE for the generic " dies."
+-- fallback -- name-matches an active record AND that record already has a
+-- latched rec.guid? The "dies." line carries no owner, so name alone is not
+-- enough to trust blindly (an unrelated mob sharing a totem's plain name
+-- would otherwise evict a live totem); requiring an already-latched GUID
+-- means the caller can go verify THAT SPECIFIC unit via
+-- UnitExists/UnitHealth/UnitIsDeadOrGhost (TotemBar.totemDestroyed, same
+-- verdict function the liveness poll uses) before evicting -- a
+-- confirmation query, not blind trust in the chat line. Returns the
+-- candidate only; the caller still has to run that verification.
+function TotemBar.elementForDiesLineCandidate(activeTotems, elements, name)
+ if not activeTotems or not name then
+ return nil
+ end
+ for i = 1, table.getn(elements) do
+ local element = elements[i]
+ local rec = activeTotems[element]
+ if rec and rec.totemName and rec.guid and TotemBar.totemNameMatches(rec.totemName, name) then
+ return element
+ end
+ end
+ return nil
+end
+
+-- Out-of-mana dim level. Blizzard's own "unusable" grey from FrameXML
+-- ActionButton.lua:280 (ActionButton_UpdateUsable). Blizzard reserves a BLUE
+-- tint (0.5,0.5,1.0) for the not-enough-mana case specifically and grey for
+-- every other reason; TotemBar dims instead, because its icons already carry a
+-- red state and a second HUE would compete with it, while a brightness step
+-- reads on top of any hue.
+TotemBar.OOM_DIM = 0.4
+
+-- Pure: the icon's vertex colour, composed from the two independent reasons a
+-- totem button can be tinted -- out of range (red, buff-presence based, see
+-- rangeTintActive above) and out of mana (dimmed, new). They MULTIPLY rather
+-- than override, so an out-of-range totem the player also cannot afford stays
+-- recognisably red while reading as unavailable; two separate SetVertexColor
+-- call sites would instead have raced, and whichever ran last would have won.
+--
+-- The fourth return is a cache key: the caller stores it on the button and only
+-- touches the texture when it changes (0 allocations, no redundant API calls on
+-- the 5Hz tick).
+function TotemBar.iconTintFor(rangeRed, oom)
+ local r, g, b = 1, 1, 1
+ if rangeRed then
+ r, g, b = 1, 0.35, 0.35
+ end
+ local key = 0
+ if rangeRed then
+ key = key + 1
+ end
+ if oom then
+ key = key + 2
+ r = r * TotemBar.OOM_DIM
+ g = g * TotemBar.OOM_DIM
+ b = b * TotemBar.OOM_DIM
+ end
+ return r, g, b, key
+end
+
+-- Pure: OmniCC-style text for an already-known-positive remaining
+-- seconds value: whole minutes rounded up from 60s on, plain rounded-up
+-- integer seconds below that.
+function TotemBar.formatRemaining(remaining)
+ if remaining >= 60 then
+ return string.format("%dm", math.ceil(remaining / 60))
+ end
+ return string.format("%d", math.ceil(remaining))
+end
+
+-- Pure: strips a trailing rank suffix from a spell name as CastSpellByName
+-- would receive it -- both "Searing Totem(Rank 4)" (no space, what the
+-- client itself produces) and "Searing Totem (Rank 4)" (a space, common in
+-- hand-typed macros) are accepted forms. Returns the input unchanged if it
+-- carries no such suffix, or nil if name is nil.
+function TotemBar.stripRankSuffix(name)
+ if not name then
+ return nil
+ end
+ local _, _, base = string.find(name, "^(.-)%s*%(.-%)%s*$")
+ if base and base ~= "" then
+ return base
+ end
+ return name
+end
+
+-- Pure: given a spell name as CastSpellByName/GetSpellName would produce it
+-- (optionally rank-suffixed, see stripRankSuffix above), returns the
+-- element it belongs to and its rank-stripped base name -- or nil, nil if
+-- rawName is nil or isn't one of TotemBar's known totems (core/totemdata.lua).
+-- Used by the universal CastSpellByName/CastSpell hooks below to decide
+-- whether a cast caught outside TotemBar's own paths is a totem at all.
+function TotemBar.elementFromCastName(rawName)
+ if not rawName then
+ return nil, nil
+ end
+ local baseName = TotemBar.stripRankSuffix(rawName)
+ local element = TotemBar.elementOf(baseName)
+ if not element then
+ return nil, nil
+ end
+ return element, baseName
+end
+
+-- Finds the spellbook index of a known spell by exact name, or nil.
+-- Used to duplicate ui.lua's own file-local FindSpellIndexByName as its
+-- own fresh linear scan (no common "api" module to hang a single copy
+-- off). Both now route through core/spellindex.lua's cached
+-- TotemBar.findSpellIndex (loaded earlier in the TOC) instead.
+
+-- ===== Cast gate: don't start a countdown for a cast that never went out =====
+--
+-- The symptom this fixes: the timer starts even when the totem could not be
+-- placed. recordCast runs right after CastSpellByName, and 1.12 gives Lua no
+-- return value saying whether the cast was accepted -- so every refused press
+-- used to start a full-length phantom countdown (and made anyTotemOut() true,
+-- which then burned Totemic Recall's 6s cooldown on an empty board).
+--
+-- The verdict is taken BEFORE the cast, by the CastSpellByName/CastSpell hooks
+-- below. Measuring afterwards cannot work: by then the mana is already spent
+-- (so every successful cast looks unaffordable) and the GCD is already running
+-- (so every successful cast looks cooldown-blocked).
+
+-- A cooldown at or below this is treated as the global cooldown and never
+-- blocks. Two reasons: GetSpellCooldown reports the GCD in the same fields as a
+-- real cooldown, and nampower QUEUES a GCD-blocked instant
+-- (NP_QueueInstantSpells, default on) so it still goes out a moment later --
+-- refusing to record it would drop the timer of a totem that IS standing.
+TotemBar.GCD_MAX = 1.6
+
+-- Pure: why can this cast not have gone out? Returns nil (fail open), "mana" or
+-- "cooldown". Every input may be nil -- an unknown never blocks, exactly like
+-- notEnoughMana/confidentNoneOut. Mana is reported first because it is the one
+-- the player can act on.
+--
+-- NO CLEARCASTING EXEMPTION (removed 2026-08-28, player-reported bug). This gate
+-- used to stand down entirely while Clearcasting (Elemental Focus) was up, on the
+-- assumption that it zeroes the next cast's cost. It does NOT cover totems --
+-- Elemental Focus applies to the shaman's damage spells only, so a totem still
+-- costs full price while Clearcasting is up. Since totems are the only thing this
+-- addon casts, that exemption disabled the mana gate outright for a shaman who
+-- crits regularly: the client refused a Magma Totem for lack of mana while the
+-- timer had already been recorded, so the bar showed a countdown for a totem that
+-- was never placed. A 6th argument is still ACCEPTED and ignored (Lua drops extra
+-- args) so no stale caller can quietly re-enable it.
+function TotemBar.castGateReason(cost, mana, cdStart, cdDuration, gcdMax)
+ if TotemBar.notEnoughMana(cost, mana) then
+ return "mana"
+ end
+ if cdStart and cdDuration and cdStart > 0 and cdDuration > (gcdMax or TotemBar.GCD_MAX) then
+ return "cooldown"
+ end
+ return nil
+end
+
+-- The pre-cast verdict, stamped with the tick it was taken in. recordCast below
+-- honours it only for the SAME element, the SAME totem and the SAME GetTime()
+-- tick, so a stale verdict can never suppress a later, legitimate cast.
+TotemBar.castBlock = nil
+
+-- WoW-side snapshot taken immediately before the cast leaves. Reads the cached
+-- tooltip mana cost, live mana, and the spell's own cooldown.
+local function computeCastBlock(element, totemName)
+ local cost = TotemBar.getTotemManaCost and TotemBar.getTotemManaCost(totemName)
+ local mana = (type(UnitMana) == "function") and UnitMana("player") or nil
+ local cdStart, cdDuration = nil, nil
+ if type(GetSpellCooldown) == "function" and TotemBar.findSpellIndex then
+ local idx = TotemBar.findSpellIndex(totemName)
+ if idx then
+ cdStart, cdDuration = GetSpellCooldown(idx, BOOKTYPE_SPELL)
+ end
+ end
+ return TotemBar.castGateReason(cost, mana, cdStart, cdDuration, TotemBar.GCD_MAX)
+end
+
+-- This runs INSIDE the CastSpellByName hook, i.e. in front of every totem cast
+-- the client makes -- including the first one for a totem, where the cost is
+-- still unknown and getTotemManaCost scans a hidden tooltip. Wrapped so that
+-- nothing in that chain (tooltip, spellbook scan, buff walk) can abort the cast
+-- itself. A failure yields no verdict, which is the fail-open state this gate
+-- has anyway whenever an input is unknown -- the cast proceeds and is tracked
+-- exactly as it was before this feature existed.
+function TotemBar.measureCastBlock(element, totemName)
+ if not element or not totemName then
+ return nil
+ end
+ local ok, reason = pcall(computeCastBlock, element, totemName)
+ if not ok then
+ reason = nil
+ end
+ if reason then
+ TotemBar.castBlock = {
+ element = element,
+ name = totemName,
+ at = GetTime(),
+ reason = reason,
+ }
+ else
+ -- Clear rather than leave: a verdict from an earlier cast in this same
+ -- tick must not outlive the cast it belongs to.
+ TotemBar.castBlock = nil
+ end
+ return reason
+end
+
+-- Records that `totemName` was just cast into `element`'s slot, into
+-- TotemBar's own tracking table (see activeTotems above). Touches
+-- GetTime(), a spellbook index/texture scan and (for Searing Totem) a
+-- rank scan, so it isn't pure; called from both the bar's left-click
+-- path (ui.lua) and castNext()/castAll() below.
+--
+-- Also stashes the totem spell's icon texture (rec.icon) and a
+-- self-learning "did I ever see this totem's buff" flag
+-- (rec.everHadBuff, starts false). ui.lua's out-of-range red-tint
+-- feature is buff-presence based: a totem's party buff uses the SAME
+-- icon texture as the totem spell itself (verified in-game), so
+-- TotemBar.hasBuffWithIcon(rec.icon) tells whether the player is
+-- currently benefiting from THIS cast totem.
+function TotemBar.recordCast(element, totemName)
+ if not element or not totemName then
+ return
+ end
+ -- Refused before it left (see measureCastBlock): no timer, and -- like the
+ -- unknown-totem guard below -- no castState.everCast either, since a cast
+ -- that never happened is not evidence that anything is out.
+ local blk = TotemBar.castBlock
+ if blk and blk.reason and blk.element == element and blk.name == totemName
+ and blk.at == GetTime() then
+ return
+ end
+ local highestRank = nil
+ if totemName == "Searing Totem" and TotemBar.highestKnownRank then
+ highestRank = TotemBar.highestKnownRank(totemName)
+ end
+ local icon = nil
+ local idx = TotemBar.findSpellIndex(totemName)
+ if idx then
+ icon = GetSpellTexture(idx, BOOKTYPE_SPELL)
+ elseif TotemBar.spellbookEntryCount and TotemBar.spellbookEntryCount() > 0 then
+ -- The spell is provably NOT in this character's spellbook, so the cast
+ -- that just went out was a guaranteed no-op -- don't start a full-length
+ -- phantom countdown (and a true anyTotemOut(), which then burns Totemic
+ -- Recall's 6s cooldown on an empty board). Reachable because
+ -- TotemBarDB is account-wide: a chosen set carried over from another
+ -- shaman lands on an alt that hasn't learned those totems.
+ -- Guarded on a NON-EMPTY spellbook scan on purpose: the index cache is
+ -- built lazily and the book isn't reliably populated at login, so an
+ -- empty scan means "no data", not "not known" -- fail OPEN there,
+ -- exactly like confidentNoneOut's unknown-state policy below.
+ return
+ end
+ local rec = {
+ start = GetTime(),
+ duration = TotemBar.durationWithMastery(
+ TotemBar.totemDuration(totemName, highestRank),
+ TotemBar.isHelpfulTotem(totemName),
+ TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery()),
+ totemName = totemName,
+ icon = icon,
+ everHadBuff = false,
+ -- Latched by ui.lua once GetTotemInfo reports this slot active under
+ -- this record's totem name; see TotemBar.rangeTintActive below.
+ gtiTracked = false,
+ -- Latched opportunistically by the UNIT_CASTEVENT/UNIT_MODEL_CHANGED
+ -- hooks below (see "Destroyed-totem detection (GUID liveness)"), NOT
+ -- by recordCast itself -- 1.12 gives no return value naming the
+ -- newly summoned unit at cast time. A fresh table every call means a
+ -- re-cast on this element can never inherit a previous totem's guid.
+ guid = nil,
+ }
+ -- Kept for revokeRecentCast below: if this cast turns out to have been
+ -- refused, the honest correction is the record as it was BEFORE the press,
+ -- not an empty slot (a refused REPEAT press must not lose the still-running
+ -- timer of the totem the first press placed).
+ TotemBar.lastOverwritten = {
+ element = element,
+ rec = TotemBar.activeTotems[element],
+ at = rec.start,
+ }
+ TotemBar.activeTotems[element] = rec
+ -- A REAL cast landing on this element proves whatever this addon
+ -- previously believed was destroyed there is no longer relevant --
+ -- clear the tombstone immediately so resolveRemaining trusts GTI again
+ -- for this element's new totem right away, instead of waiting out the
+ -- old totem's now-meaningless expiry stamp. Only reached on an actual
+ -- recorded cast (the refused-cast early return above never gets here),
+ -- matching "a new cast clears the tombstone", not "an attempted one".
+ TotemBar.destroyedTombstone[element] = nil
+ -- Sticky session evidence for confidentNoneOut() below. Deliberately NOT
+ -- derived from activeTotems' occupancy: ui.lua's timer tick evicts each
+ -- record the moment it expires, and clearActiveTotems() wipes the table
+ -- after every recall.
+ TotemBar.castState.everCast = true
+end
+
+-- Records a totem cast caught by the universal CastSpellByName/CastSpell
+-- hooks below, UNLESS TotemBar's own code already recorded this EXACT cast
+-- this same GetTime() tick. Every TotemBar-exposed cast entry point
+-- (bind.lua's CastTotem/CastElement, castNext/castAll/dropSetKey/
+-- recallAndCastAll above, ui.lua's bar/flyout click handlers) already calls
+-- TotemBar.recordCast() itself right after casting -- verified by a full
+-- inventory of every Bindings.xml binding, the "Totems" macro, and every
+-- click handler (2026-07-16): none of them skip it. So a hook firing for a
+-- cast that ALSO went through one of those paths would otherwise
+-- double-record in the same frame (harmless -- recordCast just overwrites
+-- with a near-identical timestamp -- but trivially avoidable with this one
+-- check, so it is).
+function TotemBar.recordCastFromHook(element, totemName)
+ local existing = TotemBar.activeTotems[element]
+ if existing and existing.totemName == totemName and existing.start == GetTime() then
+ return
+ end
+ TotemBar.recordCast(element, totemName)
+end
+
+-- ===== Revoking a timer a failure event proves wrong =====
+--
+-- Second line after the pre-cast gate, for refusals it cannot predict (the
+-- server's own "no", an element recovery too short to tell apart from the
+-- GCD, movement, or a client-side refusal like "another action in
+-- progress"). nampower's SPELL_FAILED_SELF (carries a SPELL ID) is the exact
+-- source when nampower is present; UI_ERROR_MESSAGE/SPELLCAST_FAILED/
+-- SPELLCAST_INTERRUPTED below are the fallback for everyone else.
+--
+-- Vanilla's SPELLCAST_FAILED and UI_ERROR_MESSAGE were refused for a long
+-- time: both are global and carry no hint of which action failed
+-- (UI_ERROR_MESSAGE's single arg is just the message text). During key spam a
+-- refused Lightning Bolt could delete the timer of a totem that is standing --
+-- the classic mis-attribution that hits every addon inferring its own outcome
+-- from those two events. This is now wired up (see lastCastAttempt and
+-- attributeCastFailure below), gated tightly enough that the Lightning Bolt
+-- case above cannot happen:
+-- 1. the LAST spell the client attempted through CastSpellByName/CastSpell
+-- -- ANY spell, not only totems -- must have been the totem itself. A
+-- Lightning Bolt cast after it, even a fraction of a second later,
+-- becomes the new "last attempt" and the totem is never touched.
+-- 2. that attempt must be within CAST_FAIL_WINDOW seconds.
+-- 3. for UI_ERROR_MESSAGE specifically (the one event of the three that
+-- fires for unrelated things too -- loot, trade, chat, ...) the message
+-- text must be one of the client's own "a spell cast was refused"
+-- strings (read live off the globals, so this self-localizes).
+-- A missing timer is still a worse failure than a phantom one, so anything
+-- that fails any of the three checks is left alone.
+
+-- How long after a cast a failure can still belong to it.
+TotemBar.CAST_FAIL_WINDOW = 0.4
+
+-- Pure: the one element whose record was created inside the window, or nil if
+-- that is ambiguous. A four-totem drop puts several records in the same window;
+-- with no spell id to disambiguate, ANY pick would be a guess, and a wrong
+-- guess deletes a standing totem's timer.
+function TotemBar.soleRecentElement(activeTotems, elements, now, window)
+ if not activeTotems then
+ return nil
+ end
+ local found = nil
+ for i = 1, table.getn(elements) do
+ local element = elements[i]
+ local rec = activeTotems[element]
+ if rec and rec.start and (now - rec.start) <= window then
+ if found then
+ return nil -- ambiguous: refuse to guess
+ end
+ found = element
+ end
+ end
+ return found
+end
+
+-- Undoes the tracking of a cast that the client/server refused. `spellName` is
+-- the failed totem's name when it could be resolved (exact pick, even with
+-- several casts in flight); nil falls back to the sole-recent rule above.
+--
+-- Restores the record the refused press overwrote instead of clearing the slot
+-- -- see TotemBar.lastOverwritten in recordCast.
+function TotemBar.revokeRecentCast(spellName)
+ local now = GetTime()
+ local element = nil
+ if spellName then
+ for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
+ local el = TotemBar.TOTEM_ELEMENTS[i]
+ local rec = TotemBar.activeTotems[el]
+ if rec and rec.totemName == spellName and rec.start
+ and (now - rec.start) <= TotemBar.CAST_FAIL_WINDOW then
+ element = el
+ end
+ end
+ end
+ if not element then
+ element = TotemBar.soleRecentElement(TotemBar.activeTotems,
+ TotemBar.TOTEM_ELEMENTS, now, TotemBar.CAST_FAIL_WINDOW)
+ end
+ if not element then
+ return nil
+ end
+ local prev = TotemBar.lastOverwritten
+ local restore = nil
+ if prev and prev.element == element and prev.at == TotemBar.activeTotems[element].start then
+ restore = prev.rec
+ end
+ TotemBar.activeTotems[element] = restore
+ TotemBar.lastOverwritten = nil
+ return element
+end
+
+-- Last spell cast attempt the client made through CastSpellByName/CastSpell --
+-- ANY spell, not only totems (element/name are nil for a non-totem attempt).
+-- Written by both hooks below, unconditionally, on every call. This is what
+-- makes attributeCastFailure's attribution exact instead of a guess: it
+-- always names the ONE totem that was actually last attempted, and a later,
+-- different spell attempt overwrites it -- so a failure belonging to that
+-- later spell can never be blamed on the totem that came before it.
+TotemBar.lastCastAttempt = nil
+
+local function noteCastAttempt(element, totemName)
+ TotemBar.lastCastAttempt = { element = element, name = totemName, at = GetTime() }
+end
+
+-- Pure: turns a flat, HOLE-FREE array of strings (built with table.insert,
+-- see buildCastFailureMessages below -- a literal array with a nil in the
+-- MIDDLE is undefined for table.getn in Lua 5.0 and would silently drop
+-- every entry after it) into a lookup set. Used to build the
+-- UI_ERROR_MESSAGE allowlist from live client globals, and independently
+-- testable offline with a hand-built array.
+function TotemBar.messageSet(list)
+ local set = {}
+ if not list then
+ return set
+ end
+ for i = 1, table.getn(list) do
+ if list[i] then
+ set[list[i]] = true
+ end
+ end
+ return set
+end
+
+-- Pure: should a global failure event revoke lastAttempt's totem timer?
+-- Returns the element and totem name to revoke (or nil, nil), plus a third
+-- value that is true when the attributed attempt was a Totemic Recall (see
+-- the recall attempts note on castRecallNoQueue below) -- the caller then
+-- restores the recall-wiped tracking instead of revoking a single element.
+--
+-- lastAttempt the last thing this addon's hooks saw the client attempt
+-- (ANY spell -- see noteCastAttempt above), or nil.
+-- now, window lastAttempt.at must be within `window` seconds of `now`.
+-- message the UI_ERROR_MESSAGE text, or nil for SPELLCAST_FAILED/
+-- SPELLCAST_INTERRUPTED (neither carries one in 1.12).
+-- allowlist set of message strings that mean "a spell cast was
+-- refused" (see messageSet/buildCastFailureMessages).
+-- Checked ONLY when a message was given -- SPELLCAST_FAILED/
+-- SPELLCAST_INTERRUPTED are already scoped to the player's
+-- own current cast by the client, so they need no text
+-- filter; UI_ERROR_MESSAGE does, since it also fires for
+-- loot/trade/chat/etc. A message that isn't recognised (or a
+-- missing/empty allowlist) fails CLOSED -- no revoke -- same
+-- policy as an unattributable failure above.
+function TotemBar.attributeCastFailure(lastAttempt, now, window, message, allowlist)
+ if not lastAttempt or not (lastAttempt.element or lastAttempt.recall) then
+ return nil, nil, nil
+ end
+ if not lastAttempt.at or not now or not window or (now - lastAttempt.at) > window then
+ return nil, nil, nil
+ end
+ if message ~= nil and (not allowlist or not allowlist[message]) then
+ return nil, nil, nil
+ end
+ return lastAttempt.element, lastAttempt.name, lastAttempt.recall
+end
+
+-- ===== Universal cast hooks: catch totem casts from ANY path =====
+-- Defense-in-depth for totems cast WITHOUT going through any TotemBar
+-- function at all -- e.g. a hand-written macro's own `/cast Searing Totem`,
+-- or another addon. Every path TotemBar itself exposes already calls
+-- recordCast() (see recordCastFromHook's comment above) so this is a
+-- safety net, not the primary fix for a "no countdown" report.
+--
+-- Fixed arity on purpose (project rule: no vararg closures) -- both globals
+-- have a stable, known 1.12 signature: CastSpellByName(name, onSelf),
+-- CastSpell(spellId, bookType).
+--
+-- Plain global reassignment, not hooksecurefunc -- pfUI provides
+-- hooksecurefunc as its own polyfill (compat/vanilla.lua; native 1.12 has
+-- no such function), and TotemBar must not hard-depend on pfUI being
+-- loaded. Saving whatever function is CURRENTLY bound to the global and
+-- calling it first (before doing our own extra work) composes correctly
+-- with pfUI's own libtotem hook on CastSpellByName/CastSpell regardless of
+-- addon load order: whichever wraps last just adds a layer on top of
+-- whatever was already there.
+--
+-- UseAction is intentionally NOT hooked. 1.12 has no GetActionInfo /
+-- GetActionSpellId to read a spell off an action-bar slot directly; pfUI's
+-- own UseAction coverage (libtotem.lua) only works by scanning a hidden
+-- GameTooltip:SetAction(slot) via its private libtipscan library, wired up
+-- through hooksecurefunc -- both the tooltip-scan trick and hooksecurefunc
+-- are pfUI-internal, not something TotemBar can reimplement without either
+-- a hard pfUI dependency or a whole new action-slot tooltip scanner (this
+-- file already has a spellbook-index tooltip scanner for mana costs, see
+-- core/manacost.lua, but nothing that scans by action slot). Given every
+-- TotemBar-native path already records, and pfUI's GetTotemInfo already
+-- covers action-bar-dragged totem casts as the resolveRemaining/
+-- resolveDuration fallback source when pfUI is present, this gap was
+-- judged not worth the added dependency/complexity.
+-- Both hooks measure the cast gate BEFORE calling through (see
+-- measureCastBlock: afterwards the mana is spent and the GCD is running, so the
+-- reading would accuse every successful cast). This placement also covers the
+-- paths that record for themselves -- ui.lua's click handlers, castNext/castAll,
+-- bind.lua -- because they all reach the client through these same globals, so
+-- their own recordCast call sees the verdict too.
+if type(CastSpellByName) == "function" then
+ local origCastSpellByName = CastSpellByName
+ CastSpellByName = function(name, onSelf)
+ local element, baseName = TotemBar.elementFromCastName(name)
+ noteCastAttempt(element, baseName)
+ if element then
+ TotemBar.measureCastBlock(element, baseName)
+ end
+ origCastSpellByName(name, onSelf)
+ if element then
+ TotemBar.recordCastFromHook(element, baseName)
+ end
+ end
+end
+
+if type(CastSpell) == "function" then
+ local origCastSpell = CastSpell
+ CastSpell = function(spellId, bookType)
+ local element, baseName = nil, nil
+ if type(GetSpellName) == "function" then
+ element, baseName = TotemBar.elementFromCastName(GetSpellName(spellId, bookType))
+ end
+ noteCastAttempt(element, baseName)
+ if element then
+ TotemBar.measureCastBlock(element, baseName)
+ end
+ origCastSpell(spellId, bookType)
+ if element then
+ TotemBar.recordCastFromHook(element, baseName)
+ end
+ end
+end
+
+-- nampower's per-cast failure event (arg1 = spell id, arg2 = result, arg3 = 1
+-- when the SERVER rejected it). It only fires with NP_EnableSpellFailedEvents
+-- on, and nampower silently RETRIES some results (NP_RetryServerRejectedSpells,
+-- default on: NOT_READY / ITEM_NOT_READY / SPELL_IN_PROGRESS), so those may
+-- never arrive at all -- which is correct for us, since a retried cast that
+-- lands really did place the totem.
+--
+-- Registering it costs nothing when nampower is absent (the event simply never
+-- fires). The CVar is NOT set here: flipping a client-wide nampower switch is a
+-- side effect on every other addon, and this is a safety net, not the primary
+-- fix.
+--
+-- UI_ERROR_MESSAGE / SPELLCAST_FAILED / SPELLCAST_INTERRUPTED cover the same
+-- ground for players WITHOUT nampower (see the header comment above for why
+-- these were declined before, and why attributeCastFailure now makes them
+-- safe: exact last-attempted-spell match, a narrow window, and -- for
+-- UI_ERROR_MESSAGE specifically -- a message-text allowlist).
+--
+-- Guarded on CreateFrame so the file still loads under plain Lua for the tests.
+if CreateFrame then
+ local failFrame = CreateFrame("Frame", "TotemBarCastFailFrame", UIParent)
+ failFrame:RegisterEvent("SPELL_FAILED_SELF")
+ failFrame:RegisterEvent("UI_ERROR_MESSAGE")
+ failFrame:RegisterEvent("SPELLCAST_FAILED")
+ failFrame:RegisterEvent("SPELLCAST_INTERRUPTED")
+
+ -- Built lazily from the client's own global error strings on first use
+ -- (see attributeCastFailure's `allowlist` doc above) -- self-localizes,
+ -- and never goes stale against a client string update. Cached: this can
+ -- fire several times a fight.
+ local castFailureMessages = nil
+ local function buildCastFailureMessages()
+ -- Built with table.insert, not a literal array, so a global that
+ -- doesn't exist on this client build never leaves a HOLE in the
+ -- middle of the list -- table.getn (which messageSet uses) is
+ -- undefined over a table with holes in Lua 5.0 and would silently
+ -- drop every entry after the first missing one.
+ local raw = {}
+ local function add(v) if v then table.insert(raw, v) end end
+ add(ERR_OUT_OF_MANA)
+ add(ERR_SPELL_COOLDOWN)
+ add(ERR_OUT_OF_RANGE)
+ add(ERR_SPELL_OUT_OF_RANGE)
+ add(SPELL_FAILED_MOVING)
+ add(SPELL_FAILED_NOT_READY)
+ add(SPELL_FAILED_ITEM_NOT_READY)
+ add(SPELL_FAILED_SPELL_IN_PROGRESS)
+ add(SPELL_FAILED_STUNNED)
+ add(SPELL_FAILED_SILENCED)
+ add(SPELL_FAILED_PACIFIED)
+ add(SPELL_FAILED_CONFUSED)
+ add(SPELL_FAILED_CASTER_DEAD)
+ add(SPELL_FAILED_CASTER_AURASTATE)
+ add(SPELL_FAILED_FLEEING)
+ add(SPELL_FAILED_AFFECTING_COMBAT)
+ return TotemBar.messageSet(raw)
+ end
+
+ failFrame:SetScript("OnEvent", function()
+ if event == "SPELL_FAILED_SELF" then
+ -- Resolve the id to a name. SuperWoW's SpellInfo reads the client
+ -- DBC; nampower ships its own lookup (and is what fired this
+ -- event, so one of the two is normally there).
+ local name = nil
+ local id = arg1
+ if id then
+ if type(SpellInfo) == "function" then
+ name = SpellInfo(id)
+ elseif type(GetSpellNameAndRankForId) == "function" then
+ name = GetSpellNameAndRankForId(id)
+ end
+ if name then
+ name = TotemBar.stripRankSuffix(name)
+ end
+ end
+ -- No name, no revoke. An unnamed failure could just as well be
+ -- the Lightning Bolt the player pressed a moment after the totem.
+ if not name then
+ return
+ end
+ -- A failed Totemic Recall undoes the recall's tracking WIPE, not a
+ -- single element's timer (see restoreRecalledTotems below; the
+ -- window check lives there, same role revokeRecentCast's own
+ -- record-age check plays for the element casts).
+ if name == RECALL_SPELL_NAME then
+ TotemBar.restoreRecalledTotems()
+ TotemBar.lastCastAttempt = nil
+ return
+ end
+ if not TotemBar.elementOf(name) then
+ return
+ end
+ TotemBar.revokeRecentCast(name)
+ TotemBar.lastCastAttempt = nil
+ return
+ end
+
+ if event == "UI_ERROR_MESSAGE" or event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then
+ local message = nil
+ if event == "UI_ERROR_MESSAGE" then
+ message = arg1
+ if not castFailureMessages then
+ castFailureMessages = buildCastFailureMessages()
+ end
+ end
+ local element, name, recall = TotemBar.attributeCastFailure(TotemBar.lastCastAttempt,
+ GetTime(), TotemBar.CAST_FAIL_WINDOW, message, castFailureMessages)
+ if recall then
+ -- The attributed attempt was a Totemic Recall: undo the
+ -- recall's tracking wipe instead of revoking one element.
+ TotemBar.restoreRecalledTotems()
+ TotemBar.lastCastAttempt = nil
+ elseif element then
+ TotemBar.revokeRecentCast(name)
+ -- Consume: a second event for the SAME refusal (e.g.
+ -- SPELLCAST_FAILED right after UI_ERROR_MESSAGE) must not
+ -- attribute again, and a later, unrelated failure must not
+ -- reuse this now-stale attempt either.
+ TotemBar.lastCastAttempt = nil
+ end
+ end
+ end)
+end
+
+-- ===== GUID latch + destruction signals (destroyed-totem detection) =====
+-- Wires the two latch paths, the CHAT_MSG_COMBAT_FRIENDLY_DEATH primary
+-- signal, and the UNIT_DIED fast-path (GUID match + GUID-free fallback)
+-- documented in "Destroyed-totem detection (GUID liveness)" /
+-- "Rank-tolerant name match + destruction signals" above onto
+-- TotemBar.activeTotems. Every path fails silently (no latch, no evict) on
+-- a client without SuperWoW/nampower -- this block adds nothing to that
+-- baseline beyond CHAT_MSG_COMBAT_FRIENDLY_DEATH, which is native 1.12 and
+-- needs neither. The liveness POLL itself (which also clears a record) lives
+-- in ui.lua's UpdateTimerDisplays, gated on a record having a rec.guid.
+if CreateFrame then
+ local guidFrame = CreateFrame("Frame", "TotemBarGuidFrame", UIParent)
+ -- UNIT_MODEL_CHANGED and CHAT_MSG_COMBAT_FRIENDLY_DEATH are native 1.12
+ -- -- safe to register directly, no pcall needed.
+ guidFrame:RegisterEvent("UNIT_MODEL_CHANGED")
+ guidFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH")
+ -- UNIT_CASTEVENT (SuperWoW) and UNIT_DIED (nampower) may not exist.
+ -- pcall-guarded exactly like core/pulsecal.lua's own UNIT_CASTEVENT
+ -- registration, so an unknown event name can never break this frame.
+ pcall(function() guidFrame:RegisterEvent("UNIT_CASTEVENT") end)
+ pcall(function() guidFrame:RegisterEvent("UNIT_DIED") end)
+
+ guidFrame:SetScript("OnEvent", function()
+ if event == "UNIT_CASTEVENT" then
+ -- SuperWoW: casterGUID, targetGUID, type, spellId, castTime.
+ -- 2026-08-20 event tap: the pulse SPELL's name is NOT the
+ -- totem's own name (Searing Totem's pulse is "Searing Bolt",
+ -- Healing Stream Totem's is "Healing Stream", ...) -- mapping
+ -- through SpellInfo(arg4) could never match. Keyed off
+ -- UnitName(arg1) instead (arg1 is the TOTEM's own casterGUID);
+ -- SpellInfo/arg4 are no longer needed for this latch at all.
+ if not arg1 or type(UnitName) ~= "function" then
+ return
+ end
+ local unitName = UnitName(arg1)
+ if not unitName then
+ return
+ end
+ -- Owner check (adversarial-review finding #2): totem NAME alone
+ -- is not unique -- another shaman's same-type totem pulsing
+ -- nearby would otherwise latch onto OUR element. See
+ -- TotemBar.shouldLatchGuidFromCastEvent's own comment.
+ local ownerName = UnitName(arg1 .. "owner")
+ local playerName = UnitName("player")
+ for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
+ local element = TotemBar.TOTEM_ELEMENTS[i]
+ local rec = TotemBar.activeTotems[element]
+ if TotemBar.shouldLatchGuidFromCastEvent(rec, unitName, ownerName, playerName) then
+ rec.guid = arg1
+ return
+ end
+ end
+ return
+ end
+
+ if event == "UNIT_MODEL_CHANGED" then
+ local unit = arg1
+ if not unit or type(UnitName) ~= "function" then
+ return
+ end
+ local unitName = UnitName(unit)
+ if not unitName then
+ return
+ end
+ -- 2026-08-20 event tap: UnitName(unit) here is RANKED ("Searing
+ -- Totem VI"), never the bare book name recordCast stores -- so
+ -- this can no longer go through TotemBar.elementOf (an exact
+ -- static-map lookup); it scans activeTotems directly via
+ -- shouldLatchGuidFromModelChanged's rank-tolerant match instead.
+ local ownerName = UnitName(unit .. "owner")
+ local playerName = UnitName("player")
+ for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
+ local element = TotemBar.TOTEM_ELEMENTS[i]
+ local rec = TotemBar.activeTotems[element]
+ if TotemBar.shouldLatchGuidFromModelChanged(rec, unitName, ownerName, playerName) then
+ local exists, guid
+ if type(UnitExists) == "function" then
+ exists, guid = UnitExists(unit)
+ end
+ if exists and guid then
+ rec.guid = guid
+ end
+ return
+ end
+ end
+ return
+ end
+
+ -- Primary destruction signal (2026-08-20 event tap): TurtleWoW
+ -- emits a totem's own death as CHAT_MSG_COMBAT_FRIENDLY_DEATH with
+ -- arg1 literally " () is destroyed." -- GUID-free and
+ -- PvP-safe (needs neither latch path above to have ever fired at
+ -- all). The generic vanilla " dies." form is accepted as a
+ -- second, stricter form: since it carries no owner, it only evicts
+ -- a candidate that already has a latched GUID, and only after
+ -- verifying THAT GUID via TotemBar.totemDestroyed (the same
+ -- UnitExists/UnitHealth/UnitIsDeadOrGhost verdict the liveness poll
+ -- uses) -- a confirmation query, not blind trust in the chat line.
+ if event == "CHAT_MSG_COMBAT_FRIENDLY_DEATH" then
+ local line = arg1
+ if not line or type(UnitName) ~= "function" then
+ return
+ end
+ local playerName = UnitName("player")
+ local name, owner = TotemBar.parseDestroyedLine(line)
+ if name and owner then
+ local element = TotemBar.elementForOwnedTotemName(TotemBar.activeTotems,
+ TotemBar.TOTEM_ELEMENTS, name, owner, playerName)
+ if element then
+ TotemBar.evictDestroyedTotem(element)
+ return
+ end
+ -- Recall-attribution race (see "Recall-attribution vs.
+ -- destruction race" above): activeTotems has nothing for
+ -- this element because a recall wipe already cleared it and
+ -- is still awaiting attribution -- re-run the same owner/
+ -- name match against that pending snapshot so a later
+ -- restoreRecalledTotems doesn't bring the dead totem back.
+ local snapshotRecs = TotemBar.pendingRecallSnapshotRecs()
+ if snapshotRecs then
+ local snapElement = TotemBar.elementForOwnedTotemName(snapshotRecs,
+ TotemBar.TOTEM_ELEMENTS, name, owner, playerName)
+ if snapElement then
+ TotemBar.evictFromRecallSnapshot(snapElement)
+ end
+ end
+ return
+ end
+ local diesName = TotemBar.parseDiesLine(line)
+ if diesName then
+ local element = TotemBar.elementForDiesLineCandidate(TotemBar.activeTotems,
+ TotemBar.TOTEM_ELEMENTS, diesName)
+ if element then
+ if TotemBar.verifyDiesLineDestruction(TotemBar.activeTotems[element]) then
+ TotemBar.evictDestroyedTotem(element)
+ end
+ return
+ end
+ -- Same recall-attribution race as the owned-name branch
+ -- above, for the generic " dies." fallback.
+ local snapshotRecs = TotemBar.pendingRecallSnapshotRecs()
+ if snapshotRecs then
+ local snapElement = TotemBar.elementForDiesLineCandidate(snapshotRecs,
+ TotemBar.TOTEM_ELEMENTS, diesName)
+ if snapElement and TotemBar.verifyDiesLineDestruction(snapshotRecs[snapElement]) then
+ TotemBar.evictFromRecallSnapshot(snapElement)
+ 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)
+ local unitName, ownerName, playerName = nil, nil, nil
+ if not element and arg1 and type(UnitName) == "function" then
+ -- GUID-free fallback (2026-08-20 event tap: UnitName/owner
+ -- DO resolve at the exact death instant on this client, for
+ -- both the totem's own name and its "owner" token --
+ -- see the CHAT_MSG_COMBAT_FRIENDLY_DEATH/UNIT_DIED fixture
+ -- pair) -- covers a totem whose GUID was never latched by
+ -- either latch path at all.
+ unitName = UnitName(arg1)
+ ownerName = UnitName(arg1 .. "owner")
+ 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)
+ return
+ end
+ -- Recall-attribution race (see "Recall-attribution vs.
+ -- destruction race" above): re-run both matches against the
+ -- pending recall snapshot, reusing whatever UnitName lookups
+ -- already ran above instead of re-querying the (by-now-gone)
+ -- unit a second time.
+ local snapshotRecs = TotemBar.pendingRecallSnapshotRecs()
+ if snapshotRecs then
+ local snapElement = TotemBar.elementForGuid(snapshotRecs,
+ TotemBar.TOTEM_ELEMENTS, arg1, TotemBar.guidsEqual)
+ if not snapElement and arg1 and type(UnitName) == "function" then
+ if not unitName then
+ unitName = UnitName(arg1)
+ ownerName = UnitName(arg1 .. "owner")
+ playerName = UnitName("player")
+ end
+ snapElement = TotemBar.elementForOwnedTotemName(snapshotRecs,
+ TotemBar.TOTEM_ELEMENTS, unitName, ownerName, playerName)
+ end
+ if snapElement then
+ TotemBar.evictFromRecallSnapshot(snapElement)
+ end
+ end
+ return
+ end
+ end)
+end
+
+-- Module-scratch table for the buff-texture scan below, reused every
+-- call (hasBuffWithIcon runs ~5x/sec, from ui.lua's throttled timer
+-- tick) so it doesn't allocate a new table each time. buffScratchLen
+-- tracks how far the previous scan filled it, so leftover entries past
+-- the new scan's length get nilled out - keeping it a clean, hole-free
+-- 1..n array (table.getn needs that to be reliable in Lua 5.0).
+local buffScratch = {}
+local buffScratchLen = 0
+
+-- Pure: the final path component of a texture path, lowercased -- i.e. the
+-- bare icon name. Keeps the case/prefix tolerance the comparison below needs
+-- (GetSpellTexture and UnitBuff don't have to return identically-spelled
+-- paths) without the false positives of a substring search.
+local function textureTail(path)
+ local s = string.lower(path)
+ local _, sep = string.find(s, ".*[\\/]")
+ if sep then
+ return string.sub(s, sep + 1)
+ end
+ return s
+end
+
+-- Pure: given a flat array of buff texture path strings (some entries
+-- may be nil) and a totem spell's icon texture path, returns true if
+-- any buff's ICON NAME equals iconPath's, case-insensitively and
+-- independent of the leading path. Returns false if iconPath or
+-- buffTexList is nil, or nothing matches.
+--
+-- Compares the trailing path component for EQUALITY, not containment: a
+-- substring search let any icon name that merely starts with the totem's
+-- count as a match, and 1.12 ships exactly such a pair -- Windwall Totem is
+-- Spell_Nature_EarthBind, Strength of Earth Totem is
+-- Spell_Nature_EarthBindTotem. Carrying the Strength of Earth buff therefore
+-- made Windwall look permanently in range, so its slot never turned red.
+function TotemBar.buffTexturesMatch(buffTexList, iconPath)
+ if not iconPath or not buffTexList then
+ return false
+ end
+ local needle = textureTail(iconPath)
+ for i = 1, table.getn(buffTexList) do
+ local tex = buffTexList[i]
+ if tex and textureTail(tex) == needle then
+ return true
+ end
+ end
+ return false
+end
+
+-- Thin WoW-API wrapper: scans the player's current buffs (UnitBuff
+-- "player" 1..32, stopping at the first nil slot) into the reusable
+-- buffScratch table, then hands it to the pure
+-- TotemBar.buffTexturesMatch() above. This is the "am I in this totem's
+-- range?" signal for ui.lua's red-tint feature: a totem's party buff
+-- shares its spell's icon texture (verified in-game), so having a
+-- matching buff means the totem is currently affecting the player.
+function TotemBar.hasBuffWithIcon(iconPath)
+ if not iconPath then
+ return false
+ end
+ local n = 0
+ for i = 1, 32 do
+ local tex = UnitBuff("player", i)
+ if not tex then
+ break
+ end
+ n = n + 1
+ buffScratch[n] = tex
+ end
+ for i = n + 1, buffScratchLen do
+ buffScratch[i] = nil
+ end
+ buffScratchLen = n
+ return TotemBar.buffTexturesMatch(buffScratch, iconPath)
+end
+
+-- Clears every own-tracked totem timer at once (e.g. after Totemic
+-- Recall, which drops all active totems simultaneously).
+function TotemBar.clearActiveTotems()
+ for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
+ TotemBar.activeTotems[TotemBar.TOTEM_ELEMENTS[i]] = nil
+ end
+end
+
+-- ===== Undoing a recall wipe a failure event proves wrong =====
+--
+-- The recall counterpart of revokeRecentCast above, closing the same
+-- phantom-state gap from the other direction: the element casts' bug was a
+-- timer for a totem that is NOT standing; a refused Totemic Recall's bug was
+-- NO timers for totems that ARE still standing. recallReady() is only the
+-- pre-check -- a press that passes it can still be refused server-side
+-- (movement, stun, silence, latency). Every recall path therefore wipes
+-- through recallWipeActiveTotems below, which keeps a snapshot of what it
+-- destroyed; when TotemBarCastFailFrame attributes a failure event to the
+-- recall (via lastCastAttempt.recall -- set in castRecallNoQueue, the one
+-- choke point every recall cast goes through), restoreRecalledTotems puts the
+-- snapshot back.
+
+-- Snapshot of the records the most recent recall wiped: { at =, recs =
+-- element -> record }. Only meaningful within CAST_FAIL_WINDOW of `at`.
+TotemBar.lastRecallWipe = nil
+
+-- Wipes the tracking for a recall cast that just went out, remembering what
+-- was destroyed so a failure event can restore it (see above). All recall
+-- call sites use this instead of a bare clearActiveTotems().
+function TotemBar.recallWipeActiveTotems()
+ local recs = {}
+ for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
+ local element = TotemBar.TOTEM_ELEMENTS[i]
+ recs[element] = TotemBar.activeTotems[element]
+ end
+ TotemBar.lastRecallWipe = { at = GetTime(), recs = recs }
+ TotemBar.clearActiveTotems()
+end
+
+-- Restores the records the last recall wiped, because a failure event proved
+-- the recall never went through. Mirrors revokeRecentCast's policies: the
+-- snapshot must be recent (same CAST_FAIL_WINDOW -- an old snapshot means the
+-- failure is somebody else's), it is consumed on use (a second event for the
+-- same refusal must not restore again), and a slot a NEW cast has already
+-- refilled is left alone -- that record is younger truth than the snapshot.
+function TotemBar.restoreRecalledTotems()
+ local wiped = TotemBar.lastRecallWipe
+ TotemBar.lastRecallWipe = nil
+ if not wiped or not wiped.at or not wiped.recs then
+ return false
+ end
+ if (GetTime() - wiped.at) > TotemBar.CAST_FAIL_WINDOW then
+ return false
+ end
+ local restored = false
+ for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
+ local element = TotemBar.TOTEM_ELEMENTS[i]
+ if wiped.recs[element] and not TotemBar.activeTotems[element] then
+ TotemBar.activeTotems[element] = wiped.recs[element]
+ restored = true
+ end
+ end
+ return restored
+end
+
+-- ===== Recall-attribution vs. destruction race =====
+--
+-- recallWipeActiveTotems above clears TotemBar.activeTotems immediately and
+-- keeps the pre-wipe records in TotemBar.lastRecallWipe.recs until the recall
+-- is attributed. If a totem that was JUST wiped dies server-side inside that
+-- same CAST_FAIL_WINDOW, the destruction handlers below (CHAT_MSG_COMBAT_
+-- FRIENDLY_DEATH / UNIT_DIED) look it up in TotemBar.activeTotems -- which is
+-- already empty for it -- so the eviction is a silent no-op and sets no
+-- tombstone. If the recall is THEN attributed as refused, restoreRecalledTotems
+-- puts the snapshot record back and the dead totem's countdown resumes. These
+-- two helpers let the destruction handlers reach into the still-pending
+-- snapshot with the exact same name/guid matches they already run against
+-- activeTotems, so a totem that died mid-attribution stays dead either way.
+
+-- The pending recall snapshot's records table, or nil if there is no recall
+-- wipe awaiting attribution right now (none at all, or it is already older
+-- than CAST_FAIL_WINDOW -- same staleness rule restoreRecalledTotems uses;
+-- an old snapshot means any subsequent event is unrelated to it).
+function TotemBar.pendingRecallSnapshotRecs()
+ local wiped = TotemBar.lastRecallWipe
+ if not wiped or not wiped.at or not wiped.recs then
+ return nil
+ end
+ if (GetTime() - wiped.at) > TotemBar.CAST_FAIL_WINDOW then
+ return nil
+ end
+ return wiped.recs
+end
+
+-- Evicts `element` from the pending recall snapshot (see above) instead of
+-- from TotemBar.activeTotems -- the wipe already cleared that table, so
+-- TotemBar.evictDestroyedTotem would no-op here. Removes the record from
+-- TotemBar.lastRecallWipe.recs so a later restoreRecalledTotems leaves this
+-- element alone, and tombstones it exactly like evictDestroyedTotem does, so
+-- pfUI's libtotem can't resurrect the display for it either.
+function TotemBar.evictFromRecallSnapshot(element)
+ local wiped = TotemBar.lastRecallWipe
+ if not wiped or not wiped.recs then
+ return
+ end
+ local rec = wiped.recs[element]
+ if not rec then
+ return
+ end
+ if rec.start and rec.duration then
+ TotemBar.destroyedTombstone[element] = rec.start + rec.duration
+ end
+ wiped.recs[element] = nil
+end
+
+-- Pure-ish (queries the live unit): runs the SAME "already exists, and either
+-- zero health or dead-or-ghost" verification the CHAT_MSG_COMBAT_FRIENDLY_
+-- DEATH generic " dies." fallback used inline before this was pulled
+-- out -- shared now so the recall-snapshot branch below runs the identical
+-- check instead of a second hand-copied one.
+function TotemBar.verifyDiesLineDestruction(rec)
+ if not rec then
+ return false
+ end
+ 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
+ return TotemBar.totemDestroyed(exists, health, deadOrGhost)
+end
+
+-- Is at least one totem currently out? Used to avoid wasting Totemic Recall's
+-- own 6-second cooldown on a no-op cast: recalling with nothing out still puts
+-- Recall on cooldown, so a fresh set placed right after can't be recalled for
+-- 6s. Permissive on purpose - returns true if EITHER our own cast-tracking OR
+-- GetTotemInfo (pfUI libtotem / SuperWoW, when present) reports a totem out, so
+-- a legitimate recall is never wrongly blocked; only when both agree nothing is
+-- out do we suppress the cast.
+function TotemBar.anyTotemOut()
+ if TotemBar.anyActiveTracked(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS,
+ GetTime(), TotemBar.remaining) then
+ return true
+ end
+ if GetTotemInfo then
+ for slot = 1, 4 do
+ if GetTotemInfo(slot) then
+ return true
+ end
+ end
+ end
+ return false
+end
+
+-- pure: does the addon currently hold ANY tracked totem record (regardless of expiry)?
+-- NOTE: this is NOT the recall gate's session evidence any more -- see
+-- confidentNoneOut below. Table occupancy cannot answer "did we cast something this
+-- session": ui.lua's 0.1s timer tick EVICTS each record the moment it expires, and
+-- clearActiveTotems() wipes the whole table after every recall.
+function TotemBar.hasTrackedTotems(activeTotems, elements)
+ if not activeTotems then return false end
+ for i = 1, table.getn(elements) do
+ if activeTotems[elements[i]] then return true end
+ end
+ return false
+end
+
+-- Should the manual-recall gate BLOCK the recall? Only when we are CONFIDENT that nothing
+-- is out: we have tracked at least one totem THIS session AND all tracked totems have
+-- expired. When tracking is empty (fresh load, or after a /reload -- activeTotems is
+-- in-memory only and reset on load) the state is UNKNOWN, not "none out": on 1.12 there is
+-- no GetTotemInfo / no "totemN" UnitID to re-detect a totem that was cast BEFORE the reload
+-- (confirmed in game 2026-07-14 -- no reload-proof detection exists for all totem types). So in
+-- the unknown case FAIL OPEN -- let the recall through rather than falsely reporting "no
+-- totems out" and blocking a legitimate recall of a still-standing totem (the bug this fixes:
+-- our reloads wiped the tracking while the totems stayed physically out). Worst case of
+-- fail-open is one needless recall right after a reload -- far cheaper than a blocked one.
+--
+-- The "cast something this session" half reads the STICKY castState.everCast flag set in
+-- recordCast, not the occupancy of activeTotems. Occupancy was the original evidence and it
+-- was wrong: ui.lua's 0.1s tick deletes a record the moment it expires and clearActiveTotems()
+-- empties the table after every recall, so the gate fell open again ~0.1s after the last totem
+-- ran out -- it only ever blocked while the bar was HIDDEN (hidden frame, no OnUpdate, no
+-- eviction), the exact inverse of the intent above. castState is in-memory like activeTotems,
+-- so a /reload still resets it to the fail-open state.
+function TotemBar.confidentNoneOut()
+ if not TotemBar.castState.everCast then
+ return false
+ end
+ return not TotemBar.anyTotemOut()
+end
+
+-- Casts exactly ONE totem per call: the next slot in Fire -> Earth ->
+-- Water -> Air order, skipping empty (unassigned) slots. If more than
+-- gapSeconds has passed since the previous call, the cycle restarts at
+-- the first filled slot (so a fresh spam always begins with totem 1).
+--
+-- Intended for a macro: `/script TotemBar.castNext()`
+function TotemBar.castNext()
+ local db = TotemBarDB
+ local chosen = (db and db.chosen) or {}
+ local gap = (db and db.gapSeconds) or TotemBar.DEFAULT_GAP_SECONDS
+ local elements = TotemBar.TOTEM_ELEMENTS
+ local numSlots = table.getn(elements)
+ local now = GetTime()
+
+ local state = TotemBar.castState
+ local startIdx = TotemBar.nextIndex(state.index, state.lastTime, now, gap, numSlots)
+ local slot = TotemBar.findFilledSlot(chosen, elements, startIdx)
+
+ if not slot then
+ -- Nothing assigned to any element; nothing to cast.
+ state.index = 0
+ state.lastTime = now
+ return nil, nil
+ end
+
+ local element = elements[slot]
+ local totemName = chosen[element]
+ CastSpellByName(totemName)
+ TotemBar.recordCast(element, totemName)
+ state.index = slot
+ state.lastTime = now
+ return totemName, element
+end
+
+-- Casts ALL filled slots in a single call (Fire -> Earth -> Water ->
+-- Air). On TurtleWoW each totem element has its own cooldown, so this
+-- MAY drop all four from one keypress. Whether 4 CastSpellByName calls
+-- in one Lua frame all land (vs only the last "winning") is unverified
+-- on this client -- offered as a one-press alternative to castNext() to
+-- test in-game.
+--
+-- Intended for a macro: `/script TotemBar.castAll()`
+function TotemBar.castAll()
+ local db = TotemBarDB
+ local chosen = (db and db.chosen) or {}
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ local element = elements[i]
+ local totemName = chosen[element]
+ if totemName then
+ CastSpellByName(totemName)
+ TotemBar.recordCast(element, totemName)
+ end
+ end
+end
+
+-- Pure: should recallAndCastAll fire Totemic Recall this call?
+-- autoRecall off -> false (never recall)
+-- never deployed (nil / <=0) -> true (nothing fresh to protect)
+-- last deploy > guard ago -> true
+-- last deploy within guard -> false (protect just-placed totems from a
+-- rapid accidental second press)
+function TotemBar.shouldRecall(autoRecall, lastDeployTime, now, guardSeconds)
+ if not autoRecall then
+ return false
+ end
+ if not lastDeployTime or lastDeployTime <= 0 then
+ return true
+ end
+ if not guardSeconds then
+ return true
+ end
+ if (now - lastDeployTime) > guardSeconds then
+ return true
+ end
+ return false
+end
+
+-- Casts Totemic Recall bypassing nampower's spell queue when that API is
+-- present. Root cause of the "place a set, it vanishes a moment later" bug:
+-- under nampower, a plain CastSpellByName of Totemic Recall (a GCD-tied
+-- instant) issued while a GCD is active is QUEUED, not cast, and pops at
+-- GCD-end -- by which time the totems are already down (TWoW gives each totem
+-- element its own non-GCD recovery category, so the 4 totems fire immediately
+-- with queue priority). The late Recall then sweeps the fresh set. Bypassing
+-- the queue means a GCD-blocked Recall simply fails that press instead of
+-- being deferred, so it can never fire late and tear the totems down. Falls
+-- back to a plain cast when nampower isn't installed (the function is nil).
+-- (NP_QueueInstantSpells default 1 is the deferral; CastSpellByNameNoQueue
+-- is nampower's queue-bypass cast.)
+local function castRecallNoQueue()
+ if type(CastSpellByNameNoQueue) == "function" then
+ CastSpellByNameNoQueue(RECALL_SPELL_NAME)
+ else
+ CastSpellByName(RECALL_SPELL_NAME)
+ end
+ -- Note the recall as the last cast attempt, so TotemBarCastFailFrame can
+ -- attribute a failure event to it and undo the tracking wipe (see
+ -- restoreRecalledTotems above). Set AFTER the cast call on purpose: the
+ -- plain-cast fallback goes through this addon's own CastSpellByName hook,
+ -- which records a non-totem attempt for it -- this overwrite corrects
+ -- that to the recall (the NoQueue path is not hooked and needs it too).
+ -- Deliberately shaped like noteCastAttempt's records, plus the `recall`
+ -- flag attributeCastFailure and the fail handler key off; element is nil
+ -- because a recall names no single element -- it wipes all four.
+ TotemBar.lastCastAttempt = {
+ element = nil,
+ name = RECALL_SPELL_NAME,
+ recall = true,
+ at = GetTime(),
+ }
+end
+
+-- pure: does GetSpellCooldown's (start, duration) pair describe a cooldown
+-- that is RUNNING right now? 1.12 reports a live cooldown as start > 0 AND
+-- duration > 0. It reports the GLOBAL cooldown here too for a GCD-tied spell
+-- like Totemic Recall, which is exactly what we want: castRecallNoQueue above
+-- deliberately does not queue, so a GCD-blocked press simply fails -- treating
+-- it as "cannot cast" keeps this check honest about what the client will do.
+--
+-- Missing data (nil) means "no cooldown information", never "on cooldown" --
+-- FAIL OPEN, so an unreadable cooldown can never block a legitimate recall
+-- (same policy as confidentNoneOut above).
+function TotemBar.cooldownActive(start, duration)
+ if not start or not duration then
+ return false
+ end
+ return start > 0 and duration > 0
+end
+
+-- Can Totemic Recall actually be cast right now? Reads the live cooldown via
+-- the shared spellbook index cache (core/spellindex.lua). Unknowns fail OPEN --
+-- no GetSpellCooldown, no index cache at all -- so an unreadable cooldown can
+-- never block a legitimate recall.
+--
+-- "Not in the book" is deliberately NOT an unknown: it is only unknown while
+-- the scan itself is unusable. A nil index against a NON-EMPTY cached scan is
+-- KNOWN state -- the spell cannot be cast, so a press cannot have gone out --
+-- and failing open there was the same evidence-destroying bug this function
+-- exists to prevent: a shaman below level 30 has not learned Totemic Recall
+-- (learned at 30), so every press returned "cast", ran clearActiveTotems() on a
+-- still-standing set and left confidentNoneOut() saying "confidently nothing
+-- out" -- the fail-CLOSED state that gate forbids. Same non-empty-scan idiom as
+-- recordCast above: the book is not reliably populated at login and the cache
+-- is built lazily, so an EMPTY scan means "no data", not "not known".
+function TotemBar.recallReady()
+ if type(GetSpellCooldown) ~= "function" or not TotemBar.findSpellIndex then
+ return true
+ end
+ local idx = TotemBar.findSpellIndex(RECALL_SPELL_NAME)
+ if not idx then
+ return not (TotemBar.spellbookEntryCount and TotemBar.spellbookEntryCount() > 0)
+ end
+ return not TotemBar.cooldownActive(GetSpellCooldown(idx, BOOKTYPE_SPELL))
+end
+
+-- pure: what should a MANUAL Totemic Recall press do?
+-- "none-out" the gate is confident nothing is out -> skip the cast, saving
+-- Totemic Recall's 6s cooldown.
+-- "cooldown" Recall is not castable right now (its own 6s cooldown, a GCD,
+-- or never learned -- see recallReady) -> skip the cast AND keep
+-- the own-tracking. A recall that cannot have gone out must never
+-- destroy the evidence that the totems are still standing: the
+-- tracking is one half of confidentNoneOut's gate while
+-- castState.everCast (the other half) is sticky for the whole
+-- session, so wiping it on a refused press left the gate saying
+-- "confidently nothing out" with a full set on the ground --
+-- fail-CLOSED, the one outcome confidentNoneOut forbids.
+-- "cast" cast it.
+--
+-- The override exists for the same reason: any state where a totem is out but
+-- UNTRACKED still trips the gate -- e.g. a totem dropped from the action bar
+-- on a client without pfUI's GetTotemInfo (1.12 cannot read an action slot's
+-- spell, see the UseAction note above). So the gate is never a dead end: a
+-- DELIBERATE re-press after a blocked one lets the recall through. The
+-- overrideMin/Max window separates that from an accidental double-click and
+-- expires the override again for the next, unrelated press.
+function TotemBar.manualRecallAction(noneOut, ready, lastBlockedAt, now, overrideMin, overrideMax)
+ if noneOut then
+ local override = false
+ if lastBlockedAt and overrideMin and overrideMax then
+ local since = now - lastBlockedAt
+ override = (since >= overrideMin) and (since <= overrideMax)
+ end
+ if not override then
+ return "none-out"
+ end
+ end
+ if not ready then
+ return "cooldown"
+ end
+ return "cast"
+end
+
+-- The ONE manual-recall implementation, shared by the Recall button's
+-- left-click (ui.lua) and the TOTEMBAR_RECALL keybind (bind.lua) so the two
+-- can never drift apart again. Returns the action taken ("none-out" /
+-- "cooldown" / "cast"); the callers own the chat feedback.
+--
+-- Casts through castRecallNoQueue exactly like the auto paths: a plain
+-- CastSpellByName here was queueable under nampower, so a manual press during
+-- a GCD could pop AFTER the next set was placed and sweep it away -- the very
+-- teardown that helper exists to prevent.
+function TotemBar.manualRecall()
+ local now = GetTime()
+ local noneOut = TotemBar.confidentNoneOut and TotemBar.confidentNoneOut()
+ local action = TotemBar.manualRecallAction(noneOut, TotemBar.recallReady(),
+ TotemBar.castState.recallBlockedAt, now,
+ TotemBar.RECALL_OVERRIDE_MIN, TotemBar.RECALL_OVERRIDE_MAX)
+
+ if action == "none-out" then
+ -- Remember the refusal so a deliberate re-press can override it.
+ TotemBar.castState.recallBlockedAt = now
+ return action
+ end
+ if action == "cooldown" then
+ -- Deliberately KEEPS recallBlockedAt: this press was refused by the
+ -- client, not by the gate, so an override the player already expressed
+ -- must survive a transient cooldown/GCD instead of costing them another
+ -- paired press once it clears.
+ return action
+ end
+
+ TotemBar.castState.recallBlockedAt = nil
+ castRecallNoQueue()
+ -- The refund learner's snapshot runs AFTER the cast but BEFORE the wipe:
+ -- it sums the cost of the totems still held in activeTotems.
+ if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end
+ -- Totemic Recall drops every active totem at once; clear own-tracking so
+ -- the icons' countdowns disappear too (GetTotemInfo, if present, will also
+ -- reflect this). Wiped through the snapshotting helper: recallReady() was
+ -- only the PRE-check, and a press it let through can still be refused
+ -- server-side -- the failure events then restore what this wiped (see
+ -- restoreRecalledTotems above).
+ TotemBar.recallWipeActiveTotems()
+ return action
+end
+
+-- Recall-then-deploy: when TotemBarDB.autoRecall is on (the default -
+-- toggleable via the Recall button's right-click, see ui.lua), casts
+-- Totemic Recall FIRST (drops existing totems and refunds some mana)
+-- and clears own-tracking, then always places all filled slots via
+-- castAll(). One keypress = recall + redeploy (or just redeploy, with
+-- the flag off). Like castAll, relies on TurtleWoW allowing several
+-- CastSpellByName calls in one Lua frame -- verify in-game.
+--
+-- Guarded against rapid double-presses: shouldRecall() only fires Recall
+-- if the last deploy was more than DEFAULT_RECALL_GUARD seconds ago. A
+-- fast accidental second press then just re-attempts placement (a no-op,
+-- since the totems are still up and each element is on its own ~1.5s
+-- cooldown) instead of recalling the totems that were just placed.
+--
+-- Also gated on recallReady(): a Recall the client cannot cast right now (its
+-- own 6s cooldown, or a GCD -- castRecallNoQueue never defers, so such a press
+-- just fails) must not run clearActiveTotems() either, or it wipes the timers
+-- of totems that are still standing.
+--
+-- Intended for a macro: `/script TotemBar.recallAndCastAll()`
+function TotemBar.recallAndCastAll()
+ local now = GetTime()
+ local autoRecall = TotemBarDB and TotemBarDB.autoRecall
+ local guard = (TotemBarDB and TotemBarDB.recallGuardSeconds) or TotemBar.DEFAULT_RECALL_GUARD
+ if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard)
+ and TotemBar.recallReady() and TotemBar.anyTotemOut() then
+ castRecallNoQueue()
+ -- Snapshotting wipe (see restoreRecalledTotems): in THIS path the
+ -- castAll below immediately overwrites lastCastAttempt with its totem
+ -- casts, so a recall refusal is rarely attributable here -- but the
+ -- snapshot costs nothing and any slot castAll refills is protected by
+ -- the restore's younger-record rule anyway.
+ TotemBar.recallWipeActiveTotems()
+ end
+ TotemBar.castAll()
+ TotemBar.castState.lastDeployTime = now
+end
+
+-- Key-down/key-up split for the DropSet keybind. Bound in Bindings.xml with
+-- runOnUp="true", so this runs on BOTH flanks with the global `keystate` set
+-- to "down" / "up" beforehand. Casting Totemic Recall on the DOWN stroke and
+-- placing the set on the RELEASE. The real fix for the teardown is
+-- castRecallNoQueue() (see above) -- bypassing nampower's queue so a
+-- GCD-blocked Recall never fires late and sweeps the fresh set. The down/up
+-- split adds belt-and-suspenders temporal separation: the Recall on the down
+-- stroke has definitively resolved-or-failed (it is never queued) by the time
+-- the release places the set. Both flanks are real hardware events, so both
+-- may cast (Blizzard's own ActionButtonUp casts on release too), unlike a
+-- timer-deferred placement which this client blocks as a non-hardware cast.
+--
+-- Still guarded by shouldRecall()'s 2s window (a rapid re-press within the
+-- guard skips the Recall so it can't pull the just-placed set). keystate is
+-- nil only if the binding ran without runOnUp (misconfig / older client);
+-- treat nil like "up" so a single fire at least still PLACES rather than
+-- silently doing nothing.
+function TotemBar.dropSetKey(keystate)
+ if keystate == "down" then
+ local now = GetTime()
+ local autoRecall = TotemBarDB and TotemBarDB.autoRecall
+ local guard = (TotemBarDB and TotemBarDB.recallGuardSeconds) or TotemBar.DEFAULT_RECALL_GUARD
+ if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard)
+ and TotemBar.recallReady() and TotemBar.anyTotemOut() then
+ castRecallNoQueue()
+ -- Snapshotting wipe (see restoreRecalledTotems): the down stroke
+ -- places nothing itself, so a refused recall here IS attributable
+ -- -- the failure event restores the timers before the release's
+ -- castAll runs.
+ TotemBar.recallWipeActiveTotems()
+ end
+ else
+ -- Release (or nil fallback): place the set now, in this hardware frame.
+ TotemBar.castAll()
+ TotemBar.castState.lastDeployTime = GetTime()
+ end
+end
+
+-- Dev aid (/tb tdump): dumps TotemBar's own per-element cast-tracking
+-- (TotemBar.activeTotems, the resolveRemaining/resolveDuration own-tracking
+-- fallback source, see this file's header comment) side by side with the
+-- RAW GetTotemInfo(1..4) (pfUI libtotem, when present -- the OTHER,
+-- normally-authoritative source), so a "countdown missing" report can be
+-- diagnosed off-client without an in-game screenshot: did TotemBar ever
+-- record this cast at all, and what does GTI say about the same slot right
+-- now? Also appends ui.lua's TotemBar.DumpRingRenderState() output (2026-07-
+-- 16, Searing-ring bug: countdown TEXT shows but no pulse RING) -- the pure
+-- ring math was already proven correct offline, so this section captures
+-- the WoW-side render state instead: cached ring flags plus the LIVE
+-- ringFill/ringTrack texture objects (shown/texture/alpha/texCoord/parent),
+-- see that function's own comment for the full field list. Written to
+-- \imports\tb_tdump.txt via SuperWoW's ExportFile (name WITHOUT
+-- the .txt extension -- ExportFile appends it itself); chat fallback when
+-- SuperWoW isn't present. GetTotemInfo is pcall-wrapped since it's
+-- third-party (pfUI) code this dump must never itself error on.
+function TotemBar.DumpTimerState()
+ local now = GetTime()
+ local els = TotemBar.TOTEM_ELEMENTS
+ local activeTotems = TotemBar.activeTotems
+ local out = "TotemBar timer-state dump (now=" .. tostring(now) .. ")\n"
+
+ out = out .. "\n-- own tracking (TotemBar.activeTotems) --\n"
+ for i = 1, table.getn(els) do
+ local element = els[i]
+ local rec = activeTotems[element]
+ if rec then
+ local rem = TotemBar.remaining(rec.start, rec.duration, now)
+ -- Raw destroyed-totem liveness reads (see "Destroyed-totem
+ -- detection (GUID liveness)" above) -- only meaningful once
+ -- rec.guid was latched; nil/nil/nil otherwise means "no GUID
+ -- yet", not "unit resolved as gone".
+ local existsRaw, healthRaw, deadRaw = nil, nil, nil
+ if rec.guid then
+ if type(UnitExists) == "function" then
+ existsRaw = UnitExists(rec.guid)
+ end
+ if type(UnitHealth) == "function" then
+ healthRaw = UnitHealth(rec.guid)
+ end
+ if type(UnitIsDeadOrGhost) == "function" then
+ deadRaw = UnitIsDeadOrGhost(rec.guid)
+ end
+ end
+ out = out .. element .. ": spell='" .. tostring(rec.totemName) .. "'"
+ .. " start=" .. tostring(rec.start)
+ .. " duration=" .. tostring(rec.duration)
+ .. " remaining=" .. tostring(rem)
+ .. " guid=" .. tostring(rec.guid)
+ .. " exists=" .. tostring(existsRaw)
+ .. " health=" .. tostring(healthRaw)
+ .. " deadOrGhost=" .. tostring(deadRaw) .. "\n"
+ else
+ -- Tombstone status (see TotemBar.destroyedTombstone/
+ -- tombstoneActive) is most relevant HERE -- a record just
+ -- evicted as destroyed shows up as "no record", and this line
+ -- is what confirms GTI is (correctly) being ignored for it
+ -- rather than resurrecting the countdown a moment later.
+ local tomb = TotemBar.destroyedTombstone[element]
+ local tombActive = TotemBar.tombstoneActive(tomb, now)
+ out = out .. element .. ": (no record) tombstone=" .. tostring(tomb)
+ .. " tombstoneActive=" .. tostring(tombActive) .. "\n"
+ end
+ end
+
+ out = out .. "\n-- raw GetTotemInfo(1..4) (pfUI libtotem, when present) --\n"
+ if type(GetTotemInfo) == "function" then
+ for slot = 1, 4 do
+ local ok, active, name, start, duration = pcall(GetTotemInfo, slot)
+ if ok then
+ out = out .. "slot " .. slot .. ": active=" .. tostring(active)
+ .. " name='" .. tostring(name) .. "'"
+ .. " start=" .. tostring(start)
+ .. " duration=" .. tostring(duration) .. "\n"
+ else
+ out = out .. "slot " .. slot .. ": pcall error: " .. tostring(active) .. "\n"
+ end
+ end
+ else
+ out = out .. "(GetTotemInfo not present -- no pfUI libtotem loaded)\n"
+ end
+
+ -- Render-state section (ui.lua): the pure timer/duration/ring math
+ -- above was already proven correct offline for the Searing-ring
+ -- report (text shows, no ring) -- this is the other half, the
+ -- WoW-side render state (cached ring flags + LIVE texture objects)
+ -- that this dump previously never captured. pcall-wrapped: ui.lua
+ -- loads after this file (see TotemBar.toc), but by the time a player
+ -- runs /tb tdump everything is loaded; the guard just keeps this dump
+ -- from erroring if that ever isn't true (e.g. a future load-order
+ -- change) or if TotemBar.DumpRingRenderState itself hits something
+ -- unexpected.
+ local okRender, renderOut = pcall(TotemBar.DumpRingRenderState)
+ if okRender and renderOut then
+ out = out .. renderOut
+ else
+ out = out .. "\n-- render state: unavailable (" .. tostring(renderOut) .. ") --\n"
+ end
+
+ if ExportFile then
+ ExportFile("tb_tdump", out)
+ end
+ if DEFAULT_CHAT_FRAME then
+ DEFAULT_CHAT_FRAME:AddMessage("TotemBar: tdump exported.")
+ end
+end
diff --git a/core/config.lua b/core/config.lua
new file mode 100644
index 0000000..1dd8f56
--- /dev/null
+++ b/core/config.lua
@@ -0,0 +1,84 @@
+-- TotemBar - core/config.lua
+-- SavedVariables model. TotemBarDB is declared in the .toc as a
+-- SavedVariable; ensureDefaults() fills in anything missing (first run,
+-- or a saved file from an older version that lacks newer fields).
+
+TotemBar = TotemBar or {}
+
+TotemBarDB = TotemBarDB or {}
+
+-- TotemBarDB shape:
+-- chosen[element] = totemName ("Fire" -> "Searing Totem", ...)
+-- gapSeconds = spam-cycle reset gap (seconds)
+-- locked = boolean, whether the bar can be dragged
+-- autoRecall = boolean, whether recallAndCastAll() prepends
+-- Totemic Recall (toggled via the Recall button's
+-- right-click, see ui.lua); default ON
+-- point/relPoint/x/y = saved frame anchor (see ui.lua)
+-- showDurationRing/ringStyle/showPulseBars/showPulseWaves/
+-- showTimerText = Pulse UI (see spec). showPulseBars = the countdown
+-- bar (primary "when's the next pulse" readout);
+-- showPulseWaves = the ripple (event feedback only);
+-- independently toggleable.
+-- barLayout = bar arrangement: "1x6"|"2x3"|"3x2" (rows x cols),
+-- cycled via the options panel (see ui.lua's
+-- ApplyBarLayout / options.lua's layout button).
+-- showDropSetButton = boolean, whether the "Drop all totems" (DropSet)
+-- button is shown on the bar; default ON, so existing
+-- users keep it. Visibility only: the button is always
+-- created, and its keybinding (BINDING_NAME_TOTEMBAR_
+-- DROPSET) plus the "Totems" macro keep working while
+-- it is hidden - same idea as the "Show bar" toggle.
+-- buttonGap = px gap between bar buttons, range 10-30, default
+-- TotemBar.DEFAULT_BUTTON_GAP (see core/cast.lua);
+-- live-applied via ui.lua's TotemBar.SetButtonGap,
+-- set from the options panel's "Button spacing" slider.
+function TotemBar.ensureDefaults()
+ TotemBarDB.chosen = TotemBarDB.chosen or {}
+ TotemBarDB.gapSeconds = TotemBarDB.gapSeconds or TotemBar.DEFAULT_GAP_SECONDS
+ if TotemBarDB.locked == nil then
+ TotemBarDB.locked = false
+ end
+ if TotemBarDB.autoRecall == nil then
+ TotemBarDB.autoRecall = true
+ end
+ TotemBarDB.point = TotemBarDB.point or "CENTER"
+ TotemBarDB.relPoint = TotemBarDB.relPoint or "CENTER"
+ TotemBarDB.x = TotemBarDB.x or 0
+ TotemBarDB.y = TotemBarDB.y or 0
+ TotemBarDB.scale = TotemBarDB.scale or 1.0
+ TotemBarDB.minimapAngle = TotemBarDB.minimapAngle or 225
+ if TotemBarDB.hidden == nil then
+ TotemBarDB.hidden = false
+ end
+ TotemBarDB.recallGuardSeconds = TotemBarDB.recallGuardSeconds or TotemBar.DEFAULT_RECALL_GUARD
+ TotemBarDB.recallRefundPct = TotemBarDB.recallRefundPct or 0.25
+ TotemBarDB.buttonGap = TotemBarDB.buttonGap or TotemBar.DEFAULT_BUTTON_GAP
+
+ -- Pulse UI:
+ -- duration ring + pulse bars, all on by default; ringStyle "round" vs
+ -- "square" is the in-game comparison toggle.
+ if TotemBarDB.showDurationRing == nil then
+ TotemBarDB.showDurationRing = true
+ end
+ TotemBarDB.ringStyle = TotemBarDB.ringStyle or "round"
+ if TotemBarDB.showPulseBars == nil then
+ TotemBarDB.showPulseBars = true
+ end
+ if TotemBarDB.showPulseWaves == nil then
+ TotemBarDB.showPulseWaves = true
+ end
+ -- dead setting removed in v0.2.2, clear persisted dust
+ TotemBarDB.pulseGlow = nil
+ if TotemBarDB.showTimerText == nil then
+ TotemBarDB.showTimerText = true
+ end
+
+ -- Bar contents: the DropSet button is optional, shown by default so an
+ -- upgrade never makes it disappear (visibility only, see the shape note).
+ if TotemBarDB.showDropSetButton == nil then
+ TotemBarDB.showDropSetButton = true
+ end
+
+ TotemBarDB.barLayout = TotemBarDB.barLayout or "1x6"
+end
diff --git a/core/known.lua b/core/known.lua
new file mode 100644
index 0000000..6062118
--- /dev/null
+++ b/core/known.lua
@@ -0,0 +1,51 @@
+-- TotemBar - core/known.lua
+-- knownTotems() is PURE (no WoW API) and offline-testable.
+-- The WoW-API spellbook scan it consumes (scanSpellbook) now lives in
+-- core/spellindex.lua, alongside the other spellbook-index accessors
+-- (findSpellIndex/findHighestRankSlot/highestKnownRank) -- kept minimal
+-- and separate so the pure filtering logic below never has to touch the
+-- real API to be tested.
+
+TotemBar = TotemBar or {}
+
+-- Pure: given a flat list of spell names (strings) known to the player
+-- and an element name ("Fire"/"Earth"/"Water"/"Air"), returns an array
+-- of the subset of those names that are totems belonging to that
+-- element, in the static map's order.
+--
+-- - Names not present in the static map (non-totem spells) are ignored.
+-- - Totems in the static map that aren't in spellNames (not known) are
+-- omitted from the result.
+-- - An unknown/unmapped element returns an empty array.
+function TotemBar.knownTotems(spellNames, element)
+ local result = {}
+ local candidates = TotemBar.TOTEMS_BY_ELEMENT[element]
+ if not candidates then
+ return result
+ end
+
+ local known = {}
+ if spellNames then
+ for i = 1, table.getn(spellNames) do
+ known[spellNames[i]] = true
+ end
+ end
+
+ local n = 0
+ for i = 1, table.getn(candidates) do
+ local name = candidates[i]
+ if known[name] then
+ n = n + 1
+ result[n] = name
+ end
+ end
+
+ return result
+end
+
+-- TotemBar.scanSpellbook() and TotemBar.highestKnownRank() used to be
+-- defined here as thin WoW-API wrappers, each doing its own fresh linear
+-- GetSpellName() scan. They're now cache-backed views provided by
+-- core/spellindex.lua (loaded earlier in the TOC) instead, so a totem
+-- drop no longer re-walks the spellbook once per caller. Same names,
+-- same signatures, same return values -- callers here are unchanged.
diff --git a/core/manacost.lua b/core/manacost.lua
new file mode 100644
index 0000000..06f8b7e
--- /dev/null
+++ b/core/manacost.lua
@@ -0,0 +1,326 @@
+-- TotemBar - core/manacost.lua
+-- Mana-cost reading + refund/duration helpers. The PURE functions here are
+-- offline-tested; the WoW-API pieces (hidden-tooltip scan, talent scan, refund
+-- learner) are appended in a later task and are guarded so this file loads
+-- fine under plain Lua for the tests.
+
+TotemBar = TotemBar or {}
+
+-- Pure: mana cost from a tooltip line like "155 Mana" -> 155, else nil.
+function TotemBar.parseManaCost(text)
+ if not text then
+ return nil
+ end
+ -- Anchor to the "Mana" keyword so a numeric line like "30 sec cooldown"
+ -- can't be mistaken for the cost (don't rely on tooltip line ordering).
+ local _, _, n = string.find(text, "^(%d+) [Mm]ana")
+ if n then
+ return tonumber(n)
+ end
+ return nil
+end
+
+-- Pure: sum costFn(chosen[element]) over the elements that have a chosen totem.
+function TotemBar.sumChosenCost(chosen, elements, costFn)
+ local total = 0
+ if not chosen then
+ return 0
+ end
+ for i = 1, table.getn(elements) do
+ local name = chosen[elements[i]]
+ if name then
+ local c = costFn(name)
+ if c then
+ total = total + c
+ end
+ end
+ end
+ return total
+end
+
+-- Pure: sum cost of active totems that are still out (remaining > 0).
+function TotemBar.sumActiveCost(activeTotems, elements, now, costFn, remainingFn)
+ local total = 0
+ if not activeTotems then
+ return 0
+ end
+ for i = 1, table.getn(elements) do
+ local rec = activeTotems[elements[i]]
+ if rec then
+ local rem = remainingFn(rec.start, rec.duration, now)
+ if rem and rem > 0 then
+ local c = costFn(rec.totemName)
+ if c then
+ total = total + c
+ end
+ end
+ end
+ end
+ return total
+end
+
+-- Pure: is a KNOWN mana shortfall in front of us? Both halves must be known
+-- numbers -- an unresolved cost (the normal state until the tooltip scan runs,
+-- and permanently for a totem that isn't in this character's book) is "I don't
+-- know", never "too expensive". Greying a bar on "I don't know" would grey the
+-- whole bar at login; refusing to record a timer on it would silently drop the
+-- countdown of a totem that is standing. Both callers therefore fail OPEN here,
+-- the same policy as confidentNoneOut/recallReady in core/cast.lua.
+function TotemBar.notEnoughMana(cost, mana)
+ if not cost or not mana then
+ return false
+ end
+ return mana < cost
+end
+
+-- Clearcasting (Elemental Focus) used to exempt every mana verdict here. That was
+-- WRONG and is gone (2026-08-28, player-reported): Elemental Focus zeroes the next
+-- DAMAGE spell, not a totem summon, so a totem costs full price while Clearcasting
+-- is up. Because totems are the only thing this addon casts, the exemption
+-- disabled the mana gate and the out-of-mana dim outright whenever the buff was
+-- up. TotemBar.hasClearcasting/CLEARCAST_ICON were removed with it -- the icon was
+-- never verified in-game and now has no consumer. TotemBar.hasBuffWithIcon stays:
+-- the out-of-range tint still uses it.
+
+-- Live mana verdict for one totem by name: true only when its cost is known,
+-- the player's mana is below it, and no Clearcasting is up. Reads the cached
+-- tooltip cost (see getTotemManaCost), so this is a table lookup plus one
+-- UnitMana call -- cheap enough for the 5Hz display tick that calls it.
+function TotemBar.totemOutOfMana(name)
+ if not name or type(UnitMana) ~= "function" then
+ return false
+ end
+ return TotemBar.notEnoughMana(TotemBar.getTotemManaCost(name), UnitMana("player"))
+end
+
+-- Pure: floored refund.
+function TotemBar.refundAmount(pct, activeCost)
+ if not pct or not activeCost then
+ return 0
+ end
+ return math.floor(pct * activeCost)
+end
+
+-- Pure: learn refund pct from an observed mana gain; nil if out of sane range.
+function TotemBar.learnRefundPct(manaGained, activeCost)
+ if not manaGained or not activeCost or activeCost <= 0 then
+ return nil
+ end
+ local pct = manaGained / activeCost
+ if pct < 0.05 or pct > 1.0 then
+ return nil
+ end
+ return pct
+end
+
+-- Pure: helpful totems get Totemic Mastery's +20% duration.
+function TotemBar.durationWithMastery(baseDuration, isHelpful, hasMastery)
+ if hasMastery and isHelpful then
+ return baseDuration * 1.2
+ end
+ return baseDuration
+end
+
+-- The pure fire-DAMAGE totems do NOT get the +20% helpful-totem duration.
+-- Everything else is treated as helpful. VERIFY in-game and adjust.
+TotemBar.NON_HELPFUL_TOTEMS = {
+ ["Searing Totem"] = true,
+ ["Magma Totem"] = true,
+ ["Fire Nova Totem"] = true,
+}
+
+function TotemBar.isHelpfulTotem(name)
+ if not name then
+ return false
+ end
+ if TotemBar.NON_HELPFUL_TOTEMS[name] then
+ return false
+ end
+ return true
+end
+
+-- Pure: brings a pfUI-libtotem-reported duration onto the same scale as the
+-- one recordCast stores. libtotem serves FLAT book durations; own tracking
+-- applies Totemic Mastery's +20% to helpful totems. ui.lua's timer prefers
+-- libtotem while it reports the slot active and falls back to own tracking
+-- once libtotem evicts it -- so at the book duration the display flipped
+-- source and the countdown jumped back UP (...3, 2, 1, 24) with the duration
+-- ring re-filling, instead of running out. Scaling both sides by the same
+-- factor makes them agree on the endpoint.
+function TotemBar.gtiDurationWithMastery(gtiDuration, gtiName, hasMastery)
+ if not gtiDuration then
+ return nil
+ end
+ return TotemBar.durationWithMastery(gtiDuration, TotemBar.isHelpfulTotem(gtiName), hasMastery)
+end
+
+-- ===== WoW-API layer (not offline-executed) =====
+
+-- TotemBar.findHighestRankSlot(name) used to be defined here as its own
+-- fresh linear GetSpellName() scan per call. It's now a cache-backed
+-- accessor provided by core/spellindex.lua (loaded earlier in the TOC) --
+-- same name, same signature, same resolved slot (the HIGHEST known rank,
+-- which is the one CastSpellByName actually casts; a first-match scan
+-- would return rank 1 -> cost far too low, the bug behind "the mana
+-- values are wrong"). Shared: ui.lua's tooltips resolve through this too -
+-- SetSpell with a first-name-match slot shows the RANK 1 tooltip (wrong
+-- mana/duration).
+
+local scanTip = nil
+local manaCache = {} -- name -> cost (only positive results cached)
+
+-- Reads a totem's mana cost via a hidden tooltip scan (no GetSpellManaCost on
+-- 1.12). Reflects cost talents (Tidal Focus, Restorative Totems) automatically.
+-- Cached by name; cache cleared on SPELLS_CHANGED (talent/rank changes).
+function TotemBar.getTotemManaCost(name)
+ if not name then return nil end
+ if manaCache[name] then return manaCache[name] end
+ local idx = TotemBar.findHighestRankSlot(name)
+ if not idx then return nil end
+ if not scanTip then
+ scanTip = CreateFrame("GameTooltip", "TotemBarScanTooltip", nil, "GameTooltipTemplate")
+ end
+ scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
+ scanTip:ClearLines()
+ scanTip:SetSpell(idx, BOOKTYPE_SPELL)
+ local cost = nil
+ local lines = scanTip:NumLines() or 0
+ for i = 1, lines do
+ local fs = getglobal("TotemBarScanTooltipTextLeft" .. i)
+ local text = fs and fs:GetText()
+ local c = TotemBar.parseManaCost(text)
+ if c then
+ cost = c
+ break
+ end
+ end
+ if cost then manaCache[name] = cost end
+ return cost
+end
+
+-- Dev aid (/tb manadump): dump the RAW mana-cost scan for the chosen totems +
+-- Totemic Recall to \imports\totembar_manadump.txt, so the resolved
+-- slot/rank, every tooltip line, and the parsed cost can be verified
+-- off-client (ground truth for the mana-cost feature). Chat fallback if no
+-- SuperWoW ExportFile.
+function TotemBar.dumpManaScan()
+ local els = TotemBar.TOTEM_ELEMENTS
+ local chosen = (TotemBarDB and TotemBarDB.chosen) or {}
+ local list = {}
+ for i = 1, table.getn(els) do
+ local nm = chosen[els[i]]
+ if nm then list[table.getn(list) + 1] = nm end
+ end
+ list[table.getn(list) + 1] = "Totemic Recall"
+ if not scanTip then
+ scanTip = CreateFrame("GameTooltip", "TotemBarScanTooltip", nil, "GameTooltipTemplate")
+ end
+ local out = "TotemBar mana scan dump\n"
+ local total = 0
+ for i = 1, table.getn(list) do
+ local nm = list[i]
+ local idx = TotemBar.findHighestRankSlot(nm)
+ out = out .. "\n[" .. nm .. "] slot=" .. tostring(idx)
+ if idx then
+ local rn, rr = GetSpellName(idx, BOOKTYPE_SPELL)
+ out = out .. " resolved='" .. tostring(rn) .. "' rank='" .. tostring(rr) .. "'\n"
+ scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
+ scanTip:ClearLines()
+ scanTip:SetSpell(idx, BOOKTYPE_SPELL)
+ local lines = scanTip:NumLines() or 0
+ local cost = nil
+ for L = 1, lines do
+ local fs = getglobal("TotemBarScanTooltipTextLeft" .. L)
+ local text = fs and fs:GetText()
+ out = out .. " L" .. L .. ": " .. tostring(text) .. "\n"
+ if not cost then
+ local c = TotemBar.parseManaCost(text)
+ if c then cost = c end
+ end
+ end
+ out = out .. " parsedCost=" .. tostring(cost) .. "\n"
+ if cost and nm ~= "Totemic Recall" then total = total + cost end
+ else
+ out = out .. " (not found in spellbook)\n"
+ end
+ end
+ out = out .. "\nsumChosenCost(live)=" .. tostring(
+ TotemBar.sumChosenCost(chosen, els, TotemBar.getTotemManaCost)) .. "\n"
+ out = out .. "dumpTotal(chosen)=" .. total .. "\n"
+ if ExportFile then
+ ExportFile("totembar_manadump", out)
+ end
+ if DEFAULT_CHAT_FRAME then
+ DEFAULT_CHAT_FRAME:AddMessage(
+ "TotemBar: mana dump written (imports\\totembar_manadump.txt). chosen sum=" .. total)
+ end
+end
+
+-- Totemic Mastery (TWoW: +20% helpful-totem duration): cached scan of the
+-- talent trees by name.
+local masteryCached = false
+local function scanMastery()
+ masteryCached = false
+ local tabs = (GetNumTalentTabs and GetNumTalentTabs()) or 0
+ for tab = 1, tabs do
+ local num = GetNumTalents(tab)
+ for i = 1, num do
+ local tname, _, _, _, rank = GetTalentInfo(tab, i)
+ if tname == "Totemic Mastery" and rank and rank > 0 then
+ masteryCached = true
+ end
+ end
+ end
+end
+
+function TotemBar.hasTotemicMastery()
+ return masteryCached
+end
+
+-- Recall-refund auto-learn. Snapshot the summed cost of the totems currently
+-- out just before a DELIBERATE Totemic Recall; when the mana-gain message
+-- arrives shortly after, learn the real refund %.
+TotemBar.recallPendingCost = 0
+local recallExpectUntil = 0
+
+function TotemBar.snapshotRecallCost()
+ TotemBar.recallPendingCost = TotemBar.sumActiveCost(
+ TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, GetTime(),
+ TotemBar.getTotemManaCost, TotemBar.remaining)
+ recallExpectUntil = GetTime() + 2
+end
+
+-- Events: refresh mastery, clear the cost cache on spell changes, and learn
+-- the refund % from the recall mana-gain message.
+-- Guarded: CreateFrame is nil under plain offline Lua, so this whole block is
+-- skipped there (loadfile/dofile must not error for the test suite).
+if CreateFrame then
+ local mcEvents = CreateFrame("Frame", "TotemBarManaCostEventFrame", UIParent)
+ mcEvents:RegisterEvent("PLAYER_ENTERING_WORLD")
+ mcEvents:RegisterEvent("CHARACTER_POINTS_CHANGED")
+ mcEvents:RegisterEvent("SPELLS_CHANGED")
+ mcEvents:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF")
+ mcEvents:SetScript("OnEvent", function()
+ if event == "PLAYER_ENTERING_WORLD" or event == "CHARACTER_POINTS_CHANGED" then
+ scanMastery()
+ elseif event == "SPELLS_CHANGED" then
+ manaCache = {}
+ scanMastery()
+ elseif event == "CHAT_MSG_SPELL_SELF_BUFF" then
+ if GetTime() > recallExpectUntil then return end
+ local msg = arg1
+ if not msg then return end
+ -- Learn only from a Totemic Recall mana-gain within the window.
+ -- NOTE: exact message text is locale/format dependent - VERIFY in-game
+ -- (adjust the "Totemic Recall" + number pattern if needed).
+ if string.find(msg, "Totemic Recall") then
+ local _, _, num = string.find(msg, "(%d+)")
+ local gained = tonumber(num)
+ local pct = TotemBar.learnRefundPct(gained, TotemBar.recallPendingCost)
+ if pct and TotemBarDB then
+ TotemBarDB.recallRefundPct = pct
+ end
+ end
+ end
+ end)
+end
diff --git a/core/optionslogic.lua b/core/optionslogic.lua
new file mode 100644
index 0000000..24e8457
--- /dev/null
+++ b/core/optionslogic.lua
@@ -0,0 +1,73 @@
+-- TotemBar - core/optionslogic.lua
+-- PURE helpers for the options panel + minimap button (no WoW API), so the
+-- fiddly bits (orbit math, slider clamping, the macro contract) are
+-- offline-testable under real Lua 5.0. The UI (minimap.lua / options.lua)
+-- calls these.
+
+TotemBar = TotemBar or {}
+
+-- Orbit offset (x, y) for a minimap button at angleDeg on a circle of the
+-- given radius. 0deg -> (radius,0); 90 -> (0,radius); 180 -> (-radius,0).
+function TotemBar.angleToOffset(angleDeg, radius)
+ local rad = math.rad(angleDeg)
+ return math.cos(rad) * radius, math.sin(rad) * radius
+end
+
+-- Clamp v into [minVal, maxVal].
+function TotemBar.clampValue(v, minVal, maxVal)
+ if v < minVal then
+ return minVal
+ end
+ if v > maxVal then
+ return maxVal
+ end
+ return v
+end
+
+-- The fixed spec for the "Totems" convenience macro: name, body, and a BARE
+-- icon file name (no Interface\Icons\ prefix, no extension - TurtleWoW's
+-- CreateMacro/EditMacro prepend the path themselves).
+function TotemBar.macroSpec()
+ return "Totems", "/script TotemBar.recallAndCastAll()", "Spell_Nature_TremorTotem"
+end
+
+-- Bar-layout grid data + math (feature: selectable 1x6 / 2x3 / 3x2 bar
+-- arrangement, see ui.lua's ApplyBarLayout). Pure so the button positions
+-- and pixel-size math can be pinned offline instead of eyeballed in-game.
+
+-- Explicit per-layout button positions {col, row} (0-based), button order:
+-- Fire, Earth, Water, Air, Recall, DropSet. "2x3" groups the four element
+-- totems as a 2x2 block in the first two columns (user request); "3x2"
+-- groups them as the top 2x2 block with the utility row below.
+-- DropSet is deliberately the LAST slot in every layout: when it is hidden
+-- (TotemBarDB.showDropSetButton = false) the remaining five buttons keep
+-- their exact slots - the element 2x2 block and Recall never move, and no
+-- second positions table is needed. The multi-row layouts then leave the
+-- trailing grid cell empty, which is invisible (transparent backdrop) and
+-- still usable as drag surface.
+TotemBar.BAR_LAYOUT_POSITIONS = {
+ ["1x6"] = { {0,0},{1,0},{2,0},{3,0},{4,0},{5,0} },
+ ["2x3"] = { {0,0},{1,0},{0,1},{1,1},{2,0},{2,1} },
+ ["3x2"] = { {0,0},{1,0},{0,1},{1,1},{0,2},{1,2} },
+}
+
+-- Pixel dimensions of the bar frame for a grid of `count` buttons arranged
+-- in at most `cols` columns of `size`x`size` buttons with `gap` spacing.
+-- `rowPitchExtra` widens the vertical pitch between rows (multi-row layouts:
+-- room for the timer text hanging below each row plus the next row's ring
+-- overhang - see ui.lua's BUTTON_GAP comment); pass 0 for a single row.
+-- height = top gap + rows*size + (rows-1)*(gap+extra) + bottom gap.
+-- `cols` is capped at `count` because a grid can never be wider than it has
+-- buttons: with the DropSet button hidden, "1x6" is a row of FIVE and the
+-- frame must shrink with it instead of trailing dead space. The multi-row
+-- layouts still need all of their columns at five buttons, so their size is
+-- unchanged (they just leave the last grid cell empty).
+function TotemBar.barDimensions(count, cols, size, gap, rowPitchExtra)
+ if cols > count then
+ cols = count
+ end
+ local rows = math.ceil(count / cols)
+ local width = cols * (size + gap) + gap
+ local height = gap + rows * size + (rows - 1) * (gap + rowPitchExtra) + gap
+ return width, height, rows
+end
diff --git a/core/pulse.lua b/core/pulse.lua
new file mode 100644
index 0000000..55f75d4
--- /dev/null
+++ b/core/pulse.lua
@@ -0,0 +1,195 @@
+-- TotemBar - core/pulse.lua
+-- PURE pulse/ring math for the Pulse UI (no WoW API; offline-tested via
+-- tools/luatests/test_pulse.lua). ui.lua calls these from its throttled
+-- timer tick - keep everything allocation-free except buildRingTexCoords
+-- (called once at load).
+
+TotemBar = TotemBar or {}
+
+-- 0..1 phase toward the next pulse. Phase origin is anchorAt (the last
+-- OBSERVED pulse, stamped by ui.lua's combat-message handler) when set,
+-- else placedAt (dead reckoning). Lua 5.0: math.mod, not the % operator.
+function TotemBar.pulseRatio(placedAt, anchorAt, interval, now)
+ if not interval or interval <= 0 then
+ return nil
+ end
+ local origin = anchorAt or placedAt
+ if not origin or not now then
+ return nil
+ end
+ local elapsed = now - origin
+ if elapsed < 0 then
+ return 0
+ end
+ return math.mod(elapsed, interval) / interval
+end
+
+-- How far before the predicted next tick an observed gain may still re-anchor
+-- the phase (seconds) -- absorbs normal message/latency jitter.
+TotemBar.PULSE_ANCHOR_TOLERANCE = 0.25
+
+-- Pure: may an observed periodic self-gain re-anchor this record's pulse phase?
+--
+-- The periodic-gain chat lines carry NO caster (1.12's ownerless
+-- POWERGAIN/PERIODICAURAHEAL ...SELFSELF templates), so a second shaman's
+-- identically-named totem produces byte-identical text to our own. Accepting
+-- every message re-stamped the anchor off-beat, roughly twice per interval,
+-- and the ripple/pulse arc jumped backwards. Rate-limiting to at most one
+-- anchor per interval locks the display onto ONE tick train instead. A record
+-- with no anchor yet (fresh cast) always gets its free first stamp, so a
+-- recast re-acquires phase immediately.
+function TotemBar.shouldAnchorPulse(lastAnchor, interval, now)
+ if not lastAnchor or not now then
+ return true
+ end
+ if not interval or interval <= 0 then
+ return true
+ end
+ return (now - lastAnchor) >= (interval - TotemBar.PULSE_ANCHOR_TOLERANCE)
+end
+
+-- 0..1 single fill from placement to detonation (Fire Nova). Clamped at
+-- both ends; callers treat >=1 as "detonated" and hide the bar.
+function TotemBar.oneshotRatio(placedAt, delay, now)
+ if not placedAt or not delay or delay <= 0 or not now then
+ return nil
+ end
+ local r = (now - placedAt) / delay
+ if r < 0 then
+ return 0
+ end
+ if r > 1 then
+ return 1
+ end
+ return r
+end
+
+-- Blueprint state D ("pulse imminent"): glow from 85% fill on.
+TotemBar.PULSE_IMMINENT_THRESHOLD = 0.85
+function TotemBar.pulseImminent(ratio)
+ if ratio and ratio >= TotemBar.PULSE_IMMINENT_THRESHOLD then
+ return true
+ end
+ return false
+end
+
+-- Flipbook frame index for the duration ring: 0 = empty ... frameCount-1 =
+-- full, rounded to nearest so the ring reads full right after placement.
+-- Generic over frameCount; callers pass the arc's actual frame count (Rev 2:
+-- 62, cells 0..61 of the ring_round.tga flipbook).
+function TotemBar.ringFrameIndex(remaining, duration, frameCount)
+ if not remaining or not duration or duration <= 0 or not frameCount then
+ return 0
+ end
+ local ratio = remaining / duration
+ if ratio < 0 then
+ ratio = 0
+ end
+ if ratio > 1 then
+ ratio = 1
+ end
+ return math.floor(ratio * (frameCount - 1) + 0.5)
+end
+
+-- Precomputed SetTexCoord rectangles for a square cols x cols grid texture,
+-- row-major, 1-based array indexed by frame+1. Built ONCE at load; the
+-- per-tick path just indexes it (zero allocation, zero math).
+function TotemBar.buildRingTexCoords(frames, cols)
+ local coords = {}
+ local cell = 1 / cols
+ for i = 0, frames - 1 do
+ local col = math.mod(i, cols)
+ local row = math.floor(i / cols)
+ coords[i + 1] = {
+ l = col * cell,
+ r = (col + 1) * cell,
+ t = row * cell,
+ b = (row + 1) * cell,
+ }
+ end
+ return coords
+end
+
+-- Duration counterpart of core/cast.lua's resolveRemaining, SAME precedence:
+-- GTI (pfUI libtotem) is authoritative while it reports the slot active;
+-- otherwise fall back to own tracking. Returns the TOTAL duration whose
+-- remaining time resolveRemaining would have returned, or nil.
+function TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration, ownRemaining, ownDuration)
+ -- FIX 2026-07-15: mirror resolveRemaining -- trust GTI only while it reports the
+ -- slot active WITH positive time left; otherwise fall back to own tracking (a
+ -- stale-active GTI slot must not block the timer, same Fire/Earth bug).
+ if gtiActive and gtiRemaining and gtiRemaining > 0 then
+ return gtiDuration
+ end
+ if ownRemaining and ownRemaining > 0 then
+ return ownDuration
+ end
+ return nil
+end
+
+-- Ripple wave phase: seconds since the last pulse mapped to 0..1 over the
+-- wave's lifetime, or nil when no wave is showing. For tick totems the wave
+-- starts AT each pulse (phase wraps via pulseRatio); waveDur is clamped to
+-- half the interval so the wave always dies before the next one spawns.
+function TotemBar.waveFrac(ratio, interval, waveDur)
+ if not ratio or not interval or interval <= 0 or not waveDur or waveDur <= 0 then
+ return nil
+ end
+ local dur = waveDur
+ if dur > interval * 0.5 then
+ dur = interval * 0.5
+ end
+ local age = ratio * interval
+ if age > dur then
+ return nil
+ end
+ return age / dur
+end
+
+-- One-shot (Fire Nova): wave for waveDur seconds AFTER detonation.
+function TotemBar.oneshotWaveFrac(placedAt, delay, now, waveDur)
+ if not placedAt or not delay or not now or not waveDur or waveDur <= 0 then
+ return nil
+ end
+ local age = (now - placedAt) - delay
+ if age < 0 or age > waveDur then
+ return nil
+ end
+ return age / waveDur
+end
+
+-- Anticipation glow: eased 0..1 ramp over the last (1 - rampStart) fraction
+-- of the pulse phase; nil below rampStart (no glow) or on bad inputs.
+function TotemBar.glowRamp(ratio, rampStart)
+ if not ratio or not rampStart or rampStart >= 1 or ratio < rampStart then
+ return nil
+ end
+ local t = (ratio - rampStart) / (1 - rampStart)
+ if t > 1 then
+ t = 1
+ end
+ return t * t
+end
+
+-- Traffic-light color for a remaining-time fraction (1 = full, 0 = empty):
+-- green above 0.5, blending green->yellow->orange->red as time runs out.
+-- Returns r, g, b as three numbers (no table - called per tick).
+function TotemBar.timeColor(frac)
+ if not frac then
+ return 1, 1, 1
+ end
+ if frac < 0 then frac = 0 end
+ if frac > 1 then frac = 1 end
+ if frac >= 0.5 then
+ -- green (0.25,0.8,0.25) -> yellow (0.95,0.85,0.2) over 1.0..0.5
+ local t = (1 - frac) * 2
+ return 0.25 + 0.70 * t, 0.80 + 0.05 * t, 0.25 - 0.05 * t
+ elseif frac >= 0.25 then
+ -- yellow -> orange (1.0,0.55,0.1) over 0.5..0.25
+ local t = (0.5 - frac) * 4
+ return 0.95 + 0.05 * t, 0.85 - 0.30 * t, 0.20 - 0.10 * t
+ end
+ -- orange -> red (0.90,0.15,0.10) over 0.25..0
+ local t = (0.25 - frac) * 4
+ return 1.00 - 0.10 * t, 0.55 - 0.40 * t, 0.10
+end
diff --git a/core/pulsecal.lua b/core/pulsecal.lua
new file mode 100644
index 0000000..277c8cb
--- /dev/null
+++ b/core/pulsecal.lua
@@ -0,0 +1,392 @@
+-- TotemBar - core/pulsecal.lua
+-- Dev-only pulse-calibration telemetry (/tb pulsecal): captures raw
+-- totem-related combat lines + our own placements into a fixed ring buffer
+-- and exports them via SuperWoW ExportFile, so the dev environment can
+-- measure REAL TurtleWoW pulse intervals offline and bake them back into
+-- core/pulsedata.lua (verified=true).
+-- Pure buffer/serializer on top (offline-tested); WoW-gated capture below.
+
+TotemBar = TotemBar or {}
+
+TotemBar.PULSECAL_CAP = 2000
+
+-- Dev-only diagnostic probe (/tb tprobe) for the destroyed-totem GUID-latch
+-- paths in core/cast.lua ("Destroyed-totem detection (GUID liveness)"). Built
+-- because two in-game measurement attempts against those latch paths were
+-- both silently wiped by a /reload before their taps could be read (see
+-- 2026-08-19/20 session) -- unlike pulsecal above (start, drop totems, dump
+-- once by hand), this probe survives /reload on its own: it re-exports its
+-- WHOLE ring buffer after every kept event (not only on demand) and re-arms
+-- itself from TotemBarDB.tprobeArmed at ADDON_LOADED (see the WoW-gated
+-- section below). Shares pulsecalPush/pulsecalFormat above for the ring
+-- buffer itself -- only the filter and the per-line detail string are new.
+TotemBar.TPROBE_CAP = 300
+
+-- Pure: push one record into the ring buffer. state = { n = total pushed,
+-- idx = next write slot 1..cap }. Record tables are REUSED on wrap (no
+-- allocation growth while capturing).
+function TotemBar.pulsecalPush(buf, cap, state, t, ev, msg)
+ local slot = state.idx
+ local rec = buf[slot]
+ if not rec then
+ rec = {}
+ buf[slot] = rec
+ end
+ rec.t = t
+ rec.e = ev
+ rec.m = msg
+ state.n = state.n + 1
+ state.idx = slot + 1
+ if state.idx > cap then
+ state.idx = 1
+ end
+end
+
+-- Pure: serialize surviving records in chronological order, one
+-- "t;event;msg" line each (t with millisecond precision). Allocates - dump
+-- time only, never per capture.
+function TotemBar.pulsecalFormat(buf, cap, state)
+ local count = state.n
+ if count > cap then
+ count = cap
+ end
+ if count == 0 then
+ return ""
+ end
+ local start = state.idx - count
+ if start < 1 then
+ start = start + cap
+ end
+ local lines = {}
+ for i = 0, count - 1 do
+ local slot = start + i
+ if slot > cap then
+ slot = slot - cap
+ end
+ local rec = buf[slot]
+ lines[i + 1] = string.format("%.3f;%s;%s", rec.t, rec.e, rec.m)
+ end
+ return table.concat(lines, "\n")
+end
+
+-- ===== /tb tprobe -- pure filter + line-builder (offline-tested) =====
+
+-- Pure: does `name` look like a totem to a plain substring eye -- "Totem" or
+-- "Searing" (the latter because Searing Totem's own pulse-cast spell is
+-- suspected to be named "Searing Bolt"/"Attack" rather than "Searing Totem",
+-- suspicion (a) in the probe's own brief -- a name check that only looked
+-- for "Totem" would silently drop exactly the lines needed to confirm or
+-- kill that suspicion). Case-sensitive plain substring, same convention the
+-- WoW-gated capture below (and pulsecal's own filter above) already uses --
+-- not a design decision unique to this function. nil-safe.
+function TotemBar.tprobeNameHit(name)
+ if not name then
+ return false
+ end
+ if string.find(name, "Totem", 1, true) then
+ return true
+ end
+ if string.find(name, "Searing", 1, true) then
+ return true
+ end
+ return false
+end
+
+-- Events always kept regardless of name (a death is diagnostic on its own --
+-- there is no "name" to filter on for CHAT_MSG_COMBAT_*_DEATH, and UNIT_DIED
+-- firing for something that never matched a totem name is itself evidence,
+-- e.g. against suspicion (b)/(c) in the probe's brief).
+local TPROBE_ALWAYS_EVENTS = {
+ UNIT_DIED = true,
+ CHAT_MSG_COMBAT_FRIENDLY_DEATH = true,
+ CHAT_MSG_COMBAT_HOSTILE_DEATH = true,
+}
+
+-- Pure: should this event be kept? nameUnit is UnitName(arg1) as resolved by
+-- the caller, nameSpell is SpellInfo(arg4) -- kept if EITHER hits
+-- tprobeNameHit, or if the event is one of TPROBE_ALWAYS_EVENTS above.
+function TotemBar.tprobeShouldKeep(event, nameUnit, nameSpell)
+ if event and TPROBE_ALWAYS_EVENTS[event] then
+ return true
+ end
+ if TotemBar.tprobeNameHit(nameUnit) then
+ return true
+ end
+ if TotemBar.tprobeNameHit(nameSpell) then
+ return true
+ end
+ return false
+end
+
+-- Pure: the "msg" half of one kept line (the ring buffer's "t;event;msg"
+-- shape from pulsecalPush/pulsecalFormat above supplies t and event already).
+-- nameUnit and nameSpell are BOTH kept, deliberately never collapsed into a
+-- single "resolved name" -- the whole point of the probe is to see whether
+-- they AGREE (suspicion (a): does a Searing Totem pulse's SpellInfo name
+-- ever equal "Searing Totem" at all?). Every argument may be nil; tostring()
+-- turns that into the literal string "nil", never a concat error.
+function TotemBar.tprobeBuildDetail(a1, a2, a3, a4, nameUnit, nameSpell, ownerName, health, latched)
+ return "a1=" .. tostring(a1)
+ .. ";a2=" .. tostring(a2)
+ .. ";a3=" .. tostring(a3)
+ .. ";a4=" .. tostring(a4)
+ .. ";nameUnit=" .. tostring(nameUnit)
+ .. ";nameSpell=" .. tostring(nameSpell)
+ .. ";owner=" .. tostring(ownerName)
+ .. ";hp=" .. tostring(health)
+ .. ";latched=" .. tostring(latched)
+end
+
+-- Pure: would the PRODUCTIVE handler (core/cast.lua's guidFrame) have
+-- latched a GUID for this event's totem, and what does that element's
+-- record show right now? nameUnit is tried first (arg1's own UnitName --
+-- the identity the productive UNIT_MODEL_CHANGED path itself keys off),
+-- nameSpell as a fallback (a totem that only ever surfaces through a SPELL
+-- name elementOfFn doesn't recognise -- e.g. "Searing Bolt" -- correctly
+-- resolves to nil here, which is itself the diagnostic: the productive
+-- UNIT_CASTEVENT path (core/cast.lua's TotemBar.elementFromCastName) has
+-- exactly the same blind spot). activeTotems/elementOfFn are injected (same
+-- dependency-injection convention core/cast.lua's own TotemBar.elementForGuid
+-- uses) so this needs no WoW globals to test. Returns nil both when no
+-- element matches AND when the matched element's record has no guid yet --
+-- either way there is no guid to report, matching the brief's own
+-- "latched=" literally.
+function TotemBar.tprobeLatchedGuid(activeTotems, elementOfFn, nameUnit, nameSpell)
+ if not elementOfFn then
+ return nil
+ end
+ local element = elementOfFn(nameUnit) or elementOfFn(nameSpell)
+ if not element or not activeTotems then
+ return nil
+ end
+ local rec = activeTotems[element]
+ if not rec then
+ return nil
+ end
+ return rec.guid
+end
+
+-- ---------------------------------------------------------------------------
+-- WoW-gated capture (skipped entirely by the offline test runner).
+if CreateFrame then
+ local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
+
+ local capturing = false
+ local buf = {}
+ local state = { n = 0, idx = 1 }
+
+ -- Everything that could plausibly carry a totem pulse on 1.12 (no
+ -- COMBAT_LOG_EVENT_UNFILTERED here - all localized text in arg1).
+ local CAPTURE_EVENTS = {
+ "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS",
+ "CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS",
+ "CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS",
+ "CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE",
+ "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE",
+ "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE",
+ "CHAT_MSG_SPELL_SELF_DAMAGE",
+ "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE",
+ "CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS",
+ "CHAT_MSG_SPELL_AURA_GONE_SELF",
+ "CHAT_MSG_SPELL_AURA_GONE_PARTY",
+ }
+
+ local calFrame = CreateFrame("Frame", "TotemBarPulseCalFrame", UIParent)
+ calFrame:SetScript("OnEvent", function()
+ if not capturing then
+ return
+ end
+ if event == "UNIT_CASTEVENT" then
+ -- SuperWoW: casterGUID, targetGUID, type, spellId, castTime.
+ -- Only record casts whose spell name mentions Totem (SpellInfo
+ -- is a SuperWoW API; guarded because the event only exists there
+ -- anyway). arg4 is guarded too (adversarial review, 2026-08-19
+ -- destroyed-totem session) -- SpellInfo(nil) is unproven safe on
+ -- this client, and a UNIT_CASTEVENT with no spellId is possible
+ -- in principle (e.g. a non-cast event type on this arg3 slot).
+ local sname = (SpellInfo and arg4) and SpellInfo(arg4)
+ if sname and string.find(sname, "Totem", 1, true) then
+ TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event,
+ tostring(arg1) .. ";" .. tostring(arg3) .. ";" .. tostring(sname))
+ end
+ return
+ end
+ -- Plain substring guard keeps the hot path cheap; "Totem"-less buff
+ -- names (Mana Spring / Healing Stream gain lines) are the exception,
+ -- so include the "You gain" prefix too.
+ local msg = arg1
+ if msg and (string.find(msg, "Totem", 1, true) or string.find(msg, "You gain", 1, true)) then
+ TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event, msg)
+ end
+ end)
+
+ -- Record our own placements as reference marks (t0 for interval math).
+ -- Wrapping recordCast is safe here: core/pulsecal.lua loads AFTER
+ -- core/cast.lua (see .toc) and ui.lua resolves TotemBar.recordCast at
+ -- call time.
+ local origRecordCast = TotemBar.recordCast
+ TotemBar.recordCast = function(element, totemName)
+ origRecordCast(element, totemName)
+ if capturing then
+ TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(),
+ "TB_PLACED", tostring(element) .. ";" .. tostring(totemName))
+ end
+ end
+
+ function TotemBar.PulseCal(sub)
+ if sub == "start" then
+ if not capturing then
+ capturing = true
+ for i = 1, table.getn(CAPTURE_EVENTS) do
+ calFrame:RegisterEvent(CAPTURE_EVENTS[i])
+ end
+ -- SuperWoW-only event; pcall-guarded in case a client build
+ -- rejects unknown event names.
+ pcall(function() calFrame:RegisterEvent("UNIT_CASTEVENT") end)
+ end
+ ChatOut:AddMessage("TotemBar: pulsecal capture STARTED (drop totems, then /tb pulsecal dump).")
+ elseif sub == "stop" then
+ capturing = false
+ calFrame:UnregisterAllEvents()
+ ChatOut:AddMessage("TotemBar: pulsecal capture stopped (" .. state.n .. " records kept).")
+ elseif sub == "dump" then
+ if not ExportFile then
+ ChatOut:AddMessage("TotemBar: pulsecal dump needs SuperWoW (ExportFile missing).")
+ return
+ end
+ -- Filename WITHOUT extension - the client appends .txt itself.
+ ExportFile("totembar_pulsecal", TotemBar.pulsecalFormat(buf, TotemBar.PULSECAL_CAP, state))
+ ChatOut:AddMessage("TotemBar: pulsecal dump written (imports\\totembar_pulsecal.txt, "
+ .. state.n .. " records).")
+ else
+ local on = capturing and "ON" or "OFF"
+ ChatOut:AddMessage("TotemBar: pulsecal " .. on .. ", " .. state.n
+ .. " records. Usage: /tb pulsecal start|stop|dump")
+ end
+ end
+
+ -- ===== /tb tprobe -- reload-survivable diagnostic probe =====
+ -- Pure observation only: never touches TotemBar.activeTotems or any
+ -- other productive state, only READS TotemBar.activeTotems/elementOf to
+ -- report what the productive handler would have seen (see
+ -- tprobeLatchedGuid above).
+ local tprobeCapturing = false
+ local tprobeBuf = {}
+ local tprobeState = { n = 0, idx = 1 }
+
+ local function tprobeExport()
+ if ExportFile then
+ -- Filename WITHOUT extension - the client appends .txt itself.
+ ExportFile("tb_tprobe", TotemBar.pulsecalFormat(tprobeBuf, TotemBar.TPROBE_CAP, tprobeState))
+ end
+ end
+
+ -- Takes explicit args (not the WoW globals directly) so the pcall below
+ -- wraps a real function value, not a closure built fresh every event.
+ local function tprobeHandleEvent(ev, a1, a2, a3, a4)
+ local nameUnit = nil
+ if a1 and type(UnitName) == "function" then
+ nameUnit = UnitName(a1)
+ end
+ local nameSpell = nil
+ if a4 and type(SpellInfo) == "function" then
+ nameSpell = SpellInfo(a4)
+ end
+ if not TotemBar.tprobeShouldKeep(ev, nameUnit, nameSpell) then
+ return
+ end
+ local ownerName = nil
+ if a1 and type(UnitName) == "function" then
+ ownerName = UnitName(a1 .. "owner")
+ end
+ local health = nil
+ if a1 and type(UnitHealth) == "function" then
+ health = UnitHealth(a1)
+ end
+ local latched = TotemBar.tprobeLatchedGuid(TotemBar.activeTotems, TotemBar.elementOf, nameUnit, nameSpell)
+ local detail = TotemBar.tprobeBuildDetail(a1, a2, a3, a4, nameUnit, nameSpell, ownerName, health, latched)
+ TotemBar.pulsecalPush(tprobeBuf, TotemBar.TPROBE_CAP, tprobeState, GetTime(), ev, detail)
+ -- Export on EVERY kept event, not only on /tb tprobe dump - the
+ -- whole reason this probe exists is that two earlier measurement
+ -- attempts were wiped by a /reload before a manual dump could run.
+ tprobeExport()
+ end
+
+ local tprobeFrame = CreateFrame("Frame", "TotemBarTProbeFrame", UIParent)
+ tprobeFrame:SetScript("OnEvent", function()
+ if not tprobeCapturing then
+ return
+ end
+ -- pcall-wrapped end to end (per spec): observation must never be
+ -- able to break anything else this client does.
+ pcall(tprobeHandleEvent, event, arg1, arg2, arg3, arg4)
+ end)
+
+ local function tprobeArm()
+ if tprobeCapturing then
+ return
+ end
+ tprobeCapturing = true
+ -- Native 1.12 events, safe to register directly.
+ tprobeFrame:RegisterEvent("UNIT_MODEL_CHANGED")
+ tprobeFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH")
+ tprobeFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH")
+ -- SuperWoW/nampower-only events; pcall-guarded exactly like
+ -- calFrame's own UNIT_CASTEVENT registration above.
+ pcall(function() tprobeFrame:RegisterEvent("UNIT_CASTEVENT") end)
+ pcall(function() tprobeFrame:RegisterEvent("UNIT_DIED") end)
+ end
+
+ local function tprobeDisarm()
+ tprobeCapturing = false
+ tprobeFrame:UnregisterAllEvents()
+ end
+
+ function TotemBar.TProbe(sub)
+ if sub == "on" then
+ tprobeArm()
+ if TotemBarDB then
+ TotemBarDB.tprobeArmed = true
+ end
+ ChatOut:AddMessage("TotemBar: tprobe capture STARTED (" .. tprobeState.n .. " records kept so far).")
+ elseif sub == "off" then
+ tprobeDisarm()
+ if TotemBarDB then
+ TotemBarDB.tprobeArmed = false
+ end
+ ChatOut:AddMessage("TotemBar: tprobe capture stopped (" .. tprobeState.n .. " records kept).")
+ elseif sub == "dump" then
+ if not ExportFile then
+ ChatOut:AddMessage("TotemBar: tprobe dump needs SuperWoW (ExportFile missing).")
+ return
+ end
+ tprobeExport()
+ ChatOut:AddMessage("TotemBar: tprobe dump written (imports\\tb_tprobe.txt, "
+ .. tprobeState.n .. " records).")
+ else
+ local on = tprobeCapturing and "ON" or "OFF"
+ ChatOut:AddMessage("TotemBar: tprobe " .. on .. ", " .. tprobeState.n
+ .. " records. Usage: /tb tprobe on|off|dump|status")
+ end
+ end
+
+ -- Re-arm after /reload if it was left on. SavedVariables survive a
+ -- /reload; this frame's event registrations do not (a fresh Lua state
+ -- runs on every reload) - so an on->reload->still-on session needs its
+ -- own ADDON_LOADED hook to re-attach tprobeFrame's events, same "wait for
+ -- ADDON_LOADED before trusting TotemBarDB" convention core/config.lua's
+ -- ensureDefaults (called from ui.lua's own ADDON_LOADED handler) and
+ -- minimap.lua's build-on-load both already use, rather than assuming
+ -- load order against either of those.
+ local tprobeInitFrame = CreateFrame("Frame", "TotemBarTProbeInitFrame", UIParent)
+ tprobeInitFrame:RegisterEvent("ADDON_LOADED")
+ tprobeInitFrame:SetScript("OnEvent", function()
+ if event == "ADDON_LOADED" and arg1 == "TotemBar" then
+ tprobeInitFrame:UnregisterEvent("ADDON_LOADED")
+ if TotemBarDB and TotemBarDB.tprobeArmed then
+ tprobeArm()
+ ChatOut:AddMessage("TotemBar: tprobe re-armed after reload (" .. tprobeState.n .. " records kept).")
+ end
+ end
+ end)
+end
diff --git a/core/pulsedata.lua b/core/pulsedata.lua
new file mode 100644
index 0000000..ab238d5
--- /dev/null
+++ b/core/pulsedata.lua
@@ -0,0 +1,60 @@
+-- TotemBar - core/pulsedata.lua
+-- Pulse metadata per totem name (PURE data, no WoW API). Drives the pulse
+-- progress bar in ui.lua. Totems absent from PULSE_DATA get NO pulse bar
+-- (better no info than invented info) - notably Searing Totem (irregular
+-- attack cadence, not a fixed pulse), Grounding (event-consumed) and every
+-- aura totem (continuous aura, no verified pulse mechanic).
+--
+-- Values are server-dump-derived (TWoW server dump, snapshot 2024-07,
+-- patch_1172 branch; tw_world.sql + SpellAuras.cpp:8647-8714), not book
+-- values - source="server" records that provenance. verified=false stays
+-- reserved for IN-GAME confirmation: /tb pulsecal (core/pulsecal.lua)
+-- captures the real timings; once measured in-game, update the value AND
+-- flip verified=true.
+--
+-- firstTick: "immediate" means the aura's periodic tick fires at t=0 (the
+-- totem is on SpellAuras.cpp's CalculatePeriodic exception list);
+-- "delayed" means the first tick lands only after one full amplitude has
+-- elapsed. NOTE: the pulseRatio math is identical either way - the bar
+-- reaches 1.0 at every tick instant, including the t=0 wrap for
+-- "immediate" totems - so firstTick is currently documentative only (no
+-- consumer branches on it yet). Kept for future use, e.g. a distinct
+-- wave/flash at t=0 for "immediate" totems.
+
+TotemBar = TotemBar or {}
+
+-- ptype "tick" -> repeating pulse every `interval` seconds
+-- ptype "oneshot" -> single detonation `delay` seconds after placement
+-- anchor "selfgain" -> phase re-anchors on observed periodic self-gain
+-- combat messages (see core/pulseparse.lua + ui.lua)
+TotemBar.PULSE_DATA = {
+ ["Magma Totem"] = { ptype = "tick", interval = 2.0, firstTick = "delayed", source = "server", verified = false },
+ -- 4.0s confirmed by server dump - corrects the earlier 3.0s book value.
+ ["Tremor Totem"] = { ptype = "tick", interval = 4.0, firstTick = "immediate", source = "server", verified = false },
+ ["Earthbind Totem"] = { ptype = "tick", interval = 3.0, firstTick = "immediate", source = "server", verified = false },
+ ["Poison Cleansing Totem"] = { ptype = "tick", interval = 5.0, firstTick = "immediate", source = "server", verified = false },
+ ["Disease Cleansing Totem"] = { ptype = "tick", interval = 5.0, firstTick = "immediate", source = "server", verified = false },
+ ["Healing Stream Totem"] = { ptype = "tick", interval = 2.0, anchor = "selfgain", firstTick = "delayed", source = "server", verified = false },
+ ["Mana Spring Totem"] = { ptype = "tick", interval = 2.0, anchor = "selfgain", firstTick = "delayed", source = "server", verified = false },
+ -- server models it as a periodic 4s trigger like Magma, but with the 5s
+ -- totem duration exactly one nova lands at t=4s, so the oneshot model
+ -- is behaviorally equivalent.
+ ["Fire Nova Totem"] = { ptype = "oneshot", delay = 4.0, firstTick = "delayed", source = "server", verified = false },
+ -- threat pulse every 2s.
+ ["Stoneclaw Totem"] = { ptype = "tick", interval = 2.0, firstTick = "immediate", source = "server", verified = false },
+}
+
+-- Periodic gain lines name the BUFF ("You gain 10 Mana from Mana Spring."),
+-- not the totem spell - map those source names back to the totem. Exact
+-- wording gets pinned by pulsecal telemetry; extend as measured.
+TotemBar.PULSE_SOURCE_ALIASES = {
+ ["Mana Spring"] = "Mana Spring Totem",
+ ["Healing Stream"] = "Healing Stream Totem",
+}
+
+function TotemBar.pulseInfo(totemName)
+ if not totemName then
+ return nil
+ end
+ return TotemBar.PULSE_DATA[totemName]
+end
diff --git a/core/pulseparse.lua b/core/pulseparse.lua
new file mode 100644
index 0000000..904046a
--- /dev/null
+++ b/core/pulseparse.lua
@@ -0,0 +1,52 @@
+-- TotemBar - core/pulseparse.lua
+-- PURE parser: periodic combat-log line -> totem name (or nil). Used by
+-- ui.lua's CHAT_MSG_SPELL_PERIODIC_* handler to re-anchor pulse phase on
+-- REAL observed ticks. Lua 5.0: string.find with captures only (no
+-- string.match / no method-call syntax). Offline-tested via
+-- tools/luatests/test_pulseparse.lua; exact accepted wordings get refined
+-- from /tb pulsecal telemetry.
+
+TotemBar = TotemBar or {}
+
+-- Strips a trailing roman-numeral rank suffix (" IV", " VII", ...) - the
+-- totem-as-caster shape names the ranked unit ("Healing Stream Totem VII").
+local function stripRank(src)
+ local _, _, base = string.find(src, "^(.-)%s+[IVXLC]+$")
+ if base and base ~= "" then
+ return base
+ end
+ return src
+end
+
+-- Maps a raw source name to a totem name: alias table first (buff names
+-- like "Mana Spring"), then any source ending in " Totem".
+local function resolveSource(src)
+ local alias = TotemBar.PULSE_SOURCE_ALIASES and TotemBar.PULSE_SOURCE_ALIASES[src]
+ if alias then
+ return alias
+ end
+ -- Every totem's name ends in " Totem" (rank suffixes already stripped),
+ -- so anchor at the end - a plain substring test would false-positive on
+ -- player names like "Totemguy".
+ if string.find(src, "%sTotem$") then
+ return src
+ end
+ return nil
+end
+
+function TotemBar.parseSelfGain(msg)
+ if not msg then
+ return nil
+ end
+ local _, _, src = string.find(msg, "^You gain %d+ [Mm]ana from (.+)%.$")
+ if not src then
+ _, _, src = string.find(msg, "^You gain %d+ [Hh]ealth from (.+)%.$")
+ end
+ if not src then
+ _, _, src = string.find(msg, "^(.-) heals you for %d+%.$")
+ end
+ if not src then
+ return nil
+ end
+ return resolveSource(stripRank(src))
+end
diff --git a/core/spellindex.lua b/core/spellindex.lua
new file mode 100644
index 0000000..9eeacc2
--- /dev/null
+++ b/core/spellindex.lua
@@ -0,0 +1,185 @@
+-- TotemBar - core/spellindex.lua
+-- A single cached name -> spellbook-slot index, replacing the four
+-- separate linear GetSpellName(i, BOOKTYPE_SPELL) scans that used to be
+-- duplicated across ui.lua (FindSpellIndexByName), core/cast.lua
+-- (findSpellIndexByName), core/manacost.lua (findHighestRankSlot) and
+-- core/known.lua (scanSpellbook/highestKnownRank). Each of those walked
+-- the WHOLE spellbook on every call; SPELL_UPDATE_COOLDOWN/cast events
+-- fire several of them per totem drop, so a 4-totem-drop macro press
+-- could re-walk the book several times in one frame (perf review
+-- 2026-07-16, TotemBar finding #14: ui.lua:210 + cast.lua:175 +
+-- manacost.lua:116).
+--
+-- buildSpellIndex() below is PURE (no WoW API) and offline-tested: given
+-- a flat array of {name=, rank=} entries in spellbook slot order (array
+-- index N == what GetSpellName(N, BOOKTYPE_SPELL) returned for slot N),
+-- it returns a name -> record table where each record is:
+-- bookIndex - the FIRST (lowest) slot with this name. Mirrors the
+-- old FindSpellIndexByName/findSpellIndexByName
+-- first-match behavior (icon/cooldown/"is it known"
+-- lookups don't care which rank's slot they read --
+-- GetSpellTexture/GetSpellCooldown are identical across
+-- ranks of the same totem).
+-- maxRankIndex - the slot of the HIGHEST parsed rank. Mirrors the old
+-- manacost.lua findHighestRankSlot: this is the slot
+-- CastSpellByName actually casts and the one
+-- tooltip/mana-cost scans must read. Falls back to the
+-- LAST matching slot if no entry's rank string for this
+-- name ever parsed a number (matches the old
+-- fallback -- ranks are listed ascending, so the last
+-- slot is still the best guess).
+-- maxRank - the highest parsed rank NUMBER, or nil if none of
+-- this name's entries carried a parseable rank. Mirrors
+-- the old known.lua highestKnownRank() return value.
+
+TotemBar = TotemBar or {}
+
+function TotemBar.buildSpellIndex(entries)
+ local idx = {}
+ if not entries then
+ return idx
+ end
+ for i = 1, table.getn(entries) do
+ local e = entries[i]
+ local name = e and e.name
+ if name then
+ local rec = idx[name]
+ if not rec then
+ rec = { bookIndex = i, maxRankIndex = i, maxRank = nil }
+ idx[name] = rec
+ end
+ local _, _, numStr = string.find(e.rank or "", "(%d+)")
+ local num = numStr and tonumber(numStr)
+ if num and (not rec.maxRank or num > rec.maxRank) then
+ rec.maxRank = num
+ rec.maxRankIndex = i
+ elseif not rec.maxRank then
+ -- No rank number has parsed for this name in any entry
+ -- seen so far; keep sliding the fallback to the LAST
+ -- matching slot (mirrors the old findHighestRankSlot
+ -- fallback for names whose rank string never parses).
+ rec.maxRankIndex = i
+ end
+ end
+ end
+ return idx
+end
+
+-- ===== WoW-API layer (not offline-executed) =====
+-- Guarded by CreateFrame's presence, same as manacost.lua's own event
+-- block, so dofile-ing this module under plain Lua (offline tests)
+-- never errors.
+
+local cachedEntries = nil -- flat array {name=, rank=}, 1..n, book order
+local cachedIndex = nil -- name -> {bookIndex, maxRankIndex, maxRank}
+
+local function scanLiveSpellbook()
+ local entries = {}
+ local i = 1
+ while true do
+ local name, rank = GetSpellName(i, BOOKTYPE_SPELL)
+ if not name then
+ break
+ end
+ entries[i] = { name = name, rank = rank }
+ i = i + 1
+ end
+ return entries
+end
+
+local function ensureBuilt()
+ if cachedIndex then
+ return
+ end
+ cachedEntries = scanLiveSpellbook()
+ cachedIndex = TotemBar.buildSpellIndex(cachedEntries)
+end
+
+-- Drops the cache; the next accessor call below rebuilds it from a
+-- fresh scan (lazy -- nothing re-scans until actually asked for). Wired
+-- to SPELLS_CHANGED below; also safe to call manually.
+function TotemBar.invalidateSpellIndex()
+ cachedEntries = nil
+ cachedIndex = nil
+end
+
+-- Returns the cached name -> record index, building it lazily on first
+-- access. Lazy-build matters at login/reload: the spellbook
+-- (GetSpellName/GetSpellTexture) isn't reliably populated as early as
+-- ADDON_LOADED fires (see ui.lua's own SPELLS_CHANGED comment) -- an
+-- eager build at file-load time could cache an empty book.
+function TotemBar.getSpellIndex()
+ ensureBuilt()
+ return cachedIndex
+end
+
+-- How many spellbook slots the cached scan holds (0 when the book was empty
+-- at scan time). Lets a caller tell "this spell is provably NOT known" apart
+-- from "we have no usable spellbook data yet" -- the book isn't reliably
+-- populated at login, and the cache is built lazily, so a nil findSpellIndex
+-- alone can't distinguish the two. Used by core/cast.lua's recordCast.
+function TotemBar.spellbookEntryCount()
+ ensureBuilt()
+ return table.getn(cachedEntries)
+end
+
+-- Mirrors old FindSpellIndexByName (ui.lua) / findSpellIndexByName
+-- (cast.lua): first-match spellbook slot, or nil if unknown.
+function TotemBar.findSpellIndex(name)
+ if not name then
+ return nil
+ end
+ local rec = TotemBar.getSpellIndex()[name]
+ return rec and rec.bookIndex
+end
+
+-- Mirrors old manacost.lua findHighestRankSlot: the slot of the
+-- highest-known rank, or nil if unknown.
+function TotemBar.findHighestRankSlot(name)
+ if not name then
+ return nil
+ end
+ local rec = TotemBar.getSpellIndex()[name]
+ return rec and rec.maxRankIndex
+end
+
+-- Mirrors old known.lua highestKnownRank: the highest known rank
+-- NUMBER, or nil if unknown / no rank string ever parsed.
+function TotemBar.highestKnownRank(name)
+ if not name then
+ return nil
+ end
+ local rec = TotemBar.getSpellIndex()[name]
+ return rec and rec.maxRank
+end
+
+-- Mirrors old known.lua scanSpellbook: flat array of every spellbook
+-- entry's name, ONE PER SLOT (a multi-rank totem appears once per rank
+-- it has) -- ShowFlyout's knownTotems() filter and PrintScan's raw dump
+-- both rely on that shape. The by-name index above intentionally
+-- collapses ranks together, so this stays a separate view over the same
+-- cached scan rather than something derived from the index.
+function TotemBar.scanSpellbook()
+ ensureBuilt()
+ local names = {}
+ for i = 1, table.getn(cachedEntries) do
+ names[i] = cachedEntries[i].name
+ end
+ return names
+end
+
+-- SPELLS_CHANGED is the event this codebase already relies on elsewhere
+-- (manacost.lua's manaCache, ui.lua's icon refresh) for "spellbook
+-- changed" -- fires on learning a new spell/rank and on talent changes.
+-- LEARNED_SPELL_IN_TAB was checked and does not exist as a 1.12 client
+-- event (not in this project's confirmed WoW 1.12 event list; it's a
+-- later-expansion API) -- SPELLS_CHANGED alone is the correct signal.
+if CreateFrame then
+ local siEvents = CreateFrame("Frame", "TotemBarSpellIndexEventFrame", UIParent)
+ siEvents:RegisterEvent("SPELLS_CHANGED")
+ siEvents:SetScript("OnEvent", function()
+ if event == "SPELLS_CHANGED" then
+ TotemBar.invalidateSpellIndex()
+ end
+ end)
+end
diff --git a/core/totemdata.lua b/core/totemdata.lua
new file mode 100644
index 0000000..f9d19ac
--- /dev/null
+++ b/core/totemdata.lua
@@ -0,0 +1,161 @@
+-- TotemBar - core/totemdata.lua
+-- PURE data module (no WoW API calls) - offline-testable.
+--
+-- Static element -> totem name map for vanilla shaman totems.
+--
+-- NOTE: TurtleWoW may rename or add totems relative to vanilla 1.12.
+-- Use "/tb scan" in-game to print the player's actual known totem spell
+-- strings and cross-check them against this map before trusting it.
+
+TotemBar = TotemBar or {}
+
+TotemBar.TOTEM_ELEMENTS = { "Fire", "Earth", "Water", "Air" }
+
+TotemBar.TOTEMS_BY_ELEMENT = {
+ Fire = {
+ "Searing Totem",
+ "Fire Nova Totem",
+ "Magma Totem",
+ "Flametongue Totem",
+ "Frost Resistance Totem",
+ },
+ Earth = {
+ "Earthbind Totem",
+ "Stoneclaw Totem",
+ "Stoneskin Totem",
+ "Strength of Earth Totem",
+ "Tremor Totem",
+ },
+ Water = {
+ "Fire Resistance Totem",
+ "Healing Stream Totem",
+ "Mana Spring Totem",
+ "Poison Cleansing Totem",
+ "Disease Cleansing Totem",
+ "Mana Tide Totem",
+ },
+ Air = {
+ "Grace of Air Totem",
+ "Grounding Totem",
+ "Nature Resistance Totem",
+ "Sentry Totem",
+ "Windfury Totem",
+ "Windwall Totem",
+ "Tranquil Air Totem",
+ },
+}
+
+-- Pure: the largest number of totems any single element offers. ui.lua sizes
+-- the hover flyout's icon pool from this. Computed from the data on purpose:
+-- the pool used to be a hard-coded 6, but Air has SEVEN totems, and with the
+-- Air slot empty the flyout lists ALL known totems of the element (not "all
+-- minus the chosen default") -- so the 7th, Tranquil Air Totem, was silently
+-- dropped and could not be cast or made default from the bar.
+function TotemBar.maxTotemsPerElement()
+ local maxN = 0
+ for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
+ local list = TotemBar.TOTEMS_BY_ELEMENT[TotemBar.TOTEM_ELEMENTS[i]]
+ local n = (list and table.getn(list)) or 0
+ if n > maxN then
+ maxN = n
+ end
+ end
+ return maxN
+end
+
+-- Build the reverse lookup (totem name -> element) once at load time.
+local elementByName = {}
+for elemIdx = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
+ local element = TotemBar.TOTEM_ELEMENTS[elemIdx]
+ local list = TotemBar.TOTEMS_BY_ELEMENT[element]
+ for totemIdx = 1, table.getn(list) do
+ elementByName[list[totemIdx]] = element
+ end
+end
+
+-- Returns the element ("Fire"/"Earth"/"Water"/"Air") a totem spell name
+-- belongs to, or nil if the name isn't in the static map.
+function TotemBar.elementOf(name)
+ if not name then
+ return nil
+ end
+ return elementByName[name]
+end
+
+-- Totem lifetime durations (seconds), tuned to TurtleWoW server timers
+-- (mirrors pfUI libtotem's verified values). Keyed by totem spell name;
+-- any totem not listed here falls back to DEFAULT_TOTEM_DURATION.
+-- Searing Totem is rank-aware (see SEARING_TOTEM_DURATIONS below) and
+-- is intentionally NOT listed in this flat table.
+TotemBar.DEFAULT_TOTEM_DURATION = 120
+
+-- Cross-checked 2026-07-16 against pfUI's own libtotem.lua duration table
+-- (Interface\AddOns\pfUI\libs\libtotem.lua, Spell_Fire_SearingTotem entry:
+-- {[-1]=55,[1]=30,[2]=35,[3]=40,[4]=45,[5]=50,[6]=55}) -- byte-identical to
+-- the vanilla values below for every rank. TurtleWoW uses vanilla Searing
+-- Totem timers, not a custom table; nothing was missing here.
+TotemBar.SEARING_TOTEM_DURATIONS = {
+ [1] = 30,
+ [2] = 35,
+ [3] = 40,
+ [4] = 45,
+ [5] = 50,
+ [6] = 55,
+}
+TotemBar.SEARING_TOTEM_MAX_RANK = 6
+TotemBar.SEARING_TOTEM_DEFAULT_DURATION = 55
+
+TotemBar.TOTEM_DURATIONS = {
+ -- Fire
+ ["Magma Totem"] = 20,
+ ["Fire Nova Totem"] = 5,
+ ["Flametongue Totem"] = 120,
+ ["Frost Resistance Totem"] = 120,
+ -- Earth
+ ["Stoneclaw Totem"] = 15,
+ ["Earthbind Totem"] = 45,
+ ["Stoneskin Totem"] = 120,
+ ["Strength of Earth Totem"] = 120,
+ ["Tremor Totem"] = 120,
+ -- Water
+ ["Mana Tide Totem"] = 12,
+ ["Healing Stream Totem"] = 60,
+ ["Mana Spring Totem"] = 60,
+ ["Fire Resistance Totem"] = 120,
+ ["Disease Cleansing Totem"] = 120,
+ ["Poison Cleansing Totem"] = 120,
+ -- Air
+ ["Grounding Totem"] = 45,
+ ["Grace of Air Totem"] = 120,
+ ["Nature Resistance Totem"] = 120,
+ -- 5 minutes (spell 6495, TurtleWoW 1.17.2 spell DB). Was missing here
+ -- entirely and fell through to the 120s default -- but that fallback is
+ -- meant as a safe OVERestimate, and for Sentry it cut the timer 180s
+ -- short. pfUI's libtotem has no Sentry entry either, so GetTotemInfo
+ -- could not correct it.
+ ["Sentry Totem"] = 300,
+ ["Tranquil Air Totem"] = 120,
+ ["Windfury Totem"] = 120,
+ ["Windwall Totem"] = 120,
+}
+
+-- Pure: seconds a totem will remain active for, given its exact spell
+-- name and (for Searing Totem only) the player's highest known rank
+-- number. Unknown/unmapped totems fall back to DEFAULT_TOTEM_DURATION
+-- (a safe overestimate, rather than a timer that disappears too early).
+function TotemBar.totemDuration(name, highestRank)
+ if not name then
+ return TotemBar.DEFAULT_TOTEM_DURATION
+ end
+ if name == "Searing Totem" then
+ if not highestRank or highestRank < 1 then
+ return TotemBar.SEARING_TOTEM_DEFAULT_DURATION
+ end
+ local rank = highestRank
+ if rank > TotemBar.SEARING_TOTEM_MAX_RANK then
+ rank = TotemBar.SEARING_TOTEM_MAX_RANK
+ end
+ return TotemBar.SEARING_TOTEM_DURATIONS[rank] or TotemBar.SEARING_TOTEM_DEFAULT_DURATION
+ end
+ return TotemBar.TOTEM_DURATIONS[name] or TotemBar.DEFAULT_TOTEM_DURATION
+end
diff --git a/minimap.lua b/minimap.lua
new file mode 100644
index 0000000..c5de5b0
--- /dev/null
+++ b/minimap.lua
@@ -0,0 +1,123 @@
+-- TotemBar - minimap.lua
+-- Hand-rolled minimap button (no LibDBIcon on 1.12). Orbits the minimap at a
+-- saved angle, drag-repositions (angle persisted), left-click opens the
+-- options panel, right-click toggles the bar. pfUI-safe: parent = Minimap,
+-- name contains "Minimap", FrameStrata HIGH + level 9 (MEDIUM hides under
+-- pfUI). /tb options is the guaranteed access path if pfUI collects/hides
+-- the button. WoW-API file (parse-checked only).
+
+TotemBar = TotemBar or {}
+
+local button = nil
+
+-- Custom icon sheet (tools/gen_icons.js, same texture ui.lua's
+-- ICONS_TEXTURE points at): 4x4 grid of 128px cells, cell 7 = minimap
+-- totem glyph (transparent backing). core/pulse.lua loads before this
+-- file (see the .toc), so buildRingTexCoords is already available; built
+-- once here at load, not per-tick.
+local MINIMAP_ICON_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\icons"
+local MINIMAP_ICON_CELL = 7
+local minimapIconCoords = TotemBar.buildRingTexCoords(16, 4)
+
+-- Repositions the button on its orbit for the given angle (degrees).
+local function PlaceButton(angleDeg)
+ if not button then
+ return
+ end
+ local radius = (Minimap:GetWidth() / 2) + 5
+ local x, y = TotemBar.angleToOffset(angleDeg, radius)
+ button:ClearAllPoints()
+ button:SetPoint("CENTER", Minimap, "CENTER", x, y)
+end
+
+local function BuildMinimapButton()
+ if button then
+ return
+ end
+
+ local btn = CreateFrame("Button", "TotemBarMinimapButton", Minimap)
+ btn:SetWidth(33)
+ btn:SetHeight(33)
+ btn:SetFrameStrata("HIGH")
+ btn:SetFrameLevel(9)
+
+ local icon = btn:CreateTexture(nil, "ARTWORK")
+ icon:SetTexture(MINIMAP_ICON_TEXTURE)
+ local ic = minimapIconCoords[MINIMAP_ICON_CELL]
+ icon:SetTexCoord(ic.l, ic.r, ic.t, ic.b)
+ icon:SetWidth(20)
+ icon:SetHeight(20)
+ icon:SetPoint("CENTER", btn, "CENTER", 0, 0)
+
+ local border = btn:CreateTexture(nil, "OVERLAY")
+ border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
+ -- Vanilla default-UI recipe (MiniMapTrackingButton in FrameXML): a 54px
+ -- border anchored TOPLEFT(0,0) on a 33px button. The ring is deliberately
+ -- off-centre WITHIN the texture, so it lands concentric with the icon at
+ -- this exact anchor/size - do NOT "centre" it (that pushes the ring off).
+ border:SetWidth(54)
+ border:SetHeight(54)
+ border:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
+
+ btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
+
+ btn.angle = TotemBarDB.minimapAngle or 225
+
+ -- Drag (repositions around the orbit). Drag needs the movement threshold,
+ -- so a plain click still fires OnClick; the two coexist (verified 1.12
+ -- pattern).
+ btn:RegisterForDrag("LeftButton")
+ btn.isDragging = false
+ btn:SetScript("OnDragStart", function() this.isDragging = true end)
+ btn:SetScript("OnDragStop", function()
+ this.isDragging = false
+ TotemBarDB.minimapAngle = this.angle
+ end)
+ btn:SetScript("OnUpdate", function()
+ if not this.isDragging then
+ return
+ end
+ local mx, my = GetCursorPosition()
+ local scale = Minimap:GetEffectiveScale()
+ mx = mx / scale
+ my = my / scale
+ local cx, cy = Minimap:GetCenter()
+ this.angle = math.deg(math.atan2(my - cy, mx - cx))
+ PlaceButton(this.angle)
+ end)
+
+ -- Clicks: left = options, right = toggle bar.
+ btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+ btn:SetScript("OnClick", function()
+ if arg1 == "RightButton" then
+ if TotemBar.ToggleBar then TotemBar.ToggleBar() end
+ else
+ if TotemBar.ToggleOptions then TotemBar.ToggleOptions() end
+ end
+ end)
+
+ btn:SetScript("OnEnter", function()
+ GameTooltip:SetOwner(this, "ANCHOR_LEFT")
+ GameTooltip:SetText("TotemBar")
+ GameTooltip:AddLine("Left-click: Options", 0.9, 0.9, 0.9)
+ GameTooltip:AddLine("Right-click: Toggle bar", 0.9, 0.9, 0.9)
+ GameTooltip:AddLine("Drag: reposition", 0.9, 0.9, 0.9)
+ GameTooltip:Show()
+ end)
+ btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
+
+ button = btn
+ PlaceButton(btn.angle)
+end
+
+-- Build on ADDON_LOADED (SavedVariables are populated by then, same as the
+-- bar). Guarded; never on PLAYER_ENTERING_WORLD (would re-run and leak
+-- textures).
+local ev = CreateFrame("Frame", "TotemBarMinimapEventFrame", UIParent)
+ev:RegisterEvent("ADDON_LOADED")
+ev:SetScript("OnEvent", function()
+ if event == "ADDON_LOADED" and arg1 == "TotemBar" then
+ BuildMinimapButton()
+ ev:UnregisterEvent("ADDON_LOADED")
+ end
+end)
diff --git a/options.lua b/options.lua
new file mode 100644
index 0000000..dc11a94
--- /dev/null
+++ b/options.lua
@@ -0,0 +1,456 @@
+-- TotemBar - options.lua
+-- Standalone options panel (no Blizzard InterfaceOptions integration). Built
+-- once (lazy) on first ToggleOptions; repopulated from TotemBarDB on show.
+-- Opened by the minimap button's left-click and by /tb options. WoW-API
+-- file (parse-checked only). Widget wiring uses 1.12 templates:
+-- UICheckButtonTemplate (GetChecked() -> 1/nil) and OptionsSliderTemplate
+-- (SetMinMaxValues before SetValue; $parentLow/$parentHigh/$parentText).
+
+TotemBar = TotemBar or {}
+
+local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
+local optionsFrame = nil
+
+-- Applies pfUI's font to a FontString (the main "matches pfUI" tweak) at the
+-- given size, or falls back to a small game font when pfUI is absent.
+local function ApplyFont(fontString, size)
+ if not fontString then
+ return
+ end
+ if pfUI and pfUI.font_default then
+ local sz = size or (pfUI_config and pfUI_config.global and pfUI_config.global.font_size) or 12
+ fontString:SetFont(pfUI.font_default, sz)
+ else
+ fontString:SetFontObject(GameFontNormalSmall)
+ end
+end
+
+-- Adds a wrapped explanatory GameTooltip on hover. Chains any existing
+-- OnEnter/OnLeave (e.g. pfUI's SkinButton hover-highlight) instead of
+-- replacing it, so skinning still works. No HookScript on 1.12.
+local function AddTooltip(widget, text)
+ if not widget or not text then
+ return
+ end
+ widget.tbTip = text
+ local oldEnter = widget:GetScript("OnEnter")
+ local oldLeave = widget:GetScript("OnLeave")
+ widget:SetScript("OnEnter", function()
+ if oldEnter then oldEnter() end
+ GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
+ GameTooltip:SetText(this.tbTip, 1, 1, 1, 1, 1)
+ GameTooltip:Show()
+ end)
+ widget:SetScript("OnLeave", function()
+ if oldLeave then oldLeave() end
+ GameTooltip:Hide()
+ end)
+end
+
+-- Unique widget-name counters (template child labels resolve via getglobal,
+-- so every widget needs a non-nil, unique frame name).
+local cbIndex = 0
+local slIndex = 0
+
+-- Factory: a labeled checkbox wired to getter/setter. getter()->boolean,
+-- setter(boolean). Returns the CheckButton.
+local function CreateCheckbox(parent, label, getter, setter)
+ cbIndex = cbIndex + 1
+ local name = "TotemBarOptCheck" .. cbIndex
+ local cb = CreateFrame("CheckButton", name, parent, "UICheckButtonTemplate")
+ cb:SetWidth(24)
+ cb:SetHeight(24)
+ local lbl = getglobal(name .. "Text")
+ if lbl then
+ lbl:SetText(label)
+ ApplyFont(lbl)
+ end
+ cb.tbGet = getter
+ cb.tbSet = setter
+ cb:SetScript("OnClick", function()
+ this.tbSet(this:GetChecked() == 1)
+ end)
+ if pfUI and pfUI.api and pfUI.api.SkinCheckbox then
+ pfUI.api.SkinCheckbox(cb)
+ end
+ return cb
+end
+
+-- Factory: a labeled slider wired to getter/setter over [minVal,maxVal] in
+-- `step` increments. `fmt` is a printf pattern for the live value text
+-- (e.g. "UI size: %.2f"). Returns the Slider.
+local function CreateSlider(parent, label, minVal, maxVal, step, fmt, getter, setter)
+ slIndex = slIndex + 1
+ local name = "TotemBarOptSlider" .. slIndex
+ local sl = CreateFrame("Slider", name, parent, "OptionsSliderTemplate")
+ sl:SetWidth(200)
+ sl:SetHeight(16)
+ sl:SetMinMaxValues(minVal, maxVal) -- BEFORE SetValue, else clamps to 0
+ sl:SetValueStep(step)
+ local low, high, txt = getglobal(name .. "Low"), getglobal(name .. "High"), getglobal(name .. "Text")
+ if low then low:SetText(tostring(minVal)); ApplyFont(low) end
+ if high then high:SetText(tostring(maxVal)); ApplyFont(high) end
+ if txt then
+ -- Left-align the value label above the slider (the template centers it),
+ -- so it lines up with the left-aligned checkbox labels. pfUI font.
+ txt:ClearAllPoints()
+ txt:SetPoint("BOTTOMLEFT", sl, "TOPLEFT", 0, 2)
+ txt:SetJustifyH("LEFT")
+ ApplyFont(txt)
+ end
+ sl.tbLabel = label
+ sl.tbFmt = fmt
+ sl.tbText = txt
+ sl.tbSet = setter
+ sl.tbGet = getter
+ sl:SetScript("OnValueChanged", function()
+ local v = this:GetValue()
+ if this.tbText then
+ this.tbText:SetText(string.format(this.tbFmt, v))
+ end
+ this.tbSet(v)
+ end)
+ if pfUI and pfUI.api and pfUI.api.SkinSlider then
+ pfUI.api.SkinSlider(sl)
+ end
+ return sl
+end
+
+-- Label text for the bar-layout cycle button, reflecting the current choice.
+local function BarLayoutLabel()
+ return "Layout: " .. (TotemBarDB.barLayout or "1x6")
+end
+
+-- Factory: a labeled push button wired to an onClick. Returns the Button.
+-- Named (project convention: name all frames) so the pfDebug profiler can attribute it.
+local function CreateButton(parent, name, label, onClick)
+ local btn = CreateFrame("Button", name, parent, "UIPanelButtonTemplate")
+ btn:SetWidth(140)
+ btn:SetHeight(22)
+ btn:SetText(label)
+ btn:SetScript("OnClick", onClick)
+ if pfUI and pfUI.api and pfUI.api.SkinButton then
+ pfUI.api.SkinButton(btn)
+ end
+ return btn
+end
+
+-- Factory: a static hairline divider spanning the panel's content width at
+-- cursor position y (Rev 4 UI chrome, section-break decoration). Anonymous
+-- texture - it's a static decoration, not an interactive/named widget.
+local function CreateDivider(parent, y)
+ local d = parent:CreateTexture(nil, "ARTWORK")
+ d:SetTexture(TotemBar.UI_TEXTURE)
+ local c = TotemBar.uiCoords[TotemBar.UI_DIVIDER_CELL]
+ d:SetTexCoord(c.l, c.r, c.t, c.b)
+ d:SetPoint("TOPLEFT", parent, "TOPLEFT", 14, y)
+ d:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -14, y)
+ d:SetHeight(7)
+ return d
+end
+
+-- All widgets, stored so OnShow can repopulate them from TotemBarDB.
+local widgets = {}
+
+local function BuildOptionsFrame()
+ if optionsFrame then
+ return optionsFrame
+ end
+
+ local f = CreateFrame("Frame", "TotemBarOptionsFrame", UIParent)
+ f:SetWidth(280)
+ -- 472 + 44 + 28 + 28 + 44 + 28: room for the header emblem (+16, taller
+ -- than the old title-only header), two section dividers (+14 each), the
+ -- "Show pulse waves" checkbox row (+28, the pulse ring's countdown
+ -- readout sits alongside the ripple), the bar-layout cycle button row
+ -- (+28), the "Button spacing" slider row (+44, same slider-row
+ -- height as guard/gap/scale above it), and the "Show drop-all button"
+ -- checkbox row (+28) - see the layout-cursor deltas below (same
+ -- mechanism as the Task-5 height bump). Not cosmetic: without the last
+ -- +28 the "Key bind mode" button would land on the version footer.
+ f:SetHeight(628)
+ f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+ f:SetFrameStrata("DIALOG")
+ -- Rev 4 UI chrome: shared bespoke panel skin (see ui.lua's PANEL_BACKDROP)
+ -- instead of the Blizzard dialog backdrop or pfUI's generic skin, so the
+ -- options panel matches the bar's own art (emblem/dividers below pair
+ -- with this border/bg specifically).
+ f:SetBackdrop(TotemBar.PANEL_BACKDROP)
+ f:SetBackdropColor(1, 1, 1, 0.97)
+ f:SetMovable(true)
+ f:EnableMouse(true)
+ f:RegisterForDrag("LeftButton")
+ f:SetScript("OnDragStart", function() this:StartMoving() end)
+ f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
+ f:SetClampedToScreen(true)
+
+ -- Plain gold title, no emblem (the Rev-4 totem glyph was removed on
+ -- user request - the silhouette read, well, unfortunate).
+ local title = f:CreateFontString("TotemBarOptionsTitle", "OVERLAY", "GameFontNormal")
+ title:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -16)
+ title:SetText("TotemBar Options")
+ ApplyFont(title, 14)
+ title:SetTextColor(0.85, 0.66, 0.31)
+
+ local close = CreateFrame("Button", "TotemBarOptionsClose", f, "UIPanelCloseButton")
+ close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8)
+ if pfUI and pfUI.api and pfUI.api.SkinCloseButton then
+ pfUI.api.SkinCloseButton(close, f)
+ end
+
+ -- Layout cursor (back to the title-only header spacing).
+ local x = 24
+ local y = -44
+ local function place(w, dy)
+ w:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
+ y = y + dy
+ end
+
+ -- Checkboxes.
+ widgets.lock = CreateCheckbox(f, "Lock bar",
+ function() return TotemBarDB.locked end,
+ function(v) TotemBarDB.locked = v end)
+ place(widgets.lock, -28)
+ AddTooltip(widgets.lock, "Locks the bar in place so you can't drag it by accident. Uncheck to reposition it.")
+
+ widgets.autoRecall = CreateCheckbox(f, "Auto-recall before setting",
+ function() return TotemBarDB.autoRecall end,
+ function(v)
+ TotemBarDB.autoRecall = v
+ if TotemBar.RefreshRecallIndicator then TotemBar.RefreshRecallIndicator() end
+ end)
+ place(widgets.autoRecall, -28)
+ AddTooltip(widgets.autoRecall, "When on, the Totems macro casts Totemic Recall before re-dropping your totems (refunds some mana when relocating).")
+
+ widgets.show = CreateCheckbox(f, "Show bar",
+ function() return not TotemBarDB.hidden end,
+ function(v)
+ if v then
+ TotemBarDB.hidden = false
+ if TotemBarFrame then TotemBarFrame:Show() end
+ else
+ TotemBarDB.hidden = true
+ if TotemBarFrame then TotemBarFrame:Hide() end
+ end
+ end)
+ place(widgets.show, -28)
+ AddTooltip(widgets.show, "Shows or hides the totem bar. The choice is remembered between sessions.")
+
+ -- Same kind of switch as "Show bar" above (visibility only, not a
+ -- feature toggle), hence the same group. ApplyBarLayout hides/shows the
+ -- button AND re-lays out the grid, so the bar resizes immediately -
+ -- no /reload needed.
+ widgets.dropSet = CreateCheckbox(f, "Show drop-all button",
+ function() return TotemBarDB.showDropSetButton end,
+ function(v)
+ TotemBarDB.showDropSetButton = v
+ if TotemBar.ApplyBarLayout then TotemBar.ApplyBarLayout() end
+ end)
+ place(widgets.dropSet, -40)
+ AddTooltip(widgets.dropSet, "Shows or hides the 'Drop all totems' button on the bar. Its key binding keeps working while it is hidden - rebind it under Key Bindings in the game menu.")
+
+ widgets.ring = CreateCheckbox(f, "Show duration ring",
+ function() return TotemBarDB.showDurationRing end,
+ function(v)
+ TotemBarDB.showDurationRing = v
+ if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
+ end)
+ place(widgets.ring, -28)
+ AddTooltip(widgets.ring, "Shows a colored arc on each button's ring that empties as the totem's remaining time runs out. The ring frame itself always stays visible.")
+
+ widgets.pulse = CreateCheckbox(f, "Show pulse ring",
+ function() return TotemBarDB.showPulseBars end,
+ function(v)
+ TotemBarDB.showPulseBars = v
+ if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
+ end)
+ place(widgets.pulse, -28)
+ AddTooltip(widgets.pulse, "Shows a thin inner ring around the icon that fills up toward the totem's next pulse, then resets.")
+
+ widgets.pulseWaves = CreateCheckbox(f, "Show pulse waves",
+ function() return TotemBarDB.showPulseWaves end,
+ function(v)
+ TotemBarDB.showPulseWaves = v
+ if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
+ end)
+ place(widgets.pulseWaves, -28)
+ AddTooltip(widgets.pulseWaves, "Shows an expanding ripple in the totem's color each time a pulsing totem fires.")
+
+ widgets.timerText = CreateCheckbox(f, "Show countdown text",
+ function() return TotemBarDB.showTimerText end,
+ function(v)
+ TotemBarDB.showTimerText = v
+ if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
+ end)
+ place(widgets.timerText, -28)
+ AddTooltip(widgets.timerText, "Shows the remaining-seconds text under each element button (the ring shows the same information graphically).")
+
+ -- Bar-layout cycle button: click steps through TotemBar.BAR_LAYOUTS
+ -- (wrapping), saves the choice, and re-lays out the live bar.
+ widgets.layout = CreateButton(f, "TotemBarOptLayoutButton", BarLayoutLabel(), function()
+ local layouts = TotemBar.BAR_LAYOUTS
+ local n = table.getn(layouts)
+ local current = TotemBarDB.barLayout or "1x6"
+ local currentIdx = 1
+ for i = 1, n do
+ if layouts[i] == current then
+ currentIdx = i
+ end
+ end
+ local nextIdx = math.mod(currentIdx, n) + 1
+ TotemBarDB.barLayout = layouts[nextIdx]
+ this:SetText(BarLayoutLabel())
+ if TotemBar.ApplyBarLayout then
+ TotemBar.ApplyBarLayout()
+ end
+ end)
+ place(widgets.layout, -28)
+ AddTooltip(widgets.layout, "Cycles the bar arrangement: one row of six, two rows of three, or three rows of two.")
+
+ -- Section divider (Rev 4 UI chrome): checkboxes above, sliders below.
+ CreateDivider(f, y)
+ y = y - 14
+
+ -- Sliders (leave headroom below each for its low/high/value text).
+ widgets.guard = CreateSlider(f, "Recall guard (sec)", 0, 5, 0.5, "Recall guard: %.1fs",
+ function() return TotemBarDB.recallGuardSeconds end,
+ function(v) TotemBarDB.recallGuardSeconds = v end)
+ place(widgets.guard, -44)
+ AddTooltip(widgets.guard, "A second press of the Totems macro within this many seconds skips Totemic Recall, so a double-press won't pull the totems you just dropped. 0 = no guard.")
+
+ widgets.gap = CreateSlider(f, "Cycle reset gap (sec)", 0.5, 5, 0.5, "Cycle gap: %.1fs",
+ function() return TotemBarDB.gapSeconds end,
+ function(v) TotemBarDB.gapSeconds = v end)
+ place(widgets.gap, -44)
+ AddTooltip(widgets.gap, "How long a pause (in seconds) restarts the one-per-press cast cycle back at the first totem.")
+
+ widgets.scale = CreateSlider(f, "UI size", 0.5, 2.0, 0.05, "UI size: %.2f",
+ function() return TotemBarDB.scale end,
+ function(v)
+ if TotemBar.SetBarScale then
+ TotemBar.SetBarScale(v)
+ elseif TotemBarFrame then
+ TotemBarDB.scale = v
+ TotemBarFrame:SetScale(v)
+ end
+ end)
+ place(widgets.scale, -44)
+ AddTooltip(widgets.scale, "Scales the whole bar. It stays anchored at its top-left corner, and the flyout scales with it.")
+
+ widgets.buttonGap = CreateSlider(f, "Button spacing", 10, 30, 1, "Spacing: %.0fpx",
+ function() return TotemBarDB.buttonGap end,
+ function(v)
+ if TotemBar.SetButtonGap then
+ TotemBar.SetButtonGap(v)
+ else
+ TotemBarDB.buttonGap = v
+ end
+ end)
+ place(widgets.buttonGap, -44)
+ AddTooltip(widgets.buttonGap, "Distance between the bar buttons. Applies immediately.")
+
+ -- Section divider (Rev 4 UI chrome): sliders above, action buttons below.
+ CreateDivider(f, y)
+ y = y - 14
+
+ optionsFrame = f
+ -- Buttons (Reset position, Create macro) are added in Task 4.
+ if TotemBar.BuildOptionsButtons then
+ TotemBar.BuildOptionsButtons(f, x, y)
+ end
+
+ -- Repopulate every widget from TotemBarDB whenever the panel shows.
+ f:SetScript("OnShow", function()
+ widgets.lock:SetChecked(TotemBarDB.locked and 1 or nil)
+ widgets.autoRecall:SetChecked(TotemBarDB.autoRecall and 1 or nil)
+ widgets.show:SetChecked((not TotemBarDB.hidden) and 1 or nil)
+ widgets.dropSet:SetChecked(TotemBarDB.showDropSetButton and 1 or nil)
+ widgets.ring:SetChecked(TotemBarDB.showDurationRing and 1 or nil)
+ widgets.pulse:SetChecked(TotemBarDB.showPulseBars and 1 or nil)
+ widgets.pulseWaves:SetChecked(TotemBarDB.showPulseWaves and 1 or nil)
+ widgets.timerText:SetChecked(TotemBarDB.showTimerText and 1 or nil)
+ widgets.layout:SetText(BarLayoutLabel())
+ widgets.guard:SetValue(TotemBar.clampValue(TotemBarDB.recallGuardSeconds, 0, 5))
+ widgets.gap:SetValue(TotemBar.clampValue(TotemBarDB.gapSeconds, 0.5, 5))
+ widgets.scale:SetValue(TotemBar.clampValue(TotemBarDB.scale, 0.5, 2.0))
+ widgets.buttonGap:SetValue(TotemBar.clampValue(TotemBarDB.buttonGap, 10, 30))
+ end)
+
+ -- Version footer (bottom-centre), read from the .toc so it always matches
+ -- the real addon version instead of a hardcoded duplicate.
+ local ver = (GetAddOnMetadata and GetAddOnMetadata("TotemBar", "Version")) or "0.1.0"
+ local verFS = f:CreateFontString("TotemBarOptionsVersion", "OVERLAY")
+ ApplyFont(verFS, 10)
+ verFS:SetPoint("BOTTOM", f, "BOTTOM", 0, 6)
+ verFS:SetText("v" .. ver)
+ verFS:SetTextColor(0.6, 0.6, 0.6)
+
+ -- ESC closes it.
+ tinsert(UISpecialFrames, "TotemBarOptionsFrame")
+
+ f:Hide()
+ return f
+end
+
+-- Creates or updates the "Totems" convenience macro from TotemBar.macroSpec.
+-- General (account) macro, capped at 18 in 1.12; if the cap is full and no
+-- "Totems" macro exists yet, reports and does nothing. Never auto-places it
+-- on the action bar (no 1.12 API for that - the player drags it).
+local function ApplyTotemsMacro()
+ local name, body, icon = TotemBar.macroSpec()
+ local existing = GetMacroIndexByName(name)
+ if existing and existing > 0 then
+ EditMacro(existing, name, icon, body, 1, nil)
+ ChatOut:AddMessage("TotemBar: '" .. name .. "' macro updated - drag it to your action bar.")
+ return
+ end
+ local numGlobal = GetNumMacros() -- returns global, perChar in 1.12
+ if numGlobal and numGlobal >= 18 then
+ ChatOut:AddMessage("TotemBar: macro slots full (18) - free one and retry.")
+ return
+ end
+ CreateMacro(name, icon, body, nil)
+ ChatOut:AddMessage("TotemBar: '" .. name .. "' macro created - drag it to your action bar.")
+end
+
+-- Adds the Reset-position and Create-macro buttons to the options frame.
+-- Called by BuildOptionsFrame (Task 3) after the sliders, at layout cursor
+-- (x, yStart). Kept separate so the panel body and the action buttons are
+-- two focused units.
+function TotemBar.BuildOptionsButtons(f, x, yStart)
+ -- Full content width (frame width minus the left/right margin x), so the
+ -- buttons read as a balanced block instead of a narrow left-biased pair.
+ local w = f:GetWidth() - (x * 2)
+
+ local reset = CreateButton(f, "TotemBarOptResetButton", "Reset position", function()
+ if TotemBar.ResetPosition then TotemBar.ResetPosition() end
+ end)
+ reset:SetWidth(w)
+ reset:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart)
+ AddTooltip(reset, "Moves the bar back to the center of the screen.")
+
+ local macro = CreateButton(f, "TotemBarOptMacroButton", "Create 'Totems' macro", function()
+ ApplyTotemsMacro()
+ end)
+ macro:SetWidth(w)
+ macro:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart - 28)
+ AddTooltip(macro, "Creates or updates a 'Totems' macro that drops all four chosen totems in one press. Drag it from the macro window to your action bar.")
+
+ local bind = CreateButton(f, "TotemBarOptBindButton", "Key bind mode", function()
+ if TotemBar.ToggleBindMode then TotemBar.ToggleBindMode() end
+ end)
+ bind:SetWidth(w)
+ bind:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart - 56)
+ AddTooltip(bind, "Toggle bind mode, then hover any bar button or a flyout totem and press a key to bind it. ESC clears. Bindings are saved automatically.")
+end
+
+-- Show/hide the options panel (builds it lazily). Left-click on the minimap
+-- button and /tb options both call this.
+function TotemBar.ToggleOptions()
+ local f = BuildOptionsFrame()
+ if f:IsShown() then
+ f:Hide()
+ else
+ f:Show()
+ end
+end
diff --git a/screenshots/bar.png b/screenshots/bar.png
new file mode 100644
index 0000000..697eaeb
Binary files /dev/null and b/screenshots/bar.png differ
diff --git a/screenshots/bind-mode.png b/screenshots/bind-mode.png
new file mode 100644
index 0000000..6823b4a
Binary files /dev/null and b/screenshots/bind-mode.png differ
diff --git a/screenshots/flyout.png b/screenshots/flyout.png
new file mode 100644
index 0000000..0d1082b
Binary files /dev/null and b/screenshots/flyout.png differ
diff --git a/screenshots/keybindings.png b/screenshots/keybindings.png
new file mode 100644
index 0000000..8fbc259
Binary files /dev/null and b/screenshots/keybindings.png differ
diff --git a/screenshots/options.png b/screenshots/options.png
new file mode 100644
index 0000000..1abf87a
Binary files /dev/null and b/screenshots/options.png differ
diff --git a/screenshots/pulse-ui.png b/screenshots/pulse-ui.png
new file mode 100644
index 0000000..28b5692
Binary files /dev/null and b/screenshots/pulse-ui.png differ
diff --git a/textures/icons.tga b/textures/icons.tga
new file mode 100644
index 0000000..21da641
Binary files /dev/null and b/textures/icons.tga differ
diff --git a/textures/panel_bg.tga b/textures/panel_bg.tga
new file mode 100644
index 0000000..4ad5562
Binary files /dev/null and b/textures/panel_bg.tga differ
diff --git a/textures/panel_border.tga b/textures/panel_border.tga
new file mode 100644
index 0000000..0b292de
Binary files /dev/null and b/textures/panel_border.tga differ
diff --git a/textures/ring_fx.tga b/textures/ring_fx.tga
new file mode 100644
index 0000000..5fe74e1
Binary files /dev/null and b/textures/ring_fx.tga differ
diff --git a/textures/ring_pulsearc.tga b/textures/ring_pulsearc.tga
new file mode 100644
index 0000000..f295547
Binary files /dev/null and b/textures/ring_pulsearc.tga differ
diff --git a/textures/ring_round.tga b/textures/ring_round.tga
new file mode 100644
index 0000000..a183c48
Binary files /dev/null and b/textures/ring_round.tga differ
diff --git a/textures/ui.tga b/textures/ui.tga
new file mode 100644
index 0000000..14d1d63
Binary files /dev/null and b/textures/ui.tga differ
diff --git a/ui.lua b/ui.lua
new file mode 100644
index 0000000..f44b4d7
--- /dev/null
+++ b/ui.lua
@@ -0,0 +1,2683 @@
+-- TotemBar - ui.lua
+-- The totem bar frame: 4 element buttons plus a Totemic Recall button.
+-- Left-click an element button to cast its chosen totem; right-click
+-- clears the slot. Hovering an element button pops an upward flyout of
+-- the element's other known totems: left-click one to cast it once,
+-- right-click one to set it as the slot's new default. Each element
+-- button also carries an OmniCC-style remaining-duration timer text (hybrid
+-- source: pfUI libtotem's GetTotemInfo when present, else TotemBar's
+-- own cast-tracking - see core/cast.lua). No per-button text labels;
+-- hover the button for a tooltip naming the element/totem. Each element
+-- button also carries a native action-button-style radial cooldown
+-- swipe (CooldownFrameTemplate) reflecting the chosen totem's spellbook
+-- cooldown, IN ADDITION to the duration timer text. The Recall button's
+-- icon pulses whenever any element totem is currently out of range.
+-- WoW-API-only file; not offline-tested, only syntax-checked (see
+-- tools/luatests notes in the repo).
+
+TotemBar = TotemBar or {}
+
+-- Defensive fallback in case DEFAULT_CHAT_FRAME is ever unset this early
+-- in the loading sequence (guards against a later/leaner client build).
+local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
+
+local BUTTON_SIZE = 36
+-- 10 (was 4): the 48px round ring frames overhang their 36px buttons by
+-- 6px per side; adjacent opaque ring bands (screen radius ~22.9px) need
+-- button centers >= ~45.8px apart to not overlap. 36+10 = 46 clears it,
+-- for the bar row and the flyout column alike. Now a live-adjustable
+-- UPVALUE (not a constant): the options panel's "Button spacing" slider
+-- (range 10-30px, TotemBarDB.buttonGap) reassigns this in place via
+-- TotemBar.SetButtonGap below - every closure that reads BUTTON_GAP
+-- (element/flyout/assign-panel positioning, ApplyBarLayout) sees the new
+-- value on its next read. 10px is still the floor: below that, adjacent
+-- ring bands start to overlap per the geometry above.
+local BUTTON_GAP = TotemBar.DEFAULT_BUTTON_GAP or 10
+
+-- Bar-layout grid (feature: selectable bar arrangement, TotemBarDB.barLayout,
+-- see ApplyBarLayout below). Maps each saved layout string to its column
+-- count; row count then falls out of math.ceil(buttonCount / cols). Cycle
+-- order for the options panel's cycle button lives in TotemBar.BAR_LAYOUTS.
+local BAR_LAYOUT_COLS = { ["1x6"] = 6, ["2x3"] = 3, ["3x2"] = 2 }
+TotemBar.BAR_LAYOUTS = { "1x6", "2x3", "3x2" }
+
+-- Rows use the SAME pitch as columns (user request: tight grid). The timer
+-- text below a row slightly overlaps the next row's ring overhang; it draws
+-- on the OVERLAY layer above it and stays readable.
+
+-- Timer-text OnUpdate throttle: refresh at most this often (seconds),
+-- not every frame, to keep the shared/guild-addon perf budget sane.
+-- 0.1s (was 0.2): the same tick also refreshes the ripple wave INPUTS (the
+-- wave animation itself runs every frame in UpdateWaves, driven off these).
+-- Still allocation-free per tick.
+local TIMER_UPDATE_INTERVAL = 0.1
+local timerElapsed = 0
+
+-- Destroyed-totem liveness poll throttle (see TotemBar.GUID_LIVENESS_POLL_INTERVAL,
+-- core/cast.lua): a SLOWER gate layered on top of the 0.1s tick above, so
+-- UnitExists/UnitHealth/UnitIsDeadOrGhost are only actually called once
+-- every ~0.5s even though UpdateTimerDisplays itself runs 10x/sec. GetTime()-based
+-- rather than an arg1 accumulator since UpdateTimerDisplays has no frame
+-- delta of its own to accumulate.
+local lastGuidLivenessCheck = nil
+
+-- Fallback icon for an unresolved/unknown totem name (flyout icons,
+-- ResolveTotemIcon, the pending-assignment panel). Element buttons' own
+-- empty-slot state uses the custom sheet's per-element glyph instead
+-- (see SetElementIcon/ELEMENT_EMPTY_CELL above), not this constant.
+local EMPTY_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
+
+-- Totemic Recall: exact icon is verified at button-creation time via a
+-- spellbook scan (GetSpellTexture); this is only the last-resort
+-- fallback if that scan fails to resolve a texture.
+local RECALL_SPELL_NAME = "Totemic Recall"
+local RECALL_ICON_FALLBACK = "Interface\\Icons\\Spell_Nature_AstralRecal"
+
+-- Pulse UI - floating round icons + ripple pulse.
+-- Ring flipbook: 8x8 grid texture (64 cells). Cells 1..62 = duration-arc
+-- fill frames (0=empty .. 61=full), cell 63 = wave ring (ripple), cell 64 =
+-- the decorative frame band - always shown, hides the icon's square
+-- corners so it reads as a floating round icon. Texture generated by
+-- tools/gen_ring_textures.js; path WITHOUT extension (client resolves
+-- .blp/.tga).
+local RING_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_round"
+local RING_FRAMES = 62
+local RING_GRID_COLS = 8
+-- Cell 64 = the permanent decorative frame band (now baked-in shadow look,
+-- see the art contract in the Rev 6 texture generator) - always shown,
+-- hides the icon's square corners so it reads as a floating round icon.
+-- (Rev 5's user-selectable ring-skin picker, which swapped this cell for a
+-- separate ring_tracks.tga, was reverted per user feedback - see
+-- ApplyRoundFrame below.)
+local RING_WAVE_CELL = 63
+local RING_TRACK_CELL = 64
+-- Built once at load (core/pulse.lua loads before ui.lua, see .toc); the
+-- per-tick path only indexes this table.
+local allRingCoords = TotemBar.buildRingTexCoords(64, RING_GRID_COLS)
+
+-- Screen px the ring/wave textures are drawn at, centered on the 36px
+-- button - the ring extends past the button's own edges so the decorative
+-- frame band's opaque interior fully covers the icon's square corners (see
+-- the texture generator's geometry rationale comment).
+local RING_TEX_SIZE = 48
+local WAVE_DURATION = 0.8 -- ripple lifetime, seconds
+local WAVE_MAX_SCALE = 1.9 -- wave grows from RING_TEX_SIZE to *WAVE_MAX_SCALE
+local WAVE_BASE_ALPHA = 0.85 -- wave alpha at spawn, fades to 0 by WAVE_DURATION
+
+-- Circular pulse countdown (Rev 6): the linear pulse bar (Rev 1) was
+-- replaced with a thin white inner arc drawn ON the band itself, since a
+-- round readout fits the round slots better than a straight bar (user
+-- feedback). Same flipbook mechanics as the duration ring (ringFill) above,
+-- just a separate all-white texture so it reads distinctly from the
+-- element-colored duration arc. Independently toggleable
+-- (TotemBarDB.showPulseBars, key name kept from the old linear bar - no
+-- migration needed) from the ripple wave (TotemBarDB.showPulseWaves).
+local PULSEARC_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_pulsearc"
+
+-- Pulse UI Rev 3: anticipation glow + spawn ripple + round hover/pushed
+-- click states, all drawn from one FX sheet (tools/gen_ring_textures.js):
+-- 2x2 grid of 128px cells. Cell 1 = annular anticipation halo, cell 2 =
+-- round hover ring, cell 3 = pushed press disc, cell 4 = spawn flash
+-- (reserved). Path WITHOUT extension, same convention as RING_TEXTURE.
+local FX_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_fx"
+local fxCoords = TotemBar.buildRingTexCoords(4, 2)
+local FX_GLOW_CELL = 1 -- annular anticipation halo (drawn at 64px)
+local FX_HOVER_CELL = 2 -- round hover ring (drawn at RING_TEX_SIZE)
+local FX_PUSHED_CELL = 3 -- press disc (drawn at RING_TEX_SIZE)
+local FX_FLASH_CELL = 4 -- reserved (spawn flash disc)
+local GLOW_TEX_SIZE = 64
+local GLOW_RAMP_START = 0.78 -- glow builds over the last 22% before a pulse
+local GLOW_MAX_ALPHA = 0.7
+local SPAWN_WAVE_DURATION = 0.6
+
+-- Custom icon sheet (tools/gen_icons.js): 4x4 grid of 128px cells.
+local ICONS_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\icons"
+local iconCoords = TotemBar.buildRingTexCoords(16, 4)
+local ELEMENT_EMPTY_CELL = { Fire = 1, Earth = 2, Water = 3, Air = 4 }
+-- cell 5 reserved (custom recall icon, reverted per user feedback)
+local ICON_DROPSET_CELL = 6
+local ICON_CROP = 0.08 -- standard action-button crop for real spell icons
+
+-- UI chrome sheet + panel skin (tools/gen_ui_textures.js).
+local UI_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ui"
+local uiCoords = TotemBar.buildRingTexCoords(4, 2)
+local UI_SHADOW_CELL = 1
+local UI_EMBLEM_CELL = 2
+local UI_DIVIDER_CELL = 3
+local PANEL_BACKDROP = {
+ bgFile = "Interface\\AddOns\\TotemBar\\textures\\panel_bg",
+ edgeFile = "Interface\\AddOns\\TotemBar\\textures\\panel_border",
+ tile = true, tileSize = 128, edgeSize = 16,
+ insets = { left = 5, right = 5, top = 5, bottom = 5 },
+}
+TotemBar.PANEL_BACKDROP = PANEL_BACKDROP -- shared with options.lua / bind.lua
+TotemBar.UI_TEXTURE = UI_TEXTURE
+TotemBar.uiCoords = uiCoords
+TotemBar.UI_EMBLEM_CELL = UI_EMBLEM_CELL
+TotemBar.UI_DIVIDER_CELL = UI_DIVIDER_CELL
+
+local ELEMENT_COLORS = {
+ Fire = { 1.00, 0.45, 0.10 },
+ Earth = { 0.40, 0.85, 0.30 },
+ Water = { 0.25, 0.60, 1.00 },
+ Air = { 0.70, 0.55, 1.00 },
+}
+
+local elementButtons = {} -- element -> button frame
+local recallButton = nil -- Totemic Recall button frame (for the auto-recall indicator refresh)
+local dropSetButton = nil -- DropSet button frame (for RefreshPulseUI's ring-skin re-apply)
+
+-- True when ANY element's totem is currently out-of-range (the
+-- buff-presence red tint, see UpdateTimerDisplays). Recomputed once per
+-- UpdateTimerDisplays pass; drives the Recall button's icon pulse
+-- (OnRecallUpdate below) as a "go recall + redeploy" visual prompt.
+local anyOutOfRange = false
+
+-- Hover flyout: hovering an element button pops a column of icon
+-- buttons UPWARD for the OTHER known totems of that element (all known
+-- for the element minus the currently-chosen default). Left-click one to
+-- cast it ONCE without changing the slot's default; right-click one to
+-- make it the slot's new default. Shares one frame + a pool of icon
+-- buttons.
+-- Computed from the static totem map, not hard-coded: Air has SEVEN totems,
+-- and a hard-coded 6 silently dropped the last one (Tranquil Air Totem)
+-- whenever the Air slot was empty -- the flyout then lists ALL known totems of
+-- the element, not "all minus the chosen default".
+local MAX_FLYOUT_ICONS = TotemBar.maxTotemsPerElement() -- most totems any single element has
+local FLYOUT_PAD = 4 -- inner padding inside the flyout frame
+local FLYOUT_GAP = 2 -- gap between element button top and flyout bottom
+local FLYOUT_HIDE_INTERVAL = 0.1 -- throttle for the mouse-leave hide check
+
+local flyoutIcons = {} -- pooled flyout icon buttons (created once)
+local flyoutFrame = nil -- lazily created shared flyout frame
+local flyoutElement = nil -- element currently shown in the flyout
+local flyoutOwnerButton = nil -- element button the flyout is anchored to
+local flyoutElapsed = 0 -- throttle accumulator for the hide check
+
+-- Forward declarations so functions defined later can be referenced by
+-- closures created earlier in the file (standard Lua forward-decl idiom:
+-- `local foo` then later `foo = function() ... end` / `function foo()`).
+local RefreshButton
+local RefreshCooldown
+local EnsureFlyoutFrame
+local ShowFlyout
+local RefreshFlyoutCooldowns
+local RefreshFlyoutMana
+local HideFlyout
+local OnFlyoutUpdate
+local CreateElementButton
+local CreateRecallButton
+local CreateDropSetButton
+local RefreshRecallIndicator
+local OnRecallUpdate
+local UpdateTimerDisplays
+local UpdateWaves
+local OnBarUpdate
+local OnDragStart
+local OnDragStop
+local EnsureAssignFrame
+local assignFrame -- lazily created pending-suggestion panel
+
+-- Finds the spellbook index of a known spell by exact name, or nil.
+-- Cache-backed accessor from core/spellindex.lua (loaded earlier in the
+-- TOC) -- used to be its own fresh linear GetSpellName() scan here.
+local FindSpellIndexByName = TotemBar.findSpellIndex
+
+-- Fills a tooltip with a totem spell, resolved to the HIGHEST known rank.
+-- A first-name-match slot (FindSpellIndexByName) is RANK 1, so SetSpell on
+-- it showed rank-1 mana/damage/duration - misleading, since the name-based
+-- CAST always uses the highest rank. Also appends the rank to the name
+-- line, which 1.12's SetSpell omits entirely.
+local function SetSpellTooltip(tip, name)
+ local idx = (TotemBar.findHighestRankSlot and TotemBar.findHighestRankSlot(name))
+ or FindSpellIndexByName(name)
+ if not idx then
+ tip:SetText(name)
+ return
+ end
+ tip:SetSpell(idx, BOOKTYPE_SPELL)
+ local _, rank = GetSpellName(idx, BOOKTYPE_SPELL)
+ if rank and rank ~= "" then
+ tip:AppendText(" |cffa8a8a8(" .. rank .. ")|r")
+ end
+end
+
+-- Resolves the icon texture path for whatever totem is currently chosen
+-- for `element`, or nil when the slot is empty/unresolved. Callers route
+-- the result through SetElementIcon, which draws the sheet's per-element
+-- glyph for nil instead of a spellbook texture.
+local function GetElementIcon(element)
+ local db = TotemBarDB
+ local name = db and db.chosen and db.chosen[element]
+ if not name then
+ return nil
+ end
+ local idx = FindSpellIndexByName(name)
+ if not idx then
+ return nil
+ end
+ return GetSpellTexture(idx, BOOKTYPE_SPELL)
+end
+
+-- Resolves (iconTexture, known) for an arbitrary totem name: the spellbook
+-- texture when the player knows it, else the empty/question-mark icon and
+-- known=false (the pending panel greys unknown totems).
+local function ResolveTotemIcon(name)
+ if not name then
+ return EMPTY_ICON, false
+ end
+ local idx = FindSpellIndexByName(name)
+ if not idx then
+ return EMPTY_ICON, false
+ end
+ local texture = GetSpellTexture(idx, BOOKTYPE_SPELL)
+ return texture or EMPTY_ICON, true
+end
+
+-- Resolves the Totemic Recall icon by scanning the spellbook for the
+-- real spell (so it matches whatever texture TWoW actually ships),
+-- falling back to a hardcoded texture if the scan can't find it.
+local function GetRecallIcon()
+ local idx = FindSpellIndexByName(RECALL_SPELL_NAME)
+ if idx then
+ local texture = GetSpellTexture(idx, BOOKTYPE_SPELL)
+ if texture then
+ return texture
+ end
+ end
+ return RECALL_ICON_FALLBACK
+end
+
+-- Sets an element button's icon: real spell icon (standard crop) when a
+-- totem is chosen/resolved, element glyph from the sheet when empty.
+-- SetTexCoord persists across SetTexture, so both branches must set it.
+local function SetElementIcon(btn, texturePath)
+ if texturePath then
+ btn.icon:SetTexture(texturePath)
+ btn.icon:SetTexCoord(ICON_CROP, 1 - ICON_CROP, ICON_CROP, 1 - ICON_CROP)
+ else
+ local c = iconCoords[ELEMENT_EMPTY_CELL[btn.element] or 1]
+ btn.icon:SetTexture(ICONS_TEXTURE)
+ btn.icon:SetTexCoord(c.l, c.r, c.t, c.b)
+ end
+end
+
+RefreshButton = function(element)
+ local btn = elementButtons[element]
+ if not btn then
+ return
+ end
+ SetElementIcon(btn, GetElementIcon(element))
+ RefreshCooldown(element)
+end
+
+-- Refreshes element `element`'s native cooldown swipe (the same radial
+-- CooldownFrameTemplate widget standard action buttons use) to match its
+-- currently-chosen totem's spellbook cooldown. Clears the swipe (start=0)
+-- when the slot is empty or unresolved. Called from RefreshButton (i.e.
+-- on selection changes and once at bar-build time) and from the
+-- SPELL_UPDATE_COOLDOWN event handler below - NEVER from the per-tick
+-- timer OnUpdate, since re-calling CooldownFrame_SetTimer every tick
+-- would restart the swipe's animation instead of letting it play.
+RefreshCooldown = function(element)
+ local btn = elementButtons[element]
+ if not btn then
+ return
+ end
+ local db = TotemBarDB
+ local totemName = db and db.chosen and db.chosen[element]
+ local idx = totemName and FindSpellIndexByName(totemName)
+ if not idx then
+ CooldownFrame_SetTimer(btn.cd, 0, 0, 0)
+ return
+ end
+ local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
+ CooldownFrame_SetTimer(btn.cd, start, duration, enable)
+end
+
+-- Glue for core/assign.lua's pending-assignment logic: it stays WoW-API-
+-- light and reaches the spellbook / bar refresh through these slots.
+-- isTotemKnown resolves a totem name against the live spellbook; RefreshAll
+-- re-skins every element button (used after a pending assignment is applied).
+TotemBar.isTotemKnown = function(name)
+ return FindSpellIndexByName(name) ~= nil
+end
+
+TotemBar.RefreshAll = function()
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ RefreshButton(elements[i])
+ end
+end
+
+-- Bind-mode key overlays: a small top-right FontString on each bindable
+-- button/flyout icon, ALWAYS shown (action-bar-hotkey style) whenever a key
+-- is bound, hidden only when nothing is bound. Independent of bind mode.
+-- Declared here, BEFORE EnsureFlyoutFrame/CreateElementButton/
+-- CreateRecallButton/CreateDropSetButton (all of which call
+-- registerBindOverlay from their function bodies): Lua resolves a `local`
+-- as an upvalue only for code appearing lexically after its declaration, so
+-- this block must sit above every factory that references it, not just
+-- above CreateElementButton (EnsureFlyoutFrame is defined earlier in the
+-- file than the element/recall/dropset button factories).
+local bindOverlayTargets = {} -- { frame = button, action = fn()->command }
+
+local function ensureBindOverlay(frame)
+ if frame.bindKeyText then
+ return frame.bindKeyText
+ end
+ local fs = frame:CreateFontString(nil, "OVERLAY")
+ fs:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
+ fs:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -1, -1)
+ fs:SetTextColor(1, 0.9, 0.2)
+ fs:Hide()
+ frame.bindKeyText = fs
+ return fs
+end
+
+-- action is a function returning the binding command for this frame right now
+-- (flyout icons change totem), or nil.
+local function registerBindOverlay(frame, actionFn)
+ ensureBindOverlay(frame)
+ tinsert(bindOverlayTargets, { frame = frame, action = actionFn })
+end
+
+TotemBar.refreshBindOverlays = function()
+ for i = 1, table.getn(bindOverlayTargets) do
+ local t = bindOverlayTargets[i]
+ local fs = t.frame.bindKeyText
+ local cmd = t.action()
+ local key = cmd and GetBindingKey(cmd) or nil
+ if key then
+ fs:SetText(TotemBar.shortenKey(key))
+ fs:Show()
+ else
+ fs:Hide()
+ end
+ end
+end
+
+-- Replaces the square Blizzard hover/pushed art with round FX-sheet states,
+-- sized to the ring frame so they hug the round button. Declared here,
+-- BEFORE EnsureFlyoutFrame (same ordering constraint as registerBindOverlay
+-- above): the pooled flyout icons' creation loop lives inside
+-- EnsureFlyoutFrame and calls this, so it must already be a visible upvalue
+-- at that point in the file, not just before CreateElementButton/
+-- CreateRecallButton/CreateDropSetButton further down.
+local function ApplyRoundClickStates(btn, size)
+ btn:SetHighlightTexture(FX_TEXTURE, "ADD")
+ local h = btn:GetHighlightTexture()
+ if h then
+ local c = fxCoords[FX_HOVER_CELL]
+ h:SetTexCoord(c.l, c.r, c.t, c.b)
+ h:ClearAllPoints()
+ h:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ h:SetWidth(size)
+ h:SetHeight(size)
+ end
+ btn:SetPushedTexture(FX_TEXTURE)
+ local p = btn:GetPushedTexture()
+ if p then
+ local c = fxCoords[FX_PUSHED_CELL]
+ p:SetTexCoord(c.l, c.r, c.t, c.b)
+ p:ClearAllPoints()
+ p:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ p:SetWidth(size)
+ p:SetHeight(size)
+ end
+end
+
+-- Repositions every pooled flyout icon per the current BUTTON_GAP (mirrors
+-- the bottom-up stacking math used when the icons are first built, below).
+-- The icon pool is built lazily ONCE and its anchors are baked in at that
+-- point - they don't reflow themselves - so this must be called again
+-- whenever BUTTON_GAP changes live (TotemBar.SetButtonGap), not just at
+-- build time. `frame` lets the build-time call pass the not-yet-assigned
+-- local `f` directly; later calls omit it and fall back to the flyoutFrame
+-- upvalue (guarded: a no-op if the flyout has never been built yet).
+local function layoutFlyoutIcons(frame)
+ local f = frame or flyoutFrame
+ if not f then
+ return
+ end
+ for i = 1, MAX_FLYOUT_ICONS do
+ local ico = flyoutIcons[i]
+ if ico then
+ ico:ClearAllPoints()
+ ico:SetPoint("BOTTOM", f, "BOTTOM", 0, FLYOUT_PAD + (i - 1) * (BUTTON_SIZE + BUTTON_GAP))
+ end
+ end
+end
+
+-- Lazily builds the single shared flyout frame plus its pool of icon
+-- buttons (TotemBarFlyoutIcon1..MAX_FLYOUT_ICONS), stacked bottom-up so
+-- ShowFlyout can just Show the first N. DIALOG strata so it draws above
+-- the bar backdrop. Same anti-bevel icon treatment as the element
+-- buttons (opaque black backdrop under an inset, cropped ARTWORK icon).
+EnsureFlyoutFrame = function()
+ if flyoutFrame then
+ return flyoutFrame
+ end
+
+ -- Parent to the bar (not UIParent) so the flyout inherits the bar's
+ -- scale (UI-size slider) and hides with it. Still DIALOG strata below,
+ -- so it draws above the bar backdrop regardless of parent.
+ local f = CreateFrame("Frame", "TotemBarFlyout", TotemBarFrame)
+ f:SetFrameStrata("DIALOG")
+ f:SetWidth(BUTTON_SIZE + FLYOUT_PAD * 2)
+ f:SetHeight(BUTTON_SIZE + FLYOUT_PAD * 2)
+ f:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 16,
+ insets = { left = 4, right = 4, top = 4, bottom = 4 },
+ })
+ -- Floating icons: fully transparent flyout backdrop/border (the flyout
+ -- frame itself is now just an invisible hit-test/layout area).
+ f:SetBackdropColor(0, 0, 0, 0)
+ f:SetBackdropBorderColor(0, 0, 0, 0)
+ f:EnableMouse(true) -- so gaps/padding still count as "over the flyout"
+ f:SetClampedToScreen(true)
+ f:Hide()
+
+ for i = 1, MAX_FLYOUT_ICONS do
+ local ico = CreateFrame("Button", "TotemBarFlyoutIcon" .. i, f)
+ ico:SetWidth(BUTTON_SIZE)
+ ico:SetHeight(BUTTON_SIZE)
+ -- Bottom-up: icon 1 just inside the bottom padding, each next
+ -- one a full button+gap higher (so nearest the element button
+ -- is first). Anchor applied below via layoutFlyoutIcons (once the
+ -- whole pool exists), not here - the same helper re-applies it
+ -- live when BUTTON_GAP changes.
+
+ ico:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 12,
+ insets = { left = 3, right = 3, top = 3, bottom = 3 },
+ })
+ ico:SetBackdropColor(0, 0, 0, 0)
+ ico:SetBackdropBorderColor(0, 0, 0, 0)
+
+ local icon = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "Icon", "ARTWORK")
+ icon:SetPoint("TOPLEFT", ico, "TOPLEFT", 3, -3)
+ icon:SetPoint("BOTTOMRIGHT", ico, "BOTTOMRIGHT", -3, 3)
+ icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
+ ico.icon = icon
+
+ -- Grounding shadow ellipse (Rev 4 UI chrome), same as the element
+ -- buttons' - BACKGROUND layer, no glow on flyout icons so ordering
+ -- relative to other regions doesn't matter here.
+ local shadow = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "Shadow", "BACKGROUND")
+ shadow:SetTexture(UI_TEXTURE)
+ local sc = uiCoords[UI_SHADOW_CELL]
+ shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
+ shadow:SetPoint("CENTER", ico, "CENTER", 0, -15)
+ shadow:SetWidth(58)
+ shadow:SetHeight(20)
+ shadow:SetAlpha(0.7) -- softened, see the element buttons' plate note
+ ico.shadow = shadow
+
+ -- Floating round frame, same decorative band as the element
+ -- buttons (no fill/tint - just the permanent frame). Flyout icons
+ -- are BUTTON_SIZE (36px) too, so the proportional ring size
+ -- S*(48/36) is just RING_TEX_SIZE (48px) again.
+ local ringTrack = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "RingTrack", "OVERLAY")
+ ringTrack:SetTexture(RING_TEXTURE)
+ local rtc = allRingCoords[RING_TRACK_CELL]
+ ringTrack:SetTexCoord(rtc.l, rtc.r, rtc.t, rtc.b)
+ ringTrack:SetPoint("CENTER", ico, "CENTER", 0, 0)
+ ringTrack:SetWidth(RING_TEX_SIZE)
+ ringTrack:SetHeight(RING_TEX_SIZE)
+ ico.ringTrack = ringTrack
+
+ -- Native cooldown swipe over the flyout icon, so the player can
+ -- see which alternative totems are on cooldown while choosing.
+ -- Same "Model" (NOT "Cooldown") frame-type caveat as the element
+ -- buttons: vanilla 1.12 has no dedicated Cooldown widget type -
+ -- CooldownFrameTemplate is a Model template here (see the element
+ -- button's cd note above). Driven once at flyout-open by
+ -- ShowFlyout (the widget animates itself from start+duration), so
+ -- no per-frame refresh for this transient popup.
+ local cd = CreateFrame("Model", "TotemBarFlyoutIcon" .. i .. "Cooldown", ico, "CooldownFrameTemplate")
+ cd:SetAllPoints(ico.icon)
+ cd:SetFrameLevel(ico:GetFrameLevel())
+ ico.cd = cd
+
+ -- Round hover/pushed states (Rev 3), same FX sheet as the bar
+ -- buttons; flyout icons are BUTTON_SIZE, sized like the bar's ring.
+ ApplyRoundClickStates(ico, RING_TEX_SIZE)
+ ico:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+
+ -- Left-click: cast once for the element the flyout is currently
+ -- showing. recordCast() drives THAT element's timer to the
+ -- actually-cast totem, without touching the slot's chosen
+ -- default. Right-click: make this totem the slot's new chosen
+ -- default and re-populate the flyout in place (so the
+ -- newly-chosen totem drops out of the "others" list and the
+ -- previously-chosen one appears), keeping the flyout open.
+ ico:SetScript("OnClick", function()
+ if not (this.totemName and flyoutElement) then
+ return
+ end
+ if arg1 == "RightButton" then
+ local element = flyoutElement
+ local owner = flyoutOwnerButton
+ TotemBarDB.chosen[element] = this.totemName
+ RefreshButton(element)
+ if owner then
+ ShowFlyout(owner, element)
+ end
+ else
+ CastSpellByName(this.totemName)
+ TotemBar.recordCast(flyoutElement, this.totemName)
+ -- Immediate cooldown feedback: don't wait for
+ -- SPELL_UPDATE_COOLDOWN. Refreshes the bar button's swipe
+ -- (in case the cast totem is also this element's chosen
+ -- default) and the flyout icon's own swipe.
+ RefreshCooldown(flyoutElement)
+ RefreshFlyoutCooldowns()
+ end
+ end)
+
+ ico:SetScript("OnEnter", function()
+ if this.totemName then
+ GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
+ SetSpellTooltip(GameTooltip, this.totemName)
+ GameTooltip:AddLine("Left-click: cast / Right-click: set default", 1, 1, 1)
+ GameTooltip:Show()
+ end
+ end)
+ ico:SetScript("OnLeave", function()
+ GameTooltip:Hide()
+ end)
+
+ ico:Hide()
+ registerBindOverlay(ico, function()
+ if ico.totemName then
+ return "TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(ico.totemName)
+ end
+ return nil
+ end)
+ flyoutIcons[i] = ico
+ end
+
+ -- Anchors every pooled icon per BUTTON_GAP now that the whole pool
+ -- exists (flyoutFrame isn't assigned yet at this point in the function,
+ -- hence passing `f` explicitly - see layoutFlyoutIcons's own comment).
+ layoutFlyoutIcons(f)
+
+ -- OnUpdate only fires while the frame is shown, so this hide-check
+ -- naturally stops running once the flyout is hidden.
+ f:SetScript("OnUpdate", OnFlyoutUpdate)
+
+ flyoutFrame = f
+ return f
+end
+
+-- Populates and shows the flyout above `button` for `element`, listing
+-- every known totem of that element EXCEPT the currently-chosen default.
+-- Shows nothing if there are no "others".
+ShowFlyout = function(button, element)
+ local f = EnsureFlyoutFrame()
+
+ local db = TotemBarDB
+ local spellNames = TotemBar.scanSpellbook()
+ local known = TotemBar.knownTotems(spellNames, element)
+ local chosen = db and db.chosen and db.chosen[element]
+
+ local count = 0
+ for i = 1, table.getn(known) do
+ local totemName = known[i]
+ if totemName ~= chosen and count < MAX_FLYOUT_ICONS then
+ count = count + 1
+ local ico = flyoutIcons[count]
+ ico.totemName = totemName
+ local idx = FindSpellIndexByName(totemName)
+ local texture = idx and GetSpellTexture(idx, BOOKTYPE_SPELL)
+ ico.icon:SetTexture(texture or EMPTY_ICON)
+ -- Drive the swipe once here at flyout-open so the player can
+ -- see which alternative totems are on cooldown; the widget
+ -- animates itself from start+duration, no per-tick refresh.
+ if idx then
+ local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
+ CooldownFrame_SetTimer(ico.cd, start, duration, enable)
+ else
+ CooldownFrame_SetTimer(ico.cd, 0, 0, 0)
+ end
+ ico:Show()
+ end
+ end
+
+ for j = count + 1, MAX_FLYOUT_ICONS do
+ flyoutIcons[j]:Hide()
+ flyoutIcons[j].totemName = nil
+ -- Clear the swipe on unused pool icons so a stale cooldown from a
+ -- previous open doesn't linger if this slot is reused later.
+ CooldownFrame_SetTimer(flyoutIcons[j].cd, 0, 0, 0)
+ end
+
+ if count == 0 then
+ -- No other known totems for this element; don't show an empty box.
+ flyoutElement = nil
+ flyoutOwnerButton = nil
+ f:Hide()
+ return
+ end
+
+ flyoutElement = element
+ flyoutOwnerButton = button
+ flyoutElapsed = 0
+
+ f:SetHeight(count * (BUTTON_SIZE + BUTTON_GAP) - BUTTON_GAP + FLYOUT_PAD * 2)
+ f:ClearAllPoints()
+ f:SetPoint("BOTTOM", button, "TOP", 0, FLYOUT_GAP)
+ f:Show()
+ -- Freshly-populated flyout icons need their overlays re-driven right
+ -- away (rather than waiting for the next UPDATE_BINDINGS/toggle) so
+ -- keys show immediately while bind mode is already on.
+ if TotemBar.refreshBindOverlays then
+ TotemBar.refreshBindOverlays()
+ end
+ RefreshFlyoutMana()
+end
+
+-- Re-drives the cooldown swipe on every CURRENTLY-SHOWN pooled flyout
+-- icon, re-resolving each one's spellbook index fresh (so a just-cast
+-- totem's swipe reflects the cooldown that cast just started). No-op if
+-- the flyout isn't open. Doesn't touch icon texture/totemName/layout -
+-- only the swipe - so it's safe to call from anywhere without disturbing
+-- ShowFlyout's own population pass (which sets the initial swipe state
+-- itself, including clearing unused pool icons; left as-is here).
+RefreshFlyoutCooldowns = function()
+ if not (flyoutFrame and flyoutFrame:IsShown()) then
+ return
+ end
+ for i = 1, MAX_FLYOUT_ICONS do
+ local ico = flyoutIcons[i]
+ if ico:IsShown() and ico.totemName then
+ local idx = FindSpellIndexByName(ico.totemName)
+ if idx then
+ local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
+ CooldownFrame_SetTimer(ico.cd, start, duration, enable)
+ else
+ CooldownFrame_SetTimer(ico.cd, 0, 0, 0)
+ end
+ end
+ end
+end
+
+-- Dims every shown flyout icon the player cannot currently afford, on the same
+-- rule as the bar buttons (TotemBar.iconTintFor / notEnoughMana). Flyout icons
+-- have no range state of their own -- they are alternatives that aren't out --
+-- so only the mana half applies. Cheap: the cost lookup is cached, and the
+-- flyout only exists while it is hovered.
+RefreshFlyoutMana = function()
+ if not (flyoutFrame and flyoutFrame:IsShown()) then
+ return
+ end
+ local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil
+ for i = 1, MAX_FLYOUT_ICONS do
+ local ico = flyoutIcons[i]
+ if ico:IsShown() and ico.totemName then
+ -- No Clearcasting exemption (2026-08-28): Elemental Focus does not
+ -- cover totems, see core/manacost.lua.
+ local oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(ico.totemName), playerMana)
+ local r, g, b, key = TotemBar.iconTintFor(false, oom)
+ if key ~= ico.tintKey then
+ ico.icon:SetVertexColor(r, g, b)
+ ico.tintKey = key
+ end
+ end
+ end
+end
+
+HideFlyout = function()
+ if flyoutFrame then
+ flyoutFrame:Hide()
+ end
+ flyoutElement = nil
+ flyoutOwnerButton = nil
+end
+
+-- Throttled mouse-leave check: hide the flyout once the cursor is over
+-- NEITHER the owning element button NOR the flyout itself. This keeps
+-- it open while the mouse travels from the button up onto the flyout.
+-- No per-frame allocation - just the accumulator and two MouseIsOver
+-- geometry checks, gated to run ~every FLYOUT_HIDE_INTERVAL seconds.
+OnFlyoutUpdate = function()
+ flyoutElapsed = flyoutElapsed + arg1
+ if flyoutElapsed < FLYOUT_HIDE_INTERVAL then
+ return
+ end
+ flyoutElapsed = 0
+ RefreshFlyoutMana()
+ if flyoutOwnerButton and (MouseIsOver(flyoutFrame) or MouseIsOver(flyoutOwnerButton)) then
+ return
+ end
+ HideFlyout()
+end
+
+-- Applies the floating round-icon frame to one button: permanent
+-- decorative-frame texture (ringTrack) + duration-arc texture/tint
+-- (ringFill, where present) + wave tint (pulseWave, where present), and
+-- blanks the button's own square backdrop/border - the frame ring now IS
+-- the visual frame, always (no ring-option gate: even with the duration
+-- arc hidden, the permanent ring frame stays visible and the button must
+-- never show a black box behind it). Used for element buttons (which have
+-- ringFill/pulseWave) and the Recall/DropSet buttons (which don't, guarded
+-- below) alike.
+local function ApplyRoundFrame(btn)
+ btn.ringTrack:SetTexture(RING_TEXTURE)
+ local trc = allRingCoords[RING_TRACK_CELL]
+ btn.ringTrack:SetTexCoord(trc.l, trc.r, trc.t, trc.b)
+
+ if btn.ringFill then
+ btn.ringFill:SetTexture(RING_TEXTURE)
+ -- No element-color assignment here anymore: the duration ring is
+ -- now a time-remaining traffic light (green->yellow->orange->red),
+ -- recolored every tick in UpdateTimerDisplays via TotemBar.timeColor.
+ btn.ringLastIdx = nil -- force a fill-frame re-apply on the next tick
+ end
+
+ if btn.pulseArc then
+ local c = ELEMENT_COLORS[btn.element]
+ if c then
+ btn.pulseArc:SetVertexColor(c[1], c[2], c[3], 0.95)
+ end
+ end
+
+ if btn.pulseWave then
+ local c = ELEMENT_COLORS[btn.element]
+ if c then
+ btn.pulseWave:SetVertexColor(c[1], c[2], c[3])
+ end
+ end
+
+ if btn.glow then
+ local c = ELEMENT_COLORS[btn.element]
+ if c then
+ btn.glow:SetVertexColor(c[1], c[2], c[3])
+ end
+ end
+
+ btn:SetBackdropColor(0, 0, 0, 0)
+ btn:SetBackdropBorderColor(0, 0, 0, 0)
+end
+
+-- The timer text hangs below the button (the pulse arc and ripple wave are
+-- both centered ON the button, so neither competes for this anchor).
+local function ApplyTimerAnchor(btn)
+ btn.timerText:ClearAllPoints()
+ btn.timerText:SetPoint("TOP", btn, "BOTTOM", 0, -1)
+end
+
+-- Deterministic-stacking child frame for regions that must render above
+-- ALL of a button's own regions regardless of draw layer (2026-07-16,
+-- HIGHLIGHT-hover regression fix). Background: f3644c5 moved
+-- ringFill/pulseWave/pulseArc (and the Recall button's autoIndicator) to
+-- the HIGHLIGHT layer to beat ringTrack's OVERLAY layer - but 1.12 only
+-- renders a Button's HIGHLIGHT draw layer while the mouse is hovering it
+-- (in-game confirmed: every fill ring/indicator went invisible except
+-- under the cursor). Layers were never going to work for "always-on-top,
+-- always visible" content on a Button. The fix used here instead (the same
+-- pattern used elsewhere for row-highlight panel frames): WoW's cross-frame
+-- draw order is primarily (strata,
+-- frame level) - a CHILD FRAME at a higher FrameLevel than its parent
+-- draws every one of its own regions above ALL of the parent's regions,
+-- no matter which of the 5 draw layers either side uses. So a small,
+-- mouse-transparent child frame pinned over the button's own bounds,
+-- one level higher, gives deterministic top-of-stack rendering without
+-- spending any of the parent's own 5-layer budget.
+local function CreateRingOverlay(btn, name)
+ local overlay = CreateFrame("Frame", name, btn)
+ overlay:EnableMouse(false) -- pure visual layer, must not steal btn's hover/click
+ overlay:SetAllPoints(btn) -- deckungsgleich with btn; CENTER anchors below resolve identically
+ overlay:SetFrameLevel(btn:GetFrameLevel() + 1)
+ return overlay
+end
+
+CreateElementButton = function(element, index)
+ local name = "TotemBarButton" .. element
+ local btn = CreateFrame("Button", name, TotemBarFrame)
+ btn:SetWidth(BUTTON_SIZE)
+ btn:SetHeight(BUTTON_SIZE)
+ btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)
+
+ -- Backdrop template kept (needed for SetBackdropColor/BorderColor
+ -- below) but floating: ApplyRoundFrame (called later in this function)
+ -- blanks both to fully transparent - the decorative ring frame is the
+ -- only visible frame around the icon now.
+ btn:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 12,
+ insets = { left = 3, right = 3, top = 3, bottom = 3 },
+ })
+
+ local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
+ -- Inset a few px from the button's own edges (the floating decorative
+ -- ring frame, drawn OVERLAY on top, covers this margin and the icon's
+ -- square corners - see ApplyRoundFrame/the texture generator's
+ -- geometry rationale).
+ icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
+ icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
+ btn.icon = icon
+ -- Set ahead of SetElementIcon below (which reads btn.element to pick
+ -- the empty-slot glyph cell); reassigned harmlessly again further
+ -- down alongside the rest of the button's setup, same repeat-write
+ -- pattern as the element-tint lookup ahead of ApplyRoundFrame below.
+ btn.element = element
+ -- SetElementIcon sets both texture and texcoord (SetTexCoord persists
+ -- across SetTexture, so both the real-icon crop and the sheet-glyph
+ -- coords always get (re)applied together - see its own comment).
+ SetElementIcon(btn, GetElementIcon(element))
+
+ -- Native action-button-style radial cooldown swipe (the same
+ -- CooldownFrameTemplate widget the default action bars use),
+ -- covering the icon area. This is IN ADDITION to the OmniCC-style
+ -- duration timer text below; RefreshCooldown() drives it from
+ -- events, see there for why it's not driven off the per-tick timer.
+ --
+ -- Frame type is "Model", NOT "Cooldown": vanilla 1.12 has no
+ -- dedicated Cooldown widget type (that was added in TBC/2.0) -
+ -- CooldownFrameTemplate is a Model-type template in 1.12 FrameXML
+ -- that renders Interface\Cooldown\UI-Cooldown-Indicator.mdx (same
+ -- approach pfUI's own libtotem/action bars use). CreateFrame with
+ -- type "Cooldown" would error on this client.
+ local cd = CreateFrame("Model", name .. "Cooldown", btn, "CooldownFrameTemplate")
+ cd:SetAllPoints(icon)
+ -- Match the button's own frame level instead of the default child
+ -- bump (parent level + 1): WoW's cross-frame draw order is primarily
+ -- (strata, level) - at the default +1 level the ENTIRE swipe frame
+ -- would draw above ALL of btn's own regions regardless of draw
+ -- layer, including the OVERLAY timer text below. At the SAME level,
+ -- ordering falls back to per-region draw layer, letting the OVERLAY
+ -- timer text render on top of the swipe as intended. Still flagged
+ -- for an in-game visual check (see file header note).
+ cd:SetFrameLevel(btn:GetFrameLevel())
+ btn.cd = cd
+
+ -- OmniCC-style remaining-duration text, anchored BELOW the button
+ -- (centered under the icon) so it doesn't overlap the native
+ -- cooldown swipe (btn.cd) rendered on top of the icon. Parented to
+ -- btn (a Button, like the element buttons themselves), not to the
+ -- bar's backdrop Frame - plain 1.12 Frames don't clip child regions
+ -- (no SetClipsChildren in this client), so text hanging below the
+ -- bar's backdrop still renders fully; flagged for an in-game visual
+ -- check regardless (see file header note). Hidden by default;
+ -- UpdateTimerDisplays() shows/updates it.
+ --
+ -- Left parented to btn (NOT moved to ringOverlay), 2026-07-16
+ -- HIGHLIGHT-hover regression fix: geometry check (btn height 36,
+ -- ring RING_TEX_SIZE 48 centered on btn -> ring overhangs 6px past
+ -- every edge) puts ring:GetBottom() at btn:GetBottom()-6, while this
+ -- FontString's SetPoint("TOP", btn, "BOTTOM", 0, -1) puts its OWN top
+ -- at only btn:GetBottom()-1 - i.e. a ~5px vertical band where the two
+ -- regions' bounding boxes technically overlap, directly under the
+ -- button's center. In practice that band is the ring's transparent
+ -- annulus hole (the band's opaque pixels hug its outer 48px edge, not
+ -- its center) plus the font's own ascender padding above the glyphs,
+ -- so no visible ink collision - stays on btn/OVERLAY, unchanged.
+ local timerText = btn:CreateFontString(name .. "Timer", "OVERLAY")
+ timerText:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE")
+ timerText:SetPoint("TOP", btn, "BOTTOM", 0, -1)
+ timerText:SetJustifyH("CENTER")
+ timerText:SetText("")
+ timerText:Hide()
+ btn.timerText = timerText
+ btn.timerVisible = false -- cached shown-state, avoid redundant Show/Hide
+ btn.timerLastText = nil -- cached last string, avoid redundant SetText
+ btn.timerLastLow = nil -- cached last <=5s tint state, avoid redundant SetTextColor
+ btn.tintRed = false -- last out-of-range verdict (read by the Recall button's pulse)
+ btn.tintKey = 0 -- cached composed tint (range + out-of-mana), avoid redundant SetVertexColor
+
+ -- Set here (ahead of the later btn.element = element below) because
+ -- ApplyRoundFrame, called at the end of this block, needs it for the
+ -- element-tint color lookup; the later assignment is then a harmless
+ -- no-op repeat of the same value.
+ btn.element = element
+
+ -- Grounding shadow ellipse (Rev 4 UI chrome). BACKGROUND layer - the
+ -- anticipation glow below used to also be BACKGROUND on the (wrong)
+ -- assumption that same-layer draw order follows creation order in this
+ -- client; the 2026-07-16 ring-occlusion investigation live-confirmed
+ -- that's false (ringTrack was randomly hiding ringFill despite being
+ -- created first) - 1.12 discards SetDrawLayer's sublevel argument, so
+ -- same-MAIN-layer siblings really do have undefined relative order.
+ -- glow now sits on its own BORDER layer instead (below), which is the
+ -- real fix: no assumption needed when the two don't share a layer.
+ local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
+ shadow:SetTexture(UI_TEXTURE)
+ local sc = uiCoords[UI_SHADOW_CELL]
+ shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
+ shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
+ shadow:SetWidth(58)
+ shadow:SetHeight(20)
+ -- Softened after preview review: full-strength plate read boxy on light
+ -- ground; flatter + more transparent grounds without drawing attention.
+ shadow:SetAlpha(0.7)
+ btn.shadow = shadow
+
+ -- Pulse UI Rev 2: floating round frame (permanent decorative band +
+ -- duration-arc fill) + ripple wave. Sized to RING_TEX_SIZE (48px)
+ -- centered on the 36px button, NOT SetAllPoints - the ring extends
+ -- past the button's own edges (see the geometry rationale in the
+ -- texture generator).
+ --
+ -- Frame-level stacking (2026-07-16, HIGHLIGHT-hover regression fix -
+ -- see CreateRingOverlay's own comment for the full story): ringTrack,
+ -- ringFill, pulseWave and pulseArc are all centered on the same point
+ -- at the same RING_TEX_SIZE, i.e. all four spatially overlap. A prior
+ -- fix moved the three dynamic ones to HIGHLIGHT to beat ringTrack's
+ -- OVERLAY - but 1.12 only paints a Button's HIGHLIGHT layer on hover,
+ -- so that made them invisible almost always. ringTrack is the one
+ -- PERMANENT member of this quartet (always shown, see ringTrack:Show()
+ -- below) so it stays right where it was: parented to btn, OVERLAY
+ -- layer. ringFill/pulseWave/pulseArc move to a dedicated child frame
+ -- (ringOverlay) one FrameLevel above btn, which puts all three above
+ -- ringTrack unconditionally, regardless of draw layer. Within that
+ -- child frame the three still need to not collide with EACH OTHER
+ -- (1.12 discards SetDrawLayer's sublevel argument, so same-main-layer
+ -- siblings have undefined relative order) - given 3 items and a full
+ -- 5-layer budget to itself now, each gets its own layer:
+ -- ringFill = ARTWORK (base: the colored duration-remaining ring)
+ -- pulseWave = OVERLAY (the brief per-pulse ripple must read above
+ -- the fill it pulses over)
+ -- pulseArc = HIGHLIGHT (the thin white countdown-to-next-pulse
+ -- readout; per its own file comment it must
+ -- "read distinctly" from ringFill, so it gets
+ -- the topmost layer to stay legible even
+ -- while a wave is mid-flash)
+ -- All three are still spatially anchored to the SAME center point as
+ -- before (now via ringOverlay, which is deckungsgleich with btn -
+ -- SetAllPoints - so a CENTER anchor resolves to the identical pixel
+ -- as anchoring to btn directly).
+ local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
+ ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ ringTrack:SetWidth(RING_TEX_SIZE)
+ ringTrack:SetHeight(RING_TEX_SIZE)
+ ringTrack:Show() -- the frame is permanent, unlike ringFill/pulseWave below
+ btn.ringTrack = ringTrack
+
+ local ringOverlay = CreateRingOverlay(btn, name .. "RingOverlay")
+ btn.ringOverlay = ringOverlay
+
+ local ringFill = ringOverlay:CreateTexture(name .. "RingFill", "ARTWORK")
+ ringFill:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
+ ringFill:SetWidth(RING_TEX_SIZE)
+ ringFill:SetHeight(RING_TEX_SIZE)
+ ringFill:Hide()
+ btn.ringFill = ringFill
+ btn.ringLastIdx = nil -- cached flipbook frame, avoid redundant SetTexCoord
+ btn.ringVisible = false -- cached shown-state, avoid redundant Show/Hide
+
+ -- Ripple wave: expanding, fading ring in the element color, one wave
+ -- per pulse (replaces the old pulse progress bar). Animated every
+ -- frame by UpdateWaves below (allocation-free), inputs refreshed by
+ -- the 0.1s timer tick in UpdateTimerDisplays.
+ local pulseWave = ringOverlay:CreateTexture(name .. "PulseWave", "OVERLAY")
+ pulseWave:SetTexture(RING_TEXTURE)
+ local wc = allRingCoords[RING_WAVE_CELL]
+ pulseWave:SetTexCoord(wc.l, wc.r, wc.t, wc.b)
+ pulseWave:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
+ pulseWave:SetWidth(RING_TEX_SIZE)
+ pulseWave:SetHeight(RING_TEX_SIZE)
+ pulseWave:Hide()
+ btn.pulseWave = pulseWave
+ btn.waveVisible = false
+ -- Per-frame wave animation inputs, refreshed by the 0.1s tick; nil
+ -- interval means "no wave for this slot right now".
+ btn.waveInterval = nil
+ btn.waveOrigin = nil -- phase origin (pulseAnchor or start)
+ btn.waveOneshotAt = nil -- detonation time for oneshot waves
+
+ -- Circular pulse countdown: element-colored inner arc on the band,
+ -- fills clockwise toward the next pulse and resets (user feedback: the
+ -- wave alone doesn't show WHEN; a circular bar fits the round slots).
+ -- Colored below by ApplyRoundFrame (called at the end of this function)
+ -- via ELEMENT_COLORS - no fixed color set here.
+ local pulseArc = ringOverlay:CreateTexture(name .. "PulseArc", "HIGHLIGHT")
+ pulseArc:SetTexture(PULSEARC_TEXTURE)
+ pulseArc:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
+ pulseArc:SetWidth(RING_TEX_SIZE)
+ pulseArc:SetHeight(RING_TEX_SIZE)
+ pulseArc:Hide()
+ btn.pulseArc = pulseArc
+ btn.pulseArcLastIdx = nil
+ btn.pulseVisible = false
+
+ -- Anticipation glow: annular halo just outside the ring band, fading in
+ -- over the last stretch before a pulse. BORDER layer (moved from
+ -- BACKGROUND, 2026-07-16 - see the shadow comment above): stays below
+ -- ARTWORK (icon) as intended, but no longer shares a layer with shadow.
+ local glow = btn:CreateTexture(name .. "Glow", "BORDER")
+ glow:SetTexture(FX_TEXTURE)
+ local gc = fxCoords[FX_GLOW_CELL]
+ glow:SetTexCoord(gc.l, gc.r, gc.t, gc.b)
+ glow:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ glow:SetWidth(GLOW_TEX_SIZE)
+ glow:SetHeight(GLOW_TEX_SIZE)
+ glow:Hide()
+ btn.glow = glow
+ btn.glowVisible = false
+ btn.spawnAt = nil -- one-shot spawn ripple origin (set by the tick)
+ btn.lastStartSeen = nil -- cast-change detector
+ btn.waveOneshotDelay = nil -- arming window length for oneshot glow
+
+ ApplyRoundFrame(btn)
+ ApplyTimerAnchor(btn)
+
+ -- No normal texture: UI-Quickslot2's bevel bleeds through
+ -- transparent icon art (see backdrop above). Round hover/pushed FX
+ -- states (Rev 3) only appear on click/hover, so they don't bleed.
+ ApplyRoundClickStates(btn, RING_TEX_SIZE)
+
+ btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+ btn.element = element
+
+ -- Left-click casts the slot's chosen totem. Right-click clears the
+ -- slot (sets no default); picking a new default happens via the
+ -- hover flyout's right-click instead (see EnsureFlyoutFrame above).
+ btn:SetScript("OnClick", function()
+ local clickedElement = this.element
+ if arg1 == "RightButton" then
+ TotemBarDB.chosen[clickedElement] = nil
+ RefreshButton(clickedElement)
+ else
+ local db = TotemBarDB
+ local totemName = db and db.chosen and db.chosen[clickedElement]
+ if totemName then
+ CastSpellByName(totemName)
+ TotemBar.recordCast(clickedElement, totemName)
+ else
+ ChatOut:AddMessage("TotemBar: no totem chosen for " .. clickedElement .. " (hover for known totems, right-click one to set it as default)")
+ end
+ end
+ end)
+
+ btn:SetScript("OnEnter", function()
+ local db = TotemBarDB
+ local totemName = db and db.chosen and db.chosen[this.element]
+ GameTooltip:SetOwner(this, "ANCHOR_TOP")
+ if totemName then
+ SetSpellTooltip(GameTooltip, totemName)
+ GameTooltip:AddLine("Left-click: cast / Right-click: clear", 1, 1, 1)
+ else
+ GameTooltip:SetText(this.element .. " (empty)")
+ GameTooltip:AddLine("Hover for known totems, right-click one to set default", 1, 1, 1)
+ end
+ GameTooltip:Show()
+ -- Pop the "cast one of the others" flyout above this button.
+ -- The flyout hides itself via its own throttled mouse-leave
+ -- check (OnFlyoutUpdate), so no OnLeave handling is needed here.
+ ShowFlyout(this, this.element)
+ end)
+ btn:SetScript("OnLeave", function()
+ GameTooltip:Hide()
+ end)
+
+ elementButtons[element] = btn
+ registerBindOverlay(btn, function() return "TOTEMBAR_CAST_" .. string.upper(element) end)
+ return btn
+end
+
+-- Refreshes the Recall button's small "A" auto-recall indicator to
+-- match TotemBarDB.autoRecall: shown (greenish) when on, hidden when
+-- off. Called once at button creation and again after every right-click
+-- toggle (see CreateRecallButton below).
+RefreshRecallIndicator = function()
+ if not recallButton or not recallButton.autoIndicator then
+ return
+ end
+ if TotemBarDB and TotemBarDB.autoRecall then
+ recallButton.autoIndicator:SetTextColor(0.3, 1, 0.3)
+ recallButton.autoIndicator:Show()
+ else
+ recallButton.autoIndicator:Hide()
+ end
+end
+
+-- Public alias so options.lua can refresh the "A" auto-recall indicator
+-- after the auto-recall checkbox is toggled.
+TotemBar.RefreshRecallIndicator = RefreshRecallIndicator
+
+-- Pulses the Recall button's icon alpha while anyOutOfRange is true (set
+-- by UpdateTimerDisplays) - a "go recall + redeploy" visual prompt.
+-- Time-based (GetTime()), so the pulse stays smooth regardless of frame
+-- rate; no table/string allocation, just float math + SetAlpha. Once
+-- anyOutOfRange goes false, resets to full alpha exactly once (cached
+-- via iconPulsing) instead of calling SetAlpha every single frame.
+OnRecallUpdate = function()
+ if anyOutOfRange then
+ this.icon:SetAlpha(0.35 + 0.65 * math.abs(math.sin(GetTime() * 3)))
+ this.iconPulsing = true
+ elseif this.iconPulsing then
+ this.icon:SetAlpha(1)
+ this.iconPulsing = false
+ end
+end
+
+-- Totemic Recall: left-click casts it immediately (no dropdown, no
+-- per-element timer - recall has no duration of its own, it just clears
+-- totems). Right-click instead toggles TotemBarDB.autoRecall, the flag
+-- that decides whether TotemBar.recallAndCastAll() (the Totems macro's
+-- entry point, core/cast.lua) prepends a recall before redeploying. A
+-- small "A" FontString overlay reflects the current flag state.
+CreateRecallButton = function(index)
+ local name = "TotemBarButtonRecall"
+ local btn = CreateFrame("Button", name, TotemBarFrame)
+ btn:SetWidth(BUTTON_SIZE)
+ btn:SetHeight(BUTTON_SIZE)
+ btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)
+
+ -- Backdrop template kept (needed for SetBackdropColor/BorderColor
+ -- below) but floating: ApplyRoundFrame blanks both to fully transparent
+ -- - the decorative ring frame (below) is the only visible frame now.
+ btn:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 12,
+ insets = { left = 3, right = 3, top = 3, bottom = 3 },
+ })
+
+ local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
+ icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
+ icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
+ icon:SetTexture(GetRecallIcon())
+ icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
+ btn.icon = icon
+ btn.iconPulsing = false -- cached: whether the icon's alpha is currently != 1 (avoids redundant SetAlpha calls once the pulse stops)
+
+ -- Auto-recall indicator: small "A" in the icon's top-left corner. Sits
+ -- inside the area ringTrack (below) is documented to cover ("covers
+ -- the icon's square corners" - see the element button's icon comment)
+ -- - i.e. a REAL, code-confirmed spatial collision with ringTrack, not
+ -- a borderline one. A same-layer HIGHLIGHT promotion (2026-07-16
+ -- ring-occlusion follow-up) tried to win that collision the same way
+ -- ringFill's did, and broke exactly the same way once confirmed
+ -- in-game: 1.12 only paints a Button's HIGHLIGHT layer on hover, so
+ -- the "A" went invisible except under the cursor. Fixed the same way
+ -- as the element buttons' ring stack (see CreateRingOverlay): its own
+ -- small child frame one FrameLevel above btn, so it renders above
+ -- ringTrack deterministically regardless of layer. Doesn't share
+ -- CreateElementButton's ringOverlay (different button, different
+ -- frame tree) - a second, button-local overlay, OVERLAY layer is
+ -- fine since it's the only region on it (no sibling to collide with).
+ -- RefreshRecallIndicator() (called below, and after every toggle)
+ -- shows/hides and colors it based on TotemBarDB.autoRecall.
+ local autoOverlay = CreateRingOverlay(btn, name .. "AutoOverlay")
+ btn.autoOverlay = autoOverlay
+ local autoIndicator = autoOverlay:CreateFontString(name .. "Auto", "OVERLAY")
+ autoIndicator:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
+ autoIndicator:SetPoint("TOPLEFT", icon, "TOPLEFT", 1, -1)
+ autoIndicator:SetJustifyH("LEFT")
+ autoIndicator:SetText("A")
+ autoIndicator:Hide()
+ btn.autoIndicator = autoIndicator
+
+ -- Grounding shadow ellipse (Rev 4 UI chrome). No glow on this button,
+ -- so creation order relative to other regions doesn't matter here.
+ local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
+ shadow:SetTexture(UI_TEXTURE)
+ local sc = uiCoords[UI_SHADOW_CELL]
+ shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
+ shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
+ shadow:SetWidth(58)
+ shadow:SetHeight(20)
+ -- Softened after preview review: full-strength plate read boxy on light
+ -- ground; flatter + more transparent grounds without drawing attention.
+ shadow:SetAlpha(0.7)
+ btn.shadow = shadow
+
+ -- Same floating round frame as the element buttons (permanent
+ -- decorative band, no fill/tint - no ringFill/pulseWave on this
+ -- button, guarded inside ApplyRoundFrame).
+ local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
+ ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ ringTrack:SetWidth(RING_TEX_SIZE)
+ ringTrack:SetHeight(RING_TEX_SIZE)
+ btn.ringTrack = ringTrack
+ ApplyRoundFrame(btn)
+
+ -- No normal texture (UI-Quickslot2 bevel bleeds through transparent
+ -- icon art). Round hover/pushed FX states (Rev 3) only show on
+ -- click/hover, no bleed.
+ ApplyRoundClickStates(btn, RING_TEX_SIZE)
+
+ btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+
+ -- "Go recall + redeploy" visual prompt: pulses the icon's alpha
+ -- while anyOutOfRange is true (set by UpdateTimerDisplays). Only
+ -- touches btn.icon's alpha, never btn.autoIndicator, so the "A"
+ -- auto-recall flag text stays steady/visible throughout.
+ btn:SetScript("OnUpdate", OnRecallUpdate)
+
+ btn:SetScript("OnClick", function()
+ if arg1 == "RightButton" then
+ TotemBarDB.autoRecall = not TotemBarDB.autoRecall
+ if TotemBarDB.autoRecall then
+ ChatOut:AddMessage("TotemBar: auto-recall before setting ON")
+ else
+ ChatOut:AddMessage("TotemBar: auto-recall before setting OFF")
+ end
+ RefreshRecallIndicator()
+ else
+ -- Shared with the TOTEMBAR_RECALL keybind (bind.lua) so the two manual
+ -- paths cannot drift apart: TotemBar.manualRecall does the gate, the
+ -- cooldown check, the queue-safe cast, the refund snapshot and the
+ -- own-tracking wipe (see core/cast.lua). Only the chat feedback is ours.
+ local action = TotemBar.manualRecall()
+ if action == "none-out" then
+ -- Confidently nothing out (cast something this session, all expired):
+ -- don't waste Totemic Recall's 6s cooldown on a no-op cast. After a
+ -- /reload the session flag is clear and 1.12 can't re-detect a pre-reload
+ -- totem, so the gate fails open there. Pressing again also overrides it,
+ -- for a totem we cannot see (e.g. dropped from the action bar).
+ ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown). Press again to recall anyway.")
+ elseif action == "cooldown" then
+ ChatOut:AddMessage("TotemBar: Totemic Recall isn't ready yet - totem timers kept.")
+ end
+ end
+ end)
+
+ btn:SetScript("OnEnter", function()
+ GameTooltip:SetOwner(this, "ANCHOR_TOP")
+ GameTooltip:SetText(RECALL_SPELL_NAME)
+ GameTooltip:AddLine("Left-click: recall now", 1, 1, 1)
+ local state = "OFF"
+ if TotemBarDB and TotemBarDB.autoRecall then
+ state = "ON"
+ end
+ GameTooltip:AddLine("Auto Recall Toggle (right-click): " .. state, 1, 1, 1)
+ local activeCost = TotemBar.sumActiveCost(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, GetTime(), TotemBar.getTotemManaCost, TotemBar.remaining)
+ local pct = (TotemBarDB and TotemBarDB.recallRefundPct) or 0.25
+ local refund = TotemBar.refundAmount(pct, activeCost)
+ if refund > 0 then
+ GameTooltip:AddLine("Refund: ~" .. refund .. " mana", 0.6, 0.6, 1)
+ end
+ GameTooltip:Show()
+ end)
+ btn:SetScript("OnLeave", function()
+ GameTooltip:Hide()
+ end)
+
+ recallButton = btn
+ RefreshRecallIndicator()
+ registerBindOverlay(btn, function() return "TOTEMBAR_RECALL" end)
+
+ return btn
+end
+
+-- The "drop set" button: one click casts all four chosen totems
+-- (TotemBar.recallAndCastAll, with its 2s double-press guard). Placed after
+-- the Recall button.
+CreateDropSetButton = function(index)
+ local name = "TotemBarButtonDropSet"
+ local btn = CreateFrame("Button", name, TotemBarFrame)
+ btn:SetWidth(BUTTON_SIZE)
+ btn:SetHeight(BUTTON_SIZE)
+ btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)
+ btn:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 12,
+ insets = { left = 3, right = 3, top = 3, bottom = 3 },
+ })
+
+ local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
+ icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
+ icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
+ icon:SetTexture(ICONS_TEXTURE)
+ local dc = iconCoords[ICON_DROPSET_CELL]
+ icon:SetTexCoord(dc.l, dc.r, dc.t, dc.b)
+ btn.icon = icon
+
+ -- Grounding shadow ellipse (Rev 4 UI chrome). No glow on this button,
+ -- so creation order relative to other regions doesn't matter here.
+ local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
+ shadow:SetTexture(UI_TEXTURE)
+ local sc = uiCoords[UI_SHADOW_CELL]
+ shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
+ shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
+ shadow:SetWidth(58)
+ shadow:SetHeight(20)
+ -- Softened after preview review: full-strength plate read boxy on light
+ -- ground; flatter + more transparent grounds without drawing attention.
+ shadow:SetAlpha(0.7)
+ btn.shadow = shadow
+
+ -- Same floating round frame as the element buttons (permanent
+ -- decorative band, no fill/tint - no ringFill/pulseWave on this
+ -- button, guarded inside ApplyRoundFrame).
+ local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
+ ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ ringTrack:SetWidth(RING_TEX_SIZE)
+ ringTrack:SetHeight(RING_TEX_SIZE)
+ btn.ringTrack = ringTrack
+ ApplyRoundFrame(btn)
+
+ -- Round hover/pushed FX states (Rev 3), same sheet as the other
+ -- bar buttons.
+ ApplyRoundClickStates(btn, RING_TEX_SIZE)
+ btn:RegisterForClicks("LeftButtonUp")
+ btn:SetScript("OnClick", function()
+ TotemBar.recallAndCastAll()
+ end)
+ btn:SetScript("OnEnter", function()
+ GameTooltip:SetOwner(this, "ANCHOR_TOP")
+ GameTooltip:SetText("Drop all totems")
+ GameTooltip:AddLine("Left-click: cast your whole set", 1, 1, 1)
+ local cost = TotemBar.sumChosenCost(TotemBarDB.chosen, TotemBar.TOTEM_ELEMENTS, TotemBar.getTotemManaCost)
+ if cost and cost > 0 then
+ GameTooltip:AddLine("Mana: " .. cost, 0.6, 0.6, 1)
+ end
+ GameTooltip:Show()
+ end)
+ btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
+
+ registerBindOverlay(btn, function() return "TOTEMBAR_DROPSET" end)
+ dropSetButton = btn
+ return btn
+end
+
+-- Refreshes every element button's remaining-duration timer text.
+-- HYBRID source: prefers pfUI libtotem's GetTotemInfo(slot) when
+-- present and reporting the slot active (also catches totems cast
+-- outside TotemBar); otherwise falls back to TotemBar's own
+-- cast-tracking (TotemBar.activeTotems). Called at most ~5x/sec by
+-- OnBarUpdate below, never per-frame.
+UpdateTimerDisplays = function()
+ local now = GetTime()
+ local hasGTI = (type(GetTotemInfo) == "function")
+ local elements = TotemBar.TOTEM_ELEMENTS
+ local activeTotems = TotemBar.activeTotems
+ local outOfRangeFound = false -- OR-accumulator across this pass; written to anyOutOfRange at the end
+
+ -- Destroyed-totem liveness poll gate for this pass (see local
+ -- lastGuidLivenessCheck above and TotemBar.GUID_LIVENESS_POLL_INTERVAL,
+ -- core/cast.lua) -- computed ONCE per call, not per element, so all four
+ -- slots share the same ~0.5s cadence.
+ local doLivenessPoll = TotemBar.shouldPollLiveness(lastGuidLivenessCheck, now,
+ TotemBar.GUID_LIVENESS_POLL_INTERVAL)
+ if doLivenessPoll then
+ lastGuidLivenessCheck = now
+ end
+
+ -- Mana input for the out-of-mana dim, read ONCE per pass rather than per
+ -- button. (The Clearcasting buff walk that used to sit here is gone with the
+ -- exemption itself -- Elemental Focus does not cover totems, see
+ -- core/manacost.lua -- so this pass no longer scans buffs at all.)
+ local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil
+
+ for i = 1, table.getn(elements) do
+ local element = elements[i]
+ local btn = elementButtons[element]
+ if btn then
+ -- Own-tracking: compute remaining, evicting the record the
+ -- moment it expires (per-element table, no growth).
+ local ownRecord = activeTotems[element]
+ local ownRemaining = nil
+ if ownRecord then
+ ownRemaining = TotemBar.remaining(ownRecord.start, ownRecord.duration, now)
+ if not ownRemaining or ownRemaining <= 0 then
+ activeTotems[element] = nil
+ ownRemaining = nil
+ ownRecord = nil -- expired: not "active" for the range-tint check below either
+ end
+ end
+
+ -- Destroyed-totem liveness poll: only runs once a GUID was
+ -- latched (core/cast.lua's UNIT_CASTEVENT/UNIT_MODEL_CHANGED
+ -- hooks) AND this pass is due (doLivenessPoll, ~0.5s cadence).
+ -- On a client without SuperWoW no record ever gets a guid, so
+ -- this is a no-op there -- today's behaviour, unchanged. A
+ -- destroyed verdict clears the record the SAME way natural
+ -- expiry does above, so the countdown text, duration ring,
+ -- pulse animation and out-of-range tint below all disappear in
+ -- one step -- they already derive from ownRecord's presence.
+ if ownRecord and ownRecord.guid and doLivenessPoll then
+ local exists, guid = nil, nil
+ if type(UnitExists) == "function" then
+ exists, guid = UnitExists(ownRecord.guid)
+ end
+ local health, deadOrGhost = nil, nil
+ if exists then
+ if type(UnitHealth) == "function" then
+ health = UnitHealth(ownRecord.guid)
+ end
+ if type(UnitIsDeadOrGhost) == "function" then
+ deadOrGhost = UnitIsDeadOrGhost(ownRecord.guid)
+ end
+ end
+ if TotemBar.totemDestroyed(exists, health, deadOrGhost) then
+ -- Tombstones the element (see TotemBar.evictDestroyedTotem /
+ -- TotemBar.destroyedTombstone, core/cast.lua) so
+ -- resolveRemaining below stops trusting GTI for it until
+ -- the evicted record's own natural expiry -- otherwise
+ -- pfUI's libtotem (itself just another blind duration
+ -- timer) keeps reporting the SAME destroyed totem active
+ -- and the countdown/ring/pulse reappear from the GTI
+ -- branch a moment after being cleared here (adversarial-
+ -- review finding #1). Mutates TotemBar.activeTotems, the
+ -- SAME table `activeTotems` above already points to, so
+ -- ownRecord/ownRemaining are nil'd out explicitly to
+ -- match rather than re-reading it.
+ TotemBar.evictDestroyedTotem(element)
+ ownRemaining = nil
+ ownRecord = nil
+ end
+ end
+
+ -- Spawn ripple: a NEW own cast (start changed) fires one ripple.
+ -- The freshness check keeps restored/old records from replaying.
+ -- Gated on showPulseWaves (the ripple's own toggle), not
+ -- showPulseBars.
+ if TotemBarDB.showPulseWaves then
+ local startNow = ownRecord and ownRecord.start
+ if startNow and startNow ~= btn.lastStartSeen then
+ btn.lastStartSeen = startNow
+ if now - startNow < 1.5 then
+ btn.spawnAt = startNow
+ end
+ elseif not ownRecord then
+ btn.lastStartSeen = nil
+ end
+ end
+
+ -- pfUI libtotem, when present: Fire=1, Earth=2, Water=3,
+ -- Air=4 - i.e. exactly TotemBar.TOTEM_ELEMENTS' own order.
+ local gtiActive, gtiRemaining, gtiName, gtiStart, gtiDuration
+ if hasGTI then
+ local active, tname, start, duration = GetTotemInfo(i)
+ gtiActive = active
+ gtiName = tname
+ gtiStart = start
+ -- Mastery parity: libtotem reports FLAT book durations, own
+ -- tracking stores the Totemic Mastery-inflated one. Without
+ -- scaling GTI by the same factor the timer flips source at the
+ -- book duration and the countdown jumps back UP instead of
+ -- running out (see TotemBar.gtiDurationWithMastery).
+ gtiDuration = TotemBar.gtiDurationWithMastery(duration, tname,
+ TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery())
+ if start and gtiDuration then
+ gtiRemaining = TotemBar.remaining(start, gtiDuration, now)
+ end
+ -- Latch: did libtotem ever report THIS record's totem active?
+ -- Only then may a later "slot inactive" veto the range tint
+ -- below (TotemBar.rangeTintActive).
+ if ownRecord and gtiActive and gtiName == ownRecord.totemName then
+ ownRecord.gtiTracked = true
+ end
+ end
+
+ -- Destroyed-totem tombstone (adversarial-review finding #1, see
+ -- TotemBar.evictDestroyedTotem above and TotemBar.tombstoneActive/
+ -- resolveRemaining, core/cast.lua): while active, resolveRemaining
+ -- below must not trust GTI for this element even though hasGTI/
+ -- gtiActive above were computed with no knowledge of the eviction.
+ -- Swept once expired so the table never holds a stale entry past
+ -- its own usefulness (bounded to 4 keys regardless, but tidy).
+ local tombstoned = TotemBar.tombstoneActive(TotemBar.destroyedTombstone[element], now)
+ if not tombstoned and TotemBar.destroyedTombstone[element] then
+ TotemBar.destroyedTombstone[element] = nil
+ end
+
+ local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining, tombstoned)
+
+ if remainingVal and TotemBarDB.showTimerText then
+ if not btn.timerVisible then
+ btn.timerText:Show()
+ btn.timerVisible = true
+ end
+ local text = TotemBar.formatRemaining(remainingVal)
+ if text ~= btn.timerLastText then
+ btn.timerText:SetText(text)
+ btn.timerLastText = text
+ end
+ local isLow = remainingVal <= 5
+ if isLow ~= btn.timerLastLow then
+ if isLow then
+ btn.timerText:SetTextColor(1, 0.15, 0.15, 1)
+ else
+ btn.timerText:SetTextColor(1, 1, 1, 1)
+ end
+ btn.timerLastLow = isLow
+ end
+ elseif btn.timerVisible then
+ btn.timerText:Hide()
+ btn.timerVisible = false
+ btn.timerLastText = nil
+ btn.timerLastLow = nil
+ end
+
+ -- Duration ring: flipbook frame from remaining/total, tinted as
+ -- a time-remaining traffic light (green->yellow->orange->red -
+ -- user feedback: element color didn't convey urgency). Gated on
+ -- showDurationRing for the ARC only (ringFill) - ringTrack (the
+ -- permanent decorative frame band) is never hidden here.
+ if TotemBarDB.showDurationRing and remainingVal then
+ local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration,
+ ownRemaining, ownRecord and ownRecord.duration)
+ local idx = TotemBar.ringFrameIndex(remainingVal, totalDur, RING_FRAMES)
+ if idx ~= btn.ringLastIdx then
+ local rc = allRingCoords[idx + 1]
+ btn.ringFill:SetTexCoord(rc.l, rc.r, rc.t, rc.b)
+ btn.ringLastIdx = idx
+ end
+ local tr, tg, tb
+ if totalDur and totalDur > 0 then
+ tr, tg, tb = TotemBar.timeColor(remainingVal / totalDur)
+ else
+ tr, tg, tb = 1, 1, 1
+ end
+ btn.ringFill:SetVertexColor(tr, tg, tb)
+ if not btn.ringVisible then
+ btn.ringFill:Show()
+ btn.ringVisible = true
+ end
+ elseif btn.ringVisible then
+ btn.ringFill:Hide()
+ btn.ringVisible = false
+ btn.ringLastIdx = nil
+ end
+
+ -- Ripple wave + pulse arc inputs: only for totems with
+ -- PULSE_DATA. Phase origin: own record start (anchored by real
+ -- gain events where available) or libtotem's start for casts
+ -- made outside TotemBar. Both the wave's animation AND the
+ -- pulse arc's flipbook frame are driven every FRAME in
+ -- UpdateWaves below (moved off this 0.1s tick - a 10Hz flipbook
+ -- update read as choppy, see rev7 report) - this block just
+ -- refreshes what they should animate toward. showPulseWaves and
+ -- showPulseBars are independent display toggles, but the arc
+ -- reads the SAME waveInterval/waveOrigin/waveOneshotAt/
+ -- waveOneshotDelay fields as the wave, so they're populated
+ -- whenever EITHER toggle is on.
+ local waveSet = false
+ if remainingVal then
+ local totemName = (ownRecord and ownRecord.totemName) or gtiName
+ local pd = totemName and TotemBar.PULSE_DATA[totemName]
+ if pd then
+ local startAt = (ownRecord and ownRecord.start) or gtiStart
+ if startAt then
+ if TotemBarDB.showPulseWaves or TotemBarDB.showPulseBars then
+ if pd.ptype == "oneshot" then
+ btn.waveInterval = nil
+ btn.waveOneshotAt = startAt + pd.delay
+ btn.waveOrigin = nil
+ btn.waveOneshotDelay = pd.delay
+ else
+ btn.waveInterval = pd.interval
+ btn.waveOrigin = (ownRecord and ownRecord.pulseAnchor) or startAt
+ btn.waveOneshotAt = nil
+ btn.waveOneshotDelay = nil
+ end
+ waveSet = true
+ end
+ end
+ end
+ end
+ if not waveSet then
+ btn.waveInterval = nil
+ btn.waveOneshotAt = nil
+ btn.waveOrigin = nil
+ btn.waveOneshotDelay = nil
+ if btn.waveVisible then
+ btn.pulseWave:Hide()
+ btn.waveVisible = false
+ end
+ end
+ -- Pulse arc hide-on-expiry: UpdateWaves (every frame) recomputes
+ -- arcRatio from the fields above and will already hide it within
+ -- the next frame once waveInterval/waveOneshotAt go nil, but that
+ -- can be up to one frame stale right after this tick - not worth
+ -- a duplicate hide-path here (see rev7 report).
+
+ -- Out-of-range tint: buff-presence based. A totem's party
+ -- buff uses the SAME icon texture as the totem spell itself
+ -- (verified in-game), so "am I in range?" == "do I have a
+ -- buff whose texture matches this totem's icon?".
+ --
+ -- ACTIVE requires an own-tracking record (ownRecord, already
+ -- nil'd above once its stored duration expires). pfUI's libtotem
+ -- may veto it - that is what keeps a totem someone/something
+ -- DESTROYED (burned, killed) before its timer ran out from
+ -- flashing red - but ONLY for a slot libtotem actually tracked
+ -- (gtiTracked, latched above). Its single cast queue records at
+ -- most one totem per multi-drop, so vetoing on a merely MISSING
+ -- GTI record silenced the tint and the Recall pulse for most of
+ -- a freshly dropped set. See TotemBar.rangeTintActive.
+ local rangeActive = TotemBar.rangeTintActive(ownRecord ~= nil, hasGTI,
+ ownRecord and ownRecord.gtiTracked, gtiActive)
+
+ local rangeRed = false
+ if rangeActive then
+ if TotemBar.hasBuffWithIcon(ownRecord.icon) then
+ -- In range: remember it (self-learning - marks this
+ -- as a buff totem so a later drop-off can be told
+ -- apart from a totem that simply never grants one).
+ ownRecord.everHadBuff = true
+ elseif ownRecord.everHadBuff then
+ -- Had the buff earlier from this cast, don't have it
+ -- now: wandered out of the totem's range. (A non-buff
+ -- totem - Searing/Magma/Grounding - never sets
+ -- everHadBuff, so it can never turn red here.)
+ rangeRed = true
+ end
+ end
+
+ -- Out-of-mana dim for the totem THIS slot would cast (the chosen
+ -- one, not the record's - the record is a totem already standing,
+ -- which costs nothing to keep). Composed with the range tint in one
+ -- place: two independent SetVertexColor call sites would race, and
+ -- whichever ran last would win (see TotemBar.iconTintFor).
+ local chosenName = TotemBarDB.chosen and TotemBarDB.chosen[element]
+ local oom = false
+ if chosenName then
+ oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(chosenName), playerMana)
+ end
+
+ local tr2, tg2, tb2, tintKey = TotemBar.iconTintFor(rangeRed, oom)
+ if tintKey ~= btn.tintKey then
+ btn.icon:SetVertexColor(tr2, tg2, tb2)
+ btn.tintKey = tintKey
+ end
+ btn.tintRed = rangeRed
+
+ if btn.tintRed then
+ outOfRangeFound = true
+ end
+ end
+ end
+
+ anyOutOfRange = outOfRangeFound
+end
+
+-- Re-applies every Pulse-UI setting to the live buttons (options panel
+-- setters call this; cheap, not on any hot path).
+function TotemBar.RefreshPulseUI()
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ local btn = elementButtons[elements[i]]
+ if btn then
+ ApplyRoundFrame(btn)
+ ApplyTimerAnchor(btn)
+ if not TotemBarDB.showDurationRing and btn.ringVisible then
+ btn.ringFill:Hide()
+ btn.ringVisible = false
+ btn.ringLastIdx = nil
+ end
+ if not TotemBarDB.showPulseWaves then
+ btn.waveInterval = nil
+ btn.waveOneshotAt = nil
+ btn.waveOrigin = nil
+ btn.waveOneshotDelay = nil
+ btn.spawnAt = nil
+ if btn.waveVisible then
+ btn.pulseWave:Hide()
+ btn.waveVisible = false
+ end
+ if btn.glow then
+ btn.glow:Hide()
+ btn.glowVisible = false
+ end
+ end
+ if not TotemBarDB.showPulseBars and btn.pulseVisible then
+ btn.pulseArc:Hide()
+ btn.pulseVisible = false
+ btn.pulseArcLastIdx = nil
+ end
+ if not TotemBarDB.showTimerText and btn.timerVisible then
+ btn.timerText:Hide()
+ btn.timerVisible = false
+ btn.timerLastText = nil
+ btn.timerLastLow = nil
+ end
+ end
+ end
+
+ -- Recall/DropSet share ApplyRoundFrame with the element buttons
+ -- (harmless re-apply of their static ring-frame texture; kept for
+ -- symmetry with the element-button refresh above). Flyout icons don't
+ -- go through ApplyRoundFrame (see EnsureFlyoutFrame) but their ring
+ -- frame texture is likewise static now, set once at creation - no
+ -- per-refresh work needed for them.
+ if recallButton then
+ ApplyRoundFrame(recallButton)
+ end
+ if dropSetButton then
+ ApplyRoundFrame(dropSetButton)
+ end
+end
+
+-- Dev aid, one line of live WoW-object state for a single ring texture
+-- (ringFill, ringTrack, the icon, or timerText), called by
+-- TotemBar.DumpRingRenderState below. Every getter is pcall-wrapped
+-- individually ("?" on failure/absence) - this must never itself error,
+-- not even on a stale/nil texture object.
+-- GetTexCoord's return arity is UNCONFIRMED on this 1.12 client (4-value
+-- left/right/top/bottom vs the 8-value corner form retail uses) - its
+-- result is captured via a table constructor (`{ pcall(...) }`) instead
+-- of assuming a fixed number of return values, so the dump itself is the
+-- in-game measurement that settles the question.
+-- rect=[...]/layer= added alongside (2026-07-16, occlusion follow-up): the
+-- screen-space GetLeft/Right/Top/Bottom quad and GetDrawLayer, so a stale
+-- anchor (ringFill not actually over the button) or a same-layer sibling
+-- collision (1.12 discards SetDrawLayer sublevels - same main layer means
+-- undefined draw order between siblings) show up directly in the dump
+-- instead of needing to be inferred from shown/alpha/texCoord alone.
+local function DumpRingTextureLine(element, label, tex)
+ if not tex then
+ return " " .. element .. " " .. label .. ": (nil texture object)\n"
+ end
+ local shownOk, shown = pcall(tex.IsShown, tex)
+ local texOk, texPath = pcall(tex.GetTexture, tex)
+ local alphaOk, alpha = pcall(tex.GetAlpha, tex)
+ local wOk, w = pcall(tex.GetWidth, tex)
+ local hOk, h = pcall(tex.GetHeight, tex)
+ local leftOk, left = pcall(tex.GetLeft, tex)
+ local rightOk, right = pcall(tex.GetRight, tex)
+ local topOk, top = pcall(tex.GetTop, tex)
+ local bottomOk, bottom = pcall(tex.GetBottom, tex)
+ local layerOk, layer = pcall(tex.GetDrawLayer, tex)
+
+ local parentShown, parentAlpha = "?", "?"
+ local parentOk, parent = pcall(tex.GetParent, tex)
+ if parentOk and parent then
+ local pShownOk, pShown = pcall(parent.IsShown, parent)
+ local pAlphaOk, pAlpha = pcall(parent.GetAlpha, parent)
+ if pShownOk then parentShown = tostring(pShown) end
+ if pAlphaOk then parentAlpha = tostring(pAlpha) end
+ end
+
+ local coordParts = { pcall(tex.GetTexCoord, tex) }
+ local coordStr = "?"
+ if coordParts[1] then
+ local n = table.getn(coordParts)
+ local s = ""
+ for ci = 2, n do
+ if s ~= "" then s = s .. "," end
+ s = s .. tostring(coordParts[ci])
+ end
+ coordStr = "[" .. s .. "]"
+ end
+
+ return " " .. element .. " " .. label .. ": shown=" .. (shownOk and tostring(shown) or "?")
+ .. " texture='" .. (texOk and tostring(texPath) or "?") .. "'"
+ .. " alpha=" .. (alphaOk and tostring(alpha) or "?")
+ .. " texCoord=" .. coordStr
+ .. " w=" .. (wOk and tostring(w) or "?")
+ .. " h=" .. (hOk and tostring(h) or "?")
+ .. " parentShown=" .. parentShown
+ .. " parentAlpha=" .. parentAlpha
+ .. " rect=[" .. (leftOk and tostring(left) or "?") .. "," .. (topOk and tostring(top) or "?")
+ .. "," .. (rightOk and tostring(right) or "?") .. "," .. (bottomOk and tostring(bottom) or "?") .. "]"
+ .. " layer=" .. (layerOk and tostring(layer) or "?") .. "\n"
+end
+
+-- Dev aid, one line per region returned by a button's own :GetRegions()
+-- (2026-07-16, occlusion follow-up). GetRegions() is Blizzard's own
+-- z-order-relevant enumeration of the regions directly parented to a
+-- frame - within a shared main draw layer (see the sublevel-discard note
+-- above) it's the best available proxy for actual sibling paint order, so
+-- walking it WITH the index is how we tell "ringTrack drawn 2nd, after
+-- ringFill" apart from "ringTrack drawn 1st, before ringFill" from outside
+-- the client. Deliberately only ever called on a single small element
+-- button (~8 regions), never on a big container frame - a wide frame can
+-- carry hundreds of regions and walking all of them was the cause of an
+-- earlier crash, hence the hard cap below regardless of how many the
+-- client reports.
+local REGION_WALK_CAP = 24
+local function DumpRegionLine(idx, region)
+ if not region then
+ return " [" .. idx .. "] (nil region)\n"
+ end
+ local typeOk, objType = pcall(region.GetObjectType, region)
+ local nameOk, name = pcall(region.GetName, region)
+ local layerOk, layer = pcall(region.GetDrawLayer, region)
+ local shownOk, shown = pcall(region.IsShown, region)
+ local leftOk, left = pcall(region.GetLeft, region)
+ local topOk, top = pcall(region.GetTop, region)
+
+ -- Texture and FontString expose different "what is this" getters
+ -- (GetTexture vs GetText); try both, pcall-guarded, and use whichever
+ -- succeeds - a region of the other kind simply fails the wrong one.
+ local content = "?"
+ local texOk, texPath = pcall(region.GetTexture, region)
+ if texOk and texPath ~= nil then
+ content = "texture='" .. tostring(texPath) .. "'"
+ else
+ local textOk, text = pcall(region.GetText, region)
+ if textOk and text ~= nil then
+ content = "text='" .. tostring(text) .. "'"
+ end
+ end
+
+ return " [" .. idx .. "] name=" .. (nameOk and tostring(name) or "?")
+ .. " type=" .. (typeOk and tostring(objType) or "?")
+ .. " layer=" .. (layerOk and tostring(layer) or "?")
+ .. " shown=" .. (shownOk and tostring(shown) or "?")
+ .. " " .. content
+ .. " left=" .. (leftOk and tostring(left) or "?")
+ .. " top=" .. (topOk and tostring(top) or "?") .. "\n"
+end
+
+-- Dev aid, called from TotemBar.DumpTimerState (core/cast.lua)'s /tb tdump
+-- so the export stays a single file/single command. Purpose: the pure
+-- ring math (resolveRemaining/resolveDuration/ringFrameIndex) was already
+-- proven correct offline for the Searing-ring report (text shows, no
+-- ring), so this section captures what DumpTimerState's own-tracking/GTI
+-- dump can't: the RENDER side. Per element button: (a) which button
+-- instance is bound to the element, (b) the cached ring state this same
+-- render path reads/writes (btn.ringVisible/btn.ringLastIdx - the exact
+-- fields UpdateTimerDisplays above uses, not a guess), (c) a same-tick
+-- recompute of resolveDuration/ringFrameIndex/timeColor with the SAME
+-- inputs the render path would use right now, (d) the countdown
+-- FontString's own live state as a control group (it's the one the player
+-- actually sees), and (e) the LIVE WoW state of both ring textures (ringFill, the
+-- gated arc in question, and ringTrack, the always-on decorative band -
+-- if ringTrack is ALSO dark then the whole assembly, not just the fill,
+-- is hidden/mispositioned upstream of this addon's own Show/Hide calls).
+function TotemBar.DumpRingRenderState()
+ local now = GetTime()
+ local hasGTI = (type(GetTotemInfo) == "function")
+ local elements = TotemBar.TOTEM_ELEMENTS
+ local activeTotems = TotemBar.activeTotems
+ local out = "\n-- render state (per-element ring + text, live WoW objects) --\n"
+
+ for i = 1, table.getn(elements) do
+ local element = elements[i]
+ local btn = elementButtons[element]
+ if not btn then
+ out = out .. element .. ": (no button)\n"
+ else
+ local btnName = "?"
+ local okName, nameVal = pcall(btn.GetName, btn)
+ if okName and nameVal then
+ btnName = nameVal
+ end
+
+ -- Same recompute inputs/precedence as UpdateTimerDisplays
+ -- above, same tick (i.e. what the render path would compute
+ -- if it ran again right now).
+ local ownRecord = activeTotems[element]
+ local ownRemaining = nil
+ if ownRecord then
+ ownRemaining = TotemBar.remaining(ownRecord.start, ownRecord.duration, now)
+ end
+ local gtiActive, gtiRemaining, gtiDuration
+ if hasGTI then
+ local ok, active, tname, start, duration = pcall(GetTotemInfo, i)
+ if ok then
+ -- Same mastery scaling UpdateTimerDisplays applies, so this
+ -- dump keeps reporting what the render path would compute.
+ gtiActive = active
+ gtiDuration = TotemBar.gtiDurationWithMastery(duration, tname,
+ TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery())
+ if start and gtiDuration then
+ gtiRemaining = TotemBar.remaining(start, gtiDuration, now)
+ end
+ end
+ end
+ -- Same tombstone read UpdateTimerDisplays' live pass does (see
+ -- its own comment) -- read-only here, the live pass owns
+ -- sweeping expired entries.
+ local tombstoned = TotemBar.tombstoneActive(TotemBar.destroyedTombstone[element], now)
+ local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining, tombstoned)
+ local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration,
+ ownRemaining, ownRecord and ownRecord.duration)
+ local idxStr = "n/a"
+ if remainingVal then
+ idxStr = tostring(TotemBar.ringFrameIndex(remainingVal, totalDur, RING_FRAMES))
+ end
+ local colorStr = "n/a"
+ if totalDur and totalDur > 0 and remainingVal then
+ local okC, cr, cg, cb = pcall(TotemBar.timeColor, remainingVal / totalDur)
+ if okC then
+ colorStr = "(" .. tostring(cr) .. "," .. tostring(cg) .. "," .. tostring(cb) .. ")"
+ end
+ end
+
+ out = out .. element .. ": btn=" .. btnName
+ .. " ringVisible=" .. tostring(btn.ringVisible)
+ .. " ringLastIdx=" .. tostring(btn.ringLastIdx)
+ .. " | recompute: remainingVal=" .. tostring(remainingVal)
+ .. " totalDur=" .. tostring(totalDur)
+ .. " ringIdx=" .. idxStr
+ .. " timeColor=" .. colorStr
+ .. " tombstoned=" .. tostring(tombstoned) .. "\n"
+
+ -- Control group: the countdown text the player actually sees, so a
+ -- mismatch (text shown, ring both cached+live hidden) pins the
+ -- bug to the ring specifically, not a shared render gate.
+ -- Guarded on btn.timerText itself (not just pcall on its
+ -- methods): indexing .IsShown/.GetText on a nil timerText
+ -- would error while pcall's OWN arguments are being evaluated,
+ -- i.e. outside pcall's protection - shouldn't happen (created
+ -- unconditionally in CreateElementButton) but this dump must
+ -- never be the thing that errors.
+ local tShown, tText = "?", "?"
+ local tLeft, tTop, tRight, tBottom, tLayer = "?", "?", "?", "?", "?"
+ if btn.timerText then
+ local tShownOk, tShownVal = pcall(btn.timerText.IsShown, btn.timerText)
+ local tTextOk, tTextVal = pcall(btn.timerText.GetText, btn.timerText)
+ local tLeftOk, tLeftVal = pcall(btn.timerText.GetLeft, btn.timerText)
+ local tRightOk, tRightVal = pcall(btn.timerText.GetRight, btn.timerText)
+ local tTopOk, tTopVal = pcall(btn.timerText.GetTop, btn.timerText)
+ local tBottomOk, tBottomVal = pcall(btn.timerText.GetBottom, btn.timerText)
+ local tLayerOk, tLayerVal = pcall(btn.timerText.GetDrawLayer, btn.timerText)
+ if tShownOk then tShown = tostring(tShownVal) end
+ if tTextOk then tText = tostring(tTextVal) end
+ if tLeftOk then tLeft = tostring(tLeftVal) end
+ if tRightOk then tRight = tostring(tRightVal) end
+ if tTopOk then tTop = tostring(tTopVal) end
+ if tBottomOk then tBottom = tostring(tBottomVal) end
+ if tLayerOk then tLayer = tostring(tLayerVal) end
+ end
+ out = out .. " " .. element .. " timerText: shown=" .. tShown
+ .. " text='" .. tText .. "'"
+ .. " rect=[" .. tLeft .. "," .. tTop .. "," .. tRight .. "," .. tBottom .. "]"
+ .. " layer=" .. tLayer .. "\n"
+
+ out = out .. DumpRingTextureLine(element, "ringFill", btn.ringFill)
+ out = out .. DumpRingTextureLine(element, "ringTrack", btn.ringTrack)
+ out = out .. DumpRingTextureLine(element, "icon", btn.icon)
+
+ -- Region walk (2026-07-16, occlusion follow-up): btn is a
+ -- small element button (icon/shadow/ringTrack/glow/timerText,
+ -- ~5 regions now that ringFill/pulseWave/pulseArc moved off
+ -- btn - see below) - safe to enumerate in full, unlike a big
+ -- container frame. Index order is GetRegions()'s own return
+ -- order, our best proxy for actual same-layer sibling paint
+ -- order (see DumpRegionLine above).
+ local regionParts = { pcall(btn.GetRegions, btn) }
+ if regionParts[1] then
+ local total = table.getn(regionParts) - 1
+ local capped = total
+ if capped > REGION_WALK_CAP then capped = REGION_WALK_CAP end
+ out = out .. " " .. element .. " regions (count=" .. total .. ", showing " .. capped .. "):\n"
+ for ri = 1, capped do
+ out = out .. DumpRegionLine(ri, regionParts[ri + 1])
+ end
+ else
+ out = out .. " " .. element .. " regions: (GetRegions failed)\n"
+ end
+
+ -- Second region walk (2026-07-16, ring-overlay fix): ringFill/
+ -- pulseWave/pulseArc now live on btn.ringOverlay, a separate
+ -- higher-FrameLevel child frame (see CreateRingOverlay) - they
+ -- no longer show up in btn:GetRegions() above at all, so the
+ -- dump would silently go blind on exactly the textures this
+ -- tool exists to inspect without this second walk. Same small
+ -- frame (~3 regions), same cap.
+ if btn.ringOverlay then
+ local overlayParts = { pcall(btn.ringOverlay.GetRegions, btn.ringOverlay) }
+ if overlayParts[1] then
+ local oTotal = table.getn(overlayParts) - 1
+ local oCapped = oTotal
+ if oCapped > REGION_WALK_CAP then oCapped = REGION_WALK_CAP end
+ out = out .. " " .. element .. " ringOverlay regions (count=" .. oTotal .. ", showing " .. oCapped .. "):\n"
+ for ri = 1, oCapped do
+ out = out .. DumpRegionLine(ri, overlayParts[ri + 1])
+ end
+ else
+ out = out .. " " .. element .. " ringOverlay regions: (GetRegions failed)\n"
+ end
+ else
+ out = out .. " " .. element .. " ringOverlay: (nil)\n"
+ end
+ end
+ end
+
+ return out
+end
+
+-- Ripple animation + pulse-arc flipbook: expanding/fading ring in the
+-- element color (one wave per pulse) and the circular countdown arc, both
+-- driven every FRAME (pure float math + Set calls, no allocation, same
+-- class as OnRecallUpdate - the arc joined the wave here in rev7, was a
+-- 0.1s-tick flipbook update before and read as choppy). Inputs are
+-- refreshed by the 0.1s tick in UpdateTimerDisplays above. Wave/glow are
+-- gated on showPulseWaves, the arc on showPulseBars - independent toggles
+-- that happen to share the same waveInterval/waveOrigin/waveOneshotAt/
+-- waveOneshotDelay fields (see the tick's comment).
+UpdateWaves = function()
+ local now = GetTime()
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ local btn = elementButtons[elements[i]]
+ if btn then
+ if TotemBarDB.showPulseWaves then
+ local frac = nil
+ if btn.spawnAt then
+ frac = TotemBar.oneshotWaveFrac(btn.spawnAt, 0, now, SPAWN_WAVE_DURATION)
+ if not frac then
+ btn.spawnAt = nil
+ end
+ end
+ if not frac then
+ if btn.waveInterval then
+ local ratio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
+ frac = TotemBar.waveFrac(ratio, btn.waveInterval, WAVE_DURATION)
+ elseif btn.waveOneshotAt then
+ frac = TotemBar.oneshotWaveFrac(btn.waveOneshotAt, 0, now, WAVE_DURATION)
+ end
+ end
+ if frac then
+ local size = RING_TEX_SIZE * (1 + (WAVE_MAX_SCALE - 1) * frac)
+ btn.pulseWave:SetWidth(size)
+ btn.pulseWave:SetHeight(size)
+ btn.pulseWave:SetAlpha(WAVE_BASE_ALPHA * (1 - frac))
+ if not btn.waveVisible then
+ btn.pulseWave:Show()
+ btn.waveVisible = true
+ end
+ elseif btn.waveVisible then
+ btn.pulseWave:Hide()
+ btn.waveVisible = false
+ end
+
+ -- Anticipation glow: ramps up as the next pulse (or the
+ -- oneshot detonation) approaches; hidden otherwise. Tied to
+ -- the wave's own toggle (showPulseWaves), unchanged.
+ local ga = nil
+ if btn.waveInterval then
+ local gratio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
+ ga = TotemBar.glowRamp(gratio, GLOW_RAMP_START)
+ elseif btn.waveOneshotAt and btn.waveOneshotDelay and now < btn.waveOneshotAt then
+ ga = TotemBar.glowRamp(1 - ((btn.waveOneshotAt - now) / btn.waveOneshotDelay), GLOW_RAMP_START)
+ end
+ if ga then
+ btn.glow:SetAlpha(GLOW_MAX_ALPHA * ga)
+ if not btn.glowVisible then
+ btn.glow:Show()
+ btn.glowVisible = true
+ end
+ elseif btn.glowVisible then
+ btn.glow:Hide()
+ btn.glowVisible = false
+ end
+ elseif btn.waveVisible or btn.glowVisible then
+ -- Toggled off (RefreshPulseUI normally already clears these
+ -- immediately; this is the per-frame safety net).
+ if btn.waveVisible then
+ btn.pulseWave:Hide()
+ btn.waveVisible = false
+ end
+ if btn.glowVisible then
+ btn.glow:Hide()
+ btn.glowVisible = false
+ end
+ end
+
+ -- Pulse arc: continuous countdown readout toward the next
+ -- pulse/detonation (own toggle: showPulseBars, independent of
+ -- the wave/glow above). A duplicate pulseRatio()/oneshotRatio()
+ -- call vs. the wave block above is cheap and keeps this block
+ -- self-contained (see rev7 report).
+ local arcRatio = nil
+ if TotemBarDB.showPulseBars then
+ if btn.waveInterval then
+ arcRatio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
+ elseif btn.waveOneshotAt and btn.waveOneshotDelay then
+ arcRatio = TotemBar.oneshotRatio(btn.waveOneshotAt - btn.waveOneshotDelay, btn.waveOneshotDelay, now)
+ if arcRatio and arcRatio >= 1 then arcRatio = nil end
+ end
+ end
+ if arcRatio then
+ local idx = math.floor(arcRatio * (RING_FRAMES - 1) + 0.5)
+ if idx ~= btn.pulseArcLastIdx then
+ local rc = allRingCoords[idx + 1]
+ btn.pulseArc:SetTexCoord(rc.l, rc.r, rc.t, rc.b)
+ btn.pulseArcLastIdx = idx
+ end
+ if not btn.pulseVisible then
+ btn.pulseArc:Show()
+ btn.pulseVisible = true
+ end
+ elseif btn.pulseVisible then
+ btn.pulseArc:Hide()
+ btn.pulseVisible = false
+ btn.pulseArcLastIdx = nil
+ end
+ end
+ end
+end
+
+OnBarUpdate = function()
+ UpdateWaves()
+ timerElapsed = timerElapsed + arg1
+ if timerElapsed < TIMER_UPDATE_INTERVAL then
+ return
+ end
+ timerElapsed = 0
+ UpdateTimerDisplays()
+end
+
+OnDragStart = function()
+ if not TotemBarDB.locked then
+ this:StartMoving()
+ end
+end
+
+OnDragStop = function()
+ this:StopMovingOrSizing()
+ local point, _, relPoint, x, y = this:GetPoint()
+ TotemBarDB.point = point
+ TotemBarDB.relPoint = relPoint
+ TotemBarDB.x = x
+ TotemBarDB.y = y
+end
+
+-- (Re)applies the current BUTTON_GAP to the pending-assignment panel: frame
+-- width (4 icons + gaps + the fixed accept-button margin) and each icon's
+-- horizontal anchor - both baked in once when the panel is first built, so
+-- (like layoutFlyoutIcons above) this must be called again from
+-- TotemBar.SetButtonGap when the spacing slider changes live, not just at
+-- build time. `frame` lets the build-time call pass the not-yet-assigned
+-- local `f` directly; later calls omit it and fall back to the assignFrame
+-- upvalue (guarded: a no-op if the panel has never been built yet).
+local function layoutAssignIcons(frame)
+ local f = frame or assignFrame
+ if not f then
+ return
+ end
+ f:SetWidth(4 * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP + 40)
+ for i = 1, 4 do
+ local ico = f.icons and f.icons[i]
+ if ico then
+ ico:ClearAllPoints()
+ ico:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT",
+ BUTTON_GAP + (i - 1) * (BUTTON_SIZE + BUTTON_GAP), 6)
+ end
+ end
+end
+
+-- Lazily builds the pending-suggestion panel: a heading label, a row of up
+-- to 4 element-ordered totem icons, an Accept button, and a close "X".
+-- Built once, reused; event-driven show/hide (no OnUpdate, no per-frame
+-- allocation). Anchored above the bar.
+EnsureAssignFrame = function()
+ if assignFrame then
+ return assignFrame
+ end
+
+ -- Parent to the bar so the pending panel inherits the bar's scale
+ -- (UI-size slider), like the flyout. Still DIALOG strata below. Width
+ -- is set below by layoutAssignIcons (once f.icons exists), not here.
+ local f = CreateFrame("Frame", "TotemBarAssignFrame", TotemBarFrame)
+ f:SetFrameStrata("DIALOG")
+ f:SetHeight(BUTTON_SIZE + 34)
+ -- Rev 4 UI chrome: shared panel skin (bespoke border/bg) instead of the
+ -- generic tooltip textures. The bg texture carries the color now, so
+ -- the tint just needs to be near-opaque white.
+ f:SetBackdrop(PANEL_BACKDROP)
+ f:SetBackdropColor(1, 1, 1, 0.97)
+ f:SetClampedToScreen(true)
+ f:ClearAllPoints()
+ f:SetPoint("BOTTOM", TotemBarFrame, "TOP", 0, 6)
+
+ local heading = f:CreateFontString("TotemBarAssignHeading", "OVERLAY")
+ heading:SetFont("Fonts\\FRIZQT__.TTF", 11, "OUTLINE")
+ heading:SetPoint("TOP", f, "TOP", 0, -6)
+ heading:SetText("Assigned set")
+ f.heading = heading
+
+ f.icons = {}
+ for i = 1, 4 do
+ local ico = f:CreateTexture("TotemBarAssignIcon" .. i, "ARTWORK")
+ ico:SetWidth(BUTTON_SIZE)
+ ico:SetHeight(BUTTON_SIZE)
+ -- Anchor applied below via layoutAssignIcons (once the whole row
+ -- exists), not here - the same helper re-applies it live when
+ -- BUTTON_GAP changes.
+ ico:SetTexCoord(0.08, 0.92, 0.08, 0.92)
+ f.icons[i] = ico
+ end
+
+ -- Sets the frame width and every icon's anchor per BUTTON_GAP now that
+ -- the whole row exists (assignFrame isn't assigned yet at this point in
+ -- the function, hence passing `f` explicitly - see layoutAssignIcons's
+ -- own comment).
+ layoutAssignIcons(f)
+
+ local accept = CreateFrame("Button", "TotemBarAssignAccept", f, "UIPanelButtonTemplate")
+ accept:SetWidth(28)
+ accept:SetHeight(BUTTON_SIZE)
+ accept:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -6, 6)
+ accept:SetText("OK")
+ accept:SetScript("OnClick", function()
+ TotemBar.ApplyPending()
+ end)
+ accept:SetScript("OnEnter", function()
+ GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
+ GameTooltip:SetText("Apply assigned set")
+ GameTooltip:AddLine("Sets these as your chosen totems (no cast).", 1, 1, 1)
+ GameTooltip:Show()
+ end)
+ accept:SetScript("OnLeave", function() GameTooltip:Hide() end)
+ f.accept = accept
+
+ local close = CreateFrame("Button", "TotemBarAssignClose", f, "UIPanelCloseButton")
+ close:SetWidth(22)
+ close:SetHeight(22)
+ close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -2, -2)
+ close:SetScript("OnClick", function()
+ TotemBar.ClearAssignment()
+ end)
+ f.close = close
+
+ f:Hide()
+ assignFrame = f
+ return f
+end
+
+-- Populates and shows the panel from TotemBar.pending. Unknown totems are
+-- greyed (desaturated + dimmed). Called by ReceiveAssignment via the hook.
+TotemBar.ShowAssignPanel = function()
+ local p = TotemBar.pending
+ if not p then
+ return
+ end
+ local f = EnsureAssignFrame()
+ f.heading:SetText(p.label or "Assigned set")
+
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, 4 do
+ local element = elements[i]
+ local name = element and p.set[element]
+ local ico = f.icons[i]
+ if name then
+ local tex, known = ResolveTotemIcon(name)
+ ico:SetTexture(tex)
+ if known then
+ ico:SetVertexColor(1, 1, 1)
+ ico:SetAlpha(1)
+ else
+ ico:SetVertexColor(0.5, 0.5, 0.5)
+ ico:SetAlpha(0.5)
+ end
+ ico:Show()
+ else
+ ico:Hide()
+ end
+ end
+ f:Show()
+end
+
+-- Hides the panel (called by ClearAssignment / ApplyPending via the hook).
+TotemBar.HideAssignPanel = function()
+ if assignFrame then
+ assignFrame:Hide()
+ end
+end
+
+-- Repositions the bar buttons (up to six) into the grid configured by
+-- TotemBarDB.barLayout and resizes the bar frame to match. Buttons are
+-- ordered: the four element buttons (TOTEM_ELEMENTS order), then Recall,
+-- then DropSet. The DropSet button is optional (TotemBarDB.showDropSetButton,
+-- options panel "Show drop-all button"): when off it is left out of the grid
+-- AND hidden, which drops the count to 5 - it is always the LAST slot in
+-- every layout table, so slots 1..5 stay exactly where they were and the
+-- 2x2 element block never breaks. Called once at the end of BuildUI (after
+-- every button exists), from options.lua's layout cycle button, and from
+-- that checkbox's setter. No-op before BuildUI has run (TotemBarFrame
+-- doesn't exist yet).
+TotemBar.ApplyBarLayout = function()
+ if not TotemBarFrame then
+ return
+ end
+
+ -- nil-safe read: an old SavedVariables file (or a call before
+ -- ensureDefaults) means "show", matching the default.
+ local showDrop = TotemBarDB.showDropSetButton
+ if showDrop == nil then
+ showDrop = true
+ end
+
+ local buttons = {}
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ tinsert(buttons, elementButtons[elements[i]])
+ end
+ tinsert(buttons, recallButton)
+ if showDrop then
+ tinsert(buttons, dropSetButton)
+ end
+
+ -- The button frame itself always exists (BuildUI creates it
+ -- unconditionally, so the toggle needs no UI rebuild and the bind
+ -- overlay stays registered) - only its visibility follows the option.
+ -- The TOTEMBAR_DROPSET keybinding keeps working either way: it is bound
+ -- to the cast function, not to this frame.
+ if dropSetButton then
+ if showDrop then
+ dropSetButton:Show()
+ else
+ dropSetButton:Hide()
+ end
+ end
+
+ local count = table.getn(buttons)
+ local layout = TotemBarDB.barLayout
+ -- Explicit per-button {col, row} slots (core/optionslogic.lua): the 2x3
+ -- layout keeps the four elements as a 2x2 block in the first two columns
+ -- with Recall/DropSet as the third column, which no row-major fill order
+ -- produces - hence a positions table per layout instead of index math.
+ local positions = TotemBar.BAR_LAYOUT_POSITIONS[layout] or TotemBar.BAR_LAYOUT_POSITIONS["1x6"]
+ local cols = BAR_LAYOUT_COLS[layout] or 6
+ local extra = 0
+
+ for i = 1, count do
+ local btn = buttons[i]
+ local pos = positions[i]
+ if btn and pos then
+ btn:ClearAllPoints()
+ btn:SetPoint("TOPLEFT", TotemBarFrame, "TOPLEFT",
+ BUTTON_GAP + pos[1] * (BUTTON_SIZE + BUTTON_GAP),
+ -(BUTTON_GAP + pos[2] * (BUTTON_SIZE + BUTTON_GAP + extra)))
+ end
+ end
+
+ local width, height = TotemBar.barDimensions(count, cols, BUTTON_SIZE, BUTTON_GAP, extra)
+ TotemBarFrame:SetWidth(width)
+ TotemBarFrame:SetHeight(height)
+end
+
+function TotemBar.BuildUI()
+ if TotemBarFrame then
+ return
+ end
+
+ local numElements = table.getn(TotemBar.TOTEM_ELEMENTS)
+
+ -- Placeholder size: TotemBar.ApplyBarLayout() (called below, once every
+ -- button exists) positions each button per the configured grid and
+ -- resizes the frame for real - this initial size is never seen on screen.
+ local frame = CreateFrame("Frame", "TotemBarFrame", UIParent)
+ frame:SetWidth(BUTTON_SIZE + BUTTON_GAP * 2)
+ frame:SetHeight(BUTTON_SIZE + BUTTON_GAP * 2)
+ frame:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 16,
+ insets = { left = 4, right = 4, top = 4, bottom = 4 },
+ })
+ -- Floating icons: the bar's own backdrop is fully transparent (an
+ -- invisible drag/hit-test area only - size/anchors unchanged).
+ frame:SetBackdropColor(0, 0, 0, 0)
+ frame:SetBackdropBorderColor(0, 0, 0, 0)
+ frame:SetScale(TotemBarDB.scale or 1.0)
+
+ frame:SetMovable(true)
+ frame:EnableMouse(true)
+ frame:RegisterForDrag("LeftButton")
+ frame:SetScript("OnDragStart", OnDragStart)
+ frame:SetScript("OnDragStop", OnDragStop)
+
+ frame:ClearAllPoints()
+ frame:SetPoint(TotemBarDB.point, UIParent, TotemBarDB.relPoint, TotemBarDB.x, TotemBarDB.y)
+
+ for i = 1, numElements do
+ local element = TotemBar.TOTEM_ELEMENTS[i]
+ CreateElementButton(element, i)
+ RefreshCooldown(element) -- initial swipe state for whatever's already chosen
+ end
+ CreateRecallButton(numElements + 1)
+ CreateDropSetButton(numElements + 2)
+
+ -- Positions every button per TotemBarDB.barLayout and gives the frame
+ -- its real size (see ApplyBarLayout above and the placeholder-size
+ -- note on the CreateFrame call).
+ TotemBar.ApplyBarLayout()
+
+ frame:SetScript("OnUpdate", OnBarUpdate)
+
+ frame:Show()
+ if TotemBarDB.hidden then
+ frame:Hide()
+ end
+
+ -- Show any already-bound key labels right after login (overlays are
+ -- always visible now, not gated on bind mode - see refreshBindOverlays).
+ TotemBar.refreshBindOverlays()
+end
+
+-- Scales the bar while keeping its TOP-LEFT corner visually fixed, so the
+-- bar grows/shrinks toward the bottom-right instead of drifting away from
+-- where the player put it. SetScale scales the anchor offset too, so a naive
+-- SetScale moves the frame; here we capture the top-left's ABSOLUTE screen
+-- position (local coord * effective scale = pixels), apply the new scale,
+-- then re-anchor TOPLEFT so those pixels are preserved. Persists the new
+-- scale + anchor so the next login reproduces it.
+function TotemBar.SetBarScale(newScale)
+ local f = TotemBarFrame
+ if not f then
+ return
+ end
+ if not newScale or newScale <= 0 then
+ newScale = 1
+ end
+ local before = f:GetEffectiveScale()
+ local left = f:GetLeft()
+ local top = f:GetTop()
+ f:SetScale(newScale)
+ if left and top and before then
+ local leftPx = left * before
+ local topPx = top * before
+ local after = f:GetEffectiveScale()
+ f:ClearAllPoints()
+ f:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", leftPx / after, topPx / after)
+ end
+ if TotemBarDB then
+ TotemBarDB.scale = newScale
+ local p, _, rp, x, y = f:GetPoint()
+ if p then
+ TotemBarDB.point = p
+ TotemBarDB.relPoint = rp
+ TotemBarDB.x = x
+ TotemBarDB.y = y
+ end
+ end
+end
+
+-- Live-applies a new button gap (px, range 10-30): reassigns the shared
+-- BUTTON_GAP upvalue (every closure that reads it - element/flyout/assign-
+-- panel positioning, ApplyBarLayout - sees the new value on its next read,
+-- no re-declaration needed) and persists it. Two icon pools bake their
+-- BUTTON_GAP-dependent anchors in once at build time instead of recomputing
+-- them on every read (unlike ApplyBarLayout, which already re-reads
+-- BUTTON_GAP fresh every call and just needs re-running): the flyout icon
+-- pool and the pending-assignment panel, both re-laid-out here via their
+-- own helpers (layoutFlyoutIcons/layoutAssignIcons above), each a no-op if
+-- that particular frame was never built yet. Driven by the options panel's
+-- "Button spacing" slider.
+function TotemBar.SetButtonGap(newGap)
+ if not newGap then
+ newGap = TotemBar.DEFAULT_BUTTON_GAP or 10
+ end
+ newGap = TotemBar.clampValue(newGap, 10, 30)
+ BUTTON_GAP = newGap
+ if TotemBarDB then
+ TotemBarDB.buttonGap = newGap
+ end
+ layoutFlyoutIcons()
+ layoutAssignIcons()
+ if TotemBar.ApplyBarLayout then
+ TotemBar.ApplyBarLayout()
+ end
+end
+
+-- Shows/hides the bar and persists the choice (TotemBarDB.hidden). Driven
+-- by the options panel's "Show bar" checkbox, the minimap right-click, and
+-- the bare /tb command.
+function TotemBar.ToggleBar()
+ if not TotemBarFrame then
+ return
+ end
+ if TotemBarFrame:IsShown() then
+ TotemBarFrame:Hide()
+ TotemBarDB.hidden = true
+ else
+ TotemBarFrame:Show()
+ TotemBarDB.hidden = false
+ end
+end
+
+-- Resets the bar's saved anchor to centered defaults and re-anchors it.
+-- Does NOT change scale. Driven by the options panel's "Reset position".
+function TotemBar.ResetPosition()
+ TotemBarDB.point = "CENTER"
+ TotemBarDB.relPoint = "CENTER"
+ TotemBarDB.x = 0
+ TotemBarDB.y = 0
+ if TotemBarFrame then
+ TotemBarFrame:ClearAllPoints()
+ TotemBarFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+ end
+end
+
+-- "/tb scan" dev-aid: one-shot print of every spellbook entry that looks
+-- like a totem, so the static element map in core/totemdata.lua can be
+-- checked against the live TurtleWoW client. Also writes the list to
+-- \imports\totembar_scan.txt via SuperWoW's ExportFile (when
+-- present) so it can be read off-client without pasting from chat.
+function TotemBar.PrintScan()
+ local names = TotemBar.scanSpellbook()
+ local count = 0
+ local dump = ""
+ ChatOut:AddMessage("TotemBar: scanning spellbook for totems...")
+ for i = 1, table.getn(names) do
+ if string.find(names[i], "Totem", 1, true) then
+ ChatOut:AddMessage(" " .. names[i])
+ dump = dump .. names[i] .. "\n"
+ count = count + 1
+ end
+ end
+ ChatOut:AddMessage("TotemBar: " .. count .. " totem spell(s) found.")
+ -- ExportFile appends .txt itself; pass the name WITHOUT extension.
+ if ExportFile then
+ ExportFile("totembar_scan", "totems=" .. count .. "\n" .. dump)
+ end
+end
+
+local function HandleSlashCommand(msg)
+ local cmd = string.lower(msg or "")
+ if cmd == "" then
+ TotemBar.ToggleBar()
+ elseif cmd == "lock" then
+ TotemBarDB.locked = not TotemBarDB.locked
+ if TotemBarDB.locked then
+ ChatOut:AddMessage("TotemBar: bar locked.")
+ else
+ ChatOut:AddMessage("TotemBar: bar unlocked (drag to move).")
+ end
+ elseif cmd == "scan" then
+ TotemBar.PrintScan()
+ elseif cmd == "assign" then
+ -- Dev aid: inject a sample assignment to exercise the pending panel
+ -- + accept/decline flow end-to-end without any transport.
+ local sample = {
+ Fire = "Searing Totem",
+ Earth = "Strength of Earth Totem",
+ Water = "Mana Spring Totem",
+ Air = "Grace of Air Totem",
+ }
+ local ok, reason = TotemBar.ReceiveAssignment(sample, "TEST assignment")
+ if ok then
+ ChatOut:AddMessage("TotemBar: injected TEST assignment (click OK on the panel to apply).")
+ else
+ ChatOut:AddMessage("TotemBar: assign failed - " .. tostring(reason))
+ end
+ elseif cmd == "options" or cmd == "opt" then
+ TotemBar.ToggleOptions()
+ elseif cmd == "bind" then
+ if TotemBar.ToggleBindMode then TotemBar.ToggleBindMode() end
+ elseif cmd == "manadump" then
+ if TotemBar.dumpManaScan then TotemBar.dumpManaScan() end
+ elseif cmd == "tdump" then
+ if TotemBar.DumpTimerState then TotemBar.DumpTimerState() end
+ elseif string.find(cmd, "^pulsecal") then
+ local _, _, sub = string.find(cmd, "^pulsecal%s*(%a*)")
+ if TotemBar.PulseCal then TotemBar.PulseCal(sub or "") end
+ elseif string.find(cmd, "^tprobe") then
+ local _, _, sub = string.find(cmd, "^tprobe%s*(%a*)")
+ if TotemBar.TProbe then TotemBar.TProbe(sub or "") end
+ else
+ ChatOut:AddMessage("TotemBar: unknown command '" .. msg .. "'. Usage: /tb, /tb lock, /tb scan, /tb assign, /tb options, /tb bind, /tb manadump, /tb tdump, /tb pulsecal, /tb tprobe")
+ end
+end
+
+-- SPELL_UPDATE_COOLDOWN fires whenever any spell's cooldown starts or
+-- ends (the standard signal the default action bars key off of) - this
+-- is the event-driven trigger for RefreshCooldown, kept separate from
+-- the throttled per-tick timer OnUpdate (see RefreshCooldown's comment
+-- for why: SetTimer-on-every-tick would restart the swipe animation).
+local eventFrame = CreateFrame("Frame", "TotemBarEventFrame", UIParent)
+eventFrame:RegisterEvent("ADDON_LOADED")
+eventFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
+eventFrame:RegisterEvent("SPELLS_CHANGED")
+eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS")
+eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS")
+eventFrame:SetScript("OnEvent", function()
+ if event == "ADDON_LOADED" and arg1 == "TotemBar" then
+ TotemBar.ensureDefaults()
+ -- BUTTON_GAP was initialized above (file scope, load time) to the
+ -- file-load default, before TotemBarDB was even readable - it must
+ -- be overwritten by the SAVED value now, BEFORE BuildUI runs, or
+ -- the very first layout bakes in the default instead of whatever
+ -- the player set via the options panel's "Button spacing" slider.
+ BUTTON_GAP = TotemBar.clampValue(TotemBarDB.buttonGap or TotemBar.DEFAULT_BUTTON_GAP or 10, 10, 30)
+ TotemBar.BuildUI()
+ eventFrame:UnregisterEvent("ADDON_LOADED")
+ elseif event == "SPELLS_CHANGED" then
+ -- BuildUI resolves each slot's icon off the live spellbook at
+ -- ADDON_LOADED time, but the spellbook (GetSpellName/
+ -- GetSpellTexture) isn't reliably populated that early after a
+ -- login/reload - so a SAVED totem's icon lookup falls through to
+ -- the sheet's empty-slot glyph even though its name is stored
+ -- fine. SPELLS_CHANGED fires once the spellbook is ready (and
+ -- whenever it later changes), so re-resolve every icon here.
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ RefreshButton(elements[i])
+ end
+ if recallButton and recallButton.icon then
+ -- Re-set BOTH texture and crop texcoord: SetTexCoord persists
+ -- across SetTexture, so a stale non-crop coord state must not
+ -- survive a texture re-resolve.
+ recallButton.icon:SetTexture(GetRecallIcon())
+ recallButton.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
+ end
+ elseif event == "SPELL_UPDATE_COOLDOWN" then
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ RefreshCooldown(elements[i])
+ end
+ RefreshFlyoutCooldowns()
+ elseif event == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS"
+ or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS" then
+ -- Re-anchor pulse phase on REAL observed periodic gains (spec 5.5).
+ local totemName = TotemBar.parseSelfGain(arg1)
+ if totemName then
+ local element = TotemBar.elementOf(totemName)
+ local rec = element and TotemBar.activeTotems[element]
+ if rec and rec.totemName == totemName then
+ -- These lines carry no caster, so another shaman's
+ -- identically-named totem is indistinguishable from ours.
+ -- Accept at most one anchor per pulse interval so a foreign
+ -- tick train can't drag our phase off-beat.
+ local pd = TotemBar.pulseInfo(totemName)
+ local nowGain = GetTime()
+ if TotemBar.shouldAnchorPulse(rec.pulseAnchor, pd and pd.interval, nowGain) then
+ rec.pulseAnchor = nowGain
+ end
+ end
+ end
+ end
+end)
+
+SLASH_TOTEMBAR1 = "/tb"
+SlashCmdList = SlashCmdList or {}
+SlashCmdList["TOTEMBAR"] = HandleSlashCommand