diff --git a/AcDream.slnx b/AcDream.slnx
index b87ab62..16bf362 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -6,6 +6,7 @@
+
diff --git a/src/AcDream.Core/Plugins/LoadedPlugin.cs b/src/AcDream.Core/Plugins/LoadedPlugin.cs
new file mode 100644
index 0000000..ee20c14
--- /dev/null
+++ b/src/AcDream.Core/Plugins/LoadedPlugin.cs
@@ -0,0 +1,11 @@
+using AcDream.Plugin.Abstractions;
+
+namespace AcDream.Core.Plugins;
+
+public sealed record LoadedPlugin(
+ PluginManifest Manifest,
+ IAcDreamPlugin? Plugin,
+ string? Error)
+{
+ public bool Success => Plugin is not null && Error is null;
+}
diff --git a/src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs b/src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs
new file mode 100644
index 0000000..9fbc42c
--- /dev/null
+++ b/src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs
@@ -0,0 +1,30 @@
+using System.Reflection;
+using System.Runtime.Loader;
+
+namespace AcDream.Core.Plugins;
+
+///
+/// Collectible ALC for a single plugin. Resolves assemblies from the plugin's
+/// own directory EXCEPT for AcDream.Plugin.Abstractions, which must come from
+/// the default (host) context so type identity for IAcDreamPlugin is preserved.
+///
+internal sealed class PluginAssemblyLoadContext : AssemblyLoadContext
+{
+ private readonly AssemblyDependencyResolver _resolver;
+
+ public PluginAssemblyLoadContext(string pluginDirectory, string pluginEntryPath)
+ : base(name: pluginDirectory, isCollectible: true)
+ {
+ _resolver = new AssemblyDependencyResolver(pluginEntryPath);
+ }
+
+ protected override Assembly? Load(AssemblyName assemblyName)
+ {
+ // Share the abstractions assembly with the host — do NOT reload it in the plugin ALC
+ if (assemblyName.Name == "AcDream.Plugin.Abstractions")
+ return null;
+
+ var path = _resolver.ResolveAssemblyToPath(assemblyName);
+ return path is null ? null : LoadFromAssemblyPath(path);
+ }
+}
diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs
new file mode 100644
index 0000000..d3743e6
--- /dev/null
+++ b/src/AcDream.Core/Plugins/PluginLoader.cs
@@ -0,0 +1,34 @@
+using System.Reflection;
+using AcDream.Plugin.Abstractions;
+
+namespace AcDream.Core.Plugins;
+
+public static class PluginLoader
+{
+ public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host)
+ {
+ var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll);
+ if (!File.Exists(dllPath))
+ return new LoadedPlugin(manifest, null, $"entry dll not found: {dllPath}");
+
+ try
+ {
+ var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
+ var asm = alc.LoadFromAssemblyPath(dllPath);
+
+ var pluginType = asm.GetTypes()
+ .FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t));
+
+ if (pluginType is null)
+ return new LoadedPlugin(manifest, null, $"no IAcDreamPlugin implementation found in {manifest.EntryDll}");
+
+ var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
+ instance.Initialize(host);
+ return new LoadedPlugin(manifest, instance, null);
+ }
+ catch (Exception ex)
+ {
+ return new LoadedPlugin(manifest, null, $"{ex.GetType().Name}: {ex.Message}");
+ }
+ }
+}
diff --git a/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj
new file mode 100644
index 0000000..46a3efe
--- /dev/null
+++ b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ false
+
+
+
+
+ false
+ runtime
+
+
+
diff --git a/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs
new file mode 100644
index 0000000..8048fb6
--- /dev/null
+++ b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs
@@ -0,0 +1,20 @@
+using AcDream.Plugin.Abstractions;
+
+namespace AcDream.Core.Tests.Fixtures.HelloPlugin;
+
+public sealed class HelloPlugin : IAcDreamPlugin
+{
+ public int InitializeCount { get; private set; }
+ public int EnableCount { get; private set; }
+ public int DisableCount { get; private set; }
+ public IPluginHost? ReceivedHost { get; private set; }
+
+ public void Initialize(IPluginHost host)
+ {
+ ReceivedHost = host;
+ InitializeCount++;
+ }
+
+ public void Enable() => EnableCount++;
+ public void Disable() => DisableCount++;
+}
diff --git a/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj b/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
index 8bd478d..2104378 100644
--- a/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
+++ b/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
@@ -22,4 +22,13 @@
+
+
+
+ false
+ true
+
+
+
\ No newline at end of file
diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
new file mode 100644
index 0000000..03a07c7
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
@@ -0,0 +1,102 @@
+using AcDream.Core.Plugins;
+using AcDream.Plugin.Abstractions;
+
+namespace AcDream.Core.Tests.Plugins;
+
+public class PluginLoaderTests
+{
+ private static string FixturePluginPath()
+ {
+ // walk up from the test bin dir to the repo root, then into the fixture's build output
+ var baseDir = AppContext.BaseDirectory;
+ var configuration = new DirectoryInfo(baseDir).Parent!.Name; // Debug / Release
+ var repoRoot = FindRepoRoot(baseDir);
+ return Path.Combine(
+ repoRoot,
+ "tests", "AcDream.Core.Tests.Fixtures.HelloPlugin", "bin", configuration, "net10.0",
+ "AcDream.Core.Tests.Fixtures.HelloPlugin.dll");
+ }
+
+ private static string FindRepoRoot(string startDir)
+ {
+ var dir = new DirectoryInfo(startDir);
+ while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "AcDream.slnx")))
+ dir = dir.Parent;
+ return dir?.FullName ?? throw new InvalidOperationException("repo root not found");
+ }
+
+ private sealed class StubHost : IPluginHost
+ {
+ public IPluginLogger Log { get; } = new StubLogger();
+ }
+
+ private sealed class StubLogger : IPluginLogger
+ {
+ public void Info(string message) { }
+ public void Warn(string message) { }
+ public void Error(string message, Exception? exception = null) { }
+ }
+
+ [Fact]
+ public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize()
+ {
+ var dllPath = FixturePluginPath();
+ Assert.True(File.Exists(dllPath), $"fixture dll not found: {dllPath}");
+
+ var host = new StubHost();
+ var manifest = new PluginManifest(
+ Id: "acdream.test.hello",
+ DisplayName: "Hello",
+ Version: "0.0.1",
+ EntryDll: Path.GetFileName(dllPath),
+ ApiVersion: 1,
+ Dependencies: Array.Empty());
+
+ var loaded = PluginLoader.Load(
+ pluginDirectory: Path.GetDirectoryName(dllPath)!,
+ manifest: manifest,
+ host: host);
+
+ Assert.True(loaded.Success);
+ Assert.NotNull(loaded.Plugin);
+ Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
+ }
+
+ [Fact]
+ public void Load_MissingDll_ReturnsFailure()
+ {
+ var host = new StubHost();
+ var manifest = new PluginManifest(
+ Id: "x",
+ DisplayName: "X",
+ Version: "0.0.1",
+ EntryDll: "nope.dll",
+ ApiVersion: 1,
+ Dependencies: Array.Empty());
+
+ var loaded = PluginLoader.Load("/does/not/exist", manifest, host);
+
+ Assert.False(loaded.Success);
+ Assert.NotNull(loaded.Error);
+ }
+
+ [Fact]
+ public void Load_DllWithNoPluginImpl_ReturnsFailure()
+ {
+ // Use AcDream.Core.dll itself — it has no IAcDreamPlugin impl
+ var coreDllDir = AppContext.BaseDirectory;
+ var host = new StubHost();
+ var manifest = new PluginManifest(
+ Id: "x",
+ DisplayName: "X",
+ Version: "0.0.1",
+ EntryDll: "AcDream.Core.dll",
+ ApiVersion: 1,
+ Dependencies: Array.Empty());
+
+ var loaded = PluginLoader.Load(coreDllDir, manifest, host);
+
+ Assert.False(loaded.Success);
+ Assert.Contains("IAcDreamPlugin", loaded.Error ?? "");
+ }
+}