Keep CultMesh I/O off the Unity main thread
This commit is contained in:
parent
f67632233a
commit
a51723bd57
9 changed files with 580 additions and 158 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 61d76e98f2054d86bf8e7480d4411c39
|
||||
timeCreated: 1787039650
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Reference in a new issue