Files
lingua-engine/plugins/engine.assets/Engine.Assets/AssetService.cs
T
EmilandClaude Sonnet 5 95d69efcdc 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
2026-09-02 05:28:15 +03:00

107 lines
3.5 KiB
C#

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();
}
}