Files
TankIC/3DTank/tools/wss_server.js
T
TermiNexus 2e5e8bd7b0 feat(3dtank): 协议工具链入库:上行命令接入 fake server + 字段表/WireClasses
- wss_server:识别 c2s 空间命令([nullmap][objectId i64][methodId i64][负载])并逐字段解码
- 应答签名改用 S:<methodId>(pair_flow.js 同步),fake 模式能按具体上行命令触发对应下行回放
- extract_codec_fields:新增「协议核心类 → 线上基础类型」挖掘(WireClasses,xr/R → i64)
- lib_space_codec:按模型真名/核心类换算兜底(ji → i64、xr → i64),抽成上下行共享库
- decode_space_frames:记录容器(_t/lt)已核实结构与未闭合环节
- README:补「协议逆向与离线模拟」章节(帧格式/字段编码/命令表/fake 回放/容器现状)

实测:297 条上行记录 100% 完整解出;fake 模式按 G.q1wd、ls.xbl 等命令精确触发应答
2026-09-16 16:30:38 +08:00

769 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// wss_server.js - 本地游戏通信服务器(替代 wss://c1.3dtank.com:9090/)
// 用法:
// node wss_server.js [--port 9091] [--mode relay|fake] [--upstream wss://c1.3dtank.com:9090/] [--label 名字]
// 模式:
// relay 中继(推荐): 客户端 → 本地 → 真实服务器。抓包落盘 + 实时解析:
// 明文段直接解命令;进入加密段后自动穷举 1 字节密钥并后续实时解密;
// 帧头 0x40 位为 zlib 压缩,自动解压后再解命令。
// fake 离线模拟: 不连真实服务器,自己回 Hash 握手(Hash Response + Open Space)。
// 新连接首帧 ID=1 → 大厅握手;首帧 ID=3 → 按 spaceId 回放该空间(不回握手)。
// 回放分两类:主动推送按抓包时间线(timeline_s2c.txt),
// 应答类(flow_s2c.tsv 里带触发签名)等对应 c2s 到达才发、超时按原时间线兜底。
// 触发签名:上行空间帧 = "S:<methodId>"(源码常量,跨会话稳定,能精确到某条命令),
// 其余 = "命令 ID:负载前 4 字节"(与 pair_flow.js 的 Signature 一致,改一边就要改另一边)。
// --replay-scale 缩放、--replay-gap-max 给单次间隔封顶、--replay-gap 兜底间隔。
// 挂载: 客户端 config.xml 指向本服务器(local/config.xml + serve.js 的 /config.xml 本地覆盖)
// <server address="localhost" wsProtocol="WS"> + <port>9091</port> → ws://localhost:9091/
// 产物: 3DTank/packets/<label>/session_00N/{log.txt, c2s_%04d.bin, s2c_%04d.bin}
//
// 协议(逆向自 module_61860 + sockets/):
// 帧 = [2 或 4 字节大端长度][帧体];b0 & 0x80 → 4 字节头;b0 & 0x40 → 帧体 zlib 压缩
// 帧体 = [nullmap][payload];payload = [1 字节命令 ID][body]
// 链路编码器: 明文 nr(直接拷贝)/ 加密 ir(自变体 XOR 流密码)
// 解密: p = c ^ key[idx]; key[idx] = p; idx ^= (p & 7)
// key[i] = K ^ (i << 3)(下发方向);上传方向再 ^ 87;K 为单字节
// Open Space 之后切换加密,故抓包里明文/密文混排
//
// 上行命令识别(默认开,--show-uplink 0 关闭):
// c2s 空间帧的正文 = [nullmap][objectId int64 8B][(methodId int64 8B)(负载)…]
// 出处:module_61860 的 SpaceCommand codec —— fr.o7g 依次写 f2u(objectId)、f2u(methodId),
// 之后才是命令自身负载;or.o7g = 先写命令 id 再写该命令注册的 codec。
// methodId 查 names/uplink_table.json → 命令类 / 发送方法;负载优先用 names/codec_fields.json 的字段名逐字段解。
// 解不动就停在原地并把剩余字节原样打出来(不做宽度猜测)。
const fs = require('fs');
const http = require('http');
const path = require('path');
const zlib = require('zlib');
const { LoadTables, MakeReader, FormatValue } = require('./lib_space_codec');
const PROJECT = path.resolve(__dirname, '..');
const ROOT = path.resolve(PROJECT, '..');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const PORT = Number(Arg('port', '9091'));
const MODE = Arg('mode', 'relay');
const UPSTREAM = Arg('upstream', 'wss://c1.3dtank.com:9090/');
const LABEL = Arg('label', new Date().toISOString().replace(/[:.]/g, '-'));
const PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets', LABEL)));
// 字段表:names/codec_fields.json(codec 字段)+ names/uplink_table.json(上行命令名 / 负载字段)
const Tables = LoadTables(path.join(PROJECT, 'names'));
const Reader = MakeReader(Tables, { EnumBytes: 4 });
const ShowUplink = Arg('show-uplink', '1') !== '0';
// methodId(8 字节大端 hex) → 上行命令(用于在上行帧里认 methodId,以及给应答签名用)
const UplinkByHex = new Map();
for (const [MethodId, Cmd] of Tables.UplinkByMethod) {
const B = Buffer.alloc(8);
B.writeBigInt64BE(BigInt(MethodId));
UplinkByHex.set(B.toString('hex'), Cmd);
}
// ws 模块:优先当前目录解析,回落到 sockets/(唯一装过 ws 的地方)
function RequireWs() {
try {
return require('ws');
} catch (Err) {
return require(path.join(ROOT, 'sockets', 'node_modules', 'ws'));
}
}
const Ws = RequireWs();
// 上游握手头:真实服务器会校验来源
const UPSTREAM_HEADERS = {
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0',
origin: 'https://3dtank.com',
referer: 'https://3dtank.com/',
};
// 命令名(入口层已从源码确证;ID 可跨方向复用,故按方向区分)
const CMD_NAME = {
c2s: { 1: 'Hash Request', 3: 'Auth/EnterSpace', 32: 'ClientLog' },
s2c: { 2: 'Hash Response', 32: 'Open Space' },
};
// ============================================================
// 帧 / nullmap / 密码
// ============================================================
function PeekFrame(Buf) {
if (Buf.length < 2) return null;
const B0 = Buf[0];
if (B0 & 0x80) {
if (Buf.length < 4) return null;
return {
TotalSize: 4 + ((((B0 & 0x3f) << 24) | (Buf[1] << 16) | (Buf[2] << 8) | Buf[3]) >>> 0),
HeaderSize: 4,
Compressed: (B0 & 0x40) !== 0,
};
}
return { TotalSize: 2 + (((B0 & 0x3f) << 8) | Buf[1]), HeaderSize: 2, Compressed: (B0 & 0x40) !== 0 };
}
// 拆出一整串帧;有任何残余则视为「不是帧」返回 null
function ParseFrames(Buf) {
const Frames = [];
let Off = 0;
while (Off < Buf.length) {
const Info = PeekFrame(Buf.slice(Off));
if (!Info || Off + Info.TotalSize > Buf.length) return null;
Frames.push({ Body: Buf.slice(Off + Info.HeaderSize, Off + Info.TotalSize), Compressed: Info.Compressed });
Off += Info.TotalSize;
}
return Frames.length ? Frames : null;
}
// nullmap 长度(对应 x7g)
function NullmapSize(Buf, Offset) {
const R = Buf[Offset];
if (R === undefined) return 0;
if ((R & 0x80) !== 0) {
if (R & 0x40) return 4 + (((R & 0x3f) << 16) | (Buf[Offset + 1] << 8) | Buf[Offset + 2]);
return 1 + (R & 0x3f);
}
return 1 + ((R & 0x60) >>> 5);
}
// 帧签名:命令 ID + 负载前 4 字节(必须与 pair_flow.js 的 Signature 完全一致)
// 上行空间帧没有命令 ID 字节层,改用 methodId 当签名(S:<methodId>)——它是客户端源码里的常量,
// 跨会话稳定,于是 fake 模式能按「具体哪条上行命令」精确触发它的应答组。
function FrameSignature(Frame) {
const At = NullmapSize(Frame.Body, 0);
if (At >= Frame.Body.length) return '-1:';
const Sp = SpaceMethodId(Frame.Body, At);
if (Sp) return 'S:' + Sp;
return Frame.Body[At] + ':' + Frame.Body.slice(At + 1, At + 5).toString('hex');
}
// ============================================================
// 上行命令识别(c2s 空间帧)
// ============================================================
// 帧体 = [nullmap][objectId int64 8B][(methodId int64 8B)(负载)…](出处见文件头注释)
// 逐条记录查 names/uplink_table.json;负载字段名优先取 codec_fields.json(按 methodId 反查 codec)
// 返回 null 表示这条帧不是空间命令(如握手帧、保活帧)
// 首字节是 objectId 的最高字节(实测 0x00 = 小正数 id,0xff = 负数 id);握手/日志帧的首字节是命令 id
// (1=Hash Request、3=Auth、32=ClientLog),且 At+8 处必须命中上行命令表,两道门一起挡住误判。
function SpaceMethodId(Body, At) {
const B0 = Body[At];
if (B0 !== 0x00 && B0 !== 0xff) return null;
if (At + 16 > Body.length) return null;
const Hex = Body.slice(At + 8, At + 16).toString('hex');
return UplinkByHex.has(Hex) ? Body.readBigInt64BE(At + 8).toString() : null;
}
function DescribeUplink(Body, At) {
if (!SpaceMethodId(Body, At)) return null;
const ObjectId = Body.readBigInt64BE(At).toString();
const Records = [];
let Off = At + 8;
while (Off + 8 <= Body.length) {
const Hex = Body.slice(Off, Off + 8).toString('hex');
const Cmd = UplinkByHex.get(Hex);
if (!Cmd) break;
const MethodId = Body.readBigInt64BE(Off).toString();
Off += 8;
const Codec = Tables.CodecByMethod.get(MethodId);
const Fields = Codec
? Codec.Fields
: (Cmd.Fields || []).map((F) => ({ Name: F.Field, Type: F.Type, TypeCodec: F.TypeCodec || '' }));
const Values = [];
let Bad = false;
for (const F of Fields) {
const R = Reader.ReadValue(Body, Off, F.Type, F.TypeCodec, 0);
Values.push([F.Name || '(匿名)', F.Type, R.Value]);
if (R.Bad || R.Next <= Off) {
Bad = true;
break;
}
Off = R.Next;
}
Records.push({
MethodId,
Name: (Cmd.OwnerName || Cmd.OwnerClass || '?') + '.' + (Cmd.SendFn || '?'),
Module: Cmd.Module,
Values,
Bad,
});
}
if (!Records.length) return null;
return { ObjectId, Records, Next: Off };
}
// 把 DescribeUplink 的结果渲染成日志行
function FormatUplink(Info, Body) {
const Lines = [' 上行 objectId=' + Info.ObjectId + ' 命令 ' + Info.Records.length + ' 条'];
for (const R of Info.Records) {
Lines.push(' ' + R.Name + ' (' + R.Module + ') methodId=' + R.MethodId);
for (const [K, T, V] of R.Values)
Lines.push(' ' + String(K).padEnd(22) + ' ' + String(T).padEnd(14) + ' = ' + FormatValue(V));
if (R.Bad) Lines.push(' <负载解不动,剩余原始字节: ' + Hex(Body.slice(Info.Next), 32) + '>');
}
return Lines;
}
// 自变体 XOR 流密码(module_61860 类 ir)
class Cipher {
constructor(K, Encoded) {
this.Slot = new Int32Array(8);
for (let I = 0; I < 8; I++) this.Slot[I] = ((K ^ (I << 3)) ^ (Encoded ? 87 : 0)) & 0xff;
this.Idx = 0;
}
Apply(Buf) {
const Out = Buffer.alloc(Buf.length);
for (let I = 0; I < Buf.length; I++) {
const Plain = (Buf[I] ^ this.Slot[this.Idx]) & 0xff;
Out[I] = Plain;
this.Slot[this.Idx] = Plain;
this.Idx = this.Idx ^ (Plain & 7);
}
return Out;
}
static Series(Window, K, Encoded) {
const C = new Cipher(K, Encoded);
return Window.map((Buf) => C.Apply(Buf));
}
}
function Hex(Buf, N) {
return Buf.slice(0, N || 32).toString('hex');
}
function Ascii(Buf, N) {
return Array.from(Buf.slice(0, N || 40))
.map((B) => (B >= 32 && B < 127 ? String.fromCharCode(B) : '.'))
.join('');
}
// 变长整数长度前缀的字符串(x7g 家族),返回 { Str, Next }
function ReadLogString(Buf, Offset) {
let Len = Buf[Offset];
let Next = Offset + 1;
if (Len & 0x80) {
if (Len & 0x40) {
Len = (((Len & 0x3f) << 16) | (Buf[Offset + 1] << 8) | Buf[Offset + 2]) >>> 0;
Next = Offset + 3;
} else {
Len = ((Len & 0x3f) << 8) | Buf[Offset + 1];
Next = Offset + 2;
}
}
return { Str: Buf.slice(Next, Next + Len).toString('utf8'), Next: Next + Len };
}
// ============================================================
// 单向流解码器:明文 → 探测密钥 → 实时解密
// ============================================================
class StreamDecoder {
constructor(State, Direction) {
this.State = State;
this.Direction = Direction;
this.Encoded = Direction === 'c2s'; // 上传方向密钥表再 ^87
this.Mode = 'plain';
this.Cipher = null;
this.Key = null;
this.Hint = null; // 对向方向破解出的密钥
this.Window = [];
}
Feed(Buf) {
if (this.Mode === 'cipher') {
this.Report(this.Cipher.Apply(Buf), true);
return;
}
if (this.Mode === 'plain') {
if (ParseFrames(Buf)) {
this.Report(Buf, false);
return;
}
// 明文解析失败 → 加密从这里开始
this.Mode = 'probing';
this.Window = [Buf];
this.State.Log('[' + this.Direction + '] 明文段结束,开始穷举密钥(首条密文 ' + Buf.length + ' 字节)');
return;
}
// probing:累积窗口,直到密钥唯一确定或放弃
// 先试对向密钥:命中即当场确定,省掉穷举等待
if (this.Hint !== null && StreamDecoder.SeriesOk(this.Window, this.Hint, this.Encoded)) {
this.State.Log('[' + this.Direction + '] 沿用对向密钥 K=' + this.Hint);
this.Key = this.Hint;
this.Cipher = new Cipher(this.Hint, this.Encoded);
this.Mode = 'cipher';
const Buffered = this.Window;
this.Window = [];
for (const B of Buffered) this.Report(this.Cipher.Apply(B), true);
return;
}
this.Window.push(Buf);
const Bytes = this.Window.reduce((A, B) => A + B.length, 0);
const Hits = [];
for (let K = 0; K < 256; K++) {
if (StreamDecoder.SeriesOk(this.Window, K, this.Encoded)) Hits.push(K);
}
if (Hits.length === 1) {
const K = Hits[0];
this.State.Log('[' + this.Direction + '] 密钥已破解 K=' + K + '(窗口 ' + this.Window.length + ' 条 / ' + Bytes + ' 字节)');
this.Key = K;
this.Cipher = new Cipher(K, this.Encoded);
this.Mode = 'cipher';
const Buffered = this.Window;
this.Window = [];
for (const B of Buffered) this.Report(this.Cipher.Apply(B), true);
return;
}
if (this.Window.length >= 8 || Bytes > 262144) {
this.State.Log('[' + this.Direction + '] 密钥探测失败(候选 ' + Hits.length + ' 个),后续按原始字节记录');
this.Mode = 'plain';
const Buffered = this.Window;
this.Window = [];
for (const B of Buffered) this.Report(B, false);
}
}
// 候选密钥:窗口内每条消息解密后都必须是完整帧
static SeriesOk(Window, K, Encoded) {
for (const Buf of Cipher.Series(Window, K, Encoded)) {
if (!ParseFrames(Buf)) return false;
}
return true;
}
Report(Buf, Decrypted) {
const Tag = Decrypted ? '[密] ' : '[明] ';
const Frames = ParseFrames(Buf);
if (!Frames) {
this.State.Log(Tag + '非帧数据 ' + Buf.length + ' 字节: ' + Hex(Buf, 32));
return;
}
for (const Frame of Frames) {
let Body = Frame.Body;
let Note = Frame.Compressed ? ' [zlib…]' : '';
if (Frame.Compressed) {
try {
Body = zlib.inflateSync(Body);
Note = ' [zlib ' + Frame.Body.length + '→' + Body.length + ']';
} catch (Err) {
Note = ' [zlib 解压失败]';
}
}
const At = NullmapSize(Body, 0);
if (At >= Body.length) {
this.State.Log(Tag + '保活 ping (' + Buf.length + 'B)' + Note);
continue;
}
// 上行空间帧没有「命令 id」字节层:帧体直接是 SpaceCommand(objectId, methodId, 负载)
const Up = this.Direction === 'c2s' && ShowUplink ? DescribeUplink(Body, At) : null;
if (Up) {
this.State.Log(Tag + '空间命令帧 ' + (Body.length - At) + 'B' + Note);
for (const L of FormatUplink(Up, Body)) this.State.Log(L);
continue;
}
const Id = Body[At];
const Payload = Body.slice(At + 1, At + 41);
const Name = (CMD_NAME[this.Direction] && CMD_NAME[this.Direction][Id]) || '未知';
const Extra = this.Direction === 'c2s' && Id === 32 ? ' ' + StreamDecoder.DescribeClientLog(Body.slice(At + 1)) : '';
this.State.Log(
Tag + '命令 ID=' + String(Id).padStart(3) + ' (' + Name + ') 负载=' + (Body.length - At - 1) + 'B' + Note + Extra,
);
this.State.Log(' ' + Payload.toString('hex') + ' |' + Ascii(Payload) + '|');
}
}
// ClientLog body = [LogEntryType][string][string]
static DescribeClientLog(Buf) {
try {
const Type = Buf[0];
const S1 = ReadLogString(Buf, 1);
const S2 = ReadLogString(Buf, S1.Next);
return '类型=' + Type + ' ' + JSON.stringify(S1.Str) + ' ' + JSON.stringify(S2.Str);
} catch (Err) {
return '';
}
}
// 只取 ClientLog 的第二个字符串(UI 事件 JSON,如 {"screen":"Lobby.Home","button":"GARAGE"})
static ClientLogText(Buf) {
try {
const S1 = ReadLogString(Buf, 1);
return ReadLogString(Buf, S1.Next).Str;
} catch (Err) {
return '';
}
}
}
// ============================================================
// 会话与落盘
// ============================================================
fs.mkdirSync(PACKET_DIR, { recursive: true });
function NewSession() {
const Used = fs
.readdirSync(PACKET_DIR, { withFileTypes: true })
.filter((D) => D.isDirectory() && D.name.startsWith('session_'))
.map((D) => Number(D.name.slice('session_'.length)))
.filter((N) => !Number.isNaN(N));
const Next = (Used.length ? Math.max.apply(null, Used) : 0) + 1;
const Dir = path.join(PACKET_DIR, 'session_' + String(Next).padStart(3, '0'));
fs.mkdirSync(Dir, { recursive: true });
const State = { Dir, Stream: fs.createWriteStream(path.join(Dir, 'log.txt'), { flags: 'w' }), C2s: 0, S2c: 0 };
State.Log = (Msg) => {
const Line = '[' + new Date().toISOString() + '] ' + Msg;
console.log(Line);
State.Stream.write(Line + '\n');
};
return State;
}
// 原始字节落盘 + 记录顺序(解析交给 StreamDecoder)
function SaveFrame(State, Direction, Buf, Note) {
const Seq = Direction === 'c2s' ? State.C2s++ : State.S2c++;
fs.writeFileSync(path.join(State.Dir, Direction + '_' + String(Seq).padStart(4, '0') + '.bin'), Buf);
State.Log(
(Direction === 'c2s' ? '>>>' : '<<<') + ' ' + Direction + '_' + String(Seq).padStart(4, '0') + '.bin ' +
Buf.length + ' 字节' + (Note ? ' ' + Note : ''),
);
State.Stream.write(Buf.toString('hex') + '\n');
}
// ============================================================
// fake 模式:自己完成 Hash 握手(明文模式)
// ============================================================
// 依据真实抓包(relay-04/session_002,该连接服务端全程明文)还原的握手尾序列
const FAKE_PING = Buffer.from([0x00, 0x01, 0x00]);
const FAKE_HASH_SPACE = 100045260; // 0x05f691cc:与 Hash Response 同帧下发的空间
// 回放库根目录:<root>/space-<spaceId>/s2c_*.bin(由 dump_plain.js 解密产出)
const REPLAY_ROOT = path.resolve(Arg('replay', path.join(PROJECT, 'packets', 'plain')));
// 回放节奏:优先按 dump_plain.js 产出的 timeline_s2c.txt(原始抓包时刻);
// 没有时间线时退回固定间隔。--replay-scale 缩放整体时间(0.5 = 快一倍),
// --replay-gap-max 给单次间隔封顶(毫秒,0 = 不封顶)
const ReplayGap = Number(Arg('replay-gap', '150')) || 150;
const ReplayScale = Number(Arg('replay-scale', '1')) || 1;
const ReplayGapMax = Number(Arg('replay-gap-max', '0')) || 0;
// 空间 ID 必须与客户端真实空间列表一致(取自抓包字节,勿手抄换算):
// 0x5f691cc=100045260(大厅,与 Hash Response 同帧) 0xd634b693fb=920007382011(快速战斗队列)
// 0x5f7b228=100119080 0x5f6fdcf=100072911 0x5f69398=100045720
// 真实服务器会 Announce 这 5 个;离线模拟默认只 Announce 大厅,
// 想额外 Announce 用 --spaces 920007382011,100119080,...
const EXTRA_SPACES = Arg('spaces', '')
.split(',')
.map((S) => Number(S.trim()))
.filter((N) => !Number.isNaN(N) && N > 0)
.map((Space, I) => ({ Delay: 2000 + I * 1000, Space }));
// 点击驱动的空间下发:真实服是按客户端上报的 button_click 才回 Open Space。
// 抓包确证(garage-01/session_001):05:08:59.549 收到 button_click "GARAGE",
// 05:08:59.992 服务器下发 Open Space(0x5f6fe6e=100073070) ← 车库空间
const CLICK_SPACES = { GARAGE: 100073070 };
function EncodeFrame(Payload, Nullmap) {
const Nm = Nullmap || Buffer.from([0x00]);
const Length = Nm.length + Payload.length;
if (Length < 0x4000) return Buffer.concat([Buffer.from([(Length >> 8) & 0xff, Length & 0xff]), Nm, Payload]);
return Buffer.concat([
Buffer.from([((Length >>> 24) & 0x3f) | 0x80, (Length >>> 16) & 0xff, (Length >>> 8) & 0xff, Length & 0xff]),
Nm,
Payload,
]);
}
// Open Space 命令体 = int64 大端 spaceId
function OpenSpaceCommand(Space) {
const Cmd = Buffer.alloc(9);
Cmd[0] = 32;
Cmd.writeBigInt64BE(BigInt(Space), 1);
return Cmd;
}
function FakeHandshake(State, Socket, Timers) {
const Hash = Buffer.alloc(32);
for (let I = 0; I < 32; I++) Hash[I] = Math.floor(Math.random() * 256);
// 末位 bool = 客户端 t.v7j_1(是否启用链路加密);发 0 让整条链路保持明文
const HashCmd = Buffer.concat([Buffer.from([2]), Hash, Buffer.from([0x00])]);
const Frame = EncodeFrame(Buffer.concat([HashCmd, OpenSpaceCommand(FAKE_HASH_SPACE)]), Buffer.from([0x00]));
State.Log(
'握手: Hash Response(hash=' + Hash.toString('hex') + ', 明文模式) + Open Space(' + FAKE_HASH_SPACE + ') ' + Frame.length + 'B',
);
Socket.send(Frame);
// 按真实抓包的节奏继续下发 Announce 的空间
let Acc = 0;
for (const Step of EXTRA_SPACES) {
Acc += Step.Delay;
Timers.push(
setTimeout(() => {
if (Socket.readyState !== Ws.OPEN) return;
State.Log('下发 Open Space(' + Step.Space + ')');
Socket.send(EncodeFrame(OpenSpaceCommand(Step.Space), Buffer.from([0x00])));
}, Acc),
);
}
}
// 读取 dump_plain.js 产出的时间线(帧名 → 原始抓包时刻,毫秒)
function ReadTimeline(Dir) {
const P = path.join(Dir, 'timeline_s2c.txt');
const Out = new Map();
if (!fs.existsSync(P)) return Out;
for (const Line of fs.readFileSync(P, 'utf8').split('\n')) {
const M = /^(\S+)\t(\d+)$/.exec(Line.trim());
if (M) Out.set(M[1], Number(M[2]));
}
return Out;
}
// 读取 pair_flow.js 产出的应答表(帧名 → { Trigger, Delay, Ms })
function ReadFlow(Dir) {
const P = path.join(Dir, 'flow_s2c.tsv');
const Out = new Map();
if (!fs.existsSync(P)) return Out;
for (const Line of fs.readFileSync(P, 'utf8').split('\n')) {
const C = Line.trim().split('\t');
if (C.length < 4) continue;
Out.set(C[0], { Trigger: C[1], Delay: Number(C[2]) || 0, Ms: Number(C[3]) || 0 });
}
return Out;
}
// 立刻发出某一个待触发的应答组(组内按抓包记录的相对延迟顺序)
function FlushWatcher(State, Socket, Item) {
if (Item.Done) return;
Item.Done = true;
for (const T of Item.Timers) clearTimeout(T);
Item.Timers = [];
let Acc = 0;
for (const X of Item.Frames) {
Acc += Math.max(0, X.Delay * ReplayScale);
const At = Math.round(Acc);
setTimeout(() => {
if (Socket.readyState !== Ws.OPEN) return;
State.Log('应答 ' + X.File + ' ' + X.Buf.length + 'B (触发 ' + SigLabel(Item.Signature) + ')');
Socket.send(X.Buf);
}, At);
}
}
// 应答签名 → 日志标签:S:<methodId> 补上上行命令名(如 S:1816… (G.q1wd))
function SigLabel(Sig) {
const M = /^S:(\d+)$/.exec(Sig);
if (!M) return Sig;
const C = Tables.UplinkByMethod.get(M[1]);
return Sig + (C ? ' (' + (C.OwnerName || C.OwnerClass || '?') + '.' + (C.SendFn || '?') + ')' : '');
}
// 空间回放:
// · 无触发者的帧(主动推送:ping 周期、状态同步)按抓包时间线发;
// · 有触发者的帧(应答)挂到 Watchers,等对应 c2s 到达再发,同时按原时间线兜底。
// CurrentSig = 当前这条 c2s 的签名(进空间那一条要立刻触发初始化序列)
function ReplaySpace(State, Socket, Timers, Space, Watchers, CurrentSig) {
const Dir = path.join(REPLAY_ROOT, 'space-' + Space);
if (!fs.existsSync(Dir)) {
State.Log('(fake 模式)无回放数据: ' + Dir);
return 0;
}
const Files = fs
.readdirSync(Dir)
.filter((F) => /^s2c_\d+\.bin$/.test(F))
.sort();
const Timeline = ReadTimeline(Dir);
const Flow = ReadFlow(Dir);
const Timed = [];
const Held = [];
let Prev = null; // 上一条消息的时间线时刻(含被跳过的保活 ping,保证节奏不压缩)
for (const F of Files) {
const Buf = fs.readFileSync(path.join(Dir, F));
const Info = PeekFrame(Buf);
const IsPing =
Info && Info.TotalSize === Buf.length && NullmapSize(Buf, Info.HeaderSize) >= Buf.length - Info.HeaderSize;
const Ms = Timeline.has(F) ? Timeline.get(F) : Prev === null ? 0 : Prev + ReplayGap;
Prev = Ms;
const Plan = Flow.get(F);
if (Plan && Plan.Trigger !== '-') Held.push({ File: F, Buf, At: Ms, Delay: Plan.Delay, Trigger: Plan.Trigger });
else Timed.push({ File: F, Buf, IsPing, At: Ms });
}
// 主动推送:相对空间进入时刻按时间线铺开
let Acc = 100;
let PrevMs = null;
for (const X of Timed) {
const Gap = PrevMs === null ? 0 : (X.At - PrevMs) * ReplayScale;
PrevMs = X.At;
Acc += ReplayGapMax > 0 ? Math.min(Gap, ReplayGapMax) : Gap;
if (X.IsPing) continue;
const At = Math.round(Acc);
Timers.push(
setTimeout(() => {
if (Socket.readyState !== Ws.OPEN) return;
State.Log('推送 ' + X.File + ' ' + X.Buf.length + 'B @+' + At + 'ms');
Socket.send(X.Buf);
}, At),
);
}
// 应答:按触发签名分组,等待 c2s;超过原时间线仍没等到就兜底发出去
const Groups = new Map();
for (const X of Held) {
let G = Groups.get(X.Trigger);
if (!G) {
G = { Signature: X.Trigger, Frames: [], Done: false, Timers: [] };
Groups.set(X.Trigger, G);
}
G.Frames.push(X);
}
for (const G of Groups.values()) {
for (const X of G.Frames) {
const At = Math.round(100 + X.At * ReplayScale);
G.Timers.push(
setTimeout(() => {
State.Log('应答组 ' + SigLabel(G.Signature) + ' 未等到触发,按原时间线兜底');
FlushWatcher(State, Socket, G);
}, At),
);
}
Watchers.push(G);
// 进空间那条 c2s 要立刻把初始化序列打出去(签名里带的是本会话随机 hash,不能按值比对)
if (CurrentSig && (G.Signature === CurrentSig || (G.Signature.startsWith('3:') && CurrentSig.startsWith('3:')))) {
FlushWatcher(State, Socket, G);
}
}
State.Log(
'(fake 模式)回放 space-' + Space + ':推送 ' + Timed.length + ' 帧(时间线) + 应答 ' + Held.length +
' 帧(' + Groups.size + ' 组等 c2s 触发)' + (Timeline.size ? '' : '(无时间线,固定 ' + ReplayGap + 'ms)'),
);
return Timed.length + Held.length;
}
// ============================================================
// 服务端
// ============================================================
const Server = http.createServer((Req, Res) => {
Res.writeHead(426, { 'Content-Type': 'text/plain; charset=utf-8' }).end('WebSocket only\n');
});
// 两个方向的 K 必然相同(一次构造展开两套密钥表),一侧破解后提示另一侧
function SyncKeys(Decoders) {
if (Decoders.s2c.Key !== null && Decoders.c2s.Hint === null) Decoders.c2s.Hint = Decoders.s2c.Key;
if (Decoders.c2s.Key !== null && Decoders.s2c.Hint === null) Decoders.s2c.Hint = Decoders.c2s.Key;
}
const WsServer = new Ws.Server({ server: Server });
WsServer.on('connection', (Client, Req) => {
const State = NewSession();
State.Log('=== 新客户端 ===');
State.Log('来源: ' + (Req.socket.remoteAddress || '?') + ' Origin: ' + (Req.headers.origin || '无'));
State.Log('模式: ' + MODE + (MODE === 'relay' ? ' 上游: ' + UPSTREAM : ''));
const Decoders = { c2s: new StreamDecoder(State, 'c2s'), s2c: new StreamDecoder(State, 's2c') };
const Timers = []; // fake 模式的定时下发
const Announced = new Set(); // 本连接已下发的(点击驱动的)空间,避免重复
const Watchers = []; // 本连接的应答组(等对应 c2s 到达才发)
let Keepalive = null;
let Upstream = null;
const Pending = []; // 上游未就绪时暂存(握手帧常比上游 TLS 建连更早到达)
if (MODE === 'relay') {
Upstream = new Ws(UPSTREAM, { headers: UPSTREAM_HEADERS, rejectUnauthorized: false });
Upstream.binaryType = 'nodebuffer';
Upstream.on('open', () => {
State.Log('上游已连接: ' + UPSTREAM + ',补发暂存 ' + Pending.length + ' 帧');
while (Pending.length) Upstream.send(Pending.shift());
});
Upstream.on('message', (Data) => {
const Buf = Buffer.from(Data);
SaveFrame(State, 's2c', Buf);
Decoders.s2c.Feed(Buf);
SyncKeys(Decoders);
if (Client.readyState === Ws.OPEN) Client.send(Buf);
});
Upstream.on('close', (Code) => {
State.Log('上游关闭 code=' + Code);
Client.close();
});
Upstream.on('error', (Err) => State.Log('上游错误: ' + Err.message));
}
Client.on('message', (Data) => {
const Buf = Buffer.from(Data);
SaveFrame(State, 'c2s', Buf);
Decoders.c2s.Feed(Buf);
SyncKeys(Decoders);
if (MODE === 'relay') {
if (Upstream && Upstream.readyState === Ws.OPEN) Upstream.send(Buf);
else {
State.Log('上游尚未就绪,暂存 ' + Buf.length + ' 字节待补发');
Pending.push(Buf);
}
return;
}
// fake 模式
const Frames = ParseFrames(Buf);
if (!Frames) {
State.Log('(fake 模式)无法解析的帧,忽略');
return;
}
for (const Frame of Frames) {
const At = NullmapSize(Frame.Body, 0);
const Id = At < Frame.Body.length ? Frame.Body[At] : -1;
const Sig = FrameSignature(Frame);
// 上行空间命令的识别与打印已由上面的 Decoders.c2s.Feed() 完成,这里只取结果用于抑制「未实现命令」噪声
const Up = ShowUplink ? DescribeUplink(Frame.Body, At) : null;
// 应答式回放:本条 c2s 命中某个待发的应答组时立刻把它发出去
for (const W of Watchers) if (!W.Done && W.Signature === Sig) FlushWatcher(State, Client, W);
if (Id === 1) {
FakeHandshake(State, Client, Timers);
if (!Keepalive) {
// 真实服务器每 10s 发一次保活
Keepalive = setInterval(() => {
if (Client.readyState === Ws.OPEN) Client.send(FAKE_PING);
}, 10000);
}
} else if (Id === -1) {
State.Log('保活 ping,原样回包');
Client.send(FAKE_PING);
} else if (Id === 3 && At + 41 <= Frame.Body.length) {
const Space = Frame.Body.readBigInt64BE(At + 33).toString();
State.Log(
'(fake 模式)收到空间鉴权: digest=' +
Frame.Body.slice(At + 1, At + 33).toString('hex').slice(0, 16) +
'… spaceId=' +
Space,
);
ReplaySpace(State, Client, Timers, Space, Watchers, Sig);
} else if (Id === 32) {
// UI 事件:照真实服的做法,按 button_click 下发对应空间
const Text = StreamDecoder.ClientLogText(Frame.Body.slice(At + 1));
const M = /"button"\s*:\s*"([A-Za-z_]+)"/.exec(Text);
const Space = M ? CLICK_SPACES[M[1]] : undefined;
if (Space && !Announced.has(Space)) {
Announced.add(Space);
State.Log('客户端点击 ' + M[1] + ' → 下发 Open Space(' + Space + ')');
Client.send(EncodeFrame(OpenSpaceCommand(Space), Buffer.from([0x00])));
}
} else if (!Up) State.Log('(fake 模式)未实现命令 ID=' + Id + ',忽略');
}
});
Client.on('close', (Code) => {
State.Log('客户端断开 code=' + Code + ' 收 ' + State.C2s + ' 条 / 发 ' + State.S2c + ' 条');
if (Keepalive) clearInterval(Keepalive);
for (const T of Timers) clearTimeout(T);
State.Stream.end();
if (Upstream && Upstream.readyState === Ws.OPEN) Upstream.close();
});
Client.on('error', (Err) => State.Log('客户端错误: ' + Err.message));
});
Server.listen(PORT, () => {
console.log('游戏通信服务器已启动: ws://localhost:' + PORT + '/');
console.log(' 模式 : ' + MODE + (MODE === 'relay' ? ' → ' + UPSTREAM : ' (离线模拟)'));
console.log(' 抓包目录: ' + PACKET_DIR);
console.log(' 实时解析: 明文直解;加密段自动穷举密钥后实时解密;zlib 压缩帧自动解压');
});