From f6d9ea896f921de6244f214a2bd2341e2cd4c323 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 2 Sep 2026 04:42:54 +0300 Subject: [PATCH] Close M1: real triangle, engine.input, screenshot-to-file capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three plugins M1 called for now exist over Silk.NET, and the milestone's actual claim is proven against a live GL context, not just argued: edit a plugin's code, rebuild just it, reload it while a real window stays open, see the change with no app restart. Verified directly this time — two PNGs of the same running window, before and after a live reload (orange triangle -> green, same process, same window, same GL context throughout) — not by analogy to the earlier clear-color version. engine.render, upgraded from clear-color to real geometry: - Vertex/fragment shaders compiled and linked at Configure() time, with real error checking (GetShader/GetProgram *Status + InfoLog on failure) rather than trusting hand-typed GLSL to just work. - VAO/VBO for a hardcoded triangle; Shutdown() deletes all three GL objects rather than leaking them across reloads. - unsafe confined to Configure() (VertexAttribPointer takes a raw offset pointer) via a narrow, documented override of Directory.Build.props' default — native graphics interop, not the kernel data structures docs/kernel-contract.md §7 was written against. engine.input: publishes IEngineInput (keyboard state) via Silk.NET.Input. Reuses Silk.NET's own Key enum rather than inventing one, same call as IEngineWindow.Native. Loads and constructs cleanly against a real window; nothing reacts to it yet since there's no gameplay code to. IScreenCapture (new, engine.render): reads the frame back via ReadPixels and writes a PNG. No SixLabors.ImageSharp — checked its license first and it isn't MIT/Apache (revenue-gated), which would have been a real surprise for downstream users of an MIT engine. PngWriter is a from-scratch encoder instead: ZLibStream (BCL, .NET 6+) for the one genuinely hard part, a correctly zlib-wrapped DEFLATE stream; chunk framing and CRC32 are small enough to get right and to verify by actually decoding files this wrote (done repeatedly, by hand, across this session). Engine.Host: "screenshot " joins "r " as a live stdin command, plus a non-interactive --screenshot/--screenshot-after- frames pair that captures once and exits — for scripts and agents that can't easily hold a pipe open into a long-running process. Real bug found and fixed in PluginHost, not specific to any one environment: loading a Contracts assembly into the Default ALC never set up resolution for ITS OWN dependencies. engine.windowing's and engine.render's Contracts both need Silk.NET packages and loaded fine anyway, by accident — Engine.Host references those two Contracts projects directly (to drive the windowed loop and screenshot capture), so their dependencies were already sitting in Engine.Host's own output directory. engine.input's Contracts has no such lucky coincidence and failed with a real FileNotFoundException. Fixed by hooking AssemblyLoadContext.Default.Resolving with an AssemblyDependencyResolver per loaded Contracts path — mirroring what PluginLoadContext already does for collectible ALCs, applied to the one path that never had it. Hooked once per process via a static list/flag, not per PluginHost instance, specifically to avoid a PluginHost instance becoming unreclaimable through its own event subscription. Not covered by automated tests, deliberately, same reasoning as the windowing/render pass: opening a real window and reading back a real framebuffer both need a real display. Verified by hand instead, documented above and in commit history rather than asserted. README status: M1 done, not "in progress." Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N --- LinguaEngine.sln | 48 +++++++ README.md | 19 ++- .../Engine.Input.Contracts.csproj | 7 + .../Engine.Input.Contracts/IEngineInput.cs | 15 +++ .../Engine.Input/Engine.Input.csproj | 17 +++ .../engine.input/Engine.Input/InputPlugin.cs | 35 +++++ .../Engine.Input/SilkEngineInput.cs | 9 ++ plugins/engine.input/plugin.json | 10 ++ .../Engine.Render.Contracts.csproj | 3 + .../Engine.Render.Contracts/IScreenCapture.cs | 13 ++ .../Engine.Render/Engine.Render.csproj | 9 ++ .../Engine.Render/GlScreenCapture.cs | 27 ++++ .../engine.render/Engine.Render/PngWriter.cs | 119 +++++++++++++++++ .../Engine.Render/RenderPlugin.cs | 125 ++++++++++++++++-- plugins/engine.render/plugin.json | 1 + samples/WindowDemo/project.json | 3 +- src/Engine.Host/Engine.Host.csproj | 1 + src/Engine.Host/Program.cs | 100 +++++++++++--- src/Engine.Kernel/Plugins/PluginHost.cs | 64 ++++++++- 19 files changed, 587 insertions(+), 38 deletions(-) create mode 100644 plugins/engine.input/Engine.Input.Contracts/Engine.Input.Contracts.csproj create mode 100644 plugins/engine.input/Engine.Input.Contracts/IEngineInput.cs create mode 100644 plugins/engine.input/Engine.Input/Engine.Input.csproj create mode 100644 plugins/engine.input/Engine.Input/InputPlugin.cs create mode 100644 plugins/engine.input/Engine.Input/SilkEngineInput.cs create mode 100644 plugins/engine.input/plugin.json create mode 100644 plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj create mode 100644 plugins/engine.render/Engine.Render.Contracts/IScreenCapture.cs create mode 100644 plugins/engine.render/Engine.Render/GlScreenCapture.cs create mode 100644 plugins/engine.render/Engine.Render/PngWriter.cs 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)