using System.Security.Cryptography; using Aetheria.State; using Aetheria.State.Documents; using Aetheria.State.Migration; using GameCult.Aetheria.State.Verse; using MessagePack; var root = args.Length > 0 ? Path.GetFullPath(args[0]) : Directory.GetCurrentDirectory(); var statePath = args.Length > 1 ? Path.GetFullPath(args[1]) : AetheriaStatePaths.ResolveDefaultStatePath(root); var sourceRoot = Path.GetRelativePath(Directory.GetCurrentDirectory(), root).Replace('\\', '/'); var outputStatePath = Path.GetRelativePath(root, statePath).Replace('\\', '/'); var gameData = Path.Combine(root, "GameData"); var catalogPath = Path.Combine(gameData, "AetherDB.msgpack"); var supplementalCatalogPath = Path.Combine(gameData, "Legacy", "AetherDB.2021-03-05.msgpack"); var recoveredHullCatalogPath = Path.Combine(gameData, "Legacy", "AetherDB.2021-04-14.msgpack"); var nameFilesRoot = Path.Combine(gameData, "NameFile"); var capturedAtUtc = DateTimeOffset.UtcNow.ToString("O"); var catalog = CaptureFile(root, catalogPath); var supplementalCatalog = CaptureFile(root, supplementalCatalogPath); var recoveredHullCatalog = CaptureFile(root, recoveredHullCatalogPath); var nameFiles = Directory.Exists(nameFilesRoot) ? Directory.EnumerateFiles(nameFilesRoot, "*.msgpack", SearchOption.TopDirectoryOnly) .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) .Select(path => CaptureFile(root, path)) .ToArray() : []; var currentEntries = LegacyCatalogReader.Read(catalogPath); var currentLegacyIds = currentEntries.Select(entry => entry.Summary.LegacyId).ToHashSet(StringComparer.Ordinal); var supplementalMineEntries = LegacyCatalogReader.Read(supplementalCatalogPath) .Where(entry => entry.ItemDefinition != null && IsMineItem(entry.ItemDefinition)) .Where(entry => !currentLegacyIds.Contains(entry.Summary.LegacyId)) .ToArray(); var recoveredGameplayEntries = LegacyCatalogReader.Read( recoveredHullCatalogPath, LegacyCatalogLayout.April2021) .Where(entry => entry.ItemDefinition != null && IsRecoveredGameplayItem(entry.ItemDefinition)) .Where(entry => !currentLegacyIds.Contains(entry.Summary.LegacyId)) .ToArray(); var entries = currentEntries .Concat(supplementalMineEntries) .Concat(recoveredGameplayEntries) .ToArray(); var nameFileEntries = Directory.Exists(nameFilesRoot) ? Directory.EnumerateFiles(nameFilesRoot, "*.msgpack", SearchOption.TopDirectoryOnly) .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) .Select(LegacyCatalogReader.ReadSingle) .ToArray() : []; var itemDefinitions = entries .Where(entry => entry.ItemDefinition != null) .Select(entry => ProjectCatalogRarity(ProjectStationSensorMount(ProjectDeployableWeapon(entry.ItemDefinition!)))) .ToArray(); var corporations = entries .Where(entry => entry.Corporation != null) .Select(entry => entry.Corporation!) .ToArray(); var parsedNameFiles = entries.Concat(nameFileEntries) .Where(entry => entry.NameFile != null) .Select(entry => entry.NameFile!) .ToArray(); static AetheriaItemDefinition ProjectDeployableWeapon(AetheriaItemDefinition item) { if (!IsMineItem(item)) return item; item.WeaponType = "Mine"; item.BehaviorPayloads = (item.BehaviorPayloads ?? []) .Select(behavior => string.Equals(behavior.Kind, "InstantWeapon", StringComparison.Ordinal) ? new AetheriaBehaviorPayload { UnionKey = behavior.UnionKey, Kind = "DeployableWeapon", Group = behavior.Group, BehaviorId = behavior.BehaviorId, Fields = behavior.Fields .Where(field => field.Key < 26 || field.Key > 30) .Concat([ NumberBehaviorField(26, 2), NumberBehaviorField(27, 30), PerformanceBehaviorField(28, 25), NumberBehaviorField(29, 2), PerformanceBehaviorField(30, 25) ]) .OrderBy(field => field.Key) .ToArray() } : behavior) .ToArray(); item.BehaviorKinds = item.BehaviorPayloads .Select(behavior => behavior.Kind) .Distinct(StringComparer.Ordinal) .OrderBy(kind => kind, StringComparer.Ordinal) .ToArray(); item.BehaviorCount = item.BehaviorPayloads.Length; item.Tags = (item.Tags ?? []) .Where(tag => !string.Equals(tag, "behavior:InstantWeapon", StringComparison.Ordinal)) .Concat(["behavior:DeployableWeapon"]) .Distinct(StringComparer.Ordinal) .ToArray(); return item; } static AetheriaItemDefinition ProjectStationSensorMount(AetheriaItemDefinition item) { const string stationHullLegacyId = "82efc0a5-1ba5-4ff3-a281-b2e6e247521d"; if (!string.Equals(item.LegacyId, stationHullLegacyId, StringComparison.OrdinalIgnoreCase) || (item.Hardpoints ?? []).Any(hardpoint => string.Equals(hardpoint.Type, "Sensors", StringComparison.Ordinal))) { return item; } const int positionX = 5; const int positionY = 1; var mountCells = new[] { new AetheriaShapeCell { X = 0, Y = 0 }, new AetheriaShapeCell { X = 1, Y = 0 }, new AetheriaShapeCell { X = 0, Y = 1 }, new AetheriaShapeCell { X = 1, Y = 1 } }; var hullCells = (item.ShapeCells ?? []) .Select(cell => (cell.X, cell.Y)) .ToHashSet(); if (mountCells.Any(cell => !hullCells.Contains((positionX + cell.X, positionY + cell.Y)))) { throw new InvalidDataException("Recovered station sensor mount does not fit the decoded station hull shape."); } item.Hardpoints = (item.Hardpoints ?? []) .Append(new AetheriaItemHardpoint { Type = "Sensors", PositionX = positionX, PositionY = positionY, ShapeWidth = 2, ShapeHeight = 2, OccupiedCells = 4, ShapeCells = mountCells, Rotation = "None" }) .ToArray(); return item; } static bool IsMineItem(AetheriaItemDefinition item) { var hasMineEffect = (item.BehaviorPayloads ?? []).Any(behavior => string.Equals(behavior.Kind, "InstantWeapon", StringComparison.Ordinal) && behavior.Fields.Any(field => field.Key == 8 && field.Value.StringValue.EndsWith("/Mine Launcher.prefab", StringComparison.OrdinalIgnoreCase))); return string.Equals(item.WeaponType, "Mine", StringComparison.Ordinal) || hasMineEffect; } static bool IsRecoveredGameplayItem(AetheriaItemDefinition item) { return string.Equals(item.LegacyId, "c4b20032-8cd3-4206-807f-97b296797425", StringComparison.OrdinalIgnoreCase) || string.Equals(item.LegacyId, "bb5d9f73-612f-4060-8fb4-f72d6b93b616", StringComparison.OrdinalIgnoreCase) || string.Equals(item.HardpointType, "Thruster", StringComparison.Ordinal); } static AetheriaItemDefinition ProjectCatalogRarity(AetheriaItemDefinition item) { var tags = (item.Tags ?? []).AsEnumerable(); if (IsRecoveredGameplayItem(item)) tags = tags.Append("provenance:recovered:2021-04-14"); var isRareHull = string.Equals(item.Name, "LonginusX", StringComparison.Ordinal) && string.Equals(item.HullType, "Ship", StringComparison.Ordinal); var isRareDrive = (item.BehaviorKinds ?? []).Contains("AetherDrive", StringComparer.Ordinal); if (isRareHull || isRareDrive) tags = tags.Append("rarity:rare"); item.Tags = tags .Distinct(StringComparer.Ordinal) .ToArray(); return item; } static AetheriaBehaviorField NumberBehaviorField(int key, double value) => new() { Key = key, Value = new AetheriaBehaviorValue { Kind = "number", NumberValue = value } }; static AetheriaBehaviorField PerformanceBehaviorField(int key, double value) => new() { Key = key, Value = new AetheriaBehaviorValue { Kind = "performance-stat", Children = [ new AetheriaBehaviorValue { Kind = "number", NumberValue = value }, new AetheriaBehaviorValue { Kind = "number", NumberValue = value }, new AetheriaBehaviorValue { Kind = "number" }, new AetheriaBehaviorValue { Kind = "number" }, new AetheriaBehaviorValue { Kind = "number" } ] } }; if (args.Any(argument => string.Equals(argument, "--merge-mine", StringComparison.Ordinal))) { var mine = itemDefinitions.SingleOrDefault(item => string.Equals(item.WeaponType, "Mine", StringComparison.Ordinal)) ?? throw new InvalidDataException("Supplemental catalog did not contain the recovered Mine Launcher item."); await using var mergeNode = await AetheriaStateNode.OpenAsync(statePath, "aetheria-mine-catalog-merge"); await mergeNode.MutableDocument(AetheriaCatalogKeys.ItemDefinitionFromLegacyId(mine.LegacyId)) .ReplaceAsync(mine); await mergeNode.FlushAsync(); var merged = await mergeNode.RuntimeCatalog().LatestAsync().ConfigureAwait(false); var mergedMine = merged.FindItem(mine.ItemKey); if (mergedMine == null || !mergedMine.BehaviorPayloads.Any(behavior => string.Equals(behavior.Kind, "DeployableWeapon", StringComparison.Ordinal))) throw new InvalidDataException("Mine Launcher merge did not become visible through the runtime catalog."); Console.WriteLine($"Merged Mine Launcher into typed state: {statePath}"); Console.WriteLine($"Item: {mine.ItemKey}"); return; } if (args.Any(argument => string.Equals(argument, "--merge-station-sensor", StringComparison.Ordinal))) { const string stationHullLegacyId = "82efc0a5-1ba5-4ff3-a281-b2e6e247521d"; var stationHull = itemDefinitions.Single(item => string.Equals(item.LegacyId, stationHullLegacyId, StringComparison.OrdinalIgnoreCase)); await using var mergeNode = await AetheriaStateNode.OpenAsync(statePath, "aetheria-station-sensor-catalog-merge"); await mergeNode.MutableDocument(AetheriaCatalogKeys.ItemDefinitionFromLegacyId(stationHull.LegacyId)) .ReplaceAsync(stationHull); await mergeNode.FlushAsync(); var merged = await mergeNode.RuntimeCatalog().LatestAsync().ConfigureAwait(false); var mergedHull = merged.FindItem(stationHull.ItemKey); if (mergedHull == null || !(mergedHull.Hardpoints ?? []).Any(hardpoint => string.Equals(hardpoint.Type, "Sensors", StringComparison.Ordinal) && hardpoint.OccupiedCells == 4)) { throw new InvalidDataException("Station sensor hardpoint merge did not become visible through the runtime catalog."); } Console.WriteLine($"Merged station sensor hardpoint into typed state: {statePath}"); Console.WriteLine($"Hull: {stationHull.ItemKey}"); return; } ResetMaterializedStateOutput(statePath); await using var node = await AetheriaStateNode.OpenAsync( statePath, "aetheria-legacy-catalog-import", useDirectoryStore: true); await node.MutableDocument(AetheriaStateNode.LegacyCatalogQuarantineKey) .ReplaceAsync(new AetheriaLegacyCatalogQuarantine { RootPath = sourceRoot, CapturedAtUtc = capturedAtUtc, CatalogFile = catalog.RelativePath, CatalogFingerprint = catalog.Fingerprint, CatalogBytes = catalog.Bytes, NameFiles = nameFiles, SupplementalCatalogFiles = new[] { supplementalCatalog, recoveredHullCatalog } .Where(file => file.Bytes > 0) .ToArray(), Notes = [ "This document quarantines legacy catalog file facts and records the bounded raw payload mapping pass. It does not grant old MessagePack files runtime state authority.", "AetherDB.msgpack and NameFile/*.msgpack remain migration inputs until Unity runtime reads typed Aetheria catalog documents directly.", "The 2021-03-05 supplemental catalog contributes only the missing Mine Launcher item and remains quarantined provenance, not runtime authority.", "The 2021-04-14 catalog contributes only the missing common Longinus and Djinni hull rows plus missing Thruster equipment required to outfit their authored mounts. It preserves the post-drive catalog's distinct Longinus/LonginusX identities after the later modular database lost the common rows." ] }); await node.MutableDocument(AetheriaStateNode.MigrationLedgerKey) .ReplaceAsync(new AetheriaMigrationLedger { Source = LegacyMigrationBoundary.LegacyGameDataFile, SourceFingerprint = catalog.Fingerprint, LastMigrationAtUtc = capturedAtUtc, Counts = [ new AetheriaMigrationCount { DocumentType = "aetheria.legacy_catalog_quarantine.v1", Count = 1 }, new AetheriaMigrationCount { DocumentType = "legacy.catalog_file", Count = catalog.Bytes > 0 ? 1 : 0 }, new AetheriaMigrationCount { DocumentType = "legacy.name_file", Count = nameFiles.Length }, new AetheriaMigrationCount { DocumentType = "aetheria.item_definition.v1", Count = itemDefinitions.Length }, new AetheriaMigrationCount { DocumentType = "aetheria.corporation.v2", Count = corporations.Length }, new AetheriaMigrationCount { DocumentType = "aetheria.name_file.v2", Count = parsedNameFiles.Length } ], LegacyCatalogEntries = entries.Select(entry => entry.Summary).ToArray(), Notes = [ "Legacy catalog quarantine captured file fingerprints and mapped stable old MessagePack union payload fields into typed CultCache documents.", "The mapper intentionally reads only stable scalar/catalog fields. Runtime object graphs, behavior payloads, and Unity-specific shapes remain legacy until dedicated typed documents exist.", "Recovered common Longinus and Djinni hulls and their missing Thruster equipment come from the fingerprinted 2021-04-14 catalog; LonginusX and AetherDrive items are explicitly tagged as rare generation candidates.", $"State path: {outputStatePath}" ] }); foreach (var item in itemDefinitions) { await node.MutableDocument(AetheriaCatalogKeys.ItemDefinitionFromLegacyId(item.LegacyId)) .ReplaceAsync(item); } foreach (var corporation in corporations) { await node.MutableDocument(AetheriaCatalogKeys.CorporationFromLegacyId(corporation.LegacyId)) .ReplaceAsync(corporation); } foreach (var nameFile in parsedNameFiles) { await node.MutableDocument(AetheriaCatalogKeys.NameFileFromLegacyId(nameFile.LegacyId)) .ReplaceAsync(nameFile); } await node.FlushAsync(); var runtimeCatalog = await node.RuntimeCatalog().LatestAsync().ConfigureAwait(false); var importedCorporationKeys = runtimeCatalog.Corporations .Select(corporation => corporation.CorporationKey) .Where(key => !string.IsNullOrWhiteSpace(key)) .Distinct(StringComparer.Ordinal) .ToArray(); if (importedCorporationKeys.Length != corporations.Length) { throw new InvalidDataException( $"Runtime catalog projected {importedCorporationKeys.Length} corporation keys from {corporations.Length} typed corporation documents."); } var runtimeMines = runtimeCatalog.Items .Where(item => string.Equals(item.WeaponType, "Mine", StringComparison.Ordinal)) .ToArray(); if (runtimeMines.Any(item => !item.BehaviorPayloads.Any(behavior => string.Equals(behavior.Kind, "DeployableWeapon", StringComparison.Ordinal) && HasNumber(behavior, 26, 2) && HasNumber(behavior, 27, 30) && HasPerformanceStat(behavior, 28, 25) && HasNumber(behavior, 29, 2) && HasPerformanceStat(behavior, 30, 25)))) { var diagnostic = string.Join("; ", runtimeMines.Select(item => $"{item.Name} [{string.Join(",", item.BehaviorPayloads.Select(behavior => behavior.Kind + ":" + string.Join("/", behavior.Fields.Select(field => field.Key))))}]")); throw new InvalidDataException($"Mine catalog import did not preserve the canonical deployable lifecycle: {diagnostic}"); } await node.MutableDocument(AetheriaStateNode.TradeValuePolicyKey) .ReplaceAsync(AetheriaRuntimeStateMapper.ToTradeValuePolicy( runtimeCatalog.TradeValueSettings, capturedAtUtc)); await node.FlushAsync(); await node.CatalogSurface().LatestAsync().ConfigureAwait(false); await node.FlushAsync(); await using (var reopenedNode = await AetheriaStateNode.OpenAsync( statePath, "aetheria-legacy-catalog-import-reopen-proof", useDirectoryStore: true)) { var reopenedCatalog = await reopenedNode.RuntimeCatalog().LatestAsync().ConfigureAwait(false); var reopenedCorporationKeys = reopenedCatalog.Corporations .Select(corporation => corporation.CorporationKey) .Where(key => !string.IsNullOrWhiteSpace(key)) .Distinct(StringComparer.Ordinal) .ToArray(); if (reopenedCorporationKeys.Length != importedCorporationKeys.Length) { throw new InvalidDataException( $"Reopened runtime catalog projected {reopenedCorporationKeys.Length} corporation keys after the importer flushed {importedCorporationKeys.Length}."); } } Console.WriteLine($"Aetheria legacy catalog mapped into typed state: {statePath}"); Console.WriteLine($"Catalog: {catalog.RelativePath} {catalog.Bytes} bytes {catalog.Fingerprint}"); Console.WriteLine($"Name files: {nameFiles.Length}"); Console.WriteLine($"Mapped items: {itemDefinitions.Length}"); Console.WriteLine($"Mapped factions: {corporations.Length}"); Console.WriteLine($"Runtime corporation keys: {string.Join(", ", importedCorporationKeys)}"); Console.WriteLine($"Mapped name files: {parsedNameFiles.Length}"); Console.WriteLine($"Mapped mine weapons: {string.Join(", ", runtimeMines.Select(item => item.Name + " (" + item.ItemKey + ")"))}"); static bool HasNumber(AetheriaRuntimeBehaviorPayload behavior, int key, double expected) => behavior.Fields.Any(field => field.Key == key && Math.Abs(field.Value.NumberValue - expected) < 0.000001); static bool HasPerformanceStat(AetheriaRuntimeBehaviorPayload behavior, int key, double expected) { var field = behavior.Fields.FirstOrDefault(candidate => candidate.Key == key); return field?.Value.Children.Count >= 2 && Math.Abs(field.Value.Children[0].NumberValue - expected) < 0.000001 && Math.Abs(field.Value.Children[1].NumberValue - expected) < 0.000001; } static AetheriaLegacyCatalogFile CaptureFile(string root, string path) { if (!File.Exists(path)) { return new AetheriaLegacyCatalogFile { RelativePath = Path.GetRelativePath(root, path).Replace('\\', '/'), Fingerprint = "missing", Bytes = 0 }; } using var stream = File.OpenRead(path); var hash = SHA256.HashData(stream); return new AetheriaLegacyCatalogFile { RelativePath = Path.GetRelativePath(root, path).Replace('\\', '/'), Fingerprint = Convert.ToHexString(hash).ToLowerInvariant(), Bytes = stream.Length }; } static void ResetMaterializedStateOutput(string statePath) { DeleteFileIfExists(statePath); DeleteDirectoryIfExists($"{statePath}.records"); var meshDirectory = Path.ChangeExtension(statePath, ".cultmesh"); if (!string.Equals(meshDirectory, statePath, StringComparison.OrdinalIgnoreCase)) { DeleteDirectoryIfExists(meshDirectory); } } static void DeleteFileIfExists(string path) { if (File.Exists(path)) { File.Delete(path); } } static void DeleteDirectoryIfExists(string path) { if (Directory.Exists(path)) { Directory.Delete(path, recursive: true); } } internal enum LegacyCatalogLayout { Current, April2021 } internal static class LegacyCatalogReader { private static readonly Dictionary UnionNames = new() { [0] = "SimpleCommodityData", [1] = "CompoundCommodityData", [2] = "GearData", [3] = "HullData", [8] = "GalaxyMapLayerData", [9] = "NameFile", [11] = "PlayerData", [13] = "Faction", [15] = "OrbitData", [16] = "BodyData", [17] = "PersonalityAttribute", [25] = "AsteroidBeltData", [26] = "GasGiantData", [27] = "SunData", [28] = "PlanetData", [29] = "CargoBayData", [30] = "DockingBayData", [31] = "WeaponItemData" }; private static readonly Dictionary ItemCategories = new() { [0] = "simple-commodity", [1] = "compound-commodity", [2] = "gear", [3] = "hull", [29] = "cargo-bay", [30] = "docking-bay", [31] = "weapon" }; private static readonly HashSet ItemUnionKeys = [0, 1, 2, 3, 29, 30, 31]; private static readonly Dictionary HardpointTypes = CreateEnumMap([ "Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Reactor", "Radiator", "Shield", "Sensors", "Energy", "Ballistic", "Launcher", "ControlModule", "AetherDrive" ]); private static readonly Dictionary ItemRotations = CreateEnumMap([ "None", "CounterClockwise", "Reversed", "Clockwise" ]); private static readonly Dictionary HullTypes = CreateEnumMap(["Ship", "Station", "Turret"]); private static readonly Dictionary SimpleCommodityCategories = CreateEnumMap([ "Minerals", "Metals", "Alloys", "Compounds", "Organics", "Ammo", "Consumer" ]); private static readonly Dictionary CompoundCommodityCategories = CreateEnumMap([ "Wearables", "Consumables", "Luxuries", "Tools", "Manufacturing", "Assemblies" ]); private static readonly Dictionary WeaponRanges = CreateEnumMap(["Melee", "Short", "Medium", "Long"]); private static readonly Dictionary WeaponCalibers = CreateEnumMap(["Small", "Medium", "Large", "ExtraLarge"]); private static readonly Dictionary WeaponTypes = CreateEnumMap([ "ElectromagneticallyPropelled", "ExplosivelyPropelled", "Laser", "Electrostatic", "ParticleProjection", "Missile", "MicroMissile", "SplitMissile", "Mine", "Jet" ]); private static readonly Dictionary WeaponFireTypeFlags = new() { [1 << 0] = "Direct", [1 << 1] = "Guided", [1 << 2] = "Seeking", [1 << 3] = "Continuous", [1 << 4] = "Charged" }; private static readonly Dictionary WeaponModifierFlags = new() { [1 << 0] = "Airburst", [1 << 1] = "Incendiary", [1 << 2] = "ArmorPenetrating", [1 << 3] = "NegativeEntropy", [1 << 4] = "RapidFire", [1 << 5] = "Burst", [1 << 6] = "Cluster" }; private static readonly Dictionary BehaviorUnionNames = new() { [0] = "GuidedWeapon", [1] = "Launcher", [2] = "Reactor", [3] = "Radiator", [4] = "StatModifier", [5] = "Sensor", [6] = "Reflector", [7] = "Shield", [8] = "Thruster", [9] = "Wear", [10] = "VelocityConversion", [11] = "VelocityLimit", [12] = "AetherDrive", [15] = "Cooldown", [16] = "Heat", [18] = "ItemUsage", [20] = "Switch", [21] = "Trigger", [22] = "Visibility", [23] = "Thermotoggle", [24] = "EnergyDraw", [26] = "MiningTool", [28] = "ResourceScanner", [31] = "Capacitor", [32] = "Cockpit", [33] = "HeatStorage", [34] = "TurretController", [35] = "InstantWeapon", [36] = "ConstantWeapon", [37] = "ChargedWeapon", [38] = "AutoWeapon" }; public static IReadOnlyList Read( string catalogPath, LegacyCatalogLayout layout = LegacyCatalogLayout.Current) { if (!File.Exists(catalogPath)) { return []; } var bytes = File.ReadAllBytes(catalogPath); var reader = new MessagePackReader(bytes); var count = reader.ReadArrayHeader(); var entries = new List(count); for (var i = 0; i < count; i++) { entries.Add(ReadEntry(ref reader, layout)); } return entries; } public static LegacyCatalogEntry ReadSingle(string path) { var bytes = File.ReadAllBytes(path); var reader = new MessagePackReader(bytes); return ReadEntry(ref reader, LegacyCatalogLayout.Current); } private static LegacyCatalogEntry ReadEntry( ref MessagePackReader reader, LegacyCatalogLayout layout) { var unionHeader = reader.ReadArrayHeader(); if (unionHeader != 2) { throw new InvalidDataException($"Unexpected legacy catalog union header length: {unionHeader}."); } var unionKey = reader.ReadInt32(); var unionName = UnionNames.TryGetValue(unionKey, out var knownName) ? knownName : $"union:{unionKey}"; var payload = ReadArrayPayload(ref reader); var legacyId = GetGuid(payload, 0); var name = GetString(payload, 1); var description = GetString(payload, 2); var shape = ReadShape(payload, 5); var interiorShape = ReadInteriorShape(unionKey, payload, shape); var dockingMaxSize = unionKey == 30 ? ReadPoint(payload, 25) : PointFacts.Empty; var hardpoints = unionKey == 3 ? ReadHardpoints(payload, layout == LegacyCatalogLayout.April2021 ? 20 : 23) : []; var behaviorKey = unionKey == 31 || unionKey is 2 or 3 or 29 or 30 ? 11 : 10; var behaviorPayloads = ReadBehaviorPayloads(payload, behaviorKey); var itemCategory = GetItemCategory(unionKey); var isConsumable = itemCategory == "consumable"; var behaviorKinds = behaviorPayloads .Select(behavior => behavior.Kind) .Where(kind => !string.IsNullOrWhiteSpace(kind)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(kind => kind, StringComparer.OrdinalIgnoreCase) .ToArray(); var summary = new AetheriaLegacyCatalogEntrySummary { LegacyId = legacyId, UnionKey = unionKey, DocumentKind = unionName, Name = name, Note = "Mapped from stable legacy MessagePack array fields." }; return new LegacyCatalogEntry( summary, ItemUnionKeys.Contains(unionKey) ? new AetheriaItemDefinition { Name = name, Category = itemCategory, LegacyId = legacyId, Description = description, Mass = GetDouble(payload, 4), SpecificHeat = GetPositiveDoubleOrDefault(payload, 6, 1), Conductivity = GetPositiveDoubleOrDefault(payload, 7, 1), Volume = shape.OccupiedCells, ManufacturerLegacyId = GetOptionalGuid(payload, 3), Price = GetInt(payload, 8), ShapeWidth = shape.Width, ShapeHeight = shape.Height, OccupiedCells = shape.OccupiedCells, ShapeCells = shape.Cells, InteriorShapeWidth = interiorShape.Width, InteriorShapeHeight = interiorShape.Height, InteriorOccupiedCells = interiorShape.OccupiedCells, InteriorShapeCells = interiorShape.Cells, Hardpoints = hardpoints, BehaviorPayloads = behaviorPayloads, HardpointType = GetHardpointType(unionKey, payload, layout), HullType = unionKey == 3 ? GetEnumName(payload, layout == LegacyCatalogLayout.April2021 ? 22 : 25, HullTypes) : "", BehaviorKinds = behaviorKinds, BehaviorCount = behaviorPayloads.Length, MaxStack = unionKey == 0 ? GetInt(payload, 9) : 0, Stackable = isConsumable && GetBool(payload, 11), Duration = isConsumable ? GetDouble(payload, 12) : 0, Durability = unionKey is 2 or 3 or 29 or 30 or 31 ? GetDouble(payload, 12) : 0, MinimumTemperature = unionKey is 2 or 3 or 29 or 30 or 31 ? GetDouble(payload, 13) : 0, MaximumTemperature = unionKey is 2 or 3 or 29 or 30 or 31 ? GetDouble(payload, 14) : 0, ThermalPerformanceCurveKeys = unionKey is 2 or 3 or 29 or 30 or 31 ? ReadCurveKeys(payload, 17) : [], ThermalResilience = unionKey is 2 or 3 or 29 or 30 or 31 ? GetPositiveDoubleOrDefault(payload, 18, 1) : 1, AudioStats = layout == LegacyCatalogLayout.Current && unionKey is 2 or 3 or 29 or 30 or 31 ? ReadAudioStats(payload, 22) : [], EffectivenessCurveKeys = isConsumable ? ReadCurveKeys(payload, 14) : [], HullPrefab = unionKey == 3 ? GetString(payload, layout == LegacyCatalogLayout.April2021 ? 21 : 24) : "", HullGridOffset = unionKey == 3 ? GetDouble(payload, layout == LegacyCatalogLayout.April2021 ? 23 : 26) : 0, HullArmor = unionKey == 3 ? GetDouble(payload, layout == LegacyCatalogLayout.April2021 ? 24 : 27) : 0, HullDrag = unionKey == 3 ? GetDouble(payload, layout == LegacyCatalogLayout.April2021 ? 25 : 28) : 0, HullCanTow = layout == LegacyCatalogLayout.Current && unionKey == 3 && GetBool(payload, 29), DockingMaxSizeX = dockingMaxSize.X, DockingMaxSizeY = dockingMaxSize.Y, ActionBarIcon = layout == LegacyCatalogLayout.Current && unionKey is 2 or 3 or 29 or 30 or 31 ? GetString(payload, 20) : "", SimpleCommodityCategory = unionKey == 0 ? GetEnumName(payload, 10, SimpleCommodityCategories) : "", CompoundCommodityCategory = unionKey == 1 ? GetEnumName(payload, 11, CompoundCommodityCategories) : "", WeaponRange = unionKey == 31 ? GetEnumName(payload, 24, WeaponRanges) : "", WeaponCaliber = unionKey == 31 ? GetEnumName(payload, 25, WeaponCalibers) : "", WeaponType = unionKey == 31 ? GetEnumName(payload, 26, WeaponTypes) : "", WeaponFireTypes = unionKey == 31 ? GetFlags(payload, 27, WeaponFireTypeFlags) : "", WeaponModifiers = unionKey == 31 ? GetFlags(payload, 28, WeaponModifierFlags) : "", Tags = [unionName, "legacy-catalog", .. behaviorKinds.Select(kind => $"behavior:{kind}")] } : null, unionKey == 13 ? new AetheriaCorporation { Name = name, LegacyId = legacyId, ShortName = GetString(payload, 2), Description = GetString(payload, 3), GeonameFileLegacyId = GetOptionalGuid(payload, 9), BossHullLegacyId = GetOptionalGuid(payload, 10), InfluenceDistance = GetInt(payload, 11), AllegianceCount = GetMapCount(payload, 12), OverworldMusic = GetUInt(payload, 13), CombatMusic = GetUInt(payload, 14), BossMusic = GetUInt(payload, 15), Allegiances = ReadAllegiances(payload, 12) } : null, unionKey == 9 ? new AetheriaNameFile { Name = name, LegacyId = legacyId, NameCount = GetArrayLength(payload, 2), SampleNames = GetStringArraySample(payload, 2, 8), Names = GetStringArray(payload, 2) } : null); } private static Dictionary ReadArrayPayload(ref MessagePackReader reader) { var length = reader.ReadArrayHeader(); var values = new Dictionary(length); for (var index = 0; index < length; index++) { values[index] = ReadValue(ref reader); } return values; } private static object? ReadValue(ref MessagePackReader reader) { if (reader.NextMessagePackType == MessagePackType.Nil) { reader.ReadNil(); return null; } return reader.NextMessagePackType switch { MessagePackType.Integer => ReadInteger(ref reader), MessagePackType.Boolean => reader.ReadBoolean(), MessagePackType.Float => reader.ReadDouble(), MessagePackType.String => reader.ReadString(), MessagePackType.Binary => ReadBinary(ref reader), MessagePackType.Array => ReadNestedArray(ref reader), MessagePackType.Map => ReadNestedMap(ref reader), _ => SkipValue(ref reader) }; } private static long ReadInteger(ref MessagePackReader reader) { return reader.NextCode >= MessagePackCode.MinNegativeFixInt || reader.NextCode == MessagePackCode.Int8 || reader.NextCode == MessagePackCode.Int16 || reader.NextCode == MessagePackCode.Int32 || reader.NextCode == MessagePackCode.Int64 ? reader.ReadInt64() : (long) reader.ReadUInt64(); } private static object?[] ReadNestedArray(ref MessagePackReader reader) { var length = reader.ReadArrayHeader(); var values = new object?[length]; for (var index = 0; index < length; index++) { values[index] = ReadValue(ref reader); } return values; } private static byte[]? ReadBinary(ref MessagePackReader reader) { var bytes = reader.ReadBytes(); if (bytes == null) { return null; } var result = new byte[bytes.Value.Length]; var offset = 0; foreach (var segment in bytes.Value) { segment.Span.CopyTo(result.AsSpan(offset)); offset += segment.Length; } return result; } private static Dictionary ReadNestedMap(ref MessagePackReader reader) { var length = reader.ReadMapHeader(); var values = new Dictionary(length); for (var index = 0; index < length; index++) { var key = ReadValue(ref reader); values[FormatMapKey(key)] = ReadValue(ref reader); } return values; } private static string FormatMapKey(object? key) { return key switch { byte[] bytes when bytes.Length == 16 => new Guid(bytes).ToString("D"), string text => text, _ => key?.ToString() ?? "" }; } private static object? SkipValue(ref MessagePackReader reader) { reader.Skip(); return null; } private static string GetGuid(IReadOnlyDictionary payload, int key) { return payload.TryGetValue(key, out var value) && value is byte[] bytes && bytes.Length == 16 ? new Guid(bytes).ToString("D") : ""; } private static string GetOptionalGuid(IReadOnlyDictionary payload, int key) { var legacyId = GetGuid(payload, key); return Guid.TryParse(legacyId, out var guid) && guid == Guid.Empty ? "" : legacyId; } private static string GetString(IReadOnlyDictionary payload, int key) { return payload.TryGetValue(key, out var value) ? value as string ?? "" : ""; } private static double GetDouble(IReadOnlyDictionary payload, int key) { if (!payload.TryGetValue(key, out var value) || value == null) { return 0; } return value switch { double doubleValue => doubleValue, float floatValue => floatValue, long integerValue => integerValue, int integerValue => integerValue, _ => 0 }; } private static double GetPositiveDoubleOrDefault(IReadOnlyDictionary payload, int key, double fallback) { var value = GetDouble(payload, key); return value > 0 ? value : fallback; } private static int GetInt(IReadOnlyDictionary payload, int key) { if (!payload.TryGetValue(key, out var value) || value == null) { return 0; } return value switch { long integerValue => checked((int) integerValue), int integerValue => integerValue, double doubleValue => checked((int) doubleValue), float floatValue => checked((int) floatValue), _ => 0 }; } private static bool GetBool(IReadOnlyDictionary payload, int key) { return payload.TryGetValue(key, out var value) && value is true; } private static uint GetUInt(IReadOnlyDictionary payload, int key) { if (!payload.TryGetValue(key, out var value) || value == null) { return 0; } return value switch { long integerValue when integerValue >= 0 => checked((uint) integerValue), int integerValue when integerValue >= 0 => checked((uint) integerValue), double doubleValue when doubleValue >= 0 => checked((uint) doubleValue), float floatValue when floatValue >= 0 => checked((uint) floatValue), _ => 0 }; } private static int GetMapCount(IReadOnlyDictionary payload, int key) { return payload.TryGetValue(key, out var value) && value is Dictionary map ? map.Count : 0; } private static AetheriaCorporationAllegiance[] ReadAllegiances(IReadOnlyDictionary payload, int key) { if (!payload.TryGetValue(key, out var value) || value is not Dictionary map) { return []; } return map .Select(entry => new AetheriaCorporationAllegiance { CorporationLegacyId = NormalizeLegacyId(entry.Key), Weight = ReadDoubleValue(entry.Value) }) .Where(allegiance => !string.IsNullOrWhiteSpace(allegiance.CorporationLegacyId)) .ToArray(); } private static string NormalizeLegacyId(string legacyId) { return Guid.TryParse(legacyId, out var parsed) && parsed != Guid.Empty ? parsed.ToString("D") : ""; } private static int GetArrayLength(IReadOnlyDictionary payload, int key) { return payload.TryGetValue(key, out var value) && value is object?[] array ? array.Length : 0; } private static string GetHardpointType( int unionKey, IReadOnlyDictionary payload, LegacyCatalogLayout layout) { return unionKey switch { 3 => "Hull", 29 or 30 => "Tool", 2 or 31 => GetEnumName( payload, layout == LegacyCatalogLayout.April2021 ? 20 : 23, HardpointTypes), _ => "" }; } private static string GetItemCategory(int unionKey) { return ItemCategories.TryGetValue(unionKey, out var category) ? category : $"legacy-union:{unionKey}"; } private static string GetEnumName( IReadOnlyDictionary payload, int key, IReadOnlyDictionary names) { var value = GetInt(payload, key); return names.TryGetValue(value, out var name) ? name : ""; } private static string GetFlags( IReadOnlyDictionary payload, int key, IReadOnlyDictionary names) { var value = GetInt(payload, key); if (value == 0) { return "None"; } var flags = names .Where(entry => (value & entry.Key) == entry.Key) .Select(entry => entry.Value) .ToArray(); return flags.Length == 0 ? "" : string.Join("|", flags); } private static AetheriaItemHardpoint[] ReadHardpoints(IReadOnlyDictionary payload, int key) { if (!payload.TryGetValue(key, out var value) || value is not object?[] hardpoints) { return []; } return hardpoints .Select(ReadHardpoint) .Where(hardpoint => hardpoint != null) .Cast() .ToArray(); } private static AetheriaItemHardpoint? ReadHardpoint(object? value) { if (value is not object?[] fields) { return null; } var position = ReadPoint(fields.ElementAtOrDefault(1)); var shape = ReadShapeValue(fields.ElementAtOrDefault(2)); return new AetheriaItemHardpoint { Type = ReadEnumName(fields.ElementAtOrDefault(0), HardpointTypes), PositionX = position.X, PositionY = position.Y, ShapeWidth = shape.Width, ShapeHeight = shape.Height, OccupiedCells = shape.OccupiedCells, ShapeCells = shape.Cells, Transform = ReadStringValue(fields.ElementAtOrDefault(3)), Rotation = ReadEnumName(fields.ElementAtOrDefault(4), ItemRotations), Armor = ReadDoubleValue(fields.ElementAtOrDefault(5)) }; } private static AetheriaCurveKey[] ReadCurveKeys(IReadOnlyDictionary payload, int key) { if (!payload.TryGetValue(key, out var value)) { return []; } return ExtractCurveKeyTuples(value) .Select(tuple => new AetheriaCurveKey { Time = ReadDoubleValue(tuple.ElementAtOrDefault(0)), Value = ReadDoubleValue(tuple.ElementAtOrDefault(1)), InTangent = ReadDoubleValue(tuple.ElementAtOrDefault(2)), OutTangent = ReadDoubleValue(tuple.ElementAtOrDefault(3)) }) .OrderBy(curveKey => curveKey.Time) .ToArray(); } private static AetheriaItemAudioStat[] ReadAudioStats(IReadOnlyDictionary payload, int key) { if (!payload.TryGetValue(key, out var value) || value is not object?[] audioStats) { return []; } return audioStats .Select(ReadAudioStat) .Where(audioStat => audioStat != null) .Cast() .ToArray(); } private static AetheriaItemAudioStat? ReadAudioStat(object? value) { if (value is not object?[] fields) { return null; } return new AetheriaItemAudioStat { Parameter = ReadUIntValue(fields.ElementAtOrDefault(0)), Stat = ReadPerformanceStat(fields.ElementAtOrDefault(1)) }; } private static AetheriaItemPerformanceStat ReadPerformanceStat(object? value) { if (value is not object?[] fields) { return new AetheriaItemPerformanceStat(); } return new AetheriaItemPerformanceStat { Min = ReadDoubleValue(fields.ElementAtOrDefault(0)), Max = ReadDoubleValue(fields.ElementAtOrDefault(1)), HeatExponentMultiplier = ReadDoubleValue(fields.ElementAtOrDefault(2)), DurabilityExponentMultiplier = ReadDoubleValue(fields.ElementAtOrDefault(3)), QualityExponent = ReadDoubleValue(fields.ElementAtOrDefault(4)) }; } private static IEnumerable ExtractCurveKeyTuples(object? value) { if (value is not object?[] values) { return []; } if (LooksLikeCurveKey(values)) { return [values]; } var directKeys = values .OfType() .Where(LooksLikeCurveKey) .ToArray(); if (directKeys.Length > 0) { return directKeys; } return values .OfType() .SelectMany(ExtractCurveKeyTuples) .ToArray(); } private static bool LooksLikeCurveKey(object?[] values) { return values.Length >= 4 && IsNumber(values[0]) && IsNumber(values[1]) && IsNumber(values[2]) && IsNumber(values[3]); } private static bool IsNumber(object? value) { return value is byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal; } private static AetheriaBehaviorPayload[] ReadBehaviorPayloads(IReadOnlyDictionary payload, int key) { if (!payload.TryGetValue(key, out var value) || value is not object?[] behaviors) { return []; } var result = behaviors .Select(ReadBehaviorPayload) .Where(behavior => behavior != null) .Cast() .ToArray(); for (var index = 0; index < result.Length; index++) if (string.IsNullOrWhiteSpace(result[index].BehaviorId)) result[index].BehaviorId = $"behavior:{index}:{result[index].Kind}"; return result; } private static AetheriaBehaviorPayload? ReadBehaviorPayload(object? value) { if (value is not object?[] union || union.Length < 2) { return null; } var unionKey = union[0] switch { long longValue => checked((int) longValue), int intValue => intValue, _ => -1 }; var fields = union[1] is object?[] payload ? payload .Select((fieldValue, index) => new AetheriaBehaviorField { Key = index, Value = ReadBehaviorValue(fieldValue) }) .ToArray() : []; return new AetheriaBehaviorPayload { UnionKey = unionKey, Kind = BehaviorUnionNames.TryGetValue(unionKey, out var name) ? name : $"behavior:{unionKey}", Group = fields.FirstOrDefault(field => field.Key == 0)?.Value.Kind == "number" ? checked((int) fields[0].Value.NumberValue) : 0, Fields = fields }; } private static AetheriaBehaviorValue ReadBehaviorValue(object? value) { if (value == null) { return new AetheriaBehaviorValue { Kind = "nil" }; } return value switch { bool boolValue => new AetheriaBehaviorValue { Kind = "bool", BoolValue = boolValue }, string stringValue => new AetheriaBehaviorValue { Kind = "string", StringValue = stringValue }, byte[] bytes when bytes.Length == 16 => ReadLegacyBehaviorId(bytes), byte[] bytes => new AetheriaBehaviorValue { Kind = "binary", StringValue = Convert.ToHexString(bytes).ToLowerInvariant() }, object?[] array => new AetheriaBehaviorValue { Kind = "array", Children = array.Select(ReadBehaviorValue).ToArray() }, Dictionary map => new AetheriaBehaviorValue { Kind = "map", MapEntries = map .OrderBy(entry => entry.Key, StringComparer.Ordinal) .Select(entry => new AetheriaBehaviorMapEntry { Key = entry.Key, Value = ReadBehaviorValue(entry.Value) }) .ToArray() }, double doubleValue => new AetheriaBehaviorValue { Kind = "number", NumberValue = doubleValue }, float floatValue => new AetheriaBehaviorValue { Kind = "number", NumberValue = floatValue }, long integerValue => new AetheriaBehaviorValue { Kind = "number", NumberValue = integerValue }, int integerValue => new AetheriaBehaviorValue { Kind = "number", NumberValue = integerValue }, _ => new AetheriaBehaviorValue { Kind = "unknown", StringValue = value.ToString() ?? "" } }; } private static AetheriaBehaviorValue ReadLegacyBehaviorId(byte[] bytes) { var legacyId = new Guid(bytes); return new AetheriaBehaviorValue { Kind = "legacy-id", LegacyIdValue = legacyId.ToString("D"), ItemKeyValue = ProjectLegacyItemKey(legacyId) }; } private static string ProjectLegacyItemKey(Guid legacyItemId) { return legacyItemId == Guid.Empty ? "" : $"aetheria.item_definition:legacy:{legacyItemId:D}"; } private static Dictionary CreateEnumMap(string[] names) { return names .Select((name, index) => new { name, index }) .ToDictionary(entry => entry.index, entry => entry.name); } private static string[] GetStringArraySample(IReadOnlyDictionary payload, int key, int max) { return payload.TryGetValue(key, out var value) && value is object?[] array ? array.OfType().Take(max).ToArray() : []; } private static string[] GetStringArray(IReadOnlyDictionary payload, int key) { return payload.TryGetValue(key, out var value) && value is object?[] array ? array.OfType().ToArray() : []; } private static ShapeFacts ReadShape(IReadOnlyDictionary payload, int key) { if (!payload.TryGetValue(key, out var value) || value == null) { return ShapeFacts.Empty; } return ReadShapeValue(value); } private static ShapeFacts ReadInteriorShape( int unionKey, IReadOnlyDictionary payload, ShapeFacts itemShape) { return unionKey switch { 3 => itemShape.Shrink(), 29 or 30 => ReadShape(payload, 24), _ => ShapeFacts.Empty }; } private static ShapeFacts ReadShapeValue(object? value) { if (value == null) { return ShapeFacts.Empty; } var matrix = UnwrapShapeMatrix(value); if (matrix is not object?[] columns) { return ShapeFacts.Empty; } if (TryReadRectangularShape(columns, out var rectangularShape)) { return rectangularShape; } var width = columns.Length; var height = 0; var cellsByRow = new List(); for (var x = 0; x < columns.Length; x++) { var column = columns[x]; if (column is not object?[] cells) { if (column is bool occupiedCell) { height = Math.Max(height, 1); if (occupiedCell) { cellsByRow.Add(new AetheriaShapeCell { X = x, Y = 0 }); } } continue; } height = Math.Max(height, cells.Length); for (var y = 0; y < cells.Length; y++) { if (cells[y] is true) { cellsByRow.Add(new AetheriaShapeCell { X = x, Y = y }); } } } return new ShapeFacts( width, height, cellsByRow .OrderBy(cell => cell.Y) .ThenBy(cell => cell.X) .ToArray()); } private static bool TryReadRectangularShape(object?[] envelope, out ShapeFacts shape) { shape = ShapeFacts.Empty; if (envelope.Length != 3 || envelope[2] is not Array flattened) { return false; } var width = ReadIntValue(envelope[0]); var height = ReadIntValue(envelope[1]); if (width <= 0 || height <= 0 || flattened.Length != width * height) { return false; } var cells = new List(); for (var x = 0; x < width; x++) for (var y = 0; y < height; y++) { if (flattened.GetValue(x * height + y) is true) { cells.Add(new AetheriaShapeCell { X = x, Y = y }); } } shape = new ShapeFacts(width, height, cells .OrderBy(cell => cell.Y) .ThenBy(cell => cell.X) .ToArray()); return true; } private static object? UnwrapShapeMatrix(object? value) { return value is object?[] shapeObject && shapeObject.Length == 1 ? shapeObject[0] : value; } private static PointFacts ReadPoint(object? value) { if (value is not object?[] coordinates) { return PointFacts.Empty; } return new PointFacts( ReadIntValue(coordinates.ElementAtOrDefault(0)), ReadIntValue(coordinates.ElementAtOrDefault(1))); } private static PointFacts ReadPoint(IReadOnlyDictionary payload, int key) { return payload.TryGetValue(key, out var value) ? ReadPoint(value) : PointFacts.Empty; } private static string ReadEnumName(object? value, IReadOnlyDictionary names) { var index = ReadIntValue(value); return names.TryGetValue(index, out var name) ? name : ""; } private static int ReadIntValue(object? value) { return value switch { long integerValue => checked((int) integerValue), int integerValue => integerValue, double doubleValue => checked((int) doubleValue), float floatValue => checked((int) floatValue), _ => 0 }; } private static uint ReadUIntValue(object? value) { return value switch { long integerValue when integerValue >= 0 => checked((uint)integerValue), int integerValue when integerValue >= 0 => checked((uint)integerValue), double doubleValue when doubleValue >= 0 => checked((uint)doubleValue), float floatValue when floatValue >= 0 => checked((uint)floatValue), _ => 0 }; } private static double ReadDoubleValue(object? value) { return value switch { double doubleValue => doubleValue, float floatValue => floatValue, long integerValue => integerValue, int integerValue => integerValue, _ => 0 }; } private static string ReadStringValue(object? value) { return value as string ?? ""; } } internal sealed record LegacyCatalogEntry( AetheriaLegacyCatalogEntrySummary Summary, AetheriaItemDefinition? ItemDefinition, AetheriaCorporation? Corporation, AetheriaNameFile? NameFile); internal readonly record struct ShapeFacts(int Width, int Height, AetheriaShapeCell[] Cells) { public static ShapeFacts Empty { get; } = new(0, 0, []); public int OccupiedCells => Cells.Length; public ShapeFacts Shrink() { if (Width == 0 || Height == 0 || Cells.Length == 0) { return Empty; } var occupied = Cells .Select(cell => (cell.X, cell.Y)) .ToHashSet(); var interior = Cells .Where(cell => IsOccupied(occupied, cell.X - 1, cell.Y - 1) && IsOccupied(occupied, cell.X, cell.Y - 1) && IsOccupied(occupied, cell.X + 1, cell.Y - 1) && IsOccupied(occupied, cell.X - 1, cell.Y) && IsOccupied(occupied, cell.X + 1, cell.Y) && IsOccupied(occupied, cell.X - 1, cell.Y + 1) && IsOccupied(occupied, cell.X, cell.Y + 1) && IsOccupied(occupied, cell.X + 1, cell.Y + 1)) .Select(cell => new AetheriaShapeCell { X = cell.X, Y = cell.Y }) .ToArray(); return new ShapeFacts(Width, Height, interior); } private static bool IsOccupied(IReadOnlySet<(int X, int Y)> occupied, int x, int y) { return occupied.Contains((x, y)); } } internal readonly record struct PointFacts(int X, int Y) { public static PointFacts Empty { get; } = new(0, 0); }