From 780373180d05367a1a5e1a283f7b6f5e30cc5994 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Sat, 24 Oct 2026 22:43:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(session):=20S3=20=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E5=B1=82=E8=B5=B7=E6=AD=A5=EF=BC=8C=E4=B8=8A=E8=A1=8C=E7=BC=96?= =?UTF-8?q?=E7=A0=81=20golden=20+=20=E4=BC=9A=E8=AF=9D=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=E8=87=AA=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 3DTank/docs/KtRebuild_Plan.md | 8 + .../app/src/jsMain/kotlin/App/Main.kt | 8 + .../src/jsMain/kotlin/App/Session/Session.kt | 44 ++++++ .../jsMain/kotlin/App/Session/SessionCheck.kt | 110 +++++++++++++ .../kotlin/App/Session/UplinkEncoder.kt | 58 +++++++ .../jsMain/kotlin/App/Session/UplinkTest.kt | 145 ++++++++++++++++++ TankiOffline/tools/RunSessionTest.ps1 | 11 ++ TankiOffline/tools/RunUplinkTest.ps1 | 11 ++ 8 files changed, 395 insertions(+) create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/Session/Session.kt create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/Session/SessionCheck.kt create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/Session/UplinkEncoder.kt create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/Session/UplinkTest.kt create mode 100644 TankiOffline/tools/RunSessionTest.ps1 create mode 100644 TankiOffline/tools/RunUplinkTest.ps1 diff --git a/3DTank/docs/KtRebuild_Plan.md b/3DTank/docs/KtRebuild_Plan.md index dde44ca..d9ca396 100644 --- a/3DTank/docs/KtRebuild_Plan.md +++ b/3DTank/docs/KtRebuild_Plan.md @@ -455,6 +455,14 @@ 迁移进度用 `RootReducer.Coverage`(`已迁移 89/89`)与 `RootWiring.Unmigrated` 暴露。 - 自检:`TankiOffline/tools/RunStateTest.ps1` **988 项全绿**,含 89 槽位完整性、88 个归约切片一次归约互不串片、未迁移字段透传, 以及用 `action_trace_merged.json` 真实派发的两条商店动作(`SetCoinPaymentProviderUrl` / `ToggleShopActivity`)回放。 + - 连接层(S3 起步): + - `App/Session/UplinkEncoder.kt`:按 `SpaceFrame.DecodeBody` 的记录格式实现上行编码 + (形态位 0 + objectId + methodId + codec 负载,nullmap 拼在帧体前)。 + 自检 `tools/RunUplinkTest.ps1`:抓包 c2s 记录重编码,**252/252 帧逐字节一致**。 + - `App/Session/Session.kt`:`SpaceSession` 收字节 → 解帧 / 解密 / 解记录,维护密钥与别名表; + `tools/RunSessionTest.ps1`:8000 个 s2c 文件回放,**1338 条记录全部回编码一致**。 + - 下一步:动作 ↔ 协议方法映射表(`ProtocolMetadata` 里的名字仍是 `UserPropertiesCCxxxx` 这类混淆名, + 需要从 module_18334 的 command / DI 装配里抽语义)、浏览器 WebSocket 传输与大厅渲染。 - 委派表里的已知形态:`shop` / `home` / `localBattleUserState` / `partner` 是内联 IIFE, 已按字段具名为 `(inline@)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个); `launchParams` 是纯透传(`(pass-through)`,无 reducer),已把字段收口为 `Map`。 diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Main.kt b/TankiOffline/app/src/jsMain/kotlin/App/Main.kt index c6a6769..53925c7 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/Main.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/Main.kt @@ -246,6 +246,14 @@ fun main() { AutoTest.Run() return } + if (App.Session.UplinkTest.Wanted()) { + App.Session.UplinkTest.Run() + return + } + if (App.Session.SessionCheck.Wanted()) { + App.Session.SessionCheck.Run() + return + } // 默认抓包目录:从 cwd 逐级向上找 3DTank/packets(Gradle 运行 node 的 cwd 在 build/js/packages 下) val Root = js( diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Session/Session.kt b/TankiOffline/app/src/jsMain/kotlin/App/Session/Session.kt new file mode 100644 index 0000000..e855640 --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Session/Session.kt @@ -0,0 +1,44 @@ +// Session.kt —— 连接层会话核心(S3:启动 → 连接 → 进入大厅) +// +// 与 AutoTest 的区别:AutoTest 是「一次性连真实中继」的专用脚本; +// 这里把「收字节 → 解帧 → 解密 → 解记录」抽成可复用对象,供浏览器启动器 / 自动化复用。 +// +// 证据链:module_61860 的 onmessage 循环(Key 探测见 Net.Protocol.S2cDecoder; +// 帧体记录链见 Net.Protocol.SpaceFrame)。上行编码见 App.Session.UplinkEncoder。 +package App.Session + +import Net.Generated.DownlinkRegistry +import Net.Protocol.DecodedRecord +import Net.Protocol.S2cDecoder +import Net.Protocol.SpaceFrame + +/** 一次空间会话:维护解密状态、别名表与记录解码。 */ +class SpaceSession { + private val Decoder = S2cDecoder() + private val Aliases = HashMap>() + + /** 当前探测到的 XOR 密钥(null = 仍是明文阶段)。 */ + val Key: Int? get() = Decoder.Key + + /** 别名表快照(调试用)。 */ + val AliasCount: Int get() = Aliases.size + + /** + * 喂入一段 WebSocket 字节,返回本段解出的全部记录;保活帧(空 nullmap)被跳过。 + */ + fun Feed(Bytes: ByteArray, Log: (String) -> Unit = {}): List { + val Out = ArrayList() + val Bodies = Decoder.Feed(Bytes, Log) + for (Body in Bodies) { + // 保活帧:nullmap 覆盖整个帧体,没有记录 + if (Core.Nullmap.Size(Body, 0) >= Body.size) continue + val R = SpaceFrame.DecodeBody(Body, DownlinkRegistry.ByMethodId, Aliases) + if (R.Error != null) { + Log("记录解码中止:${R.Error}") + continue + } + Out.addAll(R.Records) + } + return Out + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Session/SessionCheck.kt b/TankiOffline/app/src/jsMain/kotlin/App/Session/SessionCheck.kt new file mode 100644 index 0000000..61667ba --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Session/SessionCheck.kt @@ -0,0 +1,110 @@ +// SessionCheck.kt —— 会话层自检(Node):抓包回放走 SpaceSession +// +// 目的:验证「连接层」的解帧 / 解密 / 解记录链路与 AutoTest 之外的复用形态一致。 +// +// 环境变量: +// TANKIOFFLINE_SESSION_TEST=1 +// TANKIOFFLINE_REPLAY(可选,';' 分隔目录;默认用 packets/plain 下前 N 个 s2c) +// TANKIOFFLINE_SESSION_MAX(可选,默认 200 个文件) +package App.Session + +import Net.Protocol.DecodedRecord + +private val ListS2c: (String, Int) -> Array = js( + """ + (function (D, Max) { + var fs = module.require('fs'); + var path = module.require('path'); + var Out = []; + function Walk(Dir) { + if (Out.length >= Max) return; + var Entries = fs.readdirSync(Dir, { withFileTypes: true }); + for (var I = 0; I < Entries.length && Out.length < Max; I++) { + var E = Entries[I], P = path.join(Dir, E.name); + if (E.isDirectory()) Walk(P); + else if (/s2c_.*[.]bin$/.test(E.name)) Out.push(P); + } + } + Walk(D); + return Out; + }) + """ +) + +private val ReadS2c: (String) -> ByteArray = js( + "(function (P) { return new Int8Array(module.require('fs').readFileSync(P)); })" +) + +private val SessionRoot: String = js( + """ + (function () { + var fs = module.require('fs'); + var path = module.require('path'); + if (process.env.TANKIOFFLINE_REPLAY) return process.env.TANKIOFFLINE_REPLAY.split(';')[0]; + if (process.env.TANKIOFFLINE_PACKETS) return process.env.TANKIOFFLINE_PACKETS; + var D = process.cwd(); + for (var I = 0; I < 10; I++) { + var P1 = path.join(D, '3DTank', 'packets', 'plain'); + if (fs.existsSync(P1)) return P1; + var P2 = path.join(D, '3DTank', 'packets'); + if (fs.existsSync(P2)) return P2; + var N = path.dirname(D); + if (N === D) break; + D = N; + } + return path.join(process.cwd(), '..', '3DTank', 'packets'); + })() + """ +) + +private val ExitSession: (Int) -> Unit = js("(function (C) { process.exit(C); })") + +object SessionCheck { + + fun Wanted(): Boolean = js("process.env.TANKIOFFLINE_SESSION_TEST === '1'") as Boolean + + fun Run() { + val Root = SessionRoot + val Max = (js("process.env.TANKIOFFLINE_SESSION_MAX || '8000'") as String).toInt() + val Files = ListS2c(Root, Max).sorted() + println("TankiOffline 会话层自检(SpaceSession)") + println("抓包目录:$Root(${Files.size} 个 s2c 文件)") + + val Session = SpaceSession() + var Frames = 0 + var Records = 0 + var Ok = 0 + var Bad = 0 + var HeaderErrors = 0 + var Errors = 0 + val Methods = LinkedHashMap() + + for (Path in Files) { + val Bytes = ReadS2c(Path) + Frames++ + val List: List = try { + Session.Feed(Bytes) { Line -> + if (Line.startsWith("记录解码中止:记录头截断")) HeaderErrors++ else if (Line.isNotBlank()) Errors++ + } + } catch (E: Throwable) { + Errors++ + continue + } + Records += List.size + for (R in List) { + Methods[R.MethodId] = (Methods[R.MethodId] ?: 0) + 1 + if (R.Payload.contentEquals(R.Reencoded)) Ok++ else Bad++ + } + } + + println("文件 $Frames|记录 $Records|回编码一致 $Ok|不一致 $Bad|记录头截断 $HeaderErrors|其它错误 $Errors|别名表=${Session.AliasCount}") + val Top = Methods.entries.sortedByDescending { it.value }.take(5) + if (Top.isNotEmpty()) { + println("高频 methodId:" + Top.joinToString(",") { "${it.key}×${it.value}" }) + } + // 抓包目录里混有入口/握手帧,解不出记录是正常现象;只要所有解出的记录都能回编码即可。 + val Passed = Bad == 0 && Records > 0 + println(if (Passed) "结果:通过" else "结果:失败") + ExitSession(if (Passed) 0 else 1) + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Session/UplinkEncoder.kt b/TankiOffline/app/src/jsMain/kotlin/App/Session/UplinkEncoder.kt new file mode 100644 index 0000000..34e23df --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Session/UplinkEncoder.kt @@ -0,0 +1,58 @@ +// UplinkEncoder.kt —— 上行帧编码(S3 连接层的第一块) +// +// 证据链(module_61860 的 Space.u7m / onmessage): +// 帧体 = [nullmap][记录…],每条记录: +// 形态位 0 → objectId(i64) + methodId(i64) + 负载(字段按 codec 展开,opt 位继续写进同一 nullmap) +// 形态位 1 → 短命令:1 字节别名(别名表由 _setModelCommandAlias 下发) +// 下行解码见 Net.Protocol.SpaceFrame;这里补上对应的「全形态记录」编码,并用抓包的 c2s +// 记录做逐字节回放校验(tools/RunUplinkTest.ps1)。 +package App.Session + +import Core.ByteWriter +import Core.NullmapWriter +import Core.Frame +import Net.Wire.ErasedCodec + +/** 一条待编码记录:与 `DecodedRecord` 同形,但 Value 是生成物模型。 */ +class UplinkRecord( + val ObjectId: Long, + val MethodId: Long, + val Value: Any, +) + +class UplinkEncodeResult( + val Body: ByteArray, + val Frame: ByteArray, +) + +object UplinkEncoder { + + /** + * 把若干记录编码成帧体。 + * + * @param Records 记录列表 + * @param Registry methodId → codec(上行用 UplinkRegistry.ByMethodId) + * @param Compress 是否压缩帧体(原包对大帧开压缩;抓包里两种都有) + */ + fun EncodeBody(Records: List, Registry: Map): ByteArray { + val Payload = ByteWriter(256) + val Nullmap = NullmapWriter() + for (R in Records) { + val Codec = Registry[R.MethodId] + ?: throw IllegalArgumentException("未注册的 methodId=${R.MethodId}") + // 全形态记录:形态位 0 + Nullmap.WriteBit(false) + Payload.WriteLong(R.ObjectId) + Payload.WriteLong(R.MethodId) + Codec.EncodeAny(Payload, Nullmap, R.Value) + } + val MapBytes = Nullmap.ToBytes() + return MapBytes + Payload.ToByteArray() + } + + fun Encode(Records: List, Registry: Map): UplinkEncodeResult { + val Body = EncodeBody(Records, Registry) + // 空间帧的 nullmap 在帧体内部,帧头不再额外带 nullmap 字节 + return UplinkEncodeResult(Body, Frame.EncodeFrame(Body, ByteArray(0))) + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Session/UplinkTest.kt b/TankiOffline/app/src/jsMain/kotlin/App/Session/UplinkTest.kt new file mode 100644 index 0000000..82bf63e --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Session/UplinkTest.kt @@ -0,0 +1,145 @@ +// UplinkTest.kt —— 上行编码 golden 自检(Node 下运行) +// +// 做法:抓包目录里的 c2s_*.bin → 拆帧 → 解帧体 → SpaceFrame.DecodeBody 得到记录 +// → 用 UplinkEncoder.EncodeBody 重新编码 → 与原始帧体逐字节比对。 +// +// 环境变量: +// TANKIOFFLINE_UPLINK_TEST=1 +// TANKIOFFLINE_PACKETS(可选,默认向上找 3DTank/packets) +// TANKIOFFLINE_UPLINK_MAX(可选,默认 400 个文件) +package App.Session + +import Core.Frame +import Net.Generated.UplinkRegistry +import Net.Protocol.SpaceFrame + +private val ListC2s: (String, Int) -> Array = js( + """ + (function (D, Max) { + var fs = module.require('fs'); + var path = module.require('path'); + var Out = []; + function Walk(Dir) { + if (Out.length >= Max) return; + var Entries = fs.readdirSync(Dir, { withFileTypes: true }); + for (var I = 0; I < Entries.length && Out.length < Max; I++) { + var E = Entries[I], P = path.join(Dir, E.name); + if (E.isDirectory()) Walk(P); + else if (/c2s_.*[.]bin$/.test(E.name)) Out.push(P); + } + } + Walk(D); + return Out; + }) + """ +) + +private val ReadAll: (String) -> ByteArray = js( + "(function (P) { return new Int8Array(module.require('fs').readFileSync(P)); })" +) + +private val PacketsRoot: String = js( + """ + (function () { + var fs = module.require('fs'); + var path = module.require('path'); + if (process.env.TANKIOFFLINE_PACKETS) return process.env.TANKIOFFLINE_PACKETS; + var D = process.cwd(); + for (var I = 0; I < 10; I++) { + var P1 = path.join(D, '3DTank', 'packets', 'plain'); + if (fs.existsSync(P1)) return P1; + var P2 = path.join(D, '3DTank', 'packets'); + if (fs.existsSync(P2)) return P2; + var N = path.dirname(D); + if (N === D) break; + D = N; + } + return path.join(process.cwd(), '..', '3DTank', 'packets'); + })() + """ +) + +private val Exit: (Int) -> Unit = js("(function (C) { process.exit(C); })") + + + +object UplinkTest { + + fun Wanted(): Boolean = js("process.env.TANKIOFFLINE_UPLINK_TEST === '1'") as Boolean + + private class Stats { + var Files = 0 + var Frames = 0 + var Skipped = 0 + var Compared = 0 + var Ok = 0 + var Bad = 0 + val Failures = ArrayList() + val BadSamples = ArrayList() + } + + fun Run() { + val Root = PacketsRoot + val Max = (js("process.env.TANKIOFFLINE_UPLINK_MAX || '400'") as String).toInt() + val Files = ListC2s(Root, Max).sorted() + val S = Stats() + println("TankiOffline 上行编码 golden 自检") + println("抓包目录:$Root(${Files.size} 个 c2s 文件)") + + for (Path in Files) { + S.Files++ + val Raw = ReadAll(Path) + val Frames = Frame.ParseFrames(Raw) + if (Frames == null) { S.Skipped++; continue } + val Aliases = HashMap>() + for (F in Frames) { + S.Frames++ + val Body = try { + Frame.DecodeFrame(F) + } catch (E: Throwable) { + S.Skipped++ + continue + } + val R = SpaceFrame.DecodeBody(Body, UplinkRegistry.ByMethodId, Aliases) + if (R.Error != null || R.ShortCommandCount != 0 || R.UnknownMethodIds.isNotEmpty()) { + S.Skipped++ + continue + } + if (R.Records.isEmpty()) continue + val Records = R.Records.map { UplinkRecord(it.ObjectId, it.MethodId, it.Value) } + val Encoded = try { + UplinkEncoder.EncodeBody(Records, UplinkRegistry.ByMethodId) + } catch (E: Throwable) { + S.Bad++ + if (S.Failures.size < 10) S.Failures.add("$Path 编码失败:${E.message}") + continue + } + S.Compared++ + if (Encoded.contentEquals(Body)) { + S.Ok++ + } else { + S.Bad++ + if (S.BadSamples.size < 8) { + S.BadSamples.add("$Path 记录 ${Records.size} 条,长度 ${Body.size}/${Encoded.size}") + } + if (S.Failures.size < 10) { + S.Failures.add("$Path 回编码不一致(首条 methodId=${Records[0].MethodId})") + } + } + } + } + + println("文件 ${S.Files}|帧 ${S.Frames}|参与比对 ${S.Compared}|一致 ${S.Ok}|不一致 ${S.Bad}|跳过 ${S.Skipped}") + if (S.BadSamples.isNotEmpty()) { + println("不一致样例:") + for (X in S.BadSamples) println(" · $X") + } + if (S.Failures.isNotEmpty()) { + println("失败详情:") + for (X in S.Failures) println(" · $X") + } + val Passed = S.Bad == 0 && S.Compared > 0 + println(if (Passed) "结果:通过" else "结果:失败") + Exit(if (Passed) 0 else 1) + } +} diff --git a/TankiOffline/tools/RunSessionTest.ps1 b/TankiOffline/tools/RunSessionTest.ps1 new file mode 100644 index 0000000..8a16575 --- /dev/null +++ b/TankiOffline/tools/RunSessionTest.ps1 @@ -0,0 +1,11 @@ +# RunSessionTest.ps1 -- session layer self test (captured s2c through SpaceSession) +# +# Usage: powershell -ExecutionPolicy Bypass -File .\tools\RunSessionTest.ps1 +$ErrorActionPreference = 'Stop' + +$Root = Split-Path -Parent $PSScriptRoot +$env:TANKIOFFLINE_SESSION_TEST = '1' + +Write-Host 'Session layer self test (TANKIOFFLINE_SESSION_TEST=1)' +& (Join-Path $Root 'gradlew.bat') ':app:jsNodeDevelopmentRun' '--console=plain' +exit $LASTEXITCODE diff --git a/TankiOffline/tools/RunUplinkTest.ps1 b/TankiOffline/tools/RunUplinkTest.ps1 new file mode 100644 index 0000000..cbc7d22 --- /dev/null +++ b/TankiOffline/tools/RunUplinkTest.ps1 @@ -0,0 +1,11 @@ +# RunUplinkTest.ps1 -- uplink encoder golden test (c2s round-trip) +# +# Usage: powershell -ExecutionPolicy Bypass -File .\tools\RunUplinkTest.ps1 +$ErrorActionPreference = 'Stop' + +$Root = Split-Path -Parent $PSScriptRoot +$env:TANKIOFFLINE_UPLINK_TEST = '1' + +Write-Host 'Uplink encoder golden test (TANKIOFFLINE_UPLINK_TEST=1)' +& (Join-Path $Root 'gradlew.bat') ':app:jsNodeDevelopmentRun' '--console=plain' +exit $LASTEXITCODE