Bind Eve finality to base and asset generations

This commit is contained in:
Meta Vens 2026-08-19 09:36:55 +02:00
commit e44c77b4e8
3 changed files with 132 additions and 24 deletions

View file

@ -65,12 +65,14 @@ namespace GameCult.Eve.UnityScene
private CultMeshMappedFrameBodyCursor? _mappedEntityFrameCursor;
private CultMeshBodyDescriptor? _mappedEntityFrameContract;
private CancellationTokenSource? _realtimeLifetime;
private CancellationTokenSource? _surfaceAssetCandidateLifetime;
private Task? _realtimePump;
private CultMeshRealtimeSession? _realtimeSession;
private EveUnityCultMeshCommandOutbox? _commandOutbox;
private PendingAssetCatalogUpdate? _pendingAssetCatalog;
private bool _assetCatalogUpdateRunning;
private long _surfaceAssetGeneration;
private long _highestObservedBaseSurfaceVersion = -1;
private long _mountedBaseSurfaceVersion;
private long _presentationBarrierVersion = -1;
private long _lastQueuedEntityViewEpoch = -1;
@ -438,6 +440,9 @@ namespace GameCult.Eve.UnityScene
_mappedEntityFrameCursor = null;
_mappedEntityFrameContract = null;
_realtimeLifetime?.Cancel();
_surfaceAssetCandidateLifetime?.Cancel();
_surfaceAssetCandidateLifetime?.Dispose();
_surfaceAssetCandidateLifetime = null;
_realtimeSession?.Dispose();
_realtimeSession = null;
_realtimePump = null;
@ -906,8 +911,16 @@ namespace GameCult.Eve.UnityScene
private void PublishBaseSurface(EveSurfaceDocument surface)
{
if (surface == null) throw new ArgumentNullException(nameof(surface));
if (_baseSurface != null && surface.Version <= MountedBaseSurfaceVersion)
if (!TryObserveBaseSurfaceVersion(surface.Version))
return;
CancellationToken candidateToken;
lock (_presentationFinalityGate)
{
_surfaceAssetCandidateLifetime?.Cancel();
_surfaceAssetCandidateLifetime?.Dispose();
_surfaceAssetCandidateLifetime = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
candidateToken = _surfaceAssetCandidateLifetime.Token;
}
var generation = Interlocked.Increment(ref _surfaceAssetGeneration);
if (!_bootstrapped)
{
@ -917,7 +930,8 @@ namespace GameCult.Eve.UnityScene
}
var source = ResolveAssetSource(surface);
if (string.Equals(source.Identity, CurrentAssetSourceIdentity, StringComparison.Ordinal))
if (string.Equals(source.Identity, CurrentAssetSourceIdentity, StringComparison.Ordinal) &&
(source.RequiredCatalogVersion <= 0 || source.RequiredCatalogVersion == CurrentAssetCatalogVersion))
{
_baseSurface = surface;
PublishComposedSurface(surface.Version);
@ -929,7 +943,18 @@ namespace GameCult.Eve.UnityScene
// have resolved and preloaded; never lower a remote Verse surface against
// the previous Verse's catalog merely because the surface record arrived first.
BeginPresentationTransition(surface.Version);
_ = RefreshAssetsForSurfaceAsync(surface, source, generation, _lifetime.Token);
_ = RefreshAssetsForSurfaceAsync(surface, source, generation, candidateToken);
}
private bool TryObserveBaseSurfaceVersion(long version)
{
lock (_presentationFinalityGate)
{
if (version <= _highestObservedBaseSurfaceVersion)
return false;
_highestObservedBaseSurfaceVersion = version;
return true;
}
}
private void PublishEmbeddedSurface(string recordKey, EveSurfaceDocument surface)
@ -1337,12 +1362,22 @@ namespace GameCult.Eve.UnityScene
var providerId = world?.GetProp("assetProviderId") ?? "";
var verseId = world?.GetProp("assetVerseId") ?? "";
var authorityRuntimeId = world?.GetProp("assetAuthorityRuntimeId") ?? "";
var requiredCatalogVersion = long.TryParse(
world?.GetProp("assetCatalogVersion"),
out var parsedCatalogVersion)
? Math.Max(0, parsedCatalogVersion)
: 0;
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>());
return new AssetSource(
_target,
_providerId,
manifestRecordRef,
Array.Empty<string>(),
requiredCatalogVersion);
if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(verseId) ||
string.IsNullOrWhiteSpace(authorityRuntimeId))
throw new InvalidOperationException(
@ -1351,7 +1386,8 @@ namespace GameCult.Eve.UnityScene
new CultMeshSessionTarget(verseId, authorityRuntimeId),
providerId,
manifestRecordRef,
rendezvousEndpoints);
rendezvousEndpoints,
requiredCatalogVersion);
}
private async Task<AssetGeneration> BuildAssetGenerationAsync(
@ -1374,7 +1410,10 @@ namespace GameCult.Eve.UnityScene
: null);
try
{
var catalog = await AttachAssetCatalogSubscriptionAsync(candidate, cancellationToken);
var catalog = await AttachAssetCatalogSubscriptionAsync(
candidate,
source.RequiredCatalogVersion,
cancellationToken);
ConfigureAssetCatalog(candidate, catalog);
ReuseCompatibleBundles(_assetGeneration, candidate);
await PreloadAssetsAsync(candidate, cancellationToken);
@ -1415,8 +1454,10 @@ namespace GameCult.Eve.UnityScene
private async Task<EveAssetCatalogDocument> AttachAssetCatalogSubscriptionAsync(
AssetGeneration candidate,
long requiredCatalogVersion,
CancellationToken cancellationToken)
{
var requiredCatalog = new TaskCompletionSource<EveAssetCatalogDocument>();
var lease = await AssetMeshClient(candidate)
.LeaseDocumentAsync<EveAssetCatalogDocument>(
candidate.Source.Target,
@ -1428,6 +1469,8 @@ namespace GameCult.Eve.UnityScene
watch = lease.Handle.Watch(catalog =>
{
var pending = candidate.ObserveCatalog(catalog);
if (requiredCatalogVersion > 0 && catalog.Version == requiredCatalogVersion)
requiredCatalog.TrySetResult(catalog);
if (pending != null)
QueueAssetCatalogUpdate(candidate.SourceIdentity, pending);
});
@ -1435,7 +1478,10 @@ namespace GameCult.Eve.UnityScene
candidate.CatalogLease = lease;
var catalog = await lease.Handle.LatestAsync();
candidate.ObserveCatalog(catalog);
return catalog;
if (requiredCatalogVersion <= 0 || catalog.Version == requiredCatalogVersion)
return catalog;
using (cancellationToken.Register(() => requiredCatalog.TrySetCanceled()))
return await requiredCatalog.Task;
}
catch
{
@ -1605,7 +1651,13 @@ namespace GameCult.Eve.UnityScene
if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal) ||
update.Catalog.Version == CurrentAssetCatalogVersion)
continue;
var source = _assetGeneration.Source;
var currentSource = _assetGeneration.Source;
var source = new AssetSource(
currentSource.Target,
currentSource.ProviderId,
currentSource.ManifestRecordRef,
currentSource.RendezvousEndpoints,
update.Catalog.Version);
var candidate = await BuildAssetGenerationAsync(source, _lifetime.Token);
if (!string.Equals(update.SourceIdentity, CurrentAssetSourceIdentity, StringComparison.Ordinal))
{
@ -1996,8 +2048,12 @@ namespace GameCult.Eve.UnityScene
!ReceiptMatches(request, receipt))
return;
}
if (terminal && DeferTerminalReceiptUntilPresentation(receipt))
return;
if (terminal)
{
_commandOutbox?.Acknowledge(receipt.CommandId);
if (DeferTerminalReceiptUntilPresentation(receipt))
return;
}
FinalizeReceipt(receipt, terminal);
}
@ -2024,12 +2080,15 @@ namespace GameCult.Eve.UnityScene
private bool DeferTerminalReceiptUntilPresentation(EveCommandReceiptDocument receipt)
{
if (receipt.PresentationSurfaceVersion <= 0)
return false;
lock (_presentationFinalityGate)
{
if (receipt.SourceVersion > _mountedBaseSurfaceVersion)
_presentationBarrierVersion = Math.Max(_presentationBarrierVersion, receipt.SourceVersion);
if (_presentationBarrierVersion <= _mountedBaseSurfaceVersion)
if (receipt.PresentationSurfaceVersion <= _mountedBaseSurfaceVersion)
return false;
_presentationBarrierVersion = Math.Max(
_presentationBarrierVersion,
receipt.PresentationSurfaceVersion);
_deferredTerminalReceipts[receipt.CommandId] = receipt;
return true;
}
@ -2041,14 +2100,13 @@ namespace GameCult.Eve.UnityScene
lock (_presentationFinalityGate)
{
_mountedBaseSurfaceVersion = Math.Max(_mountedBaseSurfaceVersion, committedVersion);
if (_presentationBarrierVersion > committedVersion)
return;
_presentationBarrierVersion = -1;
ready = _deferredTerminalReceipts.Values
.Where(receipt => receipt.SourceVersion <= committedVersion)
.Where(receipt => receipt.PresentationSurfaceVersion <= _mountedBaseSurfaceVersion)
.ToArray();
foreach (var receipt in ready)
_deferredTerminalReceipts.Remove(receipt.CommandId);
if (_presentationBarrierVersion <= _mountedBaseSurfaceVersion)
_presentationBarrierVersion = -1;
}
foreach (var receipt in ready)
FinalizeReceipt(receipt, terminal: true);
@ -2073,7 +2131,6 @@ namespace GameCult.Eve.UnityScene
return;
_pendingCommands.Remove(receipt.CommandId);
}
_commandOutbox?.Acknowledge(receipt.CommandId);
ForgetPendingCommand(receipt.CommandId);
}
CommandReceiptAvailable?.Invoke(new EveUnitySceneCommandReceipt(
@ -2107,11 +2164,22 @@ namespace GameCult.Eve.UnityScene
string providerId,
string manifestRecordRef,
IReadOnlyList<string> rendezvousEndpoints)
: this(target, providerId, manifestRecordRef, rendezvousEndpoints, 0)
{
}
public AssetSource(
CultMeshSessionTarget target,
string providerId,
string manifestRecordRef,
IReadOnlyList<string> rendezvousEndpoints,
long requiredCatalogVersion)
{
Target = target;
ProviderId = providerId ?? "";
ManifestRecordRef = manifestRecordRef ?? "";
RendezvousEndpoints = rendezvousEndpoints ?? Array.Empty<string>();
RequiredCatalogVersion = Math.Max(0, requiredCatalogVersion);
Identity = Target.VerseId + "\u001f" + Target.AuthorityRuntimeId + "\u001f" +
ProviderId + "\u001f" + ManifestRecordRef + "\u001f" +
string.Join("\u001e", RendezvousEndpoints);
@ -2121,6 +2189,7 @@ namespace GameCult.Eve.UnityScene
public string ProviderId { get; }
public string ManifestRecordRef { get; }
public IReadOnlyList<string> RendezvousEndpoints { get; }
public long RequiredCatalogVersion { get; }
public string Identity { get; }
}

View file

@ -181,7 +181,10 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(pending.ContainsKey(request.CommandId), Is.True);
Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "pending" }));
publish.Invoke(transport, new object[] { Receipt(request, "accepted", "receipt-accepted") });
publish.Invoke(transport, new object[]
{
Receipt(request, "accepted", "receipt-accepted", sourceVersion: 1000)
});
Assert.That(pending.ContainsKey(request.CommandId), Is.False);
Assert.That(observed.Select(value => value.State), Is.EqualTo(new[] { "pending", "accepted" }));
@ -212,13 +215,27 @@ namespace GameCult.Eve.UnityScene.Tests
pending.Add(request.CommandId, request);
var observed = new List<EveUnitySceneCommandReceipt>();
transport.CommandReceiptAvailable += observed.Add;
using var outbox = new EveUnityCultMeshCommandOutbox(
(_, _) => Task.CompletedTask,
_ => null,
retryDelay: TimeSpan.FromSeconds(30),
maximumRetryDelay: TimeSpan.FromSeconds(30));
type.GetField("_commandOutbox", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(transport, outbox);
outbox.Enqueue(request);
publish.Invoke(transport, new object[]
{
Receipt(request, "accepted", "receipt-accepted", sourceVersion: 7)
Receipt(
request,
"accepted",
"receipt-accepted",
sourceVersion: 1000,
presentationSurfaceVersion: 7)
});
Assert.That(pending.ContainsKey(request.CommandId), Is.True);
Assert.That(outbox.PendingCount, Is.Zero);
Assert.That(observed, Is.Empty);
type.GetProperty("CurrentSurfaceDocument")!.SetValue(transport, SceneSurface(100));
var blocked = Assert.Throws<InvalidOperationException>(() =>
@ -323,7 +340,8 @@ namespace GameCult.Eve.UnityScene.Tests
EveSurfaceCommandRequest request,
string state,
string receiptId,
long sourceVersion = 0) => new EveCommandReceiptDocument(
long sourceVersion = 0,
long presentationSurfaceVersion = 0) => new EveCommandReceiptDocument(
receiptId,
request.CommandId,
request.Command,
@ -335,7 +353,28 @@ namespace GameCult.Eve.UnityScene.Tests
"",
DateTimeOffset.UtcNow.ToString("O"),
sourceVersion,
invocationHash: EveCommandInvocationHash.Compute(request));
invocationHash: EveCommandInvocationHash.Compute(request),
presentationSurfaceVersion: presentationSurfaceVersion);
[Test]
public void OlderObservedBaseVersionCannotSupersedeANewerCandidate()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-cache",
"cultnet+tcp://127.0.0.1:1",
"test.verse",
"test.runtime",
"test.provider",
"test.surface");
var observe = typeof(EveUnityCultMeshLiveProviderTransport).GetMethod(
"TryObserveBaseSurfaceVersion",
BindingFlags.NonPublic | BindingFlags.Instance)!;
Assert.That(observe.Invoke(transport, new object[] { 10L }), Is.True);
Assert.That(observe.Invoke(transport, new object[] { 7L }), Is.False);
Assert.That(observe.Invoke(transport, new object[] { 10L }), Is.False);
Assert.That(observe.Invoke(transport, new object[] { 11L }), Is.True);
}
private static EveUnitySceneProviderSurfaceDocument SceneSurface(long version)
{

View file

@ -1,13 +1,13 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.130",
"version": "0.3.131",
"unity": "6000.0",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {
"org.gamecult.cultlib": "1.0.56",
"org.gamecult.eve.plugin-fields": "0.2.3",
"org.gamecult.eve.surface": "0.3.5",
"org.gamecult.eve.surface": "0.3.6",
"org.gamecult.eve.unity-uitoolkit": "0.1.9",
"com.unity.mathematics": "1.3.2",
"com.unity.render-pipelines.universal": "17.4.0",