diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs
index 3c785f0..c69b529 100644
--- a/src/Engine.Host/Program.cs
+++ b/src/Engine.Host/Program.cs
@@ -1,8 +1,14 @@
// The runtime host — drives both halves of the loop described in
// docs/kernel-contract.md §7 and the M1 windowed case in §8:
//
-// engine run --headless --plugins
--project --frames [--dump ]
-// engine run --windowed --plugins --project [--dump ]
+// engine run --headless --plugins --project --frames [--scene ] [--dump ]
+// engine run --windowed --plugins --project [--scene ] [--dump ]
+//
+// --scene loads after every plugin in --project, not before: a scene file
+// names its components by type ("TypeFullName, AssemblyName" — see
+// SceneFormat), and that only resolves once the plugin that defines the
+// type has loaded its Contracts assembly into the Default ALC. It's
+// additive onto whatever's already in World — nothing pre-clears it.
//
// --windowed needs a loaded plugin that provides IEngineWindow (engine.
// windowing) — Engine.Host references that plugin's *Contracts* assembly
@@ -11,10 +17,8 @@
// loop is the host's job, not the kernel's: Engine.Kernel never hears about
// Silk.NET at all.
//
-// Deliberately not implemented yet, both noted explicitly below rather than
+// Deliberately not implemented yet, 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
@@ -54,6 +58,7 @@ if (args[0] != "run")
string? projectPath = null;
string? pluginsPath = null;
string? dumpPath = null;
+string? scenePath = null;
string? screenshotPath = null;
var frames = 0;
var screenshotAfterFrames = 1;
@@ -82,13 +87,15 @@ for (var i = 1; i < args.Length; i++)
case "--dump" when i + 1 < args.Length:
dumpPath = args[++i];
break;
+ case "--scene" when i + 1 < args.Length:
+ scenePath = args[++i];
+ break;
case "--screenshot" when i + 1 < args.Length:
screenshotPath = args[++i];
break;
case "--screenshot-after-frames" when i + 1 < args.Length:
screenshotAfterFrames = int.Parse(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;
@@ -133,6 +140,20 @@ catch (Exception ex)
Console.WriteLine($"Loaded {loaded.Count} plugin(s): {string.Join(", ", loaded)}");
+if (scenePath is not null)
+{
+ try
+ {
+ SceneFormat.Load(world, scenePath);
+ Console.WriteLine($"Loaded scene '{scenePath}'.");
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Failed to load scene '{scenePath}': {ex.Message}");
+ return 1;
+ }
+}
+
if (windowed)
{
if (!services.TryRequire(out var window))
@@ -268,7 +289,7 @@ else
if (dumpPath is not null)
{
- File.WriteAllText(dumpPath, WorldDumper.ToJson(world));
+ SceneFormat.Save(world, dumpPath);
Console.WriteLine($"Wrote world dump to '{dumpPath}'.");
}
@@ -279,8 +300,8 @@ static void PrintUsage()
Console.Error.WriteLine(
"""
Usage:
- engine run --headless --plugins --project --frames [--dump ]
- engine run --windowed --plugins --project [--dump ]
+ engine run --headless --plugins --project --frames [--scene ] [--dump ]
+ engine run --windowed --plugins --project [--scene ] [--dump ]
[--screenshot [--screenshot-after-frames ]]
--screenshot captures once, after frames (default 1), then exits
diff --git a/src/Engine.Kernel/Diagnostics/WorldDumper.cs b/src/Engine.Kernel/Diagnostics/WorldDumper.cs
deleted file mode 100644
index cfe2443..0000000
--- a/src/Engine.Kernel/Diagnostics/WorldDumper.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-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/World/GameObject.cs b/src/Engine.Kernel/World/GameObject.cs
index 989c997..88d8145 100644
--- a/src/Engine.Kernel/World/GameObject.cs
+++ b/src/Engine.Kernel/World/GameObject.cs
@@ -108,6 +108,24 @@ public sealed class GameObject
return component;
}
+ ///
+ /// Attaches an already-constructed component rather than building an
+ /// empty one — for a caller that only has a runtime ,
+ /// not a compile-time T. Scene loading is the reason this
+ /// exists: it deserializes a component straight from JSON into a real
+ /// instance via JsonSerializer.Deserialize(json, componentType),
+ /// and would otherwise need reflection just to call the generic
+ /// overload above.
+ ///
+ public Component AddComponent(Component component)
+ {
+ SystemAccessScope.CheckWrite(component.GetType());
+
+ _components.Add(component);
+ Owner?.IndexComponentAdded(this, component);
+ return component;
+ }
+
public void RemoveComponent() where T : Component
{
SystemAccessScope.CheckWrite(typeof(T));
diff --git a/src/Engine.Kernel/World/SceneFormat.cs b/src/Engine.Kernel/World/SceneFormat.cs
new file mode 100644
index 0000000..d66279f
--- /dev/null
+++ b/src/Engine.Kernel/World/SceneFormat.cs
@@ -0,0 +1,126 @@
+using System.Numerics;
+using System.Text.Json;
+
+namespace Engine.Kernel.World;
+
+///
+/// Save and load a World. Doubles as the headless introspection dump from
+/// docs/kernel-contract.md §7 (previously a separate WorldDumper) — there
+/// was never a real reason for "what an agent reads to check a frame" and
+/// "what a scene file actually is" to be two different JSON shapes, and
+/// keeping them one removes the question of which one a save/load round
+/// trip is supposed to match. Read/write both happen outside any system's
+/// execution, so neither is 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. A component is tagged
+/// with "TypeFullName, AssemblyName" (partial-name form, no version) —
+/// exact enough for Type.GetType to resolve it against whatever's loaded,
+/// loose enough that a plugin's incidental version bump doesn't strand
+/// every scene that references it.
+///
+public static class SceneFormat
+{
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ WriteIndented = true,
+ IncludeFields = true,
+ };
+
+ public static void Save(IWorld world, string path) =>
+ File.WriteAllText(path, ToJson(world));
+
+ public static string ToJson(IWorld world)
+ {
+ var roots = world.Roots.Select(Dump).ToList();
+ return JsonSerializer.Serialize(roots, Options);
+ }
+
+ ///
+ /// Additive: creates whatever this file describes in without touching what's already there. A "replace
+ /// everything" load is the caller's call to make (Destroy the existing
+ /// roots first) — additive is the more fundamental operation, and nothing
+ /// today needs the other one.
+ ///
+ public static void Load(IWorld world, string path) =>
+ FromJson(world, File.ReadAllText(path));
+
+ public static void FromJson(IWorld world, string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+
+ foreach (var element in doc.RootElement.EnumerateArray())
+ Build(world, element, parent: null);
+ }
+
+ 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 = TypeTag(c.GetType()), data = (object)c })
+ .ToList(),
+ children = go.Children.Select(Dump).ToList(),
+ };
+
+ private static void Build(IWorld world, JsonElement element, GameObject? parent)
+ {
+ var go = world.CreateGameObject(element.GetProperty("name").GetString()!);
+
+ var transform = element.GetProperty("transform");
+ var position = transform.GetProperty("position");
+ var rotation = transform.GetProperty("rotation");
+ var scale = transform.GetProperty("scale");
+
+ go.Transform = new Transform
+ {
+ LocalPosition = new Vector3(
+ position[0].GetSingle(), position[1].GetSingle(), position[2].GetSingle()),
+ LocalRotation = new Quaternion(
+ rotation[0].GetSingle(), rotation[1].GetSingle(),
+ rotation[2].GetSingle(), rotation[3].GetSingle()),
+ LocalScale = new Vector3(scale[0].GetSingle(), scale[1].GetSingle(), scale[2].GetSingle()),
+ };
+
+ foreach (var componentElement in element.GetProperty("components").EnumerateArray())
+ {
+ var typeTag = componentElement.GetProperty("type").GetString()!;
+ var componentType = Type.GetType(typeTag)
+ ?? throw new InvalidOperationException(
+ $"Scene references component type '{typeTag}', which isn't loaded. " +
+ "Load the plugin that provides it before loading this scene.");
+
+ var component = (Component)JsonSerializer.Deserialize(
+ componentElement.GetProperty("data").GetRawText(), componentType, Options)!;
+
+ go.AddComponent(component);
+ }
+
+ if (parent is not null)
+ go.SetParent(parent);
+
+ foreach (var childElement in element.GetProperty("children").EnumerateArray())
+ Build(world, childElement, go);
+ }
+
+ private static string TypeTag(Type type) => $"{type.FullName}, {type.Assembly.GetName().Name}";
+
+ private static float[] ToArray(Vector3 v) => [v.X, v.Y, v.Z];
+}
diff --git a/tests/Engine.Kernel.Tests/SceneFormatTests.cs b/tests/Engine.Kernel.Tests/SceneFormatTests.cs
new file mode 100644
index 0000000..c50ea00
--- /dev/null
+++ b/tests/Engine.Kernel.Tests/SceneFormatTests.cs
@@ -0,0 +1,145 @@
+using System.Numerics;
+using System.Text.Json;
+using Engine.Kernel.World;
+
+namespace Engine.Kernel.Tests;
+
+public class SceneFormatTests
+{
+ private sealed class Health : Component
+ {
+ public int Value;
+ }
+
+ [Fact]
+ public void ToJson_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(SceneFormat.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.Contains("Health", component.GetProperty("type").GetString());
+ Assert.Equal(42, component.GetProperty("data").GetProperty("Value").GetInt32());
+ }
+
+ [Fact]
+ public void ToJson_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(SceneFormat.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 ToJson_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(SceneFormat.ToJson(world));
+
+ var components = doc.RootElement[0].GetProperty("components");
+ Assert.Equal(2, components.GetArrayLength());
+ }
+
+ [Fact]
+ public void Round_Trip_Recreates_Name_Transform_And_Component_Field_Values()
+ {
+ var source = new GameWorld();
+ var go = source.CreateGameObject("Hero");
+ go.Transform.LocalPosition = new Vector3(1, 2, 3);
+ go.Transform.LocalScale = new Vector3(2, 2, 2);
+ go.AddComponent().Value = 42;
+
+ var json = SceneFormat.ToJson(source);
+
+ var loaded = new GameWorld();
+ SceneFormat.FromJson(loaded, json);
+
+ var root = Assert.Single(loaded.Roots);
+ Assert.Equal("Hero", root.Name);
+ Assert.Equal(new Vector3(1, 2, 3), root.Transform.LocalPosition);
+ Assert.Equal(new Vector3(2, 2, 2), root.Transform.LocalScale);
+ Assert.Equal(42, root.GetComponent()!.Value);
+ }
+
+ [Fact]
+ public void Round_Trip_Recreates_The_Parent_Child_Hierarchy()
+ {
+ var source = new GameWorld();
+ var parent = source.CreateGameObject("Parent");
+ var child = source.CreateGameObject("Child");
+ child.SetParent(parent);
+
+ var json = SceneFormat.ToJson(source);
+
+ var loaded = new GameWorld();
+ SceneFormat.FromJson(loaded, json);
+
+ var root = Assert.Single(loaded.Roots);
+ Assert.Equal("Parent", root.Name);
+ var loadedChild = Assert.Single(root.Children);
+ Assert.Equal("Child", loadedChild.Name);
+ Assert.Same(root, loadedChild.Parent);
+ }
+
+ [Fact]
+ public void Load_Is_Additive_Onto_An_Already_Populated_World()
+ {
+ var source = new GameWorld();
+ source.CreateGameObject("FromScene");
+
+ var json = SceneFormat.ToJson(source);
+
+ var target = new GameWorld();
+ target.CreateGameObject("AlreadyThere");
+ SceneFormat.FromJson(target, json);
+
+ Assert.Equal(2, target.Roots.Count);
+ Assert.Contains(target.Roots, go => go.Name == "AlreadyThere");
+ Assert.Contains(target.Roots, go => go.Name == "FromScene");
+ }
+
+ [Fact]
+ public void Load_Throws_A_Clear_Error_When_A_Component_Type_Is_Not_Loaded()
+ {
+ const string json = """
+ [
+ {
+ "name": "Broken",
+ "transform": { "position": [0,0,0], "rotation": [0,0,0,1], "scale": [1,1,1] },
+ "components": [ { "type": "Nonexistent.Ghost, Nonexistent", "data": {} } ],
+ "children": []
+ }
+ ]
+ """;
+
+ var world = new GameWorld();
+
+ var exception = Assert.Throws(() => SceneFormat.FromJson(world, json));
+ Assert.Contains("Nonexistent.Ghost", exception.Message);
+ }
+}
diff --git a/tests/Engine.Kernel.Tests/WorldDumperTests.cs b/tests/Engine.Kernel.Tests/WorldDumperTests.cs
deleted file mode 100644
index 6ea8cee..0000000
--- a/tests/Engine.Kernel.Tests/WorldDumperTests.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-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());
- }
-}