From 71b74b41e057074acf74f297a3bc3d51df91f4a2 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Wed, 21 Oct 2026 02:20:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=E6=96=B0=E5=A2=9E=20pt=20?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E5=AE=A1=E8=AE=A1=E5=99=A8=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=E5=8F=82=E8=80=83=E5=AE=9E=E7=8E=B0=E7=9A=84?= =?UTF-8?q?=20vector3=20=E4=B8=8E=E4=BD=8D=E6=8E=A8=E8=BF=9B=E5=88=A4?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 3DTank/tools/audit_pt_boundaries.js | 233 ++++++++++++++++++++++++++++ 3DTank/tools/lib_space_codec.js | 18 ++- 2 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 3DTank/tools/audit_pt_boundaries.js diff --git a/3DTank/tools/audit_pt_boundaries.js b/3DTank/tools/audit_pt_boundaries.js new file mode 100644 index 0000000..695b9cc --- /dev/null +++ b/3DTank/tools/audit_pt_boundaries.js @@ -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)。 + 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); +} diff --git a/3DTank/tools/lib_space_codec.js b/3DTank/tools/lib_space_codec.js index 9c22400..1371171 100644 --- a/3DTank/tools/lib_space_codec.js +++ b/3DTank/tools/lib_space_codec.js @@ -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 且为空,值一个字节都不占 // (只有 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 为空(走位图),或「所有字段都是 opt 且都为空」的模型 + // (例如 ParticleEffectItem 只有 opt> + 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': {