19 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 faabfc2cb4 M4: Linux + Windows CI build pipeline (.github/workflows/build.yml)
The literal thing nothing on this dev machine can verify: whether the
build actually runs on Windows at all. Solved by having real CI do it —
ubuntu-latest and windows-latest both build the native Box3D/miniaudio
shims from source via CMake, build and test the full .NET solution, stage
a shippable configuration (engine.windowing/assets/render/input/physics/
audio + physics-demo-game — engine.editor deliberately excluded, that's
M4's actual "done when"), and then run it headless against samples/
PhysicsDemo for 200 frames, asserting DemoBox settled at y≈1.0 in the
resulting dump. Real physics, real scene load, real plugin loading,
proven on both platforms, not just built.

Required restructuring the native Content items into per-OS ItemGroups
(native/linux-x64/ vs native/win-x64/, selected via
$([MSBuild]::IsOSPlatform(...))): linux-x64's .so is committed (built and
verified here); win-x64's .dll is never committed — nothing here can
build or run one to verify — and only ever exists as something the
Windows job produces fresh, in-place, right before `dotnet build`.

Caught one real bug dry-running this exact staging locally before
trusting it to a workflow run: PluginHost.Load calls Assembly.
LoadFromAssemblyPath, which throws on a relative path — the verification
step's `--plugins ../../plugins` failed immediately with "is not an
absolute path." Every manual verification earlier this session happened
to always pass an absolute --plugins path, which is exactly why this
never surfaced before. Fixed by resolving to an absolute path before
invoking Engine.Host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 17:52:28 +03:00
EmilandClaude Sonnet 5 2d0168104d M4: PhysicsDemo — the "one small game, end to end"
samples/PhysicsDemo ties physics, audio, input, and rendering together
through nothing but the kernel's own vocabulary — no plugin here
references another plugin's implementation, only Contracts. Press Space
to drop a box; engine.physics simulates it falling onto a static ground;
CubeRenderer (new — engine.render's QuadRenderer drew flat cards, no good
for a physics demo where a BoxCollider needs to actually look like a box)
draws it; physics-demo-game watches each spawned box's own Y velocity via
IPhysicsService and plays a bounce sound via IAudioService the moment a
real fall settles. A looping ambient track plays throughout via
AudioSource's own PlayOnAwake. project.json deliberately excludes
engine.editor — this is the shippable configuration M4's "done when"
actually asks for, not the dev one.

"Landed" isn't a Box3D contact event — the native shim never exposed one
(nothing but scalars crosses that boundary, see lingua_physics.c). Watching
velocity every frame is the honest, right-sized alternative for a demo
this size, not a shortcut around missing infrastructure.

Verified two ways. First, real Space-key input isn't simulable here (no
xdotool/ydotool under this Wayland session) — so PhysicsDemoGame.Tests
drives the actual PhysicsDemoGamePlugin.Configure/Tick through the real
Schedule with a controllable fake IEngineInput (and fake IPhysicsService/
IAudioService, since engine.physics/engine.audio's own correctness is
already covered elsewhere): spawn-on-press with edge detection, the
Rigidbody/BoxCollider/CubeRenderer combo the spawned box actually gets,
and — the case most likely to be subtly wrong — a box that never actually
falls doesn't false-positive as "landed," only one that fell past
FallingThreshold and then settled does, exactly once. All 5 passed
immediately. Second, a real windowed run: a box placed in scene.json above
the ground visibly falls and settles onto it on screen after a real
few-second wait — screenshotting by --screenshot-after-frames alone turned
out not to prove this (VSync is off, so frames race by far faster than
real physics time passes; enough elapsed frames isn't enough elapsed
seconds), an interactive run with a real sleep before the screenshot
command is what actually shows it.

Full suite: 92 tests. Remaining for M4: the Linux + Windows build pipeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 17:47:15 +03:00
EmilandClaude Sonnet 5 dcadc57061 M4: engine.audio — miniaudio, same narrow-shim shape as engine.physics
native/audio-native/lingua_audio.c wraps miniaudio (vendored at pinned
release 0.11.25 — dual public domain/MIT-0, confirmed from the license
statement in the file itself) the same way lingua_physics.c wraps Box3D:
ma_engine_config/ma_sound_config are large structs with optional
callbacks, so nothing but plain int/float/bool/UTF-8-path scalars crosses
the P/Invoke boundary, and handles are this shim's own array-index
handles into a fixed ma_sound table, not miniaudio's own pointer types.
Same DllImport-not-LibraryImport choice too, for the same reason: zero
AllowUnsafeBlocks needed anywhere in this plugin.

One real addition beyond mirroring the physics shim: Lingua_Audio_Init
takes a useNullBackend flag. miniaudio's null backend runs the exact same
load/decode/mix/loop pipeline against a device that discards its output —
real coverage of this shim's logic without depending on real audio
hardware being present (most CI runners have none) or making an
automated test run produce actual sound, which nobody expects. Real
gameplay (AudioPlugin) always requests the real backend; only
Engine.Audio.Tests asks for the null one.

engine.audio adds an AudioSource component (ClipPath/Volume/Loop/
PlayOnAwake) and AudioWorld, which — same shape as PhysicsWorld, same
reason: no destruction event exists to hook — diffs Query<AudioSource>()
against its own tracked set every Stage.Update, loading a clip the first
time it sees one, applying Volume/Loop changes only when they actually
changed, and unloading sounds whose GameObject is gone. IAudioService
exposes Play/Stop/IsPlaying for gameplay code to trigger a sound instead
of it only ever firing on PlayOnAwake.

5 new tests, all against the null backend, all passed after fixing one
real bug they caught: AudioWorld originally re-attempted loading (and
re-warned about) a missing clip on every single Sync call instead of
once — MissingClip_WarnsAndDoesNotThrow failed on the first run with 2
warnings instead of 1, fixed by tracking failed-load GameObjects the same
way PhysicsWorld already tracks missing-collider ones.

Full suite: 86 tests. Not yet verified: actual audible playback through a
real device — the null-backend tests prove this plugin's own logic, but
nobody has listened to real output yet. Worth doing deliberately, not as
a surprise mid-session — flagging rather than just doing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 17:13:21 +03:00
EmilandClaude Sonnet 5 792b626396 M4: engine.physics — Box3D, over a narrow, verified P/Invoke shim
native/physics-native/lingua_physics.c wraps Box3D at a specific pinned
commit (47d7f7c — Box3D has no 1.0 release yet, and its only tag, v0.1.0,
already diverges from this API, confirmed by diffing headers rather than
assuming). The wrapper is deliberately narrow: Box3D's own b3WorldDef/
b3BodyDef/b3ShapeDef are large structs with function pointers, and
b3BoxHull's own doc comment says it "has data hanging off the end and
cannot be directly copied" — none of that crosses the P/Invoke boundary.
Every exported Lingua_* function takes and returns only plain int32/float/
bool scalars, and handles are this shim's own array-index handles, not
Box3D's id structs. C# binds them with classic DllImport rather than the
newer LibraryImport specifically because LibraryImport's generated
marshalling needs AllowUnsafeBlocks even for an all-scalar signature like
every one of these — DllImport needs none, keeping this plugin inside the
kernel's "no unsafe in the v1 hot path" rule with no exception required.

engine.physics adds Rigidbody/BoxCollider/SphereCollider components and
PhysicsWorld, which diffs Query<Rigidbody>() against its own tracked set
every Stage.FixedUpdate (there's no destruction event to hook) to create
and destroy native bodies, steps Box3D once per invocation — Engine.Host's
accumulator decides how many times that runs per frame, not this plugin —
and writes each body's resulting transform back to GameObject.Transform.
IPhysicsService exposes ApplyLinearImpulse/Get/SetLinearVelocity for
gameplay code.

Verified twice: a standalone C smoke test against the native shim alone
(a box dropped from y=5 onto a static ground settles at y≈1.0, exactly
where the two half-heights sum to), and the full pipeline through Engine.
Host — a headless run with a real scene, --dump showing the same box
settling at y=0.9999 after physics, scene load, and Stage.FixedUpdate all
went through the real kernel. 5 new automated tests in Engine.Physics.
Tests cover the same settling behavior for both shapes, a missing-collider
warning that fires once and doesn't throw, cleanup after a GameObject is
destroyed mid-simulation, and that an applied impulse actually changes
velocity — all passed on the first run.

Physics-enabled GameObjects must be root-level for now: PhysicsWorld
writes Box3D's world-space transform straight into LocalPosition/
LocalRotation, correct only when local and world space are the same
thing. A parented rigidbody needs the same parent-WorldMatrix-inverse
handling GizmoMath.WorldToLocalPosition already does for the gizmo — real,
not-yet-done work, not silently wrong.

Full suite: 81 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 17:05:39 +03:00
EmilandClaude Sonnet 5 e0e6c1d503 M3: real 3D translate gizmo — the "полноценный 3D-пайплайн" it was chosen for
TranslateGizmo draws three axis handles at the selected GameObject's
world position by projecting real 3D points through the real camera's
View/Projection (GizmoMath.WorldToScreen) onto ImGui's foreground draw
list. Nothing OpenGL-side draws lines yet, so this isn't 3D geometry
in the GL sense — but it IS driven by the actual camera matrices, which
is what the earlier scoped-2D-vs-full-3D-pipeline choice was actually
about: a handle dragged in screen space has to map onto a real 3D axis,
and that only means something once there's a real camera to project
through. The screenshot below shows exactly that — the axes aren't
screen-perpendicular, because the camera at (0,3,6) looking at the
origin means they shouldn't be.

Dragging a handle re-projects the mouse delta onto the axis's own
screen-space direction (GizmoMath.ProjectDragOntoAxis) and writes the
result back through GizmoMath.WorldToLocalPosition, which inverts the
parent's WorldMatrix rather than writing LocalPosition directly — a
parent with non-identity scale or rotation means "move 1 world unit"
and "add 1 to LocalPosition" are different amounts, and
samples/WindowDemo's ChildQuad (parented under a (2,2,1)-scaled Quad)
is exactly that case.

Split the actual math into GizmoMath (Engine.Editor.Contracts, no GL/
ImGui/mouse dependency) so it's unit-testable without a window or a real
mouse — neither exists in a headless test run, and dragging is exactly
the kind of interaction that's easy to get subtly wrong (screen-space
ratio direction, perspective sign, parent-scale correctness) without
something to check it against beyond eyeballing a screenshot. New
Engine.Editor.Tests project, 8 tests: screen-center projection, a
behind-camera point returning null, drag-ratio math on both an
axis-aligned and a diagonal screen direction, and the parent-scale/
parent-translation inverse-transform cases. All 8 passed on the first
run — including the non-uniform-scale case, the one most likely to be
subtly wrong.

Full suite: 73 tests, all green (65 previous + 8 new).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 16:30:54 +03:00
EmilandClaude Sonnet 5 d7fcc20f5e M3: Play/Stop — Engine.Host stops ticking Stage.Update outside Play mode
IPlayModeController (Engine.Editor.Contracts) wraps IWorld.Snapshot()/
Restore() as EnterPlay/ExitPlay — the entire mechanism is that one
snapshot, per Snapshot's own doc comment. Exposed as a service, not just
something engine.editor's UI calls directly, so a future non-UI driver
(a test harness, a headless "play for N frames" CLI command) can drive
Play mode without depending on ImGui.

Engine.Host now looks up IPlayModeController once and checks IsPlaying
every frame before running Stage.Update: Edit mode still renders the
scene (so the view isn't frozen and the editor UI stays responsive) but
never ticks it, same distinction Unity draws between its Scene and Game
views. A project with no engine.editor loaded sees no behavior change —
Update runs unconditionally, same as before this existed.

Added "play"/"stop" as stdin commands alongside the existing "r" and
"screenshot", both real controls (not just test scaffolding) for driving
Play mode without a mouse — which is also how this got verified: an
interactive run sent "play", screenshotted, sent "stop", and the log
shows "Entered Play mode in 13.2ms", the real editor path exercising the
same under-100ms budget WorldSnapshotTests already proves at the kernel
level. The Lingua Editor panel's button/label flip Play/(Edit mode) to
Stop/(Playing) correctly across both screenshots.

Also fixes a real layout bug hit while verifying this: SetNextWindowPos
with ImGuiCond.FirstUseEver only applies with no prior imgui.ini entry
for that window title, and this project had already accumulated one from
earlier runs (all three panels stacked exactly on top of each other) —
gave each panel its own default position and .gitignore'd imgui.ini,
which is per-machine session state, not source.

Full suite still green: 65 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 16:26:23 +03:00
EmilandClaude Sonnet 5 20e0f8c53f M3: reflection-based Inspector — edit any component's fields live
InspectorPanel shows EditorState.Selected's Transform (Position/Scale as
draggable Vector3 widgets, Rotation shown read-only as a quaternion — a
real rotate control needs Euler conversion or a gizmo, out of scope for
just the Inspector) and, below it, every attached Component's public
fields via reflection. No per-component-type drawer code exists anywhere:
int/float/bool/string/Vector3 fields get a live-editable widget, anything
else falls back to a read-only ToString(), and a brand new component type
in any plugin gets an Inspector for free the moment it's attached — that
genericity is the entire reason to do this by reflection instead of a
registry.

Field edits write straight back through FieldInfo.SetValue, same gap
GameObject.AddComponent's doc comment already names: components are plain
fields with nothing to intercept a direct mutation, so this needs no
SystemAccessScope declaration any more than a hand-written `component.
Value = 5` would.

EditorPlugin now defaults EditorState.Selected to the first root once,
if nothing's been clicked yet — an empty Inspector on every fresh launch
had nothing useful to show. Verified by screenshot: Inspector displays
"Quad"'s real Position (0,0,0) and Scale (2,2,1), matching scene.json
exactly, plus a QuadRenderer header correctly showing "(no fields)" since
it has none yet.

Full suite still green: 65 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 16:21:26 +03:00
EmilandClaude Sonnet 5 bccb8c3ba7 M3: Hierarchy panel — click a GameObject to select it
HierarchyPanel walks IWorld.Roots/GameObject.Children recursively as
ImGui tree nodes, clicking one sets EditorState.Selected — the shared
selection the not-yet-built Inspector panel will read from the same
instance. PushID(go.GetHashCode()) scopes each node's ID by object
identity rather than name, since nothing stops two sibling GameObjects
sharing a Name and ImGui's default label-based IDs would otherwise merge
their open/selected state.

Also gives samples/WindowDemo/scene.json a child GameObject (offset,
half-scale, parented under Quad) — the existing scene only had one root,
nothing to show a tree with. Verified by screenshot: both quads render at
their correct composed WorldMatrix (the child visibly smaller and offset,
confirming parent/child composition is still correct through this
change), and Hierarchy lists "Quad" as a collapsible node.

Full suite still green: 65 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 16:19:36 +03:00
EmilandClaude Sonnet 5 4c67c2c905 M3: editor shell boots — ImGui overlay drawn over the live scene
Introduces engine.editor, a new plugin that renders an ImGui panel on top
of whatever the scene is drawing, using Silk.NET.OpenGL.Extensions.ImGui's
ImGuiController against the same GL context and window engine.render
already owns.

Getting the overlay to actually show up in the same frame (not delayed by
one, which is what happens if UI draws after SwapBuffers) required
splitting engine.render's Draw system: SwapBuffers moves out into its own
Present system on a new Stage.Present, run by Engine.Host after every
Stage.Render system — this plugin's Draw and, now, engine.editor's ImGui
pass — has drawn into the same back buffer. Stage.Present is the second
addition to the frame stage set (after FixedUpdate was scoped out for M4);
docs/kernel-contract.md already committed to the set being fixed and
kernel-defined, not plugin-extensible, and to only adding a stage when
there's something real to run in it — SwapBuffers already needed
somewhere to live once a second Render-stage system existed to race it.

Also extends IEngineInput with a Native IInputContext property, the same
escape hatch IEngineWindow.Native already is — ImGuiController's
constructor needs the raw Silk.NET input context directly.

Verified with a real windowed run: --screenshot after 5 frames shows the
"Lingua" ImGui panel (FPS/frame counters) correctly composited over the
still-correct 3D checkerboard quad from the M3 camera pipeline, in the
same frame. Full suite still green: 65 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 16:17:51 +03:00
EmilandClaude Sonnet 5 0c2547a804 Add real 3D rendering pipeline (camera, view/projection, QuadRenderer)
Replaces M2's single hardcoded NDC-space triangle with a real perspective
pipeline: ICameraService/CameraService (fixed camera at (0,3,6) looking at
the origin), a QuadRenderer marker component, and a Draw() that iterates
world.Query<QuadRenderer>() to draw every such GameObject at its own
WorldMatrix. Needed as the foundation for M3's gizmos, which have to map
a screen-space drag onto a real 3D axis — a 2D quad and no camera can't
support that.

Two real bugs found and fixed empirically, not designed in from the start:

- SystemAccessScope correctly threw on Draw() querying QuadRenderer without
  declaring Reads<QuadRenderer>() on its Schedule.Add registration — the
  safety net catching a real omission, exactly as designed.
- UniformMatrix4 needed transpose:true, not false. System.Numerics.Matrix4x4
  is row-major in memory; glUniformMatrix4fv with transpose=GL_FALSE reads
  that layout as column-major instead. With transpose=false the quad simply
  didn't render — no error, no crash, just a blank screen. Confirmed via a
  correctly perspective-foreshortened, texture-mapped quad after the fix.

Also adds samples/WindowDemo/scene.json (a single Quad GameObject with a
QuadRenderer) so the new pipeline has something real to draw, loaded via
the existing --scene flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 16:10:35 +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
EmilandClaude Sonnet 5 3c60e2cd45 Close the kernel's four open questions
Real decisions with real code behind them, not just answers written
into the doc:

- Time and Log: both stay in the kernel, both on IPluginContext.
  Log was already built this way by accident; Time (DeltaTime,
  ElapsedTime, FrameCount) ships now, split into ITime (plugin-facing,
  read-only) and Time (host-facing, an internal Tick(deltaTime) only
  Engine.Host calls) — the same split Schedule/ISchedule already
  established. The fixed-step accumulator from the original kernel
  scope is explicitly NOT included: nothing exists to test it against
  yet (no physics), so building it now would be exactly the kind of
  untested speculative machinery this project has avoided everywhere
  else. It arrives with M4, alongside the Stage.FixedUpdate it would
  drive — a "FixedUpdate" stage with no real fixed-timestep semantics
  behind it would be actively misleading, not just incomplete.

- Event Bus: real Publish/Subscribe/RemoveAllFrom, not events-as-
  World-entities (a worse fit for GameObject's persistent identity
  than for a disposable-entity pure ECS). Given a real consumer
  immediately rather than shipped as an unused API: PluginHost now
  publishes PluginLoaded/PluginUnloaded, and sandbox.echo subscribes
  to PluginLoaded for real, cleaning up in Shutdown() via
  Events.RemoveAllFrom — mirroring exactly how Schedule.RemoveAllFrom
  was proven. The 200-cycle leak test in AlcUnloadTests now exercises
  this cleanup path too, not just Schedule's; still green, stable
  across repeated runs. GameWorld does NOT auto-publish on every
  GameObject/Component change — a publish on every structural change
  would tax the hot path for listeners that usually don't exist.

- Frame stages: fixed, kernel-defined, not plugin-extensible — a
  stage is part of the shared vocabulary the host's loop and every
  plugin rely on. Set stays {Update, Render} until FixedUpdate earns
  its place alongside the accumulator in M4.

- Data-oriented fast path: left open on purpose, but with a trigger
  condition instead of a deadline — revisit when a concrete system
  (particles, the standing example) needs tens of thousands of
  GameObjects updated per frame AND profiling, not intuition, shows
  GameObject/Component overhead is the actual bottleneck.

PluginHost's constructor changed shape: takes EventBus (concrete, not
IEventBus — it needs RegisterPlugin, same reason it already took
Schedule instead of ISchedule) and ITime. NullEventBus is gone;
every call site now constructs a real EventBus. Engine.Host advances
Time each frame — real wall-clock delta in --windowed (via the
window's own Native.Time), a fixed nominal 1/60s in --headless, which
has no wall clock to measure and needs to stay deterministic anyway.

48 tests total now (40 in Engine.Kernel.Tests, 8 in
Engine.ConformanceHarness), all green on a clean build. Verified by
hand too: headless run against sandbox.echo now logs "[sandbox.echo]
observed load of 'sandbox.echo'" — the EventBus subscription actually
firing, not just compiling.

docs/kernel-contract.md's open-questions footer rewritten to record
each decision and why, plus the kernel scope table (§2) and the
IPluginContext listing (§3) updated to match what's actually built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 04:57:38 +03:00
EmilandClaude Sonnet 5 f6d9ea896f Close M1: real triangle, engine.input, screenshot-to-file capture
All three plugins M1 called for now exist over Silk.NET, and the
milestone's actual claim is proven against a live GL context, not
just argued: edit a plugin's code, rebuild just it, reload it while a
real window stays open, see the change with no app restart. Verified
directly this time — two PNGs of the same running window, before and
after a live reload (orange triangle -> green, same process, same
window, same GL context throughout) — not by analogy to the earlier
clear-color version.

engine.render, upgraded from clear-color to real geometry:
- Vertex/fragment shaders compiled and linked at Configure() time,
  with real error checking (GetShader/GetProgram *Status + InfoLog on
  failure) rather than trusting hand-typed GLSL to just work.
- VAO/VBO for a hardcoded triangle; Shutdown() deletes all three GL
  objects rather than leaking them across reloads.
- unsafe confined to Configure() (VertexAttribPointer takes a raw
  offset pointer) via a narrow, documented override of
  Directory.Build.props' default — native graphics interop, not the
  kernel data structures docs/kernel-contract.md §7 was written
  against.

engine.input: publishes IEngineInput (keyboard state) via Silk.NET.Input.
Reuses Silk.NET's own Key enum rather than inventing one, same call as
IEngineWindow.Native. Loads and constructs cleanly against a real
window; nothing reacts to it yet since there's no gameplay code to.

IScreenCapture (new, engine.render): reads the frame back via
ReadPixels and writes a PNG. No SixLabors.ImageSharp — checked its
license first and it isn't MIT/Apache (revenue-gated), which would
have been a real surprise for downstream users of an MIT engine.
PngWriter is a from-scratch encoder instead: ZLibStream (BCL, .NET 6+)
for the one genuinely hard part, a correctly zlib-wrapped DEFLATE
stream; chunk framing and CRC32 are small enough to get right and to
verify by actually decoding files this wrote (done repeatedly, by
hand, across this session).

Engine.Host: "screenshot <path>" joins "r <plugin-id>" as a live
stdin command, plus a non-interactive --screenshot/--screenshot-after-
frames pair that captures once and exits — for scripts and agents that
can't easily hold a pipe open into a long-running process.

Real bug found and fixed in PluginHost, not specific to any one
environment: loading a Contracts assembly into the Default ALC never
set up resolution for ITS OWN dependencies. engine.windowing's and
engine.render's Contracts both need Silk.NET packages and loaded fine
anyway, by accident — Engine.Host references those two Contracts
projects directly (to drive the windowed loop and screenshot capture),
so their dependencies were already sitting in Engine.Host's own output
directory. engine.input's Contracts has no such lucky coincidence and
failed with a real FileNotFoundException. Fixed by hooking
AssemblyLoadContext.Default.Resolving with an AssemblyDependencyResolver
per loaded Contracts path — mirroring what PluginLoadContext already
does for collectible ALCs, applied to the one path that never had it.
Hooked once per process via a static list/flag, not per PluginHost
instance, specifically to avoid a PluginHost instance becoming
unreclaimable through its own event subscription.

Not covered by automated tests, deliberately, same reasoning as the
windowing/render pass: opening a real window and reading back a real
framebuffer both need a real display. Verified by hand instead,
documented above and in commit history rather than asserted.

README status: M1 done, not "in progress."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 04:42:54 +03:00
EmilandClaude Sonnet 5 c5d3807a0f M1 (in progress): engine.windowing + engine.render, hot-reload proven live
The first plugins with a real external dependency (Silk.NET) and the
first that need a display. Both built, both verified by hand against
a real window and a live GL context — not just "compiles."

engine.windowing:
- IEngineWindow, not IWindow — Silk.NET's own windowing type already
  owns that name; same lesson as the kernel's World -> GameWorld
  rename, applied proactively this time instead of hitting the build
  error first. docs/kernel-contract.md's §3 illustrative example
  updated to match.
- Exposes the real Silk.NET IWindow directly (IEngineWindow.Native)
  rather than re-wrapping it — GL context creation and event pumping
  both need it, and hiding it buys nothing yet.

engine.render:
- Minimal: glClear + SwapBuffers against a hardcoded color, no mesh.
  Enough to prove M1's actual claim, which has nothing to do with
  triangles specifically: edit a plugin, rebuild just it, reload it
  while a real window stays open, see the change with no app restart.
- No Contracts assembly — PluginManifest.Contracts is now nullable
  rather than forcing an empty assembly into existence just to satisfy
  the schema; PluginHost.Load skips the Default-ALC step when absent.

Engine.Host:
- --windowed alongside --headless: pumps window events plus
  Stage.Update/Stage.Render each frame instead of a bounded --frames
  loop. References Engine.Windowing.Contracts directly (never the
  implementation) to know how to drive that loop — same "Contracts
  are safe to share" pattern already proven for Sandbox.Echo.Contracts.
- New: typing "r <plugin-id>" + Enter reloads that plugin live. Not a
  file watcher (still not built), but real Unload+Load through the
  same PluginHost path, against a running window — this is what
  actually exercised the hot-reload claim below.
- IServiceRegistry gained TryRequire<T> so Engine.Host can ask "is a
  window available" without treating its absence as an error; a
  plugin's own Configure()/Shutdown() should keep using Require().

Verified end to end by hand: opened the window, watched it render its
hardcoded color, edited RenderPlugin.cs's ClearColor, rebuilt only
that project, typed "r engine.render" into the running process, and
watched the color change with the same window and GL context still
alive. Also found and documented a real platform gotcha along the
way: on Wayland (unlike X11), a window with no committed buffer isn't
shown at all, not even as a black rectangle — engine.windowing alone
produces an invisible window; engine.render's first Clear+SwapBuffers
is what actually makes it appear. Noted directly on RenderPlugin.

Not covered by automated tests, deliberately: opening a real window
needs a real display, which isn't safe to assume of every environment
this runs in. ServiceRegistry.TryRequire<T> and the null-Contracts
path in PluginHost are unit-tested; the window/render/reload flow is
described here and was checked by hand instead.

README status updated: M1 in progress, not done — engine.input and an
actual drawn triangle (vs. a clear color) are still open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 04:19:32 +03:00
EmilandClaude Sonnet 5 246b969744 Close M0: headless CLI, JSON world dump, project-level plugin loading
The last piece of the agent loop from docs/kernel-contract.md §7:

- WorldDumper serializes a World to JSON — every GameObject, its
  transform, its components (arbitrary plugin-defined classes, so this
  leans on System.Text.Json's own reflection with IncludeFields=true,
  since components are public fields, not properties). Components are
  a list of {type, data}, not a dictionary keyed by type name:
  AddComponent<T>() doesn't enforce uniqueness, so a GameObject can
  carry two components of the same type, and a dictionary would throw
  on exactly that case — tested directly.
- PluginHost.LoadProject reads a project.json and resolves each
  referenced plugin id against engine + project-local search paths,
  finally putting ProjectManifest/PluginReference to use — they'd sat
  unused since the very first scaffold commit.
- Engine.Host is a real CLI now: `engine run --headless --plugins <dir>
  --project <project.json> --frames <n> [--dump <path>]`. --scene and
  --assert are explicitly rejected with a message pointing at why
  (no scene format yet — that's M2; no query DSL was ever actually
  specified for --assert, and jq over a plain JSON dump already covers
  that need), rather than silently ignored or generically rejected.
  Same for `engine diag why-pinned`: nothing has ever failed to unload
  in testing, so there's nothing to build that against yet.
- EchoPlugin now seeds one Ping-bearing GameObject in Configure() —
  sandbox.echo is documented as a test fixture, not a real subsystem,
  and there's no scene format yet to seed content any other way.

Verified by hand, not just by unit tests, since Program.cs itself
isn't covered by any: assembled a real flat plugin directory
(plugin.json + both built DLLs) and actually ran the CLI against
sandbox.echo end to end — 3 frames in, Ping.Count came back 3 in the
dump. Also checked every "not implemented" path (--scene, `diag`,
missing required args) prints its intended message rather than a
generic error.

32 tests total now (26 in Engine.Kernel.Tests, 6 in
Engine.ConformanceHarness), all green on a clean build.

README's Status section updated — M0 was the whole reason this
project exists (fast iteration without Unity's domain reload), and
that claim is no longer just architecture on paper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 01:07:36 +03:00
EmilandClaude Sonnet 5 17f66c4de5 Implement Scheduler: stage execution, conflict batching, debug enforcement
Schedule gains real execution on top of the registration bookkeeping
from the PluginHost pass: RunStage(stage, world) runs every system
registered for a stage, and Reads<>/Writes<>() declarations are
enforced live via SystemAccessScope — a system touching a component it
didn't declare throws immediately, with a message naming the
violation, from GameWorld.Query<T>() and GameObject.GetComponent<T>()/
AddComponent<T>()/RemoveComponent<T>(). Outside of a running system
(editor code, tests, scene construction) nothing is enforced.

Scope cut made deliberately, not by accident: systems are grouped into
conflict-free batches by declared access (ComputeBatches, tested
directly), but batches run sequentially rather than on real threads.
Actually parallelizing them needs GameWorld's structural changes
(Create/Destroy/AddComponent/RemoveComponent) deferred to a command
buffer first — without that, two systems with disjoint *declared*
types can still race on shared storage, since AddComponent<T>() on a
GameObject mutates that object's own component list regardless of T.
Building real concurrency on top of a known thread-safety hole would
be worse than not building it yet. Noted as a TODO on RunStage.

Two real bugs found and fixed while wiring this up, not designed in
from the start:

- ISchedule.Add took `Delegate`, and a lambda passed there doesn't
  reliably compile down to `Action<IWorld>` at runtime — the
  compiler's natural-type inference for lambdas (as opposed to method
  groups, which do work this way) can synthesize a different, private
  delegate type instead, so `is Action<IWorld>` silently failed for
  every lambda-registered system. Changed Add's parameter type to
  Action<IWorld> directly, which sidesteps the inference question
  entirely — found by ScheduleTests actually using lambdas, which
  EchoPlugin's method-group-based Tick had been masking.
- AlcUnloadTests started failing intermittently ("ALC survived unload
  cycle 3") once PluginSystemTests existed alongside it — xUnit
  parallelizes across test classes by default, and ALC-unload tests
  are sensitive to any concurrent activity in the process. Added
  [CollectionBehavior(DisableTestParallelization = true)] to the
  harness assembly; stable across 5+ repeated runs since.

EchoPlugin.Tick is no longer a stub — it increments every Ping.Count
in World, which two new integration tests in
Engine.ConformanceHarness/PluginSystemTests.cs exercise end to end: a
plugin loaded from a real collectible ALC registers a system, Schedule
actually invokes that cross-ALC delegate, and it correctly mutates a
component owned by the Default-ALC World. This only works because
PluginLoadContext resolves Sandbox.Echo.Contracts to the copy this
test project references directly, rather than loading a second,
type-incompatible one — the harness's new normal ProjectReference to
Sandbox.Echo.Contracts.csproj makes that a live assertion, not just an
implementation detail no test would notice breaking.

27 tests total now (21 in Engine.Kernel.Tests, 6 in
Engine.ConformanceHarness), all green on a clean build, harness
verified stable across repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 00:59:02 +03:00
EmilandClaude Sonnet 5 978f34727f Implement PluginHost: two-ALC plugin loading, unloading verified live
The centerpiece of the whole "no domain reload" claim, now proven
empirically rather than argued on paper: 200 load/unload cycles against
a real plugin (sandbox.echo), each one checked with a WeakReference
that the collectible ALC actually collected — docs/kernel-contract.md
§4's leak test, previously Skip-marked since the very first scaffold,
now runs and passes (stable across repeated runs).

New pieces, minimal by design:

- PluginLoadContext: the collectible ALC a plugin's implementation
  loads into. Load() defers to whatever's already in the Default ALC
  (Engine.Kernel, the plugin's own Contracts assembly) before
  consulting AssemblyDependencyResolver for genuinely private
  dependencies — the standard .NET plugin pattern, needed so component
  types stay identical across the plugin boundary instead of loading
  as two distinct, incompatible copies.
- PluginHost: reads plugin.json, loads Contracts into the Default ALC
  (once — verified directly, not just inferred from the leak test),
  loads the implementation into a fresh PluginLoadContext, finds the
  IPlugin type via reflection, calls Configure(). Unload() calls
  Shutdown() first, then .Unload()s the ALC and hands back a
  WeakReference for the caller to check.
- Schedule, ServiceRegistry, NullEventBus, ConsoleLogger: minimal real
  implementations of the remaining IPluginContext pieces — no stage
  execution or parallelism in Schedule yet, that's separate Scheduler
  work. Schedule.RemoveAllFrom is the one piece that has to be
  correct now, not later: it's what lets a plugin's Shutdown() actually
  drop the delegate reference into its own collectible ALC, which is
  exactly what the leak test is checking end to end.

Explicitly out of scope for this pass: resolving a project's or
plugin's dependsOn graph to order loading across multiple plugins.
Nothing to test that against yet — sandbox.echo is deliberately the
only, dependency-free fixture. Noted as a TODO on PluginHost rather
than built speculatively.

Sandbox.Echo.csproj gets <EnableDynamicLoading>true</EnableDynamicLoading>
(future plugins with real dependencies will need the deps.json this
generates). Engine.ConformanceHarness.csproj now copies plugin.json and
both built DLLs into one flat directory under its own output, matching
the layout PluginHost.Load(directory) expects — via $(Configuration)/
$(TargetFramework)-aware CopyToOutputDirectory items, correct in
Release too, not just Debug.

17 tests total now (13 in Engine.Kernel.Tests, 4 in
Engine.ConformanceHarness), all passing on a clean build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 00:47:23 +03:00
EmilandClaude Sonnet 5 1459657408 Scaffold the .sln and M0 project structure
Buildable skeleton matching docs/kernel-contract.md:

- src/Engine.Kernel — the frozen kernel. Interfaces and data types only
  (World, Component, GameObject, Transform, ISchedule, IServiceRegistry,
  IEventBus, ILogger, IPlugin/IPluginContext, plugin.json and project.json
  manifest models). No PluginHost/Scheduler/World implementation yet —
  that's M0's actual work, not scaffolding.
- src/Engine.Host — the CLI runtime entry point, placeholder for now.
- plugins/sandbox.echo — a minimal two-assembly plugin (Contracts in
  Default ALC, implementation in collectible ALC) that exists only to
  exercise the reload loop end to end once PluginHost exists.
- tests/Engine.Kernel.Tests, tests/Engine.ConformanceHarness — wired up
  with one Skip-marked placeholder test each, naming what M0 needs to
  make them real (including the 200-cycle ALC leak test from §4). The
  harness references the sandbox plugin with
  ReferenceOutputAssembly="false" so it loads it dynamically by path
  instead of linking its types into its own Default ALC.
- samples/EmptyProject — a starter project.json.
- Directory.Build.props centralizes shared settings across every
  project, including AllowUnsafeBlocks=false to enforce the §7 rule at
  the build level rather than by convention.

Solution builds clean and `dotnet test` runs both placeholders as
Skipped (not failing) — confirms the wiring, not the engine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 00:26:08 +03:00