Compare commits

...

18 Commits

Author SHA1 Message Date
maj00r
dee54a34bb feat: check the terrain files a scenery's terrain directive names
eu07 reads static terrain from files named by "terrain ... endterrain":
text (.txtf) or its binary version (.btf). The scenery check now tells a
terrain file of which neither is there (MissingTerrainFile).

Such a file also takes the place of the scenery's .sbt, so the terrain
includes after it are read after all and a missing one is told again. When
the .sbt was already used before the directive - a terrain include or
terrain geometry passed over, or FirstInit - the simulator passes the file
over instead, which is told too (TerrainFileBehindSbt). The parser takes
the lookup of files and whether there is an .sbt as one SceneryFileLookup.
2026-11-13 17:46:27 +01:00
maj00r
7cd3ef8f1a test: the in-memory disk has the root of an absolute Linux path 2026-11-13 17:44:26 +01:00
maj00r
1b41ef752a chore: version 1.6
Data names are now found whatever their letter case on Linux.
2026-11-13 16:23:47 +01:00
maj00r
42f702a096 fix: an include in other letter case is no fault, as eu07 opens it
eu07 now finds files whatever the case of their names on Linux too
(MaSzyna-EU07/maszyna#362), so the starter no longer folds an include or a
scenery's .sbt to lower case to judge whether the simulator opens it: it
looks them up through CaseInsensitivePaths. The IncludeLetterCase fault and
its texts go, being a false alarm now, and the parser takes a plain yes or
no for each include.
2026-11-13 16:23:47 +01:00
maj00r
7f57160aae fix: open a livery's folder whatever the case of its name
"Open texture folder" finds the folder under dynamic, or from the game
folder, through CaseInsensitivePaths, so on Linux it opens the folder
when textures.txt names it in other letter case.
2026-11-13 16:23:47 +01:00
maj00r
2efae83b57 fix: find the loading screen logo whatever the case of its name
The textures/logo folder and a trainset's or scenery's logo in it are
looked up through CaseInsensitivePaths, with extensions in any case, so
on Linux the chosen logo is shown instead of a random one or none. The
lookup takes the file system, so it is tested.
2026-11-13 16:23:47 +01:00
maj00r
34f2ce99e8 fix: list files whatever the case of their extensions on Linux
PhysicalFileSystem matches a search pattern case-insensitively, as on
Windows, so X.SCN, .BMP minis and .FIZ files of data made on Windows are
listed on Linux as well.
2026-11-13 16:23:47 +01:00
maj00r
52408a9931 fix: follow .fiz includes and check vehicle files whatever their case
An include in a .fiz is looked up in the vehicle's folder through
CaseInsensitivePaths, so its physics are read on Linux when the include
names the file in other letter case. The missing-asset scan finds skins and
models the same way, with extensions in any case, instead of reporting
files that are there.
2026-11-13 16:23:47 +01:00
maj00r
290b753f40 fix: open a scenery's attachments whatever the case of their names
A //$f attachment is looked up from the game folder through the new
AttachmentLocator, so on Linux its button is enabled and opens the file
when the scenery names it in other letter case or with backslashes.
2026-11-13 16:23:47 +01:00
maj00r
6279228a7d fix: find a scenery's i18n tables whatever the case of their names
The i18n folder and its <scenery>_<language>.json tables are looked up
through CaseInsensitivePaths, so on Linux a scenery's texts are translated
when the table is named in other letter case than the scenery file.
2026-11-13 16:23:47 +01:00
maj00r
d4975796a8 fix: find a scenery's image and timetables whatever the case of their names
The //$i image and a trainset's timetable are looked up through
CaseInsensitivePaths, so on Linux they show when the scenery names them in
other letter case than the disk has. A timetable written with backslashes
is found there too, and with it the train number of a Hasler tape.
2026-11-13 16:23:47 +01:00
maj00r
48af11b4b2 refactor: share the case-insensitive lookup of data files
The scenery repository's include lookup becomes CaseInsensitivePaths, which
takes any folder over IFileSystem and gives a name as it is on disk. Of
folders named alike in other case it now tries each, not only the first.
A name is joined to the folder as the simulator joins it, a leading slash
counting for nothing. IFileSystem tells whether the disk ignores case; there,
as on Windows, a name missed as written is not looked for any further, so
no folder is listed.
2026-11-13 16:23:47 +01:00
maj00r
744720d922 Merge pull request #44 from MaSzyna-EU07/fix/dotted-model-names
fix: a dot in a model name is part of the name, not an extension
2026-11-12 21:33:51 +01:00
maj00r
b5ba67430f fix: a dot in a model name is part of the name, not an extension
The 9-107 and 9-110 coal wagons (9-107.13.fiz and the like) found no physics, so no load could be set on them.
2026-11-12 20:57:36 +01:00
maj00r
c261d4b0ab Merge pull request #43 from MaSzyna-EU07/fix/version-no-suffix
fix: the version is the number alone, with no public beta after it
2026-11-12 19:41:31 +01:00
maj00r
3aa6a256a5 fix: the version is the number alone, with no public beta after it 2026-11-12 19:38:11 +01:00
maj00r
95c510b3ac Merge pull request #41 from MaSzyna-EU07/fix/version-in-title
The version in the window's title
2026-11-12 17:44:17 +01:00
maj00r
da5a1efea1 fix: the version in the window's title, where it has room at any width 2026-11-11 17:53:01 +01:00
37 changed files with 815 additions and 175 deletions

View File

@@ -0,0 +1,119 @@
using StarterNG.Infrastructure;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class CaseInsensitivePathsTests
{
private static InMemoryFileSystem Disk() => new InMemoryFileSystem(caseSensitive: true)
.WithFile("game/scenery/Braniewo/Base.ctr", "")
.WithFile("game/scenery/Braniewo/rozklady/TGS3657.txt", "")
.WithDirectory("game/textures/mini");
[Theory]
[InlineData("braniewo/base.ctr", "Braniewo/Base.ctr")]
[InlineData(@"BRANIEWO\Rozklady\tgs3657.TXT", "Braniewo/rozklady/TGS3657.txt")]
[InlineData("Braniewo/Base.ctr", "Braniewo/Base.ctr")]
[InlineData("braniewo//base.ctr", "Braniewo/Base.ctr")]
[InlineData("/braniewo/base.ctr", "Braniewo/Base.ctr")]
[InlineData(@"\Braniewo\Base.ctr", "Braniewo/Base.ctr")]
public void A_name_in_any_case_is_given_as_it_is_on_disk(string name, string onDisk) =>
Assert.Equal(onDisk, new CaseInsensitivePaths(Disk()).Resolve("game/scenery", name));
[Theory]
[InlineData("braniewo/brak.ctr")]
[InlineData("brak/base.ctr")]
[InlineData("")]
public void A_name_nothing_answers_to_is_not_found(string name) =>
Assert.Null(new CaseInsensitivePaths(Disk()).Resolve("game/scenery", name));
[Fact]
public void A_folder_is_found_only_when_one_is_asked_for()
{
var names = new CaseInsensitivePaths(Disk());
Assert.Equal("Braniewo/rozklady", names.Resolve("game/scenery", "braniewo/ROZKLADY", directory: true));
Assert.Null(names.Resolve("game/scenery", "braniewo/ROZKLADY"));
Assert.Null(names.Resolve("game/scenery", "braniewo/base.ctr", directory: true));
}
[Fact]
public void The_name_written_as_on_disk_wins_over_one_in_other_case()
{
var files = Disk().WithFile("game/scenery/braniewo/base.ctr", "");
var names = new CaseInsensitivePaths(files);
Assert.Equal("braniewo/base.ctr", names.Resolve("game/scenery", "braniewo/base.ctr"));
Assert.Equal("Braniewo/Base.ctr", names.Resolve("game/scenery", "Braniewo/base.CTR"));
}
[Fact]
public void Of_folders_named_alike_the_one_holding_the_file_is_taken()
{
var files = Disk().WithFile("game/scenery/braniewo/tory.scm", "");
var names = new CaseInsensitivePaths(files);
Assert.Equal("Braniewo/Base.ctr", names.Resolve("game/scenery", "braniewo/base.ctr"));
Assert.Equal("braniewo/tory.scm", names.Resolve("game/scenery", "Braniewo/Tory.scm"));
}
[Fact]
public void The_full_path_joins_the_folder_and_the_name_on_disk() =>
Assert.Equal(Path.Combine("game/scenery", "Braniewo", "Base.ctr"),
new CaseInsensitivePaths(Disk()).Find("game/scenery", "BRANIEWO/BASE.CTR"));
[Fact]
public void A_full_path_is_found_from_its_own_root()
{
string scenery = Path.GetFullPath(Path.Combine("game", "scenery"));
var files = new InMemoryFileSystem(caseSensitive: true)
.WithFile(Path.Combine(scenery, "Braniewo", "Base.ctr"), "");
Assert.Equal(Path.Combine(scenery, "Braniewo", "Base.ctr"),
new CaseInsensitivePaths(files).Find("elsewhere", Path.Combine(scenery, "BRANIEWO", "base.CTR")));
}
[Fact]
public void A_disk_that_ignores_case_gives_the_name_back_as_written()
{
var files = new InMemoryFileSystem().WithFile("game/scenery/Braniewo/Base.ctr", "");
var names = new CaseInsensitivePaths(files);
Assert.Equal("BRANIEWO/base.CTR", names.Resolve("game/scenery", "BRANIEWO/base.CTR"));
Assert.Null(names.Resolve("game/scenery", "braniewo/brak.ctr"));
}
[Fact]
public void Kept_listings_hold_until_forgotten()
{
var files = Disk();
var names = new CaseInsensitivePaths(files, keepListings: true);
Assert.Null(names.Resolve("game/scenery", "braniewo/nowy.scm"));
files.WithFile("game/scenery/Braniewo/Nowy.scm", "");
Assert.Null(names.Resolve("game/scenery", "braniewo/nowy.scm"));
names.Forget();
Assert.Equal("Braniewo/Nowy.scm", names.Resolve("game/scenery", "braniewo/nowy.scm"));
}
[Fact]
public void Listings_not_kept_see_a_file_added_since()
{
var files = Disk();
var names = new CaseInsensitivePaths(files);
Assert.Null(names.Resolve("game/scenery", "braniewo/nowy.scm"));
files.WithFile("game/scenery/Braniewo/Nowy.scm", "");
Assert.Equal("Braniewo/Nowy.scm", names.Resolve("game/scenery", "braniewo/nowy.scm"));
}
[Fact]
public void A_folder_that_cannot_be_listed_holds_nothing_to_find()
{
var files = Disk().DenyListing("game/scenery");
Assert.Null(new CaseInsensitivePaths(files).Resolve("game/scenery", "braniewo/base.ctr"));
}
}

View File

@@ -97,6 +97,8 @@ public sealed class InMemoryFileSystem : IFileSystem
return this;
}
public bool IgnoresCase => _comparison == StringComparison.OrdinalIgnoreCase;
public bool FileExists(string path) => _files.ContainsKey(Normalize(path));
/// <summary>A link counts as the directory it points at, as the disk has it.</summary>
@@ -273,6 +275,9 @@ public sealed class InMemoryFileSystem : IFileSystem
return;
slash = key.LastIndexOf('/');
}
// An absolute Unix path's parent is the root, "/", which normalises to "".
if (slash == 0)
_directories.Add("");
}
private static string Normalize(string path) =>

View File

@@ -0,0 +1,28 @@
using StarterNG.Infrastructure;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class LoadingScreenLogoTests
{
private static InMemoryFileSystem Logos() => new InMemoryFileSystem(caseSensitive: true)
.WithFile("game/Textures/Logo/EP09_Logo.PNG", "png")
.WithFile("game/Textures/Logo/Braniewo.JPG", "jpg")
.WithFile("game/Textures/Logo/Logo1.BMP", "bmp")
.WithFile("game/Textures/Logo/notes.txt", "");
[Theory]
[InlineData("ep09_logo", null, "EP09_Logo.PNG")]
[InlineData("ep09_logo.bmp", "braniewo", "EP09_Logo.PNG")]
[InlineData(null, "braniewo", "Braniewo.JPG")]
[InlineData("brak", "brak", "Logo1.BMP")]
public void The_logo_is_found_whatever_the_case_of_its_name_and_extension(string? logo, string? scenery,
string onDisk) =>
Assert.Equal(Path.GetFullPath(Path.Combine("game", "Textures", "Logo", onDisk)),
Path.GetFullPath(LoadingScreen.FindSource(Logos(), "game", logo, scenery, new Random(1))!));
[Fact]
public void With_no_logo_folder_there_is_no_logo() =>
Assert.Null(LoadingScreen.FindSource(new InMemoryFileSystem(caseSensitive: true), "game", "x", "y",
new Random(1)));
}

View File

@@ -21,6 +21,20 @@ public sealed class PhysicalFileSystemTests : IDisposable
Assert.Equal(new[] { path }, Directory.GetFiles(_root));
}
[Fact]
public void A_pattern_lists_names_in_any_letter_case()
{
Directory.CreateDirectory(Path.Combine(_root, "PKP", "EP09"));
foreach (string name in new[] { "Braniewo.SCN", "kato.scn", "notes.txt", Path.Combine("PKP", "EP09", "EP09.FIZ"),
Path.Combine("PKP", "EP09", "ep09dumb.fiz") })
File.WriteAllText(Path.Combine(_root, name), "");
Assert.Equal(new[] { "Braniewo.SCN", "kato.scn" },
_files.GetFiles(_root, "*.scn").Select(Path.GetFileName).Order(StringComparer.Ordinal));
Assert.Equal(new[] { "EP09.FIZ", "ep09dumb.fiz" },
_files.GetFilesRecursive(_root, "*.fiz").Select(Path.GetFileName).Order(StringComparer.Ordinal));
}
[Fact]
public void An_atomic_write_that_cannot_finish_leaves_what_stood_there()
{

View File

@@ -65,6 +65,32 @@ public class PhysicsAndAssetsTests
Assert.Equal(125, physics.VMax);
}
[Fact]
public void An_include_named_in_other_case_is_followed()
{
var installation = new TestInstallation(new InMemoryFileSystem(caseSensitive: true)
.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_dot_in_the_model_name_is_part_of_the_name()
{
// As in the game: cd/9-107_v1 holds 9-107.13.fiz, 9-107.6.fiz and more.
var installation = new TestInstallation(new InMemoryFileSystem()
.WithFile(Fiz("cd", "9-107_v1", "9-107.13"), "Load: LoadAccepted=coal MaxLoad=58")
.WithFile(Fiz("cd", "9-107_v1", "9-107.6"), "Load: LoadAccepted=coal MaxLoad=55"));
Assert.Equal(58, installation.Physics.For("cd/9-107_v1/", "9-107.13")!.MaxLoad);
Assert.Equal(55, installation.Physics.For("cd/9-107_v1/", "9-107.6")!.MaxLoad);
Assert.Equal(58, installation.Physics.For("cd/9-107_v1/", "9-107.13.e3d")!.MaxLoad);
}
[Fact]
public void A_dumb_variant_stands_in_when_the_model_has_no_fiz_of_its_own()
{
@@ -189,4 +215,31 @@ public class PhysicsAndAssetsTests
Assert.Empty(installation.MissingAssets.Scan(installation.Library.Vehicles));
}
[Fact]
public void A_livery_with_no_folder_is_looked_for_in_dynamic_itself()
{
var installation = new TestInstallation(new InMemoryFileSystem(caseSensitive: true)
.WithFile(TestInstallation.At("dynamic", "EP09.bmp"), "bitmap")
.WithFile(TestInstallation.At("dynamic", "ep09.e3d"), "model"));
var catalog = new StarterNG.Domain.Vehicles.VehicleCatalog(installation.MiniTextures);
catalog.Textures.Add(new StarterNG.Domain.Vehicles.VehicleTexture { Directory = "", Skinfile = "ep09", Model = "EP09" });
Assert.Empty(installation.MissingAssets.Scan(catalog));
}
[Fact]
public void A_skin_and_model_on_disk_in_other_case_are_not_reported()
{
var files = new InMemoryFileSystem(caseSensitive: true)
.WithFile(TestInstallation.At("dynamic", "PKP", "EP09", "textures.txt"),
"EP09-014.mat=ep09.e3d,ep09")
.WithFile(TestInstallation.At("dynamic", "PKP", "EP09", "ep09-014.BMP"), "bitmap")
.WithFile(TestInstallation.At("dynamic", "PKP", "EP09", "EP09.E3D"), "model");
var installation = new TestInstallation(files);
installation.Vehicles.Load(installation.Library.Vehicles);
Assert.NotEmpty(installation.Library.Vehicles.Textures);
Assert.Empty(installation.MissingAssets.Scan(installation.Library.Vehicles));
}
}

View File

@@ -182,6 +182,20 @@ public class TimetableLocatorTests
Assert.NotNull(new TimetableLocator(files).Resolve(scenery, "r1.txt"));
}
[Theory]
[InlineData(@"rozklady\tgs3657")]
[InlineData("ROZKLADY/TGS3657.TXT")]
public void A_timetable_named_in_other_case_or_with_backslashes_is_found_as_on_disk(string name)
{
var files = new InMemoryFileSystem(caseSensitive: true)
.WithFile(TestInstallation.At("scenery", "Braniewo.scn"), "//$n Braniewo")
.WithFile(TestInstallation.At("scenery", "Rozklady", "TGS3657.txt"), "rozkład");
var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll());
Assert.Equal(TestInstallation.At("scenery", "Rozklady", "TGS3657.txt"),
new TimetableLocator(files).Resolve(scenery, name));
}
[Fact]
public void The_formats_none_and_a_missing_file_both_resolve_to_nothing()
{
@@ -195,3 +209,26 @@ public class TimetableLocatorTests
Assert.Null(locator.Resolve(scenery, null));
}
}
public class AttachmentLocatorTests
{
[Theory]
[InlineData(@"scenery\Braniewo\opis.PDF")]
[InlineData("SCENERY/braniewo/Opis.pdf")]
public void An_attachment_named_in_other_case_is_found_as_on_disk(string named)
{
var installation = new TestInstallation(new InMemoryFileSystem(caseSensitive: true)
.WithFile(TestInstallation.At("scenery", "Braniewo", "Opis.pdf"), "%PDF"));
Assert.Equal(TestInstallation.At("scenery", "Braniewo", "Opis.pdf"),
new AttachmentLocator(installation.Files, installation.Paths)
.Resolve(new SceneryAttachment { FilePath = named }));
}
[Theory]
[InlineData("scenery/brak.pdf")]
[InlineData("")]
public void An_attachment_that_is_not_there_is_not_found(string named) =>
Assert.Null(new AttachmentLocator(new InMemoryFileSystem(caseSensitive: true), new TestInstallation().Paths)
.Resolve(new SceneryAttachment { FilePath = named }));
}

View File

@@ -89,6 +89,23 @@ public class SceneryRepositoryTests
Assert.Equal(Path.Combine("game", "scenery", "images", "kato.jpg"), image);
}
[Theory]
[InlineData("calkowo_v2_kopalniaA.jpg", "game/scenery/images/calkowo_v2_kopalniaA.jpg")]
[InlineData(@"Images\Calkowo_V2_KopalniaA.JPG", "game/scenery/images/calkowo_v2_kopalniaA.jpg")]
[InlineData("CALKOWO_V2.JPG", "game/scenery/Calkowo_v2.jpg")]
public void The_preview_image_is_found_whatever_the_case_it_is_named_in(string named, string onDisk)
{
var files = new InMemoryFileSystem(caseSensitive: true)
.WithFile("game/scenery/calkowo.scn", "//$i " + named)
.WithFile("game/scenery/images/calkowo_v2_kopalniaA.jpg", "binary")
.WithFile("game/scenery/Calkowo_v2.jpg", "binary");
var scenery = Assert.Single(Build(files, out _).LoadAll());
string? image = new SceneryImageLocator(files).Resolve(scenery);
Assert.Equal(Path.GetFullPath(onDisk), Path.GetFullPath(image!));
}
[Fact]
public void A_scenery_without_an_image_directive_resolves_to_nothing()
{

View File

@@ -273,7 +273,7 @@ public class ScenerySyntaxTests
include koniec.scm
""";
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn, _ => new IncludeLookup(true));
var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn, new SceneryFileLookup(_ => true));
Assert.Empty(scenery.Faults);
Assert.Equal("td/rozklad", Assert.Single(scenery.Trainsets).Name);
@@ -326,7 +326,7 @@ public class ScenerySyntaxTests
endoptional
""";
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn, name => new IncludeLookup(name is "tory.scm" or "budynek.inc")).Faults;
var faults = TestScenery.Parser().Parse("scenery/x.scn", scn, new SceneryFileLookup(name => name is "tory.scm" or "budynek.inc")).Faults;
Assert.Equal((SceneryFaultKind.MissingInclude, "x.scn", 2, null, "brak.scm"), Seen(Assert.Single(faults)));
}
@@ -347,9 +347,9 @@ public class ScenerySyntaxTests
}
[Fact]
public void An_include_on_disk_only_in_other_letter_case_is_what_linux_misses()
public void An_include_on_disk_in_other_letter_case_is_no_fault()
{
// The simulator folds the name to lower case and opens it so.
// The simulator finds the file whatever the case of its letters, on Linux too.
var files = new InMemoryFileSystem(caseSensitive: true)
.WithLegacyFile("game/scenery/braniewo_szeroki.scn",
"include braniewo/base.ctr end\ninclude Braniewo/Tory.SCM end\n")
@@ -357,21 +357,20 @@ public class ScenerySyntaxTests
.WithFile("game/scenery/braniewo/tory.scm", "");
var repository = TestScenery.Repository(files);
var faults = repository.Load("game/scenery/braniewo_szeroki.scn")!.Faults;
Assert.Equal((SceneryFaultKind.IncludeLetterCase, "braniewo_szeroki.scn", 1, "Braniewo/Base.ctr", "braniewo/base.ctr"),
Seen(Assert.Single(faults)));
Assert.Empty(repository.Load("game/scenery/braniewo_szeroki.scn")!.Faults);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void A_missing_terrain_file_is_no_fault_while_the_scenery_has_its_own_sbt(bool sbt)
[InlineData(null)]
[InlineData("lk2_sandbox.sbt")]
[InlineData("Lk2_Sandbox.SBT")]
public void A_missing_terrain_file_is_no_fault_while_the_scenery_has_its_own_sbt(string? sbtOnDisk)
{
bool sbt = sbtOnDisk is not null;
var files = new InMemoryFileSystem(caseSensitive: true)
.WithLegacyFile("game/scenery/Lk2_Sandbox.scn", "include nmt/nmt100_mazowieckie_ter.scm end\nFirstInit\n");
if (sbt)
files.WithFile("game/scenery/lk2_sandbox.sbt", "EU07");
files.WithFile("game/scenery/" + sbtOnDisk, "EU07");
var repository = TestScenery.Repository(files);
var scenery = repository.Load("game/scenery/Lk2_Sandbox.scn")!;
@@ -379,4 +378,58 @@ public class ScenerySyntaxTests
Assert.Equal(sbt, scenery.HasCompanionTimetable);
Assert.Equal(sbt ? 0 : 1, scenery.Faults.Count);
}
private static IReadOnlyList<SceneryFault> TerrainFaults(string scn, bool sbt, params string[] onDisk)
{
var files = new InMemoryFileSystem(caseSensitive: true).WithLegacyFile("game/scenery/Mapa.scn", scn);
if (sbt)
files.WithFile("game/scenery/mapa.sbt", "EU07");
foreach (string file in onDisk)
files.WithFile("game/scenery/" + file, "");
return TestScenery.Repository(files).Load("game/scenery/Mapa.scn")!.Faults;
}
[Theory]
[InlineData("Teren/Mapa.txtf")]
[InlineData("teren/mapa.btf")]
[InlineData("TEREN/MAPA.BTF")]
public void A_terrain_file_found_as_text_or_binary_is_no_fault(string onDisk) =>
Assert.Empty(TerrainFaults("terrain teren/mapa.txtf endterrain\nFirstInit\n", sbt: false, onDisk));
[Fact]
public void A_terrain_file_missing_both_ways_is_reported_but_an_sbt_named_with_it_is_not()
{
var faults = TerrainFaults("// teren\nterrain teren/mapa.btf teren/stara.sbt\nendterrain\nFirstInit\n", sbt: false);
Assert.Equal((SceneryFaultKind.MissingTerrainFile, "Mapa.scn", 2, null, "teren/mapa.btf"),
Seen(Assert.Single(faults)));
}
[Fact]
public void A_terrain_file_takes_the_place_of_the_sbt_so_terrain_includes_are_read_after_all()
{
var faults = TerrainFaults("terrain teren/mapa.txtf endterrain\ninclude nmt/brak_ter.scm end\nFirstInit\n",
sbt: true, "teren/mapa.txtf");
Assert.Equal((SceneryFaultKind.MissingInclude, "Mapa.scn", 2, null, "nmt/brak_ter.scm"),
Seen(Assert.Single(faults)));
}
[Theory]
[InlineData("include nmt/brak_ter.scm end\nterrain teren/mapa.txtf endterrain\nFirstInit\n", 2)]
[InlineData("node -1 0 t1 triangles trawa\n0 0 0 0 0 0 0 0 end\nendtri\nterrain teren/mapa.txtf endterrain\nFirstInit\n", 4)]
[InlineData("node -1 -1 t1 model 0 0 0 0 teren.t3d none endmodel\nterrain teren/mapa.txtf endterrain\nFirstInit\n", 2)]
[InlineData("FirstInit\nterrain teren/mapa.txtf endterrain\n", 2)]
public void A_terrain_file_behind_what_the_sbt_already_gave_is_passed_over(string scn, int line)
{
var faults = TerrainFaults(scn, sbt: true, "teren/mapa.txtf");
Assert.Equal((SceneryFaultKind.TerrainFileBehindSbt, "Mapa.scn", line, null, "teren/mapa.txtf"),
Seen(Assert.Single(faults)));
}
[Fact]
public void A_model_not_of_the_terrain_leaves_the_sbt_free_to_give_way() =>
Assert.Empty(TerrainFaults("node 500 0 m1 model 0 0 0 0 dom.t3d none endmodel\nterrain teren/mapa.txtf endterrain\nFirstInit\n",
sbt: true, "teren/mapa.txtf"));
}

View File

@@ -39,6 +39,24 @@ public class SceneryTranslationsTests
Assert.Equal("Polski", scenery.LocalizedDescription(translations));
}
[Fact]
public void The_tables_are_found_whatever_the_case_of_their_names()
{
var files = new InMemoryFileSystem(caseSensitive: true)
.WithFile(TestInstallation.At("scenery", "Braniewo.scn"), "//$d @brief")
.WithFile(Path.Combine(Path.GetFullPath(TestInstallation.At("scenery")), "I18N", "braniewo_en.json"),
"""{ "brief": "English" }""")
.WithFile(Path.Combine(Path.GetFullPath(TestInstallation.At("scenery")), "I18N", "BRANIEWO_PL.json"),
"""{ "brief": "Polski" }""");
var installation = new TestInstallation(files);
var scenery = Assert.Single(installation.Sceneries.LoadAll());
var translations = new SceneryTranslations(files, installation.Log);
translations.LoadFor(scenery, "Polski");
Assert.Equal("Polski", scenery.LocalizedDescription(translations));
}
[Fact]
public void Literal_text_and_unknown_keys_are_left_alone()
{

View File

@@ -130,6 +130,17 @@ public class TapeLaunchTests
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""), name));
[Fact]
public void The_train_number_comes_from_a_timetable_named_in_other_case_with_backslashes()
{
var files = new InMemoryFileSystem(caseSensitive: true)
.WithFile(TestInstallation.At("scenery", "Baltyk", "EIE1531.txt"), "[ Rodzaj i numer pociagu | EIE1531 ]\r\n");
Assert.Equal("EIE1531", new StarterNG.Infrastructure.Sceneries.TimetableLocator(files)
.TrainNumber(new StarterNG.Domain.Sceneries.Scenery(TestInstallation.At("scenery", "td.scn"), ""),
@"baltyk\eie1531"));
}
[Fact]
public void A_timetable_that_is_not_there_gives_no_train_number() =>
Assert.Null(new StarterNG.Infrastructure.Sceneries.TimetableLocator(new InMemoryFileSystem())

View File

@@ -0,0 +1,22 @@
using StarterNG.Domain.Vehicles;
using StarterNG.Infrastructure.Vehicles;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class TextureFolderTests
{
private static readonly TestInstallation Installation = new(new InMemoryFileSystem(caseSensitive: true)
.WithFile("game/dynamic/PKP/EP09_v1/textures.txt", "")
.WithDirectory("game/Other/Folder"));
[Theory]
[InlineData("pkp/ep09_v1/", "dynamic/PKP/EP09_v1")]
[InlineData(@"PKP\EP09_V1", "dynamic/PKP/EP09_v1")]
[InlineData("other/folder/", "Other/Folder")]
[InlineData("pkp/brak/", "pkp/brak")]
public void A_liverys_folder_is_found_as_it_is_named_on_disk(string named, string onDisk) =>
Assert.Equal(Path.GetFullPath(Path.Combine("game", onDisk)),
Path.GetFullPath(TextureFolder.Of(Installation.Files, Installation.Paths,
new VehicleTexture { Directory = named })));
}

View File

@@ -13,6 +13,6 @@ public static class AppInfo
#if DEBUG
text += " (DEBUG)";
#endif
return text + " public beta";
return text;
}
}

View File

@@ -7,6 +7,9 @@ namespace StarterNG.Application.Abstractions;
public interface IFileSystem
{
/// <summary>Whether a name finds its file whatever the case of its letters, as on Windows and macOS.</summary>
bool IgnoresCase { get; }
bool FileExists(string path);
bool DirectoryExists(string path);

View File

@@ -56,6 +56,7 @@ public sealed class AppServices
var missingAssets = new MissingAssetScanner(files, paths);
SceneryTexts = new SceneryTranslations(files, log);
Timetables = new TimetableLocator(files);
Attachments = new AttachmentLocator(files, paths);
LoadWeights = new LoadWeightsRepository(files, paths, log);
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
State = new AppState();
@@ -119,6 +120,8 @@ public sealed class AppServices
public TimetableLocator Timetables { get; }
public AttachmentLocator Attachments { get; }
public LoadWeightsRepository LoadWeights { get; }
public GameLibrary Library { get; }

View File

@@ -62,11 +62,17 @@ public enum SceneryFaultKind
MissingInclude,
/// <summary>
/// An include whose file is there only under a name in other letter case: the
/// simulator looks for it in lower case, which on Linux misses it. The
/// <see cref="SceneryFault.Subject"/> holds the name on disk.
/// A terrain file named by a terrain directive whose text (.txtf) and binary
/// file (.btf) are both missing; <see cref="SceneryFault.Token"/> names it.
/// </summary>
IncludeLetterCase,
MissingTerrainFile,
/// <summary>
/// A terrain file the simulator passes over: the scenery's terrain comes from its
/// .sbt, which something before the directive was already taken from;
/// <see cref="SceneryFault.Token"/> names it.
/// </summary>
TerrainFileBehindSbt,
/// <summary>An include with no <c>end</c>, which swallows what follows it.</summary>
IncludeNotClosed,

View File

@@ -5,11 +5,13 @@ using System.Linq;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// What the simulator makes of an include: whether it opens the file - and when
/// it does not, the file that is there under a name differing only in letter case.
/// </summary>
public readonly record struct IncludeLookup(bool Opens, string? OnDisk = null);
/// <summary>What the simulator finds of the files a scenery names.</summary>
/// <param name="Opens">Whether it opens a file of the scenery folder, named as the scenery names it.</param>
/// <param name="HasSbt">
/// Whether the scenery has its own .sbt: while that is in use, its terrain includes
/// (<c>_ter.scm</c>) and terrain geometry are passed over, the .sbt holding them.
/// </param>
public sealed record SceneryFileLookup(Func<string, bool> Opens, bool HasSbt = false);
/// <summary>
/// Goes through a scenery's text for the parts the starter reads - trainsets,
@@ -35,17 +37,17 @@ public static class ScenerySyntax
/// <param name="text">The scenery's text, its includes put in place.</param>
/// <param name="file">The scenery's file name, for faults in its own text.</param>
/// <param name="findInclude">
/// What the simulator makes of an include, named as the include names it; null
/// leaves includes unchecked.
/// <param name="files">
/// What the simulator finds of the files the scenery names - includes and terrain
/// files; null leaves them unchecked.
/// </param>
public static Result Scan(string text, string file, Func<string, IncludeLookup>? findInclude = null)
public static Result Scan(string text, string file, SceneryFileLookup? files = null)
{
string code = SceneryCode.Mask(text, out int unclosed);
var tokens = SceneryCode.Tokens(code);
var lines = new SceneryLines(text, file);
var simulator = new Reading(tokens, lines, findInclude, asSimulator: true);
var simulator = new Reading(tokens, lines, files, asSimulator: true);
simulator.Run();
var author = new Reading(tokens, lines, null, asSimulator: false);
author.Run();
@@ -116,8 +118,14 @@ public static class ScenerySyntax
}
private sealed class Reading(
List<SceneryCode.Token> tokens, SceneryLines lines, Func<string, IncludeLookup>? findInclude, bool asSimulator)
List<SceneryCode.Token> tokens, SceneryLines lines, SceneryFileLookup? files, bool asSimulator)
{
// Whether the scenery's .sbt is in use, and whether the simulator passed over
// something of the text on its account already - or got past FirstInit - after
// which a terrain file can no longer take its place.
private bool _sbt = files?.HasSbt ?? false;
private bool _sbtTaken;
public readonly List<(int Start, int End, string Name)> Trainsets = new();
public readonly List<(int, int)> Loose = new();
public readonly List<SceneryFault> Faults = new();
@@ -162,12 +170,20 @@ public static class ScenerySyntax
{
// Not a vehicle: only its kind is stepped over, what follows is its own.
if (i + 4 >= tokens.Count || !tokens[i + 4].Is("dynamic"))
{
if (_sbt && TerrainGeometry(i))
_sbtTaken = true;
i += 4;
}
else
i = Vehicle(i);
}
else if (token.Is("include"))
i = Include(i);
else if (token.Is("terrain") && asSimulator)
i = Terrain(i);
else if (token.Is("firstinit"))
_sbtTaken |= _sbt;
}
if (_open is { } last)
@@ -375,11 +391,11 @@ public static class ScenerySyntax
return stop < tokens.Count && tokens[stop].Is("end") ? stop : stop - 1;
}
if (findInclude is not null && Plain(name) && findInclude(name) is { Opens: false } lookup)
if (files is not null && Plain(name))
{
if (lookup.OnDisk is { } onDisk)
Fault(SceneryFaultKind.IncludeLetterCase, token.Index, onDisk, name);
else
if (_sbt && SimulatorNames.Lower(name).Contains("_ter.scm"))
_sbtTaken = true;
else if (!files.Opens(name))
Fault(SceneryFaultKind.MissingInclude, token.Index, token: name);
}
@@ -394,6 +410,52 @@ public static class ScenerySyntax
return last;
}
/// <summary>
/// A terrain directive: the files it names up to <c>endterrain</c>. Of a terrain
/// file (.txtf, .btf) the simulator takes the text or the binary file of the same
/// name, whichever is there; an .sbt it passes over quietly when it cannot use it.
/// </summary>
private int Terrain(int at)
{
int end = at + 1;
while (end < tokens.Count && !tokens[end].Is("endterrain"))
end++;
for (int named = at + 1; named < end && files is not null; named++)
{
string name = tokens[named].Text;
string lower = SimulatorNames.Lower(name);
if (!Plain(name) || !(lower.EndsWith(".txtf") || lower.EndsWith(".btf")))
continue;
string stem = name[..name.LastIndexOf('.')];
if (!files.Opens(stem + ".txtf") && !files.Opens(stem + ".btf"))
Fault(SceneryFaultKind.MissingTerrainFile, tokens[named].Index, token: name);
else if (_sbtTaken)
Fault(SceneryFaultKind.TerrainFileBehindSbt, tokens[named].Index, token: name);
else
// The .sbt gives way: the terrain includes from here on are read after all.
_sbt = false;
}
return Math.Min(end, tokens.Count - 1);
}
/// <summary>
/// Whether the node starting at the given word is terrain geometry, which an .sbt
/// in use holds in its place: triangles or lines, or a model shown from any distance.
/// </summary>
private bool TerrainGeometry(int at)
{
if (at + 4 >= tokens.Count)
return false;
var kind = tokens[at + 4];
if (kind.Is("model"))
return double.TryParse(tokens[at + 2].Text, NumberStyles.Float, CultureInfo.InvariantCulture,
out double nearest) && nearest < 0;
return kind.Is("triangles") || kind.Is("triangle_strip") || kind.Is("triangle_fan") ||
kind.Is("lines") || kind.Is("line_strip") || kind.Is("line_loop");
}
// A vehicle with no trainset around it, written as within one - offset, crew,
// coupling - where its track, offset and crew belong.
private bool TrainsetStyle(int at) =>

View File

@@ -10,6 +10,9 @@ public sealed class PhysicalFileSystem : IFileSystem
{
public const string TempSuffix = ".starter.tmp";
// Their disks as they come; a Linux one tells case apart.
public bool IgnoresCase { get; } = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS();
public bool FileExists(string path) => File.Exists(path);
public bool DirectoryExists(string path) => Directory.Exists(path);
@@ -88,8 +91,18 @@ public sealed class PhysicalFileSystem : IFileSystem
public void CopyFile(string sourcePath, string destinationPath, bool overwrite) =>
File.Copy(sourcePath, destinationPath, overwrite);
// The pattern matches names in any letter case, as on Windows: the game's data,
// made there, has X.SCN as well as x.scn.
public IReadOnlyList<string> GetFiles(string path, string searchPattern = "*") =>
Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty<string>();
Directory.Exists(path)
? Directory.GetFiles(path, searchPattern, new EnumerationOptions
{
IgnoreInaccessible = false,
AttributesToSkip = 0,
MatchType = MatchType.Win32,
MatchCasing = MatchCasing.CaseInsensitive
})
: Array.Empty<string>();
// A folder it may not enter costs what is in it, not the whole listing.
public IReadOnlyList<string> GetFilesRecursive(string path, string searchPattern) =>
@@ -99,7 +112,8 @@ public sealed class PhysicalFileSystem : IFileSystem
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = 0,
MatchType = MatchType.Win32
MatchType = MatchType.Win32,
MatchCasing = MatchCasing.CaseInsensitive
})
: Array.Empty<string>();

View File

@@ -0,0 +1,132 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using StarterNG.Application.Abstractions;
namespace StarterNG.Infrastructure;
/// <summary>
/// Finds a file or folder of the game's data by a name whose letters may stand in
/// another case than on disk, as Windows, Wine and the simulator find it, and gives
/// the name as the disk has it. The data is made on Windows: its names and the
/// names written in it seldom agree letter for letter, which on Linux matters.
/// </summary>
public sealed class CaseInsensitivePaths
{
private readonly IFileSystem _files;
private readonly Func<(string Folder, bool Files), ILookup<string, string>> _list;
/// <summary>Each folder's files and folders by name, listed once until <see cref="Forget"/>; null lists every time.</summary>
private readonly ConcurrentDictionary<(string Folder, bool Files), ILookup<string, string>>? _listings;
/// <param name="keepListings">
/// Lists each folder once, for many names looked up in data that stays as it is
/// meanwhile, until <see cref="Forget"/>.
/// </param>
public CaseInsensitivePaths(IFileSystem files, bool keepListings = false)
{
_files = files;
_list = List;
if (keepListings)
_listings = new ConcurrentDictionary<(string, bool), ILookup<string, string>>();
}
/// <summary>Lets the next lookups list the folders again.</summary>
public void Forget() => _listings?.Clear();
/// <summary>
/// The file named so under <paramref name="folder"/> as it is named on disk, relative
/// to the folder with forward slashes; null when nothing there answers to it. A name
/// written with its letters as on disk wins over one differing only in their case;
/// a disk that does not tell case apart gives it back as written.
/// </summary>
/// <param name="name">
/// A name under the folder, with either slash, joined to it as the simulator joins
/// it: a slash ahead of it counts for nothing.
/// </param>
/// <param name="directory">Looks for a folder instead of a file.</param>
public string? Resolve(string folder, string name, bool directory = false)
{
string[] parts = name.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return null;
string exact = string.Join('/', parts);
string whole = Path.Combine(folder, exact);
if (directory ? _files.DirectoryExists(whole) : _files.FileExists(whole))
return exact;
// Where case does not matter, missed as written is missed in any case.
if (_files.IgnoresCase)
return null;
var found = new string[parts.Length];
return Walk(folder, parts, 0, directory, found) ? string.Join('/', found) : null;
}
/// <summary>
/// The full path of the file named so, its letters as on disk; null when there is
/// none. A name given in full is looked up from its own root, any other under
/// <paramref name="folder"/> as <see cref="Resolve"/> does.
/// </summary>
public string? Find(string folder, string name, bool directory = false)
{
if (Path.IsPathFullyQualified(name))
{
folder = Path.GetPathRoot(name)!;
name = name[folder.Length..];
}
return Resolve(folder, name, directory) is { } found
? Path.Combine(folder, found.Replace('/', Path.DirectorySeparatorChar))
: null;
}
/// <summary>
/// Finds the parts from <paramref name="at"/> on under <paramref name="current"/>. Of
/// folders named alike in other case - two copies of the data on Linux - each is
/// tried, the one written as on disk first.
/// </summary>
private bool Walk(string current, string[] parts, int at, bool directory, string[] found)
{
if (at == parts.Length)
return true;
string part = parts[at];
if (part is "." or "..")
{
found[at] = part;
return Walk(Path.Combine(current, part), parts, at + 1, directory, found);
}
bool last = at == parts.Length - 1;
var matches = Listing(current, files: last && !directory)[part]
.OrderBy(entry => string.Equals(Path.GetFileName(entry), part, StringComparison.Ordinal) ? 0 : 1);
foreach (string match in matches)
{
found[at] = Path.GetFileName(match);
if (Walk(match, parts, at + 1, directory, found))
return true;
}
return false;
}
private ILookup<string, string> Listing(string folder, bool files) =>
_listings is null ? List((folder, files)) : _listings.GetOrAdd((folder, files), _list);
// A folder that cannot be listed holds nothing to be found.
private ILookup<string, string> List((string Folder, bool Files) key)
{
var entries = Array.Empty<string>();
try
{
if (_files.DirectoryExists(key.Folder))
entries = (key.Files ? _files.GetFiles(key.Folder) : _files.GetDirectories(key.Folder)).ToArray();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Nothing listed, nothing found.
}
return entries.ToLookup(entry => Path.GetFileName(entry), StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -5,16 +5,21 @@ using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using StarterNG.Application.Abstractions;
using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Infrastructure;
public static class LoadingScreen
{
private static readonly string[] Extensions = { ".bmp", ".jpg", ".jpeg", ".png" };
public static void Prepare(string? logoKey, string? sceneryName = null)
{
try
{
string? src = ResolveSource(logoKey) ?? ResolveSource(sceneryName) ?? PickRandom();
string? src = FindSource(new PhysicalFileSystem(), Directory.GetCurrentDirectory(), logoKey, sceneryName,
Random.Shared);
if (src is null) return;
string destDir = Path.Combine(Directory.GetCurrentDirectory(), "textures");
@@ -35,32 +40,36 @@ public static class LoadingScreen
}
}
private static string? ResolveSource(string? key)
/// <summary>
/// The picture of textures/logo under <paramref name="root"/> to show: the trainset's,
/// else the scenery's, else one of logo*.* at random; its name and extension in any
/// letter case. Null when there is none.
/// </summary>
public static string? FindSource(IFileSystem files, string root, string? logoKey, string? sceneryName,
Random random)
{
var names = new CaseInsensitivePaths(files);
if (names.Find(root, "textures/logo", directory: true) is not { } dir)
return null;
return Named(names, dir, logoKey) ?? Named(names, dir, sceneryName) ?? PickRandom(files, dir, random);
}
private static string? Named(CaseInsensitivePaths names, string dir, string? key)
{
if (string.IsNullOrWhiteSpace(key)) return null;
key = Path.GetFileNameWithoutExtension(key.Trim());
string dir = Path.Combine(Directory.GetCurrentDirectory(), "textures", "logo");
foreach (string ext in new[] { ".bmp", ".jpg", ".jpeg", ".png" })
{
string path = Path.Combine(dir, key + ext);
if (File.Exists(path)) return path;
}
return null;
if (key.Length == 0) return null;
return Extensions.Select(ext => names.Find(dir, key + ext)).FirstOrDefault(path => path is not null);
}
private static string? PickRandom()
private static string? PickRandom(IFileSystem files, string dir, Random random)
{
string dir = Path.Combine(Directory.GetCurrentDirectory(), "textures", "logo");
if (!Directory.Exists(dir)) return null;
var files = Directory.GetFiles(dir, "logo*.*")
.Where(f =>
{
string e = Path.GetExtension(f).ToLowerInvariant();
return e is ".bmp" or ".jpg" or ".jpeg" or ".png";
})
var logos = files.GetFiles(dir, "logo*.*")
.Where(f => Extensions.Contains(Path.GetExtension(f), StringComparer.OrdinalIgnoreCase))
.Order(StringComparer.Ordinal)
.ToArray();
if (files.Length == 0) return null;
return files[Random.Shared.Next(files.Length)];
if (logos.Length == 0) return null;
return logos[random.Next(logos.Length)];
}
private static void WriteBmp(string sourcePath, string destPath)

View File

@@ -0,0 +1,24 @@
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Infrastructure.Sceneries;
/// <summary>Where the file a scenery attaches (<c>//$f</c>) is on disk.</summary>
public sealed class AttachmentLocator
{
private readonly IGamePaths _paths;
private readonly CaseInsensitivePaths _names;
public AttachmentLocator(IFileSystem files, IGamePaths paths)
{
_paths = paths;
_names = new CaseInsensitivePaths(files);
}
/// <summary>
/// The attachment's file, named from the game's folder or in full, with its letters
/// as on disk; null when it is not there.
/// </summary>
public string? Resolve(SceneryAttachment attachment) =>
string.IsNullOrWhiteSpace(attachment.FilePath) ? null : _names.Find(_paths.Root, attachment.FilePath.Trim());
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Sceneries;
@@ -8,11 +9,13 @@ namespace StarterNG.Infrastructure.Sceneries;
public sealed class SceneryImageLocator
{
private readonly IFileSystem _files;
private readonly CaseInsensitivePaths _names;
private readonly Dictionary<string, string?> _cache = new();
public SceneryImageLocator(IFileSystem files)
{
_files = files;
_names = new CaseInsensitivePaths(files);
}
public string? Resolve(Scenery scenery)
@@ -34,22 +37,23 @@ public sealed class SceneryImageLocator
return null;
string name = scenery.ImageName.Replace('\\', '/').Trim();
if (Path.IsPathFullyQualified(name))
return _names.Find("", name);
if (_files.FileExists(name))
return name;
string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? ".";
string root = Path.GetDirectoryName(sceneryDir) ?? ".";
string[] candidates =
(string Folder, string Name)[] candidates =
{
name,
Path.Combine(root, name),
Path.Combine(sceneryDir, name),
Path.Combine(sceneryDir, "images", name),
Path.Combine(root, "scenery", "images", name)
(root, name),
(sceneryDir, name),
(sceneryDir, "images/" + name),
(root, "scenery/images/" + name)
};
foreach (string candidate in candidates)
if (_files.FileExists(candidate))
return candidate;
return null;
return candidates.Select(candidate => _names.Find(candidate.Folder, candidate.Name))
.FirstOrDefault(found => found is not null);
}
}

View File

@@ -20,11 +20,11 @@ public sealed class SceneryParser
_random = random;
}
/// <param name="findInclude">
/// What the simulator makes of an include, as the include names it; null leaves
/// includes unchecked.
/// <param name="files">
/// What the simulator finds of the files the scenery names; null leaves them
/// unchecked.
/// </param>
public Scenery Parse(string path, string content, Func<string, IncludeLookup>? findInclude = null)
public Scenery Parse(string path, string content, SceneryFileLookup? files = null)
{
string original = content;
@@ -38,7 +38,7 @@ public sealed class SceneryParser
// Read from the text with its optional includes blanked, which keeps every
// place where it was, so a fault's line is the line in the file.
var syntax = ScenerySyntax.Scan(content, System.IO.Path.GetFileName(path), findInclude);
var syntax = ScenerySyntax.Scan(content, System.IO.Path.GetFileName(path), files);
var faults = syntax.Faults.ToList();
var inlined = SceneryIncludes.Ranges(content);

View File

@@ -7,7 +7,6 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using StarterNG.Application.Abstractions;
using StarterNG.Domain;
using StarterNG.Domain.Sceneries;
using StarterNG.Infrastructure.Adapters;
@@ -23,7 +22,7 @@ public sealed class SceneryRepository : ISceneryRepository
private readonly SceneryParser _parser;
/// <summary>The scenery folders' files and folders, listed once per load of them all.</summary>
private readonly ConcurrentDictionary<(string, bool), IReadOnlyList<string>> _listings = new();
private readonly CaseInsensitivePaths _names;
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
{
@@ -31,11 +30,12 @@ public sealed class SceneryRepository : ISceneryRepository
_paths = paths;
_log = log;
_parser = parser;
_names = new CaseInsensitivePaths(files, keepListings: true);
}
public IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
{
_listings.Clear();
_names.Forget();
var files = SceneryFiles().ToList();
var loaded = new ConcurrentBag<Scenery>();
int total = Math.Max(1, files.Count);
@@ -58,7 +58,7 @@ public sealed class SceneryRepository : ISceneryRepository
public Scenery? Load(string path)
{
_listings.Clear();
_names.Forget();
return LoadOne(path);
}
@@ -77,12 +77,9 @@ public sealed class SceneryRepository : ISceneryRepository
var encodings = new Dictionary<string, Encoding>(StringComparer.Ordinal);
// With its own .sbt the scenery's terrain comes from it, and the simulator
// passes its terrain includes over without opening them.
// passes its terrain includes over without opening them - unless a terrain
// file takes its place.
bool binaryTerrain = Opens(Path.GetFileNameWithoutExtension(path) + ".sbt");
IncludeLookup FindInclude(string name) =>
Opens(name) || (binaryTerrain && SimulatorNames.Lower(name).Contains("_ter.scm"))
? new IncludeLookup(true)
: new IncludeLookup(false, ResolveInclude(name));
(string File, string Text)? ReadInclude(string name)
{
@@ -99,7 +96,7 @@ public sealed class SceneryRepository : ISceneryRepository
string own = LegacyText.Decode(_files.ReadAllBytes(path), out var ownEncoding);
string text = SceneryIncludes.Inline(own, ReadInclude);
var scenery = _parser.Parse(path, text, FindInclude);
var scenery = _parser.Parse(path, text, new SceneryFileLookup(Opens, binaryTerrain));
scenery.HasCompanionTimetable = binaryTerrain;
scenery.Sources = sources;
scenery.TextEncoding = ownEncoding;
@@ -114,11 +111,11 @@ public sealed class SceneryRepository : ISceneryRepository
}
/// <summary>
/// Whether the simulator opens a file of the scenery folder named so: by the
/// name in lower case, which on Linux has to be how it is named on disk.
/// Whether the simulator opens a file of the scenery folder named so: whatever the
/// case of its letters, on Linux too. A shade more lenient than eu07 there, which
/// folds only the letters A to Z and of two folders named alike takes the first.
/// </summary>
private bool Opens(string name) =>
_files.FileExists(Path.Combine(_paths.Scenery, SimulatorNames.Lower(name.Replace('\\', '/'))));
private bool Opens(string name) => ResolveInclude(name) is not null;
/// <summary>
/// An include's file as it is named on disk, from the scenery folder, whatever
@@ -126,44 +123,7 @@ public sealed class SceneryRepository : ISceneryRepository
/// copy written for a start carries in place of the include, so they come along
/// on Linux too.
/// </summary>
private string? ResolveInclude(string name)
{
string relative = name.Replace('\\', '/');
if (_files.FileExists(Path.Combine(_paths.Scenery, relative)))
return relative;
string[] parts = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
string directory = _paths.Scenery;
var found = new List<string>();
for (int i = 0; i < parts.Length; i++)
{
if (parts[i] is "." or "..")
{
directory = Path.Combine(directory, parts[i]);
found.Add(parts[i]);
continue;
}
bool last = i == parts.Length - 1;
string? match = Listing(directory, last)
.FirstOrDefault(entry => string.Equals(Path.GetFileName(entry), parts[i],
StringComparison.OrdinalIgnoreCase));
if (match is null)
return null;
directory = match;
found.Add(Path.GetFileName(match));
}
return string.Join('/', found);
}
private IReadOnlyList<string> Listing(string directory, bool files) =>
_listings.GetOrAdd((directory, files), key =>
{
if (!_files.DirectoryExists(key.Item1))
return Array.Empty<string>();
return key.Item2 ? _files.GetFiles(key.Item1) : _files.GetDirectories(key.Item1);
});
private string? ResolveInclude(string name) => _names.Resolve(_paths.Scenery, name);
private IEnumerable<string> SceneryFiles() =>
_files.GetFiles(_paths.Scenery, "*.scn")

View File

@@ -14,12 +14,14 @@ public sealed class SceneryTranslations : ISceneryTranslations
private const string BaseLanguage = "en";
private readonly IFileSystem _files;
private readonly CaseInsensitivePaths _names;
private readonly IDiagnosticsLog _log;
private readonly Dictionary<string, string> _map = new(StringComparer.OrdinalIgnoreCase);
public SceneryTranslations(IFileSystem files, IDiagnosticsLog log)
{
_files = files;
_names = new CaseInsensitivePaths(files);
_log = log;
}
@@ -29,12 +31,11 @@ public sealed class SceneryTranslations : ISceneryTranslations
string sceneryDir = Path.GetDirectoryName(Path.GetFullPath(scenery.Path)) ?? "scenery";
string id = Path.GetFileNameWithoutExtension(scenery.Path);
string i18nDir = Path.Combine(sceneryDir, "i18n");
string language = NormalizeLanguage(langCode);
TryLoad(Path.Combine(i18nDir, $"{id}_{BaseLanguage}.json"));
TryLoad(_names.Find(sceneryDir, $"i18n/{id}_{BaseLanguage}.json"));
if (!string.Equals(language, BaseLanguage, StringComparison.OrdinalIgnoreCase))
TryLoad(Path.Combine(i18nDir, $"{id}_{language}.json"));
TryLoad(_names.Find(sceneryDir, $"i18n/{id}_{language}.json"));
}
public string Translate(string? text)
@@ -50,9 +51,9 @@ public sealed class SceneryTranslations : ISceneryTranslations
return _map.TryGetValue(key, out string? translated) ? translated : text;
}
private void TryLoad(string path)
private void TryLoad(string? path)
{
if (!_files.FileExists(path))
if (path is null)
return;
try

View File

@@ -17,10 +17,12 @@ public sealed class TimetableLocator
RegexOptions.Multiline | RegexOptions.IgnoreCase);
private readonly IFileSystem _files;
private readonly CaseInsensitivePaths _names;
public TimetableLocator(IFileSystem files)
{
_files = files;
_names = new CaseInsensitivePaths(files);
}
public string? Resolve(Scenery scenery, string? name)
@@ -30,17 +32,19 @@ public sealed class TimetableLocator
string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? ".";
string root = Path.GetDirectoryName(sceneryDir) ?? ".";
name = name.Replace('\\', '/');
string[] candidates =
(string Folder, string Name)[] candidates =
{
Path.Combine(root, "timetables", name + ".txt"),
Path.Combine(sceneryDir, name + ".txt"),
Path.Combine(root, "scenario", name + ".txt"),
Path.Combine(root, "timetables", name),
Path.Combine(sceneryDir, name)
(root, "timetables/" + name + ".txt"),
(sceneryDir, name + ".txt"),
(root, "scenario/" + name + ".txt"),
(root, "timetables/" + name),
(sceneryDir, name)
};
return candidates.FirstOrDefault(_files.FileExists);
return candidates.Select(candidate => _names.Find(candidate.Folder, candidate.Name))
.FirstOrDefault(found => found is not null);
}
/// <summary>The number the timetable gives its train; null with no timetable or no such heading in it.</summary>

View File

@@ -23,6 +23,7 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
};
private readonly IFileSystem _files;
private readonly CaseInsensitivePaths _names;
private readonly IGamePaths _paths;
private readonly IDiagnosticsLog _log;
@@ -38,6 +39,7 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
public FizPhysicsRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log)
{
_files = files;
_names = new CaseInsensitivePaths(files);
_paths = paths;
_log = log;
}
@@ -51,7 +53,7 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
if (string.IsNullOrEmpty(model))
return null;
string name = Path.GetFileNameWithoutExtension(model);
string name = Path.GetFileName(model);
if (name.Length == 0)
return null;
@@ -60,7 +62,10 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
if (_cache.TryGetValue(key, out var cached))
return cached;
var physics = Read(folder, name);
// The name as written first: in 9-107.13 the dot is part of the name, not an extension.
string bare = Path.GetFileNameWithoutExtension(name);
var physics = Read(folder, name)
?? (bare.Length > 0 && bare != name ? Read(folder, bare) : null);
_cache[key] = physics;
return physics;
}
@@ -162,8 +167,9 @@ public sealed class FizPhysicsRepository : IPhysicsRepository
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);
// Named in the letter case of whoever wrote the include, not always that of the file.
string included = _names.Find(directory, includeFile) ?? Path.Combine(directory, includeFile);
Parse(physics, included, directory, includeParameters.ToArray(), depth + 1);
pendingKey = null;
continue;
}

View File

@@ -10,44 +10,53 @@ 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;
private readonly CaseInsensitivePaths _names;
public MissingAssetScanner(IFileSystem files, IGamePaths paths)
{
_files = files;
_paths = paths;
_names = new CaseInsensitivePaths(files, keepListings: true);
}
public List<string> Scan(VehicleCatalog catalog)
{
var lines = new List<string>();
foreach (var texture in catalog.Textures)
try
{
string directory = Path.Combine(
_paths.Dynamic,
texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
foreach (var texture in catalog.Textures)
{
string directory = texture.Directory.Replace('\\', '/').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.Skinfile) &&
!ExistsWithAnyExtension(Under(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}");
if (!string.IsNullOrEmpty(texture.Model) &&
!ExistsWithAnyExtension(Under(directory, texture.Model), ModelExtensions))
lines.Add($"# no model: {texture.Directory}{texture.Model}");
}
}
finally
{
// The folders are listed once for the whole catalog, and not kept past it.
_names.Forget();
}
return lines;
}
private bool ExistsWithAnyExtension(string path, string[] extensions)
private static string Under(string directory, string name) =>
directory.Length == 0 ? name : directory + "/" + name;
/// <summary>Whether the file is under dynamic as named, or with one of the extensions, in any letter case.</summary>
private bool ExistsWithAnyExtension(string name, string[] extensions)
{
if (_files.FileExists(path))
if (_names.Resolve(_paths.Dynamic, name) is not null)
return true;
foreach (string extension in extensions)
if (_files.FileExists(Path.ChangeExtension(path, extension)))
if (_names.Resolve(_paths.Dynamic, Path.ChangeExtension(name, extension)) is not null)
return true;
return false;

View File

@@ -0,0 +1,24 @@
using System.IO;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Vehicles;
namespace StarterNG.Infrastructure.Vehicles;
public static class TextureFolder
{
/// <summary>
/// A livery's folder as it is named on disk: under dynamic, or from the game's folder
/// when its name starts there, in any letter case. Where it would be when it is not there.
/// </summary>
public static string Of(IFileSystem files, IGamePaths paths, VehicleTexture texture)
{
string directory = texture.Directory.Replace('\\', '/').TrimEnd('/');
if (directory.Length == 0)
return paths.Dynamic;
var names = new CaseInsensitivePaths(files);
return names.Find(paths.Dynamic, directory, directory: true) ??
names.Find(paths.Root, directory, directory: true) ??
Path.Combine(paths.Root, directory.Replace('/', Path.DirectorySeparatorChar));
}
}

View File

@@ -46,6 +46,9 @@ public partial class MainWindow : Window
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
InitializeComponent();
// The top bar lets the version go first when it runs short of room, which at the usual widths it does;
// the title has room for it at any width.
Title = $"{Title} {AppInfo.Version}";
ExtendClientAreaToDecorationsHint = true;
ExtendClientAreaTitleBarHeightHint = TitleBarHeight;

View File

@@ -12,7 +12,7 @@
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- Major and minor only: CI adds the patch, the number of commits, so every
build of main is a higher version without anyone bumping it. -->
<VersionPrefix>1.5</VersionPrefix>
<VersionPrefix>1.6</VersionPrefix>
<AssemblyName>Starter</AssemblyName>
<Company>eu07.pl</Company>
</PropertyGroup>

View File

@@ -1,8 +1,10 @@
using System.IO;
using Avalonia.Controls;
using Avalonia.Input.Platform;
using StarterNG.Application;
using StarterNG.Domain;
using StarterNG.Domain.Vehicles;
using StarterNG.Infrastructure.Vehicles;
namespace StarterNG.Views;
@@ -17,13 +19,7 @@ internal static class TextureMenuItems
if (TopLevel.GetTopLevel(owner)?.Clipboard is { } clipboard)
await clipboard.SetTextAsync(Consist.Base(texture.Skinfile));
});
menu.AddItem(App.Loc["OpenTextureFolder"], () => Infrastructure.Diagnostics.OpenOrReport(FolderOf(texture)));
}
private static string FolderOf(VehicleTexture texture)
{
string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic",
texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
return Directory.Exists(dir) ? dir : Path.Combine(Directory.GetCurrentDirectory(), texture.Directory);
menu.AddItem(App.Loc["OpenTextureFolder"], () => Infrastructure.Diagnostics.OpenOrReport(
Path.GetFullPath(TextureFolder.Of(AppServices.Current.Files, AppServices.Current.Paths, texture))));
}
}

View File

@@ -492,20 +492,18 @@ public partial class Scenarios : UserControl
btn.Classes.Add("Flat");
ToolTip.SetTip(btn, att.FilePath);
// A button that does nothing is no way to say the file is not there.
btn.IsEnabled = File.Exists(Path.GetFullPath(att.FilePath));
btn.IsEnabled = AppServices.Current.Attachments.Resolve(att) is not null;
ToolTip.SetShowOnDisabled(btn, true);
btn.Click += (_, _) => OpenAttachment(att.FilePath);
btn.Click += (_, _) => OpenAttachment(att);
attachmentsPanel.Children.Add(btn);
}
}
private static void OpenAttachment(string relativePath)
private static void OpenAttachment(SceneryAttachment attachment)
{
string full = Path.GetFullPath(relativePath);
if (!File.Exists(full))
full = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), relativePath));
if (!File.Exists(full))
if (AppServices.Current.Attachments.Resolve(attachment) is not { } found)
return;
string full = Path.GetFullPath(found);
try
{
Process.Start(new ProcessStartInfo(full) { UseShellExecute = true });

View File

@@ -742,7 +742,8 @@
<String key="SyntaxVehicleOutsideTrainset">Vozidlo {0} stojí mimo soupravu: chybí záhlaví trainset?</String>
<String key="SyntaxNotANumber">{0}: „{1}“ místo čísla</String>
<String key="SyntaxMissingInclude">Soubor {1} nenalezen (include)</String>
<String key="SyntaxIncludeLetterCase">Soubor {1} je na disku jako {0}: linuxový eu07 ho nenačte</String>
<String key="SyntaxMissingTerrainFile">Soubor terénu {1} nenalezen, ani .txtf, ani .btf (terrain)</String>
<String key="SyntaxTerrainFileBehindSbt">Soubor terénu {1} ignorován: terén už pochází ze souboru .sbt scenérie</String>
<String key="SyntaxIncludeNotClosed">include {1} bez end</String>
<String key="SyntaxTrainsetUnreadable">Soupravu {0} se nepodařilo načíst</String>
<String key="SyntaxVehicleUnreadable">Vozidlo {0} se nepodařilo načíst</String>

View File

@@ -772,7 +772,8 @@
<String key="SyntaxVehicleOutsideTrainset">Vehicle {0} stands outside a trainset: header missing?</String>
<String key="SyntaxNotANumber">{0}: “{1}” instead of a number</String>
<String key="SyntaxMissingInclude">File {1} not found (include)</String>
<String key="SyntaxIncludeLetterCase">File {1} is on disk as {0}: the Linux eu07 will not load it</String>
<String key="SyntaxMissingTerrainFile">Terrain file {1} not found, neither .txtf nor .btf (terrain)</String>
<String key="SyntaxTerrainFileBehindSbt">Terrain file {1} ignored: the terrain already comes from the .sbt of the scenery</String>
<String key="SyntaxIncludeNotClosed">include {1} without end</String>
<String key="SyntaxTrainsetUnreadable">Trainset {0} could not be read</String>
<String key="SyntaxVehicleUnreadable">Vehicle {0} could not be read</String>

View File

@@ -742,7 +742,8 @@
<String key="SyntaxVehicleOutsideTrainset">A(z) {0} jármű szerelvényen kívül áll: hiányzik a trainset fejléc?</String>
<String key="SyntaxNotANumber">{0}: „{1}” szám helyett</String>
<String key="SyntaxMissingInclude">A(z) {1} fájl nem található (include)</String>
<String key="SyntaxIncludeLetterCase">A(z) {1} fájl a lemezen {0} néven van: a linuxos eu07 nem tölti be</String>
<String key="SyntaxMissingTerrainFile">A(z) {1} terepfájl nem található, sem .txtf, sem .btf (terrain)</String>
<String key="SyntaxTerrainFileBehindSbt">A(z) {1} terepfájl kimarad: a terep már a díszlet .sbt fájljából jön</String>
<String key="SyntaxIncludeNotClosed">include {1} end nélkül</String>
<String key="SyntaxTrainsetUnreadable">A(z) {0} szerelvényt nem sikerült beolvasni</String>
<String key="SyntaxVehicleUnreadable">A(z) {0} járművet nem sikerült beolvasni</String>

View File

@@ -769,7 +769,8 @@
<String key="SyntaxVehicleOutsideTrainset">Pojazd {0} stoi poza składem: brak nagłówka trainset?</String>
<String key="SyntaxNotANumber">{0}: „{1}” zamiast liczby</String>
<String key="SyntaxMissingInclude">Brak pliku {1} (include)</String>
<String key="SyntaxIncludeLetterCase">Plik {1} jest na dysku jako {0}: linuksowy eu07 go nie wczyta</String>
<String key="SyntaxMissingTerrainFile">Brak pliku terenu {1}, ani .txtf, ani .btf (terrain)</String>
<String key="SyntaxTerrainFileBehindSbt">Plik terenu {1} pominięty: teren pochodzi już z pliku .sbt scenerii</String>
<String key="SyntaxIncludeNotClosed">include {1} bez end</String>
<String key="SyntaxTrainsetUnreadable">Nie udało się odczytać składu {0}</String>
<String key="SyntaxVehicleUnreadable">Nie udało się odczytać pojazdu {0}</String>

View File

@@ -742,7 +742,8 @@
<String key="SyntaxVehicleOutsideTrainset">Транспорт {0} стоит вне состава: нет заголовка trainset?</String>
<String key="SyntaxNotANumber">{0}: «{1}» вместо числа</String>
<String key="SyntaxMissingInclude">Файл {1} не найден (include)</String>
<String key="SyntaxIncludeLetterCase">Файл {1} на диске назван {0}: eu07 на Linux его не загрузит</String>
<String key="SyntaxMissingTerrainFile">Файл рельефа {1} не найден, ни .txtf, ни .btf (terrain)</String>
<String key="SyntaxTerrainFileBehindSbt">Файл рельефа {1} пропущен: рельеф уже берётся из файла .sbt сценария</String>
<String key="SyntaxIncludeNotClosed">include {1} без end</String>
<String key="SyntaxTrainsetUnreadable">Не удалось прочитать состав {0}</String>
<String key="SyntaxVehicleUnreadable">Не удалось прочитать единицу {0}</String>