diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index 208639d..750709d 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -48,6 +48,10 @@ The mounted provider's trust policy authenticates only that provider. A provider-qualified asset source in another Verse/runtime uses the configured cross-target Odin trust for its catalog, subscription, manifests, content, and bundles; local-development trust does not cross that boundary. +The candidate opens its catalog lease before preload, observes updates in a +candidate-owned buffer, and verifies the leased version again before commit. +An update that lands during preload therefore rebuilds the candidate instead +of disappearing between a one-shot read and a later subscription. Navigation keeps the outgoing presentation quiesced until the candidate route is ready, then commits the provider route before activating the candidate. diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index a6e5245..a76f720 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -1270,8 +1270,7 @@ namespace GameCult.Eve.UnityScene if (string.IsNullOrWhiteSpace(source.ManifestRecordRef)) return; - var catalog = await ReadAssetCatalogAsync(source, cancellationToken); - var candidate = await BuildAssetGenerationAsync(source, catalog, cancellationToken); + var candidate = await BuildAssetGenerationAsync(source, cancellationToken); CommitAssetGeneration(candidate); } @@ -1283,7 +1282,6 @@ namespace GameCult.Eve.UnityScene { try { - var catalog = await ReadAssetCatalogAsync(source, cancellationToken); if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) return; await _assetGenerationGate.WaitAsync(cancellationToken); @@ -1291,7 +1289,7 @@ namespace GameCult.Eve.UnityScene { if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) return; - var candidate = await BuildAssetGenerationAsync(source, catalog, cancellationToken); + var candidate = await BuildAssetGenerationAsync(source, cancellationToken); if (generation != Interlocked.Read(ref _surfaceAssetGeneration)) { candidate.Dispose(); @@ -1339,53 +1337,44 @@ namespace GameCult.Eve.UnityScene rendezvousEndpoints); } - private async Task ReadAssetCatalogAsync( - AssetSource source, - CancellationToken cancellationToken) - { - if (string.Equals(source.Identity, CurrentAssetSourceIdentity, StringComparison.Ordinal)) - return await AssetMeshClient() - .ReadAsync(source.Target, source.ManifestRecordRef, cancellationToken); - var crossTarget = IsCrossTarget(source); - var endpoints = AssetRendezvousEndpoints(source, crossTarget); - using var candidateClient = crossTarget || source.RendezvousEndpoints.Count > 0 - ? CreateMeshClient( - endpoints, - AssetAuthorityTrust(source)) - : null; - return await (candidateClient ?? _meshClient ?? - throw new InvalidOperationException("The CultMesh client is not open.")) - .ReadAsync(source.Target, source.ManifestRecordRef, cancellationToken); - } - private async Task BuildAssetGenerationAsync( AssetSource source, - EveAssetCatalogDocument catalog, CancellationToken cancellationToken) { - var crossTarget = IsCrossTarget(source); - var endpoints = AssetRendezvousEndpoints(source, crossTarget); - var candidate = new AssetGeneration( - source, - source.Identity, - catalog.Version, - crossTarget || source.RendezvousEndpoints.Count > 0 - ? CreateMeshClient( - endpoints, - AssetAuthorityTrust(source)) - : null); - try + while (true) { - ConfigureAssetCatalog(candidate, catalog); - ReuseCompatibleBundles(_assetGeneration, candidate); - await PreloadAssetsAsync(candidate, cancellationToken); - await AttachAssetCatalogSubscriptionAsync(candidate, cancellationToken); - return candidate; - } - catch - { - candidate.Dispose(); - throw; + cancellationToken.ThrowIfCancellationRequested(); + var crossTarget = IsCrossTarget(source); + var endpoints = AssetRendezvousEndpoints(source, crossTarget); + var candidate = new AssetGeneration( + source, + source.Identity, + catalogVersion: -1, + crossTarget || source.RendezvousEndpoints.Count > 0 + ? CreateMeshClient( + endpoints, + AssetAuthorityTrust(source)) + : null); + try + { + var catalog = await AttachAssetCatalogSubscriptionAsync(candidate, cancellationToken); + ConfigureAssetCatalog(candidate, catalog); + ReuseCompatibleBundles(_assetGeneration, candidate); + await PreloadAssetsAsync(candidate, cancellationToken); + var latest = await candidate.CatalogLease!.Handle.LatestAsync(); + candidate.ObserveCatalog(latest); + if (latest.Version != candidate.CatalogVersion) + { + candidate.Dispose(); + continue; + } + return candidate; + } + catch + { + candidate.Dispose(); + throw; + } } } @@ -1407,7 +1396,7 @@ namespace GameCult.Eve.UnityScene return new[] { _rendezvousEndpoint }; } - private async Task AttachAssetCatalogSubscriptionAsync( + private async Task AttachAssetCatalogSubscriptionAsync( AssetGeneration candidate, CancellationToken cancellationToken) { @@ -1419,9 +1408,17 @@ namespace GameCult.Eve.UnityScene IDisposable? watch = null; try { - watch = lease.Handle.Watch(catalog => QueueAssetCatalogUpdate(candidate.SourceIdentity, catalog)); + watch = lease.Handle.Watch(catalog => + { + var pending = candidate.ObserveCatalog(catalog); + if (pending != null) + QueueAssetCatalogUpdate(candidate.SourceIdentity, pending); + }); candidate.CatalogWatch = watch; candidate.CatalogLease = lease; + var catalog = await lease.Handle.LatestAsync(); + candidate.ObserveCatalog(catalog); + return catalog; } catch { @@ -1447,6 +1444,9 @@ namespace GameCult.Eve.UnityScene var previous = _assetGeneration; previous.TransferSharedBundlesTo(candidate); _assetGeneration = candidate; + var pendingCatalog = candidate.ActivateCatalogObservation(); + if (pendingCatalog != null) + QueueAssetCatalogUpdate(candidate.SourceIdentity, pendingCatalog); if (surface != null) _baseSurface = surface; try @@ -1535,6 +1535,7 @@ namespace GameCult.Eve.UnityScene $"Provider asset catalog '{catalog.CatalogId}' has no unity-scene/{CurrentBundlePlatform()} variant. " + $"Advertised variants: {string.Join(", ", advertisedVariants)}"); } + candidate.SetCatalogVersion(catalog.Version); ReadCameraPolicies(candidate, selected.Select(selection => selection.Variant!)); foreach (var selection in selected) { @@ -1588,7 +1589,7 @@ namespace GameCult.Eve.UnityScene update.Catalog.Version == CurrentAssetCatalogVersion) continue; var source = _assetGeneration.Source; - var candidate = await BuildAssetGenerationAsync(source, update.Catalog, _lifetime.Token); + var candidate = await BuildAssetGenerationAsync(source, _lifetime.Token); if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal)) { candidate.Dispose(); @@ -1706,6 +1707,10 @@ namespace GameCult.Eve.UnityScene private sealed class AssetGeneration : IDisposable { + private readonly object _catalogObservationGate = new object(); + private EveAssetCatalogDocument? _latestObservedCatalog; + private bool _catalogObservationActive; + public AssetGeneration( AssetSource source, string sourceIdentity, @@ -1720,9 +1725,9 @@ namespace GameCult.Eve.UnityScene public AssetSource Source { get; } public string SourceIdentity { get; } - public long CatalogVersion { get; } + public long CatalogVersion { get; private set; } public CultMeshClient? MeshClient { get; } - public IDisposable? CatalogLease { get; set; } + public CultMeshDocumentLease? CatalogLease { get; set; } public IDisposable? CatalogWatch { get; set; } public CultMeshContentTransferService? ContentTransfer { get; set; } public CultMeshVerifiedBodyMappingBroker? BodyMappings { get; set; } @@ -1754,6 +1759,33 @@ namespace GameCult.Eve.UnityScene next.BorrowedAssetBundles.Clear(); } + public void SetCatalogVersion(long catalogVersion) + { + if (CatalogVersion >= 0 && CatalogVersion != catalogVersion) + throw new InvalidOperationException("An asset generation cannot change its catalog version after configuration."); + CatalogVersion = catalogVersion; + } + + public EveAssetCatalogDocument? ObserveCatalog(EveAssetCatalogDocument catalog) + { + lock (_catalogObservationGate) + { + _latestObservedCatalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + return _catalogObservationActive && catalog.Version != CatalogVersion ? catalog : null; + } + } + + public EveAssetCatalogDocument? ActivateCatalogObservation() + { + lock (_catalogObservationGate) + { + _catalogObservationActive = true; + return _latestObservedCatalog != null && _latestObservedCatalog.Version != CatalogVersion + ? _latestObservedCatalog + : null; + } + } + public void Dispose() { CatalogWatch?.Dispose(); diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs index d62393c..54abb48 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCultMeshAssetLookupTests.cs @@ -482,9 +482,9 @@ namespace GameCult.Eve.UnityScene.Tests var generationField = transportType.GetField( "_assetGeneration", BindingFlags.Instance | BindingFlags.NonPublic)!; - var buildMethod = transportType.GetMethod( - "BuildAssetGenerationAsync", - BindingFlags.Instance | BindingFlags.NonPublic)!; + var configureMethod = transportType.GetMethod( + "ConfigureAssetCatalog", + BindingFlags.Static | 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", @@ -514,18 +514,37 @@ namespace GameCult.Eve.UnityScene.Tests 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(() => task.GetAwaiter().GetResult()); + var rejected = CreateAssetGeneration( + "verse-b", "authority-b", "provider-b", "catalog-b", "source-b", -1); + var error = Assert.Throws(() => + configureMethod.Invoke(null, new[] { rejected, invalidCatalog })); + Assert.That(error!.InnerException, Is.TypeOf()); + ((IDisposable)rejected).Dispose(); Assert.That(generationField.GetValue(transport), Is.SameAs(committed)); Assert.That(GenerationProperty(committed, "SourceIdentity"), Is.EqualTo("source-a")); Assert.That(GenerationDictionary(committed, "Prefabs")["prefab.a"], Is.SameAs(prefab)); UnityEngine.Object.DestroyImmediate(prefab); } + [Test] + public void CandidateCatalogObservationSurvivesPrecommitWindow() + { + var candidate = CreateAssetGeneration( + "verse-b", "authority-b", "provider-b", "catalog-b", "source-b", 1); + var candidateType = candidate.GetType(); + var observe = candidateType.GetMethod("ObserveCatalog")!; + var activate = candidateType.GetMethod("ActivateCatalogObservation")!; + var variant = new EveAssetVariant( + "unity-scene", "StandaloneWindows64", "url", "https://example.invalid/b", "sha256:01", 1, "prefab.b"); + var catalogB = new EveAssetCatalogDocument( + "provider-b", "catalog-b", 2, DateTimeOffset.UtcNow.ToString("O"), + new[] { new EveAssetCatalogEntry("prefab.b", "prefab", new[] { variant }) }); + + Assert.That(observe.Invoke(candidate, new object[] { catalogB }), Is.Null); + Assert.That(activate.Invoke(candidate, Array.Empty()), Is.SameAs(catalogB)); + ((IDisposable)candidate).Dispose(); + } + [Test] public void CrossTargetAssetsUseConfiguredRemoteTrust() { diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 53b935d..d09c26c 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.127", + "version": "0.3.128", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": {