From b4b16867eec988f3fabad4886a9d5d58fc82e053 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 20 Jul 2026 01:52:05 +0100 Subject: [PATCH] Hydrate only Aetheria daemon working state --- .../AetheriaYmirPersistenceCoordinator.cs | 70 ++++++++++ Aetheria.State.Daemon/Program.cs | 81 +++++++++++- Aetheria.State/AetheriaDocumentRegistry.cs | 1 + Aetheria.State/AetheriaStateNode.cs | 125 +++++++++++++++++- Aetheria.Unity/Packages/manifest.json | 2 +- Aetheria.Unity/Packages/packages-lock.json | 6 +- .../Runtime/AetheriaRuntimeCatalogSnapshot.cs | 18 +++ .../Runtime/AetheriaRuntimeCatalogStore.cs | 9 +- docs/aetheria-current-codebase-model.md | 44 +++++- 9 files changed, 341 insertions(+), 15 deletions(-) diff --git a/Aetheria.State.Daemon/AetheriaYmirPersistenceCoordinator.cs b/Aetheria.State.Daemon/AetheriaYmirPersistenceCoordinator.cs index d704ca45c..728615916 100644 --- a/Aetheria.State.Daemon/AetheriaYmirPersistenceCoordinator.cs +++ b/Aetheria.State.Daemon/AetheriaYmirPersistenceCoordinator.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Diagnostics; using System.Security.Cryptography; using System.Text; using Aetheria.State.Documents; @@ -40,12 +41,32 @@ public sealed class AetheriaYmirPersistenceCoordinator : IDisposable AetheriaYmirWorldPhysics physics, AetheriaRuntimeDaemonFrameDocument? frame) { + var trace = string.Equals( + Environment.GetEnvironmentVariable("AETHERIA_TRACE_STARTUP_PHASES"), + "1", + StringComparison.Ordinal); + var phase = Stopwatch.StartNew(); + void Trace(string name) + { + if (trace) + Console.WriteLine($"Aetheria Ymir restore phase {name} took {phase.Elapsed.TotalMilliseconds:0.###}ms."); + phase.Restart(); + } ArgumentNullException.ThrowIfNull(node); ArgumentNullException.ThrowIfNull(physics); var registry = CultMesh.CreateCultCacheDocumentRegistry( typeof(AetheriaYmirPersistenceMarkerDocument), typeof(AetheriaYmirJournalChunkDocument), typeof(AetheriaYmirResumeDocument)); + var ownedSchemaIds = registry.AllDescriptors + .Select(descriptor => descriptor.SchemaId) + .ToHashSet(StringComparer.Ordinal); + var currentResumeKeys = frame?.Run == null || string.IsNullOrWhiteSpace(frame.Run.RunId) + ? new HashSet(StringComparer.Ordinal) + : (frame.Run.Zones ?? Array.Empty()) + .Where(zone => zone != null) + .Select(zone => ResumeRecordKey(frame.Run.RunId, zone.ZoneIndex, frame.FrameId)) + .ToHashSet(StringComparer.Ordinal); var cache = await CultCacheMessagePack.OpenAsync( PrivateStatePath(node.StatePath), new CultCacheOpenOptions @@ -53,9 +74,14 @@ public sealed class AetheriaYmirPersistenceCoordinator : IDisposable Registry = registry, PullOnOpen = true, UseDirectoryStore = true, + DirectoryStoreHydrationFilter = record => + ownedSchemaIds.Contains(record.SchemaId) && + (string.Equals(record.Key, AetheriaYmirPersistenceMarkerDocument.RecordKey, StringComparison.Ordinal) || + currentResumeKeys.Contains(record.Key)), FlushOnDispose = true, StoreFlushOnDispose = true }).ConfigureAwait(false); + Trace("open-private-cache"); try { var activated = cache.Get( @@ -72,14 +98,24 @@ public sealed class AetheriaYmirPersistenceCoordinator : IDisposable .Select(zone => cache.Get( new CultRecordKey(ResumeRecordKey(frame.Run.RunId, zone.ZoneIndex, frame.FrameId)))) .ToArray(); + Trace("resolve-resumes"); if (resumes.All(value => value == null) && !activated) return coordinator; if (resumes.Any(value => value == null)) throw new InvalidOperationException( $"Aetheria frame {frame.FrameId} has incomplete daemon-private Ymir resume state."); + var journalRanges = zones.Zip(resumes, (zone, resume) => + JournalRanges(frame.Run.RunId, zone.ZoneIndex, resume!)) + .SelectMany(value => value) + .ToArray(); + await cache.PullBackingStoreRecordsAsync(metadata => + journalRanges.Any(range => range.Contains(metadata.Key))).ConfigureAwait(false); + Trace("hydrate-journals"); + foreach (var pair in zones.Zip(resumes, (zone, resume) => (zone, resume: resume!))) coordinator.RestoreZone(frame.Run.RunId, frame.FrameId, pair.zone.ZoneIndex, pair.resume); + Trace("restore-zones"); return coordinator; } catch @@ -320,9 +356,43 @@ public sealed class AetheriaYmirPersistenceCoordinator : IDisposable long firstEntryIndex) => $"private:aetheria.ymir.journal.v1:{RunKey(runId)}:{zoneIndex}:{channel}:{generation}:{firstEntryIndex:D20}"; + private static JournalRange[] JournalRanges( + string runId, + int zoneIndex, + AetheriaYmirResumeDocument resume) + { + var world = YmirSessionCheckpointCodec.DecodeResumeDescriptor(resume.WorldDescriptorPayload); + var ranges = new List + { + new(JournalRecordPrefix(runId, zoneIndex, WorldChannel, world.SessionGeneration), world.JournalEntryCount) + }; + if (resume.PayloadDescriptorPayload.Length > 0) + { + var payload = YmirSessionCheckpointCodec.DecodeResumeDescriptor(resume.PayloadDescriptorPayload); + ranges.Add(new JournalRange( + JournalRecordPrefix(runId, zoneIndex, PayloadChannel, payload.SessionGeneration), + payload.JournalEntryCount)); + } + return ranges.ToArray(); + } + + private static string JournalRecordPrefix( + string runId, + int zoneIndex, + string channel, + string generation) => + $"private:aetheria.ymir.journal.v1:{RunKey(runId)}:{zoneIndex}:{channel}:{generation}:"; + private static string RunKey(string runId) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(runId))).ToLowerInvariant(); private readonly record struct CursorKey(string RunId, int ZoneIndex, string Channel); private readonly record struct Cursor(string Generation, long EntryCount); + private readonly record struct JournalRange(string Prefix, long EntryCount) + { + public bool Contains(string key) => + key.StartsWith(Prefix, StringComparison.Ordinal) && + long.TryParse(key.AsSpan(Prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var firstEntryIndex) && + firstEntryIndex < EntryCount; + } } diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 9431c2021..454bf1383 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -48,7 +48,8 @@ await using var node = await AetheriaStateNode.OpenAsync( options.StatePath, runtimeId: options.DaemonId, startServer: true, - enableDurableShardLogs: false).ConfigureAwait(false); + enableDurableShardLogs: false, + hydrationProfile: AetheriaStateHydrationProfile.DaemonBoot).ConfigureAwait(false); TraceStartup("open-state-node"); using var discoveryHost = new AetheriaVerseDiscoveryHost(node); var latestFrame = await node.MutableDocument(AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest).ReadAsync().ConfigureAwait(false); @@ -87,7 +88,10 @@ var nativeCatalogChanged = await AetheriaDaemonNativeCatalog.EnsureAsync(node).C TraceStartup("native-catalog"); await node.FlushAsync().ConfigureAwait(false); TraceStartup("initial-flush"); -if (persistedRuntimeCatalog == null || tradePolicyChanged || nativeCatalogChanged) +if (persistedRuntimeCatalog == null || + string.IsNullOrWhiteSpace(persistedRuntimeCatalog.NameCorpusRecordKey) || + tradePolicyChanged || + nativeCatalogChanged) await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false); TraceStartup("runtime-catalog"); await EnsurePlayableRunDocumentsAsync(node, options, startedAtUtc).ConfigureAwait(false); @@ -2002,7 +2006,9 @@ static async Task PublishDaemonMenuSurfacesAsync( catalog, frame.Run?.RunId ?? frame.SessionId, "cultmesh://aetheria/assets"); - var loadoutTemplates = AetheriaRuntimeCatalogStore.ReadLoadoutTemplates(node.StatePath); + var loadoutTemplates = node.Cache.GetAll() + .Select(ToLoadoutTemplateSnapshot) + .ToArray(); var playerSettings = await ReadRuntimePlayerSettingsAsync(node).ConfigureAwait(false); var currentEntity = AetheriaRuntimeGameDocuments.CurrentEntity(frame); var stationRefit = AetheriaRuntimeGameDocuments.StationRefit(frame, loadoutTemplates, catalog); @@ -2871,7 +2877,10 @@ static async Task EnsurePlayableRunDocumentsAsync( } await AetheriaDaemonRunFactory.WriteAsync( - node, node.RuntimeCatalog().Latest(), now, now).ConfigureAwait(false); + node, + await node.RuntimeCatalogForGenerationAsync().ConfigureAwait(false), + now, + now).ConfigureAwait(false); } static async Task EnsureGameSessionAsync( @@ -2933,7 +2942,10 @@ static async Task ApplyRequestedNewGameSessionAsync( var now = DateTimeOffset.UtcNow.ToString("O"); var written = await AetheriaDaemonRunFactory.WriteAsync( - node, node.RuntimeCatalog().Latest(), now, menu.LastCommandId).ConfigureAwait(false); + node, + await node.RuntimeCatalogForGenerationAsync().ConfigureAwait(false), + now, + menu.LastCommandId).ConfigureAwait(false); session.Mode = written.SessionMode; session.SessionId = options.SessionId; session.RunId = written.RunId; @@ -3476,6 +3488,65 @@ static AetheriaRuntimeLoadoutItemCommit ToLoadoutItemCommit(AetheriaLoadoutItem? }; } +static AetheriaRuntimeLoadoutTemplateSnapshot ToLoadoutTemplateSnapshot(AetheriaLoadoutTemplate template) +{ + return new AetheriaRuntimeLoadoutTemplateSnapshot( + template.Name ?? "", + template.OwnerPlayerKey ?? "", + ToEntityLoadoutSnapshot(template.RootEntity), + template.CreatedAtUtc ?? "", + template.UpdatedAtUtc ?? ""); +} + +static AetheriaRuntimeEntityLoadoutSnapshot ToEntityLoadoutSnapshot(AetheriaEntityLoadout? entity) +{ + entity ??= new AetheriaEntityLoadout(); + return new AetheriaRuntimeEntityLoadoutSnapshot( + entity.Name ?? "", + entity.Kind ?? "", + entity.FactionKey ?? "", + ToLoadoutItemSnapshot(entity.Hull), + ToSlotSnapshots(entity.Equipment), + ToSlotSnapshots(entity.CargoBays), + ToSlotSnapshots(entity.DockingBays), + ToCargoSnapshots(entity.CargoContents), + ToCargoSnapshots(entity.DockingBayContents), + (entity.DockingBayAssignments ?? []).ToArray(), + (entity.WeaponGroups ?? []).Select(group => (IReadOnlyList)group.ToArray()).ToArray(), + (entity.Children ?? []).Select(ToEntityLoadoutSnapshot).ToArray()); +} + +static AetheriaRuntimeLoadoutItemSnapshot ToLoadoutItemSnapshot(AetheriaLoadoutItem? item) +{ + item ??= new AetheriaLoadoutItem(); + return new AetheriaRuntimeLoadoutItemSnapshot( + item.ItemKey ?? "", + item.Quality, + item.Durability, + item.Quantity, + item.Enabled, + item.OverrideShutdown, + item.Temperature); +} + +static AetheriaRuntimeLoadoutItemSlotSnapshot[] ToSlotSnapshots(IEnumerable? slots) +{ + return (slots ?? []) + .Select(slot => new AetheriaRuntimeLoadoutItemSlotSnapshot( + slot.Position?.X ?? 0, + slot.Position?.Y ?? 0, + ToLoadoutItemSnapshot(slot.Item), + slot.Rotation ?? "None")) + .ToArray(); +} + +static AetheriaRuntimeCargoBayLoadoutSnapshot[] ToCargoSnapshots(IEnumerable? bays) +{ + return (bays ?? []) + .Select(bay => new AetheriaRuntimeCargoBayLoadoutSnapshot(ToSlotSnapshots(bay.Items))) + .ToArray(); +} + internal sealed record AetheriaPreparedPublication( AetheriaRuntimeDaemonTickResult Publication, IReadOnlyList Physics, diff --git a/Aetheria.State/AetheriaDocumentRegistry.cs b/Aetheria.State/AetheriaDocumentRegistry.cs index 86a04c5b1..38937d1e1 100644 --- a/Aetheria.State/AetheriaDocumentRegistry.cs +++ b/Aetheria.State/AetheriaDocumentRegistry.cs @@ -33,6 +33,7 @@ public static class AetheriaDocumentRegistry typeof(AetheriaLegacyCatalogQuarantine), typeof(AetheriaTradeValuePolicy), typeof(AetheriaRuntimeCatalogSnapshot), + typeof(AetheriaRuntimeNameCorpusSnapshot), typeof(AetheriaPlayerSettings), typeof(AetheriaLoadoutTemplate), typeof(AetheriaRunState), diff --git a/Aetheria.State/AetheriaStateNode.cs b/Aetheria.State/AetheriaStateNode.cs index d231738c7..e6dd99d31 100644 --- a/Aetheria.State/AetheriaStateNode.cs +++ b/Aetheria.State/AetheriaStateNode.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Aetheria.State.Documents; using GameCult.Aetheria.State.Verse; @@ -9,13 +11,22 @@ using GameCult.Mesh; using GameCult.Networking; using R3; using EveSurfaceDocument = GameCult.Eve.Surface.EveSurfaceDocument; +using EveSurfaceCommandRequest = GameCult.Eve.Surface.EveSurfaceCommandRequest; +using EveCommandReceiptDocument = GameCult.Eve.Surface.EveCommandReceiptDocument; namespace Aetheria.State; +public enum AetheriaStateHydrationProfile +{ + All, + DaemonBoot +} + public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable { private readonly CultMeshNode _node; private CultMeshDocumentHandle? _runtimeCatalog; + private CultMeshDocumentHandle? _runtimeNameCorpus; private CultMeshDocumentHandle? _catalogSurface; private AetheriaStateNode(string statePath, string runtimeId, CultMeshNode node) @@ -42,7 +53,8 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable string runtimeId = "aetheria-local", bool startServer = false, bool enableDurableShardLogs = true, - bool useDirectoryStore = true) + bool useDirectoryStore = true, + AetheriaStateHydrationProfile hydrationProfile = AetheriaStateHydrationProfile.All) { if (string.IsNullOrWhiteSpace(statePath)) { @@ -52,6 +64,7 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(statePath)) ?? "."); var cacheRegistry = AetheriaDocumentRegistry.CreateCultCacheRegistry(); + var hydrationFilter = CreateHydrationFilter(hydrationProfile, cacheRegistry); var node = await CultMesh.CreateNodeAsync( statePath, new CultMeshNodeOptions @@ -63,7 +76,8 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable Registry = cacheRegistry, PullOnOpen = true, StoreFlushOnDispose = true, - UseDirectoryStore = useDirectoryStore + UseDirectoryStore = useDirectoryStore, + DirectoryStoreHydrationFilter = hydrationFilter }, DatabaseOptions = new CultNetDatabaseOptions { @@ -75,6 +89,51 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable return new AetheriaStateNode(statePath, runtimeId, node); } + private static Func? CreateHydrationFilter( + AetheriaStateHydrationProfile profile, + CultDocumentRegistry registry) + { + if (profile == AetheriaStateHydrationProfile.All) + return null; + if (profile != AetheriaStateHydrationProfile.DaemonBoot) + throw new ArgumentOutOfRangeException(nameof(profile), profile, "Unknown Aetheria state hydration profile."); + + var schemaTypes = new[] + { + typeof(AetheriaItemDefinition), + typeof(AetheriaLoadoutTemplate), + typeof(AetheriaRunState), + typeof(AetheriaZoneState), + typeof(AetheriaEntitySnapshot), + typeof(AetheriaRuntimeAuthorityLeaseDocument), + typeof(AetheriaRuntimeDaemonCommandDocument), + typeof(AetheriaRuntimeCommittedCommandFactDocument), + typeof(AetheriaRuntimeEveCommandDocument), + typeof(EveSurfaceCommandRequest), + typeof(EveCommandReceiptDocument) + }; + var schemaIds = schemaTypes + .Select(registry.GetRequired) + .SelectMany(descriptor => descriptor.ToCatalogEntry().CompatibleSchemaIds.Append(descriptor.SchemaId)) + .ToHashSet(StringComparer.Ordinal); + var recordKeys = new HashSet(StringComparer.Ordinal) + { + WorldKey.ToString(), + RuntimeCatalogKey.ToString(), + TradeValuePolicyKey.ToString(), + PlayerSettingsKey.ToString(), + VerseHostSettingsKey.ToString(), + GameSessionStateKey.ToString(), + MainMenuStateKey.ToString(), + EveCommandAcceptanceStatusKey.ToString(), + AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest.ToString(), + AetheriaRuntimeVerseRecordKeys.VerseAuthorityPolicy.ToString(), + AetheriaRuntimeVerseRecordKeys.StarbridgeScenarioLatest.ToString(), + AetheriaRuntimeVerseRecordKeys.StarbridgeSessionLatest.ToString() + }; + return record => recordKeys.Contains(record.Key) || schemaIds.Contains(record.SchemaId); + } + public CultMeshDocumentHandle Document(CultRecordKey key) where TDocument : class { @@ -127,6 +186,50 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable routeHint: new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "Aetheria typed catalog state")); } + public CultMeshDocumentHandle RuntimeNameCorpus() + { + return _runtimeNameCorpus ??= CultMesh.Document( + "aetheria.catalog.runtime-names", + CultMesh.Verse("aetheria.local", RuntimeId), + async _ => await Database.GetAsync(RuntimeNameCorpusKey) + .ConfigureAwait(false) + ?? throw new InvalidDataException("The compiled Aetheria runtime name corpus is missing."), + _ => Database.WatchRecord(RuntimeNameCorpusKey) + .Where(change => change.Document != null) + .Select(change => change.Document!), + sources: new[] + { + CultMesh.ProjectionSource( + RuntimeNameCorpusKey.ToString(), + AetheriaRuntimeNameCorpusSnapshot.SchemaId, + "managed Aetheria runtime name corpus") + }, + routeHint: new CultMeshRouteHint( + CultMeshLocalityKind.SharedMemory, + "Aetheria typed name generation corpus")); + } + + public async Task RuntimeCatalogForGenerationAsync() + { + var catalog = RuntimeCatalog().Latest() + ?? throw new InvalidDataException("The compiled Aetheria runtime catalog is missing."); + if (Cache.Get(RuntimeNameCorpusKey) == null) + { + await Cache.PullBackingStoreRecordsAsync(metadata => + string.Equals(metadata.Key, RuntimeNameCorpusKey.ToString(), StringComparison.Ordinal)).ConfigureAwait(false); + } + var corpus = await RuntimeNameCorpus().LatestAsync().ConfigureAwait(false); + return new AetheriaRuntimeCatalogSnapshot( + catalog.Items.ToArray(), + catalog.Corporations.ToArray(), + corpus.NameFiles.ToArray(), + catalog.TradeValueSettings) + { + CatalogId = catalog.CatalogId, + NameCorpusRecordKey = RuntimeNameCorpusKey.ToString() + }; + } + public CultMeshDocumentHandle CatalogSurface() { var catalog = RuntimeCatalog(); @@ -157,6 +260,21 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable public async Task RefreshRuntimeCatalogAsync() { var snapshot = AetheriaRuntimeCatalogStore.OpenReadOnly(StatePath); + await Database.PutAsync( + RuntimeNameCorpusKey, + new AetheriaRuntimeNameCorpusSnapshot + { + NameFiles = snapshot.NameFiles.ToArray() + }).ConfigureAwait(false); + snapshot.NameFiles = snapshot.NameFiles + .Select(nameFile => new AetheriaRuntimeNameFile( + nameFile.NameFileKey, + nameFile.Name, + nameFile.NameCount, + nameFile.SampleNames.ToArray(), + Array.Empty())) + .ToArray(); + snapshot.NameCorpusRecordKey = RuntimeNameCorpusKey.ToString(); await Database.PutAsync(RuntimeCatalogKey, snapshot).ConfigureAwait(false); return snapshot; } @@ -252,6 +370,9 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable public static CultRecordKey RuntimeCatalogKey { get; } = new("global:aetheria.runtime_catalog.v1"); + public static CultRecordKey RuntimeNameCorpusKey { get; } = + new("global:aetheria.runtime_name_corpus.v1"); + public static CultRecordKey OperationsSurfaceKey { get; } = new(AetheriaEveSurfaceDocuments.OperationsSurfaceKey); diff --git a/Aetheria.Unity/Packages/manifest.json b/Aetheria.Unity/Packages/manifest.json index a62ebf9a7..fdbef889a 100644 --- a/Aetheria.Unity/Packages/manifest.json +++ b/Aetheria.Unity/Packages/manifest.json @@ -7,7 +7,7 @@ "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.39", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.40", "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.99" diff --git a/Aetheria.Unity/Packages/packages-lock.json b/Aetheria.Unity/Packages/packages-lock.json index 733aad9a7..48e7939ad 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.39", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.40", "depth": 0, "source": "git", "dependencies": {}, - "hash": "54e2e69ff7f4f5e0a1316bb047c315e65ed2a185" + "hash": "cce542d82467a80519803c04f0c4e43c7e105fe8" }, "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", @@ -156,7 +156,7 @@ "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.39", + "org.gamecult.cultlib": "1.0.40", "org.gamecult.eve.plugin-fields": "0.2.3", "org.gamecult.eve.surface": "0.2.4", "com.unity.mathematics": "1.3.2", diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs index 8ec213e3d..ee9d6846b 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs @@ -66,6 +66,9 @@ namespace GameCult.Aetheria.State.Verse [Key(4)] public AetheriaRuntimeTradeValueSettings TradeValueSettings { get; set; } = AetheriaRuntimeTradeValueSettings.Default; + [Key(5)] + public string NameCorpusRecordKey { get; set; } = string.Empty; + public AetheriaRuntimeCatalogItem? FindItem(string itemKey) { if (string.IsNullOrWhiteSpace(itemKey)) @@ -138,6 +141,21 @@ namespace GameCult.Aetheria.State.Verse } } + [CultDocument("gamecult.aetheria.runtime_name_corpus", "gamecult.aetheria.runtime_name_corpus.v1")] + [MessagePackObject] + public sealed class AetheriaRuntimeNameCorpusSnapshot + { + public const string SchemaId = "gamecult.aetheria.runtime_name_corpus.v1"; + public const string DocumentName = "aetheria.runtime_name_corpus"; + + [Key(0)] + [CultName] + public string CorpusId { get; set; } = DocumentName; + + [Key(1)] + public IReadOnlyList NameFiles { get; set; } = Array.Empty(); + } + [CultDocument("gamecult.aetheria.loadout_templates", "gamecult.aetheria.loadout_templates.v1")] [MessagePackObject] public sealed class AetheriaRuntimeLoadoutTemplatesDocument diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogStore.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogStore.cs index 9bde4f35d..e983fb934 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogStore.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogStore.cs @@ -232,13 +232,18 @@ namespace GameCult.Aetheria.State.Verse var records = new List(); var reader = new MessagePackReader(File.ReadAllBytes(stateFilePath)); var snapshotFields = reader.ReadArrayHeader(); - if (snapshotFields > 0) reader.Skip(); + var formatVersion = snapshotFields > 0 ? ReadString(ref reader) : ""; if (snapshotFields > 1) reader.Skip(); if (snapshotFields > 2) { var recordCount = reader.ReadArrayHeader(); for (var index = 0; index < recordCount; index++) - records.Add(ReadPersistedRecord(ref reader)); + { + if (string.Equals(formatVersion, "cultcache.store.v2.directory-indexed", StringComparison.Ordinal)) + reader.Skip(); + else + records.Add(ReadPersistedRecord(ref reader)); + } } var recordDirectory = stateFilePath + ".records"; diff --git a/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index 9144ca7be..212a87271 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -1,6 +1,6 @@ # Aetheria Current Codebase Model -Date: 2026-07-19 +Date: 2026-07-20 This is a modeling pass over the live codebase as it exists now. It is not the ideal architecture; it is the current control-flow map, with migration pressure @@ -37,7 +37,10 @@ Current daemon control flow: 1. Parse daemon options: state path, daemon id, session id, Verse id, CultMesh address, tick interval, fixed delta. -2. Open `AetheriaStateNode` with `startServer: true`. +2. Open `AetheriaStateNode` with `startServer: true` and the daemon boot + hydration profile. The indexed CultCache manifest selects the control-plane + schemas and exact singleton records required to start; authored name files, + the compiled name corpus, asset bodies, and unrelated history remain cold. 3. Start `AetheriaVerseDiscoveryHost`. 4. Ensure world, Verse host settings, runtime session, compatibility surfaces, and first daemon tick. @@ -67,6 +70,43 @@ intent, docking, loot, trade, loadout, and action-bar operations, but it is not yet a complete standalone simulation engine for every behavior that still lives under Unity `Assets/Scripts/ServerShared/Behaviors`. +### Daemon boot working set + +- **Owner:** `AetheriaStateNode` owns the daemon boot record selection; + `DirectoryMessagePackBackingStore` owns key-to-page discovery and durable + retention. A service registry contains only Aetheria's explicitly declared + document types. +- **Inputs:** the indexed manifest, exact singleton boot keys, and the schema + identities for live commands, receipts, item/loadout definitions, and + current run/zone/entity state. +- **Outputs:** a compact live CultCache working set. The runtime catalog core + contains items, corporations, trade settings, and name-file summaries; the + full name corpus is a separate typed cold record loaded explicitly only when + generation needs it. +- **Derived state:** the catalog Eve surface and ordinary daemon ticks use the + compact core. Authored name files remain migration/compiler inputs, not boot + dependencies. +- **Forbidden writers:** daemon boot must not rescan record pages, reread legacy + name files, or let global assembly discovery expand its registry. A filtered + flush must not erase cold records. +- **Shared paths:** first import writes the indexed representation; daemon boot + selects its working set from that index; new-run generation uses CultCache's + selective pull primitive to hydrate the name corpus through the same open + store. +- **Cut line:** the monolithic runtime catalog no longer embeds twelve megabytes + of names, and menu bootstrap no longer rescans loadout pages from disk. +- **Verification layer:** backing-store tests hold an unrelated cold page under + an exclusive lock while selectively hydrating another record. Startup phase + traces report state open, catalog, Ymir restore, and client publication costs. + +The remaining restart bottleneck is inside Ymir reconstruction, not CultCache +I/O: the current resume format validates state by replaying its entire +generation journal. Indexed hydration reduced journal page loading to tens of +milliseconds, but replay remains proportional to session lifetime. Ymir needs +a bounded baseline checkpoint plus journal tail before daemon restart cost is +independent of uptime; increasing timeouts or hiding the phase is not an +acceptable substitute. + ## Materialized Import And Publication Ownership ### Catalog import