工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
121 lines
5.1 KiB
JavaScript
121 lines
5.1 KiB
JavaScript
// extract_space_init.js - 把某个空间的 s2c 抓包抽成「结构化初始化模板」names/space-init-<spaceId>.json
|
||
//
|
||
// 用法: node extract_space_init.js --dir ../packets/plain/space-100073070 [--space 100073070] [--max 6]
|
||
//
|
||
// 模板不是原始帧字节:
|
||
// · 容器记录 → 存「解出来的对象树」,发送时由 server/container.js 重新编码(可改、可换数据)
|
||
// · 解不动的记录 → 存 hex(如实标注,不假装结构化)
|
||
// · nullmap 原样保留(实测有 2 字节且带内容的 nullmap,含义是「哪些字段为空」,写 0 会错位)
|
||
// · DelayMs 取自同目录 timeline_s2c.txt(相对首帧),保持原节奏
|
||
//
|
||
// 容器布局判定:三个布局都试,取「恰好吃到帧尾,或吃完后剩余字节里能认出下一条记录的 methodId」的那个。
|
||
'use strict';
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const Link = require('./server/link');
|
||
const { LoadTables, MakeReader } = require('../tools/lib_space_codec');
|
||
const { MakeWriter } = require('./server/codec');
|
||
const { MakeContainerCodec } = require('./server/container');
|
||
const ModelService = require('./server/modelservice');
|
||
|
||
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 Dir = path.resolve(Arg('dir', ''));
|
||
const Max = Number(Arg('max', '8')) || 8;
|
||
if (!Dir || !fs.existsSync(Dir)) {
|
||
console.error('用法: node extract_space_init.js --dir ../packets/plain/space-<id> [--space <id>] [--max N]');
|
||
process.exit(1);
|
||
}
|
||
|
||
const Tables = LoadTables(path.join(PROJECT, 'names'));
|
||
const Reader = MakeReader(Tables, { EnumBytes: 4 });
|
||
const Writer = MakeWriter(Tables, { EnumBytes: 4 });
|
||
const Container = MakeContainerCodec(Tables, Reader, Writer);
|
||
const LAYOUTS = ['zt', 'pt', 'lt'];
|
||
|
||
// 8 字节处是不是一个「已知的 methodId」(method_table / codec_fields / 上行表任一)
|
||
function KnownMethodId(Buf, Off) {
|
||
if (Off + 8 > Buf.length) return false;
|
||
const V = Buf.readBigInt64BE(Off).toString();
|
||
return Tables.CodecByMethod.has(V) || Tables.UplinkByMethod.has(V) || ModelService.Name(V) !== null;
|
||
}
|
||
|
||
function PickLayout(Buf, Off) {
|
||
for (const L of LAYOUTS) {
|
||
const R = Container.Read(L, Buf, Off);
|
||
if (R.Bad) continue;
|
||
if (R.Next === Buf.length || KnownMethodId(Buf, R.Next)) return { Layout: L, Result: R };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const Timeline = new Map();
|
||
const Tl = path.join(Dir, 'timeline_s2c.txt');
|
||
if (fs.existsSync(Tl)) {
|
||
for (const Line of fs.readFileSync(Tl, 'utf8').split('\n')) {
|
||
const M = /^(\S+)\t(\d+)$/.exec(Line.trim());
|
||
if (M) Timeline.set(M[1], Number(M[2]));
|
||
}
|
||
}
|
||
|
||
const Files = fs.readdirSync(Dir).filter((F) => /^s2c_\d+\.bin$/.test(F)).sort().slice(0, Max);
|
||
const SpaceArg = Arg('space', '');
|
||
const Out = { SpaceId: '', Note: '结构化初始化模板:容器存对象树(发送时重新编码),解不动的记录存 hex;nullmap 原样', Frames: [] };
|
||
let Base = null;
|
||
let Structured = 0;
|
||
let RawOnly = 0;
|
||
|
||
for (const File of Files) {
|
||
const RawFile = fs.readFileSync(path.join(Dir, File));
|
||
const Info = Link.PeekFrame(RawFile);
|
||
const Body = Link.DecodeFrame(Link.ParseFrames(RawFile)[0]);
|
||
const At = Link.NullmapSize(Body, 0);
|
||
if (At + 8 > Body.length) continue; // 保活 ping 之类:帧体只有 nullmap,没有 spaceId
|
||
const SpaceId = Body.readBigInt64BE(At).toString();
|
||
Out.SpaceId = SpaceArg || SpaceId;
|
||
const Nullmap = Body.slice(0, At).toString('hex');
|
||
let Off = At + 8;
|
||
const Records = [];
|
||
while (Off + 8 <= Body.length) {
|
||
const MethodId = Body.readBigInt64BE(Off).toString();
|
||
const Start = Off;
|
||
Off += 8;
|
||
const Picked = PickLayout(Body, Off);
|
||
if (Picked) {
|
||
Records.push({ MethodId, Layout: Picked.Layout, Value: Picked.Result.Value });
|
||
Off = Picked.Result.Next;
|
||
Structured++;
|
||
} else {
|
||
Records.push({ MethodId, Hex: Body.slice(Off).toString('hex'), Note: '解不动,原样存' });
|
||
Off = Body.length;
|
||
RawOnly++;
|
||
}
|
||
if (Records[Records.length - 1].Layout === undefined) break;
|
||
if (Off === Body.length) break;
|
||
}
|
||
const Ms = Timeline.has(File) ? Timeline.get(File) : null;
|
||
if (Base === null) Base = Ms === null ? 0 : Ms;
|
||
Out.Frames.push({
|
||
File,
|
||
SpaceId,
|
||
NullmapHex: Nullmap,
|
||
DelayMs: Ms === null ? 0 : Ms - Base,
|
||
Compressed: Info.Compressed,
|
||
Records,
|
||
});
|
||
}
|
||
|
||
const OutFile = path.join(PROJECT, 'names', 'space-init-' + Out.SpaceId + '.json');
|
||
fs.writeFileSync(OutFile, JSON.stringify(Out, null, 1));
|
||
console.log('空间 ' + Out.SpaceId + ':' + Out.Frames.length + ' 帧,容器记录 ' + Structured + ' 个,原样存的记录 ' + RawOnly + ' 个');
|
||
for (const F of Out.Frames) {
|
||
console.log(' ' + F.File.padEnd(14) + ' ' + F.Records.length + ' 条记录 @+' + F.DelayMs + 'ms nullmap=' + F.NullmapHex +
|
||
(F.Compressed ? ' (原压缩)' : '') + ' ' + F.Records.map((R) => (R.Layout ? R.Layout : 'hex') + ':' + (ModelService.Name(R.MethodId) || '#' + R.MethodId)).join(' '));
|
||
}
|
||
console.log('已写 ' + OutFile + ' ' + fs.statSync(OutFile).size + ' 字节');
|