diff --git a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj index 16839dd..f07f7e8 100644 --- a/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj +++ b/packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj @@ -5,10 +5,11 @@ latest enable Portable Eve surface and provider contracts. + $(MSBuildThisFileDirectory)..\..\..\CultLib - - + + diff --git a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs index 222b4a1..961ea2f 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs @@ -10,31 +10,30 @@ namespace GameCult.Eve.Surface [MessagePackObject] public sealed class EveEntitySoaViewDocument { - public const string SchemaId = "gamecult.eve.entity_soa_view.v1"; + 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 long FrameId { get; set; } - [Key(4)] public long Generation { get; set; } - [Key(5)] public string PublishedAtUtc { get; set; } = ""; - [Key(6)] public string Backend { get; set; } = "cultcache"; - [Key(7)] public string SynchronizationMode { get; set; } = "immutable_frame"; - [Key(8)] public EveEntitySoaBuffer[] Buffers { get; set; } = Array.Empty(); - [Key(9)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty(); - [Key(10)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty(); - [Key(11)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty(); + [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(); } [MessagePackObject] public sealed class EveEntitySoaBuffer { [Key(0)] public string BufferId { get; set; } = ""; - [Key(1)] public string Backend { get; set; } = "cultcache"; - [Key(2)] public string Location { get; set; } = ""; - [Key(3)] public long ByteOffset { get; set; } - [Key(4)] public long ByteLength { get; set; } - [Key(5)] public long Generation { get; set; } + [Key(1)] public long ByteOffset { get; set; } + [Key(2)] public long ByteLength { get; set; } } [MessagePackObject] @@ -57,7 +56,15 @@ namespace GameCult.Eve.Surface [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 Generation { 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; } = ""; } [MessagePackObject] diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index 566b19e..7b0d8ed 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -30,6 +30,7 @@ namespace GameCult.Eve.UnityScene typeof(EveCommandReceiptDocument), typeof(EveAssetCatalogDocument), typeof(EveEntitySoaViewDocument), + typeof(CultMeshBodyPublicationDocument), typeof(CultMeshCdnArtifactManifest), typeof(CultMeshCdnArtifactChunk) }; @@ -53,13 +54,15 @@ namespace GameCult.Eve.UnityScene private CultNetDatabaseSubscriptionClient? _subscriptions; private EveProviderAdvertisementDocument? _advertisement; private EveAdvertisedSurface? _advertisedSurface; + private readonly CultMeshBodyPublicationResolver _bodyResolver; public EveUnityCultMeshLiveProviderTransport( string replicaPath, string endpoint, string providerId, string surfaceId, - string runtimeId = "eve-unity") + string runtimeId = "eve-unity", + CultMeshBodyPublicationResolver? bodyResolver = null) { _replicaPath = string.IsNullOrWhiteSpace(replicaPath) ? throw new ArgumentException("Replica path must be non-empty.", nameof(replicaPath)) @@ -74,6 +77,7 @@ namespace GameCult.Eve.UnityScene ? throw new ArgumentException("Surface id must be non-empty.", nameof(surfaceId)) : surfaceId.Trim(); _runtimeId = string.IsNullOrWhiteSpace(runtimeId) ? "eve-unity" : runtimeId.Trim(); + _bodyResolver = bodyResolver ?? CreateBodyResolver(); CurrentSurfaceDocument = EmptySurfaceDocument(); CurrentAssetManifestDocument = EmptyAssetManifest(); } @@ -94,7 +98,7 @@ namespace GameCult.Eve.UnityScene public event Action? CommandReceiptAvailable; - public event Action? EntityViewAvailable; + public event Action? EntityViewAvailable; public void Connect() { @@ -119,7 +123,7 @@ namespace GameCult.Eve.UnityScene while (_liveDocuments.TryDequeue(out var document)) { if (document is EveEntitySoaViewDocument entityView) - EntityViewAvailable?.Invoke(entityView); + PublishEntityView(entityView); else if (document is EveSurfaceDocument surface) PublishSurface(surface); else if (document is EveCommandReceiptDocument receipt) @@ -268,6 +272,62 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException("The provider surface advertisement does not publish a record reference."); } + private CultMeshBodyPublicationResolver CreateBodyResolver() + { + var mappedRoot = Path.GetDirectoryName(_replicaPath) ?? "."; + return new CultMeshBodyPublicationResolver(new CultMeshBodyTransportService( + new ICultMeshBodyTransportAdapter[] + { + new CultMeshSharedMemoryBodyAdapter(), + new CultMeshMappedBodyAdapter(mappedRoot), + new CultMeshNetworkBodyAdapter(_ => throw new NotSupportedException( + "The connected Verse does not expose a CultMesh network body byte reader.")) + }, + (producerId, _) => string.Equals(producerId, _providerId, StringComparison.Ordinal))); + } + + private void PublishEntityView(EveEntitySoaViewDocument document) + { + ResolveAdvertisement(); + if (!string.Equals(document.ProviderId, _advertisement!.ProviderId, StringComparison.Ordinal)) + throw new UnauthorizedAccessException("Entity layout provider does not match the advertised Eve provider."); + if (document.Buffers == null || document.Buffers.Length == 0) + throw new InvalidOperationException("Entity layout does not name a primary logical buffer."); + var bodyId = document.Buffers[0].BufferId; + var publication = _snapshot! + .FetchDocumentsAsync( + recordKeys: new[] { CultMeshBodyPublicationDocument.CreateRecordKey(bodyId).Value }, + schemaIds: new[] { CultMeshBodyPublicationSchemaVersions.Publication }) + .GetAwaiter().GetResult().FirstOrDefault() + ?? throw new InvalidOperationException($"Provider did not publish CultMesh body '{bodyId}'."); + var lease = _bodyResolver.ResolveReadOnly(publication, new CultMeshBodyValidationRequest + { + BodyId = bodyId, + SchemaId = document.BodySchemaId, + LayoutVersion = document.LayoutVersion, + ProducerEpoch = document.ProducerEpoch, + Sequence = document.Sequence, + Capacity = document.Capacity, + AccessMode = CultMeshBodyAccessMode.ReadOnly, + NowUtc = DateTimeOffset.UtcNow + }); + var handler = EntityViewAvailable; + if (handler == null) + { + lease.Dispose(); + return; + } + try + { + handler(document, lease); + } + catch + { + lease.Dispose(); + throw; + } + } + private void RefreshSurface() { var surface = _snapshot! diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs index ac39266..ec1a239 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Collections.Concurrent; using GameCult.Eve.Surface; +using GameCult.Mesh; using UnityEngine; #nullable enable @@ -29,11 +30,9 @@ namespace GameCult.Eve.UnityScene private EveUnityCultMeshLiveProviderTransport? _transport; private EveUnitySceneLiveProviderBridge? _bridge; - private readonly ConcurrentQueue _pendingEntityViews = new ConcurrentQueue(); + private readonly ConcurrentQueue _pendingEntityViews = new ConcurrentQueue(); - public EveEntitySoaViewDocument? CurrentEntityView { get; private set; } - - public event Action? EntityViewAvailable; + public event Action? EntityViewAvailable; public EveUnityCultMeshProviderSelection? Selection { get; private set; } @@ -119,6 +118,7 @@ namespace GameCult.Eve.UnityScene { _bridge?.Dispose(); _transport?.Dispose(); + while (_pendingEntityViews.TryDequeue(out var pending)) pending.Lease.Dispose(); _bridge = null; _transport = null; Selection = null; @@ -127,11 +127,16 @@ namespace GameCult.Eve.UnityScene private void Update() { _transport?.PumpLiveEvents(); - EveEntitySoaViewDocument? latest = null; - while (_pendingEntityViews.TryDequeue(out var next)) latest = next; + EntityViewLease? latest = null; + while (_pendingEntityViews.TryDequeue(out var next)) + { + latest?.Lease.Dispose(); + latest = next; + } if (latest == null) return; - CurrentEntityView = latest; - EntityViewAvailable?.Invoke(latest); + var handler = EntityViewAvailable; + if (handler == null) latest.Lease.Dispose(); + else handler(latest.Document, latest.Lease); } private EveUnitySceneLiveProviderBridge Bridge @@ -165,8 +170,20 @@ namespace GameCult.Eve.UnityScene Selection.ProviderId, Selection.SurfaceId, runtimeId); - _transport.EntityViewAvailable += view => _pendingEntityViews.Enqueue(view); + _transport.EntityViewAvailable += (view, lease) => _pendingEntityViews.Enqueue(new EntityViewLease(view, lease)); _bridge = new EveUnitySceneLiveProviderBridge(_transport); } + + private sealed class EntityViewLease + { + public EntityViewLease(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease) + { + Document = document; + Lease = lease; + } + + public EveEntitySoaViewDocument Document { get; } + public ICultMeshBodyReadLease Lease { get; } + } } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs index de83f46..17a89a0 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaPresenter.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using GameCult.Eve.Surface; +using GameCult.Mesh; #nullable enable @@ -9,8 +10,7 @@ namespace GameCult.Eve.UnityScene { public interface IEveUnityEntitySoaViewDocumentSource { - EveEntitySoaViewDocument? CurrentEntityView { get; } - event Action EntityViewAvailable; + event Action EntityViewAvailable; } /// Projects portable entity SoA generations into the ordinary Unity scene sink. @@ -24,9 +24,9 @@ namespace GameCult.Eve.UnityScene _sink = sink ?? throw new ArgumentNullException(nameof(sink)); } - public void Apply(EveEntitySoaViewDocument document) + public void Apply(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease) { - using var view = EveUnityEntitySoaView.Open(document); + using var view = EveUnityEntitySoaView.Open(document, lease); var next = new HashSet(StringComparer.Ordinal); for (var row = 0; row < view.EntityCount; row++) { @@ -37,14 +37,15 @@ namespace GameCult.Eve.UnityScene view.TryReadFloat("render.scale", row, out var scale); view.TryReadUInt32("render.group.id", row, out var groupId); var renderGroup = view.RenderGroups.FirstOrDefault(group => group.GroupId == groupId); - var entityId = $"entity:{entityIndex}"; + var identity = document.Identities.FirstOrDefault(item => item.Index == entityIndex); + var entityId = identity?.EntityId ?? $"entity:{entityIndex}"; next.Add(entityId); var assetRef = renderGroup?.MeshAssetRef ?? ""; _sink.UpsertEntity( new EveUnityPlayableWorldEntity( entityId, entityId, - "entity", + identity?.EntityKind ?? "entity", entityId, "", assetRef, diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs index f1498ef..8aab4de 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityEntitySoaView.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using System.IO.MemoryMappedFiles; using System.Linq; using GameCult.Eve.Surface; +using GameCult.Mesh; using UnityEngine; #nullable enable @@ -11,104 +11,124 @@ namespace GameCult.Eve.UnityScene { public sealed class EveUnityEntitySoaView : IDisposable { - private readonly Dictionary _buffers = - new Dictionary(StringComparer.Ordinal); + private readonly ICultMeshBodyReadLease _lease; + private readonly Dictionary _buffers; private readonly Dictionary _columns; - private EveUnityEntitySoaView(EveEntitySoaViewDocument document) + private EveUnityEntitySoaView(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease) { Document = document; + _lease = lease; + _buffers = document.Buffers.ToDictionary(buffer => buffer.BufferId, StringComparer.Ordinal); _columns = document.Columns.ToDictionary(column => column.Semantic, StringComparer.Ordinal); - foreach (var buffer in document.Buffers) - { - if (!string.Equals(buffer.Backend, "memory_mapped_file", StringComparison.Ordinal)) - throw new NotSupportedException($"EveUnity does not support entity SoA backend '{buffer.Backend}'."); - var mapped = MemoryMappedFile.OpenExisting(buffer.Location, MemoryMappedFileRights.Read); - _buffers.Add(buffer.BufferId, mapped.CreateViewAccessor( - buffer.ByteOffset, - buffer.ByteLength, - MemoryMappedFileAccess.Read)); - mapped.Dispose(); - } } public EveEntitySoaViewDocument Document { get; } - public long Generation => Document.Generation; + public long Generation => Document.Sequence; public IReadOnlyList RenderGroups => Document.RenderGroups; + public int EntityCount => Document.Columns.Length == 0 ? 0 : Document.Columns.Min(column => column.ElementCount); - public static EveUnityEntitySoaView Open(EveEntitySoaViewDocument document) + public static EveUnityEntitySoaView Open(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease) { if (document == null) throw new ArgumentNullException(nameof(document)); - if (!string.Equals(document.Schema, EveEntitySoaViewDocument.SchemaId, StringComparison.Ordinal)) - throw new ArgumentException($"Expected {EveEntitySoaViewDocument.SchemaId}, got '{document.Schema}'.", nameof(document)); - return new EveUnityEntitySoaView(document); + if (lease == null) throw new ArgumentNullException(nameof(lease)); + Validate(document, lease); + return new EveUnityEntitySoaView(document, lease); } public bool TryReadVector3(string semantic, int index, out Vector3 value) { value = default; - if (!TryColumn(semantic, index, "float3", 12, out var column, out var accessor)) return false; - var offset = column.ByteOffset + (long)index * column.ElementStride; - value = new Vector3(accessor.ReadSingle(offset), accessor.ReadSingle(offset + 4), accessor.ReadSingle(offset + 8)); + if (!TryColumn(semantic, index, "float3", 12, out var offset)) return false; + value = new Vector3(_lease.ReadSingle(offset), _lease.ReadSingle(offset + 4), _lease.ReadSingle(offset + 8)); return true; } public bool TryReadFloat(string semantic, int index, out float value) { value = default; - if (!TryColumn(semantic, index, "float32", 4, out var column, out var accessor)) return false; - value = accessor.ReadSingle(column.ByteOffset + (long)index * column.ElementStride); + if (!TryColumn(semantic, index, "float32", 4, out var offset)) return false; + value = _lease.ReadSingle(offset); return true; } public bool TryReadInt32(string semantic, int index, out int value) { value = default; - if (!TryColumn(semantic, index, "int32", 4, out var column, out var accessor)) return false; - value = accessor.ReadInt32(column.ByteOffset + (long)index * column.ElementStride); + if (!TryColumn(semantic, index, "int32", 4, out var offset)) return false; + value = _lease.ReadInt32(offset); return true; } public bool TryReadUInt32(string semantic, int index, out uint value) { value = default; - if (!TryColumn(semantic, index, "uint32", 4, out var column, out var accessor)) return false; - value = accessor.ReadUInt32(column.ByteOffset + (long)index * column.ElementStride); + if (!TryColumn(semantic, index, "uint32", 4, out var offset)) return false; + value = unchecked((uint)_lease.ReadInt32(offset)); return true; } - public int EntityCount => Document.Columns.Length == 0 - ? 0 - : Document.Columns.Min(column => column.ElementCount); - public bool TryReadByte(string semantic, int index, out byte value) { value = default; - if (!TryColumn(semantic, index, "uint8", 1, out var column, out var accessor)) return false; - value = accessor.ReadByte(column.ByteOffset + (long)index * column.ElementStride); + if (!TryColumn(semantic, index, "uint8", 1, out var offset)) return false; + value = _lease.ReadByte(offset); return true; } - public void Dispose() + public void Dispose() => _lease.Dispose(); + + private static void Validate(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease) { - foreach (var accessor in _buffers.Values) accessor.Dispose(); - _buffers.Clear(); + if (!string.Equals(document.Schema, EveEntitySoaViewDocument.SchemaId, StringComparison.Ordinal)) + throw new ArgumentException($"Expected {EveEntitySoaViewDocument.SchemaId}, got '{document.Schema}'.", nameof(document)); + if (document.Buffers == null || document.Buffers.Length == 0) + throw new ArgumentException("Entity SoA view requires a primary logical buffer.", nameof(document)); + var descriptor = lease.Descriptor ?? throw new ArgumentException("Body lease has no descriptor.", nameof(lease)); + var primary = document.Buffers[0]; + if (!string.Equals(descriptor.BodyId, primary.BufferId, StringComparison.Ordinal) || + !string.Equals(descriptor.SchemaId, document.BodySchemaId, StringComparison.Ordinal) || + descriptor.LayoutVersion != document.LayoutVersion || + descriptor.ProducerEpoch != document.ProducerEpoch || + descriptor.Sequence != document.Sequence || + descriptor.Capacity != document.Capacity) + throw new InvalidOperationException("CultMesh body lease generation does not match the Eve entity layout."); + if (document.Capacity < 0 || document.Buffers.Any(buffer => + string.IsNullOrWhiteSpace(buffer.BufferId) || buffer.ByteOffset < 0 || buffer.ByteLength < 0 || + buffer.ByteOffset > descriptor.ByteSize - buffer.ByteLength)) + throw new InvalidOperationException("Eve entity layout contains an invalid logical buffer range."); + var buffers = document.Buffers.ToDictionary(buffer => buffer.BufferId, StringComparer.Ordinal); + foreach (var column in document.Columns ?? Array.Empty()) + { + if (!buffers.TryGetValue(column.BufferId, out var buffer) || column.ByteOffset < 0 || + column.ElementStride <= 0 || column.ElementCount < 0 || column.ElementCount > document.Capacity) + throw new InvalidOperationException($"Eve entity column '{column.ColumnId}' has invalid layout metadata."); + var width = ScalarWidth(column.ScalarType); + var occupied = column.ElementCount == 0 ? 0 : (long)(column.ElementCount - 1) * column.ElementStride + width; + if (width == 0 || column.ByteOffset > buffer.ByteLength - occupied) + throw new InvalidOperationException($"Eve entity column '{column.ColumnId}' exceeds its logical buffer."); + } } - private bool TryColumn( - string semantic, - int index, - string scalarType, - int minimumStride, - out EveEntitySoaColumn column, - out MemoryMappedViewAccessor accessor) + private bool TryColumn(string semantic, int index, string scalarType, int minimumStride, out long offset) { - column = null!; - accessor = null!; - if (index < 0 || !_columns.TryGetValue(semantic ?? "", out column)) return false; + offset = 0; + if (index < 0 || !_columns.TryGetValue(semantic ?? "", out var column)) return false; if (index >= column.ElementCount || column.ElementStride < minimumStride) return false; if (!string.Equals(column.ScalarType, scalarType, StringComparison.Ordinal)) return false; - return _buffers.TryGetValue(column.BufferId, out accessor); + if (!_buffers.TryGetValue(column.BufferId, out var buffer)) return false; + offset = buffer.ByteOffset + column.ByteOffset + (long)index * column.ElementStride; + return true; } + + private static int ScalarWidth(string scalarType) => scalarType switch + { + "float3" => 12, + "float32" => 4, + "int32" => 4, + "uint32" => 4, + "uint8" => 1, + _ => 0 + }; } } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs index 0fc0ad1..5871920 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldRuntime.cs @@ -202,11 +202,10 @@ namespace GameCult.Eve.UnityScene if (_entityViewsConnected || _entityViews == null) return; _entityViews.EntityViewAvailable += OnEntityViewAvailable; _entityViewsConnected = true; - if (_entityViews.CurrentEntityView != null) - _entityPresenter.Apply(_entityViews.CurrentEntityView); } - private void OnEntityViewAvailable(EveEntitySoaViewDocument document) => _entityPresenter.Apply(document); + private void OnEntityViewAvailable(EveEntitySoaViewDocument document, GameCult.Mesh.ICultMeshBodyReadLease lease) => + _entityPresenter.Apply(document, lease); } public sealed class EveUnityLivePlayableWorldAssetProvider : IEveUnityNativeAssetProvider diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index 10db12d..a153dee 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO.MemoryMappedFiles; using GameCult.Eve.Surface; using GameCult.Mesh; using NUnit.Framework; @@ -13,42 +12,58 @@ namespace GameCult.Eve.UnityScene.Tests public sealed class EveUnitySceneSurfaceLowererTests { [Test] - public void EntitySoaViewReadsGenericSemanticColumnsFromAdvertisedBackend() + public void EntitySoaViewReadsGenericSemanticColumnsFromInjectedLease() { - var location = $"eve-unity-soa-{Guid.NewGuid():N}"; - using var mapped = MemoryMappedFile.CreateNew(location, 32); - using (var writer = mapped.CreateViewAccessor()) - { - writer.Write(0, 4f); - writer.Write(4, 5f); - writer.Write(8, 6f); - writer.Write(12, 7f); - writer.Write(16, 8f); - writer.Write(20, 9f); - writer.Write(24, 41); - writer.Write(28, 42); - } + var bytes = new byte[32]; + Buffer.BlockCopy(new[] { 4f, 5f, 6f, 7f, 8f, 9f }, 0, bytes, 0, 24); + Buffer.BlockCopy(new[] { 41, 42 }, 0, bytes, 24, 8); var document = new EveEntitySoaViewDocument { ProviderId = "provider", ViewId = "pilot", - Generation = 7, - Backend = "memory_mapped_file", - Buffers = new[] { new EveEntitySoaBuffer { BufferId = "hot", Backend = "memory_mapped_file", Location = location, ByteLength = 32, Generation = 7 } }, + BodySchemaId = "test.entity.slab.v1", + LayoutVersion = 3, + ProducerEpoch = 5, + Sequence = 7, + Capacity = 2, + Buffers = new[] { new EveEntitySoaBuffer { BufferId = "hot", ByteLength = 32 } }, Columns = new[] { new EveEntitySoaColumn { ColumnId = "position", Semantic = "transform.position", BufferId = "hot", ScalarType = "float3", ElementStride = 12, ElementCount = 2 }, new EveEntitySoaColumn { ColumnId = "entity-index", Semantic = "entity.index", BufferId = "hot", ScalarType = "int32", ByteOffset = 24, ElementStride = 4, ElementCount = 2 } } }; + var lease = new FakeBodyReadLease(document, bytes); - using var view = EveUnityEntitySoaView.Open(document); + using var view = EveUnityEntitySoaView.Open(document, lease); Assert.That(view.TryReadVector3("transform.position", 1, out var position), Is.True); Assert.That(position, Is.EqualTo(new Vector3(7f, 8f, 9f))); Assert.That(view.TryReadInt32("entity.index", 0, out var entityIndex), Is.True); Assert.That(entityIndex, Is.EqualTo(41)); Assert.That(view.Generation, Is.EqualTo(7)); + Assert.That(lease.Disposed, Is.False); + } + + [TestCase("body")] + [TestCase("schema")] + [TestCase("layout")] + [TestCase("epoch")] + [TestCase("sequence")] + [TestCase("capacity")] + public void EntitySoaViewRejectsLeaseThatDisagreesWithLogicalLayout(string mismatch) + { + var document = EntityLeaseDocument(); + var descriptor = FakeBodyReadLease.DescriptorFor(document, 4); + if (mismatch == "body") descriptor.BodyId = "wrong"; + if (mismatch == "schema") descriptor.SchemaId = "wrong"; + if (mismatch == "layout") descriptor.LayoutVersion++; + if (mismatch == "epoch") descriptor.ProducerEpoch++; + if (mismatch == "sequence") descriptor.Sequence++; + if (mismatch == "capacity") descriptor.Capacity++; + + Assert.Throws(() => EveUnityEntitySoaView.Open( + document, new FakeBodyReadLease(descriptor, new byte[4]))); } [Test] @@ -1746,6 +1761,71 @@ namespace GameCult.Eve.UnityScene.Tests "", "", "", ""); } + private static EveEntitySoaViewDocument EntityLeaseDocument() => new EveEntitySoaViewDocument + { + ProviderId = "provider", + ViewId = "entities", + BodySchemaId = "test.entity.slab.v1", + LayoutVersion = 3, + ProducerEpoch = 5, + Sequence = 7, + Capacity = 1, + Buffers = new[] { new EveEntitySoaBuffer { BufferId = "hot", ByteLength = 4 } }, + Columns = new[] + { + new EveEntitySoaColumn + { + ColumnId = "entity-index", + Semantic = "entity.index", + BufferId = "hot", + ScalarType = "int32", + ElementStride = 4, + ElementCount = 1 + } + } + }; + + private sealed class FakeBodyReadLease : ICultMeshBodyReadLease + { + private readonly byte[] _bytes; + + public FakeBodyReadLease(EveEntitySoaViewDocument document, byte[] bytes) + : this(DescriptorFor(document, bytes.Length), bytes) { } + + public FakeBodyReadLease(CultMeshBodyDescriptor descriptor, byte[] bytes) + { + Descriptor = descriptor; + _bytes = bytes; + } + + public CultMeshBodyDescriptor Descriptor { get; } + public CultMeshBodyTransportKind TransportKind => CultMeshBodyTransportKind.Network; + public bool Disposed { get; private set; } + public byte ReadByte(long offset) => _bytes[checked((int)offset)]; + public int ReadInt32(long offset) => BitConverter.ToInt32(_bytes, checked((int)offset)); + public long ReadInt64(long offset) => BitConverter.ToInt64(_bytes, checked((int)offset)); + public float ReadSingle(long offset) => BitConverter.ToSingle(_bytes, checked((int)offset)); + public double ReadDouble(long offset) => BitConverter.ToDouble(_bytes, checked((int)offset)); + public int CopyTo(long offset, byte[] destination, int destinationOffset, int count) + { + Buffer.BlockCopy(_bytes, checked((int)offset), destination, destinationOffset, count); + return count; + } + public void Dispose() => Disposed = true; + + public static CultMeshBodyDescriptor DescriptorFor(EveEntitySoaViewDocument document, long byteSize) => + new CultMeshBodyDescriptor + { + BodyId = document.Buffers[0].BufferId, + SchemaId = document.BodySchemaId, + LayoutVersion = document.LayoutVersion, + ByteSize = byteSize, + Capacity = document.Capacity, + ProducerEpoch = document.ProducerEpoch, + Sequence = document.Sequence + }; + } + private sealed class FixedGameObjectAssetProvider : IEveUnityGameObjectAssetProvider { private readonly GameObject? _prefab; diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs index da482df..6b348ab 100644 --- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs +++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs @@ -9,6 +9,41 @@ 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, + Buffers = new[] { new EveEntitySoaBuffer { BufferId = "entity-hot", ByteLength = 64 } } + }; + + var bytes = MessagePackSerializer.Serialize(document, Options); + var reader = new MessagePackReader(bytes); + var restored = MessagePackSerializer.Deserialize(bytes, Options); + + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(14)); + Assert.That(restored.Schema, Is.EqualTo(EveEntitySoaViewDocument.SchemaId)); + Assert.That(restored.Buffers[0].BufferId, Is.EqualTo("entity-hot")); + } + + [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]