Skip to content

Commit 8cf187c

Browse files
author
StArray
committed
feat(modloader): 实现可卸载的 Mod 加载与卸载功能
- 为 ModEntry 添加 LoadContext 属性以支持可卸载的加载上下文 - 使用独立的 ModLoadContext 加载 Mod,避免文件锁定并支持卸载 - 扩展 ModLoader 构造函数,支持管理器目录路径注入 - 实现依赖递归加载,保证依赖先于 Mod 被加载 - 卸载时递归级联卸载所有依赖此 Mod 的 Mod,保证卸载顺序正确 - 卸载时调用 ModLoadContext.Unload 以支持 ALC 异步回收 - 修复 ModLoader 状态恢复,保存并还原 LoadContext 信息 - Platform 端对应构造 ModLoader 时传入管理器目录,支持路径解析 - 在 ModManagerUI 再生成 stub 前自动卸载旧版本,防止 DLL 文件锁定错误 - 删除 StubAssemblyGenerator 相关代码及其调用
1 parent 72d82ab commit 8cf187c

9 files changed

Lines changed: 485 additions & 1017 deletions

File tree

‎StArray.ModManager.Android/Managed.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public static int Entry(int argc, IntPtr argv)
7171
};
7272

7373
_ = RuntimeManager.Detect();
74-
var loader = new ModLoader(modsPath);
74+
var loader = new ModLoader(modsPath, AssemblyPath is { } ap ? Path.GetDirectoryName(ap) : null);
7575
var ui = new ModManagerUI(loader, Path.GetDirectoryName(AssemblyPath)!);
7676
HookHelper.Instance = new DobbyHook();
7777
ImGuiEGLRender.OnRender += ui.Render;
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
using System.Reflection;
2+
using System.Reflection.Emit;
3+
using System.Runtime.Loader;
4+
using StArray.ModManager.Runtime;
5+
6+
namespace StArray.ModManager.Tests;
7+
8+
/// <summary>
9+
/// ModLoadContext 的 dll 加载与卸载测试。
10+
/// 用运行时生成的动态程序集模拟 mod dll(无需原生运行时),
11+
/// 覆盖:文件加载语义(Location)、依赖解析、卸载回收、文件锁行为。
12+
/// </summary>
13+
[TestFixture]
14+
public sealed class ModLoadContextTests
15+
{
16+
private string _tempDir = null!;
17+
18+
[SetUp]
19+
public void SetUp() => _tempDir = Path.Combine(Path.GetTempPath(), $"smm-alc-test-{Guid.NewGuid():N}");
20+
[TearDown]
21+
public void TearDown()
22+
{
23+
try { Directory.Delete(_tempDir, recursive: true); } catch { /* 文件锁测试的残留由 GC 释放 */ }
24+
}
25+
26+
// ── 动态测试程序集生成 ──
27+
28+
/// <summary>生成一个程序集并落盘:含一个公共类 Marker(可被实例化验证)。</summary>
29+
private string WriteAssembly(string fileName, string typeName = "Marker")
30+
{
31+
Directory.CreateDirectory(_tempDir);
32+
var path = Path.Combine(_tempDir, fileName);
33+
34+
var ab = new PersistedAssemblyBuilder(
35+
new AssemblyName(Path.GetFileNameWithoutExtension(fileName)), typeof(object).Assembly);
36+
var mb = ab.DefineDynamicModule("MainModule");
37+
var tb = mb.DefineType(typeName, TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class);
38+
tb.DefineField("Value", typeof(int), FieldAttributes.Public | FieldAttributes.Static);
39+
tb.CreateTypeInfo();
40+
ab.Save(path);
41+
return path;
42+
}
43+
44+
/// <summary>依赖程序集:引用主程序集的类型(触发 ALC 依赖解析链)。</summary>
45+
private (string mainPath, string depPath) WriteDependentAssemblies()
46+
{
47+
var mainPath = WriteAssembly("MainLib.dll", "MainMarker");
48+
49+
// 依赖 dll:字段类型引用 MainLib.MainMarker → 加载时触发依赖解析
50+
var depName = "DepLib";
51+
var ab = new PersistedAssemblyBuilder(new AssemblyName(depName), typeof(object).Assembly);
52+
var mb = ab.DefineDynamicModule("MainModule");
53+
var tb = mb.DefineType("DepMarker", TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class);
54+
55+
// 引用主程序集(跨程序集定义字段)
56+
var mainAb = Assembly.LoadFile(mainPath);
57+
var mainType = mainAb.GetType("MainMarker")!;
58+
tb.DefineField("Ref", mainType, FieldAttributes.Public | FieldAttributes.Static);
59+
tb.CreateTypeInfo();
60+
ab.Save(Path.Combine(_tempDir, depName + ".dll"));
61+
62+
return (mainPath, Path.Combine(_tempDir, depName + ".dll"));
63+
}
64+
65+
// ── 加载语义 ──
66+
67+
[Test]
68+
public void LoadFromFilePath_SetsRealLocation()
69+
{
70+
var path = WriteAssembly("Loc.dll");
71+
var alc = new ModLoadContext("T:Loc", _tempDir);
72+
try
73+
{
74+
var asm = alc.LoadFromFilePath(path);
75+
76+
Assert.Multiple(() =>
77+
{
78+
Assert.That(asm.Location, Is.EqualTo(Path.GetFullPath(path)));
79+
Assert.That(asm.GetType("Marker"), Is.Not.Null);
80+
});
81+
}
82+
finally { alc.Unload(); }
83+
}
84+
85+
[Test]
86+
public void LoadMetadataFromPath_LocationEmpty_NoFileLock()
87+
{
88+
var path = WriteAssembly("Meta.dll");
89+
var alc = new ModLoadContext("T:Meta", _tempDir);
90+
91+
var asm = alc.LoadMetadataFromPath(path);
92+
93+
Assert.That(asm.Location, Is.Empty);
94+
95+
// 不锁文件:加载后可立即删除(路径加载做不到这一点)
96+
File.Delete(path);
97+
Assert.That(File.Exists(path), Is.False);
98+
}
99+
100+
// ── 卸载语义 ──
101+
102+
[Test]
103+
public void Unload_ReleasesAssemblyAndAllowsReload()
104+
{
105+
var path = WriteAssembly("Reload.dll");
106+
var weak = LoadAndUnload(path);
107+
108+
// ALC 卸载后:程序集应可被 GC 回收(弱引用失活)
109+
GcCollectUntil(() => !weak.IsAlive, attempts: 10);
110+
111+
// 重载:从磁盘重新读,新实例独立于旧实例
112+
var alc2 = new ModLoadContext("T:Reload2", _tempDir);
113+
try
114+
{
115+
var asm2 = alc2.LoadFromFilePath(path);
116+
var marker2 = asm2.GetType("Marker");
117+
Assert.That(marker2, Is.Not.Null);
118+
Assert.That(marker2!.Assembly, Is.SameAs(asm2));
119+
}
120+
finally { alc2.Unload(); }
121+
}
122+
123+
[Test]
124+
public void Unload_StrongReferenceKeepsAssemblyAlive()
125+
{
126+
// 对照组:持有 Type 强引用时 collectible ALC 不会回收——
127+
// 验证测试基线的有效性(Unload→可回收 并非无条件成立)
128+
var path = WriteAssembly("Pinned.dll");
129+
var alc = new ModLoadContext("T:Pinned", _tempDir);
130+
var asm = alc.LoadFromFilePath(path);
131+
var pinned = asm.GetType("Marker")!;
132+
133+
var weak = new WeakReference(alc, trackResurrection: true);
134+
alc.Unload();
135+
136+
GcCollectUntil(() => !weak.IsAlive, attempts: 5);
137+
Assert.That(weak.IsAlive, Is.True, "持有 Type 强引用时 ALC 不应被回收");
138+
_ = pinned.Name; // 保持引用存活
139+
}
140+
141+
// ── 依赖解析 ──
142+
143+
[Test]
144+
public void ResolvesDependencyFromSameDirectory()
145+
{
146+
var (mainPath, depPath) = WriteDependentAssemblies();
147+
148+
var alc = new ModLoadContext("T:Dep", _tempDir);
149+
try
150+
{
151+
var dep = alc.LoadFromFilePath(depPath);
152+
153+
// 触发依赖解析:访问引用 MainLib 类型的字段
154+
var field = dep.GetType("DepMarker")!.GetField("Ref")!;
155+
Assert.That(field.FieldType.FullName, Is.EqualTo("MainMarker"));
156+
Assert.That(field.FieldType.Assembly.Location, Is.EqualTo(Path.GetFullPath(mainPath)),
157+
"依赖应从同目录解析且 Location 为真实路径");
158+
}
159+
finally { alc.Unload(); }
160+
}
161+
162+
// ── 文件锁行为(路径加载的已知权衡) ──
163+
164+
[Test]
165+
public void PathLoad_HoldsFileUntilUnloadCollected()
166+
{
167+
var path = WriteAssembly("Locked.dll");
168+
var alc = new ModLoadContext("T:Lock", _tempDir);
169+
_ = alc.LoadFromFilePath(path);
170+
171+
// 加载期间文件被映射锁定:覆盖写入应失败(这是路径加载的确定行为)
172+
Assert.Throws<IOException>(() => File.WriteAllText(path, "corrupt"),
173+
"路径加载的文件在 ALC 卸载前应被锁定");
174+
175+
// 卸载后锁应释放。注意:ALC 回收 ≠ 立即 unmap,且测试宿主进程可能经由
176+
// 其他机制(符号缓存等)短暂持有映射——轮询最多 5 秒。
177+
// 该阶段是对运行时行为的观察而非硬性契约,超时按 Inconclusive 处理。
178+
var weak = new WeakReference(alc, trackResurrection: true);
179+
alc.Unload();
180+
var sw = System.Diagnostics.Stopwatch.StartNew();
181+
var unlocked = false;
182+
while (sw.Elapsed < TimeSpan.FromSeconds(5) && !unlocked)
183+
{
184+
GcCollectUntil(() => !weak.IsAlive, attempts: 2);
185+
try
186+
{
187+
using var fs = File.Open(path, FileMode.Open, FileAccess.Write, FileShare.None);
188+
unlocked = true;
189+
}
190+
catch (IOException)
191+
{
192+
Thread.Sleep(50);
193+
}
194+
}
195+
196+
if (!unlocked)
197+
{
198+
Assert.Inconclusive(
199+
"文件在卸载后 5s 内未解锁(宿主进程可能仍持有映射);" +
200+
"卸载前锁定的核心断言已通过");
201+
}
202+
File.WriteAllText(path, "overwritten");
203+
}
204+
205+
// ── 辅助 ──
206+
207+
private static WeakReference LoadAndUnload(string path)
208+
{
209+
var alc = new ModLoadContext("T:Transient", Path.GetDirectoryName(path)!);
210+
_ = alc.LoadFromFilePath(path);
211+
var weak = new WeakReference(alc, trackResurrection: true);
212+
alc.Unload();
213+
return weak;
214+
}
215+
216+
private static void GcCollectUntil(Func<bool> done, int attempts)
217+
{
218+
for (int i = 0; i < attempts && !done(); i++)
219+
{
220+
GC.Collect();
221+
GC.WaitForPendingFinalizers();
222+
GC.Collect();
223+
}
224+
}
225+
}

‎StArray.ModManager.Tests/StubAssemblyGeneratorTests.cs‎

Lines changed: 0 additions & 24 deletions
This file was deleted.

‎StArray.ModManager.Windows/Managed.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ public static int Entry(int argc, IntPtr argv)
8484
Il2CppFunctions.SetIl2CppLibraryPath(Path.Combine(DllParentPath, "..", "..", "GameAssembly.dll"));
8585
}
8686
HookHelper.Instance = new MinHook();
87-
ModManagerUI modManagerUI = new(new ModLoader(modsPath), DllParentPath);
87+
ModManagerUI modManagerUI = new(new ModLoader(modsPath, DllParentPath), DllParentPath);
8888

8989
NativeApi.SetBackend(1); // D3D11
9090
ImGuiDXRenderer.OnRender += modManagerUI.Render;
Lines changed: 65 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,65 @@
1-
using StArray.ModManager.Runtime;
2-
3-
namespace StArray.ModManager.Manager;
4-
5-
/// <summary>
6-
/// Mod 条目数据模型
7-
/// </summary>
8-
public class ModEntry
9-
{
10-
/// <summary>唯一标识</summary>
11-
public string Id { get; set; } = string.Empty;
12-
13-
/// <summary>Mod 名称</summary>
14-
public string Name { get; set; } = string.Empty;
15-
16-
/// <summary>版本号</summary>
17-
public string Version { get; set; } = "1.0.0";
18-
19-
/// <summary>作者</summary>
20-
public string Author { get; set; } = string.Empty;
21-
22-
/// <summary>描述</summary>
23-
public string Description { get; set; } = string.Empty;
24-
25-
/// <summary>Mod 所在文件夹路径</summary>
26-
public string FolderPath { get; set; } = string.Empty;
27-
28-
/// <summary>入口 DLL 或可执行文件路径</summary>
29-
public string? EntryPoint { get; set; }
30-
31-
/// <summary>是否已启用</summary>
32-
public bool IsEnabled { get; set; }
33-
34-
/// <summary>依赖的其他 Mod ID 列表</summary>
35-
public List<string> Dependencies { get; set; } = new();
36-
37-
/// <summary>加载状态</summary>
38-
public ModLoadState LoadState { get; set; } = ModLoadState.NotLoaded;
39-
40-
/// <summary>加载时的错误信息</summary>
41-
public string? LoadError { get; set; }
42-
43-
/// <summary>已加载的插件实例(供 UI 调用 OnGui)</summary>
44-
public IModPlugin? PluginInstance { get; set; }
45-
46-
/// <summary>格式化显示:Name vVersion</summary>
47-
public override string ToString() => $"{Name} v{Version}";
48-
}
49-
50-
/// <summary>
51-
/// Mod 加载状态
52-
/// </summary>
53-
public enum ModLoadState
54-
{
55-
NotLoaded,
56-
Loading,
57-
Loaded,
58-
Error
59-
}
1+
using StArray.ModManager.Runtime;
2+
3+
namespace StArray.ModManager.Manager;
4+
5+
/// <summary>
6+
/// Mod 条目数据模型
7+
/// </summary>
8+
public class ModEntry
9+
{
10+
/// <summary>唯一标识</summary>
11+
public string Id { get; set; } = string.Empty;
12+
13+
/// <summary>Mod 名称</summary>
14+
public string Name { get; set; } = string.Empty;
15+
16+
/// <summary>版本号</summary>
17+
public string Version { get; set; } = "1.0.0";
18+
19+
/// <summary>作者</summary>
20+
public string Author { get; set; } = string.Empty;
21+
22+
/// <summary>描述</summary>
23+
public string Description { get; set; } = string.Empty;
24+
25+
/// <summary>Mod 所在文件夹路径</summary>
26+
public string FolderPath { get; set; } = string.Empty;
27+
28+
/// <summary>入口 DLL 或可执行文件路径</summary>
29+
public string? EntryPoint { get; set; }
30+
31+
/// <summary>是否已启用</summary>
32+
public bool IsEnabled { get; set; }
33+
34+
/// <summary>依赖的其他 Mod ID 列表</summary>
35+
public List<string> Dependencies { get; set; } = new();
36+
37+
/// <summary>加载状态</summary>
38+
public ModLoadState LoadState { get; set; } = ModLoadState.NotLoaded;
39+
40+
/// <summary>加载时的错误信息</summary>
41+
public string? LoadError { get; set; }
42+
43+
/// <summary>已加载的插件实例(供 UI 调用 OnGui)</summary>
44+
public IModPlugin? PluginInstance { get; set; }
45+
46+
/// <summary>
47+
/// 本 mod 的可卸载加载上下文;null = 未加载。
48+
/// 卸载时置 null 并 <see cref="ModLoadContext.Unload"/>。
49+
/// </summary>
50+
public ModLoadContext? LoadContext { get; set; }
51+
52+
/// <summary>格式化显示:Name vVersion</summary>
53+
public override string ToString() => $"{Name} v{Version}";
54+
}
55+
56+
/// <summary>
57+
/// Mod 加载状态
58+
/// </summary>
59+
public enum ModLoadState
60+
{
61+
NotLoaded,
62+
Loading,
63+
Loaded,
64+
Error
65+
}

0 commit comments

Comments
 (0)