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
2
3
4
5
6
7
8
9
trait Minecraft {
/** [[net.minecraft.network.chat.Component]] */
type Component
/** [[net.minecraft.locale.Language]] */
type Language
// ...
def broadcastMessage(server: MinecraftServer, message: Component): Unit
// ...
}

有类型成员(或者说,像 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: trueTweakClass: org.spongepowered.asm.launch.MixinTweakerForceLoadAsMod: trueMixinConfigs: 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 加载),被mainimpl共同依赖,用来方便mainimpl的联系。自己用 Mixin 实现的东西会放在这里。和main分开是为了防止implmain循环依赖

Hell

模块化,抽出核心模块和各版本的具体实现,这个想法非常简单,相应代码实现起来也很简单,但是想把这些部分和 Minecraft 组合在一起可以说是 painful。不过想明白自己的 class 的 load 就该由自己来掌控之后,就是走上正路了。不过把语言标准库、一些基础库网络库都塞进 Mod 里后我的 Mod 达到了惊人的约 30MiB,对于一个功能简单的 Mod 来说真是大的有点异常了,甚至 proguard 都救不了多少。不过想想一个单文件的 JVM 上的 Web server, 有这点大小不过分吧(或许该试试 JNI 并用 Native 语言写核心了