diff --git a/README.md b/README.md index 3c9de63..a8586d7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs b/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs index 6c84665..5d140e9 100644 --- a/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs +++ b/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs @@ -18,6 +18,12 @@ public sealed class EchoPlugin : IPlugin ctx.Schedule.Add(Stage.Update, Tick) .Writes(); + // 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(); + ctx.Log.Info("sandbox.echo configured"); } diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs index 36b5ed8..2652f40 100644 --- a/src/Engine.Host/Program.cs +++ b/src/Engine.Host/Program.cs @@ -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 +// engine run --headless --plugins --project --frames [--dump ] // -// 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 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 --project --frames [--dump ]"); +} diff --git a/src/Engine.Kernel/Diagnostics/WorldDumper.cs b/src/Engine.Kernel/Diagnostics/WorldDumper.cs new file mode 100644 index 0000000..cfe2443 --- /dev/null +++ b/src/Engine.Kernel/Diagnostics/WorldDumper.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using Engine.Kernel.World; + +namespace Engine.Kernel.Diagnostics; + +/// +/// 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. +/// +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() + // 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]; +} diff --git a/src/Engine.Kernel/Plugins/PluginHost.cs b/src/Engine.Kernel/Plugins/PluginHost.cs index 8b0783a..9758d32 100644 --- a/src/Engine.Kernel/Plugins/PluginHost.cs +++ b/src/Engine.Kernel/Plugins/PluginHost.cs @@ -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 dependsOn 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 dependsOn 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). loads a +/// project's plugins in the order its manifest lists them and does not +/// check PluginReference.Version 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. /// 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; } + /// + /// 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 (the engine's own + /// plugin catalog) and then the project's own pluginPaths + /// (resolved relative to 's + /// directory) for a <searchPath>/<id>/plugin.json. + /// Returns the loaded ids, same order as the manifest. + /// + public IReadOnlyList LoadProject(string projectManifestPath, IReadOnlyList 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(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; + } + /// /// 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(json, ManifestOptions) + ?? throw new InvalidOperationException($"'{path}' did not deserialize to a project manifest."); + } + + private static string? ResolvePluginDirectory(string pluginId, IReadOnlyList 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); diff --git a/tests/Engine.Kernel.Tests/PluginHostTests.cs b/tests/Engine.Kernel.Tests/PluginHostTests.cs index 2d1e5ea..2611ac9 100644 --- a/tests/Engine.Kernel.Tests/PluginHostTests.cs +++ b/tests/Engine.Kernel.Tests/PluginHostTests.cs @@ -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(() => 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(() => host.LoadProject(projectPath, [dir.FullName])); + } + finally + { + dir.Delete(recursive: true); + } + } } diff --git a/tests/Engine.Kernel.Tests/WorldDumperTests.cs b/tests/Engine.Kernel.Tests/WorldDumperTests.cs new file mode 100644 index 0000000..6ea8cee --- /dev/null +++ b/tests/Engine.Kernel.Tests/WorldDumperTests.cs @@ -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().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().Value = 1; + go.AddComponent().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()); + } +}