fix(tools): 审计改用跨记录位游标,修正多记录帧误判

This commit is contained in:
TermiNexus committed 2026-10-21 03:28:52 +08:00
1 parent 8444b961c0
commit ce8eeec8d6
5 files changed
+67 -29

No files matched your search

+25 -13
View File
@@ -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);
}
}
}
+31 -8
View File
@@ -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<T>> 里每个元素占多少字节。实测「独立 enum 字段」是 0 字节,
// 但列表里的枚举元素看起来仍占 4 字节(否则 P_/Tu 这类带 list<enum> 的模型会整体错位)。
// 默认与 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<T> 且为空,值一个字节都不占
// (只有 nullmap 里的 1 个 bit,见 src/module_24635.js:412-417 的 Ht.p7g:读到位=1 时直接 return null),
// 于是 Next == At 是**合法**的。旧口径把它当「卡住」→ list<opt<T>> 里只要有空元素就整段解不动
// (实测 space-100073070/s2c_0001 的 MountedResistancesCC.resistances 就卡在这里)。
const ZeroOk = OptBits && /^opt</.test(String(List[1]));
// 「元素读不动」的判据在 OptBits 模式下要放宽:
// · 元素是 opt<T> 且为空 → 0 字节(只有 nullmap 里的 1 个 bit);
// · 元素是 enum<T> → 恒 0 字节(EnumCodecInfo 不上线)。
const ElemT = String(List[1]);
const ZeroOk = (OptBits && /^opt</.test(ElemT)) || (/^enum</.test(ElemT) && EnumInList === 0);
// 列表里的枚举元素按 EnumInList 宽度跳过(独立 enum 字段走 EnumBytes 分支)
const ElemEnum = /^enum</.test(ElemT) ? EnumInList : -1;
for (let I = 0; I < N.Value; I++) {
if (I >= 4096) {
Arr.push('<元素过多>');
Bad = true;
break;
}
if (ElemEnum > 0) {
if (At + ElemEnum > Buf.length) {
Arr.push('<越界>');
Bad = true;
break;
}
Arr.push('<enum>');
At += ElemEnum;
continue;
}
if (ElemEnum === 0) {
Arr.push('<enum 不上线>');
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</.test(String(KV[0]));
const ZeroV = OptBits && /^opt</.test(String(KV[1]));
const ZeroK = (OptBits && /^opt</.test(String(KV[0]))) || /^enum</.test(String(KV[0]));
const ZeroV = (OptBits && /^opt</.test(String(KV[1]))) || /^enum</.test(String(KV[1]));
for (let I = 0; I < N.Value; I++) {
if (I >= 4096) {
Pairs.push(['<条目过多>', '']);
+3 -1
View File
@@ -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;
@@ -4,9 +4,9 @@
// · 必填字段:直接读写
// · opt<T> :先从 nullmap 取 1 位(1=null,0=有值),有值才读写 T
// · list<T>/map<K,V>:varint 个数 + 逐项
// · enum<T>:按 i32 处理。源码上 EnumCodecInfo(module_24635 的 vn → Kt)读写都是空实现,
// 但整套改成 0 字节会让回放退化(railgun-battle 多 8 条错 + 回编码不一致 + nullmap 位不足),
// 说明表里标成 enum<...> 的字段还有别的东西没建模,宽度不能单独改
// · enum<T>:按 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
+5 -4
View File
@@ -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)"