数据: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>> 的元素级。
438 lines
22 KiB
JavaScript
438 lines
22 KiB
JavaScript
// 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(', '));
|
||
}
|