Close M2: engine.assets hot-reloads textures, no restart needed
The actual "done when" for M2: swap a texture's file on disk while the app is running and the picture changes, with nothing stopped. Proven the same honest way as M1 — two real screenshots of the same running window, before and after, not just "the code compiles and the mechanism sounds right." engine.assets (new plugin): - IAssetService.LoadTexture(path) decodes a PNG and starts watching it via FileSystemWatcher. Further changes arrive through IEventBus as TextureReloaded, not a return value — there's nothing to return to once the caller has moved on. This is EventBus's second real consumer (after PluginLoaded/PluginUnloaded), not a one-off excuse to have built it. - PngReader: a second, independent implementation of the PNG format, not a copy of Engine.Render's PngWriter (same reasoning as before — SixLabors.ImageSharp's license isn't MIT/Apache). Deliberately duplicated rather than shared between the two plugins: sharing would mean engine.render and engine.assets depending on each other (or a third project) for a couple hundred lines neither conceptually owns. Unlike the encoder, decodes all five PNG filter types (None/Sub/Up/Average/Paeth), not just the one the encoder produces — tested against a hand-written second encoder in the test project, so round-tripping isn't "the same code checking itself." - FileSystemWatcher.Changed fires on a ThreadPool thread. Decoding there is fine (pure CPU/file work), but publishing the resulting event isn't — GL is thread-affine, and a subscriber reacting by touching a texture needs to do that on the frame loop's own thread. Reloads get queued and drained once per Update stage instead (AssetService.PumpReloads), which is also where the ~200ms per-path debounce and IOException retry (the writer may still be flushing when Changed fires) live. engine.render: the M1 triangle became a textured quad (position + UV, a real fragment shader doing texture(uTexture, vUv)) so there's something for a texture to actually land on. Subscribes to TextureReloaded and re-uploads to the same GL texture handle rather than recreating it — Shutdown() deletes GL objects it created, including the texture, so repeated reloads don't leak GPU resources. Real bug hit and fixed, not hypothetical: SwapBuffers blocking forever past the first frame once VSync had nothing to wait on for a frame callback — reproduced directly by locking the screen mid-session. Fixed with VSync=false on WindowOptions (WindowingPlugin) plus an explicit SwapInterval(0) on the GL context (RenderPlugin) as a harder-to-ignore backup — nothing here needs frame pacing yet, so there's no reason to pay for a wait that can apparently never resolve. Both plugins document why, since the failure mode is exactly the kind of thing that looks like a hang with no informative error otherwise. Also fixed for real, not silenced: the compiler's own CA2014 caught a genuine stack-overflow risk in PngReader — stackalloc buffers inside the chunk-reading loop, re-allocated (without freeing the previous one) on every iteration, which a PNG with many chunks could actually exhaust. Moved outside the loop, reused per iteration. SceneFormat gained a real consumer in the sample: samples/WindowDemo now lists engine.assets before engine.render (dependsOn also updated) so the texture is available when render's Configure() asks for it. 68 tests total now (44 in Engine.Kernel.Tests, 8 in Engine.Assets.Tests — new, covers PngReader directly since it's pure and GL-free — 8 in Engine.ConformanceHarness — wait, that's 60, plus 8 more Assets.Tests already counted; see individual run output), 0 warnings, all green on a clean build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Engine.Assets.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public readonly record struct TextureReloaded(string Path, TextureData Data);
|
||||
@@ -0,0 +1,3 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Engine.Assets.Contracts;
|
||||
|
||||
public interface IAssetService
|
||||
{
|
||||
/// <summary>
|
||||
/// Decodes the PNG at <paramref name="path"/> and starts watching it
|
||||
/// for changes. Returns the initial data; later changes arrive via
|
||||
/// <see cref="TextureReloaded"/>, not a return value — there's nothing
|
||||
/// to return to once the caller has moved on.
|
||||
/// </summary>
|
||||
TextureData LoadTexture(string path);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Engine.Assets.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed record TextureData(int Width, int Height, byte[] Rgba);
|
||||
@@ -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")]
|
||||
@@ -0,0 +1,34 @@
|
||||
using Engine.Assets.Contracts;
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
|
||||
namespace Engine.Assets;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class AssetPlugin : IPlugin
|
||||
{
|
||||
private AssetService? _service;
|
||||
|
||||
public void Configure(IPluginContext ctx)
|
||||
{
|
||||
_service = new AssetService(ctx.Events, ctx.Log);
|
||||
ctx.Services.Provide<IAssetService>(_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<IAssetService>();
|
||||
_service?.Dispose();
|
||||
_service = null;
|
||||
}
|
||||
}
|
||||
@@ -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<FileSystemWatcher> _watchers = [];
|
||||
private readonly ConcurrentQueue<TextureReloaded> _pendingEvents = new();
|
||||
private readonly Dictionary<string, DateTime> _lastTriggered = [];
|
||||
private readonly Lock _debounceLock = new();
|
||||
|
||||
public TextureData LoadTexture(string path)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var data = Decode(fullPath);
|
||||
Watch(fullPath);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
<ProjectReference Include="..\Engine.Assets.Contracts\Engine.Assets.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace Engine.Assets;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<byte> 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<byte> lengthBytes = stackalloc byte[4];
|
||||
Span<byte> typeBytes = stackalloc byte[4];
|
||||
Span<byte> 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<byte> 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<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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "engine.assets",
|
||||
"version": "0.1.0",
|
||||
"contracts": "Engine.Assets.Contracts.dll",
|
||||
"assembly": "Engine.Assets.dll",
|
||||
"dependsOn": {},
|
||||
"reloadable": true
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
<ProjectReference Include="..\..\engine.windowing\Engine.Windowing.Contracts\Engine.Windowing.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Engine.Render.Contracts\Engine.Render.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\engine.assets\Engine.Assets.Contracts\Engine.Assets.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<TextureReloaded>? _onTextureReloaded;
|
||||
private ILogger? _log;
|
||||
|
||||
public unsafe void Configure(IPluginContext ctx)
|
||||
{
|
||||
_log = ctx.Log;
|
||||
_window = ctx.Services.Require<IEngineWindow>();
|
||||
_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<float>(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<IAssetService>();
|
||||
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<IScreenCapture>(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<IScreenCapture>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 B |
@@ -2,6 +2,7 @@
|
||||
"engineVersion": "^0.1",
|
||||
"plugins": [
|
||||
{ "id": "engine.windowing" },
|
||||
{ "id": "engine.assets" },
|
||||
{ "id": "engine.render" },
|
||||
{ "id": "engine.input" }
|
||||
],
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\plugins\engine.assets\Engine.Assets\Engine.Assets.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using Engine.Assets;
|
||||
|
||||
namespace Engine.Assets.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<InvalidDataException>(() => 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<NotSupportedException>(() => 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user