工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
95 lines
3.9 KiB
JavaScript
95 lines
3.9 KiB
JavaScript
// ingest.js - 把一次抓包(packets/<label>/session_*)批量入库到回放库 packets/plain/
|
|
// 用法: node ingest.js [--label mine-01] [--root ../packets] [--dry]
|
|
// 判定: 看每个 session 的首条 c2s 帧——
|
|
// cmd=1 (Hash Request) → 入口/大厅连接,入库为 lobby-<session>
|
|
// cmd=3 (EnterSpace) → 空间连接,末 8 字节是 spaceId,入库为 space-<spaceId>
|
|
// 同一空间有多个 session 时,只保留 s2c 帧最多的那个(回放库靠 space-<id> 目录名索引,
|
|
// 不能有重名),其余在报告里列为备用。
|
|
// 产物: packets/plain/<space-*|lobby-*>/{s2c_*.bin, c2s_*.bin, timeline_*.txt, summary.txt}
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const cp = require('child_process');
|
|
|
|
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')));
|
|
const Label = Arg('label', '');
|
|
const Dry = Argv.indexOf('--dry') >= 0;
|
|
const LabelDir = Label ? path.join(Root, Label) : Root;
|
|
|
|
function NullmapSize(Buf, Offset) {
|
|
const R = Buf[Offset];
|
|
if (R === undefined) return 0;
|
|
if (R & 0x80) return R & 0x40 ? 4 + (((R & 0x3f) << 16) | (Buf[1] << 8) | Buf[2]) : 1 + (R & 0x3f);
|
|
return 1 + ((R & 0x60) >>> 5);
|
|
}
|
|
|
|
// 首条 c2s 帧 → { Cmd, SpaceId }
|
|
function Peek(Buf) {
|
|
const B0 = Buf[0];
|
|
const HeaderSize = B0 & 0x80 ? 4 : 2;
|
|
const Body = Buf.slice(HeaderSize);
|
|
const At = NullmapSize(Body, 0);
|
|
if (At >= Body.length) return { Cmd: -1 };
|
|
const Cmd = Body[At];
|
|
if (Cmd === 3 && At + 41 <= Body.length) return { Cmd, SpaceId: Body.readBigInt64BE(At + 33).toString() };
|
|
return { Cmd };
|
|
}
|
|
|
|
const Sessions = fs
|
|
.readdirSync(LabelDir, { withFileTypes: true })
|
|
.filter((D) => D.isDirectory() && D.name.startsWith('session_'))
|
|
.map((D) => path.join(LabelDir, D.name))
|
|
.sort();
|
|
|
|
const Plan = new Map(); // 目标目录名 → { Session, Count, Cmd, Space }
|
|
const Rows = [];
|
|
for (const Dir of Sessions) {
|
|
const Files = fs.readdirSync(Dir);
|
|
const First = Files.filter((F) => /^c2s_\d+\.bin$/.test(F)).sort()[0];
|
|
if (!First) continue;
|
|
const Info = Peek(fs.readFileSync(path.join(Dir, First)));
|
|
const Count = Files.filter((F) => /^s2c_\d+\.bin$/.test(F)).length;
|
|
let Target;
|
|
if (Info.Cmd === 3 && Info.SpaceId) Target = 'space-' + Info.SpaceId;
|
|
else if (Info.Cmd === 1) Target = 'lobby-' + path.basename(Dir);
|
|
else Target = 'other-' + path.basename(Dir);
|
|
const Prev = Plan.get(Target);
|
|
if (!Prev) Plan.set(Target, { Session: Dir, Count, Cmd: Info.Cmd, Space: Info.SpaceId, Skipped: [] });
|
|
else if (Count > Prev.Count) {
|
|
Prev.Skipped.push(Prev.Session);
|
|
Plan.set(Target, { Session: Dir, Count, Cmd: Info.Cmd, Space: Info.SpaceId, Skipped: Prev.Skipped });
|
|
} else Prev.Skipped.push(Dir);
|
|
}
|
|
|
|
console.log('入库计划(label=' + (Label || '(root)') + ',共 ' + Sessions.length + ' 个会话):');
|
|
for (const [Target, Item] of Plan) {
|
|
const Out = path.join(PROJECT, 'packets', 'plain', Target);
|
|
console.log(
|
|
' ' + Target.padEnd(28) + ' ← ' + path.basename(Item.Session) + ' s2c ' + String(Item.Count).padStart(3) + ' 帧' +
|
|
(Item.Cmd === 3 ? ' spaceId=' + Item.Space : '') + (fs.existsSync(Out) ? ' [覆盖已存在]' : ''),
|
|
);
|
|
Rows.push({ Target, Session: Item.Session, Out });
|
|
}
|
|
|
|
if (Dry) {
|
|
console.log('\n--dry:只打印计划,未落盘');
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log('\n开始解密入库...');
|
|
for (const R of Rows) {
|
|
const Out = cp
|
|
.execFileSync('node', [path.join(__dirname, 'dump_plain.js'), '--session', R.Session, '--out', R.Out], { encoding: 'utf8' })
|
|
.split('\n')
|
|
.filter((L) => /明文前|未能解出/.test(L))
|
|
.join(' ');
|
|
console.log(' ' + R.Target.padEnd(28) + ' ' + Out.trim());
|
|
}
|
|
console.log('\n入库完成。下一步: node pair_flow.js');
|