diff --git a/client/src/jsMain/kotlin/Main.kt b/client/src/jsMain/kotlin/Main.kt index 657e92f..5ec6fa5 100644 --- a/client/src/jsMain/kotlin/Main.kt +++ b/client/src/jsMain/kotlin/Main.kt @@ -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() diff --git a/client/src/jsMain/kotlin/Protocol/ClientState.kt b/client/src/jsMain/kotlin/Protocol/ClientState.kt index bfe270c..93bd679 100644 --- a/client/src/jsMain/kotlin/Protocol/ClientState.kt +++ b/client/src/jsMain/kotlin/Protocol/ClientState.kt @@ -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 = DevicesRecords.map { it.SlotObjectId } + + // M12 (2) Qh.ycw 的靶:mountedItems(已装备物品)的对象 id。 + // 依据 Uplink.QH_YCW 头注:ycw 由 GameObject 级 action 触发(用户选中某对象),抓包 3 条靶里 + // 920004704099 正是 mountedItems 的一员;这里用「逐个选中已装备物品」近似该 UI 行为。 + fun YcwTargets(): List = MountedItems + private fun IsNegativeId(Id: String): Boolean = (Id.toLongOrNull() ?: 0L) < 0L @Suppress("UNCHECKED_CAST") diff --git a/client/src/jsMain/kotlin/Protocol/GarageDriver.kt b/client/src/jsMain/kotlin/Protocol/GarageDriver.kt index d06ce0e..4f83dd8 100644 --- a/client/src/jsMain/kotlin/Protocol/GarageDriver.kt +++ b/client/src/jsMain/kotlin/Protocol/GarageDriver.kt @@ -31,6 +31,7 @@ internal class DownlinkEntry( val Labels: List, ) { 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() + private val BaQueued = HashSet() + private val HcnQueued = HashSet() + private val YcwQueued = HashSet() + 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("--- (配对表) 源上行 → 目标下行(下行记在「收到它时最后发出的那条上行」头上)---") diff --git a/client/src/jsMain/kotlin/Protocol/SpaceDumpDriver.kt b/client/src/jsMain/kotlin/Protocol/SpaceDumpDriver.kt new file mode 100644 index 0000000..f0dd112 --- /dev/null +++ b/client/src/jsMain/kotlin/Protocol/SpaceDumpDriver.kt @@ -0,0 +1,190 @@ +// SpaceDumpDriver.kt —— M12-B:依次进入多个空间、收下初始化帧、解码并把语义 dump 出来(nodejs 目标) +// +// 为什么要有它:车库(100073070)之外的空间没有会话回放模板(names/space-session-.json 只有车库那一份), +// 服务端对它们只走 names/space-init-.json 那条「进空间即下发初始化帧」的路。M12-B 的目标不是驱动交互, +// 而是「进得去、解得开、说得清里面有什么」:对每个空间打印帧数 / 逐帧记录数 / 记录人类名 / 容器布局与规模 / Bad。 +// +// 用法(默认行为不变:不设 TANKIC_SPACES 时本模块完全不介入): +// TANKIC_SPACES=100045260,100045720,100072911,100119080,920007382011 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, + val LayoutNotes: List, + val Bad: Boolean, + val Note: String, + ) + + private var Conn: Connection? = null + private var QueueIds: List = emptyList() + private var Index = 0 + private var Current = 0L + private var EnterSent = false + private var FrameNo = 0 + private val Frames = ArrayList() + 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() + 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() + 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) + } +} diff --git a/client/src/jsMain/kotlin/Protocol/Uplink.kt b/client/src/jsMain/kotlin/Protocol/Uplink.kt index 3794c5a..2b289ab 100644 --- a/client/src/jsMain/kotlin/Protocol/Uplink.kt +++ b/client/src/jsMain/kotlin/Protocol/Uplink.kt @@ -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) + // —— 「升级物品(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()) + } }