diff --git a/3DTank/docs/KtRebuild_Plan.md b/3DTank/docs/KtRebuild_Plan.md index b92f1a9..936001f 100644 --- a/3DTank/docs/KtRebuild_Plan.md +++ b/3DTank/docs/KtRebuild_Plan.md @@ -501,6 +501,24 @@ (`ju`/`Su`/`Eu`/`Iu`,对应 `BuyableCC.buyable`/`currencyId`、`ItemCategoryCC.category` 等) 是 module_85196 的内部函数、不导出,18334 也不直接读 CC 槽位, 说明转换经 DI 服务接口完成;下一步用别名表追该接口的实现方法。 + - 物品目录(`App/Session/ItemCatalog.kt`): + - 结论修正:抓包里这 30029 条未覆盖 Pt 项**不是用户状态**,而是静态物品表 + (1371 个物品对象 + 515 个装置对象),原包也不把它放进 Redux,而是由服务按需读取。 + - 容器形态(`names/model_registry.json` + 抓包实测):`pt.Items` 按 `Id == 0` 的对象标记分组, + 标记项的值就是 ObjectId;组内按 modelId 建索引。物品组通常是 + `CountableItemCC, ItemPropertiesCC, ItemSaleByKitCC, BuyableCC, TimePeriodModelCC, + ItemModelCC, ItemRarityCC, DescriptionModelCC, ItemCategoryCC, PremiumItemCC, + ItemViewCategoryCC, DescriptionMediaCC, VisibleCC`;装置组换成 + `DevicePropertiesCC + TemporaryItemCC`。 + - `CC → GarageItemObject` / `GarageDeviceObject` 的字段顺序取自 module_18334 的 + `S$`(物品)与 `gct`(装置)的构造函数实参,token → 模型类由跨模块别名表反查,逐条登记在文件头。 + - `SessionRouter` 在 Pt 分支先调 `Catalog.Ingest`,被目录吸收的项不再计入未覆盖: + 抓包实测 **物品 1399 / 装置 516 / 吸收 27826 项**,未覆盖从 30029 降到 2203 + (余下主要是 methodId 级未覆盖、`0` 对象标记、以及 ResistancesCC/DurationCC 等非物品对象)。 + 自检同时断言目录非空,并打印名称样本(如 `史诗幸运礼盒`、`紧凑型油箱`)验证字段映射。 + - 下一步:用户拥有的物品列表来自 GarageCC 的引用字段(`itemsOnDepot` / `itemsOnMarket` / + `mountedItems`)与模型命令流;等这部分抓包/命令解码补齐后,用目录把 id 解析成 + `GarageItemObject` 再派发 `SetItemsOnDepot` / `SetItemsOnMarket` / `SetMountedItems`。 - 委派表里的已知形态:`shop` / `home` / `localBattleUserState` / `partner` 是内联 IIFE, 已按字段具名为 `(inline@)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个); `launchParams` 是纯透传(`(pass-through)`,无 reducer),已把字段收口为 `Map`。 diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Session/ItemCatalog.kt b/TankiOffline/app/src/jsMain/kotlin/App/Session/ItemCatalog.kt new file mode 100644 index 0000000..8f7e35e --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Session/ItemCatalog.kt @@ -0,0 +1,261 @@ +// ItemCatalog.kt —— 车库 / 商店物品目录(Pt 容器 → GarageItemObject / GarageDeviceObject) +// +// 为什么单列一个「目录」而不是 Redux 切片: +// · 抓包里最大的 Pt 容器是**静态物品表**(1371 个物品对象 + 515 个装置对象), +// 不是用户拥有的物品列表;原包也不把它放状态层,而是由服务按需读取。 +// · 原包的「CC → GarageItemObject」转换在 module_18334 的 `S$`(物品)与 `gct`(装置)里, +// 逐个从对象上下文取子模型:`Gr(n, Z(token))`。token → 模型类的对应关系见下表的注释。 +// +// 容器形态(names/model_registry.json + 抓包实测): +// Pt.Items = [0(对象标记,value 是 ObjectId), 模型1, 模型2, …, 0(下一个对象), …] +// 每个对象组里模型顺序不固定,按 modelId 建索引即可;组边界 = Id == 0 的项。 +// 物品组的模型集合(实测): +// CountableItemCC, ItemPropertiesCC, ItemSaleByKitCC, BuyableCC, +// TimePeriodModelCC, ItemModelCC, ItemRarityCC, DescriptionModelCC, +// ItemCategoryCC, PremiumItemCC, ItemViewCategoryCC, DescriptionMediaCC, VisibleCC +// 装置组在前述集合上换成 DevicePropertiesCC + TemporaryItemCC。 +// +// 字段对应(module_18334 的 `S$`,new GarageItemObject(...) 的实参顺序): +// id←对象标记 | description←DescriptionModelCC | item←ItemModelCC +// isPremium←PremiumItemCC.PremiumItem | itemCategory←ItemCategoryCC.Category +// itemViewCategory←ItemViewCategoryCC.Category | buyable←BuyableCC.Buyable +// availableForAll←AvailableForAllCC | discount←DiscountCC | timePeriod←TimePeriodModelCC +// modification←ModificationCC(baseItemId=Field1, level=ModificationIndex) +// properties←ItemPropertiesCC | devices←ItemDevicesCC | count←CountableItemCC.Field1 +// kit←GarageKitCC | countable←CountableItemCC 是否存在 | mountIndex←调用点下标(目录里为 0) +// availableSkins←AvailableSkinsCC | availableShotSkins←AvailableShotSkinsCC +// mountedSkin←MountedSkinCC(834829861153964627) | mountedShotSkin←MountedSkinCC(905970599512839002) +// skinPreview←SkinPreviewCC | grouped←GroupedCC | rarity←ItemRarityCC.Rarity +// resistanceBackgroundImg←ResistanceBackgroundCC | visible←VisibleCC.Visible +// descriptionMedia←DescriptionMediaCC | saleByKitItems←ItemSaleByKitCC +// isSellingForCoin/Crystal/Ruby 在原包传 `Ct`(未提供),这里保持 null。 +// +// 装置字段对应(`gct`,new GarageDeviceObject(...)): +// id←对象标记 | baseItemId←容器级 ModificationCC.Field1(组里没有则 0) +// temporaryItemCC←TemporaryItemCC | description←DescriptionModelCC | item←ItemModelCC +// isPremium←PremiumItemCC | itemCategory←ItemCategoryCC | itemViewCategory←ItemViewCategoryCC +// buyable←BuyableCC | discount←DiscountCC | timePeriod←TimePeriodModelCC +// deviceProperty←DevicePropertiesCC | rarity←ItemRarityCC | visible←VisibleCC +// descriptionMedia←DescriptionMediaCC +package App.Session + +import App.State.GarageDeviceObject +import App.State.GarageItemModification +import App.State.GarageItemObject +import Net.Generated.AvailableForAllCC +import Net.Generated.AvailableShotSkinsCC +import Net.Generated.AvailableSkinsCC +import Net.Generated.BuyableCC +import Net.Generated.CountableItemCC +import Net.Generated.DescriptionMediaCC +import Net.Generated.DescriptionModelCC +import Net.Generated.DevicePropertiesCC +import Net.Generated.DiscountCC +import Net.Generated.GarageKitCC +import Net.Generated.GroupedCC +import Net.Generated.ItemCategoryCC +import Net.Generated.ItemDevicesCC +import Net.Generated.ItemModelCC +import Net.Generated.ItemPropertiesCC +import Net.Generated.ItemRarityCC +import Net.Generated.ItemSaleByKitCC +import Net.Generated.ItemViewCategoryCC +import Net.Generated.ModificationCC +import Net.Generated.MountedSkinCC +import Net.Generated.PremiumItemCC +import Net.Generated.ResistanceBackgroundCC +import Net.Generated.SkinPreviewCC +import Net.Generated.TemporaryItemCC +import Net.Generated.TimePeriodModelCC +import Net.Generated.VisibleCC +import Net.Protocol.PtPayload + +/** 目录模型的 modelId(来源:names/model_registry.json)。 */ +object CatalogModelIds { + const val CountableItem = 120961863156462946L + const val ItemProperties = 4245000165576407500L + const val ItemSaleByKit = 4309274349580876968L + const val Buyable = 2769268442698022653L + const val TimePeriod = 8032192927455575509L + const val ItemModel = 3438505192580052999L + const val ItemRarity = 658797348514407988L + const val DescriptionModel = 3935107155251354407L + const val ItemCategory = 2114179554921095892L + const val PremiumItem = 1139677084511624062L + const val ItemViewCategory = 6117306895090150422L + const val DescriptionMedia = 5952355564848177159L + const val Visible = 5738770490413536673L + + const val AvailableForAll = 6589716392600418185L + const val Discount = 2951667042968033849L + const val Modification = 2494628067134376039L + const val UpgradeParams = 4743567393313674375L + const val ItemDevices = 7391892068905080676L + const val GarageKit = 5219530271535770658L + const val AvailableSkins = 5207857306014573244L + const val AvailableShotSkins = 1164458258646872337L + const val MountedSkin = 834829861153964627L + const val MountedShotSkin = 905970599512839002L + const val SkinPreview = 6680653332718076818L + const val Grouped = 1865478986390468300L + const val ResistanceBackground = 6628468327090974523L + + const val DeviceProperties = 7640081207058999117L + const val TemporaryItem = 3893855999545616334L + + /** 物品组只要出现其中任意一个模型,就按物品目录处理。 */ + val ItemGroupMarkers: Set = setOf( + ItemModel, ItemCategory, Buyable, DescriptionModel, TimePeriod, ItemRarity, + PremiumItem, ItemViewCategory, + ) +} + +/** + * 静态物品目录:`Pt` 容器里按「对象」分组后,把 CC 组合成 `GarageItemObject` / `GarageDeviceObject`。 + * + * 同一个 modelId 可能随对象重复出现(每个对象一份),所以目录以对象 id 为键。 + */ +class ItemCatalog { + + val Items: MutableMap = LinkedHashMap() + val Devices: MutableMap = LinkedHashMap() + + /** 被识别并吸收的 Pt 项数。 */ + var ConsumedItems: Int = 0 + private set + + /** 已识别的对象组数(物品 + 装置)。 */ + var Groups: Int = 0 + private set + + /** 不是目录的对象组数(例如车库配置对象:PassToShopCC / TemperatureCC …)。 */ + var SkippedGroups: Int = 0 + private set + + /** 组里既不是物品也不是装置的模型名,用于诊断未识别目录。 */ + val SkippedModels: MutableMap = LinkedHashMap() + + /** + * 尝试吸收一个 Pt 容器。 + * + * @return 与 `Payload.Items` 等长的「已吸收」标记;完全不是目录容器时返回 null。 + */ + fun Ingest(Payload: PtPayload): BooleanArray? { + val Raw = Payload.Items + if (Raw.isEmpty()) return null + val Consumed = BooleanArray(Raw.size) + var Any = false + + var Start = -1 + fun Flush(Begin: Int, End: Int) { + if (Begin < 0) return + val Zero = Raw[Begin].Value as? Long + val Models = HashMap(16) + var I = Begin + 1 + while (I < End) { + val Id = Raw[I].Id + if (Id != 0L) Models[Id] = Raw[I].Value + I++ + } + if (Models.isEmpty()) return + val ObjectId = Zero ?: 0L + val IsDevice = Models.containsKey(CatalogModelIds.DeviceProperties) || + Models.containsKey(CatalogModelIds.TemporaryItem) + val IsItem = CatalogModelIds.ItemGroupMarkers.any { Models.containsKey(it) } + if (IsDevice) { + Devices[ObjectId] = MakeDevice(ObjectId, Models) + Mark(Consumed, Begin, End) + Groups++ + Any = true + } else if (IsItem) { + Items[ObjectId] = MakeItem(ObjectId, Models) + Mark(Consumed, Begin, End) + Groups++ + Any = true + } else { + SkippedGroups++ + for (Name in Models.keys) { + val Text = App.State.ModelNamesByModelId[Name] ?: Name.toString() + SkippedModels[Text] = (SkippedModels[Text] ?: 0) + 1 + } + } + } + + for (I in Raw.indices) { + if (Raw[I].Id != 0L) continue + if (Start >= 0) Flush(Start, I) + Start = I + } + if (Start >= 0) Flush(Start, Raw.size) + if (!Any) return null + ConsumedItems += Consumed.count { it } + return Consumed + } + + private fun Mark(Consumed: BooleanArray, Start: Int, End: Int) { + for (I in Start until End) Consumed[I] = true + } + + private fun MakeItem(Id: Long, Models: Map): GarageItemObject { + val Modification = Models[CatalogModelIds.Modification] as? ModificationCC + val Countable = Models[CatalogModelIds.CountableItem] as? CountableItemCC + return GarageItemObject( + id = Id, + description = Models[CatalogModelIds.DescriptionModel], + item = Models[CatalogModelIds.ItemModel], + isPremium = (Models[CatalogModelIds.PremiumItem] as? PremiumItemCC)?.PremiumItem ?: false, + itemCategory = (Models[CatalogModelIds.ItemCategory] as? ItemCategoryCC)?.Category ?: 0, + itemViewCategory = (Models[CatalogModelIds.ItemViewCategory] as? ItemViewCategoryCC)?.Category ?: 0, + buyable = (Models[CatalogModelIds.Buyable] as? BuyableCC)?.Buyable ?: false, + availableForAll = (Models[CatalogModelIds.AvailableForAll] as? AvailableForAllCC)?.AvailableForAll ?: false, + discount = Models[CatalogModelIds.Discount], + timePeriod = Models[CatalogModelIds.TimePeriod], + modification = Modification?.let { + GarageItemModification(vcf_1 = it.Field1, xcf_1 = it.ModificationIndex.toInt()) + }, + properties = Models[CatalogModelIds.ItemProperties], + upgradeableParams = null, + devices = Models[CatalogModelIds.ItemDevices], + count = Countable?.Field1 ?: 0, + kit = Models[CatalogModelIds.GarageKit] as? GarageKitCC, + countable = Countable != null, + mountIndex = 0, + availableSkins = Models[CatalogModelIds.AvailableSkins] as? AvailableSkinsCC, + availableShotSkins = Models[CatalogModelIds.AvailableShotSkins] as? AvailableShotSkinsCC, + mountedSkin = Models[CatalogModelIds.MountedSkin] as? MountedSkinCC, + mountedShotSkin = Models[CatalogModelIds.MountedShotSkin] as? MountedSkinCC, + skinPreview = Models[CatalogModelIds.SkinPreview] as? SkinPreviewCC, + grouped = Models[CatalogModelIds.Grouped] as? GroupedCC, + rarity = (Models[CatalogModelIds.ItemRarity] as? ItemRarityCC)?.Rarity, + favorite = false, + isSellingForCoin = null, + isSellingForCrystal = null, + isSellingForRuby = null, + resistanceBackgroundImg = Models[CatalogModelIds.ResistanceBackground] as? ResistanceBackgroundCC, + visible = (Models[CatalogModelIds.Visible] as? VisibleCC)?.Visible ?: true, + descriptionMedia = Models[CatalogModelIds.DescriptionMedia] as? DescriptionMediaCC, + saleByKitItems = Models[CatalogModelIds.ItemSaleByKit] as? ItemSaleByKitCC, + ) + } + + private fun MakeDevice(Id: Long, Models: Map): GarageDeviceObject { + val Modification = Models[CatalogModelIds.Modification] as? ModificationCC + return GarageDeviceObject( + id = Id, + baseItemId = Modification?.Field1 ?: 0L, + temporaryItemCC = Models[CatalogModelIds.TemporaryItem] as? TemporaryItemCC, + description = Models[CatalogModelIds.DescriptionModel], + item = Models[CatalogModelIds.ItemModel], + isPremium = (Models[CatalogModelIds.PremiumItem] as? PremiumItemCC)?.PremiumItem ?: false, + itemCategory = (Models[CatalogModelIds.ItemCategory] as? ItemCategoryCC)?.Category ?: 0, + itemViewCategory = (Models[CatalogModelIds.ItemViewCategory] as? ItemViewCategoryCC)?.Category ?: 0, + buyable = (Models[CatalogModelIds.Buyable] as? BuyableCC)?.Buyable ?: false, + discount = Models[CatalogModelIds.Discount], + timePeriod = Models[CatalogModelIds.TimePeriod], + deviceProperty = Models[CatalogModelIds.DeviceProperties] as? DevicePropertiesCC, + rarity = (Models[CatalogModelIds.ItemRarity] as? ItemRarityCC)?.Rarity, + visible = (Models[CatalogModelIds.Visible] as? VisibleCC)?.Visible ?: true, + descriptionMedia = Models[CatalogModelIds.DescriptionMedia] as? DescriptionMediaCC, + ) + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Session/SessionCheck.kt b/TankiOffline/app/src/jsMain/kotlin/App/Session/SessionCheck.kt index 5dd3e75..fd9ddab 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/Session/SessionCheck.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/Session/SessionCheck.kt @@ -78,6 +78,10 @@ object SessionCheck { var Bad = 0 var HeaderErrors = 0 var Errors = 0 + var PtRecords = 0 + var PtItems = 0 + var PtMaxItems = 0 + val PtSample = ArrayList() val Methods = LinkedHashMap() for (Path in Files) { @@ -95,11 +99,47 @@ object SessionCheck { for (R in List) { Methods[R.MethodId] = (Methods[R.MethodId] ?: 0) + 1 if (R.Payload.contentEquals(R.Reencoded)) Ok++ else Bad++ + val V = R.Value + if (V is Net.Protocol.PtPayload) { + PtRecords++ + PtItems += V.Items.size + if (V.Items.size > PtMaxItems) PtMaxItems = V.Items.size + if (PtSample.size < 3) { + PtSample.add( + "obj=${V.Objects.size},items=${V.Items.size},ids=" + + V.Items.take(16).joinToString(",") { it.Id.toString() } + ) + } + } } Router.RouteAll(List) } println("文件 $Frames|记录 $Records|回编码一致 $Ok|不一致 $Bad|记录头截断 $HeaderErrors|其它错误 $Errors|别名表=${Session.AliasCount}") + println("Pt 容器 $PtRecords 条|项 $PtItems|单条最多 $PtMaxItems") + for (S in PtSample) println(" pt 样本:$S") + println( + "目录:物品 ${Router.Catalog.Items.size}|装置 ${Router.Catalog.Devices.size}" + + "|吸收项 ${Router.Cataloged}|跳过组 ${Router.Catalog.SkippedGroups}" + ) + if (Router.Catalog.SkippedModels.isNotEmpty()) { + val TopSkip = Router.Catalog.SkippedModels.entries.sortedByDescending { it.value }.take(6) + println("目录跳过模型:" + TopSkip.joinToString(",") { "${it.key}×${it.value}" }) + } + for (S in Router.Catalog.Items.values.take(3)) { + val D = S.description as? Net.Generated.DescriptionModelCC + println( + " 物品样本:id=${S.id}|name=${D?.Name}|category=${S.itemCategory}|view=${S.itemViewCategory}" + + "|premium=${S.isPremium}|count=${S.count}|rarity=${S.rarity}" + ) + } + for (S in Router.Catalog.Devices.values.take(2)) { + val D = S.description as? Net.Generated.DescriptionModelCC + println( + " 装置样本:id=${S.id}|base=${S.baseItemId}|name=${D?.Name}" + + "|category=${S.itemCategory}|rarity=${S.rarity}" + ) + } println("路由:已派发 ${Router.Applied}|未覆盖 ${Router.Unmapped}|失败 ${Router.Failed}|用户=${Router.Store.State.User.Uid}|屏幕=${Router.Store.State.Screen.Current}") val TopModels = Router.UnmappedModels.entries.sortedByDescending { it.value }.take(8) if (TopModels.isNotEmpty()) { @@ -114,7 +154,7 @@ object SessionCheck { println("高频 methodId:" + Top.joinToString(",") { "${it.key}×${it.value}" }) } // 抓包目录里混有入口/握手帧,解不出记录是正常现象;只要所有解出的记录都能回编码即可。 - val Passed = Bad == 0 && Records > 0 + val Passed = Bad == 0 && Records > 0 && Router.Catalog.Items.isNotEmpty() println(if (Passed) "结果:通过" else "结果:失败") ExitSession(if (Passed) 0 else 1) } diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Session/SessionRouter.kt b/TankiOffline/app/src/jsMain/kotlin/App/Session/SessionRouter.kt index 4a94193..2965402 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/Session/SessionRouter.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/Session/SessionRouter.kt @@ -17,8 +17,13 @@ class SessionRouter(InitialState: RootState = RootState()) { val Store: ReduxStore = ReduxStore(InitialState, ::ReduceRoot) + /** 静态物品目录:吸收 Pt 容器里的物品 / 装置 CC 组,不进 Redux 状态。 */ + val Catalog: ItemCatalog = ItemCatalog() + var Applied: Int = 0 private set + var Cataloged: Int = 0 + private set var Unmapped: Int = 0 private set var Failed: Int = 0 @@ -39,7 +44,12 @@ class SessionRouter(InitialState: RootState = RootState()) { val Value = Record.Value if (Value is PtPayload) { var Last: Any? = null - for (Item in Value.Items) { + val Consumed = Catalog.Ingest(Value) + for ((Index, Item) in Value.Items.withIndex()) { + if (Consumed != null && Consumed[Index]) { + Cataloged++ + continue + } val MakeModel = DownlinkModelActions[Item.Id] if (MakeModel == null) { Unmapped++