工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
158 lines
7.6 KiB
JavaScript
158 lines
7.6 KiB
JavaScript
// 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 里含后续记录头"的记录)');
|