Files
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

292 lines
14 KiB
JavaScript
Raw Permalink 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.
// pair_flow.js - 把抓包里的 c2s / s2c 按时间戳配对成「请求 → 响应」流
// 用法: node pair_flow.js [--root ../packets/plain] [--window 1500] [--out ../names/flow.json]
// 原理:
// dump_plain.js 已产出 timeline_c2s.txt / timeline_s2c.txt(每帧相对「该方向首条消息」的毫秒)。
// ⚠ 这两个 0 点不是同一时刻:c2s 的 0 点 = 客户端首条消息,s2c 的 0 点 = 服务端首条消息,
// 实测相差 144~4453ms(space-100073070 = 2084ms)。直接相比会让每个 s2c 帧的"触发者"
// 整体前移一个偏移量,配对表偏 1~4 秒。
// 修法:把两个方向的帧换算到同一个时钟 —— 来源会话 log.txt 里的绝对时间戳
// (同一次抓包、同一台机器的本地时钟,天然同一时间轴)。summary.txt 记着来源会话路径,
// 回读它的 log.txt 取双方首帧绝对时刻相减得 ClockOffsetMs;
// s2c 帧的对齐时刻 = 相对值 + ClockOffsetMs,与 c2s 的相对值落在同一时间轴上。
// (为何不逐帧读 log.txt 的绝对时刻:log.txt 每条 WS 消息只有一个时刻,而
// dump_plain.js 把「帧 → 消息」的映射留在内存里没落盘;timeline 的相对值保留了
// 方向内每帧的时刻,所以与绝对时钟只差一个基准差。)
// 对每条 s2c 帧,找「时间上最近的、且相距 <= Window 的」c2s 帧作为触发者;
// 找不到触发者的就是服务端主动推送(ping / 周期 inform)。
// 产物:
// <space>/flow.txt 人类可读的配对表
// <out> 机器可读:每空间的 {触发 cmdId -> 回复 cmdId 列表} + 全局统计
// 目的: 让 fake 服务器从「定时吐帧」变成「收到请求才应答」(第 1 层)
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
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 PROJECT = path.resolve(__dirname, '..');
const Root = path.resolve(Arg('root', path.join(PROJECT, 'packets', 'plain')));
const Window = Number(Arg('window', '1500')) || 1500;
const OutPath = path.resolve(Arg('out', path.join(PROJECT, 'names', 'flow.json')));
// 上行空间帧:帧体 = [nullmap][objectId int64 8B][methodId int64 8B][负载](同 wss_server.js 的 DescribeUplink)
// 判据 = 首字节是 objectId 最高位(0x00 小正数 / 0xff 负数)+ At+8 处命中 names/uplink_table.json 的 methodId。
// 用 methodId 当应答签名:它是客户端源码里的常量,跨会话稳定,于是「某条上行命令」能精确命中它的应答组。
const UplinkByName = new Map();
const UplinkByHex = new Map();
try {
const Up = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'uplink_table.json'), 'utf8'));
for (const C of Up.Commands || []) {
const B = Buffer.alloc(8);
B.writeBigInt64BE(BigInt(C.MethodId));
const Hex = B.toString('hex');
UplinkByHex.set(Hex, C);
UplinkByName.set(C.MethodId, (C.OwnerName || C.OwnerClass || '?') + '.' + (C.SendFn || '?'));
}
} catch (Err) {
console.log('(未加载 names/uplink_table.json,上行空间帧只能按命令 ID 配对)');
}
function SpaceFrame(Body, At) {
const B0 = Body[At];
if (B0 !== 0x00 && B0 !== 0xff) return null;
if (At + 16 > Body.length) return null;
const Cmd = UplinkByHex.get(Body.slice(At + 8, At + 16).toString('hex'));
if (!Cmd) return null;
return { ObjectId: Body.readBigInt64BE(At).toString(), MethodId: Body.readBigInt64BE(At + 8).toString() };
}
// 帧 → { Cmd, Size };Compressed 帧先解压再取 nullmap/命令 ID
function FrameInfo(Buf, Direction) {
const B0 = Buf[0];
const HeaderSize = B0 & 0x80 ? 4 : 2;
// 4 字节头:bit6 是压缩位,长度取低 6 位 + 后 3 字节(注意不是 & 0x7f)
const Total =
HeaderSize === 4
? 4 + ((((B0 & 0x3f) << 24) | (Buf[1] << 16) | (Buf[2] << 8) | Buf[3]) >>> 0)
: 2 + ((((B0 & 0x3f) << 8) | Buf[1]) >>> 0);
let Body = Buf.slice(HeaderSize);
if (B0 & 0x40) {
try {
Body = zlib.inflateSync(Body);
} catch (Err) {
return { Cmd: -2, Size: Total, Note: 'zlib 失败' };
}
}
const Nm = Body[0];
let At;
if (Nm & 0x80) At = Nm & 0x40 ? 4 + (((Nm & 0x3f) << 16) | (Body[1] << 8) | Body[2]) : 1 + (Nm & 0x3f);
else At = 1 + ((Nm & 0x60) >>> 5);
if (At >= Body.length) return { Cmd: -1, Size: Total, Note: '保活 ping' };
if (Direction === 'c2s') {
const Sp = SpaceFrame(Body, At);
if (Sp) return { Cmd: 0, Size: Total, Space: true, ...Sp, Payload: Body.slice(At + 16) };
}
return { Cmd: Body[At], Size: Total, Payload: Body.slice(At + 1) };
}
function ReadTimeline(Dir, Direction) {
const P = path.join(Dir, 'timeline_' + Direction + '.txt');
if (!fs.existsSync(P)) return [];
const Out = [];
for (const Line of fs.readFileSync(P, 'utf8').split('\n')) {
const M = /^(\S+)\t(\d+)$/.exec(Line.trim());
if (M) Out.push({ Name: M[1], Ms: Number(M[2]) });
}
return Out;
}
// 取「两个 timeline 0 点之差」:明细目录只记了相对时刻,来源会话的 log.txt 才有绝对时刻。
// summary.txt 首行是 dump_plain.js 写的「来源会话: <绝对路径>」。
// 基准取「序号最小的那条消息」的时刻,与 dump_plain.js 的 Times[0](按序号升序)严格一致。
// 取不到(无 summary.txt / 无 log.txt / 缺某方向)时返回 null,调用方按偏移 0 处理并在统计里计数。
function ReadClockOffset(Dir) {
const SummaryPath = path.join(Dir, 'summary.txt');
if (!fs.existsSync(SummaryPath)) return null;
const M = /^来源会话[::]\s*(.+)$/m.exec(fs.readFileSync(SummaryPath, 'utf8'));
if (!M) return null;
const SessionDir = M[1].trim();
const LogPath = path.join(SessionDir, 'log.txt');
if (!fs.existsSync(LogPath)) return null;
const First = {}; // 方向 -> { Seq, Time }
for (const Line of fs.readFileSync(LogPath, 'utf8').split('\n')) {
const X = /^\[([^\]]+)\]\s*(?:>>>|<<<)\s+(c2s|s2c)_(\d+)\.bin/.exec(Line.trim());
if (!X) continue;
const Time = Date.parse(X[1]);
if (!Number.isFinite(Time)) continue;
const Seq = Number(X[3]);
if (!First[X[2]] || Seq < First[X[2]].Seq) First[X[2]] = { Seq, Time };
}
if (!First.c2s || !First.s2c) return null;
return {
OffsetMs: First.s2c.Time - First.c2s.Time,
Session: path.basename(SessionDir),
C2sBase: new Date(First.c2s.Time).toISOString(),
S2cBase: new Date(First.s2c.Time).toISOString(),
};
}
// 帧签名:空间帧用 methodId(S:<methodId>),其余用「命令 ID + 负载前 4 字节」
// 必须与 wss_server.js 的 FrameSignature 完全一致
function Signature(Info) {
if (Info.Space) return 'S:' + Info.MethodId;
const Head = Info.Payload ? Info.Payload.slice(0, 4).toString('hex') : '';
return Info.Cmd + (Head ? ':' + Head : '');
}
// 签名 → 人类可读标签(flow.txt / 日志用)
function SigLabel(Sig) {
const M = /^S:(\d+)$/.exec(Sig);
if (!M) return 'cmd=' + Sig.split(':')[0];
const Name = UplinkByName.get(M[1]);
return '上行=' + (Name || '?') + '(' + M[1] + ')';
}
const Spaces = [];
const Global = { AsTrigger: {}, AsReply: {}, Push: {}, Spaces: 0, Pairs: 0, S2c: 0, Unanswered: {}, Clocks: {}, UnknownClock: 0 };
for (const Name of fs.readdirSync(Root).sort()) {
const Dir = path.join(Root, Name);
if (!fs.statSync(Dir).isDirectory()) continue;
const C2s = ReadTimeline(Dir, 'c2s');
const S2c = ReadTimeline(Dir, 's2c');
if (!C2s.length || !S2c.length) continue;
Global.Spaces++;
// 两个 timeline 的 0 点对齐差(见文件头原理);取不到就按 0 处理(退化成旧行为)
const Clock = ReadClockOffset(Dir);
const Offset = Clock ? Clock.OffsetMs : 0;
if (Clock) Global.Clocks[Name] = Offset;
else Global.UnknownClock++;
// s2c 帧的「对齐时刻」= 相对 s2c 首帧 + 偏移,单位同 c2s 的相对毫秒(同一时间轴)
const Aligned = (Ms) => Ms + Offset;
const C2sFrames = C2s.map((X) => ({ ...X, Info: FrameInfo(fs.readFileSync(path.join(Dir, X.Name)), 'c2s') }));
const S2cFrames = S2c.map((X) => ({ ...X, Info: FrameInfo(fs.readFileSync(path.join(Dir, X.Name)), 's2c') }));
const Lines = ['空间 ' + Name + ' 请求 ' + C2s.length + ' 条 / 响应 ' + S2c.length + ' 条(窗口 ' + Window + 'ms)',
'时钟对齐: s2c 相对值 + ' + Offset + 'ms(来源会话 ' + (Clock ? Clock.Session : '未知') + ')', ''];
const Triggers = [];
const UsedSignatures = new Set();
let Pushed = 0;
let Pairs = 0;
for (const S of S2cFrames) {
if (S.Info.Cmd === -1) continue; // ping 单独统计
Global.S2c++;
// 找最近的、时间不超过窗口的 c2s(两者都换算到同一时间轴后比较)
let Best = null;
for (const C of C2sFrames) {
if (C.Ms <= Aligned(S.Ms) && Aligned(S.Ms) - C.Ms <= Window) Best = C; // 列表按时间升序,取最后一个满足的
}
if (Best) {
const Key = Signature(Best.Info);
let Group = Triggers.find((G) => G.Trigger === Key);
if (!Group) {
Group = { Trigger: Key, TriggerName: Best.Name, TriggerMs: Best.Ms, Replies: [] };
Triggers.push(Group);
}
Group.Replies.push({ Name: S.Name, Cmd: S.Info.Cmd, Ms: S.Ms, Delay: Aligned(S.Ms) - Best.Ms, Size: S.Info.Size });
Global.AsTrigger[Key] = (Global.AsTrigger[Key] || 0) + 1;
Global.AsReply[S.Info.Cmd] = (Global.AsReply[S.Info.Cmd] || 0) + 1;
UsedSignatures.add(Key);
Pairs++;
Global.Pairs++;
} else {
Pushed++;
Global.Push[S.Info.Cmd] = (Global.Push[S.Info.Cmd] || 0) + 1;
}
}
// 未匹配到应答的请求:整段会话里「从未成为过任何 s2c 触发者」的请求签名,按条数排序
const Unanswered = new Map();
for (const C of C2sFrames) {
const Key = Signature(C.Info);
if (UsedSignatures.has(Key)) continue;
Unanswered.set(Key, (Unanswered.get(Key) || 0) + 1);
}
const UnansweredList = [...Unanswered.entries()]
.map(([Trigger, Count]) => ({ Trigger, Label: SigLabel(Trigger), Count }))
.sort((A, B) => B.Count - A.Count);
for (const U of UnansweredList) Global.Unanswered[U.Trigger] = (Global.Unanswered[U.Trigger] || 0) + U.Count;
for (const G of Triggers) {
Lines.push(
'触发 c2s ' + G.TriggerName + ' ' + SigLabel(G.Trigger) + ' @+' + G.TriggerMs + 'ms → ' + G.Replies.length + ' 条回复',
);
for (const R of G.Replies) {
Lines.push(' +' + String(R.Delay).padStart(6) + 'ms ' + R.Name + ' cmd=' + R.Cmd + ' ' + R.Size + 'B');
}
}
Lines.push('', '无触发的主动推送: ' + Pushed + ' 条');
const PushCount = {};
for (const S of S2cFrames) if (S.Info.Cmd !== -1) PushCount[S.Info.Cmd] = (PushCount[S.Info.Cmd] || 0) + 1;
Lines.push(' 各命令总条数: ' + JSON.stringify(PushCount));
Lines.push('未匹配到应答的请求: ' + UnansweredList.reduce((A, B) => A + B.Count, 0) + ' 条 / ' + UnansweredList.length + ' 种命令');
for (const U of UnansweredList.slice(0, 10)) Lines.push(' ' + String(U.Count).padStart(4) + ' 条 ' + U.Label + ' ' + U.Trigger);
fs.writeFileSync(path.join(Dir, 'flow.txt'), Lines.join('\n') + '\n', 'utf8');
// 给 fake 服务器用的极简配对表:每行「s2c 文件 \t 触发签名|- \t 相对触发的延迟ms \t 对齐后 s2c 时刻ms」
// 第 4 列已换算到 c2s 的 0 点(= 第 3 列可直接用),不再是 s2c 单方向的相对值
const Tsv = [];
for (const S of S2cFrames) {
if (S.Info.Cmd === -1) continue; // 保活 ping 交给服务器自己的定时器
let Trigger = '-';
let Delay = 0;
for (const C of C2sFrames) {
if (C.Ms <= Aligned(S.Ms) && Aligned(S.Ms) - C.Ms <= Window) {
Trigger = Signature(C.Info);
Delay = Aligned(S.Ms) - C.Ms;
}
}
Tsv.push(S.Name + '\t' + Trigger + '\t' + Delay + '\t' + Aligned(S.Ms));
}
fs.writeFileSync(path.join(Dir, 'flow_s2c.tsv'), Tsv.join('\n') + '\n', 'utf8');
Spaces.push({
Space: Name,
ClockOffsetMs: Offset,
ClockSource: Clock ? Clock.Session : null,
Triggers: Triggers.map((G) => ({
Trigger: G.Trigger,
TriggerLabel: SigLabel(G.Trigger),
TriggerName: G.TriggerName,
Replies: G.Replies.map((R) => ({ Cmd: R.Cmd, Delay: R.Delay, Size: R.Size })),
})),
Unanswered: UnansweredList,
Pushed,
});
console.log(
Name.padEnd(20) + ' c2s ' + String(C2s.length).padStart(3) + ' s2c ' + String(S2c.length).padStart(3) +
' 配对 ' + String(Pairs).padStart(4) + ' 推送 ' + Pushed + ' 偏移 ' + String(Offset).padStart(5) + 'ms' +
' 未应答 ' + UnansweredList.reduce((A, B) => A + B.Count, 0) + ' 条',
);
}
fs.writeFileSync(
OutPath,
JSON.stringify(
{
Note: '由 pair_flow.js 生成:s2c 帧先按 log.txt 绝对时刻对齐到 c2s 时间轴(ClockOffsetMs),再按时间就近归到触发的 c2s 帧;无触发的记为主动推送;Unanswered = 整段会话里从未触发过任何 s2c 的请求命令',
Window,
Stats: Global,
Spaces,
},
null,
2,
),
'utf8',
);
console.log('\n全局统计:');
console.log(' 作为触发的 c2s 命令: ' + JSON.stringify(Global.AsTrigger));
console.log(' 作为回复的 s2c 命令: ' + JSON.stringify(Global.AsReply));
console.log(' 主动推送的 s2c 命令: ' + JSON.stringify(Global.Push));
const TopUnanswered = Object.entries(Global.Unanswered).sort((A, B) => B[1] - A[1]).slice(0, 10);
console.log(' 未匹配到应答的请求 TOP10:');
for (const [Key, Count] of TopUnanswered) console.log(' ' + String(Count).padStart(5) + ' 条 ' + SigLabel(Key) + ' ' + Key);
console.log(' 已写出: ' + OutPath);