Expose immutable presented entity generations

This commit is contained in:
Meta Vens 2026-07-13 21:18:10 +01:00
commit 890692e30f
10 changed files with 420 additions and 33 deletions

View file

@ -15,6 +15,12 @@ selection filters; they are not product knowledge required by the client.
The package never imports provider product assemblies.
`IEveUnityPresentedEntityRegistry` exposes each committed SoA generation as
immutable entity facts plus presentation transforms, addressable by Eve entity
id or source index. Camera, selection, and provider UI adapters consume that
registry instead of reconstructing provider gameplay objects. It owns
presentation lookup only; simulation state and commands remain provider-owned.
Retained provider feedback is lowered without becoming Unity authority.
`EveUnityFeedbackPresenter` emits each new `feedback.event` identity once, and
`EveUnityShotReceiptPresenter` does the same for `shot.receipt` trajectories.

View file

@ -27,6 +27,7 @@ namespace GameCult.Eve.UnityScene
public void Apply(EveEntitySoaViewDocument document, ICultMeshBodyReadLease lease)
{
using var view = EveUnityEntitySoaView.Open(document, lease);
var presented = new List<EveUnityPresentedEntity>();
var next = new HashSet<string>(StringComparer.Ordinal);
for (var row = 0; row < view.EntityCount; row++)
{
@ -34,7 +35,10 @@ namespace GameCult.Eve.UnityScene
if (view.TryReadByte("render.visibility", row, out var visible) && visible == 0) continue;
if (!view.TryReadVector3("transform.position", row, out var position)) continue;
view.TryReadFloat("transform.rotation.radians", row, out var rotationRadians);
view.TryReadVector3("transform.velocity", row, out var velocity);
view.TryReadFloat("physics.body.radius", row, out var radius);
view.TryReadFloat("render.scale", row, out var scale);
view.TryReadInt32("render.lod", row, out var lod);
view.TryReadUInt32("render.group.id", row, out var groupId);
var renderGroup = view.RenderGroups.FirstOrDefault(group => group.GroupId == groupId);
var identity = document.Identities.FirstOrDefault(item => item.Index == entityIndex);
@ -43,34 +47,43 @@ namespace GameCult.Eve.UnityScene
var assetRef = string.IsNullOrWhiteSpace(identity?.AssetRef)
? renderGroup?.MeshAssetRef ?? ""
: identity!.AssetRef;
var fact = new EveUnityPresentedEntity(
entityIndex,
entityId,
identity?.EntityKind ?? "entity",
string.IsNullOrWhiteSpace(identity?.Label) ? entityId : identity!.Label,
identity?.Faction ?? "",
position,
rotationRadians * 57.2957795f,
velocity,
radius,
scale > 0f ? scale : renderGroup?.DefaultScale ?? 1f,
groupId,
lod,
identity?.Selectable ?? true,
identity?.Controllable ?? false,
assetRef);
presented.Add(fact);
if (_sink is IEveUnityEntityGenerationSink)
continue;
_sink.UpsertEntity(
new EveUnityPlayableWorldEntity(
entityId,
entityId,
identity?.EntityKind ?? "entity",
string.IsNullOrWhiteSpace(identity?.Label) ? entityId : identity!.Label,
identity?.Faction ?? "",
assetRef,
position.x,
position.y,
position.z,
rotationRadians * 57.2957795f,
scale > 0f ? scale : renderGroup?.DefaultScale ?? 1f,
selectable: identity?.Selectable ?? true,
controllable: identity?.Controllable ?? false,
focusCommand: "",
moveCommand: "",
targetCommand: "",
actionCommand: "",
props: new Dictionary<string, string>()),
fact.ToPlayableWorldEntity(),
new EveUnityPlayableWorldAssetBinding(
assetRef,
"entity",
string.IsNullOrWhiteSpace(assetRef) ? "unity-generated-placeholder" : "provider-asset-ref"));
}
foreach (var entityId in _known.Where(entityId => !next.Contains(entityId)).ToArray())
_sink.RemoveEntity(entityId);
if (_sink is IEveUnityEntityGenerationSink generationSink)
{
generationSink.ApplyGeneration(new EveUnityPresentedEntityGeneration(
document.ViewId, document.ProducerEpoch, document.Sequence, presented));
}
else
{
foreach (var entityId in _known.Where(entityId => !next.Contains(entityId)).ToArray())
_sink.RemoveEntity(entityId);
}
_known.Clear();
foreach (var entityId in next) _known.Add(entityId);
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
#nullable enable
@ -16,11 +17,18 @@ namespace GameCult.Eve.UnityScene
UnityEngine.Object? ResolveAsset(EveUnityPlayableWorldAssetBinding asset, Type assetType);
}
public sealed class EveUnityGameObjectPlayableWorldSceneSink : IEveUnityPlayableWorldSceneSink
public sealed class EveUnityGameObjectPlayableWorldSceneSink :
IEveUnityPlayableWorldSceneSink,
IEveUnityEntityGenerationSink,
IEveUnityPresentedEntityRegistry
{
private readonly Transform _root;
private readonly IEveUnityGameObjectAssetProvider _assetProvider;
private readonly Dictionary<string, GameObject> _instances = new Dictionary<string, GameObject>(StringComparer.Ordinal);
private IReadOnlyDictionary<string, EveUnityPresentedEntityHandle> _presentedById =
new Dictionary<string, EveUnityPresentedEntityHandle>(StringComparer.Ordinal);
private IReadOnlyDictionary<int, EveUnityPresentedEntityHandle> _presentedByIndex =
new Dictionary<int, EveUnityPresentedEntityHandle>();
public EveUnityGameObjectPlayableWorldSceneSink(
Transform root,
@ -31,6 +39,13 @@ namespace GameCult.Eve.UnityScene
}
public int ActiveEntityCount => _instances.Count;
public EveUnityPresentedEntityGeneration? CurrentGeneration { get; private set; }
public bool TryGetByEntityId(string entityId, out EveUnityPresentedEntityHandle handle) =>
_presentedById.TryGetValue(entityId ?? "", out handle!);
public bool TryGetBySourceIndex(int sourceIndex, out EveUnityPresentedEntityHandle handle) =>
_presentedByIndex.TryGetValue(sourceIndex, out handle!);
public void ConfigureWorld(EveUnityPlayableWorldProjection world)
{
@ -66,6 +81,90 @@ namespace GameCult.Eve.UnityScene
ApplyEntity(instance, entity, asset);
}
public void ApplyGeneration(EveUnityPresentedEntityGeneration generation)
{
if (generation == null) throw new ArgumentNullException(nameof(generation));
if (generation.Entities.Any(entity => string.IsNullOrWhiteSpace(entity.EntityId)))
throw new InvalidOperationException("Presented entity generations require stable entity ids.");
var factsById = generation.Entities.ToDictionary(entity => entity.EntityId, StringComparer.Ordinal);
var factsByIndex = generation.Entities.ToDictionary(entity => entity.SourceIndex);
GameObject? staging = null;
var created = new Dictionary<string, GameObject>(StringComparer.Ordinal);
var nextInstances = new Dictionary<string, GameObject>(StringComparer.Ordinal);
try
{
foreach (var fact in generation.Entities)
{
if (_instances.TryGetValue(fact.EntityId, out var existing) && existing != null)
{
nextInstances.Add(fact.EntityId, existing);
continue;
}
staging ??= new GameObject($"{_root.name}.generation.{generation.ProducerEpoch}.{generation.Sequence}");
if (staging.transform.parent == null)
{
staging.SetActive(false);
staging.transform.SetParent(_root, false);
}
var entity = fact.ToPlayableWorldEntity();
var asset = new EveUnityPlayableWorldAssetBinding(
fact.AssetRef,
fact.EntityKind,
string.IsNullOrWhiteSpace(fact.AssetRef) ? "unity-generated-placeholder" : "provider-asset-ref");
var instance = InstantiateEntity(entity, asset, staging.transform);
created.Add(fact.EntityId, instance);
nextInstances.Add(fact.EntityId, instance);
}
foreach (var fact in generation.Entities)
{
var entity = fact.ToPlayableWorldEntity();
var asset = new EveUnityPlayableWorldAssetBinding(
fact.AssetRef,
fact.EntityKind,
string.IsNullOrWhiteSpace(fact.AssetRef) ? "unity-generated-placeholder" : "provider-asset-ref");
ApplyEntity(nextInstances[fact.EntityId], entity, asset);
}
var nextById = factsById.ToDictionary(
pair => pair.Key,
pair => new EveUnityPresentedEntityHandle(pair.Value, nextInstances[pair.Key].transform),
StringComparer.Ordinal);
var nextByIndex = factsByIndex.ToDictionary(
pair => pair.Key,
pair => nextById[pair.Value.EntityId]);
foreach (var pair in _instances)
if (!nextInstances.ContainsKey(pair.Key) && pair.Value != null)
DestroyInstance(pair.Value);
_instances.Clear();
foreach (var pair in nextInstances)
{
if (created.ContainsKey(pair.Key))
{
pair.Value.transform.SetParent(_root, true);
pair.Value.SetActive(true);
}
_instances.Add(pair.Key, pair.Value);
}
_presentedById = nextById;
_presentedByIndex = nextByIndex;
CurrentGeneration = generation;
}
catch
{
foreach (var instance in created.Values)
if (instance != null) DestroyInstance(instance);
throw;
}
finally
{
if (staging != null)
DestroyInstance(staging);
}
}
public void RemoveEntity(string entityId)
{
if (string.IsNullOrWhiteSpace(entityId))
@ -80,14 +179,18 @@ namespace GameCult.Eve.UnityScene
DestroyInstance(instance);
}
private GameObject InstantiateEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset)
private GameObject InstantiateEntity(
EveUnityPlayableWorldEntity entity,
EveUnityPlayableWorldAssetBinding asset,
Transform? parent = null)
{
parent ??= _root;
var prefab = _assetProvider.ResolvePrefab(asset);
GameObject instance;
if (prefab != null)
{
instance = new GameObject(entity.EntityId);
instance.transform.SetParent(_root, false);
instance.transform.SetParent(parent, false);
var visual = UnityEngine.Object.Instantiate(prefab, instance.transform);
visual.transform.localPosition = Vector3.zero;
visual.transform.localRotation = Quaternion.identity;
@ -97,8 +200,8 @@ namespace GameCult.Eve.UnityScene
instance = GameObject.CreatePrimitive(PrimitiveType.Capsule);
}
if (instance.transform.parent != _root)
instance.transform.SetParent(_root, false);
if (instance.transform.parent != parent)
instance.transform.SetParent(parent, false);
return instance;
}

View file

@ -53,7 +53,7 @@ namespace GameCult.Eve.UnityScene
}
var camera = cameraTransform != null ? cameraTransform : transform;
var player = FindPlayerMarker(resolvedHost, activeWorld.PlayerEntityId);
var player = FindPlayer(resolvedHost, activeWorld.PlayerEntityId);
if (player == null)
return false;
@ -65,7 +65,7 @@ namespace GameCult.Eve.UnityScene
cameraComponent.cullingMask = cullingMask;
}
var bounds = CalculateVisualBounds(player);
var target = bounds?.center ?? player.transform.position;
var target = bounds?.center ?? player.position;
var radius = bounds?.extents.magnitude ?? 0f;
var verticalFov = cameraComponent != null ? cameraComponent.fieldOfView : 60f;
var framingDistance = radius > 0f
@ -84,7 +84,7 @@ namespace GameCult.Eve.UnityScene
return true;
}
private static Bounds? CalculateVisualBounds(EveUnityPlayableWorldEntityMarker player)
private static Bounds? CalculateVisualBounds(Transform player)
{
var renderers = player.GetComponentsInChildren<Renderer>(includeInactive: false);
Bounds? bounds = null;
@ -121,18 +121,23 @@ namespace GameCult.Eve.UnityScene
return host;
}
private static EveUnityPlayableWorldEntityMarker? FindPlayerMarker(
private static Transform? FindPlayer(
EveUnityPlayableWorldClientHost host,
string playerEntityId)
{
if (string.IsNullOrWhiteSpace(playerEntityId))
return null;
if (host.PresentedEntities?.CurrentGeneration != null)
return host.PresentedEntities.TryGetByEntityId(playerEntityId, out var presented)
? presented.Transform
: null;
var markers = host.SceneRoot.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>();
foreach (var marker in markers)
{
if (marker != null && marker.EntityId == playerEntityId)
return marker;
return marker.transform;
}
return null;

View file

@ -31,6 +31,7 @@ namespace GameCult.Eve.UnityScene
public EveUnityPlayableWorldPresentation? LastPresentation => Runtime?.LastPresentation;
public EveUnitySceneCommandReceipt? LastReceipt => Runtime?.LastReceipt;
public IEveUnityPresentedEntityRegistry? PresentedEntities => Runtime?.PresentedEntities;
public long ActiveVersion => Runtime?.ActiveVersion ?? 0;

View file

@ -14,6 +14,7 @@ namespace GameCult.Eve.UnityScene
private readonly EveUnityPlayableWorldAssetManifestDocumentSource? _assetManifestSource;
private readonly IEveUnityEntitySoaViewDocumentSource? _entityViews;
private readonly EveUnityEntitySoaPresenter _entityPresenter;
private readonly IEveUnityPresentedEntityRegistry? _presentedEntities;
private bool _entityViewsConnected;
private bool _assetManifestConnected;
@ -33,6 +34,7 @@ namespace GameCult.Eve.UnityScene
AssetManifests = assetManifests ?? new EveUnityPlayableWorldAssetManifestCache();
_surfaceSource = new EveUnitySceneProviderSurfaceDocumentSource(surfaceDocuments);
_entityViews = surfaceDocuments as IEveUnityEntitySoaViewDocumentSource;
_presentedEntities = sceneSink as IEveUnityPresentedEntityRegistry;
_entityPresenter = new EveUnityEntitySoaPresenter(sceneSink);
_connection = new EveUnitySceneProviderConnection(_surfaceSource, commandSink);
_client = new EveUnityPlayableWorldLiveClient(
@ -47,6 +49,7 @@ namespace GameCult.Eve.UnityScene
public EveUnityPlayableWorldAssetManifestCache AssetManifests { get; }
public IEveUnityGameObjectAssetProvider? GameObjectAssetProvider { get; private set; }
public IEveUnityPresentedEntityRegistry? PresentedEntities => _presentedEntities;
public EveUnityPlayableWorldProjection? ActiveWorld => _client.ActiveWorld;

View file

@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityPresentedEntity
{
public EveUnityPresentedEntity(
int sourceIndex,
string entityId,
string entityKind,
string label,
string faction,
Vector3 position,
float rotationYDegrees,
Vector3 velocity,
float radius,
float scale,
uint renderGroupId,
int lod,
bool selectable,
bool controllable,
string assetRef)
{
SourceIndex = sourceIndex;
EntityId = entityId ?? "";
EntityKind = entityKind ?? "";
Label = label ?? "";
Faction = faction ?? "";
Position = position;
RotationYDegrees = rotationYDegrees;
Velocity = velocity;
Radius = radius;
Scale = scale;
RenderGroupId = renderGroupId;
Lod = lod;
Selectable = selectable;
Controllable = controllable;
AssetRef = assetRef ?? "";
}
public int SourceIndex { get; }
public string EntityId { get; }
public string EntityKind { get; }
public string Label { get; }
public string Faction { get; }
public Vector3 Position { get; }
public float RotationYDegrees { get; }
public Vector3 Velocity { get; }
public float Radius { get; }
public float Scale { get; }
public uint RenderGroupId { get; }
public int Lod { get; }
public bool Selectable { get; }
public bool Controllable { get; }
public string AssetRef { get; }
public EveUnityPlayableWorldEntity ToPlayableWorldEntity() => new EveUnityPlayableWorldEntity(
EntityId,
EntityId,
EntityKind,
Label,
Faction,
AssetRef,
Position.x,
Position.y,
Position.z,
RotationYDegrees,
Scale > 0f ? Scale : Radius,
Selectable,
Controllable,
"", "", "", "");
}
public sealed class EveUnityPresentedEntityGeneration
{
public EveUnityPresentedEntityGeneration(
string viewId,
long producerEpoch,
long sequence,
IEnumerable<EveUnityPresentedEntity> entities)
{
ViewId = viewId ?? "";
ProducerEpoch = producerEpoch;
Sequence = sequence;
Entities = (entities ?? throw new ArgumentNullException(nameof(entities))).ToArray();
}
public string ViewId { get; }
public long ProducerEpoch { get; }
public long Sequence { get; }
public IReadOnlyList<EveUnityPresentedEntity> Entities { get; }
}
public sealed class EveUnityPresentedEntityHandle
{
public EveUnityPresentedEntityHandle(EveUnityPresentedEntity entity, Transform transform)
{
Entity = entity ?? throw new ArgumentNullException(nameof(entity));
Transform = transform != null ? transform : throw new ArgumentNullException(nameof(transform));
}
public EveUnityPresentedEntity Entity { get; }
public Transform Transform { get; }
}
public interface IEveUnityPresentedEntityRegistry
{
EveUnityPresentedEntityGeneration? CurrentGeneration { get; }
bool TryGetByEntityId(string entityId, out EveUnityPresentedEntityHandle handle);
bool TryGetBySourceIndex(int sourceIndex, out EveUnityPresentedEntityHandle handle);
}
public interface IEveUnityEntityGenerationSink
{
void ApplyGeneration(EveUnityPresentedEntityGeneration generation);
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3f7e67e0c24449bab80f2dd15cf2e118

View file

@ -171,6 +171,102 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(entity.AssetRef, Is.EqualTo("cultmesh://assets/pilot"));
}
[Test]
public void EntitySoaPresenterPublishesImmutableGenerationFactsAndRegistryLookups()
{
var root = new GameObject("presented-entities");
try
{
var bytes = new byte[48];
Buffer.BlockCopy(new[] { 12 }, 0, bytes, 0, 4);
Buffer.BlockCopy(new[] { 1f, 2f, 3f }, 0, bytes, 4, 12);
Buffer.BlockCopy(new[] { 0.5f }, 0, bytes, 16, 4);
Buffer.BlockCopy(new[] { 4f, 5f, 6f }, 0, bytes, 20, 12);
Buffer.BlockCopy(new[] { 7f }, 0, bytes, 32, 4);
Buffer.BlockCopy(new[] { 1.5f }, 0, bytes, 36, 4);
Buffer.BlockCopy(new[] { 9 }, 0, bytes, 40, 4);
Buffer.BlockCopy(new[] { 2 }, 0, bytes, 44, 4);
var document = EntityLeaseDocument();
document.Buffers[0].ByteLength = bytes.Length;
document.Columns = new[]
{
SoaColumn("entity.index", "int32", 0, 4),
SoaColumn("transform.position", "float3", 4, 12),
SoaColumn("transform.rotation.radians", "float32", 16, 4),
SoaColumn("transform.velocity", "float3", 20, 12),
SoaColumn("physics.body.radius", "float32", 32, 4),
SoaColumn("render.scale", "float32", 36, 4),
SoaColumn("render.group.id", "uint32", 40, 4),
SoaColumn("render.lod", "int32", 44, 4)
};
var sink = new EveUnityGameObjectPlayableWorldSceneSink(
root.transform, new FixedGameObjectAssetProvider(null));
new EveUnityEntitySoaPresenter(sink).Apply(document, new FakeBodyReadLease(document, bytes));
Assert.That(sink.CurrentGeneration, Is.Not.Null);
Assert.That(sink.CurrentGeneration!.ProducerEpoch, Is.EqualTo(document.ProducerEpoch));
Assert.That(sink.CurrentGeneration.Sequence, Is.EqualTo(document.Sequence));
Assert.That(sink.TryGetByEntityId("entity:pilot", out var byId), Is.True);
Assert.That(sink.TryGetBySourceIndex(12, out var byIndex), Is.True);
Assert.That(byIndex, Is.SameAs(byId));
Assert.That(byId.Entity.Position, Is.EqualTo(new Vector3(1f, 2f, 3f)));
Assert.That(byId.Entity.Velocity, Is.EqualTo(new Vector3(4f, 5f, 6f)));
Assert.That(byId.Entity.Radius, Is.EqualTo(7f));
Assert.That(byId.Entity.Scale, Is.EqualTo(1.5f));
Assert.That(byId.Entity.RenderGroupId, Is.EqualTo(9));
Assert.That(byId.Entity.Lod, Is.EqualTo(2));
Assert.That(byId.Entity.Selectable, Is.True);
Assert.That(byId.Entity.Controllable, Is.True);
Assert.That(byId.Entity.AssetRef, Is.EqualTo("cultmesh://assets/pilot"));
}
finally
{
UnityEngine.Object.DestroyImmediate(root);
}
}
[Test]
public void GameObjectGenerationRegistrySwapsOnlyAfterCompleteApplication()
{
var root = new GameObject("atomic-generation");
EveUnityGameObjectPlayableWorldSceneSink? sink = null;
long observedSequenceDuringApply = -1;
try
{
var provider = new ObservingGameObjectAssetProvider(() =>
observedSequenceDuringApply = sink?.CurrentGeneration?.Sequence ?? -1);
sink = new EveUnityGameObjectPlayableWorldSceneSink(root.transform, provider);
sink.ApplyGeneration(new EveUnityPresentedEntityGeneration(
"entities", 3, 10,
new[] { PresentedEntity(1, "first", "asset:first") }));
Assert.That(sink.TryGetByEntityId("first", out var firstGeneration), Is.True);
var firstTransform = firstGeneration.Transform;
observedSequenceDuringApply = -1;
sink.ApplyGeneration(new EveUnityPresentedEntityGeneration(
"entities", 3, 11,
new[]
{
PresentedEntity(1, "first", "asset:first"),
PresentedEntity(2, "second", "asset:second")
}));
Assert.That(observedSequenceDuringApply, Is.EqualTo(10));
Assert.That(sink.CurrentGeneration!.Sequence, Is.EqualTo(11));
Assert.That(sink.TryGetByEntityId("first", out var retained), Is.True);
Assert.That(retained.Transform, Is.SameAs(firstTransform));
Assert.That(sink.TryGetByEntityId("second", out var second), Is.True);
Assert.That(sink.TryGetBySourceIndex(2, out var byIndex), Is.True);
Assert.That(byIndex, Is.SameAs(second));
Assert.That(sink.ActiveEntityCount, Is.EqualTo(2));
}
finally
{
UnityEngine.Object.DestroyImmediate(root);
}
}
[Test]
public void LowerCarriesProviderAdvertisedWorldBoundary()
{
@ -1935,6 +2031,26 @@ namespace GameCult.Eve.UnityScene.Tests
};
}
private static EveEntitySoaColumn SoaColumn(
string semantic,
string scalarType,
long byteOffset,
int stride) => new EveEntitySoaColumn
{
ColumnId = semantic,
Semantic = semantic,
BufferId = "hot",
ScalarType = scalarType,
ByteOffset = byteOffset,
ElementStride = stride,
ElementCount = 1
};
private static EveUnityPresentedEntity PresentedEntity(int index, string entityId, string assetRef) =>
new EveUnityPresentedEntity(
index, entityId, "entity", entityId, "", Vector3.zero, 0f, Vector3.zero,
1f, 1f, 0, 0, true, false, assetRef);
private sealed class FakeBodyReadLease : ICultMeshBodyReadLease
{
private readonly byte[] _bytes;
@ -1988,6 +2104,22 @@ namespace GameCult.Eve.UnityScene.Tests
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset) => _prefab;
}
private sealed class ObservingGameObjectAssetProvider : IEveUnityGameObjectAssetProvider
{
private readonly Action _onResolve;
public ObservingGameObjectAssetProvider(Action onResolve)
{
_onResolve = onResolve;
}
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
{
_onResolve();
return null;
}
}
private sealed class FakePlayableWorldProviderComponent :
MonoBehaviour,
IEveUnitySceneProviderSurfaceDocumentSource,

View file

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