-- -- ChatThrottleLib by Mikk -- -- Manages AddOn chat output to keep player from getting kicked off. -- -- ChatThrottleLib:SendChatMessage/:SendAddonMessage functions that accept -- a Priority ("BULK", "NORMAL", "ALERT") as well as prefix for SendChatMessage. -- -- Priorities get an equal share of available bandwidth when fully loaded. -- Communication channels are separated on extension+chattype+destination and -- get round-robinned. (Destination only matters for whispers and channels, -- obviously) -- -- Will install hooks for SendChatMessage and SendAddonMessage to measure -- bandwidth bypassing the library and use less bandwidth itself. -- -- -- Fully embeddable library. Just copy this file into your addon directory, -- add it to the .toc, and it's done. -- -- Can run as a standalone addon also, but, really, just embed it! :-) -- -- LICENSE: ChatThrottleLib is released into the Public Domain -- local CTL_VERSION = 29 local _G = _G if _G.ChatThrottleLib then if _G.ChatThrottleLib.version >= CTL_VERSION then -- There's already a newer (or same) version loaded. Buh-bye. return elseif not _G.ChatThrottleLib.securelyHooked then print("ChatThrottleLib: Warning: There's an ANCIENT ChatThrottleLib.lua (pre-wow 2.0, =v16) in it!") -- ATTEMPT to unhook; this'll behave badly if someone else has hooked... -- ... and if someone has securehooked, they can kiss that goodbye too... >.< _G.SendChatMessage = _G.ChatThrottleLib.ORIG_SendChatMessage if _G.ChatThrottleLib.ORIG_SendAddonMessage then _G.SendAddonMessage = _G.ChatThrottleLib.ORIG_SendAddonMessage end end _G.ChatThrottleLib.ORIG_SendChatMessage = nil _G.ChatThrottleLib.ORIG_SendAddonMessage = nil end if not _G.ChatThrottleLib then _G.ChatThrottleLib = {} end ChatThrottleLib = _G.ChatThrottleLib -- in case some addon does "local ChatThrottleLib" above us and we're copypasted (AceComm-2, sigh) local ChatThrottleLib = _G.ChatThrottleLib ChatThrottleLib.version = CTL_VERSION ------------------ TWEAKABLES ----------------- ChatThrottleLib.MAX_CPS = 800 -- 2000 seems to be safe if NOTHING ELSE is happening. let's call it 800. ChatThrottleLib.MSG_OVERHEAD = 40 -- Guesstimate overhead for sending a message; source+dest+chattype+protocolstuff ChatThrottleLib.BURST = 4000 -- WoW's server buffer seems to be about 32KB. 8KB should be safe, but seen disconnects on _some_ servers. Using 4KB now. ChatThrottleLib.MIN_FPS = 20 -- Reduce output CPS to half (and don't burst) if FPS drops below this value local setmetatable = setmetatable local table_remove = table.remove local tostring = tostring local GetTime = GetTime local math_min = math.min local math_max = math.max local next = next local strlen = string.len local GetFramerate = GetFramerate local unpack,type,pairs,wipe = unpack,type,pairs,table.wipe ----------------------------------------------------------------------- -- Double-linked ring implementation local Ring = {} local RingMeta = { __index = Ring } function Ring:New() local ret = {} setmetatable(ret, RingMeta) return ret end function Ring:Add(obj) -- Append at the "far end" of the ring (aka just before the current position) if self.pos then obj.prev = self.pos.prev obj.prev.next = obj obj.next = self.pos obj.next.prev = obj else obj.next = obj obj.prev = obj self.pos = obj end end function Ring:Remove(obj) obj.next.prev = obj.prev obj.prev.next = obj.next if self.pos == obj then self.pos = obj.next if self.pos == obj then self.pos = nil end end end -- Note that this is local because there's no upgrade logic for existing ring -- metatables, and this isn't present on rings created in versions older than -- v25. local function Ring_Link(self, other) -- Move and append all contents of another ring to this ring if not self.pos then -- This ring is empty, so just transfer ownership. self.pos = other.pos other.pos = nil elseif other.pos then -- Our tail should point to their head, and their tail to our head. self.pos.prev.next, other.pos.prev.next = other.pos, self.pos -- Our head should point to their tail, and their head to our tail. self.pos.prev, other.pos.prev = other.pos.prev, self.pos.prev other.pos = nil end end ----------------------------------------------------------------------- -- Recycling bin for pipes -- A pipe is a plain integer-indexed queue of messages -- Pipes normally live in Rings of pipes (3 rings total, one per priority) ChatThrottleLib.PipeBin = nil -- pre-v19, drastically different local PipeBin = setmetatable({}, {__mode="k"}) local function DelPipe(pipe) PipeBin[pipe] = true end local function NewPipe() local pipe = next(PipeBin) if pipe then wipe(pipe) PipeBin[pipe] = nil return pipe end return {} end ----------------------------------------------------------------------- -- Recycling bin for messages ChatThrottleLib.MsgBin = nil -- pre-v19, drastically different local MsgBin = setmetatable({}, {__mode="k"}) local function DelMsg(msg) msg[1] = nil -- there's more parameters, but they're very repetetive so the string pool doesn't suffer really, and it's faster to just not delete them. MsgBin[msg] = true end local function NewMsg() local msg = next(MsgBin) if msg then MsgBin[msg] = nil return msg end return {} end ----------------------------------------------------------------------- -- ChatThrottleLib:Init -- Initialize queues, set up frame for OnUpdate, etc function ChatThrottleLib:Init() -- Set up queues if not self.Prio then self.Prio = {} self.Prio["ALERT"] = { ByName = {}, Ring = Ring:New(), avail = 0 } self.Prio["NORMAL"] = { ByName = {}, Ring = Ring:New(), avail = 0 } self.Prio["BULK"] = { ByName = {}, Ring = Ring:New(), avail = 0 } end if not self.BlockedQueuesDelay then -- v25: Add blocked queues to rings to handle new client throttles. for _, Prio in pairs(self.Prio) do Prio.Blocked = Ring:New() end end -- v4: total send counters per priority for _, Prio in pairs(self.Prio) do Prio.nTotalSent = Prio.nTotalSent or 0 end if not self.avail then self.avail = 0 -- v5 end if not self.nTotalSent then self.nTotalSent = 0 -- v5 end -- Set up a frame to get OnUpdate events if not self.Frame then self.Frame = CreateFrame("Frame") self.Frame:Hide() end self.Frame:SetScript("OnUpdate", self.OnUpdate) self.Frame:SetScript("OnEvent", self.OnEvent) -- v11: Monitor P_E_W so we can throttle hard for a few seconds self.Frame:RegisterEvent("PLAYER_ENTERING_WORLD") self.OnUpdateDelay = 0 self.BlockedQueuesDelay = 0 self.LastAvailUpdate = GetTime() self.HardThrottlingBeginTime = GetTime() -- v11: Throttle hard for a few seconds after startup -- Hook SendChatMessage and SendAddonMessage so we can measure unpiped traffic and avoid overloads (v7) if not self.securelyHooked then -- Use secure hooks as of v16. Old regular hook support yanked out in v21. self.securelyHooked = true --SendChatMessage hooksecurefunc("SendChatMessage", function(...) return ChatThrottleLib.Hook_SendChatMessage(...) end) --SendAddonMessage hooksecurefunc(_G.C_ChatInfo, "SendAddonMessage", function(...) return ChatThrottleLib.Hook_SendAddonMessage(...) end) end -- v26: Hook SendAddonMessageLogged for traffic logging if not self.securelyHookedLogged then self.securelyHookedLogged = true hooksecurefunc(_G.C_ChatInfo, "SendAddonMessageLogged", function(...) return ChatThrottleLib.Hook_SendAddonMessageLogged(...) end) end -- v29: Hook BNSendGameData for traffic logging if not self.securelyHookedBNGameData then self.securelyHookedBNGameData = true hooksecurefunc("BNSendGameData", function(...) return ChatThrottleLib.Hook_BNSendGameData(...) end) end self.nBypass = 0 end ----------------------------------------------------------------------- -- ChatThrottleLib.Hook_SendChatMessage / .Hook_SendAddonMessage local bMyTraffic = false function ChatThrottleLib.Hook_SendChatMessage(text, chattype, language, destination, ...) if bMyTraffic then return end local self = ChatThrottleLib local size = strlen(tostring(text or "")) + strlen(tostring(destination or "")) + self.MSG_OVERHEAD self.avail = self.avail - size self.nBypass = self.nBypass + size -- just a statistic end function ChatThrottleLib.Hook_SendAddonMessage(prefix, text, chattype, destination, ...) if bMyTraffic then return end local self = ChatThrottleLib local size = tostring(text or ""):len() + tostring(prefix or ""):len(); size = size + tostring(destination or ""):len() + self.MSG_OVERHEAD self.avail = self.avail - size self.nBypass = self.nBypass + size -- just a statistic end function ChatThrottleLib.Hook_SendAddonMessageLogged(prefix, text, chattype, destination, ...) ChatThrottleLib.Hook_SendAddonMessage(prefix, text, chattype, destination, ...) end function ChatThrottleLib.Hook_BNSendGameData(destination, prefix, text) ChatThrottleLib.Hook_SendAddonMessage(prefix, text, "WHISPER", destination) end ----------------------------------------------------------------------- -- ChatThrottleLib:UpdateAvail -- Update self.avail with how much bandwidth is currently available function ChatThrottleLib:UpdateAvail() local now = GetTime() local MAX_CPS = self.MAX_CPS; local newavail = MAX_CPS * (now - self.LastAvailUpdate) local avail = self.avail if now - self.HardThrottlingBeginTime < 5 then -- First 5 seconds after startup/zoning: VERY hard clamping to avoid irritating the server rate limiter, it seems very cranky then avail = math_min(avail + (newavail*0.1), MAX_CPS*0.5) self.bChoking = true elseif GetFramerate() < self.MIN_FPS then -- GetFrameRate call takes ~0.002 secs avail = math_min(MAX_CPS, avail + newavail*0.5) self.bChoking = true -- just a statistic else avail = math_min(self.BURST, avail + newavail) self.bChoking = false end avail = math_max(avail, 0-(MAX_CPS*2)) -- Can go negative when someone is eating bandwidth past the lib. but we refuse to stay silent for more than 2 seconds; if they can do it, we can. self.avail = avail self.LastAvailUpdate = now return avail end ----------------------------------------------------------------------- -- Despooling logic -- Reminder: -- - We have 3 Priorities, each containing a "Ring" construct ... -- - ... made up of N "Pipe"s (1 for each destination/pipename) -- - and each pipe contains messages local SendAddonMessageResult = Enum.SendAddonMessageResult or { Success = 0, AddonMessageThrottle = 3, NotInGroup = 5, ChannelThrottle = 8, GeneralError = 9, } local function MapToSendResult(ok, ...) local result if not ok then -- The send function itself errored; don't look at anything else. result = SendAddonMessageResult.GeneralError else -- Grab the last return value from the send function and remap -- it from a boolean to an enum code. If there are no results, -- assume success (true). result = select(-1, true, ...) if result == true then result = SendAddonMessageResult.Success elseif result == false then result = SendAddonMessageResult.GeneralError end end return result end local function IsThrottledSendResult(result) return result == SendAddonMessageResult.AddonMessageThrottle end -- A copy of this function exists in FrameXML, but for clarity it's here too. local function CallErrorHandler(...) return geterrorhandler()(...) end local function PerformSend(sendFunction, ...) bMyTraffic = true local sendResult = MapToSendResult(xpcall(sendFunction, CallErrorHandler, ...)) bMyTraffic = false return sendResult end function ChatThrottleLib:Despool(Prio) local ring = Prio.Ring while ring.pos and Prio.avail > ring.pos[1].nSize do local pipe = ring.pos local msg = pipe[1] local sendResult = PerformSend(msg.f, unpack(msg, 1, msg.n)) if IsThrottledSendResult(sendResult) then -- Message was throttled; move the pipe into the blocked ring. Prio.Ring:Remove(pipe) Prio.Blocked:Add(pipe) else -- Dequeue message after submission. table_remove(pipe, 1) DelMsg(msg) if not pipe[1] then -- did we remove last msg in this pipe? Prio.Ring:Remove(pipe) Prio.ByName[pipe.name] = nil DelPipe(pipe) else ring.pos = ring.pos.next end -- Update bandwidth counters on successful sends. local didSend = (sendResult == SendAddonMessageResult.Success) if didSend then Prio.avail = Prio.avail - msg.nSize Prio.nTotalSent = Prio.nTotalSent + msg.nSize end -- Notify caller of message submission. if msg.callbackFn then securecallfunction(msg.callbackFn, msg.callbackArg, didSend, sendResult) end end end end function ChatThrottleLib.OnEvent(this,event) -- v11: We know that the rate limiter is touchy after login. Assume that it's touchy after zoning, too. local self = ChatThrottleLib if event == "PLAYER_ENTERING_WORLD" then self.HardThrottlingBeginTime = GetTime() -- Throttle hard for a few seconds after zoning self.avail = 0 end end function ChatThrottleLib.OnUpdate(this,delay) local self = ChatThrottleLib self.OnUpdateDelay = self.OnUpdateDelay + delay self.BlockedQueuesDelay = self.BlockedQueuesDelay + delay if self.OnUpdateDelay < 0.08 then return end self.OnUpdateDelay = 0 self:UpdateAvail() if self.avail < 0 then return -- argh. some bastard is spewing stuff past the lib. just bail early to save cpu. end -- Integrate blocked queues back into their rings periodically. if self.BlockedQueuesDelay >= 0.35 then for _, Prio in pairs(self.Prio) do Ring_Link(Prio.Ring, Prio.Blocked) end self.BlockedQueuesDelay = 0 end -- See how many of our priorities have queued messages. This is split -- into two counters because priorities that consist only of blocked -- queues must keep our OnUpdate alive, but shouldn't count toward -- bandwidth distribution. local nSendablePrios = 0 local nBlockedPrios = 0 for prioname, Prio in pairs(self.Prio) do if Prio.Ring.pos then nSendablePrios = nSendablePrios + 1 elseif Prio.Blocked.pos then nBlockedPrios = nBlockedPrios + 1 end -- Collect unused bandwidth from priorities with nothing to send. if not Prio.Ring.pos then self.avail = self.avail + Prio.avail Prio.avail = 0 end end -- Bandwidth reclamation may take us back over the burst cap. self.avail = math_min(self.avail, self.BURST) -- If we can't currently send on any priorities, stop processing early. if nSendablePrios == 0 then -- If we're completely out of data to send, disable queue processing. if nBlockedPrios == 0 then self.bQueueing = false self.Frame:Hide() end return end -- There's stuff queued. Hand out available bandwidth to priorities as needed and despool their queues local avail = self.avail / nSendablePrios self.avail = 0 for prioname, Prio in pairs(self.Prio) do if Prio.Ring.pos then Prio.avail = Prio.avail + avail self:Despool(Prio) end end end ----------------------------------------------------------------------- -- Spooling logic function ChatThrottleLib:Enqueue(prioname, pipename, msg) local Prio = self.Prio[prioname] local pipe = Prio.ByName[pipename] if not pipe then self.Frame:Show() pipe = NewPipe() pipe.name = pipename Prio.ByName[pipename] = pipe Prio.Ring:Add(pipe) end pipe[#pipe + 1] = msg self.bQueueing = true end function ChatThrottleLib:SendChatMessage(prio, prefix, text, chattype, language, destination, queueName, callbackFn, callbackArg) if not self or not prio or not prefix or not text or not self.Prio[prio] then error('Usage: ChatThrottleLib:SendChatMessage("{BULK||NORMAL||ALERT}", "prefix", "text"[, "chattype"[, "language"[, "destination"]]]', 2) end if callbackFn and type(callbackFn)~="function" then error('ChatThrottleLib:ChatMessage(): callbackFn: expected function, got '..type(callbackFn), 2) end local nSize = text:len() if nSize>255 then error("ChatThrottleLib:SendChatMessage(): message length cannot exceed 255 bytes", 2) end nSize = nSize + self.MSG_OVERHEAD -- Check if there's room in the global available bandwidth gauge to send directly if not self.bQueueing and nSize < self:UpdateAvail() then local sendResult = PerformSend(_G.SendChatMessage, text, chattype, language, destination) if not IsThrottledSendResult(sendResult) then local didSend = (sendResult == SendAddonMessageResult.Success) if didSend then self.avail = self.avail - nSize self.Prio[prio].nTotalSent = self.Prio[prio].nTotalSent + nSize end if callbackFn then securecallfunction(callbackFn, callbackArg, didSend, sendResult) end return end end -- Message needs to be queued local msg = NewMsg() msg.f = _G.SendChatMessage msg[1] = text msg[2] = chattype or "SAY" msg[3] = language msg[4] = destination msg.n = 4 msg.nSize = nSize msg.callbackFn = callbackFn msg.callbackArg = callbackArg self:Enqueue(prio, queueName or prefix, msg) end local function SendAddonMessageInternal(self, sendFunction, prio, prefix, text, chattype, target, queueName, callbackFn, callbackArg) local nSize = #text + self.MSG_OVERHEAD -- Check if there's room in the global available bandwidth gauge to send directly if not self.bQueueing and nSize < self:UpdateAvail() then local sendResult = PerformSend(sendFunction, prefix, text, chattype, target) if not IsThrottledSendResult(sendResult) then local didSend = (sendResult == SendAddonMessageResult.Success) if didSend then self.avail = self.avail - nSize self.Prio[prio].nTotalSent = self.Prio[prio].nTotalSent + nSize end if callbackFn then securecallfunction(callbackFn, callbackArg, didSend, sendResult) end return end end -- Message needs to be queued local msg = NewMsg() msg.f = sendFunction msg[1] = prefix msg[2] = text msg[3] = chattype msg[4] = target msg.n = (target~=nil) and 4 or 3; msg.nSize = nSize msg.callbackFn = callbackFn msg.callbackArg = callbackArg self:Enqueue(prio, queueName or prefix, msg) end function ChatThrottleLib:SendAddonMessage(prio, prefix, text, chattype, target, queueName, callbackFn, callbackArg) if not self or not prio or not prefix or not text or not chattype or not self.Prio[prio] then error('Usage: ChatThrottleLib:SendAddonMessage("{BULK||NORMAL||ALERT}", "prefix", "text", "chattype"[, "target"])', 2) elseif callbackFn and type(callbackFn)~="function" then error('ChatThrottleLib:SendAddonMessage(): callbackFn: expected function, got '..type(callbackFn), 2) elseif #text>255 then error("ChatThrottleLib:SendAddonMessage(): message length cannot exceed 255 bytes", 2) end local sendFunction = _G.C_ChatInfo.SendAddonMessage SendAddonMessageInternal(self, sendFunction, prio, prefix, text, chattype, target, queueName, callbackFn, callbackArg) end function ChatThrottleLib:SendAddonMessageLogged(prio, prefix, text, chattype, target, queueName, callbackFn, callbackArg) if not self or not prio or not prefix or not text or not chattype or not self.Prio[prio] then error('Usage: ChatThrottleLib:SendAddonMessageLogged("{BULK||NORMAL||ALERT}", "prefix", "text", "chattype"[, "target"])', 2) elseif callbackFn and type(callbackFn)~="function" then error('ChatThrottleLib:SendAddonMessageLogged(): callbackFn: expected function, got '..type(callbackFn), 2) elseif #text>255 then error("ChatThrottleLib:SendAddonMessageLogged(): message length cannot exceed 255 bytes", 2) end local sendFunction = _G.C_ChatInfo.SendAddonMessageLogged SendAddonMessageInternal(self, sendFunction, prio, prefix, text, chattype, target, queueName, callbackFn, callbackArg) end local function BNSendGameDataReordered(prefix, text, _, gameAccountID) return _G.BNSendGameData(gameAccountID, prefix, text) end function ChatThrottleLib:BNSendGameData(prio, prefix, text, chattype, gameAccountID, queueName, callbackFn, callbackArg) -- Note that this API is intentionally limited to 255 bytes of data -- for reasons of traffic fairness, which is less than the 4078 bytes -- BNSendGameData natively supports. Additionally, a chat type is required -- but must always be set to 'WHISPER' to match what is exposed by the -- receipt event. -- -- If splitting messages, callers must also be aware that message -- delivery over BNSendGameData is unordered. if not self or not prio or not prefix or not text or not gameAccountID or not chattype or not self.Prio[prio] then error('Usage: ChatThrottleLib:BNSendGameData("{BULK||NORMAL||ALERT}", "prefix", "text", "chattype", gameAccountID)', 2) elseif callbackFn and type(callbackFn)~="function" then error('ChatThrottleLib:BNSendGameData(): callbackFn: expected function, got '..type(callbackFn), 2) elseif #text>255 then error("ChatThrottleLib:BNSendGameData(): message length cannot exceed 255 bytes", 2) elseif chattype ~= "WHISPER" then error("ChatThrottleLib:BNSendGameData(): chat type must be 'WHISPER'", 2) end local sendFunction = BNSendGameDataReordered SendAddonMessageInternal(self, sendFunction, prio, prefix, text, chattype, gameAccountID, queueName, callbackFn, callbackArg) end ----------------------------------------------------------------------- -- Get the ball rolling! ChatThrottleLib:Init() --[[ WoWBench debugging snippet if(WOWB_VER) then local function SayTimer() print("SAY: "..GetTime().." "..arg1) end ChatThrottleLib.Frame:SetScript("OnEvent", SayTimer) ChatThrottleLib.Frame:RegisterEvent("CHAT_MSG_SAY") end ]]