feat: M10 挂载位设备可装备(dh.hcu → Mounted.DeviceId → 重启后回显)

上行口径(源码链推出,抓包无样本):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 全部断言通过;三构建全绿。
This commit is contained in:
TermiNexus committed 2026-09-18 14:19:04 +08:00
1 parent 2299b0996b
commit 3065d23413
5 files changed
+160 -3

No files matched your search

+17 -2
View File
@@ -38,7 +38,7 @@ private val Session = GarageSession { RenderApp() }
// 页面根组件:只读渲染 Session 的快照
private val App = FC<Props> {
div {
h1 { +"TankIcClient · M6 车库(只读)" }
h1 { +"TankIcClient · M10 车库(物品装备 + 挂载位设备装备)" }
// ---------------- 1 连接区
div {
@@ -90,7 +90,22 @@ private val App = FC<Props> {
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)" }
@@ -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)
@@ -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("")
@@ -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) {
@@ -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())
}
}