From 1edb0324812aa778546fd3f7b35f05425e7da8ba Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 18 Sep 2026 10:31:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=8D=8F=E8=AE=AE=E5=B1=82=20M?= =?UTF-8?q?3=20=E2=80=94=E2=80=94=20=E5=8E=8B=E7=BC=A9=E5=B8=A7=E8=A7=A3?= =?UTF-8?q?=E5=8E=8B=E4=B8=A4=E7=AB=AF=E7=BB=9F=E4=B8=80=E8=B5=B0=20fflate?= =?UTF-8?q?.unzlibSync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实抓包里的关键帧是 zlib 压缩的(space-100045260/s2c_0000.bin 整帧 915B → 解压后帧体 1799B;space-100073070/s2c_0004.bin 整帧 41590B → 解压后 255844B),M2 只在 Node 侧用 zlib 解、浏览器目标直接抛错。改为两端同一份纯 JS 实现 fflate 0.8.3,DecodeFrame 保持同步签名。关键修正:fflate 的 inflateSync 是「无包装 DEFLATE」,喂 zlib 流会报 unexpected EOF,zlib 包装要用 unzlibSync —— 官方客户端也正是用它(3DTank/src/module_24635.js:20 g = n.unzlibSync,来自 module_29518.js:1172),与我们同路。验收:Node 侧两个压缩帧的解压结果与 link.js 的 zlib.inflateSync SHA256 完全相同,且解压后完整解出记录(1 条 zt / 1 条 pt + 28 条 fields),M2 对照脚本仍 5 帧、一致叶子 26950 / 不一致 0;浏览器侧页面加载即自动跑自检、console 全项通过(已实际用浏览器打开确认);三个构建任务全绿;production 产物 524579 → 564689 字节(+40110,fflate 的 CJS 入口无法 tree-shake,后续可换 ESM 瘦身)。 --- client/build.gradle.kts | 7 + client/kotlin-js-store/package-lock.json | 7 + client/package.json | 1 + client/src/jsMain/kotlin/Main.kt | 20 ++- .../kotlin/Protocol/CompressionSelfTest.kt | 161 ++++++++++++++++++ .../src/jsMain/kotlin/Protocol/Connection.kt | 7 +- client/src/jsMain/kotlin/Protocol/Frame.kt | 17 +- client/src/jsMain/kotlin/Protocol/SelfTest.kt | 72 +++++++- client/src/jsMain/kotlin/Protocol/Zlib.kt | 66 +++++++ client/src/jsMain/resources/index.html | 7 +- 10 files changed, 343 insertions(+), 22 deletions(-) create mode 100644 client/src/jsMain/kotlin/Protocol/CompressionSelfTest.kt create mode 100644 client/src/jsMain/kotlin/Protocol/Zlib.kt diff --git a/client/build.gradle.kts b/client/build.gradle.kts index e4e2db3..5f1dd35 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -39,6 +39,13 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") // HTML 构建库,游戏侧同样使用 implementation("org.jetbrains.kotlinx:kotlinx-html-js:0.12.0") + // fflate:纯 JS 的同步 zlib 解压(browser 与 node 共用一份实现,见 Protocol/Zlib.kt)。 + // 版本依据:0.8.3 是 npm 上的 latest(0.8.2 之后唯一的修复版),且其 unzlibSync 对 + // 真实抓包帧的解压结果与 3DTank/tools/server/link.js 的 zlib.inflateSync 逐字节一致。 + // 用 unzlibSync 而不是 inflateSync:后者只吃「raw DEFLATE(无包装)」;官方客户端 + // 网络层取的就是 unzlibSync(3DTank/src/module_24635.js:20)。 + // 注意:gradle.properties 里 kotlin.js.yarn=false,走系统 npm 安装。 + implementation(npm("fflate", "0.8.3")) } } } diff --git a/client/kotlin-js-store/package-lock.json b/client/kotlin-js-store/package-lock.json index e671ab9..cb1754d 100644 --- a/client/kotlin-js-store/package-lock.json +++ b/client/kotlin-js-store/package-lock.json @@ -1913,6 +1913,11 @@ "node": ">=0.8.0" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", @@ -5421,6 +5426,7 @@ "packages/TankIcClient": { "version": "0.1.0", "dependencies": { + "fflate": "0.8.3", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -5437,6 +5443,7 @@ "packages/TankIcClient-test": { "version": "0.1.0", "dependencies": { + "fflate": "0.8.3", "react": "^19.1.0", "react-dom": "^19.1.0" }, diff --git a/client/package.json b/client/package.json index 197920a..77887ba 100644 --- a/client/package.json +++ b/client/package.json @@ -4,6 +4,7 @@ "private": true, "description": "TankIcClient — 基于 Kotlin/JS + React 的逆向客户端脚手架", "dependencies": { + "fflate": "0.8.3", "react": "19.1.0", "react-dom": "19.1.0" } diff --git a/client/src/jsMain/kotlin/Main.kt b/client/src/jsMain/kotlin/Main.kt index b42b58d..8daa023 100644 --- a/client/src/jsMain/kotlin/Main.kt +++ b/client/src/jsMain/kotlin/Main.kt @@ -8,19 +8,35 @@ import react.dom.client.createRoot import react.dom.html.ReactHTML.div import react.dom.html.ReactHTML.h1 import react.dom.html.ReactHTML.p +import react.dom.html.ReactHTML.pre import web.dom.ElementId import web.dom.document import web.html.HTMLElement import kotlinx.browser.window +import Protocol.CompressionSelfTest import Protocol.ContainerDump import Protocol.IsNodeRuntime import Protocol.SelfTest -// 页面根组件:展示脚手架就绪信息与当前 Kotlin/JS 版本号 +// 浏览器侧压缩帧解压自检:页面加载即自动跑一次(同步),结果同时打到 console 与页面。 +// lazy:RenderApp 会被 window.TankIcClientMain 再调一次,避免重复解压。 +private val BrowserSelfTestLines: List by lazy { + val Report = CompressionSelfTest.RunEmbedded() + val Lines = ArrayList() + Lines.add("压缩帧解压自检(fflate.unzlibSync,与 node 侧同一份实现)") + Lines.addAll(Report.Lines) + Lines.add(if (Report.Passed) "结论:✓ 通过" else "结论:✗ 未通过") + // Kotlin/JS 的 println 就是 console.log(这里直接逐行输出到 console) + for (L in Lines) println("TankIcClient | $L") + Lines +} + +// 页面根组件:展示脚手架就绪信息、Kotlin/JS 版本号,以及上面那次解压自检的逐项结果 private val App = FC { div { - h1 { +"TankIcClient 脚手架就绪" } + h1 { +"TankIcClient 脚手架就绪(M3:压缩帧解压)" } p { +"当前 Kotlin/JS 版本:${KotlinVersion.CURRENT}" } + pre { +BrowserSelfTestLines.joinToString("\n") } } } diff --git a/client/src/jsMain/kotlin/Protocol/CompressionSelfTest.kt b/client/src/jsMain/kotlin/Protocol/CompressionSelfTest.kt new file mode 100644 index 0000000..bcc7185 --- /dev/null +++ b/client/src/jsMain/kotlin/Protocol/CompressionSelfTest.kt @@ -0,0 +1,161 @@ +// CompressionSelfTest.kt —— 压缩帧解压自检(browser 与 node 共用同一段判定逻辑) +// +// 样例(真实抓包,只读): +// 1) 3DTank/packets/plain/space-100045260/s2c_0000.bin +// 整帧 915 字节 = 2 字节长度头(0x4391:bit6=1 → zlib 压缩,低 14 位 0x391 = 913 字节帧体) +// + 913 字节压缩帧体(78 9c …)→ 解压后 **1799** 字节帧体 +// (1B nullmap + 1 条 zt 容器记录:16B 记录头 + 1782B 负载,精确吃满) +// 2) 3DTank/packets/plain/space-100073070/s2c_0004.bin +// 整帧 41590 字节 = 4 字节长度头 + 41586 字节压缩帧体 → 解压后 **255844** 字节帧体 +// (1 条 pt 容器记录 + 28 条 fields 记录 = 29 条) +// +// 期望值全部实测自服务端权威实现:3DTank/tools/server/link.js 的 zlib.inflateSync +// (帧体 SHA256)+ 3DTank/tools/server/semantic.js 的 DecodeFrameBody(记录口径)。 +package Protocol + +// 一条压缩帧样例的期望值(-- = 该项不校验) +internal class CompressionExpect( + val BodyBytes: Int, + val First8Hex: String, + val RecordCount: Int, + val ContainerLayouts: String, // 容器布局,多条用 , 连接 + val FieldRecordCount: Int, + val FirstMethodId: String, + val SHA256: String, +) + +internal class CompressionReport(val Lines: List, val Passed: Boolean) + +internal object CompressionSelfTest { + + // 内嵌样例 = space-100045260/s2c_0000.bin 的整帧 hex(915 字节 → 1830 个 hex 字符)。 + // 浏览器侧没有文件系统,只能把这份真实压缩帧原样搬进代码。 + private val SampleFrameHexParts = listOf( + "4391789c6d947f2c94711cc79f1395bb22245be747a20edd6495d22a899aad2473adcba4dfe7aa4b3f50d38f7395f2e3", + "84903b2d398ee9d486993a8d6caa4542d90a4942fae1fcaa8556428fc7739fcff347cfed769f7beff5bceffdf97c3fcf", + "110479198da6d6f3d5c6f3776b2bd3c9af2cfa4dea37ae911fb61c716ba671a55c69b3491a14ec1960b6b14a2a997db2", + "60e878cdb955253c77d581a4d46e0977e0f95eafdf9aa551cef7d78404be195e99a5b11828e4c56935d6f3c6bcf943e2", + "07514ec342dbdc6203a79951d9c70ebd68d744c7e746089a434dac7dad64492e459b432f6aaf3459f5af3864feb42685", + "ede564bf7fc165cb8322295bfde85c647eb5e3b6aa8387fbec05b5b3ec962daa5bae2895d5eb881b4f3a5a0529810121", + "d623a38b851c772e6b5fb92840e13028ae3de5113663b5dbd9c2c6a331215d4fbe19ac75bc577769d7f0c38c1379092f", + "64e66e029e56adcce044b07d2e38d7b47d77acda3c3a9ab033c59f47f6bc851c81aa30788f37c1265f545dee400d2647", + "a80de5d26a8eb0dcc59352d3b4f2701561483893b5d24d505132353fd6cd1d11ceb6a83e36d4abbdc60c769c98764892", + "6d60b04db49adc2667a8ae947a273e799517adde49544865b49acebaa757e373149994aa6c3c5db29ece9b3da89b1050", + "eaad1f45736d40ed8b56d26ad6aed5a8b67450eae5fee833b184d9f46f8427278411d465d3e42b228c69552e69a6aaab", + "7c516b0179bf095587da8e53d3496cd9dbab2316524466c4a4381bd83c64bd4f025b07ec78e47960cf337c1b80ad46df", + "c95260d391b5fb096c27fa7ecb9f66455f5ece9f22c93a6364244c42b109653dba186027fc342cbd6f1ac337077cdb31", + "0327113224226bc307b60dd967c990c11233c48ae90cdd8fed31afc722969e354776898a66bf54001b49708b216f3e63", + "666e90e11df636a4860c16e8eb5949fb7eda7a0433ece482af9c716e51e0fb017bf3c333d630e630006c07faaaa4c05e", + "67b0fec0d6635ec97660ef32d831605fe31cd65a039bc9608f00db85be7173805533cef838b0efb1b7b8766015ffddc9", + "6eecadb6136696cbf00d02f615fae6ff01df54069b05ec47649d245475bb679d838e666ff7f854bf862d31c2d36ca8a0", + "4ff32baf19a7132c037601b0c3653efa27e0b4294ec7157dadd037cb4abf7d7eb825939ff8c09a21cb2668f673582dfa", + "16ba023b17d9ef7f69b633cf032769894f8029b28f52f59bfaeb3efa7256006b82ac704c9fe12dfe134cd4903bf90f5b", + "cffe03", + ) + + val SampleFrameHex: String get() = SampleFrameHexParts.joinToString("") + + val SampleExpect = CompressionExpect( + BodyBytes = 1799, + First8Hex = "000000000005f691", + RecordCount = 1, + ContainerLayouts = "zt", + FieldRecordCount = 0, + FirstMethodId = "3216143066888387731", + SHA256 = "fe6c57584751699f9c68c9b00f47c21b34f73038d08b2d56c7b4e71575ec4544", + ) + + val MultiRecordExpect = CompressionExpect( + BodyBytes = 255844, + First8Hex = "c001091999999999", + RecordCount = 29, + ContainerLayouts = "pt", + FieldRecordCount = 28, + FirstMethodId = "7640916300855664666", + SHA256 = "8865fde7cd2269553986eefabce9f00e95977e48ee75fca4cb26e3267e10a88b", + ) + + // 浏览器侧自检:内嵌样例,无文件系统;SHA256 传 null(浏览器侧不校验,页面显示长度与前 8 字节) + fun RunEmbedded(): CompressionReport = + Check("space-100045260/s2c_0000.bin(内嵌 hex 整帧,915 字节)", HexToBytes(SampleFrameHex), SampleExpect, null) + + // 通用校验:整帧 → ParseFrames → DecodeFrame(fflate 解压)→ SpaceFrame.Parse + fun Check( + Name: String, + Raw: ByteArray, + Expect: CompressionExpect, + SHA256Of: ((ByteArray) -> String)?, + ): CompressionReport { + val Lines = ArrayList() + var Passed = true + Lines.add("[$Name]") + + val Info = Frame.PeekFrame(Raw, 0) + val WireFrames = Frame.ParseFrames(Raw) + if (Info == null || WireFrames == null || WireFrames.size != 1) { + Lines.add(" ✗ 整帧流解析失败(PeekFrame=${Info != null},帧数=${WireFrames?.size ?: -1})") + return CompressionReport(Lines, false) + } + val Wire = WireFrames[0] + Lines.add( + " 整帧 ${Raw.size} 字节 | 长度头 ${Info.HeaderSize} 字节 | 压缩=${Info.Compressed} | 帧体(线上) ${Wire.Body.size} 字节" + ) + + val Body = try { + Frame.DecodeFrame(Wire) + } catch (E: ProtocolException) { + Lines.add(" ✗ ${E.message}") + return CompressionReport(Lines, false) + } + + Passed = Cmp(Lines, "解压后帧体", Body.size, Expect.BodyBytes) && Passed + Passed = Cmp(Lines, "帧体前 8 字节", Body.copyOfRange(0, 8).Hex(), Expect.First8Hex) && Passed + if (SHA256Of != null) { + Passed = Cmp(Lines, "帧体 SHA256", SHA256Of(Body).lowercase(), Expect.SHA256) && Passed + } else { + Lines.add(" 帧体 SHA256 (浏览器侧不校验;node 侧自检会与 zlib.inflateSync 的实测值比对)") + } + + val Parsed = SpaceFrame.Parse(Body) + val Layouts = Parsed.Records.filter { it.Kind == "container" }.joinToString(",") { it.Layout } + val FieldCount = Parsed.Records.count { it.Kind == "fields" } + val FirstMethodId = Parsed.Records.firstOrNull()?.MethodId ?: "" + Lines.add(" 记录 ${Parsed.Records.size} 条(容器 [$Layouts] / fields $FieldCount 条),Bad=${Parsed.Bad}") + Passed = Cmp(Lines, "记录数", Parsed.Records.size, Expect.RecordCount) && Passed + Passed = Cmp(Lines, "容器布局", Layouts, Expect.ContainerLayouts) && Passed + Passed = Cmp(Lines, "fields 记录数", FieldCount, Expect.FieldRecordCount) && Passed + Passed = Cmp(Lines, "第一条记录 methodId", FirstMethodId, Expect.FirstMethodId) && Passed + Passed = Cmp(Lines, "记录 Bad", Parsed.Bad, false) && Passed + + Lines.add(if (Passed) " ✓ 与期望值一致" else " ✗ 与期望值不一致") + return CompressionReport(Lines, Passed) + } + + private fun Cmp(Lines: MutableList, Label: String, Actual: Any, Expected: Any): Boolean { + val Ok = Actual == Expected + Lines.add(" $Label = $Actual 期望 $Expected ${if (Ok) "✓" else "✗"}") + return Ok + } + + // hex → 字节(大小写不敏感;奇数长度或非法字符直接抛错,不静默截断) + fun HexToBytes(Hex: String): ByteArray { + if (Hex.length % 2 != 0) throw ProtocolException("hex 长度 ${Hex.length} 不是偶数") + val Out = ByteArray(Hex.length / 2) + for (I in Out.indices) { + val Hi = HexDigit(Hex[I * 2]) + val Lo = HexDigit(Hex[I * 2 + 1]) + Out[I] = ((Hi shl 4) or Lo).toByte() + } + return Out + } + + private fun HexDigit(C: Char): Int { + val V = when (C) { + in '0'..'9' -> C - '0' + in 'a'..'f' -> C - 'a' + 10 + in 'A'..'F' -> C - 'A' + 10 + else -> throw ProtocolException("非法 hex 字符 '$C'") + } + return V + } +} diff --git a/client/src/jsMain/kotlin/Protocol/Connection.kt b/client/src/jsMain/kotlin/Protocol/Connection.kt index 93b55bc..fedbd7a 100644 --- a/client/src/jsMain/kotlin/Protocol/Connection.kt +++ b/client/src/jsMain/kotlin/Protocol/Connection.kt @@ -20,12 +20,15 @@ internal fun IsNodeRuntime(): Boolean = js("typeof process !== 'undefined' && !!(process.versions && process.versions.node)") as Boolean // Node 专有的模块加载(只在 IsNodeRuntime() 为真时调用)。 +// 保留原因:**协议路径已经不用它了**(M3 起帧体解压走 Zlib.kt 的 fflate,两端同一份实现), +// 现在只剩 node 侧的自检/工具在用:ContainerDump 读抓包文件(fs)、SelfTest 算帧体 SHA256(crypto)。 // 刻意用 module.require 而不是 require:后者会被 browser 目标的 webpack 静态解析成 -// 「Can't resolve 'fs' / 'zlib'」,导致 browser 构建直接失败(webpack 5 对表达式形式的 +// 「Can't resolve 'fs'」,导致 browser 构建直接失败(webpack 5 对表达式形式的 // module.require 不做静态解析,browser 侧永远走不到这里)。 internal fun NodeRequire(Name: String): dynamic = js("module.require(Name)") -// Node Buffer → Kotlin ByteArray(Kotlin/JS 的 ByteArray 运行时就是 Int8Array) +// Node Buffer → Kotlin ByteArray(Kotlin/JS 的 ByteArray 运行时就是 Int8Array)。 +// 现在只被 node 侧自检路径使用(ContainerDump 读文件);协议路径不再依赖。 internal fun BufferToByteArray(Buf: dynamic): ByteArray = js("new Int8Array(Buf.buffer, Buf.byteOffset, Buf.byteLength)") as ByteArray diff --git a/client/src/jsMain/kotlin/Protocol/Frame.kt b/client/src/jsMain/kotlin/Protocol/Frame.kt index dc26c05..fbda3db 100644 --- a/client/src/jsMain/kotlin/Protocol/Frame.kt +++ b/client/src/jsMain/kotlin/Protocol/Frame.kt @@ -63,22 +63,11 @@ internal object Frame { } // 解压帧体(对应 link.js 的 DecodeFrame)。 - // Node 下走 zlib.inflateSync(与 link.js 的 zlib.inflateSync 同一套算法,逐字节一致); - // browser 侧没有 zlib 绑定,遇到压缩帧如实抛错,绝不静默返回半截数据。 + // M3 起 browser 与 node 走**同一条**代码路径:纯 JS 的 fflate.inflateSync(见 Zlib.kt), + // 不再有「Node 用 zlib、browser 抛错」的两条路。签名保持同步(不是 suspend、不返回 Promise)。 fun DecodeFrame(Frame: WireFrame): ByteArray { if (!Frame.Compressed) return Frame.Body - if (!IsNodeRuntime()) { - throw ProtocolException( - "browser 侧未接 zlib:收到 zlib 压缩帧(帧体 ${Frame.Body.size} 字节,首字节 0x" + - (Frame.Body.firstOrNull()?.let { (it.toInt() and 0xff).toString(16).padStart(2, '0') } ?: "?") + - ")。当前只处理明文帧。" - ) - } - // 注意:不能把 Frame.Body 直接写进 js(...) —— Kotlin/JS 会给属性名加后缀(Body_xxx), - // 而 js() 里的代码是原样发出的 JS,必须先把值取到普通局部变量再引用。 - val Raw = Frame.Body - val Inflated = NodeRequire("zlib").inflateSync(js("Buffer.from(Raw)")) - return BufferToByteArray(Inflated) + return Inflate(Frame.Body) } // 保活帧 = 负载为空的帧(实测 3 字节:00 01 00) diff --git a/client/src/jsMain/kotlin/Protocol/SelfTest.kt b/client/src/jsMain/kotlin/Protocol/SelfTest.kt index 87819dc..f88ab65 100644 --- a/client/src/jsMain/kotlin/Protocol/SelfTest.kt +++ b/client/src/jsMain/kotlin/Protocol/SelfTest.kt @@ -43,8 +43,10 @@ internal object SelfTest { private var Finished = false fun Run() { - Log("=== 3DTank 客户端 M2 自检(nodejs 目标)===") + Log("=== 3DTank 客户端 M3 自检(nodejs 目标)===") Log("目标服务器 $Url 目标空间 $TargetSpaceId") + // M3:压缩帧解压自检放在最前面(同步执行,不依赖服务器),用的都是真实抓包帧 + RunCompressionSelfTest() val Tables = ProtocolTables.Data Log( "协议表 GeneratedAt=${Tables.GeneratedAt} 下行方法 ${Tables.Selection.DownlinkMethodIds} 条 / " + @@ -61,6 +63,70 @@ internal object SelfTest { C.Connect() } + // ---------------- M3 压缩帧解压自检(fflate;帧体 SHA256 与服务端 link.js 的 zlib.inflateSync 实测值比对) + + private fun RunCompressionSelfTest(): Boolean { + Log("--- 压缩帧解压自检(fflate.unzlibSync,browser/node 同一份实现)---") + val Cases = listOf( + "space-100045260/s2c_0000.bin" to CompressionSelfTest.SampleExpect, + "space-100073070/s2c_0004.bin" to CompressionSelfTest.MultiRecordExpect, + ) + var AllPassed = true + var Index = 0 + for ((Rel, Expect) in Cases) { + Index++ + val Found = ReadPacket(Rel) + if (Found == null) { + Log("[$Index] $Rel ✗ 没找到(候选目录见下;可用环境变量 TANKIC_PACKET_DIR 指定)") + Log(" 候选:${PacketDirs().joinToString(" | ")}") + AllPassed = false + continue + } + val (Path, Raw) = Found + Log("[$Index] $Rel ← $Path") + val Report = CompressionSelfTest.Check(Rel, Raw, Expect) { SHA256Hex(it) } + for (L in Report.Lines.drop(1)) Log(L) + AllPassed = AllPassed && Report.Passed + } + Log( + if (AllPassed) "✓ 压缩帧解压自检通过:解压结果与 link.js 的 zlib.inflateSync 逐字节一致(SHA256 相同)" + else "✗ 压缩帧解压自检未通过" + ) + Log("") + return AllPassed + } + + // 抓包目录候选:环境变量优先,其次从 node 进程 CWD 逐级往上找(gradle 的 jsNodeDevelopmentRun + // 把 CWD 设在 build/js/packages/TankIcClient,所以要往上好几级才到仓库根)。 + private fun PacketDirs(): List { + val Cwd = js("process.cwd()") as String + val Env = js("process.env.TANKIC_PACKET_DIR || ''") as String + val Dirs = ArrayList() + if (Env.isNotEmpty()) Dirs.add(Env) + for (Up in 0..6) { + val Prefix = if (Up == 0) "" else "../".repeat(Up) + Dirs.add(Cwd + "/" + Prefix + "3DTank/packets/plain") + } + return Dirs + } + + private fun ReadPacket(Rel: String): Pair? { + val Fs = NodeRequire("fs") + for (Dir in PacketDirs()) { + val Path = Dir + "/" + Rel + try { + return Path to BufferToByteArray(Fs.readFileSync(Path)) + } catch (E: dynamic) { + // 该候选目录下没有这个文件,试下一个 + } + } + return null + } + + // 帧体 SHA256(node 侧才调用;浏览器侧自检不校验哈希) + private fun SHA256Hex(Bytes: ByteArray): String = + NodeRequire("crypto").createHash("sha256").update(js("Buffer.from(Bytes)")).digest("hex") as String + private fun OnOpen() { val Req = Entry.BuildHashRequest(HashKeys, HashValues) Log("→ c2s Hash Request ${Req.size} 字节 hex=${Req.Hex()}") @@ -164,8 +230,8 @@ internal object SelfTest { if (Finished || PingCount < 1) return Finished = true Log("") - Log("✓ M2 自检通过:入口层握手 → 进空间 → 收到初始化帧(含容器记录)→ 收到 $PingCount 条服务端保活 ping") - Conn?.Close(1000, "M2 自检完成") + Log("✓ M3 自检通过:入口层握手 → 进空间 → 收到初始化帧(含容器记录)→ 收到 $PingCount 条服务端保活 ping") + Conn?.Close(1000, "M3 自检完成") SetTimeout(100) { ExitProcess(0) } } diff --git a/client/src/jsMain/kotlin/Protocol/Zlib.kt b/client/src/jsMain/kotlin/Protocol/Zlib.kt new file mode 100644 index 0000000..b733ead --- /dev/null +++ b/client/src/jsMain/kotlin/Protocol/Zlib.kt @@ -0,0 +1,66 @@ +// Zlib.kt —— 帧体 zlib 解压(browser 与 node **同一份**纯 JS 实现:fflate) +// +// 为什么用 fflate,而不用 Node 的 zlib、也不用浏览器的 DecompressionStream: +// 1. DecompressionStream 是流式 async API,会把 DecodeFrame 的**同步签名**污染成 async,影响面太大; +// fflate 的 unzlibSync 是同步纯 JS,DecodeFrame 可以原样保持同步。 +// 2. 官方客户端自己就 bundle 了 fflate:3DTank/src/module_29518.js:1172 导出 `n.unzlibSync = Gt`, +// 网络层 3DTank/src/module_24635.js:20 取的就是它(`g = n.unzlibSync`) +// ——真实浏览器端就是靠它解压帧的,我们与官方同路。 +// 3. 两端同一份实现 ⇒ 不会出现「Node 下能跑、浏览器下挂」的分裂。 +// +// fflate 的实际导出形态(0.8.3,见 lib/index.d.ts / lib/index.cjs): +// · **`inflateSync` 是「raw DEFLATE(无包装)」**(文档原话 "Expands DEFLATE data with no wrapper"), +// 拿它去解带 78 9c 头的 zlib 流会直接抛 "unexpected EOF"; +// zlib 包装对应的同步入口是 **`unzlibSync`**(源码:`inflt(data.subarray(zls(data), -4), {i:2}, …)`, +// 即剥掉 2 字节 zlib 头 + 4 字节 Adler-32)。 +// · 官方客户端用的也正是它:3DTank/src/module_24635.js:20 `g = n.unzlibSync` +// (n 即 module_29518.js:1172 导出的 fflate)。 +// · data 必须是 Uint8Array(Kotlin/JS 的 ByteArray 运行时是有符号 Int8Array,直接传会被当成负数); +// · 返回值可能是「大 buffer 上的子视图」,取字节要按 byteOffset/byteLength 切视图。 +package Protocol + +import org.khronos.webgl.Uint8Array + +// 命名空间导入:ESM 下是 `import * as Fflate from "fflate"`,CommonJS 下是 `require("fflate")`, +// 两种模块系统里都能拿到具名导出 unzlibSync,因此两端共用这一个声明。 +@JsModule("fflate") +@JsNonModule +internal external object Fflate { + // zlib 流(含 2 字节 zlib 头 78 9c … 与 4 字节 Adler-32 尾)→ 解压后的原始字节;opts 省略即默认 + fun unzlibSync(Data: Uint8Array): Uint8Array +} + +// 解压一个 zlib 帧体。失败时抛出**带上下文**的 ProtocolException(说明是 zlib inflate 失败、输入长度、 +// 首字节),绝不静默返回半截数据。 +internal fun Inflate(Compressed: ByteArray): ByteArray { + if (Compressed.isEmpty()) { + throw ProtocolException("zlib inflate 失败:输入为 0 字节(空帧体)") + } + val Out = try { + Fflate.unzlibSync(Compressed.AsUint8Array()) + } catch (E: dynamic) { + throw ProtocolException( + "zlib inflate 失败(fflate.unzlibSync):输入 ${Compressed.size} 字节," + + "首字节 0x${(Compressed[0].toInt() and 0xff).toString(16).padStart(2, '0')}," + + "原因:${JsErrorText(E)}" + ) + } + return Out.AsByteArray() +} + +// JS 抛出的值不一定是 Error 对象(fflate 正常路径抛 Error,保险起见两种形态都取) +private fun JsErrorText(Value: dynamic): String = js("String(Value && Value.message ? Value.message : Value)") + +// Kotlin/JS 的 ByteArray 运行时是 Int8Array(有符号),先转成同段内存的 Uint8Array 视图再交给 fflate。 +// 注意:不能把 this 直接写进 js(...) —— Kotlin/JS 可能给属性名加后缀,js() 里是原样发出的 JS。 +@Suppress("UNCHECKED_CAST") +private fun ByteArray.AsUint8Array(): Uint8Array { + val B = this + return js("new Uint8Array(B.buffer, B.byteOffset, B.byteLength)") as Uint8Array +} + +@Suppress("UNCHECKED_CAST") +private fun Uint8Array.AsByteArray(): ByteArray { + val R = this + return js("new Int8Array(R.buffer, R.byteOffset, R.byteLength)") as ByteArray +} diff --git a/client/src/jsMain/resources/index.html b/client/src/jsMain/resources/index.html index 72ff8e9..51bb263 100644 --- a/client/src/jsMain/resources/index.html +++ b/client/src/jsMain/resources/index.html @@ -4,9 +4,14 @@ TankIcClient + - +