旧 main.b7114b43.js 已被新构建替换(10,011,039 字节 / 1335 模块),本地镜像整体重切到新版并清掉旧产物;hornet 工具链按 tools/map_symbols.js 生成的新旧符号映射改名后重新打包(3dtank_bundle.js 与 unpacked/hornet_bundle.js 同步)。tools/symbol_map.json(8.9MB 映射产物)与切割解包目录不进库,可用 map_symbols.js / split.js 重新生成。
586 lines
18 KiB
JavaScript
586 lines
18 KiB
JavaScript
// ============================================================
|
|
// 3DTank WebSocket 伪造客户端
|
|
// 手动构造 Hash Request 并发送,捕获服务器响应
|
|
// 用法: node forge.js [session_name]
|
|
// ============================================================
|
|
|
|
const WebSocket = require('ws');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// ============================================================
|
|
// 协议编码器
|
|
// ============================================================
|
|
|
|
// 变长整数编码 (对应 g7g)
|
|
// n < 0x80: 1 byte
|
|
// n < 0x4000: 2 bytes [0x80|(n>>8), n&0xFF]
|
|
// n < 0x400000: 3 bytes [0xC0|(n>>16), (n>>8)&0xFF, n&0xFF]
|
|
function encodeVarint(n) {
|
|
if (n < 0x80) {
|
|
return Buffer.from([n & 0x7F]);
|
|
} else if (n < 0x4000) {
|
|
const i = 0x8000 | (n & 0x3FFF);
|
|
return Buffer.from([(i >> 8) & 0xFF, i & 0xFF]);
|
|
} else {
|
|
const r = 0xC00000 | (n & 0x3FFFFF);
|
|
return Buffer.from([(r >> 16) & 0xFF, (r >> 8) & 0xFF, r & 0xFF]);
|
|
}
|
|
}
|
|
|
|
// 变长整数解码 (对应 h7g)
|
|
function decodeVarint(buf, offset) {
|
|
const b0 = buf[offset];
|
|
if ((b0 & 0x80) === 0) {
|
|
return { value: b0 & 0x7F, nextOffset: offset + 1 };
|
|
}
|
|
const b1 = buf[offset + 1];
|
|
if ((b0 & 0x40) === 0) {
|
|
// 2 bytes
|
|
return { value: ((b0 & 0x3F) << 8) | b1, nextOffset: offset + 2 };
|
|
}
|
|
// 3 bytes
|
|
const b2 = buf[offset + 2];
|
|
return { value: ((b0 & 0x3F) << 16) | (b1 << 8) | b2, nextOffset: offset + 3 };
|
|
}
|
|
|
|
// UTF-8 字符串编码 (对应 Qt.c7g)
|
|
function encodeString(str) {
|
|
const utf8 = Buffer.from(str, 'utf8');
|
|
return Buffer.concat([encodeVarint(utf8.length), utf8]);
|
|
}
|
|
|
|
// 字符串数组编码 (对应 Wt.c7g)
|
|
function encodeStringArray(arr) {
|
|
const parts = [encodeVarint(arr.length)];
|
|
for (const s of arr) {
|
|
parts.push(encodeString(s));
|
|
}
|
|
return Buffer.concat(parts);
|
|
}
|
|
|
|
// 命令 ID 编码 (对应 nn.c7g — 1 字节 int8)
|
|
// 注意: 命令 ID 在此协议中是 1 字节, 不是 8 字节 int64!
|
|
function encodeCommandId(cmdId) {
|
|
return Buffer.from([cmdId & 0xFF]);
|
|
}
|
|
|
|
// Nullmap 编码 (对应 w7g)
|
|
// 对于 0 个可选字段: 1 byte = 0x00
|
|
function encodeNullmap(bitCount, bitmap) {
|
|
if (bitCount === 0) {
|
|
return Buffer.from([0x00]);
|
|
}
|
|
if (bitCount <= 5) {
|
|
// 1 byte: bits 在高位 (bit 3-7)
|
|
const byte0 = bitmap[0] >>> 3;
|
|
return Buffer.from([byte0 & 0xFF]);
|
|
}
|
|
if (bitCount <= 13) {
|
|
// 2 bytes: flag 0x20 in first byte
|
|
const b0 = ((bitmap[0] >>> 3) + 0x20) & 0xFF;
|
|
const b1 = ((bitmap[1] >>> 3) + ((bitmap[0] << 5) & 0xFF)) & 0xFF;
|
|
return Buffer.from([b0, b1]);
|
|
}
|
|
if (bitCount <= 21) {
|
|
// 3 bytes: flag 0x40 in first byte
|
|
const b0 = ((bitmap[0] >>> 3) + 0x40) & 0xFF;
|
|
const b1 = ((bitmap[1] >>> 3) + ((bitmap[0] << 5) & 0xFF)) & 0xFF;
|
|
const b2 = ((bitmap[2] >>> 3) + ((bitmap[1] << 5) & 0xFF)) & 0xFF;
|
|
return Buffer.from([b0, b1, b2]);
|
|
}
|
|
// 对于更大的 nullmap, 使用扩展格式
|
|
const byteCount = Math.ceil(bitCount / 8);
|
|
if (bitCount <= 504) {
|
|
// 1 length byte (with 0x80 flag) + data
|
|
const lenByte = (byteCount + 0x80) & 0xFF;
|
|
return Buffer.concat([Buffer.from([lenByte]), Buffer.from(bitmap.slice(0, byteCount))]);
|
|
}
|
|
// 3 length bytes (with 0xC00000 flag) + data
|
|
const lenVal = byteCount + 0xC00000;
|
|
return Buffer.concat([
|
|
Buffer.from([(lenVal >> 16) & 0xFF, (lenVal >> 8) & 0xFF, lenVal & 0xFF]),
|
|
Buffer.from(bitmap.slice(0, byteCount))
|
|
]);
|
|
}
|
|
|
|
// 帧编码 (对应 e7f)
|
|
// 帧 = [2或4字节长度(BE)][nullmap][payload]
|
|
function encodeFrame(payload, nullmap) {
|
|
const length = nullmap.length + payload.length;
|
|
if (length < 0x4000) {
|
|
// 2-byte format
|
|
return Buffer.concat([
|
|
Buffer.from([(length >> 8) & 0xFF, length & 0xFF]),
|
|
nullmap,
|
|
payload
|
|
]);
|
|
} else {
|
|
// 4-byte format with 0x80 flag
|
|
return Buffer.concat([
|
|
Buffer.from([
|
|
((length >>> 24) & 0xFF) | 0x80,
|
|
(length >>> 16) & 0xFF,
|
|
(length >>> 8) & 0xFF,
|
|
length & 0xFF
|
|
]),
|
|
nullmap,
|
|
payload
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 帧解码 (对应 f7f 的读取逻辑)
|
|
function decodeFrame(buf) {
|
|
if (buf.length < 2) return null;
|
|
const b0 = buf[0];
|
|
let frameLength;
|
|
let headerSize;
|
|
let compressed = false;
|
|
|
|
if ((b0 & 0x80) !== 0) {
|
|
// 4-byte format
|
|
if (buf.length < 4) return null;
|
|
const b1 = buf[1], b2 = buf[2], b3 = buf[3];
|
|
frameLength = (((b0 & 0x7F) << 24) | (b1 << 16) | (b2 << 8) | b3) >>> 0;
|
|
if (b0 & 0x40) compressed = true; // compression flag
|
|
headerSize = 4;
|
|
} else {
|
|
// 2-byte format
|
|
const b1 = buf[1];
|
|
frameLength = ((b0 & 0x3F) << 8) | b1;
|
|
if (b0 & 0x40) compressed = true;
|
|
headerSize = 2;
|
|
}
|
|
|
|
const totalFrameSize = headerSize + frameLength;
|
|
if (buf.length < totalFrameSize) return null;
|
|
|
|
const frameData = buf.slice(headerSize, totalFrameSize);
|
|
return { frameData, headerSize, frameLength, compressed, totalFrameSize };
|
|
}
|
|
|
|
// 解码 nullmap (对应 x7g)
|
|
function decodeNullmap(buf, offset) {
|
|
if (offset >= buf.length) return { nullmap: null, nextOffset: offset };
|
|
const r = buf[offset++];
|
|
|
|
if ((r & 0x80) !== 0) {
|
|
// 大格式: bit 7 = 1
|
|
let size;
|
|
if ((r & 0x40) !== 0) {
|
|
// 3-byte size
|
|
const b1 = buf[offset++];
|
|
const b2 = buf[offset++];
|
|
size = (((r & 0x3F) << 16) | (b1 << 8) | b2) >>> 0;
|
|
} else {
|
|
// 1-byte size
|
|
size = r & 0x3F;
|
|
}
|
|
// 读取 size 字节的 nullmap 数据
|
|
const data = buf.slice(offset, offset + size);
|
|
offset += size;
|
|
return { nullmap: data, bitCount: size * 8, nextOffset: offset };
|
|
}
|
|
|
|
// 小格式: bit 7 = 0
|
|
const tier = (r & 0x60) >>> 5;
|
|
let bitCount;
|
|
let data;
|
|
|
|
switch (tier) {
|
|
case 0: // 5 bits, 1 byte
|
|
bitCount = 5;
|
|
data = Buffer.from([r << 3]); // 移位到高位
|
|
break;
|
|
case 1: { // 13 bits, 2 bytes
|
|
bitCount = 13;
|
|
const b1 = buf[offset++];
|
|
data = Buffer.from([
|
|
(r + (b1 >>> 5)) & 0xFF,
|
|
(b1 << 3) & 0xFF
|
|
]);
|
|
break;
|
|
}
|
|
case 2: { // 21 bits, 3 bytes
|
|
bitCount = 21;
|
|
const b1 = buf[offset++];
|
|
const b2 = buf[offset++];
|
|
data = Buffer.from([
|
|
(r + (b1 >>> 5)) & 0xFF,
|
|
((b1 << 3) + (b2 >>> 5)) & 0xFF,
|
|
(b2 << 3) & 0xFF
|
|
]);
|
|
break;
|
|
}
|
|
case 3: { // 29 bits, 4 bytes
|
|
bitCount = 29;
|
|
const b1 = buf[offset++];
|
|
const b2 = buf[offset++];
|
|
const b3 = buf[offset++];
|
|
data = Buffer.from([
|
|
(r + (b1 >>> 5)) & 0xFF,
|
|
((b1 << 3) + (b2 >>> 5)) & 0xFF,
|
|
((b2 << 3) + (b3 >>> 5)) & 0xFF,
|
|
(b3 << 3) & 0xFF
|
|
]);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { nullmap: data, bitCount, nextOffset: offset };
|
|
}
|
|
|
|
// ============================================================
|
|
// Hash Request 构造
|
|
// ============================================================
|
|
|
|
// 命令 ID 常量 (对应 w.p7j_1 等)
|
|
const CMD_HASH_REQUEST = 1;
|
|
const CMD_HASH_RESPONSE = 2;
|
|
const CMD_OPEN_SPACE = 32;
|
|
const CMD_CLIENT_LOG = 32;
|
|
|
|
// 构造 Hash Request payload (对应 or.c7g + Li.c7g)
|
|
function buildHashRequest(paramNames, paramValues) {
|
|
// 1. Command ID (1 字节) = CMD_HASH_REQUEST
|
|
const cmdId = encodeCommandId(CMD_HASH_REQUEST);
|
|
// 2. paramNames 字符串数组
|
|
const names = encodeStringArray(paramNames);
|
|
// 3. paramValues 字符串数组
|
|
const values = encodeStringArray(paramValues);
|
|
// nullmap: 0 个可选字段 → 1 byte 0x00
|
|
const nullmap = encodeNullmap(0, null);
|
|
const payload = Buffer.concat([cmdId, names, values]);
|
|
return { payload, nullmap };
|
|
}
|
|
|
|
// 构造 ClientLog 命令 (对应 Fi.c7g)
|
|
// ClientLog = Bi(LogEntryType, string, string)
|
|
// 编码: [cmdId=32][logEntryType.ordinal(1字节)][string1][string2]
|
|
function buildClientLog(logType, str1, str2) {
|
|
const cmdId = encodeCommandId(CMD_CLIENT_LOG); // 1 byte = 32
|
|
const typeByte = Buffer.from([logType & 0xFF]); // LogEntryType ordinal
|
|
const s1 = encodeString(str1);
|
|
const s2 = encodeString(str2);
|
|
const nullmap = encodeNullmap(0, null); // 无可空字段
|
|
const payload = Buffer.concat([cmdId, typeByte, s1, s2]);
|
|
return { payload, nullmap };
|
|
}
|
|
|
|
// 默认 Hash Request 参数 (从 module_61860.js 提取)
|
|
function getDefaultParams() {
|
|
return [
|
|
['lang', 'zh-CN'],
|
|
['browser_user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'],
|
|
['os', 'Win32'],
|
|
['device_type', 'DESKTOP'],
|
|
['client_type', 'DESKTOP'],
|
|
['hardware_concurrency', '8'],
|
|
['time_zone', 'Asia/Shanghai'],
|
|
['video_card', 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0)'],
|
|
['mobile_device', 'false'],
|
|
['touchscreen_support', 'false'],
|
|
['client_version', 'e3b4a3f1c8b8ea24cddc91a30e51b6903d55c0c7'],
|
|
['ram_size', '16'],
|
|
];
|
|
}
|
|
|
|
// ============================================================
|
|
// 工具函数
|
|
// ============================================================
|
|
|
|
function hexDump(buffer, prefix = '') {
|
|
const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
const lines = [];
|
|
for (let i = 0; i < bytes.length; i += 16) {
|
|
const chunk = bytes.slice(i, Math.min(i + 16, bytes.length));
|
|
const hex = Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
|
const ascii = Array.from(chunk).map(b => (b >= 32 && b < 127) ? String.fromCharCode(b) : '.').join('');
|
|
lines.push(`${prefix}${i.toString(16).padStart(8, '0')} ${hex.padEnd(48)} ${ascii}`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// 尝试解码响应帧
|
|
function tryDecodeResponse(buf) {
|
|
const frame = decodeFrame(buf);
|
|
if (!frame) return null;
|
|
|
|
const result = {
|
|
frameLength: frame.frameLength,
|
|
headerSize: frame.headerSize,
|
|
compressed: frame.compressed,
|
|
nullmap: null,
|
|
payload: null,
|
|
commands: []
|
|
};
|
|
|
|
let offset = 0;
|
|
const frameData = frame.frameData;
|
|
|
|
// 解码 nullmap
|
|
const nm = decodeNullmap(frameData, offset);
|
|
offset = nm.nextOffset;
|
|
result.nullmap = nm.nullmap;
|
|
|
|
// 剩余 = payload (可能包含多个命令)
|
|
const payload = frameData.slice(offset);
|
|
result.payload = payload;
|
|
|
|
// 解码命令 (命令 ID = 1 字节, 然后是 body)
|
|
let pOffset = 0;
|
|
const cmdNames = { 1: 'Hash Request', 2: 'Hash Response', 32: 'Open Space' };
|
|
|
|
while (pOffset < payload.length) {
|
|
const cmdStart = pOffset;
|
|
const cmdId = payload[pOffset++]; // 1 字节命令 ID
|
|
const cmdInfo = { offset: cmdStart, commandId: cmdId, name: cmdNames[cmdId] || '未知', body: null };
|
|
|
|
if (cmdId === 2) {
|
|
// Hash Response: 32 字节 hash + 1 字节 boolean
|
|
if (pOffset + 33 <= payload.length) {
|
|
const hash = payload.slice(pOffset, pOffset + 32);
|
|
const boolean = payload[pOffset + 32];
|
|
cmdInfo.body = {
|
|
hash: hash.toString('hex'),
|
|
hashBytes: Array.from(hash),
|
|
boolean: boolean !== 0
|
|
};
|
|
pOffset += 33;
|
|
} else {
|
|
cmdInfo.body = { error: 'Hash Response body 不完整', remaining: payload.length - pOffset };
|
|
break;
|
|
}
|
|
} else if (cmdId === 32) {
|
|
// Open Space: 8 字节 64位 Long (BIG_ENDIAN) = Space ID
|
|
// 对应 Xt.d7g → n2t(): W(getInt32(n, BE), getInt32(n+4, BE)) = W(high, low)
|
|
if (pOffset + 8 <= payload.length) {
|
|
const spaceIdBuf = payload.slice(pOffset, pOffset + 8);
|
|
// BIG_ENDIAN: high 在前, low 在后
|
|
const high = spaceIdBuf.readInt32BE(0);
|
|
const low = spaceIdBuf.readUInt32BE(4);
|
|
// 组合成 64 位值 (JavaScript 无原生 int64, 用字符串表示)
|
|
const spaceIdStr = (high * 0x100000000 + low).toString();
|
|
cmdInfo.body = {
|
|
rawHex: spaceIdBuf.toString('hex'),
|
|
spaceIdHigh: high,
|
|
spaceIdLow: low,
|
|
spaceId: spaceIdStr
|
|
};
|
|
pOffset += 8;
|
|
} else {
|
|
cmdInfo.body = { error: 'Open Space body 不完整', remaining: payload.length - pOffset };
|
|
break;
|
|
}
|
|
} else {
|
|
// 未知命令, 记录剩余字节
|
|
const bodyBytes = payload.slice(pOffset);
|
|
cmdInfo.body = {
|
|
rawHex: bodyBytes.toString('hex'),
|
|
rawLength: bodyBytes.length
|
|
};
|
|
pOffset = payload.length;
|
|
}
|
|
|
|
result.commands.push(cmdInfo);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ============================================================
|
|
// 主程序
|
|
// ============================================================
|
|
|
|
const sessionName = process.argv[2] || `forge_${Date.now()}`;
|
|
const sessionDir = path.join(__dirname, sessionName);
|
|
|
|
if (!fs.existsSync(sessionDir)) {
|
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
}
|
|
|
|
const logFile = path.join(sessionDir, 'log.txt');
|
|
const logStream = fs.createWriteStream(logFile, { flags: 'w' });
|
|
|
|
function log(msg) {
|
|
const line = `[${new Date().toISOString()}] ${msg}`;
|
|
console.log(line);
|
|
logStream.write(line + '\n');
|
|
}
|
|
|
|
function saveBinary(data, direction, seq) {
|
|
const filename = `${direction}_${seq.toString().padStart(4, '0')}.bin`;
|
|
const filepath = path.join(sessionDir, filename);
|
|
fs.writeFileSync(filepath, data);
|
|
return filename;
|
|
}
|
|
|
|
// 构造 Hash Request
|
|
const params = getDefaultParams();
|
|
const paramNames = params.map(p => p[0]);
|
|
const paramValues = params.map(p => p[1]);
|
|
|
|
log('=== 3DTank WebSocket 伪造客户端 ===');
|
|
log(`会话: ${sessionName}`);
|
|
log(`目录: ${sessionDir}`);
|
|
log('');
|
|
|
|
log('--- Hash Request 参数 ---');
|
|
params.forEach(([k, v]) => log(` ${k} = ${v.length > 60 ? v.slice(0, 57) + '...' : v}`));
|
|
log('');
|
|
|
|
const { payload, nullmap } = buildHashRequest(paramNames, paramValues);
|
|
const frame = encodeFrame(payload, nullmap);
|
|
|
|
log('--- 编码结果 ---');
|
|
log(`Payload 长度: ${payload.length} 字节`);
|
|
log(`Nullmap: ${nullmap.toString('hex')} (${nullmap.length} 字节)`);
|
|
log(`Frame 总长: ${frame.length} 字节`);
|
|
log('');
|
|
log('Frame 十六进制:');
|
|
log(hexDump(frame));
|
|
log('');
|
|
|
|
// 连接参数
|
|
// 默认连真实服务器;加 --url ws://localhost:9091/ 可改为连本地中继/模拟服务器
|
|
const url = (() => {
|
|
const I = process.argv.indexOf('--url');
|
|
return I >= 0 && process.argv[I + 1] ? process.argv[I + 1] : 'wss://c1.3dtank.com:9090/';
|
|
})();
|
|
let recvSeq = 0;
|
|
let sendSeq = 0;
|
|
let hashRequestSent = false;
|
|
let clientLogSent = false;
|
|
|
|
log(`正在连接 ${url} ...`);
|
|
|
|
const ws = new WebSocket(url, {
|
|
rejectUnauthorized: false,
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
'Origin': 'https://3dtank.com'
|
|
}
|
|
});
|
|
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.on('open', function open() {
|
|
log('✓ 连接成功!');
|
|
log('');
|
|
|
|
// 发送 Hash Request
|
|
log('>>> 发送 Hash Request...');
|
|
const fname = saveBinary(frame, 'send', sendSeq++);
|
|
log(` 已保存: ${fname}`);
|
|
log(` ${frame.length} 字节:`);
|
|
log(hexDump(frame, ' '));
|
|
log('');
|
|
|
|
ws.send(frame, { binary: true, mask: true }, function(err) {
|
|
if (err) {
|
|
log(`✗ 发送失败: ${err.message}`);
|
|
} else {
|
|
hashRequestSent = true;
|
|
log('✓ Hash Request 已发送');
|
|
log('');
|
|
}
|
|
});
|
|
});
|
|
|
|
ws.on('message', function incoming(data) {
|
|
const buf = Buffer.from(data);
|
|
const fname = saveBinary(buf, 'recv', recvSeq++);
|
|
log(`<<< 接收消息 #${recvSeq - 1} (${buf.length} 字节) -> ${fname}`);
|
|
log(hexDump(buf, ' '));
|
|
log('');
|
|
|
|
// 尝试解码帧
|
|
const decoded = tryDecodeResponse(buf);
|
|
if (decoded) {
|
|
log(` 帧解码:`);
|
|
log(` 头部长度: ${decoded.headerSize} 字节`);
|
|
log(` 帧体长度: ${decoded.frameLength} 字节`);
|
|
log(` 压缩: ${decoded.compressed}`);
|
|
log(` Nullmap: ${decoded.nullmap ? decoded.nullmap.toString('hex') : 'null'} (${decoded.nullmap ? decoded.nullmap.length : 0} 字节)`);
|
|
log(` Payload 长度: ${decoded.payload ? decoded.payload.length : 0} 字节`);
|
|
if (decoded.commands && decoded.commands.length > 0) {
|
|
log(` 命令 (${decoded.commands.length} 个):`);
|
|
decoded.commands.forEach((cmd, i) => {
|
|
log(` [${i}] offset=${cmd.offset} ID=${cmd.commandId} (${cmd.name})`);
|
|
if (cmd.body) {
|
|
if (cmd.body.hash) {
|
|
log(` hash: ${cmd.body.hash}`);
|
|
log(` boolean: ${cmd.body.boolean}`);
|
|
} else if (cmd.body.spaceId !== undefined) {
|
|
log(` spaceId (BE int64): ${cmd.body.spaceId}`);
|
|
log(` high=${cmd.body.spaceIdHigh}, low=${cmd.body.spaceIdLow}`);
|
|
log(` raw: ${cmd.body.rawHex}`);
|
|
} else if (cmd.body.rawHex) {
|
|
log(` body (${cmd.body.rawLength} 字节): ${cmd.body.rawHex}`);
|
|
} else if (cmd.body.error) {
|
|
log(` 错误: ${cmd.body.error}`);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
// 空 payload = ping/keepalive, 回复 pong (相同的空帧 00 01 00)
|
|
if (decoded.payload && decoded.payload.length === 0) {
|
|
const pong = Buffer.from([0x00, 0x01, 0x00]);
|
|
const pongFname = saveBinary(pong, 'send', sendSeq++);
|
|
log(`>>> 回复 Pong (ping 保持连接) -> ${pongFname}`);
|
|
ws.send(pong, { binary: true, mask: true });
|
|
}
|
|
// 收到 Open Space 后发送 ClientLog (测试是否能保持连接)
|
|
if (decoded.commands) {
|
|
const hasOpenSpace = decoded.commands.some(c => c.commandId === 32 && c.body && c.body.spaceId !== undefined);
|
|
if (hasOpenSpace && !clientLogSent) {
|
|
clientLogSent = true;
|
|
const { payload: clPayload, nullmap: clNullmap } = buildClientLog(0, 'client_ready', 'forge_test');
|
|
const clFrame = encodeFrame(clPayload, clNullmap);
|
|
const clFname = saveBinary(clFrame, 'send', sendSeq++);
|
|
log(`>>> 发送 ClientLog (测试保活) -> ${clFname}`);
|
|
log(` ${clFrame.length} 字节: ${clFrame.toString('hex')}`);
|
|
ws.send(clFrame, { binary: true, mask: true });
|
|
}
|
|
}
|
|
}
|
|
log('');
|
|
});
|
|
|
|
ws.on('close', function close(code, reason) {
|
|
log('---');
|
|
log(`✗ 连接关闭 (code=${code})`);
|
|
if (reason && reason.length > 0) {
|
|
log(` 原因: ${reason.toString()}`);
|
|
}
|
|
log(` Hash Request 已发送: ${hashRequestSent}`);
|
|
log(` 总共接收: ${recvSeq} 条消息`);
|
|
log(` 总共发送: ${sendSeq} 条消息`);
|
|
logStream.end();
|
|
});
|
|
|
|
ws.on('error', function error(err) {
|
|
log(`✗ 错误: ${err.message}`);
|
|
if (err.stack) log(err.stack);
|
|
// 不在这里 end logStream, 让 close 事件处理
|
|
});
|
|
|
|
// 优雅退出
|
|
process.on('SIGINT', function() {
|
|
log('---');
|
|
log('用户中断,正在关闭...');
|
|
if (ws.readyState === WebSocket.OPEN) ws.close();
|
|
logStream.end();
|
|
process.exit(0);
|
|
});
|
|
|
|
// 超时退出 (5 分钟)
|
|
setTimeout(() => {
|
|
log('---');
|
|
log('超时自动退出 (5 分钟)');
|
|
if (ws.readyState === WebSocket.OPEN) ws.close();
|
|
logStream.end();
|
|
process.exit(0);
|
|
}, 5 * 60 * 1000);
|