【声明】本博客所有内容均为个人业余时间创作,所述技术案例均来自公开开源项目(如Github,Apache基金会),不涉及任何企业机密或未公开技术,如有侵权请联系删除
7、【MOD开发】【骑砍2】跨平台MOD构建环境(七)/:参考程序集 vs 运行程序集——两套 DLL 的分工
背景
上篇 【MOD开发】【骑砍2】跨平台MOD构建环境(六)/:DLL 里到底装了什么——元数据、IL 与 JIT 把几个底层概念拆开了:DLL 是容器,元数据描述 API,IL 描述方法体,JIT 在运行时把 IL 编译成机器码。
这一篇回到构建环境里最常遇到的一对概念:参考程序集与运行程序集。它们经常同名、同样是.dll,但内容、用途、出现的位置都不一样。第二篇已经介绍过参考程序集,这一篇把两者的分工讲透,并和GamePath、<Private>false</Private>、元数据工具对应起来。
骑砍2
🎯 一句话区分
- 运行程序集:元数据 + IL + 资源,能真正跑起来;游戏安装目录里的 DLL。
- 参考程序集:只有元数据(公开 API),方法体被剥离/替换成占位,不能运行;NuGet refs / targeting pack。
- 两者都是
.dll;区别在内容,不在扩展名。
🏃 运行程序集:真正跑起来的 DLL
运行程序集(也叫实现程序集,implementation assembly)就是游戏运行时真正加载的 DLL。它包含:
- 元数据:类型、成员、签名、特性、引用;
- IL:每个方法的具体指令;
- 资源:字符串、图片等。
在 Windows 上,它们通常位于:
<游戏安装目录>/bin/Win64_Shipping_Client/*.dll <游戏安装目录>/Modules/<模块>/bin/Win64_Shipping_Client/*.dll游戏启动、加载模块、调用某个方法时,CLR/JIT 会读这些 DLL 里的 IL,把它编译成当前 CPU 的机器码。运行程序集是 JIT 的输入。
我们自己编译出来的 MOD DLL 也是运行程序集:它包含 IL,会被游戏加载,再由 JIT 编译执行。
📇 参考程序集:只给编译器看的 DLL
参考程序集(reference assembly)是专门给编译器用的 DLL。它通常来自:
- NuGet 包,例如
bannerlord.referenceassemblies.*; - .NET 的 targeting pack / reference pack;
- 构建环境自己装配出来的“伪游戏目录”。
它只保留公开 API 的元数据:
- 有哪些类型、成员、签名;
- 哪些是 public / protected;
- 方法参数和返回类型是什么。
方法体被剥离或替换成不可执行的占位(不同工具产物略有差异),所以它:
- 不能运行;
- 通常比运行程序集小很多;
- 不需要加载依赖就能被读取。
在这套环境里,参考程序集通常放在:
vendor/game-refs/<游戏版本>/bin/Win64_Shipping_Client/*.dll output/refs/<游戏版本>/bin/Win64_Shipping_Client/*.dll注意:它们和游戏安装目录里的运行程序集同名(比如都叫TaleWorlds.Core.dll),但来源和内容不同,不能混用。
🆚 对比表
| 维度 | 参考程序集 | 运行程序集 |
|---|---|---|
| 用途 | 编译期 | 运行期 |
| 内容 | 元数据(公开 API) | 元数据 + IL + 资源 |
| 方法体 | 无 / 占位 | 有 IL |
| 能否运行 | 不能 | 能 |
| 来源 | NuGet refs / targeting pack | 游戏安装目录 / 发布包 |
| 大小 | 小 | 大 |
| 喂给谁 | 编译器 | CLR / JIT |
🍽 一个类比:菜单 vs 完整菜谱
把程序集想成一本菜谱:
- 参考程序集= 菜单:有哪些菜、配料表、价格;编译器只需要知道“有没有这道菜、参数是什么”。
- 运行程序集= 完整菜谱 + 厨房:真正做菜时要看做法;运行时才需要 IL。
也可以类比成 C/C++ 的“头文件 vs 实现”,但要注意:C# 的参考程序集仍然是程序集(有元数据、有 PE 结构),不是纯文本头文件。类比只是为了帮助理解“声明与实现分离”。
🧠 为什么编译用参考程序集
编译器做类型检查,只需要“类型和成员的签名”,不需要方法体。用参考程序集至少有几个好处:
- 快:不用加载依赖,也不用解析 IL;
- 干净:只暴露公开 API,不会让代码意外依赖私有实现;
- 版本清晰:编译期对着哪个版本的 refs,就检查哪个版本的 API;
- 跨版本:换游戏版本时,换一套 refs 重新编译即可,不用把整个游戏装到构建机;
- 避免“假通过”:不会因为旧的运行程序集被复制进
bin/,而用旧 DLL 编新代码。
这里要特别提<Private>false</Private>:它让游戏 DLL 引用不复制到工程的bin/。如果复制了旧的运行程序集,编译器可能拿它当引用来源,于是“用旧 DLL 编新代码”也能通过,真实的 API 变更就被掩盖了。参考程序集本身也不应该被复制进 MOD 输出——它只是编译期的“合同”。
一句话:编译看合同(参考程序集),运行看实现(运行程序集)。
🛠 在这套构建环境里怎么对应
把前面的概念落到这套构建环境里:
| 环节 | 用哪套 DLL | 说明 |
|---|---|---|
| 编译 | 参考程序集 | -p:GamePath=<refs>/指向“伪游戏目录” |
| 引用复制 | 不复制 | <Private>false</Private>避免旧 DLL 进bin/ |
| 元数据查询 | 参考程序集 | 第五篇的meta.sh只读元数据,不需要 IL |
| 运行 | 运行程序集 | 游戏加载自己的 DLL,再 JIT 编译 |
| MOD 产物 | 运行程序集 | 我们编译出的 DLL 也含 IL,由游戏加载并 JIT |
用一张图表示:
编译期 参考程序集(元数据) │ 编译器 ▼ Mod.dll(元数据 + IL) 运行期 游戏运行程序集(元数据 + IL) ┐ ├─ CLR / JIT ──► 机器码 Mod.dll(元数据 + IL) ┘MOD DLL 在编译时引用的是参考程序集的“身份”(程序集名、版本、类型签名);运行时,游戏提供同名的运行程序集,JIT 再把双方的 IL 编译成机器码。
🔍 怎么自己验证
想确认一份 DLL 是参考程序集还是运行程序集,可以看几个地方:
- 看大小:参考程序集通常明显更小;
- 看方法体:用 ILSpy / ildasm 打开,运行程序集能看到 IL,参考程序集没有方法体或只有占位;
- 看来源:
vendor/game-refs/<ver>、output/refs/<ver>下的是 refs;游戏安装目录bin/Win64_Shipping_Client下的是运行程序集; - 看特性:有些参考程序集带
[assembly: ReferenceAssembly],但不是所有都有; - 用元数据工具:
meta.sh只读元数据,两套都能读;它看到的是 API,不是行为。
⚠️ 常见误区
- “参考程序集不是 DLL”:不对,它也是 DLL,只是被裁剪过。
- “DLL 一定可执行”:不对,参考程序集就不能运行。
- “运行时也需要参考程序集”:不需要;运行时用游戏自己的运行程序集。
- “编译必须用运行程序集”:不一定;参考程序集就是专门给编译器用的。
- “同名 DLL 就是同一个”:不对;同名但来源不同,内容可能完全不同。
- “用 refs 编译,运行时就不用管版本了”:不对;运行时必须提供匹配的运行程序集,API 变更仍可能导致运行期问题。
📌 本篇小结
- 运行程序集 = 元数据 + IL + 资源,能运行;游戏安装目录里的 DLL。
- 参考程序集 = 只有元数据(公开 API),方法体剥离/占位,不能运行;NuGet refs / targeting pack。
- 两者都是
.dll;同名不同源,区别在内容。 - 编译用参考程序集,运行用运行程序集;
GamePath指向 refs,<Private>false</Private>避免旧 DLL 复制进bin/。 - 元数据工具只读元数据,所以用参考程序集就够了。
一句话记忆:编译看参考程序集,运行用运行程序集;同名不同源,都是 DLL。
到这里,DLL、元数据、IL、JIT、参考程序集、运行程序集这条概念链就闭合了。后面如果再展开崩溃日志判读、产物部署、跨版本移植复盘,这些概念会一直用得上。
OK,本篇先到这里,如有疑问,欢迎评论区留言讨论,祝各位功力大涨,技术更上一层楼!!!更多内容见下篇 blog