LeanCLR 适配 .NET 10:一个完全跨平台,跨引擎的C#运行时
对普通 C# 项目来说,切到新版本通常就是改个 SDK、重新编译一下。LeanCLR 这种自研 runtime 会更底层一些:它要看懂我们写的业务代码,也要看懂 .NET 10 基础类库到底在向 runtime 要什么。
LeanCLR 现在已经有自己的 metadata loader、类型系统、对象模型、解释器、GC、异常、委托、泛型、基础反射和 AOT 生成框架。接入 .NET 10 的重点,是做一层清晰的 BCL / runtime contract 适配:让 LeanCLR 能在明确边界内跑 net10.0 纯逻辑程序集;暂时跑不了的 BCL 能力,也给出清楚的诊断,问题能早一点暴露出来。
为什么专门适配 .NET 10
如果只是想“跑一点 C# 逻辑”,LeanCLR 兼容旧版 corlib,或者自己做一套很小的基础库,也能用。目标一旦变成商业项目、跨引擎迁移、长期维护,.NET 10 就成了新的基线。后面的工具、库、语言特性和项目习惯,都会慢慢围着它走。
第一,.NET 10 是 LTS。按照 Microsoft 的 .NET 10 发布说明 和 .NET 8 / .NET 9 生命周期公告,.NET 10 会长期支持,.NET 8 和 .NET 9 会在 2026 年 11 月 10 日结束支持。LeanCLR 盯住一个 LTS 做 profile,可以少很多“刚适配完又要迁移”的麻烦,也方便商业项目安排工具链、CI、依赖审计和发布节奏。
第二,.NET 10 对应当前这一代 C#、SDK、BCL 和 runtime contract。C# 14 有 extension members、field-backed properties、null-conditional assignment、更多 Span<T> 隐式转换;.NET 10 SDK 也在继续改善 tool、restore、publish、native AOT 和诊断体验。这些东西 LeanCLR 第一阶段未必要完整支持,它们会影响我们写代码的方式、编译出来的 metadata,以及最后会调用到哪些 BCL 入口。对齐 .NET 10 以后,我们就可以继续用现代 C# 写纯逻辑 DLL。
第三,.NET 10 的 runtime 和 BCL 已经代表了现代 .NET 的很多默认形态:对象模型、反射模型、泛型、Span/Memory、async、source generator、AOT 友好 API、安全库。LeanCLR 可以先不追 CoreCLR 的 JIT、线程池、ASP.NET 或完整系统库,先弄清楚现代 BCL 会向 runtime 提哪些要求。单独做一个 coreclr-net10 profile,就是把这些要求画成一张地图:哪些 LeanCLR 自己接,哪些交给宿主 bridge,哪些直接报 NotSupported。
最后,这个选择也直接服务后面的引擎适配。AI 可以帮我们把代码从一个引擎迁到另一个引擎。迁完之后,还需要一个稳定、现代、能部署的托管运行时接住它。.NET 10 给业务代码一个足够新的语言和库基线,LeanCLR 再把这条基线收敛成一个精简可控的 runtime profile。这样,引擎适配模块面对的是一条明确的 net10.0 托管逻辑通道。
如果我们平时主要写业务层 C#,下面先垫几个基础概念。搞清楚这些,再看后面的架构图会轻松很多。
C# 程序会先变成 IL
C# 源码经过 dotnet build 后,通常会生成 .dll 或 .exe 托管程序集。这个程序集里最重要的两类内容是:
IL:Intermediate Language,中间语言。一组面向 .NET runtime 的指令。metadata:描述类型、字段、方法、泛型、特性、程序集引用等信息的数据表。
可以把程序集理解成“代码指令 + 自我介绍”。runtime 加载它以后,先看 metadata,知道里面有哪些类型、字段、方法,再按 IL 指令去执行方法体。
flowchart LR
A["C# source"] --> B["dotnet build"]
B --> C["托管程序集 .dll / .exe"]
C --> D["IL 指令"]
C --> E["metadata: 类型 / 方法 / 字段 / 引用"]
D --> F["runtime 执行"]
E --> F
所以 LeanCLR 要跑一个 C# 程序,最基础的能力是加载程序集、读取 metadata、解释或编译 IL、创建对象、调用方法,再把异常、GC、反射这些运行时状态管起来。C# 语法在编译阶段已经处理完了。
Runtime 和 BCL 分别负责什么
日常写 C# 时,我们会直接用 object、string、List<T>、Task、File、Console、Type 这些类型。它们大多来自 .NET 的基础类库,也就是 BCL。
BCL 需要 runtime 配合,很多底层能力要找 runtime 帮忙。例如:
object.GetType()需要 runtime 知道某个对象真实的运行时类型。new string(...)需要 runtime 按字符串对象布局分配和初始化内存。Array.GetLength()需要 runtime 读取数组对象头里的长度。Type.GetMethods()需要 runtime 能把 metadata 转成反射对象。GC.Collect()需要 runtime 真的执行对象扫描和回收。FileStream、Console这类 API 可能需要进入操作系统或宿主平台。
两者的分工大致是:
| 层次 | 负责什么 | 例子 |
|---|---|---|
| 业务程序集 | 项目自己的业务逻辑 | 游戏逻辑、工具代码、框架核心 DLL |
| BCL | .NET 基础类型和库 API | System.String、System.Collections、System.Reflection |
| runtime | 执行 IL,管理对象、类型、GC、异常、线程、反射和 native 边界 | LeanCLR、CoreCLR、自研托管运行时 |
| 宿主 / OS | 提供平台能力 | 文件、控制台、Unity/Godot 主线程 API |
所以“能编译”和“能运行”是两回事。编译器只需要 reference assemblies 来确认 API 存在;跑起来时,runtime 还要满足 BCL 对对象布局、类型系统、反射、GC 和平台能力的假设。
corlib 是整个世界的地基
.NET 里有一个最核心的基础库,通常叫 corlib。System.Object、System.String、System.Array、System.Type、System.Exception、System.Delegate 这些最基础的类型都在这里。
在 .NET 10 里,这个 core library 是 System.Private.CoreLib。LeanCLR 要承载 .NET 10,第一步就是把它当作地基加载起来,并且看懂它对 runtime 的要求。
System.Private.CoreLib 里的核心类型、私有字段、内部方法、反射对象、句柄模型和 runtime 调用方式,合在一起定义了 .NET 10 想看到的 runtime 长什么样。LeanCLR 需要重新确认:
- 核心类型从哪里加载。
- 对象、数组、字符串、异常等基础对象如何初始化。
RuntimeType、RuntimeMethodInfo、RuntimeFieldInfo等反射对象如何和 runtime 内部结构对应。- BCL 里由 runtime 实现的底层方法,最后该落到哪个 native helper。
这就是后面会反复出现的 runtime contract:BCL 期待 runtime 提供哪些入口、对象形态和行为语义。
TFM、runtime pack 和运行时支持
TargetFramework=net10.0 里的 TFM 是 Target Framework Moniker。简单说,它是在告诉 SDK:“这个项目按 .NET 10 的 API 面来编译”。
运行时还涉及另一组东西:
- reference assemblies:给编译器看的 API 声明。
- runtime assemblies:运行时加载的 BCL 实现。
- runtime pack:包含目标平台所需的 runtime assemblies 和 native 组件。
- runtime contract:BCL 调用 native helper 时,runtime 需要提供的入口和语义。
flowchart TD
A["TargetFramework=net10.0"] --> B["编译器使用 .NET 10 reference assemblies"]
B --> C["生成 net10.0 业务程序集"]
C --> D{"LeanCLR 能否运行"}
D --> E["加载 .NET 10 runtime assemblies"]
D --> F["识别 System.Private.CoreLib"]
D --> G["支持 .NET 10 metadata / IL 组合"]
D --> H["实现 BCL 需要的 runtime contract"]
所以对 LeanCLR 来说,支持 .NET 10 涉及一整条链路:程序集怎么加载、corlib 怎么识别、metadata 怎么解析、BCL/native 边界怎么接。
什么是 runtime contract
BCL 里有些方法就是普通 C# 写的,有 IL 方法体,runtime 照着执行就行。另一类方法会直接进入 runtime helper,或者由 runtime 做特殊处理。这些地方就是 contract 最容易扎堆的地方。
常见边界包括:
| 名词 | 可以先这样理解 |
|---|---|
| internal call / icall | BCL 方法直接进入 C++ runtime 实现 |
| intrinsic | runtime 或 AOT 对某个方法做特殊识别,不按普通方法调用处理 |
| P/Invoke | 托管代码调用 native 函数,例如操作系统 API 或宿主 API |
| runtime handle | BCL 用来代表类型、方法、字段、模块等 runtime 对象的轻量句柄 |
| reflection | 托管代码在运行时查询类型、方法、字段、特性等 metadata 的能力 |
LeanCLR 适配 .NET 10 的核心,就是让 System.Private.CoreLib 和必要 BCL 在这些边界上看到它们想要的入口;至于 LeanCLR 内部,仍然可以继续用自己的类型系统、metadata cache、解释器、GC 和反射框架。
BCL contract 是从哪里发起的
这里很容易想反:好像是 LeanCLR 先定义一套“BCL 接口协议”,然后让 BCL 配合。实际顺序刚好反过来。
contract 的源头是当前选择的 BCL assemblies。 编译器会把业务代码里的 object.GetType()、typeof(int)、new string(...)、GC.Collect() 编成 metadata token 和方法引用。LeanCLR 加载业务程序集后,再继续解析这些 token 指向哪个 BCL 类型、哪个 BCL 方法。只要这些方法来自 core library 或 runtime pack,它们就会反过来要求 runtime 提供某些能力。
可以把发起路径理解成:
flowchart LR
A["业务 C# 代码"] --> B["编译器生成 IL + metadata token"]
B --> C["token 指向 BCL 方法"]
C --> D{"BCL 方法如何实现"}
D --> E["普通 IL: runtime 解释执行"]
D --> F["InternalCall / intrinsic / PInvoke"]
F --> G["BCL 向 runtime 发起 contract 要求"]
G --> H["LeanCLR profile/catalog 映射到 C++ runtime"]
runtime API catalog 是 LeanCLR 给这些入口做的一张登记表。contract 的源头是:
- 当前 profile 选择的 BCL assemblies。
- 这些 assemblies 里的 metadata、IL、方法签名和 P/Invoke 声明。
- 对
.NET 10来说,还要参考System.Private.CoreLib和 CoreCLR VM 源码理解语义。
这就是为什么 profile 很关键。本文只讨论 coreclr-net10:也就是 LeanCLR 面向 .NET 10 runtime pack 和 System.Private.CoreLib 建的一套独立规则。
对 coreclr-net10 来说,关键假设是:
- core library 是
System.Private.CoreLib。 - BCL contract 来源是
.NET 10runtime assemblies 里的 metadata、IL、方法签名和 P/Invoke 声明。 - native 边界按
.NET 10的 internal call、intrinsic、generated P/Invoke 和 runtime handle 形态登记。 - LeanCLR 内部仍然映射到自己的
RtClass、RtMethodInfo、RtFieldInfo、metadata cache 和解释器。
从仓库结构上看,这个选择已经写进了 coreclr-net10 profile:
1 | { |
这段配置来自 src/leanaot/LeanAOT/runtime-apis/coreclr-net10/profile.json。它告诉 LeanCLR 工具链:这些模块属于当前 .NET 10 profile 的核心 BCL 范围。后面判断 icall、intrinsic、P/Invoke,都要按这套世界来解释。
runtime 侧也有对应入口。src/runtime/vm/assembly.cpp 中的 Assembly::load_corlib() 当前会加载 System.Private.CoreLib:
1 | RtResult<metadata::RtAssembly*> Assembly::load_corlib() |
BCL 对齐要做的事,是先选定一个 BCL 世界,再让 LeanCLR 的类型系统、metadata、解释器和 native helper 能接住这个世界抛过来的调用。
一个 C# 方法如何走到 LeanCLR runtime
下面用一个很小的例子把路径串起来。我们的业务代码只是做 boxing、取类型、比较类型、再拆箱:
1 | using System; |
项目文件选择 .NET 10:
1 | <Project Sdk="Microsoft.NET.Sdk"> |
编译之后,Run() 会变成类似下面这样的 IL 和 metadata 引用。这里为了方便看,做了简化:
1 | ldc.i4.s 123 |
这里有几个点值得盯一下:
box System.Int32要求 runtime 知道 value type 如何装箱成对象。System.Object::GetType()来自System.Private.CoreLib。RuntimeTypeHandle、System.Type、Type.GetTypeFromHandle()都是 core library 的反射 contract。- 这些引用的归属都是
[System.Private.CoreLib],所以 runtime 要按.NET 10的核心库形态解释。
如果用 leanrun 执行这个入口,命令大概是这样:
1 | leanrun ` |
leanrun 里面的路径可以先简化成下面这样,对应 src/tools/leanrun/main.cpp:
1 | vm::Runtime::initialize(); |
进入解释器以后,普通 IL 比如 ldc.i4、stloc、条件分支,LeanCLR 自己就能执行。解释到 System.Object::GetType() 时,事情会进入 BCL contract:这个方法来自 System.Private.CoreLib,它要求 runtime 返回当前对象的真实运行时类型。
在 coreclr-net10 profile 里,LeanCLR 用 catalog 登记这个入口。摘自 src/leanaot/LeanAOT/runtime-apis/coreclr-net10/icalls.json:
1 | { |
这段登记的意思很直白:
- BCL 侧调用的是
System.Object::GetType。 - LeanCLR runtime 侧用
SystemObject::get_type实现。 - 这个入口属于
coreclr-net10profile 的 native helper 边界。
对应 C++ 实现在 src/runtime/icalls/system_object.cpp:
1 | RtResult<vm::RtReflectionType*> SystemObject::get_type(vm::RtObject* obj) noexcept |
这个函数做的事也很直接:
- 从托管对象指针
obj取出 LeanCLR 内部的klass。 - 把
klass映射成托管世界可见的 reflection type 对象。 - 返回给
System.Private.CoreLib的Object.GetType()调用点。
把链路画出来,大概是这样:
sequenceDiagram
participant User as "BlogDemo.Program.Run"
participant IL as "IL interpreter"
participant BCL as "System.Private.CoreLib method"
participant Catalog as "coreclr-net10 icalls.json"
participant Native as "SystemObject::get_type"
participant VM as "LeanCLR Reflection / TypeSystem"
User->>IL: value.GetType()
IL->>BCL: 解析到 System.Object::GetType
IL->>Catalog: 方法属于 core library, 查 runtime helper
Catalog-->>IL: 映射到 SystemObject::get_type
IL->>Native: 传入 RtObject*
Native->>VM: obj->klass -> reflection type object
VM-->>Native: RtReflectionType*
Native-->>IL: 返回 System.Type 对象
IL-->>User: 继续执行 typeof(int) 比较
这样就能看出来,为什么整条链路最好都落在同一个 coreclr-net10 规则下:
- 如果业务程序集引用的是
[System.Private.CoreLib]System.Object::GetType(),runtime API catalog 里就得有.NET 10视角下的这个入口。 - 如果
.NET 10BCL 期待返回的是它能识别的RuntimeType/RuntimeTypeHandle形态,LeanCLR 就要把内部RtClass映射成对应的托管反射对象。 - 如果某个入口只按短名宽松匹配,不按完整签名匹配,就可能把
.NET 10的包装方法、不同参数形态或不兼容 native helper 误连到一起。
所以 LeanCLR 对齐 BCL,要保证整条链路都在同一个 profile 下说得通:
1 | 业务 IL token |
minimal net10 profile 的边界
一上来就承载完整 Microsoft.NETCore.App,LeanCLR 基本会被推成“小型 CoreCLR”。ThreadPool、网络、文件系统、反射全量语义、动态代码生成、平台 PAL、COM/Interop 这些东西都会被拉进来,范围会一下子膨胀。
更稳的做法是先收住边界,也就是 minimal net10 profile:
- 运行项目自己的、受控的、纯逻辑
net10.0DLL。 - 支持基础对象模型、数组、字符串、异常、委托、泛型、基础反射和必要 Span/Unsafe 路径。
- 使用
.NET 10runtime pack 作为托管 BCL 输入,当前阶段承诺 profile 白名单内的 runtime API。 - 对越界的 BCL、ThreadPool、Timer、I/O、网络、动态代码生成等能力给出可诊断失败,别用静默 stub 混过去。
- Unity/Godot 等引擎 API 通过宿主 bridge 暴露,不把引擎程序集当作普通 BCL 直接加载进 runtime。
这个边界的重点是:LeanCLR 先接住当前 profile 下真的会跨到 native 边界的那些能力。
托管代码在 LeanCLR 中如何执行
一个 C# 程序进入 LeanCLR 以后,大致会走下面这条路:
flowchart TD
A["C# source"] --> B["dotnet build 生成程序集"]
B --> C["LeanCLR 加载业务程序集"]
C --> D["解析 AssemblyRef / TypeRef / MemberRef"]
D --> E["选择 runtime API profile"]
E --> F["加载 profile 对应 core library"]
F --> G["初始化核心类型 Object / String / Array / Type"]
G --> H["读取 metadata: 类型 / 字段 / 方法 / 泛型 / 特性"]
H --> I{"方法如何执行"}
I --> J["解释执行 IL"]
I --> K["LeanAOT 生成 native 路径"]
J --> L["调用 LeanCLR runtime 服务"]
K --> L
L --> M{"遇到 runtime 边界"}
M --> N["internal call / QCall / FCall 风格入口"]
M --> O["intrinsic"]
M --> P["P/Invoke / host bridge"]
N --> Q["C++ runtime 或宿主 native API"]
O --> Q
P --> Q
Q --> R["返回托管世界继续执行"]
对 coreclr-net10 来说,图里的 profile 选择会把 core library 固定到 System.Private.CoreLib,也会把 BCL runtime API 固定到 .NET 10 runtime pack 里的入口和签名。入口没登记,或者返回的对象形态不对,问题通常会出现在 core library 加载、核心类型初始化、反射句柄解析,或者 BCL runtime API 调用这些地方。
profile 是隔离边界
profile 决定当前 runtime 要面对哪套 BCL、哪套路由规则。本文只讨论 coreclr-net10,它的作用就是把 .NET 10 core library、runtime pack、runtime API catalog 和 LeanCLR native helper 绑到同一套解释规则里。
flowchart TD
A["选择 coreclr-net10 profile"] --> B["core library: System.Private.CoreLib"]
A --> C["BCL 输入: .NET 10 runtime pack"]
A --> D["runtime API catalog: coreclr-net10"]
D --> E["icall / intrinsic / PInvoke entries"]
E --> F["LeanCLR C++ runtime helpers"]
F --> G["RtClass / RtMethodInfo / RtFieldInfo / metadata cache"]
比较靠谱的做法,是让 corlib 识别、required type 表、internal call 表、intrinsic 表、P/Invoke facade 和诊断逻辑都跟着 coreclr-net10 走。这样看到某个 BCL 入口时,就能一路追下去:它在 .NET 10 runtime pack 里从哪来,在 catalog 里怎么登记,最后落到 LeanCLR C++ runtime 的哪段实现。
runtime API catalog 的作用
.NET 10 BCL 里有不少方法会直接进入 runtime helper,或者需要 runtime 做特殊处理。它们通常会落在几类边界上:
| 边界 | 含义 | LeanCLR 需要做的事 |
|---|---|---|
| internal call / QCall / FCall | 托管 BCL 直接调用 runtime native helper | 按完整类型名、方法名和签名注册 C++ 实现 |
| intrinsic | 解释器或 AOT 对特定方法做特殊识别 | 为 Span<T>、RuntimeHelpers、Interlocked 等建立 profile 内映射 |
| P/Invoke / LibraryImport | 托管代码调用 native 动态库或 generated P/Invoke stub | 映射到 LeanCLR 平台层、host bridge 或明确 NotSupported |
| runtime implemented method | 方法语义由 runtime 提供,不适合按普通 IL 执行 | 在解释器和 AOT 中共享同一 contract |
runtime API catalog 就是这些边界的索引。它告诉 LeanCLR:当前 profile 下,哪些托管方法要找 runtime 帮忙,签名是什么,应该走 icall、intrinsic,还是 P/Invoke。
AOT 暂时后置时,这张表也很重要。解释器遇到 BCL 内部方法时同样要查 runtime helper;以后 AOT codegen 也可以复用同一套 catalog,少维护一份语义。
flowchart TD
A["输入程序集和 BCL"] --> B["读取 metadata / IL"]
B --> C["按 profile 加载 runtime API catalog"]
C --> D{"遇到方法调用"}
D --> E["普通 IL: 解释器执行"]
D --> F["icall: 调用 runtime helper"]
D --> G["intrinsic: runtime 特殊处理"]
D --> H["P/Invoke: 进入 native / host 边界"]
E --> I["统一 runtime 语义"]
F --> I
G --> I
H --> I
I --> J["解释执行与 AOT 共享 contract"]
icall 要精确匹配
internal call 是 BCL 和 runtime 之间最直接的通道。托管侧可能长这样:
1 | [] |
这个方法会直接进入 runtime,所以 runtime 需要根据“类型名 + 方法名 + 签名”找到 native 实现:
1 | {"System.Type::internal_from_handle", (vm::InternalCallFunction)&SystemType::internal_from_handle} |
调用流程可以这样理解:
sequenceDiagram
participant Managed as "托管 BCL 方法"
participant VM as "LeanCLR 调用解析"
participant Table as "icall 表"
participant Native as "C++ runtime 实现"
Managed->>VM: 调用 InternalCall 方法
VM->>Table: 按完整签名查找入口
alt 找到入口
Table-->>VM: 返回函数指针和 invoker
VM->>Native: 转换参数并调用 C++ 实现
Native-->>VM: 返回结果或异常
VM-->>Managed: 回到托管调用点
else 没找到入口或签名不匹配
VM-->>Managed: 抛出清晰诊断
end
这里最容易出事的是“宽松匹配”。runtime 只按 System.RuntimeFieldHandle::GetToken 这种短名称找入口,又不校验完整签名时,就可能把 .NET 10 托管侧的包装方法误当成 native 入口,甚至把托管对象指针当作 native 字段句柄传进去。
所以 coreclr-net10 profile 要尽量偏向精确签名:
- icall 注册以完整方法签名为主。
coreclr-net10catalog 之外的入口,在System.Private.CoreLib路径下直接报清晰诊断。- 找不到匹配项时应 fail fast,并输出 profile、方法签名和调用来源。
CoreCLR contract 怎么落到 LeanCLR 模型
适配 .NET 10 时,CoreCLR VM 源码更适合作为参考。它能告诉我们 BCL 调用链、QCall/FCall/InternalCall 名称、签名、异常语义和 runtime handle 预期。LeanCLR 的对象布局、GC、metadata loader、method table、exception、reflection handle、解释器调用协议,继续保持自己的实现。
更适合 LeanCLR 的做法是:外面长得像 .NET 10 BCL 期待的样子,里面仍然映射到 LeanCLR 自己的模型。
flowchart LR
A[".NET 10 System.Private.CoreLib"] --> B["CoreCLR 风格 runtime contract"]
B --> C["LeanCLR net10 facade"]
C --> D["RtClass"]
C --> E["RtMethodInfo"]
C --> F["RtFieldInfo"]
C --> G["metadata cache"]
C --> H["interpreter / GC / exception / reflection"]
典型例子就是:
RuntimeType/RuntimeTypeHandle映射到 LeanCLR 的类型系统。RuntimeMethodHandle映射到RtMethodInfo和解释器可调用方法描述。RuntimeFieldHandle映射到RtFieldInfo、字段布局和静态字段存储。RuntimeModule、MetadataImport和 token 查询映射到 LeanCLR metadata cache。CustomAttributeData、Activator、泛型实例化和私有成员访问落到 LeanCLR 反射框架。
这样做的好处是把 .NET 10 BCL 看到的外部形状,和 LeanCLR 内部实现拆开。BCL 看到的是熟悉的 CoreCLR 风格 facade;LeanCLR 内部还是自己的 VM 架构。
为什么还要谈引擎适配
AI 时代有个很明显的变化:把已有项目代码迁到另一套引擎,成本比以前低多了。以前从 Unity 换到 Godot,或者从自研框架迁到 Unreal,很多代码都要人肉重写。现在 AI 可以帮忙翻译 API、补样板代码、解释生命周期差异,甚至把一个 gameplay 系统拆成更适合目标引擎的结构。
这事真落到商业项目上,还会被一个更底层的问题卡住:主流引擎的脚本语言和 runtime 生态并不统一。
- Unity 主路径是 C#,它的 managed runtime、BCL profile、IL2CPP、平台裁剪和引擎 API 绑定都有自己的边界。
- Godot 官方主路径是 GDScript,C# 支持依赖 Godot 的 .NET 集成状态,不同平台、导出模板和版本组合会影响可用性。
- Unreal 主路径是 C++ / Blueprint,社区 C# 方案可以提供很好的开发体验,也通常会受 UE 版本、平台、热重载、构建链和发行限制影响。
对个人项目、编辑器工具或实验玩法来说,这些限制经常还能接受。商业项目会更看重可控的部署平台、稳定的构建链、明确的授权边界、能诊断的崩溃路径,还要尽量在移动端、主机、桌面、服务器这些环境里保持一致。社区版语言扩展一旦只能覆盖部分平台,或者必须绑在某个引擎自己的 managed runtime 上,承担跨项目、跨引擎核心技术栈的压力会很大。
这就是 wqaetly/leanclr 这类“精简但完整”的 CLR 有意思的地方。它在引擎之外放一个可控的托管运行时地基:能加载程序集、解析 metadata、执行 IL、管理对象和 GC,再逐步对齐 .NET 10 BCL contract。这样,项目里真正值得保留的 gameplay、ECS、规则、配置、序列化、调试域和业务框架,就可以沉到 LeanCLR 托管层里;具体引擎继续负责渲染、资源、场景、输入、物理和平台能力。
这样一来,引擎适配模块的意义就很清楚了:在 LeanCLR 和真实引擎对象之间搭一条薄而稳定的桥。我们在框架层继续写普通 C# 对象和属性;桥接层负责把这些属性映射到 Unity/Godot/UE 的真实对象。AI 可以帮忙迁移上层业务代码,LeanCLR 和 adapter 则负责给迁移后的代码一个一致、可部署、可调试的 runtime 落点。
BCL 兼容和引擎 bridge 是两件事
.NET 10 BCL 兼容解决的是:System.*、System.Private.CoreLib 和必要的 Microsoft.NETCore.App 能否在 LeanCLR 上跑。Unity/Godot/UE 接入解决的是另一件事:引擎对象、资源、场景、主线程 API、异步结果,怎么暴露给托管业务代码。
这两件事分开看会清楚很多。
flowchart LR
A["LeanCLR 托管业务代码"] --> B[".NET 10 / profile BCL"]
A --> C["框架层引擎 facade: Node / Entity / Actor / Component"]
C --> D["属性 setter / 方法调用"]
D --> E["LeanCLR host bridge ABI"]
E --> F["宿主侧 handle registry"]
F --> G["即时写入真实引擎对象属性"]
G --> H["UnityEngine / Godot / Unreal API"]
比较稳的接入方式是:
- LeanCLR 只运行引擎无关核心逻辑、业务程序集和必要
.NET 10BCL 子集。 - Unity/Godot/UE 进程作为宿主,负责创建 LeanCLR runtime、装载程序集、驱动 frame pump。
- 引擎对象跨 runtime 边界时,用
int、long、IntPtr或 opaque handle 表示。 - 宿主维护 handle registry,把 handle 映射到真实 Unity
Object、GodotObject、UnrealUObject、Node、Resource、Actor、Component 等。 - 框架层提供薄 facade,例如
Transform.Position、Node.Visible、Actor.Location这类属性;我们改属性时,setter 立即进入 bridge,宿主立刻修改真实引擎对象。 - 所有只能在主线程调用的引擎 API,都进入宿主主线程 dispatcher。
- 异步加载、场景切换、资源请求等结果通过 bridge 回填到 LeanCLR 的 continuation 或等价调度结构。
这个模型里,LeanCLR 是一个 native CLR-like runtime;Unity/Godot/UE 则像外部 service provider。我们看到的是框架层 facade 和普通属性。LeanCLR 只要理解 bridge 边界,业务层也不用维护一份等着提交的 snapshot。
三类引擎接入的共同骨架
更适合 LeanCLR 的通用路径是:业务程序集面向 net10.0 编译,LeanCLR 在引擎进程内加载业务 DLL、框架 DLL、adapter DLL 和 staged BCL。框架层给引擎对象套一层很薄的封装,里面只保存 handle、少量本地缓存和 bridge 入口;我们一改属性,setter 就直接进 bridge,bridge 再通过 handle registry 找到真实引擎对象,马上写进去。
这条路让 LeanCLR 避开 GodotSharp、UnityEngine 或 Unreal Engine 的 managed runtime,也绕开“先把 mutation 收集成 snapshot / command buffer,再在帧尾统一应用”的模式。snapshot 的边界比较集中,代价也很实在:要设计 diff/merge、序列化、对象生命周期同步、帧间一致性规则;复杂以后还会多一层内存搬运和命令解码。对多数 gameplay 属性写入来说,最古朴的 setter -> bridge -> engine object 反而更直接,性能也更好把握。
通用流程可以抽象成:
flowchart TD
A["业务 / 框架 C# 源码"] --> B["dotnet build: net10.0 managed assemblies"]
B --> C["计算 AssemblyRef 闭包"]
C --> D["stage minimal net10 BCL / framework / adapter assemblies"]
E["引擎插件 / native module"] --> F["启动 LeanCLR Runtime"]
F --> G["注册 assembly loader"]
G --> H["加载业务程序集、framework 和 adapter assembly"]
H --> I["绑定 managed entry: Reset / Tick / Debug / Host callbacks"]
J["引擎主循环"] --> K["主线程 dispatcher / frame pump"]
K --> I
I --> L["RuntimeContext.Update / ECS / gameplay logic"]
L --> M["框架 facade 修改属性"]
M --> N["setter 立即调用 host bridge"]
N --> O["handle registry 定位真实对象"]
O --> P["Object.set / SetProperty / UFunction thunk"]
P --> Q["真实引擎对象 / 资源 / 场景立即更新"]
R["debug transport"] --> K
K --> S["managed Diagnostics command endpoint"]
这里的重点是三个引擎共用同一条边界:
| 层次 | 放什么 | 不放什么 |
|---|---|---|
| LeanCLR core | .NET 10 BCL profile、metadata、IL、GC、反射、icall catalog |
引擎对象模型 |
| managed game assemblies | RuntimeContext、ECS、玩法、debug domain、框架层对象 facade | UnityEngine/GodotSharp/Unreal managed 全量 API |
| adapter assembly | handle、Variant、bridge binding、属性 marshal、少量 service interface | 真实引擎对象生命周期 |
| native engine plugin | runtime loader、assembly loader、主线程 pump、object/resource registry、property setter thunk、transport | BCL 兼容逻辑 |
即时属性桥接再拆细一点,大概是这样:
flowchart LR
A["我们的代码: player.Transform.Position = p"] --> B["框架 facade: Transform.set_Position"]
B --> C["Adapter: marshal Vector3 + handle"]
C --> D["LeanCLR host bridge ABI"]
D --> E["native bridge thunk"]
E --> F["handle registry"]
F --> G{"当前是否主线程"}
G -->|是| H["立即调用真实引擎 setter"]
G -->|否| I["投递到主线程 dispatcher"]
I --> H
H --> J["真实 Transform / Node / Actor 属性更新"]
这里说“框架层不关心引擎”,意思是我们的业务和框架核心不依赖具体引擎程序集。框架层可以定义稳定的对象和属性语义,adapter 自动把它们映射到 Godot/Unity/UE 的真实对象。好处很直接:
- setter 路径短:省掉 snapshot diff、命令聚合、批量解码和帧尾回放。
- 对象语义直观:我们写属性就等于修改引擎对象,调试时看到的状态更接近真实状态。
- 桥接层集中处理差异:属性名、类型 marshal、主线程要求、弱引用有效性都收在 adapter/native plugin 里。
- 后续仍可优化热点:如果某些属性写入特别频繁,可以在 bridge 内部做去重、批处理或 dirty flag,整体架构继续保持即时属性桥接。
Godot 接入流程
Godot 这条路可以保留现在这套结构:GDExtension 启动 LeanCLR,注册文件加载器,加载 sample assembly,再把 _process 转成 managed tick。需要改的是对象同步方式:从 command buffer 换成即时 facade。NKGGameFramework.Adapter.Godot 负责 managed contract、GodotObjectHandle、GodotNodeFacade、GodotResourceFacade 和属性 bridge;GDExtension native 层维护 Godot Object / Node / Resource registry,再把 facade setter 映射到 Object::set、Object::callv、ResourceLoader 这些 Godot API。
flowchart TD
A["dotnet build Godot sample: net10.0"] --> B["NKGGameFramework.GodotPlaneSample.dll"]
A --> C["NKGGameFramework.dll / Adapter.Godot.dll / Diagnostics.dll"]
B --> D["stage-leanclr-bcl: 复制 System.Private.CoreLib 和引用闭包"]
C --> D
E["Godot project.godot"] --> F["GDExtension: nkg_leanclr_bridge"]
F --> G["NkgLeanClrRuntimeBridge.configure(managedDir + bclDir)"]
G --> H["Runtime::initialize + Settings::set_file_loader"]
H --> I["Assembly::load_by_name(sample assembly)"]
I --> J["绑定 PlaneGameBridge 静态入口"]
K["NkgLeanClrPlaneHost._process(delta)"] --> L["ClearInput / Press* / StepSession"]
L --> M["RuntimeContext.Update + World.Update"]
M --> N["Godot facade 属性 setter / service call"]
N --> O["Adapter.Godot marshal handle + Variant"]
O --> P["NkgGodotObjectRegistry / ResourceRegistry"]
P --> Q["Godot Object::set / Object::callv / ResourceLoader"]
R["native HTTP/SSE debug transport"] --> S["主线程安全点"]
S --> T["PlaneGameBridge.HandleDebugRequest"]
T --> U["Diagnostics GameDebugEndpointDispatcher"]
Godot 这边的经验,可以沉淀成几个很实用的约束:
leanclr_bcl/net10.0是随工程 staged 的 runtime assembly 目录,运行时不启动 CoreCLR host。- Godot 官方 C# / GodotSharp 不在主路径里,Godot 的 .NET 支持状态就不会变成 LeanCLR 的平台限制。
- GDScript 只做 autoload、配置和可选 facade,不承载 ECS、Gameplay、Serialization、Debug domain。
- debug transport 在 native/Godot 层,managed 侧只处理 command endpoint 和 payload,LeanCLR 先不实现
System.Netserver。 - 所有对象属性写入、pause/step 和 debug mutation 都通过主线程安全点执行;调用来自 transport 线程时,bridge 只投递到 dispatcher,由主线程读写 ECS 或 Godot 对象。
Unity 接入流程
Unity 可以复用同一个即时 facade 模型,只是宿主胶水会不一样。Unity 自己已经有 C# 域,LeanCLR 这边仍然把 Unity managed runtime 放在外面,业务依赖也走 adapter facade。更稳的做法是:Unity C# 侧保留一个很薄的 MonoBehaviour host,加载 LeanCLR native plugin,传入 managed/BCL 路径。之后每帧只做输入采集、主线程调度和必要的 host 回调。我们在托管层拿到的是框架层 Entity、TransformFacade、RendererFacade 这类对象;属性 setter 立即进入 Unity bridge,由 host 找到 GameObject / Component 并写入真实属性。
flowchart TD
A["NKG / game logic: net10.0"] --> B["NKGGameFramework.dll"]
A --> C["NKGGameFramework.Adapter.Unity.dll"]
B --> D["Unity StreamingAssets 或 package 内 managed 目录"]
C --> D
E["Unity Editor / Player"] --> F["LeanCLR native plugin"]
G["LeanClrUnityHost MonoBehaviour"] --> F
G --> H["传入 managedDir / bclDir / entry assembly"]
F --> I["Runtime::initialize + assembly loader"]
I --> J["绑定 managed Reset / Tick / Debug 方法"]
K["Update / FixedUpdate / LateUpdate"] --> G
G --> L["采集 Input / Time / scene context"]
L --> J
J --> M["RuntimeContext.Update / gameplay logic"]
M --> N["Unity facade 属性 setter / service call"]
N --> O["Unity adapter marshal handle + value"]
O --> P["handle registry: GameObject / Component / Asset"]
P --> Q["Transform.position / Component.enabled / Instantiate / LoadAsset"]
R["Editor debug window / loopback transport"] --> G
G --> S["managed Diagnostics command endpoint"]
Unity 首版不用追求“在 LeanCLR 里完整写 Unity 脚本”。可以先这样分期:
Adapter.Unity先定义IUnityGameLoopDriver、asset、scene、audio、UI 等 host service contract。- Unity host 把
Update/FixedUpdate映射成 LeanCLR 的 frame pump。 - 我们通过 facade 修改对象属性,Unity host 维护
int/longhandle 到GameObject、Component、ScriptableObject、asset 的映射。 - Editor/desktop debug transport 可以由 Unity C# 或 native plugin 承载,再转成 Diagnostics command。
- 后续如果要暴露更多 Unity API,优先做生成式 adapter 和受控 facade,让
UnityEngine留在 Unity adapter 一侧。
Unreal Engine 接入流程
UE 会更麻烦一点,因为它还要处理反射、UObject 生命周期、GC、蓝图和热重载边界。这里可以参考 UnrealSharp 的方向:它面向 UE5,把 C# 放在 .NET 10 上,支持从 UClass 派生、用特性标注 UProperty / UFunction,并根据 Unreal 反射出来的 C++ 类型生成 C# binding。LeanCLR 可以借鉴这种“生成 binding + native module 承载 UObject 边界”的结构,运行时仍然使用 LeanCLR。
flowchart TD
A["Unreal C++ project / plugin"] --> B["UHT 生成反射数据"]
B --> C["LeanCLR Unreal binding generator"]
C --> D["Leanclr.Unreal.Adapter.dll: UObjectHandle / Actor facade / generated thunks"]
E["Gameplay C# net10.0"] --> F["managed game assemblies"]
D --> F
G["Unreal plugin module"] --> H["StartupModule / GameInstanceSubsystem"]
H --> I["启动 LeanCLR Runtime"]
I --> J["加载 BCL / adapter / gameplay assemblies"]
J --> K["绑定 Tick / BeginPlay / EndPlay / Debug / event dispatch"]
L["UE game thread Tick"] --> K
K --> M["managed gameplay / ECS / state machine"]
M --> N["UObject / Actor facade 属性 setter"]
N --> O["native bridge thunks"]
O --> P["UObject registry: handle -> TWeakObjectPtr<UObject>"]
P --> Q["SetProperty / Call UFunction / SpawnActor / LoadObject"]
R["Blueprint / UFunction event"] --> S["native event thunk"]
S --> K
UE 可以拆成两步走:
| 阶段 | 目标 | 边界 |
|---|---|---|
| Phase 1 | LeanCLR 托管逻辑通过 facade 即时驱动 UE 对象属性 | 我们通过 handle facade 控制 Actor、Component、Asset |
| Phase 2 | 生成式 UClass/UFunction 代理 | 参考 UnrealSharp 的 generated binding 思路,为 C# partial 类型生成 native proxy、UFunction thunk 和属性 marshal |
Phase 1 更适合先验证 LeanCLR 的 runtime contract,因为它只要求:
- UE plugin module 在 game thread 初始化 LeanCLR,并在 Tick 安全点调用 managed entry。
- adapter assembly 提供
UObjectHandle、ActorHandle、ComponentHandle、UnrealVariant、属性 setter thunk 和 host service interface。 - native bridge 用
TWeakObjectPtr<UObject>或等价结构维护 handle registry,让 LeanCLR 只持有受控 handle。 - 蓝图事件、input、timer、asset load 先进入 UE game thread,再通过 dispatch queue 进入 LeanCLR。
- UE GC 与 LeanCLR GC 不互相扫描对象图,只通过 handle 的 pin/release/weak validity contract 协作。
Phase 2 再引入更接近 UnrealSharp 的体验:C# 类型用特性描述 UClass、UProperty、UFunction,生成器根据 UHT/reflection 数据生成 C# facade 和 C++ thunk。这里仍然要守住一个原则:Unreal 的反射系统属于 UE adapter,.NET 10 BCL profile 只看到 adapter DLL、metadata 和受控 native bridge。
接入验证矩阵
三类引擎最好先用同一组最小验收把边界跑通,完整引擎 API 放到后面扩展:
| 场景 | Godot | Unity | UE |
|---|---|---|---|
| Runtime 启动 | GDExtension 初始化 LeanCLR | native plugin 初始化 LeanCLR | plugin module / subsystem 初始化 LeanCLR |
| Assembly 加载 | managed dir + leanclr_bcl/net10.0 |
StreamingAssets/package staged BCL | plugin content staged BCL |
| Frame pump | _process / _physics_process |
Update / FixedUpdate |
game thread Tick |
| 对象输出 | Godot facade setter 立即 Object::set |
Unity facade setter 立即写 GameObject / Component |
UE facade setter 立即写 UObject / AActor |
| 对象 registry | Object / Node / Resource |
GameObject / Component / asset |
UObject / AActor / UActorComponent |
| 属性桥接 | handle + Godot Variant marshal |
handle + Unity value marshal | handle + FProperty / thunk marshal |
| Debug transport | native HTTP/SSE 或 JS bridge | Editor window / loopback / player bridge | Editor tool / loopback / console command |
| 首个 smoke | managed sampler 每帧驱动可见对象 | managed sampler 创建/移动 GameObject | managed sampler SpawnActor/SetProperty |
只要这张矩阵跑通,后面扩展资源、UI、动画、物理、蓝图、热重载,就可以继续围绕 facade、adapter 和 host bridge 往外长。每个引擎的复杂度留在 adapter 里。真的遇到需要批处理的热点,也可以在 bridge 内部局部优化,业务层继续保持即时属性桥接模型。
单线程 runtime 的同步边界
如果 LeanCLR 的 Standard/Core 形态明确是单线程 runtime,那 .NET 10 profile 也要把同步语义说清楚。
async/await 仍然可以跑一部分。很多 async state machine、completed task、同步 continuation、next-frame continuation,都可以映射到单线程 frame scheduler 或宿主 dispatcher。需要小心的是 ThreadPool worker、Timer、WaitHandle、后台线程、I/O completion port 和跨线程调度。
可以先把同步能力分成三类:
| 类别 | 处理方式 |
|---|---|
| 单线程可表达 | async state machine、同步 continuation、frame scheduler、host dispatcher |
| 可降级或替代 | timer/next-frame、资源加载回调、主线程任务队列 |
| 不进入 profile | ThreadPool worker、后台线程、复杂 WaitHandle、跨线程 Monitor 语义 |
对第三类 API,清楚地报 NotSupported 比空实现更好。空 stub 表面上“没炸”,后面会让我们的业务代码在更远、更难定位的地方失败。
哪些 LeanCLR 能力应该复用
.NET 10 适配的主战场在 BCL/runtime contract 这一层。下面这些 LeanCLR VM 能力能复用就尽量复用:
- 程序集与 metadata 解析。
- 类型系统和对象模型。
- IL 解释器。
- LeanAOT IL 到 C++ 的生成框架。
- GC 和对象扫描。
- 异常处理。
- 委托调用。
- 泛型与泛型共享。
- 基础反射框架。
需要重建或重新校准的是:
- corlib 名称和核心模块识别。
System.Private.CoreLib对核心类型、runtime handle 和反射对象的预期。coreclr-net10runtime API catalog。- internal call、intrinsic、P/Invoke 和 runtime implemented method 的签名与行为。
- assembly resolver 对业务程序集、NuGet 依赖、runtime pack 和 adapter assembly 的搜索策略。
- 现代 C# / .NET metadata 与 IL 组合。
- 单线程 async、continuation、timer 与宿主调度的边界。
- 反射型序列化、private member access、attribute 查询等真实 workload 会组合触发的能力。
结语
把 LeanCLR 接到 .NET 10 上,真正让人兴奋的地方不只是“又支持了一个 TFM”。更大的想象空间在后面:我们可以把一套 C# 业务框架、一套玩法逻辑、一套调试和工具链,放到更稳定的托管层里,再让它去连接不同平台、不同引擎、不同宿主环境。
这件事一旦跑通,Unity、Godot、Unreal 就不再只是三条彼此分叉的技术路线。它们可以变成不同的渲染、编辑器和平台入口。我们真正沉淀下来的东西,是 net10.0 的业务程序集,是 LeanCLR 托管层里的规则、状态、序列化、调试域和框架对象。
到那一步,AI 迁移代码也会更有落点。AI 可以帮我们把工程从一个引擎搬到另一个引擎,LeanCLR + .NET 10 则负责提供一条稳定的运行通道。我们不用每换一次引擎,就重新押注一套脚本语言、一套 runtime 生态、一套调试方式。
所以这条路值得继续敢想:同一套 gameplay 核心跑在桌面、移动端、服务器、编辑器工具里;同一套框架对象通过 adapter 落到 Unity、Godot、Unreal 的真实对象上;同一套 .NET 10 逻辑程序集,随着项目需要去选择不同引擎,而不是被某个引擎提前锁死。
LeanCLR 要做的,就是把这条路铺得足够轻、足够稳、足够透明。底层把 runtime contract 接好,上层把引擎差异藏进 bridge。最后留给我们的,是更自由的 C# 项目形态:代码可以跨平台,框架可以跨引擎,想法也可以跑得更远一点。







