Commit Graph
4 Commits
Author SHA1 Message Date
EmilandClaude Sonnet 5 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
2026-09-02 05:07:14 +03:00
EmilandClaude Sonnet 5 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
2026-09-02 00:59:02 +03:00
EmilandClaude Sonnet 5 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
2026-09-02 00:33:18 +03:00
EmilandClaude Sonnet 5 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
2026-09-02 00:26:08 +03:00