Add transport-neutral entity SoA view v2
This commit is contained in:
parent
7da47f04db
commit
03407c3165
9 changed files with 327 additions and 107 deletions
|
|
@ -5,10 +5,11 @@
|
|||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Description>Portable Eve surface and provider contracts.</Description>
|
||||
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="..\..\..\CultLib\src\GameCult.Mesh\GameCult.Mesh.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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<EveEntitySoaBuffer>();
|
||||
[Key(9)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty<EveEntitySoaColumn>();
|
||||
[Key(10)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty<EveEntitySoaDirtyRange>();
|
||||
[Key(11)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty<EveEntityRenderGroup>();
|
||||
[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<EveEntitySoaBuffer>();
|
||||
[Key(10)] public EveEntitySoaColumn[] Columns { get; set; } = Array.Empty<EveEntitySoaColumn>();
|
||||
[Key(11)] public EveEntitySoaDirtyRange[] DirtyRanges { get; set; } = Array.Empty<EveEntitySoaDirtyRange>();
|
||||
[Key(12)] public EveEntityRenderGroup[] RenderGroups { get; set; } = Array.Empty<EveEntityRenderGroup>();
|
||||
[Key(13)] public EveEntityIdentity[] Identities { get; set; } = Array.Empty<EveEntityIdentity>();
|
||||
}
|
||||
|
||||
[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]
|
||||
|
|
|
|||
|
|
@ -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<EveUnitySceneCommandReceipt>? CommandReceiptAvailable;
|
||||
|
||||
public event Action<EveEntitySoaViewDocument>? EntityViewAvailable;
|
||||
public event Action<EveEntitySoaViewDocument, ICultMeshBodyReadLease>? 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<CultMeshBodyPublicationDocument>(
|
||||
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!
|
||||
|
|
|
|||
|
|
@ -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<EveEntitySoaViewDocument> _pendingEntityViews = new ConcurrentQueue<EveEntitySoaViewDocument>();
|
||||
private readonly ConcurrentQueue<EntityViewLease> _pendingEntityViews = new ConcurrentQueue<EntityViewLease>();
|
||||
|
||||
public EveEntitySoaViewDocument? CurrentEntityView { get; private set; }
|
||||
|
||||
public event Action<EveEntitySoaViewDocument>? EntityViewAvailable;
|
||||
public event Action<EveEntitySoaViewDocument, ICultMeshBodyReadLease>? 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<EveEntitySoaViewDocument> EntityViewAvailable;
|
||||
event Action<EveEntitySoaViewDocument, ICultMeshBodyReadLease> EntityViewAvailable;
|
||||
}
|
||||
|
||||
/// <summary>Projects portable entity SoA generations into the ordinary Unity scene sink.</summary>
|
||||
|
|
@ -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<string>(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,
|
||||
|
|
|
|||
|
|
@ -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<string, MemoryMappedViewAccessor> _buffers =
|
||||
new Dictionary<string, MemoryMappedViewAccessor>(StringComparer.Ordinal);
|
||||
private readonly ICultMeshBodyReadLease _lease;
|
||||
private readonly Dictionary<string, EveEntitySoaBuffer> _buffers;
|
||||
private readonly Dictionary<string, EveEntitySoaColumn> _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<EveEntityRenderGroup> 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<EveEntitySoaColumn>())
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<InvalidOperationException>(() => 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;
|
||||
|
|
|
|||
|
|
@ -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<EveEntitySoaViewDocument>(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<string>(name => forbidden.Contains(name, StringComparer.Ordinal)));
|
||||
}
|
||||
|
||||
private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard;
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
Reference in a new issue