- 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
545 lines
30 KiB
JavaScript
545 lines
30 KiB
JavaScript
// 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());
|