Prepare CultMesh providers off the Unity main thread

This commit is contained in:
Meta Vens 2026-07-18 18:38:07 +01:00
commit f75468e7f4
6 changed files with 77 additions and 20 deletions

View file

@ -57,8 +57,12 @@ public sealed class GenericEveUnityLauncher : MonoBehaviour
while (true)
{
var connected = false;
var preparation = _provider.PrepareAsync();
while (!preparation.IsCompleted)
yield return null;
try
{
preparation.GetAwaiter().GetResult();
var presentation = _bootstrap.Mount();
LowerSurface(_provider.CurrentDocument);
Debug.Log($"Connected generic Eve client: {_provider.Selection?.ProviderId} / {_provider.Selection?.SurfaceId} / {presentation.ActiveEntities} entities");

View file

@ -12,6 +12,9 @@ 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.
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.
The package never imports provider product assemblies.

View file

@ -2,6 +2,7 @@ using System;
using System.IO;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using GameCult.Eve.Surface;
using GameCult.Eve.PluginFields;
using GameCult.Eve.UnityScene.Fields;
@ -36,6 +37,7 @@ namespace GameCult.Eve.UnityScene
private EveUnityCultMeshLiveProviderTransport? _transport;
private EveUnitySceneLiveProviderBridge? _bridge;
private Task? _preparation;
private readonly ConcurrentQueue<EntityViewLease> _pendingEntityViews = new ConcurrentQueue<EntityViewLease>();
private readonly ConcurrentQueue<EveFieldsSplatsDocument> _pendingFields = new ConcurrentQueue<EveFieldsSplatsDocument>();
@ -53,7 +55,7 @@ namespace GameCult.Eve.UnityScene
{
get
{
EnsureTransport();
RequirePrepared();
return _transport!.CurrentInputCapability;
}
}
@ -62,7 +64,7 @@ namespace GameCult.Eve.UnityScene
{
get
{
EnsureTransport();
RequirePrepared();
return _transport!.CurrentAssetBodyTransportKind;
}
}
@ -88,7 +90,7 @@ namespace GameCult.Eve.UnityScene
string requiredSurfaceKind = "interactive-world",
string clientRuntimeId = "eve-unity")
{
if (_bridge != null)
if (_bridge != null || _preparation != null)
throw new InvalidOperationException("Disconnect the active provider before changing discovery configuration.");
rendezvousEndpoint = endpoint ?? "";
@ -105,6 +107,15 @@ namespace GameCult.Eve.UnityScene
Bridge.Connect();
}
public Task PrepareAsync()
{
if (_bridge != null)
return Task.CompletedTask;
if (_preparation == null || _preparation.IsCanceled || _preparation.IsFaulted)
_preparation = PrepareTransportAsync();
return _preparation;
}
public void Disconnect()
{
_bridge?.Disconnect();
@ -125,13 +136,13 @@ namespace GameCult.Eve.UnityScene
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
{
EnsureTransport();
RequirePrepared();
return _transport!.ResolvePrefab(asset);
}
public UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType)
{
EnsureTransport();
RequirePrepared();
return _transport!.ResolveAsset(asset, assetType);
}
@ -139,13 +150,13 @@ namespace GameCult.Eve.UnityScene
EveUnityPlayableWorldAssetBinding asset,
out IReadOnlyDictionary<string, string> metadata)
{
EnsureTransport();
RequirePrepared();
return _transport!.TryResolveAssetMetadata(asset, out metadata);
}
public bool TryGetRenderChannelLayer(string channel, out int layer)
{
EnsureTransport();
RequirePrepared();
return _transport!.TryGetRenderChannelLayer(channel, out layer);
}
@ -157,6 +168,7 @@ namespace GameCult.Eve.UnityScene
while (_pendingFields.TryDequeue(out _)) { }
_bridge = null;
_transport = null;
_preparation = null;
Selection = null;
}
@ -184,19 +196,19 @@ namespace GameCult.Eve.UnityScene
{
get
{
EnsureTransport();
RequirePrepared();
return _bridge!;
}
}
private void EnsureTransport()
private async Task PrepareTransportAsync()
{
if (_transport != null)
return;
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
throw new InvalidOperationException("EveUnity requires a CultMesh rendezvous endpoint.");
Selection = new EveUnityCultMeshProviderDiscovery().Discover(
Selection = await new EveUnityCultMeshProviderDiscovery().DiscoverAsync(
rendezvousEndpoint,
providerFilter,
surfaceFilter,
@ -216,6 +228,13 @@ namespace GameCult.Eve.UnityScene
_bridge = new EveUnitySceneLiveProviderBridge(_transport);
}
private void RequirePrepared()
{
if (_transport == null || _bridge == null)
throw new InvalidOperationException(
"The EveUnity CultMesh provider is not prepared. Await PrepareAsync before mounting the playable world.");
}
private sealed class EntityViewLease
{
public EntityViewLease(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease)

View file

@ -41,6 +41,15 @@ namespace GameCult.Eve.UnityScene
};
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 = "",
@ -50,13 +59,13 @@ namespace GameCult.Eve.UnityScene
if (string.IsNullOrWhiteSpace(rendezvousEndpoint))
throw new ArgumentException("Rendezvous endpoint must be non-empty.", nameof(rendezvousEndpoint));
var response = RunNetwork(() => CultMesh.CreateVerseDiscoveryClient().FetchAsync(
var response = await CultMesh.CreateVerseDiscoveryClient().FetchAsync(
rendezvousEndpoint,
new CultMeshVerseCatalogRequestMessage
{
VerseIds = string.IsNullOrWhiteSpace(verseId) ? null : new[] { verseId },
TransportVersion = "cultmesh.v0"
}));
});
var candidates = response.Verses
.Where(verse => string.IsNullOrWhiteSpace(verseId) ||
@ -73,7 +82,7 @@ namespace GameCult.Eve.UnityScene
{
try
{
var advertisement = FetchAdvertisements(candidate.Endpoint)
var advertisement = (await FetchAdvertisementsAsync(candidate.Endpoint))
.Where(document => string.IsNullOrWhiteSpace(providerId) ||
string.Equals(document.ProviderId, providerId, StringComparison.Ordinal))
.Select(document => new
@ -107,7 +116,7 @@ namespace GameCult.Eve.UnityScene
throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}");
}
private static EveProviderAdvertisementDocument[] FetchAdvertisements(string endpoint)
private static async Task<EveProviderAdvertisementDocument[]> FetchAdvertisementsAsync(string endpoint)
{
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(AdvertisementDocumentTypes);
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(AdvertisementDocumentTypes, cacheRegistry);
@ -128,11 +137,7 @@ namespace GameCult.Eve.UnityScene
RudpMaxFragmentBytes = 1024
}
});
return RunNetwork(() => snapshot.FetchDocumentsAsync<EveProviderAdvertisementDocument>())
.ToArray();
return (await snapshot.FetchDocumentsAsync<EveProviderAdvertisementDocument>()).ToArray();
}
private static T RunNetwork<T>(Func<Task<T>> operation) =>
Task.Run(operation).GetAwaiter().GetResult();
}
}

View file

@ -1,12 +1,38 @@
using System;
using System.Reflection;
using System.Collections.Generic;
using System.Diagnostics;
using GameCult.Eve.UnityScene;
using NUnit.Framework;
using UnityEngine;
namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveUnityCultMeshAssetLookupTests
{
[Test]
public void UnpreparedProviderGetterFailsFastWithoutStartingNetworkDiscovery()
{
var gameObject = new GameObject("unprepared-eve-provider");
try
{
var provider = gameObject.AddComponent<EveUnityCultMeshPlayableWorldProvider>();
provider.Configure("rudp://127.0.0.1:1");
var stopwatch = Stopwatch.StartNew();
var error = Assert.Throws<InvalidOperationException>(() => _ = provider.CurrentInputCapability);
stopwatch.Stop();
StringAssert.Contains("Await PrepareAsync", error!.Message);
Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(100));
Assert.That(provider.Selection, Is.Null);
}
finally
{
UnityEngine.Object.DestroyImmediate(gameObject);
}
}
[Test]
public void ResolvesUnityNormalizedBundleNamesWithoutChangingLogicalAssetIdentity()
{

View file

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