feat(client): 协议层 M4 —— 上行帧构造与自动应答,形成问答闭环
新增 Uplink.kt:c2s 空间帧构造 [nullmap][靶 objectId i64][methodId i64][负载];ba.lcr(19B)/ls.xbl(19B)/G.q1wd(23B) 的构造结果与抓包逐字节相同(4 条帧全字节比对通过)。新增 AutoResponder.kt:收 LoadDependencies(zt) 自动回 G.q1wd(X),X 原样回传;防重放用「(X, 来源帧序号) 只回一次」而非「X 只回一次」——X 是回调号会被复用(3DTank/tools/server/schema.sql 头注 8),按 X 去重会吞掉合法应答;另加单连接 64 次上限兜底。Connection 加 SendBaLcr/SendLsXbl/SendGQ1wd 手动触发与 LastObjectsDataObjectIds()。口径修正:G.q1wd 的靶是「客户端进入的空间 id」,不是 s2c 记录头上的 objectId(证据:s2c_0000 记录 objectId=390801,而同会话 c2s_0001 靶=100045260)。端到端(服务端 session_055 佐证):客户端自动发出的 c2s_0002.bin 23B 与服务端落盘逐字节一致;注:100045260 的 _loadObjectsData 是模板第 2 帧(进空间即排期 +455ms),不是本次 G.q1wd 因果换来的,且查库定向应答默认 Scope.GQ1wd=false,服务端记为 Disabled/G.q1wd=1,故因果闭环需在有会话回放的 100073070 验证。
This commit is contained in:
1 parent
2a0296d0bb
commit
78ce1bacea
5 files changed
+353
-16
No files matched your search
@@ -0,0 +1,66 @@
|
||||
// AutoResponder.kt —— 客户端自动应答(M4 核心)
|
||||
//
|
||||
// 规则(出处:服务端 tools/server/modelservice.js 头注 + src/module_29554.js):
|
||||
// 服务端发 ModelService._loadDependencies(methodId 3216143066888387731,容器 zt)时会带上回调号 X
|
||||
// → 客户端把资源加载完后回 `G.q1wd(X)`(负载一个 i32,原样回传 X)
|
||||
// → 服务端再回 _loadObjectsData(pt)。X 是服务端给的,客户端不改写。
|
||||
//
|
||||
// 靶 id 的选择:G.q1wd 是空间级命令,靶 = **客户端进入的那个空间 id**(实测 space-100045260/c2s_0001.bin
|
||||
// 靶 100045260、space-100073070/c2s_0001.bin 靶 100073070,都是空间 id;不是 s2c 记录头上的 objectId)。
|
||||
//
|
||||
// 防重放 / 防风暴策略:**已回过的 (X, 来源帧序号) 只回一次**。
|
||||
// 为什么不用「X 只回一次」:X 是回调号,schema.sql 头注 8 明确说它会被复用(同一个 X 在不同批次/replay
|
||||
// 里再次下发是真实现象),按 X 去重会把合法应答吞掉。
|
||||
// 为什么 (X, 帧序号) 够用:帧序号在一次连接内唯一,同一帧被处理两次(重复调用 / 同一消息被重复分发)
|
||||
// 才会命中这条去重;不同帧上的同一个 X 仍然会正常再回。
|
||||
// 额外一道上限:单连接自动应答超过 MaxReplies 次就停手并打日志(本次实测只回 1 次,上限只是兜底)。
|
||||
// LoadObjectsData(pt) 是纯下行、不触发任何上行,所以这条链不可能自激成环。
|
||||
package Protocol
|
||||
|
||||
internal class AutoResponder(private val SpaceId: Long, private val Send: (ByteArray) -> Unit) {
|
||||
|
||||
private val RepliedKeys = HashSet<String>()
|
||||
private val RepliedXList = ArrayList<Int>()
|
||||
|
||||
var ReplyCount = 0
|
||||
private set
|
||||
var SuppressedCount = 0
|
||||
private set
|
||||
var CappedOut = false
|
||||
private set
|
||||
|
||||
// 扫一帧 s2c 空间帧里的记录,命中 _loadDependencies 就自动回 G.q1wd(X);返回要打的日志行
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun OnSpaceFrame(Parsed: SpaceFrameBody, FrameNo: Int): List<String> {
|
||||
val Lines = ArrayList<String>()
|
||||
val Target = Uplink.LOAD_DEPENDENCIES.toString()
|
||||
for (R in Parsed.Records) {
|
||||
if (R.MethodId != Target) continue
|
||||
val V = R.Value as? Map<String, Any?> ?: continue
|
||||
val X = (V["X"] as? Number)?.toInt() ?: continue
|
||||
val Key = "$X@$FrameNo"
|
||||
if (!RepliedKeys.add(Key)) {
|
||||
SuppressedCount++
|
||||
Lines.add("(防重放:X=$X 在帧#$FrameNo 已回过,本次跳过)")
|
||||
continue
|
||||
}
|
||||
if (ReplyCount >= MAX_REPLIES) {
|
||||
CappedOut = true
|
||||
Lines.add("(限流:本连接自动应答已达上限 $MAX_REPLIES 次,X=$X 不再回)")
|
||||
continue
|
||||
}
|
||||
val Frame = Uplink.BuildGQ1wd(SpaceId, X)
|
||||
Send(Frame)
|
||||
ReplyCount++
|
||||
RepliedXList.add(X)
|
||||
Lines.add("自动应答 → c2s G.q1wd(X=$X) 靶(空间)=$SpaceId ${Frame.size} 字节 hex=${Frame.Hex()}")
|
||||
}
|
||||
return Lines
|
||||
}
|
||||
|
||||
fun RepliedXs(): List<Int> = RepliedXList.toList()
|
||||
|
||||
private companion object {
|
||||
const val MAX_REPLIES = 64
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,42 @@ internal class Connection(private val Url: String) {
|
||||
SetIntervalJs(IntervalMs) { Send(Frame.PingFrame()) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- M4:c2s 空间帧
|
||||
|
||||
// 手动触发(不自动发):ba.lcr / ls.xbl 都由调用方给靶 id
|
||||
fun SendBaLcr(TargetId: Long) {
|
||||
Send(Uplink.BuildBaLcr(TargetId))
|
||||
}
|
||||
|
||||
fun SendLsXbl(TargetId: Long) {
|
||||
Send(Uplink.BuildLsXbl(TargetId))
|
||||
}
|
||||
|
||||
fun SendGQ1wd(TargetId: Long, X: Int) {
|
||||
Send(Uplink.BuildGQ1wd(TargetId, X))
|
||||
}
|
||||
|
||||
// LoadObjectsData(pt) 最近一帧的对象列表(= ba.lcr / ls.xbl 这类命令的候选靶 id)
|
||||
private val LastObjectsIds = ArrayList<Long>()
|
||||
|
||||
fun LastObjectsDataObjectIds(): List<Long> = LastObjectsIds.toList()
|
||||
|
||||
// 记一帧 s2c 空间帧:看到 LoadObjectsData(pt) 就把它的对象 id 列表换掉(空列表表示这一帧里没有对象)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun NoteSpaceFrame(Parsed: SpaceFrameBody) {
|
||||
for (R in Parsed.Records) {
|
||||
if (R.MethodId != Uplink.LOAD_OBJECTS_DATA.toString()) continue
|
||||
val V = R.Value as? Map<String, Any?> ?: continue
|
||||
val Objects = V["Objects"] as? List<Any?> ?: continue
|
||||
LastObjectsIds.clear()
|
||||
for (O in Objects) {
|
||||
val M = O as? Map<String, Any?> ?: continue
|
||||
val Id = M["ObjectId"] as? String ?: continue
|
||||
LastObjectsIds.add(Id.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Close(Code: Int, Reason: String) {
|
||||
Socket?.close(Code, Reason)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// SelfTest.kt —— 协议层自检(nodejs 目标下无头运行)
|
||||
//
|
||||
// 链路:连 ws://localhost:9091/ → 发 Hash Request → 收 Hash Response(解出 hash/加密标志)
|
||||
// → 发 Auth/EnterSpace(100045260) → 收初始化帧并逐条打印记录
|
||||
// → 收到至少 1 条服务端保活 ping 后正常退出。
|
||||
// M2 起:容器记录(lt/pt/zt)能定界并解成 JSON,自检打印摘要与一个完整对象的 JSON。
|
||||
// M4 链路(自检脚本的「端到端闭环」部分):
|
||||
// 1. 连 ws://localhost:9091/ → Hash Request → Hash Response → Auth/EnterSpace(100045260)
|
||||
// 2. 收到 LoadDependencies(zt, X) → AutoResponder 自动回 G.q1wd(X)
|
||||
// 3. 收 LoadObjectsData(pt) → 打印对象数/项数(并记下候选靶 id 列表)
|
||||
// 4. 手动触发一次 ls.xbl(-2147481788)(靶 id 取自抓包)→ 观察服务端应答
|
||||
// 5. 正常退出
|
||||
// 另外两段纯本地自检:M3 的压缩帧解压、M4 的 c2s 空间帧构造字节级比对。
|
||||
// 服务端日志(3DTank/server-logs/store-02/session_NNN/log.txt)是「客户端到底发出去什么、服务端怎么答」的权威依据。
|
||||
package Protocol
|
||||
|
||||
internal object SelfTest {
|
||||
@@ -11,6 +15,9 @@ internal object SelfTest {
|
||||
private const val Url = "ws://localhost:9091/"
|
||||
private const val TargetSpaceId = 100045260L
|
||||
|
||||
// 手动触发的 ls.xbl 靶 id:原样取自抓包 space-100073070/c2s_0002.bin 的目标 id
|
||||
private const val ManualLsXblTargetId = -2147481788L
|
||||
|
||||
// 与真实抓包 packets/plain/space-100045260/c2s_0000.bin(571 字节整帧)完全一致的键/值,
|
||||
// 这样自检发出的 Hash Request 可以与该样本逐字节比对(见报告里的 hex)。
|
||||
private val HashKeys = listOf(
|
||||
@@ -37,16 +44,27 @@ internal object SelfTest {
|
||||
)
|
||||
|
||||
private var Conn: Connection? = null
|
||||
private var Auto: AutoResponder? = null
|
||||
private var FrameNo = 0
|
||||
private var PingCount = 0
|
||||
private var EnterSent = false
|
||||
private var Finished = false
|
||||
|
||||
// 闭环步骤状态
|
||||
private var DepsSeen = false
|
||||
private var DepsX = ""
|
||||
private var ObjectsSeen = false
|
||||
private var ObjectsCount = 0
|
||||
private var ItemsCount = 0
|
||||
private var LsSent = false
|
||||
private var UnloadSeen = false
|
||||
private var BuildSelfTestPassed = true
|
||||
|
||||
fun Run() {
|
||||
Log("=== 3DTank 客户端 M3 自检(nodejs 目标)===")
|
||||
Log("=== 3DTank 客户端 M4 自检(nodejs 目标)===")
|
||||
Log("目标服务器 $Url 目标空间 $TargetSpaceId")
|
||||
// M3:压缩帧解压自检放在最前面(同步执行,不依赖服务器),用的都是真实抓包帧
|
||||
// 两段本地自检放最前面(同步执行,不依赖服务器)
|
||||
RunCompressionSelfTest()
|
||||
RunUplinkBuildSelfTest()
|
||||
val Tables = ProtocolTables.Data
|
||||
Log(
|
||||
"协议表 GeneratedAt=${Tables.GeneratedAt} 下行方法 ${Tables.Selection.DownlinkMethodIds} 条 / " +
|
||||
@@ -56,6 +74,7 @@ internal object SelfTest {
|
||||
|
||||
val C = Connection(Url)
|
||||
Conn = C
|
||||
Auto = AutoResponder(TargetSpaceId) { C.Send(it) }
|
||||
C.OnOpen = { OnOpen() }
|
||||
C.OnMessage = { OnMessage(it) }
|
||||
C.OnClose = { Log("连接关闭 code=$it") }
|
||||
@@ -63,6 +82,24 @@ internal object SelfTest {
|
||||
C.Connect()
|
||||
}
|
||||
|
||||
// ---------------- M4 (a) c2s 空间帧构造自检(与真实抓包逐字节比对)
|
||||
|
||||
private fun RunUplinkBuildSelfTest() {
|
||||
Log("--- c2s 空间帧构造自检(与抓包整帧逐字节比对)---")
|
||||
var AllPassed = true
|
||||
for (Case in UplinkSelfTest.Cases) {
|
||||
val Found = ReadPacket(Case.RelPath)
|
||||
val FileBytes = Found?.second
|
||||
if (Found != null) Log(" 抓包文件 ← ${Found.first}")
|
||||
val Report = UplinkSelfTest.Check(Case, FileBytes)
|
||||
for (L in Report.Lines) Log(L)
|
||||
AllPassed = AllPassed && Report.Passed
|
||||
}
|
||||
BuildSelfTestPassed = AllPassed
|
||||
Log(if (AllPassed) "✓ 帧构造自检通过:4 条上行帧的构造结果与抓包 hex 完全相同" else "✗ 帧构造自检未通过")
|
||||
Log("")
|
||||
}
|
||||
|
||||
// ---------------- M3 压缩帧解压自检(fflate;帧体 SHA256 与服务端 link.js 的 zlib.inflateSync 实测值比对)
|
||||
|
||||
private fun RunCompressionSelfTest(): Boolean {
|
||||
@@ -136,9 +173,7 @@ internal object SelfTest {
|
||||
private fun OnMessage(Raw: ByteArray) {
|
||||
FrameNo++
|
||||
if (Frame.IsPingFrame(Raw)) {
|
||||
PingCount++
|
||||
Log("← s2c 帧#$FrameNo 整帧 ${Raw.size} 字节 保活 ping(空负载帧,仅 nullmap)")
|
||||
MaybeFinish()
|
||||
return
|
||||
}
|
||||
var Off = 0
|
||||
@@ -165,15 +200,24 @@ internal object SelfTest {
|
||||
val MethodIdAt8 = if (At + 16 <= Body.size) Body.ReadI64(At + 8).toString() else ""
|
||||
// 空间帧判据(口径同 M1,M2 追加 codec_fields 兜底):methodId 能在任一协议表里查到就是空间记录流
|
||||
if (ProtocolTables.Downlink(MethodIdAt8) != null || ProtocolTables.CodecByMethod(MethodIdAt8) != null) {
|
||||
PrintSpaceFrame(TotalBytes, Body, NmSize)
|
||||
val Parsed = SpaceFrame.Parse(Body)
|
||||
Conn?.NoteSpaceFrame(Parsed)
|
||||
val AutoLines = Auto?.OnSpaceFrame(Parsed, FrameNo) ?: emptyList()
|
||||
PrintSpaceFrame(TotalBytes, NmSize, Parsed, AutoLines)
|
||||
TrackSteps(Parsed)
|
||||
return
|
||||
}
|
||||
PrintEntryFrame(TotalBytes, Body, NmSize)
|
||||
}
|
||||
|
||||
private fun PrintSpaceFrame(TotalBytes: Int, Body: ByteArray, NmSize: Int) {
|
||||
val Parsed = SpaceFrame.Parse(Body)
|
||||
private fun PrintSpaceFrame(
|
||||
TotalBytes: Int,
|
||||
NmSize: Int,
|
||||
Parsed: SpaceFrameBody,
|
||||
AutoLines: List<String>,
|
||||
) {
|
||||
Log("← s2c 帧#$FrameNo 整帧 $TotalBytes 字节 | nullmap $NmSize 字节 | 记录数 ${Parsed.Records.size}")
|
||||
for (L in AutoLines) Log(" $L")
|
||||
for (I in Parsed.Records.indices) {
|
||||
val R = Parsed.Records[I]
|
||||
val Spec = ProtocolTables.Downlink(R.MethodId)
|
||||
@@ -212,6 +256,53 @@ internal object SelfTest {
|
||||
}
|
||||
}
|
||||
|
||||
// 闭环步骤跟踪:认 LoadDependencies / LoadObjectsData / UnloadObjects 三条
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun TrackSteps(Parsed: SpaceFrameBody) {
|
||||
for (R in Parsed.Records) {
|
||||
when (R.MethodId) {
|
||||
Uplink.LOAD_DEPENDENCIES.toString() -> {
|
||||
if (DepsSeen) continue
|
||||
DepsSeen = true
|
||||
val V = R.Value as? Map<String, Any?>
|
||||
DepsX = (V?.get("X") ?: "?").toString()
|
||||
val AutoN = Auto?.ReplyCount ?: 0
|
||||
Log(" ✓ 步骤2:收到 LoadDependencies(zt, X=$DepsX);自动应答已回 G.q1wd ${AutoN} 次")
|
||||
}
|
||||
Uplink.LOAD_OBJECTS_DATA.toString() -> {
|
||||
val V = R.Value as? Map<String, Any?>
|
||||
val Objs = (V?.get("Objects") as? List<Any?>)?.size ?: 0
|
||||
val Items = (V?.get("Items") as? List<Any?>)?.size ?: 0
|
||||
ObjectsSeen = true
|
||||
ObjectsCount = Objs
|
||||
ItemsCount = Items
|
||||
Log(" ✓ 步骤3:收到 LoadObjectsData(pt) 对象数=$Objs 项数=$Items")
|
||||
val Ids = Conn?.LastObjectsDataObjectIds() ?: emptyList()
|
||||
if (Ids.isNotEmpty()) {
|
||||
Log(
|
||||
" LastObjectsDataObjectIds() 候选靶 id:" + Ids.take(8).joinToString(",") +
|
||||
(if (Ids.size > 8) " … 共 ${Ids.size} 个" else "")
|
||||
)
|
||||
}
|
||||
if (!LsSent) {
|
||||
LsSent = true
|
||||
val F = Uplink.BuildLsXbl(ManualLsXblTargetId)
|
||||
Log("→ c2s ls.xbl(靶=$ManualLsXblTargetId) ${F.size} 字节 hex=${F.Hex()}")
|
||||
Conn?.Send(F)
|
||||
SetTimeout(3000) { Finish("步骤3 之后等待 3s,收集服务端应答") }
|
||||
}
|
||||
}
|
||||
Uplink.UNLOAD_OBJECTS.toString() -> {
|
||||
if (UnloadSeen) continue
|
||||
UnloadSeen = true
|
||||
val V = R.Value as? Map<String, Any?>
|
||||
val Ids = V?.get("Ids") as? List<Any?>
|
||||
Log(" ✓ 步骤4:收到 UnloadObjects(lt) id 数=${Ids?.size ?: 0}(${Ids?.joinToString(",") ?: ""})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PrintEntryFrame(TotalBytes: Int, Body: ByteArray, NmSize: Int) {
|
||||
val Cmds = Entry.DecodeServerBody(Body)
|
||||
Log("← s2c 帧#$FrameNo 整帧 $TotalBytes 字节 | nullmap $NmSize 字节 | 入口命令 ${Cmds.size} 条")
|
||||
@@ -222,16 +313,37 @@ internal object SelfTest {
|
||||
val Req = Entry.BuildEnterSpace(C.Digest, TargetSpaceId)
|
||||
Log("→ c2s Auth/EnterSpace(spaceId=$TargetSpaceId) ${Req.size} 字节 hex=${Req.Hex()}")
|
||||
Conn?.Send(Req)
|
||||
SetTimeout(12000) { Finish("总时限 12s 到") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MaybeFinish() {
|
||||
if (Finished || PingCount < 1) return
|
||||
// 闭环摘要 + 正常退出
|
||||
private fun Finish(Reason: String) {
|
||||
if (Finished) return
|
||||
Finished = true
|
||||
val A = Auto
|
||||
Log("")
|
||||
Log("✓ M3 自检通过:入口层握手 → 进空间 → 收到初始化帧(含容器记录)→ 收到 $PingCount 条服务端保活 ping")
|
||||
Conn?.Close(1000, "M3 自检完成")
|
||||
Log("--- M4 端到端闭环摘要($Reason)---")
|
||||
Log("1) 握手 + Auth/EnterSpace($TargetSpaceId):${if (EnterSent) "✓ 已发出" else "✗ 没发出"}")
|
||||
Log(
|
||||
"2) LoadDependencies:${if (DepsSeen) "✓ 收到(X=$DepsX)" else "✗ 没收到"};" +
|
||||
"自动回 G.q1wd ${A?.ReplyCount ?: 0} 次(已回的 X=${A?.RepliedXs() ?: emptyList<Int>()}" +
|
||||
(if ((A?.SuppressedCount ?: 0) > 0) ",防重放跳过 ${A?.SuppressedCount} 次" else "") + ")"
|
||||
)
|
||||
Log("3) LoadObjectsData:${if (ObjectsSeen) "✓ 收到(对象数=$ObjectsCount 项数=$ItemsCount)" else "✗ 没收到"}")
|
||||
Log(
|
||||
"4) 手动 ls.xbl(靶=$ManualLsXblTargetId):${if (LsSent) "✓ 已发出" else "✗ 没发"};" +
|
||||
"UnloadObjects ${if (UnloadSeen) "✓ 收到" else "✗ 没收到"}"
|
||||
)
|
||||
Log("5) 正常退出(close code=1000)")
|
||||
val KeyStepsOk = DepsSeen && ObjectsSeen && (A?.ReplyCount ?: 0) >= 1
|
||||
Log(
|
||||
if (KeyStepsOk && BuildSelfTestPassed)
|
||||
"✓ M4 自检通过:上行帧构造逐字节一致 + LoadDependencies→G.q1wd 自动应答闭环成立"
|
||||
else "✗ M4 自检未通过:关键步骤缺失(帧构造=$BuildSelfTestPassed 依赖下发=$DepsSeen 对象数据=$ObjectsSeen 自动应答=${A?.ReplyCount ?: 0})"
|
||||
)
|
||||
Conn?.Close(1000, "M4 自检完成")
|
||||
SetTimeout(100) { ExitProcess(0) }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Uplink.kt —— c2s 空间帧的构造(口径对照 3DTank/README.md 的「空间帧」一节 + tools/server/dispatch.js)
|
||||
//
|
||||
// c2s 空间帧帧体 = [nullmap][目标 objectId i64][(methodId i64)(负载)…](**没有**入口命令 ID 字节层)
|
||||
// 与 s2c 的差别只在第一个 i64 的语义:c2s 是目标对象 id,s2c 是记录自带的目标 id。
|
||||
//
|
||||
// 构造结果与真实抓包逐字节比对见 UplinkSelfTest.kt(内嵌抓包 hex)。
|
||||
package Protocol
|
||||
|
||||
internal object Uplink {
|
||||
|
||||
// 上行 methodId(十进制字符串取自 names/uplink_table.json,且与抓包逐字节核对过)
|
||||
const val BA_LCR = 7806260029063678398L // ba.lcr 空负载,靶 = 槽位对象 id
|
||||
const val LS_XBL = 2528403217084400809L // ls.xbl 空负载,靶 = 要卸载的对象 id
|
||||
const val G_Q1WD = 1816792453857564692L // G.q1wd 负载 = i32(X = 服务端 _loadDependencies 给的回调号)
|
||||
|
||||
// 下行 methodId(服务端下发的那几条;布局见 Container.LayoutByMethod)
|
||||
const val LOAD_DEPENDENCIES = 3216143066888387731L // zt
|
||||
const val LOAD_OBJECTS_DATA = 7640916300855664666L // pt
|
||||
const val UNLOAD_OBJECTS = 9038756763225879447L // lt
|
||||
|
||||
// 一条记录 = methodId i64 + 负载;目标 id 单独传(nullmap 默认 0x00,与抓包一致)
|
||||
fun Build(TargetId: Long, MethodId: Long, Payload: ByteArray, Nullmap: ByteArray = byteArrayOf(0)): ByteArray {
|
||||
val W = ByteWriter()
|
||||
W.I64(TargetId)
|
||||
W.I64(MethodId)
|
||||
W.Raw(Payload)
|
||||
return Frame.EncodeFrame(W.ToByteArray(), Nullmap)
|
||||
}
|
||||
|
||||
fun BuildBaLcr(TargetId: Long): ByteArray = Build(TargetId, BA_LCR, ByteArray(0))
|
||||
|
||||
fun BuildLsXbl(TargetId: Long): ByteArray = Build(TargetId, LS_XBL, ByteArray(0))
|
||||
|
||||
// G.q1wd:负载就是那一个 i32;靶 = 空间自身 id(实测 space-100045260/c2s_0001.bin 与
|
||||
// space-100073070/c2s_0001.bin 的靶都是「客户端进入的那个空间 id」,不是 s2c 记录头上的 objectId)
|
||||
fun BuildGQ1wd(TargetId: Long, X: Int): ByteArray {
|
||||
val W = ByteWriter()
|
||||
W.I32(X)
|
||||
return Build(TargetId, G_Q1WD, W.ToByteArray())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// UplinkSelfTest.kt —— c2s 空间帧构造的**字节级**自检(M4 验收 (a))
|
||||
//
|
||||
// 期望 hex 是真实抓包整帧的原样十六进制(3DTank/packets/plain/,只读):
|
||||
// space-100073070/c2s_0007.bin ba.lcr 19 字节
|
||||
// space-100073070/c2s_0002.bin ls.xbl 19 字节
|
||||
// space-100073070/c2s_0001.bin G.q1wd 23 字节(负载 i32=1)
|
||||
// space-100045260/c2s_0001.bin G.q1wd 23 字节(负载 i32=1;自动应答实际发出的形态,靶 = 空间 id)
|
||||
// node 侧还能读到抓包文件时,额外做一次「构造结果 vs 文件字节」的全字节比对(不只看 hex 串)。
|
||||
package Protocol
|
||||
|
||||
internal class UplinkCase(
|
||||
val Label: String,
|
||||
val RelPath: String, // 相对 3DTank/packets/plain/ 的路径(node 侧用来读文件复核)
|
||||
val ExpectedHex: String,
|
||||
val Built: ByteArray,
|
||||
)
|
||||
|
||||
internal class UplinkReport(val Lines: List<String>, val Passed: Boolean)
|
||||
|
||||
internal object UplinkSelfTest {
|
||||
|
||||
// 抓包里的靶 id(原样搬进代码,构造时直接当入参)
|
||||
private const val BaLcrTargetId = 920009630983L
|
||||
private const val LsXblTargetId = -2147481788L
|
||||
private const val GQ1wd073070TargetId = 100073070L
|
||||
private const val GQ1wd045260TargetId = 100045260L
|
||||
|
||||
val Cases: List<UplinkCase> = listOf(
|
||||
UplinkCase(
|
||||
"ba.lcr(空负载)", "space-100073070/c2s_0007.bin",
|
||||
"001100000000d634d8e5076c55682318abc9be", Uplink.BuildBaLcr(BaLcrTargetId),
|
||||
),
|
||||
UplinkCase(
|
||||
"ls.xbl(空负载)", "space-100073070/c2s_0002.bin",
|
||||
"001100ffffffff800007442316b154734544a9", Uplink.BuildLsXbl(LsXblTargetId),
|
||||
),
|
||||
UplinkCase(
|
||||
"G.q1wd(i32=1)", "space-100073070/c2s_0001.bin",
|
||||
"0015000000000005f6fe6e19368b1c6aca581400000001", Uplink.BuildGQ1wd(GQ1wd073070TargetId, 1),
|
||||
),
|
||||
UplinkCase(
|
||||
"G.q1wd(i32=1) 空间 100045260(自动应答形态)", "space-100045260/c2s_0001.bin",
|
||||
"0015000000000005f691cc19368b1c6aca581400000001", Uplink.BuildGQ1wd(GQ1wd045260TargetId, 1),
|
||||
),
|
||||
)
|
||||
|
||||
// FileBytes = node 侧读到的抓包整帧(浏览器侧传 null)
|
||||
fun Check(Case: UplinkCase, FileBytes: ByteArray?): UplinkReport {
|
||||
val Lines = ArrayList<String>()
|
||||
var Passed = true
|
||||
Lines.add("[${Case.Label}] 构造 ${Case.Built.size} 字节")
|
||||
val ActualHex = Case.Built.Hex()
|
||||
Lines.add(" 构造 hex = $ActualHex")
|
||||
Lines.add(" 抓包 hex = ${Case.ExpectedHex}")
|
||||
Passed = Cmp(Lines, "与抓包 hex 逐字节一致", ActualHex == Case.ExpectedHex, true) && Passed
|
||||
if (FileBytes != null) {
|
||||
val FileOk = FileBytes.Hex() == ActualHex
|
||||
Lines.add(" 抓包文件(${FileBytes.size} 字节)与构造结果全字节比对:" + (if (FileOk) "✓ 相同" else "✗ 不同"))
|
||||
if (!FileOk) {
|
||||
Lines.add(" 文件 hex = ${FileBytes.Hex()}")
|
||||
Lines.add(" 首个差异字节偏移 = ${FirstDiff(FileBytes, Case.Built)}")
|
||||
Passed = false
|
||||
}
|
||||
} else {
|
||||
Lines.add(" 抓包文件:浏览器侧不可读(只比内嵌 hex)")
|
||||
}
|
||||
Lines.add(if (Passed) " ✓ 通过" else " ✗ 未通过")
|
||||
return UplinkReport(Lines, Passed)
|
||||
}
|
||||
|
||||
private fun FirstDiff(A: ByteArray, B: ByteArray): String {
|
||||
val N = minOf(A.size, B.size)
|
||||
for (I in 0 until N) if (A[I] != B[I]) return "$I(0x${A[I].toInt().and(0xff).toString(16)} vs 0x${B[I].toInt().and(0xff).toString(16)})"
|
||||
return if (A.size == B.size) "无(完全相同)" else "长度不同(${A.size} vs ${B.size})"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user