diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index ce71df4a5..3c4b18fb3 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -15,6 +15,20 @@ using System.Collections.Concurrent; var options = AetheriaDaemonHostOptions.Parse(args); var startedAtUtc = DateTimeOffset.UtcNow.ToString("O"); +var startup = Stopwatch.StartNew(); +var startupPhase = Stopwatch.StartNew(); +var traceStartupPhases = string.Equals( + Environment.GetEnvironmentVariable("AETHERIA_TRACE_STARTUP_PHASES"), + "1", + StringComparison.Ordinal); +void TraceStartup(string name) +{ + if (traceStartupPhases) + Console.WriteLine( + $"Aetheria startup phase {name} took {startupPhase.Elapsed.TotalMilliseconds:0.###}ms " + + $"(total {startup.Elapsed.TotalMilliseconds:0.###}ms)."); + startupPhase.Restart(); +} using var worldPhysics = new AetheriaYmirWorldPhysics(); var traceClientTransport = string.Equals( Environment.GetEnvironmentVariable("AETHERIA_TRACE_CLIENT_TRANSPORT"), @@ -32,25 +46,17 @@ await using var node = await AetheriaStateNode.OpenAsync( runtimeId: options.DaemonId, startServer: true, enableDurableShardLogs: false).ConfigureAwait(false); +TraceStartup("open-state-node"); using var discoveryHost = new AetheriaVerseDiscoveryHost(node); - -await EnsureWorldDocumentAsync(node).ConfigureAwait(false); -await EnsureTradeValuePolicyAsync(node, startedAtUtc).ConfigureAwait(false); -await AetheriaDaemonNativeCatalog.EnsureAsync(node).ConfigureAwait(false); -await node.FlushAsync().ConfigureAwait(false); -await EnsurePlayableRunDocumentsAsync(node, options, startedAtUtc).ConfigureAwait(false); -await EnsureGameSessionAsync(node, options, startedAtUtc).ConfigureAwait(false); -var verseHost = await EnsureVerseHostSettingsAsync(node, options, startedAtUtc).ConfigureAwait(false); -await EnsureVerseAuthorityPolicyAsync(node, options).ConfigureAwait(false); -discoveryHost.Update(verseHost); -await PublishRuntimeSessionAsync(node, options, startedAtUtc, "starting").ConfigureAwait(false); -await PublishStateSurfacesAsync(node, options, startedAtUtc).ConfigureAwait(false); var latestFrame = await node.MutableDocument(AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest).ReadAsync().ConfigureAwait(false); -using var physicsPersistence = await AetheriaYmirPersistenceCoordinator.OpenAsync( - node, - worldPhysics, - latestFrame).ConfigureAwait(false); -using var cultMeshClientHost = StartClientCultMeshHost(node, options, () => latestFrame); +TraceStartup("latest-frame"); +var unityBundles = BuildUnityBundleArtifactSet(options); +TraceStartup("provider-asset-bundles"); +using var cultMeshClientHost = StartClientCultMeshHost(node, options, unityBundles, () => latestFrame); +TraceStartup("client-host"); +Console.WriteLine($"Aetheria client CultMesh endpoint: {cultMeshClientHost.ControlEndpoint}"); +Console.WriteLine($"Aetheria client CDN endpoint: {cultMeshClientHost.ContentEndpoint}"); +Console.WriteLine($"Aetheria daemon transport-ready in {startup.Elapsed.TotalMilliseconds:0.###}ms."); using var clientSubscriptions = new CultNetDatabaseSubscriptionServer(cultMeshClientHost.Control, node.Database); var managedViewportDemand = new AetheriaManagedViewportDemandState(); clientSubscriptions.DemandChanged += managedViewportDemand.Observe; @@ -64,11 +70,39 @@ using var bodyDemand = new CultMeshBodyDemandTracker(clientSubscriptions); using var soaPublisher = new AetheriaRuntimeDaemonSoaFramePublisher( DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), bodyDemand); + +await EnsureWorldDocumentAsync(node).ConfigureAwait(false); +TraceStartup("world-document"); +await EnsureTradeValuePolicyAsync(node, startedAtUtc).ConfigureAwait(false); +TraceStartup("trade-policy"); +await AetheriaDaemonNativeCatalog.EnsureAsync(node).ConfigureAwait(false); +TraceStartup("native-catalog"); +await node.FlushAsync().ConfigureAwait(false); +TraceStartup("initial-flush"); +await EnsurePlayableRunDocumentsAsync(node, options, startedAtUtc).ConfigureAwait(false); +TraceStartup("playable-run"); +await EnsureGameSessionAsync(node, options, startedAtUtc).ConfigureAwait(false); +TraceStartup("game-session"); +var verseHost = await EnsureVerseHostSettingsAsync(node, options, startedAtUtc).ConfigureAwait(false); +TraceStartup("verse-host"); +await EnsureVerseAuthorityPolicyAsync(node, options).ConfigureAwait(false); +TraceStartup("authority-policy"); +discoveryHost.Update(verseHost); +await PublishRuntimeSessionAsync(node, options, startedAtUtc, "starting").ConfigureAwait(false); +TraceStartup("runtime-session"); +await PublishStateSurfacesAsync(node, options, startedAtUtc).ConfigureAwait(false); +TraceStartup("state-surfaces"); +using var physicsPersistence = await AetheriaYmirPersistenceCoordinator.OpenAsync( + node, + worldPhysics, + latestFrame).ConfigureAwait(false); +TraceStartup("ymir-persistence"); var nextApiPublicationUtc = DateTimeOffset.UtcNow; var ingressState = new AetheriaDaemonIngressState(); var hotState = new AetheriaHotEntityPublicationState(); var reactiveSurfaceState = new AetheriaReactiveSurfacePublicationState(); -var firstTick = await TickAsync(node, options, worldPhysics, latestFrame, ingressState, buildPublications: false).ConfigureAwait(false); +var firstTick = await TickAsync(node, options, unityBundles, worldPhysics, latestFrame, ingressState, buildPublications: false).ConfigureAwait(false); +TraceStartup("first-tick"); ThrowIfClientHostFaulted(cultMeshClientHost); latestFrame = firstTick.Frame; var firstPublication = PreparePublication( @@ -81,14 +115,16 @@ await PublishPreparedDocumentsAsync( node, options, physicsPersistence, + unityBundles, firstPublication, reactiveSurfaceState, publishTopology: true).ConfigureAwait(false); +TraceStartup("first-publication"); await PublishHotEntityStateAsync(node, soaPublisher, hotState, firstTick.Frame, ingressState.Catalog).ConfigureAwait(false); +TraceStartup("hot-entity-state"); nextApiPublicationUtc = DateTimeOffset.UtcNow.Add(options.ApiPublicationInterval); Console.WriteLine($"Aetheria Verse daemon published frame {firstTick.Frame.FrameId}."); -Console.WriteLine($"Aetheria client CultMesh endpoint: {cultMeshClientHost.ControlEndpoint}"); -Console.WriteLine($"Aetheria client CDN endpoint: {cultMeshClientHost.ContentEndpoint}"); +Console.WriteLine($"Aetheria daemon playable-world-ready in {startup.Elapsed.TotalMilliseconds:0.###}ms."); if (options.Once) { @@ -119,7 +155,7 @@ while (!stopped.Task.IsCompleted) } var buildPublications = DateTimeOffset.UtcNow >= nextApiPublicationUtc; - var tick = await TickAsync(node, options, worldPhysics, latestFrame, ingressState, buildPublications: false).ConfigureAwait(false); + var tick = await TickAsync(node, options, unityBundles, worldPhysics, latestFrame, ingressState, buildPublications: false).ConfigureAwait(false); ThrowIfClientHostFaulted(cultMeshClientHost); latestFrame = tick.Frame; await PublishHotEntityStateAsync(node, soaPublisher, hotState, tick.Frame, ingressState.Catalog).ConfigureAwait(false); @@ -140,6 +176,7 @@ while (!stopped.Task.IsCompleted) node, options, physicsPersistence, + unityBundles, publication, reactiveSurfaceState, publishTopology: false); @@ -171,6 +208,7 @@ static void ThrowIfClientHostFaulted(AetheriaClientCultMeshHost host) static async Task TickAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options, + AetheriaUnityBundleArtifactSet unityBundles, IAetheriaRuntimeWorldPhysics worldPhysics, AetheriaRuntimeDaemonFrameDocument? currentFrame, AetheriaDaemonIngressState ingressState, @@ -344,7 +382,7 @@ static async Task TickAsync( if (buildPublications) { - await PublishDaemonApiDocumentsAsync(node, options, result, publishTopology: true).ConfigureAwait(false); + await PublishDaemonApiDocumentsAsync(node, options, unityBundles, result, publishTopology: true).ConfigureAwait(false); TracePhase("api-publication"); await PublishStateSurfacesAsync(node, options, result.Frame.PublishedAtUtc).ConfigureAwait(false); TracePhase("state-surfaces"); @@ -393,6 +431,7 @@ static async Task PublishPreparedDocumentsAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options, AetheriaYmirPersistenceCoordinator physicsPersistence, + AetheriaUnityBundleArtifactSet unityBundles, AetheriaPreparedPublication prepared, AetheriaReactiveSurfacePublicationState reactiveSurfaceState, bool publishTopology) @@ -401,6 +440,7 @@ static async Task PublishPreparedDocumentsAsync( await PublishDaemonApiDocumentsAsync( node, options, + unityBundles, prepared.Publication, publishTopology, reactiveSurfaceState).ConfigureAwait(false); @@ -630,15 +670,19 @@ static async Task ImportRemoteCommittedFactsAsy static AetheriaClientCultMeshHost StartClientCultMeshHost( AetheriaStateNode node, AetheriaDaemonHostOptions options, + AetheriaUnityBundleArtifactSet unityBundles, Func latestFrame) { - var bundleCdnDocuments = BuildBundleCdnDocuments(node, options); + var contentCache = new CultCache(CultMesh.CreateCultCacheDocumentRegistry( + typeof(CultMeshCdnArtifactManifest), + typeof(CultMeshCdnArtifactChunk))); + var bundleCdnDocuments = BuildBundleCdnDocuments(node, contentCache, unityBundles, options.DaemonId); var server = new TcpFramedCultNetSchemaServer(new TcpListener( ParseBindAddress(options.ClientCultMeshHost), options.ClientCultMeshPort)); var content = new CultMeshTcpContentServer(new TcpListener( ParseBindAddress(options.ClientCultMeshHost), - options.ClientCultMeshContentPort), node.Cache); + options.ClientCultMeshContentPort), contentCache); var advertisedEndpoint = $"cultnet+tcp://{options.ClientCultMeshAdvertiseHost}:{server.LocalEndPoint.Port}"; var advertisedContentEndpoint = $"{CultMeshTcpContentTransportConnector.Scheme}://{options.ClientCultMeshAdvertiseHost}:{content.LocalEndPoint.Port}"; @@ -1092,6 +1136,7 @@ static AetheriaClientCultMeshHost StartClientCultMeshHost( return new AetheriaClientCultMeshHost( server, content, + contentCache, advertisedEndpoint, advertisedContentEndpoint); } @@ -1128,13 +1173,15 @@ static void InjectCultMeshCdnManifestSnapshots( static IReadOnlyDictionary BuildBundleCdnDocuments( AetheriaStateNode node, - AetheriaDaemonHostOptions options) + CultCache contentCache, + AetheriaUnityBundleArtifactSet unityBundles, + string daemonId) { var documents = new Dictionary(StringComparer.Ordinal); - foreach (var bundle in FindAssetBundles(options)) + foreach (var bundle in unityBundles.Bundles) { - var artifact = PackAssetBundle(bundle.Path, bundle.Platform); - CultMeshCdn.PublishAsync(node.Cache, artifact).GetAwaiter().GetResult(); + var artifact = bundle.Artifact; + CultMeshCdn.PublishAsync(contentCache, artifact).GetAwaiter().GetResult(); Add(artifact.ManifestKey, artifact.Manifest); } return documents; @@ -1147,7 +1194,7 @@ static IReadOnlyDictionary BuildBundleCdnDocum document, new CultNetDocumentMessageOptions { - SourceRuntimeId = options.DaemonId, + SourceRuntimeId = daemonId, SourceRole = "aetheria-cultmesh-cdn", Tags = ["aetheria", "cultmesh-cdn", "asset-bundle"] }); @@ -1596,6 +1643,7 @@ static async Task PublishHotEntityStateAsync( static async Task PublishDaemonApiDocumentsAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options, + AetheriaUnityBundleArtifactSet unityBundles, AetheriaRuntimeDaemonTickResult result, bool publishTopology, AetheriaReactiveSurfacePublicationState? reactiveSurfaceState = null) @@ -1628,7 +1676,7 @@ static async Task PublishDaemonApiDocumentsAsync( .ReplaceAsync(result.AssetManifest) .ConfigureAwait(false); await node.MutableDocument(AetheriaRuntimeVerseRecordKeys.EveAssetCatalog) - .ReplaceAsync(BuildCoreAssetCatalog(options, result.AssetManifest)) + .ReplaceAsync(BuildCoreAssetCatalog(unityBundles, result.AssetManifest)) .ConfigureAwait(false); } if (result.StarbridgeSessionSummary != null) @@ -2251,50 +2299,51 @@ static async Task PublishOdinSurfaceAnnouncementsAsync( } static EveAssetCatalogDocument BuildCoreAssetCatalog( - AetheriaDaemonHostOptions options, + AetheriaUnityBundleArtifactSet unityBundles, AetheriaRuntimeAssetManifestDocument source) { - var variants = FindAssetBundles(options).Select(bundle => - { - var artifact = PackAssetBundle(bundle.Path, bundle.Platform); - return new - { - bundle.Platform, - Uri = artifact.ManifestKey.Value, - Hash = $"sha256:{artifact.Manifest.ContentHash}", - Size = artifact.Manifest.SizeBytes - }; - }).ToArray(); + var variants = unityBundles.Bundles; var assets = (source.Assets ?? Array.Empty()) .Where(entry => entry?.Ref != null && (entry.Ref.Metadata.TryGetValue("unityAssetPath", out _) || - string.Equals(entry.Ref.Kind, AetheriaRuntimeAssetKinds.Prefab, StringComparison.Ordinal) && entry.Ref.Metadata.TryGetValue("resourcesPath", out _))) .Select(entry => { - var unityAssetPath = entry.Ref.Metadata.TryGetValue("bundleAssetPath", out var bundleAssetPath) - ? bundleAssetPath - : entry.Ref.Metadata.TryGetValue("unityAssetPath", out var explicitPath) - ? explicitPath - : $"Assets/Resources/{entry.Ref.Metadata["resourcesPath"]}.prefab"; + var bundleName = AetheriaRuntimeAssets.ResolveUnityBundleName(entry); + var entryVariants = variants + .Where(bundle => string.Equals(bundle.BundleName, bundleName, StringComparison.Ordinal)) + .Select(bundle => + { + var dependencyUris = bundle.Dependencies.Select(dependency => variants.Single(candidate => + string.Equals(candidate.Platform, bundle.Platform, StringComparison.Ordinal) && + string.Equals(candidate.BundleName, dependency, StringComparison.Ordinal)).Artifact.ManifestKey.Value) + .ToArray(); + var unityAssetPath = ResolveUnityBundleAssetKey(entry, bundle.Assets); + return new EveAssetVariant( + "unity-scene", + bundle.Platform, + "unity-assetbundle", + bundle.Artifact.ManifestKey.Value, + $"sha256:{bundle.Artifact.Manifest.ContentHash}", + bundle.Artifact.Manifest.SizeBytes, + unityAssetPath, + new Dictionary( + entry.Ref.Metadata + .Where(pair => pair.Key.StartsWith("unity.", StringComparison.Ordinal)) + .Append(new KeyValuePair("unity.bundleName", bundle.BundleName)) + .Append(new KeyValuePair("unity.bundleDependencyUris", string.Join(";", dependencyUris))) + .Append(new KeyValuePair("renderChannel.map.unityLayer", "14")), + StringComparer.Ordinal)); + }) + .ToArray(); + if (entryVariants.Length == 0) + throw new InvalidOperationException( + $"No built Unity bundle named '{bundleName}' contains advertised asset '{entry.Ref.AssetKey}'."); return new EveAssetCatalogEntry( entry.Ref.AssetKey, entry.Ref.Kind, - variants.Select(bundle => new EveAssetVariant( - "unity-scene", - bundle.Platform, - "unity-assetbundle", - bundle.Uri, - bundle.Hash, - bundle.Size, - unityAssetPath, - new Dictionary( - entry.Ref.Metadata - .Where(pair => pair.Key.StartsWith("unity.", StringComparison.Ordinal)) - .Append(new KeyValuePair("renderChannel.map.unityLayer", "14")), - StringComparer.Ordinal))) - .ToArray(), + entryVariants, new Dictionary(StringComparer.Ordinal) { ["mimeType"] = entry.Ref.MimeType ?? "", @@ -2322,28 +2371,104 @@ static long AssetCatalogVersion(string publishedAtUtc) : DateTimeOffset.UtcNow.UtcDateTime.Ticks; } -static IReadOnlyList<(string Path, string Platform)> FindAssetBundles(AetheriaDaemonHostOptions options) +static string ResolveUnityBundleAssetKey( + AetheriaRuntimeAssetManifestEntry entry, + IReadOnlyList bundleAssets) { - const string bundleName = "aetheria-world"; - return Directory.Exists(options.AssetBundleRoot) - ? Directory.GetFiles(options.AssetBundleRoot, bundleName, SearchOption.AllDirectories) - .OrderBy(path => path, StringComparer.Ordinal) - .Select(path => (path, new DirectoryInfo(Path.GetDirectoryName(path)!).Name)) - .ToArray() - : Array.Empty<(string, string)>(); + if (entry.Ref.Metadata.TryGetValue("bundleAssetPath", out var bundleAssetPath)) + return bundleAssetPath; + if (entry.Ref.Metadata.TryGetValue("unityAssetPath", out var explicitPath)) + return explicitPath; + if (!entry.Ref.Metadata.TryGetValue("resourcesPath", out var resourcesPath)) + throw new InvalidOperationException($"Asset '{entry.Ref.AssetKey}' has no Unity asset path."); + + var expected = $"Assets/Resources/{resourcesPath}".Replace('\\', '/'); + var resolved = bundleAssets.FirstOrDefault(path => string.Equals( + Path.ChangeExtension(path, null)?.Replace('\\', '/'), + expected, + StringComparison.OrdinalIgnoreCase)); + return resolved ?? throw new InvalidOperationException( + $"Bundle asset for '{entry.Ref.AssetKey}' was not found below '{expected}'."); } -static CultMeshCdnArtifact PackAssetBundle(string path, string platform) +static (IReadOnlyList Assets, IReadOnlyList Dependencies) ReadUnityBundleSidecar(string bundlePath) +{ + var manifestPath = bundlePath + ".manifest"; + if (!File.Exists(manifestPath)) + throw new FileNotFoundException("Unity bundle sidecar manifest is missing.", manifestPath); + var assets = new List(); + var dependencies = new List(); + var section = ""; + foreach (var line in File.ReadLines(manifestPath)) + { + var trimmed = line.Trim(); + if (trimmed == "Assets:") { section = "assets"; continue; } + if (trimmed.StartsWith("Dependencies:", StringComparison.Ordinal)) + { + section = "dependencies"; + if (trimmed == "Dependencies: []") section = ""; + continue; + } + if (!trimmed.StartsWith("- ", StringComparison.Ordinal)) continue; + if (section == "assets") assets.Add(trimmed.Substring(2)); + else if (section == "dependencies") + dependencies.Add(Path.GetFileName(trimmed.Substring(2).Replace('\\', '/'))); + } + return (assets, dependencies); +} + +static IReadOnlyList<(string Path, string Platform, string BundleName)> FindAssetBundles(AetheriaDaemonHostOptions options) +{ + return Directory.Exists(options.AssetBundleRoot) + ? Directory.GetFiles(options.AssetBundleRoot, "aetheria-*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".manifest", StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => ( + path, + new DirectoryInfo(Path.GetDirectoryName(path)!).Name, + Path.GetFileName(path))) + .ToArray() + : Array.Empty<(string, string, string)>(); +} + +static AetheriaUnityBundleArtifactSet BuildUnityBundleArtifactSet(AetheriaDaemonHostOptions options) +{ + var bundles = FindAssetBundles(options) + .Select(bundle => + { + var sidecar = ReadUnityBundleSidecar(bundle.Path); + return new AetheriaUnityBundleArtifact( + bundle.Path, + bundle.Platform, + bundle.BundleName, + sidecar.Assets, + sidecar.Dependencies, + PackAssetBundle(bundle.Path, bundle.Platform, bundle.BundleName)); + }) + .ToArray(); + foreach (var bundle in bundles) + foreach (var dependency in bundle.Dependencies) + { + if (!bundles.Any(candidate => + string.Equals(candidate.Platform, bundle.Platform, StringComparison.Ordinal) && + string.Equals(candidate.BundleName, dependency, StringComparison.Ordinal))) + throw new InvalidOperationException( + $"Unity bundle '{bundle.BundleName}' depends on missing bundle '{dependency}' for {bundle.Platform}."); + } + return new AetheriaUnityBundleArtifactSet(bundles); +} + +static CultMeshCdnArtifact PackAssetBundle(string path, string platform, string bundleName) { return CultMeshCdn.PackArtifact( - $"aetheria/world/{platform}/aetheria-world", + $"aetheria/bundles/{platform}/{bundleName}", File.ReadAllBytes(path), new CultMeshCdnPackOptions { Kind = CultMeshCdnArtifactKinds.Asset, Version = "1", MimeType = "application/vnd.unity.assetbundle", - Tags = ["aetheria", "unity-scene", platform] + Tags = ["aetheria", "unity-scene", platform, bundleName] }); } @@ -2588,6 +2713,13 @@ static async Task EnsurePlayableRunDocumentsAsync( AetheriaDaemonHostOptions options, string now) { + if (options.UseTerminusFixture) + { + await AetheriaDaemonZoneGenerator.WritePlayableRunAsync( + node, node.RuntimeCatalog().Latest(), now, options.TerminusScenario).ConfigureAwait(false); + return; + } + var settings = await node.MutableDocument(AetheriaStateNode.PlayerSettingsKey).ReadAsync().ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(settings?.ActiveRunKey)) { @@ -2596,12 +2728,6 @@ static async Task EnsurePlayableRunDocumentsAsync( return; } - if (options.UseTerminusFixture) - { - await AetheriaDaemonZoneGenerator.WritePlayableRunAsync( - node, node.RuntimeCatalog().Latest(), now, options.TerminusScenario).ConfigureAwait(false); - return; - } await AetheriaDaemonRunFactory.WriteAsync( node, node.RuntimeCatalog().Latest(), now, now).ConfigureAwait(false); } @@ -3355,28 +3481,68 @@ internal sealed class AetheriaDaemonIngressState } } +internal sealed class AetheriaUnityBundleArtifactSet +{ + public AetheriaUnityBundleArtifactSet(IReadOnlyList bundles) + { + Bundles = bundles ?? throw new ArgumentNullException(nameof(bundles)); + } + + public IReadOnlyList Bundles { get; } +} + +internal sealed class AetheriaUnityBundleArtifact +{ + public AetheriaUnityBundleArtifact( + string path, + string platform, + string bundleName, + IReadOnlyList assets, + IReadOnlyList dependencies, + CultMeshCdnArtifact artifact) + { + Path = path; + Platform = platform; + BundleName = bundleName; + Assets = assets; + Dependencies = dependencies; + Artifact = artifact; + } + + public string Path { get; } + public string Platform { get; } + public string BundleName { get; } + public IReadOnlyList Assets { get; } + public IReadOnlyList Dependencies { get; } + public CultMeshCdnArtifact Artifact { get; } +} + internal sealed class AetheriaClientCultMeshHost : IDisposable { public AetheriaClientCultMeshHost( TcpFramedCultNetSchemaServer control, CultMeshTcpContentServer content, + CultCache contentCache, string controlEndpoint, string contentEndpoint) { Control = control ?? throw new ArgumentNullException(nameof(control)); Content = content ?? throw new ArgumentNullException(nameof(content)); + ContentCache = contentCache ?? throw new ArgumentNullException(nameof(contentCache)); ControlEndpoint = controlEndpoint ?? throw new ArgumentNullException(nameof(controlEndpoint)); ContentEndpoint = contentEndpoint ?? throw new ArgumentNullException(nameof(contentEndpoint)); } public TcpFramedCultNetSchemaServer Control { get; } public CultMeshTcpContentServer Content { get; } + private CultCache ContentCache { get; } public string ControlEndpoint { get; } public string ContentEndpoint { get; } public void Dispose() { Content.Dispose(); + ContentCache.Dispose(); Control.Dispose(); } } diff --git a/Aetheria.State/Aetheria.State.csproj b/Aetheria.State/Aetheria.State.csproj index c75f3560f..e1c853c98 100644 --- a/Aetheria.State/Aetheria.State.csproj +++ b/Aetheria.State/Aetheria.State.csproj @@ -16,7 +16,8 @@ - + diff --git a/Aetheria.Unity/Packages/manifest.json b/Aetheria.Unity/Packages/manifest.json index 79de95757..d76d2b1d5 100644 --- a/Aetheria.Unity/Packages/manifest.json +++ b/Aetheria.Unity/Packages/manifest.json @@ -7,9 +7,9 @@ "com.unity.modules.physics": "1.0.0", "com.unity.modules.uielements": "1.0.0", "com.unity.render-pipelines.universal": "17.4.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.34", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.35", "org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", "org.gamecult.eve.surface": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.2.4", - "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.94" + "org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.96" } } diff --git a/Aetheria.Unity/Packages/packages-lock.json b/Aetheria.Unity/Packages/packages-lock.json index f60868577..023c4a7fc 100644 --- a/Aetheria.Unity/Packages/packages-lock.json +++ b/Aetheria.Unity/Packages/packages-lock.json @@ -127,11 +127,11 @@ } }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.34", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.35", "depth": 0, "source": "git", "dependencies": {}, - "hash": "c148d891c1d8713285ae15b2f17b59c106fb9426" + "hash": "36a822d91416d7fecd4f3e67259186e45cf7cdaa" }, "org.gamecult.eve.plugin-fields": { "version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3", @@ -152,18 +152,18 @@ "hash": "e08fa08335f99e9edddeb706912eecfad07cb281" }, "org.gamecult.eve.unity-scene": { - "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.94", + "version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.96", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.34", + "org.gamecult.cultlib": "1.0.35", "org.gamecult.eve.plugin-fields": "0.2.3", "org.gamecult.eve.surface": "0.2.4", "com.unity.mathematics": "1.3.2", "com.unity.render-pipelines.universal": "17.4.0", "com.unity.modules.particlesystem": "1.0.0" }, - "hash": "0463ddff4e39c9cca14e632b7e46b6b0081c67cc" + "hash": "3da246aba8ee24af1c2528fe1c81b41a51a41c64" }, "com.unity.modules.assetbundle": { "version": "1.0.0", diff --git a/Assets/Editor/EveAssetBundleBuilder.cs b/Assets/Editor/EveAssetBundleBuilder.cs index fa989a9fd..7e53b8f8c 100644 --- a/Assets/Editor/EveAssetBundleBuilder.cs +++ b/Assets/Editor/EveAssetBundleBuilder.cs @@ -12,7 +12,6 @@ namespace Aetheria.Editor { public static class EveAssetBundleBuilder { - public const string BundleName = "aetheria-world"; private const string ProviderMaterialsRoot = "Assets/Generated/Eve/ProviderMaterials"; public static void BuildWindows() @@ -23,7 +22,9 @@ namespace Aetheria.Editor public static void VerifyWindows() { var output = ResolveOutput(BuildTarget.StandaloneWindows64); - VerifyBundle(Path.Combine(output, BundleName)); + VerifyBundles(output, Directory.GetFiles(output, "aetheria-*", SearchOption.TopDirectoryOnly) + .Where(path => !path.EndsWith(".manifest", StringComparison.OrdinalIgnoreCase)) + .Select(Path.GetFileName)); } private static void Build(BuildTarget target) @@ -32,6 +33,8 @@ namespace Aetheria.Editor EveEnvironmentProfileMigrator.EnsureGenerated(); var output = ResolveOutput(target); Directory.CreateDirectory(output); + foreach (var stale in Directory.GetFiles(output, "*", SearchOption.TopDirectoryOnly)) + File.Delete(stale); var catalog = AetheriaRuntimeCatalogStore.OpenReadOnly( Path.GetFullPath(Path.Combine("GameData", "aetheria-world.cc"))); @@ -41,18 +44,26 @@ namespace Aetheria.Editor BuildPresentationPrefab(entry); AssetDatabase.SaveAssets(); - var assetNames = assets - .Select(entry => entry.Ref.Metadata.TryGetValue("bundleAssetPath", out var presentationPath) - ? presentationPath - : entry.Ref.Metadata.TryGetValue("unityAssetPath", out var explicitPath) - ? explicitPath - : "") - .Where(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path)) - .Distinct(StringComparer.Ordinal) - .OrderBy(path => path, StringComparer.Ordinal) + var bundleBuilds = assets + .Select(entry => new + { + BundleName = AetheriaRuntimeAssets.ResolveUnityBundleName(entry), + AssetPath = ResolveBundleAssetPath(entry) + }) + .Where(asset => !string.IsNullOrWhiteSpace(asset.AssetPath) && File.Exists(asset.AssetPath)) + .GroupBy(asset => asset.BundleName, StringComparer.Ordinal) + .Select(group => new AssetBundleBuild + { + assetBundleName = group.Key, + assetNames = group.Select(asset => asset.AssetPath) + .Distinct(StringComparer.Ordinal) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray() + }) + .OrderBy(build => build.assetBundleName, StringComparer.Ordinal) .ToArray(); - if (assetNames.Length == 0) - throw new InvalidOperationException("Aetheria Eve bundle has no provider Unity assets."); + if (bundleBuilds.Length == 0) + throw new InvalidOperationException("Aetheria Eve bundles have no provider Unity assets."); var authoredStardustMaterial = AssetDatabase.LoadAssetAtPath( "Assets/Shaders/Compute/Stardust/Stardust.mat"); AetheriaStardustContinuityVerifier.VerifyTemporalDitherMaterial( @@ -66,21 +77,15 @@ namespace Aetheria.Editor var manifest = BuildPipeline.BuildAssetBundles( output, - new[] - { - new AssetBundleBuild - { - assetBundleName = BundleName, - assetNames = assetNames - } - }, - BuildAssetBundleOptions.None, + bundleBuilds, + BuildAssetBundleOptions.ChunkBasedCompression, target); - if (manifest == null || !manifest.GetAllAssetBundles().Contains(BundleName, StringComparer.Ordinal)) - throw new InvalidOperationException("Unity did not emit the Aetheria Eve world bundle."); + var expectedBundleNames = bundleBuilds.Select(build => build.assetBundleName).ToArray(); + if (manifest == null || expectedBundleNames.Except(manifest.GetAllAssetBundles(), StringComparer.Ordinal).Any()) + throw new InvalidOperationException("Unity did not emit every advertised Aetheria Eve bundle."); - VerifyBundle(Path.Combine(output, BundleName)); - Console.WriteLine($"Aetheria Eve AssetBundle: {Path.Combine(output, BundleName)}"); + VerifyBundles(output, expectedBundleNames); + Console.WriteLine($"Aetheria Eve AssetBundles: {string.Join(", ", expectedBundleNames)}"); } private static string ResolveOutput(BuildTarget target) @@ -91,6 +96,26 @@ namespace Aetheria.Editor : output; } + private static string ResolveBundleAssetPath(AetheriaRuntimeAssetManifestEntry entry) + { + if (entry.Ref.Metadata.TryGetValue("bundleAssetPath", out var presentationPath)) + return presentationPath; + if (entry.Ref.Metadata.TryGetValue("unityAssetPath", out var explicitPath)) + return explicitPath; + if (!entry.Ref.Metadata.TryGetValue("resourcesPath", out var resourcesPath)) + return ""; + + var expectedWithoutExtension = $"Assets/Resources/{resourcesPath}".Replace('\\', '/'); + var directory = Path.GetDirectoryName(expectedWithoutExtension)?.Replace('\\', '/') ?? "Assets/Resources"; + var fileName = Path.GetFileName(expectedWithoutExtension); + return AssetDatabase.FindAssets(fileName, new[] { directory }) + .Select(AssetDatabase.GUIDToAssetPath) + .FirstOrDefault(candidate => string.Equals( + Path.ChangeExtension(candidate, null)?.Replace('\\', '/'), + expectedWithoutExtension, + StringComparison.OrdinalIgnoreCase)) ?? ""; + } + private static void BuildPresentationPrefab(AetheriaRuntimeAssetManifestEntry entry) { if (!entry.Ref.Metadata.TryGetValue("bundleAssetPath", out var outputPath)) @@ -604,44 +629,57 @@ namespace Aetheria.Editor $"Presentation prefab {assetPath} contains forbidden scripts:\n{string.Join("\n", violations)}"); } - private static void VerifyBundle(string bundlePath) + private static void VerifyBundles(string output, IEnumerable bundleNames) { - if (!File.Exists(bundlePath)) - throw new InvalidOperationException($"Aetheria Eve bundle does not exist: {bundlePath}"); - var bundle = AssetBundle.LoadFromFile(bundlePath); - if (bundle == null) - throw new InvalidOperationException($"Aetheria Eve bundle could not be loaded: {bundlePath}"); + var orderedNames = bundleNames + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.Ordinal) + .OrderBy(BundleLoadOrder) + .ThenBy(name => name, StringComparer.Ordinal) + .ToArray(); + var bundles = new List(); try { - var assetNames = bundle.GetAllAssetNames(); - foreach (var assetPath in assetNames.Where(path => path.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))) + foreach (var bundleName in orderedNames) + { + var bundlePath = Path.Combine(output, bundleName); + if (!File.Exists(bundlePath)) + throw new InvalidOperationException($"Aetheria Eve bundle does not exist: {bundlePath}"); + var bundle = AssetBundle.LoadFromFile(bundlePath); + if (bundle == null) + throw new InvalidOperationException($"Aetheria Eve bundle could not be loaded: {bundlePath}"); + bundles.Add(bundle); + } + foreach (var bundle in bundles) + foreach (var assetPath in bundle.GetAllAssetNames() + .Where(path => path.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))) VerifyPrefab(bundle.LoadAsset(assetPath), assetPath); - var reflection = LoadAuthoredAsset(bundle, assetNames, "Assets/Textures/studio2.hdr"); + var reflection = LoadAuthoredAsset(bundles, "Assets/Textures/studio2.hdr"); if (reflection == null) - throw new InvalidOperationException("Aetheria Eve bundle has no provider reflection cubemap."); - var postProcess = LoadAuthoredAsset(bundle, assetNames, EveEnvironmentProfileMigrator.FlightProfilePath); + throw new InvalidOperationException("Aetheria Eve bundles have no provider reflection cubemap."); + var postProcess = LoadAuthoredAsset(bundles, EveEnvironmentProfileMigrator.FlightProfilePath); VerifyFlightPostProcessProfile(postProcess); - var gravityFog = LoadAuthoredAsset(bundle, assetNames, "Assets/Shaders/Raymarching/CloudShader.shader"); + var gravityFog = LoadAuthoredAsset(bundles, "Assets/Shaders/Raymarching/CloudShader.shader"); if (gravityFog == null || !gravityFog.isSupported) - throw new InvalidOperationException("Aetheria Eve bundle has no supported gravity-fog volume shader."); + throw new InvalidOperationException("Aetheria Eve bundles have no supported gravity-fog volume shader."); AetheriaGravityFogVerifier.VerifyCameraProjection( gravityFog, requireAuthoredSource: false); - var dither = LoadAuthoredAsset(bundle, assetNames, "Assets/Resources/LDR_LLL1_0.png"); + var dither = LoadAuthoredAsset(bundles, "Assets/Resources/LDR_LLL1_0.png"); if (dither == null) throw new InvalidOperationException("Aetheria Eve bundle has no pre-generated volume dither texture."); - var stardustCompute = LoadAuthoredAsset(bundle, assetNames, + var stardustCompute = LoadAuthoredAsset(bundles, "Assets/Shaders/Compute/Stardust/Stardust.compute"); if (stardustCompute == null || !stardustCompute.HasKernel("UpdateParticles")) throw new InvalidOperationException("Aetheria Eve bundle has no supported Stardust update program."); - var stardustMaterial = LoadAuthoredAsset(bundle, assetNames, + var stardustMaterial = LoadAuthoredAsset(bundles, "Assets/Shaders/Compute/Stardust/Stardust.mat"); if (stardustMaterial == null || stardustMaterial.shader == null || !stardustMaterial.shader.isSupported) throw new InvalidOperationException("Aetheria Eve bundle has no supported Stardust render material."); AetheriaStardustContinuityVerifier.VerifyTemporalDitherMaterial( stardustMaterial, requireAuthoredSource: false); - var stardustColors = LoadAuthoredAsset(bundle, assetNames, + var stardustColors = LoadAuthoredAsset(bundles, "Assets/Resources/Gradients/blackbody.png"); if (stardustColors == null) throw new InvalidOperationException("Aetheria Eve bundle has no pre-generated Stardust color texture."); @@ -649,16 +687,29 @@ namespace Aetheria.Editor } finally { - bundle.Unload(true); + for (var index = bundles.Count - 1; index >= 0; index--) + bundles[index].Unload(true); } } - private static T LoadAuthoredAsset(AssetBundle bundle, IEnumerable assetNames, string authoredPath) + private static int BundleLoadOrder(string bundleName) + { + if (bundleName == AetheriaRuntimeAssets.UnityShaderBundle) return 0; + if (bundleName == AetheriaRuntimeAssets.UnityCoreBundle) return 1; + if (bundleName == AetheriaRuntimeAssets.UnityUiBundle) return 2; + return 3; + } + + private static T LoadAuthoredAsset(IEnumerable bundles, string authoredPath) where T : UnityEngine.Object { - var nativePath = assetNames.FirstOrDefault(path => - string.Equals(path, authoredPath, StringComparison.OrdinalIgnoreCase)); - return string.IsNullOrWhiteSpace(nativePath) ? null : bundle.LoadAsset(nativePath); + foreach (var bundle in bundles) + { + var nativePath = bundle.GetAllAssetNames().FirstOrDefault(path => + string.Equals(path, authoredPath, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(nativePath)) return bundle.LoadAsset(nativePath); + } + return null; } private static void VerifyFlightPostProcessProfile(VolumeProfile profile) diff --git a/Assets/Scripts/Tests/AetheriaRuntimeAssetBundleLayoutTests.cs b/Assets/Scripts/Tests/AetheriaRuntimeAssetBundleLayoutTests.cs new file mode 100644 index 000000000..5be0c9d89 --- /dev/null +++ b/Assets/Scripts/Tests/AetheriaRuntimeAssetBundleLayoutTests.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; +using GameCult.Aetheria.State.Verse; +using NUnit.Framework; + +public sealed class AetheriaRuntimeAssetBundleLayoutTests +{ + [Test] + public void SharedPresentationAssetsHaveStableBundleOwners() + { + var manifest = AetheriaRuntimeAssets.ProjectManifest(null); + + Assert.AreEqual(AetheriaRuntimeAssets.UnityShaderBundle, BundleFor("shader.environment.gravity-fog")); + Assert.AreEqual(AetheriaRuntimeAssets.UnityShaderBundle, BundleFor("texture.environment.volume-dither")); + Assert.AreEqual(AetheriaRuntimeAssets.UnityUiBundle, BundleFor("map.entity.player")); + Assert.AreEqual(AetheriaRuntimeAssets.UnityUiBundle, BundleFor("font.ui.primary")); + Assert.AreEqual(AetheriaRuntimeAssets.UnityCoreBundle, BundleFor("texture.environment.reflection")); + Assert.AreEqual(AetheriaRuntimeAssets.UnityCoreBundle, BundleFor("texture.core.sun.surface-flow")); + + string BundleFor(string assetKey) + { + var entry = manifest.Assets.Single(asset => + string.Equals(asset.Ref.AssetKey, assetKey, StringComparison.Ordinal)); + return AetheriaRuntimeAssets.ResolveUnityBundleName(entry); + } + } + + [Test] + public void EntityContentIsPartitionedByAuthoredSource() + { + var manifest = AetheriaRuntimeAssets.ProjectManifest(null); + var player = manifest.Assets.Single(asset => asset.Ref.AssetKey == "prefab.entity.player"); + var ship = manifest.Assets.Single(asset => asset.Ref.AssetKey == "prefab.entity.ship"); + var station = manifest.Assets.Single(asset => asset.Ref.AssetKey == "prefab.entity.station"); + + var playerBundle = AetheriaRuntimeAssets.ResolveUnityBundleName(player); + Assert.AreEqual(playerBundle, AetheriaRuntimeAssets.ResolveUnityBundleName(ship)); + Assert.AreNotEqual(playerBundle, AetheriaRuntimeAssets.ResolveUnityBundleName(station)); + StringAssert.StartsWith("aetheria-content-", playerBundle); + } +} diff --git a/Assets/Scripts/Tests/AetheriaRuntimeAssetBundleLayoutTests.cs.meta b/Assets/Scripts/Tests/AetheriaRuntimeAssetBundleLayoutTests.cs.meta new file mode 100644 index 000000000..0d43f2c7c --- /dev/null +++ b/Assets/Scripts/Tests/AetheriaRuntimeAssetBundleLayoutTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 20642d787904fb94aa7ae63109f417ce \ No newline at end of file diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssetDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssetDocuments.cs index 24e7611b6..08873a61b 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssetDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssetDocuments.cs @@ -18,6 +18,7 @@ namespace GameCult.Aetheria.State.Verse public const string Audio = "audio"; public const string Prefab = "prefab"; public const string VolumeProfile = "unity.volume-profile"; + public const string Font = "font"; } public static class AetheriaRuntimeAssetTransports diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs index eac39825f..83bdd039a 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssets.cs @@ -9,6 +9,52 @@ namespace GameCult.Aetheria.State.Verse public static class AetheriaRuntimeAssets { public const string PresentationHullItemTagPrefix = "presentation-hull-item:"; + public const string UnityShaderBundle = "aetheria-shaders"; + public const string UnityUiBundle = "aetheria-ui"; + public const string UnityCoreBundle = "aetheria-core"; + + public static string ResolveUnityBundleName(AetheriaRuntimeAssetManifestEntry entry) + { + if (entry?.Ref == null) + throw new ArgumentNullException(nameof(entry)); + + var key = entry.Ref.AssetKey ?? ""; + if (key.StartsWith("shader.", StringComparison.Ordinal) || + key.StartsWith("compute.", StringComparison.Ordinal) || + key == "material.environment.stardust" || + key == "texture.environment.stardust-colors" || + key == "texture.environment.volume-dither" || + key == "profile.environment.flight") + return UnityShaderBundle; + + if (key.StartsWith("map.", StringComparison.Ordinal) || + key.StartsWith("material.sector-map.", StringComparison.Ordinal) || + key.StartsWith("inventory.", StringComparison.Ordinal) || + key.StartsWith("profile.thermal.", StringComparison.Ordinal) || + key == "profile.death" || + key.StartsWith("item.", StringComparison.Ordinal) && key.EndsWith(".icon", StringComparison.Ordinal) || + key == "font.ui.primary") + return UnityUiBundle; + + if (!string.Equals(entry.Ref.Kind, AetheriaRuntimeAssetKinds.Prefab, StringComparison.Ordinal)) + return UnityCoreBundle; + + var source = entry.Ref.Metadata.TryGetValue("unityAssetPath", out var unityAssetPath) + ? unityAssetPath + : entry.Ref.Metadata.TryGetValue("resourcesPath", out var resourcesPath) + ? resourcesPath + : key; + var stem = string.Concat((source ?? key).Trim().ToLowerInvariant().Select(character => + char.IsLetterOrDigit(character) ? character : '-')); + while (stem.Contains("--")) stem = stem.Replace("--", "-"); + stem = stem.Trim('-'); + if (stem.StartsWith("assets-", StringComparison.Ordinal)) stem = stem.Substring("assets-".Length); + if (stem.StartsWith("resources-", StringComparison.Ordinal)) stem = stem.Substring("resources-".Length); + if (stem.EndsWith("-prefab", StringComparison.Ordinal)) stem = stem.Substring(0, stem.Length - "-prefab".Length); + const int maximumStemLength = 96; + if (stem.Length > maximumStemLength) stem = stem.Substring(stem.Length - maximumStemLength); + return $"aetheria-content-{stem}"; + } public static string ResolveEntityPrefabAssetRef( AetheriaRuntimeEntitySnapshotCommit entity, @@ -42,6 +88,8 @@ namespace GameCult.Aetheria.State.Verse Add(entries, EnvironmentStardustComputeShader()); Add(entries, EnvironmentStardustMaterial()); Add(entries, EnvironmentStardustColorTexture()); + foreach (var coreAsset in CorePresentationResources()) Add(entries, coreAsset); + Add(entries, UiPrimaryFont()); foreach (var mapAsset in SectorMapAssets()) Add(entries, mapAsset); Add(entries, MapIcon("entity.player", "Player", "Sprites/Icons/Stroked/Ship")); Add(entries, MapIcon("entity.ship", "Ship", "Sprites/Icons/Stroked/Ship")); @@ -467,6 +515,39 @@ namespace GameCult.Aetheria.State.Verse }; } + public static IReadOnlyList CorePresentationResources() + { + return new[] + { + MapPresentationAsset("texture.core.sun.surface-flow", AetheriaRuntimeAssetKinds.Texture, + "Sun surface flow", "Assets/Resources/Sun Flow/sunsurface1offset.png", "core.sun.surface-flow"), + MapPresentationAsset("texture.core.sun.albedo", AetheriaRuntimeAssetKinds.Texture, + "Sun albedo", "Assets/Resources/Sun Albedo/sunsurface2map.png", "core.sun.albedo"), + MapPresentationAsset("texture.core.fx.explosion-emission", AetheriaRuntimeAssetKinds.Texture, + "Explosion emission", "Assets/Prefabs/Fire & Explosion Effects/Textures/ExplosionEmission.tif", "core.fx.explosion-emission"), + MapPresentationAsset("texture.core.fx.smoke-puff", AetheriaRuntimeAssetKinds.Texture, + "Smoke puff", "Assets/Prefabs/Fire & Explosion Effects/Textures/SmokePuff01.tif", "core.fx.smoke-puff"), + MapPresentationAsset("texture.core.fx.explosion", AetheriaRuntimeAssetKinds.Texture, + "Explosion", "Assets/Prefabs/Fire & Explosion Effects/Textures/Explosion.tif", "core.fx.explosion"), + MapPresentationAsset("texture.core.fx.shockwave", AetheriaRuntimeAssetKinds.Texture, + "Shockwave", "Assets/Prefabs/Fire & Explosion Effects/Textures/shockwave.tif", "core.fx.shockwave"), + MapPresentationAsset("texture.core.fx.sphere-normal", AetheriaRuntimeAssetKinds.Texture, + "Sphere normal", "Assets/Prefabs/Fire & Explosion Effects/Textures/SphereNormal.tif", "core.fx.sphere-normal"), + MapPresentationAsset("texture.core.fx.debris", AetheriaRuntimeAssetKinds.Texture, + "Debris", "Assets/Prefabs/Fire & Explosion Effects/Textures/Debris.png", "core.fx.debris") + }; + } + + public static AetheriaRuntimeAssetManifestEntry UiPrimaryFont() + { + return MapPresentationAsset( + "font.ui.primary", + AetheriaRuntimeAssetKinds.Font, + "Primary UI font", + "Assets/Fonts/Ubuntu/Ubuntu-L Small SDF.asset", + "ui.font.primary"); + } + private static AetheriaRuntimeAssetManifestEntry MapIcon( string key, string label, diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 942c54dfb..61900cb7a 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -1886,24 +1886,37 @@ edges with one worker and exactly two start/transfer/completion event triplets. ### Daemon Asset Content Delivery -- Owner: the Aetheria daemon owns bundle content and serves bytes only through - the managed `cultmesh.content.v1` session hosted by CultMesh. -- Inputs: provider bundle path, packed artifact manifest, content hash, byte - length, negotiated chunk requests, and resumable client checkpoint. +- Owner: Aetheria's runtime asset manifest owns each authored asset's Unity + bundle assignment. The build emits `aetheria-shaders`, `aetheria-ui`, and + `aetheria-core` shared bundles plus content bundles grouped by authored entity + source. The daemon owns those artifacts and serves bytes only through the + managed `cultmesh.content.v1` session hosted by CultMesh. EveUnity knows no + Aetheria bundle taxonomy. +- Inputs: provider bundle paths, Unity dependency manifests, packed artifact + manifests, content hashes, byte lengths, negotiated chunk requests, and + resumable client checkpoints. - Outputs: snapshot delivery publishes the typed artifact manifest only; - content-session requests return bounded byte ranges which the generic runtime - verifies and atomically promotes to a hash-named `.body`, then binds to a - read-only mapped-body lease for Unity loading. + catalog variants name their owning artifact and dependency artifact URIs. + Publishing the catalog transfers no bundle. The generic runtime requests an + artifact only when presentation first resolves one of its asset keys, loads + dependencies first, verifies each body, atomically promotes it to a hash-named + `.body`, and binds it to a read-only mapped-body lease for Unity loading. - Derived state: Unity `AssetBundle` instances, native prefab lookup, cache files, progress UI, and screenshots cannot publish or mutate provider bytes. -- Forbidden writers: batched snapshot chunks and the former +- Forbidden writers: a monolithic world bundle, eager catalog materialization, + client-owned content categories, batched snapshot chunks, and the former `gamecult.cultmesh.cdn.asset_blob.v1` fallback are deleted. Eve surfaces and renderer code cannot smuggle bundle bytes through state records. -- Shared paths: cold transfer, resume, and warm cache reuse all resolve the same - advertised manifest and content hash through the generic runtime transport. +- Shared paths: prefab, shader, texture, font, and profile resolution all use the + same dependency-aware artifact loader. Cold transfer, resume, and warm cache + reuse resolve the same advertised manifests and hashes through the generic + runtime transport. - Cut line: CultMesh snapshots index manifests; managed content sessions own remote bytes, verification, and promotion. Mapped-body negotiation begins only - at the returned final path and cannot map partials or reconstruct cache names. + at first asset demand and at the returned final path; it cannot map partials or + reconstruct cache names. Blender-authored native entity reconstruction remains + the intended successor to Unity prefab content and is not simulated by this + bundle partition. - Owner: `Aetheria.State` owns the new typed state spine for durable state. The embedded `GameCult.Aetheria.State.Unity` package owns Unity's runtime