diff --git a/.gitignore b/.gitignore
index 7f96da8..7a4e50c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,8 @@ packages/eve-contracts/node_modules/
packages/eve-contracts/*.tgz
!packages/org.gamecult.eve.surface/
!packages/org.gamecult.eve.surface/**
+packages/org.gamecult.eve.surface/bin~/
+packages/org.gamecult.eve.surface/obj~/
!packages/org.gamecult.eve.unity-uitoolkit/
!packages/org.gamecult.eve.unity-uitoolkit/**
obj/
diff --git a/README.md b/README.md
index 1f45762..476ec0a 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,11 @@ effects; Eve owns projection, input capture, timestamped sensor publication,
and renderer parity. If a pretty panel cannot say which fields are stale, denied,
predicted, or authoritative, it is not done. It is flirting with the operator.
+The renderer-neutral C# contract lives at
+`packages/org.gamecult.eve.surface`. Provider daemons and lowerers consume that
+Eve-owned package; renderer repositories do not own copies of the surface
+schema or composition builders.
+
## Current Mechanism
The current checked-in client is the iOS Theos app, still named `EveCanvas` at
diff --git a/packages/org.gamecult.eve.surface/Directory.Build.props b/packages/org.gamecult.eve.surface/Directory.Build.props
new file mode 100644
index 0000000..6984c05
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+ $(MSBuildThisFileDirectory)bin~\
+ $(MSBuildThisFileDirectory)obj~\
+ $(MSBuildThisFileDirectory)obj~\
+
+
+
+
diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj
new file mode 100644
index 0000000..442ae1a
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj
@@ -0,0 +1,17 @@
+
+
+ netstandard2.1
+ enable
+ latest
+ enable
+ Portable Eve surface and provider contracts.
+ $(MSBuildThisFileDirectory)..\..\..\CultLib
+
+
+
+
+
+
+
+
+
diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta
new file mode 100644
index 0000000..9b36186
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj.meta
@@ -0,0 +1,9 @@
+fileFormatVersion: 2
+guid: ce2d4913d64690c44ae68935014fd79d
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+
+
diff --git a/packages/org.gamecult.eve.surface/README.md b/packages/org.gamecult.eve.surface/README.md
new file mode 100644
index 0000000..5138bbd
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/README.md
@@ -0,0 +1,36 @@
+# Eve Surface Contract
+
+Eve owns this renderer-neutral package. It defines the retained CultUI surface
+document, provider advertisement, asset catalog, input vocabulary, and builder
+primitives shared by providers and lowerers. Headless daemons depend on this
+package without depending on EveUnity; Unity is one consumer among many.
+
+Components own normal child layout through `Children`, live CultMesh value
+bindings through `StateBindings`, and nested synced document regions through
+`EmbeddedDocuments`. An embedded document slot names the child document id,
+schema, presentation kind, and route hint; renderers resolve that child through
+CultMesh instead of rebuilding it with runtime-local projection code.
+
+See `../../docs/surface-contract-v1.md` for the full contract and
+`../../web/fixtures/cultui-embedded-surface.json` for the parity fixture.
+Runtime discovery and required feature coverage live in
+`../../tools/parity/parity-manifest.json`; active GUI runtimes must list
+`embeddedDocuments` as a supported feature and require the `embedded-surface`
+fixture.
+
+Verification entrypoints:
+
+- C# serialization: `dotnet test tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj`
+- Browser/TypeScript: `node --test web\eve-dsl.test.mjs`
+- Semantic runtime matrix: `powershell -ExecutionPolicy Bypass -File .\scripts\run-parity-harness.ps1`
+- Flutter GUI contract, when Flutter is installed: `flutter test --plain-name embedded_surface_fixture_contract`
+- Rust/CultMesh document sync: run `cargo test rust_preserves_cultui_embedded_surface_slots_through_typed_document_sync` from the sibling `CultLib/packages/cultnet-rs` directory.
+
+The parity manifest is the discovery source for web, Flutter desktop/Linux/Android,
+iOS/UIKit, Android/Kotlin, Unity UI Toolkit, and Rust contract coverage. Do not
+add a new renderer claim without adding its nested-surface fixture requirement
+there.
+
+EveUnity must not carry another live copy of this package. Its Unity Scene and
+UI Toolkit packages depend on the released Eve-owned package.
+
diff --git a/packages/org.gamecult.eve.surface/README.md.meta b/packages/org.gamecult.eve.surface/README.md.meta
new file mode 100644
index 0000000..0df23ca
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/README.md.meta
@@ -0,0 +1,9 @@
+fileFormatVersion: 2
+guid: fe5aa27636bead047b2aecb14fdc59f2
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime.meta b/packages/org.gamecult.eve.surface/Runtime.meta
new file mode 100644
index 0000000..3e9c8ce
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime.meta
@@ -0,0 +1,10 @@
+fileFormatVersion: 2
+guid: 2164f6fea1216214d895e7dc99c46ecf
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs
new file mode 100644
index 0000000..aad726d
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using GameCult.Caching;
+using MessagePack;
+
+#nullable enable
+
+namespace GameCult.Eve.Surface
+{
+ [CultDocument("gamecult.eve.asset_catalog", SchemaId)]
+ [MessagePackObject]
+ public sealed class EveAssetCatalogDocument
+ {
+ public const string SchemaId = "gamecult.eve.asset_catalog.v1";
+
+ [SerializationConstructor]
+ public EveAssetCatalogDocument(
+ string schema,
+ string providerId,
+ string catalogId,
+ long version,
+ string updatedAtUtc,
+ EveAssetCatalogEntry[] assets)
+ {
+ Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
+ ProviderId = providerId ?? "";
+ CatalogId = catalogId ?? "";
+ Version = version;
+ UpdatedAtUtc = updatedAtUtc ?? "";
+ Assets = assets ?? Array.Empty();
+ }
+
+ public EveAssetCatalogDocument(
+ string providerId,
+ string catalogId,
+ long version,
+ string updatedAtUtc,
+ EveAssetCatalogEntry[] assets)
+ : this(SchemaId, providerId, catalogId, version, updatedAtUtc, assets)
+ {
+ }
+
+ [Key(0)] public string Schema { get; }
+ [Key(1)] public string ProviderId { get; }
+ [Key(2)] public string CatalogId { get; }
+ [Key(3)] public long Version { get; }
+ [Key(4)] public string UpdatedAtUtc { get; }
+ [Key(5)] public EveAssetCatalogEntry[] Assets { get; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveAssetCatalogEntry
+ {
+ [SerializationConstructor]
+ public EveAssetCatalogEntry(
+ string assetRef,
+ string assetKind,
+ EveAssetVariant[] variants,
+ Dictionary metadata)
+ {
+ AssetRef = assetRef ?? "";
+ AssetKind = assetKind ?? "";
+ Variants = variants ?? Array.Empty();
+ Metadata = metadata == null
+ ? new Dictionary(StringComparer.Ordinal)
+ : new Dictionary(metadata, StringComparer.Ordinal);
+ }
+
+ public EveAssetCatalogEntry(
+ string assetRef,
+ string assetKind,
+ EveAssetVariant[] variants,
+ IReadOnlyDictionary? metadata = null)
+ : this(
+ assetRef,
+ assetKind,
+ variants,
+ metadata == null ? new Dictionary() : new Dictionary(metadata))
+ {
+ }
+
+ [Key(0)] public string AssetRef { get; }
+ [Key(1)] public string AssetKind { get; }
+ [Key(2)] public EveAssetVariant[] Variants { get; }
+ [Key(3)] public Dictionary Metadata { get; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveAssetVariant
+ {
+ [SerializationConstructor]
+ public EveAssetVariant(
+ string runtimeId,
+ string platform,
+ string format,
+ string uri,
+ string contentHash,
+ long sizeBytes,
+ string assetKey,
+ Dictionary metadata)
+ {
+ RuntimeId = runtimeId ?? "";
+ Platform = platform ?? "";
+ Format = format ?? "";
+ Uri = uri ?? "";
+ ContentHash = contentHash ?? "";
+ SizeBytes = sizeBytes;
+ AssetKey = assetKey ?? "";
+ Metadata = metadata == null
+ ? new Dictionary(StringComparer.Ordinal)
+ : new Dictionary(metadata, StringComparer.Ordinal);
+ }
+
+ public EveAssetVariant(
+ string runtimeId,
+ string platform,
+ string format,
+ string uri,
+ string contentHash,
+ long sizeBytes,
+ string assetKey,
+ IReadOnlyDictionary? metadata = null)
+ : this(
+ runtimeId,
+ platform,
+ format,
+ uri,
+ contentHash,
+ sizeBytes,
+ assetKey,
+ metadata == null ? new Dictionary() : new Dictionary(metadata))
+ {
+ }
+
+ [Key(0)] public string RuntimeId { get; }
+ [Key(1)] public string Platform { get; }
+ [Key(2)] public string Format { get; }
+ [Key(3)] public string Uri { get; }
+ [Key(4)] public string ContentHash { get; }
+ [Key(5)] public long SizeBytes { get; }
+ [Key(6)] public string AssetKey { get; }
+ [Key(7)] public Dictionary Metadata { get; }
+ }
+}
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta
new file mode 100644
index 0000000..32ba45b
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 8f53654f7c98d894588127ba001f11b4
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs
new file mode 100644
index 0000000..5bd7ec7
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs
@@ -0,0 +1,98 @@
+using System;
+using GameCult.Caching;
+using MessagePack;
+
+#nullable enable
+
+namespace GameCult.Eve.Surface
+{
+ [CultDocument("gamecult.eve.entity_soa_view", SchemaId)]
+ [MessagePackObject]
+ public sealed class EveEntitySoaViewDocument
+ {
+ public const string SchemaId = "gamecult.eve.entity_soa_view.v2";
+
+ [Key(0)] public string Schema { get; set; } = SchemaId;
+ [Key(1)] public string ProviderId { get; set; } = "";
+ [Key(2)] public string ViewId { get; set; } = "";
+ [Key(3)] public string PublishedAtUtc { get; set; } = "";
+ [Key(4)] public string BodySchemaId { get; set; } = "";
+ [Key(5)] public int LayoutVersion { get; set; }
+ [Key(6)] public long ProducerEpoch { get; set; }
+ [Key(7)] public long Sequence { get; set; }
+ [Key(8)] public int Capacity { get; set; }
+ [Key(9)] public EveEntitySoaBuffer[] Buffers { get; set; } = Array.Empty();
+ [Key(10)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty();
+ [Key(11)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty();
+ [Key(12)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty();
+ [Key(13)] public EveEntityIdentity[] Identities { get; set; } = Array.Empty();
+ [Key(14)] public long FrameId { get; set; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveEntitySoaBuffer
+ {
+ [Key(0)] public string BufferId { get; set; } = "";
+ [Key(1)] public long ByteOffset { get; set; }
+ [Key(2)] public long ByteLength { get; set; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveEntitySoaColumn
+ {
+ [Key(0)] public string ColumnId { get; set; } = "";
+ [Key(1)] public string Semantic { get; set; } = "";
+ [Key(2)] public string BufferId { get; set; } = "";
+ [Key(3)] public string ScalarType { get; set; } = "";
+ [Key(4)] public long ByteOffset { get; set; }
+ [Key(5)] public int ElementStride { get; set; }
+ [Key(6)] public int ElementCount { get; set; }
+ [Key(7)] public string Unit { get; set; } = "";
+ [Key(8)] public string CoordinateSpace { get; set; } = "";
+ }
+
+ [MessagePackObject]
+ public sealed class EveEntitySoaDirtyRange
+ {
+ [Key(0)] public string ColumnId { get; set; } = "";
+ [Key(1)] public int StartIndex { get; set; }
+ [Key(2)] public int Count { get; set; }
+ [Key(3)] public long Sequence { get; set; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveEntityIdentity
+ {
+ [Key(0)] public int Index { get; set; }
+ [Key(1)] public string EntityId { get; set; } = "";
+ [Key(2)] public string EntityKind { get; set; } = "";
+ [Key(3)] public string Label { get; set; } = "";
+ [Key(4)] public string Faction { get; set; } = "";
+ [Key(5)] public bool Selectable { get; set; }
+ [Key(6)] public bool Controllable { get; set; }
+ [Key(7)] public string AssetRef { get; set; } = "";
+ }
+
+ [MessagePackObject]
+ public sealed class EveEntityRenderGroup
+ {
+ [Key(0)] public int GroupId { get; set; }
+ [Key(1)] public string MeshAssetRef { get; set; } = "";
+ [Key(2)] public string MaterialAssetRef { get; set; } = "";
+ [Key(3)] public int SubMeshIndex { get; set; }
+ [Key(4)] public int Layer { get; set; }
+ [Key(5)] public int InstanceCount { get; set; }
+ [Key(6)] public float DefaultScale { get; set; } = 1f;
+ [Key(7)] public float BoundsCenterX { get; set; }
+ [Key(8)] public float BoundsCenterY { get; set; }
+ [Key(9)] public float BoundsCenterZ { get; set; }
+ [Key(10)] public float BoundsSizeX { get; set; }
+ [Key(11)] public float BoundsSizeY { get; set; }
+ [Key(12)] public float BoundsSizeZ { get; set; }
+ [Key(13)] public string ShadowMode { get; set; } = "on";
+ [Key(14)] public bool ReceiveShadows { get; set; } = true;
+ [Key(15)] public int Lod { get; set; } = -1;
+ }
+}
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta
new file mode 100644
index 0000000..4c20d19
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs.meta
@@ -0,0 +1,13 @@
+fileFormatVersion: 2
+guid: 8e87e1be783846508efea45787f2d45c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs
new file mode 100644
index 0000000..88727fc
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using GameCult.Caching;
+using MessagePack;
+
+namespace GameCult.Eve.Surface
+{
+ [CultDocument("gamecult.eve.input_capability", SchemaId)]
+ [MessagePackObject]
+ public sealed class EveInputCapabilityDocument
+ {
+ public const string SchemaId = "gamecult.eve.input_capability.v1";
+ [Key(0)] public string Schema { get; set; } = SchemaId;
+ [Key(1)] public string ProviderId { get; set; } = "";
+ [Key(2)] public string CapabilityId { get; set; } = "";
+ [Key(3)] public long Version { get; set; }
+ [Key(4)] public EveInputActionDocument[] Actions { get; set; } = Array.Empty();
+ [Key(5)] public EveInputProfileDocument[] DefaultProfiles { get; set; } = Array.Empty();
+ }
+
+ [MessagePackObject]
+ public sealed class EveInputActionDocument
+ {
+ [Key(0)] public string ActionId { get; set; } = "";
+ [Key(1)] public string Label { get; set; } = "";
+ [Key(2)] public string Operation { get; set; } = "";
+ [Key(3)] public string Context { get; set; } = "pilot";
+ [Key(4)] public string Category { get; set; } = "ship";
+ [Key(5)] public string Availability { get; set; } = "available";
+ [Key(6)] public string SourceRef { get; set; } = "";
+ [Key(7)] public Dictionary Payload { get; set; } = new Dictionary(StringComparer.Ordinal);
+ [Key(8)] public EveInputValueDocument? InputValue { get; set; }
+ [Key(9)] public string IconRef { get; set; } = "";
+ [Key(10)] public bool ActionBar { get; set; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveInputValueDocument
+ {
+ [Key(0)] public string Model { get; set; } = "";
+ [Key(1)] public string PayloadKey { get; set; } = "";
+ [Key(2)] public string[] PayloadKeys { get; set; } = Array.Empty();
+ [Key(3)] public double? CurrentValue { get; set; }
+ [Key(4)] public double? MinimumValue { get; set; }
+ [Key(5)] public double? MaximumValue { get; set; }
+ [Key(6)] public double? StepValue { get; set; }
+ [Key(7)] public string Unit { get; set; } = "";
+ }
+
+ [MessagePackObject]
+ public sealed class EveInputProfileDocument
+ {
+ [Key(0)] public string ProfileId { get; set; } = "";
+ [Key(1)] public string DeviceClass { get; set; } = "";
+ [Key(2)] public EveInputBindingDocument[] Bindings { get; set; } = Array.Empty();
+ }
+
+ [MessagePackObject]
+ public sealed class EveInputBindingDocument
+ {
+ [Key(0)] public string BindingId { get; set; } = "";
+ [Key(1)] public string ActionId { get; set; } = "";
+ [Key(2)] public EveInputGestureDocument Gesture { get; set; } = new EveInputGestureDocument();
+ [Key(3)] public bool ActionBar { get; set; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveInputGestureDocument
+ {
+ [Key(0)] public string Kind { get; set; } = "direct";
+ [Key(1)] public string[] Controls { get; set; } = Array.Empty();
+ [Key(2)] public int MaxStepIntervalMs { get; set; } = 650;
+ [Key(3)] public string CompletionControl { get; set; } = "";
+ }
+}
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta
new file mode 100644
index 0000000..61e268a
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs.meta
@@ -0,0 +1,4 @@
+fileFormatVersion: 2
+guid: 4c297c7dbb64a0f49a7f805e770c5e55
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs
new file mode 100644
index 0000000..a961c08
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+
+namespace GameCult.Eve.Surface
+{
+ public sealed class EveInputDeviceLayout
+ {
+ public string LayoutId { get; set; } = "";
+ public string DeviceClass { get; set; } = "";
+ public IReadOnlyList Controls { get; set; } = Array.Empty();
+ public bool SupportsDirectionalStrings { get; set; }
+
+ public static EveInputDeviceLayout AnsiKeyboardMouse() => new EveInputDeviceLayout
+ {
+ LayoutId = "keyboard.ansi-104+mouse.standard",
+ DeviceClass = "keyboard-mouse",
+ Controls = new[]
+ {
+ Key("keyboard.escape", "Esc", 0, 0, 1.25f), Key("keyboard.f1", "F1", 2, 0), Key("keyboard.f2", "F2", 3, 0),
+ Key("keyboard.1", "1", 1, 1), Key("keyboard.2", "2", 2, 1), Key("keyboard.3", "3", 3, 1), Key("keyboard.4", "4", 4, 1), Key("keyboard.5", "5", 5, 1),
+ Key("keyboard.tab", "Tab", 0, 2, 1.5f), Key("keyboard.q", "Q", 1.5f, 2), Key("keyboard.w", "W", 2.5f, 2), Key("keyboard.e", "E", 3.5f, 2), Key("keyboard.r", "R", 4.5f, 2),
+ Key("keyboard.a", "A", 1.75f, 3), Key("keyboard.s", "S", 2.75f, 3), Key("keyboard.d", "D", 3.75f, 3), Key("keyboard.f", "F", 4.75f, 3),
+ Key("keyboard.leftShift", "Shift", 0, 4, 2.25f), Key("keyboard.space", "Space", 3.75f, 5, 6.25f),
+ Key("mouse.primary", "Mouse 1", 18, 1), Key("mouse.secondary", "Mouse 2", 19, 1), Key("mouse.middle", "Mouse 3", 18.5f, 2)
+ }
+ };
+
+ public static EveInputDeviceLayout StandardGamepad() => new EveInputDeviceLayout
+ {
+ LayoutId = "gamepad.standard",
+ DeviceClass = "gamepad",
+ SupportsDirectionalStrings = true,
+ Controls = new[]
+ {
+ Key("gamepad.leftStick", "Left Stick", 2, 3, 2), Key("gamepad.rightStick", "Right Stick", 8, 4, 2),
+ Key("gamepad.dpad.up", "Up", 1, 1), Key("gamepad.dpad.left", "Left", 0, 2), Key("gamepad.dpad.right", "Right", 2, 2), Key("gamepad.dpad.down", "Down", 1, 3),
+ Key("gamepad.buttonSouth", "A / Cross", 10, 3), Key("gamepad.buttonEast", "B / Circle", 11, 2), Key("gamepad.buttonWest", "X / Square", 9, 2), Key("gamepad.buttonNorth", "Y / Triangle", 10, 1),
+ Key("gamepad.leftTrigger", "LT / L2", 2, 0, 2), Key("gamepad.rightTrigger", "RT / R2", 9, 0, 2)
+ }
+ };
+
+ private static EveInputLayoutControl Key(string id, string label, float x, float y, float width = 1) =>
+ new EveInputLayoutControl { Control = id, Label = label, X = x, Y = y, Width = width, Height = 1 };
+ }
+
+ public sealed class EveInputLayoutControl
+ {
+ public string Control { get; set; } = "";
+ public string Label { get; set; } = "";
+ public float X { get; set; }
+ public float Y { get; set; }
+ public float Width { get; set; } = 1;
+ public float Height { get; set; } = 1;
+ }
+}
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta
new file mode 100644
index 0000000..2c04385
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveInputDeviceLayouts.cs.meta
@@ -0,0 +1,4 @@
+fileFormatVersion: 2
+guid: 8a2a1258551e42a9831a3e4f9483d928
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs
new file mode 100644
index 0000000..ddaf856
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GameCult.Eve.Surface
+{
+ public sealed class EveInputGesture
+ {
+ public string Kind { get; set; } = "direct";
+ public IReadOnlyList Controls { get; set; } = Array.Empty();
+ public int MaxStepIntervalMs { get; set; } = 650;
+ public string CompletionControl { get; set; } = "";
+ }
+
+ public sealed class EveInputBinding
+ {
+ public string BindingId { get; set; } = "";
+ public string ActionId { get; set; } = "";
+ public EveInputGesture Gesture { get; set; } = new EveInputGesture();
+ public bool ActionBar { get; set; }
+ }
+
+ public sealed class EveInputGestureResolver
+ {
+ private readonly IReadOnlyList _bindings;
+ private readonly HashSet _pressed = new HashSet(StringComparer.Ordinal);
+ private readonly Dictionary _sequences = new Dictionary(StringComparer.Ordinal);
+
+ public EveInputGestureResolver(IEnumerable bindings)
+ {
+ _bindings = (bindings ?? Array.Empty()).Where(binding => binding != null).ToArray();
+ }
+
+ public IReadOnlyList Press(string control, long timestampMs)
+ {
+ _pressed.Add(control ?? "");
+ var actions = new HashSet(StringComparer.Ordinal);
+ foreach (var binding in _bindings)
+ {
+ var gesture = binding.Gesture ?? new EveInputGesture();
+ if (gesture.Kind == "direct" && gesture.Controls.FirstOrDefault() == control)
+ actions.Add(binding.ActionId);
+ else if (gesture.Kind == "chord" && gesture.Controls.Count > 0 && gesture.Controls.All(_pressed.Contains))
+ actions.Add(binding.ActionId);
+ else if (gesture.Kind == "sequence" && Advance(binding, control ?? "", timestampMs))
+ actions.Add(binding.ActionId);
+ }
+ return actions.ToArray();
+ }
+
+ public void Release(string control) => _pressed.Remove(control ?? "");
+
+ public void Reset()
+ {
+ _pressed.Clear();
+ _sequences.Clear();
+ }
+
+ private bool Advance(EveInputBinding binding, string control, long timestampMs)
+ {
+ var controls = binding.Gesture.Controls;
+ if (controls.Count == 0) return false;
+ _sequences.TryGetValue(binding.BindingId, out var previous);
+ var index = previous.Index > 0 && timestampMs - previous.TimestampMs <= Math.Max(50, binding.Gesture.MaxStepIntervalMs) ? previous.Index : 0;
+ var next = controls[index] == control ? index + 1 : controls[0] == control ? 1 : 0;
+ if (next == controls.Count)
+ {
+ _sequences.Remove(binding.BindingId);
+ return string.IsNullOrWhiteSpace(binding.Gesture.CompletionControl);
+ }
+ _sequences[binding.BindingId] = new SequenceState(next, timestampMs);
+ return false;
+ }
+
+ private readonly struct SequenceState
+ {
+ public SequenceState(int index, long timestampMs) { Index = index; TimestampMs = timestampMs; }
+ public int Index { get; }
+ public long TimestampMs { get; }
+ }
+ }
+}
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta
new file mode 100644
index 0000000..5c24939
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveInputGestures.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: e3c2941be243a754092ac2543d1e6bf7
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs
new file mode 100644
index 0000000..21490a9
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using GameCult.Mesh;
+
+#nullable enable
+
+namespace GameCult.Eve.Surface
+{
+ public static class EveInventoryInteraction
+ {
+ public const string GridKind = "inventory.grid";
+ public const string ItemKind = "inventory.item";
+ public const string DragSessionKind = "inventory.drag_session";
+
+ public static bool TryCreateDropRequest(
+ EveSurfaceDocument document,
+ EveSurfaceComponent source,
+ EveSurfaceComponent target,
+ int destinationX,
+ int destinationY,
+ string clientId,
+ out EveSurfaceCommandRequest? request)
+ {
+ request = null;
+ if (document == null || source == null || target == null ||
+ !string.Equals(source.Kind, ItemKind, StringComparison.Ordinal) ||
+ !string.Equals(target.Kind, GridKind, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ var sourceKind = source.GetProp("sourceKind", source.GetProp("source"));
+ var command = target.GetProp($"dropCommand.{sourceKind}", target.GetProp("dropCommand"));
+ if (string.IsNullOrWhiteSpace(sourceKind) || string.IsNullOrWhiteSpace(command))
+ return false;
+
+ var sourceIndex = Int(source.GetProp("sourceIndex"), -1);
+ var targetIndex = Int(target.GetProp("targetIndex"), -1);
+ var targetKind = target.GetProp("targetKind");
+ var payload = new Dictionary(StringComparer.Ordinal)
+ {
+ ["sourceKind"] = sourceKind,
+ ["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey")),
+ ["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture),
+ ["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1)
+ .ToString(CultureInfo.InvariantCulture),
+ ["itemKey"] = source.GetProp("itemKey"),
+ ["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture),
+ ["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture),
+ ["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture),
+ ["destinationKind"] = targetKind,
+ ["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey")),
+ ["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture),
+ ["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1)
+ .ToString(CultureInfo.InvariantCulture),
+ ["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture),
+ ["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture),
+ ["hasDestinationPosition"] = "true"
+ };
+ var template = document.Commands.FirstOrDefault(candidate =>
+ string.Equals(candidate.Command, command, StringComparison.Ordinal));
+ var operation = template == null
+ ? CultMesh.OperationInvocation(command)
+ : CultMesh.OperationInvocation(template.Operation);
+ request = new EveSurfaceCommandRequest(
+ document.ProviderId,
+ document.Surface.Id,
+ operation,
+ CultMesh.OperationPayload(payload),
+ DateTimeOffset.UtcNow,
+ string.IsNullOrWhiteSpace(clientId) ? "unity-uitoolkit" : clientId);
+ return true;
+ }
+
+ private static int Int(string value, int fallback) =>
+ int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
+ ? parsed
+ : fallback;
+ }
+}
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta
new file mode 100644
index 0000000..110cb4b
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveInventoryInteraction.cs.meta
@@ -0,0 +1,4 @@
+fileFormatVersion: 2
+guid: 17fd4173e6e44b4b9c503d343021ffcb
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs
new file mode 100644
index 0000000..eb7cd62
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs
@@ -0,0 +1,416 @@
+using System;
+using System.Collections.Generic;
+using GameCult.Caching;
+using MessagePack;
+using MessagePack.Formatters;
+
+#nullable enable
+
+namespace GameCult.Eve.Surface
+{
+ [CultDocument("gamecult.eve.provider_advertisement", SchemaId)]
+ [MessagePackObject]
+ [MessagePackFormatter(typeof(EveProviderAdvertisementCompatibilityFormatter))]
+ public sealed class EveProviderAdvertisementDocument
+ {
+ public const string SchemaId = "gamecult.eve.provider_advertisement.v1";
+
+ public EveProviderAdvertisementDocument(
+ string providerId,
+ string serviceId,
+ string verseId,
+ string title,
+ string kind,
+ string cultMeshAddress,
+ string updatedAtUtc,
+ EveProviderFreshness freshness,
+ IReadOnlyList schemas,
+ IReadOnlyList witnesses,
+ IReadOnlyList surfaces,
+ IReadOnlyList commands,
+ IReadOnlyList? authorizedBodyProducerIds = null)
+ : this(SchemaId, providerId, serviceId, verseId, title, kind, cultMeshAddress, updatedAtUtc, freshness, schemas, witnesses, surfaces, commands, authorizedBodyProducerIds)
+ {
+ }
+
+ [SerializationConstructor]
+ public EveProviderAdvertisementDocument(
+ string schema,
+ string providerId,
+ string serviceId,
+ string verseId,
+ string title,
+ string kind,
+ string cultMeshAddress,
+ string updatedAtUtc,
+ EveProviderFreshness freshness,
+ IReadOnlyList schemas,
+ IReadOnlyList witnesses,
+ IReadOnlyList surfaces,
+ IReadOnlyList commands,
+ IReadOnlyList? authorizedBodyProducerIds = null)
+ {
+ Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
+ ProviderId = providerId ?? "";
+ ServiceId = serviceId ?? "";
+ VerseId = verseId ?? "";
+ Title = title ?? "";
+ Kind = kind ?? "";
+ CultMeshAddress = cultMeshAddress ?? "";
+ UpdatedAtUtc = updatedAtUtc ?? "";
+ Freshness = freshness ?? new EveProviderFreshness("unknown", "", 0);
+ Schemas = schemas ?? Array.Empty();
+ Witnesses = witnesses ?? Array.Empty();
+ Surfaces = surfaces ?? Array.Empty();
+ Commands = commands ?? Array.Empty();
+ AuthorizedBodyProducerIds = authorizedBodyProducerIds ?? Array.Empty();
+ }
+
+ [Key(0)] public string Schema { get; }
+ [Key(1)] public string ProviderId { get; }
+ [Key(2)] public string ServiceId { get; }
+ [Key(3)] public string VerseId { get; }
+ [Key(4)] public string Title { get; }
+ [Key(5)] public string Kind { get; }
+ [Key(6)] public string CultMeshAddress { get; }
+ [Key(7)] public string UpdatedAtUtc { get; }
+ [Key(8)] public EveProviderFreshness Freshness { get; }
+ [Key(9)] public IReadOnlyList Schemas { get; }
+ [Key(10)] public IReadOnlyList Witnesses { get; }
+ [Key(11)] public IReadOnlyList Surfaces { get; }
+ [Key(12)] public IReadOnlyList Commands { get; }
+ [Key(13)] public IReadOnlyList AuthorizedBodyProducerIds { get; }
+ }
+
+ ///
+ /// Reads canonical provider advertisements and the original Aetheria-expanded v1 layout.
+ ///
+ public sealed class EveProviderAdvertisementCompatibilityFormatter :
+ IMessagePackFormatter
+ {
+ private const int CurrentFieldCount = 14;
+ private const int LegacyFieldCount = 16;
+
+ public void Serialize(
+ ref MessagePackWriter writer,
+ EveProviderAdvertisementDocument? value,
+ MessagePackSerializerOptions options)
+ {
+ if (value == null) { writer.WriteNil(); return; }
+ writer.WriteArrayHeader(CurrentFieldCount);
+ writer.Write(value.Schema);
+ writer.Write(value.ProviderId);
+ writer.Write(value.ServiceId);
+ writer.Write(value.VerseId);
+ writer.Write(value.Title);
+ writer.Write(value.Kind);
+ writer.Write(value.CultMeshAddress);
+ writer.Write(value.UpdatedAtUtc);
+ Formatter(options).Serialize(ref writer, value.Freshness, options);
+ Formatter>(options).Serialize(ref writer, value.Schemas, options);
+ Formatter>(options).Serialize(ref writer, value.Witnesses, options);
+ Formatter>(options).Serialize(ref writer, value.Surfaces, options);
+ Formatter>(options).Serialize(ref writer, value.Commands, options);
+ Formatter>(options).Serialize(ref writer, value.AuthorizedBodyProducerIds, options);
+ }
+
+ public EveProviderAdvertisementDocument? Deserialize(
+ ref MessagePackReader reader,
+ MessagePackSerializerOptions options)
+ {
+ if (reader.TryReadNil()) return null;
+ if (reader.NextMessagePackType != MessagePackType.Array)
+ throw new MessagePackSerializationException("Eve provider advertisement must be an array.");
+
+ options.Security.DepthStep(ref reader);
+ try
+ {
+ var fields = reader.ReadArrayHeader();
+ if (fields == LegacyFieldCount)
+ return ReadLegacy(ref reader, options);
+
+ var schema = ReadString(ref reader, fields, 0);
+ var providerId = ReadString(ref reader, fields, 1);
+ var serviceId = ReadString(ref reader, fields, 2);
+ var verseId = ReadString(ref reader, fields, 3);
+ var title = ReadString(ref reader, fields, 4);
+ var kind = ReadString(ref reader, fields, 5);
+ var address = ReadString(ref reader, fields, 6);
+ var updatedAtUtc = ReadString(ref reader, fields, 7);
+ var freshness = fields > 8 ? Formatter(options).Deserialize(ref reader, options) : null;
+ var schemas = fields > 9 ? Formatter>(options).Deserialize(ref reader, options) : null;
+ var witnesses = fields > 10 ? Formatter>(options).Deserialize(ref reader, options) : null;
+ var surfaces = fields > 11 ? Formatter>(options).Deserialize(ref reader, options) : null;
+ var commands = fields > 12 ? Formatter>(options).Deserialize(ref reader, options) : null;
+ var authorizedBodyProducerIds = fields > 13 ? Formatter>(options).Deserialize(ref reader, options) : null;
+ for (var index = CurrentFieldCount; index < fields; index++) reader.Skip();
+ return new EveProviderAdvertisementDocument(
+ schema, providerId, serviceId, verseId, title, kind, address, updatedAtUtc,
+ freshness ?? new EveProviderFreshness("unknown", "", 0),
+ schemas ?? Array.Empty(),
+ witnesses ?? Array.Empty(),
+ surfaces ?? Array.Empty(),
+ commands ?? Array.Empty(),
+ authorizedBodyProducerIds ?? Array.Empty());
+ }
+ finally { reader.Depth--; }
+ }
+
+ private static EveProviderAdvertisementDocument ReadLegacy(
+ ref MessagePackReader reader,
+ MessagePackSerializerOptions options)
+ {
+ var schema = ReadString(ref reader);
+ var providerId = ReadString(ref reader);
+ var serviceId = ReadString(ref reader);
+ var verseId = ReadString(ref reader);
+ reader.Skip(); // RootVerse remains provider-owned settings, not portable advertisement state.
+ reader.Skip(); // CanonicalService
+ reader.Skip(); // LocatedService
+ var address = ReadString(ref reader);
+ var title = ReadString(ref reader);
+ var kind = ReadString(ref reader);
+ var updatedAtUtc = ReadString(ref reader);
+ var freshness = Formatter(options).Deserialize(ref reader, options)
+ ?? new EveProviderFreshness("unknown", "", 0);
+ var schemas = Formatter>(options).Deserialize(ref reader, options)
+ ?? Array.Empty();
+ var witnesses = Formatter>(options).Deserialize(ref reader, options)
+ ?? Array.Empty();
+ var surfaces = ReadLegacySurfaces(ref reader);
+ var commands = ReadLegacyCommands(ref reader);
+ return new EveProviderAdvertisementDocument(
+ schema, providerId, serviceId, verseId, title, kind, address, updatedAtUtc,
+ freshness, schemas, witnesses, surfaces, commands);
+ }
+
+ private static EveAdvertisedSurface[] ReadLegacySurfaces(ref MessagePackReader reader)
+ {
+ var count = reader.ReadArrayHeader();
+ var values = new EveAdvertisedSurface[count];
+ for (var index = 0; index < count; index++)
+ {
+ var fields = reader.ReadArrayHeader();
+ var schema = ReadString(ref reader, fields, 0);
+ var surfaceId = ReadString(ref reader, fields, 1);
+ var recordRef = ReadString(ref reader, fields, 2);
+ var transport = ReadString(ref reader, fields, 3);
+ var status = ReadString(ref reader, fields, 4);
+ for (var field = 5; field < fields; field++) reader.Skip();
+ values[index] = new EveAdvertisedSurface(
+ surfaceId, schema, recordRef, transport, status, "", null);
+ }
+ return values;
+ }
+
+ private static EveAdvertisedCommand[] ReadLegacyCommands(ref MessagePackReader reader)
+ {
+ var count = reader.ReadArrayHeader();
+ var values = new EveAdvertisedCommand[count];
+ for (var index = 0; index < count; index++)
+ {
+ var fields = reader.ReadArrayHeader();
+ var command = ReadString(ref reader, fields, 0);
+ var transport = ReadString(ref reader, fields, 1);
+ var summary = ReadString(ref reader, fields, 2);
+ for (var field = 3; field < fields; field++) reader.Skip();
+ values[index] = new EveAdvertisedCommand(command, "", transport, summary);
+ }
+ return values;
+ }
+
+ 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) : "";
+ }
+
+
+ [MessagePackObject]
+ public sealed class EveProviderFreshness
+ {
+ [SerializationConstructor]
+ public EveProviderFreshness(string state, string lastSeenAtUtc, long maxAgeMs)
+ {
+ State = state ?? "";
+ LastSeenAtUtc = lastSeenAtUtc ?? "";
+ MaxAgeMs = maxAgeMs;
+ }
+
+ [Key(0)] public string State { get; }
+ [Key(1)] public string LastSeenAtUtc { get; }
+ [Key(2)] public long MaxAgeMs { get; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveProviderWitness
+ {
+ [SerializationConstructor]
+ public EveProviderWitness(string kind, string reference, string summary)
+ {
+ Kind = kind ?? "";
+ Reference = reference ?? "";
+ Summary = summary ?? "";
+ }
+
+ [Key(0)] public string Kind { get; }
+ [Key(1)] public string Reference { get; }
+ [Key(2)] public string Summary { get; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveAdvertisedSurface
+ {
+ [SerializationConstructor]
+ public EveAdvertisedSurface(
+ string surfaceId,
+ string schema,
+ string recordRef,
+ string transport,
+ string status,
+ string surfaceKind,
+ EveWorldInteractionAdvertisement? worldInteraction = null)
+ {
+ SurfaceId = surfaceId ?? "";
+ Schema = schema ?? "";
+ RecordRef = recordRef ?? "";
+ Transport = transport ?? "";
+ Status = status ?? "";
+ SurfaceKind = surfaceKind ?? "";
+ WorldInteraction = worldInteraction;
+ }
+
+ [Key(0)] public string SurfaceId { get; }
+ [Key(1)] public string Schema { get; }
+ [Key(2)] public string RecordRef { get; }
+ [Key(3)] public string Transport { get; }
+ [Key(4)] public string Status { get; }
+ [Key(5)] public string SurfaceKind { get; }
+ [Key(6)] public EveWorldInteractionAdvertisement? WorldInteraction { get; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveWorldInteractionAdvertisement
+ {
+ [SerializationConstructor]
+ public EveWorldInteractionAdvertisement(
+ string projectionKind,
+ IReadOnlyList stateSchemas,
+ string commandBoundary,
+ string commandRecordRef,
+ string receiptSchema,
+ string receiptRecordRef,
+ string assetManifestRecordRef,
+ IReadOnlyList loweringTargets,
+ string ownership)
+ {
+ ProjectionKind = projectionKind ?? "";
+ StateSchemas = stateSchemas ?? Array.Empty();
+ CommandBoundary = commandBoundary ?? "";
+ CommandRecordRef = commandRecordRef ?? "";
+ ReceiptSchema = receiptSchema ?? "";
+ ReceiptRecordRef = receiptRecordRef ?? "";
+ AssetManifestRecordRef = assetManifestRecordRef ?? "";
+ LoweringTargets = loweringTargets ?? Array.Empty();
+ Ownership = ownership ?? "";
+ }
+
+ [Key(0)] public string ProjectionKind { get; }
+ [Key(1)] public IReadOnlyList StateSchemas { get; }
+ [Key(2)] public string CommandBoundary { get; }
+ [Key(3)] public string CommandRecordRef { get; }
+ [Key(4)] public string ReceiptSchema { get; }
+ [Key(5)] public string ReceiptRecordRef { get; }
+ [Key(6)] public string AssetManifestRecordRef { get; }
+ [Key(7)] public IReadOnlyList LoweringTargets { get; }
+ [Key(8)] public string Ownership { get; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveAdvertisedCommand
+ {
+ [SerializationConstructor]
+ public EveAdvertisedCommand(string command, string surfaceId, string transport, string summary)
+ {
+ Command = command ?? "";
+ SurfaceId = surfaceId ?? "";
+ Transport = transport ?? "";
+ Summary = summary ?? "";
+ }
+
+ [Key(0)] public string Command { get; }
+ [Key(1)] public string SurfaceId { get; }
+ [Key(2)] public string Transport { get; }
+ [Key(3)] public string Summary { get; }
+ }
+
+ [CultDocument("gamecult.eve.command_receipt", SchemaId)]
+ [MessagePackObject]
+ public sealed class EveCommandReceiptDocument
+ {
+ public const string SchemaId = "gamecult.eve.command_receipt.v1";
+
+ public EveCommandReceiptDocument(
+ string receiptId,
+ string commandId,
+ string command,
+ string state,
+ string ownerRepo,
+ string authority,
+ string providerId,
+ string surfaceId,
+ string message,
+ string issuedAtUtc,
+ long sourceVersion)
+ : this(SchemaId, receiptId, commandId, command, state, ownerRepo, authority, providerId, surfaceId, message, issuedAtUtc, sourceVersion)
+ {
+ }
+
+ [SerializationConstructor]
+ public EveCommandReceiptDocument(
+ string schema,
+ string receiptId,
+ string commandId,
+ string command,
+ string state,
+ string ownerRepo,
+ string authority,
+ string providerId,
+ string surfaceId,
+ string message,
+ string issuedAtUtc,
+ long sourceVersion)
+ {
+ Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
+ ReceiptId = receiptId ?? "";
+ CommandId = commandId ?? "";
+ Command = command ?? "";
+ State = state ?? "";
+ OwnerRepo = ownerRepo ?? "";
+ Authority = authority ?? "";
+ ProviderId = providerId ?? "";
+ SurfaceId = surfaceId ?? "";
+ Message = message ?? "";
+ IssuedAtUtc = issuedAtUtc ?? "";
+ SourceVersion = sourceVersion;
+ }
+
+ [Key(0)] public string Schema { get; }
+ [Key(1)] public string ReceiptId { get; }
+ [Key(2)] public string CommandId { get; }
+ [Key(3)] public string Command { get; }
+ [Key(4)] public string State { get; }
+ [Key(5)] public string OwnerRepo { get; }
+ [Key(6)] public string Authority { get; }
+ [Key(7)] public string ProviderId { get; }
+ [Key(8)] public string SurfaceId { get; }
+ [Key(9)] public string Message { get; }
+ [Key(10)] public string IssuedAtUtc { get; }
+ [Key(11)] public long SourceVersion { get; }
+ }
+}
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta
new file mode 100644
index 0000000..274e291
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: f7ea0cd07c4d5da4099b5acf8d44ca2e
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs
new file mode 100644
index 0000000..68ee612
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs
@@ -0,0 +1,876 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using GameCult.Mesh;
+
+#nullable enable
+
+namespace GameCult.Eve.Surface
+{
+ ///
+ /// Fluent CultUI/Eve surface builder. Eve owns the surface document; CultMesh only supplies typed binding descriptors.
+ ///
+ public sealed class EveSurfaceBuilder
+ {
+ private readonly string _surfaceId;
+ private readonly List _children = new();
+ private readonly List _styles = new();
+ private readonly List _commands = new();
+ private EveSurfaceComponent? _root;
+ private long _version = 1;
+ private string _providerId = "gamecult";
+ private string _providerKind = "cultui.surface";
+ private string _title = "";
+ private string _updatedAtUtc = "";
+
+ public EveSurfaceBuilder(string surfaceId)
+ {
+ _surfaceId = RequireNonEmpty(surfaceId, nameof(surfaceId));
+ }
+
+ public EveSurfaceBuilder Provider(string providerId, string providerKind)
+ {
+ _providerId = providerId ?? "";
+ _providerKind = providerKind ?? "";
+ return this;
+ }
+
+ public EveSurfaceBuilder Title(string title)
+ {
+ _title = title ?? "";
+ _children.Add(Text($"{_surfaceId}.title", _title, "text.title"));
+ return this;
+ }
+
+ public EveSurfaceBuilder TitleSubtitle(string title, string subtitle)
+ {
+ _title = string.IsNullOrWhiteSpace(subtitle) ? title ?? "" : $"{title} {subtitle}";
+ _children.Add(Text($"{_surfaceId}.title", title ?? "", "text.title"));
+ _children.Add(Text($"{_surfaceId}.subtitle", subtitle ?? "", "text.subtitle"));
+ return this;
+ }
+
+ public EveSurfaceBuilder Text(string text, string? id = null)
+ {
+ _children.Add(Text(id ?? $"{_surfaceId}.text.{_children.Count}", text, "text"));
+ return this;
+ }
+
+ public EveSurfaceBuilder Button(string label, string command)
+ {
+ var operation = CultMesh.OperationBinding(command, label);
+ _commands.Add(new EveCommandTemplate(operation));
+ _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation));
+ return this;
+ }
+
+ public EveSurfaceBuilder Button(string label, CultMeshOperationBindingDescriptor operation)
+ {
+ if (operation == null) throw new ArgumentNullException(nameof(operation));
+ _commands.Add(new EveCommandTemplate(operation));
+ _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation));
+ return this;
+ }
+
+ public EveSurfaceBuilder ButtonColumn(string id, Action build)
+ {
+ return Group(id, "column", build);
+ }
+
+ public EveSurfaceBuilder ButtonRow(string id, Action build)
+ {
+ return Group(id, "row", build);
+ }
+
+ public EveSurfaceBuilder Form(string id, Action build)
+ {
+ if (build == null) throw new ArgumentNullException(nameof(build));
+ var form = new EveSurfaceFormBuilder(id, AddCommand);
+ build(form);
+ _children.Add(form.Build());
+ return this;
+ }
+
+ public EveSurfaceBuilder RootColumn(string id, Action build)
+ {
+ _root = BuildContainer(id, "partition", Props(("split", "y")), build);
+ return this;
+ }
+
+ public EveSurfaceBuilder RootRow(string id, Action build)
+ {
+ _root = BuildContainer(id, "partition", Props(("split", "x")), build);
+ return this;
+ }
+
+ public EveSurfaceBuilder RootGrid(
+ string id,
+ int columns,
+ int rows,
+ Action build)
+ {
+ _root = BuildContainer(
+ id,
+ "grid",
+ Props(
+ ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)),
+ ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))),
+ build);
+ return this;
+ }
+
+ public EveSurfaceBuilder Row(string id, Action build)
+ {
+ _children.Add(BuildContainer(id, "partition", Props(("split", "x")), build));
+ return this;
+ }
+
+ public EveSurfaceBuilder Column(string id, Action build)
+ {
+ _children.Add(BuildContainer(id, "partition", Props(("split", "y")), build));
+ return this;
+ }
+
+ public EveSurfaceBuilder Grid(string id, int columns, int rows, Action build)
+ {
+ _children.Add(BuildContainer(
+ id,
+ "grid",
+ Props(
+ ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)),
+ ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))),
+ build));
+ return this;
+ }
+
+ public EveSurfaceBuilder Modal(string id, string title, Action build)
+ {
+ _children.Add(BuildContainer(id, "modal", Props(("title", title ?? "")), build));
+ return this;
+ }
+
+ public EveSurfaceBuilder EmbeddedDocument(
+ string slotId,
+ string documentId,
+ string schemaId,
+ string presentationKind,
+ CultMeshRouteHint? routeHint = null)
+ {
+ _children.Add(new EveSurfaceComponent(
+ $"{_surfaceId}.slot.{Slug(slotId)}",
+ "surface.slot",
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["slotId"] = slotId ?? "",
+ ["documentId"] = documentId ?? "",
+ ["schemaId"] = schemaId ?? "",
+ ["presentationKind"] = presentationKind ?? ""
+ },
+ Array.Empty(),
+ Array.Empty(),
+ new[]
+ {
+ new EveEmbeddedDocumentSlot(
+ slotId ?? "",
+ documentId ?? "",
+ schemaId ?? "",
+ presentationKind ?? "",
+ routeHint)
+ }));
+ return this;
+ }
+
+ public EveSurfaceBuilder Style(string name, string value)
+ {
+ _styles.Add(new EveStyleToken(name, value));
+ return this;
+ }
+
+ public EveSurfaceBuilder Version(long version)
+ {
+ _version = version;
+ return this;
+ }
+
+ public EveSurfaceBuilder UpdatedAtUtc(string updatedAtUtc)
+ {
+ _updatedAtUtc = updatedAtUtc ?? "";
+ return this;
+ }
+
+ public EveSurfaceDocument Build()
+ {
+ return new EveSurfaceDocument(
+ _providerId,
+ _providerKind,
+ _title,
+ _version,
+ string.IsNullOrWhiteSpace(_updatedAtUtc) ? DateTime.UtcNow.ToString("O") : _updatedAtUtc,
+ new EveSurfaceTree(
+ _surfaceId,
+ _root ?? new EveSurfaceComponent(
+ $"{_surfaceId}.root",
+ "surface",
+ EmptyProps(),
+ _children.ToArray()),
+ _styles.ToArray()),
+ _commands.ToArray());
+ }
+
+ private EveSurfaceBuilder Group(string id, string kind, Action build)
+ {
+ if (build == null) throw new ArgumentNullException(nameof(build));
+ var group = new EveSurfaceGroupBuilder(id, kind, AddCommand);
+ build(group);
+ _children.Add(group.Build());
+ return this;
+ }
+
+ private EveSurfaceComponent BuildContainer(
+ string id,
+ string kind,
+ IReadOnlyDictionary props,
+ Action build)
+ {
+ if (build == null) throw new ArgumentNullException(nameof(build));
+ var container = new EveSurfaceContainerBuilder(id, kind, props, AddCommand);
+ build(container);
+ return container.Build();
+ }
+
+ private void AddCommand(EveCommandTemplate command)
+ {
+ if (command != null)
+ _commands.Add(command);
+ }
+
+ internal static EveSurfaceComponent Text(string id, string value, string kind)
+ {
+ return new EveSurfaceComponent(
+ id,
+ kind,
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["value"] = value ?? ""
+ },
+ Array.Empty());
+ }
+
+ internal static EveSurfaceComponent ButtonComponent(
+ string id,
+ string label,
+ CultMeshOperationBindingDescriptor operation)
+ {
+ return new EveSurfaceComponent(
+ id,
+ "control.button",
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["label"] = label ?? operation.Label,
+ ["command"] = operation.OperationId,
+ ["operationId"] = operation.OperationId,
+ ["schemaId"] = operation.SchemaId
+ },
+ Array.Empty());
+ }
+
+ internal static Dictionary EmptyProps()
+ {
+ return new Dictionary(StringComparer.Ordinal);
+ }
+
+ internal static Dictionary Props(params (string Key, string Value)[] values)
+ {
+ return (values ?? Array.Empty<(string Key, string Value)>())
+ .ToDictionary(value => value.Key, value => value.Value ?? "", StringComparer.Ordinal);
+ }
+
+ internal static string Slug(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return "unnamed";
+
+ var chars = value
+ .Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '.')
+ .ToArray();
+ var slug = new string(chars).Trim('.');
+ while (slug.Contains("..", StringComparison.Ordinal))
+ slug = slug.Replace("..", ".", StringComparison.Ordinal);
+ return string.IsNullOrWhiteSpace(slug) ? "unnamed" : slug;
+ }
+
+ private static string RequireNonEmpty(string value, string paramName)
+ {
+ return string.IsNullOrWhiteSpace(value)
+ ? throw new ArgumentException("Value must be non-empty.", paramName)
+ : value;
+ }
+ }
+
+ public sealed class EveSurfaceContainerBuilder
+ {
+ private readonly string _id;
+ private readonly string _kind;
+ private readonly Dictionary _props;
+ private readonly Action _addCommand;
+ private readonly List _children = new();
+ private readonly List _stateBindings = new();
+ private readonly List _embeddedDocuments = new();
+
+ internal EveSurfaceContainerBuilder(
+ string id,
+ string kind,
+ IReadOnlyDictionary props,
+ Action addCommand)
+ {
+ _id = id ?? "";
+ _kind = kind ?? "partition";
+ _props = new Dictionary(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal);
+ _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand));
+ }
+
+ public EveSurfaceContainerBuilder Gap(int value) => Prop("gap", value);
+
+ public EveSurfaceContainerBuilder Padding(int value) => Prop("padding", value);
+
+ public EveSurfaceContainerBuilder Size(string value) => Prop("size", value);
+
+ public EveSurfaceContainerBuilder Min(string value) => Prop("min", value);
+
+ public EveSurfaceContainerBuilder Max(string value) => Prop("max", value);
+
+ public EveSurfaceContainerBuilder Align(string value) => Prop("align", value);
+
+ public EveSurfaceContainerBuilder Clip(string value = "hidden") => Prop("clip", value);
+
+ public EveSurfaceContainerBuilder Scroll(string value = "y") => Prop("scroll", value);
+
+ public EveSurfaceContainerBuilder Region(string value) => Prop("region", value);
+
+ public EveSurfaceContainerBuilder Bind(CultMeshStateBindingDescriptor binding)
+ {
+ if (binding != null)
+ _stateBindings.Add(binding);
+ return this;
+ }
+
+ public EveSurfaceContainerBuilder Row(string id, Action build)
+ {
+ _children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "x")), build));
+ return this;
+ }
+
+ public EveSurfaceContainerBuilder Column(string id, Action build)
+ {
+ _children.Add(BuildContainer(id, "partition", EveSurfaceBuilder.Props(("split", "y")), build));
+ return this;
+ }
+
+ public EveSurfaceContainerBuilder Grid(
+ string id,
+ int columns,
+ int rows,
+ Action build)
+ {
+ _children.Add(BuildContainer(
+ id,
+ "grid",
+ EveSurfaceBuilder.Props(
+ ("columns", columns.ToString(System.Globalization.CultureInfo.InvariantCulture)),
+ ("rows", rows.ToString(System.Globalization.CultureInfo.InvariantCulture))),
+ build));
+ return this;
+ }
+
+ public EveSurfaceContainerBuilder Pane(string id, string title, Action build)
+ {
+ _children.Add(BuildContainer(id, "pane", EveSurfaceBuilder.Props(("title", title ?? "")), build));
+ return this;
+ }
+
+ public EveSurfaceContainerBuilder Card(string id, string title, Action build)
+ {
+ _children.Add(BuildContainer(id, "card", EveSurfaceBuilder.Props(("title", title ?? "")), build));
+ return this;
+ }
+
+ public EveSurfaceContainerBuilder Modal(string id, string title, Action build)
+ {
+ _children.Add(BuildContainer(id, "modal", EveSurfaceBuilder.Props(("title", title ?? "")), build));
+ return this;
+ }
+
+ public EveSurfaceContainerBuilder FieldRow(
+ string id,
+ string label,
+ Action build)
+ {
+ return Row(id, row => row
+ .Gap(8)
+ .Label($"{id}.label", label, labelPart => labelPart.Size("12rem"))
+ .Column($"{id}.field", field =>
+ {
+ field.Size("1fr");
+ build(field);
+ }));
+ }
+
+ public EveSurfaceContainerBuilder Label(
+ string id,
+ string text,
+ Action? configure = null)
+ {
+ return Leaf(id, "label", EveSurfaceBuilder.Props(("value", text ?? "")), null, null, configure);
+ }
+
+ public EveSurfaceContainerBuilder Text(string id, string text)
+ {
+ return Leaf(id, "text", EveSurfaceBuilder.Props(("value", text ?? "")));
+ }
+
+ public EveSurfaceContainerBuilder Title(string id, string text)
+ {
+ return Leaf(id, "text.title", EveSurfaceBuilder.Props(("value", text ?? "")));
+ }
+
+ public EveSurfaceContainerBuilder Metric(
+ string id,
+ string label,
+ string value,
+ CultMeshStateBindingDescriptor? binding = null)
+ {
+ return Leaf(
+ id,
+ "metric",
+ EveSurfaceBuilder.Props(("label", label ?? ""), ("value", value ?? "")),
+ binding);
+ }
+
+ public EveSurfaceContainerBuilder Progress(
+ string id,
+ string label,
+ string value,
+ string min,
+ string max,
+ CultMeshStateBindingDescriptor? binding = null)
+ {
+ return Leaf(
+ id,
+ "progress",
+ EveSurfaceBuilder.Props(
+ ("label", label ?? ""),
+ ("value", value ?? ""),
+ ("min", min ?? ""),
+ ("max", max ?? "")),
+ binding);
+ }
+
+ public EveSurfaceContainerBuilder Button(string id, string label, string command)
+ {
+ return Button(id, label, CultMesh.OperationBinding(command, label));
+ }
+
+ public EveSurfaceContainerBuilder Button(
+ string id,
+ string label,
+ CultMeshOperationBindingDescriptor operation)
+ {
+ if (operation == null) throw new ArgumentNullException(nameof(operation));
+ _addCommand(new EveCommandTemplate(operation));
+ _children.Add(EveSurfaceBuilder.ButtonComponent(id, label, operation));
+ return this;
+ }
+
+ public EveSurfaceContainerBuilder PopupButton(
+ string id,
+ string label,
+ string title,
+ Action build,
+ string anchor = "viewport",
+ string placement = "center",
+ int offsetX = 0,
+ int offsetY = 0)
+ {
+ if (build == null) throw new ArgumentNullException(nameof(build));
+ var popup = BuildContainer(
+ $"{id}.popup",
+ "modal",
+ EveSurfaceBuilder.Props(
+ ("title", title ?? label ?? ""),
+ ("anchor", anchor ?? "viewport"),
+ ("placement", placement ?? "center"),
+ ("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)),
+ ("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture)),
+ ("presentation", "popover")),
+ build);
+ _children.Add(new EveSurfaceComponent(
+ id ?? "",
+ "control.popup",
+ EveSurfaceBuilder.Props(
+ ("label", label ?? ""),
+ ("title", title ?? label ?? ""),
+ ("anchor", anchor ?? "viewport"),
+ ("placement", placement ?? "center"),
+ ("offsetX", offsetX.ToString(System.Globalization.CultureInfo.InvariantCulture)),
+ ("offsetY", offsetY.ToString(System.Globalization.CultureInfo.InvariantCulture))),
+ new[] { popup }));
+ return this;
+ }
+
+ public EveSurfaceContainerBuilder EnumField(
+ string id,
+ string label,
+ string value,
+ CultMeshStateBindingDescriptor optionsBinding,
+ CultMeshOperationBindingDescriptor setValue,
+ string anchor = "trigger",
+ string placement = "right-down")
+ {
+ return FieldRow(id, label, field => field.PopupButton(
+ $"{id}.dropdown",
+ value,
+ label,
+ popup => popup.OptionList($"{id}.options", label, optionsBinding, setValue),
+ anchor,
+ placement));
+ }
+
+ public EveSurfaceContainerBuilder OptionList(
+ string id,
+ string label,
+ CultMeshStateBindingDescriptor itemsBinding,
+ CultMeshOperationBindingDescriptor operation)
+ {
+ if (itemsBinding == null) throw new ArgumentNullException(nameof(itemsBinding));
+ if (operation == null) throw new ArgumentNullException(nameof(operation));
+ _addCommand(new EveCommandTemplate(operation));
+ _children.Add(new EveSurfaceComponent(
+ id ?? "",
+ "options",
+ EveSurfaceBuilder.Props(
+ ("label", label ?? ""),
+ ("command", operation.OperationId),
+ ("operationId", operation.OperationId),
+ ("schemaId", operation.SchemaId)),
+ Array.Empty(),
+ new[] { itemsBinding }));
+ return this;
+ }
+
+ public EveSurfaceContainerBuilder TextInput(
+ string id,
+ string label,
+ string value,
+ CultMeshOperationBindingDescriptor operation,
+ CultMeshStateBindingDescriptor? binding = null)
+ {
+ return Control("control.input.text", id, label, value, operation, binding);
+ }
+
+ public EveSurfaceContainerBuilder Toggle(
+ string id,
+ string label,
+ bool value,
+ CultMeshOperationBindingDescriptor operation,
+ CultMeshStateBindingDescriptor? binding = null)
+ {
+ return Control("control.toggle", id, label, value ? "true" : "false", operation, binding);
+ }
+
+ public EveSurfaceContainerBuilder Slider(
+ string id,
+ string label,
+ string value,
+ string min,
+ string max,
+ string step,
+ CultMeshOperationBindingDescriptor operation,
+ CultMeshStateBindingDescriptor? binding = null)
+ {
+ return Control(
+ "control.slider",
+ id,
+ label,
+ value,
+ operation,
+ binding,
+ ("min", min ?? ""),
+ ("max", max ?? ""),
+ ("step", step ?? ""));
+ }
+
+ public EveSurfaceContainerBuilder Select(
+ string id,
+ string label,
+ string value,
+ CultMeshOperationBindingDescriptor operation,
+ CultMeshStateBindingDescriptor? binding = null)
+ {
+ return Control("control.select", id, label, value, operation, binding);
+ }
+
+ public EveSurfaceContainerBuilder EmbeddedDocument(
+ string id,
+ string slotId,
+ string documentId,
+ string schemaId,
+ string presentationKind,
+ CultMeshRouteHint? routeHint = null)
+ {
+ _children.Add(new EveSurfaceComponent(
+ id ?? "",
+ "surface.slot",
+ EveSurfaceBuilder.Props(
+ ("slotId", slotId ?? ""),
+ ("documentId", documentId ?? ""),
+ ("schemaId", schemaId ?? ""),
+ ("presentationKind", presentationKind ?? "")),
+ Array.Empty(),
+ Array.Empty(),
+ new[]
+ {
+ new EveEmbeddedDocumentSlot(
+ slotId ?? "",
+ documentId ?? "",
+ schemaId ?? "",
+ presentationKind ?? "",
+ routeHint)
+ }));
+ return this;
+ }
+
+ internal EveSurfaceComponent Build()
+ {
+ return new EveSurfaceComponent(
+ _id,
+ _kind,
+ _props,
+ _children.ToArray(),
+ _stateBindings.ToArray(),
+ _embeddedDocuments.ToArray());
+ }
+
+ private EveSurfaceContainerBuilder Prop(string key, int value)
+ {
+ _props[key] = value.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ return this;
+ }
+
+ private EveSurfaceContainerBuilder Prop(string key, string value)
+ {
+ _props[key] = value ?? "";
+ return this;
+ }
+
+ private EveSurfaceContainerBuilder Leaf(
+ string id,
+ string kind,
+ IReadOnlyDictionary props,
+ CultMeshStateBindingDescriptor? binding = null,
+ IReadOnlyList? children = null,
+ Action? configure = null)
+ {
+ var builder = new EveSurfaceContainerBuilder(id, kind, props, _addCommand);
+ configure?.Invoke(builder);
+ var component = new EveSurfaceComponent(
+ id ?? "",
+ kind ?? "",
+ new Dictionary(props ?? EveSurfaceBuilder.EmptyProps(), StringComparer.Ordinal),
+ children ?? Array.Empty(),
+ binding == null ? Array.Empty() : new[] { binding });
+ if (builder._props.Count != (props?.Count ?? 0))
+ {
+ component = new EveSurfaceComponent(
+ id ?? "",
+ kind ?? "",
+ new Dictionary(builder._props, StringComparer.Ordinal),
+ children ?? Array.Empty(),
+ binding == null ? Array.Empty() : new[] { binding });
+ }
+ _children.Add(component);
+ return this;
+ }
+
+ private EveSurfaceContainerBuilder Control(
+ string kind,
+ string id,
+ string label,
+ string value,
+ CultMeshOperationBindingDescriptor operation,
+ CultMeshStateBindingDescriptor? binding,
+ params (string Key, string Value)[] extraProps)
+ {
+ if (operation == null) throw new ArgumentNullException(nameof(operation));
+ _addCommand(new EveCommandTemplate(operation));
+ var props = EveSurfaceBuilder.Props(
+ new[]
+ {
+ ("label", label ?? ""),
+ ("value", value ?? ""),
+ ("command", operation.OperationId),
+ ("operationId", operation.OperationId),
+ ("schemaId", operation.SchemaId)
+ }.Concat(extraProps ?? Array.Empty<(string Key, string Value)>()).ToArray());
+ return Leaf(kind: kind, id: id, props: props, binding: binding);
+ }
+
+ private EveSurfaceComponent BuildContainer(
+ string id,
+ string kind,
+ IReadOnlyDictionary props,
+ Action build)
+ {
+ if (build == null) throw new ArgumentNullException(nameof(build));
+ var container = new EveSurfaceContainerBuilder(id, kind, props, _addCommand);
+ build(container);
+ return container.Build();
+ }
+ }
+
+ public sealed class EveSurfaceGroupBuilder
+ {
+ private readonly string _id;
+ private readonly string _kind;
+ private readonly Action _addCommand;
+ private readonly List _children = new();
+
+ internal EveSurfaceGroupBuilder(
+ string id,
+ string kind,
+ Action addCommand)
+ {
+ _id = id ?? "";
+ _kind = kind ?? "column";
+ _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand));
+ }
+
+ public EveSurfaceGroupBuilder Button(string label, string command)
+ {
+ var operation = CultMesh.OperationBinding(command, label);
+ _addCommand(new EveCommandTemplate(operation));
+ _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation));
+ return this;
+ }
+
+ public EveSurfaceGroupBuilder Button(string label, CultMeshOperationBindingDescriptor operation)
+ {
+ if (operation == null) throw new ArgumentNullException(nameof(operation));
+ _addCommand(new EveCommandTemplate(operation));
+ _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation));
+ return this;
+ }
+
+ internal EveSurfaceComponent Build()
+ {
+ return new EveSurfaceComponent(_id, _kind, EveSurfaceBuilder.EmptyProps(), _children.ToArray());
+ }
+ }
+
+ public sealed class EveSurfaceFormBuilder
+ {
+ private readonly string _id;
+ private readonly Action _addCommand;
+ private readonly List _children = new();
+
+ internal EveSurfaceFormBuilder(string id, Action addCommand)
+ {
+ _id = id ?? "";
+ _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand));
+ }
+
+ public EveSurfaceFormBuilder Text(
+ string label,
+ string value,
+ CultMeshOperationBindingDescriptor operation,
+ CultMeshStateBindingDescriptor? binding = null)
+ {
+ if (operation == null) throw new ArgumentNullException(nameof(operation));
+ _addCommand(new EveCommandTemplate(operation));
+ _children.Add(Control(
+ "control.text",
+ label,
+ value,
+ operation,
+ binding));
+ return this;
+ }
+
+ public EveSurfaceFormBuilder Toggle(
+ string label,
+ bool value,
+ CultMeshOperationBindingDescriptor operation,
+ CultMeshStateBindingDescriptor? binding = null)
+ {
+ if (operation == null) throw new ArgumentNullException(nameof(operation));
+ _addCommand(new EveCommandTemplate(operation));
+ _children.Add(Control(
+ "control.toggle",
+ label,
+ value ? "true" : "false",
+ operation,
+ binding));
+ return this;
+ }
+
+ public EveSurfaceFormBuilder Metric(
+ string label,
+ string value,
+ CultMeshStateBindingDescriptor? binding = null)
+ {
+ var bindings = binding == null
+ ? Array.Empty()
+ : new[] { binding };
+ _children.Add(new EveSurfaceComponent(
+ $"{_id}.metric.{EveSurfaceBuilder.Slug(label)}",
+ "metric",
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["label"] = label ?? "",
+ ["value"] = value ?? ""
+ },
+ Array.Empty(),
+ bindings));
+ return this;
+ }
+
+ internal EveSurfaceComponent Build()
+ {
+ return new EveSurfaceComponent(_id, "form", EveSurfaceBuilder.EmptyProps(), _children.ToArray());
+ }
+
+ private EveSurfaceComponent Control(
+ string kind,
+ string label,
+ string value,
+ CultMeshOperationBindingDescriptor operation,
+ CultMeshStateBindingDescriptor? binding)
+ {
+ var bindings = binding == null
+ ? Array.Empty()
+ : new[] { binding };
+ return new EveSurfaceComponent(
+ $"{_id}.{EveSurfaceBuilder.Slug(label)}",
+ kind,
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["label"] = label ?? "",
+ ["value"] = value ?? "",
+ ["command"] = operation.OperationId,
+ ["operationId"] = operation.OperationId,
+ ["schemaId"] = operation.SchemaId
+ },
+ Array.Empty(),
+ bindings);
+ }
+ }
+
+ public static class EveSurface
+ {
+ public static EveSurfaceBuilder Create(string surfaceId)
+ {
+ return new EveSurfaceBuilder(surfaceId);
+ }
+ }
+}
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta
new file mode 100644
index 0000000..44bff25
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 97ac7f757692aef4db64d75d0df6fe51
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs
new file mode 100644
index 0000000..e7d6536
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs
@@ -0,0 +1,540 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using GameCult.Caching;
+using GameCult.Mesh;
+using MessagePack;
+using MessagePack.Formatters;
+
+#nullable enable
+
+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";
+ public const string SchemaId = "gamecult.eve.surface.v1";
+
+ public EveSurfaceDocument(
+ string providerId,
+ string providerKind,
+ string title,
+ long version,
+ string updatedAtUtc,
+ EveSurfaceTree surface,
+ IReadOnlyList commands)
+ : this(DefaultType, SchemaId, providerId, providerKind, title, version, updatedAtUtc, surface, commands)
+ {
+ }
+
+ [SerializationConstructor]
+ public EveSurfaceDocument(
+ string type,
+ string schema,
+ string providerId,
+ string providerKind,
+ string title,
+ long version,
+ string updatedAtUtc,
+ EveSurfaceTree surface,
+ IReadOnlyList commands)
+ {
+ Type = string.IsNullOrWhiteSpace(type) ? DefaultType : type;
+ Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
+ ProviderId = providerId ?? "";
+ ProviderKind = providerKind ?? "";
+ Title = title ?? "";
+ Version = version;
+ UpdatedAtUtc = updatedAtUtc ?? "";
+ Surface = surface ?? throw new ArgumentNullException(nameof(surface));
+ Commands = commands ?? Array.Empty();
+ }
+
+ [Key(0)]
+ public string Type { get; }
+
+ [Key(1)]
+ public string Schema { get; }
+
+ [Key(2)]
+ public string ProviderId { get; }
+
+ [Key(3)]
+ public string ProviderKind { get; }
+
+ [Key(4)]
+ public string Title { get; }
+
+ [Key(5)]
+ public long Version { get; }
+
+ [Key(6)]
+ public string UpdatedAtUtc { get; }
+
+ [Key(7)]
+ public EveSurfaceTree Surface { get; }
+
+ [Key(8)]
+ public IReadOnlyList Commands { get; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveSurfaceTree
+ {
+ [SerializationConstructor]
+ public EveSurfaceTree(string id, EveSurfaceComponent root, IReadOnlyList styles)
+ {
+ Id = id ?? "";
+ Root = root ?? throw new ArgumentNullException(nameof(root));
+ Styles = styles ?? Array.Empty();
+ }
+
+ [Key(0)]
+ public string Id { get; }
+
+ [Key(1)]
+ public EveSurfaceComponent Root { get; }
+
+ [Key(2)]
+ public IReadOnlyList Styles { get; }
+ }
+
+ [MessagePackObject]
+ public sealed class EveSurfaceComponent
+ {
+ public EveSurfaceComponent(
+ string id,
+ string kind,
+ IReadOnlyDictionary props,
+ IReadOnlyList children)
+ : this(id, kind, props, children, StateBindingsFromProps(props))
+ {
+ }
+
+ public EveSurfaceComponent(
+ string id,
+ string kind,
+ IReadOnlyDictionary props,
+ IReadOnlyList children,
+ IReadOnlyList stateBindings)
+ : this(id, kind, props, children, stateBindings, Array.Empty())
+ {
+ }
+
+ public EveSurfaceComponent(
+ string id,
+ string kind,
+ IReadOnlyDictionary props,
+ IReadOnlyList children,
+ IReadOnlyList stateBindings,
+ IReadOnlyList embeddedDocuments,
+ IReadOnlyDictionary? layout = null,
+ IReadOnlyDictionary? style = null)
+ : this(
+ id,
+ kind,
+ props,
+ children,
+ (stateBindings ?? Array.Empty())
+ .Select(CultMeshStateBindingRecord.FromBinding)
+ .ToArray(),
+ embeddedDocuments,
+ layout,
+ style)
+ {
+ }
+
+ [SerializationConstructor]
+ public EveSurfaceComponent(
+ string id,
+ string kind,
+ IReadOnlyDictionary props,
+ IReadOnlyList children,
+ CultMeshStateBindingRecord[] stateBindingRecords,
+ IReadOnlyList embeddedDocuments,
+ IReadOnlyDictionary? layout = null,
+ IReadOnlyDictionary? style = null)
+ {
+ Id = id ?? "";
+ Kind = kind ?? "";
+ Props = props ?? new Dictionary(StringComparer.Ordinal);
+ Children = children ?? Array.Empty();
+ StateBindingRecords = stateBindingRecords ?? Array.Empty();
+ EmbeddedDocuments = embeddedDocuments ?? Array.Empty();
+ Layout = layout ?? new Dictionary(StringComparer.Ordinal);
+ Style = style ?? new Dictionary(StringComparer.Ordinal);
+ }
+
+ [Key(0)]
+ public string Id { get; }
+
+ [Key(1)]
+ public string Kind { get; }
+
+ [Key(2)]
+ public IReadOnlyDictionary Props { get; }
+
+ [Key(3)]
+ public IReadOnlyList Children { get; }
+
+ [Key(4)]
+ public CultMeshStateBindingRecord[] StateBindingRecords { get; }
+
+ [IgnoreMember]
+ public IReadOnlyList StateBindings =>
+ StateBindingRecords.Select(record => record.ToBinding()).ToArray();
+
+ [Key(5)]
+ public IReadOnlyList EmbeddedDocuments { get; }
+
+ [Key(6)]
+ public IReadOnlyDictionary Layout { get; }
+
+ [Key(7)]
+ public IReadOnlyDictionary Style { get; }
+
+ public string GetProp(string key, string fallback = "")
+ {
+ return Props.TryGetValue(key, out var value) ? value : fallback;
+ }
+
+ private static IReadOnlyList StateBindingsFromProps(
+ IReadOnlyDictionary? props)
+ {
+ if (props == null || props.Count == 0)
+ return Array.Empty();
+
+ var bindings = new List();
+ foreach (var prop in props)
+ {
+ if (string.IsNullOrWhiteSpace(prop.Value) ||
+ !prop.Key.EndsWith("PointerId", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var targetProp = prop.Key.Substring(0, prop.Key.Length - "PointerId".Length);
+ if (targetProp.Length == 0)
+ targetProp = "value";
+ bindings.Add(new CultMeshStateBindingDescriptor(targetProp, prop.Value));
+ }
+
+ return bindings;
+ }
+ }
+
+ [MessagePackObject]
+ public sealed class EveEmbeddedDocumentSlot
+ {
+ public EveEmbeddedDocumentSlot(
+ string slotId,
+ string documentId,
+ string schemaId,
+ string presentationKind,
+ CultMeshRouteHint? routeHint = null)
+ : this(slotId, documentId, schemaId, presentationKind, CultMeshRouteRecord.FromRoute(routeHint))
+ {
+ }
+
+ [SerializationConstructor]
+ public EveEmbeddedDocumentSlot(
+ string slotId,
+ string documentId,
+ string schemaId,
+ string presentationKind,
+ CultMeshRouteRecord route)
+ {
+ SlotId = slotId ?? "";
+ DocumentId = documentId ?? "";
+ SchemaId = schemaId ?? "";
+ PresentationKind = presentationKind ?? "";
+ Route = route ?? CultMeshRouteRecord.FromRoute(CultMeshRouteHint.Automatic);
+ }
+
+ [Key(0)]
+ public string SlotId { get; }
+
+ [Key(1)]
+ public string DocumentId { get; }
+
+ [Key(2)]
+ public string SchemaId { get; }
+
+ [Key(3)]
+ public string PresentationKind { get; }
+
+ [Key(4)]
+ public CultMeshRouteRecord Route { get; }
+
+ [IgnoreMember]
+ public CultMeshRouteHint RouteHint => Route.ToRoute();
+ }
+
+ [MessagePackObject]
+ public sealed class EveStyleToken
+ {
+ [SerializationConstructor]
+ public EveStyleToken(string name, string value)
+ {
+ Name = name ?? "";
+ Value = value ?? "";
+ }
+
+ [Key(0)]
+ public string Name { get; }
+
+ [Key(1)]
+ public string Value { get; }
+ }
+
+ [MessagePackObject]
+ [MessagePackFormatter(typeof(EveCommandTemplateCompatibilityFormatter))]
+ public sealed class EveCommandTemplate
+ {
+ public EveCommandTemplate(CultMeshOperationBindingDescriptor operation)
+ : this(CultMeshOperationBindingRecord.FromBinding(operation))
+ {
+ }
+
+ [SerializationConstructor]
+ public EveCommandTemplate(CultMeshOperationBindingRecord operationRecord)
+ {
+ OperationRecord = operationRecord ?? throw new ArgumentNullException(nameof(operationRecord));
+ }
+
+ [Key(0)]
+ public CultMeshOperationBindingRecord OperationRecord { get; }
+
+ [IgnoreMember]
+ public CultMeshOperationBindingDescriptor Operation => OperationRecord.ToBinding();
+
+ [IgnoreMember]
+ public string Command => Operation.OperationId;
+
+ [IgnoreMember]
+ public string Label => Operation.Label;
+
+ [IgnoreMember]
+ public string Transport => Operation.RouteHint.Description ?? "";
+ }
+
+ [CultDocument("gamecult.eve.command_invocation", SchemaId)]
+ [MessagePackObject]
+ public sealed class EveSurfaceCommandRequest
+ {
+ public const string SchemaId = "gamecult.eve.command_invocation.v1";
+
+ public EveSurfaceCommandRequest(
+ string providerId,
+ string surfaceId,
+ CultMeshOperationInvocationDescriptor operation,
+ CultMeshOperationPayload payload,
+ DateTimeOffset issuedAt,
+ string clientId,
+ string commandBoundary = "",
+ string receiptSchema = "")
+ : this(
+ SchemaId,
+ providerId,
+ surfaceId,
+ CultMeshOperationInvocationRecord.FromInvocation(operation),
+ payload?.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.Ordinal)
+ ?? new Dictionary(StringComparer.Ordinal),
+ issuedAt,
+ clientId,
+ commandBoundary,
+ receiptSchema)
+ {
+ }
+
+ [SerializationConstructor]
+ public EveSurfaceCommandRequest(
+ string schema,
+ string providerId,
+ string surfaceId,
+ CultMeshOperationInvocationRecord operation,
+ Dictionary payload,
+ DateTimeOffset issuedAt,
+ string clientId,
+ string commandBoundary,
+ string receiptSchema)
+ {
+ Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema;
+ ProviderId = providerId;
+ SurfaceId = surfaceId;
+ OperationRecord = operation ?? throw new ArgumentNullException(nameof(operation));
+ PayloadFields = payload == null
+ ? new Dictionary(StringComparer.Ordinal)
+ : new Dictionary(payload, StringComparer.Ordinal);
+ IssuedAt = issuedAt;
+ ClientId = clientId;
+ CommandBoundary = commandBoundary ?? "";
+ ReceiptSchema = receiptSchema ?? "";
+ }
+
+ [Key(0)]
+ public string Schema { get; }
+
+ [Key(1)]
+ public string ProviderId { get; }
+
+ [Key(2)]
+ public string SurfaceId { get; }
+
+ [Key(3)]
+ public CultMeshOperationInvocationRecord OperationRecord { get; }
+
+ [IgnoreMember]
+ public CultMeshOperationInvocationDescriptor Operation => OperationRecord.ToInvocation();
+
+ [IgnoreMember]
+ public string Command => Operation.OperationId;
+
+ [Key(4)]
+ public Dictionary PayloadFields { get; }
+
+ [IgnoreMember]
+ public CultMeshOperationPayload Payload => new CultMeshOperationPayload(PayloadFields);
+
+ [Key(5)]
+ public DateTimeOffset IssuedAt { get; }
+
+ [Key(6)]
+ public string ClientId { get; }
+
+ [Key(7)]
+ public string CommandBoundary { get; }
+
+ [Key(8)]
+ public string ReceiptSchema { get; }
+
+ [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/EveSurfaceDocument.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta
new file mode 100644
index 0000000..834c814
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 6516c42104b235f42b15efeb4639bb73
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef
new file mode 100644
index 0000000..2240a47
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef
@@ -0,0 +1,23 @@
+{
+ "name": "GameCult.Eve.Surface",
+ "rootNamespace": "GameCult.Eve.Surface",
+ "references": [
+ "GameCult.Mesh"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": true,
+ "precompiledReferences": [
+ "GameCult.Caching.dll",
+ "GameCult.Mesh.dll",
+ "MessagePack.Annotations.dll",
+ "MessagePack.dll"
+ ],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": true
+}
+
+
diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta
new file mode 100644
index 0000000..64f708c
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef.meta
@@ -0,0 +1,9 @@
+fileFormatVersion: 2
+guid: a9004278c14816345941af2f2ce673ca
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+
+
diff --git a/packages/org.gamecult.eve.surface/package.json b/packages/org.gamecult.eve.surface/package.json
new file mode 100644
index 0000000..0e245a1
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "org.gamecult.eve.surface",
+ "displayName": "Eve Surface Contract",
+ "version": "0.3.0",
+ "unity": "2021.3",
+ "description": "Renderer-neutral C# contracts and builders for gamecult.eve.surface.v1 documents.",
+ "dependencies": {
+ "org.gamecult.cultlib": "1.0.43"
+ },
+ "files": [
+ "Runtime",
+ "Runtime.meta",
+ "README.md",
+ "README.md.meta",
+ "Directory.Build.props",
+ "GameCult.Eve.Surface.csproj",
+ "GameCult.Eve.Surface.csproj.meta",
+ "package.json.meta"
+ ],
+ "keywords": [
+ "Eve",
+ "CultUI",
+ "CultMesh",
+ "Surface"
+ ],
+ "license": "MPL-2.0",
+ "category": "Scripting"
+}
+
diff --git a/packages/org.gamecult.eve.surface/package.json.meta b/packages/org.gamecult.eve.surface/package.json.meta
new file mode 100644
index 0000000..5d44ca4
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/package.json.meta
@@ -0,0 +1,9 @@
+fileFormatVersion: 2
+guid: 49f1738bd94075e489c82190cf660b1d
+PackageManifestImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+
+
diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore b/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore
new file mode 100644
index 0000000..ccae00c
--- /dev/null
+++ b/tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore
@@ -0,0 +1,3 @@
+!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..40fd3d0
--- /dev/null
+++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs
@@ -0,0 +1,268 @@
+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
+{
+ [Test]
+ public void EntitySoaV2SerializesLogicalLayoutWithoutTransportFields()
+ {
+ var document = new EveEntitySoaViewDocument
+ {
+ ProviderId = "provider",
+ ViewId = "entities",
+ BodySchemaId = "entity.slab.v1",
+ LayoutVersion = 2,
+ ProducerEpoch = 3,
+ Sequence = 4,
+ Capacity = 8,
+ FrameId = 91,
+ Buffers = new[] { new EveEntitySoaBuffer { BufferId = "entity-hot", ByteLength = 64 } },
+ Identities = new[]
+ {
+ new EveEntityIdentity
+ {
+ Index = 0,
+ EntityId = "player",
+ EntityKind = "ship",
+ Label = "Vanguard",
+ Faction = "alliance",
+ Selectable = true,
+ Controllable = true,
+ AssetRef = "cultmesh://assets/player"
+ }
+ }
+ };
+
+ var bytes = MessagePackSerializer.Serialize(document, Options);
+ var reader = new MessagePackReader(bytes);
+ var restored = MessagePackSerializer.Deserialize(bytes, Options);
+
+ Assert.That(reader.ReadArrayHeader(), Is.EqualTo(15));
+ Assert.That(restored.Schema, Is.EqualTo(EveEntitySoaViewDocument.SchemaId));
+ Assert.That(restored.Buffers[0].BufferId, Is.EqualTo("entity-hot"));
+ Assert.That(restored.FrameId, Is.EqualTo(91));
+ Assert.That(restored.Identities[0].Label, Is.EqualTo("Vanguard"));
+ Assert.That(restored.Identities[0].Faction, Is.EqualTo("alliance"));
+ Assert.That(restored.Identities[0].Selectable, Is.True);
+ Assert.That(restored.Identities[0].Controllable, Is.True);
+ Assert.That(restored.Identities[0].AssetRef, Is.EqualTo("cultmesh://assets/player"));
+ }
+
+ [Test]
+ public void EntitySoaContractsExposeNoTransportAuthorityFields()
+ {
+ var forbidden = new[] { "Backend", "Location", "CapabilityToken", "Synchronization", "SynchronizationMode", "Descriptor" };
+ var exposed = new[] { typeof(EveEntitySoaViewDocument), typeof(EveEntitySoaBuffer), typeof(EveEntitySoaColumn) }
+ .SelectMany(type => type.GetProperties())
+ .Select(property => property.Name);
+
+ Assert.That(exposed, Has.None.Matches(name => forbidden.Contains(name, StringComparer.Ordinal)));
+ }
+
+ 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"));
+ }
+
+ [Test]
+ public void LegacyExpandedProviderAdvertisementDeserializesAsCanonicalContract()
+ {
+ var document = MessagePackSerializer.Deserialize(
+ WriteLegacyProviderAdvertisement(),
+ Options);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(document.ProviderId, Is.EqualTo("aetheria"));
+ Assert.That(document.ServiceId, Is.EqualTo("aetheria-daemon"));
+ Assert.That(document.VerseId, Is.EqualTo("aetheria.local"));
+ Assert.That(document.Title, Is.EqualTo("Aetheria"));
+ Assert.That(document.CultMeshAddress, Is.EqualTo("cultmesh://aetheria.local/aetheria"));
+ Assert.That(document.Surfaces[0].SurfaceId, Is.EqualTo("aetheria.pilot"));
+ Assert.That(document.Surfaces[0].Schema, Is.EqualTo(EveSurfaceDocument.SchemaId));
+ Assert.That(document.Commands[0].Command, Is.EqualTo("aetheria.pilot.move"));
+ Assert.That(document.Commands[0].SurfaceId, Is.Empty);
+ });
+ }
+
+ [Test]
+ public void CurrentProviderAdvertisementSerializationWritesCanonicalFourteenFields()
+ {
+ var document = new EveProviderAdvertisementDocument(
+ "provider", "service", "verse", "Title", "game.runtime", "cultmesh://verse/provider",
+ "2026-07-13T14:00:00Z", new EveProviderFreshness("fresh", "2026-07-13T14:00:00Z", 15000),
+ new[] { EveSurfaceDocument.SchemaId }, Array.Empty(),
+ Array.Empty(), Array.Empty(),
+ new[] { "body-producer" });
+
+ var bytes = MessagePackSerializer.Serialize(document, Options);
+ var reader = new MessagePackReader(bytes);
+
+ Assert.That(reader.ReadArrayHeader(), Is.EqualTo(14));
+ var roundTrip = MessagePackSerializer.Deserialize(bytes, Options);
+ Assert.Multiple(() =>
+ {
+ Assert.That(roundTrip.Title, Is.EqualTo("Title"));
+ Assert.That(roundTrip.AuthorizedBodyProducerIds, Is.EqualTo(new[] { "body-producer" }));
+ });
+ }
+
+ 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();
+ }
+
+ private static byte[] WriteLegacyProviderAdvertisement()
+ {
+ var buffer = new ArrayBufferWriter();
+ var writer = new MessagePackWriter(buffer);
+ writer.WriteArrayHeader(16);
+ writer.Write(EveProviderAdvertisementDocument.SchemaId);
+ writer.Write("aetheria");
+ writer.Write("aetheria-daemon");
+ writer.Write("aetheria.local");
+ writer.Write("asgard");
+ writer.Write("aetheria");
+ writer.Write("aetheria.local/aetheria");
+ writer.Write("cultmesh://aetheria.local/aetheria");
+ writer.Write("Aetheria");
+ writer.Write("game.runtime");
+ writer.Write("2026-07-13T14:00:00Z");
+ writer.WriteArrayHeader(3);
+ writer.Write("fresh");
+ writer.Write("2026-07-13T14:00:00Z");
+ writer.Write(15000);
+ writer.WriteArrayHeader(1);
+ writer.Write(EveSurfaceDocument.SchemaId);
+ writer.WriteArrayHeader(0);
+ writer.WriteArrayHeader(1);
+ writer.WriteArrayHeader(5);
+ writer.Write(EveSurfaceDocument.SchemaId);
+ writer.Write("aetheria.pilot");
+ writer.Write("eve:surface:aetheria.pilot");
+ writer.Write("cultmesh");
+ writer.Write("available");
+ writer.WriteArrayHeader(1);
+ writer.WriteArrayHeader(3);
+ writer.Write("aetheria.pilot.move");
+ writer.Write("cultmesh");
+ writer.Write("Move the pilot ship");
+ 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..85d1b96
--- /dev/null
+++ b/tests/GameCult.Eve.Surface.Serialization.Tests/GameCult.Eve.Surface.Serialization.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ obj\bin\
+
+
+
+
+
+
+
+
+
+
+
+
+
+