# 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..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` 动作名。 - 已迁移切片 **75/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`。 `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` 收成 `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`,缺省空 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>`,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` 整片复位。 - `launchParams` → `(pass-through)`:原根 reducer 对 `hpx_1` 没有调用,缺省是 `ag()` 空 Map; `RootState.LaunchParams` 收口为 `LaunchParamsStateSlice = Map`,不新增 reducer/动作。 - `App/State/RootReducer.kt`:`ReduceRoot(State0, Action)` 归约上述 74 个有 reducer 的切片, `launchParams` 与其余 **14 个字段原样透传**; 迁移进度用 `RootReducer.Coverage`(`已迁移 75/89`)与 `RootWiring.Unmigrated` 暴露。 - 自检:`TankiOffline/tools/RunStateTest.ps1` **823 项全绿**,含 89 槽位完整性、74 个归约切片一次归约互不串片、未迁移字段透传, 以及用 `action_trace_merged.json` 真实派发的两条商店动作(`SetCoinPaymentProviderUrl` / `ToggleShopActivity`)回放。 - 委派表里的已知形态:`shop` / `home` / `localBattleUserState` / `partner` 是内联 IIFE, 已按字段具名为 `(inline@)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个); `launchParams` 是纯透传(`(pass-through)`,无 reducer),已把字段收口为 `Map`。 - 无 `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`)同样按效果定名。 ### 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..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..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..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..js`,还是只要"能启动 + 职责等价"即可。