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
This commit is contained in:
@@ -27,11 +27,18 @@ introspection surface no classic editor bothers with — see
|
||||
|
||||
## Status
|
||||
|
||||
Pre-implementation. The current design is written up in
|
||||
[`docs/kernel-contract.md`](docs/kernel-contract.md): what belongs in the
|
||||
kernel, the plugin contract, the hot-reload model, and the build order
|
||||
(M0–M4). Nothing has shipped yet — this document is the thing to argue with
|
||||
before code gets written.
|
||||
**M0 done.** The kernel — `World` (`GameObject`/`Component`, type-indexed
|
||||
queries), `Schedule` (stage execution, conflict batching, debug-mode access
|
||||
enforcement), `PluginHost` (two-ALC load/unload, verified leak-free over
|
||||
200 cycles), and a headless CLI (`engine run --headless ... --dump`) — all
|
||||
exist and are tested. The full agent loop from
|
||||
[`docs/kernel-contract.md#7`](docs/kernel-contract.md#7-written-by-an-agent-not-a-human)
|
||||
runs end to end. No window, no rendering, no physics yet — see the build
|
||||
order (M0–M4) in [`docs/kernel-contract.md`](docs/kernel-contract.md) for
|
||||
what's next.
|
||||
|
||||
Design and implementation are argued over in the same place: the doc is
|
||||
still the thing to disagree with before code changes to match.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ public sealed class EchoPlugin : IPlugin
|
||||
ctx.Schedule.Add(Stage.Update, Tick)
|
||||
.Writes<Ping>();
|
||||
|
||||
// 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
|
||||
// wouldn't do this; scene content isn't a subsystem's job.
|
||||
ctx.World.CreateGameObject("sandbox.echo:ping").AddComponent<Ping>();
|
||||
|
||||
ctx.Log.Info("sandbox.echo configured");
|
||||
}
|
||||
|
||||
|
||||
+124
-8
@@ -1,11 +1,127 @@
|
||||
// The runtime host: resolves a project's plugins and runs the engine loop.
|
||||
// Its shape is sketched in docs/kernel-contract.md §7 —
|
||||
// The runtime host — the headless half of the loop described in
|
||||
// docs/kernel-contract.md §7:
|
||||
//
|
||||
// engine run --headless --frames 60 --scene ... --dump ... --assert ...
|
||||
// engine diag why-pinned <plugin-id>
|
||||
// engine run --headless --plugins <dir> --project <project.json> --frames <n> [--dump <path>]
|
||||
//
|
||||
// Neither the argument parsing nor the loop it drives exists yet; both
|
||||
// depend on PluginHost and Scheduler, which are M0's actual work. This is
|
||||
// a placeholder so the solution builds and runs end to end.
|
||||
// Deliberately not implemented yet, both noted explicitly below rather than
|
||||
// silently accepted or rejected as gibberish:
|
||||
// --scene no scene format exists (that's M2) — a plugin that needs
|
||||
// world content seeds it itself; see sandbox.echo's Configure.
|
||||
// --assert no query DSL exists to parse the doc's illustrative
|
||||
// `count(Rigidbody where sleeping) == 12` syntax; a dump is
|
||||
// plain JSON, so external tooling (jq, a test script) already
|
||||
// covers "check something about it" without us inventing a
|
||||
// parser for a grammar that was never actually specified.
|
||||
// diag why-pinned needs a way to walk the GC heap for what's still
|
||||
// referencing an ALC; nothing has ever failed to unload in
|
||||
// testing, so there's nothing to build this against yet.
|
||||
|
||||
Console.WriteLine("Lingua Engine host — not yet implemented. See docs/kernel-contract.md, M0.");
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.Events;
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.Services;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (args[0] == "diag")
|
||||
{
|
||||
Console.Error.WriteLine("`engine diag` isn't implemented yet — see docs/kernel-contract.md §7.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (args[0] != "run")
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
string? projectPath = null;
|
||||
string? pluginsPath = null;
|
||||
string? dumpPath = null;
|
||||
var frames = 0;
|
||||
var headless = false;
|
||||
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--headless":
|
||||
headless = true;
|
||||
break;
|
||||
case "--frames" when i + 1 < args.Length:
|
||||
frames = int.Parse(args[++i]);
|
||||
break;
|
||||
case "--project" when i + 1 < args.Length:
|
||||
projectPath = args[++i];
|
||||
break;
|
||||
case "--plugins" when i + 1 < args.Length:
|
||||
pluginsPath = args[++i];
|
||||
break;
|
||||
case "--dump" when i + 1 < args.Length:
|
||||
dumpPath = args[++i];
|
||||
break;
|
||||
case "--scene":
|
||||
case "--assert":
|
||||
Console.Error.WriteLine($"'{args[i]}' isn't implemented yet — see the notes at the top of Program.cs.");
|
||||
return 1;
|
||||
default:
|
||||
Console.Error.WriteLine($"Unrecognized argument: '{args[i]}'");
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!headless)
|
||||
{
|
||||
Console.Error.WriteLine("Only --headless is implemented — there's no windowing plugin yet.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (projectPath is null || pluginsPath is null)
|
||||
{
|
||||
Console.Error.WriteLine("--project and --plugins are both required.");
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
var world = new GameWorld();
|
||||
var schedule = new Schedule();
|
||||
var host = new PluginHost(world, new ServiceRegistry(), schedule, new NullEventBus());
|
||||
|
||||
IReadOnlyList<string> loaded;
|
||||
try
|
||||
{
|
||||
loaded = host.LoadProject(projectPath, [pluginsPath]);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to load project '{projectPath}': {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Loaded {loaded.Count} plugin(s): {string.Join(", ", loaded)}");
|
||||
|
||||
for (var frame = 0; frame < frames; frame++)
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
|
||||
Console.WriteLine($"Ran {frames} update frame(s).");
|
||||
|
||||
if (dumpPath is not null)
|
||||
{
|
||||
File.WriteAllText(dumpPath, WorldDumper.ToJson(world));
|
||||
Console.WriteLine($"Wrote world dump to '{dumpPath}'.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Usage: engine run --headless --plugins <dir> --project <project.json> --frames <n> [--dump <path>]");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Kernel.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a World to JSON for the headless introspection loop described
|
||||
/// in docs/kernel-contract.md §7 — an agent's only way to see the effect of
|
||||
/// an edit without a screen. Read-only and outside any system's execution,
|
||||
/// so it isn't subject to SystemAccessScope enforcement.
|
||||
///
|
||||
/// Component types are arbitrary plugin-defined classes, so this leans on
|
||||
/// System.Text.Json's own reflection rather than anything bespoke —
|
||||
/// including IncludeFields, since components are plain public fields
|
||||
/// (docs/kernel-contract.md §1), not properties.
|
||||
/// </summary>
|
||||
public static class WorldDumper
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
IncludeFields = true,
|
||||
};
|
||||
|
||||
public static string ToJson(IWorld world)
|
||||
{
|
||||
var roots = world.Roots.Select(Dump).ToList();
|
||||
return JsonSerializer.Serialize(roots, Options);
|
||||
}
|
||||
|
||||
private static object Dump(GameObject go) => new
|
||||
{
|
||||
name = go.Name,
|
||||
transform = new
|
||||
{
|
||||
position = ToArray(go.Transform.LocalPosition),
|
||||
rotation = new[]
|
||||
{
|
||||
go.Transform.LocalRotation.X, go.Transform.LocalRotation.Y,
|
||||
go.Transform.LocalRotation.Z, go.Transform.LocalRotation.W,
|
||||
},
|
||||
scale = ToArray(go.Transform.LocalScale),
|
||||
},
|
||||
// A list, not a dictionary keyed by type name: AddComponent<T>()
|
||||
// doesn't enforce uniqueness (see the World row in §2), so a
|
||||
// GameObject can legitimately carry two components of the same
|
||||
// type. A dictionary would throw on the very case this needs to
|
||||
// represent correctly.
|
||||
components = go.Components
|
||||
.Select(c => new { type = c.GetType().Name, data = (object)c })
|
||||
.ToList(),
|
||||
children = go.Children.Select(Dump).ToList(),
|
||||
};
|
||||
|
||||
private static float[] ToArray(System.Numerics.Vector3 v) => [v.X, v.Y, v.Z];
|
||||
}
|
||||
@@ -12,14 +12,15 @@ namespace Engine.Kernel.Plugins;
|
||||
/// Loads, unloads, and (eventually) reloads plugins. See
|
||||
/// docs/kernel-contract.md §3-§4.
|
||||
///
|
||||
/// Scope of this pass: single-plugin load/unload with the correct two-ALC
|
||||
/// split, and enough bookkeeping that a plugin's Shutdown() can be honest.
|
||||
/// NOT yet built: resolving a project's or a plugin's <c>dependsOn</c> graph
|
||||
/// to determine load order across multiple plugins — that needs at least
|
||||
/// two real interdependent plugins to test against meaningfully, and we
|
||||
/// only have one (sandbox.echo, deliberately dependency-free). Loading a
|
||||
/// plugin whose dependencies aren't already loaded will fail wherever the
|
||||
/// plugin's own code first touches them, not with a host-level error.
|
||||
/// NOT yet built: resolving a plugin's own <c>dependsOn</c> graph to
|
||||
/// determine load order — that needs at least two real interdependent
|
||||
/// plugins to test against meaningfully, and we only have one (sandbox.echo,
|
||||
/// deliberately dependency-free). <see cref="LoadProject"/> loads a
|
||||
/// project's plugins in the order its manifest lists them and does not
|
||||
/// check <c>PluginReference.Version</c> either; loading a plugin whose
|
||||
/// dependencies aren't already loaded (or aren't the version expected)
|
||||
/// 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)
|
||||
{
|
||||
@@ -61,6 +62,38 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
|
||||
return manifest.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads every plugin a project's manifest lists, in listed order.
|
||||
/// Each plugin id is resolved to a directory by checking, in order,
|
||||
/// every path in <paramref name="engineSearchPaths"/> (the engine's own
|
||||
/// plugin catalog) and then the project's own <c>pluginPaths</c>
|
||||
/// (resolved relative to <paramref name="projectManifestPath"/>'s
|
||||
/// directory) for a <c><searchPath>/<id>/plugin.json</c>.
|
||||
/// Returns the loaded ids, same order as the manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> LoadProject(string projectManifestPath, IReadOnlyList<string> engineSearchPaths)
|
||||
{
|
||||
var project = ReadProjectManifest(projectManifestPath);
|
||||
var projectDirectory = Path.GetDirectoryName(Path.GetFullPath(projectManifestPath))!;
|
||||
|
||||
var searchPaths = engineSearchPaths
|
||||
.Concat(project.PluginPaths.Select(p => Path.Combine(projectDirectory, p)))
|
||||
.ToList();
|
||||
|
||||
var loadedIds = new List<string>(project.Plugins.Count);
|
||||
|
||||
foreach (var reference in project.Plugins)
|
||||
{
|
||||
var directory = ResolvePluginDirectory(reference.Id, searchPaths)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Could not find plugin '{reference.Id}' under any of: {string.Join(", ", searchPaths)}.");
|
||||
|
||||
loadedIds.Add(Load(directory));
|
||||
}
|
||||
|
||||
return loadedIds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs Shutdown(), then unloads the plugin's ALC. Returns a weak
|
||||
/// reference to the ALC so a caller can verify it actually collected —
|
||||
@@ -92,6 +125,28 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
|
||||
?? throw new InvalidOperationException($"'{path}' did not deserialize to a plugin manifest.");
|
||||
}
|
||||
|
||||
private static ProjectManifest ReadProjectManifest(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException($"No project manifest found at '{path}'.", path);
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<ProjectManifest>(json, ManifestOptions)
|
||||
?? throw new InvalidOperationException($"'{path}' did not deserialize to a project manifest.");
|
||||
}
|
||||
|
||||
private static string? ResolvePluginDirectory(string pluginId, IReadOnlyList<string> searchPaths)
|
||||
{
|
||||
foreach (var searchPath in searchPaths)
|
||||
{
|
||||
var candidate = Path.Combine(searchPath, pluginId);
|
||||
if (File.Exists(Path.Combine(candidate, "plugin.json")))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void LoadContractsIntoDefaultAlc(string pluginDirectory, PluginManifest manifest)
|
||||
{
|
||||
var contractsPath = Path.Combine(pluginDirectory, manifest.Contracts);
|
||||
|
||||
@@ -31,4 +31,33 @@ public class PluginHostTests
|
||||
emptyDir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadProject_Throws_When_The_Project_Manifest_Is_Missing()
|
||||
{
|
||||
var host = NewHost();
|
||||
var missingPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}-project.json");
|
||||
|
||||
Assert.Throws<FileNotFoundException>(() => host.LoadProject(missingPath, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadProject_Throws_When_A_Referenced_Plugin_Is_Not_Found_In_Any_Search_Path()
|
||||
{
|
||||
var host = NewHost();
|
||||
var dir = Directory.CreateTempSubdirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var projectPath = Path.Combine(dir.FullName, "project.json");
|
||||
File.WriteAllText(projectPath,
|
||||
"""{ "engineVersion": "^0.1", "plugins": [ { "id": "nonexistent.plugin" } ] }""");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => host.LoadProject(projectPath, [dir.FullName]));
|
||||
}
|
||||
finally
|
||||
{
|
||||
dir.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Numerics;
|
||||
using System.Text.Json;
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Kernel.Tests;
|
||||
|
||||
public class WorldDumperTests
|
||||
{
|
||||
private sealed class Health : Component
|
||||
{
|
||||
public int Value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dump_Includes_Name_Transform_And_Component_Fields()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var go = world.CreateGameObject("Hero");
|
||||
go.Transform.LocalPosition = new Vector3(1, 2, 3);
|
||||
go.AddComponent<Health>().Value = 42;
|
||||
|
||||
using var doc = JsonDocument.Parse(WorldDumper.ToJson(world));
|
||||
var root = doc.RootElement[0];
|
||||
|
||||
Assert.Equal("Hero", root.GetProperty("name").GetString());
|
||||
|
||||
var position = root.GetProperty("transform").GetProperty("position");
|
||||
Assert.Equal(1, position[0].GetSingle());
|
||||
Assert.Equal(2, position[1].GetSingle());
|
||||
Assert.Equal(3, position[2].GetSingle());
|
||||
|
||||
var component = root.GetProperty("components")[0];
|
||||
Assert.Equal("Health", component.GetProperty("type").GetString());
|
||||
Assert.Equal(42, component.GetProperty("data").GetProperty("Value").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dump_Nests_Children_Under_Their_Parent_Rather_Than_Listing_Them_At_The_Top_Level()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var parent = world.CreateGameObject("Parent");
|
||||
var child = world.CreateGameObject("Child");
|
||||
child.SetParent(parent);
|
||||
|
||||
using var doc = JsonDocument.Parse(WorldDumper.ToJson(world));
|
||||
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
var root = doc.RootElement[0];
|
||||
Assert.Equal("Parent", root.GetProperty("name").GetString());
|
||||
Assert.Equal("Child", root.GetProperty("children")[0].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dump_Represents_Duplicate_Components_Of_The_Same_Type_As_A_List_Not_A_Dictionary()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var go = world.CreateGameObject("A");
|
||||
go.AddComponent<Health>().Value = 1;
|
||||
go.AddComponent<Health>().Value = 2;
|
||||
|
||||
// A dictionary keyed by type name would throw on this exact case.
|
||||
using var doc = JsonDocument.Parse(WorldDumper.ToJson(world));
|
||||
|
||||
var components = doc.RootElement[0].GetProperty("components");
|
||||
Assert.Equal(2, components.GetArrayLength());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user