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 d359846..d95db2b 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -68,6 +68,8 @@ namespace GameCult.Eve.UnityScene private EveAdvertisedSurface? _advertisedSurface; private CultMeshBodyPublicationResolver? _bodyResolver; private EveEntitySoaViewDocument? _latestEntityLayout; + private CultMeshMappedFrameBodyCursor? _mappedEntityFrameCursor; + private CultMeshBodyDescriptor? _mappedEntityFrameContract; private long _lastQueuedEntityViewEpoch = -1; private long _lastQueuedEntityViewSequence = -1; private bool _disposed; @@ -186,6 +188,8 @@ namespace GameCult.Eve.UnityScene public void PumpLiveEvents() { + ThrowIfSubscriptionFailed(_entitySubscriptions); + ThrowIfSubscriptionFailed(_subscriptions); while (_liveDocuments.TryDequeue(out var document)) { if (document is EveEntitySoaViewDocument entityView) @@ -197,10 +201,21 @@ namespace GameCult.Eve.UnityScene else if (document is EveCommandReceiptDocument receipt) PublishReceipt(receipt); } + PumpMappedEntityFrame(); + } + + private static void ThrowIfSubscriptionFailed(CultNetDatabaseSubscriptionClient? subscription) + { + var failure = subscription?.BackgroundFailure; + if (failure?.IsCompletedSuccessfully == true) + throw new InvalidOperationException( + "The retained CultMesh state subscription failed.", + failure.Result); } private void QueueEntityView(EveEntitySoaViewDocument document) { + TraceHotState($"layout epoch={document.ProducerEpoch} sequence={document.Sequence} identities={document.Identities?.Length ?? 0}"); _latestEntityLayout = document; if (document.ProducerEpoch < _lastQueuedEntityViewEpoch || (document.ProducerEpoch == _lastQueuedEntityViewEpoch && @@ -216,9 +231,25 @@ namespace GameCult.Eve.UnityScene var layout = _latestEntityLayout; if (layout == null || layout.Buffers == null || layout.Buffers.Length == 0 || !string.Equals(layout.Buffers[0].BufferId, publication.BodyId, StringComparison.Ordinal)) + { + TraceHotState($"publication skipped body={publication.BodyId} sequence={publication.Sequence} layout={(layout == null ? "missing" : "incompatible")}"); return; + } - QueueEntityView(new EveEntitySoaViewDocument + TraceHotState($"publication body={publication.BodyId} epoch={publication.ProducerEpoch} sequence={publication.Sequence} plane={string.Join(",", publication.Representations.Select(value => value.TransportKind))}"); + + QueueEntityView(MaterializeEntityGeneration( + layout, + publication.ProducerEpoch, + publication.Sequence)); + } + + private static EveEntitySoaViewDocument MaterializeEntityGeneration( + EveEntitySoaViewDocument layout, + long producerEpoch, + long sequence) + { + return new EveEntitySoaViewDocument { Schema = layout.Schema, ProviderId = layout.ProviderId, @@ -226,8 +257,8 @@ namespace GameCult.Eve.UnityScene PublishedAtUtc = DateTimeOffset.UtcNow.ToString("O"), BodySchemaId = layout.BodySchemaId, LayoutVersion = layout.LayoutVersion, - ProducerEpoch = publication.ProducerEpoch, - Sequence = publication.Sequence, + ProducerEpoch = producerEpoch, + Sequence = sequence, Capacity = layout.Capacity, Buffers = layout.Buffers, Columns = layout.Columns, @@ -237,13 +268,13 @@ namespace GameCult.Eve.UnityScene ColumnId = range.ColumnId, StartIndex = range.StartIndex, Count = range.Count, - Sequence = publication.Sequence + Sequence = sequence }) .ToArray(), RenderGroups = layout.RenderGroups, Identities = layout.Identities, - FrameId = publication.Sequence - }); + FrameId = sequence + }; } public void SubmitCommand(EveSurfaceCommandRequest request) @@ -291,6 +322,9 @@ namespace GameCult.Eve.UnityScene _prefabs.Clear(); _nativeAssets.Clear(); _nativeAssetMetadata.Clear(); + _mappedEntityFrameCursor?.Dispose(); + _mappedEntityFrameCursor = null; + _mappedEntityFrameContract = null; _subscriptions?.Dispose(); _entitySubscriptions?.Dispose(); _node?.Dispose(); @@ -513,6 +547,7 @@ namespace GameCult.Eve.UnityScene var now = DateTimeOffset.UtcNow; if (!IsPublicationLive(publication, now)) return; + EnsureMappedEntityFrameCursor(publication); _bodyResolver ??= CreateBodyResolver(); var lease = _bodyResolver.ResolveReadOnly(publication, new CultMeshBodyValidationRequest { @@ -542,6 +577,82 @@ namespace GameCult.Eve.UnityScene } } + private void EnsureMappedEntityFrameCursor(CultMeshBodyPublicationDocument publication) + { + var descriptor = (publication.Representations ?? Array.Empty()) + .FirstOrDefault(CultMeshMappedFrameBodyCursor.CanOpen); + if (descriptor == null) + { + _mappedEntityFrameCursor?.Dispose(); + _mappedEntityFrameCursor = null; + _mappedEntityFrameContract = null; + return; + } + + var current = _mappedEntityFrameContract; + if (current != null && + string.Equals(FrameCapabilityIdentity(current.CapabilityToken), + FrameCapabilityIdentity(descriptor.CapabilityToken), StringComparison.Ordinal) && + string.Equals(current.BodyId, descriptor.BodyId, StringComparison.Ordinal) && + string.Equals(current.SchemaId, descriptor.SchemaId, StringComparison.Ordinal) && + current.LayoutVersion == descriptor.LayoutVersion && + current.Capacity == descriptor.Capacity && + current.ProducerEpoch == descriptor.ProducerEpoch && + current.ByteSize == descriptor.ByteSize) + return; + + _mappedEntityFrameCursor?.Dispose(); + _mappedEntityFrameCursor = new CultMeshMappedFrameBodyCursor(descriptor); + _mappedEntityFrameContract = descriptor; + TraceHotState( + $"mapped frame cursor body={descriptor.BodyId} epoch={descriptor.ProducerEpoch} sequence={descriptor.Sequence}"); + } + + private void PumpMappedEntityFrame() + { + var cursor = _mappedEntityFrameCursor; + var layout = _latestEntityLayout; + if (cursor == null || layout == null || !cursor.TryAcquireLatest(out var lease)) + return; + + var descriptor = lease.Descriptor; + if (descriptor.ProducerEpoch < _lastQueuedEntityViewEpoch || + (descriptor.ProducerEpoch == _lastQueuedEntityViewEpoch && + descriptor.Sequence <= _lastQueuedEntityViewSequence)) + { + lease.Dispose(); + return; + } + + _lastQueuedEntityViewEpoch = descriptor.ProducerEpoch; + _lastQueuedEntityViewSequence = descriptor.Sequence; + var generation = MaterializeEntityGeneration( + layout, + descriptor.ProducerEpoch, + descriptor.Sequence); + var handler = EntityViewAvailable; + if (handler == null) + { + lease.Dispose(); + return; + } + try + { + handler(generation, lease); + } + catch + { + lease.Dispose(); + throw; + } + } + + private static string FrameCapabilityIdentity(string capabilityToken) + { + var separator = capabilityToken.LastIndexOf('.'); + return separator > 0 ? capabilityToken.Substring(0, separator) : capabilityToken; + } + private static bool IsPublicationLive( CultMeshBodyPublicationDocument publication, DateTimeOffset now) => @@ -645,6 +756,7 @@ namespace GameCult.Eve.UnityScene CultMeshBodyTransportKind.Network.ToString() }) .GetAwaiter().GetResult(); + TraceHotState($"initial subscription documents=[{string.Join(",", initial.Select(value => value.GetType().Name))}]"); foreach (var document in initial) { if (document is EveEntitySoaViewDocument current) @@ -686,6 +798,7 @@ namespace GameCult.Eve.UnityScene private void OnReplicatedDocumentChanged(CultNetReplicatedDocumentChange change) { + TraceHotState($"change subscription={change.SubscriptionId} kind={change.ChangeKind} document={change.Document?.GetType().Name ?? "null"}"); if (change.Document is EveEntitySoaViewDocument entityView) QueueEntityView(entityView); else if (change.Document is CultMeshBodyPublicationDocument publication) @@ -694,6 +807,12 @@ namespace GameCult.Eve.UnityScene _liveDocuments.Enqueue(change.Document); } + private static void TraceHotState(string message) + { + if (string.Equals(Environment.GetEnvironmentVariable("AETHERIA_TRACE_CLIENT_RUDP"), "1", StringComparison.Ordinal)) + Debug.Log($"EveUnity CultMesh hot state: {message}"); + } + private void RefreshAssetCatalog() { var interaction = RequireWorldInteraction(); 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 a10dd96..ace3b58 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -87,6 +87,70 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(generation.DirtyRanges.All(range => range.Sequence == publication.Sequence), Is.True); } + [Test] + public void LiveTransportReadsLaterMappedFramesWithoutNewControlDocuments() + { + using var transport = new EveUnityCultMeshLiveProviderTransport( + "test-replica.cc", + "cultnet://127.0.0.1:3075", + "provider", + "provider.pilot"); + var view = EntityLeaseDocument(); + view.Sequence = 0; + using var publisher = new CultMeshFrameBodyPublisher( + view.Buffers[0].BufferId, + view.BodySchemaId, + view.LayoutVersion, + view.Capacity, + view.ProducerEpoch, + slotByteLength: 4); + publisher.TryPublish(new byte[] { 1, 2, 3, 4 }, DateTimeOffset.UtcNow, out var bootstrap); + var publication = new CultMeshBodyPublicationDocument + { + BodyId = bootstrap.BodyId, + ProducerId = view.ProviderId, + SchemaId = bootstrap.SchemaId, + LayoutVersion = bootstrap.LayoutVersion, + ByteSize = bootstrap.ByteSize, + Capacity = bootstrap.Capacity, + ProducerEpoch = bootstrap.ProducerEpoch, + Sequence = bootstrap.Sequence, + Synchronization = bootstrap.Synchronization, + LivenessExpiresAtUnixMs = bootstrap.LeaseExpiresAtUnixMs, + Representations = new[] { bootstrap } + }; + var queueView = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("QueueEntityView", BindingFlags.NonPublic | BindingFlags.Instance)!; + var ensureCursor = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("EnsureMappedEntityFrameCursor", BindingFlags.NonPublic | BindingFlags.Instance)!; + var pumpCursor = typeof(EveUnityCultMeshLiveProviderTransport) + .GetMethod("PumpMappedEntityFrame", BindingFlags.NonPublic | BindingFlags.Instance)!; + var queue = (System.Collections.Concurrent.ConcurrentQueue) + typeof(EveUnityCultMeshLiveProviderTransport) + .GetField("_liveDocuments", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(transport)!; + queueView.Invoke(transport, new object[] { view }); + queue.TryDequeue(out _); + ensureCursor.Invoke(transport, new object[] { publication }); + + publisher.TryPublish(new byte[] { 9, 8, 7, 6 }, DateTimeOffset.UtcNow, out _); + EveEntitySoaViewDocument? presented = null; + byte presentedByte = 0; + transport.EntityViewAvailable += (generation, lease) => + { + presented = generation; + presentedByte = lease.ReadByte(0); + lease.Dispose(); + }; + + pumpCursor.Invoke(transport, Array.Empty()); + + Assert.That(presented, Is.Not.Null); + Assert.That(presented!.Sequence, Is.EqualTo(1)); + Assert.That(presentedByte, Is.EqualTo(9)); + Assert.That(queue, Is.Empty, "mapped frames must not be represented as queued CultNet documents"); + } + [Test] public void LiveTransportDropsExpiredHistoricalGenerationWithoutReinterpretingLatest() { diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 24f05f5..fb59f96 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,11 +1,11 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.76", + "version": "0.3.77", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { - "org.gamecult.cultlib": "1.0.21", + "org.gamecult.cultlib": "1.0.22", "org.gamecult.eve.plugin-fields": "0.2.3", "org.gamecult.eve.surface": "0.2.4", "com.unity.mathematics": "1.3.2",