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;
+}