From de5ab449cda6f6843c74eb7182871a867c0f818a Mon Sep 17 00:00:00 2001 From: cneicy Date: Sun, 16 Aug 2026 23:51:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(mod):=20=E5=AE=8C=E6=88=90=E5=A4=96?= =?UTF-8?q?=E9=83=A8=20DLL=20=E4=BA=8B=E5=8A=A1=E7=83=AD=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/ShrinkModFramework/CHANGELOG.md | 17 ++ Assets/Modules/ShrinkModFramework/README.md | 2 +- .../Runtime/AssemblyInfo.cs | 3 + .../Runtime/AssemblyInfo.cs.meta | 11 + .../Bootstrap/ShrinkModReloadDebouncer.cs | 34 +++ .../ShrinkModReloadDebouncer.cs.meta | 11 + .../Bootstrap/ShrinkModRuntimeDriver.cs | 22 +- .../Runtime/Context/ShrinkModCordisRuntime.cs | 15 +- .../ShrinkExternalModAssemblyLoader.cs | 50 +++- .../Runtime/Loading/ShrinkModLoader.cs | 2 +- .../ShrinkModFramework/Tests/Fixtures.meta | 8 + .../ExternalFixture.Consumer.dll.bytes | Bin 0 -> 4096 bytes .../ExternalFixture.Consumer.dll.bytes.meta | 7 + .../Fixtures/ExternalFixture.Mod.V1.dll.bytes | Bin 0 -> 4096 bytes .../ExternalFixture.Mod.V1.dll.bytes.meta | 7 + .../ExternalFixture.Mod.V2.Failing.dll.bytes | Bin 0 -> 4096 bytes ...ernalFixture.Mod.V2.Failing.dll.bytes.meta | 7 + .../Fixtures/ExternalFixture.Mod.V3.dll.bytes | Bin 0 -> 4096 bytes .../ExternalFixture.Mod.V3.dll.bytes.meta | 7 + .../ExternalFixture.Provider.dll.bytes | Bin 0 -> 4096 bytes .../ExternalFixture.Provider.dll.bytes.meta | 7 + .../Tests/ShrinkModContextHostTests.cs | 213 ++++++++++++++++++ CORDIS_MIGRATION.md | 11 +- 23 files changed, 416 insertions(+), 18 deletions(-) create mode 100644 Assets/Modules/ShrinkModFramework/Runtime/AssemblyInfo.cs create mode 100644 Assets/Modules/ShrinkModFramework/Runtime/AssemblyInfo.cs.meta create mode 100644 Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs create mode 100644 Assets/Modules/ShrinkModFramework/Runtime/Bootstrap/ShrinkModReloadDebouncer.cs.meta create mode 100644 Assets/Modules/ShrinkModFramework/Tests/Fixtures.meta create mode 100644 Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Consumer.dll.bytes create mode 100644 Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Consumer.dll.bytes.meta create mode 100644 Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes create mode 100644 Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V1.dll.bytes.meta create mode 100644 Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes create mode 100644 Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V2.Failing.dll.bytes.meta create mode 100644 Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes create mode 100644 Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Mod.V3.dll.bytes.meta create mode 100644 Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Provider.dll.bytes create mode 100644 Assets/Modules/ShrinkModFramework/Tests/Fixtures/ExternalFixture.Provider.dll.bytes.meta 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 0000000000000000000000000000000000000000..217d428f1baf0ac6e7482efb53a25a416218645a GIT binary patch literal 4096 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..48579519b4930800a1d7fef92aec21d5a3501d85 GIT binary patch literal 4096 zcmeHKU2GiH75?t-+Hn@LI8Kv-O4132HsohyJ3kZ%tz)l~Y_Ywr*A9Ox#k0HP^}y`R zYG&5hp{hbFDj`)BNIX<+g+v7}KQbz|5wtwR@r-=2WHkJmZEo9^y)kw@Hw8?qG?rhx=MTwl zZ{P;wDm4NOP}1L)zV$`U2FExDFsSKNJ8lt3{2I;^Q!bFk(j#Ij|GIO`iiq%i`Yj4E z4!j(%6Sf0Oz|~}){sOSj5&jQ^(GEYc93X!>VK|$Sy%ur4yoJc%kX$$IPw|o&gh7=m z9oskwNi@sR1LKU|3~bk@p~Q+M4)K-a=z(!yqKDPwC8;YxVoRY9-!tfgHCH111dzr4 zA0ZuZdDAC4koK8i)@OFvV3@*SQ8Zg6hy7y+?jsj6o$NXCH>CDmr4`(FqV&}X6-OaC ztsXH)%)=vxM{kv&`rs1(h8qlj131WB$2o>eQQ&y>P%K>5=?TumizPAFpTO{`#X_F4 zLGu3=-Gu8`I16yOI`vr$Y?X6(PaVdtb`4~2>coXEz0`$4YF=m=qL&#csP^2CSMV#< zkGC~`8qcb0@Mo&}*Z zb908s|EeylY4W9*Lu#7wd_Cr_p)QR_W9|TT8GJ``-@f)4?jczz3`_Exa0+{|70)oA z?Zo}KjyQ{(i3c%Cyb}c+PVFZCraFjwu!qvC@k4bxENycuaX(DfYMyup?j!yj&Jhpe zTbRTFyojU7oM!-6z&>&*Mw7rK?oG&^d0ErjAl8J^3eWw48NW7f*PSp5)|W~; zGpZ_%>qP5aZP6=uPUKjwbIwL-J&f!|5~+E{4nlfmmvn>d1h6>|DUexn-Pj2V-YT8e zicLGv5lyXC?WP3Tsj;{0cl{QGYoCZ6z^q1okc5+Wth(oiky8y5QJj<6&fN}5cChMH z?J%w!BW5&Yi3cltmZpc*f2ylAVU;9O)ut=c+H*-`SX@cY?o zV+|2(1n~I%y8KF);b>WlG8-8ED2v;$s@I2H=hF-NRxt+La-|FT2x!sF{NpBi)bM}q#zUNPX1b*?-lYfMonUlNrA&z(X9=|Jhbqo^W z4i0(FLZXmQOfTr406l{FQogkE_}XvZ{L4`Og`ezy>(JkydPIEWZ!0gdd)6mfj#~@M z?W>@azq1@Qn&l+wLvhq$*{|GRJ~LL<-CgdvEtqCGNvY?$F`KmtN~ceZ9X*D4_g9a_ zyGw@8W#0Myk1u48@BYE1qrZz^W(oRrp$(zl56KJO)|{R6gS_j`SdNEASoH(j)-BxD zZrnsKn@iE-k|#s%THt(-kYxKM=5f4FE_SCm?j8psIh+`x)Dco=P{LW_DZTNqd6ir8)nxAl)bIxT08h_rn-L^`M^GWn-y8acN_Nh(heLL=_-WE#-2b#Q zI7ZlHs^k02m3M^Jo3u#=FvsdEolWn3G|Nont^g}}|}}OC5kO=`|;45z?~3s=JKX_{eo!`YC+e3K;GGGyLn8kGt?w`~Qau{1>rFT(bZG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..15f15810623a140f8781bd6fd8423da5ad642577 GIT binary patch literal 4096 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 => + 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 能力或围绕程序集常驻做容量与运维策略,不再新增第三套生命周期。