Files
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

188 lines
7.3 KiB
JavaScript
Raw Permalink 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.
// selftest.js - MakeWriter 往返自检:拿抓包里的真实空间帧,逐字段解码 → 再编码 → 与原字节比对
//
// 这是写包能力的唯一验收手段:真服务器要能发 s2c,前提是「解出来再写回去」必须逐字节一致。
// 覆盖不到的地方(容器 lt/pt/zt、模块内建 codec 如 Ea)会被单独计数并列出,作为 M2 的待补清单。
//
// 用法: node server/selftest.js [--dir ../packets/plain] [--limit 0] [--show 5]
'use strict';
const fs = require('fs');
const path = require('path');
const { LoadTables, MakeReader } = require('../lib_space_codec');
const Link = require('./link');
const { MakeWriter, ResolveFields } = require('./codec');
const { MakeContainerCodec } = require('./container');
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 Root = path.resolve(Arg('dir', path.join(PROJECT, 'packets', 'plain')));
const Limit = Number(Arg('limit', '0')) || 0;
const Show = Number(Arg('show', '5')) || 5;
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);
// 容器帧(methodId 不在普通 codec / 上行表里):用容器三布局判定,要求恰好吃到帧尾
const BoxStat = { Tried: 0, Detected: 0, Exact: 0, Bad: 0, ByLayout: new Map(), FailSamples: [] };
function TryContainer(Body, At, File) {
if (At + 16 > Body.length) return;
const MethodId = Body.readBigInt64BE(At + 8).toString();
if (Tables.CodecByMethod.has(MethodId) || Tables.UplinkByMethod.has(MethodId)) return;
BoxStat.Tried++;
const Off = At + 16;
const Hits = ['zt', 'pt', 'lt'].filter((L) => {
const R = Container.Read(L, Body, Off);
return !R.Bad && R.Next === Body.length;
});
if (!Hits.length) {
BoxStat.Bad++;
if (BoxStat.FailSamples.length < 5) BoxStat.FailSamples.push(File + ' methodId=' + MethodId);
return;
}
BoxStat.Detected++;
const Layout = Hits[0];
BoxStat.ByLayout.set(Layout, (BoxStat.ByLayout.get(Layout) || 0) + 1);
const R = Container.Read(Layout, Body, Off);
try {
if (Container.Write(Layout, R.Value).equals(Body.slice(Off))) BoxStat.Exact++;
else Note('容器写回不一致', File + ' ' + Layout);
} catch (Err) {
Note('容器写侧抛错', File + ' ' + Layout + ' ' + Err.message);
}
}
// 首字节是第一个 id 的最高位,且 8 字节后的 methodId 在「下行 codec 表」或「上行命令表」里 → 认作空间记录流
// (实测两张表的 methodId 基本不重合:上行 360 条只在 uplink_table,下行 codec 只在 codec_fields)
function IsSpaceLike(Body, At) {
if (At >= Body.length) return false;
const B0 = Body[At];
if (B0 !== 0x00 && B0 !== 0xff) return false;
if (At + 16 > Body.length) return false;
const M = Body.readBigInt64BE(At + 8).toString();
return Tables.CodecByMethod.has(M) || Tables.UplinkByMethod.has(M);
}
const Stats = { Dirs: 0, Files: 0, Frames: 0, SpaceFrames: 0, Records: 0, Exact: 0, Bad: 0, Unsupported: 0, Mismatch: 0 };
const UnsupportedKinds = new Map();
const BadSamples = [];
const MismatchSamples = [];
function Note(Kind, Msg) {
UnsupportedKinds.set(Kind, (UnsupportedKinds.get(Kind) || 0) + 1);
if (BadSamples.length < 20) BadSamples.push(Kind + ' ' + Msg);
}
function ScanFile(File) {
Stats.Files++;
const Buf = fs.readFileSync(File);
const Frames = Link.ParseFrames(Buf);
if (!Frames) return;
for (const Frame of Frames) {
Stats.Frames++;
let Body;
try {
Body = Link.DecodeFrame(Frame);
} catch (Err) {
continue;
}
const At = Link.NullmapSize(Body, 0);
if (!IsSpaceLike(Body, At)) {
TryContainer(Body, At, path.basename(File));
continue;
}
Stats.SpaceFrames++;
let Off = At + 8;
while (Off + 8 <= Body.length) {
const MethodId = Body.readBigInt64BE(Off).toString();
const { Codec, Fields } = ResolveFields(Tables, MethodId, Tables.UplinkByMethod.get(MethodId));
if (!Codec && !Fields.length) break;
const Label = Codec ? Codec.ModelName || Codec.ModelClassReal || '?' : '上行命令';
const MethodRaw = Body.slice(Off, Off + 8);
const Start = Off;
Off += 8;
const Values = [];
let Bad = false;
for (const F of Fields) {
const R = Reader.ReadValue(Body, Off, F.Type, F.TypeCodec, 0);
Values.push([F, R.Value]);
if (R.Bad || R.Next <= Off) {
Bad = true;
break;
}
Off = R.Next;
}
Stats.Records++;
if (Bad) {
Stats.Bad++;
Note('解码中断', Label + ' methodId=' + MethodId);
continue;
}
const Original = Body.slice(Start, Off);
let Encoded = null;
try {
const Parts = [MethodRaw];
for (const [F, V] of Values) Parts.push(Writer.WriteValue(F.Type, F.TypeCodec, V, 0));
Encoded = Buffer.concat(Parts);
} catch (Err) {
Stats.Unsupported++;
Note(String(Err.message).split(':')[0], Label + ' methodId=' + MethodId);
continue;
}
if (Encoded.equals(Original)) Stats.Exact++;
else {
Stats.Mismatch++;
if (MismatchSamples.length < Show) {
MismatchSamples.push(
Label + ' methodId=' + MethodId + '\n 原 ' + Original.toString('hex').slice(0, 160) +
'\n 写 ' + Encoded.toString('hex').slice(0, 160),
);
}
}
}
}
}
function ScanDir(Dir) {
for (const E of fs.readdirSync(Dir, { withFileTypes: true })) {
const P = path.join(Dir, E.name);
if (E.isDirectory()) {
Stats.Dirs++;
ScanDir(P);
} else if (/^[cs]2[cs]_\d+\.bin$/.test(E.name)) {
if (Limit && Stats.Files >= Limit) return;
ScanFile(P);
}
}
}
ScanDir(Root);
console.log('MakeWriter 往返自检(' + Root + ')');
console.log(' 目录 ' + Stats.Dirs + ' 文件 ' + Stats.Files + ' 帧 ' + Stats.Frames + ' 空间帧 ' + Stats.SpaceFrames);
console.log(' 记录 ' + Stats.Records + '');
console.log(' ✓ 逐字节一致 ' + Stats.Exact);
console.log(' ✗ 不一致 ' + Stats.Mismatch);
console.log(' ⚠ 解码中断 ' + Stats.Bad);
console.log(' ⚠ 写侧不支持 ' + Stats.Unsupported + '(类型/编码还没做)');
if (UnsupportedKinds.size) {
console.log(' 写侧不支持的原因(Top 10):');
for (const [K, V] of [...UnsupportedKinds].sort((A, B) => B[1] - A[1]).slice(0, 10)) console.log(' ' + String(V).padStart(6) + ' ' + K);
}
if (BadSamples.length) {
console.log(' 样本(前 10):');
for (const S of BadSamples.slice(0, 10)) console.log(' ' + S);
}
if (MismatchSamples.length) {
console.log(' 不一致样例:');
for (const S of MismatchSamples) console.log(' ' + S);
}
console.log(' 容器帧(methodId 不在普通 codec / 上行表里): 候选 ' + BoxStat.Tried + ' 判定出布局 ' + BoxStat.Detected + ' 逐字节一致 ' + BoxStat.Exact + ' 判定失败 ' + BoxStat.Bad);
if (BoxStat.ByLayout.size) console.log(' 布局分布: ' + JSON.stringify([...BoxStat.ByLayout]));
for (const S of BoxStat.FailSamples) console.log(' 判定失败样例: ' + S);