ShrinkModFramework

一个面向 Unity 的轻量模组框架,目标是给 Shrink 系列提供接近 Forge 的核心能力:

  • 模组声明
  • 模组发现
  • 依赖解析
  • 生命周期阶段
  • 内容注册表
  • 自动启动
  • 外部 DLL 模组热加载
  • Harmony 热补丁接入
  • 网络同步通道

当前定位

这是一套 Unity 可落地的 Forge 风格核心框架,不是 Minecraft Forge 的逐项复刻。

当前重点是:

  • 已编译进工程内的模组
  • 外部 DLL 模组按内容 revision 的增量发现与替换
  • ShrinkContextHost 驱动的模组组件生命周期与失败恢复
  • Harmony 补丁自动应用
  • 与具体联网库解耦的网络同步框架

当前不包含:

  • 运行时卸载已加载程序集(程序集仍受 Unity/Mono AppDomain 限制驻留)
  • IL2CPP Player 下的外部 DLL 动态加载
  • 内置的资源包系统、命令系统、配方编辑器
  • 内置的具体联网实现

自动启动

现在默认不需要ShrinkModBootstrap 挂到场景里。

框架会通过:

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]

自动读取 ShrinkModFrameworkSettings 并调用装载流程。

相关入口:

  • Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs
  • Runtime/Loading/ShrinkModLoader.cs

ShrinkModBootstrap 仍然保留,作为手动覆盖或调试入口,但不是必需品。

配置文件

创建 ShrinkModFrameworkSettings 资产后,框架会自动查找它。

关键配置包括:

  • autoLoadOnStartup
    • 是否在启动时自动装载模组
  • useContextHost
    • 默认开启:把模组四阶段放进 ShrinkModContextHost,替换失败恢复旧组件源;关闭后回退旧 Loader
  • verboseLogging
    • 是否输出详细日志
  • assemblyNamePrefixes
    • 只扫描指定前缀的程序集
  • enableExternalDllMods
    • 是否启用外部 DLL 模组
  • externalModsFolderName
    • 外部模组目录名,默认 Mods
  • watchExternalModsDirectory
    • 是否自动监听目录变化并协调外部 DLL revision 变化
  • externalModsReloadDelaySeconds
    • 文件变更后延迟多少秒再尝试热加载
  • externalAssemblyRevisionSoftLimit
    • 外部 DLL 常驻 revision 的软阈值;达到阈值时提示执行 Domain Reload 或重启进程,默认 16
  • enableHarmonyPatching
    • 是否启用 Harmony 自动补丁
  • enableNetworkSync
    • 是否启用网络同步框架

目录结构

当前 Assets/Modules/ShrinkModFramework/Runtime/ 按职责拆成:

  • Bootstrap/
    • 启动入口、运行时驱动、设置资产
  • Core/
    • 模组实例生命周期核心类型
  • Metadata/
    • 模组特性、依赖、状态、版本信息
  • Registry/
    • 模组注册表与注册表管理器
  • Loading/
    • 模组发现、装载、外部 DLL 与 Harmony 接入
  • Network/
    • 模组网络抽象层
  • Integration/
    • 对现有 Shrink 模块的可选自动接入胶水

推荐阅读顺序:

  1. Bootstrap/
  2. Core/ + Metadata/
  3. Loading/
  4. Registry/ + Network/ + Integration/

另外还有一层编辑器脚手架:

  • Editor/Scaffolding/
    • ShrinkExternalModTemplateGenerator.cs
    • ExternalModProjectTemplate/

这层专门给模组开发者生成外部 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
{
}

生命周期

每个模组按依赖顺序进入以下阶段:

  1. OnConstruct
  2. OnRegisterContent
  3. OnInitialize
  4. OnReady

推荐职责:

  • OnConstruct
    • 初始化本模组运行时对象
  • OnRegisterContent
    • 向注册表注册物品、方块、能力、配方等
  • OnInitialize
    • 注册事件、接线系统、绑定网络频道
  • OnReady
    • 做依赖其他模组最终状态的收尾逻辑

外部 DLL 模组热加载

框架会扫描:

Application.persistentDataPath/<externalModsFolderName>

默认就是:

Application.persistentDataPath/Mods

ContextLoader 装载规则:

  • 以 DLL 内容 SHA-256 作为 revision;同 revision 幂等,不触发重载
  • 文件变更会加载新程序集并提交新的模组组件源;旧程序集仍驻留,但旧 fiber 的效应会先回滚
  • 新模组任何阶段失败时,Host 会重新协调旧 source;旧注册表内容和已登记效应恢复
  • 文件删除会移除当前 source 并卸载模组实例;不会宣称程序集已从 AppDomain 卸载
  • 不支持 IL2CPP Player 动态程序集加载
  • 支持同目录依赖程序集解析

你可以在运行时调用:

ShrinkModLoader.LoadNewExternalMods();

默认会扫描当前 DLL revision,并把新增、替换、删除映射为一个完整期望组合; 变更事务失败时保留旧模组组合。设置 useContextHost = false 才回退为仅新增 DLL 的旧路径。

如果 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/Cordis/诊断与组合 查看同一份常驻快照;该窗口通过可选反射读取,不会让 AppAdapter 对 ModFramework 建立硬依赖。

Harmony 热补丁

如果运行环境里存在 0HarmonyContextHost 会把每个模组的补丁租约作为可逆效应管理:激活时调用 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 返回未应用覆盖的基础项
  • 注册表按类型和名称双重隔离

网络同步

框架内置的是网络同步抽象层,不是具体联网库。

核心接口:

  • IShrinkModNetworkTransport
  • ShrinkModNetworkManager

这意味着你可以把它接到:

  • 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());

与现有模块协作

当前仓库里已经有这些可复用模块:

  • ShrinkEventBus
  • ShrinkDataSaver
  • ShrinkCommand
  • ShrinkCommand.Integration.EventBus
  • ShrinkCommand.Integration.Network
  • ShrinkNetwork
  • ShrinkNetwork.Integration.EventBus
  • ShrinkDataSaver.Integration.EventBus

ShrinkModFramework 当前的定位不是“重新包一层这些模块”,而是给模组提供统一生命周期,然后在合适阶段把模组实例接到这些模块现成的注册入口上。

自动接入了什么

从当前版本开始,模组经过:

  1. OnConstruct
  2. OnRegisterContent

之后,框架会自动尝试把模组实例本身接入这些现有模块:

  • 如果模组类带 [ShrinkEventSubscriber] 且携带生成绑定,自动 AttachShrinkBusKey.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/Mod/导出 Mod SDK 开发包
  • 菜单:ShrinkSDK/Mod/生成仓库内模组模板(推荐)
  • 菜单:ShrinkSDK/Mod/生成外部模组模板

更推荐优先用“仓库内模组模板”:

  • 直接生成到当前 Unity 项目的 Assets/GeneratedMods/...
  • 复用当前 ShrinkSDK.sln
  • 复用当前 Unity / asmdef 编译链
  • 不需要单独打开外部 csproj
  • 也就不会撞到独立工程那类 IDE / workload SDK 解析问题

适合:

  • 你自己就在这个 SDK 仓库里开发模组
  • 想最快开始写代码
  • 想直接享受当前解决方案里的跳转、补全、编译和 Unity 刷新

“外部模组模板”更适合:

  • 真正要把模组工程单独发给外部开发者
  • 或者明确要独立于当前仓库维护一个 DLL 工程

如果目标是给外部模组开发者发 SDK,当前更推荐直接用:

  • ShrinkSDK/Mod/导出 Mod SDK 开发包

它会导出:

  • Libs/
    • 已编译好的框架运行时 DLL
    • 当前游戏 DLL
    • 模板构建所需的基础依赖
  • Templates/ExternalMod/SampleShrinkMod/
    • 一个已经改成引用 Libs/*.dll 的外部模组模板
    • 自带 build.cmddev-shell.cmd
    • 不再直接引用你本地仓库 csproj

这套开发包比“只给一个模板工程”更适合外发,因为:

  • 外部开发者不需要拿到完整仓库
  • 不需要依赖你本地 ShrinkSDK.sln
  • 运行时框架和游戏 API 会跟模板一起发出去
  • 当前默认游戏 API 先用 Assembly-CSharp.dll
  • 后续如果你单独抽出 Game.ModAPI.dll,只需要替换导出内容即可

生成器会:

  • 让你选择一个输出目录
  • 以目录名推导默认的 ProjectName / Namespace / DisplayName / ModId
  • 生成一个可独立构建的外部 DLL 模组工程

默认生成内容包括:

  • README.md
  • build.ps1
  • .gitignore
  • global.json
  • __PROJECT_NAME__.csproj
  • src/__PROJECT_NAME__Mod.cs
  • src/__PROJECT_NAME__Contracts.cs

模板工程默认引用当前仓库里的:

  • ShrinkModFramework.Runtime.csproj
  • ShrinkEventBus.Runtime.csproj
  • ShrinkCommand.Runtime.csproj
  • ShrinkNetwork.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 解析失败。

建议流程:

  1. 你自己在当前仓库开发时,优先用 ShrinkSDK/Mod/生成仓库内模组模板(推荐)
  2. 先改 modId、命名空间、命令路径、opcode
  3. 在当前 Unity/IDE 里直接开发和编译
  4. 如果要发给外部模组开发者,改用 ShrinkSDK/Mod/导出 Mod SDK 开发包

如果你已经知道目标 Mods 目录,也可以直接跑模板里的:

.\build.ps1 -ModsDir "D:\YourGame\Mods"

设计约束

  • 一个模组类必须有 [ShrinkMod]
  • 一个模组类必须实现 IShrinkMod
  • 一个模组类必须能被无参构造
  • 模组 ID 必须全局唯一
  • 必选依赖缺失或版本不满足时,装载直接失败
  • 检测到循环依赖时,装载直接失败
  • 外部 DLL 支持当前组合的添加、替换与删除;已载入的程序集 revision 仍常驻,回滚和卸载单位是模组 fiber 与其效应

关键文件

  • Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs
  • Runtime/Loading/ShrinkModLoader.cs
  • Runtime/Loading/ShrinkExternalModAssemblyLoader.cs
  • Runtime/Loading/ShrinkHarmonyPatchService.cs
  • Runtime/Registry/ShrinkModRegistry.cs
  • Runtime/Network/ShrinkModNetworkManager.cs
  • Runtime/Network/IShrinkModNetworkTransport.cs
  • Runtime/Integration/ShrinkModOptionalRuntimeIntegration.cs

结论

现在这套框架已经从“只能在工程内静态发现模组”的骨架,升级成了:

  • 自动启动
  • 可增量接入外部 DLL
  • 可选 Harmony 补丁
  • 可扩展的网络同步框架

如果继续往 Forge 靠,下一步最值得做的是:

  • 模组配置系统
  • 模组资源系统
  • 模组命令系统
  • 模组专属存档与同步策略
S
Description
ShrinkSDK Unity UPM package.
Readme
157 KiB
Languages
C# 99%
PowerShell 1%