From 5fabae056bb4d6ce26fe48a5cd2b4895873531a0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 18 Jul 2026 19:22:35 +0100 Subject: [PATCH] Keep live CultMesh polling off the Unity main thread --- .../org.gamecult.eve.unity-scene/README.md | 4 + .../EveUnityCultMeshLiveProviderTransport.cs | 187 ++++++++++++------ .../EveUnityCultMeshProviderDiscovery.cs | 9 +- .../org.gamecult.eve.unity-scene/package.json | 2 +- 4 files changed, 142 insertions(+), 60 deletions(-) diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index e966e86..085a0f6 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -15,6 +15,10 @@ selection filters; they are not product knowledge required by the client. Await `PrepareAsync()` before mounting the bootstrap or reading provider ports. Discovery and session setup never run synchronously from Unity lifecycle or input getters, so an unavailable provider cannot stall the editor main thread. +Live entity, field, and receipt polling likewise runs as one non-overlapping +background operation. Unity's main thread only applies completed typed +documents, keeping Play, Pause, inspection, and rendering responsive while a +provider is slow or temporarily unreachable. The package never imports provider product assemblies. 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 9f67ee7..458cb6d 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Collections.Concurrent; +using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Eve.PluginFields; @@ -71,6 +72,8 @@ namespace GameCult.Eve.UnityScene private long _lastQueuedEntityViewEpoch = -1; private long _lastQueuedEntityViewSequence = -1; private readonly Dictionary _lastFieldsFrameByRecord = new Dictionary(StringComparer.Ordinal); + private Task? _livePoll; + private bool _disposed; public EveUnityCultMeshLiveProviderTransport( string replicaPath, @@ -186,8 +189,7 @@ namespace GameCult.Eve.UnityScene public void PumpLiveEvents() { - PollCurrentEntityView(); - PollCurrentFields(); + CompleteLivePoll(); while (_liveDocuments.TryDequeue(out var document)) { if (document is EveEntitySoaViewDocument entityView) @@ -199,54 +201,111 @@ namespace GameCult.Eve.UnityScene else if (document is EveCommandReceiptDocument receipt) PublishReceipt(receipt); } - PollPendingReceipts(); + ScheduleLivePoll(); } - private void PollCurrentFields() + private void CompleteLivePoll() { - if (DateTime.UtcNow < _nextFieldsPollUtc) + if (_livePoll == null || !_livePoll.IsCompleted) return; - _nextFieldsPollUtc = DateTime.UtcNow.AddMilliseconds(250); - var world = new EveUnitySceneSurfaceLowerer() - .Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface) - .PlayableWorld; - if (world == null) - return; - foreach (var recordRef in world.FieldVolumes - .Select(field => field.DocumentRef) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.Ordinal)) + var completed = _livePoll; + _livePoll = null; + var result = completed.GetAwaiter().GetResult(); + if (result.Error != null) + throw new InvalidOperationException("EveUnity live provider polling failed.", result.Error); + foreach (var entityView in result.EntityViews) + QueueEntityView(entityView); + foreach (var fields in result.Fields) { - var current = _snapshot! - .FetchDocumentsAsync( - recordKeys: new[] { recordRef }, - schemaIds: new[] { EveFieldsSchemas.Splats }) - .GetAwaiter().GetResult().FirstOrDefault(); - if (current == null || - (_lastFieldsFrameByRecord.TryGetValue(recordRef, out var frame) && current.FrameId <= frame)) + if (_lastFieldsFrameByRecord.TryGetValue(fields.RecordRef, out var frame) && fields.Document.FrameId <= frame) continue; - _lastFieldsFrameByRecord[recordRef] = current.FrameId; - _liveDocuments.Enqueue(current); + _lastFieldsFrameByRecord[fields.RecordRef] = fields.Document.FrameId; + _liveDocuments.Enqueue(fields.Document); } + foreach (var receipt in result.Receipts) + _liveDocuments.Enqueue(receipt); } - private void PollCurrentEntityView() + private void ScheduleLivePoll() { - if (DateTime.UtcNow < _nextEntityViewPollUtc) + if (_disposed || _snapshot == null || _livePoll != null) return; - _nextEntityViewPollUtc = DateTime.UtcNow.AddMilliseconds(250); - var lowered = new EveUnitySceneSurfaceLowerer() - .Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface); - var pointer = lowered.PlayableWorld?.EntityViewPointerId ?? ""; - if (string.IsNullOrWhiteSpace(pointer)) + var now = DateTime.UtcNow; + var pollEntityView = now >= _nextEntityViewPollUtc; + var pollFields = now >= _nextFieldsPollUtc; + var pollReceipts = _pendingCommandIds.Count > 0 && now >= _nextReceiptPollUtc; + if (!pollEntityView && !pollFields && !pollReceipts) return; - var current = _snapshot! - .FetchDocumentsAsync( - recordKeys: new[] { pointer }, - schemaIds: new[] { EveEntitySoaViewDocument.SchemaId }) - .GetAwaiter().GetResult().FirstOrDefault(); - if (current != null) - QueueEntityView(current); + if (pollEntityView) _nextEntityViewPollUtc = now.AddMilliseconds(250); + if (pollFields) _nextFieldsPollUtc = now.AddMilliseconds(250); + if (pollReceipts) _nextReceiptPollUtc = now.AddMilliseconds(250); + + var world = (pollEntityView || pollFields) + ? new EveUnitySceneSurfaceLowerer() + .Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface) + .PlayableWorld + : null; + var entityViewRef = pollEntityView ? world?.EntityViewPointerId ?? "" : ""; + var fieldRefs = pollFields && world != null + ? world.FieldVolumes + .Select(field => field.DocumentRef) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray() + : Array.Empty(); + var receiptKeys = Array.Empty(); + if (pollReceipts) + { + var interaction = RequireWorldInteraction(); + if (!string.IsNullOrWhiteSpace(interaction.ReceiptRecordRef)) + receiptKeys = _pendingCommandIds + .Select(commandId => ChildRecordKey(interaction.ReceiptRecordRef, commandId)) + .ToArray(); + } + if (string.IsNullOrWhiteSpace(entityViewRef) && fieldRefs.Length == 0 && receiptKeys.Length == 0) + return; + _livePoll = PollLiveAsync(entityViewRef, fieldRefs, receiptKeys); + } + + private async Task PollLiveAsync( + string entityViewRef, + string[] fieldRefs, + string[] receiptKeys) + { + try + { + var entities = string.IsNullOrWhiteSpace(entityViewRef) + ? Array.Empty() + : (await _snapshot!.FetchDocumentsAsync( + recordKeys: new[] { entityViewRef }, + schemaIds: new[] { EveEntitySoaViewDocument.SchemaId }) + .ConfigureAwait(false)).ToArray(); + var fields = new List(); + foreach (var recordRef in fieldRefs) + { + var current = (await _snapshot!.FetchDocumentsAsync( + recordKeys: new[] { recordRef }, + schemaIds: new[] { EveFieldsSchemas.Splats }) + .ConfigureAwait(false)).FirstOrDefault(); + if (current != null) + fields.Add(new PolledFields(recordRef, current)); + } + var receipts = receiptKeys.Length == 0 + ? Array.Empty() + : (await _snapshot!.FetchDocumentsAsync( + recordKeys: receiptKeys, + schemaIds: new[] { EveCommandReceiptDocument.SchemaId }) + .ConfigureAwait(false)).ToArray(); + return new LivePollResult(entities, fields.ToArray(), receipts, null); + } + catch (Exception error) + { + return new LivePollResult( + Array.Empty(), + Array.Empty(), + Array.Empty(), + error); + } } private void QueueEntityView(EveEntitySoaViewDocument document) @@ -260,25 +319,6 @@ namespace GameCult.Eve.UnityScene _liveDocuments.Enqueue(document); } - private void PollPendingReceipts() - { - if (_pendingCommandIds.Count == 0 || DateTime.UtcNow < _nextReceiptPollUtc) - return; - _nextReceiptPollUtc = DateTime.UtcNow.AddMilliseconds(250); - var interaction = RequireWorldInteraction(); - if (string.IsNullOrWhiteSpace(interaction.ReceiptRecordRef)) - return; - var receipts = _snapshot! - .FetchDocumentsAsync( - recordKeys: _pendingCommandIds - .Select(commandId => ChildRecordKey(interaction.ReceiptRecordRef, commandId)) - .ToArray(), - schemaIds: new[] { EveCommandReceiptDocument.SchemaId }) - .GetAwaiter().GetResult(); - foreach (var receipt in receipts) - PublishReceipt(receipt); - } - public void SubmitCommand(EveSurfaceCommandRequest request) { if (request == null) throw new ArgumentNullException(nameof(request)); @@ -316,6 +356,7 @@ namespace GameCult.Eve.UnityScene public void Dispose() { + _disposed = true; foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false); _assetBundles.Clear(); foreach (var lease in _assetBodyLeases) lease.Dispose(); @@ -340,6 +381,38 @@ namespace GameCult.Eve.UnityScene _lastFieldsFrameByRecord.Clear(); } + private sealed class LivePollResult + { + public LivePollResult( + EveEntitySoaViewDocument[] entityViews, + PolledFields[] fields, + EveCommandReceiptDocument[] receipts, + Exception? error) + { + EntityViews = entityViews; + Fields = fields; + Receipts = receipts; + Error = error; + } + + public EveEntitySoaViewDocument[] EntityViews { get; } + public PolledFields[] Fields { get; } + public EveCommandReceiptDocument[] Receipts { get; } + public Exception? Error { get; } + } + + private sealed class PolledFields + { + public PolledFields(string recordRef, EveFieldsSplatsDocument document) + { + RecordRef = recordRef; + Document = document; + } + + public string RecordRef { get; } + public EveFieldsSplatsDocument Document { get; } + } + public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) { if (asset == null) throw new ArgumentNullException(nameof(asset)); diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs index 9c9efcb..70efacf 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshProviderDiscovery.cs @@ -78,11 +78,15 @@ namespace GameCult.Eve.UnityScene throw new InvalidOperationException("The rendezvous endpoint advertised no compatible Verse endpoints."); var failures = new List(); + var observed = new List(); foreach (var candidate in candidates) { try { - var advertisement = (await FetchAdvertisementsAsync(candidate.Endpoint)) + var advertisements = await FetchAdvertisementsAsync(candidate.Endpoint); + observed.AddRange(advertisements.Select(document => + $"{candidate.Endpoint}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]")); + var advertisement = advertisements .Where(document => string.IsNullOrWhiteSpace(providerId) || string.Equals(document.ProviderId, providerId, StringComparison.Ordinal)) .Select(document => new @@ -113,7 +117,8 @@ namespace GameCult.Eve.UnityScene var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}'"; var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}"; - throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}"); + var observedDetail = observed.Count == 0 ? "" : $" Observed: {string.Join(" | ", observed)}"; + throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}{observedDetail}"); } private static async Task FetchAdvertisementsAsync(string endpoint) diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index cc7abb5..23b7db2 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.68", + "version": "0.3.69", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": {