From 78ce1bacea8f9467d55d020ad5cc70e0e118ee24 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 18 Sep 2026 11:13:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=8D=8F=E8=AE=AE=E5=B1=82=20M?= =?UTF-8?q?4=20=E2=80=94=E2=80=94=20=E4=B8=8A=E8=A1=8C=E5=B8=A7=E6=9E=84?= =?UTF-8?q?=E9=80=A0=E4=B8=8E=E8=87=AA=E5=8A=A8=E5=BA=94=E7=AD=94=EF=BC=8C?= =?UTF-8?q?=E5=BD=A2=E6=88=90=E9=97=AE=E7=AD=94=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 验证。 --- .../jsMain/kotlin/Protocol/AutoResponder.kt | 66 ++++++++ .../src/jsMain/kotlin/Protocol/Connection.kt | 36 +++++ client/src/jsMain/kotlin/Protocol/SelfTest.kt | 144 ++++++++++++++++-- client/src/jsMain/kotlin/Protocol/Uplink.kt | 41 +++++ .../jsMain/kotlin/Protocol/UplinkSelfTest.kt | 82 ++++++++++ 5 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 client/src/jsMain/kotlin/Protocol/AutoResponder.kt create mode 100644 client/src/jsMain/kotlin/Protocol/Uplink.kt create mode 100644 client/src/jsMain/kotlin/Protocol/UplinkSelfTest.kt diff --git a/client/src/jsMain/kotlin/Protocol/AutoResponder.kt b/client/src/jsMain/kotlin/Protocol/AutoResponder.kt new file mode 100644 index 0000000..c367102 --- /dev/null +++ b/client/src/jsMain/kotlin/Protocol/AutoResponder.kt @@ -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() + private val RepliedXList = ArrayList() + + 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 { + val Lines = ArrayList() + val Target = Uplink.LOAD_DEPENDENCIES.toString() + for (R in Parsed.Records) { + if (R.MethodId != Target) continue + val V = R.Value as? Map ?: 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 = RepliedXList.toList() + + private companion object { + const val MAX_REPLIES = 64 + } +} diff --git a/client/src/jsMain/kotlin/Protocol/Connection.kt b/client/src/jsMain/kotlin/Protocol/Connection.kt index fedbd7a..a3fb6b2 100644 --- a/client/src/jsMain/kotlin/Protocol/Connection.kt +++ b/client/src/jsMain/kotlin/Protocol/Connection.kt @@ -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() + + fun LastObjectsDataObjectIds(): List = 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 ?: continue + val Objects = V["Objects"] as? List ?: continue + LastObjectsIds.clear() + for (O in Objects) { + val M = O as? Map ?: continue + val Id = M["ObjectId"] as? String ?: continue + LastObjectsIds.add(Id.toLong()) + } + } + } + fun Close(Code: Int, Reason: String) { Socket?.close(Code, Reason) } diff --git a/client/src/jsMain/kotlin/Protocol/SelfTest.kt b/client/src/jsMain/kotlin/Protocol/SelfTest.kt index f88ab65..e9063fe 100644 --- a/client/src/jsMain/kotlin/Protocol/SelfTest.kt +++ b/client/src/jsMain/kotlin/Protocol/SelfTest.kt @@ -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, + ) { 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 + 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 + val Objs = (V?.get("Objects") as? List)?.size ?: 0 + val Items = (V?.get("Items") as? List)?.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 + val Ids = V?.get("Ids") as? List + 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()}" + + (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) } } diff --git a/client/src/jsMain/kotlin/Protocol/Uplink.kt b/client/src/jsMain/kotlin/Protocol/Uplink.kt new file mode 100644 index 0000000..fb7cb7a --- /dev/null +++ b/client/src/jsMain/kotlin/Protocol/Uplink.kt @@ -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()) + } +} diff --git a/client/src/jsMain/kotlin/Protocol/UplinkSelfTest.kt b/client/src/jsMain/kotlin/Protocol/UplinkSelfTest.kt new file mode 100644 index 0000000..4ede0dd --- /dev/null +++ b/client/src/jsMain/kotlin/Protocol/UplinkSelfTest.kt @@ -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, 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 = 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() + 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, Label: String, Actual: Any, Expected: Any): Boolean { + val Ok = Actual == Expected + Lines.add(" $Label = $Actual 期望 $Expected ${if (Ok) "✓" else "✗"}") + return Ok + } +}