.github/workflows/build.yml just ran clean end to end on both
ubuntu-latest and windows-latest (build, full test suite, native shim
compilation, headless physics verification) after fixing the two real
cross-platform bugs its first run caught. Updating README/kernel-contract
to say so instead of "pending."
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
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
docs/kernel-contract.md §5 described a design sketch — a fictional
SystemGroup.Play/Edit split, a "field-by-field clone" — written before
Play mode existed. Replaced it with what engine.editor actually does:
IWorld.Snapshot()/Restore() wrapped by IPlayModeController, and
Engine.Host gating Stage.Update on IsPlaying rather than the scheduler
knowing anything about system groups at all.
Also updates the "Frame stages" resolution to record Stage.Present (added
this milestone, not speculative) and the M3 row in the build-order table
to reflect everything actually built: the editor shell, reflection-based
Hierarchy/Inspector, Play/Stop, and the real camera-driven translate
gizmo. README.md's Status section gets the same M3 writeup M0-M2 already
had, in the same voice.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
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
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
erincatto/box3d: pure C (no C++), MIT-licensed, ID-based handles —
verified via gh api rather than assumed, since this shapes the
engine.physics P/Invoke binding directly. Same "buy, don't build" call
already made for rendering, extended consistently in the scope risk
row and M4.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
First real piece of M0 rather than scaffolding. GameWorld : IWorld owns
GameObject creation/destruction, a type-indexed Dictionary<Type,
HashSet<GameObject>> backing Query<T>(), and hierarchy bookkeeping
(roots list, parent/children, cycle rejection on SetParent).
Two corrections to the design doc found while implementing:
- WorldMatrix can't be cached on Transform as described — Transform is
a plain struct with no reference to the hierarchy it would need to
compose against. Moved to GameObject.WorldMatrix, computed from the
parent chain on read; docs/kernel-contract.md and the §3 example
updated (go.Transform.WorldMatrix -> go.WorldMatrix).
- The concrete World class collided with its own containing namespace
(Engine.Kernel.World.World), which makes the bare name ambiguous for
every consumer. Renamed to GameWorld; IWorld and the World folder/
namespace are unaffected, and the doc never named the concrete class
either way, so nothing there needed to change.
Also fixed a real correctness bug caught while writing World.Destroy:
a GameObject can carry more than one component of the same type, so
removing one from the type index has to check whether any others of
that type remain before dropping the GameObject from the index set —
tested directly (Query_Still_Finds_A_GameObject_With_A_Duplicate_
Component_After_One_Removal).
12 tests in Engine.Kernel.Tests cover creation, hierarchy (including
cycle rejection), component add/remove/query, destroy cascading
through a subtree, and WorldMatrix composition (verified against a
worked-through parent+child translation, not just asserted).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
- Restore scheduler parallelism (dropped by accident in the ECS ->
GameObject/Component rewrite; nothing about the object model
actually prevents parallel execution of systems with disjoint
declared access), plus the structural-change queuing that makes
that safe.
- Rename leftover ECS-era `Entity` references to `GameObject`.
- Add per-project plugin configuration (project.json): the doc had no
mechanism backing the "configurable per project" requirement, even
though the README already claimed it.
- State the indie/small-team scope explicitly — it's the unstated
premise behind several tradeoffs already in the doc (GC, GameObject
over ECS, no custom RHI).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
Match the more familiar Unity-style object model instead of a
struct-of-arrays ECS: GameObject hierarchy with type-indexed component
queries, Transform inlined as the one performance-motivated exception.
Data/behavior stay split the same way ECS required it, so hot-reload
safety is unaffected.
Also fixes an inconsistency in the previous reload sequence, which
described migrating live component data on a contract change even
though the Default ALC hosting contracts never unloads. Contract
changes now explicitly require an editor restart instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
Lay out the design that everything else builds on: a small frozen
kernel plugins treat as a shared language, the plugin contract, the
two-assembly hot-reload model, and the build order through M4.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N