-- 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