Add CultMesh distributed asset packages
This commit is contained in:
parent
b155e32541
commit
743d7688f0
7 changed files with 1617 additions and 0 deletions
|
|
@ -380,6 +380,7 @@ namespace GameCult.Mesh
|
|||
return local;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A locally hosted CultMesh node over CultCache, CultNet transport, and the mesh database facade.
|
||||
/// </summary>
|
||||
|
|
@ -680,6 +681,110 @@ namespace GameCult.Mesh
|
|||
return new CultMeshStreamCatalog();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs bytes into content-addressed CDN chunk documents and a versioned artifact manifest.
|
||||
/// </summary>
|
||||
public static CultMeshCdnArtifact PackCdnArtifact(
|
||||
string artifactId,
|
||||
byte[] payload,
|
||||
CultMeshCdnPackOptions? options = null)
|
||||
{
|
||||
return CultMeshCdn.PackArtifact(artifactId, payload, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a packed CDN artifact into a local cache.
|
||||
/// </summary>
|
||||
public static Task<CultMeshCdnArtifactManifest> PublishCdnArtifactAsync(
|
||||
CultCache cache,
|
||||
CultMeshCdnArtifact artifact)
|
||||
{
|
||||
return CultMeshCdn.PublishAsync(cache, artifact);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a packed CDN artifact into a distributed CultNet database.
|
||||
/// </summary>
|
||||
public static Task<CultMeshCdnArtifactManifest> PublishCdnArtifactAsync(
|
||||
CultNetDatabase database,
|
||||
CultMeshCdnArtifact artifact)
|
||||
{
|
||||
return CultMeshCdn.PublishAsync(database, artifact);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reassembles and verifies a CDN artifact from a local cache.
|
||||
/// </summary>
|
||||
public static byte[] ReadCdnArtifact(
|
||||
CultCache cache,
|
||||
CultMeshCdnArtifactManifest manifest)
|
||||
{
|
||||
return CultMeshCdn.ReadArtifact(cache, manifest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reassembles and verifies a CDN artifact from a distributed CultNet database.
|
||||
/// </summary>
|
||||
public static Task<byte[]> ReadCdnArtifactAsync(
|
||||
CultNetDatabase database,
|
||||
CultMeshCdnArtifactManifest manifest)
|
||||
{
|
||||
return CultMeshCdn.ReadArtifactAsync(database, manifest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CultNet document registry for CDN artifact manifests and chunks.
|
||||
/// </summary>
|
||||
public static CultNetDocumentRegistry CreateCdnDocumentRegistry(CultDocumentRegistry? documents = null)
|
||||
{
|
||||
return CultMeshCdn.CreateDocumentRegistry(documents);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes and stamps the content hash for a portable entity prefab package.
|
||||
/// </summary>
|
||||
public static CultMeshEntityPrefabPackage FinalizeEntityPrefabPackage(
|
||||
CultMeshEntityPrefabPackage package)
|
||||
{
|
||||
return CultMeshEntityPrefabs.FinalizePackage(package);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a portable entity prefab package into a local cache.
|
||||
/// </summary>
|
||||
public static Task<CultMeshEntityPrefabPackage> PublishEntityPrefabPackageAsync(
|
||||
CultCache cache,
|
||||
CultMeshEntityPrefabPackage package)
|
||||
{
|
||||
return CultMeshEntityPrefabs.PublishAsync(cache, package);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a portable entity prefab package into a distributed CultNet database.
|
||||
/// </summary>
|
||||
public static Task<CultMeshEntityPrefabPackage> PublishEntityPrefabPackageAsync(
|
||||
CultNetDatabase database,
|
||||
CultMeshEntityPrefabPackage package)
|
||||
{
|
||||
return CultMeshEntityPrefabs.PublishAsync(database, package);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CultNet document registry for portable entity prefab packages.
|
||||
/// </summary>
|
||||
public static CultNetDocumentRegistry CreateEntityPrefabDocumentRegistry(CultDocumentRegistry? documents = null)
|
||||
{
|
||||
return CultMeshEntityPrefabs.CreateDocumentRegistry(documents);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CultNet document registry for CDN assets and portable entity prefab packages.
|
||||
/// </summary>
|
||||
public static CultNetDocumentRegistry CreateAssetPipelineDocumentRegistry(CultDocumentRegistry? documents = null)
|
||||
{
|
||||
return CultMeshEntityPrefabs.CreateAssetPipelineDocumentRegistry(documents);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a managed Verse context for generated domain sugar.
|
||||
/// </summary>
|
||||
|
|
@ -1700,6 +1805,7 @@ namespace GameCult.Mesh
|
|||
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
||||
return DocumentsFromPublication(source, bindings, verse.Context, storeOptions, peerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads configured publication documents, hydrates them into a local node, and returns local handles as a catalog.
|
||||
/// </summary>
|
||||
|
|
@ -1751,6 +1857,7 @@ namespace GameCult.Mesh
|
|||
peerOptions,
|
||||
flush);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one typed document from a configured publication source and hydrates it into a local node.
|
||||
/// </summary>
|
||||
|
|
@ -1834,6 +1941,7 @@ namespace GameCult.Mesh
|
|||
peerOptions,
|
||||
flush);
|
||||
}
|
||||
|
||||
internal static CultMeshStoreDocumentOptions WithPublicationBindingOptions(
|
||||
CultMeshStoreDocumentOptions? options,
|
||||
CultRecordKey key,
|
||||
|
|
@ -1881,6 +1989,7 @@ namespace GameCult.Mesh
|
|||
RudpResendDelayMs = options?.RudpResendDelayMs ?? 25
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed document handle directly over one distributed CultNet database record.
|
||||
/// </summary>
|
||||
|
|
|
|||
578
src/GameCult.Mesh/CultMeshCdn.cs
Normal file
578
src/GameCult.Mesh/CultMeshCdn.cs
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Networking;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Mesh
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema versions for CultMesh distributed CDN documents.
|
||||
/// </summary>
|
||||
public static class CultMeshCdnSchemaVersions
|
||||
{
|
||||
/// <summary>Manifest document schema id.</summary>
|
||||
public const string ArtifactManifest = "gamecult.mesh.cdn_artifact_manifest.v1";
|
||||
/// <summary>Chunk document schema id.</summary>
|
||||
public const string ArtifactChunk = "gamecult.mesh.cdn_artifact_chunk.v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known CultMesh CDN artifact kinds.
|
||||
/// </summary>
|
||||
public static class CultMeshCdnArtifactKinds
|
||||
{
|
||||
/// <summary>Game, UI, shader, texture, audio, or data asset payload.</summary>
|
||||
public const string Asset = "asset";
|
||||
/// <summary>Executable build or install/update payload.</summary>
|
||||
public const string Build = "build";
|
||||
/// <summary>Generic packaged payload.</summary>
|
||||
public const string Package = "package";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for packing bytes into CultMesh CDN documents.
|
||||
/// </summary>
|
||||
public sealed class CultMeshCdnPackOptions
|
||||
{
|
||||
/// <summary>Default CDN chunk size.</summary>
|
||||
public const int DefaultChunkSizeBytes = 256 * 1024;
|
||||
|
||||
/// <summary>Maximum bytes per content chunk.</summary>
|
||||
public int ChunkSizeBytes { get; set; } = DefaultChunkSizeBytes;
|
||||
|
||||
/// <summary>Artifact kind, such as asset or build.</summary>
|
||||
public string Kind { get; set; } = CultMeshCdnArtifactKinds.Asset;
|
||||
|
||||
/// <summary>Caller-defined artifact version.</summary>
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Artifact MIME type.</summary>
|
||||
public string MimeType { get; set; } = "application/octet-stream";
|
||||
|
||||
/// <summary>Search and routing tags copied to the manifest.</summary>
|
||||
public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Caller metadata copied to the manifest.</summary>
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
|
||||
/// <summary>Override manifest creation timestamp in round-trip UTC format.</summary>
|
||||
public string? CreatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference from an artifact manifest to one content-addressed chunk document.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class CultMeshCdnChunkRef
|
||||
{
|
||||
/// <summary>SHA-256 hash of the referenced chunk payload.</summary>
|
||||
[Key(0)]
|
||||
public string ChunkHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Byte offset of this chunk inside the materialized artifact.</summary>
|
||||
[Key(1)]
|
||||
public long Offset { get; set; }
|
||||
|
||||
/// <summary>Number of payload bytes contributed by this chunk.</summary>
|
||||
[Key(2)]
|
||||
public int SizeBytes { get; set; }
|
||||
|
||||
/// <summary>CultCache record key for the referenced chunk document.</summary>
|
||||
[Key(3)]
|
||||
public string RecordKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed artifact chunk stored in CultCache and replicated through CultNet.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
[CultDocument("gamecult.mesh.cdn_artifact_chunk", CultMeshCdnSchemaVersions.ArtifactChunk)]
|
||||
public sealed class CultMeshCdnArtifactChunk
|
||||
{
|
||||
/// <summary>SHA-256 hash of the chunk payload.</summary>
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string ChunkHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Payload byte count.</summary>
|
||||
[Key(1)]
|
||||
public int SizeBytes { get; set; }
|
||||
|
||||
/// <summary>Raw chunk payload bytes.</summary>
|
||||
[Key(2)]
|
||||
public byte[] Payload { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>Builds the deterministic content-addressed chunk record key.</summary>
|
||||
public static CultRecordKey CreateRecordKey(CultMeshCdnArtifactChunk chunk)
|
||||
{
|
||||
if (chunk == null) throw new ArgumentNullException(nameof(chunk));
|
||||
return CreateRecordKey(chunk.ChunkHash);
|
||||
}
|
||||
|
||||
/// <summary>Builds the deterministic content-addressed chunk record key.</summary>
|
||||
public static CultRecordKey CreateRecordKey(string chunkHash)
|
||||
{
|
||||
return new CultRecordKey("mesh:cdn:chunk:" + CultMeshCdn.NormalizeHash(chunkHash, nameof(chunkHash)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest for one versioned CDN artifact assembled from content-addressed chunks.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
[CultDocument("gamecult.mesh.cdn_artifact_manifest", CultMeshCdnSchemaVersions.ArtifactManifest)]
|
||||
public sealed class CultMeshCdnArtifactManifest
|
||||
{
|
||||
/// <summary>Stable artifact id, such as a logical asset or build path.</summary>
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string ArtifactId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Artifact kind, such as asset, build, or package.</summary>
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
public string Kind { get; set; } = CultMeshCdnArtifactKinds.Asset;
|
||||
|
||||
/// <summary>Caller-defined artifact version.</summary>
|
||||
[Key(2)]
|
||||
[CultIndex]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>SHA-256 hash of the full materialized artifact.</summary>
|
||||
[Key(3)]
|
||||
[CultIndex]
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Full materialized artifact size.</summary>
|
||||
[Key(4)]
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
/// <summary>Artifact MIME type.</summary>
|
||||
[Key(5)]
|
||||
public string MimeType { get; set; } = "application/octet-stream";
|
||||
|
||||
/// <summary>Manifest creation timestamp in round-trip UTC format.</summary>
|
||||
[Key(6)]
|
||||
public string CreatedAtUtc { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Ordered chunk references needed to materialize the artifact.</summary>
|
||||
[Key(7)]
|
||||
public CultMeshCdnChunkRef[] Chunks { get; set; } = Array.Empty<CultMeshCdnChunkRef>();
|
||||
|
||||
/// <summary>Search and routing tags.</summary>
|
||||
[Key(8)]
|
||||
public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Caller metadata, such as platform or texture channel layout.</summary>
|
||||
[Key(9)]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
|
||||
/// <summary>Builds the deterministic versioned artifact manifest record key.</summary>
|
||||
public static CultRecordKey CreateRecordKey(CultMeshCdnArtifactManifest manifest)
|
||||
{
|
||||
if (manifest == null) throw new ArgumentNullException(nameof(manifest));
|
||||
return CreateRecordKey(manifest.ArtifactId, manifest.Version, manifest.ContentHash);
|
||||
}
|
||||
|
||||
/// <summary>Builds the deterministic versioned artifact manifest record key.</summary>
|
||||
public static CultRecordKey CreateRecordKey(string artifactId, string version, string contentHash)
|
||||
{
|
||||
var artifactToken = CultMeshCdn.StableToken(artifactId, nameof(artifactId));
|
||||
var versionToken = string.IsNullOrWhiteSpace(version)
|
||||
? "unversioned"
|
||||
: CultMeshCdn.StableToken(version, nameof(version));
|
||||
return new CultRecordKey(
|
||||
"mesh:cdn:artifact:" + artifactToken + ":" + versionToken + ":" + CultMeshCdn.NormalizeHash(contentHash, nameof(contentHash)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packed CDN artifact ready to publish into a cache or database.
|
||||
/// </summary>
|
||||
public sealed class CultMeshCdnArtifact
|
||||
{
|
||||
/// <summary>Creates a packed artifact container.</summary>
|
||||
public CultMeshCdnArtifact(
|
||||
CultMeshCdnArtifactManifest manifest,
|
||||
IReadOnlyList<CultMeshCdnArtifactChunk> chunks)
|
||||
{
|
||||
Manifest = manifest ?? throw new ArgumentNullException(nameof(manifest));
|
||||
Chunks = chunks ?? throw new ArgumentNullException(nameof(chunks));
|
||||
}
|
||||
|
||||
/// <summary>Artifact manifest document.</summary>
|
||||
public CultMeshCdnArtifactManifest Manifest { get; }
|
||||
|
||||
/// <summary>Content-addressed chunk documents.</summary>
|
||||
public IReadOnlyList<CultMeshCdnArtifactChunk> Chunks { get; }
|
||||
|
||||
/// <summary>Deterministic manifest record key.</summary>
|
||||
public CultRecordKey ManifestKey => CultMeshCdnArtifactManifest.CreateRecordKey(Manifest);
|
||||
|
||||
/// <summary>All unique record keys required to replicate this artifact.</summary>
|
||||
public IReadOnlyList<CultRecordKey> RecordKeys =>
|
||||
new[] { ManifestKey }.Concat(Chunks.Select(CultMeshCdnArtifactChunk.CreateRecordKey)).Distinct().ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed byte artifact helpers for distributing assets and builds through CultMesh clients.
|
||||
/// </summary>
|
||||
public static class CultMeshCdn
|
||||
{
|
||||
/// <summary>
|
||||
/// Splits a payload into content-addressed chunk documents and a versioned manifest.
|
||||
/// </summary>
|
||||
public static CultMeshCdnArtifact PackArtifact(
|
||||
string artifactId,
|
||||
byte[] payload,
|
||||
CultMeshCdnPackOptions? options = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactId))
|
||||
{
|
||||
throw new ArgumentException("Artifact id must be non-empty.", nameof(artifactId));
|
||||
}
|
||||
|
||||
if (payload == null) throw new ArgumentNullException(nameof(payload));
|
||||
options ??= new CultMeshCdnPackOptions();
|
||||
if (options.ChunkSizeBytes <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(options), "ChunkSizeBytes must be greater than zero.");
|
||||
}
|
||||
|
||||
var chunks = new List<CultMeshCdnArtifactChunk>();
|
||||
var refs = new List<CultMeshCdnChunkRef>();
|
||||
for (var offset = 0; offset < payload.Length; offset += options.ChunkSizeBytes)
|
||||
{
|
||||
var count = Math.Min(options.ChunkSizeBytes, payload.Length - offset);
|
||||
var chunkPayload = new byte[count];
|
||||
Buffer.BlockCopy(payload, offset, chunkPayload, 0, count);
|
||||
var chunkHash = HashBytes(chunkPayload);
|
||||
var chunk = new CultMeshCdnArtifactChunk
|
||||
{
|
||||
ChunkHash = chunkHash,
|
||||
SizeBytes = count,
|
||||
Payload = chunkPayload
|
||||
};
|
||||
chunks.Add(chunk);
|
||||
refs.Add(new CultMeshCdnChunkRef
|
||||
{
|
||||
ChunkHash = chunkHash,
|
||||
Offset = offset,
|
||||
SizeBytes = count,
|
||||
RecordKey = CultMeshCdnArtifactChunk.CreateRecordKey(chunk).Value
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.Length == 0)
|
||||
{
|
||||
var chunkHash = HashBytes(Array.Empty<byte>());
|
||||
var chunk = new CultMeshCdnArtifactChunk
|
||||
{
|
||||
ChunkHash = chunkHash,
|
||||
SizeBytes = 0,
|
||||
Payload = Array.Empty<byte>()
|
||||
};
|
||||
chunks.Add(chunk);
|
||||
refs.Add(new CultMeshCdnChunkRef
|
||||
{
|
||||
ChunkHash = chunkHash,
|
||||
Offset = 0,
|
||||
SizeBytes = 0,
|
||||
RecordKey = CultMeshCdnArtifactChunk.CreateRecordKey(chunk).Value
|
||||
});
|
||||
}
|
||||
|
||||
var manifest = new CultMeshCdnArtifactManifest
|
||||
{
|
||||
ArtifactId = artifactId,
|
||||
Kind = string.IsNullOrWhiteSpace(options.Kind) ? CultMeshCdnArtifactKinds.Asset : options.Kind,
|
||||
Version = options.Version ?? string.Empty,
|
||||
ContentHash = HashBytes(payload),
|
||||
SizeBytes = payload.LongLength,
|
||||
MimeType = string.IsNullOrWhiteSpace(options.MimeType) ? "application/octet-stream" : options.MimeType,
|
||||
CreatedAtUtc = options.CreatedAtUtc ?? DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
Chunks = refs.ToArray(),
|
||||
Tags = options.Tags ?? Array.Empty<string>(),
|
||||
Metadata = options.Metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
return new CultMeshCdnArtifact(manifest, chunks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes all chunk documents and the manifest into a local CultCache.
|
||||
/// </summary>
|
||||
public static async Task<CultMeshCdnArtifactManifest> PublishAsync(
|
||||
CultCache cache,
|
||||
CultMeshCdnArtifact artifact)
|
||||
{
|
||||
if (cache == null) throw new ArgumentNullException(nameof(cache));
|
||||
if (artifact == null) throw new ArgumentNullException(nameof(artifact));
|
||||
|
||||
ValidateArtifact(artifact);
|
||||
foreach (var chunk in artifact.Chunks)
|
||||
{
|
||||
await cache.UpsertAsync(
|
||||
chunk,
|
||||
new CultRecordHandle<CultMeshCdnArtifactChunk>(
|
||||
CultMeshCdnArtifactChunk.CreateRecordKey(chunk))).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await cache.UpsertAsync(
|
||||
artifact.Manifest,
|
||||
new CultRecordHandle<CultMeshCdnArtifactManifest>(
|
||||
CultMeshCdnArtifactManifest.CreateRecordKey(artifact.Manifest))).ConfigureAwait(false);
|
||||
|
||||
return artifact.Manifest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes all chunk documents and the manifest into a distributed CultNet database.
|
||||
/// </summary>
|
||||
public static async Task<CultMeshCdnArtifactManifest> PublishAsync(
|
||||
CultNetDatabase database,
|
||||
CultMeshCdnArtifact artifact)
|
||||
{
|
||||
if (database == null) throw new ArgumentNullException(nameof(database));
|
||||
if (artifact == null) throw new ArgumentNullException(nameof(artifact));
|
||||
|
||||
ValidateArtifact(artifact);
|
||||
foreach (var chunk in artifact.Chunks)
|
||||
{
|
||||
await database.PutAsync(CultMeshCdnArtifactChunk.CreateRecordKey(chunk), chunk)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await database.PutAsync(CultMeshCdnArtifactManifest.CreateRecordKey(artifact.Manifest), artifact.Manifest)
|
||||
.ConfigureAwait(false);
|
||||
return artifact.Manifest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reassembles and verifies an artifact from a local CultCache.
|
||||
/// </summary>
|
||||
public static byte[] ReadArtifact(CultCache cache, CultMeshCdnArtifactManifest manifest)
|
||||
{
|
||||
if (cache == null) throw new ArgumentNullException(nameof(cache));
|
||||
if (manifest == null) throw new ArgumentNullException(nameof(manifest));
|
||||
|
||||
ValidateManifestShape(manifest);
|
||||
var output = new byte[checked((int)manifest.SizeBytes)];
|
||||
foreach (var reference in manifest.Chunks.OrderBy(chunk => chunk.Offset))
|
||||
{
|
||||
var recordKey = string.IsNullOrWhiteSpace(reference.RecordKey)
|
||||
? CultMeshCdnArtifactChunk.CreateRecordKey(reference.ChunkHash)
|
||||
: new CultRecordKey(reference.RecordKey);
|
||||
var chunk = cache.Get<CultMeshCdnArtifactChunk>(recordKey)
|
||||
?? throw new FileNotFoundException("CDN artifact chunk is missing from the cache.", recordKey.Value);
|
||||
ValidateChunk(reference, chunk);
|
||||
Buffer.BlockCopy(chunk.Payload, 0, output, checked((int)reference.Offset), reference.SizeBytes);
|
||||
}
|
||||
|
||||
var actualHash = HashBytes(output);
|
||||
if (!string.Equals(NormalizeHash(manifest.ContentHash, nameof(manifest.ContentHash)), actualHash, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact content hash does not match its manifest.");
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reassembles and verifies an artifact from a distributed CultNet database.
|
||||
/// </summary>
|
||||
public static async Task<byte[]> ReadArtifactAsync(
|
||||
CultNetDatabase database,
|
||||
CultMeshCdnArtifactManifest manifest)
|
||||
{
|
||||
if (database == null) throw new ArgumentNullException(nameof(database));
|
||||
if (manifest == null) throw new ArgumentNullException(nameof(manifest));
|
||||
|
||||
ValidateManifestShape(manifest);
|
||||
var output = new byte[checked((int)manifest.SizeBytes)];
|
||||
foreach (var reference in manifest.Chunks.OrderBy(chunk => chunk.Offset))
|
||||
{
|
||||
var recordKey = string.IsNullOrWhiteSpace(reference.RecordKey)
|
||||
? CultMeshCdnArtifactChunk.CreateRecordKey(reference.ChunkHash)
|
||||
: new CultRecordKey(reference.RecordKey);
|
||||
var chunk = await database.GetAsync<CultMeshCdnArtifactChunk>(recordKey).ConfigureAwait(false)
|
||||
?? throw new FileNotFoundException("CDN artifact chunk is missing from the database.", recordKey.Value);
|
||||
ValidateChunk(reference, chunk);
|
||||
Buffer.BlockCopy(chunk.Payload, 0, output, checked((int)reference.Offset), reference.SizeBytes);
|
||||
}
|
||||
|
||||
var actualHash = HashBytes(output);
|
||||
if (!string.Equals(NormalizeHash(manifest.ContentHash, nameof(manifest.ContentHash)), actualHash, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact content hash does not match its manifest.");
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a raw CultNet document registry for CDN manifests and chunks.
|
||||
/// </summary>
|
||||
public static CultNetDocumentRegistry CreateDocumentRegistry(CultDocumentRegistry? documents = null)
|
||||
{
|
||||
documents ??= CultDocumentRegistry.Shared;
|
||||
return new CultNetDocumentRegistry(documents)
|
||||
.Register(CultNetDocumentBinding.ForDocument<CultMeshCdnArtifactManifest>(documents))
|
||||
.Register(CultNetDocumentBinding.ForDocument<CultMeshCdnArtifactChunk>(documents));
|
||||
}
|
||||
|
||||
internal static string HashBytes(byte[] bytes)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(bytes);
|
||||
var builder = new StringBuilder(hash.Length * 2);
|
||||
foreach (var value in hash)
|
||||
{
|
||||
builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void ValidateArtifact(CultMeshCdnArtifact artifact)
|
||||
{
|
||||
ValidateManifestShape(artifact.Manifest);
|
||||
var expectedRefs = artifact.Chunks
|
||||
.Select(chunk => new
|
||||
{
|
||||
Hash = HashBytes(chunk.Payload),
|
||||
ChunkHash = NormalizeHash(chunk.ChunkHash, nameof(chunk.ChunkHash)),
|
||||
chunk.SizeBytes,
|
||||
Key = CultMeshCdnArtifactChunk.CreateRecordKey(chunk).Value
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
if (artifact.Manifest.Chunks.Length != expectedRefs.Length)
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact manifest chunk count does not match chunk payloads.");
|
||||
}
|
||||
|
||||
for (var i = 0; i < expectedRefs.Length; i++)
|
||||
{
|
||||
var reference = artifact.Manifest.Chunks[i];
|
||||
var expected = expectedRefs[i];
|
||||
if (!string.Equals(NormalizeHash(reference.ChunkHash, nameof(reference.ChunkHash)), expected.Hash, StringComparison.Ordinal) ||
|
||||
!string.Equals(expected.ChunkHash, expected.Hash, StringComparison.Ordinal) ||
|
||||
reference.SizeBytes != expected.SizeBytes ||
|
||||
!string.Equals(reference.RecordKey, expected.Key, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact manifest chunk reference does not match its chunk payload.");
|
||||
}
|
||||
}
|
||||
|
||||
var materialized = new byte[checked((int)artifact.Manifest.SizeBytes)];
|
||||
foreach (var reference in artifact.Manifest.Chunks.OrderBy(chunk => chunk.Offset))
|
||||
{
|
||||
var chunk = artifact.Chunks.First(candidate =>
|
||||
string.Equals(
|
||||
CultMeshCdnArtifactChunk.CreateRecordKey(candidate).Value,
|
||||
reference.RecordKey,
|
||||
StringComparison.Ordinal));
|
||||
Buffer.BlockCopy(chunk.Payload, 0, materialized, checked((int)reference.Offset), reference.SizeBytes);
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
NormalizeHash(artifact.Manifest.ContentHash, nameof(artifact.Manifest.ContentHash)),
|
||||
HashBytes(materialized),
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact content hash does not match its manifest.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateManifestShape(CultMeshCdnArtifactManifest manifest)
|
||||
{
|
||||
if (manifest.SizeBytes < 0)
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact manifest has a negative size.");
|
||||
}
|
||||
|
||||
if (manifest.SizeBytes > int.MaxValue)
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact is too large to materialize into one byte array.");
|
||||
}
|
||||
|
||||
var expectedOffset = 0L;
|
||||
foreach (var reference in manifest.Chunks.OrderBy(chunk => chunk.Offset))
|
||||
{
|
||||
if (reference.Offset != expectedOffset)
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact chunks are not contiguous.");
|
||||
}
|
||||
|
||||
if (reference.SizeBytes < 0)
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact chunk has a negative size.");
|
||||
}
|
||||
|
||||
expectedOffset += reference.SizeBytes;
|
||||
}
|
||||
|
||||
if (expectedOffset != manifest.SizeBytes)
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact chunk sizes do not sum to the manifest size.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateChunk(CultMeshCdnChunkRef reference, CultMeshCdnArtifactChunk chunk)
|
||||
{
|
||||
if (chunk.Payload.Length != reference.SizeBytes ||
|
||||
chunk.SizeBytes != reference.SizeBytes)
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact chunk payload metadata does not match its manifest reference.");
|
||||
}
|
||||
|
||||
var actualHash = HashBytes(chunk.Payload);
|
||||
if (!string.Equals(NormalizeHash(reference.ChunkHash, nameof(reference.ChunkHash)), actualHash, StringComparison.Ordinal) ||
|
||||
!string.Equals(NormalizeHash(chunk.ChunkHash, nameof(chunk.ChunkHash)), actualHash, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException("CDN artifact chunk hash does not match its payload.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static string NormalizeHash(string hash, string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hash))
|
||||
{
|
||||
throw new ArgumentException("Hash must be non-empty.", parameterName);
|
||||
}
|
||||
|
||||
var value = hash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? hash.Substring("sha256:".Length)
|
||||
: hash;
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
internal static string StableToken(string value, string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value must be non-empty.", parameterName);
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
var builder = new StringBuilder(normalized.Length);
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
builder.Append(char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.' ? c : '-');
|
||||
}
|
||||
|
||||
var token = builder.ToString().Trim('-');
|
||||
return string.IsNullOrWhiteSpace(token)
|
||||
? HashBytes(Encoding.UTF8.GetBytes(value))
|
||||
: token;
|
||||
}
|
||||
}
|
||||
}
|
||||
395
src/GameCult.Mesh/CultMeshEntityPrefabs.cs
Normal file
395
src/GameCult.Mesh/CultMeshEntityPrefabs.cs
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Networking;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Mesh
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema versions for portable entity prefab packages.
|
||||
/// </summary>
|
||||
public static class CultMeshEntityPrefabSchemaVersions
|
||||
{
|
||||
/// <summary>Portable prefab package schema id.</summary>
|
||||
public const string Package = "gamecult.mesh.entity_prefab_package.v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known portable prefab node kinds.
|
||||
/// </summary>
|
||||
public static class CultMeshEntityPrefabNodeKinds
|
||||
{
|
||||
/// <summary>Transform-only node.</summary>
|
||||
public const string Empty = "empty";
|
||||
/// <summary>Renderable mesh node.</summary>
|
||||
public const string Mesh = "mesh";
|
||||
/// <summary>Collider node.</summary>
|
||||
public const string Collider = "collider";
|
||||
/// <summary>Particle or projectile effect node.</summary>
|
||||
public const string Effect = "effect";
|
||||
/// <summary>Camera or view anchor node.</summary>
|
||||
public const string Camera = "camera";
|
||||
/// <summary>Runtime attachment point.</summary>
|
||||
public const string Socket = "socket";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known prefab asset roles.
|
||||
/// </summary>
|
||||
public static class CultMeshEntityPrefabAssetRoles
|
||||
{
|
||||
/// <summary>Mesh geometry payload.</summary>
|
||||
public const string Mesh = "mesh";
|
||||
/// <summary>Texture image payload.</summary>
|
||||
public const string Texture = "texture";
|
||||
/// <summary>Material definition payload.</summary>
|
||||
public const string Material = "material";
|
||||
/// <summary>Animation clip payload.</summary>
|
||||
public const string Animation = "animation";
|
||||
/// <summary>Audio payload.</summary>
|
||||
public const string Audio = "audio";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Portable transform using Blender/CultMesh source coordinates before runtime lowering.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class CultMeshEntityPrefabTransform
|
||||
{
|
||||
/// <summary>Local position vector.</summary>
|
||||
[Key(0)]
|
||||
public float[] Position { get; set; } = [0f, 0f, 0f];
|
||||
|
||||
/// <summary>Local rotation quaternion.</summary>
|
||||
[Key(1)]
|
||||
public float[] Rotation { get; set; } = [0f, 0f, 0f, 1f];
|
||||
|
||||
/// <summary>Local scale vector.</summary>
|
||||
[Key(2)]
|
||||
public float[] Scale { get; set; } = [1f, 1f, 1f];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime component intent attached to a portable prefab node.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class CultMeshEntityPrefabComponent
|
||||
{
|
||||
/// <summary>Portable component kind or runtime adapter type.</summary>
|
||||
[Key(0)]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>String-valued component properties for runtime-specific lowering.</summary>
|
||||
[Key(1)]
|
||||
public Dictionary<string, string> Properties { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference from a prefab package to a CDN artifact manifest.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class CultMeshEntityPrefabAssetRef
|
||||
{
|
||||
/// <summary>Stable source asset id inside the prefab package.</summary>
|
||||
[Key(0)]
|
||||
public string AssetId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Semantic role, such as mesh, texture, material, or animation.</summary>
|
||||
[Key(1)]
|
||||
public string Role { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>CultCache record key for the referenced CDN artifact manifest.</summary>
|
||||
[Key(2)]
|
||||
public string CdnManifestRecordKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Full artifact content hash copied from the referenced CDN manifest.</summary>
|
||||
[Key(3)]
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Artifact MIME type copied from the referenced CDN manifest.</summary>
|
||||
[Key(4)]
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Runtime and source metadata for the asset reference.</summary>
|
||||
[Key(5)]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One node in a portable entity prefab graph.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class CultMeshEntityPrefabNode
|
||||
{
|
||||
/// <summary>Stable node id inside the package.</summary>
|
||||
[Key(0)]
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Source object name.</summary>
|
||||
[Key(1)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional parent node id.</summary>
|
||||
[Key(2)]
|
||||
public string ParentNodeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Node kind.</summary>
|
||||
[Key(3)]
|
||||
public string Kind { get; set; } = CultMeshEntityPrefabNodeKinds.Empty;
|
||||
|
||||
/// <summary>Local transform.</summary>
|
||||
[Key(4)]
|
||||
public CultMeshEntityPrefabTransform Transform { get; set; } = new();
|
||||
|
||||
/// <summary>Optional mesh asset id used by this node.</summary>
|
||||
[Key(5)]
|
||||
public string MeshAssetId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Material asset ids used by this node.</summary>
|
||||
[Key(6)]
|
||||
public string[] MaterialAssetIds { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Runtime component intents attached to this node.</summary>
|
||||
[Key(7)]
|
||||
public CultMeshEntityPrefabComponent[] Components { get; set; } = Array.Empty<CultMeshEntityPrefabComponent>();
|
||||
|
||||
/// <summary>Runtime tags copied from source metadata.</summary>
|
||||
[Key(8)]
|
||||
public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Additional source metadata.</summary>
|
||||
[Key(9)]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Portable entity prefab package authored from a Blender collection and lowerable into multiple runtimes.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
[CultDocument("gamecult.mesh.entity_prefab_package", CultMeshEntityPrefabSchemaVersions.Package)]
|
||||
public sealed class CultMeshEntityPrefabPackage
|
||||
{
|
||||
/// <summary>Stable logical prefab id.</summary>
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string PrefabId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Caller-defined package version.</summary>
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Tool that authored the package, such as Brokkr Blender.</summary>
|
||||
[Key(2)]
|
||||
[CultIndex]
|
||||
public string SourceTool { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Source scene name or path.</summary>
|
||||
[Key(3)]
|
||||
public string SourceScene { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Source Blender collection name.</summary>
|
||||
[Key(4)]
|
||||
[CultIndex]
|
||||
public string SourceCollection { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Stable hash of graph structure and referenced assets.</summary>
|
||||
[Key(5)]
|
||||
[CultIndex]
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Package creation timestamp in round-trip UTC format.</summary>
|
||||
[Key(6)]
|
||||
public string CreatedAtUtc { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Referenced CDN assets.</summary>
|
||||
[Key(7)]
|
||||
public CultMeshEntityPrefabAssetRef[] Assets { get; set; } = Array.Empty<CultMeshEntityPrefabAssetRef>();
|
||||
|
||||
/// <summary>Prefab hierarchy nodes.</summary>
|
||||
[Key(8)]
|
||||
public CultMeshEntityPrefabNode[] Nodes { get; set; } = Array.Empty<CultMeshEntityPrefabNode>();
|
||||
|
||||
/// <summary>Search and routing tags.</summary>
|
||||
[Key(9)]
|
||||
public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Package-level metadata.</summary>
|
||||
[Key(10)]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
|
||||
/// <summary>Builds the deterministic package record key.</summary>
|
||||
public static CultRecordKey CreateRecordKey(CultMeshEntityPrefabPackage package)
|
||||
{
|
||||
if (package == null) throw new ArgumentNullException(nameof(package));
|
||||
return CreateRecordKey(package.PrefabId, package.Version, package.ContentHash);
|
||||
}
|
||||
|
||||
/// <summary>Builds the deterministic package record key.</summary>
|
||||
public static CultRecordKey CreateRecordKey(string prefabId, string version, string contentHash)
|
||||
{
|
||||
var prefabToken = CultMeshCdn.StableToken(prefabId, nameof(prefabId));
|
||||
var versionToken = string.IsNullOrWhiteSpace(version)
|
||||
? "unversioned"
|
||||
: CultMeshCdn.StableToken(version, nameof(version));
|
||||
return new CultRecordKey(
|
||||
"mesh:entity-prefab:" + prefabToken + ":" + versionToken + ":" + CultMeshCdn.NormalizeHash(contentHash, nameof(contentHash)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for publishing and validating portable entity prefab packages.
|
||||
/// </summary>
|
||||
public static class CultMeshEntityPrefabs
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a stable package content hash from graph structure and referenced asset hashes.
|
||||
/// </summary>
|
||||
public static string ComputeContentHash(CultMeshEntityPrefabPackage package)
|
||||
{
|
||||
if (package == null) throw new ArgumentNullException(nameof(package));
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(package.PrefabId).Append('\u001F')
|
||||
.Append(package.Version).Append('\u001F')
|
||||
.Append(package.SourceTool).Append('\u001F')
|
||||
.Append(package.SourceScene).Append('\u001F')
|
||||
.Append(package.SourceCollection).Append('\u001F');
|
||||
|
||||
foreach (var asset in package.Assets.OrderBy(asset => asset.AssetId, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("asset").Append('\u001E')
|
||||
.Append(asset.AssetId).Append('\u001E')
|
||||
.Append(asset.Role).Append('\u001E')
|
||||
.Append(asset.CdnManifestRecordKey).Append('\u001E')
|
||||
.Append(CultMeshCdn.NormalizeHash(asset.ContentHash, nameof(asset.ContentHash))).Append('\u001E')
|
||||
.Append(asset.MimeType).Append('\u001E')
|
||||
.Append(StableMap(asset.Metadata)).Append('\u001F');
|
||||
}
|
||||
|
||||
foreach (var node in package.Nodes.OrderBy(node => node.NodeId, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("node").Append('\u001E')
|
||||
.Append(node.NodeId).Append('\u001E')
|
||||
.Append(node.Name).Append('\u001E')
|
||||
.Append(node.ParentNodeId).Append('\u001E')
|
||||
.Append(node.Kind).Append('\u001E')
|
||||
.Append(StableFloats(node.Transform.Position)).Append('\u001E')
|
||||
.Append(StableFloats(node.Transform.Rotation)).Append('\u001E')
|
||||
.Append(StableFloats(node.Transform.Scale)).Append('\u001E')
|
||||
.Append(node.MeshAssetId).Append('\u001E')
|
||||
.Append(string.Join(",", node.MaterialAssetIds.OrderBy(value => value, StringComparer.Ordinal))).Append('\u001E')
|
||||
.Append(StableComponents(node.Components)).Append('\u001E')
|
||||
.Append(string.Join(",", node.Tags.OrderBy(value => value, StringComparer.Ordinal))).Append('\u001E')
|
||||
.Append(StableMap(node.Metadata)).Append('\u001F');
|
||||
}
|
||||
|
||||
builder.Append(string.Join(",", package.Tags.OrderBy(value => value, StringComparer.Ordinal))).Append('\u001F')
|
||||
.Append(StableMap(package.Metadata));
|
||||
|
||||
return CultMeshCdn.HashBytes(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps the current content hash and creation timestamp on a package.
|
||||
/// </summary>
|
||||
public static CultMeshEntityPrefabPackage FinalizePackage(CultMeshEntityPrefabPackage package)
|
||||
{
|
||||
if (package == null) throw new ArgumentNullException(nameof(package));
|
||||
package.CreatedAtUtc = string.IsNullOrWhiteSpace(package.CreatedAtUtc)
|
||||
? DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
|
||||
: package.CreatedAtUtc;
|
||||
package.ContentHash = ComputeContentHash(package);
|
||||
return package;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a finalized portable entity prefab package into a local cache.
|
||||
/// </summary>
|
||||
public static async Task<CultMeshEntityPrefabPackage> PublishAsync(
|
||||
CultCache cache,
|
||||
CultMeshEntityPrefabPackage package)
|
||||
{
|
||||
if (cache == null) throw new ArgumentNullException(nameof(cache));
|
||||
if (package == null) throw new ArgumentNullException(nameof(package));
|
||||
ValidatePackage(package);
|
||||
await cache.UpsertAsync(
|
||||
package,
|
||||
new CultRecordHandle<CultMeshEntityPrefabPackage>(
|
||||
CultMeshEntityPrefabPackage.CreateRecordKey(package))).ConfigureAwait(false);
|
||||
return package;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a finalized portable entity prefab package into a distributed CultNet database.
|
||||
/// </summary>
|
||||
public static async Task<CultMeshEntityPrefabPackage> PublishAsync(
|
||||
CultNetDatabase database,
|
||||
CultMeshEntityPrefabPackage package)
|
||||
{
|
||||
if (database == null) throw new ArgumentNullException(nameof(database));
|
||||
if (package == null) throw new ArgumentNullException(nameof(package));
|
||||
ValidatePackage(package);
|
||||
await database.PutAsync(CultMeshEntityPrefabPackage.CreateRecordKey(package), package)
|
||||
.ConfigureAwait(false);
|
||||
return package;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a raw CultNet document registry for entity prefab packages.
|
||||
/// </summary>
|
||||
public static CultNetDocumentRegistry CreateDocumentRegistry(CultDocumentRegistry? documents = null)
|
||||
{
|
||||
documents ??= CultDocumentRegistry.Shared;
|
||||
return new CultNetDocumentRegistry(documents)
|
||||
.Register(CultNetDocumentBinding.ForDocument<CultMeshEntityPrefabPackage>(documents));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a raw CultNet document registry for CDN assets and portable entity prefab packages.
|
||||
/// </summary>
|
||||
public static CultNetDocumentRegistry CreateAssetPipelineDocumentRegistry(CultDocumentRegistry? documents = null)
|
||||
{
|
||||
documents ??= CultDocumentRegistry.Shared;
|
||||
return CultMeshCdn.CreateDocumentRegistry(documents)
|
||||
.Register(CultNetDocumentBinding.ForDocument<CultMeshEntityPrefabPackage>(documents));
|
||||
}
|
||||
|
||||
private static void ValidatePackage(CultMeshEntityPrefabPackage package)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(package.PrefabId))
|
||||
{
|
||||
throw new ArgumentException("Prefab id must be non-empty.", nameof(package));
|
||||
}
|
||||
|
||||
var expectedHash = ComputeContentHash(package);
|
||||
if (!string.Equals(CultMeshCdn.NormalizeHash(package.ContentHash, nameof(package.ContentHash)), expectedHash, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("Entity prefab package content hash does not match its graph.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string StableComponents(IEnumerable<CultMeshEntityPrefabComponent> components)
|
||||
{
|
||||
return string.Join("|", components
|
||||
.OrderBy(component => component.Type, StringComparer.Ordinal)
|
||||
.Select(component => component.Type + "=" + StableMap(component.Properties)));
|
||||
}
|
||||
|
||||
private static string StableFloats(IEnumerable<float> values)
|
||||
{
|
||||
return string.Join(",", values.Select(value => value.ToString("R", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
private static string StableMap(Dictionary<string, string> values)
|
||||
{
|
||||
return string.Join("|", values
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => pair.Key + "=" + pair.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,23 @@ using var node = await CultMesh.StartNodeAsync("world.ccmp", new CultMeshNodeOpt
|
|||
If `ShardLogPath` is omitted, CultMesh stores logs beside the cache under
|
||||
`world.cultmesh/shard-logs`.
|
||||
|
||||
## Distributed CDN
|
||||
|
||||
CultMesh can distribute signed, content-addressed game assets through the same
|
||||
typed document lane as gameplay state. Asset manifests, chunks, and portable
|
||||
entity prefab packages are CultCache documents that can be replicated by
|
||||
CultNet snapshots, shard logs, central servers, LAN peers, or opted-out-capable
|
||||
internet peer caches.
|
||||
|
||||
This is the canonical asset pipeline target for Aetheria. Brokkr-authored
|
||||
Blender prefab snapshots deploy into `CultMeshEntityPrefabPackage` documents
|
||||
that reference CDN artifact manifests for meshes, textures, materials, and
|
||||
other payloads. Unity and TypeScript clients should unpack the same package
|
||||
graph rather than maintaining separate Unity-prefab and Electron asset formats.
|
||||
|
||||
See [docs/distributed-cdn.md](docs/distributed-cdn.md) for document shapes,
|
||||
publish/read helpers, peer asset sharing policy, and the Brokkr prefab flow.
|
||||
|
||||
## Verses
|
||||
|
||||
A Verse is a rule-bearing consensus graph. Aetheria Main can be a
|
||||
|
|
@ -378,5 +395,6 @@ hot frame bodies remain in the cheapest storage both endpoints can share.
|
|||
- [Public API](docs/public-api.md)
|
||||
- [Durable Node Quickstart](docs/durable-node-quickstart.md)
|
||||
- [Typed Document Path](docs/typed-document-path.md)
|
||||
- [Distributed CDN Documents](docs/distributed-cdn.md)
|
||||
- [Research Notes](docs/research.md)
|
||||
- [Verse Model](docs/verses.md)
|
||||
|
|
|
|||
197
src/GameCult.Mesh/docs/distributed-cdn.md
Normal file
197
src/GameCult.Mesh/docs/distributed-cdn.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# Distributed CDN Documents
|
||||
|
||||
CultMesh CDN artifacts are typed CultCache documents, not a separate asset
|
||||
transport. The same manifest and chunk records can be written by an asset
|
||||
pipeline daemon, replicated through CultNet raw snapshots or shard logs, and
|
||||
read by Unity, TypeScript, or another runtime that implements the CultCache
|
||||
document contract.
|
||||
|
||||
This is the canonical path for moving Aetheria assets into CultLib. Starbridge
|
||||
Unity and Starbridge Electron should consume the same records so UI textures,
|
||||
map icons, environment layers, shaders, and build payloads do not fork by
|
||||
client.
|
||||
|
||||
## Feature Overview
|
||||
|
||||
The CDN feature has four layers:
|
||||
|
||||
1. `CultMeshCdnArtifactManifest` describes one versioned payload.
|
||||
2. `CultMeshCdnArtifactChunk` stores content-addressed payload bytes.
|
||||
3. `CultMeshEntityPrefabPackage` describes a portable entity/prefab graph and
|
||||
references CDN artifact manifests for meshes, textures, materials, animation,
|
||||
audio, or other runtime payloads.
|
||||
4. Mesh asset sharing moves those signed manifests and chunks through central
|
||||
servers, LAN peers, or internet peers without granting peers authority over
|
||||
game state.
|
||||
|
||||
This is not a Unity asset bundle system and not a raw FBX distribution system.
|
||||
Blender is the authoring ground truth for entity prefabs. Brokkr can mirror a
|
||||
legacy Unity prefab into Blender for migration, but the deployed CDN artifact is
|
||||
the Brokkr/Blender-authored snapshot lowered into CultMesh documents.
|
||||
|
||||
Typical flow:
|
||||
|
||||
1. Unity publishes a one-time `brokkr.unity.prefab_mirror_snapshot.v0` for an
|
||||
existing prefab.
|
||||
2. Brokkr Blender imports that snapshot into a collection so artists can
|
||||
configure renderable entity requirements in Blender.
|
||||
3. Brokkr Blender publishes a `brokkr.prefab.snapshot.v0` deploy snapshot from
|
||||
the collection.
|
||||
4. A deployer packages referenced meshes, textures, and materials as CDN
|
||||
artifacts.
|
||||
5. The deployer writes a `CultMeshEntityPrefabPackage` that references those CDN
|
||||
artifact manifests.
|
||||
6. Unity, TypeScript, and later clients fetch the same package and lower it into
|
||||
their runtime entity/component model.
|
||||
|
||||
## Document Shape
|
||||
|
||||
`CultMeshCdnArtifactManifest` is the versioned asset handle. It carries:
|
||||
|
||||
- `ArtifactId`: logical id, such as `aetheria/ui/icons/minimap`.
|
||||
- `Kind`: `asset`, `build`, or `package`.
|
||||
- `Version`: caller-defined release or content version.
|
||||
- `ContentHash`: SHA-256 of the full materialized payload.
|
||||
- `SizeBytes`: full payload size.
|
||||
- `MimeType`: byte interpretation hint.
|
||||
- `Chunks`: ordered `CultMeshCdnChunkRef` placements.
|
||||
- `Tags` and `Metadata`: routing and domain hints.
|
||||
|
||||
`CultMeshCdnArtifactChunk` is content-addressed by SHA-256. It carries only the
|
||||
chunk hash, byte count, and raw payload. Placement is deliberately in the
|
||||
manifest reference, so equal chunk bytes can be reused across assets and build
|
||||
updates.
|
||||
|
||||
## Runtime Contract
|
||||
|
||||
Clients should:
|
||||
|
||||
1. Fetch or receive a manifest by record key, tag, or index.
|
||||
2. Fetch every chunk record referenced by `manifest.Chunks`.
|
||||
3. Verify each chunk hash before copying bytes.
|
||||
4. Reassemble chunks by `Offset`.
|
||||
5. Verify the full `ContentHash`.
|
||||
6. Interpret bytes according to `MimeType` plus domain metadata.
|
||||
|
||||
Single-channel data stays single-channel. For example, a gravity map can use
|
||||
raw bytes with metadata such as `channels=r32` instead of pretending to be an
|
||||
RGBA texture.
|
||||
|
||||
## Mesh Asset Sharing Policy
|
||||
|
||||
CultMesh asset distribution is allowed to use installed clients as a peer asset
|
||||
cache because Aetheria cannot assume enough central infrastructure to serve all
|
||||
game files authoritatively. This must be visible to players and configurable in
|
||||
the client, but the initial product policy is opt-out rather than opt-in.
|
||||
|
||||
The default policy should be:
|
||||
|
||||
- Local cache is always enabled.
|
||||
- LAN asset sharing is enabled by default.
|
||||
- Internet peer asset sharing is enabled by default for signed asset chunks and
|
||||
prefab packages.
|
||||
- Executable or build-update seeding is disabled by default until the UX and
|
||||
signing story is mature.
|
||||
- Uploading is disabled or heavily throttled during gameplay.
|
||||
- Uploading has conservative default rate and daily caps.
|
||||
- Metered, cellular, battery-constrained, VPN, or corporate-network cases should
|
||||
disable or throttle peer upload when detectable.
|
||||
- The client must expose a clear `Do not upload game assets to other players`
|
||||
control.
|
||||
- The client must expose telemetry: cache size, current upload/download rate,
|
||||
bytes served to peers, and bytes received from peers.
|
||||
|
||||
Suggested runtime configuration:
|
||||
|
||||
```csharp
|
||||
var policy = new MeshAssetSharingPolicy
|
||||
{
|
||||
EnabledByDefault = true,
|
||||
AllowLanPeers = true,
|
||||
AllowInternetPeers = true,
|
||||
AllowBuildArtifacts = false,
|
||||
DisableDuringGameplay = true,
|
||||
DisableOnMeteredNetwork = true,
|
||||
RequireSignedManifests = true,
|
||||
ShowStatusTelemetry = true
|
||||
};
|
||||
```
|
||||
|
||||
Peers are never trusted as authority. They only serve bytes. Every chunk is
|
||||
content-addressed, every artifact is reconstructed from a manifest, and game
|
||||
clients must verify hashes and signatures before using the result.
|
||||
|
||||
## C# Publishing
|
||||
|
||||
```csharp
|
||||
var artifact = CultMesh.PackCdnArtifact(
|
||||
"aetheria/environment/gravity/ragnarok",
|
||||
gravityBytes,
|
||||
new CultMeshCdnPackOptions
|
||||
{
|
||||
Kind = CultMeshCdnArtifactKinds.Asset,
|
||||
Version = "2026.06.27",
|
||||
MimeType = "application/octet-stream",
|
||||
Tags = ["aetheria", "environment", "gravity"],
|
||||
Metadata =
|
||||
{
|
||||
["channels"] = "r32",
|
||||
["width"] = "1024",
|
||||
["height"] = "1024"
|
||||
}
|
||||
});
|
||||
|
||||
await CultMesh.PublishCdnArtifactAsync(node.Cache, artifact);
|
||||
```
|
||||
|
||||
For build distribution, use the same API with `Kind = CultMeshCdnArtifactKinds.Build`
|
||||
and metadata such as `platform=windows-x64` or `channel=internal`.
|
||||
|
||||
## CultNet Replication
|
||||
|
||||
CDN documents use the regular raw document lane:
|
||||
|
||||
```csharp
|
||||
var registry = CultMesh.CreateCdnDocumentRegistry(node.Cache.Registry);
|
||||
var request = registry.CreateSnapshotRequest(
|
||||
"cdn-request",
|
||||
schemaIds:
|
||||
[
|
||||
node.Cache.Registry.GetRequired<CultMeshCdnArtifactManifest>().SchemaId,
|
||||
node.Cache.Registry.GetRequired<CultMeshCdnArtifactChunk>().SchemaId
|
||||
],
|
||||
recordKeys: artifact.RecordKeys.Select(key => key.Value));
|
||||
|
||||
var response = registry.CreateRawSnapshotResponse(node.Cache, "cdn-response", request);
|
||||
```
|
||||
|
||||
The TypeScript client should mirror the C# schema names, schema versions,
|
||||
record-key rules, chunk hashing, and verification order. C# remains the
|
||||
reference implementation.
|
||||
|
||||
## Entity Prefabs
|
||||
|
||||
Entity prefabs are distributed as Brokkr-authored snapshots, then deployed into
|
||||
portable CultMesh prefab packages. Brokkr's Blender UI publishes
|
||||
`brokkr.prefab.snapshot.v0` records for a collection-scoped prefab. A deployer
|
||||
can consume that snapshot, publish meshes/textures/material payloads as CDN
|
||||
artifacts, and write a `CultMeshEntityPrefabPackage` that references those
|
||||
artifact manifests.
|
||||
|
||||
That keeps Blender as the authoring ground truth while avoiding Unity prefab
|
||||
files as the interchange format. Unity, TypeScript, and later runtimes unpack
|
||||
the same package graph into their native entity/component model.
|
||||
|
||||
## Client Responsibilities
|
||||
|
||||
Unity and TypeScript clients should implement the same rules:
|
||||
|
||||
- Fetch prefab packages by record key, tag, or version catalog.
|
||||
- Fetch every referenced artifact manifest and chunk.
|
||||
- Verify signed manifests before trusting package membership.
|
||||
- Verify chunk hashes and full artifact hashes before import.
|
||||
- Cache artifacts locally by content hash.
|
||||
- Lower package nodes into local runtime objects without writing back Unity-owned
|
||||
or Electron-owned prefab truth.
|
||||
- Respect `MeshAssetSharingPolicy` for peer upload, status telemetry, and
|
||||
opt-out behavior.
|
||||
123
tests/GameCult.Mesh.Tests/CultMeshCdnTests.cs
Normal file
123
tests/GameCult.Mesh.Tests/CultMeshCdnTests.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Networking;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Mesh.Tests;
|
||||
|
||||
public sealed class CultMeshCdnTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Artifact_PacksPublishesAndReassembles_FromCultCache()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var payload = Encoding.UTF8.GetBytes("abcdefghijklmnopqrstuvwxyz");
|
||||
var artifact = CultMesh.PackCdnArtifact(
|
||||
"aetheria/ui/icons/minimap-pack",
|
||||
payload,
|
||||
new CultMeshCdnPackOptions
|
||||
{
|
||||
ChunkSizeBytes = 8,
|
||||
Version = "2026.06.27",
|
||||
MimeType = "application/octet-stream",
|
||||
Tags = ["aetheria", "ui"],
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["channel"] = "dev"
|
||||
}
|
||||
});
|
||||
|
||||
artifact.Manifest.Kind.Should().Be(CultMeshCdnArtifactKinds.Asset);
|
||||
artifact.Manifest.SizeBytes.Should().Be(payload.Length);
|
||||
artifact.Manifest.Chunks.Should().HaveCount(4);
|
||||
artifact.Manifest.Chunks.Select(chunk => chunk.Offset).Should().Equal(0, 8, 16, 24);
|
||||
artifact.Manifest.Chunks.Select(chunk => chunk.SizeBytes).Should().Equal(8, 8, 8, 2);
|
||||
|
||||
var manifest = await CultMesh.PublishCdnArtifactAsync(cache, artifact);
|
||||
var manifestKey = CultMeshCdnArtifactManifest.CreateRecordKey(manifest);
|
||||
cache.Get<CultMeshCdnArtifactManifest>(manifestKey).Should().NotBeNull();
|
||||
|
||||
var roundTrip = CultMesh.ReadCdnArtifact(cache, manifest);
|
||||
roundTrip.Should().Equal(payload);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BuildArtifact_Replicates_ThroughCultNetRawSnapshot()
|
||||
{
|
||||
var sourceCache = new CultCache();
|
||||
var targetCache = new CultCache();
|
||||
var registry = CultMesh.CreateCdnDocumentRegistry(sourceCache.Registry);
|
||||
var payload = Encoding.UTF8.GetBytes("starbridge-build-update");
|
||||
var artifact = CultMesh.PackCdnArtifact(
|
||||
"starbridge/windows/client",
|
||||
payload,
|
||||
new CultMeshCdnPackOptions
|
||||
{
|
||||
ChunkSizeBytes = 7,
|
||||
Kind = CultMeshCdnArtifactKinds.Build,
|
||||
Version = "0.4.0-preview",
|
||||
Tags = ["starbridge", "build"],
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["platform"] = "windows-x64"
|
||||
}
|
||||
});
|
||||
var manifest = await CultMesh.PublishCdnArtifactAsync(sourceCache, artifact);
|
||||
var manifestSchemaId = sourceCache.Registry.GetRequired<CultMeshCdnArtifactManifest>().SchemaId;
|
||||
var chunkSchemaId = sourceCache.Registry.GetRequired<CultMeshCdnArtifactChunk>().SchemaId;
|
||||
var recordKeys = artifact.RecordKeys.Select(key => key.Value).ToArray();
|
||||
|
||||
var request = registry.CreateSnapshotRequest(
|
||||
"cdn-snapshot-request",
|
||||
schemaIds: [manifestSchemaId, chunkSchemaId],
|
||||
recordKeys: recordKeys);
|
||||
var response = registry.CreateRawSnapshotResponse(sourceCache, "cdn-snapshot", request);
|
||||
|
||||
response.Documents.Should().HaveCount(artifact.Chunks.Count + 1);
|
||||
response.Documents.Select(document => document.RecordKey).Should().BeEquivalentTo(recordKeys);
|
||||
|
||||
await registry.ApplyRawSnapshotResponseAsync(targetCache, response);
|
||||
var replicatedManifest = targetCache.Get<CultMeshCdnArtifactManifest>(
|
||||
CultMeshCdnArtifactManifest.CreateRecordKey(manifest));
|
||||
replicatedManifest.Should().NotBeNull();
|
||||
replicatedManifest!.Kind.Should().Be(CultMeshCdnArtifactKinds.Build);
|
||||
replicatedManifest.Metadata["platform"].Should().Be("windows-x64");
|
||||
|
||||
var roundTrip = CultMesh.ReadCdnArtifact(targetCache, replicatedManifest);
|
||||
roundTrip.Should().Equal(payload);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ArtifactRead_RejectsTamperedChunkPayload()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var artifact = CultMesh.PackCdnArtifact(
|
||||
"aetheria/gravity/layer0",
|
||||
[1, 1, 2, 3, 5, 8],
|
||||
new CultMeshCdnPackOptions
|
||||
{
|
||||
ChunkSizeBytes = 3,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["channels"] = "r32"
|
||||
}
|
||||
});
|
||||
var manifest = await CultMesh.PublishCdnArtifactAsync(cache, artifact);
|
||||
var firstChunk = artifact.Chunks[0];
|
||||
firstChunk.Payload[0] = 99;
|
||||
await cache.UpsertAsync(
|
||||
firstChunk,
|
||||
new CultRecordHandle<CultMeshCdnArtifactChunk>(
|
||||
CultMeshCdnArtifactChunk.CreateRecordKey(firstChunk)));
|
||||
|
||||
Action read = () => CultMesh.ReadCdnArtifact(cache, manifest);
|
||||
read.Should().Throw<InvalidDataException>()
|
||||
.WithMessage("*chunk hash*");
|
||||
}
|
||||
}
|
||||
197
tests/GameCult.Mesh.Tests/CultMeshEntityPrefabTests.cs
Normal file
197
tests/GameCult.Mesh.Tests/CultMeshEntityPrefabTests.cs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using GameCult.Caching;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Mesh.Tests;
|
||||
|
||||
public sealed class CultMeshEntityPrefabTests
|
||||
{
|
||||
[Test]
|
||||
public async Task EntityPrefabPackage_PublishesBrokkrAuthoredGraph_WithCdnAssetRefs()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var meshArtifact = CultMesh.PackCdnArtifact(
|
||||
"aetheria/prefabs/ships/scout/mesh/body",
|
||||
Encoding.UTF8.GetBytes("portable-mesh-bytes"),
|
||||
new CultMeshCdnPackOptions
|
||||
{
|
||||
Version = "authoring-1",
|
||||
MimeType = "model/gltf-binary",
|
||||
Tags = ["brokkr", "mesh"],
|
||||
});
|
||||
var textureArtifact = CultMesh.PackCdnArtifact(
|
||||
"aetheria/prefabs/ships/scout/texture/albedo",
|
||||
Encoding.UTF8.GetBytes("texture-bytes"),
|
||||
new CultMeshCdnPackOptions
|
||||
{
|
||||
Version = "authoring-1",
|
||||
MimeType = "image/png",
|
||||
Tags = ["brokkr", "texture"],
|
||||
});
|
||||
await CultMesh.PublishCdnArtifactAsync(cache, meshArtifact);
|
||||
await CultMesh.PublishCdnArtifactAsync(cache, textureArtifact);
|
||||
|
||||
var package = CultMesh.FinalizeEntityPrefabPackage(new CultMeshEntityPrefabPackage
|
||||
{
|
||||
PrefabId = "aetheria/entity/ship/scout",
|
||||
Version = "authoring-1",
|
||||
SourceTool = "brokkr.blender",
|
||||
SourceScene = "ships.blend",
|
||||
SourceCollection = "prefab.ship.scout",
|
||||
Tags = ["aetheria", "ship", "brokkr"],
|
||||
Assets =
|
||||
[
|
||||
AssetRef("mesh.body", CultMeshEntityPrefabAssetRoles.Mesh, meshArtifact.Manifest),
|
||||
AssetRef("texture.albedo", CultMeshEntityPrefabAssetRoles.Texture, textureArtifact.Manifest)
|
||||
],
|
||||
Nodes =
|
||||
[
|
||||
new CultMeshEntityPrefabNode
|
||||
{
|
||||
NodeId = "root",
|
||||
Name = "Scout",
|
||||
Kind = CultMeshEntityPrefabNodeKinds.Empty,
|
||||
Tags = ["entity-root"],
|
||||
Components =
|
||||
[
|
||||
new CultMeshEntityPrefabComponent
|
||||
{
|
||||
Type = "aetheria.ship",
|
||||
Properties = new Dictionary<string, string>
|
||||
{
|
||||
["hullClass"] = "scout"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
new CultMeshEntityPrefabNode
|
||||
{
|
||||
NodeId = "body",
|
||||
Name = "Body",
|
||||
ParentNodeId = "root",
|
||||
Kind = CultMeshEntityPrefabNodeKinds.Mesh,
|
||||
MeshAssetId = "mesh.body",
|
||||
MaterialAssetIds = ["texture.albedo"],
|
||||
Transform = new CultMeshEntityPrefabTransform
|
||||
{
|
||||
Position = [0f, 0f, 0f],
|
||||
Rotation = [0f, 0f, 0f, 1f],
|
||||
Scale = [1f, 1f, 1f]
|
||||
}
|
||||
}
|
||||
],
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["authoringAxis"] = "blender",
|
||||
["runtimeLowering"] = "unity,ts"
|
||||
}
|
||||
});
|
||||
|
||||
await CultMesh.PublishEntityPrefabPackageAsync(cache, package);
|
||||
|
||||
var key = CultMeshEntityPrefabPackage.CreateRecordKey(package);
|
||||
var stored = cache.Get<CultMeshEntityPrefabPackage>(key);
|
||||
stored.Should().NotBeNull();
|
||||
stored!.SourceTool.Should().Be("brokkr.blender");
|
||||
stored.SourceCollection.Should().Be("prefab.ship.scout");
|
||||
stored.Nodes.Should().Contain(node => node.Kind == CultMeshEntityPrefabNodeKinds.Mesh);
|
||||
stored.Assets.Select(asset => asset.CdnManifestRecordKey)
|
||||
.Should()
|
||||
.Contain(meshArtifact.ManifestKey.Value)
|
||||
.And.Contain(textureArtifact.ManifestKey.Value);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EntityPrefabPackage_ReplicatesWithReferencedCdnArtifacts_ThroughRawSnapshot()
|
||||
{
|
||||
var sourceCache = new CultCache();
|
||||
var targetCache = new CultCache();
|
||||
var registry = CultMesh.CreateAssetPipelineDocumentRegistry(sourceCache.Registry);
|
||||
var meshArtifact = CultMesh.PackCdnArtifact(
|
||||
"aetheria/prefabs/projectiles/bolt/mesh",
|
||||
Encoding.UTF8.GetBytes("bolt-mesh"),
|
||||
new CultMeshCdnPackOptions { Version = "v1", MimeType = "model/gltf-binary" });
|
||||
await CultMesh.PublishCdnArtifactAsync(sourceCache, meshArtifact);
|
||||
|
||||
var package = CultMesh.FinalizeEntityPrefabPackage(new CultMeshEntityPrefabPackage
|
||||
{
|
||||
PrefabId = "aetheria/entity/projectile/bolt",
|
||||
Version = "v1",
|
||||
SourceTool = "brokkr.blender",
|
||||
SourceScene = "projectiles.blend",
|
||||
SourceCollection = "prefab.projectile.bolt",
|
||||
Assets = [AssetRef("mesh", CultMeshEntityPrefabAssetRoles.Mesh, meshArtifact.Manifest)],
|
||||
Nodes =
|
||||
[
|
||||
new CultMeshEntityPrefabNode
|
||||
{
|
||||
NodeId = "bolt",
|
||||
Name = "Bolt",
|
||||
Kind = CultMeshEntityPrefabNodeKinds.Mesh,
|
||||
MeshAssetId = "mesh",
|
||||
Components =
|
||||
[
|
||||
new CultMeshEntityPrefabComponent
|
||||
{
|
||||
Type = "aetheria.projectile",
|
||||
Properties = new Dictionary<string, string>
|
||||
{
|
||||
["damage"] = "10"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
await CultMesh.PublishEntityPrefabPackageAsync(sourceCache, package);
|
||||
|
||||
var packageSchema = sourceCache.Registry.GetRequired<CultMeshEntityPrefabPackage>().SchemaId;
|
||||
var manifestSchema = sourceCache.Registry.GetRequired<CultMeshCdnArtifactManifest>().SchemaId;
|
||||
var chunkSchema = sourceCache.Registry.GetRequired<CultMeshCdnArtifactChunk>().SchemaId;
|
||||
var recordKeys = new[] { CultMeshEntityPrefabPackage.CreateRecordKey(package) }
|
||||
.Concat(meshArtifact.RecordKeys)
|
||||
.Select(key => key.Value)
|
||||
.ToArray();
|
||||
|
||||
var request = registry.CreateSnapshotRequest(
|
||||
"entity-prefab-request",
|
||||
schemaIds: [packageSchema, manifestSchema, chunkSchema],
|
||||
recordKeys: recordKeys);
|
||||
var response = registry.CreateRawSnapshotResponse(sourceCache, "entity-prefab-response", request);
|
||||
|
||||
response.Documents.Should().HaveCount(meshArtifact.Chunks.Count + 2);
|
||||
await registry.ApplyRawSnapshotResponseAsync(targetCache, response);
|
||||
|
||||
var replicatedPackage = targetCache.Get<CultMeshEntityPrefabPackage>(
|
||||
CultMeshEntityPrefabPackage.CreateRecordKey(package));
|
||||
replicatedPackage.Should().NotBeNull();
|
||||
replicatedPackage!.Nodes.Single().Components.Single().Type.Should().Be("aetheria.projectile");
|
||||
|
||||
var meshManifest = targetCache.Get<CultMeshCdnArtifactManifest>(meshArtifact.ManifestKey);
|
||||
meshManifest.Should().NotBeNull();
|
||||
CultMesh.ReadCdnArtifact(targetCache, meshManifest!).Should().Equal(Encoding.UTF8.GetBytes("bolt-mesh"));
|
||||
}
|
||||
|
||||
private static CultMeshEntityPrefabAssetRef AssetRef(
|
||||
string assetId,
|
||||
string role,
|
||||
CultMeshCdnArtifactManifest manifest)
|
||||
{
|
||||
return new CultMeshEntityPrefabAssetRef
|
||||
{
|
||||
AssetId = assetId,
|
||||
Role = role,
|
||||
CdnManifestRecordKey = CultMeshCdnArtifactManifest.CreateRecordKey(manifest).Value,
|
||||
ContentHash = manifest.ContentHash,
|
||||
MimeType = manifest.MimeType,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["sourceArtifactId"] = manifest.ArtifactId
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue