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:
Emil
2026-09-02 05:28:15 +03:00
co-authored by Claude Sonnet 5
parent 45daa6e114
commit 95d69efcdc
20 changed files with 826 additions and 22 deletions
+22
View File
@@ -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.