工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
262 lines
13 KiB
JavaScript
262 lines
13 KiB
JavaScript
// rebase_space_init.js - 以真实抓包明文帧为准,重写 names/space-init-*.json 的记录切分
|
||
//
|
||
// 为什么需要它(根因,别被"看起来对"骗了):
|
||
// 模板最早由 tools/extract_space_init.js 从抓包切出来,而它把**第一条记录的 objectId** 当成了
|
||
// 「帧级 spaceId」:extract_space_init.js:79 直接读帧体第 At 处的 i64 当 SpaceId、:105 写进
|
||
// Frames[].SpaceId,随后 :82-85 从 At+8 起把接下来 8 字节当第一条记录的 methodId ——
|
||
// 于是第一条记录**丢了自己的 objectId**,而帧级 SpaceId 里存的是它的真值。
|
||
// 后来 tools/audit_space_init.js --fix 给这类记录补记录头时,兜底用的是**模板顶层 SpaceId**
|
||
// (audit_space_init.js:127 / :129 的 RealSpace)——对「第一条记录 objectId ≠ 空间号」的帧就补错了。
|
||
// 实测 space-init-100072911.json 的 s2c_0002.bin(对应 packets/plain/space-100072911/s2c_0002.bin):
|
||
// 真值 rec1.objectId = 3074457345817168740(0x2aaaaaaab685cb64,帧体偏移 2..9 这 8 字节)
|
||
// 模板里却写成 100072911(空间号,0x0000000005f6fdcf)→ 正好差这 8 字节。
|
||
// tools/server/space.js:30 已经是「优先用记录自带的 ObjectId,只有记录没写才回退帧级 SpaceId」——
|
||
// 这条优先级规则是对的(也符合 semantic.js 头注:每条记录自带 objectId),所以错的是**模板里的值**,
|
||
// 不是 space.js:它只是老老实实把记录自带的错值写了出来。本工具负责把这批值按真实帧改对。
|
||
//
|
||
// 做法(不猜边界):
|
||
// 逐帧读 packets/plain/space-<真实空间号>/<File>(与模板 Frames[].File 同名)→
|
||
// Link.ParseFrames + Link.DecodeFrame 得真实帧体 → Sem.DecodeFrameBody 得**权威记录列表**
|
||
// (每条带 ObjectId / MethodId / 负载)。容器型 → { Layout, Value };字段型完整解出 → { Fields };
|
||
// 解不动 / 短命令形态 → 整帧原样保留并写明原因(不伪造切分、不丢字节)。
|
||
// 帧级 SpaceId 写成真实空间号(明文目录名 space-<id> 里的 id)。
|
||
//
|
||
// 安全阀:改写后的模板经 space.js 组帧,产出必须与原始帧体**逐字节一致**;
|
||
// 不一致就拒绝写回该帧,并打印前 32 个差异偏移 + 两侧 hex。
|
||
// 幂等:产物由真实帧唯一决定,重复运行不产生变化(只写到有变化时才落盘)。
|
||
//
|
||
// 用法: node rebase_space_init.js [--dry] [--names ../names] [--plain ../packets/plain]
|
||
'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 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 PlainDir = path.resolve(ToolsDir, Arg('plain', path.join('..', 'packets', 'plain')));
|
||
const Dry = Argv.indexOf('--dry') >= 0;
|
||
const Sem = SemanticModule.Load(NamesDir);
|
||
|
||
// 差异偏移(前 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, RealSpace, NullmapHex, DelayMs, Records) {
|
||
const Built = Space.BuildInitFrames(
|
||
{ Frames: [{ File, SpaceId: RealSpace, NullmapHex, DelayMs, Records }] },
|
||
Sem.Container,
|
||
Sem,
|
||
)[0].Frame;
|
||
return Link.DecodeFrame(Link.ParseFrames(Built)[0]);
|
||
}
|
||
|
||
const Stat = { Templates: 0, Frames: 0, Rebased: 0, KeptNoPlain: 0, KeptUndecodable: 0, KeptAssert: 0, Records: 0, Ctr: 0, Fld: 0, Hex: 0, ObjFilled: 0, ObjFixed: 0, ShortFrames: 0, ShortRecords: 0 };
|
||
const Lines = [];
|
||
const Skipped = [];
|
||
|
||
for (const File of fs.readdirSync(NamesDir).filter((X) => /^space-init-.*\.json$/.test(X)).sort()) {
|
||
// 登录模板来自中继抓包(packets/relay-logout/session_008),packets/plain 下没有它的对应帧
|
||
// (space-100045260 里碰巧同名的 s2c_0002.bin 是另一条连接的另一帧)——按任务要求整份跳过。
|
||
if (/^space-init-login-/.test(File)) {
|
||
Skipped.push(File);
|
||
continue;
|
||
}
|
||
Stat.Templates++;
|
||
const Path2 = path.join(NamesDir, File);
|
||
const T = JSON.parse(fs.readFileSync(Path2, 'utf8'));
|
||
const DirName = 'space-' + T.SpaceId; // 明文目录名里就是真实空间号
|
||
const Dir = path.join(PlainDir, DirName);
|
||
const RealSpace = DirName.slice('space-'.length);
|
||
let Changed = false;
|
||
Lines.push('== ' + File + ' 真实空间号=' + RealSpace + (fs.existsSync(Dir) ? '' : ' ⚠ 没有明文目录:' + Dir));
|
||
|
||
for (const Fr of T.Frames || []) {
|
||
Stat.Frames++;
|
||
const Plain = path.join(Dir, Fr.File);
|
||
if (!fs.existsSync(Plain)) {
|
||
Stat.KeptNoPlain++;
|
||
Lines.push(' ' + String(Fr.File).padEnd(14) + ' 原样保留:' + Dir + ' 下没有同名明文帧');
|
||
continue;
|
||
}
|
||
const Parsed = Link.ParseFrames(fs.readFileSync(Plain));
|
||
if (!Parsed || Parsed.length !== 1) {
|
||
Stat.KeptUndecodable++;
|
||
Lines.push(' ' + String(Fr.File).padEnd(14) + ' 原样保留:明文文件不是"恰好一整帧"(帧数 ' + (Parsed ? Parsed.length : 'null') + ')');
|
||
continue;
|
||
}
|
||
const Body = Link.DecodeFrame(Parsed[0]);
|
||
const NmSize = Link.NullmapSize(Body, 0);
|
||
const NmHex = Body.slice(0, NmSize).toString('hex');
|
||
const Decoded = Sem.DecodeFrameBody(Body);
|
||
|
||
// 模板的三种记录形态都表达不了「短命令(nullmap bit=1 的 1 字节命令号)」
|
||
// 与「解码器已经判定不了形态」这两种情况(semantic.js:236-243 / :230-235 会标 Short / 只留 Remaining),
|
||
// 这时整帧原样保留,绝不猜。
|
||
const Unrepresentable = Decoded.Records.find((R) => R.ObjectId === undefined || R.Short);
|
||
if (Unrepresentable) {
|
||
Stat.KeptUndecodable++;
|
||
if (Unrepresentable.Short) {
|
||
Stat.ShortFrames++;
|
||
Stat.ShortRecords++;
|
||
}
|
||
Lines.push(' ' + String(Fr.File).padEnd(14) + ' 原样保留(形态表达不了):' + Unrepresentable.Note);
|
||
continue;
|
||
}
|
||
|
||
const NewRecords = [];
|
||
const Shapes = [];
|
||
for (const R of Decoded.Records) {
|
||
if (R.Bad) {
|
||
// 解不动的记录(含容器三布局都吃不到记录尾):如实保留剩余字节 + 原因。
|
||
// DecodeFrameBody 一遇到 Bad 就停(semantic.js:255-259),所以这段剩余字节就是本条负载。
|
||
NewRecords.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Hex: Body.slice(R.At + 16).toString('hex'), Note: R.Note });
|
||
Shapes.push('hex(' + (Body.length - R.At - 16) + 'B)');
|
||
Stat.Hex++;
|
||
} else if (R.Kind === 'container') {
|
||
NewRecords.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Layout: R.Layout, Value: R.Value });
|
||
Shapes.push('容器/' + R.Layout);
|
||
Stat.Ctr++;
|
||
} else {
|
||
const Fields = {};
|
||
for (const X of R.Fields || []) Fields[X.Name] = X.Value;
|
||
NewRecords.push({ ObjectId: R.ObjectId, MethodId: R.MethodId, Fields });
|
||
Shapes.push('字段(' + (R.Fields || []).length + ')');
|
||
Stat.Fld++;
|
||
}
|
||
Stat.Records++;
|
||
}
|
||
|
||
// 硬性断言:改后的模板组帧必须与原始帧体逐字节一致。
|
||
// 断言用「JSON 往返之后」的记录(= 真正会落盘的那份),这样顺带保证了幂等:
|
||
// 万一某个值过不了 JSON(比如 float 的 NaN/Infinity 会变 null),这里就会当场暴露成"组帧不一致"。
|
||
const Persisted = JSON.parse(JSON.stringify(NewRecords));
|
||
let BuiltBody = null;
|
||
let Err = null;
|
||
try {
|
||
BuiltBody = BuildBody(Fr.File, RealSpace, NmHex, Fr.DelayMs || 0, Persisted);
|
||
} catch (E) {
|
||
Err = E.message;
|
||
}
|
||
if (!BuiltBody || !BuiltBody.equals(Body)) {
|
||
Stat.KeptAssert++;
|
||
Lines.push(' ' + String(Fr.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;
|
||
}
|
||
|
||
// objectId 对账:本次到底改了什么(补上 / 改错值)
|
||
const Old = Fr.Records || [];
|
||
const Fixes = [];
|
||
for (let I = 0; I < NewRecords.length; I++) {
|
||
const O = Old[I] || {};
|
||
if (O.ObjectId === undefined) Stat.ObjFilled++;
|
||
else if (String(O.ObjectId) !== String(NewRecords[I].ObjectId)) {
|
||
Stat.ObjFixed++;
|
||
Fixes.push(' 修正 objectId:第 ' + (I + 1) + ' 条 ' + String(O.ObjectId) + ' → ' + NewRecords[I].ObjectId +
|
||
'(methodId=' + NewRecords[I].MethodId + ')');
|
||
}
|
||
}
|
||
|
||
const Before = JSON.stringify(Old);
|
||
if (Before !== JSON.stringify(Persisted)) {
|
||
Fr.Records = Persisted;
|
||
Changed = true;
|
||
}
|
||
if (Fr.SpaceId !== RealSpace) {
|
||
Fr.SpaceId = RealSpace;
|
||
Changed = true;
|
||
}
|
||
if (String(Fr.NullmapHex || '') !== NmHex) {
|
||
Fr.NullmapHex = NmHex;
|
||
Changed = true;
|
||
}
|
||
Stat.Rebased++;
|
||
Lines.push(' ' + String(Fr.File).padEnd(14) + ' 帧体 ' + Body.length + 'B nullmap=' + NmHex.slice(0, 32) +
|
||
(NmHex.length > 32 ? '…' : '') + ' ' + NewRecords.length + ' 条记录 [' + Shapes.join(' + ') + ']');
|
||
for (const F of Fixes) Lines.push(F);
|
||
}
|
||
|
||
if (Changed && !Dry) fs.writeFileSync(Path2, JSON.stringify(T, null, 1) + '\n', 'utf8');
|
||
Lines.push(' ' + (Changed ? (Dry ? '(--dry)有变化,未写回' : '已写回') : '无变化'));
|
||
}
|
||
|
||
console.log('=== 以真实明文帧为准重写 space-init 模板 ===');
|
||
console.log('模板目录: ' + NamesDir);
|
||
console.log('明文目录: ' + PlainDir);
|
||
for (const L of Lines) console.log(L);
|
||
console.log('');
|
||
console.log('模板 ' + Stat.Templates + ' 份 帧 ' + Stat.Frames +
|
||
'(重写 ' + Stat.Rebased + ' / 无明文 ' + Stat.KeptNoPlain + ' / 形态表达不了 ' + Stat.KeptUndecodable + ' / 组帧不一致拒写 ' + Stat.KeptAssert + ')');
|
||
console.log('记录 ' + Stat.Records + ' 条:容器 ' + Stat.Ctr + ' / 字段 ' + Stat.Fld + ' / hex ' + Stat.Hex);
|
||
console.log('objectId:补上 ' + Stat.ObjFilled + ' 条 / 修正错值 ' + Stat.ObjFixed + ' 条');
|
||
console.log('短命令形态:' + Stat.ShortFrames + ' 帧 / ' + Stat.ShortRecords + ' 条记录(语义层判定不了命令号 → 整帧原样保留)');
|
||
for (const F of Skipped) console.log('跳过 ' + F + ':登录模板,来自中继抓包、packets/plain 下没有对应明文帧 → 记录不动');
|
||
if (Dry) console.log('(--dry:一个字节都没写)');
|
||
|
||
// ---------------------------------------------------------------- 全量对账
|
||
// 把「模板组帧(走 space.js 的现编路径)」与「原始明文帧体」逐帧比一遍:这是本工具的最终验收。
|
||
console.log('');
|
||
console.log('=== 模板组帧 vs 原始帧体 全量对账 ===');
|
||
const Rec = { Same: 0, Diff: 0, NoPlain: 0, Skip: 0 };
|
||
for (const File of fs.readdirSync(NamesDir).filter((X) => /^space-init-.*\.json$/.test(X)).sort()) {
|
||
const T = JSON.parse(fs.readFileSync(path.join(NamesDir, File), 'utf8'));
|
||
if (/^space-init-login-/.test(File)) {
|
||
console.log(' ' + File.padEnd(34) + ' 跳过(无对应明文抓包) 帧 ' + (T.Frames || []).length);
|
||
Rec.Skip += (T.Frames || []).length;
|
||
continue;
|
||
}
|
||
const Dir = path.join(PlainDir, 'space-' + T.SpaceId);
|
||
for (const Fr of T.Frames || []) {
|
||
const Plain = path.join(Dir, Fr.File);
|
||
if (!fs.existsSync(Plain)) {
|
||
console.log(' ' + File.padEnd(34) + ' ' + String(Fr.File).padEnd(14) + ' 无明文帧 → 无法对账(已原样保留)');
|
||
Rec.NoPlain++;
|
||
continue;
|
||
}
|
||
const Body = Link.DecodeFrame(Link.ParseFrames(fs.readFileSync(Plain))[0]);
|
||
let BuiltBody = null;
|
||
let Err = null;
|
||
try {
|
||
BuiltBody = BuildBody(Fr.File, String(Fr.SpaceId), Fr.NullmapHex, Fr.DelayMs || 0, Fr.Records || []);
|
||
} catch (E) {
|
||
Err = E.message;
|
||
}
|
||
const Same = !!BuiltBody && BuiltBody.equals(Body);
|
||
if (Same) Rec.Same++;
|
||
else Rec.Diff++;
|
||
const Offs = Same ? [] : DiffOffsets(BuiltBody || Buffer.alloc(0), Body, 8);
|
||
console.log(' ' + File.padEnd(34) + ' ' + String(Fr.File).padEnd(14) + (Same ? ' ✓ 一致 ' : ' ✗ 不一致 ') +
|
||
'帧体 ' + Body.length + 'B 记录 ' + (Fr.Records || []).length + (Err ? ' 组帧抛错:' + Err : '') +
|
||
(Same ? '' : ' 差异偏移=' + JSON.stringify(Offs)));
|
||
}
|
||
}
|
||
console.log('合计:一致 ' + Rec.Same + ' / 不一致 ' + Rec.Diff + ' / 无明文 ' + Rec.NoPlain + ' / 跳过(登录)' + Rec.Skip);
|
||
process.exit(Rec.Diff ? 1 : 0);
|