- 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 等命令精确触发应答
769 lines
31 KiB
JavaScript
769 lines
31 KiB
JavaScript
// 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 压缩帧自动解压');
|
||
});
|