Files
TankIC/3DTank/tools/lib_space_codec.js
TermiNexus b083d27165 fix(protocol): TankState 按 23 字节定长 blob(T5)解码,修复 TankCC 后续字节错位
- codec_fields:module_87072::T5 记为单字段 bytes23;pq.tankState 的 TypeCodec 由 85183::jq 改指 87072::T5
- 生成器支持 bytes<N>(ByteArray / R.ReadRaw(N) / W.WriteRaw),重生成 Module87072、Module85183
- 审计链:lib_space_codec 新增 bytes<N>;audit_pt_boundaries / audit_bit_budget 支持 --body/--plain;semantic 暴露 Out.Bits
- 新增工具:audit_bit_budget(nullmap 位预算)、probe_pt_record(pt 项级)、analyze_item_trace(客户端项级打点对照)、dump_codec_src
- wss_server 新增 --dump-plain:把解密+解压后的裸帧体落到 plain/,供离线审计
- 镜像打点:opt 全量位位置、pt 项级字节/位区间、TankCC 逐字段区间;重新打包 unpacked.pack.json
2026-10-21 15:21:10 +08:00

409 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib_space_codec.js - 空间层负载的字段级解码器(下行 s2c / 上行 c2s 共用)
//
// 编码规则来自客户端源码(module_24635 的序列化器实现,推导过程见 tools/extract_codec_fields.js):
// bool/i8=1B i16=2B i32/f32=4B i64/f64=8B(全部大端)
// string/bytes = varint 长度 + 原始字节
// list<T> = varint 元素数 + 元素…
// map<K, V> = varint 条数 + 条数 × (K, V)
// —— 出处 src/module_24635.js:489-512 的 Jt.o7g/p7g:先 w.s7g/w.t7g 写/读条数,
// 再对每个条目 this.w7g_1(key codec)/ this.x7g_1(value codec)各写/读一次;
// w.s7g/w.t7g 就是 list/string 用的同一个变长长度编解码(src/module_24635.js:589-611)。
// 描述符是 src/module_24635.js:262-264 的 pn = MapCodecInfo(key, value, optional)。
// 值是 [[key, value], …](不是对象):map 的键可能是任意类型/可能重复,用对象会丢信息。
// opt<T> = 1 字节标志(非 0 = 空)+ 有值时紧跟 T
// —— **这只是字段表那一层的近似**。真实线形是 1 个 bit(见下 OptBits),
// 两者只有在「调用方没法拿到 nullmap 比特流」时才等价(表驱动的那条路径就是这么用的)。
// opt<T>(OptBits 模式)= 从 nullmap 比特流读 1 个 bit:1 = 空(负载里什么都没有),0 = 有值、紧跟 T。
// 出处 src/module_24635.js:412-417:Ht.o7g 写 `t.b7g_1.x7f(!0/!1)`(位图写 1 bit),
// Ht.p7g 读 `t.b7g_1.xy() ? null : this.n7g_1.p7g(t)`(读 1 bit,为空则不读值)。
// b7g_1 就是 nullmap 的位读取器(src/module_24635.js:137-139 的上下文 a7g_1/b7g_1,
// src/module_24635.js:809-845 的 r7f 把 nullmap 灌进 b7g_1)。
// 关键:这个位图是「记录形态位 + 模型内 opt 位」共用的同一条比特流,所以按字节猜
// 「opt 有没有标志字节」在负载里出现 opt 字段时必然错位(容器负载正是这种情况)。
// enum<T> = 4 字节(库里的 EnumCodecInfo 序列化器 o7g 是空实现,但游戏自己注册的枚举写 4 字节;
// 实测 ChatCC 按 4 字节解才能精确吃满 1476B 负载,按 0 字节只有 40% 的容器内层能对上边界)
// 模型类 = 就地展开该模型 codec 的字段(无长度前缀)
// varint: <128 → 1 字节;<16384 → 2 字节(0x80|高6位, 低8位);<4194304 → 3 字节(0xC0|高6位, 中8位, 低8位)
//
// 用法:
// const { LoadTables, MakeReader } = require('./lib_space_codec');
// const Tables = LoadTables(path.join(__dirname, '..', 'names'));
// const Reader = MakeReader(Tables, { EnumBytes: 4 });
// const R = Reader.ReadFields(Buf, Offset, Tables.CodecByKey.get(CodecKey), 0);
// -> { Value: {字段名: 值}, Next: 下一个偏移, Bad: 是否中途卡住 }
const fs = require('fs');
const path = require('path');
const MAX_DEPTH = 8;
// 载入 names/ 下的三张表
// codec_fields.json: 下行/上行 codec 的字段表(含 ModelId / MethodId / TypeCodec)
// uplink_table.json: 上行命令表(methodId → 命令类 / 发送方法 / 负载字段)
function LoadTables(NamesDir) {
const Out = {
CodecByKey: new Map(),
CodecByMethod: new Map(),
CodecByModelId: new Map(),
CodecByModelName: new Map(),
WireClasses: new Map(),
ResourceClasses: {},
UplinkByMethod: new Map(),
};
try {
const CF = JSON.parse(fs.readFileSync(path.join(NamesDir, 'codec_fields.json'), 'utf8'));
for (const [Key, C] of Object.entries(CF.Codecs)) {
Out.CodecByKey.set(Key, C);
for (const M of C.MethodId || []) if (!Out.CodecByMethod.has(M)) Out.CodecByMethod.set(M, C);
if (C.ModelId && !Out.CodecByModelId.has(C.ModelId)) Out.CodecByModelId.set(C.ModelId, C);
// ModelIds:同一个 codec 类被多个 modelId 注册时(regdump 的 w7n_1 里 ctor+槽位指纹相同的多条),
// 这里把「同一个 codec 键下的全部 modelId」都登记上。只补空缺、不覆盖已绑定的,
// 这样对既有行为是纯增量(老绑定怎么解还怎么解)。
for (const M of C.ModelIds || []) if (!Out.CodecByModelId.has(M)) Out.CodecByModelId.set(M, C);
// 模型真名 → codec:字段里只写了类名、没带 TypeCodec 链接时的兜底(如 ji = module_24635::Xt 单 i64)
if (C.ModelName && !Out.CodecByModelName.has(C.ModelName)) Out.CodecByModelName.set(C.ModelName, C);
}
// 协议核心类(module_61860 注册的「一写一读」codec)→ 线上基础类型,如实体引用 xr → i64
for (const [Name, Type] of Object.entries(CF.WireClasses || {})) Out.WireClasses.set(Name, Type);
// 资源类(源码 module_71105 的 u83 注册块导出)→ 线上 i64:容器层拿它当「裸类名直译表」的一部分
Out.ResourceClasses = CF.ResourceClasses || {};
} catch (Err) {
Out.CodecError = Err.message;
}
try {
const UP = JSON.parse(fs.readFileSync(path.join(NamesDir, 'uplink_table.json'), 'utf8'));
for (const C of UP.Commands || []) if (!Out.UplinkByMethod.has(C.MethodId)) Out.UplinkByMethod.set(C.MethodId, C);
} catch (Err) {
Out.UplinkError = Err.message;
}
return Out;
}
function ReadVarint(Buf, Off) {
if (Off >= Buf.length) return { Value: 0, Next: Off, Bad: true };
const B0 = Buf[Off];
if (!(B0 & 0x80)) return { Value: B0, Next: Off + 1 };
const B1 = Buf[Off + 1] || 0;
if (B0 & 0x40) return { Value: ((B0 & 0x3f) << 16) + (B1 << 8) + (Buf[Off + 2] || 0), Next: Off + 3 };
return { Value: ((B0 & 0x3f) << 8) + B1, Next: Off + 2 };
}
// 把复合类型的内层按「顶层逗号」切开:map<enum<X>, i32> → ['enum<X>', 'i32']。
// 只按 <> 深度判断(类型串里只有尖括号会嵌套),不做任何猜测。
function SplitTypeArgs(Text) {
const Out = [];
let Depth = 0;
let Start = 0;
for (let I = 0; I < Text.length; I++) {
const C = Text[I];
if (C === '<') Depth++;
else if (C === '>') Depth--;
else if (C === ',' && Depth === 0) {
Out.push(Text.slice(Start, I).trim());
Start = I + 1;
}
}
Out.push(Text.slice(Start).trim());
return Out;
}
function MakeReader(Tables, Options) {
const Opts = Options || {};
// 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;
// OptBits:opt 的「空/非空」改从 nullmap 比特流读 1 个 bit(见文件头注释)。
// 默认 false = 老口径(1 字节标志),也就是「字段表」那条路径的既有行为,不动它。
const OptBits = !!Opts.OptBits;
// TypeAlias:codec_fields.json 里推不出嵌套 codec 的裸类名 → 线上基础类型(容器层才给)。
// 只做「查表直译」,不做任何猜测:没命中就照旧报「无 codec」。
const TypeAlias = Opts.TypeAlias || null;
// StringLiteralOnly:string 一律按「字面 UTF-8」解,**不再退回 '0x…' 十六进制转义**(容器层才开)。
// 为什么要这个开关:'0x…' 转义与「本身就是 "0xff8000" 这样的字面量」在写侧撞车——
// 实测 space-920007382011/s2c_0001.bin 的 borderColorForEventButton 就是字面量 "0xff8000",
// 走转义约定时写回会变成 3 字节裸字节(少 5 字节)。容器路径没有历史包袱,所以这里取
// 「要么字面量、要么如实报错」,不做有歧义的转义(字段表那条路径保持原样,不动它的口径)。
const StringLiteralOnly = !!Opts.StringLiteralOnly;
const CodecByKey = Tables.CodecByKey;
const CodecByModelName = Tables.CodecByModelName || new Map();
const WireClasses = Tables.WireClasses || new Map();
// nullmap 比特流游标(OptBits 用)。由调用方(container.js)AttachBits 进来:
// AttachBits(Bits, Index) → 从 Bits[Index] 开始消费;DetachBits() → 断开(opt 再遇到就报错)
let BitStream = null;
let BitIndex = 0;
function AttachBits(Bits, Index) {
BitStream = Bits;
BitIndex = Index || 0;
}
function DetachBits() {
BitStream = null;
BitIndex = 0;
}
function BitCursor() {
return { Bits: BitStream, Index: BitIndex };
}
function TakeBit() {
if (!BitStream || BitIndex >= BitStream.length) return null;
const B = BitStream[BitIndex];
BitIndex++;
return B;
}
// 读一个值:返回 { Value, Next, Bad }
function ReadValue(Buf, Off, Type, TypeCodec, Depth) {
if (Depth > MAX_DEPTH) return { Value: '<深度上限>', Next: Off, Bad: true };
if (Type === undefined || Type === null || Type === '' || Type === '?') return { Value: '<未知类型>', Next: Off, Bad: true };
const T = String(Type);
const Opt = /^opt<([\s\S]+)>$/.exec(T);
if (Opt) {
if (OptBits) {
const Bit = TakeBit();
if (Bit === null) return { Value: '<nullmap 位用尽>', Next: Off, Bad: true };
if (Bit) return { Value: null, Next: Off };
return ReadValue(Buf, Off, Opt[1], TypeCodec, Depth);
}
if (Off >= Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
// Ht 包装:先 1 字节标志,非 0 表示 null;有值则紧跟内层
if (Buf[Off] !== 0) return { Value: null, Next: Off + 1 };
return ReadValue(Buf, Off + 1, Opt[1], TypeCodec, Depth);
}
const List = /^list<([\s\S]+)>$/.exec(T);
if (List) {
const N = ReadVarint(Buf, Off);
let At = N.Next;
let Bad = N.Bad;
let Why = '';
const Arr = [];
// 「元素读不动」的判据在 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);
// 元素不占字节也可能是合法的: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, Why };
}
const Enum = /^enum<([\s\S]+)>$/.exec(T);
if (Enum) {
if (EnumBytes === 0) return { Value: '<enum 不上线>', Next: Off };
if (Off + EnumBytes > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
let V = 0;
for (let I = 0; I < EnumBytes; I++) V = (V << 8) | Buf[Off + I];
return { Value: 'enum#' + V, Next: Off + EnumBytes };
}
const Map = /^map<([\s\S]+)>$/.exec(T);
if (Map) {
// 条数用的是与 list/string 同一个变长整数(src/module_24635.js:589-611),
// 之后逐条 key、value(src/module_24635.js:498-511)。
const KV = SplitTypeArgs(Map[1]);
const N = ReadVarint(Buf, Off);
let At = N.Next;
let Bad = N.Bad;
const Pairs = [];
// 与 list 同源:OptBits 模式下 opt<…> 为空时 0 字节是合法的(见上面 list 分支的注释)
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(['<条目过多>', '']);
Bad = true;
break;
}
const RK = ReadValue(Buf, At, KV[0], TypeCodec, Depth + 1);
if (RK.Bad || (RK.Next <= At && !ZeroK)) {
Pairs.push([RK.Value, '<键读不动>']);
Bad = true;
break;
}
const RV = ReadValue(Buf, RK.Next, KV[1], TypeCodec, Depth + 1);
Pairs.push([RK.Value, RV.Value]);
if (RV.Bad || (RV.Next <= RK.Next && !ZeroV)) {
Bad = true;
break;
}
At = RV.Next;
}
return { Value: Pairs, Next: At, Bad };
}
// 定长二进制块:Type = bytes<N>
// 出处:module_87072.js:53876 `(Zy(T5).t7h = function (t) { this.j101_1.v100(t.g7h_1, 23); … })`
// —— TankState 的线上形态是一个 23 字节的打包姿态 blob(内部按 19/13/13/13 位量化 4 个向量)。
// 我们不解释内容,只按定长吃掉,保证后续字节对得上。
const Fixed = /^bytes(\d+)$/.exec(T);
if (Fixed) {
const N = Number(Fixed[1]);
if (Off + N > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: '0x' + Buf.slice(Off, Off + N).toString('hex'), Next: Off + N };
}
switch (T) {
case 'bool':
if (Off >= Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf[Off] !== 0, Next: Off + 1 };
case 'i8':
if (Off + 1 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readInt8(Off), Next: Off + 1 };
case 'i16':
if (Off + 2 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readInt16BE(Off), Next: Off + 2 };
case 'i32':
if (Off + 4 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readInt32BE(Off), Next: Off + 4 };
case 'i64':
if (Off + 8 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readBigInt64BE(Off).toString(), Next: Off + 8 };
case 'f32':
if (Off + 4 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
// 不做 toFixed 截断:截断只属于显示层(见 FormatValue),否则写回时精度就变了
return { Value: Buf.readFloatBE(Off), Next: Off + 4 };
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': {
const N = ReadVarint(Buf, Off);
const End = N.Next + N.Value;
if (End > Buf.length) return { Value: '<越界>', Next: Off, Bad: true, Why: '长度 ' + N.Value + ' 越界' };
const Raw = Buf.slice(N.Next, End);
if (T !== 'string') return { Value: '0x' + Raw.toString('hex'), Next: End };
const Str = Raw.toString('utf8');
// 二进制负载(字段类型被标成 string 但内容不是合法 UTF-8)→ 退回十六进制。
// 否则替换字符 U+FFFD 会吃掉原始字节,写回时无法还原(MakeWriter 往返自检会抓到)。
const Round = Buffer.from(Str, 'utf8');
if (Round.equals(Raw)) return { Value: Str, Next: End };
if (StringLiteralOnly) return { Value: '<不是合法 UTF-8>', Next: Off, Bad: true, Why: '字符串值不是合法 UTF-8(本路径不做 0x 转义,避免与字面量 0x… 撞车)' };
return { Value: '0x' + Raw.toString('hex'), Next: End };
}
default:
break;
}
// 模型类:就地展开它自己的 codec 字段(无长度前缀)
if (TypeCodec && CodecByKey.has(TypeCodec)) {
const R = ReadFields(Buf, Off, CodecByKey.get(TypeCodec), Depth + 1);
return { Value: R.Value, Next: R.Next, Bad: R.Bad, Why: R.Why };
}
// 字段里没带 codec 链接时:先按模型真名找 codec,再按「协议核心类 → 线上基础类型」表换算
if (CodecByModelName.has(T)) {
const R = ReadFields(Buf, Off, CodecByModelName.get(T), Depth + 1);
return { Value: R.Value, Next: R.Next, Bad: R.Bad, Why: R.Why };
}
if (WireClasses.has(T)) return ReadValue(Buf, Off, WireClasses.get(T), '', Depth + 1);
// 容器层给的「裸类名 → 线上基础类型」直译表(只查表,不做推断;没命中就照旧报无 codec)
if (TypeAlias && Object.prototype.hasOwnProperty.call(TypeAlias, T)) return ReadValue(Buf, Off, TypeAlias[T], '', Depth + 1);
return { Value: '<无 codec:' + T + '>', Next: Off, Bad: true, Why: '类型 ' + T + ' 没有对应 codec' };
}
function ReadFields(Buf, Off, Codec, Depth) {
const Obj = {};
let At = Off;
let Bad = false;
let Why = '';
for (const F of (Codec && Codec.Fields) || []) {
const BitBefore = BitIndex;
const R = ReadValue(Buf, At, F.Type, F.TypeCodec, Depth);
// 匿名字段(表里没有名字,只有线宽)用序号区分,否则会同名互相覆盖
const Key = F.Name === '(匿名)' || F.Name === '?' ? '(匿名#' + F.Order + ')' : F.Name;
Obj[Key] = R.Value;
if (OnField) {
try {
OnField({
Depth: Depth || 0, Name: Key, Type: F.Type, Start: At, Next: R.Next,
BitBefore, BitAfter: BitIndex, Bad: !!R.Bad, Why: R.Why || '',
});
} catch (E) {
// 诊断回调不允许影响解码
}
}
if (R.Bad) {
Bad = true;
// 诊断用的原因串:哪个字段、什么类型、卡在负载内第几字节、内层如果直接说得出原因也带上
// (container / probe_containers 靠它把「卡点」分类说清楚,不参与解码判定)
Why = '字段 ' + Key + '(' + F.Type + ')在 +' + (At - Off) + ' 处解不动' + (R.Why ? ':' + R.Why : '');
break;
}
At = R.Next;
}
return { Value: Obj, Next: At, Bad, Why };
}
// 按 codec 键解一条记录
function ReadByKey(Buf, Off, CodecKey, Depth) {
const C = CodecByKey.get(CodecKey);
if (!C) return null;
return ReadFields(Buf, Off, C, Depth || 0);
}
return { ReadValue, ReadFields, ReadByKey, ReadVarint, EnumBytes, OptBits, AttachBits, DetachBits, BitCursor };
}
function FormatValue(V) {
if (V === null) return 'null';
if (Array.isArray(V)) return '[' + V.length + ']' + (V.length <= 6 ? JSON.stringify(V, null, 0) : '');
if (typeof V === 'object') return JSON.stringify(V, null, 0);
// 浮点只在显示层截断(读侧保留完整精度,否则写回时会丢字节)
if (typeof V === 'number' && !Number.isInteger(V)) return String(Number(V.toFixed(4)));
if (typeof V === 'string' && V.length > 90) return JSON.stringify(V.slice(0, 90) + '…');
return JSON.stringify(V);
}
// 把解出的对象渲染成多行(字段名左对齐 28 列)
function FormatFields(Obj, Indent) {
const L = [];
for (const [K, V] of Object.entries(Obj || {})) L.push(Indent + String(K).padEnd(28) + ' = ' + FormatValue(V));
return L;
}
module.exports = { LoadTables, MakeReader, ReadVarint, SplitTypeArgs, FormatValue, FormatFields, MAX_DEPTH };