Files
TankIC/client/tools/ExportProtocolTables.mjs
T
TermiNexus 356647937e feat: M9 枚举线形序数 enum#N 解析成成员名(category 显示 ARMOR/WEAPON/PAINT…)
数据:evidence.json 的 byModule.<模块>.enums = { 枚举短名: [{field, member, value}] },全局 348 类 / 2633 成员。查表键定为 (字段所属模块, 枚举短名, 序数):同模块内短名天然唯一;跨模块同名 11 个短名 / 9 个同名异义,键含模块号即可隔离。全表 102 条 enum<X> 引用的定义模块均由字段的 TypeCodec 唯一确定(无冲突、未解 0),导出后落盘 77 类 / 1343 成员(33879 字符,整表增量 33894)。两层分离(本任务核心约束):协议层原始值仍是 enum#N,一字不改,因此 CompareContainerDecode.mjs 的逐字段对照口径不变;EnumNames 只在 UI/日志/dump 里翻译,查不到返回 null 并由调用方标「未解」,不猜。category 实测(15 件仓库物品全部解出):ARMOR#1 / WEAPON#0 / PAINT#2 / BAZOOKA#13 / DRONE#8 / INVENTORY#3 / INVISIBLE#9,真实 5 件挂载物品类别互不相同 —— M8 结论不变。UI 与日志显示「成员名(enum#N)」。顺带修正 GarageDriver 接线:上一轮把 URL 硬编码成 9092 且日志 label 写死 store-02,改为环境变量 TANKIC_WS_URL(默认 9091)与 TANKIC_LOG_LABEL(默认 store-03),否则「配对表 ⇔ 服务端日志」会找错目录。验收:CompareContainerDecode 5 帧 / 一致叶子 26950 / 不一致 0(硬门槛);server selftest 逐字节一致 1108 / 不一致 9 / 解码中断 90;三构建全绿;浏览器实测 15/15 类别显示成员名。未解:枚举成员名未覆盖 list<enum<X>>/opt<enum<X>> 的元素级。
2026-09-18 12:59:45 +08:00

438 lines
22 KiB
JavaScript
Raw 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.
// ExportProtocolTables.mjs —— 从 3DTank/names/ 生成客户端用的紧凑协议表
//
// 产物(两个文件内容一一对应,幂等可重复运行):
// 1. client/src/jsMain/resources/ProtocolTables.json
// 给人看 / 给其它语言用的 JSON 协议表。
// 2. client/src/jsMain/kotlin/Protocol/ProtocolTablesData.kt
// Kotlin/JS 没有跨 browser/node 一致的资源读取 API(webpack 产物里没有稳定的文件路径),
// 所以这里把同一份 JSON 内嵌成一个 Kotlin 常量字符串,供 ProtocolTables.kt 用
// kotlinx.serialization 解析。两个产物始终由本脚本一起生成,不会漂移。
//
// 数据来源(只读,绝不改动):
// names/uplink_table.json 上行命令表(methodId → 命令类 / 发送方法 / 负载字段)
// names/downlink_table.json 下行命令字段表 + 容器表(methodId → 有序字段 / 容器布局)
// names/codec_fields.json 模型 codec 的字段表(M2 容器解码:pt.Items 里 modelId → 字段表)
// names/space-*.json M1/M2 目标空间的初始化/会话模板,用来挑选「M1/M2 真正用得上的」条目
//
// 挑选口径(刻意不把 names/ 整份拷进来):
// DownlinkByMethod = 全部容器方法(M1/M2 靠它识别 ModelService 容器记录)
// ∪ 所有 space-*.json 模板里出现过的 Records[].MethodId
// UplinkByMethod = 所有 space-*.json 模板里出现过的 After[].MethodId(会话回放的上行触发点)
// Codecs 等模型表 = 见下方「M2 容器解码所需的模型表」一节(闭包裁剪)。
//
// 用法:node client/tools/ExportProtocolTables.mjs
'use strict';
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
const Here = path.dirname(url.fileURLToPath(import.meta.url));
const ClientDir = path.resolve(Here, '..');
const RepoDir = path.resolve(ClientDir, '..');
const NamesDir = path.join(RepoDir, '3DTank', 'names');
const OutJson = path.join(ClientDir, 'src', 'jsMain', 'resources', 'ProtocolTables.json');
const OutKotlin = path.join(ClientDir, 'src', 'jsMain', 'kotlin', 'Protocol', 'ProtocolTablesData.kt');
function ReadJson(File) {
return JSON.parse(fs.readFileSync(File, 'utf8'));
}
// methodId 是 64 位十进制字符串:用 BigInt 排序,保证产物顺序稳定(幂等)
const SortIds = (Ids) => [...Ids].sort((A, B) => (BigInt(A) < BigInt(B) ? -1 : BigInt(A) > BigInt(B) ? 1 : 0));
// ---------------------------------------------------------------- 收集「M1/M2 需要」的 methodId
const SpaceFiles = fs.readdirSync(NamesDir).filter((F) => /^space-(init|session)-.*\.json$/.test(F));
const NeededDownlink = new Set();
const NeededUplink = new Set();
for (const F of SpaceFiles) {
const Doc = ReadJson(path.join(NamesDir, F));
for (const Frame of Doc.Frames || []) {
for (const R of Frame.Records || []) if (R.MethodId) NeededDownlink.add(String(R.MethodId));
for (const A of Frame.After || []) if (A.MethodId) NeededUplink.add(String(A.MethodId));
}
}
// ---------------------------------------------------------------- 组装协议表
const UplinkSrc = ReadJson(path.join(NamesDir, 'uplink_table.json'));
const DownlinkSrc = ReadJson(path.join(NamesDir, 'downlink_table.json'));
// 字段统一形状:Order 从 1 起,Slot 是源码里的局部槽位名,Name 是可读名(挖不到就空),
// Type 是线类型串(i8..f64 / string / list<T> / opt<T> / map<K,V> / enum<T> / 模型类名 …)。
function UplinkField(F, I) {
return { Order: I + 1, Slot: String(F.Field || ''), Name: '', Type: String(F.Type || ''), TypeCodec: String(F.TypeCodec || '') };
}
function DownlinkField(F, I) {
return {
Order: F.Order === undefined ? I + 1 : Number(F.Order),
Slot: String(F.Slot || ''),
Name: String(F.Name || ''),
Type: String(F.Type || ''),
TypeCodec: String(F.TypeCodec || ''),
};
}
const UplinkByMethod = {};
for (const C of UplinkSrc.Commands || []) {
if (!NeededUplink.has(String(C.MethodId))) continue;
const Owner = C.OwnerName || C.OwnerClass || '';
UplinkByMethod[String(C.MethodId)] = {
Name: Owner && C.SendFn ? Owner + '.' + C.SendFn : Owner || C.SendFn || String(C.MethodId),
Module: String(C.Module || ''),
OwnerClass: String(C.OwnerClass || ''),
SendFn: String(C.SendFn || ''),
Fields: (C.Fields || []).map(UplinkField),
};
}
const DownlinkByMethod = {};
// 先放 Commands,再让 Containers 段覆盖:容器方法在 Commands 里也有一条(Kind=container、无布局),
// Containers 段才带可读名与布局(lt/pt/zt),必须以后者为准。
for (const C of DownlinkSrc.Commands || []) {
const Id = String(C.MethodId);
if (!NeededDownlink.has(Id)) continue;
DownlinkByMethod[Id] = {
Name: String(C.OwnerClass || ''),
Kind: String(C.Kind || 'fields'),
Layout: '',
Module: String(C.Module || ''),
OwnerClass: String(C.OwnerClass || ''),
Fields: (C.Fields || []).map(DownlinkField),
};
}
for (const C of DownlinkSrc.Containers || []) {
const Id = String(C.MethodId);
DownlinkByMethod[Id] = {
Name: String(C.Name || ''),
Kind: 'container',
Layout: String((C.Layouts || [])[0] || ''),
Module: String(C.Module || ''),
OwnerClass: String(C.OwnerClass || ''),
Fields: [],
};
}
// ---------------------------------------------------------------- M2 容器解码所需的模型表
//
// 为什么必须导出:lt/pt/zt 三个布局的负载边界由容器头部算出来,但 pt 的每一项是一段
// 「按 modelId 就地展开的模型字段」(container.js 的 ReadLtItem → CodecByModelId)。
// 没有模型字段表,pt 里的对象解不动,容器负载的边界也就无从确认。
//
// 裁剪策略(三步,产物体积是硬约束):
// ① 只留解码必需的三个属性:字段名 / 线类型串 / TypeCodec。codec_fields.json 里的
// Slots / Desc / Js / Prop / ModelKeys / MethodId / ModelClass / Module / Wire / TypeRaw
// 等元数据(占原文 92% 以上)一律不导出 —— 它们不参与任何解码路径。
// ② 闭包裁剪:从「所有带 ModelId 的 codec」+「downlink_table 里每条命令/容器方法的 codec」
// 出发,沿字段的 TypeCodec 与「模型真名」递归取闭包。闭包外的 codec 永不会被展开到,
// 导出后代码里也查不到(解到就如实报「无 codec」,不会伪装成解出来了)。
// ③ CodecByModelName 只保留闭包里「字段 TypeCodec 为空、靠模型名解析」真正会用到的名字
// (本次只有 1 个:Lt → module_61860::fi)。全量名字表要多花约 30KB,且没有任何一条
// 解码路径会用到。
//
// 已知残留(如实标注,不掩盖):闭包内含 2 处推不出 codec 的裸类型 —— module_29554::D.data
// (类型 `any`)与 module_29554::O.resources(`list<Ju>`)。它们是 modelService 自身的
// codec,只在「把这些 codec 当普通字段解」时才会命中;本次容器三布局与 5 个必测帧都不经过。
const CodecSrc = ReadJson(path.join(NamesDir, 'codec_fields.json'));
const CodecAll = CodecSrc.Codecs || {};
// 手写优先的「资源类 → 线上 i64」直译表(口径逐条对照 container.js 的 RESOURCE_CLASS_WIRE)。
// Pc 是实测标定(不是源码证据),但 container.js 就是这么用的,客户端必须同口径才能逐字段对上。
const RESOURCE_CLASS_WIRE = {
Ea: 'i64', nh: 'i64', W_: 'i64', uo: 'i64', pv: 'i64', B_: 'i64', Da: 'i64',
qs: 'i64', uh: 'i64', Gf: 'i64', kv: 'i64', Pc: 'i64',
};
// 编码字段:只留 [Name, Type, TypeCodec]。匿名字段按 lib_space_codec.js:306 的口径命名
// (`(匿名#序)`),否则解出来的键与上层比对时对不上。
function CodecFields(C) {
return (C.Fields || []).map((F) => [
F.Name && F.Name !== '(匿名)' && F.Name !== '?' ? String(F.Name) : '(匿名#' + F.Order + ')',
String(F.Type || ''),
String(F.TypeCodec || ''),
]);
}
// 取类型串里的「裸叶子类型名」:opt<T>/list<T>/enum<T> 取 T,map<K,V> 取 K 与 V(递归)
function LeafTypes(Text, Out) {
const Lt = Text.indexOf('<');
if (Lt < 0) {
Out.push(Text);
return;
}
const Name = Text.slice(0, Lt);
const Gt = Text.lastIndexOf('>');
const Inner = Text.slice(Lt + 1, Gt);
if (Name === 'map') {
let Depth = 0;
let Start = 0;
for (let I = 0; I < Inner.length; I++) {
const Ch = Inner[I];
if (Ch === '<') Depth++;
else if (Ch === '>') Depth--;
else if (Ch === ',' && Depth === 0) {
LeafTypes(Inner.slice(Start, I).trim(), Out);
Start = I + 1;
}
}
LeafTypes(Inner.slice(Start).trim(), Out);
} else {
LeafTypes(Inner.trim(), Out);
}
}
// 与 lib_space_codec.js 的 LoadTables 同口径的索引表
const CodecKeyByModelId = {};
const CodecKeyByMethod = {};
const CodecKeyByModelName = {};
for (const [Key, C] of Object.entries(CodecAll)) {
for (const M of C.MethodId || []) if (!CodecKeyByMethod[String(M)]) CodecKeyByMethod[String(M)] = Key;
const ModelIds = [];
if (C.ModelId) ModelIds.push(String(C.ModelId));
for (const M of C.ModelIds || []) ModelIds.push(String(M));
for (const M of ModelIds) if (!CodecKeyByModelId[M]) CodecKeyByModelId[M] = Key;
if (C.ModelName && !CodecKeyByModelName[C.ModelName]) CodecKeyByModelName[C.ModelName] = Key;
}
// 闭包根:带 ModelId 的 codec(pt.Items 的展开入口)+ downlink_table 每条方法的 codec
const CodecRoots = new Set(Object.values(CodecKeyByModelId));
for (const Source of [DownlinkSrc.Commands || [], DownlinkSrc.Containers || []]) {
for (const C of Source) {
const Key = CodecKeyByMethod[String(C.MethodId)];
if (Key) CodecRoots.add(Key);
for (const F of C.Fields || []) if (F.TypeCodec && CodecAll[F.TypeCodec]) CodecRoots.add(String(F.TypeCodec));
}
}
const CodecClosure = new Set();
const CodecStack = [...CodecRoots];
while (CodecStack.length) {
const Key = CodecStack.pop();
if (CodecClosure.has(Key) || !CodecAll[Key]) continue;
CodecClosure.add(Key);
for (const F of CodecAll[Key].Fields || []) {
if (F.TypeCodec && CodecAll[F.TypeCodec]) CodecStack.push(String(F.TypeCodec));
const Leaves = [];
LeafTypes(String(F.Type || ''), Leaves);
for (const Leaf of Leaves) if (CodecKeyByModelName[Leaf]) CodecStack.push(CodecKeyByModelName[Leaf]);
}
}
// 闭包里「字段 TypeCodec 为空、靠模型名解析」真正会用到的名字(见裁剪策略 ③)
const NeededModelNames = {};
for (const Key of CodecClosure) {
for (const F of CodecAll[Key].Fields || []) {
if (F.TypeCodec) continue;
const Leaves = [];
LeafTypes(String(F.Type || ''), Leaves);
for (const Leaf of Leaves) if (CodecKeyByModelName[Leaf]) NeededModelNames[Leaf] = CodecKeyByModelName[Leaf];
}
}
// 按 codec 键排序,保证产物顺序稳定(幂等)
const Codecs = {};
for (const Key of Object.keys(CodecAll).filter((K) => CodecClosure.has(K)).sort()) Codecs[Key] = CodecFields(CodecAll[Key]);
function SortKeys(Obj) {
const Out = {};
for (const K of Object.keys(Obj).sort()) Out[K] = Obj[K];
return Out;
}
const CodecByModelId = {};
for (const M of Object.keys(CodecKeyByModelId).filter((M) => CodecClosure.has(CodecKeyByModelId[M])).sort((A, B) => (BigInt(A) < BigInt(B) ? -1 : BigInt(A) > BigInt(B) ? 1 : 0)))
CodecByModelId[M] = CodecKeyByModelId[M];
const CodecByMethod = {};
for (const M of Object.keys(CodecKeyByMethod).filter((M) => CodecClosure.has(CodecKeyByMethod[M])).sort((A, B) => (BigInt(A) < BigInt(B) ? -1 : BigInt(A) > BigInt(B) ? 1 : 0)))
CodecByMethod[M] = CodecKeyByMethod[M];
const CodecByModelName = SortKeys(NeededModelNames);
// 「裸类名 → 线上基础类型」直译表:ResourceClasses[].Wire 打底,手写表覆盖(手写优先,
// 与 container.js:130-132 的构造顺序完全一致)
const TypeAliases = {};
for (const [N, R] of Object.entries(CodecSrc.ResourceClasses || {})) TypeAliases[N] = R.Wire;
for (const [N, W] of Object.entries(RESOURCE_CLASS_WIRE)) TypeAliases[N] = W;
const WireClasses = SortKeys(CodecSrc.WireClasses || {});
// 按 methodId 数值升序重排,避免依赖 names/ 里的书写顺序
function SortByMethodId(Obj) {
const Out = {};
for (const Id of SortIds(Object.keys(Obj))) Out[Id] = Obj[Id];
return Out;
}
// ---------------------------------------------------------------- 枚举成员名(显示层旁路表)
//
// 协议里的 enum<T> 解出来只有**线形序数** `enum#N`(见 FieldReader.kt / lib_space_codec.js),
// 真实客户端却知道成员名。证据在 names/evidence.json:
// byModule.<moduleId>.enums = { <枚举构造器短名>: [ {field, member, value}, … ] }
// 其中 value 就是线形序数(与线上 i32 同口径),member 是成员名。
//
// 【两层分离】本表只是**旁路**:导出的 EnumMembers 供 UI/日志/dump 查名,
// 协议层的原始值仍是 `enum#N`(FieldReader / Container 一字不改),
// 所以 client/tools/CompareContainerDecode.mjs 的逐字段字节对照口径不受任何影响。
//
// 取哪些枚举:只取「导出表里真正会出现的 enum<T>」——即 codec 闭包 + 上下行字段表里的 enum 类型串。
// 枚举短名是**模块内局部别名**,真正定义它的模块可能是字段 TypeCodec 指向的模块
// (例:module_85196::Lo 的 category 是 `enum<ja>`,但 ja 定义在 module_85667::pu)。
// 因此解析顺序:① 字段所属模块的 evidence → ② TypeCodec 模块的 evidence;两条都不中记「未解」。
// 键仍是字段所属模块(`<moduleId>::<短名>`),这样调用方拿「字段所在 codec 的模块号」就能查到。
const EvidenceSrc = ReadJson(path.join(NamesDir, 'evidence.json'));
const EnumEvidenceByModule = {};
for (const [M, V] of Object.entries(EvidenceSrc.byModule || {})) {
EnumEvidenceByModule[String(Number(M))] = V.enums || {};
}
const ModuleIdOf = (M) => {
const R = /^module_(\d+)$/.exec(String(M || ''));
return R ? R[1] : String(M || '');
};
const EnumMembersOf = (M, S) => (EnumEvidenceByModule[ModuleIdOf(M)] || {})[S];
// 取类型串里所有 enum<X> 的 X(覆盖 enum<X> / list<enum<X>> / opt<enum<X>> / map<K, enum<X>>)
function CollectEnumShorts(Type, Out) {
const Re = /enum<([^<>]+)>/g;
let M;
while ((M = Re.exec(String(Type || ''))) !== null) Out.push(M[1]);
}
const EnumRefs = []; // { Module, Short, TypeCodec, Where }
function AddEnumRefs(Type, Module, TypeCodec, Where) {
const Shorts = [];
CollectEnumShorts(Type, Shorts);
for (const S of Shorts) EnumRefs.push({ Module: ModuleIdOf(Module), Short: S, TypeCodec: String(TypeCodec || ''), Where });
}
for (const Key of CodecClosure) {
const C = CodecAll[Key];
for (const F of C.Fields || []) AddEnumRefs(F.Type, C.ModuleId || C.Module, F.TypeCodec, 'codec');
}
for (const Spec of Object.values(DownlinkByMethod)) for (const F of Spec.Fields) AddEnumRefs(F.Type, Spec.Module, F.TypeCodec, 'downlink');
for (const Spec of Object.values(UplinkByMethod)) for (const F of Spec.Fields) AddEnumRefs(F.Type, Spec.Module, F.TypeCodec, 'uplink');
const EnumMembers = {};
const EnumSourceModule = {};
const EnumMisses = [];
for (const R of EnumRefs) {
const Key = R.Module + '::' + R.Short;
if (Object.prototype.hasOwnProperty.call(EnumMembers, Key)) continue;
let Members = EnumMembersOf(R.Module, R.Short);
let From = R.Module;
if (!Members || !Members.length) {
const Tm = /^module_(\d+)::/.exec(R.TypeCodec);
if (Tm) {
const M2 = EnumMembersOf(Tm[1], R.Short);
if (M2 && M2.length) {
Members = M2;
From = Tm[1];
}
}
}
if (!Members || !Members.length) {
if (!EnumMisses.some((X) => X.Key === Key)) EnumMisses.push({ Key, Where: R.Where, TypeCodec: R.TypeCodec });
continue;
}
const Map = {};
for (const E of Members) {
const V = String(E.value);
if (!(V in Map)) Map[V] = String(E.member);
}
EnumMembers[Key] = Map;
EnumSourceModule[Key] = From;
}
// 键排序,保证产物顺序稳定(幂等)
const EnumMembersSorted = {};
for (const K of Object.keys(EnumMembers).sort()) EnumMembersSorted[K] = EnumMembers[K];
const EnumMemberTotal = Object.values(EnumMembersSorted).reduce((A, M) => A + Object.keys(M).length, 0);
const Tables = {
Note:
'客户端协议表,由 client/tools/ExportProtocolTables.mjs 从 3DTank/names/ 生成,请勿手改。' +
'UplinkByMethod 源 uplink_table.json,DownlinkByMethod 源 downlink_table.json(含 Containers 段)。' +
'Codecs/CodecByModelId/CodecByMethod/CodecByModelName 源 codec_fields.json,按「pt.Items 的 modelId + ' +
'downlink_table 方法」取闭包裁剪(只留字段名/线类型串/TypeCodec,丢掉全部元数据)。' +
'TypeAliases = codec_fields.ResourceClasses[].Wire 与手写 RESOURCE_CLASS_WIRE 合并(手写优先),' +
'WireClasses 为 codec_fields.WireClasses。' +
'EnumMembers 源 evidence.json,是**显示层旁路表**:键 <moduleId>::<枚举短名> → { <线形序数> → <成员名> }。' +
'它只供 UI/日志/dump 查名(见 Protocol/EnumNames.kt),协议层的 enum 原始值仍是 `enum#N`,两者互不影响;' +
'只导出「导出表的 enum<T> 类型串真正引用到的」枚举(codec 闭包 + 上下行字段表),未命中的键不导出。' +
'只挑选 M1/M2 目标空间(names/space-init-*.json、space-session-*.json)里出现过的 methodId 与全部容器方法。' +
'线格式口径:bool/i8=1、i16=2、i32=4、i64=8、f32=4、f64=8(全大端);string/bytes/array = varint 长度 + 原始字节;' +
'list<T> = varint 元素数 + 元素;map<K,V> = varint 条数 + 条数 × (K,V);enum<T> = i32(4 字节大端);' +
'opt<T> 的「空/非空」是 nullmap 比特流里的 1 个 bit(空值负载里 0 字节),不是 1 字节标志。' +
'帧格式见 Protocol/Frame.kt 与 Protocol/Nullmap.kt(口径与 3DTank/tools/server/link.js 一致)。',
GeneratedAt: new Date().toISOString(),
Source: '3DTank/names/{uplink_table.json,downlink_table.json,codec_fields.json,space-*.json}',
PrimitiveWidths: { i8: 1, i16: 2, i32: 4, i64: 8, f32: 4, f64: 8, bool: 1, enum: 4 },
VarintPrefixed: ['string', 'bytes', 'array'],
Selection: {
DownlinkMethodIds: Object.keys(DownlinkByMethod).length,
UplinkMethodIds: Object.keys(UplinkByMethod).length,
CodecCount: Object.keys(Codecs).length,
CodecByModelIdCount: Object.keys(CodecByModelId).length,
CodecByMethodCount: Object.keys(CodecByMethod).length,
EnumClassCount: Object.keys(EnumMembersSorted).length,
EnumMemberCount: EnumMemberTotal,
EnumMissCount: EnumMisses.length,
},
WireClasses,
TypeAliases: SortKeys(TypeAliases),
CodecByModelId,
CodecByMethod,
CodecByModelName,
Codecs,
UplinkByMethod: SortByMethodId(UplinkByMethod),
DownlinkByMethod: SortByMethodId(DownlinkByMethod),
EnumMembers: EnumMembersSorted,
};
// ---------------------------------------------------------------- 落盘
const Json = JSON.stringify(Tables);
fs.mkdirSync(path.dirname(OutJson), { recursive: true });
fs.writeFileSync(OutJson, Json + '\n', 'utf8');
// Kotlin 字符串字面量转义:\ " $ 与控制字符(JSON 已无换行,但仍统一处理)
function EscapeKotlin(S) {
let Out = '';
for (const Ch of S) {
const Code = Ch.codePointAt(0);
if (Ch === '\\') Out += '\\\\';
else if (Ch === '"') Out += '\\"';
else if (Ch === '$') Out += '\\$';
else if (Code < 0x20) Out += '\\u' + Code.toString(16).padStart(4, '0');
else Out += Ch;
}
return Out;
}
const Kotlin =
'// 本文件由 client/tools/ExportProtocolTables.mjs 生成,请勿手改。\n' +
'// 内容 = client/src/jsMain/resources/ProtocolTables.json(同一份 JSON)。\n' +
'// 为什么内嵌:Kotlin/JS 没有跨 browser/node 一致的资源读取 API,内嵌常量能保证两端行为完全一致。\n' +
'package Protocol\n' +
'\n' +
'// 协议表原始 JSON 文本(紧凑格式,仅此一处副本进入编译产物)\n' +
'internal const val ProtocolTablesJsonText: String =\n "' + EscapeKotlin(Json) + '"\n';
fs.mkdirSync(path.dirname(OutKotlin), { recursive: true });
fs.writeFileSync(OutKotlin, Kotlin, 'utf8');
console.log('已生成协议表:');
console.log(' ' + OutJson + ' ' + Json.length + ' 字符');
console.log(' ' + OutKotlin + ' ' + Kotlin.length + ' 字符');
console.log(' 下行方法 ' + Tables.Selection.DownlinkMethodIds + ' 条 / 上行方法 ' + Tables.Selection.UplinkMethodIds + ' 条');
console.log(
' 模型 codec ' + Tables.Selection.CodecCount + ' 个(闭包,全表 ' + Object.keys(CodecAll).length + ' 个)' +
' / modelId ' + Tables.Selection.CodecByModelIdCount + ' 个 / methodId ' + Tables.Selection.CodecByMethodCount + ' 个',
);
console.log(' 目标空间模板 ' + SpaceFiles.length + ' 个:' + SpaceFiles.join(', '));
// EnumMembers 的体积增量:把该键从同一份对象里摘掉再序列化一次做差(其余字段逐字节相同)
const EnumJsonLength = JSON.stringify(EnumMembersSorted).length;
const NoEnum = {};
for (const K of Object.keys(Tables)) if (K !== 'EnumMembers') NoEnum[K] = Tables[K];
const EnumDelta = Json.length - JSON.stringify(NoEnum).length;
console.log(
' 枚举成员名 ' + Tables.Selection.EnumClassCount + ' 个枚举类 / ' + Tables.Selection.EnumMemberCount + ' 个成员' +
'(enum 引用 ' + EnumRefs.length + ' 处 / 未解 ' + Tables.Selection.EnumMissCount + ' 处)' +
',EnumMembers 占 ' + EnumJsonLength + ' 字符,整表增量 ' + EnumDelta + ' 字符',
);
if (EnumMisses.length) {
console.log(' 未解枚举(前 10):' + EnumMisses.slice(0, 10).map((X) => X.Key + '(' + X.Where + ')').join(', '));
}