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