179 lines
7.0 KiB
JavaScript
179 lines
7.0 KiB
JavaScript
// rpc_serializer.js
|
|||
|
|
// Schema-driven serializer that mirrors the client's RPCSerializeBase.lua
|
||
|
|
// exactly. The offline server fills plain JS objects; this turns them into
|
||
|
|
// the wire bytes the client expects. No hand-built byte buffers.
|
||
|
|
//
|
||
|
|
// Marker convention (from RPCSerializeBase.lua):
|
||
|
|
// SerializeObjectMarkNull = 0x00
|
||
|
|
// SerializeObjectMarkCommon = 0xFF
|
||
|
|
// WriteComplex(null,nullable) -> writes 0x00 ; present -> 0xFF + fields
|
||
|
|
// WriteList(null,nullable) -> 0x00 ; present -> 0xFF + int32 count + items
|
||
|
|
// WriteStruct -> NO marker, fields written directly
|
||
|
|
// WriteBuffer(null,nullable) -> 0x00 ; present -> 0xFF + buffer
|
||
|
|
//
|
||
|
|
// A "schema" is the field list extracted from the client's Reader[N].
|
||
|
|
// Each field: {name, kind, op?, ref?, item?}
|
||
|
|
// kind: prim | struct | complex | buffer | list | dict
|
||
|
|
|
||
|
|
const fs = require("fs");
|
||
|
|
|
||
|
|
class Writer {
|
||
|
|
constructor() { this.chunks = []; }
|
||
|
|
_push(b) { this.chunks.push(b); }
|
||
|
|
WriteByte(v) { const b = Buffer.alloc(1); b.writeUInt8(v & 0xff, 0); this._push(b); }
|
||
|
|
WriteBoolean(v) { this.WriteByte(v ? 1 : 0); }
|
||
|
|
WriteInt16(v) { const b = Buffer.alloc(2); b.writeInt16LE(v | 0, 0); this._push(b); }
|
||
|
|
WriteUInt16(v) { const b = Buffer.alloc(2); b.writeUInt16LE(v & 0xffff, 0); this._push(b); }
|
||
|
|
WriteInt32(v) { const b = Buffer.alloc(4); b.writeInt32LE(v | 0, 0); this._push(b); }
|
||
|
|
WriteUInt32(v) { const b = Buffer.alloc(4); b.writeUInt32LE(v >>> 0, 0); this._push(b); }
|
||
|
|
WriteInt64(v) { const b = Buffer.alloc(8); b.writeBigInt64LE(BigInt(v || 0), 0); this._push(b); }
|
||
|
|
WriteUInt64(v) { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(v || 0), 0); this._push(b); }
|
||
|
|
WriteSingle(v) { const b = Buffer.alloc(4); b.writeFloatLE(v || 0, 0); this._push(b); }
|
||
|
|
WriteDouble(v) { const b = Buffer.alloc(8); b.writeDoubleLE(v || 0, 0); this._push(b); }
|
||
|
|
WriteString(v) {
|
||
|
|
// client string format: byte present-marker + int32 length + utf8 bytes
|
||
|
|
if (v === null || v === undefined) { this.WriteByte(0x00); return; }
|
||
|
|
const s = Buffer.from(String(v), "utf8");
|
||
|
|
this.WriteByte(0xFF);
|
||
|
|
this.WriteInt32(s.length);
|
||
|
|
this._push(s);
|
||
|
|
}
|
||
|
|
WriteRawBuffer(buf) { this._push(Buffer.from(buf)); }
|
||
|
|
toBuffer() { return Buffer.concat(this.chunks); }
|
||
|
|
}
|
||
|
|
|
||
|
|
const PRIM = {
|
||
|
|
ReadByte: "WriteByte", ReadBoolean: "WriteBoolean",
|
||
|
|
ReadInt16: "WriteInt16", ReadUInt16: "WriteUInt16",
|
||
|
|
ReadInt32: "WriteInt32", ReadUInt32: "WriteUInt32",
|
||
|
|
ReadInt64: "WriteInt64", ReadUInt64: "WriteUInt64",
|
||
|
|
ReadSingle: "WriteSingle", ReadDouble: "WriteDouble",
|
||
|
|
ReadString: "WriteString",
|
||
|
|
};
|
||
|
|
|
||
|
|
const PRIM_DEFAULT = {
|
||
|
|
WriteByte: 0, WriteBoolean: false, WriteInt16: 0, WriteUInt16: 0,
|
||
|
|
WriteInt32: 0, WriteUInt32: 0, WriteInt64: 0, WriteUInt64: 0,
|
||
|
|
WriteSingle: 0, WriteDouble: 0, WriteString: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
class RpcSerializer {
|
||
|
|
constructor(schemaPath) {
|
||
|
|
const j = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
||
|
|
this.schemas = j.schemas;
|
||
|
|
this.midToReader = j.midToReader;
|
||
|
|
this.midToName = j.midToName;
|
||
|
|
}
|
||
|
|
|
||
|
|
// write one primitive
|
||
|
|
_writePrim(w, op, val) {
|
||
|
|
const fn = PRIM[op];
|
||
|
|
if (!fn) throw new Error("unknown prim op " + op);
|
||
|
|
if (val === undefined) val = PRIM_DEFAULT[fn];
|
||
|
|
w[fn](val);
|
||
|
|
}
|
||
|
|
|
||
|
|
// write a struct (NO marker) by schema ref
|
||
|
|
_writeStruct(w, ref, val) {
|
||
|
|
const fields = this.schemas[ref] || [];
|
||
|
|
const obj = val || {};
|
||
|
|
for (const f of fields) this._writeField(w, f, obj[f.name]);
|
||
|
|
}
|
||
|
|
|
||
|
|
// write a complex (nullable, has marker) by schema ref
|
||
|
|
_writeComplex(w, ref, val) {
|
||
|
|
if (val === null || val === undefined) { w.WriteByte(0x00); return; }
|
||
|
|
w.WriteByte(0xFF);
|
||
|
|
const fields = this.schemas[ref] || [];
|
||
|
|
for (const f of fields) this._writeField(w, f, val[f.name]);
|
||
|
|
}
|
||
|
|
|
||
|
|
_writeList(w, item, val) {
|
||
|
|
if (val === null || val === undefined) { w.WriteByte(0x00); return; }
|
||
|
|
w.WriteByte(0xFF);
|
||
|
|
const arr = Array.isArray(val) ? val : [];
|
||
|
|
w.WriteInt32(arr.length);
|
||
|
|
for (const el of arr) this._writeItem(w, item, el);
|
||
|
|
}
|
||
|
|
|
||
|
|
_writeItem(w, item, val) {
|
||
|
|
if (item.kind === "prim") this._writePrim(w, item.op, val);
|
||
|
|
else if (item.kind === "struct") this._writeStruct(w, item.ref, val);
|
||
|
|
else if (item.kind === "complex") this._writeComplex(w, item.ref, val);
|
||
|
|
else throw new Error("unknown list item kind " + item.kind);
|
||
|
|
}
|
||
|
|
|
||
|
|
_writeField(w, f, val) {
|
||
|
|
switch (f.kind) {
|
||
|
|
case "prim": this._writePrim(w, f.op, val); break;
|
||
|
|
case "struct": this._writeStruct(w, f.ref, val); break;
|
||
|
|
case "complex": this._writeComplex(w, f.ref, val); break;
|
||
|
|
case "buffer":
|
||
|
|
if (val === null || val === undefined) { w.WriteByte(0x00); }
|
||
|
|
else { w.WriteByte(0xFF); w.WriteInt32(val.length); w.WriteRawBuffer(val); }
|
||
|
|
break;
|
||
|
|
case "list": this._writeList(w, f.item, val); break;
|
||
|
|
case "dict":
|
||
|
|
// dict serialized like list: null=0x00 ; present=0xFF + int32 count + pairs
|
||
|
|
if (val === null || val === undefined) { w.WriteByte(0x00); }
|
||
|
|
else { w.WriteByte(0xFF); w.WriteInt32(0); } // empty dict baseline
|
||
|
|
break;
|
||
|
|
default: throw new Error("unknown field kind " + f.kind);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Serialize a top-level struct (by reader ref) into a complex payload
|
||
|
|
// (with the leading present marker), e.g. EnterSceneInfo = ref "53".
|
||
|
|
serializeComplex(ref, obj) {
|
||
|
|
const w = new Writer();
|
||
|
|
this._writeComplex(w, ref, obj);
|
||
|
|
return w.toBuffer();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build a fully-PRESENT default object for a complex schema ref. Every field
|
||
|
|
// is materialised so the client never dereferences a nil:
|
||
|
|
// prim -> 0 / false / "" default
|
||
|
|
// struct -> recurse
|
||
|
|
// complex-> recurse (present, never null)
|
||
|
|
// list -> [] (present empty list, NOT null)
|
||
|
|
// dict -> {} (present empty dict, NOT null)
|
||
|
|
// buffer -> null (ReadBuffer tolerates absent)
|
||
|
|
// A real server sends a fresh player exactly like this - no null sub-objects.
|
||
|
|
// `overrides` lets callers set real values on specific top-level fields.
|
||
|
|
// `_depth` guards against cyclic schemas.
|
||
|
|
autoDefault(ref, overrides, _depth) {
|
||
|
|
_depth = _depth || 0;
|
||
|
|
const fields = this.schemas[ref];
|
||
|
|
if (!fields || _depth > 24) return {};
|
||
|
|
const out = {};
|
||
|
|
for (const f of fields) {
|
||
|
|
if (overrides && Object.prototype.hasOwnProperty.call(overrides, f.name)) {
|
||
|
|
out[f.name] = overrides[f.name];
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
switch (f.kind) {
|
||
|
|
case "prim":
|
||
|
|
out[f.name] = (f.op === "ReadBoolean") ? false
|
||
|
|
: (f.op === "ReadString") ? null
|
||
|
|
: 0;
|
||
|
|
break;
|
||
|
|
case "struct": out[f.name] = this.autoDefault(f.ref, null, _depth + 1); break;
|
||
|
|
case "complex": out[f.name] = this.autoDefault(f.ref, null, _depth + 1); break;
|
||
|
|
case "list": out[f.name] = []; break;
|
||
|
|
case "dict": out[f.name] = {}; break;
|
||
|
|
case "buffer": out[f.name] = null; break;
|
||
|
|
default: out[f.name] = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Serialize a struct without the outer marker.
|
||
|
|
serializeStruct(ref, obj) {
|
||
|
|
const w = new Writer();
|
||
|
|
this._writeStruct(w, ref, obj);
|
||
|
|
return w.toBuffer();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { RpcSerializer, Writer };
|