feat(client): 协议层 M1 —— 入口层握手 + 空间帧记录级解码 + Node 自检

在 client/ 里实现 3DTank 协议层(Kotlin/JS),并给同一工程加 nodejs() 目标做无头自检。自检跑通:Hash Request(571B) → Hash Response(启用链路加密=0)→ Auth/EnterSpace(100045260) → Open Space → 3 条初始化帧(LoadDependencies/zt、LoadObjectsData/pt、gn)→ 保活 ping。字节级核对:Hash Request 与真实抓包 server-logs/store-02/session_040/c2s_0000.bin 逐字节相同;EnterSpace 与 packets/plain/space-100045260/c2s_0000.bin 逐字节相同。M1 明确不做:容器三布局定界、短命令形态、压缩帧解压(遇到即报错,不静默)。协议表由 client/tools/ExportProtocolTables.mjs 从 3DTank/names/ 生成(下行 31 / 上行 7);本机拉不到 Kotlin 插件自带的 yarn,改用系统 npm,故 kotlin-js-store 由 yarn.lock 换成 package-lock.json。
This commit is contained in:
TermiNexus committed 2026-09-18 09:51:00 +08:00
1 parent 3b1094796d
commit 775ff5f3b8
18 files changed
+6731 -2905

No files matched your search

+5
View File
@@ -18,6 +18,11 @@ kotlin {
outputFileName = "TankIcClient.js"
}
}
// 追加 nodejs 目标(保留 browser):让协议层 M1 自检能无头运行。
// Node 24+ 提供全局 WebSocket,与浏览器同接口,Connection.kt 两端通用。
nodejs {
binaries.executable()
}
// 生成可执行的 JS 产物
binaries.executable()
}
+4
View File
@@ -11,3 +11,7 @@ kotlin.code.style=official
# 使用项目本地的 npm/yarn 环境,不再额外下载 Node
kotlin.js.nodejs.download=false
# 用系统 npm 而不是 Kotlin 插件自带的 yarn:
# 插件的 yarn 发行包要从 GitHub 下载,本机网络拿不到(Connection reset),而 npm 走 npmmirror 正常。
kotlin.js.yarn=false
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -12,6 +12,8 @@ import web.dom.ElementId
import web.dom.document
import web.html.HTMLElement
import kotlinx.browser.window
import Protocol.IsNodeRuntime
import Protocol.SelfTest
// 页面根组件:展示脚手架就绪信息与当前 Kotlin/JS 版本号
private val App = FC<Props> {
@@ -28,8 +30,12 @@ private fun RenderApp() {
createRoot(container).render(App.create())
}
// 入口函数:渲染页面,并把入口挂到 window 上,便于宿主页面或调试控制台调用
// 入口函数:Node 下跑协议层 M1 自检;浏览器下渲染页面并把入口挂到 window 上
fun main() {
if (IsNodeRuntime()) {
SelfTest.Run()
return
}
RenderApp()
window.asDynamic().TankIcClientMain = { RenderApp() }
}
+102
View File
@@ -0,0 +1,102 @@
// Bytes.kt —— 协议层共用的字节读写工具(大端,口径与 3DTank/tools/lib_space_codec.js 一致)
package Protocol
private const val HEX_DIGITS = "0123456789abcdef"
// ByteArray → 小写十六进制(调试输出用)
internal fun ByteArray.Hex(): String {
val Sb = StringBuilder(size * 2)
for (B in this) {
val V = B.toInt() and 0xff
Sb.append(HEX_DIGITS[V ushr 4])
Sb.append(HEX_DIGITS[V and 0xf])
}
return Sb.toString()
}
internal fun ByteArray.ReadU8(Off: Int): Int = this[Off].toInt() and 0xff
internal fun ByteArray.ReadI8(Off: Int): Int = this[Off].toInt()
internal fun ByteArray.ReadI16(Off: Int): Int = (ReadU8(Off) shl 8) or ReadU8(Off + 1)
internal fun ByteArray.ReadI32(Off: Int): Int =
(ReadU8(Off) shl 24) or (ReadU8(Off + 1) shl 16) or (ReadU8(Off + 2) shl 8) or ReadU8(Off + 3)
// 8 字节大端 → Long。Long 本身就是有符号 64 位,与 JS 的 readBigInt64BE().toString() 完全等价。
internal fun ByteArray.ReadI64(Off: Int): Long {
var V = 0L
for (I in 0 until 8) V = (V shl 8) or ReadU8(Off + I).toLong()
return V
}
internal fun ByteArray.ReadF32(Off: Int): Float = Float.fromBits(ReadI32(Off))
internal fun ByteArray.ReadF64(Off: Int): Double = Double.fromBits(ReadI64(Off))
// 变长整数(string/list/map 的长度前缀,出处同 lib_space_codec.js:81-88)
internal fun ByteArray.ReadVarint(Off: Int): VarintResult {
if (Off >= size) return VarintResult(0, Off, true)
val B0 = ReadU8(Off)
if (B0 and 0x80 == 0) return VarintResult(B0, Off + 1, false)
if (B0 and 0x40 != 0) {
val B1 = if (Off + 1 < size) ReadU8(Off + 1) else 0
val B2 = if (Off + 2 < size) ReadU8(Off + 2) else 0
return VarintResult(((B0 and 0x3f) shl 16) + (B1 shl 8) + B2, Off + 3, false)
}
val B1 = if (Off + 1 < size) ReadU8(Off + 1) else 0
return VarintResult(((B0 and 0x3f) shl 8) + B1, Off + 2, false)
}
internal class VarintResult(val Value: Int, val Next: Int, val Bad: Boolean)
// 顺序写大端字节的小工具(组帧用,帧都不大,用 ArrayList<Byte> 足够)
internal class ByteWriter {
private val Buf = ArrayList<Byte>()
val Size: Int get() = Buf.size
fun U8(V: Int) {
Buf.add((V and 0xff).toByte())
}
fun I32(V: Int) {
U8(V ushr 24); U8(V ushr 16); U8(V ushr 8); U8(V)
}
fun I64(V: Long) {
for (I in 7 downTo 0) U8((V ushr (I * 8)).toInt())
}
fun Raw(B: ByteArray) {
for (X in B) Buf.add(X)
}
// varint:<128 → 1B;<16384 → 2B(0x80|高6位, 低8位);<4194304 → 3B(0xC0|高6位, 中8位, 低8位)
fun Varint(N: Int) {
require(N >= 0) { "varint 不能为负: $N" }
if (N < 0x80) U8(N)
else if (N < 0x4000) {
U8(0x80 or (N ushr 8)); U8(N)
} else if (N < 0x400000) {
U8(0xc0 or (N ushr 16)); U8(N ushr 8); U8(N)
} else throw ProtocolException("varint 过大: $N")
}
// string / bytes = varint 长度 + 原始字节
fun Str(S: String) {
val B = S.encodeToByteArray()
Varint(B.size)
Raw(B)
}
fun Bytes(B: ByteArray) {
Varint(B.size)
Raw(B)
}
fun ToByteArray(): ByteArray = Buf.toByteArray()
}
// 协议层统一异常:任何「解不动 / M1 未实现」都显式抛出,不静默兜底
internal class ProtocolException(Message: String) : Exception(Message)
@@ -0,0 +1,83 @@
// Connection.kt —— WebSocket 客户端(browser 与 nodejs 两端都能跑)
//
// 自己声明 WHATWG WebSocket(放在本包内,避免与 web.dom 的同名类型重名冲突)。
// Node 24+ 提供全局 WebSocket,接口与浏览器一致,因此这一份代码两个目标通用。
// 接口:addEventListener("open"/"message"/"close"/"error")、send、close、binaryType="arraybuffer"。
package Protocol
// WHATWG WebSocket 的最小声明(全局名必须叫 WebSocket)
internal external class WebSocket(Url: String) {
var binaryType: String
val readyState: Int
fun send(Data: dynamic)
fun close()
fun close(Code: Int, Reason: String)
fun addEventListener(Type: String, Handler: (dynamic) -> Unit)
}
// 当前是否跑在 Node 下(browser 里没有 process.versions.node)
internal fun IsNodeRuntime(): Boolean =
js("typeof process !== 'undefined' && !!(process.versions && process.versions.node)") as Boolean
// Node 里主动结束进程(browser 不会走到这里)
internal fun ExitProcess(Code: Int) {
js("process.exit(Code)")
}
internal fun SetTimeout(DelayMs: Int, Action: () -> Unit) {
js("setTimeout(Action, DelayMs)")
}
internal fun SetIntervalJs(IntervalMs: Int, Action: () -> Unit) {
js("setInterval(Action, IntervalMs)")
}
internal class Connection(private val Url: String) {
private var Socket: WebSocket? = null
var OnOpen: (() -> Unit)? = null
var OnMessage: ((ByteArray) -> Unit)? = null
var OnClose: ((Int) -> Unit)? = null
var OnError: ((String) -> Unit)? = null
val ReadyState: Int get() = Socket?.readyState ?: 3 // 3 = CLOSED
fun Connect() {
val S = WebSocket(Url)
S.binaryType = "arraybuffer"
S.addEventListener("open") { _ ->
OnOpen?.invoke()
}
S.addEventListener("message") { Ev ->
val Bytes = ToByteArray(Ev.data)
OnMessage?.invoke(Bytes)
}
S.addEventListener("close") { Ev ->
val Code = Ev.code
OnClose?.invoke(if (Code == null) -1 else Code as Int)
}
S.addEventListener("error") { _ ->
OnError?.invoke("WebSocket error 事件(WHATWG 不提供细节,通常是连不上 " + Url + ")")
}
Socket = S
}
fun Send(Bytes: ByteArray) {
Socket?.send(Bytes)
}
// 保活 ping:负载为空的帧(3 字节 00 01 00),与服务端 link.js 的 PingFrame 同形
fun StartKeepalive(IntervalMs: Int) {
SetIntervalJs(IntervalMs) { Send(Frame.PingFrame()) }
}
fun Close(Code: Int, Reason: String) {
Socket?.close(Code, Reason)
}
// event.data(binaryType=arraybuffer ⇒ ArrayBuffer)→ ByteArray
// Kotlin/JS 里 ByteArray 的运行时表示就是 Int8Array,所以直接转视图即可
@Suppress("UNCHECKED_CAST")
private fun ToByteArray(Data: dynamic): ByteArray = js("new Int8Array(Data)") as ByteArray
}
+182
View File
@@ -0,0 +1,182 @@
// Entry.kt —— 入口层命令(口径逐条对照 3DTank/tools/server/entry.js)
//
// c2s 1 Hash Request 体 = [1][varint N][N × 字符串 键][varint M][M × 字符串 值]
// c2s 3 Auth/EnterSpace 体 = [3][32 字节 digest][int64 spaceId](digest = 回显服务器下发的 hash)
// c2s 32 ClientLog 体 = [32][1 字节类型][字符串 名称][字符串 正文]
// s2c 2 Hash Response 体 = [2][32 字节 hash][1 字节 是否启用链路加密]
// s2c 32 Open Space 体 = [32][int64 spaceId]
// 注:命令 ID 可跨方向复用(32 在两个方向含义不同),故按方向分表。本服务器把加密标志置 0,
// 整条链路走明文;M1 也只支持明文(加密帧的解密在 relay 模式里,见 3DTank/tools/wss_server.js)。
package Protocol
// 解出来的入口命令;Digest 只在 Hash Response 上有(回显给 Auth/EnterSpace 用)
internal class EntryCommand(
val Id: Int,
val Name: String,
val Detail: String,
val Digest: ByteArray? = null,
val SpaceId: Long? = null,
)
internal object Entry {
const val C2S_HASH_REQUEST = 1
const val S2C_HASH_RESPONSE = 2
const val C2S_ENTER_SPACE = 3
const val C2S_CLIENT_LOG = 32
const val S2C_OPEN_SPACE = 32
private val C2S_NAME = mapOf(1 to "Hash Request", 3 to "Auth/EnterSpace", 32 to "ClientLog")
private val S2C_NAME = mapOf(2 to "Hash Response", 32 to "Open Space")
// ---------------------------------------------------------------- 组帧(客户端发出的)
// Hash Request:[nullmap=00][01][varint N][键…][varint M][值…]
fun BuildHashRequest(Keys: List<String>, Values: List<String>): ByteArray {
val W = ByteWriter()
W.U8(C2S_HASH_REQUEST)
W.Varint(Keys.size)
for (K in Keys) W.Str(K)
W.Varint(Values.size)
for (V in Values) W.Str(V)
return Frame.EncodeFrame(W.ToByteArray())
}
// Auth/EnterSpace:[nullmap=00][03][32 字节 digest][i64 spaceId]
fun BuildEnterSpace(Digest: ByteArray, SpaceId: Long): ByteArray {
if (Digest.size != 32) throw ProtocolException("Auth/EnterSpace 的 digest 必须 32 字节,实际 ${Digest.size}")
val W = ByteWriter()
W.U8(C2S_ENTER_SPACE)
W.Raw(Digest)
W.I64(SpaceId)
return Frame.EncodeFrame(W.ToByteArray())
}
// ClientLog:[nullmap=00][20][类型][字符串 名称][字符串 正文]
fun BuildClientLog(Type: Int, Name: String, Text: String): ByteArray {
val W = ByteWriter()
W.U8(C2S_CLIENT_LOG)
W.U8(Type)
W.Str(Name)
W.Str(Text)
return Frame.EncodeFrame(W.ToByteArray())
}
// Open Space(服务端形态,供测试/回显用)
fun BuildOpenSpace(SpaceId: Long): ByteArray {
val W = ByteWriter()
W.U8(S2C_OPEN_SPACE)
W.I64(SpaceId)
return Frame.EncodeFrame(W.ToByteArray())
}
// ---------------------------------------------------------------- 解帧(服务器发来的)
// 一个帧体(nullmap + 若干条入口命令串接)→ 命令清单
fun DecodeServerBody(Body: ByteArray): List<EntryCommand> {
val Out = ArrayList<EntryCommand>()
var At = Nullmap.Size(Body, 0)
while (At < Body.size) {
val Id = Body.ReadU8(At)
At += 1
when (Id) {
S2C_HASH_RESPONSE -> {
if (At + 33 > Body.size) {
Out.add(EntryCommand(Id, S2C_NAME[Id] ?: "?", "负载不足 33 字节(剩余 ${Body.size - At})"))
break
}
val Hash = Body.copyOfRange(At, At + 32)
val Flag = Body.ReadU8(At + 32)
Out.add(
EntryCommand(
Id, S2C_NAME[Id] ?: "?", "hash=${Hash.Hex()} 启用链路加密=$Flag",
Digest = Hash,
)
)
At += 33
}
S2C_OPEN_SPACE -> {
if (At + 8 > Body.size) {
Out.add(EntryCommand(Id, S2C_NAME[Id] ?: "?", "负载不足 8 字节"))
break
}
val SpaceId = Body.ReadI64(At)
Out.add(EntryCommand(Id, S2C_NAME[Id] ?: "?", "spaceId=$SpaceId", SpaceId = SpaceId))
At += 8
}
C2S_HASH_REQUEST -> {
val R = ParseHashRequest(Body, At)
Out.add(EntryCommand(Id, C2S_NAME[Id] ?: "?", R.Detail))
At = R.Next
if (R.Bad) break
}
C2S_ENTER_SPACE -> {
if (At + 40 > Body.size) {
Out.add(EntryCommand(Id, C2S_NAME[Id] ?: "?", "负载不足 40 字节"))
break
}
val Digest = Body.copyOfRange(At, At + 32)
val SpaceId = Body.ReadI64(At + 32)
Out.add(EntryCommand(Id, C2S_NAME[Id] ?: "?", "digest=${Digest.Hex().take(16)}… spaceId=$SpaceId", SpaceId = SpaceId))
At += 40
}
C2S_CLIENT_LOG -> {
if (At >= Body.size) {
Out.add(EntryCommand(Id, C2S_NAME[Id] ?: "?", "负载为空"))
break
}
val Type = Body.ReadU8(At)
val S1 = ReadString(Body, At + 1)
val S2 = ReadString(Body, S1.Next)
Out.add(EntryCommand(Id, C2S_NAME[Id] ?: "?", "类型=$Type 名称=${Quote(S1.Value)} 正文=${Quote(S2.Value)}"))
At = S2.Next
}
else -> {
Out.add(EntryCommand(Id, "未知入口命令", "ID=$Id 剩余负载=${Body.copyOfRange(At, Body.size).Hex().take(96)}"))
break
}
}
}
return Out
}
private class HashRequestResult(val Detail: String, val Next: Int, val Bad: Boolean)
// Hash Request 体:两个各自计数的表(与 entry.js 的 ParseHashRequest 同口径)
private fun ParseHashRequest(Body: ByteArray, At: Int): HashRequestResult {
var Off = At
val Cn = Body.ReadVarint(Off)
Off = Cn.Next
val Keys = ArrayList<String>()
for (I in 0 until Cn.Value) {
val R = ReadString(Body, Off)
if (R.Bad) return HashRequestResult("键 ${Keys.size}/${Cn.Value} 处越界", Off, true)
Keys.add(R.Value)
Off = R.Next
}
val Cm = Body.ReadVarint(Off)
Off = Cm.Next
val Values = ArrayList<String>()
for (I in 0 until Cm.Value) {
val R = ReadString(Body, Off)
if (R.Bad) return HashRequestResult("值 ${Values.size}/${Cm.Value} 处越界", Off, true)
Values.add(R.Value)
Off = R.Next
}
val Pairs = Keys.indices.joinToString(" ") { I -> "${Keys[I]}=${Quote(Values.getOrNull(I) ?: "<缺>")}" }
return HashRequestResult("键 ${Cn.Value} 条 / 值 ${Cm.Value} 条 $Pairs", Off, false)
}
private class StringResult(val Value: String, val Next: Int, val Bad: Boolean)
// varint 长度前缀 + utf8 字节
private fun ReadString(Buf: ByteArray, Off: Int): StringResult {
val N = Buf.ReadVarint(Off)
if (N.Bad) return StringResult("<越界>", Off, true)
val End = N.Next + N.Value
if (End > Buf.size) return StringResult("<越界>", Buf.size, true)
return StringResult(Buf.copyOfRange(N.Next, End).decodeToString(), End, false)
}
private fun Quote(S: String): String = "\"" + S.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
}
@@ -0,0 +1,172 @@
// FieldReader.kt —— 空间帧负载的字段级解码(口径逐条对照 3DTank/tools/lib_space_codec.js)
//
// 线格式(全部大端):
// bool/i8=1B i16=2B i32/f32=4B i64/f64=8B
// string/bytes/array = varint 长度 + 原始字节
// list<T> = varint 元素数 + 元素…
// map<K,V> = varint 条数 + 条数 × (K, V)
// enum<T> = 4 字节(i32)
// opt<T> = nullmap 比特流里的 1 个 bit:1 = 空(负载里 0 字节),0 = 有值、紧跟 T
// ── 注意 opt 用的是**帧级共享**的 nullmap 比特流,不是 1 字节标志;位游标必须跨记录顺序推进。
// 模型类(codec_fields 里的嵌套 codec)M1 不支持:如实报不可解,不按字节长度硬猜。
package Protocol
// nullmap 比特流游标:Index 会被本模块与 SpaceFrame 顺序推进
internal class BitCursor(val Bits: BooleanArray, var Index: Int) {
// 取 1 个 bit;位用尽返回 null(如实报错,不默认按非空解)
fun Take(): Boolean? {
if (Index >= Bits.size) return null
return Bits[Index++]
}
}
internal class FieldReadResult(val Value: Any?, val Next: Int, val Bad: Boolean, val Why: String = "")
internal object FieldReader {
private const val MAX_DEPTH = 8
// 类型串是否以 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 {
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 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)
}
val List = MatchGeneric(T, "list")
if (List != null) {
val N = Buf.ReadVarint(Off)
var At = N.Next
val Arr = ArrayList<Any?>(N.Value)
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)
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
}
return FieldReadResult(Arr, At, false)
}
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)
}
val Map = MatchGeneric(T, "map")
if (Map != null) {
val Kv = SplitTypeArgs(Map)
if (Kv.size != 2) return FieldReadResult("<map 类型串非法>", Off, true, "map 参数不是两个:$Map")
val N = Buf.ReadVarint(Off)
var At = N.Next
val Pairs = ArrayList<Any?>(N.Value)
if (N.Bad || N.Value > 4096) return FieldReadResult(Pairs, At, true, "map 条数 ${N.Value} 不合理")
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)
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)
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
}
return FieldReadResult(Pairs, At, false)
}
when (T) {
"bool" -> {
if (Off >= Buf.size) return FieldReadResult("<越界>", Off, true, "bool 需要 1 字节")
return FieldReadResult(Buf.ReadU8(Off) != 0, Off + 1, false)
}
"i8" -> {
if (Off + 1 > Buf.size) return FieldReadResult("<越界>", Off, true, "i8 需要 1 字节")
return FieldReadResult(Buf.ReadI8(Off), Off + 1, false)
}
"i16" -> {
if (Off + 2 > Buf.size) return FieldReadResult("<越界>", Off, true, "i16 需要 2 字节")
return FieldReadResult(Buf.ReadI16(Off), Off + 2, false)
}
"i32" -> {
if (Off + 4 > Buf.size) return FieldReadResult("<越界>", Off, true, "i32 需要 4 字节")
return FieldReadResult(Buf.ReadI32(Off), Off + 4, false)
}
"i64" -> {
if (Off + 8 > Buf.size) return FieldReadResult("<越界>", Off, true, "i64 需要 8 字节")
return FieldReadResult(Buf.ReadI64(Off), Off + 8, false)
}
"f32" -> {
if (Off + 4 > Buf.size) return FieldReadResult("<越界>", Off, true, "f32 需要 4 字节")
return FieldReadResult(Buf.ReadF32(Off), Off + 4, false)
}
"f64" -> {
if (Off + 8 > Buf.size) return FieldReadResult("<越界>", Off, true, "f64 需要 8 字节")
return FieldReadResult(Buf.ReadF64(Off), Off + 8, false)
}
"string", "bytes", "array" -> {
val N = Buf.ReadVarint(Off)
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)
}
}
}
// 模型类 / 裸类名:M1 的协议表不含 codec_fields,无法就地展开 → 如实报不可解
val Hint = if (TypeCodec.isNotEmpty()) "(TypeCodec=$TypeCodec)" else ""
return FieldReadResult("<无 codec:$T>", Off, true, "类型 $T$Hint 需要 codec_fields 定义,M1 协议表只导出字段类型、不含嵌套 codec")
}
// 取形如 `name<...>` 的内层类型串;不匹配返回 null
private fun MatchGeneric(Type: String, Name: String): String? {
val Prefix = "$Name<"
if (!Type.startsWith(Prefix) || !Type.endsWith(">")) return null
return Type.substring(Prefix.length, Type.length - 1)
}
// 按顶层逗号切分复合类型参数:map<enum<X>, i32> → ["enum<X>", "i32"]
private fun SplitTypeArgs(Text: String): List<String> {
val Out = ArrayList<String>()
var Depth = 0
var Start = 0
for (I in Text.indices) {
when (Text[I]) {
'<' -> Depth++
'>' -> Depth--
',' -> if (Depth == 0) {
Out.add(Text.substring(Start, I).trim())
Start = I + 1
}
}
}
Out.add(Text.substring(Start).trim())
return Out
}
// 值 → 单行可读文本(显示层;i64 用 Long.toString 精确、不丢位)
fun FormatValue(V: Any?): String = when (V) {
null -> "null"
is String -> if (V.length > 90) "\"" + V.take(90) + "…\"" else "\"" + V + "\""
is Boolean -> if (V) "true" else "false"
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 ""
else -> V.toString()
}
}
@@ -0,0 +1,87 @@
// Frame.kt —— 链路层帧边界(口径逐条对照 3DTank/tools/server/link.js)
//
// 帧 = [2 或 4 字节大端长度][帧体]
// 长度首字节 bit7=1 → 4 字节头;bit6=1 → 帧体 zlib 压缩;低 6 位参与长度
// 帧体 = [nullmap][payload]
// payload = [1 字节入口命令 ID][命令体](Entry.kt)
// | [objectId i64][(methodId i64)(负载)…](SpaceFrame.kt,注意记录自带 objectId)
package Protocol
// 一帧的头部信息
internal class FrameInfo(val TotalSize: Int, val HeaderSize: Int, val Compressed: Boolean)
// 拆出来的帧体:Body = nullmap + payload,Compressed 表示帧体是 zlib 压缩的
internal class WireFrame(val Body: ByteArray, val Compressed: Boolean)
internal object Frame {
// 读一帧的头部(对应 link.js 的 PeekFrame);字节不够返回 null
fun PeekFrame(Buf: ByteArray, Offset: Int): FrameInfo? {
if (Buf.size - Offset < 2) return null
val B0 = Buf.ReadU8(Offset)
if (B0 and 0x80 != 0) {
if (Buf.size - Offset < 4) return null
val Size = ((((B0 and 0x3f) shl 24) or (Buf.ReadU8(Offset + 1) shl 16) or
(Buf.ReadU8(Offset + 2) shl 8) or Buf.ReadU8(Offset + 3))) + 4
return FrameInfo(Size, 4, B0 and 0x40 != 0)
}
val Size = 2 + (((B0 and 0x3f) shl 8) or Buf.ReadU8(Offset + 1))
return FrameInfo(Size, 2, B0 and 0x40 != 0)
}
// 把一串字节拆成整帧(对应 link.js 的 ParseFrames);有任何残余返回 null
fun ParseFrames(Buf: ByteArray): List<WireFrame>? {
val Out = ArrayList<WireFrame>()
var Off = 0
while (Off < Buf.size) {
val Info = PeekFrame(Buf, Off) ?: return null
if (Off + Info.TotalSize > Buf.size) return null
Out.add(WireFrame(Buf.copyOfRange(Off + Info.HeaderSize, Off + Info.TotalSize), Info.Compressed))
Off += Info.TotalSize
}
return if (Out.isEmpty()) null else Out
}
// 组帧(对应 link.js 的 EncodeFrame):Length < 0x4000 用 2 字节头,否则 4 字节头(bit7)
fun EncodeFrame(Payload: ByteArray, Nullmap: ByteArray = byteArrayOf(0)): ByteArray {
val Length = Nullmap.size + Payload.size
val Out = ByteArray(if (Length < 0x4000) 2 else 4)
if (Length < 0x4000) {
Out[0] = ((Length ushr 8) and 0xff).toByte()
Out[1] = (Length and 0xff).toByte()
} else {
Out[0] = (((Length ushr 24) and 0x3f) or 0x80).toByte()
Out[1] = ((Length ushr 16) and 0xff).toByte()
Out[2] = ((Length ushr 8) and 0xff).toByte()
Out[3] = (Length and 0xff).toByte()
}
val Result = ByteArray(Out.size + Length)
Out.copyInto(Result)
Nullmap.copyInto(Result, Out.size)
Payload.copyInto(Result, Out.size + Nullmap.size)
return Result
}
// 解压帧体(对应 link.js 的 DecodeFrame)。M1 不实现 zlib 解压:遇到压缩帧直接抛错,绝不静默。
fun DecodeFrame(Frame: WireFrame): ByteArray {
if (Frame.Compressed) {
throw ProtocolException(
"M1 未实现解压:收到 zlib 压缩帧(帧体 ${Frame.Body.size} 字节,首字节 0x" +
(Frame.Body.firstOrNull()?.let { (it.toInt() and 0xff).toString(16).padStart(2, '0') } ?: "?") +
")。真实服的握手后帧常是压缩的,M1 只处理明文帧。"
)
}
return Frame.Body
}
// 保活帧 = 负载为空的帧(实测 3 字节:00 01 00)
fun PingFrame(): ByteArray = EncodeFrame(ByteArray(0))
// 帧体只有 nullmap、没有 payload(对应 link.js 的 IsPingFrame)
fun IsPingFrame(Raw: ByteArray): Boolean {
val Frames = ParseFrames(Raw) ?: return false
if (Frames.size != 1) return false
val Body = Frames[0].Body
return Body.isNotEmpty() && Nullmap.Size(Body, 0) >= Body.size
}
}
@@ -0,0 +1,61 @@
// Nullmap.kt —— nullmap 的长度与比特流(口径逐条对照 link.js 的 NullmapSize 与 container.js 的 NullmapBits)
//
// nullmap 是「一条记录 1 个形态位 + 各条负载里每个 opt 字段 1 位」共用的比特流。
// 长度标签:
// 长格式(首字节 bit7=1):bit6=1 → 总长 = 3 + 低 3 字节组成的 16 位长度;bit6=0 → 总长 = 1 + 低 6 位
// 短格式(首字节 bit7=0):总长 = 1 + ((b0 & 0x60) >>> 5)
// 位图:
// 长格式:标签之后的字节原样按 MSB 先读(跳过 1 或 3 字节标签)
// 短格式:整串按 MSB 先读、跳过前 3 个 bit(那 3 位是长度标签)
package Protocol
internal object Nullmap {
// nullmap 的总长度(字节)
fun Size(Buf: ByteArray, Offset: Int): Int {
if (Offset >= Buf.size) return 0
val R = Buf.ReadU8(Offset)
if (R and 0x80 != 0) {
return if (R and 0x40 != 0) {
3 + (((R and 0x3f) shl 16) or (Buf.ReadU8(Offset + 1) shl 8) or Buf.ReadU8(Offset + 2))
} else {
1 + (R and 0x3f)
}
}
return 1 + ((R and 0x60) ushr 5)
}
// nullmap 字节 → 位图(MSB 先读)
fun Bits(Bytes: ByteArray): BooleanArray {
val All = ArrayList<Boolean>(Bytes.size * 8)
for (B in Bytes) {
for (K in 7 downTo 0) All.add(((B.toInt() ushr K) and 1) == 1)
}
if (Bytes.isEmpty()) return BooleanArray(0)
val Skip = if (Bytes[0].toInt() and 0x80 != 0) (if (Bytes[0].toInt() and 0x40 != 0) 24 else 8) else 3
if (Skip >= All.size) return BooleanArray(0)
val Out = BooleanArray(All.size - Skip)
for (I in Out.indices) Out[I] = All[I + Skip]
return Out
}
// 位图写回 nullmap 字节(长度一字节不变);位数对不上就抛错(对应 semantic.js 的 WriteNullmapBits)
fun WriteBits(Orig: ByteArray, Bits: BooleanArray): ByteArray {
val Skip = if (Orig.isNotEmpty() && Orig[0].toInt() and 0x80 != 0) {
if (Orig[0].toInt() and 0x40 != 0) 24 else 8
} else 3
val Cap = Orig.size * 8 - Skip
if (Bits.size != Cap) {
throw ProtocolException("位图位数 ${Bits.size} 与 nullmap 容量 $Cap(${Orig.size}B)不一致:比特流顺序不对")
}
val Out = Orig.copyOf()
for (I in 0 until Cap) {
val P = Skip + I
val Mask = 0x80 ushr (P and 7)
val ByteIndex = P ushr 3
val Cur = Out[ByteIndex].toInt() and 0xff
Out[ByteIndex] = (if (Bits[I]) Cur or Mask else Cur and Mask.inv()).toByte()
}
return Out
}
}
@@ -1,6 +0,0 @@
// 这里将放 3DTank 协议层
// 后续在此实现 3DTank / Tanki Online 中国版的网络协议编解码与消息模型,当前仅作占位。
package Protocol
// 协议层占位对象,暂时不包含任何业务逻辑
internal object ProtocolPlaceholder
@@ -0,0 +1,79 @@
// ProtocolTables.kt —— 协议表加载(数据源 client/src/jsMain/resources/ProtocolTables.json)
//
// 表的生成见 client/tools/ExportProtocolTables.mjs;同一份 JSON 由导出脚本内嵌成
// Protocol/ProtocolTablesData.kt 里的常量(Kotlin/JS 没有跨 browser/node 一致的资源读取 API),
// 所以这里直接用 kotlinx.serialization 解析那段 JSON 文本,两端行为完全一致。
package Protocol
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
// 字段描述:Slot 是源码里的局部槽位名,Name 是可读名(挖不到就是空串),
// Type 是线类型串(i8..f64 / string / list<T> / opt<T> / map<K,V> / enum<T> / 模型类名),
// TypeCodec 是 codec_fields 里的 codec 键(可能为空;M1 不消费它,供 M2 用)。
@Serializable
internal class ProtocolField(
val Order: Int = 0,
val Slot: String = "",
val Name: String = "",
val Type: String = "",
val TypeCodec: String = "",
)
@Serializable
internal class UplinkCommandSpec(
val Name: String = "",
val Module: String = "",
val OwnerClass: String = "",
val SendFn: String = "",
val Fields: List<ProtocolField> = emptyList(),
)
@Serializable
internal class DownlinkCommandSpec(
val Name: String = "",
val Kind: String = "fields", // fields | container
val Layout: String = "", // 容器布局:lt / pt / zt(M1 不实现容器解码)
val Module: String = "",
val OwnerClass: String = "",
val Fields: List<ProtocolField> = emptyList(),
)
@Serializable
internal class ProtocolSelection(
val DownlinkMethodIds: Int = 0,
val UplinkMethodIds: Int = 0,
)
@Serializable
internal class ProtocolTablesData(
val Note: String = "",
val GeneratedAt: String = "",
val Source: String = "",
val PrimitiveWidths: Map<String, Int> = emptyMap(),
val VarintPrefixed: List<String> = emptyList(),
val Selection: ProtocolSelection = ProtocolSelection(),
val UplinkByMethod: Map<String, UplinkCommandSpec> = emptyMap(),
val DownlinkByMethod: Map<String, DownlinkCommandSpec> = emptyMap(),
)
internal object ProtocolTables {
// 复用同一个 Json 实例(每次新建会被编译器告警,且解析多份表时浪费)
private val JsonFormat = Json { ignoreUnknownKeys = true }
// 懒加载:第一次用到时才解析那段 JSON
val Data: ProtocolTablesData by lazy {
JsonFormat.decodeFromString(ProtocolTablesData.serializer(), ProtocolTablesJsonText)
}
fun Downlink(MethodId: String): DownlinkCommandSpec? = Data.DownlinkByMethod[MethodId]
fun Uplink(MethodId: String): UplinkCommandSpec? = Data.UplinkByMethod[MethodId]
// 帧体在 At 处的 8 字节是不是一条已知的上行 methodId(与 dispatch.js 的 IsSpaceFrame 同口径)
fun IsUplinkMethod(Body: ByteArray, At: Int): Boolean {
if (At + 16 > Body.size) return false
return Data.UplinkByMethod.containsKey(Body.ReadI64(At + 8).toString())
}
}
@@ -0,0 +1,8 @@
// 本文件由 client/tools/ExportProtocolTables.mjs 生成,请勿手改。
// 内容 = client/src/jsMain/resources/ProtocolTables.json(同一份 JSON)。
// 为什么内嵌:Kotlin/JS 没有跨 browser/node 一致的资源读取 API,内嵌常量能保证两端行为完全一致。
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
@@ -0,0 +1,150 @@
// SelfTest.kt —— M1 协议层自检(nodejs 目标下无头运行)
//
// 链路:连 ws://localhost:9091/ → 发 Hash Request → 收 Hash Response(解出 hash/加密标志)
// → 发 Auth/EnterSpace(100045260) → 收初始化帧并逐条打印记录
// → 收到至少 1 条服务端保活 ping 后正常退出。
package Protocol
internal object SelfTest {
private const val Url = "ws://localhost:9091/"
private const val TargetSpaceId = 100045260L
// 与真实抓包 packets/plain/space-100045260/c2s_0000.bin(571 字节整帧)完全一致的键/值,
// 这样自检发出的 Hash Request 可以与该样本逐字节比对(见报告里的 hex)。
private val HashKeys = listOf(
"relay", "resources", "config-template", "lang", "browser_user_agent", "os", "device_type",
"client_type", "hardware_concurrency", "time_zone", "video_card", "mobile_device",
"touchscreen_support", "client_version", "ram_size",
)
private val HashValues = listOf(
"0",
"http://localhost:8080/resources",
"http://localhost:8080/config.xml",
"cn",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36 Edg/153.0.0.0",
"Win32",
"Desktop",
"BROWSER",
"16",
"Asia/Shanghai",
"ANGLE (AMD, AMD Radeon(TM) 860M Graphics (0x00001114) Direct3D11 vs_5_0 ps_5_0, D3D11)",
"false",
"false",
"83fabafa6dc46cc3940ff1fd9aee94f3b8752bb1",
"32768",
)
private var Conn: Connection? = null
private var FrameNo = 0
private var PingCount = 0
private var EnterSent = false
private var Finished = false
fun Run() {
Log("=== 3DTank 客户端 M1 自检(nodejs 目标)===")
Log("目标服务器 $Url 目标空间 $TargetSpaceId")
val Tables = ProtocolTables.Data
Log("协议表 GeneratedAt=${Tables.GeneratedAt} 下行方法 ${Tables.Selection.DownlinkMethodIds} 条 / 上行方法 ${Tables.Selection.UplinkMethodIds} 条")
Log("")
val C = Connection(Url)
Conn = C
C.OnOpen = { OnOpen() }
C.OnMessage = { OnMessage(it) }
C.OnClose = { Log("连接关闭 code=$it") }
C.OnError = { Log("✗ 连接错误:$it") }
C.Connect()
}
private fun OnOpen() {
val Req = Entry.BuildHashRequest(HashKeys, HashValues)
Log("→ c2s Hash Request ${Req.size} 字节 hex=${Req.Hex()}")
Conn?.Send(Req)
}
private fun OnMessage(Raw: ByteArray) {
FrameNo++
if (Frame.IsPingFrame(Raw)) {
PingCount++
Log("← s2c 帧#$FrameNo 整帧 ${Raw.size} 字节 保活 ping(空负载帧,仅 nullmap)")
MaybeFinish()
return
}
var Off = 0
while (Off < Raw.size) {
val Info = Frame.PeekFrame(Raw, Off) ?: break
val Body = try {
Frame.DecodeFrame(WireFrame(Raw.copyOfRange(Off + Info.HeaderSize, Off + Info.TotalSize), Info.Compressed))
} catch (E: ProtocolException) {
Log("✗ 帧#$FrameNo ${Info.TotalSize} 字节:${E.message}")
return
}
HandleFrame(Info.TotalSize, Body)
Off += Info.TotalSize
}
}
private fun HandleFrame(TotalBytes: Int, Body: ByteArray) {
val NmSize = Nullmap.Size(Body, 0)
val At = NmSize
if (At >= Body.size) {
Log("← s2c 帧#$FrameNo 整帧 $TotalBytes 字节 | nullmap $NmSize 字节 | 记录 0 条(空负载帧)")
return
}
val MethodIdAt8 = if (At + 16 <= Body.size) Body.ReadI64(At + 8).toString() else ""
if (ProtocolTables.Downlink(MethodIdAt8) != null) {
PrintSpaceFrame(TotalBytes, Body, NmSize)
return
}
PrintEntryFrame(TotalBytes, Body, NmSize)
}
private fun PrintSpaceFrame(TotalBytes: Int, Body: ByteArray, NmSize: Int) {
val Parsed = SpaceFrame.Parse(Body)
Log("← s2c 帧#$FrameNo 整帧 $TotalBytes 字节 | nullmap $NmSize 字节 | 记录数 ${Parsed.Records.size}")
for (I in Parsed.Records.indices) {
val R = Parsed.Records[I]
val Spec = ProtocolTables.Downlink(R.MethodId)
val Known = when {
Spec == null -> "未知"
Spec.Module.isNotEmpty() -> "${Spec.Name} (${Spec.Module})"
else -> Spec.Name
}
val Flag = if (R.PayloadKnown) "" else "(负载边界未定:${if (R.Kind == "container") "容器" else "未知"},按剩余字节整块给出)"
Log(" [$I] objectId=${R.ObjectId} | methodId=${R.MethodId} | $Known | 负载 ${R.Payload.size} 字节 $Flag")
if (R.Fields.isNotEmpty()) {
for (F in R.Fields) Log(" ${F.first} = ${F.second}")
}
if (R.Note.isNotEmpty()) Log(" ⚠ ${R.Note}")
}
if (Parsed.Note.isNotEmpty()) Log(" ⚠ ${Parsed.Note}")
}
private fun PrintEntryFrame(TotalBytes: Int, Body: ByteArray, NmSize: Int) {
val Cmds = Entry.DecodeServerBody(Body)
Log("← s2c 帧#$FrameNo 整帧 $TotalBytes 字节 | nullmap $NmSize 字节 | 入口命令 ${Cmds.size} 条")
for (C in Cmds) {
Log(" [${C.Id}] ${C.Name} ${C.Detail}")
if (C.Id == Entry.S2C_HASH_RESPONSE && C.Digest != null && !EnterSent) {
EnterSent = true
val Req = Entry.BuildEnterSpace(C.Digest, TargetSpaceId)
Log("→ c2s Auth/EnterSpace(spaceId=$TargetSpaceId) ${Req.size} 字节 hex=${Req.Hex()}")
Conn?.Send(Req)
}
}
}
private fun MaybeFinish() {
if (Finished || PingCount < 1) return
Finished = true
Log("")
Log("✓ M1 自检通过:入口层握手 → 进空间 → 收到初始化帧 → 收到 $PingCount 条服务端保活 ping")
Conn?.Close(1000, "M1 自检完成")
SetTimeout(100) { ExitProcess(0) }
}
private fun Log(Msg: String) {
println(Msg)
}
}
@@ -0,0 +1,156 @@
// SpaceFrame.kt —— 空间帧:记录列表的解析与组帧(口径逐条对照 semantic.js 的 DecodeFrameBody / dispatch.js)
//
// 帧体 = [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 不实现容器解码 ⇒ 负载长度无法确定,
// 按「帧体剩余全部字节」切片并如实标注;因此容器记录之后的记录不再解析。
package Protocol
internal class SpaceRecord(
val ObjectId: String,
val MethodId: String,
val Name: String,
val Kind: String, // fields | container | unknown
val Layout: String, // 容器布局(仅 container)
val Payload: ByteArray,
val PayloadKnown: Boolean, // true = 负载边界是算出来的;false = 按「剩余全部字节」切片
val Bad: Boolean,
val Note: String,
val Fields: List<Pair<String, String>>, // 字段名 → 值文本(仅 fields 记录)
)
internal class SpaceFrameBody(
val Nullmap: ByteArray,
val Records: List<SpaceRecord>,
val Bad: Boolean,
val Note: String,
)
internal object SpaceFrame {
// 解析一个帧体(含 nullmap)
fun Parse(Body: ByteArray): SpaceFrameBody {
val NmSize = Nullmap.Size(Body, 0)
if (NmSize > Body.size) {
return SpaceFrameBody(
ByteArray(0), emptyList(), true,
"nullmap 声明 $NmSize 字节,但帧体只有 ${Body.size} 字节"
)
}
val Nm = Body.copyOfRange(0, NmSize)
val Bits = Nullmap.Bits(Nm)
val Records = ArrayList<SpaceRecord>()
var Off = NmSize
var Index = 0
var Bad = false
var Note = ""
while (Off < Body.size) {
if (Index >= Bits.size) {
Bad = true
Note = "nullmap 的 ${Bits.size} 个 bit 用完了,帧体还剩 ${Body.size - Off} 字节没读"
break
}
if (Bits[Index]) {
Bad = true
Note = "第 ${Records.size + 1} 条记录是短命令形态(nullmap 第 $Index 个 bit=1,1 字节命令号 0x" +
(Body.ReadU8(Off).toString(16).padStart(2, '0')) + "):短命令号查表数据还没挖出来,M1 判定不了"
break
}
Index++ // 形态位 = 长记录
if (Off + 16 > Body.size) {
Bad = true
Note = "第 ${Records.size + 1} 条记录的剩余 ${Body.size - Off} 字节不足一条记录头(objectId i64 + methodId i64 = 16B)"
break
}
val ObjectId = Body.ReadI64(Off).toString()
val MethodId = Body.ReadI64(Off + 8).toString()
val Spec = ProtocolTables.Downlink(MethodId)
if (Spec == null) {
Records.add(
SpaceRecord(
ObjectId, MethodId, "(未知)", "unknown", "",
Body.copyOfRange(Off + 16, Body.size), false, true,
"methodId=$MethodId 不在 DownlinkByMethod(协议表只导出 M1/M2 需要的条目),负载长度无法确定",
emptyList(),
)
)
Bad = true
Note = "第 ${Records.size} 条记录的 methodId=$MethodId 不在协议表:无法定界,剩余字节整体当作它的负载"
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)
var At = Off + 16
val Values = ArrayList<Pair<String, String>>()
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))
val ZeroOk = FieldReader.IsOpt(F.Type)
if (R.Bad || (R.Next <= At && !ZeroOk)) {
RecBad = true
Why = "字段 $Key(${F.Type})在 +${At - (Off + 16)} 处解不动" + (if (R.Why.isNotEmpty()) ":${R.Why}" else "")
break
}
At = R.Next
}
Index = Cursor.Index
if (RecBad) {
Records.add(
SpaceRecord(
ObjectId, MethodId, Spec.Name, "fields", "",
Body.copyOfRange(Off + 16, Body.size), false, true,
Why + "(记录边界无从判定,剩余字节整体当作它的负载)",
Values,
)
)
Bad = true
Note = "第 ${Records.size} 条记录(methodId=$MethodId)解不动:$Why"
break
}
Records.add(
SpaceRecord(
ObjectId, MethodId, Spec.Name, "fields", "",
Body.copyOfRange(Off + 16, At), true, false, "", Values,
)
)
Off = At
}
return SpaceFrameBody(Nm, Records, Bad, Note)
}
// 组帧:记录列表 → 帧体 → 整帧(nullmap 原样带上,长度头由 Frame.EncodeFrame 补)
fun Encode(Records: List<SpaceRecord>, Nullmap: ByteArray = byteArrayOf(0)): ByteArray {
val W = ByteWriter()
for (R in Records) {
W.I64(R.ObjectId.toLong())
W.I64(R.MethodId.toLong())
W.Raw(R.Payload)
}
return Frame.EncodeFrame(W.ToByteArray(), Nullmap)
}
}
@@ -0,0 +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
+175
View File
@@ -0,0 +1,175 @@
// 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/space-*.json M1/M2 目标空间的初始化/会话模板,用来挑选「M1/M2 真正用得上的」条目
//
// 挑选口径(刻意不把 names/ 整份拷进来):
// DownlinkByMethod = 全部容器方法(M1/M2 靠它识别 ModelService 容器记录)
// ∪ 所有 space-*.json 模板里出现过的 Records[].MethodId
// UplinkByMethod = 所有 space-*.json 模板里出现过的 After[].MethodId(会话回放的上行触发点)
//
// 用法: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: [],
};
}
// 按 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 段)。' +
'只挑选 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: new Date().toISOString(),
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: Object.keys(DownlinkByMethod).length, UplinkMethodIds: Object.keys(UplinkByMethod).length },
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(' 目标空间模板 ' + SpaceFiles.length + ' 个:' + SpaceFiles.join(', '));