2 Commits
Author SHA1 Message Date
EmilandClaude Sonnet 5 cd8f221ddd Fix real bugs from independent review (docs/review-handoff.md)
A second AI session read the whole codebase in parallel (read-only, no
code changes) and left a handoff doc. Addressed the correctness findings:

- PhysicsWorld.Sync's early return compared _bodies.Count to live.Count,
  not their contents — same size, different membership (destroy one
  tracked GameObject, gain one untracked-because-no-collider one; or any
  Restore where the scene has both a Rigidbody+collider object and a
  Rigidbody-without-one) skipped cleanup entirely, leaking the native
  Box3D body forever. Fixed by checking the actual stale set. Two new
  regression tests reproduce the review's own two scenarios via a new
  Lingua_GetBodyCount native export, asserting on the native table's own
  count rather than PhysicsWorld's C#-side bookkeeping.
- TryCreateBody stored handle -1 (native shim refused: invalid world, or
  its 8192-slot body table full) as if it were real — every later
  GetBodyTransform on it silently teleported the GameObject to the
  origin with a degenerate rotation, no error anywhere. Now checked and
  warned once, same as the missing-collider case.
- PluginHost.Load didn't roll back anything when Configure threw partway
  through: Schedule.Add/Events.Subscribe registrations it already made
  stayed forever, and its ALC was never unloaded — neither loaded (no
  _loaded entry) nor cleanly unloadable. Fixed with try/catch: best-effort
  Shutdown (the only thing that knows which services this plugin
  provided), RemoveAllFrom on both Schedule and EventBus, best-effort ALC
  unload, rethrow. New fixture plugin (sandbox.failing-configure, mirrors
  sandbox.echo's own real-load pattern) registers a system against a
  shared Ping component then throws, so FailedConfigureRollbackTests can
  assert the dangling system actually stops firing — an earlier version
  tried to prove this via AssemblyLoadContext.All instead, which passed
  even against the deliberately-reverted buggy code (the ALC turned out
  to get collected either way once its only references went out of
  scope); watching the dangling system is what actually distinguishes
  rolled-back from not, confirmed by deliberately reverting the fix and
  watching this specific test fail before restoring it.
- Engine.Host's "r <id>" left a plugin unloaded on a failed reload with
  no honest indication of that, and retrying threw "not loaded" instead
  of ever reaching Load again. Added PluginHost.IsLoaded so the handler
  only calls Unload when there's something to unload, and the failure
  message now says the plugin is unloaded, not just "failed."
- AssetService.ReloadWithRetry's `when (attempt < 4)` guard meant the
  5th and final IOException fell out of the loop and propagated from a
  discarded fire-and-forget Task — no log, no event, nothing. Now logged.
- EditorState.Selected kept pointing at a GameObject Restore had already
  destroyed after ExitPlay, so Inspector/gizmo would silently keep
  editing something no longer in the world. EditorPlugin.DrawUi now
  compares IsPlaying against its own previous frame (not just reacting to
  the Stop button) so this is caught whether Play was exited via the
  button or the "stop" stdin command — the same stdin-vs-real-control gap
  already hit once earlier this session — and re-resolves the selection
  by name.
- README.md and kernel-contract.md's Event Bus/Time rows had fallen a
  milestone behind (still said "no asset system yet" and "no fixed-step
  accumulator yet" after both shipped).

Full suite: 98 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 20:57:08 +03:00
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