a77086b518fa33f014f8d843a9be8fd999a0250c
6
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |