工具链(3DTank/tools/):extract(模块提取)、mine_evidence/apply_names(命名证据与别名回填)、extract_codec_fields(字段表 + 全库槽位类型 + PrimClassWire/ResourceClasses)、mine_downlink(下行字段表)、extract_space_init/rebase_space_init/audit_space_init(空间模板与记录切分审计)、extract_space_session(把真实会话切成按上行触发的回放模板)、pair_flow(请求→应答配对)、probe_containers、ingest/decrypt_frames(抓包入库与解密)、serve/fetch_assets(本地站点镜像)、import_db(抓包对象仓库导入 MySQL)。 服务器(3DTank/tools/server/):link/entry/codec/container/dispatch/world/space/semantic(协议层与 lt/pt/zt 三个容器布局)、session(会话回放,顺序推进)、db + schema.sql(MySQL 连接层与对象仓库表结构)、store/responder(查库组帧与定向应答;接管范围默认只开 ba.lcr)。 同时修 pair_flow 的时间轴偏移:timeline_c2s 与 timeline_s2c 的 0 点相差 144~4453ms,改前配对整体偏约 2 秒(实测 space-100073070 偏 2084ms),改后 22.4% 的触发者被纠正。 src/、src_named/、packets/、3DTank/local/(含数据库口令)不进库,可用上述工具重新生成。
348 lines
18 KiB
JavaScript
348 lines
18 KiB
JavaScript
// 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 || {};
|
||
const EnumBytes = Opts.EnumBytes === undefined ? 4 : Opts.EnumBytes;
|
||
// 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;
|
||
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]));
|
||
for (let I = 0; I < N.Value; I++) {
|
||
if (I >= 4096) {
|
||
Arr.push('<元素过多>');
|
||
Bad = true;
|
||
break;
|
||
}
|
||
const R = ReadValue(Buf, At, List[1], TypeCodec, Depth + 1);
|
||
Arr.push(R.Value);
|
||
if (R.Bad || (R.Next <= At && !ZeroOk)) {
|
||
Arr.push('<卡住>');
|
||
Bad = true;
|
||
break;
|
||
}
|
||
At = R.Next;
|
||
}
|
||
return { Value: Arr, Next: At, Bad };
|
||
}
|
||
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]));
|
||
const ZeroV = OptBits && /^opt</.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 };
|
||
}
|
||
|
||
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 '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 R = ReadValue(Buf, At, F.Type, F.TypeCodec, Depth);
|
||
// 匿名字段(表里没有名字,只有线宽)用序号区分,否则会同名互相覆盖
|
||
const Key = F.Name === '(匿名)' || F.Name === '?' ? '(匿名#' + F.Order + ')' : F.Name;
|
||
Obj[Key] = R.Value;
|
||
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 };
|