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

563 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 动作名。
- 已迁移切片 **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_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` 整片复位。
- `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.ps1` **832 项全绿**,含 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. 换版与漂移处理
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`,还是只要"能启动 + 职责等价"即可。