8、【MOD开发】【骑砍2】跨平台MOD构建环境(八)/:游戏根 bin 与模块 bin——运行程序集的两种归属

【声明】本博客所有内容均为个人业余时间创作,所述技术案例均来自公开开源项目(如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

【MOD开发】【骑砍2】运行内存:OOM 现场与虚拟内存术语

相关推荐
HIT_Weston12 天前
5、【游戏MOD】【骑砍2】跨平台MOD构建环境(五)/:元数据查看工具与跨版本适配
mod开发·骑砍2
HIT_Weston12 天前
4、【游戏MOD】【骑砍2】跨平台MOD构建环境(四)/:跨平台编译与通用打包
mod开发·骑砍2
HIT_Weston13 天前
3、【MOD开发】【骑砍2】跨平台MOD构建环境(三):只读源码同步、子模块与路径大小写
mod开发·骑砍2
HIT_Weston14 天前
1、【MOD开发】【骑砍2】跨平台MOD构建环境(一):总体思路与 SDK 引导
mod开发·骑砍2
霸王•吕布3 年前
骑砍战团MOD开发(39)-RTS塔防保卫卡拉迪亚大陆
游戏开发·骑马与砍杀·骑砍战团·mod开发·rts塔防·rts视角·rts游戏