diff --git a/Assets/Modules/ShrinkModFramework/CHANGELOG.md b/Assets/Modules/ShrinkModFramework/CHANGELOG.md
index 1707a4d..e5d8682 100644
--- a/Assets/Modules/ShrinkModFramework/CHANGELOG.md
+++ b/Assets/Modules/ShrinkModFramework/CHANGELOG.md
@@ -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
diff --git a/Assets/Modules/ShrinkModFramework/README.md b/Assets/Modules/ShrinkModFramework/README.md
index 792d7b4..f499122 100644
--- a/Assets/Modules/ShrinkModFramework/README.md
+++ b/Assets/Modules/ShrinkModFramework/README.md
@@ -207,7 +207,7 @@ ShrinkModLoader.LoadNewExternalMods();
默认会扫描当前 DLL revision,并把新增、替换、删除映射为一个完整期望组合;
变更事务失败时保留旧模组组合。设置 `useContextHost = false` 才回退为仅新增 DLL 的旧路径。
-如果 `watchExternalModsDirectory = true`,框架还会自动监听外部模组目录并在主线程延迟触发增量加载。
+如果 `watchExternalModsDirectory = true`,框架还会监听新增、修改、删除、重命名事件,经过主线程 debouncer 后提交一次完整组合;同一 burst 内的中间坏文件不会覆盖当前有效 revision。
## Harmony 热补丁
diff --git a/Assets/Modules/ShrinkModFramework/Runtime/AssemblyInfo.cs b/Assets/Modules/ShrinkModFramework/Runtime/AssemblyInfo.cs
new file mode 100644
index 0000000..fd912bf
--- /dev/null
+++ b/Assets/Modules/ShrinkModFramework/Runtime/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("ShrinkModFramework.Tests")]
diff --git a/Assets/Modules/ShrinkModFramework/Runtime/AssemblyInfo.cs.meta b/Assets/Modules/ShrinkModFramework/Runtime/AssemblyInfo.cs.meta
new file mode 100644
index 0000000..b00bd24
--- /dev/null
+++ b/Assets/Modules/ShrinkModFramework/Runtime/AssemblyInfo.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4db6b958232258a439bebcb315ee304d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs b/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs
new file mode 100644
index 0000000..64a4e84
--- /dev/null
+++ b/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs
@@ -0,0 +1,34 @@
+using System;
+
+namespace ShrinkModFramework
+{
+ /// 把文件系统的 burst 事件折叠为一次主线程 reload。
+ 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;
+ }
+ }
+}
diff --git a/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs.meta b/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs.meta
new file mode 100644
index 0000000..29b38ba
--- /dev/null
+++ b/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ec113876ec861b64cb8391eed726108b
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModRuntimeDriver.cs b/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModRuntimeDriver.cs
index 5d50587..a80fb3a 100644
--- a/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModRuntimeDriver.cs
+++ b/Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModRuntimeDriver.cs
@@ -11,10 +11,9 @@ namespace ShrinkModFramework
private static ShrinkModRuntimeDriver _instance;
private readonly ConcurrentQueue _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();
_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;
diff --git a/Assets/Modules/ShrinkModFramework/Runtime/Context/ShrinkModCordisRuntime.cs b/Assets/Modules/ShrinkModFramework/Runtime/Context/ShrinkModCordisRuntime.cs
index a17eacb..e408084 100644
--- a/Assets/Modules/ShrinkModFramework/Runtime/Context/ShrinkModCordisRuntime.cs
+++ b/Assets/Modules/ShrinkModFramework/Runtime/Context/ShrinkModCordisRuntime.cs
@@ -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} 个。");
diff --git a/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs b/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs
index ea130bd..059fca7 100644
--- a/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs
+++ b/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkExternalModAssemblyLoader.cs
@@ -17,6 +17,14 @@ namespace ShrinkModFramework
public Assembly Assembly;
}
+ internal sealed class RevisionState
+ {
+ public Dictionary Current =
+ new(StringComparer.OrdinalIgnoreCase);
+ public Dictionary Known =
+ new(StringComparer.OrdinalIgnoreCase);
+ }
+
private static readonly Dictionary CurrentAssemblyRevisions =
new(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet 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();
diff --git a/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModLoader.cs b/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModLoader.cs
index 2a87a5c..9bd5f35 100644
--- a/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModLoader.cs
+++ b/Assets/Modules/ShrinkModFramework/Runtime/Loading/ShrinkModLoader.cs
@@ -152,7 +152,7 @@ namespace ShrinkModFramework
OnModReady = null;
OnAllModsReady = null;
ShrinkModNetworkManager.ResetForDomainReload();
- ShrinkExternalModAssemblyLoader.ResetForTesting();
+ ShrinkExternalModAssemblyLoader.ResetForDomainReload();
ShrinkHarmonyPatchService.ResetForTesting();
}
diff --git a/Assets/Modules/ShrinkModFramework/Tests/Fixtures.meta b/Assets/Modules/ShrinkModFramework/Tests/Fixtures.meta
new file mode 100644
index 0000000..aef7e6c
--- /dev/null
+++ b/Assets/Modules/ShrinkModFramework/Tests/Fixtures.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 39d24561b9507f340abccb25be811c21
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Consumer.dll.bytes b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Consumer.dll.bytes
new file mode 100644
index 0000000..217d428
Binary files /dev/null and b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Consumer.dll.bytes differ
diff --git a/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Consumer.dll.bytes.meta b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Consumer.dll.bytes.meta
new file mode 100644
index 0000000..df4ff95
--- /dev/null
+++ b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Consumer.dll.bytes.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 4bd75db4a8f23f7428ff7dc0e796a5c0
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes
new file mode 100644
index 0000000..e7e3b3a
Binary files /dev/null and b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes differ
diff --git a/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes.meta b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes.meta
new file mode 100644
index 0000000..18c735c
--- /dev/null
+++ b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 97a7806bf8820b94e83cc2011c09bc43
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes
new file mode 100644
index 0000000..4857951
Binary files /dev/null and b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes differ
diff --git a/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes.meta b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes.meta
new file mode 100644
index 0000000..6d63c72
--- /dev/null
+++ b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 8517a178ed20f2a45b30d4c45fefe2c5
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes
new file mode 100644
index 0000000..15f1581
Binary files /dev/null and b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes differ
diff --git a/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes.meta b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes.meta
new file mode 100644
index 0000000..0b795c6
--- /dev/null
+++ b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 7a3fede5ca621bd4e9ecb0dc59e5925e
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Provider.dll.bytes b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Provider.dll.bytes
new file mode 100644
index 0000000..aec4440
Binary files /dev/null and b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Provider.dll.bytes differ
diff --git a/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Provider.dll.bytes.meta b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Provider.dll.bytes.meta
new file mode 100644
index 0000000..f6069c7
--- /dev/null
+++ b/Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Provider.dll.bytes.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: c5af400c8aafe04428decd12e66face9
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Modules/ShrinkModFramework/Tests/ShrinkModContextHostTests.cs b/Assets/Modules/ShrinkModFramework/Tests/ShrinkModContextHostTests.cs
index 746685c..4d71930 100644
--- a/Assets/Modules/ShrinkModFramework/Tests/ShrinkModContextHostTests.cs
+++ b/Assets/Modules/ShrinkModFramework/Tests/ShrinkModContextHostTests.cs
@@ -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(() =>
+ 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(() =>
+ 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 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();
+ 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("external.fixture");
+ Assert.IsTrue(registry.TryGet("external.fixture:version", out var value));
+ Assert.AreEqual(registryValue, value);
+ }
+
+ private static bool PumpDriverUntil(Func 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? _onConstruct;
diff --git a/CORDIS_MIGRATION.md b/CORDIS_MIGRATION.md
index affa3c6..a16d64a 100644
--- a/CORDIS_MIGRATION.md
+++ b/CORDIS_MIGRATION.md
@@ -238,13 +238,14 @@ await loader.ApplyConfigAsync(configTree); // 增量协调:diff → reload/u
**阶段 3 保留边界**:ClassicHost 与静态服务门面仍作为兼容层存在;Command-Network 的服务 handler 暂无逐项注销 API;全局静态桥在多根上下文并行时仍需引用计数或实例化。这些不阻塞单默认根主路径,但不能误写成完全隔离保证。
-### 阶段 4:ModFramework 合流 + 热替换 🔶 第一片完成(2026-08-16)
+### 阶段 4:ModFramework 合流 + 热替换 ✅ 完成(2026-08-16)
- 新增 `ShrinkModComponentSource` / `ShrinkModContextHost`:将 `IShrinkMod` 的构造、注册、初始化、Ready 生命周期纳入 Cordis apply;模组效应通过本地逆操作登记,Registry、Network handler、Harmony lease 均可随组件退役清理。
- 事务单位固定为 `ModId + revision`:同一 revision 幂等复用;替换失败时保留 `LastError` 并重新应用上一组 source,旧模组实例、generation 与已登记内容恢复。
-- 外部 DLL 按 SHA-256 扫描当前 revision;文件内容变化创建新的组件源,旧程序集仍驻留但不会继续被发现,坏 DLL 不覆盖当前有效 revision。Unity 程序集不可卸载,因此回滚单位是组件实例与效应,不是类型。
+- 外部 DLL 按 SHA-256 扫描当前 revision;文件内容变化创建新的组件源,旧程序集仍驻留但不会继续被发现,坏 DLL 不覆盖当前有效 revision。扫描状态随 Host 事务一起快照/恢复,失败替换不会把失败 revision 留在当前缓存中。
- `ShrinkModLoader` 默认走 `ShrinkModContextHost`;`useContextHost=false` 保留旧加载路径作为兼容边界。Harmony 在 ContextHost 路径取得可逆 lease,旧路径仍是一次性应用。
-- 当前验收:ModFramework ContextHost 5/5 EditMode;真实 Starter PlayMode 确认 `modCordis=true`、宿主 7 个模块 active 且无错误。
-- **阶段 4 剩余**:补一组真实编译外部 DLL 的 watcher 场景(valid → changed → failed → restore),再覆盖 watcher debounce、缓存失效与依赖导入失败;不把“程序集未卸载”伪装成已解决。
+- `ShrinkModRuntimeDriver` 监听新增、修改、删除、重命名,并用独立 debouncer 把 burst 事件折叠为一次主线程 reload;删除文件会提交完整期望组合,依赖缺失在协调前拒绝并保留旧组合。
+- 真实 fixture 验收覆盖:编译 DLL `valid → changed(fail) → restore`、损坏字节不污染当前 revision、有效 revision 恢复、watcher burst 去抖、删除卸载、依赖 DLL 删除/恢复。`ShrinkModFramework.Tests` **9/9**,全仓 EditMode **180/180**。
+- 真实 Starter PlayMode 确认 `modCordis=true`、`modLoaded=true`、宿主 7 个模块 active 且无错误。Unity 程序集不可卸载,因此回滚单位仍是组件实例与效应,而不是类型本身。
### 阶段 5:隔离与拦截(可选增强)
- 测试域 isolate(同一份组件在多个隔离域各跑一份);intercept 做访问控制(社区模组只读 DataSaver)。
@@ -267,4 +268,4 @@ await loader.ApplyConfigAsync(configTree); // 增量协调:diff → reload/u
- ShrinkSDK 与 Cordis 的**分层直觉一致**(核心库 / 编排层 / 领域层),差距集中在**核心库的两个原语缺失**:统一可逆效应追踪(时间)与响应式依赖解析(空间)。
- 改造的本质不是重写功能模块,而是**把 ShrinkApp 的"一次性安装器"升级为 Cordis 的"持续协调的组件加载器"**,并让七个 Integration 桥接包退化为薄适配直至消失。
- Unity 的"程序集不可卸载"不阻塞该范式——可回滚单位是组件实例与效应,而非类型;这与 ModFramework 既有边界声明兼容。
-- 阶段 0-3 已完成到 Basic Starter 的默认运行主路径;阶段 4 已完成首片事务性 Mod/HMR 合流,剩余工作集中在真实外部 DLL watcher 与更完整的失败注入验收,不再新增第三套生命周期。
+- 阶段 0-4 已完成到 Basic Starter 的默认运行主路径与 ModFramework 事务性 HMR;后续若继续推进,应进入阶段 5 的 isolate/intercept 能力或围绕程序集常驻做容量与运维策略,不再新增第三套生命周期。