// 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 / opt / map / enum / 模型类名 …)。 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`)。它们是 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/list/enum 取 T,map 取 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 解出来只有**线形序数** `enum#N`(见 FieldReader.kt / lib_space_codec.js), // 真实客户端却知道成员名。证据在 names/evidence.json: // byModule..enums = { <枚举构造器短名>: [ {field, member, value}, … ] } // 其中 value 就是线形序数(与线上 i32 同口径),member 是成员名。 // // 【两层分离】本表只是**旁路**:导出的 EnumMembers 供 UI/日志/dump 查名, // 协议层的原始值仍是 `enum#N`(FieldReader / Container 一字不改), // 所以 client/tools/CompareContainerDecode.mjs 的逐字段字节对照口径不受任何影响。 // // 取哪些枚举:只取「导出表里真正会出现的 enum」——即 codec 闭包 + 上下行字段表里的 enum 类型串。 // 枚举短名是**模块内局部别名**,真正定义它的模块可能是字段 TypeCodec 指向的模块 // (例:module_85196::Lo 的 category 是 `enum`,但 ja 定义在 module_85667::pu)。 // 因此解析顺序:① 字段所属模块的 evidence → ② TypeCodec 模块的 evidence;两条都不中记「未解」。 // 键仍是字段所属模块(`::<短名>`),这样调用方拿「字段所在 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(覆盖 enum / list> / opt> / map>) 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,是**显示层旁路表**:键 ::<枚举短名> → { <线形序数> → <成员名> }。' + '它只供 UI/日志/dump 查名(见 Protocol/EnumNames.kt),协议层的 enum 原始值仍是 `enum#N`,两者互不影响;' + '只导出「导出表的 enum 类型串真正引用到的」枚举(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 = varint 元素数 + 元素;map = varint 条数 + 条数 × (K,V);enum = i32(4 字节大端);' + 'opt 的「空/非空」是 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(', ')); }