master
34
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a77086b518 |
Docs: CI is green on both platforms — M4's literal "done when" is met
.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 |
||
|
|
b238fb5b19 |
Fix CI: real failures found by the first actual workflow run, not guessed
Both jobs failed, at genuinely informative points: - linux-x64: DllNotFoundException loading lingua_physics — the .so committed for scripts/run-sample.sh's local-dev convenience was built on this dev machine's own (newer) glibc, and didn't load on ubuntu-latest's runner. liblingua_audio.so, built the same way, loaded fine there — this wasn't a generic "the file isn't where expected" problem, it was specific to what that one binary happened to require. Fixed by having CI rebuild and overwrite the native libs fresh for both platforms, every run, rather than trusting the committed one for anything but casual local use — only a binary built on the actual target platform is trustworthy on it. - win-x64: "WGL: The driver does not appear to support OpenGL" — from inside the --headless verification run. --headless only controls whether Engine.Host's own loop pumps a window; it does nothing to stop a *loaded* engine.windowing/engine.render from creating a real window and GL context regardless, which the full PhysicsDemo project always does. Fine on a real desktop, fatal on windows-latest's GPU-less runner. Fixed with a separate, minimal verification stage — engine. physics only, project.ci-headless.json/scene.ci-headless.json, no windowing/render/audio/game plugin at all — alongside the original full stage, which still produces the real uploaded artifact. engine.physics needs no display, so this is what CI can actually check without one; audio's own correctness is separately covered by Engine.Audio.Tests (forced onto miniaudio's null backend already) on both platforms. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N |
||
|
|
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 |
||
|
|
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 |
||
|
|
c6d9ff1a26 |
Add scripts/stage-plugins.sh and run-sample.sh for running the engine locally
Contracts and Implementation build into two separate bin/ folders, so there's nowhere valid to point --plugins at without a staging step first — every manual verification this session has done that staging by hand. stage-plugins.sh does it for every plugin in one pass; run-sample.sh stages then runs samples/WindowDemo windowed, forwarding extra args to `engine run` (e.g. --screenshot). 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 |
||
|
|
5189bcd04f |
Close M3: update docs to match what actually shipped
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
853a81a893 |
README: record that the kernel's open questions are closed
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
58e37a3482 |
Record Box3D as the physics engine decision
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 |
||
|
|
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 |
||
|
|
7fe966e42f |
README: state that development is primarily agent-driven
Surfaces the constraint that already shapes several kernel decisions (GameObject/Component over ECS, explicit registration, the headless introspection surface) instead of leaving it only in the internal spec. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N |
||
|
|
ae6c4aba33 |
Final review pass: fix regressions, close a real spec gap
- 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 |
||
|
|
03ca9c62b7 |
Switch World model from ECS to GameObject/Component
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 |
||
|
|
043ff432e2 |
Initial commit: project scaffold and kernel contract v0
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 |