Minecraft多版本Mod开发踩坑
动机和目标
开发一个 Minecraft 的 Mod,并想要适配多个版本的 Minecraft,通常存在大量相同的代码逻辑。社区的惯用方法是对每个 Minecraft 版本做一个分支,用 git 管理,并随着版本更新停止维护旧Minecraft版本下的Mod,减轻维护负担。
这种方法并不能解决我遇到的实际需求:全版本的群服互联(QQ群和 Minecraft 服务器消息同步)。鉴于 Minecraft Modding 社区历史之久,在1.7.10和1.12.2拥有大量「遗产」,群内所玩整合包自然也常会包含这两个版本。这也是为什么有现有轮子不用(旧版本下因为上述社区惯例没有在维护,buggy 且难以部署)。
因此需要探索一种方法,在全版本复用相同的逻辑。具体来说,将核心逻辑提出单独做成一个模块,再在各个特定版本引用该模块。Minecraft 自己没有 Mod API,各个类随版本改变都可能变化或消失,因此核心模块不能直接引用 Minecraft 的类和修改 Minecraft(core modding,特别地,Mixin),需要将自己所需的 Minecraft 类和 API 抽象为一个接口,由具体版本的代码实现接口。Core modding 是修改 Minecraft,与具体版本耦合,自然由实现侧完成。这样,在理论上,迁移版本只需要搭建对应版本的 Mod 开发环境、写胶水代码实现接口、调用核心模块。同时,这套方法与模组加载器无关,因为接口同样抽象了 Mod Loader 相关代码。
核心模块
核心模块不依赖具体的 Minecraft 和 Mod Loader 代码,因此就是一个普通的 JVM 项目。不过有所限制:为了能在较旧版本的 Minecraft 上运行,只能使用 JDK 8。
对于接口的设计,先在某个具体版本实现一个原型,观察大致需要哪些类和方法即可。
实践历程
我的第一个原型在 1.21/NeoForge 上用 Java,用常规的 Mod 开发方式完成。因为使用了 Java 8 以后的特性,同时 Minecraft 新旧版本、NeoForge 和 Forge 和其他 Loader 的差异之大,向低版本移植困难。于是我使用了 Architectury 复用公共代码、抽象 Mod Loader,改用 Kotlin 以避免使用 Java 8 的痛苦。1.16.5+的 Minecraft 代码差异并不巨大,用 git branch 管理不同版本可以接受。但在向更低版本移植时可就完蛋了,1.13以前和1.16以后的 Minecraft 开发环境的差异巨大,这包括 Minecraft 本身的代码逻辑(1.13为界)、混淆映射表(MCP 和 Mojang Mappings)、Mod Loader(还是1.13)。于是我干脆直接抽象整个(Minecraft 版本, 模组加载器)二元组,从而有了现在这个方案。
对 JVM 互操作性的需求降低了,同时出于个人喜好,我使用了 Scala 3 重新编写核心模块,Minecraft 接口参考之前的原型写出,大概如下:
1 |
|
有类型成员(或者说,像 ML 的 module 一样)写起来非常轻松。如果用 Java 写可能就是一万个接口,并且实现接口在语义上很奇怪了(例如 SLF4J)。
实现侧
核心模块很干净地实现了主要逻辑,那么 Dirty work 就得实现侧来做了。主要任务有两个:
- 实现前述接口
- 确保核心模块能在 JVM 上跑,调用核心模块
第一个任务就是写胶水代码,并不难。而第二个任务没有看上去那么简单。下面记录一些小细节后着重阐释这一部分。
构建脚本
搭建开发环境的 Gradle 插件有很多,选择适合 Minecraft 版本和 Mod Loader 的即可。但其实这些插件,包括 Gradle 本身都不太好用就是了()。ForgeGradle 槽点太多,到了古早版本里都不一定跑的起来。Fabric Loom 及其支持 Forge 的分支 Architectury Loom 非常好用,在1.16.5+可以爽用。NeoForge 的新工具 ModDevGradle 更好用,可惜现在只支持1.20以上。
我用的是Unimined,它对任意 Minecraft 版本和任意 Mod Loader 保持同一个接口,迁移构建脚本非常方便。
为了搞清楚这些插件在开发环境下运行 Minecraft 到底传了什么参数,在 Windows 上可以用wmic process get caption,commandline /value
来查看。
Mixin (Especially in MinecraftForge)
对于1.16+,Forge 是自带 Mixin 的。一个正确的构建脚本应当(假设 Mixin 配置文件命名为modid.mixins.json
):
- 开发环境下给游戏启动的参数添加
--mixin modid.mixins.json
- 打包时在Jar包的Manifest中添加
MixinConfigs: modid.mixins.json
,生成refmap并为modid.mixins.json
添加refmap
字段
而对于 LaunchWrapper 时代的1.7.10、1.12.2,Mixin 需要引导,选一个引导模组即可(自己引导也行)。我认为合适的选择是在1.7.10使用UniMixins,在1.12.2使用MixinBooter。一个正确的构建脚本应当(假设 Mixin 配置文件命名为modid.mixins.json
):
- 开发环境下给 JVM 启动参数添加
-Dfml.coreMods.load=zone.rong.mixinbooter.MixinBooterPlugin
(等号右侧为引导模组,此处以 MixinBooter 为例。UniMixins 为io.github.legacymoddingmc.unimixins.all.AllCore
),给游戏启动的参数添加--tweakClass org.spongepowered.asm.launch.MixinTweaker --mixin modid.mixins.json
- 打包时在Jar包的Manifest中添加
FMLCorePluginContainsFMLMod: true
、TweakClass: org.spongepowered.asm.launch.MixinTweaker
、ForceLoadAsMod: true
和MixinConfigs: modid.mixins.json
,生成refmap并为modid.mixins.json
添加refmap
字段
调用核心模块的几个问题
Forge 下开发模式运行的加载问题
没错,Forge 又干了。核心模块是一个普通的、非 Mod 的依赖,如果仅仅添加一个implementation
声明依赖的话,在开发环境下运行就会喜提ClassNotFoundException/NoClassDefFoundError
。对于使用 ForgeGradle 来说,这点在文档中有提及,需要同时将依赖添加到一个叫minecraftLibrary
的 Configuration 中。对于其他 Gradle 插件,可能有不同的名字,不过只要大概知道原理就不难找到。我的理解,不一定对:FML 作为一个体面的加载器,用来加载 Mod 的 ClassLoader 没有父母,其行为大致是在 classpath 下找存在 mods.toml 的模块(也就被认为是 Mod)并加载,而不会直接去找 classpath 下的类,这导致非 Mod 依赖的类无法被找到。不过它提供了一个后门,传入参数-DlegacyClassPath.file=xxx.txt
时,会对xxx.txt
里的路径用传统的加载方式加载。
顺带一提,1.17以后(Minecraft 终于不用 Java 8 了) Forge 会把模组 Jar 包当作 Java 的 Module 来加载,对于没有 module-info.class
的情况,会把除了META-INF下以外的*.class
都算进来,如果用其他 JVM 语言要小心包名不是 Java 合法标识符问题(比如包名里有个char
之类。我就是这么发现的)
依赖问题
用着最新版本的依赖,但可能要和用远古版本依赖的旧版 Minecraft 跑在同一个 JVM 上,直接加载的话,ABI 就对不上了(对于我这里的情况,就是 Scala 标准库和 netty)。(实测1.20.1的 netty 还是裁剪过的,即使版本对上了也会 ClassNotFoundException
)
同时,引入别的依赖不应影响 Mod 加载的环境,比如可能出现的和别的 Mod 依赖冲突。
解决方案
最直接的解决方案,对第一个问题就是修改 legacyClassPath
,对第二问题就是 shade 和 relocating。实际上坑很多。
最简单也是完美解决的方案是自定义 ClassLoader。
把整个项目分成四部分:
core
: 核心模块。可能依赖除了 Minecraft 的类以外的任何东西impl
: 依赖当前 Minecraft 和common
,引用并包含核心模块的全部(打包时也 shade 进来),实现其接口,调用之。对外仅提供一个无参静态入口点main
: Mod Loader 决定的真正的 Mod 入口点。实现自定义 ClassLoader,加载impl
,反射调用其入口点common
: 和main
放在一起(都由 Mod Loader 加载),被main
和impl
共同依赖,用来方便main
和impl
的联系。自己用 Mixin 实现的东西会放在这里。和main
分开是为了防止impl
和main
循环依赖
Hell
模块化,抽出核心模块和各版本的具体实现,这个想法非常简单,相应代码实现起来也很简单,但是想把这些部分和 Minecraft 组合在一起可以说是 painful。不过想明白自己的 class 的 load 就该由自己来掌控之后,就是走上正路了。不过把语言标准库、一些基础库网络库都塞进 Mod 里后我的 Mod 达到了惊人的约 30MiB,对于一个功能简单的 Mod 来说真是大的有点异常了,甚至 proguard 都救不了多少。不过想想一个单文件的 JVM 上的 Web server, 有这点大小不过分吧(或许该试试 JNI 并用 Native 语言写核心了