工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
188 lines
7.3 KiB
JavaScript
188 lines
7.3 KiB
JavaScript
// 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);
|