Add CultMesh distributed asset packages

This commit is contained in:
Meta Vens 2026-06-28 07:54:00 +01:00
commit 743d7688f0
7 changed files with 1617 additions and 0 deletions

View file

@ -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>

View 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;
}
}
}

View 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));
}
}
}

View file

@ -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)

View 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.

View 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*");
}
}

View 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
}
};
}
}