feat(client): 协议层 M3 —— 压缩帧解压两端统一走 fflate.unzlibSync

真实抓包里的关键帧是 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 瘦身)。
This commit is contained in:
TermiNexus committed 2026-09-18 10:31:37 +08:00
1 parent 61c27ef1b8
commit 1edb032481
10 files changed
+343 -22

No files matched your search

+7
View File
@@ -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"))
}
}
}
+7
View File
@@ -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"
},
+1
View File
@@ -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"
}
+18 -2
View File
@@ -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<String> by lazy {
val Report = CompressionSelfTest.RunEmbedded()
val Lines = ArrayList<String>()
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<Props> {
div {
h1 { +"TankIcClient 脚手架就绪" }
h1 { +"TankIcClient 脚手架就绪(M3:压缩帧解压)" }
p { +"当前 Kotlin/JS 版本:${KotlinVersion.CURRENT}" }
pre { +BrowserSelfTestLines.joinToString("\n") }
}
}
@@ -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<String>, 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<String>()
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<String>, 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
}
}
@@ -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
+3 -14
View File
@@ -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)
+69 -3
View File
@@ -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<String> {
val Cwd = js("process.cwd()") as String
val Env = js("process.env.TANKIC_PACKET_DIR || ''") as String
val Dirs = ArrayList<String>()
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<String, ByteArray>? {
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) }
}
+66
View File
@@ -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
}
+6 -1
View File
@@ -4,9 +4,14 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TankIcClient</title>
<style>
/* 自检结果用 <pre> 展示,允许折行以免长行溢出 */
pre { white-space: pre-wrap; word-break: break-all; }
</style>
</head>
<body>
<!-- React 挂载点,与 Main.kt 中的 id 保持一致 -->
<!-- React 挂载点,与 Main.kt 中的 id 保持一致。
页面加载即自动跑一次「压缩帧解压自检」(内嵌真实压缩帧),结果同时渲染到这里与 console。 -->
<div id="root"></div>
<!-- webpack 产物名由 build.gradle.kts 的 outputFileName 决定 -->
<script src="TankIcClient.js"></script>