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
erincatto/box3d: pure C (no C++), MIT-licensed, ID-based handles —
verified via gh api rather than assumed, since this shapes the
engine.physics P/Invoke binding directly. Same "buy, don't build" call
already made for rendering, extended consistently in the scope risk
row and M4.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
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
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
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
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
Surfaces the constraint that already shapes several kernel decisions
(GameObject/Component over ECS, explicit registration, the headless
introspection surface) instead of leaving it only in the internal spec.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
- Restore scheduler parallelism (dropped by accident in the ECS ->
GameObject/Component rewrite; nothing about the object model
actually prevents parallel execution of systems with disjoint
declared access), plus the structural-change queuing that makes
that safe.
- Rename leftover ECS-era `Entity` references to `GameObject`.
- Add per-project plugin configuration (project.json): the doc had no
mechanism backing the "configurable per project" requirement, even
though the README already claimed it.
- State the indie/small-team scope explicitly — it's the unstated
premise behind several tradeoffs already in the doc (GC, GameObject
over ECS, no custom RHI).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
Match the more familiar Unity-style object model instead of a
struct-of-arrays ECS: GameObject hierarchy with type-indexed component
queries, Transform inlined as the one performance-motivated exception.
Data/behavior stay split the same way ECS required it, so hot-reload
safety is unaffected.
Also fixes an inconsistency in the previous reload sequence, which
described migrating live component data on a contract change even
though the Default ALC hosting contracts never unloads. Contract
changes now explicitly require an editor restart instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
Lay out the design that everything else builds on: a small frozen
kernel plugins treat as a shared language, the plugin contract, the
two-assembly hot-reload model, and the build order through M4.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N