diff --git a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs index 84c67ce..e7f7f74 100644 --- a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs +++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs @@ -92,6 +92,17 @@ public sealed class InMemoryFileSystem : IFileSystem .ToList(); } + public IReadOnlyList GetFilesRecursive(string path, string searchPattern) + { + string prefix = Normalize(path) + "/"; + var pattern = ToRegex(searchPattern); + return _files.Keys + .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Where(key => pattern.IsMatch(key[(key.LastIndexOf('/') + 1)..])) + .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + public IReadOnlyList GetDirectories(string path) { string prefix = Normalize(path) + "/"; diff --git a/StarterNG.Tests/Fakes/TestInstallation.cs b/StarterNG.Tests/Fakes/TestInstallation.cs new file mode 100644 index 0000000..81721bc --- /dev/null +++ b/StarterNG.Tests/Fakes/TestInstallation.cs @@ -0,0 +1,48 @@ +using StarterNG.Application; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Infrastructure.Vehicles; + +namespace StarterNG.Tests.Fakes; + +/// +/// The production object graph wired over an in-memory installation: the same +/// composition as AppServices, with disk, clock and randomness replaced. +/// +public sealed class TestInstallation +{ + public const string Root = "game"; + + public TestInstallation(InMemoryFileSystem? files = null, DateTime? now = null) + { + Files = files ?? new InMemoryFileSystem(); + Paths = new GamePaths(Root); + Clock = new FixedClock(now ?? new DateTime(2026, 1, 1, 12, 0, 0)); + Random = new StubRandom(); + Log = new FileDiagnosticsLog(Files, Clock, Paths); + Strings = new FakeStrings(); + + MiniTextures = new MiniTextureIndex(Files, Paths); + Physics = new FizPhysicsRepository(Files, Paths, Log); + Vehicles = new TexturesTxtVehicleRepository(Files, Paths, Log, new TexturesTxtParser()); + Sceneries = new SceneryRepository(Files, Paths, Log, new SceneryParser(Clock, Random)); + MissingAssets = new MissingAssetScanner(Files, Paths); + Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, Log); + } + + public InMemoryFileSystem Files { get; } + public GamePaths Paths { get; } + public FixedClock Clock { get; } + public StubRandom Random { get; } + public FileDiagnosticsLog Log { get; } + public FakeStrings Strings { get; } + public MiniTextureIndex MiniTextures { get; } + public FizPhysicsRepository Physics { get; } + public TexturesTxtVehicleRepository Vehicles { get; } + public SceneryRepository Sceneries { get; } + public MissingAssetScanner MissingAssets { get; } + public GameLibrary Library { get; } + + /// Path of a file inside the fake installation. + public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray()); +} diff --git a/StarterNG.Tests/GameLibraryTests.cs b/StarterNG.Tests/GameLibraryTests.cs new file mode 100644 index 0000000..1efe8a5 --- /dev/null +++ b/StarterNG.Tests/GameLibraryTests.cs @@ -0,0 +1,75 @@ +using StarterNG.Application; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class GameLibraryTests +{ + private static TestInstallation Complete() => + new(new InMemoryFileSystem() + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), "ep07-135.mat=ep07.e3d,ep07") + .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice") + .WithFile(TestInstallation.At("textures", "mini", "ep07.bmp"), "bitmap")); + + [Fact] + public void Loading_reads_both_the_rolling_stock_and_the_scenarios() + { + var library = Complete().Library; + + library.Load(); + + Assert.True(library.Loaded); + Assert.Single(library.Vehicles.Textures); + Assert.Equal("Katowice", Assert.Single(library.Sceneries).Name); + } + + [Fact] + public void Loading_reports_progress_through_to_done() + { + var phases = new List(); + var progress = new Progress(status => { lock (phases) phases.Add(status.Phase); }); + + Complete().Library.Load(progress); + + SpinWait.SpinUntil(() => { lock (phases) return phases.Contains(LoadPhase.Done); }, TimeSpan.FromSeconds(2)); + lock (phases) + { + Assert.Contains(LoadPhase.Vehicles, phases); + Assert.Contains(LoadPhase.Done, phases); + } + } + + [Fact] + public void Loading_twice_does_not_duplicate_the_scenarios() + { + var library = Complete().Library; + + library.Load(); + library.Load(); + + Assert.Single(library.Sceneries); + } + + [Fact] + public void Reloading_picks_up_a_scenery_added_after_startup() + { + var installation = Complete(); + var library = installation.Library; + library.Load(); + + installation.Files.WithFile(TestInstallation.At("scenery", "gdansk.scn"), "//$n Gdańsk"); + library.ReloadSceneries(); + + Assert.Equal(2, library.Sceneries.Count); + } + + [Fact] + public void An_installation_with_no_rolling_stock_is_logged() + { + var installation = new TestInstallation(); + + installation.Library.Load(); + + Assert.Contains("textures.txt", installation.Log.Text); + } +} diff --git a/StarterNG.Tests/InstallationCheckTests.cs b/StarterNG.Tests/InstallationCheckTests.cs index ee4c139..cea1b87 100644 --- a/StarterNG.Tests/InstallationCheckTests.cs +++ b/StarterNG.Tests/InstallationCheckTests.cs @@ -9,8 +9,10 @@ public class InstallationCheckTests [Fact] public void An_empty_folder_reports_every_missing_game_directory() { - var strings = new FakeStrings().With("FaultNoDir", "missing {0}"); - var check = new InstallationCheck(new GamePaths("game"), new InMemoryFileSystem(), strings); + var installation = new TestInstallation(); + var strings = installation.Strings.With("FaultNoDir", "missing {0}"); + var check = new InstallationCheck(installation.Paths, installation.Files, strings, + installation.Physics, installation.Library); var faults = check.Run(); @@ -29,9 +31,11 @@ public class InstallationCheckTests .WithDirectory(Path.Combine("game", "scenery")) .WithDirectory(Path.Combine("game", "textures")) .WithFile(Path.Combine("game", "data", "load_weights.txt"), ""); - var strings = new FakeStrings().With("FaultNoDir", "missing {0}"); + var installation = new TestInstallation(files); + var strings = installation.Strings.With("FaultNoDir", "missing {0}"); - var faults = new InstallationCheck(new GamePaths("game"), files, strings).Run(); + var faults = new InstallationCheck(installation.Paths, files, strings, + installation.Physics, installation.Library).Run(); Assert.DoesNotContain(faults, fault => fault.StartsWith("missing ")); Assert.DoesNotContain("FaultNoWeights", faults); diff --git a/StarterNG.Tests/PhysicsAndAssetsTests.cs b/StarterNG.Tests/PhysicsAndAssetsTests.cs new file mode 100644 index 0000000..1d38874 --- /dev/null +++ b/StarterNG.Tests/PhysicsAndAssetsTests.cs @@ -0,0 +1,128 @@ +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class PhysicsAndAssetsTests +{ + private static string Fiz(string maker, string vehicle, string name) => + TestInstallation.At("dynamic", maker, vehicle, name + ".fiz"); + + [Fact] + public void A_fiz_yields_the_properties_the_starter_shows() + { + const string fiz = """ + Param: M=80000 Vmax=125 + Dimensions: L=15.9 + Load: LoadAccepted=Coal MaxLoad=40 + Engine: EngineType=DieselElectric + """; + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(Fiz("pkp", "sm42", "sm42"), fiz)); + + var physics = installation.Physics.For("pkp/sm42/", "sm42.e3d"); + + Assert.NotNull(physics); + Assert.Equal(80000, physics!.Mass); + Assert.Equal(125, physics.VMax); + Assert.Equal(15.9, physics.Length); + Assert.Equal("Coal", physics.LoadAccepted); + Assert.Equal(40, physics.MaxLoad); + Assert.True(physics.IsDieselEngine); + } + + [Fact] + public void A_vehicle_declaring_one_coupler_gets_the_same_at_both_ends() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(Fiz("pkp", "111a", "111a"), "BuffCoupl1: AllowedFlag=3 ControlType=screw")); + + var physics = installation.Physics.For("pkp/111a/", "111a")!; + + Assert.Equal(3, physics.AllowedFlagA); + Assert.Equal(3, physics.AllowedFlagB); + Assert.Equal("screw", physics.ControlTypeB); + } + + [Fact] + public void A_negative_coupler_flag_is_encoded_as_disabled() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(Fiz("pkp", "111a", "111a"), "BuffCoupl1: AllowedFlag=-3")); + + Assert.Equal(131, installation.Physics.For("pkp/111a/", "111a")!.AllowedFlagA); + } + + [Fact] + public void An_include_is_followed_and_its_parameters_substituted() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(Fiz("pkp", "ep07", "ep07"), "include common.fiz 84000 end") + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "common.fiz"), "Param: M=(1) Vmax=125")); + + var physics = installation.Physics.For("pkp/ep07/", "ep07")!; + + Assert.Equal(84000, physics.Mass); + Assert.Equal(125, physics.VMax); + } + + [Fact] + public void A_dumb_variant_stands_in_when_the_model_has_no_fiz_of_its_own() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(Fiz("pkp", "ep07", "ep07dumb"), "Param: M=1000")); + + Assert.NotNull(installation.Physics.For("pkp/ep07/", "ep07")); + } + + [Fact] + public void An_unknown_model_has_no_physics_and_an_empty_installation_indexes_nothing() + { + var installation = new TestInstallation(); + + Assert.Null(installation.Physics.For("pkp/x/", "nieznany")); + installation.Physics.Preload(); + Assert.Equal(0, installation.Physics.IndexedCount); + } + + [Fact] + public void Thumbnails_are_looked_up_by_name_with_a_fallback_to_other() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("textures", "mini", "ep07.bmp"), "bitmap") + .WithFile(TestInstallation.At("textures", "mini", "other.bmp"), "bitmap")); + + Assert.True(installation.MiniTextures.Has("EP07")); + Assert.False(installation.MiniTextures.Has("nieznany")); + Assert.EndsWith("ep07.bmp", installation.MiniTextures.PathFor("ep07")); + Assert.EndsWith("other.bmp", installation.MiniTextures.PathFor("nieznany")); + } + + [Fact] + public void A_livery_whose_skin_and_model_are_absent_is_reported() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), + "ep07-135.mat=ep07.e3d,ep07"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + + var missing = installation.MissingAssets.Scan(installation.Library.Vehicles); + + Assert.Contains(missing, line => line.Contains("no file:") && line.Contains("ep07-135")); + Assert.Contains(missing, line => line.Contains("no model:") && line.Contains("ep07.e3d")); + } + + [Fact] + public void A_skin_present_under_an_accepted_extension_is_not_reported() + { + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), + "ep07-135.mat=ep07.e3d,ep07") + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07-135.bmp"), "bitmap") + .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07.t3d"), "model"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + + Assert.Empty(installation.MissingAssets.Scan(installation.Library.Vehicles)); + } +} diff --git a/StarterNG.Tests/TexturesTxtParserTests.cs b/StarterNG.Tests/TexturesTxtParserTests.cs new file mode 100644 index 0000000..70e74f0 --- /dev/null +++ b/StarterNG.Tests/TexturesTxtParserTests.cs @@ -0,0 +1,103 @@ +using StarterNG.Infrastructure.Vehicles; + +namespace StarterNG.Tests; + +public class TexturesTxtParserTests +{ + private static readonly TexturesTxtParser Parser = new(); + + [Fact] + public void A_livery_line_yields_a_texture_with_its_model_and_thumbnail() + { + var entry = Parser.Parse("pkp/ep07/", new[] { "ep07-135.mat=ep07.e3d,ep07" }); + + var texture = Assert.Single(entry!.Textures); + Assert.Equal("ep07-135", texture.Skinfile); + Assert.Equal("pkp/ep07/", texture.Directory); + Assert.Equal("ep07.e3d", texture.Model); + Assert.Equal("ep07", texture.MiniRef); + Assert.False(texture.Wreck); + } + + [Fact] + public void The_trailing_comment_is_read_as_the_credit_line() + { + var entry = Parser.Parse("pkp/ep07/", new[] + { + "ep07-135.mat=ep07.e3d,ep07 // 1.0,EP07-135,PKP Intercity,Warszawa,2019-04-01,Wrocław,Autor,Fotograf" + }); + + var meta = Assert.Single(entry!.Textures).Meta; + Assert.NotNull(meta); + Assert.Equal("EP07-135", meta!.Vehicle); + Assert.Equal("PKP Intercity", meta.Operator); + Assert.Equal("Warszawa", meta.Depot); + Assert.Equal("Autor", meta.TextureAuthor); + Assert.Equal("Fotograf", meta.PhotoAuthor); + } + + [Fact] + public void Wreck_liveries_are_marked_so_the_depot_can_hide_them() + { + var entry = Parser.Parse("pkp/ep07/", new[] { "ep07wreck.mat=ep07.e3d,ep07" }); + + Assert.True(Assert.Single(entry!.Textures).Wreck); + } + + [Fact] + public void The_dollar_a_marker_archives_everything_after_it() + { + var entry = Parser.Parse("pkp/ep07/", new[] + { + "ep07-135.mat=ep07.e3d,ep07", + "$a", + "ep07-old.mat=ep07.e3d,ep07old" + }); + + var groups = entry!.Groups; + Assert.Contains(groups, group => group is { Mini: "ep07", Archived: false }); + Assert.Contains(groups, group => group is { Mini: "ep07old", Archived: true }); + } + + [Fact] + public void A_caret_opens_a_fixed_set_of_the_following_vehicles() + { + var entry = Parser.Parse("pkp/en57/", new[] + { + "^3", + "en57-a.mat=en57.e3d,en57", + "en57-b.mat=en57.e3d,en57", + "en57-c.mat=en57.e3d,en57" + }); + + var set = Assert.Single(entry!.Sets); + Assert.Equal(3, set.Count); + Assert.Equal(3, set.TextureRefs.Count); + } + + [Fact] + public void Extra_equals_segments_become_alternative_models() + { + var entry = Parser.Parse("pkp/111a/", new[] { "111a.mat=111a.e3d,111a=111ab.e3d,111ab" }); + + var texture = Assert.Single(entry!.Textures); + Assert.Equal("111a.e3d", texture.Model); + Assert.Equal("111ab.e3d", Assert.Single(texture.Aliases).Model); + } + + [Fact] + public void Comments_headers_and_blank_lines_are_ignored() + { + var entry = Parser.Parse("pkp/ep07/", new[] { "", "# nagłówek", "@ coś", "// komentarz", "bez znaku równości" }); + + Assert.Null(entry); + } + + [Fact] + public void The_category_marker_applies_to_the_liveries_after_it() + { + var entry = Parser.Parse("pkp/ep07/", new[] { "!=e", "ep07-135.mat=ep07.e3d,ep07" }); + + Assert.Equal("e", Assert.Single(entry!.Groups).Category); + } +} diff --git a/StarterNG.Tests/VehicleCatalogTests.cs b/StarterNG.Tests/VehicleCatalogTests.cs new file mode 100644 index 0000000..63901e2 --- /dev/null +++ b/StarterNG.Tests/VehicleCatalogTests.cs @@ -0,0 +1,120 @@ +using StarterNG.Domain.Vehicles; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class VehicleCatalogTests +{ + private static string TexturesFile(string maker, string vehicle) => + TestInstallation.At("dynamic", maker, vehicle, "textures.txt"); + + [Fact] + public void The_catalogue_is_filled_from_every_vehicle_folder() + { + var files = new InMemoryFileSystem() + .WithFile(TexturesFile("pkp", "ep07"), "ep07-135.mat=ep07.e3d,ep07") + .WithFile(TexturesFile("pkp", "111a"), "111a-1.mat=111a.e3d,111a"); + var installation = new TestInstallation(files); + + int liveries = installation.Vehicles.Load(installation.Library.Vehicles); + + Assert.Equal(2, liveries); + Assert.Equal(2, installation.Library.Vehicles.Textures.Count); + } + + [Fact] + public void The_data_folder_is_recorded_the_way_the_scenery_format_spells_it() + { + var files = new InMemoryFileSystem() + .WithFile(TexturesFile("pkp", "ep07"), "ep07-135.mat=ep07.e3d,ep07"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + + Assert.Equal("pkp/ep07/", Assert.Single(installation.Library.Vehicles.Textures).Directory); + } + + [Fact] + public void Wrecks_are_indexed_but_kept_out_of_the_offered_liveries() + { + var files = new InMemoryFileSystem() + .WithFile(TexturesFile("pkp", "ep07"), "ep07wrak.mat=ep07.e3d,ep07\nep07-135.mat=ep07.e3d,ep07"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + + var catalog = installation.Library.Vehicles; + Assert.Equal("ep07-135", Assert.Single(catalog.Textures).Skinfile); + Assert.NotNull(catalog.TextureForSkin("ep07wrak")); + } + + [Fact] + public void A_livery_is_found_by_its_skin_file_whatever_the_extension() + { + var files = new InMemoryFileSystem() + .WithFile(TexturesFile("pkp", "ep07"), "ep07-135.mat=ep07.e3d,ep07"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + + Assert.NotNull(installation.Library.Vehicles.TextureForSkin("EP07-135.bmp")); + Assert.Null(installation.Library.Vehicles.TextureForSkin("nieznany")); + } + + [Fact] + public void A_star_category_is_derived_from_the_first_letter_of_the_thumbnail() + { + var catalog = new VehicleCatalog(new MiniStub()); + catalog.BeginLoad(); + catalog.Ingest(ParsedEntry("pkp/ep07/", "ep07-135.mat=ep07.e3d,ep07")); + catalog.EndLoad(); + + Assert.Equal("E", Assert.Single(catalog.Textures).ResolvedCategory); + } + + [Fact] + public void Members_of_a_set_after_the_first_are_followers() + { + var files = new InMemoryFileSystem() + .WithFile(TexturesFile("pkp", "en57"), + "^2\nen57-a.mat=en57.e3d,en57\nen57-b.mat=en57.e3d,en57"); + var installation = new TestInstallation(files); + installation.Vehicles.Load(installation.Library.Vehicles); + var catalog = installation.Library.Vehicles; + + var lead = catalog.TextureForSkin("en57-a")!; + var follower = catalog.TextureForSkin("en57-b")!; + + Assert.False(catalog.IsSetFollower(lead)); + Assert.True(catalog.IsSetFollower(follower)); + Assert.Equal(2, catalog.ResolveSet(lead)!.Count); + } + + [Fact] + public void A_livery_falls_back_to_its_groups_thumbnail_when_its_own_is_missing() + { + var catalog = new VehicleCatalog(new MiniStub(known: "ep07")); + catalog.BeginLoad(); + catalog.Ingest(ParsedEntry("pkp/ep07/", "ep07-135.mat=ep07.e3d,ep07,brakujacy")); + catalog.EndLoad(); + + Assert.Equal("ep07", catalog.ResolveMiniName(Assert.Single(catalog.Textures))); + } + + /// Thumbnail index that knows about exactly one name. + private sealed class MiniStub : StarterNG.Application.Abstractions.IMiniTextureIndex + { + private readonly string? _known; + + public MiniStub(string? known = null) => _known = known; + + public void Preload() { } + + public bool Has(string? miniName) => miniName is not null && miniName == _known; + + public string? PathFor(string? miniName) => Has(miniName) ? miniName : FallbackPath; + + public string? FallbackPath => null; + } + + /// Builds an entry through the real parser, for catalogue-level tests. + private static VehicleEntry ParsedEntry(string directory, params string[] lines) => + new StarterNG.Infrastructure.Vehicles.TexturesTxtParser().Parse(directory, lines)!; +} diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs index 1417935..6c78401 100644 --- a/StarterNG/App.axaml.cs +++ b/StarterNG/App.axaml.cs @@ -125,7 +125,7 @@ public partial class App : Avalonia.Application }); }); - GameData.Instance.Load(progress); + AppServices.Current.Library.Load(progress); }).ContinueWith(load => { Dispatcher.UIThread.Post(async () => diff --git a/StarterNG/Application/Abstractions/IFileSystem.cs b/StarterNG/Application/Abstractions/IFileSystem.cs index 2d04926..e380c3a 100644 --- a/StarterNG/Application/Abstractions/IFileSystem.cs +++ b/StarterNG/Application/Abstractions/IFileSystem.cs @@ -51,5 +51,11 @@ public interface IFileSystem /// Top-level directories, empty when the directory does not exist. IReadOnlyList GetDirectories(string path); + /// + /// Files matching the pattern anywhere below the path. Empty when the + /// directory does not exist. + /// + IReadOnlyList GetFilesRecursive(string path, string searchPattern); + DateTime GetLastWriteTimeUtc(string path); } diff --git a/StarterNG/Application/Abstractions/IMiniTextureIndex.cs b/StarterNG/Application/Abstractions/IMiniTextureIndex.cs new file mode 100644 index 0000000..d876765 --- /dev/null +++ b/StarterNG/Application/Abstractions/IMiniTextureIndex.cs @@ -0,0 +1,19 @@ +namespace StarterNG.Application.Abstractions; + +/// +/// The thumbnails in textures/mini, looked up by name. Backed by one directory +/// scan; a name with no bitmap falls back to the generic "other" thumbnail. +/// +public interface IMiniTextureIndex +{ + /// Builds the index up front, off the UI thread. + void Preload(); + + bool Has(string? miniName); + + /// Bitmap for the name, the fallback thumbnail, or null when neither exists. + string? PathFor(string? miniName); + + /// The generic thumbnail shown for unknown stock. + string? FallbackPath { get; } +} diff --git a/StarterNG/Application/Abstractions/IPhysicsRepository.cs b/StarterNG/Application/Abstractions/IPhysicsRepository.cs new file mode 100644 index 0000000..44e9a63 --- /dev/null +++ b/StarterNG/Application/Abstractions/IPhysicsRepository.cs @@ -0,0 +1,21 @@ +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Application.Abstractions; + +/// +/// Looks up the .fiz physics of a vehicle type by model name. +/// +public interface IPhysicsRepository +{ + /// Indexes the installation's .fiz files up front, off the UI thread. + void Preload(); + + /// How many vehicle types were indexed; zero means a broken installation. + int IndexedCount { get; } + + /// + /// Physics for a model name (with or without extension), or null when the + /// installation has no .fiz for it. + /// + VehiclePhysics? For(string? dataFolder, string? model); +} diff --git a/StarterNG/Application/Abstractions/IVehicleRepository.cs b/StarterNG/Application/Abstractions/IVehicleRepository.cs new file mode 100644 index 0000000..d32247b --- /dev/null +++ b/StarterNG/Application/Abstractions/IVehicleRepository.cs @@ -0,0 +1,17 @@ +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Application.Abstractions; + +/// +/// Reads the installation's rolling stock. The only route from the dynamic/ +/// folder to a . +/// +public interface IVehicleRepository +{ + /// + /// Fills the catalogue from every textures.txt under dynamic/. Returns the + /// number of liveries read, which the caller uses to spot an installation + /// with no rolling stock at all. + /// + int Load(VehicleCatalog catalog); +} diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs index 742aef5..38108a7 100644 --- a/StarterNG/Application/AppServices.cs +++ b/StarterNG/Application/AppServices.cs @@ -2,6 +2,7 @@ using System; using StarterNG.Application.Abstractions; using StarterNG.Infrastructure.Adapters; using StarterNG.Infrastructure.Sceneries; +using StarterNG.Infrastructure.Vehicles; using StarterNG.Services; namespace StarterNG.Application; @@ -39,6 +40,11 @@ public sealed class AppServices Localization = new LocalizationService(log); Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random)); SceneryImages = new SceneryImageLocator(files); + MiniTextures = new MiniTextureIndex(files, paths); + Vehicles = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser()); + Physics = new FizPhysicsRepository(files, paths, log); + MissingAssets = new MissingAssetScanner(files, paths); + Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log); } /// @@ -76,6 +82,17 @@ public sealed class AppServices public SceneryImageLocator SceneryImages { get; } + public IMiniTextureIndex MiniTextures { get; } + + public IVehicleRepository Vehicles { get; } + + public IPhysicsRepository Physics { get; } + + public MissingAssetScanner MissingAssets { get; } + + /// Everything read out of the installation: rolling stock and scenarios. + public GameLibrary Library { get; } + /// /// Exposed as the concrete service rather than /// because the XAML binds to it as a change-notifying source; code should take diff --git a/StarterNG/Application/GameLibrary.cs b/StarterNG/Application/GameLibrary.cs new file mode 100644 index 0000000..8783c09 --- /dev/null +++ b/StarterNG/Application/GameLibrary.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Sceneries; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Application; + +/// Which part of the installation the loader is working through. +public enum LoadPhase +{ + Vehicles, + Sceneries, + Done +} + +/// How far the load has got, for the splash screen. +public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail); + +/// +/// Everything the starter reads out of the installation once: the rolling stock +/// catalogue and the list of scenarios. +/// +/// +/// Was the GameData singleton, which also owned directory enumeration and +/// parallel parsing. It is now an ordinary object built by the composition root, +/// delegating to the repositories; the loading policy (what is read, in what +/// order, what counts as progress) is all that is left here. +/// +public sealed class GameLibrary +{ + private readonly IVehicleRepository _vehicles; + private readonly ISceneryRepository _sceneries; + private readonly IMiniTextureIndex _minis; + private readonly IPhysicsRepository _physics; + private readonly IDiagnosticsLog _log; + + public GameLibrary(IVehicleRepository vehicles, ISceneryRepository sceneries, IMiniTextureIndex minis, + IPhysicsRepository physics, IDiagnosticsLog log) + { + _vehicles = vehicles; + _sceneries = sceneries; + _minis = minis; + _physics = physics; + _log = log; + Vehicles = new VehicleCatalog(minis); + } + + public VehicleCatalog Vehicles { get; } + + public List Sceneries { get; } = new(); + + public bool Loaded { get; private set; } + + /// + /// Reads the whole installation. Vehicles first, because the scenery list + /// resolves consists against them; the thumbnail and physics indexes are + /// built alongside since they are independent directory scans. + /// + public void Load(IProgress? progress = null) + { + if (Loaded) + return; + + progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt")); + if (_vehicles.Load(Vehicles) == 0) + _log.Log("dynamic: brak plikow textures.txt"); + + Parallel.Invoke(_minis.Preload, _physics.Preload); + + ReloadSceneries(progress is null + ? null + : new Progress(step => progress.Report( + new LoadStatus((double)step.Loaded / step.Total, LoadPhase.Sceneries, step.FileName)))); + + progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null)); + Loaded = true; + } + + /// Re-reads the scenery folder, picking up files added since startup. + public void ReloadSceneries(IProgress? progress = null) + { + var reloaded = _sceneries.LoadAll(progress); + Sceneries.Clear(); + Sceneries.AddRange(reloaded); + } +} diff --git a/StarterNG/Classes/GameData.cs b/StarterNG/Classes/GameData.cs deleted file mode 100644 index 938f57e..0000000 --- a/StarterNG/Classes/GameData.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using StarterNG.Application; -using StarterNG.Application.Abstractions; -using StarterNG.Domain.Sceneries; - -namespace StarterNG.Classes; - -public enum LoadPhase -{ - Vehicles, - Sceneries, - Done -} - -public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail); - -public sealed class GameData -{ - public static GameData Instance { get; } = new(); - - public VehicleDatabase Vehicles { get; } = new(); - public List Sceneries { get; } = new(); - - public bool Loaded { get; private set; } - - public void Load(IProgress? progress = null, - string miniDir = "textures/mini/", - string dynamicRoot = "dynamic") - { - if (Loaded) - return; - - progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt")); - int vehicles = Vehicles.LoadFromTexturesTxt(dynamicRoot); - if (vehicles == 0) - Infrastructure.Diagnostics.Log($"{dynamicRoot}: brak plikow textures.txt"); - - Parallel.Invoke( - () => VehicleDatabase.PreloadMiniIndex(miniDir), - () => Physics.PreloadIndex()); - - var sceneryProgress = progress is null - ? null - : new Progress(step => progress.Report( - new LoadStatus((double)step.Loaded / step.Total, LoadPhase.Sceneries, step.FileName))); - ReloadSceneries(sceneryProgress); - - progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null)); - Loaded = true; - } - - public void ReloadSceneries(IProgress? progress = null) - { - Sceneries.Clear(); - Sceneries.AddRange(AppServices.Current.Sceneries.LoadAll(progress)); - } -} diff --git a/StarterNG/Classes/Physics.cs b/StarterNG/Classes/Physics.cs deleted file mode 100644 index da41bc9..0000000 --- a/StarterNG/Classes/Physics.cs +++ /dev/null @@ -1,232 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; - -namespace StarterNG.Classes; - -public sealed class Physics -{ - public double Mass; - public double VMax; - public double Length; - public string LoadAccepted = ""; - public string LoadQ = ""; - public int MaxLoad; - public int AllowedFlagA = 3; - public int AllowedFlagB = 3; - public string ControlTypeA = ""; - public string ControlTypeB = ""; - - public string EngineType = ""; - - /// - /// True for the engine types the simulator actually runs its diesel heat model for. - /// Mover.cpp calls dizel_Update - and through it dizel_Heat - only for DieselEngine - /// and DieselElectric; LoadFIZ_EngineDecode maps the legacy "DumbDE" onto - /// DieselElectric. On anything else the coolant temperature struct is written and - /// never read. - /// - public bool IsDieselEngine => - EngineType.Equals("DieselEngine", StringComparison.OrdinalIgnoreCase) - || EngineType.Equals("DieselElectric", StringComparison.OrdinalIgnoreCase) - || EngineType.Equals("DumbDE", StringComparison.OrdinalIgnoreCase); - - private static readonly Dictionary Cache = new(StringComparer.OrdinalIgnoreCase); - - private static Dictionary? _fizIndex; - - public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot); - - public static int IndexedCount => _fizIndex?.Count ?? 0; - - private static Dictionary FizIndex(string dynamicRoot) - { - if (_fizIndex != null) - return _fizIndex; - - var index = new Dictionary(StringComparer.OrdinalIgnoreCase); - try - { - if (Directory.Exists(dynamicRoot)) - foreach (string f in Directory.EnumerateFiles(dynamicRoot, "*.fiz", SearchOption.AllDirectories)) - index[Path.GetFileNameWithoutExtension(f)] = f; - } - catch (Exception ex) - { - StarterNG.Infrastructure.Diagnostics.Log($"indexing {dynamicRoot}", ex); - } - - _fizIndex = index; - return index; - } - - public static Physics? For(string? dataFolder, string? model, string dynamicRoot = "dynamic") - { - if (string.IsNullOrEmpty(model)) - return null; - - string m = Path.GetFileNameWithoutExtension(model!); - if (m.Length == 0) - return null; - - if (Cache.TryGetValue(m, out var cached)) - return cached; - - var index = FizIndex(dynamicRoot); - string? path = null; - if (index.TryGetValue(m, out var p1)) path = p1; - else if (index.TryGetValue(m + "dumb", out var p2)) path = p2; - - Physics? phys = null; - if (path != null) - { - phys = new Physics { AllowedFlagB = -1 }; - try { Parse(phys, path, Path.GetDirectoryName(path) ?? "", null, 0); } - catch (Exception ex) { StarterNG.Infrastructure.Diagnostics.Log($"physics/{Path.GetFileName(path)}", ex); } - - if (phys.AllowedFlagB == -1) - { - phys.AllowedFlagB = phys.AllowedFlagA; - if (string.IsNullOrEmpty(phys.ControlTypeB)) - phys.ControlTypeB = phys.ControlTypeA; - } - } - - Cache[m] = phys; - return phys; - } - - private static readonly HashSet KnownKeys = new(StringComparer.OrdinalIgnoreCase) - { - "M", "Vmax", "L", "LoadAccepted", "LoadQ", "MaxLoad", "AllowedFlag", "ControlType", - "EngineType" - }; - - private static void Parse(Physics p, string path, string dir, string[]? prms, int depth) - { - if (depth > 6 || !File.Exists(path)) - return; - - var tokens = Tokenize(File.ReadAllText(path, Encoding.GetEncoding(1250))); - - string section = ""; - string? pendingKey = null; - - for (int i = 0; i < tokens.Count; i++) - { - string tok = tokens[i]; - if (tok == "=") - continue; - - if (tok.Equals("include", StringComparison.OrdinalIgnoreCase)) - { - if (++i >= tokens.Count) break; - string incFile = Resolve(tokens[i], prms); - var incParams = new List(); - i++; - while (i < tokens.Count && !tokens[i].Equals("end", StringComparison.OrdinalIgnoreCase)) - incParams.Add(Resolve(tokens[i++], prms)); - Parse(p, Path.Combine(dir, incFile), dir, incParams.ToArray(), depth + 1); - pendingKey = null; - continue; - } - - int eq = tok.IndexOf('='); - if (eq >= 0) - { - string k = tok[..eq].TrimStart('.').Trim(); - string v = tok[(eq + 1)..].Trim(); - if (v.Length == 0) { pendingKey = k; continue; } - Apply(p, section, k, Resolve(v, prms)); - pendingKey = null; - continue; - } - - string t = tok.TrimStart('.').Trim(); - if (pendingKey != null) - { - Apply(p, section, pendingKey, Resolve(t, prms)); - pendingKey = null; - } - else if (KnownKeys.Contains(t)) - { - pendingKey = t; - } - else - { - - section = new string(t.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); - } - } - } - - private static void Apply(Physics p, string section, string key, string value) - { - switch (section) - { - case "param": - if (key.Equals("M", StringComparison.OrdinalIgnoreCase)) p.Mass = ParseD(value, p.Mass); - else if (key.Equals("Vmax", StringComparison.OrdinalIgnoreCase)) p.VMax = ParseD(value, p.VMax); - break; - case "load": - if (key.Equals("LoadAccepted", StringComparison.OrdinalIgnoreCase)) p.LoadAccepted = value; - else if (key.Equals("LoadQ", StringComparison.OrdinalIgnoreCase)) p.LoadQ = value; - else if (key.Equals("MaxLoad", StringComparison.OrdinalIgnoreCase)) p.MaxLoad = (int)ParseD(value, p.MaxLoad); - break; - case "engine": - if (key.Equals("EngineType", StringComparison.OrdinalIgnoreCase)) p.EngineType = value; - break; - case "dimensions": - if (key.Equals("L", StringComparison.OrdinalIgnoreCase)) p.Length = ParseD(value, p.Length); - break; - case "buffcoupl": - case "buffcoupl1": - if (key.Equals("AllowedFlag", StringComparison.OrdinalIgnoreCase)) p.AllowedFlagA = Flag(value, p.AllowedFlagA); - else if (key.Equals("ControlType", StringComparison.OrdinalIgnoreCase)) p.ControlTypeA = value; - break; - case "buffcoupl2": - if (key.Equals("AllowedFlag", StringComparison.OrdinalIgnoreCase)) p.AllowedFlagB = Flag(value, p.AllowedFlagB); - else if (key.Equals("ControlType", StringComparison.OrdinalIgnoreCase)) p.ControlTypeB = value; - break; - } - } - - private static int Flag(string v, int fallback) - => int.TryParse(v, System.Globalization.NumberStyles.Integer, - System.Globalization.CultureInfo.InvariantCulture, out int f) - ? (f < 0 ? -f + 128 : f) - : fallback; - - private static double ParseD(string v, double fallback) - => double.TryParse(v, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out double d) - ? d - : fallback; - - private static string Resolve(string token, string[]? prms) - { - if (prms == null || token.Length < 3 || token[0] != '(' || token[^1] != ')') - return token; - string digits = new string(token.Where(char.IsDigit).ToArray()); - return int.TryParse(digits, out int idx) && idx >= 1 && idx <= prms.Length - ? prms[idx - 1] - : token; - } - - private static List Tokenize(string text) - { - var sb = new StringBuilder(); - foreach (var line in text.Split('\n')) - { - string l = line; - int hash = l.IndexOf('#'); - if (hash >= 0) l = l[..hash]; - sb.Append(l).Append(' '); - } - return sb.ToString() - .Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .ToList(); - } -} diff --git a/StarterNG/Classes/Settings.cs b/StarterNG/Classes/Settings.cs index 3b5b4fe..9d093df 100644 --- a/StarterNG/Classes/Settings.cs +++ b/StarterNG/Classes/Settings.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Text; +using StarterNG.Application; namespace StarterNG.Classes; @@ -395,7 +396,7 @@ public sealed class Settings if (!LogMissingVehicleFiles) return; try { - var lines = GameData.Instance.Vehicles.CollectMissingAssetLines(); + var lines = AppServices.Current.MissingAssets.Scan(AppServices.Current.Library.Vehicles); if (lines.Count == 0) return; Infrastructure.Diagnostics.Log(lines); } diff --git a/StarterNG/Classes/TexturesTxt.cs b/StarterNG/Classes/TexturesTxt.cs deleted file mode 100644 index 01e517f..0000000 --- a/StarterNG/Classes/TexturesTxt.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; - -namespace StarterNG.Classes; - -public static class TexturesTxt -{ - public static List EnumerateFiles(string dynamicRoot = "dynamic") - { - var files = new List(); - if (!Directory.Exists(dynamicRoot)) - return files; - - foreach (string group in Directory.EnumerateDirectories(dynamicRoot)) - foreach (string sub in Directory.EnumerateDirectories(group)) - { - string path = Path.Combine(sub, "textures.txt"); - if (File.Exists(path)) - files.Add(path); - } - return files; - } - - public static VehicleEntry? Parse(string path, string dynamicRoot = "dynamic") - { - string[] lines; - try - { - lines = File.ReadAllLines(path, Encoding.GetEncoding(1250)); - } - catch (Exception ex) - { - Infrastructure.Diagnostics.Log($"textures.txt {path}", ex); - return null; - } - - string dir = RelativeDirectory(path, dynamicRoot); - var entry = new VehicleEntry { Uuid = "legacy:" + dir }; - - bool archived = false; - string categorySign = "*"; - int setSize = 0; - var pending = new List(); - - foreach (string raw in lines) - { - string line = raw.Trim(); - if (line.Length == 0) continue; - - char c = line[0]; - if (c is '#' or '@' or '*') continue; - - if (line.StartsWith("$a", StringComparison.OrdinalIgnoreCase)) - { - archived = true; - continue; - } - - if (c == '^') - { - FlushSet(entry, pending, setSize); - setSize = line.Length > 1 && char.IsDigit(line[1]) ? line[1] - '0' : 0; - continue; - } - - if (line.StartsWith("!=", StringComparison.Ordinal)) - { - categorySign = line.Length > 2 ? line[2].ToString() : "*"; - continue; - } - - if (line.IndexOf('=') < 0) continue; - if (line.StartsWith("//", StringComparison.Ordinal)) continue; - - var texture = ParseEntryLine(line, dir, categorySign, archived, entry); - if (texture is null) continue; - - entry.Textures.Add(texture); - - if (setSize > 1) - { - pending.Add(texture.Uuid!); - if (pending.Count == setSize) - FlushSet(entry, pending, setSize); - } - } - - FlushSet(entry, pending, setSize); - return entry.Textures.Count > 0 ? entry : null; - } - - private static void FlushSet(VehicleEntry entry, List pending, int size) - { - if (pending.Count > 1) - entry.Sets.Add(new VehicleSet - { - Uuid = "legacy-set:" + pending[0], - Mode = "sequence", - Count = size > 0 ? size : pending.Count, - TextureRefs = new List(pending) - }); - pending.Clear(); - } - - private static VehicleTexture? ParseEntryLine(string line, string dir, - string categorySign, bool archived, VehicleEntry entry) - { - string desc = ""; - int comment = line.IndexOf("//", StringComparison.Ordinal); - if (comment >= 0) - { - desc = line[(comment + 2)..].Trim(); - line = line[..comment].Trim(); - } - - var parts = line.Split('='); - if (parts.Length < 2) return null; - - string skin = StripExtension(parts[0].Trim()); - if (skin.Length == 0) return null; - - var specs = new List<(string Model, string Mini, string MiniD)>(); - for (int i = 1; i < parts.Length; i++) - { - var f = parts[i].Split(','); - if (f.Length < 2) continue; - specs.Add((f[0].Trim(), f[1].Trim(), f.Length > 2 ? f[2].Trim() : "")); - } - if (specs.Count == 0) return null; - - var first = specs[0]; - string groupId = $"legacy:{dir}:{first.Mini}:{categorySign}:{(archived ? 1 : 0)}"; - - if (!entry.Groups.Exists(g => g.Id == groupId)) - entry.Groups.Add(new VehicleGroup - { - Id = groupId, - Category = categorySign, - Mini = first.Mini, - Archived = archived - }); - - var texture = new VehicleTexture - { - Uuid = $"legacy:{dir}{skin}", - Directory = dir, - Skinfile = skin, - Wreck = skin.Contains("wreck", StringComparison.OrdinalIgnoreCase) || - skin.Contains("wrak", StringComparison.OrdinalIgnoreCase), - Model = first.Model, - Group = groupId, - MiniRef = first.Mini, - TextureMini = string.IsNullOrEmpty(first.MiniD) ? null : first.MiniD, - Meta = ParseDescription(desc) - }; - - for (int i = 1; i < specs.Count; i++) - texture.Aliases.Add(new VehicleAlias - { - Model = specs[i].Model, - MiniRef = specs[i].Mini, - TextureMini = string.IsNullOrEmpty(specs[i].MiniD) ? null : specs[i].MiniD - }); - - return texture; - } - - private static VehicleMeta? ParseDescription(string desc) - { - if (string.IsNullOrWhiteSpace(desc)) return null; - - var f = desc.Replace('_', ' ').Split(','); - string At(int i) => i < f.Length ? f[i].Trim() : ""; - - return new VehicleMeta - { - Raw = desc, - Version = At(0), - Vehicle = At(1), - Operator = At(2), - Depot = At(3), - RevisionDate = At(4), - RevisionPlace = At(5), - TextureAuthor = At(6), - PhotoAuthor = At(7) - }; - } - - private static string StripExtension(string name) - { - int dot = name.LastIndexOf('.'); - return dot > 0 ? name[..dot] : name; - } - - private static string RelativeDirectory(string texturesPath, string dynamicRoot) - { - string dir = Path.GetDirectoryName(texturesPath) ?? ""; - string full = Path.GetFullPath(dir); - string root = Path.GetFullPath(dynamicRoot); - - string rel = full.StartsWith(root, StringComparison.OrdinalIgnoreCase) - ? full[root.Length..].TrimStart(Path.DirectorySeparatorChar, '/') - : dir; - - return rel.Replace('\\', '/').TrimEnd('/') + "/"; - } -} diff --git a/StarterNG/Classes/VehicleDatabase.cs b/StarterNG/Classes/VehicleDatabase.cs deleted file mode 100644 index 8d827db..0000000 --- a/StarterNG/Classes/VehicleDatabase.cs +++ /dev/null @@ -1,312 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; - -namespace StarterNG.Classes; - -public class VehicleEntry -{ - public string? Uuid { get; set; } - public List Groups { get; set; } = new(); - public List Textures { get; set; } = new(); - public List Sets { get; set; } = new(); -} - -public class VehicleGroup -{ - public string Id { get; set; } = ""; - public string? Category { get; set; } - public string? Mini { get; set; } - public bool Archived { get; set; } -} - -public class VehicleTexture -{ - public string? Uuid { get; set; } - public string Directory { get; set; } = ""; - public string Skinfile { get; set; } = ""; - public string? Model { get; set; } - public string? Group { get; set; } - public string? MiniRef { get; set; } - public string? TextureMini { get; set; } - public bool Wreck { get; set; } - public List Aliases { get; set; } = new(); - public VehicleMeta? Meta { get; set; } - - public string FullPath => Directory + Skinfile; - - public string ResolvedClass { get; internal set; } = ""; - - public string? ResolvedCategory { get; internal set; } - - public bool ResolvedArchived { get; internal set; } -} - -public class VehicleAlias -{ - public string? Model { get; set; } - public string? Group { get; set; } - public string? MiniRef { get; set; } - public string? TextureMini { get; set; } -} - -public class VehicleMeta -{ - public string? Raw { get; set; } - public string? Version { get; set; } - public string? Vehicle { get; set; } - public string? Operator { get; set; } - public string? Depot { get; set; } - public string? RevisionDate { get; set; } - public string? RevisionPlace { get; set; } - public string? TextureAuthor { get; set; } - public string? PhotoAuthor { get; set; } - public List Extra { get; set; } = new(); -} - -public class VehicleDatabase -{ - public List Textures { get; } = new(); - public Dictionary GroupsById { get; } = new(); - public List Sets { get; } = new(); - - public Dictionary SetByTextureUuid { get; } = new(); - - public Dictionary TextureByUuid { get; } = new(); - - public Dictionary TextureBySkin { get; } = new(); - - public int LoadFromTexturesTxt(string dynamicRoot = "dynamic") - { - BeginLoad(); - - int count = 0; - foreach (string file in TexturesTxt.EnumerateFiles(dynamicRoot)) - { - var entry = TexturesTxt.Parse(file, dynamicRoot); - if (entry is null) continue; - - count += entry.Textures.Count; - Ingest(entry); - } - - EndLoad(); - return count; - } - - public void BeginLoad() - { - Textures.Clear(); - GroupsById.Clear(); - Sets.Clear(); - SetByTextureUuid.Clear(); - TextureByUuid.Clear(); - TextureBySkin.Clear(); - } - - public void EndLoad() - { - BuildSetIndex(); - BuildTextureIndex(); - } - - private void Ingest(VehicleEntry entry) - { - foreach (var group in entry.Groups) - { - if (!string.IsNullOrEmpty(group.Id)) - GroupsById.TryAdd(group.Id, group); - } - - foreach (var texture in entry.Textures) - { - - if (!string.IsNullOrEmpty(texture.Uuid)) - TextureByUuid[texture.Uuid!] = texture; - if (!string.IsNullOrEmpty(texture.Skinfile)) - TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture); - - if (texture.Wreck) continue; - Textures.Add(texture); - } - - Sets.AddRange(entry.Sets); - } - - public List? ResolveSet(VehicleTexture texture) - { - if (string.IsNullOrEmpty(texture.Uuid)) return null; - if (!SetByTextureUuid.TryGetValue(texture.Uuid!, out var set) || set.TextureRefs is null) - return null; - - var cars = new List(); - foreach (string uuid in set.TextureRefs) - { - if (!string.IsNullOrEmpty(uuid) && TextureByUuid.TryGetValue(uuid, out var tex)) - cars.Add(tex); - } - return cars.Count > 0 ? cars : null; - } - - private void BuildSetIndex() - { - foreach (var set in Sets) - { - if (set.TextureRefs is null) continue; - foreach (string uuid in set.TextureRefs) - { - if (!string.IsNullOrEmpty(uuid)) - SetByTextureUuid[uuid] = set; - } - } - } - - private void BuildTextureIndex() - { - foreach (var t in Textures) - { - VehicleGroup? g = null; - if (t.Group != null) - GroupsById.TryGetValue(t.Group, out g); - - string mini = g != null && !string.IsNullOrEmpty(g.Mini) - ? g.Mini! - : t.MiniRef ?? ""; - - string? category = g?.Category; - - if (category == "*") - { - string source = !string.IsNullOrEmpty(mini) ? mini : (t.TextureMini ?? ""); - if (source.Length > 0) - category = char.ToUpperInvariant(source[0]).ToString(); - } - - t.ResolvedCategory = category; - t.ResolvedClass = mini; - t.ResolvedArchived = g?.Archived ?? false; - } - } - - public bool IsSetFollower(VehicleTexture texture) - { - if (string.IsNullOrEmpty(texture.Uuid)) return false; - if (!SetByTextureUuid.TryGetValue(texture.Uuid!, out var set) || - set.TextureRefs is null || set.TextureRefs.Count < 2) - return false; - - string? lead = set.TextureRefs.FirstOrDefault(r => !string.IsNullOrEmpty(r)); - return lead != null && - !string.Equals(lead, texture.Uuid, StringComparison.OrdinalIgnoreCase); - } - - public string? ResolveMiniName(VehicleTexture texture) - { - if (HasMini(texture.TextureMini)) - return texture.TextureMini; - - if (texture.Group != null && GroupsById.TryGetValue(texture.Group, out var grp) - && !string.IsNullOrEmpty(grp.Mini)) - return grp.Mini; - - return texture.TextureMini; - } - - public string? MiniForSkin(string? skinFile) - { - if (string.IsNullOrEmpty(skinFile)) return null; - string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant(); - return TextureBySkin.TryGetValue(key, out var tex) ? ResolveMiniName(tex) : null; - } - - public VehicleTexture? TextureForSkin(string? skinFile) - { - if (string.IsNullOrEmpty(skinFile)) return null; - string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant(); - return TextureBySkin.TryGetValue(key, out var tex) ? tex : null; - } - - public List CollectMissingAssetLines() - { - var lines = new List(); - foreach (var t in Textures) - { - string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic", - t.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/')); - string skin = Path.Combine(dir, t.Skinfile); - if (!string.IsNullOrEmpty(t.Skinfile) && !File.Exists(skin) && - !File.Exists(Path.ChangeExtension(skin, ".mat")) && - !File.Exists(Path.ChangeExtension(skin, ".bmp"))) - lines.Add($"# no file: {t.Directory}{t.Skinfile}"); - - if (!string.IsNullOrEmpty(t.Model)) - { - string model = Path.Combine(dir, t.Model!); - if (!File.Exists(model) && !File.Exists(Path.ChangeExtension(model, ".t3d")) && - !File.Exists(Path.ChangeExtension(model, ".e3d"))) - lines.Add($"# no model: {t.Directory}{t.Model}"); - } - } - return lines; - } - - public string GroupHeader(string? groupId) - { - if (!string.IsNullOrEmpty(groupId) && GroupsById.TryGetValue(groupId, out var grp)) - { - string mini = string.IsNullOrEmpty(grp.Mini) ? grp.Id : grp.Mini!; - return string.IsNullOrEmpty(grp.Category) ? mini : $"{mini} ({grp.Category})"; - } - return groupId ?? ""; - } - - private static Dictionary? _miniIndex; - - public static void PreloadMiniIndex(string miniDir = "textures/mini/") => - _miniIndex ??= BuildMiniIndex(miniDir); - - public static string? MiniPath(string? miniName, string miniDir = "textures/mini/") - { - var index = _miniIndex ??= BuildMiniIndex(miniDir); - - if (!string.IsNullOrEmpty(miniName) && - index.TryGetValue(miniName!.ToLowerInvariant(), out var path)) - return path; - - return FallbackMiniPath(miniDir); - } - - public static bool HasMini(string? miniName, string miniDir = "textures/mini/") - { - if (string.IsNullOrEmpty(miniName)) return false; - var index = _miniIndex ??= BuildMiniIndex(miniDir); - return index.ContainsKey(miniName!.ToLowerInvariant()); - } - - public static string? FallbackMiniPath(string miniDir = "textures/mini/") - { - var index = _miniIndex ??= BuildMiniIndex(miniDir); - return index.TryGetValue("other", out var other) ? other : null; - } - - private static Dictionary BuildMiniIndex(string miniDir) - { - var index = new Dictionary(); - if (!Directory.Exists(miniDir)) - return index; - - foreach (string file in Directory.GetFiles(miniDir, "*.bmp")) - index[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file; - return index; - } -} - -public class VehicleSet -{ - public string? Uuid { get; set; } - public string? Mode { get; set; } - public int Count { get; set; } - public List TextureRefs { get; set; } = new(); -} diff --git a/StarterNG/Domain/Consist.cs b/StarterNG/Domain/Consist.cs index 179dfc8..a60eca0 100644 --- a/StarterNG/Domain/Consist.cs +++ b/StarterNG/Domain/Consist.cs @@ -5,6 +5,7 @@ using System.Linq; using StarterNG.Classes; using StarterNG.Domain.Sceneries; +using StarterNG.Domain.Vehicles; namespace StarterNG.Domain; @@ -12,12 +13,12 @@ public sealed class Consist : IReadOnlyList { private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" }; - private readonly VehicleDatabase _db; + private readonly VehicleCatalog _db; private readonly VehicleInfo _info; private readonly List _items = new(); private int _nameCounter; - public Consist(VehicleDatabase db, VehicleInfo info) + public Consist(VehicleCatalog db, VehicleInfo info) { _db = db; _info = info; diff --git a/StarterNG/Domain/TexRandomizer.cs b/StarterNG/Domain/TexRandomizer.cs index 4cdbbaf..450afd9 100644 --- a/StarterNG/Domain/TexRandomizer.cs +++ b/StarterNG/Domain/TexRandomizer.cs @@ -3,12 +3,13 @@ using System.Collections.Generic; using System.IO; using System.Linq; using StarterNG.Classes; +using StarterNG.Domain.Vehicles; namespace StarterNG.Domain; public sealed class TexRandomizer { - private readonly VehicleDatabase _db; + private readonly VehicleCatalog _db; private readonly Random _rng; private readonly List _stock = new(); @@ -29,7 +30,7 @@ public sealed class TexRandomizer } } - public TexRandomizer(VehicleDatabase db, Random? rng = null) + public TexRandomizer(VehicleCatalog db, Random? rng = null) { _db = db; _rng = rng ?? Random.Shared; diff --git a/StarterNG/Domain/TrainsetDisplay.cs b/StarterNG/Domain/TrainsetDisplay.cs index e4e0aa4..040add4 100644 --- a/StarterNG/Domain/TrainsetDisplay.cs +++ b/StarterNG/Domain/TrainsetDisplay.cs @@ -6,6 +6,8 @@ using System.Linq; using System.Text; using StarterNG.Classes; using StarterNG.Domain.Sceneries; +using StarterNG.Domain.Vehicles; +using StarterNG.Application; namespace StarterNG.Domain; @@ -65,14 +67,14 @@ public static class TrainsetDisplay public static string VehicleLabel(Dynamic v, bool head = false) { - var tex = GameData.Instance.Vehicles.TextureForSkin(v.SkinFile); + var tex = AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile); string? cls = string.IsNullOrEmpty(tex?.ResolvedClass) ? null : tex!.ResolvedClass; string? name = string.IsNullOrWhiteSpace(v.Name) ? null : v.Name; return (head ? name ?? cls : cls ?? name) ?? BaseSkin(v); } public static bool IsPowered(Dynamic v) => - GameData.Instance.Vehicles.TextureForSkin(v.SkinFile)?.ResolvedCategory + AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile)?.ResolvedCategory is "e" or "s" or "p" or "z" or "a"; private static string BaseSkin(Dynamic v) => @@ -94,7 +96,7 @@ public static class TrainsetDisplay } public static List<(string Label, string Value)> StatsFields( - Trainset? trainset, Func physicsFor, + Trainset? trainset, Func physicsFor, Func? categoryOf = null, Func? loadWeightKg = null) { @@ -120,7 +122,7 @@ public static class TrainsetDisplay return fields; } - public static string FormatStats(Trainset? trainset, Func physicsFor, + public static string FormatStats(Trainset? trainset, Func physicsFor, string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel, Func? categoryOf = null, Func? loadWeightKg = null) @@ -149,7 +151,7 @@ public static class TrainsetDisplay private static (double lengthM, double massKg, double loadKg) RecalcParams( Trainset trainset, - Func physicsFor, + Func physicsFor, Func? categoryOf, Func? loadWeightKg, bool allVehicles) @@ -176,7 +178,7 @@ public static class TrainsetDisplay return (lengthM, massKg, loadKg); } - private static bool IncludeVehicleToMass(Dynamic car, Physics? p, string? category, bool allVehicles) + private static bool IncludeVehicleToMass(Dynamic car, VehiclePhysics? p, string? category, bool allVehicles) { if (p == null) return false; if (allVehicles) return true; @@ -184,7 +186,7 @@ public static class TrainsetDisplay return car.DriverType is eDriverType.Passenger or eDriverType.Nobody; } - private static bool LoadAccepted(Physics? p, string loadType) + private static bool LoadAccepted(VehiclePhysics? p, string loadType) { if (p == null || string.IsNullOrWhiteSpace(p.LoadAccepted)) return false; @@ -194,7 +196,7 @@ public static class TrainsetDisplay public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName) { var used = CollectNames(scenery); - var db = GameData.Instance.Vehicles; + var db = AppServices.Current.Library.Vehicles; bool mu = trainset.Vehicles.Any(v => { @@ -224,7 +226,7 @@ public static class TrainsetDisplay public static string PreferredBaseName(Dynamic v) { - var tex = GameData.Instance.Vehicles.TextureForSkin(v.SkinFile); + var tex = AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile); if (tex != null) { diff --git a/StarterNG/Domain/UData.cs b/StarterNG/Domain/UData.cs index dc275cb..16c3038 100644 --- a/StarterNG/Domain/UData.cs +++ b/StarterNG/Domain/UData.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using StarterNG.Classes; +using StarterNG.Domain.Vehicles; namespace StarterNG.Domain; @@ -9,7 +10,7 @@ public static class UData { public readonly record struct TrainParams(double Length, double Mass, double LoadMass, int CountVehicles); - public static int GetMaxCoupler(Dynamic v, Func physicsFor, bool leftCoupler = true) + public static int GetMaxCoupler(Dynamic v, Func physicsFor, bool leftCoupler = true) { var fiz = physicsFor(v); if (fiz == null) return 3; @@ -18,7 +19,7 @@ public static class UData : (v.Offset >= 0 ? fiz.AllowedFlagB : fiz.AllowedFlagA); } - public static string GetControlType(Dynamic v, Func physicsFor, bool leftCoupler = true) + public static string GetControlType(Dynamic v, Func physicsFor, bool leftCoupler = true) { var fiz = physicsFor(v); if (fiz == null) return ""; @@ -43,7 +44,7 @@ public static class UData } public static void AutoConnect(IList vehicles, int leftVehicle, - Func physicsFor) + Func physicsFor) { if (leftVehicle < 0 || vehicles.Count - 1 <= leftVehicle) return; @@ -98,7 +99,7 @@ public static class UData d is eDriverType.Headdriver or eDriverType.Reardriver; public static bool IncludeVehicleToMass(Dynamic v, bool allVehicles, - Func physicsFor, Func categoryOf) + Func physicsFor, Func categoryOf) { if (physicsFor(v) == null) return false; if (allVehicles) return true; @@ -109,7 +110,7 @@ public static class UData } public static TrainParams RecalcTrainParams(Trainset? train, bool allVehicles, - Func physicsFor, Func categoryOf, + Func physicsFor, Func categoryOf, Func loadWeight) { double length = 0, mass = 0, loadMass = 0; @@ -138,7 +139,7 @@ public static class UData } public static void RandomLoad(IEnumerable vehicles, - Func physicsFor, Random rng) + Func physicsFor, Random rng) { foreach (var v in vehicles) { @@ -157,7 +158,7 @@ public static class UData } public static void ReverseMultiple(IList vehicles, int position, - Func setIdOf, Func physicsFor) + Func setIdOf, Func physicsFor) { var indexes = GetMultiple(vehicles, position, setIdOf); diff --git a/StarterNG/Domain/VehicleInfo.cs b/StarterNG/Domain/VehicleInfo.cs index 28a3aa0..d83e68d 100644 --- a/StarterNG/Domain/VehicleInfo.cs +++ b/StarterNG/Domain/VehicleInfo.cs @@ -1,12 +1,14 @@ using StarterNG.Classes; +using StarterNG.Domain.Vehicles; +using StarterNG.Application; namespace StarterNG.Domain; public sealed class VehicleInfo { - private readonly VehicleDatabase _db; + private readonly VehicleCatalog _db; - public VehicleInfo(VehicleDatabase db) => _db = db; + public VehicleInfo(VehicleCatalog db) => _db = db; public static string ClassOf(VehicleTexture t) => t.ResolvedClass; @@ -20,11 +22,11 @@ public sealed class VehicleInfo public string? CategoryOf(Dynamic car) => TextureFor(car) is { } t ? CategoryOf(t) : null; - public Physics? PhysicsFor(Dynamic car) + public VehiclePhysics? PhysicsFor(Dynamic car) { string? dbModel = TextureFor(car)?.Model; - return Physics.For(car.DataFolder, dbModel) - ?? Physics.For(car.DataFolder, car.MmdFile) - ?? Physics.For(car.DataFolder, car.SkinFile); + return AppServices.Current.Physics.For(car.DataFolder, dbModel) + ?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile) + ?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile); } } diff --git a/StarterNG/Domain/Vehicles/VehicleCatalog.cs b/StarterNG/Domain/Vehicles/VehicleCatalog.cs new file mode 100644 index 0000000..96bf7c4 --- /dev/null +++ b/StarterNG/Domain/Vehicles/VehicleCatalog.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Domain.Vehicles; + +/// +/// Every livery the installation offers, indexed for the lookups the depot and +/// the consist editor make: by uuid, by skin file, and by the set a vehicle +/// belongs to. +/// +/// +/// Was VehicleCatalog, which also owned file enumeration, textures.txt +/// parsing, a static thumbnail dictionary and a missing-asset scan. Those moved +/// to Infrastructure; what is left is the model and its resolution rules, filled +/// by a repository through . +/// +public sealed class VehicleCatalog +{ + private readonly IMiniTextureIndex _minis; + + public VehicleCatalog(IMiniTextureIndex minis) + { + _minis = minis; + } + + /// Every livery worth offering (wrecks excluded). + public List Textures { get; } = new(); + + public Dictionary GroupsById { get; } = new(); + + public List Sets { get; } = new(); + + public Dictionary SetByTextureUuid { get; } = new(); + + public Dictionary TextureByUuid { get; } = new(); + + /// Liveries by skin file name without extension, lower cased. + public Dictionary TextureBySkin { get; } = new(); + + public void BeginLoad() + { + Textures.Clear(); + GroupsById.Clear(); + Sets.Clear(); + SetByTextureUuid.Clear(); + TextureByUuid.Clear(); + TextureBySkin.Clear(); + } + + /// Adds one vehicle folder's liveries to the catalogue. + public void Ingest(VehicleEntry entry) + { + foreach (var group in entry.Groups) + if (!string.IsNullOrEmpty(group.Id)) + GroupsById.TryAdd(group.Id, group); + + foreach (var texture in entry.Textures) + { + if (!string.IsNullOrEmpty(texture.Uuid)) + TextureByUuid[texture.Uuid] = texture; + + if (!string.IsNullOrEmpty(texture.Skinfile)) + TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture); + + if (texture.Wreck) + continue; + + Textures.Add(texture); + } + + Sets.AddRange(entry.Sets); + } + + /// Builds the indexes that depend on the catalogue being complete. + public void EndLoad() + { + BuildSetIndex(); + ResolveGroupInheritance(); + } + + /// The vehicles of the fixed set this livery leads or belongs to. + public List? ResolveSet(VehicleTexture texture) + { + if (string.IsNullOrEmpty(texture.Uuid)) + return null; + if (!SetByTextureUuid.TryGetValue(texture.Uuid, out var set) || set.TextureRefs is null) + return null; + + var cars = new List(); + foreach (string uuid in set.TextureRefs) + if (!string.IsNullOrEmpty(uuid) && TextureByUuid.TryGetValue(uuid, out var car)) + cars.Add(car); + + return cars.Count > 0 ? cars : null; + } + + /// + /// True when the livery is a non-leading member of a set, and so is added + /// along with its leader rather than on its own. + /// + public bool IsSetFollower(VehicleTexture texture) + { + if (string.IsNullOrEmpty(texture.Uuid)) + return false; + if (!SetByTextureUuid.TryGetValue(texture.Uuid, out var set) || + set.TextureRefs is null || set.TextureRefs.Count < 2) + return false; + + string? lead = set.TextureRefs.FirstOrDefault(reference => !string.IsNullOrEmpty(reference)); + return lead is not null && !string.Equals(lead, texture.Uuid, StringComparison.OrdinalIgnoreCase); + } + + /// + /// The thumbnail to draw for a livery: its own if we have that bitmap, + /// otherwise the one its group declares. + /// + public string? ResolveMiniName(VehicleTexture texture) + { + if (_minis.Has(texture.TextureMini)) + return texture.TextureMini; + + if (texture.Group is not null && GroupsById.TryGetValue(texture.Group, out var group) && + !string.IsNullOrEmpty(group.Mini)) + return group.Mini; + + return texture.TextureMini; + } + + public string? MiniForSkin(string? skinFile) => + TextureForSkin(skinFile) is { } texture ? ResolveMiniName(texture) : null; + + public VehicleTexture? TextureForSkin(string? skinFile) + { + if (string.IsNullOrEmpty(skinFile)) + return null; + + string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant(); + return TextureBySkin.TryGetValue(key, out var texture) ? texture : null; + } + + /// Heading shown above a group in the depot browser. + public string GroupHeader(string? groupId) + { + if (string.IsNullOrEmpty(groupId) || !GroupsById.TryGetValue(groupId, out var group)) + return groupId ?? ""; + + string mini = string.IsNullOrEmpty(group.Mini) ? group.Id : group.Mini; + return string.IsNullOrEmpty(group.Category) ? mini : $"{mini} ({group.Category})"; + } + + private void BuildSetIndex() + { + foreach (var set in Sets) + { + if (set.TextureRefs is null) + continue; + + foreach (string uuid in set.TextureRefs) + if (!string.IsNullOrEmpty(uuid)) + SetByTextureUuid[uuid] = set; + } + } + + /// + /// Copies thumbnail, category and archived status down from each group onto + /// its liveries, so the browser can sort and filter without walking back up. + /// + private void ResolveGroupInheritance() + { + foreach (var texture in Textures) + { + VehicleGroup? group = null; + if (texture.Group is not null) + GroupsById.TryGetValue(texture.Group, out group); + + string mini = group is not null && !string.IsNullOrEmpty(group.Mini) + ? group.Mini + : texture.MiniRef ?? ""; + + string? category = group?.Category; + + // "*" means "categorise by the first letter of the thumbnail name". + if (category == "*") + { + string source = !string.IsNullOrEmpty(mini) ? mini : texture.TextureMini ?? ""; + if (source.Length > 0) + category = char.ToUpperInvariant(source[0]).ToString(); + } + + texture.ResolvedCategory = category; + texture.ResolvedClass = mini; + texture.ResolvedArchived = group?.Archived ?? false; + } + } +} diff --git a/StarterNG/Domain/Vehicles/VehiclePhysics.cs b/StarterNG/Domain/Vehicles/VehiclePhysics.cs new file mode 100644 index 0000000..22c62bd --- /dev/null +++ b/StarterNG/Domain/Vehicles/VehiclePhysics.cs @@ -0,0 +1,54 @@ +using System; + +namespace StarterNG.Domain.Vehicles; + +/// +/// The physical properties of a vehicle type, as declared by its .fiz file: +/// what it weighs, how fast it goes, what it can carry and what it couples to. +/// +/// +/// Was Classes.Physics, which also owned the .fiz index, the parser and a +/// static cache. Those are now Infrastructure.Vehicles.FizPhysicsRepository; +/// this is the value the rest of the application reasons about. +/// +public sealed class VehiclePhysics +{ + /// Tare mass in kilograms. + public double Mass { get; set; } + + /// Design speed in km/h. + public double VMax { get; set; } + + /// Length over buffers in metres. + public double Length { get; set; } + + public string LoadAccepted { get; set; } = ""; + + public string LoadQ { get; set; } = ""; + + public int MaxLoad { get; set; } + + /// Coupling capabilities of the front end. + public int AllowedFlagA { get; set; } = 3; + + /// Coupling capabilities of the rear end. + public int AllowedFlagB { get; set; } = 3; + + public string ControlTypeA { get; set; } = ""; + + public string ControlTypeB { get; set; } = ""; + + public string EngineType { get; set; } = ""; + + /// + /// True for the engine types the simulator actually runs its diesel heat model for. + /// Mover.cpp calls dizel_Update - and through it dizel_Heat - only for DieselEngine + /// and DieselElectric; LoadFIZ_EngineDecode maps the legacy "DumbDE" onto + /// DieselElectric. On anything else the coolant temperature struct is written and + /// never read. + /// + public bool IsDieselEngine => + EngineType.Equals("DieselEngine", StringComparison.OrdinalIgnoreCase) + || EngineType.Equals("DieselElectric", StringComparison.OrdinalIgnoreCase) + || EngineType.Equals("DumbDE", StringComparison.OrdinalIgnoreCase); +} diff --git a/StarterNG/Domain/Vehicles/VehicleTexture.cs b/StarterNG/Domain/Vehicles/VehicleTexture.cs new file mode 100644 index 0000000..2c4131c --- /dev/null +++ b/StarterNG/Domain/Vehicles/VehicleTexture.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; + +namespace StarterNG.Domain.Vehicles; + +/// +/// One vehicle folder's contribution to the catalogue: the liveries it defines, +/// the groups they belong to and any fixed sets they form. +/// +public sealed class VehicleEntry +{ + public string? Uuid { get; set; } + public List Groups { get; } = new(); + public List Textures { get; } = new(); + public List Sets { get; } = new(); +} + +/// +/// A family of liveries sharing a thumbnail and a category letter, as declared +/// by one textures.txt block. +/// +public sealed class VehicleGroup +{ + public string Id { get; set; } = ""; + public string? Category { get; set; } + public string? Mini { get; set; } + + /// Withdrawn stock, hidden from the depot unless asked for. + public bool Archived { get; set; } +} + +/// A single livery: one skin file on one model. +public sealed class VehicleTexture +{ + public string? Uuid { get; set; } + public string Directory { get; set; } = ""; + public string Skinfile { get; set; } = ""; + public string? Model { get; set; } + public string? Group { get; set; } + public string? MiniRef { get; set; } + public string? TextureMini { get; set; } + + /// Wreck liveries exist in the files but are not offered in the depot. + public bool Wreck { get; set; } + + public List Aliases { get; } = new(); + public VehicleMeta? Meta { get; set; } + + public string FullPath => Directory + Skinfile; + + /// Thumbnail name after group inheritance; filled by the catalogue. + public string ResolvedClass { get; internal set; } = ""; + + /// Category letter after group inheritance; filled by the catalogue. + public string? ResolvedCategory { get; internal set; } + + /// Whether the owning group is archived; filled by the catalogue. + public bool ResolvedArchived { get; internal set; } +} + +/// An alternative model this same skin can be worn by. +public sealed class VehicleAlias +{ + public string? Model { get; set; } + public string? Group { get; set; } + public string? MiniRef { get; set; } + public string? TextureMini { get; set; } +} + +/// +/// The comma separated description a livery carries after "//": who made it, for +/// which operator and depot, and when it was last revised. +/// +public sealed class VehicleMeta +{ + public string? Raw { get; set; } + public string? Version { get; set; } + public string? Vehicle { get; set; } + public string? Operator { get; set; } + public string? Depot { get; set; } + public string? RevisionDate { get; set; } + public string? RevisionPlace { get; set; } + public string? TextureAuthor { get; set; } + public string? PhotoAuthor { get; set; } + public List Extra { get; } = new(); +} + +/// +/// Vehicles that belong together and are added to a consist as a unit, such as a +/// multiple unit or a fixed rake. +/// +public sealed class VehicleSet +{ + public string? Uuid { get; set; } + public string? Mode { get; set; } + public int Count { get; set; } + public List TextureRefs { get; set; } = new(); +} diff --git a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs index 124475a..f1751da 100644 --- a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs +++ b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs @@ -43,6 +43,11 @@ public sealed class PhysicalFileSystem : IFileSystem public IReadOnlyList GetFiles(string path, string searchPattern = "*") => Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty(); + public IReadOnlyList GetFilesRecursive(string path, string searchPattern) => + Directory.Exists(path) + ? Directory.GetFiles(path, searchPattern, SearchOption.AllDirectories) + : Array.Empty(); + public IReadOnlyList GetDirectories(string path) => Directory.Exists(path) ? Directory.GetDirectories(path) : Array.Empty(); diff --git a/StarterNG/Infrastructure/Diagnostics.cs b/StarterNG/Infrastructure/Diagnostics.cs index 05c1ee0..a8d45e7 100644 --- a/StarterNG/Infrastructure/Diagnostics.cs +++ b/StarterNG/Infrastructure/Diagnostics.cs @@ -70,6 +70,8 @@ public static class Diagnostics new InstallationCheck( AppServices.Current.Paths, AppServices.Current.Files, - AppServices.Current.Localization) + AppServices.Current.Localization, + AppServices.Current.Physics, + AppServices.Current.Library) .Run(); } diff --git a/StarterNG/Infrastructure/InstallationCheck.cs b/StarterNG/Infrastructure/InstallationCheck.cs index 61ed9c1..f0c9706 100644 --- a/StarterNG/Infrastructure/InstallationCheck.cs +++ b/StarterNG/Infrastructure/InstallationCheck.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using StarterNG.Application.Abstractions; using StarterNG.Classes; +using StarterNG.Application; namespace StarterNG.Infrastructure; @@ -20,12 +21,17 @@ public sealed class InstallationCheck private readonly IGamePaths _paths; private readonly IFileSystem _files; private readonly ILocalizedStrings _strings; + private readonly IPhysicsRepository _physics; + private readonly GameLibrary _library; - public InstallationCheck(IGamePaths paths, IFileSystem files, ILocalizedStrings strings) + public InstallationCheck(IGamePaths paths, IFileSystem files, ILocalizedStrings strings, + IPhysicsRepository physics, GameLibrary library) { _paths = paths; _files = files; _strings = strings; + _physics = physics; + _library = library; } public List Run() @@ -39,13 +45,13 @@ public sealed class InstallationCheck if (!_files.FileExists(Path.Combine(_paths.Data, "load_weights.txt"))) faults.Add(_strings["FaultNoWeights"]); - if (GameData.Instance.Sceneries.Count == 0) + if (_library.Sceneries.Count == 0) faults.Add(_strings["FaultNoScenery"]); - if (GameData.Instance.Vehicles.Textures.Count == 0) + if (_library.Vehicles.Textures.Count == 0) faults.Add(_strings["FaultNoVehicles"]); - if (Physics.IndexedCount == 0) + if (_physics.IndexedCount == 0) faults.Add(_strings["FaultNoPhysics"]); if (Settings.Instance.ResolveExecutable(out var problem) is var _ && problem == ExeProblem.NotFound) diff --git a/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs b/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs new file mode 100644 index 0000000..1856982 --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Infrastructure.Vehicles; + +/// +/// Reads vehicle physics from the .fiz files under dynamic/. +/// +/// +/// The format is a flat token stream of "section" headings and "key value" +/// pairs, with an include file p1 p2 end directive whose parameters are +/// substituted for (1)-style placeholders in the included file. Only the +/// handful of keys the starter shows are decoded; everything else is skipped. +/// +/// Both the name index and the parsed results are cached, because the depot asks +/// for the same model repeatedly while the user scrolls. +/// +public sealed class FizPhysicsRepository : IPhysicsRepository +{ + /// Guard against an include cycle in hand written files. + private const int MaxIncludeDepth = 6; + + /// Sentinel meaning "the rear coupler was never declared". + private const int UndeclaredCoupler = -1; + + private static readonly HashSet KnownKeys = new(StringComparer.OrdinalIgnoreCase) + { + "M", "Vmax", "L", "LoadAccepted", "LoadQ", "MaxLoad", "AllowedFlag", "ControlType", "EngineType" + }; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly IDiagnosticsLog _log; + private readonly Encoding _encoding; + + private readonly ConcurrentDictionary _cache = + new(StringComparer.OrdinalIgnoreCase); + + private readonly object _indexGate = new(); + private Dictionary? _index; + + public FizPhysicsRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log) + { + _files = files; + _paths = paths; + _log = log; + _encoding = LegacyEncoding(); + } + + public void Preload() => Index(); + + public int IndexedCount => _index?.Count ?? 0; + + public VehiclePhysics? For(string? dataFolder, string? model) + { + if (string.IsNullOrEmpty(model)) + return null; + + string name = Path.GetFileNameWithoutExtension(model); + if (name.Length == 0) + return null; + + if (_cache.TryGetValue(name, out var cached)) + return cached; + + var physics = Read(name); + _cache[name] = physics; + return physics; + } + + private VehiclePhysics? Read(string name) + { + var index = Index(); + + // Unpowered variants are shipped as "dumb.fiz". + if (!index.TryGetValue(name, out string? path) && + !index.TryGetValue(name + "dumb", out path)) + return null; + + var physics = new VehiclePhysics { AllowedFlagB = UndeclaredCoupler }; + try + { + Parse(physics, path, Path.GetDirectoryName(path) ?? "", parameters: null, depth: 0); + } + catch (Exception ex) + { + _log.Log($"physics/{Path.GetFileName(path)}", ex); + } + + // A vehicle that only declares one coupler has the same at both ends. + if (physics.AllowedFlagB == UndeclaredCoupler) + { + physics.AllowedFlagB = physics.AllowedFlagA; + if (string.IsNullOrEmpty(physics.ControlTypeB)) + physics.ControlTypeB = physics.ControlTypeA; + } + + return physics; + } + + private Dictionary Index() + { + lock (_indexGate) + { + if (_index is not null) + return _index; + + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + foreach (string file in _files.GetFilesRecursive(_paths.Dynamic, "*.fiz")) + index[Path.GetFileNameWithoutExtension(file)] = file; + } + catch (Exception ex) + { + _log.Log($"indexing {_paths.Dynamic}", ex); + } + + _index = index; + return index; + } + } + + private void Parse(VehiclePhysics physics, string path, string directory, string[]? parameters, int depth) + { + if (depth > MaxIncludeDepth || !_files.FileExists(path)) + return; + + var tokens = Tokenize(_files.ReadAllText(path, _encoding)); + + string section = ""; + string? pendingKey = null; + + for (int i = 0; i < tokens.Count; i++) + { + string token = tokens[i]; + if (token == "=") + continue; + + if (token.Equals("include", StringComparison.OrdinalIgnoreCase)) + { + if (++i >= tokens.Count) + break; + + string includeFile = Resolve(tokens[i], parameters); + var includeParameters = new List(); + i++; + while (i < tokens.Count && !tokens[i].Equals("end", StringComparison.OrdinalIgnoreCase)) + includeParameters.Add(Resolve(tokens[i++], parameters)); + + Parse(physics, Path.Combine(directory, includeFile), directory, includeParameters.ToArray(), + depth + 1); + pendingKey = null; + continue; + } + + int equals = token.IndexOf('='); + if (equals >= 0) + { + string key = token[..equals].TrimStart('.').Trim(); + string value = token[(equals + 1)..].Trim(); + if (value.Length == 0) + { + pendingKey = key; + continue; + } + Apply(physics, section, key, Resolve(value, parameters)); + pendingKey = null; + continue; + } + + string bare = token.TrimStart('.').Trim(); + if (pendingKey is not null) + { + Apply(physics, section, pendingKey, Resolve(bare, parameters)); + pendingKey = null; + } + else if (KnownKeys.Contains(bare)) + { + pendingKey = bare; + } + else + { + // Anything else at this level opens a section, e.g. "Param:" or "BuffCoupl1:". + section = new string(bare.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); + } + } + } + + private static void Apply(VehiclePhysics physics, string section, string key, string value) + { + switch (section) + { + case "param": + if (Is(key, "M")) physics.Mass = ParseDouble(value, physics.Mass); + else if (Is(key, "Vmax")) physics.VMax = ParseDouble(value, physics.VMax); + break; + case "load": + if (Is(key, "LoadAccepted")) physics.LoadAccepted = value; + else if (Is(key, "LoadQ")) physics.LoadQ = value; + else if (Is(key, "MaxLoad")) physics.MaxLoad = (int)ParseDouble(value, physics.MaxLoad); + break; + case "engine": + if (Is(key, "EngineType")) physics.EngineType = value; + break; + case "dimensions": + if (Is(key, "L")) physics.Length = ParseDouble(value, physics.Length); + break; + case "buffcoupl": + case "buffcoupl1": + if (Is(key, "AllowedFlag")) physics.AllowedFlagA = ParseFlag(value, physics.AllowedFlagA); + else if (Is(key, "ControlType")) physics.ControlTypeA = value; + break; + case "buffcoupl2": + if (Is(key, "AllowedFlag")) physics.AllowedFlagB = ParseFlag(value, physics.AllowedFlagB); + else if (Is(key, "ControlType")) physics.ControlTypeB = value; + break; + } + } + + private static bool Is(string key, string name) => key.Equals(name, StringComparison.OrdinalIgnoreCase); + + /// + /// A negative flag means the coupling is present but disabled; the simulator + /// encodes that as its absolute value plus 128. + /// + private static int ParseFlag(string value, int fallback) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int flag) + ? flag < 0 ? -flag + 128 : flag + : fallback; + + private static double ParseDouble(string value, double fallback) => + double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) + ? parsed + : fallback; + + /// Substitutes an include's "(n)" placeholder with its n-th parameter. + private static string Resolve(string token, string[]? parameters) + { + if (parameters is null || token.Length < 3 || token[0] != '(' || token[^1] != ')') + return token; + + string digits = new(token.Where(char.IsDigit).ToArray()); + return int.TryParse(digits, out int index) && index >= 1 && index <= parameters.Length + ? parameters[index - 1] + : token; + } + + private static List Tokenize(string text) + { + var sb = new StringBuilder(); + foreach (string line in text.Split('\n')) + { + string stripped = line; + int comment = stripped.IndexOf('#'); + if (comment >= 0) + stripped = stripped[..comment]; + sb.Append(stripped).Append(' '); + } + + return sb.ToString() + .Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + } + + private static Encoding LegacyEncoding() + { + try + { + return Encoding.GetEncoding(1250); + } + catch (Exception) + { + return Encoding.Latin1; + } + } +} diff --git a/StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs b/StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs new file mode 100644 index 0000000..8871522 --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.IO; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure.Vehicles; + +/// +/// over the installation's textures/mini folder. +/// +/// +/// Replaces the static thumbnail dictionary that used to live on the vehicle +/// database: an installation-scoped lookup with its own lifetime, which is what +/// lets a test point the depot at thumbnails that only exist in memory. +/// +public sealed class MiniTextureIndex : IMiniTextureIndex +{ + /// Thumbnail shown when a vehicle names one we do not have. + private const string FallbackName = "other"; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly object _gate = new(); + + private Dictionary? _index; + + public MiniTextureIndex(IFileSystem files, IGamePaths paths) + { + _files = files; + _paths = paths; + } + + public void Preload() => Index(); + + public bool Has(string? miniName) => + !string.IsNullOrEmpty(miniName) && Index().ContainsKey(miniName.ToLowerInvariant()); + + public string? PathFor(string? miniName) + { + if (!string.IsNullOrEmpty(miniName) && + Index().TryGetValue(miniName.ToLowerInvariant(), out string? path)) + return path; + + return FallbackPath; + } + + public string? FallbackPath => + Index().TryGetValue(FallbackName, out string? path) ? path : null; + + private Dictionary Index() + { + lock (_gate) + { + if (_index is not null) + return _index; + + var index = new Dictionary(StringComparer.Ordinal); + foreach (string file in _files.GetFiles(_paths.MiniTextures, "*.bmp")) + index[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file; + + _index = index; + return index; + } + } +} diff --git a/StarterNG/Infrastructure/Vehicles/MissingAssetScanner.cs b/StarterNG/Infrastructure/Vehicles/MissingAssetScanner.cs new file mode 100644 index 0000000..d44bdc1 --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/MissingAssetScanner.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.IO; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Infrastructure.Vehicles; + +/// +/// Reports liveries whose skin or model file is not actually present, for the +/// optional "log missing vehicle files" diagnostic. +/// +/// +/// Both assets are looked up under several extensions because the simulator +/// accepts a texture as .mat or .bmp and a model as .t3d or .e3d. +/// +public sealed class MissingAssetScanner +{ + private static readonly string[] TextureExtensions = { ".mat", ".bmp" }; + private static readonly string[] ModelExtensions = { ".t3d", ".e3d" }; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + + public MissingAssetScanner(IFileSystem files, IGamePaths paths) + { + _files = files; + _paths = paths; + } + + public List Scan(VehicleCatalog catalog) + { + var lines = new List(); + + foreach (var texture in catalog.Textures) + { + string directory = Path.Combine( + _paths.Dynamic, + texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/')); + + if (!string.IsNullOrEmpty(texture.Skinfile) && + !ExistsWithAnyExtension(Path.Combine(directory, texture.Skinfile), TextureExtensions)) + lines.Add($"# no file: {texture.Directory}{texture.Skinfile}"); + + if (!string.IsNullOrEmpty(texture.Model) && + !ExistsWithAnyExtension(Path.Combine(directory, texture.Model), ModelExtensions)) + lines.Add($"# no model: {texture.Directory}{texture.Model}"); + } + + return lines; + } + + private bool ExistsWithAnyExtension(string path, string[] extensions) + { + if (_files.FileExists(path)) + return true; + + foreach (string extension in extensions) + if (_files.FileExists(Path.ChangeExtension(path, extension))) + return true; + + return false; + } +} diff --git a/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs b/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs new file mode 100644 index 0000000..d3e0591 --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Infrastructure.Vehicles; + +/// +/// Reads one vehicle folder's textures.txt into a . +/// +/// +/// The format is line oriented and stateful: '$a' marks the rest of the file as +/// archived, '!=' sets the category letter, and '^n' opens a fixed set of n +/// vehicles. Everything else with an '=' is a livery. Unknown lines are skipped +/// rather than rejected, because the files are hand written. +/// +/// Pure text in, model out: the repository does the reading, so the format can +/// be tested from a string array. +/// +public sealed class TexturesTxtParser +{ + /// Category letter meaning "derive it from the thumbnail name". + private const string DeriveCategory = "*"; + + /// + /// Parses the file's lines. Returns null when the folder declares no liveries. + /// + /// + /// The folder path relative to dynamic/, with a trailing slash; it forms the + /// livery ids and is what the scenery format writes as the data folder. + /// + public VehicleEntry? Parse(string directory, IReadOnlyList lines) + { + var entry = new VehicleEntry { Uuid = "legacy:" + directory }; + + bool archived = false; + string categorySign = DeriveCategory; + int setSize = 0; + var pendingSet = new List(); + + foreach (string raw in lines) + { + string line = raw.Trim(); + if (line.Length == 0) + continue; + + char first = line[0]; + if (first is '#' or '@' or '*') + continue; + + if (line.StartsWith("$a", StringComparison.OrdinalIgnoreCase)) + { + archived = true; + continue; + } + + if (first == '^') + { + FlushSet(entry, pendingSet, setSize); + setSize = line.Length > 1 && char.IsDigit(line[1]) ? line[1] - '0' : 0; + continue; + } + + if (line.StartsWith("!=", StringComparison.Ordinal)) + { + categorySign = line.Length > 2 ? line[2].ToString() : DeriveCategory; + continue; + } + + if (line.IndexOf('=') < 0 || line.StartsWith("//", StringComparison.Ordinal)) + continue; + + var texture = ParseLivery(line, directory, categorySign, archived, entry); + if (texture is null) + continue; + + entry.Textures.Add(texture); + + if (setSize > 1) + { + pendingSet.Add(texture.Uuid!); + if (pendingSet.Count == setSize) + FlushSet(entry, pendingSet, setSize); + } + } + + FlushSet(entry, pendingSet, setSize); + return entry.Textures.Count > 0 ? entry : null; + } + + private static void FlushSet(VehicleEntry entry, List pending, int size) + { + if (pending.Count > 1) + entry.Sets.Add(new VehicleSet + { + Uuid = "legacy-set:" + pending[0], + Mode = "sequence", + Count = size > 0 ? size : pending.Count, + TextureRefs = new List(pending) + }); + + pending.Clear(); + } + + private static VehicleTexture? ParseLivery(string line, string directory, string categorySign, bool archived, + VehicleEntry entry) + { + string description = ""; + int comment = line.IndexOf("//", StringComparison.Ordinal); + if (comment >= 0) + { + description = line[(comment + 2)..].Trim(); + line = line[..comment].Trim(); + } + + string[] parts = line.Split('='); + if (parts.Length < 2) + return null; + + string skin = StripExtension(parts[0].Trim()); + if (skin.Length == 0) + return null; + + // Every "=" segment is "model, thumbnail[, per-texture thumbnail]"; the + // first is the livery proper and the rest are alternative models. + var specs = new List<(string Model, string Mini, string TextureMini)>(); + for (int i = 1; i < parts.Length; i++) + { + string[] fields = parts[i].Split(','); + if (fields.Length < 2) + continue; + specs.Add((fields[0].Trim(), fields[1].Trim(), fields.Length > 2 ? fields[2].Trim() : "")); + } + if (specs.Count == 0) + return null; + + var lead = specs[0]; + string groupId = $"legacy:{directory}:{lead.Mini}:{categorySign}:{(archived ? 1 : 0)}"; + + if (!entry.Groups.Exists(group => group.Id == groupId)) + entry.Groups.Add(new VehicleGroup + { + Id = groupId, + Category = categorySign, + Mini = lead.Mini, + Archived = archived + }); + + var texture = new VehicleTexture + { + Uuid = $"legacy:{directory}{skin}", + Directory = directory, + Skinfile = skin, + Wreck = skin.Contains("wreck", StringComparison.OrdinalIgnoreCase) || + skin.Contains("wrak", StringComparison.OrdinalIgnoreCase), + Model = lead.Model, + Group = groupId, + MiniRef = lead.Mini, + TextureMini = string.IsNullOrEmpty(lead.TextureMini) ? null : lead.TextureMini, + Meta = ParseDescription(description) + }; + + for (int i = 1; i < specs.Count; i++) + texture.Aliases.Add(new VehicleAlias + { + Model = specs[i].Model, + MiniRef = specs[i].Mini, + TextureMini = string.IsNullOrEmpty(specs[i].TextureMini) ? null : specs[i].TextureMini + }); + + return texture; + } + + /// + /// The trailing comment is a fixed, comma separated credit line. Underscores + /// stand in for spaces, a convention from the era the format comes from. + /// + private static VehicleMeta? ParseDescription(string description) + { + if (string.IsNullOrWhiteSpace(description)) + return null; + + string[] fields = description.Replace('_', ' ').Split(','); + string At(int index) => index < fields.Length ? fields[index].Trim() : ""; + + return new VehicleMeta + { + Raw = description, + Version = At(0), + Vehicle = At(1), + Operator = At(2), + Depot = At(3), + RevisionDate = At(4), + RevisionPlace = At(5), + TextureAuthor = At(6), + PhotoAuthor = At(7) + }; + } + + private static string StripExtension(string name) + { + int dot = name.LastIndexOf('.'); + return dot > 0 ? name[..dot] : name; + } +} diff --git a/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs b/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs new file mode 100644 index 0000000..6c959ab --- /dev/null +++ b/StarterNG/Infrastructure/Vehicles/TexturesTxtVehicleRepository.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Infrastructure.Vehicles; + +/// +/// Loads the catalogue from the dynamic/<maker>/<vehicle>/textures.txt +/// files an installation ships. +/// +public sealed class TexturesTxtVehicleRepository : IVehicleRepository +{ + private const string TexturesFileName = "textures.txt"; + + private readonly IFileSystem _files; + private readonly IGamePaths _paths; + private readonly IDiagnosticsLog _log; + private readonly TexturesTxtParser _parser; + private readonly Encoding _encoding; + + public TexturesTxtVehicleRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, + TexturesTxtParser parser) + { + _files = files; + _paths = paths; + _log = log; + _parser = parser; + _encoding = LegacyEncoding(); + } + + public int Load(VehicleCatalog catalog) + { + catalog.BeginLoad(); + + int liveries = 0; + foreach (string file in TexturesFiles()) + { + var entry = ReadEntry(file); + if (entry is null) + continue; + + liveries += entry.Textures.Count; + catalog.Ingest(entry); + } + + catalog.EndLoad(); + return liveries; + } + + private VehicleEntry? ReadEntry(string path) + { + try + { + // Read as text so the code page survives: the credit lines carry + // Polish characters that ReadAllLines' default encoding would mangle. + string[] lines = _files.ReadAllText(path, _encoding).Split('\n'); + return _parser.Parse(RelativeDirectory(path), lines); + } + catch (Exception ex) + { + _log.Log($"textures.txt {path}", ex); + return null; + } + } + + /// + /// Rolling stock sits exactly two levels below dynamic/: a maker folder and a + /// vehicle folder. + /// + private IEnumerable TexturesFiles() + { + foreach (string maker in _files.GetDirectories(_paths.Dynamic)) + foreach (string vehicle in _files.GetDirectories(maker)) + { + string path = Path.Combine(vehicle, TexturesFileName); + if (_files.FileExists(path)) + yield return path; + } + } + + /// + /// The folder as the scenery format spells it: relative to dynamic/, forward + /// slashes, trailing slash. + /// + private string RelativeDirectory(string texturesPath) + { + string directory = Path.GetDirectoryName(texturesPath) ?? ""; + string full = Path.GetFullPath(directory); + string root = Path.GetFullPath(_paths.Dynamic); + + string relative = full.StartsWith(root, StringComparison.OrdinalIgnoreCase) + ? full[root.Length..].TrimStart(Path.DirectorySeparatorChar, '/') + : directory; + + return relative.Replace('\\', '/').TrimEnd('/') + "/"; + } + + private static Encoding LegacyEncoding() + { + try + { + return Encoding.GetEncoding(1250); + } + catch (Exception) + { + return Encoding.Latin1; + } + } +} diff --git a/StarterNG/SplashWindow.axaml.cs b/StarterNG/SplashWindow.axaml.cs index 7e8273d..06fe34f 100644 --- a/StarterNG/SplashWindow.axaml.cs +++ b/StarterNG/SplashWindow.axaml.cs @@ -1,5 +1,6 @@ -using Avalonia.Controls; +using Avalonia.Controls; using StarterNG.Classes; +using StarterNG.Application; namespace StarterNG; diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs index 0146036..aa5738c 100644 --- a/StarterNG/Views/Depot.axaml.cs +++ b/StarterNG/Views/Depot.axaml.cs @@ -19,14 +19,16 @@ using Avalonia.VisualTree; using StarterNG.Classes; using StarterNG.Domain; using StarterNG.Domain.Sceneries; +using StarterNG.Domain.Vehicles; +using StarterNG.Application; namespace StarterNG.Views; public partial class Depot : UserControl { - private readonly VehicleDatabase _db = GameData.Instance.Vehicles; - private readonly List _sceneries = GameData.Instance.Sceneries; + private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles; + private readonly List _sceneries = AppServices.Current.Library.Sceneries; private readonly VehicleInfo _info; diff --git a/StarterNG/Views/Depot/ConsistDragging.cs b/StarterNG/Views/Depot/ConsistDragging.cs index 9be872b..7616721 100644 --- a/StarterNG/Views/Depot/ConsistDragging.cs +++ b/StarterNG/Views/Depot/ConsistDragging.cs @@ -9,6 +9,7 @@ using Avalonia.Media.Imaging; using StarterNG.Classes; using StarterNG.Domain; using StarterNG.Infrastructure; +using StarterNG.Domain.Vehicles; namespace StarterNG.Views; diff --git a/StarterNG/Views/Depot/MiniTextures.cs b/StarterNG/Views/Depot/MiniTextures.cs index c5cb0fd..86a6862 100644 --- a/StarterNG/Views/Depot/MiniTextures.cs +++ b/StarterNG/Views/Depot/MiniTextures.cs @@ -5,6 +5,7 @@ using Avalonia.Media; using Avalonia.Media.Imaging; using StarterNG.Classes; +using StarterNG.Application; namespace StarterNG.Views; @@ -28,7 +29,7 @@ public sealed class MiniTextures { if (string.IsNullOrEmpty(name)) return null; - if (strict && !VehicleDatabase.HasMini(name)) + if (strict && !AppServices.Current.MiniTextures.Has(name)) return null; string key = $"{(strict ? "!" : "")}{name}@{height}"; @@ -36,7 +37,7 @@ public sealed class MiniTextures return cached; Bitmap? bmp = null; - string? path = VehicleDatabase.MiniPath(name); + string? path = AppServices.Current.MiniTextures.PathFor(name); if (path != null) { try diff --git a/StarterNG/Views/Depot/VehicleBrowser.cs b/StarterNG/Views/Depot/VehicleBrowser.cs index 9c4d123..9b78381 100644 --- a/StarterNG/Views/Depot/VehicleBrowser.cs +++ b/StarterNG/Views/Depot/VehicleBrowser.cs @@ -15,6 +15,7 @@ using Avalonia.Threading; using StarterNG.Classes; using StarterNG.Domain; using StarterNG.Domain.Sceneries; +using StarterNG.Domain.Vehicles; namespace StarterNG.Views; @@ -28,7 +29,7 @@ public sealed class VehicleBrowser private readonly Image miniPreview; private readonly Button addVehicleButton; - private readonly VehicleDatabase _db; + private readonly VehicleCatalog _db; private readonly MiniTextures _minis; private readonly Action _openTextureBase; @@ -57,7 +58,7 @@ public sealed class VehicleBrowser public VehicleBrowser( ComboBox categoryCombo, ComboBox classCombo, ListBox vehicleListBox, TextBox searchBox, CheckBox hideArchivalCheck, Image miniPreview, Button addVehicleButton, - VehicleDatabase db, MiniTextures minis, Action openTextureBase) + VehicleCatalog db, MiniTextures minis, Action openTextureBase) { this.categoryCombo = categoryCombo; this.classCombo = classCombo; diff --git a/StarterNG/Views/Depot/VehicleCards.cs b/StarterNG/Views/Depot/VehicleCards.cs index 023d0f3..a4a84de 100644 --- a/StarterNG/Views/Depot/VehicleCards.cs +++ b/StarterNG/Views/Depot/VehicleCards.cs @@ -12,6 +12,7 @@ using Material.Icons.Avalonia; using StarterNG.Classes; using StarterNG.Domain; +using StarterNG.Domain.Vehicles; namespace StarterNG.Views; @@ -19,7 +20,7 @@ public sealed class VehicleCards { private readonly Panel consistStack; - private readonly VehicleDatabase _db; + private readonly VehicleCatalog _db; private readonly Consist _consist; private readonly Cargo _cargo; private readonly MiniTextures _minis; @@ -36,7 +37,7 @@ public sealed class VehicleCards public Dynamic? PressedCar { get; set; } - public VehicleCards(Panel consistStack, VehicleDatabase db, Consist consist, + public VehicleCards(Panel consistStack, VehicleCatalog db, Consist consist, Cargo cargo, MiniTextures minis, Cursor hand, Action redraw, Action refreshDetails, Action selectInBrowser, Action armDrag) diff --git a/StarterNG/Views/Depot/VehicleDetails.cs b/StarterNG/Views/Depot/VehicleDetails.cs index 5e911d6..01ec9c6 100644 --- a/StarterNG/Views/Depot/VehicleDetails.cs +++ b/StarterNG/Views/Depot/VehicleDetails.cs @@ -12,6 +12,7 @@ using Avalonia.Media; using StarterNG.Classes; using StarterNG.Controls; using StarterNG.Domain; +using StarterNG.Domain.Vehicles; namespace StarterNG.Views; @@ -26,7 +27,7 @@ public sealed class VehicleDetails private readonly StackPanel damagePanel; private readonly Button removeVehicleButton; - private readonly VehicleDatabase _db; + private readonly VehicleCatalog _db; private readonly VehicleInfo _info; private readonly Consist _consist; private readonly Cargo _cargo; @@ -41,7 +42,7 @@ public sealed class VehicleDetails Panel couplerActions, StackPanel brakesPanel, StackPanel loadsPanel, StackPanel damagePanel, Button removeVehicleButton, - VehicleDatabase db, VehicleInfo info, Consist consist, Cargo cargo, Cursor hand, + VehicleCatalog db, VehicleInfo info, Consist consist, Cargo cargo, Cursor hand, Action redraw, Action refreshChrome, Action showTextureInfo) { this.generalPanel = generalPanel; diff --git a/StarterNG/Views/RulesWindow.cs b/StarterNG/Views/RulesWindow.cs index 3357faf..8660cfe 100644 --- a/StarterNG/Views/RulesWindow.cs +++ b/StarterNG/Views/RulesWindow.cs @@ -9,6 +9,8 @@ using Avalonia.Media; using StarterNG.Classes; using StarterNG.Domain; using StarterNG.Infrastructure; +using StarterNG.Domain.Vehicles; +using StarterNG.Application; namespace StarterNG.Views; @@ -19,7 +21,7 @@ public sealed class RulesWindow : Window private readonly WrapPanel _members = new() { Orientation = Orientation.Horizontal }; private readonly ComboBox _category = new(); private readonly ComboBox _model = new(); - private readonly VehicleDatabase _db = GameData.Instance.Vehicles; + private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles; public RulesWindow() { diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs index 2a7536c..74ee29f 100644 --- a/StarterNG/Views/Scenarios.axaml.cs +++ b/StarterNG/Views/Scenarios.axaml.cs @@ -38,7 +38,7 @@ public partial class Scenarios : UserControl { InitializeComponent(); - Sceneries = GameData.Instance.Sceneries; + Sceneries = AppServices.Current.Library.Sceneries; _loadingFilters = true; archivalSwitch.IsChecked = !StarterNG.Classes.Settings.Instance.ShowArchivalSceneries; @@ -339,7 +339,7 @@ public partial class Scenarios : UserControl var lines = new List(); lines.AddRange(scenery.Faults); - var db = GameData.Instance.Vehicles; + var db = AppServices.Current.Library.Vehicles; foreach (var ts in scenery.Trainsets) { if (!ts.Parsed) @@ -423,8 +423,8 @@ public partial class Scenarios : UserControl try { string? selected = AppState.Instance.CurrentScenery?.Path; - await Task.Run(() => GameData.Instance.ReloadSceneries()); - Sceneries = GameData.Instance.Sceneries; + await Task.Run(() => AppServices.Current.Library.ReloadSceneries()); + Sceneries = AppServices.Current.Library.Sceneries; BuildSceneryTree(); if (!string.IsNullOrEmpty(selected)) { @@ -486,7 +486,7 @@ public partial class Scenarios : UserControl return cached; Bitmap? bmp = null; - string? path = VehicleDatabase.MiniPath(name); + string? path = AppServices.Current.MiniTextures.PathFor(name); if (path != null) { try @@ -888,12 +888,12 @@ public partial class Scenarios : UserControl UpdateTrainStats(trainset); }); - var db = GameData.Instance.Vehicles; + var db = AppServices.Current.Library.Vehicles; foreach (var train in trainset.Vehicles) { string miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile; - if (!VehicleDatabase.HasMini(miniName) && VehicleDatabase.HasMini(train.SkinFile)) + if (!AppServices.Current.MiniTextures.Has(miniName) && AppServices.Current.MiniTextures.Has(train.SkinFile)) miniName = train.SkinFile; int thumbH = StarterNG.Classes.Settings.Instance.LargeThumbnails ? 64 : 32; @@ -1193,12 +1193,12 @@ public partial class Scenarios : UserControl return; } - var db = GameData.Instance.Vehicles; + var db = AppServices.Current.Library.Vehicles; StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields( trainset, - car => Physics.For(car.DataFolder, db.TextureForSkin(car.SkinFile)?.Model) - ?? Physics.For(car.DataFolder, car.MmdFile) - ?? Physics.For(car.DataFolder, car.SkinFile), + car => AppServices.Current.Physics.For(car.DataFolder, db.TextureForSkin(car.SkinFile)?.Model) + ?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile) + ?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile), car => db.TextureForSkin(car.SkinFile)?.ResolvedCategory)); } diff --git a/StarterNG/Views/TextureBaseWindow.axaml.cs b/StarterNG/Views/TextureBaseWindow.axaml.cs index 93be3da..b081ded 100644 --- a/StarterNG/Views/TextureBaseWindow.axaml.cs +++ b/StarterNG/Views/TextureBaseWindow.axaml.cs @@ -8,12 +8,14 @@ using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Interactivity; using StarterNG.Classes; +using StarterNG.Domain.Vehicles; +using StarterNG.Application; namespace StarterNG.Views; public partial class TextureBaseWindow : Window { - private readonly VehicleDatabase _db = GameData.Instance.Vehicles; + private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles; private readonly List _all = new(); public VehicleTexture? Picked { get; private set; } @@ -193,7 +195,7 @@ internal sealed class TextureRow public string Directory { get; } public DateTime? RevisionDate { get; } - public TextureRow(VehicleTexture t, VehicleDatabase db) + public TextureRow(VehicleTexture t, VehicleCatalog db) { Texture = t; Skin = Path.GetFileNameWithoutExtension(t.Skinfile);