Qualify Eve world asset sources

This commit is contained in:
Meta Vens 2026-08-19 07:31:25 +02:00
commit 56aba155c2
4 changed files with 278 additions and 39 deletions

View file

@ -63,10 +63,15 @@ namespace GameCult.Eve.UnityScene
private readonly List<IDisposable> _documentWatches = new List<IDisposable>();
private readonly CancellationTokenSource _lifetime = new CancellationTokenSource();
private CultMeshClient? _meshClient;
private CultMeshClient? _assetMeshClient;
private IDisposable? _assetCatalogLease;
private IDisposable? _assetCatalogWatch;
private bool _liveDocumentsReady;
private CultCache? _contentState;
private CultMeshContentTransferService? _contentTransfer;
private CultMeshVerifiedBodyMappingBroker? _assetBodyMappings;
private CultMeshSessionTarget _assetTarget;
private string _assetProviderId;
private EveProviderAdvertisementDocument? _advertisement;
private EveAdvertisedSurface? _advertisedSurface;
private EveSurfaceDocument? _baseSurface;
@ -79,8 +84,9 @@ namespace GameCult.Eve.UnityScene
private Task? _realtimePump;
private CultMeshRealtimeSession? _realtimeSession;
private EveUnityCultMeshCommandOutbox? _commandOutbox;
private EveAssetCatalogDocument? _pendingAssetCatalog;
private PendingAssetCatalogUpdate? _pendingAssetCatalog;
private bool _assetCatalogUpdateRunning;
private long _surfaceAssetGeneration;
private long _lastQueuedEntityViewEpoch = -1;
private long _lastQueuedEntityViewSequence = -1;
private long _lastPresentedEntityViewEpoch = -1;
@ -109,6 +115,8 @@ namespace GameCult.Eve.UnityScene
_providerId = string.IsNullOrWhiteSpace(providerId)
? throw new ArgumentException("Provider id must be non-empty.", nameof(providerId))
: providerId.Trim();
_assetTarget = _target;
_assetProviderId = _providerId;
_surfaceId = string.IsNullOrWhiteSpace(surfaceId)
? throw new ArgumentException("Surface id must be non-empty.", nameof(surfaceId))
: surfaceId.Trim();
@ -252,7 +260,7 @@ namespace GameCult.Eve.UnityScene
else if (document is EveInputCapabilityDocument inputCapability)
CurrentInputCapability = inputCapability;
else if (document is EveAssetCatalogDocument assetCatalog)
QueueAssetCatalogUpdate(assetCatalog);
QueueAssetCatalogUpdate(CurrentAssetSourceIdentity, assetCatalog);
else if (document is EveCommandReceiptDocument receipt)
PublishReceipt(receipt);
else if (document is Exception error)
@ -457,10 +465,16 @@ namespace GameCult.Eve.UnityScene
_realtimeLifetime?.Dispose();
_realtimeLifetime = null;
DisposeLiveDocuments();
_assetCatalogWatch?.Dispose();
_assetCatalogWatch = null;
_assetCatalogLease?.Dispose();
_assetCatalogLease = null;
_contentState?.Dispose();
_contentState = null;
_meshClient?.Dispose();
_meshClient = null;
_assetMeshClient?.Dispose();
_assetMeshClient = null;
_contentTransfer = null;
_assetBodyMappings = null;
_baseSurface = null;
@ -516,9 +530,14 @@ namespace GameCult.Eve.UnityScene
{
if (_meshClient != null)
return;
_meshClient = new CultMeshClient(new CultMeshClientOptions
_meshClient = CreateMeshClient(new[] { _rendezvousEndpoint });
}
private CultMeshClient CreateMeshClient(IReadOnlyList<string> rendezvousEndpoints)
{
return new CultMeshClient(new CultMeshClientOptions
{
RendezvousEndpoints = new[] { _rendezvousEndpoint },
RendezvousEndpoints = rendezvousEndpoints,
Discovery = EveUnityCultMeshConnectivity.Discovery(),
Sessions = new CultMeshSessionManagerOptions { Trust = _authorityTrust },
Connectors = EveUnityCultMeshConnectivity.SchemaConnectors(),
@ -530,6 +549,9 @@ namespace GameCult.Eve.UnityScene
});
}
private CultMeshClient AssetMeshClient() => _assetMeshClient ??
_meshClient ?? throw new InvalidOperationException("The CultMesh client is not open.");
private async Task ResolveAdvertisementAsync(
bool forceRefresh = false,
CancellationToken cancellationToken = default)
@ -900,8 +922,28 @@ namespace GameCult.Eve.UnityScene
private void PublishBaseSurface(EveSurfaceDocument surface)
{
_baseSurface = surface ?? throw new ArgumentNullException(nameof(surface));
PublishComposedSurface();
if (surface == null) throw new ArgumentNullException(nameof(surface));
var generation = Interlocked.Increment(ref _surfaceAssetGeneration);
if (!_bootstrapped)
{
_baseSurface = surface;
PublishComposedSurface();
return;
}
var source = ResolveAssetSource(surface);
if (string.Equals(source.Identity, CurrentAssetSourceIdentity, StringComparison.Ordinal))
{
_baseSurface = surface;
PublishComposedSurface();
return;
}
// A surface and its provider-qualified asset catalog are one presentation
// candidate. Keep the previous surface mounted until the candidate assets
// have resolved and preloaded; never lower a remote Verse surface against
// the previous Verse's catalog merely because the surface record arrived first.
_ = RefreshAssetsForSurfaceAsync(surface, source, generation, _lifetime.Token);
}
private void PublishEmbeddedSurface(string recordKey, EveSurfaceDocument surface)
@ -1064,15 +1106,6 @@ namespace GameCult.Eve.UnityScene
document => _liveDocuments.Enqueue(document),
cancellationToken);
}
var assetCatalogRef = RequireWorldInteraction().AssetManifestRecordRef;
if (!string.IsNullOrWhiteSpace(assetCatalogRef))
{
await LeaseDocumentAsync<EveAssetCatalogDocument>(
assetCatalogRef,
document => _liveDocuments.Enqueue(document),
cancellationToken,
publishInitial: false);
}
var interaction = RequireWorldInteraction();
if (!string.Equals(interaction.ReceiptSchema, EveCommandReceiptDocument.SchemaId, StringComparison.Ordinal))
throw new InvalidOperationException(
@ -1233,21 +1266,155 @@ namespace GameCult.Eve.UnityScene
private async Task RefreshAssetCatalogAsync(CancellationToken cancellationToken)
{
var interaction = RequireWorldInteraction();
if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef))
var source = ResolveAssetSource(
_baseSurface ?? throw new InvalidOperationException("The provider surface must be available before its assets are resolved."));
if (string.IsNullOrWhiteSpace(source.ManifestRecordRef))
return;
var catalog = await _meshClient!
.ReadAsync<EveAssetCatalogDocument>(
_target,
interaction.AssetManifestRecordRef,
cancellationToken);
PublishAssetCatalog(catalog);
var catalog = await ReadAssetCatalogAsync(source, cancellationToken);
ApplyAssetSource(source);
PublishAssetCatalog(source.Identity, catalog);
await ReplaceAssetCatalogSubscriptionAsync(source, cancellationToken);
}
private void PublishAssetCatalog(EveAssetCatalogDocument catalog)
private async Task RefreshAssetsForSurfaceAsync(
EveSurfaceDocument surface,
AssetSource source,
long generation,
CancellationToken cancellationToken)
{
if (catalog.Version == CurrentAssetCatalogVersion)
try
{
var catalog = await ReadAssetCatalogAsync(source, cancellationToken);
if (generation != Interlocked.Read(ref _surfaceAssetGeneration))
return;
ApplyAssetSource(source);
PublishAssetCatalog(source.Identity, catalog);
await ReplaceAssetCatalogSubscriptionAsync(source, cancellationToken);
await PreloadAssetsAsync(cancellationToken);
if (generation != Interlocked.Read(ref _surfaceAssetGeneration))
return;
_baseSurface = surface;
PublishComposedSurface();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception error)
{
_liveDocuments.Enqueue(error);
}
}
private AssetSource ResolveAssetSource(EveSurfaceDocument surface)
{
var world = FindWorldScene(surface.Surface.Root);
var manifestRecordRef = world?.GetProp("assetManifest") ?? "";
if (string.IsNullOrWhiteSpace(manifestRecordRef))
manifestRecordRef = RequireWorldInteraction().AssetManifestRecordRef;
var providerId = world?.GetProp("assetProviderId") ?? "";
var verseId = world?.GetProp("assetVerseId") ?? "";
var authorityRuntimeId = world?.GetProp("assetAuthorityRuntimeId") ?? "";
var rendezvousEndpoints = ParseEndpointList(world?.GetProp("assetRendezvousEndpoints") ?? "");
var hasQualifiedSource = !string.IsNullOrWhiteSpace(providerId) ||
!string.IsNullOrWhiteSpace(verseId) ||
!string.IsNullOrWhiteSpace(authorityRuntimeId);
if (!hasQualifiedSource)
return new AssetSource(_target, _providerId, manifestRecordRef, Array.Empty<string>());
if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(verseId) ||
string.IsNullOrWhiteSpace(authorityRuntimeId))
throw new InvalidOperationException(
"A provider-qualified Eve asset source requires assetProviderId, assetVerseId, and assetAuthorityRuntimeId.");
return new AssetSource(
new CultMeshSessionTarget(verseId, authorityRuntimeId),
providerId,
manifestRecordRef,
rendezvousEndpoints);
}
private async Task<EveAssetCatalogDocument> ReadAssetCatalogAsync(
AssetSource source,
CancellationToken cancellationToken)
{
if (string.Equals(source.Identity, CurrentAssetSourceIdentity, StringComparison.Ordinal))
return await AssetMeshClient()
.ReadAsync<EveAssetCatalogDocument>(source.Target, source.ManifestRecordRef, cancellationToken);
using var candidateClient = source.RendezvousEndpoints.Count == 0
? null
: CreateMeshClient(source.RendezvousEndpoints);
return await (candidateClient ?? _meshClient ??
throw new InvalidOperationException("The CultMesh client is not open."))
.ReadAsync<EveAssetCatalogDocument>(source.Target, source.ManifestRecordRef, cancellationToken);
}
private void ApplyAssetSource(AssetSource source)
{
if (string.Equals(source.Identity, CurrentAssetSourceIdentity, StringComparison.Ordinal))
return;
_assetTarget = source.Target;
_assetProviderId = source.ProviderId;
_assetCatalogWatch?.Dispose();
_assetCatalogWatch = null;
_assetCatalogLease?.Dispose();
_assetCatalogLease = null;
_assetMeshClient?.Dispose();
_assetMeshClient = null;
if (source.RendezvousEndpoints.Count > 0)
_assetMeshClient = CreateMeshClient(source.RendezvousEndpoints);
_contentTransfer = null;
_assetBodyMappings = null;
}
private async Task ReplaceAssetCatalogSubscriptionAsync(
AssetSource source,
CancellationToken cancellationToken)
{
var lease = await AssetMeshClient()
.LeaseDocumentAsync<EveAssetCatalogDocument>(source.Target, source.ManifestRecordRef, cancellationToken);
IDisposable? watch = null;
try
{
watch = lease.Handle.Watch(catalog => QueueAssetCatalogUpdate(source.Identity, catalog));
var oldWatch = _assetCatalogWatch;
var oldLease = _assetCatalogLease;
_assetCatalogWatch = watch;
_assetCatalogLease = lease;
oldWatch?.Dispose();
oldLease?.Dispose();
}
catch
{
watch?.Dispose();
lease.Dispose();
throw;
}
}
private static EveSurfaceComponent? FindWorldScene(EveSurfaceComponent component)
{
if (string.Equals(component.Kind, "world.scene3d", StringComparison.Ordinal) ||
string.Equals(component.Kind, "world", StringComparison.Ordinal))
return component;
foreach (var child in component.Children ?? Array.Empty<EveSurfaceComponent>())
{
var found = FindWorldScene(child);
if (found != null) return found;
}
return null;
}
private static IReadOnlyList<string> ParseEndpointList(string value) =>
(value ?? "")
.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)
.Select(endpoint => endpoint.Trim())
.Where(endpoint => endpoint.Length > 0)
.Distinct(StringComparer.Ordinal)
.ToArray();
private void PublishAssetCatalog(string sourceIdentity, EveAssetCatalogDocument catalog)
{
if (string.Equals(sourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal) &&
catalog.Version == CurrentAssetCatalogVersion)
return;
foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false);
@ -1305,11 +1472,14 @@ namespace GameCult.Eve.UnityScene
}
CurrentAssetCatalogVersion = catalog.Version;
CurrentAssetSourceIdentity = sourceIdentity;
}
private void QueueAssetCatalogUpdate(EveAssetCatalogDocument catalog)
private void QueueAssetCatalogUpdate(string sourceIdentity, EveAssetCatalogDocument catalog)
{
_pendingAssetCatalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
_pendingAssetCatalog = new PendingAssetCatalogUpdate(
sourceIdentity,
catalog ?? throw new ArgumentNullException(nameof(catalog)));
if (_assetCatalogUpdateRunning) return;
_assetCatalogUpdateRunning = true;
_ = DrainAssetCatalogUpdatesAsync();
@ -1321,11 +1491,14 @@ namespace GameCult.Eve.UnityScene
{
while (!_lifetime.IsCancellationRequested)
{
var catalog = _pendingAssetCatalog;
var update = _pendingAssetCatalog;
_pendingAssetCatalog = null;
if (catalog == null) break;
PublishAssetCatalog(catalog);
if (update == null) break;
if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal))
continue;
PublishAssetCatalog(update.SourceIdentity, update.Catalog);
await PreloadAssetsAsync(_lifetime.Token);
PublishComposedSurface();
}
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
@ -1339,7 +1512,7 @@ namespace GameCult.Eve.UnityScene
{
_assetCatalogUpdateRunning = false;
if (_pendingAssetCatalog != null && !_lifetime.IsCancellationRequested)
QueueAssetCatalogUpdate(_pendingAssetCatalog);
QueueAssetCatalogUpdate(_pendingAssetCatalog.SourceIdentity, _pendingAssetCatalog.Catalog);
}
}
@ -1363,8 +1536,8 @@ namespace GameCult.Eve.UnityScene
await EnsureBundleLoadedAsync(dependency, cancellationToken);
var groupElapsed = Stopwatch.StartNew();
var descriptor = await _meshClient!
.ReadAsync<CultMeshCdnArtifactManifest>(_target, uri, cancellationToken);
var descriptor = await AssetMeshClient()
.ReadAsync<CultMeshCdnArtifactManifest>(_assetTarget, uri, cancellationToken);
TraceStartup("asset-manifest", groupElapsed);
var bundle = await LoadVerifiedBundleAsync(descriptor, variant, cancellationToken);
TraceStartup("asset-transfer-and-bundle-load", groupElapsed);
@ -1464,6 +1637,8 @@ namespace GameCult.Eve.UnityScene
private long CurrentAssetCatalogVersion { get; set; } = -1;
private string CurrentAssetSourceIdentity { get; set; } = "";
private static string CurrentBundlePlatform()
{
return Application.platform == RuntimePlatform.WindowsEditor ||
@ -1490,9 +1665,9 @@ namespace GameCult.Eve.UnityScene
_contentState,
new[]
{
_meshClient!.ContentProvider(
_advertisement.ProviderId,
_target)
AssetMeshClient().ContentProvider(
_assetProviderId,
_assetTarget)
},
new CultMeshContentTransferOptions(cacheRoot),
_assetBodyMappings);
@ -1619,6 +1794,42 @@ namespace GameCult.Eve.UnityScene
receipt.Navigation.AuthorityRuntimeId)));
}
private sealed class AssetSource
{
public AssetSource(
CultMeshSessionTarget target,
string providerId,
string manifestRecordRef,
IReadOnlyList<string> rendezvousEndpoints)
{
Target = target;
ProviderId = providerId ?? "";
ManifestRecordRef = manifestRecordRef ?? "";
RendezvousEndpoints = rendezvousEndpoints ?? Array.Empty<string>();
Identity = Target.VerseId + "\u001f" + Target.AuthorityRuntimeId + "\u001f" +
ProviderId + "\u001f" + ManifestRecordRef + "\u001f" +
string.Join("\u001e", RendezvousEndpoints);
}
public CultMeshSessionTarget Target { get; }
public string ProviderId { get; }
public string ManifestRecordRef { get; }
public IReadOnlyList<string> RendezvousEndpoints { get; }
public string Identity { get; }
}
private sealed class PendingAssetCatalogUpdate
{
public PendingAssetCatalogUpdate(string sourceIdentity, EveAssetCatalogDocument catalog)
{
SourceIdentity = sourceIdentity ?? "";
Catalog = catalog;
}
public string SourceIdentity { get; }
public EveAssetCatalogDocument Catalog { get; }
}
private bool ReceiptMatches(EveSurfaceCommandRequest request, EveCommandReceiptDocument receipt) =>
string.Equals(receipt.Schema, EveCommandReceiptDocument.SchemaId, StringComparison.Ordinal) &&
string.Equals(receipt.CommandId, request.CommandId, StringComparison.Ordinal) &&

View file

@ -172,7 +172,11 @@ namespace GameCult.Eve.UnityScene
worldRoot.GetProp("exposureAdaptation", "progressive"),
ParseFloat(worldRoot.GetProp("exposureSpeedUp"), 2f),
ParseFloat(worldRoot.GetProp("exposureSpeedDown"), 1f),
worldRoot.GetProp("colorGradingSpace"));
worldRoot.GetProp("colorGradingSpace"),
worldRoot.GetProp("assetProviderId"),
worldRoot.GetProp("assetVerseId"),
worldRoot.GetProp("assetAuthorityRuntimeId"),
ParseStringList(worldRoot.GetProp("assetRendezvousEndpoints")));
}
private static EveUnityFieldVolumeProjection BuildFieldVolume(EveSurfaceComponent component) =>
@ -443,7 +447,11 @@ namespace GameCult.Eve.UnityScene
string exposureAdaptation = "progressive",
float exposureSpeedUp = 2f,
float exposureSpeedDown = 1f,
string colorGradingSpace = "")
string colorGradingSpace = "",
string assetProviderId = "",
string assetVerseId = "",
string assetAuthorityRuntimeId = "",
IReadOnlyList<string>? assetRendezvousEndpoints = null)
{
WorldRootId = worldRootId ?? "";
StatePointerId = statePointerId ?? "";
@ -507,6 +515,10 @@ namespace GameCult.Eve.UnityScene
ExposureSpeedUp = exposureSpeedUp;
ExposureSpeedDown = exposureSpeedDown;
ColorGradingSpace = colorGradingSpace ?? "";
AssetProviderId = assetProviderId ?? "";
AssetVerseId = assetVerseId ?? "";
AssetAuthorityRuntimeId = assetAuthorityRuntimeId ?? "";
AssetRendezvousEndpoints = assetRendezvousEndpoints ?? Array.Empty<string>();
}
public string WorldRootId { get; }
@ -525,6 +537,14 @@ namespace GameCult.Eve.UnityScene
public string AssetManifest { get; }
public string AssetProviderId { get; }
public string AssetVerseId { get; }
public string AssetAuthorityRuntimeId { get; }
public IReadOnlyList<string> AssetRendezvousEndpoints { get; }
public string InputProfile { get; }
public string CameraRig { get; }

View file

@ -582,6 +582,10 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(projection.PlayableWorld.EntityBodyId, Is.EqualTo("eve:entity-soa:aetheria.daemon:pilot"));
Assert.That(projection.PlayableWorld.ZoneRenderPointerId, Is.EqualTo("cultmesh://aetheria/world/zone-render"));
Assert.That(projection.PlayableWorld.AssetManifest, Is.EqualTo("cultmesh://aetheria/assets/manifest"));
Assert.That(projection.PlayableWorld.AssetProviderId, Is.EqualTo("aetheria.assets"));
Assert.That(projection.PlayableWorld.AssetVerseId, Is.EqualTo("aetheria.mods"));
Assert.That(projection.PlayableWorld.AssetAuthorityRuntimeId, Is.EqualTo("mod-authority"));
Assert.That(projection.PlayableWorld.AssetRendezvousEndpoints, Is.EqualTo(new[] { "wss://odin-a/cultmesh", "wss://odin-b/cultmesh" }));
Assert.That(projection.PlayableWorld.InputProfile, Is.EqualTo("arpg-third-person"));
Assert.That(projection.PlayableWorld.CameraRig, Is.EqualTo("planar.top-down-follow.v1"));
Assert.That(projection.PlayableWorld.CameraLookAt, Is.Empty);
@ -2730,6 +2734,10 @@ namespace GameCult.Eve.UnityScene.Tests
["zoneRenderPointerId"] = "cultmesh://aetheria/world/zone-render",
["zoneRenderSchema"] = "gamecult.aetheria.zone_render.v1",
["assetManifest"] = "cultmesh://aetheria/assets/manifest",
["assetProviderId"] = "aetheria.assets",
["assetVerseId"] = "aetheria.mods",
["assetAuthorityRuntimeId"] = "mod-authority",
["assetRendezvousEndpoints"] = "wss://odin-a/cultmesh;wss://odin-b/cultmesh",
["inputProfile"] = "arpg-third-person",
["cameraRig"] = cameraRig,
["cameraLookAt"] = cameraLookAt,

View file

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