M1 (in progress): engine.windowing + engine.render, hot-reload proven live
The first plugins with a real external dependency (Silk.NET) and the first that need a display. Both built, both verified by hand against a real window and a live GL context — not just "compiles." engine.windowing: - IEngineWindow, not IWindow — Silk.NET's own windowing type already owns that name; same lesson as the kernel's World -> GameWorld rename, applied proactively this time instead of hitting the build error first. docs/kernel-contract.md's §3 illustrative example updated to match. - Exposes the real Silk.NET IWindow directly (IEngineWindow.Native) rather than re-wrapping it — GL context creation and event pumping both need it, and hiding it buys nothing yet. engine.render: - Minimal: glClear + SwapBuffers against a hardcoded color, no mesh. Enough to prove M1's actual claim, which has nothing to do with triangles specifically: edit a plugin, rebuild just it, reload it while a real window stays open, see the change with no app restart. - No Contracts assembly — PluginManifest.Contracts is now nullable rather than forcing an empty assembly into existence just to satisfy the schema; PluginHost.Load skips the Default-ALC step when absent. Engine.Host: - --windowed alongside --headless: pumps window events plus Stage.Update/Stage.Render each frame instead of a bounded --frames loop. References Engine.Windowing.Contracts directly (never the implementation) to know how to drive that loop — same "Contracts are safe to share" pattern already proven for Sandbox.Echo.Contracts. - New: typing "r <plugin-id>" + Enter reloads that plugin live. Not a file watcher (still not built), but real Unload+Load through the same PluginHost path, against a running window — this is what actually exercised the hot-reload claim below. - IServiceRegistry gained TryRequire<T> so Engine.Host can ask "is a window available" without treating its absence as an error; a plugin's own Configure()/Shutdown() should keep using Require(). Verified end to end by hand: opened the window, watched it render its hardcoded color, edited RenderPlugin.cs's ClearColor, rebuilt only that project, typed "r engine.render" into the running process, and watched the color change with the same window and GL context still alive. Also found and documented a real platform gotcha along the way: on Wayland (unlike X11), a window with no committed buffer isn't shown at all, not even as a black rectangle — engine.windowing alone produces an invisible window; engine.render's first Clear+SwapBuffers is what actually makes it appear. Noted directly on RenderPlugin. Not covered by automated tests, deliberately: opening a real window needs a real display, which isn't safe to assume of every environment this runs in. ServiceRegistry.TryRequire<T> and the null-Contracts path in PluginHost are unit-tested; the window/render/reload flow is described here and was checked by hand instead. README status updated: M1 in progress, not done — engine.input and an actual drawn triangle (vs. a clear color) are still open. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
@@ -23,6 +23,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Kernel.Tests", "test
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.ConformanceHarness", "tests\Engine.ConformanceHarness\Engine.ConformanceHarness.csproj", "{C9725988-61CD-4EFC-A3DD-F7F42B3C4B2D}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.windowing", "engine.windowing", "{E4E5DDBB-4FEB-AC72-3CEB-8E5D71B29053}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Windowing.Contracts", "plugins\engine.windowing\Engine.Windowing.Contracts\Engine.Windowing.Contracts.csproj", "{AE132341-387B-4C5F-A428-9D3AEC57F44F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Windowing", "plugins\engine.windowing\Engine.Windowing\Engine.Windowing.csproj", "{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.render", "engine.render", "{4B0CEF17-61C3-056D-753F-1CA96E0E17CD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Render", "plugins\engine.render\Engine.Render\Engine.Render.csproj", "{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -105,6 +115,42 @@ Global
|
||||
{C9725988-61CD-4EFC-A3DD-F7F42B3C4B2D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C9725988-61CD-4EFC-A3DD-F7F42B3C4B2D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C9725988-61CD-4EFC-A3DD-F7F42B3C4B2D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|x64.Build.0 = Release|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -117,5 +163,10 @@ Global
|
||||
{B0F967C4-B801-4A53-829E-689C2728553A} = {5277871D-FD82-4E0F-9994-47CB654DDE62}
|
||||
{D7C61E35-8805-4C28-B3D7-084D8FBC6820} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{C9725988-61CD-4EFC-A3DD-F7F42B3C4B2D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{E4E5DDBB-4FEB-AC72-3CEB-8E5D71B29053} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||
{AE132341-387B-4C5F-A428-9D3AEC57F44F} = {E4E5DDBB-4FEB-AC72-3CEB-8E5D71B29053}
|
||||
{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08} = {E4E5DDBB-4FEB-AC72-3CEB-8E5D71B29053}
|
||||
{4B0CEF17-61C3-056D-753F-1CA96E0E17CD} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||
{6D1AEAF7-9885-4557-9D7A-2B29376A34B4} = {4B0CEF17-61C3-056D-753F-1CA96E0E17CD}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -33,9 +33,15 @@ enforcement), `PluginHost` (two-ALC load/unload, verified leak-free over
|
||||
200 cycles), and a headless CLI (`engine run --headless ... --dump`) — all
|
||||
exist and are tested. The full agent loop from
|
||||
[`docs/kernel-contract.md#7`](docs/kernel-contract.md#7-written-by-an-agent-not-a-human)
|
||||
runs end to end. No window, no rendering, no physics yet — see the build
|
||||
order (M0–M4) in [`docs/kernel-contract.md`](docs/kernel-contract.md) for
|
||||
what's next.
|
||||
runs end to end.
|
||||
|
||||
**M1 in progress.** `engine.windowing` and a minimal `engine.render` (clear
|
||||
color, no mesh yet) exist over Silk.NET, and the milestone's actual claim —
|
||||
edit a plugin's code, rebuild just it, reload it while a real window stays
|
||||
open, see the change with no app restart — is proven, by hand, against a
|
||||
live GL context. `engine.input` and an actual drawn triangle (vs. a clear
|
||||
color) are still open. No physics yet — see the build order (M0–M4) in
|
||||
[`docs/kernel-contract.md`](docs/kernel-contract.md) for what's next.
|
||||
|
||||
Design and implementation are argued over in the same place: the doc is
|
||||
still the thing to disagree with before code changes to match.
|
||||
|
||||
@@ -200,7 +200,7 @@ public sealed class RenderPlugin : IPlugin
|
||||
public void Configure(IPluginContext ctx)
|
||||
{
|
||||
// control plane: hand out an interface, take one in
|
||||
var window = ctx.Services.Require<IWindow>();
|
||||
var window = ctx.Services.Require<IEngineWindow>();
|
||||
ctx.Services.Provide<IRenderer>(new VulkanRenderer(window));
|
||||
|
||||
// data plane: the system queries GameObjects by component type.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
<ProjectReference Include="..\..\engine.windowing\Engine.Windowing.Contracts\Engine.Windowing.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,58 @@
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.World;
|
||||
using Engine.Windowing.Contracts;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace Engine.Render;
|
||||
|
||||
/// <summary>
|
||||
/// M1's minimal render pipeline: clears the window to a color and swaps
|
||||
/// buffers, once per Render stage. No Contracts assembly — nothing here is
|
||||
/// a type another plugin needs to reference yet (see the null-Contracts
|
||||
/// note on PluginManifest). See M1 in docs/kernel-contract.md §8.
|
||||
///
|
||||
/// This is the whole point of M1's "done when": change ClearColor, rebuild
|
||||
/// just this plugin, and reload it while the window from engine.windowing
|
||||
/// stays open — the color changes with no app restart. Verified by hand,
|
||||
/// not by an automated test — opening a real window needs a real display,
|
||||
/// which isn't something to assume of every environment this runs in.
|
||||
///
|
||||
/// engine.windowing alone produces a window that never becomes visible on
|
||||
/// Wayland — unlike X11, a Wayland surface with no committed buffer simply
|
||||
/// isn't shown by the compositor, so an "empty" window isn't even a black
|
||||
/// rectangle, it's nothing at all. This plugin's first Clear+SwapBuffers is
|
||||
/// what actually makes the window appear.
|
||||
/// </summary>
|
||||
public sealed class RenderPlugin : IPlugin
|
||||
{
|
||||
private static readonly float[] ClearColor = [0.25f, 0.55f, 0.85f, 1f];
|
||||
|
||||
private GL? _gl;
|
||||
private IEngineWindow? _window;
|
||||
|
||||
public void Configure(IPluginContext ctx)
|
||||
{
|
||||
_window = ctx.Services.Require<IEngineWindow>();
|
||||
_window.Native.GLContext!.MakeCurrent();
|
||||
_gl = _window.Native.CreateOpenGL();
|
||||
|
||||
ctx.Schedule.Add(Stage.Render, Draw);
|
||||
ctx.Log.Info("GL context created");
|
||||
}
|
||||
|
||||
public void Shutdown(IPluginContext ctx)
|
||||
{
|
||||
ctx.Schedule.RemoveAllFrom("engine.render");
|
||||
_gl?.Dispose();
|
||||
_gl = null;
|
||||
_window = null;
|
||||
}
|
||||
|
||||
private void Draw(IWorld world)
|
||||
{
|
||||
_gl!.ClearColor(ClearColor[0], ClearColor[1], ClearColor[2], ClearColor[3]);
|
||||
_gl.Clear(ClearBufferMask.ColorBufferBit);
|
||||
_window!.Native.GLContext!.SwapBuffers();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "engine.render",
|
||||
"version": "0.1.0",
|
||||
"assembly": "Engine.Render.dll",
|
||||
"dependsOn": {
|
||||
"engine.windowing": "^0.1"
|
||||
},
|
||||
"reloadable": true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,22 @@
|
||||
using Silk.NET.Windowing;
|
||||
|
||||
namespace Engine.Windowing.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Not named <c>IWindow</c> — Silk.NET's own windowing type already has
|
||||
/// that name in this same file's scope, and living with that would be a
|
||||
/// standing invitation for the exact ambiguous-name mistake that forced
|
||||
/// renaming the kernel's own <c>World</c> to <c>GameWorld</c> — see
|
||||
/// docs/kernel-contract.md §2.
|
||||
///
|
||||
/// Exposes the real Silk.NET window directly rather than re-wrapping it:
|
||||
/// GL context creation and event pumping both need it, and hiding it
|
||||
/// behind another layer buys nothing at M1's stage. See M1 in
|
||||
/// docs/kernel-contract.md §8.
|
||||
/// </summary>
|
||||
public interface IEngineWindow
|
||||
{
|
||||
IWindow Native { get; }
|
||||
|
||||
bool IsClosing { get; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Generates the deps.json AssemblyDependencyResolver needs to find
|
||||
this plugin's NuGet dependencies — see PluginLoadContext in
|
||||
Engine.Kernel, and the same property on Sandbox.Echo.csproj. -->
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
<ProjectReference Include="..\Engine.Windowing.Contracts\Engine.Windowing.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Engine.Windowing.Contracts;
|
||||
using Silk.NET.Windowing;
|
||||
|
||||
namespace Engine.Windowing;
|
||||
|
||||
internal sealed class SilkEngineWindow(IWindow native) : IEngineWindow
|
||||
{
|
||||
public IWindow Native { get; } = native;
|
||||
|
||||
public bool IsClosing => Native.IsClosing;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Windowing.Contracts;
|
||||
using Silk.NET.Windowing;
|
||||
|
||||
namespace Engine.Windowing;
|
||||
|
||||
/// <summary>
|
||||
/// M1's first real plugin: opens an actual window and publishes it as a
|
||||
/// service. Doesn't drive the frame loop itself — Engine.Host does that,
|
||||
/// pumping window events plus Schedule.RunStage each frame, same as it
|
||||
/// already does for the headless case. See M1 in docs/kernel-contract.md §8.
|
||||
/// </summary>
|
||||
public sealed class WindowingPlugin : IPlugin
|
||||
{
|
||||
private IWindow? _window;
|
||||
|
||||
public void Configure(IPluginContext ctx)
|
||||
{
|
||||
var options = WindowOptions.Default with
|
||||
{
|
||||
Size = new(1280, 720),
|
||||
Title = "Lingua Engine",
|
||||
};
|
||||
|
||||
_window = Window.Create(options);
|
||||
_window.Initialize();
|
||||
|
||||
ctx.Services.Provide<IEngineWindow>(new SilkEngineWindow(_window));
|
||||
ctx.Log.Info($"window created ({options.Size.X}x{options.Size.Y})");
|
||||
}
|
||||
|
||||
public void Shutdown(IPluginContext ctx)
|
||||
{
|
||||
ctx.Services.Revoke<IEngineWindow>();
|
||||
_window?.Dispose();
|
||||
_window = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "engine.windowing",
|
||||
"version": "0.1.0",
|
||||
"contracts": "Engine.Windowing.Contracts.dll",
|
||||
"assembly": "Engine.Windowing.dll",
|
||||
"dependsOn": {},
|
||||
"reloadable": true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"engineVersion": "^0.1",
|
||||
"plugins": [
|
||||
{ "id": "engine.windowing" },
|
||||
{ "id": "engine.render" }
|
||||
],
|
||||
"pluginPaths": []
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
<ProjectReference Include="..\..\plugins\engine.windowing\Engine.Windowing.Contracts\Engine.Windowing.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// The runtime host — the headless half of the loop described in
|
||||
// docs/kernel-contract.md §7:
|
||||
// The runtime host — drives both halves of the loop described in
|
||||
// docs/kernel-contract.md §7 and the M1 windowed case in §8:
|
||||
//
|
||||
// engine run --headless --plugins <dir> --project <project.json> --frames <n> [--dump <path>]
|
||||
// engine run --windowed --plugins <dir> --project <project.json> [--dump <path>]
|
||||
//
|
||||
// --windowed needs a loaded plugin that provides IEngineWindow (engine.
|
||||
// windowing) — Engine.Host references that plugin's *Contracts* assembly
|
||||
// directly (never its implementation, which stays dynamically loaded via
|
||||
// PluginHost/ALC same as any other plugin) because driving a window-pumped
|
||||
// loop is the host's job, not the kernel's: Engine.Kernel never hears about
|
||||
// Silk.NET at all.
|
||||
//
|
||||
// Deliberately not implemented yet, both noted explicitly below rather than
|
||||
// silently accepted or rejected as gibberish:
|
||||
@@ -22,6 +30,7 @@ using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.Services;
|
||||
using Engine.Kernel.World;
|
||||
using Engine.Windowing.Contracts;
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
@@ -46,6 +55,7 @@ string? pluginsPath = null;
|
||||
string? dumpPath = null;
|
||||
var frames = 0;
|
||||
var headless = false;
|
||||
var windowed = false;
|
||||
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
@@ -54,6 +64,9 @@ for (var i = 1; i < args.Length; i++)
|
||||
case "--headless":
|
||||
headless = true;
|
||||
break;
|
||||
case "--windowed":
|
||||
windowed = true;
|
||||
break;
|
||||
case "--frames" when i + 1 < args.Length:
|
||||
frames = int.Parse(args[++i]);
|
||||
break;
|
||||
@@ -77,9 +90,10 @@ for (var i = 1; i < args.Length; i++)
|
||||
}
|
||||
}
|
||||
|
||||
if (!headless)
|
||||
if (headless == windowed)
|
||||
{
|
||||
Console.Error.WriteLine("Only --headless is implemented — there's no windowing plugin yet.");
|
||||
Console.Error.WriteLine("Pass exactly one of --headless or --windowed.");
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -92,7 +106,8 @@ if (projectPath is null || pluginsPath is null)
|
||||
|
||||
var world = new GameWorld();
|
||||
var schedule = new Schedule();
|
||||
var host = new PluginHost(world, new ServiceRegistry(), schedule, new NullEventBus());
|
||||
var services = new ServiceRegistry();
|
||||
var host = new PluginHost(world, services, schedule, new NullEventBus());
|
||||
|
||||
IReadOnlyList<string> loaded;
|
||||
try
|
||||
@@ -107,10 +122,69 @@ catch (Exception ex)
|
||||
|
||||
Console.WriteLine($"Loaded {loaded.Count} plugin(s): {string.Join(", ", loaded)}");
|
||||
|
||||
for (var frame = 0; frame < frames; frame++)
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
if (windowed)
|
||||
{
|
||||
if (!services.TryRequire<IEngineWindow>(out var window))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"--windowed requires a loaded plugin that provides IEngineWindow (e.g. engine.windowing).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Ran {frames} update frame(s).");
|
||||
Console.WriteLine("Window open — close it to exit. Type 'r <plugin-id>' + Enter to reload a plugin live.");
|
||||
|
||||
// Line-based, not Console.ReadKey: KeyAvailable needs a real terminal
|
||||
// in raw mode and throws or misbehaves on piped/redirected stdin. A
|
||||
// background reader plus a thread-safe queue works either way and
|
||||
// costs nothing on the render loop's own thread.
|
||||
var reloadQueue = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
string? line;
|
||||
while ((line = Console.ReadLine()) is not null)
|
||||
{
|
||||
var parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts is ["r", var pluginId])
|
||||
reloadQueue.Enqueue(pluginId);
|
||||
}
|
||||
});
|
||||
|
||||
while (!window!.IsClosing)
|
||||
{
|
||||
window.Native.DoEvents();
|
||||
|
||||
if (window.IsClosing)
|
||||
break;
|
||||
|
||||
while (reloadQueue.TryDequeue(out var pluginId))
|
||||
{
|
||||
var pluginDirectory = Path.Combine(pluginsPath, pluginId);
|
||||
Console.WriteLine($"Reloading '{pluginId}'...");
|
||||
try
|
||||
{
|
||||
host.Unload(pluginId);
|
||||
host.Load(pluginDirectory);
|
||||
Console.WriteLine($"Reloaded '{pluginId}'. World state and the window were untouched.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to reload '{pluginId}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
schedule.RunStage(Stage.Render, world);
|
||||
}
|
||||
|
||||
Console.WriteLine("Window closed.");
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var frame = 0; frame < frames; frame++)
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
|
||||
Console.WriteLine($"Ran {frames} update frame(s).");
|
||||
}
|
||||
|
||||
if (dumpPath is not null)
|
||||
{
|
||||
@@ -123,5 +197,9 @@ return 0;
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Usage: engine run --headless --plugins <dir> --project <project.json> --frames <n> [--dump <path>]");
|
||||
"""
|
||||
Usage:
|
||||
engine run --headless --plugins <dir> --project <project.json> --frames <n> [--dump <path>]
|
||||
engine run --windowed --plugins <dir> --project <project.json> [--dump <path>]
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -149,6 +149,9 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
|
||||
|
||||
private static void LoadContractsIntoDefaultAlc(string pluginDirectory, PluginManifest manifest)
|
||||
{
|
||||
if (manifest.Contracts is null)
|
||||
return;
|
||||
|
||||
var contractsPath = Path.Combine(pluginDirectory, manifest.Contracts);
|
||||
var name = AssemblyName.GetAssemblyName(contractsPath).Name;
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ public sealed class PluginManifest
|
||||
public required string Id { get; init; }
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>Assembly loaded into the Default ALC. Never unloads. See §4.</summary>
|
||||
public required string Contracts { get; init; }
|
||||
/// <summary>Assembly loaded into the Default ALC. Never unloads. See §4.
|
||||
/// Null for a plugin with no types or interfaces for anything else to
|
||||
/// reference — manufacturing an empty Contracts assembly just to fill
|
||||
/// this field would document nothing real.</summary>
|
||||
public string? Contracts { get; init; }
|
||||
|
||||
/// <summary>Assembly loaded into a collectible ALC. Reloadable. See §4.</summary>
|
||||
public required string Assembly { get; init; }
|
||||
|
||||
@@ -9,5 +9,13 @@ public interface IServiceRegistry
|
||||
{
|
||||
void Provide<T>(T instance) where T : class;
|
||||
T Require<T>() where T : class;
|
||||
|
||||
/// <summary>For a caller — typically Engine.Host — that needs to know
|
||||
/// whether an optional service exists without treating its absence as
|
||||
/// an error. A plugin's own Configure()/Shutdown() should keep using
|
||||
/// Require(): a missing dependency there is a real configuration bug,
|
||||
/// not a legitimate maybe.</summary>
|
||||
bool TryRequire<T>(out T? instance) where T : class;
|
||||
|
||||
void Revoke<T>() where T : class;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,18 @@ public sealed class ServiceRegistry : IServiceRegistry
|
||||
$"No service is registered for '{typeof(T).FullName}'.");
|
||||
}
|
||||
|
||||
public bool TryRequire<T>(out T? instance) where T : class
|
||||
{
|
||||
if (_services.TryGetValue(typeof(T), out var found))
|
||||
{
|
||||
instance = (T)found;
|
||||
return true;
|
||||
}
|
||||
|
||||
instance = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
// No-op if absent, deliberately: Shutdown() is expected to revoke
|
||||
// unconditionally, including services a partially-failed Configure()
|
||||
// never got around to providing.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using Engine.Kernel.Services;
|
||||
|
||||
namespace Engine.Kernel.Tests;
|
||||
|
||||
public class ServiceRegistryTests
|
||||
{
|
||||
private interface IGreeter
|
||||
{
|
||||
string Greet();
|
||||
}
|
||||
|
||||
private sealed class Greeter : IGreeter
|
||||
{
|
||||
public string Greet() => "hi";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provide_Then_Require_Returns_The_Same_Instance()
|
||||
{
|
||||
var registry = new ServiceRegistry();
|
||||
var greeter = new Greeter();
|
||||
|
||||
registry.Provide<IGreeter>(greeter);
|
||||
|
||||
Assert.Same(greeter, registry.Require<IGreeter>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Require_Throws_When_Nothing_Is_Registered()
|
||||
{
|
||||
var registry = new ServiceRegistry();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(registry.Require<IGreeter>);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provide_Throws_When_A_Service_For_The_Type_Already_Exists()
|
||||
{
|
||||
var registry = new ServiceRegistry();
|
||||
registry.Provide<IGreeter>(new Greeter());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => registry.Provide<IGreeter>(new Greeter()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRequire_Returns_False_And_Null_When_Nothing_Is_Registered()
|
||||
{
|
||||
var registry = new ServiceRegistry();
|
||||
|
||||
var found = registry.TryRequire<IGreeter>(out var instance);
|
||||
|
||||
Assert.False(found);
|
||||
Assert.Null(instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRequire_Returns_True_And_The_Instance_When_Registered()
|
||||
{
|
||||
var registry = new ServiceRegistry();
|
||||
var greeter = new Greeter();
|
||||
registry.Provide<IGreeter>(greeter);
|
||||
|
||||
var found = registry.TryRequire<IGreeter>(out var instance);
|
||||
|
||||
Assert.True(found);
|
||||
Assert.Same(greeter, instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Revoke_Is_A_No_Op_When_Nothing_Is_Registered()
|
||||
{
|
||||
var registry = new ServiceRegistry();
|
||||
|
||||
var exception = Record.Exception(registry.Revoke<IGreeter>);
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Revoke_Removes_The_Service_So_Require_Throws_Afterward()
|
||||
{
|
||||
var registry = new ServiceRegistry();
|
||||
registry.Provide<IGreeter>(new Greeter());
|
||||
|
||||
registry.Revoke<IGreeter>();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(registry.Require<IGreeter>);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user