diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs
index c4eb051..9eed51f 100644
--- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs
+++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs
@@ -4,6 +4,7 @@ using System.Linq;
using GameCult.Caching;
using GameCult.Mesh;
using MessagePack;
+using MessagePack.Formatters;
#nullable enable
@@ -11,6 +12,7 @@ namespace GameCult.Eve.Surface
{
[CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")]
[MessagePackObject]
+ [MessagePackFormatter(typeof(EveSurfaceDocumentCompatibilityFormatter))]
public sealed class EveSurfaceDocument
{
public const string DefaultType = "surface-state";
@@ -289,6 +291,7 @@ namespace GameCult.Eve.Surface
}
[MessagePackObject]
+ [MessagePackFormatter(typeof(EveCommandTemplateCompatibilityFormatter))]
public sealed class EveCommandTemplate
{
public EveCommandTemplate(CultMeshOperationBindingDescriptor operation)
@@ -318,7 +321,7 @@ namespace GameCult.Eve.Surface
public string Transport => Operation.RouteHint.Description ?? "";
}
- [CultDocument("gamecult.eve.command", SchemaId)]
+ [CultDocument("gamecult.eve.command_invocation", SchemaId)]
[MessagePackObject]
public sealed class EveSurfaceCommandRequest
{
@@ -411,4 +414,125 @@ namespace GameCult.Eve.Surface
[IgnoreMember]
public string CommandId => Operation.IdempotencyKey ?? "";
}
+
+ ///
+ /// Reads the current Eve surface envelope and the original seven-field v1 envelope.
+ ///
+ public sealed class EveSurfaceDocumentCompatibilityFormatter : IMessagePackFormatter
+ {
+ private const int LegacyFieldCount = 7;
+ private const int CurrentFieldCount = 9;
+
+ public void Serialize(ref MessagePackWriter writer, EveSurfaceDocument? value, MessagePackSerializerOptions options)
+ {
+ if (value == null) { writer.WriteNil(); return; }
+ writer.WriteArrayHeader(CurrentFieldCount);
+ writer.Write(value.Type);
+ writer.Write(value.Schema);
+ writer.Write(value.ProviderId);
+ writer.Write(value.ProviderKind);
+ writer.Write(value.Title);
+ writer.Write(value.Version);
+ writer.Write(value.UpdatedAtUtc);
+ Formatter(options).Serialize(ref writer, value.Surface, options);
+ Formatter>(options).Serialize(ref writer, value.Commands, options);
+ }
+
+ public EveSurfaceDocument? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
+ {
+ if (reader.TryReadNil()) return null;
+ if (reader.NextMessagePackType != MessagePackType.Array)
+ throw new MessagePackSerializationException("Eve surface document must be an array.");
+
+ options.Security.DepthStep(ref reader);
+ try
+ {
+ var fields = reader.ReadArrayHeader();
+ if (fields == LegacyFieldCount)
+ {
+ return new EveSurfaceDocument(
+ ReadString(ref reader), ReadString(ref reader), ReadString(ref reader),
+ reader.ReadInt64(), ReadString(ref reader),
+ Formatter(options).Deserialize(ref reader, options)!,
+ Formatter>(options).Deserialize(ref reader, options)
+ ?? Array.Empty());
+ }
+
+ var type = ReadString(ref reader, fields, 0);
+ var schema = ReadString(ref reader, fields, 1);
+ var providerId = ReadString(ref reader, fields, 2);
+ var providerKind = ReadString(ref reader, fields, 3);
+ var title = ReadString(ref reader, fields, 4);
+ var version = fields > 5 ? reader.ReadInt64() : 0;
+ var updatedAtUtc = ReadString(ref reader, fields, 6);
+ var surface = fields > 7 ? Formatter(options).Deserialize(ref reader, options) : null;
+ var commands = fields > 8 ? Formatter>(options).Deserialize(ref reader, options) : null;
+ for (var index = CurrentFieldCount; index < fields; index++) reader.Skip();
+ return new EveSurfaceDocument(
+ type, schema, providerId, providerKind, title, version, updatedAtUtc,
+ surface ?? throw new MessagePackSerializationException("Eve surface tree is required."),
+ commands ?? Array.Empty());
+ }
+ finally { reader.Depth--; }
+ }
+
+ private static IMessagePackFormatter Formatter(MessagePackSerializerOptions options) =>
+ options.Resolver.GetFormatter()
+ ?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}.");
+
+ private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? "";
+ private static string ReadString(ref MessagePackReader reader, int fields, int index) =>
+ index < fields ? ReadString(ref reader) : "";
+ }
+
+ ///
+ /// Reads both direct legacy command fields and the current wrapped binding record.
+ ///
+ public sealed class EveCommandTemplateCompatibilityFormatter : IMessagePackFormatter
+ {
+ public void Serialize(ref MessagePackWriter writer, EveCommandTemplate? value, MessagePackSerializerOptions options)
+ {
+ if (value == null) { writer.WriteNil(); return; }
+ writer.WriteArrayHeader(1);
+ BindingFormatter(options).Serialize(ref writer, value.OperationRecord, options);
+ }
+
+ public EveCommandTemplate? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
+ {
+ if (reader.TryReadNil()) return null;
+ if (reader.NextMessagePackType != MessagePackType.Array)
+ throw new MessagePackSerializationException("Eve command template must be an array.");
+
+ options.Security.DepthStep(ref reader);
+ try
+ {
+ var fields = reader.ReadArrayHeader();
+ if (fields == 1 && reader.NextMessagePackType == MessagePackType.Array)
+ {
+ return new EveCommandTemplate(
+ BindingFormatter(options).Deserialize(ref reader, options)
+ ?? new CultMeshOperationBindingRecord());
+ }
+
+ var operationId = ReadString(ref reader, fields, 0);
+ var label = ReadString(ref reader, fields, 1);
+ var schemaId = ReadString(ref reader, fields, 2);
+ var routeKind = ReadString(ref reader, fields, 3);
+ var routeDescription = ReadString(ref reader, fields, 4);
+ for (var index = 5; index < fields; index++) reader.Skip();
+ return new EveCommandTemplate(new CultMeshOperationBindingRecord(
+ operationId, label, schemaId, routeKind, routeDescription));
+ }
+ finally { reader.Depth--; }
+ }
+
+ private static IMessagePackFormatter BindingFormatter(
+ MessagePackSerializerOptions options) =>
+ options.Resolver.GetFormatter()
+ ?? throw new MessagePackSerializationException("No CultMesh operation binding formatter is registered.");
+
+ private static string ReadString(ref MessagePackReader reader, int fields, int index) =>
+ index < fields ? reader.ReadString() ?? "" : "";
+ }
+
}
diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef
index 8041c88..7f19662 100644
--- a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef
+++ b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef
@@ -11,7 +11,8 @@
"precompiledReferences": [
"GameCult.Caching.dll",
"GameCult.Mesh.dll",
- "MessagePack.Annotations.dll"
+ "MessagePack.Annotations.dll",
+ "MessagePack.dll"
],
"autoReferenced": true,
"defineConstraints": [],
diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore b/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore
new file mode 100644
index 0000000..296b87c
--- /dev/null
+++ b/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore
@@ -0,0 +1 @@
+!GameCult.Eve.Surface.Serialization.Tests.csproj
diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs
new file mode 100644
index 0000000..b198b2e
--- /dev/null
+++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs
@@ -0,0 +1,129 @@
+using System.Buffers;
+using GameCult.Eve.Surface;
+using GameCult.Mesh;
+using MessagePack;
+using NUnit.Framework;
+
+namespace GameCult.Eve.Surface.Tests;
+
+[TestFixture]
+public sealed class EveSurfaceSerializationCompatibilityTests
+{
+ private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard;
+
+ [Test]
+ public void LegacySevenFieldSurfaceDocumentDeserializes()
+ {
+ var bytes = WriteLegacySurfaceDocument();
+
+ var document = MessagePackSerializer.Deserialize(bytes, Options);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(document.Type, Is.EqualTo(EveSurfaceDocument.DefaultType));
+ Assert.That(document.Schema, Is.EqualTo(EveSurfaceDocument.SchemaId));
+ Assert.That(document.ProviderId, Is.EqualTo("legacy-provider"));
+ Assert.That(document.ProviderKind, Is.EqualTo("legacy-kind"));
+ Assert.That(document.Title, Is.EqualTo("Legacy surface"));
+ Assert.That(document.Version, Is.EqualTo(17));
+ Assert.That(document.UpdatedAtUtc, Is.EqualTo("2026-07-13T12:00:00Z"));
+ Assert.That(document.Surface.Id, Is.EqualTo("legacy-surface"));
+ Assert.That(document.Commands, Has.Count.EqualTo(1));
+ Assert.That(document.Commands[0].Command, Is.EqualTo("legacy.execute"));
+ });
+ }
+
+ [Test]
+ public void LegacyDirectFiveStringCommandTemplateDeserializes()
+ {
+ var bytes = WriteLegacyCommandTemplate();
+
+ var command = MessagePackSerializer.Deserialize(bytes, Options);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(command.Command, Is.EqualTo("legacy.execute"));
+ Assert.That(command.Label, Is.EqualTo("Execute legacy command"));
+ Assert.That(command.Operation.SchemaId, Is.EqualTo("gamecult.legacy.command.v1"));
+ Assert.That(command.OperationRecord.RouteKind, Is.EqualTo("cultmesh"));
+ Assert.That(command.OperationRecord.RouteDescription, Is.EqualTo("legacy.commands"));
+ Assert.That(command.Transport, Is.EqualTo("legacy.commands"));
+ });
+ }
+
+ [Test]
+ public void CurrentSerializationWritesNineFieldSurfaceAndWrappedStructuredCommandBinding()
+ {
+ var document = CreateCurrentDocument();
+
+ var bytes = MessagePackSerializer.Serialize(document, Options);
+ var reader = new MessagePackReader(bytes);
+
+ Assert.That(reader.ReadArrayHeader(), Is.EqualTo(9));
+ for (var field = 0; field < 8; field++)
+ reader.Skip();
+ Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "commands collection");
+ Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "command template wrapper");
+ Assert.That(reader.NextMessagePackType, Is.EqualTo(MessagePackType.Array), "structured operation binding");
+
+ var roundTrip = MessagePackSerializer.Deserialize(bytes, Options);
+ Assert.That(roundTrip.Commands[0].Command, Is.EqualTo("current.execute"));
+ }
+
+ private static EveSurfaceDocument CreateCurrentDocument() => new(
+ "current-provider",
+ "current-kind",
+ "Current surface",
+ 23,
+ "2026-07-13T13:00:00Z",
+ CreateSurfaceTree("current-surface"),
+ new[]
+ {
+ new EveCommandTemplate(new CultMeshOperationBindingRecord(
+ "current.execute",
+ "Execute current command",
+ "gamecult.current.command.v1",
+ "cultmesh",
+ "current.commands"))
+ });
+
+ private static EveSurfaceTree CreateSurfaceTree(string id) => new(
+ id,
+ new EveSurfaceComponent(
+ "root",
+ "column",
+ new Dictionary(),
+ Array.Empty()),
+ Array.Empty());
+
+ private static byte[] WriteLegacySurfaceDocument()
+ {
+ var buffer = new ArrayBufferWriter();
+ var writer = new MessagePackWriter(buffer);
+ writer.WriteArrayHeader(7);
+ writer.Write("legacy-provider");
+ writer.Write("legacy-kind");
+ writer.Write("Legacy surface");
+ writer.Write(17L);
+ writer.Write("2026-07-13T12:00:00Z");
+ writer.WriteRaw(MessagePackSerializer.Serialize(CreateSurfaceTree("legacy-surface"), Options));
+ writer.WriteArrayHeader(1);
+ writer.WriteRaw(WriteLegacyCommandTemplate());
+ writer.Flush();
+ return buffer.WrittenSpan.ToArray();
+ }
+
+ private static byte[] WriteLegacyCommandTemplate()
+ {
+ var buffer = new ArrayBufferWriter();
+ var writer = new MessagePackWriter(buffer);
+ writer.WriteArrayHeader(5);
+ writer.Write("legacy.execute");
+ writer.Write("Execute legacy command");
+ writer.Write("gamecult.legacy.command.v1");
+ writer.Write("cultmesh");
+ writer.Write("legacy.commands");
+ writer.Flush();
+ return buffer.WrittenSpan.ToArray();
+ }
+}
diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj b/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj
new file mode 100644
index 0000000..e3c947a
--- /dev/null
+++ b/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ obj\bin\
+
+
+
+
+
+
+
+
+
+
+
+