Commit Eve asset generations atomically

This commit is contained in:
Meta Vens 2026-08-19 07:57:33 +02:00
commit bdc942db21
4 changed files with 444 additions and 194 deletions

View file

@ -38,6 +38,13 @@ 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.
An Eve world may qualify its asset catalog with a different provider, Verse,
authority runtime, and Odin rendezvous set than the surface provider. The
transport resolves that exact asset session and commits assets as one generation:
catalog, verified bundles, leases, lookups, render policy, and surface lowering
are prepared off to the side, then swapped together. A failed or superseded
candidate is disposed without unloading or rewriting the mounted generation.
Navigation keeps the outgoing presentation quiesced until the candidate route
is ready, then commits the provider route before activating the candidate.
Presentation connections are provider-owned leases: disposing the outgoing

View file

@ -35,22 +35,7 @@ namespace GameCult.Eve.UnityScene
new Dictionary<string, EveSurfaceCommandRequest>(StringComparer.Ordinal);
private readonly Dictionary<string, Task<ReceiptSubscription>> _receiptSubscriptions =
new Dictionary<string, Task<ReceiptSubscription>>(StringComparer.Ordinal);
private readonly Dictionary<string, GameObject> _prefabs = new Dictionary<string, GameObject>(StringComparer.Ordinal);
private readonly Dictionary<string, UnityEngine.Object> _nativeAssets = new Dictionary<string, UnityEngine.Object>(StringComparer.Ordinal);
private readonly Dictionary<string, IReadOnlyDictionary<string, string>> _nativeAssetMetadata =
new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
private readonly List<AssetBundle> _assetBundles = new List<AssetBundle>();
private readonly Dictionary<string, SelectedAsset> _assetSelections =
new Dictionary<string, SelectedAsset>(StringComparer.Ordinal);
private readonly Dictionary<string, List<SelectedAsset>> _bundleSelections =
new Dictionary<string, List<SelectedAsset>>(StringComparer.Ordinal);
private readonly Dictionary<string, EveAssetVariant> _bundleVariants =
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>();
private readonly object _realtimeFrameGate = new object();
private readonly Dictionary<string, CultMeshRealtimeFrame> _latestRealtimeFrames =
@ -62,16 +47,11 @@ namespace GameCult.Eve.UnityScene
private readonly List<IDisposable> _documentLeases = new List<IDisposable>();
private readonly List<IDisposable> _documentWatches = new List<IDisposable>();
private readonly CancellationTokenSource _lifetime = new CancellationTokenSource();
private readonly SemaphoreSlim _assetGenerationGate = new SemaphoreSlim(1, 1);
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 AssetGeneration _assetGeneration;
private EveProviderAdvertisementDocument? _advertisement;
private EveAdvertisedSurface? _advertisedSurface;
private EveSurfaceDocument? _baseSurface;
@ -115,8 +95,11 @@ 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;
_assetGeneration = new AssetGeneration(
new AssetSource(_target, _providerId, "", Array.Empty<string>()),
sourceIdentity: "",
catalogVersion: -1,
meshClient: null);
_surfaceId = string.IsNullOrWhiteSpace(surfaceId)
? throw new ArgumentException("Surface id must be non-empty.", nameof(surfaceId))
: surfaceId.Trim();
@ -141,7 +124,7 @@ namespace GameCult.Eve.UnityScene
public EveInputCapabilityDocument CurrentInputCapability { get; private set; }
public CultMeshBodyTransportKind? CurrentAssetBodyTransportKind { get; private set; }
public CultMeshBodyTransportKind? CurrentAssetBodyTransportKind => _assetGeneration.BodyTransportKind;
public event Action<EveUnitySceneProviderSurfaceDocument>? SurfaceDocumentAvailable;
@ -194,8 +177,6 @@ namespace GameCult.Eve.UnityScene
TraceStartup("surface", elapsed);
await RefreshAssetCatalogAsync(cancellationToken);
TraceStartup("asset-catalog-and-bundles", elapsed);
await PreloadAssetsAsync(cancellationToken);
TraceStartup("asset-preload", elapsed);
await EnsureLiveDocumentsAsync(cancellationToken);
TraceStartup("subscriptions", elapsed);
_commandOutbox = new EveUnityCultMeshCommandOutbox(
@ -443,18 +424,7 @@ namespace GameCult.Eve.UnityScene
_lifetime.Cancel();
_commandOutbox?.Dispose();
_commandOutbox = null;
foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false);
_assetBundles.Clear();
foreach (var lease in _assetBodyLeases) lease.Dispose();
_assetBodyLeases.Clear();
_prefabs.Clear();
_nativeAssets.Clear();
_nativeAssetMetadata.Clear();
_assetSelections.Clear();
_bundleSelections.Clear();
_bundleVariants.Clear();
_loadedBundleUris.Clear();
_loadingBundleUris.Clear();
_assetGeneration.Dispose();
_mappedEntityFrameCursor?.Dispose();
_mappedEntityFrameCursor = null;
_mappedEntityFrameContract = null;
@ -465,18 +435,10 @@ 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;
_embeddedSurfaces.Clear();
lock (_pendingCommands)
@ -493,7 +455,7 @@ namespace GameCult.Eve.UnityScene
{
if (asset == null) throw new ArgumentNullException(nameof(asset));
EnsureAssetPrepared(asset.AssetRef);
return _prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null;
return _assetGeneration.Prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null;
}
public UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType)
@ -501,14 +463,15 @@ namespace GameCult.Eve.UnityScene
if (asset == null) throw new ArgumentNullException(nameof(asset));
if (assetType == null) throw new ArgumentNullException(nameof(assetType));
EnsureAssetPrepared(asset.AssetRef);
return _nativeAssets.TryGetValue(asset.AssetRef, out var value) && assetType.IsInstanceOfType(value)
return _assetGeneration.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))
if (_assetGeneration.AssetSelections.ContainsKey(assetRef) &&
!_assetGeneration.NativeAssets.ContainsKey(assetRef))
throw new InvalidOperationException(
$"Provider asset '{assetRef}' was not materialized during asynchronous preparation.");
}
@ -518,12 +481,12 @@ namespace GameCult.Eve.UnityScene
out IReadOnlyDictionary<string, string> metadata)
{
if (asset == null) throw new ArgumentNullException(nameof(asset));
return _nativeAssetMetadata.TryGetValue(asset.AssetRef, out metadata!);
return _assetGeneration.NativeAssetMetadata.TryGetValue(asset.AssetRef, out metadata!);
}
public bool TryGetRenderChannelLayer(string channel, out int layer)
{
return _renderChannelLayers.TryGetValue(channel ?? "", out layer);
return _assetGeneration.RenderChannelLayers.TryGetValue(channel ?? "", out layer);
}
private void EnsureOpen()
@ -549,7 +512,8 @@ namespace GameCult.Eve.UnityScene
});
}
private CultMeshClient AssetMeshClient() => _assetMeshClient ??
private CultMeshClient AssetMeshClient(AssetGeneration? generation = null) =>
(generation ?? _assetGeneration).MeshClient ??
_meshClient ?? throw new InvalidOperationException("The CultMesh client is not open.");
private async Task ResolveAdvertisementAsync(
@ -956,11 +920,15 @@ namespace GameCult.Eve.UnityScene
private void PublishComposedSurface()
{
var surface = ComposeSurface(
_baseSurface ?? throw new InvalidOperationException("The base Eve surface is unavailable."),
_embeddedSurfaces);
ApplyPreparedSurface(PrepareComposedSurface(
_baseSurface ?? throw new InvalidOperationException("The base Eve surface is unavailable.")));
}
private PreparedSurface PrepareComposedSurface(EveSurfaceDocument baseSurface)
{
var surface = ComposeSurface(baseSurface, _embeddedSurfaces);
var interaction = RequireWorldInteraction();
CurrentSurfaceDocument = new EveUnitySceneProviderSurfaceDocument(
var document = new EveUnitySceneProviderSurfaceDocument(
surface,
new EveUnitySceneProviderSurfaceAdvertisement(
_advertisedSurface.SurfaceId,
@ -973,12 +941,21 @@ namespace GameCult.Eve.UnityScene
_advertisedSurface.RecordRef,
surface.Version);
var lowered = new EveUnitySceneSurfaceLowerer()
.Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface);
_continuousCommandIds.Clear();
.Lower(document.SurfaceDocument, document.AdvertisedSurface);
var continuousCommands = new HashSet<string>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.MovementCommand))
_continuousCommandIds.Add(lowered.PlayableWorld.MovementCommand);
continuousCommands.Add(lowered.PlayableWorld.MovementCommand);
if (!string.IsNullOrWhiteSpace(lowered.PlayableWorld?.LookCommand))
_continuousCommandIds.Add(lowered.PlayableWorld.LookCommand);
continuousCommands.Add(lowered.PlayableWorld.LookCommand);
return new PreparedSurface(document, continuousCommands);
}
private void ApplyPreparedSurface(PreparedSurface prepared)
{
CurrentSurfaceDocument = prepared.Document;
_continuousCommandIds.Clear();
foreach (var command in prepared.ContinuousCommands)
_continuousCommandIds.Add(command);
SurfaceDocumentAvailable?.Invoke(CurrentSurfaceDocument);
}
@ -1258,6 +1235,20 @@ namespace GameCult.Eve.UnityScene
public EveSurfaceDocument Document { get; }
}
private sealed class PreparedSurface
{
public PreparedSurface(
EveUnitySceneProviderSurfaceDocument document,
IReadOnlyCollection<string> continuousCommands)
{
Document = document;
ContinuousCommands = continuousCommands;
}
public EveUnitySceneProviderSurfaceDocument Document { get; }
public IReadOnlyCollection<string> ContinuousCommands { get; }
}
private static void TraceHotState(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("AETHERIA_TRACE_CLIENT_RUDP"), "1", StringComparison.Ordinal))
@ -1272,9 +1263,8 @@ namespace GameCult.Eve.UnityScene
return;
var catalog = await ReadAssetCatalogAsync(source, cancellationToken);
ApplyAssetSource(source);
PublishAssetCatalog(source.Identity, catalog);
await ReplaceAssetCatalogSubscriptionAsync(source, cancellationToken);
var candidate = await BuildAssetGenerationAsync(source, catalog, cancellationToken);
CommitAssetGeneration(candidate);
}
private async Task RefreshAssetsForSurfaceAsync(
@ -1288,14 +1278,23 @@ namespace GameCult.Eve.UnityScene
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();
await _assetGenerationGate.WaitAsync(cancellationToken);
try
{
if (generation != Interlocked.Read(ref _surfaceAssetGeneration))
return;
var candidate = await BuildAssetGenerationAsync(source, catalog, cancellationToken);
if (generation != Interlocked.Read(ref _surfaceAssetGeneration))
{
candidate.Dispose();
return;
}
CommitAssetGeneration(candidate, surface);
}
finally
{
_assetGenerationGate.Release();
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@ -1347,40 +1346,46 @@ namespace GameCult.Eve.UnityScene
.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(
private async Task<AssetGeneration> BuildAssetGenerationAsync(
AssetSource source,
EveAssetCatalogDocument catalog,
CancellationToken cancellationToken)
{
var lease = await AssetMeshClient()
.LeaseDocumentAsync<EveAssetCatalogDocument>(source.Target, source.ManifestRecordRef, cancellationToken);
var candidate = new AssetGeneration(
source,
source.Identity,
catalog.Version,
source.RendezvousEndpoints.Count > 0 ? CreateMeshClient(source.RendezvousEndpoints) : null);
try
{
ConfigureAssetCatalog(candidate, catalog);
ReuseCompatibleBundles(_assetGeneration, candidate);
await PreloadAssetsAsync(candidate, cancellationToken);
await AttachAssetCatalogSubscriptionAsync(candidate, cancellationToken);
return candidate;
}
catch
{
candidate.Dispose();
throw;
}
}
private async Task AttachAssetCatalogSubscriptionAsync(
AssetGeneration candidate,
CancellationToken cancellationToken)
{
var lease = await AssetMeshClient(candidate)
.LeaseDocumentAsync<EveAssetCatalogDocument>(
candidate.Source.Target,
candidate.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();
watch = lease.Handle.Watch(catalog => QueueAssetCatalogUpdate(candidate.SourceIdentity, catalog));
candidate.CatalogWatch = watch;
candidate.CatalogLease = lease;
}
catch
{
@ -1390,6 +1395,69 @@ namespace GameCult.Eve.UnityScene
}
}
private void CommitAssetGeneration(AssetGeneration candidate, EveSurfaceDocument? surface = null)
{
PreparedSurface prepared;
try
{
prepared = PrepareComposedSurface(surface ?? _baseSurface ??
throw new InvalidOperationException("The base Eve surface is unavailable."));
}
catch
{
candidate.Dispose();
throw;
}
var previous = _assetGeneration;
previous.TransferSharedBundlesTo(candidate);
_assetGeneration = candidate;
if (surface != null)
_baseSurface = surface;
try
{
ApplyPreparedSurface(prepared);
}
finally
{
previous.Dispose();
}
}
private static void ReuseCompatibleBundles(AssetGeneration current, AssetGeneration candidate)
{
foreach (var pair in candidate.BundleVariants)
{
if (!current.LoadedBundlesByUri.TryGetValue(pair.Key, out var bundle) ||
!current.BundleVariants.TryGetValue(pair.Key, out var existing) ||
!string.Equals(existing.ContentHash, pair.Value.ContentHash, StringComparison.Ordinal) ||
existing.SizeBytes != pair.Value.SizeBytes)
continue;
var selections = candidate.BundleSelections[pair.Key];
if (selections.Any(selection =>
!current.NativeAssets.ContainsKey(selection.Asset.AssetRef)))
continue;
candidate.AssetBundles.Add(bundle);
candidate.BorrowedAssetBundles.Add(bundle);
candidate.LoadedBundlesByUri[pair.Key] = bundle;
candidate.LoadedBundleUris.Add(pair.Key);
foreach (var selection in selections)
{
var value = current.NativeAssets[selection.Asset.AssetRef];
candidate.NativeAssets[selection.Asset.AssetRef] = value;
if (value is GameObject prefab)
candidate.Prefabs[selection.Asset.AssetRef] = prefab;
if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) &&
!string.IsNullOrWhiteSpace(role))
{
candidate.NativeAssets[role] = value;
if (value is GameObject rolePrefab)
candidate.Prefabs[role] = rolePrefab;
}
}
}
}
private static EveSurfaceComponent? FindWorldScene(EveSurfaceComponent component)
{
if (string.Equals(component.Kind, "world.scene3d", StringComparison.Ordinal) ||
@ -1411,26 +1479,8 @@ namespace GameCult.Eve.UnityScene
.Distinct(StringComparer.Ordinal)
.ToArray();
private void PublishAssetCatalog(string sourceIdentity, EveAssetCatalogDocument catalog)
private static void ConfigureAssetCatalog(AssetGeneration candidate, EveAssetCatalogDocument catalog)
{
if (string.Equals(sourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal) &&
catalog.Version == CurrentAssetCatalogVersion)
return;
foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false);
_assetBundles.Clear();
foreach (var lease in _assetBodyLeases) lease.Dispose();
_assetBodyLeases.Clear();
CurrentAssetBodyTransportKind = null;
_prefabs.Clear();
_nativeAssets.Clear();
_nativeAssetMetadata.Clear();
_assetSelections.Clear();
_bundleSelections.Clear();
_bundleVariants.Clear();
_loadedBundleUris.Clear();
_loadingBundleUris.Clear();
_renderChannelLayers.Clear();
var selected = catalog.Assets
.Select(asset => new SelectedAsset(
asset,
@ -1449,30 +1499,27 @@ namespace GameCult.Eve.UnityScene
$"Provider asset catalog '{catalog.CatalogId}' has no unity-scene/{CurrentBundlePlatform()} variant. " +
$"Advertised variants: {string.Join(", ", advertisedVariants)}");
}
ReadCameraPolicies(selected.Select(selection => selection.Variant!));
ReadCameraPolicies(candidate, selected.Select(selection => selection.Variant!));
foreach (var selection in selected)
{
var variant = selection.Variant!;
_assetSelections[selection.Asset.AssetRef] = selection;
_bundleVariants[variant.Uri] = variant;
if (!_bundleSelections.TryGetValue(variant.Uri, out var group))
candidate.AssetSelections[selection.Asset.AssetRef] = selection;
candidate.BundleVariants[variant.Uri] = variant;
if (!candidate.BundleSelections.TryGetValue(variant.Uri, out var group))
{
group = new List<SelectedAsset>();
_bundleSelections[variant.Uri] = group;
candidate.BundleSelections[variant.Uri] = group;
}
group.Add(selection);
var metadata = MergeAssetMetadata(selection.Asset.Metadata, variant.Metadata);
_nativeAssetMetadata[selection.Asset.AssetRef] = metadata;
candidate.NativeAssetMetadata[selection.Asset.AssetRef] = metadata;
if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) &&
!string.IsNullOrWhiteSpace(role))
{
_assetSelections[role] = selection;
_nativeAssetMetadata[role] = metadata;
candidate.AssetSelections[role] = selection;
candidate.NativeAssetMetadata[role] = metadata;
}
}
CurrentAssetCatalogVersion = catalog.Version;
CurrentAssetSourceIdentity = sourceIdentity;
}
private void QueueAssetCatalogUpdate(string sourceIdentity, EveAssetCatalogDocument catalog)
@ -1496,9 +1543,27 @@ namespace GameCult.Eve.UnityScene
if (update == null) break;
if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal))
continue;
PublishAssetCatalog(update.SourceIdentity, update.Catalog);
await PreloadAssetsAsync(_lifetime.Token);
PublishComposedSurface();
if (update.Catalog.Version == CurrentAssetCatalogVersion)
continue;
await _assetGenerationGate.WaitAsync(_lifetime.Token);
try
{
if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal) ||
update.Catalog.Version == CurrentAssetCatalogVersion)
continue;
var source = _assetGeneration.Source;
var candidate = await BuildAssetGenerationAsync(source, update.Catalog, _lifetime.Token);
if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal))
{
candidate.Dispose();
continue;
}
CommitAssetGeneration(candidate);
}
finally
{
_assetGenerationGate.Release();
}
}
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
@ -1516,36 +1581,40 @@ namespace GameCult.Eve.UnityScene
}
}
private async Task PreloadAssetsAsync(CancellationToken cancellationToken)
private async Task PreloadAssetsAsync(AssetGeneration candidate, CancellationToken cancellationToken)
{
foreach (var uri in _bundleVariants.Keys.OrderBy(value => value, StringComparer.Ordinal).ToArray())
await EnsureBundleLoadedAsync(uri, cancellationToken);
foreach (var uri in candidate.BundleVariants.Keys.OrderBy(value => value, StringComparer.Ordinal).ToArray())
await EnsureBundleLoadedAsync(candidate, uri, cancellationToken);
}
private async Task EnsureBundleLoadedAsync(string uri, CancellationToken cancellationToken)
private async Task EnsureBundleLoadedAsync(
AssetGeneration candidate,
string uri,
CancellationToken cancellationToken)
{
if (_loadedBundleUris.Contains(uri)) return;
if (!_bundleVariants.TryGetValue(uri, out var variant))
if (candidate.LoadedBundleUris.Contains(uri)) return;
if (!candidate.BundleVariants.TryGetValue(uri, out var variant))
throw new InvalidOperationException($"Provider asset bundle '{uri}' is not present in the selected runtime catalog.");
if (!_loadingBundleUris.Add(uri))
if (!candidate.LoadingBundleUris.Add(uri))
throw new InvalidOperationException($"Provider asset bundle dependency cycle includes '{uri}'.");
try
{
if (variant.Metadata.TryGetValue("unity.bundleDependencyUris", out var dependencies))
foreach (var dependency in dependencies.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
await EnsureBundleLoadedAsync(dependency, cancellationToken);
await EnsureBundleLoadedAsync(candidate, dependency, cancellationToken);
var groupElapsed = Stopwatch.StartNew();
var descriptor = await AssetMeshClient()
.ReadAsync<CultMeshCdnArtifactManifest>(_assetTarget, uri, cancellationToken);
var descriptor = await AssetMeshClient(candidate)
.ReadAsync<CultMeshCdnArtifactManifest>(candidate.Source.Target, uri, cancellationToken);
TraceStartup("asset-manifest", groupElapsed);
var bundle = await LoadVerifiedBundleAsync(descriptor, variant, cancellationToken);
var bundle = await LoadVerifiedBundleAsync(candidate, descriptor, variant, cancellationToken);
TraceStartup("asset-transfer-and-bundle-load", groupElapsed);
if (bundle == null)
throw new InvalidOperationException($"Unity could not load provider asset bundle '{uri}'.");
_assetBundles.Add(bundle);
candidate.AssetBundles.Add(bundle);
candidate.LoadedBundlesByUri[uri] = bundle;
var bundleAssetNames = bundle.GetAllAssetNames();
foreach (var selection in _bundleSelections[uri])
foreach (var selection in candidate.BundleSelections[uri])
{
var bundleAssetName = ResolveBundleAssetName(bundleAssetNames, selection.Variant!.AssetKey);
var value = bundleAssetName == null
@ -1555,22 +1624,22 @@ namespace GameCult.Eve.UnityScene
throw new InvalidOperationException(
$"Provider asset '{selection.Asset.AssetRef}' advertises missing Unity bundle asset " +
$"'{selection.Variant.AssetKey}'. Available assets: " + string.Join(", ", bundleAssetNames));
_nativeAssets[selection.Asset.AssetRef] = value;
candidate.NativeAssets[selection.Asset.AssetRef] = value;
if (selection.Asset.Metadata.TryGetValue("presentationRole", out var role) &&
!string.IsNullOrWhiteSpace(role))
_nativeAssets[role] = value;
candidate.NativeAssets[role] = value;
if (value is GameObject prefab)
{
_prefabs[selection.Asset.AssetRef] = prefab;
if (!string.IsNullOrWhiteSpace(role)) _prefabs[role] = prefab;
candidate.Prefabs[selection.Asset.AssetRef] = prefab;
if (!string.IsNullOrWhiteSpace(role)) candidate.Prefabs[role] = prefab;
}
}
_loadedBundleUris.Add(uri);
candidate.LoadedBundleUris.Add(uri);
TraceStartup("bundle-assets", groupElapsed);
}
finally
{
_loadingBundleUris.Remove(uri);
candidate.LoadingBundleUris.Remove(uri);
}
}
@ -1599,6 +1668,84 @@ namespace GameCult.Eve.UnityScene
public EveAssetVariant? Variant { get; }
}
private sealed class AssetGeneration : IDisposable
{
public AssetGeneration(
AssetSource source,
string sourceIdentity,
long catalogVersion,
CultMeshClient? meshClient)
{
Source = source;
SourceIdentity = sourceIdentity ?? "";
CatalogVersion = catalogVersion;
MeshClient = meshClient;
}
public AssetSource Source { get; }
public string SourceIdentity { get; }
public long CatalogVersion { get; }
public CultMeshClient? MeshClient { get; }
public IDisposable? CatalogLease { get; set; }
public IDisposable? CatalogWatch { get; set; }
public CultMeshContentTransferService? ContentTransfer { get; set; }
public CultMeshVerifiedBodyMappingBroker? BodyMappings { get; set; }
public CultMeshBodyTransportKind? BodyTransportKind { get; set; }
public Dictionary<string, GameObject> Prefabs { get; } = new Dictionary<string, GameObject>(StringComparer.Ordinal);
public Dictionary<string, UnityEngine.Object> NativeAssets { get; } = new Dictionary<string, UnityEngine.Object>(StringComparer.Ordinal);
public Dictionary<string, IReadOnlyDictionary<string, string>> NativeAssetMetadata { get; } =
new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
public List<AssetBundle> AssetBundles { get; } = new List<AssetBundle>();
public HashSet<AssetBundle> BorrowedAssetBundles { get; } = new HashSet<AssetBundle>();
public Dictionary<string, AssetBundle> LoadedBundlesByUri { get; } =
new Dictionary<string, AssetBundle>(StringComparer.Ordinal);
public Dictionary<string, SelectedAsset> AssetSelections { get; } =
new Dictionary<string, SelectedAsset>(StringComparer.Ordinal);
public Dictionary<string, List<SelectedAsset>> BundleSelections { get; } =
new Dictionary<string, List<SelectedAsset>>(StringComparer.Ordinal);
public Dictionary<string, EveAssetVariant> BundleVariants { get; } =
new Dictionary<string, EveAssetVariant>(StringComparer.Ordinal);
public HashSet<string> LoadedBundleUris { get; } = new HashSet<string>(StringComparer.Ordinal);
public HashSet<string> LoadingBundleUris { get; } = new HashSet<string>(StringComparer.Ordinal);
public List<ICultMeshBodyReadLease> BodyLeases { get; } = new List<ICultMeshBodyReadLease>();
public Dictionary<string, int> RenderChannelLayers { get; } =
new Dictionary<string, int>(StringComparer.Ordinal);
public void TransferSharedBundlesTo(AssetGeneration next)
{
foreach (var bundle in next.BorrowedAssetBundles)
AssetBundles.Remove(bundle);
next.BorrowedAssetBundles.Clear();
}
public void Dispose()
{
CatalogWatch?.Dispose();
CatalogWatch = null;
CatalogLease?.Dispose();
CatalogLease = null;
MeshClient?.Dispose();
foreach (var bundle in AssetBundles)
if (!BorrowedAssetBundles.Contains(bundle))
bundle.Unload(unloadAllLoadedObjects: false);
AssetBundles.Clear();
BorrowedAssetBundles.Clear();
foreach (var lease in BodyLeases)
lease.Dispose();
BodyLeases.Clear();
Prefabs.Clear();
NativeAssets.Clear();
NativeAssetMetadata.Clear();
AssetSelections.Clear();
BundleSelections.Clear();
BundleVariants.Clear();
LoadedBundleUris.Clear();
LoadingBundleUris.Clear();
LoadedBundlesByUri.Clear();
RenderChannelLayers.Clear();
}
}
private static string? ResolveBundleAssetName(IEnumerable<string> bundleAssetNames, string advertisedAssetKey)
{
return bundleAssetNames.FirstOrDefault(assetName =>
@ -1615,9 +1762,8 @@ namespace GameCult.Eve.UnityScene
return merged;
}
private void ReadCameraPolicies(IEnumerable<EveAssetVariant> variants)
private static void ReadCameraPolicies(AssetGeneration candidate, IEnumerable<EveAssetVariant> variants)
{
_renderChannelLayers.Clear();
foreach (var variant in variants)
{
foreach (var pair in variant.Metadata)
@ -1630,14 +1776,14 @@ namespace GameCult.Eve.UnityScene
var channel = pair.Key.Substring(prefix.Length, pair.Key.Length - prefix.Length - suffix.Length);
if (!string.IsNullOrWhiteSpace(channel) &&
int.TryParse(pair.Value, out var layer) && layer >= 0 && layer < 32)
_renderChannelLayers[channel] = layer;
candidate.RenderChannelLayers[channel] = layer;
}
}
}
private long CurrentAssetCatalogVersion { get; set; } = -1;
private long CurrentAssetCatalogVersion => _assetGeneration.CatalogVersion;
private string CurrentAssetSourceIdentity { get; set; } = "";
private string CurrentAssetSourceIdentity => _assetGeneration.SourceIdentity;
private static string CurrentBundlePlatform()
{
@ -1647,10 +1793,10 @@ namespace GameCult.Eve.UnityScene
: Application.platform.ToString();
}
private CultMeshContentTransferService ContentTransfer()
private CultMeshContentTransferService ContentTransfer(AssetGeneration candidate)
{
if (_contentTransfer != null)
return _contentTransfer;
if (candidate.ContentTransfer != null)
return candidate.ContentTransfer;
if (_advertisement == null)
throw new InvalidOperationException("The Eve provider advertisement must be prepared before content transfer.");
if (string.IsNullOrWhiteSpace(_advertisement!.ServiceId))
@ -1658,23 +1804,24 @@ namespace GameCult.Eve.UnityScene
var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH");
if (string.IsNullOrWhiteSpace(cacheRoot))
cacheRoot = Path.Combine(Application.persistentDataPath, "EveUnity", "assets");
_assetBodyMappings = new CultMeshVerifiedBodyMappingBroker(cacheRoot);
candidate.BodyMappings = new CultMeshVerifiedBodyMappingBroker(cacheRoot);
_contentState ??= new CultCache(
CultMesh.CreateCultCacheDocumentRegistry(typeof(CultMeshContentTransferStateDocument)));
_contentTransfer = new CultMeshContentTransferService(
candidate.ContentTransfer = new CultMeshContentTransferService(
_contentState,
new[]
{
AssetMeshClient().ContentProvider(
_assetProviderId,
_assetTarget)
AssetMeshClient(candidate).ContentProvider(
candidate.Source.ProviderId,
candidate.Source.Target)
},
new CultMeshContentTransferOptions(cacheRoot),
_assetBodyMappings);
return _contentTransfer;
candidate.BodyMappings);
return candidate.ContentTransfer;
}
private async Task<AssetBundle?> LoadVerifiedBundleAsync(
AssetGeneration candidate,
CultMeshCdnArtifactManifest manifest,
EveAssetVariant variant,
CancellationToken cancellationToken)
@ -1689,7 +1836,7 @@ namespace GameCult.Eve.UnityScene
var now = DateTimeOffset.UtcNow;
var network = NetworkArtifactDescriptor(manifest, now, TimeSpan.FromMinutes(5));
var elapsed = Stopwatch.StartNew();
var mapped = await ContentTransfer()
var mapped = await ContentTransfer(candidate)
.FetchMappedContentAsync(manifest, network, now, TimeSpan.FromMinutes(5), cancellationToken);
TraceStartup("content-materialization", elapsed);
var validationRequest = new CultMeshBodyValidationRequest
@ -1706,14 +1853,14 @@ namespace GameCult.Eve.UnityScene
var transport = new CultMeshBodyTransportService(
new ICultMeshBodyTransportAdapter[]
{
new CultMeshMappedBodyAdapter(_assetBodyMappings!),
new CultMeshMappedBodyAdapter(candidate.BodyMappings!),
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, validationRequest);
CurrentAssetBodyTransportKind = negotiated.SelectedTransport;
_assetBodyLeases.Add(negotiated.Lease);
candidate.BodyTransportKind = negotiated.SelectedTransport;
candidate.BodyLeases.Add(negotiated.Lease);
var bundleRequest = AssetBundle.LoadFromFileAsync(mapped.VerifiedPath);
await AwaitUnityAsync(bundleRequest, cancellationToken);
TraceStartup("asset-bundle-load-from-file", elapsed);

View file

@ -442,30 +442,16 @@ namespace GameCult.Eve.UnityScene.Tests
}
[Test]
public void PublishingCatalogRegistersBundlesWithoutMaterializingThem()
public void ConfiguringCandidateCatalogRegistersBundlesWithoutMaterializingThem()
{
var platformMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"CurrentBundlePlatform",
BindingFlags.Static | BindingFlags.NonPublic);
var publishMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"PublishAssetCatalog",
BindingFlags.Instance | BindingFlags.NonPublic);
var bundlesField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
"_assetBundles",
BindingFlags.Instance | BindingFlags.NonPublic);
var selectionsField = typeof(EveUnityCultMeshLiveProviderTransport).GetField(
"_assetSelections",
BindingFlags.Instance | BindingFlags.NonPublic);
var configureMethod = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"ConfigureAssetCatalog",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(platformMethod, Is.Not.Null);
Assert.That(publishMethod, Is.Not.Null);
using var transport = new EveUnityCultMeshLiveProviderTransport(
Path.Combine(Path.GetTempPath(), $"eve-lazy-assets-{Guid.NewGuid():N}.cc"),
"cultnet+tcp://127.0.0.1:1",
"provider",
"provider",
"provider",
"surface");
Assert.That(configureMethod, Is.Not.Null);
var variant = new EveAssetVariant(
"unity-scene",
(string)platformMethod!.Invoke(null, Array.Empty<object>())!,
@ -480,11 +466,121 @@ namespace GameCult.Eve.UnityScene.Tests
1,
DateTimeOffset.UtcNow.ToString("O"),
new[] { new EveAssetCatalogEntry("prefab.entity", "prefab", new[] { variant }) });
var candidate = CreateAssetGeneration("verse", "authority", "provider", "catalog", "candidate", 1);
publishMethod!.Invoke(transport, new object[] { catalog });
configureMethod!.Invoke(null, new[] { candidate, catalog });
Assert.That(((System.Collections.ICollection)bundlesField!.GetValue(transport)!).Count, Is.Zero);
Assert.That(((System.Collections.IDictionary)selectionsField!.GetValue(transport)!).Count, Is.EqualTo(1));
Assert.That(GenerationCollection(candidate, "AssetBundles").Count, Is.Zero);
Assert.That(GenerationDictionary(candidate, "AssetSelections").Count, Is.EqualTo(1));
((IDisposable)candidate).Dispose();
}
[Test]
public void FailedAssetCandidatePreservesCommittedGeneration()
{
var transportType = typeof(EveUnityCultMeshLiveProviderTransport);
var generationField = transportType.GetField(
"_assetGeneration",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var buildMethod = transportType.GetMethod(
"BuildAssetGenerationAsync",
BindingFlags.Instance | BindingFlags.NonPublic)!;
using var transport = new EveUnityCultMeshLiveProviderTransport(
Path.Combine(Path.GetTempPath(), $"eve-asset-generation-{Guid.NewGuid():N}.cc"),
"cultnet+tcp://127.0.0.1:1",
"verse-a",
"authority-a",
"provider-a",
"surface-a");
var initial = (IDisposable)generationField.GetValue(transport)!;
var committed = CreateAssetGeneration(
"verse-a", "authority-a", "provider-a", "catalog-a", "source-a", 1);
var prefab = new GameObject("committed-a");
GenerationDictionary(committed, "Prefabs")["prefab.a"] = prefab;
initial.Dispose();
generationField.SetValue(transport, committed);
var invalidVariant = new EveAssetVariant(
"browser",
"web",
"url",
"https://example.invalid/a",
"sha256:00",
1,
"prefab.a");
var invalidCatalog = new EveAssetCatalogDocument(
"provider-b",
"catalog-b",
2,
DateTimeOffset.UtcNow.ToString("O"),
new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { invalidVariant }) });
var source = CreateAssetSource("verse-b", "authority-b", "provider-b", "catalog-b");
var task = (Task)buildMethod.Invoke(
transport,
new[] { source, invalidCatalog, CancellationToken.None })!;
Assert.Throws<InvalidOperationException>(() => task.GetAwaiter().GetResult());
Assert.That(generationField.GetValue(transport), Is.SameAs(committed));
Assert.That(GenerationProperty<string>(committed, "SourceIdentity"), Is.EqualTo("source-a"));
Assert.That(GenerationDictionary(committed, "Prefabs")["prefab.a"], Is.SameAs(prefab));
UnityEngine.Object.DestroyImmediate(prefab);
}
private static object CreateAssetSource(
string verseId,
string authorityRuntimeId,
string providerId,
string manifestRecordRef)
{
var type = typeof(EveUnityCultMeshLiveProviderTransport).GetNestedType(
"AssetSource",
BindingFlags.NonPublic)!;
return Activator.CreateInstance(
type,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
null,
new object[]
{
new CultMeshSessionTarget(verseId, authorityRuntimeId),
providerId,
manifestRecordRef,
Array.Empty<string>()
},
null)!;
}
private static object CreateAssetGeneration(
string verseId,
string authorityRuntimeId,
string providerId,
string manifestRecordRef,
string sourceIdentity,
long catalogVersion)
{
var type = typeof(EveUnityCultMeshLiveProviderTransport).GetNestedType(
"AssetGeneration",
BindingFlags.NonPublic)!;
return Activator.CreateInstance(
type,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
null,
new[]
{
CreateAssetSource(verseId, authorityRuntimeId, providerId, manifestRecordRef),
sourceIdentity,
catalogVersion,
null
},
null)!;
}
private static System.Collections.IList GenerationCollection(object generation, string property) =>
(System.Collections.IList)generation.GetType().GetProperty(property)!.GetValue(generation)!;
private static System.Collections.IDictionary GenerationDictionary(object generation, string property) =>
(System.Collections.IDictionary)generation.GetType().GetProperty(property)!.GetValue(generation)!;
private static T GenerationProperty<T>(object generation, string property) =>
(T)generation.GetType().GetProperty(property)!.GetValue(generation)!;
}
}

View file

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