Files
TankIC/client/tools/ExportProtocolTables.mjs
T
TermiNexus 830d6408b8 feat(client): 协议层 M2 —— 容器三布局 lt/pt/zt 落定,多记录帧不再丢记录
客户端实现三种容器布局的读,与 JS 实现(3DTank/tools/server/container.js)逐字段对照:5/5 帧、一致叶子 26950 / 不一致 0,两份规范 JSON SHA256 完全相同(各 795139 B)。覆盖 space-100073070/s2c_0004.bin 的 pt(7210 项,含 515 个 Id=0 字面量与 6695 个模型展开) 与其后 28 条 fields 记录——M1 会把这 28 条全丢掉。协议表由 codec_fields.json 的 1143497 B 裁到 ProtocolTables.json 的 124270 B(削 89.1%),做法:只留解码必需属性 + 闭包裁剪(根=所有带 ModelId 的 codec ∪ 命令/容器方法,沿 TypeCodec 与模型真名取闭包,保留 559/643 个 codec),闭包外如实报「无 codec」。计划外但必需:space-100045260 的 s2c_0000/s2c_0004 是 zlib 压缩帧,M1 直接抛错,现用 Node zlib 解压(browser 侧仍显式抛错);并新增正式产物 client/tools/CompareContainerDecode.mjs。
2026-09-18 10:11:10 +08:00

336 lines
16 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;
}
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。' +
'只挑选 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,
},
WireClasses,
TypeAliases: SortKeys(TypeAliases),
CodecByModelId,
CodecByMethod,
CodecByModelName,
Codecs,
UplinkByMethod: SortByMethodId(UplinkByMethod),
DownlinkByMethod: SortByMethodId(DownlinkByMethod),
};
// ---------------------------------------------------------------- 落盘
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(', '));