引言
最近给我的启动器添加了MuiltiMC整合包安装的支持,所以在这里分享一下我的开发过程,顺便就当作复习了。
启动器新版本转向了Rust,所以实例代码也会用Rust。
在实现过程中,我参考了HMCL对MMC整合包导入的实现
MMC整合包介绍
Muilt-MC(MMC) 整合包依旧是1个zip格式的压缩包,但是打包方式与常见的 CurseForge / Modrinth 整合包完全不一样,因为 CF / MR 整合包都是基于自家的资源平台来打包的,都有自己的资源api,可以做到几乎所有文件都基于自家平台的api+mojang的资源api来实现,几乎除了config外的所有文件都可以在线下载,但MMC整合包不一样,他是MuiltiMC启动器的整合包导出格式,没有像MC/MR一样的资源平台,基本除了Minecraft资源文件外的内容都要打包到文件中,或者整合包开发者自己提供资源文件链接。
这最大的缺陷就是这种什么都打包的整合包格式会导致打包出的整合包文件格外的大,比如同样的RCraft整合包,CF打包只有约200MB,而MMC却有超过500MB。
但MMC整合包的优点也是很明显的,其自由度和功能性远超 CF 和 MR 整合包。MMC 整合包除了可以支持 Forge NeoForge Fabric Quilt 这类常见加载器外,还可以支持 CleanRoom Babric 等小众加载器,只要你的游戏可以启动,MMC整合包就可以打包,所以像 GTNH 这样的深度魔改包采用的都是 MMC格式 的整合包打包。
实现思路
MMC整合包 的安装核心思路就一句话:把 MultiMC 的组件补丁链(OneSix scheme)转换成一个标准 Mojang 官方版本 JSON,之后启动走普通安装/启动管线。
0.整合包结构
打开一个MMC整合包,其内部的大概结构如下(以GTNH为例子):
1 | GT New Horizons 2.8.4 |
其中,mmc-pack.json 和 instance.cfg 就是整合包的配置文件,我们便是从中读取启动器信息的。
这两个文件的作用:
| 文件 | 内容 |
|---|---|
instance.cfg(INI) |
实例名、iconKey、JavaPath(仅 OverrideJavaLocation=true 时)、MaxMemAlloc(仅 OverrideMemory=true 时) |
mmc-pack.json |
components[],每个是 {uid, version, dependencyOnly} |
1.获取整合包信息
先读 instance.cfg。它就是个再朴素不过的 INI,逐行 key=value 切一刀就完事:
1 | fn parse_instance_cfg(content: &str) -> HashMap<String, String> { |
然后是重头戏 mmc-pack.json。GTNH 2.8.4 的它长这样(去掉 cachedName 之类的缓存字段):
1 | { |
components 就是这个实例的「零件清单」:每个组件是一层对游戏的修改,一层层叠出最终的游戏。
我们要读取他拿到 uid ,然后去 patches 文件夹找{uid}.json,没有的从 maven 服务器下载。
解析它的代码:
1 | /// 单个组件 |
拿到组件清单后,从中提取启动器关心的元信息:
1 | // 游戏版本:找 uid == "net.minecraft" 的组件,它的 version 就是游戏版本 |
注意:
components的顺序就是后面组件补丁的修补顺序,不能随意调换,安装时必须按序读取(为什么顺序重要,第 4 节讲合并时揭晓)。
2.读取组件补丁
现在有个问题:第 1 节拿到的 components 里只有 uid + version,根本没提主类、库列表、启动参数这些信息。那游戏的完整定义藏在哪?
答案是每个组件自己的一份 JSON 补丁(patch)文件:patches/{uid}.json。MMC 的版本定义就是拆成一叠补丁的——net.minecraft 一张、Forge 一张、LWJGL 一张——安装时按顺序一张张叠上去,最终叠出一份完整的版本定义(这套 scheme 叫 OneSix,instance.cfg 里的 InstanceType=OneSix 说的就是它)。
GTNH 2.8.4 的 patches 目录正好 5 个文件,和 components 一一对应:
1 | patches |
补丁的读取是三级回退,本地优先:
1 | async fn load_patch(root: &Path, uid: &str, version: &str, game_version: &str) |
随手放个最小的真实例子,GTNH 的 launchargs 补丁全文就这么多:
1 | { |
它只干一件事:把主类改成 RFB(RetroFuturaBootstrap)的引导类。
3.补齐依赖闭包
只按 mmc-pack.json 列出的组件加载补丁是不够的,每个补丁里还有 requires 字段声明自己依赖谁。GTNH 里就有两个例子:
net.minecraft.json声明requires org.lwjgl3 (suggests 3.3.3)——这份 1.7.10 补丁被改造成了配 LWJGL3 用的;net.minecraftforge.json声明requires net.minecraft (equals 1.7.10)。
所以加载完一圈后要再扫一遍所有补丁的 requires:依赖的 uid 不在已加载集合里,就按 suggests/equalsVersion 指定的版本补加载,新补丁又可能带来新依赖,循环直到闭包收齐为止。之后才有资格谈合并。
1 | // 补齐依赖闭包:新补丁可能带来新依赖,只能循环到收敛为止 |
4.合并出官方版本 JSON
万事俱备,开始拼装。合并的 base 不是空白 JSON,而是先从 Mojang 拉一份官方版本 JSON(版本清单 → 版本 URL → JSON),然后把补丁一个个打上去:
1 | // 1.Mojang 官方版本 JSON 打底:先拉版本清单找到目标版本的 URL, |
合并语义不是简单的「同 key 覆盖」,每个字段有自己的规则。核心的 patch 应用函数:
1 | /// 把一个组件补丁应用到 base 上 |
其中 merge_libraries 的「同名整体覆盖」值得单独看:
1 | /// 库表合并:按完整 maven 坐标去重拼接,同名者后定义者整体覆盖 |
应用顺序:dependencyOnly 的组件先打,其余按 mmc-pack.json 的声明顺序依次打——这就是第 1 节说「顺序不能乱换」的另一半原因:
1 | // dependency-only 组件(如 LWJGL3)先打,为主组件铺路; |
这里有个我踩过的最大的坑:net.minecraft 补丁自带 libraries 时,要用它整体替换 base 的库表,而不是合并:
1 | // net.minecraft 补丁自带库表 → 整体替换,不进 merge_libraries! |
原因:GTNH在 MultiMC包 的 net.minecraft.json 是一份修正过的完整库表——gson 升到了 2.10.1、log4j 换成了修复版 2.0-beta9-fixed(挂在 Prism Launcher 的镜像仓库上)、并且剔除了全部 LWJGL2(改用组件里的 LWJGL3,以兼容Java 17+)。如果只做普通合并,Mojang 官方 JSON 里的旧库(gson 2.2.4、LWJGL 2.9.1)会残留在 classpath 里新旧打架,启动直接 VerifyError (当然如果你的启动器在启动拼接classPath时会去重提取新版应该不影响)。
拿 GTNH 走完整个链路后的合并结果举例(libraries 有几十项就省略了):
1 | { |
mainClass:原版是net.minecraft.client.main.Main,被后面的补丁覆盖成了 RFB 引导类;minecraftArguments:还是旧格式的参数串,Forge 补丁的+tweakers在末尾追加了--tweakClass;javaVersion:compatibleJavaMajors [17,21,23,24,25]换算出majorVersion = 17。1.7.10 本来是跑在 Java 8 上的,是 lwjgl3ify 这类深度魔改把它推到了 Java 17+——启动器如果不知道这件事,默认选个 Java 8 就直接寄了。
最后还有一步收尾:把库里只有 maven 坐标、没有下载信息的条目物化成 Mojang 格式的 downloads.artifact:
1 | /// 物化 maven-only 库的下载信息(Mojang 格式的 downloads.artifact) |
5.落盘安装
到这里一份标准的官方版本 JSON 就诞生了。
完整的流程:
flowchart TD
A["读取 mmc-pack.json + instance.cfg"] --> B["加载组件补丁
patches/ → 实例根 → meta.multimc.org"]
B --> C["递归补齐 requires 依赖闭包"]
C --> D["以 Mojang 官方版本 JSON 为 base 依序合并"]
D --> E["写出 versions/{实例名}/{实例名}.json"]
E --> F["拷贝内嵌库到共享 libraries/"]
F --> G["对照合并结果扫描缺失文件
主 jar / 库 / 资源 → 下载"]
G --> H["拷贝 .minecraft 用户内容
到版本隔离目录"]
H --> I["收尾:图标 / JVM 参数 / 实例记录"]
剩下的流程和安装一个普通版本几乎没有区别:
1 | // merged = 第 4 节合并好的版本 JSON,jvm_args = 组件收集的 +jvmArgs |
几个值得单独说的点:
内嵌库先行。MMC-hint: local 的库要先于下载步骤处理:
1 | /// 拷贝内嵌库:{整合包}/libraries/{文件名} → {GameDir}/libraries/{maven路径} |
顺序很重要——这类库的 url 是空的,如果先跑缺失扫描,扫描器会把空 url 兜底成官方库仓库地址,然后对着一个不存在的地址 404。
用户内容拷贝。整合包 .minecraft/ 下全是用户内容(GTNH 这里是 mods、config、journeymap、resourcepacks 这些),直接拷到实例的版本隔离目录 versions/{实例名}/ (如果不开版本隔离就直接丢游戏文件夹,一般这类整合包安装都要版本隔离):
1 | /// 拷贝 .minecraft/ 的直接子项到版本隔离目录 |
JVM 参数落实例设置。+jvmArgs 拼出来的那一大串参数(GTNH 的几十个 --add-opens)不塞进版本 JSON——MultiMC 的 +jvmArgs 不是 Mojang 标准字段,塞进旧格式 JSON 反而会因为缺 arguments.game 导致启动报错。写进实例自己的 JVM 参数设置里,启动时统一生效。
总结
回顾一下,MMC 整合包安装的本质是一场「格式翻译」:
instance.cfg+mmc-pack.json提供实例元数据和组件清单;- 每个组件按
patches/{uid}.json→ 实例根 →meta.multimc.org三级回退取补丁,再用requires递归补齐依赖闭包; - 以 Mojang 官方版本 JSON 为 base 依序合并(记住
net.minecraft的库表是替换不是合并),产出一份标准官方版本 JSON; - 之后内嵌库先行、下载缺失文件、拷贝用户内容到版本隔离目录,一切回归普通安装管线。
理解了「补丁链合并」这个核心,剩下的都是工程问题:例如加载器怎么映射、内嵌库怎么处理、恶意 uid 怎么防之类。这些可以看看 HMCL 的实现。
说些什么吧!