diff --git a/.gitignore b/.gitignore
index 4041747ce..4b2b86558 100644
--- a/.gitignore
+++ b/.gitignore
@@ -103,6 +103,10 @@ Aetheria.State.Import/bin/
Aetheria.State.Import/obj/
Aetheria.State.Smoke/bin/
Aetheria.State.Smoke/obj/
+Aetheria.State.Unity/bin/
+Aetheria.State.Unity/obj/
+Aetheria.State.Unity.Smoke/bin/
+Aetheria.State.Unity.Smoke/obj/
Aetheria.State.Verify/bin/
Aetheria.State.Verify/obj/
diff --git a/.voidbot/state/aetheria.cc b/.voidbot/state/aetheria.cc
index a6a58b9d7..876056ddd 100644
Binary files a/.voidbot/state/aetheria.cc and b/.voidbot/state/aetheria.cc differ
diff --git a/Aetheria.State.Unity.Smoke/Aetheria.State.Unity.Smoke.csproj b/Aetheria.State.Unity.Smoke/Aetheria.State.Unity.Smoke.csproj
new file mode 100644
index 000000000..6516b3064
--- /dev/null
+++ b/Aetheria.State.Unity.Smoke/Aetheria.State.Unity.Smoke.csproj
@@ -0,0 +1,13 @@
+
+
+ Exe
+ net10.0
+ enable
+ latest
+ enable
+
+
+
+
+
+
diff --git a/Aetheria.State.Unity.Smoke/Program.cs b/Aetheria.State.Unity.Smoke/Program.cs
new file mode 100644
index 000000000..690fe91c5
--- /dev/null
+++ b/Aetheria.State.Unity.Smoke/Program.cs
@@ -0,0 +1,57 @@
+using Aetheria.State;
+using Aetheria.State.Unity;
+
+var root = args.Length > 0 ? Path.GetFullPath(args[0]) : Directory.GetCurrentDirectory();
+var statePath = args.Length > 1
+ ? Path.GetFullPath(args[1])
+ : AetheriaStatePaths.ResolveDefaultStatePath(root);
+
+await using var client = await AetheriaRuntimeCatalogClient.OpenAsync(statePath);
+var catalog = client.ReadCatalog();
+var surface = await client.ReadCatalogSurfaceAsync();
+
+if (catalog.Items.Count != 115)
+{
+ throw new InvalidOperationException($"Expected 115 runtime catalog items, found {catalog.Items.Count}.");
+}
+
+if (catalog.TradeItems.Count == 0)
+{
+ throw new InvalidOperationException("Runtime catalog has no trade items.");
+}
+
+if (catalog.EquipmentItems.Count == 0)
+{
+ throw new InvalidOperationException("Runtime catalog has no equipment items.");
+}
+
+var behaviorKind = catalog.Items.SelectMany(item => item.BehaviorKinds).FirstOrDefault()
+ ?? throw new InvalidOperationException("Runtime catalog has no behavior kind fingerprints.");
+if (!catalog.FindItemsByBehavior(behaviorKind).Any())
+{
+ throw new InvalidOperationException($"Runtime catalog behavior lookup failed for {behaviorKind}.");
+}
+
+var equipment = catalog.EquipmentItems.First();
+if (!catalog.FindItemsByHardpoint(equipment.HardpointType).Any())
+{
+ throw new InvalidOperationException($"Runtime catalog hardpoint lookup failed for {equipment.HardpointType}.");
+}
+
+var manufactured = catalog.Items.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.ManufacturerLegacyId))
+ ?? throw new InvalidOperationException("Runtime catalog has no manufactured item.");
+if (catalog.GetManufacturer(manufactured) == null)
+{
+ throw new InvalidOperationException($"Runtime catalog manufacturer lookup failed for {manufactured.Name}.");
+}
+
+if (surface?.Schema != "gamecult.eve.surface.v1" ||
+ surface.Surface.Id != AetheriaCatalogSurfaceProjector.SurfaceId)
+{
+ throw new InvalidOperationException("Runtime catalog client did not read the typed Eve surface.");
+}
+
+Console.WriteLine($"Aetheria Unity runtime catalog smoke passed: {statePath}");
+Console.WriteLine($"Items/trade/equipment: {catalog.Items.Count}/{catalog.TradeItems.Count}/{catalog.EquipmentItems.Count}");
+Console.WriteLine($"Behavior sample: {behaviorKind}");
+Console.WriteLine($"Eve surface: {surface.Surface.Id}");
diff --git a/Aetheria.State.Unity/Aetheria.State.Unity.csproj b/Aetheria.State.Unity/Aetheria.State.Unity.csproj
new file mode 100644
index 000000000..d0c760e7b
--- /dev/null
+++ b/Aetheria.State.Unity/Aetheria.State.Unity.csproj
@@ -0,0 +1,13 @@
+
+
+ netstandard2.1
+ enable
+ latest
+ enable
+ Unity-facing runtime read facade over typed Aetheria CultCache state.
+
+
+
+
+
+
diff --git a/Aetheria.State.Unity/AetheriaRuntimeCatalogClient.cs b/Aetheria.State.Unity/AetheriaRuntimeCatalogClient.cs
new file mode 100644
index 000000000..7cc8deec3
--- /dev/null
+++ b/Aetheria.State.Unity/AetheriaRuntimeCatalogClient.cs
@@ -0,0 +1,40 @@
+using Aetheria.State.Documents;
+
+namespace Aetheria.State.Unity;
+
+public sealed class AetheriaRuntimeCatalogClient : IAsyncDisposable, IDisposable
+{
+ private readonly AetheriaStateNode _node;
+
+ private AetheriaRuntimeCatalogClient(AetheriaStateNode node)
+ {
+ _node = node;
+ }
+
+ public static async Task OpenAsync(string statePath)
+ {
+ var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-unity-runtime-catalog").ConfigureAwait(false);
+ return new AetheriaRuntimeCatalogClient(node);
+ }
+
+ public AetheriaRuntimeCatalogSnapshot ReadCatalog()
+ {
+ var catalog = _node.ReadCatalogSnapshot();
+ return AetheriaRuntimeCatalogSnapshot.FromCatalog(catalog);
+ }
+
+ public async Task ReadCatalogSurfaceAsync()
+ {
+ return await _node.GetCatalogSurfaceAsync().ConfigureAwait(false);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ return _node.DisposeAsync();
+ }
+
+ public void Dispose()
+ {
+ _node.Dispose();
+ }
+}
diff --git a/Aetheria.State.Unity/AetheriaRuntimeCatalogSnapshot.cs b/Aetheria.State.Unity/AetheriaRuntimeCatalogSnapshot.cs
new file mode 100644
index 000000000..ee10febc2
--- /dev/null
+++ b/Aetheria.State.Unity/AetheriaRuntimeCatalogSnapshot.cs
@@ -0,0 +1,265 @@
+using Aetheria.State.Documents;
+
+namespace Aetheria.State.Unity;
+
+public sealed class AetheriaRuntimeCatalogSnapshot
+{
+ private readonly Dictionary _itemsByLegacyId;
+ private readonly Dictionary _corporationsByLegacyId;
+ private readonly Dictionary _nameFilesByLegacyId;
+
+ private AetheriaRuntimeCatalogSnapshot(
+ AetheriaRuntimeCatalogItem[] items,
+ AetheriaRuntimeCorporation[] corporations,
+ AetheriaRuntimeNameFile[] nameFiles)
+ {
+ Items = items;
+ Corporations = corporations;
+ NameFiles = nameFiles;
+ TradeItems = items.Where(item => item.Price > 0).ToArray();
+ EquipmentItems = items.Where(item => !string.IsNullOrWhiteSpace(item.HardpointType)).ToArray();
+
+ _itemsByLegacyId = items
+ .Where(item => !string.IsNullOrWhiteSpace(item.LegacyId))
+ .ToDictionary(item => item.LegacyId, StringComparer.OrdinalIgnoreCase);
+ _corporationsByLegacyId = corporations
+ .Where(corporation => !string.IsNullOrWhiteSpace(corporation.LegacyId))
+ .ToDictionary(corporation => corporation.LegacyId, StringComparer.OrdinalIgnoreCase);
+ _nameFilesByLegacyId = nameFiles
+ .Where(nameFile => !string.IsNullOrWhiteSpace(nameFile.LegacyId))
+ .ToDictionary(nameFile => nameFile.LegacyId, StringComparer.OrdinalIgnoreCase);
+ }
+
+ public IReadOnlyList Items { get; }
+
+ public IReadOnlyList TradeItems { get; }
+
+ public IReadOnlyList EquipmentItems { get; }
+
+ public IReadOnlyList Corporations { get; }
+
+ public IReadOnlyList NameFiles { get; }
+
+ public static AetheriaRuntimeCatalogSnapshot FromCatalog(AetheriaCatalogSnapshot catalog)
+ {
+ return new AetheriaRuntimeCatalogSnapshot(
+ catalog.Items.Select(AetheriaRuntimeCatalogItem.FromState).ToArray(),
+ catalog.Corporations.Select(AetheriaRuntimeCorporation.FromState).ToArray(),
+ catalog.NameFiles.Select(AetheriaRuntimeNameFile.FromState).ToArray());
+ }
+
+ public AetheriaRuntimeCatalogItem? FindItemByLegacyId(string legacyId)
+ {
+ return TryGet(_itemsByLegacyId, legacyId);
+ }
+
+ public AetheriaRuntimeCorporation? FindCorporationByLegacyId(string legacyId)
+ {
+ return TryGet(_corporationsByLegacyId, legacyId);
+ }
+
+ public AetheriaRuntimeNameFile? FindNameFileByLegacyId(string legacyId)
+ {
+ return TryGet(_nameFilesByLegacyId, legacyId);
+ }
+
+ public IEnumerable FindItemsByBehavior(string behaviorKind)
+ {
+ return string.IsNullOrWhiteSpace(behaviorKind)
+ ? []
+ : Items.Where(item => item.BehaviorKinds.Contains(behaviorKind, StringComparer.OrdinalIgnoreCase));
+ }
+
+ public IEnumerable FindItemsByHardpoint(string hardpointType)
+ {
+ return string.IsNullOrWhiteSpace(hardpointType)
+ ? []
+ : Items.Where(item => string.Equals(item.HardpointType, hardpointType, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public AetheriaRuntimeCorporation? GetManufacturer(AetheriaRuntimeCatalogItem item)
+ {
+ return FindCorporationByLegacyId(item.ManufacturerLegacyId);
+ }
+
+ public AetheriaRuntimeNameFile? GetNameFile(AetheriaRuntimeCorporation corporation)
+ {
+ return FindNameFileByLegacyId(corporation.GeonameFileLegacyId);
+ }
+
+ private static T? TryGet(IReadOnlyDictionary dictionary, string key) where T : class
+ {
+ return string.IsNullOrWhiteSpace(key) ? null : dictionary.TryGetValue(key, out var value) ? value : null;
+ }
+}
+
+public sealed class AetheriaRuntimeCatalogItem
+{
+ public AetheriaRuntimeCatalogItem(
+ string legacyId,
+ string name,
+ string category,
+ string description,
+ string manufacturerLegacyId,
+ int price,
+ double mass,
+ double volume,
+ int shapeWidth,
+ int shapeHeight,
+ int occupiedCells,
+ string hardpointType,
+ string hullType,
+ IReadOnlyList behaviorKinds,
+ int maxStack,
+ double durability,
+ string weaponRange,
+ string weaponCaliber,
+ string weaponType,
+ string weaponFireTypes,
+ string weaponModifiers)
+ {
+ LegacyId = legacyId;
+ Name = name;
+ Category = category;
+ Description = description;
+ ManufacturerLegacyId = manufacturerLegacyId;
+ Price = price;
+ Mass = mass;
+ Volume = volume;
+ ShapeWidth = shapeWidth;
+ ShapeHeight = shapeHeight;
+ OccupiedCells = occupiedCells;
+ HardpointType = hardpointType;
+ HullType = hullType;
+ BehaviorKinds = behaviorKinds;
+ MaxStack = maxStack;
+ Durability = durability;
+ WeaponRange = weaponRange;
+ WeaponCaliber = weaponCaliber;
+ WeaponType = weaponType;
+ WeaponFireTypes = weaponFireTypes;
+ WeaponModifiers = weaponModifiers;
+ }
+
+ public string LegacyId { get; }
+ public string Name { get; }
+ public string Category { get; }
+ public string Description { get; }
+ public string ManufacturerLegacyId { get; }
+ public int Price { get; }
+ public double Mass { get; }
+ public double Volume { get; }
+ public int ShapeWidth { get; }
+ public int ShapeHeight { get; }
+ public int OccupiedCells { get; }
+ public string HardpointType { get; }
+ public string HullType { get; }
+ public IReadOnlyList BehaviorKinds { get; }
+ public int MaxStack { get; }
+ public double Durability { get; }
+ public string WeaponRange { get; }
+ public string WeaponCaliber { get; }
+ public string WeaponType { get; }
+ public string WeaponFireTypes { get; }
+ public string WeaponModifiers { get; }
+
+ public static AetheriaRuntimeCatalogItem FromState(AetheriaItemDefinition item)
+ {
+ return new AetheriaRuntimeCatalogItem(
+ item.LegacyId,
+ item.Name,
+ item.Category,
+ item.Description,
+ item.ManufacturerLegacyId,
+ item.Price,
+ item.Mass,
+ item.Volume,
+ item.ShapeWidth,
+ item.ShapeHeight,
+ item.OccupiedCells,
+ item.HardpointType,
+ item.HullType,
+ item.BehaviorKinds,
+ item.MaxStack,
+ item.Durability,
+ item.WeaponRange,
+ item.WeaponCaliber,
+ item.WeaponType,
+ item.WeaponFireTypes,
+ item.WeaponModifiers);
+ }
+}
+
+public sealed class AetheriaRuntimeCorporation
+{
+ public AetheriaRuntimeCorporation(
+ string legacyId,
+ string name,
+ string shortName,
+ string description,
+ string geonameFileLegacyId,
+ string bossHullLegacyId,
+ int influenceDistance,
+ int allegianceCount)
+ {
+ LegacyId = legacyId;
+ Name = name;
+ ShortName = shortName;
+ Description = description;
+ GeonameFileLegacyId = geonameFileLegacyId;
+ BossHullLegacyId = bossHullLegacyId;
+ InfluenceDistance = influenceDistance;
+ AllegianceCount = allegianceCount;
+ }
+
+ public string LegacyId { get; }
+ public string Name { get; }
+ public string ShortName { get; }
+ public string Description { get; }
+ public string GeonameFileLegacyId { get; }
+ public string BossHullLegacyId { get; }
+ public int InfluenceDistance { get; }
+ public int AllegianceCount { get; }
+
+ public static AetheriaRuntimeCorporation FromState(AetheriaCorporation corporation)
+ {
+ return new AetheriaRuntimeCorporation(
+ corporation.LegacyId,
+ corporation.Name,
+ corporation.ShortName,
+ corporation.Description,
+ corporation.GeonameFileLegacyId,
+ corporation.BossHullLegacyId,
+ corporation.InfluenceDistance,
+ corporation.AllegianceCount);
+ }
+}
+
+public sealed class AetheriaRuntimeNameFile
+{
+ public AetheriaRuntimeNameFile(
+ string legacyId,
+ string name,
+ int nameCount,
+ IReadOnlyList sampleNames)
+ {
+ LegacyId = legacyId;
+ Name = name;
+ NameCount = nameCount;
+ SampleNames = sampleNames;
+ }
+
+ public string LegacyId { get; }
+ public string Name { get; }
+ public int NameCount { get; }
+ public IReadOnlyList SampleNames { get; }
+
+ public static AetheriaRuntimeNameFile FromState(AetheriaNameFile nameFile)
+ {
+ return new AetheriaRuntimeNameFile(
+ nameFile.LegacyId,
+ nameFile.Name,
+ nameFile.NameCount,
+ nameFile.SampleNames);
+ }
+}
diff --git a/Aetheria.State/README.md b/Aetheria.State/README.md
index f26d567e4..a222c9f6c 100644
--- a/Aetheria.State/README.md
+++ b/Aetheria.State/README.md
@@ -70,6 +70,13 @@ summary, trade-catalog, and corporation views. This is a typed CultCache
provider document and a local mirror of the Eve contract until the shared Eve
Unity UI Toolkit lowering package exists.
+`Aetheria.State.Unity` is the Unity-facing runtime read facade over typed state.
+It opens `aetheria-world.cc`, emits immutable catalog read models for trade,
+equipment, behavior, hardpoint, manufacturer, corporation, and name-file
+queries, and can read the published Eve catalog surface. It does not deserialize
+legacy `DatabaseEntry` objects and does not write state. Unity can use this as
+the first package boundary once CultLib/Eve runtime packaging is available.
+
`Aetheria.State.Verify` opens a materialized state file and checks that the
typed migration ledger matches the actual item, corporation, and name-file
records in the `.cc` store. It also verifies the canonical legacy-ID lookup API
@@ -78,6 +85,10 @@ corporation catalog facts survived materialization. The verifier also exercises
the typed catalog snapshot query surface and the published Eve catalog surface.
Use it after import when `GameData/aetheria-world.cc` changes.
+`Aetheria.State.Unity.Smoke` opens the materialized state through the runtime
+facade and proves read-only Unity-facing catalog access without the legacy
+catalog reader.
+
Current rebuild notes:
- `MessagePack` 3.1.4 is pulled transitively through CultLib and is currently
diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md
index 2cccbc56e..8a448c386 100644
--- a/docs/aetheria-perfect-machine-map.md
+++ b/docs/aetheria-perfect-machine-map.md
@@ -71,6 +71,12 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted.
`.cc` records. It exposes trade-item, manufacturer, corporation prefix, and
corporation name-file queries, plus equipment, hardpoint, and behavior
queries without touching `DatabaseEntry`.
+- `Aetheria.State.Unity` is the Unity-facing runtime read facade over typed
+ catalog state. It opens `aetheria-world.cc`, emits immutable catalog read
+ models for trade/equipment/behavior/hardpoint/manufacturer/corporation/name
+ queries, and reads the published Eve surface without deserializing legacy
+ `DatabaseEntry` objects. It is not a simulation owner and does not write
+ state.
- `AetheriaCatalogSurfaceProjector` now emits the first provider-owned Eve
surface from typed catalog state. The importer materializes a
`gamecult.eve.surface.v1` catalog operator document at
@@ -133,7 +139,9 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted.
`LegacyCatalogBoundary` is the only named owner for old MessagePack catalog
reads and legacy `DatabaseLink` resolution inside the Unity runtime until
catalog migration lands. Runtime code receives `ILegacyCatalogReader`, not the
- concrete cache. The legacy cache no longer has a public mutation surface.
+ concrete cache. `Aetheria.State.Unity` owns a read-only typed catalog facade
+ for future Unity consumers; it does not write state or own simulation. The
+ legacy cache no longer has a public mutation surface.
- Inputs: Unity gameplay code, legacy catalog files, typed state documents, and
CultMesh server state.
- Outputs: `Aetheria.State` emits `.cc` state and CultMesh documents. Legacy
@@ -250,6 +258,9 @@ First Aetheria surfaces to publish:
catalog fields not covered by the stable scalar/fingerprint pass.
4. Runtime cutover
+ - Done: add a Unity-facing typed catalog read facade and smoke proving it can
+ read the materialized `.cc` catalog plus Eve surface without the legacy
+ catalog reader.
- Replace `ActionGameManager` cache bootstrap with the new state runtime.
- Convert domain references from GUID/base-class patterns to typed record
refs.
@@ -308,6 +319,9 @@ First Aetheria surfaces to publish:
typed state without old readers remaining on the live runtime path.
- Migration smoke proves stable old catalog payload fields can be read once and
converted without old readers remaining on the live runtime path.
+- Unity runtime catalog smoke proves read-only catalog consumers can open the
+ typed `.cc` store through `Aetheria.State.Unity` without `DatabaseEntry` or
+ `LegacyCatalogCache`.
- Unity play smoke proves runtime UI reads from Eve surfaces and sends commands
through the shared state service.
- UI Toolkit lowering parity compares Aetheria surfaces against the Eve browser