Lower exact hot body state in generic Unity clients

This commit is contained in:
Meta Vens 2026-07-19 05:56:09 +01:00
commit 78f4937670
11 changed files with 204 additions and 59 deletions

View file

@ -68,10 +68,13 @@ namespace GameCult.Eve.UnityScene
private EveAdvertisedSurface? _advertisedSurface;
private CultMeshBodyPublicationResolver? _bodyResolver;
private EveEntitySoaViewDocument? _latestEntityLayout;
private CultMeshBodyPublicationDocument? _latestEntityPublication;
private CultMeshMappedFrameBodyCursor? _mappedEntityFrameCursor;
private CultMeshBodyDescriptor? _mappedEntityFrameContract;
private long _lastQueuedEntityViewEpoch = -1;
private long _lastQueuedEntityViewSequence = -1;
private long _lastPresentedEntityViewEpoch = -1;
private long _lastPresentedEntityViewSequence = -1;
private bool _disposed;
public EveUnityCultMeshLiveProviderTransport(
@ -192,8 +195,8 @@ namespace GameCult.Eve.UnityScene
ThrowIfSubscriptionFailed(_subscriptions);
while (_liveDocuments.TryDequeue(out var document))
{
if (document is EveEntitySoaViewDocument entityView)
PublishEntityView(entityView);
if (document is PendingEntityGeneration entityGeneration)
PublishEntityView(entityGeneration.View, entityGeneration.Publication);
else if (document is EveFieldsSplatsDocument fields)
FieldsSplatsAvailable?.Invoke(fields);
else if (document is EveSurfaceDocument surface)
@ -217,17 +220,14 @@ namespace GameCult.Eve.UnityScene
{
TraceHotState($"layout epoch={document.ProducerEpoch} sequence={document.Sequence} identities={document.Identities?.Length ?? 0}");
_latestEntityLayout = document;
if (document.ProducerEpoch < _lastQueuedEntityViewEpoch ||
(document.ProducerEpoch == _lastQueuedEntityViewEpoch &&
document.Sequence <= _lastQueuedEntityViewSequence))
return;
_lastQueuedEntityViewEpoch = document.ProducerEpoch;
_lastQueuedEntityViewSequence = document.Sequence;
_liveDocuments.Enqueue(document);
var publication = _latestEntityPublication;
if (publication != null && PublicationMatchesLayout(publication, document))
QueueEntityGeneration(document, publication);
}
private void QueueBodyPublication(CultMeshBodyPublicationDocument publication)
{
_latestEntityPublication = publication;
var layout = _latestEntityLayout;
if (layout == null || layout.Buffers == null || layout.Buffers.Length == 0 ||
!string.Equals(layout.Buffers[0].BufferId, publication.BodyId, StringComparison.Ordinal))
@ -238,10 +238,43 @@ namespace GameCult.Eve.UnityScene
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));
var generation = PublicationMatchesLayout(publication, layout)
? layout
: MaterializeEntityGeneration(layout, publication.ProducerEpoch, publication.Sequence);
QueueEntityGeneration(generation, publication);
}
private void QueueEntityGeneration(
EveEntitySoaViewDocument document,
CultMeshBodyPublicationDocument publication)
{
if (document.ProducerEpoch < _lastQueuedEntityViewEpoch ||
(document.ProducerEpoch == _lastQueuedEntityViewEpoch &&
document.Sequence <= _lastQueuedEntityViewSequence))
return;
_lastQueuedEntityViewEpoch = document.ProducerEpoch;
_lastQueuedEntityViewSequence = document.Sequence;
_liveDocuments.Enqueue(new PendingEntityGeneration(document, publication));
}
private static bool PublicationMatchesLayout(
CultMeshBodyPublicationDocument publication,
EveEntitySoaViewDocument layout) =>
publication.ProducerEpoch == layout.ProducerEpoch &&
publication.Sequence == layout.Sequence;
private sealed class PendingEntityGeneration
{
public PendingEntityGeneration(
EveEntitySoaViewDocument view,
CultMeshBodyPublicationDocument publication)
{
View = view;
Publication = publication;
}
public EveEntitySoaViewDocument View { get; }
public CultMeshBodyPublicationDocument Publication { get; }
}
private static EveEntitySoaViewDocument MaterializeEntityGeneration(
@ -530,7 +563,9 @@ namespace GameCult.Eve.UnityScene
return normalized.ToLowerInvariant();
}
private void PublishEntityView(EveEntitySoaViewDocument document)
private void PublishEntityView(
EveEntitySoaViewDocument document,
CultMeshBodyPublicationDocument publication)
{
ResolveAdvertisement();
if (!string.Equals(document.ProviderId, _advertisement!.ProviderId, StringComparison.Ordinal))
@ -539,15 +574,13 @@ namespace GameCult.Eve.UnityScene
if (document.Buffers == null || document.Buffers.Length == 0)
throw new InvalidOperationException("Entity layout does not name a primary logical buffer.");
var handle = BodyPublicationHandle(document);
var publicationKey = CultMeshBodyPublicationDocument.CreateLatestRecordKey(handle.BodyId);
var publication = _node!.Database.Cache.Get<CultMeshBodyPublicationDocument>(publicationKey)
?? throw new InvalidOperationException(
$"Subscribed CultMesh body publication '{publicationKey.Value}' is missing for generation {handle.Sequence}.");
handle.Validate(publication);
var now = DateTimeOffset.UtcNow;
if (!IsPublicationLive(publication, now))
return;
EnsureMappedEntityFrameCursor(publication);
if (_mappedEntityFrameCursor != null)
return;
_bodyResolver ??= CreateBodyResolver();
var lease = _bodyResolver.ResolveReadOnly(publication, new CultMeshBodyValidationRequest
{
@ -569,6 +602,8 @@ namespace GameCult.Eve.UnityScene
try
{
handler(document, lease);
_lastPresentedEntityViewEpoch = document.ProducerEpoch;
_lastPresentedEntityViewSequence = document.Sequence;
}
catch
{
@ -616,16 +651,14 @@ namespace GameCult.Eve.UnityScene
return;
var descriptor = lease.Descriptor;
if (descriptor.ProducerEpoch < _lastQueuedEntityViewEpoch ||
(descriptor.ProducerEpoch == _lastQueuedEntityViewEpoch &&
descriptor.Sequence <= _lastQueuedEntityViewSequence))
if (descriptor.ProducerEpoch < _lastPresentedEntityViewEpoch ||
(descriptor.ProducerEpoch == _lastPresentedEntityViewEpoch &&
descriptor.Sequence <= _lastPresentedEntityViewSequence))
{
lease.Dispose();
return;
}
_lastQueuedEntityViewEpoch = descriptor.ProducerEpoch;
_lastQueuedEntityViewSequence = descriptor.Sequence;
var generation = MaterializeEntityGeneration(
layout,
descriptor.ProducerEpoch,
@ -639,6 +672,8 @@ namespace GameCult.Eve.UnityScene
try
{
handler(generation, lease);
_lastPresentedEntityViewEpoch = descriptor.ProducerEpoch;
_lastPresentedEntityViewSequence = descriptor.Sequence;
}
catch
{
@ -728,33 +763,22 @@ namespace GameCult.Eve.UnityScene
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
var liveRecordKeys = new List<string>(fieldRefs);
var liveSchemaIds = new List<string>();
if (fieldRefs.Length > 0)
liveSchemaIds.Add(EveFieldsSchemas.Splats);
if (!string.IsNullOrWhiteSpace(entityViewPointer))
{
if (string.IsNullOrWhiteSpace(entityBodyId))
throw new InvalidOperationException(
"The playable world advertises an entity-view pointer without its logical body id.");
liveRecordKeys.Insert(0, entityViewPointer);
liveRecordKeys.Insert(1, CultMeshBodyPublicationDocument.CreateLatestRecordKey(entityBodyId).Value);
liveSchemaIds.Insert(0, EveEntitySoaViewDocument.SchemaId);
liveSchemaIds.Insert(1, CultMeshBodyPublicationSchemaVersions.Publication);
}
if (liveRecordKeys.Count > 0)
{
var initial = _entitySubscriptions.SubscribeAsync(
"eve-unity-entity-view",
recordKeys: liveRecordKeys,
schemaIds: liveSchemaIds,
consumerRuntimeId: _runtimeId,
bodyIds: string.IsNullOrWhiteSpace(entityBodyId) ? null : new[] { entityBodyId },
supportedBodyTransports: string.IsNullOrWhiteSpace(entityBodyId) ? null : new[]
{
CultMeshBodyTransportKind.SharedMemory.ToString(),
CultMeshBodyTransportKind.Network.ToString()
})
var initial = CultMesh.SubscribeHotBodyAsync(
_entitySubscriptions,
_bodyResolver!,
new CultMeshHotBodySubscription(
"eve-unity-entity-view",
_runtimeId,
entityViewPointer,
EveEntitySoaViewDocument.SchemaId,
entityBodyId,
fieldRefs,
fieldRefs.Length > 0 ? new[] { EveFieldsSchemas.Splats } : null))
.GetAwaiter().GetResult();
TraceHotState($"initial subscription documents=[{string.Join(",", initial.Select(value => value.GetType().Name))}]");
foreach (var document in initial)
@ -767,6 +791,16 @@ namespace GameCult.Eve.UnityScene
_liveDocuments.Enqueue(fields);
}
}
else if (fieldRefs.Length > 0)
{
var initial = _entitySubscriptions.SubscribeAsync(
"eve-unity-fields",
recordKeys: fieldRefs,
schemaIds: new[] { EveFieldsSchemas.Splats })
.GetAwaiter().GetResult();
foreach (var fields in initial.OfType<EveFieldsSplatsDocument>())
_liveDocuments.Enqueue(fields);
}
_subscriptions.SubscribeAsync(
"eve-unity-surface",
recordKeys: new[] { _advertisedSurface!.RecordRef },

View file

@ -21,6 +21,7 @@ namespace GameCult.Eve.UnityScene
RenderChannel = Get(node, "renderChannel");
ActivationActionId = Get(node, "activationActionId");
Power = NonNegative(node, "power", 0f);
PowerStateSemantic = Get(node, "powerStateSemantic");
ActivationThreshold = NonNegative(node, "activationThreshold", 0.01f);
Radius = NonNegative(node, "radius", 0f);
MaximumDistance = NonNegative(node, "maximumDistance", 0f);
@ -33,6 +34,7 @@ namespace GameCult.Eve.UnityScene
public string RenderChannel { get; }
public string ActivationActionId { get; }
public float Power { get; }
public string PowerStateSemantic { get; }
public float ActivationThreshold { get; }
public float Radius { get; }
public float MaximumDistance { get; }

View file

@ -76,8 +76,13 @@ namespace GameCult.Eve.UnityScene
beam.Root.transform.SetParent(source.Transform, false);
}
var visiblePower = presentation.Power > presentation.ActivationThreshold
? presentation.Power
var power = presentation.Power;
if (!string.IsNullOrWhiteSpace(presentation.PowerStateSemantic))
power = source.Entity.TryGetScalar(presentation.PowerStateSemantic, out var livePower)
? Math.Max(0f, livePower)
: 0f;
var visiblePower = power > presentation.ActivationThreshold
? power
: 0f;
foreach (var particles in beam.Root.GetComponentsInChildren<ParticleSystem>(true))
{

View file

@ -47,6 +47,10 @@ namespace GameCult.Eve.UnityScene
var assetRef = string.IsNullOrWhiteSpace(identity?.AssetRef)
? renderGroup?.MeshAssetRef ?? ""
: identity!.AssetRef;
var scalarState = new Dictionary<string, float>(StringComparer.Ordinal);
foreach (var semantic in view.FloatSemantics)
if (view.TryReadFloat(semantic, row, out var scalar))
scalarState[semantic] = scalar;
var fact = new EveUnityPresentedEntity(
entityIndex,
entityId,
@ -62,7 +66,8 @@ namespace GameCult.Eve.UnityScene
lod,
identity?.Selectable ?? true,
identity?.Controllable ?? false,
assetRef);
assetRef,
scalarState);
presented.Add(fact);
if (_sink is IEveUnityEntityGenerationSink)
continue;

View file

@ -14,6 +14,7 @@ namespace GameCult.Eve.UnityScene
private readonly ICultMeshBodyReadLease _lease;
private readonly Dictionary<string, EveEntitySoaBuffer> _buffers;
private readonly Dictionary<string, EveEntitySoaColumn> _columns;
private readonly string[] _floatSemantics;
private EveUnityEntitySoaView(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease)
{
@ -21,12 +22,18 @@ namespace GameCult.Eve.UnityScene
_lease = lease;
_buffers = document.Buffers.ToDictionary(buffer => buffer.BufferId, StringComparer.Ordinal);
_columns = document.Columns.ToDictionary(column => column.Semantic, StringComparer.Ordinal);
_floatSemantics = document.Columns
.Where(column => string.Equals(column.ScalarType, "float32", StringComparison.Ordinal))
.Select(column => column.Semantic)
.Where(semantic => !string.IsNullOrWhiteSpace(semantic))
.ToArray();
}
public EveEntitySoaViewDocument Document { get; }
public long Generation => Document.Sequence;
public IReadOnlyList<EveEntityRenderGroup> RenderGroups => Document.RenderGroups;
public int EntityCount => Document.Columns.Length == 0 ? 0 : Document.Columns.Min(column => column.ElementCount);
public IReadOnlyList<string> FloatSemantics => _floatSemantics;
public static EveUnityEntitySoaView Open(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease)
{

View file

@ -15,6 +15,7 @@ namespace GameCult.Eve.UnityScene
private readonly EveUnityShotReceiptPresenter _shots = new EveUnityShotReceiptPresenter();
private readonly List<EveUnitySceneCommandReceipt> _pendingReceipts =
new List<EveUnitySceneCommandReceipt>();
private long _activeStateVersion;
private bool _connected;
private bool _receiptConnected;
@ -36,7 +37,14 @@ namespace GameCult.Eve.UnityScene
public EveUnitySceneCommandReceipt? LastReceipt { get; private set; }
public long ActiveVersion => _connection.ActiveVersion;
public long ActiveVersion => Math.Max(_connection.ActiveVersion, _activeStateVersion);
public void AdvanceStateVersion(long version)
{
if (version <= _activeStateVersion) return;
_activeStateVersion = version;
PublishReceiptsWhoseStateIsVisible();
}
public string SourcePointer => _connection.SourcePointer;

View file

@ -230,8 +230,11 @@ namespace GameCult.Eve.UnityScene
_entityViewsConnected = true;
}
private void OnEntityViewAvailable(EveEntitySoaViewDocument document, GameCult.Mesh.ICultMeshBodyReadLease lease) =>
private void OnEntityViewAvailable(EveEntitySoaViewDocument document, GameCult.Mesh.ICultMeshBodyReadLease lease)
{
_client.AdvanceStateVersion(document.FrameId);
_entityPresenter.Apply(document, lease);
}
}
public sealed class EveUnityLivePlayableWorldAssetProvider :

View file

@ -24,7 +24,8 @@ namespace GameCult.Eve.UnityScene
int lod,
bool selectable,
bool controllable,
string assetRef)
string assetRef,
IReadOnlyDictionary<string, float>? scalarState = null)
{
SourceIndex = sourceIndex;
EntityId = entityId ?? "";
@ -41,6 +42,7 @@ namespace GameCult.Eve.UnityScene
Selectable = selectable;
Controllable = controllable;
AssetRef = assetRef ?? "";
ScalarState = scalarState ?? new Dictionary<string, float>(StringComparer.Ordinal);
}
public int SourceIndex { get; }
@ -58,6 +60,10 @@ namespace GameCult.Eve.UnityScene
public bool Selectable { get; }
public bool Controllable { get; }
public string AssetRef { get; }
public IReadOnlyDictionary<string, float> ScalarState { get; }
public bool TryGetScalar(string semantic, out float value) =>
ScalarState.TryGetValue(semantic ?? "", out value);
public EveUnityPlayableWorldEntity ToPlayableWorldEntity() => new EveUnityPlayableWorldEntity(
EntityId,

View file

@ -76,6 +76,44 @@ namespace GameCult.Eve.UnityScene.Tests
}
}
[Test]
public void RendererReadsDaemonPowerFromThePresentedEntityGeneration()
{
var host = new GameObject("beam-state-test-host");
var source = new GameObject("beam-state-test-source");
var prefab = new GameObject("beam-state-test-prefab");
prefab.AddComponent<ParticleSystem>();
var renderer = host.AddComponent<EveUnityBeamPresentationRenderer>();
var registry = new Registry(
"source",
source.transform,
new Dictionary<string, float>(StringComparer.Ordinal) { ["effect.beam.power"] = 0.75f });
try
{
var presentation = new EveUnityBeamPresentation(Node(
"tractor", "beam.presentation", new Dictionary<string, string>(StringComparer.Ordinal)
{
["sourceEntityId"] = "source",
["assetRole"] = "effect.beam.tractor",
["directionMode"] = "source-forward.v1",
["power"] = "0.125",
["powerStateSemantic"] = "effect.beam.power",
["activationThreshold"] = "0.01"
}));
renderer.Apply(new[] { presentation }, registry, new Provider(prefab));
Assert.That(renderer.TryGetPower("tractor", out var power), Is.True);
Assert.That(power, Is.EqualTo(0.75f).Within(0.0001f));
}
finally
{
UnityEngine.Object.DestroyImmediate(host);
UnityEngine.Object.DestroyImmediate(source);
UnityEngine.Object.DestroyImmediate(prefab);
}
}
private static EveUnityBeamPresentation Presentation(float power) => new EveUnityBeamPresentation(Node(
"tractor", "beam.presentation", new Dictionary<string, string>(StringComparer.Ordinal)
{
@ -104,11 +142,14 @@ namespace GameCult.Eve.UnityScene.Tests
private sealed class Registry : IEveUnityPresentedEntityRegistry
{
private readonly EveUnityPresentedEntityHandle _handle;
public Registry(string entityId, Transform transform)
public Registry(
string entityId,
Transform transform,
IReadOnlyDictionary<string, float>? scalarState = null)
{
_handle = new EveUnityPresentedEntityHandle(
new EveUnityPresentedEntity(0, entityId, "ship", "", "", Vector3.zero, 0, Vector3.zero,
1, 1, 0, 0, true, true, ""),
1, 1, 0, 0, true, true, "", scalarState),
transform);
}
public EveUnityPresentedEntityGeneration? CurrentGeneration => null;

View file

@ -73,11 +73,11 @@ namespace GameCult.Eve.UnityScene.Tests
.GetValue(transport)!;
queueView.Invoke(transport, new object[] { view });
Assert.That(queue.TryDequeue(out _), Is.True);
Assert.That(queue, Is.Empty, "a layout without its body publication is not a complete generation");
queuePublication.Invoke(transport, new object[] { publication });
Assert.That(queue.TryDequeue(out var queued), Is.True);
var generation = queued as EveEntitySoaViewDocument;
var generation = queued!.GetType().GetProperty("View")!.GetValue(queued) as EveEntitySoaViewDocument;
Assert.That(generation, Is.Not.Null);
Assert.That(generation!.Sequence, Is.EqualTo(publication.Sequence));
Assert.That(generation.ProducerEpoch, Is.EqualTo(publication.ProducerEpoch));
@ -87,6 +87,34 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(generation.DirtyRanges.All(range => range.Sequence == publication.Sequence), Is.True);
}
[Test]
public void LiveTransportPairsBodyPublicationThatArrivesBeforeItsLayout()
{
using var transport = new EveUnityCultMeshLiveProviderTransport(
"test-replica.cc",
"cultnet://127.0.0.1:3075",
"aetheria",
"aetheria.pilot");
var view = EntityLeaseDocument();
var publication = BodyPublication(view, view.Sequence);
var queueView = typeof(EveUnityCultMeshLiveProviderTransport)
.GetMethod("QueueEntityView", BindingFlags.NonPublic | BindingFlags.Instance)!;
var queuePublication = typeof(EveUnityCultMeshLiveProviderTransport)
.GetMethod("QueueBodyPublication", BindingFlags.NonPublic | BindingFlags.Instance)!;
var queue = (System.Collections.Concurrent.ConcurrentQueue<object>)
typeof(EveUnityCultMeshLiveProviderTransport)
.GetField("_liveDocuments", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(transport)!;
queuePublication.Invoke(transport, new object[] { publication });
Assert.That(queue, Is.Empty, "a publication without its layout is not a complete generation");
queueView.Invoke(transport, new object[] { view });
Assert.That(queue.TryDequeue(out var queued), Is.True);
Assert.That(queued!.GetType().GetProperty("View")!.GetValue(queued), Is.SameAs(view));
Assert.That(queued.GetType().GetProperty("Publication")!.GetValue(queued), Is.SameAs(publication));
}
[Test]
public void LiveTransportReadsLaterMappedFramesWithoutNewControlDocuments()
{
@ -1887,7 +1915,7 @@ namespace GameCult.Eve.UnityScene.Tests
}
[Test]
public void LivePlayableWorldClientWaitsForProviderSurfaceUpdateAfterReceipt()
public void LivePlayableWorldClientReconcilesReceiptAfterReactiveStateVersionAdvances()
{
var source = new FakeProviderSurfaceSource(
"aetheria",
@ -1944,12 +1972,18 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(client.LastReceipt.State, Is.EqualTo("pending"));
Assert.That(client.ActiveVersion, Is.EqualTo(1));
client.AdvanceStateVersion(2);
Assert.That(client.LastReceipt.State, Is.EqualTo("reconciled"));
Assert.That(client.ActiveVersion, Is.EqualTo(2));
Assert.That(sceneSink.ConfiguredWorlds.Count, Is.EqualTo(1),
"reactive state visibility must not require republishing heavy surface topology");
source.Publish(new EveUnitySceneProviderSurfaceSnapshot(
PlayableArpgDocument(includeRaider: false, playerPosition: "30,0,30"),
Advertisement("aetheria.daemon.game"),
"cultmesh://aetheria/eve/surfaces/aetheria.daemon.game",
2));
Assert.That(client.LastReceipt.State, Is.EqualTo("reconciled"));
Assert.That(client.ActiveVersion, Is.EqualTo(2));
Assert.That(client.LastPresentation, Is.Not.Null);
Assert.That(client.LastPresentation!.ActiveEntities, Is.EqualTo(2));

View file

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