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:
1 parent
2299b0996b
commit
3065d23413
5 files changed
+160
-3
No files matched your search
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user