// 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 = varint 元素数 + 元素… // opt = 1 字节标志(非 0 = 空)+ 有值时紧跟 T // enum = 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 展开正确 // ✓ 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) // · 容器负载 = [头部][内层数据],头部实测形状(见 ContainerSchema): // [i32 a][i32 b][i64 objId][i32 N][N × i64 modelId] 共 20 + 8N 字节 // N 个 modelId 是「容器里出现哪些模型」的模式表,实测语义完全对得上,例如 // space-100045260/s2c_0000 的 30 个 = CaptchaCC / RegistrationModelCC / EntranceResourcesCC / …(登录+大厅初始化批次) // space-100045720/s2c_0000 的 5 个 = _t / Kz / ChatCC / ClanChatCC / NewsShowingCC(聊天批次) // space-100072911 的 2 个 = Jj / ShopCategoryCC(商店) // 另一个 8 个 = _t / bj / PassToShopCC / uj / oj / aj / DelayMountCategoryCC / MountedResistancesCC // · 认出模式表后:内层只接受表里的模型、且按表里的先后顺序出现(把假命中几乎清干净, // 内层记录「吃满边界」的比例从 60% 提到 70%)。 // · 仍缺的一环:负载开头那几个字节(头部前 12 字节的 a/b/objId)与「模型负载」之间还没对齐—— // _t 的 codec 读的首个 i64 实测是 1,而 modelRegister 里没有 id=1 的条目,说明 methodId 与模型负载之间 // 还有一小段(可能是命令 codec 自己写的 tag/版本);大帧(如 41KB 那个容器)连模式表都认不出, // 头部形状可能还随场景变化。要彻底闭合需要拿到容器命令自身的 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(); // modelId(十进制串) → 模型信息(容器「模式表」校验用,含没有 codec 的模型) const ByModelId = 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); ByModelId.set(String(E.key), 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); const Head = ContainerSchema(Buf, At + 8); // 认出模式表就从表后开始扫(表里的 id 本身不是数据记录),认不出就退回从负载开头扫 const From = Head ? Math.min(Head.End, Next) : At + 8; Out.push({ Off: At, Model: Info.Model, Codec: Info.Codec, Container: true, Len: Next - At - 8, Schema: Head ? Head.Schema : null, Inner: InnerScan(Buf, From, Next, Head ? Head.Schema : null), }); 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 }; } // 容器头部:实测形状 [i32 a][i32 b][i64 objId][i32 N][N × i64 modelId](共 20 + 8N 字节) // a/b 是两个小整数(实测 0/1、1/1、1/42 等),objId 是对象/空间族 id(如 100045311、920004789023) // N 个 modelId 是「容器里出现哪些模型」的模式表(实测 space-100045720/s2c_0000 的 5 个 id // = _t + ChatCC + ClanChatCC + NewsShowingCC 等,正是内层记录用的模型)。 // 校验不过就返回 null —— 不猜,宁可按老办法窗口扫。 // 判据只依赖「N 个 id 全部是已注册模型」:509 个已知 id 命中 64 个连号的概率可忽略。 function ContainerSchema(Buf, Off) { if (Off + 20 > Buf.length) return null; const N = Buf.readInt32BE(Off + 16); if (N <= 0 || N > 64 || Off + 20 + 8 * N > Buf.length) return null; const Schema = []; for (let K = 0; K < N; K++) { const Id = Buf.readBigInt64BE(Off + 20 + 8 * K).toString(); if (!ByModelId.has(Id)) return null; Schema.push(Id); } return { Schema, End: Off + 20 + 8 * N }; } // 在容器负载里扫 [modelId 8B] 候选,并用「解出来正好落到下一个候选处」过滤假命中 // 容器负载实测结构: [头部(含模式表)] [ (modelId 8B)(负载) … ] // 真记录的字段一定解到下一个 modelId 开头,假命中(modelId 字节嵌在别的数据里)解不到。 // 有模式表时只用表里的模型、且按表里的先后顺序出现,假命中几乎绝迹。 function InnerScan(Buf, From, To, Schema) { const Allowed = Schema ? new Set(Schema) : null; const Hits = []; let LastRank = -1; for (let Off = From; Off + 8 <= To; Off++) { const Id = Buf.readBigInt64BE(Off).toString(); const C = CodecByModelId.get(Id); if (!C) continue; if (Allowed) { const Rank = Schema.indexOf(Id); if (Rank < 0 || Rank < LastRank) continue; LastRank = Rank; } 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', ''); // --side:both(默认)| s2c | c2s —— 上/下行帧结构相同([nullmap][记录 = objectId+methodId+负载]), // 只是文件名区分方向;解上行(c2s = 本机客户端发出去的)时用 --side c2s。 const Side = String(Arg('side', 'both')).toLowerCase(); const FileRe = Side === 'c2s' ? /^c2s_\d+\.bin$/ : Side === 's2c' ? /^s2c_\d+\.bin$/ : /^(c2s|s2c)_\d+\.bin$/; 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) => FileRe.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' : ''), ); if (Rec.Schema) { const Names = Rec.Schema.map((Id) => { const R = ByModelId.get(Id) || {}; const C = CodecByModelId.get(Id); return (C && C.ModelName) || R.Model || Id; }); Lines.push(' 模式表(' + Rec.Schema.length + '): ' + Names.join(', ')); } 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 + ' 行)');