Files
TankIC/3DTank/tools/analyze_packets.js
T
TermiNexus b6f2cb71ee feat(3dtank): 协议逆向工程与自研服务器入库(工具链 + 协议表 + 空间回放 + MySQL)
工具链(3DTank/tools/):extract(模块提取)、mine_evidence/apply_names(命名证据与别名回填)、extract_codec_fields(字段表 + 全库槽位类型 + PrimClassWire/ResourceClasses)、mine_downlink(下行字段表)、extract_space_init/rebase_space_init/audit_space_init(空间模板与记录切分审计)、extract_space_session(把真实会话切成按上行触发的回放模板)、pair_flow(请求→应答配对)、probe_containers、ingest/decrypt_frames(抓包入库与解密)、serve/fetch_assets(本地站点镜像)、import_db(抓包对象仓库导入 MySQL)。

服务器(3DTank/tools/server/):link/entry/codec/container/dispatch/world/space/semantic(协议层与 lt/pt/zt 三个容器布局)、session(会话回放,顺序推进)、db + schema.sql(MySQL 连接层与对象仓库表结构)、store/responder(查库组帧与定向应答;接管范围默认只开 ba.lcr)。

同时修 pair_flow 的时间轴偏移:timeline_c2s 与 timeline_s2c 的 0 点相差 144~4453ms,改前配对整体偏约 2 秒(实测 space-100073070 偏 2084ms),改后 22.4% 的触发者被纠正。

src/、src_named/、packets/、3DTank/local/(含数据库口令)不进库,可用上述工具重新生成。
2026-09-17 20:00:41 +08:00

222 lines
8.7 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.
// analyze_packets.js - 抓包分析:从 wss_server 的抓包产物还原「命令 ID 表」
// 用法: node analyze_packets.js [--dir ../packets] [--label relay-04]
// 输出: 控制台汇总 + names/commands.json(命令 ID → 方向/次数/样本)
//
// 背景(逆向自 module_61860):
// 命令基类 ControlCommand(id, name),入口层协议只注册了 4 条:
// c2s 编码表: 1 = "Hash request" 32 = "ClientLog"
// s2c 解码表: 2 = "Hash response" 32 = "Open space"
// 帧结构: [2/4 字节大端长度][nullmap][payload],payload = [1 字节命令 ID][body]
// ClientLog body = [LogEntryType 1 字节][字符串][字符串]
// 抓包里还有一批「非入口层」的帧(大帧/首字节带 0x80|0x40 标志),另行统计。
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
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 PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets')));
const LABEL = Arg('label', '');
const OUT_FILE = path.join(PROJECT, 'names', 'commands.json');
// 已知命令(入口层)
const CMD_TABLE = {
c2s: { 1: 'Hash Request', 32: 'ClientLog' },
s2c: { 2: 'Hash Response', 32: 'Open Space' },
};
// nullmap 长度(对应 x7g):高位标志决定「位数档位」或「显式字节数」
// bit7=0: tier = bit6..5 → 1/2/3/4 字节
// bit7=1: bit6=1 → 1 字节长度(低位 6 位) + 数据;bit6=0 → 3 字节长度 + 数据
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);
}
// 帧头解析:返回 { TotalSize, HeaderSize, BodySize, Compressed }
function PeekFrame(Buf) {
if (Buf.length < 2) return null;
const B0 = Buf[0];
let BodySize, HeaderSize;
if (B0 & 0x80) {
if (Buf.length < 4) return null;
BodySize = (((B0 & 0x3f) << 24) | (Buf[1] << 16) | (Buf[2] << 8) | Buf[3]) >>> 0;
HeaderSize = 4;
} else {
BodySize = ((B0 & 0x3f) << 8) | Buf[1];
HeaderSize = 2;
}
return { TotalSize: HeaderSize + BodySize, HeaderSize, BodySize, Compressed: (B0 & 0x40) !== 0 };
}
// 变长整数(对应 g7g/h7g):<0x80 一字节;<0x4000 两字节;否则三字节
function ReadVarint(Buf, Offset) {
const B0 = Buf[Offset];
if ((B0 & 0x80) === 0) return { Value: B0 & 0x7f, Next: Offset + 1 };
if ((B0 & 0x40) === 0) return { Value: ((B0 & 0x3f) << 8) | Buf[Offset + 1], Next: Offset + 2 };
return { Value: ((B0 & 0x3f) << 16) | (Buf[Offset + 1] << 8) | Buf[Offset + 2], Next: Offset + 3 };
}
function ReadString(Buf, Offset) {
const Len = ReadVarint(Buf, Offset);
if (Len.Next + Len.Value > Buf.length) return null;
return { Value: Buf.slice(Len.Next, Len.Next + Len.Value).toString('utf8'), Next: Len.Next + Len.Value };
}
// ClientLog body = [type][string][string]
function ParseClientLog(Buf, Offset) {
const Type = Buf[Offset];
const S1 = ReadString(Buf, Offset + 1);
if (!S1) return null;
const S2 = ReadString(Buf, S1.Next);
return { Type, S1: S1.Value, S2: S2 ? S2.Value : '' };
}
function Hex(Buf, N) {
return Buf.slice(0, N).toString('hex');
}
// ============================================================
// 扫描
// ============================================================
const Labels = LABEL
? [LABEL]
: fs
.readdirSync(PACKET_DIR, { withFileTypes: true })
.filter((D) => D.isDirectory())
.map((D) => D.name);
const Stat = {
Frames: { c2s: {}, s2c: {} }, // 方向 → id → { Count, Samples[], Bytes }
ClientLogs: {}, // S1 → { Count, Types:Set, S2Samples:Set }
Other: { c2s: { Count: 0, Bytes: 0, Samples: [] }, s2c: { Count: 0, Bytes: 0, Samples: [] } },
Pings: { c2s: 0, s2c: 0 }, // 空 payload 保活帧
Sessions: 0,
Messages: { c2s: 0, s2c: 0 },
};
function Bump(Direction, Id, Buf, NmSize) {
const Bucket = (Stat.Frames[Direction][Id] = Stat.Frames[Direction][Id] || { Count: 0, Bytes: 0, Samples: [], Nullmaps: {} });
Bucket.Count++;
Bucket.Bytes += Buf.length;
Bucket.Nullmaps[NmSize] = (Bucket.Nullmaps[NmSize] || 0) + 1;
if (Bucket.Samples.length < 4 && !Bucket.Samples.some((S) => S.length === Buf.length)) {
Bucket.Samples.push(Buf.slice(0, 48));
}
}
function ScanFile(Label, Session, Direction, File) {
const Buf = fs.readFileSync(File);
Stat.Messages[Direction]++;
const Info = PeekFrame(Buf);
// 完整一帧且未压缩 → 按入口层命令帧解析(nullmap 可能多字节)
const IsFrame = Info && Info.TotalSize === Buf.length && !Info.Compressed;
if (!IsFrame) {
const Bucket = Stat.Other[Direction];
Bucket.Count++;
Bucket.Bytes += Buf.length;
if (Bucket.Samples.length < 6) {
Bucket.Samples.push(Label + '/' + Session + '/' + path.basename(File) + ' (' + Buf.length + 'B) ' + Hex(Buf, 16));
}
return;
}
const NmSize = NullmapSize(Buf, Info.HeaderSize);
const PayloadAt = Info.HeaderSize + NmSize;
const Id = Buf[PayloadAt];
if (Id === undefined) {
// 空 payload(保活 ping)
Stat.Pings[Direction]++;
return;
}
const Payload = Buf.slice(PayloadAt + 1);
Bump(Direction, Id, Payload, NmSize);
if (Direction === 'c2s' && Id === 32) {
const Log = ParseClientLog(Payload, 0);
if (Log) {
const Entry = (Stat.ClientLogs[Log.S1] = Stat.ClientLogs[Log.S1] || { Count: 0, Types: {}, S2: [] });
Entry.Count++;
Entry.Types[Log.Type] = (Entry.Types[Log.Type] || 0) + 1;
if (Log.S2 && Entry.S2.length < 3 && !Entry.S2.includes(Log.S2)) Entry.S2.push(Log.S2);
}
}
}
for (const Label of Labels) {
const Dir = path.join(PACKET_DIR, Label);
if (!fs.existsSync(Dir)) continue;
for (const D of fs.readdirSync(Dir, { withFileTypes: true })) {
if (!D.isDirectory() || !D.name.startsWith('session_')) continue;
Stat.Sessions++;
const SessionDir = path.join(Dir, D.name);
for (const F of fs.readdirSync(SessionDir).sort()) {
const M = /^(c2s|s2c)_(\d+)\.bin$/.exec(F);
if (!M) continue;
ScanFile(Label, D.name, M[1], path.join(SessionDir, F));
}
}
}
// ============================================================
// 输出
// ============================================================
const Result = { Labels, Sessions: Stat.Sessions, Messages: Stat.Messages, C2s: {}, S2c: {}, ClientLogs: {}, Other: {} };
function Report(Direction) {
const Title = Direction === 'c2s' ? '客户端 → 服务器' : '服务器 → 客户端';
console.log('\n===== ' + Title + ' 命令表 =====');
const Ids = Object.keys(Stat.Frames[Direction])
.map(Number)
.sort((A, B) => A - B);
const Target = Direction === 'c2s' ? Result.C2s : Result.S2c;
for (const Id of Ids) {
const Bucket = Stat.Frames[Direction][Id];
const Name = (CMD_TABLE[Direction] && CMD_TABLE[Direction][Id]) || '未知';
const Nm = Object.keys(Bucket.Nullmaps)
.map((N) => N + 'B×' + Bucket.Nullmaps[N])
.join(' ');
console.log(
' ID=' + String(Id).padStart(4) + ' ' + Name.padEnd(16) + ' 次数=' + String(Bucket.Count).padStart(5) + ' 负载=' + String(Bucket.Bytes).padStart(7) + 'B nullmap=' + Nm,
);
for (const S of Bucket.Samples) console.log(' 样本: ' + S.toString('hex'));
Target[Id] = { Name, Count: Bucket.Count, Bytes: Bucket.Bytes, Nullmaps: Bucket.Nullmaps, Samples: Bucket.Samples.map((S) => S.toString('hex')) };
}
if (Stat.Pings[Direction]) console.log(' [保活 ping 空帧] ' + Stat.Pings[Direction] + ' 条');
const Other = Stat.Other[Direction];
if (Other.Count) {
console.log(' [其他/非入口层] 消息数=' + Other.Count + ' 字节=' + Other.Bytes);
for (const S of Other.Samples) console.log(' ' + S);
Result.Other[Direction] = Other;
}
}
console.log('抓包目录: ' + PACKET_DIR);
console.log('标签: ' + (Labels.join(', ') || '(无)'));
console.log('会话数: ' + Stat.Sessions + ' 消息数: c2s=' + Stat.Messages.c2s + ' s2c=' + Stat.Messages.s2c);
Report('c2s');
Report('s2c');
console.log('\n===== ClientLog 内容(c2s ID=32)=====');
const Keys = Object.keys(Stat.ClientLogs).sort((A, B) => Stat.ClientLogs[B].Count - Stat.ClientLogs[A].Count);
for (const K of Keys.slice(0, 60)) {
const E = Stat.ClientLogs[K];
console.log(
' ' + K.padEnd(28) + ' 次数=' + String(E.Count).padStart(4) + ' 类型=' + JSON.stringify(E.Types) + (E.S2.length ? ' 样本=' + E.S2.join(' | ') : ''),
);
}
Result.ClientLogs = Stat.ClientLogs;
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(Result, null, 2), 'utf8');
console.log('\n已写出: ' + OUT_FILE);