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
@@ -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();
}