【声明】本博客所有内容均为个人业余时间创作,所述技术案例均来自公开开源项目(如Github,Apache基金会),不涉及任何企业机密或未公开技术,如有侵权请联系删除
8、【MOD开发】【骑砍2】跨平台MOD构建环境(八)/:游戏根 bin 与模块 bin------运行程序集的两种归属
背景
上篇 【MOD开发】【骑砍2】跨平台MOD构建环境(七)/:参考程序集 vs 运行程序集------两套 DLL 的分工 把一对容易混淆的概念拆开了:运行程序集包含元数据、IL(Intermediate Language,中间语言)和资源,能够真正运行;参考程序集只保留公开 API 的元数据,方法体被剥离或替换成占位,不能直接运行。
上篇还给出两条实践结论:编译期使用参考程序集,运行期由游戏提供同名的运行程序集;<Private>false</Private> 阻止游戏 DLL 被复制进工程输出,避免"用旧 DLL 编新代码"的假通过。
不过,上篇把游戏安装目录里的 DLL 统一归入运行程序集,并列出了两个路径:
text
<游戏安装目录>/bin/Win64_Shipping_Client/*.dll
<游戏安装目录>/Modules/<模块>/bin/Win64_Shipping_Client/*.dll
这两个路径里的 DLL 确实都是运行程序集,但它们的归属、加载时机和依赖方向并不相同。上篇没有展开这层差异,本篇继续把运行程序集拆成两种归属。
骑砍2
🎯 先给结论
| 维度 | 结果 | 用途 |
|---|---|---|
| 共同点 | 都是运行程序集 | 都含元数据与 IL,能参与运行 |
| 根 bin | 游戏宿主与引擎的启动基础目录 | 提供始终存在的核心 API 与原生依赖 |
| 模块 bin | 单个模块的作用域目录 | 承载该模块自己的程序集与私有依赖 |
一句话概括:根 bin 管启动,模块 bin 管模块;两者不是参考程序集与运行程序集的区别,而是运行程序集的两种归属。
🗂 根 bin:宿主与启动层
根 bin 位于游戏可执行文件旁边:
text
<游戏安装目录>/bin/Win64_Shipping_Client/
它是 .NET 应用的基础目录,也是宿主程序集解析的默认来源。游戏启动链路需要的核心程序集从这里就绪,例如 TaleWorlds.Core.dll、TaleWorlds.CampaignSystem.dll、TaleWorlds.MountAndBlade.dll,以及负责模块加载的 TaleWorlds.ModuleManager.dll。
根 bin 不只包含托管程序集。本次检查的 1.5.4 环境里,根 bin 共有 85 个 DLL,其中 57 个是托管程序集,28 个是原生 DLL。原生部分包括 PhysX、Steam、EOS、音频和图形相关依赖,它们和 C# 程序集一起构成游戏宿主。

图 1 游戏根 bin 与模块 bin 的目录分工
需要特别区分一个常见误解:根 bin 不等于"全部引擎程序集"。例如 TaleWorlds.MountAndBlade.GauntletUI.dll、TaleWorlds.MountAndBlade.View.dll 位于 Modules/Native/bin/Win64_Shipping_Client/。引擎的核心 API 在根 bin,部分视图层和内容层程序集则按模块归属。
因此,根 bin 的边界不是"所有 TaleWorlds 开头的 DLL",而是启动宿主需要、且不依赖具体模块激活的程序集。
🧩 模块 bin:模块作用域
模块 bin 位于模块自己的目录下:
text
<游戏安装目录>/Modules/<模块>/bin/Win64_Shipping_Client/
它不是社区 Mod 专用目录。Native、SandBox、StoryMode、NavalDLC、Multiplayer 这些官方模块同样使用该路径。官方模块与社区模块的差别在 ModuleType 和发布者,不在 bin 的位置。
模块 bin 的内容由模块清单决定。以 Treasury 为例,SubModule.xml 在 Modules/Treasury/SubModule.xml:26-36 声明了 DLL 名称和入口类:
xml
<SubModules>
<SubModule>
<Name value="TreasurySubModule" />
<DLLName value="Treasury.dll" />
<SubModuleClassType value="Treasury.TreasurySubModule" />
</SubModule>
</SubModules>
DLLName 指向模块 bin 下的主程序集,SubModuleClassType 指向模块加载后要实例化的入口类型。模块目录还可以携带自己的依赖,例如 RTSCamera 的 bin 中同时存在 0Harmony.dll、MissionLibrary.dll 和 RTSCameraAgentComponent.dll。
这类依赖与根程序集不同:它们只服务于当前模块,或者服务于一组需要一起加载的模块。把它们放进根 bin,会让模块依赖变成全局存在,破坏模块启用与禁用之间的边界。
📊 本次安装的实测对比
本次检查的安装环境包含原版模块、NavalDLC 和若干社区模块,统计结果如下:
| 维度 | 结果 | 用途 |
|---|---|---|
| 根 bin 文件数 | 85 | 其中 57 个托管程序集、28 个原生 DLL |
| 模块 bin 文件数 | 61 | 全部为托管程序集 |
| 两边同名 DLL | 0 | 未发现根 bin 与模块 bin 的文件名重叠 |
| 模块 bin 的组成 | 官方模块 + 社区模块 | 说明该路径不是 Mod 专属 |
"没有同名重叠"这一点很关键。它说明两个目录不是同一批 DLL 的复制关系,而是两套不同的程序集集合。根 bin 提供宿主能力,模块 bin 提供模块能力。
这些数字会随游戏版本、DLC 和已安装 Mod 变化,不能当作固定常量;但"根 bin 是宿主基础目录、模块 bin 是模块作用域目录"这个结构关系不会变。
🧩 模块加载链路
模块加载从可执行文件开始,经过根 bin 进入模块管理器,再读取模块清单。

图 2 从启动到模块 DLL 的加载链路
TaleWorlds.ModuleManager 的元数据中有 ModuleInfo、SubModuleInfo、GetSortedModules、GetActiveGameAssemblies 等成员。ModuleInfo 保存模块的 FolderPath、SubModules、DependedModules,SubModuleInfo 保存 DLLPath、DLLName 和 SubModuleClassTypeName。
加载链路可以概括为:
text
Bannerlord.exe
→ 根 bin 的核心程序集就绪
→ ModuleManager 扫描 Modules
→ 读取 SubModule.xml
→ 按 DependedModules / ModulesToLoadAfterThis 排序
→ 激活模块并加载模块 bin 中的 DLL
根程序集先就绪,模块程序集只在对应模块被激活后进入加载集合。模块清单中的依赖声明决定加载顺序,也决定某个模块能否在缺少前置模块时启动。
Treasury 的 SubModule.xml:8-25 声明了 Native、SandBoxCore、Sandbox、CustomBattle、StoryMode 依赖,并给 Bannerlord.Harmony、Bannerlord.ButterLib、Bannerlord.MCM 设置了可选的 LoadBeforeThis 顺序。模块管理器据此排序,而不是按文件夹名的字典序加载。
🔗 引用方向决定依赖边界
将根 bin 与模块 bin 中的程序集拆开看,引用方向是单向的:模块程序集引用根程序集,根程序集不反向引用具体模块。

图 3 模块程序集引用根程序集,反向不成立
抽查结果可以说明这一点:
| 维度 | 结果 | 用途 |
|---|---|---|
TaleWorlds.CampaignSystem.dll |
引用 TaleWorlds.Core、TaleWorlds.Library 等根程序集 |
核心系统不依赖 SandBox |
SandBox.dll |
引用 TaleWorlds.CampaignSystem、TaleWorlds.MountAndBlade 等根程序集 |
内容模块依赖宿主 API |
RTSCamera.dll |
引用根程序集,同时引用 SandBox、0Harmony、MissionLibrary |
模块可以使用其他模块和私有依赖 |
这个方向是工程上的必然选择。引擎不可能在编译期知道未来会安装哪些模块,因此根程序集不能引用模块程序集;模块则可以引用稳定的宿主 API,并在清单中声明自己需要的前置模块。
反过来,如果把模块 DLL 放进根 bin,宿主仍然不会自动加载它:没有模块清单,就没有 DLLName 和 SubModuleClassType 的绑定关系;即使因为其他引用被加载,也会绕过模块启用状态和依赖排序。
⚠️ 同名 DLL 与程序集身份
模块 bin 可以携带私有依赖,但这不意味着可以直接复制引擎 DLL。
模块之间还出现了两组同名程序集:
0Harmony.dll出现在RTSCamera、RTSCamera.CommandSystem、UnlimitedClanLimits三个模块里,三份文件哈希相同;TaleWorlds.MountAndBlade.Multiplayer.dll出现在CustomBattle和Multiplayer两个模块里,文件名、大小和版本都相同,但 SHA-256 不同。
后一组更能说明问题:**同名、同版本不等于同一份内容。**仅凭文件名或版本号判断程序集身份并不可靠。
抽查的 TaleWorlds 程序集没有强名称,PublicKeyToken 为空,版本统一为 1.0.0.0。在这样的前提下,同名程序集一旦进入同一个加载上下文,通常只能保留一份;最终使用哪一份,会受加载顺序影响。
如果两份程序集进入不同的加载上下文,还会出现更隐蔽的问题:类型名称看起来相同,但类型身份不同,跨上下文传递对象时可能触发类型转换失败。表现形式可能是 TypeLoadException、MissingMethodException 或 InvalidCastException,而不是直观的"DLL 找不到"。
因此,模块 bin 可以放模块自己的依赖,但不应该复制 TaleWorlds.* 根程序集。引擎 DLL 由游戏安装提供,模块只引用其公开 API。
🛠 正确的产物放置规则
根据上面的边界,Mod 产物的放置规则可以整理成一张表:
| 维度 | 结果 | 用途 |
|---|---|---|
| Mod 主程序集 | Modules/<模块>/bin/Win64_Shipping_Client/ |
由模块清单加载 |
SubModule.xml |
模块根目录 | 声明依赖、DLL 与入口类 |
TaleWorlds.* |
不复制 | 使用游戏根 bin 提供的运行时程序集 |
| 第三方依赖 | 模块 bin 或独立依赖模块 | 跟随模块发布,避免污染根 bin |
| 根 bin | 不修改 | 由游戏自身维护,随更新覆盖 |
编译阶段继续使用参考程序集,并通过 <Private>false</Private> 阻止引擎引用被复制到输出目录。这样产出的 ZIP 只包含 Mod 自己的 DLL、SubModule.xml 和资产文件,部署时直接解压到 Modules/<模块>/。
如果 Mod 依赖 Harmony、MCM 或某个前置模块,正确做法是声明依赖并按该依赖自身的发布方式安装,而不是把依赖 DLL 丢进根 bin。根 bin 是共享的启动目录,任何额外写入都可能影响其他模块,也会在游戏更新或完整性校验时被覆盖。
📌 小结
- 两个路径里的 DLL 都是运行程序集,不是参考程序集。
- 根 bin 是宿主与启动基础目录,包含核心托管程序集和原生依赖。
- 模块 bin 是模块作用域目录,官方模块和社区 Mod 都使用它。
- 模块程序集引用根程序集,根程序集不反向引用具体模块。
- 同名同版本的程序集也可能内容不同,不能把文件名或版本号当作唯一身份。
- Mod 主程序集放模块 bin,引擎 DLL 不复制,依赖按模块方式交付。
一句话记忆:根 bin 提供宿主,模块 bin 提供模块;编译看参考程序集,运行看两套运行程序集。
OK,本篇先到这里,如有疑问,欢迎评论区留言讨论,祝各位功力大涨,技术更上一层楼!!!更多内容见下篇 blog