diff --git a/plugins/sandbox.echo/Sandbox.Echo/Sandbox.Echo.csproj b/plugins/sandbox.echo/Sandbox.Echo/Sandbox.Echo.csproj
index 01983e7..2112dd8 100644
--- a/plugins/sandbox.echo/Sandbox.Echo/Sandbox.Echo.csproj
+++ b/plugins/sandbox.echo/Sandbox.Echo/Sandbox.Echo.csproj
@@ -1,5 +1,12 @@
+
+
+ true
+
+
diff --git a/src/Engine.Kernel/Diagnostics/ConsoleLogger.cs b/src/Engine.Kernel/Diagnostics/ConsoleLogger.cs
new file mode 100644
index 0000000..4089a04
--- /dev/null
+++ b/src/Engine.Kernel/Diagnostics/ConsoleLogger.cs
@@ -0,0 +1,10 @@
+namespace Engine.Kernel.Diagnostics;
+
+/// Prefixes every line with the owning plugin's id — one instance
+/// per , not shared.
+internal sealed class ConsoleLogger(string pluginId) : ILogger
+{
+ public void Info(string message) => Console.WriteLine($"[{pluginId}] {message}");
+ public void Warn(string message) => Console.WriteLine($"[{pluginId}] WARN: {message}");
+ public void Error(string message) => Console.Error.WriteLine($"[{pluginId}] ERROR: {message}");
+}
diff --git a/src/Engine.Kernel/Events/NullEventBus.cs b/src/Engine.Kernel/Events/NullEventBus.cs
new file mode 100644
index 0000000..c835d79
--- /dev/null
+++ b/src/Engine.Kernel/Events/NullEventBus.cs
@@ -0,0 +1,7 @@
+namespace Engine.Kernel.Events;
+
+///
+/// IEventBus has no members yet — see the open question in
+/// docs/kernel-contract.md's footer. Nothing to implement until it does.
+///
+public sealed class NullEventBus : IEventBus;
diff --git a/src/Engine.Kernel/Plugins/LoadedPlugin.cs b/src/Engine.Kernel/Plugins/LoadedPlugin.cs
new file mode 100644
index 0000000..0030812
--- /dev/null
+++ b/src/Engine.Kernel/Plugins/LoadedPlugin.cs
@@ -0,0 +1,7 @@
+namespace Engine.Kernel.Plugins;
+
+internal sealed record LoadedPlugin(
+ PluginManifest Manifest,
+ PluginLoadContext Alc,
+ IPlugin Instance,
+ IPluginContext Context);
diff --git a/src/Engine.Kernel/Plugins/PluginContext.cs b/src/Engine.Kernel/Plugins/PluginContext.cs
new file mode 100644
index 0000000..38d8688
--- /dev/null
+++ b/src/Engine.Kernel/Plugins/PluginContext.cs
@@ -0,0 +1,24 @@
+using Engine.Kernel.Diagnostics;
+using Engine.Kernel.Events;
+using Engine.Kernel.Scheduling;
+using Engine.Kernel.Services;
+using Engine.Kernel.World;
+
+namespace Engine.Kernel.Plugins;
+
+/// One per loaded plugin, built by PluginHost. Everything it
+/// exposes except Log is a shared, kernel-owned singleton — the context
+/// itself is the only thing scoped per plugin.
+internal sealed class PluginContext(
+ string pluginId,
+ IWorld world,
+ IServiceRegistry services,
+ ISchedule schedule,
+ IEventBus events) : IPluginContext
+{
+ public IWorld World { get; } = world;
+ public IServiceRegistry Services { get; } = services;
+ public ISchedule Schedule { get; } = schedule;
+ public IEventBus Events { get; } = events;
+ public ILogger Log { get; } = new ConsoleLogger(pluginId);
+}
diff --git a/src/Engine.Kernel/Plugins/PluginHost.cs b/src/Engine.Kernel/Plugins/PluginHost.cs
new file mode 100644
index 0000000..8b0783a
--- /dev/null
+++ b/src/Engine.Kernel/Plugins/PluginHost.cs
@@ -0,0 +1,123 @@
+using System.Reflection;
+using System.Runtime.Loader;
+using System.Text.Json;
+using Engine.Kernel.Events;
+using Engine.Kernel.Scheduling;
+using Engine.Kernel.Services;
+using Engine.Kernel.World;
+
+namespace Engine.Kernel.Plugins;
+
+///
+/// Loads, unloads, and (eventually) reloads plugins. See
+/// docs/kernel-contract.md §3-§4.
+///
+/// Scope of this pass: single-plugin load/unload with the correct two-ALC
+/// split, and enough bookkeeping that a plugin's Shutdown() can be honest.
+/// NOT yet built: resolving a project's or a plugin's dependsOn graph
+/// to determine load order across multiple plugins — that needs at least
+/// two real interdependent plugins to test against meaningfully, and we
+/// only have one (sandbox.echo, deliberately dependency-free). Loading a
+/// plugin whose dependencies aren't already loaded will fail wherever the
+/// plugin's own code first touches them, not with a host-level error.
+///
+public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule schedule, IEventBus events)
+{
+ private static readonly JsonSerializerOptions ManifestOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private readonly Dictionary _loaded = [];
+
+ ///
+ /// Loads the plugin described by plugin.json in
+ /// . Contracts load into the Default
+ /// ALC; the implementation loads into a fresh collectible ALC. Returns
+ /// the plugin's id.
+ ///
+ public string Load(string pluginDirectory)
+ {
+ var manifest = ReadManifest(pluginDirectory);
+
+ if (_loaded.ContainsKey(manifest.Id))
+ throw new InvalidOperationException($"Plugin '{manifest.Id}' is already loaded.");
+
+ LoadContractsIntoDefaultAlc(pluginDirectory, manifest);
+
+ var implPath = Path.Combine(pluginDirectory, manifest.Assembly);
+ var alc = new PluginLoadContext(manifest.Id, implPath);
+ var implAssembly = alc.LoadFromAssemblyPath(implPath);
+
+ var pluginType = FindPluginType(implAssembly, manifest.Id);
+ var instance = (IPlugin)Activator.CreateInstance(pluginType)!;
+
+ schedule.RegisterPlugin(manifest.Id, implAssembly);
+ var ctx = new PluginContext(manifest.Id, world, services, schedule, events);
+
+ instance.Configure(ctx);
+
+ _loaded[manifest.Id] = new LoadedPlugin(manifest, alc, instance, ctx);
+ return manifest.Id;
+ }
+
+ ///
+ /// Runs Shutdown(), then unloads the plugin's ALC. Returns a weak
+ /// reference to the ALC so a caller can verify it actually collected —
+ /// see the leak test this exists for in
+ /// Engine.ConformanceHarness/AlcUnloadTests.cs.
+ ///
+ public WeakReference Unload(string pluginId)
+ {
+ if (!_loaded.Remove(pluginId, out var loaded))
+ throw new InvalidOperationException($"Plugin '{pluginId}' is not loaded.");
+
+ loaded.Instance.Shutdown(loaded.Context);
+
+ var weakAlc = new WeakReference(loaded.Alc);
+ loaded.Alc.Unload();
+
+ return weakAlc;
+ }
+
+ private static PluginManifest ReadManifest(string pluginDirectory)
+ {
+ var path = Path.Combine(pluginDirectory, "plugin.json");
+
+ if (!File.Exists(path))
+ throw new FileNotFoundException($"No plugin.json found in '{pluginDirectory}'.", path);
+
+ var json = File.ReadAllText(path);
+ return JsonSerializer.Deserialize(json, ManifestOptions)
+ ?? throw new InvalidOperationException($"'{path}' did not deserialize to a plugin manifest.");
+ }
+
+ private static void LoadContractsIntoDefaultAlc(string pluginDirectory, PluginManifest manifest)
+ {
+ var contractsPath = Path.Combine(pluginDirectory, manifest.Contracts);
+ var name = AssemblyName.GetAssemblyName(contractsPath).Name;
+
+ var alreadyLoaded = AssemblyLoadContext.Default.Assemblies
+ .Any(a => a.GetName().Name == name);
+
+ if (!alreadyLoaded)
+ AssemblyLoadContext.Default.LoadFromAssemblyPath(contractsPath);
+ }
+
+ private static Type FindPluginType(Assembly assembly, string pluginId)
+ {
+ var candidates = assembly.GetTypes()
+ .Where(t => t is { IsClass: true, IsAbstract: false } && typeof(IPlugin).IsAssignableFrom(t))
+ .ToList();
+
+ return candidates.Count switch
+ {
+ 0 => throw new InvalidOperationException(
+ $"'{assembly.GetName().Name}' (plugin '{pluginId}') has no IPlugin implementation."),
+ > 1 => throw new InvalidOperationException(
+ $"'{assembly.GetName().Name}' (plugin '{pluginId}') has more than one IPlugin " +
+ $"implementation: {string.Join(", ", candidates.Select(t => t.FullName))}."),
+ _ => candidates[0],
+ };
+ }
+}
diff --git a/src/Engine.Kernel/Plugins/PluginLoadContext.cs b/src/Engine.Kernel/Plugins/PluginLoadContext.cs
new file mode 100644
index 0000000..845cae8
--- /dev/null
+++ b/src/Engine.Kernel/Plugins/PluginLoadContext.cs
@@ -0,0 +1,46 @@
+using System.Reflection;
+using System.Runtime.Loader;
+
+namespace Engine.Kernel.Plugins;
+
+///
+/// The collectible ALC a plugin's implementation assembly loads into. See
+/// docs/kernel-contract.md §4.
+///
+/// The one thing this has to get right: must defer to
+/// whatever's already sitting in the Default ALC — Engine.Kernel, and this
+/// plugin's own Contracts assembly, both loaded there before this context
+/// exists — rather than loading a second copy of either. A second copy
+/// would carry its own distinct runtime Type objects, and every
+/// is T / GetComponent<T>() check across the plugin
+/// boundary would silently fail. Only a genuinely private dependency of
+/// this specific plugin should ever load through this context.
+///
+internal sealed class PluginLoadContext : AssemblyLoadContext
+{
+ private readonly AssemblyDependencyResolver _resolver;
+
+ public PluginLoadContext(string pluginId, string mainAssemblyPath)
+ : base(name: $"plugin:{pluginId}", isCollectible: true)
+ {
+ _resolver = new AssemblyDependencyResolver(mainAssemblyPath);
+ }
+
+ protected override Assembly? Load(AssemblyName assemblyName)
+ {
+ var alreadyInDefault = Default.Assemblies
+ .Any(a => a.GetName().Name == assemblyName.Name);
+
+ if (alreadyInDefault)
+ return null; // defer — the runtime resolves this to the Default copy
+
+ var path = _resolver.ResolveAssemblyToPath(assemblyName);
+ return path is not null ? LoadFromAssemblyPath(path) : null;
+ }
+
+ protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
+ {
+ var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
+ return path is not null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero;
+ }
+}
diff --git a/src/Engine.Kernel/Scheduling/Schedule.cs b/src/Engine.Kernel/Scheduling/Schedule.cs
new file mode 100644
index 0000000..c09cfe8
--- /dev/null
+++ b/src/Engine.Kernel/Scheduling/Schedule.cs
@@ -0,0 +1,42 @@
+using System.Reflection;
+
+namespace Engine.Kernel.Scheduling;
+
+///
+/// Bookkeeping only for now — no stage execution, no parallelism, no
+/// enforcement of declared access. That's the real Scheduler work described
+/// in docs/kernel-contract.md §2 and §7; this pass exists to make
+/// actually correct, because it's the one thing
+/// PluginHost's reload correctness depends on.
+///
+/// Ownership is tracked by the registering delegate's declaring assembly,
+/// not by a string tag on each entry — a plugin can only accidentally
+/// mislabel a system if it hands another plugin's delegate to Add(), which
+/// isn't a realistic failure mode. See .
+///
+public sealed class Schedule : ISchedule
+{
+ private readonly List _systems = [];
+ private readonly Dictionary _pluginAssemblies = [];
+
+ /// Called by PluginHost right after loading a plugin's
+ /// implementation assembly, before Configure() runs.
+ internal void RegisterPlugin(string pluginId, Assembly implementationAssembly)
+ => _pluginAssemblies[pluginId] = implementationAssembly;
+
+ public ISystemBuilder Add(Stage stage, Delegate system)
+ {
+ var entry = new SystemEntry(stage, system);
+ _systems.Add(entry);
+ return new SystemBuilder(entry);
+ }
+
+ public void RemoveAllFrom(string pluginId)
+ {
+ // Removing the assembly mapping here too, not just the systems —
+ // leaving it behind would itself be a stray reference into the
+ // ALC the caller is about to unload.
+ if (_pluginAssemblies.Remove(pluginId, out var assembly))
+ _systems.RemoveAll(e => e.System.Method.DeclaringType?.Assembly == assembly);
+ }
+}
diff --git a/src/Engine.Kernel/Scheduling/SystemBuilder.cs b/src/Engine.Kernel/Scheduling/SystemBuilder.cs
new file mode 100644
index 0000000..f2f33ab
--- /dev/null
+++ b/src/Engine.Kernel/Scheduling/SystemBuilder.cs
@@ -0,0 +1,24 @@
+namespace Engine.Kernel.Scheduling;
+
+using Engine.Kernel.World;
+
+internal sealed class SystemBuilder(SystemEntry entry) : ISystemBuilder
+{
+ public ISystemBuilder After(string systemId)
+ {
+ entry.After = systemId;
+ return this;
+ }
+
+ public ISystemBuilder Reads() where T : Component
+ {
+ entry.Reads.Add(typeof(T));
+ return this;
+ }
+
+ public ISystemBuilder Writes() where T : Component
+ {
+ entry.Writes.Add(typeof(T));
+ return this;
+ }
+}
diff --git a/src/Engine.Kernel/Scheduling/SystemEntry.cs b/src/Engine.Kernel/Scheduling/SystemEntry.cs
new file mode 100644
index 0000000..b02eac3
--- /dev/null
+++ b/src/Engine.Kernel/Scheduling/SystemEntry.cs
@@ -0,0 +1,17 @@
+namespace Engine.Kernel.Scheduling;
+
+using Engine.Kernel.World;
+
+///
+/// One registered system. Reads/Writes are recorded here so a future
+/// Scheduler can build the conflict graph described in docs/kernel-contract.md
+/// §2 — nothing consumes them yet; that's real Scheduler work, not this pass.
+///
+internal sealed class SystemEntry(Stage stage, Delegate system)
+{
+ public Stage Stage { get; } = stage;
+ public Delegate System { get; } = system;
+ public string? After { get; set; }
+ public HashSet Reads { get; } = [];
+ public HashSet Writes { get; } = [];
+}
diff --git a/src/Engine.Kernel/Services/ServiceRegistry.cs b/src/Engine.Kernel/Services/ServiceRegistry.cs
new file mode 100644
index 0000000..605fd84
--- /dev/null
+++ b/src/Engine.Kernel/Services/ServiceRegistry.cs
@@ -0,0 +1,28 @@
+namespace Engine.Kernel.Services;
+
+public sealed class ServiceRegistry : IServiceRegistry
+{
+ private readonly Dictionary _services = [];
+
+ public void Provide(T instance) where T : class
+ {
+ if (!_services.TryAdd(typeof(T), instance))
+ throw new InvalidOperationException(
+ $"A service for '{typeof(T).FullName}' is already registered.");
+ }
+
+ public T Require() where T : class
+ {
+ if (_services.TryGetValue(typeof(T), out var instance))
+ return (T)instance;
+
+ throw new InvalidOperationException(
+ $"No service is registered for '{typeof(T).FullName}'.");
+ }
+
+ // No-op if absent, deliberately: Shutdown() is expected to revoke
+ // unconditionally, including services a partially-failed Configure()
+ // never got around to providing.
+ public void Revoke() where T : class
+ => _services.Remove(typeof(T));
+}
diff --git a/tests/Engine.ConformanceHarness/AlcUnloadTests.cs b/tests/Engine.ConformanceHarness/AlcUnloadTests.cs
index 33d1fca..fab7870 100644
--- a/tests/Engine.ConformanceHarness/AlcUnloadTests.cs
+++ b/tests/Engine.ConformanceHarness/AlcUnloadTests.cs
@@ -1,3 +1,10 @@
+using System.Runtime.Loader;
+using Engine.Kernel.Events;
+using Engine.Kernel.Plugins;
+using Engine.Kernel.Scheduling;
+using Engine.Kernel.Services;
+using Engine.Kernel.World;
+
namespace Engine.ConformanceHarness;
///
@@ -5,20 +12,70 @@ namespace Engine.ConformanceHarness;
/// architecture from slowly degrading: load and unload a plugin 200 times,
/// and after every cycle verify the ALC actually collected. Runs against
/// Sandbox.Echo — see the ReferenceOutputAssembly="false" note in this
-/// project's .csproj for why that reference doesn't link its types in.
+/// project's .csproj for why that reference doesn't link its types in, and
+/// the CopyToOutputDirectory items for how its built DLLs end up sitting
+/// next to plugin.json under this project's own output.
///
public class AlcUnloadTests
{
- // TODO(M0): once PluginHost exists —
- // for (int i = 0; i < 200; i++) {
- // var handle = host.Load("sandbox.echo");
- // var weakAlc = host.Unload(handle);
- // GC.Collect(); GC.WaitForPendingFinalizers();
- // Assert.False(weakAlc.IsAlive, $"ALC survived cycle {i}");
- // }
- // and assert working-set memory hasn't grown beyond noise.
- [Fact(Skip = "PluginHost has no implementation yet — see M0 in docs/kernel-contract.md §8.")]
+ private static string PluginDirectory =>
+ Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.echo");
+
+ private static PluginHost NewHost() =>
+ new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus());
+
+ [Fact]
public void Plugin_Survives_200_Load_Unload_Cycles()
{
+ var host = NewHost();
+
+ for (var i = 0; i < 200; i++)
+ {
+ var id = host.Load(PluginDirectory);
+ var weakAlc = host.Unload(id);
+
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+
+ Assert.False(weakAlc.IsAlive, $"ALC survived unload cycle {i}.");
+ }
+
+ // The WeakReference check above only proves the collectible ALC
+ // let go. It says nothing about the Default ALC, which is never
+ // supposed to grow at all across reloads — Contracts loads once
+ // and every later cycle should find it already there. A count
+ // above 1 here would be a real, separate leak this test would
+ // otherwise miss entirely.
+ var contractsCopies = AssemblyLoadContext.Default.Assemblies
+ .Count(a => a.GetName().Name == "Sandbox.Echo.Contracts");
+ Assert.Equal(1, contractsCopies);
+ }
+
+ [Fact]
+ public void Load_Configures_The_Plugin_Without_Throwing()
+ {
+ var host = NewHost();
+
+ var id = host.Load(PluginDirectory);
+
+ Assert.Equal("sandbox.echo", id);
+ }
+
+ [Fact]
+ public void Load_Throws_When_The_Same_Plugin_Is_Already_Loaded()
+ {
+ var host = NewHost();
+ host.Load(PluginDirectory);
+
+ Assert.Throws(() => host.Load(PluginDirectory));
+ }
+
+ [Fact]
+ public void Unload_Throws_When_The_Plugin_Was_Never_Loaded()
+ {
+ var host = NewHost();
+
+ Assert.Throws(() => host.Unload("sandbox.echo"));
}
}
diff --git a/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj b/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj
index 938b1e7..37079ea 100644
--- a/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj
+++ b/tests/Engine.ConformanceHarness/Engine.ConformanceHarness.csproj
@@ -26,4 +26,26 @@
ReferenceOutputAssembly="false" />
+
+
+
+ plugins\sandbox.echo\plugin.json
+ PreserveNewest
+
+
+ plugins\sandbox.echo\Sandbox.Echo.Contracts.dll
+ PreserveNewest
+
+
+ plugins\sandbox.echo\Sandbox.Echo.dll
+ PreserveNewest
+
+
+
diff --git a/tests/Engine.Kernel.Tests/PluginHostTests.cs b/tests/Engine.Kernel.Tests/PluginHostTests.cs
new file mode 100644
index 0000000..2d1e5ea
--- /dev/null
+++ b/tests/Engine.Kernel.Tests/PluginHostTests.cs
@@ -0,0 +1,34 @@
+using Engine.Kernel.Events;
+using Engine.Kernel.Plugins;
+using Engine.Kernel.Scheduling;
+using Engine.Kernel.Services;
+using Engine.Kernel.World;
+
+namespace Engine.Kernel.Tests;
+
+///
+/// Edge cases that don't need a real, compiled plugin — see
+/// Engine.ConformanceHarness/AlcUnloadTests.cs for the load/unload/reload
+/// path exercised against a real one (Sandbox.Echo).
+///
+public class PluginHostTests
+{
+ private static PluginHost NewHost() =>
+ new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus());
+
+ [Fact]
+ public void Load_Throws_When_The_Directory_Has_No_Manifest()
+ {
+ var host = NewHost();
+ var emptyDir = Directory.CreateTempSubdirectory();
+
+ try
+ {
+ Assert.Throws(() => host.Load(emptyDir.FullName));
+ }
+ finally
+ {
+ emptyDir.Delete(recursive: true);
+ }
+ }
+}