【声明】本博客所有内容均为个人业余时间创作,所述技术案例均来自公开开源项目(如Github,Apache基金会),不涉及任何企业机密或未公开技术,如有侵权请联系删除
4、【游戏MOD】【骑砍2】跨平台MOD构建环境(四)/:跨平台编译与通用打包
背景
上篇《跨平台MOD构建环境(三)/:只读源码同步、子模块与路径大小写》讲清了流水线第三步 sync.sh:把只读源码 rsync 到 work/<id>/ 副本(--delete 必须排除子模块路径,否则会清空已克隆的子模块),子模块只认 commit(工作区被补丁改脏不触发重克隆),补丁用 --forward 保证幂等,最后用路径修正脚本在「原路径不存在 + 大小写不敏感唯一匹配」的安全条件下修正 Windows 工程的路径大小写。
源码副本就绪后,就剩最后两步:编译 与打包 。本篇讲 compile.sh 与 pack.sh,重点是把整条命令里那几个看似奇怪的参数解释清楚。
骑砍2
🎯 先搞懂 -p::它是 MSBuild 的属性开关
dotnet build 背后是 MSBuild (.NET 的构建引擎)。它读工程文件时,会维护一张「属性表」:名字 → 值。工程里写 $(名字),就是去这张表里取值。
命令行上的 -p:名字=值,就是在构建开始前,往这张表里预先放一个值。
-p 是 property(属性) 的缩写,不是 package,也不是 path。它有以下几种等价写法:
| 写法 | 含义 |
|---|---|
/p:名字=值 |
MSBuild 传统写法(Windows) |
-p:名字=值 |
dotnet build / MSBuild 通用短写法 |
-property:名字=值 |
MSBuild 长写法(dotnet msbuild) |
-p:名字= |
置为空字符串(有意为之) |
p 取的是 property 的首字母,属于命令行工具的短选项惯例;全拼是 -property:(Windows 上也可写 /property:)。
可以把它理解成「给构建过程传参数」:工程文件像函数,-p:名字=值 是调用时传进去的参数,工程里用 $(名字) 接收。
在 MSBuild 里,命令行传入的属性也叫全局属性(global property):它在工程求值前就已确定,优先级最高,能可靠覆盖工程里的默认值。所以它特别适合做「外部开关」------同一份源码,在不同机器、不同游戏版本上构建时,由外层把环境差异传进去,而不必改动工程文件本身。
为什么不直接改工程文件?原因很实际:
- 第三方工程、子模块工程不该为了构建环境去 fork;
- 同一份源码要在不同机器、不同游戏版本上复用;
- 参考程序集路径、宿主能力、是否打包,属于「构建环境」,不属于「MOD 代码」;
- 源码保持只读,环境差异由外层注入,职责更清楚。
🧩 三个属性,三种用途
compile.sh 内部实际执行的 dotnet build 可以简化成下面这条命令:
sh
dotnet build MyMod.sln -c Release \
-p:GamePath=/path/to/refs/ \
-p:EnableWindowsTargeting=true \
-p:ModulePackagePath=
其中 -c Release 是编译配置;三个 -p: 属性名字不同,作用可以归成三类:
| 属性 | 一句话解释 | 类别 |
|---|---|---|
GamePath |
告诉工程「游戏参考程序集放在哪」 | 自定义注入 |
EnableWindowsTargeting |
告诉 .NET SDK「允许在 Linux 上编 Windows 目标」 | 官方开关 |
ModulePackagePath |
告诉工程「不要执行自带的 Windows 打包步骤」 | 自定义开关 |
GamePath:把路径变成参数。 工程引用游戏 DLL 时,路径写成 $(GamePath)/bin/... 这种形式;构建环境把「伪游戏目录」的根路径通过 -p 注入,工程本身不需要知道真实路径。这样换一台机器、换一个版本,只换属性值即可。
EnableWindowsTargeting:打开宿主能力。 net472 是 .NET Framework 4.7.2(Windows 目标);在 Linux 上默认不允许构建,SDK 会报 NETSDK1100。这个属性是 .NET SDK 官方提供的开关,打开后 SDK 才会去准备 Windows 目标包。它不是本项目发明的 hack。
ModulePackagePath:用空值关掉一个步骤。 不少 MOD 工程自带「构建完用 xcopy 把 DLL 拷到游戏目录」的步骤,并且只在 $(ModulePackagePath) 非空时才执行。我们传一个空值,条件不成立,这段 Windows 专属逻辑就被跳过;打包改由通用的 pack.sh 负责。这不是「覆盖某个路径」,而是利用工程自己的条件开关。
🏗 属性在流水线里的位置
把上面那条编译命令放回整条流水线,关系就很清楚了:
text
config/<id>.json
│ 模块、工程、资产
▼
build.sh ──► compile.sh ──► dotnet build -p:...
│
▼
MSBuild 用全局属性求值工程文件
├─ $(GamePath) → 找到参考程序集
├─ $(EnableWindowsTargeting) → 允许 Linux 编 net472
└─ $(ModulePackagePath) → 条件不成立 → 跳过 xcopy
│
▼
bin/<配置>/<TFM>/*.dll ──► pack.sh
compile.sh 的职责只是「把环境信息翻译成属性」,它不解析工程、也不关心 MOD 代码。真正消费这些属性的是 MSBuild 和工程文件;打包器只关心最后产出的 DLL 和资产。
🛠 编译:整解优先,逐工程兜底
编译策略可以概括成一句话:能整解就整解,整解失败再逐工程。

text
先尝试整解
├─ 成功 → 完成
└─ 失败 → 退回逐工程编译
- 整解:把作者提供的 solution 当成一个整体编译;
- 逐工程 :按描述符里的
modules[].project一个个编译。
为什么需要回退?MOD 作者提供的 solution 里,常常包含一些与本 MOD 无关、甚至只在 Windows 上能编的工程。整解可能因为这些「无关工程」失败,但真正要产出的那个工程其实没问题。回退机制让这种情况不至于卡住构建。
⚠️ 跨版本第一坑:<Private>false</Private>
这一条是跨版本移植时最容易踩的坑。若工程按默认方式引用游戏 DLL(Private=true,即 Copy Local),编译时旧的游戏 DLL 会被复制进工程的 bin/ ,并在后续编译中作为引用来源------于是「用 1.4.8 的 DLL 去编 1.5.x 的代码」会假通过,把真实的 API 变更全部掩盖。
修复办法是让所有游戏引用都不复制,用一个 Directory.Build.props 统一处理:
xml
<!-- Directory.Build.props: 游戏 DLL 一律不复制到输出 -->
<Project>
<ItemDefinitionGroup>
<Reference>
<Private>false</Private>
</Reference>
</ItemDefinitionGroup>
</Project>
Directory.Build.props 会被该目录及子目录下的所有工程自动导入,因此无需改动任何 csproj 就能让全部游戏引用变成「不复制」。真正的 API 变更(比如某私有字段被改名)只有在不复制旧 DLL 时才会如实暴露出来。
一个具体例子
假设本地源码目录里混入了一份旧的 1.4.8 游戏 DLL,工程按默认方式引用它:
| 环节 | <Private>true</Private>(默认,错误) |
<Private>false</Private>(正确) |
|---|---|---|
| 编译时引用来源 | 先被拷进工程 bin/,后续编译又引用它 |
直接引用 $(GamePath) 注入的参考程序集 |
| 结果 | 用 1.4.8 DLL 编 1.5.x 代码,假通过 | 真实 API 差异暴露为编译错误 |
| 运行期 | 旧 DLL 被放进模块,与游戏程序集同名不同版本 | 只引用运行时游戏自身的程序集 |
一句话:不复制旧 DLL,编译结果才可信。
🗄 编译产物与副本布局
理解编译与打包的「接头处」,看一眼构建副本的目录结构即可。编译输出落在工程的 bin/<配置>/<tfm>/ 下:
text
work/<id>/
└── source/<Project>/bin/Release/net472/
├── <Mod>.dll # 待打包的主程序集
└── 依赖.dll
打包器的输入就是 work/<id>/<工程目录>/bin/<配置>/<tfm>;它从这里取 assemblies[],再按模块布局组装。因此「找不到编译输出」类报错,本质是这个路径没对上------多半是 TFM(Target Framework Moniker,目标框架标识)或配置名不一致。
📦 打包:按 Bannerlord 模块布局
编译产物是 bin/<配置>/<tfm>/ 下的 DLL,但游戏要的是「模块目录」布局。打包器按描述符 modules[] 逐项组装。

描述符(config/<id>.json)里与打包相关的字段:
| 字段 | 说明 |
|---|---|
modules[].name |
输出模块目录名(即 <模块>/) |
modules[].project |
对应 csproj,用于定位编译输出 |
modules[].assemblies[] |
要放进 bin/Win64_Shipping_Client/ 的 DLL |
modules[].assets[] |
MOD 自带的模块资产目录,内容铺到模块根 |
modules[].sharedAssets[] |
共享库资产目录,内容同样铺到模块根 |
sh
# pack.sh:组装模块目录(示例)
mkdir -p "$_pkg/bin/Win64_Shipping_Client"
cp -f "$_bin/$_dll" "$_pkg/bin/Win64_Shipping_Client/" # 1) 放入 assemblies[]
cp -a "$_buildroot/$_a/." "$_pkg/" # 2) 铺入 assets/sharedAssets
( cd "$_outroot" && zip -qr "$DIST/${_name}.zip" "$_name" ) # 3) 压缩为 zip
如果一个解决方案产出多个模块,modules[] 就写多项,共用同一份编译输出与资产,各自打包成独立模块;共享库通过 sharedAssets 铺进每个模块,从而做到模块自包含。
📌 本篇小结
-p是 MSBuild 的全局属性(property)开关:-p:GamePath注入参考程序集路径、-p:EnableWindowsTargeting=true让 Linux 也能编net472、-p:ModulePackagePath=关闭工程自带的 Windows 打包步骤。compile.sh整解优先、逐工程兜底,避免无关工程拖累。Directory.Build.props的<Private>false</Private>避免旧 DLL 复制进bin/导致「假通过」。pack.sh按modules[]把 DLL 放进bin/Win64_Shipping_Client/、把资产铺到模块根,并压缩成 zip。- 编译与打包都由一份 JSON 描述符(
config/<id>.json)驱动:工程、程序集、资产都在其中声明,脚本据此通用处理,不含 MOD 专属硬编码。
一句话记忆:用 -p: 注入参考程序集路径、打开 Windows 目标、关掉工程自带的 Windows 打包,再按模块布局组装。
到这里,从 SDK、参考程序集、源码同步到编译打包的主链路就完整了。下一篇换个角度,讲那个用来定位跨版本 API 改名的元数据查看工具。
OK,本篇先到这里,如有疑问,欢迎评论区留言讨论,祝各位功力大涨,技术更上一层楼!!!更多内容见下篇 blog