Godot + .NET 内部机制:PCK 文件、双运行时,以及反编译 C# 为什么那么难看
写给 Slay the Spire 2 模组作者和 Godot C# 开发者的技术深入笔记。
疑问:为什么 C# 出现在两个地方?
如果解包一个 Godot 4 C# 游戏,比如 Slay the Spire 2,你会注意到一个奇怪的现象:C# 似乎同时存在于两个地方。
sts2.pck:Godot 的资源打包归档。sts2.dll:放在可执行文件旁边的 .NET 程序集。
看起来像是重复,其实不是。它们服务于两个完全不同的使用方。
每个文件用来做什么
.pck 文件
.pck 是 Godot 专用的虚拟文件系统归档,本质上像一个压缩包,装着引擎所需的一切:场景、纹理、音频、着色器,还有 C# 源文件。游戏启动时,Godot 会挂载 .pck,通过自己的虚拟文件路径系统(res://)提供对内容的访问。
这里打包的 C# 文件是供 Godot 自身使用的,用于工具、编辑器和资源一致性。在 Godot 4 C# 项目中,原始 .cs 源文件会原样打包进去。
.dll 文件
.dll 是标准的 .NET 程序集,包含编译后的 IL 字节码,由 CoreCLR 运行时加载执行。它放在磁盘上、游戏可执行文件旁边,是因为 .NET 运行时通过普通的操作系统文件路径查找程序集,而不是通过 Godot 的虚拟文件系统。它不认识 .pck 文件。
整体流程
Source .cs files
|
v (Roslyn compiler)
sts2.dll <--- CoreCLR loads this for execution
|
v (bundled into)
sts2.pck <--- Godot mounts this as its virtual filesystem
同一个程序集,两个使用方,两个位置。
并行运转的两个运行时
这部分很容易让人困惑。运行 Godot 4 C# 游戏时,两个独立的运行时同时在工作。
Godot 引擎运行时
Godot 负责:
- 场景树、节点、物理和渲染。
- 自己的虚拟文件系统,
.pck就挂载在这里。 - 通过
GD.Load<T>()和res://路径加载资源。 - 游戏主循环和信号系统。
.NET 运行时(CoreCLR)
微软的 CoreCLR 负责:
- 实际执行编译后的 C# IL 字节码。
- 垃圾回收和内存管理。
- 程序集解析与加载。
- 类型系统和反射。
两者如何连接
Godot 本身不执行 C#,而是嵌入 CoreCLR,由它承载这个运行时,类似 Unity 嵌入 Mono 或 IL2CPP。Godot 启动时会引导 CoreCLR 启动,两侧通过名为 GodotSharp 的原生互操作桥接层通信。
Godot Engine
|
|-- starts up, mounts sts2.pck
|
|-- initializes CoreCLR as embedded host
| |
| `-- CoreCLR loads sts2.dll from disk
| `-- your C# code runs here
|
|-- calls into C# via GodotSharp bindings
`-- C# calls back into Godot via the same bridge
你的 C# 模组代码由 CoreCLR 执行,但操作的 Node、Resource、PackedScene 等对象属于 Godot 一侧,通过 GodotSharp 桥接层访问。任何一个运行时崩溃,整个程序都会出问题。
为什么从 PCK 提取的 C# 干净得多
做模组时很快就会发现:用 gdre_tools 从 .pck 提取的 C# 清晰易读;用 ILSpy 或 dnSpy 反编译 .dll,得到的代码却难看得多。同一个游戏,同一份代码,为什么会这样?
.pck 包含什么
gdre_tools 从 .pck 提取 C# 时,恢复的是 Godot 自己存进去的文件。在 Godot 4 C# 项目中,原始 .cs 源文件会直接打包进 .pck,没有经过任何转换。拿到的内容很接近原始源码,甚至就是原始源码。
.dll 包含什么
.dll 包含编译后的 IL 字节码。源码已经经过 Roslyn 编译器处理;如果是 Release 构建,还经过优化器处理:
.cs source
|
v Roslyn compiler
IL bytecode + metadata
|
v Release optimizations
|-- method inlining
|-- dead code elimination
`-- local variable merging
|
v
stored in .dll
把它交给反编译器时,反编译器做的是逆向工程,也就是从 IL 重建 C#。这个过程本来就会丢失信息:
| 丢失的内容 | 反编译输出中的结果 |
|---|---|
| 变量名 | local_0、V_3、b__4 |
| 注释 | 完全消失 |
| Lambda / 闭包 | 展开成生成的类(<>c__DisplayClass) |
| LINQ 表达式 | 展开成状态机 |
| Async/await | 显式的状态机结构体 |
| 编译器提示 | 全部显式呈现,显得很难看 |
一眼看懂
从 .pck 提取 |
反编译 .dll |
|
|---|---|---|
| 来源 | Godot 打包的原始 .cs |
IL → 重建的 C# |
| 变量名 | 原本写下的真实名称 | 丢失或被改写 |
| 注释 | 保留 | 消失 |
| Lambda | 简洁的一行表达式 | 难看的生成类 |
| 异步方法 | 清晰 | 显式状态机 |
| 准确性 | 原始源码 | 近似重建 |
给模组作者的实用建议
逆向 Godot C# 游戏时,.pck 是首要目标。gdre_tools 可以直接从中提取清晰、可读、接近原始版本的源码。
反编译 .dll 是后备方案:当某些内容没有打包进 .pck,或者你需要在 IL 层面核实实际执行的内容时,它很有用。但对于一般的模组开发和游戏逻辑理解,每次都应该先从提取 .pck 开始。
文中提到的工具
- gdre_tools / gdsdecomp:提取
.pck的合适工具;支持 Godot 3 和 4,能从编译后的.gdc字节码恢复 GDScript,也能从.pck提取 C# 源码。 - ILSpy / dnSpy:.NET 反编译器,用于在 IL 层面检查
.dll。 - GodotSharp:连接 Godot 与 .NET 运行时的原生互操作桥接层。