--[[-------------------------------------------------------------------------- ManiaxSocial - the client end of the addon-message channel. Everything that touches the client's API lives here; the parsing and the roster model live in Protocol.lua, which knows nothing about WoW. HOW THE CHANNEL WORKS The addon whispers ITSELF: SendAddonMessage("MXSOC", body, "WHISPER", UnitName("player")). The server module hooks the chat script, sees the MXSOC prefix, swallows the message and answers with a LANG_ADDON whisper, which comes back as CHAT_MSG_ADDON. Nothing else - no opcode, no client patch. RegisterAddonMessagePrefix is deliberately NOT called: the string does not exist in this client's Wow.exe (checked 2026-08-29, zero hits), because prefix registration arrived in 4.0. On 3.3.5a every addon message is delivered and the filtering is ours to do. WHAT HAPPENS WHEN THE REALM HAS NO MODULE The server side is not deployed yet, so "no answer" is the normal case, not an error case. HELLO goes out on PLAYER_ENTERING_WORLD; if no VER comes back within HELLO_TIMEOUT the tab says "Account service unavailable" in muted text and retries on a lengthening backoff that tops out at five minutes. The retry never stops, so a realm that gains the module mid-session is picked up without a reload, and nothing is ever written to disk that could later be mistaken for a roster. A rejected sender is counted rather than dropped in silence: /msoc debug prints the tally, because "the addon sees nothing" and "the addon sees traffic it refuses" are different bugs and they look identical from the tab. ----------------------------------------------------------------------------]] local NS = ManiaxSocial if not NS then return end local PLAYER = nil NS.link = { helloSent = nil, -- GetTime() of the last HELLO tries = 0, -- how many HELLOs have gone unanswered nextTry = nil, -- GetTime() at which to try again unavailable = false, -- set once the first HELLO has timed out rejected = 0, -- messages with our prefix but a sender we do not trust received = 0, } --[[ listeners ------------------------------------------------------------ ]] NS.listeners = {} function NS.Subscribe(fn) NS.listeners[#NS.listeners + 1] = fn end function NS.Notify(event, detail) for i = 1, #NS.listeners do -- A broken listener must not take the event handler with it. local ok, err = pcall(NS.listeners[i], event, detail) if not ok then NS.Print("display error: " .. tostring(err)) end end end function NS.Print(message) DEFAULT_CHAT_FRAME:AddMessage("|cff82c5ffManiaxSocial|r: " .. tostring(message)) end --[[ sending -------------------------------------------------------------- ]] local function drain() if not PLAYER then return end local now = GetTime() while true do local message = NS.Pump(now) if not message then return end SendAddonMessage(NS.PREFIX, message, "WHISPER", PLAYER) end end local function hello() local link = NS.link link.helloSent = GetTime() link.nextTry = nil NS.Hello() NS.state.staging = nil drain() end -- Entering the world starts the conversation over: the backoff counter from a -- realm that was not answering an hour ago must not make this login wait five -- minutes before its first retry. local function resetLink() local link = NS.link link.unavailable = false link.tries = 0 link.nextTry = nil link.helloSent = nil end --[[ receiving ------------------------------------------------------------ ]] -- The design doc promises one logical line per message, but promises are not -- guarantees and a chunked sender is explicitly allowed. Split on newlines so -- that a server which packs two lines into one packet still parses. local function consume(message) local now = GetTime() local lines = NS.Split(message, "\n") local events = {} for i = 1, #lines do local event, detail = NS.HandleLine(lines[i], now) if event then events[#events + 1] = { event, detail } end end local link = NS.link if NS.state.available then link.unavailable = false link.tries = 0 link.nextTry = nil end for i = 1, #events do local event, detail = events[i][1], events[i][2] if event == "VER" then -- The server answers HELLO with VER and then the roster; asking for it -- again here would only spend rate limit. NS.Notify("LINK") elseif event == "OK" then -- OK names the verb and nothing else, so the only honest way to learn -- what the change did is to ask for the roster again. if detail ~= "LIST" and detail ~= "HELLO" then NS.List() end NS.Notify("OK", detail) else NS.Notify(event, detail) end end if NS.state.resyncWanted then NS.state.resyncWanted = false NS.List() end drain() end --[[ events --------------------------------------------------------------- ]] local frame = CreateFrame("Frame", "ManiaxSocialCommFrame") frame:RegisterEvent("ADDON_LOADED") frame:RegisterEvent("PLAYER_ENTERING_WORLD") frame:RegisterEvent("CHAT_MSG_ADDON") frame:SetScript("OnEvent", function(self, event, arg1, arg2, arg3, arg4) if event == "CHAT_MSG_ADDON" then if arg1 ~= NS.PREFIX then return end NS.link.received = NS.link.received + 1 -- We whisper ourselves, so the only sender we answer to is us. Anything -- else with this prefix is another player's traffic or a spoof attempt. if PLAYER and arg4 and string.lower(arg4) ~= string.lower(PLAYER) then NS.link.rejected = NS.link.rejected + 1 return end consume(arg2 or "") return end if event == "ADDON_LOADED" and arg1 == "ManiaxSocial" then NS.LoadPrefs() return end if event == "PLAYER_ENTERING_WORLD" then PLAYER = UnitName("player") -- The roster lives on the server. Every entry into the world re-asks for -- it; nothing is carried over from the last session. NS.Reset() resetLink() hello() NS.Notify("LINK") end end) frame:SetScript("OnUpdate", function(self, elapsed) local now = GetTime() local link = NS.link if NS.ExpireStaging(now) then NS.Notify("ROSTER") end if not NS.state.available and link.helloSent then if not link.unavailable and (now - link.helloSent) > NS.HELLO_TIMEOUT then link.unavailable = true link.tries = 1 link.nextTry = now + NS.RETRY_DELAYS[1] NS.Notify("LINK") elseif link.nextTry and now >= link.nextTry then link.tries = link.tries + 1 local index = link.tries if index > #NS.RETRY_DELAYS then index = #NS.RETRY_DELAYS end link.nextTry = now + NS.RETRY_DELAYS[index] hello() end end if NS.Queued() > 0 then drain() end end) --[[ saved variables ------------------------------------------------------ ]] -- Per-character UI preferences ONLY. There is deliberately no roster, no -- handle and no friend list on disk: a stale roster shown at login is a lie -- the player cannot tell from the truth. local DEFAULT_PREFS = { toast = true, toastSound = true, } function NS.LoadPrefs() ManiaxSocialCharDB = ManiaxSocialCharDB or {} for key, value in pairs(DEFAULT_PREFS) do if ManiaxSocialCharDB[key] == nil then ManiaxSocialCharDB[key] = value end end end function NS.Pref(key) if not ManiaxSocialCharDB then return DEFAULT_PREFS[key] end return ManiaxSocialCharDB[key] end function NS.SetPref(key, value) ManiaxSocialCharDB = ManiaxSocialCharDB or {} ManiaxSocialCharDB[key] = value end --[[ slash command -------------------------------------------------------- ]] SLASH_MANIAXSOCIAL1 = "/msoc" SLASH_MANIAXSOCIAL2 = "/maniaxsocial" SlashCmdList["MANIAXSOCIAL"] = function(argument) argument = string.lower(NS.Trim(argument or "")) if argument == "debug" then local link = NS.link NS.Print(string.format( "link: %s, version %s, %d received, %d rejected, %d queued, %d friends, %d requests", NS.state.available and "up" or "no answer", tostring(NS.state.version), link.received, link.rejected, NS.Queued(), NS.FriendCount(), NS.RequestCount())) if NS.state.lastError then NS.Print("last error: " .. tostring(NS.state.lastError.code) .. " " .. tostring(NS.state.lastError.text)) end return end if argument == "refresh" then NS.List() drain() NS.Print("roster requested") return end if NS.OpenAccountTab then NS.OpenAccountTab() else NS.Print("the interface failed to load; /msoc debug still reports the link.") end end