From 3065d23413e633dc015cdc3498db92386046d6bf Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 18 Sep 2026 14:19:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M10=20=E6=8C=82=E8=BD=BD=E4=BD=8D?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=8F=AF=E8=A3=85=E5=A4=87=EF=BC=88dh.hcu=20?= =?UTF-8?q?=E2=86=92=20Mounted.DeviceId=20=E2=86=92=20=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E5=90=8E=E5=9B=9E=E6=98=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上行口径(源码链推出,抓包无样本):dh.hcu = 3804107380183105968,靶 = 挂载位对象 id,负载 = 设备对象 id(i64);证据见 Uplink.kt 的 DH_HCU 头注(module_85196.js:7134-7173 的 dh.hcu、module_10032.js:7692 的 TF 动作、module_18334.js:5926-5927 的 dispatch)。客户端:Uplink.BuildDeviceInsert + ClientState.MarkDeviceMounted(本地乐观)+ GarageSession.EquipDevice;Main.kt 挂载位面板每个可用设备一个「装备 <设备id>」按钮(已装备者置灰);GarageDriver 支持 TANKIC_DEVICE_EQUIP=<槽位id>:<设备id> 自检与 PrintDeviceMountDump。服务端见 2299b09。验收(9092 干净重启):帧 255848→255856B(+8B = i64 正好)、客户端解码 29 条记录 Bad=false;服务端日志「回显覆盖:devicesLoaded 的 mountedDevice 由模板…改为库中…」;MySQL SELECT 槽位 920009631046 DeviceId=920005132727;客户端 dump「槽位 920009631046 设备 23 个 当前装备:920005132727」。换装备:改为 920005770334 后库里只剩后者;重复同值 → 服务端「值未变,幂等跳过」。UI:浏览器点「装备」后该槽位行显示新设备。回归:CompareContainerDecode 5 帧/一致叶子 26950/不一致 0;selftest 逐字节一致 1108/不一致 9/解码中断 90;semantic.js --demo 全部断言通过;三构建全绿。 --- client/src/jsMain/kotlin/Main.kt | 19 +++++- .../src/jsMain/kotlin/Protocol/ClientState.kt | 10 +++ .../jsMain/kotlin/Protocol/GarageDriver.kt | 67 ++++++++++++++++++- .../jsMain/kotlin/Protocol/GarageSession.kt | 36 ++++++++++ client/src/jsMain/kotlin/Protocol/Uplink.kt | 31 +++++++++ 5 files changed, 160 insertions(+), 3 deletions(-) diff --git a/client/src/jsMain/kotlin/Main.kt b/client/src/jsMain/kotlin/Main.kt index 511184a..657e92f 100644 --- a/client/src/jsMain/kotlin/Main.kt +++ b/client/src/jsMain/kotlin/Main.kt @@ -38,7 +38,7 @@ private val Session = GarageSession { RenderApp() } // 页面根组件:只读渲染 Session 的快照 private val App = FC { div { - h1 { +"TankIcClient · M6 车库(只读)" } + h1 { +"TankIcClient · M10 车库(物品装备 + 挂载位设备装备)" } // ---------------- 1 连接区 div { @@ -90,7 +90,22 @@ private val App = FC { h2 { +"挂载位(devicesLoaded):槽位 ${Session.Slots.size} 个 / 设备 ${Session.DeviceCount} 个" } ul { for (D in Session.Slots) { - li { +("槽位 ${D.SlotObjectId} 设备 ${D.Devices.size} 个 当前装备:" + (D.MountedDevice ?: "未装备")) } + li { + +("槽位 ${D.SlotObjectId} 设备 ${D.Devices.size} 个 当前装备:" + (D.MountedDevice ?: "未装备")) + // 每个可用设备一个「装备 <设备id>」按钮:点击发 dh.hcu(设备id),靶 = 该挂载位。 + // 已装备的那个置灰(本地乐观更新会让该槽位行立刻变成当前装备,落库本身也幂等)。 + ul { + for (Dev in D.Devices) { + li { + button { + disabled = D.MountedDevice == Dev + onClick = { Session.EquipDevice(D.SlotObjectId, Dev) } + +"装备 $Dev" + } + } + } + } + } } } if (Session.Slots.isEmpty()) p { +"(尚未收到 devicesLoaded)" } diff --git a/client/src/jsMain/kotlin/Protocol/ClientState.kt b/client/src/jsMain/kotlin/Protocol/ClientState.kt index 5ac3d22..bfe270c 100644 --- a/client/src/jsMain/kotlin/Protocol/ClientState.kt +++ b/client/src/jsMain/kotlin/Protocol/ClientState.kt @@ -124,6 +124,16 @@ internal class ClientState { if (Next != MountedItems) MountedItems = Next } + // 挂载设备的本地乐观更新(M10):把某槽位的「当前装备」立刻改成该设备 id,界面立即可见; + // 服务端落库后的 devicesLoaded 回声(以及下次进车库的 ba.lcr 应答)会用库里的权威值把它整体替换。 + // 只在「该槽位已收到过 devicesLoaded 且该设备确实在它的设备清单里」时生效(不猜槽位、不猜设备)。 + fun MarkDeviceMounted(SlotObjectId: String, DeviceId: String) { + val Cur = DevicesBySlot[SlotObjectId] ?: return + if (!Cur.Devices.contains(DeviceId)) return + if (Cur.MountedDevice == DeviceId) return + DevicesBySlot[SlotObjectId] = DevicesLoadedEntry(Cur.FrameNo, Cur.SlotObjectId, Cur.Devices, DeviceId) + } + // ---------------------------------------------------------------- 物品类别(M8 同类互斥的依据) // 物品的「装备槽类别」 = 该物品对象在 pt 里的模型项 modelId=6117306895090150422(module_85196::Lo) diff --git a/client/src/jsMain/kotlin/Protocol/GarageDriver.kt b/client/src/jsMain/kotlin/Protocol/GarageDriver.kt index 5b4d9a5..d06ce0e 100644 --- a/client/src/jsMain/kotlin/Protocol/GarageDriver.kt +++ b/client/src/jsMain/kotlin/Protocol/GarageDriver.kt @@ -88,6 +88,14 @@ internal object GarageDriver { private var DevicesFrameNo = -1 private var SessionMaxBefore = 0 + // M10:可选的「把设备装到挂载位」步骤。默认关(不设环境变量 ⇒ 行为与 M5 完全一致)。 + // 用法:TANKIC_DEVICE_EQUIP=<槽位id>:<设备id> —— 车库数据到齐后发一条 dh.hcu(口径见 Uplink.kt 的 DH_HCU 头注)。 + private val DeviceEquipSpec: String = Env("TANKIC_DEVICE_EQUIP") + private var DeviceEquipSent = false + private var DeviceEquipSlot = "" + private var DeviceEquipDevice = "" + private var DeviceEquipHex = "" + fun Wanted(): Boolean = Env("TANKIC_GARAGE") == "1" fun Run() { @@ -134,6 +142,10 @@ internal object GarageDriver { "→ c2s #${E.Index} ${E.Name} 靶=${E.TargetId} ${E.TotalBytes} 字节" + (if (E.Auto) " [自动应答]" else "") ) + // M10:dh.hcu 真正发出去之后才开始收尾(它必须落到服务端,落库/回声才有意义) + if (P.MethodId == Uplink.DH_HCU.toString()) { + SetTimeout(1500) { Finish("已发出 dh.hcu(靶=${P.TargetId}),等回声") } + } } // ls.xbl 全部发完 → 再排 ba.lcr(顺序与真实会话一致:先卸载再取设备清单) if (LsEnqueued && !BaEnqueued && LsSent >= LsDerived.size) { @@ -223,10 +235,45 @@ internal object GarageDriver { DevicesFrameSeen = true DevicesFrameNo = FrameNo Log(" ⟳ 收到 pt+devicesLoaded 同帧(挂载位 ${State.DevicesRecords.size} 个 / 设备 ${State.DeviceCount} 个)→ 判定车库数据到齐") - SetTimeout(1500) { Finish("已拿到挂载位/设备帧 s2c_0004") } + MaybeEquipDevice() + // 没开 M10 步骤 ⇒ 与 M5 一模一样:1.5s 后收尾。 + // 开了 M10 ⇒ 不在这里定时收尾:dh.hcu 还要排队等节拍发出,发出去之后再由 Tick 安排收尾(见 Tick)。 + if (!DeviceEquipSent) SetTimeout(1500) { Finish("已拿到挂载位/设备帧 s2c_0004") } } } + // M10(可选,默认不跑):按 TANKIC_DEVICE_EQUIP=<槽位id>:<设备id> 发一条 dh.hcu, + // 与浏览器侧 GarageSession.EquipDevice 完全同口径(同一 Uplink.BuildDeviceInsert + 同一 ClientState 本地乐观更新)。 + private fun MaybeEquipDevice() { + if (DeviceEquipSent || DeviceEquipSpec.isEmpty()) return + DeviceEquipSent = true + val Parts = DeviceEquipSpec.split(":") + if (Parts.size != 2) { + Log(" ✗ TANKIC_DEVICE_EQUIP 格式应为 <槽位id>:<设备id>,实为 $DeviceEquipSpec") + return + } + val Slot = Parts[0].trim() + val Dev = Parts[1].trim() + val Entry = State.DevicesRecords.firstOrNull { it.SlotObjectId == Slot } + if (Entry == null) { + Log(" ✗ 槽位 $Slot 不在 devicesLoaded 记录里") + return + } + if (!Entry.Devices.contains(Dev)) { + Log(" ✗ 设备 $Dev 不在槽位 $Slot 的可用设备清单里(清单 ${Entry.Devices.size} 个)") + return + } + val Bytes = Uplink.BuildDeviceInsert(Slot.toLong(), Dev.toLong()) + DeviceEquipSlot = Slot + DeviceEquipDevice = Dev + DeviceEquipHex = Bytes.Hex() + EnqueueManual("dh.hcu", Uplink.DH_HCU.toString(), Slot, Bytes) + State.MarkDeviceMounted(Slot, Dev) // 本地乐观更新(与浏览器侧同口径) + Log(" ⟳ M10 挂载设备 $Dev → 槽位 $Slot(原 ${Entry.MountedDevice ?: "未装备"})") + Log(" dh.hcu 帧 ${Bytes.size} 字节 hex = $DeviceEquipHex(靶=$Slot,负载=设备 id i64)") + Log(" dh.hcu methodId=${Uplink.DH_HCU}(names/uplink_table.json:4368,module_85196 的 dh.hcu)") + } + private fun Attribute(D: DownlinkEntry) { val Owner = CurrentUplink ?: EnterEntry ?: UplinkEntry(-1, EnterLabel, "", "", 0, false).also { EnterEntry = it } D.Trigger = Owner.Name @@ -262,6 +309,7 @@ internal object GarageDriver { Safe("ClientState dump") { PrintStateDump() } Safe("靶推导 vs 抓包") { PrintCaptureComparison() } Safe("挂载位交叉验证") { PrintSlotCrossCheck() } + Safe("M10 挂载位当前装备") { PrintDeviceMountDump() } Conn?.Close(1000, "M5 自检完成") SetTimeout(900) { Safe("与最新服务端日志逐条对照") { PrintServerLogComparison() } @@ -384,6 +432,23 @@ internal object GarageDriver { Log("全部 devicesLoaded(含 ba.lcr 的查库定向应答)去重后 ${All.size} 个挂载位,其中推导命中的 ${All.count { DerivedSet.contains(it) }} 个") } + // (d) M10:挂载位的「当前装备」(devicesLoaded 的 mountedDevice,即库里 Mounted.DeviceId 的回显) + private fun PrintDeviceMountDump() { + Log("") + Log("--- (d) M10 挂载位当前装备(devicesLoaded.mountedDevice;库里 Mounted.DeviceId 的回显)---") + val Slots = State.DevicesRecords.sortedBy { it.SlotObjectId.toLong() } + val Equipped = Slots.filter { it.MountedDevice != null } + Log("挂载位 ${Slots.size} 个,其中「已装备」${Equipped.size} 个") + for (D in Slots) { + Log(" 槽位 ${D.SlotObjectId} 设备 ${D.Devices.size} 个 当前装备:${D.MountedDevice ?: "未装备"}") + } + if (DeviceEquipSlot.isNotEmpty()) { + val Cur = Slots.firstOrNull { it.SlotObjectId == DeviceEquipSlot }?.MountedDevice + Log("本次动作:槽位 $DeviceEquipSlot 装 设备 $DeviceEquipDevice → 客户端看到的当前装备 = ${Cur ?: "未装备"}" + + "(${if (Cur == DeviceEquipDevice) "✓ 与动作一致" else "✗ 与动作不一致"})") + } + } + // (a) 配对表 ⇔ 服务端日志 private fun PrintServerLogComparison() { Log("") diff --git a/client/src/jsMain/kotlin/Protocol/GarageSession.kt b/client/src/jsMain/kotlin/Protocol/GarageSession.kt index 11f3996..c43d8d4 100644 --- a/client/src/jsMain/kotlin/Protocol/GarageSession.kt +++ b/client/src/jsMain/kotlin/Protocol/GarageSession.kt @@ -226,6 +226,11 @@ internal class GarageSession(private val OnChanged: () -> Unit) { Queue.add(QueuedUplink(UplinkName(MethodId.toString()), TargetId, Bytes)) } + // 直接给名字入队(协议表里没有的上行,如 M10 的 dh.hcu —— 只有真做过该动作的会话才会被抽进模板) + private fun EnqueueNamed(Name: String, TargetId: String, Bytes: ByteArray) { + Queue.add(QueuedUplink(Name, TargetId, Bytes)) + } + // ---------------------------------------------------------------- 装备动作(M7) // 装备一件仓库物品:发 M_.fcn(ItemId)(靶 = 空间 id,口径同 M4/M5),并按本地乐观语义立刻标成已挂载, @@ -255,6 +260,37 @@ internal class GarageSession(private val OnChanged: () -> Unit) { OnChanged() } + // ---------------------------------------------------------------- 挂载设备(M10) + + // 把一个设备装到某个挂载位:发 dh.hcu(设备对象 id)(靶 = 挂载位对象 id,口径见 Uplink.kt 的 DH_HCU 头注), + // 并**本地乐观更新**该槽位的「当前装备」(与真实客户端 GBn 的顺序一致:先卸旧件再装新件,界面上就是覆盖)。 + // 落库与权威回显由服务端负责(store.js 的 SetDeviceMounted 覆盖写 Mounted.DeviceId, + // 再用库值组 devicesLoaded 回显);这里只保证「点一下 → 上行入队(下一拍发出)→ 界面立即正确」。 + fun EquipDevice(SlotObjectId: String, DeviceId: String) { + val Slot = SlotObjectId.toLongOrNull() + val Dev = DeviceId.toLongOrNull() + if (Slot == null || Dev == null) { + AddLog("✗ 挂载设备失败:id 不是 i64(槽位 $SlotObjectId / 设备 $DeviceId)") + OnChanged() + return + } + val Entry = Slots.firstOrNull { it.SlotObjectId == SlotObjectId } + if (Entry == null || !Entry.Devices.contains(DeviceId)) { + AddLog("✗ 挂载设备失败:设备 $DeviceId 不在槽位 $SlotObjectId 的可用设备清单里") + OnChanged() + return + } + val Bytes = Uplink.BuildDeviceInsert(Slot, Dev) + EnqueueNamed("dh.hcu", SlotObjectId, Bytes) + StateObj.MarkDeviceMounted(SlotObjectId, DeviceId) + val Before = Entry.MountedDevice + AddLog( + "⟳ 挂载设备 $DeviceId → 槽位 $SlotObjectId:已入队 dh.hcu(靶=$SlotObjectId,${Bytes.size} 字节,负载=设备 id i64)" + + ",本地乐观更新该槽位当前装备(原 " + (Before ?: "未装备") + ")" + ) + OnChanged() + } + // ---------------------------------------------------------------- 日志与命名 private fun AddLog(Line: String) { diff --git a/client/src/jsMain/kotlin/Protocol/Uplink.kt b/client/src/jsMain/kotlin/Protocol/Uplink.kt index b90e868..3794c5a 100644 --- a/client/src/jsMain/kotlin/Protocol/Uplink.kt +++ b/client/src/jsMain/kotlin/Protocol/Uplink.kt @@ -16,6 +16,30 @@ internal object Uplink { // 抓包 space-100073070 里 6 条样本(c2s_0081/0101/0114/0126/0129/0141.bin)逐字节核对见 UplinkSelfTest.kt。 const val M_FCN = 1079642233486876258L + // dh.hcu(module_85196 的 class dh,字段 dcu_1: i64)—— 「把某个设备装到某个挂载位」的上行。 + // 负载 = 设备对象 id(i64 大端);靶 = **挂载位对象 id**(不是空间 id,也不是设备 id)。 + // 依据是**源码链**(抓包里没有这条上行 —— 见下方说明),逐行: + // ① UI:module_82347.js:59290 与 :165488 —— 设备行(未安装)的 handler → GBn(component, device); + // ② 动作:module_82347.js:59182 GBn = 先 qao(QH(旧 mountedDevice, item)) 再 qao(tW(device, item)); + // tW = iu = module_82347.js:2540 的 Po.$_$.h14 = module_10032.js:51741 的 TF + // = GarageInsertDeviceClientAndServer(module_10032.js:7692 构造 / :31091 toString); + // TF(t,n) 的 `Hm.call(this, n.nr3_1)` 把「动作靶 = item.id(挂载位)」交给基类, + // 而分发器 module_10849.js:505-524 正是按 action 的靶过滤监听者,:340 又把该 GameObject + // 压成当前上下文(`x().f7j(i.uao_1)`)⇒ 组帧时 `Qe().g7j().p57()` 取到的就是挂载位 id。 + // ③ 处理器:module_18334.js:5926-5927 —— handler 里 `q7o().b7n(n.ori_1.ora_1)`(= GarageDevice.id + // 在已加载空间对象里查一次)然后 `t.uaw().hcu(它)`;`uaw()` = module_85196.js:7134 的 wh.uct_1 + // = `new dh`(module_85196.js:7139-7151 构造); + // ④ 组帧:module_85196.js:7163 `ke(dh).hcu(t)` = `new nu(Qe().g7j().p57(), this.ccu_1, this.yct_1)` + // —— module_61860.js:1642 证实 nu 的第一个参数就是帧的第一个 i64(靶), + // methodId = this.ccu_1 = uplink_table.json:4368 的 3804107380183105968(字段 dcu_1: i64)。 + // ⑤ 靶的旁证:同族的兄弟接口 ba.lcr(GarageDevicesCommands.LoadAvailableDevices,同样由「挂载位 + // 那个 GameObject」的 mediator 发出)在真实抓包里靶**就是挂载位 id**(c2s_0007..0034 共 28 条, + // 靶与 devicesLoaded 的记录头 ObjectId 完全一致)。 + // ★ 不确定性(如实写明):真实抓包与本项目所有会话/离线抓包里都**没有** 3804107380183105968 这条上行 + // (会话里没做过"把设备装到挂载位"这个动作),所以靶与负载**没有逐字节样本可对**;上面 ①–⑤ 是 + // 唯一依据。payload 的 i64 编码方式(8 字节大端)与同 module 的 M_.fcn 完全同口径。 + const val DH_HCU = 3804107380183105968L + // 下行 methodId(服务端下发的那几条;布局见 Container.LayoutByMethod) const val LOAD_DEPENDENCIES = 3216143066888387731L // zt const val LOAD_OBJECTS_DATA = 7640916300855664666L // pt @@ -49,4 +73,11 @@ internal object Uplink { W.I64(ItemId) return Build(TargetId, M_FCN, W.ToByteArray()) } + + // dh.hcu:负载就是那一个 i64(设备对象 id);靶 = 挂载位对象 id(依据见 DH_HCU 头注) + fun BuildDeviceInsert(TargetId: Long, DeviceId: Long): ByteArray { + val W = ByteWriter() + W.I64(DeviceId) + return Build(TargetId, DH_HCU, W.ToByteArray()) + } }