Keep CultMesh I/O off the Unity main thread

This commit is contained in:
Meta Vens 2026-08-18 10:08:03 +02:00
commit a51723bd57
9 changed files with 580 additions and 158 deletions

View file

@ -76,6 +76,10 @@ Unity code may not open provider snapshot sessions, schema clients, write
forwarders, subscriptions, or replica shards against a discovered physical
route. Reads, watches, content/body transfer, realtime frames, and command
submissions share the same stable-identity `CultMeshClient` owner.
Remote bootstrap and asset materialization complete through `PrepareAsync`
before the scene is mounted. `Connect`, `Mount`, input, refresh, and disposal
cannot synchronously wait on CultMesh. A bounded command outbox owns delivery;
only unsent continuous movement/look values may be replaced by a newer value.
Viewport-sized shader inputs are likewise presentation state. A provider may
relate a logical vector port to a logical native texture port through
@ -98,8 +102,10 @@ modes into one camera opinion or infer a product camera from entity kind.
## Shared Paths
Initial connect asks one rendezvous for Verses, selects a provider surface by
semantic kind, then leases it through the selected stable provider identity.
Initial preparation asks one rendezvous for Verses, selects an advertised
authority runtime and provider surface by semantic kind, then leases it through
the explicit Verse/authority-runtime target. The Eve provider ID remains a
separate surface-owner identity.
Refresh, route rotation, reconnect, and terminal-receipt reconciliation remain
inside the same identity-owned session. Movement, focus, target, and action
input all become `gamecult.eve.command_invocation.v1` through the

View file

@ -16,20 +16,23 @@ owned by their native lowerers and are not duplicated into empty UI elements.
networked client. Give it one Verse rendezvous endpoint and it discovers an
advertised `interactive-world`, constructs the generic CultMesh transport, and
provides every bootstrap port. Provider, surface, and Verse IDs are optional
selection filters; they are not product knowledge required by the client.
The selected provider ID remains the stable CultMesh endpoint identity while
the Verse ID names the selected rule/progression world. Physical routes are
resolved and re-resolved inside `CultMeshClient`; they are never
published as provider selection state or cached as Unity authority. The optional
local path is only an ephemeral content/body cache, not a replicated gameplay
database.
selection filters; they are not product knowledge required by the client. The
selected Verse ID and its advertised authority-runtime ID form the stable
CultMesh session target. The Eve provider ID separately names the UI owner found
through that target. Physical routes are resolved and re-resolved inside
`CultMeshClient`; they are never published as provider selection state or
cached as Unity authority. The optional local path is only an ephemeral
content/body cache, not a replicated gameplay database.
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.
It owns discovery, initial document reads, content transfer, asynchronous asset
bundle loading, and live lease installation. Once preparation succeeds,
`Mount()` and `Connect()` perform no remote work. Input submission only enters a
bounded asynchronous outbox: one-shot commands retain their idempotency keys,
while unsent movement and look values coalesce to the latest value per entity.
Live entity, field, and receipt polling likewise remains non-blocking. Unity's
main thread applies completed typed documents and assets; it never waits for a
CultMesh task, keeping Play, Pause, input, inspection, and rendering responsive
while a provider is slow or temporarily unreachable.
The package never imports provider product assemblies.

View file

@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
#nullable enable
namespace GameCult.Eve.UnityScene
{
/// <summary>
/// Owns asynchronous command delivery for the CultMesh Unity transport.
/// The Unity input path only enqueues. One-shot commands retain their
/// idempotency key; continuous controls retain only the newest unsent value.
/// </summary>
internal sealed class EveUnityCultMeshCommandOutbox : IDisposable
{
private readonly object _gate = new object();
private readonly Queue<OutboxItem> _commands = new Queue<OutboxItem>();
private readonly Dictionary<string, OutboxItem> _continuous =
new Dictionary<string, OutboxItem>(StringComparer.Ordinal);
private readonly Func<EveSurfaceCommandRequest, CancellationToken, Task> _send;
private readonly Func<EveSurfaceCommandRequest, string?> _continuousKey;
private readonly Action<string> _discard;
private readonly int _commandCapacity;
private readonly int _continuousCapacity;
private readonly SemaphoreSlim _available = new SemaphoreSlim(0);
private readonly CancellationTokenSource _lifetime = new CancellationTokenSource();
private readonly Task _worker;
private bool _disposed;
private int _inFlight;
public EveUnityCultMeshCommandOutbox(
Func<EveSurfaceCommandRequest, CancellationToken, Task> send,
Func<EveSurfaceCommandRequest, string?> continuousKey,
Action<string>? discard = null,
int commandCapacity = 256,
int continuousCapacity = 32)
{
_send = send ?? throw new ArgumentNullException(nameof(send));
_continuousKey = continuousKey ?? throw new ArgumentNullException(nameof(continuousKey));
_discard = discard ?? (_ => { });
if (commandCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(commandCapacity));
if (continuousCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(continuousCapacity));
_commandCapacity = commandCapacity;
_continuousCapacity = continuousCapacity;
_worker = Task.Run(DeliverAsync);
}
internal int PendingCount
{
get
{
lock (_gate) return _commands.Count + _continuous.Count + _inFlight;
}
}
public void Enqueue(EveSurfaceCommandRequest request)
{
if (request == null) throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(request.CommandId))
throw new InvalidOperationException("Eve command invocations require an idempotency key.");
string? discarded = null;
var signal = false;
lock (_gate)
{
if (_disposed) throw new ObjectDisposedException(nameof(EveUnityCultMeshCommandOutbox));
var key = _continuousKey(request);
var item = new OutboxItem(request, string.IsNullOrWhiteSpace(key) ? null : key);
if (string.IsNullOrWhiteSpace(key))
{
if (_commands.Count >= _commandCapacity)
throw new InvalidOperationException(
$"The EveUnity command outbox reached its {_commandCapacity}-command bound.");
_commands.Enqueue(item);
signal = true;
}
else if (_continuous.TryGetValue(key, out var previous))
{
_continuous[key] = item;
discarded = previous.Request.CommandId;
}
else
{
if (_continuous.Count >= _continuousCapacity)
throw new InvalidOperationException(
$"The EveUnity continuous-control outbox reached its {_continuousCapacity}-binding bound.");
_continuous.Add(key, item);
signal = true;
}
}
if (!string.IsNullOrWhiteSpace(discarded)) _discard(discarded);
if (signal) _available.Release();
}
public void Dispose()
{
OutboxItem[] discarded;
lock (_gate)
{
if (_disposed) return;
_disposed = true;
discarded = _commands.Concat(_continuous.Values).ToArray();
_commands.Clear();
_continuous.Clear();
}
foreach (var item in discarded) _discard(item.Request.CommandId);
_lifetime.Cancel();
_available.Release();
}
private async Task DeliverAsync()
{
var cancellationToken = _lifetime.Token;
try
{
while (true)
{
await _available.WaitAsync(cancellationToken).ConfigureAwait(false);
var item = TakeNext();
if (item == null) continue;
try
{
var retryDelay = 50;
while (true)
{
try
{
await _send(item.Request, cancellationToken).ConfigureAwait(false);
break;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
if (WasSuperseded(item)) break;
await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false);
retryDelay = Math.Min(retryDelay * 2, 2000);
}
}
}
finally
{
lock (_gate) _inFlight--;
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
}
private OutboxItem? TakeNext()
{
lock (_gate)
{
if (_commands.Count > 0)
{
_inFlight++;
return _commands.Dequeue();
}
if (_continuous.Count == 0) return null;
var pair = _continuous.First();
_continuous.Remove(pair.Key);
_inFlight++;
return pair.Value;
}
}
private bool WasSuperseded(OutboxItem item)
{
if (item.ContinuousKey == null) return false;
lock (_gate) return _continuous.ContainsKey(item.ContinuousKey);
}
private sealed class OutboxItem
{
public OutboxItem(EveSurfaceCommandRequest request, string? continuousKey)
{
Request = request;
ContinuousKey = continuousKey;
}
public EveSurfaceCommandRequest Request { get; }
public string? ContinuousKey { get; }
}
}
}

View file

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 61d76e98f2054d86bf8e7480d4411c39
timeCreated: 1787039650

View file

@ -45,6 +45,7 @@ namespace GameCult.Eve.UnityScene
new Dictionary<string, EveAssetVariant>(StringComparer.Ordinal);
private readonly HashSet<string> _loadedBundleUris = new HashSet<string>(StringComparer.Ordinal);
private readonly HashSet<string> _loadingBundleUris = new HashSet<string>(StringComparer.Ordinal);
private readonly HashSet<string> _continuousCommandIds = new HashSet<string>(StringComparer.Ordinal);
private readonly List<ICultMeshBodyReadLease> _assetBodyLeases = new List<ICultMeshBodyReadLease>();
private readonly Dictionary<string, int> _renderChannelLayers = new Dictionary<string, int>(StringComparer.Ordinal);
private readonly ConcurrentQueue<object> _liveDocuments = new ConcurrentQueue<object>();
@ -57,6 +58,7 @@ namespace GameCult.Eve.UnityScene
new Dictionary<string, EveSurfaceDocument>(StringComparer.Ordinal);
private readonly List<IDisposable> _documentLeases = new List<IDisposable>();
private readonly List<IDisposable> _documentWatches = new List<IDisposable>();
private readonly CancellationTokenSource _lifetime = new CancellationTokenSource();
private CultMeshClient? _meshClient;
private bool _liveDocumentsReady;
private CultCache? _contentState;
@ -73,6 +75,9 @@ namespace GameCult.Eve.UnityScene
private CancellationTokenSource? _realtimeLifetime;
private Task? _realtimePump;
private CultMeshRealtimeSession? _realtimeSession;
private EveUnityCultMeshCommandOutbox? _commandOutbox;
private EveAssetCatalogDocument? _pendingAssetCatalog;
private bool _assetCatalogUpdateRunning;
private long _lastQueuedEntityViewEpoch = -1;
private long _lastQueuedEntityViewSequence = -1;
private long _lastPresentedEntityViewEpoch = -1;
@ -147,8 +152,7 @@ namespace GameCult.Eve.UnityScene
public void Connect()
{
EnsureOpen();
BootstrapIfRequired();
EnsurePrepared();
}
public void Disconnect()
@ -157,32 +161,33 @@ namespace GameCult.Eve.UnityScene
public void Refresh()
{
EnsureOpen();
if (_bootstrapped)
{
PumpLiveEvents();
return;
}
BootstrapIfRequired();
EnsurePrepared();
PumpLiveEvents();
}
private void BootstrapIfRequired()
public async Task PrepareAsync(CancellationToken cancellationToken = default)
{
if (_bootstrapped)
return;
try
{
EnsureOpen();
var elapsed = Stopwatch.StartNew();
ResolveAdvertisement(forceRefresh: true);
await ResolveAdvertisementAsync(forceRefresh: true, cancellationToken);
TraceStartup("advertisement", elapsed);
RefreshSurface();
await RefreshSurfaceAsync(cancellationToken);
TraceStartup("surface", elapsed);
RefreshAssetCatalog();
await RefreshAssetCatalogAsync(cancellationToken);
TraceStartup("asset-catalog-and-bundles", elapsed);
EnsureLiveDocuments();
await PreloadAssetsAsync(cancellationToken);
TraceStartup("asset-preload", elapsed);
await EnsureLiveDocumentsAsync(cancellationToken);
TraceStartup("subscriptions", elapsed);
_commandOutbox = new EveUnityCultMeshCommandOutbox(
SendCommandAsync,
ContinuousCommandKey,
ForgetPendingCommand);
_bootstrapped = true;
}
catch (Exception error) when (error is IOException || error is SocketException || error is TimeoutException)
@ -193,6 +198,13 @@ namespace GameCult.Eve.UnityScene
}
}
private void EnsurePrepared()
{
if (!_bootstrapped)
throw new InvalidOperationException(
"The EveUnity CultMesh transport is not prepared. Await PrepareAsync before mounting or connecting it.");
}
private static void TraceStartup(string phase, Stopwatch elapsed)
{
if (string.Equals(Environment.GetEnvironmentVariable("EVEUNITY_TRACE_STARTUP_PHASES"), "1", StringComparison.Ordinal))
@ -234,7 +246,7 @@ namespace GameCult.Eve.UnityScene
else if (document is EveInputCapabilityDocument inputCapability)
CurrentInputCapability = inputCapability;
else if (document is EveAssetCatalogDocument assetCatalog)
PublishAssetCatalog(assetCatalog);
QueueAssetCatalogUpdate(assetCatalog);
else if (document is EveCommandReceiptDocument receipt)
PublishReceipt(receipt);
else if (document is Exception error)
@ -340,8 +352,7 @@ namespace GameCult.Eve.UnityScene
public void SubmitCommand(EveSurfaceCommandRequest request)
{
if (request == null) throw new ArgumentNullException(nameof(request));
EnsureOpen();
ResolveAdvertisement();
EnsurePrepared();
var interaction = RequireWorldInteraction();
if (string.IsNullOrWhiteSpace(interaction.CommandRecordRef))
throw new InvalidOperationException("The provider advertisement does not publish a command record reference.");
@ -355,29 +366,48 @@ namespace GameCult.Eve.UnityScene
if (string.IsNullOrWhiteSpace(commandId))
throw new InvalidOperationException("Eve command invocations require an idempotency key.");
_pendingCommandIds.Add(commandId);
try
{
var recordKey = ChildRecordKey(interaction.CommandRecordRef, commandId);
_meshClient!.SubmitDocumentAsync(
_target,
recordKey,
request,
_runtimeId,
"eve-unity")
.GetAwaiter()
.GetResult();
}
lock (_pendingCommandIds) _pendingCommandIds.Add(commandId);
try { _commandOutbox!.Enqueue(request); }
catch
{
_pendingCommandIds.Remove(commandId);
ForgetPendingCommand(commandId);
throw;
}
}
private Task SendCommandAsync(EveSurfaceCommandRequest request, CancellationToken cancellationToken)
{
var interaction = RequireWorldInteraction();
var recordKey = ChildRecordKey(interaction.CommandRecordRef, request.CommandId);
return _meshClient!.SubmitDocumentAsync(
_target,
recordKey,
request,
_runtimeId,
"eve-unity",
cancellationToken);
}
private string? ContinuousCommandKey(EveSurfaceCommandRequest request)
{
if (!request.PayloadFields.TryGetValue("commandId", out var commandId) ||
!_continuousCommandIds.Contains(commandId))
return null;
request.PayloadFields.TryGetValue("entityId", out var entityId);
return commandId + "\u001f" + (entityId ?? "");
}
private void ForgetPendingCommand(string commandId)
{
lock (_pendingCommandIds) _pendingCommandIds.Remove(commandId);
}
public void Dispose()
{
_disposed = true;
_lifetime.Cancel();
_commandOutbox?.Dispose();
_commandOutbox = null;
foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false);
_assetBundles.Clear();
foreach (var lease in _assetBodyLeases) lease.Dispose();
@ -396,11 +426,6 @@ namespace GameCult.Eve.UnityScene
_realtimeLifetime?.Cancel();
_realtimeSession?.Dispose();
_realtimeSession = null;
if (_realtimePump != null)
{
try { _realtimePump.GetAwaiter().GetResult(); }
catch (OperationCanceledException) { }
}
_realtimePump = null;
_realtimeLifetime?.Dispose();
_realtimeLifetime = null;
@ -413,14 +438,18 @@ namespace GameCult.Eve.UnityScene
_assetBodyMappings = null;
_baseSurface = null;
_embeddedSurfaces.Clear();
_pendingCommandIds.Clear();
_publishedReceiptIds.Clear();
lock (_pendingCommandIds)
{
_pendingCommandIds.Clear();
_publishedReceiptIds.Clear();
}
_lifetime.Dispose();
}
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
{
if (asset == null) throw new ArgumentNullException(nameof(asset));
EnsureAssetLoaded(asset.AssetRef);
EnsureAssetPrepared(asset.AssetRef);
return _prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null;
}
@ -428,11 +457,19 @@ namespace GameCult.Eve.UnityScene
{
if (asset == null) throw new ArgumentNullException(nameof(asset));
if (assetType == null) throw new ArgumentNullException(nameof(assetType));
EnsureAssetLoaded(asset.AssetRef);
EnsureAssetPrepared(asset.AssetRef);
return _nativeAssets.TryGetValue(asset.AssetRef, out var value) && assetType.IsInstanceOfType(value)
? value : null;
}
private void EnsureAssetPrepared(string assetRef)
{
EnsurePrepared();
if (_assetSelections.ContainsKey(assetRef) && !_nativeAssets.ContainsKey(assetRef))
throw new InvalidOperationException(
$"Provider asset '{assetRef}' was not materialized during asynchronous preparation.");
}
public bool TryResolveAssetMetadata(
EveUnityPlayableWorldAssetBinding asset,
out IReadOnlyDictionary<string, string> metadata)
@ -460,15 +497,16 @@ namespace GameCult.Eve.UnityScene
});
}
private void ResolveAdvertisement(bool forceRefresh = false)
private async Task ResolveAdvertisementAsync(
bool forceRefresh = false,
CancellationToken cancellationToken = default)
{
if (!forceRefresh && _advertisement != null && _advertisedSurface != null)
return;
using var advertisements = _meshClient!
.LeaseCollectionAsync<EveProviderAdvertisementDocument>(_target)
.GetAwaiter()
.GetResult();
_advertisement = advertisements.Handle.LatestAsync().GetAwaiter().GetResult().FirstOrDefault(document =>
using var lease = await _meshClient!
.LeaseCollectionAsync<EveProviderAdvertisementDocument>(_target, cancellationToken);
var documents = await lease.Handle.LatestAsync();
_advertisement = documents.FirstOrDefault(document =>
string.Equals(document.ProviderId, _providerId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"Provider '{_providerId}' did not publish an Eve advertisement.");
_advertisedSurface = _advertisement.Surfaces.FirstOrDefault(surface =>
@ -535,7 +573,7 @@ namespace GameCult.Eve.UnityScene
EveEntitySoaViewDocument document,
CultMeshBodyPublicationDocument publication)
{
ResolveAdvertisement();
EnsurePrepared();
if (!string.Equals(document.ProviderId, _advertisement!.ProviderId, StringComparison.Ordinal))
throw new UnauthorizedAccessException(
$"Entity layout provider '{document.ProviderId}' does not match advertised Eve provider '{_advertisement.ProviderId}'.");
@ -820,12 +858,10 @@ namespace GameCult.Eve.UnityScene
DateTimeOffset now) =>
publication.LivenessExpiresAtUnixMs > now.ToUnixTimeMilliseconds();
private void RefreshSurface()
private async Task RefreshSurfaceAsync(CancellationToken cancellationToken)
{
var surface = _meshClient!
.ReadAsync<EveSurfaceDocument>(_target, _advertisedSurface!.RecordRef)
.GetAwaiter()
.GetResult();
var surface = await _meshClient!
.ReadAsync<EveSurfaceDocument>(_target, _advertisedSurface!.RecordRef, cancellationToken);
PublishBaseSurface(surface);
}
@ -861,6 +897,13 @@ namespace GameCult.Eve.UnityScene
interaction.Ownership)),
_advertisedSurface.RecordRef,
surface.Version);
var lowered = new EveUnitySceneSurfaceLowerer()
.Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface);
_continuousCommandIds.Clear();
if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.MovementCommand))
_continuousCommandIds.Add(lowered.PlayableWorld.MovementCommand);
if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.LookCommand))
_continuousCommandIds.Add(lowered.PlayableWorld.LookCommand);
SurfaceDocumentAvailable?.Invoke(CurrentSurfaceDocument);
}
@ -908,13 +951,13 @@ namespace GameCult.Eve.UnityScene
component.Style);
}
private void EnsureLiveDocuments()
private async Task EnsureLiveDocumentsAsync(CancellationToken cancellationToken)
{
if (_liveDocumentsReady) return;
DisposeLiveDocuments();
try
{
OpenLiveDocuments();
await OpenLiveDocumentsAsync(cancellationToken);
_liveDocumentsReady = true;
}
catch
@ -924,7 +967,7 @@ namespace GameCult.Eve.UnityScene
}
}
private void OpenLiveDocuments()
private async Task OpenLiveDocumentsAsync(CancellationToken cancellationToken)
{
var lowered = new EveUnitySceneSurfaceLowerer()
.Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface);
@ -947,19 +990,23 @@ namespace GameCult.Eve.UnityScene
throw new InvalidOperationException(
"The playable world advertises an entity-view pointer without its logical body id.");
_bodyResolver ??= CreateBodyResolver();
LeaseDocument<EveEntitySoaViewDocument>(entityViewPointer, QueueEntityView);
LeaseCollection<CultMeshBodyPublicationDocument>(publication =>
await LeaseDocumentAsync<EveEntitySoaViewDocument>(entityViewPointer, QueueEntityView, cancellationToken);
await LeaseCollectionAsync<CultMeshBodyPublicationDocument>(publication =>
{
if (string.Equals(publication.BodyId, entityBodyId, StringComparison.Ordinal))
QueueBodyPublication(publication);
});
}, cancellationToken);
}
foreach (var fieldRef in fieldRefs)
LeaseDocument<EveFieldsSplatsDocument>(fieldRef, fields => _liveDocuments.Enqueue(fields));
await LeaseDocumentAsync<EveFieldsSplatsDocument>(
fieldRef,
fields => _liveDocuments.Enqueue(fields),
cancellationToken);
LeaseDocument<EveSurfaceDocument>(
await LeaseDocumentAsync<EveSurfaceDocument>(
_advertisedSurface!.RecordRef,
surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(_advertisedSurface.RecordRef, surface)),
cancellationToken,
publishInitial: false);
var embeddedSlots = EnumerateEmbeddedDocuments(CurrentSurfaceDocument.SurfaceDocument.Surface.Root)
.Where(slot => string.Equals(slot.SchemaId, EveSurfaceDocument.SchemaId, StringComparison.Ordinal))
@ -969,29 +1016,33 @@ namespace GameCult.Eve.UnityScene
.ToArray();
foreach (var slot in embeddedSlots)
{
LeaseDocument<EveSurfaceDocument>(
await LeaseDocumentAsync<EveSurfaceDocument>(
slot.DocumentId,
surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(slot.DocumentId, surface)));
surface => _liveDocuments.Enqueue(new PendingSurfaceDocument(slot.DocumentId, surface)),
cancellationToken);
}
var inputCapabilityRef = FindComponentProp(
CurrentSurfaceDocument.SurfaceDocument.Surface.Root,
"inputCapability");
if (!string.IsNullOrWhiteSpace(inputCapabilityRef))
{
LeaseDocument<EveInputCapabilityDocument>(
await LeaseDocumentAsync<EveInputCapabilityDocument>(
inputCapabilityRef,
document => _liveDocuments.Enqueue(document));
document => _liveDocuments.Enqueue(document),
cancellationToken);
}
var assetCatalogRef = RequireWorldInteraction().AssetManifestRecordRef;
if (!string.IsNullOrWhiteSpace(assetCatalogRef))
{
LeaseDocument<EveAssetCatalogDocument>(
await LeaseDocumentAsync<EveAssetCatalogDocument>(
assetCatalogRef,
document => _liveDocuments.Enqueue(document),
cancellationToken,
publishInitial: false);
}
LeaseCollection<EveCommandReceiptDocument>(
await LeaseCollectionAsync<EveCommandReceiptDocument>(
receipt => _liveDocuments.Enqueue(receipt),
cancellationToken,
includeInitialSnapshot: false);
}
@ -1004,29 +1055,32 @@ namespace GameCult.Eve.UnityScene
_liveDocumentsReady = false;
}
private void LeaseDocument<TDocument>(
private async Task LeaseDocumentAsync<TDocument>(
string recordKey,
Action<TDocument> publish,
CancellationToken cancellationToken,
bool publishInitial = true)
where TDocument : class
{
var lease = _meshClient!.LeaseDocumentAsync<TDocument>(_target, recordKey)
.GetAwaiter().GetResult();
var lease = await _meshClient!.LeaseDocumentAsync<TDocument>(_target, recordKey, cancellationToken);
_documentLeases.Add(lease);
if (publishInitial) publish(lease.Handle.Latest());
if (publishInitial) publish(await lease.Handle.LatestAsync());
_documentWatches.Add(lease.Handle.Watch(publish));
}
private void LeaseCollection<TDocument>(
private async Task LeaseCollectionAsync<TDocument>(
Action<TDocument> publish,
CancellationToken cancellationToken,
bool includeInitialSnapshot = true)
where TDocument : class
{
var lease = _meshClient!.LeaseCollectionAsync<TDocument>(_target, includeInitialSnapshot)
.GetAwaiter().GetResult();
var lease = await _meshClient!.LeaseCollectionAsync<TDocument>(
_target,
includeInitialSnapshot,
cancellationToken);
_documentLeases.Add(lease);
if (includeInitialSnapshot)
foreach (var document in lease.Handle.LatestAsync().GetAwaiter().GetResult())
foreach (var document in await lease.Handle.LatestAsync())
publish(document);
_documentWatches.Add(lease.Handle.WatchChanges(change =>
{
@ -1061,16 +1115,17 @@ namespace GameCult.Eve.UnityScene
Debug.Log($"EveUnity CultMesh hot state: {message}");
}
private void RefreshAssetCatalog()
private async Task RefreshAssetCatalogAsync(CancellationToken cancellationToken)
{
var interaction = RequireWorldInteraction();
if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef))
return;
var catalog = _meshClient!
.ReadAsync<EveAssetCatalogDocument>(_target, interaction.AssetManifestRecordRef)
.GetAwaiter()
.GetResult();
var catalog = await _meshClient!
.ReadAsync<EveAssetCatalogDocument>(
_target,
interaction.AssetManifestRecordRef,
cancellationToken);
PublishAssetCatalog(catalog);
}
@ -1136,14 +1191,49 @@ namespace GameCult.Eve.UnityScene
CurrentAssetCatalogVersion = catalog.Version;
}
private void EnsureAssetLoaded(string assetRef)
private void QueueAssetCatalogUpdate(EveAssetCatalogDocument catalog)
{
if (_nativeAssets.ContainsKey(assetRef) || !_assetSelections.TryGetValue(assetRef, out var selection))
return;
EnsureBundleLoaded(selection.Variant!.Uri);
_pendingAssetCatalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
if (_assetCatalogUpdateRunning) return;
_assetCatalogUpdateRunning = true;
_ = DrainAssetCatalogUpdatesAsync();
}
private void EnsureBundleLoaded(string uri)
private async Task DrainAssetCatalogUpdatesAsync()
{
try
{
while (!_lifetime.IsCancellationRequested)
{
var catalog = _pendingAssetCatalog;
_pendingAssetCatalog = null;
if (catalog == null) break;
PublishAssetCatalog(catalog);
await PreloadAssetsAsync(_lifetime.Token);
}
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
}
catch (Exception error)
{
_liveDocuments.Enqueue(error);
}
finally
{
_assetCatalogUpdateRunning = false;
if (_pendingAssetCatalog != null && !_lifetime.IsCancellationRequested)
QueueAssetCatalogUpdate(_pendingAssetCatalog);
}
}
private async Task PreloadAssetsAsync(CancellationToken cancellationToken)
{
foreach (var uri in _bundleVariants.Keys.OrderBy(value => value, StringComparer.Ordinal).ToArray())
await EnsureBundleLoadedAsync(uri, cancellationToken);
}
private async Task EnsureBundleLoadedAsync(string uri, CancellationToken cancellationToken)
{
if (_loadedBundleUris.Contains(uri)) return;
if (!_bundleVariants.TryGetValue(uri, out var variant))
@ -1154,14 +1244,13 @@ namespace GameCult.Eve.UnityScene
{
if (variant.Metadata.TryGetValue("unity.bundleDependencyUris", out var dependencies))
foreach (var dependency in dependencies.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
EnsureBundleLoaded(dependency);
await EnsureBundleLoadedAsync(dependency, cancellationToken);
var groupElapsed = Stopwatch.StartNew();
var descriptor = _meshClient!.ReadAsync<CultMeshCdnArtifactManifest>(_target, uri)
.GetAwaiter()
.GetResult();
var descriptor = await _meshClient!
.ReadAsync<CultMeshCdnArtifactManifest>(_target, uri, cancellationToken);
TraceStartup("asset-manifest", groupElapsed);
var bundle = LoadVerifiedBundle(descriptor, variant);
var bundle = await LoadVerifiedBundleAsync(descriptor, variant, cancellationToken);
TraceStartup("asset-transfer-and-bundle-load", groupElapsed);
if (bundle == null)
throw new InvalidOperationException($"Unity could not load provider asset bundle '{uri}'.");
@ -1172,7 +1261,7 @@ namespace GameCult.Eve.UnityScene
var bundleAssetName = ResolveBundleAssetName(bundleAssetNames, selection.Variant!.AssetKey);
var value = bundleAssetName == null
? null
: LoadBundleAsset(bundle, bundleAssetName, selection.Asset.AssetKind);
: await LoadBundleAssetAsync(bundle, bundleAssetName, selection.Asset.AssetKind, cancellationToken);
if (value == null)
throw new InvalidOperationException(
$"Provider asset '{selection.Asset.AssetRef}' advertises missing Unity bundle asset " +
@ -1196,11 +1285,17 @@ namespace GameCult.Eve.UnityScene
}
}
private static UnityEngine.Object? LoadBundleAsset(AssetBundle bundle, string assetName, string assetKind)
private static async Task<UnityEngine.Object?> LoadBundleAssetAsync(
AssetBundle bundle,
string assetName,
string assetKind,
CancellationToken cancellationToken)
{
return string.Equals(assetKind, "sprite", StringComparison.Ordinal)
? bundle.LoadAsset<Sprite>(assetName)
: bundle.LoadAsset(assetName);
var request = string.Equals(assetKind, "sprite", StringComparison.Ordinal)
? bundle.LoadAssetAsync<Sprite>(assetName)
: bundle.LoadAssetAsync(assetName);
await AwaitUnityAsync(request, cancellationToken);
return request.asset;
}
private sealed class SelectedAsset
@ -1263,9 +1358,10 @@ namespace GameCult.Eve.UnityScene
private CultMeshContentTransferService ContentTransfer()
{
ResolveAdvertisement();
if (_contentTransfer != null)
return _contentTransfer;
if (_advertisement == null)
throw new InvalidOperationException("The Eve provider advertisement must be prepared before content transfer.");
if (string.IsNullOrWhiteSpace(_advertisement!.ServiceId))
throw new InvalidOperationException($"Eve provider '{_advertisement.ProviderId}' has no service-instance identity for content transport.");
var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH");
@ -1287,7 +1383,10 @@ namespace GameCult.Eve.UnityScene
return _contentTransfer;
}
private AssetBundle? LoadVerifiedBundle(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant)
private async Task<AssetBundle?> LoadVerifiedBundleAsync(
CultMeshCdnArtifactManifest manifest,
EveAssetVariant variant,
CancellationToken cancellationToken)
{
var hash = NormalizeContentHash(variant.ContentHash);
if (manifest.SizeBytes != variant.SizeBytes)
@ -1299,12 +1398,10 @@ namespace GameCult.Eve.UnityScene
var now = DateTimeOffset.UtcNow;
var network = NetworkArtifactDescriptor(manifest, now, TimeSpan.FromMinutes(5));
var elapsed = Stopwatch.StartNew();
var mapped = ContentTransfer()
.FetchMappedContentAsync(manifest, network, now, TimeSpan.FromMinutes(5))
.GetAwaiter()
.GetResult();
var mapped = await ContentTransfer()
.FetchMappedContentAsync(manifest, network, now, TimeSpan.FromMinutes(5), cancellationToken);
TraceStartup("content-materialization", elapsed);
var request = new CultMeshBodyValidationRequest
var validationRequest = new CultMeshBodyValidationRequest
{
BodyId = network.BodyId,
SchemaId = network.SchemaId,
@ -1319,33 +1416,27 @@ namespace GameCult.Eve.UnityScene
new ICultMeshBodyTransportAdapter[]
{
new CultMeshMappedBodyAdapter(_assetBodyMappings!),
new CultMeshNetworkBodyAdapter(_ => ReadVerifiedArtifactBytes(manifest))
new CultMeshNetworkBodyAdapter(_ => File.ReadAllBytes(mapped.VerifiedPath))
},
descriptor => string.Equals(descriptor.BodyId, manifest.ArtifactId, StringComparison.Ordinal) &&
string.Equals(descriptor.SemanticHash, hash, StringComparison.Ordinal));
var negotiated = transport.NegotiateReadOnly(mapped.Descriptor, network, request);
var negotiated = transport.NegotiateReadOnly(mapped.Descriptor, network, validationRequest);
CurrentAssetBodyTransportKind = negotiated.SelectedTransport;
_assetBodyLeases.Add(negotiated.Lease);
if (negotiated.SelectedTransport == CultMeshBodyTransportKind.SharedFileMapping)
{
var bundle = AssetBundle.LoadFromFile(mapped.VerifiedPath);
TraceStartup("asset-bundle-load-from-file", elapsed);
return bundle;
}
if (negotiated.Lease.Descriptor.ByteSize > int.MaxValue)
throw new InvalidOperationException("Unity cannot lower a network asset body larger than one managed byte array.");
var bytes = new byte[checked((int)negotiated.Lease.Descriptor.ByteSize)];
negotiated.Lease.CopyTo(0, bytes, 0, bytes.Length);
var loaded = AssetBundle.LoadFromMemory(bytes);
TraceStartup("asset-bundle-load-from-memory", elapsed);
return loaded;
var bundleRequest = AssetBundle.LoadFromFileAsync(mapped.VerifiedPath);
await AwaitUnityAsync(bundleRequest, cancellationToken);
TraceStartup("asset-bundle-load-from-file", elapsed);
return bundleRequest.assetBundle;
}
private byte[] ReadVerifiedArtifactBytes(CultMeshCdnArtifactManifest manifest)
private static async Task AwaitUnityAsync(AsyncOperation operation, CancellationToken cancellationToken)
{
var path = ContentTransfer().FetchAsync(manifest).GetAwaiter().GetResult();
return File.ReadAllBytes(path);
while (!operation.isDone)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
}
cancellationToken.ThrowIfCancellationRequested();
}
private static CultMeshBodyDescriptor NetworkArtifactDescriptor(
@ -1375,8 +1466,12 @@ namespace GameCult.Eve.UnityScene
private void PublishReceipt(EveCommandReceiptDocument receipt)
{
if (!_pendingCommandIds.Contains(receipt.CommandId) || !_publishedReceiptIds.Add(receipt.ReceiptId))
return;
lock (_pendingCommandIds)
{
if (!_pendingCommandIds.Contains(receipt.CommandId) || !_publishedReceiptIds.Add(receipt.ReceiptId))
return;
_pendingCommandIds.Remove(receipt.CommandId);
}
CommandReceiptAvailable?.Invoke(new EveUnitySceneCommandReceipt(
receipt.ReceiptId,
receipt.Command,
@ -1398,10 +1493,6 @@ namespace GameCult.Eve.UnityScene
receipt.Navigation.SurfaceId,
receipt.Navigation.SurfaceKind,
receipt.Navigation.RendezvousEndpoints)));
if (string.Equals(receipt.State, "accepted", StringComparison.OrdinalIgnoreCase) ||
string.Equals(receipt.State, "denied", StringComparison.OrdinalIgnoreCase) ||
string.Equals(receipt.State, "reconciled", StringComparison.OrdinalIgnoreCase))
_pendingCommandIds.Remove(receipt.CommandId);
}
private EveWorldInteractionAdvertisement RequireWorldInteraction()

View file

@ -3,6 +3,7 @@ using System.IO;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Stopwatch = System.Diagnostics.Stopwatch;
using GameCult.Eve.Surface;
@ -43,6 +44,7 @@ namespace GameCult.Eve.UnityScene
private EveUnityCultMeshLiveProviderTransport? _transport;
private EveUnitySceneLiveProviderBridge? _bridge;
private Task? _preparation;
private CancellationTokenSource? _preparationLifetime;
private readonly ConcurrentQueue<EntityViewLease> _pendingEntityViews = new ConcurrentQueue<EntityViewLease>();
private readonly ConcurrentQueue<EveFieldsSplatsDocument> _pendingFields = new ConcurrentQueue<EveFieldsSplatsDocument>();
@ -109,7 +111,11 @@ namespace GameCult.Eve.UnityScene
if (_bridge != null)
return Task.CompletedTask;
if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted)
_preparation = PrepareTransportAsync();
{
_preparationLifetime?.Dispose();
_preparationLifetime = new CancellationTokenSource();
_preparation = PrepareTransportAsync(_preparationLifetime.Token);
}
return _preparation;
}
@ -184,6 +190,7 @@ namespace GameCult.Eve.UnityScene
private void ReleaseTransport()
{
_preparationLifetime?.Cancel();
if (_bridge != null)
{
_bridge.DocumentAvailable -= ForwardDocument;
@ -196,6 +203,8 @@ namespace GameCult.Eve.UnityScene
_bridge = null;
_transport = null;
_preparation = null;
_preparationLifetime?.Dispose();
_preparationLifetime = null;
Selection = null;
}
@ -228,7 +237,7 @@ namespace GameCult.Eve.UnityScene
}
}
private async Task PrepareTransportAsync()
private async Task PrepareTransportAsync(CancellationToken cancellationToken)
{
var elapsed = Stopwatch.StartNew();
if (_transport != null)
@ -241,13 +250,14 @@ namespace GameCult.Eve.UnityScene
providerFilter,
surfaceFilter,
surfaceKind,
verseFilter);
verseFilter,
cancellationToken);
TraceStartup($"discovery {elapsed.Elapsed.TotalMilliseconds:0.###}ms");
elapsed.Restart();
var resolvedCachePath = string.IsNullOrWhiteSpace(cacheDirectory)
? Path.Combine(Application.temporaryCachePath, $"eve-unity-{GetInstanceID()}")
: cacheDirectory;
_transport = new EveUnityCultMeshLiveProviderTransport(
var transport = new EveUnityCultMeshLiveProviderTransport(
resolvedCachePath,
Selection.RendezvousEndpoint,
Selection.VerseId,
@ -255,6 +265,17 @@ namespace GameCult.Eve.UnityScene
Selection.ProviderId,
Selection.SurfaceId,
runtimeId);
try
{
await transport.PrepareAsync(cancellationToken);
}
catch
{
transport.Dispose();
throw;
}
cancellationToken.ThrowIfCancellationRequested();
_transport = transport;
_transport.EntityViewAvailable += (view, lease) => _pendingEntityViews.Enqueue(new EntityViewLease(view, lease));
_transport.FieldsSplatsAvailable += fields => _pendingFields.Enqueue(fields);
_bridge = new EveUnitySceneLiveProviderBridge(_transport);

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
using GameCult.Mesh;
@ -38,21 +39,13 @@ namespace GameCult.Eve.UnityScene
public sealed class EveUnityCultMeshProviderDiscovery
{
public EveUnityCultMeshProviderSelection Discover(
string rendezvousEndpoint,
string providerId = "",
string surfaceId = "",
string surfaceKind = "interactive-world",
string verseId = "") =>
DiscoverAsync(rendezvousEndpoint, providerId, surfaceId, surfaceKind, verseId)
.GetAwaiter().GetResult();
public async Task<EveUnityCultMeshProviderSelection> DiscoverAsync(
string rendezvousEndpoint,
string providerId = "",
string surfaceId = "",
string surfaceKind = "interactive-world",
string verseId = "")
string verseId = "",
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint));
@ -67,6 +60,7 @@ namespace GameCult.Eve.UnityScene
VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId },
TransportVersion = "cultmesh.v0"
});
cancellationToken.ThrowIfCancellationRequested();
}
catch (Exception error)
{
@ -104,7 +98,7 @@ namespace GameCult.Eve.UnityScene
{
var target = new CultMeshSessionTarget(candidate.VerseId, authorityRuntimeId);
using var advertisementsLease = await mesh
.LeaseCollectionAsync<EveProviderAdvertisementDocument>(target)
.LeaseCollectionAsync<EveProviderAdvertisementDocument>(target, cancellationToken)
.ConfigureAwait(false);
var advertisements = await advertisementsLease.Handle.LatestAsync().ConfigureAwait(false);
observed.AddRange(advertisements.Select(document =>

View file

@ -5,8 +5,11 @@ using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
using GameCult.Eve.UnityScene;
using GameCult.Mesh;
using NUnit.Framework;
using UnityEngine;
@ -14,6 +17,79 @@ namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveUnityCultMeshAssetLookupTests
{
[Test]
public void ConnectCannotPerformRemoteWorkBeforeAsynchronousPreparation()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-cache",
"cultnet+tcp://127.0.0.1:1",
"test.verse",
"test.runtime",
"test.provider",
"test.surface");
var elapsed = Stopwatch.StartNew();
var failure = Assert.Throws<InvalidOperationException>(() => transport.Connect());
StringAssert.Contains("Await PrepareAsync", failure!.Message);
Assert.That(elapsed.Elapsed, Is.LessThan(TimeSpan.FromMilliseconds(100)));
}
[Test]
public async Task CommandOutboxReturnsWhileDeliveryIsBlockedAndDeliversOneShotExactlyOnce()
{
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var sends = 0;
using var outbox = new EveUnityCultMeshCommandOutbox(
async (_, cancellationToken) =>
{
Interlocked.Increment(ref sends);
entered.TrySetResult(true);
await AwaitOrCancel(release.Task, cancellationToken);
},
_ => null);
var elapsed = Stopwatch.StartNew();
outbox.Enqueue(Request("one-shot", "dock"));
Assert.That(elapsed.Elapsed, Is.LessThan(TimeSpan.FromMilliseconds(100)));
await RequireCompletion(entered.Task);
Assert.That(outbox.PendingCount, Is.EqualTo(1));
release.TrySetResult(true);
await WaitUntilAsync(() => outbox.PendingCount == 0);
Assert.That(sends, Is.EqualTo(1));
}
[Test]
public async Task CommandOutboxCoalescesBlockedContinuousInputToTheLatestValue()
{
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var sent = new List<string>();
using var outbox = new EveUnityCultMeshCommandOutbox(
async (request, cancellationToken) =>
{
lock (sent) sent.Add(request.CommandId);
entered.TrySetResult(true);
await AwaitOrCancel(release.Task, cancellationToken);
},
request => request.PayloadFields["commandId"] + "\u001f" + request.PayloadFields["entityId"]);
outbox.Enqueue(Request("move-0", "move"));
await RequireCompletion(entered.Task);
for (var index = 1; index <= 1000; index++)
outbox.Enqueue(Request("move-" + index, "move"));
Assert.That(outbox.PendingCount, Is.LessThanOrEqualTo(2));
release.TrySetResult(true);
await WaitUntilAsync(() => outbox.PendingCount == 0);
lock (sent)
{
CollectionAssert.AreEqual(new[] { "move-0", "move-1000" }, sent);
}
}
[Test]
public void ProviderSelectionCarriesStableIdentityWithoutExposingAPhysicalRoute()
{
@ -31,6 +107,41 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(typeof(EveUnityCultMeshProviderSelection).GetProperty("Endpoint"), Is.Null);
}
private static EveSurfaceCommandRequest Request(string id, string command) => new EveSurfaceCommandRequest(
"test.provider",
"test.surface",
CultMesh.OperationInvocation("test.command", idempotencyKey: id),
CultMesh.OperationPayload(new Dictionary<string, string>
{
["commandId"] = command,
["entityId"] = "ship"
}),
DateTimeOffset.UtcNow,
"test-client");
private static async Task AwaitOrCancel(Task task, CancellationToken cancellationToken)
{
var cancelled = Task.Delay(Timeout.Infinite, cancellationToken);
if (await Task.WhenAny(task, cancelled) != task)
cancellationToken.ThrowIfCancellationRequested();
await task;
}
private static async Task RequireCompletion(Task task)
{
if (await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(2))) != task)
Assert.Fail("Timed out waiting for asynchronous command delivery.");
await task;
}
private static async Task WaitUntilAsync(Func<bool> predicate)
{
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(2);
while (!predicate() && DateTimeOffset.UtcNow < deadline)
await Task.Delay(10);
Assert.That(predicate(), Is.True, "Timed out waiting for the command outbox to drain.");
}
[Test]
public void LiveTransportOwnsOneCultMeshClientAndNoDirectSnapshotReplica()
{

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.106",
"version": "0.3.107",
"unity": "6000.0",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {