feat(mod): 完成外部 DLL 事务热替换

This commit is contained in:
2026-08-16 23:52:53 +08:00
parent ad256f109b
commit de5ab449cd
23 changed files with 416 additions and 18 deletions
@@ -2,6 +2,23 @@
本文件记录 `ShrinkModFramework` 在当前工作区中的包内变更。
## [Unreleased]
### Added
- 新增 `ShrinkModContextHost` / `ShrinkModComponentSource`,把模组生命周期、Registry、Network handler 与 Harmony lease 纳入可逆组件事务。
- 新增真实外部 DLL fixture 测试,覆盖失败替换恢复、损坏文件、依赖缺失、watcher 去抖与删除卸载。
### Changed
- `ShrinkModLoader` 默认使用 ContextHost;外部 DLL 以 SHA-256 作为 revision,同内容幂等。
- watcher 监听新增、修改、删除与重命名,并把 burst 文件事件折叠为一次主线程组合提交。
### Fixed
- 新模组 apply 失败时同时恢复旧组件组合和外部 DLL revision 缓存,避免失败程序集继续被当作当前版本。
- 删除 DLL 时同步清理程序集解析路径缓存;EditMode 创建 watcher driver 时不再调用 `DontDestroyOnLoad`
## [0.1.0] - 2026-04-07
### Added
+1 -1
View File
@@ -207,7 +207,7 @@ ShrinkModLoader.LoadNewExternalMods();
默认会扫描当前 DLL revision,并把新增、替换、删除映射为一个完整期望组合;
变更事务失败时保留旧模组组合。设置 `useContextHost = false` 才回退为仅新增 DLL 的旧路径。
如果 `watchExternalModsDirectory = true`,框架还会自动监听外部模组目录并在主线程延迟触发增量加载
如果 `watchExternalModsDirectory = true`,框架还会监听新增、修改、删除、重命名事件,经过主线程 debouncer 后提交一次完整组合;同一 burst 内的中间坏文件不会覆盖当前有效 revision
## Harmony 热补丁
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ShrinkModFramework.Tests")]
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4db6b958232258a439bebcb315ee304d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,34 @@
using System;
namespace ShrinkModFramework
{
/// <summary>把文件系统的 burst 事件折叠为一次主线程 reload。</summary>
internal sealed class ShrinkModReloadDebouncer
{
private bool _pending;
private float _reloadAt;
public bool IsPending => _pending;
public void Schedule(float now, float delaySeconds)
{
_pending = true;
_reloadAt = now + Math.Max(0.05f, delaySeconds);
}
public bool TryConsume(float now)
{
if (!_pending || now < _reloadAt)
return false;
_pending = false;
return true;
}
public void Reset()
{
_pending = false;
_reloadAt = 0f;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ec113876ec861b64cb8391eed726108b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -11,10 +11,9 @@ namespace ShrinkModFramework
private static ShrinkModRuntimeDriver _instance;
private readonly ConcurrentQueue<Action> _mainThreadActions = new();
private readonly ShrinkModReloadDebouncer _externalReloadDebouncer = new();
private FileSystemWatcher _watcher;
private ShrinkModFrameworkSettings _settings;
private bool _pendingExternalReload;
private float _reloadAtRealtime;
public static void EnsureCreated(ShrinkModFrameworkSettings settings)
{
@@ -25,7 +24,8 @@ namespace ShrinkModFramework
}
var go = new GameObject("ShrinkModRuntimeDriver");
DontDestroyOnLoad(go);
if (Application.isPlaying)
DontDestroyOnLoad(go);
_instance = go.AddComponent<ShrinkModRuntimeDriver>();
_instance.ApplySettings(settings);
}
@@ -41,6 +41,12 @@ namespace ShrinkModFramework
_instance._mainThreadActions.Enqueue(action);
}
internal static ShrinkModRuntimeDriver InstanceForTesting => _instance;
internal bool HasWatcherForTesting => _watcher != null;
internal void PumpForTesting() => Update();
private void Update()
{
while (_mainThreadActions.TryDequeue(out var action))
@@ -55,11 +61,9 @@ namespace ShrinkModFramework
}
}
if (!_pendingExternalReload || Time.realtimeSinceStartup < _reloadAtRealtime)
if (!_externalReloadDebouncer.TryConsume(Time.realtimeSinceStartup))
return;
_pendingExternalReload = false;
try
{
ShrinkModLoader.LoadNewExternalMods(_settings);
@@ -117,6 +121,7 @@ namespace ShrinkModFramework
};
_watcher.Created += OnExternalModsChanged;
_watcher.Changed += OnExternalModsChanged;
_watcher.Deleted += OnExternalModsChanged;
_watcher.Renamed += OnExternalModsRenamed;
_watcher.EnableRaisingEvents = true;
}
@@ -135,6 +140,7 @@ namespace ShrinkModFramework
private void OnExternalModsRenamed(object sender, RenamedEventArgs args)
{
ScheduleExternalReload(args.OldFullPath);
ScheduleExternalReload(args.FullPath);
}
@@ -145,9 +151,8 @@ namespace ShrinkModFramework
_mainThreadActions.Enqueue(() =>
{
_pendingExternalReload = true;
var delay = _settings ? Mathf.Max(0.05f, _settings.externalModsReloadDelaySeconds) : 0.5f;
_reloadAtRealtime = Time.realtimeSinceStartup + delay;
_externalReloadDebouncer.Schedule(Time.realtimeSinceStartup, delay);
});
}
@@ -159,6 +164,7 @@ namespace ShrinkModFramework
_watcher.EnableRaisingEvents = false;
_watcher.Created -= OnExternalModsChanged;
_watcher.Changed -= OnExternalModsChanged;
_watcher.Deleted -= OnExternalModsChanged;
_watcher.Renamed -= OnExternalModsRenamed;
_watcher.Dispose();
_watcher = null;
@@ -30,9 +30,20 @@ namespace ShrinkModFramework
verboseLogging);
}
var revisionState = ShrinkExternalModAssemblyLoader.CaptureState();
ShrinkModNetworkManager.Configure(settings == null || settings.enableNetworkSync, verboseLogging);
var sources = ShrinkModComponentDiscovery.Discover(settings, verboseLogging);
await _host.ApplyAsync(sources);
try
{
var sources = ShrinkModComponentDiscovery.Discover(settings, verboseLogging);
await _host.ApplyAsync(sources);
}
catch
{
// DLL revision discovery is part of the same composition transaction:
// a failing replacement must not make the failed assembly current.
ShrinkExternalModAssemblyLoader.RestoreState(revisionState);
throw;
}
if (verboseLogging)
UnityEngine.Debug.Log($"[ShrinkModFramework] Cordis 模组组合已提交,共 {_host.Mods.Count} 个。");
@@ -17,6 +17,14 @@ namespace ShrinkModFramework
public Assembly Assembly;
}
internal sealed class RevisionState
{
public Dictionary<string, ExternalAssemblyRevision> Current =
new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, string> Known =
new(StringComparer.OrdinalIgnoreCase);
}
private static readonly Dictionary<string, ExternalAssemblyRevision> CurrentAssemblyRevisions =
new(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet<Assembly> ExternalAssemblyHistory = new();
@@ -101,6 +109,12 @@ namespace ShrinkModFramework
.ToArray())
{
CurrentAssemblyRevisions.Remove(missingPath);
var assemblyName = Path.GetFileNameWithoutExtension(missingPath);
if (KnownAssemblyFiles.TryGetValue(assemblyName, out var knownPath) &&
string.Equals(knownPath, missingPath, StringComparison.OrdinalIgnoreCase))
{
KnownAssemblyFiles.Remove(assemblyName);
}
}
return CurrentAssemblyRevisions.Values
@@ -112,6 +126,35 @@ namespace ShrinkModFramework
internal static bool IsExternalAssembly(Assembly assembly) =>
assembly != null && ExternalAssemblyHistory.Contains(assembly);
internal static RevisionState CaptureState()
{
return new RevisionState
{
Current = CurrentAssemblyRevisions.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase),
Known = KnownAssemblyFiles.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase)
};
}
internal static void RestoreState(RevisionState state)
{
if (state == null)
throw new ArgumentNullException(nameof(state));
CurrentAssemblyRevisions.Clear();
foreach (var pair in state.Current)
CurrentAssemblyRevisions[pair.Key] = pair.Value;
KnownAssemblyFiles.Clear();
foreach (var pair in state.Known)
KnownAssemblyFiles[pair.Key] = pair.Value;
}
internal static bool TryGetCurrentRevision(Assembly assembly, out string revision)
{
foreach (var current in CurrentAssemblyRevisions.Values)
@@ -138,7 +181,6 @@ namespace ShrinkModFramework
internal static void ResetForTesting()
{
CurrentAssemblyRevisions.Clear();
ExternalAssemblyHistory.Clear();
KnownAssemblyFiles.Clear();
if (_resolveRegistered)
{
@@ -147,6 +189,12 @@ namespace ShrinkModFramework
}
}
internal static void ResetForDomainReload()
{
ResetForTesting();
ExternalAssemblyHistory.Clear();
}
private static string ComputeSha256(byte[] bytes)
{
using var sha256 = SHA256.Create();
@@ -152,7 +152,7 @@ namespace ShrinkModFramework
OnModReady = null;
OnAllModsReady = null;
ShrinkModNetworkManager.ResetForDomainReload();
ShrinkExternalModAssemblyLoader.ResetForTesting();
ShrinkExternalModAssemblyLoader.ResetForDomainReload();
ShrinkHarmonyPatchService.ResetForTesting();
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 39d24561b9507f340abccb25be811c21
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 4bd75db4a8f23f7428ff7dc0e796a5c0
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 97a7806bf8820b94e83cc2011c09bc43
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8517a178ed20f2a45b30d4c45fefe2c5
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 7a3fede5ca621bd4e9ecb0dc59e5925e
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c5af400c8aafe04428decd12e66face9
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -2,11 +2,15 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using ShrinkContext;
using UnityEngine;
using UnityEngine.TestTools;
using Object = UnityEngine.Object;
namespace ShrinkModFramework.Tests
@@ -197,6 +201,146 @@ namespace ShrinkModFramework.Tests
}
}
[Test]
public void ExternalDllRevision_FailedReplacementRestoresPreviousAndBrokenBytesDoNotReplaceIt()
{
ResetExternalRuntime();
var settings = CreateExternalSettings();
var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(settings);
var target = Path.Combine(directory, "ExternalFixture.Mod.dll");
try
{
Directory.CreateDirectory(directory);
CopyFixture("ExternalFixture.Mod.V1.dll.bytes", target);
ShrinkModLoader.LoadAll(settings);
AssertExternalFixture("1.0.0", "v1");
var restoredGeneration = ShrinkModLoader.Mods["external.fixture"].Generation;
CopyFixture("ExternalFixture.Mod.V2.Failing.dll.bytes", target);
var transaction = Assert.Throws<ShrinkModTransactionException>(() =>
ShrinkModLoader.LoadNewExternalMods(settings));
Assert.IsTrue(transaction!.PreviousCompositionRestored);
AssertExternalFixture("1.0.0", "v1");
Assert.AreNotEqual(0, restoredGeneration);
Assert.Greater(ShrinkModLoader.Mods["external.fixture"].Generation, restoredGeneration);
var failedReplacementGeneration = ShrinkModLoader.Mods["external.fixture"].Generation;
File.WriteAllBytes(target, new byte[] { 0, 1, 2, 3, 4, 5 });
LogAssert.Expect(LogType.Error,
new Regex(@"\[ShrinkModFramework\] 加载外部 DLL 失败:.*ExternalFixture\.Mod\.dll"));
ShrinkModLoader.LoadNewExternalMods(settings);
AssertExternalFixture("1.0.0", "v1");
Assert.AreEqual(failedReplacementGeneration,
ShrinkModLoader.Mods["external.fixture"].Generation,
"坏 DLL 不应让失败 revision 重新成为当前 source");
CopyFixture("ExternalFixture.Mod.V3.dll.bytes", target);
ShrinkModLoader.LoadNewExternalMods(settings);
AssertExternalFixture("3.0.0", "v3");
}
finally
{
CleanupExternalRuntime(settings, directory);
}
}
[Test]
public void ExternalDllWatcher_DebouncesBurstAndHandlesDeletion()
{
ResetExternalRuntime();
var settings = CreateExternalSettings();
settings.externalModsReloadDelaySeconds = 0.05f;
var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(settings);
var target = Path.Combine(directory, "ExternalFixture.Mod.dll");
try
{
Directory.CreateDirectory(directory);
ShrinkModRuntimeDriver.EnsureCreated(settings);
Assert.IsTrue(ShrinkModRuntimeDriver.InstanceForTesting!.HasWatcherForTesting);
CopyFixture("ExternalFixture.Mod.V1.dll.bytes", target);
ShrinkModLoader.LoadAll(settings);
AssertExternalFixture("1.0.0", "v1");
// Two writes arrive as one debounce window; only the final valid revision should commit.
CopyFixture("ExternalFixture.Mod.V2.Failing.dll.bytes", target);
CopyFixture("ExternalFixture.Mod.V3.dll.bytes", target);
Assert.IsTrue(PumpDriverUntil(() =>
ShrinkModLoader.Mods.TryGetValue("external.fixture", out var handle) &&
handle.Info.Version == "3.0.0"),
"watcher 应在 burst 后提交最后一个有效 revision");
AssertExternalFixture("3.0.0", "v3");
File.Delete(target);
Assert.IsTrue(PumpDriverUntil(() => ShrinkModLoader.Mods.Count == 0),
"删除 DLL 应触发当前 source 卸载");
}
finally
{
CleanupExternalRuntime(settings, directory);
}
}
[Test]
public void ExternalDllDependencyRemoval_RestoresPreviousComposition()
{
ResetExternalRuntime();
var settings = CreateExternalSettings();
var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(settings);
var providerPath = Path.Combine(directory, "ExternalFixture.Provider.dll");
var consumerPath = Path.Combine(directory, "ExternalFixture.Consumer.dll");
try
{
Directory.CreateDirectory(directory);
CopyFixture("ExternalFixture.Provider.dll.bytes", providerPath);
CopyFixture("ExternalFixture.Consumer.dll.bytes", consumerPath);
ShrinkModLoader.LoadAll(settings);
Assert.AreEqual(2, ShrinkModLoader.Mods.Count);
Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.provider"].State);
Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.consumer"].State);
File.Delete(providerPath);
Assert.Throws<InvalidOperationException>(() =>
ShrinkModLoader.LoadNewExternalMods(settings));
Assert.AreEqual(2, ShrinkModLoader.Mods.Count,
"依赖导入失败发生在协调前,旧组合必须保持 active");
Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.provider"].State);
Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.consumer"].State);
CopyFixture("ExternalFixture.Provider.dll.bytes", providerPath);
ShrinkModLoader.LoadNewExternalMods(settings);
Assert.AreEqual(2, ShrinkModLoader.Mods.Count);
}
finally
{
CleanupExternalRuntime(settings, directory);
}
}
[Test]
public void ExternalReloadDebouncer_CoalescesBurstAndUsesMinimumDelay()
{
var debouncer = new ShrinkModReloadDebouncer();
debouncer.Schedule(10f, 0f);
Assert.IsTrue(debouncer.IsPending);
Assert.IsFalse(debouncer.TryConsume(10.049f));
debouncer.Schedule(10.04f, 0.5f);
Assert.IsFalse(debouncer.TryConsume(10.539f));
Assert.IsTrue(debouncer.TryConsume(10.54f));
Assert.IsFalse(debouncer.IsPending);
Assert.IsFalse(debouncer.TryConsume(11f));
}
private static ShrinkModComponentSource Source(string id, string version, string revision,
Func<IShrinkMod> factory, params ShrinkModDependency[] dependencies)
{
@@ -224,6 +368,75 @@ namespace ShrinkModFramework.Tests
method!.Invoke(null, null);
}
private static void ResetExternalRuntime()
{
if (ShrinkModRuntimeDriver.InstanceForTesting != null)
Object.DestroyImmediate(ShrinkModRuntimeDriver.InstanceForTesting.gameObject);
ShrinkModLoader.ResetForTesting();
}
private static ShrinkModFrameworkSettings CreateExternalSettings()
{
var settings = ScriptableObject.CreateInstance<ShrinkModFrameworkSettings>();
settings.useContextHost = true;
settings.enableExternalDllMods = true;
settings.autoCreateExternalModsDirectory = false;
settings.externalModsFolderName = "ShrinkModFrameworkTests_" + Guid.NewGuid().ToString("N");
settings.assemblyNamePrefixes = new[] { "ExternalFixture." };
settings.enableHarmonyPatching = false;
settings.enableNetworkSync = false;
settings.verboseLogging = false;
return settings;
}
private static void CleanupExternalRuntime(ShrinkModFrameworkSettings settings, string directory)
{
try
{
if (ShrinkModRuntimeDriver.InstanceForTesting != null)
Object.DestroyImmediate(ShrinkModRuntimeDriver.InstanceForTesting.gameObject);
ShrinkModLoader.ResetForTesting();
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, true);
Object.DestroyImmediate(settings);
}
}
private static void CopyFixture(string fixtureName, string targetPath)
{
var fixturePath = Path.Combine(Application.dataPath, "Modules", "ShrinkModFramework",
"Tests", "Fixtures", fixtureName);
File.Copy(fixturePath, targetPath, true);
}
private static void AssertExternalFixture(string version, string registryValue)
{
Assert.IsTrue(ShrinkModLoader.Mods.TryGetValue("external.fixture", out var handle));
Assert.AreEqual(version, handle!.Info.Version);
Assert.AreEqual(ShrinkModState.Ready, handle.State);
var registry = ShrinkModLoader.GetOrCreateRegistry<string>("external.fixture");
Assert.IsTrue(registry.TryGet("external.fixture:version", out var value));
Assert.AreEqual(registryValue, value);
}
private static bool PumpDriverUntil(Func<bool> predicate)
{
for (var i = 0; i < 120; i++)
{
ShrinkModRuntimeDriver.InstanceForTesting?.PumpForTesting();
if (predicate())
return true;
Thread.Sleep(50);
}
ShrinkModRuntimeDriver.InstanceForTesting?.PumpForTesting();
return predicate();
}
private sealed class DelegateMod : IShrinkMod
{
private readonly Action<ShrinkModContext>? _onConstruct;