4、【游戏MOD】【骑砍2】跨平台MOD构建环境(四)/:跨平台编译与通用打包

【声明】本博客所有内容均为个人业余时间创作,所述技术案例均来自公开开源项目(如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):它在工程求值前就已确定,优先级最高,能可靠覆盖工程里的默认值。所以它特别适合做「外部开关」------同一份源码,在不同机器、不同游戏版本上构建时,由外层把环境差异传进去,而不必改动工程文件本身。

为什么不直接改工程文件?原因很实际:

  1. 第三方工程、子模块工程不该为了构建环境去 fork;
  2. 同一份源码要在不同机器、不同游戏版本上复用;
  3. 参考程序集路径、宿主能力、是否打包,属于「构建环境」,不属于「MOD 代码」;
  4. 源码保持只读,环境差异由外层注入,职责更清楚。

🧩 三个属性,三种用途

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

【游戏MOD】【骑砍2】跨平台MOD构建环境(五)/:元数据查看工具与跨版本适配

相关推荐
HIT_Weston1 天前
3、【MOD开发】【骑砍2】跨平台MOD构建环境(三):只读源码同步、子模块与路径大小写
mod开发·骑砍2
HIT_Weston2 天前
1、【MOD开发】【骑砍2】跨平台MOD构建环境(一):总体思路与 SDK 引导
mod开发·骑砍2
霸王•吕布3 年前
骑砍战团MOD开发(39)-RTS塔防保卫卡拉迪亚大陆
游戏开发·骑马与砍杀·骑砍战团·mod开发·rts塔防·rts视角·rts游戏