Files
TankIC/3DTank/tools/rebase_space_init.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

262 lines
13 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.
// 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);