Move rolling stock and physics into the domain

VehicleDatabase becomes VehicleCatalog, Physics becomes VehiclePhysics, and the
textures.txt and .fiz parsers move to Infrastructure. GameLibrary replaces the
GameData singleton.
This commit is contained in:
maj00r
2026-09-04 21:03:08 +02:00
parent 789f0dd1c9
commit 0444070e12
45 changed files with 1829 additions and 877 deletions

View File

@@ -92,6 +92,17 @@ public sealed class InMemoryFileSystem : IFileSystem
.ToList();
}
public IReadOnlyList<string> 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<string> GetDirectories(string path)
{
string prefix = Normalize(path) + "/";

View File

@@ -0,0 +1,48 @@
using StarterNG.Application;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Infrastructure.Vehicles;
namespace StarterNG.Tests.Fakes;
/// <summary>
/// The production object graph wired over an in-memory installation: the same
/// composition as <c>AppServices</c>, with disk, clock and randomness replaced.
/// </summary>
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; }
/// <summary>Path of a file inside the fake installation.</summary>
public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray());
}

View File

@@ -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<LoadPhase>();
var progress = new Progress<LoadStatus>(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);
}
}

View File

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

View File

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

View File

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

View File

@@ -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)));
}
/// <summary>Thumbnail index that knows about exactly one name.</summary>
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;
}
/// <summary>Builds an entry through the real parser, for catalogue-level tests.</summary>
private static VehicleEntry ParsedEntry(string directory, params string[] lines) =>
new StarterNG.Infrastructure.Vehicles.TexturesTxtParser().Parse(directory, lines)!;
}

View File

@@ -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 () =>

View File

@@ -51,5 +51,11 @@ public interface IFileSystem
/// <summary>Top-level directories, empty when the directory does not exist.</summary>
IReadOnlyList<string> GetDirectories(string path);
/// <summary>
/// Files matching the pattern anywhere below the path. Empty when the
/// directory does not exist.
/// </summary>
IReadOnlyList<string> GetFilesRecursive(string path, string searchPattern);
DateTime GetLastWriteTimeUtc(string path);
}

View File

@@ -0,0 +1,19 @@
namespace StarterNG.Application.Abstractions;
/// <summary>
/// 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.
/// </summary>
public interface IMiniTextureIndex
{
/// <summary>Builds the index up front, off the UI thread.</summary>
void Preload();
bool Has(string? miniName);
/// <summary>Bitmap for the name, the fallback thumbnail, or null when neither exists.</summary>
string? PathFor(string? miniName);
/// <summary>The generic thumbnail shown for unknown stock.</summary>
string? FallbackPath { get; }
}

View File

@@ -0,0 +1,21 @@
using StarterNG.Domain.Vehicles;
namespace StarterNG.Application.Abstractions;
/// <summary>
/// Looks up the .fiz physics of a vehicle type by model name.
/// </summary>
public interface IPhysicsRepository
{
/// <summary>Indexes the installation's .fiz files up front, off the UI thread.</summary>
void Preload();
/// <summary>How many vehicle types were indexed; zero means a broken installation.</summary>
int IndexedCount { get; }
/// <summary>
/// Physics for a model name (with or without extension), or null when the
/// installation has no .fiz for it.
/// </summary>
VehiclePhysics? For(string? dataFolder, string? model);
}

View File

@@ -0,0 +1,17 @@
using StarterNG.Domain.Vehicles;
namespace StarterNG.Application.Abstractions;
/// <summary>
/// Reads the installation's rolling stock. The only route from the dynamic/
/// folder to a <see cref="VehicleCatalog"/>.
/// </summary>
public interface IVehicleRepository
{
/// <summary>
/// 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.
/// </summary>
int Load(VehicleCatalog catalog);
}

View File

@@ -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);
}
/// <summary>
@@ -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; }
/// <summary>Everything read out of the installation: rolling stock and scenarios.</summary>
public GameLibrary Library { get; }
/// <summary>
/// Exposed as the concrete service rather than <see cref="ILocalizedStrings"/>
/// because the XAML binds to it as a change-notifying source; code should take

View File

@@ -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;
/// <summary>Which part of the installation the loader is working through.</summary>
public enum LoadPhase
{
Vehicles,
Sceneries,
Done
}
/// <summary>How far the load has got, for the splash screen.</summary>
public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail);
/// <summary>
/// Everything the starter reads out of the installation once: the rolling stock
/// catalogue and the list of scenarios.
/// </summary>
/// <remarks>
/// Was the <c>GameData</c> 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.
/// </remarks>
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<Scenery> Sceneries { get; } = new();
public bool Loaded { get; private set; }
/// <summary>
/// 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.
/// </summary>
public void Load(IProgress<LoadStatus>? 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<SceneryLoadProgress>(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;
}
/// <summary>Re-reads the scenery folder, picking up files added since startup.</summary>
public void ReloadSceneries(IProgress<SceneryLoadProgress>? progress = null)
{
var reloaded = _sceneries.LoadAll(progress);
Sceneries.Clear();
Sceneries.AddRange(reloaded);
}
}

View File

@@ -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<Scenery> Sceneries { get; } = new();
public bool Loaded { get; private set; }
public void Load(IProgress<LoadStatus>? 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<SceneryLoadProgress>(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<SceneryLoadProgress>? progress = null)
{
Sceneries.Clear();
Sceneries.AddRange(AppServices.Current.Sceneries.LoadAll(progress));
}
}

View File

@@ -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 = "";
/// <summary>
/// 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.
/// </summary>
public bool IsDieselEngine =>
EngineType.Equals("DieselEngine", StringComparison.OrdinalIgnoreCase)
|| EngineType.Equals("DieselElectric", StringComparison.OrdinalIgnoreCase)
|| EngineType.Equals("DumbDE", StringComparison.OrdinalIgnoreCase);
private static readonly Dictionary<string, Physics?> Cache = new(StringComparer.OrdinalIgnoreCase);
private static Dictionary<string, string>? _fizIndex;
public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot);
public static int IndexedCount => _fizIndex?.Count ?? 0;
private static Dictionary<string, string> FizIndex(string dynamicRoot)
{
if (_fizIndex != null)
return _fizIndex;
var index = new Dictionary<string, string>(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<string> 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<string>();
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<string> 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();
}
}

View File

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

View File

@@ -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<string> EnumerateFiles(string dynamicRoot = "dynamic")
{
var files = new List<string>();
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<string>();
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<string> 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<string>(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('/') + "/";
}
}

View File

@@ -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<VehicleGroup> Groups { get; set; } = new();
public List<VehicleTexture> Textures { get; set; } = new();
public List<VehicleSet> 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<VehicleAlias> 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<string> Extra { get; set; } = new();
}
public class VehicleDatabase
{
public List<VehicleTexture> Textures { get; } = new();
public Dictionary<string, VehicleGroup> GroupsById { get; } = new();
public List<VehicleSet> Sets { get; } = new();
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new();
public Dictionary<string, VehicleTexture> TextureByUuid { get; } = new();
public Dictionary<string, VehicleTexture> 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<VehicleTexture>? 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<VehicleTexture>();
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<string> CollectMissingAssetLines()
{
var lines = new List<string>();
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<string, string>? _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<string, string> BuildMiniIndex(string miniDir)
{
var index = new Dictionary<string, string>();
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<string> TextureRefs { get; set; } = new();
}

View File

@@ -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<ConsistItem>
{
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<ConsistItem> _items = new();
private int _nameCounter;
public Consist(VehicleDatabase db, VehicleInfo info)
public Consist(VehicleCatalog db, VehicleInfo info)
{
_db = db;
_info = info;

View File

@@ -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<string[]> _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;

View File

@@ -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<Dynamic, Physics?> physicsFor,
Trainset? trainset, Func<Dynamic, VehiclePhysics?> physicsFor,
Func<Dynamic, string?>? categoryOf = null,
Func<string?, int>? loadWeightKg = null)
{
@@ -120,7 +122,7 @@ public static class TrainsetDisplay
return fields;
}
public static string FormatStats(Trainset? trainset, Func<Dynamic, Physics?> physicsFor,
public static string FormatStats(Trainset? trainset, Func<Dynamic, VehiclePhysics?> physicsFor,
string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel,
Func<Dynamic, string?>? categoryOf = null,
Func<string?, int>? loadWeightKg = null)
@@ -149,7 +151,7 @@ public static class TrainsetDisplay
private static (double lengthM, double massKg, double loadKg) RecalcParams(
Trainset trainset,
Func<Dynamic, Physics?> physicsFor,
Func<Dynamic, VehiclePhysics?> physicsFor,
Func<Dynamic, string?>? categoryOf,
Func<string?, int>? 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)
{

View File

@@ -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<Dynamic, Physics?> physicsFor, bool leftCoupler = true)
public static int GetMaxCoupler(Dynamic v, Func<Dynamic, VehiclePhysics?> 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<Dynamic, Physics?> physicsFor, bool leftCoupler = true)
public static string GetControlType(Dynamic v, Func<Dynamic, VehiclePhysics?> 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<Dynamic> vehicles, int leftVehicle,
Func<Dynamic, Physics?> physicsFor)
Func<Dynamic, VehiclePhysics?> 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<Dynamic, Physics?> physicsFor, Func<Dynamic, string?> categoryOf)
Func<Dynamic, VehiclePhysics?> physicsFor, Func<Dynamic, string?> 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<Dynamic, Physics?> physicsFor, Func<Dynamic, string?> categoryOf,
Func<Dynamic, VehiclePhysics?> physicsFor, Func<Dynamic, string?> categoryOf,
Func<string?, int> loadWeight)
{
double length = 0, mass = 0, loadMass = 0;
@@ -138,7 +139,7 @@ public static class UData
}
public static void RandomLoad(IEnumerable<Dynamic> vehicles,
Func<Dynamic, Physics?> physicsFor, Random rng)
Func<Dynamic, VehiclePhysics?> physicsFor, Random rng)
{
foreach (var v in vehicles)
{
@@ -157,7 +158,7 @@ public static class UData
}
public static void ReverseMultiple(IList<Dynamic> vehicles, int position,
Func<Dynamic, string?> setIdOf, Func<Dynamic, Physics?> physicsFor)
Func<Dynamic, string?> setIdOf, Func<Dynamic, VehiclePhysics?> physicsFor)
{
var indexes = GetMultiple(vehicles, position, setIdOf);

View File

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

View File

@@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using StarterNG.Application.Abstractions;
namespace StarterNG.Domain.Vehicles;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Was <c>VehicleCatalog</c>, 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 <see cref="Ingest"/>.
/// </remarks>
public sealed class VehicleCatalog
{
private readonly IMiniTextureIndex _minis;
public VehicleCatalog(IMiniTextureIndex minis)
{
_minis = minis;
}
/// <summary>Every livery worth offering (wrecks excluded).</summary>
public List<VehicleTexture> Textures { get; } = new();
public Dictionary<string, VehicleGroup> GroupsById { get; } = new();
public List<VehicleSet> Sets { get; } = new();
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new();
public Dictionary<string, VehicleTexture> TextureByUuid { get; } = new();
/// <summary>Liveries by skin file name without extension, lower cased.</summary>
public Dictionary<string, VehicleTexture> TextureBySkin { get; } = new();
public void BeginLoad()
{
Textures.Clear();
GroupsById.Clear();
Sets.Clear();
SetByTextureUuid.Clear();
TextureByUuid.Clear();
TextureBySkin.Clear();
}
/// <summary>Adds one vehicle folder's liveries to the catalogue.</summary>
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);
}
/// <summary>Builds the indexes that depend on the catalogue being complete.</summary>
public void EndLoad()
{
BuildSetIndex();
ResolveGroupInheritance();
}
/// <summary>The vehicles of the fixed set this livery leads or belongs to.</summary>
public List<VehicleTexture>? 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<VehicleTexture>();
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// The thumbnail to draw for a livery: its own if we have that bitmap,
/// otherwise the one its group declares.
/// </summary>
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;
}
/// <summary>Heading shown above a group in the depot browser.</summary>
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;
}
}
/// <summary>
/// Copies thumbnail, category and archived status down from each group onto
/// its liveries, so the browser can sort and filter without walking back up.
/// </summary>
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;
}
}
}

View File

@@ -0,0 +1,54 @@
using System;
namespace StarterNG.Domain.Vehicles;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Was <c>Classes.Physics</c>, which also owned the .fiz index, the parser and a
/// static cache. Those are now <c>Infrastructure.Vehicles.FizPhysicsRepository</c>;
/// this is the value the rest of the application reasons about.
/// </remarks>
public sealed class VehiclePhysics
{
/// <summary>Tare mass in kilograms.</summary>
public double Mass { get; set; }
/// <summary>Design speed in km/h.</summary>
public double VMax { get; set; }
/// <summary>Length over buffers in metres.</summary>
public double Length { get; set; }
public string LoadAccepted { get; set; } = "";
public string LoadQ { get; set; } = "";
public int MaxLoad { get; set; }
/// <summary>Coupling capabilities of the front end.</summary>
public int AllowedFlagA { get; set; } = 3;
/// <summary>Coupling capabilities of the rear end.</summary>
public int AllowedFlagB { get; set; } = 3;
public string ControlTypeA { get; set; } = "";
public string ControlTypeB { get; set; } = "";
public string EngineType { get; set; } = "";
/// <summary>
/// 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.
/// </summary>
public bool IsDieselEngine =>
EngineType.Equals("DieselEngine", StringComparison.OrdinalIgnoreCase)
|| EngineType.Equals("DieselElectric", StringComparison.OrdinalIgnoreCase)
|| EngineType.Equals("DumbDE", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,97 @@
using System.Collections.Generic;
namespace StarterNG.Domain.Vehicles;
/// <summary>
/// One vehicle folder's contribution to the catalogue: the liveries it defines,
/// the groups they belong to and any fixed sets they form.
/// </summary>
public sealed class VehicleEntry
{
public string? Uuid { get; set; }
public List<VehicleGroup> Groups { get; } = new();
public List<VehicleTexture> Textures { get; } = new();
public List<VehicleSet> Sets { get; } = new();
}
/// <summary>
/// A family of liveries sharing a thumbnail and a category letter, as declared
/// by one textures.txt block.
/// </summary>
public sealed class VehicleGroup
{
public string Id { get; set; } = "";
public string? Category { get; set; }
public string? Mini { get; set; }
/// <summary>Withdrawn stock, hidden from the depot unless asked for.</summary>
public bool Archived { get; set; }
}
/// <summary>A single livery: one skin file on one model.</summary>
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; }
/// <summary>Wreck liveries exist in the files but are not offered in the depot.</summary>
public bool Wreck { get; set; }
public List<VehicleAlias> Aliases { get; } = new();
public VehicleMeta? Meta { get; set; }
public string FullPath => Directory + Skinfile;
/// <summary>Thumbnail name after group inheritance; filled by the catalogue.</summary>
public string ResolvedClass { get; internal set; } = "";
/// <summary>Category letter after group inheritance; filled by the catalogue.</summary>
public string? ResolvedCategory { get; internal set; }
/// <summary>Whether the owning group is archived; filled by the catalogue.</summary>
public bool ResolvedArchived { get; internal set; }
}
/// <summary>An alternative model this same skin can be worn by.</summary>
public sealed class VehicleAlias
{
public string? Model { get; set; }
public string? Group { get; set; }
public string? MiniRef { get; set; }
public string? TextureMini { get; set; }
}
/// <summary>
/// The comma separated description a livery carries after "//": who made it, for
/// which operator and depot, and when it was last revised.
/// </summary>
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<string> Extra { get; } = new();
}
/// <summary>
/// Vehicles that belong together and are added to a consist as a unit, such as a
/// multiple unit or a fixed rake.
/// </summary>
public sealed class VehicleSet
{
public string? Uuid { get; set; }
public string? Mode { get; set; }
public int Count { get; set; }
public List<string> TextureRefs { get; set; } = new();
}

View File

@@ -43,6 +43,11 @@ public sealed class PhysicalFileSystem : IFileSystem
public IReadOnlyList<string> GetFiles(string path, string searchPattern = "*") =>
Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty<string>();
public IReadOnlyList<string> GetFilesRecursive(string path, string searchPattern) =>
Directory.Exists(path)
? Directory.GetFiles(path, searchPattern, SearchOption.AllDirectories)
: Array.Empty<string>();
public IReadOnlyList<string> GetDirectories(string path) =>
Directory.Exists(path) ? Directory.GetDirectories(path) : Array.Empty<string>();

View File

@@ -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();
}

View File

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

View File

@@ -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;
/// <summary>
/// Reads vehicle physics from the .fiz files under dynamic/.
/// </summary>
/// <remarks>
/// The format is a flat token stream of "section" headings and "key value"
/// pairs, with an <c>include file p1 p2 end</c> directive whose parameters are
/// substituted for <c>(1)</c>-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.
/// </remarks>
public sealed class FizPhysicsRepository : IPhysicsRepository
{
/// <summary>Guard against an include cycle in hand written files.</summary>
private const int MaxIncludeDepth = 6;
/// <summary>Sentinel meaning "the rear coupler was never declared".</summary>
private const int UndeclaredCoupler = -1;
private static readonly HashSet<string> 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<string, VehiclePhysics?> _cache =
new(StringComparer.OrdinalIgnoreCase);
private readonly object _indexGate = new();
private Dictionary<string, string>? _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 "<model>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<string, string> Index()
{
lock (_indexGate)
{
if (_index is not null)
return _index;
var index = new Dictionary<string, string>(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<string>();
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);
/// <summary>
/// A negative flag means the coupling is present but disabled; the simulator
/// encodes that as its absolute value plus 128.
/// </summary>
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;
/// <summary>Substitutes an include's "(n)" placeholder with its n-th parameter.</summary>
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<string> 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;
}
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.IO;
using StarterNG.Application.Abstractions;
namespace StarterNG.Infrastructure.Vehicles;
/// <summary>
/// <see cref="IMiniTextureIndex"/> over the installation's textures/mini folder.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class MiniTextureIndex : IMiniTextureIndex
{
/// <summary>Thumbnail shown when a vehicle names one we do not have.</summary>
private const string FallbackName = "other";
private readonly IFileSystem _files;
private readonly IGamePaths _paths;
private readonly object _gate = new();
private Dictionary<string, string>? _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<string, string> Index()
{
lock (_gate)
{
if (_index is not null)
return _index;
var index = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (string file in _files.GetFiles(_paths.MiniTextures, "*.bmp"))
index[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file;
_index = index;
return index;
}
}
}

View File

@@ -0,0 +1,63 @@
using System.Collections.Generic;
using System.IO;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Vehicles;
namespace StarterNG.Infrastructure.Vehicles;
/// <summary>
/// Reports liveries whose skin or model file is not actually present, for the
/// optional "log missing vehicle files" diagnostic.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<string> Scan(VehicleCatalog catalog)
{
var lines = new List<string>();
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;
}
}

View File

@@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using StarterNG.Domain.Vehicles;
namespace StarterNG.Infrastructure.Vehicles;
/// <summary>
/// Reads one vehicle folder's textures.txt into a <see cref="VehicleEntry"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class TexturesTxtParser
{
/// <summary>Category letter meaning "derive it from the thumbnail name".</summary>
private const string DeriveCategory = "*";
/// <summary>
/// Parses the file's lines. Returns null when the folder declares no liveries.
/// </summary>
/// <param name="directory">
/// 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.
/// </param>
public VehicleEntry? Parse(string directory, IReadOnlyList<string> lines)
{
var entry = new VehicleEntry { Uuid = "legacy:" + directory };
bool archived = false;
string categorySign = DeriveCategory;
int setSize = 0;
var pendingSet = new List<string>();
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<string> 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<string>(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;
}
/// <summary>
/// The trailing comment is a fixed, comma separated credit line. Underscores
/// stand in for spaces, a convention from the era the format comes from.
/// </summary>
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;
}
}

View File

@@ -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;
/// <summary>
/// Loads the catalogue from the dynamic/&lt;maker&gt;/&lt;vehicle&gt;/textures.txt
/// files an installation ships.
/// </summary>
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;
}
}
/// <summary>
/// Rolling stock sits exactly two levels below dynamic/: a maker folder and a
/// vehicle folder.
/// </summary>
private IEnumerable<string> 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;
}
}
/// <summary>
/// The folder as the scenery format spells it: relative to dynamic/, forward
/// slashes, trailing slash.
/// </summary>
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;
}
}
}

View File

@@ -1,5 +1,6 @@
using Avalonia.Controls;
using Avalonia.Controls;
using StarterNG.Classes;
using StarterNG.Application;
namespace StarterNG;

View File

@@ -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<Scenery> _sceneries = GameData.Instance.Sceneries;
private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles;
private readonly List<Scenery> _sceneries = AppServices.Current.Library.Sceneries;
private readonly VehicleInfo _info;

View File

@@ -9,6 +9,7 @@ using Avalonia.Media.Imaging;
using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Infrastructure;
using StarterNG.Domain.Vehicles;
namespace StarterNG.Views;

View File

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

View File

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

View File

@@ -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<Dynamic> selectInBrowser,
Action<int, PointerPressedEventArgs> armDrag)

View File

@@ -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<VehicleTexture, StackPanel> showTextureInfo)
{
this.generalPanel = generalPanel;

View File

@@ -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()
{

View File

@@ -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<string>();
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));
}

View File

@@ -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<TextureRow> _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);