Load provider assets through CultMesh content sessions

This commit is contained in:
Meta Vens 2026-07-14 01:26:47 +01:00
commit cf7f5b4fd8
3 changed files with 75 additions and 93 deletions

View file

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Collections.Concurrent;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
@ -33,7 +32,7 @@ namespace GameCult.Eve.UnityScene
typeof(CultMeshBodyPublicationDocument),
typeof(CultMeshNetworkBodyDocument),
typeof(CultMeshCdnArtifactManifest),
typeof(CultMeshCdnArtifactChunk)
typeof(CultMeshContentTransferStateDocument)
};
private readonly string _replicaPath;
@ -53,6 +52,8 @@ namespace GameCult.Eve.UnityScene
private CultNetDocumentRegistry? _networkRegistry;
private CultNetShardDescriptor? _replicaShard;
private CultNetDatabaseSubscriptionClient? _subscriptions;
private CultMeshClient? _meshClient;
private CultMeshContentTransferService? _contentTransfer;
private EveProviderAdvertisementDocument? _advertisement;
private EveAdvertisedSurface? _advertisedSurface;
private CultMeshBodyPublicationResolver? _bodyResolver;
@ -242,9 +243,12 @@ namespace GameCult.Eve.UnityScene
_node?.Dispose();
_snapshot?.Dispose();
_subscriptions?.Dispose();
_meshClient?.Dispose();
_node = null;
_snapshot = null;
_subscriptions = null;
_meshClient = null;
_contentTransfer = null;
_networkRegistry = null;
_replicaShard = null;
_pendingCommandIds.Clear();
@ -319,6 +323,10 @@ namespace GameCult.Eve.UnityScene
RudpMaxFragmentBytes = 1024
},
_networkRegistry);
_meshClient = new CultMeshClient(new CultMeshClientOptions
{
RendezvousEndpoints = new[] { _endpoint }
});
}
private void ResolveAdvertisement(bool forceRefresh = false)
@ -389,35 +397,41 @@ namespace GameCult.Eve.UnityScene
schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactManifest })
.GetAwaiter().GetResult().SingleOrDefault()
?? throw new FileNotFoundException("Provider network body manifest is missing.", binding.ManifestRecordKey);
var chunks = _snapshot
.FetchDocumentsAsync<CultMeshCdnArtifactChunk>(
recordKeys: manifest.Chunks.Select(chunk => chunk.RecordKey).ToArray(),
schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactChunk })
.GetAwaiter().GetResult();
return ResolveNetworkBody(descriptor, binding, manifest, chunks);
ValidateNetworkBody(descriptor, binding, manifest);
var path = ContentTransfer().FetchAsync(manifest).GetAwaiter().GetResult();
return File.ReadAllBytes(path);
}
private static byte[] ResolveNetworkBody(
private static void ValidateNetworkBody(
CultMeshBodyDescriptor descriptor,
CultMeshNetworkBodyDocument binding,
CultMeshCdnArtifactManifest manifest,
IReadOnlyList<CultMeshCdnArtifactChunk> chunks)
CultMeshCdnArtifactManifest manifest)
{
var registry = CultMesh.CreateCultCacheDocumentRegistry(
typeof(CultMeshNetworkBodyDocument),
typeof(CultMeshCdnArtifactManifest),
typeof(CultMeshCdnArtifactChunk));
var cache = new CultCache(registry);
cache.UpsertAsync(binding, new CultRecordHandle<CultMeshNetworkBodyDocument>(binding.RecordKey))
.GetAwaiter().GetResult();
cache.UpsertAsync(manifest, new CultRecordHandle<CultMeshCdnArtifactManifest>(
CultMeshCdnArtifactManifest.CreateRecordKey(manifest)))
.GetAwaiter().GetResult();
foreach (var chunk in chunks)
cache.UpsertAsync(chunk, new CultRecordHandle<CultMeshCdnArtifactChunk>(
CultMeshCdnArtifactChunk.CreateRecordKey(chunk)))
.GetAwaiter().GetResult();
return new CultMeshNetworkBodyResolver(cache).Fetch(descriptor);
if (!string.Equals(binding.CapabilityToken, descriptor.CapabilityToken, StringComparison.Ordinal) ||
!string.Equals(binding.BodyId, descriptor.BodyId, StringComparison.Ordinal) ||
!string.Equals(binding.SchemaId, descriptor.SchemaId, StringComparison.Ordinal) ||
binding.LayoutVersion != descriptor.LayoutVersion || binding.ByteSize != descriptor.ByteSize ||
binding.Capacity != descriptor.Capacity || binding.ProducerEpoch != descriptor.ProducerEpoch ||
binding.Sequence != descriptor.Sequence || binding.Synchronization != descriptor.Synchronization ||
binding.LeaseExpiresAtUnixMs != descriptor.LeaseExpiresAtUnixMs ||
!string.Equals(binding.SemanticHash, descriptor.SemanticHash, StringComparison.Ordinal))
throw new InvalidDataException("CultMesh network body descriptor disagrees with its capability binding.");
if (!string.Equals(CultMeshCdnArtifactManifest.CreateRecordKey(manifest).Value,
binding.ManifestRecordKey, StringComparison.Ordinal) ||
manifest.SizeBytes != binding.ByteSize ||
!string.Equals(NormalizeContentHash(manifest.ContentHash),
binding.SemanticHash, StringComparison.Ordinal))
throw new InvalidDataException("CultMesh network body manifest disagrees with its capability binding.");
}
private static string NormalizeContentHash(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new InvalidDataException("CultMesh content hash is missing.");
var normalized = value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
? value.Substring("sha256:".Length)
: value;
return normalized.ToLowerInvariant();
}
private void PublishEntityView(EveEntitySoaViewDocument document)
@ -623,9 +637,8 @@ namespace GameCult.Eve.UnityScene
var descriptor = manifest.FirstOrDefault();
if (descriptor == null)
throw new InvalidOperationException($"Provider asset bundle manifest '{variant.Uri}' was not available.");
var bytes = ReadCachedBundle(descriptor, variant);
VerifyBundle(bytes, variant);
var bundle = AssetBundle.LoadFromMemory(bytes);
var path = ReadVerifiedBundlePath(descriptor, variant);
var bundle = AssetBundle.LoadFromFile(path);
if (bundle == null)
throw new InvalidOperationException($"Unity could not load provider asset bundle '{variant.Uri}'.");
_assetBundles.Add(bundle);
@ -690,70 +703,38 @@ namespace GameCult.Eve.UnityScene
: Application.platform.ToString();
}
private byte[] ReadBundle(CultMeshCdnArtifactManifest manifest)
{
var bytes = new byte[checked((int)manifest.SizeBytes)];
var references = manifest.Chunks.OrderBy(chunk => chunk.Offset).ToArray();
const int chunkBatchSize = 32;
for (var index = 0; index < references.Length; index += chunkBatchSize)
{
var batch = references.Skip(index).Take(chunkBatchSize).ToArray();
var chunks = _snapshot!
.FetchDocumentsAsync<CultMeshCdnArtifactChunk>(
recordKeys: batch.Select(reference => reference.RecordKey).ToArray(),
schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactChunk })
.GetAwaiter()
.GetResult();
foreach (var reference in batch)
{
var chunk = chunks.FirstOrDefault(candidate =>
string.Equals(candidate.ChunkHash, reference.ChunkHash, StringComparison.Ordinal));
if (chunk == null)
throw new InvalidOperationException($"Provider asset chunk '{reference.RecordKey}' was not available.");
if (chunk.SizeBytes != reference.SizeBytes || chunk.Payload.Length != reference.SizeBytes)
throw new InvalidDataException($"Provider asset chunk '{reference.RecordKey}' size did not match its manifest.");
Buffer.BlockCopy(chunk.Payload, 0, bytes, checked((int)reference.Offset), reference.SizeBytes);
}
}
return bytes;
}
private byte[] ReadCachedBundle(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant)
private CultMeshContentTransferService ContentTransfer()
{
ResolveAdvertisement();
if (_contentTransfer != null)
return _contentTransfer;
if (string.IsNullOrWhiteSpace(_advertisement!.ServiceId))
throw new InvalidOperationException($"Eve provider '{_advertisement.ProviderId}' has no service-instance identity for content transport.");
var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH");
if (string.IsNullOrWhiteSpace(cacheRoot))
cacheRoot = Path.Combine(Application.persistentDataPath, "EveUnity", "assets");
Directory.CreateDirectory(cacheRoot);
var hash = variant.ContentHash.StartsWith("sha256:", StringComparison.Ordinal)
? variant.ContentHash.Substring("sha256:".Length)
: variant.ContentHash;
var cachePath = Path.Combine(cacheRoot, hash + ".bundle");
if (File.Exists(cachePath))
{
var cached = File.ReadAllBytes(cachePath);
VerifyBundle(cached, variant);
return cached;
}
var bytes = ReadBundle(manifest);
VerifyBundle(bytes, variant);
var temporaryPath = cachePath + ".tmp";
File.WriteAllBytes(temporaryPath, bytes);
if (File.Exists(cachePath)) File.Delete(cachePath);
File.Move(temporaryPath, cachePath);
return bytes;
_contentTransfer = new CultMeshContentTransferService(
_node!.Cache,
new[]
{
_meshClient!.ContentProvider(
_advertisement.ProviderId,
_advertisement.ServiceId,
new CultMeshSessionContentProviderOptions { ResponseTimeout = TimeSpan.FromSeconds(30) })
},
new CultMeshContentTransferOptions(cacheRoot));
return _contentTransfer;
}
private static void VerifyBundle(byte[] bytes, EveAssetVariant variant)
private string ReadVerifiedBundlePath(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant)
{
if (bytes.LongLength != variant.SizeBytes)
var hash = NormalizeContentHash(variant.ContentHash);
if (manifest.SizeBytes != variant.SizeBytes)
throw new InvalidOperationException($"Provider asset bundle '{variant.Uri}' size did not match its catalog.");
byte[] digest;
using (var sha256 = SHA256.Create())
digest = sha256.ComputeHash(bytes);
var actual = "sha256:" + string.Concat(digest.Select(value => value.ToString("x2")));
if (!string.Equals(actual, variant.ContentHash, StringComparison.Ordinal))
if (!string.Equals(NormalizeContentHash(manifest.ContentHash),
hash, StringComparison.Ordinal))
throw new InvalidOperationException($"Provider asset bundle '{variant.Uri}' hash did not match its catalog.");
return ContentTransfer().FetchAsync(manifest).GetAwaiter().GetResult();
}
private void PublishReceipt(EveCommandReceiptDocument receipt)

View file

@ -71,7 +71,7 @@ namespace GameCult.Eve.UnityScene.Tests
}
[Test]
public async System.Threading.Tasks.Task LiveTransportResolvesExactNetworkBodyThroughVerseDocuments()
public async System.Threading.Tasks.Task LiveTransportValidatesNetworkBodyControlDocumentsWithoutSnapshotChunks()
{
var view = EntityLeaseDocument();
var bytes = Enumerable.Range(0, 32).Select(value => (byte)value).ToArray();
@ -92,14 +92,15 @@ namespace GameCult.Eve.UnityScene.Tests
var binding = source.Get<CultMeshNetworkBodyDocument>(
CultMeshNetworkBodyDocument.CreateRecordKey(descriptor.CapabilityToken))!;
var manifest = source.Get<CultMeshCdnArtifactManifest>(new CultRecordKey(binding.ManifestRecordKey))!;
var chunks = manifest.Chunks.Select(reference =>
source.Get<CultMeshCdnArtifactChunk>(new CultRecordKey(reference.RecordKey))!).ToArray();
var method = typeof(EveUnityCultMeshLiveProviderTransport)
.GetMethod("ResolveNetworkBody", BindingFlags.NonPublic | BindingFlags.Static)!;
.GetMethod("ValidateNetworkBody", BindingFlags.NonPublic | BindingFlags.Static)!;
var resolved = (byte[])method.Invoke(null, new object[] { descriptor, binding, manifest, chunks })!;
method.Invoke(null, new object[] { descriptor, binding, manifest });
var wireTypes = (Type[])typeof(EveUnityCultMeshLiveProviderTransport)
.GetField("WireDocumentTypes", BindingFlags.NonPublic | BindingFlags.Static)!
.GetValue(null)!;
Assert.That(resolved, Is.EqualTo(bytes));
Assert.That(wireTypes.Any(type => type == typeof(CultMeshCdnArtifactChunk)), Is.False);
}
[Test]

View file

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