feat(session): S3 连接层起步,上行编码 golden + 会话解码自检

This commit is contained in:
TermiNexus committed 2026-10-24 22:43:35 +08:00
1 parent b04f0f390d
commit 780373180d
8 files changed
+395

No files matched your search

+8
View File
@@ -455,6 +455,14 @@
迁移进度用 `RootReducer.Coverage`(`已迁移 89/89`)与 `RootWiring.Unmigrated` 暴露。 迁移进度用 `RootReducer.Coverage`(`已迁移 89/89`)与 `RootWiring.Unmigrated` 暴露。
- 自检:`TankiOffline/tools/RunStateTest.ps1` **988 项全绿**,含 89 槽位完整性、88 个归约切片一次归约互不串片、未迁移字段透传, - 自检:`TankiOffline/tools/RunStateTest.ps1` **988 项全绿**,含 89 槽位完整性、88 个归约切片一次归约互不串片、未迁移字段透传,
以及用 `action_trace_merged.json` 真实派发的两条商店动作(`SetCoinPaymentProviderUrl` / `ToggleShopActivity`)回放。 以及用 `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, - 委派表里的已知形态:`shop` / `home` / `localBattleUserState` / `partner` 是内联 IIFE,
已按字段具名为 `(inline@<field>)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个); 已按字段具名为 `(inline@<field>)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个);
`launchParams` 是纯透传(`(pass-through)`,无 reducer),已把字段收口为 `Map<String, String>`。 `launchParams` 是纯透传(`(pass-through)`,无 reducer),已把字段收口为 `Map<String, String>`。
@@ -246,6 +246,14 @@ fun main() {
AutoTest.Run() AutoTest.Run()
return 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 下) // 默认抓包目录:从 cwd 逐级向上找 3DTank/packets(Gradle 运行 node 的 cwd 在 build/js/packages 下)
val Root = js( val Root = js(
@@ -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<Int, Pair<Long, Long>>()
/** 当前探测到的 XOR 密钥(null = 仍是明文阶段)。 */
val Key: Int? get() = Decoder.Key
/** 别名表快照(调试用)。 */
val AliasCount: Int get() = Aliases.size
/**
* 喂入一段 WebSocket 字节,返回本段解出的全部记录;保活帧(空 nullmap)被跳过。
*/
fun Feed(Bytes: ByteArray, Log: (String) -> Unit = {}): List<DecodedRecord> {
val Out = ArrayList<DecodedRecord>()
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
}
}
@@ -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<String> = 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<Long, Int>()
for (Path in Files) {
val Bytes = ReadS2c(Path)
Frames++
val List: List<DecodedRecord> = 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)
}
}
@@ -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<UplinkRecord>, Registry: Map<Long, ErasedCodec>): 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<UplinkRecord>, Registry: Map<Long, ErasedCodec>): UplinkEncodeResult {
val Body = EncodeBody(Records, Registry)
// 空间帧的 nullmap 在帧体内部,帧头不再额外带 nullmap 字节
return UplinkEncodeResult(Body, Frame.EncodeFrame(Body, ByteArray(0)))
}
}
@@ -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<String> = 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<String>()
val BadSamples = ArrayList<String>()
}
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<Int, Pair<Long, Long>>()
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)
}
}
+11
View File
@@ -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
+11
View File
@@ -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