Files
Ananta-ps/Ananta.Proxy/proxy/lua_writer.js
T

299 lines
13 KiB
JavaScript
Raw Normal View History

// 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("<B", (v or 0) & 0xff)) end
function w:WriteBoolean(v) self:_emit(string.pack("<B", (v and 1 or 0))) end
function w:WriteInt16(v) self:_emit(string.pack("<i2", v or 0)) end
function w:WriteUInt16(v) self:_emit(string.pack("<I2", (v or 0) & 0xffff)) end
function w:WriteInt32(v) self:_emit(string.pack("<i4", v or 0)) end
function w:WriteUInt32(v) self:_emit(string.pack("<I4", (v or 0) & 0xffffffff)) end
function w:WriteInt64(v)
local n = tonumber(v or 0)
if n < 0 then n = n + 18446744073709551616.0 end
for _=1,8 do local b=n % 256; self:WriteByte(b); n=math.floor(n/256) end
end
function w:WriteUInt64(v)
local n = tonumber(v or 0)
for _=1,8 do local b=n % 256; self:WriteByte(b); n=math.floor(n/256) end
end
function w:WriteSingle(v) self:_emit(string.pack("<f", v or 0.0)) end
function w:WriteDouble(v) self:_emit(string.pack("<d", v or 0.0)) end
function w:Write7BitEncodedInt(value)
-- Client UXBinaryWriter increments the logical value before emitting it.
-- Reader decodes and subtracts one. So count=0 is wire 0x01.
local v = math.floor(value or 0) + 1
repeat
local b = v & 0x7f
v = math.floor(v / 128)
if v ~= 0 then b = b | 0x80 end
self:WriteByte(b)
until v == 0
end
function w:WriteString(val, nullable, name, limit)
-- Strings use 0x00 for null and UX 7-bit length for non-null data.
-- Since Write7BitEncodedInt itself adds one, pass the raw UTF-8 length.
if val == nil then self:WriteByte(0); return end
local s = tostring(val)
self:Write7BitEncodedInt(#s)
self:_emit(s)
end
local function normalize_buffer(val)
local s = val or ""
if type(s) == "table" then
local t = {}
for i=1,#s do t[i] = string.char((s[i] or 0) & 0xff) end
s = table.concat(t)
end
return s
end
function w:WriteBuffer(val, length, limit)
local s = normalize_buffer(val)
self:WriteInt32(#s)
self:_emit(s)
end
function w:WriteBuffer7Bit(val, length, limit)
local s = normalize_buffer(val)
self:Write7BitEncodedInt(#s)
self:_emit(s)
end
function w:tostring() return table.concat(self._chunks) end
return w
end
_G.__new_writer = new_writer
function require(name)
if name == "LX6/Service/RPCSerializeBase" then return _G.__BaseSerializer end
return nil
end
`;
let _L = null;
function runBuffer(L, buf, chunkname) {
const arr = Uint8Array.from(buf);
const st = lauxlib.luaL_loadbuffer(L, arr, arr.length, to_luastring(chunkname));
if (st !== lua.LUA_OK) { const e = lua_tojsstring(L, -1); throw new Error("load " + chunkname + ": " + e); }
if (lua.lua_pcall(L, 0, 1, 0) !== lua.LUA_OK) { const e = lua_tojsstring(L, -1); throw new Error("run " + chunkname + ": " + e); }
}
function getState() {
if (_L) return _L;
const L = lauxlib.luaL_newstate();
lualib.luaL_openlibs(L);
if (lauxlib.luaL_dostring(L, to_luastring(BOOTSTRAP)) !== lua.LUA_OK) {
throw new Error("bootstrap: " + lua_tojsstring(L, -1));
}
// RPCSerializeBase.lua returns the Serializer table -> 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.<writeFnName>(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<Type> produces.
function serialize(writeFnName, obj) {
const L = getState();
const top = lua.lua_gettop(L);
// build: local w = __new_writer(); __Auto.<fn>(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<TypeName> 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.<writeFnName>, 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;
}