Files
TankIC/3DTank/tools/decode_space_frames.js
T
TermiNexus 2e5e8bd7b0 feat(3dtank): 协议工具链入库:上行命令接入 fake server + 字段表/WireClasses
- wss_server:识别 c2s 空间命令([nullmap][objectId i64][methodId i64][负载])并逐字段解码
- 应答签名改用 S:<methodId>(pair_flow.js 同步),fake 模式能按具体上行命令触发对应下行回放
- extract_codec_fields:新增「协议核心类 → 线上基础类型」挖掘(WireClasses,xr/R → i64)
- lib_space_codec:按模型真名/核心类换算兜底(ji → i64、xr → i64),抽成上下行共享库
- decode_space_frames:记录容器(_t/lt)已核实结构与未闭合环节
- README:补「协议逆向与离线模拟」章节(帧格式/字段编码/命令表/fake 回放/容器现状)

实测:297 条上行记录 100% 完整解出;fake 模式按 G.q1wd、ls.xbl 等命令精确触发应答
2026-09-16 16:30:38 +08:00

446 lines
19 KiB
JavaScript
Raw 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.
// decode_space_frames.js - 下行(s2c)空间帧解码器:把"记录流"逐条标出模型名/字段/负载
// 用法:
// node decode_space_frames.js [--dir ../packets/plain/space-100072911] [--limit 30] [--all] [--strings]
// node decode_space_frames.js --dir ../packets/plain/space-100045720 --fields ← 用字段表逐字段真解
// 结构(本轮逆向确认):
// 帧 = [2/4 字节头(bit6=zlib压缩)][帧体]
// 帧体 = [nullmap][spaceId int64 8B][记录…]
// 记录 = [methodId int64 8B][负载] ← methodId 查 names/method_table.json
// 字段级编码(来自 module_24635 的序列化器实现,见 tools/extract_codec_fields.js 的注释):
// bool/i8=1B i16=2B i32/f32=4B i64/f64=8B(全部大端)
// string/bytes = varint 长度 + 原始字节
// list<T> = varint 元素数 + 元素…
// opt<T> = 1 字节标志(非 0 = 空)+ 有值时紧跟 T
// enum<T> = 0 字节(EnumCodecInfo 的序列化器 o7g 是空实现)
// 模型类 = 就地展开该模型 codec 的字段(无长度前缀)
// varint: <128 → 1 字节;<16384 → 2 字节(0x80|高6位, 低8位);<4194304 → 3 字节(0xC0|高6位, 中8位, 低8位)
// nullmap: 见 NullmapSize()(1..4 字节分档,或 0x80/0xC0 前缀的变长形式)——spaceId 紧跟其后
//
// --fields 实测(space-100045720):
// ✓ ClanChatCC 吃=9B/9B { inClan:false, selfName:"WYT2WYT" } 与原始字节 00 07 "WYT2WYT" 完全一致
// ✓ ChatCC channels=[{name:"默认", sid:"默认"}] list<ChannelSidAndName> 展开正确
// ✓ NewsShowingCC newsItems=[{description:"现在购买…", header:"…", imageUrl:"…"}] 中文字段全部可读
// 仍有的偏差:个别大模型(如 ChatCC)中段字段会漂移(某个字段的类型/顺序还没对上),
// 容器(methodId 的 codec 是 ArrayCodecInfo,如模型 _t)内部头部几十字节是序号/空间号等,
// 内层元素靠 modelId 窗口扫出,约 40% 能与容器边界吻合。
//
// 容器(无 codec 的记录)已核实的部分(2026-09-16,源码 module_45686 + 抓包字节双向验证):
// · 容器的模型恒为 _t(module_45686:94),其 codec 是 lt(module_45686:483-494),
// 只有 1 个「字段」= idCodec(i64):
// id = 读 i64;id == 0 → 值是紧跟其后的那个 i64;否则按 modelRegister 里该 id 对应的 codec 就地展开
// (modelRegister = module_45686 的 at.k1we(),k7n(id) 取 codec,与 module_61860 的 or.p7g 同源:
// 读不到 codec 会抛 CodecNotFoundForCommandException)
// · 实测容器负载(space-100045720/s2c_0000.bin):
// [i64 version=1][i64 idA=100045311][i32 N=5][N × i64 modelId][i32 0][i64 idB=100045720]
// [(i64 methodId)(负载)…] ← 内层与外层帧体同形,且可无限嵌套(内层又是 _t 容器)
// · 仍缺的一环:负载首字节并不是 modelRegister 的 id(实测是 i64 1),说明 methodId 与模型负载之间
// 还有一小段头部(可能是命令 codec 自己写的 tag/版本),因此容器内层目前仍靠 modelId 窗口扫,
// 不敢按上面的形状硬解。要闭合需要拿到容器命令自身的 codec(不是 _t 的模型 codec)。
// 容器字节归属统计里的「精确覆盖」= 能自洽解到下一个 modelId 候选的字节占比,仅作参考。
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const { LoadTables, MakeReader, FormatValue, FormatFields } = require('./lib_space_codec');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const PROJECT = path.resolve(__dirname, '..');
const Table = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'method_table.json'), 'utf8'));
const Limit = Number(Arg('limit', '30')) || 30;
const ShowStrings = Argv.indexOf('--strings') >= 0;
const ShowFields = Argv.indexOf('--fields') >= 0;
const EnumBytes = Number(Arg('enum-bytes', '4'));
const All = Argv.indexOf('--all') >= 0;
// methodId(hex 8B) → 模型信息
const ByHex = new Map();
for (const R of Table.Rows) {
const Buf = Buffer.alloc(8);
Buf.writeBigInt64BE(BigInt(R.MethodId));
ByHex.set(Buf.toString('hex'), R);
}
// modelId(hex 8B) → 模型信息(内层记录用;来源 names/regdump.json 的 u7n_1/w7n_1)
const ByModelHex = new Map();
const ModelTablePath = path.join(PROJECT, 'names', 'method_table.json');
try {
const Reg = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'regdump.json'), 'utf8'));
const U = new Map();
for (const E of Reg.maps['ModelReg.u7n_1'].entries) U.set(E.key, E);
const W = new Map();
for (const E of Reg.maps['ModelReg.w7n_1'].entries) W.set(E.key, E);
const Rows = [];
for (const E of Reg.maps['ModelReg.u7n_1'].entries) {
const Ww = W.get(E.key) || {};
const Buf = Buffer.alloc(8);
Buf.writeBigInt64BE(BigInt(E.key));
const Rec = { ModelId: E.key, Model: E.ctor, ModelFields: E.keys, Codec: Ww.ctor || '', CodecFields: Ww.keys || [] };
ByModelHex.set(Buf.toString('hex'), Rec);
Rows.push(Rec);
}
fs.writeFileSync(path.join(PROJECT, 'names', 'model_table.json'), JSON.stringify({ Note: 'modelId → 模型类/codec(内层记录用)', Count: Rows.length, Rows }, null, 1), 'utf8');
} catch (Err) {
console.log('(未加载 regdump.json,内层记录无法展开:' + Err.message + ')');
}
// ---------------------------------------------------------------- 字段级解码(共享库 tools/lib_space_codec.js)
// names/codec_fields.json:CodecKey → { Fields:[{Name,Type,TypeCodec}], MethodId:[…] }
// names/uplink_table.json:上行 methodId → 命令类 / 发送方法 / 负载字段
const Tables = LoadTables(path.join(PROJECT, 'names'));
if (Tables.CodecError) console.log('(未加载 codec_fields.json,--fields 不可用:' + Tables.CodecError + ')');
if (Tables.UplinkError) console.log('(未加载 uplink_table.json,上行命令名不可用:' + Tables.UplinkError + ')');
const CodecByMethod = Tables.CodecByMethod;
const CodecByKey = Tables.CodecByKey;
const CodecByModelId = Tables.CodecByModelId;
const Reader = MakeReader(Tables, { EnumBytes });
const ReadVarint = Reader.ReadVarint;
const ReadFields = Reader.ReadFields;
// ---------------------------------------------------------------- OptionalMap(nullmap)长度
// 来自 module_24635 的 _n.j7h:
// b0 & 0x80 → 长度前缀形式:b0 & 0x40 时再吃 2 字节组成长度,否则长度 = b0 & 0x3f
// 否则 → 分档:档位 = (b0 & 0x60) >> 5(0..3),总长 = 1 + 档位
function NullmapSize(Buf, Off) {
const B0 = Buf[Off];
if (B0 & 0x80) {
const E = B0 & 0x3f;
if (B0 & 0x40) return 1 + 2 + ((E << 16) + ((Buf[Off + 1] || 0) << 8) + (Buf[Off + 2] || 0));
return 1 + E;
}
return 1 + ((B0 & 0x60) >> 5);
}
// 从 From 起找下一个「8 字节窗口命中方法表」的位置(用于 codec 未知时的兜底跳转)
function NextMethodHit(Buf, From) {
for (let Off = From; Off + 8 <= Buf.length; Off++) if (ByHex.has(Buf.slice(Off, Off + 8).toString('hex'))) return Off;
return Buf.length;
}
// 顺序走记录(记录起点紧跟上一条的负载结束处),返回 { Records, End, Ok }
function WalkRecords(Buf, Start) {
const Out = [];
let At = Start;
let Ok = true;
while (At + 8 <= Buf.length) {
const IdHex = Buf.slice(At, At + 8).toString('hex');
const Info = ByHex.get(IdHex);
if (!Info) {
Out.push({ Off: At, Error: '未知 methodId 0x' + IdHex });
Ok = false;
break;
}
const C = CodecByMethod.get(String(Info.MethodId));
if (!C) {
// 没有对应 codec:负载当容器处理。容器内的元素以 modelId 开头,已解出的就用字段表展开,
// 没解出的按「下一个命中方法表的窗口」兜底跳过去,保证还能继续往下走。
const Next = NextMethodHit(Buf, At + 8 + 1);
Out.push({
Off: At,
Model: Info.Model,
Codec: Info.Codec,
Container: true,
Len: Next - At - 8,
Inner: InnerScan(Buf, At + 8, Next),
});
At = Next;
continue;
}
const R = ReadFields(Buf, At + 8, C, 0);
Out.push({
Off: At,
Model: Info.Model,
Codec: Info.Codec,
CodecKey: C.Module + '::' + C.Codec,
ModelName: C.ModelName || '',
Len: R.Next - At - 8,
Decoded: R.Value,
Bad: R.Bad,
});
if (R.Bad || R.Next <= At + 8) {
// 解到一半就卡住:同样用兜底跳转继续走,避免整帧报废
const Next = NextMethodHit(Buf, At + 8 + 1);
if (Next >= Buf.length || Next <= At) {
Ok = false;
break;
}
At = Next;
continue;
}
At = R.Next;
}
return { Records: Out, End: At, Ok: Ok && At === Buf.length };
}
// 在容器负载里扫 [modelId 8B] 候选,并用「解出来正好落到下一个候选处」过滤假命中
// 容器负载实测结构: [头部 N 字节(序号/版本/spaceId/实体 id 等)] [ (modelId 8B)(负载) … ]
// 真记录的字段一定解到下一个 modelId 开头,假命中(modelId 字节嵌在别的数据里)解不到。
function InnerScan(Buf, From, To) {
const Hits = [];
for (let Off = From; Off + 8 <= To; Off++) {
const C = CodecByModelId.get(Buf.readBigInt64BE(Off).toString());
if (C) Hits.push({ Off, C });
}
const Out = [];
let ExactCover = 0;
let LastEnd = From;
for (let I = 0; I < Hits.length; I++) {
const H = Hits[I];
const Next = I + 1 < Hits.length ? Hits[I + 1].Off : To;
const R = ReadFields(Buf, H.Off + 8, H.C, 0);
const Consumed = R.Next - H.Off - 8;
const Exact = R.Next === Next && Consumed > 0;
if (!Exact && (R.Bad || Consumed <= 0)) continue; // 假命中
if (Exact) {
ExactCover += Next - H.Off;
LastEnd = Next;
}
Out.push({
Off: H.Off,
ModelId: H.C.ModelId,
ModelName: H.C.ModelName || '',
CodecKey: H.C.Module + '::' + H.C.Codec,
Len: Next - H.Off - 8,
Consumed,
Exact,
Decoded: R.Value,
});
}
return { Records: Out, Span: To - From, Header: (Out.length ? Out[0].Off : To) - From, ExactCover, Tail: To - LastEnd };
}
function BodyOf(Raw) {
const B0 = Raw[0];
const HeaderSize = B0 & 0x80 ? 4 : 2;
let Body = Raw.slice(HeaderSize);
if (B0 & 0x40) {
try {
Body = zlib.inflateSync(Body);
} catch (Err) {
return null;
}
}
return Body;
}
function ReadStrings(Buf, Max) {
const Out = [];
let S = '';
for (const B of Buf) {
if (B >= 32 && B < 127) S += String.fromCharCode(B);
else {
if (S.length >= 4) Out.push(S);
S = '';
}
if (Out.length >= Max) break;
}
if (S.length >= 4 && Out.length < Max) Out.push(S);
return Out;
}
// 找出所有"记录起点"(8 字节窗口命中方法表),返回 [{Off, Info}]
function FindRecords(Body) {
const Hits = [];
for (let Off = 0; Off + 8 <= Body.length; Off++) {
const Info = ByHex.get(Body.slice(Off, Off + 8).toString('hex'));
if (Info) Hits.push({ Off, Info });
}
return Hits;
}
// 在容器模型(_t/bj/hj…)的负载里找内层记录([modelId 8B][负载] 重复)
function FindInner(Payload) {
if (!ByModelHex.size || Payload.length < 24) return [];
const Hits = [];
for (let Off = 0; Off + 8 <= Payload.length; Off++) {
const Info = ByModelHex.get(Payload.slice(Off, Off + 8).toString('hex'));
if (Info) Hits.push({ Off, Info });
}
if (Hits.length < 3) return [];
return Hits.map((H, I) => {
const End = I + 1 < Hits.length ? Hits[I + 1].Off : Payload.length;
const Sub = Payload.slice(H.Off + 8, End);
return {
Off: H.Off,
Model: H.Info.Model,
Codec: H.Info.Codec,
CodecFields: (H.Info.CodecFields || []).join(','),
PayloadLen: Sub.length,
Head: Sub.slice(0, 20).toString('hex'),
Strings: ShowStrings ? ReadStrings(Sub, 4) : [],
};
});
}
function DecodeFrame(File, Raw) {
const Body = BodyOf(Raw);
if (!Body) return { File, Error: 'zlib 失败' };
const NmSize = NullmapSize(Body, 0);
const Nullmap = Body[0];
const SpaceId = Body.length >= NmSize + 8 ? Body.readBigInt64BE(NmSize).toString() : '?';
if (ShowFields) {
const W = WalkRecords(Body, NmSize + 8);
return {
File,
Raw: Raw.length,
Body: Body.length,
Compressed: !!(Raw[0] & 0x40),
Nullmap,
NullmapSize: NmSize,
SpaceId,
Records: W.Records,
WalkOk: W.Ok,
WalkEnd: W.End,
WalkFrom: NmSize + 8,
};
}
const Hits = FindRecords(Body);
const Records = Hits.map((H, I) => {
const End = I + 1 < Hits.length ? Hits[I + 1].Off : Body.length;
const Payload = Body.slice(H.Off + 8, End);
return {
Off: H.Off,
End,
Model: H.Info.Model,
Codec: H.Info.Codec,
CodecFields: H.Info.CodecFields,
PayloadLen: Payload.length,
Head: Payload.slice(0, 24).toString('hex'),
Strings: ShowStrings ? ReadStrings(Payload, 8) : [],
Inner: FindInner(Payload),
};
});
return { File, Raw: Raw.length, Body: Body.length, Compressed: !!(Raw[0] & 0x40), Nullmap, NullmapSize: NmSize, SpaceId, Records };
}
const DirArg = Arg('dir', '');
const Dirs = [];
if (All || !DirArg) {
const Root = path.join(PROJECT, 'packets', 'plain');
for (const D of fs.readdirSync(Root)) {
if (fs.statSync(path.join(Root, D)).isDirectory()) Dirs.push(path.join(Root, D));
}
} else Dirs.push(path.resolve(DirArg));
const ModelCount = {};
let Frames = 0;
let Records = 0;
let WalkOkFrames = 0;
let WalkBadFrames = 0;
let InnerTotal = 0;
let InnerExact = 0;
let InnerSpan = 0;
let InnerExactCover = 0;
let InnerHeader = 0;
let StringFields = 0;
const Lines = [];
for (const Dir of Dirs) {
const Files = fs.readdirSync(Dir).filter((F) => /^s2c_\d+\.bin$/.test(F)).sort();
if (!Files.length) continue;
Lines.push('===== ' + path.basename(Dir) + ' (' + Files.length + ' 帧)');
for (const F of Files) {
const R = DecodeFrame(F, fs.readFileSync(path.join(Dir, F)));
if (R.Error) {
Lines.push(' ' + F + ' ' + R.Error);
continue;
}
Frames++;
Records += R.Records.length;
for (const Rec of R.Records) if (Rec.Model) ModelCount[Rec.Model] = (ModelCount[Rec.Model] || 0) + 1;
if (ShowFields) {
if (R.WalkOk) WalkOkFrames++;
else WalkBadFrames++;
for (const Rec of R.Records) {
if (!Rec.Container || !Rec.Inner) continue;
InnerSpan += Rec.Inner.Span;
InnerExactCover += Rec.Inner.ExactCover;
InnerHeader += Rec.Inner.Header;
for (const In of Rec.Inner.Records) {
InnerTotal++;
if (In.Exact) InnerExact++;
}
}
}
Lines.push(
' ' + F.padEnd(14) + ' raw=' + String(R.Raw).padStart(6) + ' 体=' + String(R.Body).padStart(6) +
(R.Compressed ? ' zlib' : ' ') + ' nullmap=0x' + R.Nullmap.toString(16).padStart(2, '0') +
'(' + R.NullmapSize + 'B)' +
' spaceId=' + R.SpaceId + ' 记录 ' + R.Records.length + ' 条' +
(ShowFields ? (R.WalkOk ? ' ✓走完整帧' : ' ⚠走到 @' + R.WalkEnd + '/' + R.Body + ' 就断了') : ''),
);
for (const Rec of R.Records) {
if (ShowFields) {
const Head = ' @' + String(Rec.Off).padStart(6) + ' ' + String(Rec.Model || '?').padEnd(6);
if (Rec.Error) {
Lines.push(Head + ' ' + Rec.Error);
continue;
}
if (Rec.Container) {
const In = Rec.Inner;
Lines.push(
Head + ' 容器负载=' + Rec.Len + 'B 头部 ' + In.Header + 'B ' +
'记录 ' + In.Records.length + ' 条(吃满边界的 ' + In.Records.filter((X) => X.Exact).length + ' 条)' +
' 精确覆盖 ' + In.ExactCover + '/' + In.Span + 'B' + (In.Tail ? ' 尾部余 ' + In.Tail + 'B' : ''),
);
for (const Ri of In.Records) {
Lines.push(
' ↳ @' + String(Ri.Off).padStart(6) + ' ' + String(Ri.ModelName || Ri.ModelId).padEnd(28) +
' 吃=' + String(Ri.Consumed).padStart(6) + 'B/' + String(Ri.Len).padStart(6) + 'B ' + Ri.CodecKey +
(Ri.Exact ? ' ✓' : ' ⚠边界不符(可能是假命中)'),
);
for (const L of FormatFields(Ri.Decoded, ' ')) Lines.push(L);
}
continue;
}
Lines.push(Head + ' ' + String(Rec.ModelName || '').padEnd(28) + ' 负载=' + String(Rec.Len).padStart(6) + 'B ' + Rec.CodecKey + (Rec.Bad ? ' ⚠截断' : ''));
for (const L of FormatFields(Rec.Decoded, ' ')) Lines.push(L);
continue;
}
Lines.push(
' @' + String(Rec.Off).padStart(6) + ' ' + String(Rec.Model).padEnd(6) + ' 负载=' + String(Rec.PayloadLen).padStart(7) +
'B codec=' + String(Rec.Codec).padEnd(5) + ' 字段=[' + String(Rec.CodecFields).slice(0, 60) + ']' +
(Rec.Head ? ' hex=' + Rec.Head : '') + (Rec.Strings.length ? ' str=' + JSON.stringify(Rec.Strings) : ''),
);
for (const In of Rec.Inner.slice(0, 12)) {
Lines.push(
' ↳ @' + String(In.Off).padStart(6) + ' ' + String(In.Model).padEnd(6) + ' 负载=' + String(In.PayloadLen).padStart(6) +
'B codec=' + String(In.Codec).padEnd(5) + ' 字段=[' + String(In.CodecFields).slice(0, 50) + ']' +
(In.Head ? ' hex=' + In.Head : '') + (In.Strings.length ? ' str=' + JSON.stringify(In.Strings) : ''),
);
ModelCount['inner:' + In.Model] = (ModelCount['inner:' + In.Model] || 0) + 1;
}
if (Rec.Inner.length > 12) Lines.push(' ↳ …内层共 ' + Rec.Inner.length + ' 条');
}
}
}
const Tail = [
'',
'== 汇总 ==',
' 帧 ' + Frames + ' 个,记录 ' + Records + ' 条,出现模型 ' + Object.keys(ModelCount).length + ' 种',
' 模型分布(top20): ' + Object.entries(ModelCount).sort((A, B) => B[1] - A[1]).slice(0, 20).map(([K, V]) => K + ':' + V).join(' '),
];
if (ShowFields) {
const Pct = (A, B) => (B ? Math.round((A / B) * 100) + '%' : '-');
Tail.push(
' --fields: 走完整帧 ' + WalkOkFrames + ' / 中途断 ' + WalkBadFrames +
';容器内层记录 ' + InnerTotal + ' 条,吃满边界 ' + InnerExact + ' 条(' + Pct(InnerExact, InnerTotal) + ')' +
'(enum 按 ' + EnumBytes + ' 字节)',
' 容器字节归属:精确覆盖 ' + InnerExactCover + 'B / 容器 ' + InnerSpan + 'B(' + Pct(InnerExactCover, InnerSpan) +
'),未解析头部合计 ' + InnerHeader + 'B',
);
}
const Out = Lines.slice(0, All ? 400 : Limit * 6).concat(Tail);
console.log(Out.join('\n'));
// 落盘时截断,避免 --fields 把每一层字段都写进去导致文件爆到上百 MB
const DumpPath = path.join(PROJECT, 'names', 'decoded_frames.txt');
const MaxDumpLines = Arg('dump-lines', '6000') === '0' ? Infinity : Number(Arg('dump-lines', '6000')) || 6000;
const Dumped = Lines.length > MaxDumpLines ? Lines.slice(0, MaxDumpLines).concat(['…(已截断,共 ' + Lines.length + ' 行;用 --dump-lines 0 可全量写出)']) : Lines;
fs.writeFileSync(DumpPath, Dumped.join('\n') + Tail.join('\n') + '\n', 'utf8');
console.log('完整结果已写出: ' + DumpPath + '(' + Dumped.length + '/' + Lines.length + ' 行)');