feat(session): S3 连接层起步,上行编码 golden + 会话解码自检
This commit is contained in:
1 parent
b04f0f390d
commit
780373180d
8 files changed
+395
No files matched your search
@@ -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@<field>)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个);
|
||||
`launchParams` 是纯透传(`(pass-through)`,无 reducer),已把字段收口为 `Map<String, String>`。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user