feat(tools): 新增 pt 边界审计器,并补全参考实现的 vector3 与位推进判据
This commit is contained in:
1 parent
7845e5becd
commit
71b74b41e0
2 files changed
+249
-2
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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': {
|
||||
|
||||
Reference in new issue
Block a user