// 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 = varint 元素数 + 元素… // map = 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 = 1 字节标志(非 0 = 空)+ 有值时紧跟 T // —— **这只是字段表那一层的近似**。真实线形是 1 个 bit(见下 OptBits), // 两者只有在「调用方没法拿到 nullmap 比特流」时才等价(表驱动的那条路径就是这么用的)。 // opt(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 = 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, i32> → ['enum', '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 字段」是 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; // 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: '', 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 且为空 → 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); // 元素不占字节也可能是合法的: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, Why }; } const Enum = /^enum<([\s\S]+)>$/.exec(T); if (Enum) { if (EnumBytes === 0) return { Value: '', 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= 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 // 出处: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 };