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