23 KiB
ShrinkModFramework
一个面向 Unity 与 Godot C# 的轻量模组框架,目标是给 Shrink 系列提供接近 Forge 的核心能力:
共享模组合同和加载规则位于 DotNet~,Godot AssemblyLoadContext 适配与打包工程位于 Godot~。Godot 项目安装 ShrinkSDK.ModFramework.Godot,会同时取得共享核心包。
- 模组声明
- 模组发现
- 依赖解析
- 生命周期阶段
- 内容注册表
- 可选显式启动
- 外部 DLL 模组热加载
- Harmony 热补丁接入
- 网络同步通道
当前定位
这是一套 Unity 可落地的 Forge 风格核心框架,不是 Minecraft Forge 的逐项复刻。
当前重点是:
- 已编译进工程内的模组
- 外部 DLL 模组按内容 revision 的增量发现与替换
ShrinkContextHost驱动的模组组件生命周期与失败恢复- Harmony 补丁自动应用
- 与具体联网库解耦的网络同步框架
当前不包含:
- 运行时卸载已加载程序集(程序集仍受 Unity/Mono AppDomain 限制驻留)
- IL2CPP Player 下的外部 DLL 动态加载
- 内置的资源包系统、命令系统、配方编辑器
- 内置的具体联网实现
启动
安全默认值不会自动启动或扫描外部 DLL。需要装载工程内模组时,可以显式调用
ShrinkModLoader.LoadAll(settings);需要外部代码模组时,调用方必须先完成自己的清单、
启用状态和哈希校验,再提交精确白名单:
ShrinkModRuntimeBootstrap.InitializeDriver(settings);
ShrinkModLoader.LoadAuthorized(settings, authorizedDllPaths);
只有显式打开 autoLoadOnStartup 时,AfterAssembliesLoaded 入口才会装载工程内模组;
该自动入口仍不加载任何未授权外部 DLL。
相关入口:
Runtime/Bootstrap/ShrinkModRuntimeBootstrap.csRuntime/Loading/ShrinkModLoader.cs
ShrinkModBootstrap 仍然保留,作为手动覆盖或调试入口,但不是必需品。
配置文件
菜单 ShrinkSDK/模组/创建设置 会在
Assets/Resources/GameAssets/Runtime/Data/ShrinkSDK/ShrinkModFrameworkSettings.asset
创建配置。运行时优先加载该路径,并保留旧根路径回退。
关键配置包括:
autoLoadOnStartup- 是否在启动时自动装载工程内模组,默认关闭
useContextHost- 默认开启:把模组四阶段放进
ShrinkModContextHost,替换失败恢复旧组件源;关闭后回退旧 Loader
- 默认开启:把模组四阶段放进
verboseLogging- 是否输出详细日志
assemblyNamePrefixes- 只扫描指定前缀的程序集
enableExternalDllMods- 是否允许显式白名单中的外部 DLL 模组,默认关闭
externalModsFolderName- 外部模组目录名,默认
Mods
- 外部模组目录名,默认
watchExternalModsDirectory- 是否监听文件变化并重新提交既有白名单,默认关闭;监听不会扩大授权范围
externalModsReloadDelaySeconds- 文件变更后延迟多少秒再尝试热加载
externalAssemblyRevisionSoftLimit- 外部 DLL 常驻 revision 的软阈值;达到阈值时提示执行 Domain Reload 或重启进程,默认
16
- 外部 DLL 常驻 revision 的软阈值;达到阈值时提示执行 Domain Reload 或重启进程,默认
enableHarmonyPatching- 是否启用 Harmony 自动补丁
enableNetworkSync- 是否启用网络同步框架
目录结构
当前 Assets/Modules/ShrinkModFramework/Runtime/ 按职责拆成:
Bootstrap/- 启动入口、运行时驱动、设置资产
Core/- 模组实例生命周期核心类型
Metadata/- 模组特性、依赖、状态、版本信息
Registry/- 模组注册表与注册表管理器
Loading/- 模组发现、装载、外部 DLL 与 Harmony 接入
Network/- 模组网络抽象层
Integration/- 对现有 Shrink 模块的可选自动接入胶水
推荐阅读顺序:
Bootstrap/Core/+Metadata/Loading/Registry/+Network/+Integration/
另外还有一层编辑器脚手架:
Editor/Scaffolding/ShrinkExternalModTemplateGenerator.csExternalModProjectTemplate/
这层专门给模组开发者生成外部 DLL 模组模板,不参与运行时装载。
模组定义
基础模组
using ShrinkModFramework;
[ShrinkMod("demo.core", "Demo Core", "1.0.0")]
public class DemoCoreMod : ShrinkModBase
{
public override void OnRegisterContent(ShrinkModContext context)
{
context.RegisterContent("items", "iron_hammer", "Iron Hammer");
}
public override void OnInitialize(ShrinkModContext context)
{
context.Log("初始化完成");
}
}
声明依赖
using ShrinkModFramework;
[ShrinkMod("demo.magic", "Demo Magic", "1.0.0")]
[ShrinkModDependency("demo.core", "1.0.0")]
public class DemoMagicMod : ShrinkModBase
{
public override void OnInitialize(ShrinkModContext context)
{
context.Log("我会在 demo.core 之后初始化");
}
}
关闭模组自动补丁
[ShrinkMod("demo.safe", "Demo Safe", "1.0.0", AutoApplyHarmonyPatches = false)]
public class DemoSafeMod : ShrinkModBase
{
}
生命周期
每个模组按依赖顺序进入以下阶段:
OnConstructOnRegisterContentOnInitializeOnReady
推荐职责:
OnConstruct- 初始化本模组运行时对象
OnRegisterContent- 向注册表注册物品、方块、能力、配方等
OnInitialize- 注册事件、接线系统、绑定网络频道
OnReady- 做依赖其他模组最终状态的收尾逻辑
外部 DLL 模组热加载
框架不会递归扫描模组目录。调用方必须把每个允许进入 AppDomain 的入口 DLL 绝对路径
作为白名单提交给 LoadAuthorized;目录中未列出的 DLL 不会被读取或加载。
ContextLoader 装载规则:
- 以 DLL 内容 SHA-256 作为 revision;同 revision 幂等,不触发重载
- 文件变更会加载新程序集并提交新的模组组件源;旧程序集仍驻留,但旧 fiber 的效应会先回滚
- 新模组任何阶段失败时,Host 会重新协调旧 source;旧注册表内容和已登记效应恢复
- 文件删除会移除当前 source 并卸载模组实例;不会宣称程序集已从 AppDomain 卸载
- 不支持 IL2CPP Player 动态程序集加载
- 支持同目录依赖程序集解析
首次安全加载:
ShrinkModRuntimeBootstrap.InitializeDriver(settings);
ShrinkModLoader.LoadAuthorized(settings, authorizedDllPaths);
后续可调用 ShrinkModLoader.LoadNewExternalMods(settings) 重新读取同一白名单的 revision,
把新增、替换、删除映射为完整期望组合;变更事务失败时保留旧模组组合。设置
useContextHost = false 时,旧路径同样只读取显式白名单,但仍保持只增不减的兼容语义。
如果 watchExternalModsDirectory = true,框架会监听目录变化并在主线程 debouncer 后重新提交
既有白名单;未授权文件即使触发通知也不会被加载。同一 burst 内的中间坏文件不会覆盖当前有效 revision。
常驻 revision 诊断
Unity/Mono 不能从当前 AppDomain 单独卸载已载入程序集。框架保留当前 revision 的生效语义,同时通过以下接口暴露实际常驻情况:
var snapshot = ShrinkModDiagnostics.CaptureExternalAssemblies(settings);
快照包含当前与历史 revision、来源路径、程序集名、SHA-256 revision、载入字节数、常驻数量和软阈值状态。失败 revision 可以成为已载入的历史程序集,但不会成为 current;达到软阈值只告警,不伪造卸载行为。
导入 ShrinkContext.AppAdapter 的 Editor 工具后,也可以从 ShrinkSDK/上下文/诊断与组合 查看同一份常驻快照;该窗口通过可选反射读取,不会让 AppAdapter 对 ModFramework 建立硬依赖。
Harmony 热补丁
如果运行环境里存在 0Harmony,ContextHost 会把每个模组的补丁租约作为可逆效应管理:激活时调用
PatchAll(modAssembly),卸载/替换时调用 UnpatchSelf(或兼容的 UnpatchAll(id))。旧 Loader
路径仍按原逻辑只应用一次。
旧路径的调用形态是:
Harmony("shrink.mod.<modId>")PatchAll(modAssembly)
这意味着:
- 模组可以在自己的程序集里直接写 Harmony Patch 类
- 框架负责发现模组后自动补丁,并在 ContextHost 路径登记逆操作
- 如果没有 Harmony,框架只会跳过,不会强依赖崩溃
注意:
- 当前是“可选桥接”,不是把 Harmony 打进框架里
- 需要你自己把
0Harmony.dll放进项目环境或外部模组依赖中
注册表
注册内容
var key = context.RegisterContent("items", "sword", "Sword");
// key == "demo.core:sword"
基础注册不接受任意完整键。框架始终使用当前 ModId 生成 <owner>:<localKey>,避免模组误写其它 owner 的 namespace。
覆盖已有内容
context.OverrideContent(
"items",
"core:iron_sword",
priority: 100,
value: "Overridden Sword");
读取内容
var itemRegistry = context.GetRegistry<string>("items");
if (itemRegistry.TryGet("demo.core:sword", out var itemName))
{
context.Log($"找到内容:{itemName}");
}
规则:
- 基础项的完整键固定为
<ownerModId>:<localKey> context.GetRegistry<T>()只返回IReadOnlyShrinkModRegistry<T>;写入只能经过RegisterContent/OverrideContent- 覆盖目标必须已经存在
- 优先级越高越先命中;同目标、同优先级直接报冲突
- 每条基础项和覆盖项都记录 owner;owner 卸载时自动撤回,并恢复下一个覆盖或基础值
Entries返回当前生效项,BaseEntries返回未应用覆盖的基础项- 注册表按类型和名称双重隔离
网络同步
框架内置的是网络同步抽象层,不是具体联网库。
核心接口:
IShrinkModNetworkTransportShrinkModNetworkManager
这意味着你可以把它接到:
- NGO
- Mirror
- FishNet
- 自己的 Socket/Relay 层
注册消息处理器
context.RegisterNetworkHandler<int>("sync.hp", (messageContext, value) =>
{
Debug.Log($"收到 HP:{value}");
});
发送消息
context.SendToServer("sync.hp", 100);
context.SendToAllClients("sync.hp", 100);
context.SendToClient("sync.hp", 100, "client-1");
绑定传输层
public sealed class MyTransport : IShrinkModNetworkTransport
{
public bool IsServer => true;
public bool IsClient => true;
public event Action<ShrinkModNetworkEnvelope> OnEnvelopeReceived;
public void Send(ShrinkModNetworkEnvelope envelope)
{
// 这里接你自己的网络库
}
}
ShrinkModNetworkManager.SetTransport(new MyTransport());
与现有模块协作
当前仓库里已经有这些可复用模块:
ShrinkEventBusShrinkDataSaverShrinkCommandShrinkCommand.Integration.EventBusShrinkCommand.Integration.NetworkShrinkNetworkShrinkNetwork.Integration.EventBusShrinkDataSaver.Integration.EventBus
ShrinkModFramework 当前的定位不是“重新包一层这些模块”,而是给模组提供统一生命周期,然后在合适阶段把模组实例接到这些模块现成的注册入口上。
自动接入了什么
从当前版本开始,模组经过:
OnConstructOnRegisterContent
之后,框架会自动尝试把模组实例本身接入这些现有模块:
- 如果模组类带
[ShrinkEventSubscriber]且携带生成绑定,自动Attach到ShrinkBusKey.Mod(modId),并在模组生命周期结束时释放 binding - 如果模组类带
[ShrinkCommandSubscriber],自动调用ShrinkCommandRuntime.Default.RegisterCommands(modInstance) - 如果模组类带
[ShrinkNetworkSubscriber],自动调用ShrinkNetworkRuntime.Default.RegisterHandlers(modInstance) - 如果项目里存在
ShrinkNetwork.Integration.EventBus,会额外调用ShrinkNetworkEventBusBridge.RefreshBindings(),把新模组里声明的网络事件类型补进桥接层
这意味着:
- 项目内模组和运行时外部 DLL 模组都可以把实例方法挂到
EventBus / Command / Network - 模组作者不需要自己再写一遍宿主层胶水
- EventBus 是模组框架的正式依赖;Command、Network 及其桥接仍按安装情况接入
推荐写法
最稳的做法是把“和现有模块交互的入口”直接写在模组类实例上,而不是依赖外部 DLL 的静态自动扫描。
using Cysharp.Threading.Tasks;
using ShrinkCommand;
using ShrinkDataSaver;
using ShrinkEventBus;
using ShrinkModFramework;
using ShrinkNetwork;
[ShrinkMod("demo.full", "Demo Full", "1.0.0")]
[ShrinkEventSubscriber(OwnerId = "demo.full", DefaultBus = "mod:demo.full")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed partial class DemoFullMod : ShrinkModBase
{
private DemoSaveData _saveData = new();
public override void OnInitialize(ShrinkModContext context)
{
ShrinkSave.RegisterModule(
$"{context.ModInfo.ModId}.save",
() => _saveData,
data => _saveData = data ?? new DemoSaveData());
context.RegisterNetworkHandler<int>("sync.level", (messageContext, level) =>
{
_saveData.Level = level;
context.Log($"同步等级:{level}");
});
}
[ShrinkSubscribe]
private void OnSaveCompleted(ShrinkDataSaver.Integration.SaveCompletedEvent evt)
{
// 这里只是示意:模组类实例会被框架自动接到 EventBus
}
[ShrinkCommand("demo set-level <value>", Permission = "demo.admin")]
private string SetLevel(int value)
{
_saveData.Level = value;
return $"level={value}";
}
[ShrinkNetworkSubscribe]
private UniTask<DemoPingResponse> HandlePing(DemoPingRequest request)
{
return UniTask.FromResult(new DemoPingResponse
{
Message = "pong:" + request.Message
});
}
}
各模块怎么用
1. ShrinkEventBus
适合:
- 模组内部系统解耦
- 监听
ShrinkDataSaver.Integration.EventBus - 配合
ShrinkNetwork.Integration.EventBus做事件即网络消息
推荐:
- 模组类本身带
[ShrinkEventSubscriber]并声明DefaultBus = "mod:<modId>" - 实例方法上写
[ShrinkSubscribe] - 类型声明为
partial,由 ILPostProcessor 或 Roslyn incremental generator 生成强类型 binding - 由
ShrinkModFramework自动 Attach 到模组 Bus,并随模组生命周期释放
注意:
- Unity 项目内类型由 ILPostProcessor 生成 binding;外部 DLL 使用导出 SDK 中的
ShrinkEventBus.Generator分析器 - 外部 DLL 必须携带生成合同;正式运行时不会反射扫描没有生成合同的旧程序集
- 模组框架只负责把已生成的实例 binding Attach 到对应 Mod Bus,不会枚举方法或调用
MethodInfo.Invoke
2. ShrinkDataSaver
适合:
- 模组自己的配置和进度持久化
- 跨模组只读查询
推荐:
- 在
OnInitialize里调用ShrinkSave.RegisterModule(...) - 模块 key 用
modId做前缀,例如demo.full.save - 读取别的模块数据时优先走
ShrinkSave.QueryModule<T>(...)
当前边界:
ShrinkModFramework不会替你自动注册存档模块,因为每个模组要保存什么只能由业务自己决定- 当前也没有热卸载流程,所以运行时新增 DLL 后注册的存档模块默认跟随本轮进程直到退出
3. ShrinkCommand
适合:
- 模组开放调试命令
- 服务端管理命令
- 配合网络桥接做远程命令
推荐:
- 模组类带
[ShrinkCommandSubscriber] - 实例方法写
[ShrinkCommand("path ...")] - 交给框架自动注册到
ShrinkCommandRuntime.Default
如果要继续往外接:
- 需要 EventBus 请求式命令时,用
ShrinkCommand.Integration.EventBus - 需要远程命令 RPC 时,用
ShrinkCommand.Integration.Network
4. ShrinkNetwork
适合:
- 模组自己的 RPC / 消息协议
- 模组内部状态同步
推荐分两层:
- 简单场景:继续用
context.RegisterNetworkHandler(...)和context.SendToServer/Client/... - 需要完整
ShrinkNetwork能力时:模组类带[ShrinkNetworkSubscriber],实例方法写[ShrinkNetworkSubscribe]
当前自动接入的是:
ShrinkNetworkRuntime.Default.RegisterHandlers(modInstance)
这意味着:
- 模组实例上的网络 handler 可以直接进默认服务
- handler 参数里的消息类型如果带
[ShrinkNetworkMessage],注册时会一起补齐消息元数据
5. ShrinkNetwork.Integration.EventBus
适合:
- 事件本身就是网络协议
- 本地 EventBus 与远端 EventBus 保持同一套事件语义
推荐:
- 事件类型同时满足
IShrinkEvent + IShrinkNetworkMessage - 标记
[ShrinkNetworkEvent] + [ShrinkNetworkMessage(...)] - 让模组监听或发布这些事件,而不是再写一层重复 DTO
当前补的胶水:
- 外部 DLL 模组装载后,框架会调用
ShrinkNetworkEventBusBridge.RefreshBindings()
这能解决:
- 新模组里的网络事件类型,原来桥接层启动时看不到
- 现在新增 DLL 后可把这些事件补进桥接层的入站注册表
项目内模组 vs 外部 DLL 模组
这两类不要混着理解:
- 项目内模组
- 编译时就在 Unity 当前 AppDomain 里
- 由 Unity ILPostProcessor 生成 EventBus binding
- 运行时外部 DLL 模组
- 是
ShrinkModFramework后续动态加载进来的 - 必须引用导出包里的
ShrinkEventBus.Generator.dll生成同一份 binding 合同
- 是
所以当前推荐原则很明确:
- 对外部 DLL,优先写实例方法
- 不要把可发现性建立在“宿主已经提前扫过一次全局静态特性”上
还没自动做的事
当前版本还没有替你自动做这些:
- 不会自动把模组数据注册进
ShrinkDataSaver - 不会自动帮你给
ShrinkModNetworkManager绑定ShrinkNetwork传输层 - 不会为外部 DLL 的静态
EventBus / Command / Network特性类做全局重复扫描
原因很直接:
DataSaver需要业务决定保存什么ShrinkModNetworkManager只是抽象层,具体要不要复用ShrinkNetwork、Mirror、NGO 取决于宿主策略- 对现有静态扫描入口做“再次全局扫描”容易产生重复注册
如果后面要继续往前推,最值得补的不是再加更多反射,而是做两块正式基础设施:
ShrinkModFramework.Integration.ShrinkNetwork- 把
IShrinkModNetworkTransport正式桥到ShrinkNetworkSession / ShrinkNetworkService
- 把
ShrinkModFramework.Integration.DataSaver- 给模组提供规范化的
modId前缀存档注册和可选卸载清理策略
- 给模组提供规范化的
生成模组模板
现在已经提供和 ShrinkNetwork 独立服务器生成器同风格的模组开发入口:
- 菜单:
ShrinkSDK/模组/导出开发包 - 菜单:
ShrinkSDK/模组/生成仓库内模板(推荐) - 菜单:
ShrinkSDK/模组/生成外部模板
更推荐优先用“仓库内模组模板”:
- 直接生成到当前 Unity 项目的
Assets/GeneratedMods/... - 复用当前
ShrinkSDK.sln - 复用当前 Unity / asmdef 编译链
- 不需要单独打开外部
csproj - 也就不会撞到独立工程那类 IDE / workload SDK 解析问题
适合:
- 你自己就在这个 SDK 仓库里开发模组
- 想最快开始写代码
- 想直接享受当前解决方案里的跳转、补全、编译和 Unity 刷新
“外部模组模板”更适合:
- 真正要把模组工程单独发给外部开发者
- 或者明确要独立于当前仓库维护一个 DLL 工程
如果目标是给外部模组开发者发 SDK,当前更推荐直接用:
ShrinkSDK/模组/导出开发包
它会导出:
Libs/- 已编译好的框架运行时 DLL
- 当前游戏 DLL
- 模板构建所需的基础依赖
Templates/ExternalMod/SampleShrinkMod/- 一个已经改成引用
Libs/*.dll的外部模组模板 - 自带
build.cmd与dev-shell.cmd - 不再直接引用你本地仓库
csproj
- 一个已经改成引用
这套开发包比“只给一个模板工程”更适合外发,因为:
- 外部开发者不需要拿到完整仓库
- 不需要依赖你本地
ShrinkSDK.sln - 运行时框架和游戏 API 会跟模板一起发出去
- 当前默认游戏 API 先用
Assembly-CSharp.dll - 后续如果你单独抽出
Game.ModAPI.dll,只需要替换导出内容即可
生成器会:
- 让你选择一个输出目录
- 以目录名推导默认的
ProjectName / Namespace / DisplayName / ModId - 生成一个可独立构建的外部 DLL 模组工程
默认生成内容包括:
README.mdbuild.ps1.gitignoreglobal.json__PROJECT_NAME__.csprojsrc/__PROJECT_NAME__Mod.cssrc/__PROJECT_NAME__Contracts.cs
模板工程默认引用当前仓库里的:
ShrinkModFramework.Runtime.csprojShrinkEventBus.Runtime.csprojShrinkCommand.Runtime.csprojShrinkNetwork.Runtime.csproj
所以它不是一个“纯空壳”,而是直接演示:
[ShrinkMod]ShrinkModBase生命周期EventBus / Command / Network实例自动接入- 一个最小 RPC 请求/响应示例
兼容性处理:
- 模板
csproj默认关闭MSBuild workload resolver - 模板同时会写入
global.json,锁到当前生成时检测到的dotnet --version - 模板还会生成
dev-env.ps1,用于在启动 IDE 前先设置MSBuildEnableWorkloadResolver=false
这样可以尽量避免某些 IDE / MSBuild 环境在打开项目时误触发
Microsoft.NET.SDK.WorkloadAutoImportPropsLocator 解析失败。
建议流程:
- 你自己在当前仓库开发时,优先用
ShrinkSDK/模组/生成仓库内模板(推荐) - 先改
modId、命名空间、命令路径、opcode - 在当前 Unity/IDE 里直接开发和编译
- 如果要发给外部模组开发者,改用
ShrinkSDK/模组/导出开发包
如果你已经知道目标 Mods 目录,也可以直接跑模板里的:
.\build.ps1 -ModsDir "D:\YourGame\Mods"
设计约束
- 一个模组类必须有
[ShrinkMod] - 一个模组类必须实现
IShrinkMod - 一个模组类必须能被无参构造
- 模组 ID 必须全局唯一
- 必选依赖缺失或版本不满足时,装载直接失败
- 检测到循环依赖时,装载直接失败
- 外部 DLL 支持当前组合的添加、替换与删除;已载入的程序集 revision 仍常驻,回滚和卸载单位是模组 fiber 与其效应
关键文件
Runtime/Bootstrap/ShrinkModRuntimeBootstrap.csRuntime/Loading/ShrinkModLoader.csRuntime/Loading/ShrinkExternalModAssemblyLoader.csRuntime/Loading/ShrinkHarmonyPatchService.csRuntime/Registry/ShrinkModRegistry.csRuntime/Network/ShrinkModNetworkManager.csRuntime/Network/IShrinkModNetworkTransport.csRuntime/Integration/ShrinkModOptionalRuntimeIntegration.cs
结论
现在这套框架已经从“只能在工程内静态发现模组”的骨架,升级成了:
- 可显式托管启动
- 可按精确白名单增量接入外部 DLL
- 可选 Harmony 补丁
- 可扩展的网络同步框架
如果继续往 Forge 靠,下一步最值得做的是:
- 模组配置系统
- 模组资源系统
- 模组命令系统
- 模组专属存档与同步策略