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

158 lines
7.6 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.
// audit_space_init.js - 审计 names/space-init-*.json 的记录切分
// 用法:
// node audit_space_init.js [--fix] [--names ../names]
//
// 为什么需要它:模板是 tools/extract_space_init.js 从抓包切出来的,而它当时的帧结构理解是
// 「[nullmap][spaceId][(methodId)(负载)…]」——把**第一条记录的 objectId** 当成了帧级 spaceId。
// 于是就出现一类系统性错误:一条记录的 `Hex` 里其实还塞着后面几条记录的头
// (因为切分点只在第一条记录之后才对齐)。字节没丢,但「记录」这个语义层级是错的:
// 想按记录改一条、或按 methodId 找一条,都会错位。
//
// 判据(不猜,靠已知 methodId 表):在 Hex 里每找到一处「8 字节 = 已知 methodId」,
// 且它前面 8 字节长得像 objectId(等于所属空间号 / 负数合成 id),就认为那里是一条记录的边界。
//
// --fix:把该记录拆成多条(每条自带 ObjectId + MethodId + Hex 负载)。
// 拆分是**字节中性**的:space.js 组帧时每条记录写 [objectId][methodId][负载],
// 拆前=拆后拼出来的帧必须逐字节相同(工具会自己断言,不一致就拒绝写回)。
'use strict';
const fs = require('fs');
const path = require('path');
const ToolsDir = __dirname;
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 NamesDir = path.resolve(ToolsDir, Arg('names', path.join('..', 'names')));
const Fix = Argv.indexOf('--fix') >= 0;
const Link = require('./server/link');
const Space = require('./server/space');
const Sem = require('./server/semantic').Load(NamesDir);
// 已知 methodId(下行 + 上行 + method_table):用来在 Hex 里找记录边界
// KindByMethod:只有「字段型」记录的边界能被确定性验证(字段列表消耗的字节是死的);
// 容器型(zt/pt/lt)的容器解码在多个候选边界上都可能"成功",会误判(实测 _loadDependencies
// 那帧被拆成 28 条),所以容器型一律只报告不拆。
const KnownMethods = new Set();
const KindByMethod = new Map();
for (const F of ['downlink_table.json', 'method_table.json']) {
const P = path.join(NamesDir, F);
if (!fs.existsSync(P)) continue;
const J = JSON.parse(fs.readFileSync(P, 'utf8'));
for (const C of J.Commands || []) {
KnownMethods.add(String(C.MethodId));
KindByMethod.set(String(C.MethodId), C.Kind || 'fields');
}
for (const R of J.Methods || J.Rows || []) {
KnownMethods.add(String(R.MethodId));
if (!KindByMethod.has(String(R.MethodId))) KindByMethod.set(String(R.MethodId), 'fields');
}
}
// 「长得像 objectId」:等于所属空间号、或负数合成 id(实测 -214748xxxx 一族)
const LooksLikeObjectId = (V, SpaceId) => {
const S = V.toString();
if (S === String(SpaceId)) return true;
if (V < 0n) return true;
return false;
};
// 在 Hex 里找记录边界:返回 [{At, ObjectId, MethodId, Known}],At = 记录头起始偏移
function FindRecordHeads(Buf, SpaceId) {
const Out = [];
for (let I = 0; I + 16 <= Buf.length; I++) {
const MethodId = Buf.readBigInt64BE(I + 8).toString();
if (!KnownMethods.has(MethodId)) continue;
const Obj = Buf.readBigInt64BE(I);
if (!LooksLikeObjectId(Obj, SpaceId)) continue;
Out.push({ At: I, ObjectId: Obj.toString(), MethodId });
}
return Out;
}
// 一条 Hex 负载 → 拆成 [{ObjectId, MethodId, Hex}];第一条沿用模板已有的 ObjectId/MethodId。
// 边界**必须被语义层确认**:当前记录的负载(到候选头为止)要能被 DecodeRecord 完整解出、
// 且恰好吃到候选头位置。只靠"字节中性"不够 —— 负载里偶然出现一个像 methodId 的 8 字节
// 也能凑出字节中性的拆分,所以必须有解码证据。
function SplitRecord(Rec, SpaceId, Sem) {
const Buf = Buffer.from(Rec.Hex || '', 'hex');
const Heads = FindRecordHeads(Buf, SpaceId);
if (!Heads.length) return null;
const PayloadFits = (MethodId, From, To) => {
// 只认字段型记录:容器型的解码在多个 End 上都可能通过(见文件头 KindByMethod 的说明)
if ((KindByMethod.get(String(MethodId)) || 'fields') === 'container') return false;
const R = Sem.DecodeRecord(MethodId, Buf, From, To);
return !R.Bad && From + R.Consumed === To;
};
let At = 0;
let Cur = { ObjectId: Rec.ObjectId, MethodId: Rec.MethodId };
const Out = [];
for (const H of Heads) {
if (H.At < At) continue;
if (!PayloadFits(Cur.MethodId, At, H.At)) continue; // 当前记录的负载解不到这个位置 → 不是边界
Out.push({ ...Cur, Hex: Buf.slice(At, H.At).toString('hex') });
Cur = { ObjectId: H.ObjectId, MethodId: H.MethodId };
At = H.At + 16; // 16 字节头被当成新记录的头吃掉
}
Out.push({ ...Cur, Hex: Buf.slice(At).toString('hex') });
return Out.length > 1 ? Out : null;
}
// 模板记录 → 组帧,用于「拆前/拆后字节必须相同」的断言
function FrameBytes(Space, Records, NullmapHex, SpaceId) {
return Space.BuildSpaceFrame(SpaceId, Records.map((R) => ({
ObjectId: R.ObjectId === undefined || R.ObjectId === null ? SpaceId : R.ObjectId,
MethodId: R.MethodId,
Payload: Buffer.from(R.Hex || '', 'hex'),
})), NullmapHex);
}
let Total = 0;
let Split = 0;
let Kept = 0;
const Lines = [];
for (const F of fs.readdirSync(NamesDir).filter((X) => /^space-init-.*\.json$/.test(X)).sort()) {
const P = path.join(NamesDir, F);
const T = JSON.parse(fs.readFileSync(P, 'utf8'));
let Changed = 0;
for (const Fr of T.Frames || []) {
// 帧级 SpaceId 可能被写成第一条记录的 objectId(见文件头)——组帧时需要真实空间号
const RealSpace = String(T.SpaceId);
for (let I = 0; I < (Fr.Records || []).length; I++) {
const R = Fr.Records[I];
Total++;
if (!R.Hex) continue;
const Parts = SplitRecord({ ...R, ObjectId: R.ObjectId === undefined ? RealSpace : R.ObjectId }, RealSpace, Sem);
if (!Parts) { Kept++; continue; }
const Before = FrameBytes(Space, [R.ObjectId === undefined ? { ...R, ObjectId: RealSpace } : R], Fr.NullmapHex, RealSpace);
const After = FrameBytes(Space, Parts, Fr.NullmapHex, RealSpace);
if (!Before.equals(After)) {
Lines.push(' ✗ ' + F + ' ' + Fr.File + ' methodId=' + R.MethodId +
' 拆分后字节不一致(拆前 ' + Before.length + 'B / 拆后 ' + After.length + 'B)→ 拒绝拆分');
Kept++;
continue;
}
Lines.push(' → ' + F + ' ' + Fr.File + ' methodId=' + R.MethodId + '(Hex ' + (R.Hex.length / 2) + 'B)' +
' 拆成 ' + Parts.length + ' 条:' + Parts.map((X) => X.ObjectId + '/' + X.MethodId + '=' + (X.Hex || '').length / 2 + 'B').join(' '));
if (Fix) {
Fr.Records.splice(I, 1, ...Parts);
I += Parts.length - 1;
}
Split++;
Changed++;
}
}
if (Fix && Changed) {
T.Note = (T.Note || '') + ' 【记录已按对账拆分:原来把后续记录的头塞在前一条的 Hex 里】';
fs.writeFileSync(P, JSON.stringify(T, null, 1) + '\n', 'utf8');
}
}
console.log('=== space-init 模板记录切分审计 ===');
console.log('模板目录: ' + NamesDir + ' 已知 methodId ' + KnownMethods.size + ' 条');
console.log('记录总数: ' + Total + ' 需要拆分: ' + Split + ' 保持不变: ' + Kept + ' 模式: ' + (Fix ? '已写回(--fix)' : '只审计'));
if (Lines.length) for (const L of Lines) console.log(L);
if (!Split) console.log(' (没有发现"Hex 里含后续记录头"的记录)');