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。
This commit is contained in:
1 parent
2deb628bf6
commit
830d6408b8
14 files changed
+1011
-70
No files matched your search
@@ -12,6 +12,7 @@ import web.dom.ElementId
|
||||
import web.dom.document
|
||||
import web.html.HTMLElement
|
||||
import kotlinx.browser.window
|
||||
import Protocol.ContainerDump
|
||||
import Protocol.IsNodeRuntime
|
||||
import Protocol.SelfTest
|
||||
|
||||
@@ -30,9 +31,14 @@ private fun RenderApp() {
|
||||
createRoot(container).render(App.create())
|
||||
}
|
||||
|
||||
// 入口函数:Node 下跑协议层 M1 自检;浏览器下渲染页面并把入口挂到 window 上
|
||||
// 入口函数:Node 下跑协议层自检;浏览器下渲染页面并把入口挂到 window 上
|
||||
fun main() {
|
||||
if (IsNodeRuntime()) {
|
||||
// 比对脚本用:只把指定抓包帧解成 JSON 输出(TANKIC_CONTAINER_DUMP=1),不连服务器
|
||||
if (ContainerDump.Wanted()) {
|
||||
ContainerDump.Run()
|
||||
return
|
||||
}
|
||||
SelfTest.Run()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,6 +19,16 @@ internal external class WebSocket(Url: String) {
|
||||
internal fun IsNodeRuntime(): Boolean =
|
||||
js("typeof process !== 'undefined' && !!(process.versions && process.versions.node)") as Boolean
|
||||
|
||||
// Node 专有的模块加载(只在 IsNodeRuntime() 为真时调用)。
|
||||
// 刻意用 module.require 而不是 require:后者会被 browser 目标的 webpack 静态解析成
|
||||
// 「Can't resolve 'fs' / 'zlib'」,导致 browser 构建直接失败(webpack 5 对表达式形式的
|
||||
// module.require 不做静态解析,browser 侧永远走不到这里)。
|
||||
internal fun NodeRequire(Name: String): dynamic = js("module.require(Name)")
|
||||
|
||||
// Node Buffer → Kotlin ByteArray(Kotlin/JS 的 ByteArray 运行时就是 Int8Array)
|
||||
internal fun BufferToByteArray(Buf: dynamic): ByteArray =
|
||||
js("new Int8Array(Buf.buffer, Buf.byteOffset, Buf.byteLength)") as ByteArray
|
||||
|
||||
// Node 里主动结束进程(browser 不会走到这里)
|
||||
internal fun ExitProcess(Code: Int) {
|
||||
js("process.exit(Code)")
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Container.kt —— 容器三布局 lt / pt / zt 的读(口径逐条对照 3DTank/tools/server/container.js)
|
||||
//
|
||||
// 布局由 **methodId** 决定(不是「哪个布局恰好吃到帧尾」—— 那在**多记录帧**上必然失败,
|
||||
// 因为容器负载只占帧体的一段,后面还有别的记录):
|
||||
// ModelService.LoadDependencies 3216143066888387731 → zt
|
||||
// ModelService.LoadObjectsData 7640916300855664666 → pt
|
||||
// ModelService.UnloadObjects 9038756763225879447 → lt
|
||||
//
|
||||
// 三个布局的负载形状(与 container.js 的 ReadLt/ReadPt/ReadZt 一一对应):
|
||||
// lt = [varint 个数][个数 × i64 对象 id]
|
||||
// pt = [i32 C][C × (i64 objId, i64 gameTypeId)][i32 D][D × lt项]
|
||||
// lt项(与上面 lt 布局同名不同物)= [i64 id];id == 0 → 再读一个 i64 当值;
|
||||
// 否则 id 是 modelId,就地展开该模型的字段(ModelCodec.ReadFields)
|
||||
// zt = [i32 X][i32 C][C × {i64 ObjId, i32 N, N × i64 ModelId}]
|
||||
// [i32 D][D × {i64 R, i16 E, i16 U, i64 S, u8 O, i8 Skip, Skip × i64}]
|
||||
// 计数一律 i32(4 字节大端,不是 varint);zt 的跳过个数是 i8(1 字节,不是 varint)。
|
||||
//
|
||||
// opt 字段与记录形态位共用同一条 nullmap 比特流:容器叶子里的 opt 必须按 **bit** 读,
|
||||
// 且位游标跨记录顺序推进(Ctx 就是从 SpaceFrame 传下来的那条流)。拿不到位就如实报 Bad,
|
||||
// 绝不退回「按字节猜标志位」—— 那会把负载整体解错位。
|
||||
//
|
||||
// 任何越界 / 计数超上限 / modelId 查不到 codec 一律返回 Bad(不硬读)。
|
||||
package Protocol
|
||||
|
||||
internal class ContainerResult(val Value: Any?, val Next: Int, val Bad: Boolean, val Why: String = "")
|
||||
|
||||
internal object Container {
|
||||
|
||||
// 计数上限(与 container.js 的 MaxCount 同口径):超出即认定布局不成立
|
||||
const val MAX_COUNT = 1 shl 20
|
||||
|
||||
val LayoutByMethod: Map<String, String> = mapOf(
|
||||
"3216143066888387731" to "zt", // ModelService.LoadDependencies
|
||||
"7640916300855664666" to "pt", // ModelService.LoadObjectsData
|
||||
"9038756763225879447" to "lt", // ModelService.UnloadObjects
|
||||
)
|
||||
|
||||
fun LayoutOf(MethodId: String): String? = LayoutByMethod[MethodId]
|
||||
|
||||
fun Read(Layout: String, Buf: ByteArray, Off: Int, Ctx: BitCursor?): ContainerResult = when (Layout) {
|
||||
"lt" -> ReadLt(Buf, Off)
|
||||
"pt" -> ReadPt(Buf, Off, Ctx)
|
||||
"zt" -> ReadZt(Buf, Off)
|
||||
else -> ContainerResult(null, Off, true, "未知容器布局:$Layout")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lt
|
||||
|
||||
private fun ReadLt(Buf: ByteArray, Off: Int): ContainerResult {
|
||||
val N = Buf.ReadVarint(Off)
|
||||
if (N.Bad) return ContainerResult(null, Off, true, "流变量计数读不动")
|
||||
var At = N.Next
|
||||
if (N.Value > MAX_COUNT) return ContainerResult(null, Off, true, "个数 ${N.Value} 超上限")
|
||||
if (At + 8L * N.Value > Buf.size) {
|
||||
return ContainerResult(null, Off, true, "个数 ${N.Value} 但只剩 ${Buf.size - At} 字节")
|
||||
}
|
||||
val Ids = ArrayList<String>(N.Value)
|
||||
for (I in 0 until N.Value) {
|
||||
Ids.add(Buf.ReadI64(At).toString())
|
||||
At += 8
|
||||
}
|
||||
return ContainerResult(linkedMapOf("Ids" to Ids), At, false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pt
|
||||
|
||||
private fun ReadPt(Buf: ByteArray, Off: Int, Ctx: BitCursor?): ContainerResult {
|
||||
if (Off + 4 > Buf.size) return ContainerResult(null, Off, true, "不足 4 字节读对象表个数")
|
||||
val C = Buf.ReadI32(Off)
|
||||
var At = Off + 4
|
||||
if (C < 0 || C > MAX_COUNT) return ContainerResult(null, Off, true, "对象表个数 C=$C 不合理")
|
||||
if (At + 16L * C + 4 > Buf.size) return ContainerResult(null, Off, true, "对象表 C=$C 超出负载长度")
|
||||
val Objects = ArrayList<Any?>(C)
|
||||
for (I in 0 until C) {
|
||||
Objects.add(
|
||||
linkedMapOf(
|
||||
"ObjectId" to Buf.ReadI64(At).toString(),
|
||||
"GameTypeId" to Buf.ReadI64(At + 8).toString(),
|
||||
)
|
||||
)
|
||||
At += 16
|
||||
}
|
||||
val D = Buf.ReadI32(At)
|
||||
At += 4
|
||||
if (D < 0 || D > MAX_COUNT) return ContainerResult(null, Off, true, "ModelData 表个数 D=$D 不合理")
|
||||
val Items = ArrayList<Any?>(D)
|
||||
for (I in 0 until D) {
|
||||
if (At + 8 > Buf.size) return ContainerResult(null, At, true, "第 ${I + 1}/$D 项读 id 越界")
|
||||
val R = ReadLtItem(Buf, At, Ctx)
|
||||
if (R.Bad) return ContainerResult(null, At, true, "第 ${I + 1}/$D 项:${R.Why}")
|
||||
Items.add(R.Value)
|
||||
At = R.Next
|
||||
}
|
||||
return ContainerResult(linkedMapOf("Objects" to Objects, "Items" to Items), At, false)
|
||||
}
|
||||
|
||||
// pt 里的一项 = ModelData 的 lt codec(module_45686.js:487-494)
|
||||
private fun ReadLtItem(Buf: ByteArray, At: Int, Ctx: BitCursor?): ContainerResult {
|
||||
val Id = Buf.ReadI64(At).toString()
|
||||
val Next = At + 8
|
||||
if (Id == "0") {
|
||||
if (Next + 8 > Buf.size) return ContainerResult(null, At, true, "lt 项 id=0 但没有紧跟的 i64 值")
|
||||
return ContainerResult(
|
||||
linkedMapOf("Id" to Id, "Value" to Buf.ReadI64(Next).toString()),
|
||||
Next + 8, false,
|
||||
)
|
||||
}
|
||||
val Key = ProtocolTables.CodecByModelId(Id)
|
||||
?: return ContainerResult(null, At, true, "modelId=$Id 在 codec_fields 里查不到 codec(叶子 codec 缺失)")
|
||||
// 容器叶子路径:opt 按位读 + string 只认字面 UTF-8(对照 container.js 的 LeafReader 选项)
|
||||
val R = ModelCodec.ReadFields(Buf, Next, Key, 1, ReadCtx(Ctx, true))
|
||||
if (R.Bad) {
|
||||
return ContainerResult(null, At, true, "modelId=$Id($Key)的字段解不动:" + R.Why.ifEmpty { "未知类型/越界" })
|
||||
}
|
||||
return ContainerResult(linkedMapOf("Id" to Id, "Value" to R.Value), R.Next, false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- zt
|
||||
|
||||
private fun ReadZt(Buf: ByteArray, Off: Int): ContainerResult {
|
||||
if (Off + 8 > Buf.size) return ContainerResult(null, Off, true, "不足 8 字节读 X/批次个数")
|
||||
val X = Buf.ReadI32(Off)
|
||||
val C = Buf.ReadI32(Off + 4)
|
||||
var At = Off + 8
|
||||
if (C < 0 || C > MAX_COUNT) return ContainerResult(null, Off, true, "批次个数 C=$C 不合理")
|
||||
val Batches = ArrayList<Any?>(C)
|
||||
for (I in 0 until C) {
|
||||
if (At + 12 > Buf.size) return ContainerResult(null, At, true, "第 ${I + 1}/$C 批读头部越界")
|
||||
val ObjectId = Buf.ReadI64(At).toString()
|
||||
val N = Buf.ReadI32(At + 8)
|
||||
At += 12
|
||||
if (N < 0 || N > MAX_COUNT) return ContainerResult(null, At - 4, true, "第 ${I + 1} 批 modelId 个数 N=$N 不合理")
|
||||
if (At + 8L * N > Buf.size) return ContainerResult(null, At, true, "第 ${I + 1} 批 N=$N 超出负载长度")
|
||||
val ModelIds = ArrayList<String>(N)
|
||||
for (K in 0 until N) {
|
||||
ModelIds.add(Buf.ReadI64(At).toString())
|
||||
At += 8
|
||||
}
|
||||
Batches.add(linkedMapOf("ObjectId" to ObjectId, "ModelIds" to ModelIds))
|
||||
}
|
||||
if (At + 4 > Buf.size) return ContainerResult(null, At, true, "批次之后不足 4 字节读项个数")
|
||||
val D = Buf.ReadI32(At)
|
||||
At += 4
|
||||
if (D < 0 || D > MAX_COUNT) return ContainerResult(null, At - 4, true, "项个数 D=$D 不合理")
|
||||
val Items = ArrayList<Any?>(D)
|
||||
for (I in 0 until D) {
|
||||
if (At + 22 > Buf.size) return ContainerResult(null, At, true, "第 ${I + 1}/$D 项越界")
|
||||
val R = Buf.ReadI64(At).toString()
|
||||
val E = Buf.ReadI16(At + 8)
|
||||
val U = Buf.ReadI16(At + 10)
|
||||
val S = Buf.ReadI64(At + 12).toString()
|
||||
val O = Buf.ReadU8(At + 20)
|
||||
val Skip = Buf.ReadI8(At + 21) // n2u = 1 字节(module_72834.js:272-274),不是 varint
|
||||
At += 22
|
||||
if (Skip < 0 || Skip > MAX_COUNT) return ContainerResult(null, At - 1, true, "第 ${I + 1} 项跳过个数 $Skip 不合理")
|
||||
if (At + 8L * Skip > Buf.size) return ContainerResult(null, At, true, "第 ${I + 1} 项跳过 $Skip 个 id 越界")
|
||||
val Skipped = ArrayList<String>(Skip)
|
||||
for (K in 0 until Skip) {
|
||||
Skipped.add(Buf.ReadI64(At).toString())
|
||||
At += 8
|
||||
}
|
||||
Items.add(linkedMapOf("R" to R, "E" to E, "U" to U, "S" to S, "O" to O, "Skipped" to Skipped))
|
||||
}
|
||||
return ContainerResult(linkedMapOf("X" to X, "Batches" to Batches, "Items" to Items), At, false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 摘要(自检打印用)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun Summary(Layout: String, Value: Any?): String {
|
||||
val V = Value as? Map<String, Any?> ?: return "(空)"
|
||||
return when (Layout) {
|
||||
"zt" -> {
|
||||
val Batches = V["Batches"] as? List<Any?> ?: emptyList()
|
||||
val Items = V["Items"] as? List<Any?> ?: emptyList()
|
||||
"X=${V["X"]} 批次数=${Batches.size} 项数=${Items.size}"
|
||||
}
|
||||
"pt" -> {
|
||||
val Objects = V["Objects"] as? List<Any?> ?: emptyList()
|
||||
val Items = V["Items"] as? List<Any?> ?: emptyList()
|
||||
"对象数=${Objects.size} 项数=${Items.size}"
|
||||
}
|
||||
"lt" -> "id 数=" + ((V["Ids"] as? List<Any?>)?.size ?: 0)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// ContainerDump.kt —— 把同一批真实抓包帧用客户端解码器解出来,输出成规范的 JSON
|
||||
//
|
||||
// 用途:与 tools/CompareContainerDecode.mjs 的 JS 侧结果逐字段比对(验收 (a))。
|
||||
// 触发:环境变量 TANKIC_CONTAINER_DUMP=1(见 Main.kt 的分发);帧文件列表从
|
||||
// TANKIC_CONTAINER_FILES(`;` 分隔的绝对路径,顺序即输出顺序)读入。
|
||||
//
|
||||
// 规范(两侧必须完全一致,否则逐字段比对没有意义):
|
||||
// · 对象键按字典序排列;
|
||||
// · 所有数字输出成**十进制字符串**(i8/i16/i32/i64/f32/f64 一律如此);
|
||||
// f32/f64 用 JS 的 String(x)(不是 Kotlin 的 toString),避免浮点格式化差异;
|
||||
// · null 与「键缺失」是两回事:只有确实取值过才写键。
|
||||
package Protocol
|
||||
|
||||
internal object CanonicalJson {
|
||||
|
||||
fun Text(V: Any?): String = when (V) {
|
||||
null -> "null"
|
||||
is Boolean -> if (V) "true" else "false"
|
||||
is String -> Quote(V)
|
||||
is Int -> Quote(V.toString())
|
||||
is Long -> Quote(V.toString())
|
||||
is Float -> Quote(JsNumText(V.toDouble()))
|
||||
is Double -> Quote(JsNumText(V))
|
||||
is List<*> -> "[" + V.joinToString(",") { Text(it) } + "]"
|
||||
is Map<*, *> ->
|
||||
"{" + V.entries.sortedBy { it.key.toString() }
|
||||
.joinToString(",") { Quote(it.key.toString()) + ":" + Text(it.value) } + "}"
|
||||
else -> Quote(V.toString())
|
||||
}
|
||||
|
||||
// JS 的 String(number):与抓包侧 String(value) 完全同形,浮点不会因语言格式化而分叉
|
||||
private fun JsNumText(V: Double): String = js("String(V)") as String
|
||||
|
||||
private fun Quote(S: String): String {
|
||||
val Sb = StringBuilder(S.length + 2)
|
||||
Sb.append('"')
|
||||
for (C in S) {
|
||||
when (C) {
|
||||
'\\' -> Sb.append("\\\\")
|
||||
'"' -> Sb.append("\\\"")
|
||||
'\n' -> Sb.append("\\n")
|
||||
'\r' -> Sb.append("\\r")
|
||||
'\t' -> Sb.append("\\t")
|
||||
else -> if (C.code < 0x20) Sb.append("\\u").append(C.code.toString(16).padStart(4, '0')) else Sb.append(C)
|
||||
}
|
||||
}
|
||||
Sb.append('"')
|
||||
return Sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
internal object ContainerDump {
|
||||
|
||||
fun Wanted(): Boolean = Env("TANKIC_CONTAINER_DUMP") == "1"
|
||||
|
||||
fun Run() {
|
||||
val Files = Env("TANKIC_CONTAINER_FILES").split(";").filter { it.isNotEmpty() }
|
||||
val Parts = ArrayList<String>()
|
||||
for (Path in Files) Parts.add(DumpFile(Path))
|
||||
val Out = "{\"Tool\":\"TankIcClient/ContainerDump\",\"Files\":[" + Parts.joinToString(",") + "]}"
|
||||
// 用标记包住,方便比对脚本从(可能混着构建日志的)stdout 里精确取出这段 JSON
|
||||
js("process.stdout.write('<<<TANKIC_KOTLIN_JSON>>>' + Out + '<<<TANKIC_KOTLIN_JSON_END>>>')")
|
||||
}
|
||||
|
||||
private fun DumpFile(Path: String): String {
|
||||
val Head = "{\"Path\":" + CanonicalJson.Text(Path) + ","
|
||||
var Raw: ByteArray
|
||||
try {
|
||||
Raw = ReadFile(Path)
|
||||
} catch (E: dynamic) {
|
||||
return Head + "\"Error\":" + CanonicalJson.Text(E.toString()) + ",\"Frames\":[]}"
|
||||
}
|
||||
val Frames = Frame.ParseFrames(Raw)
|
||||
?: return Head + "\"Error\":\"整帧流解析失败(有残余字节)\",\"Frames\":[]}"
|
||||
val Parts = ArrayList<String>()
|
||||
for (F in Frames) {
|
||||
val Body = try {
|
||||
Frame.DecodeFrame(F)
|
||||
} catch (E: ProtocolException) {
|
||||
Parts.add("{\"BodyBytes\":" + F.Body.size + ",\"Records\":[],\"Bad\":true,\"Note\":" + CanonicalJson.Text(E.message ?: "") + "}")
|
||||
continue
|
||||
}
|
||||
val Parsed = SpaceFrame.Parse(Body)
|
||||
val Recs = ArrayList<String>()
|
||||
for (R in Parsed.Records) Recs.add(DumpRecord(R))
|
||||
Parts.add(
|
||||
"{\"BodyBytes\":" + Body.size + ",\"Records\":[" + Recs.joinToString(",") + "]," +
|
||||
"\"Bad\":" + (if (Parsed.Bad) "true" else "false") + ",\"Note\":" + CanonicalJson.Text(Parsed.Note) + "}"
|
||||
)
|
||||
}
|
||||
return Head + "\"Error\":null,\"Frames\":[" + Parts.joinToString(",") + "]}"
|
||||
}
|
||||
|
||||
private fun DumpRecord(R: SpaceRecord): String {
|
||||
val Sb = StringBuilder()
|
||||
Sb.append("{\"ObjectId\":").append(CanonicalJson.Text(R.ObjectId))
|
||||
Sb.append(",\"MethodId\":").append(CanonicalJson.Text(R.MethodId))
|
||||
Sb.append(",\"Kind\":").append(CanonicalJson.Text(R.Kind))
|
||||
Sb.append(",\"Layout\":").append(CanonicalJson.Text(R.Layout))
|
||||
Sb.append(",\"Bad\":").append(if (R.Bad) "true" else "false")
|
||||
Sb.append(",\"Note\":").append(CanonicalJson.Text(R.Note))
|
||||
when (R.Kind) {
|
||||
"container" -> Sb.append(",\"Value\":").append(CanonicalJson.Text(R.Value))
|
||||
"fields" -> {
|
||||
Sb.append(",\"Fields\":{")
|
||||
val Sorted = R.Fields.sortedBy { it.first }
|
||||
Sb.append(Sorted.joinToString(",") { CanonicalJson.Text(it.first) + ":" + CanonicalJson.Text(it.second) })
|
||||
Sb.append("}")
|
||||
}
|
||||
}
|
||||
Sb.append("}")
|
||||
return Sb.toString()
|
||||
}
|
||||
|
||||
private fun Env(Name: String): String = js("process.env[Name] || ''") as String
|
||||
|
||||
// 读文件(Node):Buffer → ByteArray
|
||||
private fun ReadFile(Path: String): ByteArray = BufferToByteArray(NodeRequire("fs").readFileSync(Path))
|
||||
}
|
||||
@@ -8,10 +8,10 @@
|
||||
// enum<T> = 4 字节(i32)
|
||||
// opt<T> = nullmap 比特流里的 1 个 bit:1 = 空(负载里 0 字节),0 = 有值、紧跟 T
|
||||
// ── 注意 opt 用的是**帧级共享**的 nullmap 比特流,不是 1 字节标志;位游标必须跨记录顺序推进。
|
||||
// 模型类(codec_fields 里的嵌套 codec)M1 不支持:如实报不可解,不按字节长度硬猜。
|
||||
// 模型类 = 就地展开该模型 codec 的字段(无长度前缀、无边界标记),展开入口见 ModelCodec.kt。
|
||||
package Protocol
|
||||
|
||||
// nullmap 比特流游标:Index 会被本模块与 SpaceFrame 顺序推进
|
||||
// nullmap 比特流游标:Index 会被本模块、SpaceFrame 与 Container 顺序推进
|
||||
internal class BitCursor(val Bits: BooleanArray, var Index: Int) {
|
||||
// 取 1 个 bit;位用尽返回 null(如实报错,不默认按非空解)
|
||||
fun Take(): Boolean? {
|
||||
@@ -20,6 +20,12 @@ internal class BitCursor(val Bits: BooleanArray, var Index: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
// 一次字段读的上下文:
|
||||
// Bits = 帧级 nullmap 比特流游标(opt<T> 按位读;没给就如实报不可解,绝不按字节猜)
|
||||
// StringLiteralOnly = string 只认字面 UTF-8,不是合法 UTF-8 就直接报 Bad(容器叶子路径,口径同
|
||||
// container.js 的 LeafReader);false 时退回 '0x…' 十六进制转义(字段表路径)
|
||||
internal class ReadCtx(val Bits: BitCursor?, val StringLiteralOnly: Boolean = false)
|
||||
|
||||
internal class FieldReadResult(val Value: Any?, val Next: Int, val Bad: Boolean, val Why: String = "")
|
||||
|
||||
internal object FieldReader {
|
||||
@@ -29,14 +35,15 @@ internal object FieldReader {
|
||||
// 类型串是否以 opt< 开头(SpaceFrame 判「空值 0 字节合法」用)
|
||||
fun IsOpt(Type: String): Boolean = Type.startsWith("opt<")
|
||||
|
||||
fun ReadValue(Buf: ByteArray, Off: Int, Type: String, TypeCodec: String, Depth: Int, Ctx: BitCursor?): FieldReadResult {
|
||||
fun ReadValue(Buf: ByteArray, Off: Int, Type: String, TypeCodec: String, Depth: Int, Ctx: ReadCtx?): FieldReadResult {
|
||||
if (Depth > MAX_DEPTH) return FieldReadResult("<深度上限>", Off, true, "嵌套超过 $MAX_DEPTH 层")
|
||||
if (Type.isEmpty() || Type == "?") return FieldReadResult("<未知类型>", Off, true, "字段类型缺失")
|
||||
val T = Type
|
||||
|
||||
val Opt = MatchGeneric(T, "opt")
|
||||
if (Opt != null) {
|
||||
val Cursor = Ctx ?: return FieldReadResult("<无位游标>", Off, true, "opt 字段需要 nullmap 比特流(本帧没给)")
|
||||
val Cursor = Ctx?.Bits
|
||||
?: return FieldReadResult("<无位游标>", Off, true, "opt 字段需要 nullmap 比特流(本帧没给)")
|
||||
val Bit = Cursor.Take() ?: return FieldReadResult("<nullmap 位用尽>", Off, true, "opt 的位超出 nullmap 位图")
|
||||
if (Bit) return FieldReadResult(null, Off, false)
|
||||
return ReadValue(Buf, Off, Opt, TypeCodec, Depth, Ctx)
|
||||
@@ -50,7 +57,7 @@ internal object FieldReader {
|
||||
if (N.Bad || N.Value > 4096) return FieldReadResult(Arr, At, true, "list 元素数 ${N.Value} 不合理")
|
||||
val ZeroOk = IsOpt(List)
|
||||
for (I in 0 until N.Value) {
|
||||
val R = ReadValue(Buf, At, List, "", Depth + 1, Ctx)
|
||||
val R = ReadValue(Buf, At, List, TypeCodec, Depth + 1, Ctx)
|
||||
Arr.add(R.Value)
|
||||
if (R.Bad || (R.Next <= At && !ZeroOk)) return FieldReadResult(Arr, At, true, R.Why.ifEmpty { "list 第 ${I + 1} 个元素解不动" })
|
||||
At = R.Next
|
||||
@@ -61,8 +68,8 @@ internal object FieldReader {
|
||||
val Enum = MatchGeneric(T, "enum")
|
||||
if (Enum != null) {
|
||||
if (Off + 4 > Buf.size) return FieldReadResult("<越界>", Off, true, "enum 需要 4 字节")
|
||||
val V = Buf.ReadI32(Off).toLong() and 0xFFFFFFFFL
|
||||
return FieldReadResult("enum#$V", Off + 4, false)
|
||||
// 与 lib_space_codec.js:201-203 同口径:逐字节 (V<<8)|b 得到**有符号** i32,再拼成 'enum#N'
|
||||
return FieldReadResult("enum#" + Buf.ReadI32(Off), Off + 4, false)
|
||||
}
|
||||
|
||||
val Map = MatchGeneric(T, "map")
|
||||
@@ -76,9 +83,9 @@ internal object FieldReader {
|
||||
val ZeroK = IsOpt(Kv[0])
|
||||
val ZeroV = IsOpt(Kv[1])
|
||||
for (I in 0 until N.Value) {
|
||||
val Rk = ReadValue(Buf, At, Kv[0], "", Depth + 1, Ctx)
|
||||
val Rk = ReadValue(Buf, At, Kv[0], TypeCodec, Depth + 1, Ctx)
|
||||
if (Rk.Bad || (Rk.Next <= At && !ZeroK)) return FieldReadResult(Pairs, At, true, Rk.Why.ifEmpty { "map 第 ${I + 1} 条的键解不动" })
|
||||
val Rv = ReadValue(Buf, Rk.Next, Kv[1], "", Depth + 1, Ctx)
|
||||
val Rv = ReadValue(Buf, Rk.Next, Kv[1], TypeCodec, Depth + 1, Ctx)
|
||||
Pairs.add(listOf(Rk.Value, Rv.Value))
|
||||
if (Rv.Bad || (Rv.Next <= Rk.Next && !ZeroV)) return FieldReadResult(Pairs, Rv.Next, true, Rv.Why.ifEmpty { "map 第 ${I + 1} 条的值解不动" })
|
||||
At = Rv.Next
|
||||
@@ -120,17 +127,36 @@ internal object FieldReader {
|
||||
if (N.Bad) return FieldReadResult("<越界>", Off, true, "长度前缀读不动")
|
||||
val End = N.Next + N.Value
|
||||
if (End > Buf.size) return FieldReadResult("<越界>", Off, true, "长度 ${N.Value} 越界")
|
||||
return if (T == "string") {
|
||||
FieldReadResult(Buf.copyOfRange(N.Next, End).decodeToString(), End, false)
|
||||
} else {
|
||||
FieldReadResult("0x" + Buf.copyOfRange(N.Next, End).Hex(), End, false)
|
||||
}
|
||||
val Raw = Buf.copyOfRange(N.Next, End)
|
||||
if (T != "string") return FieldReadResult("0x" + Raw.Hex(), End, false)
|
||||
return ReadString(Raw, Off, End, Ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// 模型类 / 裸类名:M1 的协议表不含 codec_fields,无法就地展开 → 如实报不可解
|
||||
// 模型类 / 裸类名:解析顺序与 lib_space_codec.js 的 ReadValue 完全一致 ——
|
||||
// ① 字段自带的 TypeCodec(在导出的 codec 表里)→ 就地展开该 codec
|
||||
// ② 模型真名 → codec(CodecByModelName)
|
||||
// ③ 协议核心类 / 资源类直译(WireClasses → TypeAliases)
|
||||
// 三条都不命中就如实报「无 codec」,不做任何长度猜测。
|
||||
val Model = ModelCodec.ResolveCodec(T, TypeCodec)
|
||||
if (Model != null) return ModelCodec.ReadFields(Buf, Off, Model, Depth + 1, Ctx)
|
||||
val Wire = ProtocolTables.WireAlias(T)
|
||||
if (Wire != null) return ReadValue(Buf, Off, Wire, "", Depth + 1, Ctx)
|
||||
val Hint = if (TypeCodec.isNotEmpty()) "(TypeCodec=$TypeCodec)" else ""
|
||||
return FieldReadResult("<无 codec:$T>", Off, true, "类型 $T$Hint 需要 codec_fields 定义,M1 协议表只导出字段类型、不含嵌套 codec")
|
||||
return FieldReadResult("<无 codec:$T>", Off, true, "类型 $T$Hint 没有对应 codec(导出表里查不到,不猜)")
|
||||
}
|
||||
|
||||
// string 的解码口径(对照 lib_space_codec.js:263-278):
|
||||
// 合法 UTF-8(解码再编码逐字节相同)→ 字面字符串;
|
||||
// 否则 StringLiteralOnly 路径如实报 Bad(避免与本身就是 "0xff8000" 的字面量撞车),
|
||||
// 字段表路径退回 '0x…' 十六进制转义。
|
||||
private fun ReadString(Raw: ByteArray, Off: Int, End: Int, Ctx: ReadCtx?): FieldReadResult {
|
||||
val Str = Raw.decodeToString()
|
||||
if (Str.encodeToByteArray().contentEquals(Raw)) return FieldReadResult(Str, End, false)
|
||||
if (Ctx != null && Ctx.StringLiteralOnly) {
|
||||
return FieldReadResult("<不是合法 UTF-8>", Off, true, "字符串值不是合法 UTF-8(本路径不做 0x 转义,避免与字面量 0x… 撞车)")
|
||||
}
|
||||
return FieldReadResult("0x" + Raw.Hex(), End, false)
|
||||
}
|
||||
|
||||
// 取形如 `name<...>` 的内层类型串;不匹配返回 null
|
||||
@@ -167,6 +193,7 @@ internal object FieldReader {
|
||||
is Double -> if (V == V.toLong().toDouble()) V.toLong().toString() else V.toString()
|
||||
is Float -> V.toString()
|
||||
is List<*> -> "[${V.size}]" + if (V.size <= 6) " " + V.joinToString(", ") { FormatValue(it) } else ""
|
||||
is Map<*, *> -> "{" + V.entries.joinToString(", ") { "${it.key}=" + FormatValue(it.value) } + "}"
|
||||
else -> V.toString()
|
||||
}
|
||||
}
|
||||
@@ -62,16 +62,23 @@ internal object Frame {
|
||||
return Result
|
||||
}
|
||||
|
||||
// 解压帧体(对应 link.js 的 DecodeFrame)。M1 不实现 zlib 解压:遇到压缩帧直接抛错,绝不静默。
|
||||
// 解压帧体(对应 link.js 的 DecodeFrame)。
|
||||
// Node 下走 zlib.inflateSync(与 link.js 的 zlib.inflateSync 同一套算法,逐字节一致);
|
||||
// browser 侧没有 zlib 绑定,遇到压缩帧如实抛错,绝不静默返回半截数据。
|
||||
fun DecodeFrame(Frame: WireFrame): ByteArray {
|
||||
if (Frame.Compressed) {
|
||||
if (!Frame.Compressed) return Frame.Body
|
||||
if (!IsNodeRuntime()) {
|
||||
throw ProtocolException(
|
||||
"M1 未实现解压:收到 zlib 压缩帧(帧体 ${Frame.Body.size} 字节,首字节 0x" +
|
||||
"browser 侧未接 zlib:收到 zlib 压缩帧(帧体 ${Frame.Body.size} 字节,首字节 0x" +
|
||||
(Frame.Body.firstOrNull()?.let { (it.toInt() and 0xff).toString(16).padStart(2, '0') } ?: "?") +
|
||||
")。真实服的握手后帧常是压缩的,M1 只处理明文帧。"
|
||||
")。当前只处理明文帧。"
|
||||
)
|
||||
}
|
||||
return Frame.Body
|
||||
// 注意:不能把 Frame.Body 直接写进 js(...) —— Kotlin/JS 会给属性名加后缀(Body_xxx),
|
||||
// 而 js() 里的代码是原样发出的 JS,必须先把值取到普通局部变量再引用。
|
||||
val Raw = Frame.Body
|
||||
val Inflated = NodeRequire("zlib").inflateSync(js("Buffer.from(Raw)"))
|
||||
return BufferToByteArray(Inflated)
|
||||
}
|
||||
|
||||
// 保活帧 = 负载为空的帧(实测 3 字节:00 01 00)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// ModelCodec.kt —— 模型 codec 的字段展开(口径逐条对照 3DTank/tools/lib_space_codec.js 的
|
||||
// ReadValue 尾部分支与 ReadFields)
|
||||
//
|
||||
// 什么时候会走到这里:容器 pt 的每一项(container.js 的 ReadLtItem:id 是 modelId →
|
||||
// 就地展开该模型的字段),以及普通字段里类型是「模型类」的字段(字段自带 TypeCodec)。
|
||||
//
|
||||
// 解析顺序(必须与 lib_space_codec.js 一致,否则同一个字节流会解出不同的边界):
|
||||
// ① 字段的 TypeCodec 命中导出的 codec 表 → 用该 codec
|
||||
// ② 类型名命中「模型真名 → codec」(CodecByModelName)→ 用该 codec
|
||||
// ③ 命中「协议核心类 / 资源类 → 线上基础类型」直译(FieldReader 里做)
|
||||
// 三条都不命中 → FieldReader 如实报「无 codec」,绝不用长度或字节数去猜。
|
||||
package Protocol
|
||||
|
||||
internal object ModelCodec {
|
||||
|
||||
// 命中 ①② 就返回 codec 键;都不命中返回 null(③ 与报错交给 FieldReader)
|
||||
fun ResolveCodec(Type: String, TypeCodec: String): String? {
|
||||
if (TypeCodec.isNotEmpty() && ProtocolTables.Codec(TypeCodec) != null) return TypeCodec
|
||||
return ProtocolTables.CodecByModelName(Type)
|
||||
}
|
||||
|
||||
// 按 codec 键就地展开字段(无长度前缀)。字段名口径同 lib_space_codec.js:306:
|
||||
// 匿名字段(表里没有名字)用 `(匿名#序)` 区分,否则同名会互相覆盖。
|
||||
// 中途解不动:返回已解出的部分 + Bad + 带字段名/类型/相对偏移的原因串(调用方据此停下并如实标注)。
|
||||
fun ReadFields(Buf: ByteArray, Off: Int, CodecKey: String, Depth: Int, Ctx: ReadCtx?): FieldReadResult {
|
||||
val Fields = ProtocolTables.Codec(CodecKey)
|
||||
?: return FieldReadResult(null, Off, true, "codec 键 $CodecKey 不在导出表里(闭包裁剪掉了,不猜)")
|
||||
val Obj = LinkedHashMap<String, Any?>()
|
||||
var At = Off
|
||||
for (F in Fields) {
|
||||
val R = FieldReader.ReadValue(Buf, At, F.Type, F.TypeCodec, Depth, Ctx)
|
||||
Obj[F.Name] = R.Value
|
||||
if (R.Bad) {
|
||||
val Why = "字段 ${F.Name}(${F.Type})在 +${At - Off} 处解不动" + (if (R.Why.isNotEmpty()) ":${R.Why}" else "")
|
||||
return FieldReadResult(Obj, At, true, Why)
|
||||
}
|
||||
At = R.Next
|
||||
}
|
||||
return FieldReadResult(Obj, At, false)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,9 @@ internal class DownlinkCommandSpec(
|
||||
internal class ProtocolSelection(
|
||||
val DownlinkMethodIds: Int = 0,
|
||||
val UplinkMethodIds: Int = 0,
|
||||
val CodecCount: Int = 0,
|
||||
val CodecByModelIdCount: Int = 0,
|
||||
val CodecByMethodCount: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -53,10 +56,25 @@ internal class ProtocolTablesData(
|
||||
val PrimitiveWidths: Map<String, Int> = emptyMap(),
|
||||
val VarintPrefixed: List<String> = emptyList(),
|
||||
val Selection: ProtocolSelection = ProtocolSelection(),
|
||||
// 模型 codec:键(module_xxx::Yy)→ 字段表。每个字段是一行 [字段名, 线类型串, TypeCodec]
|
||||
val Codecs: Map<String, List<List<String>>> = emptyMap(),
|
||||
// pt.Items 的 modelId → codec 键(容器 ReadLtItem 的展开入口)
|
||||
val CodecByModelId: Map<String, String> = emptyMap(),
|
||||
// methodId → codec 键(downlink_table 里没有的字段型记录靠它兜底,口径同 codec.js 的 ResolveFields)
|
||||
val CodecByMethod: Map<String, String> = emptyMap(),
|
||||
// 模型真名 → codec 键(字段 TypeCodec 为空时的第二解析路径,口径同 lib_space_codec.js 的 ReadValue)
|
||||
val CodecByModelName: Map<String, String> = emptyMap(),
|
||||
// 协议核心类 → 线上基础类型(codec_fields.WireClasses)
|
||||
val WireClasses: Map<String, String> = emptyMap(),
|
||||
// 裸类名 → 线上基础类型(ResourceClasses ∪ 手写表,手写优先;口径同 container.js 的 Alias)
|
||||
val TypeAliases: Map<String, String> = emptyMap(),
|
||||
val UplinkByMethod: Map<String, UplinkCommandSpec> = emptyMap(),
|
||||
val DownlinkByMethod: Map<String, DownlinkCommandSpec> = emptyMap(),
|
||||
)
|
||||
|
||||
// 模型 codec 的一个字段(只保留解码必需的三个属性)
|
||||
internal class CodecField(val Name: String, val Type: String, val TypeCodec: String)
|
||||
|
||||
internal object ProtocolTables {
|
||||
|
||||
// 复用同一个 Json 实例(每次新建会被编译器告警,且解析多份表时浪费)
|
||||
@@ -67,10 +85,30 @@ internal object ProtocolTables {
|
||||
JsonFormat.decodeFromString(ProtocolTablesData.serializer(), ProtocolTablesJsonText)
|
||||
}
|
||||
|
||||
// codec 键 → 字段表(懒解析,避免每次 lookup 都切分 List<List<String>>)
|
||||
private val CodecFieldsByKey: Map<String, List<CodecField>> by lazy {
|
||||
Data.Codecs.mapValues { (_, Rows) ->
|
||||
Rows.map { Row -> CodecField(Row.getOrElse(0) { "" }, Row.getOrElse(1) { "" }, Row.getOrElse(2) { "" }) }
|
||||
}
|
||||
}
|
||||
|
||||
fun Downlink(MethodId: String): DownlinkCommandSpec? = Data.DownlinkByMethod[MethodId]
|
||||
|
||||
fun Uplink(MethodId: String): UplinkCommandSpec? = Data.UplinkByMethod[MethodId]
|
||||
|
||||
// 按 codec 键取字段表;表里没有就返回 null(解到就如实报「无 codec」,不猜)
|
||||
fun Codec(Key: String): List<CodecField>? = CodecFieldsByKey[Key]
|
||||
|
||||
fun CodecByModelId(ModelId: String): String? = Data.CodecByModelId[ModelId]
|
||||
|
||||
fun CodecByMethod(MethodId: String): String? = Data.CodecByMethod[MethodId]
|
||||
|
||||
fun CodecByModelName(ModelName: String): String? = Data.CodecByModelName[ModelName]
|
||||
|
||||
// 裸类名 → 线上基础类型:先查协议核心类(WireClasses),再查直译表(TypeAliases)。
|
||||
// 顺序与 lib_space_codec.js 的 ReadValue 一致(WireClasses 在前,TypeAlias 在后)。
|
||||
fun WireAlias(Type: String): String? = Data.WireClasses[Type] ?: Data.TypeAliases[Type]
|
||||
|
||||
// 帧体在 At 处的 8 字节是不是一条已知的上行 methodId(与 dispatch.js 的 IsSpaceFrame 同口径)
|
||||
fun IsUplinkMethod(Body: ByteArray, At: Int): Boolean {
|
||||
if (At + 16 > Body.size) return false
|
||||
|
||||
@@ -5,4 +5,4 @@ package Protocol
|
||||
|
||||
// 协议表原始 JSON 文本(紧凑格式,仅此一处副本进入编译产物)
|
||||
internal const val ProtocolTablesJsonText: String =
|
||||
"{\"Note\":\"客户端协议表,由 client/tools/ExportProtocolTables.mjs 从 3DTank/names/ 生成,请勿手改。UplinkByMethod 源 uplink_table.json,DownlinkByMethod 源 downlink_table.json(含 Containers 段)。只挑选 M1/M2 目标空间(names/space-init-*.json、space-session-*.json)里出现过的 methodId 与全部容器方法,不含 codec_fields/regdump 等其它表。线格式口径: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\":\"2026-09-18T01:48:42.185Z\",\"Source\":\"3DTank/names/{uplink_table.json,downlink_table.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\":31,\"UplinkMethodIds\":7},\"UplinkByMethod\":{\"1079642233486876258\":{\"Name\":\"M_.fcn\",\"Module\":\"module_85196\",\"OwnerClass\":\"M_\",\"SendFn\":\"fcn\",\"Fields\":[{\"Order\":1,\"Slot\":\"ycm_1\",\"Name\":\"\",\"Type\":\"i64\",\"TypeCodec\":\"\"}]},\"1816792453857564692\":{\"Name\":\"G.q1wd\",\"Module\":\"module_29554\",\"OwnerClass\":\"G\",\"SendFn\":\"q1wd\",\"Fields\":[{\"Order\":1,\"Slot\":\"o1wd_1\",\"Name\":\"\",\"Type\":\"i32\",\"TypeCodec\":\"\"}]},\"2528403217084400809\":{\"Name\":\"ls.xbl\",\"Module\":\"module_85667\",\"OwnerClass\":\"ls\",\"SendFn\":\"xbl\",\"Fields\":[]},\"4428603232159847900\":{\"Name\":\"Qh.ycw\",\"Module\":\"module_85196\",\"OwnerClass\":\"Qh\",\"SendFn\":\"ycw\",\"Fields\":[]},\"4795202316771985007\":{\"Name\":\"M_.hcn\",\"Module\":\"module_85196\",\"OwnerClass\":\"M_\",\"SendFn\":\"hcn\",\"Fields\":[{\"Order\":1,\"Slot\":\"ccn_1\",\"Name\":\"\",\"Type\":\"i64\",\"TypeCodec\":\"\"}]},\"7806260029063678398\":{\"Name\":\"ba.lcr\",\"Module\":\"module_85196\",\"OwnerClass\":\"ba\",\"SendFn\":\"lcr\",\"Fields\":[]},\"8060894096836849355\":{\"Name\":\"ha.gcq\",\"Module\":\"module_85196\",\"OwnerClass\":\"ha\",\"SendFn\":\"gcq\",\"Fields\":[{\"Order\":1,\"Slot\":\"bcq_1\",\"Name\":\"\",\"Type\":\"i64\",\"TypeCodec\":\"\"},{\"Order\":2,\"Slot\":\"ccq_1\",\"Name\":\"\",\"Type\":\"i32\",\"TypeCodec\":\"\"},{\"Order\":3,\"Slot\":\"dcq_1\",\"Name\":\"\",\"Type\":\"i32\",\"TypeCodec\":\"\"},{\"Order\":4,\"Slot\":\"ecq_1\",\"Name\":\"\",\"Type\":\"enum<Gu>\",\"TypeCodec\":\"module_85667::Ve\"}]}},\"DownlinkByMethod\":{\"237130683810296524\":{\"Name\":\"Fn\",\"Kind\":\"fields\",\"Layout\":\"\",\"Module\":\"module_10283\",\"OwnerClass\":\"Fn\",\"Fields\":[{\"Order\":1,\"Slot\":\"jh4_1\",\"Name\":\"hash\",\"Type\":\"string\",\"TypeCodec\":\"\"}]},\"761463638726320835\":{\"Name\":\"ki\",\"Kind\":\"fields\",\"Layout\":\"\",\"Module\":\"module_10283\",\"OwnerClass\":\"ki\",\"Fields\":[]},\"1246109053620123806\":{\"Name\":\"oi\",\"Kind\":\"fields\",\"Layout\":\"\",\"Module\":\"module_25377\",\"OwnerClass\":\"oi\",\"Fields\":[{\"Order\":1,\"Slot\":\"ddn_1\",\"Name\":\"users\",\"Type\":\"list<UidNotifierData>\",\"TypeCodec\":\"module_25377::Tt\"}]},\"1247179769701109529\":{\"Name\":\"ki\",\"Kind\":\"fields\",\"Layout\":\"\",\"Module\":\"module_10283\",\"OwnerClass\":\"ki\",\"Fields\":[{\"Order\":1,\"Slot\":\"chc_1\",\"Name\":\"advisedUids\",\"Type\":\"list<string>\",\"TypeCodec\":\"\"}]},\"1247179769701225356\":{\"Name\":\"ki\",\"Kind\":\"fields\",\"Layout\":\"\",\"Module\":\"module_10283\",\"OwnerClass\":\"ki\",\"Fields\":[]},\"1936068342294351641\":{\"Name\":\"N_\",\"Kind\":\"fields\",\"Layout\":\"\",\"Module\":\"module_85196\",\"OwnerClass\":\"N_\",\"Fields\":[]},\"2025886178375810581\":{\"Name\":\"gn\",\"Kind\":\"fields\",\"Layout\":\"\",\"Module\":\"module_10283\",\"OwnerClass\":\"gn\",\"Fields\":[{\"Order\":1,\"Slot\":\"\",\"Name\":\"\",\"Type\":\"i64\",\"TypeCodec\":\"\"}]},\"2968780118652565413\":{\"Name\":\"Dm\",\"Kind\":\"fields\",\"Layout\":\"\",\"Module\":\"module_47243\",\"OwnerClass\":\"Dm\",\"Fields\":[{\"Order\":1,\"Slot\":\"nft_1\",\"Name\":\"questIds\",\"Type\":\"list<i64>\",\"TypeCodec\":\"\"},{\"Order\":2,\"Slot\":\"oft_1\",\"Name\":\"questCategoriesIds\",\"Type\":\"list<i64>\",\"TypeCodec\":\"\"},{\"Order\":3,\"Slot\":\"pft_1\",\"Name\":\"completedQuestIds\",\"Type\":\"list<i64>\",\"TypeCodec\":\"\"},{\"Order\":4,\"Slot\":\"qft_1\",\"Name\":\"completedQuestCategoriesIds\",\"Type\":\"list<i64>\",\"TypeCodec\":\"\"}]},\"3182939728106937130\":{\"Name\":\"hr\",\"Kind\":\"fields\",\"Layout\":\"\",\"Module\":\"module_89803\",\"OwnerClass\":\"hr\",\"Fields\":[]},\"3216143066888387731\":{\"Name\":\"ModelService.LoadDependencies\",\"Kind\":\"container\",\"Layout\":\"zt\",\"Module\":\"\",\"OwnerClLine truncated
|
||||
"{\"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\":\"2026-09-18T01:58:44.437Z\",\"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\":31,\"UplinkMethodIds\":7,\"CodecCount\":559,\"CodecByModelIdCount\":356,\"CodecByMethodCount\":432},\"WireClasses\":{\"R\":\"i64\",\"xr\":\"i64\"},\"TypeAliases\":{\"B_\":\"i64\",\"Da\":\"i64\",\"Ea\":\"i64\",\"Gf\":\"i64\",\"Pc\":\"i64\",\"W_\":\"i64\",\"kv\":\"i64\",\"nh\":\"i64\",\"no\":\"i64\",\"pv\":\"i64\",\"qs\":\"i64\",\"uh\":\"i64\",\"uo\":\"i64\",\"zv\":\"i64\"},\"CodecByModelId\":{\"104562115846147942\":\"module_89803::Wt\",\"120961863156462946\":\"module_85196::Du\",\"149643551432886459\":\"module_85196::m_\",\"171896995500198357\":\"module_85183::Sm\",\"213661725715747381\":\"module_85196::gs\",\"216902005816818019\":\"module_31908::Jn\",\"258682513334072065\":\"module_25377::St\",\"260111248825415414\":\"module_85183::Mb\",\"313670832122918648\":\"module_85183::Qw\",\"433350986715773690\":\"module_88693::it\",\"462434031489766566\":\"module_47243::o_\",\"466296741210741928\":\"module_10283::rt\",\"481504601146841174\":\"module_85196::mo\",\"510571369984788727\":\"module_85183::Go\",\"513176302374590130\":\"module_10283::wt\",\"573691036321759779\":\"module_85183::wp\",\"649690707811173020\":\"module_37504::\$t\",\"658797348514407988\":\"module_85196::Fs\",\"693025797100292228\":\"module_47243::zh\",\"701801920209410257\":\"module_85196::Zo\",\"712090483864327130\":\"module_85196::wo\",\"732152133982227177\":\"module_85183::ip\",\"770654516236272028\":\"module_85183::jm\",\"787936358514072261\":\"module_31908::Ei\",\"806128205466208462\":\"module_47243::Wa\",\"821863604528384663\":\"module_88693::lt\",\"834829861153964627\":\"module_85196::q_\",\"844040023906032853\":\"module_85183::fl\",\"868364325972433765\":\"module_54199::mt\",\"871785774630866042\":\"module_37504::\$t\",\"881620222948704326\":\"module_37504::\$t\",\"887431150717814637\":\"module_85183::Hy\",\"889485403566576664\":\"module_47243::\$a\",\"891137447706578619\":\"module_54199::ft\",\"905970599512839002\":\"module_85196::q_\",\"920233600808088339\":\"module_85183::Ry\",\"965113061940997187\":\"module_47243::Oe\",\"973781565169577734\":\"module_85183::pk\",\"994751080759166914\":\"module_89803::ht\",\"1009501302658881534\":\"module_47243::Tu\",\"1036461397335537760\":\"module_47243::Jh\",\"1090112775751802907\":\"module_85183::ol\",\"1092307613749405757\":\"module_85183::Np\",\"1115275516484037832\":\"module_37504::Yt\",\"1130027771466355435\":\"module_85183::Mp\",\"1139677084511624062\":\"module_85196::Ps\",\"1164458258646872337\":\"module_85196::j_\",\"1237976996871886061\":\"module_85183::Rv\",\"1259328549938381181\":\"module_85196::wu\",\"1298753286651761171\":\"module_25377::jt\",\"1417398836682569292\":\"module_47243::Su\",\"1456475022890566348\":\"module_47243::O_\",\"1472449384337994019\":\"module_99527::N\",\"1475210252818583689\":\"module_85183::c_\",\"1500120709087433677\":\"module_85183::Wm\",\"1534251830977989099\":\"module_85196::rs\",\"1535852183188388854\":\"module_47243::pa\",\"1661364280719338177\":\"module_85183::a_\",\"1663344340563853103\":\"module_85183::yw\",\"1688856467034284073\":\"module_85183::yh\",\"1748249573394295263\":\"module_85183::Df\",\"1767155986856291742\":\"module_85183::zb\",\"1782954502314814229\":\"module_85183::Mv\",\"1838248562944554574\":\"module_85183::mb\",\"1850509145998626828\":\"module_25377::wt\",\"1851605205665890036\":\"module_47243::Mc\",\"1865478986390468300\":\"module_85196::_s\",\"1893408944113965505\":\"module_47243::Us\",\"1986821874929333245\":\"module_Line truncated
|
||||
@@ -1,8 +1,9 @@
|
||||
// SelfTest.kt —— M1 协议层自检(nodejs 目标下无头运行)
|
||||
// SelfTest.kt —— 协议层自检(nodejs 目标下无头运行)
|
||||
//
|
||||
// 链路:连 ws://localhost:9091/ → 发 Hash Request → 收 Hash Response(解出 hash/加密标志)
|
||||
// → 发 Auth/EnterSpace(100045260) → 收初始化帧并逐条打印记录
|
||||
// → 收到至少 1 条服务端保活 ping 后正常退出。
|
||||
// M2 起:容器记录(lt/pt/zt)能定界并解成 JSON,自检打印摘要与一个完整对象的 JSON。
|
||||
package Protocol
|
||||
|
||||
internal object SelfTest {
|
||||
@@ -42,10 +43,13 @@ internal object SelfTest {
|
||||
private var Finished = false
|
||||
|
||||
fun Run() {
|
||||
Log("=== 3DTank 客户端 M1 自检(nodejs 目标)===")
|
||||
Log("=== 3DTank 客户端 M2 自检(nodejs 目标)===")
|
||||
Log("目标服务器 $Url 目标空间 $TargetSpaceId")
|
||||
val Tables = ProtocolTables.Data
|
||||
Log("协议表 GeneratedAt=${Tables.GeneratedAt} 下行方法 ${Tables.Selection.DownlinkMethodIds} 条 / 上行方法 ${Tables.Selection.UplinkMethodIds} 条")
|
||||
Log(
|
||||
"协议表 GeneratedAt=${Tables.GeneratedAt} 下行方法 ${Tables.Selection.DownlinkMethodIds} 条 / " +
|
||||
"上行方法 ${Tables.Selection.UplinkMethodIds} 条 / 模型 codec ${Tables.Selection.CodecCount} 个"
|
||||
)
|
||||
Log("")
|
||||
|
||||
val C = Connection(Url)
|
||||
@@ -93,7 +97,8 @@ internal object SelfTest {
|
||||
return
|
||||
}
|
||||
val MethodIdAt8 = if (At + 16 <= Body.size) Body.ReadI64(At + 8).toString() else ""
|
||||
if (ProtocolTables.Downlink(MethodIdAt8) != null) {
|
||||
// 空间帧判据(口径同 M1,M2 追加 codec_fields 兜底):methodId 能在任一协议表里查到就是空间记录流
|
||||
if (ProtocolTables.Downlink(MethodIdAt8) != null || ProtocolTables.CodecByMethod(MethodIdAt8) != null) {
|
||||
PrintSpaceFrame(TotalBytes, Body, NmSize)
|
||||
return
|
||||
}
|
||||
@@ -107,20 +112,40 @@ internal object SelfTest {
|
||||
val R = Parsed.Records[I]
|
||||
val Spec = ProtocolTables.Downlink(R.MethodId)
|
||||
val Known = when {
|
||||
Spec == null -> "未知"
|
||||
Spec == null -> if (R.Kind == "fields") "(codec_fields)" else "未知"
|
||||
Spec.Module.isNotEmpty() -> "${Spec.Name} (${Spec.Module})"
|
||||
else -> Spec.Name
|
||||
}
|
||||
val Flag = if (R.PayloadKnown) "" else "(负载边界未定:${if (R.Kind == "container") "容器" else "未知"},按剩余字节整块给出)"
|
||||
val Flag = if (R.PayloadKnown) "" else "(负载边界未定:按剩余字节整块给出)"
|
||||
Log(" [$I] objectId=${R.ObjectId} | methodId=${R.MethodId} | $Known | 负载 ${R.Payload.size} 字节 $Flag")
|
||||
if (R.Kind == "container") {
|
||||
Log(" 布局 ${R.Layout} 摘要:${Container.Summary(R.Layout, R.Value)}")
|
||||
Log(" 对象样例 JSON:${ContainerSample(R.Layout, R.Value)}")
|
||||
}
|
||||
if (R.Fields.isNotEmpty()) {
|
||||
for (F in R.Fields) Log(" ${F.first} = ${F.second}")
|
||||
for (F in R.Fields) Log(" ${F.first} = ${FieldReader.FormatValue(F.second)}")
|
||||
}
|
||||
if (R.Note.isNotEmpty()) Log(" ⚠ ${R.Note}")
|
||||
}
|
||||
if (Parsed.Note.isNotEmpty()) Log(" ⚠ ${Parsed.Note}")
|
||||
}
|
||||
|
||||
// 至少打印一个完整对象的 JSON:pt 打印第一个对象引用与第一个模型项,zt 打印第一批,lt 打印整个负载
|
||||
private fun ContainerSample(Layout: String, Value: Any?): String {
|
||||
val V = Value as? Map<*, *> ?: return "(无)"
|
||||
return when (Layout) {
|
||||
"pt" -> {
|
||||
val Objects = V["Objects"] as? List<*> ?: emptyList<Any?>()
|
||||
val Items = V["Items"] as? List<*> ?: emptyList<Any?>()
|
||||
val Model = if (Items.size > 1) Items[1] else Items.firstOrNull()
|
||||
"第一个对象=" + CanonicalJson.Text(Objects.firstOrNull()) + " 第一个模型项=" + CanonicalJson.Text(Model)
|
||||
}
|
||||
"zt" -> "第一批=" + CanonicalJson.Text((V["Batches"] as? List<*>)?.firstOrNull())
|
||||
"lt" -> CanonicalJson.Text(V)
|
||||
else -> "(无)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun PrintEntryFrame(TotalBytes: Int, Body: ByteArray, NmSize: Int) {
|
||||
val Cmds = Entry.DecodeServerBody(Body)
|
||||
Log("← s2c 帧#$FrameNo 整帧 $TotalBytes 字节 | nullmap $NmSize 字节 | 入口命令 ${Cmds.size} 条")
|
||||
@@ -139,8 +164,8 @@ internal object SelfTest {
|
||||
if (Finished || PingCount < 1) return
|
||||
Finished = true
|
||||
Log("")
|
||||
Log("✓ M1 自检通过:入口层握手 → 进空间 → 收到初始化帧 → 收到 $PingCount 条服务端保活 ping")
|
||||
Conn?.Close(1000, "M1 自检完成")
|
||||
Log("✓ M2 自检通过:入口层握手 → 进空间 → 收到初始化帧(含容器记录)→ 收到 $PingCount 条服务端保活 ping")
|
||||
Conn?.Close(1000, "M2 自检完成")
|
||||
SetTimeout(100) { ExitProcess(0) }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
// 帧体 = [nullmap][记录…]
|
||||
// 记录 = [objectId i64][methodId i64][负载](每条记录自带 objectId;nullmap 里一条记录 1 个形态位,
|
||||
// bit=0 → 长记录,bit=1 → 1 字节短命令号——短命令号 → (objectId, methodId) 的查表数据还没挖出来,
|
||||
// M1 遇到短命令形态如实报不可解并停止)
|
||||
// 负载长度:由该 s2c 方法的有序字段表逐字段推进得出(opt 字段消费 nullmap 的 bit)。
|
||||
// 容器型记录(ModelService 的 lt/pt/zt/alias)M1 不实现容器解码 ⇒ 负载长度无法确定,
|
||||
// 按「帧体剩余全部字节」切片并如实标注;因此容器记录之后的记录不再解析。
|
||||
// 遇到短命令形态如实报不可解并停止)
|
||||
//
|
||||
// 负载边界的判定(M2 起容器也定界了):
|
||||
// · 字段型记录:按该 s2c 方法的有序字段表逐字段推进得出(opt 字段消费 nullmap 的 bit)。
|
||||
// 字段表来源与 semantic.js 的 FieldsOf 同序:downlink_table 优先,其次 codec_fields(CodecByMethod)。
|
||||
// · 容器型记录(ModelService 的 lt/pt/zt):**由 methodId 定布局**,按布局算出 Next,
|
||||
// 负载 = [Off+16, Next),解完继续解下一条记录(M1 的「剩余全部字节」切片已废弃)。
|
||||
// · 解不动(字段越界 / 无 codec / 容器读失败):按 M1 的规则停下,并如实标注哪条记录、
|
||||
// 差多少字节、为什么;剩余字节整体当作它的负载(不丢字节,也不假装解出来了)。
|
||||
package Protocol
|
||||
|
||||
internal class SpaceRecord(
|
||||
@@ -19,7 +24,8 @@ internal class SpaceRecord(
|
||||
val PayloadKnown: Boolean, // true = 负载边界是算出来的;false = 按「剩余全部字节」切片
|
||||
val Bad: Boolean,
|
||||
val Note: String,
|
||||
val Fields: List<Pair<String, String>>, // 字段名 → 值文本(仅 fields 记录)
|
||||
val Fields: List<Pair<String, Any?>>, // 字段名 → 原始值(仅 fields 记录)
|
||||
val Value: Any?, // 容器记录的解码值(仅 container;null = 没解出来)
|
||||
)
|
||||
|
||||
internal class SpaceFrameBody(
|
||||
@@ -57,7 +63,7 @@ internal object SpaceFrame {
|
||||
if (Bits[Index]) {
|
||||
Bad = true
|
||||
Note = "第 ${Records.size + 1} 条记录是短命令形态(nullmap 第 $Index 个 bit=1,1 字节命令号 0x" +
|
||||
(Body.ReadU8(Off).toString(16).padStart(2, '0')) + "):短命令号查表数据还没挖出来,M1 判定不了"
|
||||
(Body.ReadU8(Off).toString(16).padStart(2, '0')) + "):短命令号查表数据还没挖出来,判定不了"
|
||||
break
|
||||
}
|
||||
Index++ // 形态位 = 长记录
|
||||
@@ -70,13 +76,57 @@ internal object SpaceFrame {
|
||||
val MethodId = Body.ReadI64(Off + 8).toString()
|
||||
val Spec = ProtocolTables.Downlink(MethodId)
|
||||
|
||||
if (Spec == null) {
|
||||
// 容器型记录:布局由 methodId 决定,负载边界算得出来 → 解完继续解下一条
|
||||
if (Spec != null && Spec.Kind == "container") {
|
||||
val Cursor = BitCursor(Bits, Index)
|
||||
val R = Container.Read(Spec.Layout, Body, Off + 16, Cursor)
|
||||
Index = Cursor.Index
|
||||
if (R.Bad) {
|
||||
Records.add(
|
||||
SpaceRecord(
|
||||
ObjectId, MethodId, Spec.Name, "container", Spec.Layout,
|
||||
Body.copyOfRange(Off + 16, Body.size), false, true,
|
||||
"容器布局 ${Spec.Layout} 在 +${R.Next - (Off + 16)} 处读失败:${R.Why}" +
|
||||
"(记录边界无从判定,剩余 ${Body.size - (Off + 16)} 字节整体当作它的负载)",
|
||||
emptyList(), null,
|
||||
)
|
||||
)
|
||||
Bad = true
|
||||
Note = "第 ${Records.size} 条容器记录(${Spec.Name} / 布局 ${Spec.Layout})解不动:${R.Why}"
|
||||
break
|
||||
}
|
||||
Records.add(
|
||||
SpaceRecord(
|
||||
ObjectId, MethodId, "(未知)", "unknown", "",
|
||||
ObjectId, MethodId, Spec.Name, "container", Spec.Layout,
|
||||
Body.copyOfRange(Off + 16, R.Next), true, false, "", emptyList(), R.Value,
|
||||
)
|
||||
)
|
||||
Off = R.Next
|
||||
continue
|
||||
}
|
||||
|
||||
// 字段型记录:downlink_table 优先,其次 codec_fields(口径同 semantic.js 的 FieldsOf)
|
||||
val Fields: List<ProtocolField>?
|
||||
val Name: String
|
||||
if (Spec != null) {
|
||||
Fields = Spec.Fields
|
||||
Name = Spec.Name
|
||||
} else {
|
||||
val Key = ProtocolTables.CodecByMethod(MethodId)
|
||||
val CodecFields = if (Key != null) ProtocolTables.Codec(Key) else null
|
||||
Name = ""
|
||||
Fields = CodecFields?.mapIndexed { I, F -> ProtocolField(I + 1, "", F.Name, F.Type, F.TypeCodec) }
|
||||
}
|
||||
|
||||
if (Fields == null) {
|
||||
// Kind 记成 fields:与 semantic.js 的 DecodeRecord(`Kind: Spec ? Spec.Kind : 'fields'`)同口径,
|
||||
// 「没有字段表」这件事由 Bad + Note 表达,不另立一种 Kind
|
||||
Records.add(
|
||||
SpaceRecord(
|
||||
ObjectId, MethodId, "(未知)", "fields", "",
|
||||
Body.copyOfRange(Off + 16, Body.size), false, true,
|
||||
"methodId=$MethodId 不在 DownlinkByMethod(协议表只导出 M1/M2 需要的条目),负载长度无法确定",
|
||||
emptyList(),
|
||||
"methodId=$MethodId 既不在 DownlinkByMethod、也不在 CodecByMethod(协议表闭包裁剪),负载长度无法确定",
|
||||
emptyList(), null,
|
||||
)
|
||||
)
|
||||
Bad = true
|
||||
@@ -84,29 +134,16 @@ internal object SpaceFrame {
|
||||
break
|
||||
}
|
||||
|
||||
if (Spec.Kind == "container") {
|
||||
Records.add(
|
||||
SpaceRecord(
|
||||
ObjectId, MethodId, Spec.Name, "container", Spec.Layout,
|
||||
Body.copyOfRange(Off + 16, Body.size), false, false,
|
||||
"容器记录(布局 ${Spec.Layout}):M1 不做容器解码(lt/pt/zt),负载长度无法确定,按「剩余全部字节」切片",
|
||||
emptyList(),
|
||||
)
|
||||
)
|
||||
Note = "第 ${Records.size} 条是容器记录(${Spec.Name} / 布局 ${Spec.Layout}),M1 未实现容器解码"
|
||||
break
|
||||
}
|
||||
|
||||
// 字段型记录:按有序字段表逐字段推进(opt 消费 nullmap 的 bit 游标)
|
||||
val Cursor = BitCursor(Bits, Index)
|
||||
val Ctx = ReadCtx(Cursor, false)
|
||||
var At = Off + 16
|
||||
val Values = ArrayList<Pair<String, String>>()
|
||||
val Values = ArrayList<Pair<String, Any?>>()
|
||||
var RecBad = false
|
||||
var Why = ""
|
||||
for (F in Spec.Fields) {
|
||||
val R = FieldReader.ReadValue(Body, At, F.Type, F.TypeCodec, 0, Cursor)
|
||||
val Key = if (F.Name.isNotEmpty()) F.Name else "(匿名#${F.Order})"
|
||||
Values.add(Key to FieldReader.FormatValue(R.Value))
|
||||
for (F in Fields) {
|
||||
val Key = FieldKey(F)
|
||||
val R = FieldReader.ReadValue(Body, At, F.Type, F.TypeCodec, 0, Ctx)
|
||||
Values.add(Key to R.Value)
|
||||
val ZeroOk = FieldReader.IsOpt(F.Type)
|
||||
if (R.Bad || (R.Next <= At && !ZeroOk)) {
|
||||
RecBad = true
|
||||
@@ -120,10 +157,10 @@ internal object SpaceFrame {
|
||||
if (RecBad) {
|
||||
Records.add(
|
||||
SpaceRecord(
|
||||
ObjectId, MethodId, Spec.Name, "fields", "",
|
||||
ObjectId, MethodId, Name, "fields", "",
|
||||
Body.copyOfRange(Off + 16, Body.size), false, true,
|
||||
Why + "(记录边界无从判定,剩余字节整体当作它的负载)",
|
||||
Values,
|
||||
Values, null,
|
||||
)
|
||||
)
|
||||
Bad = true
|
||||
@@ -133,8 +170,8 @@ internal object SpaceFrame {
|
||||
|
||||
Records.add(
|
||||
SpaceRecord(
|
||||
ObjectId, MethodId, Spec.Name, "fields", "",
|
||||
Body.copyOfRange(Off + 16, At), true, false, "", Values,
|
||||
ObjectId, MethodId, Name, "fields", "",
|
||||
Body.copyOfRange(Off + 16, At), true, false, "", Values, null,
|
||||
)
|
||||
)
|
||||
Off = At
|
||||
@@ -143,6 +180,10 @@ internal object SpaceFrame {
|
||||
return SpaceFrameBody(Nm, Records, Bad, Note)
|
||||
}
|
||||
|
||||
// 字段键:downlink_table 里挖不到名字的字段用 `(匿名#序)`(口径同 semantic.js 的 FieldKey)
|
||||
private fun FieldKey(F: ProtocolField): String =
|
||||
if (F.Name.isNotEmpty() && F.Name != "(匿名)" && F.Name != "?") F.Name else "(匿名#${F.Order})"
|
||||
|
||||
// 组帧:记录列表 → 帧体 → 整帧(nullmap 原样带上,长度头由 Frame.EncodeFrame 补)
|
||||
fun Encode(Records: List<SpaceRecord>, Nullmap: ByteArray = byteArrayOf(0)): ByteArray {
|
||||
val W = ByteWriter()
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"Note":"客户端协议表,由 client/tools/ExportProtocolTables.mjs 从 3DTank/names/ 生成,请勿手改。UplinkByMethod 源 uplink_table.json,DownlinkByMethod 源 downlink_table.json(含 Containers 段)。只挑选 M1/M2 目标空间(names/space-init-*.json、space-session-*.json)里出现过的 methodId 与全部容器方法,不含 codec_fields/regdump 等其它表。线格式口径: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":"2026-09-18T01:48:42.185Z","Source":"3DTank/names/{uplink_table.json,downlink_table.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":31,"UplinkMethodIds":7},"UplinkByMethod":{"1079642233486876258":{"Name":"M_.fcn","Module":"module_85196","OwnerClass":"M_","SendFn":"fcn","Fields":[{"Order":1,"Slot":"ycm_1","Name":"","Type":"i64","TypeCodec":""}]},"1816792453857564692":{"Name":"G.q1wd","Module":"module_29554","OwnerClass":"G","SendFn":"q1wd","Fields":[{"Order":1,"Slot":"o1wd_1","Name":"","Type":"i32","TypeCodec":""}]},"2528403217084400809":{"Name":"ls.xbl","Module":"module_85667","OwnerClass":"ls","SendFn":"xbl","Fields":[]},"4428603232159847900":{"Name":"Qh.ycw","Module":"module_85196","OwnerClass":"Qh","SendFn":"ycw","Fields":[]},"4795202316771985007":{"Name":"M_.hcn","Module":"module_85196","OwnerClass":"M_","SendFn":"hcn","Fields":[{"Order":1,"Slot":"ccn_1","Name":"","Type":"i64","TypeCodec":""}]},"7806260029063678398":{"Name":"ba.lcr","Module":"module_85196","OwnerClass":"ba","SendFn":"lcr","Fields":[]},"8060894096836849355":{"Name":"ha.gcq","Module":"module_85196","OwnerClass":"ha","SendFn":"gcq","Fields":[{"Order":1,"Slot":"bcq_1","Name":"","Type":"i64","TypeCodec":""},{"Order":2,"Slot":"ccq_1","Name":"","Type":"i32","TypeCodec":""},{"Order":3,"Slot":"dcq_1","Name":"","Type":"i32","TypeCodec":""},{"Order":4,"Slot":"ecq_1","Name":"","Type":"enum<Gu>","TypeCodec":"module_85667::Ve"}]}},"DownlinkByMethod":{"237130683810296524":{"Name":"Fn","Kind":"fields","Layout":"","Module":"module_10283","OwnerClass":"Fn","Fields":[{"Order":1,"Slot":"jh4_1","Name":"hash","Type":"string","TypeCodec":""}]},"761463638726320835":{"Name":"ki","Kind":"fields","Layout":"","Module":"module_10283","OwnerClass":"ki","Fields":[]},"1246109053620123806":{"Name":"oi","Kind":"fields","Layout":"","Module":"module_25377","OwnerClass":"oi","Fields":[{"Order":1,"Slot":"ddn_1","Name":"users","Type":"list<UidNotifierData>","TypeCodec":"module_25377::Tt"}]},"1247179769701109529":{"Name":"ki","Kind":"fields","Layout":"","Module":"module_10283","OwnerClass":"ki","Fields":[{"Order":1,"Slot":"chc_1","Name":"advisedUids","Type":"list<string>","TypeCodec":""}]},"1247179769701225356":{"Name":"ki","Kind":"fields","Layout":"","Module":"module_10283","OwnerClass":"ki","Fields":[]},"1936068342294351641":{"Name":"N_","Kind":"fields","Layout":"","Module":"module_85196","OwnerClass":"N_","Fields":[]},"2025886178375810581":{"Name":"gn","Kind":"fields","Layout":"","Module":"module_10283","OwnerClass":"gn","Fields":[{"Order":1,"Slot":"","Name":"","Type":"i64","TypeCodec":""}]},"2968780118652565413":{"Name":"Dm","Kind":"fields","Layout":"","Module":"module_47243","OwnerClass":"Dm","Fields":[{"Order":1,"Slot":"nft_1","Name":"questIds","Type":"list<i64>","TypeCodec":""},{"Order":2,"Slot":"oft_1","Name":"questCategoriesIds","Type":"list<i64>","TypeCodec":""},{"Order":3,"Slot":"pft_1","Name":"completedQuestIds","Type":"list<i64>","TypeCodec":""},{"Order":4,"Slot":"qft_1","Name":"completedQuestCategoriesIds","Type":"list<i64>","TypeCodec":""}]},"3182939728106937130":{"Name":"hr","Kind":"fields","Layout":"","Module":"module_89803","OwnerClass":"hr","Fields":[]},"3216143066888387731":{"Name":"ModelService.LoadDependencies","Kind":"container","Layout":"zt","Module":"","OwnerClass":"","Fields":[]},"3692495084100053385":{"Name":"N_","Kind":"fields","Layout":"","Module":"module_85196","OwnerClass":"N_","Fields":[{"Order":1,"Slot":"ocl_1","Name":"mountedItems","Type":"list<i64>","TypeCodec":""}]},"3996477900528000389":{"Name":"N_","Kind":"fields","Layout":"","Module":"module_85196","OwnerClass":"N_","Fields":[{"Order":1,"Slot":"kcl_1","Name":"itemsOnDepot","Type":"list<i64>","TypeCodec":""}]},"4567332396287339500":{"Name":"Yn","Kind":"fields","Layout":"","Module":"module_25377","OwnerClass":"Yn","Fields":[{"Order":1,"Slot":"xdl_1","Name":"users","Type":"list<PremiumNotifierData>","TypeCodec":"module_25377::zt"}]},"5213829805760580947":{"Name":"N_","Kind":"fields","Line truncated
|
||||
{"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":"2026-09-18T01:58:44.437Z","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":31,"UplinkMethodIds":7,"CodecCount":559,"CodecByModelIdCount":356,"CodecByMethodCount":432},"WireClasses":{"R":"i64","xr":"i64"},"TypeAliases":{"B_":"i64","Da":"i64","Ea":"i64","Gf":"i64","Pc":"i64","W_":"i64","kv":"i64","nh":"i64","no":"i64","pv":"i64","qs":"i64","uh":"i64","uo":"i64","zv":"i64"},"CodecByModelId":{"104562115846147942":"module_89803::Wt","120961863156462946":"module_85196::Du","149643551432886459":"module_85196::m_","171896995500198357":"module_85183::Sm","213661725715747381":"module_85196::gs","216902005816818019":"module_31908::Jn","258682513334072065":"module_25377::St","260111248825415414":"module_85183::Mb","313670832122918648":"module_85183::Qw","433350986715773690":"module_88693::it","462434031489766566":"module_47243::o_","466296741210741928":"module_10283::rt","481504601146841174":"module_85196::mo","510571369984788727":"module_85183::Go","513176302374590130":"module_10283::wt","573691036321759779":"module_85183::wp","649690707811173020":"module_37504::$t","658797348514407988":"module_85196::Fs","693025797100292228":"module_47243::zh","701801920209410257":"module_85196::Zo","712090483864327130":"module_85196::wo","732152133982227177":"module_85183::ip","770654516236272028":"module_85183::jm","787936358514072261":"module_31908::Ei","806128205466208462":"module_47243::Wa","821863604528384663":"module_88693::lt","834829861153964627":"module_85196::q_","844040023906032853":"module_85183::fl","868364325972433765":"module_54199::mt","871785774630866042":"module_37504::$t","881620222948704326":"module_37504::$t","887431150717814637":"module_85183::Hy","889485403566576664":"module_47243::$a","891137447706578619":"module_54199::ft","905970599512839002":"module_85196::q_","920233600808088339":"module_85183::Ry","965113061940997187":"module_47243::Oe","973781565169577734":"module_85183::pk","994751080759166914":"module_89803::ht","1009501302658881534":"module_47243::Tu","1036461397335537760":"module_47243::Jh","1090112775751802907":"module_85183::ol","1092307613749405757":"module_85183::Np","1115275516484037832":"module_37504::Yt","1130027771466355435":"module_85183::Mp","1139677084511624062":"module_85196::Ps","1164458258646872337":"module_85196::j_","1237976996871886061":"module_85183::Rv","1259328549938381181":"module_85196::wu","1298753286651761171":"module_25377::jt","1417398836682569292":"module_47243::Su","1456475022890566348":"module_47243::O_","1472449384337994019":"module_99527::N","1475210252818583689":"module_85183::c_","1500120709087433677":"module_85183::Wm","1534251830977989099":"module_85196::rs","1535852183188388854":"module_47243::pa","1661364280719338177":"module_85183::a_","1663344340563853103":"module_85183::yw","1688856467034284073":"module_85183::yh","1748249573394295263":"module_85183::Df","1767155986856291742":"module_85183::zb","1782954502314814229":"module_85183::Mv","1838248562944554574":"module_85183::mb","1850509145998626828":"module_25377::wt","1851605205665890036":"module_47243::Mc","1865478986390468300":"module_85196::_s","1893408944113965505":"module_47243::Us","1986821874929333245":"module_37504::Xt","1987602940683756146":"module_47243::ke","1997576349992148053":"module_85183::rk","2023935033691193766":"module_37504::$t","2046257084008991040":"module_85183::ya","2057320715890332864":"module_85183::ue","2057487026153364495":"module_85183::op","2078544146946392301":"module_85183::Y_","2080556892932195426":"module_47243::Sc","2085529474881905981":"module_31908::li","2101100305743734614":"mLine truncated
|
||||
@@ -0,0 +1,280 @@
|
||||
// CompareContainerDecode.mjs —— M2 容器三布局解码的「JS ↔ Kotlin」逐字段对照(正式产物)
|
||||
//
|
||||
// 目的:证明客户端的 lt/pt/zt 读法与服务端权威实现(3DTank/tools/server/container.js,
|
||||
// 经 semantic.js 的 DecodeFrameBody 驱动)在**真实抓包帧**上逐字段一致。
|
||||
//
|
||||
// 两侧结果的产出方式:
|
||||
// · JS 侧:本脚本直接 require 服务端 container.js / semantic.js,把帧解成规范 JSON(内存对象)。
|
||||
// · Kotlin 侧:先确保 Kotlin/JS 的 node 产物是最新的(gradlew compileKotlinJs +
|
||||
// jsDevelopmentExecutableCompileSync),再用 TANKIC_CONTAINER_DUMP=1 运行它;Kotlin 把
|
||||
// 同批帧解码结果写成规范 JSON,并用 <<<TANKIC_KOTLIN_JSON>>> 标记包住(见 ContainerDump.kt)。
|
||||
//
|
||||
// 比对规范(与任务要求一致):
|
||||
// · 对象键排序后比较;
|
||||
// · 数字一律按「原始十进制字符串」比(i8..i64/f32/f64 都是;f32/f64 两侧都用 JS 的
|
||||
// String(x),所以不会出现「Kotlin 输出 0.07 / JS 输出 0.07000000029802322」这类格式化分叉);
|
||||
// · null 与「键缺失」区分开(键不存在报「<缺失>」,键存在但值为 null 报 null);
|
||||
// · 只跳过两处「非线数据」:根上的 Tool,以及帧/记录两级的 Note(两侧是各自语言写的诊断文案,
|
||||
// 不是线上的字节内容;记录是否失败由 Bad 字段判定)。
|
||||
//
|
||||
// 用法:
|
||||
// node client/tools/CompareContainerDecode.mjs # 会先构建再比对
|
||||
// node client/tools/CompareContainerDecode.mjs --no-build # 直接用现有 Kotlin 产物
|
||||
'use strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
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 ServerDir = path.join(RepoDir, '3DTank', 'tools', 'server');
|
||||
const PacketsDir = path.join(RepoDir, '3DTank', 'packets', 'plain');
|
||||
const NodeBundle = path.join(ClientDir, 'build', 'js', 'packages', 'TankIcClient', 'kotlin', 'TankIcClient.js');
|
||||
const NoBuild = process.argv.includes('--no-build');
|
||||
|
||||
// 必测帧(都在 3DTank/packets/plain/ 下,**只读**)
|
||||
const Corpus = [
|
||||
'space-100045260/s2c_0000.bin', // zt,负载 1782B(精确吃满)
|
||||
'space-100045260/s2c_0001.bin', // pt,负载 448B
|
||||
'space-100073070/s2c_0002.bin', // lt,126B = nullmap(1) + 5×(16B 头 + 9B 负载)
|
||||
'space-100073070/s2c_0004.bin', // 多记录帧:1 条 pt + 28 条 fields(M1 会丢后面的记录)
|
||||
'space-920007382011/s2c_0001.bin', // pt 记录后面还有别的记录
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------- 服务端实现(只读 require)
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Link = require(path.join(ServerDir, 'link.js'));
|
||||
const { MakeContainerCodec } = require(path.join(ServerDir, 'container.js'));
|
||||
const { LoadTables, MakeReader } = require(path.join(RepoDir, '3DTank', 'tools', 'lib_space_codec.js'));
|
||||
const { MakeWriter } = require(path.join(ServerDir, 'codec.js'));
|
||||
const Semantic = require(path.join(ServerDir, 'semantic.js'));
|
||||
|
||||
const Tables = LoadTables(NamesDir);
|
||||
const Container = MakeContainerCodec(Tables, MakeReader(Tables, { EnumBytes: 4 }), MakeWriter(Tables, { EnumBytes: 4 }));
|
||||
const Sem = Semantic.Load(NamesDir);
|
||||
// 断言 semantic 用的确实是 container.js 的这套布局表(不是另一份拷贝)
|
||||
const SameLayoutTable = Sem.Container.LAYOUT_BY_METHOD === Container.LAYOUT_BY_METHOD;
|
||||
|
||||
// ---------------------------------------------------------------- 规范化
|
||||
|
||||
// 与 Kotlin 侧 CanonicalJson 同口径:数字 → 十进制字符串,对象键排序,null 保留
|
||||
function Canon(V) {
|
||||
if (V === undefined) return undefined;
|
||||
if (V === null) return null;
|
||||
if (typeof V === 'bigint') return V.toString();
|
||||
if (typeof V === 'number') return String(V);
|
||||
if (typeof V === 'boolean' || typeof V === 'string') return V;
|
||||
if (Array.isArray(V)) return V.map(Canon);
|
||||
if (typeof V === 'object') {
|
||||
const Out = {};
|
||||
for (const K of Object.keys(V).sort()) Out[K] = Canon(V[K]);
|
||||
return Out;
|
||||
}
|
||||
return String(V);
|
||||
}
|
||||
|
||||
function JsRecord(R) {
|
||||
const Out = {
|
||||
ObjectId: R.ObjectId,
|
||||
MethodId: R.MethodId,
|
||||
Kind: R.Kind,
|
||||
Layout: R.Layout || '',
|
||||
Bad: !!R.Bad,
|
||||
Note: R.Note || '',
|
||||
};
|
||||
if (R.Kind === 'container') Out.Value = Canon(R.Value === undefined ? null : R.Value);
|
||||
else if (R.Kind === 'fields') {
|
||||
const F = {};
|
||||
for (const X of R.Fields || []) F[X.Name] = Canon(X.Value);
|
||||
Out.Fields = F;
|
||||
}
|
||||
return Out;
|
||||
}
|
||||
|
||||
function JsDump(AbsPath) {
|
||||
const Buf = fs.readFileSync(AbsPath);
|
||||
const Frames = Link.ParseFrames(Buf);
|
||||
if (!Frames) return { Path: AbsPath, Error: '整帧流解析失败(有残余字节)', Frames: [] };
|
||||
const OutFrames = [];
|
||||
for (const Fr of Frames) {
|
||||
let Body;
|
||||
try {
|
||||
Body = Link.DecodeFrame(Fr);
|
||||
} catch (Err) {
|
||||
OutFrames.push({ BodyBytes: Fr.Body.length, Records: [], Bad: true, Note: String(Err.message) });
|
||||
continue;
|
||||
}
|
||||
const D = Sem.DecodeFrameBody(Body);
|
||||
OutFrames.push({
|
||||
BodyBytes: Body.length,
|
||||
Records: D.Records.map(JsRecord),
|
||||
Bad: !!D.Bad,
|
||||
Note: D.Note || '',
|
||||
});
|
||||
}
|
||||
return { Path: AbsPath, Error: null, Frames: OutFrames };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 跑 Kotlin 侧
|
||||
|
||||
function BuildKotlin() {
|
||||
if (NoBuild) return;
|
||||
const Gradlew = path.join(ClientDir, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew');
|
||||
const R = spawnSync(Gradlew, ['compileKotlinJs', 'jsDevelopmentExecutableCompileSync', '--console=plain', '-q'], {
|
||||
cwd: ClientDir,
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
if (R.status !== 0) {
|
||||
console.error('✗ Kotlin 构建失败:');
|
||||
console.error((R.stdout || '') + (R.stderr || ''));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function RunKotlin(AbsFiles) {
|
||||
const R = spawnSync('node', [NodeBundle], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 512 * 1024 * 1024,
|
||||
env: { ...process.env, TANKIC_CONTAINER_DUMP: '1', TANKIC_CONTAINER_FILES: AbsFiles.join(';') },
|
||||
});
|
||||
const Stdout = R.stdout || '';
|
||||
const Start = Stdout.indexOf('<<<TANKIC_KOTLIN_JSON>>>');
|
||||
const End = Stdout.indexOf('<<<TANKIC_KOTLIN_JSON_END>>>');
|
||||
if (Start < 0 || End < 0) {
|
||||
console.error('✗ 没能从 Kotlin 输出里取到 JSON 标记。stdout 末尾:');
|
||||
console.error(Stdout.slice(-2000));
|
||||
console.error(R.stderr || '');
|
||||
process.exit(1);
|
||||
}
|
||||
return JSON.parse(Stdout.slice(Start + '<<<TANKIC_KOTLIN_JSON>>>'.length, End));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 逐字段比对
|
||||
|
||||
const SkipKeys = new Set(['Tool']);
|
||||
function IsFrameNode(O) {
|
||||
return !!O && typeof O === 'object' && !Array.isArray(O) && Array.isArray(O.Records) && 'BodyBytes' in O;
|
||||
}
|
||||
function IsRecordNode(O) {
|
||||
return !!O && typeof O === 'object' && !Array.isArray(O) && 'ObjectId' in O && 'MethodId' in O;
|
||||
}
|
||||
function ShouldSkip(Key, Parent) {
|
||||
if (SkipKeys.has(Key)) return true;
|
||||
// 帧级/记录级的 Note 是各自语言写的诊断文案,不是线数据(失败与否看 Bad)
|
||||
if (Key === 'Note' && (IsFrameNode(Parent) || IsRecordNode(Parent))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const Stat = { Match: 0, Miss: 0 };
|
||||
const Diffs = [];
|
||||
|
||||
function Describe(V) {
|
||||
if (V === undefined) return '<缺失>';
|
||||
const S = JSON.stringify(V);
|
||||
return S === undefined ? String(V) : S.length > 160 ? S.slice(0, 160) + '…' : S;
|
||||
}
|
||||
|
||||
function Diff(Js, Kt, P) {
|
||||
if (Js === undefined || Kt === undefined) {
|
||||
Stat.Miss++;
|
||||
Diffs.push({ Path: P, Js: Describe(Js), Kotlin: Describe(Kt) });
|
||||
return;
|
||||
}
|
||||
if (Js === null || Kt === null || typeof Js !== 'object' || typeof Kt !== 'object') {
|
||||
if (Js === Kt) Stat.Match++;
|
||||
else {
|
||||
Stat.Miss++;
|
||||
Diffs.push({ Path: P, Js: Describe(Js), Kotlin: Describe(Kt) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(Js) || Array.isArray(Kt)) {
|
||||
if (!Array.isArray(Js) || !Array.isArray(Kt)) {
|
||||
Stat.Miss++;
|
||||
Diffs.push({ Path: P, Js: Describe(Js), Kotlin: Describe(Kt) });
|
||||
return;
|
||||
}
|
||||
if (Js.length !== Kt.length) {
|
||||
Stat.Miss++;
|
||||
Diffs.push({ Path: P + '(数组长度)', Js: String(Js.length), Kotlin: String(Kt.length) });
|
||||
}
|
||||
for (let I = 0; I < Math.min(Js.length, Kt.length); I++) Diff(Js[I], Kt[I], P + '[' + I + ']');
|
||||
return;
|
||||
}
|
||||
const Keys = [...new Set([...Object.keys(Js), ...Object.keys(Kt)])].sort();
|
||||
for (const K of Keys) {
|
||||
if (ShouldSkip(K, Js)) continue;
|
||||
Diff(Js[K], Kt[K], P + '.' + K);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主流程
|
||||
|
||||
const AbsFiles = Corpus.map((F) => path.join(PacketsDir, F));
|
||||
for (const F of AbsFiles) {
|
||||
if (!fs.existsSync(F)) {
|
||||
console.error('✗ 缺少必测帧:' + F);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('M2 容器解码逐字段对照(JS = server/container.js,经 semantic.js 驱动)');
|
||||
console.log(' 布局表同一份对象:' + (SameLayoutTable ? '是' : '否 ⚠'));
|
||||
console.log(' 必测帧 ' + Corpus.length + ' 个');
|
||||
BuildKotlin();
|
||||
|
||||
const JsResult = { Tool: 'TankIcClient/ContainerDump', Files: AbsFiles.map(JsDump) };
|
||||
const KtResult = RunKotlin(AbsFiles);
|
||||
KtResult.Tool = KtResult.Tool || 'TankIcClient/ContainerDump';
|
||||
|
||||
// 把两侧的规范 JSON 落盘(build/ 已被 gitignore),供人工核对
|
||||
const DumpDir = path.join(ClientDir, 'build', 'CompareContainerDecode');
|
||||
fs.mkdirSync(DumpDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(DumpDir, 'JsResult.json'), JSON.stringify(JsResult), 'utf8');
|
||||
fs.writeFileSync(path.join(DumpDir, 'KotlinResult.json'), JSON.stringify(KtResult), 'utf8');
|
||||
console.log(' 两侧规范 JSON 已落盘:' + DumpDir);
|
||||
|
||||
const JsFiles = JsResult.Files;
|
||||
const KtFiles = KtResult.Files || [];
|
||||
if (JsFiles.length !== KtFiles.length) {
|
||||
console.error('✗ 两侧文件数不同:JS ' + JsFiles.length + ' / Kotlin ' + KtFiles.length);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('逐帧记录数 / Bad 记录数(JS ↔ Kotlin):');
|
||||
for (let I = 0; I < Corpus.length; I++) {
|
||||
const J = JsFiles[I];
|
||||
const K = KtFiles[I];
|
||||
const Jf = (J.Frames || []).map((F) => (F.Records || []).length + ':' + (F.Records || []).filter((R) => R.Bad).length);
|
||||
const Kf = (K.Frames || []).map((F) => (F.Records || []).length + ':' + (F.Records || []).filter((R) => R.Bad).length);
|
||||
const Same = JSON.stringify(Jf) === JSON.stringify(Kf);
|
||||
const Layers = [];
|
||||
for (const F of K.Frames || []) for (const R of F.Records || []) if (R.Kind === 'container') Layers.push(R.Layout);
|
||||
console.log(
|
||||
' ' + Corpus[I] + ' 帧数 ' + (J.Frames || []).length + ' 容器布局 [' + Layers.join(',') + ']' +
|
||||
' 记录:Bad JS [' + Jf.join(', ') + '] Kotlin [' + Kf.join(', ') + '] ' + (Same ? '✓' : '✗')
|
||||
);
|
||||
}
|
||||
|
||||
Diff(JsFiles, KtFiles, 'Files');
|
||||
|
||||
console.log('');
|
||||
console.log('比对结果:一致叶子 ' + Stat.Match + ' / 不一致 ' + Stat.Miss);
|
||||
if (Stat.Miss) {
|
||||
console.log('差异明细(最多 40 条):');
|
||||
for (const D of Diffs.slice(0, 40)) {
|
||||
console.log(' ' + D.Path);
|
||||
console.log(' JS = ' + D.Js);
|
||||
console.log(' Kotlin = ' + D.Kotlin);
|
||||
}
|
||||
if (Diffs.length > 40) console.log(' …其余 ' + (Diffs.length - 40) + ' 条略');
|
||||
}
|
||||
console.log(Stat.Miss ? '✗ 存在不一致' : '✓ 逐字段一致');
|
||||
process.exit(Stat.Miss ? 1 : 0);
|
||||
@@ -11,12 +11,14 @@
|
||||
// 数据来源(只读,绝不改动):
|
||||
// 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';
|
||||
@@ -111,6 +113,145 @@ for (const C of DownlinkSrc.Containers || []) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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 = {};
|
||||
@@ -122,17 +263,32 @@ const Tables = {
|
||||
Note:
|
||||
'客户端协议表,由 client/tools/ExportProtocolTables.mjs 从 3DTank/names/ 生成,请勿手改。' +
|
||||
'UplinkByMethod 源 uplink_table.json,DownlinkByMethod 源 downlink_table.json(含 Containers 段)。' +
|
||||
'只挑选 M1/M2 目标空间(names/space-init-*.json、space-session-*.json)里出现过的 methodId 与全部容器方法,' +
|
||||
'不含 codec_fields/regdump 等其它表。' +
|
||||
'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,space-*.json}',
|
||||
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 },
|
||||
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),
|
||||
};
|
||||
@@ -172,4 +328,8 @@ 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(', '));
|
||||
Reference in new issue
Block a user