88e0afa46b0dd06c3c5b125cc0de4dcc5011aeb0
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |