TotemBar 0.4.1

This commit is contained in:
2026-10-15 12:29:38 +02:00
commit 06f581e8b5
35 changed files with 8068 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
<Bindings>
<Binding name="TOTEMBAR_DROPSET" runOnUp="true" header="TOTEMBAR">TotemBar.dropSetKey(keystate);</Binding>
<Binding name="TOTEMBAR_RECALL">TotemBar.CastRecall();</Binding>
<Binding name="TOTEMBAR_TOGGLEBAR">TotemBar.ToggleBar();</Binding>
<Binding name="TOTEMBAR_TOGGLEOPTIONS">TotemBar.ToggleOptions();</Binding>
<Binding name="TOTEMBAR_TOGGLEBIND">TotemBar.ToggleBindMode();</Binding>
<Binding name="TOTEMBAR_CAST_FIRE">TotemBar.CastElement("Fire");</Binding>
<Binding name="TOTEMBAR_CAST_EARTH">TotemBar.CastElement("Earth");</Binding>
<Binding name="TOTEMBAR_CAST_WATER">TotemBar.CastElement("Water");</Binding>
<Binding name="TOTEMBAR_CAST_AIR">TotemBar.CastElement("Air");</Binding>
<Binding name="TOTEMBAR_TOTEM_SEARING_TOTEM" header="TOTEMBAR_FIRE">TotemBar.CastTotem("Searing Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_FIRE_NOVA_TOTEM">TotemBar.CastTotem("Fire Nova Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_MAGMA_TOTEM">TotemBar.CastTotem("Magma Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_FLAMETONGUE_TOTEM">TotemBar.CastTotem("Flametongue Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_FROST_RESISTANCE_TOTEM">TotemBar.CastTotem("Frost Resistance Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_EARTHBIND_TOTEM" header="TOTEMBAR_EARTH">TotemBar.CastTotem("Earthbind Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_STONECLAW_TOTEM">TotemBar.CastTotem("Stoneclaw Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_STONESKIN_TOTEM">TotemBar.CastTotem("Stoneskin Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_STRENGTH_OF_EARTH_TOTEM">TotemBar.CastTotem("Strength of Earth Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_TREMOR_TOTEM">TotemBar.CastTotem("Tremor Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_FIRE_RESISTANCE_TOTEM" header="TOTEMBAR_WATER">TotemBar.CastTotem("Fire Resistance Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_HEALING_STREAM_TOTEM">TotemBar.CastTotem("Healing Stream Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_MANA_SPRING_TOTEM">TotemBar.CastTotem("Mana Spring Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_POISON_CLEANSING_TOTEM">TotemBar.CastTotem("Poison Cleansing Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_DISEASE_CLEANSING_TOTEM">TotemBar.CastTotem("Disease Cleansing Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_MANA_TIDE_TOTEM">TotemBar.CastTotem("Mana Tide Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_GRACE_OF_AIR_TOTEM" header="TOTEMBAR_AIR">TotemBar.CastTotem("Grace of Air Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_GROUNDING_TOTEM">TotemBar.CastTotem("Grounding Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_NATURE_RESISTANCE_TOTEM">TotemBar.CastTotem("Nature Resistance Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_SENTRY_TOTEM">TotemBar.CastTotem("Sentry Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_WINDFURY_TOTEM">TotemBar.CastTotem("Windfury Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_WINDWALL_TOTEM">TotemBar.CastTotem("Windwall Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_TRANQUIL_AIR_TOTEM">TotemBar.CastTotem("Tranquil Air Totem");</Binding>
</Bindings>
+365
View File
@@ -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 `"<Name> (<Owner>) is destroyed."` verbatim; parsed
and evicted once the owner resolves to the player and the name rank-tolerantly matches a
tracked totem. The generic vanilla `"<Name> dies."` form is accepted as a second, stricter
shape — since it carries no owner, it only evicts a candidate that already has a latched
GUID, confirmed via a live `UnitExists`/`UnitHealth`/`UnitIsDeadOrGhost` read.
2. **`UNIT_CASTEVENT`** (SuperWoW), for totems that periodically cast their own pulse — now
keyed off `UnitName(casterGUID)` (the totem unit's own, rank-tolerant name) instead of the
pulse spell's name; covers only pulsing totems (Searing/Magma/Fire Nova/Mana Spring/
Healing Stream/Mana Tide/the Cleansing totems), gated on the caster's resolved owner
matching the player.
3. **`UNIT_MODEL_CHANGED`** (native 1.12), for totems that never cast anything visible — now
rank-tolerant instead of an exact name compare.
4. **`UNIT_DIED`** (nampower), extended with a GUID-free fallback: when no GUID was ever
latched, a rank-tolerant name + owner match at the death instant (both resolve reliably
there per the captured log) still evicts.
A totem confirmed destroyed is evicted immediately and tombstoned for the remainder of its
own natural duration, so pfUI's libtotem — itself just another blind timer — cannot
resurrect the countdown/ring/pulse a moment later; a real re-cast on that element clears the
tombstone right away. This clears the countdown, duration ring, pulse animation and
out-of-range tint together, since all four already derive from the same tracking record. On
a client without SuperWoW, only the primary `CHAT_MSG_COMBAT_FRIENDLY_DEATH` signal is
active; without either SuperWoW or TurtleWoW's own death-line wording, behaviour is
byte-for-byte unchanged from before this feature. The decision logic (name matching, line
parsing, eviction gating) is fully offline-tested against the captured fixture lines (95
assertions in `tools/luatests/test_destroy.lua`); a live totem-kill retest with the addon
in the loop is still pending — `/tb tdump` prints each totem's latched `guid`, the raw
`UnitExists`/`UnitHealth`/`UnitIsDeadOrGhost` reads, and (for an evicted slot) its tombstone
status, for that verification.
### Fixed
- **A totem's countdown no longer starts when the cast was refused for lack of mana.** The
pre-cast mana gate stood down completely while Clearcasting (Elemental Focus) was up, on the
assumption that it zeroes the next cast's price. Elemental Focus covers the shaman's damage
spells, not totem summons -- so a totem costs full mana while Clearcasting is up, and since
totems are the only thing this addon casts, that exemption disabled the mana gate outright.
The client then refused the cast while the timer had already been recorded, leaving a
countdown running for a totem that was never placed. The exemption is gone from the cast
gate, the out-of-mana dim and the flyout tint alike; the unverified buff-icon constant it
relied on went with it. The gate still accepts (and ignores) the old sixth argument, so no
stale caller can quietly reinstate it.
- **A Totemic Recall the server refuses no longer wipes your totem timers.**
Recall already checked whether the cast could go out *before* touching the
tracking, but a press that passes that check can still be refused after the
fact — moving at the wrong moment, a stun or silence landing first, or plain
latency. The tracking was cleared unconditionally the instant the cast was
issued, so a refused Recall left the addon believing nothing was out while a
full set kept standing — the same phantom-state bug class the totem casts'
own two-layer guard already closed, mirrored. The recall paths now remember
what they cleared, and the same failure signals that take back a refused
totem's countdown (the exact per-spell one with nampower, the error-message
fallback without it) put the timers back. The restore follows the same
narrow rules as the totem-cast revoke: only when the Recall itself was the
last cast attempted, only within a short window afterward, and never over a
slot a new cast has already refilled — when attribution is uncertain it
stays silent, since here a *wrongly restored* timer would be the phantom.
Covers the Recall button, its keybind, and the DropSet keybind's recall
stroke alike.
## v0.3.0 — 2026-08-15
### 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).
+21
View File
@@ -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.
+221
View File
@@ -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.
<img src="screenshots/pulse-ui.png" alt="TotemBar in action: round slots with duration and pulse rings, totems placed in the world" width="100%">
## 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 |
| :---: | :---: |
| <img src="screenshots/bar.png" alt="The totem bar with Recall tooltip" width="100%"><br><img src="screenshots/flyout.png" alt="Hover flyout with spell tooltip" width="100%"> | <img src="screenshots/options.png" alt="Options panel" width="70%"> |
| Key bindings | Hover-bind mode |
| :---: | :---: |
| <img src="screenshots/keybindings.png" alt="Esc menu key bindings section" width="100%"> | <img src="screenshots/bind-mode.png" alt="Hover-bind mode with key labels" width="100%"> |
*(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).
+25
View File
@@ -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
+233
View File
@@ -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)
+147
View File
@@ -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
+73
View File
@@ -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
+2108
View File
File diff suppressed because it is too large Load Diff
+84
View File
@@ -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
+51
View File
@@ -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.
+326
View File
@@ -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 <WoW>\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
+73
View File
@@ -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
+195
View File
@@ -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
+392
View File
@@ -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=<guid|nil>" literally.
function TotemBar.tprobeLatchedGuid(activeTotems, elementOfFn, nameUnit, nameSpell)
if not elementOfFn then
return nil
end
local element = elementOfFn(nameUnit) or elementOfFn(nameSpell)
if not element or not activeTotems then
return nil
end
local rec = activeTotems[element]
if not rec then
return nil
end
return rec.guid
end
-- ---------------------------------------------------------------------------
-- WoW-gated capture (skipped entirely by the offline test runner).
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
+60
View File
@@ -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
+52
View File
@@ -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
+185
View File
@@ -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
+161
View File
@@ -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
+123
View File
@@ -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)
+456
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

+2683
View File
File diff suppressed because it is too large Load Diff