- 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 等命令精确触发应答
446 lines
19 KiB
JavaScript
446 lines
19 KiB
JavaScript
// 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 + ' 行)');
|