diff --git a/LinguaEngine.sln b/LinguaEngine.sln
index 041b44e..b02a63d 100644
--- a/LinguaEngine.sln
+++ b/LinguaEngine.sln
@@ -33,6 +33,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.render", "engine.ren
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Render", "plugins\engine.render\Engine.Render\Engine.Render.csproj", "{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Render.Contracts", "plugins\engine.render\Engine.Render.Contracts\Engine.Render.Contracts.csproj", "{59C40966-4302-4FD7-8DDB-961C2653D290}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.input", "engine.input", "{A97AE94C-1787-9CBF-B2A1-C74EA1A0F17A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Input.Contracts", "plugins\engine.input\Engine.Input.Contracts\Engine.Input.Contracts.csproj", "{7374240C-A59B-437B-818A-620F7A3B391C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Input", "plugins\engine.input\Engine.Input\Engine.Input.csproj", "{A3784B8F-8782-4B55-807B-1BADD06D5211}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -151,6 +159,42 @@ Global
{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
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|x64.Build.0 = Debug|Any CPU
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|x86.Build.0 = Debug|Any CPU
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|Any CPU.Build.0 = Release|Any CPU
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|x64.ActiveCfg = Release|Any CPU
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|x64.Build.0 = Release|Any CPU
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|x86.ActiveCfg = Release|Any CPU
+ {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|x86.Build.0 = Release|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|x64.Build.0 = Debug|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|x86.Build.0 = Debug|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Release|x64.ActiveCfg = Release|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Release|x64.Build.0 = Release|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Release|x86.ActiveCfg = Release|Any CPU
+ {7374240C-A59B-437B-818A-620F7A3B391C}.Release|x86.Build.0 = Release|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|x64.Build.0 = Debug|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|x86.Build.0 = Debug|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|x64.ActiveCfg = Release|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|x64.Build.0 = Release|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|x86.ActiveCfg = Release|Any CPU
+ {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -168,5 +212,9 @@ Global
{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}
+ {59C40966-4302-4FD7-8DDB-961C2653D290} = {4B0CEF17-61C3-056D-753F-1CA96E0E17CD}
+ {A97AE94C-1787-9CBF-B2A1-C74EA1A0F17A} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
+ {7374240C-A59B-437B-818A-620F7A3B391C} = {A97AE94C-1787-9CBF-B2A1-C74EA1A0F17A}
+ {A3784B8F-8782-4B55-807B-1BADD06D5211} = {A97AE94C-1787-9CBF-B2A1-C74EA1A0F17A}
EndGlobalSection
EndGlobal
diff --git a/README.md b/README.md
index 1f27f09..b69ff21 100644
--- a/README.md
+++ b/README.md
@@ -35,12 +35,19 @@ 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.
-**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
+**M1 done.** `engine.windowing`, `engine.render` (a real shader-drawn
+triangle, not just a clear color), and `engine.input` all exist over
+Silk.NET. 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 against a live GL context: two PNGs of the *same*
+running window, before and after a live reload, orange triangle then green,
+same process the whole time. `IScreenCapture` (`engine.render`) reads the
+frame back from the GPU and writes it to a file with a hand-rolled PNG
+encoder — no `SixLabors.ImageSharp` (its license isn't MIT/Apache) and no
+desktop screenshot tool, so this is checkable without a screen at all,
+exactly the introspection story `docs/kernel-contract.md#7` argues for.
+
+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
diff --git a/plugins/engine.input/Engine.Input.Contracts/Engine.Input.Contracts.csproj b/plugins/engine.input/Engine.Input.Contracts/Engine.Input.Contracts.csproj
new file mode 100644
index 0000000..e6ede92
--- /dev/null
+++ b/plugins/engine.input/Engine.Input.Contracts/Engine.Input.Contracts.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/plugins/engine.input/Engine.Input.Contracts/IEngineInput.cs b/plugins/engine.input/Engine.Input.Contracts/IEngineInput.cs
new file mode 100644
index 0000000..711471b
--- /dev/null
+++ b/plugins/engine.input/Engine.Input.Contracts/IEngineInput.cs
@@ -0,0 +1,15 @@
+using Silk.NET.Input;
+
+namespace Engine.Input.Contracts;
+
+///
+/// Not IInput — same reasoning as IEngineWindow not being IWindow: keep a
+/// clear line between our own vocabulary and the library's, even where a
+/// collision isn't imminent yet. Reuses Silk.NET's own Key enum
+/// rather than inventing one — it's just data, same as exposing
+/// IEngineWindow.Native directly.
+///
+public interface IEngineInput
+{
+ bool IsKeyDown(Key key);
+}
diff --git a/plugins/engine.input/Engine.Input/Engine.Input.csproj b/plugins/engine.input/Engine.Input/Engine.Input.csproj
new file mode 100644
index 0000000..b6438eb
--- /dev/null
+++ b/plugins/engine.input/Engine.Input/Engine.Input.csproj
@@ -0,0 +1,17 @@
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/engine.input/Engine.Input/InputPlugin.cs b/plugins/engine.input/Engine.Input/InputPlugin.cs
new file mode 100644
index 0000000..f50f81c
--- /dev/null
+++ b/plugins/engine.input/Engine.Input/InputPlugin.cs
@@ -0,0 +1,35 @@
+using Engine.Kernel.Plugins;
+using Engine.Input.Contracts;
+using Engine.Windowing.Contracts;
+using Silk.NET.Input;
+
+namespace Engine.Input;
+
+///
+/// M1's third plugin. Publishes keyboard state as a service — nothing in
+/// the engine reacts to it yet, since there's no gameplay code to react
+/// with; the bar this clears is the same one engine.windowing cleared
+/// before engine.render existed to prove it visually: constructs cleanly
+/// against a real window, doesn't throw. See M1 in docs/kernel-contract.md
+/// §8.
+///
+public sealed class InputPlugin : IPlugin
+{
+ private IInputContext? _input;
+
+ public void Configure(IPluginContext ctx)
+ {
+ var window = ctx.Services.Require();
+ _input = window.Native.CreateInput();
+
+ ctx.Services.Provide(new SilkEngineInput(_input));
+ ctx.Log.Info($"input ready ({_input.Keyboards.Count} keyboard(s), {_input.Mice.Count} mouse(s))");
+ }
+
+ public void Shutdown(IPluginContext ctx)
+ {
+ ctx.Services.Revoke();
+ _input?.Dispose();
+ _input = null;
+ }
+}
diff --git a/plugins/engine.input/Engine.Input/SilkEngineInput.cs b/plugins/engine.input/Engine.Input/SilkEngineInput.cs
new file mode 100644
index 0000000..f57b160
--- /dev/null
+++ b/plugins/engine.input/Engine.Input/SilkEngineInput.cs
@@ -0,0 +1,9 @@
+using Engine.Input.Contracts;
+using Silk.NET.Input;
+
+namespace Engine.Input;
+
+internal sealed class SilkEngineInput(IInputContext context) : IEngineInput
+{
+ public bool IsKeyDown(Key key) => context.Keyboards.Any(k => k.IsKeyPressed(key));
+}
diff --git a/plugins/engine.input/plugin.json b/plugins/engine.input/plugin.json
new file mode 100644
index 0000000..9b30045
--- /dev/null
+++ b/plugins/engine.input/plugin.json
@@ -0,0 +1,10 @@
+{
+ "id": "engine.input",
+ "version": "0.1.0",
+ "contracts": "Engine.Input.Contracts.dll",
+ "assembly": "Engine.Input.dll",
+ "dependsOn": {
+ "engine.windowing": "^0.1"
+ },
+ "reloadable": true
+}
diff --git a/plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj b/plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj
new file mode 100644
index 0000000..c632161
--- /dev/null
+++ b/plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj
@@ -0,0 +1,3 @@
+
+
+
diff --git a/plugins/engine.render/Engine.Render.Contracts/IScreenCapture.cs b/plugins/engine.render/Engine.Render.Contracts/IScreenCapture.cs
new file mode 100644
index 0000000..596636a
--- /dev/null
+++ b/plugins/engine.render/Engine.Render.Contracts/IScreenCapture.cs
@@ -0,0 +1,13 @@
+namespace Engine.Render.Contracts;
+
+///
+/// Reads the current frame back from the GPU and saves it to a PNG file.
+/// Exists so the render output can be checked without a real, visible
+/// display or screenshot tool — an agent (or a human working headlessly)
+/// can render a frame and look at the file instead. See M1 in
+/// docs/kernel-contract.md §8.
+///
+public interface IScreenCapture
+{
+ void CaptureToFile(string path);
+}
diff --git a/plugins/engine.render/Engine.Render/Engine.Render.csproj b/plugins/engine.render/Engine.Render/Engine.Render.csproj
index c4b1e09..11917a6 100644
--- a/plugins/engine.render/Engine.Render/Engine.Render.csproj
+++ b/plugins/engine.render/Engine.Render/Engine.Render.csproj
@@ -2,6 +2,14 @@
true
+
+
+ true
@@ -11,6 +19,7 @@
+
diff --git a/plugins/engine.render/Engine.Render/GlScreenCapture.cs b/plugins/engine.render/Engine.Render/GlScreenCapture.cs
new file mode 100644
index 0000000..d999866
--- /dev/null
+++ b/plugins/engine.render/Engine.Render/GlScreenCapture.cs
@@ -0,0 +1,27 @@
+using Engine.Render.Contracts;
+using Engine.Windowing.Contracts;
+using Silk.NET.OpenGL;
+
+namespace Engine.Render;
+
+internal sealed class GlScreenCapture(GL gl, IEngineWindow window) : IScreenCapture
+{
+ public void CaptureToFile(string path)
+ {
+ var size = window.Native.FramebufferSize;
+ var width = size.X;
+ var height = size.Y;
+ var stride = width * 4;
+
+ var bottomUp = new byte[stride * height];
+ gl.ReadPixels(0, 0, (uint)width, (uint)height, PixelFormat.Rgba, PixelType.UnsignedByte, bottomUp.AsSpan());
+
+ // OpenGL's row 0 is the bottom of the image; PngWriter wants row 0
+ // to be the top.
+ var topDown = new byte[bottomUp.Length];
+ for (var y = 0; y < height; y++)
+ Array.Copy(bottomUp, (height - 1 - y) * stride, topDown, y * stride, stride);
+
+ PngWriter.Write(path, width, height, topDown);
+ }
+}
diff --git a/plugins/engine.render/Engine.Render/PngWriter.cs b/plugins/engine.render/Engine.Render/PngWriter.cs
new file mode 100644
index 0000000..8e96110
--- /dev/null
+++ b/plugins/engine.render/Engine.Render/PngWriter.cs
@@ -0,0 +1,119 @@
+using System.IO.Compression;
+using System.Text;
+
+namespace Engine.Render;
+
+///
+/// Minimal PNG encoder: 8-bit RGBA, uncompressed-filter scanlines (filter
+/// type 0, "None"), one IDAT chunk. No external dependency —
+/// SixLabors.ImageSharp was considered and rejected: its license isn't
+/// MIT or Apache (it's revenue-gated), and pulling that into an MIT
+/// engine's own screenshot tooling would be exactly the kind of surprise
+/// a downstream user shouldn't have to discover later. (BCL, since .NET 6) does the one genuinely hard
+/// part — a correctly zlib-wrapped DEFLATE stream — so what's left here
+/// (chunk framing, CRC32) is small enough to get right and to verify by
+/// actually opening a file this writes.
+///
+internal static class PngWriter
+{
+ private static readonly byte[] Signature = [137, 80, 78, 71, 13, 10, 26, 10];
+ private static readonly uint[] Crc32Table = BuildCrc32Table();
+
+ ///
+ /// is *
+ /// * 4 bytes, row 0 first (top of the
+ /// image). OpenGL's ReadPixels returns rows bottom-first — flip
+ /// before calling this, not after; this writer has no opinion about
+ /// where the bytes came from.
+ ///
+ public static void Write(string path, int width, int height, ReadOnlySpan topDownRgba)
+ {
+ using var file = File.Create(path);
+ file.Write(Signature);
+ WriteChunk(file, "IHDR", BuildIhdr(width, height));
+ WriteChunk(file, "IDAT", BuildIdat(width, height, topDownRgba));
+ WriteChunk(file, "IEND", []);
+ }
+
+ private static byte[] BuildIhdr(int width, int height)
+ {
+ var ihdr = new byte[13];
+ WriteUInt32BE(ihdr, 0, (uint)width);
+ WriteUInt32BE(ihdr, 4, (uint)height);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 6; // color type: RGBA
+ ihdr[10] = 0; // compression method: deflate (the only one PNG defines)
+ ihdr[11] = 0; // filter method: adaptive (per-scanline filter byte)
+ ihdr[12] = 0; // interlace method: none
+ return ihdr;
+ }
+
+ private static byte[] BuildIdat(int width, int height, ReadOnlySpan rgba)
+ {
+ var stride = width * 4;
+ using var compressed = new MemoryStream();
+
+ using (var zlib = new ZLibStream(compressed, CompressionLevel.Optimal, leaveOpen: true))
+ {
+ var filterByte = new byte[1]; // 0 = "None" — every scanline, unfiltered
+ for (var y = 0; y < height; y++)
+ {
+ zlib.Write(filterByte);
+ zlib.Write(rgba.Slice(y * stride, stride));
+ }
+ }
+
+ return compressed.ToArray();
+ }
+
+ private static void WriteChunk(Stream stream, string type, byte[] data)
+ {
+ var typeBytes = Encoding.ASCII.GetBytes(type);
+
+ Span length = stackalloc byte[4];
+ WriteUInt32BE(length, 0, (uint)data.Length);
+ stream.Write(length);
+
+ stream.Write(typeBytes);
+ stream.Write(data);
+
+ Span crc = stackalloc byte[4];
+ WriteUInt32BE(crc, 0, Crc32(typeBytes, data));
+ stream.Write(crc);
+ }
+
+ private static void WriteUInt32BE(Span buffer, int offset, uint value)
+ {
+ buffer[offset] = (byte)(value >> 24);
+ buffer[offset + 1] = (byte)(value >> 16);
+ buffer[offset + 2] = (byte)(value >> 8);
+ buffer[offset + 3] = (byte)value;
+ }
+
+ private static uint[] BuildCrc32Table()
+ {
+ var table = new uint[256];
+ for (uint n = 0; n < 256; n++)
+ {
+ var c = n;
+ for (var k = 0; k < 8; k++)
+ c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
+ table[n] = c;
+ }
+
+ return table;
+ }
+
+ private static uint Crc32(byte[] type, byte[] data)
+ {
+ var crc = 0xFFFFFFFFu;
+
+ foreach (var b in type)
+ crc = Crc32Table[(crc ^ b) & 0xFF] ^ (crc >> 8);
+ foreach (var b in data)
+ crc = Crc32Table[(crc ^ b) & 0xFF] ^ (crc >> 8);
+
+ return crc ^ 0xFFFFFFFFu;
+ }
+}
diff --git a/plugins/engine.render/Engine.Render/RenderPlugin.cs b/plugins/engine.render/Engine.Render/RenderPlugin.cs
index 9880366..951f75a 100644
--- a/plugins/engine.render/Engine.Render/RenderPlugin.cs
+++ b/plugins/engine.render/Engine.Render/RenderPlugin.cs
@@ -1,22 +1,22 @@
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
using Engine.Kernel.World;
+using Engine.Render.Contracts;
using Engine.Windowing.Contracts;
using Silk.NET.OpenGL;
namespace Engine.Render;
///
-/// 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.
+/// M1's render pipeline: one hardcoded triangle, drawn every Render stage.
+/// 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.
+/// This is the whole point of M1's "done when": change TriangleColor,
+/// 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 against a real window and a live GL context, and via
+/// IScreenCapture — no external image library; see the note on PngWriter
+/// for why.
///
/// engine.windowing alone produces a window that never becomes visible on
/// Wayland — unlike X11, a Wayland surface with no committed buffer simply
@@ -26,33 +26,130 @@ namespace Engine.Render;
///
public sealed class RenderPlugin : IPlugin
{
- private static readonly float[] ClearColor = [0.25f, 0.55f, 0.85f, 1f];
+ private const string VertexShaderSource = """
+ #version 330 core
+ layout (location = 0) in vec2 aPosition;
+
+ void main()
+ {
+ gl_Position = vec4(aPosition, 0.0, 1.0);
+ }
+ """;
+
+ private const string FragmentShaderSource = """
+ #version 330 core
+ out vec4 FragColor;
+ uniform vec4 uColor;
+
+ void main()
+ {
+ FragColor = uColor;
+ }
+ """;
+
+ private static readonly float[] TriangleColor = [0.2f, 0.8f, 0.4f, 1f];
+
+ private static readonly float[] Vertices =
+ [
+ 0.0f, 0.6f,
+ -0.6f, -0.6f,
+ 0.6f, -0.6f,
+ ];
private GL? _gl;
private IEngineWindow? _window;
+ private uint _vao;
+ private uint _vbo;
+ private uint _program;
+ private int _colorLocation;
- public void Configure(IPluginContext ctx)
+ public unsafe void Configure(IPluginContext ctx)
{
_window = ctx.Services.Require();
_window.Native.GLContext!.MakeCurrent();
_gl = _window.Native.CreateOpenGL();
+ _program = LinkProgram(_gl, VertexShaderSource, FragmentShaderSource);
+ _colorLocation = _gl.GetUniformLocation(_program, "uColor");
+
+ _vao = _gl.GenVertexArray();
+ _gl.BindVertexArray(_vao);
+
+ _vbo = _gl.GenBuffer();
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
+ _gl.BufferData(BufferTargetARB.ArrayBuffer, Vertices, BufferUsageARB.StaticDraw);
+
+ _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), (void*)0);
+ _gl.EnableVertexAttribArray(0);
+ _gl.BindVertexArray(0);
+
+ ctx.Services.Provide(new GlScreenCapture(_gl, _window));
ctx.Schedule.Add(Stage.Render, Draw);
- ctx.Log.Info("GL context created");
+ ctx.Log.Info("GL context created, triangle ready");
}
public void Shutdown(IPluginContext ctx)
{
ctx.Schedule.RemoveAllFrom("engine.render");
- _gl?.Dispose();
+ ctx.Services.Revoke();
+
+ if (_gl is not null)
+ {
+ _gl.DeleteVertexArray(_vao);
+ _gl.DeleteBuffer(_vbo);
+ _gl.DeleteProgram(_program);
+ _gl.Dispose();
+ }
+
_gl = null;
_window = null;
}
private void Draw(IWorld world)
{
- _gl!.ClearColor(ClearColor[0], ClearColor[1], ClearColor[2], ClearColor[3]);
+ _gl!.ClearColor(0.05f, 0.05f, 0.08f, 1f);
_gl.Clear(ClearBufferMask.ColorBufferBit);
+
+ _gl.UseProgram(_program);
+ _gl.Uniform4(_colorLocation, TriangleColor[0], TriangleColor[1], TriangleColor[2], TriangleColor[3]);
+ _gl.BindVertexArray(_vao);
+ _gl.DrawArrays(PrimitiveType.Triangles, 0, 3);
+
_window!.Native.GLContext!.SwapBuffers();
}
+
+ private static uint LinkProgram(GL gl, string vertexSource, string fragmentSource)
+ {
+ var vertex = CompileShader(gl, ShaderType.VertexShader, vertexSource);
+ var fragment = CompileShader(gl, ShaderType.FragmentShader, fragmentSource);
+
+ var program = gl.CreateProgram();
+ gl.AttachShader(program, vertex);
+ gl.AttachShader(program, fragment);
+ gl.LinkProgram(program);
+
+ gl.GetProgram(program, GLEnum.LinkStatus, out var linked);
+ if (linked == 0)
+ throw new InvalidOperationException($"Shader program failed to link: {gl.GetProgramInfoLog(program)}");
+
+ gl.DetachShader(program, vertex);
+ gl.DetachShader(program, fragment);
+ gl.DeleteShader(vertex);
+ gl.DeleteShader(fragment);
+
+ return program;
+ }
+
+ private static uint CompileShader(GL gl, ShaderType type, string source)
+ {
+ var shader = gl.CreateShader(type);
+ gl.ShaderSource(shader, source);
+ gl.CompileShader(shader);
+
+ gl.GetShader(shader, GLEnum.CompileStatus, out var compiled);
+ if (compiled == 0)
+ throw new InvalidOperationException($"{type} failed to compile: {gl.GetShaderInfoLog(shader)}");
+
+ return shader;
+ }
}
diff --git a/plugins/engine.render/plugin.json b/plugins/engine.render/plugin.json
index d8001dc..16af25a 100644
--- a/plugins/engine.render/plugin.json
+++ b/plugins/engine.render/plugin.json
@@ -1,6 +1,7 @@
{
"id": "engine.render",
"version": "0.1.0",
+ "contracts": "Engine.Render.Contracts.dll",
"assembly": "Engine.Render.dll",
"dependsOn": {
"engine.windowing": "^0.1"
diff --git a/samples/WindowDemo/project.json b/samples/WindowDemo/project.json
index 427b523..3ab7f40 100644
--- a/samples/WindowDemo/project.json
+++ b/samples/WindowDemo/project.json
@@ -2,7 +2,8 @@
"engineVersion": "^0.1",
"plugins": [
{ "id": "engine.windowing" },
- { "id": "engine.render" }
+ { "id": "engine.render" },
+ { "id": "engine.input" }
],
"pluginPaths": []
}
diff --git a/src/Engine.Host/Engine.Host.csproj b/src/Engine.Host/Engine.Host.csproj
index e1dc8ed..92197ed 100644
--- a/src/Engine.Host/Engine.Host.csproj
+++ b/src/Engine.Host/Engine.Host.csproj
@@ -3,6 +3,7 @@
+
diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs
index cddbcf5..9a6222b 100644
--- a/src/Engine.Host/Program.cs
+++ b/src/Engine.Host/Program.cs
@@ -30,6 +30,7 @@ using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
using Engine.Kernel.Services;
using Engine.Kernel.World;
+using Engine.Render.Contracts;
using Engine.Windowing.Contracts;
if (args.Length == 0)
@@ -53,7 +54,9 @@ if (args[0] != "run")
string? projectPath = null;
string? pluginsPath = null;
string? dumpPath = null;
+string? screenshotPath = null;
var frames = 0;
+var screenshotAfterFrames = 1;
var headless = false;
var windowed = false;
@@ -79,6 +82,12 @@ for (var i = 1; i < args.Length; i++)
case "--dump" when i + 1 < args.Length:
dumpPath = args[++i];
break;
+ case "--screenshot" when i + 1 < args.Length:
+ screenshotPath = args[++i];
+ break;
+ case "--screenshot-after-frames" when i + 1 < args.Length:
+ screenshotAfterFrames = int.Parse(args[++i]);
+ break;
case "--scene":
case "--assert":
Console.Error.WriteLine($"'{args[i]}' isn't implemented yet — see the notes at the top of Program.cs.");
@@ -131,49 +140,104 @@ if (windowed)
return 1;
}
- Console.WriteLine("Window open — close it to exit. Type 'r ' + Enter to reload a plugin live.");
+ Console.WriteLine(
+ """
+ Window open — close it to exit. Commands (type + Enter):
+ r reload that plugin live
+ screenshot save the current frame to a PNG (needs a plugin providing IScreenCapture)
+ """);
// 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();
+ var commandQueue = new System.Collections.Concurrent.ConcurrentQueue<(string Command, string Argument)>();
_ = 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);
+ if (parts is [var command, var argument])
+ commandQueue.Enqueue((command, argument));
}
});
+ var frameCount = 0;
+
while (!window!.IsClosing)
{
+ frameCount++;
window.Native.DoEvents();
if (window.IsClosing)
break;
- while (reloadQueue.TryDequeue(out var pluginId))
+ // "screenshot" waits until after this frame's Render stage below —
+ // captured now, it would grab whatever the *previous* frame left
+ // in the framebuffer, not what this iteration is about to draw.
+ var pendingScreenshots = new List();
+
+ // --screenshot is the non-interactive path: capture once, then
+ // exit, so a script can fire-and-forget instead of managing a
+ // stdin pipe into a long-running process.
+ if (screenshotPath is not null && frameCount == screenshotAfterFrames)
+ pendingScreenshots.Add(screenshotPath);
+
+ while (commandQueue.TryDequeue(out var cmd))
{
- var pluginDirectory = Path.Combine(pluginsPath, pluginId);
- Console.WriteLine($"Reloading '{pluginId}'...");
- try
+ switch (cmd.Command)
{
- 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}");
+ case "r":
+ var pluginDirectory = Path.Combine(pluginsPath, cmd.Argument);
+ Console.WriteLine($"Reloading '{cmd.Argument}'...");
+ try
+ {
+ host.Unload(cmd.Argument);
+ host.Load(pluginDirectory);
+ Console.WriteLine($"Reloaded '{cmd.Argument}'. World state and the window were untouched.");
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Failed to reload '{cmd.Argument}': {ex.Message}");
+ }
+
+ break;
+
+ case "screenshot":
+ pendingScreenshots.Add(cmd.Argument);
+ break;
+
+ default:
+ Console.Error.WriteLine($"Unknown command: '{cmd.Command}'");
+ break;
}
}
schedule.RunStage(Stage.Update, world);
schedule.RunStage(Stage.Render, world);
+
+ foreach (var path in pendingScreenshots)
+ {
+ if (!services.TryRequire(out var capture))
+ {
+ Console.Error.WriteLine("No loaded plugin provides IScreenCapture (e.g. engine.render).");
+ continue;
+ }
+
+ try
+ {
+ capture!.CaptureToFile(path);
+ Console.WriteLine($"Wrote screenshot to '{path}'.");
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Failed to capture screenshot: {ex.Message}");
+ }
+ }
+
+ if (screenshotPath is not null && frameCount == screenshotAfterFrames)
+ break;
}
Console.WriteLine("Window closed.");
@@ -201,5 +265,11 @@ static void PrintUsage()
Usage:
engine run --headless --plugins --project --frames [--dump ]
engine run --windowed --plugins --project [--dump ]
+ [--screenshot [--screenshot-after-frames ]]
+
+ --screenshot captures once, after frames (default 1), then exits
+ — for scripts and agents; no interactive terminal needed. To keep
+ the window open and drive it interactively instead, type commands
+ into stdin while it runs: 'r ' or 'screenshot '.
""");
}
diff --git a/src/Engine.Kernel/Plugins/PluginHost.cs b/src/Engine.Kernel/Plugins/PluginHost.cs
index b92aa9e..bcf5dd0 100644
--- a/src/Engine.Kernel/Plugins/PluginHost.cs
+++ b/src/Engine.Kernel/Plugins/PluginHost.cs
@@ -29,6 +29,16 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
PropertyNameCaseInsensitive = true,
};
+ // Shared across every PluginHost in the process, not per-instance:
+ // AssemblyLoadContext.Default.Resolving is itself process-wide, and an
+ // instance-bound handler on it would keep that PluginHost reachable
+ // forever — exactly the kind of leak this whole architecture exists to
+ // avoid, just aimed at a host instead of a plugin ALC. See
+ // EnsureDefaultResolvingHooked.
+ private static readonly List ContractResolvers = [];
+ private static readonly Lock ContractResolversLock = new();
+ private static bool _defaultResolvingHooked;
+
private readonly Dictionary _loaded = [];
///
@@ -147,6 +157,26 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
return null;
}
+ ///
+ /// Loading a Contracts assembly straight into the Default ALC says
+ /// nothing about how ITS OWN dependencies (beyond Engine.Kernel) get
+ /// resolved — unlike a plugin's implementation, which always gets a
+ /// PluginLoadContext with a real AssemblyDependencyResolver behind it.
+ /// This went unnoticed for a while: engine.windowing's and
+ /// engine.render's Contracts both depend on Silk.NET packages, but
+ /// Engine.Host happens to reference those same Contracts projects
+ /// directly (to drive the windowed loop and screenshot capture — see
+ /// Program.cs), so their transitive dependencies were already sitting
+ /// in Engine.Host's own output directory and got found by luck via
+ /// normal probing. engine.input's Contracts has no such lucky
+ /// coincidence: Engine.Host has no reason to reference it, so its
+ /// Silk.NET.Input dependency wasn't anywhere the default resolution
+ /// order would look — a real FileNotFoundException, not a hypothetical
+ /// one. Hooking Default.Resolving with a resolver built against each
+ /// loaded Contracts path fixes it for real, rather than for whichever
+ /// Contracts assemblies happen to also be referenced by whatever's
+ /// hosting the engine this time.
+ ///
private static void LoadContractsIntoDefaultAlc(string pluginDirectory, PluginManifest manifest)
{
if (manifest.Contracts is null)
@@ -158,8 +188,38 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
var alreadyLoaded = AssemblyLoadContext.Default.Assemblies
.Any(a => a.GetName().Name == name);
- if (!alreadyLoaded)
- AssemblyLoadContext.Default.LoadFromAssemblyPath(contractsPath);
+ if (alreadyLoaded)
+ return;
+
+ EnsureDefaultResolvingHooked();
+
+ lock (ContractResolversLock)
+ ContractResolvers.Add(new AssemblyDependencyResolver(contractsPath));
+
+ AssemblyLoadContext.Default.LoadFromAssemblyPath(contractsPath);
+ }
+
+ private static void EnsureDefaultResolvingHooked()
+ {
+ if (_defaultResolvingHooked)
+ return;
+
+ _defaultResolvingHooked = true;
+
+ AssemblyLoadContext.Default.Resolving += (_, name) =>
+ {
+ lock (ContractResolversLock)
+ {
+ foreach (var resolver in ContractResolvers)
+ {
+ var path = resolver.ResolveAssemblyToPath(name);
+ if (path is not null)
+ return AssemblyLoadContext.Default.LoadFromAssemblyPath(path);
+ }
+ }
+
+ return null;
+ };
}
private static Type FindPluginType(Assembly assembly, string pluginId)