Files
TankIC/3DTank/tools/extract_space_session.js
T
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

316 lines
16 KiB
JavaScript
Raw 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.
// extract_space_session.js - 把真实官方服会话里的「上行 → 下行」对应关系抽成会话重放模板
//
// 为什么需要它(根因,别被"看起来对"骗了):
// 现有 names/space-init-<spaceId>.json 只是「进入空间后服务器主动推的那几帧」——
// 车库模板(packets/plain/space-100073070 共 68 条 s2c)只切了前 5 条(s2c_0000..0004),
// 之后客户端发的 G.q1wd / ba.lcr / M_.hcn / ls.xbl / M_.fcn / Qh.ycw 全部零应答:
// 真实服是「客户端问一句、服务器答一句」,模型数据的绝大部分(对象/资源/设备)都挂在这些应答上。
// 所以光有"初始化帧"不够,必须把**每一帧 s2c 是被哪条上行换来的**也记下来。
//
// 做法(不猜字节、不臆造数据):
// 1) 读 packets/mine-01/session_007/log.txt 的**双向合并时间线**(`>>> c2s_NNNN.bin` / `<<< s2c_NNNN.bin`)。
// 2) 逐帧读 packets/plain/space-<id>/s2c_*.bin(与模板 Frames[].File 同名)→
// Link.ParseFrames + Link.DecodeFrame 得帧体 → Sem.DecodeFrameBody 得**权威记录列表**
// (容器型 → { Layout, Value };字段型 → { Fields };解不动 → { Hex, Note }),
// 与 names/space-init-*.json 的记录形态**完全一致**(space.js 能直接消费)。
// 3) After = 触发该 s2c 帧的上行清单:在合并时间线里,位于「上一条 s2c 事件」与「本条 s2c 事件」
// 之间的所有 c2s 事件,每项 { MethodId, Target }(取法见 dispatch.js:c2s 帧体 =
// [nullmap][objectId 8B][(methodId 8B)(负载)…],Target = 第一个 i64)。
// 入口帧(command id 3 = Auth/EnterSpace,本会话是 c2s_0000)不算上行、直接排除。
// 4) DelayMs:同一条 After 组(连续若干帧 After 完全相同)里,基准 = 组内最后一条上行的时间戳
// 与**上一条 s2c 帧**的时间戳之差;组内第 k 帧再叠加它自己与组内首帧的真实时间差。
// After 为空的纯推送帧,基准 = 组内首帧自己的时间戳(即与上一条 s2c 的真实间隔)。
// 上一条 s2c 不存在(会话第一帧)→ 0。ping 帧整条跳过:既不入 Frames,也不当时间线边界
// (否则它前后的上行会凭空丢一组;实测两种口径下本会话的分组完全相同)。
// 5) 安全阀:每帧用 space.js 的组帧路径重建,必须与原始帧体**逐字节一致**;不一致就拒绝写该帧,
// 并打印前 32 个差异偏移 + 两侧 hex(照 rebase_space_init.js:114-123 的写法)。
// 解码出现 Short / ObjectId === undefined(模板三形态表达不了)时,整帧按 { Hex } 原样保留并写明原因。
//
// 用法: node extract_space_session.js [--space 100073070] [--session ../packets/mine-01/session_007]
// [--names ../names] [--plain ../packets/plain] [--dry]
'use strict';
const fs = require('fs');
const path = require('path');
const Link = require('./server/link');
const Space = require('./server/space');
const SemanticModule = require('./server/semantic');
const { MakeDispatcher } = require('./server/dispatch');
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 ToolsDir = __dirname;
const NamesDir = path.resolve(ToolsDir, Arg('names', path.join('..', 'names')));
const PlainRoot = path.resolve(ToolsDir, Arg('plain', path.join('..', 'packets', 'plain')));
const SessionDir = path.resolve(ToolsDir, Arg('session', path.join('..', 'packets', 'mine-01', 'session_007')));
const SpaceId = Arg('space', '100073070');
const Dry = Argv.indexOf('--dry') >= 0;
const Sem = SemanticModule.Load(NamesDir);
// 上行记录用服务端**同一条**识别路径(dispatch.js 的 Describe),避免"工具认得出、服务器认不出"
const Dispatcher = MakeDispatcher(Sem.Tables, Sem.Reader, { NoteUnimplemented: () => {} }, {});
const PlainDir = path.join(PlainRoot, 'space-' + SpaceId);
const RelSession = path.relative(path.resolve(ToolsDir, '..'), SessionDir).replace(/\\/g, '/');
const OutFile = path.join(NamesDir, 'space-session-' + SpaceId + '.json');
// 差异偏移(前 Limit 个);用来把"不一致"说清楚,而不是只丢一句"对不上"
function DiffOffsets(A, B, Limit) {
const Out = [];
for (let I = 0; I < Math.max(A.length, B.length) && Out.length < Limit; I++) if (A[I] !== B[I]) Out.push(I);
return Out;
}
// 偏移附近的 hex 窗口:整帧可能几十万字节,只贴差异邻域(±Span)
function HexAround(Buf, At, Span) {
const S = Math.max(0, At - Span);
const E = Math.min(Buf.length, At + Span);
return '偏移 ' + S + '..' + E + ':' + Buf.slice(S, E).toString('hex');
}
// 模板记录 → 帧体(含 nullmap)。走服务端真正用的那条路径(space.js → container/semantic 现编),
// 所以它同时验证「记录形态」与「编码器」两件事。
function BuildBody(File, NmHex, DelayMs, Records) {
const Built = Space.BuildInitFrames(
{ Frames: [{ File, SpaceId, NullmapHex: NmHex, DelayMs, Records }] },
Sem.Container,
Sem,
)[0].Frame;
return Link.DecodeFrame(Link.ParseFrames(Built)[0]);
}
// ---------------------------------------------------------------- 1. 合并时间线
// 返回按时间排序的 [{ Time, Dir, Name }];Name 形如 c2s_0007.bin
function ReadEvents(LogPath) {
if (!fs.existsSync(LogPath)) return null;
const Out = [];
for (const Line of fs.readFileSync(LogPath, 'utf8').split('\n')) {
const M = /^\[([^\]]+)\]\s*(>>>|<<<)\s+((?:c2s|s2c)_\d+\.bin)/.exec(Line.trim());
if (!M) continue;
const Time = Date.parse(M[1]);
if (Number.isNaN(Time)) continue;
Out.push({ Time, Dir: M[2] === '>>>' ? 'c2s' : 's2c', Name: M[3] });
}
// 同一毫秒内的多条按出现顺序保持稳定(Array.sort 在 Node 里是稳定的)
Out.sort((A, B) => A.Time - B.Time);
return Out;
}
// ---------------------------------------------------------------- 2. 上行帧 → 记录
// 一份「文件 → { Target, Records: [{ MethodId, Name }], Why }」表;解不出的一律带 Why,不静默丢
function ReadUplink(Files) {
const Table = new Map();
for (const File of Files) {
const Parsed = Link.ParseFrames(fs.readFileSync(path.join(PlainDir, File)));
if (!Parsed || Parsed.length !== 1) {
Table.set(File, { Why: '明文文件不是"恰好一整帧"(帧数 ' + (Parsed ? Parsed.length : 'null') + ')' });
continue;
}
const Body = Link.DecodeFrame(Parsed[0]);
const At = Link.NullmapSize(Body, 0);
if (At >= Body.length) {
Table.set(File, { Why: '空负载帧(无目标 id)' });
continue;
}
const Info = Dispatcher.Describe(Body, At);
if (!Info.Records.length) {
Table.set(File, { Target: Info.TargetId, Why: '首条 methodId 不在上行表(负载=' + Body.slice(At + 8).toString('hex').slice(0, 48) + ')' });
continue;
}
Table.set(File, {
Target: Info.TargetId,
Records: Info.Records.map((R) => ({ MethodId: R.MethodId, Name: R.Name, Bad: R.Bad })),
});
}
return Table;
}
// ---------------------------------------------------------------- 主流程
const Lines = [];
const Unmatched = [];
const Stat = { S2c: 0, Rebuilt: 0, Refused: 0, Ping: 0, Bad: 0, Hex: 0, Records: 0, Ctr: 0, Fld: 0, RecHex: 0 };
if (!fs.existsSync(PlainDir)) {
console.log('明文目录不存在: ' + PlainDir);
process.exit(1);
}
const LogPath = path.join(SessionDir, 'log.txt');
const Events = ReadEvents(LogPath);
const S2cFiles = fs.readdirSync(PlainDir).filter((F) => /^s2c_\d+\.bin$/.test(F)).sort();
const C2sFiles = fs.readdirSync(PlainDir).filter((F) => /^c2s_\d+\.bin$/.test(F)).sort();
const Uplink = ReadUplink(C2sFiles);
// 帧体 + 结构(供断言与 After 分组两处用)
const FrameInfo = new Map();
for (const File of S2cFiles) {
Stat.S2c++;
const Parsed = Link.ParseFrames(fs.readFileSync(path.join(PlainDir, File)));
if (!Parsed || Parsed.length !== 1) {
Stat.Bad++;
Lines.push(' ' + File.padEnd(14) + ' 跳过:明文文件不是"恰好一整帧"(帧数 ' + (Parsed ? Parsed.length : 'null') + ')');
continue;
}
const Body = Link.DecodeFrame(Parsed[0]);
const NmSize = Link.NullmapSize(Body, 0);
FrameInfo.set(File, { Body, NmHex: Body.slice(0, NmSize).toString('hex'), Ping: Body.length <= NmSize });
}
// 事件 → After / DelayMs
const AfterByFile = new Map();
const DelayByFile = new Map();
const PendingList = [];
let PrevS2cTime = null;
let GroupStart = null; // 同一 After 组的(首帧文件, 基准时刻)
let GroupBase = null;
let Assigned = 0;
for (const E of Events || []) {
if (E.Dir === 'c2s') {
if (E.Name === 'c2s_0000.bin') continue; // 入口帧(Auth/EnterSpace)不算空间上行
const U = Uplink.get(E.Name);
if (!U || !U.Records) {
Unmatched.push({ File: E.Name, Time: E.Time, Why: (U && U.Why) || '取不到目标 id' });
continue;
}
for (const R of U.Records) PendingList.push({ MethodId: R.MethodId, Target: U.Target, File: E.Name, Time: E.Time, Name: R.Name });
continue;
}
const Info = FrameInfo.get(E.Name);
if (!Info) {
Unmatched.push({ File: E.Name, Time: E.Time, Why: '明文目录里没有同名的 s2c 帧' });
continue;
}
if (Info.Ping) continue; // ping 帧跳过:不入 Frames、也不当时间线边界
const LastUplinkTime = PendingList.length ? PendingList[PendingList.length - 1].Time : null;
const After = PendingList.map((X) => ({ MethodId: X.MethodId, Target: X.Target }));
AfterByFile.set(E.Name, After);
PendingList.length = 0;
Assigned++;
// DelayMs:同组(After 完全相同)首帧用「组内最后一条上行 − 上一条 s2c」,组内后续帧叠加真实时间差;
// 组内没有任何上行(纯推送帧)时用首帧自己的时间戳,得到的就是与上一条 s2c 的真实间隔。
const Key = JSON.stringify(After);
if (GroupStart === null || GroupStart.Key !== Key) {
GroupStart = { Key, File: E.Name, Time: E.Time };
GroupBase = PrevS2cTime === null ? 0 : Math.max(0, (After.length ? LastUplinkTime : E.Time) - PrevS2cTime);
}
DelayByFile.set(E.Name, GroupBase + (E.Time - GroupStart.Time));
PrevS2cTime = E.Time;
}
// 逐帧生成 Frames[]
const Frames = [];
for (const File of S2cFiles) {
const Info = FrameInfo.get(File);
if (!Info) continue; // 不是恰好一整帧:上面已计数并打印原因
if (Info.Ping) {
Stat.Ping++;
continue;
}
const Body = Info.Body;
const After = AfterByFile.get(File) || [];
const DelayMs = DelayByFile.has(File) ? DelayByFile.get(File) : 0;
const Entry = { File, SpaceId, NullmapHex: Info.NmHex, DelayMs, After, Records: [] };
const Decoded = Sem.DecodeFrameBody(Body);
const Unrepresentable = Decoded.Records.find((R) => R.ObjectId === undefined || R.Short);
if (Unrepresentable) {
// 模板三形态表达不了「短命令(nullmap bit=1 的 1 字节命令号)」与「解码器判定不了形态」这两种情况
// (semantic.js:236-243 / :230-235 会标 Short / 只留 Remaining)——整帧原样保留,绝不猜边界。
Entry.Hex = Body.toString('hex');
Entry.Note = '形态表达不了,整帧原样保留:' + (Unrepresentable.Note || '');
Stat.Hex++;
Lines.push(' ' + File.padEnd(14) + ' 帧体 ' + Body.length + 'B 原样保留(形态表达不了):' + Unrepresentable.Note);
Frames.push(Entry);
continue;
}
for (const R of Decoded.Records) {
if (R.Bad) {
Entry.Records.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Hex: Body.slice(R.At + 16).toString('hex'), Note: R.Note });
Stat.RecHex++;
} else if (R.Kind === 'container') {
Entry.Records.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Layout: R.Layout, Value: R.Value });
Stat.Ctr++;
} else {
const Fields = {};
for (const X of R.Fields || []) Fields[X.Name] = X.Value;
Entry.Records.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Fields });
Stat.Fld++;
}
Stat.Records++;
}
// 硬性断言:重建的帧体必须与原始帧体逐字节一致。断言用「JSON 往返之后」的记录
//(= 真正会落盘的那份),顺带保证幂等:过不了 JSON 的值(如 float 的 NaN/Infinity)会当场暴露。
const Persisted = JSON.parse(JSON.stringify(Entry.Records));
let BuiltBody = null;
let Err = null;
try {
BuiltBody = BuildBody(File, Info.NmHex, DelayMs, Persisted);
} catch (E) {
Err = E.message;
}
if (!BuiltBody || !BuiltBody.equals(Body)) {
Stat.Refused++;
Lines.push(' ' + File.padEnd(14) + ' 拒绝写回:组帧与原始帧体不一致' +
(Err ? '(组帧抛错:' + Err + ')' : '(' + (BuiltBody ? BuiltBody.length : '?') + 'B vs 原始 ' + Body.length + 'B)'));
if (BuiltBody) {
const Offs = DiffOffsets(BuiltBody, Body, 32);
Lines.push(' 差异偏移(前 32 个): ' + JSON.stringify(Offs));
for (const At of Offs.slice(0, 3)) {
Lines.push(' @' + At + ' 组帧 ' + HexAround(BuiltBody, At, 16));
Lines.push(' @' + At + ' 原始 ' + HexAround(Body, At, 16));
}
}
continue;
}
Stat.Rebuilt++;
Entry.Records = Persisted;
Frames.push(Entry);
const Shapes = Entry.Records.map((R) => (R.Layout !== undefined ? '容器/' + R.Layout : R.Fields !== undefined ? '字段(' + Object.keys(R.Fields).length + ')' : 'hex'));
Lines.push(' ' + File.padEnd(14) + ' 帧体 ' + String(Body.length).padStart(7) + 'B nullmap=' + Info.NmHex.slice(0, 24) +
(Info.NmHex.length > 24 ? '…' : '') + ' ' + String(Entry.Records.length).padStart(2) + ' 条记录 [' + Shapes.join(' + ') + ']' +
' After(' + After.length + ')' + (After.length ? ' = ' + After.map((A) => A.MethodId + '@' + A.Target).join(', ') : '') +
' DelayMs=' + DelayMs);
}
const Product = {
Note: '车库空间会话回放:来自真实官方服中继抓包 ' + RelSession +
'。每条 s2c 帧按「客户端的哪条上行换来的」索引(After = 触发它的上行清单,空 = 纯推送);' +
'Records 形态与 space-init-*.json 一致,组帧与原始帧体逐字节相同。',
Source: RelSession + '/log.txt',
SpaceId,
Skipped: { Ping: Stat.Ping, Undecodable: Stat.Hex + Stat.Bad },
Unmatched,
Frames,
};
const Text = JSON.stringify(Product, null, 1) + '\n';
let Changed = false;
if (!Dry) {
const Old = fs.existsSync(OutFile) ? fs.readFileSync(OutFile, 'utf8') : null;
if (Old !== Text) {
fs.writeFileSync(OutFile, Text, 'utf8');
Changed = true;
}
}
console.log('=== 从真实会话抽「上行 → 下行」对应关系 ===');
console.log('会话: ' + SessionDir);
console.log('明文目录: ' + PlainDir);
console.log('产物: ' + OutFile + (Dry ? '(--dry:一个字节都没写)' : Changed ? '(已写回)' : '(无变化)'));
console.log('');
console.log('合并时间线事件 ' + (Events ? Events.length : 0) + ' 条' +
(Events ? '(c2s ' + Events.filter((E) => E.Dir === 'c2s').length + ' / s2c ' + Events.filter((E) => E.Dir === 's2c').length + ')' : ':没有 log.txt,After 一律为空'));
console.log('s2c 帧 ' + Stat.S2c + ' 条:非 ping ' + (Stat.S2c - Stat.Ping) + ' 条,其中配对到 After 的 ' + Assigned + ' 条');
for (const L of Lines) console.log(L);
console.log('');
console.log('帧 ' + Stat.S2c + ' / 逐字节一致 ' + Stat.Rebuilt + ' / 跳过 ' + Stat.Ping +
'(ping;形态表达不了整帧原样 ' + Stat.Hex + ' 条,不是一整帧 ' + Stat.Bad + ' 条,组帧不一致拒写 ' + Stat.Refused + ' 条)');
console.log('记录 ' + Stat.Records + ' 条:容器 ' + Stat.Ctr + ' / 字段 ' + Stat.Fld + ' / hex ' + Stat.RecHex);
console.log('纯推送帧(After 为空,进空间即发):' + Frames.filter((F) => !F.After.length).map((F) => F.File).join(', ') || '(无)');
if (PendingList.length) console.log('⚠ 结尾有 ' + PendingList.length + ' 条上行没配上任何 s2c(未并入 After):' + PendingList.map((X) => X.File).join(', '));
if (Unmatched.length) {
console.log('未配对的上行事件 ' + Unmatched.length + ' 条(已如实记进 Unmatched 字段):');
for (const U of Unmatched) console.log(' ' + U.File + ' ' + U.Why);
}
process.exit(Stat.Refused || Stat.Bad || Stat.Rebuilt + Stat.Hex !== Stat.S2c - Stat.Ping ? 1 : 0);