// 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)。 // 产物: // /flow.txt 人类可读的配对表 // 机器可读:每空间的 {触发 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:),其余用「命令 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);