44 KiB
3DTank Kotlin 重构计划(KtRebuild Plan)
目标:用 Kotlin 复刻游戏本体,最终产出职责等价、可启动的 JS 客户端。 基线:
main.7cfa4058.js(10,080,299 字节)/client_version = e98d02161a563e043eabb96c6e4db5fedcf165e7文档定位:计划表 —— 有哪些工具、怎么用、按什么顺序做、怎么验收。
0. 先纠正三个前提
-
dir.file不是当前版本的职责表。 它是旧版 126 模块的职责划分;当前版本的模块 id、字节数、内部字段都有漂移。 但职责划分大体一致,仍可作为"要做哪些子系统"的规划依据;正式开工前必须用当前包重建一份职责表(见 S0)。 -
e98d0216不是文件名。e98d0216…是client_version(git release hash,写死在module_61860.js):i.g3("client_version", "e98d02161a563e043eabb96c6e4db5fedcf165e7");而
main.7cfa4058.js的7cfa4058是 webpack contenthash。两者无对应关系,不能互相推导文件名。 -
真正要复刻的只有 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的旧数据。 - 做法:
node tools/split.js --cleannode 3DTank/tools/extract.js ../main.7cfa4058.js ../srcnode mine_evidence.js(更新evidence.json)- 以
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动作名。
- 根状态
- 已迁移切片 76/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_showanalytics 是副作用,纯 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/JSLong(原构造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整片复位。launchParams→(pass-through):原根 reducer 对hpx_1没有调用,缺省是ag()空 Map;RootState.LaunchParams收口为LaunchParamsStateSlice = Map<String, String>,不新增 reducer/动作。
App/State/RootReducer.kt:ReduceRoot(State0, Action)归约上述 75 个有 reducer 的切片,launchParams与其余 13 个字段原样透传; 迁移进度用RootReducer.Coverage(已迁移 76/89)与RootWiring.Unmigrated暴露。- 自检:
TankiOffline/tools/RunStateTest.ps1832 项全绿,含 89 槽位完整性、75 个归约切片一次归约互不串片、未迁移字段透传, 以及用action_trace_merged.json真实派发的两条商店动作(SetCoinPaymentProviderUrl/ToggleShopActivity)回放。 - 委派表里的已知形态:
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. 换版与漂移处理
- 抓新
main.<hash>.js→node tools/split.js --clean node tools/map_symbols.js <旧unpacked> <新bundle> all tools/symbol_map.json__hornetSchema__.SelfCheck()列出语义字段失配 → 只改hornet_schema.js- 人工核对三类:
$_.xx导出键(队伍vd/dl、碰撞世界55088.$_.h等)- 工具 hook 的类内部方法(
o37/o3c/n3c/f3t/q9q/kz7) - 渲染管线字段语义(
d75_1/w39_1/b74_1…)
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 工具链) |
打包命令(按来源目录二选一):
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 完整流程
# 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. 待确认
32088(标注"Alternativa3D,Kotlin 编写")是自研移植还是开源库?决定 S4 是"引入"还是"重写"。10032/10849(Redux)是否必须保持 Kotlin 实现,还是可以换成原生 Redux?- S6 UI 是否允许永久保留原 JS 模块(推荐允许)。
- 是否要求最终产物能替代
main.<hash>.js,还是只要"能启动 + 职责等价"即可。