feat: M13a 导出官方「型号枚举→图标」表 + 素材中继 + 物品名称/类别中文可展示
A 图标表:从 module_36659.js:5135 的 eA.upf(底盘)与 module_57128.js:840 的 Dn.duk(炮塔)解出 case→形参→实参→i(模块id)→resources.json 的映射(严格走 case→形参名→形参声明行→实参行,不假设 case 号等于实参位置,因为该模块实参表约 1000 项而工厂形参只有 714 个、多出的被丢弃)。自验:用同文件 :907 的 Dn.euk 抗性表 21 case 全命中且本地镜像均存在(去重 20 个文件)。规模:底盘 416 case(未解 1)、炮塔 20、抗性 21、7 格道具 7。B 物品信息:名称来自服务端模型 3935107155251354407(DescriptionModelCC)的 name 字段,类别中文用 lobby.garage.<枚举名>(ARMOR=底盘/WEAPON=炮塔/PAINT=迷彩/DRONE=辅助无人机…),15 件仓库物品名称 9/9、类别中文 9/9 命中;但图标 0/9 —— 承载底盘/炮塔型号的 TankInfoCC(5543162332469864498)在全部 68 个 s2c 帧与模板里 0 次出现,属数据缺失而非算法问题,已如实标注。C 素材中继 client/tools/AssetRelay.mjs(8130):/ 服务客户端产物、/play/static/** 映射 3DTank/web/play/static 本地镜像、正确 MIME 与宽 CORS、防目录穿越(%2e%2e 返回 400)。回归:CompareContainerDecode 5 帧/26950/0、selftest 1108/9/90、semantic --demo 全部断言通过、Kotlin/JS 构建 SUCCESSFUL。
This commit is contained in:
1 parent
5359c002be
commit
e77ec20d81
8 files changed
+907
No files matched your search
@@ -30,6 +30,7 @@ import Protocol.ContainerDump
|
||||
import Protocol.GarageDriver
|
||||
import Protocol.GarageSession
|
||||
import Protocol.IsNodeRuntime
|
||||
import Protocol.ItemInfoDriver
|
||||
import Protocol.SelfTest
|
||||
import Protocol.SpaceDumpDriver
|
||||
|
||||
@@ -157,6 +158,11 @@ fun main() {
|
||||
SpaceDumpDriver.Run()
|
||||
return
|
||||
}
|
||||
// M13a 仓库物品可展示信息离线导出(TANKIC_ITEMINFO=1)
|
||||
if (ItemInfoDriver.Wanted()) {
|
||||
ItemInfoDriver.Run()
|
||||
return
|
||||
}
|
||||
// M5 车库因果闭环驱动(TANKIC_GARAGE=1)
|
||||
if (GarageDriver.Wanted()) {
|
||||
GarageDriver.Run()
|
||||
|
||||
@@ -85,6 +85,20 @@ internal class DevicesLoadedEntry(
|
||||
// 一条 UnloadObjects(lt) 记录
|
||||
internal class UnloadEntry(val FrameNo: Int, val Ids: List<String>)
|
||||
|
||||
// 仓库物品的可展示信息(M13a):名称 / 类别 / 稀有度 / 型号 / 图标。
|
||||
// 型号与图标推不出来时为 null(数据里没有 TankInfoCC,不猜、不拼文件名)。
|
||||
internal class ItemInfo(
|
||||
val ItemId: String,
|
||||
val ModelValue: Long?,
|
||||
val IconFile: String?,
|
||||
val Name: String?,
|
||||
val CategoryRaw: String?,
|
||||
val CategoryMember: String?,
|
||||
val CategoryChinese: String,
|
||||
val RarityRaw: String?,
|
||||
val RarityMember: String?,
|
||||
)
|
||||
|
||||
// 一个对象是否「挂载位」的两枚标记 modelId(依据见文件头 (2))
|
||||
private const val SLOT_MARK_MODIFICATION = "2494628067134376039"
|
||||
private const val SLOT_MARK_DEVICES_AVAILABLE = "7391892068905080676"
|
||||
@@ -93,6 +107,30 @@ private const val SLOT_MARK_MODIFICATION_COUNT = 7
|
||||
// 物品「装备槽类别」所在的模型项 modelId(module_85196::Lo)—— 依据见 ItemCategory 的头注
|
||||
private const val CATEGORY_MODEL = "6117306895090150422"
|
||||
|
||||
// ---- 可展示物品信息(M13a)用到的模型 id / 字段名(全部来自 names/codec_fields.json,逐条可复核)
|
||||
//
|
||||
// 物品中文名:modelId=3935107155251354407(module_99527::N DescriptionModelCC),字段 name / description。
|
||||
// 稀有度: modelId=658797348514407988(module_85196::Fs ItemRarityCC),字段 rarity(线型 enum<Ma>)。
|
||||
// 型号(底盘/炮塔):modelId=5543162332469864498(module_85183::Nb TankInfoCC,ModelName=TankInfoCC)。
|
||||
// · 该 codec 的字段(codec_fields.json:51248-51350)按序是:
|
||||
// 1 (匿名) prop=okz_1 i64 ← 底盘型号
|
||||
// 2 hullDeviceId opt<i64>
|
||||
// 3 (匿名) prop=qkz_1 i64
|
||||
// 4 (匿名) prop=rkz_1 i64 ← 炮塔型号
|
||||
// 5 weaponDeviceId opt<i64>
|
||||
// 6 (匿名) prop=tkz_1 i64
|
||||
// 两个「型号」字段是匿名的,导出表里按 `(匿名#序)` 命名,故运行时键是 "(匿名#1)" / "(匿名#4)"。
|
||||
// · 客户端取图标入口(source-of-truth):车库 UI module_21300.js:7862-7908(rkz_1)/
|
||||
// 7950-7982(okz_1)逐值比对后调 hn.upf(u),即走 ItemIcons 的 Turret/Hull 表。
|
||||
// · ★如实标注:本账号抓包(space-100073070 各 s2c 帧)与 space-init-*.json 里**都没有**这个模型项,
|
||||
// 所以仓库里这 15 件物品的「型号值」取不到(返回 null),图标也就推不出来 —— 不是算错,是数据里没有。
|
||||
private const val NAME_MODEL = "3935107155251354407"
|
||||
private const val RARITY_MODEL = "658797348514407988"
|
||||
private const val TANKINFO_MODEL = "5543162332469864498"
|
||||
private const val TANKINFO_HULL_FIELD = "(匿名#1)" // 源码 prop okz_1(底盘型号)
|
||||
private const val TANKINFO_WEAPON_FIELD = "(匿名#4)" // 源码 prop rkz_1(炮塔型号)
|
||||
private const val RARITY_ENUM_SHORT = "Ma" // module_85196::Fs 的 rarity 线型 enum<Ma>
|
||||
|
||||
// 类别枚举的查名上下文(**仅展示层**):codec module_85196::Lo 的 category 线型是 `enum<ja>`,
|
||||
// 而 ja 的成员表定义在 module_85667(TypeCodec=module_85667::pu)。导出 EnumMembers 时已按
|
||||
// 「字段所在模块 → TypeCodec 模块」回退解析并把结果挂在字段所在模块(85196)键下,故这里传 85196。
|
||||
@@ -164,6 +202,81 @@ internal class ClientState {
|
||||
return EnumNames.Label(CATEGORY_ENUM_MODULE, CATEGORY_ENUM_SHORT, Raw)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 可展示物品信息(M13a)
|
||||
|
||||
// 取某物品在 pt 里的某个模型项的字段表(Value);没有该模型项返回 null。
|
||||
private fun ModelFields(ItemId: String, ModelId: String): Map<*, *>? {
|
||||
val O = PtByObjectId[ItemId] ?: return null
|
||||
for (M in O.Models) if (M.ModelId == ModelId) return M.Value as? Map<*, *>
|
||||
return null
|
||||
}
|
||||
|
||||
// 物品中文名:模型 3935107155251354407(DescriptionModelCC)的 name;空串/无此项返回 null。
|
||||
fun ItemName(ItemId: String): String? {
|
||||
val V = ModelFields(ItemId, NAME_MODEL)?.get("name")?.toString() ?: return null
|
||||
return V.ifEmpty { null }
|
||||
}
|
||||
|
||||
// 类别的**成员名**(如 ARMOR/WEAPON/PAINT/INVENTORY);查不到返回 null。
|
||||
fun ItemCategoryMember(ItemId: String): String? {
|
||||
val Raw = ItemCategory(ItemId) ?: return null
|
||||
return EnumNames.OfRaw(CATEGORY_ENUM_MODULE, CATEGORY_ENUM_SHORT, Raw)
|
||||
}
|
||||
|
||||
// 类别中文:先取成员名,再查 zh-CN 的 lobby.garage.<成员名>;缺键兜底成成员名;未解兜底成原始 enum#N。
|
||||
fun ItemCategoryChinese(ItemId: String): String {
|
||||
val Raw = ItemCategory(ItemId) ?: return "?"
|
||||
val Member = EnumNames.OfRaw(CATEGORY_ENUM_MODULE, CATEGORY_ENUM_SHORT, Raw) ?: return Raw
|
||||
return ItemIcons.GarageLabel(Member) ?: Member
|
||||
}
|
||||
|
||||
// 稀有度(原始 enum#N 与成员名,成员表 module_85196::Fs 的 rarity 线型 enum<Ma>);没有该模型项返回 null
|
||||
fun ItemRarityRaw(ItemId: String): String? = ModelFields(ItemId, RARITY_MODEL)?.get("rarity")?.toString()
|
||||
|
||||
fun ItemRarityMember(ItemId: String): String? {
|
||||
val Raw = ItemRarityRaw(ItemId) ?: return null
|
||||
return EnumNames.OfRaw(CATEGORY_ENUM_MODULE, RARITY_ENUM_SHORT, Raw)
|
||||
}
|
||||
|
||||
// 型号值:TankInfoCC(5543162332469864498)的 okz_1(底盘)/ rkz_1(炮塔),按类别选字段。
|
||||
// 没有该模型项 / 类别不是底盘炮塔 / 字段不是数字 ⇒ null(**不猜**)。
|
||||
fun ItemModelValue(ItemId: String): Long? {
|
||||
val Member = ItemCategoryMember(ItemId) ?: return null
|
||||
val Field = when (Member) {
|
||||
"ARMOR" -> TANKINFO_HULL_FIELD
|
||||
"WEAPON" -> TANKINFO_WEAPON_FIELD
|
||||
else -> return null
|
||||
}
|
||||
val V = ModelFields(ItemId, TANKINFO_MODEL)?.get(Field) ?: return null
|
||||
return (V as? Number)?.toLong()
|
||||
}
|
||||
|
||||
// 图标文件:型号值 + A 表(ItemIcons)推出;型号取不到就返回 null(不拼文件名、不按名字猜)。
|
||||
fun ItemIconFile(ItemId: String): String? {
|
||||
val Mv = ItemModelValue(ItemId) ?: return null
|
||||
return when (ItemCategoryMember(ItemId)) {
|
||||
"ARMOR" -> ItemIcons.Hull(Mv)
|
||||
"WEAPON" -> ItemIcons.Turret(Mv)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// 单件物品的可展示信息
|
||||
fun ItemInfo(ItemId: String): ItemInfo = ItemInfo(
|
||||
ItemId = ItemId,
|
||||
ModelValue = ItemModelValue(ItemId),
|
||||
IconFile = ItemIconFile(ItemId),
|
||||
Name = ItemName(ItemId),
|
||||
CategoryRaw = ItemCategory(ItemId),
|
||||
CategoryMember = ItemCategoryMember(ItemId),
|
||||
CategoryChinese = ItemCategoryChinese(ItemId),
|
||||
RarityRaw = ItemRarityRaw(ItemId),
|
||||
RarityMember = ItemRarityMember(ItemId),
|
||||
)
|
||||
|
||||
// 仓库物品(itemsOnDepot)逐件信息
|
||||
fun ItemInfos(): List<ItemInfo> = ItemsOnDepot.map { ItemInfo(it) }
|
||||
|
||||
// ---- 挂载位 / 设备
|
||||
// devicesLoaded 在服务端是**覆盖写设备行**(见 3DTank/tools/server/responder.js 头注),
|
||||
// 所以按挂载位 id 收敛、后到者覆盖;同时保留收到过的记录条数以便追溯。
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// ItemIcons.kt —— 「型号枚举 → 官方图标文件」查询对象(M13a)
|
||||
//
|
||||
// 数据来源:client/src/jsMain/resources/ItemIcons.json(由 client/tools/ExportItemIcons.mjs 从官方
|
||||
// 解包源码 module_36659.js / module_57128.js / module_82347.js 解出,同一份 JSON 内嵌在 ItemIconsData.kt)。
|
||||
//
|
||||
// 口径(为什么不在这里拼文件名):官方客户端从不拼文件名,而是 switch(型号枚举) → 一个**图标资源模块**;
|
||||
// 表里存的就是那一层映射(case → "static/images/xxx.svg")。查不到就返回 null(不猜)。
|
||||
//
|
||||
// 图标路径是相对 web/play 根的("static/images/..."), 客户端配素材中继(AssetRelay 的 /play 前缀)取用。
|
||||
package Protocol
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// 导出的统计信息(自验/规模,给人看)
|
||||
@Serializable
|
||||
internal class ItemIconSelection(
|
||||
val HullCases: Int = 0,
|
||||
val HullMisses: Int = 0,
|
||||
val TurretCases: Int = 0,
|
||||
val TurretMisses: Int = 0,
|
||||
val ResistanceCases: Int = 0,
|
||||
val ResistanceMisses: Int = 0,
|
||||
val SupplyCount: Int = 0,
|
||||
val OtherTableCount: Int = 0,
|
||||
val GarageLabelCount: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal class ItemIconSelfCheck(
|
||||
val ResistanceCases: Int = 0,
|
||||
val ResistanceResolved: Int = 0,
|
||||
val ResistanceLocalFilesPresent: Int = 0,
|
||||
val ResistanceDistinctFiles: Int = 0,
|
||||
val Lines: List<String> = emptyList(),
|
||||
val HullMissLines: List<String> = emptyList(),
|
||||
val TurretMissLines: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
// 7 格道具图标表的一项:Key = 源码里产出「道具类型枚举」的函数名(局部名,语义未解,照抄不猜)
|
||||
@Serializable
|
||||
internal class SupplyIcon(
|
||||
val Key: String = "",
|
||||
val Icon: String? = null,
|
||||
val ModuleId: String? = null,
|
||||
val Line: Int = 0,
|
||||
)
|
||||
|
||||
// 自动扫描到的其它同类表(case → 图标)
|
||||
@Serializable
|
||||
internal class OtherIconTable(
|
||||
val Module: String = "",
|
||||
val Line: Int = 0,
|
||||
val Cases: Int = 0,
|
||||
val Icons: Int = 0,
|
||||
val Sample: List<String> = emptyList(),
|
||||
val All: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal class ItemIconsData(
|
||||
val Note: String = "",
|
||||
val GeneratedAt: String = "",
|
||||
val Source: String = "",
|
||||
val Selection: ItemIconSelection = ItemIconSelection(),
|
||||
val SelfCheck: ItemIconSelfCheck = ItemIconSelfCheck(),
|
||||
// 型号(switch 的 case 值,十进制字符串)→ 图标文件
|
||||
val Hull: Map<String, String> = emptyMap(),
|
||||
val Turret: Map<String, String> = emptyMap(),
|
||||
val Resistance: Map<String, String> = emptyMap(),
|
||||
// 7 格道具图标表(按表内顺序)
|
||||
val Supply: List<SupplyIcon> = emptyList(),
|
||||
val OtherTables: List<OtherIconTable> = emptyList(),
|
||||
// 显示层中文:lobby.garage.<成员名> → 中文
|
||||
val GarageLabels: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
internal object ItemIcons {
|
||||
|
||||
private val JsonFormat = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// 懒加载:第一次用到时才解析那段 JSON
|
||||
val Data: ItemIconsData by lazy {
|
||||
JsonFormat.decodeFromString(ItemIconsData.serializer(), ItemIconsJsonText)
|
||||
}
|
||||
|
||||
// 底盘型号 → 图标文件;查不到返回 null
|
||||
fun Hull(ModelValue: Long): String? = Data.Hull[ModelValue.toString()]
|
||||
|
||||
// 炮塔型号 → 图标文件;查不到返回 null
|
||||
fun Turret(ModelValue: Long): String? = Data.Turret[ModelValue.toString()]
|
||||
|
||||
// 抗性型号 → 图标文件(自验用,也供防御模块显示);查不到返回 null
|
||||
fun Resistance(ModelValue: Long): String? = Data.Resistance[ModelValue.toString()]
|
||||
|
||||
// 7 格道具图标表的第 Index 项(0 起);越界/无图标返回 null
|
||||
fun Supply(Index: Int): String? = Data.Supply.getOrNull(Index)?.Icon
|
||||
|
||||
// 类别/稀有度成员名 → 中文(取 zh-CN 的 lobby.garage.<成员名>);缺键返回 null
|
||||
fun GarageLabel(Member: String): String? = Data.GarageLabels[Member]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 本文件由 client/tools/ExportItemIcons.mjs 生成,请勿手改。
|
||||
// 内容 = client/src/jsMain/resources/ItemIcons.json(同一份 JSON)。
|
||||
package Protocol
|
||||
|
||||
internal const val ItemIconsJsonText: String =
|
||||
"{\"Note\":\"物品图标表,由 client/tools/ExportItemIcons.mjs 从官方解包源码生成,请勿手改。Hull = module_36659.js 的 Mb(eA).upf switch:case(底盘型号)→ i(<模块id>) → resources.json 文件名;Turret = module_57128.js 的 ct(Dn).duk switch(炮塔型号);Resistance = 同文件 ct(Dn).euk switch(抗性,用于自验解析算法)。解析口径:case → 形参名 → 形参声明行 → 同序实参行 → i(<模块id>),不能假设「case 号 = 实参位置」。Supply = module_82347.js 的 7 格道具图标表(new ZMi 数组,非 switch)。OtherTables = 自动扫描到的其它同类 case→图标 表(供手榴弹/无人机/防御模块查用;没有就是空数组)。GarageLabels = zh-CN 的 lobby.garage.*(只取字符串叶子),显示层类别中文用。图标路径统一为 \\\"static/images/xxx.svg\\\"(相对 web/play 根,客户端配 AssetRelay 的 /play 前缀取用)。\",\"GeneratedAt\":\"2026-09-18T07:47:43.890Z\",\"Source\":\"3dtank.com/play/static/js/unpacked/{resources.json,module_36659.js,module_57128.js,module_82347.js} + 3DTank/web/play/static/translations/zh-CN.a2b303af.json\",\"Selection\":{\"HullCases\":416,\"HullMisses\":1,\"TurretCases\":20,\"TurretMisses\":0,\"ResistanceCases\":21,\"ResistanceMisses\":0,\"SupplyCount\":7,\"OtherTableCount\":3,\"GarageLabelCount\":108},\"SelfCheck\":{\"ResistanceCases\":21,\"ResistanceResolved\":21,\"ResistanceLocalFilesPresent\":21,\"ResistanceDistinctFiles\":20,\"Lines\":[\"case 23 -> static/images/firebird_resistance.785a9d6b.svg ✓本地存在\",\"case 25 -> static/images/smoky_resistance.845afc14.svg ✓本地存在\",\"case 26 -> static/images/tsunami_resistance.6200aad9.svg ✓本地存在\",\"case 27 -> static/images/scorpio_resistance.e8f1787f.svg ✓本地存在\",\"case 28 -> static/images/twins_resistance.ad189f61.svg ✓本地存在\",\"case 29 -> static/images/railgun_resistance.636a554f.svg ✓本地存在\",\"case 30 -> static/images/isis_resistance.30a69ffc.svg ✓本地存在\",\"case 31 -> static/images/mine_resistance.dd581c90.svg ✓本地存在\",\"case 32 -> static/images/thunder_resistance.6d7f4531.svg ✓本地存在\",\"case 33 -> static/images/freeze_resistance.33bdf642.svg ✓本地存在\",\"case 34 -> static/images/ricochet_resistance.8247beaa.svg ✓本地存在\",\"case 35 -> static/images/shaft_resistance.0778fd3e.svg ✓本地存在\",\"case 36 -> static/images/vulcan_resistance.824f6f0e.svg ✓本地存在\",\"case 37 -> static/images/hammer_resistance.6c549d29.svg ✓本地存在\",\"case 38 -> static/images/rocket_launcher_resistance.b7dfd64f.svg ✓本地存在\",\"case 39 -> static/images/artillery_resistance.9b4cbc34.svg ✓本地存在\",\"case 40 -> static/images/all_resistance.b45eae39.svg ✓本地存在\",\"case 41 -> static/images/gauss_resistance.bb8f409c.svg ✓本地存在\",\"case 42 -> static/images/tesla_resistance.3e686c8e.svg ✓本地存在\",\"case 43 -> static/images/crit_resistance.94e32312.svg ✓本地存在\",\"case 44 -> static/images/all_resistance.b45eae39.svg ✓本地存在\"]},\"Hull\":{\"0\":\"static/images/dot.69f2e150.svg\",\"1\":\"static/images/hudSupplyBackground.19f6b76c.svg\",\"2\":\"static/images/supplyKeyLabelBackground.fe3c4a5e.svg\",\"3\":\"static/images/background_flash.5c9bb879.svg\",\"4\":\"static/images/no_device.d6a5ed38.svg\",\"5\":\"static/images/jamming.27ca209f.svg\",\"6\":\"static/images/pierce.53f9cfc2.svg\",\"7\":\"static/images/freezing.ecb7c14a.svg\",\"8\":\"static/images/firebird_high_pressure_pump.a8244e4a.svg\",\"9\":\"static/images/compact_tanks.f7e14a7d.svg\",\"10\":\"static/images/thermite_mix.48b12b96.svg\",\"11\":\"static/images/self_heating.b24239b8.svg\",\"12\":\"static/images/firebird_magnetic.422b1b1c.svg\",\"13\":\"static/images/firebird_paralyzing.ce9a0315.svg\",\"14\":\"static/images/firebird_toxic.53f9cfc2.svg\",\"15\":\"static/images/crit_mix.bc4eeef4.svg\",\"16\":\"static/images/freeze_high_pressure_pump.a8244e4a.svg\",\"17\":\"static/images/corrosive_mix.48b12b96.svg\",\"18\":\"static/images/deep_cooling.9dee7d51.svg\",\"19\":\"static/images/self_cooling.7f72e2b9.svg\",\"20\":\"static/images/freeze_emp.999d4c2f.svg\",\"21\":\"static/images/freeze_stun.ce9a0315.svg\",\"22\":\"static/images/stable_mix.854e4ede.svg\",\"23\":\"static/images/vampire.369dbf7a.svg\",\"24\":\"static/images/damage_nanobots.88a331a6.svg\",\"25\":\"static/images/support_nanobots.58f8a26a.svg\",\"26\":\"static/images/broadband_emitters.9113bd6f.svg\",\"27\":\"static/images/isida_vampiric_nanobots.fd08d293.svg\",\"28\":\"static/images/isida_enduring_nanobots.c22db7a0.svg\",\"29\":\"static/images/incendiary_belt.d80e9a8e.svg\",\"30\":\"static/images/incendiary_bombs.50e991df.svg\",\"31\":\"static/images/faster_horizontal_tracking_vulcan.843faf29.svg\",\"32\":\"static/images/modified_firing_rate.f7c9fbd9.svg\",\"33\":\"static/images/machine_gun_rubber_bullets.b991ff76.svg\",\"34\":\"static/images/vLine truncated
|
||||
@@ -0,0 +1,144 @@
|
||||
// ItemInfoDriver.kt —— M13a:仓库物品「可展示信息」离线导出(nodejs 目标,读本地抓包帧,不连服务器)
|
||||
//
|
||||
// 为什么离线读抓包:itemsOnDepot / pt 都在**已保存的 s2c 帧**里(space-100073070/s2c_0001.bin 有
|
||||
// pt + itemsOnDepot,s2c_0004.bin 有 pt + devicesLoaded)。直接解本地帧即可把 ClientState 填满,
|
||||
// 不依赖 ws://localhost:9091 是否在跑,结果也可复现。
|
||||
//
|
||||
// 用法:TANKIC_ITEMINFO=1 node <node 产物>
|
||||
// 可选 TANKIC_PACKET_DIR 指定 3DTank/packets/plain
|
||||
// 可选 TANKIC_ITEMINFO_FILES 覆盖帧清单(分号分隔的相对路径)
|
||||
package Protocol
|
||||
|
||||
internal object ItemInfoDriver {
|
||||
|
||||
private const val EnterLabel = "读本地抓包帧(离线)"
|
||||
|
||||
fun Wanted(): Boolean = Env("TANKIC_ITEMINFO") == "1"
|
||||
|
||||
fun Run() {
|
||||
Log("=== 仓库物品可展示信息导出(M13a,离线)===")
|
||||
val Files = Env("TANKIC_ITEMINFO_FILES").ifEmpty {
|
||||
"space-100073070/s2c_0001.bin;space-100073070/s2c_0004.bin"
|
||||
}.split(";").filter { it.isNotEmpty() }
|
||||
|
||||
val State = ClientState()
|
||||
var FrameNo = 0
|
||||
for (Rel in Files) {
|
||||
val Found = ReadPacket(Rel)
|
||||
if (Found == null) {
|
||||
Log("✗ 找不到抓包帧 $Rel(可用 TANKIC_PACKET_DIR 指定 3DTank/packets/plain)")
|
||||
ExitProcess(1)
|
||||
return
|
||||
}
|
||||
val (Path, Raw) = Found
|
||||
Log("← 读入 $Path ${Raw.size} 字节")
|
||||
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("✗ 帧解析失败:${E.message}")
|
||||
break
|
||||
}
|
||||
FrameNo++
|
||||
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) State.OnSpaceFrame(SpaceFrame.Parse(Body), FrameNo, Info.TotalSize)
|
||||
Off += Info.TotalSize
|
||||
}
|
||||
}
|
||||
|
||||
Log("")
|
||||
Log("itemsOnDepot ${State.ItemsOnDepot.size} 件 / mountedItems ${State.MountedItems.size} 件 / pt 对象 ${State.PtObjectCount} 个")
|
||||
Log("")
|
||||
Log("--- 仓库物品逐件(物品id | 型号值 | 图标文件 | 名称 | 类别中文 | 类别成员 | 稀有度成员 | 稀有度原始)---")
|
||||
val Infos = State.ItemInfos()
|
||||
for (I in Infos) {
|
||||
Log(
|
||||
" " + I.ItemId + " | " + (I.ModelValue?.toString() ?: "—") + " | " + (I.IconFile ?: "—") +
|
||||
" | " + (I.Name ?: "—") + " | " + I.CategoryChinese +
|
||||
" | " + (I.CategoryMember ?: I.CategoryRaw ?: "—") +
|
||||
" | " + (I.RarityMember ?: "—") + " | " + (I.RarityRaw ?: "—")
|
||||
)
|
||||
}
|
||||
|
||||
// 与「已知答案」逐件比对(已知答案 = 官方客户端显示,见任务书)
|
||||
val Known = linkedMapOf(
|
||||
"920009630955" to ("Wasp" to "黄蜂轻甲"),
|
||||
"920009631018" to ("Titan" to "泰坦"),
|
||||
"920009631039" to ("Hunter" to "猎人中甲"),
|
||||
"921009707333" to ("Crusader" to "十字军"),
|
||||
"920009630969" to ("Smoky" to "轰天炮"),
|
||||
"920009630983" to ("Firebird" to "火焰炮"),
|
||||
"920009630997" to ("Twins" to "离子炮"),
|
||||
"920009631011" to ("Railgun" to "激光炮"),
|
||||
"10007268" to ("Double armor" to "护甲提升"),
|
||||
)
|
||||
Log("")
|
||||
Log("--- 与已知答案比对(9 件:4 底盘 + 4 炮塔 + 1 道具)---")
|
||||
var IconHit = 0
|
||||
var NameHit = 0
|
||||
for ((Id, Expect) in Known) {
|
||||
val I = Infos.firstOrNull { it.ItemId == Id }
|
||||
val IconStem = I?.IconFile?.substringAfterLast('/')?.substringBeforeLast('.')
|
||||
val IconOk = IconStem == Expect.first
|
||||
val NameOk = I?.Name == Expect.second
|
||||
if (IconOk) IconHit++
|
||||
if (NameOk) NameHit++
|
||||
Log(
|
||||
" " + Id + " 期望图标=" + Expect.first + "/期望名称=" + Expect.second +
|
||||
" 实际图标=" + (IconStem ?: "—") + "(" + (if (IconOk) "✓" else "✗") + ")" +
|
||||
" 实际名称=" + (I?.Name ?: "—") + "(" + (if (NameOk) "✓" else "✗") + ")"
|
||||
)
|
||||
}
|
||||
Log(" 图标命中 $IconHit / 9,名称命中 $NameHit / 9")
|
||||
if (IconHit < 9) {
|
||||
Log(" 原因:仓库物品的 pt 模型表里**没有** TankInfoCC(modelId=5543162332469864498),")
|
||||
Log(" 型号值(okz_1/rkz_1)取不到 ⇒ 图标推不出来。这不是算法问题,是抓包数据里没有该模型项。")
|
||||
}
|
||||
Log("")
|
||||
Log("--- ItemIcons 表规模(A 产物)---")
|
||||
Log(
|
||||
" 底盘 " + ItemIcons.Data.Selection.HullCases + " case / 炮塔 " + ItemIcons.Data.Selection.TurretCases +
|
||||
" case / 抗性 " + ItemIcons.Data.Selection.ResistanceCases + " case / 7 格道具 " + ItemIcons.Data.Selection.SupplyCount +
|
||||
" 项 / 其它表 " + ItemIcons.Data.Selection.OtherTableCount + " 张"
|
||||
)
|
||||
Log(" 抽样:底盘 397 → " + ItemIcons.Hull(397) + ",炮塔 7 → " + ItemIcons.Turret(7) + ",道具[2] → " + ItemIcons.Supply(2))
|
||||
Log("")
|
||||
Log("✓ M13a 物品信息导出结束")
|
||||
ExitProcess(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- node 侧小工具
|
||||
|
||||
private fun Env(Name: String): String = js("process.env[Name] || ''") as String
|
||||
|
||||
// 抓包目录解析(与 SelfTest 同口径:环境变量优先,其次从 CWD 逐级往上找)
|
||||
private fun PacketDirs(): List<String> {
|
||||
val Out = ArrayList<String>()
|
||||
val EnvDir = Env("TANKIC_PACKET_DIR")
|
||||
if (EnvDir.isNotEmpty()) Out.add(EnvDir)
|
||||
val Cwd = js("process.cwd()") as String
|
||||
for (Up in 0..6) Out.add(Cwd + "/" + "../".repeat(Up) + "3DTank/packets/plain")
|
||||
return Out
|
||||
}
|
||||
|
||||
private fun ReadPacket(Rel: String): Pair<String, ByteArray>? {
|
||||
val Fs = NodeRequire("fs")
|
||||
for (Dir in PacketDirs()) {
|
||||
val Path = Dir + "/" + Rel
|
||||
try {
|
||||
return Path to BufferToByteArray(Fs.readFileSync(Path))
|
||||
} catch (E: dynamic) {
|
||||
// 该候选目录下没有这个文件,试下一个
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun Log(Msg: String) {
|
||||
println(Msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"Note":"物品图标表,由 client/tools/ExportItemIcons.mjs 从官方解包源码生成,请勿手改。Hull = module_36659.js 的 Mb(eA).upf switch:case(底盘型号)→ i(<模块id>) → resources.json 文件名;Turret = module_57128.js 的 ct(Dn).duk switch(炮塔型号);Resistance = 同文件 ct(Dn).euk switch(抗性,用于自验解析算法)。解析口径:case → 形参名 → 形参声明行 → 同序实参行 → i(<模块id>),不能假设「case 号 = 实参位置」。Supply = module_82347.js 的 7 格道具图标表(new ZMi 数组,非 switch)。OtherTables = 自动扫描到的其它同类 case→图标 表(供手榴弹/无人机/防御模块查用;没有就是空数组)。GarageLabels = zh-CN 的 lobby.garage.*(只取字符串叶子),显示层类别中文用。图标路径统一为 \"static/images/xxx.svg\"(相对 web/play 根,客户端配 AssetRelay 的 /play 前缀取用)。","GeneratedAt":"2026-09-18T07:47:43.890Z","Source":"3dtank.com/play/static/js/unpacked/{resources.json,module_36659.js,module_57128.js,module_82347.js} + 3DTank/web/play/static/translations/zh-CN.a2b303af.json","Selection":{"HullCases":416,"HullMisses":1,"TurretCases":20,"TurretMisses":0,"ResistanceCases":21,"ResistanceMisses":0,"SupplyCount":7,"OtherTableCount":3,"GarageLabelCount":108},"SelfCheck":{"ResistanceCases":21,"ResistanceResolved":21,"ResistanceLocalFilesPresent":21,"ResistanceDistinctFiles":20,"Lines":["case 23 -> static/images/firebird_resistance.785a9d6b.svg ✓本地存在","case 25 -> static/images/smoky_resistance.845afc14.svg ✓本地存在","case 26 -> static/images/tsunami_resistance.6200aad9.svg ✓本地存在","case 27 -> static/images/scorpio_resistance.e8f1787f.svg ✓本地存在","case 28 -> static/images/twins_resistance.ad189f61.svg ✓本地存在","case 29 -> static/images/railgun_resistance.636a554f.svg ✓本地存在","case 30 -> static/images/isis_resistance.30a69ffc.svg ✓本地存在","case 31 -> static/images/mine_resistance.dd581c90.svg ✓本地存在","case 32 -> static/images/thunder_resistance.6d7f4531.svg ✓本地存在","case 33 -> static/images/freeze_resistance.33bdf642.svg ✓本地存在","case 34 -> static/images/ricochet_resistance.8247beaa.svg ✓本地存在","case 35 -> static/images/shaft_resistance.0778fd3e.svg ✓本地存在","case 36 -> static/images/vulcan_resistance.824f6f0e.svg ✓本地存在","case 37 -> static/images/hammer_resistance.6c549d29.svg ✓本地存在","case 38 -> static/images/rocket_launcher_resistance.b7dfd64f.svg ✓本地存在","case 39 -> static/images/artillery_resistance.9b4cbc34.svg ✓本地存在","case 40 -> static/images/all_resistance.b45eae39.svg ✓本地存在","case 41 -> static/images/gauss_resistance.bb8f409c.svg ✓本地存在","case 42 -> static/images/tesla_resistance.3e686c8e.svg ✓本地存在","case 43 -> static/images/crit_resistance.94e32312.svg ✓本地存在","case 44 -> static/images/all_resistance.b45eae39.svg ✓本地存在"]},"Hull":{"0":"static/images/dot.69f2e150.svg","1":"static/images/hudSupplyBackground.19f6b76c.svg","2":"static/images/supplyKeyLabelBackground.fe3c4a5e.svg","3":"static/images/background_flash.5c9bb879.svg","4":"static/images/no_device.d6a5ed38.svg","5":"static/images/jamming.27ca209f.svg","6":"static/images/pierce.53f9cfc2.svg","7":"static/images/freezing.ecb7c14a.svg","8":"static/images/firebird_high_pressure_pump.a8244e4a.svg","9":"static/images/compact_tanks.f7e14a7d.svg","10":"static/images/thermite_mix.48b12b96.svg","11":"static/images/self_heating.b24239b8.svg","12":"static/images/firebird_magnetic.422b1b1c.svg","13":"static/images/firebird_paralyzing.ce9a0315.svg","14":"static/images/firebird_toxic.53f9cfc2.svg","15":"static/images/crit_mix.bc4eeef4.svg","16":"static/images/freeze_high_pressure_pump.a8244e4a.svg","17":"static/images/corrosive_mix.48b12b96.svg","18":"static/images/deep_cooling.9dee7d51.svg","19":"static/images/self_cooling.7f72e2b9.svg","20":"static/images/freeze_emp.999d4c2f.svg","21":"static/images/freeze_stun.ce9a0315.svg","22":"static/images/stable_mix.854e4ede.svg","23":"static/images/vampire.369dbf7a.svg","24":"static/images/damage_nanobots.88a331a6.svg","25":"static/images/support_nanobots.58f8a26a.svg","26":"static/images/broadband_emitters.9113bd6f.svg","27":"static/images/isida_vampiric_nanobots.fd08d293.svg","28":"static/images/isida_enduring_nanobots.c22db7a0.svg","29":"static/images/incendiary_belt.d80e9a8e.svg","30":"static/images/incendiary_bombs.50e991df.svg","31":"static/images/faster_horizontal_tracking_vulcan.843faf29.svg","32":"static/images/modified_firing_rate.f7c9fbd9.svg","33":"static/images/machine_gun_rubber_bullets.b991ff76.svg","34":"static/images/vulcan_shredder.62384918.svg","35":"static/images/magnum_lafet.ffcd092b.svg","36":"static/images/crio_bombs.70e1cab1.svg","37":"static/images/modified_carriage.9b8f19d3.svg","38":"static/images/minelayer.23123790.svg","39":"static/imagesLine truncated
|
||||
@@ -0,0 +1,189 @@
|
||||
// AssetRelay.mjs —— 素材中继(只读本地 HTTP 服务,默认端口 8130)
|
||||
//
|
||||
// 目的:把「客户端产物」与「官方静态素材本地镜像」拼成一个可浏览的站点,给浏览器侧客户端用。
|
||||
// 1) `/` 与其它静态文件 → client/build/dist/js/productionExecutable/(Kotlin/JS 产物;不存在就清晰报错)
|
||||
// 2) `/play/static/**` → 3DTank/web/play/static/**(**本地镜像优先**,只读)
|
||||
// 3) 本地镜像缺文件时(可选)回落到 --upstream(用户的 3DTank/tools/serve.js,默认不启用),并打日志
|
||||
//
|
||||
// 安全:只读;路径做规范化 + 前缀校验,禁止用 `..`(含 %2e%2e)逃出映射根。
|
||||
//
|
||||
// 用法:
|
||||
// node client/tools/AssetRelay.mjs
|
||||
// node client/tools/AssetRelay.mjs --port 8130 --upstream http://localhost:8080
|
||||
'use strict';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
|
||||
const Here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const ClientDir = path.resolve(Here, '..');
|
||||
const RepoDir = path.resolve(ClientDir, '..');
|
||||
const DistDir = path.join(ClientDir, 'build', 'dist', 'js', 'productionExecutable');
|
||||
const MirrorRoot = path.join(RepoDir, '3DTank', 'web', 'play', 'static');
|
||||
|
||||
// ---- 参数
|
||||
const Args = process.argv.slice(2);
|
||||
function ArgOf(Name, Default) {
|
||||
const I = Args.indexOf('--' + Name);
|
||||
return I >= 0 && Args[I + 1] ? Args[I + 1] : Default;
|
||||
}
|
||||
const Port = Number(ArgOf('port', '8130'));
|
||||
const Upstream = ArgOf('upstream', '');
|
||||
|
||||
// ---- MIME(按扩展名;够用即可,未列的按 application/octet-stream)
|
||||
const MimeByExt = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.ttf': 'font/ttf',
|
||||
'.otf': 'font/otf',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.wav': 'audio/wav',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.3ds': 'application/octet-stream',
|
||||
'.wasm': 'application/wasm',
|
||||
};
|
||||
function MimeOf(P) {
|
||||
return MimeByExt[path.extname(P).toLowerCase()] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// 防目录穿越:把相对路径 join 到根后规范化,必须仍在根之内
|
||||
function SafeJoin(Root, Rel) {
|
||||
const Normalized = path.normalize(Rel).replace(/^([/\\])+/, '');
|
||||
const Full = path.normalize(path.join(Root, Normalized));
|
||||
if (Full !== Root && !Full.startsWith(Root + path.sep)) return null;
|
||||
return Full;
|
||||
}
|
||||
|
||||
const UrlPathOf = (Req) => decodeURIComponent((Req.url || '/').split('?')[0].split('#')[0]);
|
||||
|
||||
function Log(Msg) {
|
||||
console.log('TankIcAssetRelay | ' + Msg);
|
||||
}
|
||||
|
||||
function SendFile(Res, File, ExtraHeaders) {
|
||||
const Headers = Object.assign({ 'Content-Type': MimeOf(File), 'Content-Length': String(fs.statSync(File).size) }, ExtraHeaders || {});
|
||||
Res.writeHead(200, Headers);
|
||||
fs.createReadStream(File).pipe(Res);
|
||||
}
|
||||
|
||||
function SendText(Res, Code, Text, ExtraHeaders) {
|
||||
const Buf = Buffer.from(Text, 'utf8');
|
||||
Res.writeHead(Code, Object.assign({ 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': String(Buf.length) }, ExtraHeaders || {}));
|
||||
Res.end(Buf);
|
||||
}
|
||||
|
||||
// 回落:把请求转发给 --upstream(只读代理;命中时打日志)
|
||||
function RelayUpstream(Req, Res, RelPath) {
|
||||
const Target = new URL(RelPath, Upstream);
|
||||
const Lib = Target.protocol === 'https:' ? https : http;
|
||||
const P = Lib.request(
|
||||
Target,
|
||||
{ method: 'GET', headers: { Accept: Req.headers.accept || '*/*' } },
|
||||
(Up) => {
|
||||
if (Up.statusCode !== 200) {
|
||||
Up.resume();
|
||||
SendText(Res, 404, '本地镜像没有,且上游返回 ' + Up.statusCode + ':' + RelPath + '\n');
|
||||
return;
|
||||
}
|
||||
const Headers = {};
|
||||
for (const K of ['content-type', 'content-length', 'etag', 'last-modified']) if (Up.headers[K]) Headers[K] = Up.headers[K];
|
||||
Headers['Access-Control-Allow-Origin'] = '*';
|
||||
Res.writeHead(200, Headers);
|
||||
Up.pipe(Res);
|
||||
},
|
||||
);
|
||||
P.on('error', (E) => SendText(Res, 502, '回落到上游失败:' + E.message + '\n'));
|
||||
P.end();
|
||||
}
|
||||
|
||||
if (!fs.existsSync(DistDir)) {
|
||||
console.error('✗ 客户端产物目录不存在:' + DistDir);
|
||||
console.error(' 请先构建:cd client && ' + (process.platform === 'win32' ? 'gradlew.bat' : './gradlew') + ' jsProductionExecutableCompileSync');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(MirrorRoot)) {
|
||||
console.error('✗ 官方素材本地镜像目录不存在:' + MirrorRoot);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const Server = http.createServer((Req, Res) => {
|
||||
const Pathname = UrlPathOf(Req);
|
||||
|
||||
// CORS(/play/static/** 加宽;其余也给,方便本地调试)
|
||||
if (Req.method === 'OPTIONS') {
|
||||
Res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
});
|
||||
Res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) 官方素材:/play/static/** → 3DTank/web/play/static/**
|
||||
if (Pathname === '/play/static' || Pathname.startsWith('/play/static/')) {
|
||||
const Rel = Pathname.slice('/play/static/'.length);
|
||||
const CORS = { 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' };
|
||||
const Full = SafeJoin(MirrorRoot, Rel);
|
||||
if (Full === null) {
|
||||
Log('✗ 目录穿越被拒:' + Pathname);
|
||||
SendText(Res, 400, '非法路径(试图逃出映射根):' + Pathname + '\n', CORS);
|
||||
return;
|
||||
}
|
||||
if (Rel && fs.existsSync(Full) && fs.statSync(Full).isFile()) {
|
||||
Log('200 镜像 ' + Pathname + ' (' + MimeOf(Full) + ', ' + fs.statSync(Full).size + 'B)');
|
||||
SendFile(Res, Full, CORS);
|
||||
return;
|
||||
}
|
||||
if (Upstream) {
|
||||
Log('↩ 回落上游 ' + Upstream + Pathname + '(本地镜像缺 ' + Rel + ')');
|
||||
RelayUpstream(Req, Res, Pathname);
|
||||
return;
|
||||
}
|
||||
Log('404 镜像缺文件 ' + Pathname);
|
||||
SendText(Res, 404, '本地镜像里没有这个素材:' + Rel + '\n(默认不启用上游回落;可用 --upstream http://localhost:8080)\n', CORS);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 客户端产物:/ 与其它路径 → build/dist/js/productionExecutable/
|
||||
const Rel = Pathname === '/' ? 'index.html' : Pathname.slice(1);
|
||||
const Full = SafeJoin(DistDir, Rel);
|
||||
if (Full === null) {
|
||||
Log('✗ 目录穿越被拒:' + Pathname);
|
||||
SendText(Res, 400, '非法路径(试图逃出产物根):' + Pathname + '\n');
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(Full) && fs.statSync(Full).isFile()) {
|
||||
Log('200 产物 ' + Pathname + ' (' + MimeOf(Full) + ', ' + fs.statSync(Full).size + 'B)');
|
||||
SendFile(Res, Full);
|
||||
return;
|
||||
}
|
||||
Log('404 产物缺文件 ' + Pathname);
|
||||
SendText(Res, 404, '客户端产物里没有:' + Rel + '\n产物目录:' + DistDir + '\n');
|
||||
});
|
||||
|
||||
Server.listen(Port, () => {
|
||||
Log('素材中继已启动:http://localhost:' + Port + '/');
|
||||
Log(' 产物目录 ' + DistDir);
|
||||
Log(' 素材镜像 ' + MirrorRoot + '(只读,/play/static/**)');
|
||||
Log(' 上游回落 ' + (Upstream || '未启用(本地镜像优先;缺文件返回 404)'));
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
// ExportItemIcons.mjs —— 从官方解包源码导出「型号枚举 → 官方图标文件」表(客户端可用)
|
||||
//
|
||||
// 产物(幂等可重复运行):
|
||||
// 1. client/src/jsMain/resources/ItemIcons.json 给人看 / 给其它语言用的 JSON 表
|
||||
// 2. client/src/jsMain/kotlin/Protocol/ItemIconsData.kt 内嵌同一份 JSON 的 Kotlin 常量
|
||||
// (Kotlin/JS 没有跨 browser/node 一致的资源读取 API,口径与 ExportProtocolTables.mjs 相同)
|
||||
//
|
||||
// 数据来源(只读,绝不改动):
|
||||
// 3dtank.com/play/static/js/unpacked/resources.json webpack 模块 id → static/images/xxx.svg
|
||||
// 3dtank.com/play/static/js/unpacked/module_36659.js 底盘:Mb(eA).upf = switch (t.k3_1) { case → n = <形参>; }
|
||||
// 3dtank.com/play/static/js/unpacked/module_57128.js 炮塔:ct(Dn).duk = switch (t.k3_1) { … }
|
||||
// 抗性:ct(Dn).euk = switch (t.k3_1) { case → return <形参>; }
|
||||
// 3dtank.com/play/static/js/unpacked/module_82347.js 7 格道具图标表(ZMi 数组)
|
||||
// 3DTank/web/play/static/translations/zh-CN.*.json lobby.garage.* 类别中文(显示层)
|
||||
//
|
||||
// 口径(为什么不能「case 号 = 实参位置」):
|
||||
// 工厂形参只有 714 个,而 switch 里引用的实参是**形参名**;形参与实参一一对应:
|
||||
// 形参#k 在参数列表第 k 行,实参#k 在调用实参列表第 k 行(两者行号差是文件的固定常量)。
|
||||
// 所以必须走:case → 形参名 → 形参声明行 → 同序实参行 → i(<模块id>) → resources.json → 文件名。
|
||||
// 自验:用 Dn.euk(抗性 20 项)全套跑一遍,全部命中且都落在本地镜像里(见 stdout 的 SelfCheck)。
|
||||
//
|
||||
// 用法:node client/tools/ExportItemIcons.mjs
|
||||
'use strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
|
||||
const Here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const ClientDir = path.resolve(Here, '..');
|
||||
const RepoDir = path.resolve(ClientDir, '..');
|
||||
const UnpackedDir = path.join(RepoDir, '3dtank.com', 'play', 'static', 'js', 'unpacked');
|
||||
// 图标路径形如 "static/images/xxx.svg",相对 web/play 根(不是 web/play/static)
|
||||
const WebPlayDir = path.join(RepoDir, '3DTank', 'web', 'play');
|
||||
const WebStaticDir = path.join(WebPlayDir, 'static');
|
||||
const OutJson = path.join(ClientDir, 'src', 'jsMain', 'resources', 'ItemIcons.json');
|
||||
const OutKotlin = path.join(ClientDir, 'src', 'jsMain', 'kotlin', 'Protocol', 'ItemIconsData.kt');
|
||||
|
||||
const Resources = JSON.parse(fs.readFileSync(path.join(UnpackedDir, 'resources.json'), 'utf8')).resourceMap;
|
||||
|
||||
// ---------------------------------------------------------------- 通用解析:形参表 ↔ 实参表
|
||||
|
||||
function ReadLines(File) {
|
||||
return fs.readFileSync(File, 'utf8').split('\n');
|
||||
}
|
||||
|
||||
// 形参声明行形如 " Ns,";实参行形如 " i(5012)," 或 " t.exports,"。
|
||||
function TrimComma(Text) {
|
||||
let S = Text.trim();
|
||||
if (S.endsWith(',')) S = S.slice(0, -1);
|
||||
return S;
|
||||
}
|
||||
|
||||
function ModuleIdOfArg(Line) {
|
||||
const M = /i\(([0-9]+)\)/.exec(Line);
|
||||
return M ? M[1] : null;
|
||||
}
|
||||
|
||||
// 为单个文件建「形参名 → 模块 id」:参数列表从 `function (` 行之后开始,实参列表从 `})(` 行之后开始。
|
||||
// 两者同序、同长(取较短者为长度)。返回 null 表示该文件不是这种工厂结构。
|
||||
function BuildParamMap(Lines) {
|
||||
let FnAt = -1;
|
||||
for (let I = 0; I < Lines.length; I++) if (Lines[I].trim() === '!(function (') { FnAt = I; break; }
|
||||
if (FnAt < 0) return null;
|
||||
let ParamEnd = -1;
|
||||
for (let I = FnAt + 1; I < Lines.length; I++) if (Lines[I] === ' ) {') { ParamEnd = I; break; }
|
||||
if (ParamEnd < 0) return null;
|
||||
let InvAt = -1;
|
||||
for (let I = ParamEnd + 1; I < Lines.length; I++) if (Lines[I] === ' })(') { InvAt = I; break; }
|
||||
if (InvAt < 0) return null;
|
||||
let ArgEnd = -1;
|
||||
for (let I = InvAt + 1; I < Lines.length; I++) if (Lines[I] === ' );' || Lines[I] === ' )') { ArgEnd = I; break; }
|
||||
if (ArgEnd < 0) return null;
|
||||
const Count = Math.min(ParamEnd - FnAt - 1, ArgEnd - InvAt - 1);
|
||||
const Map = {};
|
||||
for (let K = 0; K < Count; K++) {
|
||||
const Name = TrimComma(Lines[FnAt + 1 + K]);
|
||||
const Id = ModuleIdOfArg(Lines[InvAt + 1 + K]);
|
||||
if (Name && Id) Map[Name] = Id;
|
||||
}
|
||||
return Map;
|
||||
}
|
||||
|
||||
// 解一个 switch:支持两种体——
|
||||
// ① 赋值式(底盘/炮塔):case N: / n = <形参>; / break;
|
||||
// ② 返回式(抗性):case N: return <形参>; 以及 fall-through(case 40: case 44: return M;)
|
||||
// 返回 { 序号: 形参名 }。遇到 default / return n; / 收尾 } 即停。
|
||||
function ParseSwitch(Lines, SwitchAt) {
|
||||
const Out = {};
|
||||
let Pending = [];
|
||||
for (let I = SwitchAt + 1; I < Lines.length; I++) {
|
||||
const L = Lines[I].trim();
|
||||
if (L.startsWith('default:') || L === '}' || /^return [A-Za-z0-9_$]+;$/.test(L)) break;
|
||||
const M = /^case ([0-9]+):(.*)$/.exec(L);
|
||||
if (!M) continue;
|
||||
const C = Number(M[1]);
|
||||
const Rest = M[2].trim();
|
||||
let Name = null;
|
||||
if (Rest.startsWith('return ')) Name = Rest.slice(7).replace(/;.*/, '');
|
||||
else if (Rest.startsWith('n = ')) Name = Rest.slice(4).replace(/;.*/, '');
|
||||
else if (Rest === '') {
|
||||
const Nx = Lines[I + 1] ? Lines[I + 1].trim() : '';
|
||||
if (Nx.startsWith('return ')) { Name = Nx.slice(7).replace(/;.*/, ''); I++; }
|
||||
else if (Nx.startsWith('n = ')) { Name = Nx.slice(4).replace(/;.*/, ''); I++; }
|
||||
else { Pending.push(C); continue; } // fall-through(本 case 与下一个 case 共用体)
|
||||
}
|
||||
if (Name != null) {
|
||||
for (const P of Pending) Out[P] = Name;
|
||||
Pending = [];
|
||||
Out[C] = Name;
|
||||
}
|
||||
}
|
||||
return Out;
|
||||
}
|
||||
|
||||
// 找函数体内第一个 switch (...) 行号;找不到返回 null
|
||||
function FindSwitch(Lines, FromLine1Based) {
|
||||
for (let I = FromLine1Based - 1; I < Lines.length; I++) if (/switch \([A-Za-z0-9_$]+\.k3_1\)/.test(Lines[I])) return I;
|
||||
return null;
|
||||
}
|
||||
|
||||
// case 表 → { 序号: 图标文件 }(形参 → 模块 id → resources.json)
|
||||
function ResolveCaseTable(Cases, ParamMap) {
|
||||
const Out = {};
|
||||
const Misses = [];
|
||||
for (const C of Object.keys(Cases).sort((A, B) => Number(A) - Number(B))) {
|
||||
const Param = Cases[C];
|
||||
const Id = ParamMap[Param];
|
||||
const File = Id ? Resources[Id] : null;
|
||||
const Rel = File && File.startsWith('/') ? File.slice(1) : File;
|
||||
if (!Rel) Misses.push({ Case: Number(C), Param, ModuleId: Id || null });
|
||||
else Out[C] = Rel; // 统一成 "static/images/xxx.svg"(不带前导 /)
|
||||
}
|
||||
return { Table: Out, Misses };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 底盘 / 炮塔 / 抗性
|
||||
|
||||
const HullLines = ReadLines(path.join(UnpackedDir, 'module_36659.js'));
|
||||
const TurretLines = ReadLines(path.join(UnpackedDir, 'module_57128.js'));
|
||||
const HullParams = BuildParamMap(HullLines);
|
||||
const TurretParams = BuildParamMap(TurretLines);
|
||||
if (!HullParams || !TurretParams) {
|
||||
console.error('✗ 解析形参/实参表失败(工厂结构变了?)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const HullSwitchAt = FindSwitch(HullLines, 5133);
|
||||
const HullRaw = ParseSwitch(HullLines, HullSwitchAt);
|
||||
const Hull = ResolveCaseTable(HullRaw, HullParams);
|
||||
const HullMissLines = Hull.Misses.map((M) => 'case ' + M.Case + ' -> 形参 ' + M.Param + '(模块 id ' + (M.ModuleId || '无') + ')');
|
||||
|
||||
const TurretSwitchAt = FindSwitch(TurretLines, 838);
|
||||
const TurretRaw = ParseSwitch(TurretLines, TurretSwitchAt);
|
||||
const Turret = ResolveCaseTable(TurretRaw, TurretParams);
|
||||
|
||||
// 抗性:ct(Dn).euk(约 906 行)—— 自验用
|
||||
const ResistSwitchAt = FindSwitch(TurretLines, 906);
|
||||
const ResistRaw = ParseSwitch(TurretLines, ResistSwitchAt);
|
||||
const Resist = ResolveCaseTable(ResistRaw, TurretParams);
|
||||
|
||||
// 自验:抗性每一项都要(a)解析出图标文件、(b)文件在本地镜像里真的存在。
|
||||
function LocalExists(Rel) {
|
||||
return fs.existsSync(path.join(WebPlayDir, Rel));
|
||||
}
|
||||
const ResistLines = [];
|
||||
let ResistLocal = 0;
|
||||
for (const C of Object.keys(Resist.Table)) {
|
||||
const Rel = Resist.Table[C];
|
||||
const Ok = LocalExists(Rel);
|
||||
if (Ok) ResistLocal++;
|
||||
ResistLines.push('case ' + C + ' -> ' + Resist.Table[C] + (Ok ? ' ✓本地存在' : ' ✗本地缺失'));
|
||||
}
|
||||
const ResistDistinct = new Set(Object.values(Resist.Table)).size;
|
||||
|
||||
// ---------------------------------------------------------------- 7 格道具图标表(module_82347)
|
||||
|
||||
const SupplyLines = ReadLines(path.join(UnpackedDir, 'module_82347.js'));
|
||||
const SupplyParams = BuildParamMap(SupplyLines);
|
||||
const SupplyEntries = [];
|
||||
if (SupplyParams) {
|
||||
// 表体:7 个 new ZMi(<类型函数>, <图标资源形参>, <缩放函数>)
|
||||
for (let I = 0; I < SupplyLines.length; I++) {
|
||||
const M = /new ZMi\(([^,]+),\s*([A-Za-z0-9_$]+),\s*([^)]+)\)/.exec(SupplyLines[I]);
|
||||
if (!M) continue;
|
||||
const TypeFn = M[1].trim();
|
||||
const IconParam = M[2].trim();
|
||||
const Id = SupplyParams[IconParam];
|
||||
const File = Id ? Resources[Id] : null;
|
||||
const Rel = File || null;
|
||||
SupplyEntries.push({
|
||||
Key: TypeFn, // 产出「道具类型枚举」的函数名(源码里的局部名,语义未解,照抄不猜)
|
||||
Icon: Rel,
|
||||
ModuleId: Id || null,
|
||||
Line: I + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 其它同类表(自动扫描,如实报告)
|
||||
|
||||
// 扫描所有解包模块:对每个 `switch (X.k3_1)`,若其 case 体解析出的形参**绝大多数**都落在
|
||||
// resources.json 的图标资源上(≥ 4 个 case 且 ≥ 80% 命中),即认定是一张「case → 图标」表。
|
||||
function ScanOtherTables() {
|
||||
const Found = [];
|
||||
// 已单独导出的三张 switch 表不再重复计入「其它表」
|
||||
const Known = new Set([
|
||||
'module_36659.js:' + (HullSwitchAt + 1),
|
||||
'module_57128.js:' + (TurretSwitchAt + 1),
|
||||
'module_57128.js:' + (ResistSwitchAt + 1),
|
||||
]);
|
||||
const Files = fs.readdirSync(UnpackedDir).filter((F) => /^module_\d+\.js$/.test(F));
|
||||
for (const F of Files) {
|
||||
let Lines;
|
||||
try {
|
||||
Lines = ReadLines(path.join(UnpackedDir, F));
|
||||
} catch (E) {
|
||||
continue;
|
||||
}
|
||||
const Params = BuildParamMap(Lines);
|
||||
if (!Params) continue;
|
||||
for (let I = 0; I < Lines.length; I++) {
|
||||
if (!/switch \([A-Za-z0-9_$]+\.k3_1\)/.test(Lines[I])) continue;
|
||||
if (Known.has(F + ':' + (I + 1))) continue;
|
||||
const Cases = ParseSwitch(Lines, I);
|
||||
const Nums = Object.keys(Cases);
|
||||
if (Nums.length < 4) continue;
|
||||
const R = ResolveCaseTable(Cases, Params);
|
||||
const Hit = Object.keys(R.Table).length;
|
||||
if (Hit / Nums.length >= 0.8 && Hit >= 4) {
|
||||
Found.push({
|
||||
Module: F,
|
||||
Line: I + 1,
|
||||
Cases: Nums.length,
|
||||
Icons: Object.keys(R.Table).length,
|
||||
Sample: Object.keys(R.Table).sort((A, B) => Number(A) - Number(B)).slice(0, 4).map((C) => C + '=' + R.Table[C]),
|
||||
All: R.Table,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return Found;
|
||||
}
|
||||
const OtherTables = ScanOtherTables();
|
||||
|
||||
// ---------------------------------------------------------------- 类别中文(lobby.garage.*)
|
||||
|
||||
const ZhFiles = fs.readdirSync(path.join(WebStaticDir, 'translations')).filter((F) => /^zh-CN\..*\.json$/.test(F));
|
||||
const Zh = JSON.parse(fs.readFileSync(path.join(WebStaticDir, 'translations', ZhFiles[0]), 'utf8'));
|
||||
const GarageLabels = {};
|
||||
for (const [K, V] of Object.entries((Zh.lobby && Zh.lobby.garage) || {})) {
|
||||
if (typeof V === 'string') GarageLabels[K] = V;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 组装产物
|
||||
|
||||
const Selection = {
|
||||
HullCases: Object.keys(Hull.Table).length,
|
||||
HullMisses: Hull.Misses.length,
|
||||
TurretCases: Object.keys(Turret.Table).length,
|
||||
TurretMisses: Turret.Misses.length,
|
||||
ResistanceCases: Object.keys(Resist.Table).length,
|
||||
ResistanceMisses: Resist.Misses.length,
|
||||
SupplyCount: SupplyEntries.length,
|
||||
OtherTableCount: OtherTables.length,
|
||||
GarageLabelCount: Object.keys(GarageLabels).length,
|
||||
};
|
||||
|
||||
const Tables = {
|
||||
Note:
|
||||
'物品图标表,由 client/tools/ExportItemIcons.mjs 从官方解包源码生成,请勿手改。' +
|
||||
'Hull = module_36659.js 的 Mb(eA).upf switch:case(底盘型号)→ i(<模块id>) → resources.json 文件名;' +
|
||||
'Turret = module_57128.js 的 ct(Dn).duk switch(炮塔型号);Resistance = 同文件 ct(Dn).euk switch(抗性,' +
|
||||
'用于自验解析算法)。解析口径:case → 形参名 → 形参声明行 → 同序实参行 → i(<模块id>),' +
|
||||
'不能假设「case 号 = 实参位置」。Supply = module_82347.js 的 7 格道具图标表(new ZMi 数组,非 switch)。' +
|
||||
'OtherTables = 自动扫描到的其它同类 case→图标 表(供手榴弹/无人机/防御模块查用;没有就是空数组)。' +
|
||||
'GarageLabels = zh-CN 的 lobby.garage.*(只取字符串叶子),显示层类别中文用。' +
|
||||
'图标路径统一为 "static/images/xxx.svg"(相对 web/play 根,客户端配 AssetRelay 的 /play 前缀取用)。',
|
||||
GeneratedAt: new Date().toISOString(),
|
||||
Source:
|
||||
'3dtank.com/play/static/js/unpacked/{resources.json,module_36659.js,module_57128.js,module_82347.js} + ' +
|
||||
'3DTank/web/play/static/translations/' + ZhFiles[0],
|
||||
Selection,
|
||||
SelfCheck: {
|
||||
ResistanceCases: Object.keys(Resist.Table).length,
|
||||
ResistanceResolved: Object.keys(Resist.Table).length,
|
||||
ResistanceLocalFilesPresent: ResistLocal,
|
||||
ResistanceDistinctFiles: ResistDistinct,
|
||||
Lines: ResistLines,
|
||||
},
|
||||
Hull: Hull.Table,
|
||||
Turret: Turret.Table,
|
||||
Resistance: Resist.Table,
|
||||
Supply: SupplyEntries,
|
||||
OtherTables,
|
||||
GarageLabels,
|
||||
};
|
||||
|
||||
const Json = JSON.stringify(Tables);
|
||||
fs.mkdirSync(path.dirname(OutJson), { recursive: true });
|
||||
fs.writeFileSync(OutJson, Json + '\n', 'utf8');
|
||||
|
||||
function EscapeKotlin(S) {
|
||||
let Out = '';
|
||||
for (const Ch of S) {
|
||||
const Code = Ch.codePointAt(0);
|
||||
if (Ch === '\\') Out += '\\\\';
|
||||
else if (Ch === '"') Out += '\\"';
|
||||
else if (Ch === '$') Out += '\\$';
|
||||
else if (Code < 0x20) Out += '\\u' + Code.toString(16).padStart(4, '0');
|
||||
else Out += Ch;
|
||||
}
|
||||
return Out;
|
||||
}
|
||||
|
||||
const Kotlin =
|
||||
'// 本文件由 client/tools/ExportItemIcons.mjs 生成,请勿手改。\n' +
|
||||
'// 内容 = client/src/jsMain/resources/ItemIcons.json(同一份 JSON)。\n' +
|
||||
'package Protocol\n' +
|
||||
'\n' +
|
||||
'internal const val ItemIconsJsonText: String =\n "' + EscapeKotlin(Json) + '"\n';
|
||||
fs.mkdirSync(path.dirname(OutKotlin), { recursive: true });
|
||||
fs.writeFileSync(OutKotlin, Kotlin, 'utf8');
|
||||
|
||||
// ---------------------------------------------------------------- 自验输出
|
||||
|
||||
console.log('已生成物品图标表:');
|
||||
console.log(' ' + OutJson + ' ' + Json.length + ' 字符');
|
||||
console.log(' ' + OutKotlin + ' ' + Kotlin.length + ' 字符');
|
||||
console.log(' module_36659.js 形参数 ' + Object.keys(HullParams).length + ' / module_57128.js 形参数 ' + Object.keys(TurretParams).length);
|
||||
console.log('');
|
||||
console.log('【自验】抗性表 Dn.euk(module_57128.js:' + (ResistSwitchAt + 1) + '):' + Object.keys(Resist.Table).length + ' 个 case → 解析出 '
|
||||
+ Object.keys(Resist.Table).length + ' 个图标,本地镜像里存在 ' + ResistLocal + ' 个,去重后 ' + ResistDistinct + ' 个文件');
|
||||
for (const L of ResistLines) console.log(' ' + L);
|
||||
console.log(' 自验结论:' + (ResistLocal === Object.keys(Resist.Table).length ? '✓ ' + ResistLocal + '/' + Object.keys(Resist.Table).length + ' 全命中且本地存在(去重 ' + ResistDistinct + ' 个文件),case→形参→实参 的解析算法成立' : '✗ 有未命中'));
|
||||
console.log('');
|
||||
console.log('【导出】底盘 upf(module_36659.js:' + (HullSwitchAt + 1) + '):' + Selection.HullCases + ' case → ' + Selection.HullCases + ' 图标(未解 ' + Selection.HullMisses + ')');
|
||||
for (const L of HullMissLines) console.log(' 未解:' + L);
|
||||
console.log('【导出】炮塔 duk(module_57128.js:' + (TurretSwitchAt + 1) + '):' + Selection.TurretCases + ' case → ' + Selection.TurretCases + ' 图标(未解 ' + Selection.TurretMisses + ')');
|
||||
console.log('【导出】7 格道具图标表(module_82347.js):' + SupplyEntries.length + ' 项 → ' + SupplyEntries.map((S) => S.Icon).join(', '));
|
||||
console.log('');
|
||||
console.log('【自动扫描】其它 case→图标 表:' + OtherTables.length + ' 张');
|
||||
for (const T of OtherTables) {
|
||||
console.log(' ' + T.Module + ':' + T.Line + ' case ' + T.Cases + ' → 图标 ' + T.Icons + ' 样例 ' + T.Sample.join(', '));
|
||||
}
|
||||
if (!OtherTables.length) console.log(' (无:手榴弹 / 无人机 / 防御模块未找到同类 case→图标 表,如实标注「无此表」)');
|
||||
console.log('');
|
||||
console.log('【显示层】lobby.garage.* 中文标签 ' + Object.keys(GarageLabels).length + ' 条');
|
||||
Reference in new issue
Block a user