Compare commits

...
168 Commits
Author SHA1 Message Date
TermiNexus 6c1e8efb49 feat(ui): 接入官方资源镜像与原版登录页,搭建大厅骨架与 UI 计划书 2026-10-25 19:02:13 +08:00
TermiNexus 1d1479592f fix(session): wire 模型经适配器转为状态模型,跳过同名类型冲突 2026-10-25 13:42:00 +08:00
TermiNexus ee7504000f feat(session): 解析 Pt 容器为物品目录,未覆盖项从 30029 降到 2203 2026-10-25 13:15:06 +08:00
TermiNexus f29ad2caaa feat(tools): 生成跨模块别名表并提供导入查询 CLI 2026-10-25 12:22:59 +08:00
TermiNexus b8fb22f401 feat(session): 静态解析 modelId 注册表,补齐大厅容器模型名 2026-10-25 00:16:33 +08:00
TermiNexus 87539a9645 feat(session): 生成下行动作映射表并接入会话路由 2026-10-24 22:58:04 +08:00
TermiNexus 780373180d feat(session): S3 连接层起步,上行编码 golden + 会话解码自检 2026-10-24 22:43:35 +08:00
TermiNexus b04f0f390d feat(state): 迁移 battleCreateState,状态层 89/89 全部完成 2026-10-24 22:24:08 +08:00
TermiNexus 8b124388ec feat(state): 迁移 matchmakingGroup 2026-10-24 22:18:57 +08:00
TermiNexus 6f68afa678 feat(state): 迁移 itemNotifications(复用 ItemNotificationData) 2026-10-24 22:14:50 +08:00
TermiNexus bb544643b5 feat(state): 迁移 chatState(复用 ChatMessage 模型) 2026-10-24 22:09:49 +08:00
TermiNexus 2f38609a98 feat(state): 迁移 clan(36 字段 / 43 动作) 2026-10-24 22:05:19 +08:00
TermiNexus ad5686241d feat(state): 迁移 clansStorage(复用 ClanInfo 模型) 2026-10-24 21:58:43 +08:00
TermiNexus 25f8cd431e feat(state): 迁移 challenges(复用 Challenge 模型) 2026-10-24 21:26:43 +08:00
TermiNexus 7d1c808af9 feat(state): 迁移 battleUsers(复用 RemoteUserData/UserStat/TankInfoCC) 2026-10-24 21:21:36 +08:00
TermiNexus cd597b4693 feat(state): 迁移 battlesSelect(复用 BattleParams/BattleInfoUser) 2026-10-24 21:03:47 +08:00
TermiNexus 71aa9a5256 feat(state): 迁移 matchmaking 2026-10-24 20:42:01 +08:00
TermiNexus 9e51b695aa feat(state): 迁移 remoteUserDataStorage(复用 RemoteUserData 协议模型) 2026-10-24 20:23:50 +08:00
TermiNexus 5327312b13 feat(state): 迁移 battleChat(复用 ChatMessage 协议模型) 2026-10-24 20:08:24 +08:00
TermiNexus 8dac8d96e8 feat(state): 迁移 maps 并补地图描述协议模型 2026-10-24 19:56:41 +08:00
TermiNexus 9cf760b354 feat(state): 迁移 battleMessage 并补战斗消息协议模型 2026-10-24 19:46:20 +08:00
TermiNexus 0cd2a23c9e feat(state): 迁移 tankBuilderState 2026-10-24 17:42:50 +08:00
TermiNexus 6a1c8b4306 feat(state): 迁移 contractState 2026-10-24 17:30:56 +08:00
TermiNexus 0d1e2d00c5 feat(state): 迁移 graphicsSettings/controlSettings/battleSettings 2026-10-24 17:24:01 +08:00
TermiNexus cade4020a5 feat(state): 迁移 soundSettings/gameSettings/keyboardSettings/touchscreenHudSettings 2026-10-24 17:13:24 +08:00
TermiNexus 86979eb168 feat(state): 迁移 battleStatistics 2026-10-24 16:55:21 +08:00
TermiNexus 4aec83f684 feat(state): 迁移 clanChatState/battleSearchState 2026-10-24 16:45:20 +08:00
TermiNexus 38ec7fa28e feat(state): 迁移 battlePauseState/battleViewState/tankActionsLogState/suppliesTutorial 2026-10-24 16:33:38 +08:00
TermiNexus 0183f191d3 feat(state): 迁移 friends/suppliesState/clanUserInfo/battleInviteState/partner/userRenameState 2026-10-24 16:00:39 +08:00
TermiNexus af005ab019 feat(state): 迁移 system/contextTutorial/battleTutorial/spectator/twoStepVerificationState/antiAddiction/moderation/migrationState/register/userReportsState 2026-10-24 15:32:47 +08:00
TermiNexus 8633fc91c1 feat(state): 迁移 tipsState/rankUpDialogState/battleResult/captchaState/modalScreen/droppableGold/eventGoods/creatorTagState/rewardedVideosState/passwordChangeState 2026-10-24 14:43:51 +08:00
TermiNexus 6d2487d9ea feat(state): 迁移 icons/questCategories/mainQuest/securitySettings/referralEvent 2026-10-24 14:12:19 +08:00
TermiNexus 49e8c1efc4 feat(state): 迁移 lobbyLoader/purchaseDialogState/promoCode/supportUrl/rulesUpdated/notificationByCountry/availabilityByRank 2026-10-24 13:29:41 +08:00
TermiNexus df179dea51 fix(state): quests 的 SetNotCompletedQuests 实际是 no-op(动作字段与 cd 同为 undefined) 2026-10-24 13:14:28 +08:00
TermiNexus 81668b0a2e feat(state): 迁移 news/notifications/quests 切片 2026-10-24 13:05:13 +08:00
TermiNexus 006e6a7ebd feat(state): 批量迁移 accountSettings/proBattle/language/announcement 切片 2026-10-24 12:19:13 +08:00
TermiNexus 6947fcb567 feat(state): 迁移 settings 切片(2 字段 / 2 臂) 2026-10-23 17:20:56 +08:00
TermiNexus 9b3e76d42d feat(state): 迁移 user 切片(32 字段 / 28 臂) 2026-10-23 16:36:23 +08:00
TermiNexus 77a90fdfff feat(state): 迁移 entrance 切片(8 字段 / 7 臂) 2026-10-23 15:47:18 +08:00
TermiNexus 435cd3191e feat(state): 收口 launchParams 切片为强类型透传(11/89) 2026-10-23 15:29:05 +08:00
TermiNexus 1194c395b4 feat(state): 迁移 garagePreview 切片(22 字段 / 25 分支) 2026-10-23 15:12:10 +08:00
TermiNexus 3c0ed0a998 feat(state): 迁移 garage 切片(31 字段 / 60 分支) 2026-10-23 13:58:47 +08:00
TermiNexus f66f431670 feat(state): 迁移补给箱切片(18 个动作 + ContainerType) 2026-10-23 12:25:43 +08:00
TermiNexus 974a9f49ad feat(state): 迁移屏幕导航切片(13 个动作 + ScreenId) 2026-10-23 03:28:57 +08:00
TermiNexus b7afe07894 test(state): 用真实派发样例回放商店切片 2026-10-23 02:38:36 +08:00
TermiNexus 94374d2f36 feat(state): 全量迁移商店切片(69 个动作 + U7/w3 子状态) 2026-10-23 02:13:31 +08:00
TermiNexus 86d79eddad feat(state): 迁移 home 与 localBattleUserState 两个内联切片 2026-10-23 01:24:21 +08:00
TermiNexus f373c2053e feat(state): 按效果为无 toString 的 o1 定名 ShowShopDiscountNotification 2026-10-23 01:19:35 +08:00
TermiNexus d5098cc782 feat(state): 4 个内联 IIFE 切片按字段具名并补动作名 2026-10-23 00:56:50 +08:00
TermiNexus fdc516b7f3 feat(state): 迁密码登录与 Twitch 切片,拼出 89 槽位根 reducer 2026-10-23 00:38:10 +08:00
TermiNexus c280f2a2a1 feat(state): 迁移登录邀请切片 reducer(InviteState)
1) 动作在 Kotlin 侧不再要求公共基类:原包动作也没有基类,Store/Gateway 改用 Any,817 个生成类无需实现接口。

2) 新增 App/State/Reducers/InviteReducer.kt,逐条对照 module_10032:状态 aD=InviteState(isInviteNeed, invite, status, existsUid, inviteAccepted)、枚举 lD=InviteStatus(UNKNOWN/FREE/NOT_FOUND/ALREADY_ACTIVATED)、reducer pD.er5 的三条分支。默认值取自构造函数(isInviteNeed 默认 true、invite 默认空串、status 默认 UNKNOWN、inviteAccepted 默认 false)。

3) 自检 19 增到 26 项:默认值、三条动作分支(含 FREE 推导 inviteAccepted、ApplyInvite 重置 status/existsUid)、无关动作不动、接 Store 派发后归约。

结果:TANKIOFFLINE_STATE_TEST=1 全通过。
2026-10-23 00:05:58 +08:00
TermiNexus b7dea52f03 feat(state): 用 138 条真实 dispatch 样例回放校验动作目录
1) analyze_action_trace 保留每个动作的第一行原始 toString(Samples),并新增两条类型规则:见过 [object Object] 记为 Opaque(运行时说不出类型,但不能再让调用点猜);字段只要出现过 null 就补可空后缀(Long?/List<String>?)。

2) gen_action_kt 产出 ActionSamples.kt;可选类的判定改用「样例那一行的字段集」,修掉 Configure 选错行(1 字段的 config 版 vs 12 字段的真实版)。

3) 自检加两组:回放对照(138 个动作 × 真实字段值,缺类/缺字段/类型不符三项必须为 0)+ 全目录 817 个动作类注册并验证只路由命中项。

结果:19 项全通过;缺类 0 / 缺字段 0 / 类型不符 0。过程中修出 8 处错型:6 个 Opaque 字段曾被误判成 Long,4 个字段缺少可空性。
2026-10-22 14:36:33 +08:00
TermiNexus 74bf01c0f8 feat(state): 生成动作目录 ActionCatalog,把 817 个类与实际 dispatch 名对上
1) gen_action_kt 现在额外产出 App/State/ActionCatalog.kt:817 条(816 个 module_10032 数据类 + module_10849 的 InitState),每条带类引用与字段类型。

2) State 模块补 InitState(type, action):原 module_10849 的 ct,toString 证据为 InitState(type=…, action=…)。

3) 自检从 10 项扩到 15 项:目录非空、名字唯一、每个条目能拿到 KClass、运行时打点过的 135 个动作名 0 缺口、ByName 与 All 对齐。

结果:TANKIOFFLINE_STATE_TEST=1 全通过(目录 817 条;打点动作名 135 个;缺口 0)。
2026-10-22 12:52:18 +08:00
TermiNexus a64530761b feat(state): 移植 Redux 内核(Store/中间件/网关)并加 10 项自检
对照 module_10849 的 toString 证据实现:vt=ReduxStore(归约+监听器+中间件链)、yt=ReduxToModelGateway(按动作类先通知、再交给 Store;模型/键/游戏对象三级订阅,重复订阅 fail-fast)。

自检 TANKIOFFLINE_STATE_TEST=1:归约累加、通知次数与新状态可见性、中间件由外向内顺序、动作监听器先于归约、模型订阅/退订、重复订阅抛错、派发经过调度器,共 10 项全通过。

state 模块补 js(IR){browser();nodejs()} 使 :app 可依赖;新增 tools/RunStateTest.ps1。
2026-10-22 12:47:51 +08:00
TermiNexus 7d8ceedee2 fix(state): 布尔判定只在顶层生效,清掉 223 个假 Boolean
1) 旧规则只要表达式里出现 ===/>= 之类就判 Boolean,导致 (i = i === cd ? null : i) 这类『赋值+三元』被当成布尔,realName/battleId/mapName 都被误判成 Boolean。新增 Booleanish:去外层括号、剥离顶层赋值取右值、遇三元直接放弃、只认顶层比较/取反/equals。

2) 运行时类型若为 Any?/List<Any?>/Map<String, Any?>(本身没信息,例如值全是空列表),不再挡住调用点推断。

3) analyze_action_trace 支持 Kotlin 默认 toString 形态 Name [a = 1 b = X],从抽样里多解析出 40 个模型类(TankResistance/Range/TemporaryItemCC/UserPropertiesCC…),并让空容器的『元素未知』不参与冲突判定(List<Any?> + List<Long> → List<Long>)。

4) 新增 --why <类.字段> 排查错型:打印每个调用点的实参与推断结果。

结果:已定类型 707 → 493(真实原因是那 223 个是错型);嵌套对象类 3 → 43(235 个字段已定类型);与运行时真值冲突 6 → 2。
2026-10-21 22:10:09 +08:00
TermiNexus c957f742b1 feat(state): 按运行时 toString 样例生成嵌套对象类
1) analyze_action_trace 现在会递归解析动作 toString 里的对象值(Name(a=1, b=Xxx(...))),输出 Models 表:12 个对象类、92 个字段,字段名与类型都来自真实 dispatch 样例。

2) gen_action_kt 据此处理三类情况:①已有同名数据类且字段覆盖运行时字段 → 直接复用(并把它选为该名字的代表行,修复 Challenge 误选 1 字段版的问题);②同名但字段对不上 → 放弃;③无同名类 → 现生成 data class(Coloring / EmissionColors / TextureFrameAnimation)。

3) 动作字段遇到已确认的对象类时按类名定型,不再退化成 Any?;Kotlin 关键字做属性名时加反引号。
2026-10-21 22:05:51 +08:00
TermiNexus 5e93ba6f47 feat(state): 数据流覆盖匿名函数/IIFE/原型方法,剔除 copy 转发噪声
1) 作用域索引从「具名 function」扩到三类:具名函数、赋值到原型的方法(ld(Cls).m = function)、IIFE((function(t){...})(a,b));形参解析按由内向外逐层尝试。

2) 关键修正:Kotlin 给 data class 生成的 copy/with(原型方法里 return new Cls(同名形参…))是循环证据,不能当类型依据。剔除后覆盖率从虚高的 1185(55.7%) 回落到 689(32.4%),被剔除的 497 个字段标注为「仅 copy 转发(无类型信息)」。

3) 新增 --stats:输出仍认不出的实参形态分布,便于决定下一条规则。

4) 与 158 个运行时真值对比仍无新增冲突(保持 3 处真实分歧:AddBattle.suspicionLevel / UpdateEventPass.eventPassEndTime / SetDrone.resource)。
2026-10-21 21:42:17 +08:00
TermiNexus b77e11ad30 feat(state): 静态数据流补类型,调用点按「动作名|局部类名」分桶
1) gen_action_kt 加纯静态局部数据流:实参是变量时,按「形参 → 调用点」和「函数体内最近一次赋值」回溯,深度 2、多来源冲突即放弃;新增 --audit 用运行时真值反向校验推断规则。

2) 调用点改为按「动作名|局部类名」索引。此前按名字索引,同名不同类(Init 有 1 字段版与 18 字段版)互相串味,导致推断出与运行时真值冲突 13 处;分桶后冲突降到 6 处(其中 3 处是运行时未定类型)。

3) analyze_action_trace 修 [object Object] 被当成 List<String> 的误判;客户端每次上报的是本页累计缓冲,页面刷新会覆盖成小文件,改为并入 action_trace_merged.json,只增不减。

结果:已定类型字段 361 → 722(runtime 158 / callsite 196 / dataflow 368),无调用点字段 12,未收录模型类型 37,compileKotlinJs 通过。
2026-10-21 20:33:53 +08:00
TermiNexus a0466b6544 feat(state): 调用点扫描补上 module_10032 内部构造
此前只扫跨模块的「别名 = dep.\\$.导出键」再 new,10032 内部动作互相 new 的 666 处全漏(215 个类里 212 个因此被判成无调用点)。改为先按局部类名直扫 10032,再扫其它模块。

结果:有调用点的类 639 → 861/864,调用点 1515 → 2181,已定类型字段 306 → 361(runtime 162 / callsite 199),无调用点字段 983 → 12,compileKotlinJs 通过。
2026-10-21 20:23:58 +08:00
TermiNexus 37be0e2418 chore(trace): 在 ReduxToModelGateway.dispatch 打点动作 toString
yt.uar 是全包唯一的真实 dispatch 入口;动作类 toString 形如 SetFragsCount(fragsCount=3),据此反推动作名/字段名/字段值类型(action_trace.json)。打点只在镜像副本里,落地走 /__dump__/action_trace.json。
2026-10-21 20:20:08 +08:00
TermiNexus 85f180f331 feat(state): 生成 10032 动作/状态层 Inventory.kt(运行时类型优先)
- 新增 extract_action_inventory / infer_action_types / analyze_action_trace / gen_action_kt 四个工具

- extractor 支持 return ( 换行形式,类数 619 → 864(唯一名 816,字段 2129)

- 生成器按「运行时 dispatch 打点 > 调用点字面量 > 协议字段表」定类型,认不出写 Any? 并留证据

- 修掉两处会生成非法 Kotlin 的问题:逗号落在注释内、运行时 JS 模型名被当成 Kotlin 类型

- 结果:306/2129 字段已定类型(runtime 162 / callsite 144),compileKotlinJs 通过
2026-10-21 20:19:53 +08:00
TermiNexus b083d27165 fix(protocol): TankState 按 23 字节定长 blob(T5)解码,修复 TankCC 后续字节错位
- codec_fields:module_87072::T5 记为单字段 bytes23;pq.tankState 的 TypeCodec 由 85183::jq 改指 87072::T5
- 生成器支持 bytes<N>(ByteArray / R.ReadRaw(N) / W.WriteRaw),重生成 Module87072、Module85183
- 审计链:lib_space_codec 新增 bytes<N>;audit_pt_boundaries / audit_bit_budget 支持 --body/--plain;semantic 暴露 Out.Bits
- 新增工具:audit_bit_budget(nullmap 位预算)、probe_pt_record(pt 项级)、analyze_item_trace(客户端项级打点对照)、dump_codec_src
- wss_server 新增 --dump-plain:把解密+解压后的裸帧体落到 plain/,供离线审计
- 镜像打点:opt 全量位位置、pt 项级字节/位区间、TankCC 逐字段区间;重新打包 unpacked.pack.json
2026-10-21 15:21:10 +08:00
TermiNexus 2ce2fd0363 fix(tools): 审计不再跳过参考实现失败的 pt 记录 2026-10-21 04:26:22 +08:00
TermiNexus 4cccc084be fix(tools): 修正审计位游标少加形态位的一处偏移 2026-10-21 04:21:51 +08:00
TermiNexus b8651b4a8a feat(protocol): 全量 opt 打点按帧分组,定位每个模型消费的位数 2026-10-21 04:03:51 +08:00
TermiNexus d95e35e5c9 feat(tools): 边界审计输出带目录定位 2026-10-21 04:02:33 +08:00
TermiNexus 1c5dc3afb9 feat(protocol): 采集客户端枚举读取打点并加分析脚本 2026-10-21 03:29:56 +08:00
TermiNexus ce8eeec8d6 fix(tools): 审计改用跨记录位游标,修正多记录帧误判 2026-10-21 03:28:52 +08:00
TermiNexus 8444b961c0 feat(tools): 位痕迹分析输出客户端位游标 2026-10-21 03:02:13 +08:00
TermiNexus aaf1554fe9 feat(protocol): 位痕迹记录客户端绝对位游标 2026-10-21 03:02:02 +08:00
TermiNexus e0542b2681 feat(protocol): 位痕迹按 codec 槽位指纹定位字段 2026-10-21 02:55:25 +08:00
TermiNexus 97917478a4 feat(protocol): 采集客户端 opt<list> 位痕迹并加分析脚本 2026-10-21 02:49:44 +08:00
TermiNexus fafdb91a04 feat(tools): 边界审计支持逐字段位游标追踪 2026-10-21 02:48:17 +08:00
TermiNexus ffe633b286 feat(tools): 用源码 u7h/t7h 核对全部 codec 字段表 2026-10-21 02:46:28 +08:00
TermiNexus 71b74b41e0 feat(tools): 新增 pt 边界审计器,并补全参考实现的 vector3 与位推进判据 2026-10-21 02:20:31 +08:00
TermiNexus 7845e5becd fix(protocol): 单帧异常不再中断整轮回放,并记录 enum 宽度实验结论 2026-10-21 02:04:54 +08:00
TermiNexus 3b58a43063 fix(protocol): 修正反查漏掉 \$ 前缀类名的错位,回放错误 12→6 2026-10-21 01:53:51 +08:00
TermiNexus 5721158e6c feat(tools): dump 接口按 modelId 合并,跨会话累积注册表 2026-10-21 01:38:56 +08:00
TermiNexus b64f5cc65c feat(protocol): 采用运行时权威 modelId 绑定重生成协议层 2026-10-21 01:38:07 +08:00
TermiNexus 9975a8662f feat(protocol): 采集运行时 modelId→codec 注册并用源码反查模块归属 2026-10-21 01:32:03 +08:00
TermiNexus ca42614056 feat(protocol): 离线回放支持多目录并补齐 pt 解码失败定位信息 2026-10-21 01:16:21 +08:00
TermiNexus 353eb9b0a9 chore(bundle): 重新构建 bundle(含栅栏局部坐标判定) 2026-10-21 01:06:26 +08:00
TermiNexus ee2cba3273 fix(ignore): 改用局部坐标的竖直跨度判定栅栏,修正世界/局部坐标混用
三角形顶点是刚体局部坐标(诊断里位置恒为 0,0,0),之前拿局部 z 与车体世界 z 比较导致条件恒不成立。现判据:|法线z|<0.6 且本地竖直跨度>100(地面跨度≈0、栅栏≈200+);诊断输出补 解包type/跨度,位置改用刚体世界坐标以便识别近邻节点。
2026-10-21 01:06:20 +08:00
TermiNexus 4ee63a78c9 feat(autotest): 按 zt 回调号自动应答 G.q1wd
- 解码到 LoadDependencies(zt) 后取 X,延迟 300ms 回 G.q1wd(X)(与真实抓包 c2s_0001 逐字节一致)
- 大厅连接保持打开,避免 digest 会话被提前释放
- 实测:链路密钥唯一命中、zt 容器解码、G.q1wd 已被中继识别并转发
2026-10-21 00:50:47 +08:00
TermiNexus b2deca31be fix(ignore): 解包 type 4 克隆形状后再做三角形判定
type 4 是克隆包装(hu.c9w_1 = 原形状.d21()),地图三角形都以它出现,之前按三角形判定永远不命中。现在 IsTriangle/TriangleNormalZ/ShapeMaxZ/ShapePoint 统一先解包 c9w_1,再套「高于车体 1m + |法线z|<0.6」规则;schema 补 Shape.Clone。
2026-10-21 00:49:13 +08:00
TermiNexus d4a260ac92 feat(ignore): 增加 seen 统计与 ResetStat,便于对比栅栏与地面的形状类型
seen = 本车接触到的所有非坦克形状的 cat/type 直方图(不论是否忽略);新增 ResetStat() 清零,便于「平地 1 秒」与「顶住栅栏 1 秒」两次对比定位栅栏的真实形状类型。
2026-10-21 00:45:50 +08:00
TermiNexus f455fe5c35 feat(ignore): x9o 路径改为忽略本车与任意静态形状的相交查询
接触路径(q9q)仍只忽略立面三角形以防掉地;形状相交查询(x9o)对本车与任意静态形状返回 false,覆盖扫掠/查询式阻挡;新增 skippedOverlap 计数。
2026-10-21 00:42:54 +08:00
TermiNexus 0e8af39bfe feat(ignore): 同时接管形状相交查询 x9o(真形状检测)
栅栏判定同时作用于接触配对 q9q 与形状相交查询 x9o:命中时返回 false(不相交),覆盖扫掠/查询路径;新增 skippedOverlap/overlapPatched 统计;schema 补 CollisionWorld.Overlap。
2026-10-21 00:42:28 +08:00
TermiNexus 0ca28ff9b2 feat(autotest): 新增双连接接入测试与链路密钥探测
- AutoTest:大厅 Hash Request → 取 digest/空间 id → 空间连接 EnterSpace → 收空间帧
- StreamDecoder:首条密文即穷举单字节链路密钥(K),之后 s2c 自动解密
- 首条空间帧到达后再加密发送 G.q1wd,服务端才会继续下发容器数据
- RunAutoTest.ps1:自动拉起大厅/空间两条中继,跑完自动回收
- 实测通过:握手成功、K 唯一命中、容器 1 条、回编码一致
2026-10-21 00:38:59 +08:00
TermiNexus 2ba8df0245 fix(ignore): 只忽略立面三角形,type 1/4 静态代理不再跳过(修复掉地)
实测 byType={16/4:26530,16/1:4620} 表明被跳过的是非三角形地形代理,导致穿地。现判定只对 type=8 三角形生效:最高点高于车体中心 1m 且 |法线z|<0.6;非三角形静态一律保留。
2026-10-21 00:36:30 +08:00
TermiNexus 580a456032 fix(ignore): 栅栏判定加入高度门槛,避免把地面一起忽略导致掉出地图
上一版 skippedFence 达 4.6 万,说明地面三角也被忽略。现规则:静态形状最高点必须高于本地坦克中心 1m 才可能是栅栏;三角形再要求 |法线z|<0.6。新增 byType 统计(cat/type → 忽略次数)便于确认命中对象。
2026-10-21 00:32:24 +08:00
TermiNexus bfc3821dc7 fix(ignore): 恢复非三角形静态碰撞的忽略,栅栏恢复可穿过
上一版把判定收窄为仅陡峭三角形,导致非三角形静态代理(栅栏碰撞体)仍会挡住本地坦克。现判定:静态几何中非三角形一律忽略;三角形按 |法线z|<0.5 忽略,地面/缓坡保留。
2026-10-21 00:30:00 +08:00
TermiNexus 69defa85e7 feat(ignore): 栅栏诊断输出重叠物体信息(形状 + 邻近场景节点名)
保持原有几何判定(仅陡峭静态三角形);DebugFence(true) 后,本车与任意非坦克形状配对时按签名去重打印 cat/type/法线/位置/7m 内场景节点名,用于确认栅栏对应哪个物体;schema 补 Body.State/PositionVec。
2026-10-21 00:25:16 +08:00
TermiNexus 63c08ab165 fix(ignore): 放宽栅栏判定并加入静态形状诊断
静态障碍不再只认三角形:非三角形静态碰撞代理直接视为障碍,三角形按法线 |nz|<0.5 判定;新增 __hornetNoClip__.DebugFence(true) 打印静态形状样本(cat/type/三角/nz/是否跳过),用于定位未生效原因。
2026-10-21 00:18:51 +08:00
TermiNexus f3db51cf4b feat(protocol): 补齐容器命令与短命令别名机制
- Containers.kt 实现 lt/pt/zt/alias 四种布局,覆盖 ModelService 4 条容器命令
- 生成 ModelRegistry.ByModelId,供 pt 项按 modelId 展开
- 短命令:_setModelCommandAlias 解码后写入别名表,记录链可按别名继续解
- 下行注册补到 681 条;railgun-battle 400 帧复跑仍为 399/399 回编码一致
2026-10-21 00:13:12 +08:00
TermiNexus 7481dad24e feat(ignore): 面板新增「忽略」区域(坦克按阵营 / 反弹 / 栅栏)
hornet_noclip 改为通用忽略模块:配对过滤支持 关闭/仅敌方/仅友方/全部 的坦克忽略,并新增忽略栅栏(只跳过 |法线z|<0.5 的静态三角形,地面与缓坡保留);面板新增可折叠「忽略」区域,接管忽略坦克下拉、忽略反弹(NoKnockback)、忽略栅栏,各自 localStorage 持久化;schema 补 Shape.Type/Vertex0-2。
2026-10-21 00:04:38 +08:00
TermiNexus 5088524f22 feat(tankioffline): 搭建 Kotlin/JS 多模块工程与 S2 全量协议层
- 新增 TankiOffline 多模块骨架(app/core/net/ecs/physics/render/resource/state/battle/ui)
- core 实现帧边界、nullmap 位流、大端读写、varint、fflate 解压
- GenerateProtocol.py 从 names 表生成 644 个模型、679 条下行、361 条上行 Kotlin codec
- 手写 TankState 的 23 字节位打包 codec(真实注册点是 module_87072 的 T5)
- app 增加 Node golden 自检:抓包解码后立即回编码逐字节比对
- 实测 plain/railgun-battle 400 帧:399 条记录全部回编码一致
2026-10-21 00:03:18 +08:00
TermiNexus 2ef424929b fix(protocol): 适配 e98d0216 版编解码 API 并重生成协议表
- extract_codec_fields:字段工厂 n7f→r7g、读写方法 p7g/o7g→t7h/s7h、槽位初始化 q7g→u7h
- 资源类注册 u83→y84,核心类型按 模块::导出键 解析(Vector3/Long/实体引用)
- mine_downlink:读取器映射按当前 ByteBuffer 修正(t2u=f32、u2u=f64 等),容器槽位泛化
- 重生成 codec_fields/uplink_table/downlink_table:762 下行字段 0 未解,361 上行命令全量有类型
2026-10-21 00:03:07 +08:00
TermiNexus 78c02345c2 revert(local): 运行链路默认改回 unpacked,src 改为 -UseSrc 可选
保持原有行为:默认加载 3dtank.com/play/static/js/unpacked(含 4 个模块的人工补丁),启动时按需打单包,-Build 强制重打;-UseSrc 才切到 3DTank/src,-Split 作为兼容旧参数等同默认。serve.js 的 --src 能力保留但不启用。
2026-10-20 23:06:09 +08:00
TermiNexus a487c28dc1 feat(local): 运行链路默认切到 3DTank/src,启动时按需打单包
serve.js 新增 --src:unpacked/module_*.js、resources.json、index.json 与 unpacked.pack.json 路由到源码目录(加载器与 hornet_bundle 仍走镜像);修复 403 白名单未包含 src 目录。Start-LocalAndRelay 默认跑 src,源码更新或单包缺失时自动打包,-Build 强制重打,-Split 回退切割产物;恢复 ps1 的 UTF-8 BOM 以免 Windows PowerShell 误读中文。
2026-10-20 22:53:34 +08:00
TermiNexus fd952e814c chore(extract): 重建 src 到当前包 main.7cfa4058 并修复证据挖掘
3DTank/src 原为 6469b966(client_version 83fabafa)时代的产物,已按 main.7cfa4058(e98d0216)重新 extract(126 代码模块 + 1221 资源);mine_evidence 的组件 getter 正则补上当前版本的 Z\$ 抛错函数,字段证据从 0 恢复到 1740 条;证据来源包改为读取 index.json.bundle。
2026-10-20 22:43:37 +08:00
TermiNexus 8bbdb40364 docs(kt): 新增 Kotlin 重构计划(工具清单/阶段/验收)
按 dir.file 的 40/21/65 分类修正范围:仅 40 个游戏本体模块需要复刻;补充现有工具用法、S0-S6 阶段、可启动 JS 验收标准、换版漂移处理与风险回退。
2026-10-20 22:31:40 +08:00
TermiNexus 66a74df6e3 refactor(schema): 全部工具改为经字段抽象层取值
hornet_lib / hook_gamemode / radar / 透视 / 后视镜 / 瞄准警告 / 自由视角 / 战争沙盘 全部改为 Fld/Mth 常量取值;schema 补齐渲染器、网格、射线、武器、皮肤槽位等 88 项字段与别名;SelfCheck 改为只校验核心字段,避免别名误报。
2026-10-20 22:04:07 +08:00
TermiNexus 5a3440229e fix(noclip): 改为拦截碰撞配对,修复关闭后拾取物失效
穿坦克不再修改碰撞 mask(坦克 category 与拾取物触发体共用 bit0,清位会导致拾取失败且关闭后需重生才恢复)。改为包装 module_55088 碰撞世界 ki.q9q,仅跳过双方都是坦克物理组件的配对:地形、拾取物触发、地雷、子弹均保持原样,关闭开关无需还原状态。
2026-10-20 20:39:29 +08:00
TermiNexus bea22bb5f9 feat(noknockback): 新增拒绝击退开关,中弹不再被推开
在本地坦克实体的 kz7 事件派发处丢弃外力事件(ODt:作用点/方向/强度),坦克物理收不到就不产生位移与旋转;悬挂与行驶仍走 m9k 直调,不受影响。面板新增「拒绝击退」开关。字段名走 hornet_schema。
2026-10-20 20:35:19 +08:00
TermiNexus 692b812e58 fix(schema): 自检跳过常量项,避免 Team.NeutralId 误报失配 2026-10-20 20:18:26 +08:00
TermiNexus 972928e848 refactor(schema): 新增字段抽象层,透视滤除非本车节点并重做敌我判定
新增 hornet_schema.js 集中维护匿名引擎字段映射与导出键,支持 SelfCheck 自检失配;透视兜底扫描增加车体距离闸门,只保留跟着本车动的节点;敌我改为按队伍编号(k3_1)每帧比对,中立时沿用上次结果,取消一次判定永久锁定;穿坦克改用抽象字段名。
2026-10-20 20:18:14 +08:00
TermiNexus 6e79057856 feat(noclip): 新增穿坦克工具并接入面板开关
清掉本地坦克碰撞形状 mask 的坦克位(bit0),可穿过其他玩家坦克;地形静态几何(category bit4)碰撞保持不变。引擎状态切换会重写 mask,运行期每帧补一次,停止时按原始快照精确还原。
2026-10-20 20:06:24 +08:00
TermiNexus fcc4901a1f feat(garage): 战局空闲预热车库资源,静态资源启用长缓存与异步读 2026-10-20 16:48:41 +08:00
TermiNexus ce1ffbeb60 fix(team): 队伍函数按需补加载,开局即用可靠基准锁定敌我 2026-10-20 14:28:00 +08:00
TermiNexus f07f182fd8 fix(team): 本地队伍缓存不再随 gm 重建清空,并锁定单局敌我结果 2026-10-20 13:43:21 +08:00
TermiNexus 781ba862b7 fix(alive): 存活判定改用引擎 s14j()(含 TELEPORT),死亡坦克稳定过滤 2026-10-20 13:02:03 +08:00
TermiNexus 8906ba6764 fix(team): 队伍同步延迟时沿用上次判定,消除敌我颜色跳变 2026-10-20 13:01:01 +08:00
TermiNexus 62357fcd9f fix(radar): 恢复死亡坦克的红叉标记(含血量辅助判死) 2026-10-20 11:29:47 +08:00
TermiNexus 2520a91bf1 fix(team): 队伍基准改用本地拥有坦克并缓存;死亡坦克不再标记 2026-10-20 11:20:38 +08:00
TermiNexus 82ee3fd8a4 fix(client): 下发 host 为空时还原为 cN 服务器地址,修复进战斗短命连接 2026-10-20 11:19:09 +08:00
TermiNexus aa3bcba363 fix(wireframe): 扫描结果按字段路径缓存并每帧合并,补齐炮塔与车体漏采 2026-10-19 22:52:33 +08:00
TermiNexus 41b94ef0f9 fix(wireframe): 去重键去掉世界坐标并取消扫描节流,修复模型闪烁 2026-10-19 22:37:31 +08:00
TermiNexus a83f2a5407 feat(ws-hook): 控制台实时打印每个 WebSocket 的地址、关闭码与存活时长 2026-10-19 22:36:34 +08:00
TermiNexus 797f28c8cd fix(wireframe): 采集改为单路径优先并按 mesh+位置去重,修复重复身体与卡顿 2026-10-19 22:32:10 +08:00
TermiNexus af8cde08d2 fix(wireframe): 车身改用皮肤根节点并合并结构扫描,修复漏采与只剩炮塔 2026-10-19 22:08:33 +08:00
TermiNexus 526adc760c fix(wireframe): 顶点/索引优先读 CPU 端缓冲区,修复未上传 mesh 采集失败 2026-10-19 21:45:54 +08:00
TermiNexus 57481a1d09 feat(client-info): 本地镜像的 ClientInfo 显示 SRV: CN1/CN2 2026-10-19 21:43:33 +08:00
TermiNexus 2197f23517 feat(relay): 接管 _reconnectFast,改写战场服地址并按原 host 切换上游 2026-10-19 20:59:20 +08:00
TermiNexus 42e1944cce fix(launcher): 兼容 -SRV=1 等号写法(改用 args 手动解析) 2026-10-19 20:30:40 +08:00
TermiNexus 8820639feb feat(launcher): 增加 -SRV 1/2 统一切换前后端服务器(c1/c2) 2026-10-19 20:29:24 +08:00
TermiNexus 1dd7143e67 fix(wireframe): 去掉炮塔门槛并加实体节点扫描兜底,修复只有点没有模型 2026-10-19 20:14:47 +08:00
TermiNexus dcf9a57456 fix(wireframe): GPU 顶点/索引缓冲字段按新版重定位,恢复模型采集 2026-10-18 19:37:43 +08:00
TermiNexus 86b9322a9f fix(wireframe): 按新版字段修复模型采集与敌我判定 2026-10-18 19:10:22 +08:00
TermiNexus 4d8cbd81d6 chore(sw): 本地镜像禁用 Service Worker(自注销旧注册 + 置空游戏 SW 注册模块) 2026-10-18 19:00:52 +08:00
TermiNexus a34cae5474 fix(tools): 按新旧差异修复位置与相机链路(雷达/透视恢复绘制) 2026-10-17 16:50:54 +08:00
TermiNexus 9506648877 fix(lib): 位置探测增加矩阵式兜底([12],[13],[14] 平移分量)
- 旧版位置来自物理组件的世界变换矩阵(K$ 实例,非普通向量),新版字段平移后无法靠名字命中

- 兜底 2:遍历组件找长度 >=15 的矩阵对象,取 [12]/[13]/[14] 作为平移(引擎惯用布局)
2026-10-17 16:29:06 +08:00
TermiNexus 67b2ff4a80 fix(lib): 坦克位置改为多级字段探测(修复雷达/透视为空)
- 根因:物理组件的位置向量字段每构建都平移,本次 pz7_1→gzh_1→rzf_1 的映射全都指错(gzh_1 是 TerminatorCC、rzf_1 是 RicochetSfxData)

- GetTankPosition 改为多级探测:先试一组成员字段(xzq_1/uzq_1/azr_1/bzr_1/dzr_1/izr_1/jzr_1…),再兜底遍历组件找带 f20_1/g20_1/h20_1 的 O\$ 向量

- 透视的坦克绘制与金箱位置都改用 GetTankPosition
2026-10-17 16:19:54 +08:00
TermiNexus 1cc1afdb08 fix(hook): GameMode hook 目标改为新版导出键(jc/ic),修复所有工具静默失效
- 根因:新版导出键语义漂移 —— \$.ac 变成两字段数据类 \、\ \$.zb 变成 ydt,不再是「取 gmt 实例 / ght 类」

- 新版对应关系:\ \$.jc = dct(scene) 取 ght 实例;\ \$.ic = wct 即 ght 类;\ \$.kc = gct(scene) 取 GameMode 实体

- hook 目标改为 \ \$.jc,并继续 hook wct.prototype 全方法作为兜底

- 重新生成 hornet_bundle.js 并同步 unpacked
2026-10-17 15:57:40 +08:00
TermiNexus 56a1356cce fix(tools): 补全新版符号替换(修复透视/雷达仍不可用)
- 双向校验误判:q100_1 / r11l / jyw / t101_1 在新版同名但含义不同(普通数值 / 别的方法),被当作未变而跳过

- 强制替换:q100_1 到 m10b_1(坦克管理)、r11l 到 j11y(坦克列表)、jyw 到 lz4(取实体)、t101_1 到 p10c_1

- 覆盖 hook_gamemode.js(文件名不匹配 hornet_ 被漏掉)

- 物理组件字段 gzh_1 改为 rzf_1(新版 gzh_1 已是 TerminatorCC 字段,非物理)

- 移除已不存在的 xyu(GameMode),改用 gm.lz4()(等价:取 GameMode 实体)

- 重新生成 hornet_bundle.js(10 模块 / 249229 字节)并同步 unpacked
2026-10-17 15:49:50 +08:00
TermiNexus dcf5dfce5c fix(tools): 工具链符号对齐新版 bundle(修复 HORNET 功能大面积失效)
- 根因:新版 main.7cfa4058 重拆后游戏符号整体平移,工具里写死的字段名(pyt_1/q100_1/r11l/oyz/jyw/cyw_1/dyw_1…)全部对不上 → 找不到组件/相机/场景

- 新增 tools/apply_symbol_map.js:按 map_symbols 映射批量替换,4 道安全规则:① 长名或含下划线 ② 排除 JS 关键字/内置名黑名单 ③ 游戏符号命名特征(含数字或 _数字 结尾) ④ 双向校验(旧名在新 bundle 仍存在→视为未变跳过;新名不存在→映射错误丢弃)

- 单遍替换,避免链式污染(映射表新旧同名复用时会被二次替换)

- 应用到 12 个 hornet_*.js 共 231 处(跳过 25764 个未变符号)

- 重新生成 hornet_bundle.js(10 模块 / 249263 字节)并同步 unpacked
2026-10-16 14:55:39 +08:00
TermiNexus 469f913619 fix(page): 修复 index.html 主文件引用失配(新主文件没被加载,自动刷新拦截等全部失效)
- 根因:index.html 写死 main.9f4e7a89.js,重拆后主文件改名 main.7cfa4058.js → 请求旧名 404,新主文件里的 HORNET 注入 / 自动刷新拦截 / F5 兜底压根没加载

- 3DTank/web/index.html:引用改为 main.7cfa4058.js

- tools/serve.js:Resolve 增加兜底 —— 请求 main.<hash>.js 但文件不存在时,回退到目录里最新的 main.<hash>.js(以后换 bundle 无需再改 HTML)

- tools/split.js:拆分完成后自动改写 index.html 的主文件引用(防复发)
2026-10-16 14:23:02 +08:00
TermiNexus fd0dffe31b feat(bundle): 迁移到新版 main.7cfa4058.js(重做拆分 + 移植 7 处针对性修改)
- 拆分:tools/split.js 重切新版(1347 模块 / 126 代码模块),主文件 main.7cfa4058.js,语法验证通过

- 修复 split.js 内自动刷新拦截的 \\n 转义:它在模板字符串里,必须写 \\\\n,否则生成的主文件字符串断行、语法报错

- 符号映射:map_symbols.js 对齐旧→新(qyr→ryz、w187→c18o、z4t→jtn、vgt→rkt、Bmt→Ezt、Qjt→Fjt、txt→Gjt、k15b→s15n、azi_1→vzr_1…)

- 移植 7 处针对性修改:炮塔摆头不回弹(vSt.ryz)、摆头不过冲(case 0)、精准跟手(case 1/2)、悬浮底盘 raw 两级(Ezt.n36 + Zmt 实际速度)、电磁炮蓄力时长(jtn.c18o)

- 重新打包 unpacked.pack.json(127 文件 / 版本 96833eb2b0cf8909)
2026-10-16 14:20:15 +08:00
TermiNexus cbeb78baba feat(page): 自动刷新拦截改为默认开启(便于查看 err toast)
- 原来默认关闭(需 localStorage.hornetBlockReload=1),游戏一遇到致命错误/断线就 location.reload() 刷页,err toast 一闪即没

- 现改为默认开启:localStorage.hornetBlockReload='0' 才关闭;F5 仍然放行一次

- 同步源文件 tools/split.js 与产物 main.9f4e7a89.js

- 注意:拦截期间游戏无法靠刷页重连,只能反复内部重连(临时排查用)
2026-10-16 14:14:51 +08:00
TermiNexus 6dbe6c6b24 fix(hover): 悬浮底盘 raw 速度补上第二级平滑器(Zmt.wak 的 m14f_1)
- 上一版只改了 Bmt.l36(目标速度那一级),实际速度还在 Zmt.wak 里经 m14f_1.x142 逐帧追赶,所以摆动依旧

- 速度链是两级串联:Bmt.y12d_1(目标速度)→ Zmt.m14f_1(实际速度),现两级都按 raw 直接锁目标

- 回退:window.__hornetHoverRaw__ = false
2026-10-15 18:51:41 +08:00
TermiNexus f8502a0726 feat(hover): 悬浮底盘改为 raw 速度(参考炮塔做法,消除无端摆动)
- 位置:module_87072 的 Bmt.l36(悬浮底盘每帧速度推进)

- 原逻辑:y12d_1/z12d_1 两个 vgt 平滑器按加速度 u143_1/v143_1 逐帧逼近目标 → 惯性来回摆

- 改为:直接 na0(w143_1) 把当前值锁成目标值(等同炮塔 qyr 的 i = this.azi_1)

- 回退开关:window.__hornetHoverRaw__ = false
2026-10-15 18:20:35 +08:00
TermiNexus ab4c31311c fix(relay): 上游握手不再发送 Referer(对齐浏览器真实握手)
- 依据:官方 Copy-as-cURL 的握手头里没有 Referer —— 浏览器发起的 WebSocket 握手本身不带它

- 实测:用原版头直连 wss://c1.3dtank.com:9090/ 可保持连接(8s 超时退出);而中继因多带 Referer 被上游 code=1000 关闭(短命连接)

- 仅删 Referer;Origin 仍强制覆盖为 https://3dtank.com(本机 origin 会被上游拒)
2026-10-15 16:27:34 +08:00
TermiNexus 1b487fbdd6 revert(relay): 回退「按首帧分流上游」(假设已被原始 bundle 证伪)
- 依据:官方 main.9f4e7a89.js 中 config-template = https://c{server}.3dtank.com/config.xml,server 号由 balancer(/s/status.js)决定,与连接类型无关

- 本机镜像走 else 分支:config-template = http://<本机>/config.xml(无 {server} 占位符),根本不参与分服

- 恢复「连上立即建上游」以保持与上游 TLS 建连并行;保留 --upstream-alt 参数与 FirstCmdId 记录备用
2026-10-15 16:19:35 +08:00
TermiNexus 941dde0913 fix(relay): 按首帧分流上游 —— Hash Request 走 c2(大厅),EnterSpace 走 c1(二次进空间)
- 取证:短命连接是「复用首个会话哈希、跳过 Hash Request 直接 EnterSpace」,被服务器 code=1000 关闭(约 1.2 秒);完整握手的连接可稳定存活(2 分半)

- wss_server:上游改为「首帧到达后再建」——StreamDecoder 记录 FirstCmdId,首帧 ID=3 走备用上游、其余走主上游;首帧仍走 Pending 原序补发

- 新增 --upstream-alt / -UpstreamAlt(默认 wss://c1.3dtank.com:9090/)
2026-10-15 16:16:16 +08:00
TermiNexus 529868b194 feat(wireframe): 3D 透视里给人机坦克标「人机」文字
- hornet_3d_wireframe:坦克绘制后在其上方投影并写「人机」(先描黑边再填黄字,任何背景下可读)

- 复用 lib.ScanBots / IsBot,节流约每 0.3 秒扫描一次;雷达的方块标记保留
2026-10-15 15:47:52 +08:00
TermiNexus 2b64211173 feat(radar): 人机识别标记(同队装备分数相同且 < 8000)
- hornet_lib:新增 GetGearScore(BattleGearScoreCC.score → 组件 cjf_1)、ScanBots(按队伍分组,同分且低于阈值判为人机)、GetBotSet/IsBot

- hornet_radar:约每 0.3 秒扫一次;人机画「灰方块 + 黄框」,真人仍「实心圆 + 白边」;顶部统计加 Bot 计数

- 重新生成 hornet_bundle.js 并同步到 unpacked
2026-10-15 15:27:34 +08:00
TermiNexus 92b5ca6e9b refactor(panel): 移除左上角常驻 HUD(「3DTank 控制台」小窗)
- tools/hornet_panel.js:HUD 不再挂载到页面(等于不显示),原样式与 appendChild 一并去掉

- 保留 hud 对象与 UpdateHud 循环:循环里还负责 TryAutoEnable()(按保存状态自动开启工具)

- 重新生成 hornet_bundle.js 并同步到 unpacked
2026-10-15 15:15:28 +08:00
TermiNexus cca13974de fix(relay): 上游对齐官方 —— config 回源与中继上游改为 c2
- serve.js:新增 --config-upstream(默认 https://c2.3dtank.com),/config.xml 回源与改写底稿都改用它

- wss_server.js / Start-LocalAndRelay.ps1:上游默认 c1 改为 c2,可用 -Upstream / --upstream 切回

- 依据:客户端 JS 无 c1/c2 硬编码(连谁完全由 config 决定);实测 c1/c2 各自提供指向自己的 config,官方客户端实际连 c2
2026-10-15 14:09:25 +08:00
TermiNexus 9bce46a86e fix(loader): 断线重连死循环 —— location.reload 拦截改为默认关闭
- 游戏断线时靠 location.reload() 刷页重连;此前为看 err toast 接管了 reload,会把刷页吃掉

- 后果:游戏只能反复内部重连 + 反复拉 config.xml(表现为一直重连 c1 / 反复访问 xml,且只在断线时触发,故呈概率性)

- 现默认关闭;需保留页面看 err toast 时用 localStorage.hornetBlockReload='1' 临时开启
2026-10-15 13:47:35 +08:00
TermiNexus 86aa8bd9bd feat(turret): 炮塔精准跟手加死区消抖(滤掉悬浮底盘的 micro-motion)
- module_87072 oxt.wak case 1/2:目标角变化小于死区(默认 0.5 度)时不搬动炮塔

- 死区可实时调:window.__hornetTurretDeadZone__ = 弧度值
2026-10-15 13:35:39 +08:00
TermiNexus d4c5a89703 feat(turret): 炮塔摆头精准跟手(默认开启)+ 探针默认关闭
- module_87072 oxt.wak:case 1/2 直接锁到目标角,去掉速度追赶导致的追过头再回摆

- module_87072 weaponMount.qyr:去掉渲染层二次插值(dzi_1=azi_1、izi_1=gzi_1)

- 回退开关:window.__hornetTurretPrecise__ = 'off' / false

- probe_chassis.js:新增 oxt.wak / weaponMount.qyr 采样链路,用于定位过冲

- main.9f4e7a89.js:探针改为默认关闭(localStorage.hornetProbe='1' 开启)
2026-10-15 13:34:25 +08:00
TermiNexus 512c718d38 feat(hornet): 十字军底盘控制探针 + 中继 config.xml 动态改写
- 新增 sockets/probe_chassis.js:采样 Cyt.j14k_1 / Zmt.r14f_1 三轴并 hook Cyt.wak / Zmt.wak,转储到 names/probe_chassis.log

- serve.js:新增 /probe_chassis.js 路由与 /__probe__?f= 落盘选择;新增 --relay-port 动态改写 config.xml 指向本机中继(不落盘、不依赖 local/config.xml)

- main.9f4e7a89.js:探针块改为自动加载 railgun + chassis 两个探针

- Start-LocalAndRelay.ps1:-WithRelay 改传 --relay-port,修复「前端未走中继」的根因
2026-10-13 17:14:22 +08:00
TermiNexus 12e547f32f docs(3DTank): 电磁炮锁定蓄力改造的计划与进度(机制/门槛/改动/待办/教训)
含:z4t 三条上行(amr/o1ag/tnl)、三个时长(w1al/w187/x187)、FSM(TWt/vWt)结构、实测门槛(1.5s 最低满伤)、已落地改动与 commit 列表、离线解码流水线命令、关键行号速查。
同批提交 decode_space_frames.js 的 --side s2c/c2s 选项(MD 里的解码命令依赖它)。
2026-10-12 11:20:34 +08:00
TermiNexus 651e29f8b2 feat: 探针改整帧 hex 记录 + 新增 probe_log_to_bins.js 接现有解码流水线
- sockets/probe_railgun.js: SEND 记录整帧字节(流密码跨消息连续,截断/丢帧会导致后续解密全错)
- 3DTank/tools/probe_log_to_bins.js: 把日志里的 c2s 帧还原成 <out>/<label>/session_001/c2s_*.bin,供 decrypt_frames.js → dump_plain.js → decode_space_frames.js 复用
2026-10-12 11:11:57 +08:00
TermiNexus d4c8a843e2 feat(panel): 电磁炮锁定时长固定默认 1.5s(面板默认开启覆盖)
面板回填逻辑改为「未显式关闭即开启」,默认值 1.5s(实测满伤的最小值);取消勾选仍可回退原版 3~5s。z4t.w187 的 HORNET-TAG 同步。
2026-10-12 11:08:01 +08:00
TermiNexus db8a92a2f7 feat(panel): Alt+T 面板新增「电磁炮设置」——锁定/蓄力时长可实时调
启用时长覆盖(checkbox) + 锁定/蓄力(s) 滑块 0.1~5.0/步进0.05,拖动立即写入 window.__hornetLockSec__ 并持久化到 localStorage(hornet_lock_on/hornet_lock_sec)。实测门槛:1.5s/2.0s 满伤,1.0s/0.1s 无伤害。
2026-10-12 11:01:36 +08:00
TermiNexus 1af3b3a7a3 feat(mirror): 电磁炮锁定时长支持运行时覆盖(window.__hornetLockSec__)
z4t.w187() 优先读 window.__hornetLockSec__(正数则用它),否则回退 gaussParams.njs_1;便于面板实时调值,不必重打包刷新。
2026-10-12 11:01:34 +08:00
TermiNexus 2d2a2c9f61 chore(mirror): 电磁炮锁定时长试 0.1s(极端对照,用于离线解码对比)
z4t.w187() 返回 LR(0.1);已知 1.5s/2.0s 满伤、1.0s 掉伤。HORNET-TAG 同步 0.1s。
2026-10-12 10:57:50 +08:00
TermiNexus 8e13aac99f feat(mirror): 电磁炮锁定时长 2.0s → 1.5s(二分探门槛)
z4t.w187() 返回 LR(1.5);2.0s 已实测满伤、1.0s 掉伤,门槛在 1.0~2.0s 之间。HORNET-TAG 同步为 1.5s。
2026-10-12 10:41:49 +08:00
TermiNexus e154c1ab14 fix(sockets): 探针 WS 字节读取兼容 DataView(此前 hex 恒为空)
游戏实际发的是 DataView(有 byteLength、无 slice),走 buffer+byteOffset 取视图;此前 new Uint8Array(dataView,0,24) 得到长度 0 视图,导致 SEND 行 hex 恒空。
2026-10-12 10:35:39 +08:00
TermiNexus 712d24e059 fix(mirror): 徽标抗 DOM 裁剪 + 探针块加自证日志
- 徽标每 2s 自检补回(游戏初始化重排 DOM 时会被删掉)
- 打印「版本徽标已注入」「探针块已执行(hornetProbe=…)」两条日志,便于区分是加载器没生效还是探针 fetch 404
2026-10-12 10:31:34 +08:00
TermiNexus dfdd69b375 feat(mirror): 探针自动加载并转储(/probe_railgun.js 路由 + 加载器引入)
- tools/serve.js: 新增 /probe_railgun.js 路由,读 sockets/probe_railgun.js
- main.9f4e7a89.js: 等 __gameMode__ 就绪后自动 fetch+eval 探针(localStorage.hornetProbe='0' 可关闭),数据走 POST /__probe__ 落盘 3DTank/names/probe_railgun.log,省去每次手粘控制台
2026-10-12 10:23:07 +08:00
TermiNexus 09f8769585 feat(mirror): 电磁炮锁定时长 3~5s → 2.0s(z4t.w187,实测满伤)
z4t.w187() 返回 LR(2),不再取 gaussParams.njs_1;冷却 w1al()/x187() 未动(服务端按冷却裁决)。附 HORNET-TAG 供版本徽标显示。
2026-10-12 10:21:04 +08:00
TermiNexus bb3d76117a feat(mirror): 右下角版本徽标(包版本 + 从模块源码读出的 HORNET-TAG)
加载器记录 __hornetPackVersion__ 并渲染固定徽标;TAG 用正则从 __modules__[87072] 源码读取,避免手写常量与真实包不一致。
2026-10-12 10:21:03 +08:00
TermiNexus b63435d401 fix(sockets): probe_railgun.js WS 上行改异步读 Blob(此前 hex 恒为空)
游戏 send 的是 Blob,slice 后 new Uint8Array(Blob) 长度为 0,导致 SEND 行 hex 全空;改为 blob.arrayBuffer() 异步读前 24 字节。
2026-10-12 10:21:02 +08:00
TermiNexus 2b95e5de28 chore(3DTank): 新增页面内探针的网络转储通道(/__probe__ + probe_railgun.js)
- tools/serve.js: POST /__probe__ 追加写 3DTank/names/probe_railgun.log
- sockets/probe_railgun.js: 电磁炮/武器链路现场探针(组件清单 + 采样 + WS 上行帧),fetch 批量转储,POST 不通自动回落控制台
2026-10-12 10:13:32 +08:00
TermiNexus 0c55021479 fix(origin): Origin 修正为 https://3dtank.com 并回退误改的 c2 上游
- 中继/代理 origin 由 https://3dtank.com/play/ 改为 https://3dtank.com(Origin 规范只到源、不带路径);referer 保持完整地址 https://3dtank.com/play/
- 上游与 /config.xml 回源回退到 c1.3dtank.com:9090:战斗必须连 c1,改 c2 会让前端战斗连 c2 而无法使用
- wss_server.js 新增「上游握手头」诊断日志,区分「浏览器→中继」与「中继→上游」两段
- README 同步
2026-10-12 00:19:04 +08:00
TermiNexus dd2c6c4c16 change(origin): Origin/Referer 改为 https://3dtank.com/play/(中继上游强制覆盖 + 镜像回源)
- wss_server.js:上游握手头的 origin/referer 不再透传前端值(前端带的是本机 http://localhost:8080),改为强制覆盖为 https://3dtank.com/play/;其余请求头仍整包透传

- serve.js:代理回源(/resources、/config.xml)的 origin/referer 同步改为 https://3dtank.com/play/
2026-10-11 22:21:14 +08:00
556 changed files with 1429308 additions and 445999 deletions

No files matched your search

+15
View File
@@ -19,6 +19,7 @@ sockets/relaytest*/
# ---- 反编译 / 切割产物(可用 tools/extract.js、tools/split.js 重新生成) ----
3DTank/src/
3DTank/src_*/
3DTank/src_named/
# ---- 客户端(client/)构建产物与 Gradle 缓存 ----
@@ -34,3 +35,17 @@ tools/symbol_map.json
/1
/curlcmd
/i.html
# ---- UI 资源镜像(开发用本地镜像;生产走 CDN/反代,不入库) ----
TankiOffline/app/src/jsMain/resources/play/
# ---- UI / 调试临时产物 ----
bud/
3DTank/tmp_old_*.js
3DTank/names/*_trace.json
3DTank/names/slice_*.json
3DTank/names/decoded_frames.txt
3DTank/names/regdump_model.json
3DTank/names/action_trace*.json
__pycache__/
/1.html
+3 -2
View File
@@ -86,10 +86,11 @@ config-template = http(s)://<本机>/config.xml
| 本机路径 | 转发到 | 说明 |
|---|---|---|
| `/resources/**` | `https://res.3dtank.com/**` | CDN 资源基址 |
| `/config.xml` | `https://c1.3dtank.com/config.xml` | 服务器配置(指向 `wss://c1.3dtank.com:9090`) |
| `/config.xml` | `https://c1.3dtank.com/config.xml` | 服务器配置(指向 `wss://c1.3dtank.com:9090`;开始战斗必须连 c1) |
| `/s/status.js` | `https://3dtank.com/s/status.js` | balancer 节点状态 |
代理会强制带上 `origin: https://3dtank.com` 与 `referer`(CDN 有来源校验),并落盘缓存。
代理会强制带上 `origin: https://3dtank.com`(Origin 按规范只到源、不带路径)与
`referer: https://3dtank.com/play/`(Referer 才是完整地址),并落盘缓存。
另外 URL 查询参数优先级高于上述默认值,也可用
`?resources=…&config-template=…&balancer=…` 直接覆盖(值需 URL 编码)。
+107 -26
View File
@@ -1,11 +1,11 @@
<#
<#
.SYNOPSIS
一键启动 3DTank 本地镜像(serve.js)+ 中继(wss_server.js relay 模式)
.DESCRIPTION
- 本地镜像:把官方 web 客户端在 http://localhost:<MirrorPort>/ 跑起来
· web/ 提供首页与镜像资源;/play/static/js/** 直接挂 3dtank.com 的切割版 JS
· /config.xml 优先用 3DTank/local/config.xml(指向本地中继 9091),否则代理真实 config
· /config.xml:-WithRelay 时由 serve.js 动态改写为 ws://localhost:<RelayPort>/(不落盘);否则代理真实 config
- 中继:客户端 → ws://localhost:<RelayPort>/ → 真实服务器(抓包落盘 + 实时解密/解析)
· 产物目录:3DTank/packets/<label>/session_00N/
- 依赖:中继需要 ws(Node 自带的是客户端 WebSocket,服务端不够用),脚本会自动装
@@ -18,15 +18,36 @@
// 资源缺失(页面 404 很多)时先镜像资源再起(首次约 70MB)
.\Start-LocalAndRelay.ps1 -FetchAssets -OpenBrowser
.EXAMPLE
// 端口被上次残留占用时,先杀掉再起
.\Start-LocalAndRelay.ps1 -KillExisting -OpenBrowser
#>
[CmdletBinding()]
param(
[int]$MirrorPort = 8080,
[int]$RelayPort = 9091,
[string]$Upstream = 'wss://c1.3dtank.com:9090/',
.EXAMPLE
// 端口被上次残留占用时,先杀掉再起
.\Start-LocalAndRelay.ps1 -KillExisting -OpenBrowser
.EXAMPLE
// 强制整个链路(前端 config 来源 + 中继上游)都走 c1
// 注意:PowerShell 不认 -SRV=1 这种等号写法,要写 -SRV 1 或 -SRV:1
.\Start-LocalAndRelay.ps1 -SRV 1 -WithRelay -OpenBrowser
.EXAMPLE
// 强制走 c2(官方默认主服)
.\Start-LocalAndRelay.ps1 -SRV 2 -OpenBrowser
.EXAMPLE
// 默认用切割产物 unpacked;启动时按需打单包(-Build 强制重打)
.\Start-LocalAndRelay.ps1 -OpenBrowser
.EXAMPLE
// 改用反编译源码 3DTank/src(改完刷新即生效)
.\Start-LocalAndRelay.ps1 -UseSrc -OpenBrowser
#>
# 注意:这里刻意不用 [CmdletBinding()]。
# PowerShell 原生不支持 -SRV=1 这种等号写法(会报 "parameter cannot be found"),
# 去掉 CmdletBinding 后,未绑定的 -SRV=1 会落进 $args,由下面手动解析兼容。
param(
[int]$MirrorPort = 8080,
[int]$RelayPort = 9091,
[string]$Upstream = 'wss://c2.3dtank.com:9090/', # 主上游(大厅:首帧 = Hash Request)
[string]$UpstreamAlt = 'wss://c1.3dtank.com:9090/', # 备用上游(首帧直接 EnterSpace 的「二次进空间」连接)
[ValidateSet(0, 1, 2)][int]$SRV = 0, # 服务器选择:0=保持默认(c2);1=前后端都走 c1.3dtank.com;2=前后端都走 c2.3dtank.com
[string]$Label = (Get-Date -Format 'yyyyMMdd-HHmmss'),
[switch]$FetchAssets, # 先跑 fetch_assets.js 镜像资源(首次或资源缺失时用)
[switch]$KillExisting, # 端口被占用时,先结束占用进程
@@ -36,14 +57,40 @@ param(
[int]$DumpEvery = 1, # 中继:每 N 帧写一个 .bin(默认 1 = 每帧都写;0 = 不写抓包文件)
[switch]$NoLog, # 中继:不写 log.txt(默认写)
[switch]$NoHex, # 中继:log.txt 不写十六进制(日志体积大幅减小)
[switch]$Quiet # 中继:只写 log.txt,不刷控制台
[switch]$Quiet, # 中继:只写 log.txt,不刷控制台
[switch]$NoTunnel, # 中继:关闭 _reconnectFast 隧道接管(默认开启:战场连接也走中继)
[switch]$Build, # 强制重打单包(默认只在缺失/模块更新时打包)
[switch]$UseSrc, # 可选:改用反编译源码 3DTank/src;默认仍用切割产物 unpacked
[switch]$Split # 兼容旧参数:默认就是 unpacked,此开关等同默认
)
$ErrorActionPreference = 'Stop'
# ---------- 兼容 -SRV=1 / --SRV=1 等号写法 ----------
# PowerShell 只认 -SRV 1 或 -SRV:1;等号形式会被当作未绑定参数落进 $args,这里手动识别
foreach ($A in $args) {
if ($A -is [string] -and $A -match '^--?SRV=(.+)$') {
$SRV = [int]$Matches[1]
}
}
if ($SRV -notin 0, 1, 2) { throw "-SRV 只能是 0 / 1 / 2(收到: $SRV)" }
$Project = $PSScriptRoot # 3DTank/
$Tools = Join-Path $Project 'tools'
$PacketDir = Join-Path $Project ("packets\{0}" -f $Label)
# ---------- 服务器选择(-SRV 1/2)----------
# 同时决定:① 前端 /config.xml 的回源(真实 config 里的 server address)
# ② 中继 --upstream(后端实际连哪台)
$ConfigUpstream = 'https://c2.3dtank.com'
if ($SRV -eq 1 -or $SRV -eq 2) {
$ServerHost = "c$SRV.3dtank.com"
$Upstream = "wss://${ServerHost}:9090/"
$UpstreamAlt = $Upstream
$ConfigUpstream = "https://$ServerHost"
Write-Host ("[服务器] -SRV={0} → 前端 config 与中继上游统一走 {1}" -f $SRV, $ServerHost) -ForegroundColor Yellow
}
# ---------- 小工具 ----------
function Get-CmdPath([string]$Name) {
$c = Get-Command $Name -ErrorAction SilentlyContinue
@@ -122,20 +169,50 @@ if ($FetchAssets) {
try { & $NodeExe fetch_assets.js --concurrency 10 | Out-Host } finally { Pop-Location }
}
# ---------- 3.5) 生成单包(unpacked.pack.json):126 个小文件 → 1 个包 ----------
$PackScript = Join-Path (Split-Path -Parent $Project) 'tools\pack_unpacked.js'
if (Test-Path $PackScript) {
Write-Host '[打包] pack_unpacked.js 生成单包(unpacked.pack.json)...' -ForegroundColor Cyan
try { & $NodeExe $PackScript | Out-Host } catch { Write-Warning ('打包失败: ' + $_.Exception.Message) }
# ---------- 3.5) 模块来源与单包 ----------
# 默认:用切割产物 3dtank.com/play/static/js/unpacked(保持原有行为)
# -UseSrc:改用反编译源码 3DTank/src(改完代码刷新即生效)
# -Build :强制重打单包
$SrcDir = Join-Path $Project 'src'
$RootDir = Split-Path -Parent $Project
$PackScript = Join-Path $RootDir 'tools\pack_unpacked.js'
if (-not (Test-Path $PackScript)) { throw ('找不到打包脚本: ' + $PackScript) }
if ($Split) { $UseSrc = $false }
if ($UseSrc) {
if (-not (Test-Path $SrcDir)) {
throw ('找不到源码目录 ' + $SrcDir + ',请先运行 3DTank/tools/extract.js 生成;或去掉 -UseSrc 用切割产物')
}
$PackDir = $SrcDir
$PackFile = Join-Path $SrcDir 'unpacked.pack.json'
} else {
Write-Warning ('未找到 ' + $PackScript + ',跳过单包(将逐文件加载)')
$PackDir = Join-Path $RootDir '3dtank.com\play\static\js\unpacked'
$PackFile = Join-Path $RootDir '3dtank.com\play\static\js\unpacked.pack.json'
}
$NeedPack = $Build -or (-not (Test-Path $PackFile))
if (-not $NeedPack) {
$PackTime = (Get-Item $PackFile).LastWriteTimeUtc
$Newest = Get-ChildItem $PackDir -Filter 'module_*.js' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1
if ($Newest -and $Newest.LastWriteTimeUtc -gt $PackTime) {
$NeedPack = $true
Write-Host ('[打包] 检测到源码有更新(' + $Newest.Name + '),重新打包') -ForegroundColor Yellow
}
}
if ($NeedPack) {
Write-Host ('[打包] ' + $PackDir + ' → ' + $PackFile) -ForegroundColor Cyan
& $NodeExe $PackScript $PackDir $PackFile | Out-Host
if ($LASTEXITCODE -ne 0) { throw '单包打包失败' }
} else {
Write-Host '[打包] 单包已是最新,跳过(要强制重打请加 -Build)' -ForegroundColor DarkGray
}
# ---------- 4) 中继(可选;默认不启动 —— 页面直连真实服务器) ----------
$RelayProc = $null
if ($WithRelay) {
Write-Host ("[中继] wss_server.js relay ws://localhost:{0}/ -> {1}" -f $RelayPort, $Upstream) -ForegroundColor Green
$RelayArgs = @('wss_server.js', '--port', "$RelayPort", '--upstream', $Upstream, '--label', $Label, '--dump-every', "$DumpEvery")
$RelayArgs = @('wss_server.js', '--port', "$RelayPort", '--upstream', $Upstream, '--upstream-alt', $UpstreamAlt, '--label', $Label, '--dump-every', "$DumpEvery", '--tunnel-reconnect', $(if ($NoTunnel) { '0' } else { '1' }))
if (-not $NoLog) { $RelayArgs += @('--log', '1') }
if ($NoHex) { $RelayArgs += @('--no-hex', '1') }
if ($Quiet) { $RelayArgs += @('--quiet', '1') }
@@ -148,10 +225,11 @@ if ($WithRelay) {
}
# ---------- 5) 启动本地镜像 ----------
# 默认加 --real-config:/config.xml 取真实站点,游戏因此直连 wss://c1.3dtank.com:9090/
# 走中继(-WithRelay)时不加它,改用 local/config.xml 把客户端指向本机 127.0.0.1:<RelayPort>
$MirrorArgs = @('serve.js', '--port', "$MirrorPort")
if (-not $WithRelay) { $MirrorArgs += '--real-config' }
# 默认加 --real-config:/config.xml 取真实站点(--config-upstream 决定 c1 / c2),游戏据此直连对应服务器
# 走中继(-WithRelay)时改用 --relay-port:serve.js 动态把 config 改写为 ws://localhost:<RelayPort>/(不落盘)
$MirrorArgs = @('serve.js', '--port', "$MirrorPort", '--config-upstream', $ConfigUpstream)
if ($UseSrc) { $MirrorArgs += @('--src', $SrcDir) }
if ($WithRelay) { $MirrorArgs += @('--relay-port', "$RelayPort") } else { $MirrorArgs += '--real-config' }
if ($Hook) { $MirrorArgs += '--ws-hook' }
Write-Host ("[镜像] serve.js http://localhost:{0}/ {1}{2}" -f $MirrorPort, ($(if ($WithRelay) {'(config → 本地中继)'} else {'(config → 真实服务器,直连)'})), ($(if ($Hook) {' + ws 抓包'} else {''}))) -ForegroundColor Green
$MirrorProc = Start-Process -FilePath 'cmd.exe' `
@@ -165,17 +243,20 @@ $MirrorOk = Test-TcpOpen '127.0.0.1' $MirrorPort
Write-Host ''
Write-Host '================= 已启动 =================' -ForegroundColor Yellow
Write-Host ("浏览器打开 : http://localhost:{0}/" -f $MirrorPort) -ForegroundColor White
Write-Host ("浏览器打开 : http://localhost:{0}/" -f $MirrorPort) -ForegroundColor White
Write-Host ("模块来源 : {0}" -f ($(if ($UseSrc) {'3DTank/src(反编译源码,改完刷新即生效)'} else {'unpacked(切割产物)'}))) -ForegroundColor White
Write-Host ("工具链 : 默认已注入(纯净页 http://localhost:{0}/#nohornet);切勿加查询参数 ?xxx —— 会被游戏当成 config 参数,导致握手失败 1005" -f $MirrorPort) -ForegroundColor White
if ($WithRelay) {
Write-Host ("连接路径 : 页面 → ws://localhost:{0}/ (中继)→ {1}" -f $RelayPort, $Upstream) -ForegroundColor White
Write-Host ("连接路径 : 页面 → ws://localhost:{0}/ (中继)→ {1}" -f $RelayPort, $Upstream) -ForegroundColor White
Write-Host ("隧道接管 : {0}" -f ($(if ($NoTunnel) {'关闭(客户端直连战场服)'} else {'开启(_reconnectFast 改写为本地,战场连接继续走中继)'}))) -ForegroundColor White
$DiskInfo = if ($DumpEvery -gt 0 -or -not $NoLog) { "dump-every=$DumpEvery log=$(if ($NoLog) {'off'} else {'on'}) hex=$(if ($NoHex) {'off'} else {'on'})" } else { '不落盘(只转发 + 实时解析)' }
Write-Host ("抓包目录 : {0} (仅落盘时使用)" -f $PacketDir) -ForegroundColor White
Write-Host ("落盘策略 : {0} (默认落盘保存;不落盘加 -DumpEvery 0 -NoLog)" -f $DiskInfo) -ForegroundColor White
Write-Host ("进程 : 中继 PID={0} 镜像 PID={1}" -f $RelayProc.Id, $MirrorProc.Id) -ForegroundColor White
Write-Host ("自检 : 中继={0} 镜像={1}" -f ($(if ($RelayOk) {'OK'} else {'未监听'})), ($(if ($MirrorOk) {'OK'} else {'未监听'}))) -ForegroundColor White
} else {
Write-Host '连接路径 : 页面 → config 取自真实站点 → 直连 wss://c1.3dtank.com:9090/(未启动中继)' -ForegroundColor White
Write-Host ("连接路径 : 页面 → config 取自 {0} → 直连 {1}(未启动中继)" -f $ConfigUpstream, $Upstream) -ForegroundColor White
Write-Host ("服务器 : {0}" -f ($(if ($SRV -eq 0) {'-SRV 未指定(默认 c2)'} else {"-SRV=$SRV"}))) -ForegroundColor White
Write-Host ("进程 : 镜像 PID={0}" -f $MirrorProc.Id) -ForegroundColor White
Write-Host ("自检 : 镜像={0}" -f ($(if ($MirrorOk) {'OK'} else {'未监听'}))) -ForegroundColor White
}
+177
View File
@@ -0,0 +1,177 @@
# 电磁炮(RAILGUN / `z4t`)锁定蓄力「加速」改造 —— 计划与进度
> 状态:**功能已可用(锁定 3~5s → 1.5s,满伤,面板可实时调)**;**归因(离线解码)待做**。
> 本文档是「日后再说」的交接手记:机制、实测门槛、已落地改动、待办与命令、踩过的坑。
---
## 0. 一句话目标
原版:按住开火 → **锁定/蓄力 3~5s** → 才发(满伤)。
目标:缩短这段等待,**且必须保持满伤** —— 服务端会按「锁定开始 → 开火」的时间差裁决。
**当前结果**:锁定/蓄力 **1.5s**(实测满伤的最小值),比我方原版快 2~3 倍,且可在 Alt+T 面板实时调。
---
## 1. 已确证的机制(含证据)
### 1.1 玩家坦克的武器组件 = `z4t`(**不是** `xen`)
- 现场探针实测:`__gameMode__.y100_1.oyt_1.c2a_1.93`,`ctor = function z4t()`,字段含 `h1ah_1 / i1ah_1 / l1ah_1 / o1ah_1 / w1ah_1 …`
- `xen`(代码自标注 `railgunWeapon`)**不在玩家车上** —— 早期改它是空转(教训)。
### 1.2 三条相关上行命令
| 命令 | 语义 | 触发链 |
|---|---|---|
| `amr` | **普通弹**(点一下) | `J6t.z1aq → z4t.z1al → UYt → N4t.amr` |
| `o1ag` | **锁定弹**(长按/蓄力) | `K6t.z1aq → z4t.a1am(目标) → Q6t → N4t.o1ag` |
| `tnl` | **锁定开始**(无载荷、只带坦克 id) | 进 `vWt` 锁定态时 `iyz(new Mdn(n,i,b189_1))` → `N4t`:`n.h18v_1 && P4t(t).tnl(...)` |
- 走 `o1ag` 还是 `amr`,由 `V6t` 里 **「按下时刻 + 阈值」** 判定:`W6t().a1ar_1 = Hj(0.2)`(按住 ≥0.2s → `A6t → K6t`;否则 `R6t → J6t`)。
- `tnl` **只带坦克 id** ⇒ 服务端只能**用自己收到它的时刻**计时 ⇒ 这是「短于门槛就没伤害」的根因。
### 1.3 三个时长参数(都在 `k4t(this)` = `gaussParams` 组件上)
| 方法 | 原式 | 语义 | 实测 |
|---|---|---|---|
| `w1al()` | `LR(0.001 * ojs_1)` | 带锁定弹的**冷却/装填** | ~6.6s(探针采样 `o1ah_1` 从 0 涨到 1) |
| **`w187()`** | `LR(0.001 * njs_1)` | **锁定/蓄力时长** ★ 改这里 | 原版 3~5s |
| `x187()` | `LR(0.001 * mjs_1)` | 另一条 | 未仔细测 |
| (普通弹那支) | `X6t.l1ar_1 ← L6t(i1ah_1)` | 普通弹冷却 | `i1ah_1 = 1.639` |
### 1.4 状态机结构
- `z4t.y1ah` 建 FSM:`e=G6t(Idle) / u=Z6t / s=V6t(按下) / o=J6t(普通弹) / _=K6t(锁定弹) / a=X6t(累积) / h=Y6t / l=$4t(t)=TWt`
- `TWt` = `new L9("StrikerTargetSelection")`(内嵌目标选择 FSM);其**内部 `vWt` 状态用 `w187()` 当累积上限**:
`y188_1` 从 `w187()` 递减到 0 → 发 `bWt`(选中目标)→ `K6t.z1aq()` → `a1am(目标)` → `Q6t` → 上行 `o1ag`。
- `z4t.t187(y188_1 / w187())` 写出的 `o1ah_1` 就是 **HUD 上那条倒计时/进度条**。
---
## 2. 实测门槛(二分结果)
| 锁定值 | 结果 |
|---|---|
| 原版 3~5s | 满伤 |
| 2.0s | 满伤 |
| **1.5s** | **满伤(当前采用值)** |
| 1.25s | 用户实测:不行 ⇒ **1.5s 为最低可用值** |
| 1.0s / 0.1s | 无伤害(另一号**完全看不到弹药** ⇒ 服务端整发拒收) |
结论:**服务端对「`tnl` → 开火」的时间差有硬门槛,约 1.5s。**
---
## 3. 已落地改动
### 3.1 游戏侧(`3dtank.com/play/static/js/unpacked/module_87072.js`)
```js
(m$(z4t).w187 = function () {
// HORNET-TAG: 电磁炮 锁定/蓄力时长 = 面板默认 1.5s(可实时调) (z4t.w187)
var O = window.__hornetLockSec__;
if (typeof O === 'number' && O > 0) return LR(O); // 运行时覆盖
var t = k4t(this).njs_1; // 回退原版参数
return LR(0.001 * t);
}),
```
### 3.2 面板(`tools/hornet_panel.js` → 经 `python build_bundle.py` 进 bundle)
- Alt+T → **「电磁炮设置」** → **启用时长覆盖(checkbox,默认开)** + **锁定/蓄力(s) 滑块 0.1~5.0 / 步进 0.05**
- 拖动**立即生效**(`window.__hornetLockSec__`),持久化到 `localStorage`:`hornet_lock_on` / `hornet_lock_sec`
- **取消勾选 = 回原版**(3~5s)
- 控制台等价:`__hornetLockSec__ = 1.25` / 恢复原版 `__hornetLockSec__ = undefined`
### 3.3 观测/调试设施(本次会话顺带产出)
| 设施 | 位置 | 说明 |
|---|---|---|
| 版本徽标 | `3dtank.com/play/static/js/main.9f4e7a89.js` | 右下角显示「包版本 + 从 `__modules__[87072]` 源码正则读出的 `HORNET-TAG`」(防「改了没生效」);每 2s 抗 DOM 裁剪自愈 |
| 探针 | `sockets/probe_railgun.js` | 组件清单 + 300ms 采样 + **整帧** WS 上行记录;自动加载(等 `__gameMode__`),`localStorage.hornetProbe='0'` 关 |
| 转储入口 | `3DTank/tools/serve.js` | `POST /__probe__` → 追加写 `3DTank/names/probe_railgun.log`;`GET /probe_railgun.js` 提供探针 |
| 解码供给 | `3DTank/tools/probe_log_to_bins.js` | 日志 → `packets/probe/<label>/session_001/c2s_*.bin` |
### 3.4 本次会话的 commit(时间顺序)
```
09f8769 feat(mirror): 电磁炮锁定时长 3~5s → 2.0s(z4t.w187,实测满伤)
bb3d761 feat(mirror): 右下角版本徽标(包版本 + 从模块源码读出的 HORNET-TAG)
712d24e fix(mirror): 徽标抗 DOM 裁剪 + 探针块加自证日志
09f8769…(同上)
dfdd69b feat(mirror): 探针自动加载并转储(/probe_railgun.js 路由 + 加载器引入)
e154c1a fix(sockets): 探针 WS 字节读取兼容 DataView(此前 hex 恒为空)
8e13aac feat(mirror): 电磁炮锁定时长 2.0s → 1.5s(二分探门槛)
2d2a2c9 chore(mirror): 电磁炮锁定时长试 0.1s(极端对照)
1af3b3a feat(mirror): 电磁炮锁定时长支持运行时覆盖(window.__hornetLockSec__)
db8a92a feat(panel): Alt+T 面板新增「电磁炮设置」
d4c8a84 feat(panel): 电磁炮锁定时长固定默认 1.5s(面板默认开启覆盖)
651e29f feat: 探针改整帧 hex 记录 + 新增 probe_log_to_bins.js
```
---
## 4. 待办(日后接着做)
### 4.1 采一轮整帧数据(需要人工在游戏里操作一次)
1. F5(探针自动加载;徽标第二行应为「面板默认 1.5s(可实时调)」)
2. 进战场 → 打 **2~3 发蓄力弹**(最好有一次「选中坦克并打中」)
3. 跑流水线:
```bat
node 3DTank\tools\probe_log_to_bins.js
node 3DTank\tools\decrypt_frames.js --dir 3DTank\packets\probe --label probe-01
node 3DTank\tools\dump_plain.js --session probe-01 --out 3DTank\packets\plain\probe-01
node 3DTank\tools\decode_space_frames.js --dir 3DTank\packets\plain\probe-01 --side c2s --fields
```
### 4.2 归因目标(两轮对比)
- **1.5s 轮**(满伤)与 **0.1s 轮**(无伤害)的**上行命令序列**对比:
- 命令集合/顺序**一致**(只是时间被压缩)⇒ 服务端按时长裁决(目前已基本确认,只差这最后一步铁证);
- 命令**不一致** ⇒ 客户端侧问题(那一发压根没发出去 / 参数没就绪),改客户端那一步。
### 4.3 可选优化方向
- 查服务端门槛的**确切值**(1.25s 不行、1.5s 行 ⇒ 门槛在 (1.25, 1.5] 之间,可用面板滑块继续夹)。
- 若想让服务端「以为」锁更久:需要把 `tnl` **提前**发 —— 但**别用假载荷**(见 5.2 教训)。
- 冷却那一支(`w1al()` ≈ 6.6s / `x187()`)若也想缩短,需先确认服务端是否同样按它裁决(大概率是)。
---
## 5. 教训与注意事项(★ 重要)
1. **不能动冷却**:`X6t` / `l1ar_1`(普通弹 `i1ah_1=1.639`;锁定弹 `w1al()`≈6.6s)—— 服务端按它裁决;
短路它 = **出弹但无伤害**(我第一次就踩了这个)。
2. **别用假载荷预置 `tnl`**:我一度在冷却开始时提前发 `Mdn(0,0,!0)` → 那一轮**连弹药都看不到**(整发被拒),已回滚。
3. **开关只能用 `localStorage`**:`location.hash` 会被游戏当路由(**进不去战场**);查询串会被当 config 参数(**握手 1005**)。
4. **改完必须走对应构建**:
- 改 `3dtank.com/.../unpacked/module_*.js` → `node tools/pack_unpacked.js`(单包)
- 改 `tools/hornet_*.js` → `python build_bundle.py`(hornet bundle)
- `serve.js` 只有自身改动才需重启(`Start-LocalAndRelay.ps1 -KillExisting`)
5. **面板滑块实时生效的前提**:`z4t.w187()` 只在**每次开始锁定时**被读 ⇒ 改值影响**下一发**,当前这发不变。
6. **探针整帧记录是硬要求**:流密码状态跨消息连续,截断/漏帧会让后续解密**全错**。
7. **验证版本永远看徽标第二行**(`HORNET-TAG`),不要凭手感判断「改了没生效」。
---
## 6. 关键行号速查(`3dtank.com/play/static/js/unpacked/module_87072.js`)
| 内容 | 行 |
|---|---|
| `z4t.w1al/w187/x187`(三个时长) | ~92824-92839 |
| `z4t.z1al`(普通弹开火) | ~93026 |
| `z4t.a1am`(锁定弹开火) | ~93038 |
| `z4t.y1ah`(FSM 构建 + `TWt` 实例化) | ~92862-92910 |
| `V6t`(0.2s 阈值判定走哪条) | ~93601-93619 |
| `X6t`(累积/冷却) | ~93633-93652 |
| `K6t.z1aq`(锁定弹入口 → `a1am`) | ~93620 |
| `J6t.z1aq`(普通弹入口 → `z1al`) | ~93664 |
| `TWt` = `new L9("StrikerTargetSelection")` | ~23375 |
| `vWt`(用 `w187()` 累积 → `bWt`) | ~83807-83826 |
| `Mdn` 定义(`h18v_1` = 第 3 个参数) | ~34869 |
| `N4t`(`tnl`/`amr`/`o1ag` 上行分发) | ~93103-93153 |
| `PYt`(lockingServerInterface 窗口,与本案无关) | ~86175-86216 |
+719
View File
@@ -0,0 +1,719 @@
# 3DTank Kotlin 重构计划(KtRebuild Plan)
> **目标**:用 Kotlin 复刻游戏本体,最终产出**职责等价、可启动**的 JS 客户端。
> **基线**:`main.7cfa4058.js`(10,080,299 字节)/ `client_version = e98d02161a563e043eabb96c6e4db5fedcf165e7`
> **文档定位**:计划表 —— 有哪些工具、怎么用、按什么顺序做、怎么验收。
---
## 0. 先纠正三个前提
1. **`dir.file` 不是当前版本的职责表。**
它是旧版 126 模块的职责划分;当前版本的模块 id、字节数、内部字段都有漂移。
但**职责划分大体一致**,仍可作为"要做哪些子系统"的规划依据;正式开工前必须用当前包重建一份职责表(见 S0)。
2. **`e98d0216` 不是文件名。**
`e98d0216…` 是 `client_version`(git release hash,写死在 `module_61860.js`):
```js
i.g3("client_version", "e98d02161a563e043eabb96c6e4db5fedcf165e7");
```
而 `main.7cfa4058.js` 的 `7cfa4058` 是 webpack **contenthash**。两者无对应关系,不能互相推导文件名。
3. **真正要复刻的只有 40 个模块。**
`dir.file` 的分类计数:**游戏本体 40 / Kotlin 生态库 21 / 第三方库 65**(另有 index.json、resources.json)。
后 86 个是已知开源依赖,直接引上游即可,不需要反编译重写。
---
## 1. 验收标准("可启动 JS"的定义)
| 级别 | 判定 | 手段 |
|---|---|---|
| **L1 可加载** | 126 模块 + 资源全部注册成功,无 404 / 无语法错误 | `tools/test_loader.js` 全绿 |
| **L2 可进战局** | `serve.js` 起站 → 登录 → 车库 → 进战斗 → 控制台无未捕获异常 | 人工 + 控制台 |
| **L3 行为等价** | 同场景关键状态(位置/血量/状态机/事件序列)与原包对比一致 | 双跑 golden trace |
| **L4 工具兼容** | HORNET 全部开关可用(雷达/透视/后视镜/穿坦克/拒绝击退…) | `build_bundle.py` 产物实测 |
> L1/L2 是最低门槛;L3 才是"职责等价"的真正证据。
---
## 2. 模块分层(依据 `dir.file`)
| 分类 | 数量 | 处理方式 | 是否重写 |
|---|---|---|---|
| 游戏本体 | 40 | 反混淆 → Kotlin 复刻 | ✅ |
| Kotlin 生态库 | 21 | 换官方依赖(stdlib / serialization / coroutines / atomicfu / kvbuffer…) | ❌ |
| 第三方库 | 65 | 换 npm 依赖(React 18.3.1 / Redux / Sentry 5.30.0 / qrcode / Underscore 1.13.6 / Emotion / fflate…) | ❌ |
| 资源/索引 | 2 | index.json、resources.json 原样托管 | ❌ |
**游戏本体的体积分布(决定推进顺序)**:
| 模块 | 大小 | 职责 | 阶段 |
|---|---|---|---|
| 82347 | 5.50 MB | 大厅/车库/商店/部落/结算 UI | S6(最后) |
| 87072 | 3.78 MB | 战斗系统:底盘物理、模式、物品池、特效、空投、地图渲染 | S5 |
| 10032 | 1.54 MB | 核心 Redux 状态 | S3 |
| 85183 | 1.18 MB | 全量协议 Codec 注册 | S2 |
| 92980 | 0.83 MB | WebGL 渲染核心 | S4 |
| 其余 35 个 | < 5 MB | 协议/工具/音频/输入/资源/ECS/物理… | S2–S5 |
前 5 个约占 12.8 MB,是工作量的绝对主体。
---
## 3. 工具清单与用法
### 3.1 基线与拆分
| 工具 | 作用 | 用法 |
|---|---|---|
| `tools/split.js` | 把 `main.<hash>.js` 切成 126 个模块 + `resources.json`,并注入 bundle 加载 | `node tools/split.js [bundle] [输出目录] --clean`(不传参自动取根目录最新 `main.*.js`) |
| `3DTank/tools/extract.js` | bundle → prettier 美化源码(`3DTank/src/`) | `cd 3DTank/tools && node extract.js [bundle] [输出目录]` |
| `tools/pack_unpacked.js` | 把 `unpacked/` 打成 `unpacked.pack.json`(一次请求加载全部模块) | `node tools/pack_unpacked.js` |
| `tools/test_loader.js` | 模拟浏览器加载流程,验证切割产物完整 | `node tools/test_loader.js` |
### 3.2 命名还原
| 工具 | 作用 | 用法 |
|---|---|---|
| `3DTank/tools/mine_evidence.js` | 从源码挖命名证据(组件名/类名/枚举/闭包原名/导出表/字符串) | `cd 3DTank/tools && node mine_evidence.js` |
| `3DTank/tools/apply_names.js` | 把证据回填成 `src_named/`(三道校验) | `node apply_names.js --aliases` |
| `tools/map_symbols.js` | 新旧 bundle 符号映射(K 元块锚点 + 投票) | `node tools/map_symbols.js <旧unpacked> <新bundle> all tools/symbol_map.json` |
| `tools/apply_symbol_map.js` | 按映射批量改名(五道安全规则 + 双向校验) | 见文件头注释;默认读 `3DTank/tmp_symbol_map.json` |
| `3DTank/names/module-graph.json` | 模块依赖图(`DependsOn` / `Exports` / `Anchors` / `Kind`) | 数据文件,作为 Kotlin 模块切分依据 |
### 3.3 字段抽象(当前版本适配的主入口)
| 工具 | 作用 | 用法 |
|---|---|---|
| `tools/hornet_schema.js` | 匿名字段 → 语义字段总表(88 项)+ 导出键表 | 控制台 `__hornetSchema__.SelfCheck()` 一次列出失配项 |
| `build_bundle.py` | 把 `tools/hornet_*.js` 拼成 `3dtank_bundle.js` 并同步到 `unpacked/` | `python build_bundle.py` |
换版本时**只改 schema**,工具代码不动;这是本计划里"漂移成本最低"的一环。
### 3.4 本地运行与协议取证
| 工具 | 作用 | 用法 |
|---|---|---|
| `3DTank/tools/serve.js` | 本地静态服务器 + 反向代理(resources / config / balancer)+ 中继 config 改写 | `node serve.js --port 8080 [--relay-port 9091] [--verbose]` |
| `3DTank/tools/wss_server.js` | 本地游戏通信服务器(中继 + 抓包 + 实时解密) | `node wss_server.js --port 9091 --upstream wss://c1.3dtank.com:9090/` |
| `3DTank/tools/analyze_packets.js` | 抓包产物分析 | 见文件头 |
| `3DTank/tools/prefetch_assets.js` | 按 `resources.json` 预拉资源到本地镜像 | `node prefetch_assets.js` |
| `3DTank/tools/server/` | 协议服务端骨架(codec / dispatch / session / space / world…) | 供 S3 协议层对照 |
### 3.5 HORNET 探针(现成的行为断言)
`tools/hornet_lib.js` / `hornet_radar.js` / `hornet_3d_wireframe.js` / `hornet_noclip.js` / `hornet_noknockback.js` 等,
直接读引擎状态(位置、血量、队伍、物理形状)。S5 之后可用它们做"新包是否语义一致"的活体探针。
---
## 4. 阶段计划
### S0 基线重建(必须先做)
- **目标**:产出**当前版本**的职责表与依赖图,替换 `dir.file` 的旧数据。
- **做法**:
1. `node tools/split.js --clean`
2. `node 3DTank/tools/extract.js ../main.7cfa4058.js ../src`
3. `node mine_evidence.js`(更新 `evidence.json`)
4. 以 `module-graph.json` 的 `DependsOn/Exports/Anchors/Kind` 重新分类 126 模块
- **输出**:`3DTank/names/module-graph.json` + 新版职责表(按当前模块 id)
- **验收**:`node tools/test_loader.js` 全绿;分类计数与旧表基本一致(40/21/65 量级)
### S1 依赖还原(86 个库)
- **目标**:把库模块映射为上游依赖,产出 `deps.lock`。
- **做法**:用 `Anchors`(版本字符串、API 名)反推包名与版本;导出表对照 API 面。
- **已知**:React/React-DOM 18.3.1、Sentry 5.30.0、Underscore 1.13.6。
- **待挖**:Kotlin 编译器版本、kotlinx-coroutines / serialization / atomicfu 版本、kotlin-wrappers、Emotion。
- **验收**:每个库模块都能指到"包名 + 版本 + 用到的导出符号"。
### S2 协议层(14 个 codec 模块)
- 模块:85183 / 47243 / 85196 / 31908 / 16100 / 10283 / 25377 / 54199 / 37504 / 88693 / 89803 / 85667 / 67222 / 99527
- **目标**:Kotlin 数据类 + `@Serializable`,按 **命令 id + 字段序号** 对齐。
- **验证**:用 `wss_server.js` 抓到的帧做 golden 编解码对比(现有 `names/*_table.json`、`codec_fields.json` 可直接用)。
- **注意**:字段顺序/默认值错一位整包不可用,必须逐帧验。
### S3 状态与胶水
- 模块:10032(Redux 核心)、18334(DI 装配)、61860(连接与对象模型)、13431(启动)、52943(工具库)、26482(SW 注册)
- **目标**:新工程能完成"启动 → 连接 → 进入大厅"。
- **验收**:L2 的一半(登录 + 大厅)。
- **进度(Kotlin/JS,2026-10)**:
- 根状态 / 委派表由 `3DTank/tools/gen_root_wiring.js` 从当前 `module_10032.js` 现场解析:
`names/root_wiring.json` + `TankiOffline/.../App/State/RootState.kt`、`RootWiring.kt`。
- 根状态 `z6`(`TOState`)的 **89 个槽位**全部入表;字段顺序取 `toString`,槽位取构造函数 `<名>_1`。
- 根 reducer `k6` 的逐切片委派记全:reducer 引用、委派表达式、顶层 `instanceof` 动作名。
- 已迁移切片 **89/89**:
- `inviteState` → `aD` / `pD.er5`(`InviteReducer.kt`)
- `loginByPassword` → `gD` / `MD.cr6`(`LoginReducer.kt`,含 `SetLogin`/`SetPassword` 把 status 打回 `NONE` 的原语义)
- `twitchState` → `g9` / `y9.gta`(`TwitchReducer.kt`;`SetAccount` 是 Twitch 账号,不是 UID)
- `home` → `s1` / 根 reducer `(inline@home)` 分支(`HomeReducer.kt`;`ShopDiscountNotification`)
- `localBattleUserState` → `lE` / `(inline@localBattleUserState)` 分支(`LocalBattleUserReducer.kt`)
- `shop` → `j4` / `(inline@shop)` 分支(`ShopReducer.kt`):顶层 30 字段 32 分支;
嵌套 `U7`(`Categories`,7 字段)5 臂、`w3`(`ShopItems`,23 字段)32 臂,共 **69 个 instanceof 动作** 全量翻译。
无 `toString` 的 `Yut/F5/g4/X5/w5/o4/m4/k4/i4/t4/f5/v5/p5/H7/s5/o5/_5` 按效果定名
(`ShopLoaded`/`ResetShop`/…/`AddCategoriesWithBonus`/…),原名保留在 `ShopReducer.kt` 注释里。
- `screen` → `WK` / `dY().ks5`(`ScreenReducer.kt`):10 字段,13 个 `instanceof` 臂全量翻译。
`GoTo` 的新栈规则 = `isResetBackStackOnNextGoTo ? [] : history ? stack + current : stack`,
`current` 换成目标屏并清 `isResetBackStackOnNextGoTo` / `isDelayedGoto`;`screen_show` analytics 是副作用,纯 reducer 只保留状态。
屏幕身份用 `App.State.ScreenId`(`EntranceScreen` / `LoaderScreen` / `NamedScreen(name)`);
`LX` / `RX` 是屏幕标识类,已从动作表剔除并记入 `root_wiring.json` 的 `Markers`(`screen-id-class`)。
- `lootBoxes` → `aH` / `EH.wrv`(`LootBoxesReducer.kt`):18 字段,18 个 `instanceof` 臂全量翻译。
`timeToGiveLootBox` 是 Long 毫秒,`SetTimeToGiveLootBox` 按 `Date.now() + seconds * 1000` 写入;
`AddKeys` 沿用原 `Map.getValue` 语义(缺键抛错),`SetRewards` 同时把 `isReopening` 置 false;
`ClearRewards` 清空 `rewards` / `shownRewards`,`ShownRewards` 是追加(原 `List.plus`)。
补给箱类型用 `App.State.ContainerType`(`STANDARD/RARE/EPIC/LEGENDARY`,默认 `STANDARD`);
无 `toString` 的 `mH/kH/zH/gH/CH/SH` 按效果定名(`SetTimeToGiveLootBox`/`SetPeriodOfGiveLootBox`/…)。
- `garage` → `UB` / `hF`(`GarageReducer.kt`):31 字段;58 个 `instanceof` 臂,
另加 `Xd(t, LF)`(`GarageInsertDeviceClientAndServer`,`LF` 是标记接口)与 `yd(t, Nu)`(`xU` 单例)两个非 `instanceof` 臂,**共 60 分支**。
`PB`(`itemsByBase`)用 `GarageBaseItems`,`UF`(`baseItemIdToDevices`)与 `GarageDevice` 强类型;
`aB` 的 `urb_1` = `modification.vcf_1 ?: id`、`trb_1` = `hasDeviceSale && owned`。
`GarageLoaded` 按 depot/mounted/market/skins 重建物品表;`MU` 的 `krg` 是批量 `owned + mounted = true`(不是卸下);
`dU`/`wU` 写整组 `mountedSkin`/`mountedShotSkin`;`GoTo` 仅在目标屏幕为 Garage 时复位 `currentCategory`。
无 `toString` 的 25 个匿名类按效果定名(`rF`→`ResetGarage` … `FB`→`ClearWaitServerItemMount`);
`FX`(Garage 屏幕)与 `UB`(状态类)已从动作表剔除并记入 `Markers`(`screen-id-class` / `state-class`)。
- `garagePreview` → `IG` / `fs.eru`(`GaragePreviewReducer.kt`):22 字段,25 个顶层臂
(22 个 `instanceof` + 3 个单例 `yd(t, is/es/rs)`)。`ShowRequest` 按 `withDelay` 决定
`isVisible = !withDelay && 原值`、`needVisible = withDelay || 原值`;`UpdateViewStyle` 无字段,
原 `t.fru_1` 是 `undefined`,`trs` 视作省略,状态不变。
`SetHullData` / `SetWeaponData` / `SetDrone` / `SetPaint` 先查 `rrs(category, itemId)`
(`expectedItemId[category] == null || == itemId`);命中才写 3D 数据,但无论命中都缓存动作并删 `requestedItems`。
`TankPartData` / `TankPreviewSettings` / `HullEngineType` / `TurretMechanism` 见 `GaragePreviewModels.kt`;
12 个匿名动作按效果定名(`BG`→`CaptureScreenshot` … `tH`→`ClearDroneData`),
3 个单例臂定名 `ClearGaragePreviewParams` / `ShowGaragePreview` / `HideGaragePreview`,不在 `Actions` 表里。
- `entrance` → `hD` / `Bv.pth`(`EntranceReducer.kt`):8 个布尔字段,7 个 `instanceof` 臂。
`showSocialNetworks` 缺省 true,其余 false;`HideSocialNetworks` 置 false,
`SetControlsForChina` / `SetRunningInChinaDataCenter` / `SetRunningInChinaPartners` /
`SetAuthorizedByChinaSdkToken` / `SetClassic` 各置一个 true,`SetAgeWarnShow` 写入 `isAgeWarnShow`。
原 reducer 用 `ur4` 的 `cd` 参数保留其余字段;`authorizedByClassicEarlyAccessToken` 没有写入臂。
- `user` → `t8` / `M8().qtd`(`UserReducer.kt`):32 字段,28 个 `instanceof` 臂。
根委派是 `M8().qtd(t, n.ypu_1)`,全部通过 `qtb`(`t8.copy`,33 参数 = 32 字段 + 回调)写回,`cd` 表示保留原字段。
`id` / `premiumExpirationTime` / `eventPassEndTime` 是 Kotlin/JS `Long`(原构造 `Dd`),
`rankBounds` 保留原 `RankBounds` 形状(默认 `UserRankBounds(0,0)`);`disabledWithoutPremiumLocations` 收口为 `Set<Int>`。
`SetUserPropertiesAction` 只复制 id/货币/rank/分数/rating/uid/观战权限/normalizedRank/profileUrl,不清空未触及字段;
`AddCoins`/`AddRubies` 把 `spent` 收回主余额后清零,`SpendCoins`/`SpendRubies` 同步累加 `spent`。
`UpdateRank` 用 `min(rankNames.size, rank)` 重算 `normalizedRank`;`SetUserId(null)` 归零。
3 个匿名动作按效果定名:`R8`→`DisableLobbyTutorial`、`v8`→`SetInDebt`、`p8`→`ClearInDebt`。
- `settings` → `Uut` / `xw.ltg`(`SettingsReducer.kt`):2 个字段,2 个 `instanceof` 臂。
`currentCategory` 是设置页分类枚举(`ACCOUNT`…`SUPPORT`,默认 `GAME`),
`disableAccountSettings` 默认 false;`Fut`→`SetSettingsCategory`、`Gut`→`DisableAccountSettings`。
- `accountSettings` → `oet` / `Lp.mtg`(`AccountSettingsReducer.kt`):13 字段,13 个 `instanceof` 臂。
`newPasswordStatus` 用 `PasswordStatus`(`NONE/OK/SIMPLE/NOT_EQUALS`);
`ResetPasswordFields` 只清三个密码输入、状态和解锁按钮;`removalTimer` 存时间戳 `Long?`;
`locationCheckEnabled` 没有写入臂;11 个匿名动作按效果定名并登记。
- `proBattle` → `Wtt` / `uv.wth`(`ProBattleReducer.kt`):3 字段,2 个 `instanceof` 臂。
默认 `lastFightBattleId=-1`、`lastFightBattleTeam=NONE`、`battlePasswords=emptyMap()`;
`Fight` 写 id/team,`SetPassword` 空串删键、非空覆盖。登录切片的 `SetPassword` 是不同类,
这里用 `App.State.Reducers.SetPassword(battleId, password)`,避免丢 `battleId`。
- `language` → 枚举值 / `m6().jtg`(`LanguageReducer.kt`):`language` 在根状态里是枚举对象本身,
默认 `EN`;`d6`→`SetLanguage` 只写回语言,不包 StateSlice。
- `announcement` → `lC` / `PC().rq5`(`AnnouncementReducer.kt`):5 字段,4 个 `instanceof` 臂。
`isEnabled` 缺省 true,其余 false/空列表;`bC`→`DisableAnnouncement`、`qC`→`SetAnnouncementModelLoaded`、
`CC`→`SetAnnouncementItems`(生成物 `Init` 属于另一个切片,已改名)、`$C`→`SetSkipNextPromotedAnnounce`。
- `news` → `ZX` / `E_.asd`(`NewsReducer.kt`):4 字段,7 个分支(4 个 `instanceof` + 2 个单例 + `hJ`)。
`SetNewsItemAction` / `AddNewsItemAction` 都按 `compareBy { -dateInSeconds }` 排序(与原包 32 位取负一致),
后者是「追加再排序」;`RemoveNewsItemAction` 按 `id` 过滤,`SetUnreadNewsCounter` 写计数。
单例 `x_`/`j_` 定名 `OpenNews` / `CloseNews`;`hJ` 无字段、只把语义未定的 `p` 置 true,暂名 `SetNewsFlag`。
- `notifications` → `jJ` / `B_.ise`(`NotificationsReducer.kt`):2 字段,4 个 `instanceof` 臂。
缺省 `notifications=emptyList()`、`notificationSound=null`(根状态是 `new jJ()`,生成器认不出类名,迁移表里显式补 `jJ`)。
`AddNotification` 追加、`PJ`→`RemoveNotifications(predicate)` 按谓词过滤、`RJ`→`RemoveFirstNotification` 丢第一条。
- `quests` → `Frt` / `Yrt.otg`(`QuestsReducer.kt` + `QuestModels.kt`):3 字段,4 个分支。
缺省 `currentSection=SPECIAL`、`currentCategory=null`、`isNotCompletedQuests=false`。
`GoTo` 到 `tX`(`QuestCategoryScreen(section, categoryId)`)时切到目标分区,到 `nX`(`ChallengeScreen`)时切 `CHALLENGE`;
原包那条分支还会发 `Wrt(QuestsChangeCategory)` 命令,属于副作用,留给中间件层。
`Hrt` 类体为空、reducer 读的 `t.iu5_1` 是 `undefined`(`cd` 也是 `undefined`),三个 copy 参数全走默认值,
这一支实际是 no-op;Kotlin 侧保留 `SetNotCompletedQuests` 动作但归约时原样返回。
`tX` / `nX` 是屏幕标识类,已从动作表剔除并记入 `Markers`(`screen-id-class`)。
- `lobbyLoader` → `KW` / `Bs.es1`(`LobbyLoaderReducer.kt`):2 字段,3 个分支。
缺省 `isShowLoader=false`、`activeLoadersCount=0`;`FW`→`ShowLoader` 计数 +1 并显示,
`GW`→`HideLoader` 计数 `max(count-1,0)`,显示取 `count != 0`(计数夹到 0 后自动隐藏)。
- `purchaseDialogState` → `CO` / `je.ur2`(`PurchaseDialogReducer.kt`):4 字段,4 个分支。
`NO`→`ResetPurchaseDialog` 复位四字段;`ChangeEmailStatus` 只写 `emailStatus`(`PurchaseEmailStatus`:
`BUSY/FORBIDDEN/FREE`);`SuccessfullyPurchase` 写 withEmail/time,`goods` 是**追加**(`Im` = List.plus)。
- `promoCode` → `cZ` / `Q_.ysg`(`PromoCodeReducer.kt`):1 字段,2 个实例臂。
`ActivatePromoCode` 置 `WAIT`,`SetCodeValidateStatus` 写入服务端状态;
`PromoCodeStatus` = `NONE/INVALID/WAIT/NOT_ACTIVATED/ACTIVATED`,缺省 `NONE`。
- `supportUrl` → `I4` / `Yl.ct7`(`SupportUrlReducer.kt`):1 字段,2 个分支。
`SetUrl` 写链接,`R4`→`ClearSupportUrl` 清成空串。
- `rulesUpdated` → `i1` / `Ch.dsm`(`RulesUpdatedReducer.kt`):1 字段,`UpdateRules` 写正文,缺省 null。
- `notificationByCountry` → `gJ` / `R_.ssd`(`NotificationByCountryReducer.kt`):4 字段,2 个分支。
`Init` 写三条国家提示消息,`qJ`→`MarkHomeScreenDialogShown` 只把 `homeScreenDialogShown` 置 true。
- `availabilityByRank` → `tet` / `$p.xth`(`AvailabilityByRankReducer.kt`):1 字段,
`SetLoadedData` 整体替换等级可购买表,缺省空 Map。
- `icons` → `oH` / `vs.kru`(`IconsReducer.kt`):1 字段,2 个分支。
`SetIcons` 把 `List<IconData>` 收成 `icon → resource` 的 Map(原 `HashMap.put`),缺省空 Map。
- `questCategories` → `zZ` / `th.lsh`(`QuestCategoriesReducer.kt`):3 字段,5 个分支。
`SetQuestCategories` 按 `index` 升序;`RemoveQuestCategory` 按对象删除;
`RequestQuestCategoriesStatistic` 在 `isHiddenUpdate=false` 时把 `statisticLoadedTime` 推到 `Long.MAX_VALUE`;
`SetQuestCategoriesStatistic` 按 `questCategoryId` 建索引并写 `Date.now()`。
- `mainQuest` → `WZ` / `ih.bsk`(`MainQuestReducer.kt`):5 字段,7 个实例臂。
`SetQuests` 把任务按 `completedCount` 展开(`completed && count != 0` 的原始项丢弃,
再为 `1..count` 生成 `completed=true` 的副本),最后按 `completed` 降序稳定排序,并清空已领奖;
`UpdateQuests` 按 `id` 就地替换/追加;`AddRewardedQuest` 里的 `questId` 实际是任务对象;
`VZ`→`ResetQuestsLoaded`(无 `toString`,只 `isLoaded=false`),`KZ`→`QuestsLoaded` 只置 `isLoaded=true`。
- `securitySettings` → `V2` / `pc.qsv`(`SecuritySettingsReducer.kt`):1 字段,缺省 `isProfileBlocked=true`,
`SetProfileBlockStatus` 直接写布尔。
- `referralEvent` → `pQ` / `lh.osk`(`ReferralEventReducer.kt`):14 字段,4 个实例臂。
`SetReferrer` 写前 2 字段、`SetReferrals` 写人数与明细、`SetPromoCode` 写邀请码;
`SetReferralEventData` 一次写后 9 个字段(面板标题 / 弹窗图与背景 / 两个分类 id / 任务数 / 两个隐藏标志 / 公告时间)。
- `tipsState` → `dst` / `Hw.vth`(`TipsStateReducer.kt`):1 字段,`UpdateTips` 整体替换提示列表,缺省空表。
- `rankUpDialogState` → `LO` / `Se.br3`(`RankUpDialogReducer.kt`):1 字段,2 个分支。
`ShowAvailableItems` 是**追加**(`Im`:元素或整表),`FO`→`ResetRankUpDialog` 直接 `new LO()` 清空。
- `battleResult` → `sR` / `Fi.aqp`(`BattleResultReducer.kt`):5 字段,6 个实例臂。
`SetRewards` / `SetHasBattleResult` 写前两字段;`cR`/`lR`→`SetIncreasedReward`/`SetSimpleReward` 写两个奖励数值;
`fR`→`ResetBattleRewards` 只把两数值归零;`DoInit` 重建状态只带 `initialCurrentUserXP`。
- `captchaState` → `jP` / `ar.iqt`(`CaptchaStateReducer.kt`):4 字段,7 个实例臂。
三个位置集合缺省 `emptySet()`;`SetLocations` 用 `toSet()` 收口;
`SP`→`ShowFakeCaptcha` 只置 `useFake`,其余是启用 / 已验证 / 待重置集合的增删元素。
- `modalScreen` → `nV` / `Fs.js1`(`ModalScreenReducer.kt`):2 字段,4 个分支。
`AddModal` 按 `toString()` 判重后 `current=screenId` 并入队;`RemoveModal` 出队后 `current = queue.firstOrNull()`;
`XW`→`CloseCurrentModal` 关当前窗口;`RemoveCurrentByName` 名称不匹配时 no-op。
- `droppableGold` → `Vet` / `Wp.kth`(`DroppableGoldReducer.kt`):1 字段,缺省 `showDroppableGoldAuthor=true`;
`Ket`/`Yet` 两个单例 → `ShowDroppableGoldAuthor` / `HideDroppableGoldAuthor`。
- `eventGoods` → `W7` / `Wc.esx`(`EventGoodsReducer.kt`):1 字段 `Map<Any?, Any?>`,缺省空 Map;
`InitEventGoods(itemId, goods)` 在 `goods == null` 时删键,否则写键值。
- `creatorTagState` → `qO` / `ze.er2`(`CreatorTagReducer.kt`):2 字段,4 个分支。
`creatorTags` 缺省 null;`SetCreatorTags` / `SetCreatorTag` / `SetAttachedCreatorTag` 写对应字段,
`jO`→`ClearCreatorTag` 只清 `creatorTag`。
- `rewardedVideosState` → `JQ` / `xh.zsl`(`RewardedVideosReducer.kt`):1 字段,
新增枚举 `RewardVideoDisplayState { NONE, LOADING, DISPLAY }`,缺省 `NONE`;`SetDisplayState` 整体替换。
- `passwordChangeState` → `VD` / `uu.pr6`(`PasswordChangeReducer.kt`):3 个字符串字段(hash / uid / email),
缺省全为空串;`SetPasswordChangeHash` 只写 hash,`ShowEmailAndPasswordChangeDialog` 写 uid/email 并保留 hash。
- `system` → `_9` / `w9().xt9`(`SystemReducer.kt`):3 字段,2 个分支。
`DoShowMessage(message, restart)` 写前两字段,`SetFatalError` 只写 `fatalError`。
- `contextTutorial` → `Mst` / `Jst().tth`(`ContextTutorialReducer.kt`):4 字段,5 个分支。
`MarkElementAsViewed` 从 `unViewedElements` 删一个元素(`Ym`),其余为整表 / 整 Map 替换。
- `battleTutorial` → `rE` / `Q.sq8`(`BattleTutorialReducer.kt`):3 字段,4 个分支。
`oE`→`MarkMovementStarted` 置 `movementHasStarted`,`sE`→`ResetBattleTutorial` 整片复位。
- `spectator` → `HA` / `Li.jqm`(`SpectatorReducer.kt`):4 字段,6 个分支。
`JA`→`AddSpectator` 追加 `SpectatorInfo`(复用生成物里的同名模型),`ZA`→`RemoveSpectator` 按 userId 过滤,
`XA`→`SetSpectators` 整表替换,`YA`→`SetSpectatorsListVisible`。
`spectatorHudLayout` 的原模型是 15 字段的 HUD 布局对象(战斗层),按 `Any?` 收口,缺省 null。
- `twoStepVerificationState` → `x9` / `_f.atb`(`TwoStepVerificationReducer.kt`):6 字段,5 个分支。
`VerificationStatus` 三态:null 清空、false 写失败、true 直接把 `isActivated2FA` 置 true(原 `rta(cd, !0)`)。
- `antiAddiction` → `UC` / `W.iq8`(`AntiAddictionReducer.kt`):8 字段,5 个分支。
`FC`→`EnableRealNameCheck`、`XC`→`RequireUserIdVerification`(同时置 `unknownUserId`)、
`YC`→`UpdatePlayingTime`;`SetRealNameAndIdNumber` 的参数为 null 时保留原值。
- `moderation` → `NI` / `Ei.oqh`(`ModerationReducer.kt`):11 字段,13 个分支。
选中用户列表用 `ModerationUserRecord` 收口:`GI`/`WI` 先按 userId 去重再追加,`KI` 删除,`YI`/`uA` 清空;
`XI`/`JI` 与 `ZI`/`QI` 分别是战斗信息面板、系统消息的显示/收起,`tA`/`nA` 是封禁菜单。
`battleInfo` 的原模型 `MI` 有 13 字段(战斗层),按 `Any?` 收口。
- `migrationState` → `$it` / `Uv.rtg`(`MigrationStateReducer.kt`):7 字段,11 个分支。
新增 `MigrationLoginStatus { UNKNOWN, FREE, INCORRECT, BUSY, SHORT_NAME }` 与
`MigrationRegisterStatus { UNKNOWN, PASSWORD_CORRECT, PASSWORD_INCORRECT, FAILED }`(对应源码 `Eit()` / `Iit()`)。
`SetUid` / `ForceSetUid` / `SetPassword` 与注册、登录切片共用同一批动作类,因此几片 reducer 同时响应、各自动自己的字段。
- `register` → `Cit` / `cp.stg`(`RegisterReducer.kt`):16 字段,19 个分支。
新增 `UidStatus`、`RegistrationStatus` 两个枚举;`SetUid` / `ForceSetUid` 会复位 uid 校验状态,
且只有原状态不是 `INCORRECT` 时才把 `registerStatus` 一起打回 `UNKNOWN`(原 `2 === n.wtr_1.k3_1` 分支)。
- `userReportsState` → `Zst` / `qot.zth`(`UserReportsReducer.kt`):5 字段,6 个分支。
新增 `ReportKind { BATTLE_NAME, LOBBY_CHAT_MESSAGE, BATTLE_CHAT_MESSAGE }` 与 `ReportEntry(kind, targetId, expireTime)`;
三类举报都追加一条记录、有效期 +1 天。原 reducer 还把列表写回 localStorage 的 `SENT_REPORTS`,
属 storage port 职责,纯 reducer 不做 IO(Init 保留现有列表)。
- `friends` → `KD` / `NL().cr9`(`FriendsReducer.kt`):8 字段,13 个分支。
新增 `FriendSearchState { HIDDEN, SEARCHING, FOUND, NOT_FOUND }`(对应 `YD()`);
四个集合分别做集合 / 单值增删;`SetValidationStatus` 原判据是 `status.equals(pk())`,
`pk()` 是校验通过单例,离线重建按「非空即 FOUND」收口。
`AddUsersToNewAccepted` 与 clan 切片的同名动作合并成一个类(`clanId, userIds`),friends 只读 `userIds`。
- `suppliesState` → `wE` / `$t.fq9`(`SuppliesStateReducer.kt`):1 字段 `Map`,
`qE`→`SetSupplyCount(supplyId, count)` 写一个键值。
- `clanUserInfo` → `hO` / `de.gr1`(`ClanUserInfoReducer.kt`):1 字段 `Map`,
`Loaded(users)` 是**合并**(`jm` = copy + `putAll`),不是覆盖。
- `battleInviteState` → `att` / `Ttt().nth`(`BattleInviteStateReducer.kt`):3 字段,6 个分支。
`mtt` 的 `toString` 名 `InviteToBattle` 与另一个只有 `battleId` 的同名动作冲突,
改用 `AddInvitedUser(receiverId)`;`dtt`→`ClearSelectedForInvite`。
- `partner` → `UJ` / `(inline@partner)`(`PartnerReducer.kt`):15 字段,11 个内联分支。
`_Z`→`UpdatePartnerInviteAction`、`tZ`/`JJ`→`MarkAccountLinked`/`MarkAccountNotLinked`、
`hZ`→`SetPartnerItemEnabled`(`Km` = Pair,写 `itemsEnabledForPartner`);
`loginForBindingStatus` 复用登录切片的 `ByPasswordStatus`。
- `userRenameState` → `O8` / `Rf.pte`(`UserRenameReducer.kt`):10 字段,11 个分支。
新增 `RenameUidState { EMPTY, SHORT, RESERVED, FORBIDDEN, NOT_VALID, FREE }`(对应 `D8()`);
`SetValidationStatus` 按 ordinal 映射输入状态(EMPTY→SHORT、SHORT→NOT_VALID、RESERVED→RESERVED、
FORBIDDEN/NOT_VALID→FORBIDDEN、FREE→FREE)并把 `isResponseDelivered` 置 true;
`G8`→`ResetRenameDialog` 复位弹窗字段但保留 rubies / hash / email。
- `battlePauseState` → `dT` / `NT().mqc`(`BattlePauseStateReducer.kt`):3 字段,4 个分支。
`OnPauseMode` 置 `inAutoPauseMode=true` 并写剩余时长;`ResetBattlePause` 只保留
`idleKickPeriodInMsec`;`timeForAutoLeave` 原值是「当前时间 + 时长」,时钟换算留给中间件层。
- `battleViewState` → `DT` / `sI.jqd`(`BattleViewStateReducer.kt`):7 字段,14 个分支。
`SetVisibility` / `SetElementsVisibility` 按 `visible` 增删 `invisibleElements`;
其余 12 个无 `toString` 的动作两两成对,分别切换输入 / 统计 / 菜单 / 隐藏 / 后台位。
- `tankActionsLogState` → `vI` / `wi.gqe`(`TankActionsLogReducer.kt`):2 字段,4 个分支。
`UpdateLog` 在超过 10 条时先丢弃最早一条再追加(源码 `(Lf = 10)`);
`OnUserJoinTheBattle` / `OnUserLeaveTheBattle` 增删 `noticed`;`dI`→`ResetTankActionsLog` 整片复位。
- `suppliesTutorial` → `ust` / `Uw.uth`(`SuppliesTutorialReducer.kt`):2 字段,2 个分支。
生成器认不出切片类(根构造默认值不是 `new ust()`),在迁移表里显式补 `SliceClass: 'ust'`。
- `clanChatState` → `qnt` / `yv.qtg`(`ClanChatStateReducer.kt`):3 字段,5 个分支。
`$nt`/`xnt` 开关面板,`Configure` 写配置(`ynt` 的 `config` 与 clan 切片同名动作冲突,
整条动作对象按配置载荷收口),`DoAddMessages` 追加后只保留最后 100 条,`Tnt` 整片复位。
- `battleSearchState` → `Itt` / `Htt().vtg`(`BattleSearchStateReducer.kt`):5 字段,9 个分支。
`Fight` 只发副作用、状态不变;`RemoveBattle` 仅当选中 id 匹配时清空;
`selectedFormat` 默认值来自服务定位器,按 `null` 收口。
- `battleStatistics` → `jR` / `rr.fqr`(`BattleStatisticsReducer.kt`):27 字段,22 个分支。
`FR`(14 字段的 Init)与通知切片的同名类冲突,改名为 `InitBattle`(contractState 之后复用);
其余为战斗元信息 setter + 回合 / 统计面板 / 菜单 / 旗主 / 加载位;`SetRemainingTime` 用 `$m` 把时长转秒。
- `soundSettings` → `Det` / `Wet().jth`(`SoundSettingsReducer.kt`):10 字段,4 个分支。
音量缺省 100/100/50/50/50/50/50,三个开关 false/false/true;`Bet`/`Fet` 都改名为
`ApplySoundSettings` / `SetEntranceSoundSettings`(原名都是 `UpdateSettings`,与其它设置切片同名)。
storage 读写属 storage port,`_C`→`ResetSettingsFromStorage` 保留现有状态。
- `gameSettings` → `c2` / `g2().zss`(`GameSettingsReducer.kt`):6 字段,2 个分支。
五个显示开关缺省 true、`mobilePadding=null`;`v2` 同样因 `UpdateSettings` 重名改名为 `ApplyGameSettings`。
- `keyboardSettings` → `Sut` / `bw.oth`(`KeyboardSettingsReducer.kt`):1 字段 `Map<action, Map<index, keyState>>`,5 个分支。
`ChangeAction` 先按 keyState 从其它 index 解绑再写入新绑定;`RemoveAction` 解绑单个 index;
`ResetAction` 依赖外部 helper,纯 reducer 保留原状态;两个默认复位清空绑定表。
- `touchscreenHudSettings` → `j7` / `N7().xsv`(`TouchscreenHudSettingsReducer.kt`):1 字段,3 个分支。
`C7`(原名 `UpdateSettings`)改名为 `ApplyTouchscreenHudSettings`;`E7`→`ResetTouchscreenHudMapping` 恢复默认布局。
- `graphicsSettings` → `L2` / `lc.isu`(`GraphicsSettingsReducer.kt`):17 字段,1 个分支。
`B2`(原名 `UpdateSettings`)改名为 `ApplyGraphicsSettings` 整体替换;画质枚举按 `Any?` 收口,
vsync 变化触发的重启提示是副作用,留给中间件层。
- `controlSettings` → `i2` / `_2().ess`(`ControlSettingsReducer.kt`):7 字段,2 个分支。
嵌套模型收口为 `ControlMouseSettings` / `ControlGamepadSettings` / `ControlTouchscreenSettings`
(缺省 50/true、true/13/13、50/true/false/true/true/true/true);
`u2`(原名 `UpdateSettings`)改名为 `ApplyControlSettings`。
- `battleSettings` → `W0` / `X0().esq`(`BattleSettingsReducer.kt`):11 字段,2 个分支。
`K0`(原名 `UpdateSettings`)改名为 `ApplyBattleSettings`;`_C` 的旧键迁移属 storage port,保留现状态。
- `contractState` → `Ont` / `xv.bti`(`ContractStateReducer.kt`):3 字段,7 个分支。
合同模型跨模块(id / progress / rewards),收口为 `ContractEntry`;`SetContract` 是**合并**(`putAll`);
`UpdateProgress` / `GetReward` 复制单条合同再放回;`Fnt`(原名 `GetReward`,与任务切片同名)改名
`ClaimContractReward`,`Hnt`→`RemoveCompletedContracts` 只留还有奖励的合同;
`InitBattle` 只把 `lastBattleProgress` 归零。
- `tankBuilderState` → `brt` / `wp.ati`(`TankBuilderStateReducer.kt`):6 字段,4 个分支。
`SetParts` 把零件表拆成 `hulls`(hullId 列表)与 `weapons`(index → weaponIds)、并写 `paints`;
`SetPartsInfo` 按零件 / 涂装各自的 id 建索引;零件模型跨模块,收口为
`TankBuilderPart` / `TankBuilderPartInfo` / `TankBuilderPaintInfo`;`Trt`→`ResetTankBuilder` 整片复位。
- `battleMessage` → `FE` / `vT().rqb`(`BattleMessageReducer.kt`):1 字段,7 个分支。
四种消息模型按源码 `toString` 原样收口:`TextMessage(type, hideTimeMs, text, isHidden)`、
`LocalizedMessage(…, content, arguments, isHidden)`、`LocalizedUserMessage(…, content, userTeamRelation, userUid)`、
`LocalizedTeamMessage(…, content, teamRelation)`;`HideMessage` 只把 `hideTimeMs` 匹配的消息标隐藏,
`RemoveMessage` 删掉匹配项,`sT`→`ResetBattleMessages` 整片复位。
- `maps` → `OA` / `Oi.sql`(`MapsReducer.kt`):1 字段,1 个分支。
`AddMap` 的动作载荷就是生成物里的 `MapInfoCC`(11 个字段,与源码 properties 一致),
直接复用;reducer 以 `info.mapId` 为键新建 / 复用 `MapsDescriptionEntry`,
再把 `MapsThemeEntry` 写进 `themes[info.theme]`(同地图多主题是合并)。
- `battleChat` → `Q6` / `Hf.wtg`(`BattleChatStateReducer.kt`):6 字段,6 个分支。
`ChatLoaded` 写两个频道开关并置 `loaded`;`AddMessage` 只保留最后 120 条再追加;
`ett`/`utt` 开关输入框、`stt` 取反历史面板、`rtt`→`ResetBattleChat` 整片复位。
消息类型直接复用生成物里的协议模型 `ChatMessage`(13 字段)。
- `remoteUserDataStorage` → `$Q` / `vh.isl`(`RemoteUserDataStorageReducer.kt`):1 字段,8 个分支。
按 userId 就地更新缓存里的用户数据:uid / rank / hasPremium / clanTag / onlineStatus / battle;
`UserLeaveBattle` 在「仍在组里」时保留 `inGroup` 标记,`UserLeaveGroup` 清空 battle;
没有缓存条目的 userId 不新建(原 reducer 直接返回原状态)。
用户数据模型直接复用生成物 `RemoteUserData`,战斗 / 在线 / 会员状态用
`BattleNotifierData` / `OnlineNotifierData` / `PremiumNotifierData`。
- `matchmaking` → `TH` / `JH().qrx`(`MatchmakingReducer.kt`):19 字段,9 个实例臂。
`Configure` 一次写 12 个字段(模式表 / 禁用列表 / 节日与活动 / 团队人数);
`WasRegistered` 写等待时间与模式并清 `requestSent`;`EnterToBattleApply` 同时置两个进行中位;
`WH`→`ResetMatchmakingProgress`、`tW`/`nW`→`StartLoadingBattle`/`CancelLoadingBattle`;
模式表复用生成物 `MatchmakingModeRank`,`rankIndexToMinGS` 收口为 `Map<String, Int>`。
- `battlesSelect` → `fA` / `Mi.vqk`(`BattlesSelectReducer.kt`):15 字段,20 个实例臂。
`AddBattle` 把 `BattleCreateParameters`(+ suspicionLevel)转成 `BattleParams` 并建 users / score 空表;
`AddUsers` 批量写 users / usersTeam 并同步个人分与可疑标记(`AddUser` 复用它);
`SwapTeam` 换队并把该战斗内用户分数归零;`RemoveBattle` / `RemoveUser` 清对应的四张子表;
其余为名字 / 可疑度 / 队伍分 / 个人分 / 资金 / 奖励上限 / 观战标记。
模型全部复用生成物:`BattleParams` / `BattleInfoUser` / `BattleCreateParameters` / `ProBattleRewardLimitData`。
- `battleUsers` → `uP` / `sr.hqs`(`BattleUsersReducer.kt`):13 字段,18 个分支。
用户加入时同时写 onlineUsers / uids / ranks / clanTags / usersPremium / teams / stats;
stats 值是生成物 `UserStat`(deaths / killScore / kills / score / user);
坦克信息与抗性复用 `TankInfoCC` / `TankResistance`;`sP`(旧队伍枚举迁移)按 no-op 收口。
- `challenges` → `CZ` / `nh.hsj`(`ChallengesReducer.kt`):4 字段,9 个分支。
`stars` / `battlePassPackages` 整体替换;`SetChallenge` 按 `version` 建索引、`RemoveChallenge` 按 `version` 删;
`SetBattlePass(flavor)` 追加一个 `Challenge` 战令条目,两个「已领取索引」动作按 flavor 命中后 copy;
`SetRewardForDialog` / `LZ` 依赖 `FZ`/`GZ` 构造与外部刷新,纯 reducer 里按 no-op 收口。
`Challenge` 直接复用生成物模型(9 字段)。
- `clansStorage` → `lO` / `me.vr1`(`ClanStorageReducer.kt`):3 字段,13 个分支。
列表项复用生成物 `ClanInfo`(16 字段);各分支都是对 `ClanInfo` 的 copy:
`MinimalRankChanged`(第 5 参) / `RecruitmentChanged`(第 6 参) / 成员与待审用户(第 9/16 参) /
`UpdateClan`(name/tag/description) / `BuyAdditionalPlace`(maxMembers+1)。
`vO`(原名 `ClanLoaded`,与 clan 切片的 `isLoaded` 同名)改名 `LoadClanIntoStorage`;
`dO`/`gO`(原名 `AddUser`/`RemoveUser`,与战斗列表同名)改名 `AddClanUser`/`RemoveClanUser`;
查找时兼容 Int/Long 两种 clanId。
- `clan` → `jN` / `we.hr0`(`ClanReducer.kt`):36 字段,43 个分支。
成员 / 待审用户 / 人员 / 外战队 / 新申请各有增删两臂;校验状态 4 个 setter + 2 个复位;
`ClanInfoLoaded` 写 6 个身份字段(复用 `ClanInfo`);`UpdateHasClan(false)` 整片复位;
通知计数 3 个直接写 + 2 个已读 + 计数 ±1;其余为中间态三元组、封禁、弹窗与改名价格。
- `chatState` → `LP` / `$r.wqv`(`ChatStateReducer.kt`):16 字段,24 个分支。
`SaveChatMessage` 写消息并优先选中消息里的目标用户;`SetMessages` 整体替换频道表;
`CleanUsersMessage` 按 uid 过滤每个频道;键盘 / 滚动 / 右键菜单各有开关;
`IgnoreUserMessages`/`ShowUserMessages` 增删屏蔽名单(`_C` 重载时清空);
`rN`(用户资料刷新)依赖消息内的用户对象,离线重建按 no-op;`SendMessageToServer` 的时间戳留给中间件。
消息复用生成物 `ChatMessage`。
- `itemNotifications` → `_1` / `A1().dso`(`ItemNotificationsReducer.kt`):1 字段,6 个分支。
`AddItemNotification(s)` 加入(构造 `ItemNotificationData`);`RemoveItemNotification` 先按有效期过滤,
再删掉匹配谓词的通知;`UpdateItemNotification` 对匹配项应用 handler;
`S1` 只做持久化(no-op);`_C` 重载时只保留有效期内的通知。
- `matchmakingGroup` → `rW` / `NW().is0`(`MatchmakingGroupReducer.kt`):4 字段,13 个分支。
组员模型 `MatchmakingGroupMember(userId, isReady, mountItems)`(原包由 DI 服务构造,这里按 userId + 默认值收口);
`AddUser`(与战斗列表同名)改名 `AddGroupMember`;邀请 / 接受 / 拒绝维护 pending 与 members;
`UpdateUserReadiness` / `UpdateMountItems` 按 userId 命中后 copy;选中列表三臂 + `zW` 清空。
- `battleCreateState` → `$6` / `Df.ttg`(`BattleCreateStateReducer.kt`):18 字段,15 个分支。
`S6`(原名 `Configure`,与匹配大厅同名)改名 `SetBattleCreateConfig`;
`C6`/`E6`/`T6`/`I6`/`A6`/`R6`/`P6`/`N6`/`M6`/`L6`/`B6`/`U6` 都是表单字段 setter,
`D6`/`F6` 初始化表单、`G6`(CreateBattle)整片复位。
原 reducer 还读 maps(默认地图名)与 user(等级限制)两个切片;离线重建按「动作携带值直接写入」收口。
- `launchParams` → `(pass-through)`:原根 reducer 对 `hpx_1` 没有调用,缺省是 `ag()` 空 Map;
`RootState.LaunchParams` 收口为 `LaunchParamsStateSlice = Map<String, String>`,不新增 reducer/动作。
- `App/State/RootReducer.kt`:`ReduceRoot(State0, Action)` 归约上述 88 个有 reducer 的切片,
`launchParams` 与其余 **0 个字段**(全部已迁移);
迁移进度用 `RootReducer.Coverage`(`已迁移 89/89`)与 `RootWiring.Unmigrated` 暴露。
- 自检:`TankiOffline/tools/RunStateTest.ps1` **988 项全绿**,含 89 槽位完整性、88 个归约切片一次归约互不串片、未迁移字段透传,
以及用 `action_trace_merged.json` 真实派发的两条商店动作(`SetCoinPaymentProviderUrl` / `ToggleShopActivity`)回放。
- 连接层(S3 起步):
- `App/Session/UplinkEncoder.kt`:按 `SpaceFrame.DecodeBody` 的记录格式实现上行编码
(形态位 0 + objectId + methodId + codec 负载,nullmap 拼在帧体前)。
自检 `tools/RunUplinkTest.ps1`:抓包 c2s 记录重编码,**252/252 帧逐字节一致**。
- `App/Session/Session.kt`:`SpaceSession` 收字节 → 解帧 / 解密 / 解记录,维护密钥与别名表;
`tools/RunSessionTest.ps1`:8000 个 s2c 文件回放,**1338 条记录全部回编码一致**。
- 下一步:动作 ↔ 协议方法映射表(`ProtocolMetadata` 里的名字仍是 `UserPropertiesCCxxxx` 这类混淆名,
需要从 module_18334 的 command / DI 装配里抽语义)、浏览器 WebSocket 传输与大厅渲染。
- 18334 与下行映射(本轮进展):
- 18334 实际只做 DI 装配与 UI 动作派发(509 处 `uar(new …)`),**协议下行 → 动作**的转换不在里面;
转换代码与动作类同处 module_10032(动作直接持有 CC,例如 `SetUserPropertiesAction(user: UserPropertiesCC)`)。
- 新增 `tools/gen_downlink_actions.js`:`method_table.json` → `model_binding.json` → `codec_fields.json`
的 `ModelName`,再与 `Inventory.kt` 的「单字段动作」对齐,生成
`names/downlink_actions.json` 与 `App/State/DownlinkActions.kt`。
覆盖:681 个 methodId 中 **477 个能解析出协议模型名**,其中 **13 个 methodId 自动匹配到动作**
(`UserPropertiesCC`×9 → `SetUserPropertiesAction`、`ReferralEventUserModelCC`×3、`GaragePreviewModelCC`×1)。
- `App/Session/SessionRouter.kt`:解码记录 → `DownlinkActions`(或 Pt 容器的 `DownlinkModelActions`)→ Store。
- 已知缺口:抓包里的 Pt 容器 item id(如 `2114179554921095892`)在 `model_binding.json` 里查不到,
说明现有 modelId→codec 绑定只覆盖 363 个模型;需要补「modelId → 模块/类」的运行时注册表,
才能把大厅容器的模型路由进状态层。当前 `RunSessionTest.ps1` 只断言「会话解码 + 回编码」,
路由计数(已派发/未覆盖)作为诊断输出。
- modelId 注册表(继续补):
- 新增 `tools/gen_model_registry.js`:静态解析各模块构造函数里的
`this.<slot> = new X(lo, hi)` + `ModelReg.n7o(this.<slot>, … new W(b(ModelClass), …))`,
得到 **362 个 modelId**(361 个解析出语义名,如 `ItemCategoryCC` / `BuyableCC` / `UserPropertiesCC`)。
产物:`names/model_registry.json` + `App/State/ModelRegistry.kt`(`ModelNamesByModelId`)。
- `SessionRouter` 把它用于诊断:`RunSessionTest.ps1` 现在会打印「未覆盖协议模型」TOP。
抓包实测:`plain` 目录前 8000 个 s2c 里未覆盖的主要是
`ItemCategoryCC×1918`、`DescriptionModelCC×1883`、`BuyableCC×1879`、`TimePeriodModelCC×1879`、
`ItemModelCC×1879`、`ItemRarityCC×1879`、`PremiumItemCC×1879`、`ItemViewCategoryCC×1879`
—— 即车库 / 商店的物品配置容器,不是用户属性。
- 下一步:给这 8 个 CC 写「CC → 动作」适配器(原包在 module_47243 的模型层里聚合后派发),
用户属性那条链路(`UserPropertiesCC` → `SetUserPropertiesAction`)已经可用。
- 跨模块别名表(`tools/gen_module_imports.js`):
- 模块两种包装都解析:简单模块 `var x = i(<id>)`,核心模块
`!(function (p0…pN) { … })(t, n, i, i(<id1>), …)`(内层参数 p3… 对应尾部 require 列表)。
- 扫描 `alias = <depVar>.$_$.<exportName>`,产出 **126 模块 / 8159 条别名**,
并带 `ByExport` 反向索引(某个导出被哪些模块以什么别名消费)。
产物:`names/module_imports.json`。
- 查询 CLI `tools/find_imports.js`:
`node find_imports.js 10032::b1q`(谁导入了它)/ `--module 18334` / `--alias 18334 B_`。
实测 `10032::b1q` → 只有 `18334::B_`,即 `SetUserPropertiesAction` 的唯一消费点。
- 用途:定位「CC → 动作」的转换点。当前已知 CC 访问器
(`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`。
- wire → 状态层模型适配(`App/Session/WireAdapters.kt`):
- 修正了一个会静默失败的问题:`DownlinkActions` 之前直接 `Value as UserPropertiesCC`,
但 codec 解出的是 `Net.Generated.UserPropertiesCC`(字段 `Field1…`),
动作要的是 `App.State.UserPropertiesCC`(语义字段),运行时必然 ClassCastException
(会话自检里的「失败 3」就是这类转换)。
- 生成器 `gen_downlink_actions.js` 增加 `Adapters` 表:需要转换的模型改为调用
`WireAdapters.ToXxx(Value as Net.Generated.Xxx)`;当前覆盖 3 个模型
(UserProperties / ReferralEventUserModel / GaragePreview)。
- `UserPropertiesCC` 的 wire 字段 ↔ 语义名取自 module_47243 的 `Gs.t7h`(写入顺序)
与 `fi(lg).toString`(槽位 → 名称),逐位对应关系登记在适配器文件头;
`rankBounds` 的嵌套对象拆成状态层的 `rankBounds` / `topBound` 两个字符串。
- 会话自检新增合成用例:构造一条 `UserPropertiesCC` Pt 记录,断言
`Uid / Id / Crystals / Rank / Score / HasSpectatorPermissions` 写进 `RootState.User`。
- 同名类型冲突:`ProtocolMetadata.Downlink` 里部分方法的实际 Kotlin 类型带后缀
(`GaragePreviewModelCC0004`、`UserPropertiesCC5452` 等,跨切片同名类改名)。
生成器现在读取该表,跳过「实际类型 ≠ 语义模型名」的方法(4 条),
不再生成必然 ClassCastException 的映射;清单记在 `downlink_actions.json` 的
`SkippedTypeConflicts`。
- 浏览器启动器(S3 验收载体):
- `App/Browser/LobbyClient.kt`:大厅 WS(Hash Request → Hash Response / Open Space)→
空间 WS(EnterSpace → LoadDependencies 回 `G.q1wd`)→ `SpaceSession.Feed` → `SessionRouter`。
与 AutoTest 的差别是不退出进程,状态通过 `OnChange` 推给 UI,错误只记日志。
- `App/Browser/BrowserUi.kt` + `app/src/jsMain/resources/index.html`:极简 DOM 视图,
展示连接状态、用户属性、当前屏幕、物品目录规模与最近日志。
- `Main.kt` 在非 Node 环境进入浏览器入口;Node 下的自检逻辑原样保留。
- 运行:`gradlew :app:jsBrowserDevelopmentRun`(默认 `http://localhost:8080/`),
可用 `?lobby=ws://…&space=ws://…` 预填地址。
- `webpack.config.d/node-fallback.js` 把 `fs` / `path` 解析成空模块:
jsMain 与 Node 自检共用源码,浏览器分支不会调用那些 `module.require`,但 webpack 会静态扫描到。
- 委派表里的已知形态:`shop` / `home` / `localBattleUserState` / `partner` 是内联 IIFE,
已按字段具名为 `(inline@<field>)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个);
`launchParams` 是纯透传(`(pass-through)`,无 reducer),已把字段收口为 `Map<String, String>`。
- 无 `toString` 的默认动作按证据定名(`root_wiring.json` 的 `AnonymousActions`):
`o1` → `ShowShopDiscountNotification`(`$_$.gl` 单例,由 module_18334 的 `Qx.qge` 派发;
效果是把 `HomeScreen.shopDiscountNotification` 置 true,对应主菜单商店项的折扣提示);
shop 的 17 个匿名类按 reducer 效果定名并登记(`Yut`→`ShopLoaded`、`F5`→`ResetShop` 等);
screen 的 9 个、lootBoxes 的 6 个、garage 的 25 个、garagePreview 的 15 个、
entrance 的 6 个、user 的 3 个、settings 的 2 个、accountSettings 的 11 个、
language 的 1 个、announcement 的 3 个、news 的 3 个(含两个单例)、notifications 的 2 个、
quests 的 1 个、lobbyLoader 的 2 个、purchaseDialogState 的 1 个、supportUrl 的 1 个、
notificationByCountry 的 1 个、mainQuest 的 1 个、rankUpDialogState 的 1 个、battleResult 的 3 个、
captchaState 的 5 个、modalScreen 的 1 个、droppableGold 的 2 个、creatorTagState 的 1 个、
battleTutorial 的 2 个、spectator 的 4 个、antiAddiction 的 3 个、moderation 的 13 个、
迁移 / 注册共用的 10 个(`Mit`…`_rt`)匿名类 / 单例同样按效果定名并登记。
friends 的 2 个、suppliesState 的 1 个、battleInviteState 的 2 个、partner 的 4 个、
userRenameState 的 5 个也已登记(其中 `mtt` 因同名类冲突改名为 `AddInvitedUser`);
battlePauseState 的 2 个、battleViewState 的 12 个、tankActionsLogState 的 1 个同样按效果定名。
clanChatState 的 3 个(`$nt`/`xnt`/`Tnt`)、battleSearchState 的 1 个也已登记。
battleStatistics 的 12 个(含改名的 `FR`→`InitBattle`)同样按效果定名。
设置类切片的 `_C`(共用重载)+ `Bet`/`Fet`/`Uet`/`v2`/`C7`/`E7`/`Iut` 也已登记,
其中原名都是 `UpdateSettings` 的类按切片改名为 `Apply*`,避免生成物同名冲突
(`Bet`/`v2`/`B2`/`u2`/`K0`/`C7` 共 6 个)。
contractState 的 `Fnt`/`Hnt` 也已登记(`Fnt` 因与任务切片同名改名为 `ClaimContractReward`);
tankBuilderState 的 `Trt`(`ResetTankBuilder`)同样按效果定名。
battleMessage 的 `sT`(`ResetBattleMessages`)也已登记。
### S4 引擎核心
- 模块:32088(3D 数学)、92980(渲染)、61378(ECS)、55088(物理)、36659 + 71105(资源)、36734(音频)、3569(输入)
- **目标**:场景可渲染、物理可步进。
- **做法**:先纯函数层(矩阵/几何/物理步进),再渲染管线;用同输入同输出做单元比对。
- **验收**:车库 3D 预览可显示;进战斗不崩。
### S5 战斗系统(87072,3.78 MB)
- 按子系统拆文件:底盘/悬挂、物理、战斗模式、物品池、空投、特效、地图渲染。
- **验收**:L2 完整(可进战斗并驾驶/开火),HORNET 探针读数与原包一致。
### S6 UI(82347 5.5 MB + 21300 HUD)
- **建议最后做**,或长期保留原 JS 模块(Kotlin 与 JS 模块可混跑)。
- **验收**:各界面可打开、无报错;不追求像素级一致。
---
## 5. 目录规划(拟)
```
KT/
settings.gradle.kts
build.gradle.kts
app/ # 入口(对应 13431)
core/ # 数学 32088、工具 52943
ecs/ # 61378
physics/ # 55088
render/ # 92980
resource/ # 36659、71105
net/ # 61860 + codec/*(S2 全部模块)
state/ # 10032、18334
battle/ # 87072(内部再按子系统分文件)
ui/ # 82347、21300(可延后)
deps/ # React/Redux/Sentry 的 external 声明
web/ # index.html、resources.json、静态资源
```
约定:
- 一个 webpack 模块 ≈ 一个 Kotlin 文件(巨型模块按类拆);
- 对外属性名保持不变(跨模块共享),类名可改;
- 浏览器 API 全部 `external`(WebGL2 / WebSocket / PointerLock / WebAudio / Storage / Worker)。
---
## 6. 依赖与版本
| 依赖 | 模块 | 版本 | 状态 |
|---|---|---|---|
| kotlin-stdlib-js | 37036 | 待挖 | — |
| kotlinx-serialization-core / json | 9539 / 61430 | 待挖 | — |
| kotlinx-coroutines | 89613 | 待挖 | — |
| atomicfu | 71426 | 待挖 | — |
| kvbuffer | 72834 | 待挖 | — |
| React / React-DOM | 78931 / 45555 | 18.3.1 | ✅ |
| Sentry Browser SDK | 30285 | 5.30.0 | ✅ |
| Underscore | 94965 | 1.13.6 | ✅ |
| Redux(Kotlin 侧) | 10849 | 自研封装 | 需评估 |
| qrcode 系列 | 59411 等 | 待挖 | — |
| Emotion / inline-style-prefixer | 14766 / 77249 等 | 待挖 | — |
| fflate | 29518 | 待挖 | — |
---
## 7. 换版与漂移处理
1. 抓新 `main.<hash>.js` → `node tools/split.js --clean`
2. `node tools/map_symbols.js <旧unpacked> <新bundle> all tools/symbol_map.json`
3. `__hornetSchema__.SelfCheck()` 列出语义字段失配 → **只改 `hornet_schema.js`**
4. 人工核对三类:
- `$_.xx` 导出键(队伍 `vd/dl`、碰撞世界 `55088.$_.h` 等)
- 工具 hook 的类内部方法(`o37` / `o3c` / `n3c` / `f3t` / `q9q` / `kz7`)
- 渲染管线字段语义(`d75_1` / `w39_1` / `b74_1` …)
5. `python build_bundle.py` → 实测工具
---
## 8. 产出"可启动 JS"的端到端流程
### 8.1 运行时模块来源
默认:**`3dtank.com/play/static/js/unpacked`**(切割产物,含 4 个模块的人工补丁)。
可选:`-UseSrc` 改用 `3DTank/src`(纯净还原;人工补丁需先移植过去)。
| 行为 | 说明 |
|---|---|
| 启动时打包 | 单包缺失、或所选目录的 `module_*.js` 比单包新 → 自动跑 `pack_unpacked.js` |
| `-Build` | 强制重打单包 |
| `-UseSrc` | 改用 `3DTank/src`(`-Split` 为兼容旧参数,等同默认) |
| 路由 | `serve.js --src <目录>`:仅 `-UseSrc` 时传入;`unpacked/module_*.js`、`resources.json`、`index.json`、`unpacked.pack.json` → 源码目录 |
| 仍取镜像 | 加载器 `main.<hash>.js`、`unpacked/hornet_bundle.js`(HORNET 工具链) |
打包命令(按来源目录二选一):
```bash
node tools/pack_unpacked.js 3dtank.com/play/static/js/unpacked 3dtank.com/play/static/js/unpacked.pack.json
node tools/pack_unpacked.js 3DTank/src 3DTank/src/unpacked.pack.json
```
### 8.2 完整流程
```bash
# 1) 基线
node tools/split.js --clean
node 3DTank/tools/extract.js ../main.7cfa4058.js ../src
cd 3DTank/tools && node mine_evidence.js && cd ..
# 2) Kotlin 编译(工程建好后)
./gradlew :app:build # 产物:build/dist/js/productionExecutable/
# 3) 产物接入(二选一)
# A. 用 Kotlin 产物直接替换 main.<hash>.js(需自建 webpack runtime)
# B. 保留现有 loader,把 Kotlin 产物作为新模块注册进 __modules__
node tools/pack_unpacked.js
# 4) 验收
node tools/test_loader.js # L1
node 3DTank/tools/serve.js --port 8080 # L2:浏览器进站
node 3DTank/tools/wss_server.js --port 9091 --upstream wss://c1.3dtank.com:9090/ # L3:抓包对比
```
---
## 9. 风险与回退
| 风险 | 影响 | 应对 |
|---|---|---|
| 协议字段序号错位 | 整包不可用 | S2 逐帧 golden 对比;先只做只读命令 |
| Kotlin 版本差异 | 输出与原包不同 | 不追求字节一致,只追求语义等价(双跑) |
| 巨型模块拆分失控 | 进度不可控 | 87072/82347 按子系统拆文件,先骨架后逻辑 |
| 渲染管线语义不明 | 画面异常 | 保留原 92980 模块,先替换非渲染层 |
| UI 体量过大 | 拖慢整体 | S6 延后,允许长期混跑原 JS 模块 |
| 换版漂移 | 工具失效 | schema + `SelfCheck()` 作为唯一适配面 |
**回退策略**:每个阶段都保留"原模块可用"的开关;Kotlin 模块按模块 id 替换,出问题单模块回退,不影响整包。
---
## 10. 待确认
1. `32088`(标注"Alternativa3D,Kotlin 编写")是自研移植还是开源库?决定 S4 是"引入"还是"重写"。
2. `10032` / `10849`(Redux)是否必须保持 Kotlin 实现,还是可以换成原生 Redux?
3. S6 UI 是否允许永久保留原 JS 模块(推荐允许)。
4. 是否要求最终产物能**替代** `main.<hash>.js`,还是只要"能启动 + 职责等价"即可。
+5
View File
@@ -1,5 +1,10 @@
# TOState 字段 ↔ reducer 映射表
> **版本说明(2026-10)**:本文依据旧版 `names/tostate-map.json`(根状态 `w8` / 根 reducer `v8`、
> 槽位 `app_1` / `bpp_1`)写成,与当前 `src/module_10032.js`(根状态 `z6` / `TOState`、根 reducer `k6`、
> 槽位 `<名>_1`)符号名不同,**只用于交叉验证动作归属**。当前源码口径以 `3DTank/tools/gen_root_wiring.js`
> 现场解析出的 `names/root_wiring.json` 与 Kotlin 侧 `App/State/RootWiring.kt` 为准。
本文是 `names/tostate-map.json` 的人类可读版,用途:**重写客户端时对照根状态的每个分片该由哪个 reducer / 哪些 action 驱动**。
## 1. TOState 是什么
+221
View File
@@ -0,0 +1,221 @@
# TankiOffline UI 重建计划书(全站 UI)
> 目标:把原客户端(`module_82347` 大厅/车库/商店/军团/结算 UI + `module_21300` 战斗 HUD)
> 还原到 Kotlin/JS 新工程,最终覆盖**全站所有界面**,并与真实状态 / 协议数据打通。
> 本文件用于对齐范围、技术栈、里程碑与验收口径。
## 0. 已确认决策(2026-10-25)
| 议题 | 决策 |
|---|---|
| 技术栈 | **Kotlin/JS + kotlin-react + Emotion**(与现工程同栈) |
| 还原精度 | **组件级还原**;背景/摄像机是动态的,不做像素 diff,由人工验收,尽可能贴近 |
| 数据源 | **本地 mock 服务器**(协议级 mock,稳定产出用户/物品/大厅数据) |
| 原版账号 | 有(`WYT2WYT`),仅用于线上获取必要信息(DOM/CSS/资源/文案),**不做逐屏截图对拍** |
| 3D | **现在接 WebGL**:先做 3D 资源加载器与车库预览,再逐步接 `92980` 渲染管线 |
| 首批屏幕 | **大厅骨架 + 车库** |
| 资源部署 | 开发用本地镜像;生产走 CDN / 反向代理 |
| 混跑策略 | 允许长期保留部分原 JS UI,按屏幕逐步替换为 Kotlin |
---
## 1. 调研结论(先纠正三个认知)
### 1.1 原前端不是手写 React,而是 **Kotlin/JS + React + Emotion**
证据:
| 证据 | 说明 |
|---|---|
| `module_glossary`:`78931` = React 18.3.1、`45555` = React-DOM 18.3.1 | 运行时确实是 React |
| `94090` = “React 封装的内部 UI 构建工具 RBuilder” | Kotlin 侧的 React DSL |
| `82347` 的代码形态:`Sq(d7n, "MainScreenComponentStyle", $q, h$)`、`this.aux([], K2n)`、`Eq("containerPanel", 1, …)` | 这是 Kotlin/JS 编译后的 styled-component 构造,不是手写 JS |
| 类名如 `MainScreenComponentStyle-containerPanel`、`RoundBigButtonComponentStyle-outerCircle` | Emotion 生成的组件样式名 |
| `82347` 依赖 `10032 / 18334 / 61860 / 87072 / 92980 / 61378 / 85196 …` | UI 直接吃状态、服务、渲染、战斗系统 |
**结论**:另起一个 React/TS 工程不是“更原版”,反而会丢掉 Kotlin 数据模型。
推荐在现有 Kotlin/JS 工程里加 **kotlin-react + Emotion**,逐屏移植;纯 React/TS 只作为备选。
### 1.2 原 UI 的体量与耦合
- `82347`:5.50 MB,约 21.3 万行,覆盖大厅 / 车库 / 商店 / 军团 / 好友 / 新闻 / 设置 / 结算 / 弹窗。
- `21300`:战斗内 HUD(血条、准星、道具、计分板、聊天…)。
- 大量组件直接依赖:
- 状态:`RootState` 的 89 个切片(已迁移 89/89);
- 服务:`18334` 的 509 处动作派发、物品目录(已迁移 `ItemCatalog`);
- 渲染:`92980` WebGL(车库 3D 预览、战斗场景);
- 战斗:`87072`(HUD 数据)。
- 因此 UI 必须分层:**布局/样式可先独立还原,数据和 3D 后接**。
### 1.3 资源已经齐了
- 镜像:`3DTank/web/play/static`(1239 文件 / 71.4 MB,含 images / fonts / translations / sound / videos / textdata)。
- 已复制到:`TankiOffline/app/src/jsMain/resources/play/static`,开发服务器直接以 `/play/static/...` 提供。
- 官方翻译:`/play/static/translations/zh-CN.61d6ab60.json`(运行时加载,已接)。
- 可直接引用的资源 id → 路径:`3DTank/src/resources.json` 的 `resourceMap`。
- 可选:`3DTank/Start-LocalAndRelay.ps1` 启动镜像 + 中继(抓包/真实服务器)。
---
## 2. 技术方案
### 2.1 推荐方案 A:Kotlin/JS + kotlin-react + Emotion(与现工程同栈)
```
TankiOffline/
ui/ # 新:React 组件(Kotlin)
src/jsMain/kotlin/Ui/
Theme.kt # 颜色/字体/间距 token(从原 Emotion 样式抽出)
Components/ # Button / Panel / Tabs / Dialog / ItemCard ...
Screens/ # Lobby / Garage / Shop / Battles / Quests ...
Layout/ # MainScreen / Header / Menu / Chat / Footer
app/ # 入口:Entrance → 连接 → Lobby
state/ # 已有 RootState
net/ # 已有协议
```
依赖:
```kotlin
implementation(npm("react", "18.3.1"))
implementation(npm("react-dom", "18.3.1"))
implementation(npm("@emotion/react", "11.x"))
// Kotlin 侧可用 kotlin-react / kotlin-emotion 包装,或直接 external 声明
```
数据流:
```
SessionRouter (Store) ──订阅──> UiStore (Adapter) ──props──> React 组件
ItemCatalog ───────────────────┘
Session 状态(连接/帧/日志)─────┘
```
优点:复用现有状态类;一套 Gradle 构建;后续可直接接 3D/战斗。
缺点:Kotlin React 组件写法与原始编译产物不完全一致,需要逐屏翻译。
### 2.2 备选方案 B:独立 React/TS 工程
```
webui/ (Vite + React + TS + Emotion)
↕ WebSocket / postMessage
TankiOffline (Kotlin/JS 客户端)
```
优点:前端生态成熟、UI 开发快、便于像素级还原。
缺点:状态模型要跨语言复制/序列化;构建两套;长期维护成本高。
### 2.3 备选方案 C:混跑原 JS 模块
直接把 `module_82347` 作为 external 挂到 `window.__modules__`,用现有 Kotlin 状态通过 bridge 驱动。
优点:**保真度最高**、最快看到完整原版 UI。
缺点:不是“重建”,依赖原 bundle;长期难以维护。
可作为 **过渡验收**(视觉基准)而不是最终形态。
### 2.4 保真度分层
| 级别 | 内容 | 验收 |
|---|---|---|
| L0 | 结构骨架(当前) | 能导航、无报错 |
| L1 | 官方资源 / 字体 / 布局比例 | 截图肉眼接近 |
| L2 | 像素级静态还原 | 与原版截图逐屏 diff |
| L3 | 真实数据 + 交互 | 状态变化驱动 UI |
| L4 | 3D 车库 / 战斗场景 | WebGL 管线接入 |
---
## 3. 里程碑
### M0 基础设施(进行中)
- [x] 资源镜像接入(`/play/static`)。
- [x] 登录页 1:1(EntranceView + entrance.css)。
- [ ] `ui` 模块启用 kotlin-react + Emotion,建立 `Theme.kt`。
- [ ] 协议级 mock 服务器(用户属性 / 物品目录 / 大厅列表)。
- [ ] `ScreenRegistry`:屏幕 ID ↔ 组件(对齐原 `ScreenId`)。
- [ ] `UiStore`:订阅 `SessionRouter.Store`,节流重绘。
- [ ] 3D 资源加载器(`.3ds` / 贴图 / 场景描述)+ 车库预览最小场景。
- [ ] 截图 / DOM 基准工具(人工验收辅助,不做像素门禁)。
### M1 大厅骨架(2 个迭代)
- 顶栏:等级/昵称/GS/水晶/金币/VIP/设置/声音。
- 左侧菜单:9 个官方图标 + 文案 + 选中态。
- 底部:大号“开始战斗”按钮、聊天面板、新闻条。
- 公告/活动/每日任务浮层。
- 验收:与官方大厅截图逐项对齐(无 3D 场景时用静态背景占位)。
### M2 车库(3 个迭代)
- 分类页签(12 类,来自 `ItemCategory` 枚举)。
- 物品网格:名称/稀有度/数量/VIP/价格;数据源 `ItemCatalog`。
- 物品详情:属性、升级、装备、皮肤。
- 3D 预览:接 M0 的 3D 加载器,先渲染底盘/炮塔/贴图与旋转,再补光照/摄像机。
### M2.5 3D / 渲染(并行)
- 3DS / 贴图 / 资源清单加载。
- 车库场景:平台、背景、坦克组装、光照、摄像机环绕。
- 与 `92980` 渲染管线的接口对齐;最终替换为原引擎实现。
### M3 商店 / 幸运礼盒(2 个迭代)
- 商店分类、卡片、折扣、支付方式、礼盒开启动画占位。
- 数据:`Shop` 切片 + `ItemCatalog`。
### M4 战斗选择 / 任务 / 军团 / 好友 / 新闻(3 个迭代)
- 战斗模式卡片、战斗列表、创建战场表单。
- 任务:主线/每日/每周/星级挑战/合约。
- 军团/好友:列表、搜索、请求、成员管理。
- 新闻:列表 + 详情。
### M5 设置 / 弹窗体系(2 个迭代)
- 设置 8 个页签(游戏/画面/声音/操控/账户/战斗/安全/支持)。
- 通用 Dialog / Toast / 确认框 / 购买弹窗 / 升级弹窗 / 排名弹窗。
### M6 结算与 HUD(依赖 S5)
- 战斗结算、奖励、排名变化。
- 战斗 HUD(`21300`):血条、道具、准星、计分板、聊天。
---
## 4. 每屏的数据契约(先定,避免返工)
| 屏幕 | 主要数据 | 现有来源 | 缺口 |
|---|---|---|---|
| Entrance | 语言/资源/连接状态 | `Translations` / `LobbyClient` | 真实登录 |
| Lobby Home | User / News / Notifications / Quests 摘要 | RootState 已迁移 | 公告、活动配置 |
| Garage | ItemCatalog / Garage 切片 / GaragePreview | 已有 | 3D 预览、装备操作 |
| Shop | Shop 切片 / ItemCatalog | 已有 | 支付流程 |
| Battles | BattlesSelect / Maps / Matchmaking | 已有 | 创建/匹配 |
| Quests | Quests / Challenges / ContractState | 已有 | 领奖链路 |
| Clans | Clan / ClanStorage / ClanChat | 已有 | 社交操作 |
| Friends | Friends 切片 | 已有 | 搜索/请求 |
| News | News 切片 | 已有 | 详情页 |
| Settings | 8 个设置切片 | 已有 | 持久化 port |
| HUD | BattleStatistics / BattleUsers / Supplies | 部分 | S5 战斗系统 |
---
## 5. 验收方式
1. **视觉基准**:原版客户端逐屏截图(需要可登录环境);新 UI 同分辨率截图,人工/像素 diff。
2. **DOM 基准**:同一数据下对比关键元素的尺寸、间距、颜色 token。
3. **交互基准**:点击路径一致(菜单、返回、弹窗层级)。
4. **数据基准**:同一抓包回放后,界面显示与状态断言一致(可自动化)。
---
## 6. 风险与待决事项
| 风险 | 影响 | 对策 |
|---|---|---|
| 没有可登录账号,拿不到原版大厅截图/DOM | 无法做像素级对比 | 用测试账号 / 本地 mock 服务器 / 仅按源码还原 |
| 3D 车库与战斗场景 | 大厅“灵魂”缺失 | 先用静态背景 + 官方素材,S4 后替换 |
| `82347` 21 万行,逐屏翻译量大 | 周期长 | 按 M1–M5 切片,先布局后逻辑 |
| 原样式是 Emotion 动态类名 | 难以直接复用 CSS | 抽 token + 按截图复写;或运行时抓取 CSS |
| 资源 71 MB 进包 | 首屏体积 | 开发用镜像;生产走 CDN/代理 |
---
## 7. 当前进度(本文件创建时)
- 登录页:1:1 完成(官方背景、logo、圆形登录按钮、健康忠告、年龄提示、ClientInfo)。
- 大厅:L0/L1 骨架(官方图标、真实翻译、物品目录、连接状态)。
- 资源:本地镜像已接入。
- 已确认:技术栈 Kotlin/React、组件级还原、本地 mock、3D 现在做、首批大厅+车库、允许混跑。
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"at":"2026-10-21T12:33:52.214Z","count":934,"trace":[{"s":"SetLocations(captchaLocations=[])"},{"s":"[object Object]"},{"s":"SocialNetworkAvailability(socialNetworkAvailability={facebook=false, google=false, apple=true, vkontakte=false})"},{"s":"SocialNetworkClientIds(socialNetworkClientIds={facebook=558265767576435, google=156495950351-9vvpk8ubf4udrdbrvsma33n01gqm484d.apps.googleusercontent.com, apple=com.3dtank.mobile.production, vkontakte=53596836})"},{"s":"SetInviteNeed(inviteNeed=false)"},{"s":"Init(disableRegistrationMessage=null, disableShopMessage=null, homeScreenMessage=null)"},{"s":"SetPasswordRestrictions(minPasswordLength=8, maxPasswordLength=20, passwordNeedLower=true, passwordNeedUpper=true, passwordNeedDigit=true)"},{"s":"[object Object]"},{"s":"[object Object]"},{"s":"GoTo(screen=[object Object], history=false)"},{"s":"[object Object]"},{"s":"Configure(modes=[MatchmakingModeRank [matchmakingMode = QUICK_BATTLE rank = 1 ], MatchmakingModeRank [matchmakingMode = DM_ONLY rank = 1 ], MatchmakingModeRank [matchmakingMode = TDM_ONLY rank = 1 ], MatchmakingModeRank [matchmakingMode = CTF_ONLY rank = 6 ], MatchmakingModeRank [matchmakingMode = CP_ONLY rank = 2 ], MatchmakingModeRank [matchmakingMode = SGE_ONLY rank = 3 ], MatchmakingModeRank [matchmakingMode = AS_ONLY rank = 7 ], MatchmakingModeRank [matchmakingMode = RUGBY_ONLY rank = 5 ], MatchmakingModeRank [matchmakingMode = TJR_ONLY rank = 4 ], MatchmakingModeRank [matchmakingMode = JGR_ONLY rank = 7 ], MatchmakingModeRank [matchmakingMode = HOLIDAY rank = 8 ]], disabledModes=[JGR_ONLY, DM_ONLY], holidayEnabled=true, holidayTitle=节日模式, holidayIcon=[object Object], eventBigTile=[object Object], holidayDescription=专为当前游戏内庆祝活动创建的特殊模式。 仅在有限的时间内可用。, minRankForProBattle=9, maxUsersInGroup=2, groupsInEventMode=false, eventEndDate=[object Object], borderColorForEventButton=FFCC00)"},{"s":"InitProBattleRewardLimit(proLimit=ProBattleRewardLimitData [crystalFundLimit = 60200 crystalFundValue = 0 xpFundLimit = 36400 xpFundValue = 566 ])"},{"s":"LimitReached(isLimitReached=false)"},{"s":"Loaded(users={3074457345817168740=ClanNotifierData [clanId = 0 clanTag = null userId = 3074457345817168740 ]})"},{"s":"SetLocations(captchaLocations=[])"},{"s":"[object Object]"},{"s":"SetUserId(userId=3074457345817168740)"},{"s":"SetPremiumRemainingSeconds(seconds=0)"},{"s":"SetDisabledWithoutPremiumLocations(locations=[])"},{"s":"SetRankNames(rankNames=[新兵, 二等兵, 一等兵, 下士, 中士, 上士, 三级军士长, 二级军士长, 一级军士长, 军士长, 五级准尉, 四级准尉, 三级准尉, 二级准尉, 一级准尉, 特级准尉, 少尉, 中尉, 上尉, 少校, 中校, 上校, 准将, 少将, 中将, 上将, 元帅, 陆军元帅, 统帅, 大元帅, 传奇])"},{"s":"UpdateHasClan(hasClan=false)"},{"s":"UpdateTimeToJoinRestriction(restrictionTimeJoinClanInSec=0)"},{"s":"AddIncomingClan(clanIds=[])"},{"s":"AddNewIncomingClan(clanIds=[])"},{"s":"AddUsersToNewAccepted(clanId=-2144183555, userIds=[])"},{"s":"AddNewIncoming(newIncoming=[])"},{"s":"UpdateClanLicense(hasLicense=false)"},{"s":"AddUsersToAccepted(usersId=[])"},{"s":"SetLimits(acceptedLimit=300, incomingLimit=50)"},{"s":"AddUsersToIncoming(usersId=[])"},{"s":"AddUsersToAccepted(usersId=[3074457345778698355, 3074457345814924878, 3074457345810102728, 3074457345814619490, 3074457345797743185])"},{"s":"AddUsersToNewAccepted(usersId=[])"},{"s":"AddUsersToNewIncoming(usersId=[])"},{"s":"Loaded(users={3074457345817168740=ClanNotifierData [clanId = 0 clanTag = null userId = 3074457345817168740 ]})"},{"s":"UpdateOnlineStatus(status={3074457345817168740=OnlineNotifierData [online = false AbstractNotifier [userId = 3074457345817168740 ]]})"},{"s":"UpdatePremiumStatus(premiums={3074457345817168740=PremiumNotifierData [hasPremium = false AbstractNotifier [userId = 3074457345817168740 ]]})"},{"s":"UpdateRanks(ranks={3074457345817168740=15})"},{"s":"UpdateUids(uids={3074457345817168740=WYT2WYT})"},{"s":"InitProBattleRewardLimit(proLimit=ProBattleRewardLimitData [crystalFundLimit = 60200 crystalFundValue = 0 xpFundLimit = 36400 xpFundValue = 566 ])"},{"s":"LimitReached(isLimitReached=false)"},{"s":"[object Object]"},{"s":"[object Object]"},{"s":"UnsubscribeUpdateUserData(usersId=[])"},{"s":"SetMountedItemsProperties(properties=[ROTATING_WEAPON, HOVER_CHASSIS])"},{"s":"SetUserPropertiesAction(user=UserPropertiesCC [bazookaGearScore = 110 canUseGroup = true crystals = 972 crystalsRating = 0 droneGearScore = 371 eventPassEndTime = 0 gearScore = 3842 goldsTakenRating = 0 hasSpectatorPermissions = false hullGearScore = 1586 id = 3074457345817168740 partnerUid = null rank = 15 rankBounds = RankBounds [lowBound = 156000 topBound = 192000 ] registrationTimestamp = 0 resistanceGearScore = 25 score = 169847 scoreRating = 0 uid = WYT2WYT userProfileUrl = http://ratings.3dtank.com/user/ userRating = 0 weaponGearScore = 1750 ])"},{"s":"[object Object]"},{"sLine truncated
+1
View File
@@ -0,0 +1 @@
{"updatedAt":"2026-10-21T12:31:30.471Z","count":5673,"trace":[{"s":"SetLocations(captchaLocations=[])"},{"s":"[object Object]"},{"s":"SocialNetworkAvailability(socialNetworkAvailability={facebook=false, google=false, apple=true, vkontakte=false})"},{"s":"SocialNetworkClientIds(socialNetworkClientIds={facebook=558265767576435, google=156495950351-9vvpk8ubf4udrdbrvsma33n01gqm484d.apps.googleusercontent.com, apple=com.3dtank.mobile.production, vkontakte=53596836})"},{"s":"SetInviteNeed(inviteNeed=false)"},{"s":"Init(disableRegistrationMessage=null, disableShopMessage=null, homeScreenMessage=null)"},{"s":"SetPasswordRestrictions(minPasswordLength=8, maxPasswordLength=20, passwordNeedLower=true, passwordNeedUpper=true, passwordNeedDigit=true)"},{"s":"[object Object]"},{"s":"[object Object]"},{"s":"GoTo(screen=[object Object], history=false)"},{"s":"[object Object]"},{"s":"Configure(modes=[MatchmakingModeRank [matchmakingMode = QUICK_BATTLE rank = 1 ], MatchmakingModeRank [matchmakingMode = DM_ONLY rank = 1 ], MatchmakingModeRank [matchmakingMode = TDM_ONLY rank = 1 ], MatchmakingModeRank [matchmakingMode = CTF_ONLY rank = 6 ], MatchmakingModeRank [matchmakingMode = CP_ONLY rank = 2 ], MatchmakingModeRank [matchmakingMode = SGE_ONLY rank = 3 ], MatchmakingModeRank [matchmakingMode = AS_ONLY rank = 7 ], MatchmakingModeRank [matchmakingMode = RUGBY_ONLY rank = 5 ], MatchmakingModeRank [matchmakingMode = TJR_ONLY rank = 4 ], MatchmakingModeRank [matchmakingMode = JGR_ONLY rank = 7 ], MatchmakingModeRank [matchmakingMode = HOLIDAY rank = 8 ]], disabledModes=[JGR_ONLY, DM_ONLY], holidayEnabled=true, holidayTitle=节日模式, holidayIcon=[object Object], eventBigTile=[object Object], holidayDescription=专为当前游戏内庆祝活动创建的特殊模式。 仅在有限的时间内可用。, minRankForProBattle=9, maxUsersInGroup=2, groupsInEventMode=false, eventEndDate=[object Object], borderColorForEventButton=FFCC00)"},{"s":"InitProBattleRewardLimit(proLimit=ProBattleRewardLimitData [crystalFundLimit = 60200 crystalFundValue = 0 xpFundLimit = 36400 xpFundValue = 242 ])"},{"s":"LimitReached(isLimitReached=false)"},{"s":"Loaded(users={3074457345817168740=ClanNotifierData [clanId = 0 clanTag = null userId = 3074457345817168740 ]})"},{"s":"SetLocations(captchaLocations=[])"},{"s":"[object Object]"},{"s":"SetUserId(userId=3074457345817168740)"},{"s":"SetPremiumRemainingSeconds(seconds=0)"},{"s":"SetDisabledWithoutPremiumLocations(locations=[])"},{"s":"SetRankNames(rankNames=[新兵, 二等兵, 一等兵, 下士, 中士, 上士, 三级军士长, 二级军士长, 一级军士长, 军士长, 五级准尉, 四级准尉, 三级准尉, 二级准尉, 一级准尉, 特级准尉, 少尉, 中尉, 上尉, 少校, 中校, 上校, 准将, 少将, 中将, 上将, 元帅, 陆军元帅, 统帅, 大元帅, 传奇])"},{"s":"UpdateHasClan(hasClan=false)"},{"s":"UpdateTimeToJoinRestriction(restrictionTimeJoinClanInSec=0)"},{"s":"AddIncomingClan(clanIds=[])"},{"s":"AddNewIncomingClan(clanIds=[])"},{"s":"AddUsersToNewAccepted(clanId=-2144223137, userIds=[])"},{"s":"AddNewIncoming(newIncoming=[])"},{"s":"UpdateClanLicense(hasLicense=false)"},{"s":"AddUsersToAccepted(usersId=[])"},{"s":"SetLimits(acceptedLimit=300, incomingLimit=50)"},{"s":"AddUsersToIncoming(usersId=[])"},{"s":"AddUsersToAccepted(usersId=[3074457345778698355, 3074457345814924878, 3074457345810102728, 3074457345814619490, 3074457345797743185])"},{"s":"AddUsersToNewAccepted(usersId=[])"},{"s":"AddUsersToNewIncoming(usersId=[])"},{"s":"Loaded(users={3074457345817168740=ClanNotifierData [clanId = 0 clanTag = null userId = 3074457345817168740 ]})"},{"s":"UpdateOnlineStatus(status={3074457345817168740=OnlineNotifierData [online = false AbstractNotifier [userId = 3074457345817168740 ]]})"},{"s":"UpdatePremiumStatus(premiums={3074457345817168740=PremiumNotifierData [hasPremium = false AbstractNotifier [userId = 3074457345817168740 ]]})"},{"s":"UpdateRanks(ranks={3074457345817168740=15})"},{"s":"UpdateUids(uids={3074457345817168740=WYT2WYT})"},{"s":"InitProBattleRewardLimit(proLimit=ProBattleRewardLimitData [crystalFundLimit = 60200 crystalFundValue = 0 xpFundLimit = 36400 xpFundValue = 242 ])"},{"s":"LimitReached(isLimitReached=false)"},{"s":"SetMountedItemsProperties(properties=[ROTATING_WEAPON, HOVER_CHASSIS])"},{"s":"Configure(config=ChatCC [admin = false antifloodEnabled = true bufferSize = 60 channels = [ChannelSidAndName [name = 默认 sid = 默认 ]] chatEnabled = true chatModeratorLevel = NONE hashTags = [HashTagData [tag = help text = Help url = https://en.tankiwiki.com/Technical_support ], HashTagData [tag = rules text = Rules url = http://tankionline.com/en/rules/ ], HashTagData [tag = plans text = Developers’ missions and plans url = http://en.tankiwiki.com/Development_Plans ], HashTagData [tag = ranks text = Ranks url = https://en.tankiwiki.com/Ranks_and_Experience ], HashTagData [tag = clans text = Clans url = http://en.tankiforum.com/index.php?showforum=42 ], HashLine truncated
File diff suppressed because it is too large. Load diff
+58
View File
@@ -0,0 +1,58 @@
{
"List": [
{
"Url": "https://3dtank.com/play/static/js/main.0734b57c.js",
"Status": 200,
"Bytes": 9198089,
"Type": "application/javascript",
"Server": "openresty",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://3dtank.com/play/static/js/main.2693cabd.js",
"Status": 200,
"Bytes": 9774295,
"Type": "application/javascript",
"Server": "openresty",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://3dtank.com/play/static/js/main.9bea169a.js",
"Status": 200,
"Bytes": 9416967,
"Type": "application/javascript",
"Server": "openresty",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://3dtank.com/play/static/js/main.ba10fa70.js",
"Status": 200,
"Bytes": 9860514,
"Type": "application/javascript",
"Server": "openresty",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://3dtank.com/play/static/js/main.6469b966.js",
"Status": 200,
"Bytes": 9991139,
"Type": "application/javascript",
"Server": "openresty",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://3dtank.com/play/static/js/main.b7114b43.js",
"Status": 200,
"Bytes": 9949194,
"Type": "application/javascript",
"Server": "openresty",
"Acao": "*",
"Preview": ""
}
]
}
+49
View File
@@ -0,0 +1,49 @@
{
"List": [
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js%2emap",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js.map%20",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js.map/",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js.map;.js",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
}
]
}
+60
View File
@@ -0,0 +1,60 @@
{
"Cors": [
{
"Name": "纯 GET 无任何头",
"Status": 200,
"Bytes": 6892,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "外来 Origin",
"Status": 200,
"Bytes": 3173,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "外来 Referer",
"Status": 200,
"Bytes": 3172,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "空 Referer",
"Status": 200,
"Bytes": 4397,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "站点头(基准)",
"Status": 200,
"Bytes": 4396,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "null Origin",
"Status": 200,
"Bytes": 3156,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "OPTIONS 预检",
"Status": 405,
"Bytes": 559,
"Acao": "",
"Allow": "",
"Server": "openresty"
}
]
}
+357
View File
@@ -0,0 +1,357 @@
{
"Dns": {
"3dtank.com": [
{
"Host": "3dtank.com",
"A": [
"221.229.162.62",
"218.12.77.90",
"218.11.1.241",
"116.153.39.128",
"120.39.165.50",
"106.225.240.24",
"223.247.106.57",
"120.52.95.237"
],
"Cname": [
"3dtank.com.2d1590d5.cdnhwcggk22.com"
]
},
{
"Host": "www.3dtank.com",
"A": [
"120.39.165.50",
"120.52.95.237",
"218.11.1.241",
"218.12.77.90",
"221.229.162.62",
"223.247.106.57",
"106.225.240.24",
"116.153.39.128"
],
"Cname": [
"www.3dtank.com.2d1590d5.cdnhwcggk22.com"
]
},
{
"Host": "res.3dtank.com",
"A": [
"223.247.106.57",
"106.225.240.24",
"116.153.39.128",
"120.39.165.50",
"120.52.95.237",
"218.11.1.241",
"218.12.77.90",
"221.229.162.62"
],
"Cname": [
"res.3dtank.com.2d1590d5.cdnhwcggk22.com"
]
},
{
"Host": "s.3dtank.com",
"A": [
"103.244.232.58"
],
"Cname": []
},
{
"Host": "c1.3dtank.com",
"A": [
"103.244.232.53"
],
"Cname": []
},
{
"Host": "c2.3dtank.com",
"A": [
"103.244.232.54"
],
"Cname": []
},
{
"Host": "c3.3dtank.com",
"A": [
"103.244.232.55"
],
"Cname": []
},
{
"Host": "open-api.3dtank.com",
"A": [
"103.244.232.57"
],
"Cname": []
},
{
"Host": "union.3dtank.com",
"A": [
"117.79.93.105"
],
"Cname": []
},
{
"Host": "dev.3dtank.com",
"A": [
"103.244.232.62"
],
"Cname": []
}
],
"tankionline.com": [
{
"Host": "tankionline.com",
"A": [
"46.174.143.136"
],
"Cname": []
},
{
"Host": "www.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "res.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "static.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "sstatic.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "cdn.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "img.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "images.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "assets.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "s.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "c1.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "c2.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "c3.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "game.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "play.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "patch.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "update.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "updater.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "dl.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "download.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "files.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "media.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "mirror.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "client.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "launcher.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "api.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "m.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "test.tankionline.com",
"A": [
"185.94.109.119"
],
"Cname": []
},
{
"Host": "dev.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "beta.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "new.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "old.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "forum.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "en.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "ru.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "de.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
}
]
}
}
+467
View File
@@ -0,0 +1,467 @@
{
"Dns": {
"3dtank.com": [
{
"Host": "3dtank.com",
"A": [
"183.201.248.44",
"183.201.248.45"
],
"Cname": [
"3dtank.com.2d1590d5.cdnhwcggk22.com"
]
},
{
"Host": "www.3dtank.com",
"A": [
"183.201.248.44",
"183.201.248.45"
],
"Cname": [
"www.3dtank.com.2d1590d5.cdnhwcggk22.com"
]
},
{
"Host": "res.3dtank.com",
"A": [
"183.201.248.45",
"183.201.248.44"
],
"Cname": [
"res.3dtank.com.2d1590d5.cdnhwcggk22.com"
]
},
{
"Host": "s.3dtank.com",
"A": [
"103.244.232.58"
],
"Cname": []
},
{
"Host": "c1.3dtank.com",
"A": [
"103.244.232.53"
],
"Cname": []
},
{
"Host": "c2.3dtank.com",
"A": [
"103.244.232.54"
],
"Cname": []
},
{
"Host": "c3.3dtank.com",
"A": [
"103.244.232.55"
],
"Cname": []
},
{
"Host": "open-api.3dtank.com",
"A": [
"103.244.232.57"
],
"Cname": []
},
{
"Host": "union.3dtank.com",
"A": [
"117.79.93.105"
],
"Cname": []
},
{
"Host": "dev.3dtank.com",
"A": [
"103.244.232.62"
],
"Cname": []
}
],
"tankionline.com": [
{
"Host": "tankionline.com",
"A": [
"46.174.143.136"
],
"Cname": []
},
{
"Host": "www.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "res.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "static.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "sstatic.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "cdn.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "img.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "images.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "assets.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "s.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "c1.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "c2.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "c3.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "game.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "play.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "patch.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "update.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "updater.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "dl.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "download.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "files.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "media.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "mirror.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "client.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "launcher.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "api.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "m.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "test.tankionline.com",
"A": [
"185.94.109.119"
],
"Cname": []
},
{
"Host": "dev.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "beta.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "new.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "old.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "forum.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "en.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "ru.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
},
{
"Host": "de.tankionline.com",
"A": [
"185.65.150.147"
],
"Cname": []
}
],
"3dtk.youxi567.com": [],
"3dtk.2144.cn": [],
"3dtk.iwan4399.com": [],
"3dtk.kuaiwan.com": [],
"youxi567.com": [
{
"Host": "www.youxi567.com",
"A": [
"111.132.38.49"
],
"Cname": [
"www.youxi567.com.wswebpic.com"
]
}
],
"2144.cn": [
{
"Host": "www.2144.cn",
"A": [
"120.222.226.149"
],
"Cname": [
"www.2144.cn.cdn.dnsv1.com"
]
},
{
"Host": "static.2144.cn",
"A": [
"120.222.226.149"
],
"Cname": [
"static.2144.cn.cdn.dnsv1.com"
]
},
{
"Host": "img.2144.cn",
"A": [
"120.222.226.149"
],
"Cname": [
"img.2144.cn.cdn.dnsv1.com"
]
},
{
"Host": "play.2144.cn",
"A": [
"120.222.226.149"
],
"Cname": [
"play.2144.cn.cdn.dnsv1.com"
]
},
{
"Host": "download.2144.cn",
"A": [
"123.206.212.191"
],
"Cname": []
},
{
"Host": "m.2144.cn",
"A": [
"120.222.226.149"
],
"Cname": [
"m.2144.cn.cdn.dnsv1.com"
]
}
],
"iwan4399.com": [
{
"Host": "www.iwan4399.com",
"A": [
"101.43.9.144"
],
"Cname": []
},
{
"Host": "game.iwan4399.com",
"A": [
"111.32.202.19"
],
"Cname": [
"game.iwan4399.com.lxdns.com"
]
}
],
"kuaiwan.com": [
{
"Host": "www.kuaiwan.com",
"A": [
"47.106.241.227"
],
"Cname": []
},
{
"Host": "s.kuaiwan.com",
"A": [
"47.106.241.227"
],
"Cname": []
},
{
"Host": "dl.kuaiwan.com",
"A": [
"120.222.223.174"
],
"Cname": [
"dl.kuaiwan.com.w.kunlunaq.com"
]
},
{
"Host": "download.kuaiwan.com",
"A": [
"120.222.223.179"
],
"Cname": [
"download.kuaiwan.com.w.kunlunaq.com"
]
},
{
"Host": "api.kuaiwan.com",
"A": [
"47.106.241.227"
],
"Cname": []
}
]
}
}
+85
View File
@@ -0,0 +1,85 @@
{
"List": [
{
"Url": "https://3dtank.com/play/static/.DS_Store",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/.DS_Store",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/js/.DS_Store",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/images/.DS_Store",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://s.3dtank.com/.DS_Store",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://res.3dtank.com/.DS_Store",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://3dtank.com/.DS_Store",
"Status": 200,
"Bytes": 14989,
"Type": "text/html; charset=UTF-8",
"Server": "openresty",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/js/.DS_Store",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"List": [
{
"Url": "https://46.174.143.136/static/js/main.f1de53fa.js.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://46.174.143.136/static/js/main.f1de53fa.js",
"Status": 200,
"Bytes": 15642,
"Type": "application/javascript",
"Server": "QRATOR",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://185.65.150.147/static/js/main.f1de53fa.js.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://185.65.150.147/static/js/main.f1de53fa.js",
"Status": 200,
"Bytes": 15642,
"Type": "application/javascript",
"Server": "QRATOR",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://185.94.109.119/static/js/main.f1de53fa.js.map",
"Status": 404,
"Bytes": 548,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://185.94.109.119/static/js/main.f1de53fa.js",
"Status": 404,
"Bytes": 548,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
}
]
}
+76
View File
@@ -0,0 +1,76 @@
{
"List": [
{
"Url": "https://res.3dtank.com/resources/static/images/31.2d481c05.webp",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://res.3dtank.com/resources/index.html",
"Status": 200,
"Bytes": 1,
"Type": "text/html",
"Server": "openresty",
"Acao": "*",
"Preview": " "
},
{
"Url": "https://res.3dtank.com/play/index.html",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://s.eu.tankionline.com/static/images/31.2d481c05.webp",
"Status": 200,
"Bytes": 5580,
"Type": "image/webp",
"Server": "Qrator.CDN",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/play/static/images/31.2d481c05.webp",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://sentry.tankionline.com/api/0/projects/",
"Status": 401,
"Bytes": 59,
"Type": "application/json",
"Server": "QRATOR",
"Acao": "https://3dtank.com",
"Preview": "{\"detail\": \"Authentication credentials were not provided.\"}"
},
{
"Url": "https://sentry.tankionline.com/api/0/organizations/",
"Status": 401,
"Bytes": 59,
"Type": "application/json",
"Server": "QRATOR",
"Acao": "https://3dtank.com",
"Preview": "{\"detail\": \"Authentication credentials were not provided.\"}"
},
{
"Url": "https://sentry.tankionline.com/api/0/internal/health/",
"Status": 401,
"Bytes": 59,
"Type": "application/json",
"Server": "QRATOR",
"Acao": "https://3dtank.com",
"Preview": "{\"detail\": \"Authentication credentials were not provided.\"}"
}
]
}
+76
View File
@@ -0,0 +1,76 @@
{
"List": [
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js.map?1",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/",
"Status": 403,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.js.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/resources.json",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/resources.json",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://3dtank.com/play/static/js/main.f1de53fa.js",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
}
]
}
+139
View File
@@ -0,0 +1,139 @@
{
"List": [
{
"Url": "https://3dtank.com/play/static/js/main.6469b966.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/js/main.6469b966.js.map?1",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://www.3dtank.com/play/static/js/main.6469b966.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://res.3dtank.com/play/static/js/main.6469b966.js.map",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://3dtank.com/play/static/js/main.6469b966.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/js/main.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/js/main.6469b966.js.map.gz",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/images/31.2d481c05.webp",
"Status": 200,
"Bytes": 4286,
"Type": "image/webp",
"Server": "openresty",
"Acao": "",
"Preview": "RIFFÄ\u0015\u0000\u0000WEBPVP8X \u0000\u0000\u0000\u0010\u0000\u0000\u0000\u0000\u0000\u0000\u0000ALPH¶ \u0000\u0000\u0001\u0019…mÛ pŒ‰è(3\u0000\u0001žTr\u0004\u0002Iûƒ¯\u0010\u0011©Á¸mÛ@R÷_¹õ\u0000ºï\u0001\u0011\u00130\u0001ž°í?çÈÿ¿ë~<_¯ª ÚL{æmÛ¶mÛ~¯?¶5»\u000fw¶mk<o»­t§§“ª¼ð¼\u0017UI%UÉˆ ~\u001e¥mL\u0016 áíI\u000e#\"BHÚ~d!\u0018АØve 'E· E\bm7\u0004F©`v¦#Il·’¢;xÙ\u001b¢—$)´½€„"
},
{
"Url": "https://res.3dtank.com/play/static/images/31.2d481c05.webp",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://3dtank.com/resources/static/images/31.2d481c05.webp",
"Status": 200,
"Bytes": 20520,
"Type": "text/html; charset=UTF-8",
"Server": "openresty",
"Acao": "",
"Preview": ""
},
{
"Url": "https://res.3dtank.com/robots.txt",
"Status": 200,
"Bytes": 57,
"Type": "text/plain",
"Server": "openresty",
"Acao": "*",
"Preview": "User-agent: Yandex Disallow: / User-agent: * Disallow: / "
},
{
"Url": "https://res.3dtank.com/crossdomain.xml",
"Status": 200,
"Bytes": 464,
"Type": "text/xml",
"Server": "openresty",
"Acao": "*",
"Preview": "<cross-domain-policy> <allow-access-from domain=\"*.3dtank.com\" to-ports=\"*\"/> <allow-access-from domain=\"*.3dtk.youxi567.com\" to-ports=\"*\"/> <allow-access-from domain=\"*.3dtk.2144.cn\" to-ports=\"*\"/> <"
},
{
"Url": "https://res.3dtank.com/",
"Status": 200,
"Bytes": 1,
"Type": "text/html",
"Server": "openresty",
"Acao": "*",
"Preview": " "
},
{
"Url": "https://3dtank.com/play/",
"Status": 200,
"Bytes": 5681,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<!doctype html><html class=\"notranslate\" translate=\"no\"><head><meta charset=\"utf-8\"/><link rel=\"manifest\" href=\"/play/manifest.webmanifest\"/><meta name=\"viewport\" content=\"width=device-width,viewport-"
},
{
"Url": "https://c1.3dtank.com/config.xml",
"Status": 200,
"Bytes": 286,
"Type": "text/xml;charset=utf-8",
"Server": "nginx/1.17.1",
"Acao": "*",
"Preview": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><cfg xmlns=\"http://alternativaplatform.com/core/config.xsd\"><server address=\"c1.3dtank.com\" version=\"\" mode=\"simple\" wsProtocol=\"WSS\"><ports/><we"
}
]
}
+99
View File
@@ -0,0 +1,99 @@
{
"List": [
{
"Url": "https://tankionline.com/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://tankionline.com/play/",
"Preview": "",
"Error": "ECONNRESET"
},
{
"Url": "https://tankionline.com/s/status.js/",
"Status": 200,
"Bytes": 1129,
"Type": "application/json;charset=utf-8",
"Server": "QRATOR",
"Acao": "*",
"Preview": "{\"linkForDownloadAPK\":\"\",\"maxSupportedAndroidVersion\":2147483647,\"minSupportedAndroidVersion\":7,\"nodes\":{\"main.c1\":{\"endpoint\":{\"host\":\"c1.eu.tankionline.com\",\"status\":\"NORMAL\",\"tcpPorts\":[],\"wsPorts\""
},
{
"Url": "https://s.eu.tankionline.com/",
"Status": 403,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/robots.txt",
"Status": 200,
"Bytes": 57,
"Type": "text/plain",
"Server": "Qrator.CDN",
"Acao": "*",
"Preview": "User-agent: Yandex Disallow: / User-agent: * Disallow: / "
},
{
"Url": "https://c1.eu.tankionline.com/config.xml",
"Status": 200,
"Bytes": 334,
"Type": "text/xml;charset=utf-8",
"Server": "QRATOR",
"Acao": "*",
"Preview": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><cfg xmlns=\"http://alternativaplatform.com/core/config.xsd\"><server address=\"c1.eu.tankionline.com\" version=\"83fabafa6dc46cc3940ff1fd9aee94f3b875"
},
{
"Url": "https://c2.eu.tankionline.com/config.xml",
"Status": 200,
"Bytes": 334,
"Type": "text/xml;charset=utf-8",
"Server": "QRATOR",
"Acao": "*",
"Preview": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><cfg xmlns=\"http://alternativaplatform.com/core/config.xsd\"><server address=\"c2.eu.tankionline.com\" version=\"83fabafa6dc46cc3940ff1fd9aee94f3b875"
},
{
"Url": "https://balancer.eu.tankionline.com/balancer",
"Status": 200,
"Bytes": 1129,
"Type": "application/json;charset=utf-8",
"Server": "nginx/1.17.1",
"Acao": "*",
"Preview": "{\"linkForDownloadAPK\":\"\",\"maxSupportedAndroidVersion\":2147483647,\"minSupportedAndroidVersion\":7,\"nodes\":{\"main.c1\":{\"endpoint\":{\"host\":\"c1.eu.tankionline.com\",\"status\":\"NORMAL\",\"tcpPorts\":[],\"wsPorts\""
},
{
"Url": "https://tankionline.com/en/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://sentry.tankionline.com/api/0/",
"Status": 200,
"Bytes": 44,
"Type": "application/json",
"Server": "QRATOR",
"Acao": "https://3dtank.com",
"Preview": "{\"version\": \"0\", \"user\": null, \"auth\": null}"
},
{
"Url": "https://sentry.tankionline.com/",
"Status": 404,
"Bytes": 548,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly error page"
}
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"Sourcemap": {
"Js": "https://3dtank.com/play/static/js/main.6469b966.js",
"Tail": "n>=0))throw wo(Br(\"Invalid new array size: \"+n+\".\"));return Wo(t,new Float64Array(n))},t.$_$.x6=function(t,n){if(!(n>=0))throw wo(Br(\"Invalid new array size: \"+n+\".\"));return Wo(t,new Float32Array(n))},t.$_$.y6=Pe,t.$_$.z6=Ne,t.$_$.a7=_u,t.$_$.b7=function(t){if(be(t,ci))return t.e1();for(var n=0,i=t",
"MappingUrl": "",
"Candidates": [
{
"Url": "https://3dtank.com/play/static/js/main.6469b966.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html"
},
{
"Url": "https://3dtank.com/play/static/js/main.6469b966.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html"
},
{
"Url": "https://3dtank.com/play/static/js/main.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html"
},
{
"Url": "https://3dtank.com/play/static/js/main.6469b966.js.map?",
"Status": 404,
"Bytes": 572,
"Type": "text/html"
}
]
}
}
+141
View File
@@ -0,0 +1,141 @@
{
"List": [
{
"Url": "http://3dtank.com/play/static/js/main.6469b966.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/js/main.b7114b43.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/js/main.0734b57c.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/js/main.2693cabd.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/js/main.9bea169a.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/js/main.ba10fa70.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body bgcolor=\"white\"> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.11.10</center> </body> </html> <!-- a padding to disable MSIE and Ch"
},
{
"Url": "https://3dtank.com/play/static/js/main.ba10fa70.js",
"Status": 200,
"Bytes": 4932,
"Type": "application/javascript",
"Server": "openresty",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.b7114b43.js.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://dev.3dtank.com/play/static/js/main.6469b966.js.map",
"Preview": "",
"Error": "timeout"
},
{
"Url": "https://dev.3dtank.com/play/",
"Preview": "",
"Error": "timeout"
},
{
"Url": "https://dev.3dtank.com/",
"Preview": "",
"Error": "timeout"
},
{
"Url": "https://test.3dtank.com/",
"Preview": "",
"Error": "ENOTFOUND"
},
{
"Url": "https://c1.test-eu.tankionline.com/",
"Status": 404,
"Bytes": 548,
"Type": "text/html",
"Server": "",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly error page"
},
{
"Url": "https://test-eu.tankionline.com/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://a.test-eu.tankionline.com/",
"Status": 404,
"Bytes": 548,
"Type": "text/html",
"Server": "",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly error page"
},
{
"Url": "https://s1.test-eu.tankionline.com/",
"Status": 404,
"Bytes": 548,
"Type": "text/html",
"Server": "",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly error page"
},
{
"Url": "https://balancer.test-eu.tankionline.com/balancer",
"Status": 404,
"Bytes": 548,
"Type": "text/html",
"Server": "",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly error page"
}
]
}
+86
View File
@@ -0,0 +1,86 @@
{
"List": [
{
"Url": "https://union.3dtank.com/",
"Status": 200,
"Bytes": 167,
"Type": "text/html",
"Server": "Microsoft-IIS/10.0",
"Acao": "*",
"Preview": "<html> <head><title>403 Forbidden</title></head> <body bgcolor=\"white\"> <center><h1>403 Forbidden</h1></center> <hr><center>nginx/1.24.0</center> </body> </html>"
},
{
"Url": "https://union.3dtank.com/play/",
"Status": 404,
"Bytes": 1163,
"Type": "text/html",
"Server": "Microsoft-IIS/10.0",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://union.3dtank.com/play/static/js/main.6469b966.js.map",
"Status": 404,
"Bytes": 1163,
"Type": "text/html",
"Server": "Microsoft-IIS/10.0",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://103.244.232.62/",
"Preview": "",
"Error": "timeout"
},
{
"Url": "http://103.244.232.62/play/static/js/main.6469b966.js.map",
"Preview": "",
"Error": "timeout"
},
{
"Url": "https://open-api.3dtank.com/",
"Status": 200,
"Bytes": 54,
"Type": "application/json",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": "{\"message\":\"404 NOT_FOUND\",\"data\":\"GET\",\"errCode\":500}"
},
{
"Url": "https://open-api.3dtank.com/play/static/js/main.6469b966.js",
"Status": 200,
"Bytes": 54,
"Type": "application/json",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": "{\"message\":\"404 NOT_FOUND\",\"data\":\"GET\",\"errCode\":500}"
},
{
"Url": "https://3dtank.com/play/static/js/unpacked/",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": ""
},
{
"Url": "https://3dtank.com/play/static/js/main.6469b966.js.map.br",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": ""
},
{
"Url": "https://3dtank.com/play/static/js/main.6469b966.js.map?t=1",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": "",
"Preview": ""
}
]
}
+112
View File
@@ -0,0 +1,112 @@
{
"List": [
{
"Url": "https://s.eu.tankionline.com/static/.DS_Store",
"Status": 200,
"Bytes": 8196,
"Type": "application/octet-stream",
"Server": "Qrator.CDN",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/../resources.json",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/%2e%2e/config.xml",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js.bak",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/.git/config",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/index.html",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/",
"Status": 403,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/images/",
"Status": 403,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js",
"Status": 200,
"Bytes": 16384,
"Type": "application/javascript",
"Server": "Qrator.CDN",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.b7114b43.js",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.6469b966.js",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.ba10fa70.js",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
}
]
}
+73
View File
@@ -0,0 +1,73 @@
{
"List": [
{
"Url": "https://103.244.232.53/play/static/js/main.6469b966.js",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://103.244.232.54/play/static/js/main.6469b966.js",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://103.244.232.55/play/static/js/main.6469b966.js",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://103.244.232.57/play/static/js/main.6469b966.js",
"Status": 200,
"Bytes": 15924,
"Type": "application/javascript",
"Server": "nginx/1.26.1",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://103.244.232.58/play/static/js/main.6469b966.js",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://103.244.232.62/play/static/js/main.6469b966.js",
"Preview": "",
"Error": "timeout"
},
{
"Url": "https://103.244.232.53/play/static/js/main.6469b966.js.map",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://103.244.232.62/play/static/js/main.6469b966.js.map",
"Preview": "",
"Error": "timeout"
},
{
"Url": "https://103.244.232.62/.DS_Store",
"Preview": "",
"Error": "timeout"
}
]
}
+94
View File
@@ -0,0 +1,94 @@
{
"List": [
{
"Url": "https://103.244.232.57/play/static/js/main.6469b966.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": ""
},
{
"Url": "https://103.244.232.57/play/static/js/main.6469b966.js",
"Status": 200,
"Bytes": 15924,
"Type": "application/javascript",
"Server": "nginx/1.26.1",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://103.244.232.57/play/static/js/",
"Status": 403,
"Bytes": 8062,
"Type": "text/html",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": ""
},
{
"Url": "https://103.244.232.57/play/static/",
"Status": 403,
"Bytes": 8062,
"Type": "text/html",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": ""
},
{
"Url": "https://103.244.232.57/play/",
"Status": 200,
"Bytes": 5681,
"Type": "text/html",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": ""
},
{
"Url": "https://103.244.232.57/.DS_Store",
"Status": 200,
"Bytes": 5104,
"Type": "text/html; charset=UTF-8",
"Server": "openresty",
"Acao": "",
"Preview": ""
},
{
"Url": "https://103.244.232.57/.git/HEAD",
"Status": 200,
"Bytes": 2280,
"Type": "text/html; charset=UTF-8",
"Server": "openresty",
"Acao": "",
"Preview": ""
},
{
"Url": "https://103.244.232.57/robots.txt",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": ""
},
{
"Url": "https://103.244.232.57/play/static/.DS_Store",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": ""
},
{
"Url": "https://103.244.232.57/play/static/js/main.6469b966.js.map?1",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": ""
}
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"List": [
{
"Url": "https://103.244.232.57/static/images/31.2d481c05.webp",
"Status": 200,
"Bytes": 54,
"Type": "application/json",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": "{\"message\":\"404 NOT_FOUND\",\"data\":\"GET\",\"errCode\":500}"
},
{
"Url": "https://103.244.232.57/static/js/main.6469b966.js",
"Status": 200,
"Bytes": 54,
"Type": "application/json",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": "{\"message\":\"404 NOT_FOUND\",\"data\":\"GET\",\"errCode\":500}"
},
{
"Url": "https://103.244.232.57/static/js/main.6469b966.js.map",
"Status": 200,
"Bytes": 54,
"Type": "application/json",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": "{\"message\":\"404 NOT_FOUND\",\"data\":\"GET\",\"errCode\":500}"
},
{
"Url": "https://103.244.232.57/",
"Status": 200,
"Bytes": 54,
"Type": "application/json",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": "{\"message\":\"404 NOT_FOUND\",\"data\":\"GET\",\"errCode\":500}"
},
{
"Url": "https://103.244.232.57/robots.txt",
"Status": 200,
"Bytes": 54,
"Type": "application/json",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": "{\"message\":\"404 NOT_FOUND\",\"data\":\"GET\",\"errCode\":500}"
},
{
"Url": "https://103.244.232.57/.DS_Store",
"Status": 200,
"Bytes": 54,
"Type": "application/json",
"Server": "nginx/1.26.1",
"Acao": "",
"Preview": "{\"message\":\"404 NOT_FOUND\",\"data\":\"GET\",\"errCode\":500}"
}
]
}
+196
View File
@@ -0,0 +1,196 @@
{
"Paths": [
{
"Path": "/",
"Status": 200,
"Bytes": 9356,
"Type": "text/html; charset=UTF-8",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/index.html",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/resources/",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/resources/index.json",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/",
"Status": 200,
"Bytes": 5681,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/static/",
"Status": 403,
"Bytes": 4524,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/static/js/",
"Status": 403,
"Bytes": 4522,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/static/js/index.json",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/static/js/resources.json",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/static/js/main.js.map",
"Status": 404,
"Bytes": 572,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/static/",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/assets/",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/files/",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/dl/",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/patch/",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/config.xml",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/s/status.js",
"Status": 200,
"Bytes": 507,
"Type": "application/json;charset=utf-8",
"Server": "openresty",
"Acao": "*, *"
},
{
"Path": "/robots.txt",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/sitemap.xml",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/.git/HEAD",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/crossdomain.xml",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/version.txt",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/manifest.json",
"Status": 301,
"Bytes": 166,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/favicon.ico",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
}
]
}
+121
View File
@@ -0,0 +1,121 @@
{
"List": [
{
"Url": "https://s.tankionline.com/",
"Status": 403,
"Bytes": 0,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.tankionline.com/static/js/main.f1de53fa.js.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://static.tankionline.com/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://static.tankionline.com/static/js/main.f1de53fa.js.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html; charset=utf8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://sstatic.tankionline.com/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://sstatic.tankionline.com/static/js/main.f1de53fa.js.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html; charset=utf8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.ru.tankionline.com/",
"Status": 403,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.br.tankionline.com/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.pl.tankionline.com/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.de.tankionline.com/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://m.tankionline.com/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://tankionline.com/en/download/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/favicon.ico",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
}
]
}
+196
View File
@@ -0,0 +1,196 @@
{
"Paths": [
{
"Path": "/",
"Status": 200,
"Bytes": 1,
"Type": "text/html",
"Server": "openresty",
"Acao": "*"
},
{
"Path": "/index.html",
"Status": 200,
"Bytes": 1,
"Type": "text/html",
"Server": "openresty",
"Acao": "*"
},
{
"Path": "/resources/",
"Status": 200,
"Bytes": 1,
"Type": "text/html",
"Server": "openresty",
"Acao": "*"
},
{
"Path": "/resources/index.json",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/static/",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/static/js/",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/static/js/index.json",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/static/js/resources.json",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/play/static/js/main.js.map",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/static/",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/assets/",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/files/",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/dl/",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/patch/",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/config.xml",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/s/status.js",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/robots.txt",
"Status": 200,
"Bytes": 57,
"Type": "text/plain",
"Server": "openresty",
"Acao": "*"
},
{
"Path": "/sitemap.xml",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/.git/HEAD",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/crossdomain.xml",
"Status": 200,
"Bytes": 464,
"Type": "text/xml",
"Server": "openresty",
"Acao": "*"
},
{
"Path": "/version.txt",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/manifest.json",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "openresty",
"Acao": ""
},
{
"Path": "/favicon.ico",
"Status": 200,
"Bytes": 894,
"Type": "image/x-icon",
"Server": "openresty",
"Acao": "*"
}
]
}
+60
View File
@@ -0,0 +1,60 @@
{
"Cors": [
{
"Name": "纯 GET 无任何头",
"Status": 404,
"Bytes": 555,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "外来 Origin",
"Status": 404,
"Bytes": 555,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "外来 Referer",
"Status": 404,
"Bytes": 555,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "空 Referer",
"Status": 404,
"Bytes": 555,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "站点头(基准)",
"Status": 404,
"Bytes": 555,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "null Origin",
"Status": 404,
"Bytes": 555,
"Acao": "",
"Allow": "",
"Server": "openresty"
},
{
"Name": "OPTIONS 预检",
"Status": 204,
"Bytes": 0,
"Acao": "*",
"Allow": "",
"Server": "openresty"
}
]
}
+112
View File
@@ -0,0 +1,112 @@
{
"List": [
{
"Url": "https://s.tankionline.com/static/images/31.2d481c05.webp",
"Status": 200,
"Bytes": 5580,
"Type": "image/webp",
"Server": "QRATOR",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://s.tankionline.com/robots.txt",
"Status": 200,
"Bytes": 57,
"Type": "text/plain",
"Server": "QRATOR",
"Acao": "*",
"Preview": "User-agent: Yandex Disallow: / User-agent: * Disallow: / "
},
{
"Url": "https://s.tankionline.com/static/js/",
"Status": 403,
"Bytes": 0,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.tankionline.com/static/js/main.js",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.ru.tankionline.com/static/images/31.2d481c05.webp",
"Status": 200,
"Bytes": 5580,
"Type": "image/webp",
"Server": "Qrator.CDN",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://s.ru.tankionline.com/static/js/",
"Status": 403,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.ru.tankionline.com/static/js/main.js",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
},
{
"Url": "https://tankionline.com/ru/play/",
"Status": 200,
"Bytes": 14249,
"Type": "text/html; charset=utf8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://tankionline.com/ru/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://tankionline.com/pl/play/",
"Status": 200,
"Bytes": 15276,
"Type": "text/html; charset=UTF-8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.tankionline.com/.DS_Store",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.tankionline.com/static/.DS_Store",
"Status": 200,
"Bytes": 8196,
"Type": "application/octet-stream",
"Server": "QRATOR",
"Acao": "*",
"Preview": ""
}
]
}
+85
View File
@@ -0,0 +1,85 @@
{
"List": [
{
"Url": "https://s.3dtank.com/",
"Status": 200,
"Bytes": 1,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "*",
"Preview": " "
},
{
"Url": "https://s.3dtank.com/robots.txt",
"Status": 200,
"Bytes": 57,
"Type": "text/plain",
"Server": "nginx/1.17.1",
"Acao": "*",
"Preview": "User-agent: Yandex Disallow: / User-agent: * Disallow: / "
},
{
"Url": "https://s.3dtank.com/static/images/31.2d481c05.webp",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://s.3dtank.com/static/js/main.6469b966.js",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://s.3dtank.com/static/js/",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://s.3dtank.com/play/static/js/main.6469b966.js",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
},
{
"Url": "https://c2.3dtank.com/config.xml",
"Status": 200,
"Bytes": 286,
"Type": "text/xml;charset=utf-8",
"Server": "nginx/1.17.1",
"Acao": "*",
"Preview": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><cfg xmlns=\"http://alternativaplatform.com/core/config.xsd\"><server address=\"c2.3dtank.com\" version=\"\" mode=\"simple\" wsProtocol=\"WSS\"><ports/><we"
},
{
"Url": "https://c3.3dtank.com/config.xml",
"Status": 502,
"Bytes": 559,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>502 Bad Gateway</title></head> <body> <center><h1>502 Bad Gateway</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly"
},
{
"Url": "https://s.3dtank.com/s/status.js",
"Status": 404,
"Bytes": 555,
"Type": "text/html",
"Server": "nginx/1.17.1",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx/1.17.1</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly err"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"Tail": {
"Js": "https://3dtank.com/play/static/js/main.6469b966.js",
"Status": 206,
"Bytes": 600,
"Range": "bytes 9990539-9991138/9991139",
"Tail": "rror(\"ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: \"+t.id)}}),t),__webpack_require__.o=(t,n)=>Object.prototype.hasOwnProperty.call(t,n),__webpack_require__.r=t=>{\"undefined\"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"}),Object.defineProperty(t,\"__esModule\",{value:!0})},__webpack_require__.p=\"/play/\",__webpack_require__(76074),__webpack_require__(13431),__webpack_require__(26482);var __webpack_exports__=__webpack_require__(78231);return __webpack_exports__})());\n//# sourceMappingURL=main.6469b966.js.map",
"MappingUrl": "main.6469b966.js.map"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"Tail": {
"Js": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js",
"Status": 206,
"Bytes": 400,
"Range": "bytes 9990761-9991160/9991161",
"Tail": " Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"}),Object.defineProperty(t,\"__esModule\",{value:!0})},__webpack_require__.p=\"https://s.eu.tankionline.com/\",__webpack_require__(76074),__webpack_require__(13431),__webpack_require__(26482);var __webpack_exports__=__webpack_require__(78231);return __webpack_exports__})());\n//# sourceMappingURL=main.f1de53fa.js.map",
"MappingUrl": "main.f1de53fa.js.map"
}
}
+103
View File
@@ -0,0 +1,103 @@
{
"List": [
{
"Url": "https://test.tankionline.com/",
"Status": 200,
"Bytes": 3160,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": "<!DOCTYPE html> <html> <head> <meta http-equiv=\"Content-Type\" content=\"charset=UTF-8\"> <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"> <meta name=\"viewport\" content=\"width=device-width,initial-s"
},
{
"Url": "https://test.tankionline.com/play/",
"Status": 404,
"Bytes": 548,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly error page"
},
{
"Url": "https://test.tankionline.com/play/static/js/",
"Status": 404,
"Bytes": 548,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly error page"
},
{
"Url": "https://test.tankionline.com/static/js/main.f1de53fa.js.map",
"Status": 404,
"Bytes": 548,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly error page"
},
{
"Url": "https://test.tankionline.com/s/status.js/",
"Status": 404,
"Bytes": 548,
"Type": "text/html",
"Server": "QRATOR",
"Acao": "",
"Preview": "<html> <head><title>404 Not Found</title></head> <body> <center><h1>404 Not Found</h1></center> <hr><center>nginx</center> </body> </html> <!-- a padding to disable MSIE and Chrome friendly error page"
},
{
"Url": "https://tankionline.com/play/static/js/main.f1de53fa.js",
"Status": 404,
"Bytes": 0,
"Type": "text/html; charset=utf8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://tankionline.com/play/static/js/main.f1de53fa.js.map",
"Status": 403,
"Bytes": 0,
"Type": "text/html; charset=utf8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://tankionline.com/static/js/main.f1de53fa.js.map",
"Status": 404,
"Bytes": 0,
"Type": "text/html; charset=utf8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://tankionline.com/static/js/main.f1de53fa.js",
"Status": 404,
"Bytes": 0,
"Type": "text/html; charset=utf8",
"Server": "QRATOR",
"Acao": "",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/main.f1de53fa.js?x=1",
"Status": 200,
"Bytes": 16384,
"Type": "application/javascript",
"Server": "Qrator.CDN",
"Acao": "*",
"Preview": ""
},
{
"Url": "https://s.eu.tankionline.com/static/js/MAIN.F1DE53FA.JS",
"Status": 404,
"Bytes": 0,
"Type": "text/html",
"Server": "Qrator.CDN",
"Acao": "",
"Preview": ""
}
]
}
+37980 -38491
View File
File diff suppressed because it is too large. Load diff
+418
View File
@@ -0,0 +1,418 @@
{
"Note": "由 decrypt_frames.js 暴力穷举单字节密钥后统计,仅包含完全自洽的会话方向",
"Keys": {
"20261011-222641/session_001 c2s": {
"K": 87,
"Start": 9,
"Perfect": true
},
"20261011-222641/session_001 s2c": {
"K": 0,
"Start": 44,
"Perfect": true
},
"20261011-222641/session_002 c2s": {
"K": 100,
"Start": 1,
"Perfect": true
},
"20261011-222641/session_002 s2c": {
"K": 100,
"Start": 0,
"Perfect": true
},
"20261011-222641/session_003 c2s": {
"K": 246,
"Start": 1,
"Perfect": true
},
"20261011-222641/session_003 s2c": {
"K": 246,
"Start": 0,
"Perfect": true
},
"20261011-222641/session_004 c2s": {
"K": 162,
"Start": 1,
"Perfect": true
},
"20261011-222641/session_004 s2c": {
"K": 162,
"Start": 0,
"Perfect": true
},
"20261011-222641/session_005 c2s": {
"K": 11,
"Start": 1,
"Perfect": true
},
"20261011-222641/session_005 s2c": {
"K": 11,
"Start": 0,
"Perfect": true
},
"20261011-222641/session_006 c2s": {
"K": 50,
"Start": 1,
"Perfect": true
},
"20261011-222641/session_006 s2c": {
"K": 50,
"Start": 0,
"Perfect": true
},
"20261011-222641/session_007 c2s": {
"K": 148,
"Start": 1,
"Perfect": true
},
"20261011-222641/session_007 s2c": {
"K": 148,
"Start": 0,
"Perfect": true
},
"20261011-222641/session_008 c2s": {
"K": 175,
"Start": 1,
"Perfect": true
},
"20261011-222641/session_008 s2c": {
"K": 175,
"Start": 0,
"Perfect": true
},
"20261011-222641/session_009 c2s": {
"K": 148,
"Start": 1,
"Perfect": true
},
"20261011-222641/session_009 s2c": {
"K": 148,
"Start": 0,
"Perfect": true
},
"20261011-222641/session_010 c2s": {
"K": 87,
"Start": 0,
"Perfect": true
},
"20261011-222641/session_010 s2c": {
"K": 50,
"Start": 0,
"Perfect": true
}
},
"C2s": {
"0": {
"Name": "未知",
"Count": 45,
"Bytes": 870,
"Sample": "0000000005f691cc19368b1c6aca581400000001"
},
"1": {
"Name": "Hash Request",
"Count": 1,
"Bytes": 559,
"Sample": "010e097265736f75726365730f636f6e6669672d74656d70"
},
"3": {
"Name": "Auth/EnterSpace(待证)",
"Count": 8,
"Bytes": 328,
"Sample": "038d0df23b4ac650e2229602a40dc4a23e96d43417a5d685"
},
"19": {
"Name": "未知",
"Count": 1,
"Bytes": 41,
"Sample": "13852d770b62fe27e9dc8e60c42a4a88b64a1024a18f9ccf"
},
"31": {
"Name": "未知",
"Count": 1,
"Bytes": 45,
"Sample": "1f24537c6a6d367e695b58371a754c5c3f79565a0c381a66"
},
"32": {
"Name": "ClientLog",
"Count": 8,
"Bytes": 324,
"Sample": "2007066c6f675f696e00"
},
"42": {
"Name": "未知",
"Count": 1329,
"Bytes": 47856,
"Sample": "2aaaaaaab685cb64793bf2d473c51fc4d20a7678"
},
"255": {
"Name": "未知",
"Count": 1494,
"Bytes": 37010,
"Sample": "ffffffff800003d32316b154734544a9"
}
},
"S2c": {
"0": {
"Name": "待证(带 int64 与时间戳)",
"Count": 166,
"Bytes": 258471,
"Sample": "0000000005f691cc2ca2091458b7bc930000000100000001"
},
"2": {
"Name": "Hash Response",
"Count": 1,
"Bytes": 43,
"Sample": "028d0df23b4ac650e2229602a40dc4a23e96d43417a5d685"
},
"3": {
"Name": "未知",
"Count": 93,
"Bytes": 6224,
"Sample": "0374706f869de9a9c00200087189dc06dd77000800400000"
},
"6": {
"Name": "未知",
"Count": 13,
"Bytes": 496,
"Sample": "0674ed8ff1ede929c00e00a62f900bd89fff000801c00000"
},
"28": {
"Name": "未知",
"Count": 212,
"Bytes": 7279,
"Sample": "1c7a9c4ffad1f296c002001c11800400200100080045d800"
},
"29": {
"Name": "未知",
"Count": 397,
"Bytes": 7146,
"Sample": "1d91ad84e8"
},
"30": {
"Name": "未知",
"Count": 471,
"Bytes": 11556,
"Sample": "1e0000fb1d81f28518"
},
"32": {
"Name": "Open Space",
"Count": 7,
"Bytes": 63,
"Sample": "20000000d634b693fb"
},
"34": {
"Name": "未知",
"Count": 292,
"Bytes": 9546,
"Sample": "227c636f8e59f2274655e89a2a7fbc005fbafea832c02800"
},
"35": {
"Name": "未知",
"Count": 361,
"Bytes": 11404,
"Sample": "23b0f7cdb82a0654227c85efa61df1e040665571b783838d"
},
"36": {
"Name": "未知",
"Count": 1,
"Bytes": 52,
"Sample": "2480c2f0289de942c0020019568004002001000800400000"
},
"39": {
"Name": "未知",
"Count": 13,
"Bytes": 1047,
"Sample": "2780c30f65b5e91fc00200171c800400200d000800400000"
},
"40": {
"Name": "未知",
"Count": 3,
"Bytes": 84,
"Sample": "28894581582905fbfe277e758f6b81ecf2c9821c36196a8b"
},
"41": {
"Name": "未知",
"Count": 39,
"Bytes": 2068,
"Sample": "2901050023b1cc58382a0555"
},
"42": {
"Name": "未知",
"Count": 934,
"Bytes": 90744,
"Sample": "2aaaaaaab685cb644f8ec5eef576b61a0300000194b10a39"
},
"43": {
"Name": "未知",
"Count": 45,
"Bytes": 1924,
"Sample": "2b7b9470373df28c40020007f18004002001000800400000"
},
"44": {
"Name": "未知",
"Count": 31,
"Bytes": 266,
"Sample": "2c0a2fb818"
},
"45": {
"Name": "未知",
"Count": 32,
"Bytes": 675,
"Sample": "2d0655"
},
"48": {
"Name": "未知",
"Count": 1,
"Bytes": 9,
"Sample": "303030303035ce9bad"
},
"58": {
"Name": "未知",
"Count": 37,
"Bytes": 1041,
"Sample": "3aaf1e00583b050500"
},
"59": {
"Name": "未知",
"Count": 166,
"Bytes": 13503,
"Sample": "3b05000103766c8f8ccde9e448fdfec85690b40f612d0027"
},
"60": {
"Name": "未知",
"Count": 23,
"Bytes": 1658,
"Sample": "3c73b88f5f7de9a9c0020008e48004001d77000800400000"
},
"61": {
"Name": "未知",
"Count": 10,
"Bytes": 192,
"Sample": "3d00000940"
},
"62": {
"Name": "未知",
"Count": 28,
"Bytes": 802,
"Sample": "3e0955"
},
"63": {
"Name": "未知",
"Count": 1,
"Bytes": 33,
"Sample": "3f92ab00081e0000002280522fd531e93b802a1cd3348004"
},
"64": {
"Name": "未知",
"Count": 59,
"Bytes": 3370,
"Sample": "407500efd3b5e93f40020008008004001c83000800400000"
},
"65": {
"Name": "未知",
"Count": 72,
"Bytes": 2084,
"Sample": "41b0000008000002f7f641cc00002aaaaaaab6920ccd4b95"
},
"66": {
"Name": "未知",
"Count": 62,
"Bytes": 1439,
"Sample": "4200004076a08fd3b1ea274be6000800928c002335000822"
},
"67": {
"Name": "未知",
"Count": 201,
"Bytes": 12700,
"Sample": "438110effa59e93fbffa0018e0808c00dc850017fcbfcc00"
},
"68": {
"Name": "未知",
"Count": 251,
"Bytes": 20138,
"Sample": "440000000023a628b188"
},
"69": {
"Name": "未知",
"Count": 456,
"Bytes": 24114,
"Sample": "450552"
},
"70": {
"Name": "未知",
"Count": 38,
"Bytes": 6981,
"Sample": "4680c2f0289de93f40020019568004001c83000800400000"
},
"71": {
"Name": "未知",
"Count": 16,
"Bytes": 530,
"Sample": "47400081681e0000011d81eb00581e0000031d83910088"
},
"72": {
"Name": "未知",
"Count": 40,
"Bytes": 2170,
"Sample": "48095523b02d815823ae958108"
},
"73": {
"Name": "未知",
"Count": 12,
"Bytes": 842,
"Sample": "4973cbcff059e95fc012000639815bfc5d0cff97f7400000"
},
"74": {
"Name": "未知",
"Count": 10,
"Bytes": 229,
"Sample": "4a000008a0"
},
"75": {
"Name": "未知",
"Count": 15,
"Bytes": 1614,
"Sample": "4b05552a05512d0555"
},
"76": {
"Name": "未知",
"Count": 89,
"Bytes": 6999,
"Sample": "4c741c4fe6fde91c3fae0027288e1b9d600f0007ffc14c00"
},
"77": {
"Name": "未知",
"Count": 30,
"Bytes": 1331,
"Sample": "4dae7408682aaaaaaab2152f5d21ac80433d363f71000000"
},
"78": {
"Name": "未知",
"Count": 226,
"Bytes": 9503,
"Sample": "4e050204"
},
"79": {
"Name": "未知",
"Count": 3,
"Bytes": 67,
"Sample": "4f00008700500000fd"
},
"80": {
"Name": "未知",
"Count": 14,
"Bytes": 442,
"Sample": "500000002a06522274bbafb5f1e9563d7e001a308a6c249f"
},
"255": {
"Name": "未知",
"Count": 84,
"Bytes": 6770,
"Sample": "ffffffff800e5d5f1db76ecf1c5606912aaaaaaab2152f5d"
},
"PING": 144,
"COMPRESSED": 40,
"UNZIP_OK": 40
}
}
File diff suppressed because it is too large. Load diff
+910
View File
@@ -0,0 +1,910 @@
{
"Note": "下行 methodId → 协议模型 → 客户端动作。模型名来自 codec_fields.json 的 ModelName;动作来自 Inventory.kt 里“只有一个字段且字段类型等于该模型”的 data class。未匹配的模型在 Models 里保留候选,留给后续手工对照(多为 CC → 多个基本字段的转换动作)。",
"Source": [
"method_table.json",
"model_binding.json",
"codec_fields.json",
"Inventory.kt"
],
"Methods": 681,
"Resolved": 477,
"Matched": 9,
"MatchedModels": [
{
"Model": "GaragePreviewModelCC",
"Action": "InitGarageResources",
"Field": "params"
},
{
"Model": "ReferralEventUserModelCC",
"Action": "SetReferralEventData",
"Field": "eventData"
},
{
"Model": "UserPropertiesCC",
"Action": "SetUserPropertiesAction",
"Field": "user"
}
],
"SkippedTypeConflicts": [
{
"MethodId": "8836555743203700004",
"Model": "GaragePreviewModelCC",
"Type": "GaragePreviewModelCC0004"
},
{
"MethodId": "2940608322101861340",
"Model": "ReferralEventUserModelCC",
"Type": "ReferralEventUserModelCC1340"
},
{
"MethodId": "5628691596950015452",
"Model": "UserPropertiesCC",
"Type": "UserPropertiesCC5452"
},
{
"MethodId": "7864034006236084309",
"Model": "UserPropertiesCC",
"Type": "UserPropertiesCC4309"
}
],
"Models": [
{
"Model": "AndroidPayCC",
"Methods": 2,
"Actions": []
},
{
"Model": "AntiAddictionRealNameValidatorModelCC",
"Methods": 6,
"Actions": []
},
{
"Model": "AppleAppStoreCC",
"Methods": 2,
"Actions": []
},
{
"Model": "AresUltimateProjectileCC",
"Methods": 4,
"Actions": []
},
{
"Model": "ArmsRaceCC",
"Methods": 3,
"Actions": []
},
{
"Model": "ArtilleryElevatingBarrelCC",
"Methods": 1,
"Actions": []
},
{
"Model": "BattleBotControlCC",
"Methods": 1,
"Actions": []
},
{
"Model": "BattleChatCC",
"Methods": 5,
"Actions": []
},
{
"Model": "BattleCreateCC",
"Methods": 6,
"Actions": []
},
{
"Model": "BattleDMInfoCC",
"Methods": 3,
"Actions": []
},
{
"Model": "BattleGearScoreCC",
"Methods": 1,
"Actions": []
},
{
"Model": "BattleGoldBonusCC",
"Methods": 1,
"Actions": []
},
{
"Model": "BattleInfoCC",
"Methods": 7,
"Actions": []
},
{
"Model": "BattleInviteCC",
"Methods": 8,
"Actions": []
},
{
"Model": "BattleMineCC",
"Methods": 6,
"Actions": []
},
{
"Model": "BattlePassPurchaseNotifierCC",
"Methods": 1,
"Actions": []
},
{
"Model": "BattleStartDelayCC",
"Methods": 1,
"Actions": []
},
{
"Model": "BattleTutorialCC",
"Methods": 4,
"Actions": []
},
{
"Model": "BattleUltimateRadarCC",
"Methods": 1,
"Actions": []
},
{
"Model": "BattlefieldBonusesCC",
"Methods": 6,
"Actions": []
},
{
"Model": "BattlefieldCC",
"Methods": 4,
"Actions": []
},
{
"Model": "BonkWeaponCC",
"Methods": 4,
"Actions": []
},
{
"Model": "BossStateCC",
"Methods": 1,
"Actions": []
},
{
"Model": "BotNavigationCC",
"Methods": 4,
"Actions": []
},
{
"Model": "CaptchaCC",
"Methods": 4,
"Actions": []
},
{
"Model": "ChallengeClientCC",
"Methods": 2,
"Actions": []
},
{
"Model": "ChatCC",
"Methods": 3,
"Actions": []
},
{
"Model": "ClanChatCC",
"Methods": 2,
"Actions": []
},
{
"Model": "ClanCreateCC",
"Methods": 8,
"Actions": []
},
{
"Model": "ClanFriendsCC",
"Methods": 3,
"Actions": []
},
{
"Model": "ClanIncomingCC",
"Methods": 2,
"Actions": []
},
{
"Model": "ClanMembersCC",
"Methods": 1,
"Actions": []
},
{
"Model": "ClanNotifierData",
"Methods": 1,
"Actions": []
},
{
"Model": "ClanOutgoingCC",
"Methods": 2,
"Actions": []
},
{
"Model": "ClanPanelNotificationCC",
"Methods": 4,
"Actions": []
},
{
"Model": "ClanPermissionsCC",
"Methods": 1,
"Actions": []
},
{
"Model": "ClanUserCC",
"Methods": 15,
"Actions": []
},
{
"Model": "CoinInfoCC",
"Methods": 1,
"Actions": []
},
{
"Model": "CommonFacilityCC",
"Methods": 1,
"Actions": []
},
{
"Model": "CompositePartnerCC",
"Methods": 11,
"Actions": []
},
{
"Model": "ContainerCC",
"Methods": 16,
"Actions": []
},
{
"Model": "ContextTutorialModelCC",
"Methods": 1,
"Actions": []
},
{
"Model": "ContractUserModelCC",
"Methods": 3,
"Actions": []
},
{
"Model": "CrisisEffectCC",
"Methods": 1,
"Actions": []
},
{
"Model": "CrusaderUltimateCC",
"Methods": 2,
"Actions": []
},
{
"Model": "DamageIndicatorCC",
"Methods": 2,
"Actions": []
},
{
"Model": "DebtInfoCC",
"Methods": 1,
"Actions": []
},
{
"Model": "DeferredOverdriveCC",
"Methods": 2,
"Actions": []
},
{
"Model": "DemomanSfxCC",
"Methods": 1,
"Actions": []
},
{
"Model": "DictatorUltimateCC",
"Methods": 1,
"Actions": []
},
{
"Model": "DominationCC",
"Methods": 8,
"Actions": []
},
{
"Model": "DonationAlertCC",
"Methods": 5,
"Actions": []
},
{
"Model": "DroneIndicatorCC",
"Methods": 3,
"Actions": []
},
{
"Model": "EffectDescriptionCC",
"Methods": 3,
"Actions": []
},
{
"Model": "ExternalEntranceCC",
"Methods": 8,
"Actions": []
},
{
"Model": "FacebookPaymentCC",
"Methods": 1,
"Actions": []
},
{
"Model": "FlameThrowerCC",
"Methods": 2,
"Actions": []
},
{
"Model": "FreezeCC",
"Methods": 2,
"Actions": []
},
{
"Model": "FriendsCC",
"Methods": 8,
"Actions": []
},
{
"Model": "GaragePreviewModelCC",
"Methods": 1,
"Actions": [
"InitGarageResources"
]
},
{
"Model": "GaussCC",
"Methods": 5,
"Actions": []
},
{
"Model": "GlobeLightningCC",
"Methods": 2,
"Actions": []
},
{
"Model": "GoldBonusCC",
"Methods": 3,
"Actions": []
},
{
"Model": "GrenadeCC",
"Methods": 8,
"Actions": []
},
{
"Model": "HopperUltimateCC",
"Methods": 1,
"Actions": []
},
{
"Model": "HornetUltimateCC",
"Methods": 2,
"Actions": []
},
{
"Model": "HoverChassisCC",
"Methods": 1,
"Actions": []
},
{
"Model": "HunterUltimateCC",
"Methods": 1,
"Actions": []
},
{
"Model": "InventoryItemCC",
"Methods": 1,
"Actions": []
},
{
"Model": "InviteEntranceCC",
"Methods": 3,
"Actions": []
},
{
"Model": "IsisCC",
"Methods": 5,
"Actions": []
},
{
"Model": "JuggernautCC",
"Methods": 2,
"Actions": []
},
{
"Model": "JuggernautUltimateCC",
"Methods": 1,
"Actions": []
},
{
"Model": "KeysInfoCC",
"Methods": 1,
"Actions": []
},
{
"Model": "KillStreakCC",
"Methods": 1,
"Actions": []
},
{
"Model": "LaserPointerCC",
"Methods": 3,
"Actions": []
},
{
"Model": "LicenseClanUserCC",
"Methods": 2,
"Actions": []
},
{
"Model": "MachineGunCC",
"Methods": 6,
"Actions": []
},
{
"Model": "MainQuestUserModelCC",
"Methods": 2,
"Actions": []
},
{
"Model": "MammothUltimateCC",
"Methods": 3,
"Actions": []
},
{
"Model": "MatchmakingGroupCC",
"Methods": 5,
"Actions": []
},
{
"Model": "MatchmakingLayoutCC",
"Methods": 2,
"Actions": []
},
{
"Model": "MeteorStormCC",
"Methods": 2,
"Actions": []
},
{
"Model": "ModerationToolInteropCC",
"Methods": 3,
"Actions": []
},
{
"Model": "MountedResistancesCC",
"Methods": 1,
"Actions": []
},
{
"Model": "NewsShowingCC",
"Methods": 2,
"Actions": []
},
{
"Model": "NotificationsByCountryModelCC",
"Methods": 1,
"Actions": []
},
{
"Model": "NuclearRechargeCC",
"Methods": 2,
"Actions": []
},
{
"Model": "PaidRenameCC",
"Methods": 2,
"Actions": []
},
{
"Model": "PaladinAuraVisualCC",
"Methods": 4,
"Actions": []
},
{
"Model": "PayGardenPaymentCC",
"Methods": 1,
"Actions": []
},
{
"Model": "PayPalPayModelCC",
"Methods": 3,
"Actions": []
},
{
"Model": "PeriodicRewardCC",
"Methods": 1,
"Actions": []
},
{
"Model": "PointBasedBattleCC",
"Methods": 7,
"Actions": []
},
{
"Model": "PremiumNotifierCC",
"Methods": 2,
"Actions": []
},
{
"Model": "RailgunCC",
"Methods": 5,
"Actions": []
},
{
"Model": "RankNotifierData",
"Methods": 1,
"Actions": []
},
{
"Model": "ReferralEventUserModelCC",
"Methods": 3,
"Actions": [
"SetReferralEventData"
]
},
{
"Model": "RegistrationModelCC",
"Methods": 7,
"Actions": []
},
{
"Model": "RemoteTankStateUpdatingCC",
"Methods": 1,
"Actions": []
},
{
"Model": "RicochetCC",
"Methods": 4,
"Actions": []
},
{
"Model": "RotatingTurretCC",
"Methods": 2,
"Actions": []
},
{
"Model": "RuStorePayCC",
"Methods": 2,
"Actions": []
},
{
"Model": "RubyInfoCC",
"Methods": 1,
"Actions": []
},
{
"Model": "ScorpioCC",
"Methods": 4,
"Actions": []
},
{
"Model": "ServerHaltCC",
"Methods": 1,
"Actions": []
},
{
"Model": "ServerHaltEntranceCC",
"Methods": 1,
"Actions": []
},
{
"Model": "ShaftCC",
"Methods": 4,
"Actions": []
},
{
"Model": "ShotCC",
"Methods": 2,
"Actions": []
},
{
"Model": "ShotgunShotCC",
"Methods": 2,
"Actions": []
},
{
"Model": "ShowcaseViewCC",
"Methods": 1,
"Actions": []
},
{
"Model": "SmokyCC",
"Methods": 2,
"Actions": []
},
{
"Model": "SpectatorsModelCC",
"Methods": 3,
"Actions": []
},
{
"Model": "SpeedCharacteristicsCC",
"Methods": 1,
"Actions": []
},
{
"Model": "StarsInfoCC",
"Methods": 1,
"Actions": []
},
{
"Model": "StatisticsDMCC",
"Methods": 4,
"Actions": []
},
{
"Model": "StatisticsModelCC",
"Methods": 8,
"Actions": []
},
{
"Model": "StatisticsTeamCC",
"Methods": 6,
"Actions": []
},
{
"Model": "StatusEffectCC",
"Methods": 3,
"Actions": []
},
{
"Model": "StreamWeaponCC",
"Methods": 1,
"Actions": []
},
{
"Model": "SuicideCC",
"Methods": 1,
"Actions": []
},
{
"Model": "SuppliesTutorialModelCC",
"Methods": 1,
"Actions": []
},
{
"Model": "TankCC",
"Methods": 6,
"Actions": []
},
{
"Model": "TankCritCC",
"Methods": 1,
"Actions": []
},
{
"Model": "TankEffectSFXCC",
"Methods": 2,
"Actions": []
},
{
"Model": "TankHasGrenadeCC",
"Methods": 1,
"Actions": []
},
{
"Model": "TankPointersCC",
"Methods": 1,
"Actions": []
},
{
"Model": "TankResistancesCC",
"Methods": 1,
"Actions": []
},
{
"Model": "TankSpawnerCC",
"Methods": 2,
"Actions": []
},
{
"Model": "TankStunCC",
"Methods": 2,
"Actions": []
},
{
"Model": "TankSuperchargeCC",
"Methods": 2,
"Actions": []
},
{
"Model": "TeamBattleInfoCC",
"Methods": 5,
"Actions": []
},
{
"Model": "TeamJuggernautCC",
"Methods": 2,
"Actions": []
},
{
"Model": "TerminatorCC",
"Methods": 5,
"Actions": []
},
{
"Model": "TeslaBazookaCC",
"Methods": 1,
"Actions": []
},
{
"Model": "TeslaCC",
"Methods": 5,
"Actions": []
},
{
"Model": "ThunderCC",
"Methods": 2,
"Actions": []
},
{
"Model": "TitanUltimateGeneratorCC",
"Methods": 2,
"Actions": []
},
{
"Model": "TrackedChassisCC",
"Methods": 1,
"Actions": []
},
{
"Model": "TsunamiCC",
"Methods": 3,
"Actions": []
},
{
"Model": "TwinsCC",
"Methods": 2,
"Actions": []
},
{
"Model": "TwitchAccountModelCC",
"Methods": 2,
"Actions": []
},
{
"Model": "TwoStepVerificationModelCC",
"Methods": 2,
"Actions": []
},
{
"Model": "UidCheckCC",
"Methods": 1,
"Actions": []
},
{
"Model": "UidNotifierData",
"Methods": 1,
"Actions": []
},
{
"Model": "UltimateCC",
"Methods": 5,
"Actions": []
},
{
"Model": "UpgradeGarageItemCC",
"Methods": 1,
"Actions": []
},
{
"Model": "UserAccountRemovalModelCC",
"Methods": 2,
"Actions": []
},
{
"Model": "UserAchievementCC",
"Methods": 1,
"Actions": []
},
{
"Model": "UserActionsLoggerCC",
"Methods": 2,
"Actions": []
},
{
"Model": "UserContainerCC",
"Methods": 8,
"Actions": []
},
{
"Model": "UserEmailCC",
"Methods": 10,
"Actions": []
},
{
"Model": "UserPropertiesCC",
"Methods": 9,
"Actions": [
"SetUserPropertiesAction"
]
},
{
"Model": "UserReportsModelCC",
"Methods": 1,
"Actions": []
},
{
"Model": "VikingUltimateCC",
"Methods": 2,
"Actions": []
},
{
"Model": "WaspUltimateBombCC",
"Methods": 1,
"Actions": []
},
{
"Model": "WeaponCommonCC",
"Methods": 1,
"Actions": []
},
{
"Model": "WeaponWeakeningCC",
"Methods": 1,
"Actions": []
},
{
"Model": "at",
"Methods": 4,
"Actions": []
}
],
"Actions": [
{
"MethodId": "2940606982196178690",
"Model": "ReferralEventUserModelCC",
"Action": "SetReferralEventData",
"Field": "eventData"
},
{
"MethodId": "1880027114297758713",
"Model": "ReferralEventUserModelCC",
"Action": "SetReferralEventData",
"Field": "eventData"
},
{
"MethodId": "276076212290602640",
"Model": "UserPropertiesCC",
"Action": "SetUserPropertiesAction",
"Field": "user"
},
{
"MethodId": "7864195052961084235",
"Model": "UserPropertiesCC",
"Action": "SetUserPropertiesAction",
"Field": "user"
},
{
"MethodId": "3982372259754314184",
"Model": "UserPropertiesCC",
"Action": "SetUserPropertiesAction",
"Field": "user"
},
{
"MethodId": "6492680006777979140",
"Model": "UserPropertiesCC",
"Action": "SetUserPropertiesAction",
"Field": "user"
},
{
"MethodId": "3977381235093458189",
"Model": "UserPropertiesCC",
"Action": "SetUserPropertiesAction",
"Field": "user"
},
{
"MethodId": "848734776772436751",
"Model": "UserPropertiesCC",
"Action": "SetUserPropertiesAction",
"Field": "user"
},
{
"MethodId": "8468360636859619961",
"Model": "UserPropertiesCC",
"Action": "SetUserPropertiesAction",
"Field": "user"
}
]
}
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
{
"File": "..\\probe\\dsstore\\https___s_eu_tankionline_com_static__DS_Store",
"Size": 8196,
"Entries": [
"arrow.e65ad0f5.3ds",
"artillery_shell.ebdc414e.3ds",
"fonts",
"images",
"w.e65a"
]
}
+16262 -20105
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"at":"2026-10-20T17:43:16.698Z","href":"http://127.0.0.1:8080/","count":363,"entries":[{"modelId":"821863604528384663","ctor":"lt","ctorSrc":"function lt() {\n at();\n }","keys":[],"protoKeys":[],"u7h":"function (t) {\n this.b1x2_1 = t.r7g(new E(m(w), !1));\n }","t7h":"function (t) {\n var n = ct(this).t7h(t),\n i = n instanceof w ? n : p();\n if (d(i, new w(0, 0))) return I(T(ct(this).t7h(t)), i);\n var r = at().a1x2().o7o(i),\n e = T(r.t7h(t));\n return I(e, i);\n }","s7h":"function (t, n) {}"},{"modelId":"3152693668290247629","ctor":"qu","ctorSrc":"function qu() {}","keys":["qdy_1"],"protoKeys":[],"u7h":"function (t) {\n this.qdy_1 = t.r7g(new Ei(new ki(bi(up), !1), !1));\n }","t7h":"function (t) {\n var n = sp(),\n i = n,\n r = bu(this).t7h(t);\n return ((i.rdy_1 = null != r && Ii(r, Ti) ? r : wi()), n);\n }","s7h":"function (t, n) {\n if (null == n) throw vi(\"Object is null. Use @ProtocolOptional annotation.\");\n var i = n instanceof op ? n : wi();\n bu(this).s7h(t, i.sdy());\n }"},{"modelId":"2547093126916834195","ctor":"Pt","ctorSrc":"function Pt() {}","keys":["daw_1","eaw_1","faw_1","gaw_1","haw_1","iaw_1"],"protoKeys":[],"u7h":"function (t) {\n ((this.daw_1 = t.r7g(new $(P().sb(), !0))),\n (this.eaw_1 = t.r7g(new $(P().sb(), !0))),\n (this.faw_1 = t.r7g(new $(P().sb(), !0))),\n (this.gaw_1 = t.r7g(new $(P().sb(), !0))),\n (this.haw_1 = t.r7g(new $(P().sb(), !0))),\n (this.iaw_1 = t.r7g(new $(P().sb(), !0))));\n }","t7h":"function (t) {\n var n = Ri(),\n i = n,\n r = Ct(this).t7h(t);\n i.jaw_1 = null == r || \"number\" == typeof r ? r : S();\n var e = n,\n u = Et(this).t7h(t);\n e.kaw_1 = null == u || \"number\" == typeof u ? u : S();\n var s = n,\n o = Tt(this).t7h(t);\n s.law_1 = null == o || \"number\" == typeof o ? o : S();\n var _ = n,\n h = It(this).t7h(t);\n _.maw_1 = null == h || \"number\" == typeof h ? h : S();\n var a = n,\n c = At(this).t7h(t);\n a.naw_1 = null == c || \"number\" == typeof c ? c : S();\n var l = n,\n f = Rt(this).t7h(t);\n return ((l.oaw_1 = null == f || \"number\" == typeof f ? f : S()), n);\n }","s7h":"function (t, n) {\n if (null == n) throw I(\"Object is null. Use @ProtocolOptional annotation.\");\n var i = n instanceof Pi ? n : S();\n (Ct(this).s7h(t, i.jaw_1),\n Et(this).s7h(t, i.kaw_1),\n Tt(this).s7h(t, i.law_1),\n It(this).s7h(t, i.maw_1),\n At(this).s7h(t, i.naw_1),\n Rt(this).s7h(t, i.oaw_1));\n }"},{"modelId":"1164458258646872337","ctor":"A_","ctorSrc":"function A_() {}","keys":["lcm_1"],"protoKeys":[],"u7h":"function (t) {\n this.lcm_1 = t.r7g(new Ue(new je(Re(Ve), !1), !1));\n }","t7h":"function (t) {\n var n = wd(),\n i = n,\n r = I_(this).t7h(t);\n return ((i.mcm_1 = null != r && Ge(r, Fe) ? r : Ce()), n);\n }","s7h":"function (t, n) {\n if (null == n) throw Te(\"Object is null. Use @ProtocolOptional annotation.\");\n var i = n instanceof dd ? n : Ce();\n I_(this).s7h(t, i.icm());\n }"},{"modelId":"8618289906968528240","ctor":"Rt","ctorSrc":"function Rt() {}","keys":["lhk_1","mhk_1","nhk_1","ohk_1","phk_1"],"protoKeys":[],"u7h":"function (t) {\n ((this.lhk_1 = t.r7g(new v(f().pb(), !1))),\n (this.mhk_1 = t.r7g(new q(new v(w(C), !1), !1))),\n (this.nhk_1 = t.r7g(new q(new v(w(x), !1), !1))),\n (this.ohk_1 = t.r7g(new v(w(T), !1))),\n (this.phk_1 = t.r7g(new v(w(T), !1))));\n }","t7h":"function (t) {\n var n = ki(),\n i = n,\n r = Ct(this).t7h(t);\n i.qhk_1 = null != r && \"boolean\" == typeof r ? r : m();\n var e = n,\n u = Et(this).t7h(t);\n e.rhk_1 = null != u && $(u, y) ? u : m();\n var s = n,\n o = Tt(this).t7h(t);\n s.shk_1 = null != o && $(o, y) ? o : m();\n var _ = n,\n h = It(this).t7h(t);\n _.thk_1 = h instanceof T ? h : m();\n var a = n,\n c = At(this).t7h(t);\n return (\n (a.uhk_1 = c instanceof T ? c : m()),\n (n.vhk_1 = t.e7h_1.r2u()),\n (n.whk_1 = t.e7h_1.r2u()),\n n\n );\n }","s7h":"function (t, n) {\n if (null == n) throw z(\"Object is null. Use @ProtocolOptional annotation.\");\n var i = n instanceof zi ? n : m();\n (Ct(this).s7h(t, i.qhk_1),\n Et(this).s7h(t, i.xhk()),\n Tt(this).s7h(t, i.yhk()),\n It(this).s7h(t, i.zhk()),\n At(this).s7h(t, i.ahl()),\n t.e7h_1.m2u(i.vhk_1),\n t.e7h_1.m2u(i.whk_1));\n }"},{"modelId":"7588919819099098108","ctor":"Yr"Line truncated
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+696
View File
@@ -0,0 +1,696 @@
{
"generatedAt": "2026-10-23T07:01:46.681Z",
"module": "module_10032.js",
"Field": "garagePreview",
"Slot": "epv_1",
"Class": "IG",
"ClassSource": "new IG()",
"NamedReducer": "fs.eru",
"Params": [
"t",
"n",
"i",
"r",
"e",
"u",
"s",
"o",
"_",
"h",
"a",
"c",
"l",
"f",
"v",
"p",
"w",
"d",
"g",
"m",
"k",
"z"
],
"Fields": [
{
"Field": "isVisible",
"Slot": "vrr_1",
"Kotlin": "IsVisible",
"Default": "false"
},
{
"Field": "photoMode",
"Slot": "wrr_1",
"Kotlin": "PhotoMode",
"Default": "false"
},
{
"Field": "needVisible",
"Slot": "xrr_1",
"Kotlin": "NeedVisible",
"Default": "false"
},
{
"Field": "viewStyle",
"Slot": "yrr_1",
"Kotlin": "ViewStyle",
"Default": "null"
},
{
"Field": "dynViewStyle",
"Slot": "zrr_1",
"Kotlin": "DynViewStyle",
"Default": "null"
},
{
"Field": "tankPreviewSettings",
"Slot": "ars_1",
"Kotlin": "TankPreviewSettings",
"Default": "new Ny()"
},
{
"Field": "needShowDrone",
"Slot": "brs_1",
"Kotlin": "NeedShowDrone",
"Default": "false"
},
{
"Field": "params",
"Slot": "crs_1",
"Kotlin": "Params",
"Default": "null"
},
{
"Field": "hullData",
"Slot": "drs_1",
"Kotlin": "HullData",
"Default": "null"
},
{
"Field": "weaponData",
"Slot": "ers_1",
"Kotlin": "WeaponData",
"Default": "null"
},
{
"Field": "droneData",
"Slot": "frs_1",
"Kotlin": "DroneData",
"Default": "null"
},
{
"Field": "grenadeData",
"Slot": "grs_1",
"Kotlin": "GrenadeData",
"Default": "null"
},
{
"Field": "hasGrenades",
"Slot": "hrs_1",
"Kotlin": "HasGrenades",
"Default": "false"
},
{
"Field": "hullEngineType",
"Slot": "irs_1",
"Kotlin": "HullEngineType",
"Default": "My()"
},
{
"Field": "turretMechanism",
"Slot": "jrs_1",
"Kotlin": "TurretMechanism",
"Default": "Oy()"
},
{
"Field": "paint",
"Slot": "krs_1",
"Kotlin": "Paint",
"Default": "null"
},
{
"Field": "properties",
"Slot": "lrs_1",
"Kotlin": "Properties",
"Default": "md()"
},
{
"Field": "captureScreenshotAction",
"Slot": "mrs_1",
"Kotlin": "CaptureScreenshotAction",
"Default": "null"
},
{
"Field": "captureVideoAction",
"Slot": "nrs_1",
"Kotlin": "CaptureVideoAction",
"Default": "null"
},
{
"Field": "item3dModelActionCache",
"Slot": "ors_1",
"Kotlin": "Item3dModelActionCache",
"Default": "ag()"
},
{
"Field": "requestedItems",
"Slot": "prs_1",
"Kotlin": "RequestedItems",
"Default": "Yg()"
},
{
"Field": "expectedItemId",
"Slot": "qrs_1",
"Kotlin": "ExpectedItemId",
"Default": "ag()"
}
],
"CopyMethod": "trs",
"Branches": [
{
"Guard": "yd(t, is)",
"Locals": [],
"Kind": "copy",
"Changed": [
{
"Index": 7,
"Field": "params",
"Slot": "crs_1",
"Kotlin": "Params",
"Value": "null"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, cd, cd, null)"
},
{
"Guard": "t instanceof NG",
"Locals": [
"NG"
],
"Kind": "copy",
"Changed": [
{
"Index": 0,
"Field": "isVisible",
"Slot": "vrr_1",
"Kotlin": "IsVisible",
"Value": "r"
},
{
"Index": 1,
"Field": "photoMode",
"Slot": "wrr_1",
"Kotlin": "PhotoMode",
"Value": "_"
},
{
"Index": 2,
"Field": "needVisible",
"Slot": "xrr_1",
"Kotlin": "NeedVisible",
"Value": "e"
},
{
"Index": 3,
"Field": "viewStyle",
"Slot": "yrr_1",
"Kotlin": "ViewStyle",
"Value": "u"
},
{
"Index": 5,
"Field": "tankPreviewSettings",
"Slot": "ars_1",
"Kotlin": "TankPreviewSettings",
"Value": "s"
},
{
"Index": 6,
"Field": "needShowDrone",
"Slot": "brs_1",
"Kotlin": "NeedShowDrone",
"Value": "o"
}
],
"Expr": "n.trs(r, _, e, u, cd, s, o)"
},
{
"Guard": "t instanceof MG",
"Locals": [
"MG"
],
"Kind": "copy",
"Changed": [
{
"Index": 5,
"Field": "tankPreviewSettings",
"Slot": "ars_1",
"Kotlin": "TankPreviewSettings",
"Value": "t.zrs_1"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, t.zrs_1)"
},
{
"Guard": "t instanceof OG",
"Locals": [
"OG"
],
"Kind": "copy",
"Changed": [
{
"Index": 4,
"Field": "dynViewStyle",
"Slot": "zrr_1",
"Kotlin": "DynViewStyle",
"Value": "t.fru_1"
}
],
"Expr": "n.trs(cd, cd, cd, cd, t.fru_1)"
},
{
"Guard": "yd(t, es)",
"Locals": [],
"Kind": "copy",
"Changed": [
{
"Index": 0,
"Field": "isVisible",
"Slot": "vrr_1",
"Kotlin": "IsVisible",
"Value": "!0"
},
{
"Index": 2,
"Field": "needVisible",
"Slot": "xrr_1",
"Kotlin": "NeedVisible",
"Value": "!1"
}
],
"Expr": "n.trs(!0, cd, !1)"
},
{
"Guard": "yd(t, rs)",
"Locals": [],
"Kind": "copy",
"Changed": [
{
"Index": 0,
"Field": "isVisible",
"Slot": "vrr_1",
"Kotlin": "IsVisible",
"Value": "!1"
}
],
"Expr": "n.trs(!1)"
},
{
"Guard": "t instanceof FG",
"Locals": [
"FG"
],
"Kind": "copy",
"Changed": [
{
"Index": 7,
"Field": "params",
"Slot": "crs_1",
"Kotlin": "Params",
"Value": "t.crt_1"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, cd, cd, t.crt_1)"
},
{
"Guard": "t instanceof HG",
"Locals": [
"HG"
],
"Kind": "copy-helper",
"Changed": [
{
"Index": 21,
"Field": "expectedItemId",
"Slot": "qrs_1",
"Kotlin": "ExpectedItemId",
"Value": "am(n.qrs_1, t.grt_1)"
}
],
"Expr": "null == t.hrt_1 ? n.trs( cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, am(n.qrs_1, t.grt_1), ) : n.trs( cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cg(n.qrs_1, Vd(t.grt_1, t.hrt_1)), )"
},
{
"Guard": "t instanceof WG",
"Locals": [
"WG"
],
"Kind": "copy-helper",
"Changed": [
{
"Index": 20,
"Field": "requestedItems",
"Slot": "prs_1",
"Kotlin": "RequestedItems",
"Value": "Xg(n.prs_1, t.irt_1)"
}
],
"Expr": "n.trs( cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, Xg(n.prs_1, t.irt_1), )"
},
{
"Guard": "t instanceof VG",
"Locals": [
"VG"
],
"Kind": "conditional",
"Changed": [],
"Expr": "h"
},
{
"Guard": "t instanceof KG",
"Locals": [
"KG"
],
"Kind": "conditional",
"Changed": [],
"Expr": "v"
},
{
"Guard": "t instanceof iH",
"Locals": [
"iH"
],
"Kind": "conditional",
"Changed": [],
"Expr": "m"
},
{
"Guard": "t instanceof ZG",
"Locals": [
"ZG"
],
"Kind": "copy",
"Changed": [
{
"Index": 9,
"Field": "weaponData",
"Slot": "ers_1",
"Kotlin": "WeaponData",
"Value": "null"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, cd, cd, cd, cd, null)"
},
{
"Guard": "t instanceof QG",
"Locals": [
"QG"
],
"Kind": "copy",
"Changed": [
{
"Index": 8,
"Field": "hullData",
"Slot": "drs_1",
"Kotlin": "HullData",
"Value": "null"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, cd, cd, cd, null)"
},
{
"Guard": "t instanceof tH",
"Locals": [
"tH"
],
"Kind": "copy",
"Changed": [
{
"Index": 10,
"Field": "droneData",
"Slot": "frs_1",
"Kotlin": "DroneData",
"Value": "null"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, null)"
},
{
"Guard": "t instanceof nH",
"Locals": [
"nH"
],
"Kind": "copy",
"Changed": [
{
"Index": 11,
"Field": "grenadeData",
"Slot": "grs_1",
"Kotlin": "GrenadeData",
"Value": "null"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, null)"
},
{
"Guard": "t instanceof YG",
"Locals": [
"YG"
],
"Kind": "copy",
"Changed": [
{
"Index": 11,
"Field": "grenadeData",
"Slot": "grs_1",
"Kotlin": "GrenadeData",
"Value": "b"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, b)"
},
{
"Guard": "t instanceof XG",
"Locals": [
"XG"
],
"Kind": "copy",
"Changed": [
{
"Index": 12,
"Field": "hasGrenades",
"Slot": "hrs_1",
"Kotlin": "HasGrenades",
"Value": "!0"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, !0)"
},
{
"Guard": "t instanceof JG",
"Locals": [
"JG"
],
"Kind": "copy",
"Changed": [
{
"Index": 12,
"Field": "hasGrenades",
"Slot": "hrs_1",
"Kotlin": "HasGrenades",
"Value": "!1"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, !1)"
},
{
"Guard": "t instanceof rH",
"Locals": [
"rH"
],
"Kind": "copy-helper",
"Changed": [
{
"Index": 15,
"Field": "paint",
"Slot": "krs_1",
"Kotlin": "Paint",
"Value": "t.bru_1"
},
{
"Index": 19,
"Field": "item3dModelActionCache",
"Slot": "ors_1",
"Kotlin": "Item3dModelActionCache",
"Value": "cg(n.ors_1, Vd(t.aru_1, t))"
},
{
"Index": 20,
"Field": "requestedItems",
"Slot": "prs_1",
"Kotlin": "RequestedItems",
"Value": "Jg(n.prs_1, t.aru_1)"
}
],
"Expr": "n.rrs(Vk(), t.aru_1) ? n.trs( cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, t.bru_1, cd, cd, cd, cg(n.ors_1, Vd(t.aru_1, t)), Jg(n.prs_1, t.aru_1), ) : n.trs( cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cg(n.ors_1, Vd(t.aru_1, t)), Jg(n.prs_1, t.aru_1), "
},
{
"Guard": "t instanceof eH",
"Locals": [
"eH"
],
"Kind": "copy-helper",
"Changed": [
{
"Index": 19,
"Field": "item3dModelActionCache",
"Slot": "ors_1",
"Kotlin": "Item3dModelActionCache",
"Value": "am(n.ors_1, t.cru_1)"
}
],
"Expr": "n.trs( cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, am(n.ors_1, t.cru_1), )"
},
{
"Guard": "t instanceof uH",
"Locals": [
"uH"
],
"Kind": "copy",
"Changed": [
{
"Index": 16,
"Field": "properties",
"Slot": "lrs_1",
"Kotlin": "Properties",
"Value": "t.dru_1"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, t.dru_1)"
},
{
"Guard": "t instanceof LG",
"Locals": [
"LG"
],
"Kind": "copy",
"Changed": [
{
"Index": 17,
"Field": "captureScreenshotAction",
"Slot": "mrs_1",
"Kotlin": "CaptureScreenshotAction",
"Value": "t.art_1"
},
{
"Index": 18,
"Field": "captureVideoAction",
"Slot": "nrs_1",
"Kotlin": "CaptureVideoAction",
"Value": "t.brt_1"
}
],
"Expr": "n.trs(cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, cd, t.art_1, t.brt_1)"
},
{
"Guard": "t instanceof BG",
"Locals": [
"BG"
],
"Kind": "noop",
"Changed": [],
"Expr": "n"
},
{
"Guard": "t instanceof UG",
"Locals": [
"UG"
],
"Kind": "noop",
"Changed": [],
"Expr": "n"
},
{
"Guard": "(else)",
"Locals": [],
"Kind": "noop",
"Changed": [],
"Expr": "n"
}
],
"SubReducers": [],
"Counts": {
"TotalInstanceof": 22,
"TopLevelBranches": 26,
"SubReducerArms": 0
},
"Locals": {
"NG": {
"Name": "ShowRequest"
},
"MG": {
"Name": "UpdatePreviewSettings"
},
"OG": {
"Name": null
},
"FG": {
"Name": "InitGarageResources"
},
"HG": {
"Name": "SetExpectedItemId"
},
"WG": {
"Name": "AddRequestedItem"
},
"VG": {
"Name": null
},
"KG": {
"Name": null
},
"iH": {
"Name": "SetDrone"
},
"ZG": {
"Name": null
},
"QG": {
"Name": null
},
"tH": {
"Name": null
},
"nH": {
"Name": null
},
"YG": {
"Name": null
},
"XG": {
"Name": null
},
"JG": {
"Name": null
},
"rH": {
"Name": "SetPaint"
},
"eH": {
"Name": "ResetItem3dModelActionCache"
},
"uH": {
"Name": "SetMountedItemsProperties"
},
"LG": {
"Name": "SetCaptureActions"
},
"BG": {
"Name": null
},
"UG": {
"Name": null
}
},
"Delegate": "fs.eru(t, n.epv_1)",
"CopyParamCount": 23,
"CopyMatchesFields": false
}
+155
View File
@@ -0,0 +1,155 @@
{
"generatedAt": "2026-10-23T03:54:25.170Z",
"module": "module_10032.js",
"Field": "lootBoxes",
"Slot": "wpv_1",
"Class": "aH",
"ClassSource": "new aH()",
"Params": [
"t",
"n",
"i",
"r",
"e",
"u",
"s",
"o",
"_",
"h",
"a",
"c",
"l",
"f",
"v",
"p",
"w",
"d"
],
"Fields": [
{
"Field": "rewardsForKeys",
"Slot": "lru_1",
"Kotlin": "RewardsForKeys",
"Default": "md()"
},
{
"Field": "currentContainerType",
"Slot": "mru_1",
"Kotlin": "CurrentContainerType",
"Default": "Ly()"
},
{
"Field": "keys",
"Slot": "nru_1",
"Kotlin": "Keys",
"Default": "ag()"
},
{
"Field": "containerNames",
"Slot": "oru_1",
"Kotlin": "ContainerNames",
"Default": "ag()"
},
{
"Field": "containerDescriptions",
"Slot": "pru_1",
"Kotlin": "ContainerDescriptions",
"Default": "ag()"
},
{
"Field": "rewards",
"Slot": "qru_1",
"Kotlin": "Rewards",
"Default": "md()"
},
{
"Field": "shownRewards",
"Slot": "rru_1",
"Kotlin": "ShownRewards",
"Default": "md()"
},
{
"Field": "gotoGarage",
"Slot": "sru_1",
"Kotlin": "GotoGarage",
"Default": "false"
},
{
"Field": "timeToGiveLootBox",
"Slot": "tru_1",
"Kotlin": "TimeToGiveLootBox",
"Default": "new Dd(0, 0)"
},
{
"Field": "periodOfGiveLootBoxInSec",
"Slot": "uru_1",
"Kotlin": "PeriodOfGiveLootBoxInSec",
"Default": "0"
},
{
"Field": "openingFailed",
"Slot": "vru_1",
"Kotlin": "OpeningFailed",
"Default": "false"
},
{
"Field": "adsAvailable",
"Slot": "wru_1",
"Kotlin": "AdsAvailable",
"Default": "false"
},
{
"Field": "currentCategory",
"Slot": "xru_1",
"Kotlin": "CurrentCategory",
"Default": "null"
},
{
"Field": "isOpening",
"Slot": "yru_1",
"Kotlin": "IsOpening",
"Default": "false"
},
{
"Field": "isAccelerated",
"Slot": "zru_1",
"Kotlin": "IsAccelerated",
"Default": "false"
},
{
"Field": "isShopVisited",
"Slot": "arv_1",
"Kotlin": "IsShopVisited",
"Default": "false"
},
{
"Field": "isReopening",
"Slot": "brv_1",
"Kotlin": "IsReopening",
"Default": "false"
},
{
"Field": "isBlockOpenButtons",
"Slot": "crv_1",
"Kotlin": "IsBlockOpenButtons",
"Default": "false"
}
],
"CopyMethod": null,
"Branches": [
{
"Guard": "(具名 reducer)",
"Locals": [],
"Kind": "named",
"Expr": "ws.wrv(t, n.wpv_1)"
}
],
"SubReducers": [],
"Counts": {
"TotalInstanceof": null,
"TopLevelBranches": 1,
"SubReducerArms": 0
},
"Locals": {},
"Delegate": "ws.wrv(t, n.wpv_1)"
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"startedAt":1791722775818,"connections":[{"id":0,"url":"wss://c1.3dtank.com:9090/","openTime":1791722779164,"closeCode":1005,"messages":[{"d":"c2s","t":1791722779172,"n":562,"b":"AjAAAQ4JcmVzb3VyY2VzD2NvbmZpZy10ZW1wbGF0ZQRsYW5nEmJyb3dzZXJfdXNlcl9hZ2VudAJvcwtkZXZpY2VfdHlwZQtjbGllbnRfdHlwZRRoYXJkd2FyZV9jb25jdXJyZW5jeQl0aW1lX3pvbmUKdmlkZW9fY2FyZA1tb2JpbGVfZGV2aWNlE3RvdWNoc2NyZWVuX3N1cHBvcnQOY2xpZW50X3ZlcnNpb24IcmFtX3NpemUOH2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9yZXNvdXJjZXMgaHR0cDovL2xvY2FsaG9zdDo4MDgwL2NvbmZpZy54bWwCY259TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1NC4wLjAuMCBTYWZhcmkvNTM3LjM2IEVkZy8xNTQuMC4wLjAFV2luMzIHRGVza3RvcAdCUk9XU0VSAjE2DUFzaWEvU2hhbmdoYWlXQU5HTEUgKEFNRCwgQU1EIFJhZGVvbihUTSkgIDg2ME0gR3JhcGhpY3MgKDB4MDAwMDExMTQpIERpcmVjdDNEMTEgdnNfNV8wIHBzXzVfMCwgRDNEMTEpBWZhbHNlBHRydWUoOTAzMzE3ZDU2NGU1MTdhNTFlZTg4OGNiNmJiMmE3NGYzYTliNWRlZgUzMjc2OA=="},{"d":"s2c","t":1791722780444,"n":46,"b":"ACwAAgJMJZmLUOPImcLvAGcgYZLidJGvMOqqGgrOpZBbmPfvASAAAAAABfaRzA=="},{"d":"s2c","t":1791722781452,"n":12,"b":"AAoAIAAAANY0tpP7"},{"d":"s2c","t":1791722782450,"n":12,"b":"AAoAIAAAAAAF97Io"},{"d":"c2s","t":1791722782779,"n":13,"b":"AAsAIAcGbG9nX2luAA=="}]},{"id":1,"url":"wss://c1.3dtank.com:9090/","openTime":1791722780543,"closeCode":1000,"messages":[{"d":"c2s","t":1791722780543,"n":44,"b":"ACoAAwJMJZmLUOPImcLvAGcgYZLidJGvMOqqGgrOpZBbmPfvAAAAAAX2kcw="},{"d":"s2c","t":1791722780770,"n":914,"b":"B8zo5BHAHSGPMlzV05nCxAiHyCqD0ZxSWkl0c++jfL+2UMld+gmH08EH1xwLmjB+CrVCgCYqxHotUGA1ZKuOhkXJMIWXQwMXgNomQaxsDyZqWs5CVkIcbn5QFVoloRKyetnoo0fRKm5GsF2pumZB9Ja/CPVYtGb9ZJ8zsdcOK2H1sTOm0AoQqAhw7a4dUUTOvNyx+CNNzPyNiF+YNcb14/ynvOQgZI4kO/AFfDw9TSmnm0Kjc1S/L+s7+xttuqvW4iwpBpG/7VQSeINXIDTshuYhs1Q8fz0EgWX6pn2ZoH3WpCXNkRUOgPqWrPbFhk5Jso17IMtH6MSD938M+ohULz4QbBR4shEoBG179C+KJ2YrTwmj/6pq6BCtPlZzTaEu+PuUsexCaVSUgkTvPp0+RDiZ4N/i7aNb+MOZILZ0MrcJmkBSCA16fnqusZpP894VoZrKG6nmCjyZFwAykD2EW3dVvav2c4agKJWwnhtsTkHVce+KixExv7hMx1KUGFHWLMLMkiuuBe0tay20DAcPQKEjsSli+PwclnC12N2vl6kUr/AC60lx0KfKw+C6zuuwh4c6EucRlnsAwOMCnx5QSMHrQX5bk468YQ4qDX0RoXATywB3heHWYDtpIsYbme3b3aX7F3ylet6grbgOrBmczox/+7UuUSU892CxjFWnjz8JWc3HGk3rJMYWyQL5yD8/ay1E8m/VoH+SY44MEiIzCNLP8Zv4BaiHIPQnxElSZuQmgOdueRULMl5gWK2oiADLI8H9zQC8SHknu9fpLl8nA4Ccn4LkuVP1EMTyn23/W9NA2S1KOeEDKws/QH7yc7TKgGJJbCoEeJTY1p3S9mPOMq/BeYX740KpNsP/g1qHypzZZUDf2577Vs2BOZqKBKS01vBlHW+ToeO5xShgPnjr1Wyp6eX3+a/I+k75Eoh2oygHgwmOtMSE3CnMu23ncjZyexcqfFPeS5DCLyAJzeAF5N9w+N5hdhUexWewrXTzycpFW6yPGeh3GXKnFDpM6Nry4z8yRzJmg/hII73T/zSCXFOFvYr8FcK/vSpMgcXllL+gtXR8IOC90e2W7QA2qEO/Mr/seTw+pRDXdsdcQXlhbPmliW5uTd+RAyZlFqNc//hXO61k4oYa2oD/GZ6WPjggj0ukHwhtO0KDN6FSkg7/z1Mv1ketj1LZ7NL1UHkuEYanQmFAX6Xxb0/YLurOqWhZYQ0="},{"d":"c2s","t":1791722781202,"n":23,"b":"ExU7AAAAAAXjsucSNbjQc9EyTBwAAAE="},{"d":"s2c","t":1791722781228,"n":468,"b":"yG2THJkAAAAFOU0yuDUKgJJvDnoBAAABagAAAAUIi8UFAAAABWdmFQUAAAv2AAAAAAAAAAAAAAAF85rNmNCxlUbIEUFVHqhjFS+TPUwYMgAAAAICBCcqQgUqXgwMHxM0FhEYBQRFFW9aIgQQKmQAbPIKWNWXXlh4at+50Y55UfU1AAHMaPI4L1MAAYZA/HXBgAAB/XVrKVCG6+Zstu+2kqzTAlYNChg0qf8FOgEDMAYANwhuUlVTVVgKBC55V1kAWFYNXgkDAAAYD0NDBhsLTRRLARcRS1VbFhQBCxdYQV1FARQfDFlJDFAaUBcAA0pTCwgIAwRGFgJcDwIdBksBB1sGAQNlBgQMCAgDCjIXaXFLRwkXBQIEWgAEAwRfCkdeAggKWwEYDAAKaGoVXgAcYGk9VkBaWDkLRGwJQ15cWEcXYhoOR+cpB2WlEthmS9UxxVVqLanPfSxz6sTzmQPlFDs0I5WNuAAAAAAUVwAACAkaAGRExiKLbMm4c18AAbSqcCeMlgAB/vA344sNAAGa8AVLClYAAVwZFkQVtwABZT3hfQjpAAGdPnK9H58AAWz7Gyi0kwAB7okGng9AAAHdKrntPLcAARy2E6WL4lWOuF1Gn/O3"},{"d":"c2s","t":1791722781233,"n":30,"b":"kRw2AAAAAAX3Wzq6YHydzyTs4kc4bf5/DWVFB6EY"},{"d":"c2s","t":1791722781234,"n":27,"b":"bxlvAAAAAAWClDpZLb745SFPGJGDgAAcr8zS"},{"d":"c2s","t":1791722781235,"n":22,"b":"VBRkAAAAAAUAWma9/z37tnc1aRbsaQ=="},{"d":"c2s","t":1791722781236,"n":20,"b":"OBJjAAAAAAU6k94kB29zHL0sAfk="},{"d":"c2s","t":1791722781236,"n":84,"b":"AFKRAAAAAAXxpJ7qwd3In8nUOYw2WHNlwRoGQieGfy+4NR1CDQMSfnJ8bAp8Nj88JxgqBgMkIiM9WQEDMHVZOnJVFAYARCQkCGAADSQTfj5SJlMq"},{"d":"s2c","t":1791722781285,"n":27,"b":"ABl2AAAAAAX1A0a10aovF3ejYzMUgAAc4XXS"},{"d":"c2s","t":1791722781288,"n":19,"b":"QRFlAAAAAAWB0qpDABtdLwnbuw=="}]},{"id":2,"url":"wss://c1.3dtank.com:9090/","openTime":1791722781531,"closeCode":1005,"messages":[{"d":"c2s","t":1791722781532,"n":44,"b":"ACoAAwJMJZmLUOPImcLvAGcgYZLidJGvMOqqGgrOpZBbmPfvAAAA1jS2k/s="},{"d":"s2c","t":1791722781805,"n":129,"b":"1n/uAAAA1upIXYS/dL/io+8ev7cAAAE0AAABAQAA1tJwh0YJAAAIA1PZQJFqGfBSpKr1PhJuHUOytMtPw4BXTtszV/kGjSAllm0CtPn692dB521z2YHO3y0rDAQ3NTWmGXCxaQ3ElOMAAAHGAAGXOXm/3+4KoQoKAAHJa3kNrACW"},{"d":"c2s","t":1791722781805,"n":23,"b":"gRWpAAAA1q0PGmqKIzrKc/4yTBwAAAE="},{"d":"s2c","t":1791722781905,"n":344,"b":"AVeLIAAAANZiIJn6+T0HSZJfDnqFAAABagAA1oK/RR+TAADWgoJF4AEAAAL6AAAAAAAAAAAAANbKtGgtwWdYLmkNxAUyzOB8cHXdMV8AAAkzAAABCQABl6F5NpEGAAHnLVXuBYBbyHsrCOMCRC52RG0rXLkAXm4HWBUMAD9gaEAbQ1E2Y24cTTITAyZfbQFhXDIjb0lsAxIJAjYuZzwCxsQVYGcZEU57ag0QeXcGGH8NUWYAAl5gEgAKG2g7TUtnAoXjAAGXPnlgJIp+YiRsNqTqKgkMKy6fvAAAAAAAAAGFAAABAQAAAQEAAAILAAAB5gAAAwIAAAalAAAEAQAAAgYAAAWPAAADBAAABgUAAAfmAAAHBwAABQMAAAgIAAAEAQAACQUAAAcHAAAKBAALine truncated
@@ -0,0 +1 @@
{"startedAt":1791725670551,"connections":[{"id":0,"url":"wss://c1.3dtank.com:9090/","openTime":1791725694860,"closeCode":null,"messages":[{"d":"c2s","t":1791725694878,"n":562,"b":"AjAAAQ4JcmVzb3VyY2VzD2NvbmZpZy10ZW1wbGF0ZQRsYW5nEmJyb3dzZXJfdXNlcl9hZ2VudAJvcwtkZXZpY2VfdHlwZQtjbGllbnRfdHlwZRRoYXJkd2FyZV9jb25jdXJyZW5jeQl0aW1lX3pvbmUKdmlkZW9fY2FyZA1tb2JpbGVfZGV2aWNlE3RvdWNoc2NyZWVuX3N1cHBvcnQOY2xpZW50X3ZlcnNpb24IcmFtX3NpemUOH2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9yZXNvdXJjZXMgaHR0cDovL2xvY2FsaG9zdDo4MDgwL2NvbmZpZy54bWwCY259TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1NC4wLjAuMCBTYWZhcmkvNTM3LjM2IEVkZy8xNTQuMC4wLjAFV2luMzIHRGVza3RvcAdCUk9XU0VSAjE2DUFzaWEvU2hhbmdoYWlXQU5HTEUgKEFNRCwgQU1EIFJhZGVvbihUTSkgIDg2ME0gR3JhcGhpY3MgKDB4MDAwMDExMTQpIERpcmVjdDNEMTEgdnNfNV8wIHBzXzVfMCwgRDNEMTEpBWZhbHNlBHRydWUoOTAzMzE3ZDU2NGU1MTdhNTFlZTg4OGNiNmJiMmE3NGYzYTliNWRlZgUzMjc2OA=="},{"d":"s2c","t":1791725695516,"n":46,"b":"ACwAAoEosZi053lR9iGw+UxvKQsyqgQeZ30WbIVjDWEYCHuUASAAAAAABfaRzA=="},{"d":"s2c","t":1791725730696,"n":3,"b":"AAEA"},{"d":"s2c","t":1791725730872,"n":3,"b":"AAEA"},{"d":"s2c","t":1791725730874,"n":3,"b":"AAEA"},{"d":"s2c","t":1791725731646,"n":12,"b":"AAoAIAAAANY0tpP7"},{"d":"s2c","t":1791725733942,"n":12,"b":"AAoAIAAAAAAF97Io"},{"d":"c2s","t":1791725735145,"n":13,"b":"AAsAIAcGbG9nX2luAA=="},{"d":"s2c","t":1791725735645,"n":12,"b":"AAoAIAAAAAAF9v3P"},{"d":"s2c","t":1791725736657,"n":12,"b":"AAoAIAAAAAAF9pOY"},{"d":"c2s","t":1791725741088,"n":41,"b":"ACcAIAcLc2NyZWVuX3Nob3cXeyJzY3JlZW4iOiJMb2JieS5Ib21lIn0="},{"d":"s2c","t":1791725746664,"n":3,"b":"AAEA"},{"d":"s2c","t":1791725756685,"n":3,"b":"AAEA"},{"d":"s2c","t":1791725766706,"n":3,"b":"AAEA"},{"d":"s2c","t":1791725776726,"n":3,"b":"AAEA"}]},{"id":1,"url":"wss://c1.3dtank.com:9090/","openTime":1791725695939,"closeCode":1000,"messages":[{"d":"c2s","t":1791725695943,"n":44,"b":"ACoAA4EosZi053lR9iGw+UxvKQsyqgQeZ30WbIVjDWEYCHuUAAAAAAX2kcw="},{"d":"s2c","t":1791725697046,"n":914,"b":"HtXo5AjZBCGPMkXVypnCxAiHyCqD0ZxSQ0l0c++jfL+2UMld+gmH08EH1xwLmjB+CrVCgCYqxHotUGA1ZKuOhkXJMIWXQwMXgNomQaxsDyZqWs5CVkIcbn5QFVoloRKyetnoo0fRKm5GsF2pumZB9Ja/CPVYtGb9ZJ8zsdcOK2H1sTOm0AoQqAhw7a4dUUTOvNyx+CNNzPyNiF+YNcb14/ynvOQgZI4kO/AFfDw9TSmnm0Kjc1S/L+s7+xttuqvW4iwpBpG/7VQSeINXIDTshuYhs1Q8fz0EgWX6pn2ZoH3WpCXNkRUOgPqWrPbFhk5Jso17IMtH6MSD938M+ohULz4QbBR4shEoBG179C+KJ2YrTwmj/6pq6BCtPlZzTaEu+PuUsexCaVSUgkTvPp0+RDiZ4N/i7aNb+MOZILZ0MrcJmkBSCA16fnqusZpP894VoZrKG6nmCjyZFwAykD2EW3dVvav2c4agKJWwnhtsTkHVce+KixExv7hMx1KUGFHWLMLMkiuuBe0tay20DAcPQKEjsSli+PwclnC12N2vl6kUr/AC60lx0KfKw+C6zuuwh4c6EucRlnsAwOMCnx5QSMHrQX5bk468YQ4qDX0RoXATywB3heHWYDtpIsYbme3b3aX7F3ylet6grbgOrBmczox/+7UuUSU892CxjFWnjz8JWc3HGk3rJMYWyQL5yD8/ay1E8m/VoH+SY44MEiIzCNLP8Zv4BaiHIPQnxElSZuQmgOdueRULMl5gWK2oiADLI8H9zQC8SHknu9fpLl8nA4Ccn4LkuVP1EMTyn23/W9NA2S1KOeEDKws/QH7yc7TKgGJJbCoEeJTY1p3S9mPOMq/BeYX740KpNsP/g1qHypzZZUDf2577Vs2BOZqKBKS01vBlHW+ToeO5xShgPnjr1Wyp6eX3+a/I+k75Eoh2oygHgwmOtMSE3CnMu23ncjZyexcqfFPeS5DCLyAJzeAF5N9w+N5hdhUexWewrXTzycpFW6yPGeh3GXKnFDpM6Nry4z8yRzJmg/hII73T/zSCXFOFvYr8FcK/vSpMgcXllL+gtXR8IOC90e2W7QA2qEO/Mr/seTw+pRDXdsdcQXlhbPmliW5uTd+RAyZlFqNc//hXO61k4oYa2oD/GZ6WPjggj0ukHwhtO0KDN6FSkg7/z1Mv1ketj1LZ7NL1UHkuEYanQmFAX6Xxb0/YLurOqWhZYQ0="},{"d":"c2s","t":1791725730369,"n":23,"b":"ChUiAAAAAAXjq/4LLKHQc8gyTBwAAAE="},{"d":"s2c","t":1791725730699,"n":3,"b":"yQG/"},{"d":"s2c","t":1791725730875,"n":3,"b":"AAEB"},{"d":"s2c","t":1791725730876,"n":3,"b":"AAEB"},{"d":"s2c","t":1791725730877,"n":468,"b":"AdPvHGQAAAAFRW8QuDUKfZJvDnoBAAABagAAAAUIi8UFAAAABWdmFQUAAAv2AAAAAAAAAAAAAAAF85rNmNCxlUbIEUFVHqhjFS+TPUwYMgAAAAICBCcqQgUqXgwMHxM0FhEYBQRFFW9aIgQQKmQAbPIKWNWXXlh4at+50Y55UfU1AAHMaPI4L1MAAYZA/HXBgAAB/XVrKVCG6+Zstu+2kqzTAlYNChg0qf8FOgEDMAYANwhuUlVTVVgKBC55V1kAWFYNXgkDAAAYD0NDBhsLTRRLARcRS1VbFhQBCxdYQV1FARQfDFlJDFAaUBcAA0pTCwgIAwRGFgJcDwIdBksBB1sGAQNlBgQMCAgDCjIXaXFLRwkXBQIEWgAEAwRfCkdeAggKWwEYDAAKaGoVXgAcYGk9VkBaWDkLRGwJQ15cWEcXYhoOR+cpB2WlEthmS9UxxVVqLanPfSxz6sTzmQPlFDs0I5WNuAAAAAAUVwAACAkaAGRExiKLbMm4c18AAbSqcCeMlgAB/vA344sNAAGa8AVLClYAAVwZFkQVtwABZT3hfQjpAAGdPnK9H58AAWz7Gyi0kwAB7okGng9AAAHdKrntPLcAARy2E6WL4lWOuF1Gn/O3"},{"d":"c2s","t":1791725730906,"n":30,"b":"kRw2AAAAAAX3Wzq6YHydzyTs4kc4bf5/DWVFB6EY"},{"d":"c2s","t":1791725730921,"n":27,"b":"bxlvAAAAAAWClDpZLb745SFPGJGDgAAcr8zS"},{"d":"c2s","t":1791725730925,"n":22,"b":"VBRkAAAAAAUAWma9/z37tnc1aRbsaQ=="},{"d":"c2s","t":1791725730932,"n":20,"b":"OBJjAAAAAAU6k94kB29zHL0sAfk="},{"d":"c2s","t":1791725730937,"n":84,"b":"AFKRAAAAAAXxpJ7xv7eV4i/QGzUNGy9lU9W6qjdfBQtALCQnCwoiNBE7ZRIfEGZieR0IIlUDEmwMIFgrYVRdLQpdTzl2H1oKVR8aVX0bATJ/BXcp"},{"d":"s2c","t":1791725731079,"n":27,"b":"ABl2AAAAAAX1A0a10aovF3ejYzMUgAAc4XXS"},{"d":"c2s","t":1791725731115,"n":19,"b":"dBEyAAAAAAXA+4lQeRtdLwnbuw=="}]},{"id":2,"url":"wss://c1.3dtank.com:9090/","openTime":1791725732408,"closeCode":null,"messages":[{"d":"c2s","t":1791725732410,"n":44,"b":"ACoAA4EosZi053lR9iGw+UxvKQsyqgQeZ30WbIVjDWEYCHuUAAAA1jS2k/s="},{"d":"s2c","t":1791725733215,"n":129,"b":"z3/3AALine truncated
@@ -0,0 +1 @@
{"startedAt":1791726276764,"connections":[{"id":0,"url":"wss://c1.3dtank.com:9090/","openTime":1791726278680,"closeCode":null,"messages":[{"d":"c2s","t":1791726278683,"n":562,"b":"AjAAAQ4JcmVzb3VyY2VzD2NvbmZpZy10ZW1wbGF0ZQRsYW5nEmJyb3dzZXJfdXNlcl9hZ2VudAJvcwtkZXZpY2VfdHlwZQtjbGllbnRfdHlwZRRoYXJkd2FyZV9jb25jdXJyZW5jeQl0aW1lX3pvbmUKdmlkZW9fY2FyZA1tb2JpbGVfZGV2aWNlE3RvdWNoc2NyZWVuX3N1cHBvcnQOY2xpZW50X3ZlcnNpb24IcmFtX3NpemUOH2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9yZXNvdXJjZXMgaHR0cDovL2xvY2FsaG9zdDo4MDgwL2NvbmZpZy54bWwCY259TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1NC4wLjAuMCBTYWZhcmkvNTM3LjM2IEVkZy8xNTQuMC4wLjAFV2luMzIHRGVza3RvcAdCUk9XU0VSAjE2DUFzaWEvU2hhbmdoYWlXQU5HTEUgKEFNRCwgQU1EIFJhZGVvbihUTSkgIDg2ME0gR3JhcGhpY3MgKDB4MDAwMDExMTQpIERpcmVjdDNEMTEgdnNfNV8wIHBzXzVfMCwgRDNEMTEpBWZhbHNlBHRydWUoOTAzMzE3ZDU2NGU1MTdhNTFlZTg4OGNiNmJiMmE3NGYzYTliNWRlZgUzMjc2OA=="},{"d":"s2c","t":1791726279816,"n":46,"b":"ACwAAmtj/hD1kn0EM06Pn0np/dc/y8OjonuPdttzW3GnzICRASAAAAAABfaRzA=="},{"d":"s2c","t":1791726289832,"n":3,"b":"AAEA"},{"d":"s2c","t":1791726299844,"n":3,"b":"AAEA"},{"d":"s2c","t":1791726309863,"n":3,"b":"AAEA"}]},{"id":1,"url":"wss://c1.3dtank.com:9090/","openTime":null,"closeCode":1006,"messages":[]}]}
@@ -0,0 +1 @@
{"startedAt":1791726456233,"connections":[{"id":0,"url":"wss://c1.3dtank.com:9090/","openTime":null,"closeCode":1006,"messages":[]}]}
@@ -0,0 +1 @@
{"startedAt":1791726460138,"connections":[{"id":0,"url":"wss://c1.3dtank.com:9090/","openTime":null,"closeCode":1006,"messages":[]}]}
@@ -0,0 +1 @@
{"startedAt":1791726464249,"connections":[]}
+223
View File
@@ -0,0 +1,223 @@
// analyze_action_trace.js —— 把运行时动作打点(toString 原文)解析成「字段类型覆盖表」
//
// 打点来源:module_10849 的 ReduxToModelGateway.dispatch(`yt.uar`),每行是动作类的 toString:
// "SetInviteNeed(inviteNeed=false)"
// "Init(disableRegistrationMessage=null, disableShopMessage=null, …)"
// "SocialNetworkClientIds(socialNetworkClientIds={facebook=…, google=…})"
// "AddMap(mapName=LostTemple)" (无引号文本 = 字符串/枚举名,按 String 记)
//
// 输出:names/action_types_10032.json
// { "SetInviteNeed": { "inviteNeed": "Boolean" }, … }
// 供 gen_action_kt.js 覆盖生成时的类型。
//
// 用法: node analyze_action_trace.js
'use strict';
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const Trace = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'action_trace.json'), 'utf8'));
// 客户端每次上报的是「本页累计缓冲」,页面刷新会覆盖成更小的文件;
// 这里把历史并进 action_trace_merged.json,保证只增不减。
const MergedPath = path.join(PROJECT, 'names', 'action_trace_merged.json');
let Merged = [];
try { Merged = JSON.parse(fs.readFileSync(MergedPath, 'utf8')).trace || []; } catch (E) { Merged = []; }
const SeenText = new Set(Merged.map((R) => R.s));
const Rows = Merged.slice();
for (const R of Trace.trace || []) {
if (SeenText.has(R.s)) continue;
SeenText.add(R.s);
Rows.push(R);
}
fs.writeFileSync(MergedPath, JSON.stringify({ updatedAt: new Date().toISOString(), count: Rows.length, trace: Rows }), 'utf8');
// --- 值解析 -------------------------------------------------------------
function SplitTop(Text, Sep) {
const Out = [];
let Depth = 0;
let Cur = '';
for (const C of Text) {
if ('([{'.includes(C)) Depth++;
else if (')]}'.includes(C)) Depth--;
if (C === Sep && Depth === 0) { Out.push(Cur); Cur = ''; continue; }
Cur += C;
}
if (Cur.trim()) Out.push(Cur);
return Out;
}
// 嵌套模型:模型名 → (字段 → 类型集合)。来源是 runtime toString 里的 `Name(a=1, b=Xxx(...))`
const Models = new Map();
function BucketOf(Name) {
let FieldMap = Models.get(Name);
if (!FieldMap) { FieldMap = new Map(); Models.set(Name, FieldMap); }
return FieldMap;
}
function AddField(FieldMap, Key, T) {
const Set0 = FieldMap.get(Key) || new global.Set();
Set0.add(T);
FieldMap.set(Key, Set0);
}
function RegisterModel(Name, Body) {
const FieldMap = BucketOf(Name);
for (const P of SplitTop(Body, ',')) {
const Eq = P.indexOf('=');
if (Eq < 0) continue;
const Key = P.slice(0, Eq).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(Key)) continue;
AddField(FieldMap, Key, ParseValue(P.slice(Eq + 1)));
}
}
// Kotlin 默认 toString 形态:`TankResistance [resistanceInPercent = 10 resistanceProperty = MINE_RESISTANCE ]`
// 字段之间没有逗号,靠 `名字 = ` 切分
function RegisterSpaceModel(Name, Body) {
const FieldMap = BucketOf(Name);
for (const M of Body.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([\s\S]*?)(?=\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*|$)/g)) {
AddField(FieldMap, M[1], ParseValue(M[2].trim()));
}
}
function ParseValue(V) {
const S = String(V).trim();
if (S === 'true' || S === 'false') return 'Boolean';
if (S === 'null') return 'null';
if (/^-?\d+$/.test(S)) {
const N = Number(S);
return Math.abs(N) > 2147483647 ? 'Long' : 'Int';
}
if (/^-?\d+\.\d+(E-?\d+)?$/.test(S) || /^-?\d+E-?\d+$/i.test(S)) return 'Double';
// 注意 `[object Object]` 也满足 startsWith('[') && endsWith(']'),它不是列表。
// 用 Opaque 标记「运行时看到的是对象,但说不出类型」,与「运行时没信息(空列表/null)」区分开:
// 前者不能被调用点推断覆盖,后者可以。
if (/^\[object [A-Za-z]+\]$/.test(S)) return 'Opaque';
// Kotlin 默认 toString:`Name [a = 1 b = X]`
const SpaceModel = /^([A-Z][A-Za-z0-9_$]*) \[([\s\S]*)\]$/.exec(S);
if (SpaceModel) { RegisterSpaceModel(SpaceModel[1], SpaceModel[2]); return SpaceModel[1]; }
if (S.startsWith('[') && S.endsWith(']')) {
const Inner = S.slice(1, -1).trim();
if (!Inner) return 'List<Any?>';
const Elems = SplitTop(Inner, ',').map(ParseValue);
return 'List<' + UnifyAll(Elems).replace(/\?$/, '') + '>';
}
if (S.startsWith('{') && S.endsWith('}')) {
const Inner = S.slice(1, -1).trim();
if (!Inner) return 'Map<String, Any?>';
const Pairs = SplitTop(Inner, ',').map((P) => {
const Eq = P.indexOf('=');
return Eq < 0 ? 'Any?' : ParseValue(P.slice(Eq + 1));
});
return 'Map<String, ' + UnifyAll(Pairs).replace(/\?$/, '') + '>';
}
const Model = /^([A-Z][A-Za-z0-9_$]*)\(([\s\S]*)\)$/.exec(S);
if (Model) { RegisterModel(Model[1], Model[2]); return Model[1]; }
return 'String';
}
function UnifyAll(List) {
const All = List.filter((T) => T && T !== 'null');
const Distinct = new global.Set(All);
if (!Distinct.size) return 'Any?';
// 运行时见过对象但说不出类型:与「没信息」不同,保留 Opaque 让上层别再拿调用点猜
if (Distinct.has('Opaque')) return 'Opaque';
// 顶层出现真·未知值时不猜
if (Distinct.has('Any?')) return 'Any?';
// 容器:空容器(元素类型未知)不参与冲突判定,让有元素的那次说了算
if ([...Distinct].every((T) => /^List</.test(T))) {
const Known = [...Distinct].filter((T) => T !== 'List<Any?>');
if (!Known.length) return 'List<Any?>';
const Elem = UnifyAll(Known.map((T) => T.slice(5, -1)));
return 'List<' + Elem.replace(/\?$/, '') + '>';
}
if ([...Distinct].every((T) => /^Map</.test(T))) {
const Known = [...Distinct].filter((T) => !/^Map<String, Any\??>$/.test(T));
if (!Known.length) return 'Map<String, Any?>';
const Vals = Known.map((T) => {
const M = /^Map<[^,]+,\s*([\s\S]+)>$/.exec(T);
return M ? M[1] : 'Any?';
});
return 'Map<String, ' + UnifyAll(Vals).replace(/\?$/, '') + '>';
}
if (Distinct.size === 1) return [...Distinct][0];
// Int + Long → Long;其余冲突 → Any?
if (Distinct.size === 2 && Distinct.has('Int') && Distinct.has('Long')) return 'Long';
return 'Any?';
}
// --- 解析每行 ------------------------------------------------------------
const Types = new Map(); // 动作名 → Map<字段, Set<类型>>
// 每个动作名保留第一行原文,供 Kotlin 侧做「回放对照」(字段名/值形态 vs 生成的类)
const Samples = new Map();
let Parsed = 0;
let Skipped = 0;
for (const R of Rows) {
const S = String(R.s || '');
const M = /^([A-Z][A-Za-z0-9_$]*)\((.*)\)$/s.exec(S);
if (!M) { Skipped++; continue; }
const [, Name, Body] = M;
if (!Samples.has(Name)) Samples.set(Name, S);
if (!Body.trim()) { Parsed++; continue; }
const Fields = Types.get(Name) || new Map();
for (const Part of SplitTop(Body, ',')) {
const Eq = Part.indexOf('=');
if (Eq < 0) continue;
const Key = Part.slice(0, Eq).trim();
const Val = ParseValue(Part.slice(Eq + 1));
const Bucket = Fields.get(Key) || new global.Set();
Bucket.add(Val);
Fields.set(Key, Bucket);
}
Types.set(Name, Fields);
Parsed++;
}
// 汇总时补可空性:同一字段只要出现过 null,类型就带 ?
function WithNullability(T, Bucket) {
if (!Bucket.has('null')) return T;
if (T === 'Any?' || T === 'Opaque' || T.endsWith('?')) return T;
return T + '?';
}
// --- 汇总成最终类型 ------------------------------------------------------
const Out = {};
const Unresolved = [];
for (const [Name, Fields] of Types) {
const Row = {};
for (const [Key, Bucket] of Fields) {
const T = WithNullability(UnifyAll([...Bucket]), Bucket);
Row[Key] = T;
if (T === 'Any?' || T === 'Opaque') Unresolved.push(Name + '.' + Key + ' = ' + [...Bucket].join('|'));
}
Out[Name] = Row;
}
// 嵌套模型汇总(动作字段里出现的对象类,如 EmissionColors / Coloring)
const ModelsOut = {};
const ModelUnresolved = [];
for (const [Name, FieldMap] of Models) {
const Row = {};
for (const [Key, Bucket] of FieldMap) {
const T = WithNullability(UnifyAll([...Bucket]), Bucket);
Row[Key] = T;
if (T === 'Any?' || T === 'Opaque') ModelUnresolved.push(Name + '.' + Key + ' = ' + [...Bucket].join('|'));
}
ModelsOut[Name] = Row;
}
fs.writeFileSync(path.join(PROJECT, 'names', 'action_types_10032.json'), JSON.stringify({
generatedAt: new Date().toISOString(),
stats: {
Rows: Rows.length, Parsed, Skipped, Actions: Object.keys(Out).length, Unresolved: Unresolved.length,
Models: Object.keys(ModelsOut).length, ModelFields: Object.values(ModelsOut).reduce((A, B) => A + Object.keys(B).length, 0),
ModelUnresolved: ModelUnresolved.length,
},
Types: Out,
Unresolved,
Models: ModelsOut,
ModelUnresolved,
Samples: Object.fromEntries([...Samples.entries()].sort((A, B) => (A[0] < B[0] ? -1 : 1))),
}, null, 1), 'utf8');
console.log('打点 ' + Rows.length + ' 行:可解析 ' + Parsed + ',跳过(无 toString)' + Skipped +
';动作 ' + Object.keys(Out).length + ',字段类型冲突 ' + Unresolved.length);
console.log('嵌套模型 ' + Object.keys(ModelsOut).length + ' 个,字段 ' +
Object.values(ModelsOut).reduce((A, B) => A + Object.keys(B).length, 0) + ',类型冲突 ' + ModelUnresolved.length);
console.log(' 输出: 3DTank/names/action_types_10032.json');
for (const N of Object.keys(Out).slice(0, 6)) {
console.log(' ' + N + ': ' + JSON.stringify(Out[N]));
}
+71
View File
@@ -0,0 +1,71 @@
# analyze_enum_trace.py - 分析客户端「枚举读取」打点:确认读 enum 时字节游标是否推进
#
# 数据来源:镜像站 module_24635.js 的 Kt.t7h 补丁(枚举读器)。它记录
# {enum: 枚举 kClass 名, pos0/pos1: 读前/读后的字节游标, bit: nullmap 位游标}
# 节流 POST 到 serve.js 的 /__dump__/enum_trace.json。
#
# 判读:
# pos1 - pos0 == 0 → 该枚举不占字节(与源码 EnumCodecInfo/Kt 空实现一致);
# pos1 - pos0 != 0 → 客户端确实为这个枚举读了字节,说明宽度不是常量。
#
# 用法: python tools/analyze_enum_trace.py [--names names]
import json
import os
import sys
from collections import Counter, defaultdict
Root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def Arg(Name, Def):
if '--' + Name in sys.argv:
I = sys.argv.index('--' + Name)
if I + 1 < len(sys.argv):
return sys.argv[I + 1]
return Def
NamesDir = os.path.abspath(Arg('names', os.path.join(Root, 'names')))
TracePath = os.path.join(NamesDir, 'enum_trace.json')
FieldsPath = os.path.join(NamesDir, 'codec_fields.json')
def Main():
if not os.path.exists(TracePath):
print('缺少 ' + TracePath)
print('请先用镜像站进一局战斗(Kt.t7h 已打枚举打点,会自动 POST)。')
return 1
with open(TracePath, 'r', encoding='utf-8') as F:
Data = json.load(F)
Rows = Data.get('trace') or []
print('枚举打点:%d 条(采集于 %s)' % (len(Rows), Data.get('at', '?')))
with open(FieldsPath, 'r', encoding='utf-8') as F:
Codecs = json.load(F).get('Codecs') or {}
# 枚举类型名 → 用到它的 (codec, 字段)
Users = defaultdict(list)
for Key, C in Codecs.items():
for F in C.get('Fields') or []:
T = str(F.get('Type') or '')
if T.startswith('enum<') and T.endswith('>'):
Users[T[5:-1]].append((Key, F.get('Name')))
Delta = Counter()
ByEnum = defaultdict(Counter)
for R in Rows:
D = (R.get('pos1', -1) - R.get('pos0', -1)) if R.get('pos0', -1) >= 0 else -1
Delta[D] += 1
ByEnum[R.get('enum') or '?'][D] += 1
print(' 字节推进量分布(pos1-pos0):%s' % dict(Delta))
print(' 按枚举类型:')
for Name, Cnt in sorted(ByEnum.items(), key=lambda X: -sum(X[1].values()))[:25]:
Where = Users.get(Name) or []
Sample = ('%s.%s' % Where[0]) if Where else '?'
print(' %-4s 次数=%-5d 推进=%s 使用处=%s%s'
% (Name or '?', sum(Cnt.values()), dict(Cnt), Sample, (' 等 %d 处' % len(Where)) if len(Where) > 1 else ''))
return 0
if __name__ == '__main__':
sys.exit(Main())
+164
View File
@@ -0,0 +1,164 @@
// analyze_item_trace.js —— 用客户端 pt 项打点(module_45686::lt.t7h)核对「每个模型到底消费几个 opt 位」
//
// 打点行(names/item_trace.json):{ own, b0, b1, p0, p1 }
// own = modelId 对象(Kotlin Long)的自有属性串,如 "f20_1=0;g20_1=123;"
// b0/b1 = 本项负载起止字节;p0/p1 = 本项消费的绝对位区间(p1-p0 就是该项的 opt 位数)
//
// 做法:把 own 还原成 modelId(用 model_binding 校验哪种高低位组合能命中已知 modelId),
// 再按 modelId 汇总「客户端实测位数」,与字段表递归算出的位数对比,打印不一致的模型。
//
// 用法: node analyze_item_trace.js [--top 30]
'use strict';
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const Argv = process.argv.slice(2);
const Arg = (N, D) => { const I = Argv.indexOf('--' + N); return I >= 0 && Argv[I + 1] ? Argv[I + 1] : D; };
const Top = Number(Arg('top', '30')) || 30;
const J = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'codec_fields.json'), 'utf8'));
const Binding = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'model_binding.json'), 'utf8'));
const KeyByModelId = new Map();
const KnownIds = new Set();
for (const E of Binding.Entries) {
KeyByModelId.set(String(E.ModelId), E.Key);
KnownIds.add(String(E.ModelId));
}
// 字段表递归数 opt 位(与 audit_bit_budget 同口径;list/map 不计元素,元素数由数据决定)
function OptBitsOfKey(Key, Depth) {
if (Depth > 8) return null;
const C = J.Codecs[Key];
if (!C) return null;
let N = 0;
for (const F of C.Fields || []) {
const V = OptBitsOfType(F.Type, Depth);
if (V === null) return null;
N += V;
}
return N;
}
function OptBitsOfType(Type, Depth) {
if (!Type) return null;
let T = String(Type).trim();
let N = 0;
if (T.startsWith('opt<')) {
N++;
T = T.slice(4, -1).trim();
}
const M = /^(list|enum|map)<([\s\S]*)>$/.exec(T);
if (M) {
// 列表/映射的元素在运行时才知道有几个 → 这里只算元素类型自身的「固定」部分
if (M[1] === 'map') {
const P = M[2].split(',');
const A = OptBitsOfType(P[0], Depth + 1);
const B = OptBitsOfType(P[1], Depth + 1);
return A === null || B === null ? N : N + A + B;
}
const Inner = OptBitsOfType(M[2], Depth + 1);
return Inner === null ? N : N + Inner;
}
const K = FindKey(T);
if (!K) return N; // 基础类型 / 未知:只算自身的 opt 位
const Sub = OptBitsOfKey(K, Depth + 1);
if (Sub === null) return N;
return N + Sub;
}
function FindKey(Name) {
if (J.Codecs[Name]) return Name;
for (const K of Object.keys(J.Codecs)) if (K.endsWith('::' + Name)) return K;
// 字段里写的是「模型真名」(如 ParticleEffectsEntity)而不是 codec 键
for (const [K, C] of Object.entries(J.Codecs)) if (C.ModelName === Name) return K;
return null;
}
// own → modelId
function ParseId(Own) {
if (!Own) return null;
const Pairs = Own.split(';').filter(Boolean).map((P) => P.split('='));
const Nums = Pairs.map((P) => Number(P[1])).filter((V) => Number.isFinite(V));
if (Nums.length === 1) return String(Nums[0]);
if (Nums.length !== 2) return null;
const [A, B] = Nums;
const Cand = [
String(BigInt(A) * 4294967296n + BigInt(B >>> 0)), // (high, low)
String(BigInt(B) * 4294967296n + BigInt(A >>> 0)), // (low, high)
];
for (const C of Cand) if (KnownIds.has(C)) return C;
return Cand[0];
}
const Raw = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'item_trace.json'), 'utf8'));
const Rows = Raw.trace || Raw.rows || [];
console.log('pt 项打点 ' + Rows.length + ' 行(at ' + Raw.at + ')');
// --frames N:按「b0 回落」切帧,打印每项 modelId / 位区间 / 字节区间,便于和本地 packets 对照
const WantFrames = Number(Arg('frames', '0')) || 0;
if (WantFrames) {
const Frames = [];
let Cur = null;
let PrevB0 = -1;
for (const R of Rows) {
if (!Cur || R.b0 < PrevB0) { Cur = []; Frames.push(Cur); }
Cur.push(R);
PrevB0 = R.b0;
}
console.log('切出 ' + Frames.length + ' 帧');
const PerFrame = Number(Arg('per-frame', '40')) || 40;
Frames.slice(0, WantFrames).forEach((F, I) => {
console.log('帧#' + (I + 1) + ' 共 ' + F.length + ' 项:');
let PrevBuf = null;
let PrevNm = null;
for (const R of F.slice(0, PerFrame)) {
const Id = ParseId(R.own) || '?';
const Key = KeyByModelId.get(Id) || '';
// buf 变化 = 换了位流(嵌套消息新开一条);nm = 位流总位数
const Sw = PrevBuf !== null && R.buf !== undefined && R.buf !== PrevBuf;
const NmSw = PrevNm !== null && R.nm !== undefined && R.nm !== PrevNm;
if (Sw || NmSw) console.log(' ---- 位流切换:buf ' + PrevBuf + '→' + R.buf + ',nm ' + PrevNm + '→' + R.nm + ' ----');
PrevBuf = R.buf;
PrevNm = R.nm;
console.log(' id=' + Id.padStart(20) + ' ' + Key.padEnd(22) + ' byte ' + R.b0 + '→' + R.b1 +
'(' + (R.b1 - R.b0) + ') 位 ' + R.p0 + '→' + R.p1 + '(' + (R.p1 - R.p0) + ')' +
(R.nm === undefined ? '' : ' 流长 ' + R.nm + ' buf#' + R.buf));
}
if (F.length > PerFrame) console.log(' …还有 ' + (F.length - PerFrame) + ' 项');
});
process.exit(0);
}
const Stats = new Map(); // modelId -> { Count, Bits: Map }
let UnknownId = 0;
for (const R of Rows) {
const Id = ParseId(R.own);
if (!Id) { UnknownId++; continue; }
const Key = KeyByModelId.get(Id);
if (!Key) { UnknownId++; continue; }
const Bits = R.p1 - R.p0;
let S = Stats.get(Id);
if (!S) { S = { Id, Key, Count: 0, Bits: new Map() }; Stats.set(Id, S); }
S.Count++;
S.Bits.set(Bits, (S.Bits.get(Bits) || 0) + 1);
}
console.log('可识别项 ' + [...Stats.values()].reduce((A, S) => A + S.Count, 0) + ',不可识别 ' + UnknownId);
const Diffs = [];
const Same = [];
for (const S of Stats.values()) {
const Our = OptBitsOfKey(S.Key, 0);
const BitsList = [...S.Bits.entries()].sort((A, B) => B[1] - A[1]);
const Main = BitsList[0][0];
const Rec = { Key: S.Key, Id: S.Id, Count: S.Count, Our, Main, BitsList };
if (Our === null || Main !== Our) Diffs.push(Rec);
else Same.push(Rec);
}
Diffs.sort((A, B) => B.Count - A.Count);
console.log('一致 ' + Same.length + ' 个模型;不一致 ' + Diffs.length + ' 个:');
for (const D of Diffs.slice(0, Top)) {
console.log(' ' + D.Key + '(modelId ' + D.Id + ')出现 ' + D.Count + ' 次:客户端实测位 ' +
JSON.stringify(D.BitsList) + ',字段表递归算 ' + D.Our);
}
const SameTop = Same.sort((A, B) => B.Count - A.Count).slice(0, 10);
console.log('一致的模型(前 10):' + SameTop.map((S) => S.Key + '×' + S.Count + '(' + S.Main + '位)').join(', '));
+99
View File
@@ -0,0 +1,99 @@
# analyze_nullmap_trace.py - 把客户端采到的「可选 + 列表」位痕迹定位到具体字段
#
# 数据来源:镜像站 module_24635.js 的 Ht.t7h 补丁(可选读器)。它只对「内层读器是列表」的可选字段
# 记录 {hid, bit, inner, elem, elemKeys, stack},节流 POST 到 serve.js 的 /__dump__/nullmap_trace.json。
# 目的:确认 opt<list<…>> 在真实数据里到底占不占 nullmap 位(例如 ParticleEffectItem.colors)。
#
# 定位方法(不依赖 stack,因为镜像站用 eval 加载模块,栈里没有模块名):
# 1. 每个可选包装器实例在客户端被分配稳定 id(hid),同一字段的所有读取共享同一个 hid;
# 2. elemKeys = 内层列表元素 codec 的槽位名集合。槽位名全局唯一,
# 用它去 names/codec_fields.json 的 Slots 里反查,能唯一确定「元素是哪个 codec」;
# 3. 再找哪些字段的类型是 opt<list<该模型>>,若唯一即定位成功。
#
# 用法: python tools/analyze_nullmap_trace.py [--names names]
import json
import os
import sys
from collections import Counter, defaultdict
Root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def Arg(Name, Def):
if '--' + Name in sys.argv:
I = sys.argv.index('--' + Name)
if I + 1 < len(sys.argv):
return sys.argv[I + 1]
return Def
NamesDir = os.path.abspath(Arg('names', os.path.join(Root, 'names')))
TracePath = os.path.join(NamesDir, 'nullmap_trace.json')
FieldsPath = os.path.join(NamesDir, 'codec_fields.json')
def LoadSlots():
"""槽位集合 → [(codec 键, 模型名)],用于反查元素 codec。"""
with open(FieldsPath, 'r', encoding='utf-8') as F:
Codecs = json.load(F).get('Codecs') or {}
Out = defaultdict(list)
for Key, C in Codecs.items():
Slots = tuple(sorted(S.get('Slot') for S in (C.get('Slots') or [])))
if Slots:
Out[Slots].append((Key, C.get('ModelName') or C.get('ModelClassLocal') or '?'))
return Out, Codecs
def FindOwners(Codecs, ModelName):
"""类型为 opt<list<ModelName>> 的 (codec 键, 字段名)。"""
Out = []
Needle = 'opt<list<' + ModelName + '>>'
for Key, C in Codecs.items():
for F in C.get('Fields') or []:
if F.get('Type') == Needle:
Out.append((Key, F.get('Name')))
return Out
def Main():
if not os.path.exists(TracePath):
print('缺少 ' + TracePath)
print('请先用镜像站进一局战斗(module_24635.js 已打位痕迹补丁,会自动 POST)。')
return 1
with open(TracePath, 'r', encoding='utf-8') as F:
Data = json.load(F)
Rows = Data.get('trace') or []
print('位痕迹:%d 条(采集于 %s)' % (len(Rows), Data.get('at', '?')))
SlotIndex, Codecs = LoadSlots()
ByHid = defaultdict(list)
for R in Rows:
ByHid[R.get('hid')].append(R)
print(' 可选列表字段 %d 个(按 hid 分组):' % len(ByHid))
for Hid in sorted(ByHid, key=lambda H: (H is None, H)):
Group = ByHid[Hid]
ElemKeys = tuple(sorted(Group[0].get('elemKeys') or []))
Elems = SlotIndex.get(ElemKeys) or []
ElemName = Elems[0][1] if len(Elems) == 1 else Group[0].get('elem')
Owners = FindOwners(Codecs, ElemName) if ElemName else []
Bits = Counter(R.get('bit') for R in Group)
Pos = [R.get('pos') for R in Group if isinstance(R.get('pos'), int) and R.get('pos') >= 0]
Seq = ''.join('1' if R.get('bit') else '0' for R in Group[:40])
Where = (Owners[0][0] + ' . ' + str(Owners[0][1])) if len(Owners) == 1 else '?'
print(' hid=%-4s 次数=%-4d bit0=%-4d bit1=%-4d 元素=%-22s 字段=%s'
% (Hid, len(Group), Bits.get(0, 0), Bits.get(1, 0), ElemName or '?', Where))
if Pos:
print(' 客户端位游标 pos:首个=%d 最后=%d 前 12 个=%s'
% (Pos[0], Pos[-1], ','.join(str(P) for P in Pos[:12])))
if len(Seq) >= 4:
print(' 位序列:' + Seq)
if len(Elems) > 1:
print(' 元素多解:' + ', '.join(K + '(' + N + ')' for K, N in Elems[:5]))
if len(Owners) > 1:
print(' 字段多解:' + ', '.join(K + '.' + str(N) for K, N in Owners[:5]))
return 0
if __name__ == '__main__':
sys.exit(Main())
+104
View File
@@ -0,0 +1,104 @@
# analyze_opt_trace.py - 还原客户端每帧的 opt 位序列,按「模型片段」汇总
#
# 数据来源:镜像站 module_24635.js 的 Ht.t7h 打点,每条记录:
# rid = 位读取器 id(同一帧共享)、pos = 读位前绝对位索引、bit = 读到的位、
# slots/elemKeys = 内层读器(或列表元素)的槽位名(全局唯一,可反查模型)。
#
# 输出:把同一帧里「连续属于同一个模型」的读位合并成片段:
# 模型@起始位 × 位数 = 位串
# 这样可以直观看出某个模型到底消费了几位(例如 Item3DCC 是 4 位还是 5 位)。
#
# 用法: python tools/analyze_opt_trace.py [--only bs,Tu,tu] [--frames 5]
import json
import os
import sys
from collections import defaultdict
Root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def Arg(Name, Def):
if '--' + Name in sys.argv:
I = sys.argv.index('--' + Name)
if I + 1 < len(sys.argv):
return sys.argv[I + 1]
return Def
NamesDir = os.path.abspath(Arg('names', os.path.join(Root, 'names')))
TracePath = os.path.join(NamesDir, 'opt_trace.json')
FieldsPath = os.path.join(NamesDir, 'codec_fields.json')
def LoadIndex():
"""槽位集合 → codec 键。用 Fields[].Slot(比 Slots 更全),再补 Slots。"""
with open(FieldsPath, 'r', encoding='utf-8') as F:
Codecs = json.load(F).get('Codecs') or {}
Index = {}
for Key, C in Codecs.items():
Slots = tuple(sorted({str(F2.get('Slot')) for F2 in (C.get('Fields') or []) if F2.get('Slot')}))
if Slots and Slots not in Index:
Index[Slots] = Key
for Key, C in Codecs.items():
Slots = tuple(sorted(S.get('Slot') for S in (C.get('Slots') or [])))
if Slots and Slots not in Index:
Index[Slots] = Key
return Index
def Main():
if not os.path.exists(TracePath):
print('缺少 ' + TracePath + '(先让镜像站进一局)')
return 1
with open(TracePath, 'r', encoding='utf-8') as F:
Data = json.load(F)
Rows = Data.get('trace') or []
print('opt 打点:%d 条(采集于 %s)' % (len(Rows), Data.get('at', '?')))
Index = LoadIndex()
Only = [S for S in (Arg('only', 'Tu,tu,bs') or '').split(',') if S]
MaxFrames = int(Arg('frames', '5') or 5)
Frames = defaultdict(list)
for R in Rows:
Frames[R.get('rid')].append(R)
def NameOf(R):
Slots = tuple(sorted(R.get('slots') or []))
Key = Index.get(Slots)
if Key:
return Key.split('::')[-1]
Elem = tuple(sorted(R.get('elemKeys') or []))
if Elem:
K2 = Index.get(Elem)
return 'list<' + (K2.split('::')[-1] if K2 else '?') + '>'
return '?' + ''.join(Slots)
def Segments(Seq):
Out = []
for R in Seq:
N = NameOf(R)
if Out and Out[-1][0] == N and Out[-1][2] == R.get('pos'):
Out[-1][1] += str(R.get('bit') and 1 or 0)
Out[-1][2] = R.get('pos', 0) + 1
else:
Out.append([N, str(R.get('bit') and 1 or 0), R.get('pos', 0) + 1, R.get('pos', 0)])
return Out
Shown = 0
for Rid in sorted(Frames, key=lambda X: (X is None, X)):
Seq = Frames[Rid]
Segs = Segments(Seq)
if Only and not any(S[0] in Only for S in Segs):
continue
Shown += 1
if Shown > MaxFrames:
continue
Line = ' '.join('%s@%d×%d=%s' % (S[0], S[3], len(S[1]), S[1]) for S in Segs[:40])
print(' rid=%s(%d 位):%s' % (Rid, len(Seq), Line))
print(' 匹配帧数:%d' % Shown)
return 0
if __name__ == '__main__':
sys.exit(Main())
+153
View File
@@ -0,0 +1,153 @@
// audit_bit_budget.js —— 用 nullmap 长度形态反推「真实 opt 位总数」,校验字段表的 opt 判定
//
// 原理(编码端选最长?不,选最短):nullmap 的长度形态由编码端按本次用到的位数选最短——
// 短形态(bit7=0):总长 1..4 字节,容量 5/13/21/29(扣掉 3 位长度标签)
// 长形态(bit7=1,bit6=0):1 字节标签 + L 字节,容量 8L
// 长形态(bit7=1,bit6=1):3 字节标签 + L 字节,容量 8L
// 所以「实际容量」必然等于 minimalCapacity(真实位数)。若和我们算出的位数不一致,
// 说明字段表在某些模型上少算/多算了 opt 位——这类错误**看不出来**:opt 位为 0(有值)时字节完全一样。
//
// 两种模式:
// --calib 只看「不含容器、无短命令」的帧:这些帧 semantic 的位游标就是我们的位数,直接比容量
// --pt 只看向量记录(pt)是最后一帧、且前面记录都能解:容器项解完后位游标应正好用满容量
//
// 用法:
// node audit_bit_budget.js --calib
// node audit_bit_budget.js --pt [--top 20]
'use strict';
const fs = require('fs');
const path = require('path');
const { LoadTables, MakeReader } = require('./lib_space_codec');
const Link = require('./server/link');
const Semantic = require('./server/semantic').Load(path.join(__dirname, '..', 'names'));
const PROJECT = path.resolve(__dirname, '..');
const Argv = process.argv.slice(2);
const Arg = (N, D) => { const I = Argv.indexOf('--' + N); return I >= 0 && Argv[I + 1] ? Argv[I + 1] : D; };
const Mode = Argv.indexOf('--pt') >= 0 ? 'pt' : 'calib';
const Top = Number(Arg('top', '20')) || 20;
// --body:文件即帧体(无长度头),配合 wss_server.js --dump-plain 的产物
const BodyMode = Argv.indexOf('--body') >= 0;
const PT_METHOD = '7640916300855664666';
function CapacityOf(Count) {
if (Count <= 29) return 8 * Math.ceil((Count + 3) / 8) - 3;
if (Count <= 504) return 8 * (Math.ceil((Count + 8) / 8) - 1);
return 8 * Math.ceil(Count / 8);
}
const Tables = LoadTables(path.join(PROJECT, 'names'));
const Binding = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'model_binding.json'), 'utf8'));
const CodecKeyByModelId = new Map();
for (const E of Binding.Entries) CodecKeyByModelId.set(String(E.ModelId), E.Key);
const RESOURCE_CLASS_WIRE = {
Ea: 'i64', nh: 'i64', W_: 'i64', uo: 'i64', pv: 'i64', B_: 'i64', Da: 'i64',
qs: 'i64', uh: 'i64', Gf: 'i64', kv: 'i64', Pc: 'i64',
};
const Alias = {};
for (const [N, R] of Object.entries(Tables.ResourceClasses || {})) Alias[N] = R.Wire;
for (const [N, W] of Object.entries(RESOURCE_CLASS_WIRE)) Alias[N] = W;
const Reader = MakeReader(Tables, {
EnumBytes: 4, EnumInList: 4, OptBits: true, TypeAlias: Alias, StringLiteralOnly: true,
});
function NullmapBits(Bytes) {
const Bits = [];
const Push = (B) => { for (let K = 7; K >= 0; K--) Bits.push((B >> K) & 1); };
if (Bytes.length && Bytes[0] & 0x80) {
for (let I = Bytes[0] & 0x40 ? 3 : 1; I < Bytes.length; I++) Push(Bytes[I]);
} else {
for (const B of Bytes) Push(B);
Bits.splice(0, 3);
}
return Bits;
}
const Dir = path.resolve(Arg('plain', path.join(PROJECT, 'packets', 'plain')));
const Files = [];
function Walk(P) {
for (const E of fs.readdirSync(P, { withFileTypes: true })) {
const Q = path.join(P, E.name);
if (E.isDirectory()) Walk(Q);
else if (/^s2c_\d+\.bin$/.test(E.name)) Files.push(Q);
}
}
Walk(Dir);
let Checked = 0;
const Delta = new Map();
const Rows = [];
for (const File of Files) {
// --body:文件即帧体(无长度头)
const Bodies = [];
if (BodyMode) {
try { Bodies.push(fs.readFileSync(File)); } catch (E) { continue; }
} else {
let Parsed;
try { Parsed = Link.ParseFrames(fs.readFileSync(File)); } catch (E) { continue; }
for (const F of Parsed || []) {
try { Bodies.push(Link.DecodeFrame(F)); } catch (E) {}
}
}
for (const Body of Bodies) {
let Dec;
try { Dec = Semantic.DecodeFrameBody(Body); } catch (E) { continue; }
const Recs = Dec.Records || [];
if (!Recs.length) continue;
const NmSize = Link.NullmapSize(Body, 0);
if (NmSize <= 0 || NmSize > Body.length) continue;
const Pb = Body[0];
const ActualCap = Pb & 0x80 ? 8 * (NmSize - (Pb & 0x40 ? 3 : 1)) : 8 * NmSize - 3;
let Our = null;
let Tail = '';
if (Mode === 'calib') {
// 纯字段表帧:所有记录都不走容器、不是短命令
if (Dec.Bad) continue;
if (Recs.some((R) => R.Layout || R.Short || R.Bad)) continue;
if (typeof Dec.Bits !== 'number') continue;
Our = Dec.Bits;
Tail = String(Recs[Recs.length - 1].MethodId);
} else {
// 容器帧:最后一条记录是 pt,前面没有解不动的
const R = Recs[Recs.length - 1];
if (String(R.MethodId) !== PT_METHOD || R.At === undefined) continue;
if (Recs.slice(0, -1).some((X) => X.Bad)) continue;
const Bits = NullmapBits(Body.slice(0, NmSize));
Reader.AttachBits(Bits, R.BitStart + 1);
let At = R.At + 16;
let Bad = false;
try {
const C = Body.readInt32BE(At);
At += 4 + 16 * C;
const N = Body.readInt32BE(At);
At += 4;
for (let I = 0; I < N; I++) {
const Id = Body.readBigInt64BE(At).toString();
At += 8;
if (Id === '0') { At += 8; continue; }
const Key = CodecKeyByModelId.get(Id) || '未注册';
Tail = Key;
const R2 = Reader.ReadFields(Body, At, Tables.CodecByKey.get(Key), 0);
At = R2.Next;
if (R2.Bad) { Bad = true; break; }
}
} catch (E) { Bad = true; }
Our = Reader.BitCursor().Index;
Reader.DetachBits();
if (Bad) continue;
}
Checked++;
const Want = CapacityOf(Our);
Delta.set(ActualCap - Want, (Delta.get(ActualCap - Want) || 0) + 1);
if (Want !== ActualCap) Rows.push({ File: path.relative(Dir, File), Our, ActualCap, Want, Tail });
}
}
console.log('[' + Mode + '] 可核对帧 ' + Checked + ',容量差分布(实际 - 期望):' +
JSON.stringify([...Delta.entries()].sort((A, B) => B[1] - A[1])));
Rows.sort((A, B) => (B.ActualCap - B.Our) - (A.ActualCap - A.Our));
console.log('不匹配 ' + Rows.length + ' 帧:');
for (const R of Rows.slice(0, Top)) {
console.log(' ' + R.File + ' 我们 ' + R.Our + ' 位(期望容量 ' + R.Want + ') 实际容量 ' + R.ActualCap +
' 差 ' + (R.ActualCap - R.Our) + ' 末项 ' + R.Tail);
}
+271
View File
@@ -0,0 +1,271 @@
// audit_pt_boundaries.js - 只读审计:pt 容器里「每项解完后是否正好落在下一个合法 modelId 上」
//
// 为什么要它:
// 回放失败时只知道「第 N 项之后读出了不存在的 modelId」,但不知道到底是哪一类 codec
// 把字节数算错了(字段表不全 / 类型宽度错 / opt 位错)。这个审计把「边界命中」按 codec 汇总,
// 直接给出嫌疑排序,避免在个别模型上反复猜。
//
// 判定口径(严格、不猜):
// · 一项解完后,紧跟的 8 字节若等于某个「运行期已注册的 modelId」→ 命中;
// · 若正好到负载末尾 → 命中(最后一项);
// · 否则记为该项未命中,并停止解析该记录(后续偏移已不可信)。
//
// 用法: node tools/audit_pt_boundaries.js --plain packets/plain --limit 200000
'use strict';
const fs = require('fs');
const path = require('path');
const { LoadTables, MakeReader } = require('./lib_space_codec');
const Link = require('./server/link');
const Semantic = require('./server/semantic').Load(path.join(__dirname, '..', 'names'));
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const PROJECT = path.resolve(__dirname, '..');
const PlainDir = path.resolve(Arg('plain', path.join(PROJECT, 'packets', 'plain')));
const Limit = Number(Arg('limit', '0')) || 0;
// --body:文件即帧体(无长度头),配合 wss_server.js --dump-plain 的产物
const BodyMode = Argv.indexOf('--body') >= 0;
const Tables = LoadTables(path.join(PROJECT, 'names'));
const Binding = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'model_binding.json'), 'utf8'));
const CodecKeyByModelId = new Map();
const Registered = new Set();
for (const E of Binding.Entries) {
CodecKeyByModelId.set(String(E.ModelId), E.Key);
Registered.add(String(E.ModelId));
}
// 资源类裸名 → 线上基础类型(与 container.js 同口径)
const RESOURCE_CLASS_WIRE = {
Ea: 'i64', nh: 'i64', W_: 'i64', uo: 'i64', pv: 'i64', B_: 'i64', Da: 'i64',
qs: 'i64', uh: 'i64', Gf: 'i64', kv: 'i64', Pc: 'i64',
};
const Alias = {};
for (const [N, R] of Object.entries(Tables.ResourceClasses || {})) Alias[N] = R.Wire;
for (const [N, W] of Object.entries(RESOURCE_CLASS_WIRE)) Alias[N] = W;
// --trace <文件名>:<项序号>:只对这一项打开逐字段追踪,打印「字段 / 字节区间 / 位游标前后」
const TraceArg = Arg('trace', '');
const TraceDepth = Number(Arg('trace-depth', '6')) || 6;
let TraceOn = false;
const EnumBytesArg = Arg('enum-bytes', '4');
const EnumBytes = EnumBytesArg === undefined || EnumBytesArg === '' ? 4 : Number(EnumBytesArg);
const EnumInListArg = Arg('enum-in-list', '');
const EnumInList = EnumInListArg === '' ? EnumBytes : Number(EnumInListArg);
if (!Number.isFinite(EnumBytes) || !Number.isFinite(EnumInList)) throw new Error('--enum-bytes/--enum-in-list 需要数字');
const Reader = MakeReader(Tables, {
EnumBytes,
EnumInList,
EnumListOptional: Argv.indexOf('--enum-list-optional') >= 0,
OptBits: true,
TypeAlias: Alias,
StringLiteralOnly: true,
OnField: (I) => {
if (!TraceOn || I.Depth > TraceDepth) return;
console.log(' ' + ' '.repeat(I.Depth) + I.Name + ' <' + I.Type + '> ' +
I.Start + '→' + I.Next + ' bit ' + I.BitBefore + '→' + I.BitAfter +
(I.Bad ? ' BAD ' + I.Why : ''));
},
});
const PT_METHOD = '7640916300855664666';
// nullmap 位图(与 server/container.js 同口径):长形态首字节 bit7=1,位图从标签之后开始;
// 短形态整串从 MSB 读、跳过前 3 位(那 3 位是长度标签)。
function NullmapBits(Bytes) {
const Bits = [];
const Push = (B) => {
for (let K = 7; K >= 0; K--) Bits.push((B >> K) & 1);
};
if (Bytes.length && Bytes[0] & 0x80) {
for (let I = Bytes[0] & 0x40 ? 3 : 1; I < Bytes.length; I++) Push(Bytes[I]);
} else {
for (const B of Bytes) Push(B);
Bits.splice(0, 3);
}
return Bits;
}
const Stats = { Files: 0, Records: 0, Items: 0, Ok: 0, Bad: 0 };
const ByCodec = new Map(); // key -> { Ok, Bad, FirstBad }
const BadSamples = [];
function Bump(Key, Bad, Where) {
let S = ByCodec.get(Key);
if (!S) {
S = { Key, Ok: 0, Bad: 0, FirstBad: '' };
ByCodec.set(Key, S);
}
if (Bad) {
S.Bad++;
if (!S.FirstBad) S.FirstBad = Where;
} else S.Ok++;
}
function IdAt(Buf, Off) {
return Off + 8 <= Buf.length ? Buf.readBigInt64BE(Off).toString() : null;
}
// 合法边界的三种形态(严格,不猜):
// ① 负载末尾;
// ② 下一个 pt 项:id=0(内联值)或 id 是运行期已注册的 modelId;
// ③ 下一条记录的头:objectId + 已知 methodId。
const KnownMethods = new Set([
'7640916300855664666', '3216143066888387731', '9038756763225879447', '8179162314198150518',
]);
for (const C of JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'downlink_table.json'), 'utf8')).Commands || []) {
KnownMethods.add(String(C.MethodId));
}
for (const C of JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'uplink_table.json'), 'utf8')).Commands || []) {
KnownMethods.add(String(C.MethodId));
}
function IsBoundary(Buf, At) {
if (At === Buf.length) return true;
if (At + 8 > Buf.length) return false;
const Id = IdAt(Buf, At);
if (Id === '0' || Registered.has(Id)) return true;
if (At + 16 <= Buf.length) {
const M = Buf.readBigInt64BE(At + 8).toString();
if (KnownMethods.has(M)) return true;
}
return false;
}
function AuditPt(Body, Off, File, BitStart, Label) {
if (Off + 4 > Body.length) return;
const Buf = Body;
const C = Buf.readInt32BE(Off);
let At = Off + 4;
if (C < 0 || C > 1 << 20 || At + 16 * C + 4 > Buf.length) return;
At += 16 * C;
const D = Buf.readInt32BE(At);
At += 4;
if (D < 0 || D > 1 << 20) return;
Stats.Records++;
// opt 字段的「空/非空」位与记录形态位共用同一条 nullmap 比特流;
// 起始位索引由 semantic.DecodeFrameBody 顺序推进得出(见下面的 BitStart)。
const NmSize = Link.NullmapSize(Body, 0);
const Bits = NmSize > 0 && NmSize <= Body.length ? NullmapBits(Body.slice(0, NmSize)) : null;
// 位游标由 semantic 的顺序推进给出(BitStart = 本记录形态位之后的绝对位索引),
// 不再假设「pt 是帧内第一条记录」——多记录帧里那样会整体错位。
// BitStart 是「本记录形态位」的索引(semantic 在消费形态位之前记录),
// 负载里的 opt 位从它的下一位开始 —— 少加这个 1 会让整条记录的位游标整体早一位。
if (Bits) Reader.AttachBits(Bits, (typeof BitStart === 'number' ? BitStart : 0) + 1);
try {
for (let I = 0; I < D; I++) {
if (At + 8 > Buf.length) return;
const Id = IdAt(Buf, At);
const Key = CodecKeyByModelId.get(Id);
At += 8;
if (Id === '0') {
At += 8;
Stats.Items++;
continue;
}
const Codec = Tables.CodecByKey.get(Key);
const Where = (Label || File) + ' 第 ' + (I + 1) + '/' + D + ' 项 ' + Id + '(' + Key + ')@' + At;
if (!Codec) {
Stats.Bad++;
Bump(Key || ('未注册:' + Id), true, Where);
if (BadSamples.length < 30) BadSamples.push(Where + ':没有 codec');
return;
}
const BitBefore = Bits ? Reader.BitCursor().Index : 0;
const TraceNow = TraceArg && path.basename(File) === TraceArg.split(':')[0] && String(I + 1) === TraceArg.split(':')[1];
if (TraceNow) {
console.log(' [trace] ' + path.basename(File) + ' 第 ' + (I + 1) + ' 项 ' + Id + '(' + Key + ')负载@' + At +
' 起始 bit=' + BitBefore);
TraceOn = true;
}
const R = Reader.ReadFields(Buf, At, Codec, 0);
TraceOn = false;
Stats.Items++;
// 「解不动」的判据要包含位游标:整条模型字段全是 opt 且都为空时,字节不推进但位在推进,
// 这也是合法的(例如 DescriptionMediaCC 只有两个 opt<i64>)。
const BitMoved = Bits ? Reader.BitCursor().Index > BitBefore : false;
if (R.Bad || (R.Next <= At && !BitMoved)) {
Stats.Bad++;
Bump(Key, true, Where + ':解不动 ' + (R.Why || ''));
if (BadSamples.length < 30) BadSamples.push(Where + ':解不动 ' + (R.Why || ''));
return;
}
At = R.Next;
if (IsBoundary(Buf, At)) {
Stats.Ok++;
Bump(Key, false, '');
} else {
Stats.Bad++;
Bump(Key, true, Where + ' → 下一 id=' + IdAt(Buf, At));
if (BadSamples.length < 30) BadSamples.push(Where + ' → 下一 id=' + IdAt(Buf, At));
return;
}
}
} finally {
if (Bits) Reader.DetachBits();
}
}
function ScanFile(File) {
Stats.Files++;
// --body:文件内容就是「解密 + 解压后的帧体」(wss_server.js --dump-plain 的产物),
// 没有 2/4 字节长度头,不能再走 Link.ParseFrames。
const Bodies = [];
if (BodyMode) {
try { Bodies.push(fs.readFileSync(File)); } catch (E) { return; }
} else {
let Frames = null;
try { Frames = Link.ParseFrames(fs.readFileSync(File)); } catch (E) { return; }
if (!Frames) return;
for (const F of Frames) {
try { Bodies.push(Link.DecodeFrame(F)); } catch (E) {}
}
}
for (const Body of Bodies) {
// 用 semantic 顺序解完整帧:它维护跨记录的位游标,并给每条记录标出 BitStart。
// 只审计 pt 记录;其它记录的负载长度由 semantic 负责推进(短命令会中断整帧,属已知限制)。
let Decoded = null;
try {
Decoded = Semantic.DecodeFrameBody(Body);
} catch (E) {
continue;
}
for (const R of (Decoded && Decoded.Records) || []) {
// 注意:不要因为 R.Bad 就跳过——semantic 在同一个 pt 记录上失败,恰恰是我们要审计的对象;
// 只要拿到了 At/BitStart 就独立重解一遍(否则审计会被参考实现的失败“静音”)。
if (String(R.MethodId) !== PT_METHOD || R.At === undefined || R.BitStart === undefined) continue;
const Label = path.basename(path.dirname(File)) + '/' + path.basename(File);
AuditPt(Body, R.At + 16, path.basename(File), R.BitStart, Label);
}
}
}
function Walk(Dir) {
for (const E of fs.readdirSync(Dir, { withFileTypes: true })) {
if (Limit && Stats.Files >= Limit) return;
const P = path.join(Dir, E.name);
if (E.isDirectory()) Walk(P);
else if (/^s2c_\d+\.bin$/.test(E.name)) ScanFile(P);
}
}
Walk(PlainDir);
console.log('pt 边界审计(' + PlainDir + ')');
console.log(' 文件 ' + Stats.Files + ' 记录 ' + Stats.Records + ' 项 ' + Stats.Items +
' 边界命中 ' + Stats.Ok + ' 未命中 ' + Stats.Bad);
const Rows = [...ByCodec.values()].filter((S) => S.Bad > 0).sort((A, B) => B.Bad - A.Bad);
console.log(' 有问题的 codec(按未命中次数):');
for (const S of Rows.slice(0, 20)) {
console.log(' ' + String(S.Bad).padStart(4) + ' / ' + String(S.Bad + S.Ok).padStart(4) +
' ' + S.Key + ' 首个:' + S.FirstBad);
}
if (BadSamples.length) {
console.log(' 样例:');
for (const S of BadSamples.slice(0, 10)) console.log(' ' + S);
}
+26
View File
@@ -0,0 +1,26 @@
// build_info.js - 打印一个 webpack 客户端 bundle 的构建元信息
// 用法: node build_info.js <bundle.js> [more.js ...]
// 输出: 字节数 / 模块数 / publicPath / 入口 / sentry dsn / release / environment / 版本相关常量
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const Grab = (Text, Re) => {
const M = Text.match(Re);
return M ? (M[1] !== undefined ? M[1] : M[0]) : '-';
};
for (const File of process.argv.slice(2)) {
const Buf = fs.readFileSync(File);
const Text = Buf.toString('latin1');
console.log('== ' + path.basename(File) + ' ==');
console.log(' bytes = ' + Buf.length);
console.log(' sha256 = ' + crypto.createHash('sha256').update(Buf).digest('hex'));
console.log(' modules = ' + (Text.match(/(\d{3,6}):(?:\(|function)/g) || []).length);
console.log(' publicPath = ' + Grab(Text, /__webpack_require__\.p\s*=\s*"([^"]*)"/));
console.log(' entry = ' + Grab(Text, /__webpack_require__\((\d+)\);?\s*var\s+__webpack_exports__/));
console.log(' sentry dsn = ' + Grab(Text, /dsn\s*=\s*"([^"]+)"/));
console.log(' release = ' + Grab(Text, /release\s*=\s*"([^"]+)"/));
console.log(' environment = ' + Grab(Text, /environment\s*=\s*"([^"]+)"/));
console.log(' sourceMappingURL = ' + Grab(Text, /sourceMappingURL=([^\s"'*]+)/));
}
+6 -1
View File
@@ -360,6 +360,11 @@ function DecodeFrame(File, Raw) {
}
const DirArg = Arg('dir', '');
// --side:both(默认)| s2c | c2s —— 上/下行帧结构相同([nullmap][记录 = objectId+methodId+负载]),
// 只是文件名区分方向;解上行(c2s = 本机客户端发出去的)时用 --side c2s。
const Side = String(Arg('side', 'both')).toLowerCase();
const FileRe =
Side === 'c2s' ? /^c2s_\d+\.bin$/ : Side === 's2c' ? /^s2c_\d+\.bin$/ : /^(c2s|s2c)_\d+\.bin$/;
const Dirs = [];
if (All || !DirArg) {
const Root = path.join(PROJECT, 'packets', 'plain');
@@ -382,7 +387,7 @@ let StringFields = 0;
const Lines = [];
for (const Dir of Dirs) {
const Files = fs.readdirSync(Dir).filter((F) => /^s2c_\d+\.bin$/.test(F)).sort();
const Files = fs.readdirSync(Dir).filter((F) => FileRe.test(F)).sort();
if (!Files.length) continue;
Lines.push('===== ' + path.basename(Dir) + ' (' + Files.length + ' 帧)');
for (const F of Files) {
+40
View File
@@ -0,0 +1,40 @@
// _dump_codec_src.js —— 打印指定 codec 的 u7h / t7h 源码体(临时诊断用)
// 用法: node _dump_codec_src.js 85183::Tl 85183::Rl ...
'use strict';
const fs = require('fs');
const path = require('path');
const SRC = path.join(__dirname, '..', 'src');
function MatchBrace(Src, Start) {
let Depth = 0;
for (let I = Start; I < Src.length; I++) {
if (Src[I] === '{') Depth++;
else if (Src[I] === '}') { Depth--; if (Depth === 0) return I; }
}
return -1;
}
function ClassMethod(Src, Cls, Kind) {
const Head = new RegExp('\\(\\s*' + Cls.replace(/\$/g, '\\$') + '\\s*\\)\\.' + Kind + '\\s*=\\s*function\\s*\\([^)]*\\)\\s*\\{', 'g');
const Out = [];
let M;
while ((M = Head.exec(Src))) {
const Start = M.index + M[0].length - 1;
const End = MatchBrace(Src, Start);
if (End > 0) Out.push(Src.slice(Start, End + 1));
}
return Out;
}
for (const Key of process.argv.slice(2)) {
const [Mod, Cls] = Key.split('::');
const File = path.join(SRC, (Mod.startsWith('module_') ? Mod : 'module_' + Mod) + '.js');
if (!fs.existsSync(File)) { console.log('!! 无源码 ' + File); continue; }
const Src = fs.readFileSync(File, 'utf8');
console.log('=== ' + Key + ' ===');
for (const Kind of ['u7h', 't7h']) {
const Bodies = ClassMethod(Src, Cls, Kind);
if (!Bodies.length) { console.log(' [' + Kind + '] 未找到'); continue; }
Bodies.forEach((B, I) => console.log(' [' + Kind + '#' + I + '] ' + B.replace(/\s+/g, ' ')));
}
}
+76
View File
@@ -0,0 +1,76 @@
// extract_action_inventory.js —— 把「动作/状态类」结构化成 JSON(S3 状态层的第一步)
//
// 为什么要它:
// module_10032(1.5MB / 1661 导出)里没有 Redux 引擎,只有业务动作与状态数据类。
// 这些类的可恢复信息有三份:
// ① 构造参数(`function Cls(a, b, c) {`)
// ② toString 里的真名与字段顺序(`return "SetFragsCount(a=" + … `)
// ③ 构造体里的 `this.<属性> = <参数>` 绑定
// 本工具把三者对齐成一张表,供后续生成 Kotlin data class / sealed interface。
//
// 用法: node extract_action_inventory.js [模块号=10032] [--out <文件>]
'use strict';
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const Argv = process.argv.slice(2);
const Arg = (N, D) => { const I = Argv.indexOf('--' + N); return I >= 0 && Argv[I + 1] ? Argv[I + 1] : D; };
const Mod = Arg('module', '10032');
const Out = path.resolve(Arg('out', path.join(PROJECT, 'names', 'action_inventory_' + Mod + '.json')));
const Src = fs.readFileSync(path.join(PROJECT, 'src', 'module_' + Mod + '.js'), 'utf8');
// ① 构造参数
const Ctors = new Map(); // 局部类名 → [参数名…]
for (const M of Src.matchAll(/function ([A-Za-z0-9_$]+)\(([^)]*)\)\s*\{/g)) {
if (Ctors.has(M[1])) continue;
const Params = M[2].trim() ? M[2].split(',').map((S) => S.trim()) : [];
Ctors.set(M[1], Params);
}
// ② toString:`(helper(Cls).toString = function () { … return "<Name>(<字段>=" …`
const Rows = [];
for (const M of Src.matchAll(/\(\s*[A-Za-z_$][\w$]*\(([A-Za-z0-9_$]+)\)\.toString = function \(\) \{([\s\S]{0,4000}?)\n\s*\}\)/g)) {
const Cls = M[1];
const Body = M[2];
// 真名既可能紧跟 return(`return "Foo(`),也可能被包裹(`return (\n "Foo(`)
const NameM = /return\s*\(?\s*"([A-Z][A-Za-z0-9_]{1,60})\(/.exec(Body);
if (!NameM) continue;
const Name = NameM[1];
// 字段顺序:`字段名=` 出现顺序(排除内部的 "…=" 之外的表达式影响,只用带标识符前缀的片段)
const Fields = [];
const FieldRe = /([A-Za-z_][A-Za-z0-9_]{0,40})=/g;
let F;
while ((F = FieldRe.exec(Body))) {
if (F[1] === 'return') continue;
Fields.push(F[1]);
}
Rows.push({ Local: Cls, Name, Params: Ctors.get(Cls) || [], ToStringFields: Fields });
}
// ③ 构造体绑定:`this.<属性> = <参数>`
const Binds = new Map(); // 局部类名 → [{Prop, Param}]
for (const M of Src.matchAll(/function ([A-Za-z0-9_$]+)\(([^)]*)\)\s*\{([\s\S]{0,3000}?)\n\s{4}\}/g)) {
const Cls = M[1];
const Body = M[3];
const List = [];
for (const B of Body.matchAll(/this\.([A-Za-z0-9_$]+) = ([A-Za-z0-9_$]+)/g)) List.push({ Prop: B[1], Param: B[2] });
if (List.length) Binds.set(Cls, List);
}
for (const R of Rows) R.Binds = Binds.get(R.Local) || [];
const Names = new Map();
for (const R of Rows) if (!Names.has(R.Name)) Names.set(R.Name, R);
const Out_obj = {
module: Mod,
generatedAt: new Date().toISOString(),
stats: { Rows: Rows.length, UniqueNames: Names.size, WithParams: Rows.filter((R) => R.Params.length).length },
Rows,
};
fs.writeFileSync(Out, JSON.stringify(Out_obj, null, 1), 'utf8');
console.log('动作/状态类 ' + Rows.length + '(唯一名 ' + Names.size + ',有构造参数 ' +
Out_obj.stats.WithParams + ')');
console.log(' 输出: ' + Out);
for (const R of Rows.slice(0, 8)) {
console.log(' ' + R.Name + '(' + R.Params.join(', ') + ') 字段=' + R.ToStringFields.slice(0, 6).join(','));
}
+99 -24
View File
@@ -136,19 +136,16 @@ const PrimTable = {
const PrimName = (K) => PrimTable[K] || 'prim:' + K;
// 读取器方法名 → 线级数值宽度(module_72834 的读取器)
// 注意:这是「读」方法。e98d0216 版里 p2u..u2u 是读、n2u/o2u 是写,旧版口径整体右移了,
// 所以这里以当前源码为准(h(F).p2u→xy、q2u→u2t、r2u→v2t、s2u→w2t、t2u→x2t、u2u→y2t)。
const ReaderType = {
xy: 'i8',
n2u: 'i8',
s2t: 'i16',
o2u: 'i16',
t2t: 'i32',
p2u: 'i32',
u2t: 'i64',
q2u: 'i64',
v2t: 'f32',
r2u: 'f32',
w2t: 'f64',
s2u: 'f64',
p2u: 'i8',
q2u: 'i16',
r2u: 'i32',
s2u: 'i64',
t2u: 'f32',
u2u: 'f64',
};
// 类型描述符表达式 → {Type, Raw}
@@ -226,7 +223,9 @@ const ROLE_KEYS = {
[CODEC_INFO_MODULE + '::b']: 'Enum',
[CODEC_INFO_MODULE + '::d']: 'Map',
[KOLIB_MODULE + '::w4']: 'Prim',
// kClass 助手:旧包导出键 mf,新包(e98d0216)为 nf;两个都收,换版时不必再改
[KOLIB_MODULE + '::mf']: 'KClass',
[KOLIB_MODULE + '::nf']: 'KClass',
};
// module_03569.js → '3569'
@@ -299,8 +298,10 @@ function FindClassNames(Src) {
// 数据类工厂:function Ut() { return ((t = M(d(Ft))), Ft.call(t), t); } → Ut 其实是造 Ft
function FindFactories(Src) {
const Out = {};
// 旧包:return ((X = Y(d(Z))), Z.call(X), X);
// 新包:return ((t = L(w(Mt))), Mt.call(t), t); ← 多包了一层工厂,故放宽为「两层单参调用」
const Re =
/function ([A-Za-z_$][\w$]*)\(\) \{\s*return \(\(([A-Za-z_$][\w$]*) = [A-Za-z_$][\w$]*\(d\(([A-Za-z_$][\w$]*)\)\)\), \3\.call\(\2\), \2\);/g;
/function ([A-Za-z_$][\w$]*)\(\) \{\s*return \(\(([A-Za-z_$][\w$]*) = [A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*)\)\)\), \3\.call\(\2\), \2\);/g;
let M;
while ((M = Re.exec(Src))) if (!Out[M[1]]) Out[M[1]] = M[3];
return Out;
@@ -339,7 +340,8 @@ function FindGetters(Src) {
// q7g 块 → [{Slot, Desc, Type, Raw}]
function ParseSlots(Body, Roles) {
const Out = [];
const Re = /this\.([a-z0-9]+_\d+) = t\.n7f\(/g;
// 字段工厂:旧包 n7f,新包 r7g
const Re = /this\.([a-z0-9]+_\d+) = t\.r7g\(/g;
let M;
while ((M = Re.exec(Body))) {
const Open = Body.indexOf('(', M.index + M[0].length - 1);
@@ -355,7 +357,8 @@ function ParseSlots(Body, Roles) {
// p7g 块 → [{Slot|Reader, Prop, Js, Wire}],次序即线上次序
function ParseOrder(Body) {
const Hits = [];
const Re = /([A-Za-z_$][\w$]*)\(this\)\.p7g\(t\)|t\.a7g_1\.([A-Za-z0-9_$]+)\(\)/g;
// 读序:旧包 p7g,新包 t7h;基础类型走 <缓冲槽位>.<reader>()(旧包固定 a7g_1,新包槽位名会变,故泛化)
const Re = /([A-Za-z_$][\w$]*)\(this\)\.t7h\(t\)|t\.[a-z0-9]+_\d+\.([A-Za-z0-9_$]+)\(\)/g;
let M;
while ((M = Re.exec(Body))) Hits.push({ At: M.index, End: Re.lastIndex, Getter: M[1] || '', Reader: M[2] || '' });
const Out = [];
@@ -499,6 +502,56 @@ function ResolveClass(Name, ModId, ModuleInfo, Seen) {
}
// 单个别名 → { Name, Codec }(Codec = 该类的 codec 键,供递归解码用)
// 协议核心类 / 数学类在线上没有独立 codec,但会话双方对它们的字节形状是固定的。
// 键 = 模块::导出键(唯一身份,避免重名),值 = 线上形状:
// 61860::r = xr(协议实体引用)→ i64
// 37036::sj = Kotlin Long → i64
// 32088::j = Vector3 → 3 × f32(由生成器展开)
const CoreIdentities = {
'61860::r': 'i64',
'37036::sj': 'i64',
'32088::j': 'vector3',
};
// 资源类:模块::导出键;线上只出现 i64 引用(由下面的 y84 注册块填充)
const ResourceIdentities = new Set();
// 类名(模块内短名)→ 稳定身份(模块::导出键)。优先别名,其次导出反查,最后退回本地类名。
function IdentityOf(Name, ModId, ModuleInfo) {
const Info = ModuleInfo[ModId] || {};
const A = (Info.Aliases || {})[Name];
if (A) return A.Mod + '::' + A.Key;
const ExportKey = Object.keys(Info.Exports || {}).find((K) => Info.Exports[K] === Name);
return ModId + '::' + (ExportKey || Name);
}
// 源码里「共享 codec」的登记:q7g(kClass(A), codec.t7g(kClass(B))) → A 与 B 同线形。
// 实例:module_71105.js:12590 的 Jf 复用 qv 的 codec(两者都是资源引用 i64)。
function AddSharedCodecIdentities(ModuleFiles, ModuleInfo) {
for (const File of ModuleFiles) {
let Src;
try {
Src = fs.readFileSync(File, 'utf8');
} catch (Err) {
continue;
}
const ModId = ModIdOf(File);
const Roles = (ModuleInfo[ModId] || {}).Roles || {};
const Re =
/\.q7g\(\s*([A-Za-z_$][\w$]*)\(\s*([A-Za-z_$][\w$]*)\s*\)\s*,\s*[A-Za-z_$][\w$]*\.t7g\(\s*([A-Za-z_$][\w$]*)\(\s*([A-Za-z_$][\w$]*)\s*\)\s*\)/g;
let M;
while ((M = Re.exec(Src))) {
if ((Roles[M[1]] || '') !== 'KClass' || (Roles[M[3]] || '') !== 'KClass') continue;
const A = IdentityOf(M[2], ModId, ModuleInfo);
const B = IdentityOf(M[4], ModId, ModuleInfo);
if (ResourceIdentities.has(B)) ResourceIdentities.add(A);
}
}
}
// 源码侧没有 codec、但有实测标定出处的裸类名(详见 3DTank/tools/server/container.js 的注释)
const ManualClassWire = { Pc: 'i64' };
// 名字优先用 codec 的模型真名;拿不到就退回 toString 得到的名字
function ResolveOne(Name, ModId, ModuleInfo, ClassToCodec) {
const R = ResolveClass(Name, ModId, ModuleInfo, []);
@@ -544,6 +597,12 @@ function ResolveType(Type, ModId, ModuleInfo, ClassToCodec, Trace) {
const Hop = AliasChain(W, ModId, ModuleInfo).find((H) => PrimClassWire[H]);
// 协议核心类没有 codec 时按「线上基础类型」换算(如实体引用 xr → i64),否则保留类名
if (Hop) return PrimClassWire[Hop];
// 核心类型 / 资源类:按 模块::导出键 的稳定身份换算
const Chain = AliasChain(W, ModId, ModuleInfo);
const Core = Chain.find((H) => CoreIdentities[H]);
if (Core) return CoreIdentities[Core];
if (Chain.some((H) => ResourceIdentities.has(H))) return 'i64';
if (ManualClassWire[R.Name]) return ManualClassWire[R.Name];
if (WireClasses[R.Name]) return WireClasses[R.Name];
// 两条都没命中:这个裸类名在线上没有解码器,记进 Leftover 让调用方判「类型没推出来」
else if (Trace) Trace.Leftover.push(W);
@@ -636,7 +695,7 @@ for (const File of ModuleFiles) {
}
const ModId = ModIdOf(File);
const Roles = (ModuleInfo[ModId] || {}).Roles || {};
const ByCodec = new Map(FindBlocks(Src, 'o7g').map((B) => [B.Codec, B.Body]));
const ByCodec = new Map(FindBlocks(Src, 's7h').map((B) => [B.Codec, B.Body]));
if (!ByCodec.size) continue;
const Re = /\.m7f\(\s*([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\s*,\s*new ([A-Za-z_$][\w$]*)\(/g;
let M;
@@ -685,16 +744,31 @@ for (const File of ModuleFiles) {
const ModId = ModIdOf(File);
const Roles = (ModuleInfo[ModId] || {}).Roles || {};
const LineOf = MakeLineIndex(Src);
const Re = /\.u83\(\s*([^,()]+),\s*([A-Za-z_$][\w$]*)\(\s*([A-Za-z_$][\w$]*)\s*\)/g;
// 当前版注册方法:r.y84(<typeId>, oi(<类>), <工厂>);旧版是 .u83(
const Re = /\.y84\(\s*([^,()]+),\s*([A-Za-z_$][\w$]*)\(\s*([A-Za-z_$][\w$]*)\s*\)/g;
let M;
while ((M = Re.exec(Src))) {
if ((Roles[M[2]] || '') !== 'KClass') continue; // 第 2 参必须是 kClass(类)
const Name = ResolveClass(M[3], ModId, ModuleInfo, []).Name;
// 记录稳定身份:注册处传入的可能是本模块别名,也可能是本模块本地类名;
// 后者要反查导出键(类名只是模块内短名,导出键才是跨模块身份)
const Info = ModuleInfo[ModId] || {};
const A = (Info.Aliases || {})[M[3]];
let Identity = '';
if (A) Identity = A.Mod + '::' + A.Key;
else {
const ExportKey = Object.keys(Info.Exports || {}).find((K) => Info.Exports[K] === M[3]);
Identity = ModId + '::' + (ExportKey || M[3]);
}
ResourceIdentities.add(Identity);
if (!ResourceClasses[Name])
ResourceClasses[Name] = { Wire: 'i64', TypeId: M[1].trim(), From: path.basename(File) + ':' + LineOf(M.index) };
}
}
// 资源型 codec 共享(如 Jf 复用 qv)——必须在 codec 抽取之前填好身份集
AddSharedCodecIdentities(ModuleFiles, ModuleInfo);
// ---------------------------------------------------------------- 第二遍:抽 codec
const Codecs = {};
let ModulesUsed = 0;
@@ -713,8 +787,8 @@ for (const File of ModuleFiles) {
const Roles = (ModuleInfo[ModId] || {}).Roles || {};
const Factories = (ModuleInfo[ModId] || {}).Factories || {};
const { SlotToName, GetterToSlot } = FindGetters(Src);
const SlotBlocks = FindBlocks(Src, 'q7g');
const OrderBlocks = FindBlocks(Src, 'p7g');
const SlotBlocks = FindBlocks(Src, 'u7h');
const OrderBlocks = FindBlocks(Src, 't7h');
if (!SlotBlocks.length && !OrderBlocks.length) continue;
ModulesUsed++;
@@ -743,8 +817,8 @@ for (const File of ModuleFiles) {
}
return { Order: O.Order, Name, Type, Js: O.Js, Slot, Desc, Prop: O.Prop, Wire: O.Wire };
});
// 模型类:优先看 o7g 里的 instanceof(直接是数据类),其次看 p7g 用的工厂函数(可能是 Ut → Ft)
const O7g = FindBlocks(Src, 'o7g').find((B) => B.Codec === CodecName);
// 模型类:优先看写方法(旧 o7g / 新 s7h)里的 instanceof,其次看读方法用的工厂函数
const O7g = FindBlocks(Src, 's7h').find((B) => B.Codec === CodecName);
const Inst = O7g && /instanceof ([A-Za-z_$][\w$]*)/.exec(O7g.Body);
const Made = /var [A-Za-z_$][\w$]* = ([A-Za-z_$][\w$]*)\(\)/.exec(OrderBody);
const DataClass = (Inst && Inst[1]) || (Made ? Made[1] : '');
@@ -800,7 +874,7 @@ for (const C of Object.values(Codecs)) {
function ParseSlotAssignments(Src, Roles, LineOf) {
const Out = new Map();
const Conflicts = [];
const Re = /\.n7f\(/g;
const Re = /\.r7g\(/g;
let M;
while ((M = Re.exec(Src))) {
const Open = M.index + M[0].length - 1;
@@ -892,7 +966,7 @@ function FindFieldInfo(Src, Roles) {
Long[M[1]] = { Cls: M[2], Lo: Number(M[3]), Hi: Number(M[4]), Value: LongOf(Number(M[3]), Number(M[4])) };
const Desc = {};
const Re = /\.n7f\(/g;
const Re = /\.r7g\(/g;
while ((M = Re.exec(Src))) {
const Open = M.index + M[0].length - 1;
const Close = MatchBracket(Src, Open, '(', ')');
@@ -966,7 +1040,7 @@ for (const File of ModuleFiles) {
const Fields = [];
if (Host) {
const Head = Src.slice(Host.BodyStart, At);
for (const G of Head.matchAll(/this\.([a-z0-9]+_\d+)\.o7g\(/g)) {
for (const G of Head.matchAll(/this\.([a-z0-9]+_\d+)\.s7h\(/g)) {
const D = Desc[G[1]];
const T = D ? ResolveType(D.Type, ModId, ModuleInfo, ClassToCodec) : { Type: '?', Codecs: [] };
Fields.push({ Field: G[1], Type: T.Type, TypeCodec: T.Codecs[0] || '', Desc: D ? D.Raw : '' });
@@ -1008,7 +1082,8 @@ function FindModelRegistrations(Src, Roles) {
while ((M = ReId.exec(Src))) IdOf[M[1]] = LongOf(Number(M[3]), Number(M[4]));
const Out = [];
if (!Object.keys(IdOf).length) return Out;
const Re = /j7n\(\s*this\.([a-z0-9]+_\d+)\s*,[\s\S]{0,90}?\.n7f\(\s*new ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)/g;
// 旧包登记函数 j7n,新包 n7o;字段工厂旧 n7f / 新 r7g
const Re = /n7o\(\s*this\.([a-z0-9]+_\d+)\s*,[\s\S]{0,90}?\.r7g\(\s*new ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)/g;
while ((M = Re.exec(Src))) {
const Id = IdOf[M[1]];
if (!Id) continue;
+49
View File
@@ -0,0 +1,49 @@
// find_imports.js —— 查询跨模块别名表
//
// 用法:
// node find_imports.js 10032::b1q # 谁导入了某个导出(<depModule>::<exportName>)
// node find_imports.js --module 18334 # 某模块导入了什么
// node find_imports.js --alias 18334 B_ # 某模块里某个别名的来源
'use strict';
const fs = require('fs');
const path = require('path');
const P = path.resolve(__dirname, '..');
const Data = JSON.parse(fs.readFileSync(path.join(P, 'names', 'module_imports.json'), 'utf8'));
const Args = process.argv.slice(2);
if (Args[0] === '--module') {
const Id = Args[1];
const M = Data.Modules[Id];
if (!M) {
console.log('没有模块 ' + Id);
process.exit(1);
}
console.log(`module_${Id}(${M.Style}),导入 ${M.Imports.length} 条:`);
for (const I of M.Imports) console.log(` ${I.Alias} = ${I.Dep}.$_$.${I.Export}`);
process.exit(0);
}
if (Args[0] === '--alias') {
const Id = Args[1];
const Alias = Args[2];
const M = Data.Modules[Id];
if (!M) {
console.log('没有模块 ' + Id);
process.exit(1);
}
const Hits = M.Imports.filter((I) => I.Alias === Alias);
console.log(Hits.length ? JSON.stringify(Hits, null, 1) : '没找到别名 ' + Alias);
process.exit(0);
}
if (!Args[0]) {
console.log('用法:node find_imports.js <depModule>::<exportName> | --module <id> | --alias <id> <alias>');
process.exit(1);
}
const Key = Args[0];
const Consumers = Data.ByExport[Key] || [];
console.log(`${Key} 被 ${Consumers.length} 处导入:`);
for (const C of Consumers) console.log(' ' + C);
+901
View File
@@ -0,0 +1,901 @@
// gen_action_kt.js —— 生成 S3 状态层的 Kotlin 数据类(动作 + 状态)
//
// 输入:
// names/action_inventory_10032.json 类名 / 构造参数 / toString 字段顺序
// names/action_calls_10032.json 每个类的调用点实参表达式
// names/codec_fields.json 协议字段的 Prop → 线上类型(类型反查的权威来源)
// 输出:
// TankiOffline/app/src/jsMain/kotlin/App/State/Inventory.kt
//
// 类型判定(认不出就写 Any? 并附调用点原文,不静默猜):
// !0 / !1 → Boolean
// 纯数字字面量 → Int(带小数点 → Double)
// "…" → String
// <x>.<prop> → 查 codec_fields 的 Prop 得到协议类型再映射 Kotlin
// new <Cls>(…) / <Cls>() → 该 Cls(同表内的类)或 Any?
// 其它 → Any?(注释里留原文)
//
// 用法: node gen_action_kt.js [--out <文件>]
'use strict';
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const Argv = process.argv.slice(2);
const Arg = (N, D) => { const I = Argv.indexOf('--' + N); return I >= 0 && Argv[I + 1] ? Argv[I + 1] : D; };
// --audit:对「已有运行时真值」的字段也跑一遍推断,输出冲突,用来校验推断规则是否可靠
const Audit = Argv.indexOf('--audit') >= 0;
const Conflicts = [];
// --stats:统计仍然认不出的实参形态(决定下一条推断规则该打哪里)
const Stats = Argv.indexOf('--stats') >= 0;
// --why <类名.字段>:打印该字段每个调用点的推断结果,用于排查错型
const Why = Arg('why', '');
const ShapeHist = new Map();
const CallHist = new Map();
function Shape(Expr) {
const E = String(Expr || '').trim();
if (!E) return 'empty';
if (E === 'null') return 'null';
if (/^[A-Za-z_$][\w$]*$/.test(E)) return 'ident';
if (/^this\.[A-Za-z0-9_$]+$/.test(E)) return 'this.field';
if (/^(?:[A-Za-z_$][\w$]*\.)+[A-Za-z0-9_$]+$/.test(E)) return 'a.b.path';
if (/^[A-Za-z_$][\w$]*\(/.test(E) || /^(?:[A-Za-z_$][\w$]*\.)+[A-Za-z0-9_$]+\(/.test(E)) return 'call';
if (/^new\s+/.test(E)) return 'new';
if (/^function|^\(function/.test(E)) return 'func';
if (/^\[/.test(E)) return 'array';
if (/^\{/.test(E)) return 'object';
if (/^!/.test(E)) return 'not';
return 'other';
}
// TankIC/3DTank/tools → TankIC/TankiOffline/...
const OUT = path.resolve(Arg('out', path.join(PROJECT, '..', 'TankiOffline', 'app', 'src', 'jsMain', 'kotlin', 'App', 'State', 'Inventory.kt')));
const Inventory = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'action_inventory_10032.json'), 'utf8'));
const Calls = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'action_calls_10032.json'), 'utf8')).Calls || {};
const Codec = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'codec_fields.json'), 'utf8'));
// 运行时真值(客户端 dispatch 打点解析出来的字段类型)优先级最高
let RuntimeTypes = {};
let RuntimeModels = {};
let RuntimeSamples = {};
try {
const J = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'action_types_10032.json'), 'utf8'));
RuntimeTypes = J.Types || {};
RuntimeModels = J.Models || {};
RuntimeSamples = J.Samples || {};
} catch (E) { RuntimeTypes = {}; RuntimeModels = {}; RuntimeSamples = {}; }
// Prop → 协议类型(重复项若类型不一致则不采用)
const PropType = new Map();
const PropBad = new Set();
for (const C of Object.values(Codec.Codecs)) {
for (const F of C.Fields || []) {
if (!F.Prop) continue;
if (PropBad.has(F.Prop)) continue;
const Prev = PropType.get(F.Prop);
if (Prev && Prev !== F.Type) { PropType.delete(F.Prop); PropBad.add(F.Prop); continue; }
if (!Prev) PropType.set(F.Prop, F.Type);
}
}
// 协议类型 → Kotlin
function KotlinOf(ProtoType) {
let T = String(ProtoType || '').trim();
let Suffix = '';
if (T.startsWith('opt<')) { T = T.slice(4, -1).trim(); Suffix = '?'; }
const L = /^list<([\s\S]+)>$/.exec(T);
if (L) return 'List<' + KotlinOf(L[1]).replace(/\?$/, '') + '>' + Suffix;
const M = /^map<([\s\S]+)>$/.exec(T);
if (M) {
const P = M[1].split(',').map((X) => KotlinOf(X).replace(/\?$/, ''));
return 'Map<' + P[0] + ', ' + P[1] + '>' + Suffix;
}
if (T.startsWith('enum<')) return 'Int' + Suffix;
const Base = {
bool: 'Boolean', i8: 'Int', i16: 'Int', i32: 'Int', i64: 'Long',
f32: 'Float', f64: 'Double', string: 'String', bytes: 'ByteArray',
vector3: 'Vector3',
}[T];
return (Base || 'Any?') + (Base ? Suffix : '');
}
const Names = new Set(Inventory.Rows.map((R) => R.Name));
// 运行时 toString 里出现的嵌套对象类(EmissionColors / Coloring …)。
// 有三种情况:
// ① 已有同名数据类且字段集覆盖运行时字段 → 同一个类,直接用(并把它选为该名字的代表行)
// ② 有同名数据类但字段集对不上 → 不是同一个类,放弃,避免张冠李戴
// ③ 没有同名数据类 → 按运行时字段现生成一个 data class
const RowsByName = new Map();
for (const R of Inventory.Rows) {
const List = RowsByName.get(R.Name) || [];
List.push(R);
RowsByName.set(R.Name, List);
}
const ModelDefs = {};
const KnownModels = new Set();
const EmitModels = new Set();
const RowPick = new Map(); // 名字 → 与运行时证据吻合的那一行
for (const [Name, Fields] of Object.entries(RuntimeModels)) {
const Rows0 = RowsByName.get(Name);
if (Rows0) {
const Keys = Object.keys(Fields);
const Match = Rows0.find((R) => {
const Fs = new Set(R.ToStringFields.length ? R.ToStringFields : R.Params);
return Keys.every((K) => Fs.has(K));
});
if (!Match) continue;
KnownModels.add(Name);
RowPick.set(Name, Match);
continue;
}
ModelDefs[Name] = Fields;
KnownModels.add(Name);
EmitModels.add(Name);
}
// 同名多行时,除了上面的对象类证据,动作的运行时字段集同样能指出「哪一行才是真的」。
// 优先用「样例那一行」的字段集(同名不同类的类型会互相污染,合并后的 RuntimeTypes 可能多出不属于该类的字段)。
function SampleFieldNames(Raw) {
const Open = String(Raw).indexOf('(');
const Close = String(Raw).lastIndexOf(')');
if (Open < 0 || Close <= Open) return [];
const Body = String(Raw).slice(Open + 1, Close);
if (!Body.trim()) return [];
const Parts = [];
let Depth = 0;
let Cur = '';
for (const C of Body) {
if (C === '(' || C === '[' || C === '{') Depth++;
else if (C === ')' || C === ']' || C === '}') Depth--;
if (C === ',' && Depth === 0) { Parts.push(Cur); Cur = ''; continue; }
Cur += C;
}
if (Cur.trim()) Parts.push(Cur);
const Out = [];
for (const P of Parts) {
const Eq = P.indexOf('=');
if (Eq > 0) Out.push(P.slice(0, Eq).trim());
}
return Out;
}
for (const [Name, Raw] of Object.entries(RuntimeSamples)) {
const Rows0 = RowsByName.get(Name);
if (!Rows0) continue;
const Keys = SampleFieldNames(Raw);
if (!Keys.length) continue;
const Match = Rows0.find((R) => {
const Fs = new Set(R.ToStringFields.length ? R.ToStringFields : R.Params);
return Keys.every((K) => Fs.has(K));
});
if (Match) RowPick.set(Name, Match);
}
for (const [Name, Fields] of Object.entries(RuntimeTypes)) {
if (RowPick.has(Name)) continue;
const Rows0 = RowsByName.get(Name);
if (!Rows0) continue;
const Keys = Object.keys(Fields);
const Match = Rows0.find((R) => {
const Fs = new Set(R.ToStringFields.length ? R.ToStringFields : R.Params);
return Keys.every((K) => Fs.has(K));
});
if (Match) RowPick.set(Name, Match);
}
// Kotlin 关键字做属性名时要加反引号
const KotlinKeyword = new Set(['as', 'break', 'class', 'continue', 'do', 'else', 'false', 'for', 'fun', 'if', 'in',
'interface', 'is', 'null', 'object', 'package', 'return', 'super', 'this', 'throw', 'true', 'try', 'typealias',
'typeof', 'val', 'var', 'when', 'while', 'by', 'catch', 'constructor', 'delegate', 'dynamic', 'field', 'file',
'finally', 'get', 'import', 'init', 'param', 'property', 'receiver', 'set', 'setparam', 'where', 'actual',
'abstract', 'annotation', 'companion', 'const', 'crossinline', 'data', 'enum', 'expect', 'external', 'final',
'infix', 'inline', 'inner', 'internal', 'lateinit', 'noinline', 'open', 'operator', 'out', 'override', 'private',
'protected', 'public', 'reified', 'sealed', 'suspend', 'tailrec', 'vararg']);
function EscapeName(N) {
return KotlinKeyword.has(N) ? '`' + N + '`' : N;
}
const PrimitiveKinds = new Set(['Int', 'Long', 'Double', 'Boolean', 'String', 'Any?']);
function InferType(Expr) {
const E = String(Expr || '').trim();
if (!E) return null;
if (E === '!0' || E === '!1') return 'Boolean';
if (/^-?\d+$/.test(E)) return 'Int';
if (/^-?\d+\.\d+$/.test(E)) return 'Double';
if (/^".*"$/.test(E)) return 'String';
const Prop = /^[A-Za-z_$][\w$]*\.([A-Za-z0-9_$]+)$/.exec(E);
if (Prop) {
const T = PropType.get(Prop[1]);
if (T) return KotlinOf(T);
return null;
}
const New = /^new\s+([A-Za-z0-9_$]+)\s*\(/.exec(E);
if (New && Names.has(New[1])) return New[1];
// 布尔表达式要判在顶层:`(i = i === cd ? null : i)` 这种「赋值 + 三元」不是 Boolean
const B = Booleanish(E);
if (B) return B;
return null;
}
function SkipStr(S, I) {
const Q = S[I];
I++;
while (I < S.length) {
if (S[I] === '\\') { I += 2; continue; }
if (S[I] === Q) return I + 1;
I++;
}
return I;
}
function HasTopLevel(S, Needle) {
let Depth = 0;
for (let I = 0; I < S.length; I++) {
const C = S[I];
if (C === '"' || C === "'" || C === '`') { I = SkipStr(S, I) - 1; continue; }
if (C === '(' || C === '[' || C === '{') { Depth++; continue; }
if (C === ')' || C === ']' || C === '}') { Depth--; continue; }
if (Depth === 0 && S.startsWith(Needle, I)) return true;
}
return false;
}
function StripOuterParens(S) {
let T = S;
while (T.startsWith('(') && T.endsWith(')')) {
let Depth = 0;
let ClosedEarly = false;
for (let I = 0; I < T.length; I++) {
const C = T[I];
if (C === '"' || C === "'" || C === '`') { I = SkipStr(T, I) - 1; continue; }
if (C === '(') Depth++;
else if (C === ')') { Depth--; if (Depth === 0 && I < T.length - 1) { ClosedEarly = true; break; } }
}
if (ClosedEarly) break;
T = T.slice(1, -1).trim();
}
return T;
}
// 顶层赋值 `X = expr` → 返回 `=` 的下标(排除 ==/!=/<=/>=)
function TopLevelAssign(S) {
let Depth = 0;
for (let I = 0; I < S.length; I++) {
const C = S[I];
if (C === '"' || C === "'" || C === '`') { I = SkipStr(S, I) - 1; continue; }
if (C === '(' || C === '[' || C === '{') { Depth++; continue; }
if (C === ')' || C === ']' || C === '}') { Depth--; continue; }
if (Depth === 0 && C === '=' && S[I + 1] !== '=' && '=!<>'.indexOf(S[I - 1] || '') < 0) return I;
}
return -1;
}
function Booleanish(E) {
const S = StripOuterParens(String(E || '').trim());
if (!S) return null;
if (S[0] === '!') return 'Boolean';
const Asg = TopLevelAssign(S);
if (Asg >= 0) return Booleanish(S.slice(Asg + 1));
if (HasTopLevel(S, '?')) return null; // 三元表达式类型不定
if (/\.equals\(/.test(S) || /\binstanceof\b/.test(S)) return 'Boolean';
for (const Op of ['===', '!==', '==', '!=', '>=', '<=', '>', '<']) {
if (HasTopLevel(S, Op)) return 'Boolean';
}
return null;
}
// 只接受「当前工程里确实存在」的 Kotlin 类型,否则退回 Any?(避免生成引用不到的名字而编译失败)
// · 标量 / Vector3 / 它们的容器 → 接受
// · 运行时 toString 里的模型类名 → 拒绝(那是 JS 模型名,本工程没有对应 Kotlin 类)
// · 调用点 new <动作类>(...) → 接受(同文件内确有该 data class,且不能是自身)
const Primitive = new Set(['Boolean', 'Int', 'Long', 'Float', 'Double', 'String', 'ByteArray', 'Vector3']);
function CheckType(T, Source, Self) {
const S = String(T).trim().replace(/\?$/, '');
if (Primitive.has(S)) return { Ok: true, Type: T };
const L = /^List<([\s\S]+)>$/.exec(S);
if (L) {
const I = CheckType(L[1], Source, Self);
return I.Ok ? { Ok: true, Type: 'List<' + I.Type.replace(/\?$/, '') + '>' + (/\?$/.test(T) ? '?' : '') } : { Ok: false, Bad: T };
}
const M = /^Map<([\s\S]+)>$/.exec(S);
if (M) {
const P = M[1].split(',').map((X) => CheckType(X.trim(), Source, Self));
return P.every((X) => X.Ok)
? { Ok: true, Type: 'Map<' + P[0].Type.replace(/\?$/, '') + ', ' + P[1].Type.replace(/\?$/, '') + '>' + (/\?$/.test(T) ? '?' : '') }
: { Ok: false, Bad: T };
}
// 运行时 toString 里出现过的对象类(已确认与同名数据类一致,或已现生成),可直接当类型用
if (Source === 'runtime' && KnownModels.has(S) && S !== Self) return { Ok: true, Type: T };
if ((Source === 'callsite' || Source === 'dataflow') && Names.has(S) && S !== Self) return { Ok: true, Type: T };
return { Ok: false, Bad: T };
}
// ---- 纯静态局部数据流:实参是变量时,回溯形参/局部赋值 ----
// 只做两种最可靠的形状,且深度受限、冲突即放弃(不猜):
// ① 标识符是某个函数的形参 → 找该函数的调用点,推断对应位置的实参类型
// ② 标识符最近一次被赋值 → 推断赋值右侧表达式
const SrcDir = path.join(PROJECT, 'src');
const SrcCache = new Map();
function SrcOf(Mod) {
if (!SrcCache.has(Mod)) {
let Text = '';
try { Text = fs.readFileSync(path.join(SrcDir, 'module_' + String(Mod).replace(/^module_/, '') + '.js'), 'utf8'); } catch (E) { Text = ''; }
SrcCache.set(Mod, Text);
}
return SrcCache.get(Mod);
}
// 模块内所有 `function Name(params) {`(带花括号配对得到的函数体范围)
const FnIndexCache = new Map();
const BraceCache = new Map();
function BracePairs(Text) {
if (BraceCache.has(Text)) return BraceCache.get(Text);
const Pairs = new Map();
const Stack = [];
let I = 0;
while (I < Text.length) {
const C = Text[I];
if (C === '"' || C === "'" || C === '`') { I = SkipString(Text, I); continue; }
if (C === '/' && Text[I + 1] === '/') { const N = Text.indexOf('\n', I); if (N < 0) break; I = N; continue; }
if (C === '/' && Text[I + 1] === '*') { const N = Text.indexOf('*/', I + 2); if (N < 0) break; I = N + 2; continue; }
if (C === '{') Stack.push(I);
else if (C === '}') { const O = Stack.pop(); if (O !== undefined) Pairs.set(O, I); }
I++;
}
BraceCache.set(Text, Pairs);
return Pairs;
}
function SkipString(Text, I) {
const Q = Text[I];
I++;
while (I < Text.length) {
if (Text[I] === '\\') { I += 2; continue; }
if (Text[I] === Q) return I + 1;
I++;
}
return I;
}
function FnIndex(Mod) {
if (!FnIndexCache.has(Mod)) {
const Text = SrcOf(Mod);
const Pairs = BracePairs(Text);
const List = [];
for (const M of Text.matchAll(/(?<![\w$.])function\s+([A-Za-z0-9_$]+)\s*\(([^)]*)\)\s*\{/g)) {
const Open = M.index + M[0].length - 1;
const End = Pairs.get(Open);
if (End === undefined) continue;
List.push({
Name: M[1],
Params: M[2].trim() ? M[2].split(',').map((S) => S.trim()) : [],
Start: M.index,
End,
});
}
FnIndexCache.set(Mod, List);
}
return FnIndexCache.get(Mod);
}
// 包住 Off、且形参表里含 Param 的所有函数,由内向外(具名 / 赋值到方法 / IIFE 三种)
function ScopesFor(Mod, Off, Param) {
const Out = [];
for (const F of FnIndex(Mod)) {
if (F.Start <= Off && F.End >= Off && F.Params.indexOf(Param) >= 0) Out.push({ ...F, Kind: 'named' });
}
for (const F of AnonIndex(Mod)) {
if (F.Start > Off || F.End < Off || F.Params.indexOf(Param) < 0) continue;
if (F.Method) Out.push({ ...F, Kind: 'method', Name: F.Method });
if (F.Iife) Out.push({ ...F, Kind: 'iife' });
}
Out.sort((A, B) => B.Start - A.Start);
return Out;
}
// 匿名函数表达式:赋值到方法(`x.m = function (…)`)或立即调用(`(function (…) {…})(a, b)`)
const AnonCache = new Map();
function AnonIndex(Mod) {
if (AnonCache.has(Mod)) return AnonCache.get(Mod);
const Text = SrcOf(Mod);
const Pairs = BracePairs(Text);
const List = [];
for (const M of Text.matchAll(/(?<![\w$.])function\s*\(([^)]*)\)\s*\{/g)) {
const Open = M.index + M[0].length - 1;
const End = Pairs.get(Open);
if (End === undefined) continue;
const Asg = /\.([A-Za-z0-9_$]+)\s*=\s*$/.exec(Text.slice(Math.max(0, M.index - 90), M.index));
const Params = M[1].trim() ? M[1].split(',').map((S) => S.trim()) : [];
// 立即调用:} (…) 或 } ) (…)
let I = End + 1;
while (I < Text.length && /\s/.test(Text[I])) I++;
if (Text[I] === ')') { I++; while (I < Text.length && /\s/.test(Text[I])) I++; }
let Iife = null;
let IifeAt = -1;
if (Text[I] === '(') {
const Close = MatchParen(Text, I);
if (Close > 0) { Iife = SplitArgs(Text.slice(I + 1, Close)); IifeAt = I; }
}
List.push({ Params, Start: M.index, End, Method: Asg ? Asg[1] : null, Iife, IifeAt });
}
AnonCache.set(Mod, List);
return List;
}
// `x.<Name>(…)` 调用点(用于 `x.m = function (…)` 这种原型方法)
const MethodCallsCache = new Map();
function MethodCalls(Mod, Name) {
const Key = Mod + '|.' + Name;
if (MethodCallsCache.has(Key)) return MethodCallsCache.get(Key);
const Text = SrcOf(Mod);
const Out = [];
const Re = new RegExp('\\.' + Name.replace(/[$]/g, '\\$') + '\\s*\\(', 'g');
let M;
while ((M = Re.exec(Text)) && Out.length < 40) {
const Open = Text.indexOf('(', M.index);
if (/=\s*function\s*$/.test(Text.slice(Math.max(0, M.index - 20), M.index))) continue;
const End = MatchParen(Text, Open);
if (End < 0) continue;
Out.push({ Args: SplitArgs(Text.slice(Open + 1, End)), At: M.index });
}
MethodCallsCache.set(Key, Out);
return Out;
}
// 包住 Off 的最内层函数(不管形参是什么)
function InnermostScope(Mod, Off) {
let Best = null;
for (const F of FnIndex(Mod)) if (F.Start <= Off && F.End >= Off && (!Best || F.Start > Best.Start)) Best = { ...F, Kind: 'named' };
for (const F of AnonIndex(Mod)) {
if (F.Start > Off || F.End < Off) continue;
if (!Best || F.Start > Best.Start) Best = { ...F, Kind: F.Method ? 'method' : (F.Iife ? 'iife' : 'anon'), Name: F.Method };
}
return Best;
}
// 原样转发:实参就是所在函数的形参表,且该函数挂在原型上(`ld(Cls).m = function (…) { return new Cls(…) }`)。
// 这是 Kotlin 为 data class 生成的 copy/with,类型信息就是类自身的字段,属于循环,不能当证据。
// 自由函数里的 `make(a, b) { return new X(a, b) }` 不在此列(那条链是有信息的)。
const ForwardCache = new WeakSet();
const ForwardJudged = new WeakSet();
function IsForwarding(Mod, Site) {
if (ForwardJudged.has(Site)) return ForwardCache.has(Site);
ForwardJudged.add(Site);
const Sc = InnermostScope(Mod, Site.At);
if (!Sc || Sc.Kind !== 'method' || !Sc.Params.length) return false;
if (Sc.Params.length !== Site.Args.length) return false;
for (let I = 0; I < Sc.Params.length; I++) if (Site.Args[I].trim() !== Sc.Params[I]) return false;
ForwardCache.add(Site);
return true;
}
const FnCallsCache = new Map();
function FnCalls(Mod, Name) {
const Key = Mod + '|' + Name;
if (FnCallsCache.has(Key)) return FnCallsCache.get(Key);
const Text = SrcOf(Mod);
const Out = [];
const Re = new RegExp('(?<![\\w$.])' + Name.replace(/[$]/g, '\\$') + '\\s*\\(', 'g');
let M;
while ((M = Re.exec(Text)) && Out.length < 40) {
if (/function\s+$/.test(Text.slice(Math.max(0, M.index - 10), M.index))) continue;
const End = MatchParen(Text, Text.indexOf('(', M.index));
if (End < 0) continue;
Out.push({ Args: SplitArgs(Text.slice(Text.indexOf('(', M.index) + 1, End)), At: M.index });
}
FnCallsCache.set(Key, Out);
return Out;
}
function MatchParen(Text, Open) {
let Depth = 0;
for (let I = Open; I < Text.length; I++) {
const C = Text[I];
if (C === '(') Depth++;
else if (C === ')') { Depth--; if (Depth === 0) return I; }
}
return -1;
}
function SplitArgs(Text) {
const Out = [];
let Depth = 0;
let Cur = '';
for (const C of Text) {
if (C === '(' || C === '[' || C === '{') Depth++;
else if (C === ')' || C === ']' || C === '}') Depth--;
if (C === ',' && Depth === 0) { Out.push(Cur.trim()); Cur = ''; continue; }
Cur += C;
}
if (Cur.trim()) Out.push(Cur.trim());
return Out;
}
// [From, To) 范围内最近一次 `X = <expr>`
function LastAssign(Text, Name, From, To) {
const Re = new RegExp('(?<![\\w$.])' + Name.replace(/[$]/g, '\\$') + '\\s*=\\s*([^;\\n]{1,80})', 'g');
let Best = null;
let M;
while ((M = Re.exec(Text)) && M.index < To) {
if (M.index >= From) Best = M[1];
}
if (!Best) return null;
const E = Best.trim().replace(/[,)]+$/, '').trim();
return /^[A-Za-z0-9_$"]/.test(E) || /^!/.test(E) ? E : null;
}
// 多来源类型合一:全同 → 该类型;Int/Long 混合 → Long;否则放弃
function Unify(List) {
const S = new Set(List.filter(Boolean));
if (!S.size) return null;
if (S.size === 1) return [...S][0];
if (S.size === 2 && S.has('Int') && S.has('Long')) return 'Long';
return null;
}
const FlowSeen = new Set();
function InferDeep(Expr, Mod, Off, Depth) {
const E = String(Expr || '').trim();
if (!E) return null;
const Base = InferType(E);
if (Base) return Base;
if (Depth <= 0 || !Mod) return null;
const Text = SrcOf(Mod);
if (!Text) return null;
// a.b.c 形式:用最后一段查协议字段表
const Chain = /^(?:[A-Za-z_$][\w$]*\.)+([A-Za-z0-9_$]+)$/.exec(E);
if (Chain) {
const P = PropType.get(Chain[1]);
return P ? KotlinOf(P) : null;
}
if (!/^[A-Za-z_$][\w$]*$/.test(E)) return null;
const Guard = Mod + '|' + Off + '|' + E + '|' + Depth;
if (FlowSeen.has(Guard)) return null;
FlowSeen.add(Guard);
try {
// ① 形参 → 调用点 / IIFE 实参(由内向外逐层试,按调用点自身的作用域继续回溯)
const Scopes = ScopesFor(Mod, Off, E);
for (const Scope of Scopes) {
const Idx = Scope.Params.indexOf(E);
const Types = [];
if (Scope.Kind === 'iife') {
if (Scope.Iife.length > Idx) Types.push(InferDeep(Scope.Iife[Idx], Mod, Scope.IifeAt, Depth - 1));
} else {
const CallSites = Scope.Kind === 'method' ? MethodCalls(Mod, Scope.Name) : FnCalls(Mod, Scope.Name);
for (const Site of CallSites) {
if (Site.Args.length <= Idx) continue;
Types.push(InferDeep(Site.Args[Idx], Mod, Site.At, Depth - 1));
}
}
const U = Unify(Types);
if (U) return U;
}
// ② 所在函数体内的最近一次赋值
const Assign = LastAssign(Text, E, Scopes.length ? Scopes[0].Start : 0, Off);
if (Assign && Assign !== E) return InferDeep(Assign, Mod, Off, Depth - 1);
} finally {
FlowSeen.delete(Guard);
}
return null;
}
// 个别字段的调用点/运行时证据只能是 Any?(例如 GoTo 样例里的 screen=[object Object]),
// 但切片 reducer 已经能给出强类型时在这里显式指定。键:`类名.字段`。
// GoTo.screen:QK.grm_1 在 screen 切片里只按 ScreenId 使用(instanceof LX/RX + m()/equals),
// 类型定义见 TankiOffline/.../App/State/ScreenId.kt。
const FieldTypeOverrides = new Map([
['GoTo.screen', 'ScreenId'],
// proBattle 切片:Fight 的字段在别处调用点只能推成 Any?,reducer 需要真实类型。
// battleId → Long,battleTeam → BattleTeam(module_31908 的 TEAM_A/TEAM_B/NONE)。
['Fight.battleId', 'Long'],
['Fight.battleTeam', 'BattleTeam'],
// lootBoxes 切片:lH/qH/yH 的字段在别处的调用点只能给出 Boolean / Any?,但 reducer 里分别是
// ContainerType(默认 Ly()=STANDARD)、可空 ContainerType、及补给箱奖励列表。
['SetCurrentContainer.type', 'ContainerType'],
['SelectCategory.containerCategory', 'ContainerType?'],
['ShownRewards.rewards', 'List<ContainerGivenItem>'],
// garage 切片:生成器从调用点只能给 Any?,但 reducer 里这些字段参与 Map 键/算术/子状态类型。
// · 装备/物品 id 在运行时派发里都是 Long;count 是 Int。
// · itemsByBase 的值是 iB(无 toString):Kotlin 侧自建 GarageBaseItems(Items + 派生字段)。
// · baseItemIdToDevices 的值是 List<VF(GarageDevice)>。
['GarageItem.id', 'Long'],
['GarageItem.count', 'Int'],
['GarageDevice.id', 'Long'],
['GarageDevice.baseItemId', 'Long'],
['GarageItem.modification', 'GarageItemModification?'],
['GarageItem.upgradeableParams', 'UpgradableItemParams?'],
['GarageItemsState.itemsByBase', 'Map<Long, GarageBaseItems>'],
['Devices.baseItemIdToDevices', 'Map<Long, List<GarageDevice>>'],
['GarageItemObject.id', 'Long'],
['GarageItemObject.count', 'Int'],
['GarageItemObject.itemCategory', 'Int'],
['GarageItemObject.itemViewCategory', 'Int'],
['GarageItemObject.isPremium', 'Boolean'],
['GarageItemObject.buyable', 'Boolean'],
['GarageItemObject.availableForAll', 'Boolean'],
['GarageItemObject.modification', 'GarageItemModification?'],
['GarageItemObject.upgradeableParams', 'UpgradableItemParams?'],
['GarageItemObject.visible', 'Boolean'],
['GarageDeviceObject.id', 'Long'],
['GarageDeviceObject.baseItemId', 'Long'],
['GarageDeviceObject.itemCategory', 'Int'],
['GarageDeviceObject.itemViewCategory', 'Int'],
['GarageDeviceObject.isPremium', 'Boolean'],
['GarageDeviceObject.buyable', 'Boolean'],
['GarageDeviceObject.visible', 'Boolean'],
['GarageDevicesLoadedAction.mountedDevice', 'GarageDevice?'],
['GarageSelectItem.itemId', 'Long'],
['GarageAddItemToDepot.itemId', 'Long'],
['GarageAddItemToDepot.count', 'Int'],
['GarageRemoveItemFromDepot.item', 'GarageItemObject'],
['GarageRemoveItemFromStore.itemId', 'Long'],
['GarageUpdateItemCount.item', 'GarageItem'],
['GarageUpdateItemCount.count', 'Int'],
['GarageUpgradeItemAction.itemId', 'Long'],
['GarageUpgradeItemCoolDownStarted.itemId', 'Long'],
['GarageUpgradeItemCoolDownStarted.endTime', 'Long'],
['GarageUpgradeItemCoolDownFinished.itemId', 'Long'],
['GarageUpgradeItemCoolDownFinished.endTime', 'Long'],
['GarageRemoveDevice.device', 'GarageDevice'],
['GarageRemoveDevice.item', 'GarageItem'],
['GarageInsertDeviceClientAndServer.device', 'GarageDevice'],
['GarageInsertDeviceClientAndServer.item', 'GarageItem'],
['GarageBuyDevice.device', 'GarageDevice'],
['GarageBuyDevice.item', 'GarageItem'],
['GarageDeviceTrySetInfinityLifetime.deviceId', 'Long'],
['GarageApplyResistanceMount.resistance', 'GarageItem'],
['GarageApplyResistanceMount.index', 'Int'],
['GarageResistanceUnMount.resistance', 'Long'],
['GarageSelectAlteration.alterationId', 'Long'],
['GarageSelectSkin.skinId', 'Long'],
['GarageSelectShotSkin.shotSkinId', 'Long'],
['GarageRemoveDiscountForDevice.item', 'GarageItem'],
['UpdateDiscount.item', 'GarageItem'],
['RemoveFromSale.removeItems', 'Collection<Long>'],
['AddToFavoriteItem.itemId', 'Long'],
['RemoveFromFavoriteItem.itemId', 'Long'],
['BuyProtectionSlot.slotToUnlock', 'Int'],
// garagePreview 切片:状态里这些字段参与 Long/Boolean/Set 运算;动作字段来自真实派发。
['ShowRequest.needShowDrone', 'Boolean'],
['AddRequestedItem.itemId', 'Long'],
['ResetItem3dModelActionCache.itemId', 'Long'],
['SetCaptureActions.screenshotAction', '(() -> Unit)?'],
['SetCaptureActions.videoAction', '(() -> Unit)?'],
// user 切片:按状态字段/copy 调用点收口;y8 在源码里有 `new y8(null)`,userId 必须可空。
['SetUserId.userId', 'Long?'],
['UpdateRank.rank', 'Int'],
['UpdateRank.score', 'Int'],
['UpdateUserRating.userRating', 'Int'],
['SpendCrystals.crystal', 'Int'],
['SpendCoins.coins', 'Int'],
['AddCoins.coins', 'Int'],
['SpendRubies.rubies', 'Int'],
['AddRubies.rubies', 'Int'],
['SetHasTemporaryCash.hasTemporaryCash', 'Boolean'],
['UpdateUid.uid', 'String'],
['UpdateEventPass.eventPassEndTime', 'Long'],
['SetPremiumExpirationTime.expirationTime', 'Long'],
['SetDisabledWithoutPremiumLocations.locations', 'Collection<Int>'],
]);
const Emitted = [];
// 动作目录:类名 + 字段(名, 类型),用于生成 ActionCatalog.kt(运行时按名字查类)
const Catalog = [];
const Seen = new Set();
let TypedFields = 0;
let TotalFields = 0;
for (const R of Inventory.Rows) {
if (Seen.has(R.Name)) continue;
// 同名多行时,优先用与运行时证据吻合的那一行
const Pick = RowPick.get(R.Name);
if (Pick && Pick !== R) continue;
Seen.add(R.Name);
// 调用点按「动作名|局部类名」索引:同名不同类(如 Init 的 1 字段版/18 字段版)不能混用
const Sites = Calls[R.Name + '|' + R.Local] || [];
const Fields = R.ToStringFields.length ? R.ToStringFields : R.Params.map((P, I) => 'field' + (I + 1));
const Lines = [];
const FieldPairs = [];
Fields.forEach((F, I) => {
TotalFields++;
// ① 运行时打点(真值)→ ② 调用点/数据流反推 → ③ Any?
// 运行时也可能「什么也没说」(值全是空列表 / [object Object]),这时不能挡住调用点推断
const RtRaw = RuntimeTypes[R.Name] && RuntimeTypes[R.Name][F];
// Opaque = 运行时看到对象但说不出类型,此时不能用调用点猜;其余「没信息」类型才让位
const Rt = !RtRaw ? null
: (RtRaw === 'Opaque' ? 'Any?'
: (/^(Any\?|List<Any\?>|Map<String, Any\?>|Map<String, Any>)$/.test(RtRaw) ? null : RtRaw));
let Inferred = null;
let InferredSource = '';
const Notes = [];
let NullSeen = false;
let SiteSeen = 0;
let SiteSkipped = 0;
{
const Deep = [];
for (const S of Sites) {
const A = S.Args[I];
if (A === undefined) continue;
SiteSeen++;
if (Why === R.Name + '.' + F) {
const Direct = InferType(A);
const DeepOne = Direct || InferDeep(A, S.Module, S.At, 2);
console.log(' [why] ' + S.Module + '@' + S.At + ' 实参=' + String(A).replace(/\s+/g, ' ').slice(0, 70) +
' 直接=' + (Direct || '-') + ' 深推=' + (DeepOne || '-'));
}
if (IsForwarding(S.Module, S)) { SiteSkipped++; continue; }
if (A.trim() === 'null') NullSeen = true;
const T = InferType(A);
if (T) { if (!Inferred) { Inferred = T; InferredSource = 'callsite'; } }
else {
if (Notes.length < 2) Notes.push(A.slice(0, 40));
const D = InferDeep(A, S.Module, S.At, 2);
if (D) Deep.push(D);
}
}
if (!Inferred) {
const U = Unify(Deep);
if (U) { Inferred = U; InferredSource = 'dataflow'; }
}
}
// 协议字段表的 opt<> 只说明「线上可选」,动作字段本身没观察到 null 就当非空。
// 但运行时若见过 null(RtRaw 为 Any? 或带 ?),保留可空性。
if (Inferred && !NullSeen) {
const RuntimeNullish = RtRaw === 'Any?' || (RtRaw && RtRaw.endsWith('?'));
if (!RuntimeNullish) Inferred = Inferred.replace(/\?$/, '');
}
if (Audit && Rt && Inferred && Rt.replace(/\?$/, '') !== Inferred.replace(/\?$/, '')) {
Conflicts.push(R.Name + '.' + F + ' 运行时=' + Rt + ' 推断=' + Inferred + '(' + InferredSource + ')');
}
let Type = Rt || Inferred;
let Source = Rt ? 'runtime' : InferredSource;
let Note = '';
if (Type) {
const C = CheckType(Type, Source, R.Name);
if (C.Ok) Type = C.Type;
else { Note = '// 未收录类型: ' + C.Bad + '(' + Source + ')'; Type = null; }
}
const Override = FieldTypeOverrides.get(R.Name + '.' + F);
if (Override) { Type = Override; Source = 'override'; Note = ''; }
if (Type) TypedFields++;
if (Stats && !Type) {
for (const S of Sites) {
const A = S.Args[I];
if (A === undefined) continue;
const K = Shape(A);
ShapeHist.set(K, (ShapeHist.get(K) || 0) + 1);
if (K === 'call') {
const All = String(A).trim().match(/[A-Za-z0-9_$]+(?=\s*\()/g) || [];
const Name = All.length ? All[All.length - 1] : '?';
CallHist.set(Name, (CallHist.get(Name) || 0) + 1);
}
}
}
if (!Type && !Note) {
if (SiteSeen && SiteSkipped === SiteSeen) Note = '// 仅 copy 转发(无类型信息)';
else Note = Notes.length ? '// 调用点: ' + Notes.join(' | ') : '// 无调用点';
}
if (!Note) Note = '// ' + Source;
// 注释必须压成单行,且逗号要放在注释之前(否则逗号被 // 吞掉)
Note = Note.replace(/\s+/g, ' ').slice(0, 120);
Lines.push(' val ' + F + ': ' + (Type || 'Any?') + ', ' + Note);
FieldPairs.push([F, Type || 'Any?']);
});
Emitted.push('data class ' + R.Name + '(\n' + Lines.join('\n') + '\n)');
Catalog.push({ Name: R.Name, Fields: FieldPairs });
}
// 嵌套模型类(字段名来自 runtime toString,类型来自样例值)
function ModelFieldType(T) {
const S = String(T || '').trim();
// 可空性直接透传(Any? 本身已经是可空)
if (S.endsWith('?') && S !== 'Any?') {
const Inner = ModelFieldType(S.slice(0, -1));
return Inner === 'Any?' ? 'Any?' : Inner + '?';
}
if (PrimitiveKinds.has(S)) return S;
const L = /^List<([\s\S]+)>$/.exec(S);
if (L) {
const I = ModelFieldType(L[1]);
return 'List<' + (I === 'Any?' ? 'Any?' : I) + '>';
}
const M = /^Map<([\s\S]+),\s*([\s\S]+)>$/.exec(S);
if (M) return 'Map<String, ' + ModelFieldType(M[2]) + '>';
return KnownModels.has(S) ? S : 'Any?';
}
const ModelEmitted = [];
let ModelTyped = 0;
let ModelTotal = 0;
for (const Name of [...EmitModels].sort()) {
const Fields = ModelDefs[Name];
const Lines = [];
for (const [F, T] of Object.entries(Fields)) {
ModelTotal++;
const K = ModelFieldType(T);
if (K !== 'Any?') ModelTyped++;
Lines.push(' val ' + EscapeName(F) + ': ' + K + ', // ' + (K === 'Any?' ? '未收录: ' + T : 'runtime'));
}
if (!Lines.length) continue;
ModelEmitted.push('data class ' + Name + '(\n' + Lines.join('\n') + '\n)');
}
const Head = [
'// 本文件由 3DTank/tools/gen_action_kt.js 生成。',
'// 来源:module_10032 的动作/状态类(类名与字段顺序取自 toString 证据,类型由调用点 + 协议字段表反推)。',
'// 标注 Any? 的字段表示调用点认不出类型,行尾注释给出原始实参,便于人工补。',
'package App.State',
'',
'import Core.Vector3',
'',
'// 动作/状态类共 ' + Emitted.length + ' 个,字段 ' + TotalFields + ' 个(已定类型 ' + TypedFields + ' 个)',
'',
];
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, Head.join('\n') + Emitted.join('\n\n') + '\n\n' +
'// ---- 嵌套对象类(来自运行时 toString 样例:字段名是证据,类型由样例值推断)----\n' +
'// 共 ' + ModelEmitted.length + ' 个,字段 ' + ModelTotal + ' 个(已定类型 ' + ModelTyped + ' 个)\n\n' +
ModelEmitted.join('\n\n') + '\n', 'utf8');
console.log('生成 ' + Emitted.length + ' 个 data class,字段 ' + TotalFields + ',已定类型 ' + TypedFields +
'(' + ((TypedFields * 100 / Math.max(1, TotalFields)).toFixed(1)) + '%)');
console.log('生成 ' + ModelEmitted.length + ' 个嵌套对象类,字段 ' + ModelTotal + ',已定类型 ' + ModelTyped);
// ---- 动作目录:名字 → Kotlin 类 + 字段类型(运行时按 dispatch 打点里的名字反查)----
const CatalogPath = path.join(path.dirname(OUT), 'ActionCatalog.kt');
const TracedNames = Object.keys(RuntimeTypes).sort();
// 不在 module_10032 里、但有 toString 证据的动作。
// InitState:module_10849 的 ct,toString 为 `InitState(type=…, action=…)`,已在 State 模块实现
const ExternalActions = [
{ Name: 'InitState', Type: 'InitState', Fields: [['type', 'Any?'], ['action', 'Any?']] },
];
const CatalogLines = [];
for (const Row of Catalog) {
const Fields = Row.Fields.map(([N, T]) => 'ActionFieldMeta("' + N + '", "' + T + '")').join(', ');
CatalogLines.push(' ActionMeta("' + Row.Name + '", ' + Row.Name + '::class, listOf(' + Fields + ')),');
}
for (const Row of ExternalActions) {
const Fields = Row.Fields.map(([N, T]) => 'ActionFieldMeta("' + N + '", "' + T + '")').join(', ');
CatalogLines.push(' ActionMeta("' + Row.Name + '", ' + Row.Type + '::class, listOf(' + Fields + ')),');
}
const CatalogText = [
'// 本文件由 3DTank/tools/gen_action_kt.js 生成。',
'// 动作/状态类目录:把 dispatch 打点里的字符串类名映射到 Kotlin 类(字段类型一并带上)。',
'package App.State',
'',
'import kotlin.reflect.KClass',
'import State.InitState',
'',
'data class ActionFieldMeta(val Name: String, val Type: String)',
'',
'data class ActionMeta(val Name: String, val Type: KClass<*>, val Fields: List<ActionFieldMeta>)',
'',
'object ActionCatalog {',
' val All: List<ActionMeta> = listOf(',
CatalogLines.join('\n'),
' )',
'',
' val ByName: Map<String, ActionMeta> = All.associateBy { it.Name }',
'',
' /** 运行时 dispatch 打点里真实出现过的动作名(真值证据)。 */',
' val TracedNames: Set<String> = setOf(' + TracedNames.map((N) => '"' + N + '"').join(', ') + ')',
'',
' /** 打点里出现但目录里没有的名字(正常应为空)。 */',
' fun MissingTraced(): List<String> = TracedNames.filter { !ByName.containsKey(it) }.sorted()',
'}',
'',
].join('\n');
fs.writeFileSync(CatalogPath, CatalogText, 'utf8');
console.log('生成动作目录 ' + (Catalog.length + ExternalActions.length) + ' 条(打点出现过 ' + TracedNames.length + ' 个名字)');
// ---- 回放样例:每个动作在运行时打点里的第一行原文(供 Kotlin 侧字段/类型对照)----
const SamplesPath = path.join(path.dirname(OUT), 'ActionSamples.kt');
const EscapeKt = (S) => String(S).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/\r?\n/g, '\\n');
const SampleNames = Object.keys(RuntimeSamples).sort();
const SampleLines = SampleNames.map((N) => ' "' + N + '" to "' + EscapeKt(RuntimeSamples[N]) + '",');
const SamplesText = [
'// 本文件由 3DTank/tools/gen_action_kt.js 生成。',
'// 每个动作在运行时 dispatch 打点里的第一行原文(真值样例),用于回放对照。',
'package App.State',
'',
'object ActionSamples {',
' val Raw: Map<String, String> = mapOf(',
SampleLines.join('\n'),
' )',
'}',
'',
].join('\n');
fs.writeFileSync(SamplesPath, SamplesText, 'utf8');
console.log('生成回放样例 ' + SampleNames.length + ' 条');
if (Audit) {
console.log('推断 vs 运行时真值冲突 ' + Conflicts.length + ' 处');
for (const C of Conflicts.slice(0, 25)) console.log(' ' + C);
}
if (Stats) {
console.log('认不出的实参形态:');
for (const [K, V] of [...ShapeHist.entries()].sort((A, B) => B[1] - A[1])) console.log(' ' + K + ' = ' + V);
console.log('其中调用表达式的被调名 Top 15:');
for (const [K, V] of [...CallHist.entries()].sort((A, B) => B[1] - A[1]).slice(0, 15)) console.log(' ' + K + ' = ' + V);
}
console.log(' 输出: ' + OUT);
+197
View File
@@ -0,0 +1,197 @@
// gen_downlink_actions.js —— 生成「下行 methodId → 协议模型 → 客户端动作」表
//
// 数据源(都不猜,全部来自既有产物):
// 1. names/method_table.json methodId → modelId / codec
// 2. names/model_binding.json modelId → module::codec
// 3. names/codec_fields.json module::codec → ModelName(协议模型名,例如 UserPropertiesCC)
// 4. TankiOffline/.../Inventory.kt 动作类字段表(`data class Xxx(...)`)
//
// 输出:
// names/downlink_actions.json { Note, Resolved, Matched, Models: [...], Actions: [...] }
// TankiOffline/.../App/State/DownlinkActions.kt
// val DownlinkActions: Map<Long, (Any) -> Any?>
//
// 用法:cd 3DTank/tools && node gen_downlink_actions.js
'use strict';
const fs = require('fs');
const path = require('path');
const P = path.resolve(__dirname, '..');
const Names = path.join(P, 'names');
const KotlinRoot = path.resolve(P, '..', 'TankiOffline', 'app', 'src', 'jsMain', 'kotlin', 'App', 'State');
const MethodTable = JSON.parse(fs.readFileSync(path.join(Names, 'method_table.json'), 'utf8')).Rows;
const CodecFields = JSON.parse(fs.readFileSync(path.join(Names, 'codec_fields.json'), 'utf8')).Codecs;
const ModelBinding = JSON.parse(fs.readFileSync(path.join(Names, 'model_binding.json'), 'utf8')).Entries;
const Inventory = fs.readFileSync(path.join(KotlinRoot, 'Inventory.kt'), 'utf8');
const MetadataPath = path.resolve(
P, '..', 'TankiOffline', 'net', 'src', 'jsMain', 'kotlin', 'Net', 'Generated', 'Metadata.kt',
);
// 方法 → 生成代码里实际使用的 Kotlin 类型名。
// 同名协议模型跨切片冲突时,生成器会加后缀(如 GaragePreviewModelCC0004);
// 这类方法不能按语义名去匹配动作,否则运行时 ClassCastException。
const MethodType = new Map();
try {
const Meta = fs.readFileSync(MetadataPath, 'utf8');
const Re = /(\d+)L to ProtocolCommandMeta\(\d+L, "([^"]+)"/g;
let M;
while ((M = Re.exec(Meta))) MethodType.set(M[1], M[2]);
} catch (E) {
// 没有生成产物时退化为「不检查类型冲突」,保持旧行为。
}
// ---------------------------------------------------------------- 模型名解析
const ByModuleCodec = new Map();
const ByCodec = new Map();
for (const C of Object.values(CodecFields)) {
if (!C.ModelName) continue;
ByModuleCodec.set(C.Module + '::' + C.Codec, C.ModelName);
if (!ByCodec.has(C.Codec)) ByCodec.set(C.Codec, C.ModelName);
}
const ModelIdToKey = new Map(ModelBinding.map((E) => [E.ModelId, E.Module + '::' + E.Codec]));
const Rows = [];
for (const R of MethodTable) {
const Key = ModelIdToKey.get(R.ModelId);
const Model = (Key && ByModuleCodec.get(Key)) || ByCodec.get(R.Codec) || null;
Rows.push({ MethodId: R.MethodId, ModelId: R.ModelId, Codec: R.Codec, Model });
}
// ---------------------------------------------------------------- 动作字段表
/** 解析 Inventory.kt 的 data class:name → [{ field, type }] */
function ParseActions(Text) {
const Out = new Map();
const Re = /data class (\w+)\(\n([\s\S]*?)\n\)/g;
let M;
while ((M = Re.exec(Text))) {
const Fields = [];
const FRe = /val (\w+): ([A-Za-z0-9_.]+)(<[^>]*>)?\??,/g;
let F;
while ((F = FRe.exec(M[2]))) Fields.push({ Field: F[1], Type: F[2], Generic: F[3] || '' });
Out.set(M[1], Fields);
}
return Out;
}
const Actions = ParseActions(Inventory);
const ModelNames = new Set(Rows.map((R) => R.Model).filter(Boolean));
// 模型名 → 候选动作(字段类型恰为该模型,且动作只有这一个字段)
// 注意:wire 模型(Net.Generated)与状态层模型(App.State)同名但类型不同,
// 这些模型必须经 App/Session/WireAdapters.kt 显式转换,不能直接 `as`。
const Adapters = {
UserPropertiesCC: 'App.Session.WireAdapters.ToUserProperties',
ReferralEventUserModelCC: 'App.Session.WireAdapters.ToReferralEvent',
GaragePreviewModelCC: 'App.Session.WireAdapters.ToGaragePreview',
};
function ValueExpr(Model, TypeName) {
return Adapters[Model]
? `${Adapters[Model]}(Value as Net.Generated.${TypeName})`
: `Value as ${TypeName}`;
}
// 模型名 → 候选动作(字段类型恰为该模型,且动作只有这一个字段)
const ActionByModel = new Map();
for (const [Name, Fields] of Actions) {
if (Fields.length !== 1) continue;
const T = Fields[0].Type;
if (!ModelNames.has(T)) continue;
if (!ActionByModel.has(T)) ActionByModel.set(T, []);
ActionByModel.get(T).push({ Action: Name, Field: Fields[0].Field });
}
// ---------------------------------------------------------------- 汇总
const Matched = [];
const MatchedModels = [];
const SkippedTypeConflicts = [];
const Models = [];
for (const Name of [...ModelNames].sort()) {
const Cand = ActionByModel.get(Name) || [];
const Methods = Rows.filter((R) => R.Model === Name).map((R) => R.MethodId);
Models.push({ Model: Name, Methods: Methods.length, Actions: Cand.map((C) => C.Action) });
if (Cand.length === 1) {
for (const Id of Methods) {
const ActualType = MethodType.get(String(Id));
if (ActualType && ActualType !== Name) {
SkippedTypeConflicts.push({ MethodId: Id, Model: Name, Type: ActualType });
continue;
}
Matched.push({ MethodId: Id, Model: Name, Action: Cand[0].Action, Field: Cand[0].Field });
}
MatchedModels.push({ Model: Name, Action: Cand[0].Action, Field: Cand[0].Field });
}
}
fs.writeFileSync(
path.join(Names, 'downlink_actions.json'),
JSON.stringify(
{
Note:
'下行 methodId → 协议模型 → 客户端动作。模型名来自 codec_fields.json 的 ModelName;' +
'动作来自 Inventory.kt 里“只有一个字段且字段类型等于该模型”的 data class。' +
'未匹配的模型在 Models 里保留候选,留给后续手工对照(多为 CC → 多个基本字段的转换动作)。',
Source: ['method_table.json', 'model_binding.json', 'codec_fields.json', 'Inventory.kt'],
Methods: Rows.length,
Resolved: Rows.filter((R) => R.Model).length,
Matched: Matched.length,
MatchedModels,
SkippedTypeConflicts,
Models,
Actions: Matched,
},
null,
1,
),
'utf8',
);
// ---------------------------------------------------------------- Kotlin 表
const Lines = [];
Lines.push('// DownlinkActions.kt —— 下行 methodId → 客户端动作(生成物,不要手改)');
Lines.push('//');
Lines.push('// 由 3DTank/tools/gen_downlink_actions.js 生成:');
Lines.push('// methodId(method_table.json)→ modelId → module::codec(model_binding.json)');
Lines.push('// → 协议模型名(codec_fields.json 的 ModelName)');
Lines.push('// → Inventory.kt 里唯一一个「只带该模型字段」的动作。');
Lines.push('//');
Lines.push('// 覆盖范围:仅自动可判定的组合;其余协议模型需要 CC → 多字段的转换动作,');
Lines.push('// 见 names/downlink_actions.json 的 Models 清单(后续按协议逐个补转换器)。');
Lines.push('//');
Lines.push('// wire 模型(Net.Generated)与状态层模型(App.State)同名但类型不同;');
Lines.push('// 需要转换的模型走 App/Session/WireAdapters.kt,生成器里登记在 Adapters 表。');
Lines.push('package App.State');
Lines.push('');
Lines.push('/** 每条记录解码后,用 (Any) -> Any? 构造对应动作;未覆盖的 methodId 不在表里。 */');
Lines.push('val DownlinkActions: Map<Long, (Any) -> Any?> = mapOf(');
for (const M of Matched) {
Lines.push(
' ' + M.MethodId + 'L to { Value -> ' + M.Action + '(' + ValueExpr(M.Model, M.Model) + ') },',
);
}
Lines.push(')');
Lines.push('');
Lines.push('/** Pt/Lt 容器里的 modelId → 动作(同一批协议模型的容器形态)。 */');
Lines.push('val DownlinkModelActions: Map<Long, (Any) -> Any?> = mapOf(');
for (const M of MatchedModels) {
const Ids = ModelBinding.filter((E) => {
const Key = E.Module + '::' + E.Codec;
return ByModuleCodec.get(Key) === M.Model;
}).map((E) => E.ModelId);
for (const Id of Ids) {
Lines.push(
' ' + Id + 'L to { Value -> ' + M.Action + '(' + ValueExpr(M.Model, M.Model) + ') },',
);
}
}
Lines.push(')');
Lines.push('');
fs.writeFileSync(path.join(KotlinRoot, 'DownlinkActions.kt'), Lines.join('\n'), 'utf8');
console.log(
`methodId ${Rows.length} 个,解析出协议模型 ${Rows.filter((R) => R.Model).length} 个;` +
`自动匹配动作 ${Matched.length} 条,涉及模型 ${new Set(Matched.map((M) => M.Model)).size} 个;` +
`跳过同名类型冲突 ${SkippedTypeConflicts.length} 条。`,
);
+135
View File
@@ -0,0 +1,135 @@
// gen_model_registry.js —— 静态解析「modelId → 模块 / 模型类 / 语义名」
//
// 问题:names/model_binding.json 只有 363 条,且抓包 Pt 容器里的 modelId 大多不在里面
// (那批只覆盖了「运行时 dump 到的 codec」)。但客户端在每个模块的构造函数里都会调用
// `ModelReg.n7o(modelId, codec)` 注册模型,注册点本身就带模块与模型类,可以静态挖出来。
//
// 形态(module_10283 例):
// (this.vgw_1 = new R(-1339607649, 1825012020)),
// Rt(this).n7o(this.vgw_1, At(this).r7g(new p(b(Mt), !1)))
// → modelId = (hi << 32) | (lo & 0xffffffff),lo 在前、hi 在后(按无符号 lo 处理)
// → 模块 = 文件,模型类 = `b(<Class>)` 里的 Class
// → 语义名 = codec_fields.json 里同模块、ModelClassLocal 相同的 codec 的 ModelName
//
// 输出:names/model_registry.json
// { Note, Count, WithName, Rows: [{ ModelId, Module, ModelClass, ModelName }] }
//
// 用法:cd 3DTank/tools && node gen_model_registry.js
'use strict';
const fs = require('fs');
const path = require('path');
const P = path.resolve(__dirname, '..');
const SrcDir = path.join(P, 'src');
const Names = path.join(P, 'names');
const Codecs = JSON.parse(fs.readFileSync(path.join(Names, 'codec_fields.json'), 'utf8')).Codecs;
// module -> (ModelClassLocal -> ModelName[])
const NameByModuleClass = new Map();
for (const C of Object.values(Codecs)) {
if (!C.ModelClassLocal) continue;
const Key = C.Module + '::' + C.ModelClassLocal;
if (!NameByModuleClass.has(Key)) NameByModuleClass.set(Key, new Set());
if (C.ModelName) NameByModuleClass.get(Key).add(C.ModelName);
}
function ReadParen(S, Start) {
let D = 0, I = Start;
for (; I < S.length; I++) {
if (S[I] === '(') D++;
else if (S[I] === ')') { D--; if (!D) { I++; break; } }
}
return S.slice(Start, I);
}
function ModelIdOf(Hi, Lo) {
const H = BigInt(Hi);
const L = BigInt.asUintN(32, BigInt(Lo));
return BigInt.asIntN(64, (H << 32n) | L).toString();
}
const Rows = [];
const Files = fs.readdirSync(SrcDir).filter((F) => /^module_\d+\.js$/.test(F)).sort();
for (const F of Files) {
const Module = F.replace(/\.js$/, '');
const S = fs.readFileSync(path.join(SrcDir, F), 'utf8');
const Re = /n7o\(\s*this\.(\w+)\s*,/g;
let M;
while ((M = Re.exec(S))) {
const Slot = M[1];
const Asg = new RegExp('this\\.' + Slot + '\\s*=\\s*new\\s+[\\w$]+').exec(S);
if (!Asg) continue;
const CallStart = S.indexOf('(', Asg.index + Asg[0].length);
const Call = ReadParen(S, CallStart);
const Args = Call.slice(1, -1).split(',').map((X) => X.trim());
if (Args.length !== 2 || !/^-?\d+$/.test(Args[0]) || !/^-?\d+$/.test(Args[1])) continue;
const ModelId = ModelIdOf(Number(Args[1]), Number(Args[0]));
// 注册表达式尾部:取到本行结束,抓 `(<Class>)` 作为模型类
const LineEnd = S.indexOf('\n', M.index);
const Tail = S.slice(M.index, LineEnd < 0 ? S.length : LineEnd);
const ClassMatch = /\(\s*([\w$]+)\s*\)\s*,\s*!?[01]/.exec(Tail);
const ModelClass = ClassMatch ? ClassMatch[1] : null;
const NamesSet = ModelClass ? NameByModuleClass.get(Module + '::' + ModelClass) : null;
let ModelName = null;
if (NamesSet && NamesSet.size) {
const Arr = [...NamesSet];
ModelName = Arr.find((N) => N !== ModelClass) || Arr[0];
}
Rows.push({ ModelId, Module, ModelClass, ModelName });
}
}
const Seen = new Set();
const Unique = [];
for (const R of Rows) {
if (Seen.has(R.ModelId)) continue;
Seen.add(R.ModelId);
Unique.push(R);
}
fs.writeFileSync(
path.join(Names, 'model_registry.json'),
JSON.stringify(
{
Note:
'静态解析的 modelId → 模块 / 模型类 / 语义名。来源:各模块构造函数里的 `ModelReg.n7o(modelId, codec)` ' +
'注册点 + `this.<slot> = new X(lo, hi)` 的 64 位常量;语义名同模块的 ModelClassLocal 反查 codec_fields.json。',
Source: ['src/*.js', 'codec_fields.json'],
Count: Unique.length,
WithName: Unique.filter((R) => R.ModelName).length,
Rows: Unique,
},
null,
1,
),
'utf8',
);
// ---------------------------------------------------------------- Kotlin 表(诊断 + 适配器用)
const KotlinPath = path.resolve(P, '..', 'TankiOffline', 'app', 'src', 'jsMain', 'kotlin', 'App', 'State', 'ModelRegistry.kt');
const Lines = [];
Lines.push('// ModelRegistry.kt —— modelId → 协议模型名(生成物,不要手改)');
Lines.push('//');
Lines.push('// 由 3DTank/tools/gen_model_registry.js 静态解析各模块的 ModelReg 注册点得到:');
Lines.push('// this.<slot> = new X(lo, hi) + n7o(this.<slot>, ... new W(b(ModelClass), ...))');
Lines.push('// 语义名来自 codec_fields.json 里同模块、ModelClassLocal 相同的 codec 的 ModelName。');
Lines.push('package App.State');
Lines.push('');
Lines.push('/** modelId → 协议模型名(未解析出语义名的条目不在表里)。 */');
Lines.push('val ModelNamesByModelId: Map<Long, String> = mapOf(');
for (const R of Unique) {
if (!R.ModelName) continue;
Lines.push(' ' + R.ModelId + 'L to ' + JSON.stringify(R.ModelName) + ',');
}
Lines.push(')');
Lines.push('');
fs.writeFileSync(KotlinPath, Lines.join('\n'), 'utf8');
console.log('Kotlin: ' + KotlinPath);
console.log(
`modelId ${Unique.length} 个(注册点 ${Rows.length} 个),其中 ${Unique.filter((R) => R.ModelName).length} 个解析出语义名。`,
);
+130
View File
@@ -0,0 +1,130 @@
// gen_module_imports.js —— 跨模块别名表:module → {alias → (depModule, exportName)}
//
// 模块文件两种包装(`window.__modules__[id] = (t, n, i) => { … }`,`i` 是 require 函数):
// A. 简单模块:体内 `var x = i(<id>)` 直接拿到依赖模块对象;
// B. 核心模块:`!(function (p0…pN) { … })(t, n, i, i(<id1>), i(<id2>), …)`,
// 内层参数 p3…pN 依次对应尾部的 `i(<id>)` 调用。
// 两种风格的「导入」都写作 `alias = <depVar>.$_$.<exportName>`。
//
// 输出:names/module_imports.json
// { Note, Modules: { <id>: { Imports: [{ Alias, Dep, Export }] } },
// ByExport: { "<dep>::<export>": [ "<module>::<alias>" ] } }
//
// 用法:cd 3DTank/tools && node gen_module_imports.js
'use strict';
const fs = require('fs');
const path = require('path');
const P = path.resolve(__dirname, '..');
const SrcDir = path.join(P, 'src');
const OutPath = path.join(P, 'names', 'module_imports.json');
function ReadTopArgs(S) {
// 尾部:…})(t, n, i, i(85183), …);\n};
const T = S.replace(/\r/g, '');
const T2 = T.replace(/};\s*$/, '').replace(/\);\s*$/, '');
const Open = T2.lastIndexOf('})(');
if (Open < 0) return null;
const Raw = T2.slice(Open + 3);
const Args = [];
let Depth = 0, Cur = '';
for (let I = 0; I < Raw.length; I++) {
const C = Raw[I];
if (C === '(') Depth++;
else if (C === ')') Depth--;
if (C === ',' && Depth === 0) { Args.push(Cur.trim()); Cur = ''; continue; }
Cur += C;
}
if (Cur.trim()) Args.push(Cur.trim());
return Args;
}
const Modules = {};
const Stats = { Files: 0, StyleIIFE: 0, StylePlain: 0, NoImports: 0, Aliases: 0 };
const Files = fs.readdirSync(SrcDir).filter((F) => /^module_\d+\.js$/.test(F)).sort();
for (const F of Files) {
Stats.Files++;
const S = fs.readFileSync(path.join(SrcDir, F), 'utf8').replace(/\r/g, '');
const IdMatch = /^;\(window\.__modules__[^)]*\)\[(\d+)\]/.exec(S);
const Id = IdMatch ? IdMatch[1] : F.match(/(\d+)/)[1];
// 依赖变量 → 模块 id
const DepVar = new Map();
// 内层参数风格(B)
const Head = /=\s*\(\w+,\s*\w+,\s*\w+\)\s*=>\s*\{\s*!\(function\s*\(([^)]*)\)/.exec(S);
if (Head) {
const Params = Head[1].split(',').map((x) => x.trim()).filter(Boolean);
const Args = ReadTopArgs(S);
if (Args && Args.length === Params.length) {
for (let I = 3; I < Args.length; I++) {
const M = /^\w+\(\s*(\d+)\s*\)$/.exec(Args[I]);
if (M) DepVar.set(Params[I], M[1]);
}
}
}
// 体内 inline require(A 与 B 通用):var/let x = i(<id>)
const InlineRe = /(?:var|let|const|,)\s+([\w$]+)\s*=\s*\w+\(\s*(\d+)\s*\)/g;
let M;
while ((M = InlineRe.exec(S))) DepVar.set(M[1], M[2]);
// alias = depVar.$_$.export
const Imports = [];
const DepVars = [...DepVar.keys()];
if (DepVars.length) {
const Re = new RegExp('([\\w$]+)\\s*=\\s*(' + DepVars.map((V) => V.replace(/\$/g, '\\$')).join('|') + ')\\.\\$_\\$\\.([\\w$]+)', 'g');
let A;
while ((A = Re.exec(S))) {
Imports.push({ Alias: A[1], Dep: DepVar.get(A[2]), Export: A[3] });
}
}
// 去重
const Seen = new Set();
const Unique = Imports.filter((I) => {
const K = I.Alias + '|' + I.Dep + '|' + I.Export;
if (Seen.has(K)) return false;
Seen.add(K);
return true;
});
if (Head) Stats.StyleIIFE++;
else Stats.StylePlain++;
if (!Unique.length) Stats.NoImports++;
Stats.Aliases += Unique.length;
Modules[Id] = { File: F, Style: Head ? 'iife' : 'plain', Imports: Unique };
}
// 反向索引:"<dep>::<export>" → ["<module>::<alias>"]
const ByExport = {};
for (const [Id, Info] of Object.entries(Modules)) {
for (const I of Info.Imports) {
const K = I.Dep + '::' + I.Export;
(ByExport[K] = ByExport[K] || []).push(Id + '::' + I.Alias);
}
}
fs.writeFileSync(
OutPath,
JSON.stringify(
{
Note:
'跨模块别名表:每个模块的 `alias = <depVar>.$_$.<exportName>`。' +
'依赖变量来自内层 IIFE 参数(对应尾部 i(<id>) 列表)或体内 `var x = i(<id>)`。' +
'ByExport 为反向索引,供「某个导出被谁消费」查询。',
Stats,
Modules,
ByExport,
},
null,
1,
),
'utf8',
);
console.log(
`模块 ${Stats.Files}(iife ${Stats.StyleIIFE} / plain ${Stats.StylePlain}),` +
`别名 ${Stats.Aliases} 条,无导入模块 ${Stats.NoImports} 个。`,
);
File diff suppressed because it is too large. Load diff
+524
View File
@@ -0,0 +1,524 @@
// gen_slice_plan.js —— 从 module_10032 抽取某个根状态切片的「状态字段 + reducer 分支表」
//
// 用途:为逐个迁移大切片(shop / user / screen / garage …)提供机器可读证据,
// 避免人工在 1 万行 reducer 里数位置。产出 3DTank/names/slice_<field>.json:
// State : 状态类(class / 构造参数 / 槽位 / toString 字段 / 默认值 / copy 方法)
// Copy : copy 方法的参数个数(== 字段数才认为可信)
// Branches : 每个顶层动作分支 { Local, Name, Kind, Changed:[{Field, Slot, Index, Value}], Expr }
// Kind = copy / reset / noop / conditional;Value 保留原表达式文本,供人工翻译
// SubReducers : 顶层分支里内嵌的 `(function (t, n) { … })(t, n.<子状态>)` 子 reducer
// (例如 shop 的 x4 分支会把 shopCategories/shopItems 两个嵌套模型交给子 reducer)
// { Var, Arg, Style(chain|ternary), ArmCount, Locals:[{Local,Name}], Arms:[{Local,Expr}] }
// Counts : TotalInstanceof / TopLevelBranches / SubReducerArms,用来说明「为什么不是一句话」
// Locals : 局部动作类 → 动作名(action_inventory 里查不到则为 null)
//
// 用法:cd 3DTank/tools && node gen_slice_plan.js shop [user screen ...]
'use strict';
const fs = require('fs');
const path = require('path');
const P = path.resolve(__dirname, '..');
const T = fs.readFileSync(path.join(P, 'src', 'module_10032.js'), 'utf8');
const Inv = JSON.parse(fs.readFileSync(path.join(P, 'names', 'action_inventory_10032.json'), 'utf8'));
const LocalToName = new Map();
for (const R of Inv.Rows) if (!LocalToName.has(R.Local)) LocalToName.set(R.Local, R.Name);
LocalToName.set('iz', 'InitState');
// ---------------------------------------------------------------- 基础扫描
function BracePairs(Text) {
const Pairs = new Map();
const Stack = [];
let I = 0;
while (I < Text.length) {
const C = Text[I];
if (C === '"' || C === "'" || C === '`') {
const Q = C; I++;
while (I < Text.length) { if (Text[I] === '\\') { I += 2; continue; } if (Text[I] === Q) { I++; break; } I++; }
continue;
}
if (C === '/' && Text[I + 1] === '/') { const N = Text.indexOf('\n', I); I = N < 0 ? Text.length : N; continue; }
if (C === '/' && Text[I + 1] === '*') { const N = Text.indexOf('*/', I + 2); I = N < 0 ? Text.length : N + 2; continue; }
if (C === '{') Stack.push(I);
else if (C === '}') { const O = Stack.pop(); if (O !== undefined) Pairs.set(O, I); }
I++;
}
return Pairs;
}
const Pairs = BracePairs(T);
const Fns = new Map();
for (const M of T.matchAll(/(?<![\w$.])function ([A-Za-z0-9_$]+)\(([^)]*)\)\s*\{/g)) {
const Open = M.index + M[0].length - 1;
const End = Pairs.get(Open);
if (End !== undefined) Fns.set(M[1], { Name: M[1], Params: M[2], Body: Open + 1, End });
}
const Methods = [];
for (const M of T.matchAll(/\(([A-Za-z_$][\w$]*)\(([A-Za-z0-9_$]+)\)\.([A-Za-z0-9_$]+) = function \(([^)]*)\)\s*\{/g)) {
const Open = M.index + M[0].length - 1;
const End = Pairs.get(Open);
if (End !== undefined) Methods.push({ Obj: M[2], Method: M[3], Params: M[4], Start: M.index, Body: Open + 1, End });
}
function FindMethod(Obj, Method) { return Methods.find((M) => M.Obj === Obj && M.Method === Method) || null; }
// ---------------------------------------------------------------- 根 reducer / 根状态
const RootReducerFn = [...Fns.values()].find((F) => T.slice(F.Body, F.End).includes('return n.cti('));
if (!RootReducerFn) throw new Error('找不到根 reducer');
const CopyMethod = Methods.find((M) => M.Method === 'cti');
const RootClass = CopyMethod.Obj;
const RootClassFn = Fns.get(RootClass);
const RootBody = T.slice(RootClassFn.Body, RootClassFn.End);
const SlotOfParam = new Map();
for (const M of RootBody.matchAll(/\(this\.(\w+) = (\w+)\)/g)) SlotOfParam.set(M[2], M[1]);
const DefaultOfParam = new Map();
for (const M of RootBody.matchAll(/\((\w+) = \1 === cd \? ([\s\S]*?) : \1\)/g)) DefaultOfParam.set(M[1], M[2].replace(/\s+/g, ' ').trim());
for (const M of RootBody.matchAll(/\((\w+) = \1 !== cd && \1\)/g)) DefaultOfParam.set(M[1], 'false');
for (const M of RootBody.matchAll(/\((\w+) = \1 === cd \|\| \1\)/g)) DefaultOfParam.set(M[1], 'true');
const ToStringStart = T.indexOf(`(ld(${RootClass}).toString = function`);
const ToStringEnd = T.indexOf(`(ld(${RootClass}).hashCode`, ToStringStart);
const RootFields = [];
for (const M of T.slice(ToStringStart, ToStringEnd).matchAll(/"([^"]*?)([A-Za-z0-9_]+)="\s*\+\s*(?:[A-Za-z_$][\w$]*\()?this\.(\w+)/g)) {
RootFields.push({ Field: M[2], Slot: M[3] });
}
/** 根 reducer 顶层赋值 [{Name, Expr}]。 */
function TopLevelAssignments(Body) {
const Out = [];
let I = 0, Depth = 0;
while (I < Body.length) {
const C = Body[I];
if (C === '"' || C === "'" || C === '`') {
const Q = C; I++;
while (I < Body.length) { if (Body[I] === '\\') { I += 2; continue; } if (Body[I] === Q) { I++; break; } I++; }
continue;
}
if ('([{'.includes(C)) { Depth++; I++; continue; }
if (')]}'.includes(C)) { Depth--; I++; continue; }
if (Depth === 0) {
const M = /^([A-Za-z_$][\w$]*)\s*=\s*/.exec(Body.slice(I));
if (M && (I === 0 || /[;{}\s]/.test(Body[I - 1]))) {
let J = I + M[0].length, D = 0;
while (J < Body.length) {
const D0 = Body[J];
if (D0 === '"' || D0 === "'" || D0 === '`') {
const Q = D0; J++;
while (J < Body.length) { if (Body[J] === '\\') { J += 2; continue; } if (Body[J] === Q) { J++; break; } J++; }
continue;
}
if ('([{'.includes(D0)) D++;
else if (')]}'.includes(D0)) D--;
else if ((D0 === ',' || D0 === ';') && D === 0) break;
J++;
}
Out.push({ Name: M[1], Expr: Body.slice(I + M[0].length, J).trim() });
I = J + 1; continue;
}
}
I++;
}
return Out;
}
const Assignments = TopLevelAssignments(T.slice(RootReducerFn.Body, RootReducerFn.End));
// ---------------------------------------------------------------- 工具
/** 顶层逗号切分(按掩码文本判定深度,字符串里的逗号不切)。 */
function SplitTop(Text) {
const M = MaskStrings(Text);
const Out = []; let D = 0, Cur = '';
for (let I = 0; I < Text.length; I++) {
const C = M[I];
if ('([{'.includes(C)) D++;
else if (')]}'.includes(C)) D--;
if (C === ',' && D === 0) { Out.push(Cur.trim()); Cur = ''; } else Cur += Text[I];
}
if (Cur.trim()) Out.push(Cur.trim());
return Out;
}
/**
* 把字符串/注释内容替换成同长度空格(保留长度,便于用同一套下标扫描括号)。
* 不做这一步的话,字符串里的 `[` `{` 会让括号深度错位,漏掉后面的分支。
*/
function MaskStrings(Text) {
const Out = Text.split('');
let I = 0;
while (I < Text.length) {
const C = Text[I];
if (C === '"' || C === "'" || C === '`') {
const Q = C;
Out[I] = ' ';
I++;
while (I < Text.length) {
if (Text[I] === '\\') { Out[I] = ' '; Out[I + 1] = ' '; I += 2; continue; }
if (Text[I] === Q) { Out[I] = ' '; I++; break; }
if (Text[I] !== '\n') Out[I] = ' ';
I++;
}
continue;
}
if (C === '/' && Text[I + 1] === '/') {
const N = Text.indexOf('\n', I);
const E = N < 0 ? Text.length : N;
for (let J = I; J < E; J++) Out[J] = ' ';
I = E;
continue;
}
if (C === '/' && Text[I + 1] === '*') {
const N = Text.indexOf('*/', I + 2);
const E = N < 0 ? Text.length : N + 2;
for (let J = I; J < E; J++) if (Text[J] !== '\n') Out[J] = ' ';
I = E;
continue;
}
I++;
}
return Out.join('');
}
/** 从 Text[Open] 的 `{` 找到配对的 `}` 下标(掩码判定)。 */
function MatchBrace(Text, Open) {
const M = MaskStrings(Text);
let D = 0;
for (let I = Open; I < Text.length; I++) {
const C = M[I];
if (C === '{') D++;
else if (C === '}') { D--; if (D === 0) return I; }
}
return -1;
}
/** 找顶层 `if (… instanceof …)` 守卫(掩码判深度)。 */
function FindGuards(Text) {
const M = MaskStrings(Text);
const Out = [];
let I = 0, Depth = 0;
while (I < Text.length) {
const C = M[I];
if ('([{'.includes(C)) { Depth++; I++; continue; }
if (')]}'.includes(C)) { Depth--; I++; continue; }
if (
Depth === 0 &&
M.startsWith('if (', I) &&
(M.slice(I, I + 80).includes('instanceof') || M.slice(I, I + 80).includes('yd(t,'))
) {
const OpenP = I + 3;
let D = 0, J = OpenP;
for (; J < Text.length; J++) { const D0 = M[J]; if (D0 === '(') D++; else if (D0 === ')') { D--; if (D === 0) break; } }
Out.push({ At: I, GuardEnd: J, Cond: Text.slice(OpenP + 1, J).replace(/\s+/g, ' ').trim() });
I = J + 1; continue;
}
I++;
}
return Out;
}
/** 从 chunk 里取最浅层的 `i = <expr>`(掩码判深度)。 */
function OuterAssign(Chunk) {
const MC = MaskStrings(Chunk);
let Best = null, BestDepth = Infinity, I2 = 0, D2 = 0;
while (I2 < Chunk.length) {
const C2 = MC[I2];
if ('([{'.includes(C2)) { D2++; I2++; continue; }
if (')]}'.includes(C2)) { D2--; I2++; continue; }
if (MC.startsWith('i =', I2)) {
let J2 = I2 + 3, DD = 0;
while (J2 < Chunk.length) {
const D0 = MC[J2];
if ('([{'.includes(D0)) DD++;
else if (')]}'.includes(D0)) { if (DD === 0) break; DD--; }
else if (D0 === ';' && DD === 0) break;
J2++;
}
if (D2 < BestDepth) { BestDepth = D2; Best = Chunk.slice(I2 + 3, J2).trim(); }
I2 = J2; continue;
}
I2++;
}
return Best;
}
function LocalsIn(Text) {
const Out = [];
for (const M of Text.matchAll(/t instanceof ([A-Za-z0-9_$]+)/g)) Out.push(M[1]);
for (const M of Text.matchAll(/Xd\(t, ([A-Za-z0-9_$]+)\)/g)) Out.push(M[1]);
return [...new Set(Out)];
}
/** 把 `t instanceof A ? e1 : t instanceof B ? e2 : e3` 切成手。 */
function TernaryArms(Text) {
const Hits = [...Text.matchAll(/t instanceof ([A-Za-z0-9_$]+)\s*\?/g)];
return Hits.map((H, Hi) => {
const From = H.index + H[0].length;
const To = Hi + 1 < Hits.length ? Hits[Hi + 1].index : Text.length;
return {
Local: H[1],
Expr: Text.slice(From, To).replace(/^\s*/, '').replace(/[,\s]*:\s*$/, '').replace(/\s*:\s*$/, '').trim(),
};
});
}
/** 槽位 → 字段名 / 字段名 → 索引(按 toString 顺序)。 */
const FieldOfSlot = new Map(RootFields.map((F) => [F.Slot, F.Field]));
function KotlinName(Field) { return Field.charAt(0).toUpperCase() + Field.slice(1); }
// ---------------------------------------------------------------- 单切片计划
function PlanSlice(Field) {
const F = RootFields.find((X) => X.Field === Field);
if (!F) throw new Error('根状态里没有字段 ' + Field);
// 委派有两种形状:
// 1) 根 reducer 内联写 `n.<slot>`(多数切片,或 `n.<slot>.方法(t)`)
// 2) 根 reducer 直接调用具名 reducer `hF(t, n)`,切片槽位只在 hF 体内出现(garage)
// 第 2 种需要回查被调函数的函数体,确认它确实读写 `n.<slot>`。
let A = Assignments.find((X) => new RegExp('\\bn\\.' + F.Slot + '\\b').test(X.Expr));
let NamedReducer = null;
let NamedBody = null;
if (!A) {
for (const X of Assignments) {
const Call = /^([A-Za-z0-9_$]+)\(t,\s*n\)$/.exec(X.Expr.replace(/\s+/g, ' ').trim());
if (!Call || !Fns.has(Call[1])) continue;
const Fn = Fns.get(Call[1]);
if (new RegExp('\\bn\\.' + F.Slot + '\\b').test(T.slice(Fn.Body, Fn.End))) {
A = X;
NamedReducer = Call[1];
NamedBody = T.slice(Fn.Body, Fn.End);
break;
}
}
}
if (!A) throw new Error(Field + ' 在根 reducer 里找不到委派');
// 第 3 种:根 reducer 直接调用对象方法,`fs.eru(t, n.epv_1)`(garagePreview)。
// 方法体在 Methods 表里,和具名 reducer 一样按字段数锁定 copy 方法。
if (!NamedBody) {
const MCall = /^([A-Za-z0-9_$]+)\.([A-Za-z0-9_$]+)\(t,\s*n\.[A-Za-z0-9_$]+\)$/.exec(
A.Expr.replace(/\s+/g, ' ').trim(),
);
if (MCall) {
const M = FindMethod(MCall[1], MCall[2]) || Methods.find((X) => X.Method === MCall[2]);
if (M) {
NamedReducer = MCall[1] + '.' + MCall[2];
NamedBody = T.slice(M.Body, M.End);
}
}
}
const Param = [...SlotOfParam.keys()].find((P2) => SlotOfParam.get(P2) === F.Slot);
const SliceClass = (DefaultOfParam.get(Param) || '').match(/^new ([A-Za-z0-9_$]+)\(/);
const ClassName = SliceClass ? SliceClass[1] : null;
const Info = {
Field, Slot: F.Slot, Class: ClassName, ClassSource: DefaultOfParam.get(Param) || '',
NamedReducer, Params: [], Fields: [], CopyMethod: null, Branches: [], SubReducers: [], Counts: {}, Locals: {},
Delegate: A.Expr.replace(/\s+/g, ' ').trim().slice(0, 400),
};
if (ClassName) {
const CFn = Fns.get(ClassName);
Info.Params = CFn ? CFn.Params.split(',').map((S) => S.trim()).filter(Boolean) : [];
const CB = CFn ? T.slice(CFn.Body, CFn.End) : '';
const SOfP = new Map();
for (const M of CB.matchAll(/\(this\.([A-Za-z_$][\w$]*) = ([A-Za-z_$][\w$]*)\)/g)) SOfP.set(M[2], M[1]);
const DOfP = new Map();
for (const M of CB.matchAll(/\(([A-Za-z_$][\w$]*) = \1 === cd \? ([\s\S]*?) : \1\)/g)) DOfP.set(M[1], M[2].replace(/\s+/g, ' ').trim());
for (const M of CB.matchAll(/\(([A-Za-z_$][\w$]*) = \1 !== cd && \1\)/g)) DOfP.set(M[1], 'false');
for (const M of CB.matchAll(/\(([A-Za-z_$][\w$]*) = \1 === cd \|\| \1\)/g)) DOfP.set(M[1], 'true');
const S = T.indexOf(`(ld(${ClassName}).toString = function`);
const E = T.indexOf(`(ld(${ClassName}).hashCode`, S);
if (S >= 0 && E > S) {
for (const M of T.slice(S, E).matchAll(/"([^"]*?)([A-Za-z0-9_]+)="\s*\+\s*(?:[A-Za-z_$][\w$]*\()?this\.(\w+)/g)) {
const P2 = [...SOfP.keys()].find((K) => SOfP.get(K) === M[3]);
Info.Fields.push({ Field: M[2], Slot: M[3], Kotlin: KotlinName(M[2]), Default: P2 ? DOfP.get(P2) || '' : '' });
}
}
}
const CopyName = (A.Expr.match(/\bn\.([A-Za-z0-9_$]+)\(/) || [])[1] || null;
// 具名 reducer / 对象方法 reducer 的副本方法只在函数体里(`o.hri(...)` 或 `n.trs(...)`),
// 按「参数个数 == 字段数」锁定。
if (NamedBody) {
const ByArity = [];
for (const M of NamedBody.matchAll(/\b(?:o|n)\.([A-Za-z0-9_$]+)\(/g)) {
let O = M.index + M[0].length, D = 0, E = -1;
for (let J = O; J < NamedBody.length; J++) {
const C2 = NamedBody[J];
if (C2 === '(') D++;
else if (C2 === ')') { D--; if (D === 0) { E = J; break; } }
}
if (E < 0) continue;
const N = SplitTop(NamedBody.slice(O, E)).length;
ByArity.push({ Name: M[1], Count: N });
}
const Hit = ByArity.find((X) => X.Count === Info.Fields.length);
Info.CopyMethod = Hit ? Hit.Name : (ByArity[0] || {}).Name || null;
} else {
Info.CopyMethod = CopyName;
}
const CopyMethodName = Info.CopyMethod;
if (CopyMethodName && ClassName) {
const M = Methods.find((X) => X.Obj === ClassName && X.Method === CopyMethodName);
Info.CopyParamCount = M ? M.Params.split(',').map((S) => S.trim()).filter(Boolean).length : null;
Info.CopyMatchesFields = Info.CopyParamCount === Info.Fields.length;
}
// ---- 分支抽取:内联 IIFE 或具名 reducer(`hF(t, n)`)
const Expr = A.Expr;
let Body;
if (NamedReducer) {
Body = NamedBody;
} else if (/^\(function \(t, n\)/.test(Expr)) {
// IIFE 的 { 位置要对到全局源码:用 Pos 定位
const Pos = T.indexOf(A.Expr, RootReducerFn.Body);
const GlobalOpen = T.indexOf('{', T.indexOf('function', Pos));
const GlobalClose = Pairs.get(GlobalOpen);
Body = T.slice(GlobalOpen + 1, GlobalClose);
} else {
Info.Branches = [{ Guard: '(具名 reducer)', Locals: [], Kind: 'named', Expr: Expr.replace(/\s+/g, ' ').slice(0, 200) }];
Info.Counts = { TotalInstanceof: null, TopLevelBranches: 1, SubReducerArms: 0 };
return Info;
}
function Decode(BranchExpr) {
const E = BranchExpr.replace(/\s+/g, ' ').trim();
const Fields = [];
if (/^n$/.test(E)) return { Kind: 'noop', Changed: [] };
const CopyCall = new RegExp('\\b(?:n|o)\\.' + (CopyMethodName || 'qt6') + '\\(');
const M = CopyCall.exec(E);
if (/new [A-Za-z0-9_$]+\(/.test(E) && !M) return { Kind: 'reset', Changed: [] };
if (!M) return { Kind: 'conditional', Changed: [] };
// 找该调用的参数(最外层)
let O = E.indexOf('(', M.index), D = 0, End = -1;
for (let J = O; J < E.length; J++) { const C2 = E[J]; if (C2 === '(') D++; else if (C2 === ')') { D--; if (D === 0) { End = J; break; } } }
const Args = SplitTop(E.slice(O + 1, End));
Args.forEach((Arg, Index) => {
if (Arg === 'cd') return;
const FI = Info.Fields[Index];
Fields.push({ Index, Field: FI ? FI.Field : null, Slot: FI ? FI.Slot : null, Kotlin: FI ? FI.Kotlin : null, Value: Arg });
});
const HasHelper = Fields.some((X) => /[A-Za-z_$][\w$]*\(/.test(X.Value) && !/^t\.[A-Za-z0-9_$]+$/.test(X.Value));
const Simple = Fields.length > 0 && Fields.every((X) => /^t\.[A-Za-z0-9_$]+$/.test(X.Value) || /^(null|!0|!1|true|false|-?\d+)$/.test(X.Value));
return { Kind: Simple || !HasHelper ? 'copy' : 'copy-helper', Changed: Fields };
}
/** 顶层 if/else-if 链 → 分支表。 */
function ChainBranches(Text) {
const Guards = FindGuards(Text);
const Out = [];
Guards.forEach((G, Gi) => {
const NextGuard = Guards[Gi + 1];
const ChunkEnd = NextGuard ? Text.lastIndexOf('else', NextGuard.At) : Text.length;
const Chunk = Text.slice(G.GuardEnd + 1, ChunkEnd);
let BranchExpr = OuterAssign(Chunk);
// 块体分支(例如 KU):`{ var o = t.irl_1; i = null == o ? n : n.qt6(…); }`
// 先剥掉最外层花括号,再取块内最后一个顶层赋值,避免被后续 else 链的三元污染。
const ChunkTrim = Chunk.trim();
if (ChunkTrim.startsWith('{')) {
const Open = Chunk.indexOf('{');
const Close = MatchBrace(Chunk, Open);
if (Close > Open) {
const Inner = OuterAssign(Chunk.slice(Open + 1, Close));
if (Inner !== null) BranchExpr = Inner;
}
}
if (BranchExpr === null) return;
Out.push({ Guard: G.Cond, Locals: LocalsIn(G.Cond), ...Decode(BranchExpr), Expr: BranchExpr.replace(/\s+/g, ' ').trim().slice(0, 300), _Chunk: Chunk });
});
// 末尾 else:`else i = t instanceof m5 ? … : t instanceof f5 ? … : n;`
if (Guards.length) {
const LastEnd = Guards[Guards.length - 1].GuardEnd;
const Tail = Text.slice(LastEnd);
const ElseAt = Tail.lastIndexOf('else');
if (ElseAt >= 0) {
const BranchExpr = OuterAssign(Tail.slice(ElseAt));
if (BranchExpr) {
const Arms = TernaryArms(BranchExpr);
for (const Arm of Arms) {
Out.push({ Guard: 't instanceof ' + Arm.Local, Locals: [Arm.Local], ...Decode(Arm.Expr), Expr: Arm.Expr.replace(/\s+/g, ' ').trim().slice(0, 300) });
}
if (!Arms.length) Out.push({ Guard: '(else)', Locals: [], ...Decode(BranchExpr), Expr: BranchExpr.replace(/\s+/g, ' ').trim().slice(0, 300) });
}
}
}
return Out;
}
/** 分支里内嵌的 `(function (t, n) { … })(t, <arg>)` 子 reducer。 */
function NestedReducers(Chunk) {
const Out = [];
const Re = /(?:var\s+([A-Za-z0-9_$]+)\s*=\s*)?\(function \(t, n\)\s*\{/g;
let M;
while ((M = Re.exec(Chunk))) {
const Open = Chunk.indexOf('{', M.index + M[0].length - 1);
const Close = MatchBrace(Chunk, Open);
if (Close < 0) continue;
const Body2 = Chunk.slice(Open + 1, Close);
const After = Chunk.slice(Close + 1, Close + 120);
const CallArg = (After.match(/^\)\(t,\s*([^)]+)\)/) || [])[1] || null;
const IsTernary = /\breturn\s+t instanceof [A-Za-z0-9_$]+\s*\?/.test(Body2);
let Arms;
if (IsTernary) {
const Ret = Body2.slice(Body2.search(/\breturn\s+t instanceof /));
Arms = TernaryArms(Ret).map((Arm) => ({ Local: Arm.Local, Expr: Arm.Expr.replace(/\s+/g, ' ').slice(0, 200) }));
} else {
Arms = ChainBranches(Body2).map((B) => ({
Local: (B.Locals || [])[0] || null,
Guard: B.Guard,
Kind: B.Kind,
Expr: B.Expr,
}));
}
Out.push({
Var: M[1] || null,
Arg: CallArg ? CallArg.trim() : null,
Style: IsTernary ? 'ternary' : 'chain',
ArmCount: Arms.length,
Locals: Arms.map((X) => X.Local).filter(Boolean).map((L) => ({ Local: L, Name: LocalToName.get(L) || null })),
Arms,
});
Re.lastIndex = Close + 1;
}
return Out;
}
const Top = ChainBranches(Body);
Info.Branches = Top.map(({ _Chunk, ...Rest }) => Rest);
for (const B of Top) {
if (B._Chunk) Info.SubReducers.push(...NestedReducers(B._Chunk));
}
Info.Counts = {
TotalInstanceof: (Body.match(/\bt instanceof [A-Za-z0-9_$]+/g) || []).length,
TopLevelBranches: Info.Branches.length,
SubReducerArms: Info.SubReducers.reduce((N, S) => N + S.ArmCount, 0),
};
// 局部类命名情况
const Collect = (L) => { if (!Info.Locals[L]) Info.Locals[L] = { Name: LocalToName.get(L) || null }; };
for (const B of Info.Branches) for (const L of B.Locals || []) Collect(L);
for (const S of Info.SubReducers) for (const X of S.Locals) Collect(X.Local);
return Info;
}
// ---------------------------------------------------------------- 输出
const Want = process.argv.slice(2);
if (!Want.length) throw new Error('用法:node gen_slice_plan.js <field> [field ...]');
for (const Field of Want) {
const Info = PlanSlice(Field);
const OutFile = path.join(P, 'names', 'slice_' + Field + '.json');
fs.writeFileSync(OutFile, JSON.stringify({ generatedAt: new Date().toISOString(), module: 'module_10032.js', ...Info }, null, 2) + '\n');
const Kinds = {};
for (const B of Info.Branches) Kinds[B.Kind] = (Kinds[B.Kind] || 0) + 1;
const Anon = Object.entries(Info.Locals).filter(([, V]) => !V.Name).map(([K]) => K);
const C = Info.Counts || {};
console.log(`${Field}: class=${Info.Class} 字段=${Info.Fields.length} copy=${Info.CopyMethod}(${Info.CopyParamCount}) 顶层分支=${Info.Branches.length} ${JSON.stringify(Kinds)}`);
console.log(` 实例判定=${C.TotalInstanceof} 子reducer=${(Info.SubReducers || []).length} 子reducer分支=${C.SubReducerArms}`);
for (const S of Info.SubReducers || []) console.log(` 子reducer var=${S.Var} arg=${S.Arg} style=${S.Style} arms=${S.ArmCount}`);
if (Anon.length) console.log(' 未具名局部类: ' + Anon.join(', '));
}
+124
View File
@@ -0,0 +1,124 @@
// infer_action_types.js —— 从调用点反推「动作/状态类」的字段类型(S3 状态层第二步)
//
// 思路:
// 1. module_10032 的导出表:导出键 → 局部类名;配合 action_inventory 得到 类名 ↔ 导出键;
// 2. 其它模块用 `<别名> = i(10032).$_$.<键>` 引入,再 `<别名>(...)` 构造(Kotlin data class 的 new 被编译成函数调用);
// 3. 采集每个构造点的实参表达式,按下面规则定类型(认不出就记 unknown,不猜):
// · 数字字面量 → Int/Double;true/false → Boolean;"…" → String;null → null
// · `<x>.f20_1/g20_1/h20_1`(向量分量) → Float
// · `new <Cls>()` / `<Cls>()` → 该 Cls 的局部名
// · `<x>.<prop>` → 记录 prop 名,供后续按 codec_fields 反查
//
// 用法: node infer_action_types.js [--limit 30]
'use strict';
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const SRC = path.join(PROJECT, 'src');
const Argv = process.argv.slice(2);
const Arg = (N, D) => { const I = Argv.indexOf('--' + N); return I >= 0 && Argv[I + 1] ? Argv[I + 1] : D; };
const Limit = Number(Arg('limit', '30')) || 30;
const Inventory = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'action_inventory_10032.json'), 'utf8'));
function SplitArgs(Text) {
const Out = [];
let Depth = 0;
let Cur = '';
for (let I = 0; I < Text.length; I++) {
const C = Text[I];
if (C === '(' || C === '[' || C === '{') Depth++;
else if (C === ')' || C === ']' || C === '}') Depth--;
if (C === ',' && Depth === 0) { Out.push(Cur.trim()); Cur = ''; continue; }
Cur += C;
}
if (Cur.trim()) Out.push(Cur.trim());
return Out;
}
function FindDeps(Src) {
const Head = /\(\s*function\s*\(([^)]*)\)\s*\{/.exec(Src);
let Tail = null;
const Re = /\}\)\(([^;]*)\);/g;
let M;
while ((M = Re.exec(Src))) Tail = M;
if (!Head || !Tail) return {};
const P = SplitArgs(Head[1]);
const A = SplitArgs(Tail[1]);
const Out = {};
for (let I = 0; I < P.length && I < A.length; I++) {
const X = /\(\s*(\d+)\s*\)/.exec(A[I]);
if (X) Out[P[I]] = X[1];
}
return Out;
}
// 10032 导出表:键 → 局部类名
const Src10032 = fs.readFileSync(path.join(SRC, 'module_10032.js'), 'utf8');
const ExportToLocal = new Map();
for (const M of Src10032.matchAll(/t\.\$_\$\.([A-Za-z0-9_$]+) = ([A-Za-z0-9_$]+)/g)) ExportToLocal.set(M[1], M[2]);
const LocalToName = new Map();
for (const R of Inventory.Rows) if (!LocalToName.has(R.Local)) LocalToName.set(R.Local, R.Name);
// 键必须带局部类名:不同模块里存在同名但字段完全不同的动作类(如 Init 有 1 字段版和 18 字段版)
const KeyToClass = new Map();
for (const [K, L] of ExportToLocal) if (LocalToName.has(L)) KeyToClass.set(K, { Name: LocalToName.get(L), Local: L });
// 扫全包调用点
const Calls = new Map(); // "动作名|局部类名" → [{Module, At, Args}]
// 在 Text 里收集 `new <NewName>(…)` 的构造点(Kotlin/JS 的 data class 用 new 调用)
// KeyLocal = 该类在 module_10032 里的局部类名(跨模块时 NewName 是别名,不能拿来做键)
function CollectCalls(Text, NewName, Name, KeyLocal, Module) {
const Key = Name + '|' + KeyLocal;
const Re = new RegExp('(?<![\\w$.])new ' + NewName.replace(/[$]/g, '\\$') + '\\s*\\(', 'g');
let M;
while ((M = Re.exec(Text))) {
const Open = Text.indexOf('(', M.index);
let Depth = 0;
let End = -1;
for (let I = Open; I < Text.length; I++) {
if (Text[I] === '(') Depth++;
else if (Text[I] === ')') { Depth--; if (Depth === 0) { End = I; break; } }
}
if (End < 0) continue;
const Args = SplitArgs(Text.slice(Open + 1, End));
const List = Calls.get(Key) || [];
// At = new 关键字在文件里的偏移,供 gen_action_kt 做局部数据流回溯
List.push({ Module, At: M.index, Args });
Calls.set(Key, List);
}
}
// ① module_10032 内部:动作之间互相 new(这些类大多不导出,外部扫不到)
for (const [Local, Name] of LocalToName) CollectCalls(Src10032, Local, Name, Local, 'module_10032');
// ② 其它模块:经由 `别名 = dep.$_$.<导出键>` 引入后 new
for (const F of fs.readdirSync(SRC).filter((N) => /^module_\d+\.js$/.test(N))) {
if (F === 'module_10032.js') continue;
const Text = fs.readFileSync(path.join(SRC, F), 'utf8');
const Deps = FindDeps(Text);
const Alias = new Map(); // 局部别名 → {Name, Local}
for (const M of Text.matchAll(/(?<![A-Za-z0-9_$.])([A-Za-z_$][\w$]*) = ([A-Za-z_$][\w$]*)\.\$_\$\.([A-Za-z0-9_$]+)[,;)]/g)) {
if (Deps[M[2]] !== '10032') continue;
const Cls = KeyToClass.get(M[3]);
if (Cls && !Alias.has(M[1])) Alias.set(M[1], Cls);
}
if (!Alias.size) continue;
for (const [Local, Cls] of Alias) CollectCalls(Text, Local, Cls.Name, Cls.Local, F.replace('.js', ''));
}
const Named = Inventory.Rows.filter((R) => Calls.has(R.Name + '|' + R.Local));
console.log('有调用点的动作/状态类 ' + Named.length + ' / ' + Inventory.Rows.length);
console.log('总调用点 ' + [...Calls.values()].reduce((A, B) => A + B.length, 0));
for (const R of Named.slice(0, Limit)) {
const List = Calls.get(R.Name + '|' + R.Local);
const Sample = List.slice(0, 3).map((C) => C.Module + ': ' + C.Args.join(' , '));
console.log(' ' + R.Name + '(' + R.Params.join(',') + ') ×' + List.length);
for (const S of Sample) console.log(' ' + S.slice(0, 140));
}
fs.writeFileSync(path.join(PROJECT, 'names', 'action_calls_10032.json'), JSON.stringify({
generatedAt: new Date().toISOString(),
stats: { Classes: Inventory.Rows.length, WithCalls: Named.length },
Calls: Object.fromEntries([...Calls.entries()]),
}, null, 1), 'utf8');
console.log(' 输出: 3DTank/names/action_calls_10032.json');
+71 -10
View File
@@ -108,7 +108,17 @@ function SplitTypeArgs(Text) {
function MakeReader(Tables, Options) {
const Opts = Options || {};
const EnumBytes = Opts.EnumBytes === undefined ? 4 : Opts.EnumBytes;
// enum 宽度:源码上 EnumCodecInfo(vn → Kt)读写都是空实现(0 字节),但实测数据自相矛盾——
// EmissionColorCC 的 4×i32 正好到帧尾(该 enum 必须 0 字节),而 enum=0 又会让 P_/Tu 等 21 个模型错位。
// 全量审计下 4 字节口径最优(6 处未命中 vs 21),故默认 4;需要时用 --enum-bytes 0 复核。
const EnumBytes = Opts.EnumBytes === undefined ? 4 : Opts.EnumBytes;
// EnumInList:list<enum<T>> 里每个元素占多少字节。实测「独立 enum 字段」是 0 字节,
// 但列表里的枚举元素看起来仍占 4 字节(否则 P_/Tu 这类带 list<enum> 的模型会整体错位)。
// 默认与 EnumBytes 一致;审计时可单独调。
const EnumInList = Opts.EnumInList === undefined ? EnumBytes : Opts.EnumInList;
// OnField:逐字段回调(诊断用)。参数 {Depth, Name, Type, Start, Next, BitBefore, BitAfter, Bad, Why}
// —— 只读观测,不参与解码判定;审计器用它打印「每个字段消费了哪些字节/位」。
const OnField = Opts.OnField || null;
// OptBits:opt 的「空/非空」改从 nullmap 比特流读 1 个 bit(见文件头注释)。
// 默认 false = 老口径(1 字节标志),也就是「字段表」那条路径的既有行为,不动它。
const OptBits = !!Opts.OptBits;
@@ -171,28 +181,51 @@ function MakeReader(Tables, Options) {
const N = ReadVarint(Buf, Off);
let At = N.Next;
let Bad = N.Bad;
let Why = '';
const Arr = [];
// 「元素读不动」的判据在 OptBits 模式下要放宽:元素若是 opt<T> 且为空,值一个字节都不占
// (只有 nullmap 里的 1 个 bit,见 src/module_24635.js:412-417 的 Ht.p7g:读到位=1 时直接 return null),
// 于是 Next == At 是**合法**的。旧口径把它当「卡住」→ list<opt<T>> 里只要有空元素就整段解不动
// (实测 space-100073070/s2c_0001 的 MountedResistancesCC.resistances 就卡在这里)。
const ZeroOk = OptBits && /^opt</.test(String(List[1]));
// 「元素读不动」的判据在 OptBits 模式下要放宽:
// · 元素是 opt<T> 且为空 → 0 字节(只有 nullmap 里的 1 个 bit);
// · 元素是 enum<T> → 恒 0 字节(EnumCodecInfo 不上线)。
const ElemT = String(List[1]);
const ZeroOk = (OptBits && /^opt</.test(ElemT)) || (/^enum</.test(ElemT) && EnumInList === 0);
// 列表里的枚举元素按 EnumInList 宽度跳过(独立 enum 字段走 EnumBytes 分支)
const ElemEnum = /^enum</.test(ElemT) ? EnumInList : -1;
for (let I = 0; I < N.Value; I++) {
if (I >= 4096) {
Arr.push('<元素过多>');
Bad = true;
break;
}
if (ElemEnum > 0) {
if (At + ElemEnum > Buf.length) {
Arr.push('<越界>');
Bad = true;
break;
}
Arr.push('<enum>');
At += ElemEnum;
continue;
}
if (ElemEnum === 0) {
Arr.push('<enum 不上线>');
continue;
}
const BitBefore = BitIndex;
const R = ReadValue(Buf, At, List[1], TypeCodec, Depth + 1);
Arr.push(R.Value);
if (R.Bad || (R.Next <= At && !ZeroOk)) {
// 元素不占字节也可能是合法的:opt<T> 为空(走位图),或「所有字段都是 opt 且都为空」的模型
// (例如 ParticleEffectItem 只有 opt<list<…>> + i64 + string,若前两项为空则可能 0 字节)。
// 判据:字节没推进但位游标推进了。
const BitMoved = BitIndex > BitBefore;
if (R.Bad || (R.Next <= At && !ZeroOk && !BitMoved)) {
Arr.push('<卡住>');
Bad = true;
if (R.Why) Why = R.Why; // 把元素内部的具体原因带出去,否则上层只看到「list 解不动」
break;
}
At = R.Next;
}
return { Value: Arr, Next: At, Bad };
return { Value: Arr, Next: At, Bad, Why };
}
const Enum = /^enum<([\s\S]+)>$/.exec(T);
if (Enum) {
@@ -212,8 +245,8 @@ function MakeReader(Tables, Options) {
let Bad = N.Bad;
const Pairs = [];
// 与 list 同源:OptBits 模式下 opt<…> 为空时 0 字节是合法的(见上面 list 分支的注释)
const ZeroK = OptBits && /^opt</.test(String(KV[0]));
const ZeroV = OptBits && /^opt</.test(String(KV[1]));
const ZeroK = (OptBits && /^opt</.test(String(KV[0]))) || /^enum</.test(String(KV[0]));
const ZeroV = (OptBits && /^opt</.test(String(KV[1]))) || /^enum</.test(String(KV[1]));
for (let I = 0; I < N.Value; I++) {
if (I >= 4096) {
Pairs.push(['<条目过多>', '']);
@@ -237,6 +270,16 @@ function MakeReader(Tables, Options) {
return { Value: Pairs, Next: At, Bad };
}
// 定长二进制块:Type = bytes<N>
// 出处:module_87072.js:53876 `(Zy(T5).t7h = function (t) { this.j101_1.v100(t.g7h_1, 23); … })`
// —— TankState 的线上形态是一个 23 字节的打包姿态 blob(内部按 19/13/13/13 位量化 4 个向量)。
// 我们不解释内容,只按定长吃掉,保证后续字节对得上。
const Fixed = /^bytes(\d+)$/.exec(T);
if (Fixed) {
const N = Number(Fixed[1]);
if (Off + N > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: '0x' + Buf.slice(Off, Off + N).toString('hex'), Next: Off + N };
}
switch (T) {
case 'bool':
if (Off >= Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
@@ -260,6 +303,13 @@ function MakeReader(Tables, Options) {
case 'f64':
if (Off + 8 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return { Value: Buf.readDoubleBE(Off), Next: Off + 8 };
case 'vector3':
// 向量 = 3 × f32(module_32088 的向量类;字段表里以裸类型名 vector3 出现,无嵌套 codec)
if (Off + 12 > Buf.length) return { Value: '<越界>', Next: Off, Bad: true };
return {
Value: { x: Buf.readFloatBE(Off), y: Buf.readFloatBE(Off + 4), z: Buf.readFloatBE(Off + 8) },
Next: Off + 12,
};
case 'string':
case 'bytes':
case 'array': {
@@ -301,10 +351,21 @@ function MakeReader(Tables, Options) {
let Bad = false;
let Why = '';
for (const F of (Codec && Codec.Fields) || []) {
const BitBefore = BitIndex;
const R = ReadValue(Buf, At, F.Type, F.TypeCodec, Depth);
// 匿名字段(表里没有名字,只有线宽)用序号区分,否则会同名互相覆盖
const Key = F.Name === '(匿名)' || F.Name === '?' ? '(匿名#' + F.Order + ')' : F.Name;
Obj[Key] = R.Value;
if (OnField) {
try {
OnField({
Depth: Depth || 0, Name: Key, Type: F.Type, Start: At, Next: R.Next,
BitBefore, BitAfter: BitIndex, Bad: !!R.Bad, Why: R.Why || '',
});
} catch (E) {
// 诊断回调不允许影响解码
}
}
if (R.Bad) {
Bad = true;
// 诊断用的原因串:哪个字段、什么类型、卡在负载内第几字节、内层如果直接说得出原因也带上
+7 -6
View File
@@ -49,8 +49,8 @@ const NamesDir = path.resolve(ToolsDir, '..', 'names');
const OutPath = path.resolve(ToolsDir, Arg('out', path.join('..', 'names', 'downlink_table.json')));
const Check = Argv.indexOf('--check') >= 0;
// ByteBuffer 的定长读取器:名字来自 src_named/module_72834.js(xi/n2u=xy=getInt8 … s2u=w2t=getFloat64)
const ReaderType = { n2u: 'i8', o2u: 'i16', p2u: 'i32', q2u: 'i64', r2u: 'f32', s2u: 'f64' };
// ByteBuffer 的定长读取器:名字来自 src/module_72834.js(p2u=xy=getInt8 … t2u=getFloat32、u2u=getFloat64)
const ReaderType = { xy: 'i8', p2u: 'i8', q2u: 'i16', r2u: 'i32', s2u: 'i64', t2u: 'f32', u2u: 'f64' };
// ---------------------------------------------------------------- 载入 codec 字段表
const CodecFields = JSON.parse(fs.readFileSync(path.join(NamesDir, 'codec_fields.json'), 'utf8'));
@@ -93,7 +93,7 @@ for (const N of NameAmbiguous) NameType.delete(N);
// 以及「模型类」——但模型类只有在能定位到 codec 时才解得出(TypeCodec 链接,或该真名全局唯一)。
// 不在这个集合里的(any/number/nothing/throwable/xxArray/prim:xx/认不出的裸类名)一律算没解出来,
// 免得把解不动的类型写进表里、到 semantic.js 那一步才炸。
const ReaderLeaf = new Set(['bool', 'i8', 'i16', 'i32', 'i64', 'f32', 'f64', 'string', 'bytes', 'array']);
const ReaderLeaf = new Set(['bool', 'i8', 'i16', 'i32', 'i64', 'f32', 'f64', 'string', 'bytes', 'array', 'vector3']);
function Readable(Type, TypeCodec) {
const T = String(Type || '');
if (!T || T === '?' || T === '...' || T[0] === '<') return false;
@@ -176,8 +176,9 @@ function ReadOrder(Body) {
/\(function \(t\) \{\s*var ([A-Za-z0-9_$]+) = t\.([a-z0-9]+_\d+);\s*if \(null != \1\) return \1;\s*[A-Za-z_$][\w$]*\(\s*"([^"]*)"\s*\);\s*\}\)\(this\)/g;
let M;
while ((M = ReCodec.exec(Body))) Hits.push({ At: M.index, Slot: M[2], Tag: M[3] });
const RePrim = /\.a7g_1\.([A-Za-z0-9_$]+)\(\)/g;
while ((M = RePrim.exec(Body))) Hits.push({ At: M.index, Reader: M[1] });
// 基础类型:旧包固定缓冲槽位 a7g_1,新包槽位名会变,故泛化
const RePrim = /\.([a-z0-9]+_\d+)\.([A-Za-z0-9_$]+)\(\)/g;
while ((M = RePrim.exec(Body))) Hits.push({ At: M.index, Reader: M[2] });
Hits.sort((A, B) => A.At - B.At);
return Hits;
}
@@ -208,7 +209,7 @@ for (const File of Files) {
const Longs = FindLongs(Text);
// 找每个 `.q7i = function (...) {` 的函数体。源码形态是 `(p(Pt).q7i = function (t, n) {` ——
// 类名在包装函数的括号里(包装函数逐模块不同:p / ke / ee / nt / …),所以捕获 `)<方法名>` 前那个标识符。
const ReHead = /([A-Za-z_$][\w$]*)\)\.q7i = function \(([^)]*)\) \{/g;
const ReHead = /([A-Za-z_$][\w$]*)\)\.u7j = function \(([^)]*)\) \{/g;
let H;
while ((H = ReHead.exec(Text))) {
const Owner = H[1];
+10 -2
View File
@@ -151,7 +151,8 @@ function Run() {
let M;
// 1) 懒加载组件 getter(容忍换行/空格)
const GetterRe = /var\s+([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\s*;[\s\S]{0,40}?if\s*\(\s*null\s*!=\s*\1\s*\)\s*return\s+\1\s*;[\s\S]{0,40}?(?:vj|Ys|rq)\s*\(\s*"([^"]+)"\s*\)/g;
// 旧版抛错函数是 vj / Ys / rq;当前版本(main.7cfa4058)换成了 Z$
const GetterRe = /var\s+([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\s*;[\s\S]{0,40}?if\s*\(\s*null\s*!=\s*\1\s*\)\s*return\s+\1\s*;[\s\S]{0,40}?(?:vj|Ys|rq|Z\$)\s*\(\s*"([^"]+)"\s*\)/g;
while ((M = GetterRe.exec(Text)) !== null) {
Mod.componentGetters[M[3]] = M[4];
Push(FieldsGlobal, M[3], M[4]);
@@ -208,8 +209,15 @@ function Run() {
}
}
// 记录证据来源包:优先读 extract.js 产出的 index.json(其中的 bundle 字段)
let BundleName = 'unknown';
try {
const Index = JSON.parse(fs.readFileSync(path.join(SRC_DIR, 'index.json'), 'utf8'));
if (Index && Index.bundle) BundleName = Index.bundle;
} catch (e) { /* 没有 index.json 就保持 unknown */ }
const Result = {
bundle: 'main.6469b966.js',
bundle: BundleName,
generatedAt: new Date().toISOString(),
stats: Stats,
byModule: ByModule,
+58
View File
@@ -0,0 +1,58 @@
// parse_dsstore.js - 从 macOS .DS_Store 里抽出目录项名字
// 用法: node parse_dsstore.js <file.ds_store> [--json out.json]
// 原理: DS_Store 是 B 树,文件/目录名记录以 [type=0x00000005][offset u32][len u32][len×UTF-16BE] 内联存放,
// 因此用滑动扫描定位 type 标记再读长度即可,无需完整解析 B 树。
const fs = require('fs');
const path = require('path');
const Argv = process.argv.slice(2);
const File = Argv[0];
const JsonOut = (() => {
const I = Argv.indexOf('--json');
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : '';
})();
const Buf = fs.readFileSync(File);
const Names = new Set();
// 主路径:定位 type=5 记录
for (let I = 0; I + 12 <= Buf.length; I++) {
if (Buf[I] !== 0 || Buf[I + 1] !== 0 || Buf[I + 2] !== 0 || Buf[I + 3] !== 5) continue;
const Len = Buf.readUInt32BE(I + 8);
if (Len === 0 || Len > 1024) continue;
const Start = I + 12;
if (Start + Len * 2 > Buf.length) continue;
const Name = Buf.slice(Start, Start + Len * 2).toString('utf16le');
// 用 utf16le 读 BE 数据会字节交换;这里改回手工 BE 解码
let BeName = '';
for (let K = 0; K < Len; K++) BeName += String.fromCharCode(Buf.readUInt16BE(Start + K * 2));
if (!BeName || /[\x00-\x08\x0b-\x1f]/.test(BeName)) continue;
Names.add(BeName);
void Name;
}
// 兜底:任意可打印 UTF-16BE 串(长度 >= 3)
if (Names.size === 0) {
for (let I = 0; I + 6 <= Buf.length; I += 2) {
let S = '';
let J = I;
while (J + 2 <= Buf.length) {
const C = Buf.readUInt16BE(J);
if (C < 0x20 || C > 0x7e) break;
S += String.fromCharCode(C);
J += 2;
}
if (S.length >= 3) {
Names.add(S);
I = J - 2;
}
}
}
const List = [...Names].sort();
const Stat = fs.statSync(File);
console.log('文件: ' + path.basename(File) + ' ' + Stat.size + ' 字节');
console.log('条目: ' + List.length);
for (const N of List) console.log(' ' + N);
if (JsonOut) fs.writeFileSync(path.resolve(JsonOut), JSON.stringify({ File, Size: Stat.size, Entries: List }, null, 2), 'utf8');
+125
View File
@@ -0,0 +1,125 @@
// prefetch_assets.js - 预拉取车库/大厅所需的静态资源到本地镜像目录
// 用法: node prefetch_assets.js [--concurrency 8] [--only images]
//
// 背景:
// 本地镜像只镜像了 JS,`/play/static/images/*` 全靠 serve.js 现场回源 3dtank.com 并落盘
// (见 serve.js 的「自愈」分支)。从战局返回车库时车库界面要一次性加载大量贴图/图标,
// 首次全部走回源 → 明显卡顿。
// 本脚本把 webpack 资源表(unpacked/resources.json)里的所有静态资源**预先下载**到
// `3DTank/web/play/static/`,之后 serve.js 直接本地直出,返回车库秒开。
//
// 产物位置与 serve.js 的 WEB_DIR 一致:
// URL /play/static/images/xxx.webp → 3DTank/web/play/static/images/xxx.webp
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const ROOT = path.resolve(PROJECT, '..');
const WEB_DIR = path.join(PROJECT, 'web');
const RES_JSON = path.join(ROOT, '3dtank.com', 'play', 'static', 'js', 'unpacked', 'resources.json');
const BASE_URL = 'https://3dtank.com/play/';
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const CONCURRENCY = Math.max(1, Number(Arg('concurrency', '8')) || 8);
const HEADERS = {
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
origin: 'https://3dtank.com',
referer: 'https://3dtank.com/play/',
};
function ReadResourceMap() {
if (!fs.existsSync(RES_JSON)) {
console.error('[prefetch] 找不到资源表: ' + RES_JSON);
process.exit(1);
}
const Json = JSON.parse(fs.readFileSync(RES_JSON, 'utf8'));
const Map = Json.resourceMap || {};
// 去重:多个模块可能引用同一个文件
return Array.from(new Set(Object.values(Map).filter((V) => typeof V === 'string' && V)));
}
function TargetFile(Rel) {
// 安全:禁止跳出 WEB_DIR
const Clean = Rel.replace(/^\/+/, '');
const Out = path.resolve(WEB_DIR, 'play', Clean);
if (!Out.startsWith(path.resolve(WEB_DIR))) return null;
return Out;
}
async function DownloadOne(Rel, Stats) {
const Out = TargetFile(Rel);
if (!Out) {
Stats.skip++;
return;
}
if (fs.existsSync(Out)) {
Stats.cached++;
return;
}
const Url = BASE_URL + Rel.replace(/^\/+/, '');
for (let Try = 0; Try < 3; Try++) {
try {
const Res = await fetch(Url, { headers: HEADERS, redirect: 'follow' });
if (!Res.ok) {
if (Res.status === 404) {
Stats.missing++;
console.warn('[404] ' + Rel);
return;
}
throw new Error('HTTP ' + Res.status);
}
const Buf = Buffer.from(await Res.arrayBuffer());
fs.mkdirSync(path.dirname(Out), { recursive: true });
fs.writeFileSync(Out, Buf);
Stats.downloaded++;
Stats.bytes += Buf.length;
if (Stats.downloaded % 50 === 0) {
console.log(
'[prefetch] 已下载 ' + Stats.downloaded + ' / ' + Stats.total + '(' + (Stats.bytes / 1048576).toFixed(1) + ' MB)',
);
}
return;
} catch (Err) {
if (Try === 2) {
Stats.failed++;
console.warn('[fail] ' + Rel + ' : ' + Err.message);
} else {
await new Promise((R) => setTimeout(R, 400 * (Try + 1)));
}
}
}
}
async function Main() {
const List = ReadResourceMap();
const Stats = { total: List.length, downloaded: 0, cached: 0, missing: 0, failed: 0, skip: 0, bytes: 0 };
console.log('[prefetch] 资源表条目: ' + List.length + ',并发: ' + CONCURRENCY);
console.log('[prefetch] 输出目录: ' + path.join(WEB_DIR, 'play'));
let Index = 0;
async function Worker() {
while (Index < List.length) {
const Rel = List[Index++];
await DownloadOne(Rel, Stats);
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, Worker));
console.log(
'[prefetch] 完成:新下载 ' + Stats.downloaded +
',已存在 ' + Stats.cached +
',404 ' + Stats.missing +
',失败 ' + Stats.failed +
',合计 ' + (Stats.bytes / 1048576).toFixed(1) + ' MB',
);
}
Main();
+371
View File
@@ -0,0 +1,371 @@
// probe_cdn.js - 探测 3dtank.com / tankionline.com 的边缘 CDN 与 source map 暴露情况
// 用法:
// node probe_cdn.js --mode dns # 子域枚举(DNS 解析)
// node probe_cdn.js --mode sourcemap --js <url> # 拉取 js 尾部找 sourceMappingURL,并试候选 map 路径
// node probe_cdn.js --mode tail --js <url> # 只取 js 末尾 N 字节(找 sourceMappingURL / 版本注释)
// node probe_cdn.js --mode paths --base <url> # 试一批常见路径(目录列表/备份/配置)
// node probe_cdn.js --mode cors --base <url> # 试探来源校验与 CORS
// node probe_cdn.js --mode all --js <url> --base <url>
// 说明:
// 只用 GET/HEAD/OPTIONS,不做任何写操作(避免对线上产生影响)。
// 默认忽略证书错误(站点多为老配置),并把结果落盘到 names/cdn_probe.json。
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const dns = require('dns');
const { URL } = require('url');
const PROJECT = path.resolve(__dirname, '..');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const MODE = Arg('mode', 'all');
const JS_URL = Arg('js', 'https://3dtank.com/play/static/js/main.6469b966.js');
const BASE = Arg('base', 'https://3dtank.com');
const TIMEOUT = Number(Arg('timeout', '12000'));
const CONCURRENCY = Number(Arg('concurrency', '8'));
const OUT = Arg('out', path.join(PROJECT, 'names', 'cdn_probe.json'));
const CORS_PATH = Arg('path', '/');
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0';
const SITE_HEADERS = {
accept: '*/*',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
origin: 'https://3dtank.com',
referer: 'https://3dtank.com/',
'user-agent': UA,
};
// 候选子域(两个站点都试)
const HOSTS = {
'3dtank.com': [
'3dtank.com', 'www', 'res', 'static', 'cdn', 'img', 'images', 'assets', 's',
'c1', 'c2', 'c3', 'c4', 'game', 'play', 'patch', 'update', 'updater', 'dl',
'download', 'files', 'media', 'mirror', 'client', 'launcher', 'api', 'open-api',
'mgr', 'ops', 'forum', 'union', 'ratings', 'test', 'dev', 'beta', 'new', 'old',
'bbs', 'sso', 'pay', 'stat', 'log', 'oss',
],
'tankionline.com': [
'tankionline.com', 'www', 'res', 'static', 'sstatic', 'cdn', 'img', 'images',
'assets', 's', 'c1', 'c2', 'c3', 'game', 'play', 'patch', 'update', 'updater',
'dl', 'download', 'files', 'media', 'mirror', 'client', 'launcher', 'api',
'm', 'test', 'dev', 'beta', 'new', 'old', 'forum', 'en', 'ru', 'de',
],
};
// 各域通用的子域候选(--domains 追加新域时复用)
const SUBS_DEFAULT = [
'www', 'res', 'static', 'cdn', 'img', 'images', 'assets', 's', 'play', 'game',
'c1', 'c2', 'c3', 'dl', 'download', 'files', 'patch', 'update', 'api', 'm',
];
// 常见可疑路径(开放目录/备份/配置泄漏)
const PATHS = [
'/', '/index.html', '/resources/', '/resources/index.json', '/play/', '/play/static/',
'/play/static/js/', '/play/static/js/index.json', '/play/static/js/resources.json',
'/play/static/js/main.js.map', '/static/', '/assets/', '/files/', '/dl/', '/patch/',
'/config.xml', '/s/status.js', '/robots.txt', '/sitemap.xml', '/.git/HEAD',
'/crossdomain.xml', '/version.txt', '/manifest.json', '/favicon.ico',
];
// 单次请求;Follow 为真时自动跟 3xx
function Request(Url, Opt) {
const O = Opt || {};
return new Promise((Resolve) => {
let U;
try {
U = new URL(Url);
} catch (E) {
return Resolve({ Error: 'bad url' });
}
const Lib = U.protocol === 'http:' ? http : https;
const Headers = Object.assign({}, O.Headers || {});
const Req = Lib.request(
{
protocol: U.protocol,
hostname: U.hostname,
port: U.port || (U.protocol === 'http:' ? 80 : 443),
path: U.pathname + U.search,
method: O.Method || 'GET',
headers: Headers,
rejectUnauthorized: false,
servername: U.hostname,
timeout: TIMEOUT,
},
(Res) => {
const Chunks = [];
let Total = 0;
let Finished = false;
const Cap = O.Cap || 0; // >0 时只读前 Cap 字节
const Done = () => {
if (Finished) return;
Finished = true;
const Body = Buffer.concat(Chunks);
if (O.Follow && Res.statusCode >= 300 && Res.statusCode < 400 && Res.headers.location) {
const Next = new URL(Res.headers.location, Url).href;
return Resolve(Request(Next, Object.assign({}, O, { Follow: (O.Follow || 1) - 1 })));
}
Resolve({
Url,
Status: Res.statusCode,
Bytes: Total,
Type: String(Res.headers['content-type'] || ''),
Server: String(Res.headers.server || ''),
Location: String(Res.headers.location || ''),
Acao: String(Res.headers['access-control-allow-origin'] || ''),
Allow: String(Res.headers.allow || ''),
Headers: Res.headers,
Body,
});
};
Res.on('data', (C) => {
Total += C.length;
if (!Cap || Total <= Cap) Chunks.push(C);
if (Cap && Total > Cap) {
Res.destroy();
Done();
}
});
Res.on('end', Done);
}
);
Req.on('timeout', () => {
Req.destroy();
Resolve({ Url, Error: 'timeout' });
});
Req.on('error', (E) => Resolve({ Url, Error: E.code || E.message }));
if (O.Body) Req.write(O.Body);
Req.end();
});
}
async function Pool(Items, Worker, N) {
let Next = 0;
const Out = new Array(Items.length);
await Promise.all(
new Array(Math.min(N, Items.length || 1)).fill(0).map(async () => {
while (true) {
const I = Next++;
if (I >= Items.length) return;
Out[I] = await Worker(Items[I], I);
}
})
);
return Out;
}
function Brief(R) {
if (R.Error) return 'ERR ' + R.Error;
return (
String(R.Status).padEnd(4) +
String(R.Bytes).padStart(9) +
' ' +
(R.Type || '-').split(';')[0].padEnd(24) +
(R.Server ? 'srv=' + R.Server + ' ' : '') +
(R.Acao ? 'ACAO=' + R.Acao + ' ' : '') +
(R.Location ? '-> ' + R.Location : '')
);
}
// ---- DNS 子域枚举 ----
async function ModeDns() {
const Result = {};
const Extra = Arg('domains', '');
if (Extra) for (const D of Extra.split(',').map((S) => S.trim()).filter(Boolean)) HOSTS[D] = HOSTS[D] || SUBS_DEFAULT;
const SubsOverride = Arg('subs', '');
if (SubsOverride) {
const S = SubsOverride.split(',').map((X) => X.trim()).filter(Boolean);
for (const D of Object.keys(HOSTS)) HOSTS[D] = S;
}
for (const [Domain, Subs] of Object.entries(HOSTS)) {
console.log('== DNS ' + Domain + ' ==');
const Found = [];
const Rows = await Pool(
Subs,
async (S) => {
const Fqdn = S.includes('.') ? S : S + '.' + Domain;
const A = await new Promise((R) => dns.resolve4(Fqdn, (E, Addr) => R(E ? null : Addr)));
const C = await new Promise((R) => dns.resolveCname(Fqdn, (E, Addr) => R(E ? null : Addr)));
if (!A && !C) return null;
const Row = { Host: Fqdn, A: A || [], Cname: C || [] };
Found.push(Row);
console.log(' ' + Fqdn.padEnd(34) + (A ? 'A=' + A.join(',') : '') + (C ? ' CNAME=' + C.join(',') : ''));
return Row;
},
CONCURRENCY
);
Result[Domain] = Rows.filter(Boolean);
}
return Result;
}
// ---- source map ----
async function ModeSourcemap() {
console.log('== sourcemap 探测: ' + JS_URL + ' ==');
const Result = { Js: JS_URL, Tail: '', MappingUrl: '', Candidates: [] };
const Main = await Request(JS_URL, { Headers: SITE_HEADERS, Follow: 3, Cap: 2 * 1024 * 1024 });
console.log(' js 本体: ' + Brief(Main));
if (Main.Body) {
const Text = Main.Body.toString('latin1');
const M = Text.match(/sourceMappingURL=([^\s'"*]+)/);
if (M) {
Result.MappingUrl = M[1];
console.log(' 发现 sourceMappingURL: ' + M[1]);
} else {
const Tail = Text.slice(-300).replace(/[^\x20-\x7e]/g, '.');
Result.Tail = Tail;
console.log(' 正文尾部(未见 sourceMappingURL): ' + Tail.slice(-160));
}
}
const Base = JS_URL.replace(/\.js($|\?)/, '');
const Cands = [
JS_URL + '.map',
Base + '.js.map',
Base + '.map',
Base.replace(/\/[^/]+$/, '/') + 'main.js.map',
Base + '.js.map?',
];
if (Result.MappingUrl) Cands.unshift(new URL(Result.MappingUrl, JS_URL).href);
const Rows = await Pool(
[...new Set(Cands)],
async (U) => {
const R = await Request(U, { Headers: SITE_HEADERS, Follow: 3 });
console.log(' ' + Brief(R) + ' ' + U);
return { Url: U, Status: R.Status, Bytes: R.Bytes, Type: R.Type, Error: R.Error };
},
CONCURRENCY
);
Result.Candidates = Rows;
return Result;
}
// ---- 尾部字节(sourceMappingURL / 版本注释常在这里)----
async function ModeTail() {
const N = Number(Arg('bytes', '2048'));
console.log('== 尾部 ' + N + ' 字节: ' + JS_URL + ' ==');
const R = await Request(JS_URL, {
Headers: Object.assign({}, SITE_HEADERS, { range: 'bytes=-' + N }),
Follow: 2,
});
console.log(' ' + Brief(R));
const Text = (R.Body || Buffer.alloc(0)).toString('latin1');
const Tail = Text.slice(-N);
const Result = { Js: JS_URL, Status: R.Status, Bytes: R.Bytes, Range: R.Headers && R.Headers['content-range'], Tail: Tail };
const M = Tail.match(/sourceMappingURL=([^\s'"*]+)/);
if (M) {
Result.MappingUrl = M[1];
console.log(' >>> sourceMappingURL = ' + M[1]);
}
console.log(' --- 尾部原文 ---');
console.log(Tail.replace(/[\x00-\x08\x0b-\x1f]/g, '.'));
return Result;
}
// ---- 常见路径 ----
async function ModePaths() {
console.log('== 路径探测: ' + BASE + ' ==');
const Rows = await Pool(
PATHS,
async (P) => {
const R = await Request(BASE + P, { Headers: SITE_HEADERS, Follow: 2, Cap: 4096 });
console.log(' ' + Brief(R).padEnd(80) + ' ' + P);
return { Path: P, Status: R.Status, Bytes: R.Bytes, Type: R.Type, Server: R.Server, Acao: R.Acao };
},
CONCURRENCY
);
return Rows;
}
// ---- 任意 URL 列表 ----
async function ModeList() {
const Inline = Arg('urls', '');
const File = Arg('file', '');
let List = Inline ? Inline.split(',').map((S) => S.trim()).filter(Boolean) : [];
if (File) {
List = List.concat(
fs.readFileSync(path.resolve(File), 'utf8').split(/\r?\n/).map((S) => S.trim()).filter((S) => S && !S.startsWith('#'))
);
}
const Cap = Number(Arg('cap', '4096'));
const HostOverride = Arg('host', ''); // 指定 Host(配 IP 直连做 CDN 回源探测)
const ExtraHeaders = Arg('headers', ''); // 额外请求头,形如 "k:v|k:v"
let HeadersBase = HostOverride ? Object.assign({}, SITE_HEADERS, { host: HostOverride }) : SITE_HEADERS;
if (ExtraHeaders) {
HeadersBase = Object.assign({}, HeadersBase);
for (const Pair of ExtraHeaders.split('|')) {
const C = Pair.indexOf(':');
if (C > 0) HeadersBase[Pair.slice(0, C).trim().toLowerCase()] = Pair.slice(C + 1).trim();
}
}
console.log('== 列表探测 ' + List.length + ' 个 URL ==' + (HostOverride ? ' Host 覆盖: ' + HostOverride : ''));
const Rows = await Pool(
List,
async (U) => {
const R = await Request(U, { Headers: HeadersBase, Follow: 3, Cap });
const Preview = R.Body ? R.Body.slice(0, Cap).toString('latin1').replace(/\s+/g, ' ').slice(0, 200) : '';
console.log(' ' + Brief(R).padEnd(76) + ' ' + U);
if (Preview && (!R.Type || !/image|font|octet/.test(R.Type))) console.log(' | ' + Preview);
return {
Url: U,
Status: R.Status,
Bytes: R.Bytes,
Type: R.Type,
Server: R.Server,
Acao: R.Acao,
Range: R.Headers && R.Headers['content-range'],
Preview,
Error: R.Error,
};
},
CONCURRENCY
);
return Rows;
}
// ---- CORS / 来源校验 / 写方法 ----
async function ModeCors() {
console.log('== CORS/来源校验/方法探测: ' + BASE + ' ==');
const Tests = [
{ Name: '纯 GET 无任何头', Headers: { 'user-agent': UA }, Method: 'GET' },
{ Name: '外来 Origin', Headers: { 'user-agent': UA, origin: 'https://evil.example' }, Method: 'GET' },
{ Name: '外来 Referer', Headers: { 'user-agent': UA, referer: 'https://evil.example/' }, Method: 'GET' },
{ Name: '空 Referer', Headers: { 'user-agent': UA, referer: '' }, Method: 'GET' },
{ Name: '站点头(基准)', Headers: SITE_HEADERS, Method: 'GET' },
{ Name: 'null Origin', Headers: { 'user-agent': UA, origin: 'null' }, Method: 'GET' },
{ Name: 'OPTIONS 预检', Headers: SITE_HEADERS, Method: 'OPTIONS' },
];
const Rows = [];
for (const T of Tests) {
const R = await Request(BASE + CORS_PATH, { Headers: T.Headers, Method: T.Method, Follow: 2, Cap: 2048 });
const Row = {
Name: T.Name,
Status: R.Status,
Bytes: R.Bytes,
Acao: R.Acao,
Allow: R.Allow,
Server: R.Server,
Error: R.Error,
};
Rows.push(Row);
console.log(' ' + T.Name.padEnd(20) + Brief(R).padEnd(70) + (R.Allow ? 'Allow=' + R.Allow : ''));
}
return Rows;
}
(async () => {
const Out = {};
if (MODE === 'dns' || MODE === 'all') Out.Dns = await ModeDns();
if (MODE === 'sourcemap' || MODE === 'all') Out.Sourcemap = await ModeSourcemap();
if (MODE === 'tail') Out.Tail = await ModeTail();
if (MODE === 'list') Out.List = await ModeList();
if (MODE === 'paths' || MODE === 'all') Out.Paths = await ModePaths();
if (MODE === 'cors' || MODE === 'all') Out.Cors = await ModeCors();
fs.writeFileSync(OUT, JSON.stringify(Out, (K, V) => (K === 'Body' ? undefined : V), 2), 'utf8');
console.log('结果落盘: ' + OUT);
})();
+63
View File
@@ -0,0 +1,63 @@
// probe_log_to_bins.js — 把页面探针(sockets/probe_railgun.js)的 WS 上行记录还原成 .bin,
// 以便**直接复用**仓库里现成的解密/解码流水线:
// decrypt_frames.js → dump_plain.js → decode_space_frames.js
//
// 输入(3DTank/names/probe_railgun.log)中的行:
// [+1234ms] SEND len=48 hex=e22e2b2a5f80... ← 整帧 hex(探针已改成整帧记录)
// 输出:<out>/<label>/session_001/c2s_0000.bin … ← 严格按出现顺序
//
// ⚠ 必须整帧且不丢帧:流密码状态跨消息连续,截断/漏掉任何一帧都会让后续解密全错。
//
// 用法:
// node probe_log_to_bins.js [--log ../names/probe_railgun.log] [--out ../packets/probe] [--label probe-01]
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const LOG = path.resolve(Arg('log', path.join(PROJECT, 'names', 'probe_railgun.log')));
const OUT = path.resolve(Arg('out', path.join(PROJECT, 'packets', 'probe')));
const LABEL = Arg('label', 'probe-01');
const SESS = path.join(OUT, LABEL, 'session_001');
if (!fs.existsSync(LOG)) {
console.error('[ERROR] 日志不存在: ' + LOG);
process.exit(1);
}
fs.mkdirSync(SESS, { recursive: true });
const Re = /^\[([^\]]+)\]\s+SEND\s+(?:blob\s+)?len=(\d+)\s+hex=([0-9a-fA-F]*)\s*$/;
const Lines = fs.readFileSync(LOG, 'utf8').split(/\r?\n/);
let N = 0;
let Bad = 0;
let Bytes = 0;
const LogLines = [];
for (const Line of Lines) {
const M = Re.exec(Line);
if (!M) continue;
const T = M[1];
const Len = Number(M[2]);
const Hex = M[3];
if (Hex.length !== Len * 2) {
Bad++;
LogLines.push('SKIP(hex 长度与 len 不符) ' + Line);
continue;
}
fs.writeFileSync(path.join(SESS, 'c2s_' + String(N).padStart(4, '0') + '.bin'), Buffer.from(Hex, 'hex'));
LogLines.push(T + ' c2s_' + String(N).padStart(4, '0') + '.bin len=' + Len);
N++;
Bytes += Len;
}
fs.writeFileSync(path.join(SESS, 'log.txt'), LogLines.join('\n') + '\n', 'utf8');
console.log('[OK] 还原 c2s 帧 ' + N + ' 个 ' + Bytes + ' 字节(跳过 ' + Bad + ' 条)');
console.log(' 输出目录: ' + SESS);
console.log(' 下一步:');
console.log(' node decrypt_frames.js --dir ' + OUT + ' --label ' + LABEL);
console.log(' node dump_plain.js --session ' + LABEL + ' --out ..\\packets\\plain\\' + LABEL);
console.log(' node decode_space_frames.js --dir ..\\packets\\plain\\' + LABEL + ' --side c2s --fields');
+144
View File
@@ -0,0 +1,144 @@
// _probe_record.js —— 列出某个 pt 记录里每一项的 modelId / codec / 字节区间 / opt 位数
//
// 用途:定位「位游标与客户端不一致」时,究竟是前面哪一项少算了 opt 位。
// 用法: node _probe_record.js <相对 packets/plain 的文件> [记录序号=1] [最多项=20]
'use strict';
const fs = require('fs');
const path = require('path');
const { LoadTables, MakeReader } = require('./lib_space_codec');
const Link = require('./server/link');
const Semantic = require('./server/semantic').Load(path.join(__dirname, '..', 'names'));
const PROJECT = path.resolve(__dirname, '..');
const File = path.resolve(PROJECT, 'packets', 'plain', process.argv[2]);
const WantRecord = Number(process.argv[3] || '1');
const MaxItems = Number(process.argv[4] || '20');
// 可选:只打印 codec 键包含该子串的项(其余项照常推进,只是不打印)
const KeyFilter = process.argv[5] || '';
// 可选:<项序号>:<位数> —— 在第 N 项(1 基)开始前额外推进位游标(模拟「前面的项多读了几位」)
const InsertArg = (process.argv[6] || '').split(':');
const InsertAt = InsertArg.length === 2 ? Number(InsertArg[0]) - 1 : -1;
const InsertBits = InsertArg.length === 2 ? Number(InsertArg[1]) : 0;
// 可选:--tree 打印每一项的字段树(含嵌套模型 / 列表元素),用来核对「嵌套里到底有几个元素」
const Tree = process.argv.indexOf('--tree') >= 0;
const Tables = LoadTables(path.join(PROJECT, 'names'));
const Binding = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'model_binding.json'), 'utf8'));
const CodecKeyByModelId = new Map();
for (const E of Binding.Entries) CodecKeyByModelId.set(String(E.ModelId), E.Key);
const RESOURCE_CLASS_WIRE = {
Ea: 'i64', nh: 'i64', W_: 'i64', uo: 'i64', pv: 'i64', B_: 'i64', Da: 'i64',
qs: 'i64', uh: 'i64', Gf: 'i64', kv: 'i64', Pc: 'i64',
};
const Alias = {};
for (const [N, R] of Object.entries(Tables.ResourceClasses || {})) Alias[N] = R.Wire;
for (const [N, W] of Object.entries(RESOURCE_CLASS_WIRE)) Alias[N] = W;
let TreeOn = false;
const Reader = MakeReader(Tables, {
EnumBytes: 4, EnumInList: 4, OptBits: true, TypeAlias: Alias, StringLiteralOnly: true,
OnField: (I) => {
if (!TreeOn) return;
console.log(' ' + ' '.repeat(I.Depth) + I.Name + ' <' + I.Type + '> ' + I.Start + '→' + I.Next +
' bit ' + I.BitBefore + '→' + I.BitAfter + (I.Bad ? ' BAD ' + I.Why : ''));
},
});
// 位流(与 audit 同口径)
function NullmapBits(Bytes) {
const Bits = [];
const Push = (B) => { for (let K = 7; K >= 0; K--) Bits.push((B >> K) & 1); };
if (Bytes.length && Bytes[0] & 0x80) {
for (let I = Bytes[0] & 0x40 ? 3 : 1; I < Bytes.length; I++) Push(Bytes[I]);
} else {
for (const B of Bytes) Push(B);
Bits.splice(0, 3);
}
return Bits;
}
// 一个 codec(含嵌套)会消耗的 opt 位数(只看字段表)
function CountOptBits(Key, Depth) {
if (Depth > 6) return 0;
const C = Tables.CodecByKey.get(Key);
if (!C) return 0;
let N = 0;
for (const F of C.Fields || []) N += CountTypeOptBits(F.Type, Depth);
return N;
}
function CountTypeOptBits(Type, Depth) {
if (!Type) return 0;
let N = 0;
let T = Type.trim();
if (T.startsWith('opt<')) { N++; T = T.slice(4, -1).trim(); }
const M = /^(list|enum|map)<([\s\S]*)>$/.exec(T);
if (M) {
const Inner = M[2].trim();
if (M[1] === 'map') {
const P = Inner.split(',');
N += CountTypeOptBits(P[0], Depth + 1) + CountTypeOptBits(P[1], Depth + 1);
} else N += CountTypeOptBits(Inner, Depth + 1);
return N;
}
const K = FindCodecKey(T);
if (K) N += CountOptBits(K, Depth + 1);
return N;
}
function FindCodecKey(Name) {
if (Tables.CodecByKey.has(Name)) return Name;
for (const [K] of Tables.CodecByKey) if (K.endsWith('::' + Name)) return K;
return null;
}
const Body = process.argv.indexOf('--body') >= 0
? fs.readFileSync(File)
: Link.DecodeFrame(Link.ParseFrames(fs.readFileSync(File))[0]);
const NmSize = Link.NullmapSize(Body, 0);
const Bits = NullmapBits(Body.slice(0, NmSize));
console.log('帧体 ' + Body.length + ' 字节,nullmap ' + NmSize + ' 字节 = ' + Bits.length + ' 位');
const Decoded = Semantic.DecodeFrameBody(Body);
let Seen = 0;
for (const R of Decoded.Records || []) {
if (String(R.MethodId) !== '7640916300855664666') continue;
Seen++;
if (Seen !== WantRecord) continue;
const At0 = R.At + 16;
const C = Body.readInt32BE(At0);
let At = At0 + 4 + 16 * C;
const D = Body.readInt32BE(At);
At += 4;
Reader.AttachBits(Bits, R.BitStart + 1);
console.log('记录#' + WantRecord + ' 项数 ' + D + ' 起始 byte=' + At + ' bit=' + (R.BitStart + 1));
for (let I = 0; I < D && I < MaxItems; I++) {
if (InsertBits && I === InsertAt) {
Reader.AttachBits(Bits, Reader.BitCursor().Index + InsertBits);
console.log(' --- 第 ' + (I + 1) + ' 项前额外推进 ' + InsertBits + ' 位 ---');
}
const AtId = At;
const Id = Body.readBigInt64BE(At).toString();
const Key = CodecKeyByModelId.get(Id) || '?';
At += 8;
const Bit0 = Reader.BitCursor().Index;
const B0 = At;
if (Id === '0') {
At += 8;
console.log(' [' + String(I + 1).padStart(3) + '] <内联> id@' + AtId + ' byte ' + B0 + '→' + At + ' bit ' + Bit0 + '→' + Reader.BitCursor().Index);
continue;
}
if (Tree && Key && Key.includes(KeyFilter || '::')) {
console.log(' [树] 第 ' + (I + 1) + ' 项 ' + Key);
TreeOn = true;
}
const Res = Reader.ReadFields(Body, At, Tables.CodecByKey.get(Key), 0);
TreeOn = false;
At = Res.Next;
const Bit1 = Reader.BitCursor().Index;
const Opt = Key && Key !== '?' ? CountOptBits(Key, 0) : -1;
if (KeyFilter && !String(Key).includes(KeyFilter)) continue;
console.log(' [' + String(I + 1).padStart(3) + '] ' + Key + ' model=' + Id + ' id@' + AtId +
' byte ' + B0 + '→' + At + '(' + (At - B0) + ') bit ' + Bit0 + '→' + Bit1 +
'(+' + (Bit1 - Bit0) + ') 字段表 opt 共 ' + Opt + (Res.Bad ? ' BAD ' + Res.Why : ''));
}
break;
}
+312
View File
@@ -0,0 +1,312 @@
// rebuild_codec_fields.js - 用源码里的 u7h/t7h 重建 codec 字段表(含「没被注册成顶层模型」的嵌套类型)
//
// 为什么需要它:
// names/regdump_model.json 只能拿到「运行时注册过 modelId 的 codec」的 t7h;
// 像 ParticleEffectItem / ParticleEffectsEntity / LightingSFXEntity 这类只作为别的模型字段出现的
// 嵌套类型,运行时 dump 里没有,字段表只能靠离线抽取。抽取一旦把工厂角色(List/Type/Enum/Map)
// 或 opt 标志认错,解码就会静默错位。
//
// 本工具的做法(全部以源码为准,不猜):
// 1. 定位 `(<类名>).u7h = function (t) {...}` 与 `(<类名>).t7h = function (t) {...}`;
// 2. u7h 里逐条 `this.<槽位> = t.r7g(new <工厂>(...))` 拿到「槽位 → 类型描述符表达式」;
// 3. t7h 里按出现顺序收集两类读操作:
// t.e7h_1.<读取器>() → 基础类型(见 ReaderType)
// <访问器>(this).t7h(t) → 槽位(访问器定义形如 function X(t){var n=t.<槽位>;...})
// 4. 用模块内的别名角色(24635::b=Enum、c=List、d=Map、e=Type、37036::nf=KClass、w4=Prim)
// 把槽位的描述符表达式翻成 list<>/enum<>/map<>/opt<>;
// 5. 与 names/codec_fields.json 里的 Type 逐字段比对,打印差异;加 --apply 才写回。
//
// 用法:
// node tools/rebuild_codec_fields.js # 只报告差异
// node tools/rebuild_codec_fields.js --apply # 把可确定的类型写回 codec_fields.json
// node tools/rebuild_codec_fields.js --only 85183 # 只看某个模块
'use strict';
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const NAMES = path.join(PROJECT, 'names');
const SRC = path.join(PROJECT, 'src');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const Apply = Argv.indexOf('--apply') >= 0;
const Only = Arg('only', '');
// ---------------------------------------------------------------- 角色与基础类型表
const CODEC_INFO_MODULE = '24635';
const KOLIB_MODULE = '37036';
const ROLE_KEYS = {
[CODEC_INFO_MODULE + '::e']: 'Type',
[CODEC_INFO_MODULE + '::c']: 'List',
[CODEC_INFO_MODULE + '::b']: 'Enum',
[CODEC_INFO_MODULE + '::d']: 'Map',
[KOLIB_MODULE + '::w4']: 'Prim',
[KOLIB_MODULE + '::mf']: 'KClass',
[KOLIB_MODULE + '::nf']: 'KClass',
};
const PrimTable = {
mb: 'any', nb: 'number', ob: 'nothing', pb: 'bool', qb: 'i8', rb: 'i16',
sb: 'i32', tb: 'i64', ub: 'f32', vb: 'f64', wb: 'array', xb: 'string',
yb: 'throwable', zb: 'boolArray', ac: 'charArray', bc: 'i8Array',
cc: 'i16Array', dc: 'i32Array', ec: 'f32Array', fc: 'f64Array',
};
const PrimName = (K) => PrimTable[K] || 'prim:' + K;
const ReaderType = {
xy: 'i8', p2u: 'i8', q2u: 'i16', r2u: 'i32', s2u: 'i64', t2u: 'f32', u2u: 'f64',
};
// ---------------------------------------------------------------- 源码小工具
function MatchBrace(Src, Start) {
let Depth = 0;
for (let I = Start; I < Src.length; I++) {
if (Src[I] === '{') Depth++;
else if (Src[I] === '}') {
Depth--;
if (Depth === 0) return I;
}
}
return -1;
}
function MatchParen(Src, Start) {
let Depth = 0;
for (let I = Start; I < Src.length; I++) {
if (Src[I] === '(') Depth++;
else if (Src[I] === ')') {
Depth--;
if (Depth === 0) return I;
}
}
return -1;
}
// 顶层逗号切分(忽略括号/引号内的逗号)
function SplitArgs(Text) {
const Out = [];
let Depth = 0;
let Cur = '';
for (let I = 0; I < Text.length; I++) {
const C = Text[I];
if (C === '(' || C === '[') Depth++;
else if (C === ')' || C === ']') Depth--;
if (C === ',' && Depth === 0) {
Out.push(Cur.trim());
Cur = '';
continue;
}
Cur += C;
}
if (Cur.trim()) Out.push(Cur.trim());
return Out;
}
function FindDeps(Src) {
const Head = /\(\s*function\s*\(([^)]*)\)\s*\{/.exec(Src);
const Tails = Src.match(/\}\)\(([^;]*)\);\s*$/);
if (!Head || !Tails) return {};
const Params = SplitArgs(Head[1]);
const Args = SplitArgs(Tails[1]);
const Out = {};
for (let I = 0; I < Params.length && I < Args.length; I++) {
const M = /\(\s*(\d+)\s*\)/.exec(Args[I]);
if (M) Out[Params[I]] = String(Number(M[1]));
}
return Out;
}
function FindAliases(Src, Deps) {
const Out = {};
const Re = /(?<![A-Za-z0-9_$.])([A-Za-z_$][\w$]*) = ([A-Za-z_$][\w$]*)\.\$_\$\.([A-Za-z0-9_$]+)[,;]/g;
let M;
while ((M = Re.exec(Src))) {
const Dep = Deps[M[2]];
if (Dep && !Out[M[1]]) Out[M[1]] = { Mod: Dep, Key: M[3] };
}
return Out;
}
function FindRoles(Src, Deps) {
const Roles = {};
for (const [Name, A] of Object.entries(FindAliases(Src, Deps))) {
const Role = ROLE_KEYS[A.Mod + '::' + A.Key];
if (Role) Roles[Name] = Role;
}
return Roles;
}
// 访问器(`function X(t) { var n = t.<槽位>; if (null != n) return n; ... }`)→ 槽位
function FindAccessors(Src) {
const Out = {};
const Re = /function ([A-Za-z_$][\w$]*)\(t\) \{\s*var [A-Za-z_$][\w$]* = t\.([A-Za-z0-9_$]+);\s*if \(null != [A-Za-z_$][\w$]*\) return/g;
let M;
while ((M = Re.exec(Src))) if (!Out[M[1]]) Out[M[1]] = M[2];
return Out;
}
// `(<类>).u7h = function (...) {...}` 的函数体
function ClassMethod(Src, Cls, Kind) {
const Head = new RegExp('\\(\\s*' + Cls.replace(/\$/g, '\\$') + '\\s*\\)\\.' + Kind + '\\s*=\\s*function\\s*\\([^)]*\\)\\s*\\{');
const M = Head.exec(Src);
if (!M) return null;
const Start = M.index + M[0].length - 1;
const End = MatchBrace(Src, Start);
if (End < 0) return null;
return Src.slice(Start, End + 1);
}
// u7h:槽位 → 描述符表达式(t.r7g(...) 的实参)
function ParseSlots(Body) {
const Out = new Map();
const Re = /this\.([A-Za-z0-9_$]+)\s*=\s*t\.r7g\(/g;
let M;
while ((M = Re.exec(Body))) {
const Open = Body.indexOf('(', M.index + M[0].length - 1);
const Close = MatchParen(Body, Open);
if (Close < 0) continue;
Out.set(M[1], Body.slice(Open + 1, Close).trim());
}
return Out;
}
// t7h:按出现顺序的读步骤
function ParseReads(Body, Accessors) {
const Out = [];
const Re = /t\.e7h_1\.([A-Za-z0-9_$]+)\(\)|([A-Za-z_$][\w$]*)\(this\)\.t7h\(t\)/g;
let M;
while ((M = Re.exec(Body))) {
if (M[1]) {
const T = ReaderType[M[1]];
Out.push({ Kind: 'prim', Reader: M[1], Type: T || null });
} else {
Out.push({ Kind: 'slot', Accessor: M[2], Slot: Accessors[M[2]] || null });
}
}
return Out;
}
// 描述符表达式 → 类型串(认不出就返回 null,保持原样不动)
function Describe(Expr, Roles, Depth) {
if (!Expr || Depth > 6) return null;
const Raw = Expr.trim().replace(/^new\s+/, '');
const Prim = /^([A-Za-z_$][\w$]*)\(\)\.([A-Za-z0-9_$]+)\(\)$/.exec(Raw);
if (Prim) return PrimName(Prim[2]);
const M = /^([A-Za-z_$][\w$]*)\(([\s\S]*)\)$/.exec(Raw);
if (!M) return null; // 裸类名:交给调用方保留原值
const Role = Roles[M[1]];
const Args = SplitArgs(M[2]);
const Optional = Args.some((A) => A === '!0');
const Inner = Args[0] ? Describe(Args[0], Roles, Depth + 1) : null;
let Base = null;
if (Role === 'List') Base = 'list<' + (Inner || '?') + '>';
else if (Role === 'Map') {
const V = Args[1] ? Describe(Args[1], Roles, Depth + 1) : null;
Base = 'map<' + (Inner || '?') + ', ' + (V || '?') + '>';
} else if (Role === 'Enum') Base = 'enum<' + (Inner || '?') + '>';
else if (Role === 'Type' || Role === 'KClass') Base = Inner;
else if (Role === 'Prim') return null;
else return null;
if (Base === null) return null;
return Optional ? 'opt<' + Base + '>' : Base;
}
// ---------------------------------------------------------------- 主流程
const Files = fs.readdirSync(SRC).filter((F) => /^module_\d+\.js$/.test(F));
const ModCache = new Map();
function LoadMod(Mod) {
if (ModCache.has(Mod)) return ModCache.get(Mod);
const File = path.join(SRC, Mod + '.js');
if (!fs.existsSync(File)) return null;
const Src = fs.readFileSync(File, 'utf8');
const Deps = FindDeps(Src);
const Ctx = { Src, Roles: FindRoles(Src, Deps), Accessors: FindAccessors(Src) };
ModCache.set(Mod, Ctx);
return Ctx;
}
const FieldsPath = path.join(NAMES, 'codec_fields.json');
const CodecFields = JSON.parse(fs.readFileSync(FieldsPath, 'utf8'));
const Codecs = CodecFields.Codecs || {};
const Diffs = [];
const Changed = [];
let Checked = 0;
let NoSource = 0;
for (const [Key, C] of Object.entries(Codecs)) {
const Mod = C.Module;
if (Only && !String(Mod).includes(Only)) continue;
if (!Mod || !C.Codec) continue;
const Ctx = LoadMod(Mod);
if (!Ctx) {
NoSource++;
continue;
}
const U7h = ClassMethod(Ctx.Src, C.Codec, 'u7h');
const T7h = ClassMethod(Ctx.Src, C.Codec, 't7h');
if (!T7h) {
NoSource++;
continue;
}
const Slots = U7h ? ParseSlots(U7h) : new Map();
const Reads = ParseReads(T7h, Ctx.Accessors);
const Fields = C.Fields || [];
Checked++;
// 读步骤必须和字段表一一对应;对不上说明字段表结构本身就不对,单独报出来
if (Reads.length !== Fields.length) {
Diffs.push({
Key,
Kind: '结构',
Detail: 't7h 读 ' + Reads.length + ' 次,字段表 ' + Fields.length + ' 个:' + Reads.map((R) =>
R.Kind === 'prim' ? (R.Reader + (R.Type ? '=' + R.Type : '')) : ('slot:' + (R.Slot || R.Accessor))).join(', '),
});
continue;
}
let Dirty = false;
const NewFields = Fields.map((F, I) => {
const R = Reads[I];
let Type = null;
if (R.Kind === 'prim') {
Type = R.Type;
} else {
const Slot = R.Slot || F.Slot;
const Expr = Slot ? Slots.get(Slot) : null;
if (Expr) Type = Describe(Expr, Ctx.Roles, 0);
}
if (!Type) return F;
if (Type !== F.Type) {
Dirty = true;
Diffs.push({ Key, Kind: '类型', Detail: '第 ' + (I + 1) + ' 个(' + (F.Name || '匿名') + '):' + F.Type + ' → ' + Type });
return Object.assign({}, F, { Type });
}
return F;
});
if (Dirty) {
Changed.push(Key);
if (Apply) {
C.Fields = NewFields;
C.RebuiltFrom = 'u7h/t7h 源码(rebuild_codec_fields.js)';
}
}
}
console.log('字段表重建核对(源码 u7h/t7h)');
console.log(' codec ' + Object.keys(Codecs).length + ',核对 ' + Checked + ',无源码/无 t7h ' + NoSource +
',有差异 ' + Diffs.length + '(涉及 ' + Changed.length + ' 个 codec)');
const ByKind = {};
for (const D of Diffs) ByKind[D.Kind] = (ByKind[D.Kind] || 0) + 1;
console.log(' 差异类型:' + JSON.stringify(ByKind));
for (const D of Diffs.slice(0, 40)) console.log(' ' + D.Key + ' [' + D.Kind + '] ' + D.Detail);
if (Diffs.length > 40) console.log(' …还有 ' + (Diffs.length - 40) + ' 条');
if (Apply && Changed.length) {
fs.writeFileSync(FieldsPath, JSON.stringify(CodecFields, null, 1), 'utf8');
console.log(' 已写回 ' + FieldsPath + '(' + Changed.length + ' 个 codec)');
} else if (Changed.length) {
console.log(' 未写回;加 --apply 生效');
}
+201
View File
@@ -0,0 +1,201 @@
# resolve_model_binding.py - 用「运行时 modelId → codec 实例」dump 反查 codec 的模块归属
#
# 为什么需要这个工具:
# regdump.json 的 ModelReg.w7n_1 只给出「类短名 + codec 槽位名」。类短名在 126 个模块里
# 大量复用(Fs / hs / It 这类两字母名到处都是),而 regdump 不含模块信息,
# 所以靠「短名 + 槽位」做指纹经常多解或误绑(例:658797348514407988 被绑到 ItemModelCC,
# 真实 codec 是另一个模块里的 Fs)→ pt 项静默串位,最后读出 modelId=8 这种不存在的值。
#
# 数据来源(由镜像站自动采集,不需要手工粘控制台):
# 3dtank.com/play/static/js/unpacked/module_61860.js 的 [Hornet 调试] 补丁
# 在 ModelReg.n7o(modelId, codec) 注册时记录 codec 的 u7h/t7h/s7h 源码文本,
# 节流后 POST 到 serve.js 的 /__dump__/regdump_model.json
# → 落盘到 3DTank/names/regdump_model.json
#
# 反查方法:
# u7h(codec 的「建槽」函数)文本在每个模块里都是唯一的:把它的空白全部去掉,
# 在同样去掉空白的模块源码里搜;命中后回看最近的一个 `X(类名).u7h = function`,
# 该赋值左侧的类名就是 codec 类,文件名给出模块 id。
# u7h 缺失(继承父类)时退回 t7h。
#
# 用法: python tools/resolve_model_binding.py [--names names] [--src src]
# 输出: names/model_binding.json
import json
import os
import re
import sys
Root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def Arg(Name, Def):
if '--' + Name in sys.argv:
I = sys.argv.index('--' + Name)
if I + 1 < len(sys.argv):
return sys.argv[I + 1]
return Def
NamesDir = os.path.abspath(Arg('names', os.path.join(Root, 'names')))
SrcDir = os.path.abspath(Arg('src', os.path.join(Root, 'src')))
DumpPath = os.path.join(NamesDir, 'regdump_model.json')
OutPath = os.path.join(NamesDir, 'model_binding.json')
# u7h/t7h 的赋值点:Se(ds).u7h = function (...) / x(lt).t7h = function (...)
# 注意类名里允许 `$`:Kotlin/JS 的局部类常被命名为 `$s`、`j_` 这类;
# 写成 \w+ 会漏掉 `$s`,从而把「最近的前一个 u7h」误当成本类(曾把 $s 误判成 bs)。
AssignRe = re.compile(r'\(([\w$]+)\)\.(u7h|t7h)\s*=\s*function')
ModuleRe = re.compile(r'module_(\d+)\.js$')
def Normalize(Text):
"""去掉全部空白,同时保留「归一化下标 → 原文下标」的映射。"""
Chars = []
Idx = []
for I, C in enumerate(Text):
if C.isspace():
continue
Chars.append(C)
Idx.append(I)
return ''.join(Chars), Idx
def LoadModules():
Out = []
for Name in sorted(os.listdir(SrcDir)):
if not ModuleRe.search(Name):
continue
Path = os.path.join(SrcDir, Name)
with open(Path, 'r', encoding='utf-8', errors='replace') as F:
Text = F.read()
Norm, Idx = Normalize(Text)
Assigned = [(M.start(), M.group(1), M.group(2)) for M in AssignRe.finditer(Text)]
Out.append({'Module': ModuleRe.search(Name).group(0)[:-3], 'Text': Text,
'Norm': Norm, 'Idx': Idx, 'Assigned': Assigned})
return Out
def OwnerAt(Mod, Offset, Want):
"""在原文 Offset 处,找出包含它的那一次 `<类>.u7h/t7h = function` 赋值。"""
Best = None
for Start, Cls, Kind in Mod['Assigned']:
if Start <= Offset and (Best is None or Start > Best[0]):
Best = (Start, Cls, Kind)
if Best is None:
return None
# 命中的函数体必须就是这次赋值(同一 class 可能先后有 u7h 和 t7h 两个赋值)
return Best[1] if Best[2] == Want else None
def Main():
if not os.path.exists(DumpPath):
print('缺少运行期 dump:' + DumpPath)
print('请先用镜像站进一次游戏(镜像 module_61860.js 已打采集补丁,会自动 POST)。')
return 1
with open(DumpPath, 'r', encoding='utf-8') as F:
Dump = json.load(F)
Entries = Dump.get('entries') or []
print('运行期 dump:%d 条 modelId 注册(采集于 %s)' % (len(Entries), Dump.get('at', '?')))
Modules = LoadModules()
print('已载入 %d 个模块源码' % len(Modules))
# codec_fields 里已有的 codec 键(module_XXXX::类名),用于判断「绑定成功但字段表缺失」
with open(os.path.join(NamesDir, 'codec_fields.json'), 'r', encoding='utf-8') as F:
Codecs = json.load(F).get('Codecs') or {}
Resolved = []
Unresolved = []
Ambiguous = []
for E in Entries:
ModelId = str(E.get('modelId'))
Hit = None
HitCount = 0
for Kind in ('u7h', 't7h'):
Sig = E.get(Kind)
if not Sig:
continue
NormSig = ''.join(Sig.split())
if len(NormSig) < 40:
continue
Hits = []
for Mod in Modules:
Pos = Mod['Norm'].find(NormSig)
if Pos < 0:
continue
Owner = OwnerAt(Mod, Mod['Idx'][Pos], Kind)
if Owner:
Hits.append((Mod['Module'], Owner))
if len(Hits) == 1:
Hit = Hits[0]
HitCount = 1
break
if len(Hits) > 1:
Ambiguous.append({'ModelId': ModelId, 'How': Kind, 'Candidates': Hits})
HitCount = len(Hits)
break
if Hit:
Module, Codec = Hit
Key = Module + '::' + Codec
# 自洽性校验:运行期 codec 实例的槽位名(keys)应与 codec_fields 里该 codec 的 Slots 一致。
# 不一致说明「源码反查落到了同名的另一个类」或字段表抽错,必须显式标出来,不能静默使用。
Slots = [S.get('Slot') for S in ((Codecs.get(Key) or {}).get('Slots') or [])]
RuntimeKeys = [str(K) for K in (E.get('keys') or [])]
SlotsOk = sorted(Slots) == sorted(RuntimeKeys)
Resolved.append({
'ModelId': ModelId,
'Module': Module,
'Codec': Codec,
'Key': Key,
'InCodecFields': Key in Codecs,
'SlotsOk': SlotsOk,
'Slots': Slots,
'RuntimeKeys': RuntimeKeys,
'Ctor': E.get('ctor'),
'Keys': E.get('keys'),
})
elif HitCount == 0:
Unresolved.append({'ModelId': ModelId, 'Ctor': E.get('ctor'), 'Keys': E.get('keys')})
MissingCodec = [R for R in Resolved if not R['InCodecFields']]
SlotMismatch = [R for R in Resolved if not R['SlotsOk']]
Out = {
'Note': '运行时 modelId → codec 的权威绑定:u7h/t7h 源码文本反查模块 + 类名;'
'InCodecFields=false 表示绑定已确定但 codec_fields 还没抽出该 codec 的字段表;'
'SlotsOk=false 表示反查到的类与运行期 codec 槽位不一致(疑似落在同名类上)。',
'Source': 'names/regdump_model.json(镜像站 module_61860.n7o 采集)',
'Count': len(Entries),
'Resolved': len(Resolved),
'MissingCodec': len(MissingCodec),
'SlotMismatch': len(SlotMismatch),
'AmbiguousCount': len(Ambiguous),
'UnresolvedCount': len(Unresolved),
'Entries': Resolved,
'MissingCodecList': MissingCodec,
'SlotMismatchList': SlotMismatch,
'Ambiguous': Ambiguous,
'Unresolved': Unresolved,
}
with open(OutPath, 'w', encoding='utf-8') as F:
json.dump(Out, F, ensure_ascii=False, indent=1)
print('已写入 ' + OutPath)
print(' 反查成功 %d / %d;其中字段表缺失 %d;槽位不一致 %d;多解 %d;未解 %d' %
(len(Resolved), len(Entries), len(MissingCodec), len(SlotMismatch), len(Ambiguous), len(Unresolved)))
if SlotMismatch:
print(' 槽位不一致(前 15):')
for R in SlotMismatch[:15]:
print(' %s %s 运行期=%s 字段表=%s' %
(R['ModelId'], R['Key'], R['RuntimeKeys'], R['Slots']))
if Ambiguous:
print(' 多解(前 5):')
for A in Ambiguous[:5]:
print(' %s ' % A['ModelId'] + ' / '.join(M + '::' + C for M, C in A['Candidates']))
if Unresolved:
print(' 未解(前 10):')
for U in Unresolved[:10]:
print(' %s ctor=%s keys=%s' % (U['ModelId'], U.get('Ctor'), U.get('Keys')))
return 0
if __name__ == '__main__':
sys.exit(Main())
+268 -30
View File
@@ -23,7 +23,17 @@ const Arg = (Name, Def) => {
const PORT = Number(Arg('port', '8080'));
const WEB_DIR = path.resolve(Arg('web', path.join(PROJECT, 'web')));
const JS_DIR = path.join(ROOT, '3dtank.com', 'play', 'static', 'js');
// --src <目录>:模块源码改从「反编译源码目录」(如 3DTank/src)加载
// · /play/static/js/unpacked/module_*.js、resources.json、index.json → src 目录
// · /play/static/js/unpacked.pack.json → src 目录里的单包
// · 其它(main.<hash>.js 加载器、unpacked/hornet_bundle.js 工具链)仍走 JS_DIR
const SRC_DIR = Arg('src', '') ? path.resolve(Arg('src', '')) : '';
const MISSING_LOG = path.join(PROJECT, 'names', 'missing.txt');
// 页面内探针的网络转储落盘位置:POST /__probe__?f=<name> 追加写对应文件(不传 f 默认 railgun)
const PROBE_LOGS = {
railgun: path.join(PROJECT, 'names', 'probe_railgun.log'),
chassis: path.join(PROJECT, 'names', 'probe_chassis.log'),
};
const CACHE_DIR = path.join(PROJECT, 'cache');
const NO_PROXY = Argv.indexOf('--no-proxy') >= 0;
const VERBOSE = Argv.indexOf('--verbose') >= 0;
@@ -31,23 +41,30 @@ const VERBOSE = Argv.indexOf('--verbose') >= 0;
// 加 --real-config 可强制走真实服务器
const LOCAL_CONFIG = path.join(PROJECT, 'local', 'config.xml');
const REAL_CONFIG = Argv.indexOf('--real-config') >= 0;
// 中继模式:--relay-port <n> 时,/config.xml **动态改写**为指向本机 ws://localhost:<n>/
// (每次实时生成,不落盘、不依赖 local/config.xml;address→localhost、wsProtocol→WS、port→n)
const RELAY_PORT = Number(Arg('relay-port', '0')) || 0;
// --ws-hook:服务 index.html 时注入页面内 ws 抓包脚本(sockets/ws_hook.js)。
// 刻意用**启动参数**而不是 URL 开关:游戏用 location.hash 做路由/状态,
// 任何 hash(哪怕只是 #hook)都会让它进不去战场。
const WS_HOOK = Argv.indexOf('--ws-hook') >= 0;
// config.xml 的回源目标:官方客户端实际连的是 **c2**(c1/c2 是两个独立服,各自提供自己的 config)
// 可用 --config-upstream 覆盖(例如 https://c1.3dtank.com)
const CONFIG_UPSTREAM = Arg('config-upstream', 'https://c2.3dtank.com').replace(/\/+$/, '');
// 本机路径 → 真实站点(StripPrefix: 去掉本机前缀再拼到 Target 上;KeepQuery: 是否保留查询串)
const PROXY = [
{ Prefix: '/resources', Target: 'https://res.3dtank.com', StripPrefix: true, KeepQuery: true },
{ Prefix: '/config.xml', Target: 'https://c1.3dtank.com', StripPrefix: false, KeepQuery: false },
{ Prefix: '/config.xml', Target: CONFIG_UPSTREAM, StripPrefix: false, KeepQuery: false },
{ Prefix: '/s/', Target: 'https://3dtank.com', StripPrefix: false, KeepQuery: false },
];
// 代理回源头:Origin 按规范只到源(不带路径),Referer 才是完整地址
const UPSTREAM_HEADERS = {
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0',
'accept-language': 'zh-CN,zh;q=0.9,en-GB;q=0.8,en;q=0.7,en-US;q=0.6',
origin: 'https://3dtank.com',
referer: 'https://3dtank.com/',
referer: 'https://3dtank.com/play/',
};
const MIME = {
@@ -82,7 +99,32 @@ function Resolve(UrlPath) {
const Clean = decodeURIComponent(UrlPath.split('?')[0]);
// 切割版 JS 走 TankIC 目录
if (Clean.startsWith('/play/static/js/')) {
return path.join(JS_DIR, Clean.slice('/play/static/js/'.length));
const Rest = Clean.slice('/play/static/js/'.length);
// --src:模块与单包改从反编译源码目录取(加载器与 hornet 工具链仍用 JS_DIR)
if (SRC_DIR) {
if (Rest === 'unpacked.pack.json') return path.join(SRC_DIR, 'unpacked.pack.json');
if (Rest.startsWith('unpacked/')) {
const Name = Rest.slice('unpacked/'.length);
if (/^module_\d+\.js$/.test(Name) || Name === 'resources.json' || Name === 'index.json') {
return path.join(SRC_DIR, Name);
}
}
}
const P = path.join(JS_DIR, Rest);
// 主文件名的 hash 随 bundle 版本变化,而 index.html 里写的是固定名(重新拆分后会失配 → 404)。
// 这里兜底:请求 main.<hash>.js 但该文件不存在时,回退到 JS 目录里最新的 main.<hash>.js。
if (/[/\\]main\.[0-9a-f]{8}\.js$/.test(P) && !fs.existsSync(P)) {
const Newest = fs
.readdirSync(JS_DIR)
.filter((F) => /^main\.[0-9a-f]{8}\.js$/.test(F))
.map((F) => ({ F, T: fs.statSync(path.join(JS_DIR, F)).mtimeMs }))
.sort((A, B) => B.T - A.T)[0];
if (Newest) {
console.log('[serve] 主文件失配,回退到最新: ' + path.basename(P) + ' → ' + Newest.F);
return path.join(JS_DIR, Newest.F);
}
}
return P;
}
const Rel = Clean.replace(/^\/+/, '');
return Rel === '' ? path.join(WEB_DIR, 'index.html') : path.join(WEB_DIR, Rel);
@@ -112,6 +154,12 @@ function MatchProxy(UrlPath) {
const ProxyStats = { total: 0, cached: 0, failed: 0 };
// 缓存策略:镜像资源(图片/模型/音频)路径稳定、内容不变,允许浏览器长期缓存。
// 之前一律 no-cache,车库一次性加载上千个资源时每个都要回服务器验证 → 返回车库很慢。
const LONG_CACHE = 'public, max-age=604800, immutable';
const IsCacheableAsset = (P) =>
/\.(webp|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|mp3|ogg|wav|webm|mp4|3ds|a3d|bin|json)$/i.test(P);
function CachePath(Url) {
const U = new URL(Url);
const Rel = (U.hostname + U.pathname).replace(/[^\w.\-/]/g, '_');
@@ -125,14 +173,16 @@ async function HandleProxy(Rule, Req, Res) {
}
const CacheFile = Rule.SaveFile || CachePath(Rule.Url);
if (VERBOSE) console.log('[代理] ' + Rule.Url);
const CacheHeader = Rule.Url.includes('config') ? 'no-cache' : LONG_CACHE;
try {
if (fs.existsSync(CacheFile)) {
ProxyStats.cached++;
const Buf = fs.readFileSync(CacheFile);
// 异步读:车库会并发拉上千个资源,同步 readFile 会阻塞事件循环、把其他请求全排队
const Buf = await fs.promises.readFile(CacheFile);
Res.writeHead(200, {
'Content-Type': MIME[path.extname(new URL(Rule.Url).pathname).toLowerCase()] || 'application/octet-stream',
'Content-Length': Buf.length,
'Cache-Control': 'no-cache',
'Cache-Control': CacheHeader,
'X-Hornet-Proxy': 'cache',
});
Res.end(Buf);
@@ -146,8 +196,8 @@ async function HandleProxy(Rule, Req, Res) {
Res.writeHead(Upstream.status, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Upstream ' + Upstream.status);
return;
}
fs.mkdirSync(path.dirname(CacheFile), { recursive: true });
fs.writeFileSync(CacheFile, Buf);
await fs.promises.mkdir(path.dirname(CacheFile), { recursive: true });
await fs.promises.writeFile(CacheFile, Buf);
Res.writeHead(200, {
// 优先按扩展名给类型(config.xml 必须是 XML,否则 responseXML 为 null)
'Content-Type':
@@ -155,7 +205,7 @@ async function HandleProxy(Rule, Req, Res) {
Upstream.headers.get('content-type') ||
'application/octet-stream',
'Content-Length': Buf.length,
'Cache-Control': 'no-cache',
'Cache-Control': CacheHeader,
'X-Hornet-Proxy': 'upstream',
});
Res.end(Buf);
@@ -166,10 +216,47 @@ async function HandleProxy(Rule, Req, Res) {
}
}
// 中继模式:取真实 config.xml(本地缓存优先,否则回源 CONFIG_UPSTREAM),把 address/wsProtocol/port 改写成
// 指向本机中继 ws://localhost:<RELAY_PORT>/,每次实时生成返回给客户端(不落盘)。
async function ServeRewrittenConfig(Res) {
const UpHost = new URL(CONFIG_UPSTREAM).hostname;
const CacheFile = path.join(CACHE_DIR, UpHost, 'config.xml');
let Xml = '';
try {
if (fs.existsSync(CacheFile)) {
Xml = fs.readFileSync(CacheFile, 'utf8');
} else {
const Up = await fetch(CONFIG_UPSTREAM + '/config.xml', { headers: UPSTREAM_HEADERS, redirect: 'follow' });
if (Up.ok) Xml = await Up.text();
}
} catch (Err) {
console.log('[relay-config] 取真实 config 失败: ' + Err.message);
}
if (!Xml || Xml.indexOf('<server') < 0) {
// 兜底模板(与 c1 实际内容同构)
Xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cfg xmlns="http://alternativaplatform.com/core/config.xsd"><server address="c1.3dtank.com" version="" mode="simple" wsProtocol="WSS"><ports/><webSocketPorts><port>9090</port></webSocketPorts></server><status>normal</status></cfg>';
}
const Out = Xml
.replace(/(<server\b[^>]*\baddress=")[^"]*(")/, '$1localhost$2')
.replace(/(<server\b[^>]*\bwsProtocol=")[^"]*(")/, '$1WS$2')
.replace(/(<webSocketPorts>\s*<port>)\d+(<\/port>)/, '$1' + RELAY_PORT + '$2');
const Buf = Buffer.from(Out, 'utf8');
Res.writeHead(200, {
'Content-Type': 'application/xml; charset=utf-8',
'Content-Length': Buf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Config': 'relay',
});
Res.end(Buf);
}
const Server = http.createServer((Req, Res) => {
const UrlPath = Req.url.split('#')[0];
// 浏览器 → 本地落盘:控制台脚本把 dump 结果 POST 上来,写到 names/ 下
// 用法(控制台):fetch('/__dump__/regdump.json', {method:'POST', body: JSON.stringify(x)})
// *.json 且新旧都是 {entries:[{modelId,...}]} 形态时**按 modelId 合并**:
// modelId → codec 是懒注册的(进车库/商店/战局各注册一批),每个页面会话的累积集不同,
// 直接覆盖会把上一轮的注册表冲掉;合并后刷新/关页都不丢数据。
if (Req.method === 'POST' && UrlPath.startsWith('/__dump__/')) {
const Name = path.basename(decodeURIComponent(UrlPath.slice('/__dump__/'.length))).replace(/[^\w.\-]/g, '_') || 'dump.json';
const Out = path.join(PROJECT, 'names', Name);
@@ -177,10 +264,43 @@ const Server = http.createServer((Req, Res) => {
Req.on('data', (C) => Chunks.push(C));
Req.on('end', () => {
try {
const Buf = Buffer.concat(Chunks);
let Buf = Buffer.concat(Chunks);
let Merged = 0;
if (/\.json$/i.test(Name)) {
try {
const NewObj = JSON.parse(Buf.toString('utf8'));
if (NewObj && Array.isArray(NewObj.entries)) {
let OldObj = null;
try {
OldObj = JSON.parse(fs.readFileSync(Out, 'utf8'));
} catch (E) {
OldObj = null;
}
if (OldObj && Array.isArray(OldObj.entries)) {
const Seen = new Set();
const All = [];
const Push = (E) => {
if (!E || E.modelId === undefined || Seen.has(String(E.modelId))) return;
Seen.add(String(E.modelId));
All.push(E);
};
OldObj.entries.forEach(Push);
NewObj.entries.forEach(Push);
Merged = All.length - NewObj.entries.length;
NewObj.entries = All;
NewObj.count = All.length;
NewObj.mergedFrom = OldObj.count;
Buf = Buffer.from(JSON.stringify(NewObj), 'utf8');
}
}
} catch (E) {
// 不是 JSON 或不是该形态:原样落盘
}
}
fs.mkdirSync(path.dirname(Out), { recursive: true });
fs.writeFileSync(Out, Buf);
console.log('[dump] 已写入 ' + Out + '(' + Buf.length + ' 字节)');
console.log('[dump] 已写入 ' + Out + '(' + Buf.length + ' 字节' +
(Merged ? ',合并新增 ' + Merged + ' 条' : '') + ')');
Res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }).end('saved ' + Name + ' ' + Buf.length);
} catch (Err) {
console.log('[dump] 写入失败: ' + Err.message);
@@ -194,22 +314,32 @@ const Server = http.createServer((Req, Res) => {
Res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }).end('/* noop */\n');
return;
}
// /hornet_sw.js:Service Worker 本体。放在根路径 + 放行 Service-Worker-Allowed,
// 作用域才能覆盖 /,从而接管首页 / 发起的 /play/... 碎请求(unpacked 单包缓存)
if (UrlPath.split('?')[0] === '/hornet_sw.js') {
const SwFile = path.join(ROOT, 'tools', 'hornet_sw.js');
if (!fs.existsSync(SwFile)) {
Res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('hornet_sw.js not found');
return;
}
const Sw = fs.readFileSync(SwFile);
// 本地镜像已禁用 Service Worker。
// 对任何还在注册的旧 SW(hornet_sw / 游戏自带 service-worker.js)返回一份"自注销"脚本:
// 浏览器下次更新检查拉到新脚本后会 activate → unregister() → 刷新受控页面,
// 避免旧 SW 的 fetch 拦截把导航请求搞成 "network error response"。
const SwPath = UrlPath.split('?')[0];
if (SwPath === '/hornet_sw.js' || SwPath === '/service-worker.js' || SwPath === '/play/service-worker.js') {
const SelfKill = [
"self.addEventListener('install', function () { self.skipWaiting(); });",
"self.addEventListener('activate', function (e) {",
" e.waitUntil(self.registration.unregister().then(function () {",
" return self.clients.matchAll({ type: 'window' });",
" }).then(function (cs) {",
" cs.forEach(function (c) { try { c.navigate(c.url); } catch (err) {} });",
" }));",
"});",
"// 不调用 respondWith:请求走浏览器默认网络,不做任何拦截",
"self.addEventListener('fetch', function () {});",
"",
].join('\n');
Res.writeHead(200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Content-Length': Sw.length,
'Cache-Control': 'no-cache',
'Content-Length': Buffer.byteLength(SelfKill),
'Cache-Control': 'no-store',
'Service-Worker-Allowed': '/',
'X-Hornet-Sw': 'local',
}).end(Sw);
'X-Hornet-Sw': 'disabled',
}).end(SelfKill);
return;
}
// /ws_hook.js:页面内 window.WebSocket 抓包脚本(旁听,不改传输/不改模块加载)。
@@ -229,6 +359,100 @@ const Server = http.createServer((Req, Res) => {
}).end(Hook);
return;
}
// /__prefetch_list__:返回「最近访问过的 res.3dtank.com 资源」清单(本地缓存里按 mtime 倒序)
// 客户端在战局空闲时按这个清单预热,从战局返回车库时资源已在浏览器缓存里。
if (UrlPath.split('?')[0] === '/__prefetch_list__') {
const Query = new URLSearchParams(UrlPath.split('?')[1] || '');
const Limit = Math.max(1, Math.min(3000, Number(Query.get('n')) || 500));
const Base = path.join(CACHE_DIR, 'res.3dtank.com');
const Files = [];
const Walk = (D) => {
let Ents = [];
try { Ents = fs.readdirSync(D, { withFileTypes: true }); } catch (e) { return; }
for (const E of Ents) {
const P = path.join(D, E.name);
if (E.isDirectory()) { Walk(P); continue; }
// 带 query 的缓存文件名带 _hex 后缀,无法还原成原路径 → 跳过
if (/_[0-9a-f]{12}$/i.test(E.name)) continue;
try { Files.push({ P, M: fs.statSync(P).mtimeMs }); } catch (e) {}
}
};
Walk(Base);
Files.sort((A, B) => B.M - A.M);
const List = Files.slice(0, Limit).map(({ P }) => '/resources/' + path.relative(Base, P).split(path.sep).join('/'));
const Buf = Buffer.from(JSON.stringify({ count: List.length, list: List }));
Res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Prefetch': 'list',
}).end(Buf);
return;
}
// /probe_railgun.js:武器链路现场探针(只读采样 + WS 上行记录,数据 POST /__probe__ 落盘)
// 由加载器自动引入(localStorage.hornetProbe='0' 可关闭),省去每次手粘控制台。
if (UrlPath.split('?')[0] === '/probe_railgun.js') {
const ProbeFile = path.join(ROOT, 'sockets', 'probe_railgun.js');
if (!fs.existsSync(ProbeFile)) {
Res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('probe_railgun.js not found');
return;
}
const Probe = fs.readFileSync(ProbeFile);
Res.writeHead(200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Content-Length': Probe.length,
'Cache-Control': 'no-cache',
'X-Hornet-Probe': 'local',
}).end(Probe);
return;
}
// /probe_chassis.js:十字军/悬浮底盘「旋转 + 移动」控制链路探针(数据 POST /__probe__?f=chassis)
if (UrlPath.split('?')[0] === '/probe_chassis.js') {
const ProbeFile = path.join(ROOT, 'sockets', 'probe_chassis.js');
if (!fs.existsSync(ProbeFile)) {
Res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('probe_chassis.js not found');
return;
}
const Probe = fs.readFileSync(ProbeFile);
Res.writeHead(200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Content-Length': Probe.length,
'Cache-Control': 'no-cache',
'X-Hornet-Probe': 'local',
}).end(Probe);
return;
}
// /__probe__:页面内探针的**网络转储**入口(控制台刷屏太扰,改为 POST 落盘)
// 探针把组件清单与采样分批 POST 到这里;?f=<name> 选择落盘文件(railgun / chassis),默认 railgun。
if (UrlPath.split('?')[0] === '/__probe__') {
if (Req.method !== 'POST') {
Res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' }).end('POST only');
return;
}
const FMatch = /(?:^|&)f=([a-z0-9_]+)/i.exec(UrlPath.split('?')[1] || '');
const LogFile = PROBE_LOGS[FMatch ? FMatch[1].toLowerCase() : 'railgun'] || PROBE_LOGS.railgun;
const Chunks = [];
Req.on('data', (C) => Chunks.push(C));
Req.on('end', () => {
const Body = Buffer.concat(Chunks).toString('utf8');
try {
fs.appendFileSync(LogFile, Body.endsWith('\n') ? Body : Body + '\n');
} catch (Err) {
console.warn('[probe] 落盘失败: ' + Err.message);
}
Res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache' }).end('ok');
});
return;
}
// /config.xml 中继模式:动态改写为指向本机 ws://localhost:<RELAY_PORT>/(不落盘、不依赖 local/config.xml)
if (RELAY_PORT > 0 && UrlPath.split('?')[0] === '/config.xml') {
ServeRewrittenConfig(Res).catch((Err) => {
console.log('[relay-config] 生成失败: ' + Err.message);
if (!Res.headersSent) Res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
Res.end('relay-config error: ' + Err.message);
});
return;
}
// /config.xml 本地覆盖:存在 local/config.xml 时用它,把游戏指向本地 wss_server
if (!REAL_CONFIG && UrlPath.split('?')[0] === '/config.xml' && fs.existsSync(LOCAL_CONFIG)) {
const Conf = fs.readFileSync(LOCAL_CONFIG);
@@ -246,7 +470,9 @@ const Server = http.createServer((Req, Res) => {
return;
}
const File = Resolve(UrlPath);
if (!File.startsWith(WEB_DIR) && !File.startsWith(JS_DIR)) {
// 允许的根:站点根、切割版 JS、以及 --src 指定的反编译源码目录
const Allowed = File.startsWith(WEB_DIR) || File.startsWith(JS_DIR) || (SRC_DIR && File.startsWith(SRC_DIR));
if (!Allowed) {
Res.writeHead(403).end('Forbidden');
return;
}
@@ -265,19 +491,28 @@ const Server = http.createServer((Req, Res) => {
}
const Ext = path.extname(File).toLowerCase();
const Type = MIME[Ext] || 'application/octet-stream';
// --ws-hook:HTML 里注入抓包脚本(在主脚本之前 → 先于游戏创建 WebSocket)
if (WS_HOOK && (Ext === '.html' || Ext === '.htm')) {
const Html = fs.readFileSync(File, 'utf8').replace(/<\/head>/i, '<script src="/ws_hook.js"></script></head>');
// HTML 注入:
// ① window.__HORNET_SRV__ —— 当前配置的目标服务器编号(ClientInfo 里显示 SRV: CN1 / CN2)
// ② --ws-hook 时再注入抓包脚本(必须排在游戏主脚本之前)
if (Ext === '.html' || Ext === '.htm') {
const SrvMatch = /c([12])\.3dtank\.com/.exec(CONFIG_UPSTREAM);
const Srv = SrvMatch ? SrvMatch[1] : '1';
let Head = '<script>window.__HORNET_SRV__=' + Srv + ';</script>';
if (WS_HOOK) Head += '<script src="/ws_hook.js"></script>';
let Html = fs.readFileSync(File, 'utf8');
Html = /<\/head>/i.test(Html) ? Html.replace(/<\/head>/i, Head + '</head>') : (Head + Html);
const Buf = Buffer.from(Html, 'utf8');
Res.writeHead(200, { 'Content-Type': Type, 'Content-Length': Buf.length, 'Cache-Control': 'no-cache' });
if (VERBOSE) console.log('[200] ' + Buf.length + '\t' + UrlPath + ' (已注入 ws_hook)');
if (VERBOSE) console.log('[200] ' + Buf.length + '\t' + UrlPath + ' (已注入 SRV=' + Srv + (WS_HOOK ? ' + ws_hook' : '') + ')');
Res.end(Buf);
return;
}
Res.writeHead(200, {
'Content-Type': Type,
'Content-Length': Stat.size,
'Cache-Control': 'no-cache',
// 图片/模型/字体等静态资源允许长期缓存(文件名带 hash 或路径稳定),
// 车库一次加载上千个资源时不再逐个回服务器验证
'Cache-Control': IsCacheableAsset(File) ? LONG_CACHE : 'no-cache',
});
if (VERBOSE) console.log('[200] ' + Stat.size + '\t' + UrlPath);
fs.createReadStream(File).pipe(Res);
@@ -290,7 +525,10 @@ Server.listen(PORT, () => {
console.log('本地客户端: http://localhost:' + PORT + '/');
console.log(' 站点根 : ' + WEB_DIR);
console.log(' 切割版 JS : ' + JS_DIR);
console.log(' 模块来源 : ' + (SRC_DIR ? ('src = ' + SRC_DIR + '(单包/模块/资源)') : 'unpacked(切割产物)'));
console.log(' 404 记录 : ' + MISSING_LOG);
console.log(' config.xml: ' + (!REAL_CONFIG && fs.existsSync(LOCAL_CONFIG) ? '本地覆盖 ' + LOCAL_CONFIG : '回源 c1.3dtank.com(--real-config)'));
console.log(' config.xml: ' + (RELAY_PORT > 0
? ('中继改写 → ws://localhost:' + RELAY_PORT + '/(动态生成,不落盘)')
: (!REAL_CONFIG && fs.existsSync(LOCAL_CONFIG) ? '本地覆盖 ' + LOCAL_CONFIG : '回源 ' + CONFIG_UPSTREAM + '(--real-config)')));
console.log(' ws 抓包 : ' + (WS_HOOK ? '已开启(HTML 注入 /ws_hook.js;保存用 __ws_save__())' : '关闭(要抓包加 --ws-hook,勿用 URL hash)'));
});
+5 -1
View File
@@ -308,6 +308,8 @@ function Load(NamesDir) {
let Off = NmSize;
const Ctx = { Bits, Index: 0 };
while (Off < Body.length) {
// 记录起点时的位游标:诊断用(审计器拿它把 opt 位对齐回本记录)
const BitStart = Ctx.Index;
if (Ctx.Index >= Bits.length) {
Out.Bad = true;
Out.Note = 'nullmap 的 ' + Bits.length + ' 个 bit 用完了,帧体还剩 ' + (Body.length - Off) + ' 字节没读';
@@ -332,7 +334,7 @@ function Load(NamesDir) {
const ObjectId = Body.readBigInt64BE(Off).toString();
const MethodId = Body.readBigInt64BE(Off + 8).toString();
const R = DecodeRecord(MethodId, Body, Off + 16, undefined, Ctx);
Out.Records.push(Object.assign({ ObjectId, At: Off }, R));
Out.Records.push(Object.assign({ ObjectId, At: Off, BitStart }, R));
if (R.Bad) {
Out.Bad = true;
Out.Note = '第 ' + Out.Records.length + ' 条记录(methodId=' + MethodId + ')解不动:' + R.Note;
@@ -340,6 +342,8 @@ function Load(NamesDir) {
}
Off += 16 + R.Consumed;
}
// 诊断用:本帧消费到的位索引(nullmap 容量自检见 tools/_nullmap_capacity.js)
Out.Bits = Ctx.Index;
return Out;
}
+219 -23
View File
@@ -29,7 +29,7 @@ const fs = require('fs');
const http = require('http');
const path = require('path');
const zlib = require('zlib');
const { LoadTables, MakeReader, FormatValue } = require('./lib_space_codec');
const { LoadTables, MakeReader, ReadVarint, FormatValue } = require('./lib_space_codec');
const PROJECT = path.resolve(__dirname, '..');
const ROOT = path.resolve(PROJECT, '..');
@@ -40,7 +40,21 @@ const Arg = (Name, Def) => {
};
const PORT = Number(Arg('port', '9091'));
const UPSTREAM = Arg('upstream', 'wss://c1.3dtank.com:9090/');
const UPSTREAM = Arg('upstream', 'wss://c2.3dtank.com:9090/'); // 主上游(大厅:首帧 = Hash Request)
// 备用上游:首帧直接是 Auth/EnterSpace 的连接(复用会话哈希的"二次进空间",官方走的是另一台服务器)→ 默认 c1
const UPSTREAM_ALT = Arg('upstream-alt', 'wss://c1.3dtank.com:9090/');
// 隧道接管:服务端在进入战斗时会下发 _reconnectFast(RemoteEndpointData{host,ports,wsProto}),
// 让客户端连到它指定的战场服(可能是另一台 c1/c2)。开启后中继会:
// ① 把该包里的 host/port 改写成 localhost:<中继端口>,让客户端继续连回中继;
// ② 记住原始 host:port,作为**下一条**客户端连接的实际上游。
// 这样战场连接也能被抓包/解密。--tunnel-reconnect 0 可关闭(客户端将直连真实战场服)。
const TUNNEL_RECONNECT = Arg('tunnel-reconnect', '1') !== '0';
const RECONNECT_METHOD_ID = '4504535536312151081'; // _reconnectFast(见 names/downlink_table.json)
const RECONNECT_ID_BUF = (() => {
const B = Buffer.alloc(8);
B.writeBigInt64BE(BigInt(RECONNECT_METHOD_ID));
return B;
})();
const LABEL = Arg('label', new Date().toISOString().replace(/[:.]/g, '-'));
const PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets', LABEL)));
@@ -50,6 +64,10 @@ const PACKET_DIR = path.resolve(Arg('dir', path.join(PROJECT, 'packets', LABEL))
// --no-hex 1 log.txt 不写十六进制
// --quiet 1 只写 log.txt,不刷控制台
const DUMP_EVERY = Number(Arg('dump-every', '1'));
// 额外落一份「解密 + 解压后的帧体」到 <session>/plain/s2c_%04d.bin:
// 审计工具(audit_pt_boundaries / audit_bit_budget)要的是这种裸帧体,
// 原始 .bin 落的是收到的原始字节(加密段里是密文),离线没法直接解。
const DUMP_PLAIN = Arg('dump-plain', '1') === '1';
const WRITE_LOG = Arg('log', '1') === '1';
const WRITE_HEX = Arg('no-hex', '0') !== '1';
const QUIET = Arg('quiet', '0') === '1';
@@ -91,11 +109,14 @@ function RequireWs() {
const Ws = RequireWs();
// 上游握手头:真实服务器会校验来源
// 注意 Origin 与 Referer 的差别(对齐真实浏览器在 https://3dtank.com/play/ 下的行为):
// Origin 按规范**只到源**,不带路径 → https://3dtank.com
// Referer 是完整地址,带路径 → https://3dtank.com/play/
const UPSTREAM_HEADERS = {
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0',
origin: 'https://3dtank.com',
referer: 'https://3dtank.com/',
referer: 'https://3dtank.com/play/',
};
// 命令名(入口层已从源码确证;ID 可跨方向复用,故按方向区分)
@@ -235,6 +256,131 @@ class Cipher {
}
}
// ============================================================
// _reconnectFast 隧道接管(s2c 方向改包)
// ============================================================
// 下一条客户端连接要连的上游(由服务端下发的 RemoteEndpointData 决定)
let TunnelUpstream = null;
let TunnelUpstreamAt = 0;
const TUNNEL_TTL_MS = 15000;
function WriteVarint(N) {
const V = Number(N) | 0;
if (V < 0x80) return Buffer.from([V & 0x7f]);
if (V < 0x4000) return Buffer.from([0x80 | ((V >> 8) & 0x3f), V & 0xff]);
return Buffer.from([0xc0 | ((V >> 16) & 0x3f), (V >> 8) & 0xff, V & 0xff]);
}
// 按 PeekFrame 的逆过程重建帧头
function BuildFrame(Body, Compressed) {
const Size = Body.length;
const C = Compressed ? 0x40 : 0;
if (Size < 0x4000) {
return Buffer.concat([Buffer.from([C | ((Size >> 8) & 0x3f), Size & 0xff]), Body]);
}
if (Size < 0x400000) {
return Buffer.concat([Buffer.from([0x80 | C | ((Size >> 16) & 0x3f), (Size >> 8) & 0xff, Size & 0xff]), Body]);
}
const H = Buffer.alloc(4);
H[0] = 0x80 | C | ((Size >> 24) & 0x3f);
H[1] = (Size >> 16) & 0xff;
H[2] = (Size >> 8) & 0xff;
H[3] = Size & 0xff;
return Buffer.concat([H, Body]);
}
// 解析 RemoteEndpointData:host(string) / ports(list<i32>) / wsProto(string)
function ParseEndpoint(Buf, Off) {
const H = ReadVarint(Buf, Off);
if (H.Bad) return null;
const Host = Buf.slice(H.Next, H.Next + H.Value).toString('utf8');
let At = H.Next + H.Value;
const N = ReadVarint(Buf, At);
if (N.Bad) return null;
At = N.Next;
const Ports = [];
for (let I = 0; I < N.Value; I++) {
const R = ReadVarint(Buf, At);
if (R.Bad) return null;
Ports.push(R.Value);
At = R.Next;
}
const S = ReadVarint(Buf, At);
if (S.Bad) return null;
const Proto = Buf.slice(S.Next, S.Next + S.Value).toString('utf8');
return { Host, Ports, Proto, Next: S.Next + S.Value };
}
// 在一条 s2c 明文消息里找 _reconnectFast;命中则改写成 localhost:<中继端口> 并返回新明文
function RewriteReconnect(State, Plain) {
const Frames = ParseFrames(Plain);
if (!Frames) return null;
const Out = [];
let Changed = false;
for (const F of Frames) {
let NewBody = null;
if (!F.Compressed) {
const Hit = F.Body.indexOf(RECONNECT_ID_BUF);
if (Hit >= 0) {
const Info = ParseEndpoint(F.Body, Hit + 8);
if (Info && Info.Host && Info.Ports.length) {
const Proto = String(Info.Proto || 'WSS').toUpperCase();
const Scheme = Proto === 'WSS' ? 'wss' : 'ws';
const Target = Scheme + '://' + Info.Host + ':' + Info.Ports[0] + '/';
TunnelUpstream = Target;
TunnelUpstreamAt = Date.now();
State.Log('[_reconnectFast] 服务端指定战场服 ' + Target + ' → 已接管为下一条连接的上游');
const Local = Buffer.from('localhost', 'utf8');
const Ws = Buffer.from('WS', 'utf8');
NewBody = Buffer.concat([
F.Body.slice(0, Hit + 8), // nullmap + objectId + methodId 原样保留
WriteVarint(Local.length), Local,
WriteVarint(1), WriteVarint(PORT),
WriteVarint(Ws.length), Ws,
]);
Changed = true;
} else {
State.Log('[_reconnectFast] 命中但字段解不动: ' + Hex(F.Body.slice(Hit, Hit + 48), 48));
}
}
}
Out.push(BuildFrame(NewBody || F.Body, F.Compressed ? true : false));
}
return Changed ? Buffer.concat(Out) : null;
}
// s2c 解密 + 可选改写。返回 { Send, FollowUp }
// 说明:改包会改变长度,本连接的密码流会与真实服务器错位;因此 FollowUp=true 时调用方
// 需要在发完这一帧后主动关闭该连接(客户端本来也会因切服而断开)。
function DecodeS2c(State, Decoder, Buf) {
if (Decoder.Mode !== 'cipher' || !Decoder.Cipher) {
// 明文段也可能是空间帧(少数地图/阶段),同样尝试接管
const NewPlain = TUNNEL_RECONNECT ? RewriteReconnect(State, Buf) : null;
if (NewPlain) {
Decoder.Feed(NewPlain);
return { Send: NewPlain, FollowUp: true };
}
Decoder.Feed(Buf);
return { Send: Buf, FollowUp: false };
}
const Backup = new Cipher(Decoder.Key, Decoder.Encoded);
Backup.Slot.set(Decoder.Cipher.Slot);
Backup.Idx = Decoder.Cipher.Idx;
const Plain = Decoder.Cipher.Apply(Buf);
const NewPlain = TUNNEL_RECONNECT ? RewriteReconnect(State, Plain) : null;
if (!NewPlain) {
Decoder.Report(Plain, true);
return { Send: Buf, FollowUp: false };
}
const Enc = new Cipher(Decoder.Key, Decoder.Encoded);
Enc.Slot.set(Backup.Slot);
Enc.Idx = Backup.Idx;
const NewBuf = Enc.Apply(NewPlain);
Decoder.Cipher = Enc;
Decoder.Report(NewPlain, true);
return { Send: NewBuf, FollowUp: true };
}
function Hex(Buf, N) {
return Buf.slice(0, N || 32).toString('hex');
}
@@ -274,6 +420,7 @@ class StreamDecoder {
this.Key = null;
this.Hint = null; // 对向方向破解出的密钥
this.Window = [];
this.FirstCmdId = null; // 本方向解出的**第一条**命令 ID(用于按首帧选上游)
}
Feed(Buf) {
@@ -357,6 +504,16 @@ class StreamDecoder {
}
}
const At = NullmapSize(Body, 0);
// 明文帧体落盘(审计用,见文件头 --dump-plain):<session>/plain/s2c_%04d.bin
if (DUMP_PLAIN && this.Direction === 's2c' && this.State.Dir) {
this.PlainSeq = (this.PlainSeq || 0) + 1;
const P = path.join(this.State.Dir, 'plain', 's2c_' + String(this.PlainSeq).padStart(4, '0') + '.bin');
EnqueueSave(Body.length, (Done) => {
fs.mkdir(path.dirname(P), { recursive: true }, () => {
fs.writeFile(P, Body, () => Done());
});
});
}
if (At >= Body.length) {
this.State.Log(Tag + '保活 ping (' + Buf.length + 'B)' + Note);
continue;
@@ -369,6 +526,7 @@ class StreamDecoder {
continue;
}
const Id = Body[At];
if (this.FirstCmdId === null) this.FirstCmdId = Id; // 首条命令 ID(供上游分流)
const Payload = Body.slice(At + 1, At + 41);
const Name = (CMD_NAME[this.Direction] && CMD_NAME[this.Direction][Id]) || '未知';
const Extra = this.Direction === 'c2s' && Id === 32 ? ' ' + StreamDecoder.DescribeClientLog(Body.slice(At + 1)) : '';
@@ -530,32 +688,67 @@ WsServer.on('connection', (Client, Req) => {
UpHeaders[L] = Req.headers[K];
}
if (!UpHeaders['user-agent']) UpHeaders['user-agent'] = UPSTREAM_HEADERS['user-agent'];
if (!UpHeaders['origin']) UpHeaders['origin'] = UPSTREAM_HEADERS.origin;
if (!UpHeaders['referer']) UpHeaders['referer'] = UPSTREAM_HEADERS.referer;
// Origin **强制用真实站点的值**:前端(浏览器)带的是本机地址 http://localhost:8080,
// 真实服务器会据此校验来源,必须覆盖,不能透传。
UpHeaders['origin'] = UPSTREAM_HEADERS.origin;
// Referer:**不要加**。浏览器发起的 WebSocket 握手本身不带 Referer(见官方 Copy-as-cURL 的握手头),
// 带上它会让上游把连接识别为「非浏览器来源」——实测疑似导致「复用哈希的二次连接」被 code=1000 关闭。
delete UpHeaders['referer'];
// 诊断:打印**发往上游**的真实握手来源(注意与上面「来源: … Origin: …」那行区分开 ——
// DevTools 里看到的本机 Origin 是「浏览器→中继」这一段,页面 JS 改不了;中继改的是「中继→上游」这一段)
State.Log(
'上游握手头: origin=' + UpHeaders['origin'] +
' referer=' + UpHeaders['referer'] +
' user-agent=' + String(UpHeaders['user-agent'] || '').slice(0, 48) + '…' +
' cookie=' + (UpHeaders['cookie'] ? '有(' + UpHeaders['cookie'].length + 'B)' : '无'),
);
const ClientProto = Req.headers['sec-websocket-protocol'];
const UpProtocols = ClientProto
? String(ClientProto).split(',').map(function (S) { return S.trim(); }).filter(Boolean)
: undefined;
// 转发:客户端帧**原样直发**;上游未就绪时暂存,open 后按原序补发(不重放、不改字节)。
Upstream = new Ws(UPSTREAM, { headers: UpHeaders, protocols: UpProtocols, rejectUnauthorized: false });
Upstream.binaryType = 'nodebuffer';
Upstream.on('open', () => {
State.Log('上游已连接: ' + UPSTREAM + ',补发暂存 ' + Pending.length + ' 帧');
while (Pending.length) Upstream.send(Pending.shift());
});
Upstream.on('message', (Data) => {
const Buf = Buffer.from(Data);
SaveFrame(State, 's2c', Buf);
Decoders.s2c.Feed(Buf);
SyncKeys(Decoders);
if (Client.readyState === Ws.OPEN) Client.send(Buf);
});
Upstream.on('close', (Code) => {
State.Log('上游关闭 code=' + Code);
if (Client.readyState === Ws.OPEN) Client.close();
});
Upstream.on('error', (Err) => State.Log('上游错误: ' + Err.message));
// 注:曾试过"等首帧再选上游",但已证伪(本机镜像的 config-template 没有 {server},官方分服是靠 /s/status.js),
// 故恢复为"连上立即建上游",保持与上游 TLS 建连的并行(不增加握手延迟)。
const StartUpstream = (Target) => {
State.Log('上游选择: ' + Target);
const U = new Ws(Target, { headers: UpHeaders, protocols: UpProtocols, rejectUnauthorized: false });
U.binaryType = 'nodebuffer';
U.on('open', () => {
State.Log('上游已连接: ' + Target + ',补发暂存 ' + Pending.length + ' 帧');
while (Pending.length) U.send(Pending.shift());
});
U.on('message', (Data) => {
const Buf = Buffer.from(Data);
SaveFrame(State, 's2c', Buf);
const Res = DecodeS2c(State, Decoders.s2c, Buf);
SyncKeys(Decoders);
if (Client.readyState === Ws.OPEN) Client.send(Res.Send);
if (Res.FollowUp) {
// 改包后密码流已与服务器错位;客户端本来也会切服,这里主动收尾
State.Log('[_reconnectFast] 已改写并接管,关闭当前连接等待客户端重连');
setTimeout(() => {
try { if (Client.readyState === Ws.OPEN) Client.close(); } catch (E) {}
try { if (U.readyState === Ws.OPEN) U.close(); } catch (E) {}
}, 200);
}
});
U.on('close', (Code) => {
State.Log('上游关闭 code=' + Code);
if (Client.readyState === Ws.OPEN) Client.close();
});
U.on('error', (Err) => State.Log('上游错误: ' + Err.message));
Upstream = U;
};
// 若上一条连接收到 _reconnectFast,则本连接直接连它指定的战场服
let InitialTarget = UPSTREAM;
if (TUNNEL_RECONNECT && TunnelUpstream && Date.now() - TunnelUpstreamAt < TUNNEL_TTL_MS) {
InitialTarget = TunnelUpstream;
TunnelUpstream = null;
State.Log('沿用 _reconnectFast 指定的上游: ' + InitialTarget);
}
StartUpstream(InitialTarget);
Client.on('message', (Data) => {
const Buf = Buffer.from(Data);
@@ -583,5 +776,8 @@ Server.listen(PORT, () => {
console.log(' 抓包目录: ' + PACKET_DIR);
console.log(' 落盘策略: ' + (NeedDisk ? ('dump-every=' + DUMP_EVERY + ' log=' + (WRITE_LOG ? 'on' : 'off')) : '不落盘(只转发+实时解析;要抓包加 --dump-every 1 --log 1)'));
console.log(' 转发策略: 原样直发;上游未就绪时暂存竞态帧,一连上立即按原序补发');
console.log(' 隧道接管: ' + (TUNNEL_RECONNECT
? '_reconnectFast → 改写 host/port 为 localhost:' + PORT + ',并按原 host 切换上游'
: '关闭(客户端会直连真实战场服)'));
console.log(' 实时解析: 明文直解;加密段自动穷举密钥后实时解密;zlib 压缩帧自动解压');
});
+107
View File
@@ -0,0 +1,107 @@
// ===== 3DTank Hornet Service Worker =====
// 功能:拦截 main.*.js 请求,注入 window.__wp_require__ 捕获行 + 自动加载 hornet_bundle.js
// 用途:当无法修改 main 文件、无法预 hook 时,通过网络层注入
// SW 放在 /play/hornet_sw.js,scope = /play/ 覆盖 main.*.js 和 unpacked/
//
// 使用方法:
// 1. 确保 hornet_sw.js 在 /play/ 目录(scope 覆盖 /play/static/js/)
// 2. 在浏览器控制台执行:
// navigator.serviceWorker.register('/play/hornet_sw.js')
// .then(() => { console.log('[Hornet] SW 已注册,刷新页面'); location.reload(); });
// 3. 页面刷新后,SW 拦截 main.*.js:
// a) 在 __webpack_require__ 函数体开头注入 window.__wp_require__=__webpack_require__
// b) 在 __webpack_require__(78231) 后注入 hornet_bundle.js 加载
// 4. 游戏正常启动,__wp_require__ 和 hornet_bundle 自动就绪
//
// 卸载方法:
// navigator.serviceWorker.getRegistration().then(r => r.unregister()).then(() => location.reload())
var SW_DEBUG = '[Hornet SW]';
var MAIN_JS_PATTERN = /\/main\.[a-f0-9]+\.js$/;
var INJECT_LINE = 'window.__wp_require__=__webpack_require__;';
// 在入口模块加载后注入 hornet_bundle.js
var BUNDLE_MARKER = '__webpack_require__(78231)';
var BUNDLE_INJECT = ';var __hb=document.createElement("script");__hb.src="/play/static/js/unpacked/hornet_bundle.js";document.head.appendChild(__hb);';
self.addEventListener('install', function(event) {
self.skipWaiting();
});
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', function(event) {
var url = event.request.url;
// 拦截 main.*.js
if (MAIN_JS_PATTERN.test(url)) {
event.respondWith(
fetch(event.request).then(function(response) {
if (!response.ok) return response;
return response.text().then(function(text) {
// 在 __webpack_require__ 函数定义后注入捕获行
// 匹配模式:function __webpack_require__(t) { ... }
var marker = 'function __webpack_require__(';
var idx = text.indexOf(marker);
if (idx === -1) {
// 尝试 webpack 5 格式
marker = '__webpack_require__ = function';
idx = text.indexOf(marker);
}
if (idx !== -1) {
// 找到函数体的第一个 { 后注入捕获行
var braceIdx = text.indexOf('{', idx);
if (braceIdx !== -1) {
var injectPos = braceIdx + 1;
var modified = text.slice(0, injectPos) + INJECT_LINE + text.slice(injectPos);
// 在入口模块加载后注入 hornet_bundle.js
var bundleIdx = modified.indexOf(BUNDLE_MARKER);
if (bundleIdx !== -1) {
var afterMarker = bundleIdx + BUNDLE_MARKER.length;
modified = modified.slice(0, afterMarker) + BUNDLE_INJECT + modified.slice(afterMarker);
console.log(SW_DEBUG, '注入 __wp_require__ + bundle 加载到', url);
} else {
console.log(SW_DEBUG, '注入 __wp_require__ 捕获行(未找到 bundle marker)', url);
}
return new Response(modified, {
status: response.status,
statusText: response.statusText,
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-cache'
}
});
}
}
console.warn(SW_DEBUG, '未找到 __webpack_require__ 定义,返回原始响应');
return new Response(text, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
});
}).catch(function(err) {
console.error(SW_DEBUG, '拦截失败:', err);
return fetch(event.request);
})
);
return;
}
// 其他请求正常处理
event.respondWith(fetch(event.request));
});
// 监听消息:手动加载 hornet_bundle
self.addEventListener('message', function(event) {
if (event.data === 'load_bundle') {
self.clients.matchAll().then(function(clients) {
clients.forEach(function(client) {
client.postMessage('load_bundle');
});
});
}
});
@@ -0,0 +1,62 @@
console.log('Launcher loaded');
var __script_base_url__=(typeof document!=="undefined"&&document.currentScript&&document.currentScript.src)?document.currentScript.src.replace(/[^\/]+$/,""):"./";!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.Launcher=n():t.Launcher=n()}(globalThis,async()=>{
// ===== 切割版模块加载器 =====
window.__modules__ = {};
const __base_url__ = window.__script_base_url__ || './';
// 6.1 加载资源模块 (从 resources.json 批量注册)
const __resource_resp__ = await fetch(__base_url__ + 'unpacked/resources.json');
if (!__resource_resp__.ok) throw new Error('resources.json load failed: HTTP ' + __resource_resp__.status);
const __resource_data__ = await __resource_resp__.json();
const __resource_map__ = __resource_data__.resourceMap;
for (const __id__ in __resource_map__) {
if (!Object.prototype.hasOwnProperty.call(__resource_map__, __id__)) continue;
const __path__ = __resource_map__[__id__];
const __id_num__ = Number(__id__);
window.__modules__[__id_num__] = (function(__p__) {
return (t, n, i) => {
"use strict";
t.exports = i.p + __p__;
};
})(__path__);
}
// 6.2 加载代码模块 (fetch + eval 独立文件)
const __code_ids__ = [806,905,1510,1901,3535,3569,7426,7552,7793,9345,9539,9963,10032,10283,10849,12218,13431,13532,13754,14766,15152,15330,15831,16100,17259,17323,18334,19074,19487,21300,21973,24635,24923,25377,26482,27736,28485,29518,29545,29554,30285,31509,31584,31620,31908,32088,36659,36734,37036,37504,38335,40293,41164,42920,43319,44740,44794,45555,45686,47243,48576,50824,51233,51400,51887,52943,54199,55088,55520,55920,56077,57128,57328,58309,59131,59411,60273,60824,61378,61430,61860,63383,64280,64477,65201,66610,67222,69682,70130,70298,71105,71426,72834,73932,74897,75453,76074,77249,77672,78231,78931,79763,80397,80693,81886,82347,84441,85183,85196,85667,85745,87072,88693,89613,89679,89803,90225,90875,92980,94090,94965,96480,97556,97675,99295,99527];
await Promise.all(__code_ids__.map(async (__id__) => {
const __padded__ = typeof __id__ === 'number' ? String(__id__).padStart(5, '0') : __id__;
const __url__ = __base_url__ + 'unpacked/module_' + __padded__ + '.js';
const __resp__ = await fetch(__url__);
if (!__resp__.ok) throw new Error('Module ' + __id__ + ' load failed: HTTP ' + __resp__.status + ' @ ' + __url__);
const __code__ = await __resp__.text();
(0, eval)(__code__);
}));
var __webpack_modules__ = window.__modules__;
// ===== 原 webpack runtime (一字未改) =====
;var __webpack_module_cache__={};function __webpack_require__(t){var n=__webpack_module_cache__[t];if(void 0!==n)return n.exports;var i=__webpack_module_cache__[t]={id:t,loaded:!1,exports:{}};return __webpack_modules__[t].call(i.exports,i,i.exports,__webpack_require__),i.loaded=!0,i.exports}__webpack_require__.n=t=>{var n=t&&t.__esModule?()=>t.default:()=>t;return __webpack_require__.d(n,{a:n}),n},__webpack_require__.d=(t,n)=>{for(var i in n)__webpack_require__.o(n,i)&&!__webpack_require__.o(t,i)&&Object.defineProperty(t,i,{enumerable:!0,get:n[i]})},__webpack_require__.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),__webpack_require__.hmd=t=>((t=Object.create(t)).children||(t.children=[]),Object.defineProperty(t,"exports",{enumerable:!0,set:()=>{throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+t.id)}}),t),__webpack_require__.o=(t,n)=>Object.prototype.hasOwnProperty.call(t,n),__webpack_require__.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},__webpack_require__.p="/play/",__webpack_require__(76074),__webpack_require__(13431),__webpack_require__(26482);var __webpack_exports__=__webpack_require__(78231);
// ===== 暴露 webpack require(hornet 工具链 hook_gamemode/hornet_lib 依赖)=====
window.__wp_require__ = __webpack_require__;
// ===== 加载 hornet_bundle(默认关闭:URL 加 ?hornet=1 才加载,避免日志遮挡)=====
if (/[?&]hornet=1(&|$)/.test(location.search)) {
try {
const __hornet_url__ = __base_url__ + 'unpacked/hornet_bundle.js';
const __hornet_resp__ = await fetch(__hornet_url__);
if (__hornet_resp__.ok) {
const __hornet_code__ = await __hornet_resp__.text();
(0, eval)(__hornet_code__);
console.log('[Hornet] bundle 已加载');
} else {
console.warn('[Hornet] bundle 加载失败: HTTP ' + __hornet_resp__.status);
}
} catch (__hornet_err__) {
console.error('[Hornet] bundle 加载异常:', __hornet_err__);
}
}
return __webpack_exports__;
})//# sourceMappingURL=main.9f4e7a89.js.map
@@ -0,0 +1,62 @@
console.log('Launcher loaded');
var __script_base_url__=(typeof document!=="undefined"&&document.currentScript&&document.currentScript.src)?document.currentScript.src.replace(/[^\/]+$/,""):"./";!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.Launcher=n():t.Launcher=n()}(globalThis,async()=>{
// ===== 切割版模块加载器 =====
window.__modules__ = {};
const __base_url__ = window.__script_base_url__ || './';
// 6.1 加载资源模块 (从 resources.json 批量注册)
const __resource_resp__ = await fetch(__base_url__ + 'unpacked/resources.json');
if (!__resource_resp__.ok) throw new Error('resources.json load failed: HTTP ' + __resource_resp__.status);
const __resource_data__ = await __resource_resp__.json();
const __resource_map__ = __resource_data__.resourceMap;
for (const __id__ in __resource_map__) {
if (!Object.prototype.hasOwnProperty.call(__resource_map__, __id__)) continue;
const __path__ = __resource_map__[__id__];
const __id_num__ = Number(__id__);
window.__modules__[__id_num__] = (function(__p__) {
return (t, n, i) => {
"use strict";
t.exports = i.p + __p__;
};
})(__path__);
}
// 6.2 加载代码模块 (fetch + eval 独立文件)
const __code_ids__ = [806,905,1510,1901,3535,3569,7426,7552,7793,9345,9539,9963,10032,10283,10849,12218,13431,13532,13754,14766,15152,15330,15831,16100,17259,17323,18334,19074,19487,21300,21973,24635,24923,25377,26482,27736,28485,29518,29545,29554,30285,31509,31584,31620,31908,32088,36659,36734,37036,37504,38335,40293,41164,42920,43319,44740,44794,45555,45686,47243,48576,50824,51233,51400,51887,52943,54199,55088,55520,55920,56077,57128,57328,58309,59131,59411,60273,60824,61378,61430,61860,63383,64280,64477,65201,66610,67222,69682,70130,70298,71105,71426,72834,73932,74897,75453,76074,77249,77672,78231,78931,79763,80397,80693,81886,82347,84441,85183,85196,85667,85745,87072,88693,89613,89679,89803,90225,90875,92980,94090,94965,96480,97556,97675,99295,99527];
await Promise.all(__code_ids__.map(async (__id__) => {
const __padded__ = typeof __id__ === 'number' ? String(__id__).padStart(5, '0') : __id__;
const __url__ = __base_url__ + 'unpacked/module_' + __padded__ + '.js';
const __resp__ = await fetch(__url__);
if (!__resp__.ok) throw new Error('Module ' + __id__ + ' load failed: HTTP ' + __resp__.status + ' @ ' + __url__);
const __code__ = await __resp__.text();
(0, eval)(__code__);
}));
var __webpack_modules__ = window.__modules__;
// ===== 原 webpack runtime (一字未改) =====
;var __webpack_module_cache__={};function __webpack_require__(t){var n=__webpack_module_cache__[t];if(void 0!==n)return n.exports;var i=__webpack_module_cache__[t]={id:t,loaded:!1,exports:{}};return __webpack_modules__[t].call(i.exports,i,i.exports,__webpack_require__),i.loaded=!0,i.exports}__webpack_require__.n=t=>{var n=t&&t.__esModule?()=>t.default:()=>t;return __webpack_require__.d(n,{a:n}),n},__webpack_require__.d=(t,n)=>{for(var i in n)__webpack_require__.o(n,i)&&!__webpack_require__.o(t,i)&&Object.defineProperty(t,i,{enumerable:!0,get:n[i]})},__webpack_require__.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),__webpack_require__.hmd=t=>((t=Object.create(t)).children||(t.children=[]),Object.defineProperty(t,"exports",{enumerable:!0,set:()=>{throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+t.id)}}),t),__webpack_require__.o=(t,n)=>Object.prototype.hasOwnProperty.call(t,n),__webpack_require__.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},__webpack_require__.p="/play/",__webpack_require__(76074),__webpack_require__(13431),__webpack_require__(26482);var __webpack_exports__=__webpack_require__(78231);
// ===== 暴露 webpack require(hornet 工具链 hook_gamemode/hornet_lib 依赖)=====
window.__wp_require__ = __webpack_require__;
// ===== 加载 hornet_bundle(默认关闭:URL 加 ?hornet=1 才加载,避免日志遮挡)=====
if (/[?&]hornet=1(&|$)/.test(location.search)) {
try {
const __hornet_url__ = __base_url__ + 'unpacked/hornet_bundle.js';
const __hornet_resp__ = await fetch(__hornet_url__);
if (__hornet_resp__.ok) {
const __hornet_code__ = await __hornet_resp__.text();
(0, eval)(__hornet_code__);
console.log('[Hornet] bundle 已加载');
} else {
console.warn('[Hornet] bundle 加载失败: HTTP ' + __hornet_resp__.status);
}
} catch (__hornet_err__) {
console.error('[Hornet] bundle 加载异常:', __hornet_err__);
}
}
return __webpack_exports__;
})//# sourceMappingURL=main.9f4e7a89.js.map
Loaded 100 of 556 files, more files were not shown because too many files have changed in this diff. Show more