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
This commit is contained in:
Emil
2026-09-02 04:57:38 +03:00
co-authored by Claude Sonnet 5
parent f6d9ea896f
commit 3c60e2cd45
17 changed files with 344 additions and 30 deletions
+45 -10
View File
@@ -53,8 +53,8 @@ nothing.
| 02 | **Scheduler** | Frame stages, topological system ordering, parallel execution of systems with disjoint declared access, and debug-mode enforcement of that access — see §7. Structural changes (adding/removing a `GameObject` or `Component`) are queued and applied at the stage boundary, so a running system never sees a collection mutate under it. |
| 03 | **Plugin Host** | Manifest parsing, dependency resolution, ALC loading, unloading, reload. |
| 04 | **Service Registry** | Publishing and discovering interfaces between plugins. Control path, not the hot path. |
| 05 | **Event Bus** | Decoupled notifications: `GameObject` created, asset reloaded, plugin unloaded. |
| 06 | **Time & Log** | Frame clock, fixed-step accumulator, logging interface. Kept minimal. |
| 05 | **Event Bus** | `Publish`/`Subscribe`, ownership-tracked and leak-safe the same way as the Scheduler's systems — see §7. `PluginHost` publishes `PluginLoaded`/`PluginUnloaded`; nothing publishes `GameObject` created/destroyed or asset-reloaded facts yet — the first because `GameWorld` doesn't touch the bus at all (a publish on every structural change would tax the hot path for listeners that usually don't exist), the second because there's no asset system yet. |
| 06 | **Time & Log** | Frame clock (`DeltaTime`, `ElapsedTime`, `FrameCount`) and logging, both on `IPluginContext`. No fixed-step accumulator yet — deferred to M4, alongside the physics system it would actually drive. |
`GameObject.Transform` is the one field embedded directly rather than
modeled as a `Component` subclass — it's a plain struct holding local
@@ -161,8 +161,9 @@ public interface IPluginContext
IWorld World { get; } // data
IServiceRegistry Services { get; } // Provide<T> / Require<T>
ISchedule Schedule { get; } // systems and ordering
IEventBus Events { get; }
IEventBus Events { get; } // Publish / Subscribe
ILogger Log { get; }
ITime Time { get; } // DeltaTime, ElapsedTime, FrameCount
}
```
@@ -429,10 +430,44 @@ cost of changing course is still zero.
---
Open questions to resolve before M0: whether `Time` and `Log` belong in the
kernel or as plugins; whether the Event Bus is needed at launch or whether
event-components in `World` cover its role; whether the set of frame stages
is fixed or plugin-extensible; and whether a data-oriented fast path (for
bulk operations like particles) is worth introducing later without
abandoning GameObject/Component for everything else. Assembly names in the
examples are placeholders.
**The kernel's open questions are resolved.** What M0 shipped without
deciding, in order:
- **`Time` and `Log`: both in the kernel**, both on `IPluginContext`. Every
plugin needs logging and a frame clock; there's no realistic case for a
project wanting to swap either out per-project the way it would swap
physics or rendering. `Log` was already built this way by the time the
question got asked explicitly — `Time` (`DeltaTime`, `ElapsedTime`,
`FrameCount`) shipped alongside closing the question, not before it.
**Explicitly still deferred:** the fixed-step accumulator the original
kernel scope named alongside the frame clock. Building it now, with no
physics system to test it against, would be untested speculative
machinery — exactly what this project has avoided everywhere else. It
arrives with M4, alongside the `Stage.FixedUpdate` it would drive.
- **Event Bus: real, not event-components in `World`.** A disposable
event-as-`GameObject` fits a pure ECS's cheap-entity model better than
ours, where `GameObject` carries persistent identity and hierarchy. A
conventional `Publish`/`Subscribe` bus is the better fit for this object
model specifically. `PluginHost` publishing `PluginLoaded` /
`PluginUnloaded` is the concrete proof it's real infrastructure, not an
API nobody calls — and `Subscribe`/`RemoveAllFrom` follow the exact
leak-safety shape `Schedule` already established (ownership tracked by
the subscribing delegate's declaring assembly), proven the same way:
`sandbox.echo` subscribes for real, and the 200-cycle leak test in
`AlcUnloadTests` now exercises that cleanup path, not just `Schedule`'s.
- **Frame stages: fixed, kernel-defined — not plugin-extensible.** A stage
is part of the shared language every plugin and the host loop rely on;
letting plugins register arbitrary custom stages would mean the host's
frame loop can no longer just call a known, closed set of `RunStage`s.
The set stays `{Update, Render}` until `FixedUpdate` arrives with M4 — no
stage gets added without something real to run in it.
- **Data-oriented fast path: still open, on purpose, with a trigger
condition instead of a deadline.** Not "undecided" the way the other
three were — deliberately not worth deciding before there's a concrete
system to decide it against. Revisit when a specific system (particles is
the standing example) needs tens of thousands of `GameObject`s updated
per frame *and* profiling — not intuition — shows `GameObject`/`Component`
overhead is the actual bottleneck. Until then, an early decision here
would be optimizing against a guess.
Assembly names in the examples are placeholders.
@@ -18,6 +18,12 @@ public sealed class EchoPlugin : IPlugin
ctx.Schedule.Add(Stage.Update, Tick)
.Writes<Ping>();
// A real cross-ALC subscription, not just a system — this is what
// makes the 200-cycle leak test in AlcUnloadTests actually prove
// EventBus.RemoveAllFrom works, the same way registering Tick
// above proves it for Schedule.RemoveAllFrom.
ctx.Events.Subscribe<PluginLoaded>(OnPluginLoaded);
// There's no scene format yet (that's M2) — nothing else will ever
// put a GameObject in front of `engine run --headless`, so this
// deliberately-a-test-fixture plugin seeds its own. A real plugin
@@ -30,6 +36,7 @@ public sealed class EchoPlugin : IPlugin
public void Shutdown(IPluginContext ctx)
{
ctx.Schedule.RemoveAllFrom("sandbox.echo");
ctx.Events.RemoveAllFrom("sandbox.echo");
}
static void Tick(IWorld world)
@@ -37,4 +44,7 @@ public sealed class EchoPlugin : IPlugin
foreach (var go in world.Query<Ping>())
go.GetComponent<Ping>()!.Count++;
}
static void OnPluginLoaded(PluginLoaded evt) =>
Console.WriteLine($"[sandbox.echo] observed load of '{evt.PluginId}'");
}
+18 -2
View File
@@ -116,7 +116,9 @@ if (projectPath is null || pluginsPath is null)
var world = new GameWorld();
var schedule = new Schedule();
var services = new ServiceRegistry();
var host = new PluginHost(world, services, schedule, new NullEventBus());
var events = new EventBus();
var time = new Time();
var host = new PluginHost(world, services, schedule, events, time);
IReadOnlyList<string> loaded;
try
@@ -164,12 +166,17 @@ if (windowed)
});
var frameCount = 0;
var lastTime = window!.Native.Time;
while (!window!.IsClosing)
while (!window.IsClosing)
{
frameCount++;
window.Native.DoEvents();
var currentTime = window.Native.Time;
time.Tick((float)(currentTime - lastTime));
lastTime = currentTime;
if (window.IsClosing)
break;
@@ -244,8 +251,17 @@ if (windowed)
}
else
{
// No wall clock to measure — headless runs as fast as the CPU allows,
// not once per real 1/60s. A fixed nominal delta keeps ITime.DeltaTime
// meaningful for systems that use it, and keeps headless runs
// deterministic, which --windowed's real wall-clock delta can't be.
const float headlessDeltaTime = 1f / 60f;
for (var frame = 0; frame < frames; frame++)
{
time.Tick(headlessDeltaTime);
schedule.RunStage(Stage.Update, world);
}
Console.WriteLine($"Ran {frames} update frame(s).");
}
+23
View File
@@ -0,0 +1,23 @@
namespace Engine.Kernel.Diagnostics;
/// <summary>
/// Frame clock. See docs/kernel-contract.md §2.
///
/// No fixed-step accumulator yet — that was in the original kernel scope
/// but nothing consumes it: building it now, with no physics system to
/// test it against, 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 without a real fixed-timestep accumulator behind it would be
/// actively misleading, not just incomplete.
/// </summary>
public interface ITime
{
/// <summary>Seconds since the previous frame's Update stage.</summary>
float DeltaTime { get; }
/// <summary>Seconds since the engine started.</summary>
double ElapsedTime { get; }
int FrameCount { get; }
}
+21
View File
@@ -0,0 +1,21 @@
namespace Engine.Kernel.Diagnostics;
/// <summary>
/// Read-only to plugins (ITime); advanced by whoever owns the frame loop —
/// Engine.Host today, headless or windowed — via <see cref="Tick"/>, which
/// isn't on the interface. Same split as Schedule/ISchedule: the host gets
/// the extra, plugin-facing code doesn't.
/// </summary>
public sealed class Time : ITime
{
public float DeltaTime { get; private set; }
public double ElapsedTime { get; private set; }
public int FrameCount { get; private set; }
public void Tick(float deltaTime)
{
DeltaTime = deltaTime;
ElapsedTime += deltaTime;
FrameCount++;
}
}
+52
View File
@@ -0,0 +1,52 @@
using System.Reflection;
namespace Engine.Kernel.Events;
/// <summary>
/// Ownership tracked the same way Schedule tracks systems: by the
/// subscribing delegate's declaring assembly, not a string tag per
/// subscription. See <see cref="RegisterPlugin"/> and
/// Engine.Kernel.Scheduling.Schedule.
/// </summary>
public sealed class EventBus : IEventBus
{
private readonly Dictionary<Type, List<Delegate>> _handlers = [];
private readonly Dictionary<string, Assembly> _pluginAssemblies = [];
/// <summary>Called by PluginHost right after loading a plugin's
/// implementation assembly, before Configure() runs — mirrors
/// Schedule.RegisterPlugin exactly.</summary>
internal void RegisterPlugin(string pluginId, Assembly implementationAssembly)
=> _pluginAssemblies[pluginId] = implementationAssembly;
public void Publish<TEvent>(TEvent evt) where TEvent : notnull
{
if (!_handlers.TryGetValue(typeof(TEvent), out var handlers) || handlers.Count == 0)
return;
// Snapshot: a handler subscribing or unsubscribing during dispatch
// must not corrupt the in-progress iteration.
foreach (var handler in handlers.ToArray())
((Action<TEvent>)handler)(evt);
}
public void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : notnull
{
if (!_handlers.TryGetValue(typeof(TEvent), out var handlers))
{
handlers = [];
_handlers[typeof(TEvent)] = handlers;
}
handlers.Add(handler);
}
public void RemoveAllFrom(string pluginId)
{
if (!_pluginAssemblies.Remove(pluginId, out var assembly))
return;
foreach (var handlers in _handlers.Values)
handlers.RemoveAll(h => h.Method.DeclaringType?.Assembly == assembly);
}
}
+18 -4
View File
@@ -1,11 +1,25 @@
namespace Engine.Kernel.Events;
/// <summary>
/// Open question (see the footer of docs/kernel-contract.md): whether this
/// is needed at launch at all, or whether event-components in World cover
/// its role. Left as a bare marker so IPluginContext compiles — no API
/// decided yet.
/// Decoupled notifications: a publisher fires a fact with no fixed
/// consumer at design time. See the "two channels" — well, three, counting
/// this — table in docs/kernel-contract.md §2.
///
/// Deliberately not "events as World entities": disposable event
/// GameObjects would need something to create and clean them up every
/// frame, which fits a pure-ECS model better than GameObject/Component's
/// persistent-identity one. A real pub/sub bus is the better fit here.
/// </summary>
public interface IEventBus
{
void Publish<TEvent>(TEvent evt) where TEvent : notnull;
/// <summary>No matching Unsubscribe — same shape as ISchedule, which
/// only offers bulk removal by plugin id, not per-system removal.
/// Call RemoveAllFrom your own plugin id in Shutdown(), or a forgotten
/// subscription pins your ALC exactly the way a forgotten system
/// does — see §4.</summary>
void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : notnull;
void RemoveAllFrom(string pluginId);
}
-7
View File
@@ -1,7 +0,0 @@
namespace Engine.Kernel.Events;
/// <summary>
/// IEventBus has no members yet — see the open question in
/// docs/kernel-contract.md's footer. Nothing to implement until it does.
/// </summary>
public sealed class NullEventBus : IEventBus;
@@ -13,4 +13,5 @@ public interface IPluginContext
ISchedule Schedule { get; } // systems and ordering
IEventBus Events { get; }
ILogger Log { get; }
ITime Time { get; }
}
+3 -1
View File
@@ -14,11 +14,13 @@ internal sealed class PluginContext(
IWorld world,
IServiceRegistry services,
ISchedule schedule,
IEventBus events) : IPluginContext
IEventBus events,
ITime time) : IPluginContext
{
public IWorld World { get; } = world;
public IServiceRegistry Services { get; } = services;
public ISchedule Schedule { get; } = schedule;
public IEventBus Events { get; } = events;
public ILogger Log { get; } = new ConsoleLogger(pluginId);
public ITime Time { get; } = time;
}
@@ -0,0 +1,9 @@
namespace Engine.Kernel.Plugins;
/// <summary>
/// The concrete proof that EventBus is real infrastructure, not an
/// unused API — PluginHost publishes both. See docs/kernel-contract.md §2.
/// </summary>
public readonly record struct PluginLoaded(string PluginId);
public readonly record struct PluginUnloaded(string PluginId);
+11 -2
View File
@@ -1,6 +1,7 @@
using System.Reflection;
using System.Runtime.Loader;
using System.Text.Json;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.Events;
using Engine.Kernel.Scheduling;
using Engine.Kernel.Services;
@@ -22,7 +23,12 @@ namespace Engine.Kernel.Plugins;
/// will fail wherever its own code first touches them, not with a
/// host-level error.
/// </summary>
public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule schedule, IEventBus events)
public sealed class PluginHost(
IWorld world,
IServiceRegistry services,
Schedule schedule,
EventBus events,
ITime time)
{
private static readonly JsonSerializerOptions ManifestOptions = new()
{
@@ -64,11 +70,13 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
var instance = (IPlugin)Activator.CreateInstance(pluginType)!;
schedule.RegisterPlugin(manifest.Id, implAssembly);
var ctx = new PluginContext(manifest.Id, world, services, schedule, events);
events.RegisterPlugin(manifest.Id, implAssembly);
var ctx = new PluginContext(manifest.Id, world, services, schedule, events, time);
instance.Configure(ctx);
_loaded[manifest.Id] = new LoadedPlugin(manifest, alc, instance, ctx);
events.Publish(new PluginLoaded(manifest.Id));
return manifest.Id;
}
@@ -120,6 +128,7 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
var weakAlc = new WeakReference(loaded.Alc);
loaded.Alc.Unload();
events.Publish(new PluginUnloaded(pluginId));
return weakAlc;
}
@@ -1,4 +1,5 @@
using System.Runtime.Loader;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.Events;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
@@ -22,7 +23,7 @@ public class AlcUnloadTests
Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.echo");
private static PluginHost NewHost() =>
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus());
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new EventBus(), new Time());
[Fact]
public void Plugin_Survives_200_Load_Unload_Cycles()
@@ -1,3 +1,4 @@
using Engine.Kernel.Diagnostics;
using Engine.Kernel.Events;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
@@ -28,7 +29,7 @@ public class PluginSystemTests
{
var world = new GameWorld();
var schedule = new Schedule();
var host = new PluginHost(world, new ServiceRegistry(), schedule, new NullEventBus());
var host = new PluginHost(world, new ServiceRegistry(), schedule, new EventBus(), new Time());
var ping = world.CreateGameObject("Pinger").AddComponent<Ping>();
var id = host.Load(PluginDirectory);
@@ -46,7 +47,7 @@ public class PluginSystemTests
{
var world = new GameWorld();
var schedule = new Schedule();
var host = new PluginHost(world, new ServiceRegistry(), schedule, new NullEventBus());
var host = new PluginHost(world, new ServiceRegistry(), schedule, new EventBus(), new Time());
var ping = world.CreateGameObject("Pinger").AddComponent<Ping>();
var id = host.Load(PluginDirectory);
@@ -56,4 +57,34 @@ public class PluginSystemTests
Assert.Equal(0, ping.Count);
}
[Fact]
public void Loading_A_Plugin_Publishes_PluginLoaded_With_Its_Id()
{
var world = new GameWorld();
var events = new EventBus();
var host = new PluginHost(world, new ServiceRegistry(), new Schedule(), events, new Time());
string? observedId = null;
events.Subscribe<PluginLoaded>(e => observedId = e.PluginId);
var id = host.Load(PluginDirectory);
Assert.Equal("sandbox.echo", observedId);
host.Unload(id);
}
[Fact]
public void Unloading_A_Plugin_Publishes_PluginUnloaded_With_Its_Id()
{
var world = new GameWorld();
var events = new EventBus();
var host = new PluginHost(world, new ServiceRegistry(), new Schedule(), events, new Time());
var id = host.Load(PluginDirectory);
string? observedId = null;
events.Subscribe<PluginUnloaded>(e => observedId = e.PluginId);
host.Unload(id);
Assert.Equal("sandbox.echo", observedId);
}
}
@@ -0,0 +1,55 @@
using Engine.Kernel.Events;
namespace Engine.Kernel.Tests;
public class EventBusTests
{
private readonly record struct Ping(int Value);
[Fact]
public void Publish_Invokes_Every_Subscriber_For_That_Event_Type()
{
var bus = new EventBus();
var received = new List<int>();
bus.Subscribe<Ping>(p => received.Add(p.Value));
bus.Subscribe<Ping>(p => received.Add(p.Value * 10));
bus.Publish(new Ping(3));
Assert.Equal([3, 30], received);
}
[Fact]
public void Publish_With_No_Subscribers_Does_Not_Throw()
{
var bus = new EventBus();
var exception = Record.Exception(() => bus.Publish(new Ping(1)));
Assert.Null(exception);
}
[Fact]
public void Publish_Does_Not_Invoke_Handlers_Subscribed_To_A_Different_Event_Type()
{
var bus = new EventBus();
var otherReceived = false;
bus.Subscribe<string>(_ => otherReceived = true);
bus.Publish(new Ping(1));
Assert.False(otherReceived);
}
[Fact]
public void RemoveAllFrom_An_Id_With_No_Registered_Plugin_Is_A_No_Op()
{
var bus = new EventBus();
var exception = Record.Exception(() => bus.RemoveAllFrom("nothing-registered"));
Assert.Null(exception);
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
using Engine.Kernel.Diagnostics;
using Engine.Kernel.Events;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
@@ -14,7 +15,7 @@ namespace Engine.Kernel.Tests;
public class PluginHostTests
{
private static PluginHost NewHost() =>
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus());
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new EventBus(), new Time());
[Fact]
public void Load_Throws_When_The_Directory_Has_No_Manifest()
+41
View File
@@ -0,0 +1,41 @@
using Engine.Kernel.Diagnostics;
namespace Engine.Kernel.Tests;
public class TimeTests
{
[Fact]
public void Starts_At_Zero()
{
var time = new Time();
Assert.Equal(0, time.DeltaTime);
Assert.Equal(0, time.ElapsedTime);
Assert.Equal(0, time.FrameCount);
}
[Fact]
public void Tick_Sets_DeltaTime_And_Advances_ElapsedTime_And_FrameCount()
{
var time = new Time();
time.Tick(0.5f);
Assert.Equal(0.5f, time.DeltaTime);
Assert.Equal(0.5, time.ElapsedTime, 3);
Assert.Equal(1, time.FrameCount);
}
[Fact]
public void ElapsedTime_And_FrameCount_Accumulate_Across_Ticks()
{
var time = new Time();
time.Tick(0.5f);
time.Tick(0.25f);
Assert.Equal(0.25f, time.DeltaTime);
Assert.Equal(0.75, time.ElapsedTime, 3);
Assert.Equal(2, time.FrameCount);
}
}