Add a JSON format for the vehicle database

This commit is contained in:
maj00r
2026-10-04 12:18:36 +02:00
parent 3ddd00895f
commit d1d458f8ee
8 changed files with 678 additions and 1 deletions

View File

@@ -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();
/// <summary>
/// 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.
/// </summary>
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
""";
/// <summary>A unit whose cars are coupled into a fixed set.</summary>
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);
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>Everything an entry carries, flattened so a difference reads as one.</summary>
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}")));
/// <summary>The same for a whole catalogue, wrecks and resolved fields included.</summary>
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}"));
}

View File

@@ -12,6 +12,9 @@ public interface IGamePaths
string Data { get; }
/// <summary>Where the JSON vehicle database and the packages layered on it live.</summary>
string VehicleDatabase { get; }
string StarterConfig { get; }
string DiagnosticsLog { get; }

View File

@@ -5,9 +5,25 @@ namespace StarterNG.Domain.Vehicles;
public sealed class VehicleEntry
{
public string? Uuid { get; set; }
/// <summary>
/// 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.
/// </summary>
public int SchemaVersion { get; set; }
public List<VehicleGroup> Groups { get; } = new();
public List<VehicleTexture> Textures { get; } = new();
public List<VehicleSet> Sets { get; } = new();
/// <summary>
/// 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.
/// </summary>
public List<string> 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; }
/// <summary>
/// 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.
/// </summary>
public bool Implicit { get; set; }
}
public sealed class VehicleTexture

View File

@@ -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");

View File

@@ -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

View File

@@ -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<VehicleGroupDocument>? Groups { get; set; }
[JsonPropertyName("textures")] public List<VehicleTextureDocument>? Textures { get; set; }
[JsonPropertyName("sets")] public List<VehicleSetDocument>? Sets { get; set; }
[JsonPropertyName("unknown")] public List<string>? Unknown { get; set; }
}
/// <summary>Root of a merged database holding many entries in one file.</summary>
public sealed class VehicleCollectionDocument
{
[JsonPropertyName("vehicles")] public List<VehicleEntryDocument>? 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<VehicleAliasDocument>? 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<string>? 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<string>? 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
{
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
private IEnumerable<string> 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<VehicleEntry> Listed(VehicleEntry? entry) =>
entry is null ? new List<VehicleEntry>() : new List<VehicleEntry> { entry };
}

View File

@@ -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;
/// <summary>
/// Reads and writes <see cref="VehicleEntry"/> in the JSON database format,
/// mapping it on and off the document types that define the format.
/// </summary>
public sealed class VehicleJsonSerializer
{
/// <summary>The format this build writes, and the newest one it can read.</summary>
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<VehicleEntryDocument> EntryInfo =
(JsonTypeInfo<VehicleEntryDocument>)Options.GetTypeInfo(typeof(VehicleEntryDocument));
private static readonly JsonTypeInfo<VehicleCollectionDocument> CollectionInfo =
(JsonTypeInfo<VehicleCollectionDocument>)Options.GetTypeInfo(typeof(VehicleCollectionDocument));
public string Write(VehicleEntry entry) => JsonSerializer.Serialize(ToDocument(entry), EntryInfo);
public string WriteCollection(IEnumerable<VehicleEntry> entries) =>
JsonSerializer.Serialize(
new VehicleCollectionDocument { Vehicles = entries.Select(ToDocument).ToList() },
CollectionInfo);
/// <summary>One entry per file, the form a package is authored in.</summary>
public VehicleEntry? Read(string json)
{
var document = JsonSerializer.Deserialize(json, EntryInfo);
return document is null ? null : FromDocument(document);
}
/// <summary>Many entries in one file, the form a whole database is shipped in.</summary>
public List<VehicleEntry> ReadCollection(string json)
{
var document = JsonSerializer.Deserialize(json, CollectionInfo);
if (document?.Vehicles is null)
return new List<VehicleEntry>();
return document.Vehicles.Select(FromDocument).ToList();
}
/// <summary>
/// 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.
/// </summary>
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<VehicleGroupDocument>())
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<VehicleTextureDocument>())
entry.Textures.Add(FromDocument(texture));
foreach (var set in document.Sets ?? Enumerable.Empty<VehicleSetDocument>())
entry.Sets.Add(new VehicleSet
{
Uuid = set.Uuid,
Mode = set.Mode,
Count = set.Count,
TextureRefs = set.TextureRefs ?? new List<string>()
});
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<VehicleAliasDocument>())
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<T>? OrNull<T>(List<T> items) => items.Count > 0 ? items : null;
}