From 45daa6e11479c2d097c0a4453333293d8c79abdb Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 2 Sep 2026 05:07:14 +0300 Subject: [PATCH] =?UTF-8?q?M2,=20part=20one:=20scene=20format=20=E2=80=94?= =?UTF-8?q?=20World=20actually=20saves=20and=20loads=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first half of M2's "done when" (a scene loads and saves) rather than the whole milestone — the asset hot-reload half is a comparably sized, separate chunk of work, staged on its own rather than crammed in alongside this. SceneFormat replaces WorldDumper rather than sitting next to it: there was never a real reason for "what an agent reads to check a frame" (the existing --dump) and "what a scene file actually is" to be two different JSON shapes, and keeping them one removes the question of which shape a save/load round trip is supposed to match. Moved from Engine.Kernel.Diagnostics to Engine.Kernel.World to match — this is core content loading now, not a debug tool that happens to also serialize things. Components are tagged "TypeFullName, AssemblyName" (partial-name form, deliberately no version) so Type.GetType resolves them against whatever's loaded regardless of an incidental version bump on the plugin that defines them — full four-part AssemblyQualifiedName would have made every saved scene brittle against that. Loading a scene whose component type isn't loaded fails loudly, naming the missing type, rather than silently dropping data. New kernel API this needed: GameObject.AddComponent(Component) — attaches an already-constructed instance, for a caller (the deserializer) that only has a runtime Type from a file, not a compile-time T. Deserializing straight into a real instance via JsonSerializer.Deserialize(json, componentType) and attaching that is simpler and more certain than constructing an empty component through reflection and then trying to populate it after the fact. Engine.Host: --scene now does something (was an explicit "not implemented yet" since the very first CLI pass) — loads additively after every plugin in --project, since a scene's component type tags only resolve once the plugin defining them has loaded its Contracts assembly. Verified beyond the round-trip unit tests: two separate real CLI runs, sandbox.echo both times. Run 1 ticks 3 frames and dumps a scene (Ping.Count: 3). Run 2 loads that scene fresh alongside its own newly-seeded Ping (Count: 0) and ticks 2 more frames — dump shows Count: 2 for the fresh one and Count: 5 for the loaded one. Not just "the file round-trips" — the loaded component's state kept being a real, live, Scheduler-ticked object across the save/load boundary. 44 tests in Engine.Kernel.Tests now (up from 40 — 3 carried over from WorldDumperTests plus 4 new round-trip/error-path tests), 8 in Engine.ConformanceHarness unaffected. All green on a clean build. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N --- src/Engine.Host/Program.cs | 39 +++-- src/Engine.Kernel/Diagnostics/WorldDumper.cs | 56 ------- src/Engine.Kernel/World/GameObject.cs | 18 +++ src/Engine.Kernel/World/SceneFormat.cs | 126 +++++++++++++++ tests/Engine.Kernel.Tests/SceneFormatTests.cs | 145 ++++++++++++++++++ tests/Engine.Kernel.Tests/WorldDumperTests.cs | 68 -------- 6 files changed, 319 insertions(+), 133 deletions(-) delete mode 100644 src/Engine.Kernel/Diagnostics/WorldDumper.cs create mode 100644 src/Engine.Kernel/World/SceneFormat.cs create mode 100644 tests/Engine.Kernel.Tests/SceneFormatTests.cs delete mode 100644 tests/Engine.Kernel.Tests/WorldDumperTests.cs 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()); - } -}