faabfc2cb4eca95ced73c4e94731e91567098337
17
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
88e0afa46b |
Fix: exiting Play mode from the editor's own Stop button crashed
Real bug, caught by hand (not by any automated test): clicking Stop in the Lingua Editor panel threw InvalidOperationException — "A system structurally changed 'QuadRenderer' without declaring Writes<QuadRenderer>()". Root cause: GameWorld.Restore rebuilds the world via GameObject. AddComponent, which SystemAccessScope checks. That's fine when Restore is called from unconstrained code — every existing WorldSnapshotTests test calls it directly, outside any system, which is exactly why none of them caught this. But PlayModeController.ExitPlay is called from inside EditorPlugin's own Stage.Render system (the button click handler runs as part of DrawUi), which correctly declares no Reads/Writes at all — it has no compile-time knowledge of QuadRenderer or any other game's component types. So the ambient SystemAccessScope was still active when Restore tried to rebuild them. SystemAccessScope's own doc comment already said "editor code... [is] unconstrained by design" — true only when the call happened to originate outside a system's scope, not actually true in general. Fixed with SystemAccessScope.Suspend(), which GameWorld.Restore now wraps its entire rebuild in: Restore is a bulk, whole-world reset regardless of who calls it, the same category of operation Destroy already is (which never went through the checked RemoveComponent path to begin with). New regression test reproduces the exact shape: a system with no declared access calling Restore, run through the real Schedule — not calling SystemAccessScope directly, and not calling Restore outside a system either, which is why this slipped through the first time. Passed immediately after the fix; would have failed loudly before it. Full suite: 87 tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N |
||
|
|
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 |
||
|
|
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 |
||
|
|
c2bcb9b9fe |
M4: Stage.FixedUpdate + Time's fixed-step accumulator
Both were explicitly deferred to M4 by ITime and Stage's own doc comments back when they were written: a FixedUpdate stage with no real accumulator behind it would be actively misleading, and building the accumulator with no physics system to test it against would be untested speculative machinery. engine.physics (next) is the real consumer. Time.ConsumeFixedSteps accumulates DeltaTime and hands back how many FixedDeltaTime-sized (1/50s) steps it can pay for, capped at 5 per frame so a real stall becomes visible lag instead of a catch-up burst of physics steps. Engine.Host calls it once per frame in both the headless and windowed loops, running Stage.FixedUpdate that many times before Stage.Update — gated by IPlayModeController.IsPlaying the same way Update already is, and only accumulating time while actually playing, so entering Play doesn't open with a burst of steps for however long Edit mode had been sitting idle. 3 new tests on the accumulator itself (below-one-step, whole-steps-plus- remainder, the 5-step cap under a simulated stall). Full suite: 76 tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N |
||
|
|
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 |
||
|
|
2f5a8bfde6 |
M3, part one: World.Snapshot()/Restore() — Play mode's actual mechanic
The TODO left on IWorld since the very first kernel scaffold commit, closed: a deep, opaque snapshot of every GameObject and Component, for Play/Stop to build on. Not a new clone mechanism — backed by SceneFormat. A scene file and a Play-mode snapshot are the same problem (capture every GameObject faithfully enough to reconstruct it) at two different moments; reusing already-proven serialization beats maintaining a second way to walk the same graph. Restore() is NOT additive the way SceneFormat.Load() is by design — it destroys every current root first. Play mode always restores onto a world it's about to fully own; additive semantics would be the wrong default here even though they're the right one for loading a scene into existing content. Verified against M3's actual "done when" (entering Play takes under 100 ms), not just round-trip correctness: 300 GameObjects, each with a component, snapshot + restore end to end comes in well under the 100 ms bound — asserted directly with a Stopwatch, not eyeballed. Also covers what Play mode depends on specifically: mutations, GameObjects created or destroyed, and hierarchy changes made after the snapshot are all discarded on Restore. 73 tests total now (49 in Engine.Kernel.Tests, 8 in Engine.Assets.Tests, 8 in Engine.ConformanceHarness... — wait, that's 65; the two build-time contract projects add no test counts. Actual total per the run: 49+8+8 = 65.), all green on a clean build. Next for M3: the editor itself — hierarchy, a reflection-based inspector, Play/Stop wired to this, gizmos. All still ahead; this commit is only the kernel mechanic underneath Play/Stop. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N |
||
|
|
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 |
||
|
|
45daa6e114 |
M2, part one: scene format — World actually saves and loads now
The first half of M2's "done when" (a scene loads and saves) rather than the whole milestone — the asset hot-reload half is a comparably sized, separate chunk of work, staged on its own rather than crammed in alongside this. SceneFormat replaces WorldDumper rather than sitting next to it: there was never a real reason for "what an agent reads to check a frame" (the existing --dump) and "what a scene file actually is" to be two different JSON shapes, and keeping them one removes the question of which shape a save/load round trip is supposed to match. Moved from Engine.Kernel.Diagnostics to Engine.Kernel.World to match — this is core content loading now, not a debug tool that happens to also serialize things. Components are tagged "TypeFullName, AssemblyName" (partial-name form, deliberately no version) so Type.GetType resolves them against whatever's loaded regardless of an incidental version bump on the plugin that defines them — full four-part AssemblyQualifiedName would have made every saved scene brittle against that. Loading a scene whose component type isn't loaded fails loudly, naming the missing type, rather than silently dropping data. New kernel API this needed: GameObject.AddComponent(Component) — attaches an already-constructed instance, for a caller (the deserializer) that only has a runtime Type from a file, not a compile-time T. Deserializing straight into a real instance via JsonSerializer.Deserialize(json, componentType) and attaching that is simpler and more certain than constructing an empty component through reflection and then trying to populate it after the fact. Engine.Host: --scene now does something (was an explicit "not implemented yet" since the very first CLI pass) — loads additively after every plugin in --project, since a scene's component type tags only resolve once the plugin defining them has loaded its Contracts assembly. Verified beyond the round-trip unit tests: two separate real CLI runs, sandbox.echo both times. Run 1 ticks 3 frames and dumps a scene (Ping.Count: 3). Run 2 loads that scene fresh alongside its own newly-seeded Ping (Count: 0) and ticks 2 more frames — dump shows Count: 2 for the fresh one and Count: 5 for the loaded one. Not just "the file round-trips" — the loaded component's state kept being a real, live, Scheduler-ticked object across the save/load boundary. 44 tests in Engine.Kernel.Tests now (up from 40 — 3 carried over from WorldDumperTests plus 4 new round-trip/error-path tests), 8 in Engine.ConformanceHarness unaffected. All green on a clean build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
f040f71045 |
Implement World: GameObject hierarchy, components, type-indexed queries
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 |
||
|
|
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 |