Files
TermiNexus b083d27165 fix(protocol): TankState 按 23 字节定长 blob(T5)解码,修复 TankCC 后续字节错位
- codec_fields:module_87072::T5 记为单字段 bytes23;pq.tankState 的 TypeCodec 由 85183::jq 改指 87072::T5
- 生成器支持 bytes<N>(ByteArray / R.ReadRaw(N) / W.WriteRaw),重生成 Module87072、Module85183
- 审计链:lib_space_codec 新增 bytes<N>;audit_pt_boundaries / audit_bit_budget 支持 --body/--plain;semantic 暴露 Out.Bits
- 新增工具:audit_bit_budget(nullmap 位预算)、probe_pt_record(pt 项级)、analyze_item_trace(客户端项级打点对照)、dump_codec_src
- wss_server 新增 --dump-plain:把解密+解压后的裸帧体落到 plain/,供离线审计
- 镜像打点:opt 全量位位置、pt 项级字节/位区间、TankCC 逐字段区间;重新打包 unpacked.pack.json
2026-10-21 15:21:10 +08:00

545 lines
30 KiB
JavaScript
Raw Permalink 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.
// 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<T> 的「空/非空」从 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<T> 为空是**合法的 0 字节字段**(只有 nullmap 里的 1 个 bit,见
// src/module_24635.js:412-417 的 Ht.p7g:读到位=1 直接 return null)。旧口径把「没推进字节」
// 一律当卡住 → 实测 space-100073070/s2c_0004 的 mountedDevice(opt<i64>) 为空时整条记录被误判 Bad。
// 与 lib_space_codec.js:179 对 list<opt<T>> 的 ZeroOk 同一口径。
const ZeroOk = UseBits && /^opt</.test(String(F.Type));
if (R.Bad || (R.Next <= At && !ZeroOk)) {
Out.Bad = true;
Out.Note = '字段 ' + Key + '(' + F.Type + ')在偏移 ' + At + ' 解不动' + (R.Why ? ':' + R.Why : '');
break;
}
Seen[Key] = R.Value;
At = R.Next;
}
} finally {
if (UseBits) {
Ctx.Index = BitReader.BitCursor().Index; // 本条负载消费掉的 opt 位数(缺口 A 靠它跨记录推进)
BitReader.DetachBits();
}
}
Out.Consumed = At - Off;
if (Out.Bad) Out.Hex = Buf.slice(Off, Limit).toString('hex'); // 解不动 → 原字节留住(原则见本函数开头)
else LastDecoded.set(Id, Seen);
return Out;
}
// ---------------------------------------------------------------- 编码
// Json 形状:{ Fields: {字段名: 值} | [{Name,Value}] };容器型:{ Layout, Value }
// 只给部分字段时,缺的字段用「同一 methodId 上一次解出的原值」回填;连原值都没有且不是 opt → 报错(不静默补零)
// Ctx(可选)= 帧级位游标 { Bits, Index }:给了 ⇒ 除了写出负载,还把 opt 的「空/非空」位按编码顺序
// 写回该比特流(EncodeFrameBody 重建 nullmap 用);没给 ⇒ 只写负载(位由帧级 nullmap 原样带上)。
function EncodeRecord(MethodId, Json, Ctx) {
const Id = String(MethodId);
const Spec = SpecByMethod.get(Id);
const J = Json || {};
if (Spec && Spec.Kind === 'container') {
const Layout = J.Layout || (Spec.Layouts || [])[0];
if (!Layout) throw new Error('methodId=' + Id + ' 是容器型,但没说用哪个布局');
if (J.Value === undefined) throw new Error('methodId=' + Id + ' 容器型记录缺少 Value');
return Container.Write(Layout, J.Value, Ctx);
}
const Fields = FieldsOf(Id, Spec);
if (Fields === null) throw new Error('methodId=' + Id + ' 没有字段表,无法编码');
const Given = {};
if (Array.isArray(J.Fields)) for (const F of J.Fields) Given[F.Name] = F.Value;
else if (J.Fields && typeof J.Fields === 'object') Object.assign(Given, J.Fields);
const Base = LastDecoded.get(Id) || {};
const UseBits = !!(Ctx && Ctx.Bits);
const Parts = [];
if (UseBits) FieldWriter.AttachBits(Ctx.Bits, Ctx.Index);
try {
for (const F of Fields) {
const Key = FieldKey(F);
let Value;
if (Object.prototype.hasOwnProperty.call(Given, Key)) Value = Given[Key];
else if (Object.prototype.hasOwnProperty.call(Base, Key)) Value = Base[Key];
else if (/^opt</.test(String(F.Type))) Value = null;
else throw new Error('缺少字段 ' + Key + '(' + F.Type + '),也没有原值可回填');
Parts.push(FieldWriter.WriteValue(F.Type, F.TypeCodec, Value, 0));
}
} finally {
if (UseBits) {
Ctx.Index = FieldWriter.BitCursor().Index; // 把本条负载写掉的 opt 位数交回给调用方,供下一条写形态位
FieldWriter.DetachBits();
}
}
return Buffer.concat(Parts);
}
// ---------------------------------------------------------------- 帧体
// 记录链 → { Nullmap, Records }。逐 bit 判形态;判不出来就如实标 Bad/Short 并停,不猜(剩下的字节不丢)
// 位游标 Ctx.Index 跨记录推进:每读一条先消费 1 个形态位,再把该条负载里的 opt 位(容器/字段两条路径都算)
// 一起推到位 —— 位图是共用的,不推进就会把后一条的形态位读错(gap A)。
function DecodeFrameBody(Body) {
const NmSize = Link.NullmapSize(Body, 0);
const Nullmap = Body.slice(0, NmSize);
const Bits = NullmapBits(Nullmap);
const Out = { Nullmap, Records: [], Bad: false, Note: '' };
let Off = NmSize;
const Ctx = { Bits, Index: 0 };
while (Off < Body.length) {
// 记录起点时的位游标:诊断用(审计器拿它把 opt 位对齐回本记录)
const BitStart = Ctx.Index;
if (Ctx.Index >= 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, BitStart }, R));
if (R.Bad) {
Out.Bad = true;
Out.Note = '第 ' + Out.Records.length + ' 条记录(methodId=' + MethodId + ')解不动:' + R.Note;
break;
}
Off += 16 + R.Consumed;
}
// 诊断用:本帧消费到的位索引(nullmap 容量自检见 tools/_nullmap_capacity.js)
Out.Bits = Ctx.Index;
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());