feat(sdk): migrate to EventBus 2.0

Replace the legacy EventBase runtime with generated multi-bus bindings and explicit scheduling. Migrate app, data, network, demo, and mod consumers; add generated network-event registration and owner-scoped mod content overrides.
This commit is contained in:
2026-08-26 01:15:48 +08:00
parent 67d32795c4
commit ad5a7b68a3
129 changed files with 5071 additions and 5584 deletions
@@ -2,17 +2,20 @@
本文件记录 `ShrinkModFramework` 在当前工作区中的包内变更。
## [Unreleased]
## [0.2.0] - 2026-08-24
### Added
- 新增 `ShrinkModContextHost` / `ShrinkModComponentSource`,把模组生命周期、Registry、Network handler 与 Harmony lease 纳入可逆组件事务。
- 新增真实外部 DLL fixture 测试,覆盖失败替换恢复、损坏文件、依赖缺失、watcher 去抖与删除卸载。
- 注册表新增 owner namespace、显式优先级覆盖、同优先级冲突拒绝和覆盖候选诊断;owner 卸载时会恢复下一个覆盖或基础项。
- `ShrinkModContext` 新增 `RegisterContent` / `OverrideContent`,模组不再手工传入 owner;查询改为 `IReadOnlyShrinkModRegistry<T>`
### Changed
- `ShrinkModLoader` 默认使用 ContextHost;外部 DLL 以 SHA-256 作为 revision,同内容幂等。
- watcher 监听新增、修改、删除与重命名,并把 burst 文件事件折叠为一次主线程组合提交。
- 移除允许任意完整键的 `ShrinkModRegistry.Register(owner, key, value)`;基础内容必须通过 owner namespace 注册。
### Fixed
@@ -82,10 +82,11 @@ Application.persistentDataPath/Mods
- `[ShrinkMod]` 模组声明
- `ShrinkModBase` 生命周期
- `EventBusSubscriber` 实例自动接入
- `[ShrinkEventSubscriber]` / `[ShrinkSubscribe]` 生成式实例接入 Mod Bus
- `ShrinkCommandSubscriber` 实例命令自动接入
- `ShrinkNetworkSubscriber` 实例网络处理自动接入
- `context.GetRegistry(...)` 注册内容
- `context.RegisterContent(...)` 使用 ModId namespace 注册内容
- `context.OverrideContent(...)` 以显式优先级覆盖已有内容
- `context.RegisterNetworkHandler(...)` 走模组网络抽象层
## 注意
@@ -94,6 +95,7 @@ Application.persistentDataPath/Mods
- 当前模板默认依赖当前仓库根目录下的:
- `ShrinkModFramework.Runtime.csproj`
- `ShrinkEventBus.Runtime.csproj`
- `DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj`(作为 Roslyn analyzer
- `ShrinkCommand.Runtime.csproj`
- `ShrinkNetwork.Runtime.csproj`
- 如果你把模板工程移到别处,需要同步修正 `__PROJECT_NAME__.csproj` 里的 `ProjectReference`
@@ -16,6 +16,9 @@
<ItemGroup>
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkModFramework.Runtime.csproj" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkEventBus.Runtime.csproj" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\DotNet\ShrinkEventBus.Generator\ShrinkEventBus.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkCommand.Runtime.csproj" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkNetwork.Runtime.csproj" />
</ItemGroup>
@@ -5,7 +5,7 @@ using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
public sealed class __PROJECT_NAME__Event : EventBase
public sealed class __PROJECT_NAME__Event : IShrinkEvent
{
public string Message { get; set; } = string.Empty;
}
@@ -8,10 +8,10 @@ using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
[ShrinkMod("__MOD_ID__", "__DISPLAY_NAME__", "1.0.0")]
[EventBusSubscriber]
[ShrinkEventSubscriber(OwnerId = "__MOD_ID__", DefaultBus = "mod:__MOD_ID__")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed class __PROJECT_NAME__Mod : ShrinkModBase
public sealed partial class __PROJECT_NAME__Mod : ShrinkModBase
{
private readonly RuntimeState _state = new();
@@ -22,11 +22,7 @@ namespace __ROOT_NAMESPACE__
public override void OnRegisterContent(ShrinkModContext context)
{
var registry = context.GetRegistry<string>("example.items");
registry.Register(
context.ModInfo.ModId,
context.ModInfo.ModId + ":example_item",
"__DISPLAY_NAME__ Item");
context.RegisterContent("example.items", "example_item", "__DISPLAY_NAME__ Item");
}
public override void OnInitialize(ShrinkModContext context)
@@ -38,7 +34,7 @@ namespace __ROOT_NAMESPACE__
});
}
[EventSubscribe]
[ShrinkSubscribe]
private void OnExampleEvent(__PROJECT_NAME__Event evt)
{
_state.LastEventMessage = evt.Message ?? string.Empty;
@@ -5,7 +5,7 @@ using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
public sealed class __PROJECT_NAME__Event : EventBase
public sealed class __PROJECT_NAME__Event : IShrinkEvent
{
public string Message { get; set; } = string.Empty;
}
@@ -8,10 +8,10 @@ using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
[ShrinkMod("__MOD_ID__", "__DISPLAY_NAME__", "1.0.0")]
[EventBusSubscriber]
[ShrinkEventSubscriber(OwnerId = "__MOD_ID__", DefaultBus = "mod:__MOD_ID__")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed class __PROJECT_NAME__Mod : ShrinkModBase
public sealed partial class __PROJECT_NAME__Mod : ShrinkModBase
{
private readonly RuntimeState _state = new();
@@ -22,11 +22,7 @@ namespace __ROOT_NAMESPACE__
public override void OnRegisterContent(ShrinkModContext context)
{
var registry = context.GetRegistry<string>("example.items");
registry.Register(
context.ModInfo.ModId,
context.ModInfo.ModId + ":example_item",
"__DISPLAY_NAME__ Item");
context.RegisterContent("example.items", "example_item", "__DISPLAY_NAME__ Item");
}
public override void OnInitialize(ShrinkModContext context)
@@ -38,7 +34,7 @@ namespace __ROOT_NAMESPACE__
});
}
[EventSubscribe]
[ShrinkSubscribe]
private void OnExampleEvent(__PROJECT_NAME__Event evt)
{
_state.LastEventMessage = evt.Message ?? string.Empty;
@@ -20,6 +20,7 @@ public static class ShrinkModSdkExporter
{
"ShrinkModFramework.Runtime",
"ShrinkEventBus.Runtime",
"ShrinkEventBus.Generator",
"ShrinkCommand.Runtime",
"ShrinkCommand.Integration.EventBus",
"ShrinkCommand.Integration.Network",
@@ -43,6 +44,7 @@ public static class ShrinkModSdkExporter
{
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
BuildSolution(projectRoot);
BuildEventBusGenerator(projectRoot);
var defaultOutputRoot = Path.Combine(projectRoot, DefaultOutputRootRelativePath);
Directory.CreateDirectory(defaultOutputRoot);
@@ -109,6 +111,30 @@ public static class ShrinkModSdkExporter
}
}
private static void BuildEventBusGenerator(string projectRoot)
{
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "build .\\DotNet\\ShrinkEventBus.Generator\\ShrinkEventBus.Generator.csproj -c Release /nr:false",
WorkingDirectory = projectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null)
throw new InvalidOperationException("无法启动 ShrinkEventBus.Generator 构建。");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
throw new InvalidOperationException("构建 ShrinkEventBus.Generator 失败。\n" + stdout + "\n" + stderr);
}
private static void PrepareOutputDirectory(string outputRoot)
{
Directory.CreateDirectory(outputRoot);
@@ -135,6 +161,7 @@ public static class ShrinkModSdkExporter
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "Assembly-CSharp"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkModFramework.Runtime"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkEventBus.Runtime"),
Path.Combine(projectRoot, "DotNet", "ShrinkEventBus.Generator", "bin", "Release", "netstandard2.0"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Runtime"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Integration.EventBus"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Integration.Network"),
@@ -181,7 +208,7 @@ public static class ShrinkModSdkExporter
builder.AppendLine("## 内容");
builder.AppendLine();
builder.AppendLine("- `Libs/`");
builder.AppendLine(" - 已编译好的框架运行时 DLL、当前游戏 DLL,以及模板需要的基础依赖");
builder.AppendLine(" - 已编译好的框架运行时 DLL、EventBus 代码生成器、当前游戏 DLL,以及模板需要的基础依赖");
builder.AppendLine("- `Templates/ExternalMod/SampleShrinkMod/`");
builder.AppendLine(" - 可直接复制一份开始写外部模组的模板工程");
builder.AppendLine();
@@ -266,6 +293,8 @@ public static class ShrinkModSdkExporter
- `src/`
- 示例模组代码
EventBus handler 只使用 `[ShrinkEventSubscriber]` / `[ShrinkSubscribe]`。项目已把 `Libs/ShrinkEventBus.Generator.dll` 作为 Roslyn analyzer 引入,因此 Attach/Post 路径不依赖运行时反射。
## 推荐构建方式
双击或在命令行运行:
@@ -367,6 +396,10 @@ cmd /k";
builder.AppendLine(" </Reference>");
}
builder.AppendLine(" </ItemGroup>");
builder.AppendLine();
builder.AppendLine(" <ItemGroup>");
builder.AppendLine(" <Analyzer Include=\"..\\..\\..\\Libs\\ShrinkEventBus.Generator.dll\" />");
builder.AppendLine(" </ItemGroup>");
builder.AppendLine("</Project>");
return builder.ToString();
@@ -381,7 +414,7 @@ using ShrinkNetwork;
namespace SampleShrinkMod
{
public sealed class SampleShrinkModEvent : EventBase
public sealed class SampleShrinkModEvent : IShrinkEvent
{
public string Message { get; set; } = string.Empty;
}
@@ -413,10 +446,10 @@ using ShrinkNetwork;
namespace SampleShrinkMod
{
[ShrinkMod(""sample.shrink.mod"", ""Sample Shrink Mod"", ""1.0.0"")]
[EventBusSubscriber]
[ShrinkEventSubscriber(OwnerId = ""sample.shrink.mod"", DefaultBus = ""mod:sample.shrink.mod"")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed class SampleShrinkModMod : ShrinkModBase
public sealed partial class SampleShrinkModMod : ShrinkModBase
{
private readonly RuntimeState _state = new();
@@ -427,11 +460,7 @@ namespace SampleShrinkMod
public override void OnRegisterContent(ShrinkModContext context)
{
var registry = context.GetRegistry<string>(""example.items"");
registry.Register(
context.ModInfo.ModId,
context.ModInfo.ModId + "":example_item"",
""Sample Shrink Mod Item"");
context.RegisterContent(""example.items"", ""example_item"", ""Sample Shrink Mod Item"");
}
public override void OnInitialize(ShrinkModContext context)
@@ -443,7 +472,7 @@ namespace SampleShrinkMod
});
}
[EventSubscribe]
[ShrinkSubscribe]
private void OnExampleEvent(SampleShrinkModEvent evt)
{
_state.LastEventMessage = evt.Message ?? string.Empty;
+38 -21
View File
@@ -125,8 +125,7 @@ public class DemoCoreMod : ShrinkModBase
{
public override void OnRegisterContent(ShrinkModContext context)
{
var itemRegistry = context.GetRegistry<string>("items");
itemRegistry.Register(context.ModInfo.ModId, "demo:iron_hammer", "Iron Hammer");
context.RegisterContent("items", "iron_hammer", "Iron Hammer");
}
public override void OnInitialize(ShrinkModContext context)
@@ -250,14 +249,27 @@ var snapshot = ShrinkModDiagnostics.CaptureExternalAssemblies(settings);
### 注册内容
```csharp
var itemRegistry = context.GetRegistry<string>("items");
itemRegistry.Register(context.ModInfo.ModId, "demo:sword", "Sword");
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
if (itemRegistry.TryGet("demo:sword", out var itemName))
var itemRegistry = context.GetRegistry<string>("items");
if (itemRegistry.TryGet("demo.core:sword", out var itemName))
{
context.Log($"找到内容:{itemName}");
}
@@ -265,8 +277,12 @@ if (itemRegistry.TryGet("demo:sword", out var itemName))
规则:
- 一个注册表内的键必须唯一
- 每条注册项都记录归属模组
- 基础项的完整键固定为 `<ownerModId>:<localKey>`
- `context.GetRegistry<T>()` 只返回 `IReadOnlyShrinkModRegistry<T>`;写入只能经过 `RegisterContent` / `OverrideContent`
- 覆盖目标必须已经存在
- 优先级越高越先命中;同目标、同优先级直接报冲突
- 每条基础项和覆盖项都记录 owner;owner 卸载时自动撤回,并恢复下一个覆盖或基础值
- `Entries` 返回当前生效项,`BaseEntries` 返回未应用覆盖的基础项
- 注册表按类型和名称双重隔离
## 网络同步
@@ -345,7 +361,7 @@ ShrinkModNetworkManager.SetTransport(new MyTransport());
之后,框架会自动尝试把**模组实例本身**接入这些现有模块:
- 如果模组类带 `[EventBusSubscriber]`,自动调用 `EventBus.AutoRegister(modInstance)`
- 如果模组类带 `[ShrinkEventSubscriber]` 且携带生成绑定,自动 `Attach``ShrinkBusKey.Mod(modId)`,并在模组生命周期结束时释放 binding
- 如果模组类带 `[ShrinkCommandSubscriber]`,自动调用 `ShrinkCommandRuntime.Default.RegisterCommands(modInstance)`
- 如果模组类带 `[ShrinkNetworkSubscriber]`,自动调用 `ShrinkNetworkRuntime.Default.RegisterHandlers(modInstance)`
- 如果项目里存在 `ShrinkNetwork.Integration.EventBus`,会额外调用 `ShrinkNetworkEventBusBridge.RefreshBindings()`,把新模组里声明的网络事件类型补进桥接层
@@ -354,7 +370,7 @@ ShrinkModNetworkManager.SetTransport(new MyTransport());
- **项目内模组**和**运行时外部 DLL 模组**都可以把实例方法挂到 `EventBus / Command / Network`
- 模组作者不需要自己再写一遍宿主层胶水
- `ShrinkModFramework` 本身仍然没有硬引用这些包,保持可选依赖关系
- EventBus 是模组框架的正式依赖;Command、Network 及其桥接仍按安装情况接入
### 推荐写法
@@ -369,10 +385,10 @@ using ShrinkModFramework;
using ShrinkNetwork;
[ShrinkMod("demo.full", "Demo Full", "1.0.0")]
[EventBusSubscriber]
[ShrinkEventSubscriber(OwnerId = "demo.full", DefaultBus = "mod:demo.full")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed class DemoFullMod : ShrinkModBase
public sealed partial class DemoFullMod : ShrinkModBase
{
private DemoSaveData _saveData = new();
@@ -390,7 +406,7 @@ public sealed class DemoFullMod : ShrinkModBase
});
}
[EventSubscribe]
[ShrinkSubscribe]
private void OnSaveCompleted(ShrinkDataSaver.Integration.SaveCompletedEvent evt)
{
// 这里只是示意:模组类实例会被框架自动接到 EventBus
@@ -426,15 +442,16 @@ public sealed class DemoFullMod : ShrinkModBase
推荐:
- 模组类本身带 `[EventBusSubscriber]`
- 实例方法上写 `[EventSubscribe]`
- `ShrinkModFramework` 自动 `AutoRegister`
- 模组类本身带 `[ShrinkEventSubscriber]` 并声明 `DefaultBus = "mod:<modId>"`
- 实例方法上写 `[ShrinkSubscribe]`
- 类型声明为 `partial`,由 ILPostProcessor 或 Roslyn incremental generator 生成强类型 binding
-`ShrinkModFramework` 自动 Attach 到模组 Bus,并随模组生命周期释放
注意:
- `ShrinkEventBus``MonoBehaviour` 零侵入自动注册依赖编译期织入
- 对**运行时外部 DLL**,不要假设它也会自动织入生命周期
- 外部 DLL 更稳的方式是“实例订阅 + 框架自动 `EventBus.AutoRegister(modInstance)`
- Unity 项目内类型由 ILPostProcessor 生成 binding;外部 DLL 使用导出 SDK 中的 `ShrinkEventBus.Generator` 分析器
- 外部 DLL 必须携带生成合同;正式运行时不会反射扫描没有生成合同的旧程序集
- 模组框架只负责把已生成的实例 binding Attach 到对应 Mod Bus,不会枚举方法或调用 `MethodInfo.Invoke`
#### 2. `ShrinkDataSaver`
@@ -503,7 +520,7 @@ public sealed class DemoFullMod : ShrinkModBase
推荐:
- 事件类型同时满足 `EventBase + IShrinkNetworkMessage`
- 事件类型同时满足 `IShrinkEvent + IShrinkNetworkMessage`
- 标记 `[ShrinkNetworkEvent] + [ShrinkNetworkMessage(...)]`
- 让模组监听或发布这些事件,而不是再写一层重复 DTO
@@ -522,10 +539,10 @@ public sealed class DemoFullMod : ShrinkModBase
- **项目内模组**
- 编译时就在 Unity 当前 AppDomain 里
- 更容易吃到各模块自己的静态扫描/自动注册能力
- 由 Unity ILPostProcessor 生成 EventBus binding
- **运行时外部 DLL 模组**
-`ShrinkModFramework` 后续动态加载进来的
- 更稳的是走“模组实例注册”而不是“静态全局扫描”
- 必须引用导出包里的 `ShrinkEventBus.Generator.dll` 生成同一份 binding 合同
所以当前推荐原则很明确:
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using ShrinkContext;
using ShrinkEventBus;
namespace ShrinkModFramework
{
@@ -66,6 +67,16 @@ namespace ShrinkModFramework
instance.OnRegisterContent(modContext);
handle.State = ShrinkModState.ContentRegistered;
IDisposable eventBinding = null;
ctx.Effect(
() => eventBinding = ShrinkModOptionalRuntimeIntegration.TryAttachEventBusInstance(
instance.GetType(), instance, handle.Info.ModId, _host.VerboseLogging),
() =>
{
eventBinding?.Dispose();
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
});
// 模组网络 handler 以 ModId 为归属键,可在失败或卸载时整体撤回。
ctx.Effect(() => { }, () => ShrinkModNetworkManager.UnregisterHandlers(handle.Info.ModId));
instance.OnInitialize(modContext);
@@ -54,7 +54,7 @@ namespace ShrinkModFramework
public IReadOnlyList<ShrinkModComponentSource> CurrentSources => _currentSources.ToArray();
internal ShrinkModRegistryManager RegistryManager { get; }
public ShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
public IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
RegistryManager.GetOrCreateRegistry<T>(name);
public static string GetModKey(string modId)
@@ -50,7 +50,7 @@ namespace ShrinkModFramework
return _host.Mods;
}
public static ShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
public static IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
Host.GetOrCreateRegistry<T>(name);
internal static void ResetForDomainReload()
@@ -27,11 +27,22 @@ namespace ShrinkModFramework
public IReadOnlyDictionary<string, ShrinkModHandle> LoadedMods => _loadedMods;
public bool IsContextManaged => _effectContext != null;
public ShrinkModRegistry<T> GetRegistry<T>(string name) => _registryManager.GetOrCreateRegistry<T>(name);
public IReadOnlyShrinkModRegistry<T> GetRegistry<T>(string name) =>
_registryManager.GetOrCreateRegistry<T>(name);
public bool TryGetRegistry<T>(string name, out ShrinkModRegistry<T> registry)
public bool TryGetRegistry<T>(string name, out IReadOnlyShrinkModRegistry<T> registry)
=> _registryManager.TryGetRegistry(name, out registry);
/// <summary>用当前 ModId 作为 namespace 注册内容,并返回最终完整键。</summary>
public string RegisterContent<T>(string registryName, string localKey, T value) =>
_registryManager.GetOrCreateMutableRegistry<T>(registryName)
.RegisterNamespaced(ModInfo.ModId, localKey, value);
/// <summary>以当前模组为 owner 覆盖已有内容;同目标、同优先级冲突会直接失败。</summary>
public void OverrideContent<T>(string registryName, string targetKey, int priority, T value) =>
_registryManager.GetOrCreateMutableRegistry<T>(registryName)
.RegisterOverride(ModInfo.ModId, targetKey, priority, value);
public bool IsModLoaded(string modId) => !string.IsNullOrWhiteSpace(modId) && _loadedMods.ContainsKey(modId);
public bool TryGetLoadedMod(string modId, out ShrinkModHandle handle)
@@ -1,3 +1,5 @@
using System;
namespace ShrinkModFramework
{
public sealed class ShrinkModHandle
@@ -6,6 +8,7 @@ namespace ShrinkModFramework
public IShrinkMod Instance { get; }
public ShrinkModState State { get; internal set; }
public long Generation { get; internal set; }
internal IDisposable EventBinding { get; set; }
internal ShrinkModHandle(ShrinkModInfo info, IShrinkMod instance)
{
@@ -1,21 +1,24 @@
using System;
using System.Linq;
using System.Reflection;
using ShrinkEventBus;
using UnityEngine;
namespace ShrinkModFramework
{
internal static class ShrinkModOptionalRuntimeIntegration
{
public static void RegisterModInstance(IShrinkMod modInstance, ShrinkModInfo modInfo, bool verboseLogging)
public static IDisposable RegisterModInstance(IShrinkMod modInstance, ShrinkModInfo modInfo,
bool verboseLogging)
{
if (modInstance == null || modInfo == null)
return;
return null;
var modType = modInstance.GetType();
TryRegisterEventBusInstance(modType, modInstance, modInfo.ModId, verboseLogging);
var eventBinding = TryAttachEventBusInstance(modType, modInstance, modInfo.ModId, verboseLogging);
TryRegisterCommandInstance(modType, modInstance, modInfo.ModId, verboseLogging);
TryRegisterNetworkInstance(modType, modInstance, modInfo.ModId, verboseLogging);
return eventBinding;
}
public static void RefreshGlobalBindings(bool verboseLogging)
@@ -23,29 +26,25 @@ namespace ShrinkModFramework
TryRefreshNetworkEventBusBridge(verboseLogging);
}
private static void TryRegisterEventBusInstance(Type modType, object modInstance, string modId, bool verboseLogging)
public static IDisposable TryAttachEventBusInstance(Type modType, object modInstance,
string modId, bool verboseLogging)
{
if (!HasAttribute(modType, "ShrinkEventBus.EventBusSubscriberAttribute", "ShrinkEventBus.Runtime"))
return;
if (modInstance is not IShrinkGeneratedSubscriber)
return null;
try
{
var eventBusType = FindType("ShrinkEventBus.EventBus", "ShrinkEventBus.Runtime");
var autoRegisterMethod = eventBusType?.GetMethod("AutoRegister",
BindingFlags.Public | BindingFlags.Static,
null,
new[] { typeof(object) },
null);
if (autoRegisterMethod == null)
return;
autoRegisterMethod.Invoke(null, new[] { modInstance });
var busKey = ShrinkBusKey.Mod(modId);
EventBus.GetOrCreateBus(busKey, ShrinkBusOptions.DedicatedThread());
var binding = EventBus.Attach(modInstance, busKey);
if (verboseLogging)
Debug.Log($"[ShrinkModFramework] 模组 {modId} 已接入 ShrinkEventBus 实例订阅。");
return binding;
}
catch (Exception ex)
{
Debug.LogWarning($"[ShrinkModFramework] 模组 {modId} 接入 ShrinkEventBus 失败:{ex.Message}");
return null;
}
}
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using ShrinkEventBus;
using UnityEngine;
namespace ShrinkModFramework
@@ -106,6 +107,9 @@ namespace ShrinkModFramework
{
foreach (var handle in newHandles)
{
handle.EventBinding?.Dispose();
handle.EventBinding = null;
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
LoadedMods.Remove(handle.Info.ModId);
LoadSequence.Remove(handle);
}
@@ -123,7 +127,7 @@ namespace ShrinkModFramework
return false;
}
public static ShrinkModRegistry<T> GetOrCreateRegistry<T>(string name)
public static IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name)
=> ShrinkModCordisRuntime.IsInitialized
? ShrinkModCordisRuntime.GetOrCreateRegistry<T>(name)
: RegistryManager.GetOrCreateRegistry<T>(name);
@@ -131,6 +135,7 @@ namespace ShrinkModFramework
internal static void ResetForTesting()
{
ShrinkModCordisRuntime.ResetForTesting();
DisposeLegacyEventBindings();
LoadedMods.Clear();
LoadSequence.Clear();
RegistryManager.Clear();
@@ -145,6 +150,7 @@ namespace ShrinkModFramework
internal static void ResetForDomainReload()
{
ShrinkModCordisRuntime.ResetForDomainReload();
DisposeLegacyEventBindings();
LoadedMods.Clear();
LoadSequence.Clear();
RegistryManager.Clear();
@@ -383,11 +389,22 @@ namespace ShrinkModFramework
private static void RegisterOptionalRuntimeIntegrations(IEnumerable<ShrinkModHandle> handles, bool verboseLogging)
{
foreach (var handle in handles)
ShrinkModOptionalRuntimeIntegration.RegisterModInstance(handle.Instance, handle.Info, verboseLogging);
handle.EventBinding = ShrinkModOptionalRuntimeIntegration.RegisterModInstance(
handle.Instance, handle.Info, verboseLogging);
ShrinkModOptionalRuntimeIntegration.RefreshGlobalBindings(verboseLogging);
}
private static void DisposeLegacyEventBindings()
{
foreach (var handle in LoadedMods.Values)
{
handle.EventBinding?.Dispose();
handle.EventBinding = null;
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
}
}
private static void RunPhase(IEnumerable<ShrinkModHandle> handles, ShrinkModState targetState, bool verboseLogging,
Action<IShrinkMod, ShrinkModContext> callback, string phaseName)
{
@@ -4,14 +4,28 @@ using System.Linq;
namespace ShrinkModFramework
{
public interface IReadOnlyShrinkModRegistry<T>
{
string Name { get; }
IReadOnlyCollection<ShrinkRegistryEntry<T>> Entries { get; }
IReadOnlyCollection<ShrinkRegistryEntry<T>> BaseEntries { get; }
bool Contains(string key);
bool TryGet(string key, out T value);
bool TryGetEntry(string key, out ShrinkRegistryEntry<T> entry);
IReadOnlyList<ShrinkRegistryEntry<T>> GetOverrideCandidates(string key);
}
internal interface IShrinkOwnedRegistry
{
void RemoveOwnedEntries(string ownerModId);
}
public sealed class ShrinkModRegistry<T> : IShrinkOwnedRegistry
internal sealed class ShrinkModRegistry<T> : IReadOnlyShrinkModRegistry<T>, IShrinkOwnedRegistry
{
private readonly Dictionary<string, ShrinkRegistryEntry<T>> _entries = new();
private readonly Dictionary<string, ShrinkRegistryEntry<T>> _baseEntries =
new(StringComparer.Ordinal);
private readonly Dictionary<string, List<ShrinkRegistryEntry<T>>> _overrides =
new(StringComparer.Ordinal);
public string Name { get; }
@@ -23,27 +37,88 @@ namespace ShrinkModFramework
Name = name.Trim();
}
public IReadOnlyCollection<ShrinkRegistryEntry<T>> Entries => _entries.Values.ToArray();
/// <summary>当前生效项;存在覆盖时返回最高优先级的覆盖项。</summary>
public IReadOnlyCollection<ShrinkRegistryEntry<T>> Entries => _baseEntries.Keys
.OrderBy(key => key, StringComparer.Ordinal)
.Select(ResolveEntry)
.ToArray();
public void Register(string ownerModId, string key, T value)
/// <summary>不应用覆盖的基础注册项快照。</summary>
public IReadOnlyCollection<ShrinkRegistryEntry<T>> BaseEntries => _baseEntries.Values
.OrderBy(entry => entry.Key, StringComparer.Ordinal)
.ToArray();
private void RegisterBase(string ownerModId, string key, T value)
{
if (string.IsNullOrWhiteSpace(ownerModId))
throw new ArgumentException("归属模组 ID 不能为空。", nameof(ownerModId));
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("注册键不能为空。", nameof(key));
key = key.Trim();
if (_entries.ContainsKey(key))
ownerModId = NormalizeOwner(ownerModId);
key = NormalizeKey(key);
if (_baseEntries.ContainsKey(key))
throw new InvalidOperationException($"注册表 '{Name}' 中已存在键 '{key}'。");
_entries.Add(key, new ShrinkRegistryEntry<T>(ownerModId.Trim(), key, value));
_baseEntries.Add(key, new ShrinkRegistryEntry<T>(ownerModId, key, value));
}
public bool Contains(string key) => !string.IsNullOrWhiteSpace(key) && _entries.ContainsKey(key.Trim());
/// <summary>
/// 使用 owner 作为 namespace 注册内容,并返回最终键。localKey 只能是局部名称,不能包含冒号。
/// </summary>
public string RegisterNamespaced(string ownerModId, string localKey, T value)
{
ownerModId = NormalizeOwner(ownerModId);
if (ownerModId.IndexOf(':') >= 0)
throw new ArgumentException("归属模组 ID 不能包含 ':'。", nameof(ownerModId));
if (string.IsNullOrWhiteSpace(localKey))
throw new ArgumentException("局部注册键不能为空。", nameof(localKey));
localKey = localKey.Trim();
if (localKey.IndexOf(':') >= 0)
throw new ArgumentException("局部注册键不能包含 ':'。", nameof(localKey));
var key = ownerModId + ":" + localKey;
RegisterBase(ownerModId, key, value);
return key;
}
/// <summary>
/// 为已有基础项注册显式覆盖。优先级越高越先命中;同一目标的相同优先级视为硬冲突。
/// </summary>
public void RegisterOverride(string ownerModId, string targetKey, int priority, T value)
{
ownerModId = NormalizeOwner(ownerModId);
targetKey = NormalizeKey(targetKey);
if (!_baseEntries.ContainsKey(targetKey))
throw new InvalidOperationException(
$"注册表 '{Name}' 的覆盖目标 '{targetKey}' 不存在。");
if (!_overrides.TryGetValue(targetKey, out var candidates))
{
candidates = new List<ShrinkRegistryEntry<T>>();
_overrides.Add(targetKey, candidates);
}
if (candidates.Any(candidate => candidate.Priority == priority))
throw new InvalidOperationException(
$"注册表 '{Name}' 的键 '{targetKey}' 在优先级 {priority} 存在覆盖冲突。");
candidates.Add(ShrinkRegistryEntry<T>.CreateOverride(ownerModId, targetKey, priority, value));
}
public IReadOnlyList<ShrinkRegistryEntry<T>> GetOverrideCandidates(string key)
{
if (string.IsNullOrWhiteSpace(key) || !_overrides.TryGetValue(key.Trim(), out var candidates))
return Array.Empty<ShrinkRegistryEntry<T>>();
return candidates
.OrderByDescending(candidate => candidate.Priority)
.ThenBy(candidate => candidate.OwnerModId, StringComparer.Ordinal)
.ToArray();
}
public bool Contains(string key) =>
!string.IsNullOrWhiteSpace(key) && _baseEntries.ContainsKey(key.Trim());
public bool TryGet(string key, out T value)
{
if (!string.IsNullOrWhiteSpace(key) && _entries.TryGetValue(key.Trim(), out var entry))
if (TryGetEntry(key, out var entry))
{
value = entry.Value;
return true;
@@ -55,8 +130,11 @@ namespace ShrinkModFramework
public bool TryGetEntry(string key, out ShrinkRegistryEntry<T> entry)
{
if (!string.IsNullOrWhiteSpace(key))
return _entries.TryGetValue(key.Trim(), out entry);
if (!string.IsNullOrWhiteSpace(key) && _baseEntries.ContainsKey(key.Trim()))
{
entry = ResolveEntry(key.Trim());
return true;
}
entry = default;
return false;
@@ -67,12 +145,52 @@ namespace ShrinkModFramework
if (string.IsNullOrWhiteSpace(ownerModId))
return;
var keys = _entries
ownerModId = ownerModId.Trim();
var keys = _baseEntries
.Where(pair => string.Equals(pair.Value.OwnerModId, ownerModId, StringComparison.Ordinal))
.Select(pair => pair.Key)
.ToArray();
foreach (var key in keys)
_entries.Remove(key);
_baseEntries.Remove(key);
var emptyOverrideTargets = new List<string>();
foreach (var pair in _overrides)
{
pair.Value.RemoveAll(candidate =>
string.Equals(candidate.OwnerModId, ownerModId, StringComparison.Ordinal));
if (pair.Value.Count == 0)
emptyOverrideTargets.Add(pair.Key);
}
foreach (var targetKey in emptyOverrideTargets)
_overrides.Remove(targetKey);
}
private ShrinkRegistryEntry<T> ResolveEntry(string key)
{
if (_overrides.TryGetValue(key, out var candidates) && candidates.Count > 0)
{
return candidates
.OrderByDescending(candidate => candidate.Priority)
.ThenBy(candidate => candidate.OwnerModId, StringComparer.Ordinal)
.First();
}
return _baseEntries[key];
}
private static string NormalizeOwner(string ownerModId)
{
if (string.IsNullOrWhiteSpace(ownerModId))
throw new ArgumentException("归属模组 ID 不能为空。", nameof(ownerModId));
return ownerModId.Trim();
}
private static string NormalizeKey(string key)
{
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("注册键不能为空。", nameof(key));
return key.Trim();
}
}
@@ -81,12 +199,25 @@ namespace ShrinkModFramework
public string OwnerModId { get; }
public string Key { get; }
public T Value { get; }
public bool IsOverride { get; }
public int Priority { get; }
public ShrinkRegistryEntry(string ownerModId, string key, T value)
: this(ownerModId, key, value, false, 0)
{
}
private ShrinkRegistryEntry(string ownerModId, string key, T value, bool isOverride, int priority)
{
OwnerModId = ownerModId;
Key = key;
Value = value;
IsOverride = isOverride;
Priority = priority;
}
internal static ShrinkRegistryEntry<T> CreateOverride(
string ownerModId, string key, int priority, T value) =>
new(ownerModId, key, value, true, priority);
}
}
@@ -7,7 +7,10 @@ namespace ShrinkModFramework
{
private readonly Dictionary<string, object> _registries = new();
public ShrinkModRegistry<T> GetOrCreateRegistry<T>(string name)
public IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
GetOrCreateMutableRegistry<T>(name);
internal ShrinkModRegistry<T> GetOrCreateMutableRegistry<T>(string name)
{
var compositeKey = BuildCompositeKey(typeof(T), name);
if (_registries.TryGetValue(compositeKey, out var existing))
@@ -18,7 +21,7 @@ namespace ShrinkModFramework
return registry;
}
public bool TryGetRegistry<T>(string name, out ShrinkModRegistry<T> registry)
public bool TryGetRegistry<T>(string name, out IReadOnlyShrinkModRegistry<T> registry)
{
var compositeKey = BuildCompositeKey(typeof(T), name);
if (_registries.TryGetValue(compositeKey, out var existing))
@@ -4,6 +4,7 @@
"references": [
"Newtonsoft.Json",
"ShrinkContext.Core.Runtime",
"ShrinkEventBus.Runtime",
"UniTask"
],
"includePlatforms": [],
@@ -18,6 +18,91 @@ namespace ShrinkModFramework.Tests
{
public sealed class ShrinkModContextHostTests
{
[Test]
public void RegistryNamespacedRegistration_BuildsOwnedKeyAndRejectsAmbiguousLocalKeys()
{
var registry = new ShrinkModRegistryManager().GetOrCreateMutableRegistry<string>("items");
var key = registry.RegisterNamespaced("example.mod", "iron_hammer", "Iron Hammer");
Assert.AreEqual("example.mod:iron_hammer", key);
Assert.IsTrue(registry.TryGet(key, out var value));
Assert.AreEqual("Iron Hammer", value);
Assert.Throws<ArgumentException>(() =>
registry.RegisterNamespaced("example.mod", "other:key", "invalid"));
Assert.Throws<ArgumentException>(() =>
registry.RegisterNamespaced("invalid:owner", "key", "invalid"));
}
[Test]
public void RegistryOverrides_HighestPriorityWinsAndOwnerRemovalRestoresPreviousValue()
{
var manager = new ShrinkModRegistryManager();
var registry = manager.GetOrCreateMutableRegistry<string>("items");
registry.RegisterNamespaced("core", "iron_hammer", "Core");
registry.RegisterOverride("mod.low", "core:iron_hammer", 10, "Low");
registry.RegisterOverride("mod.high", "core:iron_hammer", 20, "High");
Assert.IsTrue(registry.TryGetEntry("core:iron_hammer", out var high));
Assert.AreEqual("High", high.Value);
Assert.AreEqual("mod.high", high.OwnerModId);
Assert.IsTrue(high.IsOverride);
Assert.AreEqual(20, high.Priority);
Assert.AreEqual("mod.high", registry.Entries.Single().OwnerModId);
manager.RemoveOwnedEntries("mod.high");
Assert.IsTrue(registry.TryGetEntry("core:iron_hammer", out var low));
Assert.AreEqual("Low", low.Value);
Assert.AreEqual("mod.low", low.OwnerModId);
manager.RemoveOwnedEntries("mod.low");
Assert.IsTrue(registry.TryGetEntry("core:iron_hammer", out var original));
Assert.AreEqual("Core", original.Value);
Assert.AreEqual("core", original.OwnerModId);
Assert.IsFalse(original.IsOverride);
manager.RemoveOwnedEntries("core");
Assert.IsFalse(registry.Contains("core:iron_hammer"));
Assert.IsFalse(registry.TryGet("core:iron_hammer", out _));
}
[Test]
public void RegistryOverrides_RejectMissingTargetsAndSamePriorityConflicts()
{
var registry = new ShrinkModRegistryManager().GetOrCreateMutableRegistry<string>("items");
Assert.Throws<InvalidOperationException>(() =>
registry.RegisterOverride("mod.one", "core:missing", 10, "missing"));
registry.RegisterNamespaced("core", "iron_hammer", "Core");
registry.RegisterOverride("mod.one", "core:iron_hammer", 10, "One");
Assert.Throws<InvalidOperationException>(() =>
registry.RegisterOverride("mod.two", "core:iron_hammer", 10, "Two"));
Assert.That(registry.GetOverrideCandidates("core:iron_hammer").Select(entry => entry.OwnerModId),
Is.EqualTo(new[] { "mod.one" }));
}
[Test]
public void ModContext_ExposesReadOnlyRegistryAndOwnedWriteCommands()
{
var getRegistry = typeof(ShrinkModContext).GetMethod(nameof(ShrinkModContext.GetRegistry));
Assert.IsNotNull(getRegistry);
Assert.IsTrue(getRegistry!.ReturnType.IsGenericType);
Assert.AreEqual(typeof(IReadOnlyShrinkModRegistry<>),
getRegistry.ReturnType.GetGenericTypeDefinition());
var readMethods = typeof(IReadOnlyShrinkModRegistry<string>)
.GetMethods()
.Select(method => method.Name)
.ToArray();
Assert.That(readMethods, Does.Not.Contain("Register"));
Assert.That(readMethods, Does.Not.Contain("RegisterNamespaced"));
Assert.That(readMethods, Does.Not.Contain("RegisterOverride"));
Assert.IsNotNull(typeof(ShrinkModContext).GetMethod(nameof(ShrinkModContext.RegisterContent)));
Assert.IsNotNull(typeof(ShrinkModContext).GetMethod(nameof(ShrinkModContext.OverrideContent)));
}
[Test]
public void FailedReplacement_RestoresPreviousModEffectsAndRegistryContent()
{
@@ -34,8 +119,7 @@ namespace ShrinkModFramework.Tests
() => activeEffects.Remove("old"));
},
onRegisterContent: context =>
context.GetRegistry<string>("items")
.Register(context.ModInfo.ModId, "test:item", "old")));
context.RegisterContent("items", "item", "old")));
var failingSource = Source("test.transaction", "2.0.0", "broken", () =>
new DelegateMod(
@@ -43,8 +127,7 @@ namespace ShrinkModFramework.Tests
() => activeEffects.Add("new-partial"),
() => activeEffects.Remove("new-partial")),
onRegisterContent: context =>
context.GetRegistry<string>("items")
.Register(context.ModInfo.ModId, "test:item", "new"),
context.RegisterContent("items", "item", "new"),
onInitialize: _ => throw new InvalidOperationException("new mod failed")));
try
@@ -73,7 +156,7 @@ namespace ShrinkModFramework.Tests
Assert.IsEmpty(activeEffects);
Assert.IsEmpty(host.Mods);
Assert.IsFalse(host.GetOrCreateRegistry<string>("items").Contains("test:item"));
Assert.IsFalse(host.GetOrCreateRegistry<string>("items").Contains("test.transaction:item"));
}
[Test]
@@ -365,7 +448,7 @@ namespace ShrinkModFramework.Tests
private static void AssertRegistryValue(ShrinkModContextHost host, string expected)
{
var registry = host.GetOrCreateRegistry<string>("items");
Assert.IsTrue(registry.TryGet("test:item", out var value));
Assert.IsTrue(registry.TryGet("test.transaction:item", out var value));
Assert.AreEqual(expected, value);
}
@@ -420,10 +503,18 @@ namespace ShrinkModFramework.Tests
{
var package = UnityEditor.PackageManager.PackageInfo.FindForAssembly(
typeof(ShrinkModContextHostTests).Assembly);
if (package == null || string.IsNullOrWhiteSpace(package.resolvedPath))
throw new InvalidOperationException("Could not resolve the ShrinkModFramework package path.");
var testsRoot = package != null && !string.IsNullOrWhiteSpace(package.resolvedPath)
? Path.Combine(package.resolvedPath, "Tests")
: UnityEditor.AssetDatabase.FindAssets("ShrinkModFramework.Tests t:asmdef")
.Select(UnityEditor.AssetDatabase.GUIDToAssetPath)
.Where(path => string.Equals(Path.GetFileName(path),
"ShrinkModFramework.Tests.asmdef", StringComparison.Ordinal))
.Select(Path.GetDirectoryName)
.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path));
if (string.IsNullOrWhiteSpace(testsRoot))
throw new InvalidOperationException("Could not resolve the ShrinkModFramework tests path.");
var fixturePath = Path.Combine(package.resolvedPath, "Tests", "Fixtures", fixtureName);
var fixturePath = Path.Combine(testsRoot, "Fixtures", fixtureName);
File.Copy(fixturePath, targetPath, true);
}
@@ -1,12 +1,13 @@
{
"name": "com.cneicy.shrink-mod-framework",
"version": "0.1.0",
"version": "0.2.0",
"displayName": "ShrinkModFramework",
"description": "Unity 用轻量模组框架,提供模组发现、依赖解析、生命周期与注册表。",
"description": "Unity 模组框架,提供发现、依赖、可逆生命周期、命名空间内容注册与优先级覆盖。",
"unity": "2022.3",
"dependencies": {
"com.unity.nuget.newtonsoft-json": "3.2.2",
"com.cneicy.shrink-context-core": "0.1.0",
"com.cneicy.shrink-eventbus": "2.0.0",
"com.cysharp.unitask": "2.5.10"
},
"keywords": ["mod", "modding", "framework", "plugin", "registry"],