feat(tools): 新增 pt 边界审计器,并补全参考实现的 vector3 与位推进判据

This commit is contained in:
TermiNexus committed 2026-10-21 02:20:31 +08:00
1 parent 7845e5becd
commit 71b74b41e0
2 files changed
+249 -2

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
// audit_pt_boundaries.js - 只读审计:pt 容器里「每项解完后是否正好落在下一个合法 modelId 上」
//
// 为什么要它:
// 回放失败时只知道「第 N 项之后读出了不存在的 modelId」,但不知道到底是哪一类 codec
// 把字节数算错了(字段表不全 / 类型宽度错 / opt 位错)。这个审计把「边界命中」按 codec 汇总,
// 直接给出嫌疑排序,避免在个别模型上反复猜。
//
// 判定口径(严格、不猜):
// · 一项解完后,紧跟的 8 字节若等于某个「运行期已注册的 modelId」→ 命中;
// · 若正好到负载末尾 → 命中(最后一项);
// · 否则记为该项未命中,并停止解析该记录(后续偏移已不可信)。
//
// 用法: node tools/audit_pt_boundaries.js --plain packets/plain --limit 200000
'use strict';
const fs = require('fs');
const path = require('path');
const { LoadTables, MakeReader } = require('./lib_space_codec');
const Link = require('./server/link');
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 PlainDir = path.resolve(Arg('plain', path.join(PROJECT, 'packets', 'plain')));
const Limit = Number(Arg('limit', '0')) || 0;
const Tables = LoadTables(path.join(PROJECT, 'names'));
const Binding = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'model_binding.json'), 'utf8'));
const CodecKeyByModelId = new Map();
const Registered = new Set();
for (const E of Binding.Entries) {
CodecKeyByModelId.set(String(E.ModelId), E.Key);
Registered.add(String(E.ModelId));
}
// 资源类裸名 → 线上基础类型(与 container.js 同口径)
const RESOURCE_CLASS_WIRE = {
Ea: 'i64', nh: 'i64', W_: 'i64', uo: 'i64', pv: 'i64', B_: 'i64', Da: 'i64',
qs: 'i64', uh: 'i64', Gf: 'i64', kv: 'i64', Pc: 'i64',
};
const Alias = {};
for (const [N, R] of Object.entries(Tables.ResourceClasses || {})) Alias[N] = R.Wire;
for (const [N, W] of Object.entries(RESOURCE_CLASS_WIRE)) Alias[N] = W;
const Reader = MakeReader(Tables, {
EnumBytes: Number(Arg('enum-bytes', '4')) || 4,
OptBits: true,
TypeAlias: Alias,
StringLiteralOnly: true,
});
const PT_METHOD = '7640916300855664666';
// nullmap 位图(与 server/container.js 同口径):长形态首字节 bit7=1,位图从标签之后开始;
// 短形态整串从 MSB 读、跳过前 3 位(那 3 位是长度标签)。
function NullmapBits(Bytes) {
const Bits = [];
const Push = (B) => {
for (let K = 7; K >= 0; K--) Bits.push((B >> K) & 1);
};
if (Bytes.length && Bytes[0] & 0x80) {
for (let I = Bytes[0] & 0x40 ? 3 : 1; I < Bytes.length; I++) Push(Bytes[I]);
} else {
for (const B of Bytes) Push(B);
Bits.splice(0, 3);
}
return Bits;
}
const Stats = { Files: 0, Records: 0, Items: 0, Ok: 0, Bad: 0 };
const ByCodec = new Map(); // key -> { Ok, Bad, FirstBad }
const BadSamples = [];
function Bump(Key, Bad, Where) {
let S = ByCodec.get(Key);
if (!S) {
S = { Key, Ok: 0, Bad: 0, FirstBad: '' };
ByCodec.set(Key, S);
}
if (Bad) {
S.Bad++;
if (!S.FirstBad) S.FirstBad = Where;
} else S.Ok++;
}
function IdAt(Buf, Off) {
return Off + 8 <= Buf.length ? Buf.readBigInt64BE(Off).toString() : null;
}
// 合法边界的三种形态(严格,不猜):
// ① 负载末尾;
// ② 下一个 pt 项:id=0(内联值)或 id 是运行期已注册的 modelId;
// ③ 下一条记录的头:objectId + 已知 methodId。
const KnownMethods = new Set([
'7640916300855664666', '3216143066888387731', '9038756763225879447', '8179162314198150518',
]);
for (const C of JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'downlink_table.json'), 'utf8')).Commands || []) {
KnownMethods.add(String(C.MethodId));
}
for (const C of JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'uplink_table.json'), 'utf8')).Commands || []) {
KnownMethods.add(String(C.MethodId));
}
function IsBoundary(Buf, At) {
if (At === Buf.length) return true;
if (At + 8 > Buf.length) return false;
const Id = IdAt(Buf, At);
if (Id === '0' || Registered.has(Id)) return true;
if (At + 16 <= Buf.length) {
const M = Buf.readBigInt64BE(At + 8).toString();
if (KnownMethods.has(M)) return true;
}
return false;
}
function AuditPt(Body, Off, File) {
if (Off + 4 > Body.length) return;
const Buf = Body;
const C = Buf.readInt32BE(Off);
let At = Off + 4;
if (C < 0 || C > 1 << 20 || At + 16 * C + 4 > Buf.length) return;
At += 16 * C;
const D = Buf.readInt32BE(At);
At += 4;
if (D < 0 || D > 1 << 20) return;
Stats.Records++;
// opt 字段的「空/非空」位与记录形态位共用同一条 nullmap 比特流;本审计只处理「pt 是帧内第一条记录」
// 的情形,所以位游标 = 1(跳过本条记录的形态位),随后由 Reader 内部逐个 opt 推进。
const NmSize = Link.NullmapSize(Body, 0);
const Bits = NmSize > 0 && NmSize <= Body.length ? NullmapBits(Body.slice(0, NmSize)) : null;
if (Bits) Reader.AttachBits(Bits, 1);
try {
for (let I = 0; I < D; I++) {
if (At + 8 > Buf.length) return;
const Id = IdAt(Buf, At);
const Key = CodecKeyByModelId.get(Id);
At += 8;
if (Id === '0') {
At += 8;
Stats.Items++;
continue;
}
const Codec = Tables.CodecByKey.get(Key);
const Where = File + ' 第 ' + (I + 1) + '/' + D + ' 项 ' + Id + '(' + Key + ')@' + At;
if (!Codec) {
Stats.Bad++;
Bump(Key || ('未注册:' + Id), true, Where);
if (BadSamples.length < 30) BadSamples.push(Where + ':没有 codec');
return;
}
const BitBefore = Bits ? Reader.BitCursor().Index : 0;
const R = Reader.ReadFields(Buf, At, Codec, 0);
Stats.Items++;
// 「解不动」的判据要包含位游标:整条模型字段全是 opt 且都为空时,字节不推进但位在推进,
// 这也是合法的(例如 DescriptionMediaCC 只有两个 opt<i64>)。
const BitMoved = Bits ? Reader.BitCursor().Index > BitBefore : false;
if (R.Bad || (R.Next <= At && !BitMoved)) {
Stats.Bad++;
Bump(Key, true, Where + ':解不动 ' + (R.Why || ''));
if (BadSamples.length < 30) BadSamples.push(Where + ':解不动 ' + (R.Why || ''));
return;
}
At = R.Next;
if (IsBoundary(Buf, At)) {
Stats.Ok++;
Bump(Key, false, '');
} else {
Stats.Bad++;
Bump(Key, true, Where + ' → 下一 id=' + IdAt(Buf, At));
if (BadSamples.length < 30) BadSamples.push(Where + ' → 下一 id=' + IdAt(Buf, At));
return;
}
}
} finally {
if (Bits) Reader.DetachBits();
}
}
function ScanFile(File) {
Stats.Files++;
let Frames = null;
try {
Frames = Link.ParseFrames(fs.readFileSync(File));
} catch (E) {
return;
}
if (!Frames) return;
for (const F of Frames) {
let Body;
try {
Body = Link.DecodeFrame(F);
} catch (E) {
continue;
}
const Start = Link.NullmapSize(Body, 0);
let Off = Start + 8;
while (Off + 8 <= Body.length) {
const MethodId = Body.readBigInt64BE(Off).toString();
// 只认 pt;其它记录的负载长度这里不建模,遇到就结束本帧
if (MethodId !== PT_METHOD) break;
AuditPt(Body, Off + 8, path.basename(File));
break;
}
}
}
function Walk(Dir) {
for (const E of fs.readdirSync(Dir, { withFileTypes: true })) {
if (Limit && Stats.Files >= Limit) return;
const P = path.join(Dir, E.name);
if (E.isDirectory()) Walk(P);
else if (/^s2c_\d+\.bin$/.test(E.name)) ScanFile(P);
}
}
Walk(PlainDir);
console.log('pt 边界审计(' + PlainDir + ')');
console.log(' 文件 ' + Stats.Files + ' 记录 ' + Stats.Records + ' 项 ' + Stats.Items +
' 边界命中 ' + Stats.Ok + ' 未命中 ' + Stats.Bad);
const Rows = [...ByCodec.values()].filter((S) => S.Bad > 0).sort((A, B) => B.Bad - A.Bad);
console.log(' 有问题的 codec(按未命中次数):');
for (const S of Rows.slice(0, 20)) {
console.log(' ' + String(S.Bad).padStart(4) + ' / ' + String(S.Bad + S.Ok).padStart(4) +
' ' + S.Key + ' 首个:' + S.FirstBad);
}
if (BadSamples.length) {
console.log(' 样例:');
for (const S of BadSamples.slice(0, 10)) console.log(' ' + S);
}
+16 -2
View File
@@ -171,6 +171,7 @@ function MakeReader(Tables, Options) {
const N = ReadVarint(Buf, Off);
let At = N.Next;
let Bad = N.Bad;
let Why = '';
const Arr = [];
// 「元素读不动」的判据在 OptBits 模式下要放宽:元素若是 opt<T> 且为空,值一个字节都不占
// (只有 nullmap 里的 1 个 bit,见 src/module_24635.js:412-417 的 Ht.p7g:读到位=1 时直接 return null),
@@ -183,16 +184,22 @@ function MakeReader(Tables, Options) {
Bad = true;
break;
}
const BitBefore = BitIndex;
const R = ReadValue(Buf, At, List[1], TypeCodec, Depth + 1);
Arr.push(R.Value);
if (R.Bad || (R.Next <= At && !ZeroOk)) {
// 元素不占字节也可能是合法的:opt<T> 为空(走位图),或「所有字段都是 opt 且都为空」的模型
// (例如 ParticleEffectItem 只有 opt<list<…>> + i64 + string,若前两项为空则可能 0 字节)。
// 判据:字节没推进但位游标推进了。
const BitMoved = BitIndex > BitBefore;
if (R.Bad || (R.Next <= At && !ZeroOk && !BitMoved)) {
Arr.push('<卡住>');
Bad = true;
if (R.Why) Why = R.Why; // 把元素内部的具体原因带出去,否则上层只看到「list 解不动」
break;
}
At = R.Next;
}
return { Value: Arr, Next: At, Bad };
return { Value: Arr, Next: At, Bad, Why };
}
const Enum = /^enum<([\s\S]+)>$/.exec(T);
if (Enum) {
@@ -260,6 +267,13 @@ function MakeReader(Tables, Options) {
case 'f64':
if (Off + 8 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readDoubleBE(Off), Next: Off + 8 };
case 'vector3':
// 向量 = 3 × f32(module_32088 的向量类;字段表里以裸类型名 vector3 出现,无嵌套 codec)
if (Off + 12 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return {
Value: { x: Buf.readFloatBE(Off), y: Buf.readFloatBE(Off + 4), z: Buf.readFloatBE(Off + 8) },
Next: Off + 12,
};
case 'string':
case 'bytes':
case 'array': {