diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3a49bcd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.lua] +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[*.{toc,txt}] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f612b41 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto eol=lf + +*.lua text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.md text eol=lf +*.toc text eol=lf +*.txt text eol=lf +*.sh text eol=lf +.luacheckrc text eol=lf +.editorconfig text eol=lf +.gitignore text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..76bbfa9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install Lua 5.1 and Luacheck + run: | + sudo apt-get update + sudo apt-get install -y lua5.1 liblua5.1-0-dev luarocks + sudo luarocks --lua-version=5.1 install luacheck + luacheck --version + + - name: Run tests (Lua 5.1, full suite) + run: lua5.1 tests/run.lua + + - name: Run Luacheck on core helpers and tests + run: | + luacheck --config .luacheckrc \ + AIO_Server/queue.lua AIO_Client/queue.lua \ + AIO_Server/aio_util.lua AIO_Client/aio_util.lua \ + AIO_Server/aio_framing.lua AIO_Client/aio_framing.lua \ + AIO_Server/aio_reassembler.lua AIO_Client/aio_reassembler.lua \ + AIO_Server/aio_rpc.lua AIO_Client/aio_rpc.lua \ + AIO_Server/aio_core.lua AIO_Client/aio_core.lua \ + tests/run.lua tests/test_queue.lua tests/test_smallfolk.lua \ + tests/test_framing.lua tests/test_util.lua tests/test_stored.lua \ + tests/test_path_legacy.lua tests/test_reassembler.lua tests/test_lualzw.lua \ + tests/test_aio_rpc.lua tests/test_aio_core.lua \ + tests/lua_compat.lua tests/wow_stub.lua tests/aio_integration_util.lua \ + tests/test_aio_integration_server.lua tests/test_aio_integration_client.lua + + - name: Run Luacheck on WoW-only side modules + run: | + luacheck --config .luacheckrc \ + AIO_Server/aio_server_pipeline.lua AIO_Client/aio_client_ui.lua + + - name: Verify server and client shared modules stay in sync + run: | + diff -q AIO_Server/AIO.lua AIO_Client/AIO.lua + diff -q AIO_Server/aio_util.lua AIO_Client/aio_util.lua + diff -q AIO_Server/aio_framing.lua AIO_Client/aio_framing.lua + diff -q AIO_Server/aio_reassembler.lua AIO_Client/aio_reassembler.lua + diff -q AIO_Server/aio_rpc.lua AIO_Client/aio_rpc.lua + diff -q AIO_Server/queue.lua AIO_Client/queue.lua + diff -q AIO_Server/aio_core.lua AIO_Client/aio_core.lua + + test-server-lua: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + lua: ["5.2", "5.3", "5.4"] + steps: + - uses: actions/checkout@v5 + + - name: Install Lua ${{ matrix.lua }} + run: | + sudo apt-get update + sudo apt-get install -y "lua${{ matrix.lua }}" + + - name: Run tests (unit + server AIO integration; client suite skipped) + run: lua${{ matrix.lua }} tests/run.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..400e3a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# OS and editor +.DS_Store +Thumbs.db +*.swp +*.swo +*~ + +# Local config overrides (if used) +config.lua + +# IDE +.idea/ +.vscode/ +*.code-workspace + +# Test / build artifacts +*.luac diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..710dbc2 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,98 @@ +std = "lua51" + +exclude_files = { + "AIO_Server/Dep_*", + "AIO_Client/Dep_*", + "AIO_Server/lualzw-zeros/**", + "AIO_Client/lualzw-zeros/**", + "Examples/**", +} + +max_line_length = 120 + +globals = { + "AIO", + "LibStub", + "GetLuaEngine", + "GetTime", + "GetStateMapId", + "RegisterServerEvent", + "RegisterPlayerEvent", + "CreateFrame", + "CreateLuaEvent", + "GetPlayersInWorld", + "SendAddonMessage", + "UnitName", + "ReloadUI", + "SlashCmdList", + "_ERRORMESSAGE", + "Smallfolk", + "NewQueue", + "lualzw", + "Ping", + "test", + "assert_eq", + "assert_true", + "require", + "package", + "debug", +} + +files["AIO_Server/aio_util.lua"] = {} +files["AIO_Client/aio_util.lua"] = {} + +files["AIO_Server/queue.lua"] = {} +files["AIO_Client/queue.lua"] = {} + +files["AIO_Server/aio_framing.lua"] = {} +files["AIO_Client/aio_framing.lua"] = {} + +files["AIO_Server/aio_reassembler.lua"] = {} +files["AIO_Client/aio_reassembler.lua"] = {} + +files["AIO_Server/aio_rpc.lua"] = {} +files["AIO_Client/aio_rpc.lua"] = {} + +files["AIO_Server/aio_core.lua"] = {} +files["AIO_Client/aio_core.lua"] = {} + +files["AIO_Server/aio_server_pipeline.lua"] = { + ignore = {"1.", "2.", "3.", "4.", "5.", "6."}, +} + +files["AIO_Client/aio_client_ui.lua"] = { + ignore = {"1.", "2.", "3.", "4.", "5.", "6."}, +} + +files["AIO_Server/AIO.lua"] = { + ignore = {"1.", "2.", "3.", "4.", "5.", "6."}, +} + +files["AIO_Client/AIO.lua"] = { + ignore = {"1.", "2.", "3.", "4.", "5.", "6."}, +} + +files["tests/run.lua"] = { + globals = {"dofile", "os", "skip"}, +} + +files["tests/lua_compat.lua"] = { + ignore = {"1.", "2.", "3.", "4.", "5.", "6."}, +} + +files["tests/wow_stub.lua"] = { + ignore = {"1.", "2.", "3.", "4.", "5.", "6."}, +} + +files["tests/aio_integration_util.lua"] = { + ignore = {"1.", "2.", "3.", "4.", "5.", "6."}, +} + +files["tests/test_aio_integration_server.lua"] = { + ignore = {"1.", "2.", "3.", "4.", "5.", "6."}, +} + +files["tests/test_aio_integration_client.lua"] = { + ignore = {"1.", "2.", "3.", "4.", "5.", "6."}, +} + diff --git a/AIO_Client/AIO.lua b/AIO_Client/AIO.lua index ba283b3..ff61e9e 100644 --- a/AIO_Client/AIO.lua +++ b/AIO_Client/AIO.lua @@ -51,7 +51,7 @@ end -- according to settings in AIO.lua. The addon is cached on client side and will -- be updated only when needed. 'name' is an unique name for the addon, usually -- you can use the file name or addon name there. Do note that short names are --- better since they are sent back and forth to indentify files. +-- better since they are sent back and forth to identify files. -- The function only exists on server side. Only on main lua state. -- You should call this function only on startup to ensure everyone gets the same -- addons and no addon is duplicate. @@ -80,7 +80,7 @@ handlertable = AIO.AddHandlers(name, handlertable) -- Only on main lua state. -- Adds a new callback function that is called if a message with the given --- name is recieved. All parameters the sender sends in the message will +-- name is received. All parameters the sender sends in the message will -- be passed to func when called. -- Example message: AIO.Msg():Add(name, ...):Send() -- AIO.AddHandlers uses AIO.RegisterEvent internally, so same name can not be used on both. @@ -210,11 +210,15 @@ local AIO_UI_INIT_DELAY = 5*1000 -- ms -- default 5*1000 local AIO_MSG_COMPRESS = true -- default true -- Setting to enable and disable obfuscation for code to reduce size --- Note that error messages will not have correct line numbers since obfuscation rearranage the code +-- Note that error messages will not have correct line numbers since obfuscation rearrange the code -- for debugging purposes it is recommended to disable this option -- Server side only local AIO_CODE_OBFUSCATE = true -- default true +-- Force all online players to reload UI when the AIO server script loads or reloads +-- Server side only +local AIO_FORCE_RELOAD_ON_STARTUP = true -- default true + -- Setting to send client errors to server -- Client must have AIO_ENABLE_PCALL enabled -- Client side only @@ -224,36 +228,38 @@ local AIO_ERROR_LOG = false -- default false ---------------------------------- -local assert = assert -local type = type -local tostring = tostring -local pairs = pairs -local ipairs = ipairs local ssub = string.sub -local match = string.match -local ceil = ceil or math.ceil -local floor = floor or math.floor -local sbyte = strbyte or string.byte local schar = string.char +local aio_core = require("aio_core") +local aio_framing = require("aio_framing") +local aio_reassembler_mod = require("aio_reassembler") +local aio_rpc_mod = require("aio_rpc") local tconcat = table.concat -local select = select -local pcall = pcall -local xpcall = xpcall -- Some lua compatibility between 5.1 and 5.2 -local loadstring = loadstring or load -- loadstring name varies with lua 5.1 and 5.2 -local unpack = unpack or table.unpack -- unpack place varies with lua 5.1 and 5.2 --- server client compatibility -local AIO_GetTime = os and os.time or function() return GetTime()*1000 end -local AIO_GetTimeDiff = os and os.difftime or function(_now, _then) return _now-_then end +local loadstring = loadstring or load -- luacheck: ignore 113 +local unpack = _G.unpack +if not unpack then + unpack = table.unpack -- luacheck: ignore 113 +end +-- server client compatibility (milliseconds on both sides) +local AIO_GetTime = os and function() return os.time() * 1000 end or function() return GetTime() * 1000 end +local AIO_GetTimeDiff = function(now, earlier) return now - earlier end -- boolean value to define whether we are on server or client side local AIO_SERVER = type(GetLuaEngine) == "function" + +local function AIO_IsPlayerObject(player) + if _G.AIO_TEST_ALLOW_TABLE_PLAYERS then + return type(player) == "userdata" or type(player) == "table" + end + return type(player) == "userdata" +end -- boolean value to define if we are on main lua state (eluna multistate support) local AIO_MAIN_LUA_STATE = not AIO_SERVER or not GetStateMapId or GetStateMapId() == -1 -- Client must have same version (basically same AIO file) -local AIO_VERSION = 1.75 +local AIO_VERSION = 1.76 -- ID characters for client-server messaging -local AIO_ShortMsg = schar(1)..schar(1) +local AIO_ShortMsg = aio_framing.SHORT_TAG local AIO_Compressed = 'C' local AIO_Uncompressed = 'U' local AIO_Prefix = "AIO" @@ -276,15 +282,19 @@ AIO = unpack = unpack, } -local AIO = AIO +local AIO = AIO -- luacheck: ignore 431 -- Client side table containing frames that need to have their position saved local AIO_SAVEDFRAMES = {} -- Client side tables that contain keys to _G table for saved variables -- you should add your variables here with AIO.AddSavedVar(key) or AIO.AddSavedVarChar(key) local AIO_SAVEDVARS = {} local AIO_SAVEDVARSCHAR = {} --- Client side flag for noting if the client has been inited or not -local AIO_INITED = false +-- Client-only mutable state (shared with aio_client_ui) +local AIO_client_state = not AIO_SERVER and { + AIO_INITED = false, + AIO_PENDING_RESET = false, + AIO_VERSION_MISMATCH = false, +} or nil -- Server and Client side functions to execute on AIO messages local AIO_HANDLERS = AIO_MAIN_LUA_STATE and {} or nil -- Server side functions to execute when an init msg is received @@ -296,11 +306,12 @@ local AIO_BLOCKHANDLES = AIO_MAIN_LUA_STATE and {} or nil local AIO_ADDONSORDER = AIO_MAIN_LUA_STATE and {} or nil -- Dependencies -local LibWindow -local LuaSrcDiet local NewQueue = NewQueue or require("queue") local Smallfolk = Smallfolk or require("smallfolk") local lualzw = lualzw or require("lualzw") +local aio_util = require("aio_util") +local LibWindow -- luacheck: ignore 231 +local LuaSrcDiet -- luacheck: ignore 231 if AIO_SERVER then if AIO_MAIN_LUA_STATE then LuaSrcDiet = require("LuaSrcDiet") @@ -324,39 +335,8 @@ function AIO.GetVersion() return AIO_VERSION end --- Converts an uint16 number to string (2 chars) --- Note that this escapes using \0 character so the full uint16 range is not usable -local function AIO_16tostring(uint16) - -- split 16bit to 2 8bit parts but without \0 - assert(uint16 <= 2^16-767, "Too high value") - assert(uint16 >= 0, "Negative value") - local high = floor(uint16 / 254) - local l = high +1 - local r = uint16 - high * 254 +1 - return schar(l)..schar(r) -end - --- Converts a string (2 chars) to uint16 number --- Note that the chars can not be \0 character so the full uint16 range is not usable -local function AIO_stringto16(str) - local l = sbyte(ssub(str, 1,1)) -1 - local r = sbyte(ssub(str, 2,2)) -1 - local val = l*254 + r - assert(val <= 2^16-767, "Too high value") - assert(val >= 0, "Negative value") - return val -end - --- Resets AIO saved variables on client side -local AIO_RESET -if not AIO_SERVER then - function AIO_RESET() - AIO_SAVEDVARS = nil - AIO_SAVEDVARSCHAR = nil - AIO_sv_Addons = nil - AIO_SAVEDFRAMES = {} - end -end +-- Client reset (set in client branch below) +local AIO_RESET -- luacheck: ignore 231 -- Used to print debug messages if AIO_ENABLE_DEBUG_MSGS is true function AIO_debug(...) @@ -365,117 +345,53 @@ function AIO_debug(...) end end --- returns the amount of varargs from passed varargs -local function AIO_extractN(...) - return select("#", ...), ... -end - --- Calls function f with parameters ... with pcall --- Shows errors with print or AIO_debug -local function AIO_pcall(f, ...) - assert(type(f) == 'function') - if not AIO_ENABLE_PCALL then - return f(...) - end - local data - if AIO_SERVER and AIO_ENABLE_TRACEBACK and debug.traceback then - data = {AIO_extractN(xpcall(f, debug.traceback, ...))} - else - data = {AIO_extractN(pcall(f, ...))} - end - if not data[2] then +local AIO_pcall = aio_core.make_pcall({ + unpack = unpack, + pcall = pcall, + xpcall = xpcall, + enable_pcall = AIO_ENABLE_PCALL, + server = AIO_SERVER, + enable_traceback = AIO_ENABLE_TRACEBACK, + debug_traceback = debug.traceback, + on_error = function(err) if AIO_SERVER then - AIO_debug(data[3]) + AIO_debug(err) else if AIO_ERROR_LOG then - AIO.Handle("AIO", "Error", data[3]) + AIO.Handle("AIO", "Error", err) end if AIO_ENABLE_TRACEBACK then - _ERRORMESSAGE(data[3]) + _ERRORMESSAGE(err) else - print(data[3]) + print(err) end end - return - end - return unpack(data, 3, data[1]+1) -end + end, +}) --- Reads a file at given absolute or relative to server root path --- and returns the full file contents as a string -local function AIO_ReadFile(path) - AIO_debug("Reading a file") - assert(type(path) == 'string', "#1 string expected") - local f = assert(io.open(path, "rb")) - local str = f:read("*all") - f:close() - return str -end +local framing_codec = aio_framing.new(AIO_MsgLen) +local reassembler = aio_reassembler_mod.new({ + framing = framing_codec, + NewQueue = NewQueue, + get_time = AIO_GetTime, + get_time_diff = AIO_GetTimeDiff, + get_message_stored_size = aio_util.getMessageStoredSize, + cache_space = AIO_SERVER and AIO_MSG_CACHE_SPACE or nil, + cache_time_ms = AIO_MSG_CACHE_TIME, + msg_id_min = MSG_MIN, + msg_id_max = MSG_MAX, +}) --- player data handler -local plrdata = {} -local removeque = NewQueue() -local function RemoveData(guid, msgid) - local pdata = plrdata[guid] - if pdata then - if msgid then - local data = pdata[msgid] - if data then - pdata[msgid] = nil - pdata.ramque:gettable()[data.ramquepos] = nil - removeque:gettable()[data.remquepos] = nil - end - else - local que = pdata.ramque:gettable() - local l, r = pdata.ramque:getrange() - for i = l, r do - if que[i] then - removeque:gettable()[que[i].remquepos] = nil - end - end - plrdata[guid] = nil - end - end -end local function ProcessRemoveQue() - if removeque:empty() then - return - end - local now = AIO_GetTime() - local l, r = removeque:getrange() - for i = l, r do - local v = removeque:popleft() - if v then - if AIO_GetTimeDiff(now, v.stamp) < AIO_MSG_CACHE_TIME then - AIO_debug("removing outdated incomplete message") - removeque:pushleft(v) - break - end - RemoveData(v.guid, v.id) - end - end + reassembler:sweep_expired() end -if AIO_SERVER then - if AIO_MAIN_LUA_STATE then - CreateLuaEvent(ProcessRemoveQue, AIO_MSG_CACHE_DELAY, 0) - end -else - local frame = CreateFrame("Frame") - local timer = AIO_MSG_CACHE_DELAY - local function ONUPDATE(self, diff) - if timer > diff then - timer = timer - diff - else - ProcessRemoveQue() - timer = AIO_MSG_CACHE_DELAY - end - end - frame:SetScript("OnUpdate", ONUPDATE) +if AIO_SERVER and AIO_MAIN_LUA_STATE then + CreateLuaEvent(ProcessRemoveQue, AIO_MSG_CACHE_DELAY, 0) end -- Erase data on logout if AIO_SERVER and AIO_MAIN_LUA_STATE then - local function Erase(event, player) - RemoveData(player:GetGUIDLow()) + local function Erase(_, player) + reassembler:remove_peer(player:GetGUIDLow()) end RegisterPlayerEvent(4, Erase) end @@ -497,53 +413,20 @@ end -- Splits too long messages into smaller pieces local function AIO_Send(msg, player, ...) assert(type(msg) == "string", "#1 string expected") - assert(not AIO_SERVER or type(player) == 'userdata', "#2 player expected") + assert(not AIO_SERVER or AIO_IsPlayerObject(player), "#2 player expected") AIO_debug("Sending message length:", #msg) if AIO_ENABLE_MSGPRINT then print("sent:", msg) end - -- split message to 255 character packets if needed (send long message) if #msg <= AIO_MsgLen then - -- Send short <= AIO_MsgLen msg - AIO_SendAddonMessage(AIO_ShortMsg..msg, player) + AIO_SendAddonMessage(AIO_ShortMsg .. msg, player) else - -- Send long > AIO_MsgLen msg - - local guid = AIO_SERVER and player:GetGUIDLow() or 1 - if not plrdata[guid] then - plrdata[guid] = { - stored = 0, - ramque = NewQueue(), - MSG_GUID = MSG_MIN, - } - end - local pdata = plrdata[guid] - - -- the chars can not contain \0 - -- 16bit -> Message ID -- 0 reserved for identifying short msg - -- 16bit -> Number of parts (should be > 1) - -- 16bit -> Part ID - -- Rest -> Message String - - -- msglen - 4 bits for header data, messageid is already substracted - local msglen = (AIO_MsgLen-4) - -- Calculate amount of messages to send - local parts = ceil(#msg / msglen) - -- assemble header - local header = AIO_16tostring(pdata.MSG_GUID)..AIO_16tostring(parts) - - -- update guid - if pdata.MSG_GUID >= MSG_MAX then - pdata.MSG_GUID = MSG_MIN - else - pdata.MSG_GUID = pdata.MSG_GUID+1 - end - - -- send messages - for i = 1, parts do - AIO_SendAddonMessage(header..AIO_16tostring(i)..ssub(msg, ((i-1)*msglen)+1, (i*msglen)), player) + local peer_id = AIO_SERVER and player:GetGUIDLow() or 1 + local packets = reassembler:split_payload(peer_id, msg) + for i = 1, #packets do + AIO_SendAddonMessage(packets[i], player) end end @@ -555,255 +438,66 @@ local function AIO_Send(msg, player, ...) end end --- Message class metatable -local msgmt = {} -function msgmt.__index(tbl, key) - return msgmt[key] -end - --- Add a new block to message and returns self --- A block is a chunk of data identified by a string name --- blocks are sent between server and client and handled on the receiving end --- by block handlers. Blockhandlers are functions you can assign to --- a specific name as a handler with AIO.RegisterEvent(name, func) --- The All values in the block after it's name will be passed to the handler --- function in same order. -function msgmt:Add(Name, ...) - assert(Name, "#1 Block must have name") - self.params[#self.params+1] = {select('#', ...), Name, ...} - self.assemble = true - return self -end - --- Function to append messages together, returns self --- Example AIO.Msg():Append(msg):Append(msg2):Send(...) -function msgmt:Append(msg2) - assert(type(msg2) == 'table', "#1 table expected") - for i = 1, #msg2.params do - assert(type(msg2.params[i]) == 'table', "#1["..i.."] table expected") - self.params[#self.params+1] = msg2.params[i] - end - self.assemble = true - return self -end - --- Assembles the message string from stored data -function msgmt:Assemble() - if not self.assemble then - return self - end - self.MSG = Smallfolk.dumps(self.params) - self.assemble = false - return self -end - --- Function to send the message to given players -function msgmt:Send(player, ...) - assert(not AIO_SERVER or player, "#1 player is nil") - AIO_Send(self:ToString(), player, ...) - return self -end - --- Erases the so far built message and returns self -function msgmt:Clear() - for i = 1, #self.params do - self.params[i] = nil - end - self.MSG = nil - self.assemble = false - return self -end - --- Returns the message string or an empty string -function msgmt:ToString() - return self:Assemble().MSG -end - --- Returns true if the message has something in it -function msgmt:HasMsg() - return #self.params > 0 -end +local timeout_parse_msg = '' +local function AIO_Timeout() + error(string.format( + "AIO Timeout. Your code ran over %s instructions with message:\n%s", + '' .. AIO_TIMEOUT_INSTRUCTIONCOUNT, + timeout_parse_msg or 'nil' + )) +end + +local timeout_hook +if AIO_SERVER and AIO_TIMEOUT_INSTRUCTIONCOUNT > 0 then + timeout_hook = { + begin = function(msg) + timeout_parse_msg = msg + debug.sethook(AIO_Timeout, "", AIO_TIMEOUT_INSTRUCTIONCOUNT) + end, + on_end = function() + debug.sethook() + end, + } +end + +local rpc = aio_rpc_mod.new({ + dumps = Smallfolk.dumps, + loads = Smallfolk.loads, + pcall = AIO_pcall, + get_handlers = function() return AIO_BLOCKHANDLES end, + debug = AIO_debug, + enable_msgprint = AIO_ENABLE_MSGPRINT, + server = AIO_SERVER, + timeout_hook = timeout_hook, +}) +rpc.bind_send(AIO_Send) --- Creates and returns a new message that you can append stuff to and send to client or server --- Example: AIO.Msg():Add("MyHandlerName", param1, param2):Send(player) function AIO.Msg() - local msg = {params = {}, MSG = nil, assemble = false} - setmetatable(msg, msgmt) - return msg + return rpc.Msg() end --- Calls the handler for block, see AIO.RegisterEvent --- for adding handlers for blocks -local preinitblocks = {} -local function AIO_HandleBlock(player, data, skipstored) - local HandleName = data[2] - assert(HandleName, "Invalid handle, no handle name") - - if not AIO_SERVER and not AIO_INITED and (HandleName ~= 'AIO' or data[3] ~= 'Init') then - -- store blocks received before initialization - preinitblocks[#preinitblocks+1] = data - AIO_debug("Received block before Init:", HandleName, data[1], data[3]) - return - end - - local handledata = AIO_BLOCKHANDLES[HandleName] - if not handledata then - error("Unknown AIO block handle: '"..tostring(HandleName).."'") - end - - -- found the block handler and arguments match the format. - -- call the block handler - if AIO_SERVER and data[1] > 15 then - error("Received AIO block with over 15 arguments. Try using tables instead") - return - end - handledata(player, unpack(data, 3, data[1]+2)) - - if not skipstored and not AIO_SERVER and AIO_INITED and HandleName == 'AIO' and data[3] == 'Init' then - -- handle stored blocks after initialization, if they are not init messages - for i = 1, #preinitblocks do - AIO_HandleBlock(player, preinitblocks[i], true) - preinitblocks[i] = nil - end - end -end - --- Extracts blocks from assembled addon messages -local curmsg = '' -local function AIO_Timeout() - error(string.format("AIO Timeout. Your code ran over %s instructions with message:\n%s", ''..AIO_TIMEOUT_INSTRUCTIONCOUNT, (curmsg or 'nil'))) -end -local function _AIO_ParseBlocks(msg, player) - if AIO_SERVER and AIO_TIMEOUT_INSTRUCTIONCOUNT > 0 then - curmsg = msg - debug.sethook(AIO_Timeout, "", AIO_TIMEOUT_INSTRUCTIONCOUNT) - end - - AIO_debug("Received messagelength:", #msg) - if AIO_ENABLE_MSGPRINT then - print("received:", msg) - end - - -- deserialize the message - local data = AIO_pcall(Smallfolk.loads, msg, #msg) - if not data or type(data) ~= 'table' then - AIO_debug("Received invalid message - data not a table") - return - end - - -- Handle parsing of all blocks - for i = 1, #data do - -- Using pcall here so errors wont stop handling other blocks in the msg - AIO_pcall(AIO_HandleBlock, player, data[i]) - end +local AIO_HandleBlock = aio_core.make_handle_block({ + unpack = unpack, + client_state = AIO_client_state, + block_handlers = AIO_BLOCKHANDLES, + server = AIO_SERVER, + max_block_args = 15, + debug = AIO_debug, +}) - if AIO_SERVER and AIO_TIMEOUT_INSTRUCTIONCOUNT > 0 then - debug.sethook() - end -end local function AIO_ParseBlocks(msg, player) - AIO_pcall(_AIO_ParseBlocks, msg, player) + AIO_pcall(function() + rpc.parse_blocks(msg, player, function(p, data) + AIO_pcall(AIO_HandleBlock, p, data) + end) + end) end --- Handles cleaning and assembling the messages received --- Messages can be 255 characters long, so big messages will be split local function _AIO_HandleIncomingMsg(msg, player) - -- Received a long message part (msg split into 255 character parts) - local msgid = ssub(msg, 1,2) - - if msgid == AIO_ShortMsg then - -- Received <= 255 char msg, direct parse, take out the msg tag first - AIO_ParseBlocks(ssub(msg, 3), player) - return - end - - -- the chars can not contain \0 - -- 16bit -> Message ID -- 0 reserved for identifying short msg - -- 16bit -> Number of parts (should be > 1) - -- 16bit -> Part ID - -- Rest -> Message String - - if #msg < 3*2 then - return - end - - local messageId = AIO_stringto16(msgid) - local parts = AIO_stringto16(ssub(msg, 3,4)) - local partId = AIO_stringto16(ssub(msg, 5,6)) - if partId <= 0 or partId > parts then - error("received long message with invalid amount of parts. id, parts: "..partId.." "..parts) - return - end - - msg = ssub(msg, 7) - - -- guid is used to store information about long messages for specific player - local guid = AIO_SERVER and player:GetGUIDLow() or 1 - - if not plrdata[guid] then - plrdata[guid] = { - stored = 0, - ramque = NewQueue(), - MSG_GUID = MSG_MIN, - } - end - local pdata = plrdata[guid] - pdata[messageId] = pdata[messageId] or {} - local data = pdata[messageId] - - -- Different message with same ID, scrap previous message (probably reloaded UI) - -- Or new message so parts is nil - if not data.parts or data.parts.n ~= parts then - if data.parts then - for i = 0, data.parts.n do - data.parts[i] = nil - end - end - data.guid = guid - data.parts = {n=parts} - data.id = messageId - data.stamp = AIO_GetTime() - data.remquepos = removeque:pushright(data) - data.ramquepos = pdata.ramque:pushright(data) - end - - data.parts[partId] = msg - - pdata.stored = pdata.stored + #msg - if AIO_SERVER and pdata.stored > AIO_MSG_CACHE_SPACE then - local l, r = pdata.ramque:getrange() - for i = l, r-1 do -- -1 for leaving at least one message - -- remove message from stores leaving it for GC - local msgdata = pdata.ramque:popleft() - if msgdata then - removeque:gettable()[msgdata.remquepos] = nil - pdata[msgdata.id] = nil - -- count the data it holds and substract from stored data - for j = 1, msgdata.parts.n do - if msgdata.parts[j] then - pdata.stored = pdata.stored - #msgdata.parts[j] - end - end - -- check if enough freed to hold latest message in the cache - if pdata.stored <= AIO_MSG_CACHE_SPACE then - break - end - end - end - -- if still error even though tried freeing all memory possible to free - -- throw error and clear cache - if pdata.stored > AIO_MSG_CACHE_SPACE then - RemoveData(guid) - error("AIO_MSG_CACHE_SPACE is too small for received message") - return - end - end - - -- Has all parts, process - if #data.parts == data.parts.n then - local cat = tconcat(data.parts) - RemoveData(guid, messageId) - AIO_ParseBlocks(cat, player) + local peer_id = AIO_SERVER and player:GetGUIDLow() or 1 + local assembled = reassembler:ingest(peer_id, msg) + if assembled then + AIO_ParseBlocks(assembled, player) end end local function AIO_HandleIncomingMsg(msg, player) @@ -812,7 +506,7 @@ end if AIO_MAIN_LUA_STATE then -- Adds a new callback function for AIO that is called if - -- a block with the same name is recieved. + -- a block with the same name is received. -- All parameters the client sends will be passed to func when called -- Only one function can be a handler for one name (subject for change) function AIO.RegisterEvent(name, func) @@ -838,6 +532,8 @@ if AIO_MAIN_LUA_STATE then local function handler(player, key, ...) if key and handlertable[key] then handlertable[key](player, ...) + elseif key then + AIO_debug("Unknown handler key for", name, ":", key) end end AIO.RegisterEvent(name, handler) @@ -851,370 +547,66 @@ end -- omitted the file the function is executed in will be used as path -- and the path's or given path's file name will be used. -- Returns true if called on server side +local aio_side_ctx = { + AIO = AIO, + AIO_VERSION = AIO_VERSION, + AIO_client_state = AIO_client_state, + AIO_SAVEDFRAMES = AIO_SAVEDFRAMES, + AIO_SAVEDVARS = AIO_SAVEDVARS, + AIO_SAVEDVARSCHAR = AIO_SAVEDVARSCHAR, + AIO_HANDLERS = AIO_HANDLERS, + AIO_ADDONSORDER = AIO_ADDONSORDER, + AIO_INITHOOKS = AIO_INITHOOKS, + AIO_debug = AIO_debug, + AIO_pcall = AIO_pcall, + AIO_HandleIncomingMsg = AIO_HandleIncomingMsg, + AIO_Msg = AIO.Msg, + AIO_CODE_OBFUSCATE = AIO_CODE_OBFUSCATE, + AIO_MSG_COMPRESS = AIO_MSG_COMPRESS, + AIO_UI_INIT_DELAY = AIO_UI_INIT_DELAY, + AIO_FORCE_RELOAD_ON_STARTUP = AIO_FORCE_RELOAD_ON_STARTUP, + AIO_MSG_CACHE_DELAY = AIO_MSG_CACHE_DELAY, + AIO_Compressed = AIO_Compressed, + AIO_Uncompressed = AIO_Uncompressed, + AIO_ClientPrefix = AIO_ClientPrefix, + AIO_ServerPrefix = AIO_ServerPrefix, + AIO_Prefix = AIO_Prefix, + lualzw = lualzw, + aio_util = aio_util, + LuaSrcDiet = LuaSrcDiet, + LibWindow = LibWindow, + loadstring = loadstring, + ssub = ssub, + reassembler_sweep = ProcessRemoveQue, +} + function AIO.AddAddon(path, name) if AIO_SERVER then if AIO_MAIN_LUA_STATE then - path = path or debug.getinfo(2, 'S').source:sub(2) - name = name or match(path, "([^/]*)$") - local code = AIO_ReadFile(path) - AIO.AddAddonCode(name, code) - AIO_debug("Added addon path&name:", path, name) + require("aio_server_pipeline").add_addon_file(aio_side_ctx, path, name) end return true end end if AIO_SERVER then - -- A shorthand for sending a message for a handler. function AIO.Handle(player, name, handlername, ...) - assert(type(player) == 'userdata', "#1 player expected") + assert(AIO_IsPlayerObject(player), "#1 player expected") assert(name ~= nil, "#2 expected not nil") return AIO.Msg():Add(name, handlername, ...):Send(player) end - if AIO_MAIN_LUA_STATE then - -- Adds the addon code to the sent addons on login. - -- The addon code is trimmed according to settings at top of this file. - -- The addon is cached on client side and will be updated if needed. - -- name is an unique ID for the addon, usually you can use the file name or addon name there - -- Do note that short names are better since they are sent back and forth to indentify files - local crc32 = require("crc32lua").crc32 - function AIO.AddAddonCode(name, code) - assert(type(name) == 'string', "#1 string expected") - assert(type(code) == 'string', "#2 string expected") - if AIO_CODE_OBFUSCATE then - code = LuaSrcDiet(code, 3) - end - if AIO_MSG_COMPRESS then - code = AIO_Compressed..assert(lualzw.compress(code)) - else - code = AIO_Uncompressed..code - end - AIO_ADDONSORDER[#AIO_ADDONSORDER+1] = {name=name, crc=crc32(code), code=code} - end - - -- Adds a new function that is called when an init message - -- is about to be sent by server. The function is called before sending and - -- the message is passed to it along with the player if available: - -- func(msg[, player]) - -- you can modify the passed message and or return a new one - function AIO.AddOnInit(func) - assert(type(func) == 'function', "#1 function expected") - table.insert(AIO_INITHOOKS, func) - end - - -- This restricts player's ability to request the initial UI to some set time delay - local timers = {} - local function RemoveInitTimer(eventid, playerguid) - if type(playerguid) == "number" then - timers[playerguid] = nil - end - end - -- This handles sending initial UI to player. - -- The Client sends a request to the server for the addons along with it's cached addon data. - -- Then the server checks what files it has to send back and what it has to remove from the client's cache. - -- Then after server sends the required data to client, the client will one by one execute the addons - -- in the same order as they are sent from the server. - local versionmsg = AIO.Msg():Add("AIO", "Init", AIO_VERSION) - function AIO_HANDLERS.Init(player, version, clientdata) - -- check that the player is not on cooldown for init calling - local guid = player:GetGUIDLow() - if timers[guid] then - return - end - - -- make a new cooldown for init calling - timers[guid] = CreateLuaEvent(function(e) RemoveInitTimer(e, guid) end, AIO_UI_INIT_DELAY, 1) -- the timer here (AIO_UI_INIT_DELAY) is the min time in ms between inits the player can do - - -- Check for bad version and send version back for error directly - if version ~= AIO_VERSION then - versionmsg:Send(player) - return - end - - local istable = type(clientdata) == 'table' - - local addons = {} - local cached = {} - for i = 1, #AIO_ADDONSORDER do - local data = AIO_ADDONSORDER[i] - local clientcrc = istable and clientdata[data.name] or nil - if clientcrc and clientcrc == data.crc then - -- valid - send name only - cached[i] = data.name - else - -- not cached or outdated - send new - addons[i] = data - end - end - - local initmsg = AIO.Msg():Add("AIO", "Init", AIO_VERSION, #AIO_ADDONSORDER, addons, cached) - - for k,v in ipairs(AIO_INITHOOKS) do - initmsg = v(initmsg, player) or initmsg - end - - initmsg:Send(player) - end - - -- Handler that catches client errors - -- can be used to log client errors to server - function AIO_HANDLERS.Error(player, errmsg) - if not AIO_ERROR_LOG or type(errmsg) ~= 'string' then - return - end - PrintInfo(errmsg) - end - - -- An addon message event handler for the lua engine - -- If the message data is correct, move the message forward to the AIO message handler. - local function ONADDONMSG(event, sender, Type, prefix, msg, target) - if prefix == AIO_ClientPrefix and tostring(sender) == tostring(target) and #msg < 510 then - AIO_HandleIncomingMsg(msg, sender) - end - end - - RegisterServerEvent(30, ONADDONMSG) - - for k,v in ipairs(GetPlayersInWorld()) do - AIO.Handle(v, "AIO", "ForceReload") - end + require("aio_server_pipeline").install_main_state(aio_side_ctx) end - else - - -- A shorthand for sending a message for a handler. function AIO.Handle(name, handlername, ...) assert(name ~= nil, "#1 expected not nil") return AIO.Msg():Add(name, handlername, ...):Send() end - -- Key is a key for a variable in the global table _G - -- The variable is stored when the player logs out and will be restored - -- when he logs back in before the addon codes are run - -- these variables are account bound - function AIO.AddSavedVar(key) - assert(key ~= nil, "#1 table key expected") - AIO_SAVEDVARS[key] = true - end - - -- Key is a key for a variable in the global table _G - -- The variable is stored when the player logs out and will be restored - -- when he logs back in before the addon codes are run - -- these variables are character bound - function AIO.AddSavedVarChar(key) - assert(key ~= nil, "#1 table key expected") - AIO_SAVEDVARSCHAR[key] = true - end - - AIO_FRAMEPOSITIONS = AIO_FRAMEPOSITIONS or {} - AIO.AddSavedVar("AIO_FRAMEPOSITIONS") - AIO_FRAMEPOSITIONSCHAR = AIO_FRAMEPOSITIONSCHAR or {} - AIO.AddSavedVarChar("AIO_FRAMEPOSITIONSCHAR") - -- Makes the frame save it's position over relog - -- If char is true, the position saving is character bound, otherwise account bound - function AIO.SavePosition(frame, char) - assert(frame:GetName(), "Called AIO.SavePosition on a nameless frame") - local store = char and AIO_FRAMEPOSITIONSCHAR or AIO_FRAMEPOSITIONS - if not store[frame:GetName()] then - store[frame:GetName()] = {} - end - LibWindow.RegisterConfig(frame, store[frame:GetName()]) - LibWindow.RestorePosition(frame) - LibWindow.SavePosition(frame) - table.insert(AIO_SAVEDFRAMES, frame) - end - - -- A client side event handler - -- Passes the incoming message to AIO message handler if it is valid - local function ONADDONMSG(self, event, prefix, msg, Type, sender) - if prefix == AIO_ServerPrefix then - if event == "CHAT_MSG_ADDON" and sender == UnitName("player") then - -- Normal AIO message handling from addon messages - AIO_HandleIncomingMsg(msg, sender) - end - end - end - local MsgReceiver = CreateFrame("Frame") - MsgReceiver:RegisterEvent("CHAT_MSG_ADDON") - MsgReceiver:SetScript("OnEvent", ONADDONMSG) - - -- A block handler for Init name, checks the version number and errors out if needed - -- On wrong version prevents handling any more messages - -- Stores new and changed addons to cache and runs the addons from cache - -- Also removes removed and outdated addons - local function RunAddon(name) - -- Check if code is compressed and uncompress if needed - local code = AIO_sv_Addons[name] and AIO_sv_Addons[name].code - assert(code, "Addon doesnt exist") - local compression, compressedcode = ssub(code, 1, 1), ssub(code, 2) - if compression == AIO_Compressed then - compressedcode = assert(lualzw.decompress(compressedcode)) - end - assert(loadstring(compressedcode, name))() - end - function AIO_HANDLERS.Init(player, version, N, addons, cached) - if(AIO_VERSION ~= version) then - AIO_INITED = true - -- stop handling any incoming messages - AIO_HandleBlock = function() end - print("You have AIO version "..AIO_VERSION.." and the server uses "..(version or "nil")..". Get the same version") - return - end - - assert(type(N) == 'number') - assert(type(addons) == 'table') - assert(type(cached) == 'table') - - local validAddons = {} - for i = 1, N do - local name - if addons[i] then - name = addons[i].name - AIO_sv_Addons[name] = addons[i] - validAddons[name] = true - elseif cached[i] then - name = cached[i] - validAddons[name] = true - else - error("Unexpected behavior, try /aio reset") - end - - AIO_pcall(RunAddon, name) - end - - local invalidAddons = {} - for name, data in pairs(AIO_sv_Addons) do - if not validAddons[name] then - invalidAddons[#invalidAddons+1] = name - end - end - - for i = 1, #invalidAddons do - AIO_sv_Addons[invalidAddons[i]] = nil - end - - AIO_INITED = true - print("Initialized AIO version "..AIO_VERSION..". Type '/aio help' for commands") - end - - -- Forces reload of UI for user on next action - function AIO_HANDLERS.ForceReload(player) - local frame = CreateFrame("BUTTON") - frame:SetToplevel(true) - frame:SetFrameStrata("TOOLTIP") - frame:SetFrameLevel(100) - frame:SetAllPoints(WorldFrame) - -- frame.texture = frame:CreateTexture() - -- frame.texture:SetAllPoints(frame) - -- frame.texture:SetTexture(0.1, 0.1, 0.1, 0.5) - frame:SetScript("OnClick", ReloadUI) - print("AIO: Force reloading UI") - message("AIO: Force reloading UI") - end - - -- Forces reset of UI for user on next action - function AIO_HANDLERS.ForceReset(player) - AIO_RESET() - AIO_HANDLERS.ForceReload(player) - end - - local frame = CreateFrame("FRAME") -- Need a frame to respond to events - frame:RegisterEvent("ADDON_LOADED") -- Fired when saved variables are loaded - frame:RegisterEvent("PLAYER_LOGOUT") -- Fired when about to log out - - -- message to request initialization of UI - function frame:OnEvent(event, addon) - if event == "ADDON_LOADED" and addon == "AIO_Client" then - -- Register addon channel on cata+ - local _,_,_, tocversion = GetBuildInfo() - if tocversion and tocversion >= 40100 and RegisterAddonMessagePrefix then - RegisterAddonMessagePrefix("C"..AIO_Prefix) - end - - -- Our saved variables are ready at this point. If there is no save, they will be nil - -- Must be before any other addon action like sending init request - if type(AIO_sv) ~= 'table' then - AIO_sv = {} -- This is the first time this addon is loaded; initialize the var - end - if type(AIO_sv_char) ~= 'table' then - AIO_sv_char = {} -- This is the first time this addon is loaded; initialize the var - end - if type(AIO_sv_Addons) ~= 'table' then - AIO_sv_Addons = {} -- This is the first time this addon is loaded; initialize the var - end - - -- Restore addon saved variables to global namespace - -- Must be before sending init request - for k,v in pairs(AIO_sv) do - if _G[k] then - AIO_debug("Overwriting global var _G["..k.."] with a saved var") - end - _G[k] = v - end - for k,v in pairs(AIO_sv_char) do - if _G[k] then - AIO_debug("Overwriting global var _G["..k.."] with a saved character var") - end - _G[k] = v - end - - -- Request initialization of UI if not done yet - -- works by timer for every second. Timer shut down after inited. - -- initmsg consists of the version and all known crc codes for cached addons. - local rem = {} - local addons = {} - for name, data in pairs(AIO_sv_Addons) do - if type(name) ~= 'string' or type(data) ~= 'table' or type(data.crc) ~= 'number' or type(data.code) ~= 'string' then - table.insert(rem, name) - else - addons[name] = data.crc - end - end - for _,name in ipairs(rem) do - AIO_sv_Addons[name] = nil -- remove invalid addons - end - - local initmsg = AIO.Msg():Add("AIO", "Init", AIO_VERSION, addons) - - local reset = 1 - local timer = reset - local function ONUPDATE(self, diff) - if AIO_INITED then - self:SetScript("OnUpdate", nil) - initmsg = nil - reset = nil - timer = nil - return - end - if timer < diff then - initmsg:Send() - timer = reset - reset = reset * 1.5 - else - timer = timer - diff - end - end - frame:SetScript("OnUpdate", ONUPDATE) - -- initmsg:Send() - elseif event == "PLAYER_LOGOUT" then - -- On logout we must store all global namespace to saved vars - AIO_sv = {} -- discard vars that no longer exist - for key,_ in pairs(AIO_SAVEDVARS or {}) do - AIO_sv[key] = _G[key] - end - AIO_sv_char = {} -- discard vars that no longer exist - for key,_ in pairs(AIO_SAVEDVARSCHAR or {}) do - AIO_sv_char[key] = _G[key] - end - - for k,v in ipairs(AIO_SAVEDFRAMES or {}) do - LibWindow.SavePosition(v) - end - end - end - frame:SetScript("OnEvent", frame.OnEvent) + require("aio_client_ui").install(aio_side_ctx) + AIO_RESET = aio_side_ctx.AIO_RESET end if AIO_MAIN_LUA_STATE then @@ -1236,7 +628,7 @@ if AIO_MAIN_LUA_STATE then end if AIO_SERVER then - local function OnCommand(event, player, msg) + local function OnCommand(_, player, msg) msg = msg:lower() if ssub(msg, 1, 3) ~= 'aio' then return @@ -1258,16 +650,16 @@ if AIO_MAIN_LUA_STATE then else SLASH_AIO1 = "/aio" function SlashCmdList.AIO(msg) - local msg = msg:lower() - if msg and msg ~= "" then + local cmd = msg:lower() + if cmd and cmd ~= "" then for k,v in pairs(cmds) do - if k:find(msg, 1, true) == 1 then + if k:find(cmd, 1, true) == 1 then v() return end end end - print("Unknown command /aio "..tostring(msg)) + print("Unknown command /aio "..tostring(cmd)) cmds.help() end end diff --git a/AIO_Client/AIO_Client.toc b/AIO_Client/AIO_Client.toc index 28ae884..0cc4c2e 100644 --- a/AIO_Client/AIO_Client.toc +++ b/AIO_Client/AIO_Client.toc @@ -11,6 +11,12 @@ Dep_LibWindow-1.1\LibWindow-1.1\LibWindow-1.1.lua Dep_Smallfolk\smallfolk.lua lualzw-zeros\lualzw.lua queue.lua +aio_util.lua +aio_framing.lua +aio_reassembler.lua +aio_rpc.lua +aio_core.lua +aio_client_ui.lua #core AIO.lua diff --git a/AIO_Client/Dep_LibWindow-1.1/LibWindow-1.1.toc b/AIO_Client/Dep_LibWindow-1.1/LibWindow-1.1.toc index 780baa7..854c603 100644 --- a/AIO_Client/Dep_LibWindow-1.1/LibWindow-1.1.toc +++ b/AIO_Client/Dep_LibWindow-1.1/LibWindow-1.1.toc @@ -1,4 +1,4 @@ -## Interface: 60000 +## Interface: 60000 ## LoadOnDemand: 1 ## Title: Lib: Window-1.1 ## Version: 1.1.12 diff --git a/AIO_Client/Dep_LibWindow-1.1/LibWindow-1.1/LibWindow-1.1.lua b/AIO_Client/Dep_LibWindow-1.1/LibWindow-1.1/LibWindow-1.1.lua index 169c94e..c6c42ae 100644 --- a/AIO_Client/Dep_LibWindow-1.1/LibWindow-1.1/LibWindow-1.1.lua +++ b/AIO_Client/Dep_LibWindow-1.1/LibWindow-1.1/LibWindow-1.1.lua @@ -1,4 +1,4 @@ ---[[ +--[[ Name: LibWindow-1.1 Revision: $Rev: 8 $ Author(s): Mikk (dpsgnome@mail.com) diff --git a/AIO_Client/aio_client_ui.lua b/AIO_Client/aio_client_ui.lua new file mode 100644 index 0000000..1194b40 --- /dev/null +++ b/AIO_Client/aio_client_ui.lua @@ -0,0 +1,253 @@ +-- luacheck: ignore +--[[ + Client-only: addon cache, SavedVariables, init/reload UI, incoming addon messages. +]] + +local M = {} + +function M.install(ctx) + local AIO = ctx.AIO + local AIO_HANDLERS = ctx.AIO_HANDLERS + local ssub = ctx.ssub + local AIO_pcall = ctx.AIO_pcall + local AIO_debug = ctx.AIO_debug + + function ctx.AIO_RESET() + ctx.AIO_client_state.AIO_PENDING_RESET = true + ctx.AIO_client_state.AIO_VERSION_MISMATCH = false + if ctx.AIO_SAVEDVARS then + for key in pairs(ctx.AIO_SAVEDVARS) do + _G[key] = nil + end + end + if ctx.AIO_SAVEDVARSCHAR then + for key in pairs(ctx.AIO_SAVEDVARSCHAR) do + _G[key] = nil + end + end + AIO_sv_Addons = nil + ctx.AIO_SAVEDFRAMES = {} + end + + function AIO.AddSavedVar(key) + assert(key ~= nil, "#1 table key expected") + ctx.AIO_SAVEDVARS[key] = true + end + + function AIO.AddSavedVarChar(key) + assert(key ~= nil, "#1 table key expected") + ctx.AIO_SAVEDVARSCHAR[key] = true + end + + AIO_FRAMEPOSITIONS = AIO_FRAMEPOSITIONS or {} + AIO.AddSavedVar("AIO_FRAMEPOSITIONS") + AIO_FRAMEPOSITIONSCHAR = AIO_FRAMEPOSITIONSCHAR or {} + AIO.AddSavedVarChar("AIO_FRAMEPOSITIONSCHAR") + + function AIO.SavePosition(frame, char) + assert(frame:GetName(), "Called AIO.SavePosition on a nameless frame") + local store = char and AIO_FRAMEPOSITIONSCHAR or AIO_FRAMEPOSITIONS + if not store[frame:GetName()] then + store[frame:GetName()] = {} + end + ctx.LibWindow.RegisterConfig(frame, store[frame:GetName()]) + ctx.LibWindow.RestorePosition(frame) + ctx.LibWindow.SavePosition(frame) + table.insert(ctx.AIO_SAVEDFRAMES, frame) + end + + local function ONADDONMSG(_, event, prefix, msg, _, sender) + if prefix == ctx.AIO_ServerPrefix then + if event == "CHAT_MSG_ADDON" and sender == UnitName("player") then + ctx.AIO_HandleIncomingMsg(msg, sender) + end + end + end + local MsgReceiver = CreateFrame("Frame") + MsgReceiver:RegisterEvent("CHAT_MSG_ADDON") + MsgReceiver:SetScript("OnEvent", ONADDONMSG) + + local function RunAddon(name) + local code = AIO_sv_Addons[name] and AIO_sv_Addons[name].code + assert(code, "Addon doesnt exist") + local compression, compressedcode = ssub(code, 1, 1), ssub(code, 2) + if compression == ctx.AIO_Compressed then + compressedcode = assert(ctx.lualzw.decompress(compressedcode)) + end + assert(loadstring(compressedcode, name))() + end + + function AIO_HANDLERS.Init(_, version, N, addons, cached) + if ctx.AIO_VERSION ~= version then + if not ctx.AIO_client_state.AIO_VERSION_MISMATCH then + ctx.AIO_client_state.AIO_VERSION_MISMATCH = true + print("You have AIO version " .. ctx.AIO_VERSION .. " and the server uses " .. (version or "nil") .. ". Get the same version") + end + return + end + ctx.AIO_client_state.AIO_VERSION_MISMATCH = false + + assert(type(N) == "number") + assert(type(addons) == "table") + assert(type(cached) == "table") + + local validAddons = {} + for i = 1, N do + local name + if addons[i] then + name = addons[i].name + AIO_sv_Addons[name] = addons[i] + validAddons[name] = true + elseif cached[i] then + name = cached[i] + validAddons[name] = true + else + error("Unexpected behavior, try /aio reset") + end + AIO_pcall(RunAddon, name) + end + + local invalidAddons = {} + for name in pairs(AIO_sv_Addons) do + if not validAddons[name] then + invalidAddons[#invalidAddons + 1] = name + end + end + for i = 1, #invalidAddons do + AIO_sv_Addons[invalidAddons[i]] = nil + end + + ctx.AIO_client_state.AIO_INITED = true + print("Initialized AIO version " .. ctx.AIO_VERSION .. ". Type '/aio help' for commands") + end + + function AIO_HANDLERS.ForceReload(_) + local frame = CreateFrame("BUTTON") + frame:SetToplevel(true) + frame:SetFrameStrata("TOOLTIP") + frame:SetFrameLevel(100) + frame:SetAllPoints(WorldFrame) + frame:SetScript("OnClick", ReloadUI) + print("AIO: Force reloading UI") + message("AIO: Force reloading UI") + end + + function AIO_HANDLERS.ForceReset(_) + ctx.AIO_RESET() + AIO_HANDLERS.ForceReload() + end + + local frame = CreateFrame("FRAME") + frame:RegisterEvent("ADDON_LOADED") + frame:RegisterEvent("PLAYER_LOGOUT") + + function frame:OnEvent(event, addon) + if event == "ADDON_LOADED" and addon == "AIO_Client" then + local _, _, _, tocversion = GetBuildInfo() + if tocversion and tocversion >= 40100 and RegisterAddonMessagePrefix then + RegisterAddonMessagePrefix("C" .. ctx.AIO_Prefix) + end + + if type(AIO_sv) ~= "table" then + AIO_sv = {} + end + if type(AIO_sv_char) ~= "table" then + AIO_sv_char = {} + end + if type(AIO_sv_Addons) ~= "table" then + AIO_sv_Addons = {} + end + + if ctx.AIO_client_state.AIO_PENDING_RESET then + AIO_sv = {} + AIO_sv_char = {} + AIO_sv_Addons = {} + ctx.AIO_client_state.AIO_PENDING_RESET = false + if ctx.AIO_SAVEDVARS then + for key in pairs(ctx.AIO_SAVEDVARS) do + _G[key] = nil + end + end + if ctx.AIO_SAVEDVARSCHAR then + for key in pairs(ctx.AIO_SAVEDVARSCHAR) do + _G[key] = nil + end + end + else + for k, v in pairs(AIO_sv) do + if _G[k] then + AIO_debug("Overwriting global var _G[" .. k .. "] with a saved var") + end + _G[k] = v + end + for k, v in pairs(AIO_sv_char) do + if _G[k] then + AIO_debug("Overwriting global var _G[" .. k .. "] with a saved character var") + end + _G[k] = v + end + end + + local rem = {} + local addons = {} + for name, data in pairs(AIO_sv_Addons) do + if type(name) ~= "string" or type(data) ~= "table" or type(data.crc) ~= "number" or type(data.code) ~= "string" then + table.insert(rem, name) + else + addons[name] = data.crc + end + end + for _, name in ipairs(rem) do + AIO_sv_Addons[name] = nil + end + + local initmsg = ctx.AIO_Msg():Add("AIO", "Init", ctx.AIO_VERSION, addons) + local initInterval = 1 + local initElapsed = 0 + local function ONUPDATE(self, diff) + if ctx.AIO_client_state.AIO_INITED then + self:SetScript("OnUpdate", nil) + return + end + initElapsed = initElapsed + diff + if initElapsed >= initInterval then + initmsg:Send() + initElapsed = 0 + initInterval = initInterval * 1.5 + end + end + frame:SetScript("OnUpdate", ONUPDATE) + elseif event == "PLAYER_LOGOUT" then + AIO_sv = {} + for key in pairs(ctx.AIO_SAVEDVARS or {}) do + AIO_sv[key] = _G[key] + end + AIO_sv_char = {} + for key in pairs(ctx.AIO_SAVEDVARSCHAR or {}) do + AIO_sv_char[key] = _G[key] + end + for _, v in ipairs(ctx.AIO_SAVEDFRAMES or {}) do + ctx.LibWindow.SavePosition(v) + end + end + end + frame:SetScript("OnEvent", frame.OnEvent) + + M.install_reassembler_timer(ctx) +end + +function M.install_reassembler_timer(ctx) + local frame = CreateFrame("Frame") + local timer = ctx.AIO_MSG_CACHE_DELAY + local function ONUPDATE(_, diff) + if timer > diff then + timer = timer - diff + else + ctx.reassembler_sweep() + timer = ctx.AIO_MSG_CACHE_DELAY + end + end + frame:SetScript("OnUpdate", ONUPDATE) +end + +return M diff --git a/AIO_Client/aio_core.lua b/AIO_Client/aio_core.lua new file mode 100644 index 0000000..5a41963 --- /dev/null +++ b/AIO_Client/aio_core.lua @@ -0,0 +1,80 @@ +--[[ + Shared helpers for AIO.lua: vararg counting, pcall wrapper, block dispatch rules. +]] + +local M = {} + +function M.extract_n(...) + return select("#", ...), ... +end + +function M.make_pcall(opts) + local extract_n = M.extract_n + local unpack_fn = opts.unpack + local pcall_fn = opts.pcall + local xpcall_fn = opts.xpcall + + return function(f, ...) + assert(type(f) == "function") + if not opts.enable_pcall then + return f(...) + end + local data + if opts.server and opts.enable_traceback and opts.debug_traceback then + data = { extract_n(xpcall_fn(f, opts.debug_traceback, ...)) } + else + data = { extract_n(pcall_fn(f, ...)) } + end + if not data[2] then + opts.on_error(data[3]) + return + end + return unpack_fn(data, 3, data[1] + 1) + end +end + +-- Returns handle_block(player, data, skipstored) and a table holding queued pre-init blocks. +function M.make_handle_block(opts) + local preinitblocks = {} + local unpack_fn = opts.unpack + local client_state = opts.client_state + local block_handles = opts.block_handlers + local debug_fn = opts.debug + + local function handle_block(player, data, skipstored) + local handle_name = data[2] + assert(handle_name, "Invalid handle, no handle name") + + if client_state and client_state.AIO_VERSION_MISMATCH and not (handle_name == "AIO" and data[3] == "Init") then + return + end + + if client_state and not client_state.AIO_INITED and (handle_name ~= "AIO" or data[3] ~= "Init") then + preinitblocks[#preinitblocks + 1] = data + debug_fn("Received block before Init:", handle_name, data[1], data[3]) + return + end + + local handledata = block_handles[handle_name] + if not handledata then + error("Unknown AIO block handle: '" .. tostring(handle_name) .. "'") + end + + if opts.server and data[1] > opts.max_block_args then + error("Received AIO block with over " .. opts.max_block_args .. " arguments. Try using tables instead") + end + handledata(player, unpack_fn(data, 3, data[1] + 2)) + + if not skipstored and client_state and client_state.AIO_INITED + and handle_name == "AIO" and data[3] == "Init" then + for i = 1, #preinitblocks do + handle_block(player, preinitblocks[i], true) + preinitblocks[i] = nil + end + end + end + + return handle_block +end + +return M diff --git a/AIO_Client/aio_framing.lua b/AIO_Client/aio_framing.lua new file mode 100644 index 0000000..04dac9e --- /dev/null +++ b/AIO_Client/aio_framing.lua @@ -0,0 +1,92 @@ +--[[ + WoW addon-message framing: uint16 packing without NUL and split/rejoin for size limits. +]] + +local floor = math.floor +local ssub = string.sub +local sbyte = string.byte +local schar = string.char +local ceil = math.ceil + +local M = {} + +-- Short-message tag (2 bytes); must not collide with uint16 message IDs used for long messages. +M.SHORT_TAG = schar(1) .. schar(1) + +function M.uint16_encode(uint16) + assert(uint16 <= 2^16 - 767, "Too high value") + assert(uint16 >= 0, "Negative value") + local high = floor(uint16 / 254) + local l = high + 1 + local r = uint16 - high * 254 + 1 + return schar(l) .. schar(r) +end + +function M.uint16_decode(str) + local l = sbyte(ssub(str, 1, 1)) - 1 + local r = sbyte(ssub(str, 2, 2)) - 1 + local val = l * 254 + r + assert(val <= 2^16 - 767, "Too high value") + assert(val >= 0, "Negative value") + return val +end + +-- max_payload: max bytes per wire packet body (after channel prefix accounting). +function M.new(max_payload) + local short_tag = M.SHORT_TAG + local uint16_encode = M.uint16_encode + local uint16_decode = M.uint16_decode + + local framing = { + max_payload = max_payload, + } + + function framing.split(_, payload, message_id) + if #payload <= max_payload then + return { short_tag .. payload } + end + + local msglen = max_payload - 4 + local part_count = ceil(#payload / msglen) + local header = uint16_encode(message_id) .. uint16_encode(part_count) + local packets = {} + + for i = 1, part_count do + packets[i] = header .. uint16_encode(i) .. ssub(payload, ((i - 1) * msglen) + 1, i * msglen) + end + + return packets + end + + -- Returns complete payload string, or nil if packet is too short / incomplete chunk. + -- On incomplete long message, returns nil, chunk_table (for tests); production uses reassembler. + function framing.parse(_, packet) + local msgid = ssub(packet, 1, 2) + + if msgid == short_tag then + return ssub(packet, 3) + end + + if #packet < 6 then + return nil + end + + local message_id = uint16_decode(msgid) + local parts = uint16_decode(ssub(packet, 3, 4)) + local part_id = uint16_decode(ssub(packet, 5, 6)) + if part_id <= 0 or part_id > parts then + error("received long message with invalid amount of parts. id, parts: " .. part_id .. " " .. parts) + end + + return nil, { + message_id = message_id, + parts = parts, + part_id = part_id, + payload = ssub(packet, 7), + } + end + + return framing +end + +return M diff --git a/AIO_Client/aio_reassembler.lua b/AIO_Client/aio_reassembler.lua new file mode 100644 index 0000000..582f465 --- /dev/null +++ b/AIO_Client/aio_reassembler.lua @@ -0,0 +1,192 @@ +--[[ + Stateful reassembly of framed long messages with TTL and optional per-peer byte caps. +]] + +local tconcat = table.concat + +local M = {} + +function M.new(opts) + assert(opts["framing"], "framing required") + assert(opts["NewQueue"], "NewQueue required") + assert(opts["get_time"], "get_time required") + assert(opts["get_time_diff"], "get_time_diff required") + assert(opts["get_message_stored_size"], "get_message_stored_size required") + assert(opts["cache_time_ms"], "cache_time_ms required") + + local framing = opts["framing"] + local create_queue = opts["NewQueue"] + local get_time = opts["get_time"] + local get_time_diff = opts["get_time_diff"] + local get_message_stored_size = opts["get_message_stored_size"] + local cache_space = opts["cache_space"] + local cache_time_ms = opts["cache_time_ms"] + local msg_id_min = opts["msg_id_min"] or 1 + local msg_id_max = opts["msg_id_max"] or 2^16 - 767 + + local plrdata = {} + local removeque = create_queue() + + local function remove_data(peer_id, msgid) + local pdata = plrdata[peer_id] + if not pdata then + return + end + if msgid then + local data = pdata[msgid] + if data then + pdata.stored = pdata.stored - get_message_stored_size(data) + if pdata.stored < 0 then + pdata.stored = 0 + end + pdata[msgid] = nil + pdata.ramque:gettable()[data.ramquepos] = nil + removeque:gettable()[data.remquepos] = nil + end + else + local que = pdata.ramque:gettable() + local l, r = pdata.ramque:getrange() + for i = l, r do + if que[i] then + removeque:gettable()[que[i].remquepos] = nil + end + end + plrdata[peer_id] = nil + end + end + + local function ensure_peer(peer_id) + if not plrdata[peer_id] then + plrdata[peer_id] = { + stored = 0, + ramque = create_queue(), + MSG_GUID = msg_id_min, + } + end + return plrdata[peer_id] + end + + local function bump_send_id(pdata) + if pdata.MSG_GUID >= msg_id_max then + pdata.MSG_GUID = msg_id_min + else + pdata.MSG_GUID = pdata.MSG_GUID + 1 + end + end + + local function all_parts_present(parts) + for i = 1, parts.n do + if not parts[i] then + return false + end + end + return true + end + + local reassembler = {} + + function reassembler.split_payload(_, peer_id, payload) + local pdata = ensure_peer(peer_id) + local message_id = pdata.MSG_GUID + local packets = framing:split(payload, message_id) + bump_send_id(pdata) + return packets + end + + function reassembler.ingest(_, peer_id, packet) + local complete, chunk = framing:parse(packet) + if complete then + return complete + end + if not chunk then + return nil + end + + local message_id = chunk.message_id + local parts = chunk.parts + local part_id = chunk.part_id + local msg = chunk.payload + + local pdata = ensure_peer(peer_id) + pdata[message_id] = pdata[message_id] or {} + local data = pdata[message_id] + + if not data.parts or data.parts.n ~= parts then + if data.parts then + for i = 0, data.parts.n do + data.parts[i] = nil + end + end + data.peer_id = peer_id + data.parts = { n = parts } + data.id = message_id + data.stamp = get_time() + data.remquepos = removeque:pushright(data) + data.ramquepos = pdata.ramque:pushright(data) + end + + data.parts[part_id] = msg + + pdata.stored = pdata.stored + #msg + if cache_space and pdata.stored > cache_space then + local l, r = pdata.ramque:getrange() + for _ = l, r - 1 do + local msgdata = pdata.ramque:popleft() + if msgdata then + removeque:gettable()[msgdata.remquepos] = nil + pdata[msgdata.id] = nil + for j = 1, msgdata.parts.n do + if msgdata.parts[j] then + pdata.stored = pdata.stored - #msgdata.parts[j] + end + end + if pdata.stored <= cache_space then + break + end + end + end + if pdata.stored > cache_space then + remove_data(peer_id) + error("AIO_MSG_CACHE_SPACE is too small for received message") + end + end + + if all_parts_present(data.parts) then + local cat = tconcat(data.parts) + remove_data(peer_id, message_id) + return cat + end + + return nil + end + + function reassembler.remove_peer(_, peer_id) + remove_data(peer_id) + end + + function reassembler.remove_message(_, peer_id, message_id) + remove_data(peer_id, message_id) + end + + function reassembler.sweep_expired(_) + if removeque:empty() then + return + end + local now = get_time() + local l, r = removeque:getrange() + for _ = l, r do + local v = removeque:popleft() + if v then + if get_time_diff(now, v.stamp) < cache_time_ms then + removeque:pushleft(v) + break + end + remove_data(v.peer_id, v.id) + end + end + end + + return reassembler +end + +return M diff --git a/AIO_Client/aio_rpc.lua b/AIO_Client/aio_rpc.lua new file mode 100644 index 0000000..05a1a1e --- /dev/null +++ b/AIO_Client/aio_rpc.lua @@ -0,0 +1,149 @@ +--[[ + Block-oriented RPC over a serialized message blob (Smallfolk by default). +]] + +local M = {} + +function M.new(opts) + assert(opts["dumps"], "dumps required") + assert(opts["loads"], "loads required") + assert(opts["pcall"], "pcall required") + assert(opts["get_handlers"], "get_handlers required") + + local dumps = opts["dumps"] + local loads = opts["loads"] + local pcall_fn = opts["pcall"] + local get_handlers = opts["get_handlers"] + local debug_fn = opts["debug"] or function() end + local enable_msgprint = opts["enable_msgprint"] + local server = opts["server"] + local max_block_args = opts["max_block_args"] or 15 + local timeout_hook = opts["timeout_hook"] + + local send_fn + local msgmt = {} + function msgmt.__index(_, key) + return msgmt[key] + end + + function msgmt:Add(name, ...) + assert(name, "#1 Block must have name") + self.params[#self.params + 1] = { select("#", ...), name, ... } + self.assemble = true + return self + end + + function msgmt:Append(msg2) + assert(type(msg2) == "table", "#1 table expected") + for i = 1, #msg2.params do + assert(type(msg2.params[i]) == "table", "#1[" .. i .. "] table expected") + self.params[#self.params + 1] = msg2.params[i] + end + self.assemble = true + return self + end + + function msgmt:Assemble() + if not self.assemble then + return self + end + self.MSG = dumps(self.params) + self.assemble = false + return self + end + + function msgmt:Send(player, ...) + assert(not server or player, "#1 player is nil") + assert(send_fn, "send not bound") + send_fn(self:ToString(), player, ...) + return self + end + + function msgmt:Clear() + for i = 1, #self.params do + self.params[i] = nil + end + self.MSG = nil + self.assemble = false + return self + end + + function msgmt:ToString() + return self:Assemble().MSG + end + + function msgmt:HasMsg() + return #self.params > 0 + end + + local function handle_block(player, data) + local handle_name = data[2] + assert(handle_name, "Invalid handle, no handle name") + + local handledata = get_handlers()[handle_name] + if not handledata then + error("Unknown AIO block handle: '" .. tostring(handle_name) .. "'") + end + + if server and data[1] > max_block_args then + error("Received AIO block with over " .. max_block_args .. " arguments. Try using tables instead") + end + handledata(player, unpack(data, 3, data[1] + 2)) + end + + local function parse_blocks(msg, player, on_block) + local hooked = timeout_hook + if hooked then + hooked.begin(msg) + end + + local ok, err = pcall(function() + debug_fn("Received messagelength:", #msg) + if enable_msgprint then + print("received:", msg) + end + + local data = pcall_fn(loads, msg, #msg) + if not data or type(data) ~= "table" then + debug_fn("Received invalid message - data not a table") + return + end + + for i = 1, #data do + local block = data[i] + if on_block then + on_block(player, block) + else + pcall_fn(handle_block, player, block) + end + end + end) + + if hooked then + hooked.on_end() + end + + if not ok then + error(err) + end + end + + local rpc = {} + + function rpc.Msg() + local msg = { params = {}, MSG = nil, assemble = false } + setmetatable(msg, msgmt) + return msg + end + + function rpc.bind_send(fn) + send_fn = fn + end + + rpc.handle_block = handle_block + rpc.parse_blocks = parse_blocks + + return rpc +end + +return M diff --git a/AIO_Client/aio_util.lua b/AIO_Client/aio_util.lua new file mode 100644 index 0000000..a93aa8b --- /dev/null +++ b/AIO_Client/aio_util.lua @@ -0,0 +1,26 @@ +local M = {} + +function M.basename(path) + assert(type(path) == "string", "#1 string expected") + return string.match(path, "([^/\\]*)$") or path +end + +function M.getMessageStoredSize(data) + if not data or not data.parts then + return 0 + end + local stored = 0 + for i = 1, data.parts.n do + local part = data.parts[i] + if part then + stored = stored + #part + end + end + return stored +end + +function M.isMessageExpired(stamp, now, cacheTimeMs) + return (now - stamp) >= cacheTimeMs +end + +return M diff --git a/AIO_Client/lualzw-zeros/LICENSE b/AIO_Client/lualzw-zeros/LICENSE index a8fc851..437e0dd 100644 --- a/AIO_Client/lualzw-zeros/LICENSE +++ b/AIO_Client/lualzw-zeros/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2016 +Copyright (c) 2016 Rochet2 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/AIO_Client/lualzw-zeros/README.md b/AIO_Client/lualzw-zeros/README.md index 755dc66..3ff4163 100644 --- a/AIO_Client/lualzw-zeros/README.md +++ b/AIO_Client/lualzw-zeros/README.md @@ -1,72 +1,7 @@ -# lualzw -A relatively fast LZW compression algorithm in pure lua +# lualzw (vendored) -# encoding and decoding -Lossless compression for any text. The more repetition in the text, the better. +Vendored from [Rochet2/lualzw](https://github.com/Rochet2/lualzw) **v1.1.0** (`6cbf8ab`, 2026-05-31). -16 bit encoding is used. So each 8 bit character is encoded as 16 bit. -This means that the dictionary size is 65280. +AIO loads this folder as `require("lualzw")` and uses the null-safe codec (`skip = { [0] = true }`), which matches the historical `zeros` branch wire format used by this project. -Any special characters like `äöå` that are represented with multiple characters are supported. The special characters are split up into single characters that are then encoded and decoded. - -While compressing, the algorithm checks if the result size gets over the input. If it does, then the input is not compressed and the algorithm returns the input prematurely as the compressed result. - -The `zeros` branch contains a version that does not add additional null `\0` characters to the input when encoding. Any existing null characters in input string are preserved as nulls however so make sure your input does not contain nulls. - -# usage -```lua -local lualzw = require("lualzw") - -local input = "foofoofoofoofoofoofoofoofoo" -local compressed = assert(lualzw.compress(input)) -local decompressed = assert(lualzw.decompress(compressed)) -assert(input == decompressed) -``` - -# errors -Returns nil and an error message when the algorithm fails to compress or decompress. - -# speed -Times are in seconds. -Both have the same generated input. -The values are an average of 10 tries. - -Note that compressing random generated inputs results usually in bigger result than original. In these cases the algorithms do not compress and return input instead and thus compression result is 100% of input. - -lualzw is at an advantage in cases where compression cannot be done as it stops prematurely and LibCompress does not. -Also lualzw is at an advantage in cases where compression can be done as it has a larger dictionary in use. - -Input: 1000000 random generated bytes converted into string - -algorithm|compress|decompress|result % of input ----------|--------|----------|------------- -lualzw|0.6622|0.0003|100 -LibCompress|2.1983|0.0024|100 - -Input: 1000000 random generated bytes in ASCII range converted into string - -algorithm|compress|decompress|result % of input ----------|--------|----------|------------- -lualzw|0.812|0.0022|100 -LibCompress|1.782|0.0007|100 - -Input: 1000000 random generated repeating bytes converted into string - -algorithm|compress|decompress|result % of input ----------|--------|----------|------------- -lualzw|0.3975|0.0262|4.5001 -LibCompress|0.3907|0.0264|6.6997 - -Input: 1000000 of same character - -algorithm|compress|decompress|result % of input ----------|--------|----------|------------- -lualzw|0.7045|0.0026|0.2829 -LibCompress|0.6418|0.0038|0.4241 - -Input: "ymn32h8hm8ekrwjkrn9f" repeated 50000 times. In total 1000000 bytes - -algorithm|compress|decompress|result % of input ----------|--------|----------|------------- -lualzw|0.4788|0.0088|1.2629 -LibCompress|0.4426|0.0093|1.8905 +Upstream docs, API, and benchmarks: https://github.com/Rochet2/lualzw diff --git a/AIO_Client/lualzw-zeros/lualzw.lua b/AIO_Client/lualzw-zeros/lualzw.lua index 62a5b32..6783064 100644 --- a/AIO_Client/lualzw-zeros/lualzw.lua +++ b/AIO_Client/lualzw-zeros/lualzw.lua @@ -22,145 +22,308 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] +local VERSION = "1.1.0" + local char = string.char +local byte = string.byte local type = type -local select = select local sub = string.sub local tconcat = table.concat -local basedictcompress = {} -local basedictdecompress = {} -for i = 0, 255 do - local ic, iic = char(i), char(i, 1) - basedictcompress[ic] = iic - basedictdecompress[iic] = ic +local function normalizeSkip(skip) + local skippedcharacters = {} + if skip then + for k, v in pairs(skip) do + if v == true then + if type(k) == "number" and k >= 0 and k <= 255 then + skippedcharacters[k] = true + end + elseif type(v) == "number" and v >= 0 and v <= 255 then + skippedcharacters[v] = true + end + end + end + return skippedcharacters end -local function dictAddA(str, dict, a, b) - if a >= 256 then - a, b = 1, b+1 - if b >= 256 then - dict = {} - b = 2 - end +local function normalizeControl(value, name, default) + if value == nil then + return default + end + if type(value) ~= "string" or #value ~= 1 then + error("invalid " .. name .. " control character") end - dict[str] = char(a,b) - a = a+1 - return dict, a, b + return value end -local function compress(input) - if type(input) ~= "string" then - return nil, "string expected, got "..type(input) +local function validateLimit(name, value) + if value == nil then + return true end - local len = #input - if len <= 1 then - return "u"..input + if type(value) ~= "number" or value < 0 then + return nil, "number expected for " .. name .. ", got " .. type(value) end + return true +end - local dict = {} - local a, b = 1, 2 - - local result = {"c"} - local resultlen = 1 - local n = 2 - local word = "" - for i = 1, len do - local c = sub(input, i, i) - local wc = word..c - if not (basedictcompress[wc] or dict[wc]) then - local write = basedictcompress[word] or dict[word] - if not write then - return nil, "algorithm error, could not fetch word" - end - result[n] = write - resultlen = resultlen + #write - n = n+1 - if len <= resultlen then - return "u"..input +local function buildState(skippedcharacters) + local function findNextNotSkipped(i) + repeat + if not skippedcharacters[i] then + return i end - dict, a, b = dictAddA(wc, dict, a, b) - word = c - else - word = wc - end + i = i + 1 + until false end - result[n] = basedictcompress[word] or dict[word] - resultlen = resultlen+#result[n] - n = n+1 - if len <= resultlen then - return "u"..input + + local basedictcompress = {} + local basedictdecompress = {} + + local firstNotSkipped = findNextNotSkipped(0) + local secondNotSkipped = findNextNotSkipped(firstNotSkipped + 1) + if firstNotSkipped > 255 or secondNotSkipped > 255 or firstNotSkipped == secondNotSkipped then + return nil, "invalid configuration, no character can be used in compression" end - return tconcat(result) -end -local function dictAddB(str, dict, a, b) - if a >= 256 then - a, b = 1, b+1 - if b >= 256 then - dict = {} - b = 2 + for i = 0, 255 do + local ic, iic = char(i), char(i, firstNotSkipped) + basedictcompress[ic] = iic + basedictdecompress[iic] = ic + end + + local function dictBump(dict, a, b) + if a >= 256 then + a, b = firstNotSkipped, findNextNotSkipped(b + 1) + if b >= 256 then + dict = {} + b = secondNotSkipped + end end + local code = char(a, b) + a = findNextNotSkipped(a + 1) + return dict, a, b, code end - dict[char(a,b)] = str - a = a+1 - return dict, a, b + + return { + skippedcharacters = skippedcharacters, + basedictcompress = basedictcompress, + basedictdecompress = basedictdecompress, + firstNotSkipped = firstNotSkipped, + secondNotSkipped = secondNotSkipped, + dictBump = dictBump, + } end -local function decompress(input) - if type(input) ~= "string" then - return nil, "string expected, got "..type(input) +local function createCodec(options) + options = options or {} + + local uncompressedControl = normalizeControl(options.uncompressed, "uncompressed", "u") + local compressedControl = normalizeControl(options.compressed, "compressed", "c") + if uncompressedControl == compressedControl then + error("uncompressed and compressed control characters must differ") end - if #input < 1 then - return nil, "invalid input - not a compressed string" + local state, err = buildState(normalizeSkip(options.skip)) + if not state then + error(err) end - local control = sub(input, 1, 1) - if control == "u" then - return sub(input, 2) - elseif control ~= "c" then - return nil, "invalid input - not a compressed string" + local prefixCost = 1 + + local function unwrap(input) + if #input < 1 then + return nil, "invalid input - not a compressed string" + end + return state, char(byte(input, 1)), sub(input, 2) end - input = sub(input, 2) - local len = #input - if len < 2 then - return nil, "invalid input - not a compressed string" + local function compress(input, max_input_size) + if type(input) ~= "string" then + return nil, "string expected, got " .. type(input) + end + + local ok, limitErr = validateLimit("max_input_size", max_input_size) + if not ok then + return nil, limitErr + end + + local len = #input + if max_input_size and len > max_input_size then + return nil, "input exceeds limit" + end + + if len <= 1 then + return uncompressedControl .. input + end + + local basedictcompress = state.basedictcompress + local dictBump = state.dictBump + local dict = {} + local a, b = state.firstNotSkipped, state.secondNotSkipped + local dictCode + + local result = {} + local resultlen = 0 + local n = 1 + local word = "" + for i = 1, len do + local c = char(byte(input, i)) + local wc = word .. c + if not (basedictcompress[wc] or dict[wc]) then + local write = basedictcompress[word] or dict[word] + if not write then + return nil, "algorithm error, could not fetch word" + end + result[n] = write + resultlen = resultlen + 2 + n = n + 1 + if len <= resultlen + prefixCost then + return uncompressedControl .. input + end + dict, a, b, dictCode = dictBump(dict, a, b) + dict[wc] = dictCode + word = c + else + word = wc + end + end + + result[n] = basedictcompress[word] or dict[word] + resultlen = resultlen + 2 + if len <= resultlen + prefixCost then + return uncompressedControl .. input + end + + return compressedControl .. tconcat(result) end - local dict = {} - local a, b = 1, 2 - - local result = {} - local n = 1 - local last = sub(input, 1, 2) - result[n] = basedictdecompress[last] or dict[last] - n = n+1 - for i = 3, len, 2 do - local code = sub(input, i, i+1) - local lastStr = basedictdecompress[last] or dict[last] - if not lastStr then + local function decompress(input, max_output_size, max_input_size, max_codes) + if type(input) ~= "string" then + return nil, "string expected, got " .. type(input) + end + + local ok, limitErr = validateLimit("max_output_size", max_output_size) + if not ok then + return nil, limitErr + end + + ok, limitErr = validateLimit("max_input_size", max_input_size) + if not ok then + return nil, limitErr + end + + ok, limitErr = validateLimit("max_codes", max_codes) + if not ok then + return nil, limitErr + end + + if max_input_size and #input > max_input_size then + return nil, "compressed input exceeds limit" + end + + local decodeState, control, body = unwrap(input) + if not decodeState then + return nil, control + end + + if max_input_size and #body > max_input_size then + return nil, "compressed input exceeds limit" + end + + if control == uncompressedControl then + if max_output_size and #body > max_output_size then + return nil, "decompressed output exceeds limit" + end + return body + elseif control ~= compressedControl then + return nil, "invalid input - not a compressed string" + end + + local len = #body + if len < 2 or len % 2 == 1 then + return nil, "invalid input - not a compressed string" + end + + local basedictdecompress = decodeState.basedictdecompress + local dictBump = decodeState.dictBump + local dict = {} + local a, b = decodeState.firstNotSkipped, decodeState.secondNotSkipped + local dictCode + local codeCount = 0 + + local function bumpDictionary(value) + if max_codes and codeCount >= max_codes then + return nil, "decompression step limit exceeded" + end + dict, a, b, dictCode = dictBump(dict, a, b) + dict[dictCode] = value + codeCount = codeCount + 1 + return true + end + + local result = {} + local n = 1 + local outputlen = 0 + local last = sub(body, 1, 2) + local firstStr = basedictdecompress[last] or dict[last] + if not firstStr then return nil, "could not find last from dict. Invalid input?" end - local toAdd = basedictdecompress[code] or dict[code] - if toAdd then - result[n] = toAdd - n = n+1 - dict, a, b = dictAddB(lastStr..sub(toAdd, 1, 1), dict, a, b) - else - local tmp = lastStr..sub(lastStr, 1, 1) - result[n] = tmp - n = n+1 - dict, a, b = dictAddB(tmp, dict, a, b) - end - last = code + result[n] = firstStr + outputlen = outputlen + #firstStr + if max_output_size and outputlen > max_output_size then + return nil, "decompressed output exceeds limit" + end + n = n + 1 + + for i = 3, len, 2 do + local inputCode = sub(body, i, i + 1) + local lastStr = basedictdecompress[last] or dict[last] + if not lastStr then + return nil, "could not find last from dict. Invalid input?" + end + local toAdd = basedictdecompress[inputCode] or dict[inputCode] + if toAdd then + outputlen = outputlen + #toAdd + if max_output_size and outputlen > max_output_size then + return nil, "decompressed output exceeds limit" + end + result[n] = toAdd + n = n + 1 + local bumped, bumpErr = bumpDictionary(lastStr .. sub(toAdd, 1, 1)) + if not bumped then + return nil, bumpErr + end + else + local tmp = lastStr .. sub(lastStr, 1, 1) + outputlen = outputlen + #tmp + if max_output_size and outputlen > max_output_size then + return nil, "decompressed output exceeds limit" + end + result[n] = tmp + n = n + 1 + local bumped, bumpErr = bumpDictionary(tmp) + if not bumped then + return nil, bumpErr + end + end + last = inputCode + end + + return tconcat(result) end - return tconcat(result) + + return { + compress = compress, + decompress = decompress, + configure = createCodec, + _VERSION = VERSION, + uncompressed = uncompressedControl, + compressed = compressedControl, + } end -lualzw = { - compress = compress, - decompress = decompress, -} -return lualzw +-- AIO uses the former "zeros" branch wire format (no null bytes in dictionary codes). +local codec = createCodec({ skip = { [0] = true } }) +lualzw = codec +return codec diff --git a/AIO_Client/queue.lua b/AIO_Client/queue.lua index 3184165..40f7058 100644 --- a/AIO_Client/queue.lua +++ b/AIO_Client/queue.lua @@ -1,5 +1,5 @@ local Queue = {} -function Queue.__index(que, key) +function Queue.__index(_, key) return Queue[key] end @@ -58,9 +58,9 @@ function Queue.size(que) end function Queue.clear(que) - local l, r = self:getrange() + local l, r = que:getrange() for i = l, r do - que[idx] = nil + que[i] = nil end que.first, que.last = 0, -1 end diff --git a/AIO_Server/AIO.lua b/AIO_Server/AIO.lua index ba283b3..ff61e9e 100644 --- a/AIO_Server/AIO.lua +++ b/AIO_Server/AIO.lua @@ -51,7 +51,7 @@ end -- according to settings in AIO.lua. The addon is cached on client side and will -- be updated only when needed. 'name' is an unique name for the addon, usually -- you can use the file name or addon name there. Do note that short names are --- better since they are sent back and forth to indentify files. +-- better since they are sent back and forth to identify files. -- The function only exists on server side. Only on main lua state. -- You should call this function only on startup to ensure everyone gets the same -- addons and no addon is duplicate. @@ -80,7 +80,7 @@ handlertable = AIO.AddHandlers(name, handlertable) -- Only on main lua state. -- Adds a new callback function that is called if a message with the given --- name is recieved. All parameters the sender sends in the message will +-- name is received. All parameters the sender sends in the message will -- be passed to func when called. -- Example message: AIO.Msg():Add(name, ...):Send() -- AIO.AddHandlers uses AIO.RegisterEvent internally, so same name can not be used on both. @@ -210,11 +210,15 @@ local AIO_UI_INIT_DELAY = 5*1000 -- ms -- default 5*1000 local AIO_MSG_COMPRESS = true -- default true -- Setting to enable and disable obfuscation for code to reduce size --- Note that error messages will not have correct line numbers since obfuscation rearranage the code +-- Note that error messages will not have correct line numbers since obfuscation rearrange the code -- for debugging purposes it is recommended to disable this option -- Server side only local AIO_CODE_OBFUSCATE = true -- default true +-- Force all online players to reload UI when the AIO server script loads or reloads +-- Server side only +local AIO_FORCE_RELOAD_ON_STARTUP = true -- default true + -- Setting to send client errors to server -- Client must have AIO_ENABLE_PCALL enabled -- Client side only @@ -224,36 +228,38 @@ local AIO_ERROR_LOG = false -- default false ---------------------------------- -local assert = assert -local type = type -local tostring = tostring -local pairs = pairs -local ipairs = ipairs local ssub = string.sub -local match = string.match -local ceil = ceil or math.ceil -local floor = floor or math.floor -local sbyte = strbyte or string.byte local schar = string.char +local aio_core = require("aio_core") +local aio_framing = require("aio_framing") +local aio_reassembler_mod = require("aio_reassembler") +local aio_rpc_mod = require("aio_rpc") local tconcat = table.concat -local select = select -local pcall = pcall -local xpcall = xpcall -- Some lua compatibility between 5.1 and 5.2 -local loadstring = loadstring or load -- loadstring name varies with lua 5.1 and 5.2 -local unpack = unpack or table.unpack -- unpack place varies with lua 5.1 and 5.2 --- server client compatibility -local AIO_GetTime = os and os.time or function() return GetTime()*1000 end -local AIO_GetTimeDiff = os and os.difftime or function(_now, _then) return _now-_then end +local loadstring = loadstring or load -- luacheck: ignore 113 +local unpack = _G.unpack +if not unpack then + unpack = table.unpack -- luacheck: ignore 113 +end +-- server client compatibility (milliseconds on both sides) +local AIO_GetTime = os and function() return os.time() * 1000 end or function() return GetTime() * 1000 end +local AIO_GetTimeDiff = function(now, earlier) return now - earlier end -- boolean value to define whether we are on server or client side local AIO_SERVER = type(GetLuaEngine) == "function" + +local function AIO_IsPlayerObject(player) + if _G.AIO_TEST_ALLOW_TABLE_PLAYERS then + return type(player) == "userdata" or type(player) == "table" + end + return type(player) == "userdata" +end -- boolean value to define if we are on main lua state (eluna multistate support) local AIO_MAIN_LUA_STATE = not AIO_SERVER or not GetStateMapId or GetStateMapId() == -1 -- Client must have same version (basically same AIO file) -local AIO_VERSION = 1.75 +local AIO_VERSION = 1.76 -- ID characters for client-server messaging -local AIO_ShortMsg = schar(1)..schar(1) +local AIO_ShortMsg = aio_framing.SHORT_TAG local AIO_Compressed = 'C' local AIO_Uncompressed = 'U' local AIO_Prefix = "AIO" @@ -276,15 +282,19 @@ AIO = unpack = unpack, } -local AIO = AIO +local AIO = AIO -- luacheck: ignore 431 -- Client side table containing frames that need to have their position saved local AIO_SAVEDFRAMES = {} -- Client side tables that contain keys to _G table for saved variables -- you should add your variables here with AIO.AddSavedVar(key) or AIO.AddSavedVarChar(key) local AIO_SAVEDVARS = {} local AIO_SAVEDVARSCHAR = {} --- Client side flag for noting if the client has been inited or not -local AIO_INITED = false +-- Client-only mutable state (shared with aio_client_ui) +local AIO_client_state = not AIO_SERVER and { + AIO_INITED = false, + AIO_PENDING_RESET = false, + AIO_VERSION_MISMATCH = false, +} or nil -- Server and Client side functions to execute on AIO messages local AIO_HANDLERS = AIO_MAIN_LUA_STATE and {} or nil -- Server side functions to execute when an init msg is received @@ -296,11 +306,12 @@ local AIO_BLOCKHANDLES = AIO_MAIN_LUA_STATE and {} or nil local AIO_ADDONSORDER = AIO_MAIN_LUA_STATE and {} or nil -- Dependencies -local LibWindow -local LuaSrcDiet local NewQueue = NewQueue or require("queue") local Smallfolk = Smallfolk or require("smallfolk") local lualzw = lualzw or require("lualzw") +local aio_util = require("aio_util") +local LibWindow -- luacheck: ignore 231 +local LuaSrcDiet -- luacheck: ignore 231 if AIO_SERVER then if AIO_MAIN_LUA_STATE then LuaSrcDiet = require("LuaSrcDiet") @@ -324,39 +335,8 @@ function AIO.GetVersion() return AIO_VERSION end --- Converts an uint16 number to string (2 chars) --- Note that this escapes using \0 character so the full uint16 range is not usable -local function AIO_16tostring(uint16) - -- split 16bit to 2 8bit parts but without \0 - assert(uint16 <= 2^16-767, "Too high value") - assert(uint16 >= 0, "Negative value") - local high = floor(uint16 / 254) - local l = high +1 - local r = uint16 - high * 254 +1 - return schar(l)..schar(r) -end - --- Converts a string (2 chars) to uint16 number --- Note that the chars can not be \0 character so the full uint16 range is not usable -local function AIO_stringto16(str) - local l = sbyte(ssub(str, 1,1)) -1 - local r = sbyte(ssub(str, 2,2)) -1 - local val = l*254 + r - assert(val <= 2^16-767, "Too high value") - assert(val >= 0, "Negative value") - return val -end - --- Resets AIO saved variables on client side -local AIO_RESET -if not AIO_SERVER then - function AIO_RESET() - AIO_SAVEDVARS = nil - AIO_SAVEDVARSCHAR = nil - AIO_sv_Addons = nil - AIO_SAVEDFRAMES = {} - end -end +-- Client reset (set in client branch below) +local AIO_RESET -- luacheck: ignore 231 -- Used to print debug messages if AIO_ENABLE_DEBUG_MSGS is true function AIO_debug(...) @@ -365,117 +345,53 @@ function AIO_debug(...) end end --- returns the amount of varargs from passed varargs -local function AIO_extractN(...) - return select("#", ...), ... -end - --- Calls function f with parameters ... with pcall --- Shows errors with print or AIO_debug -local function AIO_pcall(f, ...) - assert(type(f) == 'function') - if not AIO_ENABLE_PCALL then - return f(...) - end - local data - if AIO_SERVER and AIO_ENABLE_TRACEBACK and debug.traceback then - data = {AIO_extractN(xpcall(f, debug.traceback, ...))} - else - data = {AIO_extractN(pcall(f, ...))} - end - if not data[2] then +local AIO_pcall = aio_core.make_pcall({ + unpack = unpack, + pcall = pcall, + xpcall = xpcall, + enable_pcall = AIO_ENABLE_PCALL, + server = AIO_SERVER, + enable_traceback = AIO_ENABLE_TRACEBACK, + debug_traceback = debug.traceback, + on_error = function(err) if AIO_SERVER then - AIO_debug(data[3]) + AIO_debug(err) else if AIO_ERROR_LOG then - AIO.Handle("AIO", "Error", data[3]) + AIO.Handle("AIO", "Error", err) end if AIO_ENABLE_TRACEBACK then - _ERRORMESSAGE(data[3]) + _ERRORMESSAGE(err) else - print(data[3]) + print(err) end end - return - end - return unpack(data, 3, data[1]+1) -end + end, +}) --- Reads a file at given absolute or relative to server root path --- and returns the full file contents as a string -local function AIO_ReadFile(path) - AIO_debug("Reading a file") - assert(type(path) == 'string', "#1 string expected") - local f = assert(io.open(path, "rb")) - local str = f:read("*all") - f:close() - return str -end +local framing_codec = aio_framing.new(AIO_MsgLen) +local reassembler = aio_reassembler_mod.new({ + framing = framing_codec, + NewQueue = NewQueue, + get_time = AIO_GetTime, + get_time_diff = AIO_GetTimeDiff, + get_message_stored_size = aio_util.getMessageStoredSize, + cache_space = AIO_SERVER and AIO_MSG_CACHE_SPACE or nil, + cache_time_ms = AIO_MSG_CACHE_TIME, + msg_id_min = MSG_MIN, + msg_id_max = MSG_MAX, +}) --- player data handler -local plrdata = {} -local removeque = NewQueue() -local function RemoveData(guid, msgid) - local pdata = plrdata[guid] - if pdata then - if msgid then - local data = pdata[msgid] - if data then - pdata[msgid] = nil - pdata.ramque:gettable()[data.ramquepos] = nil - removeque:gettable()[data.remquepos] = nil - end - else - local que = pdata.ramque:gettable() - local l, r = pdata.ramque:getrange() - for i = l, r do - if que[i] then - removeque:gettable()[que[i].remquepos] = nil - end - end - plrdata[guid] = nil - end - end -end local function ProcessRemoveQue() - if removeque:empty() then - return - end - local now = AIO_GetTime() - local l, r = removeque:getrange() - for i = l, r do - local v = removeque:popleft() - if v then - if AIO_GetTimeDiff(now, v.stamp) < AIO_MSG_CACHE_TIME then - AIO_debug("removing outdated incomplete message") - removeque:pushleft(v) - break - end - RemoveData(v.guid, v.id) - end - end + reassembler:sweep_expired() end -if AIO_SERVER then - if AIO_MAIN_LUA_STATE then - CreateLuaEvent(ProcessRemoveQue, AIO_MSG_CACHE_DELAY, 0) - end -else - local frame = CreateFrame("Frame") - local timer = AIO_MSG_CACHE_DELAY - local function ONUPDATE(self, diff) - if timer > diff then - timer = timer - diff - else - ProcessRemoveQue() - timer = AIO_MSG_CACHE_DELAY - end - end - frame:SetScript("OnUpdate", ONUPDATE) +if AIO_SERVER and AIO_MAIN_LUA_STATE then + CreateLuaEvent(ProcessRemoveQue, AIO_MSG_CACHE_DELAY, 0) end -- Erase data on logout if AIO_SERVER and AIO_MAIN_LUA_STATE then - local function Erase(event, player) - RemoveData(player:GetGUIDLow()) + local function Erase(_, player) + reassembler:remove_peer(player:GetGUIDLow()) end RegisterPlayerEvent(4, Erase) end @@ -497,53 +413,20 @@ end -- Splits too long messages into smaller pieces local function AIO_Send(msg, player, ...) assert(type(msg) == "string", "#1 string expected") - assert(not AIO_SERVER or type(player) == 'userdata', "#2 player expected") + assert(not AIO_SERVER or AIO_IsPlayerObject(player), "#2 player expected") AIO_debug("Sending message length:", #msg) if AIO_ENABLE_MSGPRINT then print("sent:", msg) end - -- split message to 255 character packets if needed (send long message) if #msg <= AIO_MsgLen then - -- Send short <= AIO_MsgLen msg - AIO_SendAddonMessage(AIO_ShortMsg..msg, player) + AIO_SendAddonMessage(AIO_ShortMsg .. msg, player) else - -- Send long > AIO_MsgLen msg - - local guid = AIO_SERVER and player:GetGUIDLow() or 1 - if not plrdata[guid] then - plrdata[guid] = { - stored = 0, - ramque = NewQueue(), - MSG_GUID = MSG_MIN, - } - end - local pdata = plrdata[guid] - - -- the chars can not contain \0 - -- 16bit -> Message ID -- 0 reserved for identifying short msg - -- 16bit -> Number of parts (should be > 1) - -- 16bit -> Part ID - -- Rest -> Message String - - -- msglen - 4 bits for header data, messageid is already substracted - local msglen = (AIO_MsgLen-4) - -- Calculate amount of messages to send - local parts = ceil(#msg / msglen) - -- assemble header - local header = AIO_16tostring(pdata.MSG_GUID)..AIO_16tostring(parts) - - -- update guid - if pdata.MSG_GUID >= MSG_MAX then - pdata.MSG_GUID = MSG_MIN - else - pdata.MSG_GUID = pdata.MSG_GUID+1 - end - - -- send messages - for i = 1, parts do - AIO_SendAddonMessage(header..AIO_16tostring(i)..ssub(msg, ((i-1)*msglen)+1, (i*msglen)), player) + local peer_id = AIO_SERVER and player:GetGUIDLow() or 1 + local packets = reassembler:split_payload(peer_id, msg) + for i = 1, #packets do + AIO_SendAddonMessage(packets[i], player) end end @@ -555,255 +438,66 @@ local function AIO_Send(msg, player, ...) end end --- Message class metatable -local msgmt = {} -function msgmt.__index(tbl, key) - return msgmt[key] -end - --- Add a new block to message and returns self --- A block is a chunk of data identified by a string name --- blocks are sent between server and client and handled on the receiving end --- by block handlers. Blockhandlers are functions you can assign to --- a specific name as a handler with AIO.RegisterEvent(name, func) --- The All values in the block after it's name will be passed to the handler --- function in same order. -function msgmt:Add(Name, ...) - assert(Name, "#1 Block must have name") - self.params[#self.params+1] = {select('#', ...), Name, ...} - self.assemble = true - return self -end - --- Function to append messages together, returns self --- Example AIO.Msg():Append(msg):Append(msg2):Send(...) -function msgmt:Append(msg2) - assert(type(msg2) == 'table', "#1 table expected") - for i = 1, #msg2.params do - assert(type(msg2.params[i]) == 'table', "#1["..i.."] table expected") - self.params[#self.params+1] = msg2.params[i] - end - self.assemble = true - return self -end - --- Assembles the message string from stored data -function msgmt:Assemble() - if not self.assemble then - return self - end - self.MSG = Smallfolk.dumps(self.params) - self.assemble = false - return self -end - --- Function to send the message to given players -function msgmt:Send(player, ...) - assert(not AIO_SERVER or player, "#1 player is nil") - AIO_Send(self:ToString(), player, ...) - return self -end - --- Erases the so far built message and returns self -function msgmt:Clear() - for i = 1, #self.params do - self.params[i] = nil - end - self.MSG = nil - self.assemble = false - return self -end - --- Returns the message string or an empty string -function msgmt:ToString() - return self:Assemble().MSG -end - --- Returns true if the message has something in it -function msgmt:HasMsg() - return #self.params > 0 -end +local timeout_parse_msg = '' +local function AIO_Timeout() + error(string.format( + "AIO Timeout. Your code ran over %s instructions with message:\n%s", + '' .. AIO_TIMEOUT_INSTRUCTIONCOUNT, + timeout_parse_msg or 'nil' + )) +end + +local timeout_hook +if AIO_SERVER and AIO_TIMEOUT_INSTRUCTIONCOUNT > 0 then + timeout_hook = { + begin = function(msg) + timeout_parse_msg = msg + debug.sethook(AIO_Timeout, "", AIO_TIMEOUT_INSTRUCTIONCOUNT) + end, + on_end = function() + debug.sethook() + end, + } +end + +local rpc = aio_rpc_mod.new({ + dumps = Smallfolk.dumps, + loads = Smallfolk.loads, + pcall = AIO_pcall, + get_handlers = function() return AIO_BLOCKHANDLES end, + debug = AIO_debug, + enable_msgprint = AIO_ENABLE_MSGPRINT, + server = AIO_SERVER, + timeout_hook = timeout_hook, +}) +rpc.bind_send(AIO_Send) --- Creates and returns a new message that you can append stuff to and send to client or server --- Example: AIO.Msg():Add("MyHandlerName", param1, param2):Send(player) function AIO.Msg() - local msg = {params = {}, MSG = nil, assemble = false} - setmetatable(msg, msgmt) - return msg + return rpc.Msg() end --- Calls the handler for block, see AIO.RegisterEvent --- for adding handlers for blocks -local preinitblocks = {} -local function AIO_HandleBlock(player, data, skipstored) - local HandleName = data[2] - assert(HandleName, "Invalid handle, no handle name") - - if not AIO_SERVER and not AIO_INITED and (HandleName ~= 'AIO' or data[3] ~= 'Init') then - -- store blocks received before initialization - preinitblocks[#preinitblocks+1] = data - AIO_debug("Received block before Init:", HandleName, data[1], data[3]) - return - end - - local handledata = AIO_BLOCKHANDLES[HandleName] - if not handledata then - error("Unknown AIO block handle: '"..tostring(HandleName).."'") - end - - -- found the block handler and arguments match the format. - -- call the block handler - if AIO_SERVER and data[1] > 15 then - error("Received AIO block with over 15 arguments. Try using tables instead") - return - end - handledata(player, unpack(data, 3, data[1]+2)) - - if not skipstored and not AIO_SERVER and AIO_INITED and HandleName == 'AIO' and data[3] == 'Init' then - -- handle stored blocks after initialization, if they are not init messages - for i = 1, #preinitblocks do - AIO_HandleBlock(player, preinitblocks[i], true) - preinitblocks[i] = nil - end - end -end - --- Extracts blocks from assembled addon messages -local curmsg = '' -local function AIO_Timeout() - error(string.format("AIO Timeout. Your code ran over %s instructions with message:\n%s", ''..AIO_TIMEOUT_INSTRUCTIONCOUNT, (curmsg or 'nil'))) -end -local function _AIO_ParseBlocks(msg, player) - if AIO_SERVER and AIO_TIMEOUT_INSTRUCTIONCOUNT > 0 then - curmsg = msg - debug.sethook(AIO_Timeout, "", AIO_TIMEOUT_INSTRUCTIONCOUNT) - end - - AIO_debug("Received messagelength:", #msg) - if AIO_ENABLE_MSGPRINT then - print("received:", msg) - end - - -- deserialize the message - local data = AIO_pcall(Smallfolk.loads, msg, #msg) - if not data or type(data) ~= 'table' then - AIO_debug("Received invalid message - data not a table") - return - end - - -- Handle parsing of all blocks - for i = 1, #data do - -- Using pcall here so errors wont stop handling other blocks in the msg - AIO_pcall(AIO_HandleBlock, player, data[i]) - end +local AIO_HandleBlock = aio_core.make_handle_block({ + unpack = unpack, + client_state = AIO_client_state, + block_handlers = AIO_BLOCKHANDLES, + server = AIO_SERVER, + max_block_args = 15, + debug = AIO_debug, +}) - if AIO_SERVER and AIO_TIMEOUT_INSTRUCTIONCOUNT > 0 then - debug.sethook() - end -end local function AIO_ParseBlocks(msg, player) - AIO_pcall(_AIO_ParseBlocks, msg, player) + AIO_pcall(function() + rpc.parse_blocks(msg, player, function(p, data) + AIO_pcall(AIO_HandleBlock, p, data) + end) + end) end --- Handles cleaning and assembling the messages received --- Messages can be 255 characters long, so big messages will be split local function _AIO_HandleIncomingMsg(msg, player) - -- Received a long message part (msg split into 255 character parts) - local msgid = ssub(msg, 1,2) - - if msgid == AIO_ShortMsg then - -- Received <= 255 char msg, direct parse, take out the msg tag first - AIO_ParseBlocks(ssub(msg, 3), player) - return - end - - -- the chars can not contain \0 - -- 16bit -> Message ID -- 0 reserved for identifying short msg - -- 16bit -> Number of parts (should be > 1) - -- 16bit -> Part ID - -- Rest -> Message String - - if #msg < 3*2 then - return - end - - local messageId = AIO_stringto16(msgid) - local parts = AIO_stringto16(ssub(msg, 3,4)) - local partId = AIO_stringto16(ssub(msg, 5,6)) - if partId <= 0 or partId > parts then - error("received long message with invalid amount of parts. id, parts: "..partId.." "..parts) - return - end - - msg = ssub(msg, 7) - - -- guid is used to store information about long messages for specific player - local guid = AIO_SERVER and player:GetGUIDLow() or 1 - - if not plrdata[guid] then - plrdata[guid] = { - stored = 0, - ramque = NewQueue(), - MSG_GUID = MSG_MIN, - } - end - local pdata = plrdata[guid] - pdata[messageId] = pdata[messageId] or {} - local data = pdata[messageId] - - -- Different message with same ID, scrap previous message (probably reloaded UI) - -- Or new message so parts is nil - if not data.parts or data.parts.n ~= parts then - if data.parts then - for i = 0, data.parts.n do - data.parts[i] = nil - end - end - data.guid = guid - data.parts = {n=parts} - data.id = messageId - data.stamp = AIO_GetTime() - data.remquepos = removeque:pushright(data) - data.ramquepos = pdata.ramque:pushright(data) - end - - data.parts[partId] = msg - - pdata.stored = pdata.stored + #msg - if AIO_SERVER and pdata.stored > AIO_MSG_CACHE_SPACE then - local l, r = pdata.ramque:getrange() - for i = l, r-1 do -- -1 for leaving at least one message - -- remove message from stores leaving it for GC - local msgdata = pdata.ramque:popleft() - if msgdata then - removeque:gettable()[msgdata.remquepos] = nil - pdata[msgdata.id] = nil - -- count the data it holds and substract from stored data - for j = 1, msgdata.parts.n do - if msgdata.parts[j] then - pdata.stored = pdata.stored - #msgdata.parts[j] - end - end - -- check if enough freed to hold latest message in the cache - if pdata.stored <= AIO_MSG_CACHE_SPACE then - break - end - end - end - -- if still error even though tried freeing all memory possible to free - -- throw error and clear cache - if pdata.stored > AIO_MSG_CACHE_SPACE then - RemoveData(guid) - error("AIO_MSG_CACHE_SPACE is too small for received message") - return - end - end - - -- Has all parts, process - if #data.parts == data.parts.n then - local cat = tconcat(data.parts) - RemoveData(guid, messageId) - AIO_ParseBlocks(cat, player) + local peer_id = AIO_SERVER and player:GetGUIDLow() or 1 + local assembled = reassembler:ingest(peer_id, msg) + if assembled then + AIO_ParseBlocks(assembled, player) end end local function AIO_HandleIncomingMsg(msg, player) @@ -812,7 +506,7 @@ end if AIO_MAIN_LUA_STATE then -- Adds a new callback function for AIO that is called if - -- a block with the same name is recieved. + -- a block with the same name is received. -- All parameters the client sends will be passed to func when called -- Only one function can be a handler for one name (subject for change) function AIO.RegisterEvent(name, func) @@ -838,6 +532,8 @@ if AIO_MAIN_LUA_STATE then local function handler(player, key, ...) if key and handlertable[key] then handlertable[key](player, ...) + elseif key then + AIO_debug("Unknown handler key for", name, ":", key) end end AIO.RegisterEvent(name, handler) @@ -851,370 +547,66 @@ end -- omitted the file the function is executed in will be used as path -- and the path's or given path's file name will be used. -- Returns true if called on server side +local aio_side_ctx = { + AIO = AIO, + AIO_VERSION = AIO_VERSION, + AIO_client_state = AIO_client_state, + AIO_SAVEDFRAMES = AIO_SAVEDFRAMES, + AIO_SAVEDVARS = AIO_SAVEDVARS, + AIO_SAVEDVARSCHAR = AIO_SAVEDVARSCHAR, + AIO_HANDLERS = AIO_HANDLERS, + AIO_ADDONSORDER = AIO_ADDONSORDER, + AIO_INITHOOKS = AIO_INITHOOKS, + AIO_debug = AIO_debug, + AIO_pcall = AIO_pcall, + AIO_HandleIncomingMsg = AIO_HandleIncomingMsg, + AIO_Msg = AIO.Msg, + AIO_CODE_OBFUSCATE = AIO_CODE_OBFUSCATE, + AIO_MSG_COMPRESS = AIO_MSG_COMPRESS, + AIO_UI_INIT_DELAY = AIO_UI_INIT_DELAY, + AIO_FORCE_RELOAD_ON_STARTUP = AIO_FORCE_RELOAD_ON_STARTUP, + AIO_MSG_CACHE_DELAY = AIO_MSG_CACHE_DELAY, + AIO_Compressed = AIO_Compressed, + AIO_Uncompressed = AIO_Uncompressed, + AIO_ClientPrefix = AIO_ClientPrefix, + AIO_ServerPrefix = AIO_ServerPrefix, + AIO_Prefix = AIO_Prefix, + lualzw = lualzw, + aio_util = aio_util, + LuaSrcDiet = LuaSrcDiet, + LibWindow = LibWindow, + loadstring = loadstring, + ssub = ssub, + reassembler_sweep = ProcessRemoveQue, +} + function AIO.AddAddon(path, name) if AIO_SERVER then if AIO_MAIN_LUA_STATE then - path = path or debug.getinfo(2, 'S').source:sub(2) - name = name or match(path, "([^/]*)$") - local code = AIO_ReadFile(path) - AIO.AddAddonCode(name, code) - AIO_debug("Added addon path&name:", path, name) + require("aio_server_pipeline").add_addon_file(aio_side_ctx, path, name) end return true end end if AIO_SERVER then - -- A shorthand for sending a message for a handler. function AIO.Handle(player, name, handlername, ...) - assert(type(player) == 'userdata', "#1 player expected") + assert(AIO_IsPlayerObject(player), "#1 player expected") assert(name ~= nil, "#2 expected not nil") return AIO.Msg():Add(name, handlername, ...):Send(player) end - if AIO_MAIN_LUA_STATE then - -- Adds the addon code to the sent addons on login. - -- The addon code is trimmed according to settings at top of this file. - -- The addon is cached on client side and will be updated if needed. - -- name is an unique ID for the addon, usually you can use the file name or addon name there - -- Do note that short names are better since they are sent back and forth to indentify files - local crc32 = require("crc32lua").crc32 - function AIO.AddAddonCode(name, code) - assert(type(name) == 'string', "#1 string expected") - assert(type(code) == 'string', "#2 string expected") - if AIO_CODE_OBFUSCATE then - code = LuaSrcDiet(code, 3) - end - if AIO_MSG_COMPRESS then - code = AIO_Compressed..assert(lualzw.compress(code)) - else - code = AIO_Uncompressed..code - end - AIO_ADDONSORDER[#AIO_ADDONSORDER+1] = {name=name, crc=crc32(code), code=code} - end - - -- Adds a new function that is called when an init message - -- is about to be sent by server. The function is called before sending and - -- the message is passed to it along with the player if available: - -- func(msg[, player]) - -- you can modify the passed message and or return a new one - function AIO.AddOnInit(func) - assert(type(func) == 'function', "#1 function expected") - table.insert(AIO_INITHOOKS, func) - end - - -- This restricts player's ability to request the initial UI to some set time delay - local timers = {} - local function RemoveInitTimer(eventid, playerguid) - if type(playerguid) == "number" then - timers[playerguid] = nil - end - end - -- This handles sending initial UI to player. - -- The Client sends a request to the server for the addons along with it's cached addon data. - -- Then the server checks what files it has to send back and what it has to remove from the client's cache. - -- Then after server sends the required data to client, the client will one by one execute the addons - -- in the same order as they are sent from the server. - local versionmsg = AIO.Msg():Add("AIO", "Init", AIO_VERSION) - function AIO_HANDLERS.Init(player, version, clientdata) - -- check that the player is not on cooldown for init calling - local guid = player:GetGUIDLow() - if timers[guid] then - return - end - - -- make a new cooldown for init calling - timers[guid] = CreateLuaEvent(function(e) RemoveInitTimer(e, guid) end, AIO_UI_INIT_DELAY, 1) -- the timer here (AIO_UI_INIT_DELAY) is the min time in ms between inits the player can do - - -- Check for bad version and send version back for error directly - if version ~= AIO_VERSION then - versionmsg:Send(player) - return - end - - local istable = type(clientdata) == 'table' - - local addons = {} - local cached = {} - for i = 1, #AIO_ADDONSORDER do - local data = AIO_ADDONSORDER[i] - local clientcrc = istable and clientdata[data.name] or nil - if clientcrc and clientcrc == data.crc then - -- valid - send name only - cached[i] = data.name - else - -- not cached or outdated - send new - addons[i] = data - end - end - - local initmsg = AIO.Msg():Add("AIO", "Init", AIO_VERSION, #AIO_ADDONSORDER, addons, cached) - - for k,v in ipairs(AIO_INITHOOKS) do - initmsg = v(initmsg, player) or initmsg - end - - initmsg:Send(player) - end - - -- Handler that catches client errors - -- can be used to log client errors to server - function AIO_HANDLERS.Error(player, errmsg) - if not AIO_ERROR_LOG or type(errmsg) ~= 'string' then - return - end - PrintInfo(errmsg) - end - - -- An addon message event handler for the lua engine - -- If the message data is correct, move the message forward to the AIO message handler. - local function ONADDONMSG(event, sender, Type, prefix, msg, target) - if prefix == AIO_ClientPrefix and tostring(sender) == tostring(target) and #msg < 510 then - AIO_HandleIncomingMsg(msg, sender) - end - end - - RegisterServerEvent(30, ONADDONMSG) - - for k,v in ipairs(GetPlayersInWorld()) do - AIO.Handle(v, "AIO", "ForceReload") - end + require("aio_server_pipeline").install_main_state(aio_side_ctx) end - else - - -- A shorthand for sending a message for a handler. function AIO.Handle(name, handlername, ...) assert(name ~= nil, "#1 expected not nil") return AIO.Msg():Add(name, handlername, ...):Send() end - -- Key is a key for a variable in the global table _G - -- The variable is stored when the player logs out and will be restored - -- when he logs back in before the addon codes are run - -- these variables are account bound - function AIO.AddSavedVar(key) - assert(key ~= nil, "#1 table key expected") - AIO_SAVEDVARS[key] = true - end - - -- Key is a key for a variable in the global table _G - -- The variable is stored when the player logs out and will be restored - -- when he logs back in before the addon codes are run - -- these variables are character bound - function AIO.AddSavedVarChar(key) - assert(key ~= nil, "#1 table key expected") - AIO_SAVEDVARSCHAR[key] = true - end - - AIO_FRAMEPOSITIONS = AIO_FRAMEPOSITIONS or {} - AIO.AddSavedVar("AIO_FRAMEPOSITIONS") - AIO_FRAMEPOSITIONSCHAR = AIO_FRAMEPOSITIONSCHAR or {} - AIO.AddSavedVarChar("AIO_FRAMEPOSITIONSCHAR") - -- Makes the frame save it's position over relog - -- If char is true, the position saving is character bound, otherwise account bound - function AIO.SavePosition(frame, char) - assert(frame:GetName(), "Called AIO.SavePosition on a nameless frame") - local store = char and AIO_FRAMEPOSITIONSCHAR or AIO_FRAMEPOSITIONS - if not store[frame:GetName()] then - store[frame:GetName()] = {} - end - LibWindow.RegisterConfig(frame, store[frame:GetName()]) - LibWindow.RestorePosition(frame) - LibWindow.SavePosition(frame) - table.insert(AIO_SAVEDFRAMES, frame) - end - - -- A client side event handler - -- Passes the incoming message to AIO message handler if it is valid - local function ONADDONMSG(self, event, prefix, msg, Type, sender) - if prefix == AIO_ServerPrefix then - if event == "CHAT_MSG_ADDON" and sender == UnitName("player") then - -- Normal AIO message handling from addon messages - AIO_HandleIncomingMsg(msg, sender) - end - end - end - local MsgReceiver = CreateFrame("Frame") - MsgReceiver:RegisterEvent("CHAT_MSG_ADDON") - MsgReceiver:SetScript("OnEvent", ONADDONMSG) - - -- A block handler for Init name, checks the version number and errors out if needed - -- On wrong version prevents handling any more messages - -- Stores new and changed addons to cache and runs the addons from cache - -- Also removes removed and outdated addons - local function RunAddon(name) - -- Check if code is compressed and uncompress if needed - local code = AIO_sv_Addons[name] and AIO_sv_Addons[name].code - assert(code, "Addon doesnt exist") - local compression, compressedcode = ssub(code, 1, 1), ssub(code, 2) - if compression == AIO_Compressed then - compressedcode = assert(lualzw.decompress(compressedcode)) - end - assert(loadstring(compressedcode, name))() - end - function AIO_HANDLERS.Init(player, version, N, addons, cached) - if(AIO_VERSION ~= version) then - AIO_INITED = true - -- stop handling any incoming messages - AIO_HandleBlock = function() end - print("You have AIO version "..AIO_VERSION.." and the server uses "..(version or "nil")..". Get the same version") - return - end - - assert(type(N) == 'number') - assert(type(addons) == 'table') - assert(type(cached) == 'table') - - local validAddons = {} - for i = 1, N do - local name - if addons[i] then - name = addons[i].name - AIO_sv_Addons[name] = addons[i] - validAddons[name] = true - elseif cached[i] then - name = cached[i] - validAddons[name] = true - else - error("Unexpected behavior, try /aio reset") - end - - AIO_pcall(RunAddon, name) - end - - local invalidAddons = {} - for name, data in pairs(AIO_sv_Addons) do - if not validAddons[name] then - invalidAddons[#invalidAddons+1] = name - end - end - - for i = 1, #invalidAddons do - AIO_sv_Addons[invalidAddons[i]] = nil - end - - AIO_INITED = true - print("Initialized AIO version "..AIO_VERSION..". Type '/aio help' for commands") - end - - -- Forces reload of UI for user on next action - function AIO_HANDLERS.ForceReload(player) - local frame = CreateFrame("BUTTON") - frame:SetToplevel(true) - frame:SetFrameStrata("TOOLTIP") - frame:SetFrameLevel(100) - frame:SetAllPoints(WorldFrame) - -- frame.texture = frame:CreateTexture() - -- frame.texture:SetAllPoints(frame) - -- frame.texture:SetTexture(0.1, 0.1, 0.1, 0.5) - frame:SetScript("OnClick", ReloadUI) - print("AIO: Force reloading UI") - message("AIO: Force reloading UI") - end - - -- Forces reset of UI for user on next action - function AIO_HANDLERS.ForceReset(player) - AIO_RESET() - AIO_HANDLERS.ForceReload(player) - end - - local frame = CreateFrame("FRAME") -- Need a frame to respond to events - frame:RegisterEvent("ADDON_LOADED") -- Fired when saved variables are loaded - frame:RegisterEvent("PLAYER_LOGOUT") -- Fired when about to log out - - -- message to request initialization of UI - function frame:OnEvent(event, addon) - if event == "ADDON_LOADED" and addon == "AIO_Client" then - -- Register addon channel on cata+ - local _,_,_, tocversion = GetBuildInfo() - if tocversion and tocversion >= 40100 and RegisterAddonMessagePrefix then - RegisterAddonMessagePrefix("C"..AIO_Prefix) - end - - -- Our saved variables are ready at this point. If there is no save, they will be nil - -- Must be before any other addon action like sending init request - if type(AIO_sv) ~= 'table' then - AIO_sv = {} -- This is the first time this addon is loaded; initialize the var - end - if type(AIO_sv_char) ~= 'table' then - AIO_sv_char = {} -- This is the first time this addon is loaded; initialize the var - end - if type(AIO_sv_Addons) ~= 'table' then - AIO_sv_Addons = {} -- This is the first time this addon is loaded; initialize the var - end - - -- Restore addon saved variables to global namespace - -- Must be before sending init request - for k,v in pairs(AIO_sv) do - if _G[k] then - AIO_debug("Overwriting global var _G["..k.."] with a saved var") - end - _G[k] = v - end - for k,v in pairs(AIO_sv_char) do - if _G[k] then - AIO_debug("Overwriting global var _G["..k.."] with a saved character var") - end - _G[k] = v - end - - -- Request initialization of UI if not done yet - -- works by timer for every second. Timer shut down after inited. - -- initmsg consists of the version and all known crc codes for cached addons. - local rem = {} - local addons = {} - for name, data in pairs(AIO_sv_Addons) do - if type(name) ~= 'string' or type(data) ~= 'table' or type(data.crc) ~= 'number' or type(data.code) ~= 'string' then - table.insert(rem, name) - else - addons[name] = data.crc - end - end - for _,name in ipairs(rem) do - AIO_sv_Addons[name] = nil -- remove invalid addons - end - - local initmsg = AIO.Msg():Add("AIO", "Init", AIO_VERSION, addons) - - local reset = 1 - local timer = reset - local function ONUPDATE(self, diff) - if AIO_INITED then - self:SetScript("OnUpdate", nil) - initmsg = nil - reset = nil - timer = nil - return - end - if timer < diff then - initmsg:Send() - timer = reset - reset = reset * 1.5 - else - timer = timer - diff - end - end - frame:SetScript("OnUpdate", ONUPDATE) - -- initmsg:Send() - elseif event == "PLAYER_LOGOUT" then - -- On logout we must store all global namespace to saved vars - AIO_sv = {} -- discard vars that no longer exist - for key,_ in pairs(AIO_SAVEDVARS or {}) do - AIO_sv[key] = _G[key] - end - AIO_sv_char = {} -- discard vars that no longer exist - for key,_ in pairs(AIO_SAVEDVARSCHAR or {}) do - AIO_sv_char[key] = _G[key] - end - - for k,v in ipairs(AIO_SAVEDFRAMES or {}) do - LibWindow.SavePosition(v) - end - end - end - frame:SetScript("OnEvent", frame.OnEvent) + require("aio_client_ui").install(aio_side_ctx) + AIO_RESET = aio_side_ctx.AIO_RESET end if AIO_MAIN_LUA_STATE then @@ -1236,7 +628,7 @@ if AIO_MAIN_LUA_STATE then end if AIO_SERVER then - local function OnCommand(event, player, msg) + local function OnCommand(_, player, msg) msg = msg:lower() if ssub(msg, 1, 3) ~= 'aio' then return @@ -1258,16 +650,16 @@ if AIO_MAIN_LUA_STATE then else SLASH_AIO1 = "/aio" function SlashCmdList.AIO(msg) - local msg = msg:lower() - if msg and msg ~= "" then + local cmd = msg:lower() + if cmd and cmd ~= "" then for k,v in pairs(cmds) do - if k:find(msg, 1, true) == 1 then + if k:find(cmd, 1, true) == 1 then v() return end end end - print("Unknown command /aio "..tostring(msg)) + print("Unknown command /aio "..tostring(cmd)) cmds.help() end end diff --git a/AIO_Server/aio_core.lua b/AIO_Server/aio_core.lua new file mode 100644 index 0000000..5a41963 --- /dev/null +++ b/AIO_Server/aio_core.lua @@ -0,0 +1,80 @@ +--[[ + Shared helpers for AIO.lua: vararg counting, pcall wrapper, block dispatch rules. +]] + +local M = {} + +function M.extract_n(...) + return select("#", ...), ... +end + +function M.make_pcall(opts) + local extract_n = M.extract_n + local unpack_fn = opts.unpack + local pcall_fn = opts.pcall + local xpcall_fn = opts.xpcall + + return function(f, ...) + assert(type(f) == "function") + if not opts.enable_pcall then + return f(...) + end + local data + if opts.server and opts.enable_traceback and opts.debug_traceback then + data = { extract_n(xpcall_fn(f, opts.debug_traceback, ...)) } + else + data = { extract_n(pcall_fn(f, ...)) } + end + if not data[2] then + opts.on_error(data[3]) + return + end + return unpack_fn(data, 3, data[1] + 1) + end +end + +-- Returns handle_block(player, data, skipstored) and a table holding queued pre-init blocks. +function M.make_handle_block(opts) + local preinitblocks = {} + local unpack_fn = opts.unpack + local client_state = opts.client_state + local block_handles = opts.block_handlers + local debug_fn = opts.debug + + local function handle_block(player, data, skipstored) + local handle_name = data[2] + assert(handle_name, "Invalid handle, no handle name") + + if client_state and client_state.AIO_VERSION_MISMATCH and not (handle_name == "AIO" and data[3] == "Init") then + return + end + + if client_state and not client_state.AIO_INITED and (handle_name ~= "AIO" or data[3] ~= "Init") then + preinitblocks[#preinitblocks + 1] = data + debug_fn("Received block before Init:", handle_name, data[1], data[3]) + return + end + + local handledata = block_handles[handle_name] + if not handledata then + error("Unknown AIO block handle: '" .. tostring(handle_name) .. "'") + end + + if opts.server and data[1] > opts.max_block_args then + error("Received AIO block with over " .. opts.max_block_args .. " arguments. Try using tables instead") + end + handledata(player, unpack_fn(data, 3, data[1] + 2)) + + if not skipstored and client_state and client_state.AIO_INITED + and handle_name == "AIO" and data[3] == "Init" then + for i = 1, #preinitblocks do + handle_block(player, preinitblocks[i], true) + preinitblocks[i] = nil + end + end + end + + return handle_block +end + +return M diff --git a/AIO_Server/aio_framing.lua b/AIO_Server/aio_framing.lua new file mode 100644 index 0000000..04dac9e --- /dev/null +++ b/AIO_Server/aio_framing.lua @@ -0,0 +1,92 @@ +--[[ + WoW addon-message framing: uint16 packing without NUL and split/rejoin for size limits. +]] + +local floor = math.floor +local ssub = string.sub +local sbyte = string.byte +local schar = string.char +local ceil = math.ceil + +local M = {} + +-- Short-message tag (2 bytes); must not collide with uint16 message IDs used for long messages. +M.SHORT_TAG = schar(1) .. schar(1) + +function M.uint16_encode(uint16) + assert(uint16 <= 2^16 - 767, "Too high value") + assert(uint16 >= 0, "Negative value") + local high = floor(uint16 / 254) + local l = high + 1 + local r = uint16 - high * 254 + 1 + return schar(l) .. schar(r) +end + +function M.uint16_decode(str) + local l = sbyte(ssub(str, 1, 1)) - 1 + local r = sbyte(ssub(str, 2, 2)) - 1 + local val = l * 254 + r + assert(val <= 2^16 - 767, "Too high value") + assert(val >= 0, "Negative value") + return val +end + +-- max_payload: max bytes per wire packet body (after channel prefix accounting). +function M.new(max_payload) + local short_tag = M.SHORT_TAG + local uint16_encode = M.uint16_encode + local uint16_decode = M.uint16_decode + + local framing = { + max_payload = max_payload, + } + + function framing.split(_, payload, message_id) + if #payload <= max_payload then + return { short_tag .. payload } + end + + local msglen = max_payload - 4 + local part_count = ceil(#payload / msglen) + local header = uint16_encode(message_id) .. uint16_encode(part_count) + local packets = {} + + for i = 1, part_count do + packets[i] = header .. uint16_encode(i) .. ssub(payload, ((i - 1) * msglen) + 1, i * msglen) + end + + return packets + end + + -- Returns complete payload string, or nil if packet is too short / incomplete chunk. + -- On incomplete long message, returns nil, chunk_table (for tests); production uses reassembler. + function framing.parse(_, packet) + local msgid = ssub(packet, 1, 2) + + if msgid == short_tag then + return ssub(packet, 3) + end + + if #packet < 6 then + return nil + end + + local message_id = uint16_decode(msgid) + local parts = uint16_decode(ssub(packet, 3, 4)) + local part_id = uint16_decode(ssub(packet, 5, 6)) + if part_id <= 0 or part_id > parts then + error("received long message with invalid amount of parts. id, parts: " .. part_id .. " " .. parts) + end + + return nil, { + message_id = message_id, + parts = parts, + part_id = part_id, + payload = ssub(packet, 7), + } + end + + return framing +end + +return M diff --git a/AIO_Server/aio_reassembler.lua b/AIO_Server/aio_reassembler.lua new file mode 100644 index 0000000..582f465 --- /dev/null +++ b/AIO_Server/aio_reassembler.lua @@ -0,0 +1,192 @@ +--[[ + Stateful reassembly of framed long messages with TTL and optional per-peer byte caps. +]] + +local tconcat = table.concat + +local M = {} + +function M.new(opts) + assert(opts["framing"], "framing required") + assert(opts["NewQueue"], "NewQueue required") + assert(opts["get_time"], "get_time required") + assert(opts["get_time_diff"], "get_time_diff required") + assert(opts["get_message_stored_size"], "get_message_stored_size required") + assert(opts["cache_time_ms"], "cache_time_ms required") + + local framing = opts["framing"] + local create_queue = opts["NewQueue"] + local get_time = opts["get_time"] + local get_time_diff = opts["get_time_diff"] + local get_message_stored_size = opts["get_message_stored_size"] + local cache_space = opts["cache_space"] + local cache_time_ms = opts["cache_time_ms"] + local msg_id_min = opts["msg_id_min"] or 1 + local msg_id_max = opts["msg_id_max"] or 2^16 - 767 + + local plrdata = {} + local removeque = create_queue() + + local function remove_data(peer_id, msgid) + local pdata = plrdata[peer_id] + if not pdata then + return + end + if msgid then + local data = pdata[msgid] + if data then + pdata.stored = pdata.stored - get_message_stored_size(data) + if pdata.stored < 0 then + pdata.stored = 0 + end + pdata[msgid] = nil + pdata.ramque:gettable()[data.ramquepos] = nil + removeque:gettable()[data.remquepos] = nil + end + else + local que = pdata.ramque:gettable() + local l, r = pdata.ramque:getrange() + for i = l, r do + if que[i] then + removeque:gettable()[que[i].remquepos] = nil + end + end + plrdata[peer_id] = nil + end + end + + local function ensure_peer(peer_id) + if not plrdata[peer_id] then + plrdata[peer_id] = { + stored = 0, + ramque = create_queue(), + MSG_GUID = msg_id_min, + } + end + return plrdata[peer_id] + end + + local function bump_send_id(pdata) + if pdata.MSG_GUID >= msg_id_max then + pdata.MSG_GUID = msg_id_min + else + pdata.MSG_GUID = pdata.MSG_GUID + 1 + end + end + + local function all_parts_present(parts) + for i = 1, parts.n do + if not parts[i] then + return false + end + end + return true + end + + local reassembler = {} + + function reassembler.split_payload(_, peer_id, payload) + local pdata = ensure_peer(peer_id) + local message_id = pdata.MSG_GUID + local packets = framing:split(payload, message_id) + bump_send_id(pdata) + return packets + end + + function reassembler.ingest(_, peer_id, packet) + local complete, chunk = framing:parse(packet) + if complete then + return complete + end + if not chunk then + return nil + end + + local message_id = chunk.message_id + local parts = chunk.parts + local part_id = chunk.part_id + local msg = chunk.payload + + local pdata = ensure_peer(peer_id) + pdata[message_id] = pdata[message_id] or {} + local data = pdata[message_id] + + if not data.parts or data.parts.n ~= parts then + if data.parts then + for i = 0, data.parts.n do + data.parts[i] = nil + end + end + data.peer_id = peer_id + data.parts = { n = parts } + data.id = message_id + data.stamp = get_time() + data.remquepos = removeque:pushright(data) + data.ramquepos = pdata.ramque:pushright(data) + end + + data.parts[part_id] = msg + + pdata.stored = pdata.stored + #msg + if cache_space and pdata.stored > cache_space then + local l, r = pdata.ramque:getrange() + for _ = l, r - 1 do + local msgdata = pdata.ramque:popleft() + if msgdata then + removeque:gettable()[msgdata.remquepos] = nil + pdata[msgdata.id] = nil + for j = 1, msgdata.parts.n do + if msgdata.parts[j] then + pdata.stored = pdata.stored - #msgdata.parts[j] + end + end + if pdata.stored <= cache_space then + break + end + end + end + if pdata.stored > cache_space then + remove_data(peer_id) + error("AIO_MSG_CACHE_SPACE is too small for received message") + end + end + + if all_parts_present(data.parts) then + local cat = tconcat(data.parts) + remove_data(peer_id, message_id) + return cat + end + + return nil + end + + function reassembler.remove_peer(_, peer_id) + remove_data(peer_id) + end + + function reassembler.remove_message(_, peer_id, message_id) + remove_data(peer_id, message_id) + end + + function reassembler.sweep_expired(_) + if removeque:empty() then + return + end + local now = get_time() + local l, r = removeque:getrange() + for _ = l, r do + local v = removeque:popleft() + if v then + if get_time_diff(now, v.stamp) < cache_time_ms then + removeque:pushleft(v) + break + end + remove_data(v.peer_id, v.id) + end + end + end + + return reassembler +end + +return M diff --git a/AIO_Server/aio_rpc.lua b/AIO_Server/aio_rpc.lua new file mode 100644 index 0000000..05a1a1e --- /dev/null +++ b/AIO_Server/aio_rpc.lua @@ -0,0 +1,149 @@ +--[[ + Block-oriented RPC over a serialized message blob (Smallfolk by default). +]] + +local M = {} + +function M.new(opts) + assert(opts["dumps"], "dumps required") + assert(opts["loads"], "loads required") + assert(opts["pcall"], "pcall required") + assert(opts["get_handlers"], "get_handlers required") + + local dumps = opts["dumps"] + local loads = opts["loads"] + local pcall_fn = opts["pcall"] + local get_handlers = opts["get_handlers"] + local debug_fn = opts["debug"] or function() end + local enable_msgprint = opts["enable_msgprint"] + local server = opts["server"] + local max_block_args = opts["max_block_args"] or 15 + local timeout_hook = opts["timeout_hook"] + + local send_fn + local msgmt = {} + function msgmt.__index(_, key) + return msgmt[key] + end + + function msgmt:Add(name, ...) + assert(name, "#1 Block must have name") + self.params[#self.params + 1] = { select("#", ...), name, ... } + self.assemble = true + return self + end + + function msgmt:Append(msg2) + assert(type(msg2) == "table", "#1 table expected") + for i = 1, #msg2.params do + assert(type(msg2.params[i]) == "table", "#1[" .. i .. "] table expected") + self.params[#self.params + 1] = msg2.params[i] + end + self.assemble = true + return self + end + + function msgmt:Assemble() + if not self.assemble then + return self + end + self.MSG = dumps(self.params) + self.assemble = false + return self + end + + function msgmt:Send(player, ...) + assert(not server or player, "#1 player is nil") + assert(send_fn, "send not bound") + send_fn(self:ToString(), player, ...) + return self + end + + function msgmt:Clear() + for i = 1, #self.params do + self.params[i] = nil + end + self.MSG = nil + self.assemble = false + return self + end + + function msgmt:ToString() + return self:Assemble().MSG + end + + function msgmt:HasMsg() + return #self.params > 0 + end + + local function handle_block(player, data) + local handle_name = data[2] + assert(handle_name, "Invalid handle, no handle name") + + local handledata = get_handlers()[handle_name] + if not handledata then + error("Unknown AIO block handle: '" .. tostring(handle_name) .. "'") + end + + if server and data[1] > max_block_args then + error("Received AIO block with over " .. max_block_args .. " arguments. Try using tables instead") + end + handledata(player, unpack(data, 3, data[1] + 2)) + end + + local function parse_blocks(msg, player, on_block) + local hooked = timeout_hook + if hooked then + hooked.begin(msg) + end + + local ok, err = pcall(function() + debug_fn("Received messagelength:", #msg) + if enable_msgprint then + print("received:", msg) + end + + local data = pcall_fn(loads, msg, #msg) + if not data or type(data) ~= "table" then + debug_fn("Received invalid message - data not a table") + return + end + + for i = 1, #data do + local block = data[i] + if on_block then + on_block(player, block) + else + pcall_fn(handle_block, player, block) + end + end + end) + + if hooked then + hooked.on_end() + end + + if not ok then + error(err) + end + end + + local rpc = {} + + function rpc.Msg() + local msg = { params = {}, MSG = nil, assemble = false } + setmetatable(msg, msgmt) + return msg + end + + function rpc.bind_send(fn) + send_fn = fn + end + + rpc.handle_block = handle_block + rpc.parse_blocks = parse_blocks + + return rpc +end + +return M diff --git a/AIO_Server/aio_server_pipeline.lua b/AIO_Server/aio_server_pipeline.lua new file mode 100644 index 0000000..3019067 --- /dev/null +++ b/AIO_Server/aio_server_pipeline.lua @@ -0,0 +1,117 @@ +-- luacheck: ignore +--[[ + Server-only: addon build (obfuscate/compress), init handshake, addon push to clients. +]] + +local M = {} + +function M.add_addon_file(ctx, path, name) + path = path or debug.getinfo(2, "S").source:sub(2) + name = name or ctx.aio_util.basename(path) + local code = M.read_file(ctx, path) + ctx.AIO.AddAddonCode(name, code) + ctx.AIO_debug("Added addon path&name:", path, name) + return true +end + +function M.read_file(ctx, path) + ctx.AIO_debug("Reading a file") + assert(type(path) == "string", "#1 string expected") + local f = assert(io.open(path, "rb")) + local str = f:read("*all") + f:close() + return str +end + +function M.install_main_state(ctx) + local AIO = ctx.AIO + local AIO_HANDLERS = ctx.AIO_HANDLERS + local AIO_ADDONSORDER = ctx.AIO_ADDONSORDER + local AIO_INITHOOKS = ctx.AIO_INITHOOKS + local crc32 = require("crc32lua").crc32 + + function AIO.AddAddonCode(name, code) + assert(type(name) == "string", "#1 string expected") + assert(type(code) == "string", "#2 string expected") + if ctx.AIO_CODE_OBFUSCATE then + code = ctx.LuaSrcDiet(code, 3) + end + if ctx.AIO_MSG_COMPRESS then + code = ctx.AIO_Compressed .. assert(ctx.lualzw.compress(code)) + else + code = ctx.AIO_Uncompressed .. code + end + AIO_ADDONSORDER[#AIO_ADDONSORDER + 1] = { name = name, crc = crc32(code), code = code } + end + + function AIO.AddOnInit(func) + assert(type(func) == "function", "#1 function expected") + table.insert(AIO_INITHOOKS, func) + end + + local timers = {} + local function RemoveInitTimer(_, playerguid) + if type(playerguid) == "number" then + timers[playerguid] = nil + end + end + + local versionmsg = ctx.AIO_Msg():Add("AIO", "Init", ctx.AIO_VERSION) + function AIO_HANDLERS.Init(player, version, clientdata) + local guid = player:GetGUIDLow() + if timers[guid] then + return + end + + timers[guid] = CreateLuaEvent(function(e) + RemoveInitTimer(e, guid) + end, ctx.AIO_UI_INIT_DELAY, 1) + + if version ~= ctx.AIO_VERSION then + versionmsg:Send(player) + return + end + + local istable = type(clientdata) == "table" + local addons = {} + local cached = {} + for i = 1, #AIO_ADDONSORDER do + local data = AIO_ADDONSORDER[i] + local clientcrc = istable and clientdata[data.name] or nil + if clientcrc and clientcrc == data.crc then + cached[i] = data.name + else + addons[i] = data + end + end + + local initmsg = ctx.AIO_Msg():Add("AIO", "Init", ctx.AIO_VERSION, #AIO_ADDONSORDER, addons, cached) + for _, hook in ipairs(AIO_INITHOOKS) do + initmsg = hook(initmsg, player) or initmsg + end + initmsg:Send(player) + end + + function AIO_HANDLERS.Error(_, errmsg) + if type(errmsg) ~= "string" then + return + end + PrintInfo(errmsg) + end + + local function ONADDONMSG(_, sender, _, prefix, msg, target) + if prefix == ctx.AIO_ClientPrefix and tostring(sender) == tostring(target) and #msg < 510 then + ctx.AIO_HandleIncomingMsg(msg, sender) + end + end + + RegisterServerEvent(30, ONADDONMSG) + + if ctx.AIO_FORCE_RELOAD_ON_STARTUP then + for _, v in ipairs(GetPlayersInWorld()) do + AIO.Handle(v, "AIO", "ForceReload") + end + end +end + +return M diff --git a/AIO_Server/aio_util.lua b/AIO_Server/aio_util.lua new file mode 100644 index 0000000..a93aa8b --- /dev/null +++ b/AIO_Server/aio_util.lua @@ -0,0 +1,26 @@ +local M = {} + +function M.basename(path) + assert(type(path) == "string", "#1 string expected") + return string.match(path, "([^/\\]*)$") or path +end + +function M.getMessageStoredSize(data) + if not data or not data.parts then + return 0 + end + local stored = 0 + for i = 1, data.parts.n do + local part = data.parts[i] + if part then + stored = stored + #part + end + end + return stored +end + +function M.isMessageExpired(stamp, now, cacheTimeMs) + return (now - stamp) >= cacheTimeMs +end + +return M diff --git a/AIO_Server/lualzw-zeros/LICENSE b/AIO_Server/lualzw-zeros/LICENSE index a8fc851..437e0dd 100644 --- a/AIO_Server/lualzw-zeros/LICENSE +++ b/AIO_Server/lualzw-zeros/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2016 +Copyright (c) 2016 Rochet2 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/AIO_Server/lualzw-zeros/README.md b/AIO_Server/lualzw-zeros/README.md index 755dc66..3ff4163 100644 --- a/AIO_Server/lualzw-zeros/README.md +++ b/AIO_Server/lualzw-zeros/README.md @@ -1,72 +1,7 @@ -# lualzw -A relatively fast LZW compression algorithm in pure lua +# lualzw (vendored) -# encoding and decoding -Lossless compression for any text. The more repetition in the text, the better. +Vendored from [Rochet2/lualzw](https://github.com/Rochet2/lualzw) **v1.1.0** (`6cbf8ab`, 2026-05-31). -16 bit encoding is used. So each 8 bit character is encoded as 16 bit. -This means that the dictionary size is 65280. +AIO loads this folder as `require("lualzw")` and uses the null-safe codec (`skip = { [0] = true }`), which matches the historical `zeros` branch wire format used by this project. -Any special characters like `äöå` that are represented with multiple characters are supported. The special characters are split up into single characters that are then encoded and decoded. - -While compressing, the algorithm checks if the result size gets over the input. If it does, then the input is not compressed and the algorithm returns the input prematurely as the compressed result. - -The `zeros` branch contains a version that does not add additional null `\0` characters to the input when encoding. Any existing null characters in input string are preserved as nulls however so make sure your input does not contain nulls. - -# usage -```lua -local lualzw = require("lualzw") - -local input = "foofoofoofoofoofoofoofoofoo" -local compressed = assert(lualzw.compress(input)) -local decompressed = assert(lualzw.decompress(compressed)) -assert(input == decompressed) -``` - -# errors -Returns nil and an error message when the algorithm fails to compress or decompress. - -# speed -Times are in seconds. -Both have the same generated input. -The values are an average of 10 tries. - -Note that compressing random generated inputs results usually in bigger result than original. In these cases the algorithms do not compress and return input instead and thus compression result is 100% of input. - -lualzw is at an advantage in cases where compression cannot be done as it stops prematurely and LibCompress does not. -Also lualzw is at an advantage in cases where compression can be done as it has a larger dictionary in use. - -Input: 1000000 random generated bytes converted into string - -algorithm|compress|decompress|result % of input ----------|--------|----------|------------- -lualzw|0.6622|0.0003|100 -LibCompress|2.1983|0.0024|100 - -Input: 1000000 random generated bytes in ASCII range converted into string - -algorithm|compress|decompress|result % of input ----------|--------|----------|------------- -lualzw|0.812|0.0022|100 -LibCompress|1.782|0.0007|100 - -Input: 1000000 random generated repeating bytes converted into string - -algorithm|compress|decompress|result % of input ----------|--------|----------|------------- -lualzw|0.3975|0.0262|4.5001 -LibCompress|0.3907|0.0264|6.6997 - -Input: 1000000 of same character - -algorithm|compress|decompress|result % of input ----------|--------|----------|------------- -lualzw|0.7045|0.0026|0.2829 -LibCompress|0.6418|0.0038|0.4241 - -Input: "ymn32h8hm8ekrwjkrn9f" repeated 50000 times. In total 1000000 bytes - -algorithm|compress|decompress|result % of input ----------|--------|----------|------------- -lualzw|0.4788|0.0088|1.2629 -LibCompress|0.4426|0.0093|1.8905 +Upstream docs, API, and benchmarks: https://github.com/Rochet2/lualzw diff --git a/AIO_Server/lualzw-zeros/lualzw.lua b/AIO_Server/lualzw-zeros/lualzw.lua index 62a5b32..6783064 100644 --- a/AIO_Server/lualzw-zeros/lualzw.lua +++ b/AIO_Server/lualzw-zeros/lualzw.lua @@ -22,145 +22,308 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] +local VERSION = "1.1.0" + local char = string.char +local byte = string.byte local type = type -local select = select local sub = string.sub local tconcat = table.concat -local basedictcompress = {} -local basedictdecompress = {} -for i = 0, 255 do - local ic, iic = char(i), char(i, 1) - basedictcompress[ic] = iic - basedictdecompress[iic] = ic +local function normalizeSkip(skip) + local skippedcharacters = {} + if skip then + for k, v in pairs(skip) do + if v == true then + if type(k) == "number" and k >= 0 and k <= 255 then + skippedcharacters[k] = true + end + elseif type(v) == "number" and v >= 0 and v <= 255 then + skippedcharacters[v] = true + end + end + end + return skippedcharacters end -local function dictAddA(str, dict, a, b) - if a >= 256 then - a, b = 1, b+1 - if b >= 256 then - dict = {} - b = 2 - end +local function normalizeControl(value, name, default) + if value == nil then + return default + end + if type(value) ~= "string" or #value ~= 1 then + error("invalid " .. name .. " control character") end - dict[str] = char(a,b) - a = a+1 - return dict, a, b + return value end -local function compress(input) - if type(input) ~= "string" then - return nil, "string expected, got "..type(input) +local function validateLimit(name, value) + if value == nil then + return true end - local len = #input - if len <= 1 then - return "u"..input + if type(value) ~= "number" or value < 0 then + return nil, "number expected for " .. name .. ", got " .. type(value) end + return true +end - local dict = {} - local a, b = 1, 2 - - local result = {"c"} - local resultlen = 1 - local n = 2 - local word = "" - for i = 1, len do - local c = sub(input, i, i) - local wc = word..c - if not (basedictcompress[wc] or dict[wc]) then - local write = basedictcompress[word] or dict[word] - if not write then - return nil, "algorithm error, could not fetch word" - end - result[n] = write - resultlen = resultlen + #write - n = n+1 - if len <= resultlen then - return "u"..input +local function buildState(skippedcharacters) + local function findNextNotSkipped(i) + repeat + if not skippedcharacters[i] then + return i end - dict, a, b = dictAddA(wc, dict, a, b) - word = c - else - word = wc - end + i = i + 1 + until false end - result[n] = basedictcompress[word] or dict[word] - resultlen = resultlen+#result[n] - n = n+1 - if len <= resultlen then - return "u"..input + + local basedictcompress = {} + local basedictdecompress = {} + + local firstNotSkipped = findNextNotSkipped(0) + local secondNotSkipped = findNextNotSkipped(firstNotSkipped + 1) + if firstNotSkipped > 255 or secondNotSkipped > 255 or firstNotSkipped == secondNotSkipped then + return nil, "invalid configuration, no character can be used in compression" end - return tconcat(result) -end -local function dictAddB(str, dict, a, b) - if a >= 256 then - a, b = 1, b+1 - if b >= 256 then - dict = {} - b = 2 + for i = 0, 255 do + local ic, iic = char(i), char(i, firstNotSkipped) + basedictcompress[ic] = iic + basedictdecompress[iic] = ic + end + + local function dictBump(dict, a, b) + if a >= 256 then + a, b = firstNotSkipped, findNextNotSkipped(b + 1) + if b >= 256 then + dict = {} + b = secondNotSkipped + end end + local code = char(a, b) + a = findNextNotSkipped(a + 1) + return dict, a, b, code end - dict[char(a,b)] = str - a = a+1 - return dict, a, b + + return { + skippedcharacters = skippedcharacters, + basedictcompress = basedictcompress, + basedictdecompress = basedictdecompress, + firstNotSkipped = firstNotSkipped, + secondNotSkipped = secondNotSkipped, + dictBump = dictBump, + } end -local function decompress(input) - if type(input) ~= "string" then - return nil, "string expected, got "..type(input) +local function createCodec(options) + options = options or {} + + local uncompressedControl = normalizeControl(options.uncompressed, "uncompressed", "u") + local compressedControl = normalizeControl(options.compressed, "compressed", "c") + if uncompressedControl == compressedControl then + error("uncompressed and compressed control characters must differ") end - if #input < 1 then - return nil, "invalid input - not a compressed string" + local state, err = buildState(normalizeSkip(options.skip)) + if not state then + error(err) end - local control = sub(input, 1, 1) - if control == "u" then - return sub(input, 2) - elseif control ~= "c" then - return nil, "invalid input - not a compressed string" + local prefixCost = 1 + + local function unwrap(input) + if #input < 1 then + return nil, "invalid input - not a compressed string" + end + return state, char(byte(input, 1)), sub(input, 2) end - input = sub(input, 2) - local len = #input - if len < 2 then - return nil, "invalid input - not a compressed string" + local function compress(input, max_input_size) + if type(input) ~= "string" then + return nil, "string expected, got " .. type(input) + end + + local ok, limitErr = validateLimit("max_input_size", max_input_size) + if not ok then + return nil, limitErr + end + + local len = #input + if max_input_size and len > max_input_size then + return nil, "input exceeds limit" + end + + if len <= 1 then + return uncompressedControl .. input + end + + local basedictcompress = state.basedictcompress + local dictBump = state.dictBump + local dict = {} + local a, b = state.firstNotSkipped, state.secondNotSkipped + local dictCode + + local result = {} + local resultlen = 0 + local n = 1 + local word = "" + for i = 1, len do + local c = char(byte(input, i)) + local wc = word .. c + if not (basedictcompress[wc] or dict[wc]) then + local write = basedictcompress[word] or dict[word] + if not write then + return nil, "algorithm error, could not fetch word" + end + result[n] = write + resultlen = resultlen + 2 + n = n + 1 + if len <= resultlen + prefixCost then + return uncompressedControl .. input + end + dict, a, b, dictCode = dictBump(dict, a, b) + dict[wc] = dictCode + word = c + else + word = wc + end + end + + result[n] = basedictcompress[word] or dict[word] + resultlen = resultlen + 2 + if len <= resultlen + prefixCost then + return uncompressedControl .. input + end + + return compressedControl .. tconcat(result) end - local dict = {} - local a, b = 1, 2 - - local result = {} - local n = 1 - local last = sub(input, 1, 2) - result[n] = basedictdecompress[last] or dict[last] - n = n+1 - for i = 3, len, 2 do - local code = sub(input, i, i+1) - local lastStr = basedictdecompress[last] or dict[last] - if not lastStr then + local function decompress(input, max_output_size, max_input_size, max_codes) + if type(input) ~= "string" then + return nil, "string expected, got " .. type(input) + end + + local ok, limitErr = validateLimit("max_output_size", max_output_size) + if not ok then + return nil, limitErr + end + + ok, limitErr = validateLimit("max_input_size", max_input_size) + if not ok then + return nil, limitErr + end + + ok, limitErr = validateLimit("max_codes", max_codes) + if not ok then + return nil, limitErr + end + + if max_input_size and #input > max_input_size then + return nil, "compressed input exceeds limit" + end + + local decodeState, control, body = unwrap(input) + if not decodeState then + return nil, control + end + + if max_input_size and #body > max_input_size then + return nil, "compressed input exceeds limit" + end + + if control == uncompressedControl then + if max_output_size and #body > max_output_size then + return nil, "decompressed output exceeds limit" + end + return body + elseif control ~= compressedControl then + return nil, "invalid input - not a compressed string" + end + + local len = #body + if len < 2 or len % 2 == 1 then + return nil, "invalid input - not a compressed string" + end + + local basedictdecompress = decodeState.basedictdecompress + local dictBump = decodeState.dictBump + local dict = {} + local a, b = decodeState.firstNotSkipped, decodeState.secondNotSkipped + local dictCode + local codeCount = 0 + + local function bumpDictionary(value) + if max_codes and codeCount >= max_codes then + return nil, "decompression step limit exceeded" + end + dict, a, b, dictCode = dictBump(dict, a, b) + dict[dictCode] = value + codeCount = codeCount + 1 + return true + end + + local result = {} + local n = 1 + local outputlen = 0 + local last = sub(body, 1, 2) + local firstStr = basedictdecompress[last] or dict[last] + if not firstStr then return nil, "could not find last from dict. Invalid input?" end - local toAdd = basedictdecompress[code] or dict[code] - if toAdd then - result[n] = toAdd - n = n+1 - dict, a, b = dictAddB(lastStr..sub(toAdd, 1, 1), dict, a, b) - else - local tmp = lastStr..sub(lastStr, 1, 1) - result[n] = tmp - n = n+1 - dict, a, b = dictAddB(tmp, dict, a, b) - end - last = code + result[n] = firstStr + outputlen = outputlen + #firstStr + if max_output_size and outputlen > max_output_size then + return nil, "decompressed output exceeds limit" + end + n = n + 1 + + for i = 3, len, 2 do + local inputCode = sub(body, i, i + 1) + local lastStr = basedictdecompress[last] or dict[last] + if not lastStr then + return nil, "could not find last from dict. Invalid input?" + end + local toAdd = basedictdecompress[inputCode] or dict[inputCode] + if toAdd then + outputlen = outputlen + #toAdd + if max_output_size and outputlen > max_output_size then + return nil, "decompressed output exceeds limit" + end + result[n] = toAdd + n = n + 1 + local bumped, bumpErr = bumpDictionary(lastStr .. sub(toAdd, 1, 1)) + if not bumped then + return nil, bumpErr + end + else + local tmp = lastStr .. sub(lastStr, 1, 1) + outputlen = outputlen + #tmp + if max_output_size and outputlen > max_output_size then + return nil, "decompressed output exceeds limit" + end + result[n] = tmp + n = n + 1 + local bumped, bumpErr = bumpDictionary(tmp) + if not bumped then + return nil, bumpErr + end + end + last = inputCode + end + + return tconcat(result) end - return tconcat(result) + + return { + compress = compress, + decompress = decompress, + configure = createCodec, + _VERSION = VERSION, + uncompressed = uncompressedControl, + compressed = compressedControl, + } end -lualzw = { - compress = compress, - decompress = decompress, -} -return lualzw +-- AIO uses the former "zeros" branch wire format (no null bytes in dictionary codes). +local codec = createCodec({ skip = { [0] = true } }) +lualzw = codec +return codec diff --git a/AIO_Server/queue.lua b/AIO_Server/queue.lua index 3184165..40f7058 100644 --- a/AIO_Server/queue.lua +++ b/AIO_Server/queue.lua @@ -1,5 +1,5 @@ local Queue = {} -function Queue.__index(que, key) +function Queue.__index(_, key) return Queue[key] end @@ -58,9 +58,9 @@ function Queue.size(que) end function Queue.clear(que) - local l, r = self:getrange() + local l, r = que:getrange() for i = l, r do - que[idx] = nil + que[i] = nil end que.first, que.last = 0, -1 end diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..28d1fd1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to the AIO core (server/client `AIO.lua` and shared modules) are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [1.76] - 2026-05-31 + +### Added + +- Shared modules: `aio_framing`, `aio_reassembler`, `aio_rpc`, `aio_util` (server and client copies kept in sync; CI enforces `diff`). +- Asymmetric modules: `aio_server_pipeline` (server), `aio_client_ui` (client); `AIO.lua` delegates to them while staying identical on both sides. +- Shared `aio_core` module (pcall wrapper, block dispatch); unit tests; CI Luacheck on `AIO.lua` (syntax + unused locals). +- Pure Lua test suite under `tests/` and GitHub Actions CI (unit tests, Luacheck, module sync checks). +- `SECURITY.md`, `DEPENDENCIES.md`, `CHANGELOG.md`, and `FUTURE_WORK.md`. +- `scripts/run_luacheck_local.lua` for running Luacheck on Windows without a full luarocks install. + +### Changed + +- **Version** `AIO_VERSION` is now `1.76` (was `1.75`). Server and client must match; clients show a mismatch warning until versions align. +- Vendored **lualzw** upgraded to [v1.1.0](https://github.com/Rochet2/lualzw/releases/tag/v1.1.0) with `skip = { [0] = true }` for the former `zeros` wire format. +- `AIO_FORCE_RELOAD_ON_STARTUP` default is **`true`** (reload-all on server startup is opt-out). +- Examples: KaevStatTest server messages translated to English. + +### Fixed + +- `Queue.clear` in both `queue.lua` files (wrong variable names; would error if called). +- Client cache / version handling and related edge cases from the 1.76 maintenance pass. +- Windows path handling for addon basenames (`aio_util.basename`). + +### Upgrade notes + +1. Deploy **both** `AIO_Server` and `AIO_Client` together so `AIO_VERSION` matches. +2. After the lualzw upgrade, players with a stale client cache may need **`/aio reset`** (or your usual cache clear) if compressed addons fail to load. +3. Client `.toc` load order must list the new modules before `AIO.lua` (see `DEPENDENCIES.md`). + +## [1.75] and earlier + +See git history and [releases](https://github.com/Rochet2/AIO/releases) before this changelog was introduced. diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md new file mode 100644 index 0000000..bf59bb1 --- /dev/null +++ b/DEPENDENCIES.md @@ -0,0 +1,74 @@ +# Dependencies + +AIO vendors its dependencies inside `AIO_Server/` and `AIO_Client/`. You do not need to install them separately for normal use. + +## First-party modules + +| Module | Server path | Client path | Notes | +|--------|-------------|-------------|-------| +| AIO core | `AIO_Server/AIO.lua` | `AIO_Client/AIO.lua` | Transport, addon push, handlers; must stay identical | +| aio_framing | `AIO_Server/aio_framing.lua` | `AIO_Client/aio_framing.lua` | Uint16 wire encoding and message split/parse | +| aio_reassembler | `AIO_Server/aio_reassembler.lua` | `AIO_Client/aio_reassembler.lua` | Long-message reassembly, TTL, byte caps | +| aio_rpc | `AIO_Server/aio_rpc.lua` | `AIO_Client/aio_rpc.lua` | Block RPC over Smallfolk | +| aio_util | `AIO_Server/aio_util.lua` | `AIO_Client/aio_util.lua` | Shared helpers (basename, cache accounting) | +| aio_core | `AIO_Server/aio_core.lua` | `AIO_Client/aio_core.lua` | Shared pcall wrapper and block dispatch rules for `AIO.lua` | +| aio_server_pipeline | `AIO_Server/aio_server_pipeline.lua` | — | Server-only: addon build, init push, addon channel | +| aio_client_ui | — | `AIO_Client/aio_client_ui.lua` | Client-only: cache, SavedVariables, init/reload UI | +| Queue | `AIO_Server/queue.lua` | `AIO_Client/queue.lua` | Based on PIL 11.4, with AIO modifications | +| Smallfolk | `AIO_Server/Dep_Smallfolk/` | `AIO_Client/Dep_Smallfolk/` | Wire serialization | +| lualzw | `AIO_Server/lualzw-zeros/` | `AIO_Client/lualzw-zeros/` | [Rochet2/lualzw](https://github.com/Rochet2/lualzw) **v1.1.0** (2026-05-31), configured with `skip = { [0] = true }` for the former `zeros` branch wire format | + +## Server-only dependencies + +| Module | Path | Upstream | License | +|--------|------|----------|---------| +| crc32lua | `AIO_Server/Dep_crc32lua/` | [davidm/lua-digest-crc32lua](https://github.com/davidm/lua-digest-crc32lua) | See `COPYRIGHT` | +| LuaSrcDiet | `AIO_Server/Dep_LuaSrcDiet/` | [LuaSrcDiet](http://luasrcdiet.luaforge.net/) | See `COPYRIGHT`, `COPYRIGHT_Lua51` | + +## Client-only dependencies + +| Module | Path | Upstream | License | +|--------|------|----------|---------| +| LibStub | `AIO_Client/Dep_LibWindow-1.1/LibStub.lua` | [LibStub](https://www.wowace.com/projects/libstub) | Public domain | +| LibWindow-1.1 | `AIO_Client/Dep_LibWindow-1.1/LibWindow-1.1/` | [LibWindow-1.1 r12](https://www.wowace.com/projects/libwindow-1-1) | See addon TOC | + +LibWindow is loaded for `AIO.SavePosition()` frame persistence on the client. + +## WoW addon load order + +The client `.toc` file lists Lua files in load order. Each file must be loaded before anything that `require()`s it: + +``` +Dep_LibWindow-1.1\LibStub.lua +Dep_LibWindow-1.1\LibWindow-1.1\LibWindow-1.1.lua +Dep_Smallfolk\smallfolk.lua → require("smallfolk") +lualzw-zeros\lualzw.lua → require("lualzw") +queue.lua → require("queue") +aio_util.lua → require("aio_util") +aio_framing.lua → require("aio_framing") +aio_reassembler.lua → require("aio_reassembler") +aio_rpc.lua → require("aio_rpc") +aio_core.lua → require("aio_core") +aio_client_ui.lua → require("aio_client_ui") from AIO.lua (client) +AIO.lua → require("AIO") in other addons; loads deps above +``` + +Server-side Eluna resolves `require("smallfolk")`, `require("lualzw")`, `require("queue")`, `require("LuaSrcDiet")`, and `require("crc32lua")` from paths under `lua_scripts/` (same folder layout as `AIO_Server/`). + +## Upgrading vendored libraries + +1. Replace the vendored folder with the new upstream version. +2. Re-test addon push, client cache, compression, and message round-trips. +3. Run `lua tests/run.lua` and CI locally if possible. +4. Update this file with the new upstream URL or version if known. + +Note the commit or release you vendor when upgrading. AIO release versions are listed in [CHANGELOG.md](CHANGELOG.md) (`AIO_VERSION` in `AIO.lua`). + +## Development / CI tools (not shipped to players) + +| Tool | Purpose | +|------|---------| +| [Luacheck](https://github.com/luarocks/luacheck) | Static analysis (see `.luacheckrc`) | +| Lua 5.1 | Runs `tests/run.lua` outside WoW | + +See `.github/workflows/ci.yml` for the CI setup. diff --git a/Examples/KaevStatTest/Server.lua b/Examples/KaevStatTest/Server.lua index 9da6cb1..6d00e1c 100644 --- a/Examples/KaevStatTest/Server.lua +++ b/Examples/KaevStatTest/Server.lua @@ -44,7 +44,7 @@ end function MyHandlers.AttributesIncrease(player, statId) if (player:IsInCombat()) then - player:SendBroadcastMessage("Du kannst während einem Kampfes keine Attributspunkte verteilen.") + player:SendBroadcastMessage("You can't assign attribute points during combat.") else local guid = player:GetGUIDLow() local spend, left = AttributesPointsSpend[guid], AttributesPointsLeft[guid] @@ -55,7 +55,7 @@ function MyHandlers.AttributesIncrease(player, statId) return end if (left <= 0) then - player:SendBroadcastMessage("Du hast nicht genuegend Attributspunkte.") + player:SendBroadcastMessage("You don't have enough attribute points.") else AttributesPointsLeft[guid] = left - 1 spend[statId] = spend[statId] + 1 @@ -72,7 +72,7 @@ end function MyHandlers.AttributesDecrease(player, statId) if (player:IsInCombat()) then - player:SendBroadcastMessage("Du kannst während einem Kampfes keine Attributspunkte verteilen.") + player:SendBroadcastMessage("You can't assign attribute points during combat.") else local guid = player:GetGUIDLow() local spend, left = AttributesPointsSpend[guid], AttributesPointsLeft[guid] @@ -83,7 +83,7 @@ function MyHandlers.AttributesDecrease(player, statId) return end if (spend[statId] <= 0) then - player:SendBroadcastMessage("Es sind keine Punkte auf diesem Attribut verteilt.") + player:SendBroadcastMessage("You didn't spend any attribute points on this attribute.") else AttributesPointsLeft[guid] = left + 1 spend[statId] = spend[statId] - 1 diff --git a/FUTURE_WORK.md b/FUTURE_WORK.md new file mode 100644 index 0000000..c41c867 --- /dev/null +++ b/FUTURE_WORK.md @@ -0,0 +1,65 @@ +# Future work + +Ideas and larger tasks that are **out of scope** for the 1.76 maintenance release but worth tracking. Not a commitment to implement. + +## Product / protocol features + +### Multi-file addons ([PR #14](https://github.com/Rochet2/AIO/pull/14) direction) + +- Today: one Lua blob per addon name; `AddAddon` / `AddAddonCode` and client cache keys assume a single string per addon. +- Desired: multiple files per logical addon, optional namespaces, less boilerplate than manual `require` strings in one blob. +- **Backwards compatibility** is the hard part. Reasonable approaches: + - **Dual protocol**: old clients keep single-blob cache keys; new clients understand a manifest (file list + hashes) while server can still serve legacy blobs. + - **Optional namespace layer** on top of current API without changing wire cache format (Rochet2-style lighter API only). +- Do **not** merge PR #14 as-is: it changes cache keys and breaks existing `AddAddon` / `AddAddonCode` callers. + +### Further split of `AIO.lua` + +- **Done (1.76):** server addon pipeline (`aio_server_pipeline.lua`) and client cache/UI (`aio_client_ui.lua`) extracted; `AIO.lua` stays identical on both sides and delegates with `require`. +- Remaining in `AIO.lua`: shared config, messaging, RPC wiring, `AIO_HandleBlock`, slash commands, handler registration. + +### Eluna / C++ CAIO + +- Document or test against [SaiFi0102’s CAIO fork](https://github.com/SaiFi0102/TrinityCore/blob/CAIO-3.3.5/CAIO_README.md) if users rely on non-Eluna paths. + +## Repository setup + +| Improvement | Why | +|-------------|-----| +| **GitHub Releases** tied to `AIO_VERSION` | Tag `v1.76` with `CHANGELOG.md` excerpt so server owners know what changed. | +| **PR / issue templates** | `.github/PULL_REQUEST_TEMPLATE.md` reminding contributors to sync server/client copies and run `tests/run.lua`. | +| **Dependabot or yearly vendored audit** | Remind to bump Smallfolk, lualzw, LuaSrcDiet; record versions in `DEPENDENCIES.md`. | +| **Optional: pre-commit hook** | Run `tests/run.lua` + `scripts/run_luacheck_local.lua` (or `luacheck` on Linux) before push. | +| **Squash / linear history on `master`** | Many small CI-fix commits on feature branches; squash on merge for readable `master`. | +| **`.github/workflows` on all branches** | CI already runs on PRs; consider `workflow_dispatch` for manual re-runs. | + +## Test coverage + +### Covered today (pure Lua, no WoW) + +- `queue`, Smallfolk, `aio_framing`, `aio_util`, `aio_reassembler`, lualzw, `aio_rpc`, `aio_core` (pcall + `HandleBlock` rules; see `tests/`). +- **`AIO.lua` stub harness** (`tests/wow_stub.lua`, `tests/aio_integration_util.lua`): server integration on **Lua 5.1–5.4**, client integration on **5.1 only** (`tests/lua_compat.lua`). Client harness does not replace `_G.print` on newer Lua. `aio_client_ui` is preloaded with a minimal stub until real UI load is debugged. + +### Gaps (high value) + +| Area | Notes | +|------|--------| +| **`AIO.lua` integration (deeper)** | Cache hit/miss, version mismatch, full client UI (`aio_client_ui` + `ADDON_LOADED`), `loadstring` addon delivery—in-game or richer stub. | +| **Server-only paths** | LuaSrcDiet obfuscation, crc32, compression flags—could unit-test with fixtures if extracted. | +| **Client-only paths** | SavedVariables, `/aio` commands, `ForceReload` / `ForceReset`—mostly UI; manual or headless WoW. | +| **Fuzz / property tests** | Random round-trips through framing + reassembler + Smallfolk at scale. | +| **Regression fixtures** | Golden compressed addon blobs per lualzw version so upgrades cannot silently break cache. | + +### Not worth chasing soon + +- Linting `Examples/` or vendored `Dep_*` trees. + +## Luacheck on `AIO.lua` + +- CI runs Luacheck on `AIO.lua` (**syntax only** for now; categories 2–6 ignored). Tighten 2.x (unused locals) incrementally once warnings are cleared locally with Lua 5.1. +- Next: add Eluna/WoW globals and enable 3.x; then unused locals (2.x) and line length (611). + +## Documentation + +- Per-addon author guide: minimal server + client example using 1.76 module layout. +- Troubleshooting: version mismatch, cache reset after lualzw upgrade, `.toc` order mistakes. diff --git a/README.md b/README.md index bbe546d..fa5bfff 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,39 @@ Backlink: https://github.com/Rochet2/AIO - Make sure you have [Eluna Lua Engine](https://github.com/ElunaLuaEngine/Eluna) - Copy the `AIO_Client` to your `WoW_installation_folder/Interface/AddOns/` - Copy the `AIO_Server` to your `server_root/lua_scripts/` -- See configuration settings on AIO.lua file. You can tweak both the server and client file respectively -- When developing an addon it is recommended to have AIO_ENABLE_PCALL off and sometimes you may need AIO_ENABLE_DEBUG_MSGS on to see some information about what is going on. +- See configuration settings in `AIO.lua` on both server and client. Important defaults in 1.76: + - `AIO_FORCE_RELOAD_ON_STARTUP = true` — on server startup, all online players are asked to reload UI (set `false` to disable). + - `AIO_VERSION` must match between server and client (currently **1.76**; see [CHANGELOG.md](CHANGELOG.md)). +- When developing an addon it is recommended to have `AIO_ENABLE_PCALL` off and sometimes you may need `AIO_ENABLE_DEBUG_MSGS` on to see some information about what is going on. +- See [SECURITY.md](SECURITY.md) for the threat model and recommended production settings. +- See [DEPENDENCIES.md](DEPENDENCIES.md) for vendored libraries and client load order. +- See [FUTURE_WORK.md](FUTURE_WORK.md) for planned or deferred improvements. + +# Testing +Pure Lua modules can be tested outside WoW: + +```sh +lua5.1 tests/run.lua +``` + +Unit tests cover shared modules (`aio_rpc`, `aio_core`, framing, queue, etc.) on any Lua version you run. + +Integration tests load `AIO.lua` via `tests/wow_stub.lua` (minimal Eluna/WoW globals): + +| Suite | Lua versions | +|-------|----------------| +| Server (`test_aio_integration_server.lua`) | 5.1 – 5.4 | +| Client (`test_aio_integration_client.lua`) | 5.1 only | + +CI runs the full suite on 5.1 and re-runs server integration on 5.2–5.4. + +On Windows (without luarocks/luacheck on PATH), you can run the same Luacheck scope as CI: + +```sh +lua scripts/run_luacheck_local.lua +``` + +CI (see `.github/workflows/ci.yml`) runs unit tests, Luacheck on shared modules, `AIO.lua`, and tests, and `diff` checks that these server/client pairs stay identical: `AIO.lua`, `aio_core.lua`, `aio_util.lua`, `aio_framing.lua`, `aio_reassembler.lua`, `aio_rpc.lua`, and `queue.lua`. # About AIO works so that the server and client have their own lua scripts that handle sending and receiving messages from and to eachother. @@ -95,7 +126,7 @@ end -- according to settings in AIO.lua. The addon is cached on client side and will -- be updated only when needed. 'name' is an unique name for the addon, usually -- you can use the file name or addon name there. Do note that short names are --- better since they are sent back and forth to indentify files. +-- better since they are sent back and forth to identify files. -- The function only exists on server side. Only on main lua state. -- You should call this function only on startup to ensure everyone gets the same -- addons and no addon is duplicate. @@ -124,7 +155,7 @@ handlertable = AIO.AddHandlers(name, handlertable) -- Only on main lua state. -- Adds a new callback function that is called if a message with the given --- name is recieved. All parameters the sender sends in the message will +-- name is received. All parameters the sender sends in the message will -- be passed to func when called. -- Example message: AIO.Msg():Add(name, ...):Send() -- AIO.AddHandlers uses AIO.RegisterEvent internally, so same name can not be used on both. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d5e0616 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,80 @@ +# Security + +AIO is a server–client messaging layer for Eluna and WoW. This document describes the threat model, built-in protections, and what you must enforce in your own handler code. + +## Threat model + +| Direction | Trust level | What happens | +|-----------|-------------|--------------| +| Server → client (addon code) | Server is fully trusted | Addon Lua is compressed, optionally obfuscated, and executed on the client with `loadstring`. This is equivalent to remote code execution by design. | +| Server → client (handler messages) | Server is trusted | Deserialized data is passed to registered handlers. | +| Client → server (handler messages) | Client is **not** trusted | Data is deserialized only (no `loadstring`). Handlers must validate every field before use. | + +Anyone who can modify server scripts can push arbitrary code to connected clients. Treat server-side AIO scripts like production server code with full review and access control. + +## Built-in protections + +AIO includes several safeguards (see `AIO.lua` config block): + +- **Safe deserialization** — Smallfolk does not use `loadstring` when loading messages from the wire. +- **pcall wrapping** — Handler execution is wrapped by default (`AIO_ENABLE_PCALL`). +- **Instruction timeout** — Server-side message handling can abort runaway code (`AIO_TIMEOUT_INSTRUCTIONCOUNT`). +- **Message cache limits** — Per-player caps on incomplete and stored message data (`AIO_MSG_CACHE_SPACE`, `AIO_MSG_CACHE_TIME`). +- **Init rate limiting** — Limits how often full addon lists can be requested (`AIO_UI_INIT_DELAY`). +- **Version check** — Client and server must share the same `AIO_VERSION`. +- **Addon channel filter** — Server rejects addon messages with wrong prefix, sender/target mismatch, or length ≥ 510. + +These reduce abuse and accidental hangs but do **not** replace input validation in your handlers. + +## Your responsibilities + +### Validate all client input + +Never trust types or ranges from the client. Example checks: + +```lua +function MyHandlers.DoThing(player, statId, amount) + if type(statId) ~= "number" or statId < 1 or statId > 5 then + return + end + if type(amount) ~= "number" or amount ~= math.floor(amount) or amount < 1 or amount > 100 then + return + end + -- safe to use statId and amount +end +``` + +See `Examples/KaevStatTest/Server.lua` for a fuller pattern (bounds checks, nil guards, combat gates). + +Avoid copying `Examples/PingPong.lua` as a server handler template — it echoes client data without validation. + +### Treat server addons as signed code + +- Review all code passed to `AIO.AddAddon()` / `AIO.AddAddonCode()`. +- Disable obfuscation (`AIO_CODE_OBFUSCATE = false`) while debugging so stack traces stay useful. +- Do not load untrusted third-party server scripts into AIO without review. + +### Production settings + +Recommended server defaults for live realms: + +| Setting | Recommended | +|---------|-------------| +| `AIO_ENABLE_PCALL` | `true` | +| `AIO_ENABLE_DEBUG_MSGS` | `false` | +| `AIO_ENABLE_MSGPRINT` | `false` | +| `AIO_TIMEOUT_INSTRUCTIONCOUNT` | non-zero (default `1e8`) | +| `AIO_MSG_CACHE_SPACE` | tuned to your traffic | +| `AIO_FORCE_RELOAD_ON_STARTUP` | `true` by default; set to `false` if you do not want all online clients to reload when the server script reloads | + +Client-side errors are sent when `AIO_ERROR_LOG` is enabled on the client. The server logs them via `PrintInfo` when received. + +Client slash commands (`/aio pcall`, `/aio debug`, etc.) change runtime behavior. Restrict who can use them on production clients if that matters for your setup. + +### Client-side limits + +There is no instruction timeout on the client. A malicious or buggy **server** addon can still freeze the client Lua VM. Server-side review is the primary control. + +## Reporting issues + +If you find a security issue in AIO itself, report it privately to the maintainers rather than opening a public issue with exploit details. diff --git a/scripts/ci_luacheck.sh b/scripts/ci_luacheck.sh new file mode 100644 index 0000000..2ff56e0 --- /dev/null +++ b/scripts/ci_luacheck.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Same file list as .github/workflows/ci.yml (for local use on Unix). +set -e +luacheck --config .luacheckrc \ + AIO_Server/queue.lua AIO_Client/queue.lua \ + AIO_Server/aio_util.lua AIO_Client/aio_util.lua \ + AIO_Server/aio_framing.lua AIO_Client/aio_framing.lua \ + AIO_Server/aio_reassembler.lua AIO_Client/aio_reassembler.lua \ + AIO_Server/aio_rpc.lua AIO_Client/aio_rpc.lua \ + AIO_Server/aio_core.lua AIO_Client/aio_core.lua \ + tests/run.lua tests/test_queue.lua tests/test_smallfolk.lua \ + tests/test_framing.lua tests/test_util.lua tests/test_stored.lua \ + tests/test_path_legacy.lua tests/test_reassembler.lua tests/test_lualzw.lua \ + tests/test_aio_rpc.lua tests/test_aio_core.lua \ + tests/lua_compat.lua tests/wow_stub.lua tests/aio_integration_util.lua \ + tests/test_aio_integration_server.lua tests/test_aio_integration_client.lua +luacheck --config .luacheckrc \ + AIO_Server/aio_server_pipeline.lua AIO_Client/aio_client_ui.lua diff --git a/scripts/run_luacheck_local.lua b/scripts/run_luacheck_local.lua new file mode 100644 index 0000000..e803f2a --- /dev/null +++ b/scripts/run_luacheck_local.lua @@ -0,0 +1,101 @@ +#!/usr/bin/env lua +-- Local luacheck runner (no luarocks/lfs): uses vendored luacheck under .tmp/luacheck. +local root = arg[0]:match("^(.*)[/\\]") or "." +root = root .. "/../" +package.path = root .. ".tmp/luacheck/src/?.lua;" .. package.path + +package.preload["lfs"] = function() + return { + attributes = function(path, attr) + local f = io.open(path, "r") + if not f then + return nil + end + f:close() + if attr == "mode" then + return "file" + end + if attr == "modification" then + return 0 + end + return {} + end, + dir = function(path) + return function() + return nil + end + end, + currentdir = function() + return (os.getenv("CD") or "."):gsub("\\", "/") + end, + mkdir = function() + return true + end, + } +end + +local config = require("luacheck.config") +local format = require("luacheck.format") +local luacheck = require("luacheck") + +local cfg, err = config.load_config(root .. ".luacheckrc", root:sub(1, -2)) +if not cfg then + io.stderr:write("config: " .. tostring(err) .. "\n") + os.exit(2) +end + +local files = { + "AIO_Server/queue.lua", + "AIO_Client/queue.lua", + "AIO_Server/aio_util.lua", + "AIO_Client/aio_util.lua", + "AIO_Server/aio_framing.lua", + "AIO_Client/aio_framing.lua", + "AIO_Server/aio_reassembler.lua", + "AIO_Client/aio_reassembler.lua", + "AIO_Server/aio_rpc.lua", + "AIO_Client/aio_rpc.lua", + "AIO_Server/aio_core.lua", + "AIO_Client/aio_core.lua", + "AIO_Server/aio_server_pipeline.lua", + "AIO_Client/aio_client_ui.lua", + "AIO_Server/AIO.lua", + "AIO_Client/AIO.lua", + "tests/run.lua", + "tests/test_queue.lua", + "tests/test_smallfolk.lua", + "tests/test_framing.lua", + "tests/test_util.lua", + "tests/test_stored.lua", + "tests/test_path_legacy.lua", + "tests/test_reassembler.lua", + "tests/test_lualzw.lua", + "tests/test_aio_rpc.lua", + "tests/test_aio_core.lua", + "tests/lua_compat.lua", + "tests/wow_stub.lua", + "tests/aio_integration_util.lua", + "tests/test_aio_integration_server.lua", + "tests/test_aio_integration_client.lua", +} + +local paths = {} +for i = 1, #files do + paths[i] = root .. files[i] +end + +local report = luacheck.check_files(paths, cfg.options) +local out = format.format(report, paths, { formatter = "default", color = false, codes = true }) +if out ~= "" then + io.stdout:write(out, "\n") +end + +local failed = false +for _, file_report in ipairs(report) do + if file_report.fatal or #file_report > 0 then + failed = true + break + end +end + +os.exit(failed and 1 or 0) diff --git a/tests/aio_integration_util.lua b/tests/aio_integration_util.lua new file mode 100644 index 0000000..9d0b9d7 --- /dev/null +++ b/tests/aio_integration_util.lua @@ -0,0 +1,75 @@ +local M = {} + +M.wow_stub = dofile((debug.getinfo(1, "S").source:match("@?(.*[/\\])") or "./") .. "wow_stub.lua") + +local script_path = debug.getinfo(1, "S").source:match("@?(.*[/\\])") or "./" +M.root = script_path .. "../" +M.base_package_path = package.path + +function M.add_path(dir) + package.path = dir .. "?.lua;" .. package.path +end + +function M.aio_prefixes() + local p = string.sub("AIO", 1, 16) + return string.sub("S" .. p, 1, 16), string.sub("C" .. p, 1, 16) +end + +function M.short_wire(wire) + local framing = require("aio_framing") + return framing.SHORT_TAG .. wire +end + +function M.load_aio_from(side_dir, deps_fn, install_env) + M.wow_stub.clear_packages() + package.path = M.base_package_path + if install_env then + install_env() + end + M.add_path(M.root .. side_dir) + M.add_path(M.root .. side_dir .. "Dep_Smallfolk/") + M.add_path(M.root .. side_dir .. "lualzw-zeros/") + if deps_fn then + deps_fn(function(dir) + M.add_path(M.root .. dir) + end) + end + return require("AIO") +end + +function M.last_addon_msg(dir) + for i = #M.wow_stub.addon_messages, 1, -1 do + local m = M.wow_stub.addon_messages[i] + if m.dir == dir then + return m + end + end + return nil +end + +-- Count calls to the built-in print without replacing _G.print (Lua 5.4-safe). +function M.count_builtin_print_calls(fn) + local builtin_print = print + local n = 0 + local old_hook, old_mask, old_count = debug.gethook() + debug.sethook(function(event) + if event == "call" then + local info = debug.getinfo(2, "f") + if info and info.func == builtin_print then + n = n + 1 + end + end + end, "c") + local ok, err = pcall(fn) + if old_hook then + debug.sethook(old_hook, old_mask, old_count) + else + debug.sethook() + end + if not ok then + error(err) + end + return n +end + +return M diff --git a/tests/lua_compat.lua b/tests/lua_compat.lua new file mode 100644 index 0000000..6d7e1c9 --- /dev/null +++ b/tests/lua_compat.lua @@ -0,0 +1,26 @@ +--[[ + Lua version policy for AIO tests (see tests/run.lua): + - Server AIO integration: Lua 5.1 through 5.4 + - Client AIO integration: Lua 5.1 only +]] + +local M = {} + +function M.version_number() + local maj, min = _VERSION:match("^Lua (%d+)%.(%d+)") + if not maj then + return nil + end + return tonumber(maj) * 100 + tonumber(min) +end + +function M.supports_server_aio_tests() + local v = M.version_number() + return v ~= nil and v >= 501 and v <= 504 +end + +function M.supports_client_aio_tests() + return M.version_number() == 501 +end + +return M diff --git a/tests/run.lua b/tests/run.lua new file mode 100644 index 0000000..830da6b --- /dev/null +++ b/tests/run.lua @@ -0,0 +1,70 @@ +#!/usr/bin/env lua + +local script_path = debug.getinfo(1, "S").source:match("@?(.*[/\\])") or "./" +local root = script_path .. "../" +local compat = dofile(script_path .. "lua_compat.lua") + +local function add_path(dir) + package.path = dir .. "?.lua;" .. package.path +end + +add_path(root .. "AIO_Server/Dep_Smallfolk/") +add_path(root .. "AIO_Server/lualzw-zeros/") +add_path(root .. "AIO_Server/") + +local passed, failed, skipped = 0, 0, 0 + +function test(name, fn) + local ok, err = pcall(fn) + if ok then + passed = passed + 1 + print(" ok: " .. name) + else + failed = failed + 1 + print("FAIL: " .. name) + print(" " .. tostring(err)) + end +end + +function skip(name, reason) + skipped = skipped + 1 + print(" skip: " .. name .. " (" .. reason .. ")") +end + +function assert_eq(a, b, msg) + if a ~= b then + error((msg or "assert_eq failed") .. ": got " .. tostring(a) .. ", expected " .. tostring(b)) + end +end + +function assert_true(v, msg) + if not v then + error(msg or "assert_true failed") + end +end + +dofile(script_path .. "test_queue.lua") +dofile(script_path .. "test_smallfolk.lua") +dofile(script_path .. "test_framing.lua") +dofile(script_path .. "test_util.lua") +dofile(script_path .. "test_stored.lua") +dofile(script_path .. "test_path_legacy.lua") +dofile(script_path .. "test_reassembler.lua") +dofile(script_path .. "test_lualzw.lua") +dofile(script_path .. "test_aio_rpc.lua") +dofile(script_path .. "test_aio_core.lua") + +if compat.supports_server_aio_tests() then + dofile(script_path .. "test_aio_integration_server.lua") +else + skip("server AIO integration suite", "requires Lua 5.1-5.4, got " .. _VERSION) +end + +if compat.supports_client_aio_tests() then + dofile(script_path .. "test_aio_integration_client.lua") +else + skip("client AIO integration suite", "requires Lua 5.1, got " .. _VERSION) +end + +print(string.format("\n%d passed, %d failed, %d skipped (%s)", passed, failed, skipped, _VERSION)) +os.exit(failed > 0 and 1 or 0) diff --git a/tests/test_aio_core.lua b/tests/test_aio_core.lua new file mode 100644 index 0000000..9576d20 --- /dev/null +++ b/tests/test_aio_core.lua @@ -0,0 +1,127 @@ +-- luacheck: ignore +local unpack_fn = _G.unpack +if not unpack_fn then + unpack_fn = table.unpack -- luacheck: ignore 113 +end +local aio_core = require("aio_core") + +test("aio_core extract_n", function() + local n, a, b = aio_core.extract_n(true, "x", 3) + assert_eq(n, 3) + assert_eq(a, true) + assert_eq(b, "x") +end) + +test("aio_core pcall returns values on success", function() + local fn = aio_core.make_pcall({ + unpack = unpack_fn, + pcall = pcall, + xpcall = xpcall, + enable_pcall = true, + server = true, + enable_traceback = false, + on_error = function() + error("on_error should not run") + end, + }) + local sum = fn(function(a, b) + return a + b + end, 2, 5) + assert_eq(sum, 7) +end) + +test("aio_core pcall returns nil on failure", function() + local err_msg + local fn = aio_core.make_pcall({ + unpack = unpack_fn, + pcall = pcall, + xpcall = xpcall, + enable_pcall = true, + server = true, + enable_traceback = false, + on_error = function(err) + err_msg = err + end, + }) + local result = fn(function() + error("boom") + end) + assert_eq(result, nil) + assert_true(err_msg and string.find(err_msg, "boom", 1, true)) +end) + +test("aio_core pcall disabled calls through", function() + local fn = aio_core.make_pcall({ + unpack = unpack_fn, + pcall = pcall, + xpcall = xpcall, + enable_pcall = false, + server = false, + enable_traceback = false, + on_error = function() + error("on_error should not run") + end, + }) + assert_eq(fn(function() + return 99 + end), 99) +end) + +test("aio_core handle_block queues pre-init messages", function() + local seen = {} + local client_state = { AIO_INITED = false, AIO_VERSION_MISMATCH = false } + local handle_block = aio_core.make_handle_block({ + unpack = unpack_fn, + client_state = client_state, + block_handlers = { + Ping = function(_, msg) + seen[#seen + 1] = msg + end, + AIO = function() end, + }, + server = false, + max_block_args = 15, + debug = function() end, + }) + + handle_block(1, { 1, "Ping", "early" }) + assert_eq(#seen, 0) + + client_state.AIO_INITED = true + handle_block(1, { 1, "AIO", "Init" }) + assert_eq(#seen, 1) + assert_eq(seen[1], "early") +end) + +test("aio_core handle_block ignores when version mismatch", function() + local seen = {} + local client_state = { AIO_INITED = true, AIO_VERSION_MISMATCH = true } + local handle_block = aio_core.make_handle_block({ + unpack = unpack_fn, + client_state = client_state, + block_handlers = { + Ping = function(_, msg) + seen[#seen + 1] = msg + end, + }, + server = false, + max_block_args = 15, + debug = function() end, + }) + + handle_block(1, { 1, "Ping", "blocked" }) + assert_eq(#seen, 0) +end) + +test("aio_core handle_block server max args", function() + local handle_block = aio_core.make_handle_block({ + unpack = unpack_fn, + client_state = nil, + block_handlers = { H = function() end }, + server = true, + max_block_args = 2, + debug = function() end, + }) + local ok = pcall(handle_block, 1, { 3, "H", 1, 2, 3 }) + assert_true(not ok) +end) diff --git a/tests/test_aio_integration_client.lua b/tests/test_aio_integration_client.lua new file mode 100644 index 0000000..997005f --- /dev/null +++ b/tests/test_aio_integration_client.lua @@ -0,0 +1,30 @@ +-- luacheck: ignore +local u = dofile((debug.getinfo(1, "S").source:match("@?(.*[/\\])") or "./") .. "aio_integration_util.lua") +local wow_stub = u.wow_stub + +test("integration client loads AIO.lua", function() + wow_stub.reset_log() + local AIO = u.load_aio_from("AIO_Client/", wow_stub.install_client_deps, wow_stub.install_client) + assert_true(not AIO.IsServer()) + assert_true(AIO.IsMainState()) + assert_eq(AIO.GetVersion(), 1.76) +end) + +test("integration client slash aio help", function() + wow_stub.reset_log() + local AIO = u.load_aio_from("AIO_Client/", wow_stub.install_client_deps, wow_stub.install_client) + local print_calls = u.count_builtin_print_calls(function() + SlashCmdList.AIO("help") + end) + assert_true(print_calls > 0) + assert_true(not AIO.IsServer()) +end) + +test("integration client AIO_Handle sends addon message", function() + wow_stub.reset_log() + local AIO = u.load_aio_from("AIO_Client/", wow_stub.install_client_deps, wow_stub.install_client) + AIO.Handle("Ping", "hello") + local msg = u.last_addon_msg("client_to_server") + assert_true(msg ~= nil) + assert_true(#msg.msg > 0) +end) diff --git a/tests/test_aio_integration_server.lua b/tests/test_aio_integration_server.lua new file mode 100644 index 0000000..8e1aa57 --- /dev/null +++ b/tests/test_aio_integration_server.lua @@ -0,0 +1,47 @@ +-- luacheck: ignore +local u = dofile((debug.getinfo(1, "S").source:match("@?(.*[/\\])") or "./") .. "aio_integration_util.lua") +local wow_stub = u.wow_stub + +test("integration server loads AIO.lua", function() + wow_stub.reset_log() + local AIO = u.load_aio_from("AIO_Server/", wow_stub.install_server_deps, wow_stub.install_server) + assert_true(AIO.IsServer()) + assert_true(AIO.IsMainState()) + assert_eq(AIO.GetVersion(), 1.76) + assert_true(wow_stub.server_events[30] ~= nil) +end) + +test("integration server AIO_Send delivers short addon message", function() + wow_stub.reset_log() + local AIO = u.load_aio_from("AIO_Server/", wow_stub.install_server_deps, wow_stub.install_server) + local player = wow_stub.make_player(42) + AIO.Msg():Add("Ping", "hello"):Send(player) + local msg = u.last_addon_msg("server_to_client") + assert_true(msg ~= nil) + assert_eq(msg.guid, 42) + assert_true(#msg.msg > 0) +end) + +test("integration server pipeline AddAddonCode and Init push", function() + wow_stub.reset_log() + local AIO = u.load_aio_from("AIO_Server/", wow_stub.install_server_deps, wow_stub.install_server) + local _, client_prefix = u.aio_prefixes() + local player = wow_stub.make_player(7) + AIO.AddAddonCode("Demo", "AIO_DemoLoaded = true") + wow_stub.addon_messages = {} + + local init_wire = u.short_wire(AIO.Msg():Add("AIO", "Init", 1.76, {}):ToString()) + wow_stub.fire_server_addon_msg(player, client_prefix, init_wire, player) + + local out = u.last_addon_msg("server_to_client") + assert_true(out ~= nil, "server should reply to Init") + assert_true(#out.msg > 0) +end) + +test("integration server slash aio help", function() + wow_stub.reset_log() + local AIO = u.load_aio_from("AIO_Server/", wow_stub.install_server_deps, wow_stub.install_server) + local player = wow_stub.make_player(1) + wow_stub.fire_player_command(player, "aio help") + assert_true(#wow_stub.print_log > 0) +end) diff --git a/tests/test_aio_rpc.lua b/tests/test_aio_rpc.lua new file mode 100644 index 0000000..9217701 --- /dev/null +++ b/tests/test_aio_rpc.lua @@ -0,0 +1,160 @@ +-- luacheck: ignore +local unpack_fn = _G.unpack +if not unpack_fn then + unpack_fn = table.unpack -- luacheck: ignore 113 +end +if not _G.unpack then + _G.unpack = unpack_fn +end +local smallfolk = require("smallfolk") +local aio_rpc = require("aio_rpc") + +-- Match AIO_pcall: return values on success, nil on failure (not ok, err). +local function aio_pcall(f, ...) + local results = {pcall(f, ...)} + if not results[1] then + return nil + end + return unpack_fn(results, 2, #results) +end + +local function make_rpc(opts) + opts = opts or {} + local handlers = opts.handlers or {} + return aio_rpc.new({ + dumps = smallfolk.dumps, + loads = smallfolk.loads, + pcall = aio_pcall, + get_handlers = function() + return handlers + end, + server = opts.server, + max_block_args = opts.max_block_args, + timeout_hook = opts.timeout_hook, + enable_msgprint = opts.enable_msgprint, + }) +end + +test("rpc Msg Add ToString and HasMsg", function() + local rpc = make_rpc({ handlers = {} }) + local msg = rpc.Msg():Add("Ping", "hello") + assert_true(msg:HasMsg()) + local wire = msg:ToString() + assert_true(#wire > 0) + local params = smallfolk.loads(wire) + assert_eq(params[1][2], "Ping") + assert_eq(params[1][3], "hello") +end) + +test("rpc Msg Clear resets state", function() + local rpc = make_rpc({ handlers = {} }) + local msg = rpc.Msg():Add("A", 1):Clear() + assert_true(not msg:HasMsg()) + assert_eq(msg:ToString(), nil) +end) + +test("rpc Msg Append merges blocks", function() + local rpc = make_rpc({ handlers = {} }) + local a = rpc.Msg():Add("One", 1) + local b = rpc.Msg():Add("Two", 2) + local wire = a:Append(b):ToString() + local params = smallfolk.loads(wire) + assert_eq(#params, 2) + assert_eq(params[1][2], "One") + assert_eq(params[2][2], "Two") +end) + +test("rpc parse_blocks dispatches handler", function() + local seen = {} + local rpc = make_rpc({ + handlers = { + Echo = function(player, a, b) + seen.player = player + seen.a = a + seen.b = b + end, + }, + }) + local wire = rpc.Msg():Add("Echo", "x", 3):ToString() + rpc.parse_blocks(wire, 42) + assert_eq(seen.player, 42) + assert_eq(seen.a, "x") + assert_eq(seen.b, 3) +end) + +test("rpc parse_blocks uses on_block when provided", function() + local blocks = {} + local rpc = make_rpc({ handlers = { H = function() end } }) + local wire = rpc.Msg():Add("H", 1):Add("H", 2):ToString() + rpc.parse_blocks(wire, nil, function(player, block) + blocks[#blocks + 1] = block + end) + assert_eq(#blocks, 2) + assert_eq(blocks[1][2], "H") + assert_eq(blocks[1][3], 1) +end) + +test("rpc parse_blocks ignores non-table payload", function() + local rpc = make_rpc({ handlers = { H = function() + error("should not run") + end } }) + rpc.parse_blocks("not valid smallfolk", 1) +end) + +test("rpc unknown handler raises via handle_block", function() + local rpc = make_rpc({ handlers = {} }) + local ok = pcall(rpc.handle_block, 1, { 1, "Missing", 1 }) + assert_true(not ok) +end) + +test("rpc server max_block_args enforced via handle_block", function() + local rpc = make_rpc({ + server = true, + max_block_args = 2, + handlers = { H = function() end }, + }) + local ok = pcall(rpc.handle_block, 1, { 3, "H", 1, 2, 3 }) + assert_true(not ok) +end) + +test("rpc timeout_hook begin and end", function() + local log = {} + local rpc = make_rpc({ + handlers = { H = function() end }, + timeout_hook = { + begin = function(msg) + log.begin = #msg + end, + on_end = function() + log.ended = true + end, + }, + }) + local wire = rpc.Msg():Add("H", 1):ToString() + rpc.parse_blocks(wire, 1) + assert_true(log.begin > 0) + assert_true(log.ended) +end) + +test("rpc bind_send Send on client", function() + local sent = {} + local rpc = make_rpc({ handlers = {} }) + rpc.bind_send(function(payload, player, channel) + sent.payload = payload + sent.player = player + sent.channel = channel + end) + rpc.Msg():Add("A", 1):Send(nil, "CHAN") + assert_true(sent.payload ~= nil) + assert_eq(sent.player, nil) + assert_eq(sent.channel, "CHAN") +end) + +test("rpc server Send requires player", function() + local rpc = make_rpc({ handlers = {}, server = true }) + rpc.bind_send(function() end) + local ok = pcall(function() + rpc.Msg():Add("A", 1):Send(nil) + end) + assert_true(not ok) +end) diff --git a/tests/test_framing.lua b/tests/test_framing.lua new file mode 100644 index 0000000..c680a08 --- /dev/null +++ b/tests/test_framing.lua @@ -0,0 +1,57 @@ +local aio_framing = require("aio_framing") + +local tconcat = table.concat + +local AIO_ServerPrefix = "SAIO" +local AIO_MsgLen = 255 - 1 - #AIO_ServerPrefix - #aio_framing.SHORT_TAG +local framing = aio_framing.new(AIO_MsgLen) +local uint16_encode = aio_framing.uint16_encode +local uint16_decode = aio_framing.uint16_decode + +local function split_message(msg, msg_guid) + return framing:split(msg, msg_guid) +end + +local function assemble_message(packet) + local complete, chunk = framing:parse(packet) + if complete then + return complete + end + return chunk +end + +local function reassemble_long(chunks) + table.sort(chunks, function(a, b) return a.part_id < b.part_id end) + local payloads = {} + for i = 1, #chunks do + payloads[i] = chunks[i].payload + end + return tconcat(payloads) +end + +test("framing uint16 roundtrip", function() + for _, value in ipairs({0, 1, 255, 1000, 64769}) do + assert_eq(uint16_decode(uint16_encode(value)), value, "roundtrip " .. value) + end +end) + +test("framing short message unchanged", function() + local msg = "short payload" + local packets = split_message(msg, 1) + assert_eq(#packets, 1) + assert_eq(assemble_message(packets[1]), msg) +end) + +test("framing long message split and reassemble", function() + local msg = string.rep("x", AIO_MsgLen + 100) + local packets = split_message(msg, 42) + assert_true(#packets > 1) + + local chunks = {} + for i = 1, #packets do + chunks[i] = assemble_message(packets[i]) + end + + assert_eq(chunks[1].parts, #packets) + assert_eq(reassemble_long(chunks), msg) +end) diff --git a/tests/test_lualzw.lua b/tests/test_lualzw.lua new file mode 100644 index 0000000..516dbee --- /dev/null +++ b/tests/test_lualzw.lua @@ -0,0 +1,19 @@ +local lzw = require("lualzw") + +test("lualzw roundtrip repetitive", function() + local input = string.rep("foo", 500) + local compressed = assert(lzw.compress(input)) + local decompressed = assert(lzw.decompress(compressed)) + assert_eq(decompressed, input) + assert_true(#compressed < #input) +end) + +test("lualzw null-safe compressed output", function() + local input = string.rep("bar", 500) + local compressed = assert(lzw.compress(input)) + assert_true(not compressed:find("\0", 1, true)) +end) + +test("lualzw version", function() + assert_eq(lzw._VERSION, "1.1.0") +end) diff --git a/tests/test_path_legacy.lua b/tests/test_path_legacy.lua new file mode 100644 index 0000000..da47f50 --- /dev/null +++ b/tests/test_path_legacy.lua @@ -0,0 +1,20 @@ +-- Documents why basename must handle both separators on Windows. +local old_basename = function(path) + return string.match(path, "([^/]*)$") +end + +local new_basename = function(path) + return string.match(path, "([^/\\]*)$") +end + +test("legacy basename fails on windows backslash paths", function() + local path = "C:\\server\\lua_scripts\\MyAddon.lua" + assert_eq(old_basename(path), path) + assert_eq(new_basename(path), "MyAddon.lua") +end) + +test("legacy basename works on forward slash paths", function() + local path = "lua_scripts/MyAddon.lua" + assert_eq(old_basename(path), "MyAddon.lua") + assert_eq(new_basename(path), "MyAddon.lua") +end) diff --git a/tests/test_queue.lua b/tests/test_queue.lua new file mode 100644 index 0000000..3f6a46d --- /dev/null +++ b/tests/test_queue.lua @@ -0,0 +1,50 @@ +local create_queue = require("queue") + +test("queue pushright/popleft", function() + local q = create_queue() + q:pushright("a") + q:pushright("b") + assert_eq(q:popleft(), "a") + assert_eq(q:popleft(), "b") + assert_true(q:empty()) +end) + +test("queue pushleft/popright", function() + local q = create_queue() + q:pushleft("a") + q:pushleft("b") + assert_eq(q:popright(), "a") + assert_eq(q:popright(), "b") + assert_true(q:empty()) +end) + +test("queue peek and size", function() + local q = create_queue() + q:pushright(1) + q:pushright(2) + assert_eq(q:peekleft(), 1) + assert_eq(q:peekright(), 2) + assert_eq(q:size(), 2) +end) + +test("queue clear", function() + local q = create_queue() + q:pushright(1) + q:pushright(2) + q:pushright(3) + q:clear() + assert_true(q:empty()) + assert_eq(q:size(), 0) +end) + +test("queue get and getrange", function() + local q = create_queue() + q:pushright("x") + q:pushright("y") + local l, r = q:getrange() + assert_eq(l, 0) + assert_eq(r, 1) + assert_eq(q:get(0), "x") + assert_eq(q:get(1), "y") + assert_eq(q:get(-1), nil) +end) diff --git a/tests/test_reassembler.lua b/tests/test_reassembler.lua new file mode 100644 index 0000000..a1f014c --- /dev/null +++ b/tests/test_reassembler.lua @@ -0,0 +1,47 @@ +local aio_framing = require("aio_framing") +local aio_reassembler = require("aio_reassembler") +local aio_util = require("aio_util") +local create_queue = require("queue") + +local AIO_MsgLen = 200 +local framing = aio_framing.new(AIO_MsgLen) + +local function make_reassembler(cache_space) + return aio_reassembler.new({ + framing = framing, + NewQueue = create_queue, + get_time = function() return 1000 end, + get_time_diff = function(now, then_) return now - then_ end, + get_message_stored_size = aio_util.getMessageStoredSize, + cache_space = cache_space, + cache_time_ms = 15000, + }) +end + +test("reassembler short message ingest", function() + local r = make_reassembler() + local packets = framing:split("hello", 1) + assert_eq(r:ingest(1, packets[1]), "hello") +end) + +test("reassembler long message ingest", function() + local r = make_reassembler() + local payload = string.rep("z", AIO_MsgLen + 50) + local packets = framing:split(payload, 7) + local assembled + for i = 1, #packets do + assembled = r:ingest(1, packets[i]) + end + assert_eq(assembled, payload) +end) + +test("reassembler split_payload advances message id", function() + local r = make_reassembler() + local long = string.rep("a", AIO_MsgLen + 10) + local packets1 = r:split_payload(1, long) + local packets2 = r:split_payload(1, long) + assert_true(#packets1 > 1) + local id1 = aio_framing.uint16_decode(string.sub(packets1[1], 1, 2)) + local id2 = aio_framing.uint16_decode(string.sub(packets2[1], 1, 2)) + assert_eq(id2, id1 + 1) +end) diff --git a/tests/test_smallfolk.lua b/tests/test_smallfolk.lua new file mode 100644 index 0000000..20fbc68 --- /dev/null +++ b/tests/test_smallfolk.lua @@ -0,0 +1,34 @@ +local smallfolk = require("smallfolk") + +test("smallfolk roundtrip string", function() + local value = "hello world" + assert_eq(smallfolk.loads(smallfolk.dumps(value)), value) +end) + +test("smallfolk roundtrip number", function() + local value = 42.5 + assert_eq(smallfolk.loads(smallfolk.dumps(value)), value) +end) + +test("smallfolk roundtrip boolean and nil", function() + assert_eq(smallfolk.loads(smallfolk.dumps(true)), true) + assert_eq(smallfolk.loads(smallfolk.dumps(false)), false) + assert_eq(smallfolk.loads(smallfolk.dumps(nil)), nil) +end) + +test("smallfolk roundtrip table", function() + local value = {1, "two", nested = {a = true, b = 2}} + local restored = smallfolk.loads(smallfolk.dumps(value)) + assert_eq(restored[1], 1) + assert_eq(restored[2], "two") + assert_eq(restored.nested.a, true) + assert_eq(restored.nested.b, 2) +end) + +test("smallfolk roundtrip message params", function() + local params = {{2, "PingPong", "ping"}, {1, "AIO", "Init"}} + local restored = smallfolk.loads(smallfolk.dumps(params)) + assert_eq(restored[1][2], "PingPong") + assert_eq(restored[1][3], "ping") + assert_eq(restored[2][2], "AIO") +end) diff --git a/tests/test_stored.lua b/tests/test_stored.lua new file mode 100644 index 0000000..c466351 --- /dev/null +++ b/tests/test_stored.lua @@ -0,0 +1,61 @@ +local aio_util = require("aio_util") + +local function makeRemoveData() + local plrdata = {} + + local function RemoveData(guid, msgid) + local pdata = plrdata[guid] + if not pdata then + return + end + if msgid then + local data = pdata[msgid] + if data then + pdata.stored = pdata.stored - aio_util.getMessageStoredSize(data) + if pdata.stored < 0 then + pdata.stored = 0 + end + pdata[msgid] = nil + end + else + plrdata[guid] = nil + end + end + + return plrdata, RemoveData +end + +test("RemoveData subtracts stored bytes for completed message", function() + local plrdata, RemoveData = makeRemoveData() + local guid = 42 + plrdata[guid] = {stored = 0} + plrdata[guid][7] = { + parts = { + n = 2, + [1] = string.rep("a", 100), + [2] = string.rep("b", 50), + }, + } + plrdata[guid].stored = 150 + + RemoveData(guid, 7) + + assert_eq(plrdata[guid].stored, 0) + assert_eq(plrdata[guid][7], nil) +end) + +test("RemoveData clamps stored at zero", function() + local plrdata, RemoveData = makeRemoveData() + local guid = 1 + plrdata[guid] = {stored = 10} + plrdata[guid][1] = { + parts = { + n = 1, + [1] = string.rep("x", 100), + }, + } + + RemoveData(guid, 1) + + assert_eq(plrdata[guid].stored, 0) +end) diff --git a/tests/test_util.lua b/tests/test_util.lua new file mode 100644 index 0000000..2be80ce --- /dev/null +++ b/tests/test_util.lua @@ -0,0 +1,46 @@ +local aio_util = require("aio_util") + +test("basename unix path", function() + assert_eq(aio_util.basename("/server/lua_scripts/MyAddon.lua"), "MyAddon.lua") +end) + +test("basename windows backslash path", function() + assert_eq(aio_util.basename("C:\\server\\lua_scripts\\MyAddon.lua"), "MyAddon.lua") +end) + +test("basename windows forward slash path", function() + assert_eq(aio_util.basename("C:/server/lua_scripts/MyAddon.lua"), "MyAddon.lua") +end) + +test("basename plain filename", function() + assert_eq(aio_util.basename("MyAddon.lua"), "MyAddon.lua") +end) + +test("getMessageStoredSize sums part lengths", function() + local data = { + parts = { + n = 3, + [1] = "abc", + [2] = "de", + [3] = "f", + }, + } + assert_eq(aio_util.getMessageStoredSize(data), 6) +end) + +test("getMessageStoredSize ignores missing parts", function() + local data = { + parts = { + n = 3, + [1] = "abc", + [3] = "f", + }, + } + assert_eq(aio_util.getMessageStoredSize(data), 4) +end) + +test("isMessageExpired uses milliseconds", function() + assert_true(not aio_util.isMessageExpired(1000, 10000, 15000)) + assert_true(aio_util.isMessageExpired(1000, 17000, 15000)) + assert_true(aio_util.isMessageExpired(1000, 16000, 15000)) +end) diff --git a/tests/wow_stub.lua b/tests/wow_stub.lua new file mode 100644 index 0000000..780a2f3 --- /dev/null +++ b/tests/wow_stub.lua @@ -0,0 +1,304 @@ +--[[ + Minimal Eluna/WoW globals for loading AIO.lua outside the game. +]] + +local wow_stub = { + addon_messages = {}, + server_events = {}, + player_events = {}, + lua_events = {}, + frames = {}, + reload_ui_count = 0, + print_log = {}, +} + +local MODULES = { + "AIO", + "aio_core", + "aio_framing", + "aio_reassembler", + "aio_rpc", + "aio_util", + "aio_server_pipeline", + "aio_client_ui", + "crc32lua", + "LuaSrcDiet", +} + +function wow_stub.clear_packages() + for i = 1, #MODULES do + package.loaded[MODULES[i]] = nil + end + _G.AIO = nil + _G.AIO_sv = nil + _G.AIO_sv_char = nil + _G.AIO_sv_Addons = nil +end + +function wow_stub.reset_log() + wow_stub.addon_messages = {} + wow_stub.server_events = {} + wow_stub.player_events = {} + wow_stub.lua_events = {} + wow_stub.frames = {} + wow_stub.reload_ui_count = 0 + wow_stub.print_log = {} +end + +local function make_frame() + local frame = { + scripts = {}, + events = {}, + } + function frame:RegisterEvent(name) + frame.events[name] = true + end + function frame:SetScript(name, fn) + frame.scripts[name] = fn + end + function frame:SetToplevel() end + function frame:SetFrameStrata() end + function frame:SetFrameLevel() end + function frame:SetAllPoints() end + wow_stub.frames[#wow_stub.frames + 1] = frame + return frame +end + +function wow_stub.make_player(guid) + guid = guid or 1 + local player = { _guid = guid } + function player:GetGUIDLow() + return player._guid + end + function player:SendAddonMessage(prefix, msg, channel, target) + wow_stub.addon_messages[#wow_stub.addon_messages + 1] = { + dir = "server_to_client", + guid = player._guid, + prefix = prefix, + msg = msg, + channel = channel, + target = target, + } + end + function player:SendBroadcastMessage(text) + wow_stub.print_log[#wow_stub.print_log + 1] = { player = player._guid, text = text } + end + return player +end + +local function format_print(...) + local parts = { ... } + for i = 1, #parts do + parts[i] = tostring(parts[i]) + end + return table.concat(parts, "\t") +end + +local function stub_print(...) + wow_stub.print_log[#wow_stub.print_log + 1] = { text = format_print(...) } +end + +function wow_stub.install_server_deps(_package_path_fn) + package.preload["LuaSrcDiet"] = function() + return function(code) + return code + end + end + package.preload["crc32lua"] = function() + return { + crc32 = function(data, crc) + local h = crc or 0 + for i = 1, #data do + h = (h * 33 + string.byte(data, i)) % 4294967296 + end + return h + end, + } + end +end + +function wow_stub.install_client_deps(package_path_fn) + package_path_fn("AIO_Client/Dep_LibWindow-1.1/") + package_path_fn("AIO_Client/Dep_LibWindow-1.1/LibWindow-1.1/") + package.preload["aio_client_ui"] = function() + local M = {} + function M.install(ctx) + ctx.AIO_RESET = function() + ctx.AIO_client_state.AIO_PENDING_RESET = true + end + function ctx.AIO.AddSavedVar(key) + ctx.AIO_SAVEDVARS[key] = true + end + function ctx.AIO.AddSavedVarChar(key) + ctx.AIO_SAVEDVARSCHAR[key] = true + end + function ctx.AIO.SavePosition() end + ctx.AIO_HANDLERS.Init = function() end + ctx.AIO_HANDLERS.ForceReload = function() end + ctx.AIO_HANDLERS.ForceReset = function() end + end + function M.install_reassembler_timer() end + return M + end +end + +function wow_stub.clear_server_globals() + _G.AIO_TEST_ALLOW_TABLE_PLAYERS = nil + _G.GetLuaEngine = nil + _G.GetStateMapId = nil + _G.RegisterServerEvent = nil + _G.RegisterPlayerEvent = nil + _G.CreateLuaEvent = nil + _G.PrintInfo = nil + _G.GetPlayersInWorld = nil +end + +function wow_stub.clear_client_globals() + _G.GetTime = nil + _G.CreateFrame = nil + _G.SendAddonMessage = nil + _G.UnitName = nil + _G.ReloadUI = nil + _G.SlashCmdList = nil + _G.SLASH_AIO1 = nil + _G.GetBuildInfo = nil + _G.RegisterAddonMessagePrefix = nil + _G.message = nil + _G.WorldFrame = nil + _G.LibStub = nil + _G.AIO_sv = nil + _G.AIO_sv_char = nil + _G.AIO_sv_Addons = nil + _G.AIO_FRAMEPOSITIONS = nil + _G.AIO_FRAMEPOSITIONSCHAR = nil +end + +local function clear_debug_hook() + if debug and debug.sethook then + debug.sethook() + end +end + +function wow_stub.install_server() + wow_stub.clear_server_globals() + wow_stub.clear_client_globals() + clear_debug_hook() + _G.AIO_TEST_ALLOW_TABLE_PLAYERS = true + + _G.GetLuaEngine = function() + return {} + end + _G.GetStateMapId = function() + return -1 + end + _G.RegisterServerEvent = function(id, fn) + wow_stub.server_events[id] = fn + end + _G.RegisterPlayerEvent = function(id, fn) + wow_stub.player_events[id] = fn + end + _G.CreateLuaEvent = function(fn, delay, repeats) + local handle = #wow_stub.lua_events + 1 + wow_stub.lua_events[handle] = { + fn = fn, + delay = delay, + repeats = repeats, + } + return handle + end + _G.PrintInfo = stub_print + _G.GetPlayersInWorld = function() + return {} + end +end + +function wow_stub.install_client() + wow_stub.clear_server_globals() + wow_stub.clear_client_globals() + clear_debug_hook() + _G.AIO_TEST_ALLOW_TABLE_PLAYERS = true + + -- Do not replace _G.print: Lua 5.4 can hang when shadowing the C print global. + _G.GetTime = function() + return 0 + end + _G.CreateFrame = make_frame + _G.WorldFrame = {} + _G.UnitName = function() + return "TestPlayer" + end + _G.SendAddonMessage = function(prefix, msg, channel, target) + wow_stub.addon_messages[#wow_stub.addon_messages + 1] = { + dir = "client_to_server", + prefix = prefix, + msg = msg, + channel = channel, + target = target, + } + end + _G.ReloadUI = function() + wow_stub.reload_ui_count = wow_stub.reload_ui_count + 1 + end + _G.message = function() end + _G.GetBuildInfo = function() + return nil, nil, nil, 30300 + end + _G.RegisterAddonMessagePrefix = function() end + _G.SlashCmdList = {} + _G.SLASH_AIO1 = "/aio" + _G.LibStub = function(name) + if name == "LibWindow-1.1" then + return { + RegisterConfig = function() end, + RestorePosition = function() end, + SavePosition = function() end, + } + end + error("LibStub: unknown library " .. tostring(name)) + end + _G.AIO_sv = {} + _G.AIO_sv_char = {} + _G.AIO_sv_Addons = {} +end + +function wow_stub.fire_server_addon_msg(player, prefix, msg, target) + local handler = wow_stub.server_events[30] + assert(handler, "server addon event 30 not registered") + handler(nil, player, nil, prefix, msg, target or player) +end + +function wow_stub.fire_player_command(player, msg) + local handler = wow_stub.player_events[42] + assert(handler, "player command event 42 not registered") + return handler(nil, player, msg) +end + +function wow_stub.fire_addon_loaded(addon_name) + for i = 1, #wow_stub.frames do + local frame = wow_stub.frames[i] + if frame.scripts.OnEvent and frame.events.ADDON_LOADED then + frame.scripts.OnEvent(frame, "ADDON_LOADED", addon_name) + end + end +end + +function wow_stub.fire_client_chat_addon(prefix, msg) + for i = 1, #wow_stub.frames do + local frame = wow_stub.frames[i] + if frame.scripts.OnEvent and frame.events.CHAT_MSG_ADDON then + frame.scripts.OnEvent(frame, "CHAT_MSG_ADDON", nil, prefix, msg, nil, UnitName("player")) + end + end +end + +function wow_stub.run_frame_onupdate(diff) + diff = diff or 1000 + for i = 1, #wow_stub.frames do + local onupdate = wow_stub.frames[i].scripts.OnUpdate + if onupdate then + onupdate(wow_stub.frames[i], diff) + end + end +end + +return wow_stub