From d1d458f8eefae04d79bb6c3df298c487c2985b2f Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 4 Oct 2026 12:18:36 +0200 Subject: [PATCH] Add a JSON format for the vehicle database --- StarterNG.Tests/VehicleJsonTests.cs | 199 ++++++++++++++++ .../Application/Abstractions/IGamePaths.cs | 3 + StarterNG/Domain/Vehicles/VehicleTexture.cs | 23 ++ .../Infrastructure/Adapters/GamePaths.cs | 2 + .../Vehicles/TexturesTxtParser.cs | 12 +- .../Vehicles/VehicleJsonDocument.cs | 124 ++++++++++ .../Vehicles/VehicleJsonRepository.cs | 101 ++++++++ .../Vehicles/VehicleJsonSerializer.cs | 215 ++++++++++++++++++ 8 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 StarterNG.Tests/VehicleJsonTests.cs create mode 100644 StarterNG/Infrastructure/Vehicles/VehicleJsonDocument.cs create mode 100644 StarterNG/Infrastructure/Vehicles/VehicleJsonRepository.cs create mode 100644 StarterNG/Infrastructure/Vehicles/VehicleJsonSerializer.cs diff --git a/StarterNG.Tests/VehicleJsonTests.cs b/StarterNG.Tests/VehicleJsonTests.cs new file mode 100644 index 0000000..2e48eed --- /dev/null +++ b/StarterNG.Tests/VehicleJsonTests.cs @@ -0,0 +1,199 @@ +using System.Text; +using StarterNG.Domain.Vehicles; +using StarterNG.Infrastructure.Vehicles; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class VehicleJsonTests +{ + private static readonly TexturesTxtParser Parser = new(); + + private static readonly VehicleJsonSerializer Serializer = new(); + + /// + /// A vehicle folder exercising every piece of the legacy syntax at once: the + /// headers and comments that carry no data, a category marker, a credit line + /// with Polish text, an alternative model, a wreck and an archival section. + /// + private const string Ep07 = """ + # EP07 - lokomotywy elektryczne + @ep07 + !=e + ep07-135.mat=ep07.e3d,ep07 // 1.0,EP07-135,PKP Intercity,Warszawa Grochów,2019-04-01,Wrocław,Zażółć,Gęślą Jaźń + ep07-174.mat=ep07.e3d,ep07=ep07p.e3d,ep07p + ep07wrak.mat=ep07.e3d,ep07 + linia bez znaku rownosci + $a + ep07-stary.mat=ep07.e3d,ep07 + """; + + /// A unit whose cars are coupled into a fixed set. + private const string En57 = """ + ^2 + en57-1000ra.mat=en57.e3d,en57 + en57-1000rb.mat=en57.e3d,en57 + """; + + [Fact] + public void An_entry_written_out_and_read_back_is_the_entry_it_started_as() + { + var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!; + + var restored = Serializer.Read(Serializer.Write(entry)); + + Assert.Equal(Describe(entry), Describe(restored!)); + } + + [Fact] + public void The_catalogue_built_from_json_matches_the_one_built_from_textures_txt() + { + var files = new InMemoryFileSystem() + .WithLegacyFile(Dynamic("pkp", "ep07"), Ep07) + .WithLegacyFile(Dynamic("pkp", "en57"), En57); + var installation = new TestInstallation(files); + + installation.Vehicles.Load(installation.Library.Vehicles); + var fromJson = ReloadThroughJson(installation); + + Assert.Equal(5, installation.Library.Vehicles.Textures.Count); + Assert.Equal(Describe(installation.Library.Vehicles), Describe(fromJson)); + } + + [Fact] + public void Headers_and_unparsable_lines_survive_the_trip_so_nothing_is_dropped() + { + var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!; + + var restored = Serializer.Read(Serializer.Write(entry))!; + + Assert.Equal(new[] { "# EP07 - lokomotywy elektryczne", "@ep07", "linia bez znaku rownosci" }, + restored.Unknown); + } + + [Fact] + public void Polish_text_is_written_as_itself_rather_than_as_escapes() + { + var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!; + + string json = Serializer.Write(entry); + + Assert.Contains("Warszawa Grochów", json); + Assert.DoesNotContain("\\u", json); + } + + [Fact] + public void The_merged_database_is_read_before_the_packages_layered_on_it() + { + var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!; + var package = Parser.Parse("pkp/en57/", Lines(En57))!; + var files = new InMemoryFileSystem() + .WithFile(Database("vehicles.json"), Serializer.WriteCollection(new[] { entry })) + .WithFile(Database("aaa-pakiet.json"), Serializer.Write(package)); + var installation = new TestInstallation(files); + var catalog = new VehicleCatalog(installation.MiniTextures); + + int liveries = Repository(installation).Load(catalog); + + Assert.Equal(6, liveries); + Assert.Equal("legacy:pkp/ep07/ep07-135", catalog.Textures[0].Uuid); + } + + [Fact] + public void An_entry_from_a_newer_format_is_refused_instead_of_half_read() + { + var files = new InMemoryFileSystem() + .WithFile(Database("z-przyszlosci.json"), + """{"uuid":"x","schema_version":99,"textures":[{"skinfile":"ep07-135"}]}"""); + var installation = new TestInstallation(files); + var catalog = new VehicleCatalog(installation.MiniTextures); + + int liveries = Repository(installation).Load(catalog); + + Assert.Equal(0, liveries); + Assert.Empty(catalog.Textures); + Assert.Contains("z-przyszlosci.json", installation.Log.Text); + } + + [Fact] + public void An_unreadable_package_is_logged_and_leaves_the_rest_of_the_database_alone() + { + var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!; + var files = new InMemoryFileSystem() + .WithFile(Database("aaa-polamany.json"), "{ to nie jest json") + .WithFile(Database("bbb-dobry.json"), Serializer.Write(entry)); + var installation = new TestInstallation(files); + var catalog = new VehicleCatalog(installation.MiniTextures); + + int liveries = Repository(installation).Load(catalog); + + Assert.Equal(4, liveries); + Assert.Contains("aaa-polamany.json", installation.Log.Text); + } + + private static VehicleJsonRepository Repository(TestInstallation installation) => + new(installation.Files, installation.Paths, installation.Log, Serializer); + + /// + /// Writes every vehicle folder of an installation out as its own package file + /// and loads the catalogue back from those, which is the migration the starter + /// will do silently once this format is the one it reads. + /// + private static VehicleCatalog ReloadThroughJson(TestInstallation installation) + { + foreach (string maker in installation.Files.GetDirectories(installation.Paths.Dynamic)) + foreach (string vehicle in installation.Files.GetDirectories(maker)) + { + string path = Path.Combine(vehicle, "textures.txt"); + if (!installation.Files.FileExists(path)) + continue; + + string directory = $"{Path.GetFileName(maker)}/{Path.GetFileName(vehicle)}/"; + var entry = Parser.Parse(directory, + installation.Files.ReadAllText(path, Encoding.GetEncoding(1250)).Split('\n')); + if (entry is not null) + installation.Files.WithFile(Database($"{directory.Replace('/', '.')}json"), + Serializer.Write(entry)); + } + + var catalog = new VehicleCatalog(installation.MiniTextures); + Repository(installation).Load(catalog); + return catalog; + } + + private static string[] Lines(string file) => file.Split('\n'); + + private static string Dynamic(string maker, string vehicle) => + TestInstallation.At("dynamic", maker, vehicle, "textures.txt"); + + private static string Database(string file) => TestInstallation.At("databases", "vehicles", file); + + /// Everything an entry carries, flattened so a difference reads as one. + private static string Describe(VehicleEntry entry) => + string.Join('\n', + new[] { $"entry {entry.Uuid}" } + .Concat(entry.Groups.Select(Describe)) + .Concat(entry.Textures.Select(Describe)) + .Concat(entry.Sets.Select(Describe)) + .Concat(entry.Unknown.Select(line => $"unknown {line}"))); + + /// The same for a whole catalogue, wrecks and resolved fields included. + private static string Describe(VehicleCatalog catalog) => + string.Join('\n', + catalog.GroupsById.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => Describe(pair.Value)) + .Concat(catalog.TextureByUuid.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => Describe(pair.Value))) + .Concat(catalog.Sets.Select(Describe))); + + private static string Describe(VehicleGroup group) => + $"group {group.Id}|{group.Category}|{group.Mini}|{group.Archived}|{group.Implicit}"; + + private static string Describe(VehicleSet set) => + $"set {set.Uuid}|{set.Mode}|{set.Count}|{string.Join(',', set.TextureRefs)}"; + + private static string Describe(VehicleTexture texture) => + $"texture {texture.Uuid}|{texture.Directory}|{texture.Skinfile}|{texture.Model}|{texture.Group}|" + + $"{texture.MiniRef}|{texture.TextureMini}|{texture.Wreck}|{texture.ResolvedClass}|" + + $"{texture.ResolvedCategory}|{texture.ResolvedArchived}|{texture.Meta?.Raw}|" + + string.Join(';', texture.Aliases.Select(alias => $"{alias.Model},{alias.MiniRef},{alias.TextureMini}")); +} diff --git a/StarterNG/Application/Abstractions/IGamePaths.cs b/StarterNG/Application/Abstractions/IGamePaths.cs index 314647c..5adcf5d 100644 --- a/StarterNG/Application/Abstractions/IGamePaths.cs +++ b/StarterNG/Application/Abstractions/IGamePaths.cs @@ -12,6 +12,9 @@ public interface IGamePaths string Data { get; } + /// Where the JSON vehicle database and the packages layered on it live. + string VehicleDatabase { get; } + string StarterConfig { get; } string DiagnosticsLog { get; } diff --git a/StarterNG/Domain/Vehicles/VehicleTexture.cs b/StarterNG/Domain/Vehicles/VehicleTexture.cs index 820b08a..e6469b2 100644 --- a/StarterNG/Domain/Vehicles/VehicleTexture.cs +++ b/StarterNG/Domain/Vehicles/VehicleTexture.cs @@ -5,9 +5,25 @@ namespace StarterNG.Domain.Vehicles; public sealed class VehicleEntry { public string? Uuid { get; set; } + + /// + /// Version of the file format the entry was read from, zero when it came from + /// a legacy textures.txt. Kept so a file written by a newer starter is refused + /// outright instead of being read with half its meaning missing. + /// + public int SchemaVersion { get; set; } + public List Groups { get; } = new(); public List Textures { get; } = new(); public List Sets { get; } = new(); + + /// + /// Source lines the reader did not turn into anything else - legacy headers, + /// comments and malformed liveries - kept verbatim so an entry written back + /// out does not quietly drop what its author put in the file. Content only: + /// blank lines and the original spacing are not preserved. + /// + public List Unknown { get; } = new(); } public sealed class VehicleGroup @@ -17,6 +33,13 @@ public sealed class VehicleGroup public string? Mini { get; set; } public bool Archived { get; set; } + + /// + /// True when nothing declared the group and it was inferred from the liveries + /// that share a thumbnail - which is every group textures.txt produces, as the + /// legacy format has no syntax for declaring one. + /// + public bool Implicit { get; set; } } public sealed class VehicleTexture diff --git a/StarterNG/Infrastructure/Adapters/GamePaths.cs b/StarterNG/Infrastructure/Adapters/GamePaths.cs index 3dd5c67..bbf86db 100644 --- a/StarterNG/Infrastructure/Adapters/GamePaths.cs +++ b/StarterNG/Infrastructure/Adapters/GamePaths.cs @@ -22,6 +22,8 @@ public sealed class GamePaths : IGamePaths public string Data => Path.Combine(Root, "data"); + public string VehicleDatabase => Path.Combine(Root, "databases", "vehicles"); + public string StarterConfig => Path.Combine(Root, "starter"); public string DiagnosticsLog => Path.Combine(StarterConfig, "bledy.txt"); diff --git a/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs b/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs index c99a88e..ba731ec 100644 --- a/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs +++ b/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs @@ -25,7 +25,10 @@ public sealed class TexturesTxtParser char first = line[0]; if (first is '#' or '@' or '*') + { + entry.Unknown.Add(line); continue; + } if (line.StartsWith("$a", StringComparison.OrdinalIgnoreCase)) { @@ -47,11 +50,17 @@ public sealed class TexturesTxtParser } if (line.IndexOf('=') < 0 || line.StartsWith("//", StringComparison.Ordinal)) + { + entry.Unknown.Add(line); continue; + } var texture = ParseLivery(line, directory, categorySign, archived, entry); if (texture is null) + { + entry.Unknown.Add(line); continue; + } entry.Textures.Add(texture); @@ -120,7 +129,8 @@ public sealed class TexturesTxtParser Id = groupId, Category = categorySign, Mini = lead.Mini, - Archived = archived + Archived = archived, + Implicit = true }); var texture = new VehicleTexture diff --git a/StarterNG/Infrastructure/Vehicles/VehicleJsonDocument.cs b/StarterNG/Infrastructure/Vehicles/VehicleJsonDocument.cs new file mode 100644 index 0000000..b75962f --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/VehicleJsonDocument.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace StarterNG.Infrastructure.Vehicles; + +// The vehicle database as it is written to disk (schema_version 1). Deliberately +// separate from the domain model: the model is free to change shape with the +// starter, the file format is not - packages written by someone else have to keep +// parsing. The property names are the ones the format shipped with, so a file +// written for the older reader still loads. +// +// Collections are nullable and left out when empty, which keeps a generated file +// close to what a person would have typed by hand. + +public sealed class VehicleEntryDocument +{ + [JsonPropertyName("uuid")] public string? Uuid { get; set; } + + [JsonPropertyName("schema_version")] public int SchemaVersion { get; set; } + + [JsonPropertyName("groups")] public List? Groups { get; set; } + + [JsonPropertyName("textures")] public List? Textures { get; set; } + + [JsonPropertyName("sets")] public List? Sets { get; set; } + + [JsonPropertyName("unknown")] public List? Unknown { get; set; } +} + +/// Root of a merged database holding many entries in one file. +public sealed class VehicleCollectionDocument +{ + [JsonPropertyName("vehicles")] public List? Vehicles { get; set; } +} + +public sealed class VehicleGroupDocument +{ + [JsonPropertyName("id")] public string? Id { get; set; } + + [JsonPropertyName("category")] public string? Category { get; set; } + + [JsonPropertyName("mini")] public string? Mini { get; set; } + + [JsonPropertyName("archived")] public bool Archived { get; set; } + + [JsonPropertyName("implicit")] public bool Implicit { get; set; } +} + +public sealed class VehicleTextureDocument +{ + [JsonPropertyName("uuid")] public string? Uuid { get; set; } + + [JsonPropertyName("directory")] public string? Directory { get; set; } + + [JsonPropertyName("skinfile")] public string? Skinfile { get; set; } + + [JsonPropertyName("model")] public string? Model { get; set; } + + [JsonPropertyName("group")] public string? Group { get; set; } + + [JsonPropertyName("mini_ref")] public string? MiniRef { get; set; } + + [JsonPropertyName("texture_mini")] public string? TextureMini { get; set; } + + [JsonPropertyName("wreck")] public bool Wreck { get; set; } + + [JsonPropertyName("aliases")] public List? Aliases { get; set; } + + [JsonPropertyName("meta")] public VehicleMetaDocument? Meta { get; set; } +} + +public sealed class VehicleAliasDocument +{ + [JsonPropertyName("model")] public string? Model { get; set; } + + [JsonPropertyName("group")] public string? Group { get; set; } + + [JsonPropertyName("mini_ref")] public string? MiniRef { get; set; } + + [JsonPropertyName("texture_mini")] public string? TextureMini { get; set; } +} + +public sealed class VehicleMetaDocument +{ + [JsonPropertyName("raw")] public string? Raw { get; set; } + + [JsonPropertyName("version")] public string? Version { get; set; } + + [JsonPropertyName("vehicle")] public string? Vehicle { get; set; } + + [JsonPropertyName("operator")] public string? Operator { get; set; } + + [JsonPropertyName("depot")] public string? Depot { get; set; } + + [JsonPropertyName("revision_date")] public string? RevisionDate { get; set; } + + [JsonPropertyName("revision_place")] public string? RevisionPlace { get; set; } + + [JsonPropertyName("texture_author")] public string? TextureAuthor { get; set; } + + [JsonPropertyName("photo_author")] public string? PhotoAuthor { get; set; } + + [JsonPropertyName("extra")] public List? Extra { get; set; } +} + +public sealed class VehicleSetDocument +{ + [JsonPropertyName("uuid")] public string? Uuid { get; set; } + + [JsonPropertyName("mode")] public string? Mode { get; set; } + + [JsonPropertyName("count")] public int Count { get; set; } + + [JsonPropertyName("texture_refs")] public List? TextureRefs { get; set; } +} + +// Source-generated metadata: PublishAot and PublishTrimmed rule out the reflection +// based serializer. The generator pulls in every nested document type on its own, +// and the reader's leniency is set on the options in VehicleJsonSerializer. +[JsonSerializable(typeof(VehicleEntryDocument))] +[JsonSerializable(typeof(VehicleCollectionDocument))] +internal partial class VehicleJsonContext : JsonSerializerContext +{ +} diff --git a/StarterNG/Infrastructure/Vehicles/VehicleJsonRepository.cs b/StarterNG/Infrastructure/Vehicles/VehicleJsonRepository.cs new file mode 100644 index 0000000..a0e0d72 --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/VehicleJsonRepository.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Infrastructure.Vehicles; + +/// +/// Reads the vehicle catalogue from databases/vehicles: a merged vehicles.json +/// holding the whole database, and one file per package layered on top of it. +/// +public sealed class VehicleJsonRepository : IVehicleRepository +{ + private const string MergedFileName = "vehicles.json"; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly IDiagnosticsLog _log; + private readonly VehicleJsonSerializer _serializer; + + public VehicleJsonRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, + VehicleJsonSerializer serializer) + { + _files = files; + _paths = paths; + _log = log; + _serializer = serializer; + } + + public int Load(VehicleCatalog catalog) + { + catalog.BeginLoad(); + + int liveries = 0; + foreach (string file in DatabaseFiles()) + liveries += ReadInto(catalog, file); + + catalog.EndLoad(); + return liveries; + } + + private int ReadInto(VehicleCatalog catalog, string path) + { + try + { + string json = _files.ReadAllText(path); + var entries = IsMerged(path) + ? _serializer.ReadCollection(json) + : Listed(_serializer.Read(json)); + + int liveries = 0; + foreach (var entry in entries) + { + if (!VehicleJsonSerializer.IsReadable(entry)) + { + _log.Log($"{Name(path)}: format w wersji {entry.SchemaVersion}, " + + $"ten starter czyta do {VehicleJsonSerializer.SchemaVersion}"); + continue; + } + + liveries += entry.Textures.Count; + catalog.Ingest(entry); + } + + return liveries; + } + catch (Exception ex) + { + _log.Log(Name(path), ex); + return 0; + } + } + + /// + /// The merged database first and the packages after it, both in a fixed order, + /// so which entry wins a clash is the same on every machine - the file system + /// does not promise one. + /// + private IEnumerable DatabaseFiles() + { + var files = _files.GetFiles(_paths.VehicleDatabase, "*.json") + .OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (string file in files.Where(IsMerged)) + yield return file; + + foreach (string file in files.Where(file => !IsMerged(file))) + yield return file; + } + + private static bool IsMerged(string path) => + string.Equals(Path.GetFileName(path), MergedFileName, StringComparison.OrdinalIgnoreCase); + + private static string Name(string path) => $"databases/vehicles/{Path.GetFileName(path)}"; + + private static List Listed(VehicleEntry? entry) => + entry is null ? new List() : new List { entry }; +} diff --git a/StarterNG/Infrastructure/Vehicles/VehicleJsonSerializer.cs b/StarterNG/Infrastructure/Vehicles/VehicleJsonSerializer.cs new file mode 100644 index 0000000..d4fc885 --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/VehicleJsonSerializer.cs @@ -0,0 +1,215 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Infrastructure.Vehicles; + +/// +/// Reads and writes in the JSON database format, +/// mapping it on and off the document types that define the format. +/// +public sealed class VehicleJsonSerializer +{ + /// The format this build writes, and the newest one it can read. + public const int SchemaVersion = 1; + + // Leniency a hand-written package file deserves: names matched case + // insensitively, // and /* */ comments skipped, trailing commas allowed. The + // relaxed encoder keeps Polish text in the credits and in the preserved legacy + // comments readable instead of turning it into \uXXXX escapes, and the + // source-generated resolver keeps all of it AOT-safe. + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + TypeInfoResolver = VehicleJsonContext.Default + }; + + private static readonly JsonTypeInfo EntryInfo = + (JsonTypeInfo)Options.GetTypeInfo(typeof(VehicleEntryDocument)); + + private static readonly JsonTypeInfo CollectionInfo = + (JsonTypeInfo)Options.GetTypeInfo(typeof(VehicleCollectionDocument)); + + public string Write(VehicleEntry entry) => JsonSerializer.Serialize(ToDocument(entry), EntryInfo); + + public string WriteCollection(IEnumerable entries) => + JsonSerializer.Serialize( + new VehicleCollectionDocument { Vehicles = entries.Select(ToDocument).ToList() }, + CollectionInfo); + + /// One entry per file, the form a package is authored in. + public VehicleEntry? Read(string json) + { + var document = JsonSerializer.Deserialize(json, EntryInfo); + return document is null ? null : FromDocument(document); + } + + /// Many entries in one file, the form a whole database is shipped in. + public List ReadCollection(string json) + { + var document = JsonSerializer.Deserialize(json, CollectionInfo); + if (document?.Vehicles is null) + return new List(); + + return document.Vehicles.Select(FromDocument).ToList(); + } + + /// + /// Whether this build understands the file. An entry written by a newer + /// starter is refused rather than half-read, an entry with no version at all + /// is taken as the current one - the format carried no version when the first + /// files were written by hand. + /// + public static bool IsReadable(VehicleEntry entry) => entry.SchemaVersion <= SchemaVersion; + + private static VehicleEntryDocument ToDocument(VehicleEntry entry) => + new() + { + Uuid = entry.Uuid, + SchemaVersion = SchemaVersion, + Groups = OrNull(entry.Groups.Select(group => new VehicleGroupDocument + { + Id = group.Id, + Category = group.Category, + Mini = group.Mini, + Archived = group.Archived, + Implicit = group.Implicit + }).ToList()), + Textures = OrNull(entry.Textures.Select(ToDocument).ToList()), + Sets = OrNull(entry.Sets.Select(set => new VehicleSetDocument + { + Uuid = set.Uuid, + Mode = set.Mode, + Count = set.Count, + TextureRefs = OrNull(set.TextureRefs) + }).ToList()), + Unknown = OrNull(entry.Unknown) + }; + + private static VehicleTextureDocument ToDocument(VehicleTexture texture) => + new() + { + Uuid = texture.Uuid, + Directory = texture.Directory, + Skinfile = texture.Skinfile, + Model = texture.Model, + Group = texture.Group, + MiniRef = texture.MiniRef, + TextureMini = texture.TextureMini, + Wreck = texture.Wreck, + Aliases = OrNull(texture.Aliases.Select(alias => new VehicleAliasDocument + { + Model = alias.Model, + Group = alias.Group, + MiniRef = alias.MiniRef, + TextureMini = alias.TextureMini + }).ToList()), + Meta = texture.Meta is null + ? null + : new VehicleMetaDocument + { + Raw = texture.Meta.Raw, + Version = texture.Meta.Version, + Vehicle = texture.Meta.Vehicle, + Operator = texture.Meta.Operator, + Depot = texture.Meta.Depot, + RevisionDate = texture.Meta.RevisionDate, + RevisionPlace = texture.Meta.RevisionPlace, + TextureAuthor = texture.Meta.TextureAuthor, + PhotoAuthor = texture.Meta.PhotoAuthor, + Extra = OrNull(texture.Meta.Extra) + } + }; + + private static VehicleEntry FromDocument(VehicleEntryDocument document) + { + var entry = new VehicleEntry + { + Uuid = document.Uuid, + SchemaVersion = document.SchemaVersion == 0 ? SchemaVersion : document.SchemaVersion + }; + + foreach (var group in document.Groups ?? Enumerable.Empty()) + entry.Groups.Add(new VehicleGroup + { + Id = group.Id ?? "", + Category = group.Category, + Mini = group.Mini, + Archived = group.Archived, + Implicit = group.Implicit + }); + + foreach (var texture in document.Textures ?? Enumerable.Empty()) + entry.Textures.Add(FromDocument(texture)); + + foreach (var set in document.Sets ?? Enumerable.Empty()) + entry.Sets.Add(new VehicleSet + { + Uuid = set.Uuid, + Mode = set.Mode, + Count = set.Count, + TextureRefs = set.TextureRefs ?? new List() + }); + + if (document.Unknown is not null) + entry.Unknown.AddRange(document.Unknown); + + return entry; + } + + private static VehicleTexture FromDocument(VehicleTextureDocument document) + { + var texture = new VehicleTexture + { + Uuid = document.Uuid, + Directory = document.Directory ?? "", + Skinfile = document.Skinfile ?? "", + Model = document.Model, + Group = document.Group, + MiniRef = document.MiniRef, + TextureMini = document.TextureMini, + Wreck = document.Wreck + }; + + foreach (var alias in document.Aliases ?? Enumerable.Empty()) + texture.Aliases.Add(new VehicleAlias + { + Model = alias.Model, + Group = alias.Group, + MiniRef = alias.MiniRef, + TextureMini = alias.TextureMini + }); + + if (document.Meta is not null) + { + texture.Meta = new VehicleMeta + { + Raw = document.Meta.Raw, + Version = document.Meta.Version, + Vehicle = document.Meta.Vehicle, + Operator = document.Meta.Operator, + Depot = document.Meta.Depot, + RevisionDate = document.Meta.RevisionDate, + RevisionPlace = document.Meta.RevisionPlace, + TextureAuthor = document.Meta.TextureAuthor, + PhotoAuthor = document.Meta.PhotoAuthor + }; + + if (document.Meta.Extra is not null) + texture.Meta.Extra.AddRange(document.Meta.Extra); + } + + return texture; + } + + private static List? OrNull(List items) => items.Count > 0 ? items : null; +}