Files
ShrinkModFramework/README.md
T
cneicy d15bf00512
Publish UPM package / publish (push) Successful in 3s
Publish NuGet packages / publish (push) Successful in 2m18s
feat: 公开宿主组合加载 API、Forge 风格依赖版本区间与加载顺序、程序集解析策略
- ShrinkModComponentDiscovery.Discover 公开:只发现、不执行生命周期
- ShrinkModLoader.LoadComposed / ShrinkModCordisRuntime.ApplyComposedAsync:宿主提供元数据组合加载,支持纯数据包与 IShrinkModWrapper 公开真实实例
- 公开 TryAttachEventBusInstance,随生命周期逆操作释放
- 依赖新增 VersionRange(Maven/Forge 区间语法)与 Ordering(Before/After)
- AssemblyResolvePolicy 可限制跨模组程序集解析
2026-11-10 07:39:22 +08:00

833 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)`;需要外部代码模组时,调用方必须先完成自己的清单、
启用状态和哈希校验,再提交精确白名单:
```csharp
ShrinkModRuntimeBootstrap.InitializeDriver(settings);
ShrinkModLoader.LoadAuthorized(settings, authorizedDllPaths);
```
只有显式打开 `autoLoadOnStartup` 时,`AfterAssembliesLoaded` 入口才会装载工程内模组;
该自动入口仍不加载任何未授权外部 DLL。
相关入口:
- `Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs`
- `Runtime/Loading/ShrinkModLoader.cs`
`ShrinkModBootstrap` 仍然保留,作为手动覆盖或调试入口,但不是必需品。
## 宿主清单组合(0.3.8)
Unity 宿主可以先发现授权 DLL,再用清单替换元数据和生命周期工厂,最后提交完整组合。
`Discover` 不构造模组、不运行生命周期、不改变已提交的 current revision;DLL 会进入 AppDomain,
外部源的 `Revision` 仍为 `SHA-256:入口类型全名`。工程内源仍按 `assemblyNamePrefixes` 筛选。
```csharp
var discovered = ShrinkModComponentDiscovery.Discover(settings, authorizedDllPaths);
var original = discovered.Single(source => source.Info.ModId == "demo.code");
var info = new ShrinkModInfo("demo.code", "代码包", "1.2.0", 0,
original.Info.EntryType, original.Info.AutoApplyHarmonyPatches,
new[] { new ShrinkModDependency("demo.data", "1.0", optional: false) });
var code = original.WithMetadata(info, original.Revision,
() => new PackageWrapper(original.Factory()));
var dataInfo = new ShrinkModInfo("demo.data", "数据包", "1.0", 0,
entryType: null, autoApplyHarmonyPatches: false,
dependencies: Array.Empty<ShrinkModDependency>());
var data = ShrinkModComponentSource.CreateDataOnly(dataInfo, "manifest-sha256");
ShrinkModLoader.LoadComposed(settings, new[] { code, data });
sealed class PackageWrapper : ShrinkModBase, IShrinkModWrapper
{
public PackageWrapper(IShrinkMod instance) => PublicInstance = instance;
public IShrinkMod PublicInstance { get; }
public override void OnConstruct(ShrinkModContext ctx) => PublicInstance.OnConstruct(ctx);
public override void OnRegisterContent(ShrinkModContext ctx) => PublicInstance.OnRegisterContent(ctx);
public override void OnInitialize(ShrinkModContext ctx) => PublicInstance.OnInitialize(ctx);
public override void OnReady(ShrinkModContext ctx) => PublicInstance.OnReady(ctx);
}
```
包装器可以在各阶段加入宿主作用域、数据注册和 `EffectInverse`。`Handle.Instance` 保持原有
`IShrinkMod` 类型,返回 `IShrinkModWrapper.PublicInstance`,自动 EventBus 绑定也使用这个真实实例。
`CreateDataOnly` 的默认工厂不执行业务,公开实例为 null;传入自定义 `Func<IShrinkMod>` 可处理数据注册。
数据包的 `EntryType` 必须为 null,框架不会尝试为它应用 Harmony 补丁。
`LoadComposed` 始终使用可逆 ContextHost,与 `useContextHost` 的旧 Loader 开关无关。每个模组按
依赖/排序关系执行 Construct → RegisterContent → Initialize → Ready;成功后设置 `IsLoaded`,
对新建或重建的 handle 发送 `OnModReady`,再发送 `OnAllModsReady`。失败恢复旧组合及 DLL revision。
相同 revision、元数据和排序前驱保持幂等;工厂行为或清单数据改变时,宿主应更新 revision。
`WithMetadata` 保留发现快照,修改外部源时应使用它。不要把不同扫描中同一路径的不同 revision 混进一次提交。
热更新时重新发现、重新按清单组合,再调用 `LoadComposed`;删除源即卸载,空列表卸载全部实例。
宿主组合模式下 `LoadNewExternalMods` 保留当前组合,内置 watcher 不会丢弃清单元数据;宿主负责触发重组。
已有 `LoadAuthorized`、普通 ContextHost 和旧 Loader 的入口保持可用,不混用旧 Loader 实例与宿主组合。
高级异步宿主可调用 `ShrinkModCordisRuntime.ApplyComposedAsync(settings, sources)`,它与既有
`ApplyDiscoveredAsync` 一样仅负责根事务;需要 Loader 状态与静态通知时使用 `LoadComposed`。
### 依赖区间和排序
```csharp
[ShrinkModDependency("demo.core", optional: false,
VersionRange = "[1.0,2.0)", Ordering = ShrinkModDependencyOrdering.After)]
[ShrinkModDependency("demo.ui", optional: true,
Ordering = ShrinkModDependencyOrdering.Before)]
public sealed class MyMod : ShrinkModBase { }
var dependency = new ShrinkModDependency("demo.core", minimumVersion: null,
optional: true, ordering: ShrinkModDependencyOrdering.After, versionRange: "[1.2,)");
bool matches = ShrinkModVersionRange.Parse("[1.0,2.0)").Contains("1.5.0");
```
区间支持开闭边界、无上/下界、精确值 `[1.2]` 和并集 `(,1.0],[1.2,)`;裸版本 `1.0`
表示 `>= 1.0`。数字版本的尾部零等价(`1.2 == 1.2.0`),带后缀版本沿用框架版本比较规则,
不承诺 Maven 的全部 qualifier 排序规则。无效区间、范围不满足、必选依赖缺失和排序环均明确报错。
`optional` 只决定目标能否缺席;目标存在时仍检查区间。`None` 不添加先后边,`Before` 让本模组
先于目标,`After` 让目标先于本模组。若只想排序且不要求安装目标,使用 `optional: true`。
原 `minimumVersion` 参数与三参数构造函数保留,未指定排序时仍为 `After`;`VersionRange` 优先于
`MinimumVersion`。旧 Loader 的增量路径无法把新模组插到已运行模组之前,会明确要求改用 ContextHost。
### 按清单约束程序集解析
在发现之前设置策略;宿主把程序集简单名称、授权绝对路径映射回自己的 packageId:
```csharp
ShrinkModLoader.AssemblyResolvePolicy = (requestingAssembly, requestedName, candidatePath) =>
{
if (requestingAssembly == null) return false;
if (!assemblyOwners.TryGetValue(requestingAssembly.GetName().Name, out var requesterId)) return false;
if (!pathOwners.TryGetValue(candidatePath, out var candidateId)) return false;
return requesterId == candidateId || declaredDependencies[requesterId].Contains(candidateId);
};
```
`assemblyOwners`、`pathOwners` 和 `declaredDependencies` 由宿主清单建立。回调在返回缓存候选或加载
候选字节前执行,返回 false 拒绝本次解析;候选始终来自授权白名单,策略不能扩大白名单。
未设置策略时保持原行为。入口 DLL 文件名应与程序集简单名称一致。策略仅约束框架的
`AssemblyResolve` 回调,不能拦截 CLR 已解析的程序集引用,也不是不可信代码沙箱。
### 显式绑定生成订阅
宿主组合在 RegisterContent 后自动为公开实例调用以下入口,并将 Dispose 登记为可逆效应;
Initialize/Ready 失败、替换或卸载都会退订。自动路径不需要包装器再手动绑定同一个实例。
如果需要手动绑定其它订阅对象:
```csharp
var binding = ShrinkModOptionalRuntimeIntegration.TryAttachEventBusInstance(
subscriber.GetType(), subscriber, context.ModInfo.ModId, verboseLogging: false);
if (binding != null) context.EffectInverse(binding.Dispose);
```
返回值为 `IDisposable`;没有生成合同或可选绑定失败时返回 null。它使用 `ShrinkBusKey.Mod(modId)`,
不会反射枚举订阅方法。手动调用方负责释放;ContextHost 自动路径还会在退役时移除对应 Mod Bus。
共享 NuGet `ShrinkSDK.ModFramework` 0.3.8 包含依赖元数据和范围解析;上述 Unity 发现/ContextHost API
属于 UPM Runtime。Godot 适配包独立版本为 0.1.7,本次仅更新它对共享包的发布引用。
## 配置文件
菜单 `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`
- `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 模组模板,不参与运行时装载。
## 模组定义
### 基础模组
```csharp
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("初始化完成");
}
}
```
### 声明依赖
```csharp
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 之后初始化");
}
}
```
### 关闭模组自动补丁
```csharp
[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 模组热加载
框架不会递归扫描模组目录。调用方必须把每个允许进入 AppDomain 的入口 DLL 绝对路径
作为白名单提交给 `LoadAuthorized`;目录中未列出的 DLL 不会被读取或加载。
ContextLoader 装载规则:
- 以 DLL 内容 SHA-256 作为 revision;同 revision 幂等,不触发重载
- 文件变更会加载新程序集并提交新的模组组件源;旧程序集仍驻留,但旧 fiber 的效应会先回滚
- 新模组任何阶段失败时,Host 会重新协调旧 source;旧注册表内容和已登记效应恢复
- 文件删除会移除当前 source 并卸载模组实例;不会宣称程序集已从 AppDomain 卸载
- 不支持 IL2CPP Player 动态程序集加载
- 支持同目录依赖程序集解析
首次安全加载:
```csharp
ShrinkModRuntimeBootstrap.InitializeDriver(settings);
ShrinkModLoader.LoadAuthorized(settings, authorizedDllPaths);
```
后续可调用 `ShrinkModLoader.LoadNewExternalMods(settings)` 重新读取同一白名单的 revision,
把新增、替换、删除映射为完整期望组合;变更事务失败时保留旧模组组合。设置
`useContextHost = false` 时,旧路径同样只读取显式白名单,但仍保持只增不减的兼容语义。
如果 `watchExternalModsDirectory = true`,框架会监听目录变化并在主线程 debouncer 后重新提交
既有白名单;未授权文件即使触发通知也不会被加载。同一 burst 内的中间坏文件不会覆盖当前有效 revision。
### 常驻 revision 诊断
Unity/Mono 不能从当前 AppDomain 单独卸载已载入程序集。框架保留当前 revision 的生效语义,同时通过以下接口暴露实际常驻情况:
```csharp
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` 放进项目环境或外部模组依赖中
## 注册表
### 注册内容
```csharp
var key = context.RegisterContent("items", "sword", "Sword");
// key == "demo.core:sword"
```
基础注册不接受任意完整键。框架始终使用当前 `ModId` 生成 `<owner>:<localKey>`,避免模组误写其它 owner 的 namespace。
### 覆盖已有内容
```csharp
context.OverrideContent(
"items",
"core:iron_sword",
priority: 100,
value: "Overridden Sword");
```
### 读取内容
```csharp
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 层
### 注册消息处理器
```csharp
context.RegisterNetworkHandler<int>("sync.hp", (messageContext, value) =>
{
Debug.Log($"收到 HP:{value}");
});
```
### 发送消息
```csharp
context.SendToServer("sync.hp", 100);
context.SendToAllClients("sync.hp", 100);
context.SendToClient("sync.hp", 100, "client-1");
```
### 绑定传输层
```csharp
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]` 且携带生成绑定,自动 `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 的静态自动扫描。
```csharp
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.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/模组/生成仓库内模板(推荐)`
2. 先改 `modId`、命名空间、命令路径、`opcode`
3. 在当前 Unity/IDE 里直接开发和编译
4. 如果要发给外部模组开发者,改用 `ShrinkSDK/模组/导出开发包`
如果你已经知道目标 `Mods` 目录,也可以直接跑模板里的:
```powershell
.\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 靠,下一步最值得做的是:
- 模组配置系统
- 模组资源系统
- 模组命令系统
- 模组专属存档与同步策略
## 可复用能力
<!-- shrink:capabilities -->
Capability: 模组发现、依赖、阶段生命周期、内容注册和外部程序集加载
Aliases: mod 插件 模组 模组退役 热加载 内容注册
Limits: 动态程序集不能假定全部卸载;宿主加载上下文须单独验证;不默认 NativeAOT
Extension: 模组声明、阶段钩子与宿主加载接口
Evidence: [ShrinkModContextHost](Runtime/Context/ShrinkModContextHost.cs)
<!-- /shrink:capabilities -->