// semantic.js - 空间帧的「语义层」:内部用 JSON 表示,收发时用 codec 双向序列化 // // 为什么要有这一层:模板里的帧原来是「原始 hex」,想改一个用户名字段就得做字节替换。 // 有了它,帧变成可读可改的字段 JSON,改完交给 codec(codec.js / container.js)重新编码出字节。 // // 帧体结构(依据 src/module_61860.js:2111-2131 的空间命令解析 u7m): // 帧体 = [nullmap][记录…] // nullmap 是「一条记录 1 bit」的位图,按 MSB 先读(src/module_24635.js:357-364 的 Et.xy): // bit=1 → 1 字节短命令号(查 Gr(space) 表,src/module_61860.js:2116-2118) // bit=0 → [objectId i64][methodId i64][负载](src/module_61860.js:2119) // **每条记录自带 objectId**(src/module_61860.js:2120 用第一个 i64 定位对象)。 // 单记录帧里它恰好等于空间 id,所以旧模板把它当「帧级 spaceId」——语义层不沿用这个简化。 // // 负载 = 该 s2c 方法的有序字段串接。字段表来自 names/downlink_table.json(tools/mine_downlink.js 挖的), // 容器型负载(modelService 的 4 条)交给 container.js 的三布局。 // // 用法: // const Semantic = require('./semantic').Load(namesDir); // const R = Semantic.DecodeRecord(MethodId, Buf, Off); // const Bytes = Semantic.EncodeRecord(MethodId, R); 'use strict'; const fs = require('fs'); const path = require('path'); const Link = require('./link'); const { LoadTables, MakeReader } = require('../lib_space_codec'); const { MakeWriter, ResolveFields } = require('./codec'); const { MakeContainerCodec } = require('./container'); const NAMES_DIR = path.resolve(__dirname, '..', '..', 'names'); // 匿名字段(表里只有线宽、没有名字)在 JSON 里的键:与 lib_space_codec.js:181 / codec.js:138 完全同口径, // 否则「解出来的键」和「写回去要找的键」对不上。 function FieldKey(F) { return F.Name && F.Name !== '(匿名)' && F.Name !== '?' ? F.Name : '(匿名#' + F.Order + ')'; } // nullmap 的位图(依据 src/module_24635.js:640-695 的解码器 _n.j7h 与 :612-639 的编码器 i7h): // 短形态(首字节 bit7=0):总长 = 1 + ((b0 & 0x60) >>> 5),位图 = 这串字节按 MSB 先读、**跳过前 3 个 bit** // (那 3 bit 是长度标签:i7h 存的是 `byte >>> 3`,即标签占低 3 位,位图占高 5 位); // 位图 bit 数 = 8 * 总长 - 3,即 5 / 13 / 21 / 29。 // 长形态(首字节 bit7=1):长度标签占 1 或 3 字节(link.js 的 NullmapSize 算的正是总长), // 位图 = 标签之后的字节原样按 MSB 先读,bit 数 = 长度 * 8。 // 注意:这个位图是「记录形态位 + 模型里 opt 字段的位」共用的**比特流**(opt 位见 src/module_24635.js:412-417 的 // Ht.p7g/o7g),所以第 N 条记录的形态位并不总是第 N 个 bit —— 只有前面记录的负载没消耗 bit 时才严格对齐。 // 因此解码必须维护一个**跨记录顺序推进**的位游标 Ctx = { Bits, Index }(见 DecodeFrameBody), // 否则「前一条是容器、负载吃掉上千个 opt 位」时后一条的形态位就读错(误判成短命令)。 function NullmapBits(Bytes) { const Bits = []; const Push = (B) => { for (let K = 7; K >= 0; K--) Bits.push((B >> K) & 1); }; if (Bytes.length && Bytes[0] & 0x80) { for (let I = Bytes[0] & 0x40 ? 3 : 1; I < Bytes.length; I++) Push(Bytes[I]); } else { for (const B of Bytes) Push(B); Bits.splice(0, 3); // 前 3 bit 是长度标签(src/module_24635.js:614) } return Bits; } // NullmapBits 的逆:把(就地改过的)位图写回 nullmap 字节,**长度一字节都不变**。 // 为什么必须长度不变:nullmap 的长度标签(短形态低 3 bit = i7h 的 `byte >>> 3`;长形态 1/3 字节) // 属于帧结构,位图位数 = 8*总长-3(短)/ 8*总长(长)。长度变了说明比特流顺序还没对,直接抛错。 // 位序号用「整串按 MSB 先读」的口径:短形态标签占 bit 0..2,长形态标签占前 Skip 个字节。 function WriteNullmapBits(Orig, Bits) { const Out = Buffer.from(Orig); const SkipBits = Orig.length && Orig[0] & 0x80 ? (Orig[0] & 0x40 ? 24 : 8) : 3; const Cap = Orig.length * 8 - SkipBits; if (Bits.length !== Cap) { throw new Error('位图位数 ' + Bits.length + ' 与 nullmap 容量 ' + Cap + '(' + Orig.length + 'B)不一致:比特流顺序不对'); } for (let I = 0; I < Cap; I++) { const P = SkipBits + I; const Mask = 0x80 >> (P & 7); if (Bits[I]) Out[P >> 3] |= Mask; else Out[P >> 3] &= ~Mask; } return Out; } function Load(NamesDir) { const Dir = NamesDir || NAMES_DIR; const Tables = LoadTables(Dir); const Reader = MakeReader(Tables, { EnumBytes: 4 }); const Writer = MakeWriter(Tables, { EnumBytes: 4 }); // 「位模式」的实例:opt 的「空/非空」从 nullmap 比特流读/写(真实线形,见 lib_space_codec.js:16-22)。 // 读侧(BitReader)**只在调用方给了位游标时用**:dispatch.js 的上行/下行字段路径与 audit_space_init.js // 走的是 Reader(旧的 1 字节 flag 近似),口径一字不变。 const BitReader = MakeReader(Tables, { EnumBytes: 4, OptBits: true }); // 写侧的字段路径实例(FieldWriter)与容器的 LeafWriter(container.js:139-144)**同口径**:opt 一律 // 按真实线形写(空值 0 字节、有值只写内层),不给 Ctx 时不写位。 // 为什么字段路径不能继续用「1 字节 flag」:帧的 nullmap 是**帧级原样带上**的(space.js:36 的 // Link.EncodeFrame(Body, Nm)),负载必须是位模式才对得上;否则实测 space-100072911/s2c_0001 会差 // 1 个字节(@37113 原始 0x92 被 codec.js:109 的 `Buffer.from([1])` 写成 0x01)。 // 容器记录一直是位模式写的,所以能逐字节还原;字段路径这里只是把口径统一过来。 // 老的「1 字节 flag」口径原样保留在 Writer(codec.js 的默认实例)里,不动它。 const FieldWriter = MakeWriter(Tables, { EnumBytes: 4, OptBits: true }); const Container = MakeContainerCodec(Tables, Reader, Writer); // 下行字段表:methodId → 该 s2c 方法实际读的有序字段(Kind='fields')或容器标记(Kind='container') const Downlink = JSON.parse(fs.readFileSync(path.join(Dir, 'downlink_table.json'), 'utf8')); const SpecByMethod = new Map(); for (const C of Downlink.Commands || []) SpecByMethod.set(String(C.MethodId), C); const ContainerName = new Map(); for (const C of Downlink.Containers || []) ContainerName.set(String(C.MethodId), C.Name); // 可读名(派生命名词典):methodId → LoginCommand.SendLogin 这类标注 const AliasById = Object.create(null); try { Object.assign(AliasById, JSON.parse(fs.readFileSync(path.join(Dir, 'command_alias.json'), 'utf8')).MethodIds || {}); } catch (Err) { // 词典是可选的显示层,缺了不影响语义 } // 模型真名(method_table 的 Model 列,如 _t / EE):只用于显示 const ModelByMethod = new Map(); try { const MT = JSON.parse(fs.readFileSync(path.join(Dir, 'method_table.json'), 'utf8')); for (const R of MT.Rows || []) ModelByMethod.set(String(R.MethodId), R); } catch (Err) {} // methodId → 上一次解出来的 {字段名: 值}:EncodeRecord 只给部分字段时用它回填「原值」。 // 之所以要它:模板里改一个字段时不该被迫抄一遍整条记录,但缺的字段必须有据可依(不能编)。 const LastDecoded = new Map(); function NameOf(MethodId, Spec) { const A = AliasById[MethodId]; if (A && A.Alias) return A.Alias; if (Spec && Spec.Kind === 'container' && ContainerName.has(MethodId)) return ContainerName.get(MethodId); return ''; } function ModelOf(MethodId) { const M = ModelByMethod.get(MethodId); return (M && M.Model) || ''; } // 一条记录的字段表:① downlink_table(专为「这条方法实际读哪些字段」挖的,首选) // ② codec_fields 里 methodId → 模型 codec 的字段全集(codec.js:166 的既有口径,第二证据源) // 两个都没有 → null(如实报不可解,不硬凑字段) function FieldsOf(MethodId, Spec) { if (Spec) return (Spec.Fields || []).map((F) => ({ Name: F.Name, Type: F.Type, TypeCodec: F.TypeCodec, Order: F.Order })); const R = ResolveFields(Tables, MethodId, null); if (R.Codec || (R.Fields && R.Fields.length)) { return R.Fields.map((F, I) => ({ Name: F.Name, Type: F.Type, TypeCodec: F.TypeCodec, Order: F.Order === undefined ? I + 1 : F.Order, Source: 'codec_fields' })); } return null; } // ---------------------------------------------------------------- 解码 // Buf[Off..End) 解一条记录的负载。End 缺省 = Buf 末尾(容器只能靠「恰好吃到记录尾」定界,见 container.js) // Ctx(可选)= 帧级位游标 { Bits, Index }(见 DecodeFrameBody)。给了 ⇒ opt 按 nullmap 的 bit 读, // 并把本条负载消费掉的 opt 位数写回 Ctx.Index;没给 ⇒ 保持旧的「1 字节 flag」近似 // (audit_space_init.js:86 的 DecodeRecord(MethodId, Buf, From, To) 走的就是这条,不受影响)。 function DecodeRecord(MethodId, Buf, Off, End, Ctx) { const Id = String(MethodId); const Spec = SpecByMethod.get(Id); const Limit = End === undefined ? Buf.length : End; const Out = { MethodId: Id, Name: NameOf(Id, Spec), Model: ModelOf(Id), Kind: Spec ? Spec.Kind : 'fields', Fields: [], Consumed: 0, Bad: false, Note: '', }; // 「解不动就留原字节」:本函数的每条 Bad 出路都会把**本条负载的原字节**放进 Out.Hex // (从负载起点 Off 到调用方给的 Limit;解不动时记录尾无从判定,只能按 Limit 取)。 // 为什么非留不可:语义层的承诺是「解不动不猜」,可原来只留了一句 Note——产物里没有任何可回写的 // payload,EncodeFrameBody 走到这里只能拿「前 k 个字段」去编(缺的字段非 opt 会抛错、opt 会被 // 填成 null,见 EncodeRecord 的缺字段分支),那既不像「保真」也不像「报错」,是把解码器的失败 // 偷偷变成了编码器的猜测。留了 Hex 之后 EncodeFrameBody 才有一条「原样透传 + 带上原因」的路。 // ⚠ 这不是兜底:Hex 只存字节、不改判 Bad/Note,调用方(rebase_space_init.js / structure_space_init.js) // 照样按 Bad 决定「不结构化」。 if (Spec && Spec.Kind === 'container') { // 容器型:三布局都试,只认「恰好吃到记录尾」的那个(container.js 的 Detect 不猜) // 有帧级位游标时按 methodId 直接定布局:Detect 内部会重算游标(ResolveCursor 要把前面的记录 // 重走一遍),多记录帧上是 O(n²),而调用方已经把游标送过来了。 const Layout = (Ctx && Ctx.Bits && Container.LAYOUT_BY_METHOD[Id]) || Container.Detect(Buf, Off, Limit); if (!Layout) { // 三布局各试一遍,把「吃到哪 / 在哪中断」写进 Note:比一句「解不动」有用得多 // (比如三布局都在 +0 中断 = 布局形状不对;lt 在 +8 中断 = modelRegister 里缺那个 modelId 的 codec) const Tried = ['zt', 'pt', 'lt'].map((L) => { const R = Container.Read(L, Buf, Off); return L + (R.Bad ? ' 在 +' + (R.Next - Off) + ' 处中断' : ' 吃到 +' + (R.Next - Off)); }); Out.Bad = true; Out.Note = '容器三布局都吃不到记录尾:' + Tried.join(',') + '(记录尾 ' + Limit + ',负载从 ' + Off + ' 起)'; Out.Hex = Buf.slice(Off, Limit).toString('hex'); // 解不动 → 原字节留住(原则见本函数开头) return Out; } const R = Container.Read(Layout, Buf, Off, Ctx); if (R.Bad) { Out.Bad = true; Out.Note = '容器布局 ' + Layout + ' 读到一半失败'; Out.Hex = Buf.slice(Off, Limit).toString('hex'); return Out; } Out.Layout = Layout; Out.Value = R.Value; Out.Consumed = R.Next - Off; return Out; } const Fields = FieldsOf(Id, Spec); if (Fields === null) { Out.Bad = true; Out.Note = 'methodId=' + Id + ' 不在 downlink_table、也不在 codec_fields 里:没有字段表,不硬解'; Out.Hex = Buf.slice(Off, Limit).toString('hex'); // 解不动 → 原字节留住(原则见本函数开头) return Out; } Out.Source = Spec ? 'downlink' : 'codec_fields'; let At = Off; const Seen = {}; // opt 走位模式还是字节模式,由**有没有位游标**显式门控(不是全局打开): // 位游标在位 ⇒ BitReader(位不够会如实报 Bad,不退回按字节猜)。 const UseBits = !!(Ctx && Ctx.Bits); if (UseBits) BitReader.AttachBits(Ctx.Bits, Ctx.Index); try { for (const F of Fields) { const Key = FieldKey(F); const R = UseBits ? BitReader.ReadValue(Buf, At, F.Type, F.TypeCodec, 0) : Reader.ReadValue(Buf, At, F.Type, F.TypeCodec, 0); Out.Fields.push({ Name: Key, Type: F.Type, Value: R.Value }); // 位模式下 opt 为空是**合法的 0 字节字段**(只有 nullmap 里的 1 个 bit,见 // src/module_24635.js:412-417 的 Ht.p7g:读到位=1 直接 return null)。旧口径把「没推进字节」 // 一律当卡住 → 实测 space-100073070/s2c_0004 的 mountedDevice(opt) 为空时整条记录被误判 Bad。 // 与 lib_space_codec.js:179 对 list> 的 ZeroOk 同一口径。 const ZeroOk = UseBits && /^opt= Bits.length) { Out.Bad = true; Out.Note = 'nullmap 的 ' + Bits.length + ' 个 bit 用完了,帧体还剩 ' + (Body.length - Off) + ' 字节没读'; Out.Records.push({ At: Off, Remaining: Body.slice(Off).toString('hex'), Bad: true, Note: Out.Note }); break; } if (Bits[Ctx.Index]) { Out.Bad = true; Out.Note = '第 ' + (Out.Records.length + 1) + ' 条记录是短命令形态(nullmap 第 ' + Ctx.Index + ' 个 bit=1,1 字节命令号 0x' + Body[Off].toString(16).padStart(2, '0') + '):命令号 → (objectId, methodId) 的查表数据还没挖出来,判定不了'; Out.Records.push({ At: Off, Short: true, CmdByte: Body[Off], Note: Out.Note }); break; } Ctx.Index++; // 形态位 = 长记录([objectId i64][methodId i64][负载]) if (Off + 16 > Body.length) { Out.Bad = true; Out.Note = '第 ' + (Out.Records.length + 1) + ' 条记录的剩余 ' + (Body.length - Off) + ' 字节不足一条记录头(objectId i64 + methodId i64 = 16B)'; Out.Records.push({ At: Off, Remaining: Body.slice(Off).toString('hex'), Bad: true, Note: Out.Note }); break; } const ObjectId = Body.readBigInt64BE(Off).toString(); const MethodId = Body.readBigInt64BE(Off + 8).toString(); const R = DecodeRecord(MethodId, Body, Off + 16, undefined, Ctx); Out.Records.push(Object.assign({ ObjectId, At: Off }, R)); if (R.Bad) { Out.Bad = true; Out.Note = '第 ' + Out.Records.length + ' 条记录(methodId=' + MethodId + ')解不动:' + R.Note; break; } Off += 16 + R.Consumed; } return Out; } // { Nullmap, Records } → [nullmap][每条 [objectId i64][methodId i64][负载]] // 每条记录的负载来源(按优先序):Payload(已编好的字节)/ Hex(未解记录原样透传)/ 走 EncodeRecord 现编 // nullmap **按编码顺序重建**:形态位(长记录=0)+ 各条负载里 opt 字段的位,就地覆盖原 nullmap 的位图。 // 长度一字节不变(WriteNullmapBits 会校验位数);没被覆盖到的尾部 bit 保持原值(原始帧里 nullmap // 的填充位我们无从判定,原样保真比编一个值可靠)。 // Payload / Hex 这条透传路径拿不到位游标(不知道该条负载吃掉几个 opt 位)⇒ 只把它自己的形态位写上, // 之后停止重建(后面的 bit 保持原值)——不猜、也不因此丢掉字节。 // Hex 透传是 DecodeRecord「解不动就留原字节」的对偶:DecodeFrameBody → EncodeFrameBody 的往返因此 // 对「含解不动记录的帧」也成立(原来这种帧会在编码侧抛错——记录里没有可回写的负载)。 // 原因不吞掉:Trail(可选,调用方给的数组)逐条记下 { Index, MethodId, Bytes, Reason }, // Reason 就是解码侧写好的那条 Note;不给 Trail 也不影响字节,只是没人接收这句原因。 function EncodeFrameBody(Frame, Trail) { const F = Frame || {}; if (F.Nullmap === undefined || F.Nullmap === null) throw new Error('EncodeFrameBody 需要 Nullmap(不许默默用 0x00 代替)'); const Nm = Buffer.isBuffer(F.Nullmap) ? F.Nullmap : Buffer.from(String(F.Nullmap), 'hex'); const NmSize = Link.NullmapSize(Nm, 0); if (NmSize > Nm.length) throw new Error('nullmap 声明 ' + NmSize + 'B 但只有 ' + Nm.length + 'B'); const OrigNm = Nm.slice(0, NmSize); const Bits = NullmapBits(OrigNm); const NewBits = Bits.slice(); const Ctx = { Bits: NewBits, Index: 0 }; let Rebuild = true; // 一旦遇到拿不到位游标的记录就置 false(见上) const Parts = []; let RecCount = 0; for (const R of F.Records || []) { RecCount++; const Id = Buffer.alloc(8); Id.writeBigInt64BE(BigInt(R.ObjectId), 0); const Method = Buffer.alloc(8); Method.writeBigInt64BE(BigInt(R.MethodId), 0); let Payload; if (Rebuild) { // 形态位:长记录 = 0。位图用完还写不下 = 这帧表达不了(不静默忽略,否则 nullmap 是错的) if (Ctx.Index >= NewBits.length) { throw new Error('nullmap 位图只有 ' + NewBits.length + ' 位,写不下第 ' + RecCount + ' 条记录的形态位'); } NewBits[Ctx.Index++] = 0; } if (Buffer.isBuffer(R.Payload)) { Payload = R.Payload; Rebuild = false; } else if (R.Hex !== undefined) { Payload = Buffer.from(R.Hex || '', 'hex'); Rebuild = false; // 透传 = 这条记录没能被解出来(或本来就是 Hex 形态),字节原样走。 // 原因(解码侧写的 Note)跟着一起交给调用方,免得「解不动」在编码这一步变成静默通过。 if (Trail) { const Why = R.Note || (R.Bad ? '记录解不动(解码侧没写出原因)' : '记录以 Hex 形态给定,未走解码器'); Trail.push({ Index: RecCount, MethodId: R.MethodId, Bytes: Payload.length, Reason: Why }); } } else { Payload = EncodeRecord(R.MethodId, R, Rebuild ? Ctx : null); } Parts.push(Id, Method, Payload); } return Buffer.concat([WriteNullmapBits(OrigNm, NewBits), ...Parts]); } return { Tables, Reader, Writer, BitReader, FieldWriter, Container, DecodeRecord, EncodeRecord, DecodeFrameBody, EncodeFrameBody, NullmapBits, WriteNullmapBits, NameOf, ModelOf }; } // ============================================================ // --demo:验收三段(往返一致 / 语义可读 / 改字段再编码) // 用法: node server/semantic.js --demo // ============================================================ function RunDemo() { const Space = require('./space'); const NamesDir = NAMES_DIR; const Semantic = Load(NamesDir); const File = path.join(NamesDir, 'space-init-login-100045260.json'); const Template = JSON.parse(fs.readFileSync(File, 'utf8')); const Frames = Space.BuildInitFrames(Template, Semantic.Container, Semantic); let Failed = 0; // 参考字节 = 按模板里存的形态组帧(Hex 原样透传 / Fields 走 EncodeRecord / 容器走 Container.Write)。 // objectId 缺省时回退帧级 SpaceId —— 这正是升级前模板组帧的规则,所以它就是「升级前的那串字节」。 const ReferenceBody = (F) => { const Parts = [Buffer.from(F.NullmapHex || '00', 'hex')]; for (const R of F.Records || []) { const Id = Buffer.alloc(8); Id.writeBigInt64BE(BigInt(R.ObjectId === undefined ? F.SpaceId : R.ObjectId), 0); const Method = Buffer.alloc(8); Method.writeBigInt64BE(BigInt(R.MethodId), 0); let Payload; if (R.Hex !== undefined) Payload = Buffer.from(R.Hex || '', 'hex'); else if (R.Layout !== undefined) Payload = Semantic.Container.Write(R.Layout, R.Value); else Payload = Semantic.EncodeRecord(R.MethodId, R); Parts.push(Id, Method, Payload); } return Buffer.concat(Parts); }; // 帧体(含 nullmap)→ 整帧(补长度头)。Link.EncodeFrame 自己拼 nullmap,所以这里拆开喂 const ToFrame = (Body) => { const N = Link.NullmapSize(Body, 0); return Link.EncodeFrame(Body.slice(N), Body.slice(0, N)); }; // 记录的可读形态:Fields 数组 → {字段名: 值} const Readable = (R) => { const O = { ObjectId: R.ObjectId, MethodId: R.MethodId, Name: R.Name, Model: R.Model, Kind: R.Kind }; if (R.Kind === 'container') { O.Layout = R.Layout; O.Value = R.Value; } else { O.Fields = {}; for (const F of R.Fields || []) O.Fields[F.Name] = F.Value; } if (R.Bad) O.Bad = R.Note; return O; }; const Check = (Ok, Msg) => { if (!Ok) Failed++; console.log(' ' + (Ok ? '✓' : '✗') + ' ' + Msg); }; console.log('语义层验收(' + File.replace(path.resolve(__dirname, '..', '..') + path.sep, '') + ')'); console.log(''); console.log('(A) 往返一致:每条记录 Decode → Encode,整帧(含 nullmap)必须逐字节相同'); Template.Frames.forEach((F, I) => { const Ref = ReferenceBody(F); const Decoded = Semantic.DecodeFrameBody(Ref); let Encoded; try { Encoded = Semantic.EncodeFrameBody(Decoded); } catch (Err) { Check(false, F.File + ' 重新编码抛错:' + Err.message); return; } const Same = Encoded.equals(Ref); // 顺带验服务端真正用的那条路径(space.js → semantic 现编)也产出同样的整帧 const Built = Frames[I] && Frames[I].Frame; const SameBuilt = !!Built && Built.equals(ToFrame(Ref)); Check(Same && SameBuilt, F.File + ' ' + Ref.length + 'B ' + F.Records.length + ' 条记录' + (Same ? '' : ' ⚠差异 ' + Encoded.length + 'B') + (SameBuilt ? '' : ' ⚠服务端组帧不同') + ' ' + F.Records.map((R) => Semantic.NameOf(R.MethodId) || '#' + R.MethodId).join(' + ')); }); console.log(''); console.log('(B) 语义可读:打印解析结果(JSON)'); for (const Name of ['s2c_0003.bin', 's2c_0006.bin']) { const F = Template.Frames.find((X) => X.File === Name); if (!F) { Check(false, '模板里没有 ' + Name); continue; } const Decoded = Semantic.DecodeFrameBody(ReferenceBody(F)); console.log(' ' + Name + ' nullmap=' + Decoded.Nullmap.toString('hex') + ' →'); console.log(JSON.stringify({ Nullmap: Decoded.Nullmap.toString('hex'), Records: Decoded.Records.map(Readable) }, null, 1) .split('\n').map((L) => ' ' + L).join('\n')); if (Name === 's2c_0003.bin') { const Uids = (Decoded.Records[0].Fields.find((X) => X.Name === 'advisedUids') || {}).Value; Check(Array.isArray(Uids) && Uids.length === 5 && Uids.every((X) => typeof X === 'string'), 'advisedUids 是 5 个字符串的数组'); Check(Decoded.Records.length === 2 && Decoded.Records[1].Fields.length === 0, '第 2 条记录存在且 0 字段(methodId=' + Decoded.Records[1].MethodId + ')'); } if (Name === 's2c_0006.bin') { const Hash = (Decoded.Records[0].Fields.find((X) => X.Name === 'hash') || {}).Value; Check(typeof Hash === 'string' && Hash.length === 64, 'hash 是 64 字符 token(' + (Hash || '').length + ' 字符)'); Check(Decoded.Records.length === 2, '第 2 条记录存在(methodId=' + Decoded.Records[1].MethodId + ')'); } } console.log(''); console.log('(C) 改字段再编码:把 advisedUids[0] 的末位字符换成一个同长度的不同字符'); const F3 = Template.Frames.find((X) => X.File === 's2c_0003.bin'); const RefBody = ReferenceBody(F3); const Decoded = Semantic.DecodeFrameBody(RefBody); const UidField = Decoded.Records[0].Fields.find((X) => X.Name === 'advisedUids'); const Before = UidField.Value[0]; // 只动 1 个字符且保持长度 —— 断言才可能精确到"只有 1 个字节不同"。 // 不写死 WYT2WYT→WYT3WYT:模板里的值本身会被我们改(现在就是 WYT3WYT), // 写死会让这个断言随数据变化而失效(曾经就失效过一次)。 const LastCh = Before[Before.length - 1]; const NextCh = /[0-8]/.test(LastCh) ? String(Number(LastCh) + 1) : LastCh === '9' ? '8' : 'A'; UidField.Value[0] = Before.slice(0, -1) + NextCh; const NewBody = Semantic.EncodeFrameBody(Decoded); const OldFrame = ToFrame(RefBody); const NewFrame = ToFrame(NewBody); const Diff = []; for (let I = 0; I < Math.max(OldFrame.length, NewFrame.length); I++) if (OldFrame[I] !== NewFrame[I]) Diff.push(I); console.log(' 旧帧 ' + OldFrame.length + 'B: ' + OldFrame.toString('hex')); console.log(' 新帧 ' + NewFrame.length + 'B: ' + NewFrame.toString('hex')); console.log(' ' + Before + ' → ' + UidField.Value[0]); Check(NewFrame.length === OldFrame.length, '新帧与旧帧长度相同(' + NewFrame.length + ' = ' + OldFrame.length + ')'); Check(Diff.length === 1, '只有 1 个字节不同(差异位置 ' + Diff.map((I) => I + ':' + OldFrame[I].toString(16) + '→' + NewFrame[I].toString(16)).join(', ') + ')'); Check(Diff.length === 1 && OldFrame[Diff[0]] === LastCh.charCodeAt(0) && NewFrame[Diff[0]] === NextCh.charCodeAt(0), '该字节正是 ASCII "' + LastCh + '" → "' + NextCh + '"'); console.log(''); console.log(Failed ? '✗ ' + Failed + ' 项断言未通过' : '✓ 全部断言通过'); return Failed ? 1 : 0; } module.exports = { Load, NAMES_DIR }; if (process.argv.indexOf('--demo') >= 0) process.exit(RunDemo());