不加载DLL如何读取版本?Lumafly用Mono.Cecil解析Assembly-CSharp.dll完整原理
【免费下载链接】LumaflyA cross platform mod manager for Hollow Knight written in Avalonia.项目地址: https://gitcode.com/gh_mirrors/lu/Lumafly
Lumafly 是一款跨平台的《空洞骑士》(Hollow Knight)Mod 管理工具(Mod Manager),基于 Avalonia 开发。它最核心的设计之一是:检测 Modding API 版本时,从不把游戏的 Assembly-CSharp.dll 加载进内存执行,而是用 Mono.Cecil 以"只读模式"解析这个 .NET 程序集的元数据,从中提取 API 版本号。本文带你用源码走一遍"不加载 DLL 也能读版本"的完整原理。
为什么不能直接"加载" Assembly-CSharp.dll 来读版本?
直觉上,读取版本号最简单的做法是Assembly.Load加载 DLL,然后反射读字段。但对 Lumafly 来说这条路走不通,原因有三:
- 依赖缺失会直接崩溃。Assembly-CSharp.dll 是 Unity 游戏的核心程序集,它引用了 Unity 引擎的大量运行时库。在 Lumafly 的进程里加载它,找不到依赖程序集会直接抛异常。
- 副作用不可控。加载程序集可能触发静态构造函数、注册事件、写文件等隐藏逻辑,Mod 管理器不能容忍这种"读个版本号反而动了游戏"的行为。
- 版本文件需要频繁探测。Lumafly 每次启动、每次切换 Mod 开关时都要判断当前文件是原版还是打入了 API,加载方式太重、太危险。
🔍 正确的姿势是:不执行代码,只读元数据。这正是 Mono.Cecil 的用武之地。
Mono.Cecil:一个"只读"的 .NET 程序集解析器
Mono.Cecil 是 .NET 生态中经典的程序集操作库,它可以直接解析 PE 二进制文件(也就是 .dll),读取其中的类、方法、字段等 IL 元数据——全程不需要 JIT 编译,更不会执行任何一行游戏代码。
Lumafly 在 Lumafly.csproj 中引用了 Mono.Cecil 0.11.5:
<PackageReference Include="Mono.Cecil" Version="0.11.5" />它相当于给程序集开了一个"透明窗口":你可以像查目录一样翻阅 DLL 内部结构,但不会"唤醒"里面的任何代码。⚡
四步提取 API 版本号:完整解析流程
核心逻辑集中在 CheckValidityOfAssembly.cs 的GetAPIVersion方法中,整个过程只有四步:
第一步:读取程序集元数据。调用AssemblyDefinition.ReadAssembly(asm),Cecil 打开 DLL 文件并解析出类层次结构,此时还没有执行任何游戏代码。
第二步:定位 Modding.ModHooks 类。通过asmDefinition.MainModule.GetType("Modding.ModHooks")查找 API 的"签名类"。这个类是 Modding API 打入游戏后留下的标记——原版游戏里根本不存在它。
第三步:找到 _modVersion 字段。在 ModHooks 的字段列表中筛选出名为_modVersion的字段,并用IsLiteral校验它确实是一个常量字段。
第四步:取出常量值。返回(int) ver.Constant,版本号到手。
核心代码极其精炼:
using AssemblyDefinition asmDefinition = AssemblyDefinition.ReadAssembly(asm); var modhooks = asmDefinition.MainModule.GetType("Modding.ModHooks"); var ver = modhooks.Fields.FirstOrDefault(x => x.Name == "_modVersion"); return (int)ver.Constant;💡 注意整个方法包在 try-catch 里,任何异常都静默返回null。这是一个刻意的防御设计:文件损坏、格式不对都不能让管理器崩溃,"读不到"本身就是一种有效信息(后面会用到)。
版本号的妙用:三文件切换机制
在 Installer.cs 中,Lumafly 定义了三个关键常量,对应 Managed 目录下的同一份主程序集:
| 常量 | 文件名 | 含义 |
|---|---|---|
Current | Assembly-CSharp.dll | 游戏当前实际运行的文件 |
Vanilla | Assembly-CSharp.dll.v | 备份的原版文件 |
Modded | Assembly-CSharp.dll.m | 备份的已注入 API文件 |
切换 Mod 开关的本质就是:把Current替换为.v(原版)或.m(Mod 版)文件。那么管理器怎么知道"现在当前是哪种状态"?答案还是靠GetAPIVersion这个不加载的探测:
Assembly-CSharp.dll里有ModHooks 类 → 当前是 Mod 模式,直接读出版本号;- 没有ModHooks 类 → 当前是原版,再去
.m文件里读版本号,并把启用状态标记为false; - 两个文件都读不到 → API 未安装,记录为
NotInstalledState。
这套判断逻辑在 Installer.cs 的 CheckAPI 方法 中实现,读到的版本号会被封装成InstalledState持久化下来,界面上的 API 开关状态、版本号显示都源于此。
另外还有个巧思:CheckVanillaFileValidity 用"文件存在且GetAPIVersion 返回 null"来判定一份原版备份是否"干净"——读不到版本号在这里反而变成了校验条件。
第二个实战场景:自动定位 Mod 配置文件
不加载 DLL 的解析能力在 GlobalSettingsFinder.cs 中还有第二个妙用。
很多 Mod 的设置界面由"设置 Mod 类"驱动,配置文件保存为类名.GlobalSettings.json。但 Mod 的显示名称和它的类名经常对不上(比如显示名没有空格、类名带了额外后缀)。Lumafly 的对策是:
- 用
AssemblyDefinition.ReadAssembly(dll)解析 Mod 自己的 DLL; - 遍历其中所有非抽象类,检查基类是否以
Modding.Mod、SFCore.Generics.SaveSettingsMod等已知基类开头; - 对每个候选类名,去存档目录找是否存在对应的
.GlobalSettings.json; - 命中即返回该文件名,实现配置文件自动定位。
全程只读元数据,零执行风险。这个功能的正确性由 MiscServicesTest.cs 中的FindSettingsFile用例覆盖。
Lumafly 如何测试"读版本"功能
测试 GetAPIVersion 用例 的做法很聪明:仓库里放了一个最小化的桩文件 MockMAPI.dll——它只是一个包含Modding.ModHooks类、且类里有_modVersion常量字段的小 DLL:
var version = _checkValidityOfAssembly.GetAPIVersion("MockMAPI.dll"); Assert.Equal(74, version);测试断言解析出的版本号必须是 74,从而验证了"ReadAssembly → 找类 → 找字段 → 读常量"这条链路在真实文件上完全可用。📦
总结:这个技巧为什么值得借鉴
Lumafly 的方案回答了"不加载 DLL 如何读取版本"这个问题,核心经验有三点:
- 用 Mono.Cecil 做静态元数据解析,替代
Assembly.Load的动态加载,彻底规避依赖缺失与代码执行副作用; - 把"解析失败返回 null"当成设计契约,让同一个探测函数既能读版本,又能判断 Mod/Vanilla 状态,一石二鸟;
- 用最小桩 DLL 做单元测试,把对真实二进制文件的解析逻辑纳入自动化测试。
这套"只读不执行"的思路不只适用于 Mod 管理——任何需要探测、校验 .NET 程序集内部信息的工具,都可以直接参考 CheckValidityOfAssembly.cs 这个不到 50 行的实现。
【免费下载链接】LumaflyA cross platform mod manager for Hollow Knight written in Avalonia.项目地址: https://gitcode.com/gh_mirrors/lu/Lumafly
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考