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:
1 parent
3b1094796d
commit
775ff5f3b8
18 files changed
+6731
-2905
No files matched your search
@@ -18,6 +18,11 @@ kotlin {
|
||||
outputFileName = "TankIcClient.js"
|
||||
}
|
||||
}
|
||||
// 追加 nodejs 目标(保留 browser):让协议层 M1 自检能无头运行。
|
||||
// Node 24+ 提供全局 WebSocket,与浏览器同接口,Connection.kt 两端通用。
|
||||
nodejs {
|
||||
binaries.executable()
|
||||
}
|
||||
// 生成可执行的 JS 产物
|
||||
binaries.executable()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Generated
+5459
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(', '));
|
||||
Reference in new issue
Block a user