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

19 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 动作名。
    • 已迁移切片 9/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)。
    • App/State/RootReducer.kt:ReduceRoot(State0, Action) 只归约上述九片,其余 80 个字段原样透传; 迁移进度用 RootReducer.Coverage(已迁移 9/89)与 RootWiring.Unmigrated 暴露。
    • 自检:TankiOffline/tools/RunStateTest.ps1 234 项全绿,含 89 槽位完整性、九片一次归约互不串片、未迁移字段透传, 以及用 action_trace_merged.json 真实派发的两条商店动作(SetCoinPaymentProviderUrl / ToggleShopActivity)回放。
    • 委派表里的已知形态:shop / home / localBattleUserState / partner 是内联 IIFE, 已按字段具名为 (inline@<field>) 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个); launchParams 是纯透传((pass-through),无 reducer)。
    • 无 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 个匿名类同样按效果定名并登记。

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,还是只要"能启动 + 职责等价"即可。