M2, part one: scene format — World actually saves and loads now

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
Emil
2026-09-02 05:07:14 +03:00
co-authored by Claude Sonnet 5
parent 853a81a893
commit 45daa6e114
6 changed files with 319 additions and 133 deletions
+18
View File
@@ -108,6 +108,24 @@ public sealed class GameObject
return component;
}
/// <summary>
/// Attaches an already-constructed component rather than building an
/// empty one — for a caller that only has a runtime <see cref="Type"/>,
/// not a compile-time <c>T</c>. Scene loading is the reason this
/// exists: it deserializes a component straight from JSON into a real
/// instance via <c>JsonSerializer.Deserialize(json, componentType)</c>,
/// and would otherwise need reflection just to call the generic
/// overload above.
/// </summary>
public Component AddComponent(Component component)
{
SystemAccessScope.CheckWrite(component.GetType());
_components.Add(component);
Owner?.IndexComponentAdded(this, component);
return component;
}
public void RemoveComponent<T>() where T : Component
{
SystemAccessScope.CheckWrite(typeof(T));
+126
View File
@@ -0,0 +1,126 @@
using System.Numerics;
using System.Text.Json;
namespace Engine.Kernel.World;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Additive: creates whatever this file describes in <paramref
/// name="world"/> 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.
/// </summary>
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<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 = 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];
}