工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
222 lines
8.7 KiB
JavaScript
222 lines
8.7 KiB
JavaScript
// 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);
|