diff --git a/modules/energytick.lua b/modules/energytick.lua index 6324bd65..ce787798 100644 --- a/modules/energytick.lua +++ b/modules/energytick.lua @@ -1,20 +1,82 @@ -local function getAdjustedTickTimer() - local adjustedEnergyTick = 2 +-- One server clock drives every power: Player::RegenerateAll fires every +-- REGEN_TIME_FULL (2s), re-arms with `+=`, and is never reset by casting. The +-- five-second rule (SetLastManaUse on any mana-costing cast) changes what a tick +-- pays, never when it lands; mp5 and the player's MOD_MANA_REGEN_INTERRUPT share +-- still come in. That share can't be computed here -- item sources are equip +-- auras absent from the buff list and m_modManaRegenInterrupt is never sent -- +-- so the spark shows it instead: dim through the window until a tick lands. +-- +-- The sweep free-runs on that clock and phase-locks to observed gains. A gain +-- mid-sweep (Illumination, Judgement of Wisdom, potions, a Mana Spring totem on +-- its own phase) is not the tick and never moves it. - -- Check rogue talents and compute energy tick timing reduction for Combat spec (1.18.0 Blade Rush Talent) - if UnitClassBase("player") == "ROGUE" then - local _, _, _, _, currRank = GetTalentInfo(2, 16) - local bladeRushRank = currRank or 0 +local FIVE_SECOND_RULE = 5 - if bladeRushRank > 0 then - local agility = UnitStat("player", 2) -- 2 is agility stat index - local reductionPerAgi = 0.0006 * bladeRushRank -- 0.0006 for rank 1, 0.0012 for rank 2 - local totalReduction = agility * reductionPerAgi - adjustedEnergyTick = adjustedEnergyTick - totalReduction +-- gains farther than this from the predicted boundary are not the tick +local TICK_TOLERANCE = .25 + +-- arrival jitter; a tick inside this band confirms the sweep rather than +-- re-anchoring it, or the spark hitches at every wrap +local TICK_JITTER = .08 + +-- Player::RegenerateAll: +-- mod = GetTotalAuraModifier(SPELL_AURA_MOD_ENERGY_REGEN_TIME) +-- if mod > 0 then mod = mod * agility / 10 end +-- m_regenTimer += max(1, REGEN_TIME_FULL - mod) -- milliseconds +local REGEN_TIME_FULL = 2 +local ENERGY_REGEN_TIME_AURA = 217 -- SPELL_AURA_MOD_ENERGY_REGEN_TIME + +-- fixed magnitude is basePoints + baseDice (stored 11 -> 12); a die above 1 is +-- a roll the client can't know, so it counts as nothing rather than a guess +local amountCache = {} +local function auraAmount(spellID) + local amount = amountCache[spellID] + if amount then return amount end + amount = 0 + local effects = C_Spell.GetSpellEffectInfo(spellID) -- nil for an id with no record + if effects then + for i = 1, 3 do + local fx = effects[i] + if fx.auraName == ENERGY_REGEN_TIME_AURA and fx.dieSides <= 1 then + amount = fx.basePoints + fx.baseDice + break + end end end + amountCache[spellID] = amount + return amount +end - return adjustedEnergyTick +-- A passive is in effect exactly while known (current rank only, never in the +-- buff list); anything castable or cast on us counts only while it is up. +local function getEnergyRegenTimeMod() + local sum = 0 + for _, spellID in ipairs(C_SpellBook.GetPlayerSpellsByAura(ENERGY_REGEN_TIME_AURA)) do + if C_Spell.IsSpellPassive(spellID) then + sum = sum + auraAmount(spellID) + end + end + for i = 1, 32 do + local spellID = select(10, C_UnitAuras.UnitAura("player", i, "HELPFUL")) + if not spellID then break end + sum = sum + auraAmount(spellID) + end + return sum +end + +-- cleared on SPELLS_CHANGED (passives) and PLAYER_AURAS_CHANGED (buffs), and +-- recomputed by the next tick that asks. Agility stays live: it's one call. +local energyRegenTimeMod + +local function getAdjustedTickTimer() + if not energyRegenTimeMod then + energyRegenTimeMod = getEnergyRegenTimeMod() + end + if energyRegenTimeMod == 0 then return REGEN_TIME_FULL end + + -- ms on the server, seconds here; the 1ms floor is the server's and this is a divisor + local reduction = energyRegenTimeMod * UnitStat("player", 2) / 10000 + return math.max(0.001, REGEN_TIME_FULL - reduction) end pfUI:RegisterModule("energytick", function() @@ -22,14 +84,53 @@ pfUI:RegisterModule("energytick", function() return end + -- inside the module body on purpose: C is on pfUI.env, not _G + local function getBarWidth() + return C.unitframes.player.pwidth ~= "-1" and C.unitframes.player.pwidth or C.unitframes.player.width + end + + -- was this gain the regen tick? if so, re-anchor the sweep on it + local function lockTick(frame) + local now, period = GetTime(), getAdjustedTickTimer() + + if frame.start then + -- signed distance to the nearest predicted boundary + local err = mod(now - frame.start, period) + if err > period / 2 then err = err - period end + + if math.abs(err) <= TICK_TOLERANCE then + -- correct only what lies beyond normal jitter + if err > TICK_JITTER then + frame.start = frame.start + (err - TICK_JITTER) + elseif err < -TICK_JITTER then + frame.start = frame.start + (err + TICK_JITTER) + end + frame.max, frame.rejected = period, nil + return true + end + + -- two rejected gains one period apart are the real clock: relock to it + local periodic = frame.rejected and math.abs(now - frame.rejected - period) <= TICK_TOLERANCE + if not periodic then + frame.rejected = now + return false + end + end + + frame.start, frame.max, frame.rejected = now, period, nil + return true + end + local energytick = CreateFrame("Frame", nil, pfUI.uf.player.power.bar) energytick:SetAllPoints(pfUI.uf.player.power.bar) energytick:RegisterEvent("PLAYER_ENTERING_WORLD") energytick:RegisterUnitEvent("UNIT_DISPLAYPOWER", "player") energytick:RegisterUnitEvent("UNIT_ENERGY", "player") energytick:RegisterUnitEvent("UNIT_MANA", "player") - energytick:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF") - energytick:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS") + energytick:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player") + energytick:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_STOP", "player") + energytick:RegisterEvent("SPELLS_CHANGED") + energytick:RegisterEvent("PLAYER_AURAS_CHANGED") energytick:SetScript("OnEvent", function() if UnitPowerType("player") == Enum.PowerType.Mana and C.unitframes.player.manatick == "1" then @@ -42,44 +143,52 @@ pfUI:RegisterModule("energytick", function() this:Hide() end - -- Filter nur eigene Energy-Gewinne von Talents/Buffs - if event == "CHAT_MSG_SPELL_SELF_BUFF" or event == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS" then - if string.find(arg1, "You gain") and string.find(arg1, "Energy from") then - this.ignoreNextGain = true - end + if event == "SPELLS_CHANGED" or event == "PLAYER_AURAS_CHANGED" then + energyRegenTimeMod = nil return end if event == "PLAYER_ENTERING_WORLD" then - this.lastMana = UnitPower("player") + this.lastPower = UnitPower("player") + end + + -- the rule arms on the cast (Spell::TakePower: mana powerType, cost > 0), + -- not on a mana drop -- Mana Burn lowers mana without arming it + if event == "UNIT_SPELLCAST_SUCCEEDED" and arg1 == "player" then + local cost = C_Spell.GetSpellPowerCost(arg3) + cost = cost and cost[1] + if cost and cost.type == Enum.PowerType.Mana and cost.cost > 0 then + this.fsrSpell, this.fsrEnd = arg3, GetTime() + FIVE_SECOND_RULE + this.fsrGain = nil + end + return + end + + -- Unit::Update won't expire the rule while the spending spell still channels + if event == "UNIT_SPELLCAST_CHANNEL_STOP" and arg1 == "player" then + if this.fsrSpell and this.fsrSpell == arg3 then + this.fsrEnd, this.fsrGain = GetTime() + FIVE_SECOND_RULE, nil + end + return end if (event == "UNIT_MANA" or event == "UNIT_ENERGY") and arg1 == "player" then - this.currentMana = UnitPower("player") - local diff = 0 - if this.lastMana then - diff = this.currentMana - this.lastMana + local power = UnitPower("player") + local diff = this.lastPower and (power - this.lastPower) or 0 + this.lastPower = power + + -- only a gain can be the tick; a spend never touches the phase + if diff > 0 and lockTick(this) then + -- a tick inside the window proves regen continues through it + if this.fsrEnd and this.fsrEnd > GetTime() then + this.fsrGain = true + end end - if this.mode == "MANA" and diff < 0 then - this.target = 5 - elseif this.mode == "MANA" and diff > 0 then - if UnitPower("player") >= UnitPowerMax("player") then - this.start = nil - this.spark:SetAlpha(0) - this:Hide() - elseif this.max ~= 5 and diff > (this.badtick and this.badtick * 1.2 or 5) then - this.target = 2 - else - this.badtick = diff - end - elseif this.mode == "ENERGY" and diff >= 0 then - if not this.ignoreNextGain then - this.target = getAdjustedTickTimer() - end - this.ignoreNextGain = false + -- phase is kept while hidden; OnUpdate catches up by whole periods + if this.mode == "MANA" and power >= UnitPowerMax("player") then + this:Hide() end - this.lastMana = this.currentMana end end) @@ -90,37 +199,53 @@ pfUI:RegisterModule("energytick", function() end this.tick = GetTime() + 0.020 -- ~50 FPS - if this.target then - this.start, this.max = GetTime(), this.target - this.target = nil - this.spark:SetAlpha(1) - this:Show() + -- five-second rule drains to nothing + local remaining = this.fsrEnd and (this.fsrEnd - GetTime()) or 0 + if this.mode == "MANA" and remaining > 0 then + this.fsrbar:SetWidth(getBarWidth() * remaining / FIVE_SECOND_RULE) + this.fsrbar:Show() + else + this.fsrSpell, this.fsrEnd, this.fsrGain = nil, nil, nil + this.fsrbar:Hide() end if not this.start then + this.spark:SetAlpha(0) + return + end + + if this.mode == "MANA" and UnitPower("player") >= UnitPowerMax("player") then + this.spark:SetAlpha(0) return end this.current = GetTime() - this.start + -- roll over by whole periods, not from now: restarting bakes frame + -- overshoot into the phase as drift the lock then has to chase if this.current > this.max then - -- Don't restart tick timer if mana is full - if this.mode == "MANA" and UnitPower("player") >= UnitPowerMax("player") then - this.start = nil - this.spark:SetAlpha(0) - return - end - this.start, this.max, this.current = GetTime(), getAdjustedTickTimer(), 0 + this.start = this.start + this.max * math.floor(this.current / this.max) + this.max = getAdjustedTickTimer() + this.current = GetTime() - this.start end - local pos = (C.unitframes.player.pwidth ~= "-1" and C.unitframes.player.pwidth or C.unitframes.player.width) - * (this.current / this.max) + -- dim while the rule is up and nothing has ticked inside it yet + this.spark:SetAlpha((remaining > 0 and not this.fsrGain) and .4 or 1) + if not C.unitframes.player.pheight then return end + + local pos = getBarWidth() * (this.current / this.max) this.spark:SetPoint("LEFT", pos - ((C.unitframes.player.pheight + 5) / 2), 0) end) + energytick.fsrbar = energytick:CreateTexture(nil, "ARTWORK") + energytick.fsrbar:SetTexture(1, 1, 1, .15) + energytick.fsrbar:SetPoint("TOPLEFT", 0, 0) + energytick.fsrbar:SetPoint("BOTTOMLEFT", 0, 0) + energytick.fsrbar:Hide() + energytick.spark = energytick:CreateTexture(nil, "OVERLAY") energytick.spark:SetTexture("Interface\\CastingBar\\UI-CastingBar-Spark") energytick.spark:SetHeight(C.unitframes.player.pheight + 15) @@ -133,4 +258,4 @@ pfUI:RegisterModule("energytick", function() energytick.spark:SetWidth(C.unitframes.player.pheight + 5) hookUpdateConfig(pfUI.uf.player) end -end) \ No newline at end of file +end)