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

3486 lines
121 KiB
JavaScript

const dns = require("dns").promises;
const crypto = require("crypto");
const fs = require("fs");
const http = require("http");
const https = require("https");
const net = require("net");
const os = require("os");
const path = require("path");
const util = require("util");
// Mirror the runtime console into the same files as the C# server. Run-All.ps1
// prepares these paths once per run, so both processes share one timestamp.
const Ananta_CONSOLE_LATEST = process.env.Ananta_CONSOLE_LOG_LATEST || null;
const Ananta_CONSOLE_ARCHIVE = process.env.Ananta_CONSOLE_LOG_ARCHIVE || null;
function appendUnifiedConsole(line) {
for (const file of [Ananta_CONSOLE_LATEST, Ananta_CONSOLE_ARCHIVE]) {
if (!file) continue;
try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.appendFileSync(file, `${line}\n`, "utf8"); } catch { }
}
}
const originalConsoleLog = console.log.bind(console);
const originalConsoleWarn = console.warn.bind(console);
const originalConsoleError = console.error.bind(console);
console.log = (...args) => { const line = util.format(...args); appendUnifiedConsole(line); originalConsoleLog(...args); };
console.warn = (...args) => { const line = util.format(...args); appendUnifiedConsole(line); originalConsoleWarn(...args); };
console.error = (...args) => { const line = util.format(...args); appendUnifiedConsole(line); originalConsoleError(...args); };
const { config } = require("./private_server_config");
// v12: schema-driven serializer (mirrors client RPCSerializeBase.lua)
const { RpcSerializer } = require("./rpc_serializer");
const { Writer } = require("./rpc_serializer");
const RPC = new RpcSerializer(path.join(__dirname, "rpc_schema.json"));
// REAL client serializer (RPCSerializeAuto.lua executed verbatim in a Lua VM).
// This is the single source of truth for packet bytes - identical to what the
// official server's generated serializer produces. No hand-built schema.
const LuaWriter = require("./lua_writer");
// SyncPlayerAllTask notify (0x03D48AF5) - 4229938 client layout, verified
// against UXRPCMethodArgs64260853.Read and TypeSerializer.ReadEventPanelInfo
// in the 4229938 GameAssembly:
// taskInfos nullable list (flag + 7-bit count) — empty
// submitTaskList nullable list — empty
// currentTask u32
// eventPanelInfo nullable complex { EventsInfo, SubmitEventList,
// SubmitReplayEventList, EventViewInfoList } — each a
// nullable list with 7-bit count; no inner object marker
// loginGameServer bool
// The 4229938 stream (0xFF + Int32 counts, extra submitEventList arg-level
// field, EventViewInfoList outside the complex) desyncs this reader and kills
// the client with DeserializeRpcStreamBufferNotEnoughException.
function buildSyncPlayerAllTask() {
const w = new Writer();
const emptyList = [0x01, 0x00]; // present flag + 7-bit count 0
// taskInfos: List<TaskViewData> -> empty
w.WriteRawBuffer(Buffer.from(emptyList));
// submitTaskList: List<u32> -> empty
w.WriteRawBuffer(Buffer.from(emptyList));
// currentTask: u32
w.WriteUInt32(0);
// eventPanelInfo: present, then its four nullable lists
w.WriteByte(0x01);
w.WriteRawBuffer(Buffer.from(emptyList)); // EventsInfo
w.WriteRawBuffer(Buffer.from(emptyList)); // SubmitEventList
w.WriteRawBuffer(Buffer.from(emptyList)); // SubmitReplayEventList
w.WriteRawBuffer(Buffer.from(emptyList)); // EventViewInfoList
// loginGameServer: bool = true
w.WriteBoolean(true);
return w.toBuffer();
}
const HOST = config.network.proxy.updateHost;
const PORT = config.network.proxy.httpsPort;
const HTTP_CAPTURE_PORT = config.network.proxy.httpPort;
const LOGIN_LIST_PORT = config.network.proxy.loginListPort;
const LOCAL_LOGIN_HOST = config.network.advertisedHost;
const LOCAL_LOGIN_TCP_BIND_HOST = config.network.bindHost;
const LOCAL_LOGIN_TCP_PORT = config.network.proxy.loginTcpPort;
const LOCAL_GAME_TCP_PORT = config.network.proxy.gameTcpPort;
const ROOT = __dirname;
const CERT_DIR = path.join(ROOT, "certs");
const LOG_DIR = path.join(ROOT, "logs");
const VERSION_JSON = path.join(ROOT, "public", "pc_netease_version.json");
const PFX_PATH = path.join(CERT_DIR, `${HOST}.pfx`);
const PFX_PASSPHRASE = config.network.proxy.certificatePassphrase;
const CAPTURE_BODY_LIMIT = 16 * 1024;
fs.mkdirSync(LOG_DIR, { recursive: true });
function getLanIpv4() {
for (const entries of Object.values(os.networkInterfaces())) {
for (const entry of entries || []) {
if (entry.family === "IPv4" && !entry.internal) {
return entry.address;
}
}
}
return null;
}
const LOCAL_LOGIN_TCP_HOSTS = Array.from(
new Set([LOCAL_LOGIN_HOST, getLanIpv4()].filter(Boolean)),
);
const RPC_PACKET_NOTIFY = 3;
const RPC_PACKET_INVOKE = 1;
const RPC_PACKET_RETURN = 2;
const RPC_ERR_NO_MORE_HOTFIX_PATCH = config.client.noMoreHotfixPatchError;
const CLIENT_VERSION = String(config.client.version);
const CLIENT_ARTIFACT_VERSION = String(config.client.artifactVersion);
const LOCAL_SERVER_ID = config.client.serverId;
const LOCAL_AID = config.client.aid;
const LOCAL_PLAYER_PID = config.player.pid;
const LOCAL_ACCOUNT_ID = config.player.accountId;
const LOCAL_USERNAME = config.player.userName;
const LOCAL_DISPLAY_NAME = config.player.displayName;
const LOCAL_LOGIN_TOKEN = config.player.loginToken;
const LOCAL_PLAYER_TOKEN = config.player.gameToken;
const LOCAL_SHARE_TOKEN = config.player.shareToken;
const LOCAL_FP_PASS_TOKEN = config.player.fpPassToken;
const LOCAL_SKEY = config.player.skey;
// Server identity and RPC MD5 come from config/private-server.json.
const CLIENT_RPC_MD5 = process.env.Ananta_RPC_MD5 || config.client.rpcMd5;
const RPC_METHODS = Object.freeze({
Login_RequestCreateRoleEx: 34383517,
Login_CheckVersion: 34700853,
Login_AskNewHotFixPatchLogin: 34692559,
Login_RequestPatchesCheckDataFromLogin: 34601301,
Login_AskUniSdkShareToken_Login: 34110053,
Login_CheckAccount: 34339919,
Login_CheckAccountPassBy: 34163006,
Login_CheckAccountOpenId: 34381554,
Login_TryLogin: 34529582,
Login_HasOnlinePlayer: 34822236,
Login_RequestPatchesFromLogin: 34022227,
Login_RequestEnterGame: 34566515,
Login_DebugRequestEnterGame: 34478273,
Login_RequestFpPassToken: 34828770,
Gate_AskUniSdkShareToken: 52152686,
Gate_AskCloseConnection: 52140059,
Gate_GetServerTime: 52951195,
Gate_Login: 52023760,
Game_AskCloseConnection: 63990036,
Game_AskChangeHackerName: 63975191,
Game_AskRemainChangeNameCount: 63980351,
Game_GetServerTime: 63266454,
Game_AskChangeNameByItem: 63087020,
Game_RequestGameSceneData: 63427902,
// This RPC is absent from the 4229938 table. Kept for older fallback clients.
Game_AskStartGame: 63820182,
Game_LoginGame: 63142082,
Game_AskPanelBrowsingTime: 63927330,
});
const RPC_NOTIFIES = Object.freeze({
Login_SyncRoleList: 35167428,
Login_SyncGameModeInfo: 35892717,
Gate_SendServerTime: 53477227,
Avatar_SyncPlayerGameServerInfo: 154693736,
Game_SendServerTimeGame: 64114176,
Game_SyncPlayerAllTask: 64260853,
Game_SyncPlayerInfo: 64682159,
Game_SyncEnterScene: 64406364,
GameScene_SyncWorldReady: 68490276,
GameScene_SyncPlayerLoadRate: 68547190,
GameScene_SyncPlayerCurrentSpirit: 68830687,
});
const RPC_METHOD_NAMES = new Map(
Object.entries(RPC_METHODS).map(([name, methodId]) => [methodId, name]),
);
function rpcMethodName(methodId) {
return RPC_METHOD_NAMES.get(methodId) || `Unknown_${methodId}`;
}
let loginListRequestCount = 0;
function loginListCandidates() {
const candidates = [];
candidates.push({
name: "csharp-login-first",
body: `${LOCAL_LOGIN_HOST}:${config.network.loginPorts[0]}\n${LOCAL_LOGIN_HOST}:${config.network.loginPorts[1]}\n`,
});
for (const host of LOCAL_LOGIN_TCP_HOSTS) {
// V1-proven simple format `host:port\n` is what worked.
candidates.push({ name: `colon-${host}`, body: `${host}:${LOCAL_LOGIN_TCP_PORT}\n` });
candidates.push({ name: `csv-ip-port-${host}`, body: `${host},${LOCAL_LOGIN_TCP_PORT}\n` });
candidates.push({ name: `csv-index-ip-port-${host}`, body: `0,${host},${LOCAL_LOGIN_TCP_PORT}\n` });
candidates.push({ name: `csv-two-index-ip-port-${host}`, body: `0,0,${host},${LOCAL_LOGIN_TCP_PORT}\n` });
candidates.push({ name: `csv-server-ip-port-${host}`, body: `${LOCAL_SERVER_ID},${host},${LOCAL_LOGIN_TCP_PORT}\n` });
candidates.push({ name: `pipe-ip-port-${host}`, body: `${host}|${LOCAL_LOGIN_TCP_PORT}\n` });
candidates.push({ name: `space-ip-port-${host}`, body: `${host} ${LOCAL_LOGIN_TCP_PORT}\n` });
candidates.push({
name: `json-array-${host}`,
body: JSON.stringify([{ ip: host, port: LOCAL_LOGIN_TCP_PORT }]) + "\n",
});
candidates.push({
name: `json-data-${host}`,
body:
JSON.stringify({
code: 0,
data: [{ ip: host, port: LOCAL_LOGIN_TCP_PORT }],
}) + "\n",
});
}
return candidates;
}
const COMPACT_LOG = process.env.Ananta_COMPACT_LOG === "1";
const HIDE_BOOT_LOG = process.env.Ananta_HIDE_BOOT_LOG === "1";
function log(message) {
const iso = new Date().toISOString();
const line = `[${iso}] ${message}`;
fs.appendFileSync(path.join(LOG_DIR, "proxy.log"), `${line}\n`);
if (!COMPACT_LOG) {
console.log(line);
return;
}
// Listener announcements are bootstrap-only noise. Keep them in both log files,
// but do not print them into the interactive startup window.
if (HIDE_BOOT_LOG && message.startsWith("listening on ")) {
const bootTime = new Date().toLocaleTimeString("en-GB", { hour12: false });
appendUnifiedConsole(`[${bootTime}] [PX] ${message}`);
return;
}
// Full request bodies remain in proxy/logs/proxy.log. The console only shows
// milestones that are useful while bringing the client online.
const noisy =
message.startsWith("unisdk-capture ") ||
message.startsWith("local-gray-release ") ||
message.startsWith("local-music ") ||
message.startsWith("proxy GET ") ||
message.startsWith("proxy POST ");
if (noisy) return;
const keep =
message.startsWith("listening on ") ||
message.startsWith("local-serverlist") ||
message.startsWith("local-loginlist") ||
message.startsWith("local-outertest-config") ||
message.startsWith("local-fastpatch") ||
message.startsWith("local-resource-patch-info") ||
message.startsWith("local-startup-patch") ||
message.startsWith("local-device-check") ||
message.startsWith("local-uni-sauth") ||
message.startsWith("local-check-enter") ||
message.includes("error") ||
message.includes("fatal") ||
message.includes("404");
if (!keep) return;
// Console time is local, matching the C# server logger. Full proxy.log keeps ISO UTC.
const time = new Date().toLocaleTimeString("en-GB", { hour12: false });
console.log(`[${time}] [PX] ${message}`);
}
const upstreamCache = new Map();
const UPSTREAM_IP_OVERRIDES = Object.freeze({
// Akamai addresses returned for the 4229938 resource CDN. This fallback is
// needed on machines where the local hosts redirect makes resolve4 time out.
"l50.gdl.netease.com": ["104.109.143.24", "104.109.143.10"],
});
function isPublicIpv4(ip) {
return !(
ip.startsWith("127.") ||
ip.startsWith("10.") ||
ip.startsWith("192.168.") ||
ip.startsWith("172.16.") ||
ip.startsWith("172.17.") ||
ip.startsWith("172.18.") ||
ip.startsWith("172.19.") ||
ip.startsWith("172.2") ||
ip.startsWith("172.30.") ||
ip.startsWith("172.31.") ||
ip === "0.0.0.0"
);
}
async function resolveUpstream(host = HOST) {
const normalizedHost = String(host || HOST).split(":")[0].toLowerCase();
const now = Date.now();
const cached = upstreamCache.get(normalizedHost);
if (cached && cached.until > now) {
return cached.address;
}
let addresses;
try {
addresses = await dns.resolve4(normalizedHost);
} catch (error) {
addresses = UPSTREAM_IP_OVERRIDES[normalizedHost];
if (!addresses) {
throw error;
}
log(`using upstream IP fallback for ${normalizedHost}: ${addresses.join(", ")}`);
}
const publicAddress = addresses.find(isPublicIpv4);
if (!publicAddress) {
throw new Error(`No public DNS address for ${normalizedHost}: ${addresses.join(", ")}`);
}
upstreamCache.set(normalizedHost, { address: publicAddress, until: now + 60_000 });
return publicAddress;
}
function serveLocalVersion(res) {
// Trunk resource stream 20260805061659654p0. The client's local download
// history still says 20260707064825384p0 (the trunk build ships 0707-based
// resources), so the client runs the LARGE_PATCH flow: it fetches the real
// CDN Patch.info (md5 must equal resC) and then downloads the delta packages
// through this proxy. The real manifest for this stream is deployed at
// public/Patch_20260805061659654p0.info (md5 b5481abf8e882801b424fe2c0bf0adfe,
// verified against the official CDN).
const resourceVersion = String(
process.env.Ananta_PROXY_RESOURCE_VERSION || "20260805061659654p0"
);
const resourceChecksum = String(
process.env.Ananta_PROXY_RESOURCE_CHECKSUM || "b5481abf8e882801b424fe2c0bf0adfe"
);
const body = Buffer.from(JSON.stringify({
resUpdate: [{
minV: "0",
maxV: "18446744073709551615",
resV: resourceVersion,
resC: resourceChecksum,
soResC: "",
enableDlc: "False",
triggerWarmupBelowVersion: "",
codeV: CLIENT_VERSION,
artifactV: CLIENT_ARTIFACT_VERSION,
enableIBT: "False",
enableIBTOneInN: "1",
}],
}));
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"content-length": body.length,
"cache-control": "no-store",
});
res.end(body);
}
function serveLocalDeviceCheck(res) {
serveJson(res, {
checkInGame: false,
minimumRequirements: {
minCPUScore: 0,
minGPUScore: 0,
minRAM: 0,
minGraphicsMemory: 0,
minOSVersion: 0,
ssdRequired: false,
},
cpuRequirements: [],
gpuRequirements: [],
cpuBlacklist: [],
gpuBlacklist: [],
gpuDriverBlacklist: [],
cpuWhitelist: [],
gpuWhitelist: [],
});
}
function serveText(res, body, contentType = "text/plain; charset=utf-8") {
const buffer = Buffer.from(body, "utf8");
res.writeHead(200, {
"content-type": contentType,
"content-length": buffer.length,
"cache-control": "no-store",
"connection": "close",
});
res.end(buffer);
}
function serveJson(res, value) {
serveText(res, JSON.stringify(value), "application/json; charset=utf-8");
}
function serveHtml(res, body) {
serveText(res, body, "text/html; charset=utf-8");
}
function collectRequestBody(req) {
return new Promise((resolve) => {
const chunks = [];
let size = 0;
req.on("data", (chunk) => {
size += chunk.length;
if (size <= CAPTURE_BODY_LIMIT) {
chunks.push(chunk);
}
});
req.on("end", () => {
const buffer = Buffer.concat(chunks);
resolve({
size,
text: buffer.toString("utf8").replace(/[\r\n]+/g, " ").slice(0, CAPTURE_BODY_LIMIT),
});
});
req.on("error", () => {
resolve({ size, text: "" });
});
});
}
function serveEmptyStartupPatchInfo(res) {
serveText(res, "");
}
// =============================================================================
// IFix startup patch routes
// =============================================================================
// The client fetches GET /trunk-client_startup_patch_info.txt to discover
// which IFix hot-patches must be loaded at boot. The body lists patch IDs
// pointing at .bytes.standalone files. Loading the listed stage0 IFix
// patch registers iFix-slot method patches like
// LoginManager.LoginGameServer's iFix dispatcher slot, which is what
// makes the post-Gate_Login SyncPlayerAllTask -> ConnectGame chain fire.
//
// Files served:
// /trunk-client_startup_patch_info.txt
// -> public/trunk-client_startup_patch_info.txt (loader manifest)
// /2724864/2724864-20260116021030-Assembly-CSharp.stage0.patch.bytes.standalone
// -> public/stage0_patch.bytes.standalone (IFix magic 3d 57 5d e8 ...)
function serveBinaryFile(res, relPath, contentType, extraHeaders = {}) {
const fs = require("fs");
const filePath = path.join(ROOT, "public", relPath);
try {
const data = fs.readFileSync(filePath);
res.writeHead(200, {
"content-type": contentType || "application/octet-stream",
"content-length": data.length,
"connection": "close",
...extraHeaders,
});
res.end(data);
return true;
} catch (e) {
return false;
}
}
function streamBinaryFile(res, relPath, contentType) {
const filePath = path.join(ROOT, "public", relPath);
try {
const stat = fs.statSync(filePath);
res.writeHead(200, {
"content-type": contentType || "application/octet-stream",
"content-length": stat.size,
"connection": "close",
});
const stream = fs.createReadStream(filePath);
stream.on("error", (error) => {
log(`local file stream error ${relPath}: ${error.message}`);
res.destroy(error);
});
stream.pipe(res);
return true;
} catch (e) {
return false;
}
}
function serveStartupPatchInfo(res) {
// The old stage-0 blob targets client 2758041 and is binary-incompatible with
// 4229938. The official endpoint also has no startup patch for this build.
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "connection": "close" });
res.end(`no startup patch for client ${CLIENT_VERSION}`);
}
function serveStage0Patch(res) {
if (!serveBinaryFile(res, "stage0_patch.bytes.standalone", "application/octet-stream")) {
res.writeHead(404, { "content-type": "text/plain", "connection": "close" });
res.end("stage0 patch not present");
}
}
function serveOuterTestConfig(res) {
if (!serveBinaryFile(res, "OuterTest1_ConstConfig.cfg", "application/octet-stream")) {
res.writeHead(404, { "content-type": "text/plain", "connection": "close" });
res.end("OuterTest1 config not present");
}
}
function serveFastPatchClient(res) {
const zipName = `fastpatch_${config.client.version}.zip`;
if (!serveBinaryFile(res, zipName, "application/zip", {
"cache-control": "no-store, no-cache, must-revalidate, max-age=0",
"pragma": "no-cache",
"expires": "0",
})) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "connection": "close" });
res.end(`fastpatch for client ${config.client.version} not present`);
}
}
function serveResourcePatchInfo(res, requestedVersion, isHead = false) {
// Serve the manifest whose md5 equals the advertised resC. The file name
// follows resV from serveLocalVersion, so both stay in sync (patch info and
// version entry come from the same resource stream).
const resourceVersion = String(
requestedVersion || process.env.Ananta_PROXY_RESOURCE_VERSION || "20260805061659654p0"
);
const filePath = path.join(ROOT, "public", `Patch_${resourceVersion}.info`);
if (!fs.existsSync(filePath)) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "connection": "close" });
res.end(`resource Patch.info for ${resourceVersion} not present`);
return;
}
const stat = fs.statSync(filePath);
res.writeHead(200, {
"content-type": "application/x-info",
"content-length": stat.size,
"connection": "close",
});
if (isHead) {
res.end();
return;
}
const stream = fs.createReadStream(filePath);
stream.on("error", (error) => {
log(`local file stream error Patch_${resourceVersion}.info: ${error.message}`);
res.destroy(error);
});
stream.pipe(res);
}
function serveLocalServerList(res) {
// The row format matches the REAL captured official serverlist
// (public/OuterTest1_serverlist.upstream.txt): exactly 7 columns, with no
// version/rpcMd5/artifact columns. LoginManager.CheckVersion in build
// 4229938 compares RpcMd5/Branch/Tag/ArtifactVersion only when non-empty;
// the official row omits them entirely and so must we. Sending the legacy
// 6905aaa9... md5 made CheckVersion reject every row (the trunk client
// computes its expected contract md5 at runtime), which surfaced as
// "Server is under maintenance" after server-list retries.
const loginListUrl = `http://${LOCAL_LOGIN_HOST}:${LOGIN_LIST_PORT}/LoginList`;
const body = [[
"*",
String(LOCAL_SERVER_ID),
"QA101",
"Outer",
loginListUrl,
"1",
"14",
].join(",")].join("\n") + "\n";
log(`local-serverlist-body config client=${CLIENT_VERSION} serverId=${LOCAL_SERVER_ID} rpcMd5=<empty, CheckVersion bypass>`);
serveText(res, body);
}
function serveLocalAudit(res) {
serveText(
res,
[
"8000000",
"local private server compatibility stub",
"ok",
"",
].join("\n"),
);
}
function serveLocalGameNotice(res) {
// The /game_notice/Login.bin endpoint is consumed by
// LX6.Manager.LoginManager.OnLoginLuaUpdate which decrypts the body
// (AES-256-CBC + PKCS#7, key="Iwgt7nczyaxtZ2jcY9jxvVz6xorrsczf",
// iv="G5EcEeO5SmFXFAR4") and runs it as a Lua text chunk
// (load(content, nil, "t") + xpcall). If we have a pre-built Login.bin
// in public/ we serve that, otherwise an empty body so the game just
// skips the patch step.
if (!serveBinaryFile(res, "Login.bin", "application/octet-stream")) {
serveText(res, "");
}
}
function isUpdateHost(req) {
const host = (req.headers.host || "").split(":")[0].toLowerCase();
// Treat the IFix patch CDN host (l50.gph.netease.com) as "local" too:
// the manifest at /trunk-client_startup_patch_info.txt points the client
// at this host for the stage0 .bytes.standalone download. We serve that
// file from public/ via the /...stage0.patch.bytes.standalone route.
return (
host === HOST ||
host === "serverlist-test.l50.leihuo.netease.com" ||
host === "l50.gdl.netease.com" ||
host === "l50.gph.netease.com" ||
host === "l50.gsgph.netease.com" ||
host === "127.0.0.1" ||
host === "localhost"
);
}
const DEFAULT_DEVICE_ID = crypto
.createHash("md5")
.update(`Ananta:${LOCAL_ACCOUNT_ID}:${LOCAL_PLAYER_PID}`)
.digest("hex")
.toUpperCase();
let lastClientDeviceIdentity = {
deviceid: DEFAULT_DEVICE_ID,
udid: DEFAULT_DEVICE_ID,
};
function normalizeDeviceIdentityValue(value) {
if (value === null || value === undefined) return "";
const text = String(value).trim();
if (!text || text === "null" || text === "undefined") return "";
return text.slice(0, 256);
}
function extractDeviceIdentityFromObject(value, out = {}) {
if (!value || typeof value !== "object") return out;
const keys = Object.keys(value);
for (const key of keys) {
const lower = key.toLowerCase().replace(/[-_]/g, "");
const raw = value[key];
if (lower === "deviceid" || lower === "unisdkdeviceid") {
const v = normalizeDeviceIdentityValue(raw);
if (v) out.deviceid = v;
} else if (lower === "udid") {
const v = normalizeDeviceIdentityValue(raw);
if (v) out.udid = v;
} else if (raw && typeof raw === "object") {
extractDeviceIdentityFromObject(raw, out);
}
}
return out;
}
function parseMaybeJson(text) {
if (!text) return null;
try { return JSON.parse(text); } catch { return null; }
}
function rememberClientDeviceIdentity(req, bodyText = "") {
const found = {};
try {
const url = new URL(req.url || "/", "https://localhost");
for (const [key, value] of url.searchParams.entries()) {
extractDeviceIdentityFromObject({ [key]: value }, found);
}
} catch {}
const contentType = String(req.headers["content-type"] || "").toLowerCase();
const json = parseMaybeJson(bodyText);
if (json) extractDeviceIdentityFromObject(json, found);
if (bodyText && (!json || contentType.includes("application/x-www-form-urlencoded"))) {
try {
const params = new URLSearchParams(bodyText);
for (const [key, value] of params.entries()) {
extractDeviceIdentityFromObject({ [key]: value }, found);
const nested = parseMaybeJson(value);
if (nested) extractDeviceIdentityFromObject(nested, found);
}
} catch {}
}
for (const [key, value] of Object.entries(req.headers || {})) {
extractDeviceIdentityFromObject({ [key]: Array.isArray(value) ? value[0] : value }, found);
}
if (found.deviceid || found.udid) {
const next = {
deviceid: found.deviceid || found.udid || lastClientDeviceIdentity.deviceid || DEFAULT_DEVICE_ID,
udid: found.udid || found.deviceid || lastClientDeviceIdentity.udid || DEFAULT_DEVICE_ID,
};
if (next.deviceid !== lastClientDeviceIdentity.deviceid || next.udid !== lastClientDeviceIdentity.udid) {
log(`unisdk-device captured deviceid=${JSON.stringify(next.deviceid)} udid=${JSON.stringify(next.udid)}`);
}
lastClientDeviceIdentity = next;
}
return lastClientDeviceIdentity;
}
function deviceIdentityFields() {
const identity = lastClientDeviceIdentity || {};
const deviceid = normalizeDeviceIdentityValue(identity.deviceid || identity.udid) || DEFAULT_DEVICE_ID;
const udid = normalizeDeviceIdentityValue(identity.udid || identity.deviceid) || deviceid;
const deviceInfo = {
deviceid,
device_id: deviceid,
deviceId: deviceid,
udid,
UDID: udid,
unisdk_device_id: deviceid,
unisdkDeviceId: deviceid,
UnisdkDeviceId: deviceid,
};
return {
...deviceInfo,
device: { ...deviceInfo },
device_info: { ...deviceInfo },
deviceInfo: { ...deviceInfo },
};
}
function localSauthPayload() {
const pid = String(LOCAL_PLAYER_PID);
const device = deviceIdentityFields();
return {
...device,
code: 200,
subcode: 0,
msg: "ok",
uid: LOCAL_ACCOUNT_ID,
sdkuid: LOCAL_ACCOUNT_ID,
user_id: LOCAL_ACCOUNT_ID,
aid: LOCAL_AID,
pid: LOCAL_PLAYER_PID,
player_id: pid,
playerId: pid,
role_id: pid,
roleid: pid,
roleId: pid,
role_name: LOCAL_ACCOUNT_ID,
roleName: LOCAL_ACCOUNT_ID,
server_id: String(LOCAL_SERVER_ID),
serverId: String(LOCAL_SERVER_ID),
host_id: LOCAL_SERVER_ID,
hostId: LOCAL_SERVER_ID,
username: LOCAL_USERNAME,
account: LOCAL_ACCOUNT_ID,
login_channel: "netease",
app_channel: "a50_sdk_cn",
account_channel: "netease",
pay_channel: "netease",
platform: "pc",
token: LOCAL_LOGIN_TOKEN,
access_token: LOCAL_LOGIN_TOKEN,
ext_access_token: LOCAL_LOGIN_TOKEN,
sessionid: LOCAL_LOGIN_TOKEN,
login_ticket: LOCAL_LOGIN_TOKEN,
realname_status: 1,
realname_verify_status: 1,
mobile_bind_status: 1,
related_login_status: 0,
};
}
function localUniSdkLoginJson() {
const sauth = localSauthPayload();
const device = deviceIdentityFields();
return {
...device,
code: 200,
subcode: 0,
msg: "ok",
uid: sauth.uid,
sdkuid: sauth.sdkuid,
aid: sauth.aid,
pid: sauth.pid,
player_id: sauth.player_id,
playerId: sauth.playerId,
role_id: sauth.role_id,
roleid: sauth.roleid,
roleId: sauth.roleId,
role_name: sauth.role_name,
roleName: sauth.roleName,
server_id: sauth.server_id,
serverId: sauth.serverId,
host_id: sauth.host_id,
hostId: sauth.hostId,
username: sauth.username,
account: sauth.account,
login_channel: sauth.login_channel,
account_channel: sauth.account_channel,
app_channel: sauth.app_channel,
pay_channel: sauth.pay_channel,
platform: sauth.platform,
token: sauth.token,
access_token: sauth.access_token,
ext_access_token: sauth.ext_access_token,
sessionid: sauth.sessionid,
login_ticket: sauth.login_ticket,
sauth: JSON.stringify(sauth),
sauth_json: JSON.stringify(sauth),
realname_status: sauth.realname_status,
mobile_bind_status: sauth.mobile_bind_status,
related_login_status: sauth.related_login_status,
};
}
function localLoginDataJsonString() {
const sauth = localSauthPayload();
const device = deviceIdentityFields();
const uniSdkLogin = localUniSdkLoginJson();
return JSON.stringify({
...device,
code: 200,
subcode: 0,
msg: "ok",
uid: sauth.uid,
sdkuid: sauth.sdkuid,
user_id: sauth.user_id,
aid: sauth.aid,
pid: sauth.pid,
player_id: sauth.player_id,
playerId: sauth.playerId,
role_id: sauth.role_id,
roleid: sauth.roleid,
roleId: sauth.roleId,
role_name: sauth.role_name,
roleName: sauth.roleName,
server_id: sauth.server_id,
serverId: sauth.serverId,
host_id: sauth.host_id,
hostId: sauth.hostId,
username: sauth.username,
account: sauth.account,
token: sauth.token,
access_token: sauth.access_token,
ext_access_token: sauth.ext_access_token,
sessionid: sauth.sessionid,
login_ticket: sauth.login_ticket,
login_channel: sauth.login_channel,
account_channel: sauth.account_channel,
app_channel: sauth.app_channel,
pay_channel: sauth.pay_channel,
platform: sauth.platform,
realname_status: sauth.realname_status,
realname_verify_status: sauth.realname_verify_status,
mobile_bind_status: sauth.mobile_bind_status,
related_login_status: sauth.related_login_status,
sauth: JSON.stringify(sauth),
sauth_json: JSON.stringify(sauth),
SAUTH_JSON: JSON.stringify(sauth),
SAUTH_STR: JSON.stringify(sauth),
NT_SAUTH_STR: JSON.stringify(sauth),
UNISDK_LOGIN_JSON: JSON.stringify(uniSdkLogin),
unisdk_login_json: JSON.stringify(uniSdkLogin),
});
}
function localAccountPayload() {
const sauth = localSauthPayload();
const device = deviceIdentityFields();
const uniSdkLogin = localUniSdkLoginJson();
const sauthJson = JSON.stringify(sauth);
const uniSdkLoginJson = JSON.stringify(uniSdkLogin);
const extraUniSdkData = {
...device,
NT_SAUTH_STR: sauthJson,
SAUTH_STR: sauthJson,
SAUTH_JSON: sauthJson,
UNISDK_LOGIN_JSON: uniSdkLoginJson,
unisdk_login_json: uniSdkLoginJson,
EXT_ACCESS_TOKEN: sauth.ext_access_token,
CHANNEL_ACCESS_TOKEN: sauth.access_token,
};
return {
...device,
id: LOCAL_ACCOUNT_ID,
accountId: LOCAL_ACCOUNT_ID,
account_id: LOCAL_ACCOUNT_ID,
uid: LOCAL_ACCOUNT_ID,
user_id: LOCAL_ACCOUNT_ID,
userid: LOCAL_ACCOUNT_ID,
sdkuid: LOCAL_ACCOUNT_ID,
aid: LOCAL_AID,
pid: LOCAL_PLAYER_PID,
player_id: String(LOCAL_PLAYER_PID),
playerId: String(LOCAL_PLAYER_PID),
role_id: String(LOCAL_PLAYER_PID),
roleid: String(LOCAL_PLAYER_PID),
roleId: String(LOCAL_PLAYER_PID),
role_name: LOCAL_ACCOUNT_ID,
roleName: LOCAL_ACCOUNT_ID,
server_id: String(LOCAL_SERVER_ID),
serverId: String(LOCAL_SERVER_ID),
host_id: LOCAL_SERVER_ID,
hostId: LOCAL_SERVER_ID,
account: LOCAL_ACCOUNT_ID,
un: LOCAL_ACCOUNT_ID,
username: LOCAL_USERNAME,
client_username: LOCAL_USERNAME,
display_username: LOCAL_ACCOUNT_ID,
nickname: LOCAL_ACCOUNT_ID,
idType: "netease",
icon: "",
inGame: false,
rankScore: 0,
rank: 0,
remark: "",
login_channel: "netease",
account_channel: "netease",
app_channel: "a50_sdk_cn",
pay_channel: "netease",
platform: "pc",
token: LOCAL_LOGIN_TOKEN,
access_token: LOCAL_LOGIN_TOKEN,
ext_access_token: LOCAL_LOGIN_TOKEN,
login_ticket: LOCAL_LOGIN_TOKEN,
urs_token: LOCAL_LOGIN_TOKEN,
urs_login_ticket: LOCAL_LOGIN_TOKEN,
sessionid: LOCAL_LOGIN_TOKEN,
sauth: sauthJson,
sauth_json: sauthJson,
NT_SAUTH_STR: sauthJson,
SAUTH_STR: sauthJson,
SAUTH_JSON: sauthJson,
UNISDK_LOGIN_JSON: uniSdkLoginJson,
unisdk_login_json: uniSdkLoginJson,
extra_unisdk_data: extraUniSdkData,
pc_ext_info: {
...device,
extra_unisdk_data: extraUniSdkData,
SAUTH_STR: sauthJson,
SAUTH_JSON: sauthJson,
},
pc_ext_info_json: JSON.stringify({
...device,
extra_unisdk_data: extraUniSdkData,
SAUTH_STR: sauthJson,
SAUTH_JSON: sauthJson,
}),
realname_status: 1,
realname_verify_status: 1,
need_aas: 0,
need_real_name: 0,
need_passwd: 0,
need_email: 0,
need_sms: 0,
mobile_bind_status: 1,
mask_related_mobile: "",
related_login_status: 0,
result: 0,
code: 0,
};
}
function localUniSauthResponse() {
const sauth = localSauthPayload();
const account = localAccountPayload();
const device = deviceIdentityFields();
const sauthJson = JSON.stringify(sauth);
const uniSdkLoginJson = JSON.stringify(localUniSdkLoginJson());
return {
...device,
code: 200,
subcode: 0,
ret: 0,
result: 0,
errno: 0,
status: 0,
success: true,
msg: "ok",
message: "ok",
uid: sauth.uid,
sdkuid: sauth.sdkuid,
user_id: sauth.user_id,
aid: sauth.aid,
username: sauth.username,
account: sauth.account,
login_channel: sauth.login_channel,
account_channel: sauth.account_channel,
app_channel: sauth.app_channel,
pay_channel: sauth.pay_channel,
platform: sauth.platform,
token: sauth.token,
access_token: sauth.access_token,
ext_access_token: sauth.ext_access_token,
sessionid: sauth.sessionid,
login_ticket: sauth.login_ticket,
gas_token: sauth.token,
SAUTH_JSON: sauthJson,
SAUTH_STR: sauthJson,
NT_SAUTH_STR: sauthJson,
UNISDK_LOGIN_JSON: uniSdkLoginJson,
sauth: sauthJson,
sauth_json: sauthJson,
data: sauth,
user: account,
account_info: account,
};
}
function localCheckEnterResponse() {
const sauth = localSauthPayload();
const account = localAccountPayload();
const device = deviceIdentityFields();
return {
...device,
code: 200,
subcode: 0,
ret: 0,
result: 0,
errno: 0,
status: 0,
success: true,
msg: "ok",
message: "ok",
uid: sauth.uid,
sdkuid: sauth.sdkuid,
user_id: sauth.user_id,
aid: sauth.aid,
pid: sauth.pid,
player_id: sauth.player_id,
playerId: sauth.playerId,
role_id: sauth.role_id,
roleid: sauth.roleid,
roleId: sauth.roleId,
token: sauth.token,
access_token: sauth.access_token,
ext_access_token: sauth.ext_access_token,
sessionid: sauth.sessionid,
login_ticket: sauth.login_ticket,
realname_status: 1,
realname_verify_status: 1,
mobile_bind_status: 1,
need_real_name: 0,
need_aas: 0,
can_enter: true,
allow_enter: true,
is_can_enter: true,
data: {
...device,
code: 200,
subcode: 0,
msg: "ok",
enable: true,
uid: sauth.uid,
sdkuid: sauth.sdkuid,
user_id: sauth.user_id,
aid: sauth.aid,
pid: sauth.pid,
player_id: sauth.player_id,
playerId: sauth.playerId,
role_id: sauth.role_id,
roleid: sauth.roleid,
roleId: sauth.roleId,
token: sauth.token,
access_token: sauth.access_token,
ext_access_token: sauth.ext_access_token,
sessionid: sauth.sessionid,
login_ticket: sauth.login_ticket,
realname_status: 1,
realname_verify_status: 1,
mobile_bind_status: 1,
need_real_name: 0,
need_aas: 0,
can_enter: true,
allow_enter: true,
is_can_enter: true,
account,
user: account,
},
};
}
function localGrayReleaseInfoResponse() {
return {
code: 200,
subcode: 0,
ret: 0,
result: 0,
errno: 0,
status: 0,
success: true,
msg: "ok",
message: "ok",
enable: false,
enabled: false,
data: {
enable: false,
enabled: false,
},
};
}
function localProtocolResponse() {
const data = {
protocol_id: 0,
protocol_version: 0,
version: 0,
need_show: false,
need_accept: false,
show: false,
accept: true,
accepted: true,
latest: false,
force: false,
list: [],
templates: [],
agreements: [],
content: "",
};
return {
code: 0,
subcode: 0,
ret: 0,
result: 0,
errno: 0,
status: 0,
success: true,
msg: "ok",
message: "ok",
need_show: false,
need_accept: false,
show: false,
accept: true,
accepted: true,
protocol_id: 0,
protocol_version: 0,
version: 0,
latest: data,
data,
};
}
function mpayOk(data, extra = {}) {
return {
code: 0,
subCode: "OK",
subcode: 0,
ret: 0,
result: 0,
errno: 0,
status: 0,
success: true,
msg: "ok",
message: "ok",
data,
...extra,
};
}
function localMpayLoginUrl(host) {
const safeHost = (host || "service.mkey.163.com").split(":")[0];
// Real MPay URLs carry both names. 4229938/MPay webview reads these
// directly from the login URL, not only from the JSON user object.
const device = deviceIdentityFields();
const params = new URLSearchParams({
game_id: "l50",
app_type: "games",
app_channel: "netease",
udid: device.udid,
device_id: device.device_id,
deviceid: device.deviceid,
});
return `https://${safeHost}/local-mpay-login?${params.toString()}`;
}
function localMpayEmptyUrl(host) {
const safeHost = (host || "service.mkey.163.com").split(":")[0];
return `https://${safeHost}/local-empty`;
}
function localLoginMethodList(host) {
const loginUrl = localMpayLoginUrl(host);
return [
{
id: "urs_mobile_mail",
method: "urs_mobile_mail",
methodId: "urs_mobile_mail",
type: "urs_mobile_mail",
name: "netease",
title: "netease",
enabled: true,
enable: true,
login_url: loginUrl,
url: loginUrl,
},
{
id: "mobile",
method: "mobile",
methodId: "mobile",
type: "mobile",
name: "mobile",
enabled: true,
enable: true,
login_url: loginUrl,
url: loginUrl,
},
{
id: "qrcode",
method: "qrcode",
methodId: "qrcode",
type: "qrcode",
name: "qrcode",
enabled: false,
enable: false,
},
];
}
function localMpayConfig(host) {
const loginUrl = localMpayLoginUrl(host);
const emptyUrl = localMpayEmptyUrl(host);
const methods = localLoginMethodList(host);
return {
app_mode: 2,
pc_mode: 1,
auto_login: false,
web_token_persist: false,
qrcode_enabled: false,
qrcode_scanners: [],
qrcode_extern_links: [emptyUrl],
only_qrcode_pay: false,
qrcode_select_platform: false,
enable_2fa: false,
support_2fa: false,
support_2FA: false,
login_url: loginUrl,
url: loginUrl,
icon_url: emptyUrl,
user_icon_url: emptyUrl,
welcome_icon_url: emptyUrl,
qrcode_icon_url: emptyUrl,
url_info: {
login_url: loginUrl,
login: loginUrl,
register: emptyUrl,
service: emptyUrl,
privacy: emptyUrl,
realname: emptyUrl,
},
global: {
login_url: loginUrl,
url: loginUrl,
service_url: emptyUrl,
privacy_url: emptyUrl,
enable_2fa: false,
support_2fa: false,
support_2FA: false,
},
login_methods: methods,
methods,
list: methods,
country_codes_hash: "",
country_codes: [],
select_platforms: [
{ id: "netease", name: "netease", enabled: true, enable: true },
],
reg_agreement: emptyUrl,
yd_reg_agreement: emptyUrl,
privacy_rule: emptyUrl,
privacy_url: emptyUrl,
service_rule: emptyUrl,
service_url: emptyUrl,
realname_service_url: emptyUrl,
age_tips_intro_url: emptyUrl,
age_tips_icon_url: emptyUrl,
oversea_review_url: emptyUrl,
scanner_download_guide_link: emptyUrl,
mobile_related_login: {
enable: false,
enabled: false,
},
aas: {
enable: false,
enabled: false,
},
};
}
function localLoginMethodsResponse(host) {
const loginUrl = localMpayLoginUrl(host);
const emptyUrl = localMpayEmptyUrl(host);
const entrance = [
[
{ type: 1, name: "netease", hot: false, login_url: "", icon_url: "" },
{ type: 7, name: "guest", hot: false, login_url: "", icon_url: "" },
{ type: 14, name: "quick", hot: false, login_url: "", icon_url: "" },
],
];
const baseConfig = {
bind_guest: false,
binding: false,
select_platforms: [],
user_icon_url: "",
mobile_related_login: false,
welcome_icon_url: "",
};
return {
entrance,
expire_time: 3600,
select_platform: false,
scheme: 0,
account_login: true,
token_login: true,
qrcode_login: false,
login_url: loginUrl,
config: {
"1": {
...baseConfig,
bind_guest: true,
register: true,
login_url: loginUrl,
url: loginUrl,
url_info: {
login_url: loginUrl,
login: loginUrl,
register: emptyUrl,
},
},
"3": baseConfig,
"33": baseConfig,
"7": {
bind_guest: true,
one_click_login_new_ui: false,
one_click_login: false,
global: true,
binding: false,
country_codes: {
list: [
[86, "China Mainland"],
[1, "United States"],
[49, "Germany"],
],
hash: "cc2dda9a7f49de91d23166f9d7140252",
},
select_platforms: [],
user_icon_url: "",
welcome_icon_url: "",
login_url: loginUrl,
url: loginUrl,
},
"9": baseConfig,
"10": baseConfig,
"14": {
bind_guest: true,
user_icon_url: "",
select_platforms: [],
welcome_icon_url: "",
login_url: loginUrl,
url: loginUrl,
url_info: {
privacy_agreement: "",
reg_agreement: "",
login_url: loginUrl,
login: loginUrl,
},
},
"17": {
user_icon_url: "",
select_platforms: [],
welcome_icon_url: "",
},
"18": {
bind_guest: false,
binding: false,
token_url_pattern: "http://localhost:0/\\?ST=.*",
select_platforms: [],
user_icon_url: "",
oauth_url: emptyUrl,
welcome_icon_url: "",
},
"29": baseConfig,
},
list_scheme: 0,
qrcode_select_platform: false,
};
}
function localPcConfigResponse(host) {
const emptyUrl = localMpayEmptyUrl(host);
return {
game: {
config: {
ios_checkstand: 0,
channel_pay_method: 0,
qrcode_pay_allow_all: 0,
only_qrcode_pay: true,
allow_update_pay_method: false,
privacy_default: false,
encrypt: { switch: false },
hm_checkstand: 0,
cv_review_status: 1,
app_mode: 2,
pc_pay_channel_mode: 0,
pay_cashier_type: 0,
version_id: 83122,
web_token_persist: false,
pay_confirm: {
game_url: "",
qrcode_enabled: 0,
},
guide_animation: false,
privacy: {
title: "User Agreement and Privacy Policy",
default: false,
url: emptyUrl,
title2: "User Agreement and Privacy Policy",
agree_style: 2,
usercenter_custom: false,
},
auto_login: false,
aas: {
aas_rollback: 0,
realname_service_url: emptyUrl,
oversea_review_enabled: false,
show_countdown: 0,
oversea_review_url: emptyUrl,
oversea_realname_guide: 0,
show_detail: 0,
type: 0,
unrealname_guide: 0,
},
mobile_related_login: {
guide_related_mobile: false,
force_related_login: false,
allow_update_rl_status: true,
},
login: {
logout_style: 1,
logout_ban: false,
switch_platform_ban: false,
},
age_tips_enable: 0,
limit_device_send_sms_enabled: false,
reuse_migrate: {
reuse_mobile: false,
reuse_guest: false,
reuse_urs: false,
guide_page_type: 0,
},
},
},
};
}
function serveLocalMpayLoginPage(res) {
const user = localAccountPayload();
const serializedUser = JSON.stringify(user);
serveHtml(
res,
`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Local MPay Login</title>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
display: grid;
place-items: center;
background: #f7f7f7;
color: #333;
font-family: system-ui, "Segoe UI", sans-serif;
}
main { text-align: center; }
button {
border: 0;
background: #f5a623;
color: #111;
padding: 12px 22px;
font-weight: 700;
cursor: pointer;
}
</style>
</head>
<body>
<main>
<h1>Local login</h1>
<p>Sending local test account to UniSDK...</p>
<button onclick="sendLogin()">Continue</button>
</main>
<script>
const user = ${serializedUser};
const payload = {
methodId: "ngwebview_notify_native",
reqData: {
methodId: "onUserLogin",
device_id: user.device_id,
deviceid: user.deviceid,
udid: user.udid,
user
}
};
function sendLogin() {
try {
if (window.NeteaseMpayJSBridge &&
window.NeteaseMpayJSBridge.Common &&
typeof window.NeteaseMpayJSBridge.Common.onUserLogin === "function") {
window.NeteaseMpayJSBridge.Common.onUserLogin(user);
}
} catch (error) {}
try {
if (window.UniSDKJSBridge && typeof window.UniSDKJSBridge.postMsgToNative === "function") {
window.UniSDKJSBridge.postMsgToNative(payload);
}
} catch (error) {}
try {
if (typeof window.mwsInvoke === "function") {
window.mwsInvoke({
request: "unisdk_js_native_call:" + JSON.stringify(payload),
onSuccess: function () {},
onFailure: function () {}
});
}
} catch (error) {}
}
window.addEventListener("NeteaseMpayJSBridgeReady", sendLogin);
setTimeout(sendLogin, 200);
setTimeout(sendLogin, 1000);
</script>
</body>
</html>`,
);
}
function serveHttpDns(res) {
const hosts = [
"service.mkey.163.com",
"qatest.g.mkey.163.com",
"qatest-1.g.mkey.163.com",
"qatest-2.g.mkey.163.com",
"qatest-3.g.mkey.163.com",
"qatest-4.g.mkey.163.com",
"qatest-5.g.mkey.163.com",
"qatest-6.g.mkey.163.com",
"qatest-7.g.mkey.163.com",
"qatest-8.g.mkey.163.com",
"mpay-common-server.g.mkey.163.com",
"bind-mobile.g.mkey.163.com",
"mgbsdk.matrix.netease.com",
"openapi.music.163.com",
];
const records = hosts.map((host) => ({
host,
domain: host,
ip: "127.0.0.1",
ips: ["127.0.0.1"],
ttl: 3600,
}));
serveJson(res, {
code: 0,
result: 0,
ret: 0,
dns: records,
data: records,
servers: records,
mapping: Object.fromEntries(hosts.map((host) => [host, ["127.0.0.1"]])),
});
}
function serveLocalMusicApi(res, pathname) {
const token = {
accessToken: "codex-local-music-token",
refreshToken: "codex-local-music-refresh",
// The 4229938 client model declares expireTime as Int32. A year-2100
// timestamp overflows Newtonsoft.Json's ReadAsInt32 and aborts this API.
expireTime: 2147483647,
};
const ok = (data) => ({
code: 200,
subCode: "OK",
message: "ok",
data,
});
if (pathname.includes("/oauth2/login/anonymous")) {
serveJson(res, ok(token));
return;
}
if (pathname.includes("/oauth2/device/login/qrcode/get")) {
serveJson(res, ok({ accessToken: token, status: 803, msg: "ok" }));
return;
}
if (pathname.includes("/oauth2/qrcodekey/get")) {
serveJson(res, ok({ qrCodeUrl: "https://openapi.music.163.com/local-empty", uniKey: "codex-local" }));
return;
}
if (pathname.includes("/song/playurl/get")) {
serveJson(res, ok([]));
return;
}
if (
pathname.includes("/playlist/song/list/get") ||
pathname.includes("/playlist/star/get") ||
pathname.includes("/recommend/songlist/get")
) {
serveJson(res, ok([]));
return;
}
if (pathname.includes("/play/data/record") || pathname.includes("/resource/encrypt")) {
serveJson(res, ok({}));
return;
}
serveJson(res, ok({}));
}
function serveLocalQrcodeApi(res, pathname) {
const account = localAccountPayload();
const qrcode = {
uuid: "codex-local-qrcode",
data_id: "codex-local-qrcode",
qrcode_uid: "codex-local-qrcode",
qrcode_status: 2,
status: 2,
query_interval: 1,
qrcode_url: "https://service.mkey.163.com/local-empty",
qrcode_img_url: "https://service.mkey.163.com/local-empty",
token: account.token,
access_token: account.access_token,
login_ticket: account.login_ticket,
user: account,
account,
};
if (pathname.includes("/api/qrcode/image")) {
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l6KxWQAAAABJRU5ErkJggg==",
"base64",
);
res.writeHead(200, {
"content-type": "image/png",
"content-length": png.length,
"cache-control": "no-store",
"connection": "close",
});
res.end(png);
return;
}
serveJson(res, mpayOk(qrcode, qrcode));
}
async function captureUniSdkRequest(req, res) {
const host = req.headers.host || "unknown-host";
// Crash-dump capture: the client's CrashHunter POSTs the full native crash
// bundle (multipart with mymaindump_*.native.dmp + crashHunterParam_*.txt)
// to l50.appdump.nie.netease.com/upload. We save the RAW bytes to disk so we
// can extract the real crash reason instead of letting it get truncated in
// the text log. This is read-only diagnostics of our own client run.
const hostnameEarly = host.split(":")[0].toLowerCase();
const pathnameEarly = req.url.split("?")[0].toLowerCase();
if (req.method === "POST" && (hostnameEarly.includes("appdump") || pathnameEarly.includes("/upload"))) {
const chunks = [];
let total = 0;
await new Promise((resolve) => {
req.on("data", (c) => { chunks.push(c); total += c.length; });
req.on("end", resolve);
req.on("error", resolve);
});
try {
const crashDir = path.join(ROOT, "crash_uploads");
fs.mkdirSync(crashDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const kind = total > 4096 ? "crash" : "appdump";
const outPath = path.join(crashDir, `${kind}_${stamp}_${total}.bin`);
fs.writeFileSync(outPath, Buffer.concat(chunks));
log(`${kind}-upload-saved ${host}${req.url} bytes=${total} small=${total <= 4096} -> ${outPath}`);
} catch (e) {
log(`appdump-upload-save-error ${e.message}`);
}
serveJson(res, { code: 0, result: 0, msg: "ok" });
return;
}
const body = await collectRequestBody(req);
rememberClientDeviceIdentity(req, body.text);
log(`unisdk-capture ${req.method} https://${host}${req.url} bodyBytes=${body.size} body=${JSON.stringify(body.text)}`);
const pathname = req.url.split("?")[0].toLowerCase();
const hostname = host.split(":")[0].toLowerCase();
if (hostname === "openapi.music.163.com" || pathname.includes("/openapi/music/")) {
log(`local-music ${req.method} https://${host}${req.url}`);
serveLocalMusicApi(res, pathname);
return;
}
if (pathname === "/local-mpay-login") {
log(`local-mpay-login ${req.method} https://${host}${req.url}`);
serveLocalMpayLoginPage(res);
return;
}
if (pathname === "/local-empty") {
log(`local-empty-unisdk ${req.method} https://${host}${req.url}`);
const accept = (req.headers.accept || "").toLowerCase();
if (accept.includes("text/html")) {
serveHtml(res, "<!doctype html><meta charset=\"utf-8\"><title>ok</title>");
} else {
const device = deviceIdentityFields();
serveJson(res, { code: 0, ret: 0, result: 0, errno: 0, msg: "ok", ...device, data: device });
}
return;
}
if (pathname.includes("hdserver")) {
serveHttpDns(res);
return;
}
// The production SDK build talks to mgbsdk.matrix.netease.com (the older test
// build used mgbsdktest.matrix.netease.com); both must hit the local mock,
// otherwise uni_sauth goes to the real server and fails with 401 -> login 220.
const isMgbsdkHost =
hostname === "mgbsdktest.matrix.netease.com" || hostname === "mgbsdk.matrix.netease.com";
if (isMgbsdkHost && pathname.includes("/sdk/check_enter")) {
log(`local-check-enter ${req.method} https://${host}${req.url}`);
serveJson(res, localCheckEnterResponse());
return;
}
if (isMgbsdkHost && pathname.includes("/sdk/get_gray_release_info")) {
log(`local-gray-release ${req.method} https://${host}${req.url}`);
serveJson(res, localGrayReleaseInfoResponse());
return;
}
if (pathname.endsWith("/sdk/uni_sauth") || pathname.includes("/sdk/uni_sauth")) {
log(`local-uni-sauth ${req.method} https://${host}${req.url}`);
serveJson(res, localUniSauthResponse());
return;
}
if (isMgbsdkHost) {
log(`local-mgbsdk-generic ${req.method} https://${host}${req.url}`);
const device = deviceIdentityFields();
serveJson(res, {
...device,
code: 200,
subcode: 0,
ret: 0,
result: 0,
errno: 0,
status: 0,
success: true,
msg: "ok",
message: "ok",
data: device,
});
return;
}
if (
pathname.includes("clientlog") ||
pathname.includes("open_log") ||
pathname.includes("ff_log") ||
pathname.includes("feature2") ||
pathname.includes("/class/upload")
) {
serveJson(res, { code: 0, result: 0, msg: "ok" });
return;
}
if (pathname.includes("/api/template/") || pathname.includes("/tpsl/")) {
serveJson(res, {
code: 0,
result: 0,
data: {},
msg: "ok",
});
return;
}
if (pathname.includes("/api/qrcode/")) {
serveLocalQrcodeApi(res, pathname);
return;
}
if (pathname.endsWith("/mpay/config/common.json") || pathname.endsWith("/config/common.json")) {
const config = localMpayConfig(host);
serveJson(res, mpayOk(config, config));
return;
}
if (pathname.endsWith("/mpay/games/pc_config") || pathname.endsWith("/games/pc_config")) {
serveJson(res, localPcConfigResponse(host));
return;
}
if (pathname.endsWith("/login_methods")) {
serveJson(res, localLoginMethodsResponse(host));
return;
}
if (pathname.includes("/login/mobile/guide")) {
const account = localAccountPayload();
serveJson(
res,
mpayOk(
{
need_guide: false,
guide: false,
show_guide: false,
guide_related_mobile: false,
force_related_login: false,
mobile_related_login: {
enable: false,
enabled: false,
guide_related_mobile: false,
force_related_login: false,
},
account,
user: account,
},
{
need_guide: false,
guide: false,
show_guide: false,
account,
user: account,
},
),
);
return;
}
if (
pathname.includes("/api/config") ||
pathname.includes("/api/devices/upload") ||
pathname.includes("/api/users/create_ticket") ||
pathname.includes("/api/users/check_token") ||
pathname.includes("/api/users/login/ticket") ||
pathname.includes("/api/users/login/battle_net/auth") ||
pathname.includes("/api/users/get_urs_login_ticket_by_token") ||
pathname.includes("/api/users/login/qrcode/exchange_token") ||
pathname.includes("/api/users/login/pc/oauth") ||
pathname.includes("/api/users/realname/update_by_token")
) {
const account = localAccountPayload();
serveJson(res, mpayOk(account, { user: account, account, session: account }));
return;
}
const account = localAccountPayload();
const device = deviceIdentityFields();
serveJson(res, {
...device,
code: 0,
ret: 0,
result: 0,
errno: 0,
msg: "ok",
message: "ok",
data: account,
user: account,
account,
session: account,
});
}
async function proxyRequest(req, res) {
const requestedHost = (req.headers.host || HOST).split(":")[0].toLowerCase();
const upstreamHost = requestedHost === "127.0.0.1" || requestedHost === "localhost"
? HOST
: requestedHost;
const upstreamIp = await resolveUpstream(upstreamHost);
const headers = { ...req.headers, host: upstreamHost };
const upstreamReq = https.request(
{
host: upstreamIp,
servername: upstreamHost,
port: 443,
method: req.method,
path: req.url,
headers,
},
(upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
upstreamRes.pipe(res);
},
);
upstreamReq.on("error", (error) => {
log(`proxy error ${req.method} ${req.url}: ${error.message}`);
if (!res.headersSent) {
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
}
res.end(`proxy error: ${error.message}`);
});
req.pipe(upstreamReq);
}
const server = https.createServer(
{
pfx: fs.readFileSync(PFX_PATH),
passphrase: PFX_PASSPHRASE,
},
async (req, res) => {
try {
// Some 4229938 environment URLs are assembled with a trailing slash and
// arrive as //OuterTest1/.... Normalize them before local route matching.
const pathname = (`/${req.url.split("?")[0]}`).replace(/\/{2,}/g, "/");
if (req.method === "GET" && pathname.endsWith("/serverlist.txt")) {
log(`local-serverlist ${req.method} ${req.url}`);
serveLocalServerList(res);
return;
}
if (req.method === "GET" && pathname.endsWith("/serverlist_review.txt")) {
log(`local-serverlist-review ${req.method} ${req.url}`);
serveLocalServerList(res);
return;
}
if (req.method === "GET" && pathname === "/audit.txt") {
log(`local-audit ${req.method} ${req.url}`);
serveLocalAudit(res);
return;
}
if (req.method === "GET" && pathname === "/game_notice/Login.bin") {
log(`local-notice ${req.method} ${req.url}`);
serveLocalGameNotice(res);
return;
}
if (pathname === "/local-mpay-login" || pathname === "/local-empty") {
await captureUniSdkRequest(req, res);
return;
}
if (!isUpdateHost(req)) {
await captureUniSdkRequest(req, res);
return;
}
if (req.method === "GET" && pathname.endsWith("/ConstConfig.cfg")) {
log(`local-outertest-config ${req.method} ${req.url}`);
serveOuterTestConfig(res);
return;
}
if (req.method === "GET" && pathname.endsWith("/pc_boot.config")) {
log(`local-pc-boot-config ${req.method} ${req.url}`);
const body = Buffer.from("enable-cs-warmup=0\nenable-ecs-shader-warmup=0\n");
res.writeHead(200, {
"content-type": "text/plain; charset=utf-8",
"content-length": body.length,
"cache-control": "no-store",
});
res.end(body);
return;
}
if (req.method === "GET" && pathname.endsWith("/pc_netease_version.json")) {
log(`local ${req.method} ${req.url}`);
serveLocalVersion(res);
return;
}
if (req.method === "GET" && pathname.endsWith("/device_check_pc.json")) {
log(`local-device-check ${req.method} ${req.url}`);
serveLocalDeviceCheck(res);
return;
}
if (req.method === "GET" && pathname.endsWith("/trunk-client_startup_patch_info.txt")) {
log(`local-startup-patch-info ${req.method} ${req.url}`);
serveStartupPatchInfo(res);
return;
}
// Any fastpatch zip request for the configured client version. The URL
// pattern is /fastpatch/trunk/<clientVersion>/fastpatch.zip; matching
// the tail keeps this working across client version bumps.
if (req.method === "GET" && pathname.startsWith("/fastpatch/") && pathname.endsWith("/fastpatch.zip")) {
log(`local-fastpatch ${req.method} ${req.url}`);
serveFastPatchClient(res);
return;
}
const patchInfoMatch = (req.method === "GET" || req.method === "HEAD") && pathname.match(/^\/pc\/([^/]+)\/Patch\.info$/);
if (patchInfoMatch) {
log(`local-resource-patch-info ${req.method} ${req.url}`);
serveResourcePatchInfo(res, patchInfoMatch[1], req.method === "HEAD");
return;
}
if ((req.method === "GET" || req.method === "HEAD") && pathname.includes("vfs_res_version")) {
log(`local-vfs-res-version ${req.method} ${req.url}`);
const vfsPath = path.join(ROOT, "public", "vfs_res_version");
if (fs.existsSync(vfsPath)) {
const data = fs.readFileSync(vfsPath);
res.writeHead(200, {
"content-type": "application/octet-stream",
"content-length": data.length,
"cache-control": "no-store",
});
if (req.method === "HEAD") { res.end(); return; }
res.end(data);
return;
}
}
if ((req.method === "GET" || req.method === "HEAD") && pathname.includes("download_header.ehd")) {
log(`local-download-header ${req.method} ${req.url}`);
const ehdPath = path.join(ROOT, "public", "Blocks", "download_header.ehd");
if (fs.existsSync(ehdPath)) {
const data = fs.readFileSync(ehdPath);
res.writeHead(200, {
"content-type": "application/octet-stream",
"content-length": data.length,
"cache-control": "no-store",
});
if (req.method === "HEAD") { res.end(); return; }
res.end(data);
return;
}
}
// IFix stage0 .bytes.standalone (loaded after the manifest above).
// The manifest references the path
// 2724864/2724864-20260116021030-Assembly-CSharp.stage0.patch.bytes.standalone
// We accept any URL ending in stage0.patch.bytes.standalone so that
// a future patch-id rotation still works without code changes.
if (req.method === "GET" && pathname.endsWith(".stage0.patch.bytes.standalone")) {
log(`local-stage0-patch ${req.method} ${req.url}`);
serveStage0Patch(res);
return;
}
// The same manifest also points at a wrapper ZIP:
// 2724864/2724864-20260116021030-client_startup_patch.zip
// We don't have the original (8638-byte standalone is all the contact
// shared), so we rebuild a ZIP locally that contains only the
// .standalone entry. If the client validates the MD5 against the
// manifest hash (9dd906c1...), this will fail and we'll see that in
// the next log; otherwise the client extracts the standalone from
// here and feeds it to IFix the same way as the direct fetch.
if (req.method === "GET" && pathname.endsWith("client_startup_patch.zip")) {
log(`local-startup-patch-zip ${req.method} ${req.url}`);
if (!serveBinaryFile(res, "client_startup_patch.zip", "application/zip")) {
res.writeHead(404, { "content-type": "text/plain", "connection": "close" });
res.end("startup patch zip not present");
}
return;
}
if (req.method === "GET" && req.url === "/__health") {
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
res.end("ok");
return;
}
log(`proxy ${req.method} ${req.url}`);
await proxyRequest(req, res);
} catch (error) {
log(`fatal request error ${req.method} ${req.url}: ${error.stack || error.message}`);
if (!res.headersSent) {
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
}
res.end(`local proxy error: ${error.message}`);
}
},
);
server.on("tlsClientError", (error, socket) => {
log(
`tls-client-error ${socket.remoteAddress}:${socket.remotePort} ` +
`sni=${JSON.stringify(socket.servername || "")}: ${error.message}`,
);
});
server.listen(PORT, "0.0.0.0", () => {
log(`listening on https://0.0.0.0:${PORT} for ${HOST} and UniSDK capture hosts`);
});
const httpCaptureServer = http.createServer(async (req, res) => {
await captureUniSdkRequest(req, res);
});
httpCaptureServer.listen(HTTP_CAPTURE_PORT, "0.0.0.0", () => {
log(`listening on http://0.0.0.0:${HTTP_CAPTURE_PORT} for UniSDK capture hosts`);
});
const loginListServer = http.createServer((req, res) => {
if (req.method === "GET" && req.url.split("?")[0] === "/LoginList") {
const candidates = loginListCandidates();
const candidate = candidates[0];
loginListRequestCount += 1;
const body = candidate.body;
log(`local-loginlist ${req.method} ${req.url} #${loginListRequestCount} ${candidate.name} -> ${JSON.stringify(body)}`);
serveText(res, body);
return;
}
log(`local-loginlist-404 ${req.method} ${req.url}`);
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
res.end("not found");
});
loginListServer.listen(LOGIN_LIST_PORT, LOCAL_LOGIN_HOST, () => {
log(`listening on http://${LOCAL_LOGIN_HOST}:${LOGIN_LIST_PORT}/LoginList`);
});
const loginTcpServer = net.createServer((socket) => {
const remote = `${socket.remoteAddress}:${socket.remotePort}`;
let pending = Buffer.alloc(0);
let handshaked = false;
log(`login-tcp connect ${remote}`);
function uxFrame(mode, payload) {
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload || []);
const frame = Buffer.alloc(5 + body.length);
frame.writeInt32LE(body.length, 0);
frame.writeUInt8(mode, 4);
body.copy(frame, 5);
return frame;
}
function sendS2CHandshake(clientPayload) {
const clientMagic = clientPayload.readInt32LE(0);
const aesKey = clientPayload.subarray(4, 20);
const nonce = crypto.randomBytes(12);
const chaChaHead = crypto.randomBytes(76);
const aes = crypto.createCipheriv("aes-128-gcm", aesKey, nonce);
const encryptedHead = Buffer.concat([aes.update(chaChaHead), aes.final(), aes.getAuthTag()]);
const payload = Buffer.alloc(368);
payload.writeInt32LE(1, 0); // SessionId
payload.writeInt32LE(10, 4); // HeartbeatInterval
nonce.copy(payload, 8);
encryptedHead.copy(payload, 20);
// bytes 112..367 are the fixed RSA-2048 signature field. The matching
// private-client patch accepts the local unsigned handshake.
const frame = uxFrame(1, payload);
socket.write(frame);
handshaked = true;
log(
`login-tcp send-handshake ${remote} clientMagic=${clientMagic} ` +
`${frame.length} bytes ${frame.toString("hex")}`,
);
}
function sendRpcReturn(methodId, invokeId, err = 0, resultPayload = Buffer.alloc(0)) {
const body = Buffer.alloc(13 + resultPayload.length);
body.writeUInt8(RPC_PACKET_RETURN, 0);
body.writeInt32LE(methodId, 1);
body.writeInt32LE(invokeId, 5);
body.writeUInt32LE(err >>> 0, 9);
resultPayload.copy(body, 13);
const frame = uxFrame(9, body);
socket.write(frame);
log(
`login-tcp send-rpc-return ${remote} method=${methodId} name=${rpcMethodName(methodId)} invoke=${invokeId} ` +
`err=${err} ${frame.length} bytes ${frame.toString("hex")}`,
);
}
function uxI32(value) {
const buffer = Buffer.alloc(4);
buffer.writeInt32LE(value, 0);
return buffer;
}
function uxU32(value) {
const buffer = Buffer.alloc(4);
buffer.writeUInt32LE(value >>> 0, 0);
return buffer;
}
function uxU64(value) {
const buffer = Buffer.alloc(8);
buffer.writeBigUInt64LE(BigInt(value), 0);
return buffer;
}
function uxBool(value) {
return Buffer.from([value ? 1 : 0]);
}
function ux7BitEncodedInt(value) {
const bytes = [];
let remaining = value >>> 0;
while (remaining >= 0x80) {
bytes.push((remaining & 0x7f) | 0x80);
remaining >>>= 7;
}
bytes.push(remaining);
return Buffer.from(bytes);
}
function uxString(value) {
if (value === null || value === undefined) {
return Buffer.from([0]);
}
const text = Buffer.from(String(value), "utf8");
return Buffer.concat([ux7BitEncodedInt(text.length + 1), text]);
}
function uxBuffer(value) {
if (value === null || value === undefined) {
return Buffer.from([0]);
}
const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value);
return Buffer.concat([Buffer.from([1]), uxI32(buffer.length), buffer]);
}
function uxList(values, writeItem) {
if (values === null || values === undefined) {
// SerializeObjectMark.IsNull (0) = null list
return Buffer.from([0x00]);
}
// SerializeObjectMark.Common (255) = non-null list, then int32 count + items
const items = Array.from(values, writeItem);
return Buffer.concat([Buffer.from([0xff]), uxI32(items.length), ...items]);
}
function uxListString(values) {
return uxList(values, uxString);
}
function uxListBytes(values) {
return uxList(values, (value) => Buffer.from([Number(value) & 0xff]));
}
function uxList7Bit(values, writeItem) {
if (values === null || values === undefined) return Buffer.from([0x00]);
const items = Array.from(values, writeItem);
// ux7BitEncodedInt is the raw varint helper; UX logical count N is wire N+1.
return Buffer.concat([Buffer.from([0xff]), ux7BitEncodedInt(items.length + 1), ...items]);
}
function uxListString7Bit(values) {
return uxList7Bit(values, uxString);
}
function uxListBytes7Bit(values) {
return uxList7Bit(values, (value) => Buffer.from([Number(value) & 0xff]));
}
function tryRead7BitEncodedInt(payload, offset) {
let result = 0;
let shift = 0;
for (let i = 0; i < 5; i += 1) {
if (offset + i >= payload.length) {
return null;
}
const byte = payload.readUInt8(offset + i);
result |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) {
return { value: result, next: offset + i + 1 };
}
shift += 7;
}
return null;
}
function tryReadUxString(payload, offset) {
const encodedLen = tryRead7BitEncodedInt(payload, offset);
if (!encodedLen) {
return null;
}
if (encodedLen.value === 0) {
return {
value: null,
next: encodedLen.next,
};
}
const stringLen = encodedLen.value - 1;
const start = encodedLen.next;
const end = start + stringLen;
if (end > payload.length) {
return null;
}
return {
value: payload.subarray(start, end).toString("utf8"),
next: end,
};
}
function checkAccountResultPayload() {
// NeedRoleEnter=false tells the client a character already exists.
// Combined with SyncRoleList(pid=100001) sent after TryLogin +
// CanRequestEnterGame=always-true patch, the client goes directly
// to RequestEnterGame without showing the Create Character panel.
return Buffer.concat([
uxBool(true), // non-null CheckAccountResult object
uxString(localLoginDataJsonString()),
uxString(LOCAL_LOGIN_TOKEN),
uxString(LOCAL_USERNAME),
uxI32(LOCAL_AID),
uxBool(false), // NeedRealNameTip
uxBool(false), // NeedRoleEnter: false = role exists, skip creation.
uxBool(true), // RealNameVerified
uxI32(LOCAL_SERVER_ID),
uxString(""),
uxI32(200),
uxI32(0),
uxString("ok"),
]);
}
function parseCreateRoleArgs(args) {
for (let offset = 0; offset < Math.min(args.length, 12); offset += 1) {
const parsed = tryReadUxString(args, offset);
if (
parsed &&
parsed.value &&
parsed.value.length > 0 &&
parsed.value.length <= 64 &&
/^[\x20-\x7e]+$/.test(parsed.value)
) {
return { name: parsed.value, nameOffset: offset };
}
}
return { raw: args.toString("hex") };
}
function enterGameDataPayload() {
// EnterGameData wire format (RPCSerializeAuto.lua line 2163):
// Aid:Int32, Pid:UInt64, Token:Complex(TokenInfo)
// TokenInfo.Write (line 5199): Aid:Int32, Pid:UInt64, Ip:String, Port:Int32,
// Token:String, RC4Key:String, GateServerId:Int32, AccountId:String
// Complex marker: 0xff (SerializeObjectMarkCommon, NOT 0x01).
const pid = BigInt(LOCAL_PLAYER_PID);
const tokenInfo = Buffer.concat([
Buffer.from([0xff]), // non-null TokenInfo marker (SerializeObjectMarkCommon)
uxI32(LOCAL_AID), // Aid
uxU64(pid), // Pid
uxString(LOCAL_LOGIN_HOST), // Ip = 127.0.0.1
uxI32(LOCAL_LOGIN_TCP_PORT), // Port = 5803 (gate)
uxString(LOCAL_PLAYER_TOKEN), // Token
uxString(""), // RC4Key
uxI32(LOCAL_SERVER_ID), // GateServerId
uxString(LOCAL_ACCOUNT_ID), // AccountId
]);
return Buffer.concat([
uxI32(LOCAL_AID),
uxU64(pid),
tokenInfo,
]);
}
function hotfixPatchCheckPayload() {
return Buffer.concat([
uxI32(0), // remain
// The callback metadata marks md5Infos as a non-null List<string>.
// Send an empty list to mean "no hotfix patches", not a null list.
uxListString7Bit([]),
]);
}
function newHotfixPatchDataPayload() {
return Buffer.concat([
uxI32(-1), // Version: no hotfix patch available.
uxListBytes7Bit([]), // Content
uxString(""), // Md5
]);
}
function patchEntryListPayload() {
return uxList7Bit([], (entry) =>
Buffer.concat([
Buffer.from([0xff]),
uxI32(entry.Version || 0),
uxListBytes7Bit(entry.Content || []),
uxString(entry.Md5 || ""),
]),
);
}
function parseTryLoginArgs(args) {
let offset = 0;
if (offset + 4 > args.length) {
return null;
}
const aid = args.readInt32LE(offset);
offset += 4;
const token = tryReadUxString(args, offset);
if (!token) {
return null;
}
offset = token.next;
if (offset + 2 > args.length) {
return { aid, token: token.value, truncated: true };
}
const updateAasInfo = !!args.readUInt8(offset);
offset += 1;
const kick = !!args.readUInt8(offset);
offset += 1;
const deviceId = tryReadUxString(args, offset);
if (!deviceId) {
return { aid, token: token.value, updateAasInfo, kick, truncated: true };
}
offset = deviceId.next;
if (offset + 2 > args.length) {
return { aid, token: token.value, updateAasInfo, kick, deviceId: deviceId.value, truncated: true };
}
const strictOnlineMode = !!args.readUInt8(offset);
offset += 1;
const confirmBindDevice = !!args.readUInt8(offset);
return {
aid,
token: token.value,
updateAasInfo,
kick,
deviceId: deviceId.value,
strictOnlineMode,
confirmBindDevice,
};
}
function tryReadU64(payload, offset) {
if (offset + 8 > payload.length) {
return null;
}
return {
value: payload.readBigUInt64LE(offset).toString(),
next: offset + 8,
};
}
function tryReadBool(payload, offset) {
if (offset + 1 > payload.length) {
return null;
}
return {
value: !!payload.readUInt8(offset),
next: offset + 1,
};
}
function tryReadDouble(payload, offset) {
if (offset + 8 > payload.length) {
return null;
}
return {
value: payload.readDoubleLE(offset),
next: offset + 8,
};
}
function parseSingleStringArg(args) {
const value = tryReadUxString(args, 0);
if (!value) {
return null;
}
return {
value: value.value,
trailingBytes: args.length - value.next,
};
}
function parsePidTokenArgs(args) {
const pid = tryReadU64(args, 0);
if (!pid) {
return null;
}
const token = tryReadUxString(args, pid.next);
if (!token) {
return { pid: pid.value, truncated: true };
}
return {
pid: pid.value,
token: token.value,
trailingBytes: args.length - token.next,
};
}
function parseGateLoginArgs(args) {
const parsed = parsePidTokenArgs(args);
if (!parsed || parsed.truncated) {
return parsed;
}
let offset = args.length - parsed.trailingBytes;
const isReconnect = tryReadBool(args, offset);
if (!isReconnect) {
return { ...parsed, truncated: true };
}
offset = isReconnect.next;
const deviceInfo = tryReadUxString(args, offset);
if (!deviceInfo) {
return {
...parsed,
isReconnect: isReconnect.value,
remainingHex: args.subarray(offset).toString("hex"),
};
}
offset = deviceInfo.next;
const debug = tryReadBool(args, offset);
if (!debug) {
return {
...parsed,
isReconnect: isReconnect.value,
deviceInfo: deviceInfo.value,
remainingHex: args.subarray(offset).toString("hex"),
};
}
return {
...parsed,
isReconnect: isReconnect.value,
deviceInfo: deviceInfo.value,
debug: debug.value,
trailingBytes: args.length - debug.next,
};
}
function parseClientUnixTimeArgs(args) {
const clientUnixTime = tryReadDouble(args, 0);
if (!clientUnixTime) {
return null;
}
return {
clientUnixTime: clientUnixTime.value,
trailingBytes: args.length - clientUnixTime.next,
};
}
function handleRpcNotify(methodId, args) {
if (methodId === RPC_METHODS.Gate_GetServerTime || methodId === RPC_METHODS.Game_GetServerTime) {
log(
`login-tcp rpc-notify ${remote} method=${methodId} name=${rpcMethodName(methodId)} ` +
`${JSON.stringify(parseClientUnixTimeArgs(args))}`,
);
return true;
}
if (methodId === RPC_METHODS.Game_LoginGame) {
log(
`login-tcp rpc-notify ${remote} method=${methodId} name=${rpcMethodName(methodId)} ` +
`${JSON.stringify(parsePidTokenArgs(args))}`,
);
return true;
}
if (methodId === RPC_METHODS.Game_RequestGameSceneData) {
log(`login-tcp rpc-notify ${remote} method=${methodId} name=${rpcMethodName(methodId)}`);
return true;
}
log(`login-tcp rpc-notify-unhandled ${remote} method=${methodId} name=${rpcMethodName(methodId)} args=${args.toString("hex")}`);
return false;
}
function handleRawRpc(payload) {
if (payload.length < 5) {
log(`login-tcp rpc-short ${remote} payload=${payload.toString("hex")}`);
return false;
}
const rpcMode = payload.readUInt8(0);
const methodId = payload.readInt32LE(1);
if (rpcMode === RPC_PACKET_NOTIFY) {
return handleRpcNotify(methodId, payload.subarray(5));
}
if (payload.length < 9) {
log(
`login-tcp rpc-short ${remote} rpcMode=${rpcMode} method=${methodId} ` +
`name=${rpcMethodName(methodId)} payload=${payload.toString("hex")}`,
);
return false;
}
const invokeId = payload.readInt32LE(5);
const args = payload.subarray(9);
log(
`login-tcp rpc ${remote} rpcMode=${rpcMode} method=${methodId} name=${rpcMethodName(methodId)} ` +
`invoke=${invokeId} args=${args.toString("hex")}`,
);
if (rpcMode !== RPC_PACKET_INVOKE) {
return false;
}
if (methodId === RPC_METHODS.Login_CheckVersion) {
const codeMd5 = tryReadUxString(args, 0);
const clientVersion =
codeMd5 && codeMd5.next + 4 <= args.length ? args.readInt32LE(codeMd5.next) : null;
log(
`login-tcp CheckVersion ${remote} codeMd5=${JSON.stringify(codeMd5 && codeMd5.value)} ` +
`clientVersion=${clientVersion}`,
);
sendRpcReturn(methodId, invokeId, 0);
return true;
}
if (methodId === RPC_METHODS.Login_AskNewHotFixPatchLogin) {
const version = args.length >= 4 ? args.readInt32LE(0) : null;
const md5 = version !== null ? tryReadUxString(args, 4) : null;
const clientVersion =
md5 && md5.next + 4 <= args.length ? args.readInt32LE(md5.next) : null;
// Confirmed by Player.log: the client downloads startup hotfix patches over HTTP
// (trunk-client_startup_patch_info.txt -> stage0). This RPC is not the Stage1
// loading trigger. Returning err=20903 (NoMoreHotFixPatch) lets the client continue
// to the menu; returning err=0 with a struct breaks menu initialization.
log(
`login-tcp AskNewHotFixPatchLogin ${remote} version=${version} ` +
`md5=${JSON.stringify(md5 && md5.value)} clientVersion=${clientVersion} ` +
`reply=NoMoreHotFixPatch(${RPC_ERR_NO_MORE_HOTFIX_PATCH})`,
);
sendRpcReturn(methodId, invokeId, RPC_ERR_NO_MORE_HOTFIX_PATCH);
return true;
}
if (methodId === RPC_METHODS.Login_RequestPatchesCheckDataFromLogin) {
const clientVersion = args.length >= 4 ? args.readInt32LE(0) : null;
const patchVersion = args.length >= 8 ? args.readInt32LE(4) : null;
log(
`login-tcp RequestPatchesCheckDataFromLogin ${remote} ` +
`clientVersion=${clientVersion} patchVersion=${patchVersion}`,
);
sendRpcReturn(methodId, invokeId, 0, hotfixPatchCheckPayload());
return true;
}
if (methodId === RPC_METHODS.Login_RequestCreateRoleEx) {
log(`login-tcp RequestCreateRoleEx ${remote} ${JSON.stringify(parseCreateRoleArgs(args))}`);
// Response: new pid (UXRPCTask<ulong>).
sendRpcReturn(methodId, invokeId, 0, uxU64(BigInt(LOCAL_PLAYER_PID)));
// After CreateRole, send SyncRoleList again with the newly created pid so
// loginRolePid points to the new character and RequestEnterGame can continue.
try {
const rolePayload = uxU64(BigInt(LOCAL_PLAYER_PID));
const body = Buffer.alloc(5 + rolePayload.length);
body.writeUInt8(RPC_PACKET_NOTIFY, 0);
body.writeInt32LE(RPC_NOTIFIES.Login_SyncRoleList, 1);
rolePayload.copy(body, 5);
socket.write(uxFrame(9, body));
log(`login-tcp send-SyncRoleList ${remote} roleId=${LOCAL_PLAYER_PID} [after-CreateRole]`);
} catch (e) {
log(`login-tcp post-CreateRole SyncRoleList error: ${e.message}`);
}
return true;
}
if (methodId === RPC_METHODS.Login_AskUniSdkShareToken_Login) {
log(`login-tcp AskUniSdkShareToken_Login ${remote}`);
sendRpcReturn(methodId, invokeId, 0, uxString(LOCAL_SHARE_TOKEN));
return true;
}
if (
methodId === RPC_METHODS.Login_CheckAccount ||
methodId === RPC_METHODS.Login_CheckAccountPassBy ||
methodId === RPC_METHODS.Login_CheckAccountOpenId
) {
const value = tryReadUxString(args, 0);
log(
`login-tcp CheckAccount ${remote} method=${methodId} ` +
`value=${JSON.stringify(value && value.value)}`,
);
sendRpcReturn(methodId, invokeId, 0, checkAccountResultPayload());
return true;
}
if (methodId === RPC_METHODS.Login_TryLogin) {
const parsed = parseTryLoginArgs(args);
log(`login-tcp TryLogin ${remote} ${JSON.stringify(parsed)}`);
sendRpcReturn(methodId, invokeId, 0);
// After TryLogin, server pushes SyncRoleList(roleId) as Notify.
// SyncRoleList reader = ReadUInt64() -> a single pid, not a list.
//
// Confirmed by LoginManager.lua OnLogin: the client decides:
// loginRolePid == 0 -> RequestEnterGame() (character exists, enter directly)
// loginRolePid != 0 -> CheckShow(CREATE_CHARACTER_PANEL) (name prompt)
// Previously this sent 100001 (!=0). Ananta_ROLE_PID controls the value:
// "0" -> enter the existing character directly, without a prompt
// otherwise -> that value (for example 100001) -> character creation/popup
const rolePidEnv = (process.env.Ananta_ROLE_PID ?? process.env.Ananta_ROLE_PID);
const rolePidValue = rolePidEnv !== undefined ? BigInt(rolePidEnv) : 0n;
if ((process.env.Ananta_NO_ROLELIST ?? process.env.Ananta_NO_ROLELIST) === "1") {
log(`login-tcp SKIP SyncRoleList (Ananta_NO_ROLELIST=1) -> waiting for character creation`);
} else {
try {
const rolePayload = uxU64(rolePidValue);
const body = Buffer.alloc(5 + rolePayload.length);
body.writeUInt8(RPC_PACKET_NOTIFY, 0);
body.writeInt32LE(RPC_NOTIFIES.Login_SyncRoleList, 1);
rolePayload.copy(body, 5);
socket.write(uxFrame(9, body));
log(`login-tcp send-SyncRoleList ${remote} roleId=${rolePidValue} mode=notify(existing-role signal) [reply-to-TryLogin]`);
} catch (e) {
log(`login-tcp SyncRoleList error: ${e.message}`);
}
}
return true;
}
if (methodId === RPC_METHODS.Login_HasOnlinePlayer) {
log(`login-tcp HasOnlinePlayer ${remote} ${JSON.stringify(parseTryLoginArgs(args))}`);
sendRpcReturn(methodId, invokeId, 0);
return true;
}
if (methodId === RPC_METHODS.Login_RequestPatchesFromLogin) {
log(`login-tcp RequestPatchesFromLogin ${remote} args=${args.toString("hex")}`);
sendRpcReturn(methodId, invokeId, 0, patchEntryListPayload());
return true;
}
if (
methodId === RPC_METHODS.Login_RequestEnterGame ||
methodId === RPC_METHODS.Login_DebugRequestEnterGame
) {
log(`login-tcp RequestEnterGame ${remote} method=${methodId}`);
sendRpcReturn(methodId, invokeId, 0, enterGameDataPayload());
return true;
}
if (methodId === RPC_METHODS.Login_RequestFpPassToken) {
log(`login-tcp RequestFpPassToken ${remote} args=${args.toString("hex")}`);
sendRpcReturn(methodId, invokeId, 0, uxString(LOCAL_FP_PASS_TOKEN));
return true;
}
if (methodId === RPC_METHODS.Gate_Login) {
log(`login-tcp Gate_Login ${remote} ${JSON.stringify(parseGateLoginArgs(args))}`);
// Reply to Gate_Login immediately; no timer is required.
sendRpcReturn(methodId, invokeId, 0);
log(`login-tcp Gate_Login response sent ${remote}`);
// Routing (per RPCDeserializeBase.lua: ctx.sidToImpl[mid/1000000][midToName[mid]]):
// SID 53 = GateToClientImpl -> this Gate connection (5803)
// SID 64 = GameToClientImpl -> game-tcp/5804
// SID 154 = AvatarToClientImpl -> RouteWithPid=Gate -> this Gate connection
// Immediately after Gate_Login, send these responses sequentially:
// 1. SendServerTime (SID 53) sets the server Unix time.
// 2. SyncPlayerGameServerInfo (SID 154) tells the client
// where the game server is available (host:port + token).
// 1) SendServerTime (2x f64 LE = unix sec).
try {
const nowSec = Date.now() / 1000.0;
const stPayload = Buffer.alloc(16);
stPayload.writeDoubleLE(nowSec, 0); // clientTime
stPayload.writeDoubleLE(nowSec, 8); // serverTime
const stBody = Buffer.alloc(5 + stPayload.length);
stBody.writeUInt8(RPC_PACKET_NOTIFY, 0); // rpcMode = Notify
stBody.writeInt32LE(RPC_NOTIFIES.Gate_SendServerTime, 1);
stPayload.copy(stBody, 5);
const stFrame = uxFrame(9, stBody);
socket.write(stFrame);
log(`login-tcp send-SendServerTime ${remote} t=${nowSec.toFixed(3)} [reply-to-Gate_Login]`);
} catch (e) {
log(`login-tcp SendServerTime error ${remote}: ${e.message}`);
}
// 2) SyncPlayerGameServerInfo so the client knows where
// the Game-Server lives. SID 154 = AvatarToClientImpl, routed via Gate.
// GameServerInfo wire format = 0xff(marker) + uxString(IP) + uxI32(Port) + uxString(Token)
try {
const gameServerInfoPayload = Buffer.concat([
Buffer.from([0xff]), // common marker (struct present)
uxString(LOCAL_LOGIN_HOST), // ClientListenIp = 127.0.0.1
uxI32(LOCAL_GAME_TCP_PORT), // ClientListenPort = 5804
uxString(LOCAL_PLAYER_TOKEN), // Token
]);
const gsBody = Buffer.alloc(5 + gameServerInfoPayload.length);
gsBody.writeUInt8(RPC_PACKET_NOTIFY, 0); // rpcMode = Notify
gsBody.writeInt32LE(RPC_NOTIFIES.Avatar_SyncPlayerGameServerInfo, 1);
gameServerInfoPayload.copy(gsBody, 5);
const gsFrame = uxFrame(9, gsBody);
socket.write(gsFrame);
log(`login-tcp send-SyncPlayerGameServerInfo ${remote} mode=notify(3) port=${LOCAL_GAME_TCP_PORT} [reply-to-Gate_Login]`);
} catch (e) {
log(`login-tcp SyncPlayerGameServerInfo error ${remote}: ${e.message}`);
}
return true;
}
if (methodId === RPC_METHODS.Gate_AskUniSdkShareToken) {
log(`login-tcp Gate_AskUniSdkShareToken ${remote}`);
sendRpcReturn(methodId, invokeId, 0, uxString(LOCAL_SHARE_TOKEN));
return true;
}
if (
methodId === RPC_METHODS.Gate_AskCloseConnection ||
methodId === RPC_METHODS.Game_AskCloseConnection
) {
log(
`login-tcp AskCloseConnection ${remote} method=${methodId} ` +
`${JSON.stringify(parseSingleStringArg(args))}`,
);
sendRpcReturn(methodId, invokeId, 0, uxU32(0));
return true;
}
if (methodId === RPC_METHODS.Game_AskRemainChangeNameCount) {
log(`login-tcp Game_AskRemainChangeNameCount ${remote}`);
sendRpcReturn(methodId, invokeId, 0, uxU32(99));
return true;
}
if (
methodId === RPC_METHODS.Game_AskChangeNameByItem ||
methodId === RPC_METHODS.Game_AskChangeHackerName
) {
log(
`login-tcp ${rpcMethodName(methodId)} ${remote} ` +
`${JSON.stringify(parseSingleStringArg(args))}`,
);
sendRpcReturn(methodId, invokeId, 0);
return true;
}
if (methodId === RPC_METHODS.Game_AskStartGame) {
log(`login-tcp Game_AskStartGame ${remote}`);
sendRpcReturn(methodId, invokeId, 0);
return true;
}
if (methodId === RPC_METHODS.Game_LoginGame) {
log(`login-tcp Game_LoginGame ${remote} ${JSON.stringify(parsePidTokenArgs(args))}`);
sendRpcReturn(methodId, invokeId, 0);
return true;
}
if (methodId === RPC_METHODS.Game_RequestGameSceneData) {
log(`login-tcp Game_RequestGameSceneData ${remote}`);
// Return empty scene data - the client will load a default/empty scene
sendRpcReturn(methodId, invokeId, 0);
return true;
}
log(`login-tcp rpc-unhandled ${remote} method=${methodId} name=${rpcMethodName(methodId)} invoke=${invokeId} args=${args.toString("hex")}`);
return false;
}
function handleUxMessage(mode, payload) {
log(`login-tcp msg ${remote} mode=${mode} size=${payload.length} payload=${payload.toString("hex")}`);
if (mode === 2 && payload.length >= 20) {
sendS2CHandshake(payload);
return;
}
if (mode === 4) {
// The captured working server replies to heartbeat frames with eight zero
// bytes. Echoing the client timestamp keeps the socket alive briefly, but
// the client later resets the login connection before Play can send RPCs.
const heartbeatAck = Buffer.alloc(8);
const frame = uxFrame(3, heartbeatAck);
socket.write(frame);
log(`login-tcp send-heartbeat ${remote} ${frame.length} bytes ${frame.toString("hex")}`);
return;
}
if (mode === 9) {
handleRawRpc(payload);
return;
}
if (!handshaked) {
log(`login-tcp unexpected-before-handshake ${remote} mode=${mode}`);
}
}
socket.on("data", (chunk) => {
log(`login-tcp recv ${remote} ${chunk.length} bytes ${chunk.toString("hex")}`);
pending = Buffer.concat([pending, chunk]);
while (pending.length >= 5) {
const size = pending.readInt32LE(0);
const mode = pending.readUInt8(4);
if (size < 0 || size > 8 * 1024 * 1024) {
log(`login-tcp invalid-frame ${remote} mode=${mode} size=${size} buffer=${pending.toString("hex")}`);
socket.destroy();
return;
}
if (pending.length < 5 + size) {
return;
}
const payload = pending.subarray(5, 5 + size);
pending = pending.subarray(5 + size);
handleUxMessage(mode, payload);
}
});
socket.on("error", (error) => {
log(`login-tcp error ${remote}: ${error.message}`);
});
socket.on("close", () => {
log(`login-tcp close ${remote}`);
});
});
loginTcpServer.listen(LOCAL_LOGIN_TCP_PORT, LOCAL_LOGIN_TCP_BIND_HOST, () => {
log(
`listening on tcp://${LOCAL_LOGIN_TCP_BIND_HOST}:${LOCAL_LOGIN_TCP_PORT} for login handshake capture; advertising ${LOCAL_LOGIN_TCP_HOSTS.join(", ")}`,
);
});
// =============================================================================
// Phase 1 Game-TCP listener (port 5804) - capture-only, no business logic yet.
// We respond with the same handshake (mode=1, SessionId+HeartbeatInterval+Encryption=false)
// so the client moves past the handshake and starts emitting Game RPC frames.
// Every frame is hex-logged so we can see the actual Game RPC sequence.
// =============================================================================
const gameTcpServer = net.createServer((socket) => {
const remote = `${socket.remoteAddress}:${socket.remotePort}`;
let pending = Buffer.alloc(0);
let handshaked = false;
log(`game-tcp connect ${remote}`);
function uxFrameGame(mode, payload) {
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload || []);
const frame = Buffer.alloc(5 + body.length);
frame.writeInt32LE(body.length, 0);
frame.writeUInt8(mode, 4);
body.copy(frame, 5);
return frame;
}
function sendGameHandshake(clientPayload) {
const aesKey = clientPayload.subarray(4, 20);
const nonce = crypto.randomBytes(12);
const chaChaHead = crypto.randomBytes(76);
const aes = crypto.createCipheriv("aes-128-gcm", aesKey, nonce);
const encryptedHead = Buffer.concat([aes.update(chaChaHead), aes.final(), aes.getAuthTag()]);
const payload = Buffer.alloc(368);
// SessionId can be overridden through the environment (default 1).
// Ananta_GAME_SESSIONID=100001 can be used to test session-matching behavior.
const sessionId = Number((process.env.Ananta_GAME_SESSIONID ?? process.env.Ananta_GAME_SESSIONID) || 1);
payload.writeInt32LE(sessionId, 0); // SessionId
payload.writeInt32LE(10, 4); // HeartbeatInterval seconds
nonce.copy(payload, 8);
encryptedHead.copy(payload, 20);
const frame = uxFrameGame(1, payload);
socket.write(frame);
handshaked = true;
log(`game-tcp send-handshake ${remote} SessionId=${sessionId} ${frame.length} bytes ${frame.toString("hex")}`);
}
socket.on("data", (chunk) => {
log(`game-tcp recv ${remote} ${chunk.length} bytes ${chunk.toString("hex")}`);
pending = Buffer.concat([pending, chunk]);
while (pending.length >= 5) {
const size = pending.readInt32LE(0);
const mode = pending.readUInt8(4);
if (size < 0 || size > 8 * 1024 * 1024) {
log(`game-tcp invalid-frame ${remote} mode=${mode} size=${size}`);
socket.destroy();
return;
}
if (pending.length < 5 + size) {
return;
}
const payload = pending.subarray(5, 5 + size);
pending = pending.subarray(5 + size);
log(`game-tcp frame ${remote} mode=${mode} size=${size} payload=${payload.toString("hex")}`);
// The Game-Server handshake is identical to Login: client sends a Notify
// frame (mode=2) with ClientMagic + local AES key, server responds with a
// mode=1 S2C handshake (SessionId + HeartbeatInterval + Encryption=false).
if (!handshaked && mode === 2 && size >= 20) {
const clientMagic = payload.readUInt32LE(0);
log(`game-tcp client-magic ${remote} 0x${clientMagic.toString(16)}`);
sendGameHandshake(payload);
continue;
}
// Legacy fallback: some flows might send mode=1 directly.
// For RPCBegin (mode=8), we currently don't know the protocol semantics.
// Reply with a neutral RpcReturn so the client doesn't time out.
if (mode === 8 && payload.length >= 9) {
const methodId = payload.readInt32LE(1);
const invokeId = payload.readInt32LE(5);
const ret = Buffer.alloc(13);
ret.writeUInt8(RPC_PACKET_RETURN, 0);
ret.writeInt32LE(methodId, 1);
ret.writeInt32LE(invokeId, 5);
ret.writeUInt32LE(0, 9); // err = 0
const frame = uxFrameGame(9, ret);
socket.write(frame);
log(`game-tcp send-rpc-return ${remote} method=${methodId} invoke=${invokeId} (stub) ${frame.toString("hex")}`);
}
// Mode=9 with RPC_PACKET sub-mode: parse rpcMode + methodId + ...
// LoginGame notify contains uint64 pid + string token.
// We do NOT close the socket. We log and let the client's heartbeat keep the
// connection open. Phase 2 will send proper Init-Notifies in response.
if (mode === 9 && payload.length >= 5) {
const rpcMode = payload.readUInt8(0);
const methodId = payload.readInt32LE(1);
if (rpcMode === RPC_PACKET_NOTIFY) {
const args = payload.subarray(5);
log(`game-tcp client-NOTIFY ${remote} method=${methodId} args=${args.toString("hex")}`);
if (methodId === RPC_METHODS.Game_LoginGame) {
// Bisection switch (env Ananta_SEND), to isolate which post-LoginGame
// packet is toxic vs. which the client actually waits for:
// none -> send ONLY server time; expect the client to TIME OUT (like
// the reference implementations) and NOT crash. Proves the
// crash is caused by one of our data packets, not the flow.
// login -> server time + SyncPlayerInfo with InfoLogin only
// info-item/info-spirit/info-achievement/info-minor -> cumulative
// SyncPlayerInfo bisection up to that top-level Info block
// info -> server time + full autoDefault SyncPlayerInfo
// task -> server time + SyncPlayerInfo + SyncPlayerAllTask
// all -> legacy everything with empty InfoSpirit
// world-solo -> automatic default: real player + one real spirit,
// without the extra world-kick nudges.
const SEND = ((process.env.Ananta_SEND ?? process.env.Ananta_SEND) || "world-solo").toLowerCase();
const infoModes = new Set([
"login", "info-login",
"info-item", "item",
"info-spirit", "spirit",
"info-achievement", "achievement",
"info-minor", "minor",
"info", "task", "all", "world", "world-solo", "solo-world", "world-fresh", "world-min", "world-null",
]);
const worldModes = new Set(["world", "world-solo", "solo-world", "world-fresh", "world-min"]);
const sendInfo = infoModes.has(SEND);
const sendTask = SEND === "task" || SEND === "all" || worldModes.has(SEND);
const sendScene = SEND === "all" || worldModes.has(SEND);
// world-fresh: new character WITHOUT a spirit (no fabricated spirit IDs).
// Testet ob der L50Game-Bau an den konstruierten Spirit-Daten scheitert.
const useWorldSpirit = worldModes.has(SEND) && SEND !== "world-fresh" && SEND !== "world-min";
const useWorldLoadingType = SEND === "world";
const useWorldKick = SEND === "world";
const postLoginDelayMs = Math.max(0, Number((process.env.Ananta_POST_LOGIN_DELAY_MS ?? process.env.Ananta_POST_LOGIN_DELAY_MS) || 350));
log(`game-tcp client-NOTIFY ${remote} = LoginGame(pid+token) - bundle mode=${SEND} (info=${sendInfo} task=${sendTask} scene=${sendScene} loadingType=${useWorldLoadingType} kick=${useWorldKick} delay=${postLoginDelayMs}ms)`);
// 2026-06-06 v12: All payloads built via the schema-driven serializer
// (rpc_serializer.js) that mirrors the client's RPCSerializeBase.lua.
// We fill plain objects; the serializer produces correct bytes.
const PID_VALUE = LOCAL_PLAYER_PID;
const DEFAULT_SPIRIT_TEMPLATE_ID = config.player.initialSpiritTemplateId;
const DEFAULT_SPIRIT_INSTANCE_ID = PID_VALUE;
const DEFAULT_WEAPON_TEMPLATE_ID = 0;
const nowSec = Date.now() / 1000.0;
const notify = (mid, payload) => {
const body = Buffer.concat([Buffer.from([RPC_PACKET_NOTIFY]), int32le(mid), payload]);
return uxFrameGame(9, body);
};
// Experiment: send SyncPlayerInfo as INVOKE (rpcMode=1) instead of Notify
// to bypass Lua notify routing and exercise the native dispatcher.
// Invoke-Frame: [rpcMode=1][int32 mid][int32 invokeId][payload]
const FORCE_INVOKE = ((process.env.Ananta_PLAYERINFO_INVOKE ?? process.env.Ananta_PLAYERINFO_INVOKE) === "1");
const invoke = (mid, invokeId, payload) => {
const body = Buffer.concat([Buffer.from([RPC_PACKET_INVOKE]), int32le(mid), int32le(invokeId), payload]);
return uxFrameGame(9, body);
};
function int32le(n){ const b=Buffer.alloc(4); b.writeInt32LE(n,0); return b; }
function uint32le(n){ const b=Buffer.alloc(4); b.writeUInt32LE(n >>> 0,0); return b; }
function uint64le(n){ const b=Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n),0); return b; }
function boolByte(v){ return Buffer.from([v ? 1 : 0]); }
function doublele(n){ const b=Buffer.alloc(8); b.writeDoubleLE(Number(n),0); return b; }
// (1) SendServerTimeGame - two doubles, no nesting
const stPayload = Buffer.alloc(16);
stPayload.writeDoubleLE(nowSec, 0);
stPayload.writeDoubleLE(nowSec, 8);
try {
socket.write(notify(RPC_NOTIFIES.Game_SendServerTimeGame, stPayload));
log(`game-tcp send-SendServerTimeGame ${remote} t=${nowSec.toFixed(3)}`);
} catch (e) { log(`game-tcp SendServerTimeGame error: ${e.message}`); }
// (2) SyncPlayerInfo -> PlayerClientInfo
// A real server NEVER sends a player with null sub-structs. The client
// (InitPlayerInfo + many PlayerData modules) immediately dereferences
// fields like InfoMinor.InfoNpcProfile.NpcProfiles right after this
// notify - a null there is a hard nil-deref that crashes the Lua VM
// (tolua.dll access violation; isolated via bisection 2026-06-07).
//
// So we build the ENTIRE PlayerClientInfo fully-present via the
// schema-driven autoDefault(): every complex sub-object exists, every
// list/dict is present-empty, every primitive is 0. Then we overlay the
// real identity values. This is exactly what a fresh level-1 role looks
// like on a real server - zero nulls anywhere.
// REAL-WRITER PATH: build the object with the REAL field names from
// RPCSerializeAuto.lua and let the actual client serializer (executed
// in a Lua VM) produce the bytes. Empty lists are passed as null/omitted;
// the real WriteList writes them as present-empty exactly like the
// official server. No hand-built schema, no byte guessing.
const baseInfoLogin = {
Aid: LOCAL_AID,
Pid: PID_VALUE,
AccountId: LOCAL_ACCOUNT_ID,
Name: LOCAL_DISPLAY_NAME,
Level: 1,
Sex: 1,
PzHeadInfo: { HeadType: 0, SystemHeadId: 0 },
};
// Minimal payload experiment: only the three required fields used by the
// L50Game constructor (AccountId/Pid/Aid). Complex info blocks stay present
// but empty, with no spirit, to isolate payload compatibility issues.
const minInfoLogin = {
Aid: LOCAL_AID,
Pid: PID_VALUE,
AccountId: LOCAL_ACCOUNT_ID,
};
const infoBisectRank = {
"info-item": 1, item: 1,
"info-spirit": 2, spirit: 2,
"info-achievement": 3, achievement: 3,
"info-minor": 4, minor: 4,
}[SEND] || 0;
let playerInfoVariant = "login-only";
// A real server sends a fresh player with ALL Info blocks PRESENT
// (never null). The real writer fills every nested field with its
// default, exactly like a freshly created level-1 character. Passing
// {} = present-with-defaults; passing null = absent (which makes the
// Lua SyncPlayerInfoProxy handlers deref nil and abort).
let playerInfoObj = { Config: null, InfoLogin: baseInfoLogin, InfoItem: {}, InfoSpirit: {}, InfoMinor: {}, InfoAchievement: {} };
if (SEND === "world-min") {
// Minimal payload: required fields only, with all complex blocks present but empty.
playerInfoVariant = "world-min";
playerInfoObj = { Config: null, InfoLogin: minInfoLogin, InfoItem: {}, InfoSpirit: {}, InfoMinor: {}, InfoAchievement: {} };
} else if (SEND === "world-null") {
// Null-block test: mark complex sub-blocks as absent (null -> 0x00) so the
// parser does not attempt to read empty present blocks.
playerInfoVariant = "world-null";
playerInfoObj = {
Config: null,
InfoLogin: { Aid: LOCAL_AID, Pid: PID_VALUE, AccountId: LOCAL_ACCOUNT_ID, Level: 1, Sex: 1 },
InfoItem: null,
InfoSpirit: null,
InfoMinor: null,
InfoAchievement: null,
};
} else if (SEND === "info" || SEND === "task" || SEND === "all" || worldModes.has(SEND)) {
playerInfoVariant = "real-writer";
if (useWorldSpirit) {
playerInfoVariant = "world-spirit-realwriter";
playerInfoObj.InfoSpirit = {
// Spirits: list of SpiritInfo (real field names from WriteSpiritInfo)
Spirits: [{
Id: DEFAULT_SPIRIT_INSTANCE_ID,
TemplateId: DEFAULT_SPIRIT_TEMPLATE_ID,
PossessTime: Math.floor(nowSec),
HpRate: 1.0,
SpiritUrbanSkill: null,
SpiritAbilities: null,
SpiritJobInfo: null,
PermanentAddAttributes: null,
InfoBadge: null,
MobileSkinInfo: null,
WeaponSlots: [], // present-empty list (Chef-Fix: explizit [] statt null)
EverSwitched: true,
CurrentJobId: 0,
SpiritBattleInfo: null,
TalentInfo: null,
SpiritFightStyle: null,
Blocked: false,
}],
InfoPokemon: {
AllPokemons: null,
FastFightSquad: [DEFAULT_SPIRIT_INSTANCE_ID],
EnabledBodyIds: [DEFAULT_SPIRIT_TEMPLATE_ID],
EnabledCampIds: null,
EnabledWeaponIds: DEFAULT_WEAPON_TEMPLATE_ID ? [DEFAULT_WEAPON_TEMPLATE_ID] : null,
},
AvailableSkinParts: null,
InfoArmory: null,
ActiveSpirit: DEFAULT_SPIRIT_TEMPLATE_ID,
DisableBadgeInfoDict: null,
InfoFightStyle: null,
CommonSpiritTalentExp: 0,
};
}
} else if (infoBisectRank > 0) {
playerInfoVariant = ["login-only", "up-to-item", "up-to-spirit", "up-to-achievement", "up-to-minor"][infoBisectRank];
if (infoBisectRank >= 1) playerInfoObj.InfoItem = {};
if (infoBisectRank >= 2) playerInfoObj.InfoSpirit = {};
if (infoBisectRank >= 3) playerInfoObj.InfoAchievement = {};
if (infoBisectRank >= 4) playerInfoObj.InfoMinor = {};
}
// ============================================================
// REQUEST-DRIVEN FLOW (2026-06-09): SyncPlayerInfo and SyncPlayerAllTask
// are sent immediately and sequentially after the client's LoginGame notify.
// The scene bundle is handled by the scene-ready flow below.
// ============================================================
// (2) SyncPlayerInfo -> PlayerClientInfo
if (sendInfo) {
try {
const buf = LuaWriter.serializeComplexByRef("78", playerInfoObj);
if (FORCE_INVOKE) {
socket.write(invoke(RPC_NOTIFIES.Game_SyncPlayerInfo, 1, buf));
log(`game-tcp send-SyncPlayerInfo(INVOKE rpcMode=1 invokeId=1 ${playerInfoVariant}) ${remote} ${buf.length}b [reply-to-LoginGame]`);
} else {
socket.write(notify(RPC_NOTIFIES.Game_SyncPlayerInfo, buf));
log(`game-tcp send-SyncPlayerInfo(realwriter-${playerInfoVariant}) ${remote} ${buf.length}b [reply-to-LoginGame]`);
}
} catch (e) { log(`game-tcp SyncPlayerInfo error: ${e.message}`); }
} else {
log(`game-tcp SKIP SyncPlayerInfo (bisect mode)`);
}
// (3) SyncPlayerAllTask - reader has its own schema
// Reader fields: taskInfos[], submitTaskList[], submitEventList[],
// currentTask u32, eventPanelInfo complex[39], eventViewInfoList[], loginGameServer bool
if (sendTask) {
try {
const buf = buildSyncPlayerAllTask();
socket.write(notify(RPC_NOTIFIES.Game_SyncPlayerAllTask, buf));
log(`game-tcp send-SyncPlayerAllTask(v12) ${remote} loginGameServer=true ${buf.length}b [reply-to-LoginGame]`);
} catch (e) { log(`game-tcp SyncPlayerAllTask error: ${e.message}`); }
} else {
log(`game-tcp SKIP SyncPlayerAllTask (bisect mode)`);
}
// (4) SyncEnterScene -> EnterSceneInfo. REAL-WRITER path:
// real field names from WriteEnterSceneInfo, serialized by the actual
// client serializer in the Lua VM.
const WORLD_MAP_RAID_ID = config.world.raidId;
const enterSceneObj = {
PlayerSessionId: PID_VALUE,
RaidId: WORLD_MAP_RAID_ID,
InstanceId: useWorldSpirit ? PID_VALUE : 0,
Position: { X: 1346.209, Y: 133.163, Z: 1857.175 },
Facing: 0,
SpoonLevels: null,
SpoonMd5s: null,
Spirits: useWorldSpirit ? [{
Id: DEFAULT_SPIRIT_INSTANCE_ID,
TemplateId: DEFAULT_SPIRIT_TEMPLATE_ID,
IsActive: true,
WeaponTemplateId: DEFAULT_WEAPON_TEMPLATE_ID,
WeaponSkinId: 0,
}] : null,
GridInfo: { MinX: 0, MinZ: 0, MaxX: 4096, MaxZ: 4096 },
MatchGameId: 0,
SwitchShowId: 0,
IsSwitchSpiritShow: false,
SectorControlId: 0,
LoadingType: useWorldLoadingType ? { Type: 0, Members: [PID_VALUE] } : null,
};
socket._pendingEnterScene = enterSceneObj;
// (4) SyncEnterScene belongs in the direct LoginGame response, sequentially
// after SyncPlayerAllTask. The client reacts to SyncEnterScene before it can
// emit RequestGameSceneData, so do not wait on that request first.
if (sendScene) {
// Timing hardening: SyncEnterScene may be delayed for compatibility.
// Delay scene entry long enough for the native game object to be created
// before the scene-entry gate checks that the game object is available.
const sceneDelayMs = Math.max(0, Number((process.env.Ananta_SCENE_DELAY_MS ?? process.env.Ananta_SCENE_DELAY_MS) || 1500));
const sendSceneNow = () => {
try {
const sceneBuf = LuaWriter.serializeComplexByRef("53", enterSceneObj);
socket.write(notify(RPC_NOTIFIES.Game_SyncEnterScene, sceneBuf));
log(`game-tcp send-SyncEnterScene(realwriter delay=${sceneDelayMs}ms) ${remote} ${sceneBuf.length}b raid=${enterSceneObj.RaidId} [reply-to-LoginGame]`);
} catch (e) { log(`game-tcp SyncEnterScene error: ${e.message}`); }
// Observed on 2026-06-10: this client build does not send
// RequestGameSceneData over the network. Send the required scene follow-up
// packets proactively after SyncEnterScene so world loading can complete.
const proactive = (process.env.Ananta_PROACTIVE_FOLLOWUP ?? process.env.Ananta_PROACTIVE_FOLLOWUP) !== "0";
if (proactive) {
const fuDelay = Math.max(0, Number((process.env.Ananta_FOLLOWUP_DELAY_MS ?? process.env.Ananta_FOLLOWUP_DELAY_MS) || 800));
setTimeout(() => {
if (socket.destroyed) return;
if (typeof socket._sendSceneFollowup === "function") {
log(`game-tcp PROACTIVE scene-followup (no RequestGameSceneData arrives over RPC) +${fuDelay}ms`);
socket._sendSceneFollowup("proactive-after-SyncEnterScene", true);
}
}, fuDelay);
}
};
if (sceneDelayMs > 0) {
log(`game-tcp queuing SyncEnterScene with ${sceneDelayMs}ms delay (C# L50Game build window)`);
setTimeout(() => { if (!socket.destroyed) sendSceneNow(); }, sceneDelayMs);
} else {
sendSceneNow();
}
} else {
log(`game-tcp SKIP SyncEnterScene (bisect mode)`);
}
// Scene follow-up packets may be sent by the request-driven path when the
// client emits RequestGameSceneData.
socket._sceneFollowupSent = false;
socket._sendSceneFollowup = (trigger, force) => {
// In world-solo mode, the proactive path uses force=true because the
// normal world-kick guard would otherwise suppress CurrentSpirit,
// LoadRate, and WorldReady follow-up packets.
if (!sendScene) return;
if (!force && !useWorldKick) return;
if (socket._sceneFollowupSent) return;
socket._sceneFollowupSent = true;
// (5) SyncPlayerCurrentSpirit
try {
const currentSpiritBuf = Buffer.concat([
uint64le(PID_VALUE),
uint32le(DEFAULT_SPIRIT_TEMPLATE_ID),
uint64le(DEFAULT_SPIRIT_INSTANCE_ID),
boolByte(false),
]);
socket.write(notify(RPC_NOTIFIES.GameScene_SyncPlayerCurrentSpirit, currentSpiritBuf));
log(`game-tcp send-SyncPlayerCurrentSpirit ${remote} pid=${PID_VALUE} ${currentSpiritBuf.length}b [reply-to-${trigger}]`);
} catch (e) { log(`game-tcp SyncPlayerCurrentSpirit error: ${e.message}`); }
// (6) SyncPlayerLoadRate
try {
const loadRateBuf = Buffer.concat([
uint64le(PID_VALUE),
doublele(1.0),
]);
socket.write(notify(RPC_NOTIFIES.GameScene_SyncPlayerLoadRate, loadRateBuf));
log(`game-tcp send-SyncPlayerLoadRate ${remote} pid=${PID_VALUE} rate=1 ${loadRateBuf.length}b`);
} catch (e) { log(`game-tcp SyncPlayerLoadRate error: ${e.message}`); }
// (7) SyncWorldReady (GameScene sid 68) - LuaOnly,
// empty-reader "world is ready" trigger, sent last.
try {
socket.write(notify(RPC_NOTIFIES.GameScene_SyncWorldReady, Buffer.alloc(0)));
log(`game-tcp send-SyncWorldReady ${remote} 0b-payload`);
} catch (e) { log(`game-tcp SyncWorldReady error: ${e.message}`); }
};
} else if (methodId === RPC_METHODS.Game_RequestGameSceneData) {
// The client signals that it is ready for scene data after SyncEnterScene.
// Confirm scene entry again and send the scene follow-up packets.
log(`game-tcp client-NOTIFY ${remote} = RequestGameSceneData -> scene followup (request-driven)`);
try {
const enterSceneObj = socket._pendingEnterScene || {
PlayerSessionId: LOCAL_PLAYER_PID, RaidId: config.world.raidId, InstanceId: 0,
Position: { X: 1346.209, Y: 133.163, Z: 1857.175 }, Facing: 0,
SpoonLevels: null, SpoonMd5s: null, Spirits: null,
GridInfo: { MinX: 0, MinZ: 0, MaxX: 4096, MaxZ: 4096 },
MatchGameId: 0, SwitchShowId: 0, IsSwitchSpiritShow: false,
SectorControlId: 0, LoadingType: null,
};
const sceneBuf = LuaWriter.serializeComplexByRef("53", enterSceneObj);
const midBuf = Buffer.alloc(4); midBuf.writeInt32LE(RPC_NOTIFIES.Game_SyncEnterScene, 0);
const esBody = Buffer.concat([Buffer.from([RPC_PACKET_NOTIFY]), midBuf, sceneBuf]);
socket.write(uxFrameGame(9, esBody));
log(`game-tcp send-SyncEnterScene(on-request realwriter) ${remote} ${sceneBuf.length}b raid=${enterSceneObj.RaidId}`);
} catch (e) {
log(`game-tcp SyncEnterScene(on-request) error ${remote}: ${e.message}`);
}
// Send CurrentSpirit, LoadRate, and WorldReady as the response to the
// client's scene-ready signal.
if (typeof socket._sendSceneFollowup === "function") {
socket._sendSceneFollowup("RequestGameSceneData");
}
} else if (methodId === RPC_METHODS.Game_AskPanelBrowsingTime) {
const panelId = args.length >= 4 ? args.readUInt32LE(0) : null;
const logicId = args.length >= 8 ? args.readUInt32LE(4) : null;
const time = args.length >= 12 ? args.readUInt32LE(8) : null;
log(`game-tcp client-NOTIFY ${remote} = AskPanelBrowsingTime panelId=${panelId} logicId=${logicId} time=${time} (no reply)`);
} else {
// Diagnostic logging for every unknown client notify, including method name
// and service ID (floor(mid/1e6)), to identify unhandled world-loading traffic.
const svc = Math.floor(methodId / 1000000);
log(`game-tcp client-NOTIFY ${remote} method=${methodId} name=${rpcMethodName(methodId)} sid=${svc} args=${args.slice(0,32).toString("hex")} (UNHANDLED-NOTIFY)`);
}
} else if (rpcMode === 1 && payload.length >= 9) {
const invokeId = payload.readInt32LE(5);
const args = payload.subarray(9);
log(`game-tcp client-INVOKE ${remote} method=${methodId} invoke=${invokeId} args=${args.toString("hex")}`);
// Stub-return so the client doesn't time out.
const ret = Buffer.alloc(13);
ret.writeUInt8(RPC_PACKET_RETURN, 0);
ret.writeInt32LE(methodId, 1);
ret.writeInt32LE(invokeId, 5);
ret.writeUInt32LE(0, 9);
const frame = uxFrameGame(9, ret);
socket.write(frame);
log(`game-tcp send-rpc-return ${remote} method=${methodId} invoke=${invokeId} (auto-OK) ${frame.toString("hex")}`);
}
}
// 4229938 heartbeat: C2S=4, S2C=3.
if (mode === 4) {
const echo = uxFrameGame(3, payload);
socket.write(echo);
log(`game-tcp heartbeat-echo ${remote} ${echo.toString("hex")}`);
}
}
});
socket.on("error", (error) => {
log(`game-tcp error ${remote}: ${error.message}`);
});
socket.on("close", () => {
log(`game-tcp close ${remote}`);
});
});
gameTcpServer.listen(LOCAL_GAME_TCP_PORT, LOCAL_LOGIN_TCP_BIND_HOST, () => {
log(
`listening on tcp://${LOCAL_LOGIN_TCP_BIND_HOST}:${LOCAL_GAME_TCP_PORT} for game-server handshake capture (Phase 1)`,
);
});
// =============================================================================
// GameScene compatibility sub-server. The listening port comes from private-server.json.
// After L50Game is built, the client connection is redirected to this local listener. We answer the same UX
// 4229938 handshake (mode=2 -> mode=1) and RPC Raw mode=9.
// and log every frame to learn the GameScene (sid 68) protocol.
// =============================================================================
const SCENE_SUB_PORT = config.network.proxy.sceneSubPort;
// The client sends HTTP-style /roadsign requests on this compatibility listener.
// Das ist ein NetEase AOI/Scene-Routing-Dienst ("roadsign"). Wir antworten als
// HTTP server. Keep responses minimal (empty JSON/OK) until the expected
// Format aus der vollen Anfrage kennen.
const sceneSubServer = net.createServer((socket) => {
const remote = `${socket.remoteAddress}:${socket.remotePort}`;
let pending = Buffer.alloc(0);
log(`scene-sub connect ${remote}`);
socket.on("data", (chunk) => {
pending = Buffer.concat([pending, chunk]);
const text = pending.toString("utf8");
log(`scene-sub recv ${remote} ${chunk.length}b FULL=${JSON.stringify(text.slice(0, 400))}`);
// Warte auf Ende der HTTP-Header (\r\n\r\n)
const headerEnd = text.indexOf("\r\n\r\n");
if (headerEnd === -1) return;
const reqLine = text.split("\r\n")[0];
log(`scene-sub HTTP request: ${reqLine}`);
// roadsign = In-Game-Schilder-System (LX6.RoadSign.RoadSignManager). Der
// the native C# parser expects a LIST of signs. An empty object {}
// wirft InvalidCast -> Scene-Load-Coroutine stirbt lautlos -> ANR.
// Daher: data als leeres ARRAY liefern. Per ENV umstellbar fuer Varianten.
const variant = (process.env.Ananta_ROADSIGN ?? process.env.Ananta_ROADSIGN) || "array";
let bodyObj;
if (variant === "listobj") bodyObj = { code: 0, ret: 0, errno: 0, msg: "ok", data: { list: [], signs: [] } };
else bodyObj = { code: 0, ret: 0, errno: 0, msg: "ok", data: [] };
const body = Buffer.from(JSON.stringify(bodyObj), "utf8");
const resp = Buffer.concat([
Buffer.from(
`HTTP/1.1 200 OK\r\n` +
`Content-Type: application/json\r\n` +
`Content-Length: ${body.length}\r\n` +
`Connection: close\r\n\r\n`, "utf8"),
body,
]);
socket.write(resp);
log(`scene-sub HTTP 200 sent ${remote} ${body.length}b body`);
pending = Buffer.alloc(0);
});
socket.on("error", (e) => log(`scene-sub error ${remote}: ${e.message}`));
socket.on("close", () => log(`scene-sub close ${remote}`));
});
sceneSubServer.listen(SCENE_SUB_PORT, LOCAL_LOGIN_TCP_BIND_HOST, () => {
log(`listening on tcp://${LOCAL_LOGIN_TCP_BIND_HOST}:${SCENE_SUB_PORT} for GameScene compatibility sub-server`);
});