Files
TankIC/3DTank/docs/KtRebuild_Plan.md
T

47 KiB
Raw Blame History

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):

    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 动作名。
    • 已迁移切片 83/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 字段)。
      • launchParams → (pass-through):原根 reducer 对 hpx_1 没有调用,缺省是 ag() 空 Map; RootState.LaunchParams 收口为 LaunchParamsStateSlice = Map<String, String>,不新增 reducer/动作。
    • App/State/RootReducer.kt:ReduceRoot(State0, Action) 归约上述 82 个有 reducer 的切片, launchParams 与其余 6 个字段原样透传; 迁移进度用 RootReducer.Coverage(已迁移 83/89)与 RootWiring.Unmigrated 暴露。
    • 自检:TankiOffline/tools/RunStateTest.ps1 913 项全绿,含 89 槽位完整性、82 个归约切片一次归约互不串片、未迁移字段透传, 以及用 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. 换版与漂移处理

  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 工具链)

打包命令(按来源目录二选一):

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. 待确认

  1. 32088(标注"Alternativa3D,Kotlin 编写")是自研移植还是开源库?决定 S4 是"引入"还是"重写"。
  2. 10032 / 10849(Redux)是否必须保持 Kotlin 实现,还是可以换成原生 Redux?
  3. S6 UI 是否允许永久保留原 JS 模块(推荐允许)。
  4. 是否要求最终产物能替代 main.<hash>.js,还是只要"能启动 + 职责等价"即可。