// lua_writer.js // Executes the REAL client serializer (RPCSerializeBase.lua + RPCSerializeAuto.lua) // verbatim inside a Lua 5.3 VM (fengari). No hand-written schema, no byte // guessing: the exact same write code the official server's generated // serializer uses. We only provide the low-level binary writer (string.pack) // which matches the verified wire format (LE primitives; strings use UX 7-bit // encoded length+1, buffers use the matching Buffer/Buffer7Bit length encoding). const fs = require("fs"); const path = require("path"); const fengari = require("fengari"); const { lua, lauxlib, lualib, to_luastring } = fengari; const lua_tojsstring = lua.lua_tojsstring; const LUA_DIR = path.join(__dirname, "lua"); const SERIALIZE_BASE = path.join(LUA_DIR, "RPCSerializeBase.lua"); const SERIALIZE_AUTO = path.join(LUA_DIR, "RPCSerializeAuto.lua"); function stripBom(s) { return s.replace(/^\uFEFF/, "").replace(/^\xEF\xBB\xBF/, ""); } let baseSrc = stripBom(fs.readFileSync(SERIALIZE_BASE, "latin1")); let autoSrc = stripBom(fs.readFileSync(SERIALIZE_AUTO, "latin1")); // FIX a known bad-decompile in game Lua source RPCSerializeBase.lua: the limit // guard was decompiled as `not (limit > 0) and not (limit < num)` which throws // on empty lists/dicts. The real client logic (verified vs 5_clean_mk dump) is // `limit > 0 and limit < num`. Patch both List and Dict guards. baseSrc = baseSrc.replace(/not \(limit > 0\) and not \(limit < num\)/g, "limit > 0 and limit < num"); baseSrc = baseSrc.replace(/not \(limit > 0\) and not \(limit < count\)/g, "limit > 0 and limit < count"); // Also fix the badly-decompiled counter() max-key logic: // `count = not (count < n) and n or count` -> `count = count < n and n or count` baseSrc = baseSrc.replace(/count = not \(count < n\) and n or count/g, "count = count < n and n or count"); // Il2Cpp Lua decompiler leaves upvalue placeholders (uv0) in standalone chunks. // In the real client these are pre-bound tables; recreate them when executing // the serializer outside the game. baseSrc = baseSrc.replace(/local Serializer = uv0/, "local Serializer = {}"); autoSrc = autoSrc.replace(/local Auto = uv0/, "local Auto = {}"); // EnumCheck is another injected client upvalue. Validation is irrelevant for // offline packet generation; preserve the supplied value or the generated // default exactly. baseSrc = baseSrc.replace(/function Serializer\.CheckEnum\(value, typeName, defaultVal\)[\s\S]*?\nend\n\nreturn Serializer/, "function Serializer.CheckEnum(value, typeName, defaultVal) if value == nil then return defaultVal end return value end\n\nreturn Serializer"); // Bootstrap: only ASCII shims + the binary writer factory. const BOOTSTRAP = ` function print_error(...) end function print_warn(...) end function print_notice(...) end function print_debug(...) end RpcLengthLimits = setmetatable({}, { __index = function() return 0 end }) local function new_writer() local w = { _chunks = {} } function w:_emit(s) self._chunks[#self._chunks+1] = s end function w:WriteByte(v) self:_emit(string.pack(" store as global. runBuffer(L, Buffer.from(baseSrc, "latin1"), "RPCSerializeBase.lua"); lua.lua_setglobal(L, to_luastring("__BaseSerializer")); // RPCSerializeAuto.lua declares local Auto and ends with `return Auto`. runBuffer(L, Buffer.from(autoSrc, "latin1"), "RPCSerializeAuto.lua"); lua.lua_setglobal(L, to_luastring("__Auto")); _L = L; return L; } // Convert a JS value into a Lua value on the stack. function pushValue(L, v) { if (v === null || v === undefined) { lua.lua_pushnil(L); return; } const t = typeof v; if (t === "boolean") { lua.lua_pushboolean(L, v ? 1 : 0); return; } if (t === "number") { // Fengari's lua_Integer is 32-bit. Preserve exact larger integer values (our 64-bit // entity IDs are still safely below JS 2^53) as Lua numbers; WriteUInt64 serializes // them byte-by-byte and never truncates through a 32-bit integer API. if (Number.isInteger(v) && v >= -2147483648 && v <= 2147483647) lua.lua_pushinteger(L, v); else lua.lua_pushnumber(L, v); return; } if (t === "bigint") { const n = Number(v); if (!Number.isSafeInteger(n)) throw new Error("BigInt exceeds exact JS safe-integer range"); if (n >= -2147483648 && n <= 2147483647) lua.lua_pushinteger(L, n); else lua.lua_pushnumber(L, n); return; } if (t === "string") { lua.lua_pushstring(L, to_luastring(v)); return; } if (Array.isArray(v)) { lua.lua_createtable(L, v.length, 1); for (let i = 0; i < v.length; i++) { pushValue(L, v[i]); lua.lua_rawseti(L, -2, i + 1); // 1-based array } // Set explicit .Count so WriteList uses `val.Count` and never the fragile // (badly-decompiled) counter() fallback. WriteList does: // local num = length or val.Count or counter(val) lua.lua_pushstring(L, to_luastring("Count")); lua.lua_pushinteger(L, v.length); lua.lua_rawset(L, -3); return; } if (t === "object") { lua.lua_createtable(L, 0, 0); for (const k of Object.keys(v)) { // numeric dict keys -> integer keys const nk = Number(k); if (k !== "" && Number.isInteger(nk) && String(nk) === k) { pushValue(L, v[k]); lua.lua_rawseti(L, -2, nk); } else { lua.lua_pushstring(L, to_luastring(k)); pushValue(L, v[k]); lua.lua_rawset(L, -3); } } return; } lua.lua_pushnil(L); } // Serialize: call Auto.(writer, obj) and return Buffer. // Mode "complex": wrap with present marker via Base.WriteComplex semantics? // The RPC payload for a notify is the raw struct WITHOUT outer marker? We test // both; default writes the object via the Auto fn directly (fields only) which // is what a top-level Write produces. function serialize(writeFnName, obj) { const L = getState(); const top = lua.lua_gettop(L); // build: local w = __new_writer(); __Auto.(w, obj); return w:tostring() // We'll do it via stack ops. lua.lua_getglobal(L, to_luastring("__new_writer")); if (lua.lua_pcall(L, 0, 1, 0) !== lua.LUA_OK) { const e = lua_tojsstring(L, -1); lua.lua_settop(L, top); throw new Error("new_writer: " + e); } // writer at -1; keep a copy const writerIdx = lua.lua_gettop(L); lua.lua_getglobal(L, to_luastring("__Auto")); lua.lua_getfield(L, -1, to_luastring(writeFnName)); if (lua.lua_isnil(L, -1)) { lua.lua_settop(L, top); throw new Error("no Auto." + writeFnName); } // stack: writer, Auto, fn -> need fn(writer, obj) lua.lua_pushvalue(L, writerIdx); // writer pushValue(L, obj); // obj if (lua.lua_pcall(L, 2, 0, 0) !== lua.LUA_OK) { const e = lua_tojsstring(L, -1); lua.lua_settop(L, top); throw new Error(writeFnName + ": " + e); } // call writer:tostring() lua.lua_pushvalue(L, writerIdx); lua.lua_getfield(L, -1, to_luastring("tostring")); lua.lua_pushvalue(L, writerIdx); if (lua.lua_pcall(L, 1, 1, 0) !== lua.LUA_OK) { const e = lua_tojsstring(L, -1); lua.lua_settop(L, top); throw new Error("tostring: " + e); } // result is a lua string (bytes). Read raw. const len = { value: 0 }; const ptr = lua.lua_tolstring(L, -1, len); // fengari returns a Uint8Array via lua_tolstring? Use luaL helper let bytes; const s = lua.lua_tolstring(L, -1); bytes = Buffer.from(s); // s is Uint8Array of bytes lua.lua_settop(L, top); return bytes; } // ---- ref -> Write map, built from RPCDeserializeAuto.lua Meta tables ---- // Auto.Meta[ref] = { _type_name = "PlayerClientInfo" } -> writer fn "WritePlayerClientInfo". const DESERIALIZE_AUTO = path.join(LUA_DIR, "RPCDeserializeAuto.lua"); const refToType = {}; { const dsrc = fs.readFileSync(DESERIALIZE_AUTO, "latin1"); const re = /Auto\.Meta\[(\d+)\]\s*=\s*\{\s*_type_name\s*=\s*"([^"]+)"/g; let m; while ((m = re.exec(dsrc)) !== null) refToType[m[1]] = m[2]; } // Serialize by reader-ref number (drop-in replacement for old RPC.serializeComplex). function serializeComplexByRef(ref, obj) { const type = refToType[String(ref)]; if (!type) throw new Error("no _type_name for ref " + ref); return serializeComplex("Write" + type, obj, type); } module.exports = { serialize, serializeComplex, serializeComplexByRef, refToType }; // Serialize WITH the outer present-marker, exactly as a real notify payload: // Base.WriteComplex(writer, obj, Auto., name, false). // The receiving reader does Base.ReadComplex(reader, Auto.Reader[N]) which reads // that leading marker first, so this is the byte-accurate on-wire form. function serializeComplex(writeFnName, obj, name) { const L = getState(); const top = lua.lua_gettop(L); lua.lua_getglobal(L, to_luastring("__new_writer")); if (lua.lua_pcall(L, 0, 1, 0) !== lua.LUA_OK) { const e = lua_tojsstring(L, -1); lua.lua_settop(L, top); throw new Error("new_writer: " + e); } const writerIdx = lua.lua_gettop(L); // __BaseSerializer.WriteComplex(writer, obj, Auto.fn, name, false) lua.lua_getglobal(L, to_luastring("__BaseSerializer")); lua.lua_getfield(L, -1, to_luastring("WriteComplex")); if (lua.lua_isnil(L, -1)) { lua.lua_settop(L, top); throw new Error("no Base.WriteComplex"); } lua.lua_pushvalue(L, writerIdx); // writer pushValue(L, obj); // val lua.lua_getglobal(L, to_luastring("__Auto")); lua.lua_getfield(L, -1, to_luastring(writeFnName)); lua.lua_remove(L, -2); // drop __Auto, keep fn if (lua.lua_isnil(L, -1)) { lua.lua_settop(L, top); throw new Error("no Auto." + writeFnName); } lua.lua_pushstring(L, to_luastring(name || writeFnName)); // name lua.lua_pushboolean(L, 0); // nullable=false if (lua.lua_pcall(L, 5, 0, 0) !== lua.LUA_OK) { const e = lua_tojsstring(L, -1); lua.lua_settop(L, top); throw new Error(writeFnName + ": " + e); } lua.lua_pushvalue(L, writerIdx); lua.lua_getfield(L, -1, to_luastring("tostring")); lua.lua_pushvalue(L, writerIdx); if (lua.lua_pcall(L, 1, 1, 0) !== lua.LUA_OK) { const e = lua_tojsstring(L, -1); lua.lua_settop(L, top); throw new Error("tostring: " + e); } const s = lua.lua_tolstring(L, -1); const bytes = Buffer.from(s); lua.lua_settop(L, top); return bytes; }