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;
+6 -5
View File
@@ -238,13 +238,14 @@ await loader.ApplyConfigAsync(configTree); // 增量协调:diff → reload/u
**阶段 3 保留边界**ClassicHost 与静态服务门面仍作为兼容层存在;Command-Network 的服务 handler 暂无逐项注销 API;全局静态桥在多根上下文并行时仍需引用计数或实例化。这些不阻塞单默认根主路径,但不能误写成完全隔离保证。
### 阶段 4ModFramework 合流 + 热替换 🔶 第一片完成(2026-08-16
### 阶段 4ModFramework 合流 + 热替换 完成(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 → changedfailed → 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 能力或围绕程序集常驻做容量与运维策略,不再新增第三套生命周期。