Consume mapped entity generations without snapshots

This commit is contained in:
Meta Vens 2026-07-19 04:39:33 +01:00
commit bedcee3279
3 changed files with 191 additions and 8 deletions

View file

@ -68,6 +68,8 @@ namespace GameCult.Eve.UnityScene
private EveAdvertisedSurface? _advertisedSurface;
private CultMeshBodyPublicationResolver? _bodyResolver;
private EveEntitySoaViewDocument? _latestEntityLayout;
private CultMeshMappedFrameBodyCursor? _mappedEntityFrameCursor;
private CultMeshBodyDescriptor? _mappedEntityFrameContract;
private long _lastQueuedEntityViewEpoch = -1;
private long _lastQueuedEntityViewSequence = -1;
private bool _disposed;
@ -186,6 +188,8 @@ namespace GameCult.Eve.UnityScene
public void PumpLiveEvents()
{
ThrowIfSubscriptionFailed(_entitySubscriptions);
ThrowIfSubscriptionFailed(_subscriptions);
while (_liveDocuments.TryDequeue(out var document))
{
if (document is EveEntitySoaViewDocument entityView)
@ -197,10 +201,21 @@ namespace GameCult.Eve.UnityScene
else if (document is EveCommandReceiptDocument receipt)
PublishReceipt(receipt);
}
PumpMappedEntityFrame();
}
private static void ThrowIfSubscriptionFailed(CultNetDatabaseSubscriptionClient? subscription)
{
var failure = subscription?.BackgroundFailure;
if (failure?.IsCompletedSuccessfully == true)
throw new InvalidOperationException(
"The retained CultMesh state subscription failed.",
failure.Result);
}
private void QueueEntityView(EveEntitySoaViewDocument document)
{
TraceHotState($"layout epoch={document.ProducerEpoch} sequence={document.Sequence} identities={document.Identities?.Length ?? 0}");
_latestEntityLayout = document;
if (document.ProducerEpoch < _lastQueuedEntityViewEpoch ||
(document.ProducerEpoch == _lastQueuedEntityViewEpoch &&
@ -216,9 +231,25 @@ namespace GameCult.Eve.UnityScene
var layout = _latestEntityLayout;
if (layout == null || layout.Buffers == null || layout.Buffers.Length == 0 ||
!string.Equals(layout.Buffers[0].BufferId, publication.BodyId, StringComparison.Ordinal))
{
TraceHotState($"publication skipped body={publication.BodyId} sequence={publication.Sequence} layout={(layout == null ? "missing" : "incompatible")}");
return;
}
QueueEntityView(new EveEntitySoaViewDocument
TraceHotState($"publication body={publication.BodyId} epoch={publication.ProducerEpoch} sequence={publication.Sequence} plane={string.Join(",", publication.Representations.Select(value => value.TransportKind))}");
QueueEntityView(MaterializeEntityGeneration(
layout,
publication.ProducerEpoch,
publication.Sequence));
}
private static EveEntitySoaViewDocument MaterializeEntityGeneration(
EveEntitySoaViewDocument layout,
long producerEpoch,
long sequence)
{
return new EveEntitySoaViewDocument
{
Schema = layout.Schema,
ProviderId = layout.ProviderId,
@ -226,8 +257,8 @@ namespace GameCult.Eve.UnityScene
PublishedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
BodySchemaId = layout.BodySchemaId,
LayoutVersion = layout.LayoutVersion,
ProducerEpoch = publication.ProducerEpoch,
Sequence = publication.Sequence,
ProducerEpoch = producerEpoch,
Sequence = sequence,
Capacity = layout.Capacity,
Buffers = layout.Buffers,
Columns = layout.Columns,
@ -237,13 +268,13 @@ namespace GameCult.Eve.UnityScene
ColumnId = range.ColumnId,
StartIndex = range.StartIndex,
Count = range.Count,
Sequence = publication.Sequence
Sequence = sequence
})
.ToArray(),
RenderGroups = layout.RenderGroups,
Identities = layout.Identities,
FrameId = publication.Sequence
});
FrameId = sequence
};
}
public void SubmitCommand(EveSurfaceCommandRequest request)
@ -291,6 +322,9 @@ namespace GameCult.Eve.UnityScene
_prefabs.Clear();
_nativeAssets.Clear();
_nativeAssetMetadata.Clear();
_mappedEntityFrameCursor?.Dispose();
_mappedEntityFrameCursor = null;
_mappedEntityFrameContract = null;
_subscriptions?.Dispose();
_entitySubscriptions?.Dispose();
_node?.Dispose();
@ -513,6 +547,7 @@ namespace GameCult.Eve.UnityScene
var now = DateTimeOffset.UtcNow;
if (!IsPublicationLive(publication, now))
return;
EnsureMappedEntityFrameCursor(publication);
_bodyResolver ??= CreateBodyResolver();
var lease = _bodyResolver.ResolveReadOnly(publication, new CultMeshBodyValidationRequest
{
@ -542,6 +577,82 @@ namespace GameCult.Eve.UnityScene
}
}
private void EnsureMappedEntityFrameCursor(CultMeshBodyPublicationDocument publication)
{
var descriptor = (publication.Representations ?? Array.Empty<CultMeshBodyDescriptor>())
.FirstOrDefault(CultMeshMappedFrameBodyCursor.CanOpen);
if (descriptor == null)
{
_mappedEntityFrameCursor?.Dispose();
_mappedEntityFrameCursor = null;
_mappedEntityFrameContract = null;
return;
}
var current = _mappedEntityFrameContract;
if (current != null &&
string.Equals(FrameCapabilityIdentity(current.CapabilityToken),
FrameCapabilityIdentity(descriptor.CapabilityToken), StringComparison.Ordinal) &&
string.Equals(current.BodyId, descriptor.BodyId, StringComparison.Ordinal) &&
string.Equals(current.SchemaId, descriptor.SchemaId, StringComparison.Ordinal) &&
current.LayoutVersion == descriptor.LayoutVersion &&
current.Capacity == descriptor.Capacity &&
current.ProducerEpoch == descriptor.ProducerEpoch &&
current.ByteSize == descriptor.ByteSize)
return;
_mappedEntityFrameCursor?.Dispose();
_mappedEntityFrameCursor = new CultMeshMappedFrameBodyCursor(descriptor);
_mappedEntityFrameContract = descriptor;
TraceHotState(
$"mapped frame cursor body={descriptor.BodyId} epoch={descriptor.ProducerEpoch} sequence={descriptor.Sequence}");
}
private void PumpMappedEntityFrame()
{
var cursor = _mappedEntityFrameCursor;
var layout = _latestEntityLayout;
if (cursor == null || layout == null || !cursor.TryAcquireLatest(out var lease))
return;
var descriptor = lease.Descriptor;
if (descriptor.ProducerEpoch < _lastQueuedEntityViewEpoch ||
(descriptor.ProducerEpoch == _lastQueuedEntityViewEpoch &&
descriptor.Sequence <= _lastQueuedEntityViewSequence))
{
lease.Dispose();
return;
}
_lastQueuedEntityViewEpoch = descriptor.ProducerEpoch;
_lastQueuedEntityViewSequence = descriptor.Sequence;
var generation = MaterializeEntityGeneration(
layout,
descriptor.ProducerEpoch,
descriptor.Sequence);
var handler = EntityViewAvailable;
if (handler == null)
{
lease.Dispose();
return;
}
try
{
handler(generation, lease);
}
catch
{
lease.Dispose();
throw;
}
}
private static string FrameCapabilityIdentity(string capabilityToken)
{
var separator = capabilityToken.LastIndexOf('.');
return separator > 0 ? capabilityToken.Substring(0, separator) : capabilityToken;
}
private static bool IsPublicationLive(
CultMeshBodyPublicationDocument publication,
DateTimeOffset now) =>
@ -645,6 +756,7 @@ namespace GameCult.Eve.UnityScene
CultMeshBodyTransportKind.Network.ToString()
})
.GetAwaiter().GetResult();
TraceHotState($"initial subscription documents=[{string.Join(",", initial.Select(value => value.GetType().Name))}]");
foreach (var document in initial)
{
if (document is EveEntitySoaViewDocument current)
@ -686,6 +798,7 @@ namespace GameCult.Eve.UnityScene
private void OnReplicatedDocumentChanged(CultNetReplicatedDocumentChange change)
{
TraceHotState($"change subscription={change.SubscriptionId} kind={change.ChangeKind} document={change.Document?.GetType().Name ?? "null"}");
if (change.Document is EveEntitySoaViewDocument entityView)
QueueEntityView(entityView);
else if (change.Document is CultMeshBodyPublicationDocument publication)
@ -694,6 +807,12 @@ namespace GameCult.Eve.UnityScene
_liveDocuments.Enqueue(change.Document);
}
private static void TraceHotState(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("AETHERIA_TRACE_CLIENT_RUDP"), "1", StringComparison.Ordinal))
Debug.Log($"EveUnity CultMesh hot state: {message}");
}
private void RefreshAssetCatalog()
{
var interaction = RequireWorldInteraction();

View file

@ -87,6 +87,70 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(generation.DirtyRanges.All(range => range.Sequence == publication.Sequence), Is.True);
}
[Test]
public void LiveTransportReadsLaterMappedFramesWithoutNewControlDocuments()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-replica.cc",
"cultnet://127.0.0.1:3075",
"provider",
"provider.pilot");
var view = EntityLeaseDocument();
view.Sequence = 0;
using var publisher = new CultMeshFrameBodyPublisher(
view.Buffers[0].BufferId,
view.BodySchemaId,
view.LayoutVersion,
view.Capacity,
view.ProducerEpoch,
slotByteLength: 4);
publisher.TryPublish(new byte[] { 1, 2, 3, 4 }, DateTimeOffset.UtcNow, out var bootstrap);
var publication = new CultMeshBodyPublicationDocument
{
BodyId = bootstrap.BodyId,
ProducerId = view.ProviderId,
SchemaId = bootstrap.SchemaId,
LayoutVersion = bootstrap.LayoutVersion,
ByteSize = bootstrap.ByteSize,
Capacity = bootstrap.Capacity,
ProducerEpoch = bootstrap.ProducerEpoch,
Sequence = bootstrap.Sequence,
Synchronization = bootstrap.Synchronization,
LivenessExpiresAtUnixMs = bootstrap.LeaseExpiresAtUnixMs,
Representations = new[] { bootstrap }
};
var queueView = typeof(EveUnityCultMeshLiveProviderTransport)
.GetMethod("QueueEntityView", BindingFlags.NonPublic | BindingFlags.Instance)!;
var ensureCursor = typeof(EveUnityCultMeshLiveProviderTransport)
.GetMethod("EnsureMappedEntityFrameCursor", BindingFlags.NonPublic | BindingFlags.Instance)!;
var pumpCursor = typeof(EveUnityCultMeshLiveProviderTransport)
.GetMethod("PumpMappedEntityFrame", BindingFlags.NonPublic | BindingFlags.Instance)!;
var queue = (System.Collections.Concurrent.ConcurrentQueue<object>)
typeof(EveUnityCultMeshLiveProviderTransport)
.GetField("_liveDocuments", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(transport)!;
queueView.Invoke(transport, new object[] { view });
queue.TryDequeue(out _);
ensureCursor.Invoke(transport, new object[] { publication });
publisher.TryPublish(new byte[] { 9, 8, 7, 6 }, DateTimeOffset.UtcNow, out _);
EveEntitySoaViewDocument? presented = null;
byte presentedByte = 0;
transport.EntityViewAvailable += (generation, lease) =>
{
presented = generation;
presentedByte = lease.ReadByte(0);
lease.Dispose();
};
pumpCursor.Invoke(transport, Array.Empty<object>());
Assert.That(presented, Is.Not.Null);
Assert.That(presented!.Sequence, Is.EqualTo(1));
Assert.That(presentedByte, Is.EqualTo(9));
Assert.That(queue, Is.Empty, "mapped frames must not be represented as queued CultNet documents");
}
[Test]
public void LiveTransportDropsExpiredHistoricalGenerationWithoutReinterpretingLatest()
{

View file

@ -1,11 +1,11 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.76",
"version": "0.3.77",
"unity": "6000.0",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {
"org.gamecult.cultlib": "1.0.21",
"org.gamecult.cultlib": "1.0.22",
"org.gamecult.eve.plugin-fields": "0.2.3",
"org.gamecult.eve.surface": "0.2.4",
"com.unity.mathematics": "1.3.2",