Aetheria/Aetheria.State.Import/Program.cs

1175 lines
40 KiB
C#
Raw Normal View History

2026-06-13 05:53:18 +01:00
using System.Security.Cryptography;
using Aetheria.State;
using Aetheria.State.Documents;
using Aetheria.State.Migration;
2026-06-13 06:15:55 +01:00
using MessagePack;
2026-06-13 05:53:18 +01:00
var root = args.Length > 0 ? Path.GetFullPath(args[0]) : Directory.GetCurrentDirectory();
var statePath = args.Length > 1
? Path.GetFullPath(args[1])
: AetheriaStatePaths.ResolveDefaultStatePath(root);
2026-06-13 06:52:19 +01:00
var sourceRoot = Path.GetRelativePath(Directory.GetCurrentDirectory(), root).Replace('\\', '/');
var outputStatePath = Path.GetRelativePath(root, statePath).Replace('\\', '/');
2026-06-13 05:53:18 +01:00
var gameData = Path.Combine(root, "GameData");
var catalogPath = Path.Combine(gameData, "AetherDB.msgpack");
var nameFilesRoot = Path.Combine(gameData, "NameFile");
var capturedAtUtc = DateTimeOffset.UtcNow.ToString("O");
var catalog = CaptureFile(root, catalogPath);
var nameFiles = Directory.Exists(nameFilesRoot)
? Directory.EnumerateFiles(nameFilesRoot, "*.msgpack", SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Select(path => CaptureFile(root, path))
.ToArray()
: [];
2026-06-13 06:15:55 +01:00
var entries = LegacyCatalogReader.Read(catalogPath);
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 => 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();
2026-06-13 05:53:18 +01:00
2026-06-13 10:29:12 +01:00
ResetMaterializedStateOutput(statePath);
2026-06-13 05:53:18 +01:00
await using var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-legacy-catalog-import");
await node.PutLegacyCatalogQuarantineAsync(new AetheriaLegacyCatalogQuarantine
{
2026-06-13 06:52:19 +01:00
RootPath = sourceRoot,
2026-06-13 05:53:18 +01:00
CapturedAtUtc = capturedAtUtc,
CatalogFile = catalog.RelativePath,
CatalogFingerprint = catalog.Fingerprint,
CatalogBytes = catalog.Bytes,
NameFiles = nameFiles,
Notes =
[
2026-06-13 06:15:55 +01:00
"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."
2026-06-13 05:53:18 +01:00
]
});
await node.PutMigrationLedgerAsync(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
2026-06-13 06:15:55 +01:00
},
new AetheriaMigrationCount
{
DocumentType = "aetheria.item_definition.v1",
Count = itemDefinitions.Length
},
new AetheriaMigrationCount
{
2026-06-13 13:18:03 +01:00
DocumentType = "aetheria.corporation.v2",
2026-06-13 06:15:55 +01:00
Count = corporations.Length
},
new AetheriaMigrationCount
{
2026-06-13 12:46:34 +01:00
DocumentType = "aetheria.name_file.v2",
2026-06-13 06:15:55 +01:00
Count = parsedNameFiles.Length
2026-06-13 05:53:18 +01:00
}
],
2026-06-13 06:15:55 +01:00
LegacyCatalogEntries = entries.Select(entry => entry.Summary).ToArray(),
2026-06-13 05:53:18 +01:00
Notes =
[
2026-06-13 06:15:55 +01:00
"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.",
2026-06-13 06:52:19 +01:00
$"State path: {outputStatePath}"
2026-06-13 05:53:18 +01:00
]
});
2026-06-13 06:15:55 +01:00
foreach (var item in itemDefinitions)
{
2026-06-13 07:46:28 +01:00
await node.PutLegacyItemDefinitionAsync(item);
2026-06-13 06:15:55 +01:00
}
foreach (var corporation in corporations)
{
2026-06-13 07:46:28 +01:00
await node.PutLegacyCorporationAsync(corporation);
2026-06-13 06:15:55 +01:00
}
foreach (var nameFile in parsedNameFiles)
{
2026-06-13 07:46:28 +01:00
await node.PutLegacyNameFileAsync(nameFile);
2026-06-13 06:15:55 +01:00
}
2026-06-13 08:37:10 +01:00
await node.PutCatalogSurfaceAsync(AetheriaCatalogSurfaceProjector.Build(node.ReadCatalogSnapshot(), capturedAtUtc));
2026-06-13 05:53:18 +01:00
await node.FlushAsync();
2026-06-13 06:15:55 +01:00
Console.WriteLine($"Aetheria legacy catalog mapped into typed state: {statePath}");
2026-06-13 05:53:18 +01:00
Console.WriteLine($"Catalog: {catalog.RelativePath} {catalog.Bytes} bytes {catalog.Fingerprint}");
Console.WriteLine($"Name files: {nameFiles.Length}");
2026-06-13 06:15:55 +01:00
Console.WriteLine($"Mapped items: {itemDefinitions.Length}");
Console.WriteLine($"Mapped factions: {corporations.Length}");
Console.WriteLine($"Mapped name files: {parsedNameFiles.Length}");
2026-06-13 05:53:18 +01:00
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
};
}
2026-06-13 06:15:55 +01:00
2026-06-13 10:29:12 +01:00
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);
}
}
2026-06-13 06:15:55 +01:00
internal static class LegacyCatalogReader
{
private static readonly Dictionary<int, string> 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 HashSet<int> ItemUnionKeys = [0, 1, 2, 3, 29, 30, 31];
2026-06-13 09:01:13 +01:00
private static readonly Dictionary<int, string> HardpointTypes = CreateEnumMap([
"Hull",
"Tool",
"Thermal",
"Thruster",
"WarpDrive",
"Reactor",
"Radiator",
"Shield",
"Sensors",
"Energy",
"Ballistic",
"Launcher",
"ControlModule",
"AetherDrive"
]);
private static readonly Dictionary<int, string> ItemRotations = CreateEnumMap([
"None",
"CounterClockwise",
"Reversed",
"Clockwise"
]);
2026-06-13 09:01:13 +01:00
private static readonly Dictionary<int, string> HullTypes = CreateEnumMap(["Ship", "Station", "Turret"]);
private static readonly Dictionary<int, string> SimpleCommodityCategories = CreateEnumMap([
"Minerals",
"Metals",
"Alloys",
"Compounds",
"Organics",
"Ammo",
"Consumer"
]);
private static readonly Dictionary<int, string> CompoundCommodityCategories = CreateEnumMap([
"Wearables",
"Consumables",
"Luxuries",
"Tools",
"Manufacturing",
"Assemblies"
]);
2026-06-13 09:01:13 +01:00
private static readonly Dictionary<int, string> WeaponRanges = CreateEnumMap(["Melee", "Short", "Medium", "Long"]);
private static readonly Dictionary<int, string> WeaponCalibers = CreateEnumMap(["Small", "Medium", "Large", "ExtraLarge"]);
private static readonly Dictionary<int, string> WeaponTypes = CreateEnumMap([
"ElectromagneticallyPropelled",
"ExplosivelyPropelled",
"Laser",
"Electrostatic",
"ParticleProjection",
"Missile",
"MicroMissile",
"SplitMissile",
"Mine",
"Jet"
]);
private static readonly Dictionary<int, string> WeaponFireTypeFlags = new()
{
[1 << 0] = "Direct",
[1 << 1] = "Guided",
[1 << 2] = "Seeking",
[1 << 3] = "Continuous",
[1 << 4] = "Charged"
};
private static readonly Dictionary<int, string> 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<int, string> 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"
};
2026-06-13 06:15:55 +01:00
public static IReadOnlyList<LegacyCatalogEntry> Read(string catalogPath)
{
if (!File.Exists(catalogPath))
{
return [];
}
var bytes = File.ReadAllBytes(catalogPath);
var reader = new MessagePackReader(bytes);
var count = reader.ReadArrayHeader();
var entries = new List<LegacyCatalogEntry>(count);
for (var i = 0; i < count; i++)
{
entries.Add(ReadEntry(ref reader));
}
return entries;
}
public static LegacyCatalogEntry ReadSingle(string path)
{
var bytes = File.ReadAllBytes(path);
var reader = new MessagePackReader(bytes);
return ReadEntry(ref reader);
}
private static LegacyCatalogEntry ReadEntry(ref MessagePackReader reader)
{
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);
2026-06-13 08:09:41 +01:00
var shape = ReadShape(payload, 5);
var interiorShape = ReadInteriorShape(unionKey, payload, shape);
2026-06-14 05:39:15 +01:00
var dockingMaxSize = unionKey == 30 ? ReadPoint(payload, 25) : PointFacts.Empty;
var hardpoints = unionKey == 3 ? ReadHardpoints(payload, 23) : [];
2026-06-13 10:01:20 +01:00
var behaviorKey = unionKey == 31 || unionKey is 2 or 3 or 29 or 30 ? 11 : 10;
var behaviorPayloads = ReadBehaviorPayloads(payload, behaviorKey);
var behaviorKinds = behaviorPayloads
.Select(behavior => behavior.Kind)
.Where(kind => !string.IsNullOrWhiteSpace(kind))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(kind => kind, StringComparer.OrdinalIgnoreCase)
.ToArray();
2026-06-13 06:15:55 +01:00
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 = unionName,
LegacyId = legacyId,
Description = description,
Mass = GetDouble(payload, 4),
2026-06-14 04:35:51 +01:00
SpecificHeat = GetPositiveDoubleOrDefault(payload, 6, 1),
2026-06-14 05:08:39 +01:00
Conductivity = GetPositiveDoubleOrDefault(payload, 7, 1),
2026-06-13 08:09:41 +01:00
Volume = shape.OccupiedCells,
2026-06-13 08:24:37 +01:00
ManufacturerLegacyId = GetOptionalGuid(payload, 3),
2026-06-13 08:09:41 +01:00
Price = GetInt(payload, 8),
ShapeWidth = shape.Width,
ShapeHeight = shape.Height,
OccupiedCells = shape.OccupiedCells,
2026-06-13 09:32:24 +01:00
ShapeCells = shape.Cells,
InteriorShapeWidth = interiorShape.Width,
InteriorShapeHeight = interiorShape.Height,
InteriorOccupiedCells = interiorShape.OccupiedCells,
InteriorShapeCells = interiorShape.Cells,
Hardpoints = hardpoints,
2026-06-13 10:01:20 +01:00
BehaviorPayloads = behaviorPayloads,
2026-06-13 09:01:13 +01:00
HardpointType = GetHardpointType(unionKey, payload),
HullType = unionKey == 3 ? GetEnumName(payload, 25, HullTypes) : "",
BehaviorKinds = behaviorKinds,
2026-06-13 10:01:20 +01:00
BehaviorCount = behaviorPayloads.Length,
2026-06-13 09:01:13 +01:00
MaxStack = unionKey == 0 ? GetInt(payload, 9) : 0,
Stackable = unionName == "ConsumableItemData" && GetBool(payload, 11),
Duration = unionName == "ConsumableItemData" ? GetDouble(payload, 12) : 0,
2026-06-13 09:01:13 +01:00
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 = unionKey is 2 or 3 or 29 or 30 or 31 ? ReadAudioStats(payload, 22) : [],
EffectivenessCurveKeys = unionName == "ConsumableItemData" ? ReadCurveKeys(payload, 14) : [],
HullPrefab = unionKey == 3 ? GetString(payload, 24) : "",
2026-06-14 05:08:39 +01:00
HullGridOffset = unionKey == 3 ? GetDouble(payload, 26) : 0,
HullArmor = unionKey == 3 ? GetDouble(payload, 27) : 0,
HullDrag = unionKey == 3 ? GetDouble(payload, 28) : 0,
HullCanTow = unionKey == 3 && GetBool(payload, 29),
2026-06-14 05:39:15 +01:00
DockingMaxSizeX = dockingMaxSize.X,
DockingMaxSizeY = dockingMaxSize.Y,
2026-06-14 06:47:47 +01:00
ActionBarIcon = 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) : "",
2026-06-13 09:01:13 +01:00
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}")]
2026-06-13 06:15:55 +01:00
}
: null,
unionKey == 13
? new AetheriaCorporation
{
Name = name,
LegacyId = legacyId,
2026-06-13 08:09:41 +01:00
ShortName = GetString(payload, 2),
Description = GetString(payload, 3),
2026-06-13 08:24:37 +01:00
GeonameFileLegacyId = GetOptionalGuid(payload, 9),
BossHullLegacyId = GetOptionalGuid(payload, 10),
2026-06-13 08:09:41 +01:00
InfluenceDistance = GetInt(payload, 11),
AllegianceCount = GetMapCount(payload, 12),
OverworldMusic = GetUInt(payload, 13),
CombatMusic = GetUInt(payload, 14),
2026-06-13 13:18:03 +01:00
BossMusic = GetUInt(payload, 15),
Allegiances = ReadAllegiances(payload, 12)
2026-06-13 06:15:55 +01:00
}
: null,
unionKey == 9
? new AetheriaNameFile
{
Name = name,
LegacyId = legacyId,
NameCount = GetArrayLength(payload, 2),
2026-06-13 12:46:34 +01:00
SampleNames = GetStringArraySample(payload, 2, 8),
Names = GetStringArray(payload, 2)
2026-06-13 06:15:55 +01:00
}
: null);
}
private static Dictionary<int, object?> ReadArrayPayload(ref MessagePackReader reader)
{
var length = reader.ReadArrayHeader();
var values = new Dictionary<int, object?>(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<string, object?> ReadNestedMap(ref MessagePackReader reader)
{
var length = reader.ReadMapHeader();
var values = new Dictionary<string, object?>(length);
for (var index = 0; index < length; index++)
{
var key = ReadValue(ref reader);
2026-06-13 13:18:03 +01:00
values[FormatMapKey(key)] = ReadValue(ref reader);
2026-06-13 06:15:55 +01:00
}
return values;
}
2026-06-13 13:18:03 +01:00
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() ?? ""
};
}
2026-06-13 06:15:55 +01:00
private static object? SkipValue(ref MessagePackReader reader)
{
reader.Skip();
return null;
}
private static string GetGuid(IReadOnlyDictionary<int, object?> payload, int key)
{
return payload.TryGetValue(key, out var value) && value is byte[] bytes && bytes.Length == 16
? new Guid(bytes).ToString("D")
: "";
}
2026-06-13 08:24:37 +01:00
private static string GetOptionalGuid(IReadOnlyDictionary<int, object?> payload, int key)
{
var legacyId = GetGuid(payload, key);
return Guid.TryParse(legacyId, out var guid) && guid == Guid.Empty ? "" : legacyId;
}
2026-06-13 06:15:55 +01:00
private static string GetString(IReadOnlyDictionary<int, object?> payload, int key)
{
return payload.TryGetValue(key, out var value) ? value as string ?? "" : "";
}
private static double GetDouble(IReadOnlyDictionary<int, object?> 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
};
}
2026-06-14 04:35:51 +01:00
private static double GetPositiveDoubleOrDefault(IReadOnlyDictionary<int, object?> payload, int key, double fallback)
{
var value = GetDouble(payload, key);
return value > 0 ? value : fallback;
}
2026-06-13 08:09:41 +01:00
private static int GetInt(IReadOnlyDictionary<int, object?> 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
};
}
2026-06-13 09:01:13 +01:00
private static bool GetBool(IReadOnlyDictionary<int, object?> payload, int key)
{
return payload.TryGetValue(key, out var value) && value is true;
}
2026-06-13 08:09:41 +01:00
private static uint GetUInt(IReadOnlyDictionary<int, object?> 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<int, object?> payload, int key)
{
return payload.TryGetValue(key, out var value) && value is Dictionary<string, object?> map ? map.Count : 0;
}
2026-06-13 13:18:03 +01:00
private static AetheriaCorporationAllegiance[] ReadAllegiances(IReadOnlyDictionary<int, object?> payload, int key)
{
if (!payload.TryGetValue(key, out var value) || value is not Dictionary<string, object?> 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")
: "";
}
2026-06-13 06:15:55 +01:00
private static int GetArrayLength(IReadOnlyDictionary<int, object?> payload, int key)
{
return payload.TryGetValue(key, out var value) && value is object?[] array ? array.Length : 0;
}
2026-06-13 09:01:13 +01:00
private static string GetHardpointType(int unionKey, IReadOnlyDictionary<int, object?> payload)
{
return unionKey switch
{
3 => "Hull",
29 or 30 => "Tool",
2 or 31 => GetEnumName(payload, 23, HardpointTypes),
_ => ""
};
}
private static string GetEnumName(
IReadOnlyDictionary<int, object?> payload,
int key,
IReadOnlyDictionary<int, string> names)
{
var value = GetInt(payload, key);
return names.TryGetValue(value, out var name) ? name : "";
}
private static string GetFlags(
IReadOnlyDictionary<int, object?> payload,
int key,
IReadOnlyDictionary<int, string> 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<int, object?> 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<AetheriaItemHardpoint>()
.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<int, object?> 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<int, object?> 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<AetheriaItemAudioStat>()
.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<object?[]> ExtractCurveKeyTuples(object? value)
{
if (value is not object?[] values)
{
return [];
}
if (LooksLikeCurveKey(values))
{
return [values];
}
var directKeys = values
.OfType<object?[]>()
.Where(LooksLikeCurveKey)
.ToArray();
if (directKeys.Length > 0)
{
return directKeys;
}
return values
.OfType<object?[]>()
.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;
}
2026-06-13 10:01:20 +01:00
private static AetheriaBehaviorPayload[] ReadBehaviorPayloads(IReadOnlyDictionary<int, object?> payload, int key)
2026-06-13 09:01:13 +01:00
{
if (!payload.TryGetValue(key, out var value) || value is not object?[] behaviors)
{
return [];
}
return behaviors
2026-06-13 10:01:20 +01:00
.Select(ReadBehaviorPayload)
.Where(behavior => behavior != null)
.Cast<AetheriaBehaviorPayload>()
2026-06-13 09:01:13 +01:00
.ToArray();
}
2026-06-13 10:01:20 +01:00
private static AetheriaBehaviorPayload? ReadBehaviorPayload(object? value)
2026-06-13 09:01:13 +01:00
{
2026-06-13 10:01:20 +01:00
if (value is not object?[] union || union.Length < 2)
2026-06-13 09:01:13 +01:00
{
2026-06-13 10:01:20 +01:00
return null;
2026-06-13 09:01:13 +01:00
}
var unionKey = union[0] switch
{
long longValue => checked((int) longValue),
int intValue => intValue,
_ => -1
};
2026-06-13 10:01:20 +01:00
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 => new AetheriaBehaviorValue
{
Kind = "legacy-id",
LegacyIdValue = new Guid(bytes).ToString("D")
},
byte[] bytes => new AetheriaBehaviorValue
{
Kind = "binary",
StringValue = Convert.ToHexString(bytes).ToLowerInvariant()
},
object?[] array => new AetheriaBehaviorValue
{
Kind = "array",
Children = array.Select(ReadBehaviorValue).ToArray()
},
Dictionary<string, object?> 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() ?? ""
}
};
2026-06-13 09:01:13 +01:00
}
private static Dictionary<int, string> CreateEnumMap(string[] names)
{
return names
.Select((name, index) => new { name, index })
.ToDictionary(entry => entry.index, entry => entry.name);
}
2026-06-13 06:15:55 +01:00
private static string[] GetStringArraySample(IReadOnlyDictionary<int, object?> payload, int key, int max)
{
return payload.TryGetValue(key, out var value) && value is object?[] array
? array.OfType<string>().Take(max).ToArray()
: [];
}
2026-06-13 08:09:41 +01:00
2026-06-13 12:46:34 +01:00
private static string[] GetStringArray(IReadOnlyDictionary<int, object?> payload, int key)
{
return payload.TryGetValue(key, out var value) && value is object?[] array
? array.OfType<string>().ToArray()
: [];
}
2026-06-13 08:09:41 +01:00
private static ShapeFacts ReadShape(IReadOnlyDictionary<int, object?> payload, int key)
{
if (!payload.TryGetValue(key, out var value) || value == null)
{
2026-06-13 09:32:24 +01:00
return ShapeFacts.Empty;
2026-06-13 08:09:41 +01:00
}
return ReadShapeValue(value);
}
private static ShapeFacts ReadInteriorShape(
int unionKey,
IReadOnlyDictionary<int, object?> 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;
}
2026-06-13 08:09:41 +01:00
var matrix = UnwrapShapeMatrix(value);
if (matrix is not object?[] columns)
{
2026-06-13 09:32:24 +01:00
return ShapeFacts.Empty;
2026-06-13 08:09:41 +01:00
}
var width = columns.Length;
var height = 0;
2026-06-13 09:32:24 +01:00
var cellsByRow = new List<AetheriaShapeCell>();
for (var x = 0; x < columns.Length; x++)
2026-06-13 08:09:41 +01:00
{
2026-06-13 09:32:24 +01:00
var column = columns[x];
2026-06-13 08:09:41 +01:00
if (column is not object?[] cells)
{
if (column is bool occupiedCell)
{
height = Math.Max(height, 1);
if (occupiedCell)
{
2026-06-13 09:32:24 +01:00
cellsByRow.Add(new AetheriaShapeCell { X = x, Y = 0 });
2026-06-13 08:09:41 +01:00
}
}
continue;
}
height = Math.Max(height, cells.Length);
2026-06-13 09:32:24 +01:00
for (var y = 0; y < cells.Length; y++)
{
if (cells[y] is true)
{
cellsByRow.Add(new AetheriaShapeCell { X = x, Y = y });
}
}
2026-06-13 08:09:41 +01:00
}
2026-06-13 09:32:24 +01:00
return new ShapeFacts(
width,
height,
cellsByRow
.OrderBy(cell => cell.Y)
.ThenBy(cell => cell.X)
.ToArray());
2026-06-13 08:09:41 +01:00
}
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)));
}
2026-06-14 05:39:15 +01:00
private static PointFacts ReadPoint(IReadOnlyDictionary<int, object?> payload, int key)
{
return payload.TryGetValue(key, out var value)
? ReadPoint(value)
: PointFacts.Empty;
}
private static string ReadEnumName(object? value, IReadOnlyDictionary<int, string> 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 ?? "";
}
2026-06-13 06:15:55 +01:00
}
internal sealed record LegacyCatalogEntry(
AetheriaLegacyCatalogEntrySummary Summary,
AetheriaItemDefinition? ItemDefinition,
AetheriaCorporation? Corporation,
AetheriaNameFile? NameFile);
2026-06-13 08:09:41 +01:00
2026-06-13 09:32:24 +01:00
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);
2026-06-13 09:32:24 +01:00
}