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 -> empty w.WriteRawBuffer(Buffer.from(emptyList)); // submitTaskList: List -> 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=`); 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, ` Local MPay Login

Local login

Sending local test account to UniSDK...

`, ); } 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, "ok"); } 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//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. // 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). 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`); });