feat(mod): 完成外部 DLL 事务热替换
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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} 个。");
|
||||
|
||||
+49
-1
@@ -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:
|
||||
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4bd75db4a8f23f7428ff7dc0e796a5c0
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 97a7806bf8820b94e83cc2011c09bc43
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8517a178ed20f2a45b30d4c45fefe2c5
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a3fede5ca621bd4e9ecb0dc59e5925e
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
+7
@@ -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;
|
||||
|
||||
+6
-5
@@ -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 能力或围绕程序集常驻做容量与运维策略,不再新增第三套生命周期。
|
||||
|
||||
Reference in New Issue
Block a user