From ce8eeec8d6b62c02c7d41b5fe5f970a0d6a0efc2 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Wed, 21 Oct 2026 03:28:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(tools):=20=E5=AE=A1=E8=AE=A1=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E8=B7=A8=E8=AE=B0=E5=BD=95=E4=BD=8D=E6=B8=B8=E6=A0=87?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=AD=A3=E5=A4=9A=E8=AE=B0=E5=BD=95=E5=B8=A7?= =?UTF-8?q?=E8=AF=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 3DTank/tools/audit_pt_boundaries.js | 38 +++++++++++------- 3DTank/tools/lib_space_codec.js | 39 +++++++++++++++---- 3DTank/tools/server/semantic.js | 4 +- .../src/jsMain/kotlin/Net/Wire/ModelCodec.kt | 6 +-- TankiOffline/tools/GenerateProtocol.py | 9 +++-- 5 files changed, 67 insertions(+), 29 deletions(-) diff --git a/3DTank/tools/audit_pt_boundaries.js b/3DTank/tools/audit_pt_boundaries.js index f6a0ccd..c52c18b 100644 --- a/3DTank/tools/audit_pt_boundaries.js +++ b/3DTank/tools/audit_pt_boundaries.js @@ -16,6 +16,7 @@ const fs = require('fs'); const path = require('path'); const { LoadTables, MakeReader } = require('./lib_space_codec'); const Link = require('./server/link'); +const Semantic = require('./server/semantic').Load(path.join(__dirname, '..', 'names')); const Argv = process.argv.slice(2); const Arg = (Name, Def) => { @@ -50,8 +51,14 @@ const TraceArg = Arg('trace', ''); const TraceDepth = Number(Arg('trace-depth', '6')) || 6; let TraceOn = false; +const EnumBytesArg = Arg('enum-bytes', '4'); +const EnumBytes = EnumBytesArg === undefined || EnumBytesArg === '' ? 4 : Number(EnumBytesArg); +const EnumInListArg = Arg('enum-in-list', ''); +const EnumInList = EnumInListArg === '' ? EnumBytes : Number(EnumInListArg); +if (!Number.isFinite(EnumBytes) || !Number.isFinite(EnumInList)) throw new Error('--enum-bytes/--enum-in-list 需要数字'); const Reader = MakeReader(Tables, { - EnumBytes: Number(Arg('enum-bytes', '4')) || 4, + EnumBytes, + EnumInList, OptBits: true, TypeAlias: Alias, StringLiteralOnly: true, @@ -127,7 +134,7 @@ function IsBoundary(Buf, At) { return false; } -function AuditPt(Body, Off, File) { +function AuditPt(Body, Off, File, BitStart) { if (Off + 4 > Body.length) return; const Buf = Body; const C = Buf.readInt32BE(Off); @@ -138,11 +145,13 @@ function AuditPt(Body, Off, File) { At += 4; if (D < 0 || D > 1 << 20) return; Stats.Records++; - // opt 字段的「空/非空」位与记录形态位共用同一条 nullmap 比特流;本审计只处理「pt 是帧内第一条记录」 - // 的情形,所以位游标 = 1(跳过本条记录的形态位),随后由 Reader 内部逐个 opt 推进。 + // opt 字段的「空/非空」位与记录形态位共用同一条 nullmap 比特流; + // 起始位索引由 semantic.DecodeFrameBody 顺序推进得出(见下面的 BitStart)。 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); + // 位游标由 semantic 的顺序推进给出(BitStart = 本记录形态位之后的绝对位索引), + // 不再假设「pt 是帧内第一条记录」——多记录帧里那样会整体错位。 + if (Bits) Reader.AttachBits(Bits, typeof BitStart === 'number' ? BitStart : 1); try { for (let I = 0; I < D; I++) { if (At + 8 > Buf.length) return; @@ -213,14 +222,17 @@ function ScanFile(File) { } 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; + // 用 semantic 顺序解完整帧:它维护跨记录的位游标,并给每条记录标出 BitStart。 + // 只审计 pt 记录;其它记录的负载长度由 semantic 负责推进(短命令会中断整帧,属已知限制)。 + let Decoded = null; + try { + Decoded = Semantic.DecodeFrameBody(Body); + } catch (E) { + continue; + } + for (const R of (Decoded && Decoded.Records) || []) { + if (String(R.MethodId) !== PT_METHOD || R.Bad || R.At === undefined || R.Consumed === undefined) continue; + AuditPt(Body, R.At + 16, path.basename(File), R.BitStart); } } } diff --git a/3DTank/tools/lib_space_codec.js b/3DTank/tools/lib_space_codec.js index 102255a..fbfb1e5 100644 --- a/3DTank/tools/lib_space_codec.js +++ b/3DTank/tools/lib_space_codec.js @@ -108,7 +108,14 @@ function SplitTypeArgs(Text) { function MakeReader(Tables, Options) { const Opts = Options || {}; - const EnumBytes = Opts.EnumBytes === undefined ? 4 : Opts.EnumBytes; + // enum 宽度:源码上 EnumCodecInfo(vn → Kt)读写都是空实现(0 字节),但实测数据自相矛盾—— + // EmissionColorCC 的 4×i32 正好到帧尾(该 enum 必须 0 字节),而 enum=0 又会让 P_/Tu 等 21 个模型错位。 + // 全量审计下 4 字节口径最优(6 处未命中 vs 21),故默认 4;需要时用 --enum-bytes 0 复核。 + const EnumBytes = Opts.EnumBytes === undefined ? 4 : Opts.EnumBytes; + // EnumInList:list> 里每个元素占多少字节。实测「独立 enum 字段」是 0 字节, + // 但列表里的枚举元素看起来仍占 4 字节(否则 P_/Tu 这类带 list 的模型会整体错位)。 + // 默认与 EnumBytes 一致;审计时可单独调。 + const EnumInList = Opts.EnumInList === undefined ? EnumBytes : Opts.EnumInList; // OnField:逐字段回调(诊断用)。参数 {Depth, Name, Type, Start, Next, BitBefore, BitAfter, Bad, Why} // —— 只读观测,不参与解码判定;审计器用它打印「每个字段消费了哪些字节/位」。 const OnField = Opts.OnField || null; @@ -176,17 +183,33 @@ function MakeReader(Tables, Options) { let Bad = N.Bad; let Why = ''; const Arr = []; - // 「元素读不动」的判据在 OptBits 模式下要放宽:元素若是 opt 且为空,值一个字节都不占 - // (只有 nullmap 里的 1 个 bit,见 src/module_24635.js:412-417 的 Ht.p7g:读到位=1 时直接 return null), - // 于是 Next == At 是**合法**的。旧口径把它当「卡住」→ list> 里只要有空元素就整段解不动 - // (实测 space-100073070/s2c_0001 的 MountedResistancesCC.resistances 就卡在这里)。 - const ZeroOk = OptBits && /^opt 且为空 → 0 字节(只有 nullmap 里的 1 个 bit); + // · 元素是 enum → 恒 0 字节(EnumCodecInfo 不上线)。 + const ElemT = String(List[1]); + const ZeroOk = (OptBits && /^opt= 4096) { Arr.push('<元素过多>'); Bad = true; break; } + if (ElemEnum > 0) { + if (At + ElemEnum > Buf.length) { + Arr.push('<越界>'); + Bad = true; + break; + } + Arr.push(''); + At += ElemEnum; + continue; + } + if (ElemEnum === 0) { + Arr.push(''); + continue; + } const BitBefore = BitIndex; const R = ReadValue(Buf, At, List[1], TypeCodec, Depth + 1); Arr.push(R.Value); @@ -222,8 +245,8 @@ function MakeReader(Tables, Options) { let Bad = N.Bad; const Pairs = []; // 与 list 同源:OptBits 模式下 opt<…> 为空时 0 字节是合法的(见上面 list 分支的注释) - const ZeroK = OptBits && /^opt= 4096) { Pairs.push(['<条目过多>', '']); diff --git a/3DTank/tools/server/semantic.js b/3DTank/tools/server/semantic.js index 2581d60..80f3c9d 100644 --- a/3DTank/tools/server/semantic.js +++ b/3DTank/tools/server/semantic.js @@ -308,6 +308,8 @@ function Load(NamesDir) { let Off = NmSize; const Ctx = { Bits, Index: 0 }; while (Off < Body.length) { + // 记录起点时的位游标:诊断用(审计器拿它把 opt 位对齐回本记录) + const BitStart = Ctx.Index; if (Ctx.Index >= Bits.length) { Out.Bad = true; Out.Note = 'nullmap 的 ' + Bits.length + ' 个 bit 用完了,帧体还剩 ' + (Body.length - Off) + ' 字节没读'; @@ -332,7 +334,7 @@ function Load(NamesDir) { const ObjectId = Body.readBigInt64BE(Off).toString(); const MethodId = Body.readBigInt64BE(Off + 8).toString(); const R = DecodeRecord(MethodId, Body, Off + 16, undefined, Ctx); - Out.Records.push(Object.assign({ ObjectId, At: Off }, R)); + Out.Records.push(Object.assign({ ObjectId, At: Off, BitStart }, R)); if (R.Bad) { Out.Bad = true; Out.Note = '第 ' + Out.Records.length + ' 条记录(methodId=' + MethodId + ')解不动:' + R.Note; diff --git a/TankiOffline/net/src/jsMain/kotlin/Net/Wire/ModelCodec.kt b/TankiOffline/net/src/jsMain/kotlin/Net/Wire/ModelCodec.kt index 69b9417..8570ff7 100644 --- a/TankiOffline/net/src/jsMain/kotlin/Net/Wire/ModelCodec.kt +++ b/TankiOffline/net/src/jsMain/kotlin/Net/Wire/ModelCodec.kt @@ -4,9 +4,9 @@ // · 必填字段:直接读写 // · opt :先从 nullmap 取 1 位(1=null,0=有值),有值才读写 T // · list/map:varint 个数 + 逐项 -// · enum:按 i32 处理。源码上 EnumCodecInfo(module_24635 的 vn → Kt)读写都是空实现, -// 但整套改成 0 字节会让回放退化(railgun-battle 多 8 条错 + 回编码不一致 + nullmap 位不足), -// 说明表里标成 enum<...> 的字段还有别的东西没建模,宽度不能单独改 +// · enum:按 i32 处理(与 74 个 enum 字段「全部来自 EnumCodecInfo」的源码结论冲突,见下): +// EmissionColorCC 的 4×i32 正好到帧尾,说明该处 enum 必须 0 字节;但全量审计里 enum=0 会让 +// 21 个模型错位(P_.category 必须 4 字节)。两种口径无法同时满足,暂取整体最优的 i32。 // · string/bytes:varint 长度 + 原始字节 // · Vector3:3 × f32 package Net.Wire diff --git a/TankiOffline/tools/GenerateProtocol.py b/TankiOffline/tools/GenerateProtocol.py index 881b01c..45aa892 100644 --- a/TankiOffline/tools/GenerateProtocol.py +++ b/TankiOffline/tools/GenerateProtocol.py @@ -162,10 +162,11 @@ def DecodeExpr(Node) -> str: if Kind == "map": return "Wire.ReadMap(R, { " + DecodeExpr(Node[1]) + " }, { " + DecodeExpr(Node[2]) + " })" if Kind == "enum": - # 实测口径:按 i32 读。源码上 EnumCodecInfo(module_24635 的 vn → Kt)读写都是空实现, - # 但把它改成 0 字节后回放立刻退化(railgun-battle 多出 8 条错、出现回编码不一致、 - # 并在 space-3074457345817190050 触发“nullmap 位不足”), - # 说明现有字段表里标成 enum<...> 的字段还有别的东西没建模,宽度不能单独改。 + # 枚举宽度在数据上自相矛盾,暂按 i32(最优口径): + # · 源码:EnumCodecInfo(vn → Kt)读写都是空实现 → 0 字节; + # · 实测 A:EmissionColorCC 的 4×i32 正好到帧尾,threshold 这个 enum 必须 0 字节; + # · 实测 B:Item3DCC 之外有 21 个模型在 enum=0 时整体错位(P_ 的 category 必须 4 字节)。 + # 全量审计:enum=4 → 6 处未命中 / 127 项;enum=0 → 21 处。先保留 4,等客户端位痕迹定案。 return "R.ReadInt()" if Kind == "model": return Node[1] + "Codec.Decode(R, N)"