feat: M12 车库会话推满 39 帧 + 客户端可进其它空间解码(含 3 种未解上行语义)

A 会话推满:新增驱动策略 PumpDerive(每拍从 ClientState 推上行)——ls.xbl←pt 负数对象 id、ba.lcr←pt 挂载位标记、M_.hcn←devicesLoaded 记录头、Qh.ycw←mountedItems、G.q1wd←AutoResponder 对每条 zt 回 X;队列空而会话未满就 RefillPump 再补一轮;收尾判据=进空间满 12s 且连续 7s 无新下行。结果:39/39 帧全部收到并与服务端 session_002/log.txt 逐条对照 39/39 命中,重放卡住 0,全部 Bad=false(含 28 条 ka 查库应答帧)。诚实口径:触发上行的类型与靶 100% 由已收到下行推出,但「下一步该发哪条」不可从语义推出(回放器只认会话顺序里最靠前的待触发帧),必须靠派生行流轮询补发——已在报告与代码里标注。未解上行语义(file:line 证据):M_.hcn 4795202316771985007 = RequestItem3DPart(靶=空间 id,负载=对象 id,module_85196.js:6760 组帧 / module_18334.js:5767 触发);Qh.ycw 4428603232159847900 = GameObject 级选中/请求(靶=对象 id,空负载,module_85196.js:7290,对应 action sU module_10032.js:6064 无 toString 故人类名未确认);ha.gcq 8060894096836849355 = GarageUpgradeItem(靶=空间 id,负载 itemId+i32 1+期望价+货币 enum,module_85196.js:6879 / module_10032.js:30492 的 GarageUpgradeItemAction)。B 其它空间:新增 SpaceDumpDriver(TANKIC_SPACES=... 控制,默认不影响既有自检),实测 5 个空间全部进得去解得开、Bad 全 false:Entrance 3帧/3记录、Chat 1帧/2记录、Shop 3帧/11记录(zt 150批1731项 + pt 764对象)、Unknown 2帧/11记录、Queue 3帧/5记录。回归:CompareContainerDecode 26950/0、selftest 1108/9/90、semantic.js --demo 全部断言通过、三构建 exit 0。
This commit is contained in:
TermiNexus committed 2026-09-18 14:42:31 +08:00
1 parent 3065d23413
commit 17083ceff4
5 files changed
+367 -14

No files matched your search

+6
View File
@@ -31,6 +31,7 @@ import Protocol.GarageDriver
import Protocol.GarageSession
import Protocol.IsNodeRuntime
import Protocol.SelfTest
import Protocol.SpaceDumpDriver
// 全局会话状态容器:node 分支不会用到它(构造时不碰任何浏览器 API)
private val Session = GarageSession { RenderApp() }
@@ -151,6 +152,11 @@ fun main() {
ContainerDump.Run()
return
}
// M12-B 多空间 dump(TANKIC_SPACES=id,id,…):依次进多个空间、收帧、解码并打印语义摘要
if (SpaceDumpDriver.Wanted()) {
SpaceDumpDriver.Run()
return
}
// M5 车库因果闭环驱动(TANKIC_GARAGE=1)
if (GarageDriver.Wanted()) {
GarageDriver.Run()
@@ -295,6 +295,16 @@ internal class ClientState {
return Seen.toList()
}
// M12 (1) M_.hcn 的靶(负载):已收到的挂载位对象 id(devicesLoaded 的记录头 ObjectId)。
// 依据 Uplink.M_HCN 头注:hcn 是对「某个已加载对象」请求 3D 部件;挂载位是 hcn 抓包样本里
// 最主要的对象(27 个不同负载值里 24 个就是这 28 个挂载位),故用它们当候选池。
fun HcnTargets(): List<String> = DevicesRecords.map { it.SlotObjectId }
// M12 (2) Qh.ycw 的靶:mountedItems(已装备物品)的对象 id。
// 依据 Uplink.QH_YCW 头注:ycw 由 GameObject 级 action 触发(用户选中某对象),抓包 3 条靶里
// 920004704099 正是 mountedItems 的一员;这里用「逐个选中已装备物品」近似该 UI 行为。
fun YcwTargets(): List<String> = MountedItems
private fun IsNegativeId(Id: String): Boolean = (Id.toLongOrNull() ?: 0L) < 0L
@Suppress("UNCHECKED_CAST")
+118 -14
View File
@@ -31,6 +31,7 @@ internal class DownlinkEntry(
val Labels: List<String>,
) {
var Trigger: String = ""
var Bad: Boolean = false
}
// 一条已发出的上行(含 AutoResponder 自动应答的那些)
@@ -63,7 +64,10 @@ internal object GarageDriver {
private const val SpaceId = 100073070L
private const val TickMs = 120
private const val EnterLabel = "进空间(纯推送)"
private const val MaxRunMs = 90000
private const val MaxRunMs = 120000
// M12 收尾判据:进空间满 MinSessionMs 且连续 QuietMs 没有新下行 ⇒ 判定会话推满
private const val QuietMs = 7000.0
private const val MinSessionMs = 12000.0
private var Conn: Connection? = null
private var Auto: AutoResponder? = null
@@ -88,6 +92,25 @@ internal object GarageDriver {
private var DevicesFrameNo = -1
private var SessionMaxBefore = 0
// M12:把车库会话推满(模板 39 帧)。默认开;TANKIC_FULL_SESSION=0 退回 M5/M10 的「拿到 s2c_0004 就收尾」。
// 驱动原则(不是抄模板 After):从**已收到的下行语义**推上行 ——
// · ba.lcr ← 从 pt 推挂载位(既有规则)
// · ls.xbl ← 从 pt 推负数对象 id(既有规则)
// · M_.hcn ← 从 devicesLoaded 记录头取挂载位对象 id,逐个请求它的 3D 部件(靶=空间 id)
// · Qh.ycw ← 从 mountedItems 取已装备物品,逐个「选中」(靶=物品对象 id,空负载)
// · G.q1wd ← AutoResponder 对每条 zt 的自动应答(既有 M4 机制)
// 队列空了但会话未满 → 再补一轮 hcn/ycw(真实客户端就是用户反复在车库点选,天然重复)。
private val FullSession: Boolean = Env("TANKIC_FULL_SESSION").ifEmpty { "1" } != "0"
private val LsQueued = HashSet<String>()
private val BaQueued = HashSet<String>()
private val HcnQueued = HashSet<String>()
private val YcwQueued = HashSet<String>()
private var SpaceFrames = 0
private var SpaceFramesBad = 0
private var PumpRounds = 0
private var EnterAtMs = 0.0
private var LastFrameAtMs = 0.0
// M10:可选的「把设备装到挂载位」步骤。默认关(不设环境变量 ⇒ 行为与 M5 完全一致)。
// 用法:TANKIC_DEVICE_EQUIP=<槽位id>:<设备id> —— 车库数据到齐后发一条 dh.hcu(口径见 Uplink.kt 的 DH_HCU 头注)。
private val DeviceEquipSpec: String = Env("TANKIC_DEVICE_EQUIP")
@@ -99,10 +122,11 @@ internal object GarageDriver {
fun Wanted(): Boolean = Env("TANKIC_GARAGE") == "1"
fun Run() {
Log("=== 3DTank 客户端 M5 自检:车库因果闭环(nodejs 目标)===")
Log("=== 3DTank 客户端 M12 自检:车库会话推满 + 因果闭环(nodejs 目标)===")
Log("目标服务器 $Url 目标空间 $SpaceId(替代服务器只有它有会话回放模板)")
Log("驾驶模式:${if (FullSession) "M12 推满会话(TANKIC_FULL_SESSION≠0)" else "M5/M10 拿到 s2c_0004 即收尾(TANKIC_FULL_SESSION=0)"}")
SessionMaxBefore = LogsRoot()?.let { MaxSessionNo(it) } ?: 0
Log("开始前 store-02 最大 session 号 = $SessionMaxBefore(之后只看新 session,只读)")
Log("开始前 $LogLabel 最大 session 号 = $SessionMaxBefore(之后只看新 session,只读)")
Log("")
val C = Connection(Url)
@@ -113,6 +137,8 @@ internal object GarageDriver {
C.OnMessage = { OnMessage(it) }
C.OnClose = { Log("连接关闭 code=$it") }
C.OnError = { Log("✗ 连接错误:$it") }
EnterAtMs = NowMs()
LastFrameAtMs = EnterAtMs
SetIntervalJs(TickMs) { Tick() }
SetTimeout(MaxRunMs) { Finish("总时限 ${MaxRunMs / 1000}s 到") }
C.Connect()
@@ -131,6 +157,7 @@ internal object GarageDriver {
private fun Tick() {
if (Finished) return
if (FullSession) PumpDerive()
if (Queue.isNotEmpty() && Conn?.ReadyState == 1) {
val P = Queue.removeAt(0)
val E = UplinkEntry(Sends.size, P.Name, P.MethodId, P.TargetId, P.Bytes.size, P.Auto)
@@ -143,20 +170,73 @@ internal object GarageDriver {
(if (E.Auto) " [自动应答]" else "")
)
// M10:dh.hcu 真正发出去之后才开始收尾(它必须落到服务端,落库/回声才有意义)
if (P.MethodId == Uplink.DH_HCU.toString()) {
// 非 M12 模式下才在此收尾;M12 要等整条会话推满
if (!FullSession && P.MethodId == Uplink.DH_HCU.toString()) {
SetTimeout(1500) { Finish("已发出 dh.hcu(靶=${P.TargetId}),等回声") }
}
}
// ls.xbl 全部发完 → 再排 ba.lcr(顺序与真实会话一致:先卸载再取设备清单)
if (LsEnqueued && !BaEnqueued && LsSent >= LsDerived.size) {
} else if (FullSession && DevicesFrameSeen && Conn?.ReadyState == 1) {
// 队列空了但会话还没推满 → 再补一轮 hcn / ycw(靶都来自已收到的语义状态)
RefillPump()
} else if (!FullSession && LsEnqueued && !BaEnqueued && LsSent >= LsDerived.size) {
// (非 M12 的旧路径)ls.xbl 全部发完 → 再排 ba.lcr
BaEnqueued = true
for (Id in BaDerived) {
EnqueueManual("ba.lcr", Uplink.BA_LCR.toString(), Id, Uplink.BuildBaLcr(Id.toLong()))
}
Log("→ 由 pt 推导出挂载位 ${BaDerived.size} 个,已排入 ba.lcr 队列(靶=推导结果,非抓包抄写)")
}
if (FullSession) CheckQuiet()
}
// M12:把「从已收到的下行语义推出来的」上行排进队列(每种靶各自只排一次)。
// 靶全部来自 ClientState:ls.xbl=pt 负数对象 id / ba.lcr=pt 挂载位 / hcn=devicesLoaded 挂载位 /
// ycw=mountedItems。**不读模板 After**。
private fun PumpDerive() {
LsDerived = State.LsXblTargets()
BaDerived = State.BaLcrTargets()
for (Id in LsDerived) {
if (LsQueued.add(Id)) EnqueueManual("ls.xbl", Uplink.LS_XBL.toString(), Id, Uplink.BuildLsXbl(Id.toLong()))
}
for (Id in BaDerived) {
if (BaQueued.add(Id)) EnqueueManual("ba.lcr", Uplink.BA_LCR.toString(), Id, Uplink.BuildBaLcr(Id.toLong()))
}
for (Id in State.HcnTargets()) {
if (HcnQueued.add(Id)) {
EnqueueManual("M_.hcn", Uplink.M_HCN.toString(), SpaceId.toString(), Uplink.BuildHcn(SpaceId, Id.toLong()))
}
}
for (Id in State.YcwTargets()) {
if (YcwQueued.add(Id)) EnqueueManual("Qh.ycw", Uplink.QH_YCW.toString(), Id, Uplink.BuildYcw(Id.toLong()))
}
}
// M12:再补一轮 hcn / ycw(重复是刻意的——真实客户端就是用户反复点选;靶仍全部来自语义状态)
private fun RefillPump() {
var N = 0
for (Id in State.HcnTargets()) {
EnqueueManual("M_.hcn", Uplink.M_HCN.toString(), SpaceId.toString(), Uplink.BuildHcn(SpaceId, Id.toLong()))
N++
}
for (Id in State.YcwTargets()) {
EnqueueManual("Qh.ycw", Uplink.QH_YCW.toString(), Id, Uplink.BuildYcw(Id.toLong()))
N++
}
if (N > 0) {
PumpRounds++
Log(" ⟳ M12 补发一轮(第 $PumpRounds 轮:hcn×${State.HcnTargets().size} + ycw×${State.YcwTargets().size})")
}
}
// M12:进空间满 MinSessionMs 且连续 QuietMs 无新下行 ⇒ 判定推满并收尾
private fun CheckQuiet() {
val Now = NowMs()
if (Now - EnterAtMs < MinSessionMs) return
if (Now - LastFrameAtMs < QuietMs) return
Finish("M12:连续 ${(QuietMs / 1000).toInt()}s 无新下行,判定会话推满")
}
private fun NowMs(): Double = js("Date.now()") as Double
// ---------------------------------------------------------------- 收帧
private fun OnOpen() {
@@ -190,11 +270,15 @@ internal object GarageDriver {
return
}
val Parsed = SpaceFrame.Parse(Body)
SpaceFrames++
if (Parsed.Bad) SpaceFramesBad++
LastFrameAtMs = NowMs()
val D = DownlinkEntry(
FrameNo, TotalBytes, Parsed.Records.size,
Parsed.Records.map { it.MethodId },
Parsed.Records.map { LabelOf(it) },
)
D.Bad = Parsed.Bad
Attribute(D)
Conn?.NoteSpaceFrame(Parsed)
// 先落状态,再让 AutoResponder 应答(应答会入队,不立即发)
@@ -219,7 +303,7 @@ internal object GarageDriver {
if (R.MethodId == Uplink.LOAD_OBJECTS_DATA.toString()) HasPt = true
if (R.MethodId == "7615142300796401818") HasDevices = true
}
if (HasPt && !LsEnqueued) {
if (!FullSession && HasPt && !LsEnqueued) {
LsEnqueued = true
LsDerived = State.LsXblTargets()
BaDerived = State.BaLcrTargets()
@@ -236,9 +320,9 @@ internal object GarageDriver {
DevicesFrameNo = FrameNo
Log(" ⟳ 收到 pt+devicesLoaded 同帧(挂载位 ${State.DevicesRecords.size} 个 / 设备 ${State.DeviceCount} 个)→ 判定车库数据到齐")
MaybeEquipDevice()
// 没开 M10 步骤 ⇒ 与 M5 一模一样:1.5s 后收尾。
// 开了 M10 ⇒ 不在这里定时收尾:dh.hcu 还要排队等节拍发出,发出去之后再由 Tick 安排收尾(见 Tick)。
if (!DeviceEquipSent) SetTimeout(1500) { Finish("已拿到挂载位/设备帧 s2c_0004") }
// 非 M12 模式:与 M5 一模一样,1.5s 后收尾(开了 M10 则等 dh.hcu 发出去后再收尾,见 Tick)。
// M12 模式:不收尾,继续按语义推会话(hcn/ycw/后续 ls.xbl 由 PumpDerive 持续派生)。
if (!FullSession && !DeviceEquipSent) SetTimeout(1500) { Finish("已拿到挂载位/设备帧 s2c_0004") }
}
}
@@ -303,14 +387,16 @@ internal object GarageDriver {
val Dropped = Queue.size
Queue.clear()
Log("")
Log("--- M5 车库因果闭环摘要($Reason)---")
Log("--- M12 车库会话摘要($Reason)---")
Log("上行合计 ${Sends.size} 条(其中自动应答 ${Sends.count { it.Auto }} 条);达成目标后停止发送排队中的 $Dropped 条")
Log("空间下行帧合计 $SpaceFrames 帧(Bad=$SpaceFramesBad);M12 补发轮次 $PumpRounds")
Safe("配对表") { PrintPairingTable() }
Safe("会话帧清单") { PrintFullSessionSummary() }
Safe("ClientState dump") { PrintStateDump() }
Safe("靶推导 vs 抓包") { PrintCaptureComparison() }
Safe("挂载位交叉验证") { PrintSlotCrossCheck() }
Safe("M10 挂载位当前装备") { PrintDeviceMountDump() }
Conn?.Close(1000, "M5 自检完成")
Conn?.Close(1000, "M12 自检完成")
SetTimeout(900) {
Safe("与最新服务端日志逐条对照") { PrintServerLogComparison() }
Done()
@@ -321,10 +407,28 @@ internal object GarageDriver {
if (ExitDone) return
ExitDone = true
Log("")
Log("✓ M5 车库自检结束")
Log("✓ M12 车库自检结束")
ExitProcess(0)
}
// M12:整条会话收到的每一帧的摘要(帧字节数 / 记录数 / 每条记录人类名 / Bad),
// 与服务端 session_NNN/log.txt 的「← 重放:发 s2c_NNNN.bin …B(被 … 触发)」逐条对照用
private fun PrintFullSessionSummary() {
Log("")
Log("--- (e) M12 会话帧清单(帧# / 整帧字节 / 记录数 / 记录人类名 / Bad)---")
var Bad = 0
var N = 0
for (D in AllDownlinks()) {
N++
if (D.Bad) Bad++
Log(
" 帧#${D.FrameNo} ${D.TotalBytes}B 记录${D.RecordCount}条" +
" [${D.Labels.joinToString(" + ")}] Bad=${D.Bad} 触发=${D.Trigger}"
)
}
Log(" 合计 $N 帧,Bad=$Bad" + (if (Bad == 0) " ✓ 全部可解" else " ✗ 有解不动的帧"))
}
private fun PrintPairingTable() {
Log("")
Log("--- (配对表) 源上行 → 目标下行(下行记在「收到它时最后发出的那条上行」头上)---")
@@ -0,0 +1,190 @@
// SpaceDumpDriver.kt —— M12-B:依次进入多个空间、收下初始化帧、解码并把语义 dump 出来(nodejs 目标)
//
// 为什么要有它:车库(100073070)之外的空间没有会话回放模板(names/space-session-<id>.json 只有车库那一份),
// 服务端对它们只走 names/space-init-<id>.json 那条「进空间即下发初始化帧」的路。M12-B 的目标不是驱动交互,
// 而是「进得去、解得开、说得清里面有什么」:对每个空间打印帧数 / 逐帧记录数 / 记录人类名 / 容器布局与规模 / Bad。
//
// 用法(默认行为不变:不设 TANKIC_SPACES 时本模块完全不介入):
// TANKIC_SPACES=100045260,100045720,100072911,100119080,920007382011 node <node 产物>
// 连接地址可用 TANKIC_WS_URL 覆盖(默认 ws://localhost:9091/)。
//
// 每个空间一次独立连接(与服务端 index.js 的「空间连接」模型一致):Hash Request → Hash Response →
// Auth/EnterSpace(spaceId) → 收 DumpMs 毫秒的帧 → 打印摘要 → 关掉 → 下一个空间。
package Protocol
internal object SpaceDumpDriver {
private val Url: String = Env("TANKIC_WS_URL").ifEmpty { "ws://localhost:9091/" }
// 每个空间收帧的时长:初始化帧在服务端是立刻/带 DelayMs 下发的(最大间隔实测 < 9s),给足即可
private const val DumpMs = 9000
private const val SpaceGapMs = 400
private const val MaxRunMs = 180000
private class DumpFrame(
val TotalBytes: Int,
val RecordCount: Int,
val Labels: List<String>,
val LayoutNotes: List<String>,
val Bad: Boolean,
val Note: String,
)
private var Conn: Connection? = null
private var QueueIds: List<Long> = emptyList()
private var Index = 0
private var Current = 0L
private var EnterSent = false
private var FrameNo = 0
private val Frames = ArrayList<DumpFrame>()
private var Closed = false
fun Wanted(): Boolean = Env("TANKIC_SPACES").isNotEmpty()
fun Run() {
val Raw = Env("TANKIC_SPACES")
val Ids = Raw.split(",").mapNotNull { it.trim().takeIf { S -> S.isNotEmpty() }?.toLongOrNull() }
Log("=== 3DTank 客户端 M12-B 自检:多空间进入 + 解码 dump(nodejs 目标)===")
Log("目标服务器 $Url")
Log("空间序列(TANKIC_SPACES):${Ids.joinToString(", ")}")
if (Ids.isEmpty()) {
Log("✗ TANKIC_SPACES 里没有可解析的空间 id:$Raw")
ExitProcess(1)
return
}
QueueIds = Ids
SetTimeout(MaxRunMs) {
if (!Closed) {
Log("⚠ 总时限 ${MaxRunMs / 1000}s 到,强制结束")
Done()
}
}
EnterNext()
}
private fun EnterNext() {
if (Index >= QueueIds.size) {
Done()
return
}
Current = QueueIds[Index]
Index++
EnterSent = false
FrameNo = 0
Frames.clear()
Log("")
Log("=== 空间 $Current ===")
val C = Connection(Url)
Conn = C
C.OnOpen = {
val Req = Entry.BuildHashRequest(SelfTest.HashKeys, SelfTest.HashValues)
Log("→ c2s Hash Request ${Req.size} 字节")
C.Send(Req)
}
C.OnMessage = { OnMessage(it) }
C.OnClose = { }
C.OnError = { Log("✗ 连接错误:$it") }
C.Connect()
}
private fun OnMessage(Raw: ByteArray) {
var Off = 0
while (Off < Raw.size) {
val Info = Frame.PeekFrame(Raw, Off) ?: break
val Body = try {
Frame.DecodeFrame(WireFrame(Raw.copyOfRange(Off + Info.HeaderSize, Off + Info.TotalSize), Info.Compressed))
} catch (E: ProtocolException) {
Log("✗ 帧 ${Info.TotalSize} 字节:${E.message}")
return
}
HandleFrame(Info.TotalSize, Body)
Off += Info.TotalSize
}
}
private fun HandleFrame(TotalBytes: Int, Body: ByteArray) {
val NmSize = Nullmap.Size(Body, 0)
val MethodAt8 = if (Body.size >= NmSize + 16) Body.ReadI64(NmSize + 8).toString() else ""
val IsSpace = ProtocolTables.Downlink(MethodAt8) != null || ProtocolTables.CodecByMethod(MethodAt8) != null
if (!IsSpace) {
val Cmds = Entry.DecodeServerBody(Body)
for (Cm in Cmds) {
Log("← s2c 入口命令 [${Cm.Id}] ${Cm.Name} ${Cm.Detail}")
if (Cm.Id == Entry.S2C_HASH_RESPONSE && Cm.Digest != null && !EnterSent) {
EnterSent = true
val Req = Entry.BuildEnterSpace(Cm.Digest, Current)
Log("→ c2s Auth/EnterSpace(spaceId=$Current) ${Req.size} 字节")
Conn?.Send(Req)
SetTimeout(DumpMs) { FinishSpace() }
}
}
return
}
FrameNo++
val Parsed = SpaceFrame.Parse(Body)
val Labels = Parsed.Records.map { LabelOf(it) }
val Layouts = ArrayList<String>()
for (R in Parsed.Records) {
if (R.Kind == "container") Layouts.add("${R.Layout}: ${Container.Summary(R.Layout, R.Value)}")
}
Frames.add(DumpFrame(TotalBytes, Parsed.Records.size, Labels, Layouts, Parsed.Bad, Parsed.Note))
Log("← s2c 帧#$FrameNo 整帧 $TotalBytes 字节 | 记录数 ${Parsed.Records.size} ${Labels.joinToString(" + ")}")
for (I in Parsed.Records.indices) {
val R = Parsed.Records[I]
val Flag = if (R.PayloadKnown) "" else "(负载边界未定)"
Log(" [$I] objectId=${R.ObjectId} | methodId=${R.MethodId} | ${LabelOf(R)} | 负载 ${R.Payload.size} 字节 $Flag")
if (R.Kind == "container") Log(" 布局 ${R.Layout} 摘要:${Container.Summary(R.Layout, R.Value)}")
}
if (Parsed.Bad) Log(" ⚠ ${Parsed.Note}")
}
private fun FinishSpace() {
Log("")
Log("--- 空间 $Current dump 摘要:共 $FrameNo 帧 ---")
var Bad = 0
var Records = 0
val LayoutCount = LinkedHashMap<String, Int>()
for ((I, F) in Frames.withIndex()) {
Records += F.RecordCount
if (F.Bad) Bad++
Log(
" 帧#${I + 1} ${F.TotalBytes}B 记录${F.RecordCount}条 [${F.Labels.joinToString(" + ")}]" +
" Bad=${F.Bad}"
)
for (L in F.LayoutNotes) Log(" $L")
if (F.Bad) Log(" ⚠ ${F.Note}")
for (L in F.Labels) if (L.contains('/')) {
val K = L.substringAfter('/')
LayoutCount[K] = (LayoutCount[K] ?: 0) + 1
}
}
Log(
" 合计:帧 $FrameNo 记录 $Records Bad $Bad 容器布局统计 " +
(if (LayoutCount.isEmpty()) "(无)" else LayoutCount.entries.joinToString(", ") { it.key + "×" + it.value })
)
Log(" Bad 全 false:${Bad == 0}")
Conn?.Close(1000, "空间 $Current dump 完成")
Conn = null
SetTimeout(SpaceGapMs) { EnterNext() }
}
private fun Done() {
if (Closed) return
Closed = true
Log("")
Log("✓ M12-B 多空间 dump 结束(共进入 ${Index} 个空间)")
ExitProcess(0)
}
private fun LabelOf(R: SpaceRecord): String {
val Spec = ProtocolTables.Downlink(R.MethodId)
if (Spec == null) return if (R.Kind == "container") "容器${R.Layout}" else "未知(${R.MethodId})"
val Tail = if (Spec.Kind == "container") "/${Spec.Layout}" else ""
return Spec.Name.ifEmpty { "(未命名)" } + Tail
}
private fun Env(Name: String): String = js("process.env[Name] || ''") as String
private fun Log(Msg: String) {
println(Msg)
}
}
@@ -40,6 +40,29 @@ internal object Uplink {
// 唯一依据。payload 的 i64 编码方式(8 字节大端)与同 module 的 M_.fcn 完全同口径。
const val DH_HCU = 3804107380183105968L
// M_.hcn(module_85196.js:6760 的 `ke(M_).hcn`,字段 ccn_1: i64)—— 「请求某对象的 3D 部件/模型依赖」。
// 负载 = i64 对象 id(对象既可能是挂载位、也可能是车库物品);靶 = 客户端进入的空间 id。
// 源码链(module_18334.js):action `Z(WB/b16)`=RequestItem3DPart(names/evidence.json:216)与
// `Z(xrt/a13)`=RequestItem(evidence.json:600)的处理器分别发 `hcn(n.zrb_1)`(:5767)与
// `hcn(n.vtm_1)`(:8950)。抓包 41 条样本(c2s_0063..0168)靶恒为 100073070,负载 27 个不同值,
// 其中 24 个正是 ba.lcr 的 28 个挂载位对象 id ⇒「拿到某个已加载对象后,逐个请求它的 3D 部件」。
const val M_HCN = 4795202316771985007L
// Qh.ycw(module_85196.js:7290 的 `ke(Qh).ycw`)—— 空负载,靶 = 对象 id(GameObject 级)。
// 源码链:module_18334.js:5641-5655 的 GameObject 级订阅 `lap(...)` 里 `wao(Z(sU/e15), handler)`
// → `t.uaw().ycw()`;sU 是 module_10032.js:6064 的 GameObject 作用域 action(继承 Hm,构造自
// `t.nr3_1`),**不是服务端下行的直接应答**。抓包 3 条:靶 920004704099 / 1931009779874 /
// 1931009779880,都是 pt 里的对象 id(920004704099 也在 mountedItems 里)。
const val QH_YCW = 4428603232159847900L
// ha.gcq(module_85196.js:6879 的 `ke(ha).gcq`;字段 bcq_1: i64 / ccq_1: i32 / dcq_1: i32 / ecq_1: enum<Gu>)
// —— 「升级物品(GarageUpgradeItem)」。负载 = itemId(i64) + 常量 1(i32) + 期望价(i32) + 货币(enum)。
// 源码链:action `Z(VB/q15)`=GarageUpgradeItemAction(itemId,expectedPrice,currency)
// (module_10032.js:30492-30502 的 toString)的处理器(module_18334.js:5680)
// 发 `gcq(resolve(itemId), 1, expectedPrice, currency)`。抓包唯一 1 条(c2s_0105):
// itemId=920009631011(=0xd634d8e523)、1、100、enum#0(CRY),靶 = 100073070(空间 id)。
const val HA_GCQ = 8060894096836849355L
// 下行 methodId(服务端下发的那几条;布局见 Container.LayoutByMethod)
const val LOAD_DEPENDENCIES = 3216143066888387731L // zt
const val LOAD_OBJECTS_DATA = 7640916300855664666L // pt
@@ -80,4 +103,24 @@ internal object Uplink {
W.I64(DeviceId)
return Build(TargetId, DH_HCU, W.ToByteArray())
}
// M_.hcn:负载一个 i64(对象 id);靶 = 空间 id(依据见 M_HCN 头注)
fun BuildHcn(TargetId: Long, ObjectId: Long): ByteArray {
val W = ByteWriter()
W.I64(ObjectId)
return Build(TargetId, M_HCN, W.ToByteArray())
}
// Qh.ycw:空负载;靶 = 对象 id(依据见 QH_YCW 头注)
fun BuildYcw(TargetId: Long): ByteArray = Build(TargetId, QH_YCW, ByteArray(0))
// ha.gcq:负载 itemId(i64) + 数量(i32) + 期望价(i32) + 货币枚举(i32);靶 = 空间 id(依据见 HA_GCQ 头注)
fun BuildGcq(TargetId: Long, ItemId: Long, Count: Int, ExpectedPrice: Int, Currency: Int): ByteArray {
val W = ByteWriter()
W.I64(ItemId)
W.I32(Count)
W.I32(ExpectedPrice)
W.I32(Currency)
return Build(TargetId, HA_GCQ, W.ToByteArray())
}
}