diff --git a/LinguaEngine.sln b/LinguaEngine.sln
index b02a63d..83d01ab 100644
--- a/LinguaEngine.sln
+++ b/LinguaEngine.sln
@@ -41,6 +41,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Input.Contracts", "p
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Input", "plugins\engine.input\Engine.Input\Engine.Input.csproj", "{A3784B8F-8782-4B55-807B-1BADD06D5211}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.assets", "engine.assets", "{BBD4296A-8D59-75AB-1261-7E1302A19746}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Assets.Contracts", "plugins\engine.assets\Engine.Assets.Contracts\Engine.Assets.Contracts.csproj", "{1F6F4B27-5624-42F8-87A7-A4729FB198E3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Assets", "plugins\engine.assets\Engine.Assets\Engine.Assets.csproj", "{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Assets.Tests", "tests\Engine.Assets.Tests\Engine.Assets.Tests.csproj", "{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -195,6 +203,42 @@ Global
{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
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|x64.Build.0 = Debug|Any CPU
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|x86.Build.0 = Debug|Any CPU
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|x64.ActiveCfg = Release|Any CPU
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|x64.Build.0 = Release|Any CPU
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|x86.ActiveCfg = Release|Any CPU
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|x86.Build.0 = Release|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|x64.Build.0 = Debug|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|x86.Build.0 = Debug|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|x64.ActiveCfg = Release|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|x64.Build.0 = Release|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|x86.ActiveCfg = Release|Any CPU
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|x86.Build.0 = Release|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|x64.Build.0 = Debug|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|x86.Build.0 = Debug|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|x64.ActiveCfg = Release|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|x64.Build.0 = Release|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|x86.ActiveCfg = Release|Any CPU
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -216,5 +260,9 @@ Global
{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}
+ {BBD4296A-8D59-75AB-1261-7E1302A19746} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
+ {1F6F4B27-5624-42F8-87A7-A4729FB198E3} = {BBD4296A-8D59-75AB-1261-7E1302A19746}
+ {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15} = {BBD4296A-8D59-75AB-1261-7E1302A19746}
+ {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
diff --git a/README.md b/README.md
index 215ab4f..816d772 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,28 @@ both shipped; `sandbox.echo` subscribes to `PluginLoaded` for real, so the
(the fast path) is deliberately still open, but with a concrete trigger
condition instead of a deadline, not left vague.
+**M2 done.** `World` actually saves and loads now (`SceneFormat`,
+replacing the old introspection-only `WorldDumper` — there was never a
+real reason for "what an agent reads to check a frame" and "what a scene
+file is" to be different shapes). Verified beyond round-trip unit tests:
+two separate CLI runs against the same scene file, second one picking up
+right where the first left off, component state and all.
+
+`engine.assets` hot-reloads textures from disk — the actual "done when"
+for M2. `engine.render`'s triangle became a textured quad; swap the PNG
+file on disk while the app is running and the picture changes with no
+restart, no manual reload command, just a `FileSystemWatcher` noticing
+and `IEventBus` carrying `TextureReloaded` from `engine.assets` to
+`engine.render`. Verified the same honest way as M1 — real screenshots,
+before and after, same running process — plus two things caught and fixed
+along the way rather than papered over: a PNG decoder was needed (no
+`SixLabors.ImageSharp`, same licensing reason as the encoder — it's a
+second, independent implementation of the format, tested against all five
+PNG filter types, not just the one this codebase's own writer produces),
+and a real hang, not a hypothetical one: `SwapBuffers` blocking forever
+once VSync had nothing to wait on — reproduced by locking the screen,
+fixed by turning VSync off, since nothing here needs frame pacing yet.
+
No physics yet — see the build order (M0–M4) in
[`docs/kernel-contract.md`](docs/kernel-contract.md) for what's next.
diff --git a/plugins/engine.assets/Engine.Assets.Contracts/AssetEvents.cs b/plugins/engine.assets/Engine.Assets.Contracts/AssetEvents.cs
new file mode 100644
index 0000000..ca059fc
--- /dev/null
+++ b/plugins/engine.assets/Engine.Assets.Contracts/AssetEvents.cs
@@ -0,0 +1,9 @@
+namespace Engine.Assets.Contracts;
+
+///
+/// M2's "done when": swapping a texture on disk changes the picture with
+/// nothing stopped. engine.assets watches the file and publishes this
+/// through IEventBus when it changes; engine.render subscribes and
+/// re-uploads to the GPU. Nothing else connects the two plugins directly.
+///
+public readonly record struct TextureReloaded(string Path, TextureData Data);
diff --git a/plugins/engine.assets/Engine.Assets.Contracts/Engine.Assets.Contracts.csproj b/plugins/engine.assets/Engine.Assets.Contracts/Engine.Assets.Contracts.csproj
new file mode 100644
index 0000000..c632161
--- /dev/null
+++ b/plugins/engine.assets/Engine.Assets.Contracts/Engine.Assets.Contracts.csproj
@@ -0,0 +1,3 @@
+
+
+
diff --git a/plugins/engine.assets/Engine.Assets.Contracts/IAssetService.cs b/plugins/engine.assets/Engine.Assets.Contracts/IAssetService.cs
new file mode 100644
index 0000000..f797d03
--- /dev/null
+++ b/plugins/engine.assets/Engine.Assets.Contracts/IAssetService.cs
@@ -0,0 +1,12 @@
+namespace Engine.Assets.Contracts;
+
+public interface IAssetService
+{
+ ///
+ /// Decodes the PNG at and starts watching it
+ /// for changes. Returns the initial data; later changes arrive via
+ /// , not a return value — there's nothing
+ /// to return to once the caller has moved on.
+ ///
+ TextureData LoadTexture(string path);
+}
diff --git a/plugins/engine.assets/Engine.Assets.Contracts/TextureData.cs b/plugins/engine.assets/Engine.Assets.Contracts/TextureData.cs
new file mode 100644
index 0000000..3f84f35
--- /dev/null
+++ b/plugins/engine.assets/Engine.Assets.Contracts/TextureData.cs
@@ -0,0 +1,8 @@
+namespace Engine.Assets.Contracts;
+
+///
+/// Raw decoded pixels, nothing graphics-API-specific — uploading this to
+/// an actual GPU texture is engine.render's job, not this plugin's, so a
+/// future non-OpenGL render plugin could reuse engine.assets unchanged.
+///
+public sealed record TextureData(int Width, int Height, byte[] Rgba);
diff --git a/plugins/engine.assets/Engine.Assets/AssemblyInfo.cs b/plugins/engine.assets/Engine.Assets/AssemblyInfo.cs
new file mode 100644
index 0000000..56ac559
--- /dev/null
+++ b/plugins/engine.assets/Engine.Assets/AssemblyInfo.cs
@@ -0,0 +1,6 @@
+using System.Runtime.CompilerServices;
+
+// PngReader is internal — nothing outside this plugin needs it, texture
+// loading goes through IAssetService. The test project needs direct access
+// to test the codec itself without going through a GL-dependent round trip.
+[assembly: InternalsVisibleTo("Engine.Assets.Tests")]
diff --git a/plugins/engine.assets/Engine.Assets/AssetPlugin.cs b/plugins/engine.assets/Engine.Assets/AssetPlugin.cs
new file mode 100644
index 0000000..7b9f085
--- /dev/null
+++ b/plugins/engine.assets/Engine.Assets/AssetPlugin.cs
@@ -0,0 +1,34 @@
+using Engine.Assets.Contracts;
+using Engine.Kernel.Plugins;
+using Engine.Kernel.Scheduling;
+
+namespace Engine.Assets;
+
+///
+/// M2's second half: engine.windowing made a window visible, engine.render
+/// made it draw something, this makes what it draws hot-reloadable from a
+/// file on disk — the actual "done when" for M2. See docs/kernel-contract.md
+/// §8.
+///
+public sealed class AssetPlugin : IPlugin
+{
+ private AssetService? _service;
+
+ public void Configure(IPluginContext ctx)
+ {
+ _service = new AssetService(ctx.Events, ctx.Log);
+ ctx.Services.Provide(_service);
+
+ // Pumping on Update, not from the FileSystemWatcher callback
+ // directly — see the note on AssetService.PumpReloads.
+ ctx.Schedule.Add(Stage.Update, _ => _service!.PumpReloads());
+ }
+
+ public void Shutdown(IPluginContext ctx)
+ {
+ ctx.Schedule.RemoveAllFrom("engine.assets");
+ ctx.Services.Revoke();
+ _service?.Dispose();
+ _service = null;
+ }
+}
diff --git a/plugins/engine.assets/Engine.Assets/AssetService.cs b/plugins/engine.assets/Engine.Assets/AssetService.cs
new file mode 100644
index 0000000..4d4c9a8
--- /dev/null
+++ b/plugins/engine.assets/Engine.Assets/AssetService.cs
@@ -0,0 +1,106 @@
+using System.Collections.Concurrent;
+using Engine.Assets.Contracts;
+using Engine.Kernel.Diagnostics;
+using Engine.Kernel.Events;
+
+namespace Engine.Assets;
+
+internal sealed class AssetService(IEventBus events, ILogger log) : IAssetService, IDisposable
+{
+ private readonly List _watchers = [];
+ private readonly ConcurrentQueue _pendingEvents = new();
+ private readonly Dictionary _lastTriggered = [];
+ private readonly Lock _debounceLock = new();
+
+ public TextureData LoadTexture(string path)
+ {
+ var fullPath = Path.GetFullPath(path);
+ var data = Decode(fullPath);
+ Watch(fullPath);
+ return data;
+ }
+
+ ///
+ /// Drains reloads decoded on background threads and publishes them on
+ /// whichever thread calls this — meant to run once per Update stage,
+ /// which always runs on the frame loop's own thread. A subscriber that
+ /// reacts to TextureReloaded by touching a GL texture needs that: GL
+ /// contexts are thread-affine, and FileSystemWatcher.Changed fires on
+ /// a ThreadPool thread that was never made current for any of them.
+ ///
+ public void PumpReloads()
+ {
+ while (_pendingEvents.TryDequeue(out var evt))
+ {
+ events.Publish(evt);
+ log.Info($"reloaded texture '{evt.Path}'");
+ }
+ }
+
+ private void Watch(string fullPath)
+ {
+ var directory = Path.GetDirectoryName(fullPath)!;
+ var fileName = Path.GetFileName(fullPath);
+
+ var watcher = new FileSystemWatcher(directory, fileName)
+ {
+ NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size,
+ };
+
+ watcher.Changed += (_, _) => OnChanged(fullPath);
+ watcher.EnableRaisingEvents = true;
+ _watchers.Add(watcher);
+ }
+
+ private void OnChanged(string path)
+ {
+ // Editors/tools often fire several Changed events for one save
+ // (truncate + write, or multiple flushes) — a quiet window per
+ // path collapses those into a single reload instead of several.
+ lock (_debounceLock)
+ {
+ var now = DateTime.UtcNow;
+ if (_lastTriggered.TryGetValue(path, out var last) && now - last < TimeSpan.FromMilliseconds(200))
+ return;
+ _lastTriggered[path] = now;
+ }
+
+ _ = Task.Run(() => ReloadWithRetry(path));
+ }
+
+ private async Task ReloadWithRetry(string path)
+ {
+ // The writer may still be flushing when Changed fires — a short
+ // retry window absorbs that instead of surfacing a transient
+ // IOException as a real failure.
+ for (var attempt = 0; attempt < 5; attempt++)
+ {
+ try
+ {
+ _pendingEvents.Enqueue(new TextureReloaded(path, Decode(path)));
+ return;
+ }
+ catch (IOException) when (attempt < 4)
+ {
+ await Task.Delay(50);
+ }
+ }
+ }
+
+ private static TextureData Decode(string path)
+ {
+ var (width, height, rgba) = PngReader.Read(path);
+ return new TextureData(width, height, rgba);
+ }
+
+ public void Dispose()
+ {
+ // Undisposed FileSystemWatchers would pin this plugin's ALC the
+ // same way a forgotten Schedule/EventBus registration would —
+ // each one's Changed handler is a delegate into this assembly.
+ foreach (var watcher in _watchers)
+ watcher.Dispose();
+
+ _watchers.Clear();
+ }
+}
diff --git a/plugins/engine.assets/Engine.Assets/Engine.Assets.csproj b/plugins/engine.assets/Engine.Assets/Engine.Assets.csproj
new file mode 100644
index 0000000..b039de2
--- /dev/null
+++ b/plugins/engine.assets/Engine.Assets/Engine.Assets.csproj
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+ true
+
+
+
diff --git a/plugins/engine.assets/Engine.Assets/PngReader.cs b/plugins/engine.assets/Engine.Assets/PngReader.cs
new file mode 100644
index 0000000..87c5fb8
--- /dev/null
+++ b/plugins/engine.assets/Engine.Assets/PngReader.cs
@@ -0,0 +1,189 @@
+using System.IO.Compression;
+
+namespace Engine.Assets;
+
+///
+/// Minimal PNG decoder — the inverse of Engine.Render's PngWriter, and
+/// written for the same reason: SixLabors.ImageSharp's license isn't
+/// MIT/Apache, so it's not something an MIT engine should depend on for
+/// its own texture loading. Same deliberate subset as the encoder: 8-bit
+/// RGBA (color type 6), no interlacing. Unlike the encoder, this decodes
+/// all five PNG filter types (None/Sub/Up/Average/Paeth), not just
+/// None — a real image tool exporting a texture won't necessarily pick
+/// the same filter this codebase's own writer does, and rejecting
+/// anything but self-produced files would defeat the point of loading
+/// textures at all.
+///
+/// Not shared with Engine.Render's PngWriter as a common library: the two
+/// plugins would gain a dependency on each other (or on a third one) for
+/// a couple hundred lines neither owns conceptually — texture loading and
+/// screenshot writing are different concerns that happen to touch the
+/// same file format. Small, deliberate duplication over a premature
+/// shared abstraction for two consumers.
+///
+internal static class PngReader
+{
+ private static readonly byte[] Signature = [137, 80, 78, 71, 13, 10, 26, 10];
+ private static readonly uint[] Crc32Table = BuildCrc32Table();
+
+ public static (int Width, int Height, byte[] Rgba) Read(string path)
+ {
+ using var stream = File.OpenRead(path);
+
+ Span signature = stackalloc byte[8];
+ stream.ReadExactly(signature);
+ if (!signature.SequenceEqual(Signature))
+ throw new InvalidDataException($"'{path}' is not a PNG file.");
+
+ var width = 0;
+ var height = 0;
+ var sawIhdr = false;
+ using var idat = new MemoryStream();
+
+ // Allocated once, outside the loop, and reused every iteration —
+ // a PNG can carry an unbounded number of chunks (multiple IDATs
+ // are routine for a large image), and a stackalloc that re-runs
+ // per iteration doesn't free the previous one until this whole
+ // method returns. The compiler's own CA2014 caught this — a real
+ // stack-overflow risk on a large enough file, not a style nit.
+ Span lengthBytes = stackalloc byte[4];
+ Span typeBytes = stackalloc byte[4];
+ Span crcBytes = stackalloc byte[4];
+
+ while (true)
+ {
+ stream.ReadExactly(lengthBytes);
+ var length = ReadUInt32BE(lengthBytes);
+
+ stream.ReadExactly(typeBytes);
+ var type = System.Text.Encoding.ASCII.GetString(typeBytes);
+
+ var data = new byte[length];
+ stream.ReadExactly(data);
+
+ stream.ReadExactly(crcBytes);
+ var expectedCrc = ReadUInt32BE(crcBytes);
+ var actualCrc = Crc32(typeBytes, data);
+ if (actualCrc != expectedCrc)
+ throw new InvalidDataException($"'{path}': corrupt {type} chunk (CRC mismatch).");
+
+ switch (type)
+ {
+ case "IHDR":
+ width = (int)ReadUInt32BE(data.AsSpan(0, 4));
+ height = (int)ReadUInt32BE(data.AsSpan(4, 4));
+ var bitDepth = data[8];
+ var colorType = data[9];
+ var interlace = data[12];
+ if (bitDepth != 8 || colorType != 6 || interlace != 0)
+ {
+ throw new NotSupportedException(
+ $"'{path}': only 8-bit non-interlaced RGBA PNGs are supported " +
+ $"(got bit depth {bitDepth}, color type {colorType}, interlace {interlace}).");
+ }
+
+ sawIhdr = true;
+ break;
+
+ case "IDAT":
+ idat.Write(data);
+ break;
+
+ case "IEND":
+ goto doneReadingChunks;
+ }
+ }
+
+ doneReadingChunks:
+ if (!sawIhdr)
+ throw new InvalidDataException($"'{path}': no IHDR chunk found.");
+
+ idat.Position = 0;
+ using var zlib = new ZLibStream(idat, CompressionMode.Decompress);
+ using var raw = new MemoryStream();
+ zlib.CopyTo(raw);
+ var scanlines = raw.ToArray();
+
+ var rgba = Unfilter(scanlines, width, height);
+ return (width, height, rgba);
+ }
+
+ private static byte[] Unfilter(byte[] scanlines, int width, int height)
+ {
+ const int bytesPerPixel = 4; // fixed: 8-bit RGBA, see the color type check above
+ var stride = width * bytesPerPixel;
+ var rawStride = stride + 1; // +1 filter-type byte per row
+ var rgba = new byte[stride * height];
+
+ for (var y = 0; y < height; y++)
+ {
+ var filterType = scanlines[y * rawStride];
+ var rowStart = y * rawStride + 1;
+
+ for (var x = 0; x < stride; x++)
+ {
+ var filtered = scanlines[rowStart + x];
+
+ int a = x >= bytesPerPixel ? rgba[y * stride + x - bytesPerPixel] : 0;
+ int b = y > 0 ? rgba[(y - 1) * stride + x] : 0;
+ int c = x >= bytesPerPixel && y > 0 ? rgba[(y - 1) * stride + x - bytesPerPixel] : 0;
+
+ int reconstructed = filterType switch
+ {
+ 0 => filtered,
+ 1 => filtered + a,
+ 2 => filtered + b,
+ 3 => filtered + (a + b) / 2,
+ 4 => filtered + Paeth(a, b, c),
+ _ => throw new NotSupportedException($"Unknown PNG filter type {filterType}."),
+ };
+
+ rgba[y * stride + x] = (byte)reconstructed;
+ }
+ }
+
+ return rgba;
+ }
+
+ private static int Paeth(int a, int b, int c)
+ {
+ var p = a + b - c;
+ var pa = Math.Abs(p - a);
+ var pb = Math.Abs(p - b);
+ var pc = Math.Abs(p - c);
+
+ if (pa <= pb && pa <= pc)
+ return a;
+
+ return pb <= pc ? b : c;
+ }
+
+ private static uint ReadUInt32BE(ReadOnlySpan bytes) =>
+ ((uint)bytes[0] << 24) | ((uint)bytes[1] << 16) | ((uint)bytes[2] << 8) | bytes[3];
+
+ 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(ReadOnlySpan 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.assets/plugin.json b/plugins/engine.assets/plugin.json
new file mode 100644
index 0000000..80c774a
--- /dev/null
+++ b/plugins/engine.assets/plugin.json
@@ -0,0 +1,8 @@
+{
+ "id": "engine.assets",
+ "version": "0.1.0",
+ "contracts": "Engine.Assets.Contracts.dll",
+ "assembly": "Engine.Assets.dll",
+ "dependsOn": {},
+ "reloadable": true
+}
diff --git a/plugins/engine.render/Engine.Render/Engine.Render.csproj b/plugins/engine.render/Engine.Render/Engine.Render.csproj
index 11917a6..c5e4d10 100644
--- a/plugins/engine.render/Engine.Render/Engine.Render.csproj
+++ b/plugins/engine.render/Engine.Render/Engine.Render.csproj
@@ -20,6 +20,7 @@
+
diff --git a/plugins/engine.render/Engine.Render/RenderPlugin.cs b/plugins/engine.render/Engine.Render/RenderPlugin.cs
index 951f75a..f01ca82 100644
--- a/plugins/engine.render/Engine.Render/RenderPlugin.cs
+++ b/plugins/engine.render/Engine.Render/RenderPlugin.cs
@@ -1,3 +1,5 @@
+using Engine.Assets.Contracts;
+using Engine.Kernel.Diagnostics;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
using Engine.Kernel.World;
@@ -8,52 +10,71 @@ using Silk.NET.OpenGL;
namespace Engine.Render;
///
-/// M1's render pipeline: one hardcoded triangle, drawn every Render stage.
-/// See M1 in docs/kernel-contract.md §8.
+/// M2's render pipeline: a textured quad, drawn every Render stage. See M2
+/// in docs/kernel-contract.md §8.
///
-/// 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.
+/// This is the actual "done when": swap TexturePath's file on disk and the
+/// quad's texture changes with no app restart — engine.assets watches the
+/// file and publishes TextureReloaded; this plugin subscribes and
+/// re-uploads to the same GL texture handle. Verified by hand against a
+/// real window and a live GL context, 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
/// 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.
+///
+/// Also found the hard way: with VSync on (the default), a session where
+/// the compositor stops handing out frame callbacks — locking the screen
+/// reproduced it directly — makes the *second* frame's SwapBuffers block
+/// forever (the first has nothing to wait on yet, so it returns fine,
+/// which is what makes this easy to miss). See VSync=false in
+/// WindowingPlugin and the SwapInterval(0) call below.
///
public sealed class RenderPlugin : IPlugin
{
+ // There's no material/asset-reference component yet (that's real
+ // content-authoring work, M3+ territory) — hardcoded the same way
+ // TriangleColor was hardcoded before textures existed at all.
+ private const string TexturePath = "assets/texture.png";
+
private const string VertexShaderSource = """
#version 330 core
layout (location = 0) in vec2 aPosition;
+ layout (location = 1) in vec2 aUv;
+ out vec2 vUv;
void main()
{
gl_Position = vec4(aPosition, 0.0, 1.0);
+ vUv = aUv;
}
""";
private const string FragmentShaderSource = """
#version 330 core
+ in vec2 vUv;
out vec4 FragColor;
- uniform vec4 uColor;
+ uniform sampler2D uTexture;
void main()
{
- FragColor = uColor;
+ FragColor = texture(uTexture, vUv);
}
""";
- 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,
+ // position uv
+ -0.6f, 0.6f, 0f, 1f,
+ -0.6f, -0.6f, 0f, 0f,
+ 0.6f, -0.6f, 1f, 0f,
+
+ -0.6f, 0.6f, 0f, 1f,
+ 0.6f, -0.6f, 1f, 0f,
+ 0.6f, 0.6f, 1f, 1f,
];
private GL? _gl;
@@ -61,16 +82,25 @@ public sealed class RenderPlugin : IPlugin
private uint _vao;
private uint _vbo;
private uint _program;
- private int _colorLocation;
+ private uint _texture;
+ private Action? _onTextureReloaded;
+ private ILogger? _log;
public unsafe void Configure(IPluginContext ctx)
{
+ _log = ctx.Log;
_window = ctx.Services.Require();
_window.Native.GLContext!.MakeCurrent();
_gl = _window.Native.CreateOpenGL();
+ // Belt-and-suspenders alongside VSync=false in WindowingPlugin's
+ // WindowOptions — SwapInterval(0) is the lower-level, harder-to-
+ // ignore way to say the same thing directly to the GL context. See
+ // the note there on why a blocked SwapBuffers is a real, already-hit
+ // failure mode here, not a hypothetical one.
+ _window.Native.GLContext.SwapInterval(0);
+
_program = LinkProgram(_gl, VertexShaderSource, FragmentShaderSource);
- _colorLocation = _gl.GetUniformLocation(_program, "uColor");
_vao = _gl.GenVertexArray();
_gl.BindVertexArray(_vao);
@@ -79,22 +109,40 @@ public sealed class RenderPlugin : IPlugin
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
_gl.BufferData(BufferTargetARB.ArrayBuffer, Vertices, BufferUsageARB.StaticDraw);
- _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), (void*)0);
+ const uint stride = 4 * sizeof(float);
+ _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(0);
+ _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
+ _gl.EnableVertexAttribArray(1);
+
_gl.BindVertexArray(0);
+ var assets = ctx.Services.Require();
+ var initial = assets.LoadTexture(TexturePath);
+ _texture = _gl.GenTexture();
+ UploadPixels(initial);
+
+ _onTextureReloaded = evt =>
+ {
+ if (Path.GetFullPath(evt.Path) == Path.GetFullPath(TexturePath))
+ UploadPixels(evt.Data);
+ };
+ ctx.Events.Subscribe(_onTextureReloaded);
+
ctx.Services.Provide(new GlScreenCapture(_gl, _window));
ctx.Schedule.Add(Stage.Render, Draw);
- ctx.Log.Info("GL context created, triangle ready");
+ ctx.Log.Info("GL context created, textured quad ready");
}
public void Shutdown(IPluginContext ctx)
{
ctx.Schedule.RemoveAllFrom("engine.render");
+ ctx.Events.RemoveAllFrom("engine.render");
ctx.Services.Revoke();
if (_gl is not null)
{
+ _gl.DeleteTexture(_texture);
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
_gl.DeleteProgram(_program);
@@ -103,6 +151,38 @@ public sealed class RenderPlugin : IPlugin
_gl = null;
_window = null;
+ _onTextureReloaded = null;
+ _log = null;
+ }
+
+ private unsafe void UploadPixels(TextureData data)
+ {
+ _gl!.BindTexture(TextureTarget.Texture2D, _texture);
+
+ fixed (byte* pixels = data.Rgba)
+ {
+ _gl.TexImage2D(
+ TextureTarget.Texture2D, level: 0, internalformat: InternalFormat.Rgba,
+ (uint)data.Width, (uint)data.Height, border: 0,
+ format: PixelFormat.Rgba, type: PixelType.UnsignedByte, pixels);
+ }
+
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)GLEnum.Nearest);
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)GLEnum.Nearest);
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)GLEnum.ClampToEdge);
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)GLEnum.ClampToEdge);
+
+ // Only checked here, not every frame in Draw() — a hot-path
+ // GetError() call would tax the one thing that runs constantly
+ // for a check that only ever fires from a handful of infrequent
+ // upload calls. Worth it here specifically: an upload that fails
+ // silently doesn't throw, it just leaves stale or undefined data
+ // bound to the texture — exactly what a blank-screen bug looks
+ // like from the outside, with nothing in the way of a stack trace
+ // to point at it.
+ var error = _gl.GetError();
+ if (error != GLEnum.NoError)
+ _log?.Warn($"GL error after texture upload: {error}");
}
private void Draw(IWorld world)
@@ -111,9 +191,10 @@ public sealed class RenderPlugin : IPlugin
_gl.Clear(ClearBufferMask.ColorBufferBit);
_gl.UseProgram(_program);
- _gl.Uniform4(_colorLocation, TriangleColor[0], TriangleColor[1], TriangleColor[2], TriangleColor[3]);
+ _gl.ActiveTexture(TextureUnit.Texture0);
+ _gl.BindTexture(TextureTarget.Texture2D, _texture);
_gl.BindVertexArray(_vao);
- _gl.DrawArrays(PrimitiveType.Triangles, 0, 3);
+ _gl.DrawArrays(PrimitiveType.Triangles, 0, 6);
_window!.Native.GLContext!.SwapBuffers();
}
diff --git a/plugins/engine.render/plugin.json b/plugins/engine.render/plugin.json
index 16af25a..68bd549 100644
--- a/plugins/engine.render/plugin.json
+++ b/plugins/engine.render/plugin.json
@@ -4,7 +4,8 @@
"contracts": "Engine.Render.Contracts.dll",
"assembly": "Engine.Render.dll",
"dependsOn": {
- "engine.windowing": "^0.1"
+ "engine.windowing": "^0.1",
+ "engine.assets": "^0.1"
},
"reloadable": true
}
diff --git a/plugins/engine.windowing/Engine.Windowing/WindowingPlugin.cs b/plugins/engine.windowing/Engine.Windowing/WindowingPlugin.cs
index 581eb30..0bb6754 100644
--- a/plugins/engine.windowing/Engine.Windowing/WindowingPlugin.cs
+++ b/plugins/engine.windowing/Engine.Windowing/WindowingPlugin.cs
@@ -20,6 +20,16 @@ public sealed class WindowingPlugin : IPlugin
{
Size = new(1280, 720),
Title = "Lingua Engine",
+
+ // VSync waits for the compositor's frame callback before a
+ // SwapBuffers call returns — found the hard way: under a
+ // nested/off-screen-ish Wayland session, that callback can
+ // simply never arrive, and the *second* frame's SwapBuffers
+ // (the first has nothing to wait on yet) blocks the whole
+ // process forever. Nothing here needs frame pacing yet — no
+ // input-driven gameplay, no animation — so there's no reason
+ // to pay for it before it's needed.
+ VSync = false,
};
_window = Window.Create(options);
diff --git a/samples/WindowDemo/assets/texture.png b/samples/WindowDemo/assets/texture.png
new file mode 100644
index 0000000..524e41a
Binary files /dev/null and b/samples/WindowDemo/assets/texture.png differ
diff --git a/samples/WindowDemo/project.json b/samples/WindowDemo/project.json
index 3ab7f40..2e298c2 100644
--- a/samples/WindowDemo/project.json
+++ b/samples/WindowDemo/project.json
@@ -2,6 +2,7 @@
"engineVersion": "^0.1",
"plugins": [
{ "id": "engine.windowing" },
+ { "id": "engine.assets" },
{ "id": "engine.render" },
{ "id": "engine.input" }
],
diff --git a/tests/Engine.Assets.Tests/Engine.Assets.Tests.csproj b/tests/Engine.Assets.Tests/Engine.Assets.Tests.csproj
new file mode 100644
index 0000000..8e95467
--- /dev/null
+++ b/tests/Engine.Assets.Tests/Engine.Assets.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Engine.Assets.Tests/PngReaderTests.cs b/tests/Engine.Assets.Tests/PngReaderTests.cs
new file mode 100644
index 0000000..d1e23b1
--- /dev/null
+++ b/tests/Engine.Assets.Tests/PngReaderTests.cs
@@ -0,0 +1,231 @@
+using System.IO.Compression;
+using System.Text;
+using Engine.Assets;
+
+namespace Engine.Assets.Tests;
+
+///
+/// Builds tiny test PNGs by hand rather than reusing Engine.Render's
+/// PngWriter — keeps this test project independent of a second plugin,
+/// and a from-scratch encoder here is also a second, independent
+/// implementation of the format to check PngReader against, not the same
+/// code testing itself.
+///
+public class PngReaderTests
+{
+ [Fact]
+ public void Read_Recovers_Exact_Pixel_Values_Through_A_Round_Trip()
+ {
+ // 2x2, four distinct colors — catches row-order and channel-order
+ // mistakes that a single flat color would hide.
+ byte[] pixels =
+ [
+ 255, 0, 0, 255, 0, 255, 0, 255, // row 0: red, green
+ 0, 0, 255, 255, 255, 255, 0, 255, // row 1: blue, yellow
+ ];
+
+ var path = WriteTestPng(2, 2, pixels, filterType: 0);
+ try
+ {
+ var (width, height, rgba) = PngReader.Read(path);
+
+ Assert.Equal(2, width);
+ Assert.Equal(2, height);
+ Assert.Equal(pixels, rgba);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Theory]
+ [InlineData((byte)0)] // None
+ [InlineData((byte)1)] // Sub
+ [InlineData((byte)2)] // Up
+ [InlineData((byte)3)] // Average
+ [InlineData((byte)4)] // Paeth
+ public void Read_Correctly_Unfilters_Every_Filter_Type(byte filterType)
+ {
+ byte[] pixels =
+ [
+ 10, 20, 30, 255, 40, 50, 60, 255,
+ 70, 80, 90, 255, 100, 110, 120, 255,
+ ];
+
+ var path = WriteTestPng(2, 2, pixels, filterType);
+ try
+ {
+ var (_, _, rgba) = PngReader.Read(path);
+ Assert.Equal(pixels, rgba);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void Read_Throws_On_A_File_That_Is_Not_A_PNG()
+ {
+ var path = Path.GetTempFileName();
+ File.WriteAllText(path, "not a png");
+
+ try
+ {
+ Assert.Throws(() => PngReader.Read(path));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void Read_Throws_On_An_Unsupported_Color_Type()
+ {
+ // Grayscale (color type 0) instead of RGBA (6) — the reader is
+ // deliberately scoped to the one subset it actually supports.
+ var path = WriteTestPng(1, 1, [128], filterType: 0, colorType: 0, bytesPerPixel: 1);
+
+ try
+ {
+ Assert.Throws(() => PngReader.Read(path));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ private static string WriteTestPng(
+ int width, int height, byte[] rgba, byte filterType, byte colorType = 6, int bytesPerPixel = 4)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.png");
+
+ using var file = File.Create(path);
+ file.Write((byte[]) [137, 80, 78, 71, 13, 10, 26, 10]);
+
+ var ihdr = new byte[13];
+ WriteUInt32BE(ihdr, 0, (uint)width);
+ WriteUInt32BE(ihdr, 4, (uint)height);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = colorType;
+ ihdr[10] = 0;
+ ihdr[11] = 0;
+ ihdr[12] = 0; // interlace: none
+ WriteChunk(file, "IHDR", ihdr);
+
+ var stride = width * bytesPerPixel;
+ using var raw = new MemoryStream();
+ for (var y = 0; y < height; y++)
+ {
+ var row = new byte[stride];
+ Array.Copy(rgba, y * stride, row, 0, stride);
+ var filtered = ApplyFilter(filterType, row, y > 0 ? Slice(rgba, (y - 1) * stride, stride) : null, bytesPerPixel);
+ raw.WriteByte(filterType);
+ raw.Write(filtered);
+ }
+
+ using var compressed = new MemoryStream();
+ using (var zlib = new ZLibStream(compressed, CompressionLevel.Optimal, leaveOpen: true))
+ zlib.Write(raw.ToArray());
+ WriteChunk(file, "IDAT", compressed.ToArray());
+
+ WriteChunk(file, "IEND", []);
+ return path;
+ }
+
+ private static byte[] Slice(byte[] source, int offset, int length)
+ {
+ var slice = new byte[length];
+ Array.Copy(source, offset, slice, 0, length);
+ return slice;
+ }
+
+ private static byte[] ApplyFilter(byte filterType, byte[] row, byte[]? previousRow, int bytesPerPixel)
+ {
+ var result = new byte[row.Length];
+
+ for (var x = 0; x < row.Length; x++)
+ {
+ int a = x >= bytesPerPixel ? row[x - bytesPerPixel] : 0;
+ int b = previousRow?[x] ?? 0;
+ int c = x >= bytesPerPixel ? previousRow?[x - bytesPerPixel] ?? 0 : 0;
+
+ result[x] = filterType switch
+ {
+ 0 => row[x],
+ 1 => (byte)(row[x] - a),
+ 2 => (byte)(row[x] - b),
+ 3 => (byte)(row[x] - (a + b) / 2),
+ 4 => (byte)(row[x] - Paeth(a, b, c)),
+ _ => throw new ArgumentOutOfRangeException(nameof(filterType)),
+ };
+ }
+
+ return result;
+ }
+
+ private static int Paeth(int a, int b, int c)
+ {
+ var p = a + b - c;
+ var pa = Math.Abs(p - a);
+ var pb = Math.Abs(p - b);
+ var pc = Math.Abs(p - c);
+
+ if (pa <= pb && pa <= pc)
+ return a;
+
+ return pb <= pc ? b : c;
+ }
+
+ private static void WriteChunk(Stream stream, string type, byte[] data)
+ {
+ var typeBytes = Encoding.ASCII.GetBytes(type);
+
+ var length = new byte[4];
+ WriteUInt32BE(length, 0, (uint)data.Length);
+ stream.Write(length);
+ stream.Write(typeBytes);
+ stream.Write(data);
+
+ var crc = new byte[4];
+ WriteUInt32BE(crc, 0, Crc32(typeBytes, data));
+ stream.Write(crc);
+ }
+
+ private static void WriteUInt32BE(byte[] 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 readonly uint[] Crc32Table = BuildCrc32Table();
+
+ 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;
+ }
+}