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:
TermiNexus committed 2026-09-18 11:13:33 +08:00
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)
}
+128 -16
View File
@@ -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
}
}