Commit Graph
4 Commits
Author SHA1 Message Date
EmilandClaude Sonnet 5 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
2026-09-02 04:57:38 +03:00
EmilandClaude Sonnet 5 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
2026-09-02 01:07:36 +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 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