工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
188 lines
9.5 KiB
JavaScript
188 lines
9.5 KiB
JavaScript
// session.js - 会话重放应答器:把真实官方服会话里「上行 → 下行」的对应关系搬到我们服务器上
|
||
//
|
||
// 为什么要有它:车库空间(100073070)在真实服里是**一问一答**的——
|
||
// 模板 names/space-init-<id>.json 只装得下"进空间时服务器主动推的那几帧"(车库 5 帧),
|
||
// 而客户端进车库后发的 G.q1wd / ba.lcr / M_.hcn / ls.xbl / M_.fcn / Qh.ycw 换来的 s2c
|
||
// (对象数据、资源、容器增量)原来一条都没回,于是武器列表/切换全都不动。
|
||
// 本模块读的是 names/space-session-<id>.json(由 tools/extract_space_session.js 从真实抓包抽出):
|
||
// 每条 s2c 帧带一个 After = 「换它出来的上行清单」({MethodId, Target})。
|
||
//
|
||
// 约定(与产物文件一一对应):
|
||
// After 为空 → 纯推送帧(真实服在客户端进空间后无人请求就推的那几帧);
|
||
// DelayMs=0 的进空间立刻发,DelayMs>0 的按真实时延定时发
|
||
// (实测车库那 2 帧:s2c_0000 立刻,s2c_0040 在 +8.7s)
|
||
// After 非空 → 等客户端发到清单里的上行时发(同一帧只发一次)
|
||
// 命中判定 → MethodId 相同,且(After 项没有 Target 或 Target 相同)
|
||
//
|
||
// 顺序推进(关键,别再改回「任一命中即发」):
|
||
// 真实服是一问一答、且**成批**——s2c_0003 要等 28 条 ba.lcr 齐了才发一条含 28 条
|
||
// LoadDependencies 的帧,s2c_0004 要等 28 条 G.q1wd 齐了才发。
|
||
// 所以每次上行**只推进会话顺序里最靠前的那个「还没发的被触发帧」**,一帧一上行。
|
||
// 早期实现用「任一 After 项命中就发」:因为会话里 30 多帧的 After 都含 `G.q1wd @100073070`,
|
||
// 客户端第一个 G.q1wd(1) 就把 21 帧全扑了出去(server-logs/session-02/session_007/log.txt:59),
|
||
// 客户端拿到乱序数据当场抛错重载,第二轮起车库再也进不去 —— 务必保持顺序推进。
|
||
// Frame.Hex → 形态表达不了(Short 命令号 / 解不出 objectId)时整帧原样的兜底:
|
||
// 直接按帧体重组,一个字节都不改(extract_space_session.js 里写明原因)
|
||
//
|
||
// 发送走的是现成的组帧路径(Space.BuildInitFrames → space.js → Link.EncodeFrame),
|
||
// 与 space-init-*.json 那条路完全同源,所以「模板里存的记录能逐字节重建原帧」这条保证直接继承过来。
|
||
//
|
||
// 用法:
|
||
// const Session = require('./session');
|
||
// const Doc = Session.Load(namesDir, spaceId); // 没有产物文件 → null
|
||
// const Replay = Session.MakeReplay(Doc.Frames, { Container, Semantic, Log });
|
||
// Replay.OnEnter(SendFrames); // 进空间时
|
||
// const Hits = Replay.OnUplink({ MethodId, Target }, SendFrames); // 每条上行都问一次;返回本次发出的帧数
|
||
// const Hits2 = Replay.OnUplink({ MethodId, Target }, SendFrames, { Suppress: true });
|
||
// // 该上行已由查库定向应答接管 → 命中的帧只标记已发、不发字节
|
||
// SendFrames(Frame, Bytes) 返回 true 表示**真的发出去了**(连接已断就返回 false,这时不标记已发)
|
||
'use strict';
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const Link = require('./link');
|
||
const Space = require('./space');
|
||
const SemanticModule = require('./semantic');
|
||
|
||
const NAMES_DIR = path.resolve(__dirname, '..', '..', 'names');
|
||
|
||
// 卡住看门狗阈值:连续这么多条上行都匹配不上「会话顺序里最靠前的待触发帧」就跳过它并大声记日志。
|
||
// 取值偏大(我们的客户端与抓包那份是同一个构建,正常不该触发),只是为了不把整条会话卡死。
|
||
const STALL_LIMIT = 40;
|
||
|
||
// 读 names/space-session-<SpaceId>.json;不存在返回 null(调用方回落到 space-init-<id>.json)
|
||
function Load(NamesDir, SpaceId) {
|
||
const File = path.join(NamesDir || NAMES_DIR, 'space-session-' + SpaceId + '.json');
|
||
if (!fs.existsSync(File)) return null;
|
||
return JSON.parse(fs.readFileSync(File, 'utf8'));
|
||
}
|
||
|
||
// After 清单里有没有命中这条上行记录
|
||
function Match(After, Record) {
|
||
for (const A of After || []) {
|
||
if (String(A.MethodId) !== String(Record.MethodId)) continue;
|
||
if (A.Target === undefined || A.Target === null || A.Target === '') return true;
|
||
if (String(A.Target) === String(Record.Target)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function MakeReplay(Frames, Deps) {
|
||
const Opts = Deps || {};
|
||
const Log = typeof Opts.Log === 'function' ? Opts.Log : () => {};
|
||
// 没给 Container / Semantic 时自己加载一份(用法上 index.js 会传现成的实例,避免重复读表)
|
||
const Semantic = Opts.Semantic || SemanticModule.Load(NAMES_DIR);
|
||
const Container = Opts.Container || Semantic.Container;
|
||
const All = Frames || [];
|
||
const Sent = new Set();
|
||
const Waited = new Set();
|
||
const Timers = [];
|
||
const BytesByFile = new Map();
|
||
const State = { Stall: 0 };
|
||
|
||
// 纯推送帧 = After 为空的帧(真实服没人问就推)
|
||
function IsPush(F) {
|
||
return !(F.After || []).length;
|
||
}
|
||
|
||
// 帧字节按需构建(车库那两帧是 58KB / 775KB,进空间时没必要全编一遍)
|
||
function BytesOf(F) {
|
||
if (BytesByFile.has(F.File)) return BytesByFile.get(F.File);
|
||
let Buf;
|
||
if (F.Hex !== undefined) {
|
||
// 形态表达不了的整帧原样兜底:帧体 → [nullmap][payload] 交给 Link.EncodeFrame(长度头由它补)
|
||
const Body = Buffer.from(String(F.Hex), 'hex');
|
||
const NmSize = Link.NullmapSize(Body, 0);
|
||
Buf = Link.EncodeFrame(Body.slice(NmSize), Body.slice(0, NmSize));
|
||
} else {
|
||
Buf = Space.BuildInitFrames({ Frames: [F] }, Container, Semantic)[0].Frame;
|
||
}
|
||
BytesByFile.set(F.File, Buf);
|
||
return Buf;
|
||
}
|
||
|
||
function Send(F, SendFrames, Why) {
|
||
const Buf = BytesOf(F);
|
||
Log(' ← 重放:发 ' + F.File + ' ' + Buf.length + 'B(' + Why + ')');
|
||
const Ok = SendFrames ? SendFrames(F, Buf) !== false : true;
|
||
if (Ok) Sent.add(F.File);
|
||
else Log(' ⚠ 重放 ' + F.File + ' 没真正发出(连接已断),不标记已发');
|
||
return Ok;
|
||
}
|
||
|
||
// 进空间时:只有纯推送帧走这条(DelayMs=0 立刻发,>0 的按真实时延定时发)
|
||
function OnEnter(SendFrames) {
|
||
let Count = 0;
|
||
for (const F of All) {
|
||
if (Sent.has(F.File)) continue;
|
||
if (!IsPush(F)) continue;
|
||
const Delay = Number(F.DelayMs) || 0;
|
||
if (Delay <= 0) {
|
||
if (Send(F, SendFrames, '纯推送帧,进空间即发')) Count++;
|
||
continue;
|
||
}
|
||
const T = setTimeout(() => Send(F, SendFrames, '纯推送帧,按真实时延 +' + Delay + 'ms'), Delay);
|
||
if (T && typeof T.unref === 'function') T.unref();
|
||
Timers.push(T);
|
||
}
|
||
return Count;
|
||
}
|
||
|
||
// 会话顺序里最靠前的「还没发的、需要上行触发」的帧;跳过错过的纯推送帧
|
||
function NextGated() {
|
||
for (const F of All) {
|
||
if (Sent.has(F.File)) continue;
|
||
if (IsPush(F)) continue;
|
||
return F;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// After 清单的可读摘要(只用于日志:让人一眼看出这帧在等什么)
|
||
function Describe(After) {
|
||
const Count = new Map();
|
||
for (const A of After || []) {
|
||
const Key = String(A.MethodId) + (A.Target === undefined || A.Target === null || A.Target === '' ? '' : '@' + A.Target);
|
||
Count.set(Key, (Count.get(Key) || 0) + 1);
|
||
}
|
||
return Array.from(Count.entries()).map(([K, N]) => K + (N > 1 ? '×' + N : '')).join(' + ');
|
||
}
|
||
|
||
// 收到一条上行记录:只推进一帧(会话顺序里最靠前的那个待触发帧),返回本次发出的帧数
|
||
// Options.Suppress = true(可选,查库定向应答用):这条上行已经被查库路径接管,
|
||
// 命中的那帧**只标记已发、不发字节**——顺序照旧推进,避免后面所有待触发帧被堵住(看门狗要 40 条才跳)。
|
||
function OnUplink(Record, SendFrames, Options) {
|
||
const F = NextGated();
|
||
if (!F) return 0;
|
||
if (Match(F.After, Record)) {
|
||
State.Stall = 0;
|
||
if (Options && Options.Suppress) {
|
||
Sent.add(F.File);
|
||
Log(' (' + F.File + ' 本由 ' + Record.MethodId + ' 触发,但该上行已由查库定向应答接管 → 跳过重放)');
|
||
return 0;
|
||
}
|
||
const Why = '被 ' + Record.MethodId + ' 触发' + (Record.Name ? ',' + Record.Name : '');
|
||
return Send(F, SendFrames, Why) ? 1 : 0;
|
||
}
|
||
State.Stall++;
|
||
// 每条待触发帧只提醒一次,避免刷屏
|
||
if (!Waited.has(F.File)) {
|
||
Waited.add(F.File);
|
||
Log(' ⚠ 重放等待:' + F.File + ' 需要 [' + Describe(F.After) + '],本条上行是 ' +
|
||
Record.MethodId + (Record.Target === undefined ? '' : '@' + Record.Target));
|
||
}
|
||
// 卡住看门狗:会话顺序本该与我们的客户端一致;连续多次对不上说明抓包与本次客户端行为不一致,
|
||
// 这时**大声**跳过这一帧继续(不静默兜底——跳过意味着这一步的下行缺失,必须能在日志里看见)
|
||
if (State.Stall >= STALL_LIMIT) {
|
||
Log(' ⚠ 重放卡住:' + F.File + ' 连续 ' + State.Stall + ' 条上行都没等到 [' +
|
||
Describe(F.After) + '],跳过它继续(该帧的下行缺失)');
|
||
Sent.add(F.File);
|
||
State.Stall = 0;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
return { OnEnter, OnUplink };
|
||
}
|
||
|
||
module.exports = { Load, MakeReplay, NAMES_DIR };
|